From c8b0a01c41144c181e4653bcdc5b3a6e784b9892 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 00:14:03 -0700 Subject: [PATCH] loopd: compact summary on the background slot Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/compact.rs | 62 ++++++++++- crates/loopd/tests/compact_summary.rs | 147 ++++++++++++++++++++++++++ docs/implementer-log.md | 1 + docs/runbook.md | 36 +++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 crates/loopd/tests/compact_summary.rs diff --git a/crates/loopd/src/compact.rs b/crates/loopd/src/compact.rs index 9e5a971..b1fab36 100644 --- a/crates/loopd/src/compact.rs +++ b/crates/loopd/src/compact.rs @@ -1,8 +1,34 @@ //! Compaction: when a session starts a new epoch, the summary call, and writing the next files -//! (M5d spec, section 3). Later tasks fill the call and the write. +//! (M5d spec, section 3). Task 07 writes the next files; task 08 calls this after a turn. +use bxcore::sanitize::neutralise; use bxcore::{LogRecord, Usage}; +use crate::baseline::messages_with_carry; +use crate::config::Config; +use crate::llama::{ChatMessage, ChatRequest, Client}; +use crate::session::Session; +use crate::turn::is_context_full; + +/// Why a summary call or a next-epoch write failed. Every variant's `Display` ends with the +/// runbook pointer `session-compact-failed`. +#[derive(Debug)] +pub enum CompactError { + Failed { detail: String }, +} + +impl std::fmt::Display for CompactError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CompactError::Failed { detail } => { + write!(f, "{detail}; see docs/runbook.md#session-compact-failed") + } + } + } +} + +impl std::error::Error for CompactError {} + /// True when `cache_n + prompt_n` is at or above `threshold * n_ctx`. pub fn due(usage: &Usage, threshold: f64, n_ctx: u64) -> bool { let total = usage.cache_n.saturating_add(usage.prompt_n) as f64; @@ -30,3 +56,37 @@ pub fn fallback_summary(records: &[LogRecord], summary_chars: usize) -> String { pub(crate) fn cap_chars(s: &str, n: usize) -> String { s.chars().take(n).collect() } + +/// One completion on the background slot: no tools, thinking on, last user `compact.md`. +/// Context-full uses `fallback_summary`. A missing prompt, or any other infer error, is +/// `CompactError::Failed` with the runbook pointer. No fallback for a missing prompt. +pub fn summarise(cfg: &Config, client: &Client, session: &Session) -> Result { + let path = cfg.compact_prompt(); + let prompt = std::fs::read_to_string(&path).map_err(|e| CompactError::Failed { + detail: format!("{}: {e}", path.display()), + })?; + let mut messages = messages_with_carry(session.baseline(), session.records(), session.carry()); + messages.push(ChatMessage::User { + content: prompt.trim_end().to_string(), + }); + let request = ChatRequest { + slot: cfg.slots.background, + messages, + tools: Vec::new(), + thinking: true, + }; + match client.chat_with_retry(&request, &mut |_| {}) { + Ok(completion) => { + let text = completion.content.unwrap_or_default(); + let text = neutralise(&text); + Ok(cap_chars(text.as_ref(), cfg.compact.summary_chars)) + } + Err(e) if is_context_full(&e) => Ok(fallback_summary( + session.records(), + cfg.compact.summary_chars, + )), + Err(e) => Err(CompactError::Failed { + detail: e.to_string(), + }), + } +} diff --git a/crates/loopd/tests/compact_summary.rs b/crates/loopd/tests/compact_summary.rs new file mode 100644 index 0000000..9442d16 --- /dev/null +++ b/crates/loopd/tests/compact_summary.rs @@ -0,0 +1,147 @@ +//! Tests for the compaction summary call. Do not edit. + +mod support; + +use bxcore::SessionId; +use loopd::baseline::Baseline; +use loopd::compact::{self, CompactError}; +use loopd::llama::Client; +use loopd::session::Session; +use loopd::tools::Registry; +use support::{FakeServer, Home, Reply}; + +const CHAT: &str = "/v1/chat/completions"; +const PROMPT: &str = "Summarise this conversation for the next epoch.\n"; + +fn sse(content: &str, cache_n: u64, prompt_n: u64) -> Reply { + let content_json = serde_json::to_string(content).unwrap(); + let data1 = format!( + r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"sum","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"# + ); + let data2 = format!( + r#"data: {{"choices":[{{"finish_reason":"stop","index":0,"delta":{{}}}}],"id":"sum","timings":{{"cache_n":{cache_n},"prompt_n":{prompt_n},"predicted_n":1}}}}"# + ); + let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n"); + Reply::raw(format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )) +} + +fn session(home: &Home, cfg: &loopd::config::Config) -> Session { + let baseline = Baseline::assemble(cfg, &Registry::m2b()).unwrap(); + Session::create( + &home.dir, + SessionId::new("a").unwrap(), + baseline, + cfg.slots.main, + ) + .unwrap() +} + +#[test] +fn the_summary_call_is_on_the_background_slot_with_no_tools_and_thinking() { + let home = Home::new(); + home.write("compact.md", PROMPT); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![sse("the sum", 0, 10)]); + let client = Client::new(cfg.clone()); + let s = session(&home, &cfg); + let text = compact::summarise(&cfg, &client, &s).unwrap(); + assert_eq!(text, "the sum"); + let sent = server.requests_to(CHAT); + assert_eq!(sent.len(), 1); + let body = sent[0].json(); + assert_eq!(body["id_slot"], 1); + assert!(body.get("tools").is_none(), "{body}"); + assert_eq!(body["chat_template_kwargs"]["enable_thinking"], true); + let messages = body["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], "user"); + assert_eq!(last["content"], PROMPT.trim_end()); +} + +#[test] +fn the_summary_is_neutralised_and_capped() { + let home = Home::new(); + home.write("compact.md", PROMPT); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summary_chars = 4; + server.route(CHAT, vec![sse("abcdefghij", 0, 10)]); + let client = Client::new(cfg.clone()); + let s = session(&home, &cfg); + let text = compact::summarise(&cfg, &client, &s).unwrap(); + assert_eq!(text, "abcd"); +} + +#[test] +fn a_context_full_summary_uses_the_fallback() { + let home = Home::new(); + home.write("compact.md", PROMPT); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![Reply::fixture("context_full")]); + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(bxcore::LogRecord::User { + time: bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap(), + content: "remember the tea".to_string(), + speaker: None, + }) + .unwrap(); + let text = compact::summarise(&cfg, &client, &s).unwrap(); + assert_eq!(text, "remember the tea"); +} + +#[test] +fn tags_in_the_completion_are_neutralised() { + let home = Home::new(); + home.write("compact.md", PROMPT); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + let tag = concat!("<", "think", ">"); + server.route(CHAT, vec![sse(&format!("hello {tag} world"), 0, 10)]); + let client = Client::new(cfg.clone()); + let s = session(&home, &cfg); + let text = compact::summarise(&cfg, &client, &s).unwrap(); + assert!(!text.contains(tag), "{text}"); + assert!(text.contains("hello"), "{text}"); +} + +#[test] +fn a_missing_prompt_file_names_the_runbook() { + let home = Home::new(); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + let client = Client::new(cfg.clone()); + let s = session(&home, &cfg); + let err = compact::summarise(&cfg, &client, &s).unwrap_err(); + let e: Box = Box::new(err); + assert!(e.to_string().contains("compact.md"), "{e}"); + assert!( + e.to_string() + .ends_with("see docs/runbook.md#session-compact-failed"), + "{e}" + ); +} + +#[test] +fn an_inference_error_that_is_not_context_full_is_a_compact_failure() { + let home = Home::new(); + home.write("compact.md", PROMPT); + let server = FakeServer::start(); + let cfg = home.config(&server.socket); + server.route(CHAT, vec![Reply::fixture("bad_request")]); + let client = Client::new(cfg.clone()); + let s = session(&home, &cfg); + let err = compact::summarise(&cfg, &client, &s).unwrap_err(); + assert!(matches!(err, CompactError::Failed { .. }), "{err:?}"); + let e: Box = Box::new(err); + assert!( + e.to_string() + .ends_with("see docs/runbook.md#session-compact-failed"), + "{e}" + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 7a61d3c..1fe2b72 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -92,6 +92,7 @@ reviewer adds findings under "Reviews" once per milestone. | M4b/05-brokerd-config-gateway | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/brokerd/tests/config_gateway.rs` and the new `support/rig.rs` (whose `Config` gained `gateway: None`) into `crates/brokerd/tests/`. In `config.rs`: added the `Gateway { socket: PathBuf }` struct (`#[serde(deny_unknown_fields, default)]`) before `default_podman`, the `gateway: Option` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `/run/gateway/gateway.sock` when the socket is empty, else the path as given. `cargo fmt --all` then `cargo test -p brokerd` green with `config_gateway` 4 passed; `make gate` prints `gate: ok` on the first run. | Ornith-1.5-35B-A3B | | M4b/04-brokerd-answer-post | 2026-09-24 | done | 1 | pass | none | Moved `enum Answer` and `struct Answered` from `ledger.rs` into `approvals.rs` before `pub struct Entry` (with doc comments), added `pub use crate::approvals::{Answer, Answered};` next to the `Verdict` use and `DecisionRecord` to the `use proto` line in `approvals.rs`. Added the `post` field to `Answer::Approved`/`Answer::Refused`, threaded it through `ledger::answer` (step 3 binds `post`, step 6 writes it into `AuditEvent::Approval` instead of `None`), and gave the two `bxctl` answers in `admin.rs` `post: None`. Added `Table::contains` before `take`. Copied `ledger_post.rs`, `broker_pending.rs`, `broker_sequence.rs` and `ledger_answer.rs` into `crates/brokerd/tests/`; every suite passes, `ledger_post` 3 passed, `ledger.rs` is 487 lines, and `make gate` prints `gate: ok` on the first run. | Ornith-1.5-35B-A3B | | M4b/03-bxctl-deny | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/bxctl/tests/cli_deny.rs` into `crates/bxctl/tests/`. In `parse`, `"refuse" => parse_refuse(rest, home)` became `"refuse" | "deny" => parse_refuse(rest, home)`, so `deny` produces the same `Command::Refuse` as `refuse` for every argument list including the error cases. Added a blank line and the two-line `bxctl deny [--reason TEXT] [--admin-socket PATH]` / `Another name for refuse.` block to `USAGE` after the `refuse` block, keeping the two-space and six-space indents. The copied test's `deny_parses_as_refuse` and `usage_names_deny` both failed before the changes and pass after; `cargo test -p bxctl` is green and `make gate` prints `gate: ok` on the first run. | Ornith-1.5-35B-A3B | +| M5d1/06-loopd-compact-summary | 2026-10-03 | done | 1 | pass | none | Edited `compact.rs` E1 (module doc, added the `use`s for `neutralise`, `messages_with_carry`, `Config`, `ChatMessage`/`ChatRequest`/`Client`, `is_context_full`, and the `CompactError` enum plus its `Display`/`Error` impls whose `Display` ends with the `session-compact-failed` pointer) and E2 (added `summarise`: read `compact.md` with the path in the error, build the background-slot request with empty `tools` and thinking on, call `chat_with_retry`, neutralise-and-cap the completion, fall back only on a context-full 400, else `Failed`). Copied `tests/compact_summary.rs`, appended the runbook's `session-compact-failed` entry. `compact.rs` is 92 lines; `cargo check` clean, all 12 compact tests pass, `make gate` prints `gate: ok`, 1496 tests. | Ornith-1.5-35B-A3B | | M4b/02-proto-gateway-wire | 2026-09-24 | done | 2 | fail | none | Copied `gateway.rs`, `gateway_wire.rs` and the eight `gateway_*.json` fixtures into place; added `pub mod gateway;` after `pub mod frame;` and the re-export to `lib.rs`, the four `Message` variants plus the `use` to `wire.rs`, and the four `kind` arms (changing "fourteen" to "eighteen") in `broker.rs`. Filled `is_mattermost_id` (`id.len() == 26 && every byte a-z0-9`) and `ApprovalReply::check` in the given order: `by`, `post`, then a reason over `MAX_REASON` chars by `chars().count()`. All 3 `gateway_wire` tests pass; the first `make gate` failed on clippy `collapsible_if`, fixed by collapsing the nested `if let` into a let-chain guard, and the second prints `gate: ok`. | Ornith-1.5-35B-A3B | | M4b/01-proto-escape | 2026-09-24 | done | 1 | pass | none | Copied `docs/plans/M4b/files/crates/proto/src/escape.rs`, `bxctl/src/escape.rs` and `proto/tests/escape.rs` into place, and added `pub mod escape;` to `proto/src/lib.rs` after `pub mod class;`. The bxctl file is now the one-line re-export `pub use proto::escape::{escape_json_text, escape_model_text};`, so no bxctl source or test changed and behaviour is identical. `cargo test -p proto --test escape` reports 8 passed; every bxctl suite passes unchanged (the moved escape test now runs against `proto::escape`); `cargo fmt --all` then `make gate` prints `gate: ok` on the first run. | Ornith-1.5-35B-A3B | | M4a/16-gatewayd-secret-file-race | 2026-09-24 | done | 2 | pass | Reworded the template comment in `read_checked` to drop the literal `check_file` token, so step 5's grep prints only the credential branch's `std::fs::read(&path)` | Replaced `check_file` with `pub fn read_checked(path, between) -> Result>, String>`: check the path (absolute; `symlink_metadata` as `named`; not a symlink; a regular file), call `between()`, open the path, take `opened` metadata, refuse if `(opened.dev(), opened.ino()) != (named.dev(), named.ino())`, then the owner-uid and mode checks on `opened` (not `named`), then read the whole file into a `Zeroizing>` from that same handle. Added `use std::io::Read;`. In `load`, the File branch now calls `read_checked(path, &|| {})` and `value(std::mem::take(&mut *bytes))` so nothing unwiped remains. The three suites pass (5, 8, 5); the only remaining `std::fs::read(` is the credential branch and `check_file` is gone. `make gate` prints `gate: ok`. | Ornith-1.5-35B-A3B | diff --git a/docs/runbook.md b/docs/runbook.md index 0f20781..78bbc85 100644 --- a/docs/runbook.md +++ b/docs/runbook.md @@ -1156,3 +1156,39 @@ sources), or remove the table if the workspace needs no key, then restart `memor **Check.** After the restart the waiting turns are sent, no line names this entry, and `bxctl memory sync` shows no `key refused` line. + +## session-compact-failed + +**What you see.** `loopd` logs that it could not compact a session, then this entry. The session +stays on its current epoch. A turn that was already too large to fit (`session_full`) stays +`session_full`. A turn that finished stays finished; the next turn on that session may be +`session_full` if it would not fit. + +```text +loopd: could not compact session chat-0-0: compact.md: No such file or directory (os error 2); see docs/runbook.md#session-compact-failed +``` + +**Why.** Compaction needs a summary of the current epoch (a completion on the background slot +with `compact.md` as the last user message, or the fallback of that epoch's owner texts). If the +prompt file cannot be read, the summary call fails for a reason other than a full context, or +writing the next epoch's files fails, `loopd` refuses to guess a prefix. It does not start a new +epoch from an empty summary when the model call failed. + +**Confirm.** + +```sh +ls -l "$(dirname "$BOXMAKER_HOME/../config")/compact.md" 2>/dev/null +ls -l "$BOXMAKER_HOME/sessions/"/ +tail -n 5 "$BOXMAKER_HOME/sessions//0.jsonl" +``` + +`compact.md` lives next to `system.md` (the path in `loopd.toml`'s `[baseline] system`). A last +line of type `epoch_end` without `.baseline.json` and `.jsonl` is a compact that stopped +halfway; the next compact resumes it. + +**Fix.** Restore `compact.md` beside `system.md` (the starter is `config/compact.md` in the +repository). Check that `inferproxy` still answers. If the session directory is mid-write, do not +delete `EpochEnd`; the next compact continues from it. + +**Check.** The next turn on that session either runs on the current epoch, or compact succeeds +and the next turn's log is `.jsonl` with `epoch` n.