Skip to main content

gglib_core/domain/agent/loop_detection/
observation.rs

1//! Whether a tool-call batch is read-only.
2//!
3//! Separated from the detector because it answers a different question. The
4//! detector counts repeats; this decides which allowance a repeat is held to.
5//! Keeping them apart also leaves `mod.rs` room to change.
6
7use crate::ToolCall;
8
9// =============================================================================
10// Observation-batch classifier
11// =============================================================================
12
13/// Return `true` if **every** call in `calls` is an observation-only tool.
14///
15/// A tool call is classified as observation-only when its lowercased name
16/// contains at least one pattern in `patterns` as a substring.  Matching is
17/// case-insensitive (both sides are lowercased before comparison).
18///
19/// A substring captures more than it names, which matters for a hand-written
20/// list, since a user's `observation_tools` replaces the shipped one whole: a
21/// pattern `read` also matches `thread_create` and `spreadsheet_update`, and
22/// tools that change state get the read-only allowance. The shipped
23/// coding-agent patterns are long enough that everything they capture is
24/// read-only (see `AgentConfig::observation_tools`); the browser ones are
25/// not, and `snapshot` also matches `delete_snapshot`. Whole-segment
26/// matching, the rule `is_costly_batch` uses, would stop `list_dir` covering
27/// `list_directory` and `click` covering `get_clickable_elements`, and
28/// over-capturing here is the cheaper error.
29///
30/// An empty `patterns` list means no tools are ever classified as
31/// observation-only, so the function always returns `false`.
32///
33/// An empty `calls` slice returns `true` (vacuous truth), but the caller
34/// ([`crate::domain::agent::LoopDetector::check`]) is never invoked with an empty batch — both the
35/// agent loop and the proxy's history scan skip loop detection when there are
36/// no tool calls. That is now load bearing rather than merely tidy: an empty
37/// batch would hash to a signature of its own and break the consecutive run.
38pub fn is_observation_batch(calls: &[ToolCall], patterns: &[String]) -> bool {
39    if patterns.is_empty() {
40        return false;
41    }
42    calls.iter().all(|call| {
43        let name = call.name.to_lowercase();
44        patterns.iter().any(|pat| {
45            // Lowercased here, not at the call sites: patterns are user
46            // supplied, and one carrying a capital could never match a name
47            // already lowered. That read as case-sensitive; it was a no-op.
48            let pat = pat.to_lowercase();
49            name.contains(&pat)
50        })
51    })
52}
53
54// =============================================================================
55// Costly-observation classifier
56// =============================================================================
57
58/// Shipped observation entries that are read-only *here* but not free to repeat.
59///
60/// `navigate` changes where the browser session is, `click` changes page state,
61/// and `fetch_webpage` spends someone else's rate limit. All three earn their
62/// place in the default `observation_tools` list — a browser agent recovering
63/// from a redirect, or a coding agent re-reading docs mid-task, is ordinary
64/// work. What they cannot have is the *waiver*: the read-only exemption drops
65/// the `total` bound entirely, and a fetched page's content essentially always
66/// moves, so for these three the exemption is the normal case rather than the
67/// exception and the repeat becomes unbounded.
68///
69/// Kept in the classifier's list and excluded from the waiver, rather than
70/// dropped from the list: dropping them would hold a redirect-recovering
71/// browser agent to `max_repeated_batch_steps` instead of
72/// `max_observation_steps`, which is the regression
73/// `test_navigate_tool_uses_elevated_threshold_by_default` exists to catch.
74const COSTLY_OBSERVATION_TOOLS: &[&str] = &["navigate", "click", "fetch_webpage"];
75
76/// Return `true` if **any** call in `calls` costs something to repeat.
77///
78/// Read against the batch's own tool names rather than against the active
79/// `observation_tools` list, so a user-supplied list naming one of these is
80/// bounded too. The harm is a property of the tool, not of who listed it.
81///
82/// # Why this does *not* reuse [`is_observation_batch`]'s rule
83///
84/// The two lists need **opposite** safety properties, so they cannot share a
85/// matcher. Over-capturing as observation is *permissive* — it grants a larger
86/// allowance — which is why that list can afford `contains` and only has to
87/// ensure every captured name is itself read-only. Over-capturing here is
88/// *restrictive*: it aborts a session. So this rule leans the other way —
89/// it prefers to miss a costly tool over refusing a free one.
90///
91/// Unanchored `contains` cannot: `click` captures `get_clickable_elements` and
92/// `clickhouse_query`, both genuinely read-only, and both would be refused at
93/// the 16th call. Matching is therefore anchored to whole `_`/`-`/`.`-delimited
94/// segments, which admits `browser_navigate`, `mcp__playwright__click` and
95/// `click_element_by_index` while leaving `clickhouse_query` alone. Residue
96/// remains — a bare `navigate` segment still catches an LSP-style
97/// `navigate_to_definition` — and it is pinned by test rather than hidden: the
98/// cost there is a ceiling on repeats, not a refusal of honest work.
99///
100/// # Two limits, both deliberate
101///
102/// **Residue.** Anchoring is not exact matching. A bare `navigate` or `click`
103/// segment still catches read-only names built from the same word —
104/// `navigate_to_definition`, `get_click_count`, `ad_click_report`,
105/// `click_house_query`. Those are bounded at 15 rather than refused outright,
106/// which is the pre-#928 behaviour and the cheaper of the two errors.
107///
108/// **camelCase is missed.** `browserNavigate` and `clickElement` carry no
109/// separator, so they split to one segment and are *not* recognised as costly
110/// — while [`is_observation_batch`]'s `contains` still classifies them, which
111/// leaves them exempt and unbounded. Splitting on case boundaries would close
112/// that, and would newly capture `clickHouseQuery` — the camelCase spelling of
113/// the exact tool the anchoring exists to protect. Given the asymmetry above,
114/// missing a bound is the better error than aborting a read-only session, so
115/// the gap stays and is pinned by test rather than left to be rediscovered.
116/// Narrowing it properly needs the two classifiers to share one anchored rule,
117/// which is a change to `observation_tools`' matching and out of scope here.
118///
119/// `any`, not `all`: one costly call in the batch is enough to spend the thing
120/// that must not be spent without bound.
121pub(crate) fn is_costly_batch(calls: &[ToolCall]) -> bool {
122    calls.iter().any(|call| {
123        let name = call.name.to_lowercase();
124        let segments: Vec<&str> = split_segments(&name);
125        COSTLY_OBSERVATION_TOOLS
126            .iter()
127            .any(|pat| covers_whole_segments(&segments, pat))
128    })
129}
130
131/// Split a tool name on the separators clients build compound names from.
132fn split_segments(name: &str) -> Vec<&str> {
133    name.split(['_', '-', '.'])
134        .filter(|s| !s.is_empty())
135        .collect()
136}
137
138/// Whether `pat` occupies a whole run of `segments`.
139///
140/// `pat` is split the same way, so a two-word pattern like `fetch_webpage` has
141/// to match two consecutive segments rather than appearing inside one.
142fn covers_whole_segments(segments: &[&str], pat: &str) -> bool {
143    let wanted = split_segments(pat);
144    if wanted.is_empty() {
145        return false;
146    }
147    segments
148        .windows(wanted.len())
149        .any(|w| w == wanted.as_slice())
150}
151
152#[cfg(test)]
153#[path = "observation_tests.rs"]
154mod tests;