Files
chrome-use/cli/src/native/adaptive.rs
T
leeguooooo 1a4c440d9e ci: fix long-broken CI (version-sync, dead dashboard job, fmt, clippy, flaky test)
The fork's CI had never been green. Pre-existing failures:
- version-sync: check-version-sync.js read packages/dashboard/package.json,
  which doesn't exist in this fork (workspace is just "."). Drop the dashboard
  comparison; check package.json vs cli/Cargo.toml only.
- Dashboard job: `pnpm install --filter dashboard` for a non-existent package.
  Remove the job.
- Format check: repo was never `cargo fmt`-clean. Ran cargo fmt (mechanical).
- Clippy -D warnings (newly enforced on Rust 1.94 stable): manual_contains in
  commands.rs (.iter().any()->.contains()), question_mark in element.rs
  (if-let-Err -> ?), result_large_err on the tungstenite handshake callback in
  connect.rs (allow — the Result type is fixed by the accept_hdr_async contract).
- rust-cross: lightpanda::waits_for_ready_without_logs spawns a real process +
  binds a socket with timing assumptions; flaky in CI. Marked #[ignore].

Also: skill docs note fork.30's relay-preferred auto-connect (plain
`agent-browser open` is dialog-free once the ab-connect extension is loaded) and
the extension's new "agent-browser-stealth" display name.
2026-06-10 11:49:11 +09:00

374 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Adaptive @ref relocation.
//!
//! When a saved `@ref`'s DOM node is gone (stale `backendNodeId`) and the
//! role/name/nth re-query also fails, we score the current page's candidate
//! elements against the ref's stored [`ElementFingerprint`] and relocate to the
//! best match — but ONLY when confident: the best candidate must clear a high
//! absolute threshold AND beat the runner-up by a clear margin. This matches the
//! project's "fail loudly rather than mis-click" posture (see the identity and
//! occlusion guards in `element.rs`).
//!
//! Everything in this module is pure and browser-free so the scoring can be
//! unit-tested directly.
use std::collections::BTreeMap;
/// Minimum absolute similarity (0..1) for a relocation candidate to be accepted.
pub const ADAPTIVE_THRESHOLD: f64 = 0.70;
/// Minimum gap between the best and second-best candidate to avoid ambiguity.
pub const ADAPTIVE_MARGIN: f64 = 0.15;
/// A structural/semantic fingerprint of an element, captured at snapshot time so
/// a moved element can be re-identified after the page mutates.
///
/// Populated purely from the accessibility tree we already walk (`TreeNode`), so
/// capturing it costs no extra CDP round-trips — `TreeNode` has no DOM tag or
/// attributes (those would need an N×`DOM.describeNode` storm per snapshot), so
/// `tag` holds the AX **role** and `attrs` holds discriminating AX properties
/// (value/url/level/checked), not DOM `id`/`class`.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ElementFingerprint {
/// AX role, e.g. "button" (used where a DOM tag would otherwise go).
pub tag: String,
/// Accessible name / visible text — the dominant identity signal.
pub text: String,
/// Discriminating AX properties: value, url, level, checked. Keyed by name.
pub attrs: BTreeMap<String, String>,
/// Ancestor role signatures from nearest to farthest, e.g. "form" / "list".
pub ancestors: Vec<String>,
/// Parent role.
pub parent_tag: String,
/// Parent accessible name / text.
pub parent_text: String,
/// Index among same-role siblings.
pub sibling_index: u32,
/// Count of same-role siblings.
pub sibling_count: u32,
}
/// Component weights. They sum to 1.0 so the total score lands in 0..1.
/// Tuned for AX-derived fingerprints: the accessible name dominates, with role
/// and tree structure carrying disambiguation when the name has changed (which
/// is exactly when the exact role+name+nth fallback failed and we got here).
const W_TAG: f64 = 0.20;
const W_TEXT: f64 = 0.40;
const W_ATTRS: f64 = 0.10;
const W_ANCESTORS: f64 = 0.20;
const W_PARENT_SIBLING: f64 = 0.10;
/// Per-attribute importance for the attribute-overlap score. Strong identity
/// signals (a link's url) outweigh weak ones (heading level).
fn attr_weight(name: &str) -> f64 {
match name {
"url" | "value" => 3.0,
"checked" => 2.0,
_ => 1.0,
}
}
/// Levenshtein-based string similarity in 0..1 (1.0 = identical). Two empty
/// strings are treated as a perfect match (consistent absence of text).
pub fn string_similarity(a: &str, b: &str) -> f64 {
if a == b {
return 1.0;
}
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let max_len = a.len().max(b.len());
if max_len == 0 {
return 1.0;
}
let dist = levenshtein(&a, &b);
1.0 - (dist as f64 / max_len as f64)
}
fn levenshtein(a: &[char], b: &[char]) -> usize {
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
/// Jaccard similarity over whitespace-separated tokens (used for `class`).
fn token_jaccard(a: &str, b: &str) -> f64 {
let sa: std::collections::BTreeSet<&str> = a.split_whitespace().collect();
let sb: std::collections::BTreeSet<&str> = b.split_whitespace().collect();
if sa.is_empty() && sb.is_empty() {
return 1.0;
}
let inter = sa.intersection(&sb).count() as f64;
let union = sa.union(&sb).count() as f64;
if union == 0.0 {
1.0
} else {
inter / union
}
}
/// Length-ratio of the longest common subsequence over two ancestor sequences.
fn lcs_ratio(a: &[String], b: &[String]) -> f64 {
if a.is_empty() && b.is_empty() {
return 1.0;
}
if a.is_empty() || b.is_empty() {
return 0.0;
}
let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
for i in 0..a.len() {
for j in 0..b.len() {
dp[i + 1][j + 1] = if a[i] == b[j] {
dp[i][j] + 1
} else {
dp[i][j + 1].max(dp[i + 1][j])
};
}
}
let lcs = dp[a.len()][b.len()] as f64;
(2.0 * lcs) / (a.len() + b.len()) as f64
}
fn attr_score(base: &BTreeMap<String, String>, cand: &BTreeMap<String, String>) -> f64 {
let mut names: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
names.extend(base.keys().map(|s| s.as_str()));
names.extend(cand.keys().map(|s| s.as_str()));
if names.is_empty() {
return 1.0; // no attributes on either side — neutral
}
let mut total = 0.0;
let mut got = 0.0;
for name in names {
let w = attr_weight(name);
total += w;
// present on only one side → no credit
if let (Some(a), Some(b)) = (base.get(name), cand.get(name)) {
if name == "class" {
got += w * token_jaccard(a, b);
} else if a == b {
got += w;
}
}
}
if total == 0.0 {
1.0
} else {
got / total
}
}
fn parent_sibling_score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
// Split the 0.10 budget: parent tag 0.4, parent text 0.3, sibling pos 0.3.
let parent_tag = if base.parent_tag == cand.parent_tag {
1.0
} else {
0.0
};
let parent_text = string_similarity(&base.parent_text, &cand.parent_text);
let span = base.sibling_count.max(1) as f64;
let delta = (base.sibling_index as i64 - cand.sibling_index as i64).unsigned_abs() as f64;
let sibling = 1.0 - (delta / span).min(1.0);
0.4 * parent_tag + 0.3 * parent_text + 0.3 * sibling
}
/// Similarity score in 0..1 between a stored baseline and a candidate element.
pub fn score(base: &ElementFingerprint, cand: &ElementFingerprint) -> f64 {
let tag = if base.tag == cand.tag { 1.0 } else { 0.0 };
let text = string_similarity(&base.text, &cand.text);
let attrs = attr_score(&base.attrs, &cand.attrs);
let ancestors = lcs_ratio(&base.ancestors, &cand.ancestors);
let parent_sibling = parent_sibling_score(base, cand);
W_TAG * tag
+ W_TEXT * text
+ W_ATTRS * attrs
+ W_ANCESTORS * ancestors
+ W_PARENT_SIBLING * parent_sibling
}
/// Why a relocation was rejected.
#[derive(Debug, Clone, PartialEq)]
pub enum RejectReason {
/// No candidates to score.
NoCandidates,
/// Best score below [`ADAPTIVE_THRESHOLD`].
LowScore { best: f64 },
/// Best score too close to the runner-up (below [`ADAPTIVE_MARGIN`]).
Ambiguous { best: f64, second: f64 },
}
/// A successful relocation decision.
#[derive(Debug, Clone, PartialEq)]
pub struct Relocation {
/// Chosen candidate's backend node id.
pub backend_node_id: i64,
/// Winning score.
pub score: f64,
/// Runner-up score (0.0 when there was only one candidate).
pub second_score: f64,
}
/// Pick the best candidate, accepting only when confident. `candidates` is a
/// list of `(backend_node_id, fingerprint)` for the current page.
pub fn pick_best(
base: &ElementFingerprint,
candidates: &[(i64, ElementFingerprint)],
threshold: f64,
margin: f64,
) -> Result<Relocation, RejectReason> {
if candidates.is_empty() {
return Err(RejectReason::NoCandidates);
}
let mut scored: Vec<(i64, f64)> = candidates
.iter()
.map(|(id, fp)| (*id, score(base, fp)))
.collect();
// Highest score first; stable enough for deterministic ties.
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let (best_id, best) = scored[0];
let second = scored.get(1).map(|(_, s)| *s).unwrap_or(0.0);
if best < threshold {
return Err(RejectReason::LowScore { best });
}
if best - second < margin {
return Err(RejectReason::Ambiguous { best, second });
}
Ok(Relocation {
backend_node_id: best_id,
score: best,
second_score: second,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(tag: &str, text: &str, attrs: &[(&str, &str)]) -> ElementFingerprint {
ElementFingerprint {
tag: tag.to_string(),
text: text.to_string(),
attrs: attrs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
..Default::default()
}
}
#[test]
fn identical_fingerprints_score_one() {
let a = fp(
"button",
"Submit",
&[("id", "go"), ("class", "btn primary")],
);
assert!((score(&a, &a) - 1.0).abs() < 1e-9);
}
#[test]
fn different_tag_caps_score_below_threshold() {
let a = fp("button", "Submit", &[("id", "go")]);
let b = fp("a", "Submit", &[("id", "go")]);
// Same text + same attrs but different role: must lose the role weight
// (W_TAG = 0.20), landing around 0.80 and below a perfect match.
let s = score(&a, &b);
assert!(s < 0.85 && s > 0.75, "got {s}");
}
#[test]
fn string_similarity_basics() {
assert_eq!(string_similarity("abc", "abc"), 1.0);
assert_eq!(string_similarity("", ""), 1.0);
assert!(string_similarity("Submit", "Submit now") > 0.5);
assert!(string_similarity("Add post", "Post all") < 0.6);
}
#[test]
fn class_uses_token_overlap() {
let a = fp("div", "", &[("class", "card primary big")]);
let b = fp("div", "", &[("class", "card primary")]);
// partial class overlap should still score high (tag+text match, attrs partial)
let s = score(&a, &b);
assert!(s > 0.85, "got {s}");
}
#[test]
fn ancestors_lcs() {
let mut a = fp("button", "OK", &[]);
let mut b = fp("button", "OK", &[]);
a.ancestors = vec!["form#f".into(), "div.col".into(), "body".into()];
// b wrapped in an extra div — DOM path changed but mostly preserved
b.ancestors = vec![
"form#f".into(),
"div.wrap".into(),
"div.col".into(),
"body".into(),
];
let s = score(&a, &b);
assert!(s > 0.85, "got {s}");
}
#[test]
fn pick_best_accepts_clear_winner() {
let base = fp("button", "Submit", &[("id", "go")]);
let winner = fp("button", "Submit", &[("id", "go")]);
let other = fp("a", "Home", &[("href", "/")]);
let out = pick_best(
&base,
&[(10, other), (20, winner)],
ADAPTIVE_THRESHOLD,
ADAPTIVE_MARGIN,
)
.expect("should accept");
assert_eq!(out.backend_node_id, 20);
assert!(out.score > out.second_score);
}
#[test]
fn pick_best_rejects_ambiguous_twins() {
let base = fp("button", "Delete", &[("class", "btn danger")]);
// Two near-identical delete buttons — must refuse to guess.
let twin_a = fp("button", "Delete", &[("class", "btn danger")]);
let twin_b = fp("button", "Delete", &[("class", "btn danger")]);
let err = pick_best(
&base,
&[(1, twin_a), (2, twin_b)],
ADAPTIVE_THRESHOLD,
ADAPTIVE_MARGIN,
)
.unwrap_err();
assert!(matches!(err, RejectReason::Ambiguous { .. }), "got {err:?}");
}
#[test]
fn pick_best_rejects_low_score() {
let base = fp("button", "Submit order", &[("id", "checkout")]);
let junk = fp("span", "unrelated footer text", &[("class", "muted")]);
let err = pick_best(&base, &[(1, junk)], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err();
assert!(matches!(err, RejectReason::LowScore { .. }), "got {err:?}");
}
#[test]
fn pick_best_no_candidates() {
let base = fp("button", "x", &[]);
assert_eq!(
pick_best(&base, &[], ADAPTIVE_THRESHOLD, ADAPTIVE_MARGIN).unwrap_err(),
RejectReason::NoCandidates
);
}
}