Skip to main content

vtcode_core/core/agent/
hash_utils.rs

1//! Hash utilities for tool catalog and system prompt caching.
2//!
3//! Provides hashing functions for tool definitions, system prompts, and
4//! low-signal attempt deduplication keys.
5//!
6//! All hashes are stable across processes (FNV-1a) so cache keys remain
7//! comparable after restarts and in persisted harness artifacts.
8
9use std::hash::{Hash, Hasher};
10
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::llm::provider::ToolDefinition;
15
16/// Resolved wire capabilities that define a cache-stable request segment.
17/// Runtime counters and environment observations deliberately do not belong here.
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct PromptCapabilityIdentity {
20    provider: compact_str::CompactString,
21    model: compact_str::CompactString,
22    context_window: usize,
23    reasoning_tag: compact_str::CompactString,
24    reasoning_effort: bool,
25    parallel_tools: bool,
26    tools: bool,
27    caching: bool,
28    catalog_epoch: u64,
29}
30
31impl PromptCapabilityIdentity {
32    /// Construct the cache identity available from the generated model catalog.
33    ///
34    /// The runtime provider path should prefer [`Self::resolve`], because it
35    /// can observe provider-specific capabilities such as parallel tool
36    /// configuration. Static prompt assembly does not own a provider object,
37    /// so it uses this catalog-only identity and keeps missing capability bits
38    /// explicit rather than falling back to a model-name hash.
39    #[must_use]
40    pub fn from_catalog(
41        provider: &str,
42        model: &str,
43        reasoning: Option<crate::config::types::ReasoningEffortLevel>,
44        catalog_epoch: u64,
45        catalog: Option<vtcode_config::models::ModelCatalogEntry>,
46    ) -> Self {
47        let context_window = catalog.map(|entry| entry.context_window).unwrap_or_default();
48        let reasoning_effort = catalog.is_some_and(|entry| !entry.reasoning_efforts.is_empty());
49        let tools = catalog.map(|entry| entry.tool_call).unwrap_or(true);
50        let caching = catalog.is_some_and(|entry| entry.caching);
51        Self {
52            provider: provider.into(),
53            model: model.into(),
54            context_window,
55            reasoning_tag: reasoning.map_or_else(|| "unset".into(), |effort| effort.to_string().into()),
56            reasoning_effort,
57            // Static prompt assembly has no provider instance from which to
58            // resolve this wire capability. Runtime request construction
59            // replaces this with the Provider-trait value.
60            parallel_tools: false,
61            tools,
62            caching,
63            catalog_epoch,
64        }
65    }
66
67    /// Prefer discovery/catalog capacity when the caller already resolved a model.
68    #[must_use]
69    pub fn with_resolved_model(mut self, resolved: &crate::llm::model_resolver::ResolvedModel) -> Self {
70        self.context_window = resolved.context_window().unwrap_or(self.context_window);
71        self
72    }
73    #[must_use]
74    pub fn resolve(
75        provider: &dyn crate::llm::provider::LLMProvider,
76        model: &str,
77        reasoning: Option<crate::config::types::ReasoningEffortLevel>,
78        catalog_epoch: u64,
79    ) -> Self {
80        Self {
81            provider: provider.name().into(),
82            model: model.into(),
83            context_window: provider.effective_context_size(model),
84            reasoning_tag: reasoning.map_or_else(|| "unset".into(), |effort| effort.to_string().into()),
85            reasoning_effort: !provider.supported_reasoning_efforts(model).is_empty(),
86            parallel_tools: provider.supports_parallel_tool_config(model),
87            tools: provider.supports_tools(model),
88            caching: provider.supports_context_caching(model),
89            catalog_epoch,
90        }
91    }
92
93    #[must_use]
94    pub fn prefix_hash(&self, prompt_hash: u64) -> u64 {
95        hash_value(&(prompt_hash, self))
96    }
97
98    /// Return the digest for the resolved capabilities alone. Callers that
99    /// also have a stable prompt hash can combine the two with
100    /// [`Self::prefix_hash`].
101    #[must_use]
102    pub fn digest(&self) -> u64 {
103        hash_value(self)
104    }
105}
106
107#[cfg(test)]
108mod capability_tests {
109    use super::*;
110
111    #[test]
112    fn stable_hasher_is_deterministic_across_instances() {
113        assert_eq!(hash_value(&"cache-key"), hash_value(&"cache-key"));
114        assert_ne!(hash_value(&"cache-key-a"), hash_value(&"cache-key-b"));
115    }
116
117    #[test]
118    fn stable_prefix_hash_strips_all_runtime_sections() {
119        let base = "Base prompt\n[Harness Limits]\n- max_tool_calls_per_turn: 5";
120        for suffix in [
121            "\n\n## Active Tools\n- Capabilities: read-only.",
122            "\n\n[Runtime Tool Catalog]\n- version: 1",
123            "\n\n[Deferred Tools]\n- code_search (2 tools): search",
124            "\n\n[Runtime Context]\n- turns: 1",
125            "\n\n[History Directives]\n- reuse prior tool outputs",
126            "\n\n[Context]\n- workspace: /tmp",
127            "\n\n# PLANNING WORKFLOW (READ-ONLY)\nMutating file edits are blocked.",
128            "\n\n# FULL-AUTO: Complete task autonomously until done or blocked.\n- Stay within the exposed tool list.",
129            "\n\n# FULL-AUTO (PLANNING WORKFLOW): Work autonomously within planning workflow constraints.\n- Stay read-only.",
130            "\n\n## Active Primary Agent Runtime State\n- Active agent: Build",
131        ] {
132            let with_section = format!("{base}{suffix}");
133            assert_eq!(
134                stable_system_prefix_hash(base),
135                stable_system_prefix_hash(&with_section),
136                "runtime section should not affect prefix hash: {suffix:?}"
137            );
138        }
139    }
140
141    #[test]
142    fn stable_prefix_hash_keeps_stable_content_after_dynamic_appendix() {
143        // Static-first/dynamic-last discipline: stable sections after a dynamic
144        // appendix must still contribute (explicit classification, not
145        // earliest-header truncation). Planning notices themselves stay dynamic.
146        let base = "# VT Code\nBase contract";
147        let with_planning = format!(
148            "{base}\n\n# PLANNING WORKFLOW (READ-ONLY)\nread-only notice\n\n[Harness Limits]\n- max_tool_calls_per_turn: 5"
149        );
150        assert_eq!(
151            stable_system_prefix_hash(base),
152            stable_system_prefix_hash(&with_planning),
153            "planning/full-auto notices must not perturb the stable prefix"
154        );
155    }
156
157    #[test]
158    fn stable_prefix_hash_ignores_hash_body_lines_inside_dynamic_section() {
159        // Body lines starting with `#` but without a header space (e.g.
160        // `#comment`) must not end the dynamic section and leak dynamic
161        // content into the stable hash.
162        let base = "Base prompt";
163        let dynamic = format!("{base}\n\n[Harness Limits]\n- max_tool_calls_per_turn: 5\n#comment body\n- more");
164        assert_eq!(
165            stable_system_prefix_hash(base),
166            stable_system_prefix_hash(&dynamic),
167            "hash-prefixed body lines must not reset the dynamic section"
168        );
169    }
170
171    #[test]
172    fn capability_identity_resolves_three_provider_families_without_transport() {
173        use crate::config::constants::models;
174        use crate::config::types::ReasoningEffortLevel;
175        use crate::llm::provider::LLMProvider;
176        use crate::llm::providers::{AnthropicProvider, GeminiProvider, OpenAIProvider};
177
178        let providers: [(Box<dyn LLMProvider>, &str); 3] = [
179            (Box::new(OpenAIProvider::new("offline-fixture".into())), models::openai::DEFAULT_MODEL),
180            (Box::new(AnthropicProvider::new("offline-fixture".into())), models::anthropic::DEFAULT_MODEL),
181            (Box::new(GeminiProvider::new("offline-fixture".into())), models::google::DEFAULT_MODEL),
182        ];
183        for (provider, model) in providers {
184            let identity =
185                PromptCapabilityIdentity::resolve(provider.as_ref(), model, Some(ReasoningEffortLevel::High), 1);
186            assert_eq!(identity.context_window, provider.effective_context_size(model));
187            assert_eq!(identity.parallel_tools, provider.supports_parallel_tool_config(model));
188            assert_eq!(identity.reasoning_effort, !provider.supported_reasoning_efforts(model).is_empty());
189            let repeat =
190                PromptCapabilityIdentity::resolve(provider.as_ref(), model, Some(ReasoningEffortLevel::High), 1);
191            assert_eq!(identity.prefix_hash(7), repeat.prefix_hash(7));
192            let refreshed =
193                PromptCapabilityIdentity::resolve(provider.as_ref(), model, Some(ReasoningEffortLevel::High), 2);
194            assert_ne!(identity.prefix_hash(7), refreshed.prefix_hash(7));
195        }
196    }
197
198    #[test]
199    fn capability_prefix_tracks_changes_for_three_provider_families() {
200        for (provider, model, context_window) in [
201            ("openai", "openai-fixture", 1_000_000),
202            ("anthropic", "claude-fixture", 200_000),
203            ("gemini", "gemini-fixture", 32_000),
204        ] {
205            let identity = PromptCapabilityIdentity {
206                provider: provider.into(),
207                model: model.into(),
208                context_window,
209                reasoning_tag: "high".into(),
210                reasoning_effort: true,
211                parallel_tools: true,
212                tools: true,
213                caching: true,
214                catalog_epoch: 1,
215            };
216            let baseline = identity.prefix_hash(7);
217            assert_eq!(baseline, identity.clone().prefix_hash(7));
218            let mut changed = identity.clone();
219            changed.catalog_epoch += 1;
220            assert_ne!(baseline, changed.prefix_hash(7));
221            changed = identity.clone();
222            changed.reasoning_tag = "low".into();
223            assert_ne!(baseline, changed.prefix_hash(7));
224            changed = identity.clone();
225            changed.context_window /= 2;
226            assert_ne!(baseline, changed.prefix_hash(7));
227            changed = identity;
228            changed.parallel_tools = false;
229            assert_ne!(baseline, changed.prefix_hash(7));
230        }
231    }
232}
233
234/// Stable FNV-1a 64-bit hasher with fixed offset basis.
235///
236/// `std::collections::hash_map::DefaultHasher` uses per-process random keys,
237/// so hashes differ across restarts. Cache and harness keys must be comparable
238/// across processes, hence this deterministic alternative shared by
239/// [`hash_value`], [`hash_json_value`], and execution-cache builders.
240#[derive(Debug, Clone)]
241pub struct StableHasher {
242    hash: u64,
243}
244
245impl Default for StableHasher {
246    fn default() -> Self {
247        Self { hash: 0xcbf29ce484222325 }
248    }
249}
250
251impl StableHasher {
252    #[must_use]
253    pub fn new() -> Self {
254        Self::default()
255    }
256}
257
258impl Hasher for StableHasher {
259    fn write(&mut self, bytes: &[u8]) {
260        for byte in bytes {
261            self.hash ^= u64::from(*byte);
262            self.hash = self.hash.wrapping_mul(0x100000001b3);
263        }
264    }
265
266    fn finish(&self) -> u64 {
267        self.hash
268    }
269}
270
271/// Hash a value with a stable cross-process hash.
272pub fn hash_value<T: Hash>(value: &T) -> u64 {
273    let mut hasher = StableHasher::new();
274    value.hash(&mut hasher);
275    hasher.finish()
276}
277
278/// Hash a serializable value as JSON with a stable cross-process hash.
279pub fn hash_json_value<T: Serialize + ?Sized>(value: &T) -> Option<u64> {
280    let mut hasher = StableHasher::new();
281    serde_json::to_writer(HasherWriter::new(&mut hasher), value).ok().map(|_| {
282        hasher.write_u8(0xff);
283        hasher.finish()
284    })
285}
286
287/// Hash tool definitions for cache key computation.
288pub fn hash_tool_definitions(tools: Option<&[ToolDefinition]>) -> Option<u64> {
289    tools.and_then(hash_json_value)
290}
291
292/// Compute the stable instruction digest from explicitly classified sections.
293///
294/// Unlike earliest-header truncation, this retains stable sections that occur
295/// after a dynamic appendix and excludes each runtime section independently.
296/// Keep the dynamic list aligned with prompt assembly and provider cache
297/// breakpoints.
298///
299/// Prompt-caching discipline (prefix match): static content first, dynamic
300/// last. Planning/full-auto notices are runtime mode transitions and must
301/// never perturb the stable prefix hash; they belong in the uncached suffix
302/// (or, better, in a `<system-reminder>` message appended to the history).
303pub fn stable_system_prefix_hash(system_prompt: &str) -> u64 {
304    const DYNAMIC_HEADERS: &[&str] = &[
305        "## Active Tools",
306        "## Environment",
307        "## Active Primary Agent Runtime State",
308        "# PLANNING WORKFLOW (READ-ONLY)",
309        "# FULL-AUTO: Complete task autonomously until done or blocked.",
310        "# FULL-AUTO (PLANNING WORKFLOW): Work autonomously within planning workflow constraints.",
311        "[Harness Limits]",
312        "[Runtime Tool Catalog]",
313        "[Deferred Tools]",
314        "[Runtime Context]",
315        "[History Directives]",
316        "[Context]",
317        "[Recovery Mode]",
318    ];
319    let mut stable = String::with_capacity(system_prompt.len());
320    let mut dynamic = false;
321    for line in system_prompt.lines() {
322        let trimmed = line.trim();
323        // Only section headers toggle the dynamic flag. Require the space
324        // after `#`/`##` so body lines like `#comment` or `#123` inside a
325        // dynamic section cannot prematurely end it and leak dynamic content
326        // into the stable hash.
327        let is_header = trimmed.starts_with("## ")
328            || trimmed.starts_with("# ")
329            || trimmed == "#"
330            || (trimmed.starts_with('[') && trimmed.ends_with(']'));
331        if is_header {
332            dynamic = DYNAMIC_HEADERS.contains(&trimmed);
333        }
334        if !dynamic {
335            stable.push_str(line);
336            stable.push('\n');
337        }
338    }
339    hash_value(&stable.trim_end())
340}
341
342/// Generate a deduplication key for low-signal tool attempts.
343pub fn low_signal_attempt_key(name: &str, args: &Value) -> String {
344    let mut hash: u64 = 0xcbf29ce484222325;
345    let mut input_len = 0usize;
346    if serde_json::to_writer(HashingWriter::new(&mut hash, &mut input_len), args).is_err() {
347        for byte in b"{}" {
348            hash ^= u64::from(*byte);
349            hash = hash.wrapping_mul(0x100000001b3);
350            input_len = input_len.saturating_add(1);
351        }
352    }
353
354    format!("{name}:len{input_len}-fnv{hash:016x}")
355}
356
357struct HashingWriter<'a> {
358    hash: &'a mut u64,
359    input_len: &'a mut usize,
360}
361
362impl<'a> HashingWriter<'a> {
363    fn new(hash: &'a mut u64, input_len: &'a mut usize) -> Self {
364        Self { hash, input_len }
365    }
366}
367
368impl std::io::Write for HashingWriter<'_> {
369    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
370        for byte in buf {
371            *self.hash ^= u64::from(*byte);
372            *self.hash = self.hash.wrapping_mul(0x100000001b3);
373            *self.input_len = self.input_len.saturating_add(1);
374        }
375        Ok(buf.len())
376    }
377
378    fn flush(&mut self) -> std::io::Result<()> {
379        Ok(())
380    }
381}
382
383struct HasherWriter<'a, H> {
384    hasher: &'a mut H,
385}
386
387impl<'a, H> HasherWriter<'a, H> {
388    fn new(hasher: &'a mut H) -> Self {
389        Self { hasher }
390    }
391}
392
393impl<H: Hasher> std::io::Write for HasherWriter<'_, H> {
394    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
395        self.hasher.write(buf);
396        Ok(buf.len())
397    }
398
399    fn flush(&mut self) -> std::io::Result<()> {
400        Ok(())
401    }
402}