supercode-harness 0.5.45

The optional native Volter Harness agent and tool harness
Documentation
//! 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)
}