bamboo_engine/session_app/
chat.rs1use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use crate::selection::normalize_selected_skill_ids;
6use bamboo_agent_core::{Role, Session};
7use bamboo_domain::Message;
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
17const 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
32pub 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 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(&mut session, input.enhance_prompt.as_deref());
57
58 resolve_copilot_conclusion_with_options_enhancement(
60 &mut session,
61 input.copilot_conclusion_with_options_enhancement_enabled,
62 );
63
64 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(
73 &mut session,
74 input.selected_skill_ids.as_deref(),
75 &input.message,
76 );
77
78 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
80 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
81
82 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 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 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 repo.save_and_cache(&mut session).await?;
128
129 Ok(session)
130}
131
132pub 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
238pub 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#[derive(Debug, Clone, PartialEq, Eq)]
269pub enum GoalCommand {
270 Status,
272 Off,
274 Clear,
276 On,
278 SetPrompt(String),
280}
281
282pub 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 let rest = &trimmed[5..]; 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 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#[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}