P8: auto-continue chunks like Grok Bot (progress, total ceiling)

This commit is contained in:
王性驊 2026-09-13 17:02:23 +08:00
parent 44b0d7c038
commit c94a32ea72
6 changed files with 392 additions and 87 deletions

View File

@ -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/<id>.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

View File

@ -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<ChatMessage>, 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<ChatMessage>,
@ -194,21 +226,31 @@ pub async fn run_agent(
max_rounds: usize,
) -> Result<AgentVerdict> {
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<F, Fut>(
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
mut complete: F,
) -> Result<AgentVerdict>
@ -219,74 +261,118 @@ where
let tools = tool_definitions();
let mut prev_round_sig: Option<String> = 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<AgentVerdict> = 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<AgentVerdict> = 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<F, Fut>(
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<F, Fut>(
messages: &[ChatMessage],
context_budget: usize,
max_rounds: usize,
complete: &mut F,
) -> String
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
{
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::<usize>().ok())
.filter(|&n| n > 0);
let got = max_rounds_total_budget();
assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL));
}
#[test]
fn round_signature_stable() {
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") };
}
}

View File

@ -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};

View File

@ -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);

View File

@ -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

View File

@ -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.