Skip to main content

agent_engine/extensions/
feedback.rs

1//! Bounded, process-local feedback for one caller-owned foreground turn.
2//!
3//! The caller must filter ownership, call `begin_turn` before observing, and call
4//! `finish` only after success. Session/history, agent, and partial tool events
5//! are never inspected. Starting again abandons any unfinished observation.
6//!
7//! This is a byte-fingerprint heuristic, not proof of progress or equality:
8//! `DefaultHasher` collisions can produce false repeats, and its fingerprints
9//! are neither cryptographic nor stable across Rust versions. Only bounded
10//! model/effort metadata and private hash state survive calls; no response/input
11//! content is retained, logged, exported, or read from storage.
12
13use std::collections::hash_map::DefaultHasher;
14use std::hash::Hasher;
15use std::io::{self, Write};
16
17use serde_json::Value;
18use crate::{LlmEvent, StreamEvent};
19
20const RECENT_SIGNATURES: usize = 4;
21const MAX_BYTES: usize = 1024 * 1024;
22const MAX_TOOL_EVENTS: usize = 1024;
23const MAX_JSON_NODES: usize = 16 * 1024;
24const MAX_JSON_DEPTH: usize = 64;
25const MAX_SCOPE_BYTES: usize = 4096;
26
27#[derive(Default)]
28pub(crate) struct Tracker {
29    // Exact, bounded metadata: do not normalize or fingerprint the scope key.
30    scope: Option<(Box<str>, Box<str>)>,
31    recent: [Option<u64>; RECENT_SIGNATURES],
32    next: usize,
33    current: Option<Turn>,
34}
35
36impl Tracker {
37    /// Begin an owned turn, discarding any unfinished (failed/interrupted) turn.
38    /// Changing either exact scope string clears the comparison window, even
39    /// when the new turn is subsequently abandoned. Oversized metadata is unknown.
40    pub(crate) fn begin_turn(&mut self, model: &str, effort: &str) {
41        self.current = None;
42        if model.len() > MAX_SCOPE_BYTES || effort.len() > MAX_SCOPE_BYTES - model.len() {
43            self.scope = None;
44            self.clear_recent();
45            self.current = Some(Turn {
46                unknown: true,
47                ..Turn::default()
48            });
49            return;
50        }
51
52        let same_scope = self
53            .scope
54            .as_ref()
55            .is_some_and(|(m, e)| m.as_ref() == model && e.as_ref() == effort);
56        if !same_scope {
57            self.clear_recent();
58            self.scope = Some((model.into(), effort.into()));
59        }
60        self.current = Some(Turn::default());
61    }
62
63    /// Observe only final LLM output; everything outside an active turn is ignored.
64    pub(crate) fn observe(&mut self, event: &StreamEvent) {
65        let Some(turn) = self.current.as_mut() else {
66            return;
67        };
68        if turn.unknown {
69            return;
70        }
71        if let StreamEvent::Llm(event) = event {
72            if turn.observe(event).is_none() {
73                // Never compare a truncated prefix, nor recover within this turn.
74                turn.unknown = true;
75            }
76        }
77    }
78
79    /// Commit a successful turn once. Repeated calls without `begin_turn` return
80    /// `unknown` and cannot insert another signature. Empty/unknown turns do not
81    /// enter or clear the ring; their non-repeated labels break a caller's streak.
82    pub(crate) fn finish(&mut self) -> &'static str {
83        let Some(mut turn) = self.current.take() else {
84            return "unknown";
85        };
86        if turn.unknown {
87            return "unknown";
88        }
89
90        let signature = if turn.tool_events != 0 {
91            // Any final tool event, including a standalone result, is meaningful.
92            // Accompanying assistant prose is excluded from this signature.
93            turn.tools.write_u64(turn.tool_events as u64);
94            turn.tools.finish()
95        } else if turn.meaningful_text {
96            // One length for the entire byte stream, never one per chunk.
97            turn.text.write_u64(turn.text_bytes as u64);
98            turn.text.finish()
99        } else {
100            return "empty";
101        };
102
103        let repeated = self.recent.contains(&Some(signature));
104        self.recent[self.next] = Some(signature);
105        self.next = (self.next + 1) % RECENT_SIGNATURES;
106        if repeated {
107            "repeated"
108        } else {
109            "changed"
110        }
111    }
112
113    fn clear_recent(&mut self) {
114        self.recent = [None; RECENT_SIGNATURES];
115        self.next = 0;
116    }
117}
118
119#[derive(Clone)]
120struct Turn {
121    text: DefaultHasher,
122    tools: DefaultHasher,
123    text_bytes: usize,
124    meaningful_text: bool,
125    tool_events: usize,
126    bytes_left: usize,
127    json_nodes_left: usize,
128    unknown: bool,
129    response_baseline: Option<Box<Turn>>,
130}
131
132impl Default for Turn {
133    fn default() -> Self {
134        let mut text = DefaultHasher::new();
135        let mut tools = DefaultHasher::new();
136        // Domain separation for text-only and tool-based signatures.
137        text.write_u8(1);
138        tools.write_u8(2);
139        Self {
140            text,
141            tools,
142            text_bytes: 0,
143            meaningful_text: false,
144            tool_events: 0,
145            bytes_left: MAX_BYTES,
146            json_nodes_left: MAX_JSON_NODES,
147            unknown: false,
148            response_baseline: None,
149        }
150    }
151}
152
153impl Turn {
154    fn observe(&mut self, event: &LlmEvent) -> Option<()> {
155        match event {
156            LlmEvent::ResponseStart => {
157                self.response_baseline = None;
158                self.response_baseline = Some(Box::new(self.clone()));
159            }
160            LlmEvent::ResponseReset => {
161                if let Some(baseline) = self.response_baseline.take() {
162                    *self = *baseline;
163                }
164            }
165            LlmEvent::Text(text) => {
166                // Count all final text against the budget, even when tools make
167                // it irrelevant to equality. Ignored event types cost no budget.
168                reserve(&mut self.bytes_left, text.len())?;
169                self.text_bytes += text.len();
170                self.meaningful_text |= !text.trim().is_empty();
171                // DefaultHasher's incremental byte writes concatenate chunks;
172                // Hash::hash(str) would instead frame each chunk separately.
173                self.text.write(text.as_bytes());
174            }
175            LlmEvent::ToolUse {
176                tool_name, input, ..
177            } => {
178                self.tool_event()?;
179                self.tools.write_u8(1); // Call: framed name + framed JSON digest.
180                self.field(tool_name)?;
181
182                // Check borrowed shape/lengths BEFORE sorting or serializing.
183                // This bounds recursion, allocation, string scanning, and total
184                // JSON traversal across the turn, not just the bytes written.
185                let mut lower_bound_left = self.bytes_left;
186                validate_json(input, 0, &mut lower_bound_left, &mut self.json_nodes_left)?;
187                let mut writer = HashWriter::new(self.bytes_left);
188                write_canonical_json(&mut writer, input).ok()?;
189                reserve(&mut self.bytes_left, writer.written)?;
190                self.tools.write_u64(writer.written as u64);
191                self.tools.write_u64(writer.hash.finish());
192            }
193            LlmEvent::ToolResult { result, .. } => {
194                self.tool_event()?;
195                self.tools.write_u8(2); // Result, in observed order; no ID lookup.
196                self.field(result)?;
197            }
198            LlmEvent::Thinking(_)
199            | LlmEvent::ToolUseStart { .. }
200            | LlmEvent::ToolUseDelta { .. }
201            | LlmEvent::ToolResultDelta { .. } => {}
202        }
203        Some(())
204    }
205
206    fn tool_event(&mut self) -> Option<()> {
207        if self.tool_events == MAX_TOOL_EVENTS {
208            return None;
209        }
210        self.tool_events += 1;
211        Some(())
212    }
213
214    fn field(&mut self, value: &str) -> Option<()> {
215        reserve(&mut self.bytes_left, value.len())?;
216        self.tools.write_u64(value.len() as u64);
217        self.tools.write(value.as_bytes());
218        Some(())
219    }
220}
221
222fn reserve(remaining: &mut usize, amount: usize) -> Option<()> {
223    *remaining = remaining.checked_sub(amount)?;
224    Some(())
225}
226
227/// A cheap lower bound on encoded bytes, plus strict node/depth bounds. No clone
228/// of JSON or allocation proportional to unchecked input size. A node requires
229/// at least one encoded byte; raw string/key lengths are further lower bounds.
230fn validate_json(
231    value: &Value,
232    depth: usize,
233    bytes_left: &mut usize,
234    nodes_left: &mut usize,
235) -> Option<()> {
236    if depth > MAX_JSON_DEPTH {
237        return None;
238    }
239    reserve(nodes_left, 1)?;
240    reserve(bytes_left, 1)?;
241    match value {
242        Value::String(text) => reserve(bytes_left, text.len())?,
243        Value::Array(values) => {
244            if values.len() > *nodes_left {
245                return None;
246            }
247            for value in values {
248                validate_json(value, depth + 1, bytes_left, nodes_left)?;
249            }
250        }
251        Value::Object(values) => {
252            if values.len() > *nodes_left {
253                return None;
254            }
255            for (key, value) in values {
256                reserve(bytes_left, key.len())?;
257                validate_json(value, depth + 1, bytes_left, nodes_left)?;
258            }
259        }
260        Value::Null | Value::Bool(_) | Value::Number(_) => {}
261    }
262    Some(())
263}
264
265/// In-memory, all-or-error writer: an overflow never yields a usable prefix.
266/// Actual serialized bytes (including escaping) consume the remaining budget.
267struct HashWriter {
268    hash: DefaultHasher,
269    left: usize,
270    written: usize,
271}
272
273impl HashWriter {
274    fn new(left: usize) -> Self {
275        Self {
276            hash: DefaultHasher::new(),
277            left,
278            written: 0,
279        }
280    }
281}
282
283impl Write for HashWriter {
284    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
285        reserve(&mut self.left, bytes.len())
286            .ok_or_else(|| io::Error::other("feedback byte budget exceeded"))?;
287        self.hash.write(bytes);
288        self.written += bytes.len();
289        Ok(bytes.len())
290    }
291
292    fn flush(&mut self) -> io::Result<()> {
293        Ok(())
294    }
295}
296
297/// Compact canonical JSON: recursively sorted object keys, ordered arrays,
298/// serde string escaping and serde_json's number spelling (not numeric semantic
299/// normalization). Call ONLY after validate_json. Sorting holds borrowed entries
300/// bounded by MAX_JSON_NODES across all active recursion frames, never raw copies.
301fn write_canonical_json(writer: &mut HashWriter, value: &Value) -> io::Result<()> {
302    match value {
303        Value::Null => writer.write_all(b"null"),
304        Value::Bool(true) => writer.write_all(b"true"),
305        Value::Bool(false) => writer.write_all(b"false"),
306        // Display is serde_json's JSON number representation and writes directly
307        // to our budget. Unlike to_string(), it cannot allocate a huge temporary
308        // if another dependency enables serde_json's arbitrary_precision feature.
309        Value::Number(number) => write!(writer, "{number}"),
310        Value::String(text) => write_json_string(writer, text),
311        Value::Array(values) => {
312            writer.write_all(b"[")?;
313            for (index, value) in values.iter().enumerate() {
314                if index != 0 {
315                    writer.write_all(b",")?;
316                }
317                write_canonical_json(writer, value)?;
318            }
319            writer.write_all(b"]")
320        }
321        Value::Object(values) => {
322            // Do not depend on whether serde_json's preserve_order is enabled.
323            let mut entries: Vec<_> = values.iter().collect();
324            entries.sort_unstable_by_key(|(a, _)| *a);
325            writer.write_all(b"{")?;
326            for (index, (key, value)) in entries.into_iter().enumerate() {
327                if index != 0 {
328                    writer.write_all(b",")?;
329                }
330                write_json_string(writer, key)?;
331                writer.write_all(b":")?;
332                write_canonical_json(writer, value)?;
333            }
334            writer.write_all(b"}")
335        }
336    }
337}
338
339fn write_json_string(writer: &mut HashWriter, text: &str) -> io::Result<()> {
340    // text.len() has already been bounded, so serde cannot scan a giant string
341    // before reaching the writer's budget check. Never retain serializer errors.
342    serde_json::to_writer(writer, text)
343        .map_err(|_| io::Error::other("feedback JSON serialization failed"))
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use serde_json::json;
350    use crate::{AgentEvent, SessionEvent};
351
352    fn text(value: &str) -> StreamEvent {
353        StreamEvent::Llm(LlmEvent::Text(value.into()))
354    }
355
356    fn call(id: &str, name: &str, input: Value) -> StreamEvent {
357        StreamEvent::Llm(LlmEvent::ToolUse {
358            tool_name: name.into(),
359            tool_id: id.into(),
360            input,
361        })
362    }
363
364    fn result(id: &str, output: &str) -> StreamEvent {
365        StreamEvent::Llm(LlmEvent::ToolResult {
366            tool_id: id.into(),
367            result: output.into(),
368        })
369    }
370
371    #[test]
372    fn retry_feedback_ignores_discarded_attempt_but_keeps_earlier_tools() {
373        let mut tracker = Tracker::default();
374        let previous = call("p", "read", json!({"path":"before"}));
375        let after = call("q", "write", json!({"path":"after"}));
376        assert_eq!(
377            run(&mut tracker, &[previous.clone(), after.clone()]),
378            "changed"
379        );
380        assert_eq!(
381            run(
382                &mut tracker,
383                &[
384                    previous,
385                    StreamEvent::Llm(LlmEvent::ResponseStart),
386                    call("bad", "bash", json!({"command":"not executed"})),
387                    text("discard me"),
388                    StreamEvent::Llm(LlmEvent::ResponseReset),
389                    StreamEvent::Llm(LlmEvent::ResponseStart),
390                    after,
391                ]
392            ),
393            "repeated"
394        );
395    }
396
397    fn run(tracker: &mut Tracker, events: &[StreamEvent]) -> &'static str {
398        tracker.begin_turn("model", "effort");
399        for event in events {
400            tracker.observe(event);
401        }
402        tracker.finish()
403    }
404
405    fn scoped(tracker: &mut Tracker, model: &str, effort: &str) -> &'static str {
406        tracker.begin_turn(model, effort);
407        tracker.observe(&text("same output"));
408        tracker.finish()
409    }
410
411    #[test]
412    fn first_changed_then_repeated_and_changed() {
413        let mut tracker = Tracker::default();
414        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
415        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
416        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
417        assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
418    }
419
420    #[test]
421    fn recognizes_a_b_a_and_keeps_exactly_four_successful_signatures() {
422        let mut tracker = Tracker::default();
423        for value in ["A", "B"] {
424            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
425        }
426        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
427
428        let mut tracker = Tracker::default();
429        for value in ["A", "B", "C", "D"] {
430            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
431        }
432        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
433        assert_eq!(run(&mut tracker, &[text("E")]), "changed");
434        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
435    }
436
437    #[test]
438    fn ring_counts_turns_not_distinct_signatures() {
439        let mut tracker = Tracker::default();
440        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
441        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
442        for _ in 0..3 {
443            assert_eq!(run(&mut tracker, &[text("B")]), "repeated");
444        }
445        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
446    }
447
448    #[test]
449    fn arbitrary_tool_ids_and_accompanying_prose_are_ignored() {
450        let mut tracker = Tracker::default();
451        assert_eq!(
452            run(
453                &mut tracker,
454                &[
455                    text("before"),
456                    call("old-id", "read", json!({"path": "a"})),
457                    text("between"),
458                    result("old-id", "contents"),
459                    text("after"),
460                ],
461            ),
462            "changed"
463        );
464        assert_eq!(
465            run(
466                &mut tracker,
467                &[
468                    call("new-id", "read", json!({"path": "a"})),
469                    result("unrelated-result-id", "contents"),
470                    text("completely different explanation"),
471                ],
472            ),
473            "repeated"
474        );
475        assert_eq!(
476            run(
477                &mut tracker,
478                &[
479                    call("", "read", json!({"path": "a"})),
480                    result("", "contents"),
481                ],
482            ),
483            "repeated"
484        );
485    }
486
487    #[test]
488    fn changed_tool_names_inputs_or_results_are_changed() {
489        let mut tracker = Tracker::default();
490        for (name, input, output) in [
491            ("read", json!({"path": "a"}), "one"),
492            ("read", json!({"path": "b"}), "one"),
493            ("read", json!({"path": "b"}), "two"),
494            ("other", json!({"path": "b"}), "two"),
495        ] {
496            assert_eq!(
497                run(
498                    &mut tracker,
499                    &[call("id", name, input), result("id", output)]
500                ),
501                "changed"
502            );
503        }
504    }
505
506    #[test]
507    fn unique_results_never_look_repeated_just_because_calls_repeat() {
508        let mut tracker = Tracker::default();
509        for index in 0..12 {
510            assert_eq!(
511                run(
512                    &mut tracker,
513                    &[
514                        call("id", "poll", json!({})),
515                        result("id", &format!("output {index}")),
516                    ],
517                ),
518                "changed"
519            );
520        }
521    }
522
523    #[test]
524    fn canonicalizes_object_keys_recursively_but_not_array_order_or_types() {
525        let mut tracker = Tracker::default();
526        let a: Value = serde_json::from_str(r#"{"z":[{"b":2,"a":1}],"a":true}"#).unwrap();
527        let b: Value = serde_json::from_str(r#"{"a":true,"z":[{"a":1,"b":2}]}"#).unwrap();
528        assert_eq!(run(&mut tracker, &[call("a", "tool", a)]), "changed");
529        assert_eq!(run(&mut tracker, &[call("b", "tool", b)]), "repeated");
530        for input in [
531            json!([1, 2]),
532            json!([2, 1]),
533            json!(["2", "1"]),
534            json!("[2,1]"),
535        ] {
536            assert_eq!(run(&mut tracker, &[call("id", "tool", input)]), "changed");
537        }
538    }
539
540    #[test]
541    fn tool_event_order_is_significant() {
542        let mut tracker = Tracker::default();
543        let a = call("a", "one", json!(null));
544        let b = call("b", "two", json!(null));
545        let x = result("a", "x");
546        let y = result("b", "y");
547        assert_eq!(
548            run(&mut tracker, &[a.clone(), b.clone(), x.clone(), y.clone()]),
549            "changed"
550        );
551        assert_eq!(
552            run(&mut tracker, &[b.clone(), a.clone(), x.clone(), y.clone()]),
553            "changed"
554        );
555        assert_eq!(run(&mut tracker, &[b, a, y, x]), "changed");
556    }
557
558    #[test]
559    fn type_tags_and_field_lengths_prevent_concatenation_aliases() {
560        let mut tracker = Tracker::default();
561        assert_eq!(run(&mut tracker, &[text("abc")]), "changed");
562        assert_eq!(run(&mut tracker, &[result("", "abc")]), "changed");
563        assert_eq!(
564            run(&mut tracker, &[result("", "ab"), result("", "c")]),
565            "changed"
566        );
567        assert_eq!(
568            run(&mut tracker, &[result("", "a"), result("", "bc")]),
569            "changed"
570        );
571        assert_eq!(run(&mut tracker, &[call("", "ab", json!("c"))]), "changed");
572        assert_eq!(run(&mut tracker, &[call("", "a", json!("bc"))]), "changed");
573    }
574
575    #[test]
576    fn text_chunk_boundaries_do_not_affect_signature() {
577        let mut tracker = Tracker::default();
578        let whole = " \r\nCafé 🦀\n";
579        assert_eq!(run(&mut tracker, &[text(whole)]), "changed");
580        for split in whole
581            .char_indices()
582            .map(|(index, _)| index)
583            .chain([whole.len()])
584        {
585            assert_eq!(
586                run(
587                    &mut tracker,
588                    &[text(&whole[..split]), text(""), text(&whole[split..])]
589                ),
590                "repeated"
591            );
592        }
593        let chunks: Vec<_> = whole.chars().map(|ch| text(&ch.to_string())).collect();
594        assert_eq!(run(&mut tracker, &chunks), "repeated");
595    }
596
597    #[test]
598    fn nonempty_text_uses_exact_bytes_not_trimmed_or_normalized_bytes() {
599        let mut tracker = Tracker::default();
600        for value in ["answer", " answer", "answer ", "Answer", "answer\n"] {
601            assert_eq!(run(&mut tracker, &[text(value)]), "changed");
602        }
603    }
604
605    #[test]
606    fn thinking_notices_history_agent_events_and_deltas_are_ignored() {
607        let mut tracker = Tracker::default();
608        // Synthetic history is supplied but never traversed by the tracker.
609        let ignored = [
610            StreamEvent::Llm(LlmEvent::Thinking("private reasoning".into())),
611            StreamEvent::Llm(LlmEvent::ToolUseStart {
612                tool_name: "unfinished".into(),
613                tool_id: "id".into(),
614            }),
615            StreamEvent::Llm(LlmEvent::ToolUseDelta {
616                tool_id: "id".into(),
617                delta: "{incomplete".into(),
618            }),
619            StreamEvent::Llm(LlmEvent::ToolResultDelta {
620                tool_id: "id".into(),
621                delta: "partial output".into(),
622            }),
623            StreamEvent::Session(SessionEvent::Notice("notice".into())),
624            StreamEvent::Session(SessionEvent::MessageHistory(vec![std::sync::Arc::new(
625                json!({
626                    "role": "user", "content": "user goal/history is not output"
627                }),
628            )])),
629            StreamEvent::Agent(AgentEvent::SteeringDelivered {
630                message: "new goal".into(),
631            }),
632            StreamEvent::Session(SessionEvent::Done),
633        ];
634        assert_eq!(run(&mut tracker, &ignored), "empty");
635        assert_eq!(run(&mut tracker, &[text("answer")]), "changed");
636        tracker.begin_turn("model", "effort");
637        tracker.observe(&text("ans"));
638        for event in &ignored {
639            tracker.observe(event);
640        }
641        tracker.observe(&text("wer"));
642        assert_eq!(tracker.finish(), "repeated");
643    }
644
645    #[test]
646    fn empty_or_whitespace_only_output_never_becomes_repeated() {
647        let mut tracker = Tracker::default();
648        for _ in 0..6 {
649            assert_eq!(run(&mut tracker, &[]), "empty");
650            assert_eq!(
651                run(&mut tracker, &[text(""), text(" \t\r\n\u{2003}")]),
652                "empty"
653            );
654            assert_eq!(
655                run(
656                    &mut tracker,
657                    &[StreamEvent::Llm(LlmEvent::Thinking("alone".into()))]
658                ),
659                "empty"
660            );
661        }
662        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
663        for _ in 0..6 {
664            assert_eq!(run(&mut tracker, &[]), "empty");
665        }
666        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
667    }
668
669    #[test]
670    fn final_tools_are_meaningful_even_with_empty_output() {
671        let mut tracker = Tracker::default();
672        assert_eq!(
673            run(&mut tracker, &[call("", "tool", json!(null))]),
674            "changed"
675        );
676        assert_eq!(
677            run(&mut tracker, &[call("new", "tool", json!(null))]),
678            "repeated"
679        );
680        assert_eq!(run(&mut tracker, &[result("", "")]), "changed");
681        assert_eq!(run(&mut tracker, &[result("new", "")]), "repeated");
682    }
683
684    #[test]
685    fn exact_model_and_effort_changes_reset_the_ring() {
686        let mut tracker = Tracker::default();
687        assert_eq!(scoped(&mut tracker, "model", "high"), "changed");
688        assert_eq!(scoped(&mut tracker, "model", "high"), "repeated");
689        for (model, effort) in [
690            ("Model", "high"),
691            ("model", "high"),
692            ("model", "HIGH"),
693            ("model", "high "),
694            ("model", "high"),
695            ("ab", "c"),
696            ("a", "bc"),
697        ] {
698            assert_eq!(scoped(&mut tracker, model, effort), "changed");
699        }
700    }
701
702    #[test]
703    fn oversized_scope_is_unknown_without_retaining_unbounded_metadata() {
704        let mut tracker = Tracker::default();
705        assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
706        assert_eq!(
707            scoped(&mut tracker, &"m".repeat(MAX_SCOPE_BYTES + 1), ""),
708            "unknown"
709        );
710        assert_eq!(
711            scoped(&mut tracker, "m", &"e".repeat(MAX_SCOPE_BYTES)),
712            "unknown"
713        );
714        assert_eq!(scoped(&mut tracker, "model", "effort"), "changed");
715    }
716
717    #[test]
718    fn finish_and_observe_outside_a_turn_do_not_account_twice() {
719        let mut tracker = Tracker::default();
720        tracker.observe(&text("unowned"));
721        assert_eq!(tracker.finish(), "unknown");
722        assert_eq!(run(&mut tracker, &[text("A")]), "changed");
723        assert_eq!(run(&mut tracker, &[text("B")]), "changed");
724        for _ in 0..8 {
725            tracker.observe(&text("unowned"));
726            assert_eq!(tracker.finish(), "unknown");
727        }
728        assert_eq!(run(&mut tracker, &[text("C")]), "changed");
729        assert_eq!(run(&mut tracker, &[text("D")]), "changed");
730        assert_eq!(run(&mut tracker, &[text("A")]), "repeated");
731        assert_eq!(run(&mut tracker, &[text("unowned")]), "changed");
732    }
733
734    #[test]
735    fn interrupted_turn_is_discarded_without_losing_completed_signatures() {
736        let mut tracker = Tracker::default();
737        assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
738        tracker.begin_turn("model", "effort");
739        tracker.observe(&text("interrupted"));
740        // No finish on failure; the next begin discards that observation.
741        assert_eq!(run(&mut tracker, &[text("completed")]), "repeated");
742        assert_eq!(run(&mut tracker, &[text("interrupted")]), "changed");
743        tracker.begin_turn("different model", "effort");
744        assert_eq!(run(&mut tracker, &[text("completed")]), "changed");
745    }
746
747    #[test]
748    fn text_budget_boundary_and_overflow_do_not_compare_truncated_prefixes() {
749        let mut tracker = Tracker::default();
750        let at_limit = text(&"x".repeat(MAX_BYTES));
751        assert_eq!(
752            run(&mut tracker, std::slice::from_ref(&at_limit)),
753            "changed"
754        );
755        assert_eq!(
756            run(&mut tracker, std::slice::from_ref(&at_limit)),
757            "repeated"
758        );
759        for _ in 0..6 {
760            // This non-repeated label breaks a prior repeated streak. Neither
761            // overflow nor its otherwise matching prefix enters the ring.
762            assert_eq!(run(&mut tracker, &[at_limit.clone(), text("x")]), "unknown");
763        }
764        assert_eq!(
765            run(&mut tracker, std::slice::from_ref(&at_limit)),
766            "repeated"
767        );
768        assert_eq!(
769            run(&mut tracker, &[text(&"x".repeat(MAX_BYTES + 1))]),
770            "unknown"
771        );
772    }
773
774    #[test]
775    fn tool_event_limit_is_inclusive_and_overflow_is_sticky() {
776        let mut tracker = Tracker::default();
777        for expected in ["changed", "repeated"] {
778            tracker.begin_turn("model", "effort");
779            for _ in 0..MAX_TOOL_EVENTS {
780                tracker.observe(&result("ignored", ""));
781            }
782            assert_eq!(tracker.finish(), expected);
783        }
784        tracker.begin_turn("model", "effort");
785        for _ in 0..=MAX_TOOL_EVENTS {
786            tracker.observe(&result("ignored", ""));
787        }
788        tracker.observe(&text("cannot recover"));
789        assert_eq!(tracker.finish(), "unknown");
790        assert_eq!(run(&mut tracker, &[text("cannot recover")]), "changed");
791    }
792
793    #[test]
794    fn byte_budget_is_shared_by_text_names_inputs_and_results() {
795        let mut tracker = Tracker::default();
796        let events = [
797            text(&"x".repeat(MAX_BYTES - 7)),
798            call("id", "t", json!(null)), // 1 byte name + 4 bytes JSON.
799            result("id", "ok"),           // 2 bytes result: exactly the limit.
800        ];
801        assert_eq!(run(&mut tracker, &events), "changed");
802        tracker.begin_turn("model", "effort");
803        for event in &events {
804            tracker.observe(event);
805        }
806        tracker.observe(&text("!"));
807        assert_eq!(tracker.finish(), "unknown");
808        assert_eq!(
809            run(&mut tracker, &[result("", &"x".repeat(MAX_BYTES + 1))]),
810            "unknown"
811        );
812        assert_eq!(
813            run(
814                &mut tracker,
815                &[call("", &"x".repeat(MAX_BYTES + 1), json!(null))]
816            ),
817            "unknown"
818        );
819    }
820
821    #[test]
822    fn ignored_content_does_not_consume_the_budget() {
823        let mut tracker = Tracker::default();
824        let ignored = StreamEvent::Llm(LlmEvent::Thinking("x".repeat(MAX_BYTES + 1)));
825        assert_eq!(run(&mut tracker, &[ignored, text("answer")]), "changed");
826        assert_eq!(run(&mut tracker, &[text("answer")]), "repeated");
827    }
828
829    #[test]
830    fn oversized_json_and_escaped_serialization_overflow_are_unknown() {
831        let mut tracker = Tracker::default();
832        for input in [
833            Value::String("x".repeat(MAX_BYTES + 1)),
834            // Raw text passes preflight, but JSON escaping exceeds the budget.
835            Value::String("\n".repeat(MAX_BYTES / 2)),
836        ] {
837            assert_eq!(run(&mut tracker, &[call("", "tool", input)]), "unknown");
838        }
839        assert_eq!(
840            run(&mut tracker, &[call("", "tool", json!("small"))]),
841            "changed"
842        );
843        assert_eq!(
844            run(&mut tracker, &[call("", "tool", json!("small"))]),
845            "repeated"
846        );
847    }
848
849    #[test]
850    fn json_depth_and_total_node_limits_fail_closed() {
851        let mut tracker = Tracker::default();
852        let mut nested = Value::Null;
853        for _ in 0..MAX_JSON_DEPTH {
854            nested = Value::Array(vec![nested]);
855        }
856        assert_eq!(
857            run(&mut tracker, &[call("", "tool", nested.clone())]),
858            "changed"
859        );
860        assert_eq!(
861            run(
862                &mut tracker,
863                &[call("", "tool", Value::Array(vec![nested]))]
864            ),
865            "unknown"
866        );
867        assert_eq!(
868            run(
869                &mut tracker,
870                &[call(
871                    "",
872                    "tool",
873                    Value::Array(vec![Value::Null; MAX_JSON_NODES])
874                )]
875            ),
876            "unknown"
877        );
878        let half = call(
879            "",
880            "tool",
881            Value::Array(vec![Value::Null; MAX_JSON_NODES / 2]),
882        );
883        assert_eq!(run(&mut tracker, &[half.clone(), half]), "unknown");
884    }
885}