P5: interactive multi-turn agent REPL with tools

This commit is contained in:
王性驊 2026-09-13 16:25:07 +08:00
parent 5507e5b4b5
commit 5b6fe82945
4 changed files with 238 additions and 24 deletions

View File

@ -1,6 +1,6 @@
# GrokBoy # GrokBoy
Minimal local **GrokBot-like** CLI agent. Phase **P4**: human browser handoff (login/OTP/captcha). Minimal local **GrokBot-like** CLI agent. Phase **P5**: interactive multi-turn agent REPL (dialogue + tools).
## Status ## Status
@ -11,6 +11,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P4**: human browser handoff (l
| P2 completion / loop guard / truncation | done | | P2 completion / loop guard / truncation | done |
| 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 |
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).
@ -22,7 +23,7 @@ export GROKBOY_API_KEY=your_key # or XAI_API_KEY
# export GROKBOY_BASE_URL=https://api.x.ai/v1 # export GROKBOY_BASE_URL=https://api.x.ai/v1
# export GROKBOY_MODEL=grok-4.6 # export GROKBOY_MODEL=grok-4.6
# export GROKBOY_CONTEXT_CHARS=100000 # export GROKBOY_CONTEXT_CHARS=100000
# export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff) # export GROKBOY_BROWSER_HEADED=1 # visible Chromium (recommended for handoff / agent)
cd ~/GrokBoy cd ~/GrokBoy
cargo run -p grokboy -- chat cargo run -p grokboy -- chat
@ -53,6 +54,8 @@ When the agent hits a login / OTP / captcha wall it calls `browser_handoff`:
# Prefer headed for runs that may need handoff: # Prefer headed for runs that may need handoff:
export GROKBOY_BROWSER_HEADED=1 export GROKBOY_BROWSER_HEADED=1
cargo run -p grokboy -- run "打開需要登入的頁面並完成任務" cargo run -p grokboy -- run "打開需要登入的頁面並完成任務"
# or interactive:
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).
@ -61,10 +64,14 @@ Tests / CI: `GROKBOY_HANDOFF_AUTO=1` auto-resumes (no interactive Enter).
- `grokboy chat` — interactive streaming chat (no tools) - `grokboy chat` — interactive streaming chat (no tools)
- `grokboy run "<prompt>"` — one-shot agent with tools - `grokboy run "<prompt>"` — one-shot agent with tools
- `grokboy run --session <id> "<prompt>"` — continue a saved session - `grokboy run --session <id> "<prompt>"` — continue a saved session (one shot)
- `grokboy agent`**interactive multi-turn** agent REPL with tools (auto session)
- `grokboy agent --session <id>` — resume an agent session
- `grokboy smoke` — offline checks (no API key / no interactive handoff) - `grokboy smoke` — offline checks (no API key / no interactive handoff)
- `grokboy help` - `grokboy help`
In `agent` REPL: `/exit` or `/quit` leave; `/session` show id; empty line ignored.
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`.
@ -75,7 +82,7 @@ The agent stops on `report_done` / `report_blocked`, blocks identical tool round
## Traditional Chinese ## Traditional Chinese
本機終端機 coding assistant。P4 支援瀏覽器人工接手登入OTP驗證碼代理暫停 → 你在可見 Chromium 完成 → 終端機按 Enter 繼續 本機終端機 coding assistant。P5 支援互動式多輪代理(含工具):`grokboy agent`。P4 瀏覽器人工接手登入OTP驗證碼`run` / `agent` 內皆可用
```bash ```bash
export GROKBOY_API_KEY=你的金鑰 export GROKBOY_API_KEY=你的金鑰
@ -84,6 +91,7 @@ cd ~/GrokBoy
cargo run -p grokboy -- smoke cargo run -p grokboy -- smoke
# 可選瀏覽器: # 可選瀏覽器:
cd tools/playwright && npm install && npx playwright install chromium cd tools/playwright && npm install && npx playwright install chromium
cargo run -p grokboy -- agent
cargo run -p grokboy -- run "打開 example.com 並 snapshot" cargo run -p grokboy -- run "打開 example.com 並 snapshot"
cargo run -p grokboy -- chat cargo run -p grokboy -- chat
``` ```

View File

@ -30,6 +30,7 @@ async fn run() -> Result<()> {
match cmd.as_str() { match cmd.as_str() {
"chat" => cmd_chat().await, "chat" => cmd_chat().await,
"run" => cmd_run(&args).await, "run" => cmd_run(&args).await,
"agent" => cmd_agent(&args).await,
"smoke" => cmd_smoke().await, "smoke" => cmd_smoke().await,
"version" | "-V" | "--version" => { "version" | "-V" | "--version" => {
println!("grokboy {}", env!("CARGO_PKG_VERSION")); println!("grokboy {}", env!("CARGO_PKG_VERSION"));
@ -50,12 +51,14 @@ async fn run() -> Result<()> {
fn print_help() { fn print_help() {
println!( println!(
"\ "\
GrokBoy minimal local CLI agent (P4: human browser handoff) GrokBoy minimal local CLI agent (P5: interactive multi-turn agent)
USAGE: USAGE:
grokboy chat Interactive streaming chat (no tools) grokboy chat Interactive streaming chat (no tools)
grokboy run \"<prompt>\" One-shot agent with tools grokboy run \"<prompt>\" One-shot agent with tools
grokboy run --session <id> \"...\" Continue a saved session grokboy run --session <id> \"...\" Continue a saved session
grokboy agent Interactive multi-turn agent REPL (with tools)
grokboy agent --session <id> Resume an agent session
grokboy smoke Offline checks (no API key required) grokboy smoke Offline checks (no API key required)
grokboy version grokboy version
grokboy help grokboy help
@ -65,7 +68,7 @@ ENV:
GROKBOY_BASE_URL default https://api.x.ai/v1 GROKBOY_BASE_URL default https://api.x.ai/v1
GROKBOY_MODEL default grok-4.6 GROKBOY_MODEL default grok-4.6
GROKBOY_CONTEXT_CHARS context budget (default 100000) GROKBOY_CONTEXT_CHARS context budget (default 100000)
GROKBOY_BROWSER_HEADED 1 = always launch Chromium headed (visible) 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
Tools: shell, list_dir, read_file, write_file, report_done, report_blocked, Tools: shell, list_dir, read_file, write_file, report_done, report_blocked,
@ -74,6 +77,7 @@ Tools: shell, list_dir, read_file, write_file, report_done, report_blocked,
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
REPL: /exit /quit leave; /session show id; empty line ignored
" "
); );
} }
@ -185,8 +189,135 @@ async fn cmd_run(args: &[String]) -> Result<()> {
Ok(()) Ok(())
} }
/// Parse `agent` CLI flags. Returns (session_id, show_help).
fn parse_agent_args(args: &[String]) -> Result<(Option<String>, bool)> {
let mut session_id: Option<String> = None;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--session" | "-s" => {
i += 1;
let id = args
.get(i)
.ok_or_else(|| anyhow!("--session requires an id"))?;
session_id = Some(id.clone());
}
"--help" | "-h" => return Ok((session_id, true)),
other => {
return Err(anyhow!(
"unexpected argument: {other}\nusage: grokboy agent [--session <id>]"
));
}
}
i += 1;
}
Ok((session_id, false))
}
async fn cmd_agent(args: &[String]) -> Result<()> {
let (session_id, show_help) = parse_agent_args(args)?;
if show_help {
println!(
"Usage: grokboy agent [--session <id>]\n\
Interactive multi-turn ReAct agent with tools.\n\
Creates/resumes a session under ~/.grokboy/sessions/.\n\
Commands: /exit /quit leave; /session show id; empty line ignored.\n\
Tip: GROKBOY_BROWSER_HEADED=1 for visible Chromium (handoff)."
);
return Ok(());
}
let config = Config::from_env().map_err(anyhow::Error::msg)?;
let cwd = std::env::current_dir().context("cwd")?;
let created_new = session_id.is_none();
let mut session = load_or_create(session_id.as_deref(), &cwd)?;
if session.messages.is_empty() {
session.push(ChatMessage::system(AGENT_SYSTEM));
}
session.cwd = cwd.clone();
// One ToolContext for the whole REPL so browser URL/state carries across turns.
let tool_ctx = ToolContext::new(session.cwd.clone());
if let Some(url) = &session.last_browser_url {
if let Ok(mut g) = tool_ctx.last_browser_url.lock() {
*g = Some(url.clone());
}
}
// Persist early so the printed session id is on disk.
let path = save_session(&session)?;
println!(
"GrokBoy agent model={} base={}",
config.model, config.base_url
);
println!("session {}", session.id);
if created_new {
eprintln!("[new session → {}]", path.display());
} else {
eprintln!("[resumed → {}]", path.display());
}
println!(
"互動式多輪代理(含工具)。輸入訊息後會跑 ReAct/exit 或 /quit 離開;/session 顯示 id。\n"
);
let stdin = io::stdin();
let mut stdout = io::stdout();
loop {
print!("you> ");
stdout.flush().ok();
let mut line = String::new();
if stdin.read_line(&mut line).context("stdin")? == 0 {
println!();
break;
}
let input = line.trim();
if input.is_empty() {
continue;
}
if input == "/exit" || input == "/quit" {
break;
}
if input == "/session" {
println!("session {}", session.id);
continue;
}
session.push(ChatMessage::user(input));
let verdict = run_agent(
&config,
&mut session.messages,
&tool_ctx,
DEFAULT_MAX_ROUNDS,
)
.await?;
if let Some(url) = tool_ctx.last_browser_url_value() {
session.last_browser_url = Some(url);
}
session.touch();
let path = save_session(&session)?;
println!("{}", verdict.message());
eprintln!(
"[verdict: {} | session {} → {}]",
verdict.kind(),
session.id,
path.display()
);
// Interactive: blocked does not exit the REPL — user can continue.
}
session.touch();
let _ = save_session(&session)?;
Ok(())
}
async fn cmd_smoke() -> Result<()> { async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline P4)…"); println!("GrokBoy smoke (offline P5)…");
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")?;
@ -393,6 +524,40 @@ async fn cmd_smoke() -> Result<()> {
} }
println!(" session ok"); println!(" session ok");
// agent CLI parse (offline)
let (sid, help) = parse_agent_args(&[]).expect("empty agent args");
if sid.is_some() || help {
return Err(anyhow!("parse_agent_args([]) unexpected"));
}
let sess_args = vec!["--session".to_string(), "abc-123".to_string()];
let (sid, help) = parse_agent_args(&sess_args)?;
if sid.as_deref() != Some("abc-123") || help {
return Err(anyhow!("parse_agent_args --session failed"));
}
let help_args = vec!["--help".to_string()];
let (_, help) = parse_agent_args(&help_args)?;
if !help {
return Err(anyhow!("parse_agent_args --help failed"));
}
// one-turn session plumbing without API: system + user + fake assistant, save/load
let mut agent_sess = Session::new(dir.clone());
agent_sess.push(ChatMessage::system(AGENT_SYSTEM));
agent_sess.push(ChatMessage::user("第一輪"));
agent_sess.push(ChatMessage::assistant("回覆一"));
agent_sess.push(ChatMessage::user("第二輪"));
let agent_path = dir.join("agent-turn.json");
let data = serde_json::to_vec_pretty(&agent_sess)?;
std::fs::write(&agent_path, data)?;
let loaded_agent: Session = serde_json::from_slice(&std::fs::read(&agent_path)?)?;
if loaded_agent.messages.len() != 4 {
return Err(anyhow!("agent multi-turn session plumbing failed"));
}
if loaded_agent.messages[0].text() != AGENT_SYSTEM {
return Err(anyhow!("agent session missing system prompt"));
}
println!(" agent parse ok");
println!(" agent session ok");
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
if Config::from_env().is_ok() { if Config::from_env().is_ok() {
@ -420,3 +585,36 @@ fn uuid_like() -> String {
.unwrap_or(0); .unwrap_or(0);
format!("{n}") format!("{n}")
} }
#[cfg(test)]
mod tests {
use super::parse_agent_args;
#[test]
fn agent_args_empty() {
let (sid, help) = parse_agent_args(&[]).unwrap();
assert!(sid.is_none());
assert!(!help);
}
#[test]
fn agent_args_session() {
let args = vec!["--session".into(), "sess-1".into()];
let (sid, help) = parse_agent_args(&args).unwrap();
assert_eq!(sid.as_deref(), Some("sess-1"));
assert!(!help);
}
#[test]
fn agent_args_help() {
let args = vec!["-h".into()];
let (_, help) = parse_agent_args(&args).unwrap();
assert!(help);
}
#[test]
fn agent_args_rejects_extra() {
let args = vec!["nope".into()];
assert!(parse_agent_args(&args).is_err());
}
}

View File

@ -37,3 +37,14 @@
- [x] Wired into tool defs + agent system prompt; fail-closed if no browser - [x] Wired into tool defs + agent system prompt; fail-closed if no browser
- [x] Offline tests / smoke without API key or interactive stdin (`GROKBOY_HANDOFF_AUTO`) - [x] Offline tests / smoke without API key or interactive stdin (`GROKBOY_HANDOFF_AUTO`)
- [x] README status table updated - [x] README status table updated
## P5 — interactive multi-turn agent
- [x] `grokboy agent` REPL: read line → ReAct with tools → print verdict → save session
- [x] `/exit` `/quit` leave; `/session` show id; empty line ignored
- [x] `--session <id>` resume; auto-create + print session id when omitted
- [x] Keep `run` one-shot; keep `chat` streaming no-tools
- [x] Same tool-capable system prompt (`AGENT_SYSTEM`); Traditional Chinese welcome
- [x] Docs: ACCEPTANCE P5, PRODUCT.md note, README commands/status
- [x] Offline smoke/tests: agent parses / help lists it; multi-turn session plumbing without API
- [x] `cargo test` 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 (P0P3) ## Done (P0P5)
| Phase | What | | Phase | What |
|-------|------| |-------|------|
@ -10,20 +10,22 @@
| **P1** | Tools + ReAct (`shell`, files), sessions under `~/.grokboy/sessions/` | | **P1** | Tools + ReAct (`shell`, files), sessions under `~/.grokboy/sessions/` |
| **P2** | Completion contract (`report_done` / `report_blocked`), loop guard, context truncation | | **P2** | Completion contract (`report_done` / `report_blocked`), loop guard, context truncation |
| **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 |
| **P5** | Interactive multi-turn agent REPL (`grokboy agent`) with tools + session persist |
## P4 — Human handoff (this slice) ## P5 — Interactive multi-turn agent (this slice)
Auth walls (login, OTP, captcha) often cannot be automated safely. P4 adds **`browser_handoff`**: Gap after P4: `chat` streams but has no tools; `run` has tools but is one-shot. P5 adds **`grokboy agent`**:
1. Agent calls `browser_handoff` with a `reason` (optional `timeout_secs`). 1. REPL reads a user line (ignore empty; `/exit` `/quit` leave; `/session` prints id).
2. Helper ensures Chromium is **headed** (visible); may relaunch from headless and restore URL. 2. Each turn runs the **same** ReAct loop as `run` (tools + handoff inherited).
3. Terminal prints bilingual (繁中 + English) instructions. 3. Prints the verdict / assistant answer; **saves** under `~/.grokboy/sessions/` after every turn.
4. Loop **blocks** until you press **Enter** (continue) or type **`abort`**, or timeout → fail-closed blocked. 4. `--session <id>` resumes; omitting id auto-creates and prints the session id.
5. On resume, a **DOM snapshot** is returned so the model can continue. 5. `chat` stays streaming no-tools; `run` stays one-shot.
### Env ### Env
- `GROKBOY_BROWSER_HEADED=1` — always launch Chromium headed (recommended when handoff is likely). - `GROKBOY_BROWSER_HEADED=1` — always launch Chromium headed (recommended for `run` / `agent` when handoff is likely).
- `GROKBOY_HANDOFF_AUTO=1` — auto-resume (tests / CI); `abort` to auto-abort. - `GROKBOY_HANDOFF_AUTO=1` — auto-resume (tests / CI); `abort` to auto-abort.
### Non-goals (this slice) ### Non-goals (this slice)
@ -32,17 +34,12 @@ Auth walls (login, OTP, captcha) often cannot be automated safely. P4 adds **`br
- Desktop accessibility / native UI automation - Desktop accessibility / native UI automation
- External connectors / SaaS integrations - External connectors / SaaS integrations
- Forking LazyBoy or Codex - Forking LazyBoy or Codex
- Turning `chat` into a tools REPL (kept simple on purpose)
### Acceptance (summary) ### Acceptance (summary)
See `docs/ACCEPTANCE.md` section P4. Short list: See `docs/ACCEPTANCE.md` section P5.
- `browser_handoff` registered and wired into the agent loop
- Headed Chromium for handoff; JSONL daemon keeps state within one run when already headed
- Bilingual terminal prompt; Enter / abort / timeout fail-closed
- Post-resume DOM snapshot as tool result
- Offline `cargo test` / `grokboy smoke` without API key or interactive stdin
## Roadmap hint (later) ## Roadmap hint (later)
P5+ may deepen persistence, richer session UX, or more tools — still thin core, DOM-first browser. P6+ may deepen session UX, richer browser persistence across process restarts, or more tools — still thin core, DOM-first browser.