use std::sync::Arc;
use serde_json::Value;
use crate::error::Error;
use crate::tool::{Tool, ToolOutput};
use crate::{ExecutionContext, llm::types::ToolDefinition};
pub(crate) const MUTATING_TOOLS: &[&str] = &[
"click",
"fill",
"fill_form",
"hover",
"drag",
"type_text",
"press_key",
"upload_file",
];
pub(crate) fn is_stale_uid_error(output: &ToolOutput) -> bool {
if !output.is_error {
return false;
}
let c = output.content.to_lowercase();
(c.contains("uid") && (c.contains("no element") || c.contains("not found")))
|| c.contains("no element with uid")
|| c.contains("stale")
|| (c.contains("element") && c.contains("no longer"))
}
pub struct ReliableInteractionTool {
inner: Arc<dyn Tool>,
mutating: bool,
}
impl ReliableInteractionTool {
pub fn wrap(inner: Arc<dyn Tool>) -> Self {
let mutating = MUTATING_TOOLS.contains(&inner.definition().name.as_str());
Self { inner, mutating }
}
pub fn wrap_all(tools: Vec<Arc<dyn Tool>>) -> Vec<Arc<dyn Tool>> {
tools
.into_iter()
.map(|t| Arc::new(Self::wrap(t)) as Arc<dyn Tool>)
.collect()
}
fn with_snapshot(&self, mut input: Value) -> Value {
if self.mutating
&& let Value::Object(ref mut map) = input
{
map.insert("includeSnapshot".to_string(), Value::Bool(true));
}
input
}
}
impl Tool for ReliableInteractionTool {
fn definition(&self) -> ToolDefinition {
self.inner.definition()
}
fn execute(
&self,
ctx: &ExecutionContext,
input: Value,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
{
let input = self.with_snapshot(input);
let ctx = ctx.clone();
Box::pin(async move {
let first = self.inner.execute(&ctx, input.clone()).await?;
if self.mutating && is_stale_uid_error(&first) {
let retried = self.inner.execute(&ctx, input).await?;
if retried.is_error {
return Ok(ToolOutput::error(format!(
"{}\n\n[browser] stale uid: re-snapshot and re-resolve the \
target element from the latest snapshot, then retry.",
retried.content
)));
}
return Ok(retried);
}
Ok(first)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
struct ScriptedTool {
name: String,
calls: Arc<AtomicUsize>,
inputs: Arc<Mutex<Vec<Value>>>,
outputs: Vec<ToolOutput>,
}
impl ScriptedTool {
fn new(
name: &str,
outputs: Vec<ToolOutput>,
) -> (Arc<Self>, Arc<AtomicUsize>, Arc<Mutex<Vec<Value>>>) {
let calls = Arc::new(AtomicUsize::new(0));
let inputs = Arc::new(Mutex::new(Vec::new()));
let t = Arc::new(Self {
name: name.to_string(),
calls: Arc::clone(&calls),
inputs: Arc::clone(&inputs),
outputs,
});
(t, calls, inputs)
}
}
impl Tool for ScriptedTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: self.name.clone(),
description: "scripted".into(),
input_schema: serde_json::json!({ "type": "object" }),
}
}
fn execute(
&self,
_ctx: &ExecutionContext,
input: Value,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
self.inputs.lock().expect("inputs lock").push(input);
let idx = n.min(self.outputs.len() - 1);
let out = self.outputs[idx].clone();
Box::pin(async move { Ok(out) })
}
}
#[tokio::test]
async fn injects_include_snapshot_for_mutating_tool() {
let (inner, calls, inputs) = ScriptedTool::new("click", vec![ToolOutput::success("ok")]);
let tool = ReliableInteractionTool::wrap(inner);
tool.execute(
&ExecutionContext::default(),
serde_json::json!({ "uid": "1_2" }),
)
.await
.expect("ok");
assert_eq!(calls.load(Ordering::SeqCst), 1);
let recorded = &inputs.lock().expect("lock")[0];
assert_eq!(
recorded["includeSnapshot"],
Value::Bool(true),
"mutating tool must get includeSnapshot:true, got {recorded}"
);
assert_eq!(recorded["uid"], "1_2", "original args preserved");
}
#[tokio::test]
async fn does_not_inject_for_non_mutating_tool() {
let (inner, _calls, inputs) =
ScriptedTool::new("take_snapshot", vec![ToolOutput::success("tree")]);
let tool = ReliableInteractionTool::wrap(inner);
tool.execute(&ExecutionContext::default(), serde_json::json!({}))
.await
.expect("ok");
let recorded = &inputs.lock().expect("lock")[0];
assert!(
recorded.get("includeSnapshot").is_none(),
"non-mutating tool must not be modified, got {recorded}"
);
}
#[tokio::test]
async fn retries_once_on_stale_uid_then_succeeds() {
let (inner, calls, _inputs) = ScriptedTool::new(
"click",
vec![
ToolOutput::error("Error: no element with uid 1_2"),
ToolOutput::success("clicked"),
],
);
let tool = ReliableInteractionTool::wrap(inner);
let out = tool
.execute(
&ExecutionContext::default(),
serde_json::json!({ "uid": "1_2" }),
)
.await
.expect("ok");
assert_eq!(calls.load(Ordering::SeqCst), 2, "must retry exactly once");
assert!(!out.is_error, "second attempt succeeded");
assert_eq!(out.content, "clicked");
}
#[tokio::test]
async fn stale_uid_twice_escalates_with_hint() {
let (inner, calls, _inputs) =
ScriptedTool::new("click", vec![ToolOutput::error("no element with uid 1_2")]);
let tool = ReliableInteractionTool::wrap(inner);
let out = tool
.execute(
&ExecutionContext::default(),
serde_json::json!({ "uid": "1_2" }),
)
.await
.expect("ok");
assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then give up");
assert!(out.is_error);
assert!(
out.content.contains("re-snapshot"),
"escalation must hint re-grounding, got {}",
out.content
);
}
#[tokio::test]
async fn non_stale_error_is_not_retried() {
let (inner, calls, _inputs) =
ScriptedTool::new("click", vec![ToolOutput::error("Error: network timeout")]);
let tool = ReliableInteractionTool::wrap(inner);
let out = tool
.execute(
&ExecutionContext::default(),
serde_json::json!({ "uid": "1_2" }),
)
.await
.expect("ok");
assert_eq!(
calls.load(Ordering::SeqCst),
1,
"non-stale error must not retry"
);
assert!(out.is_error);
}
#[test]
fn is_stale_uid_error_matches_phrasings() {
assert!(is_stale_uid_error(&ToolOutput::error(
"no element with uid 1_2"
)));
assert!(is_stale_uid_error(&ToolOutput::error(
"Error: uid 3_7 not found in snapshot"
)));
assert!(!is_stale_uid_error(&ToolOutput::error("network timeout")));
assert!(!is_stale_uid_error(&ToolOutput::success(
"no element with uid (but success?)"
)));
}
#[test]
fn wrap_all_preserves_count_and_names() {
let (a, _, _) = ScriptedTool::new("click", vec![ToolOutput::success("x")]);
let (b, _, _) = ScriptedTool::new("take_snapshot", vec![ToolOutput::success("y")]);
let wrapped = ReliableInteractionTool::wrap_all(vec![a, b]);
assert_eq!(wrapped.len(), 2);
assert_eq!(wrapped[0].definition().name, "click");
assert_eq!(wrapped[1].definition().name, "take_snapshot");
}
}