P5: interactive multi-turn agent REPL with tools
This commit is contained in:
parent
5507e5b4b5
commit
5b6fe82945
16
README.md
16
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 "<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 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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 \"<prompt>\" One-shot agent with tools
|
||||
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 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/<id>.json
|
||||
Browser (optional): cd tools/playwright && npm i && npx playwright install chromium
|
||||
Handoff: agent pauses on login/OTP/captcha → you fix in headed Chromium → Enter
|
||||
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<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<()> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <id>` 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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue