From 15d271b656165cf89e02a47e3157e754bb8701f4 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 16:54:03 +0800 Subject: [PATCH] UX: chat answer preference, max-round summary, blocked recovery hint Prefer natural assistant text for greetings/no-tools; summarize progress before max-rounds Blocked; CLI recovery hints for run/agent; GROKBOY_MAX_ROUNDS. --- README.md | 4 +- crates/grokboy-core/src/agent.rs | 136 ++++++++++++++++++++++++++++--- crates/grokboy-core/src/lib.rs | 4 +- crates/grokboy/src/main.rs | 51 ++++++++---- docs/ACCEPTANCE.md | 8 ++ docs/PRODUCT.md | 14 +++- 6 files changed, 185 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index dd22d17..53bd77b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # GrokBoy -Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + confirm-before-post (`request_user_confirm`). +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. ## Status @@ -13,6 +13,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + con | P4 human handoff | done | | P5 interactive agent REPL | done | | P6 scenario playbooks + confirm | done | +| P7 agent UX polish | done (slice) | No Docker desktop, no Codex/LazyBoy fork. Product notes: [`docs/PRODUCT.md`](docs/PRODUCT.md). @@ -24,6 +25,7 @@ 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) cd ~/GrokBoy diff --git a/crates/grokboy-core/src/agent.rs b/crates/grokboy-core/src/agent.rs index 88b40bc..a2ed26f 100644 --- a/crates/grokboy-core/src/agent.rs +++ b/crates/grokboy-core/src/agent.rs @@ -19,8 +19,9 @@ Available tools: shell, list_dir, read_file, write_file, report_done, report_blo For web pages prefer DOM snapshot + selector/role click/type — not screenshots or pixel XY clicks. 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. -When the task is finished, call report_done with a short summary. -If you are stuck or cannot proceed, call report_blocked with the reason — do not invent results or loop. +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. Do not invent tool results — call the tools."; /// Final verdict from the agent loop (fail-closed when stuck). @@ -59,6 +60,17 @@ pub fn context_char_budget() -> usize { .unwrap_or(DEFAULT_CONTEXT_CHARS) } +/// Resolve max ReAct rounds from `GROKBOY_MAX_ROUNDS` or default. +pub fn max_rounds_budget() -> usize { + std::env::var("GROKBOY_MAX_ROUNDS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n| n > 0) + .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."; + /// Stable signature for a single tool call (name + args). pub fn tool_call_signature(call: &ToolCall) -> String { format!("{}:{}", call.function.name, call.function.arguments) @@ -185,13 +197,14 @@ pub async fn run_agent( 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, Some(&tools)).await } + async move { chat_completion(&config, &msgs, tools.as_ref()).await } }) .await } /// Core loop with injectable completer (for offline tests). -/// `complete` receives a snapshot of messages and tool defs each round. +/// `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). pub async fn run_agent_with( messages: &mut Vec, tool_ctx: &ToolContext, @@ -200,7 +213,7 @@ pub async fn run_agent_with( mut complete: F, ) -> Result where - F: FnMut(Vec, Value) -> Fut, + F: FnMut(Vec, Option) -> Fut, Fut: Future>, { let tools = tool_definitions(); @@ -210,7 +223,7 @@ where for round in 0..max_rounds { truncate_messages(messages, context_budget); - let reply = complete(messages.clone(), tools.clone()).await?; + let reply = complete(messages.clone(), Some(tools.clone())).await?; let tool_calls = reply.tool_calls.clone().unwrap_or_default(); if tool_calls.is_empty() { @@ -258,9 +271,36 @@ where } } - Ok(AgentVerdict::Blocked(format!( - "blocked: reached max rounds ({max_rounds}) without completion" - ))) + 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( + messages: &[ChatMessage], + context_budget: usize, + max_rounds: usize, + complete: &mut F, +) -> String +where + F: FnMut(Vec, Option) -> Fut, + Fut: Future>, +{ + 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)); + match complete(msgs, None).await { + Ok(reply) => { + let text = reply.text().trim().to_string(); + if text.is_empty() { + fallback + } else { + format!("blocked: reached max rounds ({max_rounds}). Progress so far:\n{text}") + } + } + Err(_) => fallback, + } } fn parse_completion_verdict(name: &str, result_json: &str) -> Option { @@ -320,6 +360,20 @@ mod tests { assert!(AGENT_SYSTEM.contains("browser_handoff")); 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")); + } + + #[test] + fn max_rounds_budget_defaults_to_12() { + // Unset in unit tests may still inherit env; only assert default constant + parse path. + assert_eq!(DEFAULT_MAX_ROUNDS, 12); + let parsed = std::env::var("GROKBOY_MAX_ROUNDS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|&n| n > 0); + let got = max_rounds_budget(); + assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS)); } #[test] @@ -478,13 +532,19 @@ 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, 100_000, move |_msgs, tools| { let i = { let mut g = n2.lock().unwrap(); *g += 1; *g }; async move { + // Final no-tools summary call after max rounds. + if tools.is_none() { + return Ok(ChatMessage::assistant( + "已讀 a.txt 與 b.txt,任務尚未完成。", + )); + } let path = if i == 1 { "a.txt" } else { "b.txt" }; Ok(ChatMessage::assistant_tool_calls( None, @@ -502,9 +562,65 @@ mod tests { match verdict { AgentVerdict::Blocked(reason) => { assert!(reason.contains("max rounds"), "{reason}"); + assert!(reason.contains("Progress so far"), "{reason}"); + assert!(reason.contains("已讀 a.txt"), "{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")), + "summary nudge should not be pushed into messages" + ); + // Completer called 2 tool rounds + 1 summary. + assert_eq!(*n.lock().unwrap(), 3); + let _ = std::fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn max_rounds_summary_fallback_on_complete_error() { + let dir = std::env::temp_dir().join(format!("grokboy-agent-maxfb-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let ctx = ToolContext::new(dir.clone()); + std::fs::write(dir.join("a.txt"), "1").unwrap(); + let mut messages = vec![ChatMessage::user("spin")]; + 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 _ = { + let mut g = n2.lock().unwrap(); + *g += 1; + *g + }; + async move { + if tools.is_none() { + return Err(anyhow::anyhow!("summary API down")); + } + Ok(ChatMessage::assistant_tool_calls( + None, + vec![tc( + "c1", + "read_file", + &json!({"path": "a.txt"}).to_string(), + )], + )) + } + }) + .await + .unwrap(); + + match verdict { + AgentVerdict::Blocked(reason) => { + assert_eq!( + reason, + "blocked: reached max rounds (1) without completion" + ); + } + other => panic!("expected blocked fallback, got {other:?}"), + } let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/grokboy-core/src/lib.rs b/crates/grokboy-core/src/lib.rs index c6823ef..aa4ed12 100644 --- a/crates/grokboy-core/src/lib.rs +++ b/crates/grokboy-core/src/lib.rs @@ -10,8 +10,8 @@ mod tools; pub use agent::{ AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, LOOP_GUARD_REPEAT, - context_char_budget, message_char_len, messages_char_len, round_signature, run_agent, - run_agent_with, tool_call_signature, truncate_messages, + context_char_budget, max_rounds_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}; diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index 1135775..a4f39ac 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result, anyhow}; use grokboy_core::{ - AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, - execute_tool, is_completion_tool, load_or_create, messages_char_len, run_agent, save_session, - stream_chat, tool_definitions, truncate_messages, + 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, }; use serde_json::json; use std::io::{self, Write}; @@ -68,6 +68,7 @@ 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_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 @@ -164,13 +165,8 @@ async fn cmd_run(args: &[String]) -> Result<()> { session.push(ChatMessage::user(&prompt)); let tool_ctx = ToolContext::new(session.cwd.clone()); - let verdict = run_agent( - &config, - &mut session.messages, - &tool_ctx, - DEFAULT_MAX_ROUNDS, - ) - .await?; + let max_rounds = max_rounds_budget(); + let verdict = run_agent(&config, &mut session.messages, &tool_ctx, max_rounds).await?; if let Some(url) = tool_ctx.last_browser_url_value() { session.last_browser_url = Some(url); @@ -186,6 +182,7 @@ async fn cmd_run(args: &[String]) -> Result<()> { ); // Non-zero exit on blocked so scripts can detect fail-closed. if matches!(verdict, AgentVerdict::Blocked(_)) { + eprintln_blocked_recovery_hint_run(&session.id); return Err(anyhow!("agent blocked: {}", verdict.message())); } Ok(()) @@ -289,13 +286,8 @@ async fn cmd_agent(args: &[String]) -> Result<()> { } session.push(ChatMessage::user(input)); - let verdict = run_agent( - &config, - &mut session.messages, - &tool_ctx, - DEFAULT_MAX_ROUNDS, - ) - .await?; + let max_rounds = max_rounds_budget(); + let verdict = run_agent(&config, &mut session.messages, &tool_ctx, max_rounds).await?; if let Some(url) = tool_ctx.last_browser_url_value() { session.last_browser_url = Some(url); @@ -310,6 +302,9 @@ async fn cmd_agent(args: &[String]) -> Result<()> { session.id, path.display() ); + if matches!(verdict, AgentVerdict::Blocked(_)) { + eprintln_blocked_recovery_hint_agent(); + } // Interactive: blocked does not exit the REPL — user can continue. } @@ -319,7 +314,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> { } async fn cmd_smoke() -> Result<()> { - println!("GrokBoy smoke (offline P6)…"); + println!("GrokBoy smoke (offline P6+UX)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; @@ -603,6 +598,13 @@ async fn cmd_smoke() -> Result<()> { println!(" agent parse ok"); println!(" agent session ok"); + // max rounds env resolver (default 12) + let rounds = max_rounds_budget(); + if rounds == 0 { + return Err(anyhow!("max_rounds_budget returned 0")); + } + println!(" max rounds ok ({rounds})"); + let _ = std::fs::remove_dir_all(&dir); if Config::from_env().is_ok() { @@ -615,6 +617,19 @@ async fn cmd_smoke() -> Result<()> { Ok(()) } + +fn eprintln_blocked_recovery_hint_agent() { + eprintln!( + "提示:這回合被擋下了,session 還在。請換更短、更具體的指令繼續(不要把 blocked 原文貼回來)。例:直接開 https://affiliate.shopee.tw/ ,需要登入就 browser_handoff,找到就 report_done。" + ); +} + +fn eprintln_blocked_recovery_hint_run(session_id: &str) { + eprintln!( + "提示:這次 run 被擋下了。session 已保存(可用 --session {session_id} 繼續)。請換更短、更具體的指令重跑(不要把 blocked 原文貼回來)。" + ); +} + fn assert_ok(v: &serde_json::Value, label: &str) -> Result<()> { if let Some(err) = v.get("error") { return Err(anyhow!("{label} failed: {err}")); diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 9dc1cf4..a60a94c 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -60,3 +60,11 @@ - [x] Wired in tools.rs / confirm.rs; offline unit + smoke - [x] PRODUCT.md / README pointer to scenario playbooks - [x] `cargo test` / `grokboy smoke` green without API key + +## P7 slice — agent UX polish +- [x] AGENT_SYSTEM: natural text for greetings/small talk/no-tools; `report_done` only for finished tool workflows; `report_blocked` when stuck +- [x] Max-rounds: progress summary via final no-tools complete (fallback to bare message); offline injectable completer covered in unit tests +- [x] Env `GROKBOY_MAX_ROUNDS` (default 12) used by CLI `run`/`agent`; keep `DEFAULT_MAX_ROUNDS = 12` +- [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 + diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 5464b97..2bd2aac 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -67,6 +67,18 @@ Pattern: See `docs/ACCEPTANCE.md` section P6. +## P7 slice — agent UX polish (chat / max-rounds / blocked recovery) + +Small product UX fixes (not a full phase): + +1. **Natural chat answers** — `AGENT_SYSTEM` prefers plain assistant text for greetings / small talk / no-tools; `report_done` only after a real tool workflow; `report_blocked` when stuck. +2. **Max-rounds progress summary** — hitting `GROKBOY_MAX_ROUNDS` (default 12) triggers one final no-tools completion asking for a concise progress summary (Traditional Chinese welcome); that text becomes the Blocked message (`blocked: reached max rounds (N). Progress so far:\n…`). Falls back to the bare max-rounds string if the summary call fails. +3. **Blocked recovery hint** — `run` / `agent` eprintln a Traditional Chinese tip to continue with a shorter concrete instruction (session kept; do not paste the blocked blob back). + +### Env + +- `GROKBOY_MAX_ROUNDS` — ReAct tool rounds (default 12; `DEFAULT_MAX_ROUNDS`). + ## Roadmap hint (later) -P7+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance. +P7+ may deepen session UX further, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance.