Files
claw/src/compat/browser_tool_adapter.rs
木炎 3e18350320 feat: add websocket browser service runtime
Wire the service/browser runtime onto the websocket-driven execution path and add the new browser/service modules needed for the submit flow and runtime integration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 23:42:27 +08:00

410 lines
12 KiB
Rust

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<dyn BrowserBackend>,
tool_name: &'static str,
description: &'static str,
}
impl ZeroClawBrowserTool {
pub fn new(browser_tool: Arc<dyn BrowserBackend>) -> Self {
Self::named(
browser_tool,
BROWSER_ACTION_TOOL_NAME,
BROWSER_ACTION_TOOL_DESCRIPTION,
)
}
pub fn new_superrpa(browser_tool: Arc<dyn BrowserBackend>) -> Self {
Self::named(
browser_tool,
SUPERRPA_BROWSER_TOOL_NAME,
SUPERRPA_BROWSER_TOOL_DESCRIPTION,
)
}
fn named(
browser_tool: Arc<dyn BrowserBackend>,
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<ToolResult> {
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<BrowserActionRequest, BrowserActionAdapterError> {
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<Action, BrowserActionAdapterError> {
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<String, Value>,
key: &'static str,
) -> Result<String, BrowserActionAdapterError> {
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<String, Value>,
) -> 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<String, Value>,
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<String, Value>,
) -> Result<String, BrowserActionAdapterError> {
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<String> {
Url::parse(raw)
.ok()?
.host_str()
.map(|host| host.to_ascii_lowercase())
}
fn normalize_domain_like(raw: &str) -> Option<String> {
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<Value> = 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),
}