The tasks build the agent loop on M2a's client: channel messages and the usage record in proto, four config tables, the tool port and registry with find_tool and call_tool, the baseline and replay, the session store, the turn loop with its limits and the append-only property test, the channel server, loopd serve, bxctl chat, and the device checks including a four-turn conversation with a restart. Checked against a private reference implementation: the gate passes after every task in order, the new suites pass under CPU load, and the reference passes make verify-device on straylight with no cache loss. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
227 lines
7.1 KiB
Rust
227 lines
7.1 KiB
Rust
//! Tests for `loopd`'s configuration. Do not edit: these define the required behaviour.
|
|
|
|
use loopd::config::{Config, ConfigError, Limits, Sampling};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
fn fixture(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/fixtures/config")
|
|
.join(name)
|
|
}
|
|
|
|
#[test]
|
|
fn minimal_file_gets_the_documented_defaults() {
|
|
let c = Config::load(&fixture("minimal.toml")).unwrap();
|
|
assert_eq!(
|
|
c.infer.socket,
|
|
PathBuf::from("/run/boxmaker/infer/infer.sock")
|
|
);
|
|
assert_eq!(c.infer.model, "ornith-1.5-35b-a3b");
|
|
assert_eq!((c.slots.main, c.slots.background), (0, 1));
|
|
assert_eq!(c.expect.n_ctx, 131_072);
|
|
assert_eq!(c.expect.slots, 2);
|
|
assert_eq!(
|
|
c.expect.template_sha256.to_hex(),
|
|
"f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b"
|
|
);
|
|
assert_eq!(
|
|
c.sampling,
|
|
Sampling {
|
|
temperature: 0.6,
|
|
top_p: 0.95,
|
|
top_k: 20
|
|
}
|
|
);
|
|
let want = Limits {
|
|
poll_ms: 5_000,
|
|
busy_wait_ms: 600_000,
|
|
load_wait_ms: 180_000,
|
|
idle_grace_ms: 30_000,
|
|
liveness_ms: 30_000,
|
|
thinking_cap: 4_096,
|
|
thinking_overrun: 256,
|
|
max_tokens: 8_192,
|
|
queue_len: 8,
|
|
retry_attempts: 4,
|
|
retry_backoff_ms: vec![2_000, 8_000, 30_000],
|
|
retry_window_ms: 300_000,
|
|
};
|
|
assert_eq!(c.limits, want);
|
|
assert_eq!(Limits::default(), want);
|
|
}
|
|
|
|
#[test]
|
|
fn full_file_overrides_every_default() {
|
|
let c = Config::load(&fixture("full.toml")).unwrap();
|
|
assert_eq!(
|
|
c.sampling,
|
|
Sampling {
|
|
temperature: 0.2,
|
|
top_p: 0.9,
|
|
top_k: 40
|
|
}
|
|
);
|
|
let want = Limits {
|
|
poll_ms: 50,
|
|
busy_wait_ms: 200,
|
|
load_wait_ms: 300,
|
|
idle_grace_ms: 150,
|
|
liveness_ms: 100,
|
|
thinking_cap: 20,
|
|
thinking_overrun: 10,
|
|
max_tokens: 512,
|
|
queue_len: 1,
|
|
retry_attempts: 2,
|
|
retry_backoff_ms: vec![10],
|
|
retry_window_ms: 1_000,
|
|
};
|
|
assert_eq!(c.limits, want);
|
|
}
|
|
|
|
#[test]
|
|
fn a_partial_limits_table_keeps_the_other_defaults() {
|
|
let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap();
|
|
let c = Config::parse(&format!("{text}\n[limits]\nliveness_ms = 1234\n")).unwrap();
|
|
assert_eq!(c.limits.liveness_ms, 1234);
|
|
assert_eq!(c.limits.poll_ms, 5_000);
|
|
}
|
|
|
|
/// Every table in the file must reject a key it does not know: a misspelt limit that silently
|
|
/// fell back to its default would be a limit the owner believes is set and is not.
|
|
#[test]
|
|
fn unknown_keys_are_errors_in_every_table() {
|
|
let text = std::fs::read_to_string(fixture("full.toml")).unwrap();
|
|
assert!(Config::parse(&text).is_ok());
|
|
for table in ["infer", "slots", "expect", "sampling", "limits"] {
|
|
let header = format!("[{table}]\n");
|
|
assert!(text.contains(&header), "fixture has no [{table}] table");
|
|
let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1);
|
|
assert!(
|
|
Config::parse(&bad).is_err(),
|
|
"[{table}] accepted an unknown key"
|
|
);
|
|
}
|
|
assert!(
|
|
Config::parse(&format!("zz_unknown = 1\n{text}")).is_err(),
|
|
"top level"
|
|
);
|
|
assert!(
|
|
Config::parse(&format!("{text}\n[zz_unknown]\nx = 1\n")).is_err(),
|
|
"unknown table"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn required_tables_and_values_are_checked() {
|
|
let text = std::fs::read_to_string(fixture("minimal.toml")).unwrap();
|
|
for table in ["infer", "slots", "expect"] {
|
|
let without: String = text
|
|
.split("\n\n")
|
|
.filter(|block| !block.trim_start().starts_with(&format!("[{table}]")))
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n");
|
|
assert!(Config::parse(&without).is_err(), "[{table}] is required");
|
|
}
|
|
let bad_hash = text.replace("f55f5293", "F55F5293");
|
|
assert!(
|
|
Config::parse(&bad_hash).is_err(),
|
|
"the hash must be lowercase hex"
|
|
);
|
|
let negative = text.replace("main = 0", "main = -1");
|
|
assert!(Config::parse(&negative).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn load_reports_which_file_failed() {
|
|
let missing = fixture("does-not-exist.toml");
|
|
match Config::load(&missing) {
|
|
Err(ConfigError::Read(path, _)) => assert_eq!(path, missing),
|
|
other => panic!("expected a read error, got {other:?}"),
|
|
}
|
|
let e: Box<dyn std::error::Error> = Box::new(Config::load(&missing).unwrap_err());
|
|
assert!(e.to_string().contains("does-not-exist.toml"));
|
|
}
|
|
|
|
// ---- M2b: paths, channel, loop and baseline ----
|
|
|
|
#[test]
|
|
fn the_m2b_tables_have_defaults() {
|
|
let c = Config::load(&fixture("minimal.toml")).unwrap();
|
|
assert_eq!(
|
|
c.channel.socket,
|
|
PathBuf::new(),
|
|
"empty means: under the home"
|
|
);
|
|
assert_eq!(c.channel_socket(), c.paths.home.join("run/loop/loop.sock"));
|
|
assert_eq!(
|
|
(
|
|
c.r#loop.tool_iterations,
|
|
c.r#loop.repeat_detection,
|
|
c.r#loop.tool_result_cap
|
|
),
|
|
(8, true, 16 * 1024)
|
|
);
|
|
// A relative system prompt is taken from the config file's directory.
|
|
assert_eq!(c.baseline.system, fixture("system.md"));
|
|
// The home comes from BOXMAKER_HOME when set, else /var/lib/boxmaker. Either way it is absolute.
|
|
assert!(c.paths.home.is_absolute(), "{:?}", c.paths.home);
|
|
}
|
|
|
|
#[test]
|
|
fn the_m2b_tables_can_be_set() {
|
|
let c = Config::load(&fixture("m2b.toml")).unwrap();
|
|
assert_eq!(c.paths.home, PathBuf::from("/srv/boxmaker"));
|
|
assert_eq!(
|
|
c.channel.socket,
|
|
PathBuf::from("/run/boxmaker/loop/loop.sock")
|
|
);
|
|
assert_eq!(
|
|
c.channel_socket(),
|
|
PathBuf::from("/run/boxmaker/loop/loop.sock")
|
|
);
|
|
assert_eq!(
|
|
(
|
|
c.r#loop.tool_iterations,
|
|
c.r#loop.repeat_detection,
|
|
c.r#loop.tool_result_cap
|
|
),
|
|
(3, false, 1024)
|
|
);
|
|
assert_eq!(
|
|
c.baseline.system,
|
|
fixture("prompts/agent.md"),
|
|
"relative to the config file"
|
|
);
|
|
let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
|
|
let absolute = text.replace("prompts/agent.md", "/etc/boxmaker/system.md");
|
|
assert_eq!(
|
|
Config::parse(&absolute).unwrap().baseline.system,
|
|
PathBuf::from("/etc/boxmaker/system.md")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_keys_are_errors_in_the_m2b_tables_too() {
|
|
let text = std::fs::read_to_string(fixture("m2b.toml")).unwrap();
|
|
assert!(Config::parse(&text).is_ok());
|
|
for table in ["paths", "channel", "loop", "baseline"] {
|
|
let header = format!("[{table}]\n");
|
|
let bad = text.replacen(&header, &format!("{header}zz_unknown = 1\n"), 1);
|
|
assert!(
|
|
Config::parse(&bad).is_err(),
|
|
"[{table}] accepted an unknown key"
|
|
);
|
|
}
|
|
let partial = text.replace("tool_iterations = 3\nrepeat_detection = false\n", "");
|
|
let c = Config::parse(&partial).unwrap();
|
|
assert_eq!(
|
|
(
|
|
c.r#loop.tool_iterations,
|
|
c.r#loop.repeat_detection,
|
|
c.r#loop.tool_result_cap
|
|
),
|
|
(8, true, 1024),
|
|
"a partial table keeps the other defaults"
|
|
);
|
|
}
|