Files
boxmaker/crates/loopd/src/baseline.rs
T
kyle 469be2c0a1 Add BrokerPort: loopd asks brokerd for every tool call
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-20 19:40:35 -07:00

137 lines
4.9 KiB
Rust

//! 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,
Core(PathBuf, std::io::Error),
}
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"),
BaselineError::Core(path, err) => write!(
f,
"{}: {err}; see docs/runbook.md#core-memory-unreadable",
path.display()
),
}
}
}
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");
match std::fs::read_to_string(&core) {
Ok(text) => {
let trimmed = text.trim();
if !trimmed.is_empty() {
system.push_str("\n\n");
system.push_str(trimmed);
}
}
// A missing file is fine; an unreadable one is reported so the owner is not given a
// session without the memory they curated and no sign of it.
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(BaselineError::Core(core.clone(), e));
}
Err(_) => {}
}
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
}