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 = r#"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).
14
15You help users solve problems quickly and correctly. Be concise, practical, and proactive.
16For ordinary executing Sessions, delegate to sub-agents sparingly, and only when parallelism or isolation earns its cost — the task-execution ladder in the operating directives below says when. A trusted Supervisor instead delegates execution under its runtime-supplied role policy, while retaining necessary dedicated read-only acquisition and orchestration. When you delegate:
17- Use SubAgent's compact contract: `intent`, `target`, `role`, `message`, and `reply_to`. Omit unused fields.
18- Give each child ONE narrow responsibility in a detailed, self-contained `message`, including the workspace paths and files it needs. The child does not automatically receive this conversation; any forked context is background only.
19- Omit `target` to create a child; include its returned logical ActorId to correct or continue that child. Select `role` only on creation. The runtime manages activation and waiting.
20- Use `intent=inspect` to check progress or retrieve evidence. Before trusting a child's result, verify it actually accessed the files and resources it needed, and correct the same child with a complete `message` when context or evidence is missing.
21
22If 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.
23
24You 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.
25
26When 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."#;
27
28/// Framework-invariant agent operating directives, applied on top of whatever
29/// base prompt is in effect.
30///
31/// Unlike [`DEFAULT_BASE_PROMPT`] — which a user can fully replace via
32/// `${BAMBOO_DATA_DIR}/system-prompt.md` — these directives are appended during
33/// per-round prompt assembly (see `append_core_agent_directives`), so they
34/// always apply even when the base prompt is overridden. Keep the text static:
35/// it rides in the cacheable system field, so churn here forces a cache re-warm.
36pub 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.
37
38A trusted Supervisor follows its runtime-supplied delegation-first policy in both Standard and Ultra: delegate implementation even for a simple task, retain only necessary dedicated read-only acquisition and orchestration, and report a recoverable blocker if no authorized worker is available. The following decision ladder applies to ordinary executing Sessions; it does not authorize Supervisor self-execution. First matching case wins, and don't over-plan:
391. 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.
402. One tool call (or one short read → grep → read sequence) gets it → do it directly. Don't open a Task list, don't delegate.
413. Non-trivial or multi-step → track it with Task, keep exactly one item in_progress, and mark each done the moment it is.
424. Multiple independent, read-only branches → delegate narrow tasks with workspace paths in message. Runtime manages waiting; verify results. Same-module concurrent writers ≤ 2.
435. Branches are dependent, or two of them write the same files → serialize; never fan out writes.
44Judgment 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.
45
46When you delegate, give every child one self-contained assignment in this exact six-part order:
471. Scope.
482. Inputs and background context.
493. Allowed actions and mutation scope.
504. Acceptance criteria and required evidence.
515. Non-goals.
526. Stop and report instruction.
53State 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.
54
55Verify 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.
56
57Scratch 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."#;
58
59pub const WORKSPACE_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_START -->";
60pub const WORKSPACE_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_WORKSPACE_CONTEXT_END -->";
61pub const WORKSPACE_CONTEXT_PREFIX: &str = "Workspace path: ";
62pub const PROJECT_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_START -->";
63pub const PROJECT_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_PROJECT_CONTEXT_END -->";
64pub const PROJECT_CONTEXT_PREFIX: &str = "Project ID: ";
65pub const ENV_CONTEXT_START_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_START -->";
66pub const ENV_CONTEXT_END_MARKER: &str = "<!-- BAMBOO_ENV_CONTEXT_END -->";
67
68/// Guidance for workspace-based interactions
69pub fn workspace_prompt_guidance() -> String {
70    let config_path = paths::path_to_display_string(&paths::config_json_path());
71    format!(
72        "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).",
73        paths::bamboo_dir_display(),
74        config_path
75    )
76}
77
78fn build_env_prompt_guidance() -> Option<String> {
79    let env_vars = Config::current_prompt_safe_env_vars();
80    if env_vars.is_empty() {
81        return None;
82    }
83
84    let mut lines = vec![
85        "These environment variables were explicitly configured by the user inside Bodhi."
86            .to_string(),
87        "- They are already available to Bash/tool processes launched by Bodhi and may be relevant to tools and skills."
88            .to_string(),
89        "- Treat them as user-approved runtime context instead of asking the user to repeat them immediately."
90            .to_string(),
91        "- Secret values are intentionally hidden from the model.".to_string(),
92        "- If the listed variables appear sufficient, prefer a minimal verification or execution attempt before asking follow-up questions."
93            .to_string(),
94        "- 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."
95            .to_string(),
96    ];
97
98    for entry in env_vars {
99        let visibility = if entry.secret { "secret" } else { "non-secret" };
100        let mut line = format!("- {} ({})", entry.name, visibility);
101        if let Some(description) = entry.description {
102            line.push_str(" — ");
103            line.push_str(&description);
104        }
105        lines.push(line);
106    }
107
108    Some(lines.join("\n"))
109}
110
111pub fn build_env_prompt_context() -> Option<String> {
112    let body = build_env_prompt_guidance()?;
113    Some(format!(
114        "{ENV_CONTEXT_START_MARKER}\n{body}\n{ENV_CONTEXT_END_MARKER}"
115    ))
116}
117
118pub fn build_workspace_prompt_context(workspace_path: &str) -> Option<String> {
119    build_workspace_prompt_context_with_binding(
120        workspace_path,
121        WorkspaceBindingStatus::Unregistered,
122    )
123}
124
125pub fn build_workspace_prompt_context_with_binding(
126    workspace_path: &str,
127    binding_status: WorkspaceBindingStatus,
128) -> Option<String> {
129    build_workspace_prompt_context_with_binding_and_source(workspace_path, binding_status, None)
130}
131
132pub fn build_workspace_prompt_context_with_binding_and_source(
133    workspace_path: &str,
134    binding_status: WorkspaceBindingStatus,
135    source: Option<WorkspaceSource>,
136) -> Option<String> {
137    let workspace_path = workspace_path.trim();
138    if workspace_path.is_empty() {
139        return None;
140    }
141
142    let body = format!(
143        "{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{}",
144        prompt_safe_scalar(workspace_path),
145        source.unwrap_or(WorkspaceSource::Session).as_str(),
146        binding_status.as_str(),
147        workspace_prompt_guidance()
148    );
149
150    Some(format!(
151        "{WORKSPACE_CONTEXT_START_MARKER}\n{body}\n{WORKSPACE_CONTEXT_END_MARKER}"
152    ))
153}
154
155/// Locate a complete unwrapped Workspace block emitted by legacy Bamboo builds.
156///
157/// Some persisted sessions predate the marker wrapper, but an ordinary custom
158/// System prompt is also allowed to discuss a `Workspace path:`. Treat the
159/// prefix as host authority only when it appears at the start of a line and is
160/// followed by the exact generated guidance, with only known generated
161/// metadata lines in between. Otherwise migration must leave the text alone.
162pub(crate) fn legacy_unwrapped_workspace_context_bounds(prompt: &str) -> Option<(usize, usize)> {
163    let guidance = workspace_prompt_guidance();
164
165    for (start_idx, _) in prompt.match_indices(WORKSPACE_CONTEXT_PREFIX) {
166        if start_idx > 0 && prompt.as_bytes()[start_idx - 1] != b'\n' {
167            continue;
168        }
169
170        let path_start = start_idx + WORKSPACE_CONTEXT_PREFIX.len();
171        let Some(path_end_rel) = prompt[path_start..].find('\n') else {
172            continue;
173        };
174        let path_end = path_start + path_end_rel;
175        if prompt[path_start..path_end].trim().is_empty() {
176            continue;
177        }
178
179        let metadata_start = path_end + 1;
180        let Some(guidance_rel) = prompt[metadata_start..].find(&guidance) else {
181            continue;
182        };
183        let guidance_start = metadata_start + guidance_rel;
184        if guidance_start > 0 && prompt.as_bytes()[guidance_start - 1] != b'\n' {
185            continue;
186        }
187
188        let metadata_is_generated = prompt[metadata_start..guidance_start]
189            .lines()
190            .map(str::trim)
191            .filter(|line| !line.is_empty())
192            .all(|line| {
193                matches!(
194                    line,
195                    "Workspace source: explicit"
196                        | "Workspace source: project_default"
197                        | "Workspace source: session"
198                        | "Binding status: registered"
199                        | "Binding status: unregistered"
200                        | "Workspace-local resources may override Project-shared resources."
201                        | "Changing the workspace changes only the filesystem execution context; it does not change Project membership or Project memory."
202                )
203            });
204        if !metadata_is_generated {
205            continue;
206        }
207
208        return Some((start_idx, guidance_start + guidance.len()));
209    }
210
211    None
212}
213
214/// Build the stable Project identity block.
215///
216/// Resource counts and revisions are intentionally excluded: they belong to
217/// the per-round dynamic resource envelope. Secret settings, credential
218/// values, MCP headers, and environment values do not belong in
219/// [`ResolvedProjectContext`] and therefore cannot leak through this builder.
220pub fn build_project_prompt_context(context: &ResolvedProjectContext) -> String {
221    let project = &context.project;
222    let project_path = project
223        .project_path
224        .as_deref()
225        .map(paths::path_to_display_string)
226        .unwrap_or_else(|| "not configured".to_string());
227    let body = format!(
228        "{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.",
229        prompt_safe_scalar(project.id.as_str()),
230        prompt_safe_scalar(&project.name),
231        prompt_safe_scalar(&project_path),
232        prompt_safe_scalar(&paths::path_to_display_string(&project.home)),
233    );
234    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
235}
236
237/// Build provider-visible Project identity without filesystem locations.
238///
239/// The active workspace path is rendered exactly once by the sibling Workspace
240/// context. Project roots and Bamboo-owned data paths are host details and must
241/// not duplicate that path or leak into provider-visible diagnostics.
242pub fn build_project_model_context(context: &ResolvedProjectContext) -> String {
243    let project = &context.project;
244    let body = format!(
245        "{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.",
246        prompt_safe_scalar(project.id.as_str()),
247        prompt_safe_scalar(&project.name),
248    );
249    format!("{PROJECT_CONTEXT_START_MARKER}\n{body}\n{PROJECT_CONTEXT_END_MARKER}")
250}
251
252/// Replace every existing Project block with exactly one current block.
253///
254/// Workspace blocks are deliberately outside the removed marker range.
255pub fn upsert_project_prompt_context(
256    prompt: &str,
257    context: Option<&ResolvedProjectContext>,
258) -> String {
259    replace_prompt_block(
260        prompt,
261        PROJECT_CONTEXT_START_MARKER,
262        PROJECT_CONTEXT_END_MARKER,
263        context.map(build_project_prompt_context).as_deref(),
264    )
265}
266
267/// Replace every existing Workspace block with exactly one current block.
268///
269/// Project identity is left byte-for-byte unchanged.
270pub fn upsert_workspace_prompt_context(
271    prompt: &str,
272    workspace_path: Option<&str>,
273    binding_status: WorkspaceBindingStatus,
274) -> String {
275    upsert_workspace_prompt_context_with_source(prompt, workspace_path, binding_status, None)
276}
277
278pub fn upsert_workspace_prompt_context_with_source(
279    prompt: &str,
280    workspace_path: Option<&str>,
281    binding_status: WorkspaceBindingStatus,
282    source: Option<WorkspaceSource>,
283) -> String {
284    let block = workspace_path.and_then(|workspace| {
285        build_workspace_prompt_context_with_binding_and_source(workspace, binding_status, source)
286    });
287    replace_prompt_block(
288        prompt,
289        WORKSPACE_CONTEXT_START_MARKER,
290        WORKSPACE_CONTEXT_END_MARKER,
291        block.as_deref(),
292    )
293}
294
295fn prompt_safe_scalar(value: &str) -> String {
296    value
297        .chars()
298        .map(|character| {
299            if character.is_control() {
300                ' '
301            } else {
302                character
303            }
304        })
305        .collect::<String>()
306        .replace("<!--", "< !--")
307        .trim()
308        .to_string()
309}
310
311fn replace_prompt_block(
312    prompt: &str,
313    start_marker: &str,
314    end_marker: &str,
315    replacement: Option<&str>,
316) -> String {
317    let mut current = prompt.to_string();
318    while let Some(start) = current.find(start_marker) {
319        let content_start = start + start_marker.len();
320        let Some(relative_end) = current[content_start..].find(end_marker) else {
321            current.truncate(start);
322            break;
323        };
324        let end = content_start + relative_end + end_marker.len();
325        let before = current[..start].trim_end();
326        let after = current[end..].trim_start();
327        current = match (before.is_empty(), after.is_empty()) {
328            (true, true) => String::new(),
329            (true, false) => after.to_string(),
330            (false, true) => before.to_string(),
331            (false, false) => format!("{before}\n\n{after}"),
332        };
333    }
334
335    if let Some(replacement) = replacement.map(str::trim).filter(|value| !value.is_empty()) {
336        if !current.trim().is_empty() {
337            current = current.trim().to_string();
338            current.push_str("\n\n");
339        }
340        current.push_str(replacement);
341    }
342    current
343}
344
345/// Assemble a full system prompt from base prompt, optional enhancement, and context segments.
346///
347/// This is the shared prompt assembly logic used by both the HTTP handler and the schedule
348/// manager. System text contains only the caller-owned base plus optional
349/// enhancement. Project, Workspace, instruction, and environment context are
350/// assembled later as typed model-context blocks.
351pub fn assemble_system_prompt(
352    base: &str,
353    enhance: Option<&str>,
354    _workspace_path: Option<&str>,
355) -> String {
356    assemble_system_prompt_with_project(base, enhance, None, None)
357}
358
359pub fn assemble_system_prompt_with_project(
360    base: &str,
361    enhance: Option<&str>,
362    _project_context: Option<&ResolvedProjectContext>,
363    _workspace_path: Option<&str>,
364) -> String {
365    let mut prompt = base.trim().to_string();
366    if let Some(extra) = enhance.map(str::trim).filter(|v| !v.is_empty()) {
367        if !prompt.is_empty() {
368            prompt.push_str("\n\n");
369        }
370        prompt.push_str(extra);
371    }
372    prompt
373}
374
375#[cfg(test)]
376mod project_context_tests {
377    use std::path::PathBuf;
378
379    use crate::project_context::{
380        ProjectDescriptor, ResolvedProjectContext, WorkspaceBindingStatus,
381    };
382    use bamboo_domain::{
383        ProjectId, ProjectResourceEntry, ProjectResourceKind, ProjectResourceSummary,
384        WorkspaceBinding,
385    };
386
387    use super::*;
388
389    fn project_context(workspace: &str) -> ResolvedProjectContext {
390        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
391        ResolvedProjectContext {
392            project: ProjectDescriptor {
393                id: project_id.clone(),
394                name: "Zenith".to_string(),
395                project_path: Some(PathBuf::from(workspace)),
396                home: PathBuf::from("/data/projects/01JPROJECT00000000000000000"),
397                workspace_bindings: vec![WorkspaceBinding {
398                    path: workspace.to_string(),
399                    label: Some("main".to_string()),
400                    git_common_dir: None,
401                }],
402                resources: ProjectResourceSummary {
403                    project_id,
404                    resource_revision: 9,
405                    resources: vec![
406                        ProjectResourceEntry {
407                            kind: ProjectResourceKind::Memory,
408                            present: true,
409                            item_count: 1,
410                        },
411                        ProjectResourceEntry {
412                            kind: ProjectResourceKind::Skills,
413                            present: true,
414                            item_count: 2,
415                        },
416                        ProjectResourceEntry {
417                            kind: ProjectResourceKind::Commands,
418                            present: true,
419                            item_count: 1,
420                        },
421                    ],
422                },
423            },
424            workspace: Some(PathBuf::from(workspace)),
425            workspace_source: WorkspaceSource::Session,
426            binding_status: WorkspaceBindingStatus::Registered,
427        }
428    }
429
430    #[test]
431    fn system_assembly_ignores_legacy_dynamic_context_arguments() {
432        let context = project_context("/workspace/private");
433        let prompt = assemble_system_prompt_with_project(
434            "base",
435            None,
436            Some(&context),
437            Some("/workspace/private"),
438        );
439        assert_eq!(prompt, "base");
440        assert!(!prompt.contains("/workspace/private"));
441        assert!(!prompt.contains("/data/projects"));
442        assert!(!prompt.contains(PROJECT_CONTEXT_START_MARKER));
443        assert!(!prompt.contains(WORKSPACE_CONTEXT_START_MARKER));
444        assert!(!prompt.contains(WORKSPACE_CONTEXT_END_MARKER));
445        assert!(!prompt.contains(ENV_CONTEXT_START_MARKER));
446    }
447
448    #[test]
449    fn provider_project_context_contains_no_host_paths() {
450        let context = project_context("/workspace/main");
451        let prompt = build_project_model_context(&context);
452        assert!(prompt.contains("Project ID:"));
453        assert!(!prompt.contains("/workspace/main"));
454        assert!(!prompt.contains("/data/projects"));
455    }
456
457    #[test]
458    fn workspace_upsert_preserves_project_block_byte_for_byte() {
459        let context = project_context("/workspace/main");
460        let project_block = build_project_prompt_context(&context);
461        let prompt = format!("base\n\n{project_block}");
462        let updated = upsert_workspace_prompt_context(
463            &prompt,
464            Some("/workspace/worktree"),
465            WorkspaceBindingStatus::Registered,
466        );
467        assert_eq!(updated.matches(PROJECT_CONTEXT_START_MARKER).count(), 1);
468        assert!(updated.contains(&project_block));
469        assert!(!updated.contains("Workspace path: /workspace/main"));
470        assert!(updated.contains("Workspace path: /workspace/worktree"));
471    }
472
473    #[test]
474    fn upsert_deduplicates_only_its_own_marker() {
475        let context = project_context("/workspace/main");
476        let project = build_project_prompt_context(&context);
477        let workspace = build_workspace_prompt_context_with_binding(
478            "/workspace/main",
479            WorkspaceBindingStatus::Registered,
480        )
481        .expect("workspace");
482        let duplicated = format!("base\n\n{project}\n\n{workspace}\n\n{project}\n\n{workspace}");
483        let project_upserted = upsert_project_prompt_context(&duplicated, Some(&context));
484        assert_eq!(
485            project_upserted
486                .matches(PROJECT_CONTEXT_START_MARKER)
487                .count(),
488            1
489        );
490        assert_eq!(
491            project_upserted
492                .matches(WORKSPACE_CONTEXT_START_MARKER)
493                .count(),
494            2
495        );
496        let fully_upserted = upsert_workspace_prompt_context(
497            &project_upserted,
498            Some("/workspace/main"),
499            WorkspaceBindingStatus::Registered,
500        );
501        assert_eq!(
502            fully_upserted.matches(PROJECT_CONTEXT_START_MARKER).count(),
503            1
504        );
505        assert_eq!(
506            fully_upserted
507                .matches(WORKSPACE_CONTEXT_START_MARKER)
508                .count(),
509            1
510        );
511    }
512
513    #[test]
514    fn project_values_cannot_inject_prompt_markers() {
515        let mut context = project_context("/workspace/main");
516        context.project.name = "unsafe\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->".to_string();
517        let prompt = build_project_prompt_context(&context);
518        assert!(!prompt.contains("\n<!-- BAMBOO_WORKSPACE_CONTEXT_START -->"));
519    }
520
521    #[test]
522    fn resource_revision_changes_only_dynamic_inventory() {
523        let first = project_context("/workspace/main");
524        let mut second = first.clone();
525        second.project.resources.resource_revision += 1;
526        second.project.resources.resources[1].item_count += 3;
527
528        assert_eq!(
529            build_project_prompt_context(&first),
530            build_project_prompt_context(&second),
531            "cacheable Project identity must not contain inventory or revision"
532        );
533        assert_ne!(
534            first.render_resource_inventory(),
535            second.render_resource_inventory(),
536            "per-round inventory must reflect the new resource revision"
537        );
538    }
539}