Skip to main content

clawft_core/
policy_kernel.rs

1//! POLICY_KERNEL -- persistent routing policy store (H2.5).
2//!
3//! Stores `IntelligentRouter` routing policies and cost records to disk
4//! so they survive agent restarts. The default location is
5//! `~/.clawft/agents/<id>/policy_kernel.json`, falling back to
6//! `~/.clawft/memory/policy_kernel.json` when no agent ID is set.
7//!
8//! The store is loaded on startup and saved on every policy change.
9//! Format is intentionally JSON for debuggability.
10//!
11//! This module is gated behind the `vector-memory` feature flag.
12
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use tracing::{debug, warn};
18
19// ── Serializable types ──────────────────────────────────────────────
20
21/// A persisted routing policy entry.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PolicyEntry {
24    /// Unique identifier for this policy.
25    pub id: String,
26    /// The prompt pattern that triggered this policy.
27    pub pattern: String,
28    /// The embedding vector for the pattern.
29    pub embedding: Vec<f32>,
30    /// The tier (1=WASM, 2=Haiku, 3=Sonnet/Opus).
31    pub tier: u8,
32    /// Tags (first tag is the tier string).
33    pub tags: Vec<String>,
34    /// Arbitrary metadata.
35    pub metadata: HashMap<String, serde_json::Value>,
36    /// Unix timestamp (seconds since epoch) when the policy was created.
37    pub timestamp: u64,
38}
39
40/// A persisted cost record.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PersistedCostRecord {
43    /// The model that was used.
44    pub model: String,
45    /// Number of tokens consumed.
46    pub tokens: u64,
47    /// Monetary cost in USD.
48    pub cost: f32,
49    /// End-to-end latency in milliseconds.
50    pub latency_ms: u64,
51    /// Unix timestamp (seconds since epoch).
52    pub timestamp: u64,
53}
54
55/// The full persisted state of the policy kernel.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PolicyKernelState {
58    /// Schema version for forward compatibility.
59    pub version: u32,
60    /// Routing policy entries.
61    pub policies: Vec<PolicyEntry>,
62    /// Cost record history.
63    pub cost_records: Vec<PersistedCostRecord>,
64}
65
66impl Default for PolicyKernelState {
67    fn default() -> Self {
68        Self {
69            version: 1,
70            policies: Vec::new(),
71            cost_records: Vec::new(),
72        }
73    }
74}
75
76// ── Errors ──────────────────────────────────────────────────────────
77
78/// Errors from policy kernel persistence.
79#[non_exhaustive]
80#[derive(Debug)]
81pub enum PolicyKernelError {
82    /// An I/O error occurred.
83    Io(std::io::Error),
84    /// A serialization/deserialization error occurred.
85    Serde(serde_json::Error),
86}
87
88impl std::fmt::Display for PolicyKernelError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            PolicyKernelError::Io(e) => write!(f, "policy kernel I/O error: {e}"),
92            PolicyKernelError::Serde(e) => {
93                write!(f, "policy kernel serde error: {e}")
94            }
95        }
96    }
97}
98
99impl std::error::Error for PolicyKernelError {}
100
101impl From<std::io::Error> for PolicyKernelError {
102    fn from(e: std::io::Error) -> Self {
103        PolicyKernelError::Io(e)
104    }
105}
106
107impl From<serde_json::Error> for PolicyKernelError {
108    fn from(e: serde_json::Error) -> Self {
109        PolicyKernelError::Serde(e)
110    }
111}
112
113// ── PolicyKernel ────────────────────────────────────────────────────
114
115/// Persistent routing policy store.
116///
117/// Wraps a [`PolicyKernelState`] with load/save operations tied to a
118/// file path. Changes are saved eagerly on every mutation.
119pub struct PolicyKernel {
120    state: PolicyKernelState,
121    path: PathBuf,
122}
123
124impl PolicyKernel {
125    /// Resolve the default policy kernel path for a given agent.
126    ///
127    /// Returns `~/.clawft/agents/<agent_id>/policy_kernel.json`.
128    pub fn agent_path(agent_id: &str) -> Option<PathBuf> {
129        #[cfg(feature = "native")]
130        { dirs::home_dir().map(|h| {
131            h.join(".clawft")
132                .join("agents")
133                .join(agent_id)
134                .join("policy_kernel.json")
135        }) }
136        #[cfg(not(feature = "native"))]
137        { Some(PathBuf::from(".clawft")
138            .join("agents")
139            .join(agent_id)
140            .join("policy_kernel.json")) }
141    }
142
143    /// Resolve the default global policy kernel path.
144    ///
145    /// Returns `~/.clawft/memory/policy_kernel.json`.
146    pub fn global_path() -> Option<PathBuf> {
147        #[cfg(feature = "native")]
148        { dirs::home_dir()
149            .map(|h| h.join(".clawft").join("memory").join("policy_kernel.json")) }
150        #[cfg(not(feature = "native"))]
151        { Some(PathBuf::from(".clawft").join("memory").join("policy_kernel.json")) }
152    }
153
154    /// Load the policy kernel from disk, or create a new empty one.
155    ///
156    /// If the file does not exist, returns an empty kernel that will
157    /// create the file on the first save.
158    pub fn load(path: &Path) -> Result<Self, PolicyKernelError> {
159        if path.exists() {
160            debug!(path = %path.display(), "loading policy kernel");
161            let data = std::fs::read_to_string(path)?;
162            let state: PolicyKernelState = serde_json::from_str(&data)?;
163            debug!(
164                policies = state.policies.len(),
165                cost_records = state.cost_records.len(),
166                "policy kernel loaded"
167            );
168            Ok(Self {
169                state,
170                path: path.to_path_buf(),
171            })
172        } else {
173            debug!(path = %path.display(), "no existing policy kernel, creating new");
174            Ok(Self {
175                state: PolicyKernelState::default(),
176                path: path.to_path_buf(),
177            })
178        }
179    }
180
181    /// Save the current state to disk.
182    pub fn save(&self) -> Result<(), PolicyKernelError> {
183        if let Some(parent) = self.path.parent() {
184            std::fs::create_dir_all(parent)?;
185        }
186
187        let json = serde_json::to_string_pretty(&self.state)?;
188        std::fs::write(&self.path, json)?;
189
190        debug!(
191            path = %self.path.display(),
192            policies = self.state.policies.len(),
193            "policy kernel saved"
194        );
195        Ok(())
196    }
197
198    /// Add a routing policy and persist to disk.
199    pub fn add_policy(
200        &mut self,
201        id: String,
202        pattern: String,
203        embedding: Vec<f32>,
204        tier: u8,
205    ) -> Result<(), PolicyKernelError> {
206        let timestamp = std::time::SystemTime::now()
207            .duration_since(std::time::UNIX_EPOCH)
208            .unwrap_or_default()
209            .as_secs();
210
211        self.state.policies.push(PolicyEntry {
212            id,
213            pattern,
214            embedding,
215            tier,
216            tags: vec![tier.to_string()],
217            metadata: HashMap::new(),
218            timestamp,
219        });
220
221        self.save()
222    }
223
224    /// Record a cost observation and persist to disk.
225    pub fn record_cost(
226        &mut self,
227        model: &str,
228        tokens: u64,
229        cost: f32,
230        latency_ms: u64,
231    ) -> Result<(), PolicyKernelError> {
232        let timestamp = std::time::SystemTime::now()
233            .duration_since(std::time::UNIX_EPOCH)
234            .unwrap_or_default()
235            .as_secs();
236
237        self.state.cost_records.push(PersistedCostRecord {
238            model: model.to_owned(),
239            tokens,
240            cost,
241            latency_ms,
242            timestamp,
243        });
244
245        self.save()
246    }
247
248    /// Return the policy entries (read-only).
249    pub fn policies(&self) -> &[PolicyEntry] {
250        &self.state.policies
251    }
252
253    /// Return the cost records (read-only).
254    pub fn cost_records(&self) -> &[PersistedCostRecord] {
255        &self.state.cost_records
256    }
257
258    /// Return the number of policy entries.
259    pub fn policy_count(&self) -> usize {
260        self.state.policies.len()
261    }
262
263    /// Return the full state (for export).
264    pub fn state(&self) -> &PolicyKernelState {
265        &self.state
266    }
267
268    /// Replace the full state (for import). Saves immediately.
269    pub fn set_state(
270        &mut self,
271        state: PolicyKernelState,
272    ) -> Result<(), PolicyKernelError> {
273        self.state = state;
274        self.save()
275    }
276
277    /// Return the file path of this kernel.
278    pub fn path(&self) -> &Path {
279        &self.path
280    }
281
282    /// Clear all policies and cost records. Saves immediately.
283    pub fn clear(&mut self) -> Result<(), PolicyKernelError> {
284        self.state.policies.clear();
285        self.state.cost_records.clear();
286        self.save()
287    }
288}
289
290/// Try to load a policy kernel for a specific agent, falling back to
291/// the global kernel, and finally to a fresh empty kernel at the
292/// global path.
293pub fn load_policy_kernel(agent_id: Option<&str>) -> PolicyKernel {
294    // Try agent-specific path first.
295    if let Some(id) = agent_id
296        && let Some(path) = PolicyKernel::agent_path(id)
297    {
298        match PolicyKernel::load(&path) {
299            Ok(k) => return k,
300            Err(e) => warn!(
301                agent_id = id,
302                error = %e,
303                "failed to load agent policy kernel, trying global"
304            ),
305        }
306    }
307
308    // Try global path.
309    if let Some(path) = PolicyKernel::global_path() {
310        match PolicyKernel::load(&path) {
311            Ok(k) => return k,
312            Err(e) => warn!(
313                error = %e,
314                "failed to load global policy kernel, using in-memory"
315            ),
316        }
317    }
318
319    // Last resort: in-memory only (will fail on save without a valid path).
320    PolicyKernel {
321        state: PolicyKernelState::default(),
322        path: PathBuf::from("policy_kernel.json"),
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::sync::atomic::{AtomicU64, Ordering};
330
331    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
332
333    fn temp_path(label: &str) -> PathBuf {
334        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
335        let pid = std::process::id();
336        std::env::temp_dir().join(format!(
337            "clawft_policy_kernel_test_{label}_{pid}_{n}.json"
338        ))
339    }
340
341    #[test]
342    fn load_nonexistent_creates_empty() {
343        let path = temp_path("nonexist");
344        let _ = std::fs::remove_file(&path);
345
346        let kernel = PolicyKernel::load(&path).unwrap();
347        assert_eq!(kernel.policy_count(), 0);
348        assert!(kernel.cost_records().is_empty());
349    }
350
351    #[test]
352    fn add_policy_persists() {
353        let path = temp_path("add_policy");
354        let _ = std::fs::remove_file(&path);
355
356        {
357            let mut kernel = PolicyKernel::load(&path).unwrap();
358            kernel
359                .add_policy(
360                    "p1".into(),
361                    "convert temperature".into(),
362                    vec![1.0, 0.0],
363                    2,
364                )
365                .unwrap();
366            assert_eq!(kernel.policy_count(), 1);
367        }
368
369        // Reload and verify persistence.
370        let kernel = PolicyKernel::load(&path).unwrap();
371        assert_eq!(kernel.policy_count(), 1);
372        assert_eq!(kernel.policies()[0].id, "p1");
373        assert_eq!(kernel.policies()[0].tier, 2);
374        assert_eq!(kernel.policies()[0].pattern, "convert temperature");
375
376        let _ = std::fs::remove_file(&path);
377    }
378
379    #[test]
380    fn record_cost_persists() {
381        let path = temp_path("record_cost");
382        let _ = std::fs::remove_file(&path);
383
384        {
385            let mut kernel = PolicyKernel::load(&path).unwrap();
386            kernel
387                .record_cost("claude-haiku-3.5", 100, 0.001, 500)
388                .unwrap();
389            kernel
390                .record_cost("claude-sonnet-4.5", 500, 0.01, 2000)
391                .unwrap();
392        }
393
394        let kernel = PolicyKernel::load(&path).unwrap();
395        assert_eq!(kernel.cost_records().len(), 2);
396        assert_eq!(kernel.cost_records()[0].model, "claude-haiku-3.5");
397        assert_eq!(kernel.cost_records()[1].model, "claude-sonnet-4.5");
398
399        let _ = std::fs::remove_file(&path);
400    }
401
402    #[test]
403    fn clear_removes_all() {
404        let path = temp_path("clear");
405        let _ = std::fs::remove_file(&path);
406
407        let mut kernel = PolicyKernel::load(&path).unwrap();
408        kernel
409            .add_policy("p1".into(), "test".into(), vec![1.0], 2)
410            .unwrap();
411        kernel.record_cost("test-model", 10, 0.0, 100).unwrap();
412        kernel.clear().unwrap();
413
414        assert_eq!(kernel.policy_count(), 0);
415        assert!(kernel.cost_records().is_empty());
416
417        // Verify on disk.
418        let reloaded = PolicyKernel::load(&path).unwrap();
419        assert_eq!(reloaded.policy_count(), 0);
420
421        let _ = std::fs::remove_file(&path);
422    }
423
424    #[test]
425    fn set_state_replaces_and_persists() {
426        let path = temp_path("set_state");
427        let _ = std::fs::remove_file(&path);
428
429        let mut kernel = PolicyKernel::load(&path).unwrap();
430        kernel
431            .add_policy("old".into(), "old pattern".into(), vec![0.0], 1)
432            .unwrap();
433
434        let new_state = PolicyKernelState {
435            version: 1,
436            policies: vec![PolicyEntry {
437                id: "imported".into(),
438                pattern: "imported pattern".into(),
439                embedding: vec![1.0, 2.0],
440                tier: 3,
441                tags: vec!["3".into()],
442                metadata: HashMap::new(),
443                timestamp: 12345,
444            }],
445            cost_records: Vec::new(),
446        };
447        kernel.set_state(new_state).unwrap();
448
449        assert_eq!(kernel.policy_count(), 1);
450        assert_eq!(kernel.policies()[0].id, "imported");
451
452        let reloaded = PolicyKernel::load(&path).unwrap();
453        assert_eq!(reloaded.policy_count(), 1);
454        assert_eq!(reloaded.policies()[0].id, "imported");
455
456        let _ = std::fs::remove_file(&path);
457    }
458
459    #[test]
460    fn state_version_is_1() {
461        let kernel = PolicyKernel::load(&temp_path("version")).unwrap();
462        assert_eq!(kernel.state().version, 1);
463    }
464
465    #[test]
466    fn path_accessor() {
467        let path = temp_path("path_acc");
468        let kernel = PolicyKernel::load(&path).unwrap();
469        assert_eq!(kernel.path(), path.as_path());
470    }
471
472    #[test]
473    fn agent_path_contains_agent_id() {
474        let path = PolicyKernel::agent_path("test-agent").unwrap();
475        assert!(path.to_string_lossy().contains("test-agent"));
476        assert!(path.to_string_lossy().contains("policy_kernel.json"));
477    }
478
479    #[test]
480    fn global_path_contains_memory() {
481        let path = PolicyKernel::global_path().unwrap();
482        assert!(path.to_string_lossy().contains("memory"));
483        assert!(path.to_string_lossy().contains("policy_kernel.json"));
484    }
485
486    #[test]
487    fn default_state_is_empty() {
488        let state = PolicyKernelState::default();
489        assert_eq!(state.version, 1);
490        assert!(state.policies.is_empty());
491        assert!(state.cost_records.is_empty());
492    }
493
494    #[test]
495    fn policy_entry_fields() {
496        let entry = PolicyEntry {
497            id: "test".into(),
498            pattern: "pattern".into(),
499            embedding: vec![1.0],
500            tier: 2,
501            tags: vec!["2".into()],
502            metadata: HashMap::new(),
503            timestamp: 999,
504        };
505        assert_eq!(entry.tier, 2);
506        assert_eq!(entry.timestamp, 999);
507    }
508
509    #[test]
510    fn error_display() {
511        let io_err = PolicyKernelError::Io(std::io::Error::new(
512            std::io::ErrorKind::NotFound,
513            "test",
514        ));
515        assert!(format!("{io_err}").contains("test"));
516
517        let json_str = "invalid json";
518        let serde_err: Result<PolicyKernelState, _> =
519            serde_json::from_str(json_str);
520        if let Err(e) = serde_err {
521            let pke = PolicyKernelError::Serde(e);
522            assert!(format!("{pke}").contains("serde"));
523        }
524    }
525}