worker: replace filesystem prompts with effective DCDL catalog
This commit is contained in:
+427
-587
File diff suppressed because it is too large
Load Diff
@@ -1,425 +0,0 @@
|
||||
//! Prefix-addressed prompt asset loader used by [`crate::SystemPromptTemplate`].
|
||||
//!
|
||||
//! Three prefixes address three physical libraries:
|
||||
//!
|
||||
//! | prefix | location |
|
||||
//! |--------------|---------------------------------------------------------|
|
||||
//! | `$yoi` | builtin, baked into the binary via `include_dir!` |
|
||||
//! | `$user` | `<config_dir>/prompts/` (resolved by `manifest::paths`) |
|
||||
//! | `$workspace` | `<project>/.yoi/prompts/` |
|
||||
//!
|
||||
//! A reference is `$<prefix>/<path>` where `<path>` is a `/`-separated
|
||||
//! name without the `.md` extension (e.g. `$yoi/common/header`).
|
||||
//! Unqualified names (no `$prefix/` at the front) are resolved relative
|
||||
//! to an optional current reference — typically the file that issued
|
||||
//! the `{% include %}` — so a prompt library can be authored as a
|
||||
//! self-contained directory.
|
||||
//!
|
||||
//! Missing files produce a [`LoaderError::NotFound`]; there is no
|
||||
//! fallthrough between layers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use thiserror::Error;
|
||||
|
||||
static BUILTIN_PROMPTS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/../../resources/prompts");
|
||||
|
||||
const PREFIX_YOI: &str = "$yoi";
|
||||
const PREFIX_USER: &str = "$user";
|
||||
const PREFIX_WORKSPACE: &str = "$workspace";
|
||||
|
||||
/// Prefix-resolved reference to a prompt asset. Produced by
|
||||
/// [`PromptLoader::parse_ref`] from a user-supplied string such as
|
||||
/// `"$yoi/default"`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PromptRef {
|
||||
prefix: Prefix,
|
||||
/// Relative path under the prefix root, without the `.md` extension.
|
||||
/// `/`-separated, never empty, never starts with `/`.
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Prefix {
|
||||
Yoi,
|
||||
User,
|
||||
Workspace,
|
||||
}
|
||||
|
||||
impl Prefix {
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Yoi => PREFIX_YOI,
|
||||
Self::User => PREFIX_USER,
|
||||
Self::Workspace => PREFIX_WORKSPACE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptRef {
|
||||
/// Produce a canonical `$prefix/path` string.
|
||||
pub fn to_qualified_string(&self) -> String {
|
||||
format!("{}/{}", self.prefix.as_str(), self.path)
|
||||
}
|
||||
|
||||
/// Directory portion (leading prefix segments minus the file name),
|
||||
/// joined with `/`. Returns an empty string when the ref points at
|
||||
/// a file directly under the prefix root.
|
||||
fn dir(&self) -> &str {
|
||||
match self.path.rsplit_once('/') {
|
||||
Some((dir, _)) => dir,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced when resolving a [`PromptRef`].
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LoaderError {
|
||||
#[error("invalid prompt reference '{raw}': {reason}")]
|
||||
InvalidRef { raw: String, reason: String },
|
||||
#[error("unknown prompt prefix '{prefix}' in reference '{raw}'")]
|
||||
UnknownPrefix { raw: String, prefix: String },
|
||||
#[error(
|
||||
"unqualified prompt reference '{raw}' requires a current prefix \
|
||||
(include it from inside another template, or use an explicit \
|
||||
$prefix/path form)"
|
||||
)]
|
||||
UnqualifiedWithoutCurrent { raw: String },
|
||||
#[error("prompt prefix '{prefix}' is not configured for this loader")]
|
||||
PrefixNotConfigured { prefix: &'static str },
|
||||
#[error("prompt asset not found: '{}'", .reference.to_qualified_string())]
|
||||
NotFound { reference: PromptRef },
|
||||
#[error("failed to read prompt asset '{}': {source}", .reference.to_qualified_string())]
|
||||
Io {
|
||||
reference: PromptRef,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
/// Loader that resolves [`PromptRef`]s against the configured prompt
|
||||
/// libraries. Cheap to clone.
|
||||
///
|
||||
/// Also carries the auto-discovered `prompts.toml` pack file paths so
|
||||
/// [`crate::prompt::catalog::PromptCatalog`] can read the same user/workspace
|
||||
/// layers without a separate plumbing channel. These fields do not
|
||||
/// affect `$prefix` asset resolution — they are purely metadata
|
||||
/// consulted by the catalog loader.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PromptLoader {
|
||||
user_dir: Option<PathBuf>,
|
||||
workspace_dir: Option<PathBuf>,
|
||||
user_pack_file: Option<PathBuf>,
|
||||
workspace_pack_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl PromptLoader {
|
||||
/// Loader with only the builtin `$yoi` library available.
|
||||
/// `$user` / `$workspace` references fail with
|
||||
/// [`LoaderError::PrefixNotConfigured`].
|
||||
pub fn builtins_only() -> Self {
|
||||
Self {
|
||||
user_dir: None,
|
||||
workspace_dir: None,
|
||||
user_pack_file: None,
|
||||
workspace_pack_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Loader with optional user and workspace prompt directories.
|
||||
pub fn new(user_dir: Option<PathBuf>, workspace_dir: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
user_dir,
|
||||
workspace_dir,
|
||||
user_pack_file: None,
|
||||
workspace_pack_file: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override pack file paths supplied by the caller's profile/manifest
|
||||
/// resolution context.
|
||||
pub fn with_pack_files(
|
||||
mut self,
|
||||
user_pack_file: Option<PathBuf>,
|
||||
workspace_pack_file: Option<PathBuf>,
|
||||
) -> Self {
|
||||
self.user_pack_file = user_pack_file;
|
||||
self.workspace_pack_file = workspace_pack_file;
|
||||
self
|
||||
}
|
||||
|
||||
/// Root of the `$user` prompt library, if configured.
|
||||
pub fn user_dir(&self) -> Option<&Path> {
|
||||
self.user_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Root of the `$workspace` prompt library, if configured.
|
||||
pub fn workspace_dir(&self) -> Option<&Path> {
|
||||
self.workspace_dir.as_deref()
|
||||
}
|
||||
|
||||
/// Auto-discovered path to the user-layer `prompts.toml` pack, if any.
|
||||
pub fn user_pack_file(&self) -> Option<&Path> {
|
||||
self.user_pack_file.as_deref()
|
||||
}
|
||||
|
||||
/// Auto-discovered path to the workspace-layer `prompts.toml` pack, if any.
|
||||
pub fn workspace_pack_file(&self) -> Option<&Path> {
|
||||
self.workspace_pack_file.as_deref()
|
||||
}
|
||||
|
||||
/// Parse a string reference into a [`PromptRef`]. Unqualified
|
||||
/// references (no leading `$prefix/`) are resolved against
|
||||
/// `current`: the prefix is inherited, and the path is joined to
|
||||
/// the current ref's directory.
|
||||
pub fn parse_ref(
|
||||
&self,
|
||||
raw: &str,
|
||||
current: Option<&PromptRef>,
|
||||
) -> Result<PromptRef, LoaderError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "reference must not be empty".into(),
|
||||
});
|
||||
}
|
||||
if let Some(prefix) = trimmed.strip_prefix('$') {
|
||||
let (prefix_name, rest) =
|
||||
prefix
|
||||
.split_once('/')
|
||||
.ok_or_else(|| LoaderError::InvalidRef {
|
||||
raw: raw.to_string(),
|
||||
reason: "prefix must be followed by '/'".into(),
|
||||
})?;
|
||||
let prefix = parse_prefix(raw, prefix_name)?;
|
||||
let path = normalize_path(raw, rest)?;
|
||||
Ok(PromptRef { prefix, path })
|
||||
} else {
|
||||
let Some(current) = current else {
|
||||
return Err(LoaderError::UnqualifiedWithoutCurrent {
|
||||
raw: raw.to_string(),
|
||||
});
|
||||
};
|
||||
let dir = current.dir();
|
||||
let joined = if dir.is_empty() {
|
||||
trimmed.to_string()
|
||||
} else {
|
||||
format!("{dir}/{trimmed}")
|
||||
};
|
||||
let path = normalize_path(raw, &joined)?;
|
||||
Ok(PromptRef {
|
||||
prefix: current.prefix,
|
||||
path,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a [`PromptRef`] to its raw template source. Hard-errors
|
||||
/// when the prefix is not configured or the file does not exist.
|
||||
pub fn load(&self, reference: &PromptRef) -> Result<String, LoaderError> {
|
||||
match reference.prefix {
|
||||
Prefix::Yoi => load_from_include_dir(&BUILTIN_PROMPTS, reference),
|
||||
Prefix::User => match self.user_dir.as_deref() {
|
||||
Some(dir) => load_from_dir(dir, reference),
|
||||
None => Err(LoaderError::PrefixNotConfigured {
|
||||
prefix: PREFIX_USER,
|
||||
}),
|
||||
},
|
||||
Prefix::Workspace => match self.workspace_dir.as_deref() {
|
||||
Some(dir) => load_from_dir(dir, reference),
|
||||
None => Err(LoaderError::PrefixNotConfigured {
|
||||
prefix: PREFIX_WORKSPACE,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `raw` against `current`, then load the resulting ref.
|
||||
/// Convenience wrapper for the minijinja loader hook.
|
||||
pub fn resolve(
|
||||
&self,
|
||||
raw: &str,
|
||||
current: Option<&PromptRef>,
|
||||
) -> Result<(PromptRef, String), LoaderError> {
|
||||
let reference = self.parse_ref(raw, current)?;
|
||||
let source = self.load(&reference)?;
|
||||
Ok((reference, source))
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prefix(raw: &str, prefix_name: &str) -> Result<Prefix, LoaderError> {
|
||||
match prefix_name {
|
||||
"yoi" => Ok(Prefix::Yoi),
|
||||
"user" => Ok(Prefix::User),
|
||||
"workspace" => Ok(Prefix::Workspace),
|
||||
_ => Err(LoaderError::UnknownPrefix {
|
||||
raw: raw.to_string(),
|
||||
prefix: format!("${prefix_name}"),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_path(raw: &str, rest: &str) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn builtin_default_resolves() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let (r, source) = loader.resolve("$yoi/default", None).unwrap();
|
||||
assert_eq!(r.to_qualified_string(), "$yoi/default");
|
||||
assert!(!source.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_ticket_role_instructions_resolve() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
for role in ["intake", "orchestrator", "coder", "reviewer"] {
|
||||
let (reference, source) = loader.resolve(&format!("$yoi/role/{role}"), None).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), format!("$yoi/role/{role}"));
|
||||
assert!(source.contains("first committed user message"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_subdirectory_lookup() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let (_, source) = loader.resolve("$yoi/common/tool-usage", None).unwrap();
|
||||
assert!(source.contains("tool"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prefix_resolves() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("my.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
let (_, source) = loader.resolve("$user/my", None).unwrap();
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_prefix_resolves() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ws_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(ws_dir.join("custom.md"), "ws-body").unwrap();
|
||||
let loader = PromptLoader::new(None, Some(ws_dir));
|
||||
let (_, source) = loader.resolve("$workspace/custom", None).unwrap();
|
||||
assert_eq!(source, "ws-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_hard_error() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$yoi/definitely-missing", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prefix_not_configured_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$user/my", None).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
LoaderError::PrefixNotConfigured { prefix: "$user" }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_prefix_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$bogus/x", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::UnknownPrefix { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_without_current_errors() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("default", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::UnqualifiedWithoutCurrent { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_resolves_relative_to_current() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$yoi/common/tool-usage", None).unwrap();
|
||||
// Sibling lookup under the same prefix and directory.
|
||||
let sibling = loader.parse_ref("workspace", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/common/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unqualified_ref_from_root_file_has_empty_dir() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
let sibling = loader.parse_ref("other", Some(¤t)).unwrap();
|
||||
assert_eq!(sibling.to_qualified_string(), "$yoi/other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_prefix_overrides_current() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let user_dir = tmp.path().to_path_buf();
|
||||
std::fs::write(user_dir.join("custom.md"), "user-body").unwrap();
|
||||
let loader = PromptLoader::new(Some(user_dir), None);
|
||||
|
||||
let current = loader.parse_ref("$yoi/default", None).unwrap();
|
||||
// Even with an $yoi-rooted current, an explicit $user
|
||||
// prefix must win.
|
||||
let (reference, source) = loader.resolve("$user/custom", Some(¤t)).unwrap();
|
||||
assert_eq!(reference.to_qualified_string(), "$user/custom");
|
||||
assert_eq!(source, "user-body");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_segments_rejected() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = loader.resolve("$yoi/../etc/passwd", None).unwrap_err();
|
||||
assert!(matches!(err, LoaderError::InvalidRef { .. }));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub(crate) mod agents_md;
|
||||
pub(crate) mod catalog;
|
||||
pub(crate) mod loader;
|
||||
pub(crate) mod source;
|
||||
pub(crate) mod system;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Immutable effective Prompt catalog carrier.
|
||||
//!
|
||||
//! Prompt sources are resolved and evaluated by Workspace config authority.
|
||||
//! This type carries only the already-materialized projection into Worker
|
||||
//! construction; it performs no filesystem, prefix, relative-path, user, or
|
||||
//! repository discovery.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::catalog::EffectivePromptCatalog;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PromptCatalogSource {
|
||||
effective_catalog: Option<Arc<EffectivePromptCatalog>>,
|
||||
}
|
||||
|
||||
impl PromptCatalogSource {
|
||||
pub fn builtins_only() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_effective_catalog(mut self, catalog: EffectivePromptCatalog) -> Self {
|
||||
self.effective_catalog = Some(Arc::new(catalog));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn effective_catalog(&self) -> Option<&EffectivePromptCatalog> {
|
||||
self.effective_catalog.as_deref()
|
||||
}
|
||||
}
|
||||
+117
-539
@@ -3,7 +3,7 @@
|
||||
//! Manifests describe the system prompt body as a reference to a
|
||||
//! prompt asset (`worker.instruction`, see [`manifest::EngineManifest`]).
|
||||
//! [`SystemPromptTemplate`] resolves that reference through a
|
||||
//! [`PromptLoader`], parses the source as a minijinja template, and
|
||||
//! [`PromptCatalogSource`], parses the source as a minijinja template, and
|
||||
//! eagerly syntax-checks it at Worker construction. The final system
|
||||
//! prompt is materialised exactly once just before the first LLM turn:
|
||||
//! the rendered body is appended with a fixed trailing section carrying
|
||||
@@ -22,17 +22,16 @@ use std::sync::Arc;
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use manifest::Scope;
|
||||
use minijinja::value::Value;
|
||||
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::feature::{FeatureInstructionDeclaration, dedupe_instruction_contributions};
|
||||
use crate::prompt::catalog::{CatalogError, PromptCatalog};
|
||||
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
|
||||
#[cfg(test)]
|
||||
use crate::prompt::catalog::{EffectivePromptCatalog, builtin_prompt_templates};
|
||||
use crate::prompt::source::PromptCatalogSource;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SystemPromptError {
|
||||
#[error("failed to resolve instruction reference: {0}")]
|
||||
LoaderResolve(#[source] LoaderError),
|
||||
#[error("system prompt template parse error: {0}")]
|
||||
Parse(String),
|
||||
#[error("system prompt template render error: {0}")]
|
||||
@@ -41,69 +40,37 @@ pub enum SystemPromptError {
|
||||
Catalog(#[from] CatalogError),
|
||||
}
|
||||
|
||||
/// Parsed instruction template bound to a prompt loader.
|
||||
///
|
||||
/// Holds a minijinja Environment pre-populated with the instruction
|
||||
/// template registered under its fully-qualified name (`$prefix/path`).
|
||||
/// Includes are resolved via the loader using a path-join callback that
|
||||
/// tracks the including template's prefix and directory, so
|
||||
/// `{% include "sibling" %}` fragments work as expected.
|
||||
/// Parsed instruction template bound to one immutable effective Prompt catalog.
|
||||
#[derive(Clone)]
|
||||
pub struct SystemPromptTemplate {
|
||||
env: Arc<Environment<'static>>,
|
||||
catalog: Arc<PromptCatalog>,
|
||||
instruction_name: String,
|
||||
}
|
||||
|
||||
impl SystemPromptTemplate {
|
||||
/// Parse the instruction asset referenced by `instruction_ref`
|
||||
/// using the supplied [`PromptLoader`]. The reference is resolved
|
||||
/// at parse time so syntax errors surface immediately.
|
||||
pub fn parse(instruction_ref: &str, loader: PromptLoader) -> Result<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);
|
||||
|
||||
// Path-join callback: compute the target template name when a
|
||||
// template includes another by a possibly-unqualified string.
|
||||
// The joined name is then looked up via `set_loader` below.
|
||||
let loader_for_join = loader.clone();
|
||||
env.set_path_join_callback(move |name, parent| {
|
||||
let parent_ref = loader_for_join.parse_ref(parent, None).ok();
|
||||
match loader_for_join.parse_ref(name, parent_ref.as_ref()) {
|
||||
Ok(r) => r.to_qualified_string().into(),
|
||||
// Propagate the raw name on error so set_loader surfaces
|
||||
// a proper TemplateNotFound/LoaderError to the caller.
|
||||
Err(_) => name.to_string().into(),
|
||||
}
|
||||
});
|
||||
|
||||
let loader_for_src = loader.clone();
|
||||
env.set_loader(move |name| {
|
||||
let reference = loader_for_src
|
||||
.parse_ref(name, None)
|
||||
.map_err(|e| minijinja::Error::new(ErrorKind::TemplateNotFound, e.to_string()))?;
|
||||
match loader_for_src.load(&reference) {
|
||||
Ok(source) => Ok(Some(source)),
|
||||
Err(e) => Err(minijinja::Error::new(
|
||||
ErrorKind::TemplateNotFound,
|
||||
e.to_string(),
|
||||
)),
|
||||
}
|
||||
});
|
||||
|
||||
env.add_template_owned(root_name.clone(), source)
|
||||
.map_err(|e| SystemPromptError::Parse(e.to_string()))?;
|
||||
|
||||
/// Resolve an exact catalog-root dotted Prompt name and eagerly verify it.
|
||||
pub fn parse(
|
||||
instruction_ref: &str,
|
||||
loader: PromptCatalogSource,
|
||||
) -> Result<Self, SystemPromptError> {
|
||||
let instruction_name = exact_prompt_name(instruction_ref).ok_or_else(|| {
|
||||
SystemPromptError::Parse(format!(
|
||||
"instruction must be an exact catalog-root dotted Prompt name: {instruction_ref}"
|
||||
))
|
||||
})?;
|
||||
let catalog = if let Some(projection) = loader.effective_catalog() {
|
||||
Arc::new(PromptCatalog::from_projection(projection.clone())?)
|
||||
} else {
|
||||
PromptCatalog::builtins_only()?
|
||||
};
|
||||
if !catalog.contains(&instruction_name) {
|
||||
return Err(SystemPromptError::Parse(format!(
|
||||
"Prompt '{instruction_name}' is not present in the effective catalog"
|
||||
)));
|
||||
}
|
||||
Ok(Self {
|
||||
env: Arc::new(env),
|
||||
instruction_name: root_name,
|
||||
catalog,
|
||||
instruction_name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,18 +79,14 @@ impl SystemPromptTemplate {
|
||||
/// section is assembled in Rust so that authored templates cannot
|
||||
/// accidentally omit the scope boundary or the project instructions.
|
||||
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
|
||||
let tmpl = self
|
||||
.env
|
||||
.get_template(&self.instruction_name)
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let body = tmpl
|
||||
.render(ctx.to_minijinja_value())
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let body = self
|
||||
.catalog
|
||||
.render_name(&self.instruction_name, ctx.to_minijinja_value())
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
append_trailing_section(
|
||||
&body,
|
||||
&self.env,
|
||||
ctx,
|
||||
ctx.prompts,
|
||||
&self.catalog,
|
||||
ctx.scope,
|
||||
ctx.agents_md.as_deref(),
|
||||
ctx.resident_summary,
|
||||
@@ -273,6 +236,22 @@ impl ToolCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
fn exact_prompt_name(reference: &str) -> Option<String> {
|
||||
let candidate = reference.to_string();
|
||||
if candidate.is_empty()
|
||||
|| candidate.split('.').any(|segment| {
|
||||
segment.is_empty()
|
||||
|| !segment.chars().all(|character| {
|
||||
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
||||
})
|
||||
})
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the final system prompt by appending the fixed trailing
|
||||
/// section to `body`. The Rust side owns the layout (blank-line
|
||||
/// separators, trailing-whitespace trim); each section's header + body
|
||||
@@ -281,7 +260,6 @@ impl ToolCapabilities {
|
||||
/// per-pack without touching this function.
|
||||
fn append_trailing_section(
|
||||
body: &str,
|
||||
env: &Environment<'static>,
|
||||
ctx: &SystemPromptContext<'_>,
|
||||
prompts: &PromptCatalog,
|
||||
scope: &Scope,
|
||||
@@ -315,12 +293,15 @@ fn append_trailing_section(
|
||||
}
|
||||
for instruction in dedupe_instruction_contributions(ctx.feature_instructions.iter().cloned()) {
|
||||
out.push('\n');
|
||||
let template = env
|
||||
.get_template(&instruction.prompt_ref)
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let section = template
|
||||
.render(ctx.to_minijinja_value())
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
let prompt_ref = exact_prompt_name(&instruction.prompt_ref).ok_or_else(|| {
|
||||
SystemPromptError::Render(format!(
|
||||
"feature instruction must be an exact catalog-root dotted Prompt name: {}",
|
||||
instruction.prompt_ref
|
||||
))
|
||||
})?;
|
||||
let section = prompts
|
||||
.render_name(&prompt_ref, ctx.to_minijinja_value())
|
||||
.map_err(|error| SystemPromptError::Render(error.to_string()))?;
|
||||
let section = section.trim_end_matches(&['\n', ' '][..]);
|
||||
if !section.trim().is_empty() {
|
||||
out.push_str(section);
|
||||
@@ -335,13 +316,6 @@ fn append_trailing_section(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests
|
||||
/// can construct a synthetic context without going through a full Worker.
|
||||
#[doc(hidden)]
|
||||
pub fn __instruction_ref_for_tests(raw: &str, loader: &PromptLoader) -> Option<PromptRef> {
|
||||
loader.parse_ref(raw, None).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -354,487 +328,91 @@ mod tests {
|
||||
}
|
||||
|
||||
fn build_scope(dir: &Path) -> Scope {
|
||||
let cfg = ScopeConfig {
|
||||
Scope::from_config(&ScopeConfig {
|
||||
allow: vec![ScopeRule {
|
||||
target: dir.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}],
|
||||
deny: Vec::new(),
|
||||
};
|
||||
Scope::from_config(&cfg).unwrap()
|
||||
}
|
||||
|
||||
fn ctx<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
tools: Vec<String>,
|
||||
agents_md: Option<String>,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.display().to_string().into(),
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: tools,
|
||||
feature_instructions: &[],
|
||||
agents_md,
|
||||
resident_summary: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_with_summary<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
summary: Option<&'a str>,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.display().to_string().into(),
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: Vec::new(),
|
||||
feature_instructions: &[],
|
||||
agents_md: None,
|
||||
resident_summary: summary,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_tool_names() -> Vec<String> {
|
||||
["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ticket_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("ticket.workflow"),
|
||||
"$yoi/common/tickets",
|
||||
"Ticket workflow guidance",
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn sub_worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("worker.orchestration"),
|
||||
"$yoi/common/worker-orchestration",
|
||||
"Worker orchestration guidance",
|
||||
)
|
||||
.unwrap()
|
||||
fn context<'a>(
|
||||
cwd: &'a Path,
|
||||
scope: &'a Scope,
|
||||
prompts: &'a PromptCatalog,
|
||||
) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.to_string_lossy(),
|
||||
tool_names: vec!["Read".into(), "Write".into()],
|
||||
scope,
|
||||
agents_md: Some("PROJECT RULES".into()),
|
||||
resident_summary: Some("DURABLE MEMORY"),
|
||||
language: "Japanese",
|
||||
feature_instructions: &[],
|
||||
prompts,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lazily-initialised builtin catalog shared across system-prompt
|
||||
/// tests, so every `ctx()` can hand out a `&'static PromptCatalog`
|
||||
/// reference without forcing test bodies to create one per call.
|
||||
fn test_prompts() -> &'static PromptCatalog {
|
||||
use std::sync::OnceLock;
|
||||
static CELL: OnceLock<Arc<PromptCatalog>> = OnceLock::new();
|
||||
CELL.get_or_init(|| PromptCatalog::builtins_only().unwrap())
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
fn user_loader_with(file_name: &str, body: &str) -> (TempDir, PromptLoader) {
|
||||
#[test]
|
||||
fn exact_catalog_name_renders_once_with_trailing_sections() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join(file_name), body).unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
(tmp, loader)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_default_resolves_to_yoi_default() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
|
||||
let scope = build_scope(tmp.path());
|
||||
let prompts = PromptCatalog::builtins_only().unwrap();
|
||||
let template =
|
||||
SystemPromptTemplate::parse("default", PromptCatalogSource::builtins_only()).unwrap();
|
||||
let rendered = template
|
||||
.render(&context(tmp.path(), &scope, &prompts))
|
||||
.unwrap();
|
||||
// Builtin default body must expose the tool and language policies.
|
||||
assert!(rendered.contains("### Memory"));
|
||||
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||
assert!(rendered.contains("Strong lookup triggers include"));
|
||||
assert!(rendered.contains("MemoryReadDocument"));
|
||||
assert!(rendered.contains("Do not query memory every turn"));
|
||||
assert!(rendered.contains("MemoryUpdateDocument"));
|
||||
assert!(rendered.contains("## Language"));
|
||||
assert!(rendered.contains("`language`: `match the user's language"));
|
||||
// Trailing section must be present.
|
||||
assert!(rendered.contains("2026-08-14") || rendered.contains("2026-04-15"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("Readable:"));
|
||||
assert!(rendered.contains("PROJECT RULES"));
|
||||
assert!(rendered.contains("DURABLE MEMORY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_default_omits_memory_guidance_without_memory_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
fn workspace_override_is_visible_through_builtin_static_include() {
|
||||
let mut templates = builtin_prompt_templates().unwrap();
|
||||
templates.insert("common.workspace".into(), "WORKSPACE OVERRIDE".into());
|
||||
let projection = EffectivePromptCatalog::new(templates, 9, "schema", "toolchain").unwrap();
|
||||
let loader =
|
||||
PromptCatalogSource::builtins_only().with_effective_catalog(projection.clone());
|
||||
let prompts = PromptCatalog::from_projection(projection).unwrap();
|
||||
let template = SystemPromptTemplate::parse("default", loader).unwrap();
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let scope = build_scope(tmp.path());
|
||||
let rendered = template
|
||||
.render(&context(tmp.path(), &scope, &prompts))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("### Memory"));
|
||||
assert!(!rendered.contains("MemoryQuery"));
|
||||
assert!(!rendered.contains("MemoryRead"));
|
||||
assert!(!rendered.contains("MemoryWrite"));
|
||||
assert!(!rendered.contains("MemoryEdit"));
|
||||
assert!(!rendered.contains("MemoryDelete"));
|
||||
assert!(rendered.contains("## Language"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("WORKSPACE OVERRIDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_guidance_is_included_for_ticket_feature_instruction() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Ticket workflow"));
|
||||
assert!(rendered.contains("available typed Ticket tools as the authority"));
|
||||
assert!(rendered.contains("Do not invoke a Ticket CLI"));
|
||||
assert!(rendered.contains("Distinguish implementation completion"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_instruction_is_appended_even_when_template_does_not_include_it() {
|
||||
let (_tmp, loader) = user_loader_with("minimal.md", "BASE ONLY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/minimal", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec![], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.starts_with("BASE ONLY"));
|
||||
assert!(rendered.contains("## Ticket workflow"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_guidance_is_omitted_without_ticket_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("## Ticket workflow"));
|
||||
assert!(!rendered.contains("Do not invoke a Ticket CLI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_role_instructions_include_feature_ticket_guidance() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [ticket_instruction()];
|
||||
|
||||
for role in ["intake", "orchestrator", "coder", "reviewer"] {
|
||||
let tmpl =
|
||||
SystemPromptTemplate::parse(&format!("$yoi/role/{role}"), loader.clone()).unwrap();
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## Ticket workflow"), "role: {role}");
|
||||
fn rejects_legacy_prefix_relative_and_missing_names() {
|
||||
for reference in ["legacy/custom", "custom.md", "../custom", "missing"] {
|
||||
assert!(
|
||||
rendered.contains("Do not invoke a Ticket CLI"),
|
||||
"role: {role}"
|
||||
SystemPromptTemplate::parse(reference, PromptCatalogSource::builtins_only())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_guidance_names_only_available_memory_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["MemoryQuery".into(), "MemoryReadDocument".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("### Memory"));
|
||||
assert!(rendered.contains("small targeted `MemoryQuery`"));
|
||||
assert!(rendered.contains("MemoryReadDocument"));
|
||||
assert!(!rendered.contains("MemoryUpdateDocument"));
|
||||
assert!(!rendered.contains("MemoryEdit"));
|
||||
assert!(!rendered.contains("MemoryDelete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_included_for_feature_instruction() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let instructions = [sub_worker_orchestration_instruction()];
|
||||
let mut ctx = ctx(dir.path(), &scope, vec!["Read".into()], None);
|
||||
ctx.feature_instructions = &instructions;
|
||||
let rendered = tmpl.render(&ctx).unwrap();
|
||||
|
||||
assert!(rendered.contains("## SubWorker orchestration"));
|
||||
assert!(rendered.contains("SubWorker notifications are background signals"));
|
||||
assert!(rendered.contains("does not need to keep a turn open"));
|
||||
assert!(rendered.contains("Do not use `sleep` or polling loops"));
|
||||
assert!(rendered.contains("worktree state, diff, and test results"));
|
||||
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
|
||||
assert!(rendered.contains("bypass user/Ticket authorization"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_omitted_without_sub_worker_management_tools() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let tmpl = SystemPromptTemplate::parse("$yoi/default", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into(), "MemoryReadDocument".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
assert!(!rendered.contains("## Worker orchestration"));
|
||||
assert!(!rendered.contains("spawned Worker notifications are background signals"));
|
||||
assert!(!rendered.contains("does not need to keep a turn open"));
|
||||
assert!(!rendered.contains("Do not use `sleep` or polling loops"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_prefix_addressing_user() {
|
||||
let (_tmp, loader) = user_loader_with("greet.md", "HELLO from {{ cwd }}");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/greet", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.starts_with("HELLO from"));
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_prefix_addressing_workspace() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("ws.md"), "WS {{ date }}").unwrap();
|
||||
let loader = PromptLoader::new(None, Some(tmp.path().to_path_buf()));
|
||||
let tmpl = SystemPromptTemplate::parse("$workspace/ws", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.starts_with("WS 2026-04-15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_unqualified_resolves_relative_to_current_prefix() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// parent.md and sibling.md both under the user root.
|
||||
std::fs::write(
|
||||
tmp.path().join("parent.md"),
|
||||
"PARENT\n{% include \"sibling\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("sibling.md"), "SIBLING-BODY").unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/parent", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("PARENT"));
|
||||
assert!(rendered.contains("SIBLING-BODY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_unqualified_from_subdirectory_resolves_in_same_dir() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("common")).unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("common/header.md"),
|
||||
"HEADER\n{% include \"nested\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(tmp.path().join("common/nested.md"), "NESTED-OK").unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/common/header", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("HEADER"));
|
||||
assert!(rendered.contains("NESTED-OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_explicit_prefix_overrides_relative() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join("root.md"),
|
||||
"U-ROOT\n{% include \"$yoi/common/tool-usage\" %}",
|
||||
)
|
||||
.unwrap();
|
||||
let loader = PromptLoader::new(Some(tmp.path().to_path_buf()), None);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/root", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["Read".into(), "Edit".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("U-ROOT"));
|
||||
// Pulled in from the builtin tool-usage asset.
|
||||
assert!(rendered.contains("Read"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_with_missing_file_is_hard_error() {
|
||||
let loader = PromptLoader::builtins_only();
|
||||
let err = SystemPromptTemplate::parse("$yoi/definitely-missing", loader).unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::LoaderResolve(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_fails_on_syntax_error() {
|
||||
let (_tmp, loader) = user_loader_with("broken.md", "{{ unclosed");
|
||||
let err = SystemPromptTemplate::parse("$user/broken", loader).unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::Parse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_fails_on_undefined_variable() {
|
||||
let (_tmp, loader) = user_loader_with("ghost.md", "{{ ghost }}");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/ghost", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let err = tmpl
|
||||
.render(&ctx(dir.path(), &scope, vec![], None))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, SystemPromptError::Render(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_date_cwd_tools() {
|
||||
let (_tmp, loader) = user_loader_with(
|
||||
"vars.md",
|
||||
"date={{ date }} cwd={{ cwd }} tools={{ tools | join(',') }}",
|
||||
);
|
||||
let tmpl = SystemPromptTemplate::parse("$user/vars", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec!["alpha".into(), "beta".into()],
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("date=2026-04-15"));
|
||||
assert!(rendered.contains(&format!("cwd={}", dir.path().display())));
|
||||
assert!(rendered.contains("tools=alpha,beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_always_contains_scope_summary() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(rendered.contains("## Working boundaries"));
|
||||
assert!(rendered.contains("Readable:"));
|
||||
assert!(rendered.contains("Writable:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_contains_agents_md_when_present() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
vec![],
|
||||
Some("PROJECT DOCS".into()),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("## Project instructions (AGENTS.md)"));
|
||||
assert!(rendered.contains("PROJECT DOCS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_omits_agents_md_when_absent() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
|
||||
assert!(!rendered.contains("AGENTS.md"));
|
||||
assert!(!rendered.contains("Project instructions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_renders_resident_summary_body() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(
|
||||
dir.path(),
|
||||
&scope,
|
||||
Some("Persistent summary body"),
|
||||
))
|
||||
.unwrap();
|
||||
assert!(rendered.contains("## Resident memory summary"));
|
||||
assert!(rendered.contains("Persistent summary body"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_section_omits_resident_summary_when_none_or_empty() {
|
||||
let (_tmp, loader) = user_loader_with("body.md", "BODY");
|
||||
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let scope = build_scope(dir.path());
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(dir.path(), &scope, None))
|
||||
.unwrap();
|
||||
assert!(!rendered.contains("Resident memory summary"));
|
||||
|
||||
let rendered = tmpl
|
||||
.render(&ctx_with_summary(dir.path(), &scope, Some(" \n")))
|
||||
.unwrap();
|
||||
assert!(!rendered.contains("Resident memory summary"));
|
||||
fn role_templates_are_selected_without_filesystem_resolution() {
|
||||
let loader = PromptCatalogSource::builtins_only();
|
||||
for role in [
|
||||
"role.coder",
|
||||
"role.intake",
|
||||
"role.orchestrator",
|
||||
"role.reviewer",
|
||||
] {
|
||||
assert!(
|
||||
SystemPromptTemplate::parse(role, loader.clone()).is_ok(),
|
||||
"{role}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user