loopd: compact summary on the background slot

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 00:14:03 -07:00
co-authored by ornith-1.5-35b-a3b
parent 32e81b51fb
commit c8b0a01c41
4 changed files with 245 additions and 1 deletions
+61 -1
View File
@@ -1,8 +1,34 @@
//! Compaction: when a session starts a new epoch, the summary call, and writing the next files //! 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 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`. /// True when `cache_n + prompt_n` is at or above `threshold * n_ctx`.
pub fn due(usage: &Usage, threshold: f64, n_ctx: u64) -> bool { pub fn due(usage: &Usage, threshold: f64, n_ctx: u64) -> bool {
let total = usage.cache_n.saturating_add(usage.prompt_n) as f64; 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 { pub(crate) fn cap_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect() 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<String, CompactError> {
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(),
}),
}
}
+147
View File
@@ -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<dyn std::error::Error> = 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<dyn std::error::Error> = Box::new(err);
assert!(
e.to_string()
.ends_with("see docs/runbook.md#session-compact-failed"),
"{e}"
);
}
+1
View File
@@ -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<Gateway>` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `<home>/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/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<Gateway>` field (with `#[serde(default)]`) after `runner` in `Config`, and the `gateway_socket()` method before `audit_dir` — `None` when the section is absent, the default `<home>/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/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 <id> [--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 | | 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 <id> [--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/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 | | 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<Zeroizing<Vec<u8>>, 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<Vec<u8>>` 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 | | 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<Zeroizing<Vec<u8>>, 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<Vec<u8>>` 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 |
+36
View File
@@ -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 **Check.** After the restart the waiting turns are sent, no line names this entry, and `bxctl
memory sync` shows no `key refused` line. 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/<id>"/
tail -n 5 "$BOXMAKER_HOME/sessions/<id>/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 `<n>.baseline.json` and `<n>.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 `<n>.jsonl` with `epoch` n.