From 6e8e24794df0038e20a3a5219a54e6979aeec2ce Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Thu, 17 Sep 2026 23:07:38 -0700 Subject: [PATCH] Add loopd configuration with every M2a limit Implemented-By: Laguna S 2.1 (OpenCode) --- Cargo.lock | 3 + crates/loopd/Cargo.toml | 3 + crates/loopd/src/config.rs | 123 +++++++++++++++ crates/loopd/src/lib.rs | 2 + crates/loopd/tests/config.rs | 143 ++++++++++++++++++ crates/loopd/tests/fixtures/config/full.toml | 31 ++++ .../loopd/tests/fixtures/config/minimal.toml | 12 ++ docs/implementer-log.md | 1 + 8 files changed, 318 insertions(+) create mode 100644 crates/loopd/src/config.rs create mode 100644 crates/loopd/tests/config.rs create mode 100644 crates/loopd/tests/fixtures/config/full.toml create mode 100644 crates/loopd/tests/fixtures/config/minimal.toml diff --git a/Cargo.lock b/Cargo.lock index 02664ee..f8d636d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,9 @@ name = "loopd" version = "0.1.0" dependencies = [ "proto", + "serde", + "serde_json", + "toml", ] [[package]] diff --git a/crates/loopd/Cargo.toml b/crates/loopd/Cargo.toml index 9c812db..11c0007 100644 --- a/crates/loopd/Cargo.toml +++ b/crates/loopd/Cargo.toml @@ -10,3 +10,6 @@ workspace = true [dependencies] proto.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true diff --git a/crates/loopd/src/config.rs b/crates/loopd/src/config.rs new file mode 100644 index 0000000..c4556f6 --- /dev/null +++ b/crates/loopd/src/config.rs @@ -0,0 +1,123 @@ +//! `loopd` configuration: read `config.toml` into a typed `Config`. +//! +//! This is our own format, so unknown keys are errors in every table: a +//! misspelt `liveness_ms` that silently fell back to its default would be a +//! limit the owner believes is set and is not. + +use serde::Deserialize; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Infer { + pub socket: std::path::PathBuf, + pub model: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Slots { + pub main: u32, + pub background: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Expect { + pub template_sha256: proto::Hash32, + pub n_ctx: u64, + pub slots: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Sampling { + pub temperature: f64, + pub top_p: f64, + pub top_k: u32, +} + +impl Default for Sampling { + fn default() -> Self { + Self { + temperature: 0.6, + top_p: 0.95, + top_k: 20, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Limits { + pub poll_ms: u64, + pub busy_wait_ms: u64, + pub load_wait_ms: u64, + pub idle_grace_ms: u64, + pub liveness_ms: u64, + pub thinking_cap: u64, + pub thinking_overrun: u64, + pub max_tokens: u64, + pub queue_len: usize, + pub retry_attempts: u32, + pub retry_backoff_ms: Vec, + pub retry_window_ms: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { + 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, + } + } +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub infer: Infer, + pub slots: Slots, + pub expect: Expect, + #[serde(default)] + pub sampling: Sampling, + #[serde(default)] + pub limits: Limits, +} + +#[derive(Debug)] +pub enum ConfigError { + Read(std::path::PathBuf, std::io::Error), + Parse(std::path::PathBuf, toml::de::Error), +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + ConfigError::Read(path, err) => write!(f, "{}: {err}", path.display()), + ConfigError::Parse(path, err) => write!(f, "{}: {err}", path.display()), + } + } +} + +impl std::error::Error for ConfigError {} + +impl Config { + pub fn parse(text: &str) -> Result { + toml::from_str(text) + } + pub fn load(path: &std::path::Path) -> Result { + let text = + std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?; + toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e)) + } +} diff --git a/crates/loopd/src/lib.rs b/crates/loopd/src/lib.rs index 6d2a343..ba0b25b 100644 --- a/crates/loopd/src/lib.rs +++ b/crates/loopd/src/lib.rs @@ -1 +1,3 @@ //! The agent loop: sessions, prompt assembly and memory. It holds no authority. + +pub mod config; diff --git a/crates/loopd/tests/config.rs b/crates/loopd/tests/config.rs new file mode 100644 index 0000000..2eaeb3c --- /dev/null +++ b/crates/loopd/tests/config.rs @@ -0,0 +1,143 @@ +//! 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::>() + .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 = Box::new(Config::load(&missing).unwrap_err()); + assert!(e.to_string().contains("does-not-exist.toml")); +} diff --git a/crates/loopd/tests/fixtures/config/full.toml b/crates/loopd/tests/fixtures/config/full.toml new file mode 100644 index 0000000..e643222 --- /dev/null +++ b/crates/loopd/tests/fixtures/config/full.toml @@ -0,0 +1,31 @@ +[infer] +socket = "/tmp/infer.sock" +model = "some-model" + +[slots] +main = 2 +background = 3 + +[expect] +template_sha256 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +n_ctx = 4096 +slots = 4 + +[sampling] +temperature = 0.2 +top_p = 0.9 +top_k = 40 + +[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 = [10] +retry_window_ms = 1000 diff --git a/crates/loopd/tests/fixtures/config/minimal.toml b/crates/loopd/tests/fixtures/config/minimal.toml new file mode 100644 index 0000000..28449e1 --- /dev/null +++ b/crates/loopd/tests/fixtures/config/minimal.toml @@ -0,0 +1,12 @@ +[infer] +socket = "/run/boxmaker/infer/infer.sock" +model = "ornith-1.5-35b-a3b" + +[slots] +main = 0 +background = 1 + +[expect] +template_sha256 = "f55f52930aa8bf44ab5cb85f99370fcc3c56e9a85640b812086d5330bce5d86b" +n_ctx = 131072 +slots = 2 diff --git a/docs/implementer-log.md b/docs/implementer-log.md index ab7e21e..c2a5663 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -16,6 +16,7 @@ reviewer adds findings under "Reviews" once per milestone. | M1/09-gate-scripts-table-form | 2026-09-17 | done | 1 | pass | none | Rewrote check-lines, check-crate-deps and check-dep-docs to parse table-form (`[dependencies.x]`) and dotted (`x.path`) dependencies and to fail closed (exit 1 when crates/Cargo.toml/docs/dependencies.md is missing); check-lines now prints `file has N lines (limit 500)`. Self-test passes with 0 failures, all three scripts pass on the real tree, and `make gate` prints `gate: ok`. | | M2a/01-proto-sha256 | 2026-09-17 | done | 1 | pass | none | Added crates/proto/src/hash.rs wrapping emsha 1.0.4 (HashError, Sha256 with new/update/finish, sha256, Default); re-exported from lib.rs, added emsha workspace dep and dependencies.md row. One compile fix: finish needed `mut self` to call finalize. 4 hash tests pass, `make gate` prints `gate: ok`. | | M2a/02-inferproxy | 2026-09-17 | done | 3 | fail | none | Implemented Limits/TokenBucket/OpenGuard/serve/handle/forward in lib.rs (std only: the inference server's JSON is upstream's concern) and arg parsing + stale-socket handling in main.rs; TokenBucket counts thousandths of a token in u128 so partial refills stay additive and `saturating_duration_since` on an earlier `now` never underflows, capping at burst; serve refuses by rate-limit then open-connection limit; handle drops its OpenGuard before closing the client so a freshly-finished client is not refused on re-connect (rule 4). The scaffold `proto` dependency in `crates/inferproxy/Cargo.toml` was left untouched since the task's Modify/Copy lists cover only lib.rs, main.rs, the two test files and this log. First `make gate` failed on `clippy::map_clone` (`main.rs` used `.map(String::clone)`); switched to `.cloned()` and re-ran, then re-ran once more after restoring the proto scaffold — both pass. Hand test against straylight returned `{"status":"ok"}`; `forward.rs` passed 6/6 ten runs in a row. | +| M2a/03-loopd-config | 2026-09-17 | done | 1 | pass | none | Wrote crates/loopd/src/config.rs: Config + Infer/Slots/Expect/Sampling/Limits with deny_unknown_fields on all six and struct-level `#[serde(deny_unknown_fields, default)]` on Sampling and Limits; manual Default impls for the two; hand-written Display/std::error::Error ConfigError named by file. Everywhere check: all six structs (Infer, Slots, Expect, Sampling, Limits, Config) carry deny_unknown_fields. One local fix: Display used `{path}` on a PathBuf and failed to build, switched to `path.display()`. 6 config tests pass; `make gate` prints `gate: ok`. | ## Reviews