//! Multi-step OpenAI-compatible tool-calling ReAct loop //! (P2: completion, loop guard, truncation; P8: chunked auto-continue). use crate::config::Config; 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; /// Rounds per progress chunk (one beat). Absolute ceiling is `DEFAULT_MAX_ROUNDS_TOTAL`. pub const DEFAULT_MAX_ROUNDS: usize = 12; /// Absolute tool-round ceiling across auto-continued chunks (default 4 × 12). pub const DEFAULT_MAX_ROUNDS_TOTAL: usize = 48; 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, report_done, report_blocked, request_user_confirm, and optional browser_* (Playwright DOM: browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, browser_handoff). For web pages prefer DOM snapshot + selector/role click/type — not screenshots or pixel XY clicks. If you hit a login / OTP / captcha wall you cannot pass alone, call browser_handoff with a clear reason so the human can help in the visible browser, then continue from the returned snapshot. Prefer browser_handoff only for auth walls. Never publish/send social posts (Threads, Facebook, Instagram, X/Twitter, etc.) or take other irreversible public actions without either (a) an explicit user message this turn approving the exact draft, or (b) request_user_confirm returning approved. Prefer draft → confirm → then act. If approval is missing, call request_user_confirm (with the draft in prompt) or report_blocked — never post unilaterally. For greetings, small talk, clarifying questions, or when no tools are needed: reply with normal assistant text and stop (do not call report_done). Do not call report_done or give a final wrap-up answer until the user's task is actually complete. If still researching/browsing, keep using tools; live progress is shown by the runtime on stderr — you do not need to narrate every step as a conclusion. If you must speak mid-flight without tools, say it is partial progress only — prefer continuing with tools instead. report_done = final delivery only (short summary when a real task/tool workflow is actually finished). Call report_blocked when stuck or cannot proceed — do not invent results or loop. If work is large, keep using tools across the session; the runtime may continue in chunks — still call report_done when truly finished; don't stop early just to 'save rounds'. Do not invent tool results — call the 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 total round budget exhausted. 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) } /// Resolve rounds **per chunk** from `GROKBOY_MAX_ROUNDS` or default. pub fn max_rounds_budget() -> usize { std::env::var("GROKBOY_MAX_ROUNDS") .ok() .and_then(|s| s.parse().ok()) .filter(|&n| n > 0) .unwrap_or(DEFAULT_MAX_ROUNDS) } /// Resolve absolute round ceiling from `GROKBOY_MAX_ROUNDS_TOTAL` or default. pub fn max_rounds_total_budget() -> usize { std::env::var("GROKBOY_MAX_ROUNDS_TOTAL") .ok() .and_then(|s| s.parse().ok()) .filter(|&n| n > 0) .unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL) } /// Progress beats on stderr unless `GROKBOY_PROGRESS=0`. Always newline + flush. fn emit_progress(msg: &str) { match std::env::var("GROKBOY_PROGRESS") { Ok(v) if v == "0" => {} _ => { use std::io::Write; let mut err = std::io::stderr(); let _ = writeln!(err, "{msg}"); let _ = err.flush(); } } } fn emit_progress_line(msg: &str, on_progress: &mut impl FnMut(&str)) { emit_progress(msg); on_progress(msg); } /// Short success/fail line for a finished tool invocation. fn tool_progress_line(name: &str, result_json: &str) -> String { let v: Value = serde_json::from_str(result_json).unwrap_or(Value::Null); if let Some(err) = v.get("error") { let s = err .as_str() .map(|x| x.to_string()) .unwrap_or_else(|| err.to_string()); return format!("〔失敗〕{name}: {}", preview_progress(&s, 80)); } if v.get("blocked").and_then(|b| b.as_bool()) == Some(true) { let reason = v .get("reason") .and_then(|r| r.as_str()) .unwrap_or("blocked"); return format!("〔失敗〕{name}: {}", preview_progress(reason, 80)); } format!("〔完成〕{name}") } /// Largest byte index ≤ `max` that sits on a UTF-8 char boundary. fn floor_char_boundary(s: &str, max: usize) -> usize { if max >= s.len() { return s.len(); } let mut end = max; while end > 0 && !s.is_char_boundary(end) { end -= 1; } end } fn preview_progress(text: &str, max_chars: usize) -> String { let t = text.trim(); if t.chars().count() <= max_chars { return t.to_string(); } let truncated: String = t.chars().take(max_chars).collect(); format!("{truncated}…") } const MAX_ROUNDS_SUMMARY_NUDGE: &str = "The agent loop hit a chunk/round budget boundary without report_done or report_blocked. Summarize progress so far in concise Traditional Chinese (or concise English if the conversation was English). List what was tried and what remains. Do not call tools."; /// 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 = floor_char_boundary(content, 200); 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. /// `max_rounds` is the per-chunk budget (`GROKBOY_MAX_ROUNDS`); total ceiling comes from env. pub async fn run_agent( config: &Config, messages: &mut Vec, tool_ctx: &ToolContext, max_rounds: usize, ) -> Result { let budget = context_char_budget(); let total = max_rounds_total_budget(); let config = config.clone(); run_agent_with( messages, tool_ctx, max_rounds, total, budget, move |msgs, tools| { let config = config.clone(); async move { chat_completion(&config, &msgs, tools.as_ref()).await } }, ) .await } /// Core loop with injectable completer (for offline tests). /// `max_rounds` = rounds per chunk; `max_rounds_total` = absolute ceiling across auto-continues. /// `complete` receives a snapshot of messages and optional tool defs each round. /// Pass `None` for tools to force a plain-text completion (used for progress summaries). pub async fn run_agent_with( messages: &mut Vec, tool_ctx: &ToolContext, max_rounds: usize, max_rounds_total: usize, context_budget: usize, complete: F, ) -> Result where F: FnMut(Vec, Option) -> Fut, Fut: Future>, { run_agent_with_progress( messages, tool_ctx, max_rounds, max_rounds_total, context_budget, complete, |_| {}, ) .await } /// Like [`run_agent_with`], but also invokes `on_progress` for every live progress line /// (stderr still gated by `GROKBOY_PROGRESS`). pub async fn run_agent_with_progress( messages: &mut Vec, tool_ctx: &ToolContext, max_rounds: usize, max_rounds_total: usize, context_budget: usize, mut complete: F, mut on_progress: P, ) -> Result where F: FnMut(Vec, Option) -> Fut, Fut: Future>, P: FnMut(&str), { let tools = tool_definitions(); let mut prev_round_sig: Option = None; let mut same_sig_streak: usize = 0; let mut total_used: usize = 0; let mut chunk_idx: usize = 0; // Ensure total is at least one chunk's worth of progress possible. let max_rounds = max_rounds.max(1); let max_rounds_total = max_rounds_total.max(1); emit_progress_line( &format!("〔開始〕最多 {max_rounds_total} 輪(每段 {max_rounds})"), &mut on_progress, ); loop { chunk_idx += 1; let remaining = max_rounds_total.saturating_sub(total_used); if remaining == 0 { let progress = summarize_progress(messages, context_budget, &mut complete).await; let verdict = AgentVerdict::Blocked(format_total_exhausted( max_rounds_total, &progress, )); emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } let chunk_limit = max_rounds.min(remaining); for _round_in_chunk in 0..chunk_limit { truncate_messages(messages, context_budget); emit_progress_line( &format!("〔思考中〕第 {}/{} 輪…", total_used + 1, max_rounds_total), &mut on_progress, ); let reply = complete(messages.clone(), Some(tools.clone())).await?; let tool_calls = reply.tool_calls.clone().unwrap_or_default(); total_used += 1; if tool_calls.is_empty() { let last_text = reply.text().to_string(); messages.push(reply); if last_text.trim().is_empty() { let verdict = AgentVerdict::Blocked( "model returned empty final answer".into(), ); emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } let verdict = AgentVerdict::Answer(last_text); emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } let names: Vec<&str> = tool_calls .iter() .map(|c| c.function.name.as_str()) .collect(); emit_progress_line( &format!("〔工具〕round {total_used}: {}", names.join(", ")), &mut on_progress, ); // Loop guard: identical tool-call round repeated N times → fail closed (no auto-continue). 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 {total_used})" ); let verdict = AgentVerdict::Blocked(reason); emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } 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; emit_progress_line( &tool_progress_line(&call.function.name, &result), &mut on_progress, ); 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 { emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } } // Chunk ended without report_done / report_blocked / Answer — progress beat, then maybe continue. let progress = summarize_progress(messages, context_budget, &mut complete).await; if total_used >= max_rounds_total { let verdict = AgentVerdict::Blocked(format_total_exhausted( max_rounds_total, &progress, )); emit_progress_line( &format!("〔結束〕verdict={}", verdict.kind()), &mut on_progress, ); return Ok(verdict); } let next_chunk = chunk_idx + 1; let preview = preview_progress(&progress, 160); emit_progress_line( &format!( "〔進度|尚未完成〕〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}" ), &mut on_progress, ); // Auto-continue another chunk in the same run_agent invocation. } } fn format_total_exhausted(max_rounds_total: usize, progress: &str) -> String { let progress = progress.trim(); if progress.is_empty() { format!( "blocked: reached max rounds (total budget {max_rounds_total}) without completion\n(total budget exhausted — continue in next REPL turn if needed)" ) } else { format!( "blocked: reached max rounds (total budget {max_rounds_total}). Progress so far:\n{progress}\n(total budget exhausted — continue in next REPL turn if needed)" ) } } /// One no-tools completion asking for a progress summary; falls back if it fails. /// Does **not** push the nudge into `messages` (progress-only). async fn summarize_progress( messages: &[ChatMessage], context_budget: usize, complete: &mut F, ) -> String where F: FnMut(Vec, Option) -> Fut, Fut: Future>, { let mut msgs = messages.to_vec(); truncate_messages(&mut msgs, context_budget); msgs.push(ChatMessage::user(MAX_ROUNDS_SUMMARY_NUDGE)); match complete(msgs, None).await { Ok(reply) => { let text = reply.text().trim().to_string(); if text.is_empty() { String::new() } else { text } } Err(_) => String::new(), } } 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_and_chinese() { assert!(AGENT_SYSTEM.contains("shell")); assert!(AGENT_SYSTEM.contains("report_done")); assert!(AGENT_SYSTEM.contains("report_blocked")); assert!(AGENT_SYSTEM.contains("request_user_confirm")); assert!(AGENT_SYSTEM.contains("browser_navigate") || AGENT_SYSTEM.contains("browser_")); assert!(AGENT_SYSTEM.contains("browser_handoff")); assert!(AGENT_SYSTEM.contains("irreversible") || AGENT_SYSTEM.contains("Never publish")); assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese")); assert!(AGENT_SYSTEM.contains("do not call report_done")); assert!(AGENT_SYSTEM.contains("real task/tool workflow") || AGENT_SYSTEM.contains("final delivery")); assert!( AGENT_SYSTEM.contains("continue in chunks") || AGENT_SYSTEM.contains("save rounds"), "P8 chunk continue guidance missing" ); assert!( AGENT_SYSTEM.contains("actually complete") || AGENT_SYSTEM.contains("partial progress"), "no mid-task conclusion guidance missing" ); assert!( AGENT_SYSTEM.contains("final delivery"), "report_done = final delivery guidance missing" ); } #[test] fn max_rounds_budget_defaults_to_12() { // Unset in unit tests may still inherit env; only assert default constant + parse path. assert_eq!(DEFAULT_MAX_ROUNDS, 12); let parsed = std::env::var("GROKBOY_MAX_ROUNDS") .ok() .and_then(|s| s.parse::().ok()) .filter(|&n| n > 0); let got = max_rounds_budget(); assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS)); } #[test] fn default_max_rounds_is_12() { assert_eq!(DEFAULT_MAX_ROUNDS, 12); } #[test] fn default_max_rounds_total_is_48() { assert_eq!(DEFAULT_MAX_ROUNDS_TOTAL, 48); let parsed = std::env::var("GROKBOY_MAX_ROUNDS_TOTAL") .ok() .and_then(|s| s.parse::().ok()) .filter(|&n| n > 0); let got = max_rounds_total_budget(); assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL)); } #[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] #[test] fn floor_char_boundary_does_not_split_chinese() { let s2 = "abcdefghij宣告"; // '告' is 3 bytes; index 11 lands inside it. let idx = 11; assert!(!s2.is_char_boundary(idx)); let end = floor_char_boundary(s2, idx); assert!(s2.is_char_boundary(end)); let _ = &s2[..end]; } #[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, 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, 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, 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, 2, 100_000, move |_msgs, tools| { let i = { let mut g = n2.lock().unwrap(); *g += 1; *g }; async move { // Final no-tools summary call after max rounds. if tools.is_none() { return Ok(ChatMessage::assistant( "已讀 a.txt 與 b.txt,任務尚未完成。", )); } 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}"); assert!(reason.contains("Progress so far"), "{reason}"); assert!(reason.contains("已讀 a.txt"), "{reason}"); assert!( reason.contains("total budget") || reason.contains("exhausted"), "{reason}" ); } other => panic!("expected blocked, got {other:?}"), } // Summary nudge must not pollute the persisted conversation. assert!( !messages.iter().any(|m| { m.text().contains("chunk/round budget") || m.text().contains("maximum number of tool rounds") }), "summary nudge should not be pushed into messages" ); // Completer called 2 tool rounds + 1 summary. assert_eq!(*n.lock().unwrap(), 3); let _ = std::fs::remove_dir_all(&dir); } #[tokio::test] async fn max_rounds_summary_fallback_on_complete_error() { let dir = std::env::temp_dir().join(format!("grokboy-agent-maxfb-{}", 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(); 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, 1, 1, 100_000, move |_msgs, tools| { let _ = { let mut g = n2.lock().unwrap(); *g += 1; *g }; async move { if tools.is_none() { return Err(anyhow::anyhow!("summary API down")); } Ok(ChatMessage::assistant_tool_calls( None, vec![tc( "c1", "read_file", &json!({"path": "a.txt"}).to_string(), )], )) } }) .await .unwrap(); match verdict { AgentVerdict::Blocked(reason) => { assert!( reason.contains("max rounds") && reason.contains("total budget 1") && reason.contains("without completion"), "{reason}" ); } other => panic!("expected blocked fallback, 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, 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); } /// Completer needs > chunk_size tool rounds then report_done → Done (auto-continue). #[tokio::test] async fn auto_continue_across_chunks_then_done() { let _env = crate::test_env::lock(); unsafe { std::env::set_var("GROKBOY_PROGRESS", "0") }; let dir = std::env::temp_dir().join(format!("grokboy-agent-chunk-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); for i in 1..=8 { std::fs::write(dir.join(format!("f{i}.txt")), format!("{i}")).unwrap(); } let ctx = ToolContext::new(dir.clone()); let mut messages = vec![ChatMessage::user("long task")]; let tool_rounds = Arc::new(Mutex::new(0usize)); let tool_rounds2 = tool_rounds.clone(); // chunk=3 → after 3 tools, progress summary, continue; on 5th tool round call report_done. let verdict = run_agent_with(&mut messages, &ctx, 3, 20, 100_000, move |_msgs, tools| { let tool_rounds2 = tool_rounds2.clone(); async move { if tools.is_none() { return Ok(ChatMessage::assistant("已讀部分檔案,繼續中。")); } let r = { let mut g = tool_rounds2.lock().unwrap(); *g += 1; *g }; if r >= 5 { return Ok(ChatMessage::assistant_tool_calls( None, vec![tc( &format!("done{r}"), "report_done", &json!({"message": "長任務完成"}).to_string(), )], )); } Ok(ChatMessage::assistant_tool_calls( None, vec![tc( &format!("c{r}"), "read_file", &json!({"path": format!("f{r}.txt")}).to_string(), )], )) } }) .await .unwrap(); assert_eq!(verdict, AgentVerdict::Done("長任務完成".into())); assert!( *tool_rounds.lock().unwrap() >= 5, "expected at least 5 tool rounds across chunks" ); let _ = std::fs::remove_dir_all(&dir); unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; } /// Hits absolute total ceiling → Blocked with progress + exhausted note. #[tokio::test] async fn total_ceiling_blocks_with_summary() { let _env = crate::test_env::lock(); unsafe { std::env::set_var("GROKBOY_PROGRESS", "0") }; let dir = std::env::temp_dir().join(format!("grokboy-agent-ceil-{}", std::process::id())); let _ = std::fs::create_dir_all(&dir); for i in 1..=10 { std::fs::write(dir.join(format!("t{i}.txt")), format!("{i}")).unwrap(); } let ctx = ToolContext::new(dir.clone()); let mut messages = vec![ChatMessage::user("spin forever")]; let tool_rounds = Arc::new(Mutex::new(0usize)); let tool_rounds2 = tool_rounds.clone(); // chunk=2, total=4 → two chunks then Blocked (no report_done). let verdict = run_agent_with(&mut messages, &ctx, 2, 4, 100_000, move |_msgs, tools| { let tool_rounds2 = tool_rounds2.clone(); async move { if tools.is_none() { return Ok(ChatMessage::assistant("仍在讀檔,尚未完成。")); } let r = { let mut g = tool_rounds2.lock().unwrap(); *g += 1; *g }; Ok(ChatMessage::assistant_tool_calls( None, vec![tc( &format!("c{r}"), "read_file", &json!({"path": format!("t{r}.txt")}).to_string(), )], )) } }) .await .unwrap(); match verdict { AgentVerdict::Blocked(reason) => { assert!(reason.contains("max rounds"), "{reason}"); assert!(reason.contains("total budget 4"), "{reason}"); assert!(reason.contains("Progress so far"), "{reason}"); assert!(reason.contains("exhausted"), "{reason}"); assert!(reason.contains("仍在讀檔"), "{reason}"); } other => panic!("expected blocked at ceiling, got {other:?}"), } assert_eq!(*tool_rounds.lock().unwrap(), 4); let _ = std::fs::remove_dir_all(&dir); unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; } /// Progress callback fires on start / think / tools / done (offline). #[tokio::test] async fn progress_callback_invoked_on_rounds() { let _env = crate::test_env::lock(); unsafe { std::env::set_var("GROKBOY_PROGRESS", "0") }; let dir = std::env::temp_dir().join(format!( "grokboy-agent-progress-{}", std::process::id() )); let _ = std::fs::create_dir_all(&dir); std::fs::write(dir.join("a.txt"), "1").unwrap(); let ctx = ToolContext::new(dir.clone()); let mut messages = vec![ChatMessage::user("go")]; let captured = Arc::new(Mutex::new(Vec::::new())); let cap = captured.clone(); let n = Arc::new(Mutex::new(0usize)); let n2 = n.clone(); let verdict = run_agent_with_progress( &mut messages, &ctx, 5, 5, 100_000, move |_msgs, tools| { let n2 = n2.clone(); async move { if tools.is_none() { return Ok(ChatMessage::assistant("should not summarize")); } let i = { let mut g = n2.lock().unwrap(); *g += 1; *g }; if i == 1 { Ok(ChatMessage::assistant_tool_calls( None, vec![tc( "c1", "read_file", &json!({"path": "a.txt"}).to_string(), )], )) } else { Ok(ChatMessage::assistant_tool_calls( None, vec![tc( "done1", "report_done", &json!({"message": "讀完了"}).to_string(), )], )) } } }, move |msg| { cap.lock().unwrap().push(msg.to_string()); }, ) .await .unwrap(); assert_eq!(verdict, AgentVerdict::Done("讀完了".into())); let lines = captured.lock().unwrap().clone(); assert!( lines.iter().any(|l| l.contains("〔開始〕") && l.contains("最多")), "missing start: {lines:?}" ); assert!( lines.iter().any(|l| l.contains("〔思考中〕") && l.contains("輪")), "missing thinking: {lines:?}" ); assert!( lines .iter() .any(|l| l.contains("〔工具〕") && l.contains("read_file")), "missing tools: {lines:?}" ); assert!( lines.iter().any(|l| l.contains("〔完成〕read_file")), "missing tool done: {lines:?}" ); assert!( lines .iter() .any(|l| l.contains("〔結束〕") && l.contains("done")), "missing end: {lines:?}" ); let _ = std::fs::remove_dir_all(&dir); unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; } /// Loop guard still fail-closes without auto-continuing forever. #[tokio::test] async fn loop_guard_does_not_auto_continue() { let _env = crate::test_env::lock(); unsafe { std::env::set_var("GROKBOY_PROGRESS", "0") }; let dir = std::env::temp_dir().join(format!("grokboy-agent-noloop-{}", 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 calls = Arc::new(Mutex::new(0usize)); let calls2 = calls.clone(); // Huge total budget — must still stop at loop guard after 3 identical rounds. let verdict = run_agent_with(&mut messages, &ctx, 12, 48, 100_000, move |_msgs, tools| { let args = args.clone(); let calls2 = calls2.clone(); async move { if tools.is_none() { panic!("should not request progress summary on loop guard"); } *calls2.lock().unwrap() += 1; 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 loop-guard blocked, got {other:?}"), } assert_eq!(*calls.lock().unwrap(), LOOP_GUARD_REPEAT); let _ = std::fs::remove_dir_all(&dir); unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; } }