OXYGENHUB
◆ v0.1.0 spectra / crates / spectra-core / src / rules.rs
4.3 KiB · Rust · last changed The 0.1 tools: brief, rules, agents, status, say, read, inbox, wait with the idle guard, ack, search, redact; every call on the record (#8) 2 days ago source blame history raw
1
//! Beacon (spec §12): the house rules an agent receives, compiled from the
2
//! layers that exist, each under a heading naming it, with no other change;
3
//! the version is the BLAKE3 of that text. 0.1 has the site layer; kind,
4
//! project and agent layers, and committing them to the ledger, are 0.3.
5
6
use crate::event::Event;
7
use anyhow::{bail, Result};
8
use std::path::Path;
9
10
/// A rule file over this is refused (spec §12.2).
11
pub const MAX_RULES: usize = 64 * 1024;
12
13
/// Compiled rules and their version.
14
#[derive(Clone, Debug, PartialEq, Eq)]
15
pub struct Rules {
16
    pub text: String,
17
    /// The first 12 hex characters of BLAKE3(text).
18
    pub version: String,
19
}
20
21
/// The rules for an agent, from `<data>/rules/`.
22
pub fn compile(data: &Path) -> Result<Rules> {
23
    let mut text = String::new();
24
    let site = data.join("rules").join("site.md");
25
    if let Ok(body) = std::fs::read_to_string(&site) {
26
        if body.len() > MAX_RULES {
27
            bail!("{} is over 64 KiB: the rules must stay readable in one call", site.display());
28
        }
29
        text.push_str("# House rules: site (rules/site.md)\n\n");
30
        text.push_str(body.trim_end());
31
        text.push('\n');
32
    }
33
    let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string();
34
    Ok(Rules { text, version })
35
}
36
37
/// The version `agent` last read, if any.
38
pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> {
39
    sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await
40
}
41
42
/// The line every result begins with while an agent works on stale rules.
43
pub fn changed_line(read: &str, now: &str) -> String {
44
    format!("House rules changed ({read} → {now}): call rules before continuing.")
45
}
46
47
/// Apply `rules.read` and `tool.called` to their tables.
48
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
49
    let p = e.json();
50
    let agent = e.actor.strip_prefix("agent:");
51
    match e.kind.as_str() {
52
        "rules.read" => {
53
            if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) {
54
                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")
55
                    .bind(a)
56
                    .bind(v)
57
                    .bind(e.at_ms as i64)
58
                    .execute(&mut *conn)
59
                    .await?;
60
            }
61
        }
62
        "tool.called" => {
63
            let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from);
64
            if let (Some(a), Some(tool)) = (agent, s("tool")) {
65
                sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
66
                    .bind(e.seq as i64)
67
                    .bind(a)
68
                    .bind(tool)
69
                    .bind(s("args").unwrap_or_default())
70
                    .bind(s("result").unwrap_or_default())
71
                    .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0))
72
                    .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false))
73
                    .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false))
74
                    .bind(&e.session)
75
                    .bind(e.at_ms as i64)
76
                    .execute(&mut *conn)
77
                    .await?;
78
            }
79
        }
80
        _ => {}
81
    }
82
    Ok(())
83
}
84
85
#[cfg(test)]
86
mod tests {
87
    use super::*;
88
89
    #[test]
90
    fn the_version_follows_the_text_exactly() {
91
        let dir = tempfile::tempdir().unwrap();
92
        let empty = compile(dir.path()).unwrap();
93
        assert_eq!(empty.text, "");
94
        std::fs::create_dir_all(dir.path().join("rules")).unwrap();
95
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy.\n").unwrap();
96
        let a = compile(dir.path()).unwrap();
97
        assert!(a.text.contains("Commit with oxy.") && a.text.starts_with("# House rules: site"));
98
        std::fs::write(dir.path().join("rules/site.md"), "Commit with oxy!\n").unwrap();
99
        let b = compile(dir.path()).unwrap();
100
        assert_ne!(a.version, b.version);
101
        assert_eq!(a.version.len(), 12);
102
        std::fs::write(dir.path().join("rules/site.md"), "x".repeat(MAX_RULES + 1)).unwrap();
103
        assert!(compile(dir.path()).is_err());
104
    }
105
}