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