LazyBoy2/crates/grokboy-core/src/agent.rs

1154 lines
43 KiB
Rust
Raw Normal View History

//! 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::<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 = 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<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.
/// `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<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
) -> Result<AgentVerdict> {
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<F, Fut>(
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
complete: F,
) -> Result<AgentVerdict>
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
{
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<F, Fut, P>(
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
mut complete: F,
mut on_progress: P,
) -> Result<AgentVerdict>
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
P: FnMut(&str),
{
let tools = tool_definitions();
let mut prev_round_sig: Option<String> = 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<AgentVerdict> = 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<F, Fut>(
messages: &[ChatMessage],
context_budget: usize,
complete: &mut F,
) -> String
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
{
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<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)]
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::<usize>().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::<usize>().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];
}
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::<String>::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") };
}
}