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

2222 lines
93 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-14 09:08:35 +00:00
use crate::model::{ChatMessage, Role, ToolCall};
use crate::runtime::AgentEvent;
use crate::tools::{
execute_tool, is_completion_tool, is_delivery_tool, is_parallel_safe, tool_definitions_for,
ToolContext,
};
use futures_util::future::join_all;
use anyhow::Result;
use serde_json::Value;
2026-09-14 09:08:35 +00:00
use std::collections::HashMap;
use std::future::Future;
2026-09-14 09:08:35 +00:00
/// Local progress beat. No extra model call is made for progress.
pub const DEFAULT_MAX_ROUNDS: usize = 12;
2026-09-14 09:08:35 +00:00
/// Absolute model-request ceiling per user turn (Grok Bot Sand is 5000 steps).
pub const DEFAULT_MAX_ROUNDS_TOTAL: usize = 5_000;
pub const DEFAULT_CONTEXT_CHARS: usize = 100_000;
2026-09-14 09:08:35 +00:00
pub const LOOP_GUARD_REMIND: usize = 2;
pub const LOOP_GUARD_REPEAT: usize = 3;
2026-09-14 09:08:35 +00:00
pub const SEND_MESSAGE_SILENCE_THRESHOLD: usize = 6;
pub const EMPTY_RESPONSE_RETRIES: usize = 3;
const START_OF_TURN_ACK_REMINDER: &str = "<system_reminder>\nYou opened this turn by calling tools without first acknowledging the user. Invoke send_message NOW with a one-line text acknowledgement before any further tool call. Plain assistant text is never shown; only send_message reaches the user.\n</system_reminder>";
const SILENCE_REMINDER: &str = "<system_reminder>\nYou have made several tool calls without send_message, so the user is watching silence. Invoke send_message with a brief, specific update on what you are doing or just found, then continue.\n</system_reminder>";
const EMPTY_RESPONSE_CONTINUATION: &str = "<system_reminder>Please continue. Send a send_message to the user or make tool calls.</system_reminder>";
const LOOP_REMINDER: &str = "<system_reminder>Your last tool calls and results repeated. Change approach, inspect new evidence, or call report_blocked. Do not retry the same action unchanged.</system_reminder>";
const DELIVER_THEN_YIELD_REMINDER: &str = "<system_reminder>If you have a result the user is waiting on, send it with send_message, then end the turn by responding with no tool calls. Plain assistant text is not delivered.</system_reminder>";
const PLAN_STEP_DONE_REMINDER: &str = "<system_reminder>\nYou marked a plan step completed. send_message the user NOW with what you found and which step is next. Do not continue silent tool work until you have reported this step.\n</system_reminder>";
pub const AGENT_SYSTEM: &str = "\
2026-09-14 09:08:35 +00:00
You are GrokBoy, a warm, concise local Grok Bot-style agent.
## How a turn works
1. Reply first. On any turn a person opened, your first action is send_message (plain text): answer directly if it is quick, or acknowledge and name the first step if it is real work. Never open such a turn with a tool call. A hidden revival (a background command finishing) may start work silently if there is nothing to report.
2. Pick the surface. Default to shell and read on your Docker computer; web_search/web_fetch for public information; the box browser for login-gated sites; shell/read to inspect box files; send_message widget or request_user_input only when the user must decide.
3. Work out loud. Keep the user posted with send_message on meaningful beats (a result, decision, blocker, change of plan). Do not vanish into a long silent run of tool calls.
4. Close the loop. Deliver the result in send_message. Then end the turn by responding with NO tool calls.
## Plans
When the work has stages, keep a short update_plan. After you mark a step completed, send_message in that same moment with the finding (what you learned) and the next step. The user should see a report after every completed line, not one dump at the end. Do not mark completed until you actually have the result.
## send_message is your only voice
Plain assistant text is a private scratchpad the user never sees. Nothing is delivered until it is send_message content replies, progress, questions, and results. An opening acknowledgement does not discharge delivery: ack delivery. If you ran something for the user, send the actual output in send_message before you yield.
Deciding to send is not sending. When a message is owed, invoke send_message in that same step.
## Ending a turn
Do not call report_done to finish ordinary work. After send_message, respond with no tools. report_done is accepted but unnecessary. report_blocked is for being stuck, with 23 concrete alternative routes.
A send_message widget, request_user_input, request_user_confirm, browser_handoff, or request_box_help must be called ALONE. They end the turn; the user's next message is the answer. Do not sit in a timeout waiting for a human.
## Commands
shell is the default command tool on your box. It waits briefly then returns a session_id if still running; the command is not killed. Use await_shell for its result when needed. Never rerun a slow box command with an external tool.
external_exec_command waits up to block_until_ms (default 30000ms) then backgrounds. Set 0 to background immediately (dev servers, watchers). Use external_await_command or external_write_stdin to observe. Do not sit in a tight poll loop if you can keep working or end the turn; the command keeps running. If you yield while a command is still running, you are revived automatically when it exits.
## Delegating background work
Use spawn_subagent for a self-contained chunk (research, files, a multi-step investigation). It returns immediately with subagent_id. After dispatch, do not sit idle: send_message that you kicked it off, keep working, or end the turn with no tool calls. You are revived automatically when it finishes never poll check_subagent for completion. check_subagent inspects a running child that may be stuck. message_subagent injects an instruction without aborting. stop_subagent aborts one. This revival is self-triggered, not someone reaching out; if the result is irrelevant and the user was not waiting, end with no send_message.
kind=computerUse delegates a GUI/desktop task that drives MY computer by screenshot, click, drag, type, key, scroll, and wait. Only one computerUse may run at a time (they share the screen); while it runs, leave the screen to it and use screenshot only to check in. Scope the goal tightly site, exact values, success criteria, stopping point. If it needs a human (password, 2FA, captcha, payment), it stops and reports; then you call request_box_help and dispatch it again to continue.
## Two computers
You have two machines. To the user, call the box \"my computer\" and the launch machine \"your computer\". Never mix paths.
- shell / read / await_shell / screenshot run on MY computer (Docker Linux). Scratch is /workspace; profile is /home/box. Use this for installs, experiments, and login-gated sites that have no MCP. First use may take a while to boot tell the user.
- external_exec_command / external_read_file / external_edit_file / external_write_file run on the USER's computer only when the user specifically asks for their local files or machine. Do not use external tools for ordinary research, scratch files, installs or experiments. The default filesystem is the box; transfer finished deliverables with copy_from_box.
- copy_to_box / copy_from_box move files between the two filesystems. A box path is invisible to external_exec_command; a user path is invisible to shell.
- screenshot is read-only. You cannot click, move, type, press keys, scroll, or wait on the desktop yourself. Delegate every desktop interaction to spawn_subagent kind=computerUse; like any subagent it runs in the background, so you keep working and are revived with its result. Do not bypass this boundary with shell-driven GUI automation such as xdotool, or by driving the box browser from shell no CDP attach, no Playwright/Puppeteer from the parent, no /json/new. Prefer browser_* (DOM snapshot + selector/role) for ordinary web; use computerUse for GUI apps, file dialogs, drag, or a site that defeats page-level automation.
- When the user must type a password or 2FA, call request_box_help (ends the turn) and give a one-line instruction; they open the same computer in a browser. Do not first ask whether to hand it over. Do not launch a separate browser/profile. Browser upload/download paths are box paths under /workspace; use copy_to_box/copy_from_box for local files.
## Files and web
2026-09-14 09:13:10 +00:00
external_read_file pages by line. external_grep uses regex; external_glob finds names; external_search_files is literal. external_edit_file needs one unique exact match. web_fetch is a fast anonymous HTTP GET from this host with HTML reduced to text; it never carries browser cookies, and repeated URLs are served from a short cache, so fetch each page once. Only when a site blocks plain HTTP does it fall back to a model-rendered summary marked content_kind=model_rendered_web_content, which is not verbatim source. web_search uses a separate search service (configured AiService gateway or native xAI web search with the existing official xAI model configuration). If that service is unconfigured, report the configuration error; do not silently search using a logged-in browser. browser_* defaults to Docker Chromium and shares its /home/box/chrome-profile with request_box_help and the desktop. Your normal local Chrome profile is not imported. GROKBOY_BROWSER_SURFACE=local explicitly selects the legacy separate local browser, never an automatic fallback. Use the box browser for authenticated pages. Prefer DOM snapshot + selector/role, not pixel clicks. Re-observe after navigation or resume.
2026-09-14 09:08:35 +00:00
## MCP
Installed connectors are structured access to services (mail, issues, docs). Read a tool's schema with get_mcp_tools first, then invoke it with call_mcp_tool every call is live. Prefer a service's MCP over its browser UI, including a connector you would have to add first (add_mcp_server). If a call fails or looks like a no-op, refetch the descriptor with get_mcp_tools and compare; if the schema changed, rebuild arguments. Ask the user for secrets rather than guessing. Config lives in ~/.grokboy/mcp.json.
## Collaboration and safety
A blocked step is not a failed task. For login/OTP/captcha use browser_handoff on the SAME browser. Never collect passwords in chat. Never publish or take irreversible public actions without explicit approval this turn or request_user_confirm. Traditional Chinese is welcome when the user writes in Chinese. Do not invent tool results.";
/// 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),
2026-09-14 09:08:35 +00:00
/// Parked for a human reply or background work. Next user line or a revival continues.
Waiting(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)
2026-09-14 09:08:35 +00:00
| Self::Cancelled(s)
| Self::Waiting(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",
2026-09-14 09:08:35 +00:00
Self::Waiting(_) => "waiting",
}
}
}
/// 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();
2026-09-14 09:08:35 +00:00
let shared: crate::tools::SharedCompleter = std::sync::Arc::new(move |msgs, tools| {
let config = config.clone();
Box::pin(async move {
crate::model::chat_completion_for(&config, &msgs, tools.as_ref()).await
})
});
*tool_ctx.model.lock().unwrap() = Some(shared.clone());
run_agent_with(
messages,
tool_ctx,
max_rounds,
total,
budget,
2026-09-14 09:08:35 +00:00
move |msgs, tools| shared(msgs, tools),
)
.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
let tools = if let Some(team) = &tool_ctx.team {
crate::team::worker::definitions(team.task.is_none())
} else {
2026-09-14 09:08:35 +00:00
tool_definitions_for(tool_ctx)
2026-09-13 16:38:32 +00:00
};
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 last_progress = String::new();
2026-09-14 09:08:35 +00:00
let mut sent_this_turn = false;
let mut tools_this_turn = 0usize;
let mut non_send_since_send = 0usize;
let mut ack_reminded = false;
let mut silence_reminded = false;
let mut delivery_reminded = false;
let mut empty_retries = 0usize;
emit_progress_line(
2026-09-14 09:08:35 +00:00
&format!("〔開始〕最多 {max_rounds_total}Grok Bot 式:無工具即結束;每 {max_rounds} 步顯示進度)"),
2026-09-13 16:38:32 +00:00
runtime,
&mut on_progress,
);
2026-09-13 16:38:32 +00:00
let verdict = 'turn: {
2026-09-14 09:08:35 +00:00
if let Some(stopped) = apply_pending_human_answer(runtime, messages) {
break 'turn stopped;
}
if let Some(report) = tool_ctx.subagents.take_completed() {
messages.push(ChatMessage::user(crate::subagents::revival_message(&report)));
}
if let Some(snapshot) = tool_ctx.jobs.take_unreaped_exit() {
messages.push(ChatMessage::user(
crate::subagents::command_revival_message(&snapshot),
));
}
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));
2026-09-14 09:08:35 +00:00
}
if !sent_this_turn && tools_this_turn > 0 && !ack_reminded {
messages.push(ChatMessage::user(START_OF_TURN_ACK_REMINDER));
ack_reminded = true;
} else if non_send_since_send > SEND_MESSAGE_SILENCE_THRESHOLD && !silence_reminded {
messages.push(ChatMessage::user(SILENCE_REMINDER));
silence_reminded = true;
2026-09-13 16:38:32 +00:00
}
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 {
2026-09-14 09:08:35 +00:00
compact_tool_history(messages, context_budget);
truncate_messages(&mut request_messages, context_budget);
}
if context_budget > 0 && messages_char_len(&request_messages) > context_budget {
break 'turn AgentVerdict::Failed("context budget exceeded after compaction; preserved system instructions and user goals. Increase GROKBOY_CONTEXT_CHARS or start a shorter task.".into());
2026-09-13 16:38:32 +00:00
}
emit_progress_line(
2026-09-14 09:08:35 +00:00
&format!("〔思考中〕第 {round}/{max_rounds_total} 步…"),
2026-09-13 16:38:32 +00:00
runtime,
&mut on_progress,
);
2026-09-14 09:08:35 +00:00
// Grok Bot style first-visible latency: the reply streams in and a text
// `send_message` runs the moment its arguments close, instead of after the
// model has also finished writing every other tool call in the same reply.
let (stream_tx, mut stream_rx) =
tokio::sync::mpsc::unbounded_channel::<crate::model::StreamEvent>();
let sink: crate::model::StreamSink = std::sync::Arc::new(move |event| {
let _ = stream_tx.send(event);
});
let completion = crate::model::with_stream_sink(
sink,
complete(request_messages, Some(tools.clone())),
);
let mut eager: HashMap<String, String> = HashMap::new();
let outcome = {
let eager = &mut eager;
let on_progress = &mut on_progress;
runtime
.wait("模型回應", async move {
tokio::pin!(completion);
let mut result = None;
loop {
let event = if result.is_none() {
tokio::select! {
r = &mut completion => { result = Some(r); continue; }
e = stream_rx.recv() => e,
}
} else {
stream_rx.try_recv().ok()
};
let Some(event) = event else {
return match result {
Some(r) => r,
None => completion.await,
};
};
match event {
crate::model::StreamEvent::TextDelta(_) => {}
crate::model::StreamEvent::ToolCallStarted { name, .. } => {
if name == "send_message" {
emit_progress_line("〔輸入中〕…", runtime, on_progress);
}
}
crate::model::StreamEvent::ToolCallReady(call) => {
if eager_deliverable(&call) {
let result = run_one_tool(tool_ctx, runtime, &call).await;
emit_progress_line(
&tool_progress_line(&call.function.name, &result),
runtime,
on_progress,
);
eager.insert(call.id.clone(), result);
}
}
}
}
})
.await
};
let reply = match outcome {
2026-09-13 16:38:32 +00:00
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();
2026-09-14 09:08:35 +00:00
if text.is_empty() && runtime.last_delivered().is_none() {
empty_retries += 1;
if empty_retries <= EMPTY_RESPONSE_RETRIES {
messages.push(ChatMessage::user(EMPTY_RESPONSE_CONTINUATION));
continue;
}
2026-09-13 16:38:32 +00:00
break 'turn AgentVerdict::Failed("model returned empty final answer".into());
}
2026-09-14 09:08:35 +00:00
if tools_this_turn > 0
&& non_send_since_send > 0
&& !delivery_reminded
&& !tool_ctx.subagents.has_running()
&& !tool_ctx.jobs.active().await
2026-09-13 16:38:32 +00:00
{
messages.push(reply);
2026-09-14 09:08:35 +00:00
messages.push(ChatMessage::user(DELIVER_THEN_YIELD_REMINDER));
delivery_reminded = true;
2026-09-13 16:38:32 +00:00
continue;
}
messages.push(reply);
2026-09-14 09:08:35 +00:00
if tool_ctx.hold_background {
if let Some(report) = tool_ctx.subagents.take_completed() {
messages.push(ChatMessage::user(crate::subagents::revival_message(&report)));
sent_this_turn = false;
tools_this_turn = 0;
ack_reminded = true;
silence_reminded = false;
continue;
}
if let Some(snapshot) = tool_ctx.jobs.take_unreaped_exit() {
messages.push(ChatMessage::user(
crate::subagents::command_revival_message(&snapshot),
));
sent_this_turn = false;
tools_this_turn = 0;
ack_reminded = true;
silence_reminded = false;
continue;
}
} else if tool_ctx.subagents.has_running()
|| tool_ctx.jobs.active().await
|| tool_ctx.jobs.has_unreaped_exit()
|| tool_ctx.subagents.has_completed()
{
break 'turn AgentVerdict::Waiting(
runtime
.last_delivered()
.unwrap_or_else(|| "背景工作進行中。完成後會自動繼續,你也可以先說話。".into()),
);
}
if tool_ctx.hold_background
&& (tool_ctx.subagents.has_running() || tool_ctx.jobs.active().await)
{
match wait_for_background(tool_ctx, runtime, &mut on_progress).await {
BackgroundWait::Cancelled(message) => {
break 'turn AgentVerdict::Cancelled(message)
}
BackgroundWait::Steering(lines) => {
for text in lines {
runtime.emit(AgentEvent::Steering {
message: text.clone(),
});
messages.push(ChatMessage::user(text));
}
sent_this_turn = false;
tools_this_turn = 0;
ack_reminded = true;
continue;
}
BackgroundWait::Revived(message) => {
messages.push(ChatMessage::user(message));
sent_this_turn = false;
tools_this_turn = 0;
ack_reminded = true;
silence_reminded = false;
continue;
}
}
}
// Only the real end of the turn may promote scratch to user-visible delivery.
if non_send_since_send > 0 && text.is_empty() {
break 'turn AgentVerdict::Failed(
"model ended after tool work without delivering a result".into(),
);
}
if (!sent_this_turn || non_send_since_send > 0) && !text.is_empty() {
runtime.deliver(&text);
}
break 'turn AgentVerdict::Answer(
runtime
.last_delivered()
.unwrap_or(text)
);
2026-09-13 16:38:32 +00:00
}
2026-09-14 09:08:35 +00:00
empty_retries = 0;
2026-09-13 16:38:32 +00:00
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
2026-09-14 09:08:35 +00:00
&& calls.iter().any(|c| is_completion_tool(&c.function.name));
2026-09-13 16:38:32 +00:00
let mut observation = round_signature(&calls);
let mut completion = None;
let mut controlled_wait = false;
2026-09-14 09:08:35 +00:00
let mut plan_needs_report = false;
let mut batch_delivered = false;
let skip_all = mixed || !steering.is_empty() || runtime.cancelled();
let (executed, batch_steering) =
execute_tool_batch(tool_ctx, runtime, &calls, skip_all, mixed, &eager).await;
steering.extend(batch_steering);
for (call, result) in calls.iter().zip(executed) {
let ran_early = eager.contains_key(&call.id);
2026-09-13 16:38:32 +00:00
let value: Value = serde_json::from_str(&result).unwrap_or_default();
2026-09-14 09:08:35 +00:00
let skipped = value["executed"] == false && value.get("error").is_some();
2026-09-13 16:38:32 +00:00
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 {
2026-09-14 09:08:35 +00:00
tools_this_turn += 1;
if is_delivery_tool(&call.function.name) && success {
sent_this_turn = true;
batch_delivered = true;
non_send_since_send = 0;
delivery_reminded = false;
} else {
non_send_since_send += 1;
}
2026-09-13 16:38:32 +00:00
controlled_wait |= matches!(
call.function.name.as_str(),
2026-09-14 09:08:35 +00:00
"external_write_stdin" | "external_await_command" | "browser_wait" | "wait_task"
2026-09-13 16:38:32 +00:00
) && success;
2026-09-14 09:08:35 +00:00
if value["must_send_message"] == true
&& value["just_completed"]
.as_array()
.is_some_and(|a| !a.is_empty())
{
plan_needs_report = true;
}
2026-09-13 16:38:32 +00:00
if value["user_stopped"] == true {
completion = Some(AgentVerdict::Cancelled(
"使用者選擇停止這份工作;已執行的操作不會撤回。".into(),
));
2026-09-14 09:08:35 +00:00
} else if value["yield_turn"] == true {
let prompt = value["question"]["question"]
.as_str()
.map(str::to_string)
.or_else(|| runtime.last_delivered())
.unwrap_or_else(|| "等待你的回覆".into());
completion = Some(AgentVerdict::Waiting(prompt));
} else if matches!(call.function.name.as_str(), "report_done" | "report_blocked")
{
2026-09-13 16:38:32 +00:00
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);
2026-09-14 09:08:35 +00:00
if !ran_early {
emit_progress_line(&last_progress, runtime, &mut on_progress);
}
2026-09-13 16:38:32 +00:00
observation.push_str(&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
repeat_count = 0;
continue;
}
if let Some(verdict) = completion {
2026-09-13 16:38:32 +00:00
break 'turn verdict;
}
2026-09-14 09:08:35 +00:00
if plan_needs_report && !batch_delivered {
messages.push(ChatMessage::user(PLAN_STEP_DONE_REMINDER));
2026-09-13 16:38:32 +00:00
}
if controlled_wait {
repeat_count = 0;
previous_observation.clear();
} else if observation == previous_observation {
repeat_count += 1;
} else {
previous_observation = observation;
repeat_count = 1;
}
2026-09-14 09:08:35 +00:00
if repeat_count == LOOP_GUARD_REMIND {
messages.push(ChatMessage::user(LOOP_REMINDER));
}
2026-09-13 16:38:32 +00:00
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 {
2026-09-14 09:08:35 +00:00
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."))
};
2026-09-14 09:08:35 +00:00
if matches!(
verdict,
AgentVerdict::Cancelled(_)
| AgentVerdict::Failed(_)
| AgentVerdict::BudgetExhausted(_)
| AgentVerdict::Blocked(_)
) {
tool_ctx.subagents.cancel_all().await;
}
if matches!(verdict, AgentVerdict::Cancelled(_)) {
tool_ctx.jobs.cancel().await;
}
2026-09-13 16:38:32 +00:00
*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)
}
2026-09-14 09:08:35 +00:00
fn compact_tool_history(messages: &mut Vec<ChatMessage>, budget: usize) {
truncate_messages(messages, budget);
}
#[allow(clippy::ptr_arg)]
fn apply_pending_human_answer(
runtime: &crate::Runtime,
messages: &mut Vec<ChatMessage>,
) -> Option<AgentVerdict> {
let question = runtime.pending_question.lock().unwrap().clone()?;
let last = messages.last()?;
if last.role != Role::User || last.text().contains("self-triggered revival") {
return None;
}
let text = last.text().to_string();
*runtime.pending_question.lock().unwrap() = None;
let answer = crate::Runtime::resolve_answer_line(&question, &text);
let kind = question["kind"].as_str().unwrap_or("");
let stopped = matches!(
answer.as_str(),
"停止這份工作" | "abort" | "cancel" | "stop"
) || (kind == "confirm"
&& matches!(answer.to_ascii_lowercase().as_str(), "no" | "abort" | "n"));
if stopped && matches!(kind, "handoff" | "recovery" | "confirm" | "box_help") {
return Some(AgentVerdict::Cancelled(
"使用者選擇停止這份工作。".into(),
));
}
if let Some(last) = messages.last_mut() {
last.content = Some(format!(
"Reply to the previously unanswered question {question}: {answer}"
));
}
None
}
enum BackgroundWait {
Revived(String),
Steering(Vec<String>),
Cancelled(String),
}
async fn wait_for_background(
tool_ctx: &ToolContext,
runtime: &crate::Runtime,
on_progress: &mut impl FnMut(&str),
) -> BackgroundWait {
let start = std::time::Instant::now();
emit_progress_line("〔等待〕背景工作完成後會自動喚醒…", runtime, on_progress);
loop {
if runtime.cancelled() {
return BackgroundWait::Cancelled(
"已停止本回合session 可續跑。已執行的操作不會自動撤銷。".into(),
);
}
let mut steering = runtime.steering();
if let Some(team) = &tool_ctx.team {
if let Ok(messages) = team.take_messages() {
steering.extend(messages);
}
}
if !steering.is_empty() {
return BackgroundWait::Steering(steering);
}
if let Some(report) = tool_ctx.subagents.take_completed() {
return BackgroundWait::Revived(crate::subagents::revival_message(&report));
}
if let Some(snapshot) = tool_ctx.jobs.take_unreaped_exit() {
return BackgroundWait::Revived(crate::subagents::command_revival_message(&snapshot));
}
let job_running = tool_ctx.jobs.active().await;
if !tool_ctx.subagents.has_running() && !job_running {
return BackgroundWait::Revived(
"<system_reminder>\nBackground work ended with no report.\n</system_reminder>"
.into(),
);
}
runtime.emit(AgentEvent::Waiting {
stage: "background work".into(),
elapsed_secs: start.elapsed().as_secs(),
});
tokio::select! {
biased;
_ = async {
match &runtime.input {
Some(input) => input.cancellation().await,
None => std::future::pending().await,
}
} => {}
_ = tool_ctx.subagents.notified() => {}
_ = tool_ctx.jobs.notify.notified() => {}
_ = tokio::time::sleep(std::time::Duration::from_millis(200)) => {}
}
}
}
fn take_live_steering(tool_ctx: &ToolContext, runtime: &crate::Runtime) -> Vec<String> {
let mut steering = runtime.steering();
if let Some(team) = &tool_ctx.team {
if let Ok(messages) = team.take_messages() {
steering.extend(messages);
}
}
steering
}
fn skipped_tool_result(message: &str) -> String {
serde_json::json!({"error":message,"executed":false}).to_string()
}
/// A plain-text `send_message` may run while the reply is still streaming: it only
/// delivers text, so executing it before its sibling tool calls exist changes nothing.
fn eager_deliverable(call: &ToolCall) -> bool {
call.function.name == "send_message"
&& serde_json::from_str::<Value>(&call.function.arguments)
.is_ok_and(|args| args["type"] != "widget" && args.get("widget").is_none())
}
async fn execute_tool_batch(
tool_ctx: &ToolContext,
runtime: &crate::Runtime,
calls: &[ToolCall],
skip_all: bool,
mixed: bool,
already_ran: &HashMap<String, String>,
) -> (Vec<String>, Vec<String>) {
let mut extra_steering = Vec::new();
if skip_all {
let msg = 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"
};
return (
calls
.iter()
.map(|c| already_ran.get(&c.id).cloned().unwrap_or_else(|| skipped_tool_result(msg)))
.collect(),
extra_steering,
);
}
let mut results = vec![String::new(); calls.len()];
let mut index = 0;
let mut skip_rest = None;
while index < calls.len() {
if let Some(result) = already_ran.get(&calls[index].id) {
results[index] = result.clone();
index += 1;
continue;
}
extra_steering.extend(take_live_steering(tool_ctx, runtime));
if runtime.cancelled() {
skip_rest = Some("cancelled");
} else if !extra_steering.is_empty() {
skip_rest = Some(
"not executed: new input or cancellation arrived; reconsider the next action",
);
}
if let Some(msg) = skip_rest {
results[index] = skipped_tool_result(msg);
index += 1;
continue;
}
if !is_parallel_safe(&calls[index].function.name) {
results[index] = run_one_tool(tool_ctx, runtime, &calls[index]).await;
index += 1;
continue;
}
let start = index;
while index < calls.len()
&& is_parallel_safe(&calls[index].function.name)
&& !already_ran.contains_key(&calls[index].id)
{
index += 1;
}
let futs = calls[start..index]
.iter()
.map(|call| run_one_tool(tool_ctx, runtime, call));
let group = join_all(futs).await;
for (offset, result) in group.into_iter().enumerate() {
results[start + offset] = result;
}
}
extra_steering.extend(take_live_steering(tool_ctx, runtime));
(results, extra_steering)
}
async fn run_one_tool(
tool_ctx: &ToolContext,
runtime: &crate::Runtime,
call: &ToolCall,
) -> String {
runtime.emit(AgentEvent::ToolStarted {
id: call.id.clone(),
name: call.function.name.clone(),
});
match runtime
.wait(&call.function.name, async {
Ok(execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await)
})
.await
{
Ok(result) => result,
Err(error) => serde_json::json!({
"error": error.to_string(),
"outcome": "unknown; observe before retrying"
})
.to_string(),
}
}
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",
2026-09-14 09:08:35 +00:00
"external_write_file",
2026-09-13 16:38:32 +00:00
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();
2026-09-14 09:08:35 +00:00
let reply = if round >= 5 {
2026-09-13 16:38:32 +00:00
ChatMessage::assistant("ready")
} else {
ChatMessage::assistant_tool_calls(
Some("checking".into()),
2026-09-14 09:08:35 +00:00
vec![tc("r", "external_read_file", r#"{"path":"state"}"#)],
2026-09-13 16:38:32 +00:00
)
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Answer("ready".into()));
2026-09-14 09:08:35 +00:00
assert!(round >= 5, "round={round}");
2026-09-13 16:38:32 +00:00
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,
2026-09-14 09:08:35 +00:00
vec![tc("x", "external_list_dir", r#"{"path":"."}"#)],
2026-09-13 16:38:32 +00:00
),
_ => ChatMessage::assistant("finished"),
};
async { Ok(reply) }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Answer(_)));
2026-09-14 09:08:35 +00:00
assert_eq!(round, 4);
assert_eq!(verdict, AgentVerdict::Answer("finished".into()));
2026-09-13 16:38:32 +00:00
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]
2026-09-14 09:08:35 +00:00
async fn commentary_loop_with_changing_text_hits_budget_not_false_block() {
2026-09-13 16:38:32 +00:00
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("task")];
let mut round = 0;
2026-09-14 09:08:35 +00:00
let verdict = run_agent_with(&mut messages, &ctx, 12, 4, 100_000, |_, _| {
2026-09-13 16:38:32 +00:00
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();
2026-09-14 09:08:35 +00:00
assert!(matches!(verdict, AgentVerdict::BudgetExhausted(_)));
assert_eq!(round, 4);
2026-09-13 16:38:32 +00:00
}
2026-09-14 09:08:35 +00:00
#[tokio::test]
async fn send_message_then_no_tools_ends_turn() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("hi")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 6, 100_000, |_, _| {
round += 1;
let reply = if round == 1 {
ChatMessage::assistant_tool_calls(
None,
vec![tc(
"s",
"send_message",
r#"{"type":"text","content":"hello from grokbot loop"}"#,
)],
)
} else {
ChatMessage::assistant("scratchpad")
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(
verdict,
AgentVerdict::Answer("hello from grokbot loop".into())
);
assert_eq!(round, 2);
let events = ctx.runtime.events.lock().unwrap();
let delivered: Vec<&str> = events
.iter()
.filter_map(|e| match e {
crate::AgentEvent::Message { content } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(delivered, ["hello from grokbot loop"]);
assert!(!delivered.iter().any(|c| c.contains("scratchpad")));
}
#[tokio::test]
async fn human_question_parks_and_next_message_is_the_answer() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("ask me")];
let verdict = run_agent_with(&mut messages, &ctx, 12, 4, 100_000, |_, _| async {
Ok(ChatMessage::assistant_tool_calls(
None,
vec![tc(
"q",
"request_user_input",
r#"{"question":"Which label?","options":["alpha","beta"]}"#,
)],
))
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Waiting(_)));
assert!(ctx.runtime.pending_question.lock().unwrap().is_some());
messages.push(ChatMessage::user("2"));
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 4, 100_000, |_, _| {
round += 1;
let reply = if round == 1 {
ChatMessage::assistant_tool_calls(
None,
vec![tc(
"s",
"send_message",
r#"{"type":"text","content":"got beta"}"#,
)],
)
} else {
ChatMessage::assistant("scratch")
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Answer("got beta".into()));
assert!(ctx.runtime.pending_question.lock().unwrap().is_none());
assert!(messages.iter().any(|m| m.text().contains("beta")));
}
#[tokio::test]
async fn completing_a_plan_step_requires_a_user_report() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("research")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 8, 100_000, |msgs, _| {
round += 1;
let reply = match round {
1 => ChatMessage::assistant_tool_calls(
None,
vec![tc(
"p1",
"update_plan",
&json!({"plan":[
{"step":"查規則","status":"in_progress"},
{"step":"整理路徑","status":"pending"}
]})
.to_string(),
)],
),
2 => ChatMessage::assistant_tool_calls(
None,
vec![tc(
"p2",
"update_plan",
&json!({"plan":[
{"step":"查規則","status":"completed"},
{"step":"整理路徑","status":"in_progress"}
]})
.to_string(),
)],
),
3 => {
assert!(
msgs.iter().any(|m| m.text().contains("marked a plan step completed")),
"expected plan-step report reminder, got {msgs:?}"
);
ChatMessage::assistant_tool_calls(
None,
vec![tc(
"s",
"send_message",
r#"{"type":"text","content":"規則查完了:資格要先申請。下一步整理賺錢路徑。"}"#,
)],
)
}
_ => ChatMessage::assistant("scratch"),
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(
verdict,
AgentVerdict::Answer("規則查完了:資格要先申請。下一步整理賺錢路徑。".into())
);
}
2026-09-13 16:38:32 +00:00
#[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,
2026-09-14 09:08:35 +00:00
vec![tc("w", "external_write_file", r#"{"path":"bad","content":"oops"}"#)],
2026-09-13 16:38:32 +00:00
)
} 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() {
2026-09-14 09:08:35 +00:00
assert!(AGENT_SYSTEM.contains("send_message"));
assert!(AGENT_SYSTEM.contains("external_exec_command"));
assert!(AGENT_SYSTEM.contains("report_blocked"));
assert!(AGENT_SYSTEM.contains("request_user_confirm"));
assert!(AGENT_SYSTEM.contains("browser_handoff"));
2026-09-14 09:08:35 +00:00
assert!(AGENT_SYSTEM.contains("web_fetch"));
assert!(AGENT_SYSTEM.contains("spawn_subagent"));
assert!(AGENT_SYSTEM.contains("get_mcp_tools"));
assert!(AGENT_SYSTEM.contains("call_mcp_tool"));
assert!(AGENT_SYSTEM.contains("shell"));
assert!(AGENT_SYSTEM.contains("request_box_help"));
assert!(AGENT_SYSTEM.contains("after every completed line") || AGENT_SYSTEM.contains("report after every completed"));
assert!(AGENT_SYSTEM.contains("irreversible") || AGENT_SYSTEM.contains("Never publish"));
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
2026-09-14 09:08:35 +00:00
assert!(AGENT_SYSTEM.contains("NO tool calls") || AGENT_SYSTEM.contains("no tool calls"));
assert!(AGENT_SYSTEM.contains("block_until_ms"));
assert!(AGENT_SYSTEM.contains("ack ≠ delivery") || AGENT_SYSTEM.contains("ack != delivery"));
}
#[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]
2026-09-14 09:08:35 +00:00
fn default_max_rounds_total_is_5000() {
assert_eq!(DEFAULT_MAX_ROUNDS_TOTAL, 5_000);
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() {
2026-09-14 09:08:35 +00:00
let a = vec![tc("1", "external_list_dir", r#"{"path":"."}"#)];
let b = vec![tc("2", "external_list_dir", r#"{"path":"."}"#)];
assert_eq!(round_signature(&a), round_signature(&b));
2026-09-14 09:08:35 +00:00
let c = vec![tc("1", "external_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"),
2026-09-14 09:08:35 +00:00
ChatMessage::assistant_tool_calls(None, vec![tc("c1", "external_read_file", r#"{"path":"a"}"#)]),
ChatMessage::tool("c1", "X".repeat(5_000)),
2026-09-14 09:08:35 +00:00
ChatMessage::assistant_tool_calls(None, vec![tc("c2", "external_read_file", r#"{"path":"b"}"#)]),
ChatMessage::tool("c2", "Y".repeat(5_000)),
2026-09-14 09:08:35 +00:00
ChatMessage::assistant_tool_calls(None, vec![tc("c3", "external_read_file", r#"{"path":"c"}"#)]),
ChatMessage::tool("c3", "Z".repeat(5_000)),
2026-09-14 09:08:35 +00:00
ChatMessage::assistant_tool_calls(None, vec![tc("c4", "external_read_file", r#"{"path":"d"}"#)]),
ChatMessage::tool("c4", "W".repeat(5_000)),
2026-09-14 09:08:35 +00:00
ChatMessage::assistant_tool_calls(None, vec![tc("c5", "external_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();
2026-09-14 09:08:35 +00:00
assert!(matches!(verdict, AgentVerdict::Waiting(_)));
assert!(ctx.runtime.pending_question.lock().unwrap().is_some());
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,
2026-09-14 09:08:35 +00:00
vec![tc("r1", "external_read_file", &args)],
2026-09-13 16:38:32 +00:00
))
}
},
)
.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}"),
2026-09-14 09:08:35 +00:00
"external_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-14 09:08:35 +00:00
assert!(reason.contains("external_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-14 09:08:35 +00:00
vec![tc("c1", "external_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}"),
2026-09-14 09:08:35 +00:00
"external_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}"),
2026-09-14 09:08:35 +00:00
"external_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-14 09:08:35 +00:00
assert!(reason.contains("external_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-14 09:08:35 +00:00
vec![tc("c1", "external_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()
2026-09-14 09:08:35 +00:00
.any(|l| l.contains("〔思考中〕") && (l.contains("") || l.contains(""))),
"missing thinking: {lines:?}"
);
assert!(
lines
.iter()
2026-09-14 09:08:35 +00:00
.any(|l| l.contains("〔工具〕") && l.contains("external_read_file")),
"missing tools: {lines:?}"
);
assert!(
2026-09-14 09:08:35 +00:00
lines.iter().any(|l| l.contains("完成external_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,
2026-09-14 09:08:35 +00:00
vec![tc("r1", "external_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);
2026-09-14 09:08:35 +00:00
assert!(
messages.iter().any(|m| m.text().contains("last tool calls and results repeated")),
"loop reminder missing: {messages:?}"
);
let _ = std::fs::remove_dir_all(&dir);
unsafe { std::env::remove_var("GROKBOY_PROGRESS") };
}
2026-09-14 09:08:35 +00:00
#[tokio::test]
async fn empty_replies_retry_then_failed() {
let ctx = ToolContext::new(std::env::temp_dir());
let mut messages = vec![ChatMessage::user("hi")];
let n = Arc::new(Mutex::new(0usize));
let n2 = n.clone();
let verdict = run_agent_with(&mut messages, &ctx, 12, 8, 100_000, move |_, _| {
*n2.lock().unwrap() += 1;
async { Ok(ChatMessage::assistant("")) }
})
.await
.unwrap();
assert!(matches!(verdict, AgentVerdict::Failed(ref m) if m.contains("empty")));
assert_eq!(*n.lock().unwrap(), EMPTY_RESPONSE_RETRIES + 1);
assert!(
messages
.iter()
.any(|m| m.text().contains("Please continue")),
"empty-reply reminder missing"
);
}
#[tokio::test]
async fn streamed_send_message_is_delivered_before_reply_completes() {
let ctx = ToolContext::new(std::env::temp_dir());
let delivered = Arc::new(Mutex::new(Vec::<String>::new()));
let d = delivered.clone();
ctx.runtime.set_event_handler(move |e| {
if let AgentEvent::Message { content } = e {
d.lock().unwrap().push(content.clone());
}
});
let seen_before_return = Arc::new(Mutex::new(false));
let mut messages = vec![ChatMessage::user("go")];
let mut round = 0;
let ack = tc("ack", "send_message", r#"{"type":"text","content":"on it"}"#);
let flag = seen_before_return.clone();
let observed = delivered.clone();
let verdict = run_agent_with(&mut messages, &ctx, 12, 6, 100_000, move |_, _| {
round += 1;
let ack = ack.clone();
let flag = flag.clone();
let observed = observed.clone();
async move {
if round == 1 {
// Simulate the provider streaming the ack first, then a slow second call.
let sink = crate::model::current_stream_sink().expect("sink installed");
sink(crate::model::StreamEvent::ToolCallStarted {
id: ack.id.clone(),
name: ack.function.name.clone(),
});
sink(crate::model::StreamEvent::ToolCallReady(ack.clone()));
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
*flag.lock().unwrap() = !observed.lock().unwrap().is_empty();
Ok(ChatMessage::assistant_tool_calls(
None,
vec![ack, tc("ls", "external_list_dir", r#"{"path":"."}"#)],
))
} else {
Ok(ChatMessage::assistant("done"))
}
}
})
.await
.unwrap();
assert!(
*seen_before_return.lock().unwrap(),
"ack must be visible while the reply is still streaming"
);
assert_eq!(*delivered.lock().unwrap(), vec!["on it".to_string(), "done".to_string()]);
assert_eq!(verdict, AgentVerdict::Answer("done".into()));
assert_tool_results_paired(&messages);
let ack_results: Vec<&ChatMessage> = messages
.iter()
.filter(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some("ack"))
.collect();
assert_eq!(ack_results.len(), 1);
assert!(ack_results[0].text().contains("\"sent\":true"));
}
#[tokio::test]
async fn parallel_safe_reads_in_one_batch_both_succeed() {
let dir = std::env::temp_dir().join(format!("gb-par-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a.txt"), "AAA").unwrap();
std::fs::write(dir.join("b.txt"), "BBB").unwrap();
let ctx = ToolContext::new(&dir);
let mut messages = vec![ChatMessage::user("read both")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 6, 100_000, |_, _| {
round += 1;
let reply = if round == 1 {
ChatMessage::assistant_tool_calls(
None,
vec![
tc("a", "external_read_file", r#"{"path":"a.txt"}"#),
tc("b", "external_read_file", r#"{"path":"b.txt"}"#),
],
)
} else if round == 2 {
ChatMessage::assistant_tool_calls(
None,
vec![tc(
"s",
"send_message",
r#"{"type":"text","content":"got both"}"#,
)],
)
} else {
ChatMessage::assistant("scratch")
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Answer("got both".into()));
assert_tool_results_paired(&messages);
let tools: Vec<&str> = messages
.iter()
.filter(|m| m.role == Role::Tool)
.map(|m| m.text())
.collect();
assert!(tools.iter().any(|t| t.contains("AAA")), "{tools:?}");
assert!(tools.iter().any(|t| t.contains("BBB")), "{tools:?}");
std::fs::remove_dir_all(dir).unwrap();
}
#[tokio::test]
async fn report_done_does_not_cancel_a_live_command() {
let dir = std::env::temp_dir().join(format!("gb-live-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&dir).unwrap();
let ctx = ToolContext::new(&dir);
let mut messages = vec![ChatMessage::user("bg")];
let mut round = 0;
let verdict = run_agent_with(&mut messages, &ctx, 12, 8, 100_000, |_, _| {
round += 1;
let reply = if round == 1 {
ChatMessage::assistant_tool_calls(
None,
vec![tc(
"e",
"external_exec_command",
&json!({"cmd":"sleep 8","block_until_ms":0,"timeout_ms":20000}).to_string(),
)],
)
} else {
ChatMessage::assistant_tool_calls(
None,
vec![tc("d", "report_done", r#"{"message":"kicked off"}"#)],
)
};
async { Ok(reply) }
})
.await
.unwrap();
assert_eq!(verdict, AgentVerdict::Done("kicked off".into()));
assert!(
ctx.jobs.active().await,
"answer/done must not cancel a live command"
);
ctx.jobs.cancel().await;
std::fs::remove_dir_all(dir).unwrap();
}
}