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>
258 lines
10 KiB
Markdown
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.
|