Skip to main content

bamboo_engine/title_gen/
mod.rs

1//! Backend auto-title-generation service.
2//!
3//! Runs asynchronously after the execute handler accepts a request, derives a
4//! concise title for the session via the configured fast model (with a heuristic
5//! fallback), persists it via the merge-save helper so concurrent runtime saves
6//! cannot clobber it, and emits [`AgentEvent::SessionTitleUpdated`].
7//!
8//! Public entry points:
9//! - [`spawn_title_generation`] - skips work when the session already has a
10//!   non-default title.
11//! - [`spawn_title_generation_force`] - always regenerates (used by the
12//!   `POST /sessions/{id}/regenerate-title` endpoint).
13//!
14//! Both share an in-flight guard keyed by session id to prevent duplicate work
15//! when called repeatedly for the same session.
16
17use std::sync::Arc;
18use std::time::Duration;
19
20use bamboo_agent_core::{Message, Role, Session, TitleSource};
21use tokio::time::timeout;
22use tokio_util::sync::CancellationToken;
23use tracing::{debug, info, warn};
24
25use crate::app_context::AgentSessionContext;
26use crate::session_app::metadata::SessionMetadataService;
27
28mod fallback;
29mod prompt;
30#[cfg(test)]
31mod tests;
32
33pub use fallback::heuristic_title;
34pub use prompt::build_title_messages;
35
36const TITLE_GEN_TIMEOUT_SECS: u64 = 30;
37const MAX_TITLE_LEN: usize = 80;
38const MAX_USER_TEXT_FOR_TITLE: usize = 2000;
39
40/// Spawn a background task that generates an auto-title for a new (untitled) session.
41/// Skips if the session already has a non-default title.
42pub fn spawn_title_generation(state: Arc<dyn AgentSessionContext>, session_id: String) {
43    spawn_inner(state, session_id, false);
44}
45
46/// Same as [`spawn_title_generation`] but bypasses the "is untitled" check -
47/// always regenerates. Used by `POST /sessions/{id}/regenerate-title`.
48pub fn spawn_title_generation_force(state: Arc<dyn AgentSessionContext>, session_id: String) {
49    spawn_inner(state, session_id, true);
50}
51
52fn spawn_inner(state: Arc<dyn AgentSessionContext>, session_id: String, force: bool) {
53    if !state.title_gen_acquire(&session_id) {
54        info!(session_id = %session_id, "title-gen: already in-flight, skipping");
55        return;
56    }
57    let state_for_task = state.clone();
58    let sid = session_id.clone();
59    tokio::spawn(async move {
60        let _guard = TitleGenGuard {
61            state: state_for_task.clone(),
62            session_id: sid.clone(),
63        };
64        if let Err(e) = run_title_generation(&state_for_task, &sid, force).await {
65            warn!(session_id = %sid, "title-gen failed: {e}");
66        }
67    });
68}
69
70struct TitleGenGuard {
71    state: Arc<dyn AgentSessionContext>,
72    session_id: String,
73}
74
75impl Drop for TitleGenGuard {
76    fn drop(&mut self) {
77        self.state.title_gen_release(&self.session_id);
78    }
79}
80
81/// Returns true when a session title is still a frontend-provided default / placeholder.
82pub fn is_untitled(title: &str) -> bool {
83    let s = title.trim();
84    if s.is_empty() {
85        return true;
86    }
87
88    // Exact known frontend defaults/localized defaults.
89    if matches!(
90        s,
91        "New Session"
92            | "新建会话"
93            | "新建會話"
94            | "Nouvelle session"
95            | "新しいセッション"
96            | "नया सत्र"
97    ) {
98        return true;
99    }
100
101    // Frontend historically created prompt-scoped placeholder titles such as
102    // `New Session - Bodhi`, `New Session with Bodhi`, and the current
103    // localized/sidebar variant `New session with Bodhi`. Treat only these
104    // narrow, obvious default forms as untitled so clear custom titles are preserved.
105    s.strip_prefix("New Session - ")
106        .or_else(|| s.strip_prefix("New Session with "))
107        .or_else(|| s.strip_prefix("New session - "))
108        .or_else(|| s.strip_prefix("New session with "))
109        .map(|suffix| !suffix.trim().is_empty())
110        .unwrap_or(false)
111}
112
113async fn run_title_generation(
114    state: &Arc<dyn AgentSessionContext>,
115    session_id: &str,
116    force: bool,
117) -> Result<(), String> {
118    // 1. Reload session.
119    let session = state
120        .storage()
121        .load_session(session_id)
122        .await
123        .map_err(|e| format!("load_session: {e}"))?
124        .ok_or_else(|| "session not found".to_string())?;
125
126    // 2. Skip if already titled (unless forced).
127    if !force && !is_untitled(&session.title) {
128        debug!(session_id = %session_id, "title-gen: session already titled, skipping");
129        return Ok(());
130    }
131
132    // 3. Find first user message text.
133    let first_user = match first_user_text(&session) {
134        Some(t) if !t.trim().is_empty() => t,
135        _ => {
136            debug!(session_id = %session_id, "title-gen: no user message text, skipping");
137            return Ok(());
138        }
139    };
140    let first_user_len = first_user.len();
141
142    // 4. Try LLM, then heuristic fallback.
143    let (raw_title, source) = match try_llm_title(state, &session, &first_user).await {
144        Ok(t) if !t.trim().is_empty() => {
145            info!(session_id = %session_id, prompt_len = first_user_len, "title-gen: LLM produced title");
146            (t, TitleSource::Auto)
147        }
148        Ok(_) => {
149            warn!(session_id = %session_id, "title-gen: LLM returned empty, using fallback");
150            (heuristic_title(&first_user), TitleSource::Fallback)
151        }
152        Err(e) => {
153            warn!(session_id = %session_id, "title-gen: LLM failed ({e}), using fallback");
154            (heuristic_title(&first_user), TitleSource::Fallback)
155        }
156    };
157
158    let title = sanitize(&raw_title);
159    if title.is_empty() {
160        debug!(session_id = %session_id, "title-gen: sanitized title empty, skipping");
161        return Ok(());
162    }
163
164    // 5. Hand off to the authoritative metadata writer. It re-checks
165    //    is_untitled (when force=false) inside a per-session lock so a
166    //    concurrent user PATCH wins, bumps title_version and metadata_version,
167    //    performs a plain save, refreshes the in-memory cache, and emits
168    //    SessionTitleUpdated through the replayable helper.
169    match SessionMetadataService::apply_generated_title(
170        state.as_ref(),
171        session_id,
172        &title,
173        source,
174        force,
175    )
176    .await
177    .map_err(|e| format!("apply_generated_title: {e}"))?
178    {
179        Some((applied, title_version)) => {
180            info!(
181                session_id = %session_id,
182                title_len = applied.len(),
183                title_version,
184                source = ?source,
185                "title-gen: applied generated title"
186            );
187        }
188        None => {
189            debug!(
190                session_id = %session_id,
191                "title-gen: skipped (already titled or unchanged)"
192            );
193        }
194    }
195    Ok(())
196}
197
198/// Extract the first user message's text, preferring `Text` parts when
199/// `content_parts` is set. Truncates to `MAX_USER_TEXT_FOR_TITLE` characters.
200/// Skips hidden runtime resume messages so internal system messages don't
201/// pollute generated titles.
202fn first_user_text(session: &Session) -> Option<String> {
203    let msg = session.messages.iter().find(|m| {
204        matches!(m.role, Role::User) && !crate::session_app::execute::is_system_resume_message(m)
205    })?;
206    let raw = if let Some(parts) = msg.content_parts.as_ref() {
207        let joined: String = parts
208            .iter()
209            .filter_map(|p| match p {
210                bamboo_agent_core::MessagePart::Text { text } => Some(text.as_str()),
211                _ => None,
212            })
213            .collect::<Vec<_>>()
214            .join("\n");
215        if joined.trim().is_empty() {
216            msg.content.clone()
217        } else {
218            joined
219        }
220    } else {
221        msg.content.clone()
222    };
223    Some(raw.chars().take(MAX_USER_TEXT_FOR_TITLE).collect())
224}
225
226/// Run the LLM call to produce a title. Wrapped in a 30s timeout; any error
227/// (resolve, stream, consume, timeout) propagates back so the caller can fall
228/// through to the heuristic.
229async fn try_llm_title(
230    state: &Arc<dyn AgentSessionContext>,
231    session: &Session,
232    first_user_text: &str,
233) -> Result<String, String> {
234    // Determine provider name (session-aware, with global config fallback) and resolve fast model.
235    let config_snapshot = { state.config().read().await.clone() };
236    let provider_name = if let Some(ref m) = session.model_ref {
237        m.provider.clone()
238    } else if let Some(p) = session.provider_name() {
239        p
240    } else {
241        config_snapshot.provider.clone()
242    };
243
244    let resolved = crate::model_config_helper::resolve_fast_model(
245        &config_snapshot,
246        &provider_name,
247        state.provider_registry(),
248    )
249    .ok_or_else(|| "no fast model resolved".to_string())?;
250
251    let messages: Vec<Message> = build_title_messages(first_user_text);
252    let model_name = resolved.model_name.clone();
253    let provider = resolved.provider;
254
255    let fut = async move {
256        let options = bamboo_llm::provider::LLMRequestOptions {
257            request_purpose: Some("title_generation".to_string()),
258            ..Default::default()
259        };
260        let stream = provider
261            .chat_stream_with_options(&messages, &[], Some(64), &model_name, Some(&options))
262            .await
263            .map_err(|e| format!("chat_stream: {e}"))?;
264        let output = crate::runtime::stream::handler::consume_llm_stream_silent(
265            stream,
266            &CancellationToken::new(),
267            "title-gen",
268        )
269        .await
270        .map_err(|e| format!("consume_llm_stream_silent: {e}"))?;
271        Ok::<String, String>(output.content)
272    };
273
274    timeout(Duration::from_secs(TITLE_GEN_TIMEOUT_SECS), fut)
275        .await
276        .map_err(|_| "title-gen LLM timeout".to_string())?
277}
278
279/// Trim, take first line, strip leading/trailing quotes/punctuation,
280/// cap at [`MAX_TITLE_LEN`] characters.
281fn sanitize(input: &str) -> String {
282    let first_line = input.lines().next().unwrap_or("").trim();
283    let trim_chars: &[char] = &['"', '\'', '`', '.', ',', ';', ':', ' ', '\t'];
284    let stripped = first_line.trim_matches(trim_chars).trim();
285    let limit = stripped
286        .char_indices()
287        .nth(MAX_TITLE_LEN)
288        .map(|(i, _)| i)
289        .unwrap_or(stripped.len());
290    stripped[..limit].trim().to_string()
291}