wip: checkpoint 2026-03-29 runtime work
This commit is contained in:
@@ -1,29 +1,70 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::Url;
|
||||
use serde_json::{json, Map, Value};
|
||||
use zeroclaw::tools::{Tool, ToolResult};
|
||||
|
||||
use crate::pipe::{Action, BrowserPipeTool, Transport};
|
||||
use crate::pipe::{Action, BrowserPipeTool, ExecutionSurfaceMetadata, Transport};
|
||||
|
||||
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<T: Transport> {
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
tool_name: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
impl<T: Transport> ZeroClawBrowserTool<T> {
|
||||
pub fn new(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self { browser_tool }
|
||||
Self::named(
|
||||
browser_tool,
|
||||
BROWSER_ACTION_TOOL_NAME,
|
||||
BROWSER_ACTION_TOOL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_superrpa(browser_tool: BrowserPipeTool<T>) -> Self {
|
||||
Self::named(
|
||||
browser_tool,
|
||||
SUPERRPA_BROWSER_TOOL_NAME,
|
||||
SUPERRPA_BROWSER_TOOL_DESCRIPTION,
|
||||
)
|
||||
}
|
||||
|
||||
fn named(
|
||||
browser_tool: BrowserPipeTool<T>,
|
||||
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<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
fn name(&self) -> &str {
|
||||
BROWSER_ACTION_TOOL_NAME
|
||||
self.tool_name
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Execute browser actions in SuperRPA through the existing sgClaw pipe protocol."
|
||||
self.description
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
@@ -72,8 +113,9 @@ impl<T: Transport + 'static> Tool for ZeroClawBrowserTool<T> {
|
||||
let output = serde_json::to_string(&json!({
|
||||
"seq": result.seq,
|
||||
"success": result.success,
|
||||
"data": result.data,
|
||||
"aom_snapshot": result.aom_snapshot,
|
||||
"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
|
||||
}))?;
|
||||
|
||||
@@ -103,9 +145,10 @@ fn parse_browser_action_request(args: Value) -> Result<BrowserActionRequest, Bro
|
||||
};
|
||||
|
||||
let action_name = take_required_string(&mut args, "action")?;
|
||||
let expected_domain = take_required_string(&mut args, "expected_domain")?;
|
||||
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,
|
||||
@@ -178,6 +221,59 @@ fn require_non_empty_string(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -193,6 +289,111 @@ fn format_browser_action_error(data: &Value) -> 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}")]
|
||||
|
||||
Reference in New Issue
Block a user