//! Beacon (spec §12): the house rules an agent receives, compiled from the //! layers that exist, each under a heading naming it, with no other change; //! the version is the BLAKE3 of that text. 0.1 has the site layer; kind, //! project and agent layers, and committing them to the ledger, are 0.3. use crate::event::Event; use anyhow::{bail, Result}; use std::path::Path; /// A rule file over this is refused (spec §12.2). pub const MAX_RULES: usize = 64 * 1024; /// Compiled rules and their version. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Rules { pub text: String, /// The first 12 hex characters of BLAKE3(text). pub version: String, } /// The rules for an agent, from `/rules/`. pub fn compile(data: &Path) -> Result { let mut text = String::new(); let site = data.join("rules").join("site.md"); if let Ok(body) = std::fs::read_to_string(&site) { if body.len() > MAX_RULES { bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display()); } text.push_str("# House rules: site (rules/site.md)\n\n"); text.push_str(body.trim_end()); text.push('\n'); } let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string(); Ok(Rules { text, version }) } /// The version `agent` last read, if any. pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result> { sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await } /// The line every result begins with while an agent works on stale rules. pub fn changed_line(read: &str, now: &str) -> String { format!("House rules changed ({read} → {now}): call rules before continuing.") } /// Apply `rules.read` and `tool.called` to their tables. pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> { let p = e.json(); let agent = e.actor.strip_prefix("agent:"); match e.kind.as_str() { "rules.read" => { if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) { sqlx::query("INSERT INTO rules_read (agent, version, at_ms) VALUES (?, ?, ?) ON CONFLICT(agent) DO UPDATE SET version = excluded.version, at_ms = excluded.at_ms") .bind(a) .bind(v) .bind(e.at_ms as i64) .execute(&mut *conn) .await?; } } "tool.called" => { let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from); if let (Some(a), Some(tool)) = (agent, s("tool")) { sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") .bind(e.seq as i64) .bind(a) .bind(tool) .bind(s("args").unwrap_or_default()) .bind(s("result").unwrap_or_default()) .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0)) .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false)) .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false)) .bind(&e.session) .bind(e.at_ms as i64) .execute(&mut *conn) .await?; } } _ => {} } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn the_version_follows_the_text_exactly() { let dir = tempfile::tempdir().unwrap(); let empty = compile(dir.path()).unwrap(); assert_eq!(empty.text, ""); std::fs::create_dir_all(dir.path().join("rules")).unwrap(); std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap(); let a = compile(dir.path()).unwrap(); assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site")); std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap(); let b = compile(dir.path()).unwrap(); assert_ne!(a.version, b.version); assert_eq!(a.version.len(), 12); std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap(); assert!(compile(dir.path()).is_err()); } }