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>
410 lines
12 KiB
Rust
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),
|
|
}
|