use crate::browser::BrowserSession;
use crate::browser::backend::{ATTACH_PAGE_TARGET_LOST_CODE, ATTACH_SESSION_PAGE_TARGET_LOSS_KIND};
use crate::contract::ViewportMetrics;
pub use crate::contract::{
DocumentActionResult, DocumentEnvelope, DocumentResult, PublicTarget, SnapshotMode,
SnapshotScope, TargetEnvelope, TargetedActionResult, ToolResult,
};
use crate::dom::{Cursor, DocumentMetadata, DomTree, NodeRef};
#[cfg(test)]
use crate::error::BackendUnsupportedDetails;
use crate::error::{BrowserError, PageTargetLostDetails, Result};
use crate::tools::{
click, close, close_tab, evaluate, extract, go_back, go_forward, hover, input, inspect_node,
markdown, navigate, new_tab, press_key, read_links, screenshot, scroll, select, set_viewport,
snapshot, switch_tab, tab_list, wait,
};
use serde_json::Value;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
pub(crate) mod snapshot_projection;
use self::snapshot_projection::{
SnapshotProjectionInput, project_snapshot, snapshot_cache_entry_from_projection,
};
pub(crate) const OPERATION_METRICS_METADATA_KEY: &str = "operation_metrics";
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub(crate) struct OperationMetrics {
pub browser_evaluations: u64,
pub poll_iterations: u64,
pub dom_extractions: u64,
pub dom_extraction_micros: u64,
pub dom_nodes_last: usize,
pub snapshot_render_micros: u64,
pub handoff_rebuilds: u64,
pub handoff_rebuild_micros: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_bytes: Option<usize>,
}
impl OperationMetrics {
fn is_empty(&self) -> bool {
self.browser_evaluations == 0
&& self.poll_iterations == 0
&& self.dom_extractions == 0
&& self.dom_extraction_micros == 0
&& self.dom_nodes_last == 0
&& self.snapshot_render_micros == 0
&& self.handoff_rebuilds == 0
&& self.handoff_rebuild_micros == 0
&& self.output_bytes.is_none()
}
}
pub(crate) fn duration_micros(duration: std::time::Duration) -> u64 {
duration.as_micros().min(u128::from(u64::MAX)) as u64
}
pub struct ToolContext<'a> {
pub session: &'a BrowserSession,
pub dom_tree: Option<DomTree>,
metrics: OperationMetrics,
}
impl<'a> ToolContext<'a> {
pub fn new(session: &'a BrowserSession) -> Self {
Self {
session,
dom_tree: None,
metrics: OperationMetrics::default(),
}
}
pub fn with_dom(session: &'a BrowserSession, dom_tree: DomTree) -> Self {
Self {
session,
dom_tree: Some(dom_tree),
metrics: OperationMetrics::default(),
}
}
pub fn invalidate_dom(&mut self) {
self.dom_tree = None;
}
pub fn get_dom(&mut self) -> Result<&DomTree> {
if self.dom_tree.is_none() {
let started = Instant::now();
let dom = self.session.extract_dom()?;
self.metrics.browser_evaluations += 1;
self.metrics.dom_extractions += 1;
self.metrics.dom_extraction_micros += duration_micros(started.elapsed());
self.metrics.dom_nodes_last = dom.count_nodes();
self.dom_tree = Some(dom);
}
Ok(self.dom_tree.as_ref().unwrap())
}
pub fn refresh_dom(&mut self) -> Result<&DomTree> {
self.invalidate_dom();
self.get_dom()
}
pub(crate) fn record_browser_evaluation(&mut self) {
self.record_browser_evaluations(1);
}
pub(crate) fn record_browser_evaluations(&mut self, count: u64) {
self.metrics.browser_evaluations += count;
}
pub(crate) fn record_poll_iteration(&mut self) {
self.record_poll_iterations(1);
}
pub(crate) fn record_poll_iterations(&mut self, count: u64) {
self.metrics.poll_iterations += count;
}
pub(crate) fn record_snapshot_render_micros(&mut self, micros: u64) {
self.metrics.snapshot_render_micros += micros;
}
pub(crate) fn record_handoff_rebuild_micros(&mut self, micros: u64) {
self.metrics.handoff_rebuilds += 1;
self.metrics.handoff_rebuild_micros += micros;
}
pub(crate) fn finish(&self, mut result: ToolResult) -> ToolResult {
if !self.metrics.is_empty() {
result.metadata.insert(
OPERATION_METRICS_METADATA_KEY.to_string(),
serde_json::to_value(&self.metrics).unwrap_or_default(),
);
}
result
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct DocumentEnvelopeOptions {
pub include_snapshot: bool,
pub include_nodes: bool,
pub snapshot_mode: SnapshotMode,
}
impl DocumentEnvelopeOptions {
pub const fn minimal() -> Self {
Self {
include_snapshot: false,
include_nodes: false,
snapshot_mode: SnapshotMode::Viewport,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub const fn full() -> Self {
Self::snapshot(SnapshotMode::Full)
}
pub const fn snapshot(snapshot_mode: SnapshotMode) -> Self {
Self {
include_snapshot: true,
include_nodes: true,
snapshot_mode,
}
}
}
#[derive(
Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema, PartialEq, Eq,
)]
pub struct TabSummary {
pub tab_id: String,
pub index: usize,
pub active: bool,
pub title: String,
pub url: String,
}
impl TabSummary {
pub fn from_browser_tab(index: usize, tab: &crate::browser::TabInfo) -> Self {
Self {
tab_id: tab.id.clone(),
index,
active: tab.active,
title: tab.title.clone(),
url: tab.url.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedTarget {
pub method: String,
pub selector: String,
pub index: Option<usize>,
pub node_ref: Option<NodeRef>,
pub cursor: Option<Cursor>,
}
impl ResolvedTarget {
pub fn to_target_envelope(&self) -> TargetEnvelope {
let (method, resolution_status, recovered_from) = decode_target_method(&self.method);
TargetEnvelope {
method,
resolution_status,
recovered_from,
cursor: self.cursor.clone(),
node_ref: self.node_ref.clone(),
selector: Some(self.selector.clone()),
index: self.index,
}
}
}
#[derive(Debug)]
pub(crate) enum TargetResolution {
Resolved(ResolvedTarget),
Failure(ToolResult),
}
fn default_target_resolution_status() -> String {
"exact".to_string()
}
#[derive(
Debug, Clone, Copy, serde::Serialize, serde::Deserialize, schemars::JsonSchema, PartialEq, Eq,
)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TargetRecoveredFrom {
Cursor,
NodeRef,
}
impl TargetRecoveredFrom {
fn encoded(self) -> &'static str {
match self {
Self::Cursor => "cursor",
Self::NodeRef => "node_ref",
}
}
fn decode(value: &str) -> Option<Self> {
match value {
"cursor" => Some(Self::Cursor),
"node_ref" => Some(Self::NodeRef),
_ => None,
}
}
}
const TARGET_METHOD_SELECTOR_REBOUND_MARKER: &str = "::selector_rebound::";
pub(crate) fn encode_selector_rebound_method(
method: &str,
recovered_from: TargetRecoveredFrom,
) -> String {
format!(
"{method}{TARGET_METHOD_SELECTOR_REBOUND_MARKER}{}",
recovered_from.encoded()
)
}
fn decode_target_method(encoded: &str) -> (String, String, Option<String>) {
let Some((method, recovered_from)) = encoded.split_once(TARGET_METHOD_SELECTOR_REBOUND_MARKER)
else {
return (
encoded.to_string(),
default_target_resolution_status(),
None,
);
};
let Some(recovered_from) = TargetRecoveredFrom::decode(recovered_from) else {
return (
encoded.to_string(),
default_target_resolution_status(),
None,
);
};
(
method.to_string(),
"selector_rebound".to_string(),
Some(recovered_from.encoded().to_string()),
)
}
fn invalid_target_failure(message: impl Into<String>) -> ToolResult {
let message = message.into();
structured_tool_failure("invalid_target", message, None, None, None, None)
}
fn stale_node_ref_failure(
provided: &NodeRef,
current: &DocumentMetadata,
selector: Option<&str>,
recovered_from: TargetRecoveredFrom,
) -> ToolResult {
let selector = selector.filter(|selector| !selector.is_empty());
structured_tool_failure(
"stale_node_ref",
"Stale node reference",
Some(current.clone()),
None,
Some(serde_json::json!({
"suggested_tool": "snapshot",
"suggested_selector": selector,
})),
Some(serde_json::json!({
"provided": provided,
"resolution": {
"status": "unrecoverable_stale",
"recovered_from": recovered_from,
"selector_rebound_attempted": selector.is_some(),
},
})),
)
}
pub(crate) fn resolve_target_with_cursor(
tool: &str,
selector: Option<String>,
index: Option<usize>,
node_ref: Option<NodeRef>,
cursor: Option<Cursor>,
dom: Option<&DomTree>,
) -> Result<TargetResolution> {
let target_count = usize::from(selector.is_some())
+ usize::from(index.is_some())
+ usize::from(node_ref.is_some())
+ usize::from(cursor.is_some());
if target_count > 1 {
return Ok(TargetResolution::Failure(invalid_target_failure(
"Cannot specify more than one of 'selector', 'index', 'node_ref', or 'cursor'.",
)));
}
if target_count == 0 {
return Ok(TargetResolution::Failure(invalid_target_failure(
"Must specify one of 'selector', 'index', 'node_ref', or 'cursor'.",
)));
}
match (selector, index, node_ref, cursor) {
(Some(selector), None, None, None) => {
let cursor = dom.and_then(|dom| actionable_cursor_for_selector(dom, &selector));
Ok(TargetResolution::Resolved(ResolvedTarget {
method: "css".to_string(),
selector,
index: None,
node_ref: None,
cursor,
}))
}
(None, Some(index), None, None) => {
let dom = dom.ok_or_else(|| BrowserError::ToolExecutionFailed {
tool: tool.to_string(),
reason: "DOM tree is required to resolve an element index.".to_string(),
})?;
let cursor = dom.cursor_for_index(index).ok_or_else(|| {
BrowserError::ElementNotFound(format!("No element with index {}", index))
})?;
Ok(TargetResolution::Resolved(ResolvedTarget {
method: "index".to_string(),
selector: cursor.selector.clone(),
index: Some(cursor.index),
node_ref: Some(cursor.node_ref.clone()),
cursor: Some(cursor),
}))
}
(None, None, Some(node_ref), None) => {
let dom = dom.ok_or_else(|| BrowserError::ToolExecutionFailed {
tool: tool.to_string(),
reason: "DOM tree is required to resolve a node reference.".to_string(),
})?;
if node_ref.document_id != dom.document.document_id
|| node_ref.revision != dom.document.revision
{
return Ok(TargetResolution::Failure(stale_node_ref_failure(
&node_ref,
&dom.document,
None,
TargetRecoveredFrom::NodeRef,
)));
}
let mut cursor = dom.cursor_for_index(node_ref.index).ok_or_else(|| {
BrowserError::ElementNotFound(format!(
"No element with index {} for the provided node reference",
node_ref.index
))
})?;
cursor.node_ref = node_ref.clone();
Ok(TargetResolution::Resolved(ResolvedTarget {
method: "node_ref".to_string(),
selector: cursor.selector.clone(),
index: Some(node_ref.index),
node_ref: Some(node_ref),
cursor: Some(cursor),
}))
}
(None, None, None, Some(cursor_input)) => {
let dom = dom.ok_or_else(|| BrowserError::ToolExecutionFailed {
tool: tool.to_string(),
reason: "DOM tree is required to resolve a cursor.".to_string(),
})?;
if cursor_input.node_ref.document_id != dom.document.document_id
|| cursor_input.node_ref.revision != dom.document.revision
{
if !cursor_input.selector.is_empty()
&& let Some(cursor) =
actionable_cursor_for_selector(dom, &cursor_input.selector)
{
return Ok(TargetResolution::Resolved(ResolvedTarget {
method: encode_selector_rebound_method(
"cursor",
TargetRecoveredFrom::Cursor,
),
selector: cursor.selector.clone(),
index: Some(cursor.index),
node_ref: Some(cursor.node_ref.clone()),
cursor: Some(cursor),
}));
}
return Ok(TargetResolution::Failure(stale_node_ref_failure(
&cursor_input.node_ref,
&dom.document,
Some(cursor_input.selector.as_str()),
TargetRecoveredFrom::Cursor,
)));
}
let mut cursor = dom
.cursor_for_index(cursor_input.node_ref.index)
.ok_or_else(|| {
BrowserError::ElementNotFound(format!(
"No element with index {} for the provided cursor",
cursor_input.node_ref.index
))
})?;
cursor.node_ref = cursor_input.node_ref.clone();
Ok(TargetResolution::Resolved(ResolvedTarget {
method: "cursor".to_string(),
selector: cursor.selector.clone(),
index: Some(cursor.index),
node_ref: Some(cursor.node_ref.clone()),
cursor: Some(cursor),
}))
}
_ => Err(BrowserError::ToolExecutionFailed {
tool: tool.to_string(),
reason: "Failed to resolve target".to_string(),
}),
}
}
pub(crate) fn actionable_cursor_for_selector(dom: &DomTree, selector: &str) -> Option<Cursor> {
dom.cursor_for_selector(selector)
}
fn normalized_error_value(value: Value) -> Option<Value> {
match value {
Value::Null => None,
Value::Object(map) if map.is_empty() => None,
other => Some(other),
}
}
pub(crate) fn structured_error_payload(
code: impl Into<String>,
error: impl Into<String>,
document: Option<DocumentMetadata>,
target: Option<TargetEnvelope>,
recovery: Option<Value>,
details: Option<Value>,
) -> Value {
let mut payload = serde_json::Map::new();
payload.insert("code".to_string(), Value::String(code.into()));
payload.insert("error".to_string(), Value::String(error.into()));
if let Some(document) = document
&& let Some(value) =
normalized_error_value(serde_json::to_value(document).unwrap_or(Value::Null))
{
payload.insert("document".to_string(), value);
}
if let Some(target) = target
&& let Some(value) =
normalized_error_value(serde_json::to_value(target).unwrap_or(Value::Null))
{
payload.insert("target".to_string(), value);
}
if let Some(recovery) = recovery.and_then(normalized_error_value) {
payload.insert("recovery".to_string(), recovery);
}
if let Some(details) = details.and_then(normalized_error_value) {
payload.insert("details".to_string(), details);
}
Value::Object(payload)
}
pub(crate) fn structured_tool_failure(
code: impl Into<String>,
error: impl Into<String>,
document: Option<DocumentMetadata>,
target: Option<TargetEnvelope>,
recovery: Option<Value>,
details: Option<Value>,
) -> ToolResult {
let error = error.into();
ToolResult::failure_with(
error.clone(),
structured_error_payload(code, error, document, target, recovery, details),
)
}
fn structured_failure(code: &str, error: String) -> ToolResult {
structured_tool_failure(code, error, None, None, None, None)
}
fn attach_session_degraded_failure(details: PageTargetLostDetails) -> ToolResult {
structured_tool_failure(
ATTACH_PAGE_TARGET_LOST_CODE,
details.detail.clone(),
None,
None,
Some(serde_json::json!({
"suggested_tool": "tab_list",
"hint": details.recovery_hint.clone().unwrap_or_default(),
})),
Some(serde_json::json!({
"kind": ATTACH_SESSION_PAGE_TARGET_LOSS_KIND,
"operation": details.operation.clone(),
"session_origin": "connected",
})),
)
}
pub(crate) fn tool_result_from_browser_error(
error: BrowserError,
) -> std::result::Result<ToolResult, BrowserError> {
match error {
BrowserError::LaunchFailed(message) => Err(BrowserError::LaunchFailed(message)),
BrowserError::ConnectionFailed(message) => Err(BrowserError::ConnectionFailed(message)),
BrowserError::ChromeError(message) => Err(BrowserError::ChromeError(message)),
BrowserError::InvalidArgument(reason) => Ok(structured_failure("invalid_argument", reason)),
BrowserError::Timeout(reason) => Ok(structured_failure("timeout", reason)),
BrowserError::SelectorInvalid(reason) => Ok(structured_failure(
"invalid_selector",
format!("Invalid selector: {}", reason),
)),
BrowserError::ElementNotFound(reason) => Ok(structured_failure(
"element_not_found",
format!("Element not found: {}", reason),
)),
BrowserError::DomParseFailed(reason) => Ok(structured_failure(
"dom_parse_failed",
format!("Failed to parse DOM: {}", reason),
)),
BrowserError::ToolExecutionFailed { tool, reason } => Ok(structured_tool_failure(
"tool_execution_failed",
reason,
None,
None,
None,
Some(serde_json::json!({
"tool": tool,
})),
)),
BrowserError::NavigationFailed(reason) => {
Ok(structured_failure("navigation_failed", reason))
}
BrowserError::EvaluationFailed(reason) => {
Ok(structured_failure("evaluation_failed", reason))
}
BrowserError::ScreenshotFailed(reason) => {
Ok(structured_failure("screenshot_failed", reason))
}
BrowserError::DownloadFailed(reason) => Ok(structured_failure("download_failed", reason)),
BrowserError::TabOperationFailed(reason) => {
Ok(structured_failure("tab_operation_failed", reason))
}
BrowserError::PageTargetLost(details) => {
if details.is_attach_session_degraded() {
return Ok(attach_session_degraded_failure(details));
}
Ok(structured_tool_failure(
ATTACH_PAGE_TARGET_LOST_CODE,
details.detail.clone(),
None,
None,
None,
Some(serde_json::json!({
"kind": ATTACH_SESSION_PAGE_TARGET_LOSS_KIND,
"operation": details.operation,
"recoverable": details.recoverable,
})),
))
}
BrowserError::BackendUnsupported(details) => Ok(structured_tool_failure(
"backend_unsupported",
details.to_string(),
None,
None,
None,
Some(serde_json::json!({
"capability": details.capability,
"operation": details.operation,
})),
)),
BrowserError::JsonError(error) => Ok(structured_failure(
"json_error",
format!("JSON error: {}", error),
)),
BrowserError::IoError(error) => Ok(structured_failure(
"io_error",
format!("IO error: {}", error),
)),
}
}
pub(crate) fn normalize_tool_outcome(
outcome: Result<ToolResult>,
context: &ToolContext<'_>,
) -> Result<ToolResult> {
match outcome {
Ok(result) => Ok(context.finish(result)),
Err(error) => match tool_result_from_browser_error(error) {
Ok(result) => Ok(context.finish(result)),
Err(error) => Err(error),
},
}
}
fn live_viewport_metrics(context: &mut ToolContext<'_>) -> Result<ViewportMetrics> {
context.record_browser_evaluation();
context.session.viewport_metrics(None)
}
pub(crate) fn build_document_envelope(
context: &mut ToolContext,
target: Option<&ResolvedTarget>,
options: DocumentEnvelopeOptions,
) -> Result<DocumentEnvelope> {
let target = target.map(|resolved| resolved.to_target_envelope());
if options.include_snapshot || options.include_nodes {
let (document, snapshot, nodes, global_interactive_count, mut scope, render_micros) = {
let session = context.session;
let dom = context.get_dom()?;
let document = dom.document.clone();
let global_interactive_count = Some(dom.count_interactive());
let base = match options.snapshot_mode {
SnapshotMode::Delta => session.snapshot_cache_entry(&document)?,
SnapshotMode::Viewport | SnapshotMode::Full => None,
};
let projection_output = project_snapshot(SnapshotProjectionInput {
dom,
mode: options.snapshot_mode,
global_interactive_count,
base: base.as_deref(),
});
if let Some(cache_projection) = projection_output.cache_projection.as_ref() {
session.store_snapshot_cache(Arc::new(snapshot_cache_entry_from_projection(
&document,
cache_projection,
)))?;
}
let projection = projection_output.current;
(
document,
options
.include_snapshot
.then(|| projection.snapshot.as_ref().to_string()),
if options.include_nodes {
projection.nodes.iter().cloned().collect()
} else {
Vec::new()
},
options
.include_nodes
.then_some(global_interactive_count)
.flatten(),
Some(projection.scope),
projection.render_micros,
)
};
if let Some(scope) = scope.as_mut() {
scope.viewport = Some(live_viewport_metrics(context)?);
}
if options.include_snapshot {
context.record_snapshot_render_micros(render_micros);
}
return Ok(DocumentEnvelope {
document,
target,
snapshot,
nodes,
scope,
global_interactive_count,
});
}
Ok(DocumentEnvelope {
document: {
context.record_browser_evaluation();
context.session.document_metadata()?
},
target,
snapshot: None,
nodes: Vec::new(),
scope: None,
global_interactive_count: None,
})
}
pub trait Tool: Send + Sync + Default {
type Params: serde::Serialize + for<'de> serde::Deserialize<'de> + schemars::JsonSchema;
type Output: serde::Serialize + schemars::JsonSchema + 'static;
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value {
serde_json::to_value(schemars::schema_for!(Self::Params)).unwrap_or_default()
}
fn output_schema(&self) -> Value {
serde_json::to_value(schemars::schema_for!(Self::Output)).unwrap_or_default()
}
fn execute_typed(&self, params: Self::Params, context: &mut ToolContext) -> Result<ToolResult>;
fn execute(&self, params: Value, context: &mut ToolContext) -> Result<ToolResult> {
let typed_params: Self::Params = serde_json::from_value(params).map_err(|e| {
crate::error::BrowserError::InvalidArgument(format!("Invalid parameters: {}", e))
})?;
self.execute_typed(typed_params, context)
}
}
pub trait DynTool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value;
fn output_schema(&self) -> Value;
fn execute(&self, params: Value, context: &mut ToolContext) -> Result<ToolResult>;
}
impl<T: Tool> DynTool for T {
fn name(&self) -> &str {
Tool::name(self)
}
fn description(&self) -> &str {
Tool::description(self)
}
fn parameters_schema(&self) -> Value {
Tool::parameters_schema(self)
}
fn output_schema(&self) -> Value {
Tool::output_schema(self)
}
fn execute(&self, params: Value, context: &mut ToolContext) -> Result<ToolResult> {
Tool::execute(self, params, context)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolDescriptor {
pub name: String,
pub description: String,
pub parameters_schema: Value,
pub output_schema: Value,
pub annotations: ToolSafetyAnnotations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolSafetyAnnotations {
pub read_only_hint: bool,
pub destructive_hint: bool,
pub idempotent_hint: bool,
pub open_world_hint: bool,
}
impl ToolSafetyAnnotations {
const fn read_only() -> Self {
Self {
read_only_hint: true,
destructive_hint: false,
idempotent_hint: true,
open_world_hint: true,
}
}
const fn mutating(destructive_hint: bool, idempotent_hint: bool) -> Self {
Self {
read_only_hint: false,
destructive_hint,
idempotent_hint,
open_world_hint: true,
}
}
}
fn explicit_tool_safety_annotations(name: &str) -> Option<ToolSafetyAnnotations> {
match name {
"extract" | "get_markdown" | "inspect_node" | "read_links" | "screenshot" | "snapshot"
| "tab_list" | "wait" => Some(ToolSafetyAnnotations::read_only()),
"set_viewport" | "switch_tab" => Some(ToolSafetyAnnotations::mutating(false, true)),
"go_back" | "go_forward" | "hover" | "input" | "navigate" | "new_tab" | "scroll"
| "select" => Some(ToolSafetyAnnotations::mutating(false, false)),
"click" | "close" | "close_tab" | "evaluate" | "press_key" => {
Some(ToolSafetyAnnotations::mutating(true, false))
}
_ => None,
}
}
fn tool_safety_annotations(name: &str) -> ToolSafetyAnnotations {
explicit_tool_safety_annotations(name)
.unwrap_or_else(|| ToolSafetyAnnotations::mutating(true, false))
}
pub struct ToolRegistry {
tools: HashMap<String, Arc<dyn DynTool>>,
}
impl ToolRegistry {
pub fn new() -> Self {
Self {
tools: HashMap::new(),
}
}
pub fn with_defaults() -> Self {
let mut registry = Self::new();
registry.register_default_tools();
registry
}
pub fn with_all_tools() -> Self {
let mut registry = Self::with_defaults();
registry.register_operator_tools();
registry
}
pub fn register_default_tools(&mut self) {
self.register(navigate::NavigateTool);
self.register(go_back::GoBackTool);
self.register(go_forward::GoForwardTool);
self.register(wait::WaitTool);
self.register(click::ClickTool);
self.register(input::InputTool);
self.register(select::SelectTool);
self.register(hover::HoverTool);
self.register(press_key::PressKeyTool);
self.register(scroll::ScrollTool);
self.register(set_viewport::SetViewportTool);
self.register(new_tab::NewTabTool);
self.register(tab_list::TabListTool);
self.register(switch_tab::SwitchTabTool);
self.register(close_tab::CloseTabTool);
self.register(extract::ExtractContentTool);
self.register(markdown::GetMarkdownTool);
self.register(read_links::ReadLinksTool);
self.register(snapshot::SnapshotTool);
self.register(inspect_node::InspectNodeTool);
self.register(screenshot::ScreenshotTool);
self.register(close::CloseTool);
}
pub fn register_operator_tools(&mut self) {
self.register(evaluate::EvaluateTool);
}
pub fn register<T: Tool + 'static>(&mut self, tool: T) {
let name = tool.name().to_string();
self.tools.insert(name, Arc::new(tool));
}
pub fn get(&self, name: &str) -> Option<&Arc<dyn DynTool>> {
self.tools.get(name)
}
pub fn has(&self, name: &str) -> bool {
self.tools.contains_key(name)
}
pub fn list_names(&self) -> Vec<String> {
let mut names: Vec<_> = self.tools.keys().cloned().collect();
names.sort();
names
}
pub fn all_tools(&self) -> Vec<Arc<dyn DynTool>> {
self.tools.values().cloned().collect()
}
pub fn descriptors(&self) -> Vec<ToolDescriptor> {
let mut descriptors: Vec<_> = self
.tools
.values()
.map(|tool| ToolDescriptor {
name: tool.name().to_string(),
description: tool.description().to_string(),
parameters_schema: tool.parameters_schema(),
output_schema: tool.output_schema(),
annotations: tool_safety_annotations(tool.name()),
})
.collect();
descriptors.sort_by(|left, right| left.name.cmp(&right.name));
descriptors
}
pub fn execute(
&self,
name: &str,
params: Value,
context: &mut ToolContext,
) -> Result<ToolResult> {
let outcome = match self.get(name) {
Some(tool) => tool.execute(params, context),
None => Ok(ToolResult::failure(format!("Tool '{}' not found", name))),
};
normalize_tool_outcome(outcome, context)
}
pub fn count(&self) -> usize {
self.tools.len()
}
}
impl Default for ToolRegistry {
fn default() -> Self {
Self::with_defaults()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::backend::FakeSessionBackend;
use crate::browser::{SnapshotCacheEntry, SnapshotCacheScope};
use crate::dom::{AriaChild, AriaNode, DomTree, SnapshotNode};
use schemars::schema_for;
use serde_json::json;
use std::collections::HashMap;
use std::sync::Arc;
fn sample_dom() -> DomTree {
let root = AriaNode::fragment().with_child(AriaChild::Node(Box::new(
AriaNode::new("button", "Submit")
.with_index(1)
.with_box(true, Some("pointer".to_string())),
)));
let mut dom = DomTree::new(root);
dom.document.document_id = "doc-1".to_string();
dom.document.revision = "main:1".to_string();
dom.replace_selectors(vec![String::new(), "#submit".to_string()]);
dom
}
#[test]
fn registered_tool_names_have_explicit_safety_annotations() {
let missing: Vec<String> = ToolRegistry::with_all_tools()
.list_names()
.into_iter()
.filter(|name| explicit_tool_safety_annotations(name).is_none())
.collect();
assert!(
missing.is_empty(),
"registered tools missing explicit safety annotations: {missing:?}"
);
}
#[test]
fn tool_descriptors_include_safety_annotations() {
let descriptors: HashMap<String, ToolSafetyAnnotations> = ToolRegistry::with_all_tools()
.descriptors()
.into_iter()
.map(|descriptor| (descriptor.name, descriptor.annotations))
.collect();
assert_eq!(
descriptors.get("snapshot"),
Some(&ToolSafetyAnnotations::read_only())
);
assert_eq!(
descriptors.get("switch_tab"),
Some(&ToolSafetyAnnotations::mutating(false, true))
);
assert_eq!(
descriptors.get("close_tab"),
Some(&ToolSafetyAnnotations::mutating(true, false))
);
assert_eq!(
descriptors.get("evaluate"),
Some(&ToolSafetyAnnotations::mutating(true, false))
);
}
#[test]
fn public_target_deserializes_plain_selector_string() {
let target: PublicTarget =
serde_json::from_value(json!("#submit")).expect("plain selector should deserialize");
assert_eq!(
target,
PublicTarget::Selector {
selector: "#submit".to_string(),
}
);
}
#[test]
fn public_target_schema_mentions_string_and_cursor_variants() {
let schema = schema_for!(PublicTarget);
let schema_json = serde_json::to_string(&schema).expect("schema should serialize");
assert!(schema_json.contains("\"type\":\"string\""));
assert!(schema_json.contains("\"kind\""));
assert!(schema_json.contains("\"selector\""));
assert!(schema_json.contains("\"cursor\""));
}
fn viewport_dom() -> DomTree {
let mut offscreen_button = AriaNode::new("button", "Offscreen save")
.with_index(0)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
offscreen_button.box_info.in_viewport = false;
let visible_heading = AriaNode::new("heading", "Visible story").with_box(true, None);
let visible_tab = AriaNode::new("button", "Visible tab")
.with_index(1)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()))
.with_selected(true);
let root = AriaNode::fragment()
.with_child(AriaChild::Node(Box::new(offscreen_button)))
.with_child(AriaChild::Node(Box::new(visible_heading)))
.with_child(AriaChild::Node(Box::new(visible_tab)));
let mut dom = DomTree::new(root);
dom.document.document_id = "doc-viewport".to_string();
dom.document.revision = "main:4".to_string();
dom.replace_selectors(vec![
"#offscreen-save".to_string(),
"#visible-tab".to_string(),
]);
dom
}
fn persistent_chrome_dom() -> DomTree {
let mut header_button = AriaNode::new("button", "Header action")
.with_index(0)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
header_button.box_info.persistent_chrome = true;
header_button.box_info.persistent_position = Some("sticky".to_string());
header_button.box_info.persistent_edge = Some("top".to_string());
let local_heading = AriaNode::new("heading", "Local section").with_box(true, None);
let local_button = AriaNode::new("button", "Local action")
.with_index(1)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
let root = AriaNode::fragment()
.with_child(AriaChild::Node(Box::new(header_button)))
.with_child(AriaChild::Node(Box::new(local_heading)))
.with_child(AriaChild::Node(Box::new(local_button)));
let mut dom = DomTree::new(root);
dom.document.document_id = "doc-persistent".to_string();
dom.document.revision = "main:9".to_string();
dom.replace_selectors(vec![
"#header-action".to_string(),
"#local-action".to_string(),
]);
dom
}
fn persistent_chrome_only_dom() -> DomTree {
let mut header_button = AriaNode::new("button", "Header action")
.with_index(0)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
header_button.box_info.persistent_chrome = true;
header_button.box_info.persistent_position = Some("fixed".to_string());
header_button.box_info.persistent_edge = Some("top".to_string());
let mut hidden_content = AriaNode::new("button", "Hidden content")
.with_index(1)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
hidden_content.box_info.in_viewport = false;
let root = AriaNode::fragment()
.with_child(AriaChild::Node(Box::new(header_button)))
.with_child(AriaChild::Node(Box::new(hidden_content)));
let mut dom = DomTree::new(root);
dom.document.document_id = "doc-persistent-only".to_string();
dom.document.revision = "main:2".to_string();
dom.replace_selectors(vec![
"#header-action".to_string(),
"#hidden-content".to_string(),
]);
dom
}
fn persistent_delta_dom(details_visible: bool) -> DomTree {
let mut header_button = AriaNode::new("button", "Header action")
.with_index(0)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
header_button.box_info.persistent_chrome = true;
header_button.box_info.persistent_position = Some("sticky".to_string());
header_button.box_info.persistent_edge = Some("top".to_string());
let local_toggle = AriaNode::new("button", "Show details")
.with_index(1)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string()));
let mut root = AriaNode::fragment()
.with_child(AriaChild::Node(Box::new(header_button)))
.with_child(AriaChild::Node(Box::new(
AriaNode::new("heading", "Local section").with_box(true, None),
)))
.with_child(AriaChild::Node(Box::new(local_toggle)));
if details_visible {
root = root.with_child(AriaChild::Node(Box::new(
AriaNode::new("button", "Details")
.with_index(2)
.with_public_handle(true)
.with_box(true, Some("pointer".to_string())),
)));
}
let mut dom = DomTree::new(root);
dom.document.document_id = "doc-persistent-delta".to_string();
dom.document.revision = if details_visible {
"main:7".to_string()
} else {
"main:6".to_string()
};
dom.replace_selectors(vec![
"#header-action".to_string(),
"#toggle".to_string(),
"#details".to_string(),
]);
dom
}
#[test]
fn tool_result_maps_attach_page_target_loss_to_structured_degraded_failure() {
let error = BrowserError::PageTargetLost(PageTargetLostDetails::attach_degraded(
"snapshot",
"Attached browser session lost its active page target during snapshot.".to_string(),
"Run tab_list, then switch_tab to reacquire an active page target.",
));
let result = tool_result_from_browser_error(error)
.expect("degraded attach failures stay tool-local");
assert!(!result.success);
let data = result
.data
.expect("structured failure data should be present");
assert_eq!(data["code"].as_str(), Some(ATTACH_PAGE_TARGET_LOST_CODE));
assert_eq!(data["details"]["kind"].as_str(), Some("page_target_lost"));
assert_eq!(data["details"]["operation"].as_str(), Some("snapshot"));
assert_eq!(
data["details"]["session_origin"].as_str(),
Some("connected")
);
assert_eq!(
data["recovery"]["suggested_tool"].as_str(),
Some("tab_list")
);
assert!(
data["recovery"]["hint"]
.as_str()
.unwrap_or_default()
.contains("tab_list")
);
}
#[test]
fn tool_result_maps_backend_unsupported_to_structured_failure() {
let error = BrowserError::BackendUnsupported(BackendUnsupportedDetails::new(
"viewport_metrics",
"snapshot",
));
let result = tool_result_from_browser_error(error)
.expect("unsupported backend capabilities stay tool-local");
assert!(!result.success);
let data = result
.data
.expect("structured failure data should be present");
assert_eq!(data["code"].as_str(), Some("backend_unsupported"));
assert_eq!(
data["details"]["capability"].as_str(),
Some("viewport_metrics")
);
assert_eq!(data["details"]["operation"].as_str(), Some("snapshot"));
}
#[test]
fn resolve_target_with_cursor_rebinds_stale_cursor_via_selector() {
let dom = sample_dom();
let mut stale_cursor = dom.cursor_for_index(1).expect("cursor should exist");
stale_cursor.node_ref.revision = "main:0".to_string();
let result =
resolve_target_with_cursor("click", None, None, None, Some(stale_cursor), Some(&dom))
.expect("stale cursor should resolve via selector rebound");
match result {
TargetResolution::Resolved(target) => {
let rebound_cursor = target
.cursor
.as_ref()
.expect("rebound cursor should be present");
assert_eq!(
target.method,
encode_selector_rebound_method("cursor", TargetRecoveredFrom::Cursor)
);
assert_eq!(target.selector, "#submit");
assert_eq!(target.index, Some(1));
assert_eq!(rebound_cursor.selector, "#submit");
assert_eq!(rebound_cursor.node_ref.document_id, "doc-1");
assert_eq!(rebound_cursor.node_ref.revision, "main:1");
assert_eq!(rebound_cursor.node_ref.index, 1);
let envelope = target.to_target_envelope();
assert_eq!(envelope.method, "cursor");
assert_eq!(envelope.resolution_status, "selector_rebound");
assert_eq!(envelope.recovered_from.as_deref(), Some("cursor"));
}
TargetResolution::Failure(failure) => panic!("unexpected failure: {:?}", failure),
}
}
#[test]
fn build_document_envelope_viewport_mode_scopes_snapshot_handles() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = viewport_dom();
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Viewport),
)
.expect("viewport envelope should build");
assert_eq!(
envelope.scope.as_ref().map(|scope| scope.mode),
Some(SnapshotMode::Viewport)
);
assert_eq!(
envelope.scope.as_ref().map(|scope| scope.viewport_biased),
Some(true)
);
assert_eq!(
envelope
.scope
.as_ref()
.and_then(|scope| scope.viewport.clone()),
Some(ViewportMetrics {
width: 800.0,
height: 600.0,
device_pixel_ratio: 2.0,
})
);
assert_eq!(
envelope
.scope
.as_ref()
.and_then(|scope| scope.locality_fallback_reason.as_deref()),
None
);
assert_eq!(envelope.global_interactive_count, Some(2));
assert_eq!(envelope.nodes.len(), 1);
assert_eq!(envelope.nodes[0].name, "Visible tab");
let snapshot = envelope.snapshot.expect("viewport snapshot should render");
assert!(snapshot.contains("Visible story"));
assert!(snapshot.contains("Visible tab"));
assert!(!snapshot.contains("Offscreen save"));
}
#[test]
fn build_document_envelope_full_mode_preserves_exhaustive_snapshot_handles() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = viewport_dom();
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Full),
)
.expect("full envelope should build");
assert_eq!(
envelope.scope.as_ref().map(|scope| scope.mode),
Some(SnapshotMode::Full)
);
assert_eq!(
envelope.scope.as_ref().map(|scope| scope.viewport_biased),
Some(false)
);
assert_eq!(
envelope
.scope
.as_ref()
.and_then(|scope| scope.viewport.clone()),
Some(ViewportMetrics {
width: 800.0,
height: 600.0,
device_pixel_ratio: 2.0,
})
);
assert_eq!(
envelope
.scope
.as_ref()
.and_then(|scope| scope.locality_fallback_reason.as_deref()),
None
);
assert_eq!(envelope.global_interactive_count, Some(2));
assert_eq!(envelope.nodes.len(), 2);
let snapshot = envelope.snapshot.expect("full snapshot should render");
assert!(snapshot.contains("Offscreen save"));
assert!(snapshot.contains("Visible tab"));
}
#[test]
fn build_document_envelope_delta_mode_falls_back_to_viewport_without_base() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = viewport_dom();
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Delta),
)
.expect("delta envelope should build");
let scope = envelope.scope.expect("delta scope should be present");
assert_eq!(scope.mode, SnapshotMode::Delta);
assert_eq!(scope.fallback_mode, Some(SnapshotMode::Viewport));
assert_eq!(scope.locality_fallback_reason.as_deref(), None);
assert_eq!(scope.returned_node_count, envelope.nodes.len());
assert_eq!(envelope.nodes.len(), 1);
assert_eq!(envelope.nodes[0].name, "Visible tab");
}
#[test]
fn build_document_envelope_delta_mode_uses_same_document_prior_revision_base() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = viewport_dom();
session
.store_snapshot_cache(Arc::new(SnapshotCacheEntry {
document: DocumentMetadata {
document_id: "doc-viewport".to_string(),
revision: "main:3".to_string(),
url: "https://viewport.example".to_string(),
title: "Viewport".to_string(),
ready_state: "complete".to_string(),
frames: Vec::new(),
},
snapshot: Arc::<str>::from("- heading \"Visible story\""),
nodes: Arc::<[SnapshotNode]>::from(Vec::new()),
scope: SnapshotCacheScope {
mode: "viewport".to_string(),
fallback_mode: None,
viewport_biased: true,
returned_node_count: 0,
unavailable_frame_count: 0,
global_interactive_count: Some(2),
},
}))
.expect("snapshot cache should seed");
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Delta),
)
.expect("delta envelope should build from prior base");
let scope = envelope.scope.expect("delta scope should be present");
assert_eq!(scope.mode, SnapshotMode::Delta);
assert_eq!(scope.fallback_mode, None);
assert_eq!(scope.locality_fallback_reason.as_deref(), None);
assert_eq!(envelope.nodes.len(), 1);
assert_eq!(envelope.nodes[0].name, "Visible tab");
let snapshot = envelope.snapshot.expect("delta snapshot should render");
assert!(snapshot.contains("Visible tab"));
}
#[test]
fn build_document_envelope_viewport_mode_demotes_persistent_chrome_when_local_anchors_exist() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = persistent_chrome_dom();
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Viewport),
)
.expect("viewport envelope should build");
let scope = envelope.scope.expect("viewport scope should be present");
assert_eq!(scope.mode, SnapshotMode::Viewport);
assert_eq!(scope.locality_fallback_reason.as_deref(), None);
assert_eq!(envelope.nodes.len(), 1);
assert_eq!(envelope.nodes[0].name, "Local action");
let snapshot = envelope.snapshot.expect("viewport snapshot should render");
assert!(snapshot.contains("Local section"));
assert!(snapshot.contains("Local action"));
assert!(!snapshot.contains("Header action"));
}
#[test]
fn build_document_envelope_viewport_mode_reports_persistent_chrome_only_fallback() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let dom = persistent_chrome_only_dom();
let mut context = ToolContext::with_dom(&session, dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Viewport),
)
.expect("viewport envelope should build");
let scope = envelope.scope.expect("viewport scope should be present");
assert_eq!(scope.mode, SnapshotMode::Viewport);
assert_eq!(
scope.locality_fallback_reason.as_deref(),
Some("persistent_chrome_only")
);
assert_eq!(envelope.nodes.len(), 1);
assert_eq!(envelope.nodes[0].name, "Header action");
let snapshot = envelope.snapshot.expect("viewport snapshot should render");
assert!(snapshot.contains("Header action"));
assert!(!snapshot.contains("Hidden content"));
}
#[test]
fn build_document_envelope_delta_mode_keeps_local_changes_ahead_of_persistent_chrome() {
let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
let base_dom = persistent_delta_dom(false);
let base_projection = project_snapshot(SnapshotProjectionInput {
dom: &base_dom,
mode: SnapshotMode::Viewport,
global_interactive_count: Some(base_dom.count_interactive()),
base: None,
});
session
.store_snapshot_cache(Arc::new(snapshot_cache_entry_from_projection(
&base_dom.document,
&base_projection.current,
)))
.expect("snapshot cache should seed");
let current_dom = persistent_delta_dom(true);
let mut context = ToolContext::with_dom(&session, current_dom);
let envelope = build_document_envelope(
&mut context,
None,
DocumentEnvelopeOptions::snapshot(SnapshotMode::Delta),
)
.expect("delta envelope should build");
let scope = envelope.scope.expect("delta scope should be present");
assert_eq!(scope.mode, SnapshotMode::Delta);
assert_eq!(scope.fallback_mode, None);
assert_eq!(scope.locality_fallback_reason.as_deref(), None);
let node_names = envelope
.nodes
.iter()
.map(|node| node.name.as_str())
.collect::<Vec<_>>();
assert!(node_names.contains(&"Details"));
assert!(node_names.contains(&"Show details"));
assert!(!node_names.contains(&"Header action"));
let snapshot = envelope.snapshot.expect("delta snapshot should render");
assert!(snapshot.contains("Details"));
assert!(!snapshot.contains("Header action"));
}
}