1use std::sync::Arc;
21
22use serde_json::Value;
23
24use crate::error::Error;
25use crate::tool::{Tool, ToolOutput};
26use crate::{ExecutionContext, llm::types::ToolDefinition};
27
28pub(crate) const MUTATING_TOOLS: &[&str] = &[
32 "click",
33 "fill",
34 "fill_form",
35 "hover",
36 "drag",
37 "type_text",
38 "press_key",
39 "upload_file",
40];
41
42pub(crate) fn is_stale_uid_error(output: &ToolOutput) -> bool {
45 if !output.is_error {
46 return false;
47 }
48 let c = output.content.to_lowercase();
49 (c.contains("uid") && (c.contains("no element") || c.contains("not found")))
51 || c.contains("no element with uid")
52 || c.contains("stale")
53 || (c.contains("element") && c.contains("no longer"))
54}
55
56pub struct ReliableInteractionTool {
59 inner: Arc<dyn Tool>,
60 mutating: bool,
62}
63
64impl ReliableInteractionTool {
65 pub fn wrap(inner: Arc<dyn Tool>) -> Self {
68 let mutating = MUTATING_TOOLS.contains(&inner.definition().name.as_str());
69 Self { inner, mutating }
70 }
71
72 pub fn wrap_all(tools: Vec<Arc<dyn Tool>>) -> Vec<Arc<dyn Tool>> {
75 tools
76 .into_iter()
77 .map(|t| Arc::new(Self::wrap(t)) as Arc<dyn Tool>)
78 .collect()
79 }
80
81 fn with_snapshot(&self, mut input: Value) -> Value {
83 if self.mutating
84 && let Value::Object(ref mut map) = input
85 {
86 map.insert("includeSnapshot".to_string(), Value::Bool(true));
87 }
88 input
89 }
90}
91
92impl Tool for ReliableInteractionTool {
93 fn definition(&self) -> ToolDefinition {
94 self.inner.definition()
95 }
96
97 fn execute(
98 &self,
99 ctx: &ExecutionContext,
100 input: Value,
101 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>>
102 {
103 let input = self.with_snapshot(input);
104 let ctx = ctx.clone();
108 Box::pin(async move {
109 let first = self.inner.execute(&ctx, input.clone()).await?;
110 if self.mutating && is_stale_uid_error(&first) {
111 let retried = self.inner.execute(&ctx, input).await?;
115 if retried.is_error {
116 return Ok(ToolOutput::error(format!(
117 "{}\n\n[browser] stale uid: re-snapshot and re-resolve the \
118 target element from the latest snapshot, then retry.",
119 retried.content
120 )));
121 }
122 return Ok(retried);
123 }
124 Ok(first)
125 })
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132 use std::sync::Mutex;
133 use std::sync::atomic::{AtomicUsize, Ordering};
134
135 struct ScriptedTool {
138 name: String,
139 calls: Arc<AtomicUsize>,
140 inputs: Arc<Mutex<Vec<Value>>>,
141 outputs: Vec<ToolOutput>,
142 }
143
144 impl ScriptedTool {
145 fn new(
146 name: &str,
147 outputs: Vec<ToolOutput>,
148 ) -> (Arc<Self>, Arc<AtomicUsize>, Arc<Mutex<Vec<Value>>>) {
149 let calls = Arc::new(AtomicUsize::new(0));
150 let inputs = Arc::new(Mutex::new(Vec::new()));
151 let t = Arc::new(Self {
152 name: name.to_string(),
153 calls: Arc::clone(&calls),
154 inputs: Arc::clone(&inputs),
155 outputs,
156 });
157 (t, calls, inputs)
158 }
159 }
160
161 impl Tool for ScriptedTool {
162 fn definition(&self) -> ToolDefinition {
163 ToolDefinition {
164 name: self.name.clone(),
165 description: "scripted".into(),
166 input_schema: serde_json::json!({ "type": "object" }),
167 }
168 }
169 fn execute(
170 &self,
171 _ctx: &ExecutionContext,
172 input: Value,
173 ) -> std::pin::Pin<
174 Box<dyn std::future::Future<Output = Result<ToolOutput, Error>> + Send + '_>,
175 > {
176 let n = self.calls.fetch_add(1, Ordering::SeqCst);
177 self.inputs.lock().expect("inputs lock").push(input);
178 let idx = n.min(self.outputs.len() - 1);
179 let out = self.outputs[idx].clone();
180 Box::pin(async move { Ok(out) })
181 }
182 }
183
184 #[tokio::test]
185 async fn injects_include_snapshot_for_mutating_tool() {
186 let (inner, calls, inputs) = ScriptedTool::new("click", vec![ToolOutput::success("ok")]);
187 let tool = ReliableInteractionTool::wrap(inner);
188 tool.execute(
189 &ExecutionContext::default(),
190 serde_json::json!({ "uid": "1_2" }),
191 )
192 .await
193 .expect("ok");
194 assert_eq!(calls.load(Ordering::SeqCst), 1);
195 let recorded = &inputs.lock().expect("lock")[0];
196 assert_eq!(
197 recorded["includeSnapshot"],
198 Value::Bool(true),
199 "mutating tool must get includeSnapshot:true, got {recorded}"
200 );
201 assert_eq!(recorded["uid"], "1_2", "original args preserved");
202 }
203
204 #[tokio::test]
205 async fn does_not_inject_for_non_mutating_tool() {
206 let (inner, _calls, inputs) =
207 ScriptedTool::new("take_snapshot", vec![ToolOutput::success("tree")]);
208 let tool = ReliableInteractionTool::wrap(inner);
209 tool.execute(&ExecutionContext::default(), serde_json::json!({}))
210 .await
211 .expect("ok");
212 let recorded = &inputs.lock().expect("lock")[0];
213 assert!(
214 recorded.get("includeSnapshot").is_none(),
215 "non-mutating tool must not be modified, got {recorded}"
216 );
217 }
218
219 #[tokio::test]
220 async fn retries_once_on_stale_uid_then_succeeds() {
221 let (inner, calls, _inputs) = ScriptedTool::new(
222 "click",
223 vec![
224 ToolOutput::error("Error: no element with uid 1_2"),
225 ToolOutput::success("clicked"),
226 ],
227 );
228 let tool = ReliableInteractionTool::wrap(inner);
229 let out = tool
230 .execute(
231 &ExecutionContext::default(),
232 serde_json::json!({ "uid": "1_2" }),
233 )
234 .await
235 .expect("ok");
236 assert_eq!(calls.load(Ordering::SeqCst), 2, "must retry exactly once");
237 assert!(!out.is_error, "second attempt succeeded");
238 assert_eq!(out.content, "clicked");
239 }
240
241 #[tokio::test]
242 async fn stale_uid_twice_escalates_with_hint() {
243 let (inner, calls, _inputs) =
244 ScriptedTool::new("click", vec![ToolOutput::error("no element with uid 1_2")]);
245 let tool = ReliableInteractionTool::wrap(inner);
246 let out = tool
247 .execute(
248 &ExecutionContext::default(),
249 serde_json::json!({ "uid": "1_2" }),
250 )
251 .await
252 .expect("ok");
253 assert_eq!(calls.load(Ordering::SeqCst), 2, "one retry then give up");
254 assert!(out.is_error);
255 assert!(
256 out.content.contains("re-snapshot"),
257 "escalation must hint re-grounding, got {}",
258 out.content
259 );
260 }
261
262 #[tokio::test]
263 async fn non_stale_error_is_not_retried() {
264 let (inner, calls, _inputs) =
265 ScriptedTool::new("click", vec![ToolOutput::error("Error: network timeout")]);
266 let tool = ReliableInteractionTool::wrap(inner);
267 let out = tool
268 .execute(
269 &ExecutionContext::default(),
270 serde_json::json!({ "uid": "1_2" }),
271 )
272 .await
273 .expect("ok");
274 assert_eq!(
275 calls.load(Ordering::SeqCst),
276 1,
277 "non-stale error must not retry"
278 );
279 assert!(out.is_error);
280 }
281
282 #[test]
283 fn is_stale_uid_error_matches_phrasings() {
284 assert!(is_stale_uid_error(&ToolOutput::error(
285 "no element with uid 1_2"
286 )));
287 assert!(is_stale_uid_error(&ToolOutput::error(
288 "Error: uid 3_7 not found in snapshot"
289 )));
290 assert!(!is_stale_uid_error(&ToolOutput::error("network timeout")));
291 assert!(!is_stale_uid_error(&ToolOutput::success(
292 "no element with uid (but success?)"
293 )));
294 }
295
296 #[test]
297 fn wrap_all_preserves_count_and_names() {
298 let (a, _, _) = ScriptedTool::new("click", vec![ToolOutput::success("x")]);
299 let (b, _, _) = ScriptedTool::new("take_snapshot", vec![ToolOutput::success("y")]);
300 let wrapped = ReliableInteractionTool::wrap_all(vec![a, b]);
301 assert_eq!(wrapped.len(), 2);
302 assert_eq!(wrapped[0].definition().name, "click");
303 assert_eq!(wrapped[1].definition().name, "take_snapshot");
304 }
305}