From d965bb4b4a5b8aa2e02efb9d992150dee8fc73c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Sun, 13 Sep 2026 17:16:30 +0800 Subject: [PATCH] UX: live terminal progress; conclusion only when done --- README.md | 4 +- crates/grokboy-core/src/agent.rs | 251 +++++++++++++++++++++++++++++-- crates/grokboy/src/main.rs | 21 ++- docs/ACCEPTANCE.md | 8 +- docs/PRODUCT.md | 10 +- 5 files changed, 270 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index cb5ffae..5f5acb0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY # export GROKBOY_CONTEXT_CHARS=100000 # 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_PROGRESS=0 # silence live progress (思考/工具/續跑/結束) # export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent) cd ~/GrokBoy @@ -103,7 +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. +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 diff --git a/crates/grokboy-core/src/agent.rs b/crates/grokboy-core/src/agent.rs index cb198ce..01e55c5 100644 --- a/crates/grokboy-core/src/agent.rs +++ b/crates/grokboy-core/src/agent.rs @@ -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. 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). -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. 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."; @@ -83,14 +86,44 @@ pub fn max_rounds_total_budget() -> usize { .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) { match std::env::var("GROKBOY_PROGRESS") { 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 { let t = text.trim(); if t.chars().count() <= max_chars { @@ -252,11 +285,39 @@ pub async fn run_agent_with( max_rounds: usize, max_rounds_total: usize, context_budget: usize, - mut complete: F, + complete: F, ) -> Result where F: FnMut(Vec, Option) -> Fut, Fut: Future>, +{ + 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( + messages: &mut Vec, + tool_ctx: &ToolContext, + max_rounds: usize, + max_rounds_total: usize, + context_budget: usize, + mut complete: F, + mut on_progress: P, +) -> Result +where + F: FnMut(Vec, Option) -> Fut, + Fut: Future>, + P: FnMut(&str), { let tools = tool_definitions(); let mut prev_round_sig: Option = None; @@ -267,21 +328,36 @@ where let max_rounds = max_rounds.max(1); let max_rounds_total = max_rounds_total.max(1); + emit_progress_line( + &format!("〔開始〕最多 {max_rounds_total} 輪(每段 {max_rounds})"), + &mut on_progress, + ); + 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( + let verdict = AgentVerdict::Blocked(format_total_exhausted( max_rounds_total, &progress, - ))); + )); + emit_progress_line( + &format!("〔結束〕verdict={}", verdict.kind()), + &mut on_progress, + ); + return Ok(verdict); } let chunk_limit = max_rounds.min(remaining); for _round_in_chunk in 0..chunk_limit { 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 tool_calls = reply.tool_calls.clone().unwrap_or_default(); total_used += 1; @@ -290,13 +366,32 @@ where let last_text = reply.text().to_string(); messages.push(reply); if last_text.trim().is_empty() { - return Ok(AgentVerdict::Blocked( + let verdict = AgentVerdict::Blocked( "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). let sig = round_signature(&tool_calls); if prev_round_sig.as_deref() == Some(sig.as_str()) { @@ -310,7 +405,12 @@ where let reason = format!( "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); @@ -319,6 +419,10 @@ where for call in &tool_calls { let result = 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) { completion = parse_completion_verdict(&call.function.name, &result); } @@ -326,6 +430,10 @@ where } if let Some(verdict) = completion { + emit_progress_line( + &format!("〔結束〕verdict={}", verdict.kind()), + &mut on_progress, + ); return Ok(verdict); } } @@ -334,17 +442,25 @@ where let progress = summarize_progress(messages, context_budget, &mut complete).await; if total_used >= max_rounds_total { - return Ok(AgentVerdict::Blocked(format_total_exhausted( + let verdict = AgentVerdict::Blocked(format_total_exhausted( max_rounds_total, &progress, - ))); + )); + emit_progress_line( + &format!("〔結束〕verdict={}", verdict.kind()), + &mut on_progress, + ); + return Ok(verdict); } let next_chunk = chunk_idx + 1; let preview = preview_progress(&progress, 160); - emit_progress(&format!( - "〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}" - )); + emit_progress_line( + &format!( + "〔進度|尚未完成〕〔續跑〕第 {next_chunk} 段(已用 {total_used}/{max_rounds_total} 輪)進度:{preview}" + ), + &mut on_progress, + ); // 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("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("real task/tool workflow") || AGENT_SYSTEM.contains("final delivery")); assert!( AGENT_SYSTEM.contains("continue in chunks") || AGENT_SYSTEM.contains("save rounds"), "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] @@ -865,6 +990,100 @@ mod tests { 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::::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. #[tokio::test] async fn loop_guard_does_not_auto_continue() { diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index b765cdf..217a111 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -70,7 +70,7 @@ ENV: GROKBOY_CONTEXT_CHARS context budget (default 100000) 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_PROGRESS 0 = silence live progress 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 @@ -175,7 +175,7 @@ async fn cmd_run(args: &[String]) -> Result<()> { } session.touch(); let path = save_session(&session)?; - println!("{}", verdict.message()); + print_verdict(&verdict); eprintln!( "\n[verdict: {} | session {} → {}]", verdict.kind(), @@ -297,7 +297,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> { session.touch(); let path = save_session(&session)?; - println!("{}", verdict.message()); + print_verdict(&verdict); eprintln!( "[verdict: {} | session {} → {}]", 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() { eprintln!( "提示:這回合被擋下了,session 還在。請換更短、更具體的指令繼續(不要把 blocked 原文貼回來)。例:直接開 https://affiliate.shopee.tw/ ,需要登入就 browser_handoff,找到就 report_done。" diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 58e6335..ec362ef 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -70,7 +70,7 @@ ## 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] 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] 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" @@ -78,3 +78,9 @@ - [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 +## 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 + diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index c772482..8ed0cbc 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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). 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`). +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. +### 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) - `GROKBOY_MAX_ROUNDS` — rounds per chunk (default 12) - `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)