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:
parent
5b6fe82945
commit
a36f0ca42e
28
README.md
28
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# GrokBoy
|
# 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
|
## Status
|
||||||
|
|
||||||
|
|
@ -12,6 +12,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn a
|
||||||
| P3 browser (Playwright DOM) | done (optional) |
|
| P3 browser (Playwright DOM) | done (optional) |
|
||||||
| P4 human handoff | done |
|
| P4 human handoff | done |
|
||||||
| P5 interactive agent REPL | 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).
|
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).
|
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
|
## Commands
|
||||||
|
|
||||||
- `grokboy chat` — interactive streaming chat (no tools)
|
- `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`,
|
Tools: `shell`, `list_dir`, `read_file`, `write_file`, `report_done`, `report_blocked`,
|
||||||
`browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval`,
|
`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`).
|
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
|
## 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
|
```bash
|
||||||
export GROKBOY_API_KEY=你的金鑰
|
export GROKBOY_API_KEY=你的金鑰
|
||||||
|
|
@ -104,4 +123,7 @@ crates/grokboy/ # CLI binary
|
||||||
tools/playwright/ # optional Node Playwright helper (JSONL)
|
tools/playwright/ # optional Node Playwright helper (JSONL)
|
||||||
docs/PRODUCT.md
|
docs/PRODUCT.md
|
||||||
docs/ACCEPTANCE.md
|
docs/ACCEPTANCE.md
|
||||||
|
docs/scenarios/ # playbook pattern + examples
|
||||||
|
prompts/templates/ # Phase A/B placeholders
|
||||||
|
prompts/examples/ # filled example prompts
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ pub const AGENT_SYSTEM: &str = "\
|
||||||
You are GrokBoy, a concise local coding assistant with tools.
|
You are GrokBoy, a concise local coding assistant with tools.
|
||||||
Use tools when they help solve the task; otherwise answer directly.
|
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.
|
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.
|
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.
|
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.
|
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.";
|
Do not invent tool results — call the tools.";
|
||||||
|
|
@ -314,8 +315,10 @@ mod tests {
|
||||||
assert!(AGENT_SYSTEM.contains("shell"));
|
assert!(AGENT_SYSTEM.contains("shell"));
|
||||||
assert!(AGENT_SYSTEM.contains("report_done"));
|
assert!(AGENT_SYSTEM.contains("report_done"));
|
||||||
assert!(AGENT_SYSTEM.contains("report_blocked"));
|
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_navigate") || AGENT_SYSTEM.contains("browser_"));
|
||||||
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("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
|
assert!(AGENT_SYSTEM.contains("Traditional Chinese") || AGENT_SYSTEM.contains("Chinese"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod browser;
|
mod browser;
|
||||||
|
mod confirm;
|
||||||
mod config;
|
mod config;
|
||||||
mod model;
|
mod model;
|
||||||
mod session;
|
mod session;
|
||||||
|
|
@ -13,6 +14,7 @@ pub use agent::{
|
||||||
run_agent_with, tool_call_signature, truncate_messages,
|
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 config::Config;
|
pub use config::Config;
|
||||||
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat};
|
pub use model::{ChatMessage, FunctionCall, Role, ToolCall, chat_completion, stream_chat};
|
||||||
pub use session::{Session, load_or_create, load_session, save_session, sessions_dir};
|
pub use session::{Session, load_or_create, load_session, save_session, sessions_dir};
|
||||||
|
|
|
||||||
|
|
@ -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 anyhow::{Context, Result, anyhow};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
@ -8,6 +9,7 @@ use std::time::Duration;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use crate::browser::{self, LastUrlSlot};
|
use crate::browser::{self, LastUrlSlot};
|
||||||
|
use crate::confirm;
|
||||||
|
|
||||||
pub const MAX_READ_BYTES: usize = 256 * 1024;
|
pub const MAX_READ_BYTES: usize = 256 * 1024;
|
||||||
pub const SHELL_TIMEOUT_SECS: u64 = 30;
|
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() {
|
if let Some(arr) = defs.as_array_mut() {
|
||||||
|
arr.push(confirm::confirm_tool_definition());
|
||||||
arr.extend(browser::browser_tool_definitions());
|
arr.extend(browser::browser_tool_definitions());
|
||||||
}
|
}
|
||||||
defs
|
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,
|
"write_file" => tool_write_file(ctx, &args).await,
|
||||||
"report_done" => tool_report_done(&args),
|
"report_done" => tool_report_done(&args),
|
||||||
"report_blocked" => tool_report_blocked(&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) => {
|
name if browser::is_browser_tool(name) => {
|
||||||
browser::execute_browser_tool(&ctx.cwd, &ctx.last_browser_url, name, &args).await
|
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() {
|
fn tool_defs_include_core_and_browser() {
|
||||||
let defs = tool_definitions();
|
let defs = tool_definitions();
|
||||||
let arr = defs.as_array().unwrap();
|
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
|
let names: Vec<&str> = arr
|
||||||
.iter()
|
.iter()
|
||||||
.map(|t| t["function"]["name"].as_str().unwrap())
|
.map(|t| t["function"]["name"].as_str().unwrap())
|
||||||
|
|
@ -507,6 +511,7 @@ mod tests {
|
||||||
assert!(names.contains(&"write_file"));
|
assert!(names.contains(&"write_file"));
|
||||||
assert!(names.contains(&"report_done"));
|
assert!(names.contains(&"report_done"));
|
||||||
assert!(names.contains(&"report_blocked"));
|
assert!(names.contains(&"report_blocked"));
|
||||||
|
assert!(names.contains(&"request_user_confirm"));
|
||||||
assert!(names.contains(&"browser_navigate"));
|
assert!(names.contains(&"browser_navigate"));
|
||||||
assert!(names.contains(&"browser_snapshot"));
|
assert!(names.contains(&"browser_snapshot"));
|
||||||
assert!(names.contains(&"browser_click"));
|
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]
|
#[tokio::test]
|
||||||
async fn browser_tool_fails_closed_without_chromium_ok_with_helper() {
|
async fn browser_tool_fails_closed_without_chromium_ok_with_helper() {
|
||||||
// Missing required args should fail closed via helper protocol (no Chromium needed).
|
// Missing required args should fail closed via helper protocol (no Chromium needed).
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ async fn run() -> Result<()> {
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"\
|
"\
|
||||||
GrokBoy — minimal local CLI agent (P5: interactive multi-turn agent)
|
GrokBoy — minimal local CLI agent (P6: confirm-before-post + scenario playbooks)
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
grokboy chat Interactive streaming chat (no tools)
|
grokboy chat Interactive streaming chat (no tools)
|
||||||
|
|
@ -70,10 +70,12 @@ ENV:
|
||||||
GROKBOY_CONTEXT_CHARS context budget (default 100000)
|
GROKBOY_CONTEXT_CHARS context budget (default 100000)
|
||||||
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
|
||||||
|
(falls back to HANDOFF_AUTO if unset)
|
||||||
|
|
||||||
Tools: shell, list_dir, read_file, write_file, report_done, report_blocked,
|
Tools: shell, list_dir, read_file, write_file, report_done, report_blocked,
|
||||||
browser_navigate, browser_snapshot, browser_click, browser_type, browser_eval,
|
request_user_confirm, browser_navigate, browser_snapshot, browser_click,
|
||||||
browser_handoff
|
browser_type, browser_eval, browser_handoff
|
||||||
Sessions: ~/.grokboy/sessions/<id>.json
|
Sessions: ~/.grokboy/sessions/<id>.json
|
||||||
Browser (optional): cd tools/playwright && npm i && npx playwright install chromium
|
Browser (optional): cd tools/playwright && npm i && npx playwright install chromium
|
||||||
Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter
|
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<()> {
|
async fn cmd_smoke() -> Result<()> {
|
||||||
println!("GrokBoy smoke (offline P5)…");
|
println!("GrokBoy smoke (offline P6)…");
|
||||||
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")?;
|
||||||
|
|
@ -416,11 +418,11 @@ async fn cmd_smoke() -> Result<()> {
|
||||||
}
|
}
|
||||||
println!(" completion ok");
|
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 defs = tool_definitions();
|
||||||
let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
|
let n_tools = defs.as_array().map(|a| a.len()).unwrap_or(0);
|
||||||
if n_tools != 12 {
|
if n_tools != 13 {
|
||||||
return Err(anyhow!("expected 12 tool defs, got {n_tools}"));
|
return Err(anyhow!("expected 13 tool defs, got {n_tools}"));
|
||||||
}
|
}
|
||||||
let tool_names: Vec<&str> = defs
|
let tool_names: Vec<&str> = defs
|
||||||
.as_array()
|
.as_array()
|
||||||
|
|
@ -431,6 +433,9 @@ async fn cmd_smoke() -> Result<()> {
|
||||||
if !tool_names.contains(&"browser_handoff") {
|
if !tool_names.contains(&"browser_handoff") {
|
||||||
return Err(anyhow!("browser_handoff missing from tool defs"));
|
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");
|
println!(" tool defs ok");
|
||||||
|
|
||||||
// browser helper protocol (no Chromium required)
|
// browser helper protocol (no Chromium required)
|
||||||
|
|
@ -489,6 +494,46 @@ async fn cmd_smoke() -> Result<()> {
|
||||||
None => unsafe { std::env::remove_var("GROKBOY_HANDOFF_AUTO") },
|
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
|
// context truncation
|
||||||
let mut msgs = vec![
|
let mut msgs = vec![
|
||||||
ChatMessage::system(AGENT_SYSTEM),
|
ChatMessage::system(AGENT_SYSTEM),
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,15 @@
|
||||||
- [x] Offline smoke/tests: agent parses / help lists it; multi-turn session plumbing without API
|
- [x] Offline smoke/tests: agent parses / help lists it; multi-turn session plumbing without API
|
||||||
- [x] `cargo test` green without API key
|
- [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
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**North star:** a local **Grok Bot–like** 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).
|
**North star:** a local **Grok Bot–like** 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 (P0–P5)
|
## Done (P0–P6)
|
||||||
|
|
||||||
| Phase | What |
|
| Phase | What |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
|
|
@ -12,8 +12,9 @@
|
||||||
| **P3** | Optional Playwright DOM tools: navigate / snapshot / click / type / eval (fail-closed) |
|
| **P3** | Optional Playwright DOM tools: navigate / snapshot / click / type / eval (fail-closed) |
|
||||||
| **P4** | Human browser handoff (`browser_handoff`) for login / OTP / captcha |
|
| **P4** | Human browser handoff (`browser_handoff`) for login / OTP / captcha |
|
||||||
| **P5** | Interactive multi-turn agent REPL (`grokboy agent`) with tools + session persist |
|
| **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`**:
|
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.
|
See `docs/ACCEPTANCE.md` section P5.
|
||||||
|
|
||||||
|
## P6 — Scenario playbooks + confirm-before-post
|
||||||
|
|
||||||
|
**Capability:** reusable **scenario playbooks** for Grok Bot–style 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)
|
## 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.
|
||||||
|
|
|
||||||
|
|
@ -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 或口頭核准
|
||||||
|
```
|
||||||
|
|
@ -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`,就不得 publish/send。
|
||||||
|
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}}` 找出約 2–5 個候選(高佣/相關/可分享)。
|
||||||
|
3. (可選)快速看 `{{TARGET_CHANNEL}}` 語氣/近期風格。
|
||||||
|
4. 用繁中(或指定語言)起草 **1** 則貼文,含連結;結構化輸出候選 + 草稿。
|
||||||
|
5. `report_done` — **禁止** click send/publish。
|
||||||
|
|
||||||
|
## 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 # 強烈建議;否則 cookie/session 易丟
|
||||||
|
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)
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# 範例:蝦皮聯盟行銷 → Threads
|
||||||
|
|
||||||
|
> **這是範例(example)**,用來示範如何把 [`docs/scenarios/README.md`](../README.md) 的可推廣模式套到真實站點。
|
||||||
|
> 換掉來源/目標即可變成別的情境;不要把蝦皮當成唯一產品路徑。
|
||||||
|
|
||||||
|
| 欄位 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| Slug | `shopee-threads-affiliate` |
|
||||||
|
| SOURCE_PORTAL | https://affiliate.shopee.tw/ |
|
||||||
|
| TARGET_CHANNEL | Threads(threads.net) |
|
||||||
|
| GOAL | 找出約 3 個高佣金、適合公開分享的商品/連結 |
|
||||||
|
| CONSTRAINTS | 繁中短貼文;自然口吻;含聯盟連結;不誇大療效/投資 |
|
||||||
|
| LANGUAGE | 繁體中文 |
|
||||||
|
|
||||||
|
## 成功標準
|
||||||
|
|
||||||
|
### 過
|
||||||
|
|
||||||
|
- Phase A:登入(handoff)→ 找到 ~3 候選 + 1 則 Threads 草稿(含連結)→ `report_done`,**未發佈**
|
||||||
|
- Phase B:本輪已核准同一草稿(聊天或 `request_user_confirm`)→ 登入 Threads(handoff)→ 發佈 → `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`。
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
【Phase A — 研究 + 草稿;禁止發佈/送出】
|
||||||
|
(範例:蝦皮聯盟 → Threads;由 prompts/templates/phase-a.txt 填入)
|
||||||
|
|
||||||
|
來源入口 SOURCE_PORTAL: https://affiliate.shopee.tw/
|
||||||
|
目標頻道 TARGET_CHANNEL: Threads(https://www.threads.net/)
|
||||||
|
目標 GOAL: 找出約 3 個高佣金、適合公開分享的商品/推廣連結
|
||||||
|
限制 CONSTRAINTS: 繁中短貼文、自然口吻、含聯盟連結、不誇大療效或投資報酬、可加少量 hashtag
|
||||||
|
語言: 繁體中文
|
||||||
|
|
||||||
|
請執行 Phase A(research + 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)。
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
【Phase B — 僅在核准後發佈】
|
||||||
|
(範例:蝦皮聯盟 → Threads;由 prompts/templates/phase-b.txt 填入)
|
||||||
|
|
||||||
|
目標頻道 TARGET_CHANNEL: Threads(https://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: 完整草稿全文
|
||||||
|
- 若 denied/timeout → report_blocked,禁止發佈。
|
||||||
|
2. 打開 Threads;若需登入,browser_handoff(僅 auth)。
|
||||||
|
3. 將已核准草稿貼上並發佈(僅此草稿)。
|
||||||
|
4. report_done,盡量附貼文 URL。
|
||||||
|
|
||||||
|
【禁止】未核准不得發佈;不得擅自改稿後偷偷發;handoff 僅 auth。
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
【Phase A — 研究 + 草稿;禁止發佈/送出】
|
||||||
|
|
||||||
|
來源入口 SOURCE_PORTAL: {{SOURCE_PORTAL}}
|
||||||
|
目標頻道 TARGET_CHANNEL: {{TARGET_CHANNEL}}
|
||||||
|
目標 GOAL: {{GOAL}}
|
||||||
|
限制 CONSTRAINTS: {{CONSTRAINTS}}
|
||||||
|
語言: {{LANGUAGE}}
|
||||||
|
|
||||||
|
請執行 Phase A(research + draft only):
|
||||||
|
|
||||||
|
1. 若 SOURCE_PORTAL 需要登入/OTP/驗證碼,呼叫 browser_handoff(僅 auth)。完成後從 snapshot 繼續。
|
||||||
|
2. 在來源找出約 2–5 個符合 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)。
|
||||||
|
|
@ -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: 完整草稿全文
|
||||||
|
- 若回傳 denied/timeout → report_blocked,**禁止**發佈。
|
||||||
|
2. 打開 TARGET_CHANNEL;若需登入,browser_handoff(僅 auth)。
|
||||||
|
3. 將已核准草稿貼上並發佈(僅此一次、僅此草稿)。
|
||||||
|
4. report_done,盡量附上貼文 URL;若看不到 URL,說明發佈結果與畫面狀態。
|
||||||
|
|
||||||
|
【禁止】
|
||||||
|
- 未核准(聊天或 request_user_confirm)不得 publish/send。
|
||||||
|
- 不得擅自改寫已核准草稿後偷偷發佈(若需改寫,回到 Phase A 或重新 confirm)。
|
||||||
|
- browser_handoff 僅用於登入牆。
|
||||||
Loading…
Reference in New Issue