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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proto",
|
"proto",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,3 +10,6 @@ workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
proto.workspace = true
|
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.
|
//! 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`. |
|
| 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/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/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
|
## Reviews
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user