worker: replace filesystem prompts with effective DCDL catalog

This commit is contained in:
2026-08-14 13:45:03 +09:00
parent 48ff977d06
commit 0ad7d6d210
45 changed files with 892 additions and 1895 deletions
+1 -3
View File
@@ -29,6 +29,7 @@ tools = { workspace = true }
workdir = { workspace = true }
minijinja = "2.19.0"
chrono = "0.4"
config-source = { path = "../config-source" }
include_dir = "0.7.4"
fs4 = { workspace = true, features = ["sync"] }
flow = { path = "../flow" }
@@ -51,6 +52,3 @@ serial_test = "3.4.0"
tempfile = { workspace = true }
wat = "1.241.2"
yoi-plugin-pdk = { workspace = true }
[build-dependencies]
toml = { workspace = true }
+1 -47
View File
@@ -1,49 +1,3 @@
//! Emits `$OUT_DIR/internal_keys.rs` containing the sorted list of keys
//! present in `resources/prompts/internal.toml`. The generated slice is
//! included into `src/prompts.rs` where a `const _` assertion compares
//! it bidirectionally against the `WorkerPrompt` enum's own key list, so
//! that a mismatch fails the build (see ticket: worker-prompt-catalog).
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
let toml_path = PathBuf::from(&manifest_dir)
.join("..")
.join("..")
.join("resources")
.join("prompts")
.join("internal.toml");
println!("cargo:rerun-if-changed={}", toml_path.display());
println!("cargo:rerun-if-changed=build.rs");
let toml_str = fs::read_to_string(&toml_path)
.unwrap_or_else(|e| panic!("failed to read {}: {e}", toml_path.display()));
let parsed: toml::Value = toml::from_str(&toml_str)
.unwrap_or_else(|e| panic!("failed to parse {}: {e}", toml_path.display()));
let prompt_section = parsed
.get("prompt")
.and_then(|v| v.as_table())
.unwrap_or_else(|| panic!("{} must contain a `[prompt]` table", toml_path.display()));
let mut keys: Vec<String> = prompt_section.keys().cloned().collect();
keys.sort();
let out_dir = env::var("OUT_DIR").expect("OUT_DIR");
let out_path = PathBuf::from(out_dir).join("internal_keys.rs");
let mut code = String::from("pub(crate) const INTERNAL_KEYS: &[&str] = &[\n");
for k in &keys {
code.push_str(" ");
code.push_str(&format!("{k:?}"));
code.push_str(",\n");
}
code.push_str("];\n");
fs::write(&out_path, code)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
println!("cargo:rerun-if-changed=../../resources/prompts");
}
+19 -24
View File
@@ -3,7 +3,8 @@ use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::{
PromptLoader, Worker, WorkerController, WorkerFilesystemAuthority, WorkerWorkspaceContext,
PromptCatalogSource, Worker, WorkerController, WorkerFilesystemAuthority,
WorkerWorkspaceContext,
};
use clap::{CommandFactory, FromArgMatches, Parser};
use manifest::{Permission, ScopeConfig, ScopeRule, WorkerManifest, WorkerManifestConfig, paths};
@@ -137,7 +138,7 @@ fn sanitise_worker_name(raw: &str) -> String {
}
}
fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptLoader), String> {
fn resolve_manifest(cli: &Cli) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let process_root = runtime_workspace_root(cli)?;
let runtime_worker_name = runtime_worker_name(cli, &process_root);
let ((mut manifest, loader), apply_direct_launch_policy) = if let Some(config_json) =
@@ -178,29 +179,31 @@ fn apply_session_restore_overrides(manifest: &mut WorkerManifest, cli: &Cli) ->
Ok(())
}
fn load_spawn_config_json(config_json: &str) -> Result<(WorkerManifest, PromptLoader), String> {
fn load_spawn_config_json(
config_json: &str,
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let config = serde_json::from_str::<WorkerManifestConfig>(config_json)
.map_err(|e| format!("failed to parse --spawn-config-json: {e}"))?;
let manifest = WorkerManifest::try_from(WorkerManifestConfig::builtin_defaults().merge(config))
.map_err(|e| format!("failed to resolve --spawn-config-json: {e}"))?;
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
fn load_builtin_default_manifest(
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let mut config = WorkerManifestConfig::builtin_defaults();
config.worker.name = Some(worker_name.to_string());
let manifest = WorkerManifest::try_from(config)
.map_err(|e| format!("failed to resolve builtin worker defaults: {e}"))?;
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
pub fn resolve_runtime_profile_manifest(
_profile: Option<&str>,
_workspace_root: &Path,
_worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
Err(
"runtime profile resolution requires a pre-resolved manifest/profile archive from Backend authority"
.to_string(),
@@ -211,7 +214,7 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
mut manifest: WorkerManifest,
workspace_root: &Path,
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
if manifest.worker.name.is_empty() {
manifest.worker.name = worker_name.to_string();
}
@@ -219,28 +222,28 @@ pub fn resolve_runtime_profile_manifest_from_manifest(
// Do not run plugin discovery here: runtime-created Workers receive their
// resolved manifest/profile archive from Backend authority, not by scanning
// materialized workdir-local plugin stores.
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
pub fn resolve_runtime_profile_manifest_from_manifest_without_filesystem(
mut manifest: WorkerManifest,
_workspace_root: &Path,
worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
if manifest.worker.name.is_empty() {
manifest.worker.name = worker_name.to_string();
}
manifest.scope = ScopeConfig::default();
manifest.delegation_scope = ScopeConfig::default();
// Same as the filesystem-capable runtime path: no local discovery.
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
fn load_single_manifest(
path: &Path,
explicit_worker_name: Option<&str>,
default_worker_name: &str,
) -> Result<(WorkerManifest, PromptLoader), String> {
) -> Result<(WorkerManifest, PromptCatalogSource), String> {
let toml = std::fs::read_to_string(path)
.map_err(|e| format!("failed to read manifest {}: {e}", path.display()))?;
let absolute_path = if path.is_absolute() {
@@ -274,7 +277,7 @@ fn load_single_manifest(
path.display()
));
}
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
fn read_rule(target: PathBuf) -> ScopeRule {
@@ -751,11 +754,9 @@ permission = "write"
let cli =
Cli::try_parse_from(["yoi worker", "--manifest", manifest.to_str().unwrap()]).unwrap();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.worker.name, "single");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
}
#[test]
@@ -834,12 +835,10 @@ language = "override"
let cli = Cli::try_parse_from(["yoi worker", "--workspace", workspace.to_str().unwrap()])
.unwrap();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.worker.name, "runtime-workspace");
assert_ne!(manifest.engine.language, "override");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
assert_scope_contains(&manifest.scope.allow, &workspace, Permission::Write);
}
@@ -1031,12 +1030,8 @@ permission = "write"
])
.unwrap();
let (manifest, loader) = resolve_manifest(&cli).unwrap();
let (manifest, _loader) = resolve_manifest(&cli).unwrap();
assert_eq!(manifest.worker.name, "single-file");
assert!(loader.user_dir().is_none());
assert!(loader.workspace_dir().is_none());
assert!(loader.user_pack_file().is_none());
assert!(loader.workspace_pack_file().is_none());
}
}
+4 -4
View File
@@ -1858,8 +1858,8 @@ mod tests {
#[test]
fn instruction_contributions_are_deduped_in_registration_order() {
let workflow = instruction("workflow", "$yoi/common/tickets");
let orchestration = instruction("orchestration", "$yoi/common/worker-orchestration");
let workflow = instruction("workflow", "common.tickets");
let orchestration = instruction("orchestration", "common.worker_orchestration");
let contributions = dedupe_instruction_contributions([
workflow.clone(),
orchestration.clone(),
@@ -1871,8 +1871,8 @@ mod tests {
#[test]
fn undeclared_instruction_contribution_is_rejected() {
let declared = instruction("declared", "$yoi/common/tickets");
let undeclared = instruction("undeclared", "$yoi/common/tickets");
let declared = instruction("declared", "common.tickets");
let undeclared = instruction("undeclared", "common.tickets");
let descriptor =
FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared);
let mut hook_builder = HookRegistryBuilder::default();
+1 -1
View File
@@ -33,7 +33,7 @@ const FEATURE_NAME: &str = "Ticket tools";
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
const TICKET_WORKFLOW_INSTRUCTION_ID: &str = "ticket.workflow";
const TICKET_WORKFLOW_PROMPT_REF: &str = "$yoi/common/tickets";
const TICKET_WORKFLOW_PROMPT_REF: &str = "common.tickets";
pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1";
@@ -23,7 +23,7 @@ const DEFAULT_PAGE_LIMIT: usize = 20;
const MAX_PAGE_LIMIT: usize = 100;
const MAX_READ_BYTES: usize = 16 * 1024;
const OBSERVATION_INSTRUCTION_ID: &str = "worker-observation.policy";
const OBSERVATION_PROMPT_REF: &str = "$yoi/common/worker-observation";
const OBSERVATION_PROMPT_REF: &str = "common.worker_observation";
#[cfg(test)]
const OBSERVATION_PROMPT_SOURCE: &str =
include_str!("../../../../../resources/prompts/common/worker-observation.md");
+4 -2
View File
@@ -32,8 +32,10 @@ pub use manifest::{
WorkerMetaConfig,
};
pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{CatalogError, PromptCatalog, WorkerPrompt};
pub use prompt::loader::PromptLoader;
pub use prompt::catalog::{
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, prompt_schema_source,
};
pub use prompt::source::PromptCatalogSource;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir;
File diff suppressed because it is too large Load Diff
-425
View File
@@ -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(&current)).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(&current)).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(&current)).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 -1
View File
@@ -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;
+30
View File
@@ -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
View File
@@ -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}"
);
}
}
}
+21 -37
View File
@@ -20,7 +20,7 @@ use manifest::{
use serde::Deserialize;
use tokio::sync::mpsc;
use crate::PromptLoader;
use crate::PromptCatalogSource;
use crate::controller::register_worker_tools;
use crate::internal_worker::{
EphemeralSessionStore, InternalWorkerSessionStatus, prepare_internal_worker_session,
@@ -44,7 +44,7 @@ struct SubWorkerSpawnInput {
/// unambiguous profile slug. Raw/path selectors are rejected.
#[serde(default)]
profile: Option<String>,
/// Instruction-file reference (e.g. `$yoi/default`, `$user/my-agent`).
/// Exact catalog-root dotted Prompt name (for example `default` or `role.coder`).
#[serde(default)]
instruction: Option<String>,
/// Child process/tool working directory. This is not the runtime workspace
@@ -276,7 +276,7 @@ pub struct SubWorkerSpawnTool {
/// child config from reusable fields here, and selected profiles are
/// merged into the same internal handoff shape before launch.
spawner_manifest: WorkerManifest,
prompt_loader: PromptLoader,
prompt_loader: PromptCatalogSource,
/// Compact selector list shared by tool description and diagnostics.
available_profiles: AvailableProfiles,
/// Spawner's runtime scope. After a successful spawn, the
@@ -310,7 +310,7 @@ impl SubWorkerSpawnTool {
spawner_cwd: PathBuf,
registry: Arc<SpawnedWorkerRegistry>,
spawner_manifest: WorkerManifest,
prompt_loader: PromptLoader,
prompt_loader: PromptCatalogSource,
available_profiles: AvailableProfiles,
spawner_scope: SharedScope,
delegation_scope: DelegationScope,
@@ -827,7 +827,6 @@ fn build_spawn_config_json(
let config = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(name.to_string()),
prompt_pack: None,
},
model: model.clone(),
engine: EngineManifestConfig {
@@ -870,7 +869,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(manifest.worker.name.clone()),
prompt_pack: manifest.worker.prompt_pack.clone(),
},
model: manifest.model.clone(),
engine: EngineManifestConfig {
@@ -1010,7 +1008,7 @@ fn sub_worker_spawn_tool_impl(
spawner_cwd.clone(),
registry.clone(),
spawner_manifest.clone(),
prompts.loader(),
prompts.source(),
available_profiles,
spawner_scope.clone(),
DelegationScope::from_config(&spawner_manifest.delegation_scope)
@@ -1086,7 +1084,7 @@ model_id = "reviewer-model"
kind = "none"
[engine]
instruction = "$yoi/reviewer"
instruction = "role.reviewer"
language = "Reviewerish"
max_tokens = 3333
@@ -1136,14 +1134,7 @@ extract_threshold = 4000
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
let observed_instruction_override = Arc::new(AtomicBool::new(false));
let fail_requests = Arc::new(AtomicBool::new(false));
let workspace_prompts = runtime.path().join("workspace-prompts");
std::fs::create_dir_all(&workspace_prompts).unwrap();
std::fs::write(
workspace_prompts.join("custom-reviewer.md"),
"WORKSPACE REVIEWER OVERRIDE",
)
.unwrap();
let prompt_loader = PromptLoader::new(None, Some(workspace_prompts));
let prompt_loader = PromptCatalogSource::builtins_only();
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
let tool = SubWorkerSpawnTool::new(
"parent".into(),
@@ -1170,7 +1161,7 @@ extract_threshold = 4000
let input = serde_json::json!({
"name": "reviewer-child",
"profile": "project:reviewer",
"instruction": "$workspace/custom-reviewer",
"instruction": "role.reviewer",
"task": "review immutable commit",
"scope": [{
"target": workspace_root.clone(),
@@ -1468,7 +1459,7 @@ extract_threshold = 4000
request
.system_prompt
.as_deref()
.is_some_and(|prompt| prompt.contains("WORKSPACE REVIEWER OVERRIDE")),
.is_some_and(|prompt| prompt.contains("review")),
Ordering::SeqCst,
);
if self.fail_requests.load(Ordering::SeqCst) {
@@ -1515,7 +1506,6 @@ extract_threshold = 4000
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("parent".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
@@ -1524,7 +1514,7 @@ extract_threshold = 4000
..Default::default()
},
engine: EngineManifestConfig {
instruction: Some("$yoi/parent".into()),
instruction: Some("default".into()),
language: Some("Parentish".into()),
max_tokens: Some(1234),
stop_sequences: Some(vec!["STOP".into()]),
@@ -1604,7 +1594,7 @@ scheme = "anthropic"
model_id = "coder-model"
[engine]
instruction = "$yoi/coder"
instruction = "role.coder"
language = "Coderish"
max_tokens = 2222
"#;
@@ -1618,7 +1608,7 @@ scheme = "anthropic"
model_id = "reviewer-model"
[engine]
instruction = "$yoi/reviewer"
instruction = "role.reviewer"
language = "Reviewerish"
max_tokens = 3333
"#;
@@ -1635,8 +1625,7 @@ max_tokens = 3333
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(parsed.model.scheme, Some(SchemeKind::Anthropic));
@@ -1658,8 +1647,7 @@ max_tokens = 3333
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.model.ref_.as_deref(),
@@ -1680,7 +1668,7 @@ max_tokens = 3333
}];
let config_json =
build_spawn_config_json("child", "$yoi/default", &scope, &model, true).unwrap();
build_spawn_config_json("child", "default", &scope, &model, true).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert_eq!(
parsed.session.as_ref().and_then(|s| s.record_event_trace),
@@ -1700,8 +1688,7 @@ max_tokens = 3333
ref_: Some("anthropic/claude-sonnet-4-6".into()),
..Default::default()
};
let config_json =
build_spawn_config_json("child", "$yoi/default", &[], &model, false).unwrap();
let config_json = build_spawn_config_json("child", "default", &[], &model, false).unwrap();
let parsed: WorkerManifestConfig = serde_json::from_str(&config_json).unwrap();
assert!(parsed.session.is_none());
@@ -1737,7 +1724,7 @@ max_tokens = 3333
assert_eq!(config.worker.name.as_deref(), Some("child-default"));
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.scope.allow, scope);
assert!(config.scope.deny.is_empty());
@@ -1776,7 +1763,7 @@ max_tokens = 3333
assert_eq!(config.worker.name.as_deref(), Some("review-child"));
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/reviewer"));
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.engine.max_tokens, Some(3333));
assert_eq!(config.scope.allow, scope);
@@ -1810,7 +1797,7 @@ max_tokens = 3333
assert_eq!(config.worker.name.as_deref(), Some("inherited-child"));
assert_eq!(config.model.model_id.as_deref(), Some("parent-model"));
assert_eq!(config.engine.instruction.as_deref(), Some("$yoi/parent"));
assert_eq!(config.engine.instruction.as_deref(), Some("default"));
assert_eq!(config.engine.language.as_deref(), Some("Parentish"));
assert_eq!(config.engine.max_tokens, Some(1234));
assert_eq!(
@@ -1845,15 +1832,12 @@ max_tokens = 3333
&available,
&project,
"override-child",
Some("$user/custom-reviewer"),
Some("role.reviewer"),
&scope,
SpawnProfileSelector::Default,
);
assert_eq!(
config.engine.instruction.as_deref(),
Some("$user/custom-reviewer")
);
assert_eq!(config.engine.instruction.as_deref(), Some("role.reviewer"));
assert_eq!(config.model.model_id.as_deref(), Some("reviewer-model"));
assert_eq!(config.engine.language.as_deref(), Some("Reviewerish"));
assert_eq!(config.engine.max_tokens, Some(3333));
+22 -22
View File
@@ -53,7 +53,7 @@ use crate::internal_worker::{
const COMPACTION_EXTENSION_DOMAIN: &str = "yoi.compaction";
const COMPACTION_BLOCK_ID: &str = "compact";
const WORKER_ORCHESTRATION_INSTRUCTION_ID: &str = "worker.orchestration";
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "$yoi/common/worker-orchestration";
const WORKER_ORCHESTRATION_PROMPT_REF: &str = "common.worker_orchestration";
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
@@ -68,7 +68,7 @@ use crate::ipc::interceptor::WorkerInterceptor;
use crate::ipc::notify_buffer::NotifyBuffer;
use crate::prompt::agents_md::read_agents_md;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::PromptLoader;
use crate::prompt::source::PromptCatalogSource;
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
use crate::runtime::dir;
use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError};
@@ -4243,7 +4243,7 @@ where
pub async fn from_manifest(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
) -> Result<Self, WorkerError> {
let cwd = current_cwd()?;
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
@@ -4255,7 +4255,7 @@ where
pub async fn from_manifest_with_context(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
) -> Result<Self, WorkerError> {
@@ -4352,7 +4352,7 @@ where
pub(crate) async fn from_internal_manifest_with_context(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
client_override: Option<Box<dyn LlmClient>>,
@@ -4435,7 +4435,7 @@ where
pub async fn from_manifest_spawned(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
callback_socket: PathBuf,
) -> Result<Self, WorkerError> {
let cwd = current_cwd()?;
@@ -4455,7 +4455,7 @@ where
pub async fn from_manifest_spawned_with_context(
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
callback_socket: PathBuf,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
@@ -4544,7 +4544,7 @@ where
worker_name: &str,
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
) -> Result<Self, WorkerError> {
let cwd = current_cwd()?;
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
@@ -4564,7 +4564,7 @@ where
worker_name: &str,
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
) -> Result<Self, WorkerError> {
@@ -4613,7 +4613,7 @@ where
worker_name: &str,
fallback: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
) -> Result<Self, WorkerError> {
@@ -4683,7 +4683,7 @@ where
segment_id: SegmentId,
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
) -> Result<Self, WorkerError> {
let cwd = current_cwd()?;
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
@@ -4705,7 +4705,7 @@ where
segment_id: SegmentId,
manifest: WorkerManifest,
store: St,
loader: PromptLoader,
loader: PromptCatalogSource,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
) -> Result<Self, WorkerError> {
@@ -4903,7 +4903,7 @@ where
pub async fn from_manifest_toml(toml: &str, store: St) -> Result<Self, WorkerError> {
let config = WorkerManifestConfig::from_toml(toml).map_err(WorkerError::ManifestParse)?;
let manifest = WorkerManifest::try_from(config).map_err(WorkerError::ManifestResolve)?;
Self::from_manifest(manifest, store, PromptLoader::builtins_only()).await
Self::from_manifest(manifest, store, PromptCatalogSource::builtins_only()).await
}
}
@@ -5588,7 +5588,7 @@ fn delegated_write_rule_to_deny(rule: WorkerSpawnedScopeRule) -> Option<ScopeRul
/// a previously-rendered `system_prompt` verbatim.
fn prepare_worker_common_with_context(
manifest: &WorkerManifest,
loader: &PromptLoader,
loader: &PromptCatalogSource,
parse_template: bool,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
@@ -5633,7 +5633,7 @@ fn prepare_worker_common_with_context(
fn prepare_worker_common_from_scope(
manifest: &WorkerManifest,
loader: &PromptLoader,
loader: &PromptCatalogSource,
parse_template: bool,
workspace_context: WorkerWorkspaceContext,
filesystem_authority: WorkerFilesystemAuthority,
@@ -5655,7 +5655,7 @@ fn prepare_worker_common_from_scope(
DelegationScope::from_config(&manifest.delegation_scope).map_err(WorkerError::Scope)?;
let client = crate::model_client::build_client(&manifest.model)?;
let prompts = PromptCatalog::load(loader, manifest.worker.prompt_pack.as_deref())?;
let prompts = PromptCatalog::load(loader)?;
let system_prompt_template = if parse_template {
Some(
SystemPromptTemplate::parse(&manifest.engine.instruction, loader.clone())
@@ -5706,7 +5706,7 @@ mod spawned_context_tests {
manifest.memory = Some(manifest::MemoryConfig::default());
let common = prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -5739,7 +5739,7 @@ mod spawned_context_tests {
std::fs::create_dir_all(&cwd).unwrap();
let mut manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
manifest.memory = Some(manifest::MemoryConfig::default());
let loader = PromptLoader::new(None, Some(workspace_root.clone()));
let loader = PromptCatalogSource::builtins_only();
let workspace_id = WorkspaceId::new("ws-api-only").unwrap();
let common = prepare_worker_common_with_context(
&manifest,
@@ -5776,7 +5776,7 @@ mod spawned_context_tests {
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
let err = match prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -5812,7 +5812,7 @@ mod spawned_context_tests {
let manifest = minimal_manifest_for_context_test(&workspace_root, &cwd);
let err = match prepare_worker_common_with_context(
&manifest,
&PromptLoader::builtins_only(),
&PromptCatalogSource::builtins_only(),
false,
WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())),
WorkerFilesystemAuthority::local(workspace_root.clone(), cwd.clone()),
@@ -6888,8 +6888,8 @@ mod build_summary_prompt_tests {
.unwrap();
worker.set_resident_memory_injection(gates.summary);
let template = SystemPromptTemplate::parse(
"$yoi/default",
crate::prompt::loader::PromptLoader::builtins_only(),
"default",
crate::prompt::source::PromptCatalogSource::builtins_only(),
)
.unwrap();
worker.set_system_prompt_template(template);
+5 -5
View File
@@ -49,7 +49,7 @@ async fn restore_from_worker_metadata_rejects_missing_metadata() {
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -84,7 +84,7 @@ async fn restore_from_worker_metadata_rejects_pending_segment() {
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -126,7 +126,7 @@ async fn restore_from_worker_metadata_resolves_active_pointer_through_session_lo
"restore-test",
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -158,7 +158,7 @@ async fn restore_from_manifest_rejects_unknown_segment() {
unknown_seg,
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -195,7 +195,7 @@ async fn restore_from_manifest_rejects_empty_segment_log() {
segid,
manifest,
store,
worker::PromptLoader::builtins_only(),
worker::PromptCatalogSource::builtins_only(),
)
.await;
@@ -11,7 +11,10 @@ use llm_engine::llm_client::{ClientError, LlmClient, Request};
use session_store::{CombinedStore, FsWorkerStore};
use session_store::{FsStore, LogEntry, Store};
use worker::{PromptLoader, SystemPromptTemplate, Worker, WorkerError};
use worker::{
EffectivePromptCatalog, PromptCatalog, PromptCatalogSource, SystemPromptTemplate, Worker,
WorkerError,
};
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
@@ -96,9 +99,9 @@ permission = "write"
/// Build a Worker 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 Worker constructed directly via `Worker::new`.
/// Builds an immutable effective catalog with `body` at the exact `test`
/// Prompt name and installs that parsed template on a directly constructed
/// Worker.
async fn make_worker_with_body(
body: &str,
client: MockClient,
@@ -117,10 +120,15 @@ async fn make_worker_with_body(
let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
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);
let mut templates = PromptCatalog::builtins_only()
.unwrap()
.projection()
.templates
.clone();
templates.insert("test".to_string(), body.to_string());
let projection =
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker = Engine::new(client);
let mut worker = Worker::new(
@@ -133,7 +141,7 @@ async fn make_worker_with_body(
)
.await?;
let template = SystemPromptTemplate::parse("$user/test", loader)
let template = SystemPromptTemplate::parse("test", loader)
.map_err(|source| WorkerError::InvalidSystemPromptTemplate { source })?;
worker.set_system_prompt_template(template);
@@ -146,15 +154,15 @@ async fn make_worker_with_body(
#[tokio::test]
async fn template_parse_rejects_invalid_syntax() {
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 worker_err: WorkerError = WorkerError::InvalidSystemPromptTemplate { source: err };
assert!(matches!(
worker_err,
WorkerError::InvalidSystemPromptTemplate { .. }
));
let mut templates = PromptCatalog::builtins_only()
.unwrap()
.projection()
.templates
.clone();
templates.insert("broken".to_string(), "{{ unclosed".to_string());
let error =
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap_err();
assert!(error.to_string().contains("does not compile"));
}
#[tokio::test]