Skip to main content

microclaw_engine/
title_generator.rs

1//! Auto-generate session titles once a chat has enough context.
2//!
3//! Port of hermes-agent's `agent/title_generator.py`. The core contract:
4//! given the first few turns of a session, ask the LLM to produce a 3-8
5//! word title, and persist it into `sessions.label`. Runs in the
6//! background scheduler — the agent loop itself never blocks on title
7//! generation.
8
9use std::sync::Arc;
10
11use crate::internal::storage::db::{call_blocking, Database};
12use microclaw_core::error::MicroClawError;
13use microclaw_core::llm_types::{Message, MessageContent, ResponseContentBlock};
14
15use crate::config::Config;
16use crate::llm::create_provider;
17
18const MIN_MESSAGES_BEFORE_TITLE: usize = 4;
19const MAX_MESSAGES_SAMPLED: usize = 8;
20
21/// Produce and persist a short title for `chat_id`. Returns Ok(None) when
22/// the chat doesn't yet meet the threshold (already labeled, too few
23/// messages, or no session row). Returns Ok(Some(title)) on success.
24pub async fn generate_and_save_title(
25    config: &Config,
26    db: Arc<Database>,
27    chat_id: i64,
28) -> Result<Option<String>, MicroClawError> {
29    let snapshot = call_blocking(db.clone(), move |d| {
30        let info = d.get_session_label_and_length(chat_id)?;
31        let messages = d.get_recent_messages(chat_id, MAX_MESSAGES_SAMPLED)?;
32        Ok::<_, MicroClawError>((info, messages))
33    })
34    .await?;
35    let (info, recent) = snapshot;
36    let Some((existing_label, total_len)) = info else {
37        return Ok(None);
38    };
39    if existing_label
40        .as_deref()
41        .map(|s| !s.trim().is_empty())
42        .unwrap_or(false)
43    {
44        return Ok(None);
45    }
46    if total_len < MIN_MESSAGES_BEFORE_TITLE {
47        return Ok(None);
48    }
49    if recent.is_empty() {
50        return Ok(None);
51    }
52
53    let transcript = build_transcript(&recent);
54    let provider = create_provider(config);
55    let system = "You generate a short session title — 3 to 8 English or Chinese words, no quotes, no punctuation at end. The title should capture the dominant topic. Do not explain.".to_string();
56    let user = format!(
57        "Produce a title for the following conversation. Reply with the title only.\n\n{transcript}"
58    );
59    // Title generation is one-shot summarization, so allow a (typically cheaper)
60    // auxiliary model. Passing `None` when unset preserves the prior behavior.
61    let response = provider
62        .send_message_with_model(
63            &system,
64            vec![Message {
65                role: "user".to_string(),
66                content: MessageContent::Text(user),
67            }],
68            None,
69            config.aux_models.title_model(),
70        )
71        .await?;
72    let title = first_text(&response.content)
73        .map(|t| clean_title(&t))
74        .filter(|t| !t.is_empty());
75    let Some(title) = title else {
76        return Ok(None);
77    };
78    let title_for_db = title.clone();
79    call_blocking(db, move |d| d.set_session_label(chat_id, &title_for_db)).await?;
80    Ok(Some(title))
81}
82
83fn build_transcript(messages: &[crate::internal::storage::db::StoredMessage]) -> String {
84    let mut out = String::new();
85    for m in messages.iter().rev().take(MAX_MESSAGES_SAMPLED) {
86        let role = if m.is_from_bot { "Assistant" } else { "User" };
87        let trimmed = m.content.chars().take(1500).collect::<String>();
88        out.push_str(role);
89        out.push_str(": ");
90        out.push_str(&trimmed);
91        out.push_str("\n\n");
92    }
93    out
94}
95
96fn first_text(blocks: &[ResponseContentBlock]) -> Option<String> {
97    for block in blocks {
98        if let ResponseContentBlock::Text { text } = block {
99            if !text.trim().is_empty() {
100                return Some(text.clone());
101            }
102        }
103    }
104    None
105}
106
107fn clean_title(raw: &str) -> String {
108    let first_line = raw.lines().next().unwrap_or("");
109    let quote_chars: &[char] = &['"', '\'', '«', '»', '“', '”', '‘', '’'];
110    let end_punct: &[char] = &['.', '。', '!', '?', ';', ';'];
111    let cleaned: String = first_line
112        .trim()
113        .trim_matches(quote_chars)
114        .trim_end_matches(end_punct)
115        .chars()
116        .take(80)
117        .collect();
118    cleaned.trim().to_string()
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn cleans_quotes_and_trailing_punctuation() {
127        assert_eq!(
128            clean_title("\"Rust async deep dive.\""),
129            "Rust async deep dive"
130        );
131        assert_eq!(clean_title("  Hello world!  "), "Hello world");
132        assert_eq!(clean_title("“写 Rust 好处”"), "写 Rust 好处");
133    }
134
135    #[test]
136    fn strips_extra_lines() {
137        assert_eq!(clean_title("Title here\nExplanation ..."), "Title here");
138    }
139
140    #[test]
141    fn caps_long_titles() {
142        let long = "a".repeat(200);
143        let out = clean_title(&long);
144        assert!(out.chars().count() <= 80);
145    }
146}