supercode_reduce/engine/summarize.rs
1//! TR-7 (T20): the injectable side-call that turns an A10 `TurnsCleared`
2//! span into a short LLM-written summary paragraph, instead of leaving it as
3//! the deterministic `[turns cleared]` stub — "what incumbents' compaction
4//! writes, but with the original retained in the sidecar" (TR-7.md).
5//!
6//! **Purity boundary.** [`super::project_messages`] must stay pure and
7//! I/O-free (its own doc comment: "nothing here touches the filesystem").
8//! Exactly like A8's disk probe ([`super::probe_read_freshness`]), the one
9//! side-call this feature ever makes lives OUTSIDE projection, in
10//! [`super::prepare_cleared_turns_summary`] — called by the driving caller
11//! (`Agent::build_request_messages`, or a test) BEFORE
12//! `project`/`project_messages` ever runs, with the result threaded through
13//! [`super::ReductionPolicy::cleared_turns_summary`] (data, not a config
14//! knob — mirrors [`super::ReductionPolicy::read_freshness`]).
15//!
16//! **Never blocks, never fails the pass.** [`SpanSummarizer::summarize`]
17//! returning `Err` (a real implementation's way of modeling a timeout, a
18//! provider error, a budget exhaustion — whatever the caller wants) simply
19//! means [`super::prepare_cleared_turns_summary`] returns `None`, and
20//! `project_messages` falls back to the byte-identical deterministic stub —
21//! SPEC.md TR-7 dev/03.
22//!
23//! **Off by default.** [`super::ReductionPolicy::summarize_cleared_turns`]
24//! defaults to `false`; with it off, `project_messages` never even looks at
25//! [`super::ReductionPolicy::cleared_turns_summary`], so the A10 stub stays
26//! byte-identical to pre-TR-7 behavior (dev/01) regardless of what a caller
27//! did or didn't precompute.
28
29use crate::Result;
30
31/// Injectable summarization side-call (SPEC.md TR-7's "explicit, budgeted,
32/// injectable side-call"). A real implementation calls out to a cheap model;
33/// tests inject a deterministic fake (and, for the dev/03 fault-injection
34/// AC, one that always errors) — nothing in this crate's own test suite ever
35/// performs a real network/model call.
36pub trait SpanSummarizer {
37 /// Summarize `span_text` (the rendering `render_span_text` produces)
38 /// into a short paragraph. `Err` — for any reason, including a
39 /// caller-modeled timeout or budget exhaustion — means the caller must
40 /// fall back to the deterministic stub; this call must never block or
41 /// fail the surrounding reduce pass.
42 ///
43 /// **Threading note for implementers.** This method is *synchronous*,
44 /// and is invoked synchronously from `Agent::build_request_messages`
45 /// (via [`super::prepare_cleared_turns_summary`]), which itself runs
46 /// on a tokio worker thread as part of `Agent::run_loop`'s async
47 /// machinery. No implementation in this crate performs real network
48 /// I/O — only in-memory test stubs implement this trait today — but a
49 /// FUTURE real, provider-backed implementation must NOT perform a
50 /// blocking network call inline here: doing so would stall that tokio
51 /// worker thread on every reduce cycle in which TR-7 fires. Such an
52 /// implementation must instead run the call off-thread and block only
53 /// on that (e.g. `tokio::task::block_in_place` + `Handle::block_on`,
54 /// or a dedicated blocking thread/pool joined synchronously), so the
55 /// surrounding async runtime is never starved by this call.
56 fn summarize(&self, span_text: &str) -> Result<String>;
57
58 /// Identifier of the model behind this summarizer (e.g.
59 /// `"claude-haiku-4-5"`), recorded on [`super::SpanSummary::model_id`]
60 /// for the audit trail (SPEC.md TR-7 dev/04).
61 fn model_id(&self) -> &str;
62}
63
64/// The fixed, in-repo, VERSIONED summarization prompt template (SPEC.md
65/// TR-7: "summarization prompt is fixed and versioned in-repo"). Bump this
66/// any time [`render_prompt`]'s wording changes — the version rides the
67/// audit trail ([`super::SpanSummary::prompt_version`]) precisely so a later
68/// reader can tell which wording produced a given summary.
69pub const PROMPT_VERSION: &str = "tr7-summary-v1";
70
71/// Render the fixed prompt for summarizing one cleared span's rendered text
72/// (see `render_span_text`). Exposed so a real [`SpanSummarizer`]
73/// implementation (elsewhere — never in this crate's test-only code) sends
74/// exactly the wording [`PROMPT_VERSION`] names.
75pub fn render_prompt(span_text: &str) -> String {
76 format!(
77 "You are compacting an AI coding agent's conversation history. Write a \
78 short (2-4 sentence) factual summary of the transcript span below, \
79 preserving concrete facts (file names, commands, decisions, results) \
80 a later turn might need to reference. Do not editorialize, and do \
81 not state anything not present in the span.\n\n\
82 --- BEGIN SPAN ---\n\
83 {span_text}\n\
84 --- END SPAN ---\n"
85 )
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn render_prompt_embeds_the_span_verbatim() {
94 let p = render_prompt("user: hello\nassistant: hi\n");
95 assert!(p.contains("user: hello"));
96 assert!(p.contains("assistant: hi"));
97 assert!(p.contains("BEGIN SPAN"));
98 assert!(p.contains("END SPAN"));
99 }
100}