use std::sync::Arc; use async_trait::async_trait; use reqwest::Url; use serde_json::{json, Map, Value}; use zeroclaw::tools::{Tool, ToolResult}; use crate::browser::BrowserBackend; use crate::pipe::{Action, ExecutionSurfaceMetadata}; pub const BROWSER_ACTION_TOOL_NAME: &str = "browser_action"; pub const SUPERRPA_BROWSER_TOOL_NAME: &str = "superrpa_browser"; const BROWSER_ACTION_TOOL_DESCRIPTION: &str = "Execute browser actions in SuperRPA through the existing sgClaw pipe protocol."; const SUPERRPA_BROWSER_TOOL_DESCRIPTION: &str = "Use SuperRPA's dedicated privileged browser interface for page navigation, DOM reading, clicking, and typing inside the protected browser host."; const MAX_DATA_STRING_CHARS: usize = 2048; const MAX_AOM_STRING_CHARS: usize = 128; const MAX_DATA_ARRAY_ITEMS: usize = 12; const MAX_DATA_OBJECT_FIELDS: usize = 24; const MAX_DATA_RECURSION_DEPTH: usize = 4; pub struct ZeroClawBrowserTool { browser_tool: Arc, tool_name: &'static str, description: &'static str, } impl ZeroClawBrowserTool { pub fn new(browser_tool: Arc) -> Self { Self::named( browser_tool, BROWSER_ACTION_TOOL_NAME, BROWSER_ACTION_TOOL_DESCRIPTION, ) } pub fn new_superrpa(browser_tool: Arc) -> Self { Self::named( browser_tool, SUPERRPA_BROWSER_TOOL_NAME, SUPERRPA_BROWSER_TOOL_DESCRIPTION, ) } fn named( browser_tool: Arc, tool_name: &'static str, description: &'static str, ) -> Self { Self { browser_tool, tool_name, description, } } pub fn surface_metadata(&self) -> ExecutionSurfaceMetadata { self.browser_tool.surface_metadata() } } #[async_trait] impl Tool for ZeroClawBrowserTool { fn name(&self) -> &str { self.tool_name } fn description(&self) -> &str { self.description } fn parameters_schema(&self) -> Value { json!({ "type": "object", "required": ["action", "expected_domain"], "properties": { "action": { "type": "string", "enum": ["click", "type", "navigate", "getText"] }, "expected_domain": { "type": "string" }, "selector": { "type": "string" }, "text": { "type": "string" }, "url": { "type": "string" }, "clear_first": { "type": "boolean" } } }) } async fn execute(&self, args: Value) -> anyhow::Result { let request = match parse_browser_action_request(args) { Ok(request) => request, Err(err) => return Ok(failed_tool_result(err.to_string())), }; let result = match self .browser_tool .invoke(request.action, request.params, &request.expected_domain) { Ok(result) => result, Err(err) => return Ok(failed_tool_result(err.to_string())), }; let output = serde_json::to_string(&json!({ "seq": result.seq, "success": result.success, "data": compact_json_value(&result.data, 0), "aom_snapshot": compact_aom_snapshot(&result.aom_snapshot), "aom_snapshot_count": result.aom_snapshot.len(), "timing": result.timing }))?; Ok(ToolResult { success: result.success, output, error: (!result.success).then(|| format_browser_action_error(&result.data)), }) } } struct BrowserActionRequest { action: Action, expected_domain: String, params: Value, } fn parse_browser_action_request( args: Value, ) -> Result { let mut args = match args { Value::Object(args) => args, other => { return Err(BrowserActionAdapterError::InvalidArguments(format!( "expected object arguments, got {other}" ))) } }; let action_name = take_required_string(&mut args, "action")?; let raw_expected_domain = take_required_string(&mut args, "expected_domain")?; let action = parse_action(&action_name)?; validate_action_params(&action_name, &args)?; let expected_domain = normalize_expected_domain(&action, &raw_expected_domain, &args)?; Ok(BrowserActionRequest { action, expected_domain, params: Value::Object(args), }) } fn parse_action(action_name: &str) -> Result { match action_name { "click" => Ok(Action::Click), "type" => Ok(Action::Type), "navigate" => Ok(Action::Navigate), "getText" => Ok(Action::GetText), other => Err(BrowserActionAdapterError::UnsupportedAction( other.to_string(), )), } } fn take_required_string( args: &mut Map, key: &'static str, ) -> Result { match args.remove(key) { Some(Value::String(value)) if !value.trim().is_empty() => Ok(value), Some(other) => Err(BrowserActionAdapterError::InvalidArguments(format!( "{key} must be a non-empty string, got {other}" ))), None => Err(BrowserActionAdapterError::MissingField(key)), } } fn failed_tool_result(error: String) -> ToolResult { ToolResult { success: false, output: String::new(), error: Some(error), } } fn validate_action_params( action_name: &str, args: &Map, ) -> Result<(), BrowserActionAdapterError> { match action_name { "click" | "getText" => require_non_empty_string(args, "selector", action_name), "type" => { require_non_empty_string(args, "selector", action_name)?; require_non_empty_string(args, "text", action_name) } "navigate" => require_non_empty_string(args, "url", action_name), _ => Ok(()), } } fn require_non_empty_string( args: &Map, key: &'static str, action_name: &str, ) -> Result<(), BrowserActionAdapterError> { match args.get(key) { Some(Value::String(value)) if !value.trim().is_empty() => Ok(()), Some(other) => Err(BrowserActionAdapterError::InvalidArguments(format!( "{action_name} requires a non-empty {key}, got {other}" ))), None => Err(BrowserActionAdapterError::InvalidArguments(format!( "{action_name} requires {key}" ))), } } fn normalize_expected_domain( action: &Action, raw_expected_domain: &str, args: &Map, ) -> Result { if matches!(action, Action::Navigate) { if let Some(url) = args.get("url").and_then(Value::as_str) { if let Some(host) = host_from_url(url) { return Ok(host); } } } normalize_domain_like(raw_expected_domain).ok_or_else(|| { BrowserActionAdapterError::InvalidArguments(format!( "expected_domain must resolve to a hostname, got {raw_expected_domain:?}" )) }) } fn host_from_url(raw: &str) -> Option { Url::parse(raw) .ok()? .host_str() .map(|host| host.to_ascii_lowercase()) } fn normalize_domain_like(raw: &str) -> Option { let trimmed = raw.trim(); if trimmed.is_empty() { return None; } if let Some(host) = host_from_url(trimmed) { return Some(host); } let without_scheme = trimmed .trim_start_matches("https://") .trim_start_matches("http://"); let host = without_scheme .split(['/', '?', '#']) .next() .unwrap_or_default() .split(':') .next() .unwrap_or_default() .trim() .to_ascii_lowercase(); (!host.is_empty()).then_some(host) } fn format_browser_action_error(data: &Value) -> String { if let Some(error) = data.get("error") { if let Some(message) = error.get("message").and_then(Value::as_str) { return message.to_string(); } return format!("browser action failed: {error}"); } if data.is_null() { return "browser action returned success=false".to_string(); } format!("browser action failed: {data}") } fn compact_json_value(value: &Value, depth: usize) -> Value { compact_json_value_with_string_limit(value, depth, MAX_DATA_STRING_CHARS) } fn compact_aom_snapshot(snapshot: &[Value]) -> Value { Value::Array( snapshot .iter() .take(MAX_DATA_ARRAY_ITEMS) .map(|item| compact_aom_value(item, 0)) .collect(), ) } fn compact_aom_value(value: &Value, depth: usize) -> Value { if depth >= MAX_DATA_RECURSION_DEPTH { return Value::String("[truncated nested value]".to_string()); } match value { Value::Object(map) => { let mut compacted = Map::new(); for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) { if matches!(key.as_str(), "text" | "value" | "html") { let summary = item .as_str() .map(|text| format!("[{} chars omitted]", text.chars().count())) .unwrap_or_else(|| "[omitted]".to_string()); compacted.insert(key.clone(), Value::String(summary)); continue; } compacted.insert(key.clone(), compact_aom_value(item, depth + 1)); } Value::Object(compacted) } Value::Array(items) => Value::Array( items .iter() .take(MAX_DATA_ARRAY_ITEMS) .map(|item| compact_aom_value(item, depth + 1)) .collect(), ), _ => compact_json_value_with_string_limit(value, depth, MAX_AOM_STRING_CHARS), } } fn compact_json_value_with_string_limit( value: &Value, depth: usize, max_string_chars: usize, ) -> Value { if depth >= MAX_DATA_RECURSION_DEPTH { return Value::String("[truncated nested value]".to_string()); } match value { Value::Null | Value::Bool(_) | Value::Number(_) => value.clone(), Value::String(text) => Value::String(truncate_string(text, max_string_chars)), Value::Array(items) => { let mut compacted: Vec = items .iter() .take(MAX_DATA_ARRAY_ITEMS) .map(|item| compact_json_value_with_string_limit(item, depth + 1, max_string_chars)) .collect(); if items.len() > MAX_DATA_ARRAY_ITEMS { compacted.push(Value::String(format!( "[{} more items omitted]", items.len() - MAX_DATA_ARRAY_ITEMS ))); } Value::Array(compacted) } Value::Object(map) => { let mut compacted = Map::new(); for (key, item) in map.iter().take(MAX_DATA_OBJECT_FIELDS) { compacted.insert( key.clone(), compact_json_value_with_string_limit(item, depth + 1, max_string_chars), ); } if map.len() > MAX_DATA_OBJECT_FIELDS { compacted.insert( "_truncated_fields".to_string(), Value::String(format!( "{} additional fields omitted", map.len() - MAX_DATA_OBJECT_FIELDS )), ); } Value::Object(compacted) } } } fn truncate_string(text: &str, max_chars: usize) -> String { let total_chars = text.chars().count(); if total_chars <= max_chars { return text.to_string(); } let prefix: String = text.chars().take(max_chars).collect(); format!("{prefix}...[truncated {} chars]", total_chars - max_chars) } #[derive(Debug, thiserror::Error)] enum BrowserActionAdapterError { #[error("unsupported action: {0}")] UnsupportedAction(String), #[error("missing required field: {0}")] MissingField(&'static str), #[error("invalid tool arguments: {0}")] InvalidArguments(String), }