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_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/<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.
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

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.
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,12 +86,42 @@ 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 {
@ -252,11 +285,39 @@ pub async fn run_agent_with<F, Fut>(
max_rounds: usize,
max_rounds_total: usize,
context_budget: usize,
mut complete: F,
complete: F,
) -> Result<AgentVerdict>
where
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
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 mut prev_round_sig: Option<String> = 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::<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.
#[tokio::test]
async fn loop_guard_does_not_auto_continue() {

View File

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

View File

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

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