Files
boxmaker/docs/plans/M5d2/08-loopd-tool-summary.md
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
session_slot and the scheduler tests held a temporary JSON Value across
statements (E0716). Cron now pins DOM and DOW as AND, which the given tests
had not. Gate counts from task 04 on move up by one.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 01:31:07 -07:00

258 lines
10 KiB
Markdown

# 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<String, String> = HashMap::new();
let mut done: HashSet<u64> = 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 **1563** (1557 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 1563 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 1563; 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.