1use crate::command::{
22 CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
23};
24use crate::events::TokenUsage;
25use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
26use crate::message::Message;
27use crate::message_filter::MessageFilterProvider;
28use crate::runtime_agent::RuntimeAgent;
29use crate::tool_types::{ToolCall, ToolDefinition};
30use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
31use crate::typed_id::SessionId;
32use crate::{session_files::SessionFileSystem, tool_context::ToolContext};
33use async_trait::async_trait;
34use everruns_capability::is_plugin_capability;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::sync::Arc;
38
39pub struct IntegrationPlugin {
64 pub experimental_only: bool,
66 pub feature_flag: Option<&'static str>,
71 pub factory: fn() -> Box<dyn Capability>,
73}
74
75inventory::collect!(IntegrationPlugin);
76
77pub use crate::capability_types::{
78 CapabilityStatus, MountAccess, MountDirectoryBuilder, MountEntry, MountPoint, MountSource,
79};
80use everruns_capability::{CapabilityId, CapabilityRef as AgentCapabilityConfig};
81
82mod declarative;
87pub mod facts;
88pub mod skill_contribution;
89pub mod util;
90
91pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
96pub const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
100pub const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
104pub use declarative::{
105 DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
106 DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
107 declarative_capability_info, hydrate_declarative_capability_config,
108 hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
109 plugin_capability_info, validate_declarative_capability_definition,
110};
111pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
112pub use skill_contribution::{
113 MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
114 SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
115 discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
116 reconstruct_skill_md, skill_capability_id,
117};
118
119pub struct SystemPromptContext {
129 pub session_id: SessionId,
131 pub locale: Option<String>,
133 pub file_store: Option<Arc<dyn SessionFileSystem>>,
135 pub model: Option<String>,
141}
142
143impl SystemPromptContext {
144 pub fn without_file_store(session_id: SessionId) -> Self {
146 Self {
147 session_id,
148 locale: None,
149 file_store: None,
150 model: None,
151 }
152 }
153
154 pub fn with_model(mut self, model: impl Into<String>) -> Self {
156 self.model = Some(model.into());
157 self
158 }
159}
160
161#[derive(Debug, Clone)]
213pub struct CapabilityLocalization {
214 pub locale: &'static str,
216 pub name: Option<&'static str>,
218 pub description: Option<&'static str>,
220 pub config_description: Option<&'static str>,
225 pub config_overlay: Option<serde_json::Value>,
231}
232
233impl CapabilityLocalization {
234 pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
236 Self {
237 locale,
238 name: Some(name),
239 description: Some(description),
240 config_description: None,
241 config_overlay: None,
242 }
243 }
244}
245
246pub fn resolve_localized_field<T>(
250 localizations: &[CapabilityLocalization],
251 locale: Option<&str>,
252 field: impl Fn(&CapabilityLocalization) -> Option<T>,
253) -> Option<T> {
254 let mut candidates: Vec<String> = Vec::new();
255 if let Some(raw) = locale {
256 let normalized = raw.trim().replace('_', "-").to_lowercase();
257 if !normalized.is_empty() {
258 if let Some((language, _)) = normalized.split_once('-') {
259 let language = language.to_string();
260 candidates.push(normalized);
261 candidates.push(language);
262 } else {
263 candidates.push(normalized);
264 }
265 }
266 }
267 candidates.push("en".to_string());
268
269 for candidate in candidates {
270 let hit = localizations
271 .iter()
272 .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
273 .and_then(&field);
274 if hit.is_some() {
275 return hit;
276 }
277 }
278 None
279}
280
281#[async_trait]
282pub trait Capability: Send + Sync {
283 fn id(&self) -> &str;
285
286 fn aliases(&self) -> Vec<&'static str> {
295 vec![]
296 }
297
298 fn name(&self) -> &str;
300
301 fn description(&self) -> &str;
303
304 fn localizations(&self) -> Vec<CapabilityLocalization> {
309 vec![]
310 }
311
312 fn localized_name(&self, locale: Option<&str>) -> String {
315 resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
316 .unwrap_or_else(|| self.name())
317 .to_string()
318 }
319
320 fn localized_description(&self, locale: Option<&str>) -> String {
322 resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
323 .unwrap_or_else(|| self.description())
324 .to_string()
325 }
326
327 fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
331 resolve_localized_field(&self.localizations(), locale, |entry| {
332 entry.config_description
333 })
334 .map(str::to_string)
335 }
336
337 fn status(&self) -> CapabilityStatus {
339 CapabilityStatus::Available
340 }
341
342 fn icon(&self) -> Option<&str> {
344 None
345 }
346
347 fn category(&self) -> Option<&str> {
349 None
350 }
351
352 fn metadata(&self) -> Option<serde_json::Value> {
364 None
365 }
366
367 fn is_guardrail(&self) -> bool {
372 false
373 }
374
375 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
386 None
387 }
388
389 fn system_prompt_addition(&self) -> Option<&str> {
409 None
410 }
411
412 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
424 self.system_prompt_addition().map(|addition| {
425 format!(
426 "<capability id=\"{}\">\n{}\n</capability>",
427 self.id(),
428 addition
429 )
430 })
431 }
432
433 fn system_prompt_preview(&self) -> Option<String> {
439 self.system_prompt_addition().map(|s| s.to_string())
440 }
441
442 fn tools(&self) -> Vec<Box<dyn Tool>> {
444 vec![]
445 }
446
447 fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
455 self.tools()
456 }
457
458 fn delegation_target_with_config(
464 &self,
465 _config: &serde_json::Value,
466 ) -> Option<DelegationTargetProvider> {
467 None
468 }
469
470 fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
474 false
475 }
476
477 async fn system_prompt_contribution_with_config(
484 &self,
485 ctx: &SystemPromptContext,
486 _config: &serde_json::Value,
487 ) -> Option<String> {
488 self.system_prompt_contribution(ctx).await
489 }
490
491 fn tool_definitions(&self) -> Vec<ToolDefinition> {
494 self.tools().iter().map(|t| t.to_definition()).collect()
495 }
496
497 fn mounts(&self) -> Vec<MountPoint> {
505 vec![]
506 }
507
508 fn dependencies(&self) -> Vec<&'static str> {
517 vec![]
518 }
519
520 fn features(&self) -> Vec<&'static str> {
535 vec![]
536 }
537
538 fn config_schema(&self) -> Option<serde_json::Value> {
544 None
545 }
546
547 fn config_ui_schema(&self) -> Option<serde_json::Value> {
552 None
553 }
554
555 fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
561 Ok(())
562 }
563
564 fn mcp_servers(&self) -> ScopedMcpServers {
570 ScopedMcpServers::default()
571 }
572
573 fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
575 self.mcp_servers()
576 }
577
578 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
591 None
592 }
593
594 fn message_filter_config(
599 &self,
600 config: &serde_json::Value,
601 _compaction_enabled: bool,
602 ) -> serde_json::Value {
603 config.clone()
604 }
605
606 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
614 None
615 }
616
617 fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
629 None
630 }
631
632 fn tool_search_config(
636 &self,
637 _config: &serde_json::Value,
638 ) -> Option<crate::driver_registry::ToolSearchConfig> {
639 None
640 }
641
642 fn prompt_cache_config(
645 &self,
646 _config: &serde_json::Value,
647 ) -> Option<crate::driver_registry::PromptCacheConfig> {
648 None
649 }
650
651 fn openrouter_routing_config(
657 &self,
658 _config: &serde_json::Value,
659 ) -> Option<crate::driver_registry::OpenRouterRoutingConfig> {
660 None
661 }
662
663 fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
666 None
667 }
668
669 fn error_disclosure(
671 &self,
672 _config: &serde_json::Value,
673 ) -> Option<crate::user_facing_error::ErrorDisclosure> {
674 None
675 }
676
677 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
680 text
681 }
682
683 fn compaction_policy(
687 &self,
688 _config: &serde_json::Value,
689 ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
690 None
691 }
692
693 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
708 vec![]
709 }
710
711 fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
722 vec![]
723 }
724
725 fn pre_tool_use_hooks_with_config(
730 &self,
731 _config: &serde_json::Value,
732 ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
733 self.pre_tool_use_hooks()
734 }
735
736 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
744 vec![]
745 }
746
747 fn post_tool_exec_hooks_with_config(
752 &self,
753 _config: &serde_json::Value,
754 ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
755 self.post_tool_exec_hooks()
756 }
757
758 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
767 vec![]
768 }
769
770 fn tool_definition_hooks_with_config(
775 &self,
776 _config: &serde_json::Value,
777 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
778 self.tool_definition_hooks()
779 }
780
781 fn tool_definition_hooks_with_context(
791 &self,
792 _ctx: &SystemPromptContext,
793 config: &serde_json::Value,
794 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
795 self.tool_definition_hooks_with_config(config)
796 }
797
798 fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
806 vec![]
807 }
808
809 fn finalized_tool_calls_hook(
813 &self,
814 _config: &serde_json::Value,
815 ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
816 None
817 }
818
819 fn narrate(
833 &self,
834 _tool_def: Option<&ToolDefinition>,
835 tool_call: &ToolCall,
836 phase: crate::tool_narration::ToolNarrationPhase,
837 locale: Option<&str>,
838 ctx: crate::tool_narration::ToolNarrationContext<'_>,
839 ) -> Option<String> {
840 self.tools()
841 .iter()
842 .find(|tool| tool.name() == tool_call.name)
843 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
844 }
845
846 fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
862 vec![]
863 }
864
865 fn user_hooks_with_config(
871 &self,
872 _config: &serde_json::Value,
873 ) -> Vec<crate::user_hook_types::UserHookSpec> {
874 self.user_hooks()
875 }
876
877 fn risk_level(&self) -> RiskLevel {
885 RiskLevel::Low
886 }
887
888 fn commands(&self) -> Vec<CommandDescriptor> {
896 vec![]
897 }
898
899 async fn execute_command(
913 &self,
914 request: &ExecuteCommandRequest,
915 _ctx: &CommandExecutionContext,
916 ) -> crate::error::Result<CommandResult> {
917 Err(crate::error::AgentLoopError::config(format!(
918 "capability {} declared command /{} but does not implement execute_command",
919 self.id(),
920 request.name,
921 )))
922 }
923
924 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
933 vec![]
934 }
935
936 fn contribute_skills(&self) -> Vec<SkillContribution> {
946 vec![]
947 }
948
949 fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
960 vec![]
961 }
962
963 fn post_output_guardrails_with_config(
975 &self,
976 _config: &serde_json::Value,
977 ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
978 vec![]
979 }
980
981 fn post_output_annotation_hooks_with_config(
997 &self,
998 _config: &serde_json::Value,
999 ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1000 vec![]
1001 }
1002
1003 fn citation_verifier_with_config(
1013 &self,
1014 _config: &serde_json::Value,
1015 ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1016 None
1017 }
1018}
1019
1020pub trait ToolDefinitionHook: Send + Sync {
1021 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1022
1023 fn applies_with_native_tool_search(&self) -> bool {
1028 true
1029 }
1030}
1031
1032pub trait ToolCallHook: Send + Sync {
1033 fn narration(
1034 &self,
1035 _tool_def: Option<&ToolDefinition>,
1036 _tool_call: &ToolCall,
1037 _phase: crate::tool_narration::ToolNarrationPhase,
1038 _locale: Option<&str>,
1039 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1040 ) -> Option<String> {
1041 None
1042 }
1043
1044 fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1045 tool_call
1046 }
1047}
1048
1049pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1055
1056impl ToolCallHook for CapabilityNarrationHook {
1057 fn narration(
1058 &self,
1059 tool_def: Option<&ToolDefinition>,
1060 tool_call: &ToolCall,
1061 phase: crate::tool_narration::ToolNarrationPhase,
1062 locale: Option<&str>,
1063 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1064 ) -> Option<String> {
1065 self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1066 }
1067}
1068
1069#[derive(
1073 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1074)]
1075#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1076#[cfg_attr(feature = "openapi", schema(example = "low"))]
1077#[serde(rename_all = "lowercase")]
1078pub enum RiskLevel {
1079 Low,
1081 Medium,
1083 High,
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize)]
1093#[serde(rename_all = "snake_case")]
1094pub enum BlueprintModel {
1095 Fixed(String),
1097 Default(String),
1099 Inherit,
1101}
1102
1103pub struct AgentBlueprint {
1109 pub id: &'static str,
1111 pub name: &'static str,
1113 pub description: &'static str,
1115 pub model: BlueprintModel,
1117 pub system_prompt: &'static str,
1119 pub tools: Vec<Box<dyn Tool>>,
1121 pub max_turns: Option<usize>,
1123 pub config_schema: Option<serde_json::Value>,
1125}
1126
1127impl AgentBlueprint {
1128 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1130 self.tools.iter().map(|t| t.to_definition()).collect()
1131 }
1132}
1133
1134impl std::fmt::Debug for AgentBlueprint {
1135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1136 f.debug_struct("AgentBlueprint")
1137 .field("id", &self.id)
1138 .field("name", &self.name)
1139 .field("model", &self.model)
1140 .field("tool_count", &self.tools.len())
1141 .field("max_turns", &self.max_turns)
1142 .finish()
1143 }
1144}
1145
1146#[derive(Clone)]
1164pub struct CapabilityRegistry {
1165 capabilities: HashMap<String, Arc<dyn Capability>>,
1166 index: everruns_capability::CapabilityIdIndex,
1170}
1171
1172impl CapabilityRegistry {
1173 pub fn new() -> Self {
1175 Self {
1176 capabilities: HashMap::new(),
1177 index: everruns_capability::CapabilityIdIndex::new(),
1178 }
1179 }
1180
1181 pub fn register(&mut self, capability: impl Capability + 'static) {
1183 self.register_arc(Arc::new(capability));
1184 }
1185
1186 pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1188 self.register_arc(Arc::from(capability));
1189 }
1190
1191 pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1197 let canonical = capability.id().to_string();
1198 self.index
1199 .insert_or_replace(canonical.clone(), &capability.aliases());
1200 self.capabilities.insert(canonical, capability);
1201 }
1202
1203 pub fn try_register_arc(
1206 &mut self,
1207 capability: Arc<dyn Capability>,
1208 ) -> Result<(), everruns_capability::CapabilityError> {
1209 let canonical = capability.id().to_string();
1210 self.index
1211 .insert(canonical.clone(), &capability.aliases())?;
1212 self.capabilities.insert(canonical, capability);
1213 Ok(())
1214 }
1215
1216 pub fn register_inventory_plugins(
1221 &mut self,
1222 mut include: impl FnMut(&IntegrationPlugin) -> bool,
1223 ) {
1224 for plugin in inventory::iter::<IntegrationPlugin>() {
1225 if include(plugin) {
1226 self.register_boxed((plugin.factory)());
1227 }
1228 }
1229 }
1230
1231 pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1233 self.capabilities.get(self.index.canonical_of(id)?)
1234 }
1235
1236 pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1241 self.index.canonical_of(id)
1242 }
1243
1244 pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1246 let canonical = self.index.remove(id)?;
1247 self.capabilities.remove(&canonical)
1248 }
1249
1250 pub fn has(&self, id: &str) -> bool {
1252 self.get(id).is_some()
1253 }
1254
1255 pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1257 self.capabilities.values().collect()
1258 }
1259
1260 pub fn len(&self) -> usize {
1262 self.capabilities.len()
1263 }
1264
1265 pub fn is_empty(&self) -> bool {
1267 self.capabilities.is_empty()
1268 }
1269
1270 pub fn builder() -> CapabilityRegistryBuilder {
1272 CapabilityRegistryBuilder::new()
1273 }
1274
1275 pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1279 for cap in self.capabilities.values() {
1280 for bp in cap.agent_blueprints() {
1281 if bp.id == id {
1282 return Some(bp);
1283 }
1284 }
1285 }
1286 None
1287 }
1288
1289 pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1293 for (capability_id, cap) in &self.capabilities {
1294 for bp in cap.agent_blueprints() {
1295 if bp.id == id {
1296 return Some((capability_id.clone(), bp));
1297 }
1298 }
1299 }
1300 None
1301 }
1302
1303 pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1305 self.capabilities
1306 .values()
1307 .flat_map(|cap| cap.agent_blueprints())
1308 .collect()
1309 }
1310}
1311
1312impl Default for CapabilityRegistry {
1313 fn default() -> Self {
1314 Self::new()
1315 }
1316}
1317
1318impl std::fmt::Debug for CapabilityRegistry {
1319 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1320 let ids: Vec<_> = self.capabilities.keys().collect();
1321 f.debug_struct("CapabilityRegistry")
1322 .field("capabilities", &ids)
1323 .finish()
1324 }
1325}
1326
1327pub struct CapabilityRegistryBuilder {
1329 registry: CapabilityRegistry,
1330}
1331
1332impl CapabilityRegistryBuilder {
1333 pub fn new() -> Self {
1335 Self {
1336 registry: CapabilityRegistry::new(),
1337 }
1338 }
1339
1340 pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1342 self.registry.register(capability);
1343 self
1344 }
1345
1346 pub fn build(self) -> CapabilityRegistry {
1348 self.registry
1349 }
1350}
1351
1352impl Default for CapabilityRegistryBuilder {
1353 fn default() -> Self {
1354 Self::new()
1355 }
1356}
1357
1358pub struct ModelViewContext<'a> {
1364 pub session_id: SessionId,
1365 pub prior_usage: Option<&'a TokenUsage>,
1366}
1367
1368pub trait ModelViewProvider: Send + Sync {
1374 fn apply_model_view(
1375 &self,
1376 messages: Vec<Message>,
1377 config: &serde_json::Value,
1378 context: &ModelViewContext<'_>,
1379 ) -> Vec<Message>;
1380
1381 fn priority(&self) -> i32 {
1382 0
1383 }
1384}
1385
1386pub struct CollectedCapabilities {
1391 pub system_prompt_parts: Vec<String>,
1393 pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1395 pub tools: Vec<Box<dyn Tool>>,
1397 pub tool_definitions: Vec<ToolDefinition>,
1399 pub mounts: Vec<MountPoint>,
1401 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1403 pub applied_ids: Vec<String>,
1405 pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1407 pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1409 pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
1412 pub parallel_tool_calls: Option<bool>,
1416 pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1418 pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1420 pub mcp_servers: ScopedMcpServers,
1422 }
1428
1429#[derive(Debug, Clone, PartialEq, Eq)]
1430pub struct SystemPromptAttribution {
1431 pub capability_id: String,
1432 pub content: String,
1433}
1434
1435impl CollectedCapabilities {
1436 pub fn system_prompt_prefix(&self) -> Option<String> {
1439 if self.system_prompt_parts.is_empty() {
1440 None
1441 } else {
1442 Some(self.system_prompt_parts.join("\n\n"))
1443 }
1444 }
1445
1446 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1450 for (provider, config) in &self.message_filter_providers {
1452 provider.apply_filters(query, config);
1453 }
1454 }
1455
1456 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1459 for (provider, config) in &self.message_filter_providers {
1460 provider.post_load(messages, config);
1461 }
1462 }
1463
1464 pub fn has_message_filters(&self) -> bool {
1466 !self.message_filter_providers.is_empty()
1467 }
1468}
1469
1470pub struct DelegationTargetProvider {
1471 pub target_type: &'static str,
1472 pub tool: Box<dyn Tool>,
1473}
1474
1475#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1477#[serde(rename_all = "snake_case")]
1478pub enum SpawnMode {
1479 Background,
1480 Foreground,
1481}
1482
1483impl SpawnMode {
1484 pub fn parse(value: &str) -> Option<Self> {
1485 match value {
1486 "background" => Some(Self::Background),
1487 "foreground" => Some(Self::Foreground),
1488 _ => None,
1489 }
1490 }
1491
1492 pub fn as_str(self) -> &'static str {
1493 match self {
1494 Self::Background => "background",
1495 Self::Foreground => "foreground",
1496 }
1497 }
1498}
1499
1500struct UnifiedSpawnAgentTool {
1501 providers: Vec<DelegationTargetProvider>,
1502}
1503
1504impl UnifiedSpawnAgentTool {
1505 fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1506 Self { providers }
1507 }
1508
1509 fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1510 self.providers
1511 .iter()
1512 .find(|provider| provider.target_type == target_type)
1513 .map(|provider| provider.tool.as_ref())
1514 }
1515
1516 fn target_types(&self) -> Vec<&'static str> {
1517 ["subagent", "agent", "external_a2a"]
1518 .into_iter()
1519 .filter(|target_type| {
1520 self.providers
1521 .iter()
1522 .any(|provider| provider.target_type == *target_type)
1523 })
1524 .collect()
1525 }
1526
1527 fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1532 self.target_types()
1533 .into_iter()
1534 .filter_map(|target_type| match target_type {
1535 "subagent" => Some(serde_json::json!({
1536 "properties": {
1537 "type": {"const": "subagent"}
1538 }
1539 })),
1540 "agent" => Some(serde_json::json!({
1541 "properties": {
1542 "type": {"const": "agent"}
1543 },
1544 "required": ["type", "id"]
1545 })),
1546 "external_a2a" => Some(serde_json::json!({
1547 "properties": {
1548 "type": {"const": "external_a2a"}
1549 },
1550 "anyOf": [
1551 {"required": ["id"]},
1552 {"required": ["external_agent_id"]}
1553 ]
1554 })),
1555 _ => None,
1556 })
1557 .collect()
1558 }
1559
1560 }
1570
1571#[async_trait]
1572impl Tool for UnifiedSpawnAgentTool {
1573 fn narrate(
1574 &self,
1575 tool_call: &ToolCall,
1576 phase: crate::tool_narration::ToolNarrationPhase,
1577 locale: Option<&str>,
1578 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1579 ) -> Option<String> {
1580 let target_type = tool_call
1581 .arguments
1582 .get("target")
1583 .and_then(|target| target.get("type"))
1584 .and_then(serde_json::Value::as_str)?;
1585 self.provider_for(target_type)
1586 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
1587 }
1588
1589 fn name(&self) -> &str {
1590 "spawn_agent"
1591 }
1592
1593 fn display_name(&self) -> Option<&str> {
1594 Some("Spawn Agent")
1595 }
1596
1597 fn description(&self) -> &str {
1598 "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."
1599 }
1600
1601 fn parameters_schema(&self) -> serde_json::Value {
1602 serde_json::json!({
1603 "type": "object",
1604 "properties": {
1605 "name": {
1606 "type": "string",
1607 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1608 },
1609 "instructions": {
1610 "type": "string",
1611 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1612 },
1613 "goal": {
1614 "type": "string",
1615 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1616 },
1617 "lifetime": {
1618 "type": "string",
1619 "enum": ["linked", "detached"],
1620 "default": "linked",
1621 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1622 },
1623 "seed": {
1624 "type": "string",
1625 "enum": ["fresh", "fork", "workspace"],
1626 "default": "fresh",
1627 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1628 },
1629 "target": {
1630 "type": "object",
1631 "properties": {
1632 "type": {
1633 "type": "string",
1634 "enum": self.target_types(),
1635 "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."
1636 },
1637 "id": {
1638 "type": "string",
1639 "description": "Configured target id for first-party handoffs or external A2A agents."
1640 },
1641 "external_agent_id": {
1642 "type": "string",
1643 "description": "Configured external A2A agent id."
1644 }
1645 },
1646 "required": ["type"],
1647 "oneOf": self.target_constraint_branches(),
1648 "additionalProperties": false
1649 },
1650 "mode": {
1651 "type": "string",
1652 "enum": ["background", "foreground"],
1653 "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."
1654 },
1655 "blueprint": {
1656 "type": "string",
1657 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1658 },
1659 "config": {
1660 "type": "object",
1661 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1662 },
1663 "result_schema": {
1664 "type": "object",
1665 "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."
1666 },
1667 "message_schema": {
1668 "type": "object",
1669 "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."
1670 },
1671 "public_context": {
1672 "type": "object",
1673 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1674 },
1675 "wait_timeout_secs": {
1676 "type": "integer",
1677 "minimum": 1,
1678 "maximum": 86400,
1679 "description": "External-A2A-only foreground timeout."
1680 },
1681 "wake_on_completion": {
1682 "type": "boolean",
1683 "description": "External-A2A-only control for background completion wake-ups."
1684 }
1685 },
1686 "required": ["name", "instructions", "target"],
1687 "additionalProperties": false
1688 })
1689 }
1690
1691 fn hints(&self) -> crate::tool_types::ToolHints {
1692 let mut hints = crate::tool_types::ToolHints::default()
1693 .with_long_running(true)
1694 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1695 if self.provider_for("external_a2a").is_some() {
1696 hints = hints.with_open_world(true);
1697 }
1698 hints
1699 }
1700
1701 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1702 ToolExecutionResult::tool_error(
1703 "spawn_agent requires context. This tool must be executed with session context.",
1704 )
1705 }
1706
1707 async fn execute_with_context(
1708 &self,
1709 arguments: serde_json::Value,
1710 context: &ToolContext,
1711 ) -> ToolExecutionResult {
1712 let target_type = match arguments
1713 .get("target")
1714 .and_then(|target| target.get("type"))
1715 .and_then(serde_json::Value::as_str)
1716 {
1717 Some(target_type) => target_type,
1718 None => {
1719 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1720 }
1721 };
1722
1723 let Some(provider) = self.provider_for(target_type) else {
1724 let supported = self.target_types().join(", ");
1725 return ToolExecutionResult::tool_error(format!(
1726 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1727 ));
1728 };
1729 if target_type == "external_a2a"
1730 && arguments
1731 .get("lifetime")
1732 .and_then(serde_json::Value::as_str)
1733 .is_some_and(|value| value == "detached")
1734 {
1735 return ToolExecutionResult::tool_error(
1736 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1737 );
1738 }
1739 if target_type == "external_a2a"
1740 && arguments
1741 .get("message_schema")
1742 .is_some_and(|schema| !schema.is_null())
1743 {
1744 return ToolExecutionResult::tool_error(
1745 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1746 );
1747 }
1748
1749 provider.execute_with_context(arguments, context).await
1750 }
1751
1752 fn requires_context(&self) -> bool {
1753 true
1754 }
1755}
1756
1757pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1762 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1763 return base_system_prompt.to_string();
1764 };
1765
1766 if base_system_prompt.is_empty() {
1767 return additions.to_string();
1768 }
1769
1770 if base_system_prompt.contains("<system-prompt>") {
1771 format!("{base_system_prompt}\n\n{additions}")
1772 } else {
1773 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1774 }
1775}
1776
1777pub struct CollectedMessageFilters {
1784 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1786}
1787
1788pub struct CollectedModelViewProviders {
1790 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1792}
1793
1794impl CollectedMessageFilters {
1800 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1802 for (provider, config) in &self.message_filter_providers {
1803 provider.apply_filters(query, config);
1804 }
1805 }
1806
1807 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1809 for (provider, config) in &self.message_filter_providers {
1810 provider.post_load(messages, config);
1811 }
1812 }
1813}
1814
1815impl CollectedModelViewProviders {
1816 pub fn apply_model_view(
1818 &self,
1819 mut messages: Vec<Message>,
1820 context: &ModelViewContext<'_>,
1821 ) -> Vec<Message> {
1822 for (provider, config) in &self.model_view_providers {
1823 messages = provider.apply_model_view(messages, config, context);
1824 }
1825 messages
1826 }
1827}
1828
1829fn compaction_is_enabled(
1835 capability_configs: &[AgentCapabilityConfig],
1836 registry: &CapabilityRegistry,
1837) -> bool {
1838 capability_configs.iter().any(|cap_config| {
1839 registry.get(cap_config.capability_id()).is_some_and(|cap| {
1840 cap.status() == CapabilityStatus::Available
1841 && cap.compaction_policy(cap_config.config_value()).is_some()
1842 })
1843 })
1844}
1845
1846pub fn collect_message_filters_only(
1852 capability_configs: &[AgentCapabilityConfig],
1853 registry: &CapabilityRegistry,
1854) -> CollectedMessageFilters {
1855 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1856 Vec::new();
1857 let compaction_on = compaction_is_enabled(capability_configs, registry);
1858
1859 for cap_config in capability_configs {
1860 let cap_id = cap_config.capability_id();
1861 if let Some(capability) = registry.get(cap_id) {
1862 if capability.status() != CapabilityStatus::Available {
1863 continue;
1864 }
1865 let effective: &dyn Capability = capability
1868 .resolve_for_model(None)
1869 .unwrap_or_else(|| capability.as_ref());
1870 if let Some(provider) = effective.message_filter_provider() {
1871 let config =
1872 effective.message_filter_config(cap_config.config_value(), compaction_on);
1873 message_filter_providers.push((provider, config));
1874 }
1875 }
1876 }
1877
1878 message_filter_providers.sort_by_key(|(p, _)| p.priority());
1879
1880 CollectedMessageFilters {
1881 message_filter_providers,
1882 }
1883}
1884
1885pub fn collect_model_view_providers(
1892 capability_configs: &[AgentCapabilityConfig],
1893 registry: &CapabilityRegistry,
1894 model: Option<&str>,
1895) -> CollectedModelViewProviders {
1896 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1897
1898 for cap_config in capability_configs {
1899 let cap_id = cap_config.capability_id();
1900 if let Some(capability) = registry.get(cap_id) {
1901 if capability.status() != CapabilityStatus::Available {
1902 continue;
1903 }
1904 let effective: &dyn Capability = capability
1905 .resolve_for_model(model)
1906 .unwrap_or_else(|| capability.as_ref());
1907 if let Some(provider) = effective.model_view_provider() {
1908 model_view_providers.push((provider, cap_config.config_value().clone()));
1909 }
1910 }
1911 }
1912
1913 model_view_providers.sort_by_key(|(p, _)| p.priority());
1914
1915 CollectedModelViewProviders {
1916 model_view_providers,
1917 }
1918}
1919
1920pub fn collect_dynamic_facts(
1926 capability_configs: &[AgentCapabilityConfig],
1927 registry: &CapabilityRegistry,
1928 model: Option<&str>,
1929 ctx: &FactsContext,
1930) -> Vec<Fact> {
1931 let mut dynamic = Vec::new();
1932 for cap_config in capability_configs {
1933 let cap_id = cap_config.capability_id();
1934 if let Some(capability) = registry.get(cap_id) {
1935 if capability.status() != CapabilityStatus::Available {
1936 continue;
1937 }
1938 let effective: &dyn Capability = capability
1939 .resolve_for_model(model)
1940 .unwrap_or_else(|| capability.as_ref());
1941 for fact in effective.facts(cap_config.config_value(), ctx) {
1942 if fact.volatility == Volatility::Dynamic {
1943 dynamic.push(fact);
1944 }
1945 }
1946 }
1947 }
1948 dynamic
1949}
1950
1951pub fn collect_capability_mcp_servers(
1952 capability_configs: &[AgentCapabilityConfig],
1953 registry: &CapabilityRegistry,
1954) -> ScopedMcpServers {
1955 let mut servers = ScopedMcpServers::default();
1956
1957 for cap_config in capability_configs {
1958 let cap_id = cap_config.capability_id();
1959 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
1962 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
1963 cap_config.config_value().clone(),
1964 ) {
1965 if definition.status != CapabilityStatus::Available {
1966 continue;
1967 }
1968 if let Some(contributed) = definition.mcp_servers {
1969 servers = merge_scoped_mcp_servers(&servers, &contributed);
1970 }
1971 }
1972 continue;
1973 }
1974 if let Some(capability) = registry.get(cap_id) {
1975 if capability.status() != CapabilityStatus::Available {
1976 continue;
1977 }
1978 servers = merge_scoped_mcp_servers(
1979 &servers,
1980 &capability.mcp_servers_with_config(cap_config.config_value()),
1981 );
1982 }
1983 }
1984
1985 servers
1986}
1987
1988pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
1995
1996#[derive(Debug, Clone, PartialEq, Eq)]
1998pub enum DependencyError {
1999 CircularDependency {
2001 capability_id: String,
2003 chain: Vec<String>,
2005 },
2006 TooManyCapabilities {
2008 count: usize,
2010 max: usize,
2012 },
2013}
2014
2015impl std::fmt::Display for DependencyError {
2016 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2017 match self {
2018 DependencyError::CircularDependency {
2019 capability_id,
2020 chain,
2021 } => {
2022 write!(
2023 f,
2024 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2025 capability_id,
2026 chain.join(" -> "),
2027 capability_id
2028 )
2029 }
2030 DependencyError::TooManyCapabilities { count, max } => {
2031 write!(
2032 f,
2033 "Too many capabilities after resolution: {} (max: {})",
2034 count, max
2035 )
2036 }
2037 }
2038 }
2039}
2040
2041impl std::error::Error for DependencyError {}
2042
2043#[derive(Debug, Clone)]
2045pub struct ResolvedCapabilities {
2046 pub resolved_ids: Vec<String>,
2049 pub added_as_dependencies: Vec<String>,
2051 pub user_selected: Vec<String>,
2053}
2054
2055pub fn resolve_dependencies(
2075 selected_ids: &[String],
2076 registry: &CapabilityRegistry,
2077) -> Result<ResolvedCapabilities, DependencyError> {
2078 use std::collections::HashSet;
2079
2080 let user_selected: HashSet<String> = selected_ids
2082 .iter()
2083 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2084 .collect();
2085 let mut resolved: Vec<String> = Vec::new();
2086 let mut resolved_set: HashSet<String> = HashSet::new();
2087 let mut added_as_dependencies: Vec<String> = Vec::new();
2088
2089 for cap_id in selected_ids {
2091 resolve_single_capability(
2092 cap_id,
2093 registry,
2094 &mut resolved,
2095 &mut resolved_set,
2096 &mut added_as_dependencies,
2097 &user_selected,
2098 &mut Vec::new(), )?;
2100 }
2101
2102 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2104 return Err(DependencyError::TooManyCapabilities {
2105 count: resolved.len(),
2106 max: MAX_RESOLVED_CAPABILITIES,
2107 });
2108 }
2109
2110 Ok(ResolvedCapabilities {
2111 resolved_ids: resolved,
2112 added_as_dependencies,
2113 user_selected: selected_ids.to_vec(),
2114 })
2115}
2116
2117pub fn resolve_capability_configs(
2122 selected_configs: &[AgentCapabilityConfig],
2123 registry: &CapabilityRegistry,
2124) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2125 let mut selected_ids: Vec<String> = Vec::new();
2126 for config in selected_configs {
2127 if (is_declarative_capability(config.capability_id())
2130 || is_plugin_capability(config.capability_id()))
2131 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2132 config.config_value().clone(),
2133 )
2134 {
2135 selected_ids.extend(definition.dependencies);
2136 }
2137 selected_ids.push(config.capability_id().to_string());
2138 }
2139 let resolved = resolve_dependencies(&selected_ids, registry)?;
2140
2141 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2144 .iter()
2145 .map(|config| {
2146 let id = config.capability_id();
2147 let id = registry.canonical_id(id).unwrap_or(id);
2148 (id.to_string(), config.config_value().clone())
2149 })
2150 .collect();
2151
2152 Ok(resolved
2153 .resolved_ids
2154 .into_iter()
2155 .map(|capability_id| {
2156 explicit_configs
2157 .get(&capability_id)
2158 .cloned()
2159 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2160 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2161 })
2162 .collect())
2163}
2164
2165fn resolve_single_capability(
2167 cap_id: &str,
2168 registry: &CapabilityRegistry,
2169 resolved: &mut Vec<String>,
2170 resolved_set: &mut std::collections::HashSet<String>,
2171 added_as_dependencies: &mut Vec<String>,
2172 user_selected: &std::collections::HashSet<String>,
2173 visiting: &mut Vec<String>,
2174) -> Result<(), DependencyError> {
2175 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2179
2180 if resolved_set.contains(cap_id) {
2182 return Ok(());
2183 }
2184
2185 if visiting.contains(&cap_id.to_string()) {
2187 return Err(DependencyError::CircularDependency {
2188 capability_id: cap_id.to_string(),
2189 chain: visiting.clone(),
2190 });
2191 }
2192
2193 let capability = match registry.get(cap_id) {
2195 Some(cap) => cap,
2196 None => {
2197 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2201 && !resolved_set.contains(cap_id)
2202 {
2203 resolved.push(cap_id.to_string());
2204 resolved_set.insert(cap_id.to_string());
2205 if !user_selected.contains(cap_id) {
2206 added_as_dependencies.push(cap_id.to_string());
2207 }
2208 }
2209 return Ok(());
2210 }
2211 };
2212
2213 visiting.push(cap_id.to_string());
2215
2216 for dep_id in capability.dependencies() {
2218 resolve_single_capability(
2219 dep_id,
2220 registry,
2221 resolved,
2222 resolved_set,
2223 added_as_dependencies,
2224 user_selected,
2225 visiting,
2226 )?;
2227 }
2228
2229 visiting.pop();
2231
2232 if !resolved_set.contains(cap_id) {
2234 resolved.push(cap_id.to_string());
2235 resolved_set.insert(cap_id.to_string());
2236
2237 if !user_selected.contains(cap_id) {
2239 added_as_dependencies.push(cap_id.to_string());
2240 }
2241 }
2242
2243 Ok(())
2244}
2245
2246pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2251 use std::collections::HashSet;
2252
2253 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2254 Ok(resolved) => resolved.resolved_ids,
2255 Err(_) => capability_ids.to_vec(),
2256 };
2257
2258 let mut seen = HashSet::new();
2259 let mut features = Vec::new();
2260 for cap_id in &resolved_ids {
2261 if let Some(cap) = registry.get(cap_id) {
2262 for feature in cap.features() {
2263 if seen.insert(feature) {
2264 features.push(feature.to_string());
2265 }
2266 }
2267 }
2268 }
2269 features
2270}
2271
2272pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2275 registry
2276 .get(cap_id)
2277 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2278 .unwrap_or_default()
2279}
2280
2281pub async fn collect_capabilities(
2297 capability_ids: &[String],
2298 registry: &CapabilityRegistry,
2299 ctx: &SystemPromptContext,
2300) -> CollectedCapabilities {
2301 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2304 Ok(resolved) => resolved.resolved_ids,
2305 Err(e) => {
2306 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2307 capability_ids.to_vec()
2308 }
2309 };
2310
2311 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2313 .iter()
2314 .map(|id| {
2315 AgentCapabilityConfig::with_config(
2316 CapabilityId::new(id),
2317 serde_json::Value::Object(serde_json::Map::new()),
2318 )
2319 })
2320 .collect();
2321
2322 collect_capabilities_with_configs(&configs, registry, ctx).await
2323}
2324
2325pub async fn collect_capabilities_with_configs(
2336 capability_configs: &[AgentCapabilityConfig],
2337 registry: &CapabilityRegistry,
2338 ctx: &SystemPromptContext,
2339) -> CollectedCapabilities {
2340 let mut system_prompt_parts: Vec<String> = Vec::new();
2341 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2342 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2343 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2344 let mut mounts: Vec<MountPoint> = Vec::new();
2345 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2346 Vec::new();
2347 let mut applied_ids: Vec<String> = Vec::new();
2348 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2349 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2350 let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2351 let mut parallel_tool_calls: Option<bool> = None;
2352 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2353 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2354 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2357 let mut mcp_servers = ScopedMcpServers::default();
2358 let mut static_facts: Vec<Fact> = Vec::new();
2362 let mut has_dynamic_facts = false;
2363 let facts_ctx = FactsContext::new(ctx.session_id);
2364 let compaction_on = compaction_is_enabled(capability_configs, registry);
2365 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2366
2367 for cap_config in capability_configs {
2368 let cap_id = cap_config.capability_id();
2369 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2374 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2375 cap_config.config_value().clone(),
2376 ) {
2377 Ok(definition) => {
2378 if definition.status != CapabilityStatus::Available {
2379 continue;
2380 }
2381
2382 if let Some(prompt) = definition.system_prompt.as_deref() {
2383 let contribution =
2384 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2385 system_prompt_attributions.push(SystemPromptAttribution {
2386 capability_id: cap_id.to_string(),
2387 content: contribution.clone(),
2388 });
2389 system_prompt_parts.push(contribution);
2390 }
2391
2392 mounts.extend(definition.mounts(cap_id));
2393 if let Some(ref servers) = definition.mcp_servers {
2394 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2395 }
2396 for skill in definition.skill_contributions() {
2397 mounts.push(skill.to_mount(cap_id));
2398 }
2399
2400 applied_ids.push(cap_id.to_string());
2401 }
2402 Err(error) => {
2403 tracing::warn!(
2404 capability_id = %cap_id,
2405 error = %error,
2406 "Skipping invalid declarative/plugin capability config"
2407 );
2408 }
2409 }
2410 continue;
2411 }
2412 if let Some(capability) = registry.get(cap_id) {
2413 if capability.status() != CapabilityStatus::Available {
2415 continue;
2416 }
2417
2418 let effective: &dyn Capability =
2430 match capability.resolve_for_model(ctx.model.as_deref()) {
2431 Some(inner) => inner,
2432 None => capability.as_ref(),
2433 };
2434 let delegation_target =
2435 effective.delegation_target_with_config(cap_config.config_value());
2436
2437 if let Some(contribution) = effective
2439 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2440 .await
2441 {
2442 system_prompt_attributions.push(SystemPromptAttribution {
2443 capability_id: cap_id.to_string(),
2444 content: contribution.clone(),
2445 });
2446 system_prompt_parts.push(contribution);
2447 }
2448
2449 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2454 match fact.volatility {
2455 Volatility::Static => static_facts.push(fact),
2456 Volatility::Dynamic => has_dynamic_facts = true,
2457 }
2458 }
2459
2460 tools.extend(effective.tools_with_config(cap_config.config_value()));
2462 if let Some(target) = delegation_target {
2463 delegation_targets.push(target);
2464 }
2465 tool_definition_hooks.extend(
2466 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2467 );
2468 tool_call_hooks.extend(effective.tool_call_hooks());
2469 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2471 let cap_category = effective.category();
2476 for def in effective.tool_definitions() {
2477 let def = match (def.category(), cap_category) {
2478 (None, Some(cat)) => def.with_category(cat),
2479 _ => def,
2480 }
2481 .with_capability_attribution(cap_id, Some(capability.name()));
2482 tool_definitions.push(def);
2483 }
2484
2485 tool_search = effective
2486 .tool_search_config(cap_config.config_value())
2487 .or(tool_search);
2488 prompt_cache = effective
2489 .prompt_cache_config(cap_config.config_value())
2490 .or(prompt_cache);
2491 parallel_tool_calls = effective
2492 .parallel_tool_calls_preference(cap_config.config_value())
2493 .or(parallel_tool_calls);
2494
2495 openrouter_routing = effective
2496 .openrouter_routing_config(cap_config.config_value())
2497 .or(openrouter_routing);
2498
2499 mounts.extend(effective.mounts());
2501
2502 mcp_servers = merge_scoped_mcp_servers(
2503 &mcp_servers,
2504 &effective.mcp_servers_with_config(cap_config.config_value()),
2505 );
2506
2507 for skill in effective.contribute_skills() {
2511 mounts.push(skill.to_mount(cap_id));
2512 }
2513
2514 if let Some(provider) = effective.message_filter_provider() {
2516 let config =
2517 effective.message_filter_config(cap_config.config_value(), compaction_on);
2518 message_filter_providers.push((provider, config));
2519 }
2520
2521 applied_ids.push(cap_id.to_string());
2522 }
2523 }
2524
2525 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2528 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2529 let def = tool
2530 .to_definition()
2531 .with_category("Orchestration")
2532 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2533 tools.push(Box::new(tool));
2534 tool_definitions.push(def);
2535 }
2536
2537 let auto_activated: Vec<_> = registry
2540 .list()
2541 .into_iter()
2542 .filter(|cap| {
2543 !applied_ids.iter().any(|id| id == cap.id())
2544 && cap.status() == CapabilityStatus::Available
2545 && cap.auto_activates_for(&tool_definitions)
2546 })
2547 .cloned()
2548 .collect();
2549 for cap in auto_activated {
2550 tools.extend(cap.tools());
2551 let cap_category = cap.category();
2552 for def in cap.tool_definitions() {
2553 let def = match (def.category(), cap_category) {
2554 (None, Some(cat)) => def.with_category(cat),
2555 _ => def,
2556 }
2557 .with_capability_attribution(cap.id(), Some(cap.name()));
2558 tool_definitions.push(def);
2559 }
2560 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2561 applied_ids.push(cap.id().to_string());
2562 }
2563
2564 if let Some(block) = facts::render_facts_block(&static_facts) {
2569 system_prompt_attributions.push(SystemPromptAttribution {
2570 capability_id: "facts".to_string(),
2571 content: block.clone(),
2572 });
2573 system_prompt_parts.push(block);
2574 }
2575 if has_dynamic_facts {
2576 system_prompt_attributions.push(SystemPromptAttribution {
2577 capability_id: "facts".to_string(),
2578 content: FACTS_DYNAMIC_NOTE.to_string(),
2579 });
2580 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2581 }
2582
2583 tool_call_hooks.extend(narration_hooks);
2587
2588 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2590
2591 CollectedCapabilities {
2592 system_prompt_parts,
2593 system_prompt_attributions,
2594 tools,
2595 tool_definitions,
2596 mounts,
2597 message_filter_providers,
2598 applied_ids,
2599 tool_search,
2600 prompt_cache,
2601 openrouter_routing,
2602 parallel_tool_calls,
2603 tool_definition_hooks,
2604 tool_call_hooks,
2605 mcp_servers,
2606 }
2607}
2608
2609pub struct AppliedCapabilities {
2615 pub runtime_agent: RuntimeAgent,
2617 pub tool_registry: ToolRegistry,
2619 pub applied_ids: Vec<String>,
2621}
2622
2623pub async fn apply_capabilities(
2659 base_runtime_agent: RuntimeAgent,
2660 capability_ids: &[String],
2661 registry: &CapabilityRegistry,
2662 ctx: &SystemPromptContext,
2663) -> AppliedCapabilities {
2664 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2665
2666 let final_system_prompt = compose_system_prompt(
2668 &base_runtime_agent.system_prompt,
2669 collected.system_prompt_prefix().as_deref(),
2670 );
2671
2672 let mut tool_registry = ToolRegistry::new();
2674 for tool in collected.tools {
2675 tool_registry.register_boxed(tool);
2676 }
2677
2678 let mut tools = collected.tool_definitions;
2680 for hook in &collected.tool_definition_hooks {
2681 tools = hook.transform(tools);
2682 }
2683
2684 let runtime_agent = RuntimeAgent {
2685 system_prompt: final_system_prompt,
2686 model: base_runtime_agent.model,
2687 tools,
2688 max_iterations: base_runtime_agent.max_iterations,
2689 temperature: base_runtime_agent.temperature,
2690 max_tokens: base_runtime_agent.max_tokens,
2691 tool_search: collected.tool_search,
2692 prompt_cache: collected.prompt_cache,
2693 openrouter_routing: collected.openrouter_routing,
2694 network_access: base_runtime_agent.network_access,
2695 parallel_tool_calls: base_runtime_agent
2698 .parallel_tool_calls
2699 .or(collected.parallel_tool_calls),
2700 };
2701
2702 AppliedCapabilities {
2703 runtime_agent,
2704 tool_registry,
2705 applied_ids: collected.applied_ids,
2706 }
2707}
2708
2709#[cfg(test)]
2714mod tests {
2715 use super::*;
2716 use crate::typed_id::SessionId;
2717 use uuid::Uuid;
2718
2719 fn test_ctx() -> SystemPromptContext {
2721 SystemPromptContext::without_file_store(SessionId::new())
2722 }
2723
2724 struct NoopFixture;
2734
2735 impl Capability for NoopFixture {
2736 fn id(&self) -> &str {
2737 "noop"
2738 }
2739 fn name(&self) -> &str {
2740 "No-Op"
2741 }
2742 fn description(&self) -> &str {
2743 "Contributes nothing."
2744 }
2745 }
2746
2747 struct FeatureFixture;
2749
2750 impl Capability for FeatureFixture {
2751 fn id(&self) -> &str {
2752 "feature_fixture"
2753 }
2754 fn name(&self) -> &str {
2755 "Feature Fixture"
2756 }
2757 fn description(&self) -> &str {
2758 "Declares one test-only feature."
2759 }
2760 fn features(&self) -> Vec<&'static str> {
2761 vec!["fixture_feature"]
2762 }
2763 }
2764
2765 struct FixtureTool(&'static str);
2766
2767 #[async_trait]
2768 impl Tool for FixtureTool {
2769 fn name(&self) -> &str {
2770 self.0
2771 }
2772 fn description(&self) -> &str {
2773 "Fixture tool."
2774 }
2775 fn parameters_schema(&self) -> serde_json::Value {
2776 serde_json::json!({
2777 "type": "object",
2778 "properties": {},
2779 "additionalProperties": false
2780 })
2781 }
2782 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2783 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2784 }
2785 }
2786
2787 struct BackgroundFixtureTool;
2788
2789 #[async_trait]
2790 impl Tool for BackgroundFixtureTool {
2791 fn name(&self) -> &str {
2792 "bash"
2793 }
2794 fn description(&self) -> &str {
2795 "Fixture background-capable shell tool."
2796 }
2797 fn parameters_schema(&self) -> serde_json::Value {
2798 serde_json::json!({"type": "object"})
2799 }
2800 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2801 ToolExecutionResult::success(serde_json::json!({"ok": true}))
2802 }
2803 fn hints(&self) -> crate::tool_types::ToolHints {
2804 crate::tool_types::ToolHints {
2805 supports_background: Some(true),
2806 ..Default::default()
2807 }
2808 }
2809 }
2810
2811 struct FileSystemFixture;
2812
2813 impl Capability for FileSystemFixture {
2814 fn id(&self) -> &str {
2815 "session_file_system"
2816 }
2817 fn name(&self) -> &str {
2818 "Fixture Filesystem"
2819 }
2820 fn description(&self) -> &str {
2821 "Fixture filesystem capability."
2822 }
2823 fn tools(&self) -> Vec<Box<dyn Tool>> {
2824 vec![
2825 Box::new(FixtureTool("read_file")),
2826 Box::new(FixtureTool("write_file")),
2827 ]
2828 }
2829 fn features(&self) -> Vec<&'static str> {
2830 vec!["file_system"]
2831 }
2832 }
2833
2834 struct StorageFixture;
2839
2840 impl Capability for StorageFixture {
2841 fn id(&self) -> &str {
2842 "session_storage"
2843 }
2844 fn name(&self) -> &str {
2845 "Fixture Storage"
2846 }
2847 fn description(&self) -> &str {
2848 "Fixture session storage capability."
2849 }
2850 fn features(&self) -> Vec<&'static str> {
2851 vec!["secrets", "key_value"]
2852 }
2853 }
2854
2855 struct BashFixture;
2856
2857 impl Capability for BashFixture {
2858 fn id(&self) -> &str {
2859 "bashkit_shell"
2860 }
2861 fn aliases(&self) -> Vec<&'static str> {
2862 vec!["virtual_bash"]
2863 }
2864 fn name(&self) -> &str {
2865 "Fixture Bash"
2866 }
2867 fn description(&self) -> &str {
2868 "Fixture shell capability."
2869 }
2870 fn tools(&self) -> Vec<Box<dyn Tool>> {
2871 vec![Box::new(BackgroundFixtureTool)]
2872 }
2873 fn dependencies(&self) -> Vec<&'static str> {
2874 vec!["session_file_system"]
2875 }
2876 fn features(&self) -> Vec<&'static str> {
2877 vec!["file_system"]
2878 }
2879 fn risk_level(&self) -> RiskLevel {
2880 RiskLevel::High
2881 }
2882 }
2883
2884 struct WebFetchFixture;
2885
2886 impl Capability for WebFetchFixture {
2887 fn id(&self) -> &str {
2888 "web_fetch"
2889 }
2890 fn name(&self) -> &str {
2891 "Fixture Web Fetch"
2892 }
2893 fn description(&self) -> &str {
2894 "Fixture web capability."
2895 }
2896 fn risk_level(&self) -> RiskLevel {
2897 RiskLevel::High
2898 }
2899 }
2900
2901 struct DynamicFactFixture;
2904
2905 impl Capability for DynamicFactFixture {
2906 fn id(&self) -> &str {
2907 "current_time"
2908 }
2909 fn name(&self) -> &str {
2910 "Dynamic Fact Fixture"
2911 }
2912 fn description(&self) -> &str {
2913 "Fixture with one dynamic fact and one tool."
2914 }
2915 fn icon(&self) -> Option<&str> {
2916 Some("clock")
2917 }
2918 fn category(&self) -> Option<&str> {
2919 Some("Core")
2920 }
2921 fn tools(&self) -> Vec<Box<dyn Tool>> {
2922 vec![Box::new(FixtureTool("get_current_time"))]
2923 }
2924 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
2925 vec![Fact::dynamic("current_time", "fixture-now")]
2926 }
2927 }
2928
2929 struct PromptToolFixture;
2930
2931 impl Capability for PromptToolFixture {
2932 fn id(&self) -> &str {
2933 "prompt_tool_fixture"
2934 }
2935 fn name(&self) -> &str {
2936 "Prompt Tool Fixture"
2937 }
2938 fn description(&self) -> &str {
2939 "Fixture with a static prompt and tool."
2940 }
2941 fn system_prompt_addition(&self) -> Option<&str> {
2942 Some("Task Management uses the write_todos tool.")
2943 }
2944 fn tools(&self) -> Vec<Box<dyn Tool>> {
2945 vec![Box::new(FixtureTool("write_todos"))]
2946 }
2947 }
2948
2949 struct SecondPromptFixture;
2950
2951 impl Capability for SecondPromptFixture {
2952 fn id(&self) -> &str {
2953 "second_prompt_fixture"
2954 }
2955 fn name(&self) -> &str {
2956 "Second Prompt Fixture"
2957 }
2958 fn description(&self) -> &str {
2959 "Fixture with a second static prompt."
2960 }
2961 fn system_prompt_addition(&self) -> Option<&str> {
2962 Some("A second capability prompt contribution.")
2963 }
2964 }
2965
2966 struct DynamicPreviewFixture;
2967
2968 impl Capability for DynamicPreviewFixture {
2969 fn id(&self) -> &str {
2970 "agent_instructions"
2971 }
2972 fn name(&self) -> &str {
2973 "Dynamic Preview Fixture"
2974 }
2975 fn description(&self) -> &str {
2976 "Fixture whose runtime prompt is dynamic."
2977 }
2978 fn system_prompt_preview(&self) -> Option<String> {
2979 Some("Reads AGENTS.md dynamically.".to_string())
2980 }
2981 }
2982
2983 struct MathFixture;
2985
2986 impl Capability for MathFixture {
2987 fn id(&self) -> &str {
2988 "test_math"
2989 }
2990 fn name(&self) -> &str {
2991 "Test Math"
2992 }
2993 fn description(&self) -> &str {
2994 "Fixture: calculator tools."
2995 }
2996 fn tools(&self) -> Vec<Box<dyn Tool>> {
2997 vec![
2998 Box::new(FixtureTool("add")),
2999 Box::new(FixtureTool("subtract")),
3000 Box::new(FixtureTool("multiply")),
3001 Box::new(FixtureTool("divide")),
3002 ]
3003 }
3004 }
3005
3006 struct WeatherFixture;
3008
3009 impl Capability for WeatherFixture {
3010 fn id(&self) -> &str {
3011 "test_weather"
3012 }
3013 fn name(&self) -> &str {
3014 "Test Weather"
3015 }
3016 fn description(&self) -> &str {
3017 "Fixture: weather tools."
3018 }
3019 fn tools(&self) -> Vec<Box<dyn Tool>> {
3020 vec![
3021 Box::new(FixtureTool("get_weather")),
3022 Box::new(FixtureTool("get_forecast")),
3023 ]
3024 }
3025 }
3026
3027 struct SampleDataFixture;
3030
3031 impl Capability for SampleDataFixture {
3032 fn id(&self) -> &str {
3033 "sample_data"
3034 }
3035 fn name(&self) -> &str {
3036 "Sample Data"
3037 }
3038 fn description(&self) -> &str {
3039 "Fixture: mounted sample files."
3040 }
3041 fn system_prompt_addition(&self) -> Option<&str> {
3042 Some("Read-only sample files are mounted at `/samples`.")
3043 }
3044 fn mounts(&self) -> Vec<MountPoint> {
3045 let samples_dir = MountDirectoryBuilder::new()
3046 .file("users.json", "[]")
3047 .build();
3048 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3049 }
3050 fn dependencies(&self) -> Vec<&'static str> {
3051 vec!["session_file_system"]
3052 }
3053 fn features(&self) -> Vec<&'static str> {
3054 vec!["file_system"]
3055 }
3056 }
3057
3058 fn fixture_registry() -> CapabilityRegistry {
3060 let mut registry = CapabilityRegistry::new();
3061 registry.register(NoopFixture);
3062 registry.register(FeatureFixture);
3063 registry.register(MathFixture);
3064 registry.register(WeatherFixture);
3065 registry.register(SampleDataFixture);
3066 registry.register(FileSystemFixture);
3067 registry.register(StorageFixture);
3068 registry.register(BashFixture);
3069 registry.register(WebFetchFixture);
3070 registry.register(DynamicFactFixture);
3071 registry.register(PromptToolFixture);
3072 registry.register(SecondPromptFixture);
3073 registry.register(DynamicPreviewFixture);
3074 registry
3075 }
3076
3077 struct HostAnnotatedCapability;
3079
3080 #[async_trait]
3081 impl Capability for HostAnnotatedCapability {
3082 fn id(&self) -> &str {
3083 "host_annotated"
3084 }
3085 fn name(&self) -> &str {
3086 "Host Annotated"
3087 }
3088 fn description(&self) -> &str {
3089 "Test capability with host-owned metadata."
3090 }
3091 fn metadata(&self) -> Option<serde_json::Value> {
3092 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3093 }
3094 }
3095
3096 #[test]
3097 fn capability_metadata_is_an_opt_in_host_hatch() {
3098 let metadata = HostAnnotatedCapability.metadata().expect("metadata");
3099 assert_eq!(metadata["icon"], "sparkles");
3100 assert_eq!(metadata["group"], "host");
3101 }
3102
3103 #[test]
3104 fn test_capability_registry_get() {
3105 let mut registry = CapabilityRegistry::new();
3106 registry.register(NoopFixture);
3107
3108 let capability = registry.get("noop").unwrap();
3109 assert_eq!(capability.id(), "noop");
3110 assert_eq!(capability.status(), CapabilityStatus::Available);
3111 }
3112
3113 #[test]
3114 fn default_registry_is_empty_and_selects_no_product_preset() {
3115 assert!(CapabilityRegistry::default().is_empty());
3116 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3117 }
3118
3119 #[test]
3120 fn test_capability_registry_blueprint_with_capability() {
3121 struct BlueprintProviderCapability;
3122
3123 impl Capability for BlueprintProviderCapability {
3124 fn id(&self) -> &str {
3125 "blueprint_provider"
3126 }
3127 fn name(&self) -> &str {
3128 "Blueprint Provider"
3129 }
3130 fn description(&self) -> &str {
3131 "Capability that provides a blueprint for tests"
3132 }
3133 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3134 vec![AgentBlueprint {
3135 id: "test_blueprint",
3136 name: "Test Blueprint",
3137 description: "Blueprint for capability registry tests",
3138 model: BlueprintModel::Inherit,
3139 system_prompt: "Test prompt",
3140 tools: vec![],
3141 max_turns: None,
3142 config_schema: None,
3143 }]
3144 }
3145 }
3146
3147 let mut registry = CapabilityRegistry::new();
3148 registry.register(BlueprintProviderCapability);
3149
3150 let (capability_id, blueprint) = registry
3151 .blueprint_with_capability("test_blueprint")
3152 .expect("blueprint should resolve with capability id");
3153 assert_eq!(capability_id, "blueprint_provider");
3154 assert_eq!(blueprint.id, "test_blueprint");
3155 }
3156
3157 #[test]
3158 fn test_capability_registry_builder() {
3159 let registry = CapabilityRegistry::builder()
3160 .capability(NoopFixture)
3161 .build();
3162
3163 assert!(registry.has("noop"));
3164 assert_eq!(registry.len(), 1);
3165 }
3166
3167 #[test]
3168 fn test_capability_status() {
3169 struct ComingSoonFixture;
3170 impl Capability for ComingSoonFixture {
3171 fn id(&self) -> &str {
3172 "coming_soon_fixture"
3173 }
3174 fn name(&self) -> &str {
3175 "Coming Soon Fixture"
3176 }
3177 fn description(&self) -> &str {
3178 "Test-only capability."
3179 }
3180 fn status(&self) -> CapabilityStatus {
3181 CapabilityStatus::ComingSoon
3182 }
3183 }
3184 assert_eq!(ComingSoonFixture.status(), CapabilityStatus::ComingSoon);
3185 }
3186
3187 #[test]
3188 fn test_capability_icons_and_categories_default_none() {
3189 assert!(NoopFixture.icon().is_none());
3190 assert!(NoopFixture.category().is_none());
3191 }
3192
3193 #[test]
3194 fn test_system_prompt_preview_default_delegates_to_addition() {
3195 struct StaticPromptCapability;
3198 impl Capability for StaticPromptCapability {
3199 fn id(&self) -> &str {
3200 "static_prompt"
3201 }
3202 fn name(&self) -> &str {
3203 "Static Prompt"
3204 }
3205 fn description(&self) -> &str {
3206 "Static prompt addition."
3207 }
3208 fn system_prompt_addition(&self) -> Option<&str> {
3209 Some("Use the static prompt.")
3210 }
3211 }
3212
3213 let cap = StaticPromptCapability;
3214 assert_eq!(
3215 cap.system_prompt_preview().as_deref(),
3216 cap.system_prompt_addition()
3217 );
3218
3219 let registry = fixture_registry();
3221 let current_time = registry.get("current_time").unwrap();
3222 assert!(current_time.system_prompt_preview().is_none());
3223 assert!(current_time.system_prompt_addition().is_none());
3224 }
3225
3226 #[test]
3227 fn test_system_prompt_preview_dynamic_capability() {
3228 let registry = fixture_registry();
3229 let cap = registry.get("agent_instructions").unwrap();
3230
3231 assert!(cap.system_prompt_addition().is_none());
3233 assert!(cap.system_prompt_preview().is_some());
3234 assert!(cap.system_prompt_preview().unwrap().contains("AGENTS.md"));
3235 }
3236
3237 #[tokio::test]
3242 async fn test_apply_capabilities_empty() {
3243 let registry = CapabilityRegistry::new();
3244 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3245
3246 let applied =
3247 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3248
3249 assert_eq!(
3250 applied.runtime_agent.system_prompt,
3251 base_runtime_agent.system_prompt
3252 );
3253 assert!(applied.tool_registry.is_empty());
3254 assert!(applied.applied_ids.is_empty());
3255 }
3256
3257 #[tokio::test]
3258 async fn test_apply_capabilities_noop() {
3259 let registry = fixture_registry();
3260 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3261
3262 let applied = apply_capabilities(
3263 base_runtime_agent.clone(),
3264 &["noop".to_string()],
3265 ®istry,
3266 &test_ctx(),
3267 )
3268 .await;
3269
3270 assert_eq!(
3272 applied.runtime_agent.system_prompt,
3273 base_runtime_agent.system_prompt
3274 );
3275 assert!(applied.tool_registry.is_empty());
3276 assert_eq!(applied.applied_ids, vec!["noop"]);
3277 }
3278
3279 #[tokio::test]
3280 async fn test_apply_capabilities_current_time() {
3281 let registry = fixture_registry();
3282 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3283
3284 let applied = apply_capabilities(
3285 base_runtime_agent.clone(),
3286 &["current_time".to_string()],
3287 ®istry,
3288 &test_ctx(),
3289 )
3290 .await;
3291
3292 assert!(
3296 applied
3297 .runtime_agent
3298 .system_prompt
3299 .contains(FACTS_DYNAMIC_NOTE),
3300 "current_time should contribute the dynamic-facts note"
3301 );
3302 assert!(
3303 applied
3304 .runtime_agent
3305 .system_prompt
3306 .contains(&base_runtime_agent.system_prompt),
3307 "base prompt is preserved"
3308 );
3309 assert!(applied.tool_registry.has("get_current_time"));
3310 assert_eq!(applied.tool_registry.len(), 1);
3311 assert_eq!(applied.applied_ids, vec!["current_time"]);
3312 }
3313
3314 #[tokio::test]
3315 async fn test_apply_capabilities_skips_coming_soon() {
3316 struct ComingSoonFixture;
3317 impl Capability for ComingSoonFixture {
3318 fn id(&self) -> &str {
3319 "coming_soon_fixture"
3320 }
3321 fn name(&self) -> &str {
3322 "Coming Soon Fixture"
3323 }
3324 fn description(&self) -> &str {
3325 "Test-only capability."
3326 }
3327 fn status(&self) -> CapabilityStatus {
3328 CapabilityStatus::ComingSoon
3329 }
3330 fn system_prompt_addition(&self) -> Option<&str> {
3331 Some("Not yet available.")
3332 }
3333 }
3334 let mut registry = CapabilityRegistry::new();
3335 registry.register(ComingSoonFixture);
3336 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3337
3338 let applied = apply_capabilities(
3339 base_runtime_agent.clone(),
3340 &["coming_soon_fixture".to_string()],
3341 ®istry,
3342 &test_ctx(),
3343 )
3344 .await;
3345
3346 assert_eq!(
3347 applied.runtime_agent.system_prompt,
3348 base_runtime_agent.system_prompt
3349 );
3350 assert!(applied.applied_ids.is_empty());
3351 }
3352
3353 #[tokio::test]
3354 async fn test_apply_capabilities_multiple() {
3355 let registry = fixture_registry();
3356 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3357
3358 let applied = apply_capabilities(
3359 base_runtime_agent.clone(),
3360 &["noop".to_string(), "current_time".to_string()],
3361 ®istry,
3362 &test_ctx(),
3363 )
3364 .await;
3365
3366 assert!(applied.tool_registry.has("get_current_time"));
3367 assert_eq!(applied.applied_ids, vec!["noop", "current_time"]);
3368 }
3369
3370 #[tokio::test]
3371 async fn test_apply_capabilities_preserves_order() {
3372 let registry = fixture_registry();
3373 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3374
3375 let applied = apply_capabilities(
3377 base_runtime_agent,
3378 &["current_time".to_string(), "noop".to_string()],
3379 ®istry,
3380 &test_ctx(),
3381 )
3382 .await;
3383
3384 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3385 }
3386
3387 #[tokio::test]
3388 async fn test_apply_capabilities_test_math() {
3389 let registry = fixture_registry();
3390 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3391
3392 let applied = apply_capabilities(
3393 base_runtime_agent.clone(),
3394 &["test_math".to_string()],
3395 ®istry,
3396 &test_ctx(),
3397 )
3398 .await;
3399
3400 assert!(
3402 !applied
3403 .runtime_agent
3404 .system_prompt
3405 .contains("<capability id=\"test_math\">")
3406 );
3407 assert!(
3409 applied
3410 .runtime_agent
3411 .system_prompt
3412 .contains("You are a helpful assistant.")
3413 );
3414 assert!(applied.tool_registry.has("add"));
3415 assert!(applied.tool_registry.has("subtract"));
3416 assert!(applied.tool_registry.has("multiply"));
3417 assert!(applied.tool_registry.has("divide"));
3418 assert_eq!(applied.tool_registry.len(), 4);
3419 }
3420
3421 #[tokio::test]
3422 async fn test_apply_capabilities_test_weather() {
3423 let registry = fixture_registry();
3424 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3425
3426 let applied = apply_capabilities(
3427 base_runtime_agent.clone(),
3428 &["test_weather".to_string()],
3429 ®istry,
3430 &test_ctx(),
3431 )
3432 .await;
3433
3434 assert!(
3436 !applied
3437 .runtime_agent
3438 .system_prompt
3439 .contains("<capability id=\"test_weather\">")
3440 );
3441 assert!(applied.tool_registry.has("get_weather"));
3442 assert!(applied.tool_registry.has("get_forecast"));
3443 assert_eq!(applied.tool_registry.len(), 2);
3444 }
3445
3446 #[tokio::test]
3447 async fn test_apply_capabilities_test_math_and_test_weather() {
3448 let registry = fixture_registry();
3449 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3450
3451 let applied = apply_capabilities(
3452 base_runtime_agent.clone(),
3453 &["test_math".to_string(), "test_weather".to_string()],
3454 ®istry,
3455 &test_ctx(),
3456 )
3457 .await;
3458
3459 assert_eq!(applied.tool_registry.len(), 6); assert!(applied.tool_registry.has("add"));
3462 assert!(applied.tool_registry.has("get_weather"));
3463 }
3464
3465 #[tokio::test]
3466 async fn test_apply_capabilities_prompt_tool_fixture() {
3467 let registry = fixture_registry();
3468 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3469
3470 let applied = apply_capabilities(
3471 base_runtime_agent.clone(),
3472 &["prompt_tool_fixture".to_string()],
3473 ®istry,
3474 &test_ctx(),
3475 )
3476 .await;
3477
3478 assert!(
3480 applied
3481 .runtime_agent
3482 .system_prompt
3483 .contains("Task Management")
3484 );
3485 assert!(applied.runtime_agent.system_prompt.contains("write_todos"));
3486 assert!(applied.tool_registry.has("write_todos"));
3487 assert_eq!(applied.tool_registry.len(), 1);
3488 }
3489
3490 #[tokio::test]
3495 async fn test_xml_tags_wrap_capability_prompts() {
3496 let registry = fixture_registry();
3497 let collected =
3498 collect_capabilities(&["prompt_tool_fixture".to_string()], ®istry, &test_ctx())
3499 .await;
3500
3501 assert_eq!(collected.system_prompt_parts.len(), 1);
3502 let part = &collected.system_prompt_parts[0];
3503 assert!(part.starts_with("<capability id=\"prompt_tool_fixture\">"));
3504 assert!(part.ends_with("</capability>"));
3505 assert!(part.contains("Task Management"));
3506 }
3507
3508 #[tokio::test]
3509 async fn test_xml_tags_multiple_capabilities() {
3510 let registry = fixture_registry();
3511 let collected = collect_capabilities(
3512 &[
3513 "prompt_tool_fixture".to_string(),
3514 "second_prompt_fixture".to_string(),
3515 ],
3516 ®istry,
3517 &test_ctx(),
3518 )
3519 .await;
3520
3521 assert_eq!(collected.system_prompt_parts.len(), 2);
3522 assert!(
3523 collected.system_prompt_parts[0].starts_with("<capability id=\"prompt_tool_fixture\">")
3524 );
3525 assert!(
3526 collected.system_prompt_parts[1]
3527 .starts_with("<capability id=\"second_prompt_fixture\">")
3528 );
3529
3530 let prefix = collected.system_prompt_prefix().unwrap();
3531 assert!(prefix.contains("</capability>\n\n<capability"));
3533 }
3534
3535 #[tokio::test]
3536 async fn test_xml_tags_system_prompt_wrapping() {
3537 let registry = fixture_registry();
3538 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3539
3540 let applied = apply_capabilities(
3541 base,
3542 &["prompt_tool_fixture".to_string()],
3543 ®istry,
3544 &test_ctx(),
3545 )
3546 .await;
3547
3548 let prompt = &applied.runtime_agent.system_prompt;
3549 assert!(prompt.starts_with("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3550 assert!(prompt.contains("<capability id=\"prompt_tool_fixture\">"));
3552 assert!(prompt.contains("</capability>"));
3553 assert!(prompt.contains("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3555 }
3556
3557 #[tokio::test]
3558 async fn test_no_xml_wrapping_without_capabilities() {
3559 let registry = CapabilityRegistry::new();
3560 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3561
3562 let applied = apply_capabilities(base, &[], ®istry, &test_ctx()).await;
3563
3564 assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3566 assert!(
3567 !applied
3568 .runtime_agent
3569 .system_prompt
3570 .contains("<system-prompt>")
3571 );
3572 }
3573
3574 #[tokio::test]
3575 async fn test_no_xml_wrapping_for_noop_capability() {
3576 let registry = fixture_registry();
3577 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3578
3579 let applied = apply_capabilities(base, &["noop".to_string()], ®istry, &test_ctx()).await;
3581
3582 assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3583 assert!(
3584 !applied
3585 .runtime_agent
3586 .system_prompt
3587 .contains("<system-prompt>")
3588 );
3589 }
3590
3591 #[tokio::test]
3596 async fn test_collect_capabilities_includes_mounts() {
3597 let registry = fixture_registry();
3598
3599 let collected =
3600 collect_capabilities(&["sample_data".to_string()], ®istry, &test_ctx()).await;
3601
3602 assert!(!collected.mounts.is_empty());
3603 assert_eq!(collected.mounts.len(), 1);
3604 assert_eq!(collected.mounts[0].path, "/samples");
3605 assert!(collected.mounts[0].is_readonly());
3606 }
3607
3608 #[tokio::test]
3609 async fn test_collect_capabilities_empty_mounts_by_default() {
3610 let registry = fixture_registry();
3611
3612 let collected =
3614 collect_capabilities(&["current_time".to_string()], ®istry, &test_ctx()).await;
3615
3616 assert!(collected.mounts.is_empty());
3617 }
3618
3619 #[tokio::test]
3620 async fn test_dynamic_facts_add_note_without_static_block() {
3621 let registry = fixture_registry();
3625 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3626 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3627 let prompt = collected.system_prompt_parts.join("\n");
3628 assert!(
3629 prompt.contains(FACTS_DYNAMIC_NOTE),
3630 "dynamic-facts note should be in the cached prompt"
3631 );
3632 assert!(
3633 !prompt.contains("<facts>\n"),
3634 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3635 );
3636 }
3637
3638 #[tokio::test]
3639 async fn test_static_facts_fold_into_prompt() {
3640 struct StaticFactCap;
3641 impl Capability for StaticFactCap {
3642 fn id(&self) -> &str {
3643 "test_static_fact"
3644 }
3645 fn name(&self) -> &str {
3646 "Static Fact"
3647 }
3648 fn description(&self) -> &str {
3649 "test"
3650 }
3651 fn status(&self) -> CapabilityStatus {
3652 CapabilityStatus::Available
3653 }
3654 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3655 vec![Fact::stat("workspace_root", "/workspace")]
3656 }
3657 }
3658 let mut registry = CapabilityRegistry::new();
3659 registry.register(StaticFactCap);
3660 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3661 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3662 let prompt = collected.system_prompt_parts.join("\n");
3663 assert!(
3664 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3665 "static fact should fold into the cached prompt; got: {prompt}"
3666 );
3667 assert!(
3668 !prompt.contains(FACTS_DYNAMIC_NOTE),
3669 "no dynamic note when only static facts exist"
3670 );
3671 }
3672
3673 #[test]
3674 fn test_collect_dynamic_facts_returns_current_time() {
3675 let registry = fixture_registry();
3676 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3677 let facts = collect_dynamic_facts(
3678 &configs,
3679 ®istry,
3680 None,
3681 &FactsContext::new(SessionId::new()),
3682 );
3683 assert_eq!(facts.len(), 1);
3684 assert_eq!(facts[0].key, "current_time");
3685 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3686 }
3687
3688 #[tokio::test]
3689 async fn test_collect_capabilities_combines_mounts() {
3690 let registry = fixture_registry();
3691
3692 let collected = collect_capabilities(
3695 &["sample_data".to_string(), "current_time".to_string()],
3696 ®istry,
3697 &test_ctx(),
3698 )
3699 .await;
3700
3701 assert_eq!(collected.mounts.len(), 1);
3702 assert!(
3704 collected
3705 .applied_ids
3706 .iter()
3707 .any(|id| id == "session_file_system")
3708 );
3709 assert!(collected.applied_ids.iter().any(|id| id == "sample_data"));
3710 assert!(collected.applied_ids.iter().any(|id| id == "current_time"));
3711 }
3712
3713 #[test]
3714 fn test_sample_data_capability() {
3715 let registry = fixture_registry();
3716 let cap = registry.get("sample_data").unwrap();
3717
3718 assert_eq!(cap.id(), "sample_data");
3719 assert_eq!(cap.name(), "Sample Data");
3720 assert_eq!(cap.status(), CapabilityStatus::Available);
3721
3722 assert!(cap.system_prompt_addition().is_some());
3724 assert!(cap.tools().is_empty());
3725
3726 assert!(!cap.mounts().is_empty());
3728 }
3729
3730 #[test]
3735 fn test_resolve_dependencies_empty() {
3736 let registry = CapabilityRegistry::new();
3737
3738 let resolved = resolve_dependencies(&[], ®istry).unwrap();
3739
3740 assert!(resolved.resolved_ids.is_empty());
3741 assert!(resolved.added_as_dependencies.is_empty());
3742 assert!(resolved.user_selected.is_empty());
3743 }
3744
3745 #[test]
3746 fn test_resolve_dependencies_no_deps() {
3747 let registry = fixture_registry();
3748
3749 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
3751
3752 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3753 assert!(resolved.added_as_dependencies.is_empty());
3754 }
3755
3756 #[test]
3757 fn test_resolve_dependencies_with_deps() {
3758 let registry = fixture_registry();
3759
3760 let resolved = resolve_dependencies(&["sample_data".to_string()], ®istry).unwrap();
3762
3763 assert_eq!(resolved.resolved_ids.len(), 2);
3765 let fs_pos = resolved
3766 .resolved_ids
3767 .iter()
3768 .position(|id| id == "session_file_system")
3769 .unwrap();
3770 let sd_pos = resolved
3771 .resolved_ids
3772 .iter()
3773 .position(|id| id == "sample_data")
3774 .unwrap();
3775 assert!(fs_pos < sd_pos, "FileSystem should come before SampleData");
3776
3777 assert_eq!(resolved.added_as_dependencies, vec!["session_file_system"]);
3779 }
3780
3781 #[test]
3782 fn test_resolve_dependencies_already_selected() {
3783 let registry = fixture_registry();
3784
3785 let resolved = resolve_dependencies(
3787 &["session_file_system".to_string(), "sample_data".to_string()],
3788 ®istry,
3789 )
3790 .unwrap();
3791
3792 assert_eq!(resolved.resolved_ids.len(), 2);
3793 assert!(resolved.added_as_dependencies.is_empty());
3795 }
3796
3797 #[test]
3798 fn test_resolve_dependencies_preserves_order() {
3799 let registry = fixture_registry();
3800
3801 let resolved =
3803 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
3804 .unwrap();
3805
3806 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3807 }
3808
3809 #[test]
3810 fn test_resolve_dependencies_unknown_capability() {
3811 let registry = CapabilityRegistry::new();
3812
3813 let resolved =
3815 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
3816
3817 assert!(resolved.resolved_ids.is_empty());
3818 }
3819
3820 #[test]
3821 fn test_get_dependencies() {
3822 let registry = fixture_registry();
3823
3824 let deps = get_dependencies("sample_data", ®istry);
3826 assert_eq!(deps, vec!["session_file_system"]);
3827
3828 let deps = get_dependencies("current_time", ®istry);
3830 assert!(deps.is_empty());
3831
3832 let deps = get_dependencies("unknown", ®istry);
3834 assert!(deps.is_empty());
3835 }
3836
3837 #[test]
3838 fn test_sample_data_has_dependency() {
3839 let registry = fixture_registry();
3840 let cap = registry.get("sample_data").unwrap();
3841
3842 let deps = cap.dependencies();
3843 assert_eq!(deps.len(), 1);
3844 assert_eq!(deps[0], "session_file_system");
3845 }
3846
3847 #[test]
3848 fn test_noop_has_no_dependencies() {
3849 let registry = fixture_registry();
3850 let cap = registry.get("noop").unwrap();
3851
3852 assert!(cap.dependencies().is_empty());
3853 }
3854
3855 #[test]
3859 fn test_circular_dependency_error() {
3860 struct CapA;
3862 struct CapB;
3863
3864 impl Capability for CapA {
3865 fn id(&self) -> &str {
3866 "test_cap_a"
3867 }
3868 fn name(&self) -> &str {
3869 "Test A"
3870 }
3871 fn description(&self) -> &str {
3872 "Test capability A"
3873 }
3874 fn dependencies(&self) -> Vec<&'static str> {
3875 vec!["test_cap_b"]
3876 }
3877 }
3878
3879 impl Capability for CapB {
3880 fn id(&self) -> &str {
3881 "test_cap_b"
3882 }
3883 fn name(&self) -> &str {
3884 "Test B"
3885 }
3886 fn description(&self) -> &str {
3887 "Test capability B"
3888 }
3889 fn dependencies(&self) -> Vec<&'static str> {
3890 vec!["test_cap_a"]
3891 }
3892 }
3893
3894 let mut registry = CapabilityRegistry::new();
3895 registry.register(CapA);
3896 registry.register(CapB);
3897
3898 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
3899
3900 assert!(result.is_err());
3901 match result.unwrap_err() {
3902 DependencyError::CircularDependency { capability_id, .. } => {
3903 assert_eq!(capability_id, "test_cap_a");
3904 }
3905 _ => panic!("Expected CircularDependency error"),
3906 }
3907 }
3908
3909 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3914
3915 struct FilterTestCapability {
3917 priority: i32,
3918 }
3919
3920 impl Capability for FilterTestCapability {
3921 fn id(&self) -> &str {
3922 "filter_test"
3923 }
3924 fn name(&self) -> &str {
3925 "Filter Test"
3926 }
3927 fn description(&self) -> &str {
3928 "Test capability with message filter"
3929 }
3930 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3931 Some(Arc::new(FilterTestProvider {
3932 priority: self.priority,
3933 }))
3934 }
3935 }
3936
3937 struct FilterTestProvider {
3938 priority: i32,
3939 }
3940
3941 impl MessageFilterProvider for FilterTestProvider {
3942 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3943 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3945 query
3946 .filters
3947 .push(MessageFilter::Search(search.to_string()));
3948 }
3949 }
3950
3951 fn priority(&self) -> i32 {
3952 self.priority
3953 }
3954 }
3955
3956 #[tokio::test]
3957 async fn test_collect_capabilities_with_configs_no_filter_providers() {
3958 let registry = fixture_registry();
3959 let configs = vec![AgentCapabilityConfig::with_config(
3960 CapabilityId::new("current_time"),
3961 serde_json::json!({}),
3962 )];
3963
3964 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3965
3966 assert!(collected.message_filter_providers.is_empty());
3967 assert!(!collected.has_message_filters());
3968 }
3969
3970 #[tokio::test]
3971 async fn test_collect_capabilities_with_configs_with_filter_provider() {
3972 let mut registry = CapabilityRegistry::new();
3973 registry.register(FilterTestCapability { priority: 0 });
3974
3975 let configs = vec![AgentCapabilityConfig::with_config(
3976 CapabilityId::new("filter_test"),
3977 serde_json::json!({ "search": "hello" }),
3978 )];
3979
3980 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3981
3982 assert_eq!(collected.message_filter_providers.len(), 1);
3983 assert!(collected.has_message_filters());
3984 }
3985
3986 #[tokio::test]
3987 async fn test_collect_capabilities_with_configs_filter_priority_order() {
3988 struct HighPriorityCapability;
3990 struct LowPriorityCapability;
3991
3992 impl Capability for HighPriorityCapability {
3993 fn id(&self) -> &str {
3994 "high_priority"
3995 }
3996 fn name(&self) -> &str {
3997 "High Priority"
3998 }
3999 fn description(&self) -> &str {
4000 "Test"
4001 }
4002 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4003 Some(Arc::new(FilterTestProvider { priority: 10 }))
4004 }
4005 }
4006
4007 impl Capability for LowPriorityCapability {
4008 fn id(&self) -> &str {
4009 "low_priority"
4010 }
4011 fn name(&self) -> &str {
4012 "Low Priority"
4013 }
4014 fn description(&self) -> &str {
4015 "Test"
4016 }
4017 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4018 Some(Arc::new(FilterTestProvider { priority: -5 }))
4019 }
4020 }
4021
4022 let mut registry = CapabilityRegistry::new();
4023 registry.register(HighPriorityCapability);
4024 registry.register(LowPriorityCapability);
4025
4026 let configs = vec![
4028 AgentCapabilityConfig::with_config(
4029 CapabilityId::new("high_priority"),
4030 serde_json::json!({}),
4031 ),
4032 AgentCapabilityConfig::with_config(
4033 CapabilityId::new("low_priority"),
4034 serde_json::json!({}),
4035 ),
4036 ];
4037
4038 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4039
4040 assert_eq!(collected.message_filter_providers.len(), 2);
4042 assert_eq!(collected.message_filter_providers[0].0.priority(), -5);
4043 assert_eq!(collected.message_filter_providers[1].0.priority(), 10);
4044 }
4045
4046 #[tokio::test]
4047 async fn test_collected_capabilities_apply_message_filters() {
4048 let mut registry = CapabilityRegistry::new();
4049 registry.register(FilterTestCapability { priority: 0 });
4050
4051 let configs = vec![AgentCapabilityConfig::with_config(
4052 CapabilityId::new("filter_test"),
4053 serde_json::json!({ "search": "test_query" }),
4054 )];
4055
4056 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4057
4058 let session_id: SessionId = Uuid::now_v7().into();
4060 let mut query = MessageQuery::new(session_id);
4061
4062 collected.apply_message_filters(&mut query);
4063
4064 assert_eq!(query.filters.len(), 1);
4066 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4067 }
4068
4069 #[tokio::test]
4070 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4071 struct SearchCapability {
4072 id: &'static str,
4073 search_term: &'static str,
4074 priority: i32,
4075 }
4076
4077 struct SearchProvider {
4078 search_term: &'static str,
4079 priority: i32,
4080 }
4081
4082 impl MessageFilterProvider for SearchProvider {
4083 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4084 query
4085 .filters
4086 .push(MessageFilter::Search(self.search_term.to_string()));
4087 }
4088
4089 fn priority(&self) -> i32 {
4090 self.priority
4091 }
4092 }
4093
4094 impl Capability for SearchCapability {
4095 fn id(&self) -> &str {
4096 self.id
4097 }
4098 fn name(&self) -> &str {
4099 "Search"
4100 }
4101 fn description(&self) -> &str {
4102 "Test"
4103 }
4104 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4105 Some(Arc::new(SearchProvider {
4106 search_term: self.search_term,
4107 priority: self.priority,
4108 }))
4109 }
4110 }
4111
4112 let mut registry = CapabilityRegistry::new();
4113 registry.register(SearchCapability {
4114 id: "cap_a",
4115 search_term: "alpha",
4116 priority: 5,
4117 });
4118 registry.register(SearchCapability {
4119 id: "cap_b",
4120 search_term: "beta",
4121 priority: 1,
4122 });
4123 registry.register(SearchCapability {
4124 id: "cap_c",
4125 search_term: "gamma",
4126 priority: 10,
4127 });
4128
4129 let configs = vec![
4130 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4131 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4132 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4133 ];
4134
4135 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4136
4137 let session_id: SessionId = Uuid::now_v7().into();
4138 let mut query = MessageQuery::new(session_id);
4139
4140 collected.apply_message_filters(&mut query);
4141
4142 assert_eq!(query.filters.len(), 3);
4144 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4145 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4146 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4147 }
4148
4149 #[test]
4150 fn test_capability_without_message_filter_returns_none() {
4151 let registry = fixture_registry();
4152
4153 let noop = registry.get("noop").unwrap();
4154 assert!(noop.message_filter_provider().is_none());
4155
4156 let current_time = registry.get("current_time").unwrap();
4157 assert!(current_time.message_filter_provider().is_none());
4158 }
4159
4160 #[tokio::test]
4161 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4162 let mut registry = CapabilityRegistry::new();
4163 registry.register(FilterTestCapability { priority: 0 });
4164
4165 let test_config = serde_json::json!({
4166 "search": "custom_search",
4167 "extra_field": 42
4168 });
4169
4170 let configs = vec![AgentCapabilityConfig::with_config(
4171 CapabilityId::new("filter_test"),
4172 test_config.clone(),
4173 )];
4174
4175 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4176
4177 assert_eq!(collected.message_filter_providers.len(), 1);
4179 let (_, stored_config) = &collected.message_filter_providers[0];
4180 assert_eq!(*stored_config, test_config);
4181 }
4182
4183 #[test]
4188 fn test_collect_message_filters_only_collects_filters() {
4189 let mut registry = CapabilityRegistry::new();
4190 registry.register(FilterTestCapability { priority: 0 });
4191
4192 let configs = vec![AgentCapabilityConfig::with_config(
4193 CapabilityId::new("filter_test"),
4194 serde_json::json!({ "search": "test_query" }),
4195 )];
4196
4197 let collected = collect_message_filters_only(&configs, ®istry);
4198
4199 let session_id: SessionId = Uuid::now_v7().into();
4200 let mut query = MessageQuery::new(session_id);
4201 collected.apply_message_filters(&mut query);
4202
4203 assert_eq!(query.filters.len(), 1);
4204 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4205 }
4206
4207 #[test]
4208 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4209 let registry = CapabilityRegistry::new();
4210
4211 let configs = vec![AgentCapabilityConfig::with_config(
4212 CapabilityId::new("nonexistent"),
4213 serde_json::json!({}),
4214 )];
4215
4216 let collected = collect_message_filters_only(&configs, ®istry);
4217 assert!(collected.message_filter_providers.is_empty());
4218 }
4219
4220 #[test]
4221 fn test_collect_message_filters_only_preserves_priority_order() {
4222 struct PriorityFilterCap {
4223 id: &'static str,
4224 search_term: &'static str,
4225 priority: i32,
4226 }
4227
4228 struct PriorityFilterProvider {
4229 search_term: &'static str,
4230 priority: i32,
4231 }
4232
4233 impl Capability for PriorityFilterCap {
4234 fn id(&self) -> &str {
4235 self.id
4236 }
4237 fn name(&self) -> &str {
4238 self.id
4239 }
4240 fn description(&self) -> &str {
4241 "priority test"
4242 }
4243 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4244 Some(Arc::new(PriorityFilterProvider {
4245 search_term: self.search_term,
4246 priority: self.priority,
4247 }))
4248 }
4249 }
4250
4251 impl MessageFilterProvider for PriorityFilterProvider {
4252 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4253 query
4254 .filters
4255 .push(MessageFilter::Search(self.search_term.to_string()));
4256 }
4257 fn priority(&self) -> i32 {
4258 self.priority
4259 }
4260 }
4261
4262 let mut registry = CapabilityRegistry::new();
4263 registry.register(PriorityFilterCap {
4264 id: "gamma",
4265 search_term: "gamma",
4266 priority: 10,
4267 });
4268 registry.register(PriorityFilterCap {
4269 id: "alpha",
4270 search_term: "alpha",
4271 priority: 5,
4272 });
4273 registry.register(PriorityFilterCap {
4274 id: "beta",
4275 search_term: "beta",
4276 priority: 1,
4277 });
4278
4279 let configs = vec![
4280 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4281 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4282 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4283 ];
4284
4285 let collected = collect_message_filters_only(&configs, ®istry);
4286
4287 let session_id: SessionId = Uuid::now_v7().into();
4288 let mut query = MessageQuery::new(session_id);
4289 collected.apply_message_filters(&mut query);
4290
4291 assert_eq!(query.filters.len(), 3);
4293 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4294 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4295 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4296 }
4297
4298 #[test]
4299 fn test_collect_message_filters_only_post_load_invoked() {
4300 use crate::message::Message;
4301
4302 struct PostLoadCap;
4303 struct PostLoadProvider;
4304
4305 impl Capability for PostLoadCap {
4306 fn id(&self) -> &str {
4307 "post_load_test"
4308 }
4309 fn name(&self) -> &str {
4310 "PostLoad Test"
4311 }
4312 fn description(&self) -> &str {
4313 "test"
4314 }
4315 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4316 Some(Arc::new(PostLoadProvider))
4317 }
4318 }
4319
4320 impl MessageFilterProvider for PostLoadProvider {
4321 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4322 fn priority(&self) -> i32 {
4323 0
4324 }
4325 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4326 messages.reverse();
4328 }
4329 }
4330
4331 let mut registry = CapabilityRegistry::new();
4332 registry.register(PostLoadCap);
4333
4334 let configs = vec![AgentCapabilityConfig::with_config(
4335 CapabilityId::new("post_load_test"),
4336 serde_json::json!({}),
4337 )];
4338
4339 let collected = collect_message_filters_only(&configs, ®istry);
4340
4341 let mut messages = vec![Message::user("first"), Message::user("second")];
4342 collected.apply_post_load_filters(&mut messages);
4343
4344 assert_eq!(messages[0].text(), Some("second"));
4346 assert_eq!(messages[1].text(), Some("first"));
4347 }
4348
4349 struct DelegatingFilterCap {
4352 id: &'static str,
4353 inner: std::sync::Arc<InnerFilterCap>,
4354 }
4355 struct InnerFilterCap;
4356
4357 impl Capability for InnerFilterCap {
4358 fn id(&self) -> &str {
4359 "inner_filter"
4360 }
4361 fn name(&self) -> &str {
4362 "Inner Filter"
4363 }
4364 fn description(&self) -> &str {
4365 "inner"
4366 }
4367 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4368 Some(std::sync::Arc::new(SentinelFilter))
4369 }
4370 }
4371 struct SentinelFilter;
4372 impl MessageFilterProvider for SentinelFilter {
4373 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4374 }
4375 impl Capability for DelegatingFilterCap {
4376 fn id(&self) -> &str {
4377 self.id
4378 }
4379 fn name(&self) -> &str {
4380 "Delegating Filter"
4381 }
4382 fn description(&self) -> &str {
4383 "delegating"
4384 }
4385 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4386 None }
4388 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4389 Some(&*self.inner)
4390 }
4391 }
4392
4393 #[test]
4394 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4395 let inner = std::sync::Arc::new(InnerFilterCap);
4396 let outer = DelegatingFilterCap {
4397 id: "delegating_filter",
4398 inner: inner.clone(),
4399 };
4400
4401 let mut registry = CapabilityRegistry::new();
4402 registry.register(outer);
4403
4404 let configs = vec![AgentCapabilityConfig::with_config(
4405 CapabilityId::new("delegating_filter"),
4406 serde_json::json!({}),
4407 )];
4408
4409 let collected = collect_message_filters_only(&configs, ®istry);
4412 assert_eq!(
4413 collected.message_filter_providers.len(),
4414 1,
4415 "provider from resolved inner capability must be collected"
4416 );
4417 }
4418
4419 struct DelegatingMvpCap {
4420 id: &'static str,
4421 inner: std::sync::Arc<InnerMvpCap>,
4422 }
4423 struct InnerMvpCap;
4424
4425 impl Capability for InnerMvpCap {
4426 fn id(&self) -> &str {
4427 "inner_mvp"
4428 }
4429 fn name(&self) -> &str {
4430 "Inner MVP"
4431 }
4432 fn description(&self) -> &str {
4433 "inner"
4434 }
4435 fn model_view_provider(
4436 &self,
4437 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4438 struct NoopMvp;
4440 impl crate::capabilities::ModelViewProvider for NoopMvp {
4441 fn apply_model_view(
4442 &self,
4443 messages: Vec<Message>,
4444 _config: &serde_json::Value,
4445 _context: &ModelViewContext<'_>,
4446 ) -> Vec<Message> {
4447 messages
4448 }
4449 }
4450 Some(std::sync::Arc::new(NoopMvp))
4451 }
4452 }
4453 impl Capability for DelegatingMvpCap {
4454 fn id(&self) -> &str {
4455 self.id
4456 }
4457 fn name(&self) -> &str {
4458 "Delegating MVP"
4459 }
4460 fn description(&self) -> &str {
4461 "delegating"
4462 }
4463 fn model_view_provider(
4464 &self,
4465 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4466 None }
4468 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4469 Some(&*self.inner)
4470 }
4471 }
4472
4473 #[test]
4474 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4475 let inner = std::sync::Arc::new(InnerMvpCap);
4476 let outer = DelegatingMvpCap {
4477 id: "delegating_mvp",
4478 inner: inner.clone(),
4479 };
4480
4481 let mut registry = CapabilityRegistry::new();
4482 registry.register(outer);
4483
4484 let configs = vec![AgentCapabilityConfig::with_config(
4485 CapabilityId::new("delegating_mvp"),
4486 serde_json::json!({}),
4487 )];
4488
4489 let collected = collect_model_view_providers(&configs, ®istry, None);
4492 assert_eq!(
4493 collected.model_view_providers.len(),
4494 1,
4495 "provider from resolved inner capability must be collected"
4496 );
4497 }
4498
4499 #[tokio::test]
4509 async fn test_bashkit_shell_capability_produces_bash_tool() {
4510 let registry = fixture_registry();
4511 let collected =
4512 collect_capabilities(&["bashkit_shell".to_string()], ®istry, &test_ctx()).await;
4513
4514 let tool_names: Vec<&str> = collected
4515 .tool_definitions
4516 .iter()
4517 .map(|t| t.name())
4518 .collect();
4519 assert!(
4520 tool_names.contains(&"bash"),
4521 "bashkit_shell capability must produce 'bash' tool, got: {:?}",
4522 tool_names
4523 );
4524 assert!(
4525 !collected.tools.is_empty(),
4526 "bashkit_shell must provide tool implementations"
4527 );
4528 }
4529
4530 #[tokio::test]
4531 async fn test_generic_harness_capability_set_produces_bash_tool() {
4532 let generic_harness_caps = vec![
4535 "session_file_system".to_string(),
4536 "bashkit_shell".to_string(),
4537 "web_fetch".to_string(),
4538 "session_storage".to_string(),
4539 "session".to_string(),
4540 "agent_instructions".to_string(),
4541 "skills".to_string(),
4542 "infinity_context".to_string(),
4543 "auto_tool_search".to_string(),
4544 ];
4545
4546 let registry = fixture_registry();
4547 let collected = collect_capabilities(&generic_harness_caps, ®istry, &test_ctx()).await;
4548
4549 let tool_names: Vec<&str> = collected
4550 .tool_definitions
4551 .iter()
4552 .map(|t| t.name())
4553 .collect();
4554 assert!(
4555 tool_names.contains(&"bash"),
4556 "Generic Harness capabilities must produce 'bash' tool, got: {:?}",
4557 tool_names
4558 );
4559 }
4560
4561 #[tokio::test]
4562 async fn test_collect_capabilities_tool_count_matches_definitions() {
4563 let registry = fixture_registry();
4566 let collected =
4567 collect_capabilities(&["bashkit_shell".to_string()], ®istry, &test_ctx()).await;
4568
4569 assert_eq!(
4570 collected.tools.len(),
4571 collected.tool_definitions.len(),
4572 "tool implementations ({}) must match tool definitions ({})",
4573 collected.tools.len(),
4574 collected.tool_definitions.len(),
4575 );
4576 }
4577
4578 #[tokio::test]
4582 async fn test_collect_capabilities_resolves_dependencies() {
4583 let registry = fixture_registry();
4586 let collected =
4587 collect_capabilities(&["sample_data".to_string()], ®istry, &test_ctx()).await;
4588
4589 assert!(
4591 collected
4592 .applied_ids
4593 .iter()
4594 .any(|id| id == "session_file_system"),
4595 "collect_capabilities must apply session_file_system as a dependency; applied_ids: {:?}",
4596 collected.applied_ids
4597 );
4598
4599 let tool_names: Vec<&str> = collected
4600 .tool_definitions
4601 .iter()
4602 .map(|t| t.name())
4603 .collect();
4604
4605 assert!(
4607 tool_names.contains(&"read_file") && tool_names.contains(&"write_file"),
4608 "collect_capabilities must resolve dependencies and include dependency tools, got: {:?}",
4609 tool_names
4610 );
4611
4612 assert_eq!(
4614 collected.tools.len(),
4615 collected.tool_definitions.len(),
4616 "dependency-added tools must have implementations, not just definitions"
4617 );
4618 }
4619
4620 #[test]
4621 fn test_defaults_do_not_include_bash() {
4622 let registry = crate::ToolRegistry::with_defaults();
4625 assert!(
4626 !registry.has("bash"),
4627 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4628 );
4629 }
4630
4631 #[test]
4636 fn test_capability_features_default_empty() {
4637 let registry = fixture_registry();
4638
4639 let noop = registry.get("noop").unwrap();
4641 assert!(noop.features().is_empty());
4642
4643 let current_time = registry.get("current_time").unwrap();
4644 assert!(current_time.features().is_empty());
4645 }
4646
4647 #[test]
4648 fn test_file_system_capability_features() {
4649 let registry = fixture_registry();
4650
4651 let fs = registry.get("session_file_system").unwrap();
4652 assert_eq!(fs.features(), vec!["file_system"]);
4653 }
4654
4655 #[test]
4656 fn test_bashkit_shell_capability_features() {
4657 let registry = fixture_registry();
4658
4659 let bash = registry.get("bashkit_shell").unwrap();
4660 assert_eq!(bash.features(), vec!["file_system"]);
4661 }
4662
4663 #[test]
4664 fn test_alias_resolves_to_canonical_capability() {
4665 let registry = fixture_registry();
4666
4667 let via_alias = registry.get("virtual_bash").unwrap();
4669 assert_eq!(via_alias.id(), "bashkit_shell");
4670 assert!(registry.has("virtual_bash"));
4671 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4672 assert_eq!(
4673 registry.canonical_id("bashkit_shell"),
4674 Some("bashkit_shell")
4675 );
4676 assert_eq!(registry.canonical_id("nonexistent"), None);
4677 }
4678
4679 #[test]
4680 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4681 let registry = fixture_registry();
4682
4683 let resolved = resolve_dependencies(
4686 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4687 ®istry,
4688 )
4689 .unwrap();
4690 let bash_ids: Vec<_> = resolved
4691 .resolved_ids
4692 .iter()
4693 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4694 .collect();
4695 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4696 assert!(
4698 !resolved
4699 .added_as_dependencies
4700 .contains(&"bashkit_shell".to_string())
4701 );
4702 }
4703
4704 #[test]
4705 fn test_alias_preserves_explicit_config_in_resolution() {
4706 let registry = fixture_registry();
4707
4708 let configs = vec![AgentCapabilityConfig::with_config(
4709 "virtual_bash".to_string(),
4710 serde_json::json!({"key": "value"}),
4711 )];
4712 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4713 let bash = resolved
4714 .iter()
4715 .find(|c| c.capability_id() == "bashkit_shell")
4716 .expect("alias must resolve to canonical bashkit_shell config");
4717 assert_eq!(
4718 bash.config_value().clone(),
4719 serde_json::json!({"key": "value"})
4720 );
4721 }
4722
4723 #[test]
4724 fn test_unregister_by_alias_removes_capability_and_aliases() {
4725 let mut registry = fixture_registry();
4726
4727 assert!(registry.unregister("virtual_bash").is_some());
4728 assert!(!registry.has("bashkit_shell"));
4729 assert!(!registry.has("virtual_bash"));
4730 }
4731
4732 #[test]
4733 fn kernel_capability_features_are_declared_on_the_capability() {
4734 let registry = fixture_registry();
4735
4736 let capability = registry
4741 .get("feature_fixture")
4742 .expect("feature fixture must be registered");
4743 assert_eq!(
4744 compute_features(&["feature_fixture".to_string()], ®istry),
4745 capability.features()
4746 );
4747 }
4748
4749 #[test]
4750 fn test_sample_data_capability_features() {
4751 let registry = fixture_registry();
4752
4753 let sample = registry.get("sample_data").unwrap();
4754 assert_eq!(sample.features(), vec!["file_system"]);
4755 }
4756
4757 #[test]
4758 fn test_compute_features_empty() {
4759 let registry = CapabilityRegistry::new();
4760
4761 let features = compute_features(&[], ®istry);
4762 assert!(features.is_empty());
4763 }
4764
4765 #[test]
4766 fn test_compute_features_single_capability() {
4767 let registry = fixture_registry();
4768
4769 let features = compute_features(&["feature_fixture".to_string()], ®istry);
4770 assert_eq!(
4771 features,
4772 registry
4773 .get("feature_fixture")
4774 .expect("feature fixture must be registered")
4775 .features()
4776 );
4777 }
4778
4779 #[test]
4780 fn test_compute_features_multiple_capabilities() {
4781 let registry = fixture_registry();
4782
4783 let features = compute_features(
4784 &[
4785 "session_file_system".to_string(),
4786 "session_storage".to_string(),
4787 ],
4788 ®istry,
4789 );
4790 assert!(features.contains(&"file_system".to_string()));
4791 assert!(features.contains(&"secrets".to_string()));
4792 assert!(features.contains(&"key_value".to_string()));
4793 }
4794
4795 #[test]
4796 fn test_compute_features_deduplicates() {
4797 let registry = fixture_registry();
4798
4799 let features = compute_features(
4801 &[
4802 "session_file_system".to_string(),
4803 "bashkit_shell".to_string(),
4804 ],
4805 ®istry,
4806 );
4807 let file_system_count = features.iter().filter(|f| *f == "file_system").count();
4808 assert_eq!(file_system_count, 1, "file_system should appear only once");
4809 }
4810
4811 #[test]
4812 fn test_compute_features_includes_dependency_features() {
4813 let registry = fixture_registry();
4814
4815 let features = compute_features(&["bashkit_shell".to_string()], ®istry);
4817 assert!(features.contains(&"file_system".to_string()));
4818 }
4819
4820 #[test]
4821 fn test_compute_features_generic_harness_set() {
4822 let registry = fixture_registry();
4823
4824 let features = compute_features(
4826 &[
4827 "session_file_system".to_string(),
4828 "bashkit_shell".to_string(),
4829 "session_storage".to_string(),
4830 "session".to_string(),
4831 ],
4832 ®istry,
4833 );
4834 assert!(features.contains(&"file_system".to_string()));
4835 assert!(features.contains(&"secrets".to_string()));
4836 assert!(features.contains(&"key_value".to_string()));
4837 }
4838
4839 #[test]
4840 fn test_compute_features_unknown_capability_ignored() {
4841 let registry = fixture_registry();
4842
4843 let features = compute_features(
4844 &["unknown_cap".to_string(), "session_storage".to_string()],
4845 ®istry,
4846 );
4847 assert_eq!(features, vec!["secrets", "key_value"]);
4848 }
4849
4850 #[test]
4851 fn test_risk_level_ordering() {
4852 assert!(RiskLevel::Low < RiskLevel::Medium);
4853 assert!(RiskLevel::Medium < RiskLevel::High);
4854 }
4855
4856 #[test]
4857 fn test_risk_level_serde_roundtrip() {
4858 let high = RiskLevel::High;
4859 let json = serde_json::to_string(&high).unwrap();
4860 assert_eq!(json, "\"high\"");
4861 let back: RiskLevel = serde_json::from_str(&json).unwrap();
4862 assert_eq!(back, RiskLevel::High);
4863 }
4864
4865 #[test]
4866 fn test_capability_risk_levels() {
4867 let registry = fixture_registry();
4868
4869 let bash = registry.get("bashkit_shell").unwrap();
4871 assert_eq!(bash.risk_level(), RiskLevel::High);
4872
4873 let fetch = registry.get("web_fetch").unwrap();
4875 assert_eq!(fetch.risk_level(), RiskLevel::High);
4876
4877 let noop = registry.get("noop").unwrap();
4879 assert_eq!(noop.risk_level(), RiskLevel::Low);
4880 }
4881
4882 struct SkillContributingCapability;
4887
4888 impl Capability for SkillContributingCapability {
4889 fn id(&self) -> &str {
4890 "contributes_skills"
4891 }
4892 fn name(&self) -> &str {
4893 "Contributes Skills"
4894 }
4895 fn description(&self) -> &str {
4896 "Test capability that contributes skills."
4897 }
4898 fn contribute_skills(&self) -> Vec<SkillContribution> {
4899 vec![
4900 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4901 .with_files(vec![(
4902 "scripts/a.sh".to_string(),
4903 "#!/bin/sh\necho a\n".to_string(),
4904 )]),
4905 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4906 .with_user_invocable(false),
4907 ]
4908 }
4909 }
4910
4911 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4912 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4913 MountSource::InlineFile { content, .. } => content.as_str(),
4914 _ => panic!("Expected InlineFile for SKILL.md"),
4915 }
4916 }
4917
4918 #[tokio::test]
4919 async fn test_contribute_skills_normalized_to_mounts() {
4920 let mut registry = CapabilityRegistry::new();
4921 registry.register(SkillContributingCapability);
4922
4923 let configs = vec![AgentCapabilityConfig::with_config(
4924 CapabilityId::new("contributes_skills"),
4925 serde_json::json!({}),
4926 )];
4927
4928 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4929
4930 let skill_mounts: Vec<_> = collected
4931 .mounts
4932 .iter()
4933 .filter(|m| m.path.starts_with("/.agents/skills/"))
4934 .collect();
4935 assert_eq!(skill_mounts.len(), 2);
4936
4937 for m in &skill_mounts {
4940 assert!(m.is_readonly());
4941 assert_eq!(m.capability_id, "contributes_skills");
4942 }
4943
4944 let alpha = skill_mounts
4945 .iter()
4946 .find(|m| m.path == "/.agents/skills/alpha-skill")
4947 .expect("alpha-skill mount missing");
4948 match &alpha.source {
4949 MountSource::InlineDirectory { entries } => {
4950 assert!(entries.contains_key("SKILL.md"));
4951 assert!(entries.contains_key("scripts/a.sh"));
4952 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4953 assert_eq!(parsed.name, "alpha-skill");
4954 assert!(parsed.user_invocable);
4955 }
4956 _ => panic!("Expected InlineDirectory"),
4957 }
4958
4959 let beta = skill_mounts
4960 .iter()
4961 .find(|m| m.path == "/.agents/skills/beta-skill")
4962 .expect("beta-skill mount missing");
4963 match &beta.source {
4964 MountSource::InlineDirectory { entries } => {
4965 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4966 assert!(!parsed.user_invocable);
4967 }
4968 _ => panic!("Expected InlineDirectory"),
4969 }
4970 }
4971
4972 #[tokio::test]
4973 async fn test_contribute_skills_default_empty() {
4974 let mut registry = CapabilityRegistry::new();
4977 registry.register(FilterTestCapability { priority: 0 });
4978
4979 let configs = vec![AgentCapabilityConfig::with_config(
4980 CapabilityId::new("filter_test"),
4981 serde_json::json!({}),
4982 )];
4983
4984 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4985 assert!(
4986 collected
4987 .mounts
4988 .iter()
4989 .all(|m| !m.path.starts_with("/.agents/skills/"))
4990 );
4991 }
4992
4993 struct LocalizedCapability;
4994
4995 impl Capability for LocalizedCapability {
4996 fn id(&self) -> &str {
4997 "localized"
4998 }
4999 fn name(&self) -> &str {
5000 "Localized"
5001 }
5002 fn description(&self) -> &str {
5003 "English description"
5004 }
5005 fn localizations(&self) -> Vec<CapabilityLocalization> {
5006 vec![
5007 CapabilityLocalization {
5008 locale: "en",
5009 name: None,
5010 description: None,
5011 config_description: Some("Controls things."),
5012 config_overlay: None,
5013 },
5014 CapabilityLocalization {
5015 locale: "uk",
5016 name: Some("Локалізована"),
5017 description: Some("Український опис"),
5018 config_description: Some("Керує налаштуваннями."),
5019 config_overlay: None,
5020 },
5021 ]
5022 }
5023 }
5024
5025 #[test]
5026 fn localized_name_falls_back_exact_language_then_base() {
5027 let cap = LocalizedCapability;
5028 assert_eq!(cap.localized_name(Some("uk-UA")), "Локалізована");
5030 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
5031 assert_eq!(cap.localized_name(Some("uk_UA")), "Локалізована");
5033 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
5035 assert_eq!(cap.localized_name(None), "Localized");
5036 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
5037 assert_eq!(cap.localized_description(Some("de")), "English description");
5038 }
5039
5040 #[test]
5041 fn describe_schema_resolves_config_description_per_locale() {
5042 let cap = LocalizedCapability;
5043 assert_eq!(
5044 cap.describe_schema(Some("uk-UA")).as_deref(),
5045 Some("Керує налаштуваннями.")
5046 );
5047 assert_eq!(
5049 cap.describe_schema(Some("pl")).as_deref(),
5050 Some("Controls things.")
5051 );
5052 assert_eq!(
5053 cap.describe_schema(None).as_deref(),
5054 Some("Controls things.")
5055 );
5056 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
5058 }
5059}