prompt: derive guidance from features
This commit is contained in:
@@ -663,9 +663,13 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
if feature_config.workers.enabled {
|
||||
worker.register_worker_orchestration_instruction();
|
||||
}
|
||||
|
||||
{
|
||||
let workspace_client = worker.workspace_client().clone();
|
||||
let worker = worker.engine_mut();
|
||||
let engine = worker.engine_mut();
|
||||
|
||||
// Objective tools expose read-only project Objective context through the
|
||||
// Backend Workspace API. Workers must not guess local `.yoi/objectives`
|
||||
@@ -680,7 +684,7 @@ where
|
||||
workspace_id.clone(),
|
||||
base_url.clone(),
|
||||
) {
|
||||
worker.register_tool(definition);
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
@@ -718,7 +722,7 @@ where
|
||||
)
|
||||
};
|
||||
for definition in definitions {
|
||||
worker.register_tool(definition);
|
||||
engine.register_tool(definition);
|
||||
}
|
||||
} else {
|
||||
return Err(std::io::Error::new(
|
||||
@@ -755,7 +759,7 @@ where
|
||||
"worker spawn tools require local Worker filesystem authority",
|
||||
)
|
||||
})?;
|
||||
worker.register_tool(spawn_worker_tool(
|
||||
engine.register_tool(spawn_worker_tool(
|
||||
spawner_name.clone(),
|
||||
spawner_socket,
|
||||
runtime_base.clone(),
|
||||
@@ -767,9 +771,9 @@ where
|
||||
scope_handle,
|
||||
prompts,
|
||||
));
|
||||
worker.register_tool(send_to_worker_tool(spawned_registry.clone()));
|
||||
worker.register_tool(read_worker_output_tool(spawned_registry.clone()));
|
||||
worker.register_tool(stop_worker_tool(spawned_registry.clone()));
|
||||
engine.register_tool(send_to_worker_tool(spawned_registry.clone()));
|
||||
engine.register_tool(read_worker_output_tool(spawned_registry.clone()));
|
||||
engine.register_tool(stop_worker_tool(spawned_registry.clone()));
|
||||
let discovery = WorkerDiscovery::new(
|
||||
worker_metadata_store,
|
||||
spawner_name,
|
||||
@@ -777,9 +781,9 @@ where
|
||||
Some(spawner_cwd),
|
||||
spawned_registry,
|
||||
);
|
||||
worker.register_tool(list_workers_tool(discovery.clone()));
|
||||
worker.register_tool(restore_worker_tool(discovery.clone()));
|
||||
worker.register_tool(send_to_peer_worker_tool(discovery));
|
||||
engine.register_tool(list_workers_tool(discovery.clone()));
|
||||
engine.register_tool(restore_worker_tool(discovery.clone()));
|
||||
engine.register_tool(send_to_peer_worker_tool(discovery));
|
||||
}
|
||||
}
|
||||
let _feature_install_report = worker.install_features(feature_registry);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! [`crate::hook::HookRegistryBuilder`], and provider output is represented as
|
||||
//! ordinary feature reports/diagnostics instead of a separate authority layer.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -311,6 +311,83 @@ impl HookDeclaration {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct FeatureInstructionId(String);
|
||||
|
||||
impl FeatureInstructionId {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, FeatureInstallError> {
|
||||
let value = value.into();
|
||||
if value.trim().is_empty() {
|
||||
return Err(FeatureInstallError::InvalidDescriptor(
|
||||
"feature instruction id must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
pub fn builtin(slug: impl AsRef<str>) -> Self {
|
||||
Self(format!("builtin:{}", slug.as_ref()))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FeatureInstructionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FeatureInstructionOrder {
|
||||
OrchestrationPolicy,
|
||||
WorkflowPolicy,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureInstructionDeclaration {
|
||||
pub id: FeatureInstructionId,
|
||||
pub prompt_ref: String,
|
||||
pub order: FeatureInstructionOrder,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
impl FeatureInstructionDeclaration {
|
||||
pub fn new(
|
||||
id: FeatureInstructionId,
|
||||
prompt_ref: impl Into<String>,
|
||||
order: FeatureInstructionOrder,
|
||||
description: impl Into<String>,
|
||||
) -> Result<Self, FeatureInstallError> {
|
||||
let prompt_ref = prompt_ref.into();
|
||||
if prompt_ref.trim().is_empty() {
|
||||
return Err(FeatureInstallError::InvalidDescriptor(
|
||||
"feature instruction prompt_ref must not be empty".into(),
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
prompt_ref,
|
||||
order,
|
||||
description: description.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FeatureInstructionContribution {
|
||||
pub declaration: FeatureInstructionDeclaration,
|
||||
}
|
||||
|
||||
impl FeatureInstructionContribution {
|
||||
pub fn new(declaration: FeatureInstructionDeclaration) -> Self {
|
||||
Self { declaration }
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task lifecycle phase represented by this registry slice.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -485,6 +562,7 @@ pub struct FeatureDescriptor {
|
||||
pub description: String,
|
||||
pub tools: Vec<ToolDeclaration>,
|
||||
pub hooks: Vec<HookDeclaration>,
|
||||
pub instructions: Vec<FeatureInstructionDeclaration>,
|
||||
pub background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||
pub provides_services: Vec<ServiceDeclaration>,
|
||||
pub requires_services: Vec<ServiceRequirement>,
|
||||
@@ -501,6 +579,7 @@ impl FeatureDescriptor {
|
||||
description: String::new(),
|
||||
tools: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
instructions: Vec::new(),
|
||||
background_tasks: Vec::new(),
|
||||
provides_services: Vec::new(),
|
||||
requires_services: Vec::new(),
|
||||
@@ -523,6 +602,11 @@ impl FeatureDescriptor {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_instruction(mut self, instruction: FeatureInstructionDeclaration) -> Self {
|
||||
self.instructions.push(instruction);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_background_task(mut self, task: BackgroundTaskDeclaration) -> Self {
|
||||
self.background_tasks.push(task);
|
||||
self
|
||||
@@ -595,6 +679,7 @@ impl FeatureDiagnostic {
|
||||
pub enum FeatureContributionKind {
|
||||
Tool,
|
||||
Hook,
|
||||
Instruction,
|
||||
BackgroundTask,
|
||||
Service,
|
||||
ProtocolProvider,
|
||||
@@ -619,6 +704,7 @@ pub struct FeatureInstallReport {
|
||||
pub installed: bool,
|
||||
pub installed_tools: Vec<String>,
|
||||
pub installed_hooks: Vec<HookDeclaration>,
|
||||
pub installed_instructions: Vec<FeatureInstructionDeclaration>,
|
||||
pub declared_background_tasks: Vec<BackgroundTaskDeclaration>,
|
||||
pub provided_services: Vec<ServiceDeclaration>,
|
||||
pub resolved_service_requirements: Vec<ServiceRequirement>,
|
||||
@@ -635,6 +721,7 @@ impl FeatureInstallReport {
|
||||
installed: false,
|
||||
installed_tools: Vec::new(),
|
||||
installed_hooks: Vec::new(),
|
||||
installed_instructions: Vec::new(),
|
||||
declared_background_tasks: Vec::new(),
|
||||
provided_services: Vec::new(),
|
||||
resolved_service_requirements: Vec::new(),
|
||||
@@ -662,6 +749,7 @@ impl FeatureInstallReport {
|
||||
struct FeatureContributionDeclarations {
|
||||
tools: HashSet<String>,
|
||||
hooks: HashSet<(String, FeatureHookPoint)>,
|
||||
instructions: HashSet<FeatureInstructionId>,
|
||||
background_tasks: HashSet<String>,
|
||||
provided_services: HashSet<(ServiceId, String)>,
|
||||
protocol_providers: HashSet<ProviderId>,
|
||||
@@ -680,6 +768,11 @@ impl FeatureContributionDeclarations {
|
||||
.iter()
|
||||
.map(|hook| (hook.name.clone(), hook.point.clone()))
|
||||
.collect(),
|
||||
instructions: descriptor
|
||||
.instructions
|
||||
.iter()
|
||||
.map(|instruction| instruction.id.clone())
|
||||
.collect(),
|
||||
background_tasks: descriptor
|
||||
.background_tasks
|
||||
.iter()
|
||||
@@ -707,6 +800,10 @@ impl FeatureContributionDeclarations {
|
||||
.contains(&(declaration.name.clone(), declaration.point.clone()))
|
||||
}
|
||||
|
||||
fn contains_instruction(&self, declaration: &FeatureInstructionDeclaration) -> bool {
|
||||
self.instructions.contains(&declaration.id)
|
||||
}
|
||||
|
||||
fn contains_background_task(&self, declaration: &BackgroundTaskDeclaration) -> bool {
|
||||
self.background_tasks.contains(&declaration.name)
|
||||
}
|
||||
@@ -947,6 +1044,39 @@ impl HookContributionRegistrar<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt instruction registrar for mandatory feature guidance contributions.
|
||||
pub struct FeatureInstructionRegistrar<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
declarations: &'a FeatureContributionDeclarations,
|
||||
report: &'a mut FeatureInstallReport,
|
||||
}
|
||||
|
||||
impl FeatureInstructionRegistrar<'_> {
|
||||
pub fn register(
|
||||
&mut self,
|
||||
contribution: FeatureInstructionContribution,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
let declaration = contribution.declaration;
|
||||
if !self.declarations.contains_instruction(&declaration) {
|
||||
return Err(reject_undeclared_contribution(
|
||||
self.feature_id,
|
||||
self.report,
|
||||
FeatureContributionKind::Instruction,
|
||||
declaration.id.to_string(),
|
||||
));
|
||||
}
|
||||
if !self
|
||||
.report
|
||||
.installed_instructions
|
||||
.iter()
|
||||
.any(|instruction| instruction.id == declaration.id)
|
||||
{
|
||||
self.report.installed_instructions.push(declaration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task registrar for descriptor/report-only contributions.
|
||||
pub struct BackgroundTaskRegistrar<'a> {
|
||||
feature_id: &'a FeatureId,
|
||||
@@ -1184,6 +1314,14 @@ impl FeatureInstallContext<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instructions(&mut self) -> FeatureInstructionRegistrar<'_> {
|
||||
FeatureInstructionRegistrar {
|
||||
feature_id: self.feature_id,
|
||||
declarations: self.declarations,
|
||||
report: self.report,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn background_tasks(&mut self) -> BackgroundTaskRegistrar<'_> {
|
||||
BackgroundTaskRegistrar {
|
||||
feature_id: self.feature_id,
|
||||
@@ -1245,6 +1383,26 @@ impl FeatureRegistryInstallReport {
|
||||
.flat_map(|report| report.installed_tools.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn installed_instruction_contributions(&self) -> Vec<FeatureInstructionDeclaration> {
|
||||
dedupe_instruction_contributions(
|
||||
self.reports
|
||||
.iter()
|
||||
.flat_map(|report| report.installed_instructions.iter().cloned()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dedupe_instruction_contributions(
|
||||
instructions: impl IntoIterator<Item = FeatureInstructionDeclaration>,
|
||||
) -> Vec<FeatureInstructionDeclaration> {
|
||||
let mut by_id = BTreeMap::new();
|
||||
for instruction in instructions {
|
||||
by_id.entry(instruction.id.clone()).or_insert(instruction);
|
||||
}
|
||||
let mut instructions = by_id.into_values().collect::<Vec<_>>();
|
||||
instructions.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.id.cmp(&b.id)));
|
||||
instructions
|
||||
}
|
||||
|
||||
/// Builder/installer for enabled feature modules.
|
||||
@@ -1550,6 +1708,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
struct InstructionFeature {
|
||||
descriptor: FeatureDescriptor,
|
||||
instruction: FeatureInstructionDeclaration,
|
||||
}
|
||||
|
||||
impl FeatureModule for InstructionFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
self.descriptor.clone()
|
||||
}
|
||||
|
||||
fn install(
|
||||
&self,
|
||||
context: &mut FeatureInstallContext<'_>,
|
||||
) -> Result<(), FeatureInstallError> {
|
||||
context
|
||||
.instructions()
|
||||
.register(FeatureInstructionContribution::new(
|
||||
self.instruction.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn instruction(
|
||||
id: &'static str,
|
||||
prompt_ref: &'static str,
|
||||
order: FeatureInstructionOrder,
|
||||
) -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(id),
|
||||
prompt_ref,
|
||||
order,
|
||||
"test instruction",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_contributions_are_recorded() {
|
||||
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
|
||||
@@ -1576,6 +1770,58 @@ mod tests {
|
||||
assert_eq!(feature_report.declared_background_tasks[0].name, "daily");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instruction_contributions_are_deduped_and_sorted() {
|
||||
let workflow = instruction(
|
||||
"workflow",
|
||||
"$yoi/common/tickets",
|
||||
FeatureInstructionOrder::WorkflowPolicy,
|
||||
);
|
||||
let orchestration = instruction(
|
||||
"orchestration",
|
||||
"$yoi/common/worker-orchestration",
|
||||
FeatureInstructionOrder::OrchestrationPolicy,
|
||||
);
|
||||
let contributions = dedupe_instruction_contributions([
|
||||
workflow.clone(),
|
||||
orchestration.clone(),
|
||||
workflow.clone(),
|
||||
]);
|
||||
|
||||
assert_eq!(contributions, vec![orchestration, workflow]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undeclared_instruction_contribution_is_rejected() {
|
||||
let declared = instruction(
|
||||
"declared",
|
||||
"$yoi/common/tickets",
|
||||
FeatureInstructionOrder::WorkflowPolicy,
|
||||
);
|
||||
let undeclared = instruction(
|
||||
"undeclared",
|
||||
"$yoi/common/tickets",
|
||||
FeatureInstructionOrder::WorkflowPolicy,
|
||||
);
|
||||
let descriptor =
|
||||
FeatureDescriptor::builtin("instruction", "Instruction").with_instruction(declared);
|
||||
let mut hook_builder = HookRegistryBuilder::default();
|
||||
let mut pending_tools = Vec::new();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(InstructionFeature {
|
||||
descriptor,
|
||||
instruction: undeclared,
|
||||
})
|
||||
.install_into_pending(&mut pending_tools, &mut hook_builder);
|
||||
|
||||
assert!(!report.reports[0].installed);
|
||||
assert!(report.reports[0].diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic
|
||||
.message
|
||||
.contains("undeclared Instruction contribution")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tool_names_are_rejected() {
|
||||
let descriptor_a = FeatureDescriptor::builtin("a", "A")
|
||||
|
||||
@@ -19,13 +19,26 @@ use ticket::{
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, ToolContribution, ToolDeclaration,
|
||||
FeatureInstructionContribution, FeatureInstructionDeclaration, FeatureInstructionId,
|
||||
FeatureInstructionOrder, FeatureModule, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
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";
|
||||
|
||||
fn ticket_workflow_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(TICKET_WORKFLOW_INSTRUCTION_ID),
|
||||
TICKET_WORKFLOW_PROMPT_REF,
|
||||
FeatureInstructionOrder::WorkflowPolicy,
|
||||
"Typed Ticket workflow guidance",
|
||||
)
|
||||
.expect("static Ticket workflow instruction declaration is valid")
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TicketFeatureAccess {
|
||||
@@ -326,7 +339,8 @@ impl TicketFeature {
|
||||
impl FeatureModule for TicketFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
.with_description(FEATURE_DESCRIPTION)
|
||||
.with_instruction(ticket_workflow_instruction());
|
||||
let enabled_tool_names = self.enabled_tool_names();
|
||||
for name in enabled_tool_names {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
@@ -349,6 +363,11 @@ impl FeatureModule for TicketFeature {
|
||||
let Some(backend) = self.tool_backend(context) else {
|
||||
return Ok(());
|
||||
};
|
||||
context
|
||||
.instructions()
|
||||
.register(FeatureInstructionContribution::new(
|
||||
ticket_workflow_instruction(),
|
||||
))?;
|
||||
let allowed_tool_names = self.enabled_tool_names();
|
||||
let mut tools = context.tools();
|
||||
for definition in ticket_tools(backend) {
|
||||
|
||||
@@ -669,6 +669,7 @@ impl FeatureModule for PluginToolFeature {
|
||||
}),
|
||||
tools: Vec::new(),
|
||||
hooks: Vec::new(),
|
||||
instructions: Vec::new(),
|
||||
background_tasks: Vec::new(),
|
||||
provides_services: Vec::new(),
|
||||
requires_services: Vec::new(),
|
||||
|
||||
@@ -25,6 +25,7 @@ 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};
|
||||
|
||||
@@ -120,11 +121,12 @@ impl SystemPromptTemplate {
|
||||
.map_err(|e| SystemPromptError::Render(e.to_string()))?;
|
||||
append_trailing_section(
|
||||
&body,
|
||||
&self.env,
|
||||
ctx,
|
||||
ctx.prompts,
|
||||
ctx.scope,
|
||||
ctx.agents_md.as_deref(),
|
||||
ctx.resident_summary,
|
||||
ToolCapabilities::from_tool_names(&ctx.tool_names),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +151,7 @@ pub struct SystemPromptContext<'a> {
|
||||
pub language: &'a str,
|
||||
pub scope: &'a Scope,
|
||||
pub tool_names: Vec<String>,
|
||||
pub feature_instructions: &'a [FeatureInstructionDeclaration],
|
||||
/// Project-level instructions read from the nearest `AGENTS.md`.
|
||||
/// Not visible from the template; consumed by the trailing-section
|
||||
/// formatter in [`SystemPromptTemplate::render`].
|
||||
@@ -209,7 +212,6 @@ struct ToolCapabilities {
|
||||
worker_stop: bool,
|
||||
worker_list: bool,
|
||||
worker_restore: bool,
|
||||
ticket_any: bool,
|
||||
}
|
||||
|
||||
impl ToolCapabilities {
|
||||
@@ -226,7 +228,6 @@ impl ToolCapabilities {
|
||||
"StopWorker" => capabilities.worker_stop = true,
|
||||
"ListWorkers" => capabilities.worker_list = true,
|
||||
"RestoreWorker" => capabilities.worker_restore = true,
|
||||
name if name.starts_with("Ticket") => capabilities.ticket_any = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +270,6 @@ impl ToolCapabilities {
|
||||
);
|
||||
map.insert("memory_mutation", Value::from(self.memory_mutation()));
|
||||
map.insert("worker_management", Value::from(self.worker_management()));
|
||||
map.insert("ticket_any", Value::from(self.ticket_any));
|
||||
Value::from(map)
|
||||
}
|
||||
}
|
||||
@@ -282,11 +282,12 @@ impl ToolCapabilities {
|
||||
/// per-pack without touching this function.
|
||||
fn append_trailing_section(
|
||||
body: &str,
|
||||
env: &Environment<'static>,
|
||||
ctx: &SystemPromptContext<'_>,
|
||||
prompts: &PromptCatalog,
|
||||
scope: &Scope,
|
||||
agents_md: Option<&str>,
|
||||
resident_summary: Option<&str>,
|
||||
tool_capabilities: ToolCapabilities,
|
||||
) -> Result<String, SystemPromptError> {
|
||||
let mut out = String::with_capacity(body.len() + 256);
|
||||
out.push_str(body);
|
||||
@@ -313,11 +314,19 @@ fn append_trailing_section(
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if tool_capabilities.worker_management() {
|
||||
out.push('\n');
|
||||
let section = prompts.worker_orchestration_guidance_section()?;
|
||||
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
|
||||
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 section = section.trim_end_matches(&['\n', ' '][..]);
|
||||
if !section.trim().is_empty() {
|
||||
out.push_str(section);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
// Canonicalise the tail so the emitted prompt has a single form
|
||||
// regardless of how individual templates chose to end.
|
||||
@@ -369,6 +378,7 @@ mod tests {
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: tools,
|
||||
feature_instructions: &[],
|
||||
agents_md,
|
||||
resident_summary: None,
|
||||
prompts: test_prompts(),
|
||||
@@ -386,25 +396,13 @@ mod tests {
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: Vec::new(),
|
||||
feature_instructions: &[],
|
||||
agents_md: None,
|
||||
resident_summary: summary,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx_with_resident<'a>(cwd: &'a Path, scope: &'a Scope) -> SystemPromptContext<'a> {
|
||||
SystemPromptContext {
|
||||
now: fixed_now(),
|
||||
cwd: cwd.display().to_string().into(),
|
||||
language: manifest::defaults::WORKER_LANGUAGE,
|
||||
scope,
|
||||
tool_names: Vec::new(),
|
||||
agents_md: None,
|
||||
resident_summary: None,
|
||||
prompts: test_prompts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_tool_names() -> Vec<String> {
|
||||
["MemoryQuery", "MemoryReadDocument", "MemoryUpdateDocument"]
|
||||
.into_iter()
|
||||
@@ -412,25 +410,24 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn worker_management_tool_names() -> Vec<String> {
|
||||
[
|
||||
"SpawnWorker",
|
||||
"SendToWorker",
|
||||
"ReadWorkerOutput",
|
||||
"StopWorker",
|
||||
"ListWorkers",
|
||||
"RestoreWorker",
|
||||
]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
fn ticket_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("ticket.workflow"),
|
||||
"$yoi/common/tickets",
|
||||
crate::feature::FeatureInstructionOrder::WorkflowPolicy,
|
||||
"Ticket workflow guidance",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn ticket_tool_names() -> Vec<String> {
|
||||
["TicketList", "TicketShow", "TicketComment"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
crate::feature::FeatureInstructionId::builtin("worker.orchestration"),
|
||||
"$yoi/common/worker-orchestration",
|
||||
crate::feature::FeatureInstructionOrder::OrchestrationPolicy,
|
||||
"Worker orchestration guidance",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Lazily-initialised builtin catalog shared across system-prompt
|
||||
@@ -499,14 +496,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_guidance_is_included_for_typed_ticket_tools() {
|
||||
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 rendered = tmpl
|
||||
.render(&ctx(dir.path(), &scope, ticket_tool_names(), None))
|
||||
.unwrap();
|
||||
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"));
|
||||
@@ -514,6 +512,21 @@ mod tests {
|
||||
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();
|
||||
@@ -534,17 +547,18 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_role_instructions_include_common_ticket_guidance() {
|
||||
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 rendered = tmpl
|
||||
.render(&ctx(dir.path(), &scope, ticket_tool_names(), None))
|
||||
.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}");
|
||||
assert!(
|
||||
@@ -578,19 +592,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_orchestration_guidance_is_included_for_worker_management_tools() {
|
||||
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 rendered = tmpl
|
||||
.render(&ctx(
|
||||
dir.path(),
|
||||
&scope,
|
||||
worker_management_tool_names(),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let instructions = [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("## Worker orchestration"));
|
||||
assert!(rendered.contains("spawned Worker notifications are background signals"));
|
||||
|
||||
@@ -34,7 +34,10 @@ use crate::feature::builtin::memory::WorkspaceMemoryBackendError;
|
||||
use crate::feature::builtin::{
|
||||
SessionExploreFeature, SessionExploreState, TaskFeature, render_extract_input,
|
||||
};
|
||||
use crate::feature::{FeatureRegistryBuilder, FeatureRegistryInstallReport};
|
||||
use crate::feature::{
|
||||
FeatureInstructionDeclaration, FeatureInstructionId, FeatureInstructionOrder,
|
||||
FeatureRegistryBuilder, FeatureRegistryInstallReport, dedupe_instruction_contributions,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookRegistryBuilder, OnAbort, OnPromptSubmit, OnTurnEnd, PostToolCall, PreLlmRequest,
|
||||
PreToolCall,
|
||||
@@ -44,6 +47,18 @@ use crate::internal_worker::{InternalWorkerSpec, run_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";
|
||||
|
||||
fn worker_orchestration_instruction() -> FeatureInstructionDeclaration {
|
||||
FeatureInstructionDeclaration::new(
|
||||
FeatureInstructionId::builtin(WORKER_ORCHESTRATION_INSTRUCTION_ID),
|
||||
WORKER_ORCHESTRATION_PROMPT_REF,
|
||||
FeatureInstructionOrder::OrchestrationPolicy,
|
||||
"Worker orchestration guidance",
|
||||
)
|
||||
.expect("static Worker orchestration instruction declaration is valid")
|
||||
}
|
||||
use crate::ipc::alerter::Alerter;
|
||||
use crate::ipc::interceptor::WorkerInterceptor;
|
||||
use crate::ipc::notify_buffer::NotifyBuffer;
|
||||
@@ -459,6 +474,10 @@ pub struct Worker<C: LlmClient, St: Store> {
|
||||
/// `Some` until `ensure_system_prompt_materialized` renders it once,
|
||||
/// then `None` forever — including after compaction.
|
||||
system_prompt_template: Option<SystemPromptTemplate>,
|
||||
/// Mandatory prompt sections contributed by enabled Worker features.
|
||||
/// These are appended by Rust-owned prompt assembly so authored top-level
|
||||
/// templates cannot accidentally omit feature workflow guidance.
|
||||
feature_instructions: Vec<FeatureInstructionDeclaration>,
|
||||
/// User-facing notification sink attached by the Controller at
|
||||
/// spawn time. `None` in tests / direct `Worker::new` usage.
|
||||
alerter: Option<Alerter>,
|
||||
@@ -604,6 +623,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
||||
tracker: None,
|
||||
task_feature: self.task_feature.clone(),
|
||||
system_prompt_template: None,
|
||||
feature_instructions: self.feature_instructions.clone(),
|
||||
alerter: self.alerter.clone(),
|
||||
event_tx: self.event_tx.clone(),
|
||||
in_flight: self.in_flight.clone(),
|
||||
@@ -797,6 +817,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: None,
|
||||
feature_instructions: Vec::new(),
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
in_flight: None,
|
||||
@@ -830,6 +851,16 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
self.system_prompt_template = Some(template);
|
||||
}
|
||||
|
||||
pub fn register_feature_instruction(&mut self, instruction: FeatureInstructionDeclaration) {
|
||||
let mut instructions = self.feature_instructions.clone();
|
||||
instructions.push(instruction);
|
||||
self.feature_instructions = dedupe_instruction_contributions(instructions);
|
||||
}
|
||||
|
||||
pub fn register_worker_orchestration_instruction(&mut self) {
|
||||
self.register_feature_instruction(worker_orchestration_instruction());
|
||||
}
|
||||
|
||||
/// Toggle all resident sections in the system prompt.
|
||||
///
|
||||
/// Default `true`: normal Workers may expose each resident section according
|
||||
@@ -1022,7 +1053,11 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
registry: FeatureRegistryBuilder,
|
||||
) -> FeatureRegistryInstallReport {
|
||||
let worker = self.engine.as_mut().expect("worker taken during run");
|
||||
registry.install_into_engine(worker, &mut self.hook_builder)
|
||||
let report = registry.install_into_engine(worker, &mut self.hook_builder);
|
||||
for instruction in report.installed_instruction_contributions() {
|
||||
self.register_feature_instruction(instruction);
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Reference to the store.
|
||||
@@ -1528,6 +1563,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
|
||||
language: worker_language,
|
||||
scope: &scope_snapshot,
|
||||
tool_names,
|
||||
feature_instructions: &self.feature_instructions,
|
||||
agents_md: agents_md_read.and_then(|read| read.body),
|
||||
resident_summary: resident_summary.as_deref(),
|
||||
prompts: &self.prompts,
|
||||
@@ -3608,6 +3644,7 @@ where
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
in_flight: None,
|
||||
@@ -3713,6 +3750,7 @@ where
|
||||
tracker: None,
|
||||
task_feature: TaskFeature::new(),
|
||||
system_prompt_template: common.system_prompt_template,
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
in_flight: None,
|
||||
@@ -4003,6 +4041,7 @@ where
|
||||
// Restore replays the saved system_prompt verbatim — no
|
||||
// template re-render on resume.
|
||||
system_prompt_template: None,
|
||||
feature_instructions: common.feature_instructions,
|
||||
alerter: None,
|
||||
event_tx: None,
|
||||
in_flight: None,
|
||||
@@ -4666,6 +4705,7 @@ struct WorkerCommon {
|
||||
client: Box<dyn LlmClient>,
|
||||
prompts: Arc<PromptCatalog>,
|
||||
system_prompt_template: Option<SystemPromptTemplate>,
|
||||
feature_instructions: Vec<FeatureInstructionDeclaration>,
|
||||
}
|
||||
|
||||
async fn restored_child_reachable(child: &WorkerSpawnedChild) -> bool {
|
||||
@@ -4823,6 +4863,7 @@ fn prepare_worker_common_from_scope(
|
||||
client,
|
||||
prompts,
|
||||
system_prompt_template,
|
||||
feature_instructions: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user