Files
boxmaker/docs/plans/M5d2/08-loopd-tool-summary.md
T
kyleandGrok 4.6 2a48b7f36d docs: M5d-2: after gate: ok, do not sum test results
Task 01 told Ornith to add up every `test result:` N passed. A greedy
sed kept the last digit (1512 became 1022) and it stopped with the
correct T1 and given test uncommitted. T50/T59: the last line of
make gate is the count; summing is not a stop.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-03 12:56:14 -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`. Queue each
opted-in `ToolResult` during the walk; **after** the walk, drop any whose `call` is in
`done`. A `ToolResult` can appear before its `ToolSummary` in the same epoch, so an in-walk
`done.contains` still queues 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,
..
} => {
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(())
}
```
`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). After `gate: ok`, do not add up
`test result:` lines.
- [ ] **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's last line is not `gate: ok`; 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.