supercode/reduce/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
29/// Injectable summarization side-call (SPEC.md TR-7's "explicit, budgeted,
30/// injectable side-call"). A real implementation calls out to a cheap model;
31/// tests inject a deterministic fake (and, for the dev/03 fault-injection
32/// AC, one that always errors) — nothing in this crate's own test suite ever
33/// performs a real network/model call.
34pub trait SpanSummarizer {
35 /// Summarize `span_text` (the rendering [`render_span_text`] produces)
36 /// into a short paragraph. `Err` — for any reason, including a
37 /// caller-modeled timeout or budget exhaustion — means the caller must
38 /// fall back to the deterministic stub; this call must never block or
39 /// fail the surrounding reduce pass.
40 ///
41 /// **Threading note for implementers.** This method is *synchronous*,
42 /// and is invoked synchronously from `Agent::build_request_messages`
43 /// (via [`super::prepare_cleared_turns_summary`]), which itself runs
44 /// on a tokio worker thread as part of `Agent::run_loop`'s async
45 /// machinery. No implementation in this crate performs real network
46 /// I/O — only in-memory test stubs implement this trait today — but a
47 /// FUTURE real, provider-backed implementation must NOT perform a
48 /// blocking network call inline here: doing so would stall that tokio
49 /// worker thread on every reduce cycle in which TR-7 fires. Such an
50 /// implementation must instead run the call off-thread and block only
51 /// on that (e.g. `tokio::task::block_in_place` + `Handle::block_on`,
52 /// or a dedicated blocking thread/pool joined synchronously), so the
53 /// surrounding async runtime is never starved by this call.
54 fn summarize(&self, span_text: &str) -> crate::Result<String>;
55
56 /// Identifier of the model behind this summarizer (e.g.
57 /// `"claude-haiku-4-5"`), recorded on [`super::SpanSummary::model_id`]
58 /// for the audit trail (SPEC.md TR-7 dev/04).
59 fn model_id(&self) -> &str;
60}
61
62/// The fixed, in-repo, VERSIONED summarization prompt template (SPEC.md
63/// TR-7: "summarization prompt is fixed and versioned in-repo"). Bump this
64/// any time [`render_prompt`]'s wording changes — the version rides the
65/// audit trail ([`super::SpanSummary::prompt_version`]) precisely so a later
66/// reader can tell which wording produced a given summary.
67pub const PROMPT_VERSION: &str = "tr7-summary-v1";
68
69/// Render the fixed prompt for summarizing one cleared span's rendered text
70/// (see [`render_span_text`]). Exposed so a real [`SpanSummarizer`]
71/// implementation (elsewhere — never in this crate's test-only code) sends
72/// exactly the wording [`PROMPT_VERSION`] names.
73pub fn render_prompt(span_text: &str) -> String {
74 format!(
75 "You are compacting an AI coding agent's conversation history. Write a \
76 short (2-4 sentence) factual summary of the transcript span below, \
77 preserving concrete facts (file names, commands, decisions, results) \
78 a later turn might need to reference. Do not editorialize, and do \
79 not state anything not present in the span.\n\n\
80 --- BEGIN SPAN ---\n\
81 {span_text}\n\
82 --- END SPAN ---\n"
83 )
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn render_prompt_embeds_the_span_verbatim() {
92 let p = render_prompt("user: hello\nassistant: hi\n");
93 assert!(p.contains("user: hello"));
94 assert!(p.contains("assistant: hi"));
95 assert!(p.contains("BEGIN SPAN"));
96 assert!(p.contains("END SPAN"));
97 }
98}