Add loopd configuration with every M2a limit
Implemented-By: Laguna S 2.1 (OpenCode)
This commit is contained in:
Generated
+3
@@ -75,6 +75,9 @@ name = "loopd"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"proto",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -10,3 +10,6 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
proto.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
|
||||
@@ -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<u64>,
|
||||
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<Config, toml::de::Error> {
|
||||
toml::from_str(text)
|
||||
}
|
||||
pub fn load(path: &std::path::Path) -> Result<Config, ConfigError> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
//! The agent loop: sessions, prompt assembly and memory. It holds no authority.
|
||||
|
||||
pub mod config;
|
||||
|
||||
@@ -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::<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"));
|
||||
}
|
||||
+31
@@ -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
|
||||
+12
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user