Skip to main content

supercode_harness/
session_title.rs

1//! P4b (COMPOSABLE-HARNESS-DESIGN.md §5.2 "P4", §1.6/§3.1
2//! `core.session.auto_title`, catalog:150, D-9): auto-title / session
3//! summary — a small-model side-call that titles a session, mirroring
4//! [`crate::reduce::summarize`]'s plumbing exactly: an injectable trait
5//! (real implementations call out to a model; this crate's own tests only
6//! ever inject deterministic fakes — no real network/model call anywhere in
7//! this crate, same posture as [`crate::reduce::summarize::SpanSummarizer`]),
8//! a fixed versioned prompt, and a "never blocks, never fails the caller"
9//! contract.
10//!
11//! **Small-model routing (D-9).** `Config::small_model` (P4a) is "a knob a
12//! caller reads, not a routing loop this crate runs" — the same is true
13//! here: [`auto_title`] takes an already-constructed [`SessionTitler`], and
14//! it is the CALLER's job to have built that titler against
15//! `config.small_model.clone().unwrap_or_else(|| config.model.clone())`
16//! (the D-9 main-model fallback) before installing it via
17//! [`crate::Agent::set_session_titler`].
18//!
19//! **Persistence.** The title TEXT this module produces is handed to
20//! [`crate::store::SessionStore::set_title`] (already existing, S14) by the
21//! caller — this module only produces the string; it never touches the
22//! filesystem itself.
23
24use supercode_interchange::ChatMessage;
25
26/// Injectable session-titling side-call (mirrors
27/// [`crate::reduce::summarize::SpanSummarizer`] exactly). `Err` — for any
28/// reason, including a caller-modeled timeout or budget exhaustion — means
29/// the caller must fall back to no title (or the session's existing one);
30/// this call must never block or fail the surrounding session-save path.
31pub trait SessionTitler {
32    /// Produce a short title from `transcript_preview` (the rendering
33    /// [`render_transcript_preview`] produces).
34    fn title(&self, transcript_preview: &str) -> crate::Result<String>;
35
36    /// Identifier of the model behind this titler (e.g.
37    /// `"claude-haiku-4-5"`), for callers that want to record provenance
38    /// alongside the title.
39    fn model_id(&self) -> &str;
40}
41
42/// The fixed, in-repo, VERSIONED titling prompt template (mirrors
43/// `reduce::summarize::PROMPT_VERSION`'s precedent — bump this any time
44/// [`render_prompt`]'s wording changes).
45pub const PROMPT_VERSION: &str = "session-title-v1";
46
47/// A produced title is trimmed and capped at this many characters — a
48/// runaway/uncooperative model response must not become an unreasonably
49/// long session name.
50pub const MAX_TITLE_CHARS: usize = 80;
51
52/// Render the first `max_chars` characters of the conversation (skipping the
53/// system prompt at index 0) as the titling input — bounded so a huge
54/// session doesn't blow up the side-call's own request size.
55pub fn render_transcript_preview(history: &[ChatMessage], max_chars: usize) -> String {
56    let mut out = String::new();
57    for msg in history.iter().skip(1) {
58        if out.len() >= max_chars {
59            break;
60        }
61        let role = match msg.role {
62            supercode_interchange::Role::User => "user",
63            supercode_interchange::Role::Assistant => "assistant",
64            supercode_interchange::Role::System => "system",
65            supercode_interchange::Role::Tool => continue, // tool output is noise for a title
66        };
67        if let Some(content) = &msg.content {
68            out.push_str(role);
69            out.push_str(": ");
70            out.push_str(content);
71            out.push('\n');
72        }
73    }
74    out.truncate(out.floor_char_boundary_compat(max_chars));
75    out
76}
77
78/// Char-boundary-safe truncation helper (stable Rust has no
79/// `floor_char_boundary` yet) — walk back from `max` to the nearest valid
80/// UTF-8 boundary so we never panic mid-codepoint.
81trait FloorCharBoundary {
82    fn floor_char_boundary_compat(&self, max: usize) -> usize;
83}
84impl FloorCharBoundary for str {
85    fn floor_char_boundary_compat(&self, max: usize) -> usize {
86        if max >= self.len() {
87            return self.len();
88        }
89        let mut end = max;
90        while end > 0 && !self.is_char_boundary(end) {
91            end -= 1;
92        }
93        end
94    }
95}
96
97/// Render the fixed prompt for titling `transcript_preview`.
98pub fn render_prompt(transcript_preview: &str) -> String {
99    format!(
100        "You are naming an AI coding agent's session. Write a short (3-8 word) \
101         descriptive title for the conversation below. Do not use quotes or a \
102         trailing period. Do not editorialize.\n\n\
103         --- BEGIN TRANSCRIPT ---\n\
104         {transcript_preview}\n\
105         --- END TRANSCRIPT ---\n"
106    )
107}
108
109/// Produce an auto-title for `history` via `titler`, or `None` if the
110/// side-call errors, returns empty text, or `titler` is unavailable.
111/// Trimmed and capped at [`MAX_TITLE_CHARS`]; never panics, never blocks
112/// longer than `titler.title` itself does.
113pub fn auto_title(history: &[ChatMessage], titler: &dyn SessionTitler) -> Option<String> {
114    let preview = render_transcript_preview(history, 4000);
115    if preview.trim().is_empty() {
116        return None;
117    }
118    let prompt = render_prompt(&preview);
119    let title = titler.title(&prompt).ok()?;
120    let cleaned: String = title
121        .trim()
122        .trim_matches(['"', '\''])
123        .split_whitespace()
124        .collect::<Vec<_>>()
125        .join(" ");
126    if cleaned.is_empty() {
127        return None;
128    }
129    let mut out = cleaned;
130    if out.len() > MAX_TITLE_CHARS {
131        let cut = out.floor_char_boundary_compat(MAX_TITLE_CHARS);
132        out.truncate(cut);
133    }
134    Some(out)
135}