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 # 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
``` ```

View File

@ -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"));
} }

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 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};

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 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).

View File

@ -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),

View File

@ -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

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). **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 | | 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 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) ## 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 僅用於登入牆。