docs+P6: scenario playbook template + example + request_user_confirm

Reusable Phase A/B acceptance pattern (auth handoff + confirm-before-post);
Shopee→Threads as filled example only; AGENT_SYSTEM blocks silent publish.
This commit is contained in:
王性驊 2026-09-13 16:37:51 +08:00
parent 5b6fe82945
commit a36f0ca42e
15 changed files with 831 additions and 18 deletions

View File

@ -1,6 +1,6 @@
# GrokBoy
Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn agent REPL (dialogue + tools).
Minimal local **GrokBot-like** CLI agent. Phase **P6**: scenario playbooks + confirm-before-post (`request_user_confirm`).
## Status
@ -12,6 +12,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn a
| P3 browser (Playwright DOM) | done (optional) |
| P4 human handoff | done |
| P5 interactive agent REPL | done |
| P6 scenario playbooks + confirm | done |
No Docker desktop, no Codex/LazyBoy fork. Product notes: [`docs/PRODUCT.md`](docs/PRODUCT.md).
@ -60,6 +61,24 @@ cargo run -p grokboy -- agent
Tests / CI: `GROKBOY_HANDOFF_AUTO=1` auto-resumes (no interactive Enter).
### Scenario playbooks (P6)
Reusable acceptance pattern: **Phase A** research+draft (no publish) → auth `browser_handoff` + **`request_user_confirm`** → **Phase B** publish only after approve.
- Pattern: [`docs/scenarios/README.md`](docs/scenarios/README.md)
- Template: [`docs/SCENARIO-TEMPLATE.md`](docs/SCENARIO-TEMPLATE.md) · prompts in `prompts/templates/`
- Example only: Shopee Affiliate → Threads — [`docs/scenarios/examples/shopee-threads-affiliate.md`](docs/scenarios/examples/shopee-threads-affiliate.md)
```bash
export GROKBOY_BROWSER_HEADED=1
cargo run -p grokboy -- agent
# paste prompts/examples/shopee-threads-phase-a.txt (or your filled template)
# review draft → paste phase-b or say「核准請發佈…」
```
Tests: `GROKBOY_CONFIRM_AUTO=1` auto-approves; `abort` denies.
## Commands
- `grokboy chat` — interactive streaming chat (no tools)
@ -74,7 +93,7 @@ In `agent` REPL: `/exit` or `/quit` leave; `/session` show id; empty line ignore
Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`,
`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval`,
`browser_handoff`.
`browser_handoff`, `request_user_confirm`.
Sessions are stored under `~/.grokboy/sessions/<id>.json` (may include `last_browser_url`).
@ -82,7 +101,7 @@ The agent stops on `report_done` / `report_blocked`, blocks identical tool round
## Traditional Chinese
本機終端機 coding assistant。P5 支援互動式多輪代理(含工具):`grokboy agent`。P4 瀏覽器人工接手登入OTP驗證碼`run` / `agent` 內皆可用
本機終端機 coding assistant。P6 支援可推廣的情境劇本Phase A 草稿 → confirm → Phase B 發佈)與 `request_user_confirm`。P5 `grokboy agent`P4 `browser_handoff`登入牆。範例蝦皮→Threads`docs/scenarios/`
```bash
export GROKBOY_API_KEY=你的金鑰
@ -104,4 +123,7 @@ crates/grokboy/ # CLI binary
tools/playwright/ # optional Node Playwright helper (JSONL)
docs/PRODUCT.md
docs/ACCEPTANCE.md
docs/scenarios/ # playbook pattern + examples
prompts/templates/ # Phase A/B placeholders
prompts/examples/ # filled example prompts
```

View File

@ -15,9 +15,10 @@ pub const AGENT_SYSTEM: &str = "\
You are GrokBoy, a concise local coding assistant with tools.
Use tools when they help solve the task; otherwise answer directly.
Prefer short, clear answers. Traditional Chinese is welcome when the user writes in Chinese.
Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked, and optional browser_* (Playwright DOM: browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, browser_handoff).
Available tools: shell, list_dir, read_file, write_file, report_done, report_blocked, request_user_confirm, and optional browser_* (Playwright DOM: browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval, browser_handoff).
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.
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.
Do not invent tool results call the tools.";
@ -314,8 +315,10 @@ mod tests {
assert!(AGENT_SYSTEM.contains("shell"));
assert!(AGENT_SYSTEM.contains("report_done"));
assert!(AGENT_SYSTEM.contains("report_blocked"));
assert!(AGENT_SYSTEM.contains("request_user_confirm"));
assert!(AGENT_SYSTEM.contains("browser_navigate") || AGENT_SYSTEM.contains("browser_"));
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"));
}

View File

@ -0,0 +1,362 @@
//! Human confirmation gate before irreversible public actions (P6).
//!
//! `request_user_confirm` prints a bilingual banner and waits for stdin:
//! `yes` / `y` / Enter → approve; `no` / `abort` → deny; timeout → deny (fail-closed).
//!
//! Env (tests / CI):
//! - `GROKBOY_CONFIRM_AUTO=1|yes|approve` → approve without stdin
//! - `GROKBOY_CONFIRM_AUTO=abort|no|0` → deny without stdin
//! - Falls back to the same values on `GROKBOY_HANDOFF_AUTO` when CONFIRM_AUTO is unset
use anyhow::{Result, anyhow};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
use std::sync::mpsc;
use std::time::Duration;
/// Outcome of waiting for human confirmation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfirmWait {
Approved,
Denied(String),
TimedOut,
}
const DEFAULT_CONFIRM_TIMEOUT_SECS: u64 = 300;
/// OpenAI-compatible tool definition for `request_user_confirm`.
pub fn confirm_tool_definition() -> Value {
json!({
"type": "function",
"function": {
"name": "request_user_confirm",
"description": "Ask the human to approve an irreversible public action (e.g. publishing a Threads/FB post, sending a message). Prints a bilingual banner and waits for yes/y/Enter (approve) or no/abort (deny). Prefer draft → confirm → then act. Do NOT publish social posts without either an explicit user message this turn approving the exact draft, or this tool returning approved.",
"parameters": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"description": "Why confirmation is needed (e.g. about to publish Threads post)"
},
"prompt": {
"type": "string",
"description": "Optional text to show the user (e.g. the exact draft to approve)"
},
"timeout_secs": {
"type": "integer",
"description": "Seconds to wait (default 300)"
}
},
"required": ["reason"]
}
}
})
}
/// Resolve auto-approve / auto-deny from env (CONFIRM_AUTO first, then HANDOFF_AUTO).
fn confirm_auto_from_env() -> Option<ConfirmWait> {
for key in ["GROKBOY_CONFIRM_AUTO", "GROKBOY_HANDOFF_AUTO"] {
if let Ok(v) = std::env::var(key) {
let v = v.trim().to_ascii_lowercase();
if v.is_empty() {
continue;
}
if v == "1"
|| v == "true"
|| v == "yes"
|| v == "y"
|| v == "approve"
|| v == "approved"
|| v == "resume"
|| v == "continue"
{
return Some(ConfirmWait::Approved);
}
if v == "abort"
|| v == "0"
|| v == "false"
|| v == "no"
|| v == "n"
|| v == "deny"
|| v == "denied"
{
return Some(ConfirmWait::Denied(format!("{key}={v}")));
}
}
}
None
}
/// Wait for stdin confirmation or timeout.
///
/// Approve: empty Enter, `yes`, `y`.\n
/// Deny: `no`, `n`, `abort`, `q`, `quit`, `cancel`.
pub fn wait_for_user_confirm(timeout_secs: u64) -> ConfirmWait {
if let Some(auto) = confirm_auto_from_env() {
return auto;
}
let (tx, rx) = mpsc::channel::<Result<String, String>>();
std::thread::spawn(move || {
let stdin = std::io::stdin();
let mut line = String::new();
match stdin.lock().read_line(&mut line) {
Ok(0) => {
let _ = tx.send(Err("stdin closed (EOF)".into()));
}
Ok(_) => {
let _ = tx.send(Ok(line));
}
Err(e) => {
let _ = tx.send(Err(format!("stdin read error: {e}")));
}
}
});
match rx.recv_timeout(Duration::from_secs(timeout_secs.max(1))) {
Ok(Ok(line)) => {
let t = line.trim().to_ascii_lowercase();
if t.is_empty() || t == "yes" || t == "y" {
ConfirmWait::Approved
} else if t == "no"
|| t == "n"
|| t == "abort"
|| t == "q"
|| t == "quit"
|| t == "cancel"
|| t == "deny"
{
ConfirmWait::Denied(format!("user typed {t}"))
} else {
// Unknown input → treat as deny (fail-closed for irreversible actions).
ConfirmWait::Denied(format!(
"unrecognized input {t:?}; type yes/y/Enter to approve, no/abort to deny"
))
}
}
Ok(Err(msg)) => ConfirmWait::Denied(msg),
Err(mpsc::RecvTimeoutError::Timeout) => ConfirmWait::TimedOut,
Err(mpsc::RecvTimeoutError::Disconnected) => {
ConfirmWait::Denied("confirm wait thread disconnected".into())
}
}
}
fn print_confirm_banner(reason: &str, prompt: Option<&str>, timeout_secs: u64) {
let draft_block = match prompt {
Some(p) if !p.trim().is_empty() => format!(
"
/ Draft to approve
{p}
"
),
_ => String::new(),
};
let banner = format!(
"
GrokBoy P6 Confirm before post /
/ Why confirm
{reason}
{draft_block}
/ What to do
yes y Enter
Type yes / y, or just press Enter to approve.
no abort Enter
Type no / abort then Enter to deny.
Timeout / : {timeout_secs}s / timeout = deny
(tests: GROKBOY_CONFIRM_AUTO=1 to approve; =abort to deny
or GROKBOY_HANDOFF_AUTO=1 / abort)
waiting for yes /
"
);
eprintln!("{banner}");
let _ = std::io::stderr().flush();
}
/// Execute `request_user_confirm` from tool arguments.
pub fn execute_request_user_confirm(args: &Value) -> Result<Value> {
let reason = args
.get("reason")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("request_user_confirm: missing 'reason'"))?
.to_string();
let prompt = args
.get("prompt")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let timeout_secs = args
.get("timeout_secs")
.and_then(|v| v.as_u64())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_CONFIRM_TIMEOUT_SECS);
// Run blocking stdin wait off the async runtime when called from async tools.
let reason_clone = reason.clone();
let prompt_clone = prompt.clone();
let wait = {
// Always print banner (even under AUTO) so smoke/logs show the gate fired.
print_confirm_banner(
&reason_clone,
prompt_clone.as_deref(),
timeout_secs,
);
wait_for_user_confirm(timeout_secs)
};
match wait {
ConfirmWait::Approved => Ok(json!({
"status": "approved",
"approved": true,
"reason": reason,
"prompt": prompt,
"message": "User approved. You may proceed with the irreversible action described in reason/prompt.",
})),
ConfirmWait::Denied(msg) => Ok(json!({
"status": "denied",
"approved": false,
"denied": true,
"reason": format!("user confirm denied: {msg}"),
"original_reason": reason,
"prompt": prompt,
"message": "User denied. Do NOT publish/send. Call report_blocked or revise the draft.",
})),
ConfirmWait::TimedOut => Ok(json!({
"status": "denied",
"approved": false,
"denied": true,
"timed_out": true,
"reason": format!(
"user confirm timed out after {timeout_secs}s (fail-closed = deny)"
),
"original_reason": reason,
"prompt": prompt,
"message": "Confirm timed out. Do NOT publish/send. Call report_blocked.",
})),
}
}
/// Async wrapper for tool dispatch (spawns blocking wait).
pub async fn execute_request_user_confirm_async(args: &Value) -> Result<Value> {
let args = args.clone();
tokio::task::spawn_blocking(move || execute_request_user_confirm(&args))
.await
.map_err(|e| anyhow!("request_user_confirm join: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
fn clear_confirm_env() {
unsafe {
std::env::remove_var("GROKBOY_CONFIRM_AUTO");
// Do not remove HANDOFF_AUTO globally if other tests need it — set/restore carefully.
}
}
#[test]
fn confirm_auto_approve_via_confirm_env() {
let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok();
let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok();
unsafe {
std::env::remove_var("GROKBOY_HANDOFF_AUTO");
std::env::set_var("GROKBOY_CONFIRM_AUTO", "1");
}
assert_eq!(wait_for_user_confirm(1), ConfirmWait::Approved);
unsafe {
std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort");
}
match wait_for_user_confirm(1) {
ConfirmWait::Denied(msg) => assert!(msg.contains("abort"), "{msg}"),
other => panic!("expected Denied, got {other:?}"),
}
restore_env("GROKBOY_CONFIRM_AUTO", prev_c);
restore_env("GROKBOY_HANDOFF_AUTO", prev_h);
let _ = clear_confirm_env;
}
#[test]
fn confirm_auto_falls_back_to_handoff_env() {
let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok();
let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok();
unsafe {
std::env::remove_var("GROKBOY_CONFIRM_AUTO");
std::env::set_var("GROKBOY_HANDOFF_AUTO", "1");
}
assert_eq!(wait_for_user_confirm(1), ConfirmWait::Approved);
unsafe {
std::env::set_var("GROKBOY_HANDOFF_AUTO", "abort");
}
match wait_for_user_confirm(1) {
ConfirmWait::Denied(msg) => assert!(msg.contains("abort"), "{msg}"),
other => panic!("expected Denied, got {other:?}"),
}
restore_env("GROKBOY_CONFIRM_AUTO", prev_c);
restore_env("GROKBOY_HANDOFF_AUTO", prev_h);
}
#[test]
fn execute_approve_and_deny_offline() {
let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok();
let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok();
unsafe {
std::env::remove_var("GROKBOY_HANDOFF_AUTO");
std::env::set_var("GROKBOY_CONFIRM_AUTO", "1");
}
let ok = execute_request_user_confirm(&json!({
"reason": "publish Threads draft",
"prompt": "測試貼文內容",
"timeout_secs": 2
}))
.unwrap();
assert_eq!(ok["status"], "approved");
assert_eq!(ok["approved"], true);
assert!(ok["prompt"].as_str().unwrap().contains("測試"));
unsafe {
std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort");
}
let no = execute_request_user_confirm(&json!({
"reason": "publish Threads draft",
"timeout_secs": 2
}))
.unwrap();
assert_eq!(no["status"], "denied");
assert_eq!(no["approved"], false);
let bad = execute_request_user_confirm(&json!({})).unwrap_err();
assert!(bad.to_string().contains("reason"), "{bad}");
restore_env("GROKBOY_CONFIRM_AUTO", prev_c);
restore_env("GROKBOY_HANDOFF_AUTO", prev_h);
}
#[test]
fn tool_def_name() {
let d = confirm_tool_definition();
assert_eq!(d["function"]["name"], "request_user_confirm");
assert!(d["function"]["parameters"]["required"]
.as_array()
.unwrap()
.iter()
.any(|x| x.as_str() == Some("reason")));
}
fn restore_env(key: &str, prev: Option<String>) {
match prev {
Some(v) => unsafe { std::env::set_var(key, v) },
None => unsafe { std::env::remove_var(key) },
}
}
}

View File

@ -2,6 +2,7 @@
mod agent;
mod browser;
mod confirm;
mod config;
mod model;
mod session;
@ -13,6 +14,7 @@ pub use 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};
pub use config::Config;
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat};
pub use session::{Session, load_or_create, load_session, save_session, sessions_dir};

View File

@ -1,4 +1,5 @@
//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4).
//! Built-in tools: shell, files, completion, Playwright browser (P3), human handoff (P4),
//! confirm-before-post (P6).
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
@ -8,6 +9,7 @@ use std::time::Duration;
use tokio::process::Command;
use crate::browser::{self, LastUrlSlot};
use crate::confirm;
pub const MAX_READ_BYTES: usize = 256 * 1024;
pub const SHELL_TIMEOUT_SECS: u64 = 30;
@ -133,8 +135,9 @@ pub fn tool_definitions() -> Value {
}
}
]);
// Optional Playwright DOM tools (always registered; fail closed with install hint if missing).
// Confirm-before-post (P6) + optional Playwright DOM tools.
if let Some(arr) = defs.as_array_mut() {
arr.push(confirm::confirm_tool_definition());
arr.extend(browser::browser_tool_definitions());
}
defs
@ -205,6 +208,7 @@ async fn execute_tool_inner(ctx: &ToolContext, name: &str, arguments_json: &str)
"write_file" => tool_write_file(ctx, &args).await,
"report_done" => tool_report_done(&args),
"report_blocked" => tool_report_blocked(&args),
"request_user_confirm" => confirm::execute_request_user_confirm_async(&args).await,
name if browser::is_browser_tool(name) => {
browser::execute_browser_tool(&ctx.cwd, &ctx.last_browser_url, name, &args).await
}
@ -496,7 +500,7 @@ mod tests {
fn tool_defs_include_core_and_browser() {
let defs = tool_definitions();
let arr = defs.as_array().unwrap();
assert_eq!(arr.len(), 12); // 6 core + 6 browser (incl. handoff)
assert_eq!(arr.len(), 13); // 6 core + confirm + 6 browser (incl. handoff)
let names: Vec<&str> = arr
.iter()
.map(|t| t["function"]["name"].as_str().unwrap())
@ -507,6 +511,7 @@ mod tests {
assert!(names.contains(&"write_file"));
assert!(names.contains(&"report_done"));
assert!(names.contains(&"report_blocked"));
assert!(names.contains(&"request_user_confirm"));
assert!(names.contains(&"browser_navigate"));
assert!(names.contains(&"browser_snapshot"));
assert!(names.contains(&"browser_click"));
@ -546,6 +551,56 @@ mod tests {
}
}
#[tokio::test]
async fn request_user_confirm_auto_approve_and_deny() {
let prev_c = std::env::var("GROKBOY_CONFIRM_AUTO").ok();
let prev_h = std::env::var("GROKBOY_HANDOFF_AUTO").ok();
unsafe {
std::env::remove_var("GROKBOY_HANDOFF_AUTO");
std::env::set_var("GROKBOY_CONFIRM_AUTO", "1");
}
let (ctx, dir) = temp_ctx();
let out = execute_tool(
&ctx,
"request_user_confirm",
&json!({
"reason": "publish example post",
"prompt": "草稿內容",
"timeout_secs": 2
})
.to_string(),
)
.await;
let v: Value = serde_json::from_str(&out).unwrap();
assert_eq!(v["status"], "approved");
assert_eq!(v["approved"], true);
unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort") };
let out2 = execute_tool(
&ctx,
"request_user_confirm",
&json!({"reason": "publish example post", "timeout_secs": 2}).to_string(),
)
.await;
let v2: Value = serde_json::from_str(&out2).unwrap();
assert_eq!(v2["status"], "denied");
assert_eq!(v2["approved"], false);
let bad = execute_tool(&ctx, "request_user_confirm", &json!({}).to_string()).await;
let bad: Value = serde_json::from_str(&bad).unwrap();
assert!(bad.get("error").is_some(), "{bad}");
match prev_c {
Some(v) => unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", v) },
None => unsafe { std::env::remove_var("GROKBOY_CONFIRM_AUTO") },
}
match prev_h {
Some(v) => unsafe { std::env::set_var("GROKBOY_HANDOFF_AUTO", v) },
None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") },
}
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn browser_tool_fails_closed_without_chromium_ok_with_helper() {
// Missing required args should fail closed via helper protocol (no Chromium needed).

View File

@ -51,7 +51,7 @@ async fn run() -> Result<()> {
fn print_help() {
println!(
"\
GrokBoy minimal local CLI agent (P5: interactive multi-turn agent)
GrokBoy minimal local CLI agent (P6: confirm-before-post + scenario playbooks)
USAGE:
grokboy chat Interactive streaming chat (no tools)
@ -70,10 +70,12 @@ ENV:
GROKBOY_CONTEXT_CHARS context budget (default 100000)
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
(falls back to HANDOFF_AUTO if unset)
Tools: shell, list_dir, read_file, write_file, report_done, report_blocked,
browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval,
browser_handoff
request_user_confirm, browser_navigate, browser_snapshot, browser_click,
browser_type, browser_eval, browser_handoff
Sessions: ~/.grokboy/sessions/<id>.json
Browser (optional): cd tools/playwright && npm i && npx playwright install chromium
Handoff: agent pauses on login/OTP/captcha you fix in headed Chromium Enter
@ -317,7 +319,7 @@ async fn cmd_agent(args: &[String]) -> Result<()> {
}
async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline P5)…");
println!("GrokBoy smoke (offline P6)…");
let stamp = uuid_like();
let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}"));
std::fs::create_dir_all(&dir).context("temp dir")?;
@ -416,11 +418,11 @@ async fn cmd_smoke() -> Result<()> {
}
println!(" completion ok");
// tool definitions present (6 core + 6 browser incl. handoff)
// tool definitions present (6 core + confirm + 6 browser incl. handoff)
let defs = tool_definitions();
let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
if n_tools != 12 {
return Err(anyhow!("expected 12 tool defs, got {n_tools}"));
if n_tools != 13 {
return Err(anyhow!("expected 13 tool defs, got {n_tools}"));
}
let tool_names: Vec<&str> = defs
.as_array()
@ -431,6 +433,9 @@ async fn cmd_smoke() -> Result<()> {
if !tool_names.contains(&"browser_handoff") {
return Err(anyhow!("browser_handoff missing from tool defs"));
}
if !tool_names.contains(&"request_user_confirm") {
return Err(anyhow!("request_user_confirm missing from tool defs"));
}
println!(" tool defs ok");
// browser helper protocol (no Chromium required)
@ -489,6 +494,46 @@ async fn cmd_smoke() -> Result<()> {
None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") },
}
// request_user_confirm (auto approve/deny; no interactive stdin)
let prev_confirm = std::env::var("GROKBOY_CONFIRM_AUTO").ok();
unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "1") };
let conf = execute_tool(
&ctx,
"request_user_confirm",
&json!({
"reason": "smoke confirm check",
"prompt": "範例草稿",
"timeout_secs": 2
})
.to_string(),
)
.await;
let conf_v: serde_json::Value = serde_json::from_str(&conf)?;
if conf_v["status"] != "approved" || conf_v["approved"] != true {
return Err(anyhow!("request_user_confirm approve mismatch: {conf_v}"));
}
println!(" confirm auto ok");
unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", "abort") };
let deny = execute_tool(
&ctx,
"request_user_confirm",
&json!({"reason": "smoke deny check", "timeout_secs": 2}).to_string(),
)
.await;
let deny_v: serde_json::Value = serde_json::from_str(&deny)?;
if deny_v["status"] != "denied" || deny_v["approved"] != false {
return Err(anyhow!("request_user_confirm deny mismatch: {deny_v}"));
}
let deny_wait = grokboy_core::wait_for_user_confirm(1);
if !matches!(deny_wait, grokboy_core::ConfirmWait::Denied(_)) {
return Err(anyhow!("expected ConfirmWait::Denied, got {deny_wait:?}"));
}
println!(" confirm deny ok");
match prev_confirm {
Some(v) => unsafe { std::env::set_var("GROKBOY_CONFIRM_AUTO", v) },
None => unsafe { std::env::remove_var("GROKBOY_CONFIRM_AUTO") },
}
// context truncation
let mut msgs = vec![
ChatMessage::system(AGENT_SYSTEM),

View File

@ -48,3 +48,15 @@
- [x] Offline smoke/tests: agent parses / help lists it; multi-turn session plumbing without API
- [x] `cargo test` green without API key
## P6 — scenario playbooks + confirm-before-post
- [x] Reusable pattern docs: `docs/scenarios/README.md`, `docs/SCENARIO-TEMPLATE.md`
- [x] Template prompts: `prompts/templates/phase-a.txt`, `phase-b.txt` (placeholders)
- [x] Example (not sole path): Shopee→Threads under `docs/scenarios/examples/` + `prompts/examples/`
- [x] Tool `request_user_confirm` (`reason`, optional `prompt`, optional `timeout_secs`)
- [x] Bilingual banner; yes/y/Enter approve; no/abort deny; timeout = deny (fail-closed)
- [x] Env: `GROKBOY_CONFIRM_AUTO` (fallback `GROKBOY_HANDOFF_AUTO`)
- [x] AGENT_SYSTEM: never irreversible public social publish without explicit approval this turn or confirm approved; prefer draft → confirm → act; handoff for auth only
- [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

View File

@ -2,7 +2,7 @@
**North star:** a local **Grok Botlike** agent — thin CLI core, tool-using ReAct loop, optional Playwright **DOM** browser (not screenshot-first). GrokBoy is the sole main line; LazyBoy is reference only (no fork).
## Done (P0P5)
## Done (P0P6)
| Phase | What |
|-------|------|
@ -12,8 +12,9 @@
| **P3** | Optional Playwright DOM tools: navigate / snapshot / click / type / eval (fail-closed) |
| **P4** | Human browser handoff (`browser_handoff`) for login / OTP / captcha |
| **P5** | Interactive multi-turn agent REPL (`grokboy agent`) with tools + session persist |
| **P6** | Scenario playbooks + `request_user_confirm` (confirm-before-post) |
## P5 — Interactive multi-turn agent (this slice)
## P5 — Interactive multi-turn agent
Gap after P4: `chat` streams but has no tools; `run` has tools but is one-shot. P5 adds **`grokboy agent`**:
@ -40,6 +41,32 @@ Gap after P4: `chat` streams but has no tools; `run` has tools but is one-shot.
See `docs/ACCEPTANCE.md` section P5.
## P6 — Scenario playbooks + confirm-before-post
**Capability:** reusable **scenario playbooks** for Grok Botstyle acceptance — not a single vertical hardcode.
Pattern:
1. **Phase A** — research + draft on a source portal (**no** irreversible publish)
2. Human only for: auth `browser_handoff` + final confirm
3. **Phase B** — publish to target channel **only** after explicit approval
**Product primitive:** `request_user_confirm` (bilingual stdin gate; `GROKBOY_CONFIRM_AUTO` for tests, falls back to `GROKBOY_HANDOFF_AUTO`). AGENT_SYSTEM forbids irreversible public social posts without (a) explicit user approval this turn of the exact draft, or (b) confirm → approved. Prefer draft → confirm → act; prefer `browser_handoff` only for auth walls.
**Docs:** [`docs/scenarios/README.md`](scenarios/README.md) (可推廣 pattern), [`docs/SCENARIO-TEMPLATE.md`](SCENARIO-TEMPLATE.md), `prompts/templates/`.
**Example only (範例,非唯一路徑):** Shopee Affiliate → Threads — [`docs/scenarios/examples/shopee-threads-affiliate.md`](scenarios/examples/shopee-threads-affiliate.md) + `prompts/examples/shopee-threads-phase-*.txt`.
### Env (P6)
- `GROKBOY_CONFIRM_AUTO=1` — auto-approve confirm (tests); `abort` to deny
- Falls back to `GROKBOY_HANDOFF_AUTO` when CONFIRM_AUTO unset
- `GROKBOY_BROWSER_HEADED=1` — required for real cookie/auth scenarios
### Acceptance (summary)
See `docs/ACCEPTANCE.md` section P6.
## Roadmap hint (later)
P6+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser.
P7+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser, playbook-driven acceptance.

37
docs/SCENARIO-TEMPLATE.md Normal file
View File

@ -0,0 +1,37 @@
# Scenario template複製此檔開新情境
> 填完後放到 `docs/scenarios/examples/<slug>.md`,並從 `prompts/templates/` 產出 phase-a/b。
## 後設資料
- **Slug**: `{{SLUG}}`
- **標題**: `{{TITLE}}`
- **SOURCE_PORTAL**: `{{SOURCE_PORTAL}}`
- **TARGET_CHANNEL**: `{{TARGET_CHANNEL}}`
- **GOAL**: `{{GOAL}}`
- **CONSTRAINTS**: `{{CONSTRAINTS}}`
- **LANGUAGE**: `{{LANGUAGE}}`(預設:繁體中文)
## 成功標準(過/不過)
- 過:…
- 不過:未確認發佈、多餘人工、空轉無 blocked 說明
## 允許的人工觸點
1. 來源登入 `browser_handoff`
2. 目標登入 `browser_handoff`
3. 最終 `request_user_confirm`(或聊天明確核准同一草稿)
## Phase A / B
`docs/scenarios/README.md`。本情境 Phase A prompt`prompts/examples/{{SLUG}}-phase-a.txt`Phase B`…-phase-b.txt`。
## 執行
```bash
export GROKBOY_API_KEY=…
export GROKBOY_BROWSER_HEADED=1
cargo run -p grokboy -- agent
# 貼上 phase-a → 審稿 → 貼 phase-b 或口頭核准
```

107
docs/scenarios/README.md Normal file
View File

@ -0,0 +1,107 @@
# Scenario playbooks情境劇本— 可推廣的 GrokBoy 驗收模式
本目錄描述 **可重複套用** 的 acceptance playbook把「研究來源 → 草稿 → 人工確認 → 發佈到目標頻道」做成固定兩階段流程。
**蝦皮聯盟 → Threads** 只是一個填好的範例,不是唯一路徑。
產品原語(讓所有情境可推廣):
| 原語 | 用途 |
|------|------|
| `browser_handoff` | 僅用於登入OTP驗證碼牆 |
| `request_user_confirm` | 任何不可逆公開動作(發文、送出)前必須過關 |
| Phase A / Phase B | 研究+草稿 vs 確認後才發佈 |
相關:[`docs/SCENARIO-TEMPLATE.md`](../SCENARIO-TEMPLATE.md)、[`prompts/templates/`](../../prompts/templates/)、範例 [`examples/shopee-threads-affiliate.md`](examples/shopee-threads-affiliate.md)。
---
## 目標與成功標準
### 過Pass
1. **端到端完成**:從來源入口找到可用素材/連結,產出目標頻道草稿,經確認後成功發佈(或明確 blocked
2. **人工觸點最小化**:理想上只有
(1) 來源站登入 handoff
(2) 目標頻道登入 handoff
(3) 最終發佈 confirm
— 其餘自動化。
3. **零靜默發佈**:沒有「本輪明確核准的草稿」或 `request_user_confirm``approved`,就不得 publishsend。
4. **Phase 分離**Phase A **禁止**發佈Phase B **只**在核准後發佈。
### 不過Fail
- 未確認就發文/按送出
- 非 headed 導致 cookie 遺失、無限重登
- 列表空結果空轉loop guard 觸發仍算流程失敗若未 `report_blocked` 說明)
- 把登入以外的事丟給人(例如叫人手動複製貼上整篇文,而非 agent 貼上後只等 confirm
---
## Phase A — 研究 + 草稿(禁止發佈)
1. 若需登入來源:`browser_handoff`(僅 auth
2. 在 `{{SOURCE_PORTAL}}``{{GOAL}}` 找出約 25 個候選(高佣/相關/可分享)。
3. (可選)快速看 `{{TARGET_CHANNEL}}` 語氣/近期風格。
4. 用繁中(或指定語言)起草 **1** 則貼文,含連結;結構化輸出候選 + 草稿。
5. `report_done`**禁止** click sendpublish。
## Phase B — 僅在確認後發佈
1. 僅在本 session 已有「明確核准的同一草稿」,或先呼叫 `request_user_confirm``prompt` = 全文草稿)。
2. 若需登入目標頻道:`browser_handoff`。
3. 貼上並發佈已核准草稿。
4. `report_done`(盡量附貼文 URL若未核准 → `report_blocked`**不得**單方面發文。
---
## 環境與指令
```bash
export GROKBOY_API_KEY=你的金鑰
export GROKBOY_BROWSER_HEADED=1 # 強烈建議;否則 cookiesession 易丟
cd ~/GrokBoy
cargo run -p grokboy -- agent
```
測試用:`GROKBOY_CONFIRM_AUTO=1`(核准)/`abort`(拒絕);亦可沿用 `GROKBOY_HANDOFF_AUTO`
### 如何貼 prompts
1. 複製 `prompts/templates/phase-a.txt`,替換 `{{SOURCE_PORTAL}}`、`{{TARGET_CHANNEL}}`、`{{GOAL}}`、`{{CONSTRAINTS}}`。
2. 貼進 `grokboy agent` 當本輪 user 訊息。
3. 審草稿後,再貼 Phase B或直接在聊天寫「核准請發佈以下草稿…」
填好的範例見 `prompts/examples/`
---
## 如何實例化新情境
填寫模板欄位即可,例如:
| 欄位 | 意義 | 範例 |
|------|------|------|
| `SOURCE_PORTAL` | 研究/取連結的站 | affiliate.shopee.tw、某官網活動頁 |
| `TARGET_CHANNEL` | 發佈目標 | Threads、FB、IG、X |
| `GOAL` | 成功長相 | 高佣商品約 3 個、季節主題 |
| `CONSTRAINTS` | 額外限制 | 語氣、禁用詞、長度、必須含 #tag |
| `LANGUAGE` | 草稿語言 | 繁體中文 |
步驟:複製 template → 填 placeholder → 存成 `docs/scenarios/examples/<name>.md` + `prompts/examples/<name>-phase-{a,b}.txt` → 用 `agent` 跑驗收。
---
## 失敗模式速查
| 現象 | 處置 |
|------|------|
| Headless cookie 遺失 | `GROKBOY_BROWSER_HEADED=1` |
| 列表空轉 | `report_blocked`;換篩選/關鍵字;勿重複同工具 ×3 |
| 未確認就發 | **產品失敗**AGENT_SYSTEM + `request_user_confirm` 擋 |
| 登入牆 | 只 handoff auth完成後 Enter |
---
## 範例索引
- [蝦皮聯盟 → Threads範例](examples/shopee-threads-affiliate.md)

View File

@ -0,0 +1,44 @@
# 範例:蝦皮聯盟行銷 → Threads
> **這是範例example**,用來示範如何把 [`docs/scenarios/README.md`](../README.md) 的可推廣模式套到真實站點。
> 換掉來源/目標即可變成別的情境;不要把蝦皮當成唯一產品路徑。
| 欄位 | 值 |
|------|-----|
| Slug | `shopee-threads-affiliate` |
| SOURCE_PORTAL | https://affiliate.shopee.tw/ |
| TARGET_CHANNEL | Threadsthreads.net |
| GOAL | 找出約 3 個高佣金、適合公開分享的商品/連結 |
| CONSTRAINTS | 繁中短貼文;自然口吻;含聯盟連結;不誇大療效/投資 |
| LANGUAGE | 繁體中文 |
## 成功標準
### 過
- Phase A登入handoff→ 找到 ~3 候選 + 1 則 Threads 草稿(含連結)→ `report_done`**未發佈**
- Phase B本輪已核准同一草稿聊天或 `request_user_confirm`)→ 登入 Threadshandoff→ 發佈 → `report_done`(盡量含 URL
- 人工僅蝦皮登入、Threads 登入、最終 confirm
### 不過
- 未確認就發 Threads
- 未 headed 導致重登cookie 遺失
- 聯盟列表空轉
- 叫人手動貼全文agent 應自己 type只留 confirm
## 執行
```bash
export GROKBOY_API_KEY=你的金鑰
export GROKBOY_BROWSER_HEADED=1
cd ~/GrokBoy
cargo run -p grokboy -- agent
```
貼上:
1. [`prompts/examples/shopee-threads-phase-a.txt`](../../../prompts/examples/shopee-threads-phase-a.txt)
2. 審草稿後貼 [`prompts/examples/shopee-threads-phase-b.txt`](../../../prompts/examples/shopee-threads-phase-b.txt),或回覆「核准,請發佈以下草稿:…」
由 template 填出:`prompts/templates/phase-a.txt` / `phase-b.txt`

View File

@ -0,0 +1,24 @@
【Phase A — 研究 + 草稿;禁止發佈/送出】
(範例:蝦皮聯盟 → Threads由 prompts/templates/phase-a.txt 填入)
來源入口 SOURCE_PORTAL: https://affiliate.shopee.tw/
目標頻道 TARGET_CHANNEL: Threadshttps://www.threads.net/
目標 GOAL: 找出約 3 個高佣金、適合公開分享的商品/推廣連結
限制 CONSTRAINTS: 繁中短貼文、自然口吻、含聯盟連結、不誇大療效或投資報酬、可加少量 hashtag
語言: 繁體中文
請執行 Phase Aresearch + draft only
1. 若 affiliate.shopee.tw 需要登入OTP驗證碼呼叫 browser_handoff僅 auth。完成後從 snapshot 繼續。
2. 在蝦皮聯盟找出約 3 個高佣、適合 Threads 分享的商品/連結(標題、佣金若可見、為何合適、完整推廣 URL
3. (可選)快速看 Threads 動態了解語氣;不要發文。
4. 用繁體中文起草剛好 1 則 Threads 貼文草稿,內含選中的聯盟連結;遵守 CONSTRAINTS。
5. 輸出結構化摘要candidates[]、chosen、draft_text、notes。
6. 呼叫 report_done訊息內含候選與完整草稿。
【禁止】
- 不要在 Threads 按發佈Post。
- 不要對「發佈」類按鈕 browser_click。
- browser_handoff 只用在登入牆。
結束前必須 report_done或真的卡住才 report_blocked

View File

@ -0,0 +1,24 @@
【Phase B — 僅在核准後發佈】
(範例:蝦皮聯盟 → Threads由 prompts/templates/phase-b.txt 填入)
目標頻道 TARGET_CHANNEL: Threadshttps://www.threads.net/
來源參考SOURCE_PORTAL: https://affiliate.shopee.tw/
前提:你必須已在本 session 看到使用者明確核准「同一則」草稿,或先 request_user_confirm。
已核准草稿(若使用者已貼在上方聊天則以此為準;否則請使用者貼上後再說「核准」):
---
{{APPROVED_DRAFT}}
---
請執行 Phase B
1. 若本 session **沒有**清楚的核准訊息,先呼叫 request_user_confirm
- reason: 即將發佈蝦皮聯盟推廣文到 Threads
- prompt: 完整草稿全文
- 若 deniedtimeout → report_blocked禁止發佈。
2. 打開 Threads若需登入browser_handoff僅 auth
3. 將已核准草稿貼上並發佈(僅此草稿)。
4. report_done盡量附貼文 URL。
【禁止】未核准不得發佈不得擅自改稿後偷偷發handoff 僅 auth。

View File

@ -0,0 +1,23 @@
【Phase A — 研究 + 草稿;禁止發佈/送出】
來源入口 SOURCE_PORTAL: {{SOURCE_PORTAL}}
目標頻道 TARGET_CHANNEL: {{TARGET_CHANNEL}}
目標 GOAL: {{GOAL}}
限制 CONSTRAINTS: {{CONSTRAINTS}}
語言: {{LANGUAGE}}
請執行 Phase Aresearch + draft only
1. 若 SOURCE_PORTAL 需要登入OTP驗證碼呼叫 browser_handoff僅 auth。完成後從 snapshot 繼續。
2. 在來源找出約 25 個符合 GOAL 的高價值候選(連結、標題、為何合適、佣金/重點若可見)。
3. (可選)快速瀏覽 TARGET_CHANNEL 了解語氣/情境,勿發文。
4. 用指定語言起草 **剛好 1** 則可直接貼上的貼文草稿,內含選中的聯盟/分享連結;遵守 CONSTRAINTS。
5. 輸出結構化摘要candidates[]、chosen、draft_text、notes。
6. 呼叫 report_done訊息內含候選與完整草稿。
【禁止】
- 不要在 TARGET_CHANNEL或任何社群按發佈送出分享。
- 不要呼叫會造成不可逆公開動作的 click例如「發佈」「Post」「分享」
- browser_handoff 只用在登入牆,不要用來叫人幫你寫文或手動發文。
結束前必須 report_done或真的卡住才 report_blocked

View File

@ -0,0 +1,26 @@
【Phase B — 僅在核准後發佈】
目標頻道 TARGET_CHANNEL: {{TARGET_CHANNEL}}
來源參考SOURCE_PORTAL: {{SOURCE_PORTAL}}
前提:使用者已在本 session 明確核准「同一則」草稿(聊天訊息貼出全文並說核准/發佈),或你必須先 request_user_confirm。
已核准草稿(若使用者已貼在上方聊天則以此為準;否則填入):
---
{{APPROVED_DRAFT}}
---
請執行 Phase B
1. 若本 session **沒有**清楚的核准訊息,先呼叫 request_user_confirm
- reason: 即將發佈到 TARGET_CHANNEL
- prompt: 完整草稿全文
- 若回傳 deniedtimeout → report_blocked**禁止**發佈。
2. 打開 TARGET_CHANNEL若需登入browser_handoff僅 auth
3. 將已核准草稿貼上並發佈(僅此一次、僅此草稿)。
4. report_done盡量附上貼文 URL若看不到 URL說明發佈結果與畫面狀態。
【禁止】
- 未核准(聊天或 request_user_confirm不得 publishsend。
- 不得擅自改寫已核准草稿後偷偷發佈(若需改寫,回到 Phase A 或重新 confirm
- browser_handoff 僅用於登入牆。