P8: auto-continue chunks like Grok Bot (progress, total ceiling)
This commit is contained in:
parent
44b0d7c038
commit
c94a32ea72
10
README.md
10
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/<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
|
||||
|
||||
|
|
|
|||
|
|
@ -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") };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue