Skip to main content

heartbit_core/browser/
distill.rs

1//! Snapshot distiller (capability 2 of the browser-bot spec).
2//!
3//! `chrome-devtools-mcp`'s `take_snapshot` returns an indented accessibility
4//! tree, one node per line:
5//!
6//! ```text
7//! uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
8//!   uid=1_1 link "Hacker News" url="https://news.ycombinator.com/news"
9//!   uid=1_9 StaticText "Hacker News"
10//! ```
11//!
12//! The raw tree is close to optimal but carries redundant nodes — most
13//! commonly a `StaticText` whose text merely echoes the accessible name of the
14//! interactive element (`link`/`button`) it sits under. AgentOccam (ICLR'25)
15//! showed that pruning the observation alone is the single highest-leverage
16//! reliability win for a web agent. This distiller removes that redundancy
17//! while **preserving every `uid`** — the agent's only handle for acting.
18//!
19//! It is a pure `&str -> String` transform: no browser, no MCP, no LLM, so it
20//! is fully unit-testable in CI.
21
22/// Tuning for [`distill_snapshot`].
23#[derive(Debug, Clone)]
24pub struct DistillConfig {
25    /// Drop a `StaticText` line whose quoted text duplicates the accessible
26    /// name of the interactive element immediately above it at a shallower
27    /// indent (the most common redundancy in the a11y tree).
28    pub drop_redundant_static_text: bool,
29    /// Drop nodes with no `uid` and no quoted name (pure structural noise).
30    pub drop_empty_nodes: bool,
31}
32
33impl Default for DistillConfig {
34    fn default() -> Self {
35        Self {
36            drop_redundant_static_text: true,
37            drop_empty_nodes: true,
38        }
39    }
40}
41
42/// A parsed view of one snapshot line.
43struct Node<'a> {
44    raw: &'a str,
45    indent: usize,
46    /// `uid=N_M` token, if present.
47    uid: Option<&'a str>,
48    /// Accessibility role (`link`, `StaticText`, `heading`, `RootWebArea`, …).
49    role: Option<&'a str>,
50    /// The first quoted string (the accessible name), without quotes.
51    name: Option<&'a str>,
52}
53
54fn parse_line(line: &str) -> Node<'_> {
55    let indent = line.len() - line.trim_start().len();
56    let body = line.trim_start();
57
58    let (uid, after_uid) = match body.strip_prefix("uid=") {
59        Some(rest) => {
60            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
61            (Some(&rest[..end]), rest[end..].trim_start())
62        }
63        None => (None, body),
64    };
65
66    // Role is the first token after the uid (and before the first quote).
67    let role = after_uid
68        .split([' ', '"'])
69        .find(|t| !t.is_empty())
70        .filter(|_| !after_uid.starts_with('"'));
71
72    // Accessible name is the first double-quoted span.
73    let name = after_uid.find('"').and_then(|start| {
74        let rest = &after_uid[start + 1..];
75        rest.find('"').map(|end| &rest[..end])
76    });
77
78    Node {
79        raw: line,
80        indent,
81        uid,
82        role,
83        name,
84    }
85}
86
87/// Roles an agent can actually act on (`click`/`fill`/`hover`/`select`/upload).
88/// Their `uid`s are load-bearing and must never be dropped.
89fn is_interactive(role: Option<&str>) -> bool {
90    matches!(
91        role,
92        Some(
93            "link"
94                | "button"
95                | "textbox"
96                | "checkbox"
97                | "radio"
98                | "combobox"
99                | "menuitem"
100                | "tab"
101                | "switch"
102                | "slider"
103                | "searchbox"
104                | "option"
105        )
106    )
107}
108
109/// Distill a raw `take_snapshot` accessibility tree into a compact form.
110/// Returns the pruned tree as text.
111///
112/// Guarantees:
113/// - **Every *interactive* `uid` survives** (links/buttons/inputs/etc. — the
114///   only handles `click`/`fill`/`hover` can target). Non-interactive
115///   `StaticText` `uid`s, which an agent never acts on, may be pruned.
116/// - Output is never larger than the input.
117/// - Idempotent: distilling a distilled snapshot is a no-op.
118pub fn distill_snapshot(raw: &str, cfg: &DistillConfig) -> String {
119    let nodes: Vec<Node> = raw.lines().map(parse_line).collect();
120    let mut keep: Vec<bool> = vec![true; nodes.len()];
121
122    for i in 0..nodes.len() {
123        let n = &nodes[i];
124
125        // Drop a redundant StaticText whose text merely echoes the accessible
126        // name of the nearest shallower interactive node (the most common a11y
127        // redundancy). Safe even when the StaticText carries a uid, because
128        // StaticText is NON-interactive — no tool ever targets that uid.
129        if cfg.drop_redundant_static_text
130            && n.role == Some("StaticText")
131            && !is_interactive(n.role)
132            && let Some(text) = n.name
133            && let Some(parent) = (0..i)
134                .rev()
135                .map(|j| &nodes[j])
136                .find(|p| p.indent < n.indent)
137            && parent.name == Some(text)
138            && is_interactive(parent.role)
139        {
140            keep[i] = false;
141            continue;
142        }
143
144        // Drop pure structural noise: a blank/whitespace-only line.
145        if cfg.drop_empty_nodes && n.uid.is_none() && n.raw.trim().is_empty() {
146            keep[i] = false;
147        }
148    }
149
150    let mut out = String::with_capacity(raw.len());
151    for (i, n) in nodes.iter().enumerate() {
152        if keep[i] {
153            out.push_str(n.raw);
154            out.push('\n');
155        }
156    }
157    // Match input's trailing-newline convention.
158    if !raw.ends_with('\n') {
159        out.pop();
160    }
161    out
162}
163
164/// Extract every *interactive* `uid` (the handles an agent can act on).
165pub fn interactive_uids(snapshot: &str) -> Vec<String> {
166    snapshot
167        .lines()
168        .filter_map(|l| {
169            let n = parse_line(l);
170            match (n.uid, is_interactive(n.role)) {
171                (Some(u), true) => Some(u.to_string()),
172                _ => None,
173            }
174        })
175        .collect()
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    /// A realistic fixture in the exact format chrome-devtools-mcp emits
183    /// (verified live against Hacker News this session): an indented a11y tree,
184    /// `uid=N_M <Role> "name" [url=...]` per line. The HN nav is the canonical
185    /// redundancy — every `link "x"` is followed by a child `StaticText "x"`
186    /// echoing its name (live capture: 475 StaticText vs 226 link).
187    const FIXTURE: &str = r#"uid=1_0 RootWebArea "Hacker News" url="https://news.ycombinator.com/"
188  uid=1_2 link "Hacker News" url="https://news.ycombinator.com/news"
189    uid=1_3 StaticText "Hacker News"
190  uid=1_4 link "new" url="https://news.ycombinator.com/newest"
191    uid=1_5 StaticText "new"
192  uid=1_6 StaticText " | "
193  uid=1_30 textbox "Search"
194  uid=1_31 StaticText "Plain caption with no interactive parent""#;
195
196    #[test]
197    fn preserves_every_interactive_uid() {
198        let before = interactive_uids(FIXTURE);
199        let out = distill_snapshot(FIXTURE, &DistillConfig::default());
200        let after = interactive_uids(&out);
201        assert_eq!(
202            before, after,
203            "distillation must preserve every interactive uid; before={before:?} after={after:?}"
204        );
205        // Concretely: the links and the textbox survive.
206        assert_eq!(before, vec!["1_2", "1_4", "1_30"]);
207    }
208
209    #[test]
210    fn strictly_shrinks_by_dropping_echoed_static_text() {
211        let out = distill_snapshot(FIXTURE, &DistillConfig::default());
212        assert!(
213            out.len() < FIXTURE.len(),
214            "distilled output ({}) must be strictly smaller than input ({})",
215            out.len(),
216            FIXTURE.len()
217        );
218        // The redundant child StaticText echoing each link's name is gone...
219        assert!(!out.contains(r#"uid=1_3 StaticText "Hacker News""#));
220        assert!(!out.contains(r#"uid=1_5 StaticText "new""#));
221        // ...but the interactive parents (with their names) remain.
222        assert!(out.contains(r#"uid=1_2 link "Hacker News""#));
223        assert!(out.contains(r#"uid=1_4 link "new""#));
224    }
225
226    #[test]
227    fn keeps_non_echoing_static_text_and_inputs() {
228        let out = distill_snapshot(FIXTURE, &DistillConfig::default());
229        // A separator and a standalone caption are NOT echoes of a parent → kept.
230        assert!(out.contains(r#"uid=1_6 StaticText " | ""#));
231        assert!(out.contains("Plain caption with no interactive parent"));
232        // The textbox (interactive) and its accessible name are kept.
233        assert!(out.contains(r#"uid=1_30 textbox "Search""#));
234        // The link urls are retained (the agent may need them).
235        assert!(out.contains("https://news.ycombinator.com/news"));
236    }
237
238    #[test]
239    fn is_idempotent() {
240        let cfg = DistillConfig::default();
241        let once = distill_snapshot(FIXTURE, &cfg);
242        let twice = distill_snapshot(&once, &cfg);
243        assert_eq!(once, twice, "distillation must be idempotent");
244    }
245
246    #[test]
247    fn parse_line_extracts_uid_role_name() {
248        let n = parse_line(r#"  uid=1_2 link "Hacker News" url="https://x""#);
249        assert_eq!(n.uid, Some("1_2"));
250        assert_eq!(n.role, Some("link"));
251        assert_eq!(n.name, Some("Hacker News"));
252        assert_eq!(n.indent, 2);
253    }
254
255    #[test]
256    fn distill_disabled_is_identity() {
257        let cfg = DistillConfig {
258            drop_redundant_static_text: false,
259            drop_empty_nodes: false,
260        };
261        assert_eq!(distill_snapshot(FIXTURE, &cfg), FIXTURE);
262    }
263
264    #[test]
265    fn interactive_uids_excludes_static_text_and_root() {
266        // Only link/textbox/etc — never StaticText, RootWebArea, or separators.
267        assert_eq!(interactive_uids(FIXTURE), vec!["1_2", "1_4", "1_30"]);
268    }
269}