Skip to main content

areev_loop/analyzers/
tool_failure.rs

1//! Tool-failure clustering (T0) — the flagship analyzer. Groups error Tool
2//! grains (captured tool calls) by (tool_name, normalized error signature) and
3//! fires when a cluster is frequent (≥ min_count) AND is either a meaningful
4//! share of that signature's OPPORTUNITIES (≥ min_rate) OR a large absolute
5//! count (≥ min_abs) — so high-volume, moderate-rate failures aren't hidden.
6//! Emits a memory lesson. Because the signature is derived from
7//! attacker-influenceable tool output, this analyzer never auto-applies
8//! (§6.3) — its manifest is `Never`.
9//!
10//! **Opportunities, not all calls.** The rate denominator is the tool's
11//! successful calls plus this cluster — NOT every call to the tool. A call
12//! that failed at an earlier check never reached the failure being scored, so
13//! counting it as an opportunity understates every mode. Using all calls made
14//! sibling failure modes mask each other: the more distinct ways a tool broke,
15//! the smaller each mode's share, so the tools failing in the most ways were
16//! the hardest to learn from — backwards. Measured on a real agent trace (150
17//! tasks, 772 calls, 139 failures across 5 modes) the old denominator put
18//! every mode between 9% and 30% and the analyzer proposed nothing at all.
19//!
20//! **The failure cause** (proposal row E3). With a decision backend installed
21//! (`Engine::with_decider`), a cluster whose tool grains carry a cause — the
22//! closed `failure_cause` vocabulary, or free text (a `failure_cause` string
23//! outside it, else `failure_detail`) — names its majority cause
24//! (`tool_failure.cluster_cause`). A known value is used as recorded. Each
25//! distinct free-text string is classified once per run with a `choice` over
26//! the vocabulary; the argmax is used when the backend is CALIBRATED and its
27//! probability reaches `CAUSE_MIN_P` (the probabilities ride on the draft's
28//! `judged_by`), else the string counts as `unknown`. Without a backend — and
29//! for a cluster with no cause signal at all — the draft is exactly as before.
30
31use crate::analyzer::{AnalyzeCtx, Analyzer};
32use crate::decide::{CauseVerdict, JudgedBy, TOOL_CAUSES};
33use crate::analyzers::bound_evidence;
34use crate::cal;
35use crate::error::Result;
36use crate::manifest::*;
37use crate::model::{ActionKind, GrainRecord, Severity};
38use crate::recommendation::{MetricSnapshot, Proposal, RecDraft, Summary};
39use std::collections::BTreeMap;
40
41use serde_json::{json, Map};
42
43pub struct ToolFailureClustering {
44    manifest: AnalyzerManifest,
45}
46
47impl ToolFailureClustering {
48    pub fn new() -> Self {
49        ToolFailureClustering {
50            manifest: AnalyzerManifest {
51                id: "loop.tool_failure/1".into(),
52                title: "Tool-failure clustering".into(),
53                description: "Clusters recurring tool failures into a memory lesson.".into(),
54                tier: Tier::T0,
55                cadence: CadenceClass::Batch,
56                requires: vec![],
57                target_classes: vec![TargetClass::Memory],
58                auto_apply: AutoApplyClass::Never, // evidence-derived free text
59                trust_class: TrustClass::Builtin,
60                params: vec![
61                    ParamSpec::Int {
62                        name: "min_count".into(),
63                        default: 3,
64                        min: 1,
65                        max: 1000,
66                        description: "Minimum failures in a cluster to fire.".into(),
67                    },
68                    ParamSpec::Float {
69                        name: "min_rate".into(),
70                        default: 0.4,
71                        min: 0.0,
72                        max: 1.0,
73                        description: "Minimum share of this signature's opportunities \
74                                      (the tool's successes plus this cluster)."
75                            .into(),
76                    },
77                    ParamSpec::Int {
78                        name: "min_abs".into(),
79                        default: 50,
80                        min: 1,
81                        max: 100_000,
82                        description: "Absolute failure count that fires regardless of rate \
83                                      (so high-volume, moderate-rate failures aren't hidden)."
84                            .into(),
85                    },
86                    ParamSpec::Int {
87                        name: "window_days".into(),
88                        default: 30,
89                        min: 1,
90                        max: 365,
91                        description: "Lookback window.".into(),
92                    },
93                ],
94                default_on: true,
95            },
96        }
97    }
98}
99
100impl Default for ToolFailureClustering {
101    fn default() -> Self {
102        Self::new()
103    }
104}
105
106impl Analyzer for ToolFailureClustering {
107    fn manifest(&self) -> &AnalyzerManifest {
108        &self.manifest
109    }
110
111    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>> {
112        let min_count = ctx.params().get_int("min_count").max(1) as usize;
113        let min_rate = ctx.params().get_float("min_rate");
114        let min_abs = ctx.params().get_int("min_abs").max(1) as usize;
115        let window_ms = ctx.params().get_int("window_days") * 86_400_000;
116        let since = Some(ctx.now_ms() - window_ms);
117
118        let tools = ctx.tools_since(since)?;
119
120        // Per-tool total calls, and per (tool, signature) error clusters —
121        // each member carries its namespace so the lesson can land where the
122        // evidence lives.
123        let mut tool_totals: BTreeMap<String, usize> = BTreeMap::new();
124        let mut tool_errors: BTreeMap<String, usize> = BTreeMap::new();
125        let mut clusters: BTreeMap<(String, String), Vec<Member>> = BTreeMap::new();
126        for e in &tools {
127            let Some(tool) = e.tool_name() else {
128                continue;
129            };
130            *tool_totals.entry(tool.to_string()).or_default() += 1;
131            if e.is_error() {
132                *tool_errors.entry(tool.to_string()).or_default() += 1;
133                let sig = normalize_signature(e.tool_content().unwrap_or(""));
134                clusters
135                    .entry((tool.to_string(), sig))
136                    .or_default()
137                    .push((e.hash.clone(), e.namespace.clone(), cause_signal(e)));
138            }
139        }
140
141        // First pass: which clusters fire (only their causes are worth a
142        // classification call).
143        let mut firing = Vec::new();
144        for ((tool, signature), members) in clusters {
145            // An empty/whitespace-only error body normalizes to "" — a lesson
146            // with object="" would trip the store's non-empty-object validation
147            // (VAL-E001) at apply time, so it can never be applied. Drop the
148            // cluster rather than queue an unappliable recommendation.
149            if signature.is_empty() {
150                continue;
151            }
152            let count = members.len();
153            // Opportunities = this tool's successful calls + this cluster.
154            // Calls that failed some OTHER way never reached this failure, so
155            // they are not opportunities to exhibit it (see the module docs).
156            let calls = tool_totals.get(&tool).copied().unwrap_or(count);
157            let errors = tool_errors.get(&tool).copied().unwrap_or(count);
158            let opportunities = calls.saturating_sub(errors).saturating_add(count).max(1);
159            let rate = count as f64 / opportunities as f64;
160            // Fire on a meaningful cluster that is EITHER a high share of the
161            // tool's calls OR a large absolute count — so a tool called 1000×
162            // at a 30% failure rate (300 real failures) isn't hidden by the
163            // rate gate alone.
164            if count < min_count || (rate < min_rate && count < min_abs) {
165                continue;
166            }
167            firing.push((tool, signature, members, count, rate, opportunities));
168        }
169
170        // E3: classify the free-text causes of the firing clusters, once per
171        // distinct string (the decider caches for the run). No backend, or an
172        // uncalibrated one → every free-text cause stays `unknown`.
173        let texts: Vec<String> = {
174            let mut t: Vec<String> = firing
175                .iter()
176                .flat_map(|f| f.2.iter())
177                .filter_map(|(_, _, sig)| match sig {
178                    CauseSignal::Text(t) => Some(t.clone()),
179                    _ => None,
180                })
181                .collect();
182            t.sort();
183            t.dedup();
184            t
185        };
186        let classified = match (ctx.decider(), texts.is_empty()) {
187            (Some(d), false) => d.classify_causes(&texts),
188            _ => BTreeMap::new(),
189        };
190
191        let mut drafts = Vec::new();
192        for (tool, signature, mut members, count, rate, opportunities) in firing {
193            members.sort_by(|a, b| a.0.cmp(&b.0));
194            // No backend → no cause on the draft: the no-decider path stays
195            // byte-for-byte what it was.
196            let cause = ctx.decider().and_then(|_| cluster_cause(&members, &classified));
197            let evidence = bound_evidence(members.iter().map(|(h, _, _)| h.clone()).collect());
198            let rate_pct = (rate * 100.0).round() as i64;
199
200            let mut args = Map::new();
201            args.insert("tool".into(), json!(tool));
202            args.insert("count".into(), json!(count));
203            args.insert("rate".into(), json!(rate_pct));
204            args.insert("signature".into(), json!(signature));
205            let template = match &cause {
206                Some((c, _)) => {
207                    args.insert("cause".into(), json!(c));
208                    "tool_failure.cluster_cause"
209                }
210                None => "tool_failure.cluster",
211            };
212
213            // Proposed lesson: a fact recording the recurring failure. It
214            // lands in the DOMINANT namespace of the evidence tool calls (an
215            // ADD without a namespace would fall to the store default and be
216            // invisible to the ns-scoped recall the agent actually runs);
217            // the `entity:lessons/…` target_ref stays a stable grouping
218            // label, deliberately independent of where the grain lives.
219            let mut ns_counts: BTreeMap<&str, usize> = BTreeMap::new();
220            for (_, ns, _) in &members {
221                if !ns.is_empty() {
222                    *ns_counts.entry(ns.as_str()).or_default() += 1;
223                }
224            }
225            // Max count; ties break to the lexicographically smallest
226            // namespace — deterministic on any host.
227            let lesson_ns = ns_counts
228                .iter()
229                .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
230                .map(|(ns, _)| ns.to_string());
231            let mut lesson = Map::new();
232            lesson.insert("subject".into(), json!(tool));
233            lesson.insert("relation".into(), json!("fails_with"));
234            lesson.insert("object".into(), json!(signature));
235            lesson.insert("confidence".into(), json!(rate));
236            if let Some(ns) = &lesson_ns {
237                lesson.insert("namespace".into(), json!(ns));
238            }
239
240            let severity = if rate >= 0.7 && count >= 5 {
241                Severity::High
242            } else if rate >= 0.5 {
243                Severity::Medium
244            } else {
245                Severity::Low
246            };
247
248            let draft = RecDraft::new(
249                format!("entity:lessons/{tool}"),
250                ActionKind::ClusterFailure,
251                Summary::new(template, args),
252                Proposal::Cal {
253                    cal: cal::add("fact", &lesson),
254                },
255            )
256            .severity(severity)
257            .evidence(evidence)
258            .confidence(rate)
259            .metric(MetricSnapshot {
260                // After the lesson is applied, does this exact tool failure
261                // recur? Baseline 0 = we expect zero recurrences if the
262                // lesson worked; any recurrence is a regression → revert.
263                metric: "tool_error_recurrence".into(),
264                baseline: 0.0,
265                unit: "count".into(),
266                // Sample size is the set the rate was computed over — this
267                // signature's opportunities, not every call to the tool.
268                n: opportunities as u64,
269                window: format!("{}d", ctx.params().get_int("window_days")),
270                subject: Some(tool.clone()),
271                namespace: None,
272                // The metric is scoped to THIS failure signature, not the
273                // whole tool — `relation` carries it so measure_metric can
274                // count only recurrences of the same signature. Without it a
275                // later, unrelated failure of the same tool reads as a
276                // regression and reverts a still-valid lesson.
277                relation: Some(signature.clone()),
278                query: format!(
279                    "RECALL tools WHERE tool_name = \"{tool}\" AND is_error AND signature = \"{signature}\" SINCE <applied_at> | COUNT"
280                ),
281                review_after_ms: 86_400_000,
282                // Re-measure at 1 day, 1 week, 1 month — a late recurrence
283                // (held at 1d, regressed at 30d) is caught by the schedule.
284                horizons_ms: vec![86_400_000, 7 * 86_400_000, 30 * 86_400_000],
285                checkpoints: Vec::new(),
286                // A count of recurrences: fewer is better.
287                higher_is_better: false,
288            });
289            drafts.push(match cause.and_then(|(_, j)| j) {
290                Some(j) => draft.judged_by(j),
291                None => draft,
292            });
293        }
294        drafts.sort_by(|a, b| a.target_ref.cmp(&b.target_ref));
295        Ok(drafts)
296    }
297}
298
299/// One error grain in a cluster: (hash, namespace, what it says about why).
300type Member = (String, String, CauseSignal);
301
302/// What one error tool grain says about why it failed.
303#[derive(Debug, Clone, PartialEq)]
304enum CauseSignal {
305    /// A value of the closed vocabulary, as recorded.
306    Known(String),
307    /// Free text: a `failure_cause` outside the vocabulary, else
308    /// `failure_detail`.
309    Text(String),
310    /// Nothing recorded.
311    None,
312}
313
314fn cause_signal(g: &GrainRecord) -> CauseSignal {
315    let known = |s: &str| TOOL_CAUSES.iter().any(|(k, _)| *k == s);
316    match g.str_field("failure_cause").map(str::trim).filter(|s| !s.is_empty()) {
317        Some(c) if known(c) => CauseSignal::Known(c.to_string()),
318        Some(c) => CauseSignal::Text(c.to_string()),
319        None => match g.str_field("failure_detail").map(str::trim).filter(|s| !s.is_empty()) {
320            Some(d) => CauseSignal::Text(d.to_string()),
321            None => CauseSignal::None,
322        },
323    }
324}
325
326/// The cluster's majority cause (ties → the lexicographically smallest), with
327/// the decision that classified it when the winner came from one. `None` when
328/// no member carries any cause signal — the draft then renders as before.
329fn cluster_cause(
330    members: &[Member],
331    classified: &BTreeMap<String, CauseVerdict>,
332) -> Option<(String, Option<JudgedBy>)> {
333    let mut votes: BTreeMap<String, usize> = BTreeMap::new();
334    let mut judged: BTreeMap<String, JudgedBy> = BTreeMap::new();
335    for (_, _, sig) in members {
336        let cause = match sig {
337            CauseSignal::Known(k) => k.clone(),
338            CauseSignal::Text(t) => match classified.get(t).cloned().flatten() {
339                Some((c, _, j)) => {
340                    judged.entry(c.clone()).or_insert(j);
341                    c
342                }
343                None => "unknown".to_string(),
344            },
345            CauseSignal::None => continue,
346        };
347        *votes.entry(cause).or_default() += 1;
348    }
349    let (winner, _) = votes
350        .iter()
351        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))?;
352    Some((winner.clone(), judged.remove(winner)))
353}
354
355/// Normalize an error message into a stable signature: lowercase, first ~80
356/// chars, digit runs → `#`, path-like tokens → `<path>`. Regex-free (std only).
357/// `pub(crate)` so the Verify gate (`measure_metric`) can re-derive the same
358/// signature when scoping a `tool_error_recurrence` re-measurement.
359pub(crate) fn normalize_signature(content: &str) -> String {
360    let lowered: String = content.trim().to_lowercase().chars().take(80).collect();
361    let mut out = String::with_capacity(lowered.len());
362    for token in lowered.split_whitespace() {
363        if !out.is_empty() {
364            out.push(' ');
365        }
366        if token.contains('/') || token.contains('\\') {
367            out.push_str("<path>");
368        } else {
369            let mut prev_digit = false;
370            for c in token.chars() {
371                if c.is_ascii_digit() {
372                    if !prev_digit {
373                        out.push('#');
374                    }
375                    prev_digit = true;
376                } else {
377                    out.push(c);
378                    prev_digit = false;
379                }
380            }
381        }
382    }
383    out
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::testkit::TestSubstrate;
390
391    #[test]
392    fn signature_strips_digits_and_paths() {
393        assert_eq!(
394            normalize_signature("Rate limited after 4295 ms"),
395            "rate limited after # ms"
396        );
397        assert_eq!(
398            normalize_signature("open /etc/passwd failed"),
399            "open <path> failed"
400        );
401    }
402
403    #[test]
404    fn fires_on_frequent_and_dominant_cluster() {
405        let mut sub = TestSubstrate::new();
406        for _ in 0..5 {
407            sub.add_tool_call("stripe_refund", true, "rate_limited 429");
408        }
409        sub.add_tool_call("stripe_refund", false, "ok");
410        let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
411        assert_eq!(drafts.len(), 1);
412        assert_eq!(drafts[0].action_kind, ActionKind::ClusterFailure);
413        assert_eq!(drafts[0].evidence.len(), 5);
414    }
415
416    #[test]
417    fn fires_on_high_volume_moderate_rate_via_absolute_count() {
418        let mut sub = TestSubstrate::new();
419        // 10 identical failures out of 40 calls = 25% (below the 40% rate) —
420        // but 10 ≥ min_abs, so it must still fire.
421        for _ in 0..10 {
422            sub.add_tool_call("search", true, "boom 500");
423        }
424        for _ in 0..30 {
425            sub.add_tool_call("search", false, "ok");
426        }
427        let drafts = sub.analyze_with(&ToolFailureClustering::new(), 10_000, &[("min_abs", serde_json::json!(10))]);
428        assert_eq!(drafts.len(), 1, "high-volume moderate-rate cluster fires via min_abs");
429    }
430
431    #[test]
432    fn sibling_failure_modes_do_not_mask_each_other() {
433        // The shape a real agent trace produced: one tool, several distinct
434        // failure modes, none of them a large absolute count. Scored against
435        // ALL calls every mode sat under the rate gate and the analyzer went
436        // silent on 93 real failures; scored against each mode's own
437        // opportunities (successes + that mode) the dominant one fires.
438        let mut sub = TestSubstrate::new();
439        for _ in 0..46 {
440            sub.add_tool_call("refund", true, "rate_limited 429");
441        }
442        for _ in 0..33 {
443            sub.add_tool_call("refund", true, "approval_required");
444        }
445        for _ in 0..14 {
446            sub.add_tool_call("refund", true, "cancel_before_refund");
447        }
448        for _ in 0..59 {
449            sub.add_tool_call("refund", false, "ok");
450        }
451        let drafts = sub.analyze(&ToolFailureClustering::new(), 10_000);
452        // 46 / (59 + 46) = 43.8% clears the 40% gate; the smaller siblings
453        // (33/92 = 36%, 14/73 = 19%) stay below it and stay silent.
454        assert_eq!(drafts.len(), 1, "the dominant mode fires, its siblings do not");
455        assert_eq!(drafts[0].evidence.len(), 46);
456    }
457
458    #[test]
459    fn a_mode_that_is_a_small_share_of_its_own_opportunities_stays_silent() {
460        // The denominator change must not turn every recurring error into a
461        // lesson: 5 failures against 95 successes is 5%, still silent.
462        let mut sub = TestSubstrate::new();
463        for _ in 0..5 {
464            sub.add_tool_call("search", true, "boom 500");
465        }
466        for _ in 0..95 {
467            sub.add_tool_call("search", false, "ok");
468        }
469        assert!(sub.analyze(&ToolFailureClustering::new(), 10_000).is_empty());
470    }
471
472    #[test]
473    fn silent_below_rate_threshold() {
474        let mut sub = TestSubstrate::new();
475        sub.add_tool_call("search", true, "boom 500");
476        sub.add_tool_call("search", true, "boom 500");
477        for _ in 0..20 {
478            sub.add_tool_call("search", false, "ok");
479        }
480        // 2 errors of 22 calls = 9% < 40%.
481        assert!(sub
482            .analyze(&ToolFailureClustering::new(), 10_000)
483            .is_empty());
484    }
485}