From ec5d887daa11e69a57977e0eb826921b36caf37a Mon Sep 17 00:00:00 2001 From: "K. Isom" Date: Fri, 18 Sep 2026 17:36:20 -0700 Subject: [PATCH] Add the paths, channel, loop and baseline config tables Implemented-By: OpenCode session (model recorded in docs/implementer-log.md) --- crates/loopd/src/config.rs | 76 ++++++++++++++++++- crates/loopd/tests/config.rs | 83 +++++++++++++++++++++ crates/loopd/tests/fixtures/config/m2b.toml | 26 +++++++ docs/implementer-log.md | 1 + 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 crates/loopd/tests/fixtures/config/m2b.toml diff --git a/crates/loopd/src/config.rs b/crates/loopd/src/config.rs index c4556f6..d09bb5f 100644 --- a/crates/loopd/src/config.rs +++ b/crates/loopd/src/config.rs @@ -82,6 +82,60 @@ impl Default for Limits { } } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)] +#[serde(deny_unknown_fields, default)] +pub struct Channel { + pub socket: std::path::PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Paths { + pub home: std::path::PathBuf, +} + +impl Default for Paths { + fn default() -> Self { + Self { + home: std::env::var_os("BOXMAKER_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("/var/lib/boxmaker")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Loop { + pub tool_iterations: u32, + pub repeat_detection: bool, + pub tool_result_cap: usize, +} + +impl Default for Loop { + fn default() -> Self { + Self { + tool_iterations: 8, + repeat_detection: true, + tool_result_cap: 16_384, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct Baseline { + pub system: std::path::PathBuf, +} + +impl Default for Baseline { + fn default() -> Self { + Self { + system: std::path::PathBuf::from("system.md"), + } + } +} + #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Config { @@ -92,6 +146,14 @@ pub struct Config { pub sampling: Sampling, #[serde(default)] pub limits: Limits, + #[serde(default)] + pub paths: Paths, + #[serde(default)] + pub channel: Channel, + #[serde(default)] + pub r#loop: Loop, + #[serde(default)] + pub baseline: Baseline, } #[derive(Debug)] @@ -118,6 +180,18 @@ impl Config { 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)) + let mut config: Config = + toml::from_str(&text).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?; + if let Some(parent) = path.parent() { + config.baseline.system = parent.join(&config.baseline.system); + } + Ok(config) + } + pub fn channel_socket(&self) -> std::path::PathBuf { + if self.channel.socket.as_os_str().is_empty() { + self.paths.home.join("run/loop/loop.sock") + } else { + self.channel.socket.clone() + } } } diff --git a/crates/loopd/tests/config.rs b/crates/loopd/tests/config.rs index 2eaeb3c..69318c9 100644 --- a/crates/loopd/tests/config.rs +++ b/crates/loopd/tests/config.rs @@ -141,3 +141,86 @@ fn load_reports_which_file_failed() { let e: Box = 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" + ); +} diff --git a/crates/loopd/tests/fixtures/config/m2b.toml b/crates/loopd/tests/fixtures/config/m2b.toml new file mode 100644 index 0000000..76571fa --- /dev/null +++ b/crates/loopd/tests/fixtures/config/m2b.toml @@ -0,0 +1,26 @@ +[infer] +socket = "/tmp/infer.sock" +model = "some-model" + +[slots] +main = 0 +background = 1 + +[expect] +template_sha256 = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" +n_ctx = 4096 +slots = 2 + +[paths] +home = "/srv/boxmaker" + +[channel] +socket = "/run/boxmaker/loop/loop.sock" + +[loop] +tool_iterations = 3 +repeat_detection = false +tool_result_cap = 1024 + +[baseline] +system = "prompts/agent.md" diff --git a/docs/implementer-log.md b/docs/implementer-log.md index bcdeb69..0a41c8b 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -31,6 +31,7 @@ reviewer adds findings under "Reviews" once per milestone. | M2a/14-inferproxy-close | 2026-09-18 | done | 2 | fail | none | Made the proxy close towards the client as soon as the server-to-client copy ends, for any reason. `forward` now joins only the s2c thread and returns the c2s JoinHandle, so it returns when the server stops sending instead of waiting for the client to stop sending too; `handle` drops the OpenGuard inside a block scope, then shuts down the client (Both) and server (Both) so the client's read returns EOF at once and the c2s thread ends, then joins c2s. This is rule 4 of task 02 (drop the open-place before closing the client). The half-close when the client stops sending first is unchanged. The copied `forward.rs` is byte-identical to the plan. 7 passed ten runs in a row; `make gate` prints `gate: ok`. | ? | Wrote no library code. Put the two given files in place (`crates/loopd/tests/device.rs`, and a `Makefile` whose only difference from the old one is the new `verify-device` target), confirmed `make gate` prints `gate: ok` with `device` at `0 passed; 0 failed; 4 ignored`, and `curl http://straylight:11434/health` returned `{"status":"ok"}`. `make verify-device` ran the four `#[ignore]` checks one at a time against the real server on slot 0: self-test, capped thinking block, a request surviving its own proxy being killed and restarted, and a second turn reusing the cache of a first turn that contained thinking. All 4 passed in ~21s (well under two minutes). | ? | Wrote crates/loopd/src/selftest.rs (SelfTestError with Display/std::error::Error/From, and `run` doing the three checks in order) and registered `pub mod selftest;` in lib.rs; rewrote main.rs into `loopd selftest --config `. Check 1 calls props() and compares chat-template sha256, then n_ctx, then total_slots, a sha256 error being Hash so a wrong server is sent no prompt; check 2 posts one read_file tool with chat_with_retry and requires finish_reason ToolCalls, a first call named read_file whose arguments parse as JSON carrying a string path, wrapping an InferError as Infer; check 3 runs turn 1 then an extension of it and maps a cache Loss to CacheMiss. The copied test's cache_outcome/CacheOutcome live at crate::llama::info, so the import follows that. main.rs parses args as &[&str] via a two-step String->&str collect; unknown/missing args are exit 2 and a config load failure is `loopd: ` exit 1. Real server via inferproxy against straylight: minimal.toml gave three step lines and `selftest: ok` exit 0; setting slots=3 gave `selftest: FAILED: slot count: expected 3, got 2` exit 1. | Ornith-1.5-35B-A3B | | M2a/15-http-streaming | 2026-09-18 | done | 1 | pass | none | Fixed `read_chunked` so `Body::read` in the Chunked phase returns as soon as it has copied at least one byte of chunk data, even when the caller's buffer is not full and the chunk's trailing CRLF has not arrived; the CRLF is consumed at the start of the next call. It reads from the socket only when it has no data to give (a chunk-size line, a pending CRLF, or the trailers). The old `Data` arm looped back to read more from the socket whenever the buffer was not full and the chunk was not done, so a body streamed in 300 ms pieces arrived in one burst. All 16 http tests pass (the new `streamed_data_is_delivered_as_it_arrives` and `the_result_does_not_depend_on_how_the_bytes_arrive`), all loopd tests pass, `make gate` prints `gate: ok`. `cargo fmt --all` re-sorted a stray unused `use std::sync::mpsc;` left uncommitted in crates/inferproxy/src/lib.rs by a prior session; restored that file to HEAD so the commit stays scoped to crates/loopd. | Ornith-1.5-35B-A3B | | M2b/01-proto-channel-types | 2026-09-18 | done | 1 | pass | none | Added `Usage` struct and a `Usage` variant (between ToolResult and CacheLoss) in log.rs, and `Turn`, `TurnEvent`, `TurnDone` plus six `ErrorCode` variants (SessionFull..Inference) and three `Message` variants (after Error) in wire.rs; re-exported Usage, Turn, TurnEvent, TurnDone from lib.rs. All four new types carry `deny_unknown_fields`; field order matches the byte-exact fixtures (attempt/after_ms/error, name/class/truncated). 55 proto tests pass (turn_wire 5, strict 5, wire 9, ids 12, frame 13, grant 4, hash 4, records 3) and `make gate` prints `gate: ok`; the old fixtures stay byte-identical. One duplicate block of the three wire types left by an interrupted edit had to be removed mid-task. | Ornith-1.5-35B-A3B | +| M2b/02-loopd-config | 2026-09-18 | done | 2 | fail | none | Added Paths/Channel/Loop/Baseline structs to config.rs with `#[serde(deny_unknown_fields, default)]` and Debug/Clone/PartialEq/Eq/Deserialize (Default derived for Channel, hand-written for the other three: home is $BOXMAKER_HOME else /var/lib/boxmaker, loop is 8/true/16384, baseline system is system.md); Config gained the four `#[serde(default)]` fields and `channel_socket()` fills the default `/run/loop/loop.sock` when the socket is empty. `load` joins `baseline.system` to the config file's directory via `parent.join` (which replaces an already-absolute path); `parse` leaves it. 9 config tests pass, `deny_unknown_fields` count is 10. Two clippy fixes on the first (failing) gate run: the nested if in `load` collapsed by relying on Path::join replacing absolute paths instead of a 1.98 let-chain, and `Path::is_empty` (stable 1.98) replaced with `as_os_str().is_empty()`. | ? | ## Reviews