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>
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. Then for eachToolResultwhosetool_call_idmaps to a name incfg.compact.summariseand whosecallis not already summarised, queue 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,
..
} => {
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
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). -
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 count is not 1563; 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.