refactor: rename pod crate to worker

This commit is contained in:
2026-06-26 00:05:57 +09:00
parent 4c677640f4
commit 6c59fe927b
194 changed files with 6637 additions and 6146 deletions
+116
View File
@@ -0,0 +1,116 @@
//! `AGENTS.md` ingestion for system-prompt templates.
//!
//! Reads `AGENTS.md` directly under the Worker cwd and exposes its body
//! to the template engine through `SystemPromptContext.agents_md`.
//! Nested / parent-directory AGENTS.md files are intentionally ignored;
//! subproject context is expressed by launching a Worker with that
//! directory as cwd.
//!
//! No size cap is applied here — the whole file is read and embedded.
//! System-prompt-size policing is the responsibility of a higher layer
//! (Usage-driven warning after the first LLM round-trip).
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use tracing::warn;
/// Outcome of an `AGENTS.md` ingestion attempt.
///
/// `body` carries the text that should be handed to the template
/// engine (if any); `warnings` are short human-readable messages that
/// Worker forwards to the user-facing notification channel. The caller
/// also gets `tracing::warn!` lines for the developer log.
pub(crate) struct AgentsMdResult {
pub body: Option<String>,
pub warnings: Vec<String>,
}
/// Read `AGENTS.md` from `cwd` if present. All non-fatal problems are
/// both logged via `tracing::warn!` (developer-facing) and surfaced
/// via `AgentsMdResult::warnings` (user-facing).
///
/// - Absent: `body = None`, no warning.
/// - Non-UTF-8 or I/O error: `body = None`, warning.
pub(crate) fn read_agents_md(cwd: &Path) -> AgentsMdResult {
let path = cwd.join("AGENTS.md");
let mut warnings = Vec::new();
match fs::read_to_string(&path) {
Ok(body) => AgentsMdResult {
body: Some(body),
warnings,
},
Err(e) if e.kind() == ErrorKind::NotFound => AgentsMdResult {
body: None,
warnings,
},
Err(e) if e.kind() == ErrorKind::InvalidData => {
warn!(path = %path.display(), error = %e, "AGENTS.md is not valid UTF-8");
warnings.push(format!(
"AGENTS.md ({}) is not valid UTF-8: {}",
path.display(),
e
));
AgentsMdResult {
body: None,
warnings,
}
}
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to read AGENTS.md");
warnings.push(format!(
"failed to read AGENTS.md ({}): {}",
path.display(),
e
));
AgentsMdResult {
body: None,
warnings,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn absent_file_returns_none() {
let dir = TempDir::new().unwrap();
assert!(read_agents_md(dir.path()).body.is_none());
}
#[test]
fn reads_small_file_verbatim() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("AGENTS.md"), "# hello\nworld").unwrap();
let result = read_agents_md(dir.path());
assert_eq!(result.body.as_deref(), Some("# hello\nworld"));
assert!(result.warnings.is_empty());
}
#[test]
fn reads_large_file_verbatim() {
// Previously truncated at 64KB; now read whole. Size-policing
// is deferred to the Usage-driven warning layer.
let dir = TempDir::new().unwrap();
let body = "a".repeat(128 * 1024);
fs::write(dir.path().join("AGENTS.md"), &body).unwrap();
let result = read_agents_md(dir.path());
assert_eq!(result.body.as_ref().map(String::len), Some(128 * 1024));
assert!(result.warnings.is_empty());
}
#[test]
fn non_utf8_surfaces_warning() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("AGENTS.md"), [0xff, 0xfe, 0xfd]).unwrap();
let result = read_agents_md(dir.path());
assert!(result.body.is_none());
assert_eq!(result.warnings.len(), 1);
}
}
+771
View File
@@ -0,0 +1,771 @@
//! Central catalog of Worker-level prompt strings.
//!
//! Prompts that Worker injects into a Engine (compaction system prompt,
//! notification wrapper, interrupt notes, system-prompt trailing
//! sections, AGENTS.md truncation notice, ...) are enumerated by
//! [`WorkerPrompt`] and rendered through a single [`PromptCatalog`]. Direct
//! `const &str` / `format!` authoring of these strings elsewhere in
//! `crates/worker` is deliberately avoided — new injection points add a
//! variant here, which forces a matching entry in
//! `resources/prompts/internal.toml` (checked at build time) and keeps
//! the "Worker tone" editable in one place.
//!
//! # Layering
//!
//! Values are merged key-wise from low priority to high:
//!
//! 1. **builtin** — `resources/prompts/internal.toml`, baked into the
//! binary. Must cover every [`WorkerPrompt`] variant (build-time check).
//! 2. **user** — `<config_dir>/prompts.toml`, when a caller supplies it.
//! Optional.
//! 3. **workspace** — `<project>/.yoi/prompts.toml`, when a caller
//! supplies it. Optional.
//! 4. **manifest pack** — `manifest.worker.prompt_pack`, an explicit path
//! per-Worker. Optional.
//!
//! Unknown keys in layers 24 are logged via `tracing::warn!` and
//! ignored (forward compatibility). Layer 1 is enforced at build time.
//!
//! # Template language
//!
//! All values are minijinja templates. `{% include "$prefix/..." %}`
//! resolves through the same [`PromptLoader`] used by the system-prompt
//! template, so long prompt bodies can be factored into `.md` files
//! under `resources/prompts/...`, the user prompts library, or the
//! workspace prompts library.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use serde::Deserialize;
use thiserror::Error;
use tracing::warn;
use crate::prompt::loader::PromptLoader;
// Generated by build.rs from `resources/prompts/internal.toml`.
include!(concat!(env!("OUT_DIR"), "/internal_keys.rs"));
/// Source of the builtin pack. Baked in at compile time.
const INTERNAL_TOML: &str = include_str!("../../../../resources/prompts/internal.toml");
/// Worker-level prompt injection point.
///
/// Adding a new variant also requires adding a matching key to
/// `resources/prompts/internal.toml`; the build fails otherwise.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum WorkerPrompt {
/// System prompt of the compaction (summary) Engine.
CompactSystem,
/// System prompt of the memory extract Engine.
MemoryExtractSystem,
/// System prompt of the memory consolidation (integration + tidy) Engine.
MemoryConsolidationSystem,
/// Wrapper around an incoming `Method::Notify` message injected into
/// the next LLM request context as a transient system message.
NotifyWrapper,
/// Synthetic `Item::ToolResult` summary used to close out orphaned
/// tool calls when a paused turn is interrupted by the user.
InterruptToolResultSummary,
/// System note prepended to the new turn after an interrupt.
InterruptSystemNote,
/// Trailing `## Working boundaries` section appended to every
/// materialised system prompt.
WorkingBoundariesSection,
/// Trailing `## Project instructions (AGENTS.md)` section, appended
/// after the scope summary when an AGENTS.md is present.
AgentsMdSection,
/// Trailing `## Resident memory summary` section, appended after the
/// AGENTS.md section when memory is enabled, summary injection is enabled,
/// and `memory/summary.md` has a valid non-empty body.
ResidentMemorySummarySection,
/// Trailing `## Resident knowledge` section, appended after the
/// resident memory summary when memory is enabled, Knowledge resident
/// injection is enabled, and at least one `knowledge/*` record advertises
/// `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing `## Resident workflows` section, appended after resident
/// knowledge when Workflow resident injection is enabled and at least one
/// workflow advertises `model_invokation: true`.
ResidentWorkflowsSection,
/// Trailing Worker orchestration guidance, appended when registered tools
/// include Worker-management capabilities.
WorkerOrchestrationGuidanceSection,
/// Weak Companion Notify payload for explicit Orchestrator Ticket events.
TicketEventCompanionNotice,
/// LLM-facing description for the SpawnWorker tool, including discovered
/// profile selectors.
SpawnWorkerToolDescription,
}
impl WorkerPrompt {
pub fn key(self) -> &'static str {
match self {
Self::CompactSystem => "compact_system",
Self::MemoryExtractSystem => "memory_extract_system",
Self::MemoryConsolidationSystem => "memory_consolidation_system",
Self::NotifyWrapper => "notify_wrapper",
Self::InterruptToolResultSummary => "interrupt_tool_result_summary",
Self::InterruptSystemNote => "interrupt_system_note",
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::ResidentWorkflowsSection => "resident_workflows_section",
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
}
}
/// All variants in declaration order. The associated `KEYS` slice
/// mirrors this for const-eval coverage checks against
/// `INTERNAL_KEYS` (generated by `build.rs`).
pub const ALL: &'static [WorkerPrompt] = &[
WorkerPrompt::CompactSystem,
WorkerPrompt::MemoryExtractSystem,
WorkerPrompt::MemoryConsolidationSystem,
WorkerPrompt::NotifyWrapper,
WorkerPrompt::InterruptToolResultSummary,
WorkerPrompt::InterruptSystemNote,
WorkerPrompt::WorkingBoundariesSection,
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentKnowledgeSection,
WorkerPrompt::ResidentWorkflowsSection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SpawnWorkerToolDescription,
];
pub const KEYS: &'static [&'static str] = &[
"compact_system",
"memory_extract_system",
"memory_consolidation_system",
"notify_wrapper",
"interrupt_tool_result_summary",
"interrupt_system_note",
"working_boundaries_section",
"agents_md_section",
"resident_memory_summary_section",
"resident_knowledge_section",
"resident_workflows_section",
"worker_orchestration_guidance_section",
"ticket_event_companion_notice",
"spawn_worker_tool_description",
];
}
// --- build-time bidirectional coverage check --------------------------------
const _: () = {
// Every enum key must appear in the builtin TOML.
let mut i = 0;
while i < WorkerPrompt::KEYS.len() {
if !const_slice_contains(INTERNAL_KEYS, WorkerPrompt::KEYS[i]) {
panic!(
"resources/prompts/internal.toml is missing a key declared by \
WorkerPrompt — regenerate the TOML or remove the variant"
);
}
i += 1;
}
// Every TOML key must correspond to an enum variant.
let mut i = 0;
while i < INTERNAL_KEYS.len() {
if !const_slice_contains(WorkerPrompt::KEYS, INTERNAL_KEYS[i]) {
panic!(
"resources/prompts/internal.toml has a key not declared by \
WorkerPrompt — add the variant or drop the key"
);
}
i += 1;
}
};
const fn const_str_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
const fn const_slice_contains(haystack: &[&str], needle: &str) -> bool {
let mut i = 0;
while i < haystack.len() {
if const_str_eq(haystack[i], needle) {
return true;
}
i += 1;
}
false
}
// --- errors ----------------------------------------------------------------
#[derive(Debug, Error)]
pub enum CatalogError {
#[error("failed to read prompt pack {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse prompt pack {}: {source}", .path.display())]
ParseToml {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to parse builtin prompt pack: {0}")]
ParseBuiltin(#[source] toml::de::Error),
#[error("failed to compile prompt template '{key}': {source}")]
TemplateCompile {
key: String,
#[source]
source: minijinja::Error,
},
#[error("failed to render prompt '{key}': {source}")]
Render {
key: String,
#[source]
source: minijinja::Error,
},
#[error("prompt key '{key}' is not registered in the catalog")]
UnknownKey { key: String },
}
// --- pack file shape -------------------------------------------------------
#[derive(Debug, Deserialize)]
struct PackFile {
#[serde(default)]
prompt: HashMap<String, String>,
}
// --- catalog ---------------------------------------------------------------
/// Merged, compiled worker-prompt catalog.
///
/// Owns a `minijinja::Environment` with one template registered per
/// [`WorkerPrompt`] key (after the 4-layer merge). Includes inside templates
/// are resolved via a provided [`PromptLoader`], so values can pull from
/// `$yoi` / `$user` / `$workspace`.
pub struct PromptCatalog {
env: Environment<'static>,
}
impl std::fmt::Debug for PromptCatalog {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PromptCatalog").finish_non_exhaustive()
}
}
impl PromptCatalog {
/// Builtin-only catalog. All `{% include %}` references must resolve
/// through `$yoi` (user/workspace prefixes are unavailable).
pub fn builtins_only() -> Result<Arc<Self>, CatalogError> {
Self::load(&PromptLoader::builtins_only(), None)
}
/// Load the catalog honouring the 4-layer overlay.
///
/// - Layer 1 (builtin): `INTERNAL_TOML` baked into the binary.
/// - Layer 2 (user): `loader.user_pack_file()` if present.
/// - Layer 3 (workspace): `loader.workspace_pack_file()` if present.
/// - Layer 4 (manifest): `manifest_pack` as an absolute filesystem
/// path (pre-resolved by profile/manifest resolution).
pub fn load(
loader: &PromptLoader,
manifest_pack: Option<&Path>,
) -> Result<Arc<Self>, CatalogError> {
let mut merged = parse_builtin_pack()?;
if let Some(path) = loader.user_pack_file() {
if path.is_file() {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "user");
}
}
if let Some(path) = loader.workspace_pack_file() {
if path.is_file() {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "workspace");
}
}
if let Some(path) = manifest_pack {
let pack = parse_pack_file(path)?;
merge_into(&mut merged, pack, "manifest");
}
build_catalog(merged, loader.clone()).map(Arc::new)
}
/// Render a prompt by variant. `ctx` provides template variables; use
/// [`Value::UNDEFINED`] (or a helper below) when the template takes
/// no inputs.
pub fn render(&self, prompt: WorkerPrompt, ctx: Value) -> Result<String, CatalogError> {
let key = prompt.key();
let tmpl = self
.env
.get_template(key)
.map_err(|_| CatalogError::UnknownKey {
key: key.to_string(),
})?;
tmpl.render(ctx).map_err(|source| CatalogError::Render {
key: key.to_string(),
source,
})
}
/// Render `WorkerPrompt::CompactSystem` (no inputs).
pub fn compact_system(&self) -> Result<String, CatalogError> {
self.render(WorkerPrompt::CompactSystem, Value::UNDEFINED)
}
/// Render `WorkerPrompt::MemoryExtractSystem` with `{{ language }}`.
pub fn memory_extract_system(&self, language: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::MemoryExtractSystem,
single("language", language),
)
}
/// Render `WorkerPrompt::MemoryConsolidationSystem` with `{{ language }}`.
pub fn memory_consolidation_system(&self, language: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::MemoryConsolidationSystem,
single("language", language),
)
}
/// Render `WorkerPrompt::NotifyWrapper` with `{{ message }}`.
pub fn notify_wrapper(&self, message: &str) -> Result<String, CatalogError> {
self.render(WorkerPrompt::NotifyWrapper, single("message", message))
}
/// Render `WorkerPrompt::InterruptToolResultSummary` (no inputs).
pub fn interrupt_tool_result_summary(&self) -> Result<String, CatalogError> {
self.render(WorkerPrompt::InterruptToolResultSummary, Value::UNDEFINED)
}
/// Render `WorkerPrompt::InterruptSystemNote` (no inputs).
pub fn interrupt_system_note(&self) -> Result<String, CatalogError> {
self.render(WorkerPrompt::InterruptSystemNote, Value::UNDEFINED)
}
/// Render `WorkerPrompt::WorkingBoundariesSection` with `{{ scope_summary }}`.
pub fn working_boundaries_section(&self, scope_summary: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkingBoundariesSection,
single("scope_summary", scope_summary),
)
}
/// Render `WorkerPrompt::AgentsMdSection` with `{{ agents_md }}`.
pub fn agents_md_section(&self, agents_md: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::AgentsMdSection,
single("agents_md", agents_md),
)
}
/// Render `WorkerPrompt::ResidentMemorySummarySection` with `{{ summary }}`.
pub fn resident_memory_summary_section(&self, summary: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::ResidentMemorySummarySection,
single("summary", summary),
)
}
/// Render `WorkerPrompt::ResidentKnowledgeSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_knowledge_section(
&self,
entries: &str,
knowledge_query_available: bool,
memory_read_available: bool,
) -> Result<String, CatalogError> {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert("entries", Value::from(entries));
m.insert(
"knowledge_query_available",
Value::from(knowledge_query_available),
);
m.insert("memory_read_available", Value::from(memory_read_available));
self.render(WorkerPrompt::ResidentKnowledgeSection, Value::from(m))
}
/// Render `WorkerPrompt::ResidentWorkflowsSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_workflows_section(&self, entries: &str) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::ResidentWorkflowsSection,
single("entries", entries),
)
}
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
WorkerPrompt::WorkerOrchestrationGuidanceSection,
Value::UNDEFINED,
)
}
/// Render `WorkerPrompt::SpawnWorkerToolDescription`.
pub fn spawn_worker_tool_description(
&self,
available_profiles: &str,
default_profile: &str,
profile_diagnostic: &str,
) -> Result<String, CatalogError> {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert("available_profiles", Value::from(available_profiles));
m.insert("default_profile", Value::from(default_profile));
m.insert("profile_diagnostic", Value::from(profile_diagnostic));
self.render(WorkerPrompt::SpawnWorkerToolDescription, Value::from(m))
}
}
fn single(key: &'static str, value: &str) -> Value {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert(key, Value::from(value));
Value::from(m)
}
fn parse_builtin_pack() -> Result<HashMap<String, String>, CatalogError> {
let parsed: PackFile = toml::from_str(INTERNAL_TOML).map_err(CatalogError::ParseBuiltin)?;
Ok(parsed.prompt)
}
fn parse_pack_file(path: &Path) -> Result<HashMap<String, String>, CatalogError> {
let src = fs::read_to_string(path).map_err(|source| CatalogError::Io {
path: path.to_path_buf(),
source,
})?;
let parsed: PackFile = toml::from_str(&src).map_err(|source| CatalogError::ParseToml {
path: path.to_path_buf(),
source,
})?;
Ok(parsed.prompt)
}
fn merge_into(
base: &mut HashMap<String, String>,
upper: HashMap<String, String>,
origin: &'static str,
) {
for (k, v) in upper {
if !WorkerPrompt::KEYS.iter().any(|declared| *declared == k) {
warn!(
origin = origin,
key = %k,
"unknown prompt pack key; ignoring"
);
continue;
}
base.insert(k, v);
}
}
fn build_catalog(
templates: HashMap<String, String>,
loader: PromptLoader,
) -> Result<PromptCatalog, CatalogError> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
// Reuse the system-prompt-template resolver so `{% include
// "$prefix/..." %}` inside a catalog value pulls from the same asset
// namespaces.
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(),
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(src) => Ok(Some(src)),
Err(e) => Err(minijinja::Error::new(
ErrorKind::TemplateNotFound,
e.to_string(),
)),
}
});
for (k, v) in templates {
env.add_template_owned(k.clone(), v)
.map_err(|source| CatalogError::TemplateCompile {
key: k.clone(),
source,
})?;
}
Ok(PromptCatalog { env })
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn loader_with_packs(
user_dir: Option<PathBuf>,
workspace_dir: Option<PathBuf>,
user_pack: Option<PathBuf>,
workspace_pack: Option<PathBuf>,
) -> PromptLoader {
PromptLoader::new(user_dir, workspace_dir).with_pack_files(user_pack, workspace_pack)
}
#[test]
fn builtin_covers_every_variant() {
let cat = PromptCatalog::builtins_only().unwrap();
for p in WorkerPrompt::ALL {
assert!(
cat.env.get_template(p.key()).is_ok(),
"builtin missing key: {}",
p.key()
);
}
}
#[test]
fn builtin_render_compact_system_includes_worker_instructions() {
let cat = PromptCatalog::builtins_only().unwrap();
let rendered = cat.compact_system().unwrap();
assert!(rendered.contains("write_summary"));
assert!(rendered.contains("mark_read_required"));
}
#[test]
fn internal_worker_prompts_do_not_include_default_memory_guidance() {
let cat = PromptCatalog::builtins_only().unwrap();
let compact = cat.compact_system().unwrap();
let extract = cat.memory_extract_system("Japanese").unwrap();
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
for rendered in [compact, extract, consolidate] {
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("Do not query memory every turn"));
assert!(!rendered.contains("Strong lookup triggers include"));
}
}
#[test]
fn memory_worker_prompts_include_language() {
let cat = PromptCatalog::builtins_only().unwrap();
let extract = cat.memory_extract_system("Japanese").unwrap();
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
assert!(extract.contains("`language`: `Japanese`"));
assert!(consolidate.contains("`language`: `Japanese`"));
}
#[test]
fn notify_wrapper_interpolates_message() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.notify_wrapper("file changed").unwrap();
assert!(out.contains("[Notification]"));
assert!(out.contains("file changed"));
assert!(out.contains("not a blocking request"));
}
#[test]
fn working_boundaries_section_wraps_summary() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.working_boundaries_section("Readable: /a").unwrap();
assert!(out.contains("## Working boundaries"));
assert!(out.contains("Readable: /a"));
}
#[test]
fn agents_md_section_contains_marker() {
let cat = PromptCatalog::builtins_only().unwrap();
let out = cat.agents_md_section("PROJECT DOCS").unwrap();
assert!(out.contains("## Project instructions (AGENTS.md)"));
assert!(out.contains("PROJECT DOCS"));
}
#[test]
fn user_pack_overrides_builtin() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("prompts.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[OVERRIDDEN]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[OVERRIDDEN]");
// Other keys still come from the builtin.
assert!(cat.notify_wrapper("x").unwrap().contains("[Notification]"));
}
#[test]
fn workspace_pack_wins_over_user_pack() {
let tmp = TempDir::new().unwrap();
let user = tmp.path().join("user.toml");
let ws = tmp.path().join("ws.toml");
fs::write(
&user,
r#"
[prompt]
interrupt_system_note = "[USER]"
"#,
)
.unwrap();
fs::write(
&ws,
r#"
[prompt]
interrupt_system_note = "[WS]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(user), Some(ws));
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[WS]");
}
#[test]
fn manifest_pack_wins_over_workspace_pack() {
let tmp = TempDir::new().unwrap();
let ws = tmp.path().join("ws.toml");
let mf = tmp.path().join("mf.toml");
fs::write(
&ws,
r#"
[prompt]
interrupt_system_note = "[WS]"
"#,
)
.unwrap();
fs::write(
&mf,
r#"
[prompt]
interrupt_system_note = "[MF]"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, None, Some(ws));
let cat = PromptCatalog::load(&loader, Some(mf.as_path())).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[MF]");
}
#[test]
fn unknown_key_in_runtime_pack_is_ignored_with_warning() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("p.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[OK]"
future_injection_point = "tolerated"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
// Loads without error; the unknown key is dropped silently at
// runtime (log warning is emitted via tracing).
let cat = PromptCatalog::load(&loader, None).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[OK]");
}
#[test]
fn manifest_pack_reads_from_absolute_path() {
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("mine.toml");
fs::write(
&pack,
r#"
[prompt]
interrupt_system_note = "[FROM-MANIFEST-PACK]"
"#,
)
.unwrap();
let loader = PromptLoader::builtins_only();
let cat = PromptCatalog::load(&loader, Some(pack.as_path())).unwrap();
assert_eq!(cat.interrupt_system_note().unwrap(), "[FROM-MANIFEST-PACK]");
}
#[test]
fn value_can_pull_long_text_via_include() {
// A runtime pack that overrides `compact_system` with an
// `{% include %}` into the same `$yoi` namespace — exercises
// the template resolver path through all four layers.
let tmp = TempDir::new().unwrap();
let pack = tmp.path().join("p.toml");
fs::write(
&pack,
r#"
[prompt]
compact_system = "PREFIX\n{% include \"$yoi/internal/compact_system\" %}"
"#,
)
.unwrap();
let loader = loader_with_packs(None, None, Some(pack), None);
let cat = PromptCatalog::load(&loader, None).unwrap();
let rendered = cat.compact_system().unwrap();
assert!(rendered.starts_with("PREFIX\n"));
assert!(rendered.contains("write_summary"));
}
#[test]
fn worker_orchestration_guidance_section_renders_resource_body() {
let cat = PromptCatalog::builtins_only().unwrap();
let rendered = cat.worker_orchestration_guidance_section().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"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]
fn spawn_worker_tool_description_renders_profile_block() {
let cat = PromptCatalog::builtins_only().unwrap();
let rendered = cat
.spawn_worker_tool_description(
"- `project:coder` — Coder\n- `project:reviewer` — Reviewer",
"project:coder",
"",
)
.unwrap();
assert!(rendered.contains("Profile selection"));
assert!(rendered.contains("Default profile: project:coder"));
assert!(rendered.contains("`project:reviewer`"));
assert!(rendered.contains("Special selector: inherit"));
}
}
+425
View File
@@ -0,0 +1,425 @@
//! 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 { .. }));
}
}
+4
View File
@@ -0,0 +1,4 @@
pub(crate) mod agents_md;
pub(crate) mod catalog;
pub(crate) mod loader;
pub(crate) mod system;
+991
View File
@@ -0,0 +1,991 @@
//! System prompt template machinery for the Worker layer.
//!
//! 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
//! 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
//! the Worker's `Scope` summary, (if present) the project's `AGENTS.md`
//! contents, resident memory sections, and conditional Worker-orchestration
//! guidance, then the whole string is handed to the Engine via
//! `set_system_prompt`. Subsequent turns and compactions reuse that
//! materialised string verbatim.
use std::collections::BTreeMap;
use std::path::Path;
use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
use workflow_crate::ResidentWorkflowEntry;
use crate::prompt::catalog::{CatalogError, PromptCatalog};
use crate::prompt::loader::{LoaderError, PromptLoader, PromptRef};
#[derive(Debug, Error)]
pub enum SystemPromptError {
#[error("failed to resolve instruction reference: {0}")]
LoaderResolve(#[source] LoaderError),
#[error("system prompt template parse error: {0}")]
Parse(String),
#[error("system prompt template render error: {0}")]
Render(String),
#[error("failed to render trailing section template: {0}")]
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.
#[derive(Clone)]
pub struct SystemPromptTemplate {
env: Arc<Environment<'static>>,
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()))?;
Ok(Self {
env: Arc::new(env),
instruction_name: root_name,
})
}
/// Render the instruction body and append the fixed trailing
/// section (scope summary + optional AGENTS.md). The trailing
/// section is assembled in Rust so that authored templates cannot
/// accidentally omit the scope boundary or the project instructions.
pub fn render(&self, ctx: &SystemPromptContext<'_>) -> Result<String, SystemPromptError> {
let tmpl = self
.env
.get_template(&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()))?;
append_trailing_section(
&body,
ctx.prompts,
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_summary,
ctx.resident_knowledge,
ctx.resident_workflows,
ToolCapabilities::from_tool_names(&ctx.tool_names),
)
}
}
impl std::fmt::Debug for SystemPromptTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SystemPromptTemplate")
.field("instruction", &self.instruction_name)
.finish_non_exhaustive()
}
}
/// Inputs available to an instruction template at materialisation time.
///
/// Scope summary and AGENTS.md are deliberately **not** exposed to the
/// template — they live in the Rust-owned trailing section so user
/// templates cannot drop them on the floor.
pub struct SystemPromptContext<'a> {
pub now: DateTime<Utc>,
pub cwd: &'a Path,
/// Language policy exposed to instruction templates as `{{ language }}`.
pub language: &'a str,
pub scope: &'a Scope,
pub tool_names: Vec<String>,
/// Project-level instructions read from the nearest `AGENTS.md`.
/// Not visible from the template; consumed by the trailing-section
/// formatter in [`SystemPromptTemplate::render`].
pub agents_md: Option<String>,
/// The body of `<workspace>/.yoi/memory/summary.md`, with
/// frontmatter stripped. `None` disables the resident summary section;
/// empty strings are ignored by the trailing-section formatter.
pub resident_summary: Option<&'a str>,
/// Resident-injection candidates from `<workspace>/knowledge/*` whose
/// frontmatter has `model_invokation: true`. `None` disables the
/// section entirely (memory disabled, or a consolidation worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Resident workflow descriptions from `<workspace>/.yoi/workflow/*`
/// whose frontmatter has `model_invokation: true`. `None` disables the
/// section; consolidation workers opt out together with resident Knowledge.
pub resident_workflows: Option<&'a [ResidentWorkflowEntry]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
pub prompts: &'a PromptCatalog,
}
impl<'a> SystemPromptContext<'a> {
fn to_minijinja_value(&self) -> Value {
let mut root: BTreeMap<String, Value> = BTreeMap::new();
root.insert(
"date".into(),
Value::from(self.now.format("%Y-%m-%d").to_string()),
);
root.insert(
"time".into(),
Value::from(self.now.format("%H:%M:%S").to_string()),
);
root.insert(
"datetime".into(),
Value::from(self.now.to_rfc3339_opts(SecondsFormat::Secs, true)),
);
root.insert("cwd".into(), Value::from(self.cwd.display().to_string()));
root.insert("language".into(), Value::from(self.language));
root.insert(
"tools".into(),
Value::from(
self.tool_names
.iter()
.cloned()
.map(Value::from)
.collect::<Vec<_>>(),
),
);
root.insert(
"tool_capabilities".into(),
ToolCapabilities::from_tool_names(&self.tool_names).to_minijinja_value(),
);
Value::from(root)
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ToolCapabilities {
memory_query: bool,
knowledge_query: bool,
memory_read: bool,
memory_write: bool,
memory_edit: bool,
memory_delete: bool,
worker_spawn: bool,
worker_send: bool,
worker_read_output: bool,
worker_stop: bool,
worker_list: bool,
worker_restore: bool,
}
impl ToolCapabilities {
fn from_tool_names(names: &[String]) -> Self {
let mut capabilities = Self::default();
for name in names {
match name.as_str() {
"MemoryQuery" => capabilities.memory_query = true,
"KnowledgeQuery" => capabilities.knowledge_query = true,
"MemoryRead" => capabilities.memory_read = true,
"MemoryWrite" => capabilities.memory_write = true,
"MemoryEdit" => capabilities.memory_edit = true,
"MemoryDelete" => capabilities.memory_delete = true,
"SpawnWorker" => capabilities.worker_spawn = true,
"SendToWorker" => capabilities.worker_send = true,
"ReadWorkerOutput" => capabilities.worker_read_output = true,
"StopWorker" => capabilities.worker_stop = true,
"ListWorkers" => capabilities.worker_list = true,
"RestoreWorker" => capabilities.worker_restore = true,
_ => {}
}
}
capabilities
}
fn memory_records(self) -> bool {
self.memory_query
|| self.memory_read
|| self.memory_write
|| self.memory_edit
|| self.memory_delete
}
fn memory_any(self) -> bool {
self.memory_records() || self.knowledge_query
}
fn memory_mutation(self) -> bool {
self.memory_write || self.memory_edit || self.memory_delete
}
fn worker_management(self) -> bool {
self.worker_spawn
|| self.worker_send
|| self.worker_read_output
|| self.worker_stop
|| self.worker_list
|| self.worker_restore
}
fn to_minijinja_value(self) -> Value {
let mut map: BTreeMap<&'static str, Value> = BTreeMap::new();
map.insert("memory_any", Value::from(self.memory_any()));
map.insert("memory_records", Value::from(self.memory_records()));
map.insert("memory_query", Value::from(self.memory_query));
map.insert("knowledge_query", Value::from(self.knowledge_query));
map.insert("memory_read", Value::from(self.memory_read));
map.insert("memory_write", Value::from(self.memory_write));
map.insert("memory_edit", Value::from(self.memory_edit));
map.insert("memory_delete", Value::from(self.memory_delete));
map.insert("memory_mutation", Value::from(self.memory_mutation()));
map.insert("worker_management", Value::from(self.worker_management()));
Value::from(map)
}
}
/// 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
/// comes from the prompt catalog (`WorkerPrompt::WorkingBoundariesSection`
/// / `WorkerPrompt::AgentsMdSection`) so that wording can be overridden
/// per-pack without touching this function.
fn append_trailing_section(
body: &str,
prompts: &PromptCatalog,
scope: &Scope,
agents_md: Option<&str>,
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
resident_workflows: Option<&[ResidentWorkflowEntry]>,
tool_capabilities: ToolCapabilities,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
out.push_str(body);
if !body.ends_with('\n') {
out.push('\n');
}
out.push('\n');
let boundaries = prompts.working_boundaries_section(&scope.summary())?;
out.push_str(boundaries.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
if let Some(agents) = agents_md {
out.push('\n');
let section = prompts.agents_md_section(agents)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
if let Some(summary) = resident_summary {
let summary = summary.trim_matches(&['\n', '\r'][..]);
if !summary.trim().is_empty() {
out.push('\n');
let section = prompts.resident_memory_summary_section(summary)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if let Some(entries) = resident_knowledge {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_knowledge_entries(entries);
let section = prompts.resident_knowledge_section(
&formatted,
tool_capabilities.knowledge_query,
tool_capabilities.memory_read,
)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if let Some(entries) = resident_workflows {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_workflow_entries(entries);
let section = prompts.resident_workflows_section(&formatted)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if tool_capabilities.worker_management() {
out.push('\n');
let section = prompts.worker_orchestration_guidance_section()?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
// Canonicalise the tail so the emitted prompt has a single form
// regardless of how individual templates chose to end.
while out.ends_with('\n') || out.ends_with(' ') {
out.pop();
}
Ok(out)
}
/// `- <slug>: <description>` per line. Description newlines are folded
/// to spaces so a single entry stays on one row in the rendered prompt.
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_workflow_entries(entries: &[ResidentWorkflowEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
let mut out = String::new();
for (i, (slug, description)) in entries.enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(slug);
out.push_str(": ");
for ch in description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
out.push(ch);
}
}
}
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::*;
use chrono::TimeZone;
use manifest::{Permission, ScopeConfig, ScopeRule};
use tempfile::TempDir;
fn fixed_now() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 4, 15, 9, 30, 0).unwrap()
}
fn build_scope(dir: &Path) -> Scope {
let cfg = 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,
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: tools,
agents_md,
resident_summary: None,
resident_knowledge: None,
resident_workflows: 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,
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: summary,
resident_knowledge: None,
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentKnowledgeEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd,
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: Some(resident),
resident_workflows: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident_workflows<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentWorkflowEntry],
) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd,
language: manifest::defaults::WORKER_LANGUAGE,
scope,
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: None,
resident_workflows: Some(resident),
prompts: test_prompts(),
}
}
fn memory_tool_names() -> Vec<String> {
[
"MemoryQuery",
"KnowledgeQuery",
"MemoryRead",
"MemoryWrite",
"MemoryEdit",
"MemoryDelete",
]
.into_iter()
.map(String::from)
.collect()
}
fn worker_management_tool_names() -> Vec<String> {
[
"SpawnWorker",
"SendToWorker",
"ReadWorkerOutput",
"StopWorker",
"ListWorkers",
"RestoreWorker",
]
.into_iter()
.map(String::from)
.collect()
}
/// 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) {
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))
.unwrap();
// Builtin default body must expose the tool and language policies.
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`"));
assert!(rendered.contains("Strong lookup triggers include"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(rendered.contains("Do not query memory every turn"));
assert!(rendered.contains("MemoryWrite"));
assert!(rendered.contains("## Language"));
assert!(rendered.contains("`language`: `match the user's language"));
// Trailing section must be present.
assert!(rendered.contains("## Working boundaries"));
assert!(rendered.contains("Readable:"));
}
#[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,
))
.unwrap();
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("MemoryQuery"));
assert!(!rendered.contains("KnowledgeQuery"));
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"));
}
#[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(), "MemoryRead".into()],
None,
))
.unwrap();
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete"));
}
#[test]
fn worker_orchestration_guidance_is_included_for_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,
worker_management_tool_names(),
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"));
assert!(rendered.contains("worktree state, diff, and test results"));
assert!(rendered.contains("not scheduler or auto-maintain authorization"));
assert!(rendered.contains("bypass user/workflow authorization"));
}
#[test]
fn worker_orchestration_guidance_is_omitted_without_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(), "MemoryRead".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"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_none() {
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("Resident knowledge"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_empty_slice() {
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_resident(dir.path(), &scope, &[]))
.unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_renders_resident_knowledge_entries() {
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 entries = vec![
ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
},
ResidentKnowledgeEntry {
slug: "beta".into(),
description: "second record\nwith newline".into(),
},
];
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &entries))
.unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("- alpha: first record"));
// Newline in description is folded to a space (one entry per line).
assert!(rendered.contains("- beta: second record with newline"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
// Resident section sits *after* the working-boundaries header.
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident knowledge").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_mentions_resident_knowledge_tools_when_available() {
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 entries = [ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
}];
let mut context = ctx_with_resident(dir.path(), &scope, &entries);
context.tool_names = memory_tool_names();
let rendered = tmpl.render(&context).unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
}
#[test]
fn trailing_section_renders_resident_workflows() {
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 workflows = [ResidentWorkflowEntry {
slug: "resident-flow".to_string(),
description: "workflow resident desc\nwith newline".to_string(),
}];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(rendered.contains("## Resident workflows"));
assert!(rendered.contains("- resident-flow: workflow resident desc with newline"));
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident workflows").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_omits_empty_resident_workflows() {
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 workflows: [ResidentWorkflowEntry; 0] = [];
let rendered = tmpl
.render(&ctx_with_resident_workflows(dir.path(), &scope, &workflows))
.unwrap();
assert!(!rendered.contains("Resident workflows"));
}
}