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
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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}"));
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue