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-13 16:38:32 +00:00
use crate ::model ::{ chat_completion , ChatMessage , Role , ToolCall } ;
use crate ::tools ::{ execute_tool , is_completion_tool , tool_definitions , ToolContext } ;
2026-09-13 07:42:59 +00:00
use anyhow ::Result ;
2026-09-13 07:51:13 +00:00
use serde_json ::Value ;
use std ::future ::Future ;
2026-09-13 07:42:59 +00:00
2026-09-13 16:38:32 +00:00
/// Model requests per 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-13 16:38:32 +00:00
/// Absolute model-request ceiling per user turn.
2026-09-13 09:02:23 +00:00
pub const DEFAULT_MAX_ROUNDS_TOTAL : usize = 48 ;
2026-09-13 07:51:13 +00:00
pub const DEFAULT_CONTEXT_CHARS : usize = 100_000 ;
pub const LOOP_GUARD_REPEAT : usize = 3 ;
2026-09-13 07:42:59 +00:00
pub const AGENT_SYSTEM : & str = " \
2026-09-13 16:38:32 +00:00
You are GrokBoy , a general - purpose local agent . Observe the actual environment , choose the next useful action , verify its result , and adapt until the user goal is handled .
2026-09-13 07:42:59 +00:00
Use tools when they help solve the task ; otherwise answer directly .
2026-09-13 16:38:32 +00:00
A blocked step is not automatically a failed task . When a required action needs human participation ( account selection , authentication , permission , local access , or a personal decision ) , explain the specific obstacle and request the smallest useful intervention through browser_handoff or request_user_input . Preserve the current task / plan / session while waiting , inspect the result when control returns , and continue . If the obstacle persists , offer concrete alternative routes through report_blocked options , including useful independent work where possible , and respect the user choice to stop . Do not substitute a blanket refusal or repeated unchanged retries for human collaboration ; budgets and security boundaries still apply .
2026-09-13 07:42:59 +00:00
Prefer short , clear answers . Traditional Chinese is welcome when the user writes in Chinese .
2026-09-13 16:38:32 +00:00
Available tools include report_progress , update_plan , request_user_input , exec_command , write_stdin , shell , search_files , list_dir , read_file , edit_file , write_file , report_done , report_blocked , request_user_confirm , and browser_ * ( Playwright DOM ) . Read tool schemas for capabilities .
For web pages prefer DOM snapshot + selector / role click / type — not screenshots or pixel XY clicks . Re - observe after navigation , changes , or resumed sessions . Do not assume previous side effects were rolled back after interruption ; inspect before retrying .
Helping a user access their own account using user - operated login in the tool browser is supported . Do not refuse authorized browsing just because authentication is required ; navigate and inspect first , then hand over the same browser for authentication if needed . Do not collect credentials in chat or bypass authentication .
2026-09-13 08:37:51 +00:00
If you hit a login / OTP / captcha wall you cannot pass alone , call browser_handoff with a clear reason so the human can help in the visible browser , then continue from the returned snapshot . Prefer browser_handoff only for auth walls .
Never publish / send social posts ( Threads , Facebook , Instagram , X / Twitter , etc . ) or take other irreversible public actions without either ( a ) an explicit user message this turn approving the exact draft , or ( b ) request_user_confirm returning approved . Prefer draft → confirm → then act . If approval is missing , call request_user_confirm ( with the draft in prompt ) or report_blocked — never post unilaterally .
2026-09-13 08:54:03 +00:00
For greetings , small talk , clarifying questions , or when no tools are needed : reply with normal assistant text and stop ( do not call report_done ) .
2026-09-13 09:16:30 +00:00
Do not call report_done or give a final wrap - up answer until the user ' s task is actually complete .
2026-09-13 16:38:32 +00:00
Before a task that requires actions , briefly tell the user what you will do ( 1 – 2 sentences ) , then act . Prefer assistant text alongside the first action tool . Use report_progress for a standalone progress update ; it continues the task . Do not repeatedly say you are working without reporting a finding or next action . For multi - stage or uncertain tasks , create a short 3 – 5 step update_plan , then execute without asking approval of the plan . Update step status at milestones and explain changes . Simple questions need no plan .
Plain text without tools is a final answer only . Use report_progress or text with tool calls for progress . Only request_user_input for essential information that available tools cannot discover ; resume after the answer . User input arriving while working is steering : keep the original goal unless the user replaces or cancels it . Use browser_read_page to read page content and cite actual URLs ; use snapshot to locate controls . Search via the browser without assuming a separate search API . Use exec_command / write_stdin for long commands ; observe completion before claiming success .
report_done = final delivery only ( short summary when a real task / tool workflow is actually finished ) . Call it alone , in a separate step after observing results . Verify the requested outcome with available tools before claiming success ; summarize evidence and any limitations .
When a stage is blocked , briefly explain what failed and provide 2 – 3 concrete next routes with report_blocked options , or use browser_handoff for manual login in the SAME browser session . Examples : let the user handle login in the visible browser , or draft content without login . Stop repeating unsuccessful login attempts after two similar failures without new evidence . Never ask for a password or OTP in chat . A choice changes the next route , not proof the blocker is solved . Update the plan and continue this same task / session ; do not spawn a fresh worker for manual login . Call report_blocked when stuck — do not invent results or loop .
2026-09-13 09:02:23 +00:00
If work is large , keep using tools across the session ; the runtime may continue in chunks — still call report_done when truly finished ; don ' t stop early just to ' save rounds ' .
2026-09-13 07:51:13 +00:00
Do not invent tool results — call the tools . " ;
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-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 )
| Self ::Cancelled ( 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-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-13 09:02:23 +00:00
run_agent_with (
messages ,
tool_ctx ,
max_rounds ,
total ,
budget ,
move | msgs , tools | {
let config = config . clone ( ) ;
async move { chat_completion ( & config , & msgs , tools . as_ref ( ) ) . await }
} ,
)
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
use crate ::runtime ::AgentEvent ;
let tools = if let Some ( team ) = & tool_ctx . team {
crate ::team ::worker ::definitions ( team . task . is_none ( ) )
} else {
tool_definitions ( )
} ;
let runtime = & tool_ctx . runtime ;
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 control_rounds = 0 ;
let mut last_progress = String ::new ( ) ;
2026-09-13 09:16:30 +00:00
emit_progress_line (
2026-09-13 16:38:32 +00:00
& format! ( " 〔開始〕最多 {max_rounds_total} 輪(每 {max_rounds} 輪顯示進度) " ) ,
runtime ,
2026-09-13 09:16:30 +00:00
& mut on_progress ,
) ;
2026-09-13 16:38:32 +00:00
let verdict = ' turn : {
let pending_question = runtime . pending_question . lock ( ) . unwrap ( ) . clone ( ) ;
if let Some ( question ) = pending_question {
let is_handoff = question [ " kind " ] = = " handoff " ;
let answer = if is_handoff {
let mut args = question . clone ( ) ;
args . as_object_mut ( ) . unwrap ( ) . remove ( " options " ) ;
if let Some ( alternatives ) = question . get ( " handoff_options " ) {
args [ " options " ] = alternatives . clone ( ) ;
}
runtime
. wait ( " 重新開啟登入視窗 " , async {
Ok ( serde_json ::from_str ::< Value > (
& execute_tool ( tool_ctx , " browser_handoff " , & args . to_string ( ) ) . await ,
) ? )
} )
. await
} else {
runtime . question ( & question ) . await
} ;
match answer {
Ok ( answer ) if answer [ " user_stopped " ] = = true = > {
break 'turn AgentVerdict ::Cancelled ( " 使用者選擇停止登入工作。 " . into ( ) )
}
Ok ( answer ) = > messages . push ( ChatMessage ::user ( format! (
" Reply to the previously unanswered question {}: {} " ,
question ,
if is_handoff {
answer
} else {
answer [ " answer " ] . clone ( )
}
) ) ) ,
Err ( error ) if runtime . cancelled ( ) = > {
break 'turn AgentVerdict ::Cancelled ( error . to_string ( ) )
}
Err ( error ) = > break 'turn AgentVerdict ::Blocked ( error . to_string ( ) ) ,
}
2026-09-13 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 ) ) ;
control_rounds = 0 ;
}
runtime . checkpoint ( messages , None ) ? ;
if let Some ( team ) = & tool_ctx . team {
team . ack_messages ( messages ) ? ;
}
let mut request_messages = messages . clone ( ) ;
if let Some ( team ) = & tool_ctx . team {
if team . task . is_none ( ) {
let starts = request_messages
. iter ( )
. enumerate ( )
. filter ( | ( _ , m ) | m . role = = Role ::User )
. map ( | ( i , _ ) | i )
. collect ::< Vec < _ > > ( ) ;
if starts . len ( ) > 20 {
request_messages . drain ( 1 .. starts [ starts . len ( ) - 20 ] ) ;
}
}
request_messages . insert (
1. min ( request_messages . len ( ) ) ,
ChatMessage ::system ( team . context ( ) ? ) ,
) ;
}
truncate_messages ( & mut request_messages , context_budget ) ;
let plan = runtime . plan . lock ( ) . unwrap ( ) . clone ( ) ;
if ! plan . is_empty ( ) {
request_messages . insert (
1. min ( request_messages . len ( ) ) ,
ChatMessage ::system ( format! (
" Current task plan (runtime state): {} " ,
serde_json ::to_string ( & plan ) ?
) ) ,
) ;
}
if let Some ( command ) = runtime . active_command . lock ( ) . unwrap ( ) . as_ref ( ) {
request_messages . insert ( 1. min ( request_messages . len ( ) ) , ChatMessage ::system ( format! ( " Last command runtime state: {command} . Verify saved output if the command was interrupted. " ) ) ) ;
}
if context_budget > 0 & & messages_char_len ( & request_messages ) > context_budget {
break 'turn AgentVerdict ::Failed ( " context budget exceeded; preserved system instructions and user goals. Increase GROKBOY_CONTEXT_CHARS or start a shorter task. " . into ( ) ) ;
}
2026-09-13 09:16:30 +00:00
emit_progress_line (
2026-09-13 16:38:32 +00:00
& format! ( " 〔思考中〕第 {round} / {max_rounds_total} 輪… " ) ,
runtime ,
2026-09-13 09:16:30 +00:00
& mut on_progress ,
) ;
2026-09-13 16:38:32 +00:00
let reply = match runtime
. wait ( " 模型回應 " , complete ( request_messages , Some ( tools . clone ( ) ) ) )
. await
{
Ok ( reply ) = > reply ,
Err ( error ) if runtime . cancelled ( ) = > {
break 'turn AgentVerdict ::Cancelled ( error . to_string ( ) )
2026-09-13 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 ( ) ;
if text . is_empty ( ) {
break 'turn AgentVerdict ::Failed ( " model returned empty final answer " . into ( ) ) ;
}
if runtime . unfinished ( )
| | tool_ctx . jobs . active ( ) . await
| | tool_ctx . team . as_ref ( ) . is_some_and ( | t | t . unfinished ( ) )
{
messages . push ( reply ) ;
messages . push ( ChatMessage ::system ( " The turn cannot finish while plan steps or a command remain active. Continue with tools, update the plan based on verified results, or report_blocked. Ask missing information with request_user_input. " ) ) ;
control_rounds + = 1 ;
if control_rounds > = 3 {
break 'turn AgentVerdict ::Blocked (
" repeated final answers while work remains unfinished " . into ( ) ,
) ;
}
continue ;
}
messages . push ( reply ) ;
break 'turn AgentVerdict ::Answer ( text ) ;
}
// Display commentary before acting. It is not a final answer.
if ! reply . text ( ) . trim ( ) . is_empty ( ) {
runtime . emit ( AgentEvent ::Progress {
message : reply . text ( ) . trim ( ) . to_string ( ) ,
} ) ;
}
let names = calls
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
& & calls . iter ( ) . any ( | c | {
is_completion_tool ( & c . function . name )
| | matches! (
c . function . name . as_str ( ) ,
" request_user_input " | " request_user_confirm " | " browser_handoff "
)
} ) ;
let mut observation = round_signature ( & calls ) ;
let mut completion = None ;
let mut actual_action = false ;
let mut received_answer = false ;
let mut controlled_wait = false ;
for call in & calls {
steering . extend ( runtime . steering ( ) ) ;
if let Some ( team ) = & tool_ctx . team {
steering . extend ( team . take_messages ( ) ? ) ;
}
let skipped = mixed | | ! steering . is_empty ( ) | | runtime . cancelled ( ) ;
let result = if skipped {
serde_json ::json! ( { " error " :if mixed { " completion and human-input tools must be called alone; this batch was not executed " } else { " not executed: new input or cancellation arrived; reconsider the next action " } , " executed " :false } ) . to_string ( )
} else {
runtime . checkpoint ( messages , Some ( & call . id ) ) ? ;
runtime . emit ( AgentEvent ::ToolStarted {
id : call . id . clone ( ) ,
name : call . function . name . clone ( ) ,
} ) ;
let result = runtime
. wait ( & call . function . name , async {
Ok ( execute_tool (
tool_ctx ,
& call . function . name ,
& call . function . arguments ,
)
. await )
} )
. await ;
match result {
Ok ( result ) = > result ,
Err ( error ) = > serde_json ::json! ( { " error " :error . to_string ( ) , " outcome " :" unknown; observe before retrying " } ) . to_string ( ) ,
}
} ;
let value : Value = serde_json ::from_str ( & result ) . unwrap_or_default ( ) ;
let success = value . get ( " error " ) . is_none ( )
& & value [ " blocked " ] ! = true
& & value [ " approved " ] ! = false
& & ! value [ " exit_code " ] . as_i64 ( ) . is_some_and ( | n | n ! = 0 ) ;
if ! skipped {
actual_action | = ! matches! (
call . function . name . as_str ( ) ,
" report_progress "
| " update_plan "
| " request_user_input "
| " report_done "
| " report_blocked "
) ;
received_answer | = ( call . function . name = = " request_user_input "
| | value [ " status " ] = = " replan " )
& & success ;
controlled_wait | = matches! (
call . function . name . as_str ( ) ,
" write_stdin " | " browser_wait " | " wait_task "
) & & success ;
if value [ " user_stopped " ] = = true {
completion = Some ( AgentVerdict ::Cancelled (
" 使用者選擇停止這份工作;已執行的操作不會撤回。 " . into ( ) ,
) ) ;
} else if is_completion_tool ( & call . function . name ) {
completion = parse_completion_verdict ( & call . function . name , & result ) ;
}
runtime . emit ( AgentEvent ::ToolFinished {
id : call . id . clone ( ) ,
name : call . function . name . clone ( ) ,
success ,
} ) ;
}
last_progress = tool_progress_line ( & call . function . name , & result ) ;
emit_progress_line ( & last_progress , runtime , & mut on_progress ) ;
observation . push_str ( & result ) ;
// Save full output first; failure to preserve it must not erase the actual tool result.
let stored = runtime
. save_output ( & result )
. ok ( )
. flatten ( )
. unwrap_or ( result ) ;
messages . push ( ChatMessage ::tool ( & call . id , stored ) ) ;
* runtime . active_command . lock ( ) . unwrap ( ) = tool_ctx . jobs . snapshot ( ) . await ;
runtime . checkpoint ( messages , None ) ? ;
2026-09-13 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
control_rounds = 0 ;
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-13 16:38:32 +00:00
if actual_action | | received_answer {
control_rounds = 0 ;
2026-09-13 08:54:03 +00:00
} else {
2026-09-13 16:38:32 +00:00
control_rounds + = 1 ;
}
if control_rounds > = 3 {
break 'turn AgentVerdict ::Blocked ( " no progress: three rounds of only commentary/plan updates or invalid control calls " . into ( ) ) ;
}
if controlled_wait {
repeat_count = 0 ;
previous_observation . clear ( ) ;
} else if observation = = previous_observation {
repeat_count + = 1 ;
} else {
previous_observation = observation ;
repeat_count = 1 ;
}
if repeat_count > = LOOP_GUARD_REPEAT {
break 'turn AgentVerdict ::Blocked ( format! ( " loop guard: identical tool calls AND results repeated {LOOP_GUARD_REPEAT} times; change approach or provide new information " ) ) ;
}
if round % max_rounds = = 0 & & round < max_rounds_total {
emit_progress_line ( & format! ( " 〔進度|尚未完成〕〔續跑〕已用 {round} / {max_rounds_total} 輪; {last_progress} " ) , runtime , & mut on_progress ) ;
2026-09-13 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. " ) )
} ;
// Stop live foreground jobs on every terminal outcome; never leave hidden work running.
tool_ctx . jobs . cancel ( ) . await ;
* runtime . active_command . lock ( ) . unwrap ( ) = tool_ctx . jobs . snapshot ( ) . await ;
runtime . checkpoint ( messages , None ) ? ;
runtime . emit ( AgentEvent ::TurnEnded {
verdict : verdict . kind ( ) . into ( ) ,
message : verdict . message ( ) . into ( ) ,
} ) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
runtime ,
& mut on_progress ,
) ;
Ok ( verdict )
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 " ,
" write_file " ,
r # "{"path":"unexpected","content":"bad"}"# ,
) ,
] ,
) )
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::BudgetExhausted ( _ ) ) ) ;
assert! ( ! dir . join ( " unexpected " ) . exists ( ) ) ;
assert_tool_results_paired ( & history ) ;
std ::fs ::remove_dir_all ( dir ) . unwrap ( ) ;
}
#[ tokio::test ]
async fn changing_results_are_progress_and_tool_text_is_not_final ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-progress- {} " , uuid ::Uuid ::new_v4 ( ) ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
let mut history = vec! [ ChatMessage ::user ( " poll " ) ] ;
let mut round = 0 ;
let file = dir . join ( " state " ) ;
let verdict = run_agent_with ( & mut history , & ctx , 1 , 6 , 100_000 , | _ , tools | {
assert! ( tools . is_some ( ) , " progress must not call the model " ) ;
round + = 1 ;
std ::fs ::write ( & file , round . to_string ( ) ) . unwrap ( ) ;
let reply = if round = = 5 {
ChatMessage ::assistant ( " ready " )
} else {
ChatMessage ::assistant_tool_calls (
Some ( " checking " . into ( ) ) ,
vec! [ tc ( " r " , " read_file " , r # "{"path":"state"}"# ) ] ,
)
} ;
async { Ok ( reply ) }
} )
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Answer ( " ready " . into ( ) ) ) ;
assert_eq! ( round , 5 ) ;
assert_tool_results_paired ( & history ) ;
std ::fs ::remove_dir_all ( dir ) . unwrap ( ) ;
}
#[ tokio::test ]
async fn provider_failure_preserves_completed_tool_history ( ) {
let ctx = ToolContext ::new ( std ::env ::temp_dir ( ) ) ;
let mut history = vec! [ ChatMessage ::user ( " test " ) ] ;
let mut round = 0 ;
let verdict = run_agent_with ( & mut history , & ctx , 1 , 4 , 100_000 , | _ , _ | {
round + = 1 ;
let reply = if round = = 1 {
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " x " , " unknown_tool " , " {} " ) ] ,
) )
} else {
Err ( anyhow ::anyhow! ( " offline failure " ) )
} ;
async { reply }
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::Failed ( _ ) ) ) ;
assert_tool_results_paired ( & history ) ;
assert_eq! ( round , 2 ) ;
}
#[ tokio::test ]
async fn invalid_done_arguments_can_be_repaired ( ) {
let ctx = ToolContext ::new ( std ::env ::temp_dir ( ) ) ;
let mut history = vec! [ ChatMessage ::user ( " test " ) ] ;
let mut round = 0 ;
let verdict = run_agent_with ( & mut history , & ctx , 1 , 3 , 100_000 , | _ , _ | {
round + = 1 ;
let args = if round = = 1 {
r # "{"message":" "}"#
} else {
r # "{"message":"verified"}"#
} ;
async move {
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " d " , " report_done " , args ) ] ,
) )
}
} )
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Done ( " verified " . into ( ) ) ) ;
assert_eq! ( round , 2 ) ;
assert_tool_results_paired ( & history ) ;
}
#[ tokio::test ]
async fn context_limit_does_not_destroy_instructions_or_call_provider ( ) {
let ctx = ToolContext ::new ( std ::env ::temp_dir ( ) ) ;
let system = " system " . repeat ( 100 ) ;
let goal = " goal " . repeat ( 100 ) ;
let mut history = vec! [ ChatMessage ::system ( & system ) , ChatMessage ::user ( & goal ) ] ;
let verdict = run_agent_with ( & mut history , & ctx , 1 , 3 , 100 , | _ , _ | async {
panic! ( " over-budget context must not be sent " ) ;
#[ allow(unreachable_code) ]
Ok ( ChatMessage ::assistant ( " bad " ) )
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::Failed ( _ ) ) ) ;
assert_eq! ( history [ 0 ] . text ( ) , system ) ;
assert_eq! ( history [ 1 ] . text ( ) , goal ) ;
}
#[ tokio::test ]
async fn commentary_precedes_tools_and_progress_alone_does_not_finish ( ) {
let ctx = ToolContext ::new ( std ::env ::temp_dir ( ) ) ;
let mut messages = vec! [ ChatMessage ::user ( " task " ) ] ;
let mut round = 0 ;
let verdict = run_agent_with ( & mut messages , & ctx , 12 , 6 , 100_000 , | _ , _ | {
round + = 1 ;
let reply = match round {
1 = > ChatMessage ::assistant_tool_calls (
Some ( " I will inspect the environment " . into ( ) ) ,
vec! [ tc (
" p " ,
" report_progress " ,
r # "{"message":"Starting the inspection"}"# ,
) ] ,
) ,
2 = > ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " x " , " list_dir " , r # "{"path":"."}"# ) ] ,
) ,
_ = > ChatMessage ::assistant ( " finished " ) ,
} ;
async { Ok ( reply ) }
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::Answer ( _ ) ) ) ;
assert_eq! ( round , 3 ) ;
let events = ctx . runtime . events . lock ( ) . unwrap ( ) ;
let commentary = events . iter ( ) . position ( | e | matches! ( e , crate ::AgentEvent ::Progress { message } if message = = " I will inspect the environment " ) ) . unwrap ( ) ;
let tool = events
. iter ( )
. position ( | e | matches! ( e , crate ::AgentEvent ::ToolStarted { .. } ) )
. unwrap ( ) ;
assert! ( commentary < tool ) ;
assert_tool_results_paired ( & messages ) ;
}
#[ tokio::test ]
async fn commentary_loop_with_changing_text_stops_after_three_requests ( ) {
let ctx = ToolContext ::new ( std ::env ::temp_dir ( ) ) ;
let mut messages = vec! [ ChatMessage ::user ( " task " ) ] ;
let mut round = 0 ;
let verdict = run_agent_with ( & mut messages , & ctx , 12 , 20 , 100_000 , | _ , _ | {
round + = 1 ;
let reply = ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
" p " ,
" report_progress " ,
& json! ( { " message " :format ! ( " progress {round} " ) } ) . to_string ( ) ,
) ] ,
) ;
async { Ok ( reply ) }
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::Blocked ( _ ) ) ) ;
assert_eq! ( round , 3 ) ;
}
#[ tokio::test ]
async fn steering_skips_unstarted_tools_and_keeps_history_paired ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-steer- {} " , uuid ::Uuid ::new_v4 ( ) ) ) ;
std ::fs ::create_dir_all ( & dir ) . unwrap ( ) ;
let input = crate ::InputBroker ::new ( ) ;
input . begin ( ) ;
let runtime = crate ::Runtime ::with_input ( input . clone ( ) ) ;
let ctx = ToolContext ::new ( & dir ) . with_runtime ( runtime ) ;
let mut messages = vec! [ ChatMessage ::user ( " original goal " ) ] ;
let mut round = 0 ;
let verdict = run_agent_with ( & mut messages , & ctx , 12 , 6 , 100_000 , | request , _ | {
round + = 1 ;
let reply = if round = = 1 {
input . feed ( " do not create the file " . into ( ) ) ;
ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " w " , " write_file " , r # "{"path":"bad","content":"oops"}"# ) ] ,
)
} else {
assert! ( request . iter ( ) . any ( | m | m . text ( ) = = " original goal " ) ) ;
assert! ( request . iter ( ) . any ( | m | m . text ( ) = = " do not create the file " ) ) ;
ChatMessage ::assistant ( " understood " )
} ;
async { Ok ( reply ) }
} )
. await
. unwrap ( ) ;
assert! ( matches! ( verdict , AgentVerdict ::Answer ( _ ) ) ) ;
assert! ( ! dir . join ( " bad " ) . exists ( ) ) ;
assert_tool_results_paired ( & messages ) ;
std ::fs ::remove_dir_all ( dir ) . unwrap ( ) ;
}
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-13 07:42:59 +00:00
assert! ( AGENT_SYSTEM . contains ( " shell " ) ) ;
2026-09-13 07:51:13 +00:00
assert! ( AGENT_SYSTEM . contains ( " report_done " ) ) ;
assert! ( AGENT_SYSTEM . contains ( " report_blocked " ) ) ;
2026-09-13 08:37:51 +00:00
assert! ( AGENT_SYSTEM . contains ( " request_user_confirm " ) ) ;
2026-09-13 07:57:57 +00:00
assert! ( AGENT_SYSTEM . contains ( " browser_navigate " ) | | AGENT_SYSTEM . contains ( " browser_ " ) ) ;
2026-09-13 08:15:27 +00:00
assert! ( AGENT_SYSTEM . contains ( " browser_handoff " ) ) ;
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-13 08:54:03 +00:00
assert! ( AGENT_SYSTEM . contains ( " do not call report_done " ) ) ;
2026-09-13 16:38:32 +00:00
assert! (
AGENT_SYSTEM . contains ( " real task/tool workflow " )
| | AGENT_SYSTEM . contains ( " final delivery " )
) ;
2026-09-13 09:02:23 +00:00
assert! (
AGENT_SYSTEM . contains ( " continue in chunks " ) | | AGENT_SYSTEM . contains ( " save rounds " ) ,
" P8 chunk continue guidance missing "
) ;
2026-09-13 09:16:30 +00:00
assert! (
2026-09-13 16:38:32 +00:00
AGENT_SYSTEM . contains ( " actually complete " ) | | AGENT_SYSTEM . contains ( " partial progress " ) ,
2026-09-13 09:16:30 +00:00
" no mid-task conclusion guidance missing "
) ;
assert! (
AGENT_SYSTEM . contains ( " final delivery " ) ,
" report_done = final delivery guidance missing "
) ;
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 ]
fn default_max_rounds_total_is_48 ( ) {
assert_eq! ( DEFAULT_MAX_ROUNDS_TOTAL , 48 ) ;
let parsed = std ::env ::var ( " GROKBOY_MAX_ROUNDS_TOTAL " )
. ok ( )
. and_then ( | s | s . parse ::< usize > ( ) . ok ( ) )
. filter ( | & n | n > 0 ) ;
let got = max_rounds_total_budget ( ) ;
assert_eq! ( got , parsed . unwrap_or ( DEFAULT_MAX_ROUNDS_TOTAL ) ) ;
}
2026-09-13 07:51:13 +00:00
#[ test ]
fn round_signature_stable ( ) {
let a = vec! [ tc ( " 1 " , " list_dir " , r # "{"path":"."}"# ) ] ;
let b = vec! [ tc ( " 2 " , " list_dir " , r # "{"path":"."}"# ) ] ;
assert_eq! ( round_signature ( & a ) , round_signature ( & b ) ) ;
let c = vec! [ tc ( " 1 " , " list_dir " , r # "{"path":"src"}"# ) ] ;
assert_ne! ( round_signature ( & a ) , round_signature ( & c ) ) ;
}
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 " ) ,
ChatMessage ::assistant_tool_calls ( None , vec! [ tc ( " c1 " , " read_file " , r # "{"path":"a"}"# ) ] ) ,
ChatMessage ::tool ( " c1 " , " X " . repeat ( 5_000 ) ) ,
ChatMessage ::assistant_tool_calls ( None , vec! [ tc ( " c2 " , " read_file " , r # "{"path":"b"}"# ) ] ) ,
ChatMessage ::tool ( " c2 " , " Y " . repeat ( 5_000 ) ) ,
ChatMessage ::assistant_tool_calls ( None , vec! [ tc ( " c3 " , " read_file " , r # "{"path":"c"}"# ) ] ) ,
ChatMessage ::tool ( " c3 " , " Z " . repeat ( 5_000 ) ) ,
ChatMessage ::assistant_tool_calls ( None , vec! [ tc ( " c4 " , " read_file " , r # "{"path":"d"}"# ) ] ) ,
ChatMessage ::tool ( " c4 " , " W " . repeat ( 5_000 ) ) ,
ChatMessage ::assistant_tool_calls ( None , vec! [ tc ( " c5 " , " read_file " , r # "{"path":"e"}"# ) ] ) ,
ChatMessage ::tool ( " c5 " , " V " . repeat ( 5_000 ) ) ,
ChatMessage ::user ( " still the goal " ) ,
ChatMessage ::assistant ( " recent " ) ,
] ;
let before = messages_char_len ( & msgs ) ;
truncate_messages ( & mut msgs , 8_000 ) ;
let after = messages_char_len ( & msgs ) ;
assert! ( after < before ) ;
assert_eq! ( msgs [ 0 ] . text ( ) , " sys " ) ;
assert! ( msgs . iter ( ) . any ( | m | m . role = = Role ::User ) ) ;
let old_tool = msgs
. iter ( )
. find ( | m | m . role = = Role ::Tool & & m . tool_call_id . as_deref ( ) = = Some ( " c1 " ) ) ;
if let Some ( t ) = old_tool {
assert! (
t . text ( ) . contains ( " truncated " ) | | t . text ( ) . len ( ) < 5_000 ,
" {} " ,
t . text ( )
) ;
}
}
#[ tokio::test ]
async fn loop_stops_on_report_done ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-done- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
let mut messages = vec! [
ChatMessage ::system ( AGENT_SYSTEM ) ,
ChatMessage ::user ( " finish " ) ,
] ;
let calls = Arc ::new ( Mutex ::new ( 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 ( ) ;
assert_eq! ( verdict , AgentVerdict ::Blocked ( " permission denied " . into ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn loop_guard_blocks_repeated_calls ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-loop- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
std ::fs ::write ( dir . join ( " f.txt " ) , " hi " ) . unwrap ( ) ;
let mut messages = vec! [ ChatMessage ::user ( " loop " ) ] ;
let args = json! ( { " path " : " f.txt " } ) . to_string ( ) ;
2026-09-13 16:38:32 +00:00
let verdict = run_agent_with (
& mut messages ,
& ctx ,
12 ,
12 ,
100_000 ,
move | _msgs , _tools | {
let args = args . clone ( ) ;
async move {
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " r1 " , " read_file " , & args ) ] ,
) )
}
} ,
)
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} " ) ,
" read_file " ,
& 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-13 16:38:32 +00:00
assert! ( reason . contains ( " 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-13 16:38:32 +00:00
vec! [ tc ( " c1 " , " 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} " ) ,
" 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.
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} " ) ,
" 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 ) = > {
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-13 16:38:32 +00:00
assert! ( reason . contains ( " 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-13 16:38:32 +00:00
vec! [ tc ( " c1 " , " 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 ( )
. any ( | l | l . contains ( " 〔思考中〕 " ) & & l . contains ( " 輪 " ) ) ,
2026-09-13 09:16:30 +00:00
" missing thinking: {lines:?} "
) ;
assert! (
lines
. iter ( )
. any ( | l | l . contains ( " 〔工具〕 " ) & & l . contains ( " read_file " ) ) ,
" missing tools: {lines:?} "
) ;
assert! (
lines . iter ( ) . any ( | l | l . contains ( " 〔 完成〕 read_file" ) ) ,
" missing tool done: {lines:?} "
) ;
assert! (
lines
. iter ( )
. any ( | l | l . contains ( " 〔結束〕 " ) & & l . contains ( " done " ) ) ,
" missing end: {lines:?} "
) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
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 ,
vec! [ tc ( " r1 " , " read_file " , & args ) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
AgentVerdict ::Blocked ( reason ) = > {
assert! ( reason . contains ( " loop guard " ) , " {reason} " ) ;
}
other = > panic! ( " expected loop-guard blocked, got {other:?} " ) ,
}
assert_eq! ( * calls . lock ( ) . unwrap ( ) , LOOP_GUARD_REPEAT ) ;
2026-09-13 16:38:32 +00:00
assert_tool_results_paired ( & messages ) ;
2026-09-13 09:02:23 +00:00
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
2026-09-13 07:42:59 +00:00
}