1use crate::command::{
22 CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
23};
24use crate::events::TokenUsage;
25use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
26use crate::message::Message;
27use crate::message_filter::MessageFilterProvider;
28use crate::runtime_agent::RuntimeAgent;
29use crate::tool_types::{ToolCall, ToolDefinition};
30use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
31use crate::typed_id::SessionId;
32use crate::{session_files::SessionFileSystem, tool_context::ToolContext};
33use async_trait::async_trait;
34use everruns_capability::is_plugin_capability;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::sync::Arc;
38
39pub struct IntegrationPlugin {
64 pub experimental_only: bool,
66 pub feature_flag: Option<&'static str>,
71 pub factory: fn() -> Box<dyn Capability>,
73}
74
75inventory::collect!(IntegrationPlugin);
76
77pub use crate::capability_types::{
78 CapabilityStatus, MountAccess, MountDirectoryBuilder, MountEntry, MountPoint, MountSource,
79};
80use everruns_capability::{CapabilityId, CapabilityRef as AgentCapabilityConfig};
81
82mod declarative;
87pub mod facts;
88pub mod skill_contribution;
89pub mod util;
90
91pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
96pub const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
100pub const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
104pub use declarative::{
105 DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
106 DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
107 declarative_capability_info, hydrate_declarative_capability_config,
108 hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
109 plugin_capability_info, validate_declarative_capability_definition,
110};
111pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
112pub use skill_contribution::{
113 MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
114 SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
115 discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
116 reconstruct_skill_md, skill_capability_id,
117};
118
119pub struct SystemPromptContext {
129 pub session_id: SessionId,
131 pub locale: Option<String>,
133 pub file_store: Option<Arc<dyn SessionFileSystem>>,
135 pub model: Option<String>,
141}
142
143impl SystemPromptContext {
144 pub fn without_file_store(session_id: SessionId) -> Self {
146 Self {
147 session_id,
148 locale: None,
149 file_store: None,
150 model: None,
151 }
152 }
153
154 pub fn with_model(mut self, model: impl Into<String>) -> Self {
156 self.model = Some(model.into());
157 self
158 }
159}
160
161#[derive(Debug, Clone)]
213pub struct CapabilityLocalization {
214 pub locale: &'static str,
216 pub name: Option<&'static str>,
218 pub description: Option<&'static str>,
220 pub config_description: Option<&'static str>,
225 pub config_overlay: Option<serde_json::Value>,
231}
232
233impl CapabilityLocalization {
234 pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
236 Self {
237 locale,
238 name: Some(name),
239 description: Some(description),
240 config_description: None,
241 config_overlay: None,
242 }
243 }
244}
245
246pub fn resolve_localized_field<T>(
250 localizations: &[CapabilityLocalization],
251 locale: Option<&str>,
252 field: impl Fn(&CapabilityLocalization) -> Option<T>,
253) -> Option<T> {
254 let mut candidates: Vec<String> = Vec::new();
255 if let Some(raw) = locale {
256 let normalized = raw.trim().replace('_', "-").to_lowercase();
257 if !normalized.is_empty() {
258 if let Some((language, _)) = normalized.split_once('-') {
259 let language = language.to_string();
260 candidates.push(normalized);
261 candidates.push(language);
262 } else {
263 candidates.push(normalized);
264 }
265 }
266 }
267 candidates.push("en".to_string());
268
269 for candidate in candidates {
270 let hit = localizations
271 .iter()
272 .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
273 .and_then(&field);
274 if hit.is_some() {
275 return hit;
276 }
277 }
278 None
279}
280
281#[async_trait]
282pub trait Capability: Send + Sync {
283 fn id(&self) -> &str;
285
286 fn aliases(&self) -> Vec<&'static str> {
295 vec![]
296 }
297
298 fn name(&self) -> &str;
300
301 fn description(&self) -> &str;
303
304 fn localizations(&self) -> Vec<CapabilityLocalization> {
309 vec![]
310 }
311
312 fn localized_name(&self, locale: Option<&str>) -> String {
315 resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
316 .unwrap_or_else(|| self.name())
317 .to_string()
318 }
319
320 fn localized_description(&self, locale: Option<&str>) -> String {
322 resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
323 .unwrap_or_else(|| self.description())
324 .to_string()
325 }
326
327 fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
331 resolve_localized_field(&self.localizations(), locale, |entry| {
332 entry.config_description
333 })
334 .map(str::to_string)
335 }
336
337 fn status(&self) -> CapabilityStatus {
339 CapabilityStatus::Available
340 }
341
342 fn icon(&self) -> Option<&str> {
344 None
345 }
346
347 fn category(&self) -> Option<&str> {
349 None
350 }
351
352 fn metadata(&self) -> Option<serde_json::Value> {
364 None
365 }
366
367 fn is_guardrail(&self) -> bool {
372 false
373 }
374
375 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
386 None
387 }
388
389 fn system_prompt_addition(&self) -> Option<&str> {
409 None
410 }
411
412 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
424 self.system_prompt_addition().map(|addition| {
425 format!(
426 "<capability id=\"{}\">\n{}\n</capability>",
427 self.id(),
428 addition
429 )
430 })
431 }
432
433 fn system_prompt_preview(&self) -> Option<String> {
439 self.system_prompt_addition().map(|s| s.to_string())
440 }
441
442 fn tools(&self) -> Vec<Box<dyn Tool>> {
444 vec![]
445 }
446
447 fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
455 self.tools()
456 }
457
458 fn delegation_target_with_config(
464 &self,
465 _config: &serde_json::Value,
466 ) -> Option<DelegationTargetProvider> {
467 None
468 }
469
470 fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
474 false
475 }
476
477 async fn system_prompt_contribution_with_config(
484 &self,
485 ctx: &SystemPromptContext,
486 _config: &serde_json::Value,
487 ) -> Option<String> {
488 self.system_prompt_contribution(ctx).await
489 }
490
491 async fn conversation_context_contribution(
502 &self,
503 _ctx: &SystemPromptContext,
504 ) -> Option<String> {
505 None
506 }
507
508 async fn conversation_context_contribution_with_config(
513 &self,
514 ctx: &SystemPromptContext,
515 _config: &serde_json::Value,
516 ) -> Option<String> {
517 self.conversation_context_contribution(ctx).await
518 }
519
520 fn tool_definitions(&self) -> Vec<ToolDefinition> {
523 self.tools().iter().map(|t| t.to_definition()).collect()
524 }
525
526 fn mounts(&self) -> Vec<MountPoint> {
534 vec![]
535 }
536
537 fn dependencies(&self) -> Vec<&'static str> {
546 vec![]
547 }
548
549 fn features(&self) -> Vec<&'static str> {
564 vec![]
565 }
566
567 fn config_schema(&self) -> Option<serde_json::Value> {
573 None
574 }
575
576 fn config_ui_schema(&self) -> Option<serde_json::Value> {
581 None
582 }
583
584 fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
590 Ok(())
591 }
592
593 fn mcp_servers(&self) -> ScopedMcpServers {
599 ScopedMcpServers::default()
600 }
601
602 fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
604 self.mcp_servers()
605 }
606
607 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
620 None
621 }
622
623 fn message_filter_config(
628 &self,
629 config: &serde_json::Value,
630 _compaction_enabled: bool,
631 ) -> serde_json::Value {
632 config.clone()
633 }
634
635 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
643 None
644 }
645
646 fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
658 None
659 }
660
661 fn tool_search_config(
665 &self,
666 _config: &serde_json::Value,
667 ) -> Option<crate::driver_registry::ToolSearchConfig> {
668 None
669 }
670
671 fn prompt_cache_config(
674 &self,
675 _config: &serde_json::Value,
676 ) -> Option<crate::driver_registry::PromptCacheConfig> {
677 None
678 }
679
680 fn openrouter_routing_config(
686 &self,
687 _config: &serde_json::Value,
688 ) -> Option<crate::driver_registry::OpenRouterRoutingConfig> {
689 None
690 }
691
692 fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
695 None
696 }
697
698 fn error_disclosure(
700 &self,
701 _config: &serde_json::Value,
702 ) -> Option<crate::user_facing_error::ErrorDisclosure> {
703 None
704 }
705
706 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
709 text
710 }
711
712 fn compaction_policy(
716 &self,
717 _config: &serde_json::Value,
718 ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
719 None
720 }
721
722 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
737 vec![]
738 }
739
740 fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
751 vec![]
752 }
753
754 fn pre_tool_use_hooks_with_config(
759 &self,
760 _config: &serde_json::Value,
761 ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
762 self.pre_tool_use_hooks()
763 }
764
765 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
773 vec![]
774 }
775
776 fn post_tool_exec_hooks_with_config(
781 &self,
782 _config: &serde_json::Value,
783 ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
784 self.post_tool_exec_hooks()
785 }
786
787 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
796 vec![]
797 }
798
799 fn tool_definition_hooks_with_config(
804 &self,
805 _config: &serde_json::Value,
806 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
807 self.tool_definition_hooks()
808 }
809
810 fn tool_definition_hooks_with_context(
820 &self,
821 _ctx: &SystemPromptContext,
822 config: &serde_json::Value,
823 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
824 self.tool_definition_hooks_with_config(config)
825 }
826
827 fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
835 vec![]
836 }
837
838 fn finalized_tool_calls_hook(
842 &self,
843 _config: &serde_json::Value,
844 ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
845 None
846 }
847
848 fn narrate(
862 &self,
863 _tool_def: Option<&ToolDefinition>,
864 tool_call: &ToolCall,
865 phase: crate::tool_narration::ToolNarrationPhase,
866 locale: Option<&str>,
867 ctx: crate::tool_narration::ToolNarrationContext<'_>,
868 ) -> Option<String> {
869 self.tools()
870 .iter()
871 .find(|tool| tool.name() == tool_call.name)
872 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
873 }
874
875 fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
891 vec![]
892 }
893
894 fn user_hooks_with_config(
900 &self,
901 _config: &serde_json::Value,
902 ) -> Vec<crate::user_hook_types::UserHookSpec> {
903 self.user_hooks()
904 }
905
906 fn risk_level(&self) -> RiskLevel {
914 RiskLevel::Low
915 }
916
917 fn commands(&self) -> Vec<CommandDescriptor> {
925 vec![]
926 }
927
928 async fn execute_command(
942 &self,
943 request: &ExecuteCommandRequest,
944 _ctx: &CommandExecutionContext,
945 ) -> crate::error::Result<CommandResult> {
946 Err(crate::error::AgentLoopError::config(format!(
947 "capability {} declared command /{} but does not implement execute_command",
948 self.id(),
949 request.name,
950 )))
951 }
952
953 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
962 vec![]
963 }
964
965 fn contribute_skills(&self) -> Vec<SkillContribution> {
975 vec![]
976 }
977
978 fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
989 vec![]
990 }
991
992 fn post_output_guardrails_with_config(
1004 &self,
1005 _config: &serde_json::Value,
1006 ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
1007 vec![]
1008 }
1009
1010 fn post_output_annotation_hooks_with_config(
1026 &self,
1027 _config: &serde_json::Value,
1028 ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1029 vec![]
1030 }
1031
1032 fn citation_verifier_with_config(
1042 &self,
1043 _config: &serde_json::Value,
1044 ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1045 None
1046 }
1047}
1048
1049pub trait ToolDefinitionHook: Send + Sync {
1050 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1051
1052 fn applies_with_native_tool_search(&self) -> bool {
1057 true
1058 }
1059}
1060
1061pub trait ToolCallHook: Send + Sync {
1062 fn narration(
1063 &self,
1064 _tool_def: Option<&ToolDefinition>,
1065 _tool_call: &ToolCall,
1066 _phase: crate::tool_narration::ToolNarrationPhase,
1067 _locale: Option<&str>,
1068 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1069 ) -> Option<String> {
1070 None
1071 }
1072
1073 fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1074 tool_call
1075 }
1076}
1077
1078pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1084
1085impl ToolCallHook for CapabilityNarrationHook {
1086 fn narration(
1087 &self,
1088 tool_def: Option<&ToolDefinition>,
1089 tool_call: &ToolCall,
1090 phase: crate::tool_narration::ToolNarrationPhase,
1091 locale: Option<&str>,
1092 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1093 ) -> Option<String> {
1094 self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1095 }
1096}
1097
1098#[derive(
1102 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1103)]
1104#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1105#[cfg_attr(feature = "openapi", schema(example = "low"))]
1106#[serde(rename_all = "lowercase")]
1107pub enum RiskLevel {
1108 Low,
1110 Medium,
1112 High,
1114}
1115
1116#[derive(Debug, Clone, Serialize, Deserialize)]
1122#[serde(rename_all = "snake_case")]
1123pub enum BlueprintModel {
1124 Fixed(String),
1126 Default(String),
1128 Inherit,
1130}
1131
1132pub struct AgentBlueprint {
1138 pub id: &'static str,
1140 pub name: &'static str,
1142 pub description: &'static str,
1144 pub model: BlueprintModel,
1146 pub system_prompt: &'static str,
1148 pub tools: Vec<Box<dyn Tool>>,
1150 pub max_turns: Option<usize>,
1152 pub config_schema: Option<serde_json::Value>,
1154}
1155
1156impl AgentBlueprint {
1157 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1159 self.tools.iter().map(|t| t.to_definition()).collect()
1160 }
1161}
1162
1163impl std::fmt::Debug for AgentBlueprint {
1164 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1165 f.debug_struct("AgentBlueprint")
1166 .field("id", &self.id)
1167 .field("name", &self.name)
1168 .field("model", &self.model)
1169 .field("tool_count", &self.tools.len())
1170 .field("max_turns", &self.max_turns)
1171 .finish()
1172 }
1173}
1174
1175#[derive(Clone)]
1193pub struct CapabilityRegistry {
1194 capabilities: HashMap<String, Arc<dyn Capability>>,
1195 index: everruns_capability::CapabilityIdIndex,
1199}
1200
1201impl CapabilityRegistry {
1202 pub fn new() -> Self {
1204 Self {
1205 capabilities: HashMap::new(),
1206 index: everruns_capability::CapabilityIdIndex::new(),
1207 }
1208 }
1209
1210 pub fn register(&mut self, capability: impl Capability + 'static) {
1212 self.register_arc(Arc::new(capability));
1213 }
1214
1215 pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1217 self.register_arc(Arc::from(capability));
1218 }
1219
1220 pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1226 let canonical = capability.id().to_string();
1227 self.index
1228 .insert_or_replace(canonical.clone(), &capability.aliases());
1229 self.capabilities.insert(canonical, capability);
1230 }
1231
1232 pub fn try_register_arc(
1235 &mut self,
1236 capability: Arc<dyn Capability>,
1237 ) -> Result<(), everruns_capability::CapabilityError> {
1238 let canonical = capability.id().to_string();
1239 self.index
1240 .insert(canonical.clone(), &capability.aliases())?;
1241 self.capabilities.insert(canonical, capability);
1242 Ok(())
1243 }
1244
1245 pub fn register_inventory_plugins(
1250 &mut self,
1251 mut include: impl FnMut(&IntegrationPlugin) -> bool,
1252 ) {
1253 for plugin in inventory::iter::<IntegrationPlugin>() {
1254 if include(plugin) {
1255 self.register_boxed((plugin.factory)());
1256 }
1257 }
1258 }
1259
1260 pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1262 self.capabilities.get(self.index.canonical_of(id)?)
1263 }
1264
1265 pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1270 self.index.canonical_of(id)
1271 }
1272
1273 pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1275 let canonical = self.index.remove(id)?;
1276 self.capabilities.remove(&canonical)
1277 }
1278
1279 pub fn has(&self, id: &str) -> bool {
1281 self.get(id).is_some()
1282 }
1283
1284 pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1286 self.capabilities.values().collect()
1287 }
1288
1289 pub fn len(&self) -> usize {
1291 self.capabilities.len()
1292 }
1293
1294 pub fn is_empty(&self) -> bool {
1296 self.capabilities.is_empty()
1297 }
1298
1299 pub fn builder() -> CapabilityRegistryBuilder {
1301 CapabilityRegistryBuilder::new()
1302 }
1303
1304 pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1308 for cap in self.capabilities.values() {
1309 for bp in cap.agent_blueprints() {
1310 if bp.id == id {
1311 return Some(bp);
1312 }
1313 }
1314 }
1315 None
1316 }
1317
1318 pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1322 for (capability_id, cap) in &self.capabilities {
1323 for bp in cap.agent_blueprints() {
1324 if bp.id == id {
1325 return Some((capability_id.clone(), bp));
1326 }
1327 }
1328 }
1329 None
1330 }
1331
1332 pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1334 self.capabilities
1335 .values()
1336 .flat_map(|cap| cap.agent_blueprints())
1337 .collect()
1338 }
1339}
1340
1341impl Default for CapabilityRegistry {
1342 fn default() -> Self {
1343 Self::new()
1344 }
1345}
1346
1347impl std::fmt::Debug for CapabilityRegistry {
1348 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1349 let ids: Vec<_> = self.capabilities.keys().collect();
1350 f.debug_struct("CapabilityRegistry")
1351 .field("capabilities", &ids)
1352 .finish()
1353 }
1354}
1355
1356pub struct CapabilityRegistryBuilder {
1358 registry: CapabilityRegistry,
1359}
1360
1361impl CapabilityRegistryBuilder {
1362 pub fn new() -> Self {
1364 Self {
1365 registry: CapabilityRegistry::new(),
1366 }
1367 }
1368
1369 pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1371 self.registry.register(capability);
1372 self
1373 }
1374
1375 pub fn build(self) -> CapabilityRegistry {
1377 self.registry
1378 }
1379}
1380
1381impl Default for CapabilityRegistryBuilder {
1382 fn default() -> Self {
1383 Self::new()
1384 }
1385}
1386
1387pub struct ModelViewContext<'a> {
1393 pub session_id: SessionId,
1394 pub prior_usage: Option<&'a TokenUsage>,
1395}
1396
1397pub trait ModelViewProvider: Send + Sync {
1403 fn apply_model_view(
1404 &self,
1405 messages: Vec<Message>,
1406 config: &serde_json::Value,
1407 context: &ModelViewContext<'_>,
1408 ) -> Vec<Message>;
1409
1410 fn priority(&self) -> i32 {
1411 0
1412 }
1413}
1414
1415pub struct CollectedCapabilities {
1420 pub system_prompt_parts: Vec<String>,
1422 pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1424 pub conversation_context_parts: Vec<String>,
1431 pub conversation_context_attributions: Vec<SystemPromptAttribution>,
1433 pub tools: Vec<Box<dyn Tool>>,
1435 pub tool_definitions: Vec<ToolDefinition>,
1437 pub mounts: Vec<MountPoint>,
1439 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1441 pub applied_ids: Vec<String>,
1443 pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1445 pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1447 pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
1450 pub parallel_tool_calls: Option<bool>,
1454 pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1456 pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1458 pub mcp_servers: ScopedMcpServers,
1460 }
1466
1467#[derive(Debug, Clone, PartialEq, Eq)]
1468pub struct SystemPromptAttribution {
1469 pub capability_id: String,
1470 pub content: String,
1471}
1472
1473impl CollectedCapabilities {
1474 pub fn system_prompt_prefix(&self) -> Option<String> {
1477 if self.system_prompt_parts.is_empty() {
1478 None
1479 } else {
1480 Some(self.system_prompt_parts.join("\n\n"))
1481 }
1482 }
1483
1484 pub fn conversation_context(&self) -> Option<String> {
1488 if self.conversation_context_parts.is_empty() {
1489 None
1490 } else {
1491 Some(self.conversation_context_parts.join("\n\n"))
1492 }
1493 }
1494
1495 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1499 for (provider, config) in &self.message_filter_providers {
1501 provider.apply_filters(query, config);
1502 }
1503 }
1504
1505 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1508 for (provider, config) in &self.message_filter_providers {
1509 provider.post_load(messages, config);
1510 }
1511 }
1512
1513 pub fn has_message_filters(&self) -> bool {
1515 !self.message_filter_providers.is_empty()
1516 }
1517}
1518
1519pub struct DelegationTargetProvider {
1520 pub target_type: &'static str,
1521 pub tool: Box<dyn Tool>,
1522}
1523
1524#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1526#[serde(rename_all = "snake_case")]
1527pub enum SpawnMode {
1528 Background,
1529 Foreground,
1530}
1531
1532impl SpawnMode {
1533 pub fn parse(value: &str) -> Option<Self> {
1534 match value {
1535 "background" => Some(Self::Background),
1536 "foreground" => Some(Self::Foreground),
1537 _ => None,
1538 }
1539 }
1540
1541 pub fn as_str(self) -> &'static str {
1542 match self {
1543 Self::Background => "background",
1544 Self::Foreground => "foreground",
1545 }
1546 }
1547}
1548
1549struct UnifiedSpawnAgentTool {
1550 providers: Vec<DelegationTargetProvider>,
1551}
1552
1553fn validate_spawn_agent_target_fields(
1554 arguments: &serde_json::Value,
1555 target_type: &str,
1556) -> Result<(), String> {
1557 for field in ["blueprint", "config"] {
1558 if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
1559 return Err(format!(
1560 "{field} is only valid for subagent targets, not {target_type}."
1561 ));
1562 }
1563 }
1564 Ok(())
1565}
1566
1567impl UnifiedSpawnAgentTool {
1568 fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1569 Self { providers }
1570 }
1571
1572 fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1573 self.providers
1574 .iter()
1575 .find(|provider| provider.target_type == target_type)
1576 .map(|provider| provider.tool.as_ref())
1577 }
1578
1579 fn target_types(&self) -> Vec<&'static str> {
1580 ["subagent", "agent", "external_a2a"]
1581 .into_iter()
1582 .filter(|target_type| {
1583 self.providers
1584 .iter()
1585 .any(|provider| provider.target_type == *target_type)
1586 })
1587 .collect()
1588 }
1589
1590 fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1595 self.target_types()
1596 .into_iter()
1597 .filter_map(|target_type| match target_type {
1598 "subagent" => Some(serde_json::json!({
1599 "properties": {
1600 "type": {"const": "subagent"}
1601 }
1602 })),
1603 "agent" => Some(serde_json::json!({
1604 "properties": {
1605 "type": {"const": "agent"}
1606 },
1607 "required": ["type", "id"]
1608 })),
1609 "external_a2a" => Some(serde_json::json!({
1610 "properties": {
1611 "type": {"const": "external_a2a"}
1612 },
1613 "anyOf": [
1614 {"required": ["id"]},
1615 {"required": ["external_agent_id"]}
1616 ]
1617 })),
1618 _ => None,
1619 })
1620 .collect()
1621 }
1622
1623 }
1633
1634#[async_trait]
1635impl Tool for UnifiedSpawnAgentTool {
1636 fn narrate(
1637 &self,
1638 tool_call: &ToolCall,
1639 phase: crate::tool_narration::ToolNarrationPhase,
1640 locale: Option<&str>,
1641 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1642 ) -> Option<String> {
1643 let from_provider = tool_call
1647 .arguments
1648 .get("target")
1649 .and_then(|target| target.get("type"))
1650 .and_then(serde_json::Value::as_str)
1651 .and_then(|target_type| self.provider_for(target_type))
1652 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1653 Some(from_provider.unwrap_or_else(|| {
1654 crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1655 }))
1656 }
1657
1658 fn name(&self) -> &str {
1659 "spawn_agent"
1660 }
1661
1662 fn display_name(&self) -> Option<&str> {
1663 Some("Spawn Agent")
1664 }
1665
1666 fn description(&self) -> &str {
1667 "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."
1668 }
1669
1670 fn parameters_schema(&self) -> serde_json::Value {
1671 serde_json::json!({
1672 "type": "object",
1673 "properties": {
1674 "name": {
1675 "type": "string",
1676 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1677 },
1678 "instructions": {
1679 "type": "string",
1680 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1681 },
1682 "goal": {
1683 "type": "string",
1684 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1685 },
1686 "lifetime": {
1687 "type": "string",
1688 "enum": ["linked", "detached"],
1689 "default": "linked",
1690 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1691 },
1692 "seed": {
1693 "type": "string",
1694 "enum": ["fresh", "fork", "workspace"],
1695 "default": "fresh",
1696 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1697 },
1698 "target": {
1699 "type": "object",
1700 "properties": {
1701 "type": {
1702 "type": "string",
1703 "enum": self.target_types(),
1704 "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."
1705 },
1706 "id": {
1707 "type": "string",
1708 "description": "Configured target id for first-party handoffs or external A2A agents."
1709 },
1710 "external_agent_id": {
1711 "type": "string",
1712 "description": "Configured external A2A agent id."
1713 }
1714 },
1715 "required": ["type"],
1716 "oneOf": self.target_constraint_branches(),
1717 "additionalProperties": false
1718 },
1719 "mode": {
1720 "type": "string",
1721 "enum": ["background", "foreground"],
1722 "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."
1723 },
1724 "blueprint": {
1725 "type": "string",
1726 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1727 },
1728 "config": {
1729 "type": "object",
1730 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1731 },
1732 "result_schema": {
1733 "type": "object",
1734 "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."
1735 },
1736 "message_schema": {
1737 "type": "object",
1738 "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."
1739 },
1740 "public_context": {
1741 "type": "object",
1742 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1743 },
1744 "wait_timeout_secs": {
1745 "type": "integer",
1746 "minimum": 1,
1747 "maximum": 86400,
1748 "description": "External-A2A-only foreground timeout."
1749 },
1750 "wake_on_completion": {
1751 "type": "boolean",
1752 "description": "External-A2A-only control for background completion wake-ups."
1753 }
1754 },
1755 "required": ["name", "instructions", "target"],
1756 "additionalProperties": false
1757 })
1758 }
1759
1760 fn hints(&self) -> crate::tool_types::ToolHints {
1761 let mut hints = crate::tool_types::ToolHints::default()
1762 .with_long_running(true)
1763 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1764 if self.provider_for("external_a2a").is_some() {
1765 hints = hints.with_open_world(true);
1766 }
1767 hints
1768 }
1769
1770 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1771 ToolExecutionResult::tool_error(
1772 "spawn_agent requires context. This tool must be executed with session context.",
1773 )
1774 }
1775
1776 async fn execute_with_context(
1777 &self,
1778 arguments: serde_json::Value,
1779 context: &ToolContext,
1780 ) -> ToolExecutionResult {
1781 let target_type = match arguments
1782 .get("target")
1783 .and_then(|target| target.get("type"))
1784 .and_then(serde_json::Value::as_str)
1785 {
1786 Some(target_type) => target_type,
1787 None => {
1788 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1789 }
1790 };
1791
1792 let Some(provider) = self.provider_for(target_type) else {
1793 let supported = self.target_types().join(", ");
1794 return ToolExecutionResult::tool_error(format!(
1795 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1796 ));
1797 };
1798 if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
1799 return ToolExecutionResult::tool_error(error);
1800 }
1801 if target_type == "external_a2a"
1802 && arguments
1803 .get("lifetime")
1804 .and_then(serde_json::Value::as_str)
1805 .is_some_and(|value| value == "detached")
1806 {
1807 return ToolExecutionResult::tool_error(
1808 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1809 );
1810 }
1811 if target_type == "external_a2a"
1812 && arguments
1813 .get("message_schema")
1814 .is_some_and(|schema| !schema.is_null())
1815 {
1816 return ToolExecutionResult::tool_error(
1817 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1818 );
1819 }
1820
1821 provider.execute_with_context(arguments, context).await
1822 }
1823
1824 fn requires_context(&self) -> bool {
1825 true
1826 }
1827}
1828
1829pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1834 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1835 return base_system_prompt.to_string();
1836 };
1837
1838 if base_system_prompt.is_empty() {
1839 return additions.to_string();
1840 }
1841
1842 if base_system_prompt.contains("<system-prompt>") {
1843 format!("{base_system_prompt}\n\n{additions}")
1844 } else {
1845 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1846 }
1847}
1848
1849pub struct CollectedMessageFilters {
1856 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1858}
1859
1860pub struct CollectedModelViewProviders {
1862 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1864}
1865
1866impl CollectedMessageFilters {
1872 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1874 for (provider, config) in &self.message_filter_providers {
1875 provider.apply_filters(query, config);
1876 }
1877 }
1878
1879 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1881 for (provider, config) in &self.message_filter_providers {
1882 provider.post_load(messages, config);
1883 }
1884 }
1885}
1886
1887impl CollectedModelViewProviders {
1888 pub fn apply_model_view(
1890 &self,
1891 mut messages: Vec<Message>,
1892 context: &ModelViewContext<'_>,
1893 ) -> Vec<Message> {
1894 for (provider, config) in &self.model_view_providers {
1895 messages = provider.apply_model_view(messages, config, context);
1896 }
1897 messages
1898 }
1899}
1900
1901fn compaction_is_enabled(
1907 capability_configs: &[AgentCapabilityConfig],
1908 registry: &CapabilityRegistry,
1909) -> bool {
1910 capability_configs.iter().any(|cap_config| {
1911 registry.get(cap_config.capability_id()).is_some_and(|cap| {
1912 cap.status().is_active() && cap.compaction_policy(cap_config.config_value()).is_some()
1913 })
1914 })
1915}
1916
1917pub fn collect_message_filters_only(
1923 capability_configs: &[AgentCapabilityConfig],
1924 registry: &CapabilityRegistry,
1925) -> CollectedMessageFilters {
1926 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1927 Vec::new();
1928 let compaction_on = compaction_is_enabled(capability_configs, registry);
1929
1930 for cap_config in capability_configs {
1931 let cap_id = cap_config.capability_id();
1932 if let Some(capability) = registry.get(cap_id) {
1933 if !capability.status().is_active() {
1934 continue;
1935 }
1936 let effective: &dyn Capability = capability
1939 .resolve_for_model(None)
1940 .unwrap_or_else(|| capability.as_ref());
1941 if let Some(provider) = effective.message_filter_provider() {
1942 let config =
1943 effective.message_filter_config(cap_config.config_value(), compaction_on);
1944 message_filter_providers.push((provider, config));
1945 }
1946 }
1947 }
1948
1949 message_filter_providers.sort_by_key(|(p, _)| p.priority());
1950
1951 CollectedMessageFilters {
1952 message_filter_providers,
1953 }
1954}
1955
1956pub fn collect_model_view_providers(
1963 capability_configs: &[AgentCapabilityConfig],
1964 registry: &CapabilityRegistry,
1965 model: Option<&str>,
1966) -> CollectedModelViewProviders {
1967 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1968
1969 for cap_config in capability_configs {
1970 let cap_id = cap_config.capability_id();
1971 if let Some(capability) = registry.get(cap_id) {
1972 if !capability.status().is_active() {
1973 continue;
1974 }
1975 let effective: &dyn Capability = capability
1976 .resolve_for_model(model)
1977 .unwrap_or_else(|| capability.as_ref());
1978 if let Some(provider) = effective.model_view_provider() {
1979 model_view_providers.push((provider, cap_config.config_value().clone()));
1980 }
1981 }
1982 }
1983
1984 model_view_providers.sort_by_key(|(p, _)| p.priority());
1985
1986 CollectedModelViewProviders {
1987 model_view_providers,
1988 }
1989}
1990
1991pub fn collect_dynamic_facts(
1997 capability_configs: &[AgentCapabilityConfig],
1998 registry: &CapabilityRegistry,
1999 model: Option<&str>,
2000 ctx: &FactsContext,
2001) -> Vec<Fact> {
2002 let mut dynamic = Vec::new();
2003 for cap_config in capability_configs {
2004 let cap_id = cap_config.capability_id();
2005 if let Some(capability) = registry.get(cap_id) {
2006 if !capability.status().is_active() {
2007 continue;
2008 }
2009 let effective: &dyn Capability = capability
2010 .resolve_for_model(model)
2011 .unwrap_or_else(|| capability.as_ref());
2012 for fact in effective.facts(cap_config.config_value(), ctx) {
2013 if fact.volatility == Volatility::Dynamic {
2014 dynamic.push(fact);
2015 }
2016 }
2017 }
2018 }
2019 dynamic
2020}
2021
2022pub fn collect_capability_mcp_servers(
2023 capability_configs: &[AgentCapabilityConfig],
2024 registry: &CapabilityRegistry,
2025) -> ScopedMcpServers {
2026 let mut servers = ScopedMcpServers::default();
2027
2028 for cap_config in capability_configs {
2029 let cap_id = cap_config.capability_id();
2030 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2033 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2034 cap_config.config_value().clone(),
2035 ) {
2036 if !definition.status.is_active() {
2037 continue;
2038 }
2039 if let Some(contributed) = definition.mcp_servers {
2040 servers = merge_scoped_mcp_servers(&servers, &contributed);
2041 }
2042 }
2043 continue;
2044 }
2045 if let Some(capability) = registry.get(cap_id) {
2046 if !capability.status().is_active() {
2047 continue;
2048 }
2049 servers = merge_scoped_mcp_servers(
2050 &servers,
2051 &capability.mcp_servers_with_config(cap_config.config_value()),
2052 );
2053 }
2054 }
2055
2056 servers
2057}
2058
2059pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2066
2067#[derive(Debug, Clone, PartialEq, Eq)]
2069pub enum DependencyError {
2070 CircularDependency {
2072 capability_id: String,
2074 chain: Vec<String>,
2076 },
2077 TooManyCapabilities {
2079 count: usize,
2081 max: usize,
2083 },
2084}
2085
2086impl std::fmt::Display for DependencyError {
2087 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2088 match self {
2089 DependencyError::CircularDependency {
2090 capability_id,
2091 chain,
2092 } => {
2093 write!(
2094 f,
2095 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2096 capability_id,
2097 chain.join(" -> "),
2098 capability_id
2099 )
2100 }
2101 DependencyError::TooManyCapabilities { count, max } => {
2102 write!(
2103 f,
2104 "Too many capabilities after resolution: {} (max: {})",
2105 count, max
2106 )
2107 }
2108 }
2109 }
2110}
2111
2112impl std::error::Error for DependencyError {}
2113
2114#[derive(Debug, Clone)]
2116pub struct ResolvedCapabilities {
2117 pub resolved_ids: Vec<String>,
2120 pub added_as_dependencies: Vec<String>,
2122 pub user_selected: Vec<String>,
2124}
2125
2126pub fn resolve_dependencies(
2146 selected_ids: &[String],
2147 registry: &CapabilityRegistry,
2148) -> Result<ResolvedCapabilities, DependencyError> {
2149 use std::collections::HashSet;
2150
2151 let user_selected: HashSet<String> = selected_ids
2153 .iter()
2154 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2155 .collect();
2156 let mut resolved: Vec<String> = Vec::new();
2157 let mut resolved_set: HashSet<String> = HashSet::new();
2158 let mut added_as_dependencies: Vec<String> = Vec::new();
2159
2160 for cap_id in selected_ids {
2162 resolve_single_capability(
2163 cap_id,
2164 registry,
2165 &mut resolved,
2166 &mut resolved_set,
2167 &mut added_as_dependencies,
2168 &user_selected,
2169 &mut Vec::new(), )?;
2171 }
2172
2173 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2175 return Err(DependencyError::TooManyCapabilities {
2176 count: resolved.len(),
2177 max: MAX_RESOLVED_CAPABILITIES,
2178 });
2179 }
2180
2181 Ok(ResolvedCapabilities {
2182 resolved_ids: resolved,
2183 added_as_dependencies,
2184 user_selected: selected_ids.to_vec(),
2185 })
2186}
2187
2188pub fn resolve_capability_configs(
2193 selected_configs: &[AgentCapabilityConfig],
2194 registry: &CapabilityRegistry,
2195) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2196 let mut selected_ids: Vec<String> = Vec::new();
2197 for config in selected_configs {
2198 if (is_declarative_capability(config.capability_id())
2201 || is_plugin_capability(config.capability_id()))
2202 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2203 config.config_value().clone(),
2204 )
2205 {
2206 selected_ids.extend(definition.dependencies);
2207 }
2208 selected_ids.push(config.capability_id().to_string());
2209 }
2210 let resolved = resolve_dependencies(&selected_ids, registry)?;
2211
2212 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2215 .iter()
2216 .map(|config| {
2217 let id = config.capability_id();
2218 let id = registry.canonical_id(id).unwrap_or(id);
2219 (id.to_string(), config.config_value().clone())
2220 })
2221 .collect();
2222
2223 Ok(resolved
2224 .resolved_ids
2225 .into_iter()
2226 .map(|capability_id| {
2227 explicit_configs
2228 .get(&capability_id)
2229 .cloned()
2230 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2231 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2232 })
2233 .collect())
2234}
2235
2236fn resolve_single_capability(
2238 cap_id: &str,
2239 registry: &CapabilityRegistry,
2240 resolved: &mut Vec<String>,
2241 resolved_set: &mut std::collections::HashSet<String>,
2242 added_as_dependencies: &mut Vec<String>,
2243 user_selected: &std::collections::HashSet<String>,
2244 visiting: &mut Vec<String>,
2245) -> Result<(), DependencyError> {
2246 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2250
2251 if resolved_set.contains(cap_id) {
2253 return Ok(());
2254 }
2255
2256 if visiting.contains(&cap_id.to_string()) {
2258 return Err(DependencyError::CircularDependency {
2259 capability_id: cap_id.to_string(),
2260 chain: visiting.clone(),
2261 });
2262 }
2263
2264 let capability = match registry.get(cap_id) {
2266 Some(cap) => cap,
2267 None => {
2268 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2272 && !resolved_set.contains(cap_id)
2273 {
2274 resolved.push(cap_id.to_string());
2275 resolved_set.insert(cap_id.to_string());
2276 if !user_selected.contains(cap_id) {
2277 added_as_dependencies.push(cap_id.to_string());
2278 }
2279 }
2280 return Ok(());
2281 }
2282 };
2283
2284 visiting.push(cap_id.to_string());
2286
2287 for dep_id in capability.dependencies() {
2289 resolve_single_capability(
2290 dep_id,
2291 registry,
2292 resolved,
2293 resolved_set,
2294 added_as_dependencies,
2295 user_selected,
2296 visiting,
2297 )?;
2298 }
2299
2300 visiting.pop();
2302
2303 if !resolved_set.contains(cap_id) {
2305 resolved.push(cap_id.to_string());
2306 resolved_set.insert(cap_id.to_string());
2307
2308 if !user_selected.contains(cap_id) {
2310 added_as_dependencies.push(cap_id.to_string());
2311 }
2312 }
2313
2314 Ok(())
2315}
2316
2317pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2322 use std::collections::HashSet;
2323
2324 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2325 Ok(resolved) => resolved.resolved_ids,
2326 Err(_) => capability_ids.to_vec(),
2327 };
2328
2329 let mut seen = HashSet::new();
2330 let mut features = Vec::new();
2331 for cap_id in &resolved_ids {
2332 if let Some(cap) = registry.get(cap_id) {
2333 for feature in cap.features() {
2334 if seen.insert(feature) {
2335 features.push(feature.to_string());
2336 }
2337 }
2338 }
2339 }
2340 features
2341}
2342
2343pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2346 registry
2347 .get(cap_id)
2348 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2349 .unwrap_or_default()
2350}
2351
2352pub async fn collect_capabilities(
2368 capability_ids: &[String],
2369 registry: &CapabilityRegistry,
2370 ctx: &SystemPromptContext,
2371) -> CollectedCapabilities {
2372 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2375 Ok(resolved) => resolved.resolved_ids,
2376 Err(e) => {
2377 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2378 capability_ids.to_vec()
2379 }
2380 };
2381
2382 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2384 .iter()
2385 .map(|id| {
2386 AgentCapabilityConfig::with_config(
2387 CapabilityId::new(id),
2388 serde_json::Value::Object(serde_json::Map::new()),
2389 )
2390 })
2391 .collect();
2392
2393 collect_capabilities_with_configs(&configs, registry, ctx).await
2394}
2395
2396pub async fn collect_capabilities_with_configs(
2407 capability_configs: &[AgentCapabilityConfig],
2408 registry: &CapabilityRegistry,
2409 ctx: &SystemPromptContext,
2410) -> CollectedCapabilities {
2411 let mut system_prompt_parts: Vec<String> = Vec::new();
2412 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2413 let mut conversation_context_parts: Vec<String> = Vec::new();
2414 let mut conversation_context_attributions: Vec<SystemPromptAttribution> = Vec::new();
2415 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2416 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2417 let mut mounts: Vec<MountPoint> = Vec::new();
2418 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2419 Vec::new();
2420 let mut applied_ids: Vec<String> = Vec::new();
2421 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2422 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2423 let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2424 let mut parallel_tool_calls: Option<bool> = None;
2425 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2426 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2427 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2430 let mut mcp_servers = ScopedMcpServers::default();
2431 let mut static_facts: Vec<Fact> = Vec::new();
2435 let mut has_dynamic_facts = false;
2436 let facts_ctx = FactsContext::new(ctx.session_id);
2437 let compaction_on = compaction_is_enabled(capability_configs, registry);
2438 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2439
2440 for cap_config in capability_configs {
2441 let cap_id = cap_config.capability_id();
2442 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2447 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2448 cap_config.config_value().clone(),
2449 ) {
2450 Ok(definition) => {
2451 if !definition.status.is_active() {
2452 continue;
2453 }
2454
2455 if let Some(prompt) = definition.system_prompt.as_deref() {
2456 let contribution =
2457 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2458 system_prompt_attributions.push(SystemPromptAttribution {
2459 capability_id: cap_id.to_string(),
2460 content: contribution.clone(),
2461 });
2462 system_prompt_parts.push(contribution);
2463 }
2464
2465 mounts.extend(definition.mounts(cap_id));
2466 if let Some(ref servers) = definition.mcp_servers {
2467 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2468 }
2469 for skill in definition.skill_contributions() {
2470 mounts.push(skill.to_mount(cap_id));
2471 }
2472
2473 applied_ids.push(cap_id.to_string());
2474 }
2475 Err(error) => {
2476 tracing::warn!(
2477 capability_id = %cap_id,
2478 error = %error,
2479 "Skipping invalid declarative/plugin capability config"
2480 );
2481 }
2482 }
2483 continue;
2484 }
2485 if let Some(capability) = registry.get(cap_id) {
2486 if !capability.status().is_active() {
2490 continue;
2491 }
2492
2493 let effective: &dyn Capability =
2505 match capability.resolve_for_model(ctx.model.as_deref()) {
2506 Some(inner) => inner,
2507 None => capability.as_ref(),
2508 };
2509 let delegation_target =
2510 effective.delegation_target_with_config(cap_config.config_value());
2511
2512 if let Some(contribution) = effective
2514 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2515 .await
2516 {
2517 system_prompt_attributions.push(SystemPromptAttribution {
2518 capability_id: cap_id.to_string(),
2519 content: contribution.clone(),
2520 });
2521 system_prompt_parts.push(contribution);
2522 }
2523
2524 if let Some(contribution) = effective
2529 .conversation_context_contribution_with_config(ctx, cap_config.config_value())
2530 .await
2531 {
2532 conversation_context_attributions.push(SystemPromptAttribution {
2533 capability_id: cap_id.to_string(),
2534 content: contribution.clone(),
2535 });
2536 conversation_context_parts.push(contribution);
2537 }
2538
2539 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2544 match fact.volatility {
2545 Volatility::Static => static_facts.push(fact),
2546 Volatility::Dynamic => has_dynamic_facts = true,
2547 }
2548 }
2549
2550 tools.extend(effective.tools_with_config(cap_config.config_value()));
2552 if let Some(target) = delegation_target {
2553 delegation_targets.push(target);
2554 }
2555 tool_definition_hooks.extend(
2556 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2557 );
2558 tool_call_hooks.extend(effective.tool_call_hooks());
2559 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2561 let cap_category = effective.category();
2566 for def in effective.tool_definitions() {
2567 let def = match (def.category(), cap_category) {
2568 (None, Some(cat)) => def.with_category(cat),
2569 _ => def,
2570 }
2571 .with_capability_attribution(cap_id, Some(capability.name()));
2572 tool_definitions.push(def);
2573 }
2574
2575 tool_search = effective
2576 .tool_search_config(cap_config.config_value())
2577 .or(tool_search);
2578 prompt_cache = effective
2579 .prompt_cache_config(cap_config.config_value())
2580 .or(prompt_cache);
2581 parallel_tool_calls = effective
2582 .parallel_tool_calls_preference(cap_config.config_value())
2583 .or(parallel_tool_calls);
2584
2585 openrouter_routing = effective
2586 .openrouter_routing_config(cap_config.config_value())
2587 .or(openrouter_routing);
2588
2589 mounts.extend(effective.mounts());
2591
2592 mcp_servers = merge_scoped_mcp_servers(
2593 &mcp_servers,
2594 &effective.mcp_servers_with_config(cap_config.config_value()),
2595 );
2596
2597 for skill in effective.contribute_skills() {
2601 mounts.push(skill.to_mount(cap_id));
2602 }
2603
2604 if let Some(provider) = effective.message_filter_provider() {
2606 let config =
2607 effective.message_filter_config(cap_config.config_value(), compaction_on);
2608 message_filter_providers.push((provider, config));
2609 }
2610
2611 applied_ids.push(cap_id.to_string());
2612 }
2613 }
2614
2615 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2618 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2619 let def = tool
2620 .to_definition()
2621 .with_category("Orchestration")
2622 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2623 tools.push(Box::new(tool));
2624 tool_definitions.push(def);
2625 }
2626
2627 let auto_activated: Vec<_> = registry
2630 .list()
2631 .into_iter()
2632 .filter(|cap| {
2633 !applied_ids.iter().any(|id| id == cap.id())
2634 && cap.status().is_active()
2635 && cap.auto_activates_for(&tool_definitions)
2636 })
2637 .cloned()
2638 .collect();
2639 for cap in auto_activated {
2640 tools.extend(cap.tools());
2641 let cap_category = cap.category();
2642 for def in cap.tool_definitions() {
2643 let def = match (def.category(), cap_category) {
2644 (None, Some(cat)) => def.with_category(cat),
2645 _ => def,
2646 }
2647 .with_capability_attribution(cap.id(), Some(cap.name()));
2648 tool_definitions.push(def);
2649 }
2650 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2651 applied_ids.push(cap.id().to_string());
2652 }
2653
2654 if let Some(block) = facts::render_facts_block(&static_facts) {
2659 system_prompt_attributions.push(SystemPromptAttribution {
2660 capability_id: "facts".to_string(),
2661 content: block.clone(),
2662 });
2663 system_prompt_parts.push(block);
2664 }
2665 if has_dynamic_facts {
2666 system_prompt_attributions.push(SystemPromptAttribution {
2667 capability_id: "facts".to_string(),
2668 content: FACTS_DYNAMIC_NOTE.to_string(),
2669 });
2670 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2671 }
2672
2673 tool_call_hooks.extend(narration_hooks);
2677
2678 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2680
2681 CollectedCapabilities {
2682 system_prompt_parts,
2683 system_prompt_attributions,
2684 conversation_context_parts,
2685 conversation_context_attributions,
2686 tools,
2687 tool_definitions,
2688 mounts,
2689 message_filter_providers,
2690 applied_ids,
2691 tool_search,
2692 prompt_cache,
2693 openrouter_routing,
2694 parallel_tool_calls,
2695 tool_definition_hooks,
2696 tool_call_hooks,
2697 mcp_servers,
2698 }
2699}
2700
2701pub struct AppliedCapabilities {
2707 pub runtime_agent: RuntimeAgent,
2709 pub tool_registry: ToolRegistry,
2711 pub applied_ids: Vec<String>,
2713}
2714
2715pub async fn apply_capabilities(
2751 base_runtime_agent: RuntimeAgent,
2752 capability_ids: &[String],
2753 registry: &CapabilityRegistry,
2754 ctx: &SystemPromptContext,
2755) -> AppliedCapabilities {
2756 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2757
2758 let final_system_prompt = compose_system_prompt(
2760 &base_runtime_agent.system_prompt,
2761 collected.system_prompt_prefix().as_deref(),
2762 );
2763
2764 let conversation_context = collected.conversation_context();
2768 let mut tool_registry = ToolRegistry::new();
2770 for tool in collected.tools {
2771 tool_registry.register_boxed(tool);
2772 }
2773
2774 let mut tools = collected.tool_definitions;
2776 for hook in &collected.tool_definition_hooks {
2777 tools = hook.transform(tools);
2778 }
2779
2780 let runtime_agent = RuntimeAgent {
2781 system_prompt: final_system_prompt,
2782 model: base_runtime_agent.model,
2783 tools,
2784 max_iterations: base_runtime_agent.max_iterations,
2785 temperature: base_runtime_agent.temperature,
2786 max_tokens: base_runtime_agent.max_tokens,
2787 tool_search: collected.tool_search,
2788 prompt_cache: collected.prompt_cache,
2789 openrouter_routing: collected.openrouter_routing,
2790 network_access: base_runtime_agent.network_access,
2791 parallel_tool_calls: base_runtime_agent
2794 .parallel_tool_calls
2795 .or(collected.parallel_tool_calls),
2796 conversation_context,
2799 };
2800
2801 AppliedCapabilities {
2802 runtime_agent,
2803 tool_registry,
2804 applied_ids: collected.applied_ids,
2805 }
2806}
2807
2808#[cfg(test)]
2813mod tests {
2814 use super::*;
2815 use crate::typed_id::SessionId;
2816 use uuid::Uuid;
2817
2818 fn test_ctx() -> SystemPromptContext {
2820 SystemPromptContext::without_file_store(SessionId::new())
2821 }
2822
2823 struct StubSubagentSpawnTool;
2832
2833 #[async_trait]
2834 impl Tool for StubSubagentSpawnTool {
2835 fn name(&self) -> &str {
2836 "spawn_agent"
2837 }
2838 fn description(&self) -> &str {
2839 "stub subagent delegation"
2840 }
2841 fn parameters_schema(&self) -> serde_json::Value {
2842 serde_json::json!({ "type": "object" })
2843 }
2844 fn narrate(
2845 &self,
2846 tool_call: &ToolCall,
2847 phase: crate::tool_narration::ToolNarrationPhase,
2848 locale: Option<&str>,
2849 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2850 ) -> Option<String> {
2851 Some(crate::tool_narration::narrate_subagent_spawn(
2852 &tool_call.arguments,
2853 phase,
2854 locale,
2855 ))
2856 }
2857 async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2858 crate::ToolExecutionResult::success(serde_json::json!({}))
2859 }
2860 }
2861
2862 fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2863 ToolCall {
2864 id: "call-1".to_string(),
2865 name: "spawn_agent".to_string(),
2866 arguments,
2867 }
2868 }
2869
2870 #[test]
2873 fn unified_spawn_agent_narration_names_the_agent() {
2874 let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
2875 target_type: "subagent",
2876 tool: Box::new(StubSubagentSpawnTool),
2877 }]);
2878 let ctx = crate::tool_narration::ToolNarrationContext::default();
2879
2880 assert_eq!(
2881 tool.narrate(
2882 &spawn_agent_call(serde_json::json!({
2883 "name": "Orbit Scout",
2884 "target": { "type": "subagent" },
2885 "blueprint": "github_scout"
2886 })),
2887 crate::tool_narration::ToolNarrationPhase::Started,
2888 None,
2889 ctx,
2890 )
2891 .as_deref(),
2892 Some("Launching Orbit Scout subagent (github_scout)")
2893 );
2894
2895 assert_eq!(
2896 tool.narrate(
2897 &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
2898 crate::tool_narration::ToolNarrationPhase::Started,
2899 None,
2900 ctx,
2901 )
2902 .as_deref(),
2903 Some("Launching Orbit Scout subagent")
2904 );
2905 }
2906
2907 #[test]
2908 fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
2909 for target_type in ["agent", "external_a2a"] {
2910 let arguments = serde_json::json!({
2911 "target": { "type": target_type, "id": "actual-target" },
2912 "blueprint": "decoy-target"
2913 });
2914 assert_eq!(
2915 validate_spawn_agent_target_fields(&arguments, target_type),
2916 Err(format!(
2917 "blueprint is only valid for subagent targets, not {target_type}."
2918 ))
2919 );
2920
2921 let arguments = serde_json::json!({
2922 "target": { "type": target_type, "id": "actual-target" },
2923 "config": { "model": "decoy" }
2924 });
2925 assert_eq!(
2926 validate_spawn_agent_target_fields(&arguments, target_type),
2927 Err(format!(
2928 "config is only valid for subagent targets, not {target_type}."
2929 ))
2930 );
2931 }
2932 }
2933
2934 struct NoopFixture;
2936
2937 impl Capability for NoopFixture {
2938 fn id(&self) -> &str {
2939 "noop"
2940 }
2941 fn name(&self) -> &str {
2942 "No-Op"
2943 }
2944 fn description(&self) -> &str {
2945 "Contributes nothing."
2946 }
2947 }
2948
2949 struct FeatureFixture;
2951
2952 impl Capability for FeatureFixture {
2953 fn id(&self) -> &str {
2954 "feature_fixture"
2955 }
2956 fn name(&self) -> &str {
2957 "Feature Fixture"
2958 }
2959 fn description(&self) -> &str {
2960 "Declares one test-only feature."
2961 }
2962 fn features(&self) -> Vec<&'static str> {
2963 vec!["fixture_feature"]
2964 }
2965 }
2966
2967 struct FixtureTool(&'static str);
2968
2969 #[async_trait]
2970 impl Tool for FixtureTool {
2971 fn name(&self) -> &str {
2972 self.0
2973 }
2974 fn description(&self) -> &str {
2975 "Fixture tool."
2976 }
2977 fn parameters_schema(&self) -> serde_json::Value {
2978 serde_json::json!({
2979 "type": "object",
2980 "properties": {},
2981 "additionalProperties": false
2982 })
2983 }
2984 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2985 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2986 }
2987 }
2988
2989 struct BackgroundFixtureTool;
2990
2991 #[async_trait]
2992 impl Tool for BackgroundFixtureTool {
2993 fn name(&self) -> &str {
2994 "bash"
2995 }
2996 fn description(&self) -> &str {
2997 "Fixture background-capable shell tool."
2998 }
2999 fn parameters_schema(&self) -> serde_json::Value {
3000 serde_json::json!({"type": "object"})
3001 }
3002 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3003 ToolExecutionResult::success(serde_json::json!({"ok": true}))
3004 }
3005 fn hints(&self) -> crate::tool_types::ToolHints {
3006 crate::tool_types::ToolHints {
3007 supports_background: Some(true),
3008 ..Default::default()
3009 }
3010 }
3011 }
3012
3013 struct FileSystemFixture;
3014
3015 impl Capability for FileSystemFixture {
3016 fn id(&self) -> &str {
3017 "session_file_system"
3018 }
3019 fn name(&self) -> &str {
3020 "Fixture Filesystem"
3021 }
3022 fn description(&self) -> &str {
3023 "Fixture filesystem capability."
3024 }
3025 fn tools(&self) -> Vec<Box<dyn Tool>> {
3026 vec![
3027 Box::new(FixtureTool("read_file")),
3028 Box::new(FixtureTool("write_file")),
3029 ]
3030 }
3031 fn features(&self) -> Vec<&'static str> {
3032 vec!["file_system"]
3033 }
3034 }
3035
3036 struct StorageFixture;
3041
3042 impl Capability for StorageFixture {
3043 fn id(&self) -> &str {
3044 "session_storage"
3045 }
3046 fn name(&self) -> &str {
3047 "Fixture Storage"
3048 }
3049 fn description(&self) -> &str {
3050 "Fixture session storage capability."
3051 }
3052 fn features(&self) -> Vec<&'static str> {
3053 vec!["secrets", "key_value"]
3054 }
3055 }
3056
3057 struct BashFixture;
3058
3059 impl Capability for BashFixture {
3060 fn id(&self) -> &str {
3061 "bashkit_shell"
3062 }
3063 fn aliases(&self) -> Vec<&'static str> {
3064 vec!["virtual_bash"]
3065 }
3066 fn name(&self) -> &str {
3067 "Fixture Bash"
3068 }
3069 fn description(&self) -> &str {
3070 "Fixture shell capability."
3071 }
3072 fn tools(&self) -> Vec<Box<dyn Tool>> {
3073 vec![Box::new(BackgroundFixtureTool)]
3074 }
3075 fn dependencies(&self) -> Vec<&'static str> {
3076 vec!["session_file_system"]
3077 }
3078 fn features(&self) -> Vec<&'static str> {
3079 vec!["file_system"]
3080 }
3081 fn risk_level(&self) -> RiskLevel {
3082 RiskLevel::High
3083 }
3084 }
3085
3086 struct WebFetchFixture;
3087
3088 impl Capability for WebFetchFixture {
3089 fn id(&self) -> &str {
3090 "web_fetch"
3091 }
3092 fn name(&self) -> &str {
3093 "Fixture Web Fetch"
3094 }
3095 fn description(&self) -> &str {
3096 "Fixture web capability."
3097 }
3098 fn risk_level(&self) -> RiskLevel {
3099 RiskLevel::High
3100 }
3101 }
3102
3103 struct DynamicFactFixture;
3106
3107 impl Capability for DynamicFactFixture {
3108 fn id(&self) -> &str {
3109 "current_time"
3110 }
3111 fn name(&self) -> &str {
3112 "Dynamic Fact Fixture"
3113 }
3114 fn description(&self) -> &str {
3115 "Fixture with one dynamic fact and one tool."
3116 }
3117 fn icon(&self) -> Option<&str> {
3118 Some("clock")
3119 }
3120 fn category(&self) -> Option<&str> {
3121 Some("Core")
3122 }
3123 fn tools(&self) -> Vec<Box<dyn Tool>> {
3124 vec![Box::new(FixtureTool("get_current_time"))]
3125 }
3126 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3127 vec![Fact::dynamic("current_time", "fixture-now")]
3128 }
3129 }
3130
3131 struct PromptToolFixture;
3132
3133 impl Capability for PromptToolFixture {
3134 fn id(&self) -> &str {
3135 "prompt_tool_fixture"
3136 }
3137 fn name(&self) -> &str {
3138 "Prompt Tool Fixture"
3139 }
3140 fn description(&self) -> &str {
3141 "Fixture with a static prompt and tool."
3142 }
3143 fn system_prompt_addition(&self) -> Option<&str> {
3144 Some("Task Management uses the write_todos tool.")
3145 }
3146 fn tools(&self) -> Vec<Box<dyn Tool>> {
3147 vec![Box::new(FixtureTool("write_todos"))]
3148 }
3149 }
3150
3151 struct SecondPromptFixture;
3152
3153 impl Capability for SecondPromptFixture {
3154 fn id(&self) -> &str {
3155 "second_prompt_fixture"
3156 }
3157 fn name(&self) -> &str {
3158 "Second Prompt Fixture"
3159 }
3160 fn description(&self) -> &str {
3161 "Fixture with a second static prompt."
3162 }
3163 fn system_prompt_addition(&self) -> Option<&str> {
3164 Some("A second capability prompt contribution.")
3165 }
3166 }
3167
3168 struct DynamicPreviewFixture;
3169
3170 impl Capability for DynamicPreviewFixture {
3171 fn id(&self) -> &str {
3172 "agent_instructions"
3173 }
3174 fn name(&self) -> &str {
3175 "Dynamic Preview Fixture"
3176 }
3177 fn description(&self) -> &str {
3178 "Fixture whose runtime prompt is dynamic."
3179 }
3180 fn system_prompt_preview(&self) -> Option<String> {
3181 Some("Reads AGENTS.md dynamically.".to_string())
3182 }
3183 }
3184
3185 struct MathFixture;
3187
3188 impl Capability for MathFixture {
3189 fn id(&self) -> &str {
3190 "test_math"
3191 }
3192 fn name(&self) -> &str {
3193 "Test Math"
3194 }
3195 fn description(&self) -> &str {
3196 "Fixture: calculator tools."
3197 }
3198 fn tools(&self) -> Vec<Box<dyn Tool>> {
3199 vec![
3200 Box::new(FixtureTool("add")),
3201 Box::new(FixtureTool("subtract")),
3202 Box::new(FixtureTool("multiply")),
3203 Box::new(FixtureTool("divide")),
3204 ]
3205 }
3206 }
3207
3208 struct WeatherFixture;
3210
3211 impl Capability for WeatherFixture {
3212 fn id(&self) -> &str {
3213 "test_weather"
3214 }
3215 fn name(&self) -> &str {
3216 "Test Weather"
3217 }
3218 fn description(&self) -> &str {
3219 "Fixture: weather tools."
3220 }
3221 fn tools(&self) -> Vec<Box<dyn Tool>> {
3222 vec![
3223 Box::new(FixtureTool("get_weather")),
3224 Box::new(FixtureTool("get_forecast")),
3225 ]
3226 }
3227 }
3228
3229 struct SampleDataFixture;
3232
3233 impl Capability for SampleDataFixture {
3234 fn id(&self) -> &str {
3235 "sample_data"
3236 }
3237 fn name(&self) -> &str {
3238 "Sample Data"
3239 }
3240 fn description(&self) -> &str {
3241 "Fixture: mounted sample files."
3242 }
3243 fn system_prompt_addition(&self) -> Option<&str> {
3244 Some("Read-only sample files are mounted at `/samples`.")
3245 }
3246 fn mounts(&self) -> Vec<MountPoint> {
3247 let samples_dir = MountDirectoryBuilder::new()
3248 .file("users.json", "[]")
3249 .build();
3250 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3251 }
3252 fn dependencies(&self) -> Vec<&'static str> {
3253 vec!["session_file_system"]
3254 }
3255 fn features(&self) -> Vec<&'static str> {
3256 vec!["file_system"]
3257 }
3258 }
3259
3260 fn fixture_registry() -> CapabilityRegistry {
3262 let mut registry = CapabilityRegistry::new();
3263 registry.register(NoopFixture);
3264 registry.register(FeatureFixture);
3265 registry.register(MathFixture);
3266 registry.register(WeatherFixture);
3267 registry.register(SampleDataFixture);
3268 registry.register(FileSystemFixture);
3269 registry.register(StorageFixture);
3270 registry.register(BashFixture);
3271 registry.register(WebFetchFixture);
3272 registry.register(DynamicFactFixture);
3273 registry.register(PromptToolFixture);
3274 registry.register(SecondPromptFixture);
3275 registry.register(DynamicPreviewFixture);
3276 registry
3277 }
3278
3279 struct HostAnnotatedCapability;
3281
3282 #[async_trait]
3283 impl Capability for HostAnnotatedCapability {
3284 fn id(&self) -> &str {
3285 "host_annotated"
3286 }
3287 fn name(&self) -> &str {
3288 "Host Annotated"
3289 }
3290 fn description(&self) -> &str {
3291 "Test capability with host-owned metadata."
3292 }
3293 fn metadata(&self) -> Option<serde_json::Value> {
3294 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3295 }
3296 }
3297
3298 #[test]
3299 fn test_capability_registry_get() {
3300 let mut registry = CapabilityRegistry::new();
3301 registry.register(NoopFixture);
3302
3303 let capability = registry.get("noop").unwrap();
3304 assert_eq!(capability.id(), "noop");
3305 assert_eq!(capability.status(), CapabilityStatus::Available);
3306 }
3307
3308 #[test]
3309 fn default_registry_is_empty_and_selects_no_product_preset() {
3310 assert!(CapabilityRegistry::default().is_empty());
3311 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3312 }
3313
3314 #[tokio::test]
3315 async fn test_capability_registry_blueprint_with_capability() {
3316 struct BlueprintProviderCapability;
3317
3318 impl Capability for BlueprintProviderCapability {
3319 fn id(&self) -> &str {
3320 "blueprint_provider"
3321 }
3322 fn name(&self) -> &str {
3323 "Blueprint Provider"
3324 }
3325 fn description(&self) -> &str {
3326 "Capability that provides a blueprint for tests"
3327 }
3328 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3329 vec![AgentBlueprint {
3330 id: "test_blueprint",
3331 name: "Test Blueprint",
3332 description: "Blueprint for capability registry tests",
3333 model: BlueprintModel::Fixed("specialist-model".into()),
3334 system_prompt: "Test prompt",
3335 tools: vec![Box::new(FixtureTool("private_lookup"))],
3336 max_turns: Some(7),
3337 config_schema: Some(
3338 serde_json::json!({"type":"object", "required":["repository"]}),
3339 ),
3340 }]
3341 }
3342 }
3343
3344 let mut registry = CapabilityRegistry::new();
3345 registry.register(BlueprintProviderCapability);
3346
3347 let (capability_id, blueprint) = registry
3348 .blueprint_with_capability("test_blueprint")
3349 .expect("blueprint should resolve with capability id");
3350 assert_eq!(capability_id, "blueprint_provider");
3351 assert_eq!(blueprint.id, "test_blueprint");
3352 assert_eq!(blueprint.name, "Test Blueprint");
3353 assert_eq!(
3354 blueprint.description,
3355 "Blueprint for capability registry tests"
3356 );
3357 assert_eq!(blueprint.system_prompt, "Test prompt");
3358 assert!(
3359 matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
3360 );
3361 assert_eq!(blueprint.max_turns, Some(7));
3362 assert_eq!(
3363 blueprint.config_schema,
3364 Some(serde_json::json!({"type":"object", "required":["repository"]}))
3365 );
3366 let definitions = blueprint.tool_definitions();
3367 assert_eq!(definitions.len(), 1);
3368 assert_eq!(definitions[0].name(), "private_lookup");
3369 assert_eq!(
3370 registry.blueprint("test_blueprint").unwrap().tools[0].name(),
3371 "private_lookup"
3372 );
3373 assert_eq!(
3374 registry
3375 .all_blueprints()
3376 .iter()
3377 .map(|b| b.id)
3378 .collect::<Vec<_>>(),
3379 ["test_blueprint"]
3380 );
3381 assert!(registry.blueprint_with_capability("missing").is_none());
3382 assert!(registry.blueprint("missing").is_none());
3383 let host =
3384 collect_capabilities(&["blueprint_provider".into()], ®istry, &test_ctx()).await;
3385 assert!(host.tools.is_empty());
3386 assert!(host.tool_definitions.is_empty());
3387 }
3388
3389 #[test]
3390 fn test_capability_registry_builder() {
3391 let registry = CapabilityRegistry::builder()
3392 .capability(NoopFixture)
3393 .build();
3394
3395 assert!(registry.has("noop"));
3396 assert_eq!(registry.len(), 1);
3397 }
3398
3399 #[test]
3400 fn test_system_prompt_preview_default_delegates_to_addition() {
3401 struct StaticPromptCapability;
3404 impl Capability for StaticPromptCapability {
3405 fn id(&self) -> &str {
3406 "static_prompt"
3407 }
3408 fn name(&self) -> &str {
3409 "Static Prompt"
3410 }
3411 fn description(&self) -> &str {
3412 "Static prompt addition."
3413 }
3414 fn system_prompt_addition(&self) -> Option<&str> {
3415 Some("Use the static prompt.")
3416 }
3417 }
3418
3419 let cap = StaticPromptCapability;
3420 assert_eq!(
3421 cap.system_prompt_preview().as_deref(),
3422 Some("Use the static prompt.")
3423 );
3424
3425 let registry = fixture_registry();
3427 let current_time = registry.get("current_time").unwrap();
3428 assert!(current_time.system_prompt_preview().is_none());
3429 assert!(current_time.system_prompt_addition().is_none());
3430 }
3431
3432 #[tokio::test]
3437 async fn test_apply_capabilities_empty() {
3438 let registry = CapabilityRegistry::new();
3439 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3440
3441 let applied =
3442 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3443
3444 assert_eq!(
3445 applied.runtime_agent.system_prompt,
3446 base_runtime_agent.system_prompt
3447 );
3448 assert!(applied.tool_registry.is_empty());
3449 assert!(applied.applied_ids.is_empty());
3450 }
3451
3452 #[tokio::test]
3453 async fn test_apply_capabilities_noop() {
3454 let registry = fixture_registry();
3455 let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3456
3457 base_runtime_agent.max_iterations = 13;
3458 base_runtime_agent.temperature = Some(0.25);
3459 base_runtime_agent.max_tokens = Some(1234);
3460 base_runtime_agent.parallel_tool_calls = Some(false);
3461 let applied = apply_capabilities(
3462 base_runtime_agent.clone(),
3463 &["noop".to_string()],
3464 ®istry,
3465 &test_ctx(),
3466 )
3467 .await;
3468
3469 assert_eq!(
3471 applied.runtime_agent.system_prompt,
3472 base_runtime_agent.system_prompt
3473 );
3474 assert!(applied.tool_registry.is_empty());
3475 assert_eq!(applied.applied_ids, vec!["noop"]);
3476 assert_eq!(
3477 serde_json::to_value(&applied.runtime_agent).unwrap(),
3478 serde_json::to_value(&base_runtime_agent).unwrap()
3479 );
3480 let collected = collect_capabilities(&["noop".into()], ®istry, &test_ctx()).await;
3481 assert!(collected.mounts.is_empty());
3482 assert!(collected.message_filter_providers.is_empty());
3483 assert!(compute_features(&["noop".into()], ®istry).is_empty());
3484 }
3485
3486 #[tokio::test]
3487 async fn test_apply_capabilities_current_time() {
3488 let registry = fixture_registry();
3489 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3490
3491 let applied = apply_capabilities(
3492 base_runtime_agent.clone(),
3493 &["current_time".to_string()],
3494 ®istry,
3495 &test_ctx(),
3496 )
3497 .await;
3498
3499 assert!(
3503 applied
3504 .runtime_agent
3505 .system_prompt
3506 .contains(FACTS_DYNAMIC_NOTE),
3507 "current_time should contribute the dynamic-facts note"
3508 );
3509 assert!(
3510 applied
3511 .runtime_agent
3512 .system_prompt
3513 .contains(&base_runtime_agent.system_prompt),
3514 "base prompt is preserved"
3515 );
3516 assert!(applied.tool_registry.has("get_current_time"));
3517 assert_eq!(applied.tool_registry.len(), 1);
3518 assert_eq!(applied.applied_ids, vec!["current_time"]);
3519 }
3520
3521 #[tokio::test]
3522 async fn test_apply_capabilities_skips_coming_soon() {
3523 struct ComingSoonFixture;
3524 impl Capability for ComingSoonFixture {
3525 fn id(&self) -> &str {
3526 "coming_soon_fixture"
3527 }
3528 fn name(&self) -> &str {
3529 "Coming Soon Fixture"
3530 }
3531 fn description(&self) -> &str {
3532 "Test-only capability."
3533 }
3534 fn status(&self) -> CapabilityStatus {
3535 CapabilityStatus::ComingSoon
3536 }
3537 fn system_prompt_addition(&self) -> Option<&str> {
3538 Some("Not yet available.")
3539 }
3540 }
3541 let mut registry = CapabilityRegistry::new();
3542 registry.register(ComingSoonFixture);
3543 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3544
3545 let applied = apply_capabilities(
3546 base_runtime_agent.clone(),
3547 &["coming_soon_fixture".to_string()],
3548 ®istry,
3549 &test_ctx(),
3550 )
3551 .await;
3552
3553 assert_eq!(
3554 applied.runtime_agent.system_prompt,
3555 base_runtime_agent.system_prompt
3556 );
3557 assert!(applied.applied_ids.is_empty());
3558 }
3559
3560 #[tokio::test]
3564 async fn test_apply_capabilities_keeps_deprecated_fully_functional() {
3565 struct DeprecatedFixture;
3566 impl Capability for DeprecatedFixture {
3567 fn id(&self) -> &str {
3568 "deprecated_fixture"
3569 }
3570 fn name(&self) -> &str {
3571 "Deprecated Fixture"
3572 }
3573 fn description(&self) -> &str {
3574 "Test-only capability."
3575 }
3576 fn status(&self) -> CapabilityStatus {
3577 CapabilityStatus::Deprecated
3578 }
3579 fn system_prompt_addition(&self) -> Option<&str> {
3580 Some("Still working.")
3581 }
3582 }
3583 let mut registry = CapabilityRegistry::new();
3584 registry.register(DeprecatedFixture);
3585 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3586
3587 let applied = apply_capabilities(
3588 base_runtime_agent,
3589 &["deprecated_fixture".to_string()],
3590 ®istry,
3591 &test_ctx(),
3592 )
3593 .await;
3594
3595 assert!(
3596 applied
3597 .runtime_agent
3598 .system_prompt
3599 .contains("Still working.")
3600 );
3601 assert_eq!(applied.applied_ids, vec!["deprecated_fixture"]);
3602 }
3603
3604 #[tokio::test]
3608 async fn test_apply_capabilities_skips_retired_without_failing() {
3609 struct RetiredFixture;
3610 impl Capability for RetiredFixture {
3611 fn id(&self) -> &str {
3612 "retired_fixture"
3613 }
3614 fn name(&self) -> &str {
3615 "Retired Fixture"
3616 }
3617 fn description(&self) -> &str {
3618 "Test-only capability."
3619 }
3620 fn status(&self) -> CapabilityStatus {
3621 CapabilityStatus::Retired
3622 }
3623 fn system_prompt_addition(&self) -> Option<&str> {
3624 Some("Should never be applied.")
3625 }
3626 }
3627 let mut registry = fixture_registry();
3628 registry.register(RetiredFixture);
3629 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3630
3631 let applied = apply_capabilities(
3632 base_runtime_agent,
3633 &["retired_fixture".to_string(), "current_time".to_string()],
3634 ®istry,
3635 &test_ctx(),
3636 )
3637 .await;
3638
3639 assert!(
3640 !applied
3641 .runtime_agent
3642 .system_prompt
3643 .contains("Should never be applied.")
3644 );
3645 assert_eq!(applied.applied_ids, vec!["current_time"]);
3647 assert!(applied.tool_registry.has("get_current_time"));
3648 }
3649
3650 #[tokio::test]
3651 async fn test_apply_capabilities_preserves_order() {
3652 let registry = fixture_registry();
3653 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3654
3655 let applied = apply_capabilities(
3657 base_runtime_agent,
3658 &["current_time".to_string(), "noop".to_string()],
3659 ®istry,
3660 &test_ctx(),
3661 )
3662 .await;
3663
3664 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3665 assert_eq!(applied.tool_registry.len(), 1);
3666 assert!(applied.tool_registry.has("get_current_time"));
3667 }
3668
3669 #[tokio::test]
3678 async fn test_dynamic_facts_add_note_without_static_block() {
3679 let registry = fixture_registry();
3683 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3684 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3685 let prompt = collected.system_prompt_parts.join("\n");
3686 assert!(
3687 prompt.contains(FACTS_DYNAMIC_NOTE),
3688 "dynamic-facts note should be in the cached prompt"
3689 );
3690 assert!(
3691 !prompt.contains("<facts>\n"),
3692 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3693 );
3694 }
3695
3696 #[tokio::test]
3697 async fn test_static_facts_fold_into_prompt() {
3698 struct StaticFactCap;
3699 impl Capability for StaticFactCap {
3700 fn id(&self) -> &str {
3701 "test_static_fact"
3702 }
3703 fn name(&self) -> &str {
3704 "Static Fact"
3705 }
3706 fn description(&self) -> &str {
3707 "test"
3708 }
3709 fn status(&self) -> CapabilityStatus {
3710 CapabilityStatus::Available
3711 }
3712 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3713 vec![Fact::stat("workspace_root", "/workspace")]
3714 }
3715 }
3716 let mut registry = CapabilityRegistry::new();
3717 registry.register(StaticFactCap);
3718 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3719 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3720 let prompt = collected.system_prompt_parts.join("\n");
3721 assert!(
3722 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3723 "static fact should fold into the cached prompt; got: {prompt}"
3724 );
3725 assert!(
3726 !prompt.contains(FACTS_DYNAMIC_NOTE),
3727 "no dynamic note when only static facts exist"
3728 );
3729 }
3730
3731 #[test]
3732 fn test_collect_dynamic_facts_returns_current_time() {
3733 let registry = fixture_registry();
3734 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3735 let facts = collect_dynamic_facts(
3736 &configs,
3737 ®istry,
3738 None,
3739 &FactsContext::new(SessionId::new()),
3740 );
3741 assert_eq!(facts.len(), 1);
3742 assert_eq!(facts[0].key, "current_time");
3743 assert_eq!(facts[0].value, "fixture-now");
3744 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3745 }
3746
3747 #[tokio::test]
3748 async fn test_collect_capabilities_combines_mounts() {
3749 struct Notes;
3750 impl Capability for Notes {
3751 fn id(&self) -> &str {
3752 "notes"
3753 }
3754 fn name(&self) -> &str {
3755 "Notes"
3756 }
3757 fn description(&self) -> &str {
3758 "Writable notes"
3759 }
3760 fn mounts(&self) -> Vec<MountPoint> {
3761 vec![MountPoint::readwrite(
3762 "/notes.txt",
3763 MountSource::text_file("Note α"),
3764 "notes",
3765 )]
3766 }
3767 }
3768 let mut registry = fixture_registry();
3769 registry.register(Notes);
3770 let collected = collect_capabilities(
3771 &["sample_data".into(), "notes".into(), "current_time".into()],
3772 ®istry,
3773 &test_ctx(),
3774 )
3775 .await;
3776 assert_eq!(
3777 collected.applied_ids,
3778 [
3779 "session_file_system",
3780 "sample_data",
3781 "notes",
3782 "current_time"
3783 ]
3784 );
3785 assert_eq!(
3786 collected.mounts,
3787 vec![
3788 MountPoint::readonly(
3789 "/samples",
3790 MountDirectoryBuilder::new()
3791 .file("users.json", "[]")
3792 .build(),
3793 "sample_data"
3794 ),
3795 MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
3796 ]
3797 );
3798 }
3799
3800 #[test]
3805 fn test_resolve_dependencies_empty() {
3806 let registry = CapabilityRegistry::new();
3807
3808 let resolved = resolve_dependencies(&[], ®istry).unwrap();
3809
3810 assert!(resolved.resolved_ids.is_empty());
3811 assert!(resolved.added_as_dependencies.is_empty());
3812 assert!(resolved.user_selected.is_empty());
3813 }
3814
3815 #[test]
3816 fn test_resolve_dependencies_no_deps() {
3817 let registry = fixture_registry();
3818
3819 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
3821
3822 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3823 assert!(resolved.added_as_dependencies.is_empty());
3824 }
3825
3826 #[test]
3827 fn test_resolve_dependencies_with_deps() {
3828 let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
3829 assert_eq!(
3830 resolved.resolved_ids,
3831 ["session_file_system", "sample_data"]
3832 );
3833 assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
3834 assert_eq!(resolved.user_selected, ["sample_data"]);
3835 }
3836
3837 #[test]
3838 fn test_resolve_dependencies_already_selected() {
3839 let registry = fixture_registry();
3840
3841 let resolved = resolve_dependencies(
3843 &["session_file_system".to_string(), "sample_data".to_string()],
3844 ®istry,
3845 )
3846 .unwrap();
3847
3848 assert_eq!(resolved.resolved_ids.len(), 2);
3849 assert!(resolved.added_as_dependencies.is_empty());
3851 }
3852
3853 #[test]
3854 fn test_resolve_dependencies_preserves_order() {
3855 let registry = fixture_registry();
3856
3857 let resolved =
3859 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
3860 .unwrap();
3861
3862 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3863 }
3864
3865 #[test]
3866 fn test_resolve_dependencies_unknown_capability() {
3867 let registry = CapabilityRegistry::new();
3868
3869 let resolved =
3871 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
3872
3873 assert!(resolved.resolved_ids.is_empty());
3874 }
3875
3876 #[test]
3877 fn test_get_dependencies() {
3878 let registry = fixture_registry();
3879
3880 let deps = get_dependencies("sample_data", ®istry);
3882 assert_eq!(deps, vec!["session_file_system"]);
3883
3884 let deps = get_dependencies("current_time", ®istry);
3886 assert!(deps.is_empty());
3887
3888 let deps = get_dependencies("unknown", ®istry);
3890 assert!(deps.is_empty());
3891 }
3892
3893 #[test]
3897 fn test_circular_dependency_error() {
3898 struct CapA;
3900 struct CapB;
3901
3902 impl Capability for CapA {
3903 fn id(&self) -> &str {
3904 "test_cap_a"
3905 }
3906 fn name(&self) -> &str {
3907 "Test A"
3908 }
3909 fn description(&self) -> &str {
3910 "Test capability A"
3911 }
3912 fn dependencies(&self) -> Vec<&'static str> {
3913 vec!["test_cap_b"]
3914 }
3915 }
3916
3917 impl Capability for CapB {
3918 fn id(&self) -> &str {
3919 "test_cap_b"
3920 }
3921 fn name(&self) -> &str {
3922 "Test B"
3923 }
3924 fn description(&self) -> &str {
3925 "Test capability B"
3926 }
3927 fn dependencies(&self) -> Vec<&'static str> {
3928 vec!["test_cap_a"]
3929 }
3930 }
3931
3932 let mut registry = CapabilityRegistry::new();
3933 registry.register(CapA);
3934 registry.register(CapB);
3935
3936 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
3937
3938 assert!(result.is_err());
3939 match result.unwrap_err() {
3940 DependencyError::CircularDependency { capability_id, .. } => {
3941 assert_eq!(capability_id, "test_cap_a");
3942 }
3943 _ => panic!("Expected CircularDependency error"),
3944 }
3945 }
3946
3947 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3952
3953 struct FilterTestCapability {
3955 priority: i32,
3956 }
3957
3958 impl Capability for FilterTestCapability {
3959 fn id(&self) -> &str {
3960 "filter_test"
3961 }
3962 fn name(&self) -> &str {
3963 "Filter Test"
3964 }
3965 fn description(&self) -> &str {
3966 "Test capability with message filter"
3967 }
3968 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3969 Some(Arc::new(FilterTestProvider {
3970 priority: self.priority,
3971 }))
3972 }
3973 }
3974
3975 struct FilterTestProvider {
3976 priority: i32,
3977 }
3978
3979 impl MessageFilterProvider for FilterTestProvider {
3980 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3981 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3983 query
3984 .filters
3985 .push(MessageFilter::Search(search.to_string()));
3986 }
3987 }
3988
3989 fn priority(&self) -> i32 {
3990 self.priority
3991 }
3992 }
3993
3994 #[tokio::test]
3995 async fn test_collect_capabilities_with_configs_no_filter_providers() {
3996 let registry = fixture_registry();
3997 let configs = vec![AgentCapabilityConfig::with_config(
3998 CapabilityId::new("current_time"),
3999 serde_json::json!({}),
4000 )];
4001
4002 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4003
4004 assert!(collected.message_filter_providers.is_empty());
4005 assert!(!collected.has_message_filters());
4006 }
4007
4008 #[tokio::test]
4009 async fn test_collected_capabilities_apply_message_filters() {
4010 let mut registry = CapabilityRegistry::new();
4011 registry.register(FilterTestCapability { priority: 0 });
4012
4013 let configs = vec![AgentCapabilityConfig::with_config(
4014 CapabilityId::new("filter_test"),
4015 serde_json::json!({ "search": "test_query" }),
4016 )];
4017
4018 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4019
4020 assert!(collected.has_message_filters());
4021
4022 let session_id: SessionId = Uuid::now_v7().into();
4024 let mut query = MessageQuery::new(session_id);
4025
4026 collected.apply_message_filters(&mut query);
4027
4028 assert_eq!(query.filters.len(), 1);
4030 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4031 }
4032
4033 #[tokio::test]
4034 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4035 struct SearchCapability {
4036 id: &'static str,
4037 search_term: &'static str,
4038 priority: i32,
4039 }
4040
4041 struct SearchProvider {
4042 search_term: &'static str,
4043 priority: i32,
4044 }
4045
4046 impl MessageFilterProvider for SearchProvider {
4047 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4048 query
4049 .filters
4050 .push(MessageFilter::Search(self.search_term.to_string()));
4051 }
4052
4053 fn priority(&self) -> i32 {
4054 self.priority
4055 }
4056 }
4057
4058 impl Capability for SearchCapability {
4059 fn id(&self) -> &str {
4060 self.id
4061 }
4062 fn name(&self) -> &str {
4063 "Search"
4064 }
4065 fn description(&self) -> &str {
4066 "Test"
4067 }
4068 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4069 Some(Arc::new(SearchProvider {
4070 search_term: self.search_term,
4071 priority: self.priority,
4072 }))
4073 }
4074 }
4075
4076 let mut registry = CapabilityRegistry::new();
4077 registry.register(SearchCapability {
4078 id: "cap_a",
4079 search_term: "alpha",
4080 priority: 5,
4081 });
4082 registry.register(SearchCapability {
4083 id: "cap_b",
4084 search_term: "beta",
4085 priority: 1,
4086 });
4087 registry.register(SearchCapability {
4088 id: "cap_c",
4089 search_term: "gamma",
4090 priority: 10,
4091 });
4092
4093 let configs = vec![
4094 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4095 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4096 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4097 ];
4098
4099 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4100
4101 let session_id: SessionId = Uuid::now_v7().into();
4102 let mut query = MessageQuery::new(session_id);
4103
4104 collected.apply_message_filters(&mut query);
4105
4106 assert_eq!(query.filters.len(), 3);
4108 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4109 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4110 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4111 }
4112
4113 #[tokio::test]
4114 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4115 let mut registry = CapabilityRegistry::new();
4116 registry.register(FilterTestCapability { priority: 0 });
4117
4118 let test_config = serde_json::json!({
4119 "search": "custom_search",
4120 "extra_field": 42
4121 });
4122
4123 let configs = vec![AgentCapabilityConfig::with_config(
4124 CapabilityId::new("filter_test"),
4125 test_config.clone(),
4126 )];
4127
4128 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4129
4130 assert_eq!(collected.message_filter_providers.len(), 1);
4132 let (_, stored_config) = &collected.message_filter_providers[0];
4133 assert_eq!(*stored_config, test_config);
4134 }
4135
4136 #[test]
4141 fn test_collect_message_filters_only_collects_filters() {
4142 let mut registry = CapabilityRegistry::new();
4143 registry.register(FilterTestCapability { priority: 0 });
4144
4145 let configs = vec![AgentCapabilityConfig::with_config(
4146 CapabilityId::new("filter_test"),
4147 serde_json::json!({ "search": "test_query" }),
4148 )];
4149
4150 let collected = collect_message_filters_only(&configs, ®istry);
4151
4152 let session_id: SessionId = Uuid::now_v7().into();
4153 let mut query = MessageQuery::new(session_id);
4154 collected.apply_message_filters(&mut query);
4155
4156 assert_eq!(query.filters.len(), 1);
4157 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4158 }
4159
4160 #[test]
4161 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4162 let registry = CapabilityRegistry::new();
4163
4164 let configs = vec![AgentCapabilityConfig::with_config(
4165 CapabilityId::new("nonexistent"),
4166 serde_json::json!({}),
4167 )];
4168
4169 let collected = collect_message_filters_only(&configs, ®istry);
4170 assert!(collected.message_filter_providers.is_empty());
4171 }
4172
4173 #[test]
4174 fn test_collect_message_filters_only_preserves_priority_order() {
4175 struct PriorityFilterCap {
4176 id: &'static str,
4177 search_term: &'static str,
4178 priority: i32,
4179 }
4180
4181 struct PriorityFilterProvider {
4182 search_term: &'static str,
4183 priority: i32,
4184 }
4185
4186 impl Capability for PriorityFilterCap {
4187 fn id(&self) -> &str {
4188 self.id
4189 }
4190 fn name(&self) -> &str {
4191 self.id
4192 }
4193 fn description(&self) -> &str {
4194 "priority test"
4195 }
4196 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4197 Some(Arc::new(PriorityFilterProvider {
4198 search_term: self.search_term,
4199 priority: self.priority,
4200 }))
4201 }
4202 }
4203
4204 impl MessageFilterProvider for PriorityFilterProvider {
4205 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4206 query
4207 .filters
4208 .push(MessageFilter::Search(self.search_term.to_string()));
4209 }
4210 fn priority(&self) -> i32 {
4211 self.priority
4212 }
4213 }
4214
4215 let mut registry = CapabilityRegistry::new();
4216 registry.register(PriorityFilterCap {
4217 id: "gamma",
4218 search_term: "gamma",
4219 priority: 10,
4220 });
4221 registry.register(PriorityFilterCap {
4222 id: "alpha",
4223 search_term: "alpha",
4224 priority: 5,
4225 });
4226 registry.register(PriorityFilterCap {
4227 id: "beta",
4228 search_term: "beta",
4229 priority: 1,
4230 });
4231
4232 let configs = vec![
4233 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4234 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4235 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4236 ];
4237
4238 let collected = collect_message_filters_only(&configs, ®istry);
4239
4240 let session_id: SessionId = Uuid::now_v7().into();
4241 let mut query = MessageQuery::new(session_id);
4242 collected.apply_message_filters(&mut query);
4243
4244 assert_eq!(query.filters.len(), 3);
4246 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4247 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4248 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4249 }
4250
4251 #[test]
4252 fn test_collect_message_filters_only_post_load_invoked() {
4253 use crate::message::Message;
4254
4255 struct PostLoadCap;
4256 struct PostLoadProvider;
4257
4258 impl Capability for PostLoadCap {
4259 fn id(&self) -> &str {
4260 "post_load_test"
4261 }
4262 fn name(&self) -> &str {
4263 "PostLoad Test"
4264 }
4265 fn description(&self) -> &str {
4266 "test"
4267 }
4268 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4269 Some(Arc::new(PostLoadProvider))
4270 }
4271 }
4272
4273 impl MessageFilterProvider for PostLoadProvider {
4274 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4275 fn priority(&self) -> i32 {
4276 0
4277 }
4278 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4279 messages.reverse();
4281 }
4282 }
4283
4284 let mut registry = CapabilityRegistry::new();
4285 registry.register(PostLoadCap);
4286
4287 let configs = vec![AgentCapabilityConfig::with_config(
4288 CapabilityId::new("post_load_test"),
4289 serde_json::json!({}),
4290 )];
4291
4292 let collected = collect_message_filters_only(&configs, ®istry);
4293
4294 let mut messages = vec![Message::user("first"), Message::user("second")];
4295 collected.apply_post_load_filters(&mut messages);
4296
4297 assert_eq!(messages[0].text(), Some("second"));
4299 assert_eq!(messages[1].text(), Some("first"));
4300 }
4301
4302 struct DelegatingFilterCap {
4305 id: &'static str,
4306 inner: std::sync::Arc<InnerFilterCap>,
4307 }
4308 struct InnerFilterCap;
4309
4310 impl Capability for InnerFilterCap {
4311 fn id(&self) -> &str {
4312 "inner_filter"
4313 }
4314 fn tools(&self) -> Vec<Box<dyn Tool>> {
4315 panic!("fast-path collection must not instantiate tools")
4316 }
4317 fn system_prompt_addition(&self) -> Option<&str> {
4318 panic!("fast-path collection must not collect prompts")
4319 }
4320 fn name(&self) -> &str {
4321 "Inner Filter"
4322 }
4323 fn description(&self) -> &str {
4324 "inner"
4325 }
4326 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4327 Some(std::sync::Arc::new(SentinelFilter))
4328 }
4329 }
4330 struct SentinelFilter;
4331 impl MessageFilterProvider for SentinelFilter {
4332 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4333 query.limit = config["limit"].as_i64();
4334 }
4335 }
4336 impl Capability for DelegatingFilterCap {
4337 fn id(&self) -> &str {
4338 self.id
4339 }
4340 fn name(&self) -> &str {
4341 "Delegating Filter"
4342 }
4343 fn description(&self) -> &str {
4344 "delegating"
4345 }
4346 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4347 None }
4349 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4350 Some(&*self.inner)
4351 }
4352 }
4353
4354 #[test]
4355 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4356 let inner = std::sync::Arc::new(InnerFilterCap);
4357 let outer = DelegatingFilterCap {
4358 id: "delegating_filter",
4359 inner: inner.clone(),
4360 };
4361
4362 let mut registry = CapabilityRegistry::new();
4363 registry.register(outer);
4364
4365 let configs = vec![AgentCapabilityConfig::with_config(
4366 CapabilityId::new("delegating_filter"),
4367 serde_json::json!({"limit": 17}),
4368 )];
4369
4370 let collected = collect_message_filters_only(&configs, ®istry);
4373 assert_eq!(
4374 collected.message_filter_providers.len(),
4375 1,
4376 "provider from resolved inner capability must be collected"
4377 );
4378 let mut query = MessageQuery::default();
4379 collected.apply_message_filters(&mut query);
4380 assert_eq!(query.limit, Some(17));
4381 }
4382
4383 struct DelegatingMvpCap {
4384 id: &'static str,
4385 inner: std::sync::Arc<InnerMvpCap>,
4386 }
4387 struct InnerMvpCap;
4388
4389 impl Capability for InnerMvpCap {
4390 fn id(&self) -> &str {
4391 "inner_mvp"
4392 }
4393 fn tools(&self) -> Vec<Box<dyn Tool>> {
4394 panic!("fast-path collection must not instantiate tools")
4395 }
4396 fn system_prompt_addition(&self) -> Option<&str> {
4397 panic!("fast-path collection must not collect prompts")
4398 }
4399 fn name(&self) -> &str {
4400 "Inner MVP"
4401 }
4402 fn description(&self) -> &str {
4403 "inner"
4404 }
4405 fn model_view_provider(
4406 &self,
4407 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4408 struct AppendingMvp;
4410 impl crate::capabilities::ModelViewProvider for AppendingMvp {
4411 fn apply_model_view(
4412 &self,
4413 mut messages: Vec<Message>,
4414 config: &serde_json::Value,
4415 context: &ModelViewContext<'_>,
4416 ) -> Vec<Message> {
4417 messages.push(Message::user(format!(
4418 "{}:{}",
4419 config["suffix"].as_str().unwrap(),
4420 context.session_id
4421 )));
4422 messages
4423 }
4424 }
4425 Some(std::sync::Arc::new(AppendingMvp))
4426 }
4427 }
4428 impl Capability for DelegatingMvpCap {
4429 fn id(&self) -> &str {
4430 self.id
4431 }
4432 fn name(&self) -> &str {
4433 "Delegating MVP"
4434 }
4435 fn description(&self) -> &str {
4436 "delegating"
4437 }
4438 fn model_view_provider(
4439 &self,
4440 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4441 None }
4443 fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
4444 (model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
4445 }
4446 }
4447
4448 #[test]
4449 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4450 let inner = std::sync::Arc::new(InnerMvpCap);
4451 let outer = DelegatingMvpCap {
4452 id: "delegating_mvp",
4453 inner: inner.clone(),
4454 };
4455
4456 let mut registry = CapabilityRegistry::new();
4457 registry.register(outer);
4458
4459 let configs = vec![AgentCapabilityConfig::with_config(
4460 CapabilityId::new("delegating_mvp"),
4461 serde_json::json!({"suffix": "delegated"}),
4462 )];
4463
4464 let collected = collect_model_view_providers(&configs, ®istry, Some("selected-model"));
4467 assert_eq!(
4468 collected.model_view_providers.len(),
4469 1,
4470 "provider from resolved inner capability must be collected"
4471 );
4472 assert!(
4473 collect_model_view_providers(&configs, ®istry, Some("other-model"))
4474 .model_view_providers
4475 .is_empty()
4476 );
4477 let session_id = SessionId::from_seed(42);
4478 let output = collected.apply_model_view(
4479 vec![Message::user("original")],
4480 &ModelViewContext {
4481 session_id,
4482 prior_usage: None,
4483 },
4484 );
4485 assert_eq!(
4486 output.iter().map(Message::text).collect::<Vec<_>>(),
4487 [
4488 Some("original"),
4489 Some(format!("delegated:{session_id}").as_str())
4490 ]
4491 );
4492 }
4493
4494 #[test]
4499 fn test_defaults_do_not_include_bash() {
4500 let registry = crate::ToolRegistry::with_defaults();
4503 assert!(
4504 !registry.has("bash"),
4505 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4506 );
4507 }
4508
4509 #[test]
4514 fn test_alias_resolves_to_canonical_capability() {
4515 let registry = fixture_registry();
4516
4517 let via_alias = registry.get("virtual_bash").unwrap();
4519 assert_eq!(via_alias.id(), "bashkit_shell");
4520 assert!(registry.has("virtual_bash"));
4521 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4522 assert_eq!(
4523 registry.canonical_id("bashkit_shell"),
4524 Some("bashkit_shell")
4525 );
4526 assert_eq!(registry.canonical_id("nonexistent"), None);
4527 }
4528
4529 #[test]
4530 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4531 let registry = fixture_registry();
4532
4533 let resolved = resolve_dependencies(
4536 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4537 ®istry,
4538 )
4539 .unwrap();
4540 let bash_ids: Vec<_> = resolved
4541 .resolved_ids
4542 .iter()
4543 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4544 .collect();
4545 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4546 assert!(
4548 !resolved
4549 .added_as_dependencies
4550 .contains(&"bashkit_shell".to_string())
4551 );
4552 }
4553
4554 #[test]
4555 fn test_alias_preserves_explicit_config_in_resolution() {
4556 let registry = fixture_registry();
4557
4558 let configs = vec![AgentCapabilityConfig::with_config(
4559 "virtual_bash".to_string(),
4560 serde_json::json!({"key": "value"}),
4561 )];
4562 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4563 let bash = resolved
4564 .iter()
4565 .find(|c| c.capability_id() == "bashkit_shell")
4566 .expect("alias must resolve to canonical bashkit_shell config");
4567 assert_eq!(
4568 bash.config_value().clone(),
4569 serde_json::json!({"key": "value"})
4570 );
4571 }
4572
4573 #[test]
4574 fn test_unregister_by_alias_removes_capability_and_aliases() {
4575 let mut registry = fixture_registry();
4576
4577 assert!(registry.unregister("virtual_bash").is_some());
4578 assert!(!registry.has("bashkit_shell"));
4579 assert!(!registry.has("virtual_bash"));
4580 }
4581
4582 #[test]
4583 fn test_compute_features_empty() {
4584 let registry = CapabilityRegistry::new();
4585
4586 let features = compute_features(&[], ®istry);
4587 assert!(features.is_empty());
4588 }
4589
4590 #[test]
4591 fn test_compute_features_unknown_capability_ignored() {
4592 let registry = fixture_registry();
4593
4594 let features = compute_features(
4595 &["unknown_cap".to_string(), "session_storage".to_string()],
4596 ®istry,
4597 );
4598 assert_eq!(features, vec!["secrets", "key_value"]);
4599 }
4600
4601 #[test]
4602 fn test_risk_level_ordering() {
4603 assert!(RiskLevel::Low < RiskLevel::Medium);
4604 assert!(RiskLevel::Medium < RiskLevel::High);
4605 }
4606
4607 #[test]
4608 fn test_risk_level_serde_roundtrip() {
4609 for (level, wire) in [
4610 (RiskLevel::Low, "\"low\""),
4611 (RiskLevel::Medium, "\"medium\""),
4612 (RiskLevel::High, "\"high\""),
4613 ] {
4614 assert_eq!(serde_json::to_string(&level).unwrap(), wire);
4615 assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
4616 }
4617 assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
4618 }
4619
4620 struct SkillContributingCapability;
4625
4626 impl Capability for SkillContributingCapability {
4627 fn id(&self) -> &str {
4628 "contributes_skills"
4629 }
4630 fn name(&self) -> &str {
4631 "Contributes Skills"
4632 }
4633 fn description(&self) -> &str {
4634 "Test capability that contributes skills."
4635 }
4636 fn contribute_skills(&self) -> Vec<SkillContribution> {
4637 vec![
4638 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4639 .with_files(vec![(
4640 "scripts/a.sh".to_string(),
4641 "#!/bin/sh\necho a\n".to_string(),
4642 )]),
4643 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4644 .with_user_invocable(false),
4645 ]
4646 }
4647 }
4648
4649 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4650 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4651 MountSource::InlineFile { content, .. } => content.as_str(),
4652 _ => panic!("Expected InlineFile for SKILL.md"),
4653 }
4654 }
4655
4656 #[tokio::test]
4657 async fn test_contribute_skills_normalized_to_mounts() {
4658 let mut registry = CapabilityRegistry::new();
4659 registry.register(SkillContributingCapability);
4660
4661 let configs = vec![AgentCapabilityConfig::with_config(
4662 CapabilityId::new("contributes_skills"),
4663 serde_json::json!({}),
4664 )];
4665
4666 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4667
4668 let skill_mounts: Vec<_> = collected
4669 .mounts
4670 .iter()
4671 .filter(|m| m.path.starts_with("/.agents/skills/"))
4672 .collect();
4673 assert_eq!(skill_mounts.len(), 2);
4674
4675 for m in &skill_mounts {
4678 assert!(m.is_readonly());
4679 assert_eq!(m.capability_id, "contributes_skills");
4680 }
4681
4682 let alpha = skill_mounts
4683 .iter()
4684 .find(|m| m.path == "/.agents/skills/alpha-skill")
4685 .expect("alpha-skill mount missing");
4686 match &alpha.source {
4687 MountSource::InlineDirectory { entries } => {
4688 assert!(entries.contains_key("SKILL.md"));
4689 assert!(entries.contains_key("scripts/a.sh"));
4690 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4691 assert_eq!(parsed.name, "alpha-skill");
4692 assert_eq!(parsed.description, "Alpha skill desc");
4693 assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
4694 assert!(parsed.user_invocable);
4695 }
4696 _ => panic!("Expected InlineDirectory"),
4697 }
4698
4699 let beta = skill_mounts
4700 .iter()
4701 .find(|m| m.path == "/.agents/skills/beta-skill")
4702 .expect("beta-skill mount missing");
4703 match &beta.source {
4704 MountSource::InlineDirectory { entries } => {
4705 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4706 assert!(!parsed.user_invocable);
4707 assert_eq!(parsed.name, "beta-skill");
4708 assert_eq!(parsed.instructions, "# Beta\nDo beta.");
4709 }
4710 _ => panic!("Expected InlineDirectory"),
4711 }
4712 }
4713
4714 #[tokio::test]
4715 async fn test_contribute_skills_default_empty() {
4716 let mut registry = CapabilityRegistry::new();
4719 registry.register(FilterTestCapability { priority: 0 });
4720
4721 let configs = vec![AgentCapabilityConfig::with_config(
4722 CapabilityId::new("filter_test"),
4723 serde_json::json!({}),
4724 )];
4725
4726 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4727 assert!(
4728 collected
4729 .mounts
4730 .iter()
4731 .all(|m| !m.path.starts_with("/.agents/skills/"))
4732 );
4733 }
4734
4735 struct LocalizedCapability;
4736
4737 impl Capability for LocalizedCapability {
4738 fn id(&self) -> &str {
4739 "localized"
4740 }
4741 fn name(&self) -> &str {
4742 "Localized"
4743 }
4744 fn description(&self) -> &str {
4745 "English description"
4746 }
4747 fn localizations(&self) -> Vec<CapabilityLocalization> {
4748 vec![
4749 CapabilityLocalization {
4750 locale: "en",
4751 name: None,
4752 description: None,
4753 config_description: Some("Controls things."),
4754 config_overlay: None,
4755 },
4756 CapabilityLocalization {
4757 locale: "uk-UA",
4758 name: Some("Регіональна"),
4759 description: None,
4760 config_description: None,
4761 config_overlay: None,
4762 },
4763 CapabilityLocalization {
4764 locale: "uk",
4765 name: Some("Локалізована"),
4766 description: Some("Український опис"),
4767 config_description: Some("Керує налаштуваннями."),
4768 config_overlay: None,
4769 },
4770 ]
4771 }
4772 }
4773
4774 #[test]
4775 fn localized_name_falls_back_exact_language_then_base() {
4776 let cap = LocalizedCapability;
4777 assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
4779 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
4780 assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
4781 assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
4782 assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
4783 assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
4785 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
4787 assert_eq!(cap.localized_name(None), "Localized");
4788 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
4789 assert_eq!(cap.localized_description(Some("de")), "English description");
4790 }
4791
4792 #[test]
4793 fn describe_schema_resolves_config_description_per_locale() {
4794 let cap = LocalizedCapability;
4795 assert_eq!(
4796 cap.describe_schema(Some("uk-UA")).as_deref(),
4797 Some("Керує налаштуваннями.")
4798 );
4799 assert_eq!(
4801 cap.describe_schema(Some("pl")).as_deref(),
4802 Some("Controls things.")
4803 );
4804 assert_eq!(
4805 cap.describe_schema(None).as_deref(),
4806 Some("Controls things.")
4807 );
4808 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
4810 }
4811
4812 #[tokio::test]
4813 async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
4814 let registry = fixture_registry();
4815 for (ids, expected) in [
4816 (
4817 vec!["test_math"],
4818 vec![
4819 ("add", "test_math", "Test Math"),
4820 ("subtract", "test_math", "Test Math"),
4821 ("multiply", "test_math", "Test Math"),
4822 ("divide", "test_math", "Test Math"),
4823 ],
4824 ),
4825 (
4826 vec!["test_weather"],
4827 vec![
4828 ("get_weather", "test_weather", "Test Weather"),
4829 ("get_forecast", "test_weather", "Test Weather"),
4830 ],
4831 ),
4832 (
4833 vec!["sample_data"],
4834 vec![
4835 ("read_file", "session_file_system", "Fixture Filesystem"),
4836 ("write_file", "session_file_system", "Fixture Filesystem"),
4837 ],
4838 ),
4839 (
4840 vec!["bashkit_shell", "test_weather"],
4841 vec![
4842 ("read_file", "session_file_system", "Fixture Filesystem"),
4843 ("write_file", "session_file_system", "Fixture Filesystem"),
4844 ("bash", "bashkit_shell", "Fixture Bash"),
4845 ("get_weather", "test_weather", "Test Weather"),
4846 ("get_forecast", "test_weather", "Test Weather"),
4847 ],
4848 ),
4849 ] {
4850 let ids: Vec<_> = ids.into_iter().map(String::from).collect();
4851 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4852 assert_eq!(
4853 collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
4854 expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
4855 );
4856 assert_eq!(collected.tool_definitions.len(), expected.len());
4857 for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
4858 assert_eq!(definition.name(), name);
4859 let hints = definition.hints();
4860 assert_eq!(hints.capability_id.as_deref(), Some(id));
4861 assert_eq!(hints.capability_name.as_deref(), Some(label));
4862 let ToolDefinition::Builtin(tool) = definition else {
4863 panic!("expected builtin")
4864 };
4865 let schema = if name == "bash" {
4866 serde_json::json!({"type":"object"})
4867 } else {
4868 serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
4869 };
4870 assert_eq!(tool.parameters, schema);
4871 }
4872 }
4873 }
4874
4875 #[tokio::test]
4876 async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
4877 let registry = fixture_registry();
4878 let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
4879 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4880 let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
4881 let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
4882 assert_eq!(collected.system_prompt_parts, vec![first, second]);
4883 assert_eq!(
4884 collected.system_prompt_attributions,
4885 vec![
4886 SystemPromptAttribution {
4887 capability_id: ids[0].clone(),
4888 content: first.into()
4889 },
4890 SystemPromptAttribution {
4891 capability_id: ids[1].clone(),
4892 content: second.into()
4893 }
4894 ]
4895 );
4896 assert_eq!(
4897 collected.system_prompt_prefix(),
4898 Some(format!("{first}\n\n{second}"))
4899 );
4900 let applied = apply_capabilities(
4901 RuntimeAgent::new("Base.", "fixture-model"),
4902 &ids,
4903 ®istry,
4904 &test_ctx(),
4905 )
4906 .await;
4907 assert_eq!(
4908 applied.runtime_agent.system_prompt,
4909 format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
4910 );
4911 assert!(applied.tool_registry.has("write_todos"));
4912 assert_eq!(applied.tool_registry.len(), 1);
4913 for (base, addition, expected) in [
4914 ("Base.", None, "Base."),
4915 ("Base.", Some(""), "Base."),
4916 ("", Some("Extra."), "Extra."),
4917 (
4918 "<system-prompt>Base.</system-prompt>",
4919 Some("Extra."),
4920 "<system-prompt>Base.</system-prompt>\n\nExtra.",
4921 ),
4922 ] {
4923 assert_eq!(compose_system_prompt(base, addition), expected);
4924 }
4925 }
4926
4927 struct DependencyFixture {
4928 id: String,
4929 deps: Vec<&'static str>,
4930 features: Vec<&'static str>,
4931 }
4932 impl Capability for DependencyFixture {
4933 fn id(&self) -> &str {
4934 &self.id
4935 }
4936 fn name(&self) -> &str {
4937 &self.id
4938 }
4939 fn description(&self) -> &str {
4940 "Dependency fixture"
4941 }
4942 fn dependencies(&self) -> Vec<&'static str> {
4943 self.deps.clone()
4944 }
4945 fn features(&self) -> Vec<&'static str> {
4946 self.features.clone()
4947 }
4948 }
4949
4950 #[test]
4951 fn feature_projection_preserves_order_and_distinct_dependency_features() {
4952 let mut registry = CapabilityRegistry::new();
4953 registry.register(DependencyFixture {
4954 id: "base".into(),
4955 deps: vec![],
4956 features: vec!["base-only", "shared"],
4957 });
4958 registry.register(DependencyFixture {
4959 id: "parent".into(),
4960 deps: vec!["base"],
4961 features: vec!["parent-only", "shared"],
4962 });
4963 registry.register(DependencyFixture {
4964 id: "other".into(),
4965 deps: vec![],
4966 features: vec!["other-only"],
4967 });
4968 assert_eq!(
4969 compute_features(&["parent".into()], ®istry),
4970 vec!["base-only", "shared", "parent-only"]
4971 );
4972 assert_eq!(
4973 compute_features(
4974 &[
4975 "other".into(),
4976 "parent".into(),
4977 "base".into(),
4978 "parent".into()
4979 ],
4980 ®istry
4981 ),
4982 vec!["other-only", "base-only", "shared", "parent-only"]
4983 );
4984 }
4985
4986 #[test]
4987 fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
4988 let mut registry = CapabilityRegistry::new();
4989 let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
4990 for id in &ids {
4991 registry.register(DependencyFixture {
4992 id: id.clone(),
4993 deps: vec![],
4994 features: vec![],
4995 });
4996 }
4997 let resolved = resolve_dependencies(&ids[..100], ®istry).unwrap();
4998 assert_eq!(resolved.resolved_ids, ids[..100]);
4999 assert_eq!(resolved.user_selected, ids[..100]);
5000 assert!(resolved.added_as_dependencies.is_empty());
5001 assert_eq!(
5002 resolve_dependencies(&ids, ®istry).unwrap_err(),
5003 DependencyError::TooManyCapabilities {
5004 count: 101,
5005 max: 100
5006 }
5007 );
5008 }
5009}