From 815b840f300aeff8b9709d7ca72bc743bd7ca46c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sun, 13 Sep 2026 15:51:13 +0800 Subject: [PATCH] P2: completion contract, loop guard, context truncation --- README.md | 15 +- crates/grokboy-core/src/agent.rs | 494 +++++++++++++++++++++++++++++-- crates/grokboy-core/src/lib.rs | 8 +- crates/grokboy-core/src/tools.rs | 94 +++++- crates/grokboy/src/main.rs | 107 +++++-- docs/ACCEPTANCE.md | 7 +- 6 files changed, 665 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 39bc051..b0d67d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 @@ -8,7 +8,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P1**: tools + ReAct + sessions |-------|--------| | P0 streaming chat | done | | P1 shell / files + ReAct | done | -| P2 completion / loop guard | planned | +| P2 completion / loop guard / truncation | done | | P3 browser (Playwright) | later | No Docker desktop, no Codex/LazyBoy fork. @@ -20,6 +20,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY # optional: # export GROKBOY_BASE_URL=https://api.x.ai/v1 # export GROKBOY_MODEL=grok-4.6 +# export GROKBOY_CONTEXT_CHARS=100000 cd ~/GrokBoy cargo run -p grokboy -- chat @@ -28,16 +29,20 @@ cargo run -p grokboy -- chat ## Commands - `grokboy chat` — interactive streaming chat (no tools) -- `grokboy run ""` — one-shot agent with tools (`shell`, `list_dir`, `read_file`, `write_file`) +- `grokboy run ""` — one-shot agent with tools - `grokboy run --session ""` — continue a saved session -- `grokboy smoke` — offline tool checks (no API key required) +- `grokboy smoke` — offline checks (no API key required) - `grokboy help` +Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`. + Sessions are stored under `~/.grokboy/sessions/.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 export GROKBOY_API_KEY=你的金鑰 diff --git a/crates/grokboy-core/src/agent.rs b/crates/grokboy-core/src/agent.rs index ca55370..d8ad7e4 100644 --- a/crates/grokboy-core/src/agent.rs +++ b/crates/grokboy-core/src/agent.rs @@ -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::model::{ChatMessage, chat_completion}; -use crate::tools::{ToolContext, execute_tool, tool_definitions}; +use crate::model::{ChatMessage, Role, ToolCall, chat_completion}; +use crate::tools::{ToolContext, execute_tool, is_completion_tool, tool_definitions}; use anyhow::Result; +use serde_json::Value; +use std::future::Future; 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 = "\ You are GrokBoy, a concise local coding assistant with tools. 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. -Available tools: shell, list_dir, read_file, write_file. -Do not invent tool results — call the tools. Stop when you can give a final answer."; +Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked. +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. -/// Appends all intermediate messages (assistant tool_calls + tool results + final) to `messages`. -/// Returns the final assistant text (may be empty if stopped on round limit with only tools). +/// Final verdict from the agent loop (fail-closed when stuck). +#[derive(Debug, Clone, PartialEq, Eq)] +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::>() + .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, 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 = 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 = 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( config: &Config, messages: &mut Vec, tool_ctx: &ToolContext, max_rounds: usize, -) -> Result { - let tools = tool_definitions(); - let mut last_text = String::new(); +) -> Result { + let budget = context_char_budget(); + 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 { - let reply = chat_completion(config, messages, Some(&tools)).await?; +/// Core loop with injectable completer (for offline tests). +/// `complete` receives a snapshot of messages and tool defs each round. +pub async fn run_agent_with( + messages: &mut Vec, + tool_ctx: &ToolContext, + max_rounds: usize, + context_budget: usize, + mut complete: F, +) -> Result +where + F: FnMut(Vec, Value) -> Fut, + Fut: Future>, +{ + let tools = tool_definitions(); + let mut prev_round_sig: Option = 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(); if tool_calls.is_empty() { - last_text = reply.text().to_string(); + let last_text = reply.text().to_string(); 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. - if let Some(c) = reply.content.as_ref().filter(|s| !s.is_empty()) { - last_text = c.clone(); + // Loop guard: identical tool-call round repeated N times → fail closed. + let sig = round_signature(&tool_calls); + 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); + let mut completion: Option = None; 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)); } + + 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 { + 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)] mod tests { 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] - fn system_prompt_mentions_tools() { + fn system_prompt_mentions_tools_and_chinese() { 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")); } @@ -65,4 +319,202 @@ mod tests { fn default_max_rounds_is_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); + } } diff --git a/crates/grokboy-core/src/lib.rs b/crates/grokboy-core/src/lib.rs index abe5fab..5d15197 100644 --- a/crates/grokboy-core/src/lib.rs +++ b/crates/grokboy-core/src/lib.rs @@ -6,8 +6,12 @@ mod model; mod session; 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 model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat}; 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}; diff --git a/crates/grokboy-core/src/tools.rs b/crates/grokboy-core/src/tools.rs index 5ca459d..b271d48 100644 --- a/crates/grokboy-core/src/tools.rs +++ b/crates/grokboy-core/src/tools.rs @@ -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 serde_json::{Value, json}; @@ -94,10 +94,43 @@ pub fn tool_definitions() -> Value { "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. pub fn resolve_path(ctx: &ToolContext, path: &str) -> Result { 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, "read_file" => tool_read_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}")), } } +fn tool_report_done(args: &Value) -> Result { + 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 { + 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 { let command = args .get("command") @@ -389,11 +446,40 @@ mod tests { 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] - fn tool_defs_include_four() { + fn tool_defs_include_six() { let defs = tool_definitions(); let arr = defs.as_array().unwrap(); - assert_eq!(arr.len(), 4); + assert_eq!(arr.len(), 6); let names: Vec<&str> = arr .iter() .map(|t| t["function"]["name"].as_str().unwrap()) @@ -402,5 +488,7 @@ mod tests { assert!(names.contains(&"list_dir")); assert!(names.contains(&"read_file")); assert!(names.contains(&"write_file")); + assert!(names.contains(&"report_done")); + assert!(names.contains(&"report_blocked")); } } diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index c7c6380..8b68408 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -1,7 +1,8 @@ use anyhow::{Context, Result, anyhow}; use grokboy_core::{ - AGENT_SYSTEM, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, execute_tool, - load_or_create, run_agent, save_session, stream_chat, tool_definitions, + AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, + 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 std::io::{self, Write}; @@ -48,22 +49,24 @@ async fn run() -> Result<()> { fn print_help() { println!( "\ -GrokBoy — minimal local CLI agent (P1: tools + ReAct) +GrokBoy — minimal local CLI agent (P2: completion + loop guard + truncation) USAGE: grokboy chat Interactive streaming chat (no tools) grokboy run \"\" One-shot agent with tools grokboy run --session \"...\" Continue a saved session - grokboy smoke Offline tool checks (no API key required) + grokboy smoke Offline checks (no API key required) grokboy version grokboy help ENV: - GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY) - GROKBOY_BASE_URL default https://api.x.ai/v1 - GROKBOY_MODEL default grok-4.6 + GROKBOY_API_KEY API key (or XAI_API_KEY / OPENAI_API_KEY) + GROKBOY_BASE_URL default https://api.x.ai/v1 + GROKBOY_MODEL default grok-4.6 + GROKBOY_CONTEXT_CHARS context budget (default 100000) -Sessions are stored under ~/.grokboy/sessions/.json +Tools: shell, list_dir, read_file, write_file, report_done, report_blocked +Sessions: ~/.grokboy/sessions/.json " ); } @@ -148,7 +151,7 @@ async fn cmd_run(args: &[String]) -> Result<()> { session.push(ChatMessage::user(&prompt)); let tool_ctx = ToolContext::new(session.cwd.clone()); - let answer = run_agent( + let verdict = run_agent( &config, &mut session.messages, &tool_ctx, @@ -158,17 +161,22 @@ async fn cmd_run(args: &[String]) -> Result<()> { session.touch(); let path = save_session(&session)?; - println!("{answer}"); + println!("{}", verdict.message()); eprintln!( - "\n[session {} saved → {}]", + "\n[verdict: {} | session {} → {}]", + verdict.kind(), session.id, 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(()) } async fn cmd_smoke() -> Result<()> { - println!("GrokBoy smoke (offline tools)…"); + println!("GrokBoy smoke (offline P2)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; @@ -183,7 +191,7 @@ async fn cmd_smoke() -> Result<()> { .await; let w: serde_json::Value = serde_json::from_str(&w)?; assert_ok(&w, "write_file")?; - println!(" write_file ok"); + println!(" write_file ok"); // read_file let r = execute_tool( @@ -197,7 +205,7 @@ async fn cmd_smoke() -> Result<()> { if r["content"].as_str() != Some("smoke ok\n第二行") { return Err(anyhow!("read_file content mismatch: {r}")); } - println!(" read_file ok"); + println!(" read_file ok"); // list_dir let l = execute_tool(&ctx, "list_dir", &json!({"path": "."}).to_string()).await; @@ -212,7 +220,7 @@ async fn cmd_smoke() -> Result<()> { if !names.contains(&"note.txt") { return Err(anyhow!("list_dir missing note.txt: {l}")); } - println!(" list_dir ok"); + println!(" list_dir ok"); // shell let s = execute_tool( @@ -226,7 +234,7 @@ async fn cmd_smoke() -> Result<()> { if s["exit_code"] != 0 { return Err(anyhow!("shell exit_code != 0: {s}")); } - println!(" shell ok"); + println!(" shell ok"); // path traversal should fail let bad = execute_tool( @@ -239,17 +247,65 @@ async fn cmd_smoke() -> Result<()> { if bad.get("error").is_none() { return Err(anyhow!("expected path traversal error, got {bad}")); } - println!(" sandbox ok"); + println!(" sandbox ok"); - // tool definitions present - let defs = tool_definitions(); - if defs.as_array().map(|a| a.len()).unwrap_or(0) != 4 { - return Err(anyhow!("expected 4 tool defs")); + // 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}")); } - println!(" tool defs ok"); + 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"); - // session roundtrip in temp (does not require ~/.grokboy for this check — - // we still exercise Session serialize via save into temp using core types) + // tool definitions present (6) + let defs = tool_definitions(); + let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0); + if n_tools != 6 { + return Err(anyhow!("expected 6 tool defs, got {n_tools}")); + } + println!(" tool defs ok"); + + // context truncation + 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()); sess.push(ChatMessage::system(AGENT_SYSTEM)); sess.push(ChatMessage::user("smoke")); @@ -260,11 +316,10 @@ async fn cmd_smoke() -> Result<()> { if loaded.messages.len() != 2 { return Err(anyhow!("session roundtrip failed")); } - println!(" session ok"); + println!(" session ok"); 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() { println!(" (API key present — skipping live call in smoke; use `run` to exercise)"); } else { diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 63f58d6..12664c0 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -15,9 +15,10 @@ - [x] Default model `grok-4.6` ## P2 — completion contract -- [ ] `report_done` / blocked -- [ ] Loop guard -- [ ] Context truncation +- [x] `report_done` / `report_blocked` stop the loop with a clear verdict +- [x] Loop guard (identical tool rounds ×3 → blocked) +- [x] Context truncation (`GROKBOY_CONTEXT_CHARS`, default ~100k) +- [x] Offline smoke/tests cover P2 without API key ## P3 — browser - [ ] Playwright DOM path (no screenshot-first)