use crate::command::{
CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
};
use crate::events::TokenUsage;
use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
use crate::message::Message;
use crate::message_filter::MessageFilterProvider;
use crate::runtime_agent::RuntimeAgent;
use crate::tool_types::{ToolCall, ToolDefinition};
use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
use crate::typed_id::SessionId;
use crate::{session_files::SessionFileSystem, tool_context::ToolContext};
use async_trait::async_trait;
use everruns_capability::is_plugin_capability;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
pub struct IntegrationPlugin {
pub experimental_only: bool,
pub feature_flag: Option<&'static str>,
pub factory: fn() -> Box<dyn Capability>,
}
inventory::collect!(IntegrationPlugin);
pub use crate::capability_types::{
CapabilityStatus, MountAccess, MountDirectoryBuilder, MountEntry, MountPoint, MountSource,
};
use everruns_capability::{CapabilityId, CapabilityRef as AgentCapabilityConfig};
mod declarative;
pub mod facts;
pub mod skill_contribution;
pub mod util;
pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
pub const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
pub const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
pub use declarative::{
DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
declarative_capability_info, hydrate_declarative_capability_config,
hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
plugin_capability_info, validate_declarative_capability_definition,
};
pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
pub use skill_contribution::{
MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
reconstruct_skill_md, skill_capability_id,
};
pub struct SystemPromptContext {
pub session_id: SessionId,
pub locale: Option<String>,
pub file_store: Option<Arc<dyn SessionFileSystem>>,
pub model: Option<String>,
}
impl SystemPromptContext {
pub fn without_file_store(session_id: SessionId) -> Self {
Self {
session_id,
locale: None,
file_store: None,
model: None,
}
}
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = Some(model.into());
self
}
}
#[derive(Debug, Clone)]
pub struct CapabilityLocalization {
pub locale: &'static str,
pub name: Option<&'static str>,
pub description: Option<&'static str>,
pub config_description: Option<&'static str>,
pub config_overlay: Option<serde_json::Value>,
}
impl CapabilityLocalization {
pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
Self {
locale,
name: Some(name),
description: Some(description),
config_description: None,
config_overlay: None,
}
}
}
pub fn resolve_localized_field<T>(
localizations: &[CapabilityLocalization],
locale: Option<&str>,
field: impl Fn(&CapabilityLocalization) -> Option<T>,
) -> Option<T> {
let mut candidates: Vec<String> = Vec::new();
if let Some(raw) = locale {
let normalized = raw.trim().replace('_', "-").to_lowercase();
if !normalized.is_empty() {
if let Some((language, _)) = normalized.split_once('-') {
let language = language.to_string();
candidates.push(normalized);
candidates.push(language);
} else {
candidates.push(normalized);
}
}
}
candidates.push("en".to_string());
for candidate in candidates {
let hit = localizations
.iter()
.find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
.and_then(&field);
if hit.is_some() {
return hit;
}
}
None
}
#[async_trait]
pub trait Capability: Send + Sync {
fn id(&self) -> &str;
fn aliases(&self) -> Vec<&'static str> {
vec![]
}
fn name(&self) -> &str;
fn description(&self) -> &str;
fn localizations(&self) -> Vec<CapabilityLocalization> {
vec![]
}
fn localized_name(&self, locale: Option<&str>) -> String {
resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
.unwrap_or_else(|| self.name())
.to_string()
}
fn localized_description(&self, locale: Option<&str>) -> String {
resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
.unwrap_or_else(|| self.description())
.to_string()
}
fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
resolve_localized_field(&self.localizations(), locale, |entry| {
entry.config_description
})
.map(str::to_string)
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn icon(&self) -> Option<&str> {
None
}
fn category(&self) -> Option<&str> {
None
}
fn metadata(&self) -> Option<serde_json::Value> {
None
}
fn is_guardrail(&self) -> bool {
false
}
fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
None
}
fn system_prompt_addition(&self) -> Option<&str> {
None
}
async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
self.system_prompt_addition().map(|addition| {
format!(
"<capability id=\"{}\">\n{}\n</capability>",
self.id(),
addition
)
})
}
fn system_prompt_preview(&self) -> Option<String> {
self.system_prompt_addition().map(|s| s.to_string())
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![]
}
fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
self.tools()
}
fn delegation_target_with_config(
&self,
_config: &serde_json::Value,
) -> Option<DelegationTargetProvider> {
None
}
fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
false
}
async fn system_prompt_contribution_with_config(
&self,
ctx: &SystemPromptContext,
_config: &serde_json::Value,
) -> Option<String> {
self.system_prompt_contribution(ctx).await
}
fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools().iter().map(|t| t.to_definition()).collect()
}
fn mounts(&self) -> Vec<MountPoint> {
vec![]
}
fn dependencies(&self) -> Vec<&'static str> {
vec![]
}
fn features(&self) -> Vec<&'static str> {
vec![]
}
fn config_schema(&self) -> Option<serde_json::Value> {
None
}
fn config_ui_schema(&self) -> Option<serde_json::Value> {
None
}
fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
Ok(())
}
fn mcp_servers(&self) -> ScopedMcpServers {
ScopedMcpServers::default()
}
fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
self.mcp_servers()
}
fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
None
}
fn message_filter_config(
&self,
config: &serde_json::Value,
_compaction_enabled: bool,
) -> serde_json::Value {
config.clone()
}
fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
None
}
fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
None
}
fn tool_search_config(
&self,
_config: &serde_json::Value,
) -> Option<crate::driver_registry::ToolSearchConfig> {
None
}
fn prompt_cache_config(
&self,
_config: &serde_json::Value,
) -> Option<crate::driver_registry::PromptCacheConfig> {
None
}
fn openrouter_routing_config(
&self,
_config: &serde_json::Value,
) -> Option<crate::driver_registry::OpenRouterRoutingConfig> {
None
}
fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
None
}
fn error_disclosure(
&self,
_config: &serde_json::Value,
) -> Option<crate::user_facing_error::ErrorDisclosure> {
None
}
fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
text
}
fn compaction_policy(
&self,
_config: &serde_json::Value,
) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
None
}
fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
vec![]
}
fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
vec![]
}
fn pre_tool_use_hooks_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
self.pre_tool_use_hooks()
}
fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
vec![]
}
fn post_tool_exec_hooks_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
self.post_tool_exec_hooks()
}
fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
vec![]
}
fn tool_definition_hooks_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<Arc<dyn ToolDefinitionHook>> {
self.tool_definition_hooks()
}
fn tool_definition_hooks_with_context(
&self,
_ctx: &SystemPromptContext,
config: &serde_json::Value,
) -> Vec<Arc<dyn ToolDefinitionHook>> {
self.tool_definition_hooks_with_config(config)
}
fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
vec![]
}
fn finalized_tool_calls_hook(
&self,
_config: &serde_json::Value,
) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
None
}
fn narrate(
&self,
_tool_def: Option<&ToolDefinition>,
tool_call: &ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
self.tools()
.iter()
.find(|tool| tool.name() == tool_call.name)
.and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
}
fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
vec![]
}
fn user_hooks_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<crate::user_hook_types::UserHookSpec> {
self.user_hooks()
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::Low
}
fn commands(&self) -> Vec<CommandDescriptor> {
vec![]
}
async fn execute_command(
&self,
request: &ExecuteCommandRequest,
_ctx: &CommandExecutionContext,
) -> crate::error::Result<CommandResult> {
Err(crate::error::AgentLoopError::config(format!(
"capability {} declared command /{} but does not implement execute_command",
self.id(),
request.name,
)))
}
fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
vec![]
}
fn contribute_skills(&self) -> Vec<SkillContribution> {
vec![]
}
fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
vec![]
}
fn post_output_guardrails_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
vec![]
}
fn post_output_annotation_hooks_with_config(
&self,
_config: &serde_json::Value,
) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
vec![]
}
fn citation_verifier_with_config(
&self,
_config: &serde_json::Value,
) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
None
}
}
pub trait ToolDefinitionHook: Send + Sync {
fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
fn applies_with_native_tool_search(&self) -> bool {
true
}
}
pub trait ToolCallHook: Send + Sync {
fn narration(
&self,
_tool_def: Option<&ToolDefinition>,
_tool_call: &ToolCall,
_phase: crate::tool_narration::ToolNarrationPhase,
_locale: Option<&str>,
_ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
None
}
fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
tool_call
}
}
pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
impl ToolCallHook for CapabilityNarrationHook {
fn narration(
&self,
tool_def: Option<&ToolDefinition>,
tool_call: &ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
self.0.narrate(tool_def, tool_call, phase, locale, ctx)
}
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[cfg_attr(feature = "openapi", schema(example = "low"))]
#[serde(rename_all = "lowercase")]
pub enum RiskLevel {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlueprintModel {
Fixed(String),
Default(String),
Inherit,
}
pub struct AgentBlueprint {
pub id: &'static str,
pub name: &'static str,
pub description: &'static str,
pub model: BlueprintModel,
pub system_prompt: &'static str,
pub tools: Vec<Box<dyn Tool>>,
pub max_turns: Option<usize>,
pub config_schema: Option<serde_json::Value>,
}
impl AgentBlueprint {
pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
self.tools.iter().map(|t| t.to_definition()).collect()
}
}
impl std::fmt::Debug for AgentBlueprint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentBlueprint")
.field("id", &self.id)
.field("name", &self.name)
.field("model", &self.model)
.field("tool_count", &self.tools.len())
.field("max_turns", &self.max_turns)
.finish()
}
}
#[derive(Clone)]
pub struct CapabilityRegistry {
capabilities: HashMap<String, Arc<dyn Capability>>,
index: everruns_capability::CapabilityIdIndex,
}
impl CapabilityRegistry {
pub fn new() -> Self {
Self {
capabilities: HashMap::new(),
index: everruns_capability::CapabilityIdIndex::new(),
}
}
pub fn register(&mut self, capability: impl Capability + 'static) {
self.register_arc(Arc::new(capability));
}
pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
self.register_arc(Arc::from(capability));
}
pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
let canonical = capability.id().to_string();
self.index
.insert_or_replace(canonical.clone(), &capability.aliases());
self.capabilities.insert(canonical, capability);
}
pub fn try_register_arc(
&mut self,
capability: Arc<dyn Capability>,
) -> Result<(), everruns_capability::CapabilityError> {
let canonical = capability.id().to_string();
self.index
.insert(canonical.clone(), &capability.aliases())?;
self.capabilities.insert(canonical, capability);
Ok(())
}
pub fn register_inventory_plugins(
&mut self,
mut include: impl FnMut(&IntegrationPlugin) -> bool,
) {
for plugin in inventory::iter::<IntegrationPlugin>() {
if include(plugin) {
self.register_boxed((plugin.factory)());
}
}
}
pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
self.capabilities.get(self.index.canonical_of(id)?)
}
pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
self.index.canonical_of(id)
}
pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
let canonical = self.index.remove(id)?;
self.capabilities.remove(&canonical)
}
pub fn has(&self, id: &str) -> bool {
self.get(id).is_some()
}
pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
self.capabilities.values().collect()
}
pub fn len(&self) -> usize {
self.capabilities.len()
}
pub fn is_empty(&self) -> bool {
self.capabilities.is_empty()
}
pub fn builder() -> CapabilityRegistryBuilder {
CapabilityRegistryBuilder::new()
}
pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
for cap in self.capabilities.values() {
for bp in cap.agent_blueprints() {
if bp.id == id {
return Some(bp);
}
}
}
None
}
pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
for (capability_id, cap) in &self.capabilities {
for bp in cap.agent_blueprints() {
if bp.id == id {
return Some((capability_id.clone(), bp));
}
}
}
None
}
pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
self.capabilities
.values()
.flat_map(|cap| cap.agent_blueprints())
.collect()
}
}
impl Default for CapabilityRegistry {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for CapabilityRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ids: Vec<_> = self.capabilities.keys().collect();
f.debug_struct("CapabilityRegistry")
.field("capabilities", &ids)
.finish()
}
}
pub struct CapabilityRegistryBuilder {
registry: CapabilityRegistry,
}
impl CapabilityRegistryBuilder {
pub fn new() -> Self {
Self {
registry: CapabilityRegistry::new(),
}
}
pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
self.registry.register(capability);
self
}
pub fn build(self) -> CapabilityRegistry {
self.registry
}
}
impl Default for CapabilityRegistryBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct ModelViewContext<'a> {
pub session_id: SessionId,
pub prior_usage: Option<&'a TokenUsage>,
}
pub trait ModelViewProvider: Send + Sync {
fn apply_model_view(
&self,
messages: Vec<Message>,
config: &serde_json::Value,
context: &ModelViewContext<'_>,
) -> Vec<Message>;
fn priority(&self) -> i32 {
0
}
}
pub struct CollectedCapabilities {
pub system_prompt_parts: Vec<String>,
pub system_prompt_attributions: Vec<SystemPromptAttribution>,
pub tools: Vec<Box<dyn Tool>>,
pub tool_definitions: Vec<ToolDefinition>,
pub mounts: Vec<MountPoint>,
pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
pub applied_ids: Vec<String>,
pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
pub parallel_tool_calls: Option<bool>,
pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
pub mcp_servers: ScopedMcpServers,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SystemPromptAttribution {
pub capability_id: String,
pub content: String,
}
impl CollectedCapabilities {
pub fn system_prompt_prefix(&self) -> Option<String> {
if self.system_prompt_parts.is_empty() {
None
} else {
Some(self.system_prompt_parts.join("\n\n"))
}
}
pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
for (provider, config) in &self.message_filter_providers {
provider.apply_filters(query, config);
}
}
pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
for (provider, config) in &self.message_filter_providers {
provider.post_load(messages, config);
}
}
pub fn has_message_filters(&self) -> bool {
!self.message_filter_providers.is_empty()
}
}
pub struct DelegationTargetProvider {
pub target_type: &'static str,
pub tool: Box<dyn Tool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SpawnMode {
Background,
Foreground,
}
impl SpawnMode {
pub fn parse(value: &str) -> Option<Self> {
match value {
"background" => Some(Self::Background),
"foreground" => Some(Self::Foreground),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Background => "background",
Self::Foreground => "foreground",
}
}
}
struct UnifiedSpawnAgentTool {
providers: Vec<DelegationTargetProvider>,
}
fn validate_spawn_agent_target_fields(
arguments: &serde_json::Value,
target_type: &str,
) -> Result<(), String> {
for field in ["blueprint", "config"] {
if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
return Err(format!(
"{field} is only valid for subagent targets, not {target_type}."
));
}
}
Ok(())
}
impl UnifiedSpawnAgentTool {
fn new(providers: Vec<DelegationTargetProvider>) -> Self {
Self { providers }
}
fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
self.providers
.iter()
.find(|provider| provider.target_type == target_type)
.map(|provider| provider.tool.as_ref())
}
fn target_types(&self) -> Vec<&'static str> {
["subagent", "agent", "external_a2a"]
.into_iter()
.filter(|target_type| {
self.providers
.iter()
.any(|provider| provider.target_type == *target_type)
})
.collect()
}
fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
self.target_types()
.into_iter()
.filter_map(|target_type| match target_type {
"subagent" => Some(serde_json::json!({
"properties": {
"type": {"const": "subagent"}
}
})),
"agent" => Some(serde_json::json!({
"properties": {
"type": {"const": "agent"}
},
"required": ["type", "id"]
})),
"external_a2a" => Some(serde_json::json!({
"properties": {
"type": {"const": "external_a2a"}
},
"anyOf": [
{"required": ["id"]},
{"required": ["external_agent_id"]}
]
})),
_ => None,
})
.collect()
}
}
#[async_trait]
impl Tool for UnifiedSpawnAgentTool {
fn narrate(
&self,
tool_call: &ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
let from_provider = tool_call
.arguments
.get("target")
.and_then(|target| target.get("type"))
.and_then(serde_json::Value::as_str)
.and_then(|target_type| self.provider_for(target_type))
.and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
Some(from_provider.unwrap_or_else(|| {
crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
}))
}
fn name(&self) -> &str {
"spawn_agent"
}
fn display_name(&self) -> Option<&str> {
Some("Spawn Agent")
}
fn description(&self) -> &str {
"Delegate work to another agent target. Set target.type to one of the advertised target types; background returns a task_id for generic task tools, and foreground waits for the result."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
},
"instructions": {
"type": "string",
"description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
},
"goal": {
"type": "string",
"description": "Optional objective stored on the spawned session and made visible at system-prompt level."
},
"lifetime": {
"type": "string",
"enum": ["linked", "detached"],
"default": "linked",
"description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
},
"seed": {
"type": "string",
"enum": ["fresh", "fork", "workspace"],
"default": "fresh",
"description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
},
"target": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": self.target_types(),
"description": "Delegation target type. Use subagent for same-agent child sessions, agent for configured first-party handoffs, or external_a2a for configured remote A2A agents."
},
"id": {
"type": "string",
"description": "Configured target id for first-party handoffs or external A2A agents."
},
"external_agent_id": {
"type": "string",
"description": "Configured external A2A agent id."
}
},
"required": ["type"],
"oneOf": self.target_constraint_branches(),
"additionalProperties": false
},
"mode": {
"type": "string",
"enum": ["background", "foreground"],
"description": "Execution mode. Use background to return immediately with a task_id, or foreground to block until the delegated work reaches a terminal state or timeout."
},
"blueprint": {
"type": "string",
"description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
},
"config": {
"type": "object",
"description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
},
"result_schema": {
"type": "object",
"description": "JSON Schema for a required final structured result. Local child agents must call report_result; external A2A agents must return a structured data artifact."
},
"message_schema": {
"type": "object",
"description": "JSON Schema for structured progress messages from local child agents. When set, the child receives report_task_progress. External A2A targets reject this option explicitly."
},
"public_context": {
"type": "object",
"description": "Agent-handoff-only non-secret structured context to include with the instructions."
},
"wait_timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": 86400,
"description": "External-A2A-only foreground timeout."
},
"wake_on_completion": {
"type": "boolean",
"description": "External-A2A-only control for background completion wake-ups."
}
},
"required": ["name", "instructions", "target"],
"additionalProperties": false
})
}
fn hints(&self) -> crate::tool_types::ToolHints {
let mut hints = crate::tool_types::ToolHints::default()
.with_long_running(true)
.with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
if self.provider_for("external_a2a").is_some() {
hints = hints.with_open_world(true);
}
hints
}
async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
ToolExecutionResult::tool_error(
"spawn_agent requires context. This tool must be executed with session context.",
)
}
async fn execute_with_context(
&self,
arguments: serde_json::Value,
context: &ToolContext,
) -> ToolExecutionResult {
let target_type = match arguments
.get("target")
.and_then(|target| target.get("type"))
.and_then(serde_json::Value::as_str)
{
Some(target_type) => target_type,
None => {
return ToolExecutionResult::tool_error("Missing required parameter: target.type");
}
};
let Some(provider) = self.provider_for(target_type) else {
let supported = self.target_types().join(", ");
return ToolExecutionResult::tool_error(format!(
"Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
));
};
if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
return ToolExecutionResult::tool_error(error);
}
if target_type == "external_a2a"
&& arguments
.get("lifetime")
.and_then(serde_json::Value::as_str)
.is_some_and(|value| value == "detached")
{
return ToolExecutionResult::tool_error(
"lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
);
}
if target_type == "external_a2a"
&& arguments
.get("message_schema")
.is_some_and(|schema| !schema.is_null())
{
return ToolExecutionResult::tool_error(
"message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
);
}
provider.execute_with_context(arguments, context).await
}
fn requires_context(&self) -> bool {
true
}
}
pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
let Some(additions) = additions.filter(|value| !value.is_empty()) else {
return base_system_prompt.to_string();
};
if base_system_prompt.is_empty() {
return additions.to_string();
}
if base_system_prompt.contains("<system-prompt>") {
format!("{base_system_prompt}\n\n{additions}")
} else {
format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
}
}
pub struct CollectedMessageFilters {
pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
}
pub struct CollectedModelViewProviders {
pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
}
impl CollectedMessageFilters {
pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
for (provider, config) in &self.message_filter_providers {
provider.apply_filters(query, config);
}
}
pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
for (provider, config) in &self.message_filter_providers {
provider.post_load(messages, config);
}
}
}
impl CollectedModelViewProviders {
pub fn apply_model_view(
&self,
mut messages: Vec<Message>,
context: &ModelViewContext<'_>,
) -> Vec<Message> {
for (provider, config) in &self.model_view_providers {
messages = provider.apply_model_view(messages, config, context);
}
messages
}
}
fn compaction_is_enabled(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
) -> bool {
capability_configs.iter().any(|cap_config| {
registry.get(cap_config.capability_id()).is_some_and(|cap| {
cap.status() == CapabilityStatus::Available
&& cap.compaction_policy(cap_config.config_value()).is_some()
})
})
}
pub fn collect_message_filters_only(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
) -> CollectedMessageFilters {
let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
Vec::new();
let compaction_on = compaction_is_enabled(capability_configs, registry);
for cap_config in capability_configs {
let cap_id = cap_config.capability_id();
if let Some(capability) = registry.get(cap_id) {
if capability.status() != CapabilityStatus::Available {
continue;
}
let effective: &dyn Capability = capability
.resolve_for_model(None)
.unwrap_or_else(|| capability.as_ref());
if let Some(provider) = effective.message_filter_provider() {
let config =
effective.message_filter_config(cap_config.config_value(), compaction_on);
message_filter_providers.push((provider, config));
}
}
}
message_filter_providers.sort_by_key(|(p, _)| p.priority());
CollectedMessageFilters {
message_filter_providers,
}
}
pub fn collect_model_view_providers(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
model: Option<&str>,
) -> CollectedModelViewProviders {
let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
for cap_config in capability_configs {
let cap_id = cap_config.capability_id();
if let Some(capability) = registry.get(cap_id) {
if capability.status() != CapabilityStatus::Available {
continue;
}
let effective: &dyn Capability = capability
.resolve_for_model(model)
.unwrap_or_else(|| capability.as_ref());
if let Some(provider) = effective.model_view_provider() {
model_view_providers.push((provider, cap_config.config_value().clone()));
}
}
}
model_view_providers.sort_by_key(|(p, _)| p.priority());
CollectedModelViewProviders {
model_view_providers,
}
}
pub fn collect_dynamic_facts(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
model: Option<&str>,
ctx: &FactsContext,
) -> Vec<Fact> {
let mut dynamic = Vec::new();
for cap_config in capability_configs {
let cap_id = cap_config.capability_id();
if let Some(capability) = registry.get(cap_id) {
if capability.status() != CapabilityStatus::Available {
continue;
}
let effective: &dyn Capability = capability
.resolve_for_model(model)
.unwrap_or_else(|| capability.as_ref());
for fact in effective.facts(cap_config.config_value(), ctx) {
if fact.volatility == Volatility::Dynamic {
dynamic.push(fact);
}
}
}
}
dynamic
}
pub fn collect_capability_mcp_servers(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
) -> ScopedMcpServers {
let mut servers = ScopedMcpServers::default();
for cap_config in capability_configs {
let cap_id = cap_config.capability_id();
if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
cap_config.config_value().clone(),
) {
if definition.status != CapabilityStatus::Available {
continue;
}
if let Some(contributed) = definition.mcp_servers {
servers = merge_scoped_mcp_servers(&servers, &contributed);
}
}
continue;
}
if let Some(capability) = registry.get(cap_id) {
if capability.status() != CapabilityStatus::Available {
continue;
}
servers = merge_scoped_mcp_servers(
&servers,
&capability.mcp_servers_with_config(cap_config.config_value()),
);
}
}
servers
}
pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencyError {
CircularDependency {
capability_id: String,
chain: Vec<String>,
},
TooManyCapabilities {
count: usize,
max: usize,
},
}
impl std::fmt::Display for DependencyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DependencyError::CircularDependency {
capability_id,
chain,
} => {
write!(
f,
"Circular dependency detected: {} depends on itself via chain: {} -> {}",
capability_id,
chain.join(" -> "),
capability_id
)
}
DependencyError::TooManyCapabilities { count, max } => {
write!(
f,
"Too many capabilities after resolution: {} (max: {})",
count, max
)
}
}
}
}
impl std::error::Error for DependencyError {}
#[derive(Debug, Clone)]
pub struct ResolvedCapabilities {
pub resolved_ids: Vec<String>,
pub added_as_dependencies: Vec<String>,
pub user_selected: Vec<String>,
}
pub fn resolve_dependencies(
selected_ids: &[String],
registry: &CapabilityRegistry,
) -> Result<ResolvedCapabilities, DependencyError> {
use std::collections::HashSet;
let user_selected: HashSet<String> = selected_ids
.iter()
.map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
.collect();
let mut resolved: Vec<String> = Vec::new();
let mut resolved_set: HashSet<String> = HashSet::new();
let mut added_as_dependencies: Vec<String> = Vec::new();
for cap_id in selected_ids {
resolve_single_capability(
cap_id,
registry,
&mut resolved,
&mut resolved_set,
&mut added_as_dependencies,
&user_selected,
&mut Vec::new(), )?;
}
if resolved.len() > MAX_RESOLVED_CAPABILITIES {
return Err(DependencyError::TooManyCapabilities {
count: resolved.len(),
max: MAX_RESOLVED_CAPABILITIES,
});
}
Ok(ResolvedCapabilities {
resolved_ids: resolved,
added_as_dependencies,
user_selected: selected_ids.to_vec(),
})
}
pub fn resolve_capability_configs(
selected_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
let mut selected_ids: Vec<String> = Vec::new();
for config in selected_configs {
if (is_declarative_capability(config.capability_id())
|| is_plugin_capability(config.capability_id()))
&& let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
config.config_value().clone(),
)
{
selected_ids.extend(definition.dependencies);
}
selected_ids.push(config.capability_id().to_string());
}
let resolved = resolve_dependencies(&selected_ids, registry)?;
let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
.iter()
.map(|config| {
let id = config.capability_id();
let id = registry.canonical_id(id).unwrap_or(id);
(id.to_string(), config.config_value().clone())
})
.collect();
Ok(resolved
.resolved_ids
.into_iter()
.map(|capability_id| {
explicit_configs
.get(&capability_id)
.cloned()
.map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
.unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
})
.collect())
}
fn resolve_single_capability(
cap_id: &str,
registry: &CapabilityRegistry,
resolved: &mut Vec<String>,
resolved_set: &mut std::collections::HashSet<String>,
added_as_dependencies: &mut Vec<String>,
user_selected: &std::collections::HashSet<String>,
visiting: &mut Vec<String>,
) -> Result<(), DependencyError> {
let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
if resolved_set.contains(cap_id) {
return Ok(());
}
if visiting.contains(&cap_id.to_string()) {
return Err(DependencyError::CircularDependency {
capability_id: cap_id.to_string(),
chain: visiting.clone(),
});
}
let capability = match registry.get(cap_id) {
Some(cap) => cap,
None => {
if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
&& !resolved_set.contains(cap_id)
{
resolved.push(cap_id.to_string());
resolved_set.insert(cap_id.to_string());
if !user_selected.contains(cap_id) {
added_as_dependencies.push(cap_id.to_string());
}
}
return Ok(());
}
};
visiting.push(cap_id.to_string());
for dep_id in capability.dependencies() {
resolve_single_capability(
dep_id,
registry,
resolved,
resolved_set,
added_as_dependencies,
user_selected,
visiting,
)?;
}
visiting.pop();
if !resolved_set.contains(cap_id) {
resolved.push(cap_id.to_string());
resolved_set.insert(cap_id.to_string());
if !user_selected.contains(cap_id) {
added_as_dependencies.push(cap_id.to_string());
}
}
Ok(())
}
pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
use std::collections::HashSet;
let resolved_ids = match resolve_dependencies(capability_ids, registry) {
Ok(resolved) => resolved.resolved_ids,
Err(_) => capability_ids.to_vec(),
};
let mut seen = HashSet::new();
let mut features = Vec::new();
for cap_id in &resolved_ids {
if let Some(cap) = registry.get(cap_id) {
for feature in cap.features() {
if seen.insert(feature) {
features.push(feature.to_string());
}
}
}
}
features
}
pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
registry
.get(cap_id)
.map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
.unwrap_or_default()
}
pub async fn collect_capabilities(
capability_ids: &[String],
registry: &CapabilityRegistry,
ctx: &SystemPromptContext,
) -> CollectedCapabilities {
let resolved_ids = match resolve_dependencies(capability_ids, registry) {
Ok(resolved) => resolved.resolved_ids,
Err(e) => {
tracing::warn!("Failed to resolve capability dependencies: {}", e);
capability_ids.to_vec()
}
};
let configs: Vec<AgentCapabilityConfig> = resolved_ids
.iter()
.map(|id| {
AgentCapabilityConfig::with_config(
CapabilityId::new(id),
serde_json::Value::Object(serde_json::Map::new()),
)
})
.collect();
collect_capabilities_with_configs(&configs, registry, ctx).await
}
pub async fn collect_capabilities_with_configs(
capability_configs: &[AgentCapabilityConfig],
registry: &CapabilityRegistry,
ctx: &SystemPromptContext,
) -> CollectedCapabilities {
let mut system_prompt_parts: Vec<String> = Vec::new();
let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
let mut mounts: Vec<MountPoint> = Vec::new();
let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
Vec::new();
let mut applied_ids: Vec<String> = Vec::new();
let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
let mut parallel_tool_calls: Option<bool> = None;
let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
let mut mcp_servers = ScopedMcpServers::default();
let mut static_facts: Vec<Fact> = Vec::new();
let mut has_dynamic_facts = false;
let facts_ctx = FactsContext::new(ctx.session_id);
let compaction_on = compaction_is_enabled(capability_configs, registry);
let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
for cap_config in capability_configs {
let cap_id = cap_config.capability_id();
if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
match serde_json::from_value::<DeclarativeCapabilityDefinition>(
cap_config.config_value().clone(),
) {
Ok(definition) => {
if definition.status != CapabilityStatus::Available {
continue;
}
if let Some(prompt) = definition.system_prompt.as_deref() {
let contribution =
format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
system_prompt_attributions.push(SystemPromptAttribution {
capability_id: cap_id.to_string(),
content: contribution.clone(),
});
system_prompt_parts.push(contribution);
}
mounts.extend(definition.mounts(cap_id));
if let Some(ref servers) = definition.mcp_servers {
mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
}
for skill in definition.skill_contributions() {
mounts.push(skill.to_mount(cap_id));
}
applied_ids.push(cap_id.to_string());
}
Err(error) => {
tracing::warn!(
capability_id = %cap_id,
error = %error,
"Skipping invalid declarative/plugin capability config"
);
}
}
continue;
}
if let Some(capability) = registry.get(cap_id) {
if capability.status() != CapabilityStatus::Available {
continue;
}
let effective: &dyn Capability =
match capability.resolve_for_model(ctx.model.as_deref()) {
Some(inner) => inner,
None => capability.as_ref(),
};
let delegation_target =
effective.delegation_target_with_config(cap_config.config_value());
if let Some(contribution) = effective
.system_prompt_contribution_with_config(ctx, cap_config.config_value())
.await
{
system_prompt_attributions.push(SystemPromptAttribution {
capability_id: cap_id.to_string(),
content: contribution.clone(),
});
system_prompt_parts.push(contribution);
}
for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
match fact.volatility {
Volatility::Static => static_facts.push(fact),
Volatility::Dynamic => has_dynamic_facts = true,
}
}
tools.extend(effective.tools_with_config(cap_config.config_value()));
if let Some(target) = delegation_target {
delegation_targets.push(target);
}
tool_definition_hooks.extend(
effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
);
tool_call_hooks.extend(effective.tool_call_hooks());
narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
let cap_category = effective.category();
for def in effective.tool_definitions() {
let def = match (def.category(), cap_category) {
(None, Some(cat)) => def.with_category(cat),
_ => def,
}
.with_capability_attribution(cap_id, Some(capability.name()));
tool_definitions.push(def);
}
tool_search = effective
.tool_search_config(cap_config.config_value())
.or(tool_search);
prompt_cache = effective
.prompt_cache_config(cap_config.config_value())
.or(prompt_cache);
parallel_tool_calls = effective
.parallel_tool_calls_preference(cap_config.config_value())
.or(parallel_tool_calls);
openrouter_routing = effective
.openrouter_routing_config(cap_config.config_value())
.or(openrouter_routing);
mounts.extend(effective.mounts());
mcp_servers = merge_scoped_mcp_servers(
&mcp_servers,
&effective.mcp_servers_with_config(cap_config.config_value()),
);
for skill in effective.contribute_skills() {
mounts.push(skill.to_mount(cap_id));
}
if let Some(provider) = effective.message_filter_provider() {
let config =
effective.message_filter_config(cap_config.config_value(), compaction_on);
message_filter_providers.push((provider, config));
}
applied_ids.push(cap_id.to_string());
}
}
if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
let tool = UnifiedSpawnAgentTool::new(delegation_targets);
let def = tool
.to_definition()
.with_category("Orchestration")
.with_capability_attribution("agent_delegation", Some("Agent Delegation"));
tools.push(Box::new(tool));
tool_definitions.push(def);
}
let auto_activated: Vec<_> = registry
.list()
.into_iter()
.filter(|cap| {
!applied_ids.iter().any(|id| id == cap.id())
&& cap.status() == CapabilityStatus::Available
&& cap.auto_activates_for(&tool_definitions)
})
.cloned()
.collect();
for cap in auto_activated {
tools.extend(cap.tools());
let cap_category = cap.category();
for def in cap.tool_definitions() {
let def = match (def.category(), cap_category) {
(None, Some(cat)) => def.with_category(cat),
_ => def,
}
.with_capability_attribution(cap.id(), Some(cap.name()));
tool_definitions.push(def);
}
narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
applied_ids.push(cap.id().to_string());
}
if let Some(block) = facts::render_facts_block(&static_facts) {
system_prompt_attributions.push(SystemPromptAttribution {
capability_id: "facts".to_string(),
content: block.clone(),
});
system_prompt_parts.push(block);
}
if has_dynamic_facts {
system_prompt_attributions.push(SystemPromptAttribution {
capability_id: "facts".to_string(),
content: FACTS_DYNAMIC_NOTE.to_string(),
});
system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
}
tool_call_hooks.extend(narration_hooks);
message_filter_providers.sort_by_key(|(p, _)| p.priority());
CollectedCapabilities {
system_prompt_parts,
system_prompt_attributions,
tools,
tool_definitions,
mounts,
message_filter_providers,
applied_ids,
tool_search,
prompt_cache,
openrouter_routing,
parallel_tool_calls,
tool_definition_hooks,
tool_call_hooks,
mcp_servers,
}
}
pub struct AppliedCapabilities {
pub runtime_agent: RuntimeAgent,
pub tool_registry: ToolRegistry,
pub applied_ids: Vec<String>,
}
pub async fn apply_capabilities(
base_runtime_agent: RuntimeAgent,
capability_ids: &[String],
registry: &CapabilityRegistry,
ctx: &SystemPromptContext,
) -> AppliedCapabilities {
let collected = collect_capabilities(capability_ids, registry, ctx).await;
let final_system_prompt = compose_system_prompt(
&base_runtime_agent.system_prompt,
collected.system_prompt_prefix().as_deref(),
);
let mut tool_registry = ToolRegistry::new();
for tool in collected.tools {
tool_registry.register_boxed(tool);
}
let mut tools = collected.tool_definitions;
for hook in &collected.tool_definition_hooks {
tools = hook.transform(tools);
}
let runtime_agent = RuntimeAgent {
system_prompt: final_system_prompt,
model: base_runtime_agent.model,
tools,
max_iterations: base_runtime_agent.max_iterations,
temperature: base_runtime_agent.temperature,
max_tokens: base_runtime_agent.max_tokens,
tool_search: collected.tool_search,
prompt_cache: collected.prompt_cache,
openrouter_routing: collected.openrouter_routing,
network_access: base_runtime_agent.network_access,
parallel_tool_calls: base_runtime_agent
.parallel_tool_calls
.or(collected.parallel_tool_calls),
};
AppliedCapabilities {
runtime_agent,
tool_registry,
applied_ids: collected.applied_ids,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::typed_id::SessionId;
use uuid::Uuid;
fn test_ctx() -> SystemPromptContext {
SystemPromptContext::without_file_store(SessionId::new())
}
struct StubSubagentSpawnTool;
#[async_trait]
impl Tool for StubSubagentSpawnTool {
fn name(&self) -> &str {
"spawn_agent"
}
fn description(&self) -> &str {
"stub subagent delegation"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({ "type": "object" })
}
fn narrate(
&self,
tool_call: &ToolCall,
phase: crate::tool_narration::ToolNarrationPhase,
locale: Option<&str>,
_ctx: crate::tool_narration::ToolNarrationContext<'_>,
) -> Option<String> {
Some(crate::tool_narration::narrate_subagent_spawn(
&tool_call.arguments,
phase,
locale,
))
}
async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
crate::ToolExecutionResult::success(serde_json::json!({}))
}
}
fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
ToolCall {
id: "call-1".to_string(),
name: "spawn_agent".to_string(),
arguments,
}
}
#[test]
fn unified_spawn_agent_narration_names_the_agent() {
let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
target_type: "subagent",
tool: Box::new(StubSubagentSpawnTool),
}]);
let ctx = crate::tool_narration::ToolNarrationContext::default();
assert_eq!(
tool.narrate(
&spawn_agent_call(serde_json::json!({
"name": "Orbit Scout",
"target": { "type": "subagent" },
"blueprint": "github_scout"
})),
crate::tool_narration::ToolNarrationPhase::Started,
None,
ctx,
)
.as_deref(),
Some("Launching Orbit Scout subagent (github_scout)")
);
assert_eq!(
tool.narrate(
&spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
crate::tool_narration::ToolNarrationPhase::Started,
None,
ctx,
)
.as_deref(),
Some("Launching Orbit Scout subagent")
);
}
#[test]
fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
for target_type in ["agent", "external_a2a"] {
let arguments = serde_json::json!({
"target": { "type": target_type, "id": "actual-target" },
"blueprint": "decoy-target"
});
assert_eq!(
validate_spawn_agent_target_fields(&arguments, target_type),
Err(format!(
"blueprint is only valid for subagent targets, not {target_type}."
))
);
let arguments = serde_json::json!({
"target": { "type": target_type, "id": "actual-target" },
"config": { "model": "decoy" }
});
assert_eq!(
validate_spawn_agent_target_fields(&arguments, target_type),
Err(format!(
"config is only valid for subagent targets, not {target_type}."
))
);
}
}
struct NoopFixture;
impl Capability for NoopFixture {
fn id(&self) -> &str {
"noop"
}
fn name(&self) -> &str {
"No-Op"
}
fn description(&self) -> &str {
"Contributes nothing."
}
}
struct FeatureFixture;
impl Capability for FeatureFixture {
fn id(&self) -> &str {
"feature_fixture"
}
fn name(&self) -> &str {
"Feature Fixture"
}
fn description(&self) -> &str {
"Declares one test-only feature."
}
fn features(&self) -> Vec<&'static str> {
vec!["fixture_feature"]
}
}
struct FixtureTool(&'static str);
#[async_trait]
impl Tool for FixtureTool {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> &str {
"Fixture tool."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
ToolExecutionResult::success(serde_json::json!({ "ok": true }))
}
}
struct BackgroundFixtureTool;
#[async_trait]
impl Tool for BackgroundFixtureTool {
fn name(&self) -> &str {
"bash"
}
fn description(&self) -> &str {
"Fixture background-capable shell tool."
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
ToolExecutionResult::success(serde_json::json!({"ok": true}))
}
fn hints(&self) -> crate::tool_types::ToolHints {
crate::tool_types::ToolHints {
supports_background: Some(true),
..Default::default()
}
}
}
struct FileSystemFixture;
impl Capability for FileSystemFixture {
fn id(&self) -> &str {
"session_file_system"
}
fn name(&self) -> &str {
"Fixture Filesystem"
}
fn description(&self) -> &str {
"Fixture filesystem capability."
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(FixtureTool("read_file")),
Box::new(FixtureTool("write_file")),
]
}
fn features(&self) -> Vec<&'static str> {
vec!["file_system"]
}
}
struct StorageFixture;
impl Capability for StorageFixture {
fn id(&self) -> &str {
"session_storage"
}
fn name(&self) -> &str {
"Fixture Storage"
}
fn description(&self) -> &str {
"Fixture session storage capability."
}
fn features(&self) -> Vec<&'static str> {
vec!["secrets", "key_value"]
}
}
struct BashFixture;
impl Capability for BashFixture {
fn id(&self) -> &str {
"bashkit_shell"
}
fn aliases(&self) -> Vec<&'static str> {
vec!["virtual_bash"]
}
fn name(&self) -> &str {
"Fixture Bash"
}
fn description(&self) -> &str {
"Fixture shell capability."
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(BackgroundFixtureTool)]
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["session_file_system"]
}
fn features(&self) -> Vec<&'static str> {
vec!["file_system"]
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::High
}
}
struct WebFetchFixture;
impl Capability for WebFetchFixture {
fn id(&self) -> &str {
"web_fetch"
}
fn name(&self) -> &str {
"Fixture Web Fetch"
}
fn description(&self) -> &str {
"Fixture web capability."
}
fn risk_level(&self) -> RiskLevel {
RiskLevel::High
}
}
struct DynamicFactFixture;
impl Capability for DynamicFactFixture {
fn id(&self) -> &str {
"current_time"
}
fn name(&self) -> &str {
"Dynamic Fact Fixture"
}
fn description(&self) -> &str {
"Fixture with one dynamic fact and one tool."
}
fn icon(&self) -> Option<&str> {
Some("clock")
}
fn category(&self) -> Option<&str> {
Some("Core")
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(FixtureTool("get_current_time"))]
}
fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
vec![Fact::dynamic("current_time", "fixture-now")]
}
}
struct PromptToolFixture;
impl Capability for PromptToolFixture {
fn id(&self) -> &str {
"prompt_tool_fixture"
}
fn name(&self) -> &str {
"Prompt Tool Fixture"
}
fn description(&self) -> &str {
"Fixture with a static prompt and tool."
}
fn system_prompt_addition(&self) -> Option<&str> {
Some("Task Management uses the write_todos tool.")
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![Box::new(FixtureTool("write_todos"))]
}
}
struct SecondPromptFixture;
impl Capability for SecondPromptFixture {
fn id(&self) -> &str {
"second_prompt_fixture"
}
fn name(&self) -> &str {
"Second Prompt Fixture"
}
fn description(&self) -> &str {
"Fixture with a second static prompt."
}
fn system_prompt_addition(&self) -> Option<&str> {
Some("A second capability prompt contribution.")
}
}
struct DynamicPreviewFixture;
impl Capability for DynamicPreviewFixture {
fn id(&self) -> &str {
"agent_instructions"
}
fn name(&self) -> &str {
"Dynamic Preview Fixture"
}
fn description(&self) -> &str {
"Fixture whose runtime prompt is dynamic."
}
fn system_prompt_preview(&self) -> Option<String> {
Some("Reads AGENTS.md dynamically.".to_string())
}
}
struct MathFixture;
impl Capability for MathFixture {
fn id(&self) -> &str {
"test_math"
}
fn name(&self) -> &str {
"Test Math"
}
fn description(&self) -> &str {
"Fixture: calculator tools."
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(FixtureTool("add")),
Box::new(FixtureTool("subtract")),
Box::new(FixtureTool("multiply")),
Box::new(FixtureTool("divide")),
]
}
}
struct WeatherFixture;
impl Capability for WeatherFixture {
fn id(&self) -> &str {
"test_weather"
}
fn name(&self) -> &str {
"Test Weather"
}
fn description(&self) -> &str {
"Fixture: weather tools."
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
vec![
Box::new(FixtureTool("get_weather")),
Box::new(FixtureTool("get_forecast")),
]
}
}
struct SampleDataFixture;
impl Capability for SampleDataFixture {
fn id(&self) -> &str {
"sample_data"
}
fn name(&self) -> &str {
"Sample Data"
}
fn description(&self) -> &str {
"Fixture: mounted sample files."
}
fn system_prompt_addition(&self) -> Option<&str> {
Some("Read-only sample files are mounted at `/samples`.")
}
fn mounts(&self) -> Vec<MountPoint> {
let samples_dir = MountDirectoryBuilder::new()
.file("users.json", "[]")
.build();
vec![MountPoint::readonly("/samples", samples_dir, self.id())]
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["session_file_system"]
}
fn features(&self) -> Vec<&'static str> {
vec!["file_system"]
}
}
fn fixture_registry() -> CapabilityRegistry {
let mut registry = CapabilityRegistry::new();
registry.register(NoopFixture);
registry.register(FeatureFixture);
registry.register(MathFixture);
registry.register(WeatherFixture);
registry.register(SampleDataFixture);
registry.register(FileSystemFixture);
registry.register(StorageFixture);
registry.register(BashFixture);
registry.register(WebFetchFixture);
registry.register(DynamicFactFixture);
registry.register(PromptToolFixture);
registry.register(SecondPromptFixture);
registry.register(DynamicPreviewFixture);
registry
}
struct HostAnnotatedCapability;
#[async_trait]
impl Capability for HostAnnotatedCapability {
fn id(&self) -> &str {
"host_annotated"
}
fn name(&self) -> &str {
"Host Annotated"
}
fn description(&self) -> &str {
"Test capability with host-owned metadata."
}
fn metadata(&self) -> Option<serde_json::Value> {
Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
}
}
#[test]
fn test_capability_registry_get() {
let mut registry = CapabilityRegistry::new();
registry.register(NoopFixture);
let capability = registry.get("noop").unwrap();
assert_eq!(capability.id(), "noop");
assert_eq!(capability.status(), CapabilityStatus::Available);
}
#[test]
fn default_registry_is_empty_and_selects_no_product_preset() {
assert!(CapabilityRegistry::default().is_empty());
assert!(CapabilityRegistryBuilder::default().build().is_empty());
}
#[tokio::test]
async fn test_capability_registry_blueprint_with_capability() {
struct BlueprintProviderCapability;
impl Capability for BlueprintProviderCapability {
fn id(&self) -> &str {
"blueprint_provider"
}
fn name(&self) -> &str {
"Blueprint Provider"
}
fn description(&self) -> &str {
"Capability that provides a blueprint for tests"
}
fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
vec![AgentBlueprint {
id: "test_blueprint",
name: "Test Blueprint",
description: "Blueprint for capability registry tests",
model: BlueprintModel::Fixed("specialist-model".into()),
system_prompt: "Test prompt",
tools: vec![Box::new(FixtureTool("private_lookup"))],
max_turns: Some(7),
config_schema: Some(
serde_json::json!({"type":"object", "required":["repository"]}),
),
}]
}
}
let mut registry = CapabilityRegistry::new();
registry.register(BlueprintProviderCapability);
let (capability_id, blueprint) = registry
.blueprint_with_capability("test_blueprint")
.expect("blueprint should resolve with capability id");
assert_eq!(capability_id, "blueprint_provider");
assert_eq!(blueprint.id, "test_blueprint");
assert_eq!(blueprint.name, "Test Blueprint");
assert_eq!(
blueprint.description,
"Blueprint for capability registry tests"
);
assert_eq!(blueprint.system_prompt, "Test prompt");
assert!(
matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
);
assert_eq!(blueprint.max_turns, Some(7));
assert_eq!(
blueprint.config_schema,
Some(serde_json::json!({"type":"object", "required":["repository"]}))
);
let definitions = blueprint.tool_definitions();
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].name(), "private_lookup");
assert_eq!(
registry.blueprint("test_blueprint").unwrap().tools[0].name(),
"private_lookup"
);
assert_eq!(
registry
.all_blueprints()
.iter()
.map(|b| b.id)
.collect::<Vec<_>>(),
["test_blueprint"]
);
assert!(registry.blueprint_with_capability("missing").is_none());
assert!(registry.blueprint("missing").is_none());
let host =
collect_capabilities(&["blueprint_provider".into()], ®istry, &test_ctx()).await;
assert!(host.tools.is_empty());
assert!(host.tool_definitions.is_empty());
}
#[test]
fn test_capability_registry_builder() {
let registry = CapabilityRegistry::builder()
.capability(NoopFixture)
.build();
assert!(registry.has("noop"));
assert_eq!(registry.len(), 1);
}
#[test]
fn test_system_prompt_preview_default_delegates_to_addition() {
struct StaticPromptCapability;
impl Capability for StaticPromptCapability {
fn id(&self) -> &str {
"static_prompt"
}
fn name(&self) -> &str {
"Static Prompt"
}
fn description(&self) -> &str {
"Static prompt addition."
}
fn system_prompt_addition(&self) -> Option<&str> {
Some("Use the static prompt.")
}
}
let cap = StaticPromptCapability;
assert_eq!(
cap.system_prompt_preview().as_deref(),
Some("Use the static prompt.")
);
let registry = fixture_registry();
let current_time = registry.get("current_time").unwrap();
assert!(current_time.system_prompt_preview().is_none());
assert!(current_time.system_prompt_addition().is_none());
}
#[tokio::test]
async fn test_apply_capabilities_empty() {
let registry = CapabilityRegistry::new();
let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
let applied =
apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
assert_eq!(
applied.runtime_agent.system_prompt,
base_runtime_agent.system_prompt
);
assert!(applied.tool_registry.is_empty());
assert!(applied.applied_ids.is_empty());
}
#[tokio::test]
async fn test_apply_capabilities_noop() {
let registry = fixture_registry();
let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
base_runtime_agent.max_iterations = 13;
base_runtime_agent.temperature = Some(0.25);
base_runtime_agent.max_tokens = Some(1234);
base_runtime_agent.parallel_tool_calls = Some(false);
let applied = apply_capabilities(
base_runtime_agent.clone(),
&["noop".to_string()],
®istry,
&test_ctx(),
)
.await;
assert_eq!(
applied.runtime_agent.system_prompt,
base_runtime_agent.system_prompt
);
assert!(applied.tool_registry.is_empty());
assert_eq!(applied.applied_ids, vec!["noop"]);
assert_eq!(
serde_json::to_value(&applied.runtime_agent).unwrap(),
serde_json::to_value(&base_runtime_agent).unwrap()
);
let collected = collect_capabilities(&["noop".into()], ®istry, &test_ctx()).await;
assert!(collected.mounts.is_empty());
assert!(collected.message_filter_providers.is_empty());
assert!(compute_features(&["noop".into()], ®istry).is_empty());
}
#[tokio::test]
async fn test_apply_capabilities_current_time() {
let registry = fixture_registry();
let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
let applied = apply_capabilities(
base_runtime_agent.clone(),
&["current_time".to_string()],
®istry,
&test_ctx(),
)
.await;
assert!(
applied
.runtime_agent
.system_prompt
.contains(FACTS_DYNAMIC_NOTE),
"current_time should contribute the dynamic-facts note"
);
assert!(
applied
.runtime_agent
.system_prompt
.contains(&base_runtime_agent.system_prompt),
"base prompt is preserved"
);
assert!(applied.tool_registry.has("get_current_time"));
assert_eq!(applied.tool_registry.len(), 1);
assert_eq!(applied.applied_ids, vec!["current_time"]);
}
#[tokio::test]
async fn test_apply_capabilities_skips_coming_soon() {
struct ComingSoonFixture;
impl Capability for ComingSoonFixture {
fn id(&self) -> &str {
"coming_soon_fixture"
}
fn name(&self) -> &str {
"Coming Soon Fixture"
}
fn description(&self) -> &str {
"Test-only capability."
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::ComingSoon
}
fn system_prompt_addition(&self) -> Option<&str> {
Some("Not yet available.")
}
}
let mut registry = CapabilityRegistry::new();
registry.register(ComingSoonFixture);
let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
let applied = apply_capabilities(
base_runtime_agent.clone(),
&["coming_soon_fixture".to_string()],
®istry,
&test_ctx(),
)
.await;
assert_eq!(
applied.runtime_agent.system_prompt,
base_runtime_agent.system_prompt
);
assert!(applied.applied_ids.is_empty());
}
#[tokio::test]
async fn test_apply_capabilities_preserves_order() {
let registry = fixture_registry();
let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
let applied = apply_capabilities(
base_runtime_agent,
&["current_time".to_string(), "noop".to_string()],
®istry,
&test_ctx(),
)
.await;
assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
assert_eq!(applied.tool_registry.len(), 1);
assert!(applied.tool_registry.has("get_current_time"));
}
#[tokio::test]
async fn test_dynamic_facts_add_note_without_static_block() {
let registry = fixture_registry();
let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
let prompt = collected.system_prompt_parts.join("\n");
assert!(
prompt.contains(FACTS_DYNAMIC_NOTE),
"dynamic-facts note should be in the cached prompt"
);
assert!(
!prompt.contains("<facts>\n"),
"no static <facts> block for a purely-dynamic fact; got: {prompt}"
);
}
#[tokio::test]
async fn test_static_facts_fold_into_prompt() {
struct StaticFactCap;
impl Capability for StaticFactCap {
fn id(&self) -> &str {
"test_static_fact"
}
fn name(&self) -> &str {
"Static Fact"
}
fn description(&self) -> &str {
"test"
}
fn status(&self) -> CapabilityStatus {
CapabilityStatus::Available
}
fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
vec![Fact::stat("workspace_root", "/workspace")]
}
}
let mut registry = CapabilityRegistry::new();
registry.register(StaticFactCap);
let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
let prompt = collected.system_prompt_parts.join("\n");
assert!(
prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
"static fact should fold into the cached prompt; got: {prompt}"
);
assert!(
!prompt.contains(FACTS_DYNAMIC_NOTE),
"no dynamic note when only static facts exist"
);
}
#[test]
fn test_collect_dynamic_facts_returns_current_time() {
let registry = fixture_registry();
let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
let facts = collect_dynamic_facts(
&configs,
®istry,
None,
&FactsContext::new(SessionId::new()),
);
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].key, "current_time");
assert_eq!(facts[0].value, "fixture-now");
assert_eq!(facts[0].volatility, Volatility::Dynamic);
}
#[tokio::test]
async fn test_collect_capabilities_combines_mounts() {
struct Notes;
impl Capability for Notes {
fn id(&self) -> &str {
"notes"
}
fn name(&self) -> &str {
"Notes"
}
fn description(&self) -> &str {
"Writable notes"
}
fn mounts(&self) -> Vec<MountPoint> {
vec![MountPoint::readwrite(
"/notes.txt",
MountSource::text_file("Note α"),
"notes",
)]
}
}
let mut registry = fixture_registry();
registry.register(Notes);
let collected = collect_capabilities(
&["sample_data".into(), "notes".into(), "current_time".into()],
®istry,
&test_ctx(),
)
.await;
assert_eq!(
collected.applied_ids,
[
"session_file_system",
"sample_data",
"notes",
"current_time"
]
);
assert_eq!(
collected.mounts,
vec![
MountPoint::readonly(
"/samples",
MountDirectoryBuilder::new()
.file("users.json", "[]")
.build(),
"sample_data"
),
MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
]
);
}
#[test]
fn test_resolve_dependencies_empty() {
let registry = CapabilityRegistry::new();
let resolved = resolve_dependencies(&[], ®istry).unwrap();
assert!(resolved.resolved_ids.is_empty());
assert!(resolved.added_as_dependencies.is_empty());
assert!(resolved.user_selected.is_empty());
}
#[test]
fn test_resolve_dependencies_no_deps() {
let registry = fixture_registry();
let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
assert_eq!(resolved.resolved_ids, vec!["current_time"]);
assert!(resolved.added_as_dependencies.is_empty());
}
#[test]
fn test_resolve_dependencies_with_deps() {
let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
assert_eq!(
resolved.resolved_ids,
["session_file_system", "sample_data"]
);
assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
assert_eq!(resolved.user_selected, ["sample_data"]);
}
#[test]
fn test_resolve_dependencies_already_selected() {
let registry = fixture_registry();
let resolved = resolve_dependencies(
&["session_file_system".to_string(), "sample_data".to_string()],
®istry,
)
.unwrap();
assert_eq!(resolved.resolved_ids.len(), 2);
assert!(resolved.added_as_dependencies.is_empty());
}
#[test]
fn test_resolve_dependencies_preserves_order() {
let registry = fixture_registry();
let resolved =
resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
.unwrap();
assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
}
#[test]
fn test_resolve_dependencies_unknown_capability() {
let registry = CapabilityRegistry::new();
let resolved =
resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
assert!(resolved.resolved_ids.is_empty());
}
#[test]
fn test_get_dependencies() {
let registry = fixture_registry();
let deps = get_dependencies("sample_data", ®istry);
assert_eq!(deps, vec!["session_file_system"]);
let deps = get_dependencies("current_time", ®istry);
assert!(deps.is_empty());
let deps = get_dependencies("unknown", ®istry);
assert!(deps.is_empty());
}
#[test]
fn test_circular_dependency_error() {
struct CapA;
struct CapB;
impl Capability for CapA {
fn id(&self) -> &str {
"test_cap_a"
}
fn name(&self) -> &str {
"Test A"
}
fn description(&self) -> &str {
"Test capability A"
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["test_cap_b"]
}
}
impl Capability for CapB {
fn id(&self) -> &str {
"test_cap_b"
}
fn name(&self) -> &str {
"Test B"
}
fn description(&self) -> &str {
"Test capability B"
}
fn dependencies(&self) -> Vec<&'static str> {
vec!["test_cap_a"]
}
}
let mut registry = CapabilityRegistry::new();
registry.register(CapA);
registry.register(CapB);
let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
assert!(result.is_err());
match result.unwrap_err() {
DependencyError::CircularDependency { capability_id, .. } => {
assert_eq!(capability_id, "test_cap_a");
}
_ => panic!("Expected CircularDependency error"),
}
}
use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
struct FilterTestCapability {
priority: i32,
}
impl Capability for FilterTestCapability {
fn id(&self) -> &str {
"filter_test"
}
fn name(&self) -> &str {
"Filter Test"
}
fn description(&self) -> &str {
"Test capability with message filter"
}
fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
Some(Arc::new(FilterTestProvider {
priority: self.priority,
}))
}
}
struct FilterTestProvider {
priority: i32,
}
impl MessageFilterProvider for FilterTestProvider {
fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
query
.filters
.push(MessageFilter::Search(search.to_string()));
}
}
fn priority(&self) -> i32 {
self.priority
}
}
#[tokio::test]
async fn test_collect_capabilities_with_configs_no_filter_providers() {
let registry = fixture_registry();
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("current_time"),
serde_json::json!({}),
)];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
assert!(collected.message_filter_providers.is_empty());
assert!(!collected.has_message_filters());
}
#[tokio::test]
async fn test_collected_capabilities_apply_message_filters() {
let mut registry = CapabilityRegistry::new();
registry.register(FilterTestCapability { priority: 0 });
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("filter_test"),
serde_json::json!({ "search": "test_query" }),
)];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
assert!(collected.has_message_filters());
let session_id: SessionId = Uuid::now_v7().into();
let mut query = MessageQuery::new(session_id);
collected.apply_message_filters(&mut query);
assert_eq!(query.filters.len(), 1);
assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
}
#[tokio::test]
async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
struct SearchCapability {
id: &'static str,
search_term: &'static str,
priority: i32,
}
struct SearchProvider {
search_term: &'static str,
priority: i32,
}
impl MessageFilterProvider for SearchProvider {
fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
query
.filters
.push(MessageFilter::Search(self.search_term.to_string()));
}
fn priority(&self) -> i32 {
self.priority
}
}
impl Capability for SearchCapability {
fn id(&self) -> &str {
self.id
}
fn name(&self) -> &str {
"Search"
}
fn description(&self) -> &str {
"Test"
}
fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
Some(Arc::new(SearchProvider {
search_term: self.search_term,
priority: self.priority,
}))
}
}
let mut registry = CapabilityRegistry::new();
registry.register(SearchCapability {
id: "cap_a",
search_term: "alpha",
priority: 5,
});
registry.register(SearchCapability {
id: "cap_b",
search_term: "beta",
priority: 1,
});
registry.register(SearchCapability {
id: "cap_c",
search_term: "gamma",
priority: 10,
});
let configs = vec![
AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
let session_id: SessionId = Uuid::now_v7().into();
let mut query = MessageQuery::new(session_id);
collected.apply_message_filters(&mut query);
assert_eq!(query.filters.len(), 3);
assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
}
#[tokio::test]
async fn test_collect_capabilities_preserves_config_for_filter_provider() {
let mut registry = CapabilityRegistry::new();
registry.register(FilterTestCapability { priority: 0 });
let test_config = serde_json::json!({
"search": "custom_search",
"extra_field": 42
});
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("filter_test"),
test_config.clone(),
)];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
assert_eq!(collected.message_filter_providers.len(), 1);
let (_, stored_config) = &collected.message_filter_providers[0];
assert_eq!(*stored_config, test_config);
}
#[test]
fn test_collect_message_filters_only_collects_filters() {
let mut registry = CapabilityRegistry::new();
registry.register(FilterTestCapability { priority: 0 });
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("filter_test"),
serde_json::json!({ "search": "test_query" }),
)];
let collected = collect_message_filters_only(&configs, ®istry);
let session_id: SessionId = Uuid::now_v7().into();
let mut query = MessageQuery::new(session_id);
collected.apply_message_filters(&mut query);
assert_eq!(query.filters.len(), 1);
assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
}
#[test]
fn test_collect_message_filters_only_skips_unknown_capabilities() {
let registry = CapabilityRegistry::new();
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("nonexistent"),
serde_json::json!({}),
)];
let collected = collect_message_filters_only(&configs, ®istry);
assert!(collected.message_filter_providers.is_empty());
}
#[test]
fn test_collect_message_filters_only_preserves_priority_order() {
struct PriorityFilterCap {
id: &'static str,
search_term: &'static str,
priority: i32,
}
struct PriorityFilterProvider {
search_term: &'static str,
priority: i32,
}
impl Capability for PriorityFilterCap {
fn id(&self) -> &str {
self.id
}
fn name(&self) -> &str {
self.id
}
fn description(&self) -> &str {
"priority test"
}
fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
Some(Arc::new(PriorityFilterProvider {
search_term: self.search_term,
priority: self.priority,
}))
}
}
impl MessageFilterProvider for PriorityFilterProvider {
fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
query
.filters
.push(MessageFilter::Search(self.search_term.to_string()));
}
fn priority(&self) -> i32 {
self.priority
}
}
let mut registry = CapabilityRegistry::new();
registry.register(PriorityFilterCap {
id: "gamma",
search_term: "gamma",
priority: 10,
});
registry.register(PriorityFilterCap {
id: "alpha",
search_term: "alpha",
priority: 5,
});
registry.register(PriorityFilterCap {
id: "beta",
search_term: "beta",
priority: 1,
});
let configs = vec![
AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
];
let collected = collect_message_filters_only(&configs, ®istry);
let session_id: SessionId = Uuid::now_v7().into();
let mut query = MessageQuery::new(session_id);
collected.apply_message_filters(&mut query);
assert_eq!(query.filters.len(), 3);
assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
}
#[test]
fn test_collect_message_filters_only_post_load_invoked() {
use crate::message::Message;
struct PostLoadCap;
struct PostLoadProvider;
impl Capability for PostLoadCap {
fn id(&self) -> &str {
"post_load_test"
}
fn name(&self) -> &str {
"PostLoad Test"
}
fn description(&self) -> &str {
"test"
}
fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
Some(Arc::new(PostLoadProvider))
}
}
impl MessageFilterProvider for PostLoadProvider {
fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
fn priority(&self) -> i32 {
0
}
fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
messages.reverse();
}
}
let mut registry = CapabilityRegistry::new();
registry.register(PostLoadCap);
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("post_load_test"),
serde_json::json!({}),
)];
let collected = collect_message_filters_only(&configs, ®istry);
let mut messages = vec![Message::user("first"), Message::user("second")];
collected.apply_post_load_filters(&mut messages);
assert_eq!(messages[0].text(), Some("second"));
assert_eq!(messages[1].text(), Some("first"));
}
struct DelegatingFilterCap {
id: &'static str,
inner: std::sync::Arc<InnerFilterCap>,
}
struct InnerFilterCap;
impl Capability for InnerFilterCap {
fn id(&self) -> &str {
"inner_filter"
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
panic!("fast-path collection must not instantiate tools")
}
fn system_prompt_addition(&self) -> Option<&str> {
panic!("fast-path collection must not collect prompts")
}
fn name(&self) -> &str {
"Inner Filter"
}
fn description(&self) -> &str {
"inner"
}
fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
Some(std::sync::Arc::new(SentinelFilter))
}
}
struct SentinelFilter;
impl MessageFilterProvider for SentinelFilter {
fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
query.limit = config["limit"].as_i64();
}
}
impl Capability for DelegatingFilterCap {
fn id(&self) -> &str {
self.id
}
fn name(&self) -> &str {
"Delegating Filter"
}
fn description(&self) -> &str {
"delegating"
}
fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
None }
fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
Some(&*self.inner)
}
}
#[test]
fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
let inner = std::sync::Arc::new(InnerFilterCap);
let outer = DelegatingFilterCap {
id: "delegating_filter",
inner: inner.clone(),
};
let mut registry = CapabilityRegistry::new();
registry.register(outer);
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("delegating_filter"),
serde_json::json!({"limit": 17}),
)];
let collected = collect_message_filters_only(&configs, ®istry);
assert_eq!(
collected.message_filter_providers.len(),
1,
"provider from resolved inner capability must be collected"
);
let mut query = MessageQuery::default();
collected.apply_message_filters(&mut query);
assert_eq!(query.limit, Some(17));
}
struct DelegatingMvpCap {
id: &'static str,
inner: std::sync::Arc<InnerMvpCap>,
}
struct InnerMvpCap;
impl Capability for InnerMvpCap {
fn id(&self) -> &str {
"inner_mvp"
}
fn tools(&self) -> Vec<Box<dyn Tool>> {
panic!("fast-path collection must not instantiate tools")
}
fn system_prompt_addition(&self) -> Option<&str> {
panic!("fast-path collection must not collect prompts")
}
fn name(&self) -> &str {
"Inner MVP"
}
fn description(&self) -> &str {
"inner"
}
fn model_view_provider(
&self,
) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
struct AppendingMvp;
impl crate::capabilities::ModelViewProvider for AppendingMvp {
fn apply_model_view(
&self,
mut messages: Vec<Message>,
config: &serde_json::Value,
context: &ModelViewContext<'_>,
) -> Vec<Message> {
messages.push(Message::user(format!(
"{}:{}",
config["suffix"].as_str().unwrap(),
context.session_id
)));
messages
}
}
Some(std::sync::Arc::new(AppendingMvp))
}
}
impl Capability for DelegatingMvpCap {
fn id(&self) -> &str {
self.id
}
fn name(&self) -> &str {
"Delegating MVP"
}
fn description(&self) -> &str {
"delegating"
}
fn model_view_provider(
&self,
) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
None }
fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
(model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
}
}
#[test]
fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
let inner = std::sync::Arc::new(InnerMvpCap);
let outer = DelegatingMvpCap {
id: "delegating_mvp",
inner: inner.clone(),
};
let mut registry = CapabilityRegistry::new();
registry.register(outer);
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("delegating_mvp"),
serde_json::json!({"suffix": "delegated"}),
)];
let collected = collect_model_view_providers(&configs, ®istry, Some("selected-model"));
assert_eq!(
collected.model_view_providers.len(),
1,
"provider from resolved inner capability must be collected"
);
assert!(
collect_model_view_providers(&configs, ®istry, Some("other-model"))
.model_view_providers
.is_empty()
);
let session_id = SessionId::from_seed(42);
let output = collected.apply_model_view(
vec![Message::user("original")],
&ModelViewContext {
session_id,
prior_usage: None,
},
);
assert_eq!(
output.iter().map(Message::text).collect::<Vec<_>>(),
[
Some("original"),
Some(format!("delegated:{session_id}").as_str())
]
);
}
#[test]
fn test_defaults_do_not_include_bash() {
let registry = crate::ToolRegistry::with_defaults();
assert!(
!registry.has("bash"),
"with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
);
}
#[test]
fn test_alias_resolves_to_canonical_capability() {
let registry = fixture_registry();
let via_alias = registry.get("virtual_bash").unwrap();
assert_eq!(via_alias.id(), "bashkit_shell");
assert!(registry.has("virtual_bash"));
assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
assert_eq!(
registry.canonical_id("bashkit_shell"),
Some("bashkit_shell")
);
assert_eq!(registry.canonical_id("nonexistent"), None);
}
#[test]
fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
let registry = fixture_registry();
let resolved = resolve_dependencies(
&["virtual_bash".to_string(), "bashkit_shell".to_string()],
®istry,
)
.unwrap();
let bash_ids: Vec<_> = resolved
.resolved_ids
.iter()
.filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
.collect();
assert_eq!(bash_ids, vec!["bashkit_shell"]);
assert!(
!resolved
.added_as_dependencies
.contains(&"bashkit_shell".to_string())
);
}
#[test]
fn test_alias_preserves_explicit_config_in_resolution() {
let registry = fixture_registry();
let configs = vec![AgentCapabilityConfig::with_config(
"virtual_bash".to_string(),
serde_json::json!({"key": "value"}),
)];
let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
let bash = resolved
.iter()
.find(|c| c.capability_id() == "bashkit_shell")
.expect("alias must resolve to canonical bashkit_shell config");
assert_eq!(
bash.config_value().clone(),
serde_json::json!({"key": "value"})
);
}
#[test]
fn test_unregister_by_alias_removes_capability_and_aliases() {
let mut registry = fixture_registry();
assert!(registry.unregister("virtual_bash").is_some());
assert!(!registry.has("bashkit_shell"));
assert!(!registry.has("virtual_bash"));
}
#[test]
fn test_compute_features_empty() {
let registry = CapabilityRegistry::new();
let features = compute_features(&[], ®istry);
assert!(features.is_empty());
}
#[test]
fn test_compute_features_unknown_capability_ignored() {
let registry = fixture_registry();
let features = compute_features(
&["unknown_cap".to_string(), "session_storage".to_string()],
®istry,
);
assert_eq!(features, vec!["secrets", "key_value"]);
}
#[test]
fn test_risk_level_ordering() {
assert!(RiskLevel::Low < RiskLevel::Medium);
assert!(RiskLevel::Medium < RiskLevel::High);
}
#[test]
fn test_risk_level_serde_roundtrip() {
for (level, wire) in [
(RiskLevel::Low, "\"low\""),
(RiskLevel::Medium, "\"medium\""),
(RiskLevel::High, "\"high\""),
] {
assert_eq!(serde_json::to_string(&level).unwrap(), wire);
assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
}
assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
}
struct SkillContributingCapability;
impl Capability for SkillContributingCapability {
fn id(&self) -> &str {
"contributes_skills"
}
fn name(&self) -> &str {
"Contributes Skills"
}
fn description(&self) -> &str {
"Test capability that contributes skills."
}
fn contribute_skills(&self) -> Vec<SkillContribution> {
vec![
SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
.with_files(vec![(
"scripts/a.sh".to_string(),
"#!/bin/sh\necho a\n".to_string(),
)]),
SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
.with_user_invocable(false),
]
}
}
fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
match &entries.get("SKILL.md").expect("SKILL.md missing").source {
MountSource::InlineFile { content, .. } => content.as_str(),
_ => panic!("Expected InlineFile for SKILL.md"),
}
}
#[tokio::test]
async fn test_contribute_skills_normalized_to_mounts() {
let mut registry = CapabilityRegistry::new();
registry.register(SkillContributingCapability);
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("contributes_skills"),
serde_json::json!({}),
)];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
let skill_mounts: Vec<_> = collected
.mounts
.iter()
.filter(|m| m.path.starts_with("/.agents/skills/"))
.collect();
assert_eq!(skill_mounts.len(), 2);
for m in &skill_mounts {
assert!(m.is_readonly());
assert_eq!(m.capability_id, "contributes_skills");
}
let alpha = skill_mounts
.iter()
.find(|m| m.path == "/.agents/skills/alpha-skill")
.expect("alpha-skill mount missing");
match &alpha.source {
MountSource::InlineDirectory { entries } => {
assert!(entries.contains_key("SKILL.md"));
assert!(entries.contains_key("scripts/a.sh"));
let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
assert_eq!(parsed.name, "alpha-skill");
assert_eq!(parsed.description, "Alpha skill desc");
assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
assert!(parsed.user_invocable);
}
_ => panic!("Expected InlineDirectory"),
}
let beta = skill_mounts
.iter()
.find(|m| m.path == "/.agents/skills/beta-skill")
.expect("beta-skill mount missing");
match &beta.source {
MountSource::InlineDirectory { entries } => {
let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
assert!(!parsed.user_invocable);
assert_eq!(parsed.name, "beta-skill");
assert_eq!(parsed.instructions, "# Beta\nDo beta.");
}
_ => panic!("Expected InlineDirectory"),
}
}
#[tokio::test]
async fn test_contribute_skills_default_empty() {
let mut registry = CapabilityRegistry::new();
registry.register(FilterTestCapability { priority: 0 });
let configs = vec![AgentCapabilityConfig::with_config(
CapabilityId::new("filter_test"),
serde_json::json!({}),
)];
let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
assert!(
collected
.mounts
.iter()
.all(|m| !m.path.starts_with("/.agents/skills/"))
);
}
struct LocalizedCapability;
impl Capability for LocalizedCapability {
fn id(&self) -> &str {
"localized"
}
fn name(&self) -> &str {
"Localized"
}
fn description(&self) -> &str {
"English description"
}
fn localizations(&self) -> Vec<CapabilityLocalization> {
vec![
CapabilityLocalization {
locale: "en",
name: None,
description: None,
config_description: Some("Controls things."),
config_overlay: None,
},
CapabilityLocalization {
locale: "uk-UA",
name: Some("Регіональна"),
description: None,
config_description: None,
config_overlay: None,
},
CapabilityLocalization {
locale: "uk",
name: Some("Локалізована"),
description: Some("Український опис"),
config_description: Some("Керує налаштуваннями."),
config_overlay: None,
},
]
}
}
#[test]
fn localized_name_falls_back_exact_language_then_base() {
let cap = LocalizedCapability;
assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
assert_eq!(cap.localized_name(None), "Localized");
assert_eq!(cap.localized_description(Some("uk")), "Український опис");
assert_eq!(cap.localized_description(Some("de")), "English description");
}
#[test]
fn describe_schema_resolves_config_description_per_locale() {
let cap = LocalizedCapability;
assert_eq!(
cap.describe_schema(Some("uk-UA")).as_deref(),
Some("Керує налаштуваннями.")
);
assert_eq!(
cap.describe_schema(Some("pl")).as_deref(),
Some("Controls things.")
);
assert_eq!(
cap.describe_schema(None).as_deref(),
Some("Controls things.")
);
assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
}
#[tokio::test]
async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
let registry = fixture_registry();
for (ids, expected) in [
(
vec!["test_math"],
vec![
("add", "test_math", "Test Math"),
("subtract", "test_math", "Test Math"),
("multiply", "test_math", "Test Math"),
("divide", "test_math", "Test Math"),
],
),
(
vec!["test_weather"],
vec![
("get_weather", "test_weather", "Test Weather"),
("get_forecast", "test_weather", "Test Weather"),
],
),
(
vec!["sample_data"],
vec![
("read_file", "session_file_system", "Fixture Filesystem"),
("write_file", "session_file_system", "Fixture Filesystem"),
],
),
(
vec!["bashkit_shell", "test_weather"],
vec![
("read_file", "session_file_system", "Fixture Filesystem"),
("write_file", "session_file_system", "Fixture Filesystem"),
("bash", "bashkit_shell", "Fixture Bash"),
("get_weather", "test_weather", "Test Weather"),
("get_forecast", "test_weather", "Test Weather"),
],
),
] {
let ids: Vec<_> = ids.into_iter().map(String::from).collect();
let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
assert_eq!(
collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
);
assert_eq!(collected.tool_definitions.len(), expected.len());
for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
assert_eq!(definition.name(), name);
let hints = definition.hints();
assert_eq!(hints.capability_id.as_deref(), Some(id));
assert_eq!(hints.capability_name.as_deref(), Some(label));
let ToolDefinition::Builtin(tool) = definition else {
panic!("expected builtin")
};
let schema = if name == "bash" {
serde_json::json!({"type":"object"})
} else {
serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
};
assert_eq!(tool.parameters, schema);
}
}
}
#[tokio::test]
async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
let registry = fixture_registry();
let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
assert_eq!(collected.system_prompt_parts, vec![first, second]);
assert_eq!(
collected.system_prompt_attributions,
vec![
SystemPromptAttribution {
capability_id: ids[0].clone(),
content: first.into()
},
SystemPromptAttribution {
capability_id: ids[1].clone(),
content: second.into()
}
]
);
assert_eq!(
collected.system_prompt_prefix(),
Some(format!("{first}\n\n{second}"))
);
let applied = apply_capabilities(
RuntimeAgent::new("Base.", "fixture-model"),
&ids,
®istry,
&test_ctx(),
)
.await;
assert_eq!(
applied.runtime_agent.system_prompt,
format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
);
assert!(applied.tool_registry.has("write_todos"));
assert_eq!(applied.tool_registry.len(), 1);
for (base, addition, expected) in [
("Base.", None, "Base."),
("Base.", Some(""), "Base."),
("", Some("Extra."), "Extra."),
(
"<system-prompt>Base.</system-prompt>",
Some("Extra."),
"<system-prompt>Base.</system-prompt>\n\nExtra.",
),
] {
assert_eq!(compose_system_prompt(base, addition), expected);
}
}
struct DependencyFixture {
id: String,
deps: Vec<&'static str>,
features: Vec<&'static str>,
}
impl Capability for DependencyFixture {
fn id(&self) -> &str {
&self.id
}
fn name(&self) -> &str {
&self.id
}
fn description(&self) -> &str {
"Dependency fixture"
}
fn dependencies(&self) -> Vec<&'static str> {
self.deps.clone()
}
fn features(&self) -> Vec<&'static str> {
self.features.clone()
}
}
#[test]
fn feature_projection_preserves_order_and_distinct_dependency_features() {
let mut registry = CapabilityRegistry::new();
registry.register(DependencyFixture {
id: "base".into(),
deps: vec![],
features: vec!["base-only", "shared"],
});
registry.register(DependencyFixture {
id: "parent".into(),
deps: vec!["base"],
features: vec!["parent-only", "shared"],
});
registry.register(DependencyFixture {
id: "other".into(),
deps: vec![],
features: vec!["other-only"],
});
assert_eq!(
compute_features(&["parent".into()], ®istry),
vec!["base-only", "shared", "parent-only"]
);
assert_eq!(
compute_features(
&[
"other".into(),
"parent".into(),
"base".into(),
"parent".into()
],
®istry
),
vec!["other-only", "base-only", "shared", "parent-only"]
);
}
#[test]
fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
let mut registry = CapabilityRegistry::new();
let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
for id in &ids {
registry.register(DependencyFixture {
id: id.clone(),
deps: vec![],
features: vec![],
});
}
let resolved = resolve_dependencies(&ids[..100], ®istry).unwrap();
assert_eq!(resolved.resolved_ids, ids[..100]);
assert_eq!(resolved.user_selected, ids[..100]);
assert!(resolved.added_as_dependencies.is_empty());
assert_eq!(
resolve_dependencies(&ids, ®istry).unwrap_err(),
DependencyError::TooManyCapabilities {
count: 101,
max: 100
}
);
}
}