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 pub session_storage: Option<Arc<dyn crate::session_services::SessionStorageStore>>,
146}
147
148impl SystemPromptContext {
149 pub fn without_file_store(session_id: SessionId) -> Self {
151 Self {
152 session_id,
153 locale: None,
154 file_store: None,
155 model: None,
156 session_storage: None,
157 }
158 }
159
160 pub fn with_model(mut self, model: impl Into<String>) -> Self {
162 self.model = Some(model.into());
163 self
164 }
165}
166
167#[derive(Debug, Clone)]
219pub struct CapabilityLocalization {
220 pub locale: &'static str,
222 pub name: Option<&'static str>,
224 pub description: Option<&'static str>,
226 pub config_description: Option<&'static str>,
231 pub config_overlay: Option<serde_json::Value>,
237}
238
239impl CapabilityLocalization {
240 pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
242 Self {
243 locale,
244 name: Some(name),
245 description: Some(description),
246 config_description: None,
247 config_overlay: None,
248 }
249 }
250}
251
252pub fn resolve_localized_field<T>(
256 localizations: &[CapabilityLocalization],
257 locale: Option<&str>,
258 field: impl Fn(&CapabilityLocalization) -> Option<T>,
259) -> Option<T> {
260 let mut candidates: Vec<String> = Vec::new();
261 if let Some(raw) = locale {
262 let normalized = raw.trim().replace('_', "-").to_lowercase();
263 if !normalized.is_empty() {
264 if let Some((language, _)) = normalized.split_once('-') {
265 let language = language.to_string();
266 candidates.push(normalized);
267 candidates.push(language);
268 } else {
269 candidates.push(normalized);
270 }
271 }
272 }
273 candidates.push("en".to_string());
274
275 for candidate in candidates {
276 let hit = localizations
277 .iter()
278 .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
279 .and_then(&field);
280 if hit.is_some() {
281 return hit;
282 }
283 }
284 None
285}
286
287#[async_trait]
288pub trait Capability: Send + Sync {
289 fn native_async_tools(
292 &self,
293 _config: &serde_json::Value,
294 ) -> Option<std::collections::BTreeMap<String, Option<serde_json::Value>>> {
295 None
296 }
297 fn id(&self) -> &str;
299
300 fn aliases(&self) -> Vec<&'static str> {
309 vec![]
310 }
311
312 fn name(&self) -> &str;
314
315 fn description(&self) -> &str;
317
318 fn localizations(&self) -> Vec<CapabilityLocalization> {
323 vec![]
324 }
325
326 fn localized_name(&self, locale: Option<&str>) -> String {
329 resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
330 .unwrap_or_else(|| self.name())
331 .to_string()
332 }
333
334 fn localized_description(&self, locale: Option<&str>) -> String {
336 resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
337 .unwrap_or_else(|| self.description())
338 .to_string()
339 }
340
341 fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
345 resolve_localized_field(&self.localizations(), locale, |entry| {
346 entry.config_description
347 })
348 .map(str::to_string)
349 }
350
351 fn status(&self) -> CapabilityStatus {
353 CapabilityStatus::Available
354 }
355
356 fn icon(&self) -> Option<&str> {
358 None
359 }
360
361 fn category(&self) -> Option<&str> {
363 None
364 }
365
366 fn metadata(&self) -> Option<serde_json::Value> {
378 None
379 }
380
381 fn is_guardrail(&self) -> bool {
386 false
387 }
388
389 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
400 None
401 }
402
403 fn system_prompt_addition(&self) -> Option<&str> {
423 None
424 }
425
426 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
438 self.system_prompt_addition().map(|addition| {
439 format!(
440 "<capability id=\"{}\">\n{}\n</capability>",
441 self.id(),
442 addition
443 )
444 })
445 }
446
447 fn system_prompt_preview(&self) -> Option<String> {
453 self.system_prompt_addition().map(|s| s.to_string())
454 }
455
456 fn tools(&self) -> Vec<Box<dyn Tool>> {
458 vec![]
459 }
460
461 fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
469 self.tools()
470 }
471
472 fn delegation_target_with_config(
478 &self,
479 _config: &serde_json::Value,
480 ) -> Option<DelegationTargetProvider> {
481 None
482 }
483
484 fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
488 false
489 }
490
491 async fn system_prompt_contribution_with_config(
498 &self,
499 ctx: &SystemPromptContext,
500 _config: &serde_json::Value,
501 ) -> Option<String> {
502 self.system_prompt_contribution(ctx).await
503 }
504
505 async fn conversation_context_contribution(
516 &self,
517 _ctx: &SystemPromptContext,
518 ) -> Option<String> {
519 None
520 }
521
522 async fn conversation_context_contribution_with_config(
527 &self,
528 ctx: &SystemPromptContext,
529 _config: &serde_json::Value,
530 ) -> Option<String> {
531 self.conversation_context_contribution(ctx).await
532 }
533
534 fn tool_definitions(&self) -> Vec<ToolDefinition> {
537 self.tools().iter().map(|t| t.to_definition()).collect()
538 }
539
540 fn mounts(&self) -> Vec<MountPoint> {
548 vec![]
549 }
550
551 fn dependencies(&self) -> Vec<&'static str> {
560 vec![]
561 }
562
563 fn features(&self) -> Vec<&'static str> {
578 vec![]
579 }
580
581 fn config_schema(&self) -> Option<serde_json::Value> {
587 None
588 }
589
590 fn config_ui_schema(&self) -> Option<serde_json::Value> {
595 None
596 }
597
598 fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
604 Ok(())
605 }
606
607 fn mcp_servers(&self) -> ScopedMcpServers {
613 ScopedMcpServers::default()
614 }
615
616 fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
618 self.mcp_servers()
619 }
620
621 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
634 None
635 }
636
637 fn message_filter_config(
642 &self,
643 config: &serde_json::Value,
644 _compaction_enabled: bool,
645 ) -> serde_json::Value {
646 config.clone()
647 }
648
649 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
657 None
658 }
659
660 fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
672 None
673 }
674
675 fn tool_search_config(
679 &self,
680 _config: &serde_json::Value,
681 ) -> Option<crate::driver_registry::ToolSearchConfig> {
682 None
683 }
684
685 fn prompt_cache_config(
688 &self,
689 _config: &serde_json::Value,
690 ) -> Option<crate::driver_registry::PromptCacheConfig> {
691 None
692 }
693
694 fn driver_options(&self, _config: &serde_json::Value) -> Vec<(String, serde_json::Value)> {
698 Vec::new()
699 }
700
701 fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
704 None
705 }
706
707 fn error_disclosure(
709 &self,
710 _config: &serde_json::Value,
711 ) -> Option<crate::user_facing_error::ErrorDisclosure> {
712 None
713 }
714
715 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
718 text
719 }
720
721 fn compaction_policy(
725 &self,
726 _config: &serde_json::Value,
727 ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
728 None
729 }
730
731 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
746 vec![]
747 }
748
749 fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
760 vec![]
761 }
762
763 fn pre_tool_use_hooks_with_config(
768 &self,
769 _config: &serde_json::Value,
770 ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
771 self.pre_tool_use_hooks()
772 }
773
774 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
782 vec![]
783 }
784
785 fn post_tool_exec_hooks_with_config(
790 &self,
791 _config: &serde_json::Value,
792 ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
793 self.post_tool_exec_hooks()
794 }
795
796 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
805 vec![]
806 }
807
808 fn tool_definition_hooks_with_config(
813 &self,
814 _config: &serde_json::Value,
815 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
816 self.tool_definition_hooks()
817 }
818
819 fn tool_definition_hooks_with_context(
829 &self,
830 _ctx: &SystemPromptContext,
831 config: &serde_json::Value,
832 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
833 self.tool_definition_hooks_with_config(config)
834 }
835
836 fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
844 vec![]
845 }
846
847 fn finalized_tool_calls_hook(
851 &self,
852 _config: &serde_json::Value,
853 ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
854 None
855 }
856
857 fn narrate(
871 &self,
872 _tool_def: Option<&ToolDefinition>,
873 tool_call: &ToolCall,
874 phase: crate::tool_narration::ToolNarrationPhase,
875 locale: Option<&str>,
876 ctx: crate::tool_narration::ToolNarrationContext<'_>,
877 ) -> Option<String> {
878 self.tools()
879 .iter()
880 .find(|tool| tool.name() == tool_call.name)
881 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
882 }
883
884 fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
900 vec![]
901 }
902
903 fn user_hooks_with_config(
909 &self,
910 _config: &serde_json::Value,
911 ) -> Vec<crate::user_hook_types::UserHookSpec> {
912 self.user_hooks()
913 }
914
915 fn risk_level(&self) -> RiskLevel {
923 RiskLevel::Low
924 }
925
926 fn commands(&self) -> Vec<CommandDescriptor> {
934 vec![]
935 }
936
937 async fn execute_command(
951 &self,
952 request: &ExecuteCommandRequest,
953 _ctx: &CommandExecutionContext,
954 ) -> crate::error::Result<CommandResult> {
955 Err(crate::error::AgentLoopError::config(format!(
956 "capability {} declared command /{} but does not implement execute_command",
957 self.id(),
958 request.name,
959 )))
960 }
961
962 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
971 vec![]
972 }
973
974 fn contribute_skills(&self) -> Vec<SkillContribution> {
984 vec![]
985 }
986
987 fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
998 vec![]
999 }
1000
1001 fn post_output_guardrails_with_config(
1013 &self,
1014 _config: &serde_json::Value,
1015 ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
1016 vec![]
1017 }
1018
1019 fn post_output_annotation_hooks_with_config(
1035 &self,
1036 _config: &serde_json::Value,
1037 ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1038 vec![]
1039 }
1040
1041 fn citation_verifier_with_config(
1051 &self,
1052 _config: &serde_json::Value,
1053 ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1054 None
1055 }
1056}
1057
1058pub trait ToolDefinitionHook: Send + Sync {
1059 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1060
1061 fn applies_with_native_tool_search(&self) -> bool {
1066 true
1067 }
1068}
1069
1070pub trait ToolCallHook: Send + Sync {
1071 fn narration(
1072 &self,
1073 _tool_def: Option<&ToolDefinition>,
1074 _tool_call: &ToolCall,
1075 _phase: crate::tool_narration::ToolNarrationPhase,
1076 _locale: Option<&str>,
1077 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1078 ) -> Option<String> {
1079 None
1080 }
1081
1082 fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1083 tool_call
1084 }
1085}
1086
1087pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1093
1094impl ToolCallHook for CapabilityNarrationHook {
1095 fn narration(
1096 &self,
1097 tool_def: Option<&ToolDefinition>,
1098 tool_call: &ToolCall,
1099 phase: crate::tool_narration::ToolNarrationPhase,
1100 locale: Option<&str>,
1101 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1102 ) -> Option<String> {
1103 self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1104 }
1105}
1106
1107#[derive(
1111 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1112)]
1113#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1114#[cfg_attr(feature = "openapi", schema(example = "low"))]
1115#[serde(rename_all = "lowercase")]
1116pub enum RiskLevel {
1117 Low,
1119 Medium,
1121 High,
1123}
1124
1125#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[serde(rename_all = "snake_case")]
1132pub enum BlueprintModel {
1133 Fixed(String),
1135 Default(String),
1137 Inherit,
1139}
1140
1141pub struct AgentBlueprint {
1147 pub id: &'static str,
1149 pub name: &'static str,
1151 pub description: &'static str,
1153 pub model: BlueprintModel,
1155 pub system_prompt: &'static str,
1157 pub tools: Vec<Box<dyn Tool>>,
1159 pub max_turns: Option<usize>,
1161 pub config_schema: Option<serde_json::Value>,
1163}
1164
1165impl AgentBlueprint {
1166 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1168 self.tools.iter().map(|t| t.to_definition()).collect()
1169 }
1170}
1171
1172impl std::fmt::Debug for AgentBlueprint {
1173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1174 f.debug_struct("AgentBlueprint")
1175 .field("id", &self.id)
1176 .field("name", &self.name)
1177 .field("model", &self.model)
1178 .field("tool_count", &self.tools.len())
1179 .field("max_turns", &self.max_turns)
1180 .finish()
1181 }
1182}
1183
1184#[derive(Clone)]
1202pub struct CapabilityRegistry {
1203 capabilities: HashMap<String, Arc<dyn Capability>>,
1204 index: everruns_capability::CapabilityIdIndex,
1208}
1209
1210impl CapabilityRegistry {
1211 pub fn new() -> Self {
1213 Self {
1214 capabilities: HashMap::new(),
1215 index: everruns_capability::CapabilityIdIndex::new(),
1216 }
1217 }
1218
1219 pub fn register(&mut self, capability: impl Capability + 'static) {
1221 self.register_arc(Arc::new(capability));
1222 }
1223
1224 pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1226 self.register_arc(Arc::from(capability));
1227 }
1228
1229 pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1235 let canonical = capability.id().to_string();
1236 self.index
1237 .insert_or_replace(canonical.clone(), &capability.aliases());
1238 self.capabilities.insert(canonical, capability);
1239 }
1240
1241 pub fn try_register_arc(
1244 &mut self,
1245 capability: Arc<dyn Capability>,
1246 ) -> Result<(), everruns_capability::CapabilityError> {
1247 let canonical = capability.id().to_string();
1248 self.index
1249 .insert(canonical.clone(), &capability.aliases())?;
1250 self.capabilities.insert(canonical, capability);
1251 Ok(())
1252 }
1253
1254 pub fn register_inventory_plugins(
1259 &mut self,
1260 mut include: impl FnMut(&IntegrationPlugin) -> bool,
1261 ) {
1262 for plugin in inventory::iter::<IntegrationPlugin>() {
1263 if include(plugin) {
1264 self.register_boxed((plugin.factory)());
1265 }
1266 }
1267 }
1268
1269 pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1271 self.capabilities.get(self.index.canonical_of(id)?)
1272 }
1273
1274 pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1279 self.index.canonical_of(id)
1280 }
1281
1282 pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1284 let canonical = self.index.remove(id)?;
1285 self.capabilities.remove(&canonical)
1286 }
1287
1288 pub fn has(&self, id: &str) -> bool {
1290 self.get(id).is_some()
1291 }
1292
1293 pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1295 self.capabilities.values().collect()
1296 }
1297
1298 pub fn len(&self) -> usize {
1300 self.capabilities.len()
1301 }
1302
1303 pub fn is_empty(&self) -> bool {
1305 self.capabilities.is_empty()
1306 }
1307
1308 pub fn builder() -> CapabilityRegistryBuilder {
1310 CapabilityRegistryBuilder::new()
1311 }
1312
1313 pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1317 for cap in self.capabilities.values() {
1318 for bp in cap.agent_blueprints() {
1319 if bp.id == id {
1320 return Some(bp);
1321 }
1322 }
1323 }
1324 None
1325 }
1326
1327 pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1331 for (capability_id, cap) in &self.capabilities {
1332 for bp in cap.agent_blueprints() {
1333 if bp.id == id {
1334 return Some((capability_id.clone(), bp));
1335 }
1336 }
1337 }
1338 None
1339 }
1340
1341 pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1343 self.capabilities
1344 .values()
1345 .flat_map(|cap| cap.agent_blueprints())
1346 .collect()
1347 }
1348}
1349
1350impl Default for CapabilityRegistry {
1351 fn default() -> Self {
1352 Self::new()
1353 }
1354}
1355
1356impl std::fmt::Debug for CapabilityRegistry {
1357 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1358 let ids: Vec<_> = self.capabilities.keys().collect();
1359 f.debug_struct("CapabilityRegistry")
1360 .field("capabilities", &ids)
1361 .finish()
1362 }
1363}
1364
1365pub struct CapabilityRegistryBuilder {
1367 registry: CapabilityRegistry,
1368}
1369
1370impl CapabilityRegistryBuilder {
1371 pub fn new() -> Self {
1373 Self {
1374 registry: CapabilityRegistry::new(),
1375 }
1376 }
1377
1378 pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1380 self.registry.register(capability);
1381 self
1382 }
1383
1384 pub fn build(self) -> CapabilityRegistry {
1386 self.registry
1387 }
1388}
1389
1390impl Default for CapabilityRegistryBuilder {
1391 fn default() -> Self {
1392 Self::new()
1393 }
1394}
1395
1396pub struct ModelViewContext<'a> {
1402 pub session_id: SessionId,
1403 pub prior_usage: Option<&'a TokenUsage>,
1404}
1405
1406pub trait ModelViewProvider: Send + Sync {
1412 fn apply_model_view(
1413 &self,
1414 messages: Vec<Message>,
1415 config: &serde_json::Value,
1416 context: &ModelViewContext<'_>,
1417 ) -> Vec<Message>;
1418
1419 fn priority(&self) -> i32 {
1420 0
1421 }
1422}
1423
1424pub struct CollectedCapabilities {
1429 pub system_prompt_parts: Vec<String>,
1431 pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1433 pub conversation_context_parts: Vec<String>,
1440 pub conversation_context_attributions: Vec<SystemPromptAttribution>,
1442 pub tools: Vec<Box<dyn Tool>>,
1444 pub tool_definitions: Vec<ToolDefinition>,
1446 pub mounts: Vec<MountPoint>,
1448 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1450 pub applied_ids: Vec<String>,
1452 pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1454 pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1456 pub driver_options: HashMap<String, serde_json::Value>,
1460 pub parallel_tool_calls: Option<bool>,
1464 pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1466 pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1468 pub mcp_servers: ScopedMcpServers,
1470 }
1476
1477#[derive(Debug, Clone, PartialEq, Eq)]
1478pub struct SystemPromptAttribution {
1479 pub capability_id: String,
1480 pub content: String,
1481}
1482
1483impl CollectedCapabilities {
1484 pub fn system_prompt_prefix(&self) -> Option<String> {
1487 if self.system_prompt_parts.is_empty() {
1488 None
1489 } else {
1490 Some(self.system_prompt_parts.join("\n\n"))
1491 }
1492 }
1493
1494 pub fn conversation_context(&self) -> Option<String> {
1498 if self.conversation_context_parts.is_empty() {
1499 None
1500 } else {
1501 Some(self.conversation_context_parts.join("\n\n"))
1502 }
1503 }
1504
1505 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1509 for (provider, config) in &self.message_filter_providers {
1511 provider.apply_filters(query, config);
1512 }
1513 }
1514
1515 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1518 for (provider, config) in &self.message_filter_providers {
1519 provider.post_load(messages, config);
1520 }
1521 }
1522
1523 pub fn has_message_filters(&self) -> bool {
1525 !self.message_filter_providers.is_empty()
1526 }
1527}
1528
1529pub struct DelegationTargetProvider {
1530 pub target_type: &'static str,
1531 pub tool: Box<dyn Tool>,
1532}
1533
1534#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1536#[serde(rename_all = "snake_case")]
1537pub enum SpawnMode {
1538 Background,
1539 Foreground,
1540}
1541
1542impl SpawnMode {
1543 pub fn parse(value: &str) -> Option<Self> {
1544 match value {
1545 "background" => Some(Self::Background),
1546 "foreground" => Some(Self::Foreground),
1547 _ => None,
1548 }
1549 }
1550
1551 pub fn as_str(self) -> &'static str {
1552 match self {
1553 Self::Background => "background",
1554 Self::Foreground => "foreground",
1555 }
1556 }
1557}
1558
1559struct UnifiedSpawnAgentTool {
1560 providers: Vec<DelegationTargetProvider>,
1561}
1562
1563fn validate_spawn_agent_target_fields(
1564 arguments: &serde_json::Value,
1565 target_type: &str,
1566) -> Result<(), String> {
1567 for field in ["blueprint", "config"] {
1568 if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
1569 return Err(format!(
1570 "{field} is only valid for subagent targets, not {target_type}."
1571 ));
1572 }
1573 }
1574 Ok(())
1575}
1576
1577impl UnifiedSpawnAgentTool {
1578 fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1579 Self { providers }
1580 }
1581
1582 fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1583 self.providers
1584 .iter()
1585 .find(|provider| provider.target_type == target_type)
1586 .map(|provider| provider.tool.as_ref())
1587 }
1588
1589 fn target_types(&self) -> Vec<&'static str> {
1590 ["subagent", "agent", "external_a2a"]
1591 .into_iter()
1592 .filter(|target_type| {
1593 self.providers
1594 .iter()
1595 .any(|provider| provider.target_type == *target_type)
1596 })
1597 .collect()
1598 }
1599
1600 fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1605 self.target_types()
1606 .into_iter()
1607 .filter_map(|target_type| match target_type {
1608 "subagent" => Some(serde_json::json!({
1609 "properties": {
1610 "type": {"const": "subagent"}
1611 }
1612 })),
1613 "agent" => Some(serde_json::json!({
1614 "properties": {
1615 "type": {"const": "agent"}
1616 },
1617 "required": ["type", "id"]
1618 })),
1619 "external_a2a" => Some(serde_json::json!({
1620 "properties": {
1621 "type": {"const": "external_a2a"}
1622 },
1623 "anyOf": [
1624 {"required": ["id"]},
1625 {"required": ["external_agent_id"]}
1626 ]
1627 })),
1628 _ => None,
1629 })
1630 .collect()
1631 }
1632
1633 }
1643
1644#[async_trait]
1645impl Tool for UnifiedSpawnAgentTool {
1646 fn narrate(
1647 &self,
1648 tool_call: &ToolCall,
1649 phase: crate::tool_narration::ToolNarrationPhase,
1650 locale: Option<&str>,
1651 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1652 ) -> Option<String> {
1653 let from_provider = tool_call
1657 .arguments
1658 .get("target")
1659 .and_then(|target| target.get("type"))
1660 .and_then(serde_json::Value::as_str)
1661 .and_then(|target_type| self.provider_for(target_type))
1662 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1663 Some(from_provider.unwrap_or_else(|| {
1664 crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1665 }))
1666 }
1667
1668 fn name(&self) -> &str {
1669 "spawn_agent"
1670 }
1671
1672 fn display_name(&self) -> Option<&str> {
1673 Some("Spawn Agent")
1674 }
1675
1676 fn description(&self) -> &str {
1677 "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."
1678 }
1679
1680 fn parameters_schema(&self) -> serde_json::Value {
1681 serde_json::json!({
1682 "type": "object",
1683 "properties": {
1684 "name": {
1685 "type": "string",
1686 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1687 },
1688 "instructions": {
1689 "type": "string",
1690 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1691 },
1692 "goal": {
1693 "type": "string",
1694 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1695 },
1696 "lifetime": {
1697 "type": "string",
1698 "enum": ["linked", "detached"],
1699 "default": "linked",
1700 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1701 },
1702 "seed": {
1703 "type": "string",
1704 "enum": ["fresh", "fork", "workspace"],
1705 "default": "fresh",
1706 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1707 },
1708 "target": {
1709 "type": "object",
1710 "properties": {
1711 "type": {
1712 "type": "string",
1713 "enum": self.target_types(),
1714 "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."
1715 },
1716 "id": {
1717 "type": "string",
1718 "description": "Configured target id for first-party handoffs or external A2A agents."
1719 },
1720 "external_agent_id": {
1721 "type": "string",
1722 "description": "Configured external A2A agent id."
1723 }
1724 },
1725 "required": ["type"],
1726 "oneOf": self.target_constraint_branches(),
1727 "additionalProperties": false
1728 },
1729 "mode": {
1730 "type": "string",
1731 "enum": ["background", "foreground"],
1732 "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."
1733 },
1734 "blueprint": {
1735 "type": "string",
1736 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1737 },
1738 "config": {
1739 "type": "object",
1740 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1741 },
1742 "result_schema": {
1743 "type": "object",
1744 "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."
1745 },
1746 "message_schema": {
1747 "type": "object",
1748 "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."
1749 },
1750 "public_context": {
1751 "type": "object",
1752 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1753 },
1754 "wait_timeout_secs": {
1755 "type": "integer",
1756 "minimum": 1,
1757 "maximum": 86400,
1758 "description": "External-A2A-only foreground timeout."
1759 },
1760 "wake_on_completion": {
1761 "type": "boolean",
1762 "description": "External-A2A-only control for background completion wake-ups."
1763 }
1764 },
1765 "required": ["name", "instructions", "target"],
1766 "additionalProperties": false
1767 })
1768 }
1769
1770 fn hints(&self) -> crate::tool_types::ToolHints {
1771 let mut hints = crate::tool_types::ToolHints::default()
1772 .with_long_running(true)
1773 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1774 if self.provider_for("external_a2a").is_some() {
1775 hints = hints.with_open_world(true);
1776 }
1777 hints
1778 }
1779
1780 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1781 ToolExecutionResult::tool_error(
1782 "spawn_agent requires context. This tool must be executed with session context.",
1783 )
1784 }
1785
1786 async fn execute_with_context(
1787 &self,
1788 arguments: serde_json::Value,
1789 context: &ToolContext,
1790 ) -> ToolExecutionResult {
1791 let target_type = match arguments
1792 .get("target")
1793 .and_then(|target| target.get("type"))
1794 .and_then(serde_json::Value::as_str)
1795 {
1796 Some(target_type) => target_type,
1797 None => {
1798 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1799 }
1800 };
1801
1802 let Some(provider) = self.provider_for(target_type) else {
1803 let supported = self.target_types().join(", ");
1804 return ToolExecutionResult::tool_error(format!(
1805 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1806 ));
1807 };
1808 if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
1809 return ToolExecutionResult::tool_error(error);
1810 }
1811 if target_type == "external_a2a"
1812 && arguments
1813 .get("lifetime")
1814 .and_then(serde_json::Value::as_str)
1815 .is_some_and(|value| value == "detached")
1816 {
1817 return ToolExecutionResult::tool_error(
1818 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1819 );
1820 }
1821 if target_type == "external_a2a"
1822 && arguments
1823 .get("message_schema")
1824 .is_some_and(|schema| !schema.is_null())
1825 {
1826 return ToolExecutionResult::tool_error(
1827 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1828 );
1829 }
1830
1831 provider.execute_with_context(arguments, context).await
1832 }
1833
1834 fn requires_context(&self) -> bool {
1835 true
1836 }
1837}
1838
1839pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1844 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1845 return base_system_prompt.to_string();
1846 };
1847
1848 if base_system_prompt.is_empty() {
1849 return additions.to_string();
1850 }
1851
1852 if base_system_prompt.contains("<system-prompt>") {
1853 format!("{base_system_prompt}\n\n{additions}")
1854 } else {
1855 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1856 }
1857}
1858
1859pub struct CollectedMessageFilters {
1866 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1868}
1869
1870pub struct CollectedModelViewProviders {
1872 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1874}
1875
1876impl CollectedMessageFilters {
1882 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1884 for (provider, config) in &self.message_filter_providers {
1885 provider.apply_filters(query, config);
1886 }
1887 }
1888
1889 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1891 for (provider, config) in &self.message_filter_providers {
1892 provider.post_load(messages, config);
1893 }
1894 }
1895}
1896
1897impl CollectedModelViewProviders {
1898 pub fn apply_model_view(
1900 &self,
1901 mut messages: Vec<Message>,
1902 context: &ModelViewContext<'_>,
1903 ) -> Vec<Message> {
1904 for (provider, config) in &self.model_view_providers {
1905 messages = provider.apply_model_view(messages, config, context);
1906 }
1907 messages
1908 }
1909}
1910
1911fn compaction_is_enabled(
1917 capability_configs: &[AgentCapabilityConfig],
1918 registry: &CapabilityRegistry,
1919) -> bool {
1920 capability_configs.iter().any(|cap_config| {
1921 registry.get(cap_config.capability_id()).is_some_and(|cap| {
1922 cap.status().is_active() && cap.compaction_policy(cap_config.config_value()).is_some()
1923 })
1924 })
1925}
1926
1927pub fn collect_message_filters_only(
1933 capability_configs: &[AgentCapabilityConfig],
1934 registry: &CapabilityRegistry,
1935) -> CollectedMessageFilters {
1936 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1937 Vec::new();
1938 let compaction_on = compaction_is_enabled(capability_configs, registry);
1939
1940 for cap_config in capability_configs {
1941 let cap_id = cap_config.capability_id();
1942 if let Some(capability) = registry.get(cap_id) {
1943 if !capability.status().is_active() {
1944 continue;
1945 }
1946 let effective: &dyn Capability = capability
1949 .resolve_for_model(None)
1950 .unwrap_or_else(|| capability.as_ref());
1951 if let Some(provider) = effective.message_filter_provider() {
1952 let config =
1953 effective.message_filter_config(cap_config.config_value(), compaction_on);
1954 message_filter_providers.push((provider, config));
1955 }
1956 }
1957 }
1958
1959 message_filter_providers.sort_by_key(|(p, _)| p.priority());
1960
1961 CollectedMessageFilters {
1962 message_filter_providers,
1963 }
1964}
1965
1966pub fn collect_model_view_providers(
1973 capability_configs: &[AgentCapabilityConfig],
1974 registry: &CapabilityRegistry,
1975 model: Option<&str>,
1976) -> CollectedModelViewProviders {
1977 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1978
1979 for cap_config in capability_configs {
1980 let cap_id = cap_config.capability_id();
1981 if let Some(capability) = registry.get(cap_id) {
1982 if !capability.status().is_active() {
1983 continue;
1984 }
1985 let effective: &dyn Capability = capability
1986 .resolve_for_model(model)
1987 .unwrap_or_else(|| capability.as_ref());
1988 if let Some(provider) = effective.model_view_provider() {
1989 model_view_providers.push((provider, cap_config.config_value().clone()));
1990 }
1991 }
1992 }
1993
1994 model_view_providers.sort_by_key(|(p, _)| p.priority());
1995
1996 CollectedModelViewProviders {
1997 model_view_providers,
1998 }
1999}
2000
2001pub fn collect_dynamic_facts(
2007 capability_configs: &[AgentCapabilityConfig],
2008 registry: &CapabilityRegistry,
2009 model: Option<&str>,
2010 ctx: &FactsContext,
2011) -> Vec<Fact> {
2012 let mut dynamic = Vec::new();
2013 for cap_config in capability_configs {
2014 let cap_id = cap_config.capability_id();
2015 if let Some(capability) = registry.get(cap_id) {
2016 if !capability.status().is_active() {
2017 continue;
2018 }
2019 let effective: &dyn Capability = capability
2020 .resolve_for_model(model)
2021 .unwrap_or_else(|| capability.as_ref());
2022 for fact in effective.facts(cap_config.config_value(), ctx) {
2023 if fact.volatility == Volatility::Dynamic {
2024 dynamic.push(fact);
2025 }
2026 }
2027 }
2028 }
2029 dynamic
2030}
2031
2032pub fn collect_capability_mcp_servers(
2033 capability_configs: &[AgentCapabilityConfig],
2034 registry: &CapabilityRegistry,
2035) -> ScopedMcpServers {
2036 let mut servers = ScopedMcpServers::default();
2037
2038 for cap_config in capability_configs {
2039 let cap_id = cap_config.capability_id();
2040 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2043 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2044 cap_config.config_value().clone(),
2045 ) {
2046 if !definition.status.is_active() {
2047 continue;
2048 }
2049 if let Some(contributed) = definition.mcp_servers {
2050 servers = merge_scoped_mcp_servers(&servers, &contributed);
2051 }
2052 }
2053 continue;
2054 }
2055 if let Some(capability) = registry.get(cap_id) {
2056 if !capability.status().is_active() {
2057 continue;
2058 }
2059 servers = merge_scoped_mcp_servers(
2060 &servers,
2061 &capability.mcp_servers_with_config(cap_config.config_value()),
2062 );
2063 }
2064 }
2065
2066 servers
2067}
2068
2069pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2076
2077#[derive(Debug, Clone, PartialEq, Eq)]
2079pub enum DependencyError {
2080 CircularDependency {
2082 capability_id: String,
2084 chain: Vec<String>,
2086 },
2087 TooManyCapabilities {
2089 count: usize,
2091 max: usize,
2093 },
2094}
2095
2096impl std::fmt::Display for DependencyError {
2097 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2098 match self {
2099 DependencyError::CircularDependency {
2100 capability_id,
2101 chain,
2102 } => {
2103 write!(
2104 f,
2105 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2106 capability_id,
2107 chain.join(" -> "),
2108 capability_id
2109 )
2110 }
2111 DependencyError::TooManyCapabilities { count, max } => {
2112 write!(
2113 f,
2114 "Too many capabilities after resolution: {} (max: {})",
2115 count, max
2116 )
2117 }
2118 }
2119 }
2120}
2121
2122impl std::error::Error for DependencyError {}
2123
2124#[derive(Debug, Clone)]
2126pub struct ResolvedCapabilities {
2127 pub resolved_ids: Vec<String>,
2130 pub added_as_dependencies: Vec<String>,
2132 pub user_selected: Vec<String>,
2134}
2135
2136pub fn resolve_dependencies(
2156 selected_ids: &[String],
2157 registry: &CapabilityRegistry,
2158) -> Result<ResolvedCapabilities, DependencyError> {
2159 use std::collections::HashSet;
2160
2161 let user_selected: HashSet<String> = selected_ids
2163 .iter()
2164 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2165 .collect();
2166 let mut resolved: Vec<String> = Vec::new();
2167 let mut resolved_set: HashSet<String> = HashSet::new();
2168 let mut added_as_dependencies: Vec<String> = Vec::new();
2169
2170 for cap_id in selected_ids {
2172 resolve_single_capability(
2173 cap_id,
2174 registry,
2175 &mut resolved,
2176 &mut resolved_set,
2177 &mut added_as_dependencies,
2178 &user_selected,
2179 &mut Vec::new(), )?;
2181 }
2182
2183 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2185 return Err(DependencyError::TooManyCapabilities {
2186 count: resolved.len(),
2187 max: MAX_RESOLVED_CAPABILITIES,
2188 });
2189 }
2190
2191 Ok(ResolvedCapabilities {
2192 resolved_ids: resolved,
2193 added_as_dependencies,
2194 user_selected: selected_ids.to_vec(),
2195 })
2196}
2197
2198pub fn resolve_capability_configs(
2203 selected_configs: &[AgentCapabilityConfig],
2204 registry: &CapabilityRegistry,
2205) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2206 let mut selected_ids: Vec<String> = Vec::new();
2207 for config in selected_configs {
2208 if (is_declarative_capability(config.capability_id())
2211 || is_plugin_capability(config.capability_id()))
2212 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2213 config.config_value().clone(),
2214 )
2215 {
2216 selected_ids.extend(definition.dependencies);
2217 }
2218 selected_ids.push(config.capability_id().to_string());
2219 }
2220 let resolved = resolve_dependencies(&selected_ids, registry)?;
2221
2222 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2225 .iter()
2226 .map(|config| {
2227 let id = config.capability_id();
2228 let id = registry.canonical_id(id).unwrap_or(id);
2229 (id.to_string(), config.config_value().clone())
2230 })
2231 .collect();
2232
2233 Ok(resolved
2234 .resolved_ids
2235 .into_iter()
2236 .map(|capability_id| {
2237 explicit_configs
2238 .get(&capability_id)
2239 .cloned()
2240 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2241 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2242 })
2243 .collect())
2244}
2245
2246fn resolve_single_capability(
2248 cap_id: &str,
2249 registry: &CapabilityRegistry,
2250 resolved: &mut Vec<String>,
2251 resolved_set: &mut std::collections::HashSet<String>,
2252 added_as_dependencies: &mut Vec<String>,
2253 user_selected: &std::collections::HashSet<String>,
2254 visiting: &mut Vec<String>,
2255) -> Result<(), DependencyError> {
2256 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2260
2261 if resolved_set.contains(cap_id) {
2263 return Ok(());
2264 }
2265
2266 if visiting.contains(&cap_id.to_string()) {
2268 return Err(DependencyError::CircularDependency {
2269 capability_id: cap_id.to_string(),
2270 chain: visiting.clone(),
2271 });
2272 }
2273
2274 let capability = match registry.get(cap_id) {
2276 Some(cap) => cap,
2277 None => {
2278 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2282 && !resolved_set.contains(cap_id)
2283 {
2284 resolved.push(cap_id.to_string());
2285 resolved_set.insert(cap_id.to_string());
2286 if !user_selected.contains(cap_id) {
2287 added_as_dependencies.push(cap_id.to_string());
2288 }
2289 }
2290 return Ok(());
2291 }
2292 };
2293
2294 visiting.push(cap_id.to_string());
2296
2297 for dep_id in capability.dependencies() {
2299 resolve_single_capability(
2300 dep_id,
2301 registry,
2302 resolved,
2303 resolved_set,
2304 added_as_dependencies,
2305 user_selected,
2306 visiting,
2307 )?;
2308 }
2309
2310 visiting.pop();
2312
2313 if !resolved_set.contains(cap_id) {
2315 resolved.push(cap_id.to_string());
2316 resolved_set.insert(cap_id.to_string());
2317
2318 if !user_selected.contains(cap_id) {
2320 added_as_dependencies.push(cap_id.to_string());
2321 }
2322 }
2323
2324 Ok(())
2325}
2326
2327pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2332 use std::collections::HashSet;
2333
2334 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2335 Ok(resolved) => resolved.resolved_ids,
2336 Err(_) => capability_ids.to_vec(),
2337 };
2338
2339 let mut seen = HashSet::new();
2340 let mut features = Vec::new();
2341 for cap_id in &resolved_ids {
2342 if let Some(cap) = registry.get(cap_id) {
2343 for feature in cap.features() {
2344 if seen.insert(feature) {
2345 features.push(feature.to_string());
2346 }
2347 }
2348 }
2349 }
2350 features
2351}
2352
2353pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2356 registry
2357 .get(cap_id)
2358 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2359 .unwrap_or_default()
2360}
2361
2362pub async fn collect_capabilities(
2378 capability_ids: &[String],
2379 registry: &CapabilityRegistry,
2380 ctx: &SystemPromptContext,
2381) -> CollectedCapabilities {
2382 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2385 Ok(resolved) => resolved.resolved_ids,
2386 Err(e) => {
2387 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2388 capability_ids.to_vec()
2389 }
2390 };
2391
2392 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2394 .iter()
2395 .map(|id| {
2396 AgentCapabilityConfig::with_config(
2397 CapabilityId::new(id),
2398 serde_json::Value::Object(serde_json::Map::new()),
2399 )
2400 })
2401 .collect();
2402
2403 collect_capabilities_with_configs(&configs, registry, ctx).await
2404}
2405
2406pub async fn collect_capabilities_with_configs(
2417 capability_configs: &[AgentCapabilityConfig],
2418 registry: &CapabilityRegistry,
2419 ctx: &SystemPromptContext,
2420) -> CollectedCapabilities {
2421 let mut system_prompt_parts: Vec<String> = Vec::new();
2422 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2423 let mut conversation_context_parts: Vec<String> = Vec::new();
2424 let mut conversation_context_attributions: Vec<SystemPromptAttribution> = Vec::new();
2425 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2426 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2427 let mut mounts: Vec<MountPoint> = Vec::new();
2428 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2429 Vec::new();
2430 let mut applied_ids: Vec<String> = Vec::new();
2431 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2432 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2433 let mut driver_options: HashMap<String, serde_json::Value> = HashMap::new();
2434 let mut parallel_tool_calls: Option<bool> = None;
2435 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2436 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2437 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2440 let mut mcp_servers = ScopedMcpServers::default();
2441 let mut static_facts: Vec<Fact> = Vec::new();
2445 let mut has_dynamic_facts = false;
2446 let facts_ctx = FactsContext::new(ctx.session_id);
2447 let compaction_on = compaction_is_enabled(capability_configs, registry);
2448 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2449
2450 for cap_config in capability_configs {
2451 let cap_id = cap_config.capability_id();
2452 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2457 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2458 cap_config.config_value().clone(),
2459 ) {
2460 Ok(definition) => {
2461 if !definition.status.is_active() {
2462 continue;
2463 }
2464
2465 if let Some(prompt) = definition.system_prompt.as_deref() {
2466 let contribution =
2467 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2468 system_prompt_attributions.push(SystemPromptAttribution {
2469 capability_id: cap_id.to_string(),
2470 content: contribution.clone(),
2471 });
2472 system_prompt_parts.push(contribution);
2473 }
2474
2475 mounts.extend(definition.mounts(cap_id));
2476 if let Some(ref servers) = definition.mcp_servers {
2477 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2478 }
2479 for skill in definition.skill_contributions() {
2480 mounts.push(skill.to_mount(cap_id));
2481 }
2482
2483 applied_ids.push(cap_id.to_string());
2484 }
2485 Err(error) => {
2486 tracing::warn!(
2487 capability_id = %cap_id,
2488 error = %error,
2489 "Skipping invalid declarative/plugin capability config"
2490 );
2491 }
2492 }
2493 continue;
2494 }
2495 if let Some(capability) = registry.get(cap_id) {
2496 if !capability.status().is_active() {
2500 continue;
2501 }
2502
2503 let effective: &dyn Capability =
2515 match capability.resolve_for_model(ctx.model.as_deref()) {
2516 Some(inner) => inner,
2517 None => capability.as_ref(),
2518 };
2519 let delegation_target =
2520 effective.delegation_target_with_config(cap_config.config_value());
2521
2522 if let Some(contribution) = effective
2524 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2525 .await
2526 {
2527 system_prompt_attributions.push(SystemPromptAttribution {
2528 capability_id: cap_id.to_string(),
2529 content: contribution.clone(),
2530 });
2531 system_prompt_parts.push(contribution);
2532 }
2533
2534 if let Some(contribution) = effective
2539 .conversation_context_contribution_with_config(ctx, cap_config.config_value())
2540 .await
2541 {
2542 conversation_context_attributions.push(SystemPromptAttribution {
2543 capability_id: cap_id.to_string(),
2544 content: contribution.clone(),
2545 });
2546 conversation_context_parts.push(contribution);
2547 }
2548
2549 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2554 match fact.volatility {
2555 Volatility::Static => static_facts.push(fact),
2556 Volatility::Dynamic => has_dynamic_facts = true,
2557 }
2558 }
2559
2560 tools.extend(effective.tools_with_config(cap_config.config_value()));
2562 if let Some(target) = delegation_target {
2563 delegation_targets.push(target);
2564 }
2565 tool_definition_hooks.extend(
2566 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2567 );
2568 tool_call_hooks.extend(effective.tool_call_hooks());
2569 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2571 let cap_category = effective.category();
2576 for def in effective.tool_definitions() {
2577 let def = match (def.category(), cap_category) {
2578 (None, Some(cat)) => def.with_category(cat),
2579 _ => def,
2580 }
2581 .with_capability_attribution(cap_id, Some(capability.name()));
2582 tool_definitions.push(def);
2583 }
2584
2585 tool_search = effective
2586 .tool_search_config(cap_config.config_value())
2587 .or(tool_search);
2588 prompt_cache = effective
2589 .prompt_cache_config(cap_config.config_value())
2590 .or(prompt_cache);
2591 parallel_tool_calls = effective
2592 .parallel_tool_calls_preference(cap_config.config_value())
2593 .or(parallel_tool_calls);
2594
2595 for (key, value) in effective.driver_options(cap_config.config_value()) {
2596 driver_options.entry(key).or_insert(value);
2597 }
2598
2599 mounts.extend(effective.mounts());
2601
2602 mcp_servers = merge_scoped_mcp_servers(
2603 &mcp_servers,
2604 &effective.mcp_servers_with_config(cap_config.config_value()),
2605 );
2606
2607 for skill in effective.contribute_skills() {
2611 mounts.push(skill.to_mount(cap_id));
2612 }
2613
2614 if let Some(provider) = effective.message_filter_provider() {
2616 let config =
2617 effective.message_filter_config(cap_config.config_value(), compaction_on);
2618 message_filter_providers.push((provider, config));
2619 }
2620
2621 applied_ids.push(cap_id.to_string());
2622 }
2623 }
2624
2625 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2628 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2629 let def = tool
2630 .to_definition()
2631 .with_category("Orchestration")
2632 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2633 tools.push(Box::new(tool));
2634 tool_definitions.push(def);
2635 }
2636
2637 let auto_activated: Vec<_> = registry
2640 .list()
2641 .into_iter()
2642 .filter(|cap| {
2643 !applied_ids.iter().any(|id| id == cap.id())
2644 && cap.status().is_active()
2645 && cap.auto_activates_for(&tool_definitions)
2646 })
2647 .cloned()
2648 .collect();
2649 for cap in auto_activated {
2650 tools.extend(cap.tools());
2651 let cap_category = cap.category();
2652 for def in cap.tool_definitions() {
2653 let def = match (def.category(), cap_category) {
2654 (None, Some(cat)) => def.with_category(cat),
2655 _ => def,
2656 }
2657 .with_capability_attribution(cap.id(), Some(cap.name()));
2658 tool_definitions.push(def);
2659 }
2660 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2661 applied_ids.push(cap.id().to_string());
2662 }
2663
2664 if let Some(block) = facts::render_facts_block(&static_facts) {
2669 system_prompt_attributions.push(SystemPromptAttribution {
2670 capability_id: "facts".to_string(),
2671 content: block.clone(),
2672 });
2673 system_prompt_parts.push(block);
2674 }
2675 if has_dynamic_facts {
2676 system_prompt_attributions.push(SystemPromptAttribution {
2677 capability_id: "facts".to_string(),
2678 content: FACTS_DYNAMIC_NOTE.to_string(),
2679 });
2680 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2681 }
2682
2683 tool_call_hooks.extend(narration_hooks);
2687
2688 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2690
2691 CollectedCapabilities {
2692 system_prompt_parts,
2693 system_prompt_attributions,
2694 conversation_context_parts,
2695 conversation_context_attributions,
2696 tools,
2697 tool_definitions,
2698 mounts,
2699 message_filter_providers,
2700 applied_ids,
2701 tool_search,
2702 prompt_cache,
2703 driver_options,
2704 parallel_tool_calls,
2705 tool_definition_hooks,
2706 tool_call_hooks,
2707 mcp_servers,
2708 }
2709}
2710
2711pub struct AppliedCapabilities {
2717 pub runtime_agent: RuntimeAgent,
2719 pub tool_registry: ToolRegistry,
2721 pub applied_ids: Vec<String>,
2723}
2724
2725pub async fn apply_capabilities(
2761 base_runtime_agent: RuntimeAgent,
2762 capability_ids: &[String],
2763 registry: &CapabilityRegistry,
2764 ctx: &SystemPromptContext,
2765) -> AppliedCapabilities {
2766 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2767
2768 let final_system_prompt = compose_system_prompt(
2770 &base_runtime_agent.system_prompt,
2771 collected.system_prompt_prefix().as_deref(),
2772 );
2773
2774 let conversation_context = collected.conversation_context();
2778 let mut tool_registry = ToolRegistry::new();
2780 for tool in collected.tools {
2781 tool_registry.register_boxed(tool);
2782 }
2783
2784 let mut tools = collected.tool_definitions;
2786 for hook in &collected.tool_definition_hooks {
2787 tools = hook.transform(tools);
2788 }
2789
2790 let runtime_agent = RuntimeAgent {
2791 system_prompt: final_system_prompt,
2792 model: base_runtime_agent.model,
2793 tools,
2794 max_iterations: base_runtime_agent.max_iterations,
2795 temperature: base_runtime_agent.temperature,
2796 max_tokens: base_runtime_agent.max_tokens,
2797 tool_search: collected.tool_search,
2798 prompt_cache: collected.prompt_cache,
2799 driver_options: collected.driver_options,
2800 network_access: base_runtime_agent.network_access,
2801 parallel_tool_calls: base_runtime_agent
2804 .parallel_tool_calls
2805 .or(collected.parallel_tool_calls),
2806 conversation_context,
2809 };
2810
2811 AppliedCapabilities {
2812 runtime_agent,
2813 tool_registry,
2814 applied_ids: collected.applied_ids,
2815 }
2816}
2817
2818#[cfg(test)]
2823mod tests {
2824 use super::*;
2825 use crate::typed_id::SessionId;
2826 use uuid::Uuid;
2827
2828 fn test_ctx() -> SystemPromptContext {
2830 SystemPromptContext::without_file_store(SessionId::new())
2831 }
2832
2833 struct StubSubagentSpawnTool;
2842
2843 #[async_trait]
2844 impl Tool for StubSubagentSpawnTool {
2845 fn name(&self) -> &str {
2846 "spawn_agent"
2847 }
2848 fn description(&self) -> &str {
2849 "stub subagent delegation"
2850 }
2851 fn parameters_schema(&self) -> serde_json::Value {
2852 serde_json::json!({ "type": "object" })
2853 }
2854 fn narrate(
2855 &self,
2856 tool_call: &ToolCall,
2857 phase: crate::tool_narration::ToolNarrationPhase,
2858 locale: Option<&str>,
2859 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2860 ) -> Option<String> {
2861 Some(crate::tool_narration::narrate_subagent_spawn(
2862 &tool_call.arguments,
2863 phase,
2864 locale,
2865 ))
2866 }
2867 async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2868 crate::ToolExecutionResult::success(serde_json::json!({}))
2869 }
2870 }
2871
2872 fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2873 ToolCall {
2874 id: "call-1".to_string(),
2875 name: "spawn_agent".to_string(),
2876 arguments,
2877 }
2878 }
2879
2880 #[test]
2883 fn unified_spawn_agent_narration_names_the_agent() {
2884 let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
2885 target_type: "subagent",
2886 tool: Box::new(StubSubagentSpawnTool),
2887 }]);
2888 let ctx = crate::tool_narration::ToolNarrationContext::default();
2889
2890 assert_eq!(
2891 tool.narrate(
2892 &spawn_agent_call(serde_json::json!({
2893 "name": "Orbit Scout",
2894 "target": { "type": "subagent" },
2895 "blueprint": "github_scout"
2896 })),
2897 crate::tool_narration::ToolNarrationPhase::Started,
2898 None,
2899 ctx,
2900 )
2901 .as_deref(),
2902 Some("Launching Orbit Scout subagent (github_scout)")
2903 );
2904
2905 assert_eq!(
2906 tool.narrate(
2907 &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
2908 crate::tool_narration::ToolNarrationPhase::Started,
2909 None,
2910 ctx,
2911 )
2912 .as_deref(),
2913 Some("Launching Orbit Scout subagent")
2914 );
2915 }
2916
2917 #[test]
2918 fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
2919 for target_type in ["agent", "external_a2a"] {
2920 let arguments = serde_json::json!({
2921 "target": { "type": target_type, "id": "actual-target" },
2922 "blueprint": "decoy-target"
2923 });
2924 assert_eq!(
2925 validate_spawn_agent_target_fields(&arguments, target_type),
2926 Err(format!(
2927 "blueprint is only valid for subagent targets, not {target_type}."
2928 ))
2929 );
2930
2931 let arguments = serde_json::json!({
2932 "target": { "type": target_type, "id": "actual-target" },
2933 "config": { "model": "decoy" }
2934 });
2935 assert_eq!(
2936 validate_spawn_agent_target_fields(&arguments, target_type),
2937 Err(format!(
2938 "config is only valid for subagent targets, not {target_type}."
2939 ))
2940 );
2941 }
2942 }
2943
2944 struct NoopFixture;
2946
2947 impl Capability for NoopFixture {
2948 fn id(&self) -> &str {
2949 "noop"
2950 }
2951 fn name(&self) -> &str {
2952 "No-Op"
2953 }
2954 fn description(&self) -> &str {
2955 "Contributes nothing."
2956 }
2957 }
2958
2959 struct FeatureFixture;
2961
2962 impl Capability for FeatureFixture {
2963 fn id(&self) -> &str {
2964 "feature_fixture"
2965 }
2966 fn name(&self) -> &str {
2967 "Feature Fixture"
2968 }
2969 fn description(&self) -> &str {
2970 "Declares one test-only feature."
2971 }
2972 fn features(&self) -> Vec<&'static str> {
2973 vec!["fixture_feature"]
2974 }
2975 }
2976
2977 struct FixtureTool(&'static str);
2978
2979 #[async_trait]
2980 impl Tool for FixtureTool {
2981 fn name(&self) -> &str {
2982 self.0
2983 }
2984 fn description(&self) -> &str {
2985 "Fixture tool."
2986 }
2987 fn parameters_schema(&self) -> serde_json::Value {
2988 serde_json::json!({
2989 "type": "object",
2990 "properties": {},
2991 "additionalProperties": false
2992 })
2993 }
2994 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2995 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2996 }
2997 }
2998
2999 struct BackgroundFixtureTool;
3000
3001 #[async_trait]
3002 impl Tool for BackgroundFixtureTool {
3003 fn name(&self) -> &str {
3004 "bash"
3005 }
3006 fn description(&self) -> &str {
3007 "Fixture background-capable shell tool."
3008 }
3009 fn parameters_schema(&self) -> serde_json::Value {
3010 serde_json::json!({"type": "object"})
3011 }
3012 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3013 ToolExecutionResult::success(serde_json::json!({"ok": true}))
3014 }
3015 fn hints(&self) -> crate::tool_types::ToolHints {
3016 crate::tool_types::ToolHints {
3017 supports_background: Some(true),
3018 ..Default::default()
3019 }
3020 }
3021 }
3022
3023 struct FileSystemFixture;
3024
3025 impl Capability for FileSystemFixture {
3026 fn id(&self) -> &str {
3027 "session_file_system"
3028 }
3029 fn name(&self) -> &str {
3030 "Fixture Filesystem"
3031 }
3032 fn description(&self) -> &str {
3033 "Fixture filesystem capability."
3034 }
3035 fn tools(&self) -> Vec<Box<dyn Tool>> {
3036 vec![
3037 Box::new(FixtureTool("read_file")),
3038 Box::new(FixtureTool("write_file")),
3039 ]
3040 }
3041 fn features(&self) -> Vec<&'static str> {
3042 vec!["file_system"]
3043 }
3044 }
3045
3046 struct StorageFixture;
3051
3052 impl Capability for StorageFixture {
3053 fn id(&self) -> &str {
3054 "session_storage"
3055 }
3056 fn name(&self) -> &str {
3057 "Fixture Storage"
3058 }
3059 fn description(&self) -> &str {
3060 "Fixture session storage capability."
3061 }
3062 fn features(&self) -> Vec<&'static str> {
3063 vec!["secrets", "key_value"]
3064 }
3065 }
3066
3067 struct BashFixture;
3068
3069 impl Capability for BashFixture {
3070 fn id(&self) -> &str {
3071 "bashkit_shell"
3072 }
3073 fn aliases(&self) -> Vec<&'static str> {
3074 vec!["virtual_bash"]
3075 }
3076 fn name(&self) -> &str {
3077 "Fixture Bash"
3078 }
3079 fn description(&self) -> &str {
3080 "Fixture shell capability."
3081 }
3082 fn tools(&self) -> Vec<Box<dyn Tool>> {
3083 vec![Box::new(BackgroundFixtureTool)]
3084 }
3085 fn dependencies(&self) -> Vec<&'static str> {
3086 vec!["session_file_system"]
3087 }
3088 fn features(&self) -> Vec<&'static str> {
3089 vec!["file_system"]
3090 }
3091 fn risk_level(&self) -> RiskLevel {
3092 RiskLevel::High
3093 }
3094 }
3095
3096 struct WebFetchFixture;
3097
3098 impl Capability for WebFetchFixture {
3099 fn id(&self) -> &str {
3100 "web_fetch"
3101 }
3102 fn name(&self) -> &str {
3103 "Fixture Web Fetch"
3104 }
3105 fn description(&self) -> &str {
3106 "Fixture web capability."
3107 }
3108 fn risk_level(&self) -> RiskLevel {
3109 RiskLevel::High
3110 }
3111 }
3112
3113 struct DynamicFactFixture;
3116
3117 impl Capability for DynamicFactFixture {
3118 fn id(&self) -> &str {
3119 "current_time"
3120 }
3121 fn name(&self) -> &str {
3122 "Dynamic Fact Fixture"
3123 }
3124 fn description(&self) -> &str {
3125 "Fixture with one dynamic fact and one tool."
3126 }
3127 fn icon(&self) -> Option<&str> {
3128 Some("clock")
3129 }
3130 fn category(&self) -> Option<&str> {
3131 Some("Core")
3132 }
3133 fn tools(&self) -> Vec<Box<dyn Tool>> {
3134 vec![Box::new(FixtureTool("get_current_time"))]
3135 }
3136 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3137 vec![Fact::dynamic("current_time", "fixture-now")]
3138 }
3139 }
3140
3141 struct PromptToolFixture;
3142
3143 impl Capability for PromptToolFixture {
3144 fn id(&self) -> &str {
3145 "prompt_tool_fixture"
3146 }
3147 fn name(&self) -> &str {
3148 "Prompt Tool Fixture"
3149 }
3150 fn description(&self) -> &str {
3151 "Fixture with a static prompt and tool."
3152 }
3153 fn system_prompt_addition(&self) -> Option<&str> {
3154 Some("Task Management uses the write_todos tool.")
3155 }
3156 fn tools(&self) -> Vec<Box<dyn Tool>> {
3157 vec![Box::new(FixtureTool("write_todos"))]
3158 }
3159 }
3160
3161 struct SecondPromptFixture;
3162
3163 impl Capability for SecondPromptFixture {
3164 fn id(&self) -> &str {
3165 "second_prompt_fixture"
3166 }
3167 fn name(&self) -> &str {
3168 "Second Prompt Fixture"
3169 }
3170 fn description(&self) -> &str {
3171 "Fixture with a second static prompt."
3172 }
3173 fn system_prompt_addition(&self) -> Option<&str> {
3174 Some("A second capability prompt contribution.")
3175 }
3176 }
3177
3178 struct DynamicPreviewFixture;
3179
3180 impl Capability for DynamicPreviewFixture {
3181 fn id(&self) -> &str {
3182 "agent_instructions"
3183 }
3184 fn name(&self) -> &str {
3185 "Dynamic Preview Fixture"
3186 }
3187 fn description(&self) -> &str {
3188 "Fixture whose runtime prompt is dynamic."
3189 }
3190 fn system_prompt_preview(&self) -> Option<String> {
3191 Some("Reads AGENTS.md dynamically.".to_string())
3192 }
3193 }
3194
3195 struct MathFixture;
3197
3198 impl Capability for MathFixture {
3199 fn id(&self) -> &str {
3200 "test_math"
3201 }
3202 fn name(&self) -> &str {
3203 "Test Math"
3204 }
3205 fn description(&self) -> &str {
3206 "Fixture: calculator tools."
3207 }
3208 fn tools(&self) -> Vec<Box<dyn Tool>> {
3209 vec![
3210 Box::new(FixtureTool("add")),
3211 Box::new(FixtureTool("subtract")),
3212 Box::new(FixtureTool("multiply")),
3213 Box::new(FixtureTool("divide")),
3214 ]
3215 }
3216 }
3217
3218 struct WeatherFixture;
3220
3221 impl Capability for WeatherFixture {
3222 fn id(&self) -> &str {
3223 "test_weather"
3224 }
3225 fn name(&self) -> &str {
3226 "Test Weather"
3227 }
3228 fn description(&self) -> &str {
3229 "Fixture: weather tools."
3230 }
3231 fn tools(&self) -> Vec<Box<dyn Tool>> {
3232 vec![
3233 Box::new(FixtureTool("get_weather")),
3234 Box::new(FixtureTool("get_forecast")),
3235 ]
3236 }
3237 }
3238
3239 struct SampleDataFixture;
3242
3243 impl Capability for SampleDataFixture {
3244 fn id(&self) -> &str {
3245 "sample_data"
3246 }
3247 fn name(&self) -> &str {
3248 "Sample Data"
3249 }
3250 fn description(&self) -> &str {
3251 "Fixture: mounted sample files."
3252 }
3253 fn system_prompt_addition(&self) -> Option<&str> {
3254 Some("Read-only sample files are mounted at `/samples`.")
3255 }
3256 fn mounts(&self) -> Vec<MountPoint> {
3257 let samples_dir = MountDirectoryBuilder::new()
3258 .file("users.json", "[]")
3259 .build();
3260 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3261 }
3262 fn dependencies(&self) -> Vec<&'static str> {
3263 vec!["session_file_system"]
3264 }
3265 fn features(&self) -> Vec<&'static str> {
3266 vec!["file_system"]
3267 }
3268 }
3269
3270 fn fixture_registry() -> CapabilityRegistry {
3272 let mut registry = CapabilityRegistry::new();
3273 registry.register(NoopFixture);
3274 registry.register(FeatureFixture);
3275 registry.register(MathFixture);
3276 registry.register(WeatherFixture);
3277 registry.register(SampleDataFixture);
3278 registry.register(FileSystemFixture);
3279 registry.register(StorageFixture);
3280 registry.register(BashFixture);
3281 registry.register(WebFetchFixture);
3282 registry.register(DynamicFactFixture);
3283 registry.register(PromptToolFixture);
3284 registry.register(SecondPromptFixture);
3285 registry.register(DynamicPreviewFixture);
3286 registry
3287 }
3288
3289 struct HostAnnotatedCapability;
3291
3292 #[async_trait]
3293 impl Capability for HostAnnotatedCapability {
3294 fn id(&self) -> &str {
3295 "host_annotated"
3296 }
3297 fn name(&self) -> &str {
3298 "Host Annotated"
3299 }
3300 fn description(&self) -> &str {
3301 "Test capability with host-owned metadata."
3302 }
3303 fn metadata(&self) -> Option<serde_json::Value> {
3304 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3305 }
3306 }
3307
3308 #[test]
3309 fn test_capability_registry_get() {
3310 let mut registry = CapabilityRegistry::new();
3311 registry.register(NoopFixture);
3312
3313 let capability = registry.get("noop").unwrap();
3314 assert_eq!(capability.id(), "noop");
3315 assert_eq!(capability.status(), CapabilityStatus::Available);
3316 }
3317
3318 #[test]
3319 fn default_registry_is_empty_and_selects_no_product_preset() {
3320 assert!(CapabilityRegistry::default().is_empty());
3321 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3322 }
3323
3324 #[tokio::test]
3325 async fn test_capability_registry_blueprint_with_capability() {
3326 struct BlueprintProviderCapability;
3327
3328 impl Capability for BlueprintProviderCapability {
3329 fn id(&self) -> &str {
3330 "blueprint_provider"
3331 }
3332 fn name(&self) -> &str {
3333 "Blueprint Provider"
3334 }
3335 fn description(&self) -> &str {
3336 "Capability that provides a blueprint for tests"
3337 }
3338 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3339 vec![AgentBlueprint {
3340 id: "test_blueprint",
3341 name: "Test Blueprint",
3342 description: "Blueprint for capability registry tests",
3343 model: BlueprintModel::Fixed("specialist-model".into()),
3344 system_prompt: "Test prompt",
3345 tools: vec![Box::new(FixtureTool("private_lookup"))],
3346 max_turns: Some(7),
3347 config_schema: Some(
3348 serde_json::json!({"type":"object", "required":["repository"]}),
3349 ),
3350 }]
3351 }
3352 }
3353
3354 let mut registry = CapabilityRegistry::new();
3355 registry.register(BlueprintProviderCapability);
3356
3357 let (capability_id, blueprint) = registry
3358 .blueprint_with_capability("test_blueprint")
3359 .expect("blueprint should resolve with capability id");
3360 assert_eq!(capability_id, "blueprint_provider");
3361 assert_eq!(blueprint.id, "test_blueprint");
3362 assert_eq!(blueprint.name, "Test Blueprint");
3363 assert_eq!(
3364 blueprint.description,
3365 "Blueprint for capability registry tests"
3366 );
3367 assert_eq!(blueprint.system_prompt, "Test prompt");
3368 assert!(
3369 matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
3370 );
3371 assert_eq!(blueprint.max_turns, Some(7));
3372 assert_eq!(
3373 blueprint.config_schema,
3374 Some(serde_json::json!({"type":"object", "required":["repository"]}))
3375 );
3376 let definitions = blueprint.tool_definitions();
3377 assert_eq!(definitions.len(), 1);
3378 assert_eq!(definitions[0].name(), "private_lookup");
3379 assert_eq!(
3380 registry.blueprint("test_blueprint").unwrap().tools[0].name(),
3381 "private_lookup"
3382 );
3383 assert_eq!(
3384 registry
3385 .all_blueprints()
3386 .iter()
3387 .map(|b| b.id)
3388 .collect::<Vec<_>>(),
3389 ["test_blueprint"]
3390 );
3391 assert!(registry.blueprint_with_capability("missing").is_none());
3392 assert!(registry.blueprint("missing").is_none());
3393 let host =
3394 collect_capabilities(&["blueprint_provider".into()], ®istry, &test_ctx()).await;
3395 assert!(host.tools.is_empty());
3396 assert!(host.tool_definitions.is_empty());
3397 }
3398
3399 #[test]
3400 fn test_capability_registry_builder() {
3401 let registry = CapabilityRegistry::builder()
3402 .capability(NoopFixture)
3403 .build();
3404
3405 assert!(registry.has("noop"));
3406 assert_eq!(registry.len(), 1);
3407 }
3408
3409 #[test]
3410 fn test_system_prompt_preview_default_delegates_to_addition() {
3411 struct StaticPromptCapability;
3414 impl Capability for StaticPromptCapability {
3415 fn id(&self) -> &str {
3416 "static_prompt"
3417 }
3418 fn name(&self) -> &str {
3419 "Static Prompt"
3420 }
3421 fn description(&self) -> &str {
3422 "Static prompt addition."
3423 }
3424 fn system_prompt_addition(&self) -> Option<&str> {
3425 Some("Use the static prompt.")
3426 }
3427 }
3428
3429 let cap = StaticPromptCapability;
3430 assert_eq!(
3431 cap.system_prompt_preview().as_deref(),
3432 Some("Use the static prompt.")
3433 );
3434
3435 let registry = fixture_registry();
3437 let current_time = registry.get("current_time").unwrap();
3438 assert!(current_time.system_prompt_preview().is_none());
3439 assert!(current_time.system_prompt_addition().is_none());
3440 }
3441
3442 #[tokio::test]
3447 async fn test_apply_capabilities_empty() {
3448 let registry = CapabilityRegistry::new();
3449 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3450
3451 let applied =
3452 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3453
3454 assert_eq!(
3455 applied.runtime_agent.system_prompt,
3456 base_runtime_agent.system_prompt
3457 );
3458 assert!(applied.tool_registry.is_empty());
3459 assert!(applied.applied_ids.is_empty());
3460 }
3461
3462 #[tokio::test]
3463 async fn test_apply_capabilities_noop() {
3464 let registry = fixture_registry();
3465 let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3466
3467 base_runtime_agent.max_iterations = 13;
3468 base_runtime_agent.temperature = Some(0.25);
3469 base_runtime_agent.max_tokens = Some(1234);
3470 base_runtime_agent.parallel_tool_calls = Some(false);
3471 let applied = apply_capabilities(
3472 base_runtime_agent.clone(),
3473 &["noop".to_string()],
3474 ®istry,
3475 &test_ctx(),
3476 )
3477 .await;
3478
3479 assert_eq!(
3481 applied.runtime_agent.system_prompt,
3482 base_runtime_agent.system_prompt
3483 );
3484 assert!(applied.tool_registry.is_empty());
3485 assert_eq!(applied.applied_ids, vec!["noop"]);
3486 assert_eq!(
3487 serde_json::to_value(&applied.runtime_agent).unwrap(),
3488 serde_json::to_value(&base_runtime_agent).unwrap()
3489 );
3490 let collected = collect_capabilities(&["noop".into()], ®istry, &test_ctx()).await;
3491 assert!(collected.mounts.is_empty());
3492 assert!(collected.message_filter_providers.is_empty());
3493 assert!(compute_features(&["noop".into()], ®istry).is_empty());
3494 }
3495
3496 #[tokio::test]
3497 async fn test_apply_capabilities_current_time() {
3498 let registry = fixture_registry();
3499 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3500
3501 let applied = apply_capabilities(
3502 base_runtime_agent.clone(),
3503 &["current_time".to_string()],
3504 ®istry,
3505 &test_ctx(),
3506 )
3507 .await;
3508
3509 assert!(
3513 applied
3514 .runtime_agent
3515 .system_prompt
3516 .contains(FACTS_DYNAMIC_NOTE),
3517 "current_time should contribute the dynamic-facts note"
3518 );
3519 assert!(
3520 applied
3521 .runtime_agent
3522 .system_prompt
3523 .contains(&base_runtime_agent.system_prompt),
3524 "base prompt is preserved"
3525 );
3526 assert!(applied.tool_registry.has("get_current_time"));
3527 assert_eq!(applied.tool_registry.len(), 1);
3528 assert_eq!(applied.applied_ids, vec!["current_time"]);
3529 }
3530
3531 #[tokio::test]
3532 async fn test_apply_capabilities_skips_coming_soon() {
3533 struct ComingSoonFixture;
3534 impl Capability for ComingSoonFixture {
3535 fn id(&self) -> &str {
3536 "coming_soon_fixture"
3537 }
3538 fn name(&self) -> &str {
3539 "Coming Soon Fixture"
3540 }
3541 fn description(&self) -> &str {
3542 "Test-only capability."
3543 }
3544 fn status(&self) -> CapabilityStatus {
3545 CapabilityStatus::ComingSoon
3546 }
3547 fn system_prompt_addition(&self) -> Option<&str> {
3548 Some("Not yet available.")
3549 }
3550 }
3551 let mut registry = CapabilityRegistry::new();
3552 registry.register(ComingSoonFixture);
3553 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3554
3555 let applied = apply_capabilities(
3556 base_runtime_agent.clone(),
3557 &["coming_soon_fixture".to_string()],
3558 ®istry,
3559 &test_ctx(),
3560 )
3561 .await;
3562
3563 assert_eq!(
3564 applied.runtime_agent.system_prompt,
3565 base_runtime_agent.system_prompt
3566 );
3567 assert!(applied.applied_ids.is_empty());
3568 }
3569
3570 #[tokio::test]
3574 async fn test_apply_capabilities_keeps_deprecated_fully_functional() {
3575 struct DeprecatedFixture;
3576 impl Capability for DeprecatedFixture {
3577 fn id(&self) -> &str {
3578 "deprecated_fixture"
3579 }
3580 fn name(&self) -> &str {
3581 "Deprecated Fixture"
3582 }
3583 fn description(&self) -> &str {
3584 "Test-only capability."
3585 }
3586 fn status(&self) -> CapabilityStatus {
3587 CapabilityStatus::Deprecated
3588 }
3589 fn system_prompt_addition(&self) -> Option<&str> {
3590 Some("Still working.")
3591 }
3592 }
3593 let mut registry = CapabilityRegistry::new();
3594 registry.register(DeprecatedFixture);
3595 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3596
3597 let applied = apply_capabilities(
3598 base_runtime_agent,
3599 &["deprecated_fixture".to_string()],
3600 ®istry,
3601 &test_ctx(),
3602 )
3603 .await;
3604
3605 assert!(
3606 applied
3607 .runtime_agent
3608 .system_prompt
3609 .contains("Still working.")
3610 );
3611 assert_eq!(applied.applied_ids, vec!["deprecated_fixture"]);
3612 }
3613
3614 #[tokio::test]
3618 async fn test_apply_capabilities_skips_retired_without_failing() {
3619 struct RetiredFixture;
3620 impl Capability for RetiredFixture {
3621 fn id(&self) -> &str {
3622 "retired_fixture"
3623 }
3624 fn name(&self) -> &str {
3625 "Retired Fixture"
3626 }
3627 fn description(&self) -> &str {
3628 "Test-only capability."
3629 }
3630 fn status(&self) -> CapabilityStatus {
3631 CapabilityStatus::Retired
3632 }
3633 fn system_prompt_addition(&self) -> Option<&str> {
3634 Some("Should never be applied.")
3635 }
3636 }
3637 let mut registry = fixture_registry();
3638 registry.register(RetiredFixture);
3639 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3640
3641 let applied = apply_capabilities(
3642 base_runtime_agent,
3643 &["retired_fixture".to_string(), "current_time".to_string()],
3644 ®istry,
3645 &test_ctx(),
3646 )
3647 .await;
3648
3649 assert!(
3650 !applied
3651 .runtime_agent
3652 .system_prompt
3653 .contains("Should never be applied.")
3654 );
3655 assert_eq!(applied.applied_ids, vec!["current_time"]);
3657 assert!(applied.tool_registry.has("get_current_time"));
3658 }
3659
3660 #[tokio::test]
3661 async fn test_apply_capabilities_preserves_order() {
3662 let registry = fixture_registry();
3663 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3664
3665 let applied = apply_capabilities(
3667 base_runtime_agent,
3668 &["current_time".to_string(), "noop".to_string()],
3669 ®istry,
3670 &test_ctx(),
3671 )
3672 .await;
3673
3674 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3675 assert_eq!(applied.tool_registry.len(), 1);
3676 assert!(applied.tool_registry.has("get_current_time"));
3677 }
3678
3679 #[tokio::test]
3688 async fn test_dynamic_facts_add_note_without_static_block() {
3689 let registry = fixture_registry();
3693 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3694 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3695 let prompt = collected.system_prompt_parts.join("\n");
3696 assert!(
3697 prompt.contains(FACTS_DYNAMIC_NOTE),
3698 "dynamic-facts note should be in the cached prompt"
3699 );
3700 assert!(
3701 !prompt.contains("<facts>\n"),
3702 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3703 );
3704 }
3705
3706 #[tokio::test]
3707 async fn test_static_facts_fold_into_prompt() {
3708 struct StaticFactCap;
3709 impl Capability for StaticFactCap {
3710 fn id(&self) -> &str {
3711 "test_static_fact"
3712 }
3713 fn name(&self) -> &str {
3714 "Static Fact"
3715 }
3716 fn description(&self) -> &str {
3717 "test"
3718 }
3719 fn status(&self) -> CapabilityStatus {
3720 CapabilityStatus::Available
3721 }
3722 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3723 vec![Fact::stat("workspace_root", "/workspace")]
3724 }
3725 }
3726 let mut registry = CapabilityRegistry::new();
3727 registry.register(StaticFactCap);
3728 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3729 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3730 let prompt = collected.system_prompt_parts.join("\n");
3731 assert!(
3732 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3733 "static fact should fold into the cached prompt; got: {prompt}"
3734 );
3735 assert!(
3736 !prompt.contains(FACTS_DYNAMIC_NOTE),
3737 "no dynamic note when only static facts exist"
3738 );
3739 }
3740
3741 #[test]
3742 fn test_collect_dynamic_facts_returns_current_time() {
3743 let registry = fixture_registry();
3744 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3745 let facts = collect_dynamic_facts(
3746 &configs,
3747 ®istry,
3748 None,
3749 &FactsContext::new(SessionId::new()),
3750 );
3751 assert_eq!(facts.len(), 1);
3752 assert_eq!(facts[0].key, "current_time");
3753 assert_eq!(facts[0].value, "fixture-now");
3754 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3755 }
3756
3757 #[tokio::test]
3758 async fn test_collect_capabilities_combines_mounts() {
3759 struct Notes;
3760 impl Capability for Notes {
3761 fn id(&self) -> &str {
3762 "notes"
3763 }
3764 fn name(&self) -> &str {
3765 "Notes"
3766 }
3767 fn description(&self) -> &str {
3768 "Writable notes"
3769 }
3770 fn mounts(&self) -> Vec<MountPoint> {
3771 vec![MountPoint::readwrite(
3772 "/notes.txt",
3773 MountSource::text_file("Note α"),
3774 "notes",
3775 )]
3776 }
3777 }
3778 let mut registry = fixture_registry();
3779 registry.register(Notes);
3780 let collected = collect_capabilities(
3781 &["sample_data".into(), "notes".into(), "current_time".into()],
3782 ®istry,
3783 &test_ctx(),
3784 )
3785 .await;
3786 assert_eq!(
3787 collected.applied_ids,
3788 [
3789 "session_file_system",
3790 "sample_data",
3791 "notes",
3792 "current_time"
3793 ]
3794 );
3795 assert_eq!(
3796 collected.mounts,
3797 vec![
3798 MountPoint::readonly(
3799 "/samples",
3800 MountDirectoryBuilder::new()
3801 .file("users.json", "[]")
3802 .build(),
3803 "sample_data"
3804 ),
3805 MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
3806 ]
3807 );
3808 }
3809
3810 #[test]
3815 fn test_resolve_dependencies_empty() {
3816 let registry = CapabilityRegistry::new();
3817
3818 let resolved = resolve_dependencies(&[], ®istry).unwrap();
3819
3820 assert!(resolved.resolved_ids.is_empty());
3821 assert!(resolved.added_as_dependencies.is_empty());
3822 assert!(resolved.user_selected.is_empty());
3823 }
3824
3825 #[test]
3826 fn test_resolve_dependencies_no_deps() {
3827 let registry = fixture_registry();
3828
3829 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
3831
3832 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3833 assert!(resolved.added_as_dependencies.is_empty());
3834 }
3835
3836 #[test]
3837 fn test_resolve_dependencies_with_deps() {
3838 let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
3839 assert_eq!(
3840 resolved.resolved_ids,
3841 ["session_file_system", "sample_data"]
3842 );
3843 assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
3844 assert_eq!(resolved.user_selected, ["sample_data"]);
3845 }
3846
3847 #[test]
3848 fn test_resolve_dependencies_already_selected() {
3849 let registry = fixture_registry();
3850
3851 let resolved = resolve_dependencies(
3853 &["session_file_system".to_string(), "sample_data".to_string()],
3854 ®istry,
3855 )
3856 .unwrap();
3857
3858 assert_eq!(resolved.resolved_ids.len(), 2);
3859 assert!(resolved.added_as_dependencies.is_empty());
3861 }
3862
3863 #[test]
3864 fn test_resolve_dependencies_preserves_order() {
3865 let registry = fixture_registry();
3866
3867 let resolved =
3869 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
3870 .unwrap();
3871
3872 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3873 }
3874
3875 #[test]
3876 fn test_resolve_dependencies_unknown_capability() {
3877 let registry = CapabilityRegistry::new();
3878
3879 let resolved =
3881 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
3882
3883 assert!(resolved.resolved_ids.is_empty());
3884 }
3885
3886 #[test]
3887 fn test_get_dependencies() {
3888 let registry = fixture_registry();
3889
3890 let deps = get_dependencies("sample_data", ®istry);
3892 assert_eq!(deps, vec!["session_file_system"]);
3893
3894 let deps = get_dependencies("current_time", ®istry);
3896 assert!(deps.is_empty());
3897
3898 let deps = get_dependencies("unknown", ®istry);
3900 assert!(deps.is_empty());
3901 }
3902
3903 #[test]
3907 fn test_circular_dependency_error() {
3908 struct CapA;
3910 struct CapB;
3911
3912 impl Capability for CapA {
3913 fn id(&self) -> &str {
3914 "test_cap_a"
3915 }
3916 fn name(&self) -> &str {
3917 "Test A"
3918 }
3919 fn description(&self) -> &str {
3920 "Test capability A"
3921 }
3922 fn dependencies(&self) -> Vec<&'static str> {
3923 vec!["test_cap_b"]
3924 }
3925 }
3926
3927 impl Capability for CapB {
3928 fn id(&self) -> &str {
3929 "test_cap_b"
3930 }
3931 fn name(&self) -> &str {
3932 "Test B"
3933 }
3934 fn description(&self) -> &str {
3935 "Test capability B"
3936 }
3937 fn dependencies(&self) -> Vec<&'static str> {
3938 vec!["test_cap_a"]
3939 }
3940 }
3941
3942 let mut registry = CapabilityRegistry::new();
3943 registry.register(CapA);
3944 registry.register(CapB);
3945
3946 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
3947
3948 assert!(result.is_err());
3949 match result.unwrap_err() {
3950 DependencyError::CircularDependency { capability_id, .. } => {
3951 assert_eq!(capability_id, "test_cap_a");
3952 }
3953 _ => panic!("Expected CircularDependency error"),
3954 }
3955 }
3956
3957 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3962
3963 struct FilterTestCapability {
3965 priority: i32,
3966 }
3967
3968 impl Capability for FilterTestCapability {
3969 fn id(&self) -> &str {
3970 "filter_test"
3971 }
3972 fn name(&self) -> &str {
3973 "Filter Test"
3974 }
3975 fn description(&self) -> &str {
3976 "Test capability with message filter"
3977 }
3978 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3979 Some(Arc::new(FilterTestProvider {
3980 priority: self.priority,
3981 }))
3982 }
3983 }
3984
3985 struct FilterTestProvider {
3986 priority: i32,
3987 }
3988
3989 impl MessageFilterProvider for FilterTestProvider {
3990 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3991 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3993 query
3994 .filters
3995 .push(MessageFilter::Search(search.to_string()));
3996 }
3997 }
3998
3999 fn priority(&self) -> i32 {
4000 self.priority
4001 }
4002 }
4003
4004 #[tokio::test]
4005 async fn test_collect_capabilities_with_configs_no_filter_providers() {
4006 let registry = fixture_registry();
4007 let configs = vec![AgentCapabilityConfig::with_config(
4008 CapabilityId::new("current_time"),
4009 serde_json::json!({}),
4010 )];
4011
4012 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4013
4014 assert!(collected.message_filter_providers.is_empty());
4015 assert!(!collected.has_message_filters());
4016 }
4017
4018 #[tokio::test]
4019 async fn test_collected_capabilities_apply_message_filters() {
4020 let mut registry = CapabilityRegistry::new();
4021 registry.register(FilterTestCapability { priority: 0 });
4022
4023 let configs = vec![AgentCapabilityConfig::with_config(
4024 CapabilityId::new("filter_test"),
4025 serde_json::json!({ "search": "test_query" }),
4026 )];
4027
4028 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4029
4030 assert!(collected.has_message_filters());
4031
4032 let session_id: SessionId = Uuid::now_v7().into();
4034 let mut query = MessageQuery::new(session_id);
4035
4036 collected.apply_message_filters(&mut query);
4037
4038 assert_eq!(query.filters.len(), 1);
4040 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4041 }
4042
4043 #[tokio::test]
4044 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4045 struct SearchCapability {
4046 id: &'static str,
4047 search_term: &'static str,
4048 priority: i32,
4049 }
4050
4051 struct SearchProvider {
4052 search_term: &'static str,
4053 priority: i32,
4054 }
4055
4056 impl MessageFilterProvider for SearchProvider {
4057 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4058 query
4059 .filters
4060 .push(MessageFilter::Search(self.search_term.to_string()));
4061 }
4062
4063 fn priority(&self) -> i32 {
4064 self.priority
4065 }
4066 }
4067
4068 impl Capability for SearchCapability {
4069 fn id(&self) -> &str {
4070 self.id
4071 }
4072 fn name(&self) -> &str {
4073 "Search"
4074 }
4075 fn description(&self) -> &str {
4076 "Test"
4077 }
4078 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4079 Some(Arc::new(SearchProvider {
4080 search_term: self.search_term,
4081 priority: self.priority,
4082 }))
4083 }
4084 }
4085
4086 let mut registry = CapabilityRegistry::new();
4087 registry.register(SearchCapability {
4088 id: "cap_a",
4089 search_term: "alpha",
4090 priority: 5,
4091 });
4092 registry.register(SearchCapability {
4093 id: "cap_b",
4094 search_term: "beta",
4095 priority: 1,
4096 });
4097 registry.register(SearchCapability {
4098 id: "cap_c",
4099 search_term: "gamma",
4100 priority: 10,
4101 });
4102
4103 let configs = vec![
4104 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4105 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4106 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4107 ];
4108
4109 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4110
4111 let session_id: SessionId = Uuid::now_v7().into();
4112 let mut query = MessageQuery::new(session_id);
4113
4114 collected.apply_message_filters(&mut query);
4115
4116 assert_eq!(query.filters.len(), 3);
4118 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4119 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4120 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4121 }
4122
4123 #[tokio::test]
4124 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4125 let mut registry = CapabilityRegistry::new();
4126 registry.register(FilterTestCapability { priority: 0 });
4127
4128 let test_config = serde_json::json!({
4129 "search": "custom_search",
4130 "extra_field": 42
4131 });
4132
4133 let configs = vec![AgentCapabilityConfig::with_config(
4134 CapabilityId::new("filter_test"),
4135 test_config.clone(),
4136 )];
4137
4138 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4139
4140 assert_eq!(collected.message_filter_providers.len(), 1);
4142 let (_, stored_config) = &collected.message_filter_providers[0];
4143 assert_eq!(*stored_config, test_config);
4144 }
4145
4146 #[test]
4151 fn test_collect_message_filters_only_collects_filters() {
4152 let mut registry = CapabilityRegistry::new();
4153 registry.register(FilterTestCapability { priority: 0 });
4154
4155 let configs = vec![AgentCapabilityConfig::with_config(
4156 CapabilityId::new("filter_test"),
4157 serde_json::json!({ "search": "test_query" }),
4158 )];
4159
4160 let collected = collect_message_filters_only(&configs, ®istry);
4161
4162 let session_id: SessionId = Uuid::now_v7().into();
4163 let mut query = MessageQuery::new(session_id);
4164 collected.apply_message_filters(&mut query);
4165
4166 assert_eq!(query.filters.len(), 1);
4167 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4168 }
4169
4170 #[test]
4171 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4172 let registry = CapabilityRegistry::new();
4173
4174 let configs = vec![AgentCapabilityConfig::with_config(
4175 CapabilityId::new("nonexistent"),
4176 serde_json::json!({}),
4177 )];
4178
4179 let collected = collect_message_filters_only(&configs, ®istry);
4180 assert!(collected.message_filter_providers.is_empty());
4181 }
4182
4183 #[test]
4184 fn test_collect_message_filters_only_preserves_priority_order() {
4185 struct PriorityFilterCap {
4186 id: &'static str,
4187 search_term: &'static str,
4188 priority: i32,
4189 }
4190
4191 struct PriorityFilterProvider {
4192 search_term: &'static str,
4193 priority: i32,
4194 }
4195
4196 impl Capability for PriorityFilterCap {
4197 fn id(&self) -> &str {
4198 self.id
4199 }
4200 fn name(&self) -> &str {
4201 self.id
4202 }
4203 fn description(&self) -> &str {
4204 "priority test"
4205 }
4206 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4207 Some(Arc::new(PriorityFilterProvider {
4208 search_term: self.search_term,
4209 priority: self.priority,
4210 }))
4211 }
4212 }
4213
4214 impl MessageFilterProvider for PriorityFilterProvider {
4215 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4216 query
4217 .filters
4218 .push(MessageFilter::Search(self.search_term.to_string()));
4219 }
4220 fn priority(&self) -> i32 {
4221 self.priority
4222 }
4223 }
4224
4225 let mut registry = CapabilityRegistry::new();
4226 registry.register(PriorityFilterCap {
4227 id: "gamma",
4228 search_term: "gamma",
4229 priority: 10,
4230 });
4231 registry.register(PriorityFilterCap {
4232 id: "alpha",
4233 search_term: "alpha",
4234 priority: 5,
4235 });
4236 registry.register(PriorityFilterCap {
4237 id: "beta",
4238 search_term: "beta",
4239 priority: 1,
4240 });
4241
4242 let configs = vec![
4243 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4244 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4245 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4246 ];
4247
4248 let collected = collect_message_filters_only(&configs, ®istry);
4249
4250 let session_id: SessionId = Uuid::now_v7().into();
4251 let mut query = MessageQuery::new(session_id);
4252 collected.apply_message_filters(&mut query);
4253
4254 assert_eq!(query.filters.len(), 3);
4256 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4257 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4258 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4259 }
4260
4261 #[test]
4262 fn test_collect_message_filters_only_post_load_invoked() {
4263 use crate::message::Message;
4264
4265 struct PostLoadCap;
4266 struct PostLoadProvider;
4267
4268 impl Capability for PostLoadCap {
4269 fn id(&self) -> &str {
4270 "post_load_test"
4271 }
4272 fn name(&self) -> &str {
4273 "PostLoad Test"
4274 }
4275 fn description(&self) -> &str {
4276 "test"
4277 }
4278 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4279 Some(Arc::new(PostLoadProvider))
4280 }
4281 }
4282
4283 impl MessageFilterProvider for PostLoadProvider {
4284 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4285 fn priority(&self) -> i32 {
4286 0
4287 }
4288 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4289 messages.reverse();
4291 }
4292 }
4293
4294 let mut registry = CapabilityRegistry::new();
4295 registry.register(PostLoadCap);
4296
4297 let configs = vec![AgentCapabilityConfig::with_config(
4298 CapabilityId::new("post_load_test"),
4299 serde_json::json!({}),
4300 )];
4301
4302 let collected = collect_message_filters_only(&configs, ®istry);
4303
4304 let mut messages = vec![Message::user("first"), Message::user("second")];
4305 collected.apply_post_load_filters(&mut messages);
4306
4307 assert_eq!(messages[0].text(), Some("second"));
4309 assert_eq!(messages[1].text(), Some("first"));
4310 }
4311
4312 struct DelegatingFilterCap {
4315 id: &'static str,
4316 inner: std::sync::Arc<InnerFilterCap>,
4317 }
4318 struct InnerFilterCap;
4319
4320 impl Capability for InnerFilterCap {
4321 fn id(&self) -> &str {
4322 "inner_filter"
4323 }
4324 fn tools(&self) -> Vec<Box<dyn Tool>> {
4325 panic!("fast-path collection must not instantiate tools")
4326 }
4327 fn system_prompt_addition(&self) -> Option<&str> {
4328 panic!("fast-path collection must not collect prompts")
4329 }
4330 fn name(&self) -> &str {
4331 "Inner Filter"
4332 }
4333 fn description(&self) -> &str {
4334 "inner"
4335 }
4336 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4337 Some(std::sync::Arc::new(SentinelFilter))
4338 }
4339 }
4340 struct SentinelFilter;
4341 impl MessageFilterProvider for SentinelFilter {
4342 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4343 query.limit = config["limit"].as_i64();
4344 }
4345 }
4346 impl Capability for DelegatingFilterCap {
4347 fn id(&self) -> &str {
4348 self.id
4349 }
4350 fn name(&self) -> &str {
4351 "Delegating Filter"
4352 }
4353 fn description(&self) -> &str {
4354 "delegating"
4355 }
4356 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4357 None }
4359 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4360 Some(&*self.inner)
4361 }
4362 }
4363
4364 #[test]
4365 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4366 let inner = std::sync::Arc::new(InnerFilterCap);
4367 let outer = DelegatingFilterCap {
4368 id: "delegating_filter",
4369 inner: inner.clone(),
4370 };
4371
4372 let mut registry = CapabilityRegistry::new();
4373 registry.register(outer);
4374
4375 let configs = vec![AgentCapabilityConfig::with_config(
4376 CapabilityId::new("delegating_filter"),
4377 serde_json::json!({"limit": 17}),
4378 )];
4379
4380 let collected = collect_message_filters_only(&configs, ®istry);
4383 assert_eq!(
4384 collected.message_filter_providers.len(),
4385 1,
4386 "provider from resolved inner capability must be collected"
4387 );
4388 let mut query = MessageQuery::default();
4389 collected.apply_message_filters(&mut query);
4390 assert_eq!(query.limit, Some(17));
4391 }
4392
4393 struct DelegatingMvpCap {
4394 id: &'static str,
4395 inner: std::sync::Arc<InnerMvpCap>,
4396 }
4397 struct InnerMvpCap;
4398
4399 impl Capability for InnerMvpCap {
4400 fn id(&self) -> &str {
4401 "inner_mvp"
4402 }
4403 fn tools(&self) -> Vec<Box<dyn Tool>> {
4404 panic!("fast-path collection must not instantiate tools")
4405 }
4406 fn system_prompt_addition(&self) -> Option<&str> {
4407 panic!("fast-path collection must not collect prompts")
4408 }
4409 fn name(&self) -> &str {
4410 "Inner MVP"
4411 }
4412 fn description(&self) -> &str {
4413 "inner"
4414 }
4415 fn model_view_provider(
4416 &self,
4417 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4418 struct AppendingMvp;
4420 impl crate::capabilities::ModelViewProvider for AppendingMvp {
4421 fn apply_model_view(
4422 &self,
4423 mut messages: Vec<Message>,
4424 config: &serde_json::Value,
4425 context: &ModelViewContext<'_>,
4426 ) -> Vec<Message> {
4427 messages.push(Message::user(format!(
4428 "{}:{}",
4429 config["suffix"].as_str().unwrap(),
4430 context.session_id
4431 )));
4432 messages
4433 }
4434 }
4435 Some(std::sync::Arc::new(AppendingMvp))
4436 }
4437 }
4438 impl Capability for DelegatingMvpCap {
4439 fn id(&self) -> &str {
4440 self.id
4441 }
4442 fn name(&self) -> &str {
4443 "Delegating MVP"
4444 }
4445 fn description(&self) -> &str {
4446 "delegating"
4447 }
4448 fn model_view_provider(
4449 &self,
4450 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4451 None }
4453 fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
4454 (model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
4455 }
4456 }
4457
4458 #[test]
4459 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4460 let inner = std::sync::Arc::new(InnerMvpCap);
4461 let outer = DelegatingMvpCap {
4462 id: "delegating_mvp",
4463 inner: inner.clone(),
4464 };
4465
4466 let mut registry = CapabilityRegistry::new();
4467 registry.register(outer);
4468
4469 let configs = vec![AgentCapabilityConfig::with_config(
4470 CapabilityId::new("delegating_mvp"),
4471 serde_json::json!({"suffix": "delegated"}),
4472 )];
4473
4474 let collected = collect_model_view_providers(&configs, ®istry, Some("selected-model"));
4477 assert_eq!(
4478 collected.model_view_providers.len(),
4479 1,
4480 "provider from resolved inner capability must be collected"
4481 );
4482 assert!(
4483 collect_model_view_providers(&configs, ®istry, Some("other-model"))
4484 .model_view_providers
4485 .is_empty()
4486 );
4487 let session_id = SessionId::from_seed(42);
4488 let output = collected.apply_model_view(
4489 vec![Message::user("original")],
4490 &ModelViewContext {
4491 session_id,
4492 prior_usage: None,
4493 },
4494 );
4495 assert_eq!(
4496 output.iter().map(Message::text).collect::<Vec<_>>(),
4497 [
4498 Some("original"),
4499 Some(format!("delegated:{session_id}").as_str())
4500 ]
4501 );
4502 }
4503
4504 #[test]
4509 fn test_defaults_do_not_include_bash() {
4510 let registry = crate::ToolRegistry::with_defaults();
4513 assert!(
4514 !registry.has("bash"),
4515 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4516 );
4517 }
4518
4519 #[test]
4524 fn test_alias_resolves_to_canonical_capability() {
4525 let registry = fixture_registry();
4526
4527 let via_alias = registry.get("virtual_bash").unwrap();
4529 assert_eq!(via_alias.id(), "bashkit_shell");
4530 assert!(registry.has("virtual_bash"));
4531 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4532 assert_eq!(
4533 registry.canonical_id("bashkit_shell"),
4534 Some("bashkit_shell")
4535 );
4536 assert_eq!(registry.canonical_id("nonexistent"), None);
4537 }
4538
4539 #[test]
4540 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4541 let registry = fixture_registry();
4542
4543 let resolved = resolve_dependencies(
4546 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4547 ®istry,
4548 )
4549 .unwrap();
4550 let bash_ids: Vec<_> = resolved
4551 .resolved_ids
4552 .iter()
4553 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4554 .collect();
4555 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4556 assert!(
4558 !resolved
4559 .added_as_dependencies
4560 .contains(&"bashkit_shell".to_string())
4561 );
4562 }
4563
4564 #[test]
4565 fn test_alias_preserves_explicit_config_in_resolution() {
4566 let registry = fixture_registry();
4567
4568 let configs = vec![AgentCapabilityConfig::with_config(
4569 "virtual_bash".to_string(),
4570 serde_json::json!({"key": "value"}),
4571 )];
4572 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4573 let bash = resolved
4574 .iter()
4575 .find(|c| c.capability_id() == "bashkit_shell")
4576 .expect("alias must resolve to canonical bashkit_shell config");
4577 assert_eq!(
4578 bash.config_value().clone(),
4579 serde_json::json!({"key": "value"})
4580 );
4581 }
4582
4583 #[test]
4584 fn test_unregister_by_alias_removes_capability_and_aliases() {
4585 let mut registry = fixture_registry();
4586
4587 assert!(registry.unregister("virtual_bash").is_some());
4588 assert!(!registry.has("bashkit_shell"));
4589 assert!(!registry.has("virtual_bash"));
4590 }
4591
4592 #[test]
4593 fn test_compute_features_empty() {
4594 let registry = CapabilityRegistry::new();
4595
4596 let features = compute_features(&[], ®istry);
4597 assert!(features.is_empty());
4598 }
4599
4600 #[test]
4601 fn test_compute_features_unknown_capability_ignored() {
4602 let registry = fixture_registry();
4603
4604 let features = compute_features(
4605 &["unknown_cap".to_string(), "session_storage".to_string()],
4606 ®istry,
4607 );
4608 assert_eq!(features, vec!["secrets", "key_value"]);
4609 }
4610
4611 #[test]
4612 fn test_risk_level_ordering() {
4613 assert!(RiskLevel::Low < RiskLevel::Medium);
4614 assert!(RiskLevel::Medium < RiskLevel::High);
4615 }
4616
4617 #[test]
4618 fn test_risk_level_serde_roundtrip() {
4619 for (level, wire) in [
4620 (RiskLevel::Low, "\"low\""),
4621 (RiskLevel::Medium, "\"medium\""),
4622 (RiskLevel::High, "\"high\""),
4623 ] {
4624 assert_eq!(serde_json::to_string(&level).unwrap(), wire);
4625 assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
4626 }
4627 assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
4628 }
4629
4630 struct SkillContributingCapability;
4635
4636 impl Capability for SkillContributingCapability {
4637 fn id(&self) -> &str {
4638 "contributes_skills"
4639 }
4640 fn name(&self) -> &str {
4641 "Contributes Skills"
4642 }
4643 fn description(&self) -> &str {
4644 "Test capability that contributes skills."
4645 }
4646 fn contribute_skills(&self) -> Vec<SkillContribution> {
4647 vec![
4648 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4649 .with_files(vec![(
4650 "scripts/a.sh".to_string(),
4651 "#!/bin/sh\necho a\n".to_string(),
4652 )]),
4653 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4654 .with_user_invocable(false),
4655 ]
4656 }
4657 }
4658
4659 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4660 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4661 MountSource::InlineFile { content, .. } => content.as_str(),
4662 _ => panic!("Expected InlineFile for SKILL.md"),
4663 }
4664 }
4665
4666 #[tokio::test]
4667 async fn test_contribute_skills_normalized_to_mounts() {
4668 let mut registry = CapabilityRegistry::new();
4669 registry.register(SkillContributingCapability);
4670
4671 let configs = vec![AgentCapabilityConfig::with_config(
4672 CapabilityId::new("contributes_skills"),
4673 serde_json::json!({}),
4674 )];
4675
4676 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4677
4678 let skill_mounts: Vec<_> = collected
4679 .mounts
4680 .iter()
4681 .filter(|m| m.path.starts_with("/.agents/skills/"))
4682 .collect();
4683 assert_eq!(skill_mounts.len(), 2);
4684
4685 for m in &skill_mounts {
4688 assert!(m.is_readonly());
4689 assert_eq!(m.capability_id, "contributes_skills");
4690 }
4691
4692 let alpha = skill_mounts
4693 .iter()
4694 .find(|m| m.path == "/.agents/skills/alpha-skill")
4695 .expect("alpha-skill mount missing");
4696 match &alpha.source {
4697 MountSource::InlineDirectory { entries } => {
4698 assert!(entries.contains_key("SKILL.md"));
4699 assert!(entries.contains_key("scripts/a.sh"));
4700 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4701 assert_eq!(parsed.name, "alpha-skill");
4702 assert_eq!(parsed.description, "Alpha skill desc");
4703 assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
4704 assert!(parsed.user_invocable);
4705 }
4706 _ => panic!("Expected InlineDirectory"),
4707 }
4708
4709 let beta = skill_mounts
4710 .iter()
4711 .find(|m| m.path == "/.agents/skills/beta-skill")
4712 .expect("beta-skill mount missing");
4713 match &beta.source {
4714 MountSource::InlineDirectory { entries } => {
4715 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4716 assert!(!parsed.user_invocable);
4717 assert_eq!(parsed.name, "beta-skill");
4718 assert_eq!(parsed.instructions, "# Beta\nDo beta.");
4719 }
4720 _ => panic!("Expected InlineDirectory"),
4721 }
4722 }
4723
4724 #[tokio::test]
4725 async fn test_contribute_skills_default_empty() {
4726 let mut registry = CapabilityRegistry::new();
4729 registry.register(FilterTestCapability { priority: 0 });
4730
4731 let configs = vec![AgentCapabilityConfig::with_config(
4732 CapabilityId::new("filter_test"),
4733 serde_json::json!({}),
4734 )];
4735
4736 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4737 assert!(
4738 collected
4739 .mounts
4740 .iter()
4741 .all(|m| !m.path.starts_with("/.agents/skills/"))
4742 );
4743 }
4744
4745 struct LocalizedCapability;
4746
4747 impl Capability for LocalizedCapability {
4748 fn id(&self) -> &str {
4749 "localized"
4750 }
4751 fn name(&self) -> &str {
4752 "Localized"
4753 }
4754 fn description(&self) -> &str {
4755 "English description"
4756 }
4757 fn localizations(&self) -> Vec<CapabilityLocalization> {
4758 vec![
4759 CapabilityLocalization {
4760 locale: "en",
4761 name: None,
4762 description: None,
4763 config_description: Some("Controls things."),
4764 config_overlay: None,
4765 },
4766 CapabilityLocalization {
4767 locale: "uk-UA",
4768 name: Some("Регіональна"),
4769 description: None,
4770 config_description: None,
4771 config_overlay: None,
4772 },
4773 CapabilityLocalization {
4774 locale: "uk",
4775 name: Some("Локалізована"),
4776 description: Some("Український опис"),
4777 config_description: Some("Керує налаштуваннями."),
4778 config_overlay: None,
4779 },
4780 ]
4781 }
4782 }
4783
4784 #[test]
4785 fn localized_name_falls_back_exact_language_then_base() {
4786 let cap = LocalizedCapability;
4787 assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
4789 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
4790 assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
4791 assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
4792 assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
4793 assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
4795 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
4797 assert_eq!(cap.localized_name(None), "Localized");
4798 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
4799 assert_eq!(cap.localized_description(Some("de")), "English description");
4800 }
4801
4802 #[test]
4803 fn describe_schema_resolves_config_description_per_locale() {
4804 let cap = LocalizedCapability;
4805 assert_eq!(
4806 cap.describe_schema(Some("uk-UA")).as_deref(),
4807 Some("Керує налаштуваннями.")
4808 );
4809 assert_eq!(
4811 cap.describe_schema(Some("pl")).as_deref(),
4812 Some("Controls things.")
4813 );
4814 assert_eq!(
4815 cap.describe_schema(None).as_deref(),
4816 Some("Controls things.")
4817 );
4818 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
4820 }
4821
4822 #[tokio::test]
4823 async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
4824 let registry = fixture_registry();
4825 for (ids, expected) in [
4826 (
4827 vec!["test_math"],
4828 vec![
4829 ("add", "test_math", "Test Math"),
4830 ("subtract", "test_math", "Test Math"),
4831 ("multiply", "test_math", "Test Math"),
4832 ("divide", "test_math", "Test Math"),
4833 ],
4834 ),
4835 (
4836 vec!["test_weather"],
4837 vec![
4838 ("get_weather", "test_weather", "Test Weather"),
4839 ("get_forecast", "test_weather", "Test Weather"),
4840 ],
4841 ),
4842 (
4843 vec!["sample_data"],
4844 vec![
4845 ("read_file", "session_file_system", "Fixture Filesystem"),
4846 ("write_file", "session_file_system", "Fixture Filesystem"),
4847 ],
4848 ),
4849 (
4850 vec!["bashkit_shell", "test_weather"],
4851 vec![
4852 ("read_file", "session_file_system", "Fixture Filesystem"),
4853 ("write_file", "session_file_system", "Fixture Filesystem"),
4854 ("bash", "bashkit_shell", "Fixture Bash"),
4855 ("get_weather", "test_weather", "Test Weather"),
4856 ("get_forecast", "test_weather", "Test Weather"),
4857 ],
4858 ),
4859 ] {
4860 let ids: Vec<_> = ids.into_iter().map(String::from).collect();
4861 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4862 assert_eq!(
4863 collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
4864 expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
4865 );
4866 assert_eq!(collected.tool_definitions.len(), expected.len());
4867 for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
4868 assert_eq!(definition.name(), name);
4869 let hints = definition.hints();
4870 assert_eq!(hints.capability_id.as_deref(), Some(id));
4871 assert_eq!(hints.capability_name.as_deref(), Some(label));
4872 let ToolDefinition::Builtin(tool) = definition else {
4873 panic!("expected builtin")
4874 };
4875 let schema = if name == "bash" {
4876 serde_json::json!({"type":"object"})
4877 } else {
4878 serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
4879 };
4880 assert_eq!(tool.parameters, schema);
4881 }
4882 }
4883 }
4884
4885 #[tokio::test]
4886 async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
4887 let registry = fixture_registry();
4888 let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
4889 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4890 let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
4891 let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
4892 assert_eq!(collected.system_prompt_parts, vec![first, second]);
4893 assert_eq!(
4894 collected.system_prompt_attributions,
4895 vec![
4896 SystemPromptAttribution {
4897 capability_id: ids[0].clone(),
4898 content: first.into()
4899 },
4900 SystemPromptAttribution {
4901 capability_id: ids[1].clone(),
4902 content: second.into()
4903 }
4904 ]
4905 );
4906 assert_eq!(
4907 collected.system_prompt_prefix(),
4908 Some(format!("{first}\n\n{second}"))
4909 );
4910 let applied = apply_capabilities(
4911 RuntimeAgent::new("Base.", "fixture-model"),
4912 &ids,
4913 ®istry,
4914 &test_ctx(),
4915 )
4916 .await;
4917 assert_eq!(
4918 applied.runtime_agent.system_prompt,
4919 format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
4920 );
4921 assert!(applied.tool_registry.has("write_todos"));
4922 assert_eq!(applied.tool_registry.len(), 1);
4923 for (base, addition, expected) in [
4924 ("Base.", None, "Base."),
4925 ("Base.", Some(""), "Base."),
4926 ("", Some("Extra."), "Extra."),
4927 (
4928 "<system-prompt>Base.</system-prompt>",
4929 Some("Extra."),
4930 "<system-prompt>Base.</system-prompt>\n\nExtra.",
4931 ),
4932 ] {
4933 assert_eq!(compose_system_prompt(base, addition), expected);
4934 }
4935 }
4936
4937 struct DependencyFixture {
4938 id: String,
4939 deps: Vec<&'static str>,
4940 features: Vec<&'static str>,
4941 }
4942 impl Capability for DependencyFixture {
4943 fn id(&self) -> &str {
4944 &self.id
4945 }
4946 fn name(&self) -> &str {
4947 &self.id
4948 }
4949 fn description(&self) -> &str {
4950 "Dependency fixture"
4951 }
4952 fn dependencies(&self) -> Vec<&'static str> {
4953 self.deps.clone()
4954 }
4955 fn features(&self) -> Vec<&'static str> {
4956 self.features.clone()
4957 }
4958 }
4959
4960 #[test]
4961 fn feature_projection_preserves_order_and_distinct_dependency_features() {
4962 let mut registry = CapabilityRegistry::new();
4963 registry.register(DependencyFixture {
4964 id: "base".into(),
4965 deps: vec![],
4966 features: vec!["base-only", "shared"],
4967 });
4968 registry.register(DependencyFixture {
4969 id: "parent".into(),
4970 deps: vec!["base"],
4971 features: vec!["parent-only", "shared"],
4972 });
4973 registry.register(DependencyFixture {
4974 id: "other".into(),
4975 deps: vec![],
4976 features: vec!["other-only"],
4977 });
4978 assert_eq!(
4979 compute_features(&["parent".into()], ®istry),
4980 vec!["base-only", "shared", "parent-only"]
4981 );
4982 assert_eq!(
4983 compute_features(
4984 &[
4985 "other".into(),
4986 "parent".into(),
4987 "base".into(),
4988 "parent".into()
4989 ],
4990 ®istry
4991 ),
4992 vec!["other-only", "base-only", "shared", "parent-only"]
4993 );
4994 }
4995
4996 #[test]
4997 fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
4998 let mut registry = CapabilityRegistry::new();
4999 let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
5000 for id in &ids {
5001 registry.register(DependencyFixture {
5002 id: id.clone(),
5003 deps: vec![],
5004 features: vec![],
5005 });
5006 }
5007 let resolved = resolve_dependencies(&ids[..100], ®istry).unwrap();
5008 assert_eq!(resolved.resolved_ids, ids[..100]);
5009 assert_eq!(resolved.user_selected, ids[..100]);
5010 assert!(resolved.added_as_dependencies.is_empty());
5011 assert_eq!(
5012 resolve_dependencies(&ids, ®istry).unwrap_err(),
5013 DependencyError::TooManyCapabilities {
5014 count: 101,
5015 max: 100
5016 }
5017 );
5018 }
5019}