//! Central catalog of Pod-level prompt strings. //! //! Prompts that Pod injects into a Worker (compaction system prompt, //! notification wrapper, interrupt notes, system-prompt trailing //! sections, AGENTS.md truncation notice, ...) are enumerated by //! [`PodPrompt`] and rendered through a single [`PromptCatalog`]. Direct //! `const &str` / `format!` authoring of these strings elsewhere in //! `crates/pod` is deliberately avoided — new injection points add a //! variant here, which forces a matching entry in //! `resources/prompts/internal.toml` (checked at build time) and keeps //! the "Pod tone" editable in one place. //! //! # Layering //! //! Values are merged key-wise from low priority to high: //! //! 1. **builtin** — `resources/prompts/internal.toml`, baked into the //! binary. Must cover every [`PodPrompt`] variant (build-time check). //! 2. **user** — `/prompts.toml`, when a caller supplies it. //! Optional. //! 3. **workspace** — `/.yoi/prompts.toml`, when a caller //! supplies it. Optional. //! 4. **manifest pack** — `manifest.pod.prompt_pack`, an explicit path //! per-Pod. Optional. //! //! Unknown keys in layers 2–4 are logged via `tracing::warn!` and //! ignored (forward compatibility). Layer 1 is enforced at build time. //! //! # Template language //! //! All values are minijinja templates. `{% include "$prefix/..." %}` //! resolves through the same [`PromptLoader`] used by the system-prompt //! template, so long prompt bodies can be factored into `.md` files //! under `resources/prompts/...`, the user prompts library, or the //! workspace prompts library. use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use minijinja::value::Value; use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use serde::Deserialize; use thiserror::Error; use tracing::warn; use crate::prompt::loader::PromptLoader; // Generated by build.rs from `resources/prompts/internal.toml`. include!(concat!(env!("OUT_DIR"), "/internal_keys.rs")); /// Source of the builtin pack. Baked in at compile time. const INTERNAL_TOML: &str = include_str!("../../../../resources/prompts/internal.toml"); /// Pod-level prompt injection point. /// /// Adding a new variant also requires adding a matching key to /// `resources/prompts/internal.toml`; the build fails otherwise. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PodPrompt { /// System prompt of the compaction (summary) Worker. CompactSystem, /// System prompt of the memory extract Worker. MemoryExtractSystem, /// System prompt of the memory consolidation (integration + tidy) Worker. MemoryConsolidationSystem, /// Wrapper around an incoming `Method::Notify` message injected into /// the next LLM request context as a transient system message. NotifyWrapper, /// Synthetic `Item::ToolResult` summary used to close out orphaned /// tool calls when a paused turn is interrupted by the user. InterruptToolResultSummary, /// System note prepended to the new turn after an interrupt. InterruptSystemNote, /// Trailing `## Working boundaries` section appended to every /// materialised system prompt. WorkingBoundariesSection, /// Trailing `## Project instructions (AGENTS.md)` section, appended /// after the scope summary when an AGENTS.md is present. AgentsMdSection, /// Trailing `## Resident memory summary` section, appended after the /// AGENTS.md section when memory is enabled, summary injection is enabled, /// and `memory/summary.md` has a valid non-empty body. ResidentMemorySummarySection, /// Trailing `## Resident knowledge` section, appended after the /// resident memory summary when memory is enabled, Knowledge resident /// injection is enabled, and at least one `knowledge/*` record advertises /// `model_invokation: true`. ResidentKnowledgeSection, /// Trailing `## Resident workflows` section, appended after resident /// knowledge when Workflow resident injection is enabled and at least one /// workflow advertises `model_invokation: true`. ResidentWorkflowsSection, /// Trailing Pod orchestration guidance, appended when registered tools /// include Pod-management capabilities. PodOrchestrationGuidanceSection, /// LLM-facing description for the SpawnPod tool, including discovered /// profile selectors. SpawnPodToolDescription, } impl PodPrompt { pub fn key(self) -> &'static str { match self { Self::CompactSystem => "compact_system", Self::MemoryExtractSystem => "memory_extract_system", Self::MemoryConsolidationSystem => "memory_consolidation_system", Self::NotifyWrapper => "notify_wrapper", Self::InterruptToolResultSummary => "interrupt_tool_result_summary", Self::InterruptSystemNote => "interrupt_system_note", Self::WorkingBoundariesSection => "working_boundaries_section", Self::AgentsMdSection => "agents_md_section", Self::ResidentMemorySummarySection => "resident_memory_summary_section", Self::ResidentKnowledgeSection => "resident_knowledge_section", Self::ResidentWorkflowsSection => "resident_workflows_section", Self::PodOrchestrationGuidanceSection => "pod_orchestration_guidance_section", Self::SpawnPodToolDescription => "spawn_pod_tool_description", } } /// All variants in declaration order. The associated `KEYS` slice /// mirrors this for const-eval coverage checks against /// `INTERNAL_KEYS` (generated by `build.rs`). pub const ALL: &'static [PodPrompt] = &[ PodPrompt::CompactSystem, PodPrompt::MemoryExtractSystem, PodPrompt::MemoryConsolidationSystem, PodPrompt::NotifyWrapper, PodPrompt::InterruptToolResultSummary, PodPrompt::InterruptSystemNote, PodPrompt::WorkingBoundariesSection, PodPrompt::AgentsMdSection, PodPrompt::ResidentMemorySummarySection, PodPrompt::ResidentKnowledgeSection, PodPrompt::ResidentWorkflowsSection, PodPrompt::PodOrchestrationGuidanceSection, PodPrompt::SpawnPodToolDescription, ]; pub const KEYS: &'static [&'static str] = &[ "compact_system", "memory_extract_system", "memory_consolidation_system", "notify_wrapper", "interrupt_tool_result_summary", "interrupt_system_note", "working_boundaries_section", "agents_md_section", "resident_memory_summary_section", "resident_knowledge_section", "resident_workflows_section", "pod_orchestration_guidance_section", "spawn_pod_tool_description", ]; } // --- build-time bidirectional coverage check -------------------------------- const _: () = { // Every enum key must appear in the builtin TOML. let mut i = 0; while i < PodPrompt::KEYS.len() { if !const_slice_contains(INTERNAL_KEYS, PodPrompt::KEYS[i]) { panic!( "resources/prompts/internal.toml is missing a key declared by \ PodPrompt — regenerate the TOML or remove the variant" ); } i += 1; } // Every TOML key must correspond to an enum variant. let mut i = 0; while i < INTERNAL_KEYS.len() { if !const_slice_contains(PodPrompt::KEYS, INTERNAL_KEYS[i]) { panic!( "resources/prompts/internal.toml has a key not declared by \ PodPrompt — add the variant or drop the key" ); } i += 1; } }; const fn const_str_eq(a: &str, b: &str) -> bool { let a = a.as_bytes(); let b = b.as_bytes(); if a.len() != b.len() { return false; } let mut i = 0; while i < a.len() { if a[i] != b[i] { return false; } i += 1; } true } const fn const_slice_contains(haystack: &[&str], needle: &str) -> bool { let mut i = 0; while i < haystack.len() { if const_str_eq(haystack[i], needle) { return true; } i += 1; } false } // --- errors ---------------------------------------------------------------- #[derive(Debug, Error)] pub enum CatalogError { #[error("failed to read prompt pack {}: {source}", .path.display())] Io { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse prompt pack {}: {source}", .path.display())] ParseToml { path: PathBuf, #[source] source: toml::de::Error, }, #[error("failed to parse builtin prompt pack: {0}")] ParseBuiltin(#[source] toml::de::Error), #[error("failed to compile prompt template '{key}': {source}")] TemplateCompile { key: String, #[source] source: minijinja::Error, }, #[error("failed to render prompt '{key}': {source}")] Render { key: String, #[source] source: minijinja::Error, }, #[error("prompt key '{key}' is not registered in the catalog")] UnknownKey { key: String }, } // --- pack file shape ------------------------------------------------------- #[derive(Debug, Deserialize)] struct PackFile { #[serde(default)] prompt: HashMap, } // --- catalog --------------------------------------------------------------- /// Merged, compiled pod-prompt catalog. /// /// Owns a `minijinja::Environment` with one template registered per /// [`PodPrompt`] key (after the 4-layer merge). Includes inside templates /// are resolved via a provided [`PromptLoader`], so values can pull from /// `$yoi` / `$user` / `$workspace`. pub struct PromptCatalog { env: Environment<'static>, } impl std::fmt::Debug for PromptCatalog { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PromptCatalog").finish_non_exhaustive() } } impl PromptCatalog { /// Builtin-only catalog. All `{% include %}` references must resolve /// through `$yoi` (user/workspace prefixes are unavailable). pub fn builtins_only() -> Result, CatalogError> { Self::load(&PromptLoader::builtins_only(), None) } /// Load the catalog honouring the 4-layer overlay. /// /// - Layer 1 (builtin): `INTERNAL_TOML` baked into the binary. /// - Layer 2 (user): `loader.user_pack_file()` if present. /// - Layer 3 (workspace): `loader.workspace_pack_file()` if present. /// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem /// path (pre-resolved by profile/manifest resolution). pub fn load( loader: &PromptLoader, manifest_pack: Option<&Path>, ) -> Result, CatalogError> { let mut merged = parse_builtin_pack()?; if let Some(path) = loader.user_pack_file() { if path.is_file() { let pack = parse_pack_file(path)?; merge_into(&mut merged, pack, "user"); } } if let Some(path) = loader.workspace_pack_file() { if path.is_file() { let pack = parse_pack_file(path)?; merge_into(&mut merged, pack, "workspace"); } } if let Some(path) = manifest_pack { let pack = parse_pack_file(path)?; merge_into(&mut merged, pack, "manifest"); } build_catalog(merged, loader.clone()).map(Arc::new) } /// Render a prompt by variant. `ctx` provides template variables; use /// [`Value::UNDEFINED`] (or a helper below) when the template takes /// no inputs. pub fn render(&self, prompt: PodPrompt, ctx: Value) -> Result { let key = prompt.key(); let tmpl = self .env .get_template(key) .map_err(|_| CatalogError::UnknownKey { key: key.to_string(), })?; tmpl.render(ctx).map_err(|source| CatalogError::Render { key: key.to_string(), source, }) } /// Render `PodPrompt::CompactSystem` (no inputs). pub fn compact_system(&self) -> Result { self.render(PodPrompt::CompactSystem, Value::UNDEFINED) } /// Render `PodPrompt::MemoryExtractSystem` with `{{ language }}`. pub fn memory_extract_system(&self, language: &str) -> Result { self.render(PodPrompt::MemoryExtractSystem, single("language", language)) } /// Render `PodPrompt::MemoryConsolidationSystem` with `{{ language }}`. pub fn memory_consolidation_system(&self, language: &str) -> Result { self.render( PodPrompt::MemoryConsolidationSystem, single("language", language), ) } /// Render `PodPrompt::NotifyWrapper` with `{{ message }}`. pub fn notify_wrapper(&self, message: &str) -> Result { self.render(PodPrompt::NotifyWrapper, single("message", message)) } /// Render `PodPrompt::InterruptToolResultSummary` (no inputs). pub fn interrupt_tool_result_summary(&self) -> Result { self.render(PodPrompt::InterruptToolResultSummary, Value::UNDEFINED) } /// Render `PodPrompt::InterruptSystemNote` (no inputs). pub fn interrupt_system_note(&self) -> Result { self.render(PodPrompt::InterruptSystemNote, Value::UNDEFINED) } /// Render `PodPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`. pub fn working_boundaries_section(&self, scope_summary: &str) -> Result { self.render( PodPrompt::WorkingBoundariesSection, single("scope_summary", scope_summary), ) } /// Render `PodPrompt::AgentsMdSection` with `{{ agents_md }}`. pub fn agents_md_section(&self, agents_md: &str) -> Result { self.render(PodPrompt::AgentsMdSection, single("agents_md", agents_md)) } /// Render `PodPrompt::ResidentMemorySummarySection` with `{{ summary }}`. pub fn resident_memory_summary_section(&self, summary: &str) -> Result { self.render( PodPrompt::ResidentMemorySummarySection, single("summary", summary), ) } /// Render `PodPrompt::ResidentKnowledgeSection` with `{{ entries }}` /// (a pre-formatted list block authored by the caller). pub fn resident_knowledge_section( &self, entries: &str, knowledge_query_available: bool, memory_read_available: bool, ) -> Result { use std::collections::BTreeMap; let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); m.insert("entries", Value::from(entries)); m.insert( "knowledge_query_available", Value::from(knowledge_query_available), ); m.insert("memory_read_available", Value::from(memory_read_available)); self.render(PodPrompt::ResidentKnowledgeSection, Value::from(m)) } /// Render `PodPrompt::ResidentWorkflowsSection` with `{{ entries }}` /// (a pre-formatted list block authored by the caller). pub fn resident_workflows_section(&self, entries: &str) -> Result { self.render( PodPrompt::ResidentWorkflowsSection, single("entries", entries), ) } /// Render `PodPrompt::PodOrchestrationGuidanceSection` (no inputs). pub fn pod_orchestration_guidance_section(&self) -> Result { self.render(PodPrompt::PodOrchestrationGuidanceSection, Value::UNDEFINED) } /// Render `PodPrompt::SpawnPodToolDescription`. pub fn spawn_pod_tool_description( &self, available_profiles: &str, default_profile: &str, profile_diagnostic: &str, ) -> Result { use std::collections::BTreeMap; let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); m.insert("available_profiles", Value::from(available_profiles)); m.insert("default_profile", Value::from(default_profile)); m.insert("profile_diagnostic", Value::from(profile_diagnostic)); self.render(PodPrompt::SpawnPodToolDescription, Value::from(m)) } } fn single(key: &'static str, value: &str) -> Value { use std::collections::BTreeMap; let mut m: BTreeMap<&'static str, Value> = BTreeMap::new(); m.insert(key, Value::from(value)); Value::from(m) } fn parse_builtin_pack() -> Result, CatalogError> { let parsed: PackFile = toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?; Ok(parsed.prompt) } fn parse_pack_file(path: &Path) -> Result, CatalogError> { let src = fs::read_to_string(path).map_err(|source| CatalogError::Io { path: path.to_path_buf(), source, })?; let parsed: PackFile = toml::from_str(&src).map_err(|source| CatalogError::ParseToml { path: path.to_path_buf(), source, })?; Ok(parsed.prompt) } fn merge_into( base: &mut HashMap, upper: HashMap, origin: &'static str, ) { for (k, v) in upper { if !PodPrompt::KEYS.iter().any(|declared| *declared == k) { warn!( origin = origin, key = %k, "unknown prompt pack key; ignoring" ); continue; } base.insert(k, v); } } fn build_catalog( templates: HashMap, loader: PromptLoader, ) -> Result { let mut env = Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); // Reuse the system-prompt-template resolver so `{% include // "$prefix/..." %}` inside a catalog value pulls from the same asset // namespaces. let loader_for_join = loader.clone(); env.set_path_join_callback(move |name, parent| { let parent_ref = loader_for_join.parse_ref(parent, None).ok(); match loader_for_join.parse_ref(name, parent_ref.as_ref()) { Ok(r) => r.to_qualified_string().into(), Err(_) => name.to_string().into(), } }); let loader_for_src = loader.clone(); env.set_loader(move |name| { let reference = loader_for_src .parse_ref(name, None) .map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?; match loader_for_src.load(&reference) { Ok(src) => Ok(Some(src)), Err(e) => Err(minijinja::Error::new( ErrorKind::TemplateNotFound, e.to_string(), )), } }); for (k, v) in templates { env.add_template_owned(k.clone(), v) .map_err(|source| CatalogError::TemplateCompile { key: k.clone(), source, })?; } Ok(PromptCatalog { env }) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn loader_with_packs( user_dir: Option, workspace_dir: Option, user_pack: Option, workspace_pack: Option, ) -> PromptLoader { PromptLoader::new(user_dir, workspace_dir).with_pack_files(user_pack, workspace_pack) } #[test] fn builtin_covers_every_variant() { let cat = PromptCatalog::builtins_only().unwrap(); for p in PodPrompt::ALL { assert!( cat.env.get_template(p.key()).is_ok(), "builtin missing key: {}", p.key() ); } } #[test] fn builtin_render_compact_system_includes_worker_instructions() { let cat = PromptCatalog::builtins_only().unwrap(); let rendered = cat.compact_system().unwrap(); assert!(rendered.contains("write_summary")); assert!(rendered.contains("mark_read_required")); } #[test] fn internal_worker_prompts_do_not_include_default_memory_guidance() { let cat = PromptCatalog::builtins_only().unwrap(); let compact = cat.compact_system().unwrap(); let extract = cat.memory_extract_system("Japanese").unwrap(); let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); for rendered in [compact, extract, consolidate] { assert!(!rendered.contains("### Memory and knowledge")); assert!(!rendered.contains("Do not query memory every turn")); assert!(!rendered.contains("Strong lookup triggers include")); } } #[test] fn memory_worker_prompts_include_language() { let cat = PromptCatalog::builtins_only().unwrap(); let extract = cat.memory_extract_system("Japanese").unwrap(); let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); assert!(extract.contains("`language`: `Japanese`")); assert!(consolidate.contains("`language`: `Japanese`")); } #[test] fn notify_wrapper_interpolates_message() { let cat = PromptCatalog::builtins_only().unwrap(); let out = cat.notify_wrapper("file changed").unwrap(); assert!(out.contains("[Notification]")); assert!(out.contains("file changed")); assert!(out.contains("not a blocking request")); } #[test] fn working_boundaries_section_wraps_summary() { let cat = PromptCatalog::builtins_only().unwrap(); let out = cat.working_boundaries_section("Readable: /a").unwrap(); assert!(out.contains("## Working boundaries")); assert!(out.contains("Readable: /a")); } #[test] fn agents_md_section_contains_marker() { let cat = PromptCatalog::builtins_only().unwrap(); let out = cat.agents_md_section("PROJECT DOCS").unwrap(); assert!(out.contains("## Project instructions (AGENTS.md)")); assert!(out.contains("PROJECT DOCS")); } #[test] fn user_pack_overrides_builtin() { let tmp = TempDir::new().unwrap(); let pack = tmp.path().join("prompts.toml"); fs::write( &pack, r#" [prompt] interrupt_system_note = "[OVERRIDDEN]" "#, ) .unwrap(); let loader = loader_with_packs(None, None, Some(pack), None); let cat = PromptCatalog::load(&loader, None).unwrap(); assert_eq!(cat.interrupt_system_note().unwrap(), "[OVERRIDDEN]"); // Other keys still come from the builtin. assert!(cat.notify_wrapper("x").unwrap().contains("[Notification]")); } #[test] fn workspace_pack_wins_over_user_pack() { let tmp = TempDir::new().unwrap(); let user = tmp.path().join("user.toml"); let ws = tmp.path().join("ws.toml"); fs::write( &user, r#" [prompt] interrupt_system_note = "[USER]" "#, ) .unwrap(); fs::write( &ws, r#" [prompt] interrupt_system_note = "[WS]" "#, ) .unwrap(); let loader = loader_with_packs(None, None, Some(user), Some(ws)); let cat = PromptCatalog::load(&loader, None).unwrap(); assert_eq!(cat.interrupt_system_note().unwrap(), "[WS]"); } #[test] fn manifest_pack_wins_over_workspace_pack() { let tmp = TempDir::new().unwrap(); let ws = tmp.path().join("ws.toml"); let mf = tmp.path().join("mf.toml"); fs::write( &ws, r#" [prompt] interrupt_system_note = "[WS]" "#, ) .unwrap(); fs::write( &mf, r#" [prompt] interrupt_system_note = "[MF]" "#, ) .unwrap(); let loader = loader_with_packs(None, None, None, Some(ws)); let cat = PromptCatalog::load(&loader, Some(mf.as_path())).unwrap(); assert_eq!(cat.interrupt_system_note().unwrap(), "[MF]"); } #[test] fn unknown_key_in_runtime_pack_is_ignored_with_warning() { let tmp = TempDir::new().unwrap(); let pack = tmp.path().join("p.toml"); fs::write( &pack, r#" [prompt] interrupt_system_note = "[OK]" future_injection_point = "tolerated" "#, ) .unwrap(); let loader = loader_with_packs(None, None, Some(pack), None); // Loads without error; the unknown key is dropped silently at // runtime (log warning is emitted via tracing). let cat = PromptCatalog::load(&loader, None).unwrap(); assert_eq!(cat.interrupt_system_note().unwrap(), "[OK]"); } #[test] fn manifest_pack_reads_from_absolute_path() { let tmp = TempDir::new().unwrap(); let pack = tmp.path().join("mine.toml"); fs::write( &pack, r#" [prompt] interrupt_system_note = "[FROM-MANIFEST-PACK]" "#, ) .unwrap(); let loader = PromptLoader::builtins_only(); let cat = PromptCatalog::load(&loader, Some(pack.as_path())).unwrap(); assert_eq!(cat.interrupt_system_note().unwrap(), "[FROM-MANIFEST-PACK]"); } #[test] fn value_can_pull_long_text_via_include() { // A runtime pack that overrides `compact_system` with an // `{% include %}` into the same `$yoi` namespace — exercises // the template resolver path through all four layers. let tmp = TempDir::new().unwrap(); let pack = tmp.path().join("p.toml"); fs::write( &pack, r#" [prompt] compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}" "#, ) .unwrap(); let loader = loader_with_packs(None, None, Some(pack), None); let cat = PromptCatalog::load(&loader, None).unwrap(); let rendered = cat.compact_system().unwrap(); assert!(rendered.starts_with("PREFIX\n")); assert!(rendered.contains("write_summary")); } #[test] fn pod_orchestration_guidance_section_renders_resource_body() { let cat = PromptCatalog::builtins_only().unwrap(); let rendered = cat.pod_orchestration_guidance_section().unwrap(); assert!(rendered.contains("## Pod orchestration")); assert!(rendered.contains("spawned Pod notifications are background signals")); assert!(rendered.contains("does not need to keep a turn open")); assert!(rendered.contains("Do not use `sleep` or polling loops")); assert!(rendered.contains("worktree status, diff, and test results")); assert!(rendered.contains("not scheduler or auto-maintain authorization")); assert!(rendered.contains("bypass user/workflow authorization")); } #[test] fn spawn_pod_tool_description_renders_profile_block() { let cat = PromptCatalog::builtins_only().unwrap(); let rendered = cat .spawn_pod_tool_description( "- `project:coder` — Coder\n- `project:reviewer` — Reviewer", "project:coder", "", ) .unwrap(); assert!(rendered.contains("Profile selection")); assert!(rendered.contains("Default profile: project:coder")); assert!(rendered.contains("`project:reviewer`")); assert!(rendered.contains("Special selector: inherit")); } }