instructionファイルの定義・読み込みの実装

This commit is contained in:
Keisuke Hirata 2026-04-16 11:16:16 +09:00
parent 493ed2c781
commit 381d31a1dc
17 changed files with 953 additions and 380 deletions

View File

@ -5,6 +5,7 @@
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md) - [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.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) - [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
- [ ] TUI 拡充 - [ ] TUI 拡充
- [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md) - [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md)

View File

@ -58,7 +58,7 @@ pub struct ProviderConfigPartial {
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig { pub struct WorkerManifestConfig {
#[serde(default)] #[serde(default)]
pub system_prompt: Option<String>, pub instruction: Option<String>,
#[serde(default)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
#[serde(default)] #[serde(default)]
@ -179,7 +179,7 @@ impl ProviderConfigPartial {
impl WorkerManifestConfig { impl WorkerManifestConfig {
fn merge(self, upper: Self) -> Self { fn merge(self, upper: Self) -> 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_tokens: upper.max_tokens.or(self.max_tokens),
max_turns: upper.max_turns.or(self.max_turns), max_turns: upper.max_turns.or(self.max_turns),
temperature: upper.temperature.or(self.temperature), temperature: upper.temperature.or(self.temperature),
@ -275,7 +275,10 @@ impl TryFrom<PodManifestConfig> for PodManifest {
)?; )?;
let worker = WorkerManifest { 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_tokens: cfg.worker.max_tokens,
max_turns: cfg.worker.max_turns, max_turns: cfg.worker.max_turns,
temperature: cfg.worker.temperature, temperature: cfg.worker.temperature,

View File

@ -21,3 +21,8 @@ pub const PRUNE_MIN_SAVINGS: u64 = 4096;
/// Number of most-recent turns retained after a compact. See /// Number of most-recent turns retained after a compact. See
/// [`crate::CompactionConfig::compact_retained_turns`]. /// [`crate::CompactionConfig::compact_retained_turns`].
pub const COMPACT_RETAINED_TURNS: usize = 2; 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";

View File

@ -78,8 +78,13 @@ impl ProviderKind {
/// Worker-level configuration embedded in the manifest. /// Worker-level configuration embedded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerManifest { pub struct WorkerManifest {
#[serde(default)] /// Reference to the instruction prompt asset used as the body of
pub system_prompt: Option<String>, /// 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)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
#[serde(default)] #[serde(default)]
@ -115,6 +120,10 @@ fn default_tool_output_max_bytes() -> usize {
defaults::TOOL_OUTPUT_MAX_BYTES defaults::TOOL_OUTPUT_MAX_BYTES
} }
fn default_instruction() -> String {
defaults::DEFAULT_INSTRUCTION.to_string()
}
impl Default for ToolOutputLimits { impl Default for ToolOutputLimits {
fn default() -> Self { fn default() -> Self {
Self { Self {
@ -270,7 +279,7 @@ permission = "write"
assert!(manifest.provider.api_key_file.is_none()); assert!(manifest.provider.api_key_file.is_none());
assert_eq!(manifest.scope.allow.len(), 1); assert_eq!(manifest.scope.allow.len(), 1);
assert!(manifest.scope.deny.is_empty()); assert!(manifest.scope.deny.is_empty());
assert!(manifest.worker.system_prompt.is_none()); assert_eq!(manifest.worker.instruction, defaults::DEFAULT_INSTRUCTION);
} }
#[test] #[test]
@ -286,7 +295,7 @@ model = "claude-sonnet-4-20250514"
api_key_file = "~/.config/insomnia/keys/anthropic" api_key_file = "~/.config/insomnia/keys/anthropic"
[worker] [worker]
system_prompt = "You are a code reviewer." instruction = "$user/reviewer"
max_tokens = 4096 max_tokens = 4096
temperature = 0.3 temperature = 0.3
@ -310,10 +319,7 @@ permission = "write"
manifest.provider.api_key_file.as_deref(), manifest.provider.api_key_file.as_deref(),
Some(std::path::Path::new("~/.config/insomnia/keys/anthropic")) Some(std::path::Path::new("~/.config/insomnia/keys/anthropic"))
); );
assert_eq!( assert_eq!(manifest.worker.instruction, "$user/reviewer");
manifest.worker.system_prompt.as_deref(),
Some("You are a code reviewer.")
);
assert_eq!(manifest.worker.max_tokens, Some(4096)); assert_eq!(manifest.worker.max_tokens, Some(4096));
assert_eq!(manifest.worker.temperature, Some(0.3)); assert_eq!(manifest.worker.temperature, Some(0.3));
let allow = &manifest.scope.allow; let allow = &manifest.scope.allow;

View File

@ -150,32 +150,42 @@ impl Scope {
/// Human-readable grouping of allow rules, suitable for embedding in /// Human-readable grouping of allow rules, suitable for embedding in
/// LLM system prompts. Deny rules are intentionally omitted — they /// LLM system prompts. Deny rules are intentionally omitted — they
/// only cap effective permission and surface them would mislead the /// 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 /// ```text
/// Readable: /// Readable:
/// - /abs/path1 /// - /abs/path1 [non-recursive]
/// Writable: /// Writable:
/// - /abs/path2 /// - /abs/path2
/// ``` /// ```
pub fn summary(&self) -> String { pub fn summary(&self) -> String {
let mut out = String::new(); fn push_rule(out: &mut String, rule: &ResolvedRule) {
let readable: Vec<_> = self.readable_paths().collect();
if !readable.is_empty() {
out.push_str("Readable:\n");
for p in &readable {
out.push_str(" - "); out.push_str(" - ");
out.push_str(&p.display().to_string()); out.push_str(&rule.target.display().to_string());
if !rule.recursive {
out.push_str(" [non-recursive]");
}
out.push('\n'); out.push('\n');
} }
let mut out = String::new();
if !self.allow.is_empty() {
out.push_str("Readable:\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() { if !writable.is_empty() {
out.push_str("Writable:\n"); out.push_str("Writable:\n");
for p in &writable { for rule in &writable {
out.push_str(" - "); push_rule(&mut out, rule);
out.push_str(&p.display().to_string());
out.push('\n');
} }
} }
if out.ends_with('\n') { if out.ends_with('\n') {
@ -427,6 +437,41 @@ mod tests {
assert!(!summary.contains("secret")); 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] #[test]
fn readable_paths_includes_writable() { fn readable_paths_includes_writable() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();

View File

@ -442,13 +442,13 @@ permission = "write"
} }
#[test] #[test]
fn resolve_produces_loader_with_project_prompts_dir() { fn resolve_produces_loader_with_workspace_prompts_dir() {
use crate::system_prompt::{SystemPromptContext, SystemPromptTemplate}; use crate::system_prompt::{SystemPromptContext, SystemPromptTemplate};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule}; use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let root = tmp.path().canonicalize().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"); let manifest_path = root.join(".insomnia").join("manifest.toml");
write( write(
&manifest_path, &manifest_path,
@ -469,11 +469,11 @@ permission = "write"
root = root.display() root = root.display()
), ),
); );
let project_prompts_dir = root.join(".insomnia").join("prompts"); let workspace_prompts_dir = root.join(".insomnia").join("prompts");
std::fs::create_dir_all(&project_prompts_dir).unwrap(); std::fs::create_dir_all(&workspace_prompts_dir).unwrap();
std::fs::write( std::fs::write(
project_prompts_dir.join("coder.md"), workspace_prompts_dir.join("local.md"),
"PROJECT-OVERRIDE from {{ cwd }}", "WORKSPACE-BODY from {{ cwd }}",
) )
.unwrap(); .unwrap();
@ -483,9 +483,8 @@ permission = "write"
.resolve() .resolve()
.unwrap(); .unwrap();
// The loader must see the project override, not the builtin. // The workspace prompt must be reachable via $workspace/local.
let source = "{% include \"coder\" %}"; let tmpl = SystemPromptTemplate::parse("$workspace/local", loader).unwrap();
let tmpl = SystemPromptTemplate::parse_with_loader(source, loader).unwrap();
let scope_cfg = ScopeConfig { let scope_cfg = ScopeConfig {
allow: vec![ScopeRule { allow: vec![ScopeRule {
target: root.clone(), target: root.clone(),
@ -500,12 +499,12 @@ permission = "write"
cwd: &root, cwd: &root,
scope: &scope, scope: &scope,
tool_names: Vec::new(), tool_names: Vec::new(),
files: std::collections::BTreeMap::new(), agents_md: None,
}; };
let rendered = tmpl.render(&ctx).unwrap(); let rendered = tmpl.render(&ctx).unwrap();
assert!( assert!(
rendered.starts_with("PROJECT-OVERRIDE"), rendered.starts_with("WORKSPACE-BODY"),
"expected project override, got: {rendered}" "expected workspace body, got: {rendered}"
); );
} }

View File

@ -402,11 +402,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
} }
} }
/// Render the manifest-supplied system-prompt template exactly once, /// Render the manifest-supplied instruction template exactly once,
/// just before the first LLM turn, and hand the resulting string to /// just before the first LLM turn, append the fixed trailing
/// the Worker via `set_system_prompt`. Subsequent invocations are /// section (scope summary + optional AGENTS.md), and hand the
/// no-ops: the template field is consumed with `Option::take()`, so /// resulting string to the Worker via `set_system_prompt`.
/// the rendered value persists across all later turns and compaction. /// 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> { fn ensure_system_prompt_materialized(&mut self) -> Result<(), PodError> {
let Some(template) = self.system_prompt_template.take() else { let Some(template) = self.system_prompt_template.take() else {
return Ok(()); return Ok(());
@ -423,9 +425,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
.into_iter() .into_iter()
.map(|d| d.name) .map(|d| d.name)
.collect(); .collect();
let mut files = std::collections::BTreeMap::new(); let agents_md_read = read_agents_md(&self.pwd);
let agents_md = read_agents_md(&self.pwd); for warning in agents_md_read.warnings {
for warning in agents_md.warnings {
if let Some(n) = notifier.as_ref() { if let Some(n) = notifier.as_ref() {
n.notify( n.notify(
NotificationLevel::Warn, NotificationLevel::Warn,
@ -434,15 +435,12 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
); );
} }
} }
if let Some(body) = agents_md.body {
files.insert("agents_md".to_string(), body);
}
let ctx = SystemPromptContext { let ctx = SystemPromptContext {
now: chrono::Utc::now(), now: chrono::Utc::now(),
cwd: &self.pwd, cwd: &self.pwd,
scope: &self.scope, scope: &self.scope,
tool_names, tool_names,
files, agents_md: agents_md_read.body,
}; };
let rendered = template let rendered = template
.render(&ctx) .render(&ctx)
@ -841,17 +839,15 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
let mut worker = Worker::new(client); let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker); apply_worker_manifest(&mut worker, &manifest.worker);
// Parse the system-prompt template eagerly (syntax check only). // Resolve the instruction reference and parse the resulting
// Rendering is deferred to `ensure_system_prompt_materialized` // template eagerly (syntax check only). Rendering is deferred
// at first turn so implementation runtime values (date, tools, // to `ensure_system_prompt_materialized` at first turn so
// scope summary, ...) can be injected. // runtime values (date, tools, scope summary, ...) can be
let system_prompt_template = match manifest.worker.system_prompt.as_deref() { // injected.
Some(source) => Some( let system_prompt_template = Some(
SystemPromptTemplate::parse_with_loader(source, loader) SystemPromptTemplate::parse(&manifest.worker.instruction, loader)
.map_err(|source| PodError::InvalidSystemPromptTemplate { source })?, .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?,
), );
None => None,
};
// Session creation is deferred to the first run (see // Session creation is deferred to the first run (see
// `ensure_session_head`) so the SessionStart entry can capture // `ensure_session_head`) so the SessionStart entry can capture

View File

@ -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): //! Three prefixes address three physical libraries:
//! 1. **Project prompts** — `<project>/.insomnia/prompts/`
//! 2. **User prompts** — `$XDG_CONFIG_HOME/insomnia/prompts/`
//! 3. **Builtin prompts** — baked into the binary from `resources/prompts/`
//! via [`include_dir!`].
//! //!
//! A prompt name is its path stem without the `.md` extension. //! | prefix | location |
//! Subdirectories are supported: `common/tool-usage` maps to //! |--------------|----------------------------------------------------|
//! `common/tool-usage.md` under whichever layer provides it first. //! | `$insomnia` | builtin, baked into the binary via `include_dir!` |
//! | `$user` | `$XDG_CONFIG_HOME/insomnia/prompts/` (or similar) |
//! | `$workspace` | `<project>/.insomnia/prompts/` |
//!
//! A reference is `$<prefix>/<path>` where `<path>` 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 std::path::{Path, PathBuf};
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
use thiserror::Error;
static BUILTIN_PROMPTS: Dir<'static> = static BUILTIN_PROMPTS: Dir<'static> =
include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts"); 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)] #[derive(Debug, Clone)]
pub struct PromptLoader { pub struct PromptLoader {
user_dir: Option<PathBuf>, user_dir: Option<PathBuf>,
project_dir: Option<PathBuf>, workspace_dir: Option<PathBuf>,
} }
impl PromptLoader { impl PromptLoader {
/// Builtins-only loader. Used for direct `Pod::from_manifest` /// Loader with only the builtin `$insomnia` library available.
/// calls that skip the factory cascade (tests, examples, simple /// `$user` / `$workspace` references fail with
/// callers). /// [`LoaderError::PrefixNotConfigured`].
pub fn builtins_only() -> Self { pub fn builtins_only() -> Self {
Self { Self {
user_dir: None, user_dir: None,
project_dir: None, workspace_dir: None,
} }
} }
/// Loader with optional user and project prompts directories. Both /// Loader with optional user and workspace prompt directories.
/// are consulted before falling back to builtins; `None` on either pub fn new(user_dir: Option<PathBuf>, workspace_dir: Option<PathBuf>) -> Self {
/// skips that layer.
pub fn new(user_dir: Option<PathBuf>, project_dir: Option<PathBuf>) -> Self {
Self { Self {
user_dir, user_dir,
project_dir, workspace_dir,
} }
} }
/// Look up the raw template source for `name`. Returns `None` if /// Parse a string reference into a [`PromptRef`]. Unqualified
/// no layer provides it. /// references (no leading `$prefix/`) are resolved against
pub fn lookup(&self, name: &str) -> Option<String> { /// `current`: the prefix is inherited, and the path is joined to
if let Some(ref dir) = self.project_dir { /// the current ref's directory.
if let Some(s) = read_from_dir(dir, name) { pub fn parse_ref(
return Some(s); &self,
raw: &str,
current: Option<&PromptRef>,
) -> Result<PromptRef, LoaderError> {
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('$') {
if let Some(ref dir) = self.user_dir { let (prefix_name, rest) =
if let Some(s) = read_from_dir(dir, name) { prefix.split_once('/').ok_or_else(|| LoaderError::InvalidRef {
return Some(s); raw: raw.to_string(),
} reason: "prefix must be followed by '/'".into(),
} })?;
read_from_include_dir(&BUILTIN_PROMPTS, name) 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,
})
} }
} }
fn read_from_dir(dir: &Path, name: &str) -> Option<String> { /// Resolve a [`PromptRef`] to its raw template source. Hard-errors
let path = dir.join(format!("{name}.md")); /// when the prefix is not configured or the file does not exist.
std::fs::read_to_string(path).ok() pub fn load(&self, reference: &PromptRef) -> Result<String, LoaderError> {
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,
}),
},
}
} }
fn read_from_include_dir(dir: &Dir<'static>, name: &str) -> Option<String> { /// Parse `raw` against `current`, then load the resulting ref.
let path = format!("{name}.md"); /// 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<Prefix, LoaderError> {
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 normalize_path(raw: &str, rest: &str) -> Result<String, LoaderError> {
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<String, LoaderError> {
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<String, LoaderError> {
let path = format!("{}.md", reference.path);
dir.get_file(&path) dir.get_file(&path)
.and_then(|f| f.contents_utf8()) .and_then(|f| f.contents_utf8())
.map(|s| s.to_string()) .map(|s| s.to_string())
.ok_or_else(|| LoaderError::NotFound {
reference: reference.clone(),
})
} }
#[cfg(test)] #[cfg(test)]
@ -80,58 +267,107 @@ mod tests {
use tempfile::TempDir; use tempfile::TempDir;
#[test] #[test]
fn builtin_coder_prompt_present() { fn builtin_default_resolves() {
let loader = PromptLoader::builtins_only(); let loader = PromptLoader::builtins_only();
let coder = loader.lookup("coder").expect("coder builtin missing"); let (r, source) = loader.resolve("$insomnia/default", None).unwrap();
assert!(coder.contains("software engineering agent")); assert_eq!(r.to_qualified_string(), "$insomnia/default");
assert!(!source.is_empty());
} }
#[test] #[test]
fn builtin_subdirectory_lookup() { fn builtin_subdirectory_lookup() {
let loader = PromptLoader::builtins_only(); let loader = PromptLoader::builtins_only();
let tu = loader let (_, source) = loader.resolve("$insomnia/common/tool-usage", None).unwrap();
.lookup("common/tool-usage") assert!(source.contains("tool"));
.expect("common/tool-usage missing");
assert!(tu.contains("tool"));
} }
#[test] #[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(); 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] #[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(&current)).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(&current)).unwrap();
assert_eq!(sibling.to_qualified_string(), "$insomnia/other");
}
#[test]
fn explicit_prefix_overrides_current() {
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
let user_dir = tmp.path().to_path_buf(); 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); 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(&current)).unwrap();
assert_eq!(reference.to_qualified_string(), "$user/custom");
assert_eq!(source, "user-body");
} }
#[test] #[test]
fn project_layer_overrides_user_and_builtin() { fn traversal_segments_rejected() {
let tmp = TempDir::new().unwrap(); let loader = PromptLoader::builtins_only();
let user_dir = tmp.path().join("user"); let err = loader.resolve("$insomnia/../etc/passwd", None).unwrap_err();
let project_dir = tmp.path().join("project"); assert!(matches!(err, LoaderError::InvalidRef { .. }));
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
} }
} }

View File

@ -1,11 +1,16 @@
//! System prompt template machinery for the Pod layer. //! System prompt template machinery for the Pod layer.
//! //!
//! Manifests describe `system_prompt` as a minijinja template string. //! Manifests describe the system prompt body as a reference to a
//! The template is parsed eagerly at `Pod::from_manifest` (syntax check //! prompt asset (`worker.instruction`, see [`manifest::WorkerManifest`]).
//! only) and held on the Pod until `ensure_system_prompt_materialized` //! [`SystemPromptTemplate`] resolves that reference through a
//! renders it exactly once, just before the first LLM turn. The rendered //! [`PromptLoader`], parses the source as a minijinja template, and
//! string is pushed to the worker via `set_system_prompt` and is reused //! eagerly syntax-checks it at Pod construction. The final system
//! for every subsequent turn, including after compaction. //! 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::collections::BTreeMap;
use std::path::Path; use std::path::Path;
@ -17,87 +22,124 @@ use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior}; use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error; use thiserror::Error;
use crate::prompt_loader::PromptLoader; use crate::prompt_loader::{LoaderError, PromptLoader, PromptRef};
const TEMPLATE_NAME: &str = "system_prompt";
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum SystemPromptError { pub enum SystemPromptError {
#[error("failed to resolve instruction reference: {0}")]
LoaderResolve(#[source] LoaderError),
#[error("system prompt template parse error: {0}")] #[error("system prompt template parse error: {0}")]
Parse(String), Parse(String),
#[error("system prompt template render error: {0}")] #[error("system prompt template render error: {0}")]
Render(String), Render(String),
} }
/// Parsed system-prompt template. Holds a minijinja Environment with a /// Parsed instruction template bound to a prompt loader.
/// single named template; rendering only needs a fresh [`SystemPromptContext`]. ///
/// 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)] #[derive(Clone)]
pub struct SystemPromptTemplate { pub struct SystemPromptTemplate {
env: Arc<Environment<'static>>, env: Arc<Environment<'static>>,
instruction_name: String,
} }
impl SystemPromptTemplate { impl SystemPromptTemplate {
/// Parse a template source with a builtins-only prompt loader. /// Parse the instruction asset referenced by `instruction_ref`
/// Convenience wrapper for callers that do not need user/project /// using the supplied [`PromptLoader`]. The reference is resolved
/// prompt layers — see [`SystemPromptTemplate::parse_with_loader`] /// at parse time so syntax errors surface immediately.
/// for the factory-driven path. pub fn parse(
pub fn parse(source: impl Into<String>) -> Result<Self, SystemPromptError> { instruction_ref: &str,
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<String>,
loader: PromptLoader, loader: PromptLoader,
) -> Result<Self, SystemPromptError> { ) -> Result<Self, SystemPromptError> {
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(); let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict); env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_loader(move |name| match loader.lookup(name) {
Some(source) => Ok(Some(source)), // Path-join callback: compute the target template name when a
None => Err(minijinja::Error::new( // template includes another by a possibly-unqualified string.
ErrorKind::TemplateNotFound, // The joined name is then looked up via `set_loader` below.
format!("prompt asset '{name}' not found"), 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()))?; .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 /// Render the instruction body and append the fixed trailing
/// surface as [`SystemPromptError::Render`]. /// 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<String, SystemPromptError> { pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
let tmpl = self let tmpl = self
.env .env
.get_template(TEMPLATE_NAME) .get_template(&self.instruction_name)
.map_err(|e| SystemPromptError::Render(e.to_string()))?; .map_err(|e| SystemPromptError::Render(e.to_string()))?;
tmpl.render(ctx.to_minijinja_value()) let body = tmpl
.map_err(|e| SystemPromptError::Render(e.to_string())) .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 { impl std::fmt::Debug for SystemPromptTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemPromptTemplate") f.debug_struct("SystemPromptTemplate")
.field("instruction", &self.instruction_name)
.finish_non_exhaustive() .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 /// Scope summary and AGENTS.md are deliberately **not** exposed to the
/// (supplied by a separate ticket). It is always present so template /// template — they live in the Rust-owned trailing section so user
/// authors can reference `{{ files.agents_md }}` without having to guard /// templates cannot drop them on the floor.
/// for key existence.
pub struct SystemPromptContext<'a> { pub struct SystemPromptContext<'a> {
pub now: DateTime<Utc>, pub now: DateTime<Utc>,
pub cwd: &'a Path, pub cwd: &'a Path,
pub scope: &'a Scope, pub scope: &'a Scope,
pub tool_names: Vec<String>, pub tool_names: Vec<String>,
pub files: BTreeMap<String, String>, /// 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<String>,
} }
impl<'a> SystemPromptContext<'a> { impl<'a> SystemPromptContext<'a> {
@ -116,7 +158,6 @@ impl<'a> SystemPromptContext<'a> {
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)), Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
); );
root.insert("cwd".into(), Value::from(self.cwd.display().to_string())); root.insert("cwd".into(), Value::from(self.cwd.display().to_string()));
root.insert("scope".into(), scope_value(self.scope));
root.insert( root.insert(
"tools".into(), "tools".into(),
Value::from( Value::from(
@ -127,33 +168,45 @@ impl<'a> SystemPromptContext<'a> {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
), ),
); );
root.insert(
"files".into(),
Value::from(
self.files
.iter()
.map(|(k, v)| (k.clone(), Value::from(v.clone())))
.collect::<BTreeMap<String, Value>>(),
),
);
Value::from(root) Value::from(root)
} }
} }
fn scope_value(scope: &Scope) -> Value { /// Build the final system prompt by appending the fixed trailing
let readable: Vec<Value> = scope /// section to `body`. Exposed at the module level so callers that skip
.readable_paths() /// the template path (e.g. pre-rendered content in tests) can reuse the
.map(|p| Value::from(p.display().to_string())) /// exact same formatter.
.collect(); pub fn append_trailing_section(body: &str, scope: &Scope, agents_md: Option<&str>) -> String {
let writable: Vec<Value> = scope let mut out = String::with_capacity(body.len() + 256);
.writable_paths() out.push_str(body);
.map(|p| Value::from(p.display().to_string())) if !body.ends_with('\n') {
.collect(); out.push('\n');
let mut obj: BTreeMap<String, Value> = BTreeMap::new(); }
obj.insert("readable".into(), Value::from(readable)); out.push('\n');
obj.insert("writable".into(), Value::from(writable)); out.push_str("---\n## Working boundaries\n\n");
obj.insert("summary".into(), Value::from(scope.summary())); out.push_str(&scope.summary());
Value::from(obj) 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<PromptRef> {
loader.parse_ref(raw, None).ok()
} }
#[cfg(test)] #[cfg(test)]
@ -179,44 +232,167 @@ mod tests {
Scope::from_config(&cfg, dir).unwrap() Scope::from_config(&cfg, dir).unwrap()
} }
fn ctx<'a>(cwd: &'a Path, scope: &'a Scope, tools: Vec<String>) -> SystemPromptContext<'a> { fn ctx<'a>(
cwd: &'a Path,
scope: &'a Scope,
tools: Vec<String>,
agents_md: Option<String>,
) -> SystemPromptContext<'a> {
SystemPromptContext { SystemPromptContext {
now: fixed_now(), now: fixed_now(),
cwd, cwd,
scope, scope,
tool_names: tools, 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] #[test]
fn parse_succeeds_for_minimal_template() { fn instruction_default_resolves_to_insomnia_default() {
let t = SystemPromptTemplate::parse("hello").unwrap(); let loader = PromptLoader::builtins_only();
let tmpl = SystemPromptTemplate::parse("$insomnia/default", loader).unwrap();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path()); let scope = build_scope(dir.path());
let rendered = t.render(&ctx(dir.path(), &scope, vec![])).unwrap(); let rendered = tmpl
assert_eq!(rendered, "hello"); .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] #[test]
fn parse_fails_on_syntax_error() { 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(_))); assert!(matches!(err, SystemPromptError::Parse(_)));
} }
#[test] #[test]
fn render_substitutes_date_cwd_tools() { fn render_fails_on_undefined_variable() {
let t = SystemPromptTemplate::parse( let (_tmp, loader) = user_loader_with("ghost.md", "{{ ghost }}");
"date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}", let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap();
)
.unwrap();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path()); 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( .render(&ctx(
dir.path(), dir.path(),
&scope, &scope,
vec!["alpha".into(), "beta".into()], vec!["alpha".into(), "beta".into()],
None,
)) ))
.unwrap(); .unwrap();
assert!(rendered.contains("date=2026-04-15")); assert!(rendered.contains("date=2026-04-15"));
@ -225,81 +401,43 @@ mod tests {
} }
#[test] #[test]
fn render_fails_on_undefined_variable() { fn trailing_section_always_contains_scope_summary() {
let t = SystemPromptTemplate::parse("{{ ghost }}").unwrap(); let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path()); let scope = build_scope(dir.path());
let err = t.render(&ctx(dir.path(), &scope, vec![])).unwrap_err(); let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
assert!(matches!(err, SystemPromptError::Render(_))); assert!(rendered.contains("## Working boundaries"));
assert!(rendered.contains("Readable:"));
assert!(rendered.contains("Writable:"));
} }
#[test] #[test]
fn escape_double_braces() { fn trailing_section_contains_agents_md_when_present() {
let t = SystemPromptTemplate::parse("literal {{ '{{' }} here").unwrap(); let (_tmp, loader) = user_loader_with("body.md", "BODY");
let dir = TempDir::new().unwrap(); let tmpl = SystemPromptTemplate::parse("$user/body", loader).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();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path()); let scope = build_scope(dir.path());
let rendered = tmpl let rendered = tmpl
.render(&ctx( .render(&ctx(
dir.path(), dir.path(),
&scope, &scope,
vec!["Read".into(), "Edit".into()], vec![],
Some("PROJECT DOCS".into()),
)) ))
.unwrap(); .unwrap();
assert!(rendered.starts_with("HEAD")); assert!(rendered.contains("## Project instructions (AGENTS.md)"));
// The common/tool-usage builtin references {{ tools | join(", ") }} assert!(rendered.contains("PROJECT DOCS"));
// so including it must have resolved that expression with the
// parent scope's variables.
assert!(rendered.contains("Read"));
assert!(rendered.contains("Edit"));
} }
#[test] #[test]
fn include_unknown_prompt_fails_at_render() { fn trailing_section_omits_agents_md_when_absent() {
let tmpl = SystemPromptTemplate::parse_with_loader( let (_tmp, loader) = user_loader_with("body.md", "BODY");
"{% include \"nonexistent-prompt\" %}", let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
PromptLoader::builtins_only(),
)
.unwrap();
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path()); let scope = build_scope(dir.path());
let err = tmpl.render(&ctx(dir.path(), &scope, vec![])).unwrap_err(); let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
assert!(matches!(err, SystemPromptError::Render(_))); assert!(!rendered.contains("AGENTS.md"));
} assert!(!rendered.contains("Project instructions"));
#[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");
} }
} }

View File

@ -1,3 +1,4 @@
use std::path::PathBuf;
use std::pin::Pin; use std::pin::Pin;
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering}; 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 llm_worker::llm_client::{ClientError, LlmClient, Request};
use session_store::{FsStore, LogEntry, Store}; use session_store::{FsStore, LogEntry, Store};
use pod::{Pod, PodError, SystemPromptTemplate}; use pod::{Pod, PodError, PromptLoader, SystemPromptTemplate};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock LLM Client // Mock LLM Client
@ -60,13 +61,7 @@ fn single_text_events(text: &str) -> Vec<LlmEvent> {
] ]
} }
fn manifest_toml(system_prompt: Option<&str>) -> String { const MINIMAL_MANIFEST_TOML: &str = r#"
let prompt_line = match system_prompt {
Some(s) => format!("system_prompt = {:?}\n", s),
None => String::new(),
};
format!(
r#"
[pod] [pod]
name = "test-pod" name = "test-pod"
pwd = "./" pwd = "./"
@ -77,19 +72,22 @@ model = "test-model"
[worker] [worker]
max_tokens = 100 max_tokens = 100
{prompt_line}
[[scope.allow]] [[scope.allow]]
target = "./" target = "./"
permission = "write" permission = "write"
"# "#;
)
}
async fn make_pod_with_template( /// Build a Pod with a synthetic instruction template.
template_source: Option<&str>, ///
/// 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, client: MockClient,
) -> Result<Pod<MockClient, FsStore>, PodError> { ) -> Result<(Pod<MockClient, FsStore>, PathBuf), PodError> {
let manifest = pod::PodManifest::from_toml(&manifest_toml(template_source)).unwrap(); let manifest = pod::PodManifest::from_toml(MINIMAL_MANIFEST_TOML).unwrap();
let store_tmp = tempfile::tempdir().unwrap(); let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.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(); let scope = pod::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp); std::mem::forget(pwd_tmp);
let worker = Worker::new(client); let user_prompts_tmp = tempfile::tempdir().unwrap();
let mut pod = Pod::new(manifest, worker, store, pwd, scope).await?; 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 worker = Worker::new(client);
let template = SystemPromptTemplate::parse(source) 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 })?; .map_err(|source| PodError::InvalidSystemPromptTemplate { source })?;
pod.set_system_prompt_template(template); pod.set_system_prompt_template(template);
}
Ok(pod) Ok((pod, pwd))
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -117,10 +119,10 @@ async fn make_pod_with_template(
#[tokio::test] #[tokio::test]
async fn template_parse_rejects_invalid_syntax() { async fn template_parse_rejects_invalid_syntax() {
let err = SystemPromptTemplate::parse("{{ unclosed").unwrap_err(); let user_prompts_tmp = tempfile::tempdir().unwrap();
// Surfaces via PodError::InvalidSystemPromptTemplate when used with std::fs::write(user_prompts_tmp.path().join("broken.md"), "{{ unclosed").unwrap();
// Pod::from_manifest — tested at the SystemPromptTemplate level here let loader = PromptLoader::new(Some(user_prompts_tmp.path().to_path_buf()), None);
// because building a Pod via from_manifest requires a real provider. let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err();
let pod_err: PodError = PodError::InvalidSystemPromptTemplate { source: err }; let pod_err: PodError = PodError::InvalidSystemPromptTemplate { source: err };
assert!(matches!( assert!(matches!(
pod_err, pod_err,
@ -131,7 +133,7 @@ async fn template_parse_rejects_invalid_syntax() {
#[tokio::test] #[tokio::test]
async fn template_is_not_materialised_before_first_run() { async fn template_is_not_materialised_before_first_run() {
let client = MockClient::new(vec![single_text_events("ok")]); 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. // Before first run, worker still has no system prompt.
assert!(pod.worker().get_system_prompt().is_none()); assert!(pod.worker().get_system_prompt().is_none());
} }
@ -139,8 +141,8 @@ async fn template_is_not_materialised_before_first_run() {
#[tokio::test] #[tokio::test]
async fn materialise_on_first_turn_populates_worker() { async fn materialise_on_first_turn_populates_worker() {
let client = MockClient::new(vec![single_text_events("ok")]); let client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template( let (mut pod, pwd) = make_pod_with_body(
Some("date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}"), "date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}",
client, client,
) )
.await .await
@ -153,27 +155,28 @@ async fn materialise_on_first_turn_populates_worker() {
.to_string(); .to_string();
assert!(rendered.contains("date=")); assert!(rendered.contains("date="));
assert!(rendered.contains("cwd=")); assert!(rendered.contains("cwd="));
assert!(rendered.contains(&pod.pwd().display().to_string())); assert!(rendered.contains(&pwd.display().to_string()));
assert!(rendered.starts_with("date=")); assert!(rendered.starts_with("date="));
// Trailing fixed section must be appended.
assert!(rendered.contains("## Working boundaries"));
} }
#[tokio::test] #[tokio::test]
async fn session_start_state_captures_rendered_prompt() { async fn session_start_state_captures_rendered_prompt() {
let client = MockClient::new(vec![single_text_events("ok")]); 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 .await
.unwrap(); .unwrap();
pod.run("hi").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 entries = pod.store().read_all(pod.session_id()).await.unwrap();
let first = entries.first().expect("at least one entry"); let first = entries.first().expect("at least one entry");
match &first.entry { match &first.entry {
LogEntry::SessionStart { system_prompt, .. } => { LogEntry::SessionStart { system_prompt, .. } => {
let sp = system_prompt.as_deref().expect("system prompt set"); let sp = system_prompt.as_deref().expect("system prompt set");
assert!(sp.starts_with("hello cwd=")); 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:?}"), other => panic!("expected SessionStart as first entry, got {other:?}"),
} }
@ -182,24 +185,18 @@ async fn session_start_state_captures_rendered_prompt() {
#[tokio::test] #[tokio::test]
async fn render_failure_propagates_as_pod_error() { async fn render_failure_propagates_as_pod_error() {
let client = MockClient::new(vec![single_text_events("ok")]); let client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template(Some("{{ ghost }}"), client) let (mut pod, _pwd) = make_pod_with_body("{{ ghost }}", client).await.unwrap();
.await
.unwrap();
let err = pod.run("hi").await.unwrap_err(); let err = pod.run("hi").await.unwrap_err();
assert!(matches!(err, PodError::SystemPromptRender { .. })); assert!(matches!(err, PodError::SystemPromptRender { .. }));
} }
#[tokio::test] #[tokio::test]
async fn materialise_runs_only_once_across_turns() { 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![ let client = MockClient::new(vec![
single_text_events("first"), single_text_events("first"),
single_text_events("second"), 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 .await
.unwrap(); .unwrap();
pod.run("one").await.unwrap(); pod.run("one").await.unwrap();
@ -210,85 +207,69 @@ async fn materialise_runs_only_once_across_turns() {
} }
#[tokio::test] #[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 client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template( let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
Some( std::fs::write(pwd.join("AGENTS.md"), "# project rules\nbe kind").unwrap();
"{% 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();
pod.run("hi").await.unwrap(); pod.run("hi").await.unwrap();
let rendered = pod.worker().get_system_prompt().unwrap().to_string(); 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] #[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 client = MockClient::new(vec![single_text_events("ok")]);
let mut pod = make_pod_with_template( let (mut pod, _pwd) = make_pod_with_body("BODY", client).await.unwrap();
Some("{% if files.agents_md is defined %}HAS{% else %}NONE{% endif %}"),
client,
)
.await
.unwrap();
// No AGENTS.md written.
pod.run("hi").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] #[tokio::test]
async fn agents_md_not_reread_after_compact() { 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![ let client = MockClient::new(vec![
single_text_events("a"), single_text_events("a"),
single_text_events("b"), single_text_events("b"),
single_text_events("summary"), single_text_events("summary"),
single_text_events("c"), single_text_events("c"),
]); ]);
let mut pod = make_pod_with_template( let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
Some("{{ files.agents_md }}"), let agents_path = pwd.join("AGENTS.md");
client,
)
.await
.unwrap();
let agents_path = pod.pwd().join("AGENTS.md");
std::fs::write(&agents_path, "original").unwrap(); std::fs::write(&agents_path, "original").unwrap();
pod.run("first").await.unwrap(); pod.run("first").await.unwrap();
let before = pod.worker().get_system_prompt().unwrap().to_string(); let before = pod.worker().get_system_prompt().unwrap().to_string();
assert_eq!(before, "original"); assert!(before.contains("original"));
pod.run("second").await.unwrap(); pod.run("second").await.unwrap();
// Mutate the file after the first turn — must not affect the cached // Mutate the file after the first turn — must not affect the cached
// system prompt either on a subsequent turn or across compaction. // system prompt either on a subsequent turn or across compaction.
std::fs::write(&agents_path, "mutated").unwrap(); std::fs::write(&agents_path, "mutated").unwrap();
pod.compact(1).await.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(); 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] #[tokio::test]
async fn compact_preserves_system_prompt() { 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![ let client = MockClient::new(vec![
single_text_events("a"), single_text_events("a"),
single_text_events("b"), single_text_events("b"),
single_text_events("summary"), single_text_events("summary"),
single_text_events("c"), 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 .await
.unwrap(); .unwrap();
@ -301,8 +282,6 @@ async fn compact_preserves_system_prompt() {
let after = pod.worker().get_system_prompt().unwrap().to_string(); let after = pod.worker().get_system_prompt().unwrap().to_string();
assert_eq!(before, after); 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(); pod.run("third").await.unwrap();
assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str()); assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str());
} }

View File

@ -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" %}

View File

@ -2,10 +2,5 @@
You have access to these tools: {{ tools | join(", ") }}. You have access to these tools: {{ tools | join(", ") }}.
Prefer the most specific tool for the job. When reading files you already 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.
know the path of, use the file-read tool directly instead of searching.
When searching, use grep/glob primitives rather than shell pipelines. When searching, use grep/glob primitives rather than shell pipelines.
Only touch paths inside your scope. Your scope is:
{{ scope.summary }}

View File

@ -0,0 +1,2 @@
You are operating in {{ cwd }}.
Today is {{ date }}.

View File

@ -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" %}

View File

@ -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" %}

View File

@ -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" %}

View File

@ -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<String>`**`worker.instruction: Option<String>`** に置き換える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` | `<project>/.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 からは触れない):
```
<instruction file のレンダ結果>
---
## 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:
- <local-path> [non-recursive]
Writable:
- <repo>
```
- マーカーの位置はパスの末尾(パースしやすさより人間可読性を優先)
- 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<String>` を追加(部分形は 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<String, Error>`
- `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 には出さない