loopd: compact after a full turn and retry SessionFull once

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 00:38:35 -07:00
co-authored by ornith-1.5-35b-a3b
parent fe8e3e9354
commit b274fa7d62
4 changed files with 353 additions and 11 deletions
+24 -11
View File
@@ -178,25 +178,38 @@ pub fn handle(stream: UnixStream, ctx: Arc<Context>) {
}
};
let mut emit = |event: &bxcore::TurnEvent| {
let _ = write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: false,
msg: Message::TurnEvent(event.clone()),
},
);
};
let outcome = run_turn(
&mut session,
&runtime,
&turn.content,
turn.speaker.as_deref(),
&mut |event| {
let _ = write_frame(
&mut stream,
&Envelope {
v: PROTOCOL_VERSION,
id: request.id,
r#final: false,
msg: Message::TurnEvent(event.clone()),
},
);
},
&mut emit,
);
let outcome = crate::compact::finish_turn(
&mut session,
&runtime,
&*ctx.memory,
&turn.content,
turn.speaker.as_deref(),
&mut emit,
outcome,
);
// 5. Release the session before the final frame, so the next turn does not find it busy.
// Compaction ran inside the hold: a second turn on this session waits until the new
// epoch is on disk (or compact failed and the epoch is unchanged).
drop(held);
// A finished turn's records are for `memoryd` to index; best effort, since `memoryd` catches
+51
View File
@@ -11,6 +11,8 @@ use crate::memory::MemorySource;
use crate::session::Session;
use crate::tools::Registry;
use crate::turn::is_context_full;
use crate::turn::{Runtime, TurnError, TurnOutcome, run_turn};
use bxcore::TurnEvent;
/// Why a summary call or a next-epoch write failed. Every variant's `Display` ends with the
/// runbook pointer `session-compact-failed`.
@@ -133,3 +135,52 @@ pub fn commit(
})?;
Ok(())
}
/// After `run_turn`: compact when the last `Usage` is due, or on `SessionFull` (then retry the
/// user message once). Compact failure on a successful turn keeps the success; on `SessionFull`
/// keeps `SessionFull`. Other errors skip compact.
pub fn finish_turn(
session: &mut Session,
rt: &Runtime<'_>,
memory: &dyn MemorySource,
text: &str,
speaker: Option<&str>,
on_event: &mut dyn FnMut(&TurnEvent),
outcome: Result<TurnOutcome, TurnError>,
) -> Result<TurnOutcome, TurnError> {
match outcome {
Err(TurnError::SessionFull) => match compact_session(session, rt, memory) {
Ok(()) => run_turn(session, rt, text, speaker, on_event),
Err(e) => {
log_compact(session, &e);
Err(TurnError::SessionFull)
}
},
Ok(done) => {
if let Some(usage) = session.last_usage()
&& due(&usage, rt.cfg.compact.threshold, rt.cfg.expect.n_ctx)
&& let Err(e) = compact_session(session, rt, memory)
{
log_compact(session, &e);
}
Ok(done)
}
Err(e) => Err(e),
}
}
fn compact_session(
session: &mut Session,
rt: &Runtime<'_>,
memory: &dyn MemorySource,
) -> Result<(), CompactError> {
let summary = summarise(rt.cfg, rt.client, session)?;
commit(session, rt.cfg, rt.registry, memory, summary)
}
fn log_compact(session: &Session, e: &CompactError) {
eprintln!(
"loopd: could not compact session {}: {e}",
session.id().as_str()
);
}
+277
View File
@@ -0,0 +1,277 @@
//! Tests for compacting after a turn and retrying a full context. Do not edit.
mod support;
use bxcore::{Epoch, LogRecord, SessionId, TurnEvent};
use loopd::baseline::{Baseline, CARRY_HEADING};
use loopd::compact;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, TurnError, run_turn};
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":"t","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":"t","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()
))
}
struct World {
home: Home,
server: FakeServer,
cfg: loopd::config::Config,
client: Client,
port: support::ScriptedPort,
registry: Registry,
}
impl World {
fn new() -> World {
let home = Home::new();
home.write("compact.md", PROMPT);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.expect.n_ctx = 1_000;
cfg.compact.threshold = 0.85;
let client = Client::new(cfg.clone());
World {
home,
server,
cfg,
client,
port: support::ScriptedPort::new(vec![]),
registry: Registry::m2b(),
}
}
fn session(&self) -> Session {
let baseline = Baseline::assemble(&self.cfg, &self.registry).unwrap();
Session::create(
&self.home.dir,
SessionId::new("a").unwrap(),
baseline,
self.cfg.slots.main,
)
.unwrap()
}
fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
fn finish(
&self,
session: &mut Session,
text: &str,
outcome: Result<loopd::turn::TurnOutcome, TurnError>,
) -> Result<loopd::turn::TurnOutcome, TurnError> {
let mut events = Vec::new();
compact::finish_turn(
session,
&self.runtime(),
&NoMemory,
text,
None,
&mut |e: &TurnEvent| events.push(e.clone()),
outcome,
)
}
}
#[test]
fn usage_under_the_threshold_leaves_the_epoch() {
let w = World::new();
w.server.route(CHAT, vec![sse("ok", 400, 449)]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "hi", None, &mut |e| {
events.push(e.clone())
});
let outcome = w.finish(&mut s, "hi", outcome).unwrap();
assert_eq!(outcome.content, "ok");
assert_eq!(s.epoch(), Epoch(0));
assert_eq!(w.server.requests_to(CHAT).len(), 1);
}
#[test]
fn usage_at_the_threshold_compacts_before_the_turn_returns() {
let w = World::new();
w.server
.route(CHAT, vec![sse("ok", 400, 450), sse("the sum", 0, 10)]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "hi", None, &mut |e| {
events.push(e.clone())
});
w.finish(&mut s, "hi", outcome).unwrap();
assert_eq!(s.epoch(), Epoch(1));
assert_eq!(s.carry(), Some("the sum"));
w.server.route(CHAT, vec![sse("next", 0, 20)]);
let mut events = Vec::new();
run_turn(&mut s, &w.runtime(), "again", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let sent = w.server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[0].json()["id_slot"], 0);
assert_eq!(sent[1].json()["id_slot"], 1);
assert!(sent[1].json().get("tools").is_none());
let live = sent[2].json();
assert_eq!(live["id_slot"], 0);
let contents: Vec<&str> = live["messages"]
.as_array()
.unwrap()
.iter()
.map(|m| m["content"].as_str().unwrap_or(""))
.collect();
assert!(
contents
.iter()
.any(|c| c.contains(CARRY_HEADING) && c.contains("the sum")),
"{contents:?}"
);
let old = w.home.records("a");
assert!(matches!(
old.last(),
Some(LogRecord::EpochEnd { next, .. }) if *next == Epoch(1)
));
}
#[test]
fn session_full_compacts_and_retries_the_user_message_once() {
let w = World::new();
w.server.route(
CHAT,
vec![
Reply::fixture("context_full"),
sse("the sum", 0, 10),
sse("retried", 0, 20),
],
);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "please fit", None, &mut |e| {
events.push(e.clone())
});
assert!(matches!(outcome, Err(TurnError::SessionFull)));
let done = w.finish(&mut s, "please fit", outcome).unwrap();
assert_eq!(done.content, "retried");
assert_eq!(s.epoch(), Epoch(1));
let users: Vec<_> = s
.records()
.iter()
.filter_map(|r| match r {
LogRecord::User { content, .. } => Some(content.as_str()),
_ => None,
})
.collect();
assert_eq!(users, ["please fit"]);
let old_users = w
.home
.records("a")
.iter()
.filter(|r| matches!(r, LogRecord::User { .. }))
.count();
assert_eq!(old_users, 1);
assert_eq!(s.turns(), 2);
let sent = w.server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[1].json()["id_slot"], 1);
assert_eq!(sent[2].json()["id_slot"], 0);
}
#[test]
fn session_full_stays_session_full_when_compact_fails() {
let w = World::new();
w.server.route(
CHAT,
vec![
Reply::fixture("context_full"),
Reply::fixture("bad_request"),
],
);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "x", None, &mut |e| {
events.push(e.clone())
});
let err = w.finish(&mut s, "x", outcome).unwrap_err();
assert!(matches!(err, TurnError::SessionFull), "{err:?}");
assert_eq!(s.epoch(), Epoch(0));
}
#[test]
fn a_successful_turn_stays_successful_when_compact_fails() {
let home = Home::new();
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.expect.n_ctx = 1_000;
cfg.compact.threshold = 0.85;
let client = Client::new(cfg.clone());
server.route(CHAT, vec![sse("ok", 400, 450)]);
let baseline = Baseline::assemble(&cfg, &Registry::m2b()).unwrap();
let mut s = Session::create(
&home.dir,
SessionId::new("a").unwrap(),
baseline,
cfg.slots.main,
)
.unwrap();
let port = support::ScriptedPort::new(vec![]);
let registry = Registry::m2b();
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hi", None, &mut |e| events.push(e.clone()));
let done = compact::finish_turn(
&mut s,
&rt,
&NoMemory,
"hi",
None,
&mut |e| events.push(e.clone()),
outcome,
)
.unwrap();
assert_eq!(done.content, "ok");
assert_eq!(s.epoch(), Epoch(0), "compact.md is missing, so no epoch");
}
#[test]
fn finish_turn_does_not_compact_an_error_other_than_session_full() {
let w = World::new();
w.server.route(CHAT, vec![Reply::fixture("bad_request")]);
let mut s = w.session();
let mut events = Vec::new();
let outcome = run_turn(&mut s, &w.runtime(), "x", None, &mut |e| {
events.push(e.clone())
});
let err = w.finish(&mut s, "x", outcome).unwrap_err();
assert!(matches!(err, TurnError::Infer(_)), "{err:?}");
assert_eq!(s.epoch(), Epoch(0));
assert_eq!(w.server.requests_to(CHAT).len(), 1);
}