bamboo_engine/session_app/
chat.rs1use crate::context::{build_env_prompt_context, build_workspace_prompt_context};
4use crate::runner::refresh_prompt_snapshot;
5use bamboo_agent_core::{Role, Session};
6use bamboo_config::paths::path_to_display_string;
7use bamboo_domain::Message;
8use bamboo_skills::selection::normalize_selected_skill_ids;
9use sha2::{Digest, Sha256};
10use std::path::{Path, PathBuf};
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 SKILL_RUNTIME_LOADED_KEY: &str = "skill_runtime_loaded_skill_ids";
20const SKILL_RUNTIME_LAST_KEY: &str = "skill_runtime_last_loaded_skill_id";
21const COPILOT_CONCLUSION_KEY: &str = "copilot_conclusion_with_options_enhancement_enabled";
22const PROMPT_COMPOSER_VERSION_KEY: &str = "prompt_composer_version";
23const PROMPT_FINGERPRINT_KEY: &str = "prompt_fingerprint";
24const PROMPT_COMPONENT_FLAGS_KEY: &str = "prompt_component_flags";
25const PROMPT_COMPONENT_LENGTHS_KEY: &str = "prompt_component_lengths";
26
27const PROMPT_COMPOSER_VERSION: &str = "bamboo.prompt-composer.v2";
28
29pub async fn prepare_chat_turn(
37 repo: &dyn SessionAccess,
38 input: ChatTurnInput,
39 global_default_prompt: &str,
40 builtin_fallback_prompt: &str,
41) -> Result<Session, ChatError> {
42 let mut session = repo.load_or_create(&input.session_id, &input.model).await?;
43
44 let base_prompt = resolve_base_prompt(
46 &mut session,
47 input.system_prompt.as_deref(),
48 global_default_prompt,
49 builtin_fallback_prompt,
50 );
51
52 resolve_enhance_prompt(&mut session, input.enhance_prompt.as_deref());
54
55 resolve_copilot_conclusion_with_options_enhancement(
57 &mut session,
58 input.copilot_conclusion_with_options_enhancement_enabled,
59 );
60
61 let workspace_path = resolve_workspace_path(
63 &mut session,
64 input.workspace_path.as_deref(),
65 input.data_dir.as_deref(),
66 );
67
68 resolve_selected_skill_ids(
70 &mut session,
71 input.selected_skill_ids.as_deref(),
72 &input.message,
73 );
74
75 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
77 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
78
79 let (system_prompt, prompt_profile) =
81 build_enhanced_system_prompt_with_profile(&base_prompt, None, workspace_path.as_deref());
82
83 session.metadata.insert(
84 PROMPT_COMPOSER_VERSION_KEY.to_string(),
85 prompt_profile.version.to_string(),
86 );
87 session.metadata.insert(
88 PROMPT_FINGERPRINT_KEY.to_string(),
89 prompt_profile.fingerprint.clone(),
90 );
91 session.metadata.insert(
92 PROMPT_COMPONENT_FLAGS_KEY.to_string(),
93 prompt_profile.component_flags_value(),
94 );
95 session.metadata.insert(
96 PROMPT_COMPONENT_LENGTHS_KEY.to_string(),
97 prompt_profile.component_lengths_value(),
98 );
99
100 session
102 .messages
103 .retain(|message| !matches!(message.role, Role::System));
104 session.messages.insert(0, Message::system(system_prompt));
105 refresh_prompt_snapshot(&mut session);
106
107 let request_model_ref = derive_model_ref(
109 input.model_ref.as_ref(),
110 input.provider.as_deref(),
111 Some(input.model.as_str()),
112 );
113 if let Some(model_ref) = request_model_ref.as_ref() {
114 persist_model_ref(&mut session, model_ref);
115 } else {
116 persist_legacy_model_provider(
117 &mut session,
118 Some(input.model.as_str()),
119 input.provider.as_deref(),
120 );
121 }
122
123 repo.save_and_cache(&mut session).await?;
125
126 Ok(session)
127}
128
129pub fn resolve_base_prompt(
132 session: &mut Session,
133 base_prompt_from_request: Option<&str>,
134 global_default_template: &str,
135 builtin_fallback: &str,
136) -> String {
137 let resolved = base_prompt_from_request
138 .map(ToString::to_string)
139 .or_else(|| {
140 session
141 .metadata
142 .get(BASE_SYSTEM_PROMPT_KEY)
143 .map(String::as_str)
144 .map(str::trim)
145 .filter(|value| !value.is_empty())
146 .map(ToString::to_string)
147 })
148 .or_else(|| {
149 session
150 .messages
151 .iter()
152 .find(|message| matches!(message.role, Role::System))
153 .map(|message| message.content.trim().to_string())
154 .filter(|value| !value.is_empty())
155 })
156 .unwrap_or_else(|| {
157 let trimmed = global_default_template.trim();
158 if trimmed.is_empty() {
159 builtin_fallback.to_string()
160 } else {
161 trimmed.to_string()
162 }
163 });
164
165 session
166 .metadata
167 .insert(BASE_SYSTEM_PROMPT_KEY.to_string(), resolved.clone());
168 resolved
169}
170
171pub fn resolve_enhance_prompt(session: &mut Session, enhance_prompt_from_request: Option<&str>) {
172 if let Some(prompt) = enhance_prompt_from_request {
173 session.set_enhance_prompt(prompt);
174 } else {
175 session.clear_enhance_prompt();
176 }
177}
178
179pub fn resolve_copilot_conclusion_with_options_enhancement(
180 session: &mut Session,
181 enabled_from_request: Option<bool>,
182) {
183 if let Some(enabled) = enabled_from_request {
184 session
185 .metadata
186 .insert(COPILOT_CONCLUSION_KEY.to_string(), enabled.to_string());
187 } else {
188 session.metadata.remove(COPILOT_CONCLUSION_KEY);
189 }
190}
191
192pub fn resolve_workspace_path(
193 session: &mut Session,
194 workspace_path_from_request: Option<&str>,
195 data_dir: Option<&Path>,
196) -> Option<String> {
197 if let Some(path) = workspace_path_from_request {
198 session.set_workspace_path_meta(path);
199 }
200
201 workspace_path_from_request
202 .map(ToString::to_string)
203 .or_else(|| session.workspace_path_meta())
204 .or_else(|| resolve_default_workspace(data_dir))
205}
206
207fn resolve_default_workspace(data_dir: Option<&Path>) -> Option<String> {
218 let configured = if bamboo_agent_core::workspace_state::has_default_workspace_provider() {
219 bamboo_agent_core::workspace_state::get_configured_default_workspace()
220 } else {
221 default_workspace_from_data_dir(data_dir)
222 };
223 configured.map(|path| path_to_display_string(&path))
224}
225
226fn default_workspace_from_data_dir(data_dir: Option<&Path>) -> Option<PathBuf> {
229 bamboo_llm::Config::from_data_dir(data_dir.map(Path::to_path_buf)).get_default_work_area_path()
230}
231
232pub fn resolve_selected_skill_ids(
233 session: &mut Session,
234 selected_skill_ids_from_request: Option<&[String]>,
235 message: &str,
236) {
237 if let Some(request_ids) = selected_skill_ids_from_request {
238 let normalized = normalize_selected_skill_ids(request_ids.iter().cloned());
239 persist_selected_skill_ids_metadata(session, normalized.as_deref());
240 return;
241 }
242
243 let from_hint = normalize_selected_skill_ids(extract_skill_ids_from_hint(message));
244 if let Some(ids) = from_hint.as_ref() {
245 persist_selected_skill_ids_metadata(session, Some(ids));
246 return;
247 }
248
249 session.clear_selected_skill_ids();
250}
251
252pub fn clear_skill_runtime_state(session: &mut Session) {
254 session.metadata.remove(SKILL_RUNTIME_LOADED_KEY);
255 session.metadata.remove(SKILL_RUNTIME_LAST_KEY);
256}
257
258fn persist_selected_skill_ids_metadata(
259 session: &mut Session,
260 selected_skill_ids: Option<&[String]>,
261) {
262 match selected_skill_ids {
263 Some(ids) if !ids.is_empty() => {
264 session.set_selected_skill_ids(ids.to_vec());
265 }
266 _ => {
267 session.clear_selected_skill_ids();
268 }
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum GoalCommand {
277 Status,
279 Off,
281 Clear,
283 On,
285 SetPrompt(String),
287}
288
289pub fn parse_goal_command(message: &str) -> Option<GoalCommand> {
292 let trimmed = message.trim();
293 if !trimmed.to_ascii_lowercase().starts_with("/goal") {
294 return None;
295 }
296 let rest = &trimmed[5..]; if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
299 return None;
300 }
301
302 let arg = rest.trim().to_ascii_lowercase();
303
304 if arg.is_empty() {
305 return Some(GoalCommand::Status);
306 }
307
308 match arg.as_str() {
309 "status" => Some(GoalCommand::Status),
310 "off" | "disable" | "disabled" => Some(GoalCommand::Off),
311 "clear" | "reset" => Some(GoalCommand::Clear),
312 "on" | "enable" | "enabled" => Some(GoalCommand::On),
313 _ => {
314 let prompt = trimmed
317 .strip_prefix("/goal")
318 .unwrap_or(trimmed)
319 .trim()
320 .to_string();
321 if prompt.is_empty() {
322 Some(GoalCommand::Status)
323 } else {
324 Some(GoalCommand::SetPrompt(prompt))
325 }
326 }
327 }
328}
329
330fn extract_skill_ids_from_hint(message: &str) -> Vec<String> {
331 const HINT_PREFIX: &str = "[User explicitly selected skill:";
332 let mut extracted = Vec::new();
333
334 for line in message.lines() {
335 let trimmed = line.trim();
336 if !trimmed.starts_with(HINT_PREFIX) || !trimmed.ends_with(']') {
337 continue;
338 }
339
340 let Some(id_marker_index) = trimmed.rfind("(ID:") else {
341 continue;
342 };
343 let id_segment = &trimmed[id_marker_index + "(ID:".len()..];
344 let Some(close_paren_index) = id_segment.find(')') else {
345 continue;
346 };
347 let id = id_segment[..close_paren_index].trim();
348 if !id.is_empty() {
349 extracted.push(id.to_string());
350 }
351 }
352
353 extracted
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
359struct PromptCompositionProfile {
360 version: &'static str,
361 fingerprint: String,
362 has_enhancement: bool,
363 has_workspace_context: bool,
364 has_env_context: bool,
365 base_len: usize,
366 enhancement_len: usize,
367 workspace_context_len: usize,
368 env_context_len: usize,
369 final_len: usize,
370}
371
372impl PromptCompositionProfile {
373 fn component_flags_value(&self) -> String {
374 format!(
375 "enhance={};workspace={};env={}",
376 self.has_enhancement as u8,
377 self.has_workspace_context as u8,
378 self.has_env_context as u8,
379 )
380 }
381
382 fn component_lengths_value(&self) -> String {
383 format!(
384 "base={};enhance={};workspace={};env={};final={}",
385 self.base_len,
386 self.enhancement_len,
387 self.workspace_context_len,
388 self.env_context_len,
389 self.final_len
390 )
391 }
392}
393
394fn build_prompt_fingerprint(
395 base_prompt: &str,
396 enhancement: Option<&str>,
397 workspace: Option<&str>,
398 env_context: Option<&str>,
399) -> String {
400 let mut hasher = Sha256::new();
401 hasher.update(PROMPT_COMPOSER_VERSION.as_bytes());
402 hasher.update([0u8]);
403 hasher.update(base_prompt.as_bytes());
404 hasher.update([0u8]);
405 hasher.update(enhancement.unwrap_or_default().as_bytes());
406 hasher.update([0u8]);
407 hasher.update(workspace.unwrap_or_default().as_bytes());
408 hasher.update([0u8]);
409 hasher.update(env_context.unwrap_or_default().as_bytes());
410 format!("{:x}", hasher.finalize())
411}
412
413fn build_enhanced_system_prompt_with_profile(
414 base_prompt: &str,
415 enhance_prompt: Option<&str>,
416 workspace_path: Option<&str>,
417) -> (String, PromptCompositionProfile) {
418 let mut merged_prompt = base_prompt.to_string();
419
420 let enhancement = enhance_prompt
421 .map(str::trim)
422 .filter(|enhancement| !enhancement.is_empty())
423 .map(ToString::to_string);
424 if let Some(enhancement) = enhancement.as_ref() {
425 merged_prompt.push_str("\n\n");
426 merged_prompt.push_str(enhancement.as_str());
427 }
428
429 let workspace_context = workspace_path
430 .map(str::trim)
431 .filter(|workspace_path| !workspace_path.is_empty())
432 .and_then(build_workspace_prompt_context);
433 if let Some(workspace_context) = workspace_context.as_ref() {
434 merged_prompt.push_str("\n\n");
435 merged_prompt.push_str(workspace_context.as_str());
436 }
437
438 let env_context = build_env_prompt_context();
439 if let Some(env_context) = env_context.as_ref() {
440 merged_prompt.push_str("\n\n");
441 merged_prompt.push_str(env_context.as_str());
442 }
443
444 let profile = PromptCompositionProfile {
445 version: PROMPT_COMPOSER_VERSION,
446 fingerprint: build_prompt_fingerprint(
447 base_prompt,
448 enhancement.as_deref(),
449 workspace_context.as_deref(),
450 env_context.as_deref(),
451 ),
452 has_enhancement: enhancement.is_some(),
453 has_workspace_context: workspace_context.is_some(),
454 has_env_context: env_context.is_some(),
455 base_len: base_prompt.len(),
456 enhancement_len: enhancement.as_ref().map(|s| s.len()).unwrap_or(0),
457 workspace_context_len: workspace_context.as_ref().map(|s| s.len()).unwrap_or(0),
458 env_context_len: env_context.as_ref().map(|s| s.len()).unwrap_or(0),
459 final_len: merged_prompt.len(),
460 };
461
462 (merged_prompt, profile)
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
475 fn default_workspace_from_data_dir_reads_configured_work_area() {
476 let temp = tempfile::tempdir().expect("temp dir");
477 let workspace = temp.path().join("default-workspace");
478 std::fs::create_dir_all(&workspace).expect("workspace dir");
479 std::fs::write(
480 temp.path().join("config.json"),
481 serde_json::json!({
482 "default_work_area": { "path": workspace.to_string_lossy() }
483 })
484 .to_string(),
485 )
486 .expect("write config.json");
487
488 let resolved = default_workspace_from_data_dir(Some(temp.path())).expect("resolves");
489 assert_eq!(
493 resolved.canonicalize().unwrap(),
494 workspace.canonicalize().unwrap()
495 );
496 }
497
498 #[test]
499 fn default_workspace_from_data_dir_is_none_without_config() {
500 let temp = tempfile::tempdir().expect("temp dir");
501 assert!(default_workspace_from_data_dir(Some(temp.path())).is_none());
502 }
503}