use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyEntry {
pub id: String,
pub pattern: String,
pub embedding: Vec<f32>,
pub tier: u8,
pub tags: Vec<String>,
pub metadata: HashMap<String, serde_json::Value>,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedCostRecord {
pub model: String,
pub tokens: u64,
pub cost: f32,
pub latency_ms: u64,
pub timestamp: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyKernelState {
pub version: u32,
pub policies: Vec<PolicyEntry>,
pub cost_records: Vec<PersistedCostRecord>,
}
impl Default for PolicyKernelState {
fn default() -> Self {
Self {
version: 1,
policies: Vec::new(),
cost_records: Vec::new(),
}
}
}
#[non_exhaustive]
#[derive(Debug)]
pub enum PolicyKernelError {
Io(std::io::Error),
Serde(serde_json::Error),
}
impl std::fmt::Display for PolicyKernelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PolicyKernelError::Io(e) => write!(f, "policy kernel I/O error: {e}"),
PolicyKernelError::Serde(e) => {
write!(f, "policy kernel serde error: {e}")
}
}
}
}
impl std::error::Error for PolicyKernelError {}
impl From<std::io::Error> for PolicyKernelError {
fn from(e: std::io::Error) -> Self {
PolicyKernelError::Io(e)
}
}
impl From<serde_json::Error> for PolicyKernelError {
fn from(e: serde_json::Error) -> Self {
PolicyKernelError::Serde(e)
}
}
pub struct PolicyKernel {
state: PolicyKernelState,
path: PathBuf,
}
impl PolicyKernel {
pub fn agent_path(agent_id: &str) -> Option<PathBuf> {
#[cfg(feature = "native")]
{ dirs::home_dir().map(|h| {
h.join(".clawft")
.join("agents")
.join(agent_id)
.join("policy_kernel.json")
}) }
#[cfg(not(feature = "native"))]
{ Some(PathBuf::from(".clawft")
.join("agents")
.join(agent_id)
.join("policy_kernel.json")) }
}
pub fn global_path() -> Option<PathBuf> {
#[cfg(feature = "native")]
{ dirs::home_dir()
.map(|h| h.join(".clawft").join("memory").join("policy_kernel.json")) }
#[cfg(not(feature = "native"))]
{ Some(PathBuf::from(".clawft").join("memory").join("policy_kernel.json")) }
}
pub fn load(path: &Path) -> Result<Self, PolicyKernelError> {
if path.exists() {
debug!(path = %path.display(), "loading policy kernel");
let data = std::fs::read_to_string(path)?;
let state: PolicyKernelState = serde_json::from_str(&data)?;
debug!(
policies = state.policies.len(),
cost_records = state.cost_records.len(),
"policy kernel loaded"
);
Ok(Self {
state,
path: path.to_path_buf(),
})
} else {
debug!(path = %path.display(), "no existing policy kernel, creating new");
Ok(Self {
state: PolicyKernelState::default(),
path: path.to_path_buf(),
})
}
}
pub fn save(&self) -> Result<(), PolicyKernelError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(&self.state)?;
std::fs::write(&self.path, json)?;
debug!(
path = %self.path.display(),
policies = self.state.policies.len(),
"policy kernel saved"
);
Ok(())
}
pub fn add_policy(
&mut self,
id: String,
pattern: String,
embedding: Vec<f32>,
tier: u8,
) -> Result<(), PolicyKernelError> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.state.policies.push(PolicyEntry {
id,
pattern,
embedding,
tier,
tags: vec![tier.to_string()],
metadata: HashMap::new(),
timestamp,
});
self.save()
}
pub fn record_cost(
&mut self,
model: &str,
tokens: u64,
cost: f32,
latency_ms: u64,
) -> Result<(), PolicyKernelError> {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.state.cost_records.push(PersistedCostRecord {
model: model.to_owned(),
tokens,
cost,
latency_ms,
timestamp,
});
self.save()
}
pub fn policies(&self) -> &[PolicyEntry] {
&self.state.policies
}
pub fn cost_records(&self) -> &[PersistedCostRecord] {
&self.state.cost_records
}
pub fn policy_count(&self) -> usize {
self.state.policies.len()
}
pub fn state(&self) -> &PolicyKernelState {
&self.state
}
pub fn set_state(
&mut self,
state: PolicyKernelState,
) -> Result<(), PolicyKernelError> {
self.state = state;
self.save()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn clear(&mut self) -> Result<(), PolicyKernelError> {
self.state.policies.clear();
self.state.cost_records.clear();
self.save()
}
}
pub fn load_policy_kernel(agent_id: Option<&str>) -> PolicyKernel {
if let Some(id) = agent_id
&& let Some(path) = PolicyKernel::agent_path(id)
{
match PolicyKernel::load(&path) {
Ok(k) => return k,
Err(e) => warn!(
agent_id = id,
error = %e,
"failed to load agent policy kernel, trying global"
),
}
}
if let Some(path) = PolicyKernel::global_path() {
match PolicyKernel::load(&path) {
Ok(k) => return k,
Err(e) => warn!(
error = %e,
"failed to load global policy kernel, using in-memory"
),
}
}
PolicyKernel {
state: PolicyKernelState::default(),
path: PathBuf::from("policy_kernel.json"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn temp_path(label: &str) -> PathBuf {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
std::env::temp_dir().join(format!(
"clawft_policy_kernel_test_{label}_{pid}_{n}.json"
))
}
#[test]
fn load_nonexistent_creates_empty() {
let path = temp_path("nonexist");
let _ = std::fs::remove_file(&path);
let kernel = PolicyKernel::load(&path).unwrap();
assert_eq!(kernel.policy_count(), 0);
assert!(kernel.cost_records().is_empty());
}
#[test]
fn add_policy_persists() {
let path = temp_path("add_policy");
let _ = std::fs::remove_file(&path);
{
let mut kernel = PolicyKernel::load(&path).unwrap();
kernel
.add_policy(
"p1".into(),
"convert temperature".into(),
vec![1.0, 0.0],
2,
)
.unwrap();
assert_eq!(kernel.policy_count(), 1);
}
let kernel = PolicyKernel::load(&path).unwrap();
assert_eq!(kernel.policy_count(), 1);
assert_eq!(kernel.policies()[0].id, "p1");
assert_eq!(kernel.policies()[0].tier, 2);
assert_eq!(kernel.policies()[0].pattern, "convert temperature");
let _ = std::fs::remove_file(&path);
}
#[test]
fn record_cost_persists() {
let path = temp_path("record_cost");
let _ = std::fs::remove_file(&path);
{
let mut kernel = PolicyKernel::load(&path).unwrap();
kernel
.record_cost("claude-haiku-3.5", 100, 0.001, 500)
.unwrap();
kernel
.record_cost("claude-sonnet-4.5", 500, 0.01, 2000)
.unwrap();
}
let kernel = PolicyKernel::load(&path).unwrap();
assert_eq!(kernel.cost_records().len(), 2);
assert_eq!(kernel.cost_records()[0].model, "claude-haiku-3.5");
assert_eq!(kernel.cost_records()[1].model, "claude-sonnet-4.5");
let _ = std::fs::remove_file(&path);
}
#[test]
fn clear_removes_all() {
let path = temp_path("clear");
let _ = std::fs::remove_file(&path);
let mut kernel = PolicyKernel::load(&path).unwrap();
kernel
.add_policy("p1".into(), "test".into(), vec![1.0], 2)
.unwrap();
kernel.record_cost("test-model", 10, 0.0, 100).unwrap();
kernel.clear().unwrap();
assert_eq!(kernel.policy_count(), 0);
assert!(kernel.cost_records().is_empty());
let reloaded = PolicyKernel::load(&path).unwrap();
assert_eq!(reloaded.policy_count(), 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn set_state_replaces_and_persists() {
let path = temp_path("set_state");
let _ = std::fs::remove_file(&path);
let mut kernel = PolicyKernel::load(&path).unwrap();
kernel
.add_policy("old".into(), "old pattern".into(), vec![0.0], 1)
.unwrap();
let new_state = PolicyKernelState {
version: 1,
policies: vec![PolicyEntry {
id: "imported".into(),
pattern: "imported pattern".into(),
embedding: vec![1.0, 2.0],
tier: 3,
tags: vec!["3".into()],
metadata: HashMap::new(),
timestamp: 12345,
}],
cost_records: Vec::new(),
};
kernel.set_state(new_state).unwrap();
assert_eq!(kernel.policy_count(), 1);
assert_eq!(kernel.policies()[0].id, "imported");
let reloaded = PolicyKernel::load(&path).unwrap();
assert_eq!(reloaded.policy_count(), 1);
assert_eq!(reloaded.policies()[0].id, "imported");
let _ = std::fs::remove_file(&path);
}
#[test]
fn state_version_is_1() {
let kernel = PolicyKernel::load(&temp_path("version")).unwrap();
assert_eq!(kernel.state().version, 1);
}
#[test]
fn path_accessor() {
let path = temp_path("path_acc");
let kernel = PolicyKernel::load(&path).unwrap();
assert_eq!(kernel.path(), path.as_path());
}
#[test]
fn agent_path_contains_agent_id() {
let path = PolicyKernel::agent_path("test-agent").unwrap();
assert!(path.to_string_lossy().contains("test-agent"));
assert!(path.to_string_lossy().contains("policy_kernel.json"));
}
#[test]
fn global_path_contains_memory() {
let path = PolicyKernel::global_path().unwrap();
assert!(path.to_string_lossy().contains("memory"));
assert!(path.to_string_lossy().contains("policy_kernel.json"));
}
#[test]
fn default_state_is_empty() {
let state = PolicyKernelState::default();
assert_eq!(state.version, 1);
assert!(state.policies.is_empty());
assert!(state.cost_records.is_empty());
}
#[test]
fn policy_entry_fields() {
let entry = PolicyEntry {
id: "test".into(),
pattern: "pattern".into(),
embedding: vec![1.0],
tier: 2,
tags: vec!["2".into()],
metadata: HashMap::new(),
timestamp: 999,
};
assert_eq!(entry.tier, 2);
assert_eq!(entry.timestamp, 999);
}
#[test]
fn error_display() {
let io_err = PolicyKernelError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"test",
));
assert!(format!("{io_err}").contains("test"));
let json_str = "invalid json";
let serde_err: Result<PolicyKernelState, _> =
serde_json::from_str(json_str);
if let Err(e) = serde_err {
let pke = PolicyKernelError::Serde(e);
assert!(format!("{pke}").contains("serde"));
}
}
}