# M5d-2 task 08: opted-in tool-output summaries **Branch:** `m5d2`. Check it: `git branch --show-current` prints `m5d2` and `git status --short` is empty; otherwise stop. Never switch branch. **Commit subject:** `loopd: background ToolSummary for opted-in results` **Log row:** the Task cell is `M5d2/08-loopd-tool-summary`. ## Goal After a turn that logged an opted-in `ToolResult` with no matching `ToolSummary`, run a no-tools completion on the background slot and append `ToolSummary` with the result's labels (spec 4.3). `messages()` already ignores it (task 02). `http_fetch` opts in via `[compact] summarise`. `summarise::missing` is the function. `finish_turn` calls it on `Ok` and on `SessionFull`, **before** compaction, so the current epoch still holds the `ToolResult` (README known limits: v1 runs this inside the turn's hold). The starter `config/summarise.md` is copied like `config/heartbeat.md`. ## Every place that changes | Place | What happens there | |---|---| | `crates/loopd/src/summarise.rs` | new: `missing` | | `crates/loopd/src/lib.rs` | `pub mod summarise;` | | `crates/loopd/src/compact.rs` | `finish_turn` calls `missing` before compact (edit F1) | | `config/summarise.md` | new, given starter | | `crates/loopd/tests/tool_summary_call.rs` | new, given | | everything else | **no change** | ## Files - Copy, from `docs/plans/M5d2/files/` (the same path under it): the new test `crates/loopd/tests/tool_summary_call.rs` and the starter `config/summarise.md`. - Create: `crates/loopd/src/summarise.rs` (function below). - Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`, `docs/implementer-log.md`. - Do not touch: any other file. ## Facts you need - **Which results.** Walk `session.records()` once. From each `Assistant`, remember `tool_calls` by `id → name`. From each `ToolSummary`, remember `call: CallId`. Then for each `ToolResult` whose `tool_call_id` maps to a name in `cfg.compact.summarise` and whose `call` is not already summarised, queue it. Do not append while iterating `records()`. - **The call.** `messages_with_carry(session.baseline(), session.records(), session.carry())`, then one `ChatMessage::User` whose content is `format!("{}\n\n{}", prompt.trim_end(), crate::compact::cap_chars(result_content, cfg.r#loop.tool_result_cap))` where `prompt` is `read_to_string` of `cfg.baseline.system.with_file_name("summarise.md")`. Missing file: return `Err` naming the path (do not guess). `ChatRequest { slot: cfg.slots.background, tools: Vec::new(), thinking: true, messages }`. `client.chat_with_retry(&request, &mut |_| {})`. - **The record.** Completion `content.unwrap_or_default()`, `neutralise`, `cap_chars` to `cfg.compact.summary_chars`. Append `LogRecord::ToolSummary { time: Timestamp::now(), call, tool_call_id, content, class, untrusted }` copying `call`, `tool_call_id`, `class`, `untrusted` from that `ToolResult`. - **Context-full / other infer errors.** Skip that result (log `loopd: tool summary: {e}`), continue the queue. Do not fail the turn. - **`finish_turn`.** At the start of the `Ok(done)` arm and the `Err(SessionFull)` arm, before `compact_session`: ```rust if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) { eprintln!("loopd: tool summary: {e}"); } ``` A compact-due turn summarises first, then compacts. The last given test goes through `finish_turn` (not `missing` alone) and expects a `ToolSummary`. - **`cap_chars` is `pub(crate)`** in `compact.rs` (M5d-1 task 05). - **`tool_call.http` calls `read_file`.** The last given test sets `summarise = ["read_file"]`. Default `["http_fetch"]` would leave it alone. - rustfmt `mod` order: `summarise` after `sse` / `session` as rustfmt decides. - The items the edits use. This table is complete. | Item | What it does | |---|---| | `messages_with_carry` | prefix plus carry | | `ChatRequest.tools: Vec::new()` | omits `tools` on the wire | | `neutralise` | `bxcore::sanitize` | | `cap_chars` | character cap | | `Session::append` | the new record | ## The function `crates/loopd/src/summarise.rs`: ```rust //! 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, .. } => { if done.contains(&call.0) { continue; } 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, )); } _ => {} } } 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(()) } ``` `CallId` is a tuple struct `CallId(pub u64)` (already used as `CallId(1)` in tests). If `call.0` does not compile, stop and report. ### `crates/loopd/src/compact.rs` **F1.** The `Ok(done)` arm. The lines ```rust 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) } ``` become the same, with the `missing` call inserted as the first statement of the arm (see Facts). The `Err(TurnError::SessionFull)` arm gets the same `missing` call before `compact_session`. ## Steps - [ ] **1. Copy the given files.** ```sh cp docs/plans/M5d2/files/crates/loopd/tests/tool_summary_call.rs crates/loopd/tests/ cp docs/plans/M5d2/files/config/summarise.md config/summarise.md ``` - [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms: test target `tool_summary_call` does not compile (`E0432` unresolved `loopd::summarise`). Every other target compiles. - [ ] **3. The edits.** `summarise.rs`, `pub mod summarise;`, F1. `cargo check -p loopd` prints no error and no warning. `wc -l crates/loopd/src/summarise.rs` and `wc -l crates/loopd/src/compact.rs` each ≤ 500. - [ ] **4. The tests.** `cargo test -p loopd --test tool_summary_call --test tool_summary_messages --test compact_turn --test turn`: every test passes, 6 in `tool_summary_call`. - [ ] **5. Check.** `git diff --stat` lists `crates/loopd/src/summarise.rs`, `crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`. - [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last line `gate: ok`. Test count **1562** (1556 before, plus 6). - [ ] **7. Log and commit.** Task cell `M5d2/08-loopd-tool-summary`. Stage with `git add crates/loopd config/summarise.md docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md` says. ## Done when - `make gate` prints `gate: ok` with 1562 tests. - `git diff HEAD~1 --stat` lists only `crates/loopd/src/summarise.rs`, `crates/loopd/src/lib.rs`, `crates/loopd/src/compact.rs`, `crates/loopd/tests/tool_summary_call.rs`, `config/summarise.md` and the log. ## Stop and report if - The branch check fails; a "before" block is not found exactly once; step 2 differs; `cargo check` warns; a test fails; the gate count is not 1562; you send tools on the summary request; you send `slots.main`; you replay the summary in `messages()`; you summarise a name not in the list; you summarise twice; you compact before summarising. ## What the given tests pin An opted-in result is summarised on slot 1 with no `tools` key; labels are copied; a name not in the list is left alone; a second `missing` does not call again; `messages()` is unchanged after a summary; `finish_turn` after a `read_file` turn appends a `ToolSummary` on slot 1.