diff --git a/README.md b/README.md index 53bd77b..cb5ffae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GrokBoy -Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + confirm-before-post. **P7 UX slice**: natural chat answers, max-round progress summary, blocked recovery hints. +Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + confirm-before-post. **P7 UX**: natural chat, max-round summary, blocked recovery. **P8**: auto-continue chunks like Grok Bot (progress beats, total ceiling). ## Status @@ -14,6 +14,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + con | P5 interactive agent REPL | done | | P6 scenario playbooks + confirm | done | | P7 agent UX polish | done (slice) | +| P8 auto-continue chunks | done | No Docker desktop, no Codex/LazyBoy fork. Product notes: [`docs/PRODUCT.md`](docs/PRODUCT.md). @@ -25,8 +26,10 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY # export GROKBOY_BASE_URL=https://api.x.ai/v1 # export GROKBOY_MODEL=grok-4.6 # export GROKBOY_CONTEXT_CHARS=100000 -# export GROKBOY_MAX_ROUNDS=12 -# export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent) +# export GROKBOY_MAX_ROUNDS=12 # rounds per chunk +# export GROKBOY_MAX_ROUNDS_TOTAL=48 # absolute ceiling across auto-continues +# export GROKBOY_PROGRESS=0 # silence 〔續跑〕 progress beats +# export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent) cd ~/GrokBoy cargo run -p grokboy -- chat @@ -100,6 +103,7 @@ Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_bl Sessions are stored under `~/.grokboy/sessions/.json` (may include `last_browser_url`). The agent stops on `report_done` / `report_blocked`, blocks identical tool rounds (×3), and truncates old context when over budget. +Long tasks **auto-continue** across chunks (`GROKBOY_MAX_ROUNDS` per beat, up to `GROKBOY_MAX_ROUNDS_TOTAL`) with `〔續跑〕` progress on stderr — like Grok Bot — instead of hard-stopping for a user re-prompt after every chunk. ## Traditional Chinese diff --git a/crates/grokboy-core/src/agent.rs b/crates/grokboy-core/src/agent.rs index a2ed26f..cb198ce 100644 --- a/crates/grokboy-core/src/agent.rs +++ b/crates/grokboy-core/src/agent.rs @@ -1,4 +1,5 @@ -//! Multi-step OpenAI-compatible tool-calling ReAct loop (P2: completion, loop guard, truncation). +//! Multi-step OpenAI-compatible tool-calling ReAct loop +//! (P2: completion, loop guard, truncation; P8: chunked auto-continue). use crate::config::Config; use crate::model::{ChatMessage, Role, ToolCall, chat_completion}; @@ -7,7 +8,10 @@ use anyhow::Result; use serde_json::Value; use std::future::Future; +/// Rounds per progress chunk (one beat). Absolute ceiling is `DEFAULT_MAX_ROUNDS_TOTAL`. pub const DEFAULT_MAX_ROUNDS: usize = 12; +/// Absolute tool-round ceiling across auto-continued chunks (default 4 × 12). +pub const DEFAULT_MAX_ROUNDS_TOTAL: usize = 48; pub const DEFAULT_CONTEXT_CHARS: usize = 100_000; pub const LOOP_GUARD_REPEAT: usize = 3; @@ -22,6 +26,7 @@ Never publish/send social posts (Threads, Facebook, Instagram, X/Twitter, etc.) For greetings, small talk, clarifying questions, or when no tools are needed: reply with normal assistant text and stop (do not call report_done). Call report_done only when a real task/tool workflow is actually finished, with a short summary. Call report_blocked when stuck or cannot proceed — do not invent results or loop. +If work is large, keep using tools across the session; the runtime may continue in chunks — still call report_done when truly finished; don't stop early just to 'save rounds'. Do not invent tool results — call the tools."; /// Final verdict from the agent loop (fail-closed when stuck). @@ -29,7 +34,7 @@ Do not invent tool results — call the tools."; pub enum AgentVerdict { /// Model called `report_done`. Done(String), - /// Model called `report_blocked`, loop guard, or max rounds. + /// Model called `report_blocked`, loop guard, or total round budget exhausted. Blocked(String), /// Model returned final text without a completion tool. Answer(String), @@ -60,7 +65,7 @@ pub fn context_char_budget() -> usize { .unwrap_or(DEFAULT_CONTEXT_CHARS) } -/// Resolve max ReAct rounds from `GROKBOY_MAX_ROUNDS` or default. +/// Resolve rounds **per chunk** from `GROKBOY_MAX_ROUNDS` or default. pub fn max_rounds_budget() -> usize { std::env::var("GROKBOY_MAX_ROUNDS") .ok() @@ -69,7 +74,33 @@ pub fn max_rounds_budget() -> usize { .unwrap_or(DEFAULT_MAX_ROUNDS) } -const MAX_ROUNDS_SUMMARY_NUDGE: &str = "The agent loop hit the maximum number of tool rounds 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."; +/// 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) +} + +/// Progress beats on stderr unless `GROKBOY_PROGRESS=0`. +fn emit_progress(msg: &str) { + match std::env::var("GROKBOY_PROGRESS") { + Ok(v) if v == "0" => {} + _ => eprintln!("{msg}"), + } +} + +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."; /// Stable signature for a single tool call (name + args). pub fn tool_call_signature(call: &ToolCall) -> String { @@ -187,6 +218,7 @@ pub fn truncate_messages(messages: &mut Vec, budget: usize) { } /// Run the agent with a live HTTP model client. +/// `max_rounds` is the per-chunk budget (`GROKBOY_MAX_ROUNDS`); total ceiling comes from env. pub async fn run_agent( config: &Config, messages: &mut Vec, @@ -194,21 +226,31 @@ pub async fn run_agent( max_rounds: usize, ) -> Result { let budget = context_char_budget(); + let total = max_rounds_total_budget(); let config = config.clone(); - run_agent_with(messages, tool_ctx, max_rounds, budget, move |msgs, tools| { - let config = config.clone(); - async move { chat_completion(&config, &msgs, tools.as_ref()).await } - }) + run_agent_with( + messages, + tool_ctx, + max_rounds, + total, + budget, + move |msgs, tools| { + let config = config.clone(); + async move { chat_completion(&config, &msgs, tools.as_ref()).await } + }, + ) .await } /// Core loop with injectable completer (for offline tests). +/// `max_rounds` = rounds per chunk; `max_rounds_total` = absolute ceiling across auto-continues. /// `complete` receives a snapshot of messages and optional tool defs each round. -/// Pass `None` for tools to force a plain-text completion (used for max-rounds summary). +/// Pass `None` for tools to force a plain-text completion (used for progress summaries). pub async fn run_agent_with( messages: &mut Vec, tool_ctx: &ToolContext, max_rounds: usize, + max_rounds_total: usize, context_budget: usize, mut complete: F, ) -> Result @@ -219,74 +261,118 @@ where let tools = tool_definitions(); let mut prev_round_sig: Option = None; let mut same_sig_streak: usize = 0; + 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); - for round in 0..max_rounds { - truncate_messages(messages, context_budget); + 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; + return Ok(AgentVerdict::Blocked(format_total_exhausted( + max_rounds_total, + &progress, + ))); + } + let chunk_limit = max_rounds.min(remaining); - let reply = complete(messages.clone(), Some(tools.clone())).await?; - let tool_calls = reply.tool_calls.clone().unwrap_or_default(); + for _round_in_chunk in 0..chunk_limit { + truncate_messages(messages, context_budget); - if tool_calls.is_empty() { - let last_text = reply.text().to_string(); - messages.push(reply); - if last_text.trim().is_empty() { - return Ok(AgentVerdict::Blocked( - "model returned empty final answer".into(), - )); + let reply = complete(messages.clone(), Some(tools.clone())).await?; + let tool_calls = reply.tool_calls.clone().unwrap_or_default(); + total_used += 1; + + if tool_calls.is_empty() { + let last_text = reply.text().to_string(); + messages.push(reply); + if last_text.trim().is_empty() { + return Ok(AgentVerdict::Blocked( + "model returned empty final answer".into(), + )); + } + return Ok(AgentVerdict::Answer(last_text)); } - return Ok(AgentVerdict::Answer(last_text)); - } - // Loop guard: identical tool-call round repeated N times → fail closed. - 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 {})", - round + 1 - ); - return Ok(AgentVerdict::Blocked(reason)); - } - - messages.push(reply); - - let mut completion: Option = None; - for call in &tool_calls { - let result = - execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await; - if completion.is_none() && is_completion_tool(&call.function.name) { - completion = parse_completion_verdict(&call.function.name, &result); + // 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})" + ); + return Ok(AgentVerdict::Blocked(reason)); + } + + messages.push(reply); + + let mut completion: Option = None; + for call in &tool_calls { + let result = + execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await; + 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)); + } + + if let Some(verdict) = completion { + return Ok(verdict); } - messages.push(ChatMessage::tool(&call.id, result)); } - if let Some(verdict) = completion { - return Ok(verdict); + // 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 { + return Ok(AgentVerdict::Blocked(format_total_exhausted( + max_rounds_total, + &progress, + ))); } + + let next_chunk = chunk_idx + 1; + let preview = preview_progress(&progress, 160); + emit_progress(&format!( + "〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}" + )); + // Auto-continue another chunk in the same run_agent invocation. } - - let summary = summarize_after_max_rounds(messages, context_budget, max_rounds, &mut complete).await; - Ok(AgentVerdict::Blocked(summary)) } -/// One final no-tools completion asking for a progress summary; falls back if it fails. -async fn summarize_after_max_rounds( +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)" + ) + } +} + +/// 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( messages: &[ChatMessage], context_budget: usize, - max_rounds: usize, complete: &mut F, ) -> String where F: FnMut(Vec, Option) -> Fut, Fut: Future>, { - let fallback = format!("blocked: reached max rounds ({max_rounds}) without completion"); let mut msgs = messages.to_vec(); truncate_messages(&mut msgs, context_budget); msgs.push(ChatMessage::user(MAX_ROUNDS_SUMMARY_NUDGE)); @@ -294,12 +380,12 @@ where Ok(reply) => { let text = reply.text().trim().to_string(); if text.is_empty() { - fallback + String::new() } else { - format!("blocked: reached max rounds ({max_rounds}). Progress so far:\n{text}") + text } } - Err(_) => fallback, + Err(_) => String::new(), } } @@ -362,6 +448,10 @@ mod tests { assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese")); assert!(AGENT_SYSTEM.contains("do not call report_done")); assert!(AGENT_SYSTEM.contains("real task/tool workflow")); + assert!( + AGENT_SYSTEM.contains("continue in chunks") || AGENT_SYSTEM.contains("save rounds"), + "P8 chunk continue guidance missing" + ); } #[test] @@ -381,6 +471,17 @@ mod tests { assert_eq!(DEFAULT_MAX_ROUNDS, 12); } + #[test] + fn default_max_rounds_total_is_48() { + assert_eq!(DEFAULT_MAX_ROUNDS_TOTAL, 48); + let parsed = std::env::var("GROKBOY_MAX_ROUNDS_TOTAL") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0); + let got = max_rounds_total_budget(); + assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL)); + } + #[test] fn round_signature_stable() { let a = vec![tc("1", "list_dir", r#"{"path":"."}"#)]; @@ -438,7 +539,7 @@ mod tests { let calls = Arc::new(Mutex::new(0usize)); let calls2 = calls.clone(); - let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| { + let verdict = run_agent_with(&mut messages, &ctx, 5, 5, 100_000, move |_msgs, _tools| { let n = { let mut g = calls2.lock().unwrap(); *g += 1; @@ -474,7 +575,7 @@ mod tests { let ctx = ToolContext::new(dir.clone()); let mut messages = vec![ChatMessage::user("x")]; - let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move { + 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( @@ -500,7 +601,7 @@ mod tests { let mut messages = vec![ChatMessage::user("loop")]; let args = json!({"path": "f.txt"}).to_string(); - let verdict = run_agent_with(&mut messages, &ctx, 12, 100_000, move |_msgs, _tools| { + 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( @@ -532,7 +633,7 @@ mod tests { let n = Arc::new(Mutex::new(0usize)); let n2 = n.clone(); - let verdict = run_agent_with(&mut messages, &ctx, 2, 100_000, move |_msgs, tools| { + let verdict = run_agent_with(&mut messages, &ctx, 2, 2, 100_000, move |_msgs, tools| { let i = { let mut g = n2.lock().unwrap(); *g += 1; @@ -564,14 +665,19 @@ mod tests { assert!(reason.contains("max rounds"), "{reason}"); assert!(reason.contains("Progress so far"), "{reason}"); assert!(reason.contains("已讀 a.txt"), "{reason}"); + assert!( + reason.contains("total budget") || reason.contains("exhausted"), + "{reason}" + ); } other => panic!("expected blocked, got {other:?}"), } // Summary nudge must not pollute the persisted conversation. assert!( - !messages - .iter() - .any(|m| m.text().contains("maximum number of tool rounds")), + !messages.iter().any(|m| { + m.text().contains("chunk/round budget") + || m.text().contains("maximum number of tool rounds") + }), "summary nudge should not be pushed into messages" ); // Completer called 2 tool rounds + 1 summary. @@ -589,7 +695,7 @@ mod tests { let n = Arc::new(Mutex::new(0usize)); let n2 = n.clone(); - let verdict = run_agent_with(&mut messages, &ctx, 1, 100_000, move |_msgs, tools| { + let verdict = run_agent_with(&mut messages, &ctx, 1, 1, 100_000, move |_msgs, tools| { let _ = { let mut g = n2.lock().unwrap(); *g += 1; @@ -614,9 +720,11 @@ mod tests { match verdict { AgentVerdict::Blocked(reason) => { - assert_eq!( - reason, - "blocked: reached max rounds (1) without completion" + assert!( + reason.contains("max rounds") + && reason.contains("total budget 1") + && reason.contains("without completion"), + "{reason}" ); } other => panic!("expected blocked fallback, got {other:?}"), @@ -631,7 +739,7 @@ mod tests { let ctx = ToolContext::new(dir.clone()); let mut messages = vec![ChatMessage::user("hi")]; - let verdict = run_agent_with(&mut messages, &ctx, 5, 100_000, move |_msgs, _tools| async move { + let verdict = run_agent_with(&mut messages, &ctx, 5, 5, 100_000, move |_msgs, _tools| async move { Ok(ChatMessage::assistant("hello there")) }) .await @@ -640,4 +748,164 @@ mod tests { assert_eq!(verdict, AgentVerdict::Answer("hello there".into())); let _ = std::fs::remove_dir_all(&dir); } + + /// Completer needs > chunk_size tool rounds then report_done → Done (auto-continue). + #[tokio::test] + async fn auto_continue_across_chunks_then_done() { + 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(0usize)); + 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(0usize)); + 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") }; + } + + /// 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(0usize)); + let calls2 = calls.clone(); + + // Huge total budget — must still stop at loop guard after 3 identical rounds. + let verdict = run_agent_with(&mut messages, &ctx, 12, 48, 100_000, move |_msgs, tools| { + let args = args.clone(); + let calls2 = calls2.clone(); + async move { + if tools.is_none() { + panic!("should not request progress summary on loop guard"); + } + *calls2.lock().unwrap() += 1; + Ok(ChatMessage::assistant_tool_calls( + None, + vec![tc("r1", "read_file", &args)], + )) + } + }) + .await + .unwrap(); + + match verdict { + AgentVerdict::Blocked(reason) => { + assert!(reason.contains("loop guard"), "{reason}"); + } + other => panic!("expected loop-guard blocked, got {other:?}"), + } + assert_eq!(*calls.lock().unwrap(), LOOP_GUARD_REPEAT); + let _ = std::fs::remove_dir_all(&dir); + unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; + } } diff --git a/crates/grokboy-core/src/lib.rs b/crates/grokboy-core/src/lib.rs index f543363..17445ff 100644 --- a/crates/grokboy-core/src/lib.rs +++ b/crates/grokboy-core/src/lib.rs @@ -9,9 +9,10 @@ mod session; mod tools; pub use agent::{ - AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, LOOP_GUARD_REPEAT, - context_char_budget, max_rounds_budget, message_char_len, messages_char_len, round_signature, - run_agent, run_agent_with, tool_call_signature, truncate_messages, + AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, + DEFAULT_MAX_ROUNDS_TOTAL, LOOP_GUARD_REPEAT, context_char_budget, max_rounds_budget, + max_rounds_total_budget, message_char_len, messages_char_len, round_signature, run_agent, + run_agent_with, tool_call_signature, truncate_messages, }; pub use browser::{INSTALL_HINT as BROWSER_INSTALL_HINT, HandoffWait, browser_oneshot, browser_self_test, find_helper_script, wait_for_handoff_resume}; pub use confirm::{ConfirmWait, confirm_tool_definition, execute_request_user_confirm, wait_for_user_confirm}; diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index a4f39ac..b765cdf 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result, anyhow}; use grokboy_core::{ AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, Session, ToolContext, execute_tool, - is_completion_tool, load_or_create, max_rounds_budget, messages_char_len, run_agent, - save_session, stream_chat, tool_definitions, truncate_messages, + is_completion_tool, load_or_create, max_rounds_budget, max_rounds_total_budget, + messages_char_len, run_agent, save_session, stream_chat, tool_definitions, truncate_messages, }; use serde_json::json; use std::io::{self, Write}; @@ -51,7 +51,7 @@ async fn run() -> Result<()> { fn print_help() { println!( "\ -GrokBoy — minimal local CLI agent (P6: confirm-before-post + scenario playbooks) +GrokBoy — minimal local CLI agent (P8: auto-continue chunks like Grok Bot) USAGE: grokboy chat Interactive streaming chat (no tools) @@ -68,7 +68,9 @@ ENV: GROKBOY_BASE_URL default https://api.x.ai/v1 GROKBOY_MODEL default grok-4.6 GROKBOY_CONTEXT_CHARS context budget (default 100000) - GROKBOY_MAX_ROUNDS ReAct tool rounds (default 12) + GROKBOY_MAX_ROUNDS ReAct tool rounds per chunk (default 12) + GROKBOY_MAX_ROUNDS_TOTAL Absolute round ceiling across chunks (default 48) + GROKBOY_PROGRESS 0 = silence 〔續跑〕 progress beats on stderr GROKBOY_BROWSER_HEADED 1 = always launch Chromium headed (visible; for run/agent) GROKBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort GROKBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny @@ -314,7 +316,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> { } async fn cmd_smoke() -> Result<()> { - println!("GrokBoy smoke (offline P6+UX)…"); + println!("GrokBoy smoke (offline P8 auto-continue)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; @@ -598,12 +600,16 @@ async fn cmd_smoke() -> Result<()> { println!(" agent parse ok"); println!(" agent session ok"); - // max rounds env resolver (default 12) + // max rounds env resolver (per chunk default 12; total default 48) let rounds = max_rounds_budget(); if rounds == 0 { return Err(anyhow!("max_rounds_budget returned 0")); } - println!(" max rounds ok ({rounds})"); + let total = max_rounds_total_budget(); + if total == 0 { + return Err(anyhow!("max_rounds_total_budget returned 0")); + } + println!(" max rounds ok (chunk={rounds}, total={total})"); let _ = std::fs::remove_dir_all(&dir); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index a60a94c..58e6335 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -68,3 +68,13 @@ - [x] CLI Blocked recovery hint (Traditional Chinese) for `run` and `agent`; agent REPL stays open on blocked - [x] Docs note in PRODUCT.md / ACCEPTANCE; `cargo test` / `grokboy smoke` green without API key +## P8 — auto-continue chunks like Grok Bot +- [x] `GROKBOY_MAX_ROUNDS` = rounds per chunk (default 12); auto-continue another chunk in same `run_agent` when chunk ends without completion +- [x] Progress summary between chunks (no-tools); stderr `〔續跑〕…` unless `GROKBOY_PROGRESS=0` +- [x] Absolute ceiling `GROKBOY_MAX_ROUNDS_TOTAL` (default 48) → Blocked + progress + exhausted note +- [x] Loop guard / identical tool rounds ×3 still Blocked without auto-continue +- [x] AGENT_SYSTEM: large work may continue in chunks; still `report_done` when finished; don't stop early to "save rounds" +- [x] CLI `run`/`agent` use per-chunk budget; recovery hint only on true stop (loop / total ceiling) +- [x] Offline tests: >chunk then Done; total ceiling Blocked; loop guard unchanged +- [x] Docs PRODUCT / ACCEPTANCE / README; `cargo test` / `grokboy smoke` green without API key + diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 2bd2aac..c772482 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -77,8 +77,24 @@ Small product UX fixes (not a full phase): ### Env -- `GROKBOY_MAX_ROUNDS` — ReAct tool rounds (default 12; `DEFAULT_MAX_ROUNDS`). +- `GROKBOY_MAX_ROUNDS` — ReAct tool rounds **per chunk** (default 12; `DEFAULT_MAX_ROUNDS`). + +## P8 — auto-continue chunks like Grok Bot + +Long legitimate work should **not** hard-stop at max rounds with only `blocked: max rounds` waiting for the user to paste a recovery command. Match real Grok Bot: keep going, post progress beats, fail-closed only when truly stuck. + +1. **`GROKBOY_MAX_ROUNDS`** (default 12) = rounds **per chunk** (one progress beat). +2. When a chunk ends without `report_done` / `report_blocked` / final Answer → short progress summary (no-tools) → **auto-continue** another chunk in the **same** `run_agent` invocation. +3. **Stop conditions:** completion tools / empty-tools Answer; identical-tool loop guard ×3 (no continue); absolute ceiling **`GROKBOY_MAX_ROUNDS_TOTAL`** (default 48) → Blocked with progress + total-budget-exhausted note. +4. Progress on stderr: `〔續跑〕第 N 段(已用 X/Y 輪)進度:…` (silence with `GROKBOY_PROGRESS=0`). +5. Blocked recovery hint only when truly stopped (loop guard or total ceiling), not after every chunk. + +### Env (P8) + +- `GROKBOY_MAX_ROUNDS` — rounds per chunk (default 12) +- `GROKBOY_MAX_ROUNDS_TOTAL` — absolute ceiling (default 48) +- `GROKBOY_PROGRESS=0` — silence continuation progress beats ## Roadmap hint (later) -P7+ may deepen session UX further, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance. +Later may deepen session UX further, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance.