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
+4 -4
View File
@@ -1028,7 +1028,7 @@ profile = "builtin:companion"
r#"
[ticket.roles.reviewer]
profile = "builtin:companion"
launch_prompt = "$workspace/ticket/reviewer/launch"
launch_prompt = "ticket.reviewer.launch"
"#,
);
let mut context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Reviewer);
@@ -1043,11 +1043,11 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
assert_eq!(plan.profile, "builtin:companion");
assert_eq!(
plan.launch_prompt_ref.as_deref(),
Some("$workspace/ticket/reviewer/launch")
Some("ticket.reviewer.launch")
);
assert!(matches!(&plan.run_segments[0], Segment::Text { .. }));
assert!(!text.contains("Configured launch_prompt"));
assert!(!text.contains("$workspace/ticket/reviewer/launch"));
assert!(!text.contains("ticket.reviewer.launch"));
assert!(!text.contains("Profile selector: builtin:companion"));
assert!(!text.contains("Role: reviewer"));
assert!(!text.contains("system_instruction"));
@@ -1228,7 +1228,7 @@ profile = "./coder.toml"
r#"
[ticket.roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
system_instruction = "unsupported"
"#,
);
let context = TicketRoleLaunchContext::new(temp.path(), TicketRole::Coder);
+1 -20
View File
@@ -349,11 +349,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
pub struct WorkerMetaConfig {
#[serde(default)]
pub name: Option<String>,
/// Optional `PromptCatalog` manifest pack override. See
/// [`crate::WorkerMeta::prompt_pack`] for semantics. Relative paths
/// are resolved through [`WorkerManifestConfig::resolve_paths`].
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -554,9 +549,6 @@ impl WorkerManifestConfig {
base.display()
);
resolve_auth_file(&mut self.model.auth, base);
if let Some(ref mut pack) = self.worker.prompt_pack {
*pack = join_if_relative(base, pack);
}
for rule in &mut self.scope.allow {
rule.target = join_if_relative(base, &rule.target);
}
@@ -718,7 +710,6 @@ impl WorkerMetaConfig {
fn merge(self, upper: Self) -> Self {
Self {
name: upper.name.or(self.name),
prompt_pack: upper.prompt_pack.or(self.prompt_pack),
}
}
}
@@ -1018,10 +1009,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
.worker
.name
.ok_or(ResolveError::MissingField("worker.name"))?;
let prompt_pack = cfg.worker.prompt_pack;
if let Some(ref p) = prompt_pack {
ensure_absolute("worker.prompt_pack", p)?;
}
validate_model_paths(&cfg.model, "model.auth.file")?;
@@ -1150,7 +1137,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
validate_mcp_config(&cfg.mcp)?;
Ok(WorkerManifest {
worker: WorkerMeta { name, prompt_pack },
worker: WorkerMeta { name },
model: cfg.model,
engine,
scope: cfg.scope,
@@ -1187,7 +1174,6 @@ mod tests {
WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
@@ -1505,7 +1491,6 @@ mod tests {
let lower = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("lower".into()),
prompt_pack: None,
},
model: ModelManifest {
model_id: Some("lower-model".into()),
@@ -1516,7 +1501,6 @@ mod tests {
let upper = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("upper".into()),
prompt_pack: None,
},
..Default::default()
};
@@ -1925,7 +1909,6 @@ enabled = false
.merge(WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("feature-test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
@@ -2008,7 +1991,6 @@ enabled = true
.merge(WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("feature-merge-test".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
@@ -2075,7 +2057,6 @@ permission = "write"
let overlay = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some("x".into()),
prompt_pack: None,
},
model: ModelManifest {
scheme: Some(SchemeKind::Anthropic),
+3 -4
View File
@@ -42,10 +42,9 @@ pub const COMPACT_OVERVIEW_WARNING_TOKENS: u64 = 16_000;
/// See [`crate::CompactionConfig::overview_deadline_tokens`].
pub const COMPACT_OVERVIEW_DEADLINE_TOKENS: u64 = 40_000;
/// Default instruction asset reference used when `worker.instruction`
/// is omitted. See the `PromptLoader` prefix addressing scheme for the
/// `$yoi/` / `$user/` / `$workspace/` namespaces.
pub const DEFAULT_INSTRUCTION: &str = "$yoi/default";
/// Default exact catalog-root dotted Prompt name used when
/// `worker.instruction` is omitted.
pub const DEFAULT_INSTRUCTION: &str = "default";
/// Default language policy used by the main worker for normal prose
/// responses. See [`crate::EngineManifest::language`].
+4 -20
View File
@@ -500,29 +500,13 @@ pub struct MemoryConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerMeta {
pub name: String,
/// Optional path to a TOML override file read as the top layer of
/// `worker::PromptCatalog`. Subject to the same relative-path
/// resolution as other manifest paths (joined against the
/// manifest's base directory). `None` leaves the 4th overlay layer
/// empty; auto-discovered user and workspace packs still apply.
///
/// Note: unlike `worker.instruction`, this is a plain filesystem
/// path — not a `$prefix/` prompt reference. Pack files carry
/// structured TOML data, while `worker.instruction` points at a
/// minijinja `.md` template; the two use different addressing
/// conventions on purpose.
#[serde(default)]
pub prompt_pack: Option<PathBuf>,
}
/// Worker-level configuration embedded in the manifest.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineManifest {
/// Reference to the instruction prompt asset used as the body of
/// the worker's system prompt. Uses the `PromptLoader` prefix
/// addressing scheme (`$yoi/...`, `$user/...`,
/// `$workspace/...`) and is always populated after resolution —
/// unset manifests fall through to [`defaults::DEFAULT_INSTRUCTION`].
/// Exact catalog-root dotted Prompt name (for example `default` or
/// `role.coder`).
#[serde(default = "default_instruction")]
pub instruction: String,
/// Language policy used by the main worker for normal prose responses.
@@ -959,7 +943,7 @@ model_id = "claude-sonnet-4-20250514"
auth = { kind = "api_key", file = "/abs/keys/anthropic" }
[engine]
instruction = "$user/reviewer"
instruction = "role.reviewer"
max_tokens = 4096
temperature = 0.3
top_p = 0.9
@@ -995,7 +979,7 @@ permission = "write"
_ => panic!("expected ApiKey"),
};
assert_eq!(file, Some(std::path::Path::new("/abs/keys/anthropic")));
assert_eq!(manifest.engine.instruction, "$user/reviewer");
assert_eq!(manifest.engine.instruction, "role.reviewer");
assert_eq!(manifest.engine.max_tokens, Some(4096));
assert_eq!(manifest.engine.temperature, Some(0.3));
assert_eq!(manifest.engine.top_p, Some(0.9));
+1 -27
View File
@@ -3,7 +3,7 @@
//! 用途別に三つの base directory を持つ:
//!
//! - **`config_dir`** — 人が手で書く / 編集する設定。`profiles.toml`,
//! `providers.toml`, `models.toml`, `prompts/`, `prompts.toml`
//! `providers.toml`, `models.toml` 等
//! - **`data_dir`** — プログラムが書く永続データ。`sessions/` 等
//! - **`secret_data_dir`** — local secret store の読み書き base。既存
//! secret store は path-derived key を使うため、通常 data とは別に
@@ -85,16 +85,6 @@ pub fn user_profiles_path() -> Option<PathBuf> {
user_profiles_path_from_config_dir(config_dir())
}
/// `<config_dir>/prompts/` — user prompts ライブラリ。
pub fn user_prompts_dir() -> Option<PathBuf> {
user_prompts_dir_from_config_dir(config_dir())
}
/// `<config_dir>/prompts.toml` — user prompt pack。
pub fn user_pack_file() -> Option<PathBuf> {
user_pack_file_from_config_dir(config_dir())
}
/// `<config_dir>/<file_name>` — providers.toml / models.toml 等の
/// user override ファイル。
pub fn user_catalog_override(file_name: &str) -> Option<PathBuf> {
@@ -200,14 +190,6 @@ fn user_profiles_path_from_config_dir(config_dir: Option<PathBuf>) -> Option<Pat
Some(config_dir?.join("profiles.toml"))
}
fn user_prompts_dir_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts"))
}
fn user_pack_file_from_config_dir(config_dir: Option<PathBuf>) -> Option<PathBuf> {
Some(config_dir?.join("prompts.toml"))
}
fn user_catalog_override_from_config_dir(
config_dir: Option<PathBuf>,
file_name: &str,
@@ -465,14 +447,6 @@ mod tests {
user_profiles_path_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/profiles.toml")
);
assert_eq!(
user_prompts_dir_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/prompts")
);
assert_eq!(
user_pack_file_from_config_dir(config_dir.clone()).unwrap(),
PathBuf::from("/sand/config/prompts.toml")
);
assert_eq!(
user_catalog_override_from_config_dir(config_dir, "providers.toml").unwrap(),
PathBuf::from("/sand/config/providers.toml")
-1
View File
@@ -547,7 +547,6 @@ fn resolve_profile_value(
let config = WorkerManifestConfig {
worker: WorkerMetaConfig {
name: Some(worker_name),
prompt_pack: None,
},
model: profile.model.unwrap_or_default(),
engine: profile.engine.unwrap_or_default(),
+6 -6
View File
@@ -1047,19 +1047,19 @@ worktree_name = "custom-orchestrator"
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
launch_prompt = "ticket.intake.launch"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
launch_prompt = "ticket.orchestrator.launch"
[ticket.roles.coder]
profile = "inherit"
launch_prompt = "$workspace/ticket/coder/launch"
launch_prompt = "ticket.coder.launch"
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
launch_prompt = "ticket.reviewer.launch"
"#,
);
@@ -1095,7 +1095,7 @@ launch_prompt = "$workspace/ticket/reviewer/launch"
.launch_prompt_for(TicketRole::Reviewer)
.unwrap()
.as_str(),
"$workspace/ticket/reviewer/launch"
"ticket.reviewer.launch"
);
}
@@ -1338,7 +1338,7 @@ profile = "builtin:companion"
r#"
[roles.coder]
profile = "inherit"
system_instruction = "$workspace/not-supported"
system_instruction = "unsupported"
"#,
);
@@ -24,6 +24,8 @@ pub struct ConfigBundle {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_catalog: Option<worker::EffectivePromptCatalog>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<ProfileSourceArchive>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive_handle: Option<BackendResourceHandle>,
@@ -69,6 +71,16 @@ impl ConfigBundle {
));
}
if let Some(prompt_catalog) = &self.prompt_catalog {
lines.push(format!(
"prompt_catalog\0{}\0{}\0{}\0{}",
prompt_catalog.config_revision,
prompt_catalog.schema_fingerprint,
prompt_catalog.toolchain_fingerprint,
prompt_catalog.catalog_digest
));
}
if let Some(archive) = &self.profile_source_archive {
lines.push(format!(
"profile_archive\0{}\0{}\0{}",
@@ -274,6 +286,12 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim
validate_declaration_reference(&bundle.metadata.id, declaration)?;
}
if let Some(prompt_catalog) = &bundle.prompt_catalog {
prompt_catalog.verify_digest().map_err(|error| {
RuntimeError::InvalidRequest(format!("invalid Prompt catalog projection: {error}"))
})?;
}
if let Some(archive) = &bundle.profile_source_archive {
validate_profile_source_archive_ref(&archive.reference).map_err(|err| {
RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}"))
@@ -582,6 +600,7 @@ mod tests {
name: "credential".to_string(),
reference: reference.to_string(),
}],
prompt_catalog: None,
profile_source_archive: None,
profile_source_archive_handle: None,
}
@@ -615,6 +634,32 @@ mod tests {
validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap();
}
#[test]
fn validates_immutable_prompt_catalog_projection() {
let mut bundle = bundle_with_declaration("secret:github-token");
bundle.prompt_catalog = Some(
worker::EffectivePromptCatalog::new(
std::collections::BTreeMap::from([("default".to_string(), "hello".to_string())]),
7,
"schema",
"toolchain",
)
.unwrap(),
);
bundle = bundle.with_computed_digest();
validate_config_bundle(&bundle).unwrap();
bundle
.prompt_catalog
.as_mut()
.unwrap()
.templates
.insert("default".into(), "tampered".into());
bundle = bundle.with_computed_digest();
let error = validate_config_bundle(&bundle).unwrap_err();
assert!(error.to_string().contains("catalog digest mismatch"));
}
#[test]
fn bundle_summary_redacts_runtime_internal_resource_handle() {
let mut bundle = bundle_with_declaration("secret:github-token");
+2
View File
@@ -1811,6 +1811,7 @@ mod tests {
label: Some("test".to_string()),
}],
declarations: Vec::new(),
prompt_catalog: None,
profile_source_archive: None,
profile_source_archive_handle: None,
}
@@ -2648,6 +2649,7 @@ mod ws_tests {
label: Some("ws".to_string()),
}],
declarations: Vec::new(),
prompt_catalog: None,
profile_source_archive: None,
profile_source_archive_handle: None,
}
+3 -3
View File
@@ -743,10 +743,10 @@ mod tests {
.load(Some("profiles/main.dcdl"), "./shared.dcdl")
.unwrap();
match loaded {
LoadedImport::Source(source) => {
assert_eq!(source.key, "profiles/shared.dcdl");
LoadedImport::Source { key, .. } => {
assert_eq!(key, "profiles/shared.dcdl");
}
LoadedImport::Value(_) => panic!("expected source import"),
LoadedImport::Value { .. } => panic!("expected source import"),
}
}
+1
View File
@@ -2832,6 +2832,7 @@ mod tests {
name: "read".to_string(),
reference: "capability:read".to_string(),
}],
prompt_catalog: None,
profile_source_archive: None,
profile_source_archive_handle: None,
}
+20 -5
View File
@@ -53,7 +53,7 @@ use worker::feature::builtin::{
#[cfg(feature = "ws-server")]
use worker::ipc::protocol_session::{live_log_entry_event, subscribe_worker_protocol_session};
use worker::{
PromptLoader, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
PromptCatalogSource, SegmentLogSink, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker,
WorkerController, WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority,
WorkerHandle, WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
};
@@ -329,12 +329,12 @@ impl ProfileRuntimeWorkerFactory {
fn restore_fallback_manifest(
worker_name: &str,
) -> Result<(manifest::WorkerManifest, PromptLoader), String> {
) -> Result<(manifest::WorkerManifest, PromptCatalogSource), String> {
let mut config = manifest::WorkerManifestConfig::builtin_defaults();
config.worker.name = Some(worker_name.to_string());
let manifest = manifest::WorkerManifest::try_from(config)
.map_err(|err| format!("failed to build restore fallback manifest: {err}"))?;
Ok((manifest, PromptLoader::builtins_only()))
Ok((manifest, PromptCatalogSource::builtins_only()))
}
async fn resolve_profile_source_archive(
&self,
@@ -566,7 +566,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
let archive = self
.resolve_profile_source_archive(&request.request.profile_source)
.await?;
let (manifest, loader) = {
let (manifest, mut loader) = {
let manifest = archive
.resolve_profile(selector, &worker_root, &worker_name)
.map_err(|err| format!("failed to resolve profile source archive: {err}"))?;
@@ -584,6 +584,13 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
)?
}
};
if let Some(prompt_catalog) = request
.config_bundle
.as_ref()
.and_then(|bundle| bundle.prompt_catalog.clone())
{
loader = loader.with_effective_catalog(prompt_catalog);
}
let flow_transition_enabled = manifest.feature.flow.enabled;
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
@@ -719,7 +726,14 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
self.worker_mutation_identity.as_ref(),
self.embedded_worker_mutation_dispatcher.as_ref(),
);
let (manifest, loader) = Self::restore_fallback_manifest(&worker_name)?;
let (manifest, mut loader) = Self::restore_fallback_manifest(&worker_name)?;
if let Some(prompt_catalog) = request
.config_bundle
.as_ref()
.and_then(|bundle| bundle.prompt_catalog.clone())
{
loader = loader.with_effective_catalog(prompt_catalog);
}
let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?;
let session_dir = worker_aggregate_dir.join("session");
@@ -2090,6 +2104,7 @@ mod tests {
label: Some("adapter-test".to_string()),
}],
declarations: Vec::new(),
prompt_catalog: None,
profile_source_archive: Some(sample_profile_archive()),
profile_source_archive_handle: None,
}
+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()
}
}
+114 -536
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(),
/// 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"
)));
}
});
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,
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(',') }}",
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}"
);
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"));
}
}
+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]
+4 -4
View File
@@ -121,19 +121,19 @@ root = ".yoi/tickets"
[ticket.roles.intake]
profile = "project:intake"
launch_prompt = "$workspace/ticket/intake/launch"
launch_prompt = "ticket.intake.launch"
[ticket.roles.orchestrator]
profile = "project:orchestrator"
launch_prompt = "$workspace/ticket/orchestrator/launch"
launch_prompt = "ticket.orchestrator.launch"
[ticket.roles.coder]
profile = "project:coder"
launch_prompt = "$workspace/ticket/coder/launch"
launch_prompt = "ticket.coder.launch"
[ticket.roles.reviewer]
profile = "project:reviewer"
launch_prompt = "$workspace/ticket/reviewer/launch"
launch_prompt = "ticket.reviewer.launch"
```
Fixed roles are:
+3 -10
View File
@@ -30,12 +30,6 @@
# 必須。Worker の表示名 (ResolveError::MissingField("worker.name") の対象)。
name = "example-agent"
# 任意。デフォルト: なし。
# PromptCatalog の 4 つ目の overlay 層として読み込む TOML pack のパス。
# 相対パスは manifest base 起点で解決。`worker.instruction` (`$prefix/...`)
# とは別系統の単なるファイルパス。
# prompt_pack = "./prompts.local.toml"
# ===== [model] ==============================================================
# LLM モデル設定。次の 3 形態を受ける:
@@ -95,10 +89,9 @@ ref = "anthropic/claude-sonnet-4-6"
# ワーカーの生成パラメータ等。セクション自体省略可 (全フィールド任意)。
[engine]
# 任意。デフォルト: "$yoi/default" (`defaults::DEFAULT_INSTRUCTION`)。
# システムプロンプト本体の `PromptLoader` 参照
# プレフィクス: "$yoi/..." | "$user/..." | "$workspace/..."
# instruction = "$yoi/default"
# 任意。デフォルト: "default" (`defaults::DEFAULT_INSTRUCTION`)。
# effective Prompt catalog の exact dotted name を選択する
# instruction = "default"
# 任意。デフォルト: なし (プロバイダ任せ)。
# 1 レスポンスあたりの出力 token 上限。
+1
View File
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
slug = "coder";
description = "Ticket implementation coder profile.";
scope = "workspace_write";
engine = { instruction = "role.coder"; };
feature = {
task = { enabled = true; };
+1
View File
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
slug = "intake";
description = "Ticket intake profile.";
scope = "workspace_write";
engine = { instruction = "role.intake"; };
feature = {
task = { enabled = true; };
+1
View File
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
slug = "orchestrator";
description = "Ticket orchestrator profile.";
scope = "workspace_write";
engine = { instruction = "role.orchestrator"; };
feature = {
task = { enabled = true; };
+1
View File
@@ -2,6 +2,7 @@ import "./base.dcdl" // {
slug = "reviewer";
description = "Ticket review profile.";
scope = "workspace_read";
engine = { instruction = "role.reviewer"; };
feature = {
task = { enabled = true; };
+71
View File
@@ -0,0 +1,71 @@
# Deterministic builtin Prompt source tree. Markdown imports use the shared
# { frontmatter, content } contract; every leaf below selects only .content.
# `default_prompt` is the DCDL source alias for the effective catalog's
# reserved dotted name `default`.
let
defaultDocument = import "./default.md";
commonLanguage = import "./common/language.md";
commonTickets = import "./common/tickets.md";
commonToolUsage = import "./common/tool-usage.md";
commonWorkerObservation = import "./common/worker-observation.md";
commonWorkerOrchestration = import "./common/worker-orchestration.md";
commonWorkspace = import "./common/workspace.md";
commonWriting = import "./common/writing.md";
roleCoder = import "./role/coder.md";
roleIntake = import "./role/intake.md";
roleOrchestrator = import "./role/orchestrator.md";
roleReviewer = import "./role/reviewer.md";
internalCompactSystem = import "./internal/compact_system.md";
internalFlowVerifierSystem = import "./internal/flow_verifier_system.md";
internalMemoryConsolidationSystem = import "./internal/memory_consolidation_system.md";
internalMemoryExtractSystem = import "./internal/memory_extract_system.md";
internalWorkspaceOrchestratorQueueAttention = import "./internal/workspace_orchestrator_queue_attention.md";
internalNotifyWrapper = import "./internal/notify_wrapper.md";
internalInterruptToolResultSummary = import "./internal/interrupt_tool_result_summary.md";
internalInterruptSystemNote = import "./internal/interrupt_system_note.md";
internalWorkingBoundariesSection = import "./internal/working_boundaries_section.md";
internalAgentsMdSection = import "./internal/agents_md_section.md";
internalResidentMemorySummarySection = import "./internal/resident_memory_summary_section.md";
internalSubWorkerSpawnToolDescription = import "./internal/sub_worker_spawn_tool_description.md";
panelOrchestratorIdleQueueNotice = import "./panel/orchestrator_idle_queue_notice.md";
workerTicketEventCompanionNotice = import "./worker/ticket_event_companion_notice.md";
in
{
default_prompt = defaultDocument.content;
common = {
language = commonLanguage.content;
tickets = commonTickets.content;
tool_usage = commonToolUsage.content;
worker_observation = commonWorkerObservation.content;
worker_orchestration = commonWorkerOrchestration.content;
workspace = commonWorkspace.content;
writing = commonWriting.content;
};
role = {
coder = roleCoder.content;
intake = roleIntake.content;
orchestrator = roleOrchestrator.content;
reviewer = roleReviewer.content;
};
internal = {
compact_system = internalCompactSystem.content;
flow_verifier_system = internalFlowVerifierSystem.content;
memory_consolidation_system = internalMemoryConsolidationSystem.content;
memory_extract_system = internalMemoryExtractSystem.content;
workspace_orchestrator_queue_attention = internalWorkspaceOrchestratorQueueAttention.content;
notify_wrapper = internalNotifyWrapper.content;
interrupt_tool_result_summary = internalInterruptToolResultSummary.content;
interrupt_system_note = internalInterruptSystemNote.content;
working_boundaries_section = internalWorkingBoundariesSection.content;
agents_md_section = internalAgentsMdSection.content;
resident_memory_summary_section = internalResidentMemorySummarySection.content;
worker_orchestration_guidance_section = commonWorkerOrchestration.content;
sub_worker_spawn_tool_description = internalSubWorkerSpawnToolDescription.content;
};
panel = {
orchestrator_idle_queue_notice = panelOrchestratorIdleQueueNotice.content;
};
worker = {
ticket_event_companion_notice = workerTicketEventCompanionNotice.content;
};
}
@@ -1,3 +1,4 @@
---
## SubWorker orchestration
+4 -4
View File
@@ -2,11 +2,11 @@ You are here as an agent of the "yoi system".
Stay precise, edit code directly when asked, and avoid speculative refactoring.
{% include "common/workspace" %}
{% include "common.workspace" %}
{% include "common/tool-usage" %}
{% include "common.tool_usage" %}
{% include "common/language" %}
{% include "common.language" %}
{% include "common/writing" %}
{% include "common.writing" %}
-71
View File
@@ -1,71 +0,0 @@
# Worker internal prompts (builtin pack).
#
# Values are minijinja template strings. Use `{% include "$prefix/..." %}`
# to pull in long text from the $yoi / $user / $workspace prompt
# libraries.
#
# Every key here MUST correspond to a `WorkerPrompt` variant; missing or
# extra keys cause a build-time error (see `crates/worker/build.rs`).
[prompt]
compact_system = "{% include \"$yoi/internal/compact_system\" %}"
memory_extract_system = "{% include \"$yoi/internal/memory_extract_system\" %}"
memory_consolidation_system = "{% include \"$yoi/internal/memory_consolidation_system\" %}"
flow_verifier_system = "{% include \"$yoi/internal/flow_verifier_system\" %}"
notify_wrapper = """\
[Notification]
{{ message }}
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.\
"""
interrupt_tool_result_summary = "[Interrupted by user]"
interrupt_system_note = "[The previous turn was interrupted by the user. The user's next request follows.]"
working_boundaries_section = """\
---
## Working boundaries
{{ scope_summary }}\
"""
agents_md_section = """\
---
## Project instructions (AGENTS.md)
{{ agents_md }}\
"""
resident_memory_summary_section = """\
---
## Resident memory summary
The following is the current durable session/workspace summary. Treat it as background context; it is not a user request.
{{ summary }}\
"""
worker_orchestration_guidance_section = "{% include \"$yoi/common/worker-orchestration\" %}"
ticket_event_companion_notice = "{% include \"$yoi/worker/ticket_event_companion_notice\" %}"
sub_worker_spawn_tool_description = """\
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
Optional `cwd`: when provided, it is the Internal SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
Default profile: {{ default_profile }}
Special selector: inherit derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
Available registry profiles:
{{ available_profiles }}{% if profile_diagnostic %}
Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}\
"""
@@ -0,0 +1,5 @@
---
## Project instructions (AGENTS.md)
{{ agents_md }}
@@ -0,0 +1 @@
[The previous turn was interrupted by the user. The user's next request follows.]
@@ -0,0 +1 @@
[Interrupted by user]
@@ -0,0 +1,4 @@
[Notification]
{{ message }}
This is a notification, not a blocking request. If you are in the middle of a task, continue your current work and address this at a natural stopping point.
@@ -0,0 +1,7 @@
---
## Resident memory summary
The following is the current durable session/workspace summary. Treat it as background context; it is not a user request.
{{ summary }}
@@ -0,0 +1,12 @@
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
Default profile: {{ default_profile }}
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
Available registry profiles:
{{ available_profiles }}{% if profile_diagnostic %}
Profile discovery diagnostic: {{ profile_diagnostic }}{% endif %}
@@ -0,0 +1,5 @@
---
## Working boundaries
{{ scope_summary }}