bamboo-engine 2026.7.27

Execution engine and orchestration for the Bamboo agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Prompt context builders (extracted from server layer).
//!
//! Stateless functions that construct workspace, environment, and instruction
//! context for the agent system prompt.

pub mod instruction;

use bamboo_config::paths;
use bamboo_llm::Config;

use crate::project_context::{ResolvedProjectContext, WorkspaceBindingStatus};

pub const DEFAULT_BASE_PROMPT: &str =
    "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.";

/// Framework-invariant agent operating directives, applied on top of whatever
/// base prompt is in effect.
///
/// Unlike [`DEFAULT_BASE_PROMPT`] — which a user can fully replace via
/// `${BAMBOO_DATA_DIR}/system-prompt.md` — these directives are appended during
/// per-round prompt assembly (see `append_core_agent_directives`), so they
/// always apply even when the base prompt is overridden. Keep the text static:
/// it rides in the cacheable system field, so churn here forces a cache re-warm.
pub 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.

Work through a task with this decision ladder — first matching case wins, and don't over-plan:
1. 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.
2. One tool call (or one short read → grep → read sequence) gets it → do it directly. Don't open a Task list, don't delegate.
3. Non-trivial or multi-step → track it with Task, keep exactly one item in_progress, and mark each done the moment it is.
4. 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.
5. Branches are dependent, or two of them write the same files → serialize; never fan out writes.
Judgment 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.

Verify 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.

Scratch 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."#;

pub const WORKSPACE_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_START -->";
pub const WORKSPACE_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_END -->";
pub const WORKSPACE_CONTEXT_PREFIX: &str = "Workspace path: ";
pub const PROJECT_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_START -->";
pub const PROJECT_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_END -->";
pub const PROJECT_CONTEXT_PREFIX: &str = "Project ID: ";
pub const ENV_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_START -->";
pub const ENV_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_END -->";

/// Guidance for workspace-based interactions
pub fn workspace_prompt_guidance() -> String {
    let config_path = paths::path_to_display_string(&paths::config_json_path());
    format!(
        "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).",
        paths::bamboo_dir_display(),
        config_path
    )
}

fn build_env_prompt_guidance() -> Option<String> {
    let env_vars = Config::current_prompt_safe_env_vars();
    if env_vars.is_empty() {
        return None;
    }

    let mut lines = vec![
        "These environment variables were explicitly configured by the user inside Bodhi."
            .to_string(),
        "- They are already available to Bash/tool processes launched by Bodhi and may be relevant to tools and skills."
            .to_string(),
        "- Treat them as user-approved runtime context instead of asking the user to repeat them immediately."
            .to_string(),
        "- Secret values are intentionally hidden from the model.".to_string(),
        "- If the listed variables appear sufficient, prefer a minimal verification or execution attempt before asking follow-up questions."
            .to_string(),
        "- 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."
            .to_string(),
    ];

    for entry in env_vars {
        let visibility = if entry.secret { "secret" } else { "non-secret" };
        let mut line = format!("- {} ({})", entry.name, visibility);
        if let Some(description) = entry.description {
            line.push_str(" — ");
            line.push_str(&description);
        }
        lines.push(line);
    }

    Some(lines.join("\n"))
}

pub fn build_env_prompt_context() -> Option<String> {
    let body = build_env_prompt_guidance()?;
    Some(format!(
        "{ENV_CONTEXT_START_MARKER}\n{body}\n{ENV_CONTEXT_END_MARKER}"
    ))
}

pub fn build_workspace_prompt_context(workspace_path: &str) -> Option<String> {
    build_workspace_prompt_context_with_binding(
        workspace_path,
        WorkspaceBindingStatus::Unregistered,
    )
}

pub fn build_workspace_prompt_context_with_binding(
    workspace_path: &str,
    binding_status: WorkspaceBindingStatus,
) -> Option<String> {
    let workspace_path = workspace_path.trim();
    if workspace_path.is_empty() {
        return None;
    }

    let body = format!(
        "{WORKSPACE_CONTEXT_PREFIX}{}\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{}",
        prompt_safe_scalar(workspace_path),
        binding_status.as_str(),
        workspace_prompt_guidance()
    );

    Some(format!(
        "{WORKSPACE_CONTEXT_START_MARKER}\n{body}\n{WORKSPACE_CONTEXT_END_MARKER}"
    ))
}

/// Build the stable Project identity block.
///
/// Resource counts and revisions are intentionally excluded: they belong to
/// the per-round dynamic resource envelope. Secret settings, credential
/// values, MCP headers, and environment values do not belong in
/// [`ResolvedProjectContext`] and therefore cannot leak through this builder.
pub fn build_project_prompt_context(context: &ResolvedProjectContext) -> String {
    let project = &context.project;
    let body = format!(
        "{PROJECT_CONTEXT_PREFIX}{}\nProject name: {}\nProject home: {}\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.",
        prompt_safe_scalar(project.id.as_str()),
        prompt_safe_scalar(&project.name),
        prompt_safe_scalar(&paths::path_to_display_string(&project.home)),
    );
    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
}

/// Replace every existing Project block with exactly one current block.
///
/// Workspace blocks are deliberately outside the removed marker range.
pub fn upsert_project_prompt_context(
    prompt: &str,
    context: Option<&ResolvedProjectContext>,
) -> String {
    replace_prompt_block(
        prompt,
        PROJECT_CONTEXT_START_MARKER,
        PROJECT_CONTEXT_END_MARKER,
        context.map(build_project_prompt_context).as_deref(),
    )
}

/// Replace every existing Workspace block with exactly one current block.
///
/// Project identity is left byte-for-byte unchanged.
pub fn upsert_workspace_prompt_context(
    prompt: &str,
    workspace_path: Option<&str>,
    binding_status: WorkspaceBindingStatus,
) -> String {
    let block = workspace_path.and_then(|workspace| {
        build_workspace_prompt_context_with_binding(workspace, binding_status)
    });
    replace_prompt_block(
        prompt,
        WORKSPACE_CONTEXT_START_MARKER,
        WORKSPACE_CONTEXT_END_MARKER,
        block.as_deref(),
    )
}

fn prompt_safe_scalar(value: &str) -> String {
    value
        .chars()
        .map(|character| {
            if character.is_control() {
                ' '
            } else {
                character
            }
        })
        .collect::<String>()
        .replace("<!--", "< !--")
        .trim()
        .to_string()
}

fn replace_prompt_block(
    prompt: &str,
    start_marker: &str,
    end_marker: &str,
    replacement: Option<&str>,
) -> String {
    let mut current = prompt.to_string();
    while let Some(start) = current.find(start_marker) {
        let content_start = start + start_marker.len();
        let Some(relative_end) = current[content_start..].find(end_marker) else {
            current.truncate(start);
            break;
        };
        let end = content_start + relative_end + end_marker.len();
        let before = current[..start].trim_end();
        let after = current[end..].trim_start();
        current = match (before.is_empty(), after.is_empty()) {
            (true, true) => String::new(),
            (true, false) => after.to_string(),
            (false, true) => before.to_string(),
            (false, false) => format!("{before}\n\n{after}"),
        };
    }

    if let Some(replacement) = replacement.map(str::trim).filter(|value| !value.is_empty()) {
        if !current.trim().is_empty() {
            current = current.trim().to_string();
            current.push_str("\n\n");
        }
        current.push_str(replacement);
    }
    current
}

/// Assemble a full system prompt from base prompt, optional enhancement, and context segments.
///
/// This is the shared prompt assembly logic used by both the HTTP handler and the schedule
/// manager. It layers:
/// 1. Base system prompt (required)
/// 2. Optional enhancement text
/// 3. Workspace context (if workspace_path is provided)
/// 4. Instruction layer context (AGENTS.md / CLAUDE.md from workspace)
/// 5. Environment variable context
pub fn assemble_system_prompt(
    base: &str,
    enhance: Option<&str>,
    workspace_path: Option<&str>,
) -> String {
    assemble_system_prompt_with_project(base, enhance, None, workspace_path)
}

pub fn assemble_system_prompt_with_project(
    base: &str,
    enhance: Option<&str>,
    project_context: Option<&ResolvedProjectContext>,
    workspace_path: Option<&str>,
) -> String {
    let mut prompt = base.trim().to_string();
    if let Some(extra) = enhance.map(str::trim).filter(|v| !v.is_empty()) {
        if !prompt.is_empty() {
            prompt.push_str("\n\n");
        }
        prompt.push_str(extra);
    }
    if let Some(context) = project_context {
        let segment = build_project_prompt_context(context);
        if !prompt.is_empty() {
            prompt.push_str("\n\n");
        }
        prompt.push_str(&segment);
    }
    if let Some(path) = workspace_path.map(str::trim).filter(|v| !v.is_empty()) {
        let binding_status = project_context
            .map(|context| context.binding_status)
            .unwrap_or(WorkspaceBindingStatus::Unregistered);
        if let Some(segment) = build_workspace_prompt_context_with_binding(path, binding_status) {
            if !prompt.is_empty() {
                prompt.push_str("\n\n");
            }
            prompt.push_str(&segment);
        }
        if let Some(instruction_segment) = instruction::build_instruction_prompt_context(path) {
            if !prompt.is_empty() {
                prompt.push_str("\n\n");
            }
            prompt.push_str(&instruction_segment);
        }
    }
    if let Some(segment) = build_env_prompt_context() {
        if !prompt.is_empty() {
            prompt.push_str("\n\n");
        }
        prompt.push_str(&segment);
    }
    prompt
}

#[cfg(test)]
mod project_context_tests {
    use std::path::PathBuf;

    use crate::project_context::{
        ProjectDescriptor, ResolvedProjectContext, WorkspaceBindingStatus,
    };
    use bamboo_domain::{
        ProjectId, ProjectResourceEntry, ProjectResourceKind, ProjectResourceSummary,
        WorkspaceBinding,
    };

    use super::*;

    fn project_context(workspace: &str) -> ResolvedProjectContext {
        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
        ResolvedProjectContext {
            project: ProjectDescriptor {
                id: project_id.clone(),
                name: "Zenith".to_string(),
                home: PathBuf::from("/data/projects/01JPROJECT00000000000000000"),
                workspace_bindings: vec![WorkspaceBinding {
                    path: workspace.to_string(),
                    label: Some("main".to_string()),
                    git_common_dir: None,
                }],
                resources: ProjectResourceSummary {
                    project_id,
                    resource_revision: 9,
                    resources: vec![
                        ProjectResourceEntry {
                            kind: ProjectResourceKind::Memory,
                            present: true,
                            item_count: 1,
                        },
                        ProjectResourceEntry {
                            kind: ProjectResourceKind::Skills,
                            present: true,
                            item_count: 2,
                        },
                        ProjectResourceEntry {
                            kind: ProjectResourceKind::Commands,
                            present: true,
                            item_count: 1,
                        },
                    ],
                },
                memory_read_roots: crate::project_context::ProjectMemoryReadRoots {
                    primary: PathBuf::from("/data/projects/01JPROJECT00000000000000000/memory/v1"),
                    legacy_aliases: Vec::new(),
                },
            },
            workspace: Some(PathBuf::from(workspace)),
            binding_status: WorkspaceBindingStatus::Registered,
        }
    }

    #[test]
    fn scoped_prompt_contains_exactly_one_project_and_workspace_block() {
        let context = project_context("/workspace/main");
        let prompt = assemble_system_prompt_with_project(
            "base",
            None,
            Some(&context),
            Some("/workspace/main"),
        );
        assert_eq!(prompt.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
        assert_eq!(prompt.matches(PROJECT_CONTEXT_END_MARKER).count(), 1);
        assert_eq!(prompt.matches(WORKSPACE_CONTEXT_START_MARKER).count(), 1);
        assert_eq!(prompt.matches(WORKSPACE_CONTEXT_END_MARKER).count(), 1);
        assert!(prompt.contains("Binding status: registered"));
    }

    #[test]
    fn workspace_upsert_preserves_project_block_byte_for_byte() {
        let context = project_context("/workspace/main");
        let prompt = assemble_system_prompt_with_project(
            "base",
            None,
            Some(&context),
            Some("/workspace/main"),
        );
        let project_block = build_project_prompt_context(&context);
        let updated = upsert_workspace_prompt_context(
            &prompt,
            Some("/workspace/worktree"),
            WorkspaceBindingStatus::Registered,
        );
        assert_eq!(updated.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
        assert!(updated.contains(&project_block));
        assert!(!updated.contains("Workspace path: /workspace/main"));
        assert!(updated.contains("Workspace path: /workspace/worktree"));
    }

    #[test]
    fn upsert_deduplicates_only_its_own_marker() {
        let context = project_context("/workspace/main");
        let project = build_project_prompt_context(&context);
        let workspace = build_workspace_prompt_context_with_binding(
            "/workspace/main",
            WorkspaceBindingStatus::Registered,
        )
        .expect("workspace");
        let duplicated = format!("base\n\n{project}\n\n{workspace}\n\n{project}\n\n{workspace}");
        let project_upserted = upsert_project_prompt_context(&duplicated, Some(&context));
        assert_eq!(
            project_upserted
                .matches(PROJECT_CONTEXT_START_MARKER)
                .count(),
            1
        );
        assert_eq!(
            project_upserted
                .matches(WORKSPACE_CONTEXT_START_MARKER)
                .count(),
            2
        );
        let fully_upserted = upsert_workspace_prompt_context(
            &project_upserted,
            Some("/workspace/main"),
            WorkspaceBindingStatus::Registered,
        );
        assert_eq!(
            fully_upserted.matches(PROJECT_CONTEXT_START_MARKER).count(),
            1
        );
        assert_eq!(
            fully_upserted
                .matches(WORKSPACE_CONTEXT_START_MARKER)
                .count(),
            1
        );
    }

    #[test]
    fn project_values_cannot_inject_prompt_markers() {
        let mut context = project_context("/workspace/main");
        context.project.name = "unsafe\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->".to_string();
        let prompt = build_project_prompt_context(&context);
        assert!(!prompt.contains("\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->"));
    }

    #[test]
    fn resource_revision_changes_only_dynamic_inventory() {
        let first = project_context("/workspace/main");
        let mut second = first.clone();
        second.project.resources.resource_revision += 1;
        second.project.resources.resources[1].item_count += 3;

        assert_eq!(
            build_project_prompt_context(&first),
            build_project_prompt_context(&second),
            "cacheable Project identity must not contain inventory or revision"
        );
        assert_ne!(
            first.render_resource_inventory(),
            second.render_resource_inventory(),
            "per-round inventory must reflect the new resource revision"
        );
    }
}