Files
boxmaker/crates/loopd/src/selftest.rs
T
kyleandClaude Opus 5.5 5a2464d81d loopd: the tag check asks /tokenize twice, not once per tag
Found in the M5b device check on straylight: the self-test asked about
each of the 33 tags and each neutralised form, 66 requests, one
connection each, and inferproxy's token bucket (burst 10) refused the
ninth, so loopd would not start. Now it asks about all the tags at once,
one per line, and then all their neutralised forms: the answer to the
first must hold each tag's recorded token in order, the second none of
them. Each tag is a special token, split out before anything else, so
the lines between cannot change it; and a neutralised form is only
dangerous as a tag's own token, so that is what the second answer must
not hold. The errors name a tag by its id, as before.

The fake tokenizer reads text with several tags in it; the tests cover
each tag as ordinary tokens, as another listed or unlisted token, a
duplicated token in the middle and at the end, a form holding each tag,
and both requests failing. Spec section 8 and the runbook entry follow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-27 19:08:42 -07:00

306 lines
10 KiB
Rust

//! The startup self-test: before `loopd` serves anyone, it checks that the server is the one its
//! config describes, that the model's tags tokenize as recorded, that a tool call comes back
//! parsed, and that a second turn reuses the first turn's cache. Any check failing means `loopd`
//! does not start.
use bxcore::sanitize::{TAGS, neutralise};
use crate::llama::{
ChatMessage, ChatRequest, Client, FinishReason, InferError, ToolSchema,
info::{CacheOutcome, cache_outcome},
};
#[derive(Debug)]
pub enum SelfTestError {
Mismatch {
what: &'static str,
expected: String,
got: String,
},
ToolCall(String),
CacheMiss {
expected: u64,
got: u64,
},
Infer(InferError),
Hash,
/// The first tag, by its recorded id, whose token is not in its place in the server's answer
/// about the tags (or, past the last tag, an extra tag token the server gave).
TagNotRecorded {
id: u32,
},
/// The server's answer about the neutralised forms holds the tag token `id`.
NeutralisedIsTag {
id: u32,
},
/// `/tokenize` failed; `what` is "the tags" or "the neutralised tags".
Tokenize {
what: &'static str,
error: InferError,
},
}
impl SelfTestError {
/// The runbook pointer that ends the line `loopd` prints for this failure. A tag failure has
/// its own entry; every other failure shares the self-test's.
pub fn pointer(&self) -> &'static str {
match self {
SelfTestError::TagNotRecorded { .. }
| SelfTestError::NeutralisedIsTag { .. }
| SelfTestError::Tokenize { .. } => "see docs/runbook.md#loopd-tags-unexpected",
SelfTestError::Mismatch { .. }
| SelfTestError::ToolCall(_)
| SelfTestError::CacheMiss { .. }
| SelfTestError::Infer(_)
| SelfTestError::Hash => "see docs/runbook.md#loopd-selftest-failed",
}
}
}
impl std::fmt::Display for SelfTestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SelfTestError::Mismatch {
what,
expected,
got,
} => write!(f, "{what}: expected {expected}, got {got}"),
SelfTestError::ToolCall(reason) => {
write!(f, "the tool call did not come back parsed: {reason}")
}
SelfTestError::CacheMiss { expected, got } => write!(
f,
"turn 2 did not reuse turn 1's cache: expected {expected}, saw {got}"
),
SelfTestError::Infer(e) => write!(f, "{e}"),
SelfTestError::Hash => write!(f, "the chat template could not be hashed"),
SelfTestError::TagNotRecorded { id } => {
write!(
f,
"the tag with id {id} does not tokenize as its recorded token"
)
}
SelfTestError::NeutralisedIsTag { id } => {
write!(f, "a neutralised tag tokenizes to the tag with id {id}")
}
SelfTestError::Tokenize { what, error } => {
write!(f, "could not tokenize {what}: {error}")
}
}
}
}
impl std::error::Error for SelfTestError {}
impl From<InferError> for SelfTestError {
fn from(e: InferError) -> Self {
SelfTestError::Infer(e)
}
}
const SYSTEM: &str = "You are Boxmaker, a careful personal agent.";
/// Runs the four checks in order. `on_step` is told the name of each check as it starts.
pub fn run(client: &Client, on_step: &mut dyn FnMut(&str)) -> Result<(), SelfTestError> {
on_step("server matches config");
server_matches(client)?;
on_step("tags tokenize as recorded");
tags_tokenize(client)?;
on_step("tool call round trip");
tool_call_round_trip(client)?;
on_step("turn 2 cache hit");
cache_hit(client)?;
Ok(())
}
/// Check 1: the server's chat template hash and slot settings are the ones the config expects.
/// A server that is not the expected one is sent no prompt at all.
fn server_matches(client: &Client) -> Result<(), SelfTestError> {
let props = client.props().map_err(SelfTestError::Infer)?;
let expect = &client.config().expect;
let expected = expect.template_sha256;
let got = bxcore::sha256(props.chat_template.as_bytes()).map_err(|_| SelfTestError::Hash)?;
if expected != got {
return Err(SelfTestError::Mismatch {
what: "chat template sha256",
expected: expected.to_hex(),
got: got.to_hex(),
});
}
if expect.n_ctx != props.n_ctx {
return Err(SelfTestError::Mismatch {
what: "context per slot",
expected: expect.n_ctx.to_string(),
got: props.n_ctx.to_string(),
});
}
if expect.slots != props.total_slots {
return Err(SelfTestError::Mismatch {
what: "slot count",
expected: expect.slots.to_string(),
got: props.total_slots.to_string(),
});
}
Ok(())
}
/// Check 2 (M5b spec, section 8): asked about every tag of `bxcore::sanitize::TAGS` at once, one
/// per line, the server gives each tag's recorded token, in the list's order; asked about every
/// neutralised form, it gives none of those tokens. Each tag is a special token, which the
/// tokenizer splits out before anything else, so the lines between cannot change it. Two requests,
/// so that the check fits in `inferproxy`'s burst of connections. Like check 1 it sends no prompt
/// and uses no slot. A tag missing from the list cannot be found: the server does not list them.
/// An error names a tag by its id, never by its text, which the operator's terminal and log would
/// show whole.
fn tags_tokenize(client: &Client) -> Result<(), SelfTestError> {
let recorded: Vec<u32> = TAGS.iter().map(|tag| tag.id).collect();
let tags: Vec<&str> = TAGS.iter().map(|tag| tag.text).collect();
let got: Vec<u32> = client
.token_ids(&tags.join("\n"))
.map_err(|error| SelfTestError::Tokenize {
what: "the tags",
error,
})?
.into_iter()
.filter(|id| recorded.contains(id))
.collect();
let places = recorded.len().max(got.len());
if let Some(at) = (0..places).find(|&at| got.get(at) != recorded.get(at)) {
let id = recorded
.get(at)
.or(got.get(at))
.copied()
.unwrap_or_default();
return Err(SelfTestError::TagNotRecorded { id });
}
let forms: Vec<String> = TAGS
.iter()
.map(|tag| neutralise(tag.text).into_owned())
.collect();
let got = client
.token_ids(&forms.join("\n"))
.map_err(|error| SelfTestError::Tokenize {
what: "the neutralised tags",
error,
})?;
match got.into_iter().find(|id| recorded.contains(id)) {
Some(id) => Err(SelfTestError::NeutralisedIsTag { id }),
None => Ok(()),
}
}
/// Check 3: a tool call comes back parsed, with a `read_file` tool and a string `path`.
fn tool_call_round_trip(client: &Client) -> Result<(), SelfTestError> {
let req = ChatRequest {
slot: client.config().slots.main,
messages: vec![
ChatMessage::System {
content: SYSTEM.to_string(),
},
ChatMessage::User {
content: "Read /etc/hostname and tell me what it says.".to_string(),
},
],
tools: vec![ToolSchema {
name: "read_file".to_string(),
description: "Read a text file and return its contents.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": { "path": { "type": "string", "description": "Absolute path" } },
"required": ["path"],
}),
}],
thinking: false,
};
let completion = client
.chat_with_retry(&req, &mut |_| {})
.map_err(SelfTestError::Infer)?;
if completion.finish_reason != FinishReason::ToolCalls {
return Err(SelfTestError::ToolCall(
"the completion did not end with a tool call".to_string(),
));
}
let Some(call) = completion.tool_calls.first() else {
return Err(SelfTestError::ToolCall(
"the completion made no tool call".to_string(),
));
};
if call.name != "read_file" {
return Err(SelfTestError::ToolCall(format!(
"the first tool call was {}, not read_file",
call.name
)));
}
let arguments: serde_json::Value = serde_json::from_str(&call.arguments).map_err(|_| {
SelfTestError::ToolCall("the tool call arguments were not valid JSON".to_string())
})?;
if arguments.get("path").and_then(|v| v.as_str()).is_none() {
return Err(SelfTestError::ToolCall(
"the tool call arguments had no string path".to_string(),
));
}
Ok(())
}
/// Check 4: a second turn that extends the first reuses the first turn's cache.
fn cache_hit(client: &Client) -> Result<(), SelfTestError> {
let first = ChatRequest {
slot: client.config().slots.main,
messages: vec![
ChatMessage::System {
content: SYSTEM.to_string(),
},
ChatMessage::User {
content: "Name one colour. One word.".to_string(),
},
],
tools: Vec::new(),
thinking: false,
};
let turn1 = client
.chat_with_retry(&first, &mut |_| {})
.map_err(SelfTestError::Infer)?;
let mut messages = vec![
ChatMessage::System {
content: SYSTEM.to_string(),
},
ChatMessage::User {
content: "Name one colour. One word.".to_string(),
},
];
messages.push(ChatMessage::Assistant {
content: turn1.content,
reasoning_content: turn1.reasoning_content,
tool_calls: turn1.tool_calls,
});
messages.push(ChatMessage::User {
content: "Name another. One word.".to_string(),
});
let second = ChatRequest {
slot: client.config().slots.main,
messages,
tools: Vec::new(),
thinking: false,
};
let turn2 = client
.chat_with_retry(&second, &mut |_| {})
.map_err(SelfTestError::Infer)?;
match cache_outcome(&turn1.timings, &turn2.timings) {
CacheOutcome::Hit => Ok(()),
CacheOutcome::Loss { expected, got } => Err(SelfTestError::CacheMiss { expected, got }),
}
}