Skip to main content

heartbit_core/browser/
harness.rs

1//! Browser reliability decorators over raw MCP tools (spec capability 4).
2//!
3//! `chrome-devtools-mcp`'s interaction tools (`click`, `fill`, …) have two
4//! hazards the research flagged as the #1 operational failure modes:
5//!
6//! 1. They default `includeSnapshot:false`, so after acting the model is blind
7//!    to the resulting page unless it remembers to ask. We inject
8//!    `includeSnapshot:true` so every mutating action returns a fresh
9//!    observation.
10//! 2. `uid` handles are snapshot-scoped. Acting on a `uid` from a stale
11//!    snapshot fails with a "no element with uid" error. We retry once after
12//!    forcing a re-observation (the wrapper surfaces a structured hint so the
13//!    loop re-grounds), then escalate.
14//!
15//! [`ReliableInteractionTool`] is an `Arc<dyn Tool>` decorator: it wraps any
16//! inner tool, so it composes with the MCP-stamped tools from
17//! [`connect_preset`](crate::connect_preset) without the MCP layer knowing.
18//! This mirrors the existing `find_closest_tool` tool-name-repair pattern.
19
20use 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
28/// chrome-devtools-mcp interaction tools that MUTATE the page and therefore both
29/// (a) benefit from a forced fresh snapshot and (b) are subject to stale-uid
30/// failures. Navigation/read tools are intentionally excluded.
31pub(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
42/// Heuristic: does this tool error indicate the target `uid` was not found in
43/// the current snapshot (i.e. the agent acted on a stale handle)?
44pub(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    // chrome-devtools-mcp phrasings + defensive variants.
50    (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
56/// Wraps an interaction tool to (1) force `includeSnapshot:true` and (2) retry
57/// once on a stale-uid error.
58pub struct ReliableInteractionTool {
59    inner: Arc<dyn Tool>,
60    /// Whether the wrapped tool is in [`MUTATING_TOOLS`] (decided at construction).
61    mutating: bool,
62}
63
64impl ReliableInteractionTool {
65    /// Wrap `inner`. Non-mutating tools pass through unchanged (no snapshot
66    /// injection, no retry) so this is safe to apply uniformly across a tool set.
67    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    /// Wrap every tool in a set; the result is a drop-in replacement for an
73    /// agent's `base_tools`/`tools`.
74    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    /// Add `includeSnapshot:true` to a mutating tool's input object.
82    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        // Own the ctx inside the future so it borrows only `&self` (the trait's
105        // elided `'_`), not the separate `ctx` parameter borrow — otherwise the
106        // borrowed `ctx` escapes the method (E0521). ExecutionContext is Clone.
107        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                // Stale handle: the snapshot the agent grounded on is gone.
112                // Retry once; the forced includeSnapshot on this retry returns a
113                // fresh tree so the loop can re-ground. Append a structured hint.
114                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    /// A mock tool that records each input it receives and returns scripted
136    /// outputs in order (cycling on the last).
137    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}