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

1547 lines
63 KiB
Rust
Raw Normal View History

//! Multi-step OpenAI-compatible tool-calling ReAct loop
2026-09-13 16:38:32 +00:00
//! Tool results drive continuation; request budgets are runtime stop conditions.
use crate::config::Config;
2026-09-13 16:38:32 +00:00
use crate::model::{chat_completion, ChatMessage, Role, ToolCall};
use crate::tools::{execute_tool, is_completion_tool, tool_definitions, ToolContext};
use anyhow::Result;
use serde_json::Value;
use std::future::Future;
2026-09-13 16:38:32 +00:00
/// Model requests per progress beat. No extra model call is made for progress.
pub const DEFAULT_MAX_ROUNDS: usize = 12;
2026-09-13 16:38:32 +00:00
/// Absolute model-request ceiling per user turn.
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 = "\
2026-09-13 16:38:32 +00:00
You are GrokBoy, a general-purpose local agent. Observe the actual environment, choose the next useful action, verify its result, and adapt until the user goal is handled.
Use tools when they help solve the task; otherwise answer directly.
2026-09-13 16:38:32 +00:00
A blocked step is not automatically a failed task. When a required action needs human participation (account selection, authentication, permission, local access, or a personal decision), explain the specific obstacle and request the smallest useful intervention through browser_handoff or request_user_input. Preserve the current task/plan/session while waiting, inspect the result when control returns, and continue. If the obstacle persists, offer concrete alternative routes through report_blocked options, including useful independent work where possible, and respect the user choice to stop. Do not substitute a blanket refusal or repeated unchanged retries for human collaboration; budgets and security boundaries still apply.
Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese.
2026-09-13 16:38:32 +00:00
Available tools include report_progress, update_plan, request_user_input, exec_command, write_stdin, shell, search_files, list_dir, read_file, edit_file, write_file, report_done, report_blocked, request_user_confirm, and browser_* (Playwright DOM). Read tool schemas for capabilities.
For web pages prefer DOM snapshot + selector/role click/type not screenshots or pixel XY clicks. Re-observe after navigation, changes, or resumed sessions. Do not assume previous side effects were rolled back after interruption; inspect before retrying.
Helping a user access their own account using user-operated login in the tool browser is supported. Do not refuse authorized browsing just because authentication is required; navigate and inspect first, then hand over the same browser for authentication if needed. Do not collect credentials in chat or bypass authentication.
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.
2026-09-13 16:38:32 +00:00
Before a task that requires actions, briefly tell the user what you will do (12 sentences), then act. Prefer assistant text alongside the first action tool. Use report_progress for a standalone progress update; it continues the task. Do not repeatedly say you are working without reporting a finding or next action. For multi-stage or uncertain tasks, create a short 35 step update_plan, then execute without asking approval of the plan. Update step status at milestones and explain changes. Simple questions need no plan.
Plain text without tools is a final answer only. Use report_progress or text with tool calls for progress. Only request_user_input for essential information that available tools cannot discover; resume after the answer. User input arriving while working is steering: keep the original goal unless the user replaces or cancels it. Use browser_read_page to read page content and cite actual URLs; use snapshot to locate controls. Search via the browser without assuming a separate search API. Use exec_command/write_stdin for long commands; observe completion before claiming success.
report_done = final delivery only (short summary when a real task/tool workflow is actually finished). Call it alone, in a separate step after observing results. Verify the requested outcome with available tools before claiming success; summarize evidence and any limitations.
When a stage is blocked, briefly explain what failed and provide 23 concrete next routes with report_blocked options, or use browser_handoff for manual login in the SAME browser session. Examples: let the user handle login in the visible browser, or draft content without login. Stop repeating unsuccessful login attempts after two similar failures without new evidence. Never ask for a password or OTP in chat. A choice changes the next route, not proof the blocker is solved. Update the plan and continue this same task/session; do not spawn a fresh worker for manual login. Call report_blocked when stuck 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),
2026-09-13 16:38:32 +00:00
/// Model called `report_blocked`, or repeated observations showed no progress.
Blocked(String),
/// Model returned final text without a completion tool.
Answer(String),
2026-09-13 16:38:32 +00:00
/// Runtime ceiling, not a model claim that the task is blocked.
BudgetExhausted(String),
/// Provider/protocol/context failure; conversation can be resumed.
Failed(String),
Cancelled(String),
}
impl AgentVerdict {
pub fn message(&self) -> &str {
match self {
2026-09-13 16:38:32 +00:00
Self::Done(s)
| Self::Blocked(s)
| Self::Answer(s)
| Self::BudgetExhausted(s)
| Self::Failed(s)
| Self::Cancelled(s) => s,
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Done(_) => "done",
Self::Blocked(_) => "blocked",
Self::Answer(_) => "answer",
2026-09-13 16:38:32 +00:00
Self::BudgetExhausted(_) => "budget_exhausted",
Self::Failed(_) => "failed",
Self::Cancelled(_) => "cancelled",
}
}
}
/// 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)
}
2026-09-13 16:38:32 +00:00
/// Resolve progress interval 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)
}
2026-09-13 16:38:32 +00:00
fn emit_progress_line(msg: &str, runtime: &crate::Runtime, on_progress: &mut impl FnMut(&str)) {
runtime.emit(crate::AgentEvent::Status {
message: msg.into(),
});
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));
}
2026-09-13 16:38:32 +00:00
if v["exit_code"].as_i64().is_some_and(|n| n != 0) || v["approved"] == false {
return format!("〔失敗〕{name}: {}", preview_progress(&v.to_string(), 240));
}
if v["running"] == true {
return format!("〔執行中〕{name}");
}
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 {
2026-09-13 16:38:32 +00:00
let text = text.trim();
let preview: String = text.chars().take(max_chars).collect();
if text.chars().count() > max_chars {
format!("{preview}")
} else {
preview
}
}
/// Stable signature for a single tool call (name + args).
pub fn tool_call_signature(call: &ToolCall) -> String {
2026-09-13 16:38:32 +00:00
let args = serde_json::from_str::<Value>(&call.function.arguments)
.map(|v| v.to_string())
.unwrap_or_else(|_| call.function.arguments.clone());
format!("{}:{}", call.function.name, args)
}
/// 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")
}
2026-09-13 16:38:32 +00:00
/// Approximate serialized size in UTF-8 bytes (legacy config name uses 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;
}
}
2026-09-13 16:38:32 +00:00
// Keep system and every user constraint. Remove complete older tool groups only.
while messages_char_len(messages) > budget {
let candidate = messages
.iter()
.enumerate()
2026-09-13 16:38:32 +00:00
.find(|(i, m)| {
*i + 2 < messages.len()
&& m.role == Role::Assistant
&& m.tool_calls.as_ref().is_some_and(|c| !c.is_empty())
})
.map(|(i, _)| i);
2026-09-13 16:38:32 +00:00
let Some(i) = candidate else { break };
let ids = messages[i]
.tool_calls
.as_ref()
.unwrap()
.iter()
2026-09-13 16:38:32 +00:00
.map(|c| c.id.clone())
.collect::<Vec<_>>();
messages.remove(i);
2026-09-13 16:38:32 +00:00
while messages.get(i).is_some_and(|m| {
m.role == Role::Tool && m.tool_call_id.as_ref().is_some_and(|id| ids.contains(id))
}) {
messages.remove(i);
}
}
if messages_char_len(messages) > budget {
for msg in messages.iter_mut().filter(|m| m.role == Role::Tool) {
if let Some(content) = msg.content.as_mut() {
if content.len() > 200 {
let keep = floor_char_boundary(content, 200);
*content = format!("{}… [truncated]", &content[..keep]);
}
}
}
}
}
/// Run the agent with a live HTTP model client.
2026-09-13 16:38:32 +00:00
/// `max_rounds` is the progress interval; total request 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).
2026-09-13 16:38:32 +00:00
/// `max_rounds` = progress interval; `max_rounds_total` = absolute model-request ceiling.
/// `complete` receives a snapshot of messages and optional tool defs each round.
2026-09-13 16:38:32 +00:00
/// Every model request receives tools and counts against the total budget.
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),
{
2026-09-13 16:38:32 +00:00
use crate::runtime::AgentEvent;
let tools = if let Some(team) = &tool_ctx.team {
crate::team::worker::definitions(team.task.is_none())
} else {
tool_definitions()
};
let runtime = &tool_ctx.runtime;
let max_rounds = max_rounds.max(1);
let max_rounds_total = max_rounds_total.max(1);
2026-09-13 16:38:32 +00:00
let mut previous_observation = String::new();
let mut repeat_count = 0;
let mut control_rounds = 0;
let mut last_progress = String::new();
emit_progress_line(
2026-09-13 16:38:32 +00:00
&format!("〔開始〕最多 {max_rounds_total} 輪(每 {max_rounds} 輪顯示進度)"),
runtime,
&mut on_progress,
);
2026-09-13 16:38:32 +00:00
let verdict = 'turn: {
let pending_question = runtime.pending_question.lock().unwrap().clone();
if let Some(question) = pending_question {
let is_handoff = question["kind"] == "handoff";
let answer = if is_handoff {
let mut args = question.clone();
args.as_object_mut().unwrap().remove("options");
if let Some(alternatives) = question.get("handoff_options") {
args["options"] = alternatives.clone();
}
runtime
.wait("重新開啟登入視窗", async {
Ok(serde_json::from_str::<Value>(
&execute_tool(tool_ctx, "browser_handoff", &args.to_string()).await,
)?)
})
.await
} else {
runtime.question(&question).await
};
match answer {
Ok(answer) if answer["user_stopped"] == true => {
break 'turn AgentVerdict::Cancelled("使用者選擇停止登入工作。".into())
}
Ok(answer) => messages.push(ChatMessage::user(format!(
"Reply to the previously unanswered question {}: {}",
question,
if is_handoff {
answer
} else {
answer["answer"].clone()
}
))),
Err(error) if runtime.cancelled() => {
break 'turn AgentVerdict::Cancelled(error.to_string())
}
Err(error) => break 'turn AgentVerdict::Blocked(error.to_string()),
}
}
2026-09-13 16:38:32 +00:00
for round in 1..=max_rounds_total {
if let Some(team) = &tool_ctx.team {
for message in team.take_messages()? {
messages.push(ChatMessage::user(message));
}
}
if runtime.cancelled() {
break 'turn AgentVerdict::Cancelled(
"已停止本回合session 可續跑。已執行的操作不會自動撤銷。".into(),
);
}
for text in runtime.steering() {
runtime.emit(AgentEvent::Steering {
message: text.clone(),
});
messages.push(ChatMessage::user(text));
control_rounds = 0;
}
runtime.checkpoint(messages, None)?;
if let Some(team) = &tool_ctx.team {
team.ack_messages(messages)?;
}
let mut request_messages = messages.clone();
if let Some(team) = &tool_ctx.team {
if team.task.is_none() {
let starts = request_messages
.iter()
.enumerate()
.filter(|(_, m)| m.role == Role::User)
.map(|(i, _)| i)
.collect::<Vec<_>>();
if starts.len() > 20 {
request_messages.drain(1..starts[starts.len() - 20]);
}
}
request_messages.insert(
1.min(request_messages.len()),
ChatMessage::system(team.context()?),
);
}
truncate_messages(&mut request_messages, context_budget);
let plan = runtime.plan.lock().unwrap().clone();
if !plan.is_empty() {
request_messages.insert(
1.min(request_messages.len()),
ChatMessage::system(format!(
"Current task plan (runtime state): {}",
serde_json::to_string(&plan)?
)),
);
}
if let Some(command) = runtime.active_command.lock().unwrap().as_ref() {
request_messages.insert(1.min(request_messages.len()),ChatMessage::system(format!("Last command runtime state: {command}. Verify saved output if the command was interrupted.")));
}
if context_budget > 0 && messages_char_len(&request_messages) > context_budget {
break 'turn AgentVerdict::Failed("context budget exceeded; preserved system instructions and user goals. Increase GROKBOY_CONTEXT_CHARS or start a shorter task.".into());
}
emit_progress_line(
2026-09-13 16:38:32 +00:00
&format!("〔思考中〕第 {round}/{max_rounds_total} 輪…"),
runtime,
&mut on_progress,
);
2026-09-13 16:38:32 +00:00
let reply = match runtime
.wait("模型回應", complete(request_messages, Some(tools.clone())))
.await
{
Ok(reply) => reply,
Err(error) if runtime.cancelled() => {
break 'turn AgentVerdict::Cancelled(error.to_string())
}
2026-09-13 16:38:32 +00:00
Err(error) => {
break 'turn AgentVerdict::Failed(format!("model request failed: {error:#}"))
}
};
let calls = reply.tool_calls.clone().unwrap_or_default();
let mut steering = runtime.steering();
if let Some(team) = &tool_ctx.team {
steering.extend(team.take_messages()?);
}
2026-09-13 16:38:32 +00:00
if calls.is_empty() {
if !steering.is_empty() {
for text in steering {
runtime.emit(AgentEvent::Steering {
message: text.clone(),
});
messages.push(ChatMessage::user(text));
}
continue;
}
let text = reply.text().trim().to_string();
if text.is_empty() {
break 'turn AgentVerdict::Failed("model returned empty final answer".into());
}
if runtime.unfinished()
|| tool_ctx.jobs.active().await
|| tool_ctx.team.as_ref().is_some_and(|t| t.unfinished())
{
messages.push(reply);
messages.push(ChatMessage::system("The turn cannot finish while plan steps or a command remain active. Continue with tools, update the plan based on verified results, or report_blocked. Ask missing information with request_user_input."));
control_rounds += 1;
if control_rounds >= 3 {
break 'turn AgentVerdict::Blocked(
"repeated final answers while work remains unfinished".into(),
);
}
continue;
}
messages.push(reply);
break 'turn AgentVerdict::Answer(text);
}
// Display commentary before acting. It is not a final answer.
if !reply.text().trim().is_empty() {
runtime.emit(AgentEvent::Progress {
message: reply.text().trim().to_string(),
});
}
let names = calls
.iter()
.map(|c| c.function.name.as_str())
2026-09-13 16:38:32 +00:00
.collect::<Vec<_>>()
.join(", ");
emit_progress_line(
2026-09-13 16:38:32 +00:00
&format!("工具round {round}: {names}"),
runtime,
&mut on_progress,
);
2026-09-13 16:38:32 +00:00
messages.push(reply);
runtime.checkpoint(messages, None)?;
let mixed = calls.len() > 1
&& calls.iter().any(|c| {
is_completion_tool(&c.function.name)
|| matches!(
c.function.name.as_str(),
"request_user_input" | "request_user_confirm" | "browser_handoff"
)
});
let mut observation = round_signature(&calls);
let mut completion = None;
let mut actual_action = false;
let mut received_answer = false;
let mut controlled_wait = false;
for call in &calls {
steering.extend(runtime.steering());
if let Some(team) = &tool_ctx.team {
steering.extend(team.take_messages()?);
}
let skipped = mixed || !steering.is_empty() || runtime.cancelled();
let result = if skipped {
serde_json::json!({"error":if mixed {"completion and human-input tools must be called alone; this batch was not executed"} else {"not executed: new input or cancellation arrived; reconsider the next action"},"executed":false}).to_string()
} else {
runtime.checkpoint(messages, Some(&call.id))?;
runtime.emit(AgentEvent::ToolStarted {
id: call.id.clone(),
name: call.function.name.clone(),
});
let result = runtime
.wait(&call.function.name, async {
Ok(execute_tool(
tool_ctx,
&call.function.name,
&call.function.arguments,
)
.await)
})
.await;
match result {
Ok(result)=>result,
Err(error)=>serde_json::json!({"error":error.to_string(),"outcome":"unknown; observe before retrying"}).to_string(),
}
};
let value: Value = serde_json::from_str(&result).unwrap_or_default();
let success = value.get("error").is_none()
&& value["blocked"] != true
&& value["approved"] != false
&& !value["exit_code"].as_i64().is_some_and(|n| n != 0);
if !skipped {
actual_action |= !matches!(
call.function.name.as_str(),
"report_progress"
| "update_plan"
| "request_user_input"
| "report_done"
| "report_blocked"
);
received_answer |= (call.function.name == "request_user_input"
|| value["status"] == "replan")
&& success;
controlled_wait |= matches!(
call.function.name.as_str(),
"write_stdin" | "browser_wait" | "wait_task"
) && success;
if value["user_stopped"] == true {
completion = Some(AgentVerdict::Cancelled(
"使用者選擇停止這份工作;已執行的操作不會撤回。".into(),
));
} else if is_completion_tool(&call.function.name) {
completion = parse_completion_verdict(&call.function.name, &result);
}
runtime.emit(AgentEvent::ToolFinished {
id: call.id.clone(),
name: call.function.name.clone(),
success,
});
}
last_progress = tool_progress_line(&call.function.name, &result);
emit_progress_line(&last_progress, runtime, &mut on_progress);
observation.push_str(&result);
// Save full output first; failure to preserve it must not erase the actual tool result.
let stored = runtime
.save_output(&result)
.ok()
.flatten()
.unwrap_or(result);
messages.push(ChatMessage::tool(&call.id, stored));
*runtime.active_command.lock().unwrap() = tool_ctx.jobs.snapshot().await;
runtime.checkpoint(messages, None)?;
}
2026-09-13 16:38:32 +00:00
if runtime.cancelled() {
break 'turn AgentVerdict::Cancelled(
"已停止本回合session 可續跑;中斷操作的結果可能未知,續跑時先重新確認。"
.into(),
);
}
2026-09-13 16:38:32 +00:00
steering.extend(runtime.steering());
if let Some(team) = &tool_ctx.team {
steering.extend(team.take_messages()?);
}
if !steering.is_empty() {
for text in steering {
runtime.emit(AgentEvent::Steering {
message: text.clone(),
});
messages.push(ChatMessage::user(text));
}
2026-09-13 16:38:32 +00:00
control_rounds = 0;
repeat_count = 0;
continue;
}
if let Some(verdict) = completion {
2026-09-13 16:38:32 +00:00
break 'turn verdict;
}
2026-09-13 16:38:32 +00:00
if actual_action || received_answer {
control_rounds = 0;
} else {
2026-09-13 16:38:32 +00:00
control_rounds += 1;
}
if control_rounds >= 3 {
break 'turn AgentVerdict::Blocked("no progress: three rounds of only commentary/plan updates or invalid control calls".into());
}
if controlled_wait {
repeat_count = 0;
previous_observation.clear();
} else if observation == previous_observation {
repeat_count += 1;
} else {
previous_observation = observation;
repeat_count = 1;
}
if repeat_count >= LOOP_GUARD_REPEAT {
break 'turn AgentVerdict::Blocked(format!("loop guard: identical tool calls AND results repeated {LOOP_GUARD_REPEAT} times; change approach or provide new information"));
}
if round % max_rounds == 0 && round < max_rounds_total {
emit_progress_line(&format!("〔進度|尚未完成〕〔續跑〕已用 {round}/{max_rounds_total} 輪;{last_progress}"),runtime,&mut on_progress);
}
}
2026-09-13 16:38:32 +00:00
AgentVerdict::BudgetExhausted(format!("reached max rounds (total budget {max_rounds_total}) without completion; total budget exhausted. Progress so far: {last_progress}\nSession can be resumed; completion has not been verified."))
};
// Stop live foreground jobs on every terminal outcome; never leave hidden work running.
tool_ctx.jobs.cancel().await;
*runtime.active_command.lock().unwrap() = tool_ctx.jobs.snapshot().await;
runtime.checkpoint(messages, None)?;
runtime.emit(AgentEvent::TurnEnded {
verdict: verdict.kind().into(),
message: verdict.message().into(),
});
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
runtime,
&mut on_progress,
);
Ok(verdict)
}
fn parse_completion_verdict(name: &str, result_json: &str) -> Option<AgentVerdict> {
let v: Value = serde_json::from_str(result_json).ok()?;
2026-09-13 16:38:32 +00:00
if v.get("error").is_some() || v["status"] == "replan" {
return None; // Invalid arguments are tool feedback; allow the model to repair them.
}
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(),
},
}
}
2026-09-13 16:38:32 +00:00
// Check the protocol invariant on persisted history, including stopped runs.
fn assert_tool_results_paired(messages: &[ChatMessage]) {
for (i, msg) in messages.iter().enumerate() {
if let Some(calls) = &msg.tool_calls {
for (offset, call) in calls.iter().enumerate() {
let result = &messages[i + offset + 1];
assert_eq!(result.role, Role::Tool);
assert_eq!(result.tool_call_id.as_deref(), Some(call.id.as_str()));
}
}
}
}
#[tokio::test]
async fn mixed_completion_batch_never_executes_actions() {
let dir = std::env::temp_dir().join(format!("grokboy-mixed-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ToolContext::new(dir.clone());
let mut history = vec![ChatMessage::user("test")];
let verdict = run_agent_with(&mut history, &ctx, 1, 1, 100_000, |_, _| async {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![
tc("d", "report_done", r#"{"message":"done"}"#),
tc(
"w",
"write_file",
r#"{"path":"unexpected","content":"bad"}"#,
),
],
))
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::BudgetExhausted(_)));
assert!(!dir.join("unexpected").exists());
assert_tool_results_paired(&history);
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn changing_results_are_progress_and_tool_text_is_not_final() {
let dir = std::env::temp_dir().join(format!("grokboy-progress-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ToolContext::new(dir.clone());
let mut history = vec![ChatMessage::user("poll")];
let mut round = 0;
let file = dir.join("state");
let verdict = run_agent_with(&mut history, &ctx, 1, 6, 100_000, |_, tools| {
assert!(tools.is_some(), "progress must not call the model");
round += 1;
std::fs::write(&file, round.to_string()).unwrap();
let reply = if round == 5 {
ChatMessage::assistant("ready")
} else {
ChatMessage::assistant_tool_calls(
Some("checking".into()),
vec![tc("r", "read_file", r#"{"path":"state"}"#)],
)
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Answer("ready".into()));
assert_eq!(round, 5);
assert_tool_results_paired(&history);
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn provider_failure_preserves_completed_tool_history() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut history = vec![ChatMessage::user("test")];
let mut round = 0;
let verdict = run_agent_with(&mut history, &ctx, 1, 4, 100_000, |_, _| {
round += 1;
let reply = if round == 1 {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc("x", "unknown_tool", "{}")],
))
} else {
Err(anyhow::anyhow!("offline failure"))
};
async { reply }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Failed(_)));
assert_tool_results_paired(&history);
assert_eq!(round, 2);
}
#[tokio::test]
async fn invalid_done_arguments_can_be_repaired() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut history = vec![ChatMessage::user("test")];
let mut round = 0;
let verdict = run_agent_with(&mut history, &ctx, 1, 3, 100_000, |_, _| {
round += 1;
let args = if round == 1 {
r#"{"message":" "}"#
} else {
r#"{"message":"verified"}"#
};
async move {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc("d", "report_done", args)],
))
}
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Done("verified".into()));
assert_eq!(round, 2);
assert_tool_results_paired(&history);
}
#[tokio::test]
async fn context_limit_does_not_destroy_instructions_or_call_provider() {
let ctx = ToolContext::new(std::env::temp_dir());
let system = "system".repeat(100);
let goal = "goal".repeat(100);
let mut history = vec![ChatMessage::system(&system), ChatMessage::user(&goal)];
let verdict = run_agent_with(&mut history, &ctx, 1, 3, 100, |_, _| async {
panic!("over-budget context must not be sent");
#[allow(unreachable_code)]
Ok(ChatMessage::assistant("bad"))
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Failed(_)));
assert_eq!(history[0].text(), system);
assert_eq!(history[1].text(), goal);
}
#[tokio::test]
async fn commentary_precedes_tools_and_progress_alone_does_not_finish() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("task")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 6, 100_000, |_, _| {
round += 1;
let reply = match round {
1 => ChatMessage::assistant_tool_calls(
Some("I will inspect the environment".into()),
vec![tc(
"p",
"report_progress",
r#"{"message":"Starting the inspection"}"#,
)],
),
2 => ChatMessage::assistant_tool_calls(
None,
vec![tc("x", "list_dir", r#"{"path":"."}"#)],
),
_ => ChatMessage::assistant("finished"),
};
async { Ok(reply) }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Answer(_)));
assert_eq!(round, 3);
let events = ctx.runtime.events.lock().unwrap();
let commentary=events.iter().position(|e|matches!(e,crate::AgentEvent::Progress{message} if message=="I will inspect the environment")).unwrap();
let tool = events
.iter()
.position(|e| matches!(e, crate::AgentEvent::ToolStarted { .. }))
.unwrap();
assert!(commentary < tool);
assert_tool_results_paired(&messages);
}
#[tokio::test]
async fn commentary_loop_with_changing_text_stops_after_three_requests() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("task")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 20, 100_000, |_, _| {
round += 1;
let reply = ChatMessage::assistant_tool_calls(
None,
vec![tc(
"p",
"report_progress",
&json!({"message":format!("progress {round}")}).to_string(),
)],
);
async { Ok(reply) }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Blocked(_)));
assert_eq!(round, 3);
}
#[tokio::test]
async fn steering_skips_unstarted_tools_and_keeps_history_paired() {
let dir = std::env::temp_dir().join(format!("grokboy-steer-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let input = crate::InputBroker::new();
input.begin();
let runtime = crate::Runtime::with_input(input.clone());
let ctx = ToolContext::new(&dir).with_runtime(runtime);
let mut messages = vec![ChatMessage::user("original goal")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 6, 100_000, |request, _| {
round += 1;
let reply = if round == 1 {
input.feed("do not create the file".into());
ChatMessage::assistant_tool_calls(
None,
vec![tc("w", "write_file", r#"{"path":"bad","content":"oops"}"#)],
)
} else {
assert!(request.iter().any(|m| m.text() == "original goal"));
assert!(request.iter().any(|m| m.text() == "do not create the file"));
ChatMessage::assistant("understood")
};
async { Ok(reply) }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Answer(_)));
assert!(!dir.join("bad").exists());
assert_tool_results_paired(&messages);
std::fs::remove_dir_all(dir).unwrap();
}
#[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"));
2026-09-13 16:38:32 +00:00
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!(
2026-09-13 16:38:32 +00:00
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]
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")];
2026-09-13 16:38:32 +00:00
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();
2026-09-13 16:38:32 +00:00
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 {
2026-09-13 16:38:32 +00:00
AgentVerdict::BudgetExhausted(reason) => {
assert!(reason.contains("max rounds"), "{reason}");
assert!(reason.contains("Progress so far"), "{reason}");
2026-09-13 16:38:32 +00:00
assert!(reason.contains("read_file"), "{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"
);
2026-09-13 16:38:32 +00:00
// Budget counts every model call; progress never calls the model.
assert_eq!(*n.lock().unwrap(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
2026-09-13 16:38:32 +00:00
async fn budget_exhaustion_needs_no_summary_request() {
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 {
2026-09-13 16:38:32 +00:00
assert!(
tools.is_some(),
"budget exhausted: no summary request allowed"
);
Ok(ChatMessage::assistant_tool_calls(
None,
2026-09-13 16:38:32 +00:00
vec![tc("c1", "read_file", &json!({"path": "a.txt"}).to_string())],
))
}
})
.await
.unwrap();
match verdict {
2026-09-13 16:38:32 +00:00
AgentVerdict::BudgetExhausted(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")];
2026-09-13 16:38:32 +00:00
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() {
2026-09-13 16:38:32 +00:00
let _env = crate::test_env::lock_async().await;
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();
2026-09-13 16:38:32 +00:00
// Progress interval=3; on request 5 call report_done. No summary request.
let verdict = run_agent_with(&mut messages, &ctx, 3, 20, 100_000, move |_msgs, tools| {
let tool_rounds2 = tool_rounds2.clone();
async move {
2026-09-13 16:38:32 +00:00
assert!(tools.is_some(), "progress must not make summary requests");
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") };
}
2026-09-13 16:38:32 +00:00
/// Hits absolute total ceiling -> BudgetExhausted with local progress.
#[tokio::test]
2026-09-13 16:38:32 +00:00
async fn total_ceiling_reports_runtime_progress() {
let _env = crate::test_env::lock_async().await;
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();
2026-09-13 16:38:32 +00:00
// interval=2, total=4 -> BudgetExhausted (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 {
2026-09-13 16:38:32 +00:00
assert!(tools.is_some(), "progress must not make summary requests");
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 {
2026-09-13 16:38:32 +00:00
AgentVerdict::BudgetExhausted(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}");
2026-09-13 16:38:32 +00:00
assert!(reason.contains("read_file"), "{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() {
2026-09-13 16:38:32 +00:00
let _env = crate::test_env::lock_async().await;
unsafe { std::env::set_var("GROKBOY_PROGRESS", "0") };
2026-09-13 16:38:32 +00:00
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,
2026-09-13 16:38:32 +00:00
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!(
2026-09-13 16:38:32 +00:00
lines
.iter()
.any(|l| l.contains("〔開始〕") && l.contains("最多")),
"missing start: {lines:?}"
);
assert!(
2026-09-13 16:38:32 +00:00
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() {
2026-09-13 16:38:32 +00:00
let _env = crate::test_env::lock_async().await;
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);
2026-09-13 16:38:32 +00:00
assert_tool_results_paired(&messages);
let _ = std::fs::remove_dir_all(&dir);
unsafe { std::env::remove_var("GROKBOY_PROGRESS") };
}
}