UX: live terminal progress; conclusion only when done

This commit is contained in:
王性驊 2026-09-13 17:16:30 +08:00
parent c94a32ea72
commit d965bb4b4a
5 changed files with 270 additions and 24 deletions

View File

@ -28,7 +28,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY
# export GROKBOY_CONTEXT_CHARS=100000 # export GROKBOY_CONTEXT_CHARS=100000
# export GROKBOY_MAX_ROUNDS=12 # rounds per chunk # export GROKBOY_MAX_ROUNDS=12 # rounds per chunk
# export GROKBOY_MAX_ROUNDS_TOTAL=48 # absolute ceiling across auto-continues # export GROKBOY_MAX_ROUNDS_TOTAL=48 # absolute ceiling across auto-continues
# export GROKBOY_PROGRESS=0 # silence 〔續跑〕 progress beats # export GROKBOY_PROGRESS=0 # silence live progress (思考/工具/續跑/結束)
# export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent) # export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent)
cd ~/GrokBoy cd ~/GrokBoy
@ -103,7 +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`). 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. 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. Long tasks **auto-continue** across chunks (`GROKBOY_MAX_ROUNDS` per beat, up to `GROKBOY_MAX_ROUNDS_TOTAL`) with live stderr progress (`〔思考中〕` / `〔工具〕` / `〔進度|尚未完成〕〔續跑〕` / …) — like Grok Bot — instead of hard-stopping for a user re-prompt after every chunk. Final Done/Answer prints as `〔結論〕`; mid-task progress is never the conclusion.
## Traditional Chinese ## Traditional Chinese

View File

@ -24,7 +24,10 @@ For web pages prefer DOM snapshot + selector/role click/type — not screenshots
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. 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. 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.
For greetings, small talk, clarifying questions, or when no tools are needed: reply with normal assistant text and stop (do not call report_done). 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. 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).
Call report_blocked when stuck or cannot proceed do not invent results or loop. 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'. 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."; Do not invent tool results call the tools.";
@ -83,13 +86,43 @@ pub fn max_rounds_total_budget() -> usize {
.unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL) .unwrap_or(DEFAULT_MAX_ROUNDS_TOTAL)
} }
/// Progress beats on stderr unless `GROKBOY_PROGRESS=0`. /// Progress beats on stderr unless `GROKBOY_PROGRESS=0`. Always newline + flush.
fn emit_progress(msg: &str) { fn emit_progress(msg: &str) {
match std::env::var("GROKBOY_PROGRESS") { match std::env::var("GROKBOY_PROGRESS") {
Ok(v) if v == "0" => {} Ok(v) if v == "0" => {}
_ => eprintln!("{msg}"), _ => {
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));
}
format!("〔完成〕{name}")
}
fn preview_progress(text: &str, max_chars: usize) -> String { fn preview_progress(text: &str, max_chars: usize) -> String {
let t = text.trim(); let t = text.trim();
@ -252,11 +285,39 @@ pub async fn run_agent_with<F, Fut>(
max_rounds: usize, max_rounds: usize,
max_rounds_total: usize, max_rounds_total: usize,
context_budget: usize, context_budget: usize,
mut complete: F, complete: F,
) -> Result<AgentVerdict> ) -> Result<AgentVerdict>
where where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut, F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>, 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>(
messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext,
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
mut complete: F,
mut on_progress: P,
) -> Result<AgentVerdict>
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>,
P: FnMut(&str),
{ {
let tools = tool_definitions(); let tools = tool_definitions();
let mut prev_round_sig: Option<String> = None; let mut prev_round_sig: Option<String> = None;
@ -267,21 +328,36 @@ where
let max_rounds = max_rounds.max(1); let max_rounds = max_rounds.max(1);
let max_rounds_total = max_rounds_total.max(1); let max_rounds_total = max_rounds_total.max(1);
emit_progress_line(
&format!("〔開始〕最多 {max_rounds_total} 輪(每段 {max_rounds}"),
&mut on_progress,
);
loop { loop {
chunk_idx += 1; chunk_idx += 1;
let remaining = max_rounds_total.saturating_sub(total_used); let remaining = max_rounds_total.saturating_sub(total_used);
if remaining == 0 { if remaining == 0 {
let progress = summarize_progress(messages, context_budget, &mut complete).await; let progress = summarize_progress(messages, context_budget, &mut complete).await;
return Ok(AgentVerdict::Blocked(format_total_exhausted( let verdict = AgentVerdict::Blocked(format_total_exhausted(
max_rounds_total, max_rounds_total,
&progress, &progress,
))); ));
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict);
} }
let chunk_limit = max_rounds.min(remaining); let chunk_limit = max_rounds.min(remaining);
for _round_in_chunk in 0..chunk_limit { for _round_in_chunk in 0..chunk_limit {
truncate_messages(messages, context_budget); truncate_messages(messages, context_budget);
emit_progress_line(
&format!("〔思考中〕第 {}/{} 輪…", total_used + 1, max_rounds_total),
&mut on_progress,
);
let reply = complete(messages.clone(), Some(tools.clone())).await?; let reply = complete(messages.clone(), Some(tools.clone())).await?;
let tool_calls = reply.tool_calls.clone().unwrap_or_default(); let tool_calls = reply.tool_calls.clone().unwrap_or_default();
total_used += 1; total_used += 1;
@ -290,13 +366,32 @@ where
let last_text = reply.text().to_string(); let last_text = reply.text().to_string();
messages.push(reply); messages.push(reply);
if last_text.trim().is_empty() { if last_text.trim().is_empty() {
return Ok(AgentVerdict::Blocked( let verdict = AgentVerdict::Blocked(
"model returned empty final answer".into(), "model returned empty final answer".into(),
)); );
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict);
} }
return Ok(AgentVerdict::Answer(last_text)); let verdict = AgentVerdict::Answer(last_text);
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict);
} }
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,
);
// Loop guard: identical tool-call round repeated N times → fail closed (no auto-continue). // Loop guard: identical tool-call round repeated N times → fail closed (no auto-continue).
let sig = round_signature(&tool_calls); let sig = round_signature(&tool_calls);
if prev_round_sig.as_deref() == Some(sig.as_str()) { if prev_round_sig.as_deref() == Some(sig.as_str()) {
@ -310,7 +405,12 @@ where
let reason = format!( let reason = format!(
"loop guard: identical tool calls repeated {LOOP_GUARD_REPEAT} times (round {total_used})" "loop guard: identical tool calls repeated {LOOP_GUARD_REPEAT} times (round {total_used})"
); );
return Ok(AgentVerdict::Blocked(reason)); let verdict = AgentVerdict::Blocked(reason);
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict);
} }
messages.push(reply); messages.push(reply);
@ -319,6 +419,10 @@ where
for call in &tool_calls { for call in &tool_calls {
let result = let result =
execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await; execute_tool(tool_ctx, &call.function.name, &call.function.arguments).await;
emit_progress_line(
&tool_progress_line(&call.function.name, &result),
&mut on_progress,
);
if completion.is_none() && is_completion_tool(&call.function.name) { if completion.is_none() && is_completion_tool(&call.function.name) {
completion = parse_completion_verdict(&call.function.name, &result); completion = parse_completion_verdict(&call.function.name, &result);
} }
@ -326,6 +430,10 @@ where
} }
if let Some(verdict) = completion { if let Some(verdict) = completion {
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict); return Ok(verdict);
} }
} }
@ -334,17 +442,25 @@ where
let progress = summarize_progress(messages, context_budget, &mut complete).await; let progress = summarize_progress(messages, context_budget, &mut complete).await;
if total_used >= max_rounds_total { if total_used >= max_rounds_total {
return Ok(AgentVerdict::Blocked(format_total_exhausted( let verdict = AgentVerdict::Blocked(format_total_exhausted(
max_rounds_total, max_rounds_total,
&progress, &progress,
))); ));
emit_progress_line(
&format!("結束verdict={}", verdict.kind()),
&mut on_progress,
);
return Ok(verdict);
} }
let next_chunk = chunk_idx + 1; let next_chunk = chunk_idx + 1;
let preview = preview_progress(&progress, 160); let preview = preview_progress(&progress, 160);
emit_progress(&format!( emit_progress_line(
"〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}" &format!(
)); "〔進度|尚未完成〕〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}"
),
&mut on_progress,
);
// Auto-continue another chunk in the same run_agent invocation. // Auto-continue another chunk in the same run_agent invocation.
} }
} }
@ -447,11 +563,20 @@ mod tests {
assert!(AGENT_SYSTEM.contains("irreversible") || AGENT_SYSTEM.contains("Never publish")); assert!(AGENT_SYSTEM.contains("irreversible") || AGENT_SYSTEM.contains("Never publish"));
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese")); assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
assert!(AGENT_SYSTEM.contains("do not call report_done")); assert!(AGENT_SYSTEM.contains("do not call report_done"));
assert!(AGENT_SYSTEM.contains("real task/tool workflow")); assert!(AGENT_SYSTEM.contains("real task/tool workflow") || AGENT_SYSTEM.contains("final delivery"));
assert!( assert!(
AGENT_SYSTEM.contains("continue in chunks") || AGENT_SYSTEM.contains("save rounds"), AGENT_SYSTEM.contains("continue in chunks") || AGENT_SYSTEM.contains("save rounds"),
"P8 chunk continue guidance missing" "P8 chunk continue guidance missing"
); );
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"
);
} }
#[test] #[test]
@ -865,6 +990,100 @@ mod tests {
unsafe { std::env::remove_var("GROKBOY_PROGRESS") }; unsafe { std::env::remove_var("GROKBOY_PROGRESS") };
} }
/// 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(0usize));
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") };
}
/// Loop guard still fail-closes without auto-continuing forever. /// Loop guard still fail-closes without auto-continuing forever.
#[tokio::test] #[tokio::test]
async fn loop_guard_does_not_auto_continue() { async fn loop_guard_does_not_auto_continue() {

View File

@ -70,7 +70,7 @@ ENV:
GROKBOY_CONTEXT_CHARS context budget (default 100000) GROKBOY_CONTEXT_CHARS context budget (default 100000)
GROKBOY_MAX_ROUNDS ReAct tool rounds per chunk (default 12) GROKBOY_MAX_ROUNDS ReAct tool rounds per chunk (default 12)
GROKBOY_MAX_ROUNDS_TOTAL Absolute round ceiling across chunks (default 48) GROKBOY_MAX_ROUNDS_TOTAL Absolute round ceiling across chunks (default 48)
GROKBOY_PROGRESS 0 = silence progress beats on stderr GROKBOY_PROGRESS 0 = silence live progress on stderr (///)
GROKBOY_BROWSER_HEADED 1 = always launch Chromium headed (visible; for run/agent) GROKBOY_BROWSER_HEADED 1 = always launch Chromium headed (visible; for run/agent)
GROKBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort GROKBOY_HANDOFF_AUTO 1 = auto-resume handoff (tests); abort = auto-abort
GROKBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny GROKBOY_CONFIRM_AUTO 1 = auto-approve confirm (tests); abort = auto-deny
@ -175,7 +175,7 @@ async fn cmd_run(args: &[String]) -> Result<()> {
} }
session.touch(); session.touch();
let path = save_session(&session)?; let path = save_session(&session)?;
println!("{}", verdict.message()); print_verdict(&verdict);
eprintln!( eprintln!(
"\n[verdict: {} | session {} → {}]", "\n[verdict: {} | session {} → {}]",
verdict.kind(), verdict.kind(),
@ -297,7 +297,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> {
session.touch(); session.touch();
let path = save_session(&session)?; let path = save_session(&session)?;
println!("{}", verdict.message()); print_verdict(&verdict);
eprintln!( eprintln!(
"[verdict: {} | session {} → {}]", "[verdict: {} | session {} → {}]",
verdict.kind(), verdict.kind(),
@ -624,6 +624,21 @@ async fn cmd_smoke() -> Result<()> {
} }
/// User-facing final answer: blank line + 〔結論〕 for Done/Answer.
/// Blocked prints the message as-is (recovery hint follows separately).
fn print_verdict(verdict: &AgentVerdict) {
println!();
match verdict {
AgentVerdict::Done(msg) | AgentVerdict::Answer(msg) => {
println!("〔結論〕{msg}");
}
AgentVerdict::Blocked(msg) => {
println!("{msg}");
}
}
}
fn eprintln_blocked_recovery_hint_agent() { fn eprintln_blocked_recovery_hint_agent() {
eprintln!( eprintln!(
"提示這回合被擋下了session 還在。請換更短、更具體的指令繼續(不要把 blocked 原文貼回來)。例:直接開 https://affiliate.shopee.tw/ ,需要登入就 browser_handoff找到就 report_done。" "提示這回合被擋下了session 還在。請換更短、更具體的指令繼續(不要把 blocked 原文貼回來)。例:直接開 https://affiliate.shopee.tw/ ,需要登入就 browser_handoff找到就 report_done。"

View File

@ -70,7 +70,7 @@
## P8 — auto-continue chunks like Grok Bot ## 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] `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] Progress summary between chunks (no-tools); stderr live progress unless `GROKBOY_PROGRESS=0`
- [x] Absolute ceiling `GROKBOY_MAX_ROUNDS_TOTAL` (default 48) → Blocked + progress + exhausted note - [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] 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] AGENT_SYSTEM: large work may continue in chunks; still `report_done` when finished; don't stop early to "save rounds"
@ -78,3 +78,9 @@
- [x] Offline tests: >chunk then Done; total ceiling Blocked; loop guard unchanged - [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 - [x] Docs PRODUCT / ACCEPTANCE / README; `cargo test` / `grokboy smoke` green without API key
## Live terminal progress + conclusion only when done
- [x] Stderr progress always on unless `GROKBOY_PROGRESS=0`: `〔開始〕` / `〔思考中〕` / `〔工具〕` / `〔完成〕|〔失敗〕` / `〔進度|尚未完成〕〔續跑〕` / `〔結束〕` (flushed; no long blank waits)
- [x] AGENT_SYSTEM: no mid-task `report_done` or final wrap-up; keep tools while researching; partial mid-flight text only if needed; `report_done` = final delivery
- [x] CLI: Done/Answer → blank line + `〔結論〕`; Blocked keeps recovery hint; chunk progress is stderr-only (not the final answer)
- [x] Offline unit test: progress callback invoked on rounds; `cargo test` / `grokboy smoke` green without API key

View File

@ -86,14 +86,20 @@ Long legitimate work should **not** hard-stop at max rounds with only `blocked:
1. **`GROKBOY_MAX_ROUNDS`** (default 12) = rounds **per chunk** (one progress beat). 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. 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. 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`). 4. Progress on stderr (always on unless `GROKBOY_PROGRESS=0`): live beats so the terminal is never blank during API/tool waits — `〔開始〕`, `〔思考中〕`, `〔工具〕`, `〔完成〕`/`〔失敗〕`, `〔進度|尚未完成〕〔續跑〕…`, `〔結束〕`.
5. Blocked recovery hint only when truly stopped (loop guard or total ceiling), not after every chunk. 5. Blocked recovery hint only when truly stopped (loop guard or total ceiling), not after every chunk.
### Live terminal progress + conclusion only when done
- Mid-task: runtime shows where work is (`做到哪`); do **not** treat chunk progress as the user-facing final answer.
- `AGENT_SYSTEM`: no `report_done` / final wrap-up until the task is actually complete; mid-flight plain text must be labeled partial — prefer continuing tools.
- CLI `run` / `agent`: final Done/Answer printed as blank line + `〔結論〕…`; Blocked keeps recovery hint. Progress stays on stderr.
### Env (P8) ### Env (P8)
- `GROKBOY_MAX_ROUNDS` — rounds per chunk (default 12) - `GROKBOY_MAX_ROUNDS` — rounds per chunk (default 12)
- `GROKBOY_MAX_ROUNDS_TOTAL` — absolute ceiling (default 48) - `GROKBOY_MAX_ROUNDS_TOTAL` — absolute ceiling (default 48)
- `GROKBOY_PROGRESS=0` — silence continuation progress beats - `GROKBOY_PROGRESS=0` — silence all live progress lines on stderr
## Roadmap hint (later) ## Roadmap hint (later)