Files
boxmaker/crates/loopd/src/baseline.rs
T
kyleandornith-1.5-35b-a3b b4d631e249 loopd: neutralise user messages, tool results and the memory block
Text from outside the model (the user's message, every tool result, and the
memory block) is neutralised the moment it is first logged, so every later
request replays the same bytes and a tag in that text cannot reach the model as
one of its own control tokens. The model's own output, replay, and the system
prompt are left byte-for-byte.

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
2026-09-27 19:08:42 -07:00

131 lines
4.7 KiB
Rust

//! The baseline: the fixed prefix of every request in an epoch — the system prompt, the memory
//! block 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 bxcore::sanitize::neutralise;
use bxcore::{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, with no memory: for tests and the self-test.
pub fn assemble(cfg: &Config, registry: &Registry) -> Result<Baseline, BaselineError> {
Baseline::assemble_with(cfg, registry, "")
}
/// The system prompt trimmed, then a blank line and the memory block `memoryd` rendered (M5a
/// spec, section 4), when there is one; the core tool schemas. The block is neutralised here
/// (M5b spec, section 8): `soul.md` is not checked on write, and the snapshot of the baseline
/// is what every request of the session sends.
pub fn assemble_with(
cfg: &Config,
registry: &Registry,
memory: &str,
) -> 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 memory = neutralise(memory.trim());
if !memory.is_empty() {
system.push_str("\n\n");
system.push_str(&memory);
}
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
}