From 45984b45bec6639da49f5449f5081064448cccbe Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 2 Oct 2026 23:46:06 -0700 Subject: [PATCH] loopd: replay a carried summary as an unlogged user message Co-Authored-By: ornith-1.5-35b-a3b --- crates/loopd/src/baseline.rs | 18 ++++ crates/loopd/src/turn.rs | 4 +- crates/loopd/tests/carry_messages.rs | 120 +++++++++++++++++++++++++++ docs/implementer-log.md | 1 + 4 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 crates/loopd/tests/carry_messages.rs diff --git a/crates/loopd/src/baseline.rs b/crates/loopd/src/baseline.rs index b74ccda..2f50c50 100644 --- a/crates/loopd/src/baseline.rs +++ b/crates/loopd/src/baseline.rs @@ -91,12 +91,30 @@ impl Baseline { } } +/// The heading in front of a carried summary. Built from pieces (T33). +pub const CARRY_HEADING: &str = concat!("Earlier ", "turns, summarised:"); + /// The system message, then each log record replayed in order. Every record variant is named, so a /// new one is a compile error rather than a silent skip. pub fn messages(baseline: &Baseline, records: &[LogRecord]) -> Vec { + messages_with_carry(baseline, records, None) +} + +/// Like `messages`, and when `carry` is `Some` inserts one user message after the system +/// prompt: the heading, a blank line, the summary. That user is not a `LogRecord::User`. +pub fn messages_with_carry( + baseline: &Baseline, + records: &[LogRecord], + carry: Option<&str>, +) -> Vec { let mut out = vec![ChatMessage::System { content: baseline.system.clone(), }]; + if let Some(summary) = carry { + out.push(ChatMessage::User { + content: format!("{CARRY_HEADING}\n\n{summary}"), + }); + } for record in records { match record { LogRecord::SessionStart { .. } => {} diff --git a/crates/loopd/src/turn.rs b/crates/loopd/src/turn.rs index 94721a0..63dfef1 100644 --- a/crates/loopd/src/turn.rs +++ b/crates/loopd/src/turn.rs @@ -5,7 +5,7 @@ //! logged (M5b spec, section 8), so every later request replays the same bytes; the model's own //! output is logged as the server returned it. -use crate::baseline::messages; +use crate::baseline::messages_with_carry; use crate::config::Config; use crate::llama::info::{CacheOutcome, cache_outcome}; use crate::llama::{ChatEvent, ChatRequest, Client, InferError}; @@ -140,7 +140,7 @@ pub fn run_turn( let baseline = session.baseline(); let request = ChatRequest { slot: rt.cfg.slots.main, - messages: messages(baseline, session.records()), + messages: messages_with_carry(baseline, session.records(), session.carry()), tools: baseline.tools.clone(), thinking: true, }; diff --git a/crates/loopd/tests/carry_messages.rs b/crates/loopd/tests/carry_messages.rs new file mode 100644 index 0000000..2d5bfb4 --- /dev/null +++ b/crates/loopd/tests/carry_messages.rs @@ -0,0 +1,120 @@ +//! Tests for the carried summary in the next epoch's prefix. Do not edit. + +use bxcore::{Epoch, Hash32, LogRecord, SessionId, Timestamp}; +use loopd::baseline::{Baseline, CARRY_HEADING, messages, messages_with_carry}; +use loopd::llama::ChatMessage; + +fn ts() -> Timestamp { + Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap() +} + +fn user(content: &str) -> LogRecord { + LogRecord::User { + time: ts(), + content: content.to_string(), + speaker: None, + } +} + +fn start(epoch: u32) -> LogRecord { + LogRecord::SessionStart { + time: ts(), + session: SessionId::new("a").unwrap(), + epoch: Epoch(epoch), + slot: 0, + baseline: Hash32::ZERO, + } +} + +#[test] +fn epoch_0_matches_messages_without_a_carry() { + let b = Baseline { + system: "sys".to_string(), + tools: vec![], + }; + let records = vec![start(0), user("hi")]; + assert_eq!( + messages_with_carry(&b, &records, None), + messages(&b, &records) + ); +} + +#[test] +fn a_carry_is_a_user_message_after_the_system_prompt() { + let b = Baseline { + system: "new-sys".to_string(), + tools: vec![], + }; + let records = vec![start(1), user("next")]; + let got = messages_with_carry(&b, &records, Some("the summary")); + assert_eq!( + got, + vec![ + ChatMessage::System { + content: "new-sys".to_string(), + }, + ChatMessage::User { + content: format!("{CARRY_HEADING}\n\nthe summary"), + }, + ChatMessage::User { + content: "next".to_string(), + }, + ] + ); +} + +#[test] +fn the_carry_is_not_taken_from_a_log_user_record() { + let b = Baseline { + system: "sys".to_string(), + tools: vec![], + }; + let records = vec![start(1)]; + let got = messages_with_carry(&b, &records, Some("carried")); + assert_eq!(got.len(), 2); + assert!( + !records.iter().any(|r| matches!(r, LogRecord::User { .. })), + "the carry is not in the log" + ); +} + +#[test] +fn the_heading_is_built_from_pieces() { + assert_eq!(CARRY_HEADING, concat!("Earlier ", "turns, summarised:")); + assert!( + !CARRY_HEADING.contains('<'), + "the heading is words, not a tag" + ); +} + +#[test] +fn epoch_1_is_not_an_extension_of_epoch_0() { + let old = Baseline { + system: "old-sys".to_string(), + tools: vec![], + }; + let new = Baseline { + system: "new-sys".to_string(), + tools: vec![], + }; + let epoch0 = vec![start(0), user("first")]; + let epoch1 = vec![start(1), user("second")]; + let prev = messages(&old, &epoch0); + let next = messages_with_carry(&new, &epoch1, Some("sum of first")); + assert!( + next.len() >= 2, + "system plus carry, then the new epoch's user" + ); + assert_ne!(next[0], prev[0], "the system message changed"); + assert!( + !next.starts_with(&prev[..]), + "the new prefix is not an extension of the old messages" + ); + match &next[1] { + ChatMessage::User { content } => { + assert!(content.contains("sum of first"), "{content}"); + assert!(!content.contains("second"), "{content}"); + } + other => panic!("{other:?}"), + } +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 7e34873..928e376 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M5d1/03-loopd-carry-messages | 2026-10-02 | done | 2 | pass | none | Copied the given test `crates/loopd/tests/carry_messages.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failure: `carry_messages` with one E0432 (`unresolved import loopd::baseline::{CARRY_HEADING, messages_with_carry}`); every other target compiled. Made B1 (`baseline.rs`: `CARRY_HEADING` = `concat!("Earlier ", "turns, summarised:")`, `messages` becomes `messages_with_carry(baseline, records, None)`, new `messages_with_carry` inserts the heading plus a blank line plus the summary as one `ChatMessage::User` after the system prompt and before walking `records`; the rest of the `match` untouched); T1 (`turn.rs`: `use crate::baseline::messages_with_carry`); T2 (`run_turn` passes `session.carry()`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test carry_messages --test baseline --test turn` passes: 5 in `carry_messages`, 6 in `baseline`, 6 in `turn`. `git diff --stat` lists only `baseline.rs` and `turn.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1478 tests (1473 before, plus the 5 of `carry_messages.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/02-loopd-session-epochs | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/session_epochs.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `session_epochs` with E0599 (`no method named epoch` / `carry` / `slot`) and E0433 (`NoCarry`); every other target compiled and the existing `session` tests still compiled. Made S1 (module doc naming `.baseline.json` / `.jsonl` and epochs after compaction), S2 (`SessionError` gains `NoCarry { path: PathBuf }`), S3 (its Display arm naming the damaged-log runbook pointer), S4 (`Session` gains `epoch`, `slot`, `carry`, `prior_users`, `prior_untrusted` in that order), S5 (`create` inits the five new fields, `slot` from the argument), S6 (whole `open`: `complete_epochs` picks the highest epoch with both files, `records` is the current log, `priors` walks every earlier complete epoch for user/untrusted counts and the epoch-1 carry, `max_call` over every epoch for `next_call`, `live_slot` from the current `SessionStart`), S7 (accessors `epoch` / `slot` / `carry`), S8 (`untrusted` = `prior_untrusted || any_untrusted`, `turns` = `prior_users.saturating_add(count_users)`), S9 (`log_path` to the current epoch's file plus helpers `complete_epochs`, `load_records`, `priors`, `carry_from`, `count_users`, `any_untrusted`, `max_call`, `live_slot`). `cargo check -p loopd` clean, no error or warning. `wc -l crates/loopd/src/session.rs` prints 402. `cargo test -p loopd --test session_epochs --test session` passes: 9 in `session_epochs`, 7 in `session`. `git diff --stat` lists only `session.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1473 tests (1472 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +9 over the 1464 before is exactly the tests of `session_epochs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d1/01-loopd-compact-config | 2026-10-02 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/config_compact.rs` (new). Step 2 (`cargo build --tests --keep-going -p loopd`) showed exactly the named failures: `config_compact` with E0609 (`no field \`compact\` on type \`Config\``), E0599 (`no method named \`compact_prompt\``) and E0433 (`no variant or associated item named \`default\` for \`Compact\``); every other target compiled. Made the edits exactly as the task gives: C1 (new `Compact` struct after `Memory`, `#[derive(Debug, Clone, PartialEq, Deserialize)]` plus `#[serde(deny_unknown_fields, default)]`, fields in order `threshold: f64`, `summary_chars: usize`, `summarise: Vec`, and `Default` = threshold `0.85`, `summary_chars` `4_000`, `summarise` `vec!["http_fetch".to_string()]`); C2 (`Config` gains `#[serde(default)] pub compact: Compact` after `memory`); C3 (`compact_prompt` after `channel_socket`, `self.baseline.system.with_file_name("compact.md")`). `cargo check -p loopd` clean, no error or warning. `cargo test -p loopd --test config_compact --test config` passes: 8 in `config_compact`, 11 in `config`. `git diff --stat` lists only `config.rs`; `cargo fmt --all` changed nothing. `make gate` printed `gate: ok` with 1464 tests (1463 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +8 over the 1456 before is exactly the tests of `config_compact.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5c2/12-memoryd-serve-ask-memory | 2026-09-29 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/honcho_ask_serve.rs` (new). Step 2 (`cargo test --no-fail-fast -p memoryd --test honcho_ask_serve`) showed exactly the six named failures: every `ask_memory` answered with `memoryd runs only recall and memory`, and the hang test after its 10 s deadline (`the chat was never sent`). Made A1 (`ask.rs`: `NOT_CONFIGURED` before `NO_ANSWER`, the sentence letter for letter), E1 (`use bxcore::AskMemoryArgs;`), E2 (`use crate::honcho::ask;`), E3 (`call` gains the third arm `bxcore::memory::ASK_MEMORY => self.ask_memory(&call)` and the unknown-tool sentence becomes `memoryd runs only recall, memory and ask_memory`), E4 (new `ask_memory`: decode `AskMemoryArgs`, `args.check()?`, the `[honcho]`-less branch returns `NOT_CONFIGURED` labelled private and trusted, else `ask::ask` then `ask::answer`). `cargo check -p memoryd` clean, no error or warning. `cargo test` passes: 6 in `honcho_ask_serve`, 10 in `honcho_ask`, 4 in `honcho_recall_serve`, 4 in `serve_admin`, 8 in `serve`. Step 5 all pass: `git diff --stat` lists only `ask.rs` and `serve.rs`; `wc -l crates/memoryd/src/serve.rs` prints 456. `make gate` printed `gate: ok` with 1454 tests (1453 in `cargo test --workspace`, plus the 1 ignored `end_to_end`; the +6 over the 1448 before is exactly the tests of `honcho_ask_serve.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |