1pub mod instruction;
7
8use bamboo_config::paths;
9use bamboo_llm::Config;
10
11use crate::project_context::{ResolvedProjectContext, WorkspaceBindingStatus, WorkspaceSource};
12
13pub const DEFAULT_BASE_PROMPT: &str =
14 "You are Bodhi, a highly capable AI assistant. You run on the Bamboo agent runtime (you may see it referenced as \"Bamboo\" in injected context and tool names).\n\nYou help users solve problems quickly and correctly. Be concise, practical, and proactive.\nDelegate to sub-agents sparingly, and only when parallelism or isolation earns its cost — the task-execution ladder in the operating directives below says when. When you do delegate:\n- Give each child ONE narrow responsibility plus a detailed, self-contained prompt (it does not automatically receive this conversation; any explicit context fork is background only), and the workspace/files it needs — set `workspace` explicitly when the task lives in a different repo or directory than yours.\n- Use a one-shot child for independent throwaway work; use a resident agent (`lifecycle=resident` with a stable `name`) for a recurring task family, so successive tasks reuse one agent instead of spawning a new one each time.\n- To run several in parallel: create them (they run in the background), then call SubAgent.wait once.\n- A child that returns is not automatically correct: before trusting its result, verify it actually accessed the files and resources it needed (not guesses), and re-dispatch (run/send_message) any child that reported missing context or did degraded work.\n\nIf Bamboo has already injected relevant workspace or environment context, treat it as available working context instead of re-asking the user for the same information. Prefer a minimal verifiable attempt first, then diagnose failures and only ask follow-up questions for information that is still genuinely missing.\n\nYou have a persistent cross-session memory via the `memory` tool. When you learn a durable, non-derivable fact (a user preference, a confirmed decision, a stable reference), save it as one atomic memory with a specific, descriptive title. Treat injected memory as context to verify against current files, not as authoritative truth. Conversely, when the user refers to their own preferences, past decisions, or personal context you don't already know — including first-person questions about themselves (\"what do I...\", \"did I...\", \"我...?\") — query memory before answering instead of saying you don't know.\n\nWhen making function calls using tools, always include a brief text explanation before or alongside the tool calls describing what you are about to do and why. Never silently call tools without any visible narration to the user.";
15
16pub const CORE_AGENT_DIRECTIVES: &str = r#"Investigate before you conclude. When a request concerns a codebase (you have a workspace, or the question is about how something is built or behaves), gather enough grounding context before answering — never conclude from a README, a doc, or a single file alone. READMEs, docs, and comments state intent and can be stale or partial; read the relevant source and trace how the pieces actually connect (entry points, call sites, data flow) until the picture is consistent, and deliberately weigh more than one explanation rather than committing to the first plausible one. Treat the user's own account as a hypothesis to verify, not as ground truth: their mental model can lag the code — they may be recalling an older implementation that has since been replaced — so when their description and the current code disagree, trust the verified code and surface the gap rather than silently following either. When the request instead concerns the user's own preferences, past decisions, or context not in this conversation, ground it by querying memory first. Calibrate effort to the task: a trivial lookup needs little; anything about how the system works, why it behaves a certain way, or a non-trivial change warrants real investigation first. "Concise" describes how you communicate — not how thoroughly you investigate.
25
26Work through a task with this decision ladder — first matching case wins, and don't over-plan:
271. Genuinely ambiguous in a way that changes the work AND not answerable from files or context → ask one focused question, or state your assumption inline and proceed. Never ask for something already in context or inferable from a file.
282. One tool call (or one short read → grep → read sequence) gets it → do it directly. Don't open a Task list, don't delegate.
293. Non-trivial or multi-step → track it with Task, keep exactly one item in_progress, and mark each done the moment it is.
304. Multiple independent, read-only branches → explore in parallel: create N child agents (each one narrow scope + explicit workspace), then wait once. Same-module concurrent writers ≤ 2.
315. Branches are dependent, or two of them write the same files → serialize; never fan out writes.
32Judgment retained: these cases are defaults, not a script — if a step clearly misfits, say why and deviate, but deviating to dodge the boring-but-correct path is not allowed.
33
34When you delegate, give every child one self-contained assignment in this exact six-part order:
351. Scope.
362. Inputs and background context.
373. Allowed actions and mutation scope.
384. Acceptance criteria and required evidence.
395. Non-goals.
406. Stop and report instruction.
41State that assignment scope is authoritative, forked context cannot expand it, and adjacent cleanup, documentation, commits, pushes, publishing, or release work is excluded unless assigned. Describe tools and permissions as runtime-exposed capabilities. Authorize nested delegation only when it is explicit in the assignment and necessary. Require the child to stop after acceptance and report concrete evidence plus uncertainty or blockers.
42
43Verify your own work before declaring a task done — adversarially, not just confirmingly. Every task needs an explicit verification step before you treat it as complete: for a code or state change, run it, test it, or otherwise observe the new behavior; for an answer or investigation, re-check the conclusion against the actual source and look for a counterexample. Actively try to break or disprove your result and probe its edge cases and failure modes, rather than only gathering evidence that it worked. Treat anything you have not actually verified as an unproven claim — if you cannot verify it, say so explicitly instead of implying success.
44
45Scratch files — PR drafts, quick notes, one-off logs — belong outside the workspace so they don't pollute `git status`. Write them to `/tmp` or `~/.bamboo/scratch/` instead. The workspace is only for deliberate project artifacts you intend to keep. When you must place a scratch file inside the workspace for a brief window, clean it up the moment you're done."#;
46
47pub const WORKSPACE_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_START -->";
48pub const WORKSPACE_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_END -->";
49pub const WORKSPACE_CONTEXT_PREFIX: &str = "Workspace path: ";
50pub const PROJECT_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_START -->";
51pub const PROJECT_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_END -->";
52pub const PROJECT_CONTEXT_PREFIX: &str = "Project ID: ";
53pub const ENV_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_START -->";
54pub const ENV_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_END -->";
55
56pub fn workspace_prompt_guidance() -> String {
58 let config_path = paths::path_to_display_string(&paths::config_json_path());
59 format!(
60 "If you need to inspect files, check the workspace first, then Bamboo data at {}. Bamboo configuration is stored in {} (equivalent to ${{BAMBOO_DATA_DIR}}/config.json).",
61 paths::bamboo_dir_display(),
62 config_path
63 )
64}
65
66fn build_env_prompt_guidance() -> Option<String> {
67 let env_vars = Config::current_prompt_safe_env_vars();
68 if env_vars.is_empty() {
69 return None;
70 }
71
72 let mut lines = vec![
73 "These environment variables were explicitly configured by the user inside Bodhi."
74 .to_string(),
75 "- They are already available to Bash/tool processes launched by Bodhi and may be relevant to tools and skills."
76 .to_string(),
77 "- Treat them as user-approved runtime context instead of asking the user to repeat them immediately."
78 .to_string(),
79 "- Secret values are intentionally hidden from the model.".to_string(),
80 "- If the listed variables appear sufficient, prefer a minimal verification or execution attempt before asking follow-up questions."
81 .to_string(),
82 "- Only ask the user for additional env details after identifying a concrete missing variable, malformed value shape, or execution failure that cannot be resolved from this injected context."
83 .to_string(),
84 ];
85
86 for entry in env_vars {
87 let visibility = if entry.secret { "secret" } else { "non-secret" };
88 let mut line = format!("- {} ({})", entry.name, visibility);
89 if let Some(description) = entry.description {
90 line.push_str(" — ");
91 line.push_str(&description);
92 }
93 lines.push(line);
94 }
95
96 Some(lines.join("\n"))
97}
98
99pub fn build_env_prompt_context() -> Option<String> {
100 let body = build_env_prompt_guidance()?;
101 Some(format!(
102 "{ENV_CONTEXT_START_MARKER}\n{body}\n{ENV_CONTEXT_END_MARKER}"
103 ))
104}
105
106pub fn build_workspace_prompt_context(workspace_path: &str) -> Option<String> {
107 build_workspace_prompt_context_with_binding(
108 workspace_path,
109 WorkspaceBindingStatus::Unregistered,
110 )
111}
112
113pub fn build_workspace_prompt_context_with_binding(
114 workspace_path: &str,
115 binding_status: WorkspaceBindingStatus,
116) -> Option<String> {
117 build_workspace_prompt_context_with_binding_and_source(workspace_path, binding_status, None)
118}
119
120pub fn build_workspace_prompt_context_with_binding_and_source(
121 workspace_path: &str,
122 binding_status: WorkspaceBindingStatus,
123 source: Option<WorkspaceSource>,
124) -> Option<String> {
125 let workspace_path = workspace_path.trim();
126 if workspace_path.is_empty() {
127 return None;
128 }
129
130 let body = format!(
131 "{WORKSPACE_CONTEXT_PREFIX}{}\nWorkspace source: {}\nBinding status: {}\nWorkspace-local resources may override Project-shared resources.\nChanging the workspace changes only the filesystem execution context; it does not change Project membership or Project memory.\n{}",
132 prompt_safe_scalar(workspace_path),
133 source.unwrap_or(WorkspaceSource::Session).as_str(),
134 binding_status.as_str(),
135 workspace_prompt_guidance()
136 );
137
138 Some(format!(
139 "{WORKSPACE_CONTEXT_START_MARKER}\n{body}\n{WORKSPACE_CONTEXT_END_MARKER}"
140 ))
141}
142
143pub(crate) fn legacy_unwrapped_workspace_context_bounds(prompt: &str) -> Option<(usize, usize)> {
151 let guidance = workspace_prompt_guidance();
152
153 for (start_idx, _) in prompt.match_indices(WORKSPACE_CONTEXT_PREFIX) {
154 if start_idx > 0 && prompt.as_bytes()[start_idx - 1] != b'\n' {
155 continue;
156 }
157
158 let path_start = start_idx + WORKSPACE_CONTEXT_PREFIX.len();
159 let Some(path_end_rel) = prompt[path_start..].find('\n') else {
160 continue;
161 };
162 let path_end = path_start + path_end_rel;
163 if prompt[path_start..path_end].trim().is_empty() {
164 continue;
165 }
166
167 let metadata_start = path_end + 1;
168 let Some(guidance_rel) = prompt[metadata_start..].find(&guidance) else {
169 continue;
170 };
171 let guidance_start = metadata_start + guidance_rel;
172 if guidance_start > 0 && prompt.as_bytes()[guidance_start - 1] != b'\n' {
173 continue;
174 }
175
176 let metadata_is_generated = prompt[metadata_start..guidance_start]
177 .lines()
178 .map(str::trim)
179 .filter(|line| !line.is_empty())
180 .all(|line| {
181 matches!(
182 line,
183 "Workspace source: explicit"
184 | "Workspace source: project_default"
185 | "Workspace source: session"
186 | "Binding status: registered"
187 | "Binding status: unregistered"
188 | "Workspace-local resources may override Project-shared resources."
189 | "Changing the workspace changes only the filesystem execution context; it does not change Project membership or Project memory."
190 )
191 });
192 if !metadata_is_generated {
193 continue;
194 }
195
196 return Some((start_idx, guidance_start + guidance.len()));
197 }
198
199 None
200}
201
202pub fn build_project_prompt_context(context: &ResolvedProjectContext) -> String {
209 let project = &context.project;
210 let project_path = project
211 .project_path
212 .as_deref()
213 .map(paths::path_to_display_string)
214 .unwrap_or_else(|| "not configured".to_string());
215 let body = format!(
216 "{PROJECT_CONTEXT_PREFIX}{}\nProject name: {}\nProject path: {}\nProject home (Bamboo data): {}\nThis session belongs to this Project.\nWorkspace is mutable execution context; changing it does not change Project membership, sidebar grouping, Project memory, or Project-shared resources.\nProject-shared resource inventory is supplied separately as per-round dynamic context.\nUse Workspace to inspect/change only the current directory.\nUse Project to inspect Project identity, bindings, and shared resources.",
217 prompt_safe_scalar(project.id.as_str()),
218 prompt_safe_scalar(&project.name),
219 prompt_safe_scalar(&project_path),
220 prompt_safe_scalar(&paths::path_to_display_string(&project.home)),
221 );
222 format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
223}
224
225pub fn build_project_model_context(context: &ResolvedProjectContext) -> String {
231 let project = &context.project;
232 let body = format!(
233 "{PROJECT_CONTEXT_PREFIX}{}\nProject name: {}\nThis session belongs to this Project.\nWorkspace is mutable execution context; changing it does not change Project membership, sidebar grouping, Project memory, or Project-shared resources.\nProject-shared resource inventory is supplied separately as per-round dynamic context.\nThe host owns workspace selection and reports the active execution context separately.",
234 prompt_safe_scalar(project.id.as_str()),
235 prompt_safe_scalar(&project.name),
236 );
237 format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
238}
239
240pub fn upsert_project_prompt_context(
244 prompt: &str,
245 context: Option<&ResolvedProjectContext>,
246) -> String {
247 replace_prompt_block(
248 prompt,
249 PROJECT_CONTEXT_START_MARKER,
250 PROJECT_CONTEXT_END_MARKER,
251 context.map(build_project_prompt_context).as_deref(),
252 )
253}
254
255pub fn upsert_workspace_prompt_context(
259 prompt: &str,
260 workspace_path: Option<&str>,
261 binding_status: WorkspaceBindingStatus,
262) -> String {
263 upsert_workspace_prompt_context_with_source(prompt, workspace_path, binding_status, None)
264}
265
266pub fn upsert_workspace_prompt_context_with_source(
267 prompt: &str,
268 workspace_path: Option<&str>,
269 binding_status: WorkspaceBindingStatus,
270 source: Option<WorkspaceSource>,
271) -> String {
272 let block = workspace_path.and_then(|workspace| {
273 build_workspace_prompt_context_with_binding_and_source(workspace, binding_status, source)
274 });
275 replace_prompt_block(
276 prompt,
277 WORKSPACE_CONTEXT_START_MARKER,
278 WORKSPACE_CONTEXT_END_MARKER,
279 block.as_deref(),
280 )
281}
282
283fn prompt_safe_scalar(value: &str) -> String {
284 value
285 .chars()
286 .map(|character| {
287 if character.is_control() {
288 ' '
289 } else {
290 character
291 }
292 })
293 .collect::<String>()
294 .replace("<!--", "< !--")
295 .trim()
296 .to_string()
297}
298
299fn replace_prompt_block(
300 prompt: &str,
301 start_marker: &str,
302 end_marker: &str,
303 replacement: Option<&str>,
304) -> String {
305 let mut current = prompt.to_string();
306 while let Some(start) = current.find(start_marker) {
307 let content_start = start + start_marker.len();
308 let Some(relative_end) = current[content_start..].find(end_marker) else {
309 current.truncate(start);
310 break;
311 };
312 let end = content_start + relative_end + end_marker.len();
313 let before = current[..start].trim_end();
314 let after = current[end..].trim_start();
315 current = match (before.is_empty(), after.is_empty()) {
316 (true, true) => String::new(),
317 (true, false) => after.to_string(),
318 (false, true) => before.to_string(),
319 (false, false) => format!("{before}\n\n{after}"),
320 };
321 }
322
323 if let Some(replacement) = replacement.map(str::trim).filter(|value| !value.is_empty()) {
324 if !current.trim().is_empty() {
325 current = current.trim().to_string();
326 current.push_str("\n\n");
327 }
328 current.push_str(replacement);
329 }
330 current
331}
332
333pub fn assemble_system_prompt(
340 base: &str,
341 enhance: Option<&str>,
342 _workspace_path: Option<&str>,
343) -> String {
344 assemble_system_prompt_with_project(base, enhance, None, None)
345}
346
347pub fn assemble_system_prompt_with_project(
348 base: &str,
349 enhance: Option<&str>,
350 _project_context: Option<&ResolvedProjectContext>,
351 _workspace_path: Option<&str>,
352) -> String {
353 let mut prompt = base.trim().to_string();
354 if let Some(extra) = enhance.map(str::trim).filter(|v| !v.is_empty()) {
355 if !prompt.is_empty() {
356 prompt.push_str("\n\n");
357 }
358 prompt.push_str(extra);
359 }
360 prompt
361}
362
363#[cfg(test)]
364mod project_context_tests {
365 use std::path::PathBuf;
366
367 use crate::project_context::{
368 ProjectDescriptor, ResolvedProjectContext, WorkspaceBindingStatus,
369 };
370 use bamboo_domain::{
371 ProjectId, ProjectResourceEntry, ProjectResourceKind, ProjectResourceSummary,
372 WorkspaceBinding,
373 };
374
375 use super::*;
376
377 fn project_context(workspace: &str) -> ResolvedProjectContext {
378 let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
379 ResolvedProjectContext {
380 project: ProjectDescriptor {
381 id: project_id.clone(),
382 name: "Zenith".to_string(),
383 project_path: Some(PathBuf::from(workspace)),
384 home: PathBuf::from("/data/projects/01JPROJECT00000000000000000"),
385 workspace_bindings: vec![WorkspaceBinding {
386 path: workspace.to_string(),
387 label: Some("main".to_string()),
388 git_common_dir: None,
389 }],
390 resources: ProjectResourceSummary {
391 project_id,
392 resource_revision: 9,
393 resources: vec![
394 ProjectResourceEntry {
395 kind: ProjectResourceKind::Memory,
396 present: true,
397 item_count: 1,
398 },
399 ProjectResourceEntry {
400 kind: ProjectResourceKind::Skills,
401 present: true,
402 item_count: 2,
403 },
404 ProjectResourceEntry {
405 kind: ProjectResourceKind::Commands,
406 present: true,
407 item_count: 1,
408 },
409 ],
410 },
411 },
412 workspace: Some(PathBuf::from(workspace)),
413 workspace_source: WorkspaceSource::Session,
414 binding_status: WorkspaceBindingStatus::Registered,
415 }
416 }
417
418 #[test]
419 fn system_assembly_ignores_legacy_dynamic_context_arguments() {
420 let context = project_context("/workspace/private");
421 let prompt = assemble_system_prompt_with_project(
422 "base",
423 None,
424 Some(&context),
425 Some("/workspace/private"),
426 );
427 assert_eq!(prompt, "base");
428 assert!(!prompt.contains("/workspace/private"));
429 assert!(!prompt.contains("/data/projects"));
430 assert!(!prompt.contains(PROJECT_CONTEXT_START_MARKER));
431 assert!(!prompt.contains(WORKSPACE_CONTEXT_START_MARKER));
432 assert!(!prompt.contains(WORKSPACE_CONTEXT_END_MARKER));
433 assert!(!prompt.contains(ENV_CONTEXT_START_MARKER));
434 }
435
436 #[test]
437 fn provider_project_context_contains_no_host_paths() {
438 let context = project_context("/workspace/main");
439 let prompt = build_project_model_context(&context);
440 assert!(prompt.contains("Project ID:"));
441 assert!(!prompt.contains("/workspace/main"));
442 assert!(!prompt.contains("/data/projects"));
443 }
444
445 #[test]
446 fn workspace_upsert_preserves_project_block_byte_for_byte() {
447 let context = project_context("/workspace/main");
448 let project_block = build_project_prompt_context(&context);
449 let prompt = format!("base\n\n{project_block}");
450 let updated = upsert_workspace_prompt_context(
451 &prompt,
452 Some("/workspace/worktree"),
453 WorkspaceBindingStatus::Registered,
454 );
455 assert_eq!(updated.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
456 assert!(updated.contains(&project_block));
457 assert!(!updated.contains("Workspace path: /workspace/main"));
458 assert!(updated.contains("Workspace path: /workspace/worktree"));
459 }
460
461 #[test]
462 fn upsert_deduplicates_only_its_own_marker() {
463 let context = project_context("/workspace/main");
464 let project = build_project_prompt_context(&context);
465 let workspace = build_workspace_prompt_context_with_binding(
466 "/workspace/main",
467 WorkspaceBindingStatus::Registered,
468 )
469 .expect("workspace");
470 let duplicated = format!("base\n\n{project}\n\n{workspace}\n\n{project}\n\n{workspace}");
471 let project_upserted = upsert_project_prompt_context(&duplicated, Some(&context));
472 assert_eq!(
473 project_upserted
474 .matches(PROJECT_CONTEXT_START_MARKER)
475 .count(),
476 1
477 );
478 assert_eq!(
479 project_upserted
480 .matches(WORKSPACE_CONTEXT_START_MARKER)
481 .count(),
482 2
483 );
484 let fully_upserted = upsert_workspace_prompt_context(
485 &project_upserted,
486 Some("/workspace/main"),
487 WorkspaceBindingStatus::Registered,
488 );
489 assert_eq!(
490 fully_upserted.matches(PROJECT_CONTEXT_START_MARKER).count(),
491 1
492 );
493 assert_eq!(
494 fully_upserted
495 .matches(WORKSPACE_CONTEXT_START_MARKER)
496 .count(),
497 1
498 );
499 }
500
501 #[test]
502 fn project_values_cannot_inject_prompt_markers() {
503 let mut context = project_context("/workspace/main");
504 context.project.name = "unsafe\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->".to_string();
505 let prompt = build_project_prompt_context(&context);
506 assert!(!prompt.contains("\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->"));
507 }
508
509 #[test]
510 fn resource_revision_changes_only_dynamic_inventory() {
511 let first = project_context("/workspace/main");
512 let mut second = first.clone();
513 second.project.resources.resource_revision += 1;
514 second.project.resources.resources[1].item_count += 3;
515
516 assert_eq!(
517 build_project_prompt_context(&first),
518 build_project_prompt_context(&second),
519 "cacheable Project identity must not contain inventory or revision"
520 );
521 assert_ne!(
522 first.render_resource_inventory(),
523 second.render_resource_inventory(),
524 "per-round inventory must reflect the new resource revision"
525 );
526 }
527}