2026-09-13 09:02:23 +00:00
//! 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.
2026-09-13 07:42:59 +00:00
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 ;
2026-09-13 07:42:59 +00:00
use anyhow ::Result ;
2026-09-13 07:51:13 +00:00
use serde_json ::Value ;
2026-09-14 09:08:35 +00:00
use std ::collections ::HashMap ;
2026-09-13 07:51:13 +00:00
use std ::future ::Future ;
2026-09-13 07:42:59 +00:00
2026-09-14 09:08:35 +00:00
/// Local progress beat. No extra model call is made for progress.
2026-09-13 07:42:59 +00:00
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 ;
2026-09-13 07:51:13 +00:00
pub const DEFAULT_CONTEXT_CHARS : usize = 100_000 ;
2026-09-14 09:08:35 +00:00
pub const LOOP_GUARD_REMIND : usize = 2 ;
2026-09-13 07:51:13 +00:00
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> \n You 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> \n You 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> \n You 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> " ;
2026-09-13 07:42:59 +00:00
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 2 – 3 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 30000 ms ) 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 , 2 FA , 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 2 FA , 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
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_search / web_fetch use a separate remote service ( configured AiService gateway or native xAI web search with the existing official xAI model configuration ) and never carry browser cookies . xAI fetched content is model - rendered , not verbatim source bytes . 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 .
## 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 . " ;
2026-09-13 07:42:59 +00:00
2026-09-13 07:51:13 +00:00
/// 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.
2026-09-13 07:51:13 +00:00
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 ) ,
2026-09-13 07:51:13 +00:00
}
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 ,
2026-09-13 07:51:13 +00:00
}
}
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 " ,
2026-09-13 07:51:13 +00:00
}
}
}
/// 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.
2026-09-13 08:54:03 +00:00
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 )
}
2026-09-13 09:02:23 +00:00
/// 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 ( ) ,
} ) ;
2026-09-13 09:16:30 +00:00
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 09:02:23 +00:00
}
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} " ) ;
}
2026-09-13 09:16:30 +00:00
format! ( " 〔完成〕 {name} " )
2026-09-13 09:02:23 +00:00
}
2026-09-13 09:22:54 +00:00
/// 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
}
2026-09-13 09:02:23 +00:00
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
2026-09-13 09:02:23 +00:00
}
}
2026-09-13 07:51:13 +00:00
/// 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 )
2026-09-13 07:51:13 +00:00
}
/// 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).
2026-09-13 07:51:13 +00:00
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 = 4 usize ;
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
2026-09-13 07:51:13 +00:00
. 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 ( ) )
} )
2026-09-13 07:51:13 +00:00
. 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 ( )
2026-09-13 07:51:13 +00:00
. iter ( )
2026-09-13 16:38:32 +00:00
. map ( | c | c . id . clone ( ) )
. collect ::< Vec < _ > > ( ) ;
2026-09-13 07:51:13 +00:00
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 ] ) ;
}
2026-09-13 07:51:13 +00:00
}
}
}
}
/// 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.
2026-09-13 07:42:59 +00:00
pub async fn run_agent (
config : & Config ,
messages : & mut Vec < ChatMessage > ,
tool_ctx : & ToolContext ,
max_rounds : usize ,
2026-09-13 07:51:13 +00:00
) -> Result < AgentVerdict > {
let budget = context_char_budget ( ) ;
2026-09-13 09:02:23 +00:00
let total = max_rounds_total_budget ( ) ;
2026-09-13 07:51:13 +00:00
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 ( ) ) ;
2026-09-13 09:02:23 +00:00
run_agent_with (
messages ,
tool_ctx ,
max_rounds ,
total ,
budget ,
2026-09-14 09:08:35 +00:00
move | msgs , tools | shared ( msgs , tools ) ,
2026-09-13 09:02:23 +00:00
)
2026-09-13 07:51:13 +00:00
. 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.
2026-09-13 08:54:03 +00:00
/// `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.
2026-09-13 07:51:13 +00:00
pub async fn run_agent_with < F , Fut > (
2026-09-13 09:16:30 +00:00
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 > (
2026-09-13 07:51:13 +00:00
messages : & mut Vec < ChatMessage > ,
tool_ctx : & ToolContext ,
max_rounds : usize ,
2026-09-13 09:02:23 +00:00
max_rounds_total : usize ,
2026-09-13 07:51:13 +00:00
context_budget : usize ,
mut complete : F ,
2026-09-13 09:16:30 +00:00
mut on_progress : P ,
2026-09-13 07:51:13 +00:00
) -> Result < AgentVerdict >
where
2026-09-13 08:54:03 +00:00
F : FnMut ( Vec < ChatMessage > , Option < Value > ) -> Fut ,
2026-09-13 07:51:13 +00:00
Fut : Future < Output = Result < ChatMessage > > ,
2026-09-13 09:16:30 +00:00
P : FnMut ( & str ) ,
2026-09-13 07:51:13 +00:00
{
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 ;
2026-09-13 09:02:23 +00:00
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 = 0 usize ;
let mut non_send_since_send = 0 usize ;
let mut ack_reminded = false ;
let mut silence_reminded = false ;
let mut delivery_reminded = false ;
let mut empty_retries = 0 usize ;
2026-09-13 09:16:30 +00:00
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 ,
2026-09-13 09:16:30 +00:00
& 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 09:02:23 +00:00
}
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
}
2026-09-13 09:16:30 +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 ,
2026-09-13 09:16:30 +00:00
& 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 09:02:23 +00:00
}
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 09:02:23 +00:00
}
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
2026-09-13 09:16:30 +00:00
. iter ( )
. map ( | c | c . function . name . as_str ( ) )
2026-09-13 16:38:32 +00:00
. collect ::< Vec < _ > > ( )
. join ( " , " ) ;
2026-09-13 09:16:30 +00:00
emit_progress_line (
2026-09-13 16:38:32 +00:00
& format! ( " 〔 工具〕 round {round} : {names} " ) ,
runtime ,
2026-09-13 09:16:30 +00:00
& 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 09:02:23 +00:00
}
2026-09-13 16:38:32 +00:00
if runtime . cancelled ( ) {
break 'turn AgentVerdict ::Cancelled (
" 已停止本回合, session 可續跑;中斷操作的結果可能未知,續跑時先重新確認。 "
. into ( ) ,
2026-09-13 09:16:30 +00:00
) ;
2026-09-13 07:51:13 +00:00
}
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 09:02:23 +00:00
}
2026-09-13 16:38:32 +00:00
repeat_count = 0 ;
continue ;
2026-09-13 09:02:23 +00:00
}
if let Some ( verdict ) = completion {
2026-09-13 16:38:32 +00:00
break 'turn verdict ;
2026-09-13 07:51:13 +00:00
}
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 08:54:03 +00:00
}
}
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} \n Session 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-13 07:51:13 +00:00
}
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> \n Background 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 ( ) ,
}
}
2026-09-13 07:51:13 +00:00
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.
2026-09-13 07:51:13 +00:00
}
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 ,
}
2026-09-13 07:42:59 +00:00
}
#[ cfg(test) ]
mod tests {
use super ::* ;
2026-09-13 07:51:13 +00:00
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 07:42:59 +00:00
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 ( ) ;
}
2026-09-13 07:42:59 +00:00
#[ test ]
2026-09-13 07:51:13 +00:00
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 " ) ) ;
2026-09-13 07:51:13 +00:00
assert! ( AGENT_SYSTEM . contains ( " report_blocked " ) ) ;
2026-09-13 08:37:51 +00:00
assert! ( AGENT_SYSTEM . contains ( " request_user_confirm " ) ) ;
2026-09-13 08:15:27 +00:00
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 " ) ) ;
2026-09-13 08:37:51 +00:00
assert! ( AGENT_SYSTEM . contains ( " irreversible " ) | | AGENT_SYSTEM . contains ( " Never publish " ) ) ;
2026-09-13 07:42:59 +00:00
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 " ) ) ;
2026-09-13 08:54:03 +00:00
}
#[ 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 ) ) ;
2026-09-13 07:42:59 +00:00
}
#[ test ]
fn default_max_rounds_is_12 ( ) {
assert_eq! ( DEFAULT_MAX_ROUNDS , 12 ) ;
}
2026-09-13 07:51:13 +00:00
2026-09-13 09:02:23 +00:00
#[ test ]
2026-09-14 09:08:35 +00:00
fn default_max_rounds_total_is_5000 ( ) {
assert_eq! ( DEFAULT_MAX_ROUNDS_TOTAL , 5_000 ) ;
2026-09-13 09:02:23 +00:00
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 ) ) ;
}
2026-09-13 07:51:13 +00:00
#[ 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":"."}"# ) ] ;
2026-09-13 07:51:13 +00:00
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"}"# ) ] ;
2026-09-13 07:51:13 +00:00
assert_ne! ( round_signature ( & a ) , round_signature ( & c ) ) ;
}
2026-09-13 09:22:54 +00:00
#[ 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 ] ;
}
2026-09-13 09:23:03 +00:00
#[ test ]
2026-09-13 07:51:13 +00:00
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"}"# ) ] ) ,
2026-09-13 07:51:13 +00:00
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"}"# ) ] ) ,
2026-09-13 07:51:13 +00:00
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"}"# ) ] ) ,
2026-09-13 07:51:13 +00:00
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"}"# ) ] ) ,
2026-09-13 07:51:13 +00:00
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"}"# ) ] ) ,
2026-09-13 07:51:13 +00:00
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 ( 0 usize ) ) ;
let calls2 = calls . clone ( ) ;
2026-09-13 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 5 , 5 , 100_000 , move | _msgs , _tools | {
2026-09-13 07:51:13 +00:00
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 ( ) ,
) ] ,
) )
} ,
)
2026-09-13 07:51:13 +00:00
. await
. unwrap ( ) ;
2026-09-14 09:08:35 +00:00
assert! ( matches! ( verdict , AgentVerdict ::Waiting ( _ ) ) ) ;
assert! ( ctx . runtime . pending_question . lock ( ) . unwrap ( ) . is_some ( ) ) ;
2026-09-13 07:51:13 +00:00
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
) )
}
} ,
)
2026-09-13 07:51:13 +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 ( 0 usize ) ) ;
let n2 = n . clone ( ) ;
2026-09-13 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 2 , 2 , 100_000 , move | _msgs , tools | {
2026-09-13 07:51:13 +00:00
let i = {
let mut g = n2 . lock ( ) . unwrap ( ) ;
* g + = 1 ;
* g
} ;
async move {
2026-09-13 08:54:03 +00:00
// Final no-tools summary call after max rounds.
if tools . is_none ( ) {
return Ok ( ChatMessage ::assistant (
" 已讀 a.txt 與 b.txt, 任務尚未完成。 " ,
) ) ;
}
2026-09-13 07:51:13 +00:00
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 " ,
2026-09-13 07:51:13 +00:00
& json! ( { " path " : path } ) . to_string ( ) ,
) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
2026-09-13 16:38:32 +00:00
AgentVerdict ::BudgetExhausted ( reason ) = > {
2026-09-13 07:51:13 +00:00
assert! ( reason . contains ( " max rounds " ) , " {reason} " ) ;
2026-09-13 08:54:03 +00:00
assert! ( reason . contains ( " Progress so far " ) , " {reason} " ) ;
2026-09-14 09:08:35 +00:00
assert! ( reason . contains ( " external_read_file " ) , " {reason} " ) ;
2026-09-13 09:02:23 +00:00
assert! (
reason . contains ( " total budget " ) | | reason . contains ( " exhausted " ) ,
" {reason} "
) ;
2026-09-13 07:51:13 +00:00
}
other = > panic! ( " expected blocked, got {other:?} " ) ,
}
2026-09-13 08:54:03 +00:00
// Summary nudge must not pollute the persisted conversation.
assert! (
2026-09-13 09:02:23 +00:00
! messages . iter ( ) . any ( | m | {
m . text ( ) . contains ( " chunk/round budget " )
| | m . text ( ) . contains ( " maximum number of tool rounds " )
} ) ,
2026-09-13 08:54:03 +00:00
" 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 ) ;
2026-09-13 08:54:03 +00:00
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 ( ) {
2026-09-13 08:54:03 +00:00
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 ( 0 usize ) ) ;
let n2 = n . clone ( ) ;
2026-09-13 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 1 , 1 , 100_000 , move | _msgs , tools | {
2026-09-13 08:54:03 +00:00
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 "
) ;
2026-09-13 08:54:03 +00:00
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 ( ) ) ] ,
2026-09-13 08:54:03 +00:00
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
2026-09-13 16:38:32 +00:00
AgentVerdict ::BudgetExhausted ( reason ) = > {
2026-09-13 09:02:23 +00:00
assert! (
reason . contains ( " max rounds " )
& & reason . contains ( " total budget 1 " )
& & reason . contains ( " without completion " ) ,
" {reason} "
2026-09-13 08:54:03 +00:00
) ;
}
other = > panic! ( " expected blocked fallback, got {other:?} " ) ,
}
2026-09-13 07:51:13 +00:00
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 " ) ) } ,
)
2026-09-13 07:51:13 +00:00
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Answer ( " hello there " . into ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
2026-09-13 09:02:23 +00:00
/// 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 ;
2026-09-13 09:02:23 +00:00
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 ( 0 usize ) ) ;
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.
2026-09-13 09:02:23 +00:00
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 " ) ;
2026-09-13 09:02:23 +00:00
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 " ,
2026-09-13 09:02:23 +00:00
& 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.
2026-09-13 09:02:23 +00:00
#[ 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 ;
2026-09-13 09:02:23 +00:00
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 ( 0 usize ) ) ;
let tool_rounds2 = tool_rounds . clone ( ) ;
2026-09-13 16:38:32 +00:00
// interval=2, total=4 -> BudgetExhausted (no report_done).
2026-09-13 09:02:23 +00:00
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 " ) ;
2026-09-13 09:02:23 +00:00
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 " ,
2026-09-13 09:02:23 +00:00
& json! ( { " path " : format ! ( " t{r}.txt " ) } ) . to_string ( ) ,
) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
2026-09-13 16:38:32 +00:00
AgentVerdict ::BudgetExhausted ( reason ) = > {
2026-09-13 09:02:23 +00:00
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} " ) ;
2026-09-13 09:02:23 +00:00
}
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 " ) } ;
}
2026-09-13 09:16:30 +00:00
/// 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 ;
2026-09-13 09:16:30 +00:00
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 ( ) ) ) ;
2026-09-13 09:16:30 +00:00
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 ( 0 usize ) ) ;
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 ( ) ) ] ,
2026-09-13 09:16:30 +00:00
) )
} 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 ( " 最多 " ) ) ,
2026-09-13 09:16:30 +00:00
" 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 ( " 步 " ) ) ) ,
2026-09-13 09:16:30 +00:00
" missing thinking: {lines:?} "
) ;
assert! (
lines
. iter ( )
2026-09-14 09:08:35 +00:00
. any ( | l | l . contains ( " 〔工具〕 " ) & & l . contains ( " external_read_file " ) ) ,
2026-09-13 09:16:30 +00:00
" missing tools: {lines:?} "
) ;
assert! (
2026-09-14 09:08:35 +00:00
lines . iter ( ) . any ( | l | l . contains ( " 〔 完成〕 external_read_file" ) ) ,
2026-09-13 09:16:30 +00:00
" 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 " ) } ;
}
2026-09-13 09:02:23 +00:00
/// 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 ;
2026-09-13 09:02:23 +00:00
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 ( 0 usize ) ) ;
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 ) ] ,
2026-09-13 09:02:23 +00:00
) )
}
} )
. 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:?} "
) ;
2026-09-13 09:02:23 +00:00
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 ( 0 usize ) ) ;
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 ( ) ;
}
2026-09-13 07:42:59 +00:00
}