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.
This commit is contained in:
王性驊 2026-09-13 16:54:03 +08:00
parent a36f0ca42e
commit 15d271b656
6 changed files with 185 additions and 32 deletions

View File

@ -1,6 +1,6 @@
# GrokBoy # 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 ## Status
@ -13,6 +13,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + con
| P4 human handoff | done | | P4 human handoff | done |
| P5 interactive agent REPL | done | | P5 interactive agent REPL | done |
| P6 scenario playbooks + confirm | 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). 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_BASE_URL=https://api.x.ai/v1
# export GROKBOY_MODEL=grok-4.6 # export GROKBOY_MODEL=grok-4.6
# export GROKBOY_CONTEXT_CHARS=100000 # export GROKBOY_CONTEXT_CHARS=100000
# export GROKBOY_MAX_ROUNDS=12
# 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

View File

@ -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. 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. 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.
When the task is finished, call report_done with a short summary. For greetings, small talk, clarifying questions, or when no tools are needed: reply with normal assistant text and stop (do not call report_done).
If you are stuck or cannot proceed, call report_blocked with the reason do not invent results or loop. 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."; Do not invent tool results call the tools.";
/// Final verdict from the agent loop (fail-closed when stuck). /// 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) .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). /// Stable signature for a single tool call (name + args).
pub fn tool_call_signature(call: &ToolCall) -> String { pub fn tool_call_signature(call: &ToolCall) -> String {
format!("{}:{}", call.function.name, call.function.arguments) format!("{}:{}", call.function.name, call.function.arguments)
@ -185,13 +197,14 @@ pub async fn run_agent(
let config = config.clone(); let config = config.clone();
run_agent_with(messages, tool_ctx, max_rounds, budget, move |msgs, tools| { run_agent_with(messages, tool_ctx, max_rounds, budget, move |msgs, tools| {
let config = config.clone(); let config = config.clone();
async move { chat_completion(&config, &msgs, Some(&tools)).await } async move { chat_completion(&config, &msgs, tools.as_ref()).await }
}) })
.await .await
} }
/// Core loop with injectable completer (for offline tests). /// 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<F, Fut>( pub async fn run_agent_with<F, Fut>(
messages: &mut Vec<ChatMessage>, messages: &mut Vec<ChatMessage>,
tool_ctx: &ToolContext, tool_ctx: &ToolContext,
@ -200,7 +213,7 @@ pub async fn run_agent_with<F, Fut>(
mut complete: F, mut complete: F,
) -> Result<AgentVerdict> ) -> Result<AgentVerdict>
where where
F: FnMut(Vec<ChatMessage>, Value) -> Fut, F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
Fut: Future<Output = Result<ChatMessage>>, Fut: Future<Output = Result<ChatMessage>>,
{ {
let tools = tool_definitions(); let tools = tool_definitions();
@ -210,7 +223,7 @@ where
for round in 0..max_rounds { for round in 0..max_rounds {
truncate_messages(messages, context_budget); 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(); let tool_calls = reply.tool_calls.clone().unwrap_or_default();
if tool_calls.is_empty() { if tool_calls.is_empty() {
@ -258,9 +271,36 @@ where
} }
} }
Ok(AgentVerdict::Blocked(format!( let summary = summarize_after_max_rounds(messages, context_budget, max_rounds, &mut complete).await;
"blocked: reached max rounds ({max_rounds}) without completion" 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>(
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));
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<AgentVerdict> { fn parse_completion_verdict(name: &str, result_json: &str) -> Option<AgentVerdict> {
@ -320,6 +360,20 @@ mod tests {
assert!(AGENT_SYSTEM.contains("browser_handoff")); assert!(AGENT_SYSTEM.contains("browser_handoff"));
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("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::<usize>().ok())
.filter(|&n| n > 0);
let got = max_rounds_budget();
assert_eq!(got, parsed.unwrap_or(DEFAULT_MAX_ROUNDS));
} }
#[test] #[test]
@ -478,13 +532,19 @@ mod tests {
let n = Arc::new(Mutex::new(0usize)); let n = Arc::new(Mutex::new(0usize));
let n2 = n.clone(); 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 i = {
let mut g = n2.lock().unwrap(); let mut g = n2.lock().unwrap();
*g += 1; *g += 1;
*g *g
}; };
async move { 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" }; let path = if i == 1 { "a.txt" } else { "b.txt" };
Ok(ChatMessage::assistant_tool_calls( Ok(ChatMessage::assistant_tool_calls(
None, None,
@ -502,9 +562,65 @@ mod tests {
match verdict { match verdict {
AgentVerdict::Blocked(reason) => { AgentVerdict::Blocked(reason) => {
assert!(reason.contains("max rounds"), "{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:?}"), 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); let _ = std::fs::remove_dir_all(&dir);
} }

View File

@ -10,8 +10,8 @@ mod tools;
pub use agent::{ pub use agent::{
AGENT_SYSTEM, AgentVerdict, DEFAULT_CONTEXT_CHARS, DEFAULT_MAX_ROUNDS, LOOP_GUARD_REPEAT, 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, context_char_budget, max_rounds_budget, message_char_len, messages_char_len, round_signature,
run_agent_with, tool_call_signature, truncate_messages, 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 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}; pub use confirm::{ConfirmWait, confirm_tool_definition, execute_request_user_confirm, wait_for_user_confirm};

View File

@ -1,8 +1,8 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use grokboy_core::{ use grokboy_core::{
AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, DEFAULT_MAX_ROUNDS, Session, ToolContext, AGENT_SYSTEM, AgentVerdict, ChatMessage, Config, Session, ToolContext, execute_tool,
execute_tool, is_completion_tool, load_or_create, messages_char_len, run_agent, save_session, is_completion_tool, load_or_create, max_rounds_budget, messages_char_len, run_agent,
stream_chat, tool_definitions, truncate_messages, save_session, stream_chat, tool_definitions, truncate_messages,
}; };
use serde_json::json; use serde_json::json;
use std::io::{self, Write}; use std::io::{self, Write};
@ -68,6 +68,7 @@ ENV:
GROKBOY_BASE_URL default https://api.x.ai/v1 GROKBOY_BASE_URL default https://api.x.ai/v1
GROKBOY_MODEL default grok-4.6 GROKBOY_MODEL default grok-4.6
GROKBOY_CONTEXT_CHARS context budget (default 100000) 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_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
@ -164,13 +165,8 @@ async fn cmd_run(args: &[String]) -> Result<()> {
session.push(ChatMessage::user(&prompt)); session.push(ChatMessage::user(&prompt));
let tool_ctx = ToolContext::new(session.cwd.clone()); let tool_ctx = ToolContext::new(session.cwd.clone());
let verdict = run_agent( let max_rounds = max_rounds_budget();
&config, let verdict = run_agent(&config, &mut session.messages, &tool_ctx, max_rounds).await?;
&mut session.messages,
&tool_ctx,
DEFAULT_MAX_ROUNDS,
)
.await?;
if let Some(url) = tool_ctx.last_browser_url_value() { if let Some(url) = tool_ctx.last_browser_url_value() {
session.last_browser_url = Some(url); 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. // Non-zero exit on blocked so scripts can detect fail-closed.
if matches!(verdict, AgentVerdict::Blocked(_)) { if matches!(verdict, AgentVerdict::Blocked(_)) {
eprintln_blocked_recovery_hint_run(&session.id);
return Err(anyhow!("agent blocked: {}", verdict.message())); return Err(anyhow!("agent blocked: {}", verdict.message()));
} }
Ok(()) Ok(())
@ -289,13 +286,8 @@ async fn cmd_agent(args: &[String]) -> Result<()> {
} }
session.push(ChatMessage::user(input)); session.push(ChatMessage::user(input));
let verdict = run_agent( let max_rounds = max_rounds_budget();
&config, let verdict = run_agent(&config, &mut session.messages, &tool_ctx, max_rounds).await?;
&mut session.messages,
&tool_ctx,
DEFAULT_MAX_ROUNDS,
)
.await?;
if let Some(url) = tool_ctx.last_browser_url_value() { if let Some(url) = tool_ctx.last_browser_url_value() {
session.last_browser_url = Some(url); session.last_browser_url = Some(url);
@ -310,6 +302,9 @@ async fn cmd_agent(args: &[String]) -> Result<()> {
session.id, session.id,
path.display() path.display()
); );
if matches!(verdict, AgentVerdict::Blocked(_)) {
eprintln_blocked_recovery_hint_agent();
}
// Interactive: blocked does not exit the REPL — user can continue. // 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<()> { async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline P6)…"); println!("GrokBoy smoke (offline P6+UX)…");
let stamp = uuid_like(); let stamp = uuid_like();
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
std::fs::create_dir_all(&dir).context("temp dir")?; std::fs::create_dir_all(&dir).context("temp dir")?;
@ -603,6 +598,13 @@ async fn cmd_smoke() -> Result<()> {
println!(" agent parse ok"); println!(" agent parse ok");
println!(" agent session 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); let _ = std::fs::remove_dir_all(&dir);
if Config::from_env().is_ok() { if Config::from_env().is_ok() {
@ -615,6 +617,19 @@ async fn cmd_smoke() -> Result<()> {
Ok(()) 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<()> { fn assert_ok(v: &serde_json::Value, label: &str) -> Result<()> {
if let Some(err) = v.get("error") { if let Some(err) = v.get("error") {
return Err(anyhow!("{label} failed: {err}")); return Err(anyhow!("{label} failed: {err}"));

View File

@ -60,3 +60,11 @@
- [x] Wired in tools.rs / confirm.rs; offline unit + smoke - [x] Wired in tools.rs / confirm.rs; offline unit + smoke
- [x] PRODUCT.md / README pointer to scenario playbooks - [x] PRODUCT.md / README pointer to scenario playbooks
- [x] `cargo test` / `grokboy smoke` green without API key - [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

View File

@ -67,6 +67,18 @@ Pattern:
See `docs/ACCEPTANCE.md` section P6. 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) ## 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.