Skip to main content

luft_core/contract/
cache.rs

1//! Stable agent cache key (§1.5) — the basis for `--resume` reuse.
2
3use crate::contract::ids::PhaseId;
4use blake3::Hasher;
5use unicode_normalization::UnicodeNormalization;
6
7/// Field separator for `agent_cache_key`. A zero byte is safe because BLAKE3
8/// hashes raw bytes — it cannot appear in any valid `backend_id`, `model`, or
9/// normalized `prompt`, so it cleanly delimits fields and prevents
10/// concatenation collisions (e.g. `("ab", "cd")` vs `("a", "bcd")`).
11const SEP: u8 = 0;
12
13/// Normalise a prompt for cache keying: NFC, unify line endings, collapse
14/// whitespace. Deliberately conservative — only removes formatting noise, not
15/// semantic differences (v0.1 trade-off; v0.2 may add similarity matching).
16fn normalize_prompt(prompt: &str) -> String {
17    prompt
18        .nfc()
19        .collect::<String>()
20        .replace("\r\n", "\n")
21        .replace('\r', "\n")
22        .split_whitespace()
23        .collect::<Vec<_>>()
24        .join(" ")
25}
26
27/// Deterministic cache key: `blake3(backend ++ model ++ normalized_prompt ++ phase)`.
28/// `\0` separators prevent field-concatenation collisions.
29pub fn agent_cache_key(
30    backend_id: &str,
31    model: Option<&str>,
32    prompt: &str,
33    phase: PhaseId,
34) -> String {
35    let mut h = Hasher::new();
36    h.update(backend_id.as_bytes());
37    h.update(&[SEP]);
38    h.update(model.unwrap_or("").as_bytes());
39    h.update(&[SEP]);
40    h.update(normalize_prompt(prompt).as_bytes());
41    h.update(&[SEP]);
42    // Big-endian encodes `phase` identically on every architecture, so cache
43    // keys stay stable across platforms for `--resume`. Do NOT switch to
44    // `to_ne_bytes()` — keys would silently diverge on big-endian hosts.
45    h.update(&phase.to_be_bytes());
46    h.finalize().to_hex().to_string()
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52
53    #[test]
54    fn key_is_stable_and_distinct() {
55        let a = agent_cache_key("opencode", Some("gpt"), "audit file", 1);
56        let b = agent_cache_key("opencode", Some("gpt"), "audit file", 1);
57        assert_eq!(a, b, "same inputs must yield same key");
58
59        assert_ne!(a, agent_cache_key("opencode", Some("gpt"), "audit file", 2));
60        assert_ne!(a, agent_cache_key("codex", Some("gpt"), "audit file", 1));
61        assert_ne!(a, agent_cache_key("opencode", None, "audit file", 1));
62    }
63
64    #[test]
65    fn whitespace_is_normalized() {
66        assert_eq!(
67            agent_cache_key("b", None, "  foo\r\nbar  ", 0),
68            agent_cache_key("b", None, "foo bar", 0),
69        );
70    }
71}