From 57d9aad2d67752fb9e2f5852180d4738b03bc3b3 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 17:11:40 -0700 Subject: [PATCH] loopd: background ToolSummary for opted-in results Co-Authored-By: ornith-1.5-35b-a3b --- config/summarise.md | 2 + crates/loopd/src/compact.rs | 20 +- crates/loopd/src/lib.rs | 1 + crates/loopd/src/summarise.rs | 92 +++++++++ crates/loopd/tests/tool_summary_call.rs | 241 ++++++++++++++++++++++++ docs/implementer-log.md | 1 + 6 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 config/summarise.md create mode 100644 crates/loopd/src/summarise.rs create mode 100644 crates/loopd/tests/tool_summary_call.rs diff --git a/config/summarise.md b/config/summarise.md new file mode 100644 index 0000000..652925e --- /dev/null +++ b/config/summarise.md @@ -0,0 +1,2 @@ +Summarise this tool result for later recall. Keep facts, names, numbers and URLs. Drop boilerplate +and navigation. Do not use tools. Do not address the owner. Do not invent facts. diff --git a/crates/loopd/src/compact.rs b/crates/loopd/src/compact.rs index b1ffe83..423a1e1 100644 --- a/crates/loopd/src/compact.rs +++ b/crates/loopd/src/compact.rs @@ -149,14 +149,22 @@ pub fn finish_turn( outcome: Result, ) -> Result { 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) + Err(TurnError::SessionFull) => { + if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) { + eprintln!("loopd: tool summary: {e}"); } - }, + 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 Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) { + eprintln!("loopd: tool summary: {e}"); + } 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) diff --git a/crates/loopd/src/lib.rs b/crates/loopd/src/lib.rs index e2a0cbd..d0b9127 100644 --- a/crates/loopd/src/lib.rs +++ b/crates/loopd/src/lib.rs @@ -15,5 +15,6 @@ pub mod selftest; pub mod session; pub mod socket; pub mod sse; +pub mod summarise; pub mod tools; pub mod turn; diff --git a/crates/loopd/src/summarise.rs b/crates/loopd/src/summarise.rs new file mode 100644 index 0000000..439b4fe --- /dev/null +++ b/crates/loopd/src/summarise.rs @@ -0,0 +1,92 @@ +//! Opted-in tool-output summaries (M5d spec, section 4.3). + +use crate::baseline::messages_with_carry; +use crate::compact::cap_chars; +use crate::config::Config; +use crate::llama::{ChatMessage, ChatRequest, Client}; +use crate::session::Session; +use bxcore::sanitize::neutralise; +use bxcore::{CallId, DataClass, LogRecord, Timestamp, ToolCall}; +use std::collections::{HashMap, HashSet}; + +/// Append a `ToolSummary` for each opted-in `ToolResult` in this epoch that does not yet have +/// one. Inference uses the background slot and no tools. +pub fn missing(cfg: &Config, client: &Client, session: &mut Session) -> Result<(), String> { + let path = cfg.baseline.system.with_file_name("summarise.md"); + let prompt = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut names: HashMap = HashMap::new(); + let mut done: HashSet = HashSet::new(); + let mut pending: Vec<(CallId, String, String, DataClass, bool)> = Vec::new(); + for record in session.records() { + match record { + LogRecord::Assistant { tool_calls, .. } => { + for ToolCall { id, name, .. } in tool_calls { + names.insert(id.clone(), name.clone()); + } + } + LogRecord::ToolSummary { call, .. } => { + done.insert(call.0); + } + LogRecord::ToolResult { + call, + tool_call_id, + content, + class, + untrusted, + .. + } => { + let Some(name) = names.get(tool_call_id) else { + continue; + }; + if !cfg.compact.summarise.iter().any(|n| n == name) { + continue; + } + pending.push(( + *call, + tool_call_id.clone(), + content.clone(), + *class, + *untrusted, + )); + } + _ => {} + } + } + pending.retain(|(call, ..)| !done.contains(&call.0)); + for (call, tool_call_id, content, class, untrusted) in pending { + let mut messages = + messages_with_carry(session.baseline(), session.records(), session.carry()); + messages.push(ChatMessage::User { + content: format!( + "{}\n\n{}", + prompt.trim_end(), + cap_chars(&content, cfg.r#loop.tool_result_cap) + ), + }); + let request = ChatRequest { + slot: cfg.slots.background, + messages, + tools: Vec::new(), + thinking: true, + }; + let text = match client.chat_with_retry(&request, &mut |_| {}) { + Ok(completion) => completion.content.unwrap_or_default(), + Err(e) => { + eprintln!("loopd: tool summary: {e}"); + continue; + } + }; + let text = cap_chars(neutralise(&text).as_ref(), cfg.compact.summary_chars); + session + .append(LogRecord::ToolSummary { + time: Timestamp::now(), + call, + tool_call_id, + content: text, + class, + untrusted, + }) + .map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/crates/loopd/tests/tool_summary_call.rs b/crates/loopd/tests/tool_summary_call.rs new file mode 100644 index 0000000..414b7a2 --- /dev/null +++ b/crates/loopd/tests/tool_summary_call.rs @@ -0,0 +1,241 @@ +//! Tests for the opted-in tool-output summary. Do not edit. + +mod support; + +use bxcore::{CallId, DataClass, LogRecord, SessionId, ToolCall}; +use loopd::baseline::Baseline; +use loopd::compact; +use loopd::llama::Client; +use loopd::memory::NoMemory; +use loopd::session::Session; +use loopd::summarise; +use loopd::tools::Registry; +use loopd::turn::{Runtime, run_turn}; +use support::{FakeServer, Home, Reply, ScriptedPort}; + +const CHAT: &str = "/v1/chat/completions"; +const SUMMARISE: &str = "Summarise this tool result.\n"; + +fn sse(content: &str) -> 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":"s","timings":{{"cache_n":0,"prompt_n":10,"predicted_n":1}}}}"# + ); + let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"s","timings":{"cache_n":0,"prompt_n":10,"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 ts() -> bxcore::Timestamp { + bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap() +} + +fn assistant(name: &str) -> LogRecord { + LogRecord::Assistant { + time: ts(), + content: None, + reasoning_content: None, + tool_calls: vec![ToolCall { + id: "call_f".to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + }], + } +} + +fn result(untrusted: bool) -> LogRecord { + LogRecord::ToolResult { + time: ts(), + call: CallId(1), + tool_call_id: "call_f".to_string(), + content: "the fetched page is about tea".to_string(), + class: DataClass::Public, + untrusted, + truncated: false, + } +} + +fn session(home: &Home, cfg: &loopd::config::Config) -> Session { + let registry = Registry::m2b(); + Session::create( + &home.dir, + SessionId::new("a").unwrap(), + Baseline::assemble(cfg, ®istry).unwrap(), + cfg.slots.main, + ) + .unwrap() +} + +#[test] +fn an_opted_in_result_gets_a_summary_on_the_background_slot() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["http_fetch".to_string()]; + server.route(CHAT, vec![sse("tea page")]); + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(assistant("http_fetch")).unwrap(); + s.append(result(false)).unwrap(); + summarise::missing(&cfg, &client, &mut s).unwrap(); + let sent = server.requests_to(CHAT); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].json()["id_slot"], 1); + assert!(sent[0].json().get("tools").is_none()); + assert!( + s.records().iter().any(|r| matches!(r, LogRecord::ToolSummary { content, call, untrusted, .. } if content == "tea page" && *call == CallId(1) && !*untrusted)), + "{:?}", + s.records() + ); +} + +#[test] +fn the_summary_copies_the_result_labels() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["http_fetch".to_string()]; + server.route(CHAT, vec![sse("x")]); + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(assistant("http_fetch")).unwrap(); + s.append(result(true)).unwrap(); + summarise::missing(&cfg, &client, &mut s).unwrap(); + match s.records().last() { + Some(LogRecord::ToolSummary { + class, + untrusted, + tool_call_id, + .. + }) => { + assert_eq!(*class, DataClass::Public); + assert!(*untrusted); + assert_eq!(tool_call_id, "call_f"); + } + other => panic!("{other:?}"), + } +} + +#[test] +fn a_tool_not_in_summarise_is_left_alone() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["http_fetch".to_string()]; + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(assistant("clock")).unwrap(); + s.append(result(false)).unwrap(); + summarise::missing(&cfg, &client, &mut s).unwrap(); + assert!(server.requests_to(CHAT).is_empty()); + assert!( + !s.records() + .iter() + .any(|r| matches!(r, LogRecord::ToolSummary { .. })) + ); +} + +#[test] +fn a_result_that_already_has_a_summary_is_not_summarised_again() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["http_fetch".to_string()]; + server.route(CHAT, vec![sse("once")]); + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(assistant("http_fetch")).unwrap(); + s.append(result(false)).unwrap(); + summarise::missing(&cfg, &client, &mut s).unwrap(); + summarise::missing(&cfg, &client, &mut s).unwrap(); + assert_eq!(server.requests_to(CHAT).len(), 1); + assert_eq!( + s.records() + .iter() + .filter(|r| matches!(r, LogRecord::ToolSummary { .. })) + .count(), + 1 + ); +} + +#[test] +fn messages_stay_the_same_after_a_summary() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["http_fetch".to_string()]; + server.route(CHAT, vec![sse("tea page")]); + let client = Client::new(cfg.clone()); + let mut s = session(&home, &cfg); + s.append(assistant("http_fetch")).unwrap(); + s.append(result(false)).unwrap(); + let before = loopd::baseline::messages(s.baseline(), s.records()); + summarise::missing(&cfg, &client, &mut s).unwrap(); + let after = loopd::baseline::messages(s.baseline(), s.records()); + assert_eq!(before, after); +} + +#[test] +fn after_a_turn_an_opted_in_call_is_summarised() { + let home = Home::new(); + home.write("summarise.md", SUMMARISE); + home.write("compact.md", "Summarise.\n"); + let server = FakeServer::start(); + let mut cfg = home.config(&server.socket); + cfg.compact.summarise = vec!["read_file".to_string()]; + server.route( + CHAT, + vec![ + Reply::fixture("tool_call"), + Reply::fixture("plain"), + sse("hosts file"), + ], + ); + let client = Client::new(cfg.clone()); + let port = ScriptedPort::new(vec![support::ok_result("127.0.0.1 localhost\n")]); + let registry = Registry::m2b(); + let mut s = Session::create( + &home.dir, + SessionId::new("a").unwrap(), + Baseline::assemble(&cfg, ®istry).unwrap(), + cfg.slots.main, + ) + .unwrap(); + let rt = Runtime { + cfg: &cfg, + client: &client, + port: &port, + registry: ®istry, + }; + let mut events = Vec::new(); + let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone())); + compact::finish_turn( + &mut s, + &rt, + &NoMemory, + "hosts?", + None, + &mut |e| events.push(e.clone()), + outcome, + ) + .unwrap(); + assert!( + s.records() + .iter() + .any(|r| matches!(r, LogRecord::ToolSummary { .. })), + "{:?}", + s.records() + ); + assert_eq!( + server.requests_to(CHAT).last().unwrap().json()["id_slot"], + 1 + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index 189a5b5..4e4e047 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 | |---|---|---|---|---|---|---|---| +| M5d2/08-loopd-tool-summary | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/tool_summary_call.rs` and the starter `config/summarise.md` from `docs/plans/M5d2/files/`. Created `crates/loopd/src/summarise.rs` with the `missing` function: walks `session.records()` once, remembering `tool_calls` by `id -> name` and each `ToolSummary`'s `call` in `done`, queueing opted-in `ToolResult`s during the walk (an in-walk `done.contains` still queues), then dropping any whose call is already done after the walk; for each pending it builds `messages_with_carry` plus a `User` message of the trimmed `summarise.md` prompt and the result content capped by `tool_result_cap`, sends on `slots.background` with `tools: Vec::new()` and thinking on, neutralises and caps the completion by `summary_chars`, and appends a `ToolSummary` copying call/tool_call_id/class/untrusted; context-full or other infer errors log `loopd: tool summary: {e}` and skip. Added `pub mod summarise;` after `pub mod sse;` in `lib.rs` (rustfmt order). Edited `finish_turn` in `compact.rs` (F1) to call `summarise::missing` as the first statement of both the `Ok(done)` arm and the `Err(SessionFull)` arm, before `compact_session`. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 96 for summarise.rs and 194 for compact.rs, both under 500. All suites pass: `tool_summary_call` 6, `tool_summary_messages` 3, `compact_turn` 6, `turn` 6. `CallId(pub u64)` `.0` field compiled. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1563 tests (1557 before, plus 6). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/07-loopd-scheduler-cron | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_cron.rs` from `docs/plans/M5d2/files/`. Added the cron loop into `tick` in `crates/loopd/src/scheduler.rs`, right after `self.heartbeat(...)` and before `work` is returned (heartbeat untouched): load `jobs` via `jobs::load(&ctx.cfg.paths.home.join("jobs"))`, on `Err` log `loopd: {e}` and return; for each job in order check `cron::due` (`Err` log+skip, `Ok(false)` skip), then `jobs::live_session` on `{home}/jobs/runs.jsonl` (`Some` push `SkippedJob` and continue), then `session_id` (`Err` log+skip), then `ctx.hold` (`Busy` push `SkippedJob`). On a hold: `Baseline::assemble`, `Session::create` on `slots.background` (on `Exists`, `open` instead), append `Started`, run `run_turn` then `compact::finish_turn` with user text `format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt)` and speaker `None`, append `Finished` on an ok turn else `Failed`, drop the hold, push `TickWork::Job { name, session }`. Added `use crate::cron;` to the imports. `cargo check -p loopd` clean, no error or warning; `wc -l` prints 304, under 500. All four suites pass: `cron` 11, `jobs` 8, `scheduler_cron` 6, `scheduler_heartbeat` 6. `cargo fmt --all` wrapped the `Session::create` call and its `Exists` arm as a nested match; `make gate` printed `gate: ok` with 1557 tests (1551 before, plus the 6 of `scheduler_cron.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/06-loopd-scheduler-heartbeat | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/scheduler_heartbeat.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/scheduler.rs` from the skeleton: `tick` closes idle leftovers once on the first tick, then runs `heartbeat`; `heartbeat` returns early when `every_s == 0` or when not due (`now - last_heartbeat.unwrap_or(started) >= every_s * 1000`), reads the prompt first (pushing `HeartbeatUnreadable` and logging once via `heartbeat_unreadable_logged` on a failed read, without holding), then holds `heartbeat` (pushing `SkippedBusy` on `Busy`), opens it or creates it on `NotFound` with `Baseline::assemble` and the background slot, and runs the turn with `speaker None` and a no-op emit, updating `last_heartbeat` and pushing `Heartbeat` only on an ok outcome. `close_idle_leftovers` loads the jobs and, for each with a live `Started` session, holds it -- dropping the hold and appending a `Failed` run when the hold succeeds and skipping when it is `Busy`, rather than calling `jobs::close_leftovers` blindly, so a busy session is never closed. Added `pub mod scheduler;` after `pub mod llama;` in `lib.rs`. Wired `run_serve` to spawn a thread that `tick`s once a second before `channel::serve`. Made `Held` pub, added `pub struct Busy` and `Context::hold` in `channel.rs`. Appended the given `heartbeat-prompt-unreadable` entry to `docs/runbook.md` (heading count 1). First `make gate` failed on clippy `single_match` in `close_idle_leftovers`, fixed by converting the match to `if let Ok(held)` (the `Busy` case is the skip); the second run printed `gate: ok` with 1551 tests (1545 before, plus the 6 of `scheduler_heartbeat.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/05-loopd-jobs | 2026-10-03 | done | 2 | fail | none | Copied the given test `crates/loopd/tests/jobs.rs` from `docs/plans/M5d2/files/`. Made `crates/loopd/src/jobs.rs` from the skeleton (192 lines, under 500): `session_id` builds `job--t` from `to_rfc3339()` and maps a `SessionId::new` failure to `JobsError::Name`; `load` reads the dir (NotFound -> empty, any other IO error or an unreadable file -> Invalid naming the path), skips names not ending in `.toml`, validates the stem against `[a-z0-9-]+` on the bytes (empty or bad -> Name), parses each with a private `File { schedule, prompt }` and `deny_unknown_fields`; `append` creates the parent dir, opens in append mode, writes one JSON object plus a newline and `sync_all`; `live_session` walks every line of the ledger (missing file -> None) and returns the last matching run's session only when that run is `Started`; `close_leftovers` closes each name whose last run is `Started` with a `Failed` run carrying the same session and `now`, and returns the count (missing file -> 0). Added `pub mod jobs;` after `pub mod llama;` in `lib.rs` (rustfmt order). Wired `run_serve` to call `jobs::load(&cfg.paths.home.join("jobs"))` after `check_summarise`, printing `loopd: {e}` and exiting 1 on error, without creating the directory. Appended the given `jobs-invalid` entry to `docs/runbook.md` (heading count 1). `cargo check -p loopd` clean, no error or warning; all 8 `jobs` tests pass. First `make gate` failed on clippy `collapsible-if` in `append` (the parent-dir guard), which I fixed by combining the two guards into one `if let Some(parent) = path.parent() && !parent.as_os_str().is_empty()`; the second run printed `gate: ok` with 1545 tests (1537 before, plus the 8 of `jobs.rs`). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |