gglib_core/domain/agent/loop_detection/results.rs
1//! Hashing the answers a tool-call batch received, for equality alone.
2//!
3//! [`super::LoopDetector`] decides whether a repeat is a strike by asking
4//! whether the answer changed, and two callers have to ask it the same way:
5//! the agent loop, which holds `ToolResult`s it produced itself, and the
6//! proxy, which reconstructs the pairing from a replayed transcript. The
7//! *rule* — pair each call with its own answer, sort the pairs, hash — lives
8//! here so there is one of it. The *sourcing* stays with each caller, because
9//! that is where they genuinely differ: the proxy has to bound itself to the
10//! contiguous run of `role: "tool"` messages after an assistant turn, which
11//! is a wire-format concern the agent loop does not have.
12//!
13//! # Why `DefaultHasher` and not the `fnv1a_64` next door
14//!
15//! Equality is the only property used. These values are compared within one
16//! process, never persisted, never sent, and never shown to anyone —
17//! `AgentError::LoopDetected` carries the *signature*, which is FNV-1a hex, and
18//! is a different thing. `DefaultHasher` is unspecified across Rust releases,
19//! which would matter if any of that were untrue and does not. Keeping it is
20//! also what makes moving this code out of `gglib-proxy` provably behaviour
21//! preserving rather than merely intended to be.
22
23use std::collections::hash_map::DefaultHasher;
24use std::hash::{Hash, Hasher};
25
26use serde_json::Value;
27
28use super::super::tool_types::ToolCall;
29use super::signature::stable_repr;
30
31// =============================================================================
32// One answer
33// =============================================================================
34
35/// Hash one answer's text.
36///
37/// The `Value::String` arm of [`hash_result_content`], without building a
38/// `Value` to hold a string the caller already has. Tool results run to tens
39/// of kilobytes and the proxy hashes them on a pre-admission path, so the
40/// copy is worth avoiding.
41#[must_use]
42pub fn hash_result_text(text: &str) -> u64 {
43 let mut hasher = DefaultHasher::new();
44 (0u8, text).hash(&mut hasher);
45 hasher.finish()
46}
47
48/// Hash one answer's content, whatever shape it arrived in.
49///
50/// Deliberately *not* a text projection: projecting objects, numbers and nulls
51/// to the empty string would make two different structured results compare
52/// equal, which manufactures an "identical" repeat out of nothing.
53///
54/// The leading discriminant is why `null` and the string `"null"` cannot
55/// collide, in a function whose only job is equality.
56#[must_use]
57pub fn hash_result_content(content: &Value) -> u64 {
58 match content {
59 Value::String(s) => hash_result_text(s),
60 other => {
61 let mut hasher = DefaultHasher::new();
62 (1u8, other.to_string()).hash(&mut hasher);
63 hasher.finish()
64 }
65 }
66}
67
68// =============================================================================
69// One batch
70// =============================================================================
71
72/// Hash the answers to one batch of tool calls.
73///
74/// `answers[i]` is the hash of the answer to `calls[i]`, or `None` if that
75/// call went unanswered. Returns `None` when any call is unanswered, or when
76/// the two slices disagree in length: a partially-answered batch says nothing
77/// about whether work repeated, and neither does a caller that has lost track
78/// of which answer belongs to which call.
79///
80/// **Pairs, not bare hashes.** Sorting answer hashes alone would meet the
81/// ordering goal — [`super::batch_signature`] sorts too, so the same parallel
82/// batch re-emitted in a different order must still match — but it severs
83/// which call produced which result, and a two-call batch whose answers
84/// swapped between occurrences would compare equal.
85///
86/// The pair key canonicalises `arguments` through
87/// [`super::signature::stable_repr`] — the *same* rendering
88/// [`super::batch_signature`] uses, and that is load bearing rather than tidy.
89/// `stable_repr` collapses everything below `MAX_REPR_DEPTH` to a sentinel, so
90/// a bare `Value::to_string` here would distinguish batches the signature calls
91/// identical: one run, a different answers hash every occurrence, and a rescue
92/// that never ends. An observation-tier batch built that way could never be
93/// refused at all — which inverts the depth cap's own safety argument, that a
94/// collision can only ever make the guard *stricter*.
95///
96/// It also removes a dependence the previous rendering carried on
97/// `serde_json::Value` being a `BTreeMap`: `stable_repr` sorts keys itself, so
98/// enabling `preserve_order` cannot make this join quietly under-report.
99#[must_use]
100pub fn batch_results_hash(calls: &[ToolCall], answers: &[Option<u64>]) -> Option<u64> {
101 if answers.len() != calls.len() {
102 return None;
103 }
104 // `collection_is_never_read` does not count `Hash::hash` as a read, and
105 // `keyed` is read by exactly that, two lines below. The lint is a nursery
106 // one and this crate inherits the workspace's nursery set while
107 // `gglib-proxy`, where this code used to live, does not — so the same
108 // lines passed there and fail here. Reproducing `Vec::hash` by hand to
109 // satisfy it would mean relying on `write_length_prefix`'s default being
110 // `write_usize`, which is a subtler equivalence than the one it buys.
111 #[allow(clippy::collection_is_never_read)]
112 let mut keyed: Vec<(String, u64)> = calls
113 .iter()
114 .zip(answers)
115 .map(|(call, answer)| {
116 Some((
117 format!("{}\u{0}{}", call.name, stable_repr(&call.arguments)),
118 (*answer)?,
119 ))
120 })
121 .collect::<Option<Vec<_>>>()?;
122 keyed.sort_unstable();
123
124 let mut hasher = DefaultHasher::new();
125 keyed.hash(&mut hasher);
126 Some(hasher.finish())
127}
128
129#[cfg(test)]
130#[path = "results_tests.rs"]
131mod tests;