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>
10 KiB
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 testcrates/loopd/tests/tool_summary_call.rsand the starterconfig/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 eachAssistant, remembertool_callsbyid → name. From eachToolSummary, remembercall: CallId. Queue each opted-inToolResultduring the walk; after the walk, drop any whosecallis indone. AToolResultcan appear before itsToolSummaryin the same epoch, so an in-walkdone.containsstill queues it. Do not append while iteratingrecords(). -
The call.
messages_with_carry(session.baseline(), session.records(), session.carry()), then oneChatMessage::Userwhose content isformat!("{}\n\n{}", prompt.trim_end(), crate::compact::cap_chars(result_content, cfg.r#loop.tool_result_cap))wherepromptisread_to_stringofcfg.baseline.system.with_file_name("summarise.md"). Missing file: returnErrnaming 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_charstocfg.compact.summary_chars. AppendLogRecord::ToolSummary { time: Timestamp::now(), call, tool_call_id, content, class, untrusted }copyingcall,tool_call_id,class,untrustedfrom thatToolResult. -
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 theOk(done)arm and theErr(SessionFull)arm, beforecompact_session: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(notmissingalone) and expects aToolSummary. -
cap_charsispub(crate)incompact.rs(M5d-1 task 05). -
tool_call.httpcallsread_file. The last given test setssummarise = ["read_file"]. Default["http_fetch"]would leave it alone. -
rustfmt
modorder:summariseaftersse/sessionas rustfmt decides. -
The items the edits use. This table is complete.
Item What it does messages_with_carryprefix plus carry ChatRequest.tools: Vec::new()omits toolson the wireneutralisebxcore::sanitizecap_charscharacter cap Session::appendthe new record
The function
crates/loopd/src/summarise.rs:
//! 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
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.
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 targettool_summary_calldoes not compile (E0432unresolvedloopd::summarise). Every other target compiles. -
3. The edits.
summarise.rs,pub mod summarise;, F1.cargo check -p loopdprints no error and no warning.wc -l crates/loopd/src/summarise.rsandwc -l crates/loopd/src/compact.rseach ≤ 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 intool_summary_call. -
5. Check.
git diff --statlistscrates/loopd/src/summarise.rs,crates/loopd/src/lib.rs,crates/loopd/src/compact.rs. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1563 (1557 before, plus 6). Aftergate: ok, do not add uptest result:lines. -
7. Log and commit. Task cell
M5d2/08-loopd-tool-summary. Stage withgit add crates/loopd config/summarise.md docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1563 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/summarise.rs,crates/loopd/src/lib.rs,crates/loopd/src/compact.rs,crates/loopd/tests/tool_summary_call.rs,config/summarise.mdand the log.
Stop and report if
- The branch check fails; a "before" block is not found exactly once; step 2 differs;
cargo checkwarns; a test fails; the gate's last line is notgate: ok; you send tools on the summary request; you sendslots.main; you replay the summary inmessages(); 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.