P3: Playwright DOM browser tools (optional)

This commit is contained in:
王性驊 2026-09-13 15:57:57 +08:00
parent 815b840f30
commit c2de102bd6
12 changed files with 1171 additions and 22 deletions

4
.gitignore vendored
View File

@ -3,3 +3,7 @@
.DS_Store .DS_Store
.env .env
*.swp *.swp
# Optional Playwright helper
tools/playwright/node_modules/
tools/playwright/package-lock.json

View File

@ -1,6 +1,6 @@
# GrokBoy # GrokBoy
Minimal local **GrokBot-like** CLI agent. Phase **P2**: completion contract, loop guard, context truncation. Minimal local **GrokBot-like** CLI agent. Phase **P3**: optional Playwright DOM browser tools.
## Status ## Status
@ -9,7 +9,7 @@ Minimal local **GrokBot-like** CLI agent. Phase **P2**: completion contract, loo
| P0 streaming chat | done | | P0 streaming chat | done |
| P1 shell / files + ReAct | done | | P1 shell / files + ReAct | done |
| P2 completion / loop guard / truncation | done | | P2 completion / loop guard / truncation | done |
| P3 browser (Playwright) | later | | P3 browser (Playwright DOM) | done (optional) |
No Docker desktop, no Codex/LazyBoy fork. No Docker desktop, no Codex/LazyBoy fork.
@ -26,36 +26,52 @@ cd ~/GrokBoy
cargo run -p grokboy -- chat cargo run -p grokboy -- chat
``` ```
### Optional: Playwright browser tools
Browser tools are always registered but **fail closed** until you install the helper:
```bash
cd ~/GrokBoy/tools/playwright
npm install
npx playwright install chromium
```
Then the agent can use `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval` (DOM/selector/role — not screenshot-first).
## Commands ## Commands
- `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
- `grokboy smoke` — offline checks (no API key required) - `grokboy smoke` — offline checks (no API key / no Chromium required)
- `grokboy help` - `grokboy help`
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`.
Sessions are stored under `~/.grokboy/sessions/<id>.json`. Sessions are stored under `~/.grokboy/sessions/<id>.json` (may include `last_browser_url`).
The agent stops on `report_done` / `report_blocked`, blocks identical tool rounds (×3), and truncates old context when over budget. The agent stops on `report_done` / `report_blocked`, blocks identical tool rounds (×3), and truncates old context when over budget.
## 繁體中文 ## 繁體中文
本機終端機 coding assistant。P2 已支援完成合約(`report_done` / `report_blocked`)、迴圈守衛與上下文截斷 本機終端機 coding assistant。P3 可選 Playwright DOM 瀏覽器工具(非截圖優先)
```bash ```bash
export GROKBOY_API_KEY=你的金鑰 export GROKBOY_API_KEY=你的金鑰
cd ~/GrokBoy cd ~/GrokBoy
cargo run -p grokboy -- smoke cargo run -p grokboy -- smoke
cargo run -p grokboy -- run "列出目前目錄並讀 README.md" # 可選瀏覽器:
cd tools/playwright && npm install && npx playwright install chromium
cargo run -p grokboy -- run "打開 example.com 並 snapshot"
cargo run -p grokboy -- chat cargo run -p grokboy -- chat
``` ```
## Layout ## Layout
``` ```
crates/grokboy-core/ # config, model, tools, agent, session crates/grokboy-core/ # config, model, tools, browser, agent, session
crates/grokboy/ # CLI binary crates/grokboy/ # CLI binary
tools/playwright/ # optional Node Playwright helper (JSONL)
docs/ACCEPTANCE.md docs/ACCEPTANCE.md
``` ```

View File

@ -15,7 +15,8 @@ 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. 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).
For web pages prefer DOM snapshot + selector/role click/type not screenshots or pixel XY clicks.
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.";
@ -312,6 +313,7 @@ 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("browser_navigate") || AGENT_SYSTEM.contains("browser_"));
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,553 @@
//! Optional Playwright DOM browser tools (P3).
//!
//! Spawns `tools/playwright/browser_helper.mjs` (JSONL over stdin/stdout).
//! Fail closed with an install hint when Node / Playwright / Chromium is missing.
//! Prefer selectors / roles — not screenshot-first control.
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const INSTALL_HINT: &str = "Playwright browser tools unavailable. Install with: \
cd tools/playwright && npm install && npx playwright install chromium";
const HELPER_REL: &str = "tools/playwright/browser_helper.mjs";
const HELPER_TIMEOUT: Duration = Duration::from_secs(60);
/// Shared last navigated URL for session metadata.
pub type LastUrlSlot = Arc<Mutex<Option<String>>>;
static HELPER: OnceLock<Mutex<HelperState>> = OnceLock::new();
enum HelperState {
/// Not started yet.
Idle,
/// Running JSONL child.
Running {
#[allow(dead_code)]
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
},
/// Permanently unavailable this process (missing node/helper).
Unavailable(String),
}
fn helper_lock() -> &'static Mutex<HelperState> {
HELPER.get_or_init(|| Mutex::new(HelperState::Idle))
}
/// Locate the helper script relative to cwd, then walk parents, then exe-relative.
pub fn find_helper_script(cwd: &Path) -> Option<PathBuf> {
let mut dir = cwd.to_path_buf();
for _ in 0..8 {
let candidate = dir.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
// Also try next to the running binary's ancestors (dev: target/debug).
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..6 {
let Some(d) = dir else { break };
let candidate = d.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
// target/debug -> repo root
if let Some(parent) = d.parent() {
let up2 = parent.parent().map(|p| p.join(HELPER_REL));
if let Some(c) = up2 {
if c.is_file() {
return Some(c);
}
}
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
None
}
fn which_node() -> Option<PathBuf> {
// Prefer PATH lookup.
if let Ok(output) = Command::new("sh")
.arg("-c")
.arg("command -v node")
.output()
{
if output.status.success() {
let p = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !p.is_empty() {
return Some(PathBuf::from(p));
}
}
}
None
}
fn spawn_helper(script: &Path) -> Result<(Child, ChildStdin, BufReader<ChildStdout>)> {
let node = which_node().ok_or_else(|| {
anyhow!("{INSTALL_HINT} (node not found on PATH)")
})?;
let mut child = Command::new(&node)
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn node {}", script.display()))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("helper stdin missing"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("helper stdout missing"))?;
Ok((child, stdin, BufReader::new(stdout)))
}
fn ensure_running<'a>(state: &'a mut HelperState, cwd: &Path) -> Result<(&'a mut ChildStdin, &'a mut BufReader<ChildStdout>, &'a mut u64)> {
match state {
HelperState::Unavailable(msg) => Err(anyhow!("{msg}")),
HelperState::Running { stdin, stdout, next_id, .. } => Ok((stdin, stdout, next_id)),
HelperState::Idle => {
let script = match find_helper_script(cwd) {
Some(p) => p,
None => {
let msg = format!(
"{INSTALL_HINT} (helper not found at {HELPER_REL} from {})",
cwd.display()
);
*state = HelperState::Unavailable(msg.clone());
return Err(anyhow!("{msg}"));
}
};
match spawn_helper(&script) {
Ok((child, stdin, stdout)) => {
*state = HelperState::Running {
child,
stdin,
stdout,
next_id: 1,
};
match state {
HelperState::Running {
stdin,
stdout,
next_id,
..
} => Ok((stdin, stdout, next_id)),
_ => unreachable!(),
}
}
Err(e) => {
let msg = format!("{INSTALL_HINT} ({e:#})");
*state = HelperState::Unavailable(msg.clone());
Err(anyhow!("{msg}"))
}
}
}
}
}
fn read_json_line(stdout: &mut BufReader<ChildStdout>, deadline: Instant) -> Result<Value> {
let mut line = String::new();
loop {
if Instant::now() > deadline {
return Err(anyhow!("browser helper timed out waiting for response"));
}
// Blocking read — browser ops are infrequent; keep it simple.
line.clear();
let n = stdout
.read_line(&mut line)
.context("read helper stdout")?;
if n == 0 {
return Err(anyhow!("browser helper exited unexpectedly"));
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: Value = serde_json::from_str(trimmed)
.with_context(|| format!("helper returned non-JSON: {trimmed}"))?;
return Ok(v);
}
}
/// Send one JSON request to the helper; returns the parsed response object.
pub fn browser_request(cwd: &Path, mut req: Value) -> Result<Value> {
let mut guard = helper_lock()
.lock()
.map_err(|_| anyhow!("browser helper lock poisoned"))?;
let (stdin, stdout, next_id) = ensure_running(&mut guard, cwd)?;
let id = *next_id;
*next_id += 1;
if req.get("id").is_none() {
req["id"] = json!(id.to_string());
}
let line = serde_json::to_string(&req)? + "\n";
stdin
.write_all(line.as_bytes())
.context("write to browser helper")?;
stdin.flush().context("flush browser helper")?;
let deadline = Instant::now() + HELPER_TIMEOUT;
let resp = read_json_line(stdout, deadline)?;
// If helper reported permanent missing playwright, mark unavailable for clearer retries.
if resp.get("ok") == Some(&json!(false)) {
if let Some(code) = resp.get("code").and_then(|c| c.as_str()) {
if code == "PLAYWRIGHT_MISSING" || code == "CHROMIUM_MISSING" {
// Keep process; user may install mid-session — don't mark Unavailable.
}
}
}
Ok(resp)
}
/// One-shot `--cmd` invocation (no daemon). Useful for offline protocol tests.
pub fn browser_oneshot(cwd: &Path, req: &Value) -> Result<Value> {
let script = find_helper_script(cwd).ok_or_else(|| {
anyhow!("{INSTALL_HINT} (helper not found)")
})?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let cmd_json = serde_json::to_string(req)?;
let output = Command::new(node)
.arg(&script)
.arg("--cmd")
.arg(&cmd_json)
.current_dir(cwd)
.output()
.context("oneshot browser helper")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"browser helper produced no JSON (stderr: {})",
stderr.trim()
));
}
let v: Value = serde_json::from_str(line)
.with_context(|| format!("oneshot non-JSON: {line}"))?;
Ok(v)
}
/// Run helper `--self-test` (no Chromium). Returns parsed summary JSON.
pub fn browser_self_test(cwd: &Path) -> Result<Value> {
let script = find_helper_script(cwd).ok_or_else(|| {
anyhow!("{INSTALL_HINT} (helper not found)")
})?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let output = Command::new(node)
.arg(&script)
.arg("--self-test")
.current_dir(
script
.parent()
.unwrap_or(cwd),
)
.output()
.context("browser helper --self-test")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
return Err(anyhow!(
"self-test empty stdout; stderr={}",
String::from_utf8_lossy(&output.stderr)
));
}
let v: Value = serde_json::from_str(line)?;
if !output.status.success() || v.get("ok") != Some(&json!(true)) {
return Err(anyhow!("self-test failed: {v}"));
}
Ok(v)
}
pub fn update_last_url(slot: &LastUrlSlot, resp: &Value) {
if let Some(url) = resp.get("url").and_then(|u| u.as_str()) {
if let Ok(mut g) = slot.lock() {
*g = Some(url.to_string());
}
}
}
pub fn response_to_tool_json(resp: Value) -> Value {
if resp.get("ok") == Some(&json!(true)) {
let mut out = resp;
if let Some(obj) = out.as_object_mut() {
obj.remove("ok");
obj.remove("id");
}
out
} else {
let err = resp
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("browser tool failed");
json!({
"error": err,
"blocked": true,
"install_hint": INSTALL_HINT,
"detail": resp,
})
}
}
/// OpenAI tool definitions for browser ops (always registered; fail closed if missing).
pub fn browser_tool_definitions() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "browser_navigate",
"description": "Navigate the optional Playwright browser to a URL (DOM path). Fails closed with install hint if Playwright is missing.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "URL to open" }
},
"required": ["url"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_snapshot",
"description": "Structured text snapshot of the page (roles/names/CSS selectors). Prefer this over screenshots for control.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_click",
"description": "Click an element by CSS selector or role/name (Playwright DOM). Not pixel/XY click.",
"parameters": {
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector" },
"role": { "type": "string", "description": "ARIA role, e.g. button, link" },
"name": { "type": "string", "description": "Accessible name when using role" },
"text": { "type": "string", "description": "Visible text alternative" },
"label": { "type": "string" },
"placeholder": { "type": "string" }
}
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_type",
"description": "Type/fill text into an element by CSS selector or role/name.",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to type" },
"selector": { "type": "string" },
"role": { "type": "string" },
"name": { "type": "string" },
"label": { "type": "string" },
"placeholder": { "type": "string" },
"clear": { "type": "boolean", "description": "Fill (clear first) vs append; default true" }
},
"required": ["text"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_eval",
"description": "Evaluate a JavaScript expression in the page context (use sparingly; prefer snapshot/click/type).",
"parameters": {
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JS expression to eval in page" }
},
"required": ["expression"]
}
}
}),
]
}
pub fn is_browser_tool(name: &str) -> bool {
matches!(
name,
"browser_navigate"
| "browser_snapshot"
| "browser_dom"
| "browser_click"
| "browser_type"
| "browser_eval"
)
}
pub async fn execute_browser_tool(
cwd: &Path,
last_url: &LastUrlSlot,
name: &str,
args: &Value,
) -> Result<Value> {
// Run blocking helper I/O off the async runtime.
let cwd = cwd.to_path_buf();
let name = name.to_string();
let args = args.clone();
let last_url = last_url.clone();
tokio::task::spawn_blocking(move || {
let op = match name.as_str() {
"browser_navigate" => "navigate",
"browser_snapshot" | "browser_dom" => "snapshot",
"browser_click" => "click",
"browser_type" => "type",
"browser_eval" => "eval",
other => return Err(anyhow!("unknown browser tool: {other}")),
};
let mut req = args;
if let Some(obj) = req.as_object_mut() {
obj.insert("op".into(), json!(op));
} else {
req = json!({ "op": op });
}
// Normalize eval field
if op == "eval" {
if req.get("expression").is_none() {
if let Some(js) = req.get("js").cloned() {
req["expression"] = js;
}
}
}
let resp = browser_request(&cwd, req)?;
update_last_url(&last_url, &resp);
Ok(response_to_tool_json(resp))
})
.await
.map_err(|e| anyhow!("browser task join: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn repo_cwd() -> PathBuf {
// Crate manifest dir is crates/grokboy-core → repo root is ../..
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn finds_helper_from_repo_root() {
let root = repo_cwd();
let helper = find_helper_script(&root);
assert!(
helper.is_some(),
"expected helper under {}/{HELPER_REL}",
root.display()
);
}
#[test]
fn helper_self_test_protocol() {
let root = repo_cwd();
// Skip soft if node missing (should still usually be present on Mac).
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let summary = browser_self_test(&root).expect("self-test");
assert_eq!(summary["ok"], true);
}
#[test]
fn oneshot_ping_json_protocol() {
let root = repo_cwd();
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let resp = browser_oneshot(&root, &json!({"op": "ping", "id": "u1"})).unwrap();
assert_eq!(resp["ok"], true);
assert_eq!(resp["pong"], true);
assert_eq!(resp["protocol"], 1);
}
#[test]
fn response_maps_errors_to_blocked() {
let v = response_to_tool_json(json!({
"ok": false,
"blocked": true,
"error": "nope",
"id": "1"
}));
assert!(v.get("error").is_some());
assert_eq!(v["blocked"], true);
assert!(v["install_hint"].as_str().unwrap().contains("npm install"));
}
#[test]
fn browser_defs_count() {
assert_eq!(browser_tool_definitions().len(), 5);
assert!(is_browser_tool("browser_navigate"));
assert!(is_browser_tool("browser_snapshot"));
assert!(!is_browser_tool("shell"));
}
#[test]
fn install_hint_constant() {
assert!(INSTALL_HINT.contains("npx playwright install chromium"));
}
#[tokio::test]
async fn execute_ping_via_navigate_missing_url_style() {
// Exercise execute path with type missing text → blocked JSON, no chromium needed
// if helper starts. Use browser_type without text through execute_browser_tool args check.
let root = repo_cwd();
if which_node().is_none() || find_helper_script(&root).is_none() {
return;
}
let slot: LastUrlSlot = Arc::new(Mutex::new(None));
// Direct request ping through oneshot already covered; here ensure spawn works with status via request.
let resp = browser_oneshot(&root, &json!({"op":"status"})).unwrap();
assert_eq!(resp["ok"], true);
let _ = slot;
let _ = SystemTime::now().duration_since(UNIX_EPOCH);
}
}

View File

@ -1,6 +1,7 @@
//! GrokBoy core: config, streaming chat, tools, ReAct agent, sessions. //! GrokBoy core: config, streaming chat, tools, ReAct agent, sessions.
mod agent; mod agent;
mod browser;
mod config; mod config;
mod model; mod model;
mod session; mod session;
@ -11,6 +12,7 @@ pub use agent::{
context_char_budget, message_char_len, messages_char_len, round_signature, run_agent, context_char_budget, message_char_len, messages_char_len, round_signature, run_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, browser_oneshot, browser_self_test, find_helper_script};
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

@ -15,6 +15,9 @@ pub struct Session {
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub cwd: PathBuf, pub cwd: PathBuf,
pub messages: Vec<ChatMessage>, pub messages: Vec<ChatMessage>,
/// Last page URL from optional Playwright browser tools (P3).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_browser_url: Option<String>,
} }
impl Session { impl Session {
@ -26,6 +29,7 @@ impl Session {
updated_at: now, updated_at: now,
cwd: cwd.into(), cwd: cwd.into(),
messages: Vec::new(), messages: Vec::new(),
last_browser_url: None,
} }
} }

View File

@ -1,4 +1,4 @@
//! Built-in tools: shell, list_dir, read_file, write_file, report_done, report_blocked. //! Built-in tools: shell, files, completion, and optional Playwright browser (P3).
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json}; use serde_json::{Value, json};
@ -7,6 +7,8 @@ use std::process::Stdio;
use std::time::Duration; use std::time::Duration;
use tokio::process::Command; use tokio::process::Command;
use crate::browser::{self, LastUrlSlot};
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;
@ -17,6 +19,8 @@ pub struct ToolContext {
pub cwd: PathBuf, pub cwd: PathBuf,
/// Optional workspace root; paths outside it are rejected when set. /// Optional workspace root; paths outside it are rejected when set.
pub workspace_root: Option<PathBuf>, pub workspace_root: Option<PathBuf>,
/// Last navigated browser URL (shared across clones).
pub last_browser_url: LastUrlSlot,
} }
impl ToolContext { impl ToolContext {
@ -25,6 +29,7 @@ impl ToolContext {
Self { Self {
cwd: cwd.clone(), cwd: cwd.clone(),
workspace_root: Some(cwd), workspace_root: Some(cwd),
last_browser_url: std::sync::Arc::new(std::sync::Mutex::new(None)),
} }
} }
@ -32,11 +37,15 @@ impl ToolContext {
self.workspace_root = root; self.workspace_root = root;
self self
} }
pub fn last_browser_url_value(&self) -> Option<String> {
self.last_browser_url.lock().ok().and_then(|g| g.clone())
}
} }
/// OpenAI-compatible tool definitions for chat completions. /// OpenAI-compatible tool definitions for chat completions.
pub fn tool_definitions() -> Value { pub fn tool_definitions() -> Value {
json!([ let mut defs = json!([
{ {
"type": "function", "type": "function",
"function": { "function": {
@ -123,7 +132,12 @@ pub fn tool_definitions() -> Value {
} }
} }
} }
]) ]);
// Optional Playwright DOM tools (always registered; fail closed with install hint if missing).
if let Some(arr) = defs.as_array_mut() {
arr.extend(browser::browser_tool_definitions());
}
defs
} }
/// True if the tool name ends the ReAct loop (completion contract). /// True if the tool name ends the ReAct loop (completion contract).
@ -191,6 +205,9 @@ 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),
name if browser::is_browser_tool(name) => {
browser::execute_browser_tool(&ctx.cwd, &ctx.last_browser_url, name, &args).await
}
other => Err(anyhow!("unknown tool: {other}")), other => Err(anyhow!("unknown tool: {other}")),
} }
} }
@ -476,10 +493,10 @@ mod tests {
} }
#[test] #[test]
fn tool_defs_include_six() { 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(), 6); assert_eq!(arr.len(), 11); // 6 core + 5 browser
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())
@ -490,5 +507,33 @@ 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(&"browser_navigate"));
assert!(names.contains(&"browser_snapshot"));
assert!(names.contains(&"browser_click"));
assert!(names.contains(&"browser_type"));
assert!(names.contains(&"browser_eval"));
}
#[tokio::test]
async fn browser_tool_fails_closed_without_chromium_ok_with_helper() {
// Missing required args should fail closed via helper protocol (no Chromium needed).
let (ctx, dir) = temp_ctx();
// Point cwd at repo root so helper is found when running under cargo test.
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
let root = root.canonicalize().unwrap_or(root);
let ctx = ToolContext {
cwd: root.clone(),
workspace_root: ctx.workspace_root,
last_browser_url: ctx.last_browser_url,
};
let out = execute_tool(
&ctx,
"browser_type",
&json!({"selector": "#x"}).to_string(),
)
.await;
let v: Value = serde_json::from_str(&out).unwrap();
assert!(v.get("error").is_some(), "{v}");
let _ = std::fs::remove_dir_all(&dir);
} }
} }

View File

@ -6,6 +6,7 @@ use grokboy_core::{
}; };
use serde_json::json; use serde_json::json;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
const CHAT_SYSTEM: &str = "You are GrokBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese."; const CHAT_SYSTEM: &str = "You are GrokBoy, a concise local coding assistant. Prefer clear, short answers. Traditional Chinese is welcome when the user writes in Chinese.";
@ -49,7 +50,7 @@ async fn run() -> Result<()> {
fn print_help() { fn print_help() {
println!( println!(
"\ "\
GrokBoy minimal local CLI agent (P2: completion + loop guard + truncation) GrokBoy minimal local CLI agent (P3: optional Playwright DOM browser)
USAGE: USAGE:
grokboy chat Interactive streaming chat (no tools) grokboy chat Interactive streaming chat (no tools)
@ -65,8 +66,10 @@ ENV:
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)
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
Sessions: ~/.grokboy/sessions/<id>.json Sessions: ~/.grokboy/sessions/<id>.json
Browser (optional): cd tools/playwright && npm i && npx playwright install chromium
" "
); );
} }
@ -159,6 +162,9 @@ async fn cmd_run(args: &[String]) -> Result<()> {
) )
.await?; .await?;
if let Some(url) = tool_ctx.last_browser_url_value() {
session.last_browser_url = Some(url);
}
session.touch(); session.touch();
let path = save_session(&session)?; let path = save_session(&session)?;
println!("{}", verdict.message()); println!("{}", verdict.message());
@ -176,7 +182,7 @@ async fn cmd_run(args: &[String]) -> Result<()> {
} }
async fn cmd_smoke() -> Result<()> { async fn cmd_smoke() -> Result<()> {
println!("GrokBoy smoke (offline P2)…"); println!("GrokBoy smoke (offline P3)…");
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")?;
@ -275,14 +281,39 @@ async fn cmd_smoke() -> Result<()> {
} }
println!(" completion ok"); println!(" completion ok");
// tool definitions present (6) // tool definitions present (6 core + 5 browser)
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 != 6 { if n_tools != 11 {
return Err(anyhow!("expected 6 tool defs, got {n_tools}")); return Err(anyhow!("expected 11 tool defs, got {n_tools}"));
} }
println!(" tool defs ok"); println!(" tool defs ok");
// browser helper protocol (no Chromium required)
let repo = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match grokboy_core::browser_self_test(&repo) {
Ok(_) => println!(" browser proto ok"),
Err(e) => {
// Soft: smoke still passes if helper missing in weird cwd; warn.
println!(" browser proto skip ({e:#})");
}
}
// Fail-closed install hint when navigating without ensuring chromium — oneshot missing url already covered in helper self-test.
let nav = execute_tool(
&ToolContext::new(repo.clone()),
"browser_navigate",
&json!({"url": ""}).to_string(),
)
.await;
let nav_v: serde_json::Value = serde_json::from_str(&nav)?;
// empty url should error from helper; if helper missing, also error with install hint
if nav_v.get("error").is_none() && nav_v.get("blocked").is_none() {
// empty string might still "succeed" oddly — accept either error or blocked
println!(" browser fail note: {nav_v}");
} else {
println!(" browser fail ok");
}
// context truncation // context truncation
let mut msgs = vec![ let mut msgs = vec![
ChatMessage::system(AGENT_SYSTEM), ChatMessage::system(AGENT_SYSTEM),

View File

@ -21,4 +21,8 @@
- [x] Offline smoke/tests cover P2 without API key - [x] Offline smoke/tests cover P2 without API key
## P3 — browser ## P3 — browser
- [ ] Playwright DOM path (no screenshot-first) - [x] Playwright DOM path (no screenshot-first)
- [x] Tools: `browser_navigate`, `browser_snapshot`, `browser_click`, `browser_type`, `browser_eval`
- [x] Thin Node helper under `tools/playwright/` (JSONL / one-shot JSON)
- [x] Fail closed with install hint when Node/Playwright/Chromium missing
- [x] `cargo test` / `grokboy smoke` pass without Playwright browsers installed

View File

@ -0,0 +1,21 @@
# GrokBoy Playwright helper (optional)
Thin Node script used by Rust `browser_*` tools.
## Install
```bash
cd tools/playwright
npm install
npx playwright install chromium
```
## Protocol
- **JSONL daemon** (default): one JSON request per stdin line → one JSON response on stdout.
- **One-shot**: `node browser_helper.mjs --cmd '{"op":"ping"}'`
- **Self-test** (no Chromium): `npm run self-test`
Ops: `ping`, `navigate`, `snapshot`/`dom`, `click`, `type`, `eval`, `close`, `status`.
Prefer CSS selector or `role`+`name` over screenshots.

View File

@ -0,0 +1,453 @@
#!/usr/bin/env node
/**
* GrokBoy Playwright DOM helper (optional).
*
* Modes:
* JSONL daemon (default when no --cmd): read JSON lines from stdin, write JSON lines to stdout.
* One-shot: node browser_helper.mjs --cmd '<json>'
* Self-test: node browser_helper.mjs --self-test (no Chromium required)
*
* Ops: ping, navigate, snapshot, click, type, eval, close, status
* Prefer CSS selector or role+name. Screenshots are NOT the primary control surface.
*/
import { createInterface } from "node:readline";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const INSTALL_HINT =
"Playwright not available. From the GrokBoy repo run: " +
"cd tools/playwright && npm install && npx playwright install chromium";
let browser = null;
let context = null;
let page = null;
let lastUrl = null;
function ok(id, extra = {}) {
return { id: id ?? null, ok: true, ...extra };
}
function fail(id, error, extra = {}) {
return {
id: id ?? null,
ok: false,
blocked: true,
error: String(error),
...extra,
};
}
async function loadPlaywright() {
try {
return await import("playwright");
} catch (e) {
const err = new Error(`${INSTALL_HINT} (import failed: ${e.message})`);
err.code = "PLAYWRIGHT_MISSING";
throw err;
}
}
async function ensurePage() {
if (page) return page;
const { chromium } = await loadPlaywright();
try {
browser = await chromium.launch({ headless: true });
} catch (e) {
const err = new Error(
`${INSTALL_HINT} (chromium launch failed: ${e.message})`
);
err.code = "CHROMIUM_MISSING";
throw err;
}
context = await browser.newContext();
page = await context.newPage();
return page;
}
/** Accessibility-ish text snapshot: roles, names, and useful selectors — not pixels. */
async function buildSnapshot(p) {
const nodes = await p.evaluate(() => {
const out = [];
const max = 400;
const interesting = new Set([
"a",
"button",
"input",
"textarea",
"select",
"option",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"label",
"li",
"summary",
"nav",
"main",
"form",
"img",
"table",
"th",
"td",
]);
function cssPath(el) {
if (el.id) return `#${CSS.escape(el.id)}`;
const parts = [];
let cur = el;
while (cur && cur.nodeType === 1 && parts.length < 5) {
let part = cur.tagName.toLowerCase();
if (cur.id) {
parts.unshift(`#${CSS.escape(cur.id)}`);
break;
}
const parent = cur.parentElement;
if (parent) {
const siblings = [...parent.children].filter(
(c) => c.tagName === cur.tagName
);
if (siblings.length > 1) {
const idx = siblings.indexOf(cur) + 1;
part += `:nth-of-type(${idx})`;
}
}
parts.unshift(part);
cur = parent;
}
return parts.join(" > ");
}
const walker = document.createTreeWalker(
document.body || document.documentElement,
NodeFilter.SHOW_ELEMENT
);
let node = walker.currentNode;
while (node && out.length < max) {
const tag = node.tagName ? node.tagName.toLowerCase() : "";
const role =
node.getAttribute("role") ||
(tag === "a"
? "link"
: tag === "button"
? "button"
: tag === "input"
? node.getAttribute("type") === "submit"
? "button"
: "textbox"
: tag === "textarea"
? "textbox"
: tag === "img"
? "img"
: tag.startsWith("h") && tag.length === 2
? "heading"
: null);
const name =
node.getAttribute("aria-label") ||
node.getAttribute("alt") ||
node.getAttribute("placeholder") ||
node.getAttribute("title") ||
(node.innerText || node.textContent || "").trim().slice(0, 120);
const href = node.getAttribute && node.getAttribute("href");
const type = node.getAttribute && node.getAttribute("type");
const value =
"value" in node && typeof node.value === "string"
? String(node.value).slice(0, 80)
: null;
if (interesting.has(tag) || node.getAttribute("role") || node.id) {
const entry = {
tag,
role: role || tag,
name: name || "",
selector: cssPath(node),
};
if (href) entry.href = href;
if (type) entry.type = type;
if (value) entry.value = value;
if (node.id) entry.id = node.id;
out.push(entry);
}
node = walker.nextNode();
}
return out;
});
const title = await p.title();
const url = p.url();
const lines = nodes.map((n, i) => {
const bits = [`[${i}]`, n.role || n.tag];
if (n.name) bits.push(`name=${JSON.stringify(n.name)}`);
if (n.selector) bits.push(`sel=${n.selector}`);
if (n.href) bits.push(`href=${n.href}`);
if (n.type) bits.push(`type=${n.type}`);
if (n.value) bits.push(`value=${JSON.stringify(n.value)}`);
return bits.join(" ");
});
return {
url,
title,
count: nodes.length,
text: lines.join("\n"),
nodes,
};
}
async function resolveLocator(p, args) {
if (args.selector) {
return p.locator(String(args.selector)).first();
}
if (args.role) {
const opts = {};
if (args.name) opts.name = String(args.name);
return p.getByRole(String(args.role), opts).first();
}
if (args.text) {
return p.getByText(String(args.text), { exact: !!args.exact }).first();
}
if (args.label) {
return p.getByLabel(String(args.label)).first();
}
if (args.placeholder) {
return p.getByPlaceholder(String(args.placeholder)).first();
}
throw new Error(
"click/type requires selector, role(+name), text, label, or placeholder"
);
}
async function handle(req) {
const id = req.id ?? null;
const op = req.op || req.command;
if (!op) return fail(id, "missing op");
try {
switch (op) {
case "ping":
return ok(id, {
pong: true,
protocol: 1,
ops: [
"ping",
"navigate",
"snapshot",
"click",
"type",
"eval",
"close",
"status",
],
});
case "status": {
return ok(id, {
browser_open: !!browser,
last_url: lastUrl,
page_url: page ? page.url() : null,
});
}
case "navigate": {
const url = req.url;
if (!url) return fail(id, "navigate: missing url");
const p = await ensurePage();
const resp = await p.goto(String(url), {
waitUntil: "domcontentloaded",
timeout: req.timeout_ms ?? 30000,
});
lastUrl = p.url();
return ok(id, {
url: lastUrl,
title: await p.title(),
status: resp ? resp.status() : null,
});
}
case "snapshot":
case "dom": {
const p = await ensurePage();
const snap = await buildSnapshot(p);
lastUrl = snap.url;
return ok(id, {
url: snap.url,
title: snap.title,
count: snap.count,
snapshot: snap.text,
nodes: snap.nodes.slice(0, 200),
});
}
case "click": {
const p = await ensurePage();
const loc = await resolveLocator(p, req);
await loc.click({ timeout: req.timeout_ms ?? 10000 });
lastUrl = p.url();
return ok(id, { clicked: true, url: lastUrl });
}
case "type": {
const p = await ensurePage();
const text = req.text ?? req.value;
if (text == null) return fail(id, "type: missing text");
const loc = await resolveLocator(p, req);
if (req.clear !== false) {
await loc.fill(String(text), { timeout: req.timeout_ms ?? 10000 });
} else {
await loc.type(String(text), { timeout: req.timeout_ms ?? 10000 });
}
lastUrl = p.url();
return ok(id, { typed: true, url: lastUrl });
}
case "eval": {
const p = await ensurePage();
const expression = req.expression ?? req.js ?? req.code;
if (!expression) return fail(id, "eval: missing expression");
// Evaluate as expression body; fail closed on throw.
const result = await p.evaluate((expr) => {
// eslint-disable-next-line no-eval
return eval(expr);
}, String(expression));
let serialized;
try {
serialized = JSON.parse(JSON.stringify(result));
} catch {
serialized = String(result);
}
return ok(id, { result: serialized, url: p.url() });
}
case "close": {
if (browser) {
await browser.close().catch(() => {});
}
browser = null;
context = null;
page = null;
return ok(id, { closed: true, last_url: lastUrl });
}
default:
return fail(id, `unknown op: ${op}`);
}
} catch (e) {
const extra = {};
if (e && e.code) extra.code = e.code;
return fail(id, e.message || e, extra);
}
}
function parseArgs(argv) {
const out = { cmd: null, selfTest: false, daemon: true };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === "--self-test") out.selfTest = true;
else if (a === "--cmd" || a === "-c") {
out.cmd = argv[++i];
out.daemon = false;
} else if (a === "--daemon") out.daemon = true;
else if (a === "--help" || a === "-h") out.help = true;
}
return out;
}
async function runSelfTest() {
const cases = [];
// Protocol: ping without playwright
let r = await handle({ id: "t1", op: "ping" });
cases.push(["ping", r.ok === true && r.pong === true]);
// Missing fields fail closed
r = await handle({ id: "t2", op: "navigate" });
cases.push(["navigate_missing_url", r.ok === false && r.blocked === true]);
r = await handle({ id: "t3", op: "type", selector: "#x" });
// ensurePage may fail if no playwright — either blocked missing text first
// type checks text before ensurePage... actually type checks text then ensurePage then resolveLocator
// missing text → fail before playwright
cases.push(["type_missing_text", r.ok === false]);
r = await handle({ id: "t4", op: "nope" });
cases.push(["unknown_op", r.ok === false]);
r = await handle({ id: "t5", op: "status" });
cases.push(["status", r.ok === true && r.browser_open === false]);
const failed = cases.filter(([, okv]) => !okv);
const summary = {
ok: failed.length === 0,
cases: Object.fromEntries(cases),
failed: failed.map(([n]) => n),
};
console.log(JSON.stringify(summary));
process.exit(failed.length === 0 ? 0 : 1);
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(
JSON.stringify({
ok: true,
usage:
"browser_helper.mjs [--self-test] | [--cmd JSON] | (JSONL on stdin)",
install: INSTALL_HINT,
})
);
return;
}
if (args.selfTest) {
await runSelfTest();
return;
}
if (args.cmd) {
let req;
try {
req = JSON.parse(args.cmd);
} catch (e) {
console.log(JSON.stringify(fail(null, `invalid --cmd JSON: ${e.message}`)));
process.exit(1);
return;
}
const res = await handle(req);
console.log(JSON.stringify(res));
process.exit(res.ok ? 0 : 1);
return;
}
// JSONL daemon
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
let req;
try {
req = JSON.parse(trimmed);
} catch (e) {
console.log(
JSON.stringify(fail(null, `invalid JSON line: ${e.message}`))
);
continue;
}
const res = await handle(req);
console.log(JSON.stringify(res));
if (req.op === "close" || req.command === "close") {
// keep process alive unless parent closes stdin
}
}
if (browser) {
await browser.close().catch(() => {});
}
}
main().catch((e) => {
console.error(JSON.stringify(fail(null, e.message || e)));
process.exit(1);
});

View File

@ -0,0 +1,14 @@
{
"name": "grokboy-playwright-helper",
"version": "0.1.0",
"private": true,
"description": "Optional Playwright DOM helper for GrokBoy (JSONL / one-shot CLI)",
"type": "module",
"main": "browser_helper.mjs",
"scripts": {
"self-test": "node browser_helper.mjs --self-test"
},
"dependencies": {
"playwright": "^1.49.0"
}
}