1#[async_trait]
2impl Tool for BrowserTool {
3 fn schema(&self) -> ToolSchema {
4 tool_schema(self.kind)
5 }
6
7 async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
8 match self.execute_impl(args).await {
9 Ok(result) => Ok(result),
10 Err(err) => {
11 let message = err.to_string();
12 let (code, detail) = split_error_code(&message);
13 Ok(error_tool_result(code, detail.to_string(), None))
14 }
15 }
16 }
17}
18
19impl RuntimeState {
20 pub async fn browser_status(&self) -> BrowserStatus {
21 self.browser.status_snapshot().await
22 }
23
24 pub async fn browser_smoke_test(
25 &self,
26 url: Option<String>,
27 ) -> anyhow::Result<BrowserSmokeTestResult> {
28 self.browser.smoke_test(url).await
29 }
30
31 pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
32 self.browser.install_sidecar().await
33 }
34
35 pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
36 self.browser.health_summary().await
37 }
38
39 pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
40 self.browser
41 .close_sessions_for_owner(owner_session_id)
42 .await
43 }
44
45 pub async fn close_all_browser_sessions(&self) -> usize {
46 self.browser.close_all_sessions().await
47 }
48}
49
50impl AppState {
51 pub async fn browser_status(&self) -> BrowserStatus {
52 match self.runtime.get() {
53 Some(runtime) => runtime.browser.status_snapshot().await,
54 None => BrowserStatus::default(),
55 }
56 }
57
58 pub async fn browser_smoke_test(
59 &self,
60 url: Option<String>,
61 ) -> anyhow::Result<BrowserSmokeTestResult> {
62 let Some(runtime) = self.runtime.get() else {
63 anyhow::bail!("runtime not ready");
64 };
65 runtime.browser_smoke_test(url).await
66 }
67
68 pub async fn install_browser_sidecar(&self) -> anyhow::Result<BrowserSidecarInstallResult> {
69 let Some(runtime) = self.runtime.get() else {
70 anyhow::bail!("runtime not ready");
71 };
72 runtime.install_browser_sidecar().await
73 }
74
75 pub async fn browser_health_summary(&self) -> BrowserHealthSummary {
76 match self.runtime.get() {
77 Some(runtime) => runtime.browser.health_summary().await,
78 None => BrowserHealthSummary::default(),
79 }
80 }
81
82 pub async fn close_browser_sessions_for_owner(&self, owner_session_id: &str) -> usize {
83 match self.runtime.get() {
84 Some(runtime) => {
85 runtime
86 .close_browser_sessions_for_owner(owner_session_id)
87 .await
88 }
89 None => 0,
90 }
91 }
92
93 pub async fn close_all_browser_sessions(&self) -> usize {
94 match self.runtime.get() {
95 Some(runtime) => runtime.close_all_browser_sessions().await,
96 None => 0,
97 }
98 }
99
100 pub async fn register_browser_tools(&self) -> anyhow::Result<()> {
101 let Some(runtime) = self.runtime.get() else {
102 anyhow::bail!("runtime not ready");
103 };
104 runtime
105 .browser
106 .register_tools(&runtime.tools, Some(self.clone()))
107 .await
108 }
109}
110
111fn evaluate_browser_status(config: BrowserConfig) -> BrowserStatus {
112 let mut status = run_doctor(BrowserDoctorOptions {
113 enabled: config.enabled,
114 headless_default: config.headless_default,
115 allow_no_sandbox: config.allow_no_sandbox,
116 executable_path: config.executable_path.clone(),
117 user_data_root: config.user_data_root.clone(),
118 });
119 status.headless_default = config.headless_default;
120 status.sidecar = evaluate_sidecar_status(config.sidecar_path.as_deref());
121 if config.enabled && !status.sidecar.found {
122 status.blocking_issues.push(BrowserBlockingIssue {
123 code: "browser_sidecar_not_found".to_string(),
124 message: "The tandem-browser sidecar binary was not found on this host.".to_string(),
125 });
126 status.recommendations.push(
127 "Install or bundle `tandem-browser`, or set `TANDEM_BROWSER_SIDECAR` / `browser.sidecar_path`."
128 .to_string(),
129 );
130 }
131 status.runnable = config.enabled
132 && status.sidecar.found
133 && status.browser.found
134 && status.blocking_issues.is_empty();
135 status
136}
137
138fn evaluate_sidecar_status(explicit: Option<&str>) -> tandem_browser::BrowserSidecarStatus {
139 let path = detect_sidecar_binary_path(explicit);
140 let version = path
141 .as_ref()
142 .and_then(|candidate| probe_binary_version(candidate).ok());
143 tandem_browser::BrowserSidecarStatus {
144 found: path.is_some(),
145 path: path.map(|row| row.to_string_lossy().to_string()),
146 version,
147 }
148}
149
150fn probe_binary_version(path: &Path) -> anyhow::Result<String> {
151 let output = std::process::Command::new(path)
152 .arg("--version")
153 .output()
154 .with_context(|| format!("failed to query `{}` version", path.display()))?;
155 if !output.status.success() {
156 anyhow::bail!(
157 "version probe failed: {}",
158 String::from_utf8_lossy(&output.stderr).trim()
159 );
160 }
161 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
162 if stdout.is_empty() {
163 anyhow::bail!("version probe returned empty stdout");
164 }
165 Ok(stdout)
166}
167
168pub async fn install_browser_sidecar(
169 config: &BrowserConfig,
170) -> anyhow::Result<BrowserSidecarInstallResult> {
171 let version = env!("CARGO_PKG_VERSION").to_string();
172 let release = fetch_release_for_version(&version).await?;
173 let asset_name = browser_release_asset_name()?;
174 let asset = release
175 .assets
176 .iter()
177 .find(|candidate| candidate.name == asset_name)
178 .ok_or_else(|| {
179 anyhow!(
180 "release_missing_asset: `{}` not found in {}",
181 asset_name,
182 release.tag_name
183 )
184 })?;
185 let install_path = sidecar_install_path(config)?;
186 let parent = install_path
187 .parent()
188 .ok_or_else(|| anyhow!("invalid install path `{}`", install_path.display()))?;
189 fs::create_dir_all(parent)
190 .await
191 .with_context(|| format!("failed to create `{}`", parent.display()))?;
192
193 let archive_bytes = download_release_asset(asset).await?;
194 let downloaded_bytes = archive_bytes.len() as u64;
195 let install_path_for_unpack = install_path.clone();
196 let asset_name_for_unpack = asset.name.clone();
197 let unpacked = tokio::task::spawn_blocking(move || {
198 unpack_sidecar_archive(
199 &asset_name_for_unpack,
200 &archive_bytes,
201 &install_path_for_unpack,
202 )
203 })
204 .await
205 .context("browser sidecar install task failed")??;
206
207 let status = evaluate_browser_status(config.clone());
208 Ok(BrowserSidecarInstallResult {
209 version,
210 asset_name: asset.name.clone(),
211 installed_path: unpacked.to_string_lossy().to_string(),
212 downloaded_bytes: asset.size.max(downloaded_bytes),
213 status,
214 })
215}
216
217async fn fetch_release_for_version(version: &str) -> anyhow::Result<GitHubRelease> {
218 let base = std::env::var(RELEASES_URL_ENV)
219 .unwrap_or_else(|_| format!("https://api.github.com/repos/{RELEASE_REPO}/releases/tags"));
220 let url = format!("{}/v{}", base.trim_end_matches('/'), version);
221 let response = reqwest::Client::new()
222 .get(&url)
223 .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
224 .send()
225 .await
226 .with_context(|| format!("failed to fetch release metadata from `{url}`"))?;
227 let status = response.status();
228 let body = response.text().await.unwrap_or_default();
229 if !status.is_success() {
230 anyhow::bail!("release_lookup_failed: {} {}", status, body.trim());
231 }
232 serde_json::from_str::<GitHubRelease>(&body).context("invalid release metadata payload")
233}
234
235async fn download_release_asset(asset: &GitHubAsset) -> anyhow::Result<Vec<u8>> {
236 let response = reqwest::Client::new()
237 .get(&asset.browser_download_url)
238 .header(reqwest::header::USER_AGENT, BROWSER_INSTALL_USER_AGENT)
239 .send()
240 .await
241 .with_context(|| format!("failed to download `{}`", asset.browser_download_url))?;
242 let status = response.status();
243 if !status.is_success() {
244 anyhow::bail!(
245 "asset_download_failed: {} {}",
246 status,
247 asset.browser_download_url
248 );
249 }
250 let bytes = response
251 .bytes()
252 .await
253 .context("failed to read asset bytes")?;
254 Ok(bytes.to_vec())
255}
256
257fn sidecar_install_path(config: &BrowserConfig) -> anyhow::Result<PathBuf> {
258 if let Some(explicit) = config
259 .sidecar_path
260 .as_deref()
261 .map(str::trim)
262 .filter(|value| !value.is_empty())
263 {
264 return Ok(PathBuf::from(explicit));
265 }
266 managed_sidecar_install_path()
267}
268
269fn managed_sidecar_install_path() -> anyhow::Result<PathBuf> {
270 let root = resolve_shared_paths()
271 .map(|paths| paths.canonical_root)
272 .unwrap_or_else(|_| {
273 dirs::home_dir()
274 .map(|home| home.join(".tandem"))
275 .unwrap_or_else(|| PathBuf::from(".tandem"))
276 });
277 Ok(root.join("binaries").join(sidecar_binary_name()))
278}
279
280fn browser_release_asset_name() -> anyhow::Result<String> {
281 let os = if cfg!(target_os = "windows") {
282 "windows"
283 } else if cfg!(target_os = "macos") {
284 "darwin"
285 } else if cfg!(target_os = "linux") {
286 "linux"
287 } else {
288 anyhow::bail!("unsupported_os: {}", std::env::consts::OS);
289 };
290 let arch = if cfg!(target_arch = "x86_64") {
291 "x64"
292 } else if cfg!(target_arch = "aarch64") {
293 "arm64"
294 } else {
295 anyhow::bail!("unsupported_arch: {}", std::env::consts::ARCH);
296 };
297 let ext = if cfg!(target_os = "windows") || cfg!(target_os = "macos") {
298 "zip"
299 } else {
300 "tar.gz"
301 };
302 Ok(format!("tandem-browser-{os}-{arch}.{ext}"))
303}
304
305fn sidecar_binary_name() -> &'static str {
306 #[cfg(target_os = "windows")]
307 {
308 "tandem-browser.exe"
309 }
310 #[cfg(not(target_os = "windows"))]
311 {
312 "tandem-browser"
313 }
314}
315
316fn unpack_sidecar_archive(
317 asset_name: &str,
318 archive_bytes: &[u8],
319 install_path: &Path,
320) -> anyhow::Result<PathBuf> {
321 if asset_name.ends_with(".zip") {
322 let cursor = std::io::Cursor::new(archive_bytes);
323 let mut archive = zip::ZipArchive::new(cursor).context("invalid zip archive")?;
324 let binary_present = archive
325 .file_names()
326 .any(|name| name == sidecar_binary_name());
327 let mut file = if binary_present {
328 archive
329 .by_name(sidecar_binary_name())
330 .context("browser binary missing from zip archive")?
331 } else {
332 archive
333 .by_index(0)
334 .context("browser binary missing from zip archive")?
335 };
336 let mut output = std::fs::File::create(install_path)
337 .with_context(|| format!("failed to create `{}`", install_path.display()))?;
338 std::io::copy(&mut file, &mut output).context("failed to unpack zip asset")?;
339 } else if asset_name.ends_with(".tar.gz") {
340 let cursor = std::io::Cursor::new(archive_bytes);
341 let decoder = GzDecoder::new(cursor);
342 let mut archive = tar::Archive::new(decoder);
343 let mut found = false;
344 for entry in archive.entries().context("invalid tar archive")? {
345 let mut entry = entry.context("invalid tar entry")?;
346 let path = entry.path().context("invalid tar entry path")?;
347 if path
348 .file_name()
349 .and_then(|name| name.to_str())
350 .is_some_and(|name| name == sidecar_binary_name())
351 {
352 entry
353 .unpack(install_path)
354 .with_context(|| format!("failed to unpack `{}`", install_path.display()))?;
355 found = true;
356 break;
357 }
358 }
359 if !found {
360 anyhow::bail!("browser binary missing from tar archive");
361 }
362 } else {
363 anyhow::bail!("unsupported archive format `{asset_name}`");
364 }
365
366 #[cfg(not(target_os = "windows"))]
367 {
368 use std::os::unix::fs::PermissionsExt;
369
370 let mut perms = std::fs::metadata(install_path)
371 .with_context(|| format!("failed to read `{}` metadata", install_path.display()))?
372 .permissions();
373 perms.set_mode(0o755);
374 std::fs::set_permissions(install_path, perms)
375 .with_context(|| format!("failed to chmod `{}`", install_path.display()))?;
376 }
377
378 Ok(install_path.to_path_buf())
379}
380
381fn parse_tool_context(args: &Value) -> BrowserToolContext {
382 serde_json::from_value(args.clone()).unwrap_or(BrowserToolContext {
383 model_session_id: None,
384 })
385}
386
387fn ok_tool_result(value: Value, metadata: Value) -> anyhow::Result<ToolResult> {
388 Ok(ToolResult {
389 output: serde_json::to_string_pretty(&value)?,
390 metadata,
391 })
392}
393
394fn error_tool_result(code: &str, message: String, metadata: Option<Value>) -> ToolResult {
395 let mut meta = metadata.unwrap_or_else(|| json!({}));
396 if let Some(obj) = meta.as_object_mut() {
397 obj.insert("ok".to_string(), Value::Bool(false));
398 obj.insert("code".to_string(), Value::String(code.to_string()));
399 obj.insert("message".to_string(), Value::String(message.clone()));
400 }
401 ToolResult {
402 output: message,
403 metadata: meta,
404 }
405}
406
407fn split_error_code(message: &str) -> (&str, &str) {
408 let Some((code, detail)) = message.split_once(':') else {
409 return ("browser_error", message);
410 };
411 let code = code.trim();
412 if code.is_empty()
413 || !code
414 .chars()
415 .all(|ch| ch.is_ascii_lowercase() || ch == '_' || ch.is_ascii_digit())
416 {
417 return ("browser_error", message);
418 }
419 (code, detail.trim())
420}
421
422fn smoke_excerpt(content: &str, max_chars: usize) -> String {
423 let mut excerpt = String::new();
424 for ch in content.chars().take(max_chars) {
425 excerpt.push(ch);
426 }
427 if content.chars().count() > max_chars {
428 excerpt.push_str("...");
429 }
430 excerpt
431}
432
433fn browser_not_runnable_result(status: &BrowserStatus) -> anyhow::Result<ToolResult> {
434 ok_tool_result(
435 serde_json::to_value(status)?,
436 json!({
437 "ok": false,
438 "code": "browser_not_runnable",
439 "runnable": status.runnable,
440 "enabled": status.enabled,
441 }),
442 )
443}
444
445fn normalize_allowed_hosts(hosts: Vec<String>) -> Vec<String> {
446 let mut out = Vec::new();
447 for host in hosts {
448 let normalized = host.trim().trim_start_matches('.').to_ascii_lowercase();
449 if normalized.is_empty() {
450 continue;
451 }
452 if !out.iter().any(|existing| existing == &normalized) {
453 out.push(normalized);
454 }
455 }
456 out
457}
458
459fn browser_url_host(url: &str) -> anyhow::Result<String> {
460 let parsed =
461 reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
462 let host = parsed
463 .host_str()
464 .ok_or_else(|| anyhow!("url `{}` has no host", url))?;
465 Ok(host.to_ascii_lowercase())
466}
467
468fn ensure_allowed_browser_url(url: &str, allow_hosts: &[String]) -> anyhow::Result<()> {
469 let parsed =
470 reqwest::Url::parse(url).with_context(|| format!("invalid browser url `{}`", url))?;
471 match parsed.scheme() {
472 "http" | "https" => {}
473 other => anyhow::bail!("unsupported_url_scheme: `{}` is not allowed", other),
474 }
475 if allow_hosts.is_empty() {
476 return Ok(());
477 }
478 let host = parsed
479 .host_str()
480 .ok_or_else(|| anyhow!("url `{}` has no host", url))?
481 .to_ascii_lowercase();
482 let allowed = allow_hosts
483 .iter()
484 .any(|candidate| host == *candidate || host.ends_with(&format!(".{candidate}")));
485 if !allowed {
486 anyhow::bail!("host `{}` is not in the browser allowlist", host);
487 }
488 Ok(())
489}
490
491fn bool_env_value(enabled: bool) -> &'static str {
492 if enabled {
493 "true"
494 } else {
495 "false"
496 }
497}
498
499fn normalize_browser_open_request(request: &mut BrowserOpenRequest) {
500 request.profile_id = request
501 .profile_id
502 .take()
503 .map(|value| value.trim().to_string())
504 .filter(|value| !value.is_empty());
505}
506
507fn parse_browser_wait_condition(
508 input: BrowserWaitConditionArgs,
509) -> anyhow::Result<BrowserWaitCondition> {
510 let BrowserWaitConditionArgs {
511 kind,
512 value,
513 selector,
514 text,
515 url,
516 } = input;
517
518 let kind = kind
519 .map(|value| value.trim().to_string())
520 .filter(|value| !value.is_empty())
521 .or_else(|| selector.as_ref().map(|_| "selector".to_string()))
522 .or_else(|| text.as_ref().map(|_| "text".to_string()))
523 .or_else(|| url.as_ref().map(|_| "url".to_string()))
524 .ok_or_else(|| anyhow!("browser_wait requires condition.kind"))?;
525
526 let value = value
527 .filter(|value| !value.trim().is_empty())
528 .or_else(|| match kind.as_str() {
529 "selector" => selector,
530 "text" => text,
531 "url" => url,
532 _ => None,
533 });
534
535 Ok(BrowserWaitCondition { kind, value })
536}
537
538fn parse_browser_wait_args(args: &Value) -> anyhow::Result<BrowserWaitParams> {
539 let raw: BrowserWaitToolArgs = serde_json::from_value(args.clone())?;
540 let condition = if let Some(condition) = raw.condition {
541 parse_browser_wait_condition(condition)?
542 } else {
543 parse_browser_wait_condition(BrowserWaitConditionArgs {
544 kind: raw.kind,
545 value: raw.value,
546 selector: raw.selector,
547 text: raw.text,
548 url: raw.url,
549 })?
550 };
551
552 Ok(BrowserWaitParams {
553 session_id: raw.session_id,
554 condition,
555 timeout_ms: raw.timeout_ms,
556 })
557}
558
559fn is_local_or_private_host(host: &str) -> bool {
560 if host.eq_ignore_ascii_case("localhost") {
561 return true;
562 }
563 let Ok(ip) = host.parse::<IpAddr>() else {
564 return false;
565 };
566 match ip {
567 IpAddr::V4(ip) => {
568 ip.is_loopback()
569 || ip.is_private()
570 || ip.is_link_local()
571 || ip.octets()[0] == 169 && ip.octets()[1] == 254
572 }
573 IpAddr::V6(ip) => {
574 ip == Ipv6Addr::LOCALHOST || ip.is_unique_local() || ip.is_unicast_link_local()
575 }
576 }
577}
578
579fn resolve_text_input(text: Option<String>, secret_ref: Option<String>) -> anyhow::Result<String> {
580 if let Some(secret_ref) = secret_ref
581 .map(|v| v.trim().to_string())
582 .filter(|v| !v.is_empty())
583 {
584 let value = std::env::var(&secret_ref).with_context(|| {
585 format!("secret_ref `{}` is not set in the environment", secret_ref)
586 })?;
587 if value.trim().is_empty() {
588 anyhow::bail!("secret_ref `{}` resolved to an empty value", secret_ref);
589 }
590 return Ok(value);
591 }
592 let text = text.unwrap_or_default();
593 if text.is_empty() {
594 anyhow::bail!("browser_type requires either `text` or `secret_ref`");
595 }
596 Ok(text)
597}
598
599fn extension_for_extract_format(format: &str) -> &'static str {
600 match format {
601 "html" => "html",
602 "markdown" => "md",
603 _ => "txt",
604 }
605}
606
607fn viewport_schema() -> Value {
608 json!({
609 "type": "object",
610 "properties": {
611 "width": { "type": "integer", "minimum": 1, "maximum": 10000 },
612 "height": { "type": "integer", "minimum": 1, "maximum": 10000 }
613 }
614 })
615}
616
617fn wait_condition_schema() -> Value {
618 json!({
619 "type": "object",
620 "properties": {
621 "kind": {
622 "type": "string",
623 "enum": ["selector", "text", "url", "network_idle", "navigation"]
624 },
625 "value": { "type": "string" }
626 },
627 "required": ["kind"]
628 })
629}
630
631fn tool_schema(kind: BrowserToolKind) -> ToolSchema {
632 match kind {
633 BrowserToolKind::Status => ToolSchema::new(
634 "browser_status",
635 "Check browser automation readiness and install guidance. Call this first when browser tools may be unavailable.",
636 json!({ "type": "object", "properties": {} }),
637 ),
638 BrowserToolKind::Open => ToolSchema::new(
639 "browser_open",
640 "Open a URL in a browser session. Only http/https are allowed. Omit profile_id for an ephemeral session.",
641 json!({
642 "type": "object",
643 "properties": {
644 "url": { "type": "string" },
645 "profile_id": { "type": "string" },
646 "headless": { "type": "boolean" },
647 "viewport": viewport_schema(),
648 "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
649 },
650 "required": ["url"]
651 }),
652 ),
653 BrowserToolKind::Navigate => ToolSchema::new(
654 "browser_navigate",
655 "Navigate an existing browser session to a new URL.",
656 json!({
657 "type": "object",
658 "properties": {
659 "session_id": { "type": "string" },
660 "url": { "type": "string" },
661 "wait_until": { "type": "string", "enum": ["navigation", "network_idle"] }
662 },
663 "required": ["session_id", "url"]
664 }),
665 ),
666 BrowserToolKind::Snapshot => ToolSchema::new(
667 "browser_snapshot",
668 "Capture a bounded page summary with stable element_id values. Call this before click/type on a new page or after navigation.",
669 json!({
670 "type": "object",
671 "properties": {
672 "session_id": { "type": "string" },
673 "max_elements": { "type": "integer", "minimum": 1, "maximum": 200 },
674 "include_screenshot": { "type": "boolean" }
675 },
676 "required": ["session_id"]
677 }),
678 ),
679 BrowserToolKind::Click => ToolSchema::new(
680 "browser_click",
681 "Click a visible page element by element_id when possible. Use wait_for to make navigation and selector waits race-free.",
682 json!({
683 "type": "object",
684 "properties": {
685 "session_id": { "type": "string" },
686 "element_id": { "type": "string" },
687 "selector": { "type": "string" },
688 "wait_for": wait_condition_schema(),
689 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
690 },
691 "required": ["session_id"]
692 }),
693 ),
694 BrowserToolKind::Type => ToolSchema::new(
695 "browser_type",
696 "Type text into an element. Prefer secret_ref over text for credentials; secret_ref resolves from the host environment and is redacted from logs.",
697 json!({
698 "type": "object",
699 "properties": {
700 "session_id": { "type": "string" },
701 "element_id": { "type": "string" },
702 "selector": { "type": "string" },
703 "text": { "type": "string" },
704 "secret_ref": { "type": "string" },
705 "replace": { "type": "boolean" },
706 "submit": { "type": "boolean" },
707 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
708 },
709 "required": ["session_id"]
710 }),
711 ),
712 BrowserToolKind::Press => ToolSchema::new(
713 "browser_press",
714 "Dispatch a key press in the active page context.",
715 json!({
716 "type": "object",
717 "properties": {
718 "session_id": { "type": "string" },
719 "key": { "type": "string" },
720 "wait_for": wait_condition_schema(),
721 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 }
722 },
723 "required": ["session_id", "key"]
724 }),
725 ),
726 BrowserToolKind::Wait => ToolSchema::new(
727 "browser_wait",
728 "Wait for a selector, text, URL fragment, navigation, or network idle.",
729 json!({
730 "type": "object",
731 "properties": {
732 "session_id": { "type": "string" },
733 "condition": wait_condition_schema(),
734 "wait_for": wait_condition_schema(),
735 "waitFor": wait_condition_schema(),
736 "kind": {
737 "type": "string",
738 "enum": ["selector", "text", "url", "network_idle", "navigation"]
739 },
740 "type": {
741 "type": "string",
742 "enum": ["selector", "text", "url", "network_idle", "navigation"]
743 },
744 "value": { "type": "string" },
745 "selector": { "type": "string" },
746 "text": { "type": "string" },
747 "url": { "type": "string" },
748 "timeout_ms": { "type": "integer", "minimum": 250, "maximum": 120000 },
749 "timeoutMs": { "type": "integer", "minimum": 250, "maximum": 120000 }
750 },
751 "required": ["session_id"],
752 "anyOf": [
753 { "required": ["condition"] },
754 { "required": ["wait_for"] },
755 { "required": ["waitFor"] },
756 { "required": ["kind"] },
757 { "required": ["type"] },
758 { "required": ["selector"] },
759 { "required": ["text"] },
760 { "required": ["url"] }
761 ]
762 }),
763 ),
764 BrowserToolKind::Extract => ToolSchema::new(
765 "browser_extract",
766 "Extract page content as visible_text, markdown, or html. Prefer this over screenshots when you need text.",
767 json!({
768 "type": "object",
769 "properties": {
770 "session_id": { "type": "string" },
771 "format": { "type": "string", "enum": ["visible_text", "markdown", "html"] },
772 "max_bytes": { "type": "integer", "minimum": 1024, "maximum": 2000000 }
773 },
774 "required": ["session_id", "format"]
775 }),
776 ),
777 BrowserToolKind::Screenshot => ToolSchema::new(
778 "browser_screenshot",
779 "Capture a screenshot and store it as a browser artifact.",
780 json!({
781 "type": "object",
782 "properties": {
783 "session_id": { "type": "string" },
784 "full_page": { "type": "boolean" },
785 "label": { "type": "string" }
786 },
787 "required": ["session_id"]
788 }),
789 ),
790 BrowserToolKind::Close => ToolSchema::new(
791 "browser_close",
792 "Close a browser session and release its resources.",
793 json!({
794 "type": "object",
795 "properties": {
796 "session_id": { "type": "string" }
797 },
798 "required": ["session_id"]
799 }),
800 ),
801 }
802}