1use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use tracing::{debug, warn};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PolicyEntry {
24 pub id: String,
26 pub pattern: String,
28 pub embedding: Vec<f32>,
30 pub tier: u8,
32 pub tags: Vec<String>,
34 pub metadata: HashMap<String, serde_json::Value>,
36 pub timestamp: u64,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PersistedCostRecord {
43 pub model: String,
45 pub tokens: u64,
47 pub cost: f32,
49 pub latency_ms: u64,
51 pub timestamp: u64,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct PolicyKernelState {
58 pub version: u32,
60 pub policies: Vec<PolicyEntry>,
62 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#[non_exhaustive]
80#[derive(Debug)]
81pub enum PolicyKernelError {
82 Io(std::io::Error),
84 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
113pub struct PolicyKernel {
120 state: PolicyKernelState,
121 path: PathBuf,
122}
123
124impl PolicyKernel {
125 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 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 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 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 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 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 pub fn policies(&self) -> &[PolicyEntry] {
250 &self.state.policies
251 }
252
253 pub fn cost_records(&self) -> &[PersistedCostRecord] {
255 &self.state.cost_records
256 }
257
258 pub fn policy_count(&self) -> usize {
260 self.state.policies.len()
261 }
262
263 pub fn state(&self) -> &PolicyKernelState {
265 &self.state
266 }
267
268 pub fn set_state(
270 &mut self,
271 state: PolicyKernelState,
272 ) -> Result<(), PolicyKernelError> {
273 self.state = state;
274 self.save()
275 }
276
277 pub fn path(&self) -> &Path {
279 &self.path
280 }
281
282 pub fn clear(&mut self) -> Result<(), PolicyKernelError> {
284 self.state.policies.clear();
285 self.state.cost_records.clear();
286 self.save()
287 }
288}
289
290pub fn load_policy_kernel(agent_id: Option<&str>) -> PolicyKernel {
294 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 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 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 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 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}