Skip to main content

rightkit_agent_tools/backends/
web.rs

1//! `browser.*` tools over `rightkit-browser` (Chrome over CDP): open, read
2//! (text plus ref-addressed observation), click and type with real
3//! `Input.dispatch*` events, screenshot, console, close. The session is lazy
4//! (first `browser.open`), isolated (temporary profile unless the caller passes
5//! `LaunchOptions` with a named one), muted, and bound by `rightkit-browser`'s
6//! network policy (loopback, private ranges and `file:` blocked unless the
7//! caller's `LaunchOptions` allow them). Dropping the [`BrowserTools`] shuts the
8//! browser down.
9
10use crate::spec::*;
11use rightkit_browser::{BrowserError, BrowserPage, BrowserSession, LaunchOptions, Target};
12use serde_json::{json, Value};
13use std::path::Path;
14use std::sync::{Arc, Mutex};
15use std::time::Duration;
16use tokio::runtime::Runtime;
17
18fn berr(e: BrowserError) -> ToolError {
19    ToolError::new("browser_error", e.to_string())
20}
21
22pub struct BrowserTools {
23    rt: Runtime,
24    opts: LaunchOptions,
25    session: Mutex<Option<Arc<BrowserSession>>>,
26}
27
28impl BrowserTools {
29    pub fn new(opts: LaunchOptions) -> Result<Arc<Self>, ToolError> {
30        let rt = tokio::runtime::Builder::new_multi_thread()
31            .worker_threads(2)
32            .enable_all()
33            .build()
34            .map_err(|e| ToolError::new("runtime", e.to_string()))?;
35        Ok(Arc::new(Self {
36            rt,
37            opts,
38            session: Mutex::new(None),
39        }))
40    }
41
42    fn session(&self) -> Result<Arc<BrowserSession>, ToolError> {
43        self.session
44            .lock()
45            .unwrap_or_else(|e| e.into_inner())
46            .clone()
47            .ok_or_else(|| {
48                ToolError::new("no_session", "no browser session: call browser.open first")
49            })
50    }
51
52    fn page(&self) -> Result<Arc<BrowserPage>, ToolError> {
53        self.session()?.current().map_err(berr)
54    }
55
56    /// Close the browser now (also happens on drop).
57    pub fn shutdown(&self) {
58        let s = self
59            .session
60            .lock()
61            .unwrap_or_else(|e| e.into_inner())
62            .take();
63        if let Some(s) = s {
64            self.rt.block_on(s.shutdown());
65        }
66    }
67
68    /// The `browser.*` tool set bound to this instance.
69    pub fn tools(self: &Arc<Self>) -> Vec<Arc<dyn Tool>> {
70        let mk = |name: &str,
71                  desc: &str,
72                  effect: Effect,
73                  schema: Value,
74                  run: fn(&BrowserTools, Value) -> Result<Value, ToolError>|
75         -> Arc<dyn Tool> {
76            Arc::new(WebTool {
77                spec: ToolSpec::new(name, desc, effect, schema),
78                host: self.clone(),
79                run,
80            })
81        };
82        let target = json!({"ref": {"type": "string", "description": "node ref from browser.read"}, "selector": {"type": "string", "description": "CSS selector matching exactly one element"}});
83        vec![
84            mk("browser.open", "Open a URL in a new page (starts an isolated, muted Chrome on first use).", Effect::Network, json!({"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}), |h, a| {
85                let url = a["url"].as_str().unwrap_or_default().to_string();
86                let existing = h.session.lock().unwrap_or_else(|e| e.into_inner()).clone();
87                let session = match existing {
88                    Some(s) => s,
89                    None => {
90                        let s = Arc::new(h.rt.block_on(BrowserSession::launch(h.opts.clone())).map_err(berr)?);
91                        *h.session.lock().unwrap_or_else(|e| e.into_inner()) = Some(s.clone());
92                        s
93                    }
94                };
95                let page = h.rt.block_on(session.new_page(&url)).map_err(berr)?;
96                Ok(json!({"page_id": page.id(), "url": h.rt.block_on(page.url()), "title": h.rt.block_on(page.title())}))
97            }),
98            mk("browser.read", "Visible text and ref-addressed interactive nodes of the current page.", Effect::Observe, json!({"type": "object", "properties": {"max_text": {"type": "integer"}}}), |h, a| {
99                let page = h.page()?;
100                let mut text = h.rt.block_on(page.text()).map_err(berr)?;
101                let max = a["max_text"].as_u64().unwrap_or(20_000) as usize;
102                if text.chars().count() > max {
103                    text = text.chars().take(max).collect();
104                }
105                let obs = h.rt.block_on(page.observe()).map_err(berr)?;
106                Ok(json!({"url": obs.url, "title": obs.title, "text": text, "epoch": obs.epoch, "nodes": serde_json::to_value(&obs.nodes).unwrap_or(Value::Null), "console_errors": obs.console_errors}))
107            }),
108            mk("browser.click", "Real click on a node ref or CSS selector.", Effect::Act, json!({"type": "object", "properties": target}), |h, a| {
109                let t = parse_target(&a)?;
110                h.rt.block_on(h.page()?.click(&t)).map_err(berr)?;
111                Ok(json!({"clicked": true}))
112            }),
113            mk(
114                "browser.type",
115                "Type text with real key events; with a ref/selector the field is focused and (optionally) cleared first.",
116                Effect::Act,
117                json!({"type": "object", "properties": {"text": {"type": "string"}, "ref": {"type": "string"}, "selector": {"type": "string"}, "clear": {"type": "boolean"}}, "required": ["text"]}),
118                |h, a| {
119                    let page = h.page()?;
120                    let text = a["text"].as_str().unwrap_or_default();
121                    if a["ref"].is_string() || a["selector"].is_string() {
122                        h.rt.block_on(page.fill(&parse_target(&a)?, text, a["clear"].as_bool().unwrap_or(true))).map_err(berr)?;
123                    } else {
124                        h.rt.block_on(page.type_text(text)).map_err(berr)?;
125                    }
126                    Ok(json!({"typed_chars": text.chars().count()}))
127                },
128            ),
129            mk("browser.press", "Press a key chord, e.g. `Enter` or `ctrl+a`.", Effect::Act, json!({"type": "object", "properties": {"key": {"type": "string"}}, "required": ["key"]}), |h, a| {
130                h.rt.block_on(h.page()?.press(a["key"].as_str().unwrap_or_default())).map_err(berr)?;
131                Ok(json!({"pressed": true}))
132            }),
133            mk("browser.screenshot", "PNG screenshot of the current page to an absolute path.", Effect::Capture, json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}), |h, a| {
134                let p = Path::new(a["path"].as_str().unwrap_or_default());
135                if !p.is_absolute() {
136                    return Err(ToolError::new(ERR_INVALID_ARGS, "`path` must be absolute"));
137                }
138                h.rt.block_on(h.page()?.screenshot_to(p)).map_err(berr)?;
139                Ok(json!({"path": p.display().to_string(), "bytes": std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)}))
140            }),
141            mk("browser.console", "Console lines of the current page.", Effect::Observe, json!({"type": "object", "properties": {}}), |h, _| {
142                let lines = h.rt.block_on(h.page()?.console());
143                Ok(json!(lines.iter().map(|l| json!({"level": l.level, "text": l.text})).collect::<Vec<_>>()))
144            }),
145            mk("browser.close", "Close the browser and delete its temporary profile.", Effect::Launch, json!({"type": "object", "properties": {}}), |h, _| {
146                h.shutdown();
147                Ok(json!({"closed": true}))
148            }),
149        ]
150    }
151}
152
153impl Drop for BrowserTools {
154    fn drop(&mut self) {
155        // Runtime is dropped after; block briefly on shutdown so Chrome never outlives us.
156        let s = self
157            .session
158            .get_mut()
159            .unwrap_or_else(|e| e.into_inner())
160            .take();
161        if let Some(s) = s {
162            let _ = self.rt.block_on(async {
163                tokio::time::timeout(Duration::from_secs(10), s.shutdown()).await
164            });
165        }
166    }
167}
168
169fn parse_target(a: &Value) -> Result<Target, ToolError> {
170    match (a["ref"].as_str(), a["selector"].as_str()) {
171        (Some(r), _) => Ok(Target::node_ref(r)),
172        (None, Some(s)) => Ok(Target::css(s)),
173        _ => Err(ToolError::new(ERR_INVALID_ARGS, "give `ref` or `selector`")),
174    }
175}
176
177struct WebTool {
178    spec: ToolSpec,
179    host: Arc<BrowserTools>,
180    run: fn(&BrowserTools, Value) -> Result<Value, ToolError>,
181}
182
183impl Tool for WebTool {
184    fn spec(&self) -> &ToolSpec {
185        &self.spec
186    }
187    fn call(&self, _ctx: &CallCtx, args: Value) -> Result<Value, ToolError> {
188        (self.run)(&self.host, args)
189    }
190}