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}