Task 08 filters pending after the records walk so a ToolResult before its ToolSummary is not summarised twice. Task 10 names Tally to satisfy clippy type_complexity. Task 06 does not return from tick when every_s is 0. README records 12/12 mutations caught. Co-Authored-By: Grok 4.6 <noreply@x.ai>
257 lines
10 KiB
Markdown
257 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).
|
|
- [ ] **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.
|