Add the baseline and the log replay function
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -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<ToolSchema>,
|
||||
}
|
||||
|
||||
#[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<Baseline, BaselineError> {
|
||||
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<String, serde_json::Error> {
|
||||
serde_json::to_string(self)
|
||||
}
|
||||
|
||||
pub fn from_json(text: &str) -> Result<Baseline, serde_json::Error> {
|
||||
serde_json::from_str(text)
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Baseline, BaselineError> {
|
||||
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<Hash32, BaselineError> {
|
||||
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<ChatMessage> {
|
||||
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
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<proto::LogRecord> {
|
||||
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<VecDeque<proto::ToolResponse>>,
|
||||
calls: Mutex<Vec<proto::ToolRequest>>,
|
||||
}
|
||||
|
||||
impl ScriptedPort {
|
||||
/// Replies are given in order; when they run out, every call gets `fallback`.
|
||||
pub fn new(replies: Vec<proto::ToolResponse>) -> ScriptedPort {
|
||||
ScriptedPort {
|
||||
replies: Mutex::new(replies.into()),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calls(&self) -> Vec<proto::ToolRequest> {
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user