2026-09-13 09:02:23 +00:00
//! Multi-step OpenAI-compatible tool-calling ReAct loop
//! (P2: completion, loop guard, truncation; P8: chunked auto-continue).
2026-09-13 07:42:59 +00:00
use crate ::config ::Config ;
2026-09-13 07:51:13 +00:00
use crate ::model ::{ ChatMessage , Role , ToolCall , chat_completion } ;
use crate ::tools ::{ ToolContext , execute_tool , is_completion_tool , tool_definitions } ;
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 09:02:23 +00:00
/// Rounds per progress chunk (one beat). Absolute ceiling is `DEFAULT_MAX_ROUNDS_TOTAL`.
2026-09-13 07:42:59 +00:00
pub const DEFAULT_MAX_ROUNDS : usize = 12 ;
2026-09-13 09:02:23 +00:00
/// Absolute tool-round ceiling across auto-continued chunks (default 4 × 12).
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 = " \
You are GrokBoy , a concise local coding assistant with tools .
Use tools when they help solve the task ; otherwise answer directly .
Prefer short , clear answers . Traditional Chinese is welcome when the user writes in Chinese .
2026-09-13 08:37:51 +00:00
Available tools : shell , list_dir , read_file , write_file , report_done , report_blocked , request_user_confirm , and optional browser_ * ( Playwright DOM : browser_navigate , browser_snapshot , browser_click , browser_type , browser_eval , browser_handoff ) .
2026-09-13 07:57:57 +00:00
For web pages prefer DOM snapshot + selector / role click / type — not screenshots or pixel XY clicks .
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 .
If still researching / browsing , keep using tools ; live progress is shown by the runtime on stderr — you do not need to narrate every step as a conclusion .
If you must speak mid - flight without tools , say it is partial progress only — prefer continuing with tools instead .
report_done = final delivery only ( short summary when a real task / tool workflow is actually finished ) .
2026-09-13 08:54:03 +00:00
Call report_blocked when stuck or cannot proceed — 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 09:02:23 +00:00
/// Model called `report_blocked`, loop guard, or total round budget exhausted.
2026-09-13 07:51:13 +00:00
Blocked ( String ) ,
/// Model returned final text without a completion tool.
Answer ( String ) ,
}
impl AgentVerdict {
pub fn message ( & self ) -> & str {
match self {
Self ::Done ( s ) | Self ::Blocked ( s ) | Self ::Answer ( s ) = > s ,
}
}
pub fn kind ( & self ) -> & 'static str {
match self {
Self ::Done ( _ ) = > " done " ,
Self ::Blocked ( _ ) = > " blocked " ,
Self ::Answer ( _ ) = > " answer " ,
}
}
}
/// Resolve context budget from `GROKBOY_CONTEXT_CHARS` or default.
pub fn context_char_budget ( ) -> usize {
std ::env ::var ( " GROKBOY_CONTEXT_CHARS " )
. ok ( )
. and_then ( | s | s . parse ( ) . ok ( ) )
. filter ( | & n | n > 0 )
. unwrap_or ( DEFAULT_CONTEXT_CHARS )
}
2026-09-13 09:02:23 +00:00
/// Resolve rounds **per chunk** 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 09:16:30 +00:00
/// Progress beats on stderr unless `GROKBOY_PROGRESS=0`. Always newline + flush.
2026-09-13 09:02:23 +00:00
fn emit_progress ( msg : & str ) {
match std ::env ::var ( " GROKBOY_PROGRESS " ) {
Ok ( v ) if v = = " 0 " = > { }
2026-09-13 09:16:30 +00:00
_ = > {
use std ::io ::Write ;
let mut err = std ::io ::stderr ( ) ;
let _ = writeln! ( err , " {msg} " ) ;
let _ = err . flush ( ) ;
}
}
}
fn emit_progress_line ( msg : & str , on_progress : & mut impl FnMut ( & str ) ) {
emit_progress ( msg ) ;
on_progress ( msg ) ;
}
/// Short success/fail line for a finished tool invocation.
fn tool_progress_line ( name : & str , result_json : & str ) -> String {
let v : Value = serde_json ::from_str ( result_json ) . unwrap_or ( Value ::Null ) ;
if let Some ( err ) = v . get ( " error " ) {
let s = err
. as_str ( )
. map ( | x | x . to_string ( ) )
. unwrap_or_else ( | | err . to_string ( ) ) ;
return format! ( " 〔失敗〕 {name} : {} " , preview_progress ( & s , 80 ) ) ;
}
if v . get ( " blocked " ) . and_then ( | b | b . as_bool ( ) ) = = Some ( true ) {
let reason = v
. get ( " reason " )
. and_then ( | r | r . as_str ( ) )
. unwrap_or ( " blocked " ) ;
return format! ( " 〔失敗〕 {name} : {} " , preview_progress ( reason , 80 ) ) ;
2026-09-13 09:02:23 +00:00
}
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 {
let t = text . trim ( ) ;
if t . chars ( ) . count ( ) < = max_chars {
return t . to_string ( ) ;
}
let truncated : String = t . chars ( ) . take ( max_chars ) . collect ( ) ;
format! ( " {truncated} … " )
}
const MAX_ROUNDS_SUMMARY_NUDGE : & str = " The agent loop hit a chunk/round budget boundary without report_done or report_blocked. Summarize progress so far in concise Traditional Chinese (or concise English if the conversation was English). List what was tried and what remains. Do not call tools. " ;
2026-09-13 08:54:03 +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 {
format! ( " {} : {} " , call . function . name , call . function . arguments )
}
/// Signature for a whole round of tool calls (order-preserving).
pub fn round_signature ( calls : & [ ToolCall ] ) -> String {
calls
. iter ( )
. map ( tool_call_signature )
. collect ::< Vec < _ > > ( )
. join ( " \n " )
}
/// Approximate serialized size of one message (chars).
pub fn message_char_len ( msg : & ChatMessage ) -> usize {
let mut n = 8 ; // role overhead
if let Some ( c ) = & msg . content {
n + = c . len ( ) ;
}
if let Some ( id ) = & msg . tool_call_id {
n + = id . len ( ) ;
}
if let Some ( calls ) = & msg . tool_calls {
for c in calls {
n + = c . id . len ( ) + c . function . name . len ( ) + c . function . arguments . len ( ) + 16 ;
}
}
n
}
pub fn messages_char_len ( messages : & [ ChatMessage ] ) -> usize {
messages . iter ( ) . map ( message_char_len ) . sum ( )
}
/// Truncate older tool results / middle turns while keeping system + recent tail + last user goal.
/// Mutates in place. Keeps it simple: shrink old tool contents first, then drop middle messages.
pub fn truncate_messages ( messages : & mut Vec < ChatMessage > , budget : usize ) {
if budget = = 0 | | messages_char_len ( messages ) < = budget {
return ;
}
// 1) Shrink older tool message contents (keep last few tool results intact).
let tool_indices : Vec < usize > = messages
. iter ( )
. enumerate ( )
. filter ( | ( _ , m ) | m . role = = Role ::Tool )
. map ( | ( i , _ ) | i )
. collect ( ) ;
let keep_recent_tools = 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 ;
}
}
// 2) Drop middle messages while preserving: leading system*, last user, and a recent tail.
while messages_char_len ( messages ) > budget & & messages . len ( ) > 4 {
let first_drop = messages
. iter ( )
. enumerate ( )
. find ( | ( i , m ) | * i > 0 & & m . role ! = Role ::System )
. map ( | ( i , _ ) | i ) ;
let Some ( i ) = first_drop else { break } ;
// Never drop the last user message or the last two messages.
let last_user = messages
. iter ( )
. rposition ( | m | m . role = = Role ::User )
. unwrap_or ( messages . len ( ) ) ;
if i > = last_user | | i + 2 > = messages . len ( ) {
// Shrink remaining large contents instead.
for msg in messages . iter_mut ( ) {
if let Some ( content ) = msg . content . as_mut ( ) {
if content . len ( ) > 200 {
2026-09-13 09:22:54 +00:00
let keep = floor_char_boundary ( content , 200 ) ;
2026-09-13 07:51:13 +00:00
let omitted = content . len ( ) . saturating_sub ( keep ) ;
* content = format! ( " {} … \n [truncated {omitted} chars] " , & content [ .. keep ] ) ;
}
}
}
break ;
}
// If dropping an assistant with tool_calls, also drop following tool messages for those ids.
let drop_ids : Vec < String > = messages
. get ( i )
. and_then ( | m | m . tool_calls . as_ref ( ) )
. map ( | calls | calls . iter ( ) . map ( | c | c . id . clone ( ) ) . collect ( ) )
. unwrap_or_default ( ) ;
messages . remove ( i ) ;
let j = i ;
while j < messages . len ( ) {
let is_orphan_tool = messages [ j ] . role = = Role ::Tool
& & messages [ j ]
. tool_call_id
. as_ref ( )
. is_some_and ( | id | drop_ids . contains ( id ) ) ;
if is_orphan_tool {
messages . remove ( j ) ;
} else {
break ;
}
}
}
}
/// Run the agent with a live HTTP model client.
2026-09-13 09:02:23 +00:00
/// `max_rounds` is the per-chunk budget (`GROKBOY_MAX_ROUNDS`); total 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 09:02:23 +00:00
/// `max_rounds` = rounds per chunk; `max_rounds_total` = absolute ceiling across auto-continues.
2026-09-13 08:54:03 +00:00
/// `complete` receives a snapshot of messages and optional tool defs each round.
2026-09-13 09:02:23 +00:00
/// Pass `None` for tools to force a plain-text completion (used for progress summaries).
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 07:42:59 +00:00
let tools = tool_definitions ( ) ;
2026-09-13 07:51:13 +00:00
let mut prev_round_sig : Option < String > = None ;
let mut same_sig_streak : usize = 0 ;
2026-09-13 09:02:23 +00:00
let mut total_used : usize = 0 ;
let mut chunk_idx : usize = 0 ;
// Ensure total is at least one chunk's worth of progress possible.
let max_rounds = max_rounds . max ( 1 ) ;
let max_rounds_total = max_rounds_total . max ( 1 ) ;
2026-09-13 09:16:30 +00:00
emit_progress_line (
& format! ( " 〔開始〕最多 {max_rounds_total} 輪(每段 {max_rounds} ) " ) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
loop {
chunk_idx + = 1 ;
let remaining = max_rounds_total . saturating_sub ( total_used ) ;
if remaining = = 0 {
let progress = summarize_progress ( messages , context_budget , & mut complete ) . await ;
2026-09-13 09:16:30 +00:00
let verdict = AgentVerdict ::Blocked ( format_total_exhausted (
2026-09-13 09:02:23 +00:00
max_rounds_total ,
& progress ,
2026-09-13 09:16:30 +00:00
) ) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
return Ok ( verdict ) ;
2026-09-13 09:02:23 +00:00
}
let chunk_limit = max_rounds . min ( remaining ) ;
2026-09-13 07:51:13 +00:00
2026-09-13 09:02:23 +00:00
for _round_in_chunk in 0 .. chunk_limit {
truncate_messages ( messages , context_budget ) ;
2026-09-13 07:42:59 +00:00
2026-09-13 09:16:30 +00:00
emit_progress_line (
& format! ( " 〔思考中〕第 {} / {} 輪… " , total_used + 1 , max_rounds_total ) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
let reply = complete ( messages . clone ( ) , Some ( tools . clone ( ) ) ) . await ? ;
let tool_calls = reply . tool_calls . clone ( ) . unwrap_or_default ( ) ;
total_used + = 1 ;
2026-09-13 07:42:59 +00:00
2026-09-13 09:02:23 +00:00
if tool_calls . is_empty ( ) {
let last_text = reply . text ( ) . to_string ( ) ;
messages . push ( reply ) ;
if last_text . trim ( ) . is_empty ( ) {
2026-09-13 09:16:30 +00:00
let verdict = AgentVerdict ::Blocked (
2026-09-13 09:02:23 +00:00
" model returned empty final answer " . into ( ) ,
2026-09-13 09:16:30 +00:00
) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
return Ok ( verdict ) ;
2026-09-13 09:02:23 +00:00
}
2026-09-13 09:16:30 +00:00
let verdict = AgentVerdict ::Answer ( last_text ) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
return Ok ( verdict ) ;
2026-09-13 09:02:23 +00:00
}
2026-09-13 09:16:30 +00:00
let names : Vec < & str > = tool_calls
. iter ( )
. map ( | c | c . function . name . as_str ( ) )
. collect ( ) ;
emit_progress_line (
& format! ( " 〔 工具〕 round {total_used} : {} " , names . join ( " , " ) ) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
// Loop guard: identical tool-call round repeated N times → fail closed (no auto-continue).
let sig = round_signature ( & tool_calls ) ;
if prev_round_sig . as_deref ( ) = = Some ( sig . as_str ( ) ) {
same_sig_streak + = 1 ;
} else {
same_sig_streak = 1 ;
prev_round_sig = Some ( sig ) ;
}
if same_sig_streak > = LOOP_GUARD_REPEAT {
messages . push ( reply ) ;
let reason = format! (
" loop guard: identical tool calls repeated {LOOP_GUARD_REPEAT} times (round {total_used}) "
) ;
2026-09-13 09:16:30 +00:00
let verdict = AgentVerdict ::Blocked ( reason ) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
return Ok ( verdict ) ;
2026-09-13 07:51:13 +00:00
}
2026-09-13 07:42:59 +00:00
2026-09-13 07:51:13 +00:00
messages . push ( reply ) ;
2026-09-13 09:02:23 +00:00
let mut completion : Option < AgentVerdict > = None ;
for call in & tool_calls {
let result =
execute_tool ( tool_ctx , & call . function . name , & call . function . arguments ) . await ;
2026-09-13 09:16:30 +00:00
emit_progress_line (
& tool_progress_line ( & call . function . name , & result ) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
if completion . is_none ( ) & & is_completion_tool ( & call . function . name ) {
completion = parse_completion_verdict ( & call . function . name , & result ) ;
}
messages . push ( ChatMessage ::tool ( & call . id , result ) ) ;
}
2026-09-13 07:42:59 +00:00
2026-09-13 09:02:23 +00:00
if let Some ( verdict ) = completion {
2026-09-13 09:16:30 +00:00
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
return Ok ( verdict ) ;
2026-09-13 07:51:13 +00:00
}
2026-09-13 07:42:59 +00:00
}
2026-09-13 07:51:13 +00:00
2026-09-13 09:02:23 +00:00
// Chunk ended without report_done / report_blocked / Answer — progress beat, then maybe continue.
let progress = summarize_progress ( messages , context_budget , & mut complete ) . await ;
if total_used > = max_rounds_total {
2026-09-13 09:16:30 +00:00
let verdict = AgentVerdict ::Blocked ( format_total_exhausted (
2026-09-13 09:02:23 +00:00
max_rounds_total ,
& progress ,
2026-09-13 09:16:30 +00:00
) ) ;
emit_progress_line (
& format! ( " 〔 結束〕 verdict={} " , verdict . kind ( ) ) ,
& mut on_progress ,
) ;
return Ok ( verdict ) ;
2026-09-13 07:51:13 +00:00
}
2026-09-13 09:02:23 +00:00
let next_chunk = chunk_idx + 1 ;
let preview = preview_progress ( & progress , 160 ) ;
2026-09-13 09:16:30 +00:00
emit_progress_line (
& format! (
" 〔進度|尚未完成〕〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview} "
) ,
& mut on_progress ,
) ;
2026-09-13 09:02:23 +00:00
// Auto-continue another chunk in the same run_agent invocation.
2026-09-13 07:42:59 +00:00
}
2026-09-13 09:02:23 +00:00
}
2026-09-13 07:42:59 +00:00
2026-09-13 09:02:23 +00:00
fn format_total_exhausted ( max_rounds_total : usize , progress : & str ) -> String {
let progress = progress . trim ( ) ;
if progress . is_empty ( ) {
format! (
" blocked: reached max rounds (total budget {max_rounds_total}) without completion \n (total budget exhausted — continue in next REPL turn if needed) "
)
} else {
format! (
" blocked: reached max rounds (total budget {max_rounds_total}). Progress so far: \n {progress} \n (total budget exhausted — continue in next REPL turn if needed) "
)
}
2026-09-13 08:54:03 +00:00
}
2026-09-13 09:02:23 +00:00
/// One no-tools completion asking for a progress summary; falls back if it fails.
/// Does **not** push the nudge into `messages` (progress-only).
async fn summarize_progress < F , Fut > (
2026-09-13 08:54:03 +00:00
messages : & [ ChatMessage ] ,
context_budget : usize ,
complete : & mut F ,
) -> String
where
F : FnMut ( Vec < ChatMessage > , Option < Value > ) -> Fut ,
Fut : Future < Output = Result < ChatMessage > > ,
{
let mut msgs = messages . to_vec ( ) ;
truncate_messages ( & mut msgs , context_budget ) ;
msgs . push ( ChatMessage ::user ( MAX_ROUNDS_SUMMARY_NUDGE ) ) ;
match complete ( msgs , None ) . await {
Ok ( reply ) = > {
let text = reply . text ( ) . trim ( ) . to_string ( ) ;
if text . is_empty ( ) {
2026-09-13 09:02:23 +00:00
String ::new ( )
2026-09-13 08:54:03 +00:00
} else {
2026-09-13 09:02:23 +00:00
text
2026-09-13 08:54:03 +00:00
}
}
2026-09-13 09:02:23 +00:00
Err ( _ ) = > String ::new ( ) ,
2026-09-13 08:54:03 +00:00
}
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 ( ) ? ;
if v . get ( " error " ) . is_some ( ) {
return Some ( AgentVerdict ::Blocked ( format! (
" completion tool {name} failed: {} " ,
v [ " error " ]
) ) ) ;
}
match name {
" report_done " = > {
let msg = v
. get ( " message " )
. and_then ( | x | x . as_str ( ) )
. unwrap_or ( " done " )
. to_string ( ) ;
Some ( AgentVerdict ::Done ( msg ) )
}
" report_blocked " = > {
let reason = v
. get ( " reason " )
. and_then ( | x | x . as_str ( ) )
. unwrap_or ( " blocked " )
. to_string ( ) ;
Some ( AgentVerdict ::Blocked ( reason ) )
}
_ = > None ,
}
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
#[ 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 09:16:30 +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! (
AGENT_SYSTEM . contains ( " actually complete " )
| | AGENT_SYSTEM . contains ( " partial progress " ) ,
" no mid-task conclusion guidance missing "
) ;
assert! (
AGENT_SYSTEM . contains ( " final delivery " ) ,
" report_done = final delivery guidance missing "
) ;
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 ) ) ;
}
#[ test ]
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 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 5 , 5 , 100_000 , move | _msgs , _tools | async move {
2026-09-13 07:51:13 +00:00
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
" b1 " ,
" report_blocked " ,
& json! ( { " reason " : " permission denied " } ) . to_string ( ) ,
) ] ,
) )
} )
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Blocked ( " permission denied " . into ( ) ) ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn loop_guard_blocks_repeated_calls ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-loop- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
std ::fs ::write ( dir . join ( " f.txt " ) , " hi " ) . unwrap ( ) ;
let mut messages = vec! [ ChatMessage ::user ( " loop " ) ] ;
let args = json! ( { " path " : " f.txt " } ) . to_string ( ) ;
2026-09-13 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 12 , 12 , 100_000 , move | _msgs , _tools | {
2026-09-13 07:51:13 +00:00
let args = args . clone ( ) ;
async move {
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc ( " r1 " , " read_file " , & args ) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
AgentVerdict ::Blocked ( reason ) = > {
assert! ( reason . contains ( " loop guard " ) , " {reason} " ) ;
}
other = > panic! ( " expected blocked, got {other:?} " ) ,
}
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn max_rounds_fail_closed ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-max- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
std ::fs ::write ( dir . join ( " a.txt " ) , " 1 " ) . unwrap ( ) ;
std ::fs ::write ( dir . join ( " b.txt " ) , " 2 " ) . unwrap ( ) ;
let mut messages = vec! [ ChatMessage ::user ( " spin " ) ] ;
let n = Arc ::new ( Mutex ::new ( 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 {
AgentVerdict ::Blocked ( reason ) = > {
assert! ( reason . contains ( " max rounds " ) , " {reason} " ) ;
2026-09-13 08:54:03 +00:00
assert! ( reason . contains ( " Progress so far " ) , " {reason} " ) ;
assert! ( reason . contains ( " 已讀 a.txt " ) , " {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 "
) ;
// Completer called 2 tool rounds + 1 summary.
assert_eq! ( * n . lock ( ) . unwrap ( ) , 3 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
}
#[ tokio::test ]
async fn max_rounds_summary_fallback_on_complete_error ( ) {
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-maxfb- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
std ::fs ::write ( dir . join ( " a.txt " ) , " 1 " ) . unwrap ( ) ;
let mut messages = vec! [ ChatMessage ::user ( " spin " ) ] ;
let n = Arc ::new ( Mutex ::new ( 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 {
if tools . is_none ( ) {
return Err ( anyhow ::anyhow! ( " summary API down " ) ) ;
}
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
" c1 " ,
" read_file " ,
& json! ( { " path " : " a.txt " } ) . to_string ( ) ,
) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
AgentVerdict ::Blocked ( reason ) = > {
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 09:02:23 +00:00
let verdict = run_agent_with ( & mut messages , & ctx , 5 , 5 , 100_000 , move | _msgs , _tools | async move {
2026-09-13 07:51:13 +00:00
Ok ( ChatMessage ::assistant ( " hello there " ) )
} )
. 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 ( ) {
let _env = crate ::test_env ::lock ( ) ;
unsafe { std ::env ::set_var ( " GROKBOY_PROGRESS " , " 0 " ) } ;
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-chunk- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
for i in 1 ..= 8 {
std ::fs ::write ( dir . join ( format! ( " f {i} .txt " ) ) , format! ( " {i} " ) ) . unwrap ( ) ;
}
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
let mut messages = vec! [ ChatMessage ::user ( " long task " ) ] ;
let tool_rounds = Arc ::new ( Mutex ::new ( 0 usize ) ) ;
let tool_rounds2 = tool_rounds . clone ( ) ;
// chunk=3 → after 3 tools, progress summary, continue; on 5th tool round call report_done.
let verdict = run_agent_with ( & mut messages , & ctx , 3 , 20 , 100_000 , move | _msgs , tools | {
let tool_rounds2 = tool_rounds2 . clone ( ) ;
async move {
if tools . is_none ( ) {
return Ok ( ChatMessage ::assistant ( " 已讀部分檔案,繼續中。 " ) ) ;
}
let r = {
let mut g = tool_rounds2 . lock ( ) . unwrap ( ) ;
* g + = 1 ;
* g
} ;
if r > = 5 {
return Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
& format! ( " done {r} " ) ,
" report_done " ,
& json! ( { " message " : " 長任務完成 " } ) . to_string ( ) ,
) ] ,
) ) ;
}
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
& format! ( " c {r} " ) ,
" read_file " ,
& json! ( { " path " : format ! ( " f{r}.txt " ) } ) . to_string ( ) ,
) ] ,
) )
}
} )
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Done ( " 長任務完成 " . into ( ) ) ) ;
assert! (
* tool_rounds . lock ( ) . unwrap ( ) > = 5 ,
" expected at least 5 tool rounds across chunks "
) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
/// Hits absolute total ceiling → Blocked with progress + exhausted note.
#[ tokio::test ]
async fn total_ceiling_blocks_with_summary ( ) {
let _env = crate ::test_env ::lock ( ) ;
unsafe { std ::env ::set_var ( " GROKBOY_PROGRESS " , " 0 " ) } ;
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-ceil- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
for i in 1 ..= 10 {
std ::fs ::write ( dir . join ( format! ( " t {i} .txt " ) ) , format! ( " {i} " ) ) . unwrap ( ) ;
}
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
let mut messages = vec! [ ChatMessage ::user ( " spin forever " ) ] ;
let tool_rounds = Arc ::new ( Mutex ::new ( 0 usize ) ) ;
let tool_rounds2 = tool_rounds . clone ( ) ;
// chunk=2, total=4 → two chunks then Blocked (no report_done).
let verdict = run_agent_with ( & mut messages , & ctx , 2 , 4 , 100_000 , move | _msgs , tools | {
let tool_rounds2 = tool_rounds2 . clone ( ) ;
async move {
if tools . is_none ( ) {
return Ok ( ChatMessage ::assistant ( " 仍在讀檔,尚未完成。 " ) ) ;
}
let r = {
let mut g = tool_rounds2 . lock ( ) . unwrap ( ) ;
* g + = 1 ;
* g
} ;
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
& format! ( " c {r} " ) ,
" read_file " ,
& json! ( { " path " : format ! ( " t{r}.txt " ) } ) . to_string ( ) ,
) ] ,
) )
}
} )
. await
. unwrap ( ) ;
match verdict {
AgentVerdict ::Blocked ( reason ) = > {
assert! ( reason . contains ( " max rounds " ) , " {reason} " ) ;
assert! ( reason . contains ( " total budget 4 " ) , " {reason} " ) ;
assert! ( reason . contains ( " Progress so far " ) , " {reason} " ) ;
assert! ( reason . contains ( " exhausted " ) , " {reason} " ) ;
assert! ( reason . contains ( " 仍在讀檔 " ) , " {reason} " ) ;
}
other = > panic! ( " expected blocked at ceiling, got {other:?} " ) ,
}
assert_eq! ( * tool_rounds . lock ( ) . unwrap ( ) , 4 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
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 ( ) {
let _env = crate ::test_env ::lock ( ) ;
unsafe { std ::env ::set_var ( " GROKBOY_PROGRESS " , " 0 " ) } ;
let dir = std ::env ::temp_dir ( ) . join ( format! (
" grokboy-agent-progress-{} " ,
std ::process ::id ( )
) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
std ::fs ::write ( dir . join ( " a.txt " ) , " 1 " ) . unwrap ( ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
let mut messages = vec! [ ChatMessage ::user ( " go " ) ] ;
let captured = Arc ::new ( Mutex ::new ( Vec ::< String > ::new ( ) ) ) ;
let cap = captured . clone ( ) ;
let n = Arc ::new ( Mutex ::new ( 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 ,
vec! [ tc (
" c1 " ,
" read_file " ,
& json! ( { " path " : " a.txt " } ) . to_string ( ) ,
) ] ,
) )
} else {
Ok ( ChatMessage ::assistant_tool_calls (
None ,
vec! [ tc (
" done1 " ,
" report_done " ,
& json! ( { " message " : " 讀完了 " } ) . to_string ( ) ,
) ] ,
) )
}
}
} ,
move | msg | {
cap . lock ( ) . unwrap ( ) . push ( msg . to_string ( ) ) ;
} ,
)
. await
. unwrap ( ) ;
assert_eq! ( verdict , AgentVerdict ::Done ( " 讀完了 " . into ( ) ) ) ;
let lines = captured . lock ( ) . unwrap ( ) . clone ( ) ;
assert! (
lines . iter ( ) . any ( | l | l . contains ( " 〔開始〕 " ) & & l . contains ( " 最多 " ) ) ,
" missing start: {lines:?} "
) ;
assert! (
lines . iter ( ) . any ( | l | l . contains ( " 〔思考中〕 " ) & & l . contains ( " 輪 " ) ) ,
" missing thinking: {lines:?} "
) ;
assert! (
lines
. iter ( )
. any ( | l | l . contains ( " 〔工具〕 " ) & & l . contains ( " read_file " ) ) ,
" missing tools: {lines:?} "
) ;
assert! (
lines . iter ( ) . any ( | l | l . contains ( " 〔 完成〕 read_file" ) ) ,
" missing tool done: {lines:?} "
) ;
assert! (
lines
. iter ( )
. any ( | l | l . contains ( " 〔結束〕 " ) & & l . contains ( " done " ) ) ,
" missing end: {lines:?} "
) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
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 ( ) {
let _env = crate ::test_env ::lock ( ) ;
unsafe { std ::env ::set_var ( " GROKBOY_PROGRESS " , " 0 " ) } ;
let dir = std ::env ::temp_dir ( ) . join ( format! ( " grokboy-agent-noloop- {} " , std ::process ::id ( ) ) ) ;
let _ = std ::fs ::create_dir_all ( & dir ) ;
let ctx = ToolContext ::new ( dir . clone ( ) ) ;
std ::fs ::write ( dir . join ( " f.txt " ) , " hi " ) . unwrap ( ) ;
let mut messages = vec! [ ChatMessage ::user ( " loop " ) ] ;
let args = json! ( { " path " : " f.txt " } ) . to_string ( ) ;
let calls = Arc ::new ( Mutex ::new ( 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 ) ;
let _ = std ::fs ::remove_dir_all ( & dir ) ;
unsafe { std ::env ::remove_var ( " GROKBOY_PROGRESS " ) } ;
}
2026-09-13 07:42:59 +00:00
}