diff --git a/crates/loopd/src/baseline.rs b/crates/loopd/src/baseline.rs new file mode 100644 index 0000000..d430781 --- /dev/null +++ b/crates/loopd/src/baseline.rs @@ -0,0 +1,122 @@ +//! The baseline: the fixed prefix of every request in an epoch — the system prompt, core memory +//! and core tool schemas. It is snapshotted per session so an edit to `system.md` changes nothing +//! for a running session, and `messages` turns a session log back into the request's messages. + +use std::path::{Path, PathBuf}; + +use crate::config::Config; +use crate::llama::{ChatMessage, ToolSchema}; +use crate::tools::Registry; +use proto::{Hash32, LogRecord, sha256}; + +use serde::Deserialize; +use serde::Serialize; + +/// The fixed prefix of a request, and the errors assembling or reading it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Baseline { + pub system: String, + pub tools: Vec, +} + +#[derive(Debug)] +pub enum BaselineError { + Read(PathBuf, std::io::Error), + Parse(PathBuf, serde_json::Error), + Hash, +} + +impl std::fmt::Display for BaselineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + BaselineError::Read(path, err) => write!(f, "{}: {err}", path.display()), + BaselineError::Parse(path, err) => write!(f, "{}: {err}", path.display()), + BaselineError::Hash => write!(f, "hashing the baseline failed"), + } + } +} + +impl std::error::Error for BaselineError {} + +impl Baseline { + /// The system prompt trimmed, plus core memory if it has content; the core tool schemas. + pub fn assemble(cfg: &Config, registry: &Registry) -> Result { + let system = std::fs::read_to_string(&cfg.baseline.system) + .map_err(|e| BaselineError::Read(cfg.baseline.system.clone(), e))?; + let mut system = system.trim_end().to_string(); + + let core = cfg.paths.home.join("memory/core.md"); + if let Ok(text) = std::fs::read_to_string(&core) { + let trimmed = text.trim(); + if !trimmed.is_empty() { + system.push_str("\n\n"); + system.push_str(trimmed); + } + } + + Ok(Baseline { + system, + tools: registry.core_schemas(), + }) + } + + /// The canonical JSON: key order is fixed, so the hash is stable. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + pub fn from_json(text: &str) -> Result { + serde_json::from_str(text) + } + + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| BaselineError::Read(path.to_path_buf(), e))?; + serde_json::from_str(&text).map_err(|e| BaselineError::Parse(path.to_path_buf(), e)) + } + + /// The sha256 of the baseline's own JSON, as written. + pub fn hash(&self) -> Result { + let json = self.to_json().map_err(|_| BaselineError::Hash)?; + sha256(json.as_bytes()).map_err(|_| BaselineError::Hash) + } +} + +/// The system message, then each log record replayed in order. Every record variant is named, so a +/// new one is a compile error rather than a silent skip. +pub fn messages(baseline: &Baseline, records: &[LogRecord]) -> Vec { + let mut out = vec![ChatMessage::System { + content: baseline.system.clone(), + }]; + for record in records { + match record { + LogRecord::SessionStart { .. } => {} + LogRecord::User { content, .. } => out.push(ChatMessage::User { + content: content.clone(), + }), + LogRecord::Assistant { + content, + reasoning_content, + tool_calls, + .. + } => out.push(ChatMessage::Assistant { + content: content.clone(), + reasoning_content: reasoning_content.clone(), + tool_calls: tool_calls.clone(), + }), + LogRecord::ToolResult { + tool_call_id, + content, + .. + } => out.push(ChatMessage::Tool { + tool_call_id: tool_call_id.clone(), + content: content.clone(), + }), + LogRecord::Usage { .. } => {} + LogRecord::CacheLoss { .. } => {} + LogRecord::EpochEnd { .. } => {} + } + } + out +} diff --git a/crates/loopd/src/lib.rs b/crates/loopd/src/lib.rs index 7e9866a..52391fb 100644 --- a/crates/loopd/src/lib.rs +++ b/crates/loopd/src/lib.rs @@ -1,5 +1,6 @@ //! The agent loop: sessions, prompt assembly and memory. It holds no authority. +pub mod baseline; pub mod config; pub mod http; pub mod llama; diff --git a/crates/loopd/tests/baseline.rs b/crates/loopd/tests/baseline.rs new file mode 100644 index 0000000..7efd4fd --- /dev/null +++ b/crates/loopd/tests/baseline.rs @@ -0,0 +1,234 @@ +//! Tests for the baseline and the replay function. Do not edit. + +mod support; + +use loopd::baseline::{Baseline, messages}; +use loopd::llama::ChatMessage; +use loopd::tools::Registry; +use proto::{CallId, DataClass, Epoch, Hash32, LogRecord, SessionId, Timestamp, ToolCall}; +use std::path::Path; +use support::Home; + +fn ts() -> Timestamp { + Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap() +} + +#[test] +fn assembles_system_prompt_and_core_schemas() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap(); + assert_eq!( + b.system, "You are Boxmaker, a test agent.", + "trailing newline trimmed" + ); + let names: Vec<&str> = b.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + ["clock", "find_tool", "call_tool"], + "core tools, then the two meta-tools; not echo" + ); +} + +#[test] +fn core_memory_is_appended_when_present_and_only_then() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + home.write("memory/core.md", "The owner likes cork.\n\n"); + let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap(); + assert_eq!( + b.system, + "You are Boxmaker, a test agent.\n\nThe owner likes cork." + ); + home.write("memory/core.md", " \n"); + let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap(); + assert_eq!( + b.system, "You are Boxmaker, a test agent.", + "an empty core file adds nothing" + ); +} + +#[test] +fn a_missing_system_prompt_is_an_error_naming_the_file() { + let home = Home::new(); + let mut cfg = home.config(Path::new("/tmp/unused.sock")); + cfg.baseline.system = home.dir.join("nope.md"); + let e = Baseline::assemble(&cfg, &Registry::m2b()).unwrap_err(); + assert!(e.to_string().contains("nope.md"), "{e}"); +} + +#[test] +fn json_round_trip_and_hash() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + let b = Baseline::assemble(&cfg, &Registry::m2b()).unwrap(); + let text = b.to_json().unwrap(); + assert_eq!(Baseline::from_json(&text).unwrap(), b); + assert_eq!( + b.hash().unwrap(), + proto::sha256(text.as_bytes()).unwrap(), + "the hash is of the JSON as written" + ); + assert_ne!(b.hash().unwrap(), Hash32::ZERO); + let mut other = b.clone(); + other.system.push('!'); + assert_ne!(other.hash().unwrap(), b.hash().unwrap()); + assert!( + Baseline::from_json(&text.replacen("\"system\"", "\"zz\":1,\"system\"", 1)).is_err(), + "unknown keys are rejected" + ); +} + +#[test] +fn replay_keeps_only_messages_in_order_and_unchanged() { + let b = Baseline { + system: "sys".to_string(), + tools: vec![], + }; + let call = ToolCall { + id: "c1".to_string(), + name: "clock".to_string(), + arguments: "{}".to_string(), + }; + let records = vec![ + LogRecord::SessionStart { + time: ts(), + session: SessionId::new("s").unwrap(), + epoch: Epoch(0), + slot: 0, + baseline: Hash32::ZERO, + }, + LogRecord::User { + time: ts(), + content: "hi".to_string(), + }, + LogRecord::Assistant { + time: ts(), + content: None, + reasoning_content: Some("think".to_string()), + tool_calls: vec![call.clone()], + }, + LogRecord::Usage { + time: ts(), + cache_n: 1, + prompt_n: 2, + predicted_n: 3, + reasoning_tokens: 1, + thinking_capped: false, + }, + LogRecord::ToolResult { + time: ts(), + call: CallId(1), + tool_call_id: "c1".to_string(), + content: "noon".to_string(), + class: DataClass::Public, + untrusted: false, + truncated: false, + }, + LogRecord::CacheLoss { + time: ts(), + expected: 10, + got: 0, + }, + LogRecord::Assistant { + time: ts(), + content: Some("It is noon.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + LogRecord::EpochEnd { + time: ts(), + next: Epoch(1), + summary: "x".to_string(), + }, + ]; + let want = vec![ + ChatMessage::System { + content: "sys".to_string(), + }, + ChatMessage::User { + content: "hi".to_string(), + }, + ChatMessage::Assistant { + content: None, + reasoning_content: Some("think".to_string()), + tool_calls: vec![call], + }, + ChatMessage::Tool { + tool_call_id: "c1".to_string(), + content: "noon".to_string(), + }, + ChatMessage::Assistant { + content: Some("It is noon.".to_string()), + reasoning_content: None, + tool_calls: vec![], + }, + ]; + assert_eq!(messages(&b, &records), want); + assert_eq!( + messages(&b, &[]), + vec![ChatMessage::System { + content: "sys".to_string() + }] + ); +} + +/// Replaying a prefix of the log gives a prefix of the messages: the function never reorders +/// or rewrites. Checked for every prefix of a generated log. +#[test] +fn replay_of_a_prefix_is_a_prefix() { + let b = Baseline { + system: "sys".to_string(), + tools: vec![], + }; + let mut records = Vec::new(); + let mut seed: u64 = 0x9e3779b97f4a7c15; + let mut next = || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + for i in 0..200u64 { + let r = match next() % 5 { + 0 => LogRecord::User { + time: ts(), + content: format!("u{i}"), + }, + 1 => LogRecord::Assistant { + time: ts(), + content: Some(format!("a{i}")), + reasoning_content: None, + tool_calls: vec![], + }, + 2 => LogRecord::ToolResult { + time: ts(), + call: CallId(i), + tool_call_id: format!("t{i}"), + content: "x".repeat((next() % 50) as usize), + class: DataClass::Private, + untrusted: true, + truncated: false, + }, + 3 => LogRecord::Usage { + time: ts(), + cache_n: i, + prompt_n: 1, + predicted_n: 1, + reasoning_tokens: 0, + thinking_capped: false, + }, + _ => LogRecord::CacheLoss { + time: ts(), + expected: i, + got: 0, + }, + }; + records.push(r); + } + let whole = messages(&b, &records); + for n in 0..=records.len() { + let part = messages(&b, &records[..n]); + assert_eq!(whole[..part.len()], part[..], "prefix of {n} records"); + } +} diff --git a/crates/loopd/tests/support/mod.rs b/crates/loopd/tests/support/mod.rs index e2eb473..5ba2d1c 100644 --- a/crates/loopd/tests/support/mod.rs +++ b/crates/loopd/tests/support/mod.rs @@ -58,6 +58,90 @@ retry_window_ms = 5000 loopd::config::Config::parse(&text).unwrap() } +/// A temporary `BOXMAKER_HOME` with a `system.md` beside a `config.toml`, for session tests. +pub struct Home { + pub dir: PathBuf, +} + +impl Home { + pub fn new() -> Home { + let n = NEXT.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("loopd-home-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("system.md"), "You are Boxmaker, a test agent.\n").unwrap(); + Home { dir } + } + + /// A config for `socket` whose home, system prompt and channel socket are all under this + /// directory, with the fast test limits. + pub fn config(&self, socket: &Path) -> loopd::config::Config { + let mut cfg = test_config(socket); + cfg.paths.home = self.dir.clone(); + cfg.baseline.system = self.dir.join("system.md"); + cfg.channel.socket = self.dir.join("loop.sock"); + cfg + } + + pub fn write(&self, relative: &str, text: &str) { + let path = self.dir.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + + pub fn read(&self, relative: &str) -> String { + std::fs::read_to_string(self.dir.join(relative)).unwrap() + } + + /// The records of a session's log, epoch 0. + pub fn records(&self, session: &str) -> Vec { + let text = self.read(&format!("sessions/{session}/0.jsonl")); + text.lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } +} + +/// A tool port that answers from a script and records what it was asked. +pub struct ScriptedPort { + replies: Mutex>, + calls: Mutex>, +} + +impl ScriptedPort { + /// Replies are given in order; when they run out, every call gets `fallback`. + pub fn new(replies: Vec) -> ScriptedPort { + ScriptedPort { + replies: Mutex::new(replies.into()), + calls: Mutex::new(Vec::new()), + } + } + + pub fn calls(&self) -> Vec { + self.calls.lock().unwrap().clone() + } +} + +pub fn ok_result(content: &str) -> proto::ToolResponse { + proto::ToolResponse::Result { + content: content.to_string(), + class: proto::DataClass::Private, + untrusted: true, + truncated: false, + } +} + +impl loopd::tools::ToolPort for ScriptedPort { + fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse { + self.calls.lock().unwrap().push(request.clone()); + self.replies + .lock() + .unwrap() + .pop_front() + .unwrap_or_else(|| ok_result("scripted")) + } +} + /// What an independent script derived from a recorded stream: `tests/fixtures/expected/`. pub fn expected(name: &str) -> serde_json::Value { serde_json::from_slice(&fixture_bytes("expected", &format!("{name}.json"))).unwrap() diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 1ee6c0b..b16c864 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -33,6 +33,7 @@ reviewer adds findings under "Reviews" once per milestone. | M2b/01-proto-channel-types | 2026-09-18 | done | 1 | pass | none | Added `Usage` struct and a `Usage` variant (between ToolResult and CacheLoss) in log.rs, and `Turn`, `TurnEvent`, `TurnDone` plus six `ErrorCode` variants (SessionFull..Inference) and three `Message` variants (after Error) in wire.rs; re-exported Usage, Turn, TurnEvent, TurnDone from lib.rs. All four new types carry `deny_unknown_fields`; field order matches the byte-exact fixtures (attempt/after_ms/error, name/class/truncated). 55 proto tests pass (turn_wire 5, strict 5, wire 9, ids 12, frame 13, grant 4, hash 4, records 3) and `make gate` prints `gate: ok`; the old fixtures stay byte-identical. One duplicate block of the three wire types left by an interrupted edit had to be removed mid-task. | Ornith-1.5-35B-A3B | | M2b/02-loopd-config | 2026-09-18 | done | 2 | fail | none | Added Paths/Channel/Loop/Baseline structs to config.rs with `#[serde(deny_unknown_fields, default)]` and Debug/Clone/PartialEq/Eq/Deserialize (Default derived for Channel, hand-written for the other three: home is $BOXMAKER_HOME else /var/lib/boxmaker, loop is 8/true/16384, baseline system is system.md); Config gained the four `#[serde(default)]` fields and `channel_socket()` fills the default `/run/loop/loop.sock` when the socket is empty. `load` joins `baseline.system` to the config file's directory via `parent.join` (which replaces an already-absolute path); `parse` leaves it. 9 config tests pass, `deny_unknown_fields` count is 10. Two clippy fixes on the first (failing) gate run: the nested if in `load` collapsed by relying on Path::join replacing absolute paths instead of a 1.98 let-chain, and `Path::is_empty` (stable 1.98) replaced with `as_os_str().is_empty()`. | ? | | M2b/03-loopd-tools | 2026-09-18 | done | 1 | pass | none | Added `pub mod tools;` to lib.rs and `serde::Serialize`/`serde::Deserialize`/`deny_unknown_fields` to ToolSchema; wrote crates/loopd/src/tools.rs with ToolPort, Entry, Registry (m2b/config), core_schemas/get/find, FIND_TOOL/CALL_TOOL constants, clock_schema/echo_schema, Dispatch with dispatch (find_tool/call_tool/local dispatch rows), cap_result via floor_char_boundary, and FakeTools recording calls and answering clock/echo/denying others with `unwrap_or_else(|p| p.into_inner())` on Mutex::lock. 7 tools tests pass, `make gate` prints `gate: ok`, no `unwrap()` in tools.rs. | ? | +| M2b/04-loopd-baseline | 2026-09-18 | done | 2 | fail | none | Wrote crates/loopd/src/baseline.rs: `Baseline` (system prompt + core tool schemas, `deny_unknown_fields`), `BaselineError` (Read/Parse name the file, plus Hash) with Display/std::error::Error, `assemble` (system prompt trimmed of trailing whitespace, core memory appended with a blank line when its trimmed content is non-empty), `to_json`/`from_json`, `load`, and `hash` (sha256 of the canonical JSON). `messages` prepends the system message and replays every `LogRecord` variant explicitly named, so a new one is a compile error. The `\n\n` separator between system prompt and core memory had to be two newlines (a blank line), not one. All 6 baseline tests pass and all loopd tests pass with the new support module; first gate run failed on a rustfmt import-order diff, fixed with `cargo fmt`. | ? | ## Reviews