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)
- [ ] 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)

View File

@ -58,7 +58,7 @@ pub struct ProviderConfigPartial {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WorkerManifestConfig {
#[serde(default)]
pub system_prompt: Option<String>,
pub instruction: Option<String>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[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<PodManifestConfig> 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,

View File

@ -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";

View File

@ -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<String>,
/// 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<u32>,
#[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;

View File

@ -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();

View File

@ -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}"
);
}

View File

@ -402,11 +402,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
}
/// 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<C: LlmClient, St: Store> Pod<C, St> {
.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<C: LlmClient, St: Store> Pod<C, St> {
);
}
}
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<St: Store> Pod<Box<dyn LlmClient>, 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

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):
//! 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!`].
//! 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` | `<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 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<PathBuf>,
project_dir: Option<PathBuf>,
workspace_dir: Option<PathBuf>,
}
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<PathBuf>, project_dir: Option<PathBuf>) -> Self {
/// Loader with optional user and workspace prompt directories.
pub fn new(user_dir: Option<PathBuf>, workspace_dir: Option<PathBuf>) -> 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<String> {
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<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(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<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,
}),
},
}
}
/// 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<String> {
let path = dir.join(format!("{name}.md"));
std::fs::read_to_string(path).ok()
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 read_from_include_dir(dir: &Dir<'static>, name: &str) -> Option<String> {
let path = format!("{name}.md");
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)
.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(&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 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(&current)).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 { .. }));
}
}

View File

@ -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<Environment<'static>>,
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<String>) -> Result<Self, SystemPromptError> {
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>,
/// 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<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();
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<String, SystemPromptError> {
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<Utc>,
pub cwd: &'a Path,
pub scope: &'a Scope,
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> {
@ -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::<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)
}
}
fn scope_value(scope: &Scope) -> Value {
let readable: Vec<Value> = scope
.readable_paths()
.map(|p| Value::from(p.display().to_string()))
.collect();
let writable: Vec<Value> = scope
.writable_paths()
.map(|p| Value::from(p.display().to_string()))
.collect();
let mut obj: BTreeMap<String, Value> = 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<PromptRef> {
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<String>) -> SystemPromptContext<'a> {
fn ctx<'a>(
cwd: &'a Path,
scope: &'a Scope,
tools: Vec<String>,
agents_md: Option<String>,
) -> 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"));
}
}

View File

@ -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<LlmEvent> {
]
}
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<Pod<MockClient, FsStore>, PodError> {
let manifest = pod::PodManifest::from_toml(&manifest_toml(template_source)).unwrap();
) -> Result<(Pod<MockClient, FsStore>, 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());
}

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(", ") }}.
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 }}

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 には出さない