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