P2: completion contract, loop guard, context truncation

This commit is contained in:
王性驊 2026-09-13 15:51:13 +08:00
parent 447498df5b
commit 815b840f30
6 changed files with 665 additions and 60 deletions

View File

@ -1,6 +1,6 @@
# GrokBoy # GrokBoy
Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions. Minimal local **GrokBot-like** CLI agent. Phase **P2**: completion contract, loop guard, context truncation.
## Status ## Status
@ -8,7 +8,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions
|-------|--------| |-------|--------|
| P0 streaming chat | done | | P0 streaming chat | done |
| P1 shell / files + ReAct | done | | P1 shell / files + ReAct | done |
| P2 completion / loop guard | planned | | P2 completion / loop guard / truncation | done |
| P3 browser (Playwright) | later | | P3 browser (Playwright) | later |
No Docker desktop, no Codex/LazyBoy fork. No Docker desktop, no Codex/LazyBoy fork.
@ -20,6 +20,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY
# optional: # optional:
# export GROKBOY_BASE_URL=https://api.x.ai/v1 # export GROKBOY_BASE_URL=https://api.x.ai/v1
# export GROKBOY_MODEL=grok-4.6 # export GROKBOY_MODEL=grok-4.6
# export GROKBOY_CONTEXT_CHARS=100000
cd ~/GrokBoy cd ~/GrokBoy
cargo run -p grokboy -- chat cargo run -p grokboy -- chat
@ -28,16 +29,20 @@ cargo run -p grokboy -- chat
## Commands ## Commands
- `grokboy chat` — interactive streaming chat (no tools) - `grokboy chat` — interactive streaming chat (no tools)
- `grokboy run "<prompt>"` — one-shot agent with tools (`shell`, `list_dir`, `read_file`, `write_file`) - `grokboy run "<prompt>"` — one-shot agent with tools
- `grokboy run --session <id> "<prompt>"` — continue a saved session - `grokboy run --session <id> "<prompt>"` — continue a saved session
- `grokboy smoke` — offline tool checks (no API key required) - `grokboy smoke` — offline checks (no API key required)
- `grokboy help` - `grokboy help`
Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`.
Sessions are stored under `~/.grokboy/sessions/<id>.json`. Sessions are stored under `~/.grokboy/sessions/<id>.json`.
The agent stops on `report_done` / `report_blocked`, blocks identical tool rounds (×3), and truncates old context when over budget.
## 繁體中文 ## 繁體中文
本機終端機 coding assistant。P1 已支援讀寫檔、列目錄、shell以及多輪 tool-calling 本機終端機 coding assistant。P2 已支援完成合約(`report_done` / `report_blocked`)、迴圈守衛與上下文截斷
```bash ```bash
export GROKBOY_API_KEY=你的金鑰 export GROKBOY_API_KEY=你的金鑰

View File

@ -1,63 +1,317 @@
//! Multi-step OpenAI-compatible tool-calling ReAct loop. //! Multi-step OpenAI-compatible tool-calling ReAct loop (P2: completion, loop guard, truncation).
use crate::config::Config; use crate::config::Config;
use crate::model::{ChatMessage, chat_completion}; use crate::model::{ChatMessage, Role, ToolCall, chat_completion};
use crate::tools::{ToolContext, execute_tool, tool_definitions}; use crate::tools::{ToolContext, execute_tool, is_completion_tool, tool_definitions};
use anyhow::Result; use anyhow::Result;
use serde_json::Value;
use std::future::Future;
pub const DEFAULT_MAX_ROUNDS: usize = 12; pub const DEFAULT_MAX_ROUNDS: usize = 12;
pub const DEFAULT_CONTEXT_CHARS: usize = 100_000;
pub const LOOP_GUARD_REPEAT: usize = 3;
pub const AGENT_SYSTEM: &str = "\ pub const AGENT_SYSTEM: &str = "\
You are GrokBoy, a concise local coding assistant with tools. You are GrokBoy, a concise local coding assistant with tools.
Use tools when they help solve the task; otherwise answer directly. Use tools when they help solve the task; otherwise answer directly.
Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese. Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese.
Available tools: shell, list_dir, read_file, write_file. Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked.
Do not invent tool results call the tools. Stop when you can give a final answer."; When the task is finished, call report_done with a short summary.
If you are stuck or cannot proceed, call report_blocked with the reason do not invent results or loop.
Do not invent tool results call the tools.";
/// Run the agent loop until the model returns text without tool_calls or max rounds. /// Final verdict from the agent loop (fail-closed when stuck).
/// Appends all intermediate messages (assistant tool_calls + tool results + final) to `messages`. #[derive(Debug, Clone, PartialEq, Eq)]
/// Returns the final assistant text (may be empty if stopped on round limit with only tools). pub enum AgentVerdict {
/// Model called `report_done`.
Done(String),
/// Model called `report_blocked`, loop guard, or max rounds.
Blocked(String),
/// Model returned final text without a completion tool.
Answer(String),
}
impl AgentVerdict {
pub fn message(&self) -> &str {
match self {
Self::Done(s) | Self::Blocked(s) | Self::Answer(s) => s,
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Done(_) => "done",
Self::Blocked(_) => "blocked",
Self::Answer(_) => "answer",
}
}
}
/// Resolve context budget from `GROKBOY_CONTEXT_CHARS` or default.
pub fn context_char_budget() -> usize {
std::env::var("GROKBOY_CONTEXT_CHARS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_CONTEXT_CHARS)
}
/// Stable signature for a single tool call (name + args).
pub fn tool_call_signature(call: &ToolCall) -> String {
format!("{}:{}", call.function.name, call.function.arguments)
}
/// Signature for a whole round of tool calls (order-preserving).
pub fn round_signature(calls: &[ToolCall]) -> String {
calls
.iter()
.map(tool_call_signature)
.collect::<Vec<_>>()
.join("\n")
}
/// Approximate serialized size of one message (chars).
pub fn message_char_len(msg: &ChatMessage) -> usize {
let mut n = 8; // role overhead
if let Some(c) = &msg.content {
n += c.len();
}
if let Some(id) = &msg.tool_call_id {
n += id.len();
}
if let Some(calls) = &msg.tool_calls {
for c in calls {
n += c.id.len() + c.function.name.len() + c.function.arguments.len() + 16;
}
}
n
}
pub fn messages_char_len(messages: &[ChatMessage]) -> usize {
messages.iter().map(message_char_len).sum()
}
/// Truncate older tool results / middle turns while keeping system + recent tail + last user goal.
/// Mutates in place. Keeps it simple: shrink old tool contents first, then drop middle messages.
pub fn truncate_messages(messages: &mut Vec<ChatMessage>, budget: usize) {
if budget == 0 || messages_char_len(messages) <= budget {
return;
}
// 1) Shrink older tool message contents (keep last few tool results intact).
let tool_indices: Vec<usize> = messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::Tool)
.map(|(i, _)| i)
.collect();
let keep_recent_tools = 4usize;
let shrink_until = tool_indices.len().saturating_sub(keep_recent_tools);
for &idx in tool_indices.iter().take(shrink_until) {
if let Some(content) = messages[idx].content.as_mut() {
if content.len() > 120 {
let omitted = content.len();
*content = format!("[truncated tool result; was {omitted} chars]");
}
}
if messages_char_len(messages) <= budget {
return;
}
}
// 2) Drop middle messages while preserving: leading system*, last user, and a recent tail.
while messages_char_len(messages) > budget && messages.len() > 4 {
let first_drop = messages
.iter()
.enumerate()
.find(|(i, m)| *i > 0 && m.role != Role::System)
.map(|(i, _)| i);
let Some(i) = first_drop else { break };
// Never drop the last user message or the last two messages.
let last_user = messages
.iter()
.rposition(|m| m.role == Role::User)
.unwrap_or(messages.len());
if i >= last_user || i + 2 >= messages.len() {
// Shrink remaining large contents instead.
for msg in messages.iter_mut() {
if let Some(content) = msg.content.as_mut() {
if content.len() > 200 {
let keep = 200.min(content.len());
let omitted = content.len().saturating_sub(keep);
*content = format!("{}\n[truncated {omitted} chars]", &content[..keep]);
}
}
}
break;
}
// If dropping an assistant with tool_calls, also drop following tool messages for those ids.
let drop_ids: Vec<String> = messages
.get(i)
.and_then(|m| m.tool_calls.as_ref())
.map(|calls| calls.iter().map(|c| c.id.clone()).collect())
.unwrap_or_default();
messages.remove(i);
let j = i;
while j < messages.len() {
let is_orphan_tool = messages[j].role == Role::Tool
&& messages[j]
.tool_call_id
.as_ref()
.is_some_and(|id| drop_ids.contains(id));
if is_orphan_tool {
messages.remove(j);
} else {
break;
}
}
}
}
/// Run the agent with a live HTTP model client.
pub async fn run_agent( pub async fn run_agent(
config: &Config, config: &Config,
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext, tool_ctx: &ToolContext,
max_rounds: usize, max_rounds: usize,
) -> Result<String> { ) -> Result<AgentVerdict> {
let tools = tool_definitions(); let budget = context_char_budget();
let mut last_text = String::new(); let config = config.clone();
run_agent_with(messages, tool_ctx, max_rounds, budget, move |msgs, tools| {
let config = config.clone();
async move { chat_completion(&config, &msgs, Some(&tools)).await }
})
.await
}
for _round in 0..max_rounds { /// Core loop with injectable completer (for offline tests).
let reply = chat_completion(config, messages, Some(&tools)).await?; /// `complete` receives a snapshot of messages and tool defs each round.
pub async fn run_agent_with<F, Fut>(
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
context_budget: usize,
mut complete: F,
) -> Result<AgentVerdict>
where
F: FnMut(Vec<ChatMessage>, Value) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
{
let tools = tool_definitions();
let mut prev_round_sig: Option<String> = None;
let mut same_sig_streak: usize = 0;
for round in 0..max_rounds {
truncate_messages(messages, context_budget);
let reply = complete(messages.clone(), tools.clone()).await?;
let tool_calls = reply.tool_calls.clone().unwrap_or_default(); let tool_calls = reply.tool_calls.clone().unwrap_or_default();
if tool_calls.is_empty() { if tool_calls.is_empty() {
last_text = reply.text().to_string(); let last_text = reply.text().to_string();
messages.push(reply); messages.push(reply);
return Ok(last_text); if last_text.trim().is_empty() {
return Ok(AgentVerdict::Blocked(
"model returned empty final answer".into(),
));
}
return Ok(AgentVerdict::Answer(last_text));
} }
// Keep any content the model sent alongside tool_calls. // Loop guard: identical tool-call round repeated N times → fail closed.
if let Some(c) = reply.content.as_ref().filter(|s| !s.is_empty()) { let sig = round_signature(&tool_calls);
last_text = c.clone(); if prev_round_sig.as_deref() == Some(sig.as_str()) {
same_sig_streak += 1;
} else {
same_sig_streak = 1;
prev_round_sig = Some(sig);
} }
if same_sig_streak >= LOOP_GUARD_REPEAT {
messages.push(reply);
let reason = format!(
"loop guard: identical tool calls repeated {LOOP_GUARD_REPEAT} times (round {})",
round + 1
);
return Ok(AgentVerdict::Blocked(reason));
}
messages.push(reply); messages.push(reply);
let mut completion: Option<AgentVerdict> = None;
for call in &tool_calls { for call in &tool_calls {
let result = execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await; let result =
execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await;
if completion.is_none() && is_completion_tool(&call.function.name) {
completion = parse_completion_verdict(&call.function.name, &result);
}
messages.push(ChatMessage::tool(&call.id, result)); messages.push(ChatMessage::tool(&call.id, result));
} }
if let Some(verdict) = completion {
return Ok(verdict);
}
} }
Ok(last_text) Ok(AgentVerdict::Blocked(format!(
"blocked: reached max rounds ({max_rounds}) without completion"
)))
}
fn parse_completion_verdict(name: &str, result_json: &str) -> Option<AgentVerdict> {
let v: Value = serde_json::from_str(result_json).ok()?;
if v.get("error").is_some() {
return Some(AgentVerdict::Blocked(format!(
"completion tool {name} failed: {}",
v["error"]
)));
}
match name {
"report_done" => {
let msg = v
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("done")
.to_string();
Some(AgentVerdict::Done(msg))
}
"report_blocked" => {
let reason = v
.get("reason")
.and_then(|x| x.as_str())
.unwrap_or("blocked")
.to_string();
Some(AgentVerdict::Blocked(reason))
}
_ => None,
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::model::{FunctionCall, ToolCall};
use serde_json::json;
use std::sync::{Arc, Mutex};
fn tc(id: &str, name: &str, args: &str) -> ToolCall {
ToolCall {
id: id.into(),
kind: "function".into(),
function: FunctionCall {
name: name.into(),
arguments: args.into(),
},
}
}
#[test] #[test]
fn system_prompt_mentions_tools() { fn system_prompt_mentions_tools_and_chinese() {
assert!(AGENT_SYSTEM.contains("shell")); assert!(AGENT_SYSTEM.contains("shell"));
assert!(AGENT_SYSTEM.contains("report_done"));
assert!(AGENT_SYSTEM.contains("report_blocked"));
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese")); assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
} }
@ -65,4 +319,202 @@ mod tests {
fn default_max_rounds_is_12() { fn default_max_rounds_is_12() {
assert_eq!(DEFAULT_MAX_ROUNDS, 12); assert_eq!(DEFAULT_MAX_ROUNDS, 12);
} }
#[test]
fn round_signature_stable() {
let a = vec![tc("1", "list_dir", r#"{"path":"."}"#)];
let b = vec![tc("2", "list_dir", r#"{"path":"."}"#)];
assert_eq!(round_signature(&a), round_signature(&b));
let c = vec![tc("1", "list_dir", r#"{"path":"src"}"#)];
assert_ne!(round_signature(&a), round_signature(&c));
}
#[test]
fn truncate_keeps_system_and_shrinks_old_tools() {
let mut msgs = vec![
ChatMessage::system("sys"),
ChatMessage::user("goal"),
ChatMessage::assistant_tool_calls(None, vec![tc("c1", "read_file", r#"{"path":"a"}"#)]),
ChatMessage::tool("c1", "X".repeat(5_000)),
ChatMessage::assistant_tool_calls(None, vec![tc("c2", "read_file", r#"{"path":"b"}"#)]),
ChatMessage::tool("c2", "Y".repeat(5_000)),
ChatMessage::assistant_tool_calls(None, vec![tc("c3", "read_file", r#"{"path":"c"}"#)]),
ChatMessage::tool("c3", "Z".repeat(5_000)),
ChatMessage::assistant_tool_calls(None, vec![tc("c4", "read_file", r#"{"path":"d"}"#)]),
ChatMessage::tool("c4", "W".repeat(5_000)),
ChatMessage::assistant_tool_calls(None, vec![tc("c5", "read_file", r#"{"path":"e"}"#)]),
ChatMessage::tool("c5", "V".repeat(5_000)),
ChatMessage::user("still the goal"),
ChatMessage::assistant("recent"),
];
let before = messages_char_len(&msgs);
truncate_messages(&mut msgs, 8_000);
let after = messages_char_len(&msgs);
assert!(after < before);
assert_eq!(msgs[0].text(), "sys");
assert!(msgs.iter().any(|m| m.role == Role::User));
let old_tool = msgs
.iter()
.find(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some("c1"));
if let Some(t) = old_tool {
assert!(
t.text().contains("truncated") || t.text().len() < 5_000,
"{}",
t.text()
);
}
}
#[tokio::test]
async fn loop_stops_on_report_done() {
let dir = std::env::temp_dir().join(format!("grokboy-agent-done-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let ctx = ToolContext::new(dir.clone());
let mut messages = vec![
ChatMessage::system(AGENT_SYSTEM),
ChatMessage::user("finish"),
];
let calls = Arc::new(Mutex::new(0usize));
let calls2 = calls.clone();
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| {
let n = {
let mut g = calls2.lock().unwrap();
*g += 1;
*g
};
async move {
if n == 1 {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc(
"done1",
"report_done",
&json!({"message": "任務完成"}).to_string(),
)],
))
} else {
Ok(ChatMessage::assistant("should not reach"))
}
}
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Done("任務完成".into()));
assert_eq!(*calls.lock().unwrap(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn loop_stops_on_report_blocked() {
let dir = std::env::temp_dir().join(format!("grokboy-agent-blk-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let ctx = ToolContext::new(dir.clone());
let mut messages = vec![ChatMessage::user("x")];
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc(
"b1",
"report_blocked",
&json!({"reason": "permission denied"}).to_string(),
)],
))
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Blocked("permission denied".into()));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn loop_guard_blocks_repeated_calls() {
let dir = std::env::temp_dir().join(format!("grokboy-agent-loop-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let ctx = ToolContext::new(dir.clone());
std::fs::write(dir.join("f.txt"), "hi").unwrap();
let mut messages = vec![ChatMessage::user("loop")];
let args = json!({"path": "f.txt"}).to_string();
let verdict = run_agent_with(&mut messages, &ctx, 12, 100_000, move |_msgs, _tools| {
let args = args.clone();
async move {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc("r1", "read_file", &args)],
))
}
})
.await
.unwrap();
match verdict {
AgentVerdict::Blocked(reason) => {
assert!(reason.contains("loop guard"), "{reason}");
}
other => panic!("expected blocked, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn max_rounds_fail_closed() {
let dir = std::env::temp_dir().join(format!("grokboy-agent-max-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let ctx = ToolContext::new(dir.clone());
std::fs::write(dir.join("a.txt"), "1").unwrap();
std::fs::write(dir.join("b.txt"), "2").unwrap();
let mut messages = vec![ChatMessage::user("spin")];
let n = Arc::new(Mutex::new(0usize));
let n2 = n.clone();
let verdict = run_agent_with(&mut messages, &ctx, 2, 100_000, move |_msgs, _tools| {
let i = {
let mut g = n2.lock().unwrap();
*g += 1;
*g
};
async move {
let path = if i == 1 { "a.txt" } else { "b.txt" };
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc(
&format!("c{i}"),
"read_file",
&json!({"path": path}).to_string(),
)],
))
}
})
.await
.unwrap();
match verdict {
AgentVerdict::Blocked(reason) => {
assert!(reason.contains("max rounds"), "{reason}");
}
other => panic!("expected blocked, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn plain_answer_without_tools() {
let dir = std::env::temp_dir().join(format!("grokboy-agent-ans-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let ctx = ToolContext::new(dir.clone());
let mut messages = vec![ChatMessage::user("hi")];
let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move {
Ok(ChatMessage::assistant("hello there"))
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Answer("hello there".into()));
let _ = std::fs::remove_dir_all(&dir);
}
} }

View File

@ -6,8 +6,12 @@ mod model;
mod session; mod session;
mod tools; mod tools;
pub use agent::{AGENT_SYSTEM, DEFAULT_MAX_ROUNDS, run_agent}; pub use agent::{
AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, LOOP_GUARD_REPEAT,
context_char_budget, message_char_len, messages_char_len, round_signature, run_agent,
run_agent_with, tool_call_signature, truncate_messages,
};
pub use config::Config; pub use config::Config;
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat}; pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat};
pub use session::{Session, load_or_create, load_session, save_session, sessions_dir}; pub use session::{Session, load_or_create, load_session, save_session, sessions_dir};
pub use tools::{ToolContext, execute_tool, tool_definitions}; pub use tools::{ToolContext, execute_tool, is_completion_tool, tool_definitions};

View File

@ -1,4 +1,4 @@
//! Built-in tools: shell, list_dir, read_file, write_file. //! Built-in tools: shell, list_dir, read_file, write_file, report_done, report_blocked.
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json}; use serde_json::{Value, json};
@ -94,10 +94,43 @@ pub fn tool_definitions() -> Value {
"required": ["path", "content"] "required": ["path", "content"]
} }
} }
},
{
"type": "function",
"function": {
"name": "report_done",
"description": "Signal that the task is complete. Stops the agent loop with a done verdict. Call once when finished; include a short summary in message.",
"parameters": {
"type": "object",
"properties": {
"message": { "type": "string", "description": "Final summary for the user" }
},
"required": ["message"]
}
}
},
{
"type": "function",
"function": {
"name": "report_blocked",
"description": "Signal that the task cannot proceed. Stops the agent loop with a blocked verdict and reason. Prefer this over spinning or inventing results.",
"parameters": {
"type": "object",
"properties": {
"reason": { "type": "string", "description": "Why the agent is blocked" }
},
"required": ["reason"]
}
}
} }
]) ])
} }
/// True if the tool name ends the ReAct loop (completion contract).
pub fn is_completion_tool(name: &str) -> bool {
matches!(name, "report_done" | "report_blocked")
}
/// Resolve a user-supplied path against cwd and optionally enforce workspace_root. /// Resolve a user-supplied path against cwd and optionally enforce workspace_root.
pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> { pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result<PathBuf> {
let raw = Path::new(path); let raw = Path::new(path);
@ -156,10 +189,34 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str)
"list_dir" => tool_list_dir(ctx, &args).await, "list_dir" => tool_list_dir(ctx, &args).await,
"read_file" => tool_read_file(ctx, &args).await, "read_file" => tool_read_file(ctx, &args).await,
"write_file" => tool_write_file(ctx, &args).await, "write_file" => tool_write_file(ctx, &args).await,
"report_done" => tool_report_done(&args),
"report_blocked" => tool_report_blocked(&args),
other => Err(anyhow!("unknown tool: {other}")), other => Err(anyhow!("unknown tool: {other}")),
} }
} }
fn tool_report_done(args: &Value) -> Result<Value> {
let message = args
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("report_done: missing 'message'"))?;
Ok(json!({
"status": "done",
"message": message,
}))
}
fn tool_report_blocked(args: &Value) -> Result<Value> {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("report_blocked: missing 'reason'"))?;
Ok(json!({
"status": "blocked",
"reason": reason,
}))
}
async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result<Value> { async fn tool_shell(ctx: &ToolContext, args: &Value) -> Result<Value> {
let command = args let command = args
.get("command") .get("command")
@ -389,11 +446,40 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[tokio::test]
async fn report_done_and_blocked() {
let (ctx, dir) = temp_ctx();
let done = execute_tool(
&ctx,
"report_done",
&json!({"message": "all good"}).to_string(),
)
.await;
let done: Value = serde_json::from_str(&done).unwrap();
assert_eq!(done["status"], "done");
assert_eq!(done["message"], "all good");
let blocked = execute_tool(
&ctx,
"report_blocked",
&json!({"reason": "no access"}).to_string(),
)
.await;
let blocked: Value = serde_json::from_str(&blocked).unwrap();
assert_eq!(blocked["status"], "blocked");
assert_eq!(blocked["reason"], "no access");
assert!(is_completion_tool("report_done"));
assert!(is_completion_tool("report_blocked"));
assert!(!is_completion_tool("shell"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn tool_defs_include_four() { fn tool_defs_include_six() {
let defs = tool_definitions(); let defs = tool_definitions();
let arr = defs.as_array().unwrap(); let arr = defs.as_array().unwrap();
assert_eq!(arr.len(), 4); assert_eq!(arr.len(), 6);
let names: Vec<&str> = arr let names: Vec<&str> = arr
.iter() .iter()
.map(|t| t["function"]["name"].as_str().unwrap()) .map(|t| t["function"]["name"].as_str().unwrap())
@ -402,5 +488,7 @@ mod tests {
assert!(names.contains(&"list_dir")); assert!(names.contains(&"list_dir"));
assert!(names.contains(&"read_file")); assert!(names.contains(&"read_file"));
assert!(names.contains(&"write_file")); assert!(names.contains(&"write_file"));
assert!(names.contains(&"report_done"));
assert!(names.contains(&"report_blocked"));
} }
} }

View File

@ -1,7 +1,8 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use grokboy_core::{ use grokboy_core::{
AGENT_SYSTEM, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, execute_tool, AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext,
load_or_create, run_agent, save_session, stream_chat, tool_definitions, execute_tool, is_completion_tool, load_or_create, messages_char_len, run_agent, save_session,
stream_chat, tool_definitions, truncate_messages,
}; };
use serde_json::json; use serde_json::json;
use std::io::{self, Write}; use std::io::{self, Write};
@ -48,13 +49,13 @@ async fn run() -> Result<()> {
fn print_help() { fn print_help() {
println!( println!(
"\ "\
GrokBoy minimal local CLI agent (P1: tools + ReAct) GrokBoy minimal local CLI agent (P2: completion + loop guard + truncation)
USAGE: USAGE:
grokboy chat Interactive streaming chat (no tools) grokboy chat Interactive streaming chat (no tools)
grokboy run \"<prompt>\" One-shot agent with tools grokboy run \"<prompt>\" One-shot agent with tools
grokboy run --session <id> \"...\" Continue a saved session grokboy run --session <id> \"...\" Continue a saved session
grokboy smoke Offline tool checks (no API key required) grokboy smoke Offline checks (no API key required)
grokboy version grokboy version
grokboy help grokboy help
@ -62,8 +63,10 @@ ENV:
GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY) GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY)
GROKBOY_BASE_URL default https://api.x.ai/v1 GROKBOY_BASE_URL default https://api.x.ai/v1
GROKBOY_MODEL default grok-4.6 GROKBOY_MODEL default grok-4.6
GROKBOY_CONTEXT_CHARS context budget (default 100000)
Sessions are stored under ~/.grokboy/sessions/<id>.json Tools: shell, list_dir, read_file, write_file, report_done, report_blocked
Sessions: ~/.grokboy/sessions/<id>.json
" "
); );
} }
@ -148,7 +151,7 @@ async fn cmd_run(args: &[String]) -> Result<()> {
session.push(ChatMessage::user(&prompt)); session.push(ChatMessage::user(&prompt));
let tool_ctx = ToolContext::new(session.cwd.clone()); let tool_ctx = ToolContext::new(session.cwd.clone());
let answer = run_agent( let verdict = run_agent(
&config, &config,
&mut session.messages, &mut session.messages,
&tool_ctx, &tool_ctx,
@ -158,17 +161,22 @@ async fn cmd_run(args: &[String]) -> Result<()> {
session.touch(); session.touch();
let path = save_session(&session)?; let path = save_session(&session)?;
println!("{answer}"); println!("{}", verdict.message());
eprintln!( eprintln!(
"\n[session {} saved → {}]", "\n[verdict: {} | session {} → {}]",
verdict.kind(),
session.id, session.id,
path.display() path.display()
); );
// Non-zero exit on blocked so scripts can detect fail-closed.
if matches!(verdict, AgentVerdict::Blocked(_)) {
return Err(anyhow!("agent blocked: {}", verdict.message()));
}
Ok(()) Ok(())
} }
async fn cmd_smoke() -> Result<()> { async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline tools)…"); println!("GrokBoy smoke (offline P2)…");
let stamp = uuid_like(); let stamp = uuid_like();
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
std::fs::create_dir_all(&dir).context("temp dir")?; std::fs::create_dir_all(&dir).context("temp dir")?;
@ -241,15 +249,63 @@ async fn cmd_smoke() -> Result<()> {
} }
println!(" sandbox ok"); println!(" sandbox ok");
// tool definitions present // completion tools
let done = execute_tool(
&ctx,
"report_done",
&json!({"message": "smoke done"}).to_string(),
)
.await;
let done: serde_json::Value = serde_json::from_str(&done)?;
if done["status"] != "done" || done["message"] != "smoke done" {
return Err(anyhow!("report_done mismatch: {done}"));
}
let blocked = execute_tool(
&ctx,
"report_blocked",
&json!({"reason": "smoke blocked"}).to_string(),
)
.await;
let blocked: serde_json::Value = serde_json::from_str(&blocked)?;
if blocked["status"] != "blocked" || blocked["reason"] != "smoke blocked" {
return Err(anyhow!("report_blocked mismatch: {blocked}"));
}
if !is_completion_tool("report_done") || !is_completion_tool("report_blocked") {
return Err(anyhow!("is_completion_tool failed"));
}
println!(" completion ok");
// tool definitions present (6)
let defs = tool_definitions(); let defs = tool_definitions();
if defs.as_array().map(|a| a.len()).unwrap_or(0) != 4 { let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
return Err(anyhow!("expected 4 tool defs")); if n_tools != 6 {
return Err(anyhow!("expected 6 tool defs, got {n_tools}"));
} }
println!(" tool defs ok"); println!(" tool defs ok");
// session roundtrip in temp (does not require ~/.grokboy for this check — // context truncation
// we still exercise Session serialize via save into temp using core types) let mut msgs = vec![
ChatMessage::system(AGENT_SYSTEM),
ChatMessage::user("goal"),
ChatMessage::tool("t1", "X".repeat(50_000)),
ChatMessage::tool("t2", "Y".repeat(50_000)),
ChatMessage::tool("t3", "Z".repeat(50_000)),
ChatMessage::tool("t4", "W".repeat(50_000)),
ChatMessage::tool("t5", "V".repeat(50_000)),
ChatMessage::user("goal again"),
];
let before = messages_char_len(&msgs);
truncate_messages(&mut msgs, 10_000);
let after = messages_char_len(&msgs);
if after >= before {
return Err(anyhow!("truncate did not shrink ({before} -> {after})"));
}
if msgs[0].text() != AGENT_SYSTEM {
return Err(anyhow!("truncate dropped system prompt"));
}
println!(" truncation ok");
// session roundtrip
let mut sess = Session::new(dir.clone()); let mut sess = Session::new(dir.clone());
sess.push(ChatMessage::system(AGENT_SYSTEM)); sess.push(ChatMessage::system(AGENT_SYSTEM));
sess.push(ChatMessage::user("smoke")); sess.push(ChatMessage::user("smoke"));
@ -264,7 +320,6 @@ async fn cmd_smoke() -> Result<()> {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
// Optional live ping if API key is present (does not fail smoke).
if Config::from_env().is_ok() { if Config::from_env().is_ok() {
println!(" (API key present — skipping live call in smoke; use `run` to exercise)"); println!(" (API key present — skipping live call in smoke; use `run` to exercise)");
} else { } else {

View File

@ -15,9 +15,10 @@
- [x] Default model `grok-4.6` - [x] Default model `grok-4.6`
## P2 — completion contract ## P2 — completion contract
- [ ] `report_done` / blocked - [x] `report_done` / `report_blocked` stop the loop with a clear verdict
- [ ] Loop guard - [x] Loop guard (identical tool rounds ×3 → blocked)
- [ ] Context truncation - [x] Context truncation (`GROKBOY_CONTEXT_CHARS`, default ~100k)
- [x] Offline smoke/tests cover P2 without API key
## P3 — browser ## P3 — browser
- [ ] Playwright DOM path (no screenshot-first) - [ ] Playwright DOM path (no screenshot-first)