Skip to main content

bamboo_engine/session_app/
chat.rs

1//! Chat use case: prepare a chat turn for execution.
2
3use bamboo_agent_core::{Role, Session};
4use bamboo_domain::Message;
5use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
6use crate::runner::refresh_prompt_snapshot;
7use crate::selection::normalize_selected_skill_ids;
8use bamboo_infrastructure::paths::path_to_display_string;
9use sha2::{Digest, Sha256};
10use std::path::Path;
11
12use super::errors::ChatError;
13use super::provider_model::{derive_model_ref, persist_legacy_model_provider, persist_model_ref};
14use super::repository::SessionAccess;
15use super::types::ChatTurnInput;
16
17// ---- Metadata keys ----
18const BASE_SYSTEM_PROMPT_KEY: &str = "base_system_prompt";
19const ENHANCE_PROMPT_KEY: &str = "enhance_prompt";
20const SELECTED_SKILL_IDS_KEY: &str = "selected_skill_ids";
21const SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
22const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
23const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
24const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
25const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
26const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
27const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
28const WORKSPACE_PATH_KEY: &str = "workspace_path";
29
30const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
31
32/// Prepare a chat turn: load/create session, resolve prompts, update metadata,
33/// append user message, persist.
34///
35/// Returns the prepared session ready for execution.
36///
37/// **Note**: Image handling and workspace sync (`ensure_session_workspace`)
38/// are NOT included here — those remain in the handler layer.
39pub async fn prepare_chat_turn(
40    repo: &dyn SessionAccess,
41    input: ChatTurnInput,
42    global_default_prompt: &str,
43    builtin_fallback_prompt: &str,
44) -> Result<Session, ChatError> {
45    let mut session = repo.load_or_create(&input.session_id, &input.model).await?;
46
47    // ---- Resolve base prompt ----
48    let base_prompt = resolve_base_prompt(
49        &mut session,
50        input.system_prompt.as_deref(),
51        global_default_prompt,
52        builtin_fallback_prompt,
53    );
54
55    // ---- Resolve enhance prompt ----
56    resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
57
58    // ---- Resolve copilot conclusion with options enhancement ----
59    resolve_copilot_conclusion_with_options_enhancement(
60        &mut session,
61        input.copilot_conclusion_with_options_enhancement_enabled,
62    );
63
64    // ---- Resolve workspace path (metadata only, no filesystem) ----
65    let workspace_path = resolve_workspace_path(
66        &mut session,
67        input.workspace_path.as_deref(),
68        input.data_dir.as_deref(),
69    );
70
71    // ---- Resolve selected skill IDs ----
72    resolve_selected_skill_ids(
73        &mut session,
74        input.selected_skill_ids.as_deref(),
75        &input.message,
76    );
77
78    // ---- Clear skill runtime state ----
79    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
80    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
81
82    // ---- Build enhanced system prompt with profile ----
83    let (system_prompt, prompt_profile) =
84        build_enhanced_system_prompt_with_profile(&base_prompt, None, workspace_path.as_deref());
85
86    session.metadata.insert(
87        PROMPT_COMPOSER_VERSION_KEY.to_string(),
88        prompt_profile.version.to_string(),
89    );
90    session.metadata.insert(
91        PROMPT_FINGERPRINT_KEY.to_string(),
92        prompt_profile.fingerprint.clone(),
93    );
94    session.metadata.insert(
95        PROMPT_COMPONENT_FLAGS_KEY.to_string(),
96        prompt_profile.component_flags_value(),
97    );
98    session.metadata.insert(
99        PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
100        prompt_profile.component_lengths_value(),
101    );
102
103    // ---- Upsert system prompt message ----
104    session
105        .messages
106        .retain(|message| !matches!(message.role, Role::System));
107    session.messages.insert(0, Message::system(system_prompt));
108    refresh_prompt_snapshot(&mut session);
109
110    // ---- Persist model/provider selection ----
111    let request_model_ref = derive_model_ref(
112        input.model_ref.as_ref(),
113        input.provider.as_deref(),
114        Some(input.model.as_str()),
115    );
116    if let Some(model_ref) = request_model_ref.as_ref() {
117        persist_model_ref(&mut session, model_ref);
118    } else {
119        persist_legacy_model_provider(
120            &mut session,
121            Some(input.model.as_str()),
122            input.provider.as_deref(),
123        );
124    }
125
126    // ---- Save ----
127    repo.save_and_cache(&mut session).await?;
128
129    Ok(session)
130}
131
132// ---- Internal helpers ----
133
134pub fn resolve_base_prompt(
135    session: &mut Session,
136    base_prompt_from_request: Option<&str>,
137    global_default_template: &str,
138    builtin_fallback: &str,
139) -> String {
140    let resolved = base_prompt_from_request
141        .map(ToString::to_string)
142        .or_else(|| {
143            session
144                .metadata
145                .get(BASE_SYSTEM_PROMPT_KEY)
146                .map(String::as_str)
147                .map(str::trim)
148                .filter(|value| !value.is_empty())
149                .map(ToString::to_string)
150        })
151        .or_else(|| {
152            session
153                .messages
154                .iter()
155                .find(|message| matches!(message.role, Role::System))
156                .map(|message| message.content.trim().to_string())
157                .filter(|value| !value.is_empty())
158        })
159        .unwrap_or_else(|| {
160            let trimmed = global_default_template.trim();
161            if trimmed.is_empty() {
162                builtin_fallback.to_string()
163            } else {
164                trimmed.to_string()
165            }
166        });
167
168    session
169        .metadata
170        .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
171    resolved
172}
173
174pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
175    if let Some(prompt) = enhance_prompt_from_request {
176        session
177            .metadata
178            .insert(ENHANCE_PROMPT_KEY.to_string(), prompt.to_string());
179    } else {
180        session.metadata.remove(ENHANCE_PROMPT_KEY);
181    }
182}
183
184pub fn resolve_copilot_conclusion_with_options_enhancement(
185    session: &mut Session,
186    enabled_from_request: Option<bool>,
187) {
188    if let Some(enabled) = enabled_from_request {
189        session
190            .metadata
191            .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
192    } else {
193        session.metadata.remove(COPILOT_CONCLUSION_KEY);
194    }
195}
196
197pub fn resolve_workspace_path(
198    session: &mut Session,
199    workspace_path_from_request: Option<&str>,
200    data_dir: Option<&Path>,
201) -> Option<String> {
202    if let Some(path) = workspace_path_from_request {
203        session
204            .metadata
205            .insert(WORKSPACE_PATH_KEY.to_string(), path.to_string());
206    }
207
208    workspace_path_from_request
209        .map(ToString::to_string)
210        .or_else(|| session.metadata.get(WORKSPACE_PATH_KEY).cloned())
211        .or_else(|| {
212            bamboo_infrastructure::Config::from_data_dir(data_dir.map(Path::to_path_buf))
213                .get_default_work_area_path()
214                .map(|path| path_to_display_string(&path))
215        })
216}
217
218pub fn resolve_selected_skill_ids(
219    session: &mut Session,
220    selected_skill_ids_from_request: Option<&[String]>,
221    message: &str,
222) {
223    if let Some(request_ids) = selected_skill_ids_from_request {
224        let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
225        persist_selected_skill_ids_metadata(session, normalized.as_deref());
226        return;
227    }
228
229    let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
230    if let Some(ids) = from_hint.as_ref() {
231        persist_selected_skill_ids_metadata(session, Some(ids));
232        return;
233    }
234
235    session.metadata.remove(SELECTED_SKILL_IDS_KEY);
236}
237
238/// Clear skill runtime state markers from session metadata.
239pub fn clear_skill_runtime_state(session: &mut Session) {
240    session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
241    session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
242}
243
244fn persist_selected_skill_ids_metadata(
245    session: &mut Session,
246    selected_skill_ids: Option<&[String]>,
247) {
248    match selected_skill_ids {
249        Some(ids) if !ids.is_empty() => {
250            if let Ok(serialized) = serde_json::to_string(ids) {
251                session
252                    .metadata
253                    .insert(SELECTED_SKILL_IDS_KEY.to_string(), serialized);
254            } else {
255                tracing::warn!("Failed to serialize selected skill IDs; clearing metadata");
256                session.metadata.remove(SELECTED_SKILL_IDS_KEY);
257            }
258        }
259        _ => {
260            session.metadata.remove(SELECTED_SKILL_IDS_KEY);
261        }
262    }
263}
264
265// ---- Goal command parsing ----
266
267/// Parsed result of a `/goal` command.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum GoalCommand {
270    /// `/goal status` or bare `/goal` — read-only status query.
271    Status,
272    /// `/goal off` or `/goal disable` or `/goal disabled`.
273    Off,
274    /// `/goal clear` or `/goal reset`.
275    Clear,
276    /// `/goal on` or `/goal enable` or `/goal enabled`.
277    On,
278    /// `/goal <prompt text>` — set the goal evaluation prompt and enable.
279    SetPrompt(String),
280}
281
282/// Attempt to parse a `/goal` command from the raw user message.
283/// Returns `None` if the message is not a `/goal` command.
284pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
285    let trimmed = message.trim();
286    if !trimmed.to_ascii_lowercase().starts_with("/goal") {
287        return None;
288    }
289    // Ensure "/goal" is followed by end-of-string or whitespace (not "/goalpost").
290    let rest = &trimmed[5..]; // skip "/goal"
291    if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
292        return None;
293    }
294
295    let arg = rest.trim().to_ascii_lowercase();
296
297    if arg.is_empty() {
298        return Some(GoalCommand::Status);
299    }
300
301    match arg.as_str() {
302        "status" => Some(GoalCommand::Status),
303        "off" | "disable" | "disabled" => Some(GoalCommand::Off),
304        "clear" | "reset" => Some(GoalCommand::Clear),
305        "on" | "enable" | "enabled" => Some(GoalCommand::On),
306        _ => {
307            // Everything else is treated as the goal prompt text.
308            // Use the original (non-lowercased) arg to preserve casing.
309            let prompt = trimmed
310                .strip_prefix("/goal")
311                .unwrap_or(trimmed)
312                .trim()
313                .to_string();
314            if prompt.is_empty() {
315                Some(GoalCommand::Status)
316            } else {
317                Some(GoalCommand::SetPrompt(prompt))
318            }
319        }
320    }
321}
322
323fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
324    const HINT_PREFIX: &str = "[User explicitly selected skill:";
325    let mut extracted = Vec::new();
326
327    for line in message.lines() {
328        let trimmed = line.trim();
329        if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
330            continue;
331        }
332
333        let Some(id_marker_index) = trimmed.rfind("(ID:") else {
334            continue;
335        };
336        let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
337        let Some(close_paren_index) = id_segment.find(')') else {
338            continue;
339        };
340        let id = id_segment[..close_paren_index].trim();
341        if !id.is_empty() {
342            extracted.push(id.to_string());
343        }
344    }
345
346    extracted
347}
348
349// ---- Prompt building ----
350
351#[derive(Debug, Clone, PartialEq, Eq)]
352struct PromptCompositionProfile {
353    version: &'static str,
354    fingerprint: String,
355    has_enhancement: bool,
356    has_workspace_context: bool,
357    has_env_context: bool,
358    base_len: usize,
359    enhancement_len: usize,
360    workspace_context_len: usize,
361    env_context_len: usize,
362    final_len: usize,
363}
364
365impl PromptCompositionProfile {
366    fn component_flags_value(&self) -> String {
367        format!(
368            "enhance={};workspace={};env={}",
369            self.has_enhancement as u8,
370            self.has_workspace_context as u8,
371            self.has_env_context as u8,
372        )
373    }
374
375    fn component_lengths_value(&self) -> String {
376        format!(
377            "base={};enhance={};workspace={};env={};final={}",
378            self.base_len,
379            self.enhancement_len,
380            self.workspace_context_len,
381            self.env_context_len,
382            self.final_len
383        )
384    }
385}
386
387fn build_prompt_fingerprint(
388    base_prompt: &str,
389    enhancement: Option<&str>,
390    workspace: Option<&str>,
391    env_context: Option<&str>,
392) -> String {
393    let mut hasher = Sha256::new();
394    hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
395    hasher.update([0u8]);
396    hasher.update(base_prompt.as_bytes());
397    hasher.update([0u8]);
398    hasher.update(enhancement.unwrap_or_default().as_bytes());
399    hasher.update([0u8]);
400    hasher.update(workspace.unwrap_or_default().as_bytes());
401    hasher.update([0u8]);
402    hasher.update(env_context.unwrap_or_default().as_bytes());
403    format!("{:x}", hasher.finalize())
404}
405
406fn build_enhanced_system_prompt_with_profile(
407    base_prompt: &str,
408    enhance_prompt: Option<&str>,
409    workspace_path: Option<&str>,
410) -> (String, PromptCompositionProfile) {
411    let mut merged_prompt = base_prompt.to_string();
412
413    let enhancement = enhance_prompt
414        .map(str::trim)
415        .filter(|enhancement| !enhancement.is_empty())
416        .map(ToString::to_string);
417    if let Some(enhancement) = enhancement.as_ref() {
418        merged_prompt.push_str("\n\n");
419        merged_prompt.push_str(enhancement.as_str());
420    }
421
422    let workspace_context = workspace_path
423        .map(str::trim)
424        .filter(|workspace_path| !workspace_path.is_empty())
425        .and_then(build_workspace_prompt_context);
426    if let Some(workspace_context) = workspace_context.as_ref() {
427        merged_prompt.push_str("\n\n");
428        merged_prompt.push_str(workspace_context.as_str());
429    }
430
431    let env_context = build_env_prompt_context();
432    if let Some(env_context) = env_context.as_ref() {
433        merged_prompt.push_str("\n\n");
434        merged_prompt.push_str(env_context.as_str());
435    }
436
437    let profile = PromptCompositionProfile {
438        version: PROMPT_COMPOSER_VERSION,
439        fingerprint: build_prompt_fingerprint(
440            base_prompt,
441            enhancement.as_deref(),
442            workspace_context.as_deref(),
443            env_context.as_deref(),
444        ),
445        has_enhancement: enhancement.is_some(),
446        has_workspace_context: workspace_context.is_some(),
447        has_env_context: env_context.is_some(),
448        base_len: base_prompt.len(),
449        enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
450        workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
451        env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
452        final_len: merged_prompt.len(),
453    };
454
455    (merged_prompt, profile)
456}