Files
boxmaker/docs/plans/M5d2/08-loopd-tool-summary.md
T
kyleandGrok 4.6 3726ae585f docs: M5d-1 and M5d-2 implementation plans for Ornith
Eight closed tasks for epochs and compaction, then ten for the scheduler,
ToolSummary, and tiers. Given tests first. Gate 1456 → 1507 → 1572.

Co-Authored-By: Grok 4.6 <noreply@x.ai>
2026-10-02 21:54:37 -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 1561 (1555 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 1561 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 1561; 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.