rightkit_agent_tools/backends/
web.rs1use 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 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 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 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}