Skip to main content

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}