loopd: background ToolSummary for opted-in results

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 17:11:40 -07:00
co-authored by ornith-1.5-35b-a3b
parent 5adb6a2cc0
commit 57d9aad2d6
6 changed files with 351 additions and 6 deletions
+14 -6
View File
@@ -149,14 +149,22 @@ pub fn finish_turn(
outcome: Result<TurnOutcome, TurnError>,
) -> Result<TurnOutcome, TurnError> {
match outcome {
Err(TurnError::SessionFull) => match compact_session(session, rt, memory) {
Ok(()) => run_turn(session, rt, text, speaker, on_event),
Err(e) => {
log_compact(session, &e);
Err(TurnError::SessionFull)
Err(TurnError::SessionFull) => {
if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) {
eprintln!("loopd: tool summary: {e}");
}
},
match compact_session(session, rt, memory) {
Ok(()) => run_turn(session, rt, text, speaker, on_event),
Err(e) => {
log_compact(session, &e);
Err(TurnError::SessionFull)
}
}
}
Ok(done) => {
if let Err(e) = crate::summarise::missing(rt.cfg, rt.client, session) {
eprintln!("loopd: tool summary: {e}");
}
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)
+1
View File
@@ -15,5 +15,6 @@ pub mod selftest;
pub mod session;
pub mod socket;
pub mod sse;
pub mod summarise;
pub mod tools;
pub mod turn;
+92
View File
@@ -0,0 +1,92 @@
//! 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(())
}
+241
View File
@@ -0,0 +1,241 @@
//! Tests for the opted-in tool-output summary. Do not edit.
mod support;
use bxcore::{CallId, DataClass, LogRecord, SessionId, ToolCall};
use loopd::baseline::Baseline;
use loopd::compact;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::summarise;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
const SUMMARISE: &str = "Summarise this tool result.\n";
fn sse(content: &str) -> Reply {
let content_json = serde_json::to_string(content).unwrap();
let data1 = format!(
r#"data: {{"choices":[{{"finish_reason":null,"index":0,"delta":{{"content":{content_json}}}}}],"id":"s","timings":{{"cache_n":0,"prompt_n":10,"predicted_n":1}}}}"#
);
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"s","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts() -> bxcore::Timestamp {
bxcore::Timestamp::parse("2026-09-18T08:00:00.000Z").unwrap()
}
fn assistant(name: &str) -> LogRecord {
LogRecord::Assistant {
time: ts(),
content: None,
reasoning_content: None,
tool_calls: vec![ToolCall {
id: "call_f".to_string(),
name: name.to_string(),
arguments: "{}".to_string(),
}],
}
}
fn result(untrusted: bool) -> LogRecord {
LogRecord::ToolResult {
time: ts(),
call: CallId(1),
tool_call_id: "call_f".to_string(),
content: "the fetched page is about tea".to_string(),
class: DataClass::Public,
untrusted,
truncated: false,
}
}
fn session(home: &Home, cfg: &loopd::config::Config) -> Session {
let registry = Registry::m2b();
Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap()
}
#[test]
fn an_opted_in_result_gets_a_summary_on_the_background_slot() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 1);
assert_eq!(sent[0].json()["id_slot"], 1);
assert!(sent[0].json().get("tools").is_none());
assert!(
s.records().iter().any(|r| matches!(r, LogRecord::ToolSummary { content, call, untrusted, .. } if content == "tea page" && *call == CallId(1) && !*untrusted)),
"{:?}",
s.records()
);
}
#[test]
fn the_summary_copies_the_result_labels() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("x")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(true)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
match s.records().last() {
Some(LogRecord::ToolSummary {
class,
untrusted,
tool_call_id,
..
}) => {
assert_eq!(*class, DataClass::Public);
assert!(*untrusted);
assert_eq!(tool_call_id, "call_f");
}
other => panic!("{other:?}"),
}
}
#[test]
fn a_tool_not_in_summarise_is_left_alone() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("clock")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert!(server.requests_to(CHAT).is_empty());
assert!(
!s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. }))
);
}
#[test]
fn a_result_that_already_has_a_summary_is_not_summarised_again() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("once")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
summarise::missing(&cfg, &client, &mut s).unwrap();
assert_eq!(server.requests_to(CHAT).len(), 1);
assert_eq!(
s.records()
.iter()
.filter(|r| matches!(r, LogRecord::ToolSummary { .. }))
.count(),
1
);
}
#[test]
fn messages_stay_the_same_after_a_summary() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["http_fetch".to_string()];
server.route(CHAT, vec![sse("tea page")]);
let client = Client::new(cfg.clone());
let mut s = session(&home, &cfg);
s.append(assistant("http_fetch")).unwrap();
s.append(result(false)).unwrap();
let before = loopd::baseline::messages(s.baseline(), s.records());
summarise::missing(&cfg, &client, &mut s).unwrap();
let after = loopd::baseline::messages(s.baseline(), s.records());
assert_eq!(before, after);
}
#[test]
fn after_a_turn_an_opted_in_call_is_summarised() {
let home = Home::new();
home.write("summarise.md", SUMMARISE);
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.compact.summarise = vec!["read_file".to_string()];
server.route(
CHAT,
vec![
Reply::fixture("tool_call"),
Reply::fixture("plain"),
sse("hosts file"),
],
);
let client = Client::new(cfg.clone());
let port = ScriptedPort::new(vec![support::ok_result("127.0.0.1 localhost\n")]);
let registry = Registry::m2b();
let mut s = Session::create(
&home.dir,
SessionId::new("a").unwrap(),
Baseline::assemble(&cfg, &registry).unwrap(),
cfg.slots.main,
)
.unwrap();
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
compact::finish_turn(
&mut s,
&rt,
&NoMemory,
"hosts?",
None,
&mut |e| events.push(e.clone()),
outcome,
)
.unwrap();
assert!(
s.records()
.iter()
.any(|r| matches!(r, LogRecord::ToolSummary { .. })),
"{:?}",
s.records()
);
assert_eq!(
server.requests_to(CHAT).last().unwrap().json()["id_slot"],
1
);
}