OXYGENHUB
◆ main spectra / crates / spectra-core / src / rules.rs
5.5 KiB · Rust · last changed Briefs and Radar's Constellation (#29, #30); spec Draft 0.6 1 day ago source blame history raw
b044d909 Andy McCall ↶ 1
//! Beacon (spec §12): the brief an agent receives, compiled from the
2
//! layers the owner has set (overall, then the agent's project), each under
3
//! a heading naming it, with no other change; the version is the BLAKE3 of
4
//! that text. Briefs are `brief.set` events signed by the owner; nothing is
5
//! read from a file.
037c45d8 Andy McCall ↶ 6
7
use crate::event::Event;
8
use anyhow::{bail, Result};
9
b044d909 Andy McCall ↶ 10
/// A brief over this is refused (spec §12.1).
11
pub const MAX_BRIEF: usize = 64 * 1024;
037c45d8 Andy McCall ↶ 12
b044d909 Andy McCall ↶ 13
/// A compiled brief and its version.
037c45d8 Andy McCall ↶ 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
b044d909 Andy McCall ↶ 21
/// Check a brief's text before it is signed.
22
pub fn check(text: &str) -> Result<()> {
23
    if text.len() > MAX_BRIEF {
24
        bail!("a brief is at most 64 KiB, so an agent can read it in one call ({} bytes)", text.len());
25
    }
26
    Ok(())
27
}
28
29
/// The text of one layer, if set.
30
pub async fn layer(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Option<String>> {
31
    let (layer, p) = match project {
32
        Some(p) => ("project", p),
33
        None => ("overall", ""),
34
    };
35
    Ok(sqlx::query_scalar("SELECT text FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(p).fetch_optional(pool).await?)
36
}
37
38
/// The brief for an agent in `project` (none for the owner: the overall
39
/// brief alone).
40
pub async fn compile(pool: &sqlx::SqlitePool, project: Option<&str>) -> Result<Rules> {
037c45d8 Andy McCall ↶ 41
    let mut text = String::new();
b044d909 Andy McCall ↶ 42
    if let Some(body) = layer(pool, None).await? {
43
        text.push_str("# Overall brief\n\n");
037c45d8 Andy McCall ↶ 44
        text.push_str(body.trim_end());
45
        text.push('\n');
46
    }
b044d909 Andy McCall ↶ 47
    if let Some(p) = project {
48
        if let Some(body) = layer(pool, Some(p)).await? {
49
            if !text.is_empty() {
50
                text.push('\n');
51
            }
52
            text.push_str(&format!("# Project brief: {p}\n\n"));
53
            text.push_str(body.trim_end());
54
            text.push('\n');
55
        }
56
    }
037c45d8 Andy McCall ↶ 57
    let version = blake3::hash(text.as_bytes()).to_hex()[..12].to_string();
58
    Ok(Rules { text, version })
59
}
60
61
/// The version `agent` last read, if any.
62
pub async fn last_read(pool: &sqlx::SqlitePool, agent: &str) -> sqlx::Result<Option<String>> {
63
    sqlx::query_scalar("SELECT version FROM rules_read WHERE agent = ?").bind(agent).fetch_optional(pool).await
64
}
65
66
/// The line every result begins with while an agent works on stale rules.
67
pub fn changed_line(read: &str, now: &str) -> String {
b044d909 Andy McCall ↶ 68
    format!("Your brief changed ({read} → {now}): call rules before continuing.")
037c45d8 Andy McCall ↶ 69
}
70
b044d909 Andy McCall ↶ 71
/// Apply `brief.set`, `rules.read` and `tool.called` to their tables.
037c45d8 Andy McCall ↶ 72
pub async fn project(conn: &mut sqlx::SqliteConnection, e: &Event) -> sqlx::Result<()> {
73
    let p = e.json();
74
    let agent = e.actor.strip_prefix("agent:");
75
    match e.kind.as_str() {
b044d909 Andy McCall ↶ 76
        // Only the owner sets a brief; an agent's key can never sign as `human`.
77
        "brief.set" if e.actor == "human" => {
78
            let project = p.get("project").and_then(|v| v.as_str()).unwrap_or("");
79
            let layer = if project.is_empty() { "overall" } else { "project" };
80
            let text = p.get("text").and_then(|v| v.as_str()).unwrap_or("");
81
            if text.trim().is_empty() {
82
                sqlx::query("DELETE FROM briefs WHERE layer = ? AND project = ?").bind(layer).bind(project).execute(&mut *conn).await?;
83
            } else {
84
                sqlx::query("INSERT OR REPLACE INTO briefs (layer, project, text, seq) VALUES (?, ?, ?, ?)")
85
                    .bind(layer)
86
                    .bind(project)
87
                    .bind(text)
88
                    .bind(e.seq as i64)
89
                    .execute(&mut *conn)
90
                    .await?;
91
            }
92
        }
037c45d8 Andy McCall ↶ 93
        "rules.read" => {
94
            if let (Some(a), Some(v)) = (agent, p.get("version").and_then(|v| v.as_str())) {
95
                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")
96
                    .bind(a)
97
                    .bind(v)
98
                    .bind(e.at_ms as i64)
99
                    .execute(&mut *conn)
100
                    .await?;
101
            }
102
        }
103
        "tool.called" => {
104
            let s = |k: &str| p.get(k).and_then(|v| v.as_str()).map(String::from);
105
            if let (Some(a), Some(tool)) = (agent, s("tool")) {
106
                sqlx::query("INSERT OR IGNORE INTO tool_calls (seq, agent, tool, args, result, ms, is_error, read, session, at_ms) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
107
                    .bind(e.seq as i64)
108
                    .bind(a)
109
                    .bind(tool)
110
                    .bind(s("args").unwrap_or_default())
111
                    .bind(s("result").unwrap_or_default())
112
                    .bind(p.get("ms").and_then(|v| v.as_i64()).unwrap_or(0))
113
                    .bind(p.get("is_error").and_then(|v| v.as_bool()).unwrap_or(false))
114
                    .bind(p.get("read").and_then(|v| v.as_bool()).unwrap_or(false))
115
                    .bind(&e.session)
116
                    .bind(e.at_ms as i64)
117
                    .execute(&mut *conn)
118
                    .await?;
119
            }
120
        }
121
        _ => {}
122
    }
123
    Ok(())
124
}
125
126
#[cfg(test)]
127
mod tests {
128
    use super::*;
129
130
    #[test]
b044d909 Andy McCall ↶ 131
    fn a_brief_must_fit_in_one_call() {
132
        assert!(check("Write for a curious twelve-year-old.").is_ok());
133
        assert!(check(&"x".repeat(MAX_BRIEF)).is_ok());
134
        assert!(check(&"x".repeat(MAX_BRIEF + 1)).is_err());
135
        assert!(changed_line("aaa", "bbb").starts_with("Your brief changed (aaa → bbb)"));
037c45d8 Andy McCall ↶ 136
    }
137
}