diff --git a/README.md b/README.md index 2c31661..d420871 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 @@ -11,6 +11,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P4**: human browser handoff (l | P2 completion / loop guard / truncation | done | | P3 browser (Playwright DOM) | done (optional) | | P4 human handoff | done | +| P5 interactive agent REPL | done | 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_MODEL=grok-4.6 # 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 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: export GROKBOY_BROWSER_HEADED=1 cargo run -p grokboy -- run "打開需要登入的頁面並完成任務" +# or interactive: +cargo run -p grokboy -- agent ``` 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 run ""` — one-shot agent with tools -- `grokboy run --session ""` — continue a saved session +- `grokboy run --session ""` — continue a saved session (one shot) +- `grokboy agent` — **interactive multi-turn** agent REPL with tools (auto session) +- `grokboy agent --session ` — resume an agent session - `grokboy smoke` — offline checks (no API key / no interactive handoff) - `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`, `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval`, `browser_handoff`. @@ -75,7 +82,7 @@ The agent stops on `report_done` / `report_blocked`, blocks identical tool round ## Traditional Chinese -本機終端機 coding assistant。P4 支援瀏覽器人工接手(登入/OTP/驗證碼):代理暫停 → 你在可見 Chromium 完成 → 終端機按 Enter 繼續。 +本機終端機 coding assistant。P5 支援互動式多輪代理(含工具):`grokboy agent`。P4 瀏覽器人工接手(登入/OTP/驗證碼)在 `run` / `agent` 內皆可用。 ```bash export GROKBOY_API_KEY=你的金鑰 @@ -84,6 +91,7 @@ cd ~/GrokBoy cargo run -p grokboy -- smoke # 可選瀏覽器: 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 -- chat ``` diff --git a/crates/grokboy/src/main.rs b/crates/grokboy/src/main.rs index b946004..fcc6abc 100644 --- a/crates/grokboy/src/main.rs +++ b/crates/grokboy/src/main.rs @@ -30,6 +30,7 @@ async fn run() -> Result<()> { match cmd.as_str() { "chat" => cmd_chat().await, "run" => cmd_run(&args).await, + "agent" => cmd_agent(&args).await, "smoke" => cmd_smoke().await, "version" | "-V" | "--version" => { println!("grokboy {}", env!("CARGO_PKG_VERSION")); @@ -50,12 +51,14 @@ async fn run() -> Result<()> { fn print_help() { println!( "\ -GrokBoy — minimal local CLI agent (P4: human browser handoff) +GrokBoy — minimal local CLI agent (P5: interactive multi-turn agent) USAGE: grokboy chat Interactive streaming chat (no tools) grokboy run \"\" One-shot agent with tools grokboy run --session \"...\" Continue a saved session + grokboy agent Interactive multi-turn agent REPL (with tools) + grokboy agent --session Resume an agent session grokboy smoke Offline checks (no API key required) grokboy version grokboy help @@ -65,7 +68,7 @@ ENV: GROKBOY_BASE_URL default https://api.x.ai/v1 GROKBOY_MODEL default grok-4.6 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 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/.json Browser (optional): cd tools/playwright && npm i && npx playwright install chromium Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter +REPL: /exit /quit leave; /session show id; empty line ignored " ); } @@ -185,8 +189,135 @@ async fn cmd_run(args: &[String]) -> Result<()> { Ok(()) } + +/// Parse `agent` CLI flags. Returns (session_id, show_help). +fn parse_agent_args(args: &[String]) -> Result<(Option, bool)> { + let mut session_id: Option = 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 ]" + )); + } + } + 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 ]\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<()> { - println!("GrokBoy smoke (offline P4)…"); + println!("GrokBoy smoke (offline P5)…"); let stamp = uuid_like(); let dir = std::env::temp_dir().join(format!("grokboy-smoke-{stamp}")); std::fs::create_dir_all(&dir).context("temp dir")?; @@ -393,6 +524,40 @@ async fn cmd_smoke() -> Result<()> { } 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); if Config::from_env().is_ok() { @@ -420,3 +585,36 @@ fn uuid_like() -> String { .unwrap_or(0); 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()); + } +} diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 80caf64..0555c03 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -37,3 +37,14 @@ - [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] 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 ` 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 + diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 4048d3d..7c2f819 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -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). -## Done (P0–P3) +## Done (P0–P5) | Phase | What | |-------|------| @@ -10,20 +10,22 @@ | **P1** | Tools + ReAct (`shell`, files), sessions under `~/.grokboy/sessions/` | | **P2** | Completion contract (`report_done` / `report_blocked`), loop guard, context truncation | | **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`). -2. Helper ensures Chromium is **headed** (visible); may relaunch from headless and restore URL. -3. Terminal prints bilingual (繁中 + English) instructions. -4. Loop **blocks** until you press **Enter** (continue) or type **`abort`**, or timeout → fail-closed blocked. -5. On resume, a **DOM snapshot** is returned so the model can continue. +1. REPL reads a user line (ignore empty; `/exit` `/quit` leave; `/session` prints id). +2. Each turn runs the **same** ReAct loop as `run` (tools + handoff inherited). +3. Prints the verdict / assistant answer; **saves** under `~/.grokboy/sessions/` after every turn. +4. `--session ` resumes; omitting id auto-creates and prints the session id. +5. `chat` stays streaming no-tools; `run` stays one-shot. ### 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. ### 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 - External connectors / SaaS integrations - Forking LazyBoy or Codex +- Turning `chat` into a tools REPL (kept simple on purpose) ### Acceptance (summary) -See `docs/ACCEPTANCE.md` section P4. Short list: - -- `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 +See `docs/ACCEPTANCE.md` section P5. ## 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.