Skip to main content

supercode_reduce/engine/
handoff.rs

1//! TR-9 (T24): `/handoff` as extreme projection — "Amp retired
2//! summarization-compaction for /handoff: spawn a new thread seeded with a
3//! drafted objective + curated relevant artifacts, old thread untouched."
4//! (TOKEN-REDUCTION-TECHNIQUES.md@ad3f2a4 §T24). In supercode a handoff is
5//! NOT a new session lineage — it is a named [`ReductionLog`] snapshot over
6//! the SAME sidecar: the projected view collapses to (1) a handoff banner,
7//! (2) an objective paragraph, and (3) a curated keep-set, with EVERY OTHER
8//! turn covered by one [`ReductionKind::TurnsCleared`] span per contiguous
9//! gap between kept turns.
10//!
11//! # Design: reusing `TurnsCleared`, not inventing a new kind
12//!
13//! A handoff keep-set is generally scattered (system prompt + some
14//! user-named early turns + the last K turns), so the "everything else"
15//! complement is generally MULTIPLE disjoint ranges, not one. Rather than a
16//! dedicated `HandoffCleared` reduction kind, this reuses
17//! [`ReductionKind::TurnsCleared`] verbatim — one record per gap — which
18//! required exactly one change to [`super::project_messages`] itself: its
19//! reapplication path used to assume "at most one `TurnsCleared` reduction,
20//! ever" (true for the auto-compactor, `Agent::maybe_compact`); it now
21//! reapplies EVERY existing `TurnsCleared` record it finds, which is a
22//! strict, backward-compatible generalization (a log with zero or one such
23//! record behaves identically to before). `invert`/`verify_log`/
24//! `expand_reduction`/`sidecar_search` already treated `log.reductions` as an
25//! unordered bag keyed by `id`/address — those needed NO changes at all.
26//!
27//! # Banner/objective are view-only, never in the sidecar
28//!
29//! The banner+objective text is never a [`Reduction`] and never recorded in
30//! the [`ReductionLog`] — it is folded into the projected VIEW's leading
31//! system-role message only (merged into an existing leading system message
32//! if the canonical session has one, else inserted as a new one), exactly
33//! like TR-7's [`super::SpanSummary`] audit metadata: view-layer only,
34//! `invert`/`verify_log` never look at it, and it structurally cannot reach
35//! `supercode-harness`'s session export adapters (which rebuild
36//! **only** from the sidecar, never from a projected view).
37//!
38//! # Fresh projection, not an incremental one
39//!
40//! [`build_handoff`] deliberately builds its handoff view from a FRESH
41//! projection (`ReductionLog::default()`) over the canonical session rather
42//! than reapplying whatever reduction log the session already had — a
43//! handoff is "one projection event" (SPEC.md C7's own framing), so kept
44//! turns show their full, untouched content (never stacked on top of
45//! whatever had been truncated/deduped/superseded before), and the emitted
46//! [`ReductionLog`] is exactly the set of reductions the handoff itself
47//! establishes: the standard lossless per-message passes (A7-A9, TR-2, TR-4,
48//! TR-6, TR-10) plus one `TurnsCleared` per non-kept gap.
49
50use std::collections::HashSet;
51
52use super::summarize::SpanSummarizer;
53use super::{
54    count_roles, format_commas, hash_turns_range, make_id, project_messages, render_span_text,
55    set_reduction_id, stub, MessageAddr, Reduction, ReductionKind, ReductionLog, ReductionPolicy,
56    SidecarPtr,
57};
58use crate::Result;
59use supercode_interchange::{ChatMessage, Role};
60
61/// One parsed `--keep` token.
62#[derive(Debug, Clone, PartialEq, Eq)]
63enum KeepToken {
64    /// A single canonical message address (e.g. `"12"`).
65    Addr(usize),
66    /// An inclusive address range (e.g. `"5-9"`).
67    Range(usize, usize),
68    /// A file path (or substring of one) — every message that reads or
69    /// writes a matching path is kept, along with its paired tool call/result.
70    Path(String),
71}
72
73/// Parse one `--keep` token: a bare integer is an address, `A-B` (both sides
74/// integers) is an inclusive range, anything else is a path/substring.
75fn parse_token(tok: &str) -> KeepToken {
76    let t = tok.trim();
77    if let Ok(n) = t.parse::<usize>() {
78        return KeepToken::Addr(n);
79    }
80    if let Some((a, b)) = t.split_once('-') {
81        if let (Ok(a), Ok(b)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
82            return KeepToken::Range(a.min(b), a.max(b));
83        }
84    }
85    KeepToken::Path(t.to_string())
86}
87
88/// Does `call`'s parsed arguments reference `path` (via a `path` or
89/// `file_path` field, substring match)? Covers this crate's own builtins
90/// (`path`) and Claude Code's native `Write`/`Read` (`file_path`).
91fn call_touches_path(call: &supercode_interchange::ToolCall, path: &str) -> bool {
92    let Ok(args) = call.function.parsed_arguments() else {
93        return false;
94    };
95    args.get("path")
96        .or_else(|| args.get("file_path"))
97        .and_then(|v| v.as_str())
98        .is_some_and(|v| v.contains(path))
99}
100
101/// The index of the `Role::Tool` result paired (by `tool_call_id`) to `call`,
102/// searched forward from `after` — mirrors [`super::detect_tool_inputs`]'s
103/// own pairing direction.
104fn paired_result_index(msgs: &[ChatMessage], after: usize, call_id: &str) -> Option<usize> {
105    msgs[after + 1..]
106        .iter()
107        .position(|m| m.role == Role::Tool && m.tool_call_id.as_deref() == Some(call_id))
108        .map(|off| after + 1 + off)
109}
110
111/// The index of the `Role::Assistant` message whose `tool_calls` contains
112/// `call_id`, searched backward from (and excluding) `before` — mirrors
113/// [`super::detect_reads`]'s own pairing direction.
114fn paired_call_index(msgs: &[ChatMessage], before: usize, call_id: &str) -> Option<usize> {
115    msgs[..before]
116        .iter()
117        .rposition(|m| m.role == Role::Assistant && m.tool_calls().iter().any(|c| c.id == call_id))
118}
119
120/// Resolve the full keep-set (SPEC.md TR-9 dev/01/dev/03): user-named
121/// addresses/paths (`tokens`) PLUS the deterministic always-keeps — the
122/// leading system prompt (if any) and the last `keep_last` messages — closed
123/// under tool-call/tool-result pairing so a kept call never dangles without
124/// its result (or vice versa). Pure function of `msgs` alone; canonical
125/// (sidecar) indices throughout.
126pub fn resolve_keep_indices(
127    msgs: &[ChatMessage],
128    tokens: &[String],
129    keep_last: usize,
130) -> Vec<usize> {
131    let len = msgs.len();
132    let mut keep: HashSet<usize> = HashSet::new();
133
134    // Always-keep #1: the leading system prompt (SPEC.md dev/03).
135    let mut i = 0;
136    while i < len && msgs[i].role == Role::System {
137        keep.insert(i);
138        i += 1;
139    }
140
141    // Always-keep #2: the last `keep_last` messages (SPEC.md dev/03),
142    // extended backward past a lone leading tool result (never start the
143    // kept tail mid-call, mirroring `compute_clear_range`'s own tool-
144    // boundary guard for the surviving window).
145    if keep_last > 0 && len > 0 {
146        let mut start = len.saturating_sub(keep_last);
147        while start > 0 && msgs[start].role == Role::Tool {
148            start -= 1;
149        }
150        for idx in start..len {
151            keep.insert(idx);
152        }
153    }
154
155    // User-named tokens.
156    for tok in tokens {
157        match parse_token(tok) {
158            KeepToken::Addr(n) => {
159                if n < len {
160                    keep.insert(n);
161                }
162            }
163            KeepToken::Range(a, b) => {
164                // Clamp the upper bound BEFORE iterating: an unclamped `b`
165                // (e.g. `--keep 5-99999999999`) would otherwise loop billions
166                // of times just to have every out-of-range `n` immediately
167                // discarded by an `n < len` check inside the loop — a
168                // plausible hang on any real session. Clamping first bounds
169                // the iteration count by `len` itself, with identical
170                // behavior for every in-range value (and a no-op when
171                // `len == 0` or `a >= len`, matching the old unclamped loop's
172                // behavior exactly in both cases).
173                if len == 0 {
174                    continue;
175                }
176                let b = b.min(len - 1);
177                if a <= b {
178                    for n in a..=b {
179                        keep.insert(n);
180                    }
181                }
182            }
183            KeepToken::Path(p) => {
184                for (idx, m) in msgs.iter().enumerate() {
185                    if m.role != Role::Assistant {
186                        continue;
187                    }
188                    for call in m.tool_calls() {
189                        if call_touches_path(call, &p) {
190                            keep.insert(idx);
191                            if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
192                                keep.insert(ridx);
193                            }
194                        }
195                    }
196                }
197            }
198        }
199    }
200
201    // Tool-call/tool-result pairing closure: one pass suffices (pairing is
202    // 1:1, no transitive chains).
203    let snapshot: Vec<usize> = keep.iter().copied().collect();
204    for idx in snapshot {
205        match msgs[idx].role {
206            Role::Tool => {
207                if let Some(cid) = msgs[idx].tool_call_id.clone() {
208                    if let Some(aidx) = paired_call_index(msgs, idx, &cid) {
209                        keep.insert(aidx);
210                    }
211                }
212            }
213            Role::Assistant => {
214                for call in msgs[idx].tool_calls().iter().cloned() {
215                    if let Some(ridx) = paired_result_index(msgs, idx, &call.id) {
216                        keep.insert(ridx);
217                    }
218                }
219            }
220            _ => {}
221        }
222    }
223
224    let mut out: Vec<usize> = keep.into_iter().collect();
225    out.sort_unstable();
226    out
227}
228
229/// Maximal contiguous runs of `0..len` NOT in `keep` — the spanning-clear
230/// candidates, one [`ReductionKind::TurnsCleared`] per run.
231fn compute_gaps(len: usize, keep: &HashSet<usize>) -> Vec<(usize, usize)> {
232    let mut gaps = Vec::new();
233    let mut i = 0;
234    while i < len {
235        if keep.contains(&i) {
236            i += 1;
237            continue;
238        }
239        let start = i;
240        while i < len && !keep.contains(&i) {
241            i += 1;
242        }
243        gaps.push((start, i - 1));
244    }
245    gaps
246}
247
248/// Where the objective paragraph came from — [`build_handoff`]'s dev/05
249/// contract: user text always wins when given; otherwise, drafting is
250/// attempted only when `draft_objective` is on AND a summarizer is injected,
251/// and any failure (no summarizer, an `Err`, or a blank result) falls back to
252/// the plain banner with no objective line at all.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum ObjectiveSource {
255    /// `--objective "<text>"` was given verbatim (trimmed).
256    UserSupplied,
257    /// Drafted via the injected [`SpanSummarizer`] (TR-7 plumbing reuse).
258    Drafted,
259    /// No objective at all: `--draft-objective` off (the default), no
260    /// summarizer injected, or the side-call errored/returned blank.
261    Fallback,
262}
263
264/// TR-9's reuse of TR-7's injectable side-call plumbing (SPEC.md TR-9 dev/05):
265/// user-supplied text always wins outright (no side-call attempted); when
266/// none is given AND `draft_objective` is on AND a summarizer is injected,
267/// draft one paragraph from a rendering of the whole session — any error or
268/// blank result falls back to no objective (the plain banner), exactly like
269/// [`super::prepare_cleared_turns_summary`]'s own failure-fallback contract.
270/// Off by default: a caller must both pass `draft_objective: true` AND inject
271/// a summarizer for drafting to ever be attempted at all.
272fn resolve_objective(
273    msgs: &[ChatMessage],
274    objective: Option<&str>,
275    draft_objective: bool,
276    summarizer: Option<&dyn SpanSummarizer>,
277) -> (Option<String>, ObjectiveSource) {
278    if let Some(text) = objective {
279        let t = text.trim();
280        if !t.is_empty() {
281            return (Some(t.to_string()), ObjectiveSource::UserSupplied);
282        }
283    }
284    if draft_objective {
285        if let Some(s) = summarizer {
286            let span_text = render_span_text(msgs);
287            if let Ok(t) = s.summarize(&span_text) {
288                let t = t.trim();
289                if !t.is_empty() {
290                    return (Some(t.to_string()), ObjectiveSource::Drafted);
291                }
292            }
293        }
294    }
295    (None, ObjectiveSource::Fallback)
296}
297
298/// Render the handoff banner: a fixed honesty header naming every gap's
299/// reduction id (so `expand_reduction("<id>")`/`/expand <id>` are spelled
300/// out, mirroring TR-7's own placeholder banner), plus the objective
301/// paragraph when one was resolved.
302fn format_banner(objective: Option<&str>, gap_ids: &[String], total_cleared: usize) -> String {
303    let mut s = String::from("=== HANDOFF ===\n");
304    if gap_ids.is_empty() {
305        s.push_str(
306            "This is a curated continuation of a prior session. Nothing was cleared — the \
307             full session is already in view.",
308        );
309    } else {
310        s.push_str(&format!(
311            "This is a curated continuation of a prior session. {} earlier turn(s) were \
312             cleared into {} spanning reduction(s) and remain fully recoverable — call \
313             expand_reduction(\"<id>\") (or `/expand <id>`) for any of: {}. The full session \
314             is unchanged in its sidecar.",
315            format_commas(total_cleared),
316            gap_ids.len(),
317            gap_ids.join(", "),
318        ));
319    }
320    if let Some(obj) = objective {
321        s.push_str("\n\nOBJECTIVE: ");
322        s.push_str(obj);
323    }
324    s
325}
326
327/// The complete output of [`build_handoff`].
328#[derive(Debug, Clone)]
329pub struct HandoffResult {
330    /// The projected view: banner+objective folded into the leading
331    /// system-role message, followed by every kept message (in original
332    /// order, with the standard lossless per-message reductions already
333    /// applied) and one placeholder per cleared gap.
334    pub view: Vec<ChatMessage>,
335    /// The fresh reduction log this handoff establishes (SPEC.md: "a named
336    /// `ReductionLog` snapshot") — the standard per-message passes plus one
337    /// `TurnsCleared` per gap. Never contains the banner/objective text.
338    pub log: ReductionLog,
339    /// Canonical (sidecar) indices in the keep-set, sorted ascending.
340    pub kept_indices: Vec<usize>,
341    /// The ids of the `TurnsCleared` reductions minted, one per gap, in
342    /// chronological (address) order.
343    pub gap_ids: Vec<String>,
344    /// Where the objective paragraph came from.
345    pub objective_source: ObjectiveSource,
346    /// The resolved objective text, if any (also folded into `view[0]`, kept
347    /// here separately for callers/tests that want it without re-parsing).
348    pub objective_text: Option<String>,
349}
350
351/// The base projection policy [`build_handoff`] runs its first (standard,
352/// per-message) pass under: every lossless SPEC.md default lever (A7-A9,
353/// TR-2, TR-4, TR-6, TR-10) stays on, but A10 (`clear_turns_older_than`) is
354/// forced off — the handoff's own multi-gap spanning clear (below) replaces
355/// it entirely, so the single-range auto-compactor pass must never also fire
356/// in the same call.
357fn base_policy() -> ReductionPolicy {
358    ReductionPolicy {
359        clear_turns_older_than: None,
360        ..ReductionPolicy::default()
361    }
362}
363
364/// Build a handoff projection over `msgs` (the canonical, full-fidelity
365/// session messages — e.g. `sidecar.messages`). SPEC.md TR-9: produces a new
366/// PROJECTED continuation — banner + objective + curated keep-set, with
367/// every other turn covered by one `TurnsCleared` span per gap between kept
368/// turns — over the SAME sidecar (this function never touches a sidecar or
369/// disk at all; persisting `HandoffResult::log`/`view` is the caller's job,
370/// e.g. `supercode handoff`'s CLI handler).
371///
372/// `keep_tokens` are `--keep` selectors (addresses, address ranges, or file
373/// paths/substrings); `keep_last` is the deterministic always-keep tail
374/// length (SPEC.md dev/03, alongside the always-kept leading system prompt).
375/// `objective`/`draft_objective`/`summarizer` implement SPEC.md dev/05 (see
376/// `resolve_objective`).
377pub fn build_handoff(
378    msgs: &[ChatMessage],
379    keep_tokens: &[String],
380    keep_last: usize,
381    objective: Option<&str>,
382    draft_objective: bool,
383    summarizer: Option<&dyn SpanSummarizer>,
384) -> Result<HandoffResult> {
385    let kept_indices = resolve_keep_indices(msgs, keep_tokens, keep_last);
386    let keep: HashSet<usize> = kept_indices.iter().copied().collect();
387
388    // Step 1: the standard lossless per-message passes over the WHOLE
389    // canonical session, fresh (SPEC.md: "one projection event") — A10
390    // stays off; the multi-gap spanning clear below is this function's own
391    // A10 analog. `view`/`log` stay index-parallel to `msgs` here (no
392    // cardinality-changing pass has run yet), which every splice below
393    // depends on.
394    let (mut view, mut log) = project_messages(msgs, &base_policy(), &ReductionLog::default());
395    debug_assert_eq!(view.len(), msgs.len());
396
397    let gaps = compute_gaps(msgs.len(), &keep);
398    let mut new_ids: Vec<String> = Vec::with_capacity(gaps.len());
399    let mut ordinal = log.reductions.len();
400
401    // Mint one `TurnsCleared` per gap, splicing from the LAST gap to the
402    // FIRST so an earlier splice's cardinality shrink never invalidates a
403    // later (still `msgs`-indexed) one.
404    // ordinal is offset from log.reductions.len() (not 0-based), so a plain
405    // enumerate() over gaps would produce the wrong ids — explicit counter is correct here.
406    #[allow(clippy::explicit_counter_loop)]
407    for &(first, last) in gaps.iter().rev() {
408        let range = &msgs[first..=last];
409        let (hash, range_bytes) = hash_turns_range(range)?;
410        let (user, assistant, tool) = count_roles(range);
411        let id = make_id(ordinal, &hash);
412        ordinal += 1;
413        let summary = format!(
414            "turns {first}..{} cleared by handoff ({} messages: {user} user, {assistant} \
415             assistant, {tool} tool; {}B) — full turns in session sidecar",
416            last + 1,
417            format_commas(range.len()),
418            format_commas(range_bytes),
419        );
420        let placeholder = stub::format(stub::Kind::TurnsCleared, &id, &summary);
421
422        // Any per-message reduction step 1 minted inside this gap is now
423        // subsumed by the single spanning placeholder (mirrors the existing
424        // A10 pass's own subsumption rule) — `invert` restores the WHOLE
425        // range straight from the sidecar regardless.
426        log.reductions
427            .retain(|r| !(r.ptr.addr.index >= first && r.ptr.addr.index <= last));
428
429        let reduction = Reduction {
430            id: id.clone(),
431            kind: ReductionKind::TurnsCleared {
432                first,
433                last,
434                summary: None,
435            },
436            ptr: SidecarPtr {
437                addr: MessageAddr {
438                    index: first,
439                    role: range[0].role,
440                },
441                span: None,
442                content_hash: hash,
443            },
444            placeholder,
445        };
446
447        let mut stub_msg = ChatMessage::system(reduction.placeholder.clone());
448        set_reduction_id(&mut stub_msg, &reduction.id);
449        view.splice(first..=last, std::iter::once(stub_msg));
450
451        log.reductions.push(reduction);
452        new_ids.push(id);
453    }
454    new_ids.reverse(); // restore chronological (address) order
455
456    let total_cleared: usize = gaps.iter().map(|&(f, l)| l - f + 1).sum();
457    let (objective_text, objective_source) =
458        resolve_objective(msgs, objective, draft_objective, summarizer);
459    let banner = format_banner(objective_text.as_deref(), &new_ids, total_cleared);
460
461    // Banner/objective: view-only, folded into the leading system-role
462    // message (merged if the CANONICAL session genuinely starts with one —
463    // checked against `msgs`, never `view`, since `view[0]` could otherwise
464    // be a `TurnsCleared` gap placeholder that also happens to be
465    // system-role and must never be contaminated with banner text) — NEVER
466    // recorded as a `Reduction`, so it can never reach the sidecar or an
467    // export (both rebuild only from sidecar bytes, never from this view).
468    // Index 0 being genuinely system-role in `msgs` also means index 0 is
469    // unconditionally in the keep-set (the always-keep loop above), so
470    // `view[0]` really is that same, unreduced message here.
471    if msgs.first().map(|m| m.role) == Some(Role::System) {
472        let existing = view[0].content.clone().unwrap_or_default();
473        view[0].content = Some(if existing.is_empty() {
474            banner
475        } else {
476            format!("{banner}\n\n{existing}")
477        });
478    } else {
479        view.insert(0, ChatMessage::system(banner));
480    }
481
482    Ok(HandoffResult {
483        view,
484        log,
485        kept_indices,
486        gap_ids: new_ids,
487        objective_source,
488        objective_text,
489    })
490}