1use std::hash::{Hash, Hasher};
10
11use serde::Serialize;
12use serde_json::Value;
13
14use crate::llm::provider::ToolDefinition;
15
16#[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 #[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 parallel_tools: false,
61 tools,
62 caching,
63 catalog_epoch,
64 }
65 }
66
67 #[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 #[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 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 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#[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
271pub fn hash_value<T: Hash>(value: &T) -> u64 {
273 let mut hasher = StableHasher::new();
274 value.hash(&mut hasher);
275 hasher.finish()
276}
277
278pub 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
287pub fn hash_tool_definitions(tools: Option<&[ToolDefinition]>) -> Option<u64> {
289 tools.and_then(hash_json_value)
290}
291
292pub 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 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
342pub 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}