instructionファイルの定義・読み込みの実装
This commit is contained in:
parent
493ed2c781
commit
381d31a1dc
1
TODO.md
1
TODO.md
|
|
@ -5,6 +5,7 @@
|
|||
- [ ] Compact の改善(要約品質 + 挙動詳細) → [tickets/compact-improvements.md](tickets/compact-improvements.md)
|
||||
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
|
||||
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
|
||||
- [ ] instruction のファイル参照化と system prompt の hardcode wrap → [tickets/instruction-file-refs.md](tickets/instruction-file-refs.md)
|
||||
- [ ] ネイティブ GUI クライアント MVP → [tickets/native-gui-mvp.md](tickets/native-gui-mvp.md)
|
||||
- [ ] TUI 拡充
|
||||
- [ ] Pod の明示的 shutdown → [tickets/tui-pod-shutdown.md](tickets/tui-pod-shutdown.md)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$insomnia/common/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_from_root_file_has_empty_dir() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$insomnia/default", None).unwrap();
|
||||
let sibling = loader.parse_ref("other", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$insomnia/other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_prefix_overrides_current() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("coder.md"), "user-coder").unwrap();
|
||||
|
||||
std::fs::write(user_dir.join("custom.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
assert_eq!(loader.lookup("coder").as_deref(), Some("user-coder"));
|
||||
|
||||
let current = loader.parse_ref("$insomnia/default", None).unwrap();
|
||||
// Even with an $insomnia-rooted current, an explicit $user
|
||||
// prefix must win.
|
||||
let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), "$user/custom");
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_layer_overrides_user_and_builtin() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().join("user");
|
||||
let project_dir = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&user_dir).unwrap();
|
||||
std::fs::create_dir_all(&project_dir).unwrap();
|
||||
std::fs::write(user_dir.join("coder.md"), "user-coder").unwrap();
|
||||
std::fs::write(project_dir.join("coder.md"), "project-coder").unwrap();
|
||||
|
||||
let loader = PromptLoader::new(Some(user_dir), Some(project_dir));
|
||||
assert_eq!(loader.lookup("coder").as_deref(), Some("project-coder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_through_to_builtin_when_user_missing_name() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
// user layer only defines "only-user", not "coder"
|
||||
std::fs::write(user_dir.join("only-user.md"), "x").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
assert!(loader.lookup("coder").is_some()); // from builtin
|
||||
fn traversal_segments_rejected() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$insomnia/../etc/passwd", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::InvalidRef { .. }));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" %}
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
2
resources/prompts/common/workspace.md
Normal file
2
resources/prompts/common/workspace.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
You are operating in {{ cwd }}.
|
||||
Today is {{ date }}.
|
||||
5
resources/prompts/default.md
Normal file
5
resources/prompts/default.md
Normal 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" %}
|
||||
|
|
@ -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" %}
|
||||
|
|
@ -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" %}
|
||||
184
tickets/instruction-file-refs.md
Normal file
184
tickets/instruction-file-refs.md
Normal 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 には出さない
|
||||
Loading…
Reference in New Issue
Block a user