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:
parent
a36f0ca42e
commit
15d271b656
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<F, Fut>(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
tool_ctx: &ToolContext,
|
||||
|
|
@ -200,7 +213,7 @@ pub async fn run_agent_with<F, Fut>(
|
|||
mut complete: F,
|
||||
) -> Result<AgentVerdict>
|
||||
where
|
||||
F: FnMut(Vec<ChatMessage>, Value) -> Fut,
|
||||
F: FnMut(Vec<ChatMessage>, Option<Value>) -> Fut,
|
||||
Fut: Future<Output = Result<ChatMessage>>,
|
||||
{
|
||||
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<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> {
|
||||
|
|
@ -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::<usize>().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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}"));
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue