From 381d31a1dcc4f08896fdff615b5642d5091fa92f Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 16 Apr 2026 11:16:16 +0900 Subject: [PATCH] =?UTF-8?q?instruction=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E5=AE=9A=E7=BE=A9=E3=83=BB=E8=AA=AD=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 1 + crates/manifest/src/config.rs | 9 +- crates/manifest/src/defaults.rs | 5 + crates/manifest/src/lib.rs | 22 +- crates/manifest/src/scope.rs | 71 ++- crates/pod/src/factory.rs | 23 +- crates/pod/src/pod.rs | 42 +- crates/pod/src/prompt_loader.rs | 378 +++++++++++++--- crates/pod/src/system_prompt.rs | 416 ++++++++++++------ .../pod/tests/system_prompt_template_test.rs | 147 +++---- resources/prompts/coder.md | 7 - resources/prompts/common/tool-usage.md | 7 +- resources/prompts/common/workspace.md | 2 + resources/prompts/default.md | 5 + resources/prompts/planner.md | 7 - resources/prompts/reviewer.md | 7 - tickets/instruction-file-refs.md | 184 ++++++++ 17 files changed, 953 insertions(+), 380 deletions(-) delete mode 100644 resources/prompts/coder.md create mode 100644 resources/prompts/common/workspace.md create mode 100644 resources/prompts/default.md delete mode 100644 resources/prompts/planner.md delete mode 100644 resources/prompts/reviewer.md create mode 100644 tickets/instruction-file-refs.md diff --git a/TODO.md b/TODO.md index 9c70f4fb..698992fb 100644 --- a/TODO.md +++ b/TODO.md @@ -5,6 +5,7 @@ - [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md) - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) +- [ ] instruction のファイル参照化と system prompt の hardcode wrap → [tickets/instruction-file-refs.md](tickets/instruction-file-refs.md) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md) - [ ] TUI 拡充 - [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md) diff --git a/crates/manifest/src/config.rs b/crates/manifest/src/config.rs index 1edce78e..246b0d5c 100644 --- a/crates/manifest/src/config.rs +++ b/crates/manifest/src/config.rs @@ -58,7 +58,7 @@ pub struct ProviderConfigPartial { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct WorkerManifestConfig { #[serde(default)] - pub system_prompt: Option, + pub instruction: Option, #[serde(default)] pub max_tokens: Option, #[serde(default)] @@ -179,7 +179,7 @@ impl ProviderConfigPartial { impl WorkerManifestConfig { fn merge(self, upper: Self) -> Self { Self { - system_prompt: upper.system_prompt.or(self.system_prompt), + instruction: upper.instruction.or(self.instruction), max_tokens: upper.max_tokens.or(self.max_tokens), max_turns: upper.max_turns.or(self.max_turns), temperature: upper.temperature.or(self.temperature), @@ -275,7 +275,10 @@ impl TryFrom for PodManifest { )?; let worker = WorkerManifest { - system_prompt: cfg.worker.system_prompt, + instruction: cfg + .worker + .instruction + .unwrap_or_else(|| defaults::DEFAULT_INSTRUCTION.to_string()), max_tokens: cfg.worker.max_tokens, max_turns: cfg.worker.max_turns, temperature: cfg.worker.temperature, diff --git a/crates/manifest/src/defaults.rs b/crates/manifest/src/defaults.rs index c6b6cffb..273b693d 100644 --- a/crates/manifest/src/defaults.rs +++ b/crates/manifest/src/defaults.rs @@ -21,3 +21,8 @@ pub const PRUNE_MIN_SAVINGS: u64 = 4096; /// Number of most-recent turns retained after a compact. See /// [`crate::CompactionConfig::compact_retained_turns`]. pub const COMPACT_RETAINED_TURNS: usize = 2; + +/// Default instruction asset reference used when `worker.instruction` +/// is omitted. See the `PromptLoader` prefix addressing scheme for the +/// `$insomnia/` / `$user/` / `$workspace/` namespaces. +pub const DEFAULT_INSTRUCTION: &str = "$insomnia/default"; diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index 891cde1c..8595b1e4 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -78,8 +78,13 @@ impl ProviderKind { /// Worker-level configuration embedded in the manifest. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkerManifest { - #[serde(default)] - pub system_prompt: Option, + /// Reference to the instruction prompt asset used as the body of + /// the worker's system prompt. Uses the `PromptLoader` prefix + /// addressing scheme (`$insomnia/...`, `$user/...`, + /// `$workspace/...`) and is always populated after resolution — + /// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`]. + #[serde(default = "default_instruction")] + pub instruction: String, #[serde(default)] pub max_tokens: Option, #[serde(default)] @@ -115,6 +120,10 @@ fn default_tool_output_max_bytes() -> usize { defaults::TOOL_OUTPUT_MAX_BYTES } +fn default_instruction() -> String { + defaults::DEFAULT_INSTRUCTION.to_string() +} + impl Default for ToolOutputLimits { fn default() -> Self { Self { @@ -270,7 +279,7 @@ permission = "write" assert!(manifest.provider.api_key_file.is_none()); assert_eq!(manifest.scope.allow.len(), 1); assert!(manifest.scope.deny.is_empty()); - assert!(manifest.worker.system_prompt.is_none()); + assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION); } #[test] @@ -286,7 +295,7 @@ model = "claude-sonnet-4-20250514" api_key_file = "~/.config/insomnia/keys/anthropic" [worker] -system_prompt = "You are a code reviewer." +instruction = "$user/reviewer" max_tokens = 4096 temperature = 0.3 @@ -310,10 +319,7 @@ permission = "write" manifest.provider.api_key_file.as_deref(), Some(std::path::Path::new("~/.config/insomnia/keys/anthropic")) ); - assert_eq!( - manifest.worker.system_prompt.as_deref(), - Some("You are a code reviewer.") - ); + assert_eq!(manifest.worker.instruction, "$user/reviewer"); assert_eq!(manifest.worker.max_tokens, Some(4096)); assert_eq!(manifest.worker.temperature, Some(0.3)); let allow = &manifest.scope.allow; diff --git a/crates/manifest/src/scope.rs b/crates/manifest/src/scope.rs index 292573f7..856b76e1 100644 --- a/crates/manifest/src/scope.rs +++ b/crates/manifest/src/scope.rs @@ -150,32 +150,42 @@ impl Scope { /// Human-readable grouping of allow rules, suitable for embedding in /// LLM system prompts. Deny rules are intentionally omitted — they /// only cap effective permission and surface them would mislead the - /// reader about what paths are accessible. + /// reader about what paths are accessible. Rules with + /// `recursive = false` are tagged with a trailing `[non-recursive]` + /// marker so the model does not assume child paths are included. /// /// ```text /// Readable: - /// - /abs/path1 + /// - /abs/path1 [non-recursive] /// Writable: /// - /abs/path2 /// ``` pub fn summary(&self) -> String { + fn push_rule(out: &mut String, rule: &ResolvedRule) { + out.push_str(" - "); + out.push_str(&rule.target.display().to_string()); + if !rule.recursive { + out.push_str(" [non-recursive]"); + } + out.push('\n'); + } + let mut out = String::new(); - let readable: Vec<_> = self.readable_paths().collect(); - if !readable.is_empty() { + if !self.allow.is_empty() { out.push_str("Readable:\n"); - for p in &readable { - out.push_str(" - "); - out.push_str(&p.display().to_string()); - out.push('\n'); + for rule in &self.allow { + push_rule(&mut out, rule); } } - let writable: Vec<_> = self.writable_paths().collect(); + let writable: Vec<&ResolvedRule> = self + .allow + .iter() + .filter(|r| r.permission == Permission::Write) + .collect(); if !writable.is_empty() { out.push_str("Writable:\n"); - for p in &writable { - out.push_str(" - "); - out.push_str(&p.display().to_string()); - out.push('\n'); + for rule in &writable { + push_rule(&mut out, rule); } } if out.ends_with('\n') { @@ -427,6 +437,41 @@ mod tests { assert!(!summary.contains("secret")); } + #[test] + fn summary_marks_non_recursive_rules() { + let dir = TempDir::new().unwrap(); + let docs = dir.path().join("docs"); + std::fs::create_dir(&docs).unwrap(); + let cfg = ScopeConfig { + allow: vec![ + ScopeRule { + target: docs.clone(), + permission: Permission::Read, + recursive: false, + }, + ScopeRule { + target: dir.path().to_path_buf(), + permission: Permission::Write, + recursive: true, + }, + ], + deny: Vec::new(), + }; + let scope = Scope::from_config(&cfg, dir.path()).unwrap(); + let summary = scope.summary(); + let docs_canon = docs.canonicalize().unwrap().display().to_string(); + let dir_canon = dir.path().canonicalize().unwrap().display().to_string(); + assert!( + summary.contains(&format!("{docs_canon} [non-recursive]")), + "expected non-recursive marker in: {summary}" + ); + // Recursive rule must NOT carry the marker. + assert!( + !summary.contains(&format!("{dir_canon} [non-recursive]")), + "recursive rule incorrectly marked: {summary}" + ); + } + #[test] fn readable_paths_includes_writable() { let dir = TempDir::new().unwrap(); diff --git a/crates/pod/src/factory.rs b/crates/pod/src/factory.rs index 06de6c65..46fbf12d 100644 --- a/crates/pod/src/factory.rs +++ b/crates/pod/src/factory.rs @@ -442,13 +442,13 @@ permission = "write" } #[test] - fn resolve_produces_loader_with_project_prompts_dir() { + fn resolve_produces_loader_with_workspace_prompts_dir() { use crate::system_prompt::{SystemPromptContext, SystemPromptTemplate}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; let tmp = TempDir::new().unwrap(); let root = tmp.path().canonicalize().unwrap(); - // .insomnia/manifest.toml and .insomnia/prompts/coder.md + // .insomnia/manifest.toml and .insomnia/prompts/local.md let manifest_path = root.join(".insomnia").join("manifest.toml"); write( &manifest_path, @@ -469,11 +469,11 @@ permission = "write" root = root.display() ), ); - let project_prompts_dir = root.join(".insomnia").join("prompts"); - std::fs::create_dir_all(&project_prompts_dir).unwrap(); + let workspace_prompts_dir = root.join(".insomnia").join("prompts"); + std::fs::create_dir_all(&workspace_prompts_dir).unwrap(); std::fs::write( - project_prompts_dir.join("coder.md"), - "PROJECT-OVERRIDE from {{ cwd }}", + workspace_prompts_dir.join("local.md"), + "WORKSPACE-BODY from {{ cwd }}", ) .unwrap(); @@ -483,9 +483,8 @@ permission = "write" .resolve() .unwrap(); - // The loader must see the project override, not the builtin. - let source = "{% include \"coder\" %}"; - let tmpl = SystemPromptTemplate::parse_with_loader(source, loader).unwrap(); + // The workspace prompt must be reachable via $workspace/local. + let tmpl = SystemPromptTemplate::parse("$workspace/local", loader).unwrap(); let scope_cfg = ScopeConfig { allow: vec![ScopeRule { target: root.clone(), @@ -500,12 +499,12 @@ permission = "write" cwd: &root, scope: &scope, tool_names: Vec::new(), - files: std::collections::BTreeMap::new(), + agents_md: None, }; let rendered = tmpl.render(&ctx).unwrap(); assert!( - rendered.starts_with("PROJECT-OVERRIDE"), - "expected project override, got: {rendered}" + rendered.starts_with("WORKSPACE-BODY"), + "expected workspace body, got: {rendered}" ); } diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index 073c4a94..6522e1cc 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -402,11 +402,13 @@ impl Pod { } } - /// Render the manifest-supplied system-prompt template exactly once, - /// just before the first LLM turn, and hand the resulting string to - /// the Worker via `set_system_prompt`. Subsequent invocations are - /// no-ops: the template field is consumed with `Option::take()`, so - /// the rendered value persists across all later turns and compaction. + /// Render the manifest-supplied instruction template exactly once, + /// just before the first LLM turn, append the fixed trailing + /// section (scope summary + optional AGENTS.md), and hand the + /// resulting string to the Worker via `set_system_prompt`. + /// Subsequent invocations are no-ops: the template field is + /// consumed with `Option::take()`, so the materialised value + /// persists across all later turns and compaction. fn ensure_system_prompt_materialized(&mut self) -> Result<(), PodError> { let Some(template) = self.system_prompt_template.take() else { return Ok(()); @@ -423,9 +425,8 @@ impl Pod { .into_iter() .map(|d| d.name) .collect(); - let mut files = std::collections::BTreeMap::new(); - let agents_md = read_agents_md(&self.pwd); - for warning in agents_md.warnings { + let agents_md_read = read_agents_md(&self.pwd); + for warning in agents_md_read.warnings { if let Some(n) = notifier.as_ref() { n.notify( NotificationLevel::Warn, @@ -434,15 +435,12 @@ impl Pod { ); } } - if let Some(body) = agents_md.body { - files.insert("agents_md".to_string(), body); - } let ctx = SystemPromptContext { now: chrono::Utc::now(), cwd: &self.pwd, scope: &self.scope, tool_names, - files, + agents_md: agents_md_read.body, }; let rendered = template .render(&ctx) @@ -841,17 +839,15 @@ impl Pod, St> { let mut worker = Worker::new(client); apply_worker_manifest(&mut worker, &manifest.worker); - // Parse the system-prompt template eagerly (syntax check only). - // Rendering is deferred to `ensure_system_prompt_materialized` - // at first turn so implementation runtime values (date, tools, - // scope summary, ...) can be injected. - let system_prompt_template = match manifest.worker.system_prompt.as_deref() { - Some(source) => Some( - SystemPromptTemplate::parse_with_loader(source, loader) - .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, - ), - None => None, - }; + // Resolve the instruction reference and parse the resulting + // template eagerly (syntax check only). Rendering is deferred + // to `ensure_system_prompt_materialized` at first turn so + // runtime values (date, tools, scope summary, ...) can be + // injected. + let system_prompt_template = Some( + SystemPromptTemplate::parse(&manifest.worker.instruction, loader) + .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, + ); // Session creation is deferred to the first run (see // `ensure_session_head`) so the SessionStart entry can capture diff --git a/crates/pod/src/prompt_loader.rs b/crates/pod/src/prompt_loader.rs index 7ae14795..08bb2201 100644 --- a/crates/pod/src/prompt_loader.rs +++ b/crates/pod/src/prompt_loader.rs @@ -1,77 +1,264 @@ -//! Three-layer prompt asset loader used by [`crate::SystemPromptTemplate`]. +//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`]. //! -//! Layers (highest priority first): -//! 1. **Project prompts** — `/.insomnia/prompts/` -//! 2. **User prompts** — `$XDG_CONFIG_HOME/insomnia/prompts/` -//! 3. **Builtin prompts** — baked into the binary from `resources/prompts/` -//! via [`include_dir!`]. +//! Three prefixes address three physical libraries: //! -//! A prompt name is its path stem without the `.md` extension. -//! Subdirectories are supported: `common/tool-usage` maps to -//! `common/tool-usage.md` under whichever layer provides it first. +//! | prefix | location | +//! |--------------|----------------------------------------------------| +//! | `$insomnia` | builtin, baked into the binary via `include_dir!` | +//! | `$user` | `$XDG_CONFIG_HOME/insomnia/prompts/` (or similar) | +//! | `$workspace` | `/.insomnia/prompts/` | +//! +//! A reference is `$/` where `` is a `/`-separated +//! name without the `.md` extension (e.g. `$insomnia/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"); -/// Lookup table for prompt assets across the three cascade layers. +const PREFIX_INSOMNIA: &str = "$insomnia"; +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 +/// `"$insomnia/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 { + Insomnia, + User, + Workspace, +} + +impl Prefix { + fn as_str(self) -> &'static str { + match self { + Self::Insomnia => PREFIX_INSOMNIA, + 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. #[derive(Debug, Clone)] pub struct PromptLoader { user_dir: Option, - project_dir: Option, + workspace_dir: Option, } impl PromptLoader { - /// Builtins-only loader. Used for direct `Pod::from_manifest` - /// calls that skip the factory cascade (tests, examples, simple - /// callers). + /// Loader with only the builtin `$insomnia` library available. + /// `$user` / `$workspace` references fail with + /// [`LoaderError::PrefixNotConfigured`]. pub fn builtins_only() -> Self { Self { user_dir: None, - project_dir: None, + workspace_dir: None, } } - /// Loader with optional user and project prompts directories. Both - /// are consulted before falling back to builtins; `None` on either - /// skips that layer. - pub fn new(user_dir: Option, project_dir: Option) -> Self { + /// Loader with optional user and workspace prompt directories. + pub fn new(user_dir: Option, workspace_dir: Option) -> Self { Self { user_dir, - project_dir, + workspace_dir, } } - /// Look up the raw template source for `name`. Returns `None` if - /// no layer provides it. - pub fn lookup(&self, name: &str) -> Option { - if let Some(ref dir) = self.project_dir { - if let Some(s) = read_from_dir(dir, name) { - return Some(s); - } + /// 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(ref dir) = self.user_dir { - if let Some(s) = read_from_dir(dir, name) { - return Some(s); - } + 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, + }) } - read_from_include_dir(&BUILTIN_PROMPTS, name) + } + + /// 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::Insomnia => 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 read_from_dir(dir: &Path, name: &str) -> Option { - let path = dir.join(format!("{name}.md")); - std::fs::read_to_string(path).ok() +fn parse_prefix(raw: &str, prefix_name: &str) -> Result { + match prefix_name { + "insomnia" => Ok(Prefix::Insomnia), + "user" => Ok(Prefix::User), + "workspace" => Ok(Prefix::Workspace), + _ => Err(LoaderError::UnknownPrefix { + raw: raw.to_string(), + prefix: format!("${prefix_name}"), + }), + } } -fn read_from_include_dir(dir: &Dir<'static>, name: &str) -> Option { - let path = format!("{name}.md"); +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)] @@ -80,58 +267,107 @@ mod tests { use tempfile::TempDir; #[test] - fn builtin_coder_prompt_present() { + fn builtin_default_resolves() { let loader = PromptLoader::builtins_only(); - let coder = loader.lookup("coder").expect("coder builtin missing"); - assert!(coder.contains("software engineering agent")); + let (r, source) = loader.resolve("$insomnia/default", None).unwrap(); + assert_eq!(r.to_qualified_string(), "$insomnia/default"); + assert!(!source.is_empty()); } #[test] fn builtin_subdirectory_lookup() { let loader = PromptLoader::builtins_only(); - let tu = loader - .lookup("common/tool-usage") - .expect("common/tool-usage missing"); - assert!(tu.contains("tool")); + let (_, source) = loader.resolve("$insomnia/common/tool-usage", None).unwrap(); + assert!(source.contains("tool")); } #[test] - fn unknown_name_returns_none() { + 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(); - assert!(loader.lookup("definitely-not-a-prompt").is_none()); + let err = loader.resolve("$insomnia/definitely-missing", None).unwrap_err(); + assert!(matches!(err, LoaderError::NotFound { .. })); } #[test] - fn user_layer_overrides_builtin() { + 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("$insomnia/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(), "$insomnia/common/workspace"); + } + + #[test] + fn unqualified_ref_from_root_file_has_empty_dir() { + let loader = PromptLoader::builtins_only(); + let current = loader.parse_ref("$insomnia/default", None).unwrap(); + let sibling = loader.parse_ref("other", Some(¤t)).unwrap(); + assert_eq!(sibling.to_qualified_string(), "$insomnia/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("coder.md"), "user-coder").unwrap(); - + std::fs::write(user_dir.join("custom.md"), "user-body").unwrap(); let loader = PromptLoader::new(Some(user_dir), None); - assert_eq!(loader.lookup("coder").as_deref(), Some("user-coder")); + + let current = loader.parse_ref("$insomnia/default", None).unwrap(); + // Even with an $insomnia-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 project_layer_overrides_user_and_builtin() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().join("user"); - let project_dir = tmp.path().join("project"); - std::fs::create_dir_all(&user_dir).unwrap(); - std::fs::create_dir_all(&project_dir).unwrap(); - std::fs::write(user_dir.join("coder.md"), "user-coder").unwrap(); - std::fs::write(project_dir.join("coder.md"), "project-coder").unwrap(); - - let loader = PromptLoader::new(Some(user_dir), Some(project_dir)); - assert_eq!(loader.lookup("coder").as_deref(), Some("project-coder")); - } - - #[test] - fn falls_through_to_builtin_when_user_missing_name() { - let tmp = TempDir::new().unwrap(); - let user_dir = tmp.path().to_path_buf(); - // user layer only defines "only-user", not "coder" - std::fs::write(user_dir.join("only-user.md"), "x").unwrap(); - let loader = PromptLoader::new(Some(user_dir), None); - assert!(loader.lookup("coder").is_some()); // from builtin + fn traversal_segments_rejected() { + let loader = PromptLoader::builtins_only(); + let err = loader.resolve("$insomnia/../etc/passwd", None).unwrap_err(); + assert!(matches!(err, LoaderError::InvalidRef { .. })); } } diff --git a/crates/pod/src/system_prompt.rs b/crates/pod/src/system_prompt.rs index 2d2ed385..63c625b8 100644 --- a/crates/pod/src/system_prompt.rs +++ b/crates/pod/src/system_prompt.rs @@ -1,11 +1,16 @@ //! System prompt template machinery for the Pod layer. //! -//! Manifests describe `system_prompt` as a minijinja template string. -//! The template is parsed eagerly at `Pod::from_manifest` (syntax check -//! only) and held on the Pod until `ensure_system_prompt_materialized` -//! renders it exactly once, just before the first LLM turn. The rendered -//! string is pushed to the worker via `set_system_prompt` and is reused -//! for every subsequent turn, including after compaction. +//! Manifests describe the system prompt body as a reference to a +//! prompt asset (`worker.instruction`, see [`manifest::WorkerManifest`]). +//! [`SystemPromptTemplate`] resolves that reference through a +//! [`PromptLoader`], parses the source as a minijinja template, and +//! eagerly syntax-checks it at Pod 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 +//! the Pod's `Scope` summary and (if present) the project's `AGENTS.md` +//! contents, and the whole string is handed to the Worker via +//! `set_system_prompt`. Subsequent turns and compactions reuse that +//! materialised string verbatim. use std::collections::BTreeMap; use std::path::Path; @@ -17,87 +22,124 @@ use minijinja::value::Value; use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use thiserror::Error; -use crate::prompt_loader::PromptLoader; - -const TEMPLATE_NAME: &str = "system_prompt"; +use crate::prompt_loader::{LoaderError, PromptLoader, PromptRef}; #[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}")] Render(String), } -/// Parsed system-prompt template. Holds a minijinja Environment with a -/// single named template; rendering only needs a fresh [`SystemPromptContext`]. +/// 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. #[derive(Clone)] pub struct SystemPromptTemplate { env: Arc>, + instruction_name: String, } impl SystemPromptTemplate { - /// Parse a template source with a builtins-only prompt loader. - /// Convenience wrapper for callers that do not need user/project - /// prompt layers — see [`SystemPromptTemplate::parse_with_loader`] - /// for the factory-driven path. - pub fn parse(source: impl Into) -> Result { - Self::parse_with_loader(source, PromptLoader::builtins_only()) - } - - /// Parse a template source with a custom prompt loader installed. - /// The loader resolves `{% include "name" %}` / `{% import "name" %}` - /// references by consulting the cascade layers (project → user → - /// builtin) before reporting a missing template. - pub fn parse_with_loader( - source: impl Into, + /// 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); - env.set_loader(move |name| match loader.lookup(name) { - Some(source) => Ok(Some(source)), - None => Err(minijinja::Error::new( - ErrorKind::TemplateNotFound, - format!("prompt asset '{name}' not found"), - )), + + // 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(), + } }); - env.add_template_owned(TEMPLATE_NAME, source.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()))?; - Ok(Self { env: Arc::new(env) }) + + Ok(Self { + env: Arc::new(env), + instruction_name: root_name, + }) } - /// Render the template with the supplied context. Missing variables - /// surface as [`SystemPromptError::Render`]. + /// Render the instruction body and append the fixed trailing + /// section (scope summary + optional AGENTS.md). The trailing + /// 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(TEMPLATE_NAME) + .get_template(&self.instruction_name) .map_err(|e| SystemPromptError::Render(e.to_string()))?; - tmpl.render(ctx.to_minijinja_value()) - .map_err(|e| SystemPromptError::Render(e.to_string())) + let body = tmpl + .render(ctx.to_minijinja_value()) + .map_err(|e| SystemPromptError::Render(e.to_string()))?; + Ok(append_trailing_section(&body, ctx.scope, ctx.agents_md.as_deref())) } } impl std::fmt::Debug for SystemPromptTemplate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("SystemPromptTemplate") + .field("instruction", &self.instruction_name) .finish_non_exhaustive() } } -/// Inputs available to a system-prompt template at materialisation time. +/// Inputs available to an instruction template at materialisation time. /// -/// `files` is reserved for AGENTS.md and other external file ingestion -/// (supplied by a separate ticket). It is always present so template -/// authors can reference `{{ files.agents_md }}` without having to guard -/// for key existence. +/// Scope summary and AGENTS.md are deliberately **not** exposed to the +/// template — they live in the Rust-owned trailing section so user +/// templates cannot drop them on the floor. pub struct SystemPromptContext<'a> { pub now: DateTime, pub cwd: &'a Path, pub scope: &'a Scope, pub tool_names: Vec, - pub files: BTreeMap, + /// Project-level instructions read from the nearest `AGENTS.md`. + /// Not visible from the template; consumed by the trailing-section + /// formatter in [`SystemPromptTemplate::render`]. + pub agents_md: Option, } impl<'a> SystemPromptContext<'a> { @@ -116,7 +158,6 @@ impl<'a> SystemPromptContext<'a> { Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), ); root.insert("cwd".into(), Value::from(self.cwd.display().to_string())); - root.insert("scope".into(), scope_value(self.scope)); root.insert( "tools".into(), Value::from( @@ -127,33 +168,45 @@ impl<'a> SystemPromptContext<'a> { .collect::>(), ), ); - root.insert( - "files".into(), - Value::from( - self.files - .iter() - .map(|(k, v)| (k.clone(), Value::from(v.clone()))) - .collect::>(), - ), - ); Value::from(root) } } -fn scope_value(scope: &Scope) -> Value { - let readable: Vec = scope - .readable_paths() - .map(|p| Value::from(p.display().to_string())) - .collect(); - let writable: Vec = scope - .writable_paths() - .map(|p| Value::from(p.display().to_string())) - .collect(); - let mut obj: BTreeMap = BTreeMap::new(); - obj.insert("readable".into(), Value::from(readable)); - obj.insert("writable".into(), Value::from(writable)); - obj.insert("summary".into(), Value::from(scope.summary())); - Value::from(obj) +/// Build the final system prompt by appending the fixed trailing +/// section to `body`. Exposed at the module level so callers that skip +/// the template path (e.g. pre-rendered content in tests) can reuse the +/// exact same formatter. +pub fn append_trailing_section(body: &str, scope: &Scope, agents_md: Option<&str>) -> String { + let mut out = String::with_capacity(body.len() + 256); + out.push_str(body); + if !body.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + out.push_str("---\n## Working boundaries\n\n"); + out.push_str(&scope.summary()); + out.push('\n'); + if let Some(agents) = agents_md { + out.push('\n'); + out.push_str("---\n## Project instructions (AGENTS.md)\n\n"); + out.push_str(agents); + if !agents.ends_with('\n') { + out.push('\n'); + } + } + // Trim trailing whitespace on the final line so the emitted prompt + // has a single canonical form regardless of input quirks. + while out.ends_with('\n') || out.ends_with(' ') { + out.pop(); + } + out +} + +/// Bridge used by [`Pod::ensure_system_prompt_materialized`] so tests +/// can construct a synthetic context without going through a full Pod. +#[doc(hidden)] +pub fn __instruction_ref_for_tests(raw: &str, loader: &PromptLoader) -> Option { + loader.parse_ref(raw, None).ok() } #[cfg(test)] @@ -179,44 +232,167 @@ mod tests { Scope::from_config(&cfg, dir).unwrap() } - fn ctx<'a>(cwd: &'a Path, scope: &'a Scope, tools: Vec) -> SystemPromptContext<'a> { + fn ctx<'a>( + cwd: &'a Path, + scope: &'a Scope, + tools: Vec, + agents_md: Option, + ) -> SystemPromptContext<'a> { SystemPromptContext { now: fixed_now(), cwd, scope, tool_names: tools, - files: BTreeMap::new(), + agents_md, } } + fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) { + 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 parse_succeeds_for_minimal_template() { - let t = SystemPromptTemplate::parse("hello").unwrap(); + fn instruction_default_resolves_to_insomnia_default() { + let loader = PromptLoader::builtins_only(); + let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap(); let dir = TempDir::new().unwrap(); let scope = build_scope(dir.path()); - let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap(); - assert_eq!(rendered, "hello"); + let rendered = tmpl + .render(&ctx(dir.path(), &scope, vec!["Read".into()], None)) + .unwrap(); + // Trailing section must be present. + assert!(rendered.contains("## Working boundaries")); + assert!(rendered.contains("Readable:")); + } + + #[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 \"$insomnia/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("$insomnia/definitely-missing", loader).unwrap_err(); + assert!(matches!(err, SystemPromptError::LoaderResolve(_))); } #[test] fn parse_fails_on_syntax_error() { - let err = SystemPromptTemplate::parse("{{ unclosed").unwrap_err(); + 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_substitutes_date_cwd_tools() { - let t = SystemPromptTemplate::parse( - "date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}", - ) - .unwrap(); + 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 rendered = t + 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")); @@ -225,81 +401,43 @@ mod tests { } #[test] - fn render_fails_on_undefined_variable() { - let t = SystemPromptTemplate::parse("{{ ghost }}").unwrap(); + 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 err = t.render(&ctx(dir.path(), &scope, vec![])).unwrap_err(); - assert!(matches!(err, SystemPromptError::Render(_))); + 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 escape_double_braces() { - let t = SystemPromptTemplate::parse("literal {{ '{{' }} here").unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap(); - assert_eq!(rendered, "literal {{ here"); - } - - #[test] - fn scope_summary_renders() { - let t = SystemPromptTemplate::parse("{{ scope.summary }}").unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap(); - assert!(rendered.starts_with("Readable:")); - assert!(rendered.contains(&dir.path().canonicalize().unwrap().display().to_string())); - } - - #[test] - fn include_resolves_builtin_prompt() { - // User-supplied source pulls in a builtin via the loader. - let source = "HEAD\n{% include \"common/tool-usage\" %}"; - let tmpl = SystemPromptTemplate::parse_with_loader( - source, - PromptLoader::builtins_only(), - ) - .unwrap(); + 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!["Read".into(), "Edit".into()], + vec![], + Some("PROJECT DOCS".into()), )) .unwrap(); - assert!(rendered.starts_with("HEAD")); - // The common/tool-usage builtin references {{ tools | join(", ") }} - // so including it must have resolved that expression with the - // parent scope's variables. - assert!(rendered.contains("Read")); - assert!(rendered.contains("Edit")); + assert!(rendered.contains("## Project instructions (AGENTS.md)")); + assert!(rendered.contains("PROJECT DOCS")); } #[test] - fn include_unknown_prompt_fails_at_render() { - let tmpl = SystemPromptTemplate::parse_with_loader( - "{% include \"nonexistent-prompt\" %}", - PromptLoader::builtins_only(), - ) - .unwrap(); + 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 err = tmpl.render(&ctx(dir.path(), &scope, vec![])).unwrap_err(); - assert!(matches!(err, SystemPromptError::Render(_))); - } - - #[test] - fn files_reserved_namespace_is_empty() { - let t = SystemPromptTemplate::parse( - "{% if files.agents_md is defined %}yes{% else %}no{% endif %}", - ) - .unwrap(); - let dir = TempDir::new().unwrap(); - let scope = build_scope(dir.path()); - let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap(); - assert_eq!(rendered, "no"); + let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap(); + assert!(!rendered.contains("AGENTS.md")); + assert!(!rendered.contains("Project instructions")); } } diff --git a/crates/pod/tests/system_prompt_template_test.rs b/crates/pod/tests/system_prompt_template_test.rs index a14a99a1..ab273e90 100644 --- a/crates/pod/tests/system_prompt_template_test.rs +++ b/crates/pod/tests/system_prompt_template_test.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -9,7 +10,7 @@ use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEve use llm_worker::llm_client::{ClientError, LlmClient, Request}; use session_store::{FsStore, LogEntry, Store}; -use pod::{Pod, PodError, SystemPromptTemplate}; +use pod::{Pod, PodError, PromptLoader, SystemPromptTemplate}; // --------------------------------------------------------------------------- // Mock LLM Client @@ -60,13 +61,7 @@ fn single_text_events(text: &str) -> Vec { ] } -fn manifest_toml(system_prompt: Option<&str>) -> String { - let prompt_line = match system_prompt { - Some(s) => format!("system_prompt = {:?}\n", s), - None => String::new(), - }; - format!( - r#" +const MINIMAL_MANIFEST_TOML: &str = r#" [pod] name = "test-pod" pwd = "./" @@ -77,19 +72,22 @@ model = "test-model" [worker] max_tokens = 100 -{prompt_line} + [[scope.allow]] target = "./" permission = "write" -"# - ) -} +"#; -async fn make_pod_with_template( - template_source: Option<&str>, +/// Build a Pod 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 Pod constructed directly via `Pod::new`. +async fn make_pod_with_body( + body: &str, client: MockClient, -) -> Result, PodError> { - let manifest = pod::PodManifest::from_toml(&manifest_toml(template_source)).unwrap(); +) -> Result<(Pod, PathBuf), PodError> { + let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap(); let store_tmp = tempfile::tempdir().unwrap(); let store = FsStore::new(store_tmp.path()).await.unwrap(); @@ -100,15 +98,19 @@ async fn make_pod_with_template( let scope = pod::Scope::writable(&pwd).unwrap(); std::mem::forget(pwd_tmp); - let worker = Worker::new(client); - let mut pod = Pod::new(manifest, worker, store, pwd, scope).await?; + 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); - if let Some(source) = template_source { - let template = SystemPromptTemplate::parse(source) - .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?; - pod.set_system_prompt_template(template); - } - Ok(pod) + let worker = Worker::new(client); + let mut pod = Pod::new(manifest, worker, store, pwd.clone(), scope).await?; + + let template = SystemPromptTemplate::parse("$user/test", loader) + .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?; + pod.set_system_prompt_template(template); + + Ok((pod, pwd)) } // --------------------------------------------------------------------------- @@ -117,10 +119,10 @@ async fn make_pod_with_template( #[tokio::test] async fn template_parse_rejects_invalid_syntax() { - let err = SystemPromptTemplate::parse("{{ unclosed").unwrap_err(); - // Surfaces via PodError::InvalidSystemPromptTemplate when used with - // Pod::from_manifest — tested at the SystemPromptTemplate level here - // because building a Pod via from_manifest requires a real provider. + 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 pod_err: PodError = PodError::InvalidSystemPromptTemplate { source: err }; assert!(matches!( pod_err, @@ -131,7 +133,7 @@ async fn template_parse_rejects_invalid_syntax() { #[tokio::test] async fn template_is_not_materialised_before_first_run() { let client = MockClient::new(vec![single_text_events("ok")]); - let pod = make_pod_with_template(Some("hello"), client).await.unwrap(); + let (pod, _pwd) = make_pod_with_body("hello", client).await.unwrap(); // Before first run, worker still has no system prompt. assert!(pod.worker().get_system_prompt().is_none()); } @@ -139,8 +141,8 @@ async fn template_is_not_materialised_before_first_run() { #[tokio::test] async fn materialise_on_first_turn_populates_worker() { let client = MockClient::new(vec![single_text_events("ok")]); - let mut pod = make_pod_with_template( - Some("date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}"), + let (mut pod, pwd) = make_pod_with_body( + "date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}", client, ) .await @@ -153,27 +155,28 @@ async fn materialise_on_first_turn_populates_worker() { .to_string(); assert!(rendered.contains("date=")); assert!(rendered.contains("cwd=")); - assert!(rendered.contains(&pod.pwd().display().to_string())); + assert!(rendered.contains(&pwd.display().to_string())); assert!(rendered.starts_with("date=")); + // Trailing fixed section must be appended. + assert!(rendered.contains("## Working boundaries")); } #[tokio::test] async fn session_start_state_captures_rendered_prompt() { let client = MockClient::new(vec![single_text_events("ok")]); - let mut pod = make_pod_with_template(Some("hello cwd={{ cwd }}"), client) + let (mut pod, pwd) = make_pod_with_body("hello cwd={{ cwd }}", client) .await .unwrap(); pod.run("hi").await.unwrap(); - // Inspect the first log entry directly: it must be a SessionStart - // with the rendered system prompt, not `None`. let entries = pod.store().read_all(pod.session_id()).await.unwrap(); let first = entries.first().expect("at least one entry"); match &first.entry { LogEntry::SessionStart { system_prompt, .. } => { let sp = system_prompt.as_deref().expect("system prompt set"); assert!(sp.starts_with("hello cwd=")); - assert!(sp.contains(&pod.pwd().display().to_string())); + assert!(sp.contains(&pwd.display().to_string())); + assert!(sp.contains("## Working boundaries")); } other => panic!("expected SessionStart as first entry, got {other:?}"), } @@ -182,24 +185,18 @@ async fn session_start_state_captures_rendered_prompt() { #[tokio::test] async fn render_failure_propagates_as_pod_error() { let client = MockClient::new(vec![single_text_events("ok")]); - let mut pod = make_pod_with_template(Some("{{ ghost }}"), client) - .await - .unwrap(); + let (mut pod, _pwd) = make_pod_with_body("{{ ghost }}", client).await.unwrap(); let err = pod.run("hi").await.unwrap_err(); assert!(matches!(err, PodError::SystemPromptRender { .. })); } #[tokio::test] async fn materialise_runs_only_once_across_turns() { - // Two turns; the second one must not re-render the template. We - // approximate this by checking that the rendered system prompt is - // identical across turns and that the Pod's template slot is - // exhausted after the first run. let client = MockClient::new(vec![ single_text_events("first"), single_text_events("second"), ]); - let mut pod = make_pod_with_template(Some("fixed prompt {{ cwd }}"), client) + let (mut pod, _pwd) = make_pod_with_body("fixed prompt {{ cwd }}", client) .await .unwrap(); pod.run("one").await.unwrap(); @@ -210,85 +207,69 @@ async fn materialise_runs_only_once_across_turns() { } #[tokio::test] -async fn agents_md_is_injected_when_present() { +async fn agents_md_is_injected_as_trailing_section_when_present() { let client = MockClient::new(vec![single_text_events("ok")]); - let mut pod = make_pod_with_template( - Some( - "{% if files.agents_md is defined %}AGENTS:{{ files.agents_md }}\ - {% else %}NONE{% endif %}", - ), - client, - ) - .await - .unwrap(); - std::fs::write(pod.pwd().join("AGENTS.md"), "# project rules\nbe kind").unwrap(); + let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap(); + std::fs::write(pwd.join("AGENTS.md"), "# project rules\nbe kind").unwrap(); pod.run("hi").await.unwrap(); let rendered = pod.worker().get_system_prompt().unwrap().to_string(); - assert_eq!(rendered, "AGENTS:# project rules\nbe kind"); + assert!(rendered.starts_with("BODY")); + assert!(rendered.contains("## Project instructions (AGENTS.md)")); + assert!(rendered.contains("# project rules")); + assert!(rendered.contains("be kind")); } #[tokio::test] -async fn agents_md_absent_leaves_key_undefined() { +async fn agents_md_absent_omits_trailing_section() { let client = MockClient::new(vec![single_text_events("ok")]); - let mut pod = make_pod_with_template( - Some("{% if files.agents_md is defined %}HAS{% else %}NONE{% endif %}"), - client, - ) - .await - .unwrap(); - // No AGENTS.md written. + let (mut pod, _pwd) = make_pod_with_body("BODY", client).await.unwrap(); pod.run("hi").await.unwrap(); - assert_eq!(pod.worker().get_system_prompt().unwrap(), "NONE"); + let rendered = pod.worker().get_system_prompt().unwrap().to_string(); + assert!(!rendered.contains("## Project instructions")); + assert!(!rendered.contains("AGENTS.md")); } #[tokio::test] async fn agents_md_not_reread_after_compact() { - // Render AGENTS.md on the first turn, then mutate the file on disk - // and compact. The post-compact prompt must still reflect the - // original content (template re-rendering is forbidden). let client = MockClient::new(vec![ single_text_events("a"), single_text_events("b"), single_text_events("summary"), single_text_events("c"), ]); - let mut pod = make_pod_with_template( - Some("{{ files.agents_md }}"), - client, - ) - .await - .unwrap(); - let agents_path = pod.pwd().join("AGENTS.md"); + let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap(); + let agents_path = pwd.join("AGENTS.md"); std::fs::write(&agents_path, "original").unwrap(); pod.run("first").await.unwrap(); let before = pod.worker().get_system_prompt().unwrap().to_string(); - assert_eq!(before, "original"); + assert!(before.contains("original")); pod.run("second").await.unwrap(); // Mutate the file after the first turn — must not affect the cached // system prompt either on a subsequent turn or across compaction. std::fs::write(&agents_path, "mutated").unwrap(); pod.compact(1).await.unwrap(); - assert_eq!(pod.worker().get_system_prompt().unwrap(), "original"); + let after_compact = pod.worker().get_system_prompt().unwrap().to_string(); + assert!(after_compact.contains("original")); + assert!(!after_compact.contains("mutated")); pod.run("third").await.unwrap(); - assert_eq!(pod.worker().get_system_prompt().unwrap(), "original"); + let after_third = pod.worker().get_system_prompt().unwrap().to_string(); + assert!(after_third.contains("original")); + assert!(!after_third.contains("mutated")); } #[tokio::test] async fn compact_preserves_system_prompt() { - // Three user turns, then compact with retained_turns=1. The new - // compacted session must carry the same rendered system prompt and - // the template must not re-run. let client = MockClient::new(vec![ single_text_events("a"), single_text_events("b"), single_text_events("summary"), single_text_events("c"), ]); - let mut pod = make_pod_with_template(Some("SP cwd={{ cwd }}"), client) + let (mut pod, _pwd) = make_pod_with_body("SP cwd={{ cwd }}", client) .await .unwrap(); @@ -301,8 +282,6 @@ async fn compact_preserves_system_prompt() { let after = pod.worker().get_system_prompt().unwrap().to_string(); assert_eq!(before, after); - // A further run must still see the same prompt (template is None, so - // ensure_system_prompt_materialized is a no-op). pod.run("third").await.unwrap(); assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str()); } diff --git a/resources/prompts/coder.md b/resources/prompts/coder.md deleted file mode 100644 index 3be58733..00000000 --- a/resources/prompts/coder.md +++ /dev/null @@ -1,7 +0,0 @@ -You are a focused software engineering agent operating in {{ cwd }}. - -Today is {{ date }}. Stay precise, edit code directly when asked, and -avoid speculative refactoring. Explain what you changed in one short -paragraph at the end of each turn. - -{% include "common/tool-usage" %} diff --git a/resources/prompts/common/tool-usage.md b/resources/prompts/common/tool-usage.md index 330121e4..1c22ae3b 100644 --- a/resources/prompts/common/tool-usage.md +++ b/resources/prompts/common/tool-usage.md @@ -2,10 +2,5 @@ You have access to these tools: {{ tools | join(", ") }}. -Prefer the most specific tool for the job. When reading files you already -know the path of, use the file-read tool directly instead of searching. +Prefer the most specific tool for the job. When reading files you already know the path of, use the file-read tool directly instead of searching. When searching, use grep/glob primitives rather than shell pipelines. - -Only touch paths inside your scope. Your scope is: - -{{ scope.summary }} diff --git a/resources/prompts/common/workspace.md b/resources/prompts/common/workspace.md new file mode 100644 index 00000000..519e4624 --- /dev/null +++ b/resources/prompts/common/workspace.md @@ -0,0 +1,2 @@ +You are operating in {{ cwd }}. +Today is {{ date }}. diff --git a/resources/prompts/default.md b/resources/prompts/default.md new file mode 100644 index 00000000..80b55e0b --- /dev/null +++ b/resources/prompts/default.md @@ -0,0 +1,5 @@ +{% include "common/workspace" %} + +Stay precise, edit code directly when asked, and avoid speculative refactoring. Explain what you changed in one short paragraph at the end of each turn. + +{% include "common/tool-usage" %} diff --git a/resources/prompts/planner.md b/resources/prompts/planner.md deleted file mode 100644 index ac3090f1..00000000 --- a/resources/prompts/planner.md +++ /dev/null @@ -1,7 +0,0 @@ -You are a planning agent operating in {{ cwd }}. - -Today is {{ date }}. Produce a concise, implementation-ready plan: list -the files to touch, the order of changes, and the risks. Do not write -code unless asked. - -{% include "common/tool-usage" %} diff --git a/resources/prompts/reviewer.md b/resources/prompts/reviewer.md deleted file mode 100644 index c281e54e..00000000 --- a/resources/prompts/reviewer.md +++ /dev/null @@ -1,7 +0,0 @@ -You are a code reviewer operating in {{ cwd }}. - -Today is {{ date }}. Read the diff or the requested files, then report -problems grouped by severity (blocking / recommended / nit). Quote -specific lines. Do not rewrite the code unless asked. - -{% include "common/tool-usage" %} diff --git a/tickets/instruction-file-refs.md b/tickets/instruction-file-refs.md new file mode 100644 index 00000000..03e952c5 --- /dev/null +++ b/tickets/instruction-file-refs.md @@ -0,0 +1,184 @@ +# instruction のファイル参照化と system prompt 末尾の固定セクション + +## 背景 + +pod-factory で `worker.system_prompt` を minijinja 文字列として扱う形にしたが、その後の運用方針の整理で以下のズレが見えてきた: + +- **manifest を手で触らない前提**なので、`system_prompt` に minijinja 文字列をインラインで書く設計そのものが過剰。人間が書くのはプロンプトの**中身ファイル**だけで十分 +- **AGENTS.md を `files.agents_md` として user template に露出**しているが、ユーザー領域のテンプレートに AGENTS.md 埋め込みを任せると、guard 忘れで静かに消えたり、preset を差し替えるたびに AGENTS.md の扱いが破綻する +- **scope はセキュリティ境界**なので、user の template が覚えていてくれる前提にすべきでない +- **prompt loader が by-name fallthrough**(project → user → builtin を順に探索)になっており、「同名上書き」と「偶然同名」が区別できない曖昧さがある +- 現在バイナリに同梱されている `coder` / `reviewer` / `planner` / `common/tool-usage` は AI 任せで書いた placeholder で、設計者の意図が乗っていない +- 既存の `Scope::summary()` は許可されたパスを列挙するだけで、**`recursive = false` の情報が失われて**おり、非再帰ルールの意味が LLM に伝わらない + +## ゴール + +1. `worker.system_prompt` を**ファイル参照フィールド** (`worker.instruction`) に置き換える +2. `$insomnia` / `$user` / `$workspace` の **import-map 形式の prefix** でプロンプト資産を addressing +3. scope と AGENTS.md を **コード側で system prompt 末尾に付加する固定セクション**として注入し、user template が触れない領域にする +4. 組み込みプロンプトを `$insomnia/default` の 1 本に整理 +5. `Scope::summary()` のフォーマットを改善し、`recursive = false` を明示する + +## 方針 + +### instruction フィールド + +- manifest schema: `worker.system_prompt: Option` を **`worker.instruction: Option`** に置き換える(minijinja 文字列を持つフィールドを消し、ファイル参照だけを受ける) +- 値は import-map 記法のファイル参照: `$insomnia/default` / `$user/my-style` / `$workspace/custom` +- `.md` 拡張子は省略する +- **デフォルト値**は `$insomnia/default`。manifest で `instruction` を書かなければこれが使われる(`defaults.rs` に `DEFAULT_INSTRUCTION: &str = "$insomnia/default"` を追加) +- サブディレクトリ許容: `$insomnia/common/header` は `resources/prompts/common/header.md` を指す + +### Prefix 解決 + +| prefix | 解決先 | +|---|---| +| `$insomnia` | バイナリ同梱の `resources/prompts/`(`include_dir!`) | +| `$user` | `$XDG_CONFIG_HOME/insomnia/prompts/`(factory が設定した user prompts dir) | +| `$workspace` | `/.insomnia/prompts/`(factory が設定した project prompts dir) | + +- 指定した prefix の dir に該当ファイルが無ければ **hard error**(fallthrough しない) +- 現在の「by-name で層を fallthrough して探す」ロジックは撤廃 + +### Unqualified include の相対解決 + +`{% include "name" %}` のように prefix 無しで書かれた場合は、**include を書いたファイル自身の prefix + ディレクトリからの相対**で解決する: + +- `$insomnia/default.md` 内の `{% include "sub" %}` → `$insomnia/sub` +- `$insomnia/common/header.md` 内の `{% include "nested/foo" %}` → `$insomnia/common/nested/foo` +- `$user/custom.md` 内の `{% include "$insomnia/default" %}` → 明示的 prefix が優先 + +これにより「同じディレクトリ内でかたまって動くプロンプト集」を自然に書ける。 + +### system prompt 末尾の固定セクション + +`SystemPromptTemplate` のレンダリング後に、Rust 側で以下の構造を**必ず**付加する(user template からは触れない): + +``` + + +--- +## Working boundaries + +{scope.summary()} +{% if agents_md %} +--- +## Project instructions (AGENTS.md) + +{agents_md 本文} +{% endif %} +``` + +- 付加部分は Rust の `const` またはハードコードされたフォーマット文字列として実装し、`resources/` には置かない(user が触れる場所に置くと、触らないでほしいものが触れる場所に置かれる矛盾になる) +- scope セクションは **必ず** 出力される +- AGENTS.md セクションは不在時に省略(区切り `---` ごと省略) + +### `SystemPromptContext.files` の削除 + +- `files` フィールドごと撤廃(元々 AGENTS.md 専用の予約席) +- user template から AGENTS.md を参照する手段は無くなる(末尾セクションが面倒を見る) + +### `Scope::summary()` フォーマットの改善 + +`crates/manifest/src/scope.rs` の `Scope::summary()` を以下の方針で書き直す: + +- **非再帰ルールを `[non-recursive]` でマークする**。例: + ``` + Readable: + - [non-recursive] + Writable: + - + ``` +- マーカーの位置はパスの末尾(パースしやすさより人間可読性を優先) +- recursive = true の場合は何も足さない(デフォルトなので無印) +- deny ルールは現行どおり summary には出さない(`from_config` の時点で effective permission に焼き込まれるため) +- `Readable` / `Writable` のセクション分けは現行どおり + +この変更により、最終セクションが出力する scope 情報が LLM にとって正確になる。 + +### 組み込みプロンプトの整理 + +- **削除**: `resources/prompts/coder.md` / `reviewer.md` / `planner.md` / `common/tool-usage.md` +- **新規**: `resources/prompts/default.md` 1 本のみ(内容は本チケットの実装時に author が記述) +- 将来、役割ごとの preset を復活させたくなったら別チケットで追加する(preset 概念そのものが pod-factory の範囲外だった経緯もあり、安易に戻さない) + +## 要件 + +### Schema + +- `manifest::WorkerManifest` と `manifest::WorkerManifestConfig` から `system_prompt` を削除、`instruction: Option` を追加(部分形は Option、resolve 時に `defaults::DEFAULT_INSTRUCTION` で埋める) +- `manifest::defaults` に `DEFAULT_INSTRUCTION: &str = "$insomnia/default"` を追加 + +### Loader + +- `PromptLoader` を prefix addressing 版に書き換える +- API: `PromptLoader::resolve(ref: &str, current: Option<&PromptRef>) -> Result` + - `ref` が `$prefix/path` 形式なら該当 dir を引く + - 素の `name` なら `current` の prefix + dir を前置して再帰 resolve + - `current` が無い状態で素の `name` が来たら **error** +- minijinja の `Environment::set_loader` 内で `current` を追跡する + +### 末尾セクションの組み立て + +- `SystemPromptTemplate::render` 相当の経路で、**user template 出力 + 固定末尾セクション**を連結する +- scope / AGENTS.md を formatter に渡す +- `Pod::ensure_system_prompt_materialized` の既存フローを素直に置き換える + +### `Scope::summary()` + +- `recursive = false` のルールに `[non-recursive]` マーカーを付ける +- 既存の `summary` 形式は維持(`Readable:` / `Writable:` のセクション + インデントのフォーマット) +- `crates/manifest/src/scope.rs` の既存テスト `summary_lists_readable_and_writable` / `summary_excludes_deny_rules` を更新または補完 + +### テスト + +- 既存: `include_resolves_builtin_prompt` / `agents_md_is_injected_when_present` / `files_reserved_namespace_is_empty` / `files_map_*` 系を書き換えまたは削除 +- 新規: + - `instruction_default_resolves_to_insomnia_default` + - `instruction_prefix_addressing_{insomnia,user,workspace}` + - `include_unqualified_resolves_relative_to_current_prefix` + - `include_explicit_prefix_overrides_relative` + - `prefix_with_missing_file_is_hard_error` + - `trailing_section_always_contains_scope_summary` + - `trailing_section_contains_agents_md_when_present` + - `trailing_section_omits_agents_md_when_absent` + - `scope_summary_marks_non_recursive_rules` + +### ドキュメント + +- `docs/pod-factory.md` を更新: + - `files.agents_md` の記述を削除 + - loader の by-name fallthrough 説明を prefix addressing に置き換え + - `instruction` フィールドと `$insomnia/default` デフォルトの記述を追加 + - system prompt 末尾の固定セクション構造を図示 +- 必要なら `docs/system-prompt-template.md` も追随 + +## 他チケットとの関係 + +- **pod-factory (完了済み)**: 本チケットは pod-factory で実装した `PromptLoader` の**一部書き換え**になる。特に「3 層 by-name fallthrough」は撤廃される。pod-factory 自体をロールバックしない +- **tickets/native-gui-mvp.md**: 直接の交差なし。GUI が instruction を差し替えたいときは同じ prefix 形式で渡せば良い +- **tickets/protocol-design.md**: protocol 非依存 +- **tickets/agents-md-ingestion.md (完了済み)**: AGENTS.md 読み取り経路は維持。表面の user template 経路だけが消える + +## 完了条件 + +- manifest `[worker]` から `system_prompt` が消え、`instruction` でファイル参照を渡す形になっている +- `instruction` を省略すれば `$insomnia/default` が使われる +- `$insomnia` / `$user` / `$workspace` の 3 種の prefix でプロンプト資産を参照できる +- prefix 無しの `{% include %}` が include 元ファイルの prefix に相対解決される +- 未知の prefix や不在ファイルは hard error になる +- Pod の system prompt は常に「instruction の render 結果 + scope 要約 + (AGENTS.md があれば)」の構造で組み立てられる +- `SystemPromptContext` から `files` が削除され、user template から AGENTS.md を参照する手段は残っていない +- `Scope::summary()` が `recursive = false` ルールに `[non-recursive]` マーカーを付ける +- `resources/prompts/` は `default.md` 1 本のみ +- 上記挙動がすべて単体テストで担保されている +- `docs/pod-factory.md` が新方針に追随している + +## 範囲外 + +- **preset 概念の復活**: 削除した `coder` / `reviewer` / `planner` を preset として体系化する議論は別チケット +- **instruction をファイル参照以外にする拡張**: インライン minijinja、TOML 配列、等は入れない +- **CLI 変更**: `pod` バイナリの flag は `--overlay` で instruction を上書きできる(`--overlay 'worker.instruction = "$user/foo"'`)形でそのまま使える。新規 flag は追加しない +- **テンプレートエンジンの差し替え**: minijinja を維持 +- **`default.md` 本文の著者判断**: ticket の範囲は枠組み。本文は実装者が書く +- **scope summary に deny ルールを出す改善**: 現行どおり deny は effective permission に焼き込むだけで、summary には出さない