Skip to main content

bamboo_engine/runtime/context/
mod.rs

1//! Prompt context builders (extracted from server layer).
2//!
3//! Stateless functions that construct workspace, environment, and instruction
4//! context for the agent system prompt.
5
6pub 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 cannot see this conversation), 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
16/// Framework-invariant agent operating directives, applied on top of whatever
17/// base prompt is in effect.
18///
19/// Unlike [`DEFAULT_BASE_PROMPT`] — which a user can fully replace via
20/// `${BAMBOO_DATA_DIR}/system-prompt.md` — these directives are appended during
21/// per-round prompt assembly (see `append_core_agent_directives`), so they
22/// always apply even when the base prompt is overridden. Keep the text static:
23/// it rides in the cacheable system field, so churn here forces a cache re-warm.
24pub 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
34Verify 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.
35
36Scratch 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."#;
37
38pub const WORKSPACE_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_START -->";
39pub const WORKSPACE_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_END -->";
40pub const WORKSPACE_CONTEXT_PREFIX: &str = "Workspace path: ";
41pub const PROJECT_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_START -->";
42pub const PROJECT_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_END -->";
43pub const PROJECT_CONTEXT_PREFIX: &str = "Project ID: ";
44pub const ENV_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_START -->";
45pub const ENV_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_END -->";
46
47/// Guidance for workspace-based interactions
48pub fn workspace_prompt_guidance() -> String {
49    let config_path = paths::path_to_display_string(&paths::config_json_path());
50    format!(
51        "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).",
52        paths::bamboo_dir_display(),
53        config_path
54    )
55}
56
57fn build_env_prompt_guidance() -> Option<String> {
58    let env_vars = Config::current_prompt_safe_env_vars();
59    if env_vars.is_empty() {
60        return None;
61    }
62
63    let mut lines = vec![
64        "These environment variables were explicitly configured by the user inside Bodhi."
65            .to_string(),
66        "- They are already available to Bash/tool processes launched by Bodhi and may be relevant to tools and skills."
67            .to_string(),
68        "- Treat them as user-approved runtime context instead of asking the user to repeat them immediately."
69            .to_string(),
70        "- Secret values are intentionally hidden from the model.".to_string(),
71        "- If the listed variables appear sufficient, prefer a minimal verification or execution attempt before asking follow-up questions."
72            .to_string(),
73        "- 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."
74            .to_string(),
75    ];
76
77    for entry in env_vars {
78        let visibility = if entry.secret { "secret" } else { "non-secret" };
79        let mut line = format!("- {} ({})", entry.name, visibility);
80        if let Some(description) = entry.description {
81            line.push_str(" — ");
82            line.push_str(&description);
83        }
84        lines.push(line);
85    }
86
87    Some(lines.join("\n"))
88}
89
90pub fn build_env_prompt_context() -> Option<String> {
91    let body = build_env_prompt_guidance()?;
92    Some(format!(
93        "{ENV_CONTEXT_START_MARKER}\n{body}\n{ENV_CONTEXT_END_MARKER}"
94    ))
95}
96
97pub fn build_workspace_prompt_context(workspace_path: &str) -> Option<String> {
98    build_workspace_prompt_context_with_binding(
99        workspace_path,
100        WorkspaceBindingStatus::Unregistered,
101    )
102}
103
104pub fn build_workspace_prompt_context_with_binding(
105    workspace_path: &str,
106    binding_status: WorkspaceBindingStatus,
107) -> Option<String> {
108    build_workspace_prompt_context_with_binding_and_source(workspace_path, binding_status, None)
109}
110
111pub fn build_workspace_prompt_context_with_binding_and_source(
112    workspace_path: &str,
113    binding_status: WorkspaceBindingStatus,
114    source: Option<WorkspaceSource>,
115) -> Option<String> {
116    let workspace_path = workspace_path.trim();
117    if workspace_path.is_empty() {
118        return None;
119    }
120
121    let body = format!(
122        "{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{}",
123        prompt_safe_scalar(workspace_path),
124        source.unwrap_or(WorkspaceSource::Session).as_str(),
125        binding_status.as_str(),
126        workspace_prompt_guidance()
127    );
128
129    Some(format!(
130        "{WORKSPACE_CONTEXT_START_MARKER}\n{body}\n{WORKSPACE_CONTEXT_END_MARKER}"
131    ))
132}
133
134/// Build the stable Project identity block.
135///
136/// Resource counts and revisions are intentionally excluded: they belong to
137/// the per-round dynamic resource envelope. Secret settings, credential
138/// values, MCP headers, and environment values do not belong in
139/// [`ResolvedProjectContext`] and therefore cannot leak through this builder.
140pub fn build_project_prompt_context(context: &ResolvedProjectContext) -> String {
141    let project = &context.project;
142    let project_path = project
143        .project_path
144        .as_deref()
145        .map(paths::path_to_display_string)
146        .unwrap_or_else(|| "not configured".to_string());
147    let body = format!(
148        "{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.",
149        prompt_safe_scalar(project.id.as_str()),
150        prompt_safe_scalar(&project.name),
151        prompt_safe_scalar(&project_path),
152        prompt_safe_scalar(&paths::path_to_display_string(&project.home)),
153    );
154    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
155}
156
157/// Replace every existing Project block with exactly one current block.
158///
159/// Workspace blocks are deliberately outside the removed marker range.
160pub fn upsert_project_prompt_context(
161    prompt: &str,
162    context: Option<&ResolvedProjectContext>,
163) -> String {
164    replace_prompt_block(
165        prompt,
166        PROJECT_CONTEXT_START_MARKER,
167        PROJECT_CONTEXT_END_MARKER,
168        context.map(build_project_prompt_context).as_deref(),
169    )
170}
171
172/// Replace every existing Workspace block with exactly one current block.
173///
174/// Project identity is left byte-for-byte unchanged.
175pub fn upsert_workspace_prompt_context(
176    prompt: &str,
177    workspace_path: Option<&str>,
178    binding_status: WorkspaceBindingStatus,
179) -> String {
180    upsert_workspace_prompt_context_with_source(prompt, workspace_path, binding_status, None)
181}
182
183pub fn upsert_workspace_prompt_context_with_source(
184    prompt: &str,
185    workspace_path: Option<&str>,
186    binding_status: WorkspaceBindingStatus,
187    source: Option<WorkspaceSource>,
188) -> String {
189    let block = workspace_path.and_then(|workspace| {
190        build_workspace_prompt_context_with_binding_and_source(workspace, binding_status, source)
191    });
192    replace_prompt_block(
193        prompt,
194        WORKSPACE_CONTEXT_START_MARKER,
195        WORKSPACE_CONTEXT_END_MARKER,
196        block.as_deref(),
197    )
198}
199
200fn prompt_safe_scalar(value: &str) -> String {
201    value
202        .chars()
203        .map(|character| {
204            if character.is_control() {
205                ' '
206            } else {
207                character
208            }
209        })
210        .collect::<String>()
211        .replace("<!--", "< !--")
212        .trim()
213        .to_string()
214}
215
216fn replace_prompt_block(
217    prompt: &str,
218    start_marker: &str,
219    end_marker: &str,
220    replacement: Option<&str>,
221) -> String {
222    let mut current = prompt.to_string();
223    while let Some(start) = current.find(start_marker) {
224        let content_start = start + start_marker.len();
225        let Some(relative_end) = current[content_start..].find(end_marker) else {
226            current.truncate(start);
227            break;
228        };
229        let end = content_start + relative_end + end_marker.len();
230        let before = current[..start].trim_end();
231        let after = current[end..].trim_start();
232        current = match (before.is_empty(), after.is_empty()) {
233            (true, true) => String::new(),
234            (true, false) => after.to_string(),
235            (false, true) => before.to_string(),
236            (false, false) => format!("{before}\n\n{after}"),
237        };
238    }
239
240    if let Some(replacement) = replacement.map(str::trim).filter(|value| !value.is_empty()) {
241        if !current.trim().is_empty() {
242            current = current.trim().to_string();
243            current.push_str("\n\n");
244        }
245        current.push_str(replacement);
246    }
247    current
248}
249
250/// Assemble a full system prompt from base prompt, optional enhancement, and context segments.
251///
252/// This is the shared prompt assembly logic used by both the HTTP handler and the schedule
253/// manager. It layers:
254/// 1. Base system prompt (required)
255/// 2. Optional enhancement text
256/// 3. Workspace context (if workspace_path is provided)
257/// 4. Instruction layer context (AGENTS.md / CLAUDE.md from workspace)
258/// 5. Environment variable context
259pub fn assemble_system_prompt(
260    base: &str,
261    enhance: Option<&str>,
262    workspace_path: Option<&str>,
263) -> String {
264    assemble_system_prompt_with_project(base, enhance, None, workspace_path)
265}
266
267pub fn assemble_system_prompt_with_project(
268    base: &str,
269    enhance: Option<&str>,
270    project_context: Option<&ResolvedProjectContext>,
271    workspace_path: Option<&str>,
272) -> String {
273    let mut prompt = base.trim().to_string();
274    if let Some(extra) = enhance.map(str::trim).filter(|v| !v.is_empty()) {
275        if !prompt.is_empty() {
276            prompt.push_str("\n\n");
277        }
278        prompt.push_str(extra);
279    }
280    if let Some(context) = project_context {
281        let segment = build_project_prompt_context(context);
282        if !prompt.is_empty() {
283            prompt.push_str("\n\n");
284        }
285        prompt.push_str(&segment);
286    }
287    if let Some(path) = workspace_path.map(str::trim).filter(|v| !v.is_empty()) {
288        let binding_status = project_context
289            .map(|context| context.binding_status)
290            .unwrap_or(WorkspaceBindingStatus::Unregistered);
291        if let Some(segment) = build_workspace_prompt_context_with_binding_and_source(
292            path,
293            binding_status,
294            project_context.map(|context| context.workspace_source),
295        ) {
296            if !prompt.is_empty() {
297                prompt.push_str("\n\n");
298            }
299            prompt.push_str(&segment);
300        }
301        if let Some(instruction_segment) = instruction::build_instruction_prompt_context(path) {
302            if !prompt.is_empty() {
303                prompt.push_str("\n\n");
304            }
305            prompt.push_str(&instruction_segment);
306        }
307    }
308    if let Some(segment) = build_env_prompt_context() {
309        if !prompt.is_empty() {
310            prompt.push_str("\n\n");
311        }
312        prompt.push_str(&segment);
313    }
314    prompt
315}
316
317#[cfg(test)]
318mod project_context_tests {
319    use std::path::PathBuf;
320
321    use crate::project_context::{
322        ProjectDescriptor, ResolvedProjectContext, WorkspaceBindingStatus,
323    };
324    use bamboo_domain::{
325        ProjectId, ProjectResourceEntry, ProjectResourceKind, ProjectResourceSummary,
326        WorkspaceBinding,
327    };
328
329    use super::*;
330
331    fn project_context(workspace: &str) -> ResolvedProjectContext {
332        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
333        ResolvedProjectContext {
334            project: ProjectDescriptor {
335                id: project_id.clone(),
336                name: "Zenith".to_string(),
337                project_path: Some(PathBuf::from(workspace)),
338                home: PathBuf::from("/data/projects/01JPROJECT00000000000000000"),
339                workspace_bindings: vec![WorkspaceBinding {
340                    path: workspace.to_string(),
341                    label: Some("main".to_string()),
342                    git_common_dir: None,
343                }],
344                resources: ProjectResourceSummary {
345                    project_id,
346                    resource_revision: 9,
347                    resources: vec![
348                        ProjectResourceEntry {
349                            kind: ProjectResourceKind::Memory,
350                            present: true,
351                            item_count: 1,
352                        },
353                        ProjectResourceEntry {
354                            kind: ProjectResourceKind::Skills,
355                            present: true,
356                            item_count: 2,
357                        },
358                        ProjectResourceEntry {
359                            kind: ProjectResourceKind::Commands,
360                            present: true,
361                            item_count: 1,
362                        },
363                    ],
364                },
365                memory_read_roots: crate::project_context::ProjectMemoryReadRoots {
366                    primary: PathBuf::from("/data/projects/01JPROJECT00000000000000000/memory/v1"),
367                    legacy_aliases: Vec::new(),
368                },
369            },
370            workspace: Some(PathBuf::from(workspace)),
371            workspace_source: WorkspaceSource::Session,
372            binding_status: WorkspaceBindingStatus::Registered,
373        }
374    }
375
376    #[test]
377    fn scoped_prompt_contains_exactly_one_project_and_workspace_block() {
378        let context = project_context("/workspace/main");
379        let prompt = assemble_system_prompt_with_project(
380            "base",
381            None,
382            Some(&context),
383            Some("/workspace/main"),
384        );
385        assert_eq!(prompt.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
386        assert_eq!(prompt.matches(PROJECT_CONTEXT_END_MARKER).count(), 1);
387        assert_eq!(prompt.matches(WORKSPACE_CONTEXT_START_MARKER).count(), 1);
388        assert_eq!(prompt.matches(WORKSPACE_CONTEXT_END_MARKER).count(), 1);
389        assert!(prompt.contains("Binding status: registered"));
390    }
391
392    #[test]
393    fn workspace_upsert_preserves_project_block_byte_for_byte() {
394        let context = project_context("/workspace/main");
395        let prompt = assemble_system_prompt_with_project(
396            "base",
397            None,
398            Some(&context),
399            Some("/workspace/main"),
400        );
401        let project_block = build_project_prompt_context(&context);
402        let updated = upsert_workspace_prompt_context(
403            &prompt,
404            Some("/workspace/worktree"),
405            WorkspaceBindingStatus::Registered,
406        );
407        assert_eq!(updated.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
408        assert!(updated.contains(&project_block));
409        assert!(!updated.contains("Workspace path: /workspace/main"));
410        assert!(updated.contains("Workspace path: /workspace/worktree"));
411    }
412
413    #[test]
414    fn upsert_deduplicates_only_its_own_marker() {
415        let context = project_context("/workspace/main");
416        let project = build_project_prompt_context(&context);
417        let workspace = build_workspace_prompt_context_with_binding(
418            "/workspace/main",
419            WorkspaceBindingStatus::Registered,
420        )
421        .expect("workspace");
422        let duplicated = format!("base\n\n{project}\n\n{workspace}\n\n{project}\n\n{workspace}");
423        let project_upserted = upsert_project_prompt_context(&duplicated, Some(&context));
424        assert_eq!(
425            project_upserted
426                .matches(PROJECT_CONTEXT_START_MARKER)
427                .count(),
428            1
429        );
430        assert_eq!(
431            project_upserted
432                .matches(WORKSPACE_CONTEXT_START_MARKER)
433                .count(),
434            2
435        );
436        let fully_upserted = upsert_workspace_prompt_context(
437            &project_upserted,
438            Some("/workspace/main"),
439            WorkspaceBindingStatus::Registered,
440        );
441        assert_eq!(
442            fully_upserted.matches(PROJECT_CONTEXT_START_MARKER).count(),
443            1
444        );
445        assert_eq!(
446            fully_upserted
447                .matches(WORKSPACE_CONTEXT_START_MARKER)
448                .count(),
449            1
450        );
451    }
452
453    #[test]
454    fn project_values_cannot_inject_prompt_markers() {
455        let mut context = project_context("/workspace/main");
456        context.project.name = "unsafe\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->".to_string();
457        let prompt = build_project_prompt_context(&context);
458        assert!(!prompt.contains("\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->"));
459    }
460
461    #[test]
462    fn resource_revision_changes_only_dynamic_inventory() {
463        let first = project_context("/workspace/main");
464        let mut second = first.clone();
465        second.project.resources.resource_revision += 1;
466        second.project.resources.resources[1].item_count += 3;
467
468        assert_eq!(
469            build_project_prompt_context(&first),
470            build_project_prompt_context(&second),
471            "cacheable Project identity must not contain inventory or revision"
472        );
473        assert_ne!(
474            first.render_resource_inventory(),
475            second.render_resource_inventory(),
476            "per-round inventory must reflect the new resource revision"
477        );
478    }
479}