Files
boxmaker/docs/plans/M5d2/08-loopd-tool-summary.md
T
kyleandGrok 4.6 df987c3058 docs: M5d-2 checklist: given-test lifetimes and cron AND
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>
2026-10-03 01:31:07 -07:00

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 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:

    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:

//! 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 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.