diff --git a/crates/client/src/ticket_role.rs b/crates/client/src/ticket_role.rs index c31bca70..21f80f19 100644 --- a/crates/client/src/ticket_role.rs +++ b/crates/client/src/ticket_role.rs @@ -1028,7 +1028,7 @@ profile = "builtin:companion" r#" [ticket.roles.reviewer] profile = "builtin:companion" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" "#, ); let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer); @@ -1043,11 +1043,11 @@ launch_prompt = "$workspace/ticket/reviewer/launch" assert_eq!(plan.profile, "builtin:companion"); assert_eq!( plan.launch_prompt_ref.as_deref(), - Some("$workspace/ticket/reviewer/launch") + Some("ticket.reviewer.launch") ); assert!(matches!(&plan.run_segments[0], Segment::Text { .. })); assert!(!text.contains("Configured launch_prompt")); - assert!(!text.contains("$workspace/ticket/reviewer/launch")); + assert!(!text.contains("ticket.reviewer.launch")); assert!(!text.contains("Profile selector: builtin:companion")); assert!(!text.contains("Role: reviewer")); assert!(!text.contains("system_instruction")); @@ -1228,7 +1228,7 @@ profile = "./coder.toml" r#" [ticket.roles.coder] profile = "inherit" -system_instruction = "$workspace/not-supported" +system_instruction = "unsupported" "#, ); let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder); diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index a1e14bb5..8be096c7 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -349,11 +349,6 @@ impl From for FeatureConfigPartial { pub struct WorkerMetaConfig { #[serde(default)] pub name: Option, - /// Optional `PromptCatalog` manifest pack override. See - /// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths - /// are resolved through [`WorkerManifestConfig::resolve_paths`]. - #[serde(default)] - pub prompt_pack: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -554,9 +549,6 @@ impl WorkerManifestConfig { base.display() ); resolve_auth_file(&mut self.model.auth, base); - if let Some(ref mut pack) = self.worker.prompt_pack { - *pack = join_if_relative(base, pack); - } for rule in &mut self.scope.allow { rule.target = join_if_relative(base, &rule.target); } @@ -718,7 +710,6 @@ impl WorkerMetaConfig { fn merge(self, upper: Self) -> Self { Self { name: upper.name.or(self.name), - prompt_pack: upper.prompt_pack.or(self.prompt_pack), } } } @@ -1018,10 +1009,6 @@ impl TryFrom for WorkerManifest { .worker .name .ok_or(ResolveError::MissingField("worker.name"))?; - let prompt_pack = cfg.worker.prompt_pack; - if let Some(ref p) = prompt_pack { - ensure_absolute("worker.prompt_pack", p)?; - } validate_model_paths(&cfg.model, "model.auth.file")?; @@ -1150,7 +1137,7 @@ impl TryFrom for WorkerManifest { validate_mcp_config(&cfg.mcp)?; Ok(WorkerManifest { - worker: WorkerMeta { name, prompt_pack }, + worker: WorkerMeta { name }, model: cfg.model, engine, scope: cfg.scope, @@ -1187,7 +1174,6 @@ mod tests { WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -1505,7 +1491,6 @@ mod tests { let lower = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("lower".into()), - prompt_pack: None, }, model: ModelManifest { model_id: Some("lower-model".into()), @@ -1516,7 +1501,6 @@ mod tests { let upper = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("upper".into()), - prompt_pack: None, }, ..Default::default() }; @@ -1925,7 +1909,6 @@ enabled = false .merge(WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("feature-test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -2008,7 +1991,6 @@ enabled = true .merge(WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("feature-merge-test".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -2075,7 +2057,6 @@ permission = "write" let overlay = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("x".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index eb703dbc..c725f1cd 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -42,10 +42,9 @@ pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000; /// See [`crate::CompactionConfig::overview_deadline_tokens`]. pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000; -/// Default instruction asset reference used when `worker.instruction` -/// is omitted. See the `PromptLoader` prefix addressing scheme for the -/// `$yoi/` / `$user/` / `$workspace/` namespaces. -pub const DEFAULT_INSTRUCTION: &str = "$yoi/default"; +/// Default exact catalog-root dotted Prompt name used when +/// `worker.instruction` is omitted. +pub const DEFAULT_INSTRUCTION: &str = "default"; /// Default language policy used by the main worker for normal prose /// responses. See [`crate::EngineManifest::language`]. diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 30304125..1f6d1f74 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -500,29 +500,13 @@ pub struct MemoryConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerMeta { pub name: String, - /// Optional path to a TOML override file read as the top layer of - /// `worker::PromptCatalog`. Subject to the same relative-path - /// resolution as other manifest paths (joined against the - /// manifest's base directory). `None` leaves the 4th overlay layer - /// empty; auto-discovered user and workspace packs still apply. - /// - /// Note: unlike `worker.instruction`, this is a plain filesystem - /// path — not a `$prefix/` prompt reference. Pack files carry - /// structured TOML data, while `worker.instruction` points at a - /// minijinja `.md` template; the two use different addressing - /// conventions on purpose. - #[serde(default)] - pub prompt_pack: Option, } /// Worker-level configuration embedded in the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EngineManifest { - /// Reference to the instruction prompt asset used as the body of - /// the worker's system prompt. Uses the `PromptLoader` prefix - /// addressing scheme (`$yoi/...`, `$user/...`, - /// `$workspace/...`) and is always populated after resolution — - /// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`]. + /// Exact catalog-root dotted Prompt name (for example `default` or + /// `role.coder`). #[serde(default = "default_instruction")] pub instruction: String, /// Language policy used by the main worker for normal prose responses. @@ -959,7 +943,7 @@ model_id = "claude-sonnet-4-20250514" auth = { kind = "api_key", file = "/abs/keys/anthropic" } [engine] -instruction = "$user/reviewer" +instruction = "role.reviewer" max_tokens = 4096 temperature = 0.3 top_p = 0.9 @@ -995,7 +979,7 @@ permission = "write" _ => panic!("expected ApiKey"), }; assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic"))); - assert_eq!(manifest.engine.instruction, "$user/reviewer"); + assert_eq!(manifest.engine.instruction, "role.reviewer"); assert_eq!(manifest.engine.max_tokens, Some(4096)); assert_eq!(manifest.engine.temperature, Some(0.3)); assert_eq!(manifest.engine.top_p, Some(0.9)); diff --git a/crates/manifest/src/paths.rs b/crates/manifest/src/paths.rs index a8b0cf0b..61982078 100644 --- a/crates/manifest/src/paths.rs +++ b/crates/manifest/src/paths.rs @@ -3,7 +3,7 @@ //! 用途別に三つの base directory を持つ: //! //! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`, -//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml` 等 +//! `providers.toml`, `models.toml` 等 //! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等 //! - **`secret_data_dir`** — local secret store の読み書き base。既存 //! secret store は path-derived key を使うため、通常 data とは別に @@ -85,16 +85,6 @@ pub fn user_profiles_path() -> Option { user_profiles_path_from_config_dir(config_dir()) } -/// `/prompts/` — user prompts ライブラリ。 -pub fn user_prompts_dir() -> Option { - user_prompts_dir_from_config_dir(config_dir()) -} - -/// `/prompts.toml` — user prompt pack。 -pub fn user_pack_file() -> Option { - user_pack_file_from_config_dir(config_dir()) -} - /// `/` — providers.toml / models.toml 等の /// user override ファイル。 pub fn user_catalog_override(file_name: &str) -> Option { @@ -200,14 +190,6 @@ fn user_profiles_path_from_config_dir(config_dir: Option) -> Option) -> Option { - Some(config_dir?.join("prompts")) -} - -fn user_pack_file_from_config_dir(config_dir: Option) -> Option { - Some(config_dir?.join("prompts.toml")) -} - fn user_catalog_override_from_config_dir( config_dir: Option, file_name: &str, @@ -465,14 +447,6 @@ mod tests { user_profiles_path_from_config_dir(config_dir.clone()).unwrap(), PathBuf::from("/sand/config/profiles.toml") ); - assert_eq!( - user_prompts_dir_from_config_dir(config_dir.clone()).unwrap(), - PathBuf::from("/sand/config/prompts") - ); - assert_eq!( - user_pack_file_from_config_dir(config_dir.clone()).unwrap(), - PathBuf::from("/sand/config/prompts.toml") - ); assert_eq!( user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(), PathBuf::from("/sand/config/providers.toml") diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 4d4d0f16..b830eb5c 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -547,7 +547,6 @@ fn resolve_profile_value( let config = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(worker_name), - prompt_pack: None, }, model: profile.model.unwrap_or_default(), engine: profile.engine.unwrap_or_default(), diff --git a/crates/ticket/src/config.rs b/crates/ticket/src/config.rs index 062cce59..3d16b9bb 100644 --- a/crates/ticket/src/config.rs +++ b/crates/ticket/src/config.rs @@ -1047,19 +1047,19 @@ worktree_name = "custom-orchestrator" [ticket.roles.intake] profile = "project:intake" -launch_prompt = "$workspace/ticket/intake/launch" +launch_prompt = "ticket.intake.launch" [ticket.roles.orchestrator] profile = "project:orchestrator" -launch_prompt = "$workspace/ticket/orchestrator/launch" +launch_prompt = "ticket.orchestrator.launch" [ticket.roles.coder] profile = "inherit" -launch_prompt = "$workspace/ticket/coder/launch" +launch_prompt = "ticket.coder.launch" [ticket.roles.reviewer] profile = "project:reviewer" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" "#, ); @@ -1095,7 +1095,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch" .launch_prompt_for(TicketRole::Reviewer) .unwrap() .as_str(), - "$workspace/ticket/reviewer/launch" + "ticket.reviewer.launch" ); } @@ -1338,7 +1338,7 @@ profile = "builtin:companion" r#" [roles.coder] profile = "inherit" -system_instruction = "$workspace/not-supported" +system_instruction = "unsupported" "#, ); diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index 12a440be..eeb3b5db 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -24,6 +24,8 @@ pub struct ConfigBundle { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub declarations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive_handle: Option, @@ -69,6 +71,16 @@ impl ConfigBundle { )); } + if let Some(prompt_catalog) = &self.prompt_catalog { + lines.push(format!( + "prompt_catalog\0{}\0{}\0{}\0{}", + prompt_catalog.config_revision, + prompt_catalog.schema_fingerprint, + prompt_catalog.toolchain_fingerprint, + prompt_catalog.catalog_digest + )); + } + if let Some(archive) = &self.profile_source_archive { lines.push(format!( "profile_archive\0{}\0{}\0{}", @@ -274,6 +286,12 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim validate_declaration_reference(&bundle.metadata.id, declaration)?; } + if let Some(prompt_catalog) = &bundle.prompt_catalog { + prompt_catalog.verify_digest().map_err(|error| { + RuntimeError::InvalidRequest(format!("invalid Prompt catalog projection: {error}")) + })?; + } + if let Some(archive) = &bundle.profile_source_archive { validate_profile_source_archive_ref(&archive.reference).map_err(|err| { RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) @@ -582,6 +600,7 @@ mod tests { name: "credential".to_string(), reference: reference.to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -615,6 +634,32 @@ mod tests { validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap(); } + #[test] + fn validates_immutable_prompt_catalog_projection() { + let mut bundle = bundle_with_declaration("secret:github-token"); + bundle.prompt_catalog = Some( + worker::EffectivePromptCatalog::new( + std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]), + 7, + "schema", + "toolchain", + ) + .unwrap(), + ); + bundle = bundle.with_computed_digest(); + validate_config_bundle(&bundle).unwrap(); + + bundle + .prompt_catalog + .as_mut() + .unwrap() + .templates + .insert("default".into(), "tampered".into()); + bundle = bundle.with_computed_digest(); + let error = validate_config_bundle(&bundle).unwrap_err(); + assert!(error.to_string().contains("catalog digest mismatch")); + } + #[test] fn bundle_summary_redacts_runtime_internal_resource_handle() { let mut bundle = bundle_with_declaration("secret:github-token"); diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 6e71276a..e86d83ef 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -1811,6 +1811,7 @@ mod tests { label: Some("test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } @@ -2648,6 +2649,7 @@ mod ws_tests { label: Some("ws".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index b8362347..6c056b87 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -743,10 +743,10 @@ mod tests { .load(Some("profiles/main.dcdl"), "./shared.dcdl") .unwrap(); match loaded { - LoadedImport::Source(source) => { - assert_eq!(source.key, "profiles/shared.dcdl"); + LoadedImport::Source { key, .. } => { + assert_eq!(key, "profiles/shared.dcdl"); } - LoadedImport::Value(_) => panic!("expected source import"), + LoadedImport::Value { .. } => panic!("expected source import"), } } diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 2b70adce..973fad6b 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -2832,6 +2832,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + prompt_catalog: None, profile_source_archive: None, profile_source_archive_handle: None, } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 45e359bd..a57661a3 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -53,7 +53,7 @@ use worker::feature::builtin::{ #[cfg(feature = "ws-server")] use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session}; use worker::{ - PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, + PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId, }; @@ -329,12 +329,12 @@ impl ProfileRuntimeWorkerFactory { fn restore_fallback_manifest( worker_name: &str, - ) -> Result<(manifest::WorkerManifest, PromptLoader), String> { + ) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> { let mut config = manifest::WorkerManifestConfig::builtin_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = manifest::WorkerManifest::try_from(config) .map_err(|err| format!("failed to build restore fallback manifest: {err}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } async fn resolve_profile_source_archive( &self, @@ -566,7 +566,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { let archive = self .resolve_profile_source_archive(&request.request.profile_source) .await?; - let (manifest, loader) = { + let (manifest, mut loader) = { let manifest = archive .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; @@ -584,6 +584,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { )? } }; + if let Some(prompt_catalog) = request + .config_bundle + .as_ref() + .and_then(|bundle| bundle.prompt_catalog.clone()) + { + loader = loader.with_effective_catalog(prompt_catalog); + } let flow_transition_enabled = manifest.feature.flow.enabled; let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; @@ -719,7 +726,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { self.worker_mutation_identity.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(), ); - let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?; + let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?; + if let Some(prompt_catalog) = request + .config_bundle + .as_ref() + .and_then(|bundle| bundle.prompt_catalog.clone()) + { + loader = loader.with_effective_catalog(prompt_catalog); + } let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; let session_dir = worker_aggregate_dir.join("session"); @@ -2090,6 +2104,7 @@ mod tests { label: Some("adapter-test".to_string()), }], declarations: Vec::new(), + prompt_catalog: None, profile_source_archive: Some(sample_profile_archive()), profile_source_archive_handle: None, } diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index c7c7785e..27e3dae0 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -29,6 +29,7 @@ tools = { workspace = true } workdir = { workspace = true } minijinja = "2.19.0" chrono = "0.4" +config-source = { path = "../config-source" } include_dir = "0.7.4" fs4 = { workspace = true, features = ["sync"] } flow = { path = "../flow" } @@ -51,6 +52,3 @@ serial_test = "3.4.0" tempfile = { workspace = true } wat = "1.241.2" yoi-plugin-pdk = { workspace = true } - -[build-dependencies] -toml = { workspace = true } diff --git a/crates/worker/build.rs b/crates/worker/build.rs index fb53fa1c..1f587307 100644 --- a/crates/worker/build.rs +++ b/crates/worker/build.rs @@ -1,49 +1,3 @@ -//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys -//! present in `resources/prompts/internal.toml`. The generated slice is -//! included into `src/prompts.rs` where a `const _` assertion compares -//! it bidirectionally against the `WorkerPrompt` enum's own key list, so -//! that a mismatch fails the build (see ticket: worker-prompt-catalog). -use std::env; -use std::fs; -use std::path::PathBuf; - fn main() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR"); - let toml_path = PathBuf::from(&manifest_dir) - .join("..") - .join("..") - .join("resources") - .join("prompts") - .join("internal.toml"); - - println!("cargo:rerun-if-changed={}", toml_path.display()); - println!("cargo:rerun-if-changed=build.rs"); - - let toml_str = fs::read_to_string(&toml_path) - .unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display())); - - let parsed: toml::Value = toml::from_str(&toml_str) - .unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display())); - - let prompt_section = parsed - .get("prompt") - .and_then(|v| v.as_table()) - .unwrap_or_else(|| panic!("{} must contain a `[prompt]` table", toml_path.display())); - - let mut keys: Vec = prompt_section.keys().cloned().collect(); - keys.sort(); - - let out_dir = env::var("OUT_DIR").expect("OUT_DIR"); - let out_path = PathBuf::from(out_dir).join("internal_keys.rs"); - - let mut code = String::from("pub(crate) const INTERNAL_KEYS: &[&str] = &[\n"); - for k in &keys { - code.push_str(" "); - code.push_str(&format!("{k:?}")); - code.push_str(",\n"); - } - code.push_str("];\n"); - - fs::write(&out_path, code) - .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); + println!("cargo:rerun-if-changed=../../resources/prompts"); } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 4a5511e3..ad345d03 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -3,7 +3,8 @@ use std::path::{Path, PathBuf}; use std::process::ExitCode; use crate::{ - PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext, + PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority, + WorkerWorkspaceContext, }; use clap::{CommandFactory, FromArgMatches, Parser}; use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths}; @@ -137,7 +138,7 @@ fn sanitise_worker_name(raw: &str) -> String { } } -fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptLoader), String> { +fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptCatalogSource), String> { let process_root = runtime_workspace_root(cli)?; let runtime_worker_name = runtime_worker_name(cli, &process_root); let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) = @@ -178,29 +179,31 @@ fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) -> Ok(()) } -fn load_spawn_config_json(config_json: &str) -> Result<(WorkerManifest, PromptLoader), String> { +fn load_spawn_config_json( + config_json: &str, +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let config = serde_json::from_str::(config_json) .map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?; let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config)) .map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_builtin_default_manifest( worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let mut config = WorkerManifestConfig::builtin_defaults(); config.worker.name = Some(worker_name.to_string()); let manifest = WorkerManifest::try_from(config) .map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?; - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } pub fn resolve_runtime_profile_manifest( _profile: Option<&str>, _workspace_root: &Path, _worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { Err( "runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority" .to_string(), @@ -211,7 +214,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest( mut manifest: WorkerManifest, workspace_root: &Path, worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { if manifest.worker.name.is_empty() { manifest.worker.name = worker_name.to_string(); } @@ -219,28 +222,28 @@ pub fn resolve_runtime_profile_manifest_from_manifest( // Do not run plugin discovery here: runtime-created Workers receive their // resolved manifest/profile archive from Backend authority, not by scanning // materialized workdir-local plugin stores. - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem( mut manifest: WorkerManifest, _workspace_root: &Path, worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { if manifest.worker.name.is_empty() { manifest.worker.name = worker_name.to_string(); } manifest.scope = ScopeConfig::default(); manifest.delegation_scope = ScopeConfig::default(); // Same as the filesystem-capable runtime path: no local discovery. - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn load_single_manifest( path: &Path, explicit_worker_name: Option<&str>, default_worker_name: &str, -) -> Result<(WorkerManifest, PromptLoader), String> { +) -> Result<(WorkerManifest, PromptCatalogSource), String> { let toml = std::fs::read_to_string(path) .map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?; let absolute_path = if path.is_absolute() { @@ -274,7 +277,7 @@ fn load_single_manifest( path.display() )); } - Ok((manifest, PromptLoader::builtins_only())) + Ok((manifest, PromptCatalogSource::builtins_only())) } fn read_rule(target: PathBuf) -> ScopeRule { @@ -751,11 +754,9 @@ permission = "write" let cli = Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "single"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); } #[test] @@ -834,12 +835,10 @@ language = "override" let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()]) .unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "runtime-workspace"); assert_ne!(manifest.engine.language, "override"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write); } @@ -1031,12 +1030,8 @@ permission = "write" ]) .unwrap(); - let (manifest, loader) = resolve_manifest(&cli).unwrap(); + let (manifest, _loader) = resolve_manifest(&cli).unwrap(); assert_eq!(manifest.worker.name, "single-file"); - assert!(loader.user_dir().is_none()); - assert!(loader.workspace_dir().is_none()); - assert!(loader.user_pack_file().is_none()); - assert!(loader.workspace_pack_file().is_none()); } } diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 3ceb71fe..41b996b9 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -1858,8 +1858,8 @@ mod tests { #[test] fn instruction_contributions_are_deduped_in_registration_order() { - let workflow = instruction("workflow", "$yoi/common/tickets"); - let orchestration = instruction("orchestration", "$yoi/common/worker-orchestration"); + let workflow = instruction("workflow", "common.tickets"); + let orchestration = instruction("orchestration", "common.worker_orchestration"); let contributions = dedupe_instruction_contributions([ workflow.clone(), orchestration.clone(), @@ -1871,8 +1871,8 @@ mod tests { #[test] fn undeclared_instruction_contribution_is_rejected() { - let declared = instruction("declared", "$yoi/common/tickets"); - let undeclared = instruction("undeclared", "$yoi/common/tickets"); + let declared = instruction("declared", "common.tickets"); + let undeclared = instruction("undeclared", "common.tickets"); let descriptor = FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared); let mut hook_builder = HookRegistryBuilder::default(); diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index fbf4f2ec..fb21a3b9 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -33,7 +33,7 @@ const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \ The tools operate through the ticket crate backend and do not grant generic filesystem write scope."; const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow"; -const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets"; +const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets"; pub const TICKET_SERVICE_ID: &str = "ticket.authority"; const TICKET_SERVICE_VERSION: &str = "1"; diff --git a/crates/worker/src/feature/builtin/worker_observation.rs b/crates/worker/src/feature/builtin/worker_observation.rs index 855b92ca..b7186ff4 100644 --- a/crates/worker/src/feature/builtin/worker_observation.rs +++ b/crates/worker/src/feature/builtin/worker_observation.rs @@ -23,7 +23,7 @@ const DEFAULT_PAGE_LIMIT: usize = 20; const MAX_PAGE_LIMIT: usize = 100; const MAX_READ_BYTES: usize = 16 * 1024; const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy"; -const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation"; +const OBSERVATION_PROMPT_REF: &str = "common.worker_observation"; #[cfg(test)] const OBSERVATION_PROMPT_SOURCE: &str = include_str!("../../../../../resources/prompts/common/worker-observation.md"); diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 469ad674..675efb78 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -32,8 +32,10 @@ pub use manifest::{ WorkerMetaConfig, }; pub use model_client::{ProviderError, build_client}; -pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt}; -pub use prompt::loader::PromptLoader; +pub use prompt::catalog::{ + CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source, +}; +pub use prompt::source::PromptCatalogSource; pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus}; pub use runtime::dir::RuntimeDir; diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index d6baab49..4b3e5001 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -1,122 +1,129 @@ -//! Central catalog of Worker-level prompt strings. +//! Typed effective Prompt catalog. //! -//! Prompts that Worker injects into a Engine (compaction system prompt, -//! notification wrapper, interrupt notes, system-prompt trailing -//! sections, AGENTS.md truncation notice, ...) are enumerated by -//! [`WorkerPrompt`] and rendered through a single [`PromptCatalog`]. Direct -//! `const &str` / `format!` authoring of these strings elsewhere in -//! `crates/worker` 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 "Worker 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 [`WorkerPrompt`] 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.worker.prompt_pack`, an explicit path -//! per-Worker. 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. +//! Builtins are evaluated from the embedded `resources/prompts/catalog.dcdl` +//! source tree. Markdown imports use the same `{ frontmatter, content }` +//! projection as Workspace config; the catalog DCDL selects `.content`. +//! Workspace configuration materializes a complete closed `prompts` object, +//! which is carried as an immutable [`EffectivePromptCatalog`] projection. -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; +use std::collections::BTreeMap; use std::sync::Arc; +use config_source::{ + ConfigContentType, ConfigEntry, ConfigTreeSnapshot, SnapshotEnvironment, ToolchainContract, + VirtualPath, digest_bytes, +}; +use include_dir::{Dir, include_dir}; use minijinja::value::Value; -use minijinja::{Environment, ErrorKind, UndefinedBehavior}; -use serde::Deserialize; +use minijinja::{Environment, UndefinedBehavior}; +use serde::{Deserialize, Serialize}; use thiserror::Error; -use tracing::warn; -use crate::prompt::loader::PromptLoader; +use crate::prompt::source::PromptCatalogSource; -// Generated by build.rs from `resources/prompts/internal.toml`. -include!(concat!(env!("OUT_DIR"), "/internal_keys.rs")); +static BUILTIN_PROMPT_SOURCES: Dir<'static> = + include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); +const BUILTIN_CATALOG_ENTRY: &str = "catalog.dcdl"; +const BUILTIN_TOOLCHAIN_FINGERPRINT: &str = "builtin:prompts:decodal-0.4"; -/// Source of the builtin pack. Baked in at compile time. -const INTERNAL_TOML: &str = include_str!("../../../../resources/prompts/internal.toml"); +/// Immutable Prompt projection delivered by Workspace authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EffectivePromptCatalog { + pub templates: BTreeMap, + pub config_revision: u64, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog_digest: String, +} + +impl EffectivePromptCatalog { + pub fn new( + templates: BTreeMap, + config_revision: u64, + schema_fingerprint: impl Into, + toolchain_fingerprint: impl Into, + ) -> Result { + validate_prompt_templates(&templates)?; + let catalog_digest = catalog_digest(&templates)?; + Ok(Self { + templates, + config_revision, + schema_fingerprint: schema_fingerprint.into(), + toolchain_fingerprint: toolchain_fingerprint.into(), + catalog_digest, + }) + } + + pub fn from_projection( + prompts: &serde_json::Value, + config_revision: u64, + schema_fingerprint: impl Into, + toolchain_fingerprint: impl Into, + ) -> Result { + let mut templates = BTreeMap::new(); + flatten_templates("", prompts, &mut templates)?; + if let Some(default_prompt) = templates.remove("default_prompt") { + templates.insert("default".to_string(), default_prompt); + } + Self::new( + templates, + config_revision, + schema_fingerprint, + toolchain_fingerprint, + ) + } + + pub fn verify_digest(&self) -> Result<(), CatalogError> { + let actual = catalog_digest(&self.templates)?; + if actual != self.catalog_digest { + return Err(CatalogError::DigestMismatch { + expected: self.catalog_digest.clone(), + actual, + }); + } + validate_prompt_templates(&self.templates) + } +} /// Worker-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 WorkerPrompt { - /// System prompt of the compaction (summary) Engine. CompactSystem, - /// System prompt of the memory extract Engine. MemoryExtractSystem, - /// System prompt of the memory consolidation (integration + tidy) Engine. MemoryConsolidationSystem, - /// System prompt of the bounded Flow transition verifier. FlowVerifierSystem, - /// 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, resident injection is enabled, - /// and the workspace Memory document has a valid non-empty body. ResidentMemorySummarySection, - /// Trailing Worker orchestration guidance, appended when registered tools - /// include Worker-management capabilities. WorkerOrchestrationGuidanceSection, - /// Weak Companion Notify payload for explicit Orchestrator Ticket events. TicketEventCompanionNotice, - /// LLM-facing description for the SubWorkerSpawn tool, including discovered - /// profile selectors. SubWorkerSpawnToolDescription, } impl WorkerPrompt { pub fn key(self) -> &'static str { match self { - Self::CompactSystem => "compact_system", - Self::MemoryExtractSystem => "memory_extract_system", - Self::MemoryConsolidationSystem => "memory_consolidation_system", - Self::FlowVerifierSystem => "flow_verifier_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::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section", - Self::TicketEventCompanionNotice => "ticket_event_companion_notice", - Self::SubWorkerSpawnToolDescription => "sub_worker_spawn_tool_description", + Self::CompactSystem => "internal.compact_system", + Self::MemoryExtractSystem => "internal.memory_extract_system", + Self::MemoryConsolidationSystem => "internal.memory_consolidation_system", + Self::FlowVerifierSystem => "internal.flow_verifier_system", + Self::NotifyWrapper => "internal.notify_wrapper", + Self::InterruptToolResultSummary => "internal.interrupt_tool_result_summary", + Self::InterruptSystemNote => "internal.interrupt_system_note", + Self::WorkingBoundariesSection => "internal.working_boundaries_section", + Self::AgentsMdSection => "internal.agents_md_section", + Self::ResidentMemorySummarySection => "internal.resident_memory_summary_section", + Self::WorkerOrchestrationGuidanceSection => { + "internal.worker_orchestration_guidance_section" + } + Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice", + Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_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 [WorkerPrompt] = &[ WorkerPrompt::CompactSystem, WorkerPrompt::MemoryExtractSystem, @@ -132,96 +139,18 @@ impl WorkerPrompt { WorkerPrompt::TicketEventCompanionNotice, WorkerPrompt::SubWorkerSpawnToolDescription, ]; - - pub const KEYS: &'static [&'static str] = &[ - "compact_system", - "memory_extract_system", - "memory_consolidation_system", - "flow_verifier_system", - "notify_wrapper", - "interrupt_tool_result_summary", - "interrupt_system_note", - "working_boundaries_section", - "agents_md_section", - "resident_memory_summary_section", - "worker_orchestration_guidance_section", - "ticket_event_companion_notice", - "sub_worker_spawn_tool_description", - ]; } -// --- build-time bidirectional coverage check -------------------------------- - -const _: () = { - // Every enum key must appear in the builtin TOML. - let mut i = 0; - while i < WorkerPrompt::KEYS.len() { - if !const_slice_contains(INTERNAL_KEYS, WorkerPrompt::KEYS[i]) { - panic!( - "resources/prompts/internal.toml is missing a key declared by \ - WorkerPrompt — 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(WorkerPrompt::KEYS, INTERNAL_KEYS[i]) { - panic!( - "resources/prompts/internal.toml has a key not declared by \ - WorkerPrompt — 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 build builtin Prompt source tree: {0}")] + BuiltinTree(String), + #[error("failed to evaluate builtin Prompt source tree: {0}")] + BuiltinEvaluation(String), + #[error("effective Prompt projection at '{path}' must be an object or string")] + InvalidProjection { path: String }, + #[error("invalid effective Prompt template catalog: {0}")] + InvalidTemplateCatalog(String), #[error("failed to compile prompt template '{key}': {source}")] TemplateCompile { key: String, @@ -236,544 +165,455 @@ pub enum CatalogError { }, #[error("prompt key '{key}' is not registered in the catalog")] UnknownKey { key: String }, + #[error("failed to serialize effective Prompt catalog: {0}")] + Serialize(#[from] serde_json::Error), + #[error("effective Prompt catalog digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, } -// --- pack file shape ------------------------------------------------------- - -#[derive(Debug, Deserialize)] -struct PackFile { - #[serde(default)] - prompt: HashMap, -} - -// --- catalog --------------------------------------------------------------- - -/// Merged, compiled worker-prompt catalog. -/// -/// Owns a `minijinja::Environment` with one template registered per -/// [`WorkerPrompt`] 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>, - loader: PromptLoader, + projection: EffectivePromptCatalog, } impl std::fmt::Debug for PromptCatalog { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PromptCatalog").finish_non_exhaustive() + f.debug_struct("PromptCatalog") + .field("config_revision", &self.projection.config_revision) + .field("catalog_digest", &self.projection.catalog_digest) + .finish_non_exhaustive() } } impl PromptCatalog { - pub(crate) fn loader(&self) -> PromptLoader { - self.loader.clone() - } - - /// 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) + let templates = builtin_prompt_templates()?; + let projection = EffectivePromptCatalog::new( + templates, + 0, + BUILTIN_TOOLCHAIN_FINGERPRINT, + BUILTIN_TOOLCHAIN_FINGERPRINT, + )?; + Self::from_projection(projection).map(Arc::new) } - /// 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"); - } + pub fn load(loader: &PromptCatalogSource) -> Result, CatalogError> { + if let Some(projection) = loader.effective_catalog() { + return Self::from_projection(projection.clone()).map(Arc::new); } - 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) + Self::builtins_only() } - /// 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: WorkerPrompt, ctx: Value) -> Result { - let key = prompt.key(); - let tmpl = self + pub fn from_projection(projection: EffectivePromptCatalog) -> Result { + projection.verify_digest()?; + let mut env = Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Strict); + for (key, source) in &projection.templates { + env.add_template_owned(key.clone(), source.clone()) + .map_err(|source| CatalogError::TemplateCompile { + key: key.clone(), + source, + })?; + } + Ok(Self { env, projection }) + } + + pub fn projection(&self) -> &EffectivePromptCatalog { + &self.projection + } + + pub(crate) fn source(&self) -> PromptCatalogSource { + PromptCatalogSource::builtins_only().with_effective_catalog(self.projection.clone()) + } + + pub fn contains(&self, key: &str) -> bool { + self.projection.templates.contains_key(key) + } + + pub fn render_name(&self, key: &str, ctx: Value) -> Result { + let template = 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(), + .map_err(|_| CatalogError::UnknownKey { key: key.into() })?; + template.render(ctx).map_err(|source| CatalogError::Render { + key: key.into(), source, }) } - /// Render `WorkerPrompt::CompactSystem` (no inputs). + pub fn render(&self, prompt: WorkerPrompt, ctx: Value) -> Result { + self.render_name(prompt.key(), ctx) + } + pub fn compact_system(&self) -> Result { self.render(WorkerPrompt::CompactSystem, Value::UNDEFINED) } - - /// Render `WorkerPrompt::MemoryExtractSystem` with `{{ language }}`. pub fn memory_extract_system(&self, language: &str) -> Result { self.render( WorkerPrompt::MemoryExtractSystem, single("language", language), ) } - - /// Render `WorkerPrompt::MemoryConsolidationSystem` with `{{ language }}`. pub fn memory_consolidation_system(&self, language: &str) -> Result { self.render( WorkerPrompt::MemoryConsolidationSystem, single("language", language), ) } - - /// Render `WorkerPrompt::FlowVerifierSystem` (no inputs). pub fn flow_verifier_system(&self) -> Result { self.render(WorkerPrompt::FlowVerifierSystem, Value::UNDEFINED) } - - /// Render `WorkerPrompt::NotifyWrapper` with `{{ message }}`. pub fn notify_wrapper(&self, message: &str) -> Result { self.render(WorkerPrompt::NotifyWrapper, single("message", message)) } - - /// Render `WorkerPrompt::InterruptToolResultSummary` (no inputs). pub fn interrupt_tool_result_summary(&self) -> Result { self.render(WorkerPrompt::InterruptToolResultSummary, Value::UNDEFINED) } - - /// Render `WorkerPrompt::InterruptSystemNote` (no inputs). pub fn interrupt_system_note(&self) -> Result { self.render(WorkerPrompt::InterruptSystemNote, Value::UNDEFINED) } - - /// Render `WorkerPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`. pub fn working_boundaries_section(&self, scope_summary: &str) -> Result { self.render( WorkerPrompt::WorkingBoundariesSection, single("scope_summary", scope_summary), ) } - - /// Render `WorkerPrompt::AgentsMdSection` with `{{ agents_md }}`. pub fn agents_md_section(&self, agents_md: &str) -> Result { self.render( WorkerPrompt::AgentsMdSection, single("agents_md", agents_md), ) } - - /// Render `WorkerPrompt::ResidentMemorySummarySection` with `{{ summary }}`. pub fn resident_memory_summary_section(&self, summary: &str) -> Result { self.render( WorkerPrompt::ResidentMemorySummarySection, single("summary", summary), ) } - - /// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs). pub fn worker_orchestration_guidance_section(&self) -> Result { self.render( WorkerPrompt::WorkerOrchestrationGuidanceSection, Value::UNDEFINED, ) } - - /// Render `WorkerPrompt::SubWorkerSpawnToolDescription`. pub fn sub_worker_spawn_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(WorkerPrompt::SubWorkerSpawnToolDescription, Value::from(m)) + let mut context = BTreeMap::new(); + context.insert("available_profiles", Value::from(available_profiles)); + context.insert("default_profile", Value::from(default_profile)); + context.insert("profile_diagnostic", Value::from(profile_diagnostic)); + self.render( + WorkerPrompt::SubWorkerSpawnToolDescription, + Value::from(context), + ) } } +/// DCDL schema contribution for the closed `WorkspaceConfig.prompts` namespace. +/// Every leaf defaults to its builtin value, so Workspace config is a right-biased +/// deep patch while evaluation materializes a complete effective catalog. +pub fn prompt_schema_source() -> Result { + let mut templates = builtin_prompt_templates()?; + if let Some(default) = templates.remove("default") { + templates.insert("default_prompt".to_string(), default); + } + let tree = unflatten_templates(&templates); + let mut output = String::from("{ prompts = "); + write_schema_object(&mut output, &tree)?; + output.push_str(" default {}; }"); + Ok(output) +} + +pub fn builtin_prompt_templates() -> Result, CatalogError> { + let mut entries = Vec::new(); + collect_builtin_entries(&BUILTIN_PROMPT_SOURCES, "", &mut entries)?; + let snapshot = ConfigTreeSnapshot::from_entries(0, entries) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + let entry = VirtualPath::parse(BUILTIN_CATALOG_ENTRY) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + let result = SnapshotEnvironment::new(snapshot) + .evaluate_contract(&ToolchainContract::new(1, vec![entry], 1)) + .map_err(|diagnostics| { + CatalogError::BuiltinEvaluation( + diagnostics + .into_iter() + .map(|diagnostic| { + format!( + "{}:{}:{}..{}: {}", + diagnostic.path, + diagnostic.kind, + diagnostic.span.start_byte, + diagnostic.span.end_byte, + diagnostic.message + ) + }) + .collect::>() + .join("; "), + ) + })?; + let projection = result + .projections + .first() + .ok_or_else(|| CatalogError::BuiltinEvaluation("catalog produced no projection".into()))?; + let mut templates = BTreeMap::new(); + flatten_templates("", &projection.data_json, &mut templates)?; + if let Some(default_prompt) = templates.remove("default_prompt") { + templates.insert("default".to_string(), default_prompt); + } + validate_prompt_templates(&templates)?; + Ok(templates) +} + +fn collect_builtin_entries( + dir: &Dir<'static>, + prefix: &str, + entries: &mut Vec, +) -> Result<(), CatalogError> { + for file in dir.files() { + let Some(name) = file.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + let relative = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + let content_type = if relative.ends_with(".dcdl") { + ConfigContentType::Decodal + } else if relative.ends_with(".md") { + ConfigContentType::Text + } else { + continue; + }; + let content = file.contents_utf8().ok_or_else(|| { + CatalogError::BuiltinTree(format!("builtin Prompt source is not UTF-8: {relative}")) + })?; + let path = VirtualPath::parse(&relative) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?; + entries.push( + ConfigEntry::new(path, content_type, content) + .map_err(|error| CatalogError::BuiltinTree(error.to_string()))?, + ); + } + for child in dir.dirs() { + let Some(name) = child.path().file_name().and_then(|name| name.to_str()) else { + continue; + }; + let child_prefix = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}/{name}") + }; + collect_builtin_entries(child, &child_prefix, entries)?; + } + Ok(()) +} + 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) + Value::from(BTreeMap::from([(key, Value::from(value))])) } -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 !WorkerPrompt::KEYS.iter().any(|declared| *declared == k) { - warn!( - origin = origin, - key = %k, - "unknown prompt pack key; ignoring" - ); - continue; +fn flatten_templates( + prefix: &str, + value: &serde_json::Value, + output: &mut BTreeMap, +) -> Result<(), CatalogError> { + match value { + serde_json::Value::String(source) if !prefix.is_empty() => { + output.insert(prefix.to_string(), source.clone()); + Ok(()) } - base.insert(k, v); + serde_json::Value::Object(fields) => { + for (name, value) in fields { + let key = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + flatten_templates(&key, value, output)?; + } + Ok(()) + } + _ => Err(CatalogError::InvalidProjection { + path: if prefix.is_empty() { + "prompts".into() + } else { + format!("prompts.{prefix}") + }, + }), } } -fn build_catalog( - templates: HashMap, - loader: PromptLoader, -) -> Result { - let mut env = Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); +#[derive(Default)] +struct TemplateNode { + value: Option, + children: BTreeMap, +} - // 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(), +fn unflatten_templates(templates: &BTreeMap) -> TemplateNode { + let mut root = TemplateNode::default(); + for (key, value) in templates { + let mut node = &mut root; + for segment in key.split('.') { + node = node.children.entry(segment.to_string()).or_default(); } - }); - - 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, - })?; + node.value = Some(value.clone()); } + root +} - Ok(PromptCatalog { env, loader }) +fn write_schema_object(output: &mut String, node: &TemplateNode) -> Result<(), CatalogError> { + output.push_str("{"); + for (name, child) in &node.children { + output.push_str(name); + output.push_str(" = "); + if let Some(value) = &child.value { + output.push_str("String default "); + output.push_str(&serde_json::to_string(value)?); + } else { + write_schema_object(output, child)?; + output.push_str(" default {}"); + } + output.push_str("; "); + } + output.push('}'); + Ok(()) +} + +fn catalog_digest(templates: &BTreeMap) -> Result { + Ok(digest_bytes(&serde_json::to_vec(templates)?)) +} + +fn validate_prompt_templates(templates: &BTreeMap) -> Result<(), CatalogError> { + config_source::validate_static_template_catalog(templates) + .map_err(CatalogError::InvalidTemplateCatalog) } #[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 WorkerPrompt::ALL { - assert!( - cat.env.get_template(p.key()).is_ok(), - "builtin missing key: {}", - p.key() - ); + fn builtin_dcdl_catalog_covers_worker_prompts() { + let catalog = PromptCatalog::builtins_only().unwrap(); + for prompt in WorkerPrompt::ALL { + assert!(catalog.projection.templates.contains_key(prompt.key())); } + assert!(catalog.projection.templates.contains_key("default")); + assert!( + catalog + .projection + .templates + .contains_key("common.workspace") + ); + assert!(catalog.projection.templates.contains_key("role.coder")); + assert!( + catalog + .projection + .templates + .contains_key("panel.orchestrator_idle_queue_notice") + ); } #[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")); + fn builtin_render_resolves_catalog_root_dotted_includes() { + let catalog = PromptCatalog::builtins_only().unwrap(); + let source = &catalog.projection.templates["default"]; + assert!(source.contains("{% include \"common.workspace\" %}")); + assert!(source.contains("{% include \"common.tool_usage\" %}")); } #[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("Do not query memory every turn")); - assert!(!rendered.contains("Strong lookup triggers include")); - } + fn schema_is_closed_and_materializes_builtin_defaults() { + let source = prompt_schema_source().unwrap(); + assert!(source.starts_with("{ prompts = {")); + assert!(source.contains("compact_system = String default")); + assert!(source.contains("role = {")); } #[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`")); + fn graph_rejects_dynamic_legacy_missing_and_cycles() { + let invalid = BTreeMap::from([ + ("a".into(), "{% include target %}".into()), + ("target".into(), "ok".into()), + ]); + assert!(validate_prompt_templates(&invalid).is_err()); + + let legacy = BTreeMap::from([("a".into(), "{% include \"legacy/default\" %}".into())]); + assert!(validate_prompt_templates(&legacy).is_err()); + + let missing = BTreeMap::from([("a".into(), "{% include \"missing\" %}".into())]); + assert!(validate_prompt_templates(&missing).is_err()); + + let cycle = BTreeMap::from([ + ("a".into(), "{% include \"b\" %}".into()), + ("b".into(), "{% include \"a\" %}".into()), + ]); + assert!(validate_prompt_templates(&cycle).is_err()); } #[test] - fn memory_consolidation_prompt_describes_document_edit_policy_without_storage_details() { - let cat = PromptCatalog::builtins_only().unwrap(); - let consolidate = cat.memory_consolidation_system("Japanese").unwrap(); - assert!(consolidate.contains("Durable Memory is one Markdown document")); - assert!(consolidate.contains("existing `##` section")); - assert!(consolidate.contains("MemoryUpdateDocument")); - assert!(consolidate.contains("old_string")); - assert!(consolidate.contains("new_string")); - assert!(consolidate.contains("MemoryStagingClose")); - assert!(consolidate.contains("`applied`")); - assert!(consolidate.contains(r#"{"operation":"edit"}"#)); - assert!(consolidate.contains("Do not create Knowledge, Skill, Ticket")); - assert!(consolidate.contains("Do not create separate categorized Memory records")); - assert!(!consolidate.contains("SQLite")); - assert!(!consolidate.contains("SQL")); - assert!(!consolidate.contains("storage")); - assert!(!consolidate.contains("decision record")); - assert!(!consolidate.contains("request record")); - assert!(!consolidate.contains("summary record")); + fn workspace_projection_digest_is_stable_and_verified() { + let templates = builtin_prompt_templates().unwrap(); + let projection = EffectivePromptCatalog::new(templates, 42, "schema", "toolchain").unwrap(); + projection.verify_digest().unwrap(); + let mut tampered = projection.clone(); + tampered + .templates + .insert("default".into(), "tampered".into()); + assert!(matches!( + tampered.verify_digest(), + Err(CatalogError::DigestMismatch { .. }) + )); } #[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]" -"#, + fn catalog_source_preserves_workspace_projection_for_subworkers() { + let mut templates = builtin_prompt_templates().unwrap(); + templates.insert("common.workspace".into(), "CHILD OVERRIDE".into()); + let catalog = PromptCatalog::from_projection( + EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(), ) .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]")); + let child = PromptCatalog::load(&catalog.source()).unwrap(); + assert_eq!(child.projection.config_revision, 9); + assert_eq!( + child.projection.templates["common.workspace"], + "CHILD OVERRIDE" + ); } #[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 worker_orchestration_guidance_section_renders_resource_body() { - let cat = PromptCatalog::builtins_only().unwrap(); - let rendered = cat.worker_orchestration_guidance_section().unwrap(); - assert!(rendered.contains("## SubWorker orchestration")); - assert!(rendered.contains("SubWorker 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 state, diff, and test results")); - assert!(rendered.contains("not scheduler or auto-maintain authorization")); - assert!(rendered.contains("bypass user/Ticket authorization")); - } - - #[test] - fn orchestrator_role_prompt_fences_worker_remove_authority() { - let source = include_str!("../../../../resources/prompts/role/orchestrator.md"); - assert!(source.contains("Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder")); - assert!(source.contains("exact current `updated_at` value")); - assert!(source.contains("must have no current Ticket assignment")); - assert!(source.contains("pending notification, Reviewer handoff, legal hold, or pin")); - assert!(source.contains("After removal, reread the Worker catalog and attachment state")); - assert!(source.contains("attachment-close, and attachment-release conflicts")); - assert!(source.contains("preserves the Workdir materialization")); - assert!(!source.contains("source proof")); - assert!(!source.contains("provider handle")); - assert!(!source.contains("retention plan")); - } - - #[test] - fn sub_worker_spawn_tool_description_renders_profile_block() { - let cat = PromptCatalog::builtins_only().unwrap(); - let rendered = cat - .sub_worker_spawn_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")); + fn existing_internal_prompt_render_contracts_are_preserved() { + let catalog = PromptCatalog::builtins_only().unwrap(); + assert!(catalog.compact_system().unwrap().contains("write_summary")); + assert!( + catalog + .memory_extract_system("Japanese") + .unwrap() + .contains("`language`: `Japanese`") + ); + assert!( + catalog + .notify_wrapper("changed") + .unwrap() + .contains("changed") + ); + assert!( + catalog + .working_boundaries_section("Readable: /a") + .unwrap() + .contains("Readable: /a") + ); + assert!( + catalog + .worker_orchestration_guidance_section() + .unwrap() + .contains("## SubWorker orchestration") + ); } } diff --git a/crates/worker/src/prompt/loader.rs b/crates/worker/src/prompt/loader.rs deleted file mode 100644 index d65b118b..00000000 --- a/crates/worker/src/prompt/loader.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`]. -//! -//! Three prefixes address three physical libraries: -//! -//! | prefix | location | -//! |--------------|---------------------------------------------------------| -//! | `$yoi` | builtin, baked into the binary via `include_dir!` | -//! | `$user` | `/prompts/` (resolved by `manifest::paths`) | -//! | `$workspace` | `/.yoi/prompts/` | -//! -//! A reference is `$/` where `` is a `/`-separated -//! name without the `.md` extension (e.g. `$yoi/common/header`). -//! Unqualified names (no `$prefix/` at the front) are resolved relative -//! to an optional current reference — typically the file that issued -//! the `{% include %}` — so a prompt library can be authored as a -//! self-contained directory. -//! -//! Missing files produce a [`LoaderError::NotFound`]; there is no -//! fallthrough between layers. - -use std::path::{Path, PathBuf}; - -use include_dir::{Dir, include_dir}; -use thiserror::Error; - -static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); - -const PREFIX_YOI: &str = "$yoi"; -const PREFIX_USER: &str = "$user"; -const PREFIX_WORKSPACE: &str = "$workspace"; - -/// Prefix-resolved reference to a prompt asset. Produced by -/// [`PromptLoader::parse_ref`] from a user-supplied string such as -/// `"$yoi/default"`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PromptRef { - prefix: Prefix, - /// Relative path under the prefix root, without the `.md` extension. - /// `/`-separated, never empty, never starts with `/`. - path: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Prefix { - Yoi, - User, - Workspace, -} - -impl Prefix { - fn as_str(self) -> &'static str { - match self { - Self::Yoi => PREFIX_YOI, - Self::User => PREFIX_USER, - Self::Workspace => PREFIX_WORKSPACE, - } - } -} - -impl PromptRef { - /// Produce a canonical `$prefix/path` string. - pub fn to_qualified_string(&self) -> String { - format!("{}/{}", self.prefix.as_str(), self.path) - } - - /// Directory portion (leading prefix segments minus the file name), - /// joined with `/`. Returns an empty string when the ref points at - /// a file directly under the prefix root. - fn dir(&self) -> &str { - match self.path.rsplit_once('/') { - Some((dir, _)) => dir, - None => "", - } - } -} - -/// Errors produced when resolving a [`PromptRef`]. -#[derive(Debug, Error)] -pub enum LoaderError { - #[error("invalid prompt reference '{raw}': {reason}")] - InvalidRef { raw: String, reason: String }, - #[error("unknown prompt prefix '{prefix}' in reference '{raw}'")] - UnknownPrefix { raw: String, prefix: String }, - #[error( - "unqualified prompt reference '{raw}' requires a current prefix \ - (include it from inside another template, or use an explicit \ - $prefix/path form)" - )] - UnqualifiedWithoutCurrent { raw: String }, - #[error("prompt prefix '{prefix}' is not configured for this loader")] - PrefixNotConfigured { prefix: &'static str }, - #[error("prompt asset not found: '{}'", .reference.to_qualified_string())] - NotFound { reference: PromptRef }, - #[error("failed to read prompt asset '{}': {source}", .reference.to_qualified_string())] - Io { - reference: PromptRef, - #[source] - source: std::io::Error, - }, -} - -/// Loader that resolves [`PromptRef`]s against the configured prompt -/// libraries. Cheap to clone. -/// -/// Also carries the auto-discovered `prompts.toml` pack file paths so -/// [`crate::prompt::catalog::PromptCatalog`] can read the same user/workspace -/// layers without a separate plumbing channel. These fields do not -/// affect `$prefix` asset resolution — they are purely metadata -/// consulted by the catalog loader. -#[derive(Debug, Clone)] -pub struct PromptLoader { - user_dir: Option, - workspace_dir: Option, - user_pack_file: Option, - workspace_pack_file: Option, -} - -impl PromptLoader { - /// Loader with only the builtin `$yoi` library available. - /// `$user` / `$workspace` references fail with - /// [`LoaderError::PrefixNotConfigured`]. - pub fn builtins_only() -> Self { - Self { - user_dir: None, - workspace_dir: None, - user_pack_file: None, - workspace_pack_file: None, - } - } - - /// Loader with optional user and workspace prompt directories. - pub fn new(user_dir: Option, workspace_dir: Option) -> Self { - Self { - user_dir, - workspace_dir, - user_pack_file: None, - workspace_pack_file: None, - } - } - - /// Override pack file paths supplied by the caller's profile/manifest - /// resolution context. - pub fn with_pack_files( - mut self, - user_pack_file: Option, - workspace_pack_file: Option, - ) -> Self { - self.user_pack_file = user_pack_file; - self.workspace_pack_file = workspace_pack_file; - self - } - - /// Root of the `$user` prompt library, if configured. - pub fn user_dir(&self) -> Option<&Path> { - self.user_dir.as_deref() - } - - /// Root of the `$workspace` prompt library, if configured. - pub fn workspace_dir(&self) -> Option<&Path> { - self.workspace_dir.as_deref() - } - - /// Auto-discovered path to the user-layer `prompts.toml` pack, if any. - pub fn user_pack_file(&self) -> Option<&Path> { - self.user_pack_file.as_deref() - } - - /// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any. - pub fn workspace_pack_file(&self) -> Option<&Path> { - self.workspace_pack_file.as_deref() - } - - /// Parse a string reference into a [`PromptRef`]. Unqualified - /// references (no leading `$prefix/`) are resolved against - /// `current`: the prefix is inherited, and the path is joined to - /// the current ref's directory. - pub fn parse_ref( - &self, - raw: &str, - current: Option<&PromptRef>, - ) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "reference must not be empty".into(), - }); - } - if let Some(prefix) = trimmed.strip_prefix('$') { - let (prefix_name, rest) = - prefix - .split_once('/') - .ok_or_else(|| LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "prefix must be followed by '/'".into(), - })?; - let prefix = parse_prefix(raw, prefix_name)?; - let path = normalize_path(raw, rest)?; - Ok(PromptRef { prefix, path }) - } else { - let Some(current) = current else { - return Err(LoaderError::UnqualifiedWithoutCurrent { - raw: raw.to_string(), - }); - }; - let dir = current.dir(); - let joined = if dir.is_empty() { - trimmed.to_string() - } else { - format!("{dir}/{trimmed}") - }; - let path = normalize_path(raw, &joined)?; - Ok(PromptRef { - prefix: current.prefix, - path, - }) - } - } - - /// Resolve a [`PromptRef`] to its raw template source. Hard-errors - /// when the prefix is not configured or the file does not exist. - pub fn load(&self, reference: &PromptRef) -> Result { - match reference.prefix { - Prefix::Yoi => load_from_include_dir(&BUILTIN_PROMPTS, reference), - Prefix::User => match self.user_dir.as_deref() { - Some(dir) => load_from_dir(dir, reference), - None => Err(LoaderError::PrefixNotConfigured { - prefix: PREFIX_USER, - }), - }, - Prefix::Workspace => match self.workspace_dir.as_deref() { - Some(dir) => load_from_dir(dir, reference), - None => Err(LoaderError::PrefixNotConfigured { - prefix: PREFIX_WORKSPACE, - }), - }, - } - } - - /// Parse `raw` against `current`, then load the resulting ref. - /// Convenience wrapper for the minijinja loader hook. - pub fn resolve( - &self, - raw: &str, - current: Option<&PromptRef>, - ) -> Result<(PromptRef, String), LoaderError> { - let reference = self.parse_ref(raw, current)?; - let source = self.load(&reference)?; - Ok((reference, source)) - } -} - -fn parse_prefix(raw: &str, prefix_name: &str) -> Result { - match prefix_name { - "yoi" => Ok(Prefix::Yoi), - "user" => Ok(Prefix::User), - "workspace" => Ok(Prefix::Workspace), - _ => Err(LoaderError::UnknownPrefix { - raw: raw.to_string(), - prefix: format!("${prefix_name}"), - }), - } -} - -fn normalize_path(raw: &str, rest: &str) -> Result { - let cleaned = rest.trim_matches('/').trim(); - if cleaned.is_empty() { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "path component must not be empty".into(), - }); - } - if cleaned.split('/').any(|seg| seg == "." || seg == "..") { - return Err(LoaderError::InvalidRef { - raw: raw.to_string(), - reason: "path must not contain '.' or '..' segments".into(), - }); - } - Ok(cleaned.to_string()) -} - -fn load_from_dir(dir: &Path, reference: &PromptRef) -> Result { - let path = dir.join(format!("{}.md", reference.path)); - match std::fs::read_to_string(&path) { - Ok(s) => Ok(s), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(LoaderError::NotFound { - reference: reference.clone(), - }), - Err(source) => Err(LoaderError::Io { - reference: reference.clone(), - source, - }), - } -} - -fn load_from_include_dir(dir: &Dir<'static>, reference: &PromptRef) -> Result { - let path = format!("{}.md", reference.path); - dir.get_file(&path) - .and_then(|f| f.contents_utf8()) - .map(|s| s.to_string()) - .ok_or_else(|| LoaderError::NotFound { - reference: reference.clone(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::TempDir; - - #[test] - fn builtin_default_resolves() { - let loader = PromptLoader::builtins_only(); - let (r, source) = loader.resolve("$yoi/default", None).unwrap(); - assert_eq!(r.to_qualified_string(), "$yoi/default"); - assert!(!source.is_empty()); - } - - #[test] - fn builtin_ticket_role_instructions_resolve() { - let loader = PromptLoader::builtins_only(); - for role in ["intake", "orchestrator", "coder", "reviewer"] { - let (reference, source) = loader.resolve(&format!("$yoi/role/{role}"), None).unwrap(); - assert_eq!(reference.to_qualified_string(), format!("$yoi/role/{role}")); - assert!(source.contains("first committed user message")); - } - } - - #[test] - fn builtin_subdirectory_lookup() { - let loader = PromptLoader::builtins_only(); - let (_, source) = loader.resolve("$yoi/common/tool-usage", None).unwrap(); - assert!(source.contains("tool")); - } - - #[test] - fn user_prefix_resolves() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().to_path_buf(); - std::fs::write(user_dir.join("my.md"), "user-body").unwrap(); - let loader = PromptLoader::new(Some(user_dir), None); - let (_, source) = loader.resolve("$user/my", None).unwrap(); - assert_eq!(source, "user-body"); - } - - #[test] - fn workspace_prefix_resolves() { - let tmp = TempDir::new().unwrap(); - let ws_dir = tmp.path().to_path_buf(); - std::fs::write(ws_dir.join("custom.md"), "ws-body").unwrap(); - let loader = PromptLoader::new(None, Some(ws_dir)); - let (_, source) = loader.resolve("$workspace/custom", None).unwrap(); - assert_eq!(source, "ws-body"); - } - - #[test] - fn missing_file_is_hard_error() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$yoi/definitely-missing", None).unwrap_err(); - assert!(matches!(err, LoaderError::NotFound { .. })); - } - - #[test] - fn user_prefix_not_configured_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$user/my", None).unwrap_err(); - assert!(matches!( - err, - LoaderError::PrefixNotConfigured { prefix: "$user" } - )); - } - - #[test] - fn unknown_prefix_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$bogus/x", None).unwrap_err(); - assert!(matches!(err, LoaderError::UnknownPrefix { .. })); - } - - #[test] - fn unqualified_ref_without_current_errors() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("default", None).unwrap_err(); - assert!(matches!(err, LoaderError::UnqualifiedWithoutCurrent { .. })); - } - - #[test] - fn unqualified_ref_resolves_relative_to_current() { - let loader = PromptLoader::builtins_only(); - let current = loader.parse_ref("$yoi/common/tool-usage", None).unwrap(); - // Sibling lookup under the same prefix and directory. - let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap(); - assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace"); - } - - #[test] - fn unqualified_ref_from_root_file_has_empty_dir() { - let loader = PromptLoader::builtins_only(); - let current = loader.parse_ref("$yoi/default", None).unwrap(); - let sibling = loader.parse_ref("other", Some(¤t)).unwrap(); - assert_eq!(sibling.to_qualified_string(), "$yoi/other"); - } - - #[test] - fn explicit_prefix_overrides_current() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().to_path_buf(); - std::fs::write(user_dir.join("custom.md"), "user-body").unwrap(); - let loader = PromptLoader::new(Some(user_dir), None); - - let current = loader.parse_ref("$yoi/default", None).unwrap(); - // Even with an $yoi-rooted current, an explicit $user - // prefix must win. - let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap(); - assert_eq!(reference.to_qualified_string(), "$user/custom"); - assert_eq!(source, "user-body"); - } - - #[test] - fn traversal_segments_rejected() { - let loader = PromptLoader::builtins_only(); - let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err(); - assert!(matches!(err, LoaderError::InvalidRef { .. })); - } -} diff --git a/crates/worker/src/prompt/mod.rs b/crates/worker/src/prompt/mod.rs index ae52e7ca..c9800a3b 100644 --- a/crates/worker/src/prompt/mod.rs +++ b/crates/worker/src/prompt/mod.rs @@ -1,4 +1,4 @@ pub(crate) mod agents_md; pub(crate) mod catalog; -pub(crate) mod loader; +pub(crate) mod source; pub(crate) mod system; diff --git a/crates/worker/src/prompt/source.rs b/crates/worker/src/prompt/source.rs new file mode 100644 index 00000000..fed08a16 --- /dev/null +++ b/crates/worker/src/prompt/source.rs @@ -0,0 +1,30 @@ +//! Immutable effective Prompt catalog carrier. +//! +//! Prompt sources are resolved and evaluated by Workspace config authority. +//! This type carries only the already-materialized projection into Worker +//! construction; it performs no filesystem, prefix, relative-path, user, or +//! repository discovery. + +use std::sync::Arc; + +use super::catalog::EffectivePromptCatalog; + +#[derive(Debug, Clone, Default)] +pub struct PromptCatalogSource { + effective_catalog: Option>, +} + +impl PromptCatalogSource { + pub fn builtins_only() -> Self { + Self::default() + } + + pub fn with_effective_catalog(mut self, catalog: EffectivePromptCatalog) -> Self { + self.effective_catalog = Some(Arc::new(catalog)); + self + } + + pub fn effective_catalog(&self) -> Option<&EffectivePromptCatalog> { + self.effective_catalog.as_deref() + } +} diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index df5c4950..d043b703 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -3,7 +3,7 @@ //! Manifests describe the system prompt body as a reference to a //! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]). //! [`SystemPromptTemplate`] resolves that reference through a -//! [`PromptLoader`], parses the source as a minijinja template, and +//! [`PromptCatalogSource`], parses the source as a minijinja template, and //! eagerly syntax-checks it at Worker construction. The final system //! prompt is materialised exactly once just before the first LLM turn: //! the rendered body is appended with a fixed trailing section carrying @@ -22,17 +22,16 @@ use std::sync::Arc; use chrono::{DateTime, SecondsFormat, Utc}; use manifest::Scope; use minijinja::value::Value; -use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use thiserror::Error; use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions}; use crate::prompt::catalog::{CatalogError, PromptCatalog}; -use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef}; +#[cfg(test)] +use crate::prompt::catalog::{EffectivePromptCatalog, builtin_prompt_templates}; +use crate::prompt::source::PromptCatalogSource; #[derive(Debug, Error)] pub enum SystemPromptError { - #[error("failed to resolve instruction reference: {0}")] - LoaderResolve(#[source] LoaderError), #[error("system prompt template parse error: {0}")] Parse(String), #[error("system prompt template render error: {0}")] @@ -41,69 +40,37 @@ pub enum SystemPromptError { Catalog(#[from] CatalogError), } -/// Parsed instruction template bound to a prompt loader. -/// -/// Holds a minijinja Environment pre-populated with the instruction -/// template registered under its fully-qualified name (`$prefix/path`). -/// Includes are resolved via the loader using a path-join callback that -/// tracks the including template's prefix and directory, so -/// `{% include "sibling" %}` fragments work as expected. +/// Parsed instruction template bound to one immutable effective Prompt catalog. #[derive(Clone)] pub struct SystemPromptTemplate { - env: Arc>, + catalog: Arc, instruction_name: String, } impl SystemPromptTemplate { - /// Parse the instruction asset referenced by `instruction_ref` - /// using the supplied [`PromptLoader`]. The reference is resolved - /// at parse time so syntax errors surface immediately. - pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result { - let root_ref = loader - .parse_ref(instruction_ref, None) - .map_err(SystemPromptError::LoaderResolve)?; - let source = loader - .load(&root_ref) - .map_err(SystemPromptError::LoaderResolve)?; - let root_name = root_ref.to_qualified_string(); - - let mut env = Environment::new(); - env.set_undefined_behavior(UndefinedBehavior::Strict); - - // Path-join callback: compute the target template name when a - // template includes another by a possibly-unqualified string. - // The joined name is then looked up via `set_loader` below. - 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(), - // Propagate the raw name on error so set_loader surfaces - // a proper TemplateNotFound/LoaderError to the caller. - 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(source) => Ok(Some(source)), - Err(e) => Err(minijinja::Error::new( - ErrorKind::TemplateNotFound, - e.to_string(), - )), - } - }); - - env.add_template_owned(root_name.clone(), source) - .map_err(|e| SystemPromptError::Parse(e.to_string()))?; - + /// Resolve an exact catalog-root dotted Prompt name and eagerly verify it. + pub fn parse( + instruction_ref: &str, + loader: PromptCatalogSource, + ) -> Result { + let instruction_name = exact_prompt_name(instruction_ref).ok_or_else(|| { + SystemPromptError::Parse(format!( + "instruction must be an exact catalog-root dotted Prompt name: {instruction_ref}" + )) + })?; + let catalog = if let Some(projection) = loader.effective_catalog() { + Arc::new(PromptCatalog::from_projection(projection.clone())?) + } else { + PromptCatalog::builtins_only()? + }; + if !catalog.contains(&instruction_name) { + return Err(SystemPromptError::Parse(format!( + "Prompt '{instruction_name}' is not present in the effective catalog" + ))); + } Ok(Self { - env: Arc::new(env), - instruction_name: root_name, + catalog, + instruction_name, }) } @@ -112,18 +79,14 @@ impl SystemPromptTemplate { /// section is assembled in Rust so that authored templates cannot /// accidentally omit the scope boundary or the project instructions. pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result { - let tmpl = self - .env - .get_template(&self.instruction_name) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; - let body = tmpl - .render(ctx.to_minijinja_value()) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; + let body = self + .catalog + .render_name(&self.instruction_name, ctx.to_minijinja_value()) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; append_trailing_section( &body, - &self.env, ctx, - ctx.prompts, + &self.catalog, ctx.scope, ctx.agents_md.as_deref(), ctx.resident_summary, @@ -273,6 +236,22 @@ impl ToolCapabilities { } } +fn exact_prompt_name(reference: &str) -> Option { + let candidate = reference.to_string(); + if candidate.is_empty() + || candidate.split('.').any(|segment| { + segment.is_empty() + || !segment.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_' + }) + }) + { + None + } else { + Some(candidate) + } +} + /// Build the final system prompt by appending the fixed trailing /// section to `body`. The Rust side owns the layout (blank-line /// separators, trailing-whitespace trim); each section's header + body @@ -281,7 +260,6 @@ impl ToolCapabilities { /// per-pack without touching this function. fn append_trailing_section( body: &str, - env: &Environment<'static>, ctx: &SystemPromptContext<'_>, prompts: &PromptCatalog, scope: &Scope, @@ -315,12 +293,15 @@ fn append_trailing_section( } for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) { out.push('\n'); - let template = env - .get_template(&instruction.prompt_ref) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; - let section = template - .render(ctx.to_minijinja_value()) - .map_err(|e| SystemPromptError::Render(e.to_string()))?; + let prompt_ref = exact_prompt_name(&instruction.prompt_ref).ok_or_else(|| { + SystemPromptError::Render(format!( + "feature instruction must be an exact catalog-root dotted Prompt name: {}", + instruction.prompt_ref + )) + })?; + let section = prompts + .render_name(&prompt_ref, ctx.to_minijinja_value()) + .map_err(|error| SystemPromptError::Render(error.to_string()))?; let section = section.trim_end_matches(&['\n', ' '][..]); if !section.trim().is_empty() { out.push_str(section); @@ -335,13 +316,6 @@ fn append_trailing_section( Ok(out) } -/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests -/// can construct a synthetic context without going through a full Worker. -#[doc(hidden)] -pub fn __instruction_ref_for_tests(raw: &str, loader: &PromptLoader) -> Option { - loader.parse_ref(raw, None).ok() -} - #[cfg(test)] mod tests { use super::*; @@ -354,487 +328,91 @@ mod tests { } fn build_scope(dir: &Path) -> Scope { - let cfg = ScopeConfig { + Scope::from_config(&ScopeConfig { allow: vec![ScopeRule { target: dir.to_path_buf(), permission: Permission::Write, recursive: true, }], deny: Vec::new(), - }; - Scope::from_config(&cfg).unwrap() - } - - fn ctx<'a>( - cwd: &'a Path, - scope: &'a Scope, - tools: Vec, - agents_md: Option, - ) -> SystemPromptContext<'a> { - SystemPromptContext { - now: fixed_now(), - cwd: cwd.display().to_string().into(), - language: manifest::defaults::WORKER_LANGUAGE, - scope, - tool_names: tools, - feature_instructions: &[], - agents_md, - resident_summary: None, - prompts: test_prompts(), - } - } - - fn ctx_with_summary<'a>( - cwd: &'a Path, - scope: &'a Scope, - summary: Option<&'a str>, - ) -> SystemPromptContext<'a> { - SystemPromptContext { - now: fixed_now(), - cwd: cwd.display().to_string().into(), - language: manifest::defaults::WORKER_LANGUAGE, - scope, - tool_names: Vec::new(), - feature_instructions: &[], - agents_md: None, - resident_summary: summary, - prompts: test_prompts(), - } - } - - fn memory_tool_names() -> Vec { - ["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"] - .into_iter() - .map(String::from) - .collect() - } - - fn ticket_instruction() -> FeatureInstructionDeclaration { - FeatureInstructionDeclaration::new( - crate::feature::FeatureInstructionId::builtin("ticket.workflow"), - "$yoi/common/tickets", - "Ticket workflow guidance", - ) + }) .unwrap() } - fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration { - FeatureInstructionDeclaration::new( - crate::feature::FeatureInstructionId::builtin("worker.orchestration"), - "$yoi/common/worker-orchestration", - "Worker orchestration guidance", - ) - .unwrap() + fn context<'a>( + cwd: &'a Path, + scope: &'a Scope, + prompts: &'a PromptCatalog, + ) -> SystemPromptContext<'a> { + SystemPromptContext { + now: fixed_now(), + cwd: cwd.to_string_lossy(), + tool_names: vec!["Read".into(), "Write".into()], + scope, + agents_md: Some("PROJECT RULES".into()), + resident_summary: Some("DURABLE MEMORY"), + language: "Japanese", + feature_instructions: &[], + prompts, + } } - /// Lazily-initialised builtin catalog shared across system-prompt - /// tests, so every `ctx()` can hand out a `&'static PromptCatalog` - /// reference without forcing test bodies to create one per call. - fn test_prompts() -> &'static PromptCatalog { - use std::sync::OnceLock; - static CELL: OnceLock> = OnceLock::new(); - CELL.get_or_init(|| PromptCatalog::builtins_only().unwrap()) - .as_ref() - } - - fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) { + #[test] + fn exact_catalog_name_renders_once_with_trailing_sections() { let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join(file_name), body).unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - (tmp, loader) - } - - #[test] - fn instruction_default_resolves_to_yoi_default() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx(dir.path(), &scope, memory_tool_names(), None)) + let scope = build_scope(tmp.path()); + let prompts = PromptCatalog::builtins_only().unwrap(); + let template = + SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap(); + let rendered = template + .render(&context(tmp.path(), &scope, &prompts)) .unwrap(); - // Builtin default body must expose the tool and language policies. - assert!(rendered.contains("### Memory")); - assert!(rendered.contains("small targeted `MemoryQuery`")); - assert!(rendered.contains("Strong lookup triggers include")); - assert!(rendered.contains("MemoryReadDocument")); - assert!(rendered.contains("Do not query memory every turn")); - assert!(rendered.contains("MemoryUpdateDocument")); - assert!(rendered.contains("## Language")); - assert!(rendered.contains("`language`: `match the user's language")); - // Trailing section must be present. + assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15")); assert!(rendered.contains("## Working boundaries")); - assert!(rendered.contains("Readable:")); + assert!(rendered.contains("PROJECT RULES")); + assert!(rendered.contains("DURABLE MEMORY")); } #[test] - fn instruction_default_omits_memory_guidance_without_memory_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) + fn workspace_override_is_visible_through_builtin_static_include() { + let mut templates = builtin_prompt_templates().unwrap(); + templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into()); + let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap(); + let loader = + PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone()); + let prompts = PromptCatalog::from_projection(projection).unwrap(); + let template = SystemPromptTemplate::parse("default", loader).unwrap(); + let tmp = TempDir::new().unwrap(); + let scope = build_scope(tmp.path()); + let rendered = template + .render(&context(tmp.path(), &scope, &prompts)) .unwrap(); - - assert!(!rendered.contains("### Memory")); - assert!(!rendered.contains("MemoryQuery")); - assert!(!rendered.contains("MemoryRead")); - assert!(!rendered.contains("MemoryWrite")); - assert!(!rendered.contains("MemoryEdit")); - assert!(!rendered.contains("MemoryDelete")); - assert!(rendered.contains("## Language")); - assert!(rendered.contains("## Working boundaries")); + assert!(rendered.contains("WORKSPACE OVERRIDE")); } #[test] - fn ticket_guidance_is_included_for_ticket_feature_instruction() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## Ticket workflow")); - assert!(rendered.contains("available typed Ticket tools as the authority")); - assert!(rendered.contains("Do not invoke a Ticket CLI")); - assert!(rendered.contains("Distinguish implementation completion")); - } - - #[test] - fn feature_instruction_is_appended_even_when_template_does_not_include_it() { - let (_tmp, loader) = user_loader_with("minimal.md", "BASE ONLY"); - let tmpl = SystemPromptTemplate::parse("$user/minimal", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec![], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.starts_with("BASE ONLY")); - assert!(rendered.contains("## Ticket workflow")); - } - - #[test] - fn ticket_guidance_is_omitted_without_ticket_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) - .unwrap(); - - assert!(!rendered.contains("## Ticket workflow")); - assert!(!rendered.contains("Do not invoke a Ticket CLI")); - } - - #[test] - fn ticket_role_instructions_include_feature_ticket_guidance() { - let loader = PromptLoader::builtins_only(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [ticket_instruction()]; - - for role in ["intake", "orchestrator", "coder", "reviewer"] { - let tmpl = - SystemPromptTemplate::parse(&format!("$yoi/role/{role}"), loader.clone()).unwrap(); - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## Ticket workflow"), "role: {role}"); + fn rejects_legacy_prefix_relative_and_missing_names() { + for reference in ["legacy/custom", "custom.md", "../custom", "missing"] { assert!( - rendered.contains("Do not invoke a Ticket CLI"), - "role: {role}" + SystemPromptTemplate::parse(reference, PromptCatalogSource::builtins_only()) + .is_err() ); } } #[test] - fn memory_guidance_names_only_available_memory_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["MemoryQuery".into(), "MemoryReadDocument".into()], - None, - )) - .unwrap(); - - assert!(rendered.contains("### Memory")); - assert!(rendered.contains("small targeted `MemoryQuery`")); - assert!(rendered.contains("MemoryReadDocument")); - assert!(!rendered.contains("MemoryUpdateDocument")); - assert!(!rendered.contains("MemoryEdit")); - assert!(!rendered.contains("MemoryDelete")); - } - - #[test] - fn worker_orchestration_guidance_is_included_for_feature_instruction() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let instructions = [sub_worker_orchestration_instruction()]; - let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None); - ctx.feature_instructions = &instructions; - let rendered = tmpl.render(&ctx).unwrap(); - - assert!(rendered.contains("## SubWorker orchestration")); - assert!(rendered.contains("SubWorker 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 state, diff, and test results")); - assert!(rendered.contains("not scheduler or auto-maintain authorization")); - assert!(rendered.contains("bypass user/Ticket authorization")); - } - - #[test] - fn worker_orchestration_guidance_is_omitted_without_sub_worker_management_tools() { - let loader = PromptLoader::builtins_only(); - let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into(), "MemoryReadDocument".into()], - None, - )) - .unwrap(); - - assert!(!rendered.contains("## Worker orchestration")); - assert!(!rendered.contains("spawned Worker 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")); - } - - #[test] - fn instruction_prefix_addressing_user() { - let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}"); - let tmpl = SystemPromptTemplate::parse("$user/greet", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.starts_with("HELLO from")); - assert!(rendered.contains("## Working boundaries")); - } - - #[test] - fn instruction_prefix_addressing_workspace() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("ws.md"), "WS {{ date }}").unwrap(); - let loader = PromptLoader::new(None, Some(tmp.path().to_path_buf())); - let tmpl = SystemPromptTemplate::parse("$workspace/ws", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.starts_with("WS 2026-04-15")); - } - - #[test] - fn include_unqualified_resolves_relative_to_current_prefix() { - let tmp = TempDir::new().unwrap(); - // parent.md and sibling.md both under the user root. - std::fs::write( - tmp.path().join("parent.md"), - "PARENT\n{% include \"sibling\" %}", - ) - .unwrap(); - std::fs::write(tmp.path().join("sibling.md"), "SIBLING-BODY").unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/parent", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("PARENT")); - assert!(rendered.contains("SIBLING-BODY")); - } - - #[test] - fn include_unqualified_from_subdirectory_resolves_in_same_dir() { - let tmp = TempDir::new().unwrap(); - std::fs::create_dir(tmp.path().join("common")).unwrap(); - std::fs::write( - tmp.path().join("common/header.md"), - "HEADER\n{% include \"nested\" %}", - ) - .unwrap(); - std::fs::write(tmp.path().join("common/nested.md"), "NESTED-OK").unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/common/header", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("HEADER")); - assert!(rendered.contains("NESTED-OK")); - } - - #[test] - fn include_explicit_prefix_overrides_relative() { - let tmp = TempDir::new().unwrap(); - std::fs::write( - tmp.path().join("root.md"), - "U-ROOT\n{% include \"$yoi/common/tool-usage\" %}", - ) - .unwrap(); - let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None); - let tmpl = SystemPromptTemplate::parse("$user/root", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["Read".into(), "Edit".into()], - None, - )) - .unwrap(); - assert!(rendered.contains("U-ROOT")); - // Pulled in from the builtin tool-usage asset. - assert!(rendered.contains("Read")); - } - - #[test] - fn prefix_with_missing_file_is_hard_error() { - let loader = PromptLoader::builtins_only(); - let err = SystemPromptTemplate::parse("$yoi/definitely-missing", loader).unwrap_err(); - assert!(matches!(err, SystemPromptError::LoaderResolve(_))); - } - - #[test] - fn parse_fails_on_syntax_error() { - let (_tmp, loader) = user_loader_with("broken.md", "{{ unclosed"); - let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err(); - assert!(matches!(err, SystemPromptError::Parse(_))); - } - - #[test] - fn render_fails_on_undefined_variable() { - let (_tmp, loader) = user_loader_with("ghost.md", "{{ ghost }}"); - let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let err = tmpl - .render(&ctx(dir.path(), &scope, vec![], None)) - .unwrap_err(); - assert!(matches!(err, SystemPromptError::Render(_))); - } - - #[test] - fn render_substitutes_date_cwd_tools() { - let (_tmp, loader) = user_loader_with( - "vars.md", - "date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}", - ); - let tmpl = SystemPromptTemplate::parse("$user/vars", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec!["alpha".into(), "beta".into()], - None, - )) - .unwrap(); - assert!(rendered.contains("date=2026-04-15")); - assert!(rendered.contains(&format!("cwd={}", dir.path().display()))); - assert!(rendered.contains("tools=alpha,beta")); - } - - #[test] - fn trailing_section_always_contains_scope_summary() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(rendered.contains("## Working boundaries")); - assert!(rendered.contains("Readable:")); - assert!(rendered.contains("Writable:")); - } - - #[test] - fn trailing_section_contains_agents_md_when_present() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx( - dir.path(), - &scope, - vec![], - Some("PROJECT DOCS".into()), - )) - .unwrap(); - assert!(rendered.contains("## Project instructions (AGENTS.md)")); - assert!(rendered.contains("PROJECT DOCS")); - } - - #[test] - fn trailing_section_omits_agents_md_when_absent() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); - assert!(!rendered.contains("AGENTS.md")); - assert!(!rendered.contains("Project instructions")); - } - - #[test] - fn trailing_section_renders_resident_summary_body() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx_with_summary( - dir.path(), - &scope, - Some("Persistent summary body"), - )) - .unwrap(); - assert!(rendered.contains("## Resident memory summary")); - assert!(rendered.contains("Persistent summary body")); - } - - #[test] - fn trailing_section_omits_resident_summary_when_none_or_empty() { - let (_tmp, loader) = user_loader_with("body.md", "BODY"); - let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = tmpl - .render(&ctx_with_summary(dir.path(), &scope, None)) - .unwrap(); - assert!(!rendered.contains("Resident memory summary")); - - let rendered = tmpl - .render(&ctx_with_summary(dir.path(), &scope, Some(" \n"))) - .unwrap(); - assert!(!rendered.contains("Resident memory summary")); + fn role_templates_are_selected_without_filesystem_resolution() { + let loader = PromptCatalogSource::builtins_only(); + for role in [ + "role.coder", + "role.intake", + "role.orchestrator", + "role.reviewer", + ] { + assert!( + SystemPromptTemplate::parse(role, loader.clone()).is_ok(), + "{role}" + ); + } } } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 6934b252..5f29ef5f 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -20,7 +20,7 @@ use manifest::{ use serde::Deserialize; use tokio::sync::mpsc; -use crate::PromptLoader; +use crate::PromptCatalogSource; use crate::controller::register_worker_tools; use crate::internal_worker::{ EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session, @@ -44,7 +44,7 @@ struct SubWorkerSpawnInput { /// unambiguous profile slug. Raw/path selectors are rejected. #[serde(default)] profile: Option, - /// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`). + /// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`). #[serde(default)] instruction: Option, /// Child process/tool working directory. This is not the runtime workspace @@ -276,7 +276,7 @@ pub struct SubWorkerSpawnTool { /// child config from reusable fields here, and selected profiles are /// merged into the same internal handoff shape before launch. spawner_manifest: WorkerManifest, - prompt_loader: PromptLoader, + prompt_loader: PromptCatalogSource, /// Compact selector list shared by tool description and diagnostics. available_profiles: AvailableProfiles, /// Spawner's runtime scope. After a successful spawn, the @@ -310,7 +310,7 @@ impl SubWorkerSpawnTool { spawner_cwd: PathBuf, registry: Arc, spawner_manifest: WorkerManifest, - prompt_loader: PromptLoader, + prompt_loader: PromptCatalogSource, available_profiles: AvailableProfiles, spawner_scope: SharedScope, delegation_scope: DelegationScope, @@ -827,7 +827,6 @@ fn build_spawn_config_json( let config = WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(name.to_string()), - prompt_pack: None, }, model: model.clone(), engine: EngineManifestConfig { @@ -870,7 +869,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi WorkerManifestConfig { worker: WorkerMetaConfig { name: Some(manifest.worker.name.clone()), - prompt_pack: manifest.worker.prompt_pack.clone(), }, model: manifest.model.clone(), engine: EngineManifestConfig { @@ -1010,7 +1008,7 @@ fn sub_worker_spawn_tool_impl( spawner_cwd.clone(), registry.clone(), spawner_manifest.clone(), - prompts.loader(), + prompts.source(), available_profiles, spawner_scope.clone(), DelegationScope::from_config(&spawner_manifest.delegation_scope) @@ -1086,7 +1084,7 @@ model_id = "reviewer-model" kind = "none" [engine] -instruction = "$yoi/reviewer" +instruction = "role.reviewer" language = "Reviewerish" max_tokens = 3333 @@ -1136,14 +1134,7 @@ extract_threshold = 4000 let observed_parent_write_revoked = Arc::new(AtomicBool::new(false)); let observed_instruction_override = Arc::new(AtomicBool::new(false)); let fail_requests = Arc::new(AtomicBool::new(false)); - let workspace_prompts = runtime.path().join("workspace-prompts"); - std::fs::create_dir_all(&workspace_prompts).unwrap(); - std::fs::write( - workspace_prompts.join("custom-reviewer.md"), - "WORKSPACE REVIEWER OVERRIDE", - ) - .unwrap(); - let prompt_loader = PromptLoader::new(None, Some(workspace_prompts)); + let prompt_loader = PromptCatalogSource::builtins_only(); let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8); let tool = SubWorkerSpawnTool::new( "parent".into(), @@ -1170,7 +1161,7 @@ extract_threshold = 4000 let input = serde_json::json!({ "name": "reviewer-child", "profile": "project:reviewer", - "instruction": "$workspace/custom-reviewer", + "instruction": "role.reviewer", "task": "review immutable commit", "scope": [{ "target": workspace_root.clone(), @@ -1468,7 +1459,7 @@ extract_threshold = 4000 request .system_prompt .as_deref() - .is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")), + .is_some_and(|prompt| prompt.contains("review")), Ordering::SeqCst, ); if self.fail_requests.load(Ordering::SeqCst) { @@ -1515,7 +1506,6 @@ extract_threshold = 4000 WorkerManifestConfig { worker: WorkerMetaConfig { name: Some("parent".into()), - prompt_pack: None, }, model: ModelManifest { scheme: Some(SchemeKind::Anthropic), @@ -1524,7 +1514,7 @@ extract_threshold = 4000 ..Default::default() }, engine: EngineManifestConfig { - instruction: Some("$yoi/parent".into()), + instruction: Some("default".into()), language: Some("Parentish".into()), max_tokens: Some(1234), stop_sequences: Some(vec!["STOP".into()]), @@ -1604,7 +1594,7 @@ scheme = "anthropic" model_id = "coder-model" [engine] -instruction = "$yoi/coder" +instruction = "role.coder" language = "Coderish" max_tokens = 2222 "#; @@ -1618,7 +1608,7 @@ scheme = "anthropic" model_id = "reviewer-model" [engine] -instruction = "$yoi/reviewer" +instruction = "role.reviewer" language = "Reviewerish" max_tokens = 3333 "#; @@ -1635,8 +1625,7 @@ max_tokens = 3333 ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic)); @@ -1658,8 +1647,7 @@ max_tokens = 3333 ref_: Some("anthropic/claude-sonnet-4-6".into()), ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.model.ref_.as_deref(), @@ -1680,7 +1668,7 @@ max_tokens = 3333 }]; let config_json = - build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap(); + build_spawn_config_json("child", "default", &scope, &model, true).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert_eq!( parsed.session.as_ref().and_then(|s| s.record_event_trace), @@ -1700,8 +1688,7 @@ max_tokens = 3333 ref_: Some("anthropic/claude-sonnet-4-6".into()), ..Default::default() }; - let config_json = - build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap(); + let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap(); let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap(); assert!(parsed.session.is_none()); @@ -1737,7 +1724,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("child-default")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer")); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.scope.allow, scope); assert!(config.scope.deny.is_empty()); @@ -1776,7 +1763,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("review-child")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer")); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.engine.max_tokens, Some(3333)); assert_eq!(config.scope.allow, scope); @@ -1810,7 +1797,7 @@ max_tokens = 3333 assert_eq!(config.worker.name.as_deref(), Some("inherited-child")); assert_eq!(config.model.model_id.as_deref(), Some("parent-model")); - assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent")); + assert_eq!(config.engine.instruction.as_deref(), Some("default")); assert_eq!(config.engine.language.as_deref(), Some("Parentish")); assert_eq!(config.engine.max_tokens, Some(1234)); assert_eq!( @@ -1845,15 +1832,12 @@ max_tokens = 3333 &available, &project, "override-child", - Some("$user/custom-reviewer"), + Some("role.reviewer"), &scope, SpawnProfileSelector::Default, ); - assert_eq!( - config.engine.instruction.as_deref(), - Some("$user/custom-reviewer") - ); + assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer")); assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model")); assert_eq!(config.engine.language.as_deref(), Some("Reviewerish")); assert_eq!(config.engine.max_tokens, Some(3333)); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index bab0c157..073dbcfb 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -53,7 +53,7 @@ use crate::internal_worker::{ const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction"; const COMPACTION_BLOCK_ID: &str = "compact"; const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration"; -const WORKER_ORCHESTRATION_PROMPT_REF: &str = "$yoi/common/worker-orchestration"; +const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration"; fn worker_orchestration_instruction() -> FeatureInstructionDeclaration { FeatureInstructionDeclaration::new( @@ -68,7 +68,7 @@ use crate::ipc::interceptor::WorkerInterceptor; use crate::ipc::notify_buffer::NotifyBuffer; use crate::prompt::agents_md::read_agents_md; use crate::prompt::catalog::{CatalogError, PromptCatalog}; -use crate::prompt::loader::PromptLoader; +use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError}; @@ -4243,7 +4243,7 @@ where pub async fn from_manifest( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4255,7 +4255,7 @@ where pub async fn from_manifest_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4352,7 +4352,7 @@ where pub(crate) async fn from_internal_manifest_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, client_override: Option>, @@ -4435,7 +4435,7 @@ where pub async fn from_manifest_spawned( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, callback_socket: PathBuf, ) -> Result { let cwd = current_cwd()?; @@ -4455,7 +4455,7 @@ where pub async fn from_manifest_spawned_with_context( manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, callback_socket: PathBuf, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, @@ -4544,7 +4544,7 @@ where worker_name: &str, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4564,7 +4564,7 @@ where worker_name: &str, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4613,7 +4613,7 @@ where worker_name: &str, fallback: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4683,7 +4683,7 @@ where segment_id: SegmentId, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, ) -> Result { let cwd = current_cwd()?; let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone()); @@ -4705,7 +4705,7 @@ where segment_id: SegmentId, manifest: WorkerManifest, store: St, - loader: PromptLoader, + loader: PromptCatalogSource, workspace_context: WorkerWorkspaceContext, filesystem_authority: WorkerFilesystemAuthority, ) -> Result { @@ -4903,7 +4903,7 @@ where pub async fn from_manifest_toml(toml: &str, store: St) -> Result { let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?; let manifest = WorkerManifest::try_from(config).map_err(WorkerError::ManifestResolve)?; - Self::from_manifest(manifest, store, PromptLoader::builtins_only()).await + Self::from_manifest(manifest, store, PromptCatalogSource::builtins_only()).await } } @@ -5588,7 +5588,7 @@ fn delegated_write_rule_to_deny(rule: WorkerSpawnedScopeRule) -> Option; @@ -96,9 +99,9 @@ permission = "write" /// Build a Worker with a synthetic instruction template. /// -/// Writes `body` to a temp user-prompts dir under `$user/test`, builds a -/// PromptLoader pointing at it, parses the template, and installs it on -/// a Worker constructed directly via `Worker::new`. +/// Builds an immutable effective catalog with `body` at the exact `test` +/// Prompt name and installs that parsed template on a directly constructed +/// Worker. async fn make_worker_with_body( body: &str, client: MockClient, @@ -117,10 +120,15 @@ async fn make_worker_with_body( let scope = worker::Scope::writable(&pwd).unwrap(); std::mem::forget(pwd_tmp); - let user_prompts_tmp = tempfile::tempdir().unwrap(); - std::fs::write(user_prompts_tmp.path().join("test.md"), body).unwrap(); - let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None); - std::mem::forget(user_prompts_tmp); + let mut templates = PromptCatalog::builtins_only() + .unwrap() + .projection() + .templates + .clone(); + templates.insert("test".to_string(), body.to_string()); + let projection = + EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap(); + let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection); let worker = Engine::new(client); let mut worker = Worker::new( @@ -133,7 +141,7 @@ async fn make_worker_with_body( ) .await?; - let template = SystemPromptTemplate::parse("$user/test", loader) + let template = SystemPromptTemplate::parse("test", loader) .map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?; worker.set_system_prompt_template(template); @@ -146,15 +154,15 @@ async fn make_worker_with_body( #[tokio::test] async fn template_parse_rejects_invalid_syntax() { - let user_prompts_tmp = tempfile::tempdir().unwrap(); - std::fs::write(user_prompts_tmp.path().join("broken.md"), "{{ unclosed").unwrap(); - let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None); - let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err(); - let worker_err: WorkerError = WorkerError::InvalidSystemPromptTemplate { source: err }; - assert!(matches!( - worker_err, - WorkerError::InvalidSystemPromptTemplate { .. } - )); + let mut templates = PromptCatalog::builtins_only() + .unwrap() + .projection() + .templates + .clone(); + templates.insert("broken".to_string(), "{{ unclosed".to_string()); + let error = + EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err(); + assert!(error.to_string().contains("does not compile")); } #[tokio::test] diff --git a/docs/development/work-items.md b/docs/development/work-items.md index fdd78af5..df27693d 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -121,19 +121,19 @@ root = ".yoi/tickets" [ticket.roles.intake] profile = "project:intake" -launch_prompt = "$workspace/ticket/intake/launch" +launch_prompt = "ticket.intake.launch" [ticket.roles.orchestrator] profile = "project:orchestrator" -launch_prompt = "$workspace/ticket/orchestrator/launch" +launch_prompt = "ticket.orchestrator.launch" [ticket.roles.coder] profile = "project:coder" -launch_prompt = "$workspace/ticket/coder/launch" +launch_prompt = "ticket.coder.launch" [ticket.roles.reviewer] profile = "project:reviewer" -launch_prompt = "$workspace/ticket/reviewer/launch" +launch_prompt = "ticket.reviewer.launch" ``` Fixed roles are: diff --git a/docs/manifest.toml b/docs/manifest.toml index 14d1a465..28ce2e2f 100644 --- a/docs/manifest.toml +++ b/docs/manifest.toml @@ -30,12 +30,6 @@ # 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。 name = "example-agent" -# 任意。デフォルト: なし。 -# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。 -# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`) -# とは別系統の単なるファイルパス。 -# prompt_pack = "./prompts.local.toml" - # ===== [model] ============================================================== # LLM モデル設定。次の 3 形態を受ける: @@ -95,10 +89,9 @@ ref = "anthropic/claude-sonnet-4-6" # ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。 [engine] -# 任意。デフォルト: "$yoi/default" (`defaults::DEFAULT_INSTRUCTION`)。 -# システムプロンプト本体の `PromptLoader` 参照。 -# プレフィクス: "$yoi/..." | "$user/..." | "$workspace/..." -# instruction = "$yoi/default" +# 任意。デフォルト: "default" (`defaults::DEFAULT_INSTRUCTION`)。 +# effective Prompt catalog の exact dotted name を選択する。 +# instruction = "default" # 任意。デフォルト: なし (プロバイダ任せ)。 # 1 レスポンスあたりの出力 token 上限。 diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl index 5a064bd7..74f3795b 100644 --- a/resources/profiles/coder.dcdl +++ b/resources/profiles/coder.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "coder"; description = "Ticket implementation coder profile."; scope = "workspace_write"; + engine = { instruction = "role.coder"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index ae69be4b..1778c930 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "intake"; description = "Ticket intake profile."; scope = "workspace_write"; + engine = { instruction = "role.intake"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index 74549402..feb7f402 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "orchestrator"; description = "Ticket orchestrator profile."; scope = "workspace_write"; + engine = { instruction = "role.orchestrator"; }; feature = { task = { enabled = true; }; diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl index e86a7406..82270494 100644 --- a/resources/profiles/reviewer.dcdl +++ b/resources/profiles/reviewer.dcdl @@ -2,6 +2,7 @@ import "./base.dcdl" // { slug = "reviewer"; description = "Ticket review profile."; scope = "workspace_read"; + engine = { instruction = "role.reviewer"; }; feature = { task = { enabled = true; }; diff --git a/resources/prompts/catalog.dcdl b/resources/prompts/catalog.dcdl new file mode 100644 index 00000000..c6e3599f --- /dev/null +++ b/resources/prompts/catalog.dcdl @@ -0,0 +1,71 @@ +# Deterministic builtin Prompt source tree. Markdown imports use the shared +# { frontmatter, content } contract; every leaf below selects only .content. +# `default_prompt` is the DCDL source alias for the effective catalog's +# reserved dotted name `default`. +let +defaultDocument = import "./default.md"; +commonLanguage = import "./common/language.md"; +commonTickets = import "./common/tickets.md"; +commonToolUsage = import "./common/tool-usage.md"; +commonWorkerObservation = import "./common/worker-observation.md"; +commonWorkerOrchestration = import "./common/worker-orchestration.md"; +commonWorkspace = import "./common/workspace.md"; +commonWriting = import "./common/writing.md"; +roleCoder = import "./role/coder.md"; +roleIntake = import "./role/intake.md"; +roleOrchestrator = import "./role/orchestrator.md"; +roleReviewer = import "./role/reviewer.md"; +internalCompactSystem = import "./internal/compact_system.md"; +internalFlowVerifierSystem = import "./internal/flow_verifier_system.md"; +internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md"; +internalMemoryExtractSystem = import "./internal/memory_extract_system.md"; +internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md"; +internalNotifyWrapper = import "./internal/notify_wrapper.md"; +internalInterruptToolResultSummary = import "./internal/interrupt_tool_result_summary.md"; +internalInterruptSystemNote = import "./internal/interrupt_system_note.md"; +internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md"; +internalAgentsMdSection = import "./internal/agents_md_section.md"; +internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md"; +internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md"; +panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md"; +workerTicketEventCompanionNotice = import "./worker/ticket_event_companion_notice.md"; +in +{ + default_prompt = defaultDocument.content; + common = { + language = commonLanguage.content; + tickets = commonTickets.content; + tool_usage = commonToolUsage.content; + worker_observation = commonWorkerObservation.content; + worker_orchestration = commonWorkerOrchestration.content; + workspace = commonWorkspace.content; + writing = commonWriting.content; + }; + role = { + coder = roleCoder.content; + intake = roleIntake.content; + orchestrator = roleOrchestrator.content; + reviewer = roleReviewer.content; + }; + internal = { + compact_system = internalCompactSystem.content; + flow_verifier_system = internalFlowVerifierSystem.content; + memory_consolidation_system = internalMemoryConsolidationSystem.content; + memory_extract_system = internalMemoryExtractSystem.content; + workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content; + notify_wrapper = internalNotifyWrapper.content; + interrupt_tool_result_summary = internalInterruptToolResultSummary.content; + interrupt_system_note = internalInterruptSystemNote.content; + working_boundaries_section = internalWorkingBoundariesSection.content; + agents_md_section = internalAgentsMdSection.content; + resident_memory_summary_section = internalResidentMemorySummarySection.content; + worker_orchestration_guidance_section = commonWorkerOrchestration.content; + sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content; + }; + panel = { + orchestrator_idle_queue_notice = panelOrchestratorIdleQueueNotice.content; + }; + worker = { + ticket_event_companion_notice = workerTicketEventCompanionNotice.content; + }; +} diff --git a/resources/prompts/common/worker-orchestration.md b/resources/prompts/common/worker-orchestration.md index d7147789..6bf73286 100644 --- a/resources/prompts/common/worker-orchestration.md +++ b/resources/prompts/common/worker-orchestration.md @@ -1,3 +1,4 @@ + --- ## SubWorker orchestration diff --git a/resources/prompts/default.md b/resources/prompts/default.md index 8f88470e..81c945e6 100644 --- a/resources/prompts/default.md +++ b/resources/prompts/default.md @@ -2,11 +2,11 @@ You are here as an agent of the "yoi system". Stay precise, edit code directly when asked, and avoid speculative refactoring. -{% include "common/workspace" %} +{% include "common.workspace" %} -{% include "common/tool-usage" %} +{% include "common.tool_usage" %} -{% include "common/language" %} +{% include "common.language" %} -{% include "common/writing" %} +{% include "common.writing" %} diff --git a/resources/prompts/internal.toml b/resources/prompts/internal.toml deleted file mode 100644 index bbcd711e..00000000 --- a/resources/prompts/internal.toml +++ /dev/null @@ -1,71 +0,0 @@ -# Worker internal prompts (builtin pack). -# -# Values are minijinja template strings. Use `{% include "$prefix/..." %}` -# to pull in long text from the $yoi / $user / $workspace prompt -# libraries. -# -# Every key here MUST correspond to a `WorkerPrompt` variant; missing or -# extra keys cause a build-time error (see `crates/worker/build.rs`). - -[prompt] -compact_system = "{% include \"$yoi/internal/compact_system\" %}" - -memory_extract_system = "{% include \"$yoi/internal/memory_extract_system\" %}" - -memory_consolidation_system = "{% include \"$yoi/internal/memory_consolidation_system\" %}" - -flow_verifier_system = "{% include \"$yoi/internal/flow_verifier_system\" %}" - -notify_wrapper = """\ -[Notification] -{{ message }} - -This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.\ -""" - -interrupt_tool_result_summary = "[Interrupted by user]" - -interrupt_system_note = "[The previous turn was interrupted by the user. The user's next request follows.]" - -working_boundaries_section = """\ ---- -## Working boundaries - -{{ scope_summary }}\ -""" - -agents_md_section = """\ ---- -## Project instructions (AGENTS.md) - -{{ agents_md }}\ -""" - -resident_memory_summary_section = """\ ---- -## Resident memory summary - -The following is the current durable session/workspace summary. Treat it as background context; it is not a user request. - -{{ summary }}\ -""" - - -worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}" - -ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}" - -sub_worker_spawn_tool_description = """\ -Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits. - -Optional `cwd`: when provided, it is the Internal SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. - -Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope. - -Default profile: {{ default_profile }} -Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope. -Available registry profiles: -{{ available_profiles }}{% if profile_diagnostic %} - -Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\ -""" diff --git a/resources/prompts/internal/agents_md_section.md b/resources/prompts/internal/agents_md_section.md new file mode 100644 index 00000000..d9279c4d --- /dev/null +++ b/resources/prompts/internal/agents_md_section.md @@ -0,0 +1,5 @@ + +--- +## Project instructions (AGENTS.md) + +{{ agents_md }} \ No newline at end of file diff --git a/resources/prompts/internal/interrupt_system_note.md b/resources/prompts/internal/interrupt_system_note.md new file mode 100644 index 00000000..3d4eca60 --- /dev/null +++ b/resources/prompts/internal/interrupt_system_note.md @@ -0,0 +1 @@ +[The previous turn was interrupted by the user. The user's next request follows.] \ No newline at end of file diff --git a/resources/prompts/internal/interrupt_tool_result_summary.md b/resources/prompts/internal/interrupt_tool_result_summary.md new file mode 100644 index 00000000..5c191724 --- /dev/null +++ b/resources/prompts/internal/interrupt_tool_result_summary.md @@ -0,0 +1 @@ +[Interrupted by user] \ No newline at end of file diff --git a/resources/prompts/internal/notify_wrapper.md b/resources/prompts/internal/notify_wrapper.md new file mode 100644 index 00000000..d73ad961 --- /dev/null +++ b/resources/prompts/internal/notify_wrapper.md @@ -0,0 +1,4 @@ +[Notification] +{{ message }} + +This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point. \ No newline at end of file diff --git a/resources/prompts/internal/resident_memory_summary_section.md b/resources/prompts/internal/resident_memory_summary_section.md new file mode 100644 index 00000000..56e7447c --- /dev/null +++ b/resources/prompts/internal/resident_memory_summary_section.md @@ -0,0 +1,7 @@ + +--- +## Resident memory summary + +The following is the current durable session/workspace summary. Treat it as background context; it is not a user request. + +{{ summary }} \ No newline at end of file diff --git a/resources/prompts/internal/sub_worker_spawn_tool_description.md b/resources/prompts/internal/sub_worker_spawn_tool_description.md new file mode 100644 index 00000000..a652f551 --- /dev/null +++ b/resources/prompts/internal/sub_worker_spawn_tool_description.md @@ -0,0 +1,12 @@ +Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits. + +Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children. + +Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope. + +Default profile: {{ default_profile }} +Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope. +Available registry profiles: +{{ available_profiles }}{% if profile_diagnostic %} + +Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %} \ No newline at end of file diff --git a/resources/prompts/internal/working_boundaries_section.md b/resources/prompts/internal/working_boundaries_section.md new file mode 100644 index 00000000..94cbf5dd --- /dev/null +++ b/resources/prompts/internal/working_boundaries_section.md @@ -0,0 +1,5 @@ + +--- +## Working boundaries + +{{ scope_summary }} \ No newline at end of file