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>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent ca8b50cd69
commit b4d631e249
6 changed files with 621 additions and 4 deletions
+6 -3
View File
@@ -7,6 +7,7 @@ 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;
@@ -46,7 +47,9 @@ impl Baseline {
}
/// 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.
/// 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,
@@ -55,10 +58,10 @@ impl Baseline {
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 = memory.trim();
let memory = neutralise(memory.trim());
if !memory.is_empty() {
system.push_str("\n\n");
system.push_str(memory);
system.push_str(&memory);
}
Ok(Baseline {
system,
+9 -1
View File
@@ -1,6 +1,9 @@
//! One turn: ask, run any tool calls under their limits, and return.
//! The loop is bounded every way a runaway can go: a cap on tool iterations, a repeated-call
//! detector, the two M2a limits (thinking tokens, a full context), and a tool-result cap.
//! Text from outside (the user's message, every tool result) is neutralised as it is first
//! logged (M5b spec, section 8), so every later request replays the same bytes; the model's own
//! output is logged as the server returned it.
use crate::baseline::messages;
use crate::config::Config;
@@ -8,6 +11,7 @@ use crate::llama::info::{CacheOutcome, cache_outcome};
use crate::llama::{ChatEvent, ChatRequest, Client, InferError};
use crate::session::{Session, SessionError};
use crate::tools::{Registry, ToolPort, cap_result, denial_message, dispatch};
use bxcore::sanitize::neutralise;
use bxcore::{
CallId, DataClass, LogRecord, Timestamp, ToolRequest, ToolResponse, TurnEvent, Usage,
};
@@ -118,7 +122,7 @@ pub fn run_turn(
session
.append(LogRecord::User {
time: Timestamp::now(),
content: content.to_string(),
content: neutralise(content).into_owned(),
})
.map_err(TurnError::Session)?;
@@ -235,7 +239,11 @@ pub fn run_turn(
run_call(rt, session, call, call_id, on_event)
};
// Capped first, then neutralised: the logged text may be a few bytes over the cap,
// and `truncated` says only whether the cap cut it. Denials and failures take this
// path too, since they reach the model as tool messages as well.
let (text, truncated) = cap_result(&text, rt.cfg.r#loop.tool_result_cap);
let text = neutralise(&text).into_owned();
session
.append(LogRecord::ToolResult {
time: Timestamp::now(),