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 from_provider = tool_call
1584 .arguments
1585 .get("target")
1586 .and_then(|target| target.get("type"))
1587 .and_then(serde_json::Value::as_str)
1588 .and_then(|target_type| self.provider_for(target_type))
1589 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1590 Some(from_provider.unwrap_or_else(|| {
1591 crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1592 }))
1593 }
1594
1595 fn name(&self) -> &str {
1596 "spawn_agent"
1597 }
1598
1599 fn display_name(&self) -> Option<&str> {
1600 Some("Spawn Agent")
1601 }
1602
1603 fn description(&self) -> &str {
1604 "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."
1605 }
1606
1607 fn parameters_schema(&self) -> serde_json::Value {
1608 serde_json::json!({
1609 "type": "object",
1610 "properties": {
1611 "name": {
1612 "type": "string",
1613 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1614 },
1615 "instructions": {
1616 "type": "string",
1617 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1618 },
1619 "goal": {
1620 "type": "string",
1621 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1622 },
1623 "lifetime": {
1624 "type": "string",
1625 "enum": ["linked", "detached"],
1626 "default": "linked",
1627 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1628 },
1629 "seed": {
1630 "type": "string",
1631 "enum": ["fresh", "fork", "workspace"],
1632 "default": "fresh",
1633 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1634 },
1635 "target": {
1636 "type": "object",
1637 "properties": {
1638 "type": {
1639 "type": "string",
1640 "enum": self.target_types(),
1641 "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."
1642 },
1643 "id": {
1644 "type": "string",
1645 "description": "Configured target id for first-party handoffs or external A2A agents."
1646 },
1647 "external_agent_id": {
1648 "type": "string",
1649 "description": "Configured external A2A agent id."
1650 }
1651 },
1652 "required": ["type"],
1653 "oneOf": self.target_constraint_branches(),
1654 "additionalProperties": false
1655 },
1656 "mode": {
1657 "type": "string",
1658 "enum": ["background", "foreground"],
1659 "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."
1660 },
1661 "blueprint": {
1662 "type": "string",
1663 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1664 },
1665 "config": {
1666 "type": "object",
1667 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1668 },
1669 "result_schema": {
1670 "type": "object",
1671 "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."
1672 },
1673 "message_schema": {
1674 "type": "object",
1675 "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."
1676 },
1677 "public_context": {
1678 "type": "object",
1679 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1680 },
1681 "wait_timeout_secs": {
1682 "type": "integer",
1683 "minimum": 1,
1684 "maximum": 86400,
1685 "description": "External-A2A-only foreground timeout."
1686 },
1687 "wake_on_completion": {
1688 "type": "boolean",
1689 "description": "External-A2A-only control for background completion wake-ups."
1690 }
1691 },
1692 "required": ["name", "instructions", "target"],
1693 "additionalProperties": false
1694 })
1695 }
1696
1697 fn hints(&self) -> crate::tool_types::ToolHints {
1698 let mut hints = crate::tool_types::ToolHints::default()
1699 .with_long_running(true)
1700 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1701 if self.provider_for("external_a2a").is_some() {
1702 hints = hints.with_open_world(true);
1703 }
1704 hints
1705 }
1706
1707 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1708 ToolExecutionResult::tool_error(
1709 "spawn_agent requires context. This tool must be executed with session context.",
1710 )
1711 }
1712
1713 async fn execute_with_context(
1714 &self,
1715 arguments: serde_json::Value,
1716 context: &ToolContext,
1717 ) -> ToolExecutionResult {
1718 let target_type = match arguments
1719 .get("target")
1720 .and_then(|target| target.get("type"))
1721 .and_then(serde_json::Value::as_str)
1722 {
1723 Some(target_type) => target_type,
1724 None => {
1725 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1726 }
1727 };
1728
1729 let Some(provider) = self.provider_for(target_type) else {
1730 let supported = self.target_types().join(", ");
1731 return ToolExecutionResult::tool_error(format!(
1732 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1733 ));
1734 };
1735 if target_type == "external_a2a"
1736 && arguments
1737 .get("lifetime")
1738 .and_then(serde_json::Value::as_str)
1739 .is_some_and(|value| value == "detached")
1740 {
1741 return ToolExecutionResult::tool_error(
1742 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1743 );
1744 }
1745 if target_type == "external_a2a"
1746 && arguments
1747 .get("message_schema")
1748 .is_some_and(|schema| !schema.is_null())
1749 {
1750 return ToolExecutionResult::tool_error(
1751 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1752 );
1753 }
1754
1755 provider.execute_with_context(arguments, context).await
1756 }
1757
1758 fn requires_context(&self) -> bool {
1759 true
1760 }
1761}
1762
1763pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1768 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1769 return base_system_prompt.to_string();
1770 };
1771
1772 if base_system_prompt.is_empty() {
1773 return additions.to_string();
1774 }
1775
1776 if base_system_prompt.contains("<system-prompt>") {
1777 format!("{base_system_prompt}\n\n{additions}")
1778 } else {
1779 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1780 }
1781}
1782
1783pub struct CollectedMessageFilters {
1790 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1792}
1793
1794pub struct CollectedModelViewProviders {
1796 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1798}
1799
1800impl CollectedMessageFilters {
1806 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1808 for (provider, config) in &self.message_filter_providers {
1809 provider.apply_filters(query, config);
1810 }
1811 }
1812
1813 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1815 for (provider, config) in &self.message_filter_providers {
1816 provider.post_load(messages, config);
1817 }
1818 }
1819}
1820
1821impl CollectedModelViewProviders {
1822 pub fn apply_model_view(
1824 &self,
1825 mut messages: Vec<Message>,
1826 context: &ModelViewContext<'_>,
1827 ) -> Vec<Message> {
1828 for (provider, config) in &self.model_view_providers {
1829 messages = provider.apply_model_view(messages, config, context);
1830 }
1831 messages
1832 }
1833}
1834
1835fn compaction_is_enabled(
1841 capability_configs: &[AgentCapabilityConfig],
1842 registry: &CapabilityRegistry,
1843) -> bool {
1844 capability_configs.iter().any(|cap_config| {
1845 registry.get(cap_config.capability_id()).is_some_and(|cap| {
1846 cap.status() == CapabilityStatus::Available
1847 && cap.compaction_policy(cap_config.config_value()).is_some()
1848 })
1849 })
1850}
1851
1852pub fn collect_message_filters_only(
1858 capability_configs: &[AgentCapabilityConfig],
1859 registry: &CapabilityRegistry,
1860) -> CollectedMessageFilters {
1861 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1862 Vec::new();
1863 let compaction_on = compaction_is_enabled(capability_configs, registry);
1864
1865 for cap_config in capability_configs {
1866 let cap_id = cap_config.capability_id();
1867 if let Some(capability) = registry.get(cap_id) {
1868 if capability.status() != CapabilityStatus::Available {
1869 continue;
1870 }
1871 let effective: &dyn Capability = capability
1874 .resolve_for_model(None)
1875 .unwrap_or_else(|| capability.as_ref());
1876 if let Some(provider) = effective.message_filter_provider() {
1877 let config =
1878 effective.message_filter_config(cap_config.config_value(), compaction_on);
1879 message_filter_providers.push((provider, config));
1880 }
1881 }
1882 }
1883
1884 message_filter_providers.sort_by_key(|(p, _)| p.priority());
1885
1886 CollectedMessageFilters {
1887 message_filter_providers,
1888 }
1889}
1890
1891pub fn collect_model_view_providers(
1898 capability_configs: &[AgentCapabilityConfig],
1899 registry: &CapabilityRegistry,
1900 model: Option<&str>,
1901) -> CollectedModelViewProviders {
1902 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1903
1904 for cap_config in capability_configs {
1905 let cap_id = cap_config.capability_id();
1906 if let Some(capability) = registry.get(cap_id) {
1907 if capability.status() != CapabilityStatus::Available {
1908 continue;
1909 }
1910 let effective: &dyn Capability = capability
1911 .resolve_for_model(model)
1912 .unwrap_or_else(|| capability.as_ref());
1913 if let Some(provider) = effective.model_view_provider() {
1914 model_view_providers.push((provider, cap_config.config_value().clone()));
1915 }
1916 }
1917 }
1918
1919 model_view_providers.sort_by_key(|(p, _)| p.priority());
1920
1921 CollectedModelViewProviders {
1922 model_view_providers,
1923 }
1924}
1925
1926pub fn collect_dynamic_facts(
1932 capability_configs: &[AgentCapabilityConfig],
1933 registry: &CapabilityRegistry,
1934 model: Option<&str>,
1935 ctx: &FactsContext,
1936) -> Vec<Fact> {
1937 let mut dynamic = Vec::new();
1938 for cap_config in capability_configs {
1939 let cap_id = cap_config.capability_id();
1940 if let Some(capability) = registry.get(cap_id) {
1941 if capability.status() != CapabilityStatus::Available {
1942 continue;
1943 }
1944 let effective: &dyn Capability = capability
1945 .resolve_for_model(model)
1946 .unwrap_or_else(|| capability.as_ref());
1947 for fact in effective.facts(cap_config.config_value(), ctx) {
1948 if fact.volatility == Volatility::Dynamic {
1949 dynamic.push(fact);
1950 }
1951 }
1952 }
1953 }
1954 dynamic
1955}
1956
1957pub fn collect_capability_mcp_servers(
1958 capability_configs: &[AgentCapabilityConfig],
1959 registry: &CapabilityRegistry,
1960) -> ScopedMcpServers {
1961 let mut servers = ScopedMcpServers::default();
1962
1963 for cap_config in capability_configs {
1964 let cap_id = cap_config.capability_id();
1965 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
1968 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
1969 cap_config.config_value().clone(),
1970 ) {
1971 if definition.status != CapabilityStatus::Available {
1972 continue;
1973 }
1974 if let Some(contributed) = definition.mcp_servers {
1975 servers = merge_scoped_mcp_servers(&servers, &contributed);
1976 }
1977 }
1978 continue;
1979 }
1980 if let Some(capability) = registry.get(cap_id) {
1981 if capability.status() != CapabilityStatus::Available {
1982 continue;
1983 }
1984 servers = merge_scoped_mcp_servers(
1985 &servers,
1986 &capability.mcp_servers_with_config(cap_config.config_value()),
1987 );
1988 }
1989 }
1990
1991 servers
1992}
1993
1994pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2001
2002#[derive(Debug, Clone, PartialEq, Eq)]
2004pub enum DependencyError {
2005 CircularDependency {
2007 capability_id: String,
2009 chain: Vec<String>,
2011 },
2012 TooManyCapabilities {
2014 count: usize,
2016 max: usize,
2018 },
2019}
2020
2021impl std::fmt::Display for DependencyError {
2022 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2023 match self {
2024 DependencyError::CircularDependency {
2025 capability_id,
2026 chain,
2027 } => {
2028 write!(
2029 f,
2030 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2031 capability_id,
2032 chain.join(" -> "),
2033 capability_id
2034 )
2035 }
2036 DependencyError::TooManyCapabilities { count, max } => {
2037 write!(
2038 f,
2039 "Too many capabilities after resolution: {} (max: {})",
2040 count, max
2041 )
2042 }
2043 }
2044 }
2045}
2046
2047impl std::error::Error for DependencyError {}
2048
2049#[derive(Debug, Clone)]
2051pub struct ResolvedCapabilities {
2052 pub resolved_ids: Vec<String>,
2055 pub added_as_dependencies: Vec<String>,
2057 pub user_selected: Vec<String>,
2059}
2060
2061pub fn resolve_dependencies(
2081 selected_ids: &[String],
2082 registry: &CapabilityRegistry,
2083) -> Result<ResolvedCapabilities, DependencyError> {
2084 use std::collections::HashSet;
2085
2086 let user_selected: HashSet<String> = selected_ids
2088 .iter()
2089 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2090 .collect();
2091 let mut resolved: Vec<String> = Vec::new();
2092 let mut resolved_set: HashSet<String> = HashSet::new();
2093 let mut added_as_dependencies: Vec<String> = Vec::new();
2094
2095 for cap_id in selected_ids {
2097 resolve_single_capability(
2098 cap_id,
2099 registry,
2100 &mut resolved,
2101 &mut resolved_set,
2102 &mut added_as_dependencies,
2103 &user_selected,
2104 &mut Vec::new(), )?;
2106 }
2107
2108 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2110 return Err(DependencyError::TooManyCapabilities {
2111 count: resolved.len(),
2112 max: MAX_RESOLVED_CAPABILITIES,
2113 });
2114 }
2115
2116 Ok(ResolvedCapabilities {
2117 resolved_ids: resolved,
2118 added_as_dependencies,
2119 user_selected: selected_ids.to_vec(),
2120 })
2121}
2122
2123pub fn resolve_capability_configs(
2128 selected_configs: &[AgentCapabilityConfig],
2129 registry: &CapabilityRegistry,
2130) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2131 let mut selected_ids: Vec<String> = Vec::new();
2132 for config in selected_configs {
2133 if (is_declarative_capability(config.capability_id())
2136 || is_plugin_capability(config.capability_id()))
2137 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2138 config.config_value().clone(),
2139 )
2140 {
2141 selected_ids.extend(definition.dependencies);
2142 }
2143 selected_ids.push(config.capability_id().to_string());
2144 }
2145 let resolved = resolve_dependencies(&selected_ids, registry)?;
2146
2147 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2150 .iter()
2151 .map(|config| {
2152 let id = config.capability_id();
2153 let id = registry.canonical_id(id).unwrap_or(id);
2154 (id.to_string(), config.config_value().clone())
2155 })
2156 .collect();
2157
2158 Ok(resolved
2159 .resolved_ids
2160 .into_iter()
2161 .map(|capability_id| {
2162 explicit_configs
2163 .get(&capability_id)
2164 .cloned()
2165 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2166 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2167 })
2168 .collect())
2169}
2170
2171fn resolve_single_capability(
2173 cap_id: &str,
2174 registry: &CapabilityRegistry,
2175 resolved: &mut Vec<String>,
2176 resolved_set: &mut std::collections::HashSet<String>,
2177 added_as_dependencies: &mut Vec<String>,
2178 user_selected: &std::collections::HashSet<String>,
2179 visiting: &mut Vec<String>,
2180) -> Result<(), DependencyError> {
2181 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2185
2186 if resolved_set.contains(cap_id) {
2188 return Ok(());
2189 }
2190
2191 if visiting.contains(&cap_id.to_string()) {
2193 return Err(DependencyError::CircularDependency {
2194 capability_id: cap_id.to_string(),
2195 chain: visiting.clone(),
2196 });
2197 }
2198
2199 let capability = match registry.get(cap_id) {
2201 Some(cap) => cap,
2202 None => {
2203 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2207 && !resolved_set.contains(cap_id)
2208 {
2209 resolved.push(cap_id.to_string());
2210 resolved_set.insert(cap_id.to_string());
2211 if !user_selected.contains(cap_id) {
2212 added_as_dependencies.push(cap_id.to_string());
2213 }
2214 }
2215 return Ok(());
2216 }
2217 };
2218
2219 visiting.push(cap_id.to_string());
2221
2222 for dep_id in capability.dependencies() {
2224 resolve_single_capability(
2225 dep_id,
2226 registry,
2227 resolved,
2228 resolved_set,
2229 added_as_dependencies,
2230 user_selected,
2231 visiting,
2232 )?;
2233 }
2234
2235 visiting.pop();
2237
2238 if !resolved_set.contains(cap_id) {
2240 resolved.push(cap_id.to_string());
2241 resolved_set.insert(cap_id.to_string());
2242
2243 if !user_selected.contains(cap_id) {
2245 added_as_dependencies.push(cap_id.to_string());
2246 }
2247 }
2248
2249 Ok(())
2250}
2251
2252pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2257 use std::collections::HashSet;
2258
2259 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2260 Ok(resolved) => resolved.resolved_ids,
2261 Err(_) => capability_ids.to_vec(),
2262 };
2263
2264 let mut seen = HashSet::new();
2265 let mut features = Vec::new();
2266 for cap_id in &resolved_ids {
2267 if let Some(cap) = registry.get(cap_id) {
2268 for feature in cap.features() {
2269 if seen.insert(feature) {
2270 features.push(feature.to_string());
2271 }
2272 }
2273 }
2274 }
2275 features
2276}
2277
2278pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2281 registry
2282 .get(cap_id)
2283 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2284 .unwrap_or_default()
2285}
2286
2287pub async fn collect_capabilities(
2303 capability_ids: &[String],
2304 registry: &CapabilityRegistry,
2305 ctx: &SystemPromptContext,
2306) -> CollectedCapabilities {
2307 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2310 Ok(resolved) => resolved.resolved_ids,
2311 Err(e) => {
2312 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2313 capability_ids.to_vec()
2314 }
2315 };
2316
2317 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2319 .iter()
2320 .map(|id| {
2321 AgentCapabilityConfig::with_config(
2322 CapabilityId::new(id),
2323 serde_json::Value::Object(serde_json::Map::new()),
2324 )
2325 })
2326 .collect();
2327
2328 collect_capabilities_with_configs(&configs, registry, ctx).await
2329}
2330
2331pub async fn collect_capabilities_with_configs(
2342 capability_configs: &[AgentCapabilityConfig],
2343 registry: &CapabilityRegistry,
2344 ctx: &SystemPromptContext,
2345) -> CollectedCapabilities {
2346 let mut system_prompt_parts: Vec<String> = Vec::new();
2347 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2348 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2349 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2350 let mut mounts: Vec<MountPoint> = Vec::new();
2351 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2352 Vec::new();
2353 let mut applied_ids: Vec<String> = Vec::new();
2354 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2355 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2356 let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2357 let mut parallel_tool_calls: Option<bool> = None;
2358 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2359 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2360 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2363 let mut mcp_servers = ScopedMcpServers::default();
2364 let mut static_facts: Vec<Fact> = Vec::new();
2368 let mut has_dynamic_facts = false;
2369 let facts_ctx = FactsContext::new(ctx.session_id);
2370 let compaction_on = compaction_is_enabled(capability_configs, registry);
2371 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2372
2373 for cap_config in capability_configs {
2374 let cap_id = cap_config.capability_id();
2375 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2380 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2381 cap_config.config_value().clone(),
2382 ) {
2383 Ok(definition) => {
2384 if definition.status != CapabilityStatus::Available {
2385 continue;
2386 }
2387
2388 if let Some(prompt) = definition.system_prompt.as_deref() {
2389 let contribution =
2390 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2391 system_prompt_attributions.push(SystemPromptAttribution {
2392 capability_id: cap_id.to_string(),
2393 content: contribution.clone(),
2394 });
2395 system_prompt_parts.push(contribution);
2396 }
2397
2398 mounts.extend(definition.mounts(cap_id));
2399 if let Some(ref servers) = definition.mcp_servers {
2400 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2401 }
2402 for skill in definition.skill_contributions() {
2403 mounts.push(skill.to_mount(cap_id));
2404 }
2405
2406 applied_ids.push(cap_id.to_string());
2407 }
2408 Err(error) => {
2409 tracing::warn!(
2410 capability_id = %cap_id,
2411 error = %error,
2412 "Skipping invalid declarative/plugin capability config"
2413 );
2414 }
2415 }
2416 continue;
2417 }
2418 if let Some(capability) = registry.get(cap_id) {
2419 if capability.status() != CapabilityStatus::Available {
2421 continue;
2422 }
2423
2424 let effective: &dyn Capability =
2436 match capability.resolve_for_model(ctx.model.as_deref()) {
2437 Some(inner) => inner,
2438 None => capability.as_ref(),
2439 };
2440 let delegation_target =
2441 effective.delegation_target_with_config(cap_config.config_value());
2442
2443 if let Some(contribution) = effective
2445 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2446 .await
2447 {
2448 system_prompt_attributions.push(SystemPromptAttribution {
2449 capability_id: cap_id.to_string(),
2450 content: contribution.clone(),
2451 });
2452 system_prompt_parts.push(contribution);
2453 }
2454
2455 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2460 match fact.volatility {
2461 Volatility::Static => static_facts.push(fact),
2462 Volatility::Dynamic => has_dynamic_facts = true,
2463 }
2464 }
2465
2466 tools.extend(effective.tools_with_config(cap_config.config_value()));
2468 if let Some(target) = delegation_target {
2469 delegation_targets.push(target);
2470 }
2471 tool_definition_hooks.extend(
2472 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2473 );
2474 tool_call_hooks.extend(effective.tool_call_hooks());
2475 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2477 let cap_category = effective.category();
2482 for def in effective.tool_definitions() {
2483 let def = match (def.category(), cap_category) {
2484 (None, Some(cat)) => def.with_category(cat),
2485 _ => def,
2486 }
2487 .with_capability_attribution(cap_id, Some(capability.name()));
2488 tool_definitions.push(def);
2489 }
2490
2491 tool_search = effective
2492 .tool_search_config(cap_config.config_value())
2493 .or(tool_search);
2494 prompt_cache = effective
2495 .prompt_cache_config(cap_config.config_value())
2496 .or(prompt_cache);
2497 parallel_tool_calls = effective
2498 .parallel_tool_calls_preference(cap_config.config_value())
2499 .or(parallel_tool_calls);
2500
2501 openrouter_routing = effective
2502 .openrouter_routing_config(cap_config.config_value())
2503 .or(openrouter_routing);
2504
2505 mounts.extend(effective.mounts());
2507
2508 mcp_servers = merge_scoped_mcp_servers(
2509 &mcp_servers,
2510 &effective.mcp_servers_with_config(cap_config.config_value()),
2511 );
2512
2513 for skill in effective.contribute_skills() {
2517 mounts.push(skill.to_mount(cap_id));
2518 }
2519
2520 if let Some(provider) = effective.message_filter_provider() {
2522 let config =
2523 effective.message_filter_config(cap_config.config_value(), compaction_on);
2524 message_filter_providers.push((provider, config));
2525 }
2526
2527 applied_ids.push(cap_id.to_string());
2528 }
2529 }
2530
2531 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2534 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2535 let def = tool
2536 .to_definition()
2537 .with_category("Orchestration")
2538 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2539 tools.push(Box::new(tool));
2540 tool_definitions.push(def);
2541 }
2542
2543 let auto_activated: Vec<_> = registry
2546 .list()
2547 .into_iter()
2548 .filter(|cap| {
2549 !applied_ids.iter().any(|id| id == cap.id())
2550 && cap.status() == CapabilityStatus::Available
2551 && cap.auto_activates_for(&tool_definitions)
2552 })
2553 .cloned()
2554 .collect();
2555 for cap in auto_activated {
2556 tools.extend(cap.tools());
2557 let cap_category = cap.category();
2558 for def in cap.tool_definitions() {
2559 let def = match (def.category(), cap_category) {
2560 (None, Some(cat)) => def.with_category(cat),
2561 _ => def,
2562 }
2563 .with_capability_attribution(cap.id(), Some(cap.name()));
2564 tool_definitions.push(def);
2565 }
2566 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2567 applied_ids.push(cap.id().to_string());
2568 }
2569
2570 if let Some(block) = facts::render_facts_block(&static_facts) {
2575 system_prompt_attributions.push(SystemPromptAttribution {
2576 capability_id: "facts".to_string(),
2577 content: block.clone(),
2578 });
2579 system_prompt_parts.push(block);
2580 }
2581 if has_dynamic_facts {
2582 system_prompt_attributions.push(SystemPromptAttribution {
2583 capability_id: "facts".to_string(),
2584 content: FACTS_DYNAMIC_NOTE.to_string(),
2585 });
2586 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2587 }
2588
2589 tool_call_hooks.extend(narration_hooks);
2593
2594 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2596
2597 CollectedCapabilities {
2598 system_prompt_parts,
2599 system_prompt_attributions,
2600 tools,
2601 tool_definitions,
2602 mounts,
2603 message_filter_providers,
2604 applied_ids,
2605 tool_search,
2606 prompt_cache,
2607 openrouter_routing,
2608 parallel_tool_calls,
2609 tool_definition_hooks,
2610 tool_call_hooks,
2611 mcp_servers,
2612 }
2613}
2614
2615pub struct AppliedCapabilities {
2621 pub runtime_agent: RuntimeAgent,
2623 pub tool_registry: ToolRegistry,
2625 pub applied_ids: Vec<String>,
2627}
2628
2629pub async fn apply_capabilities(
2665 base_runtime_agent: RuntimeAgent,
2666 capability_ids: &[String],
2667 registry: &CapabilityRegistry,
2668 ctx: &SystemPromptContext,
2669) -> AppliedCapabilities {
2670 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2671
2672 let final_system_prompt = compose_system_prompt(
2674 &base_runtime_agent.system_prompt,
2675 collected.system_prompt_prefix().as_deref(),
2676 );
2677
2678 let mut tool_registry = ToolRegistry::new();
2680 for tool in collected.tools {
2681 tool_registry.register_boxed(tool);
2682 }
2683
2684 let mut tools = collected.tool_definitions;
2686 for hook in &collected.tool_definition_hooks {
2687 tools = hook.transform(tools);
2688 }
2689
2690 let runtime_agent = RuntimeAgent {
2691 system_prompt: final_system_prompt,
2692 model: base_runtime_agent.model,
2693 tools,
2694 max_iterations: base_runtime_agent.max_iterations,
2695 temperature: base_runtime_agent.temperature,
2696 max_tokens: base_runtime_agent.max_tokens,
2697 tool_search: collected.tool_search,
2698 prompt_cache: collected.prompt_cache,
2699 openrouter_routing: collected.openrouter_routing,
2700 network_access: base_runtime_agent.network_access,
2701 parallel_tool_calls: base_runtime_agent
2704 .parallel_tool_calls
2705 .or(collected.parallel_tool_calls),
2706 };
2707
2708 AppliedCapabilities {
2709 runtime_agent,
2710 tool_registry,
2711 applied_ids: collected.applied_ids,
2712 }
2713}
2714
2715#[cfg(test)]
2720mod tests {
2721 use super::*;
2722 use crate::typed_id::SessionId;
2723 use uuid::Uuid;
2724
2725 fn test_ctx() -> SystemPromptContext {
2727 SystemPromptContext::without_file_store(SessionId::new())
2728 }
2729
2730 struct StubSubagentSpawnTool;
2739
2740 #[async_trait]
2741 impl Tool for StubSubagentSpawnTool {
2742 fn name(&self) -> &str {
2743 "spawn_agent"
2744 }
2745 fn description(&self) -> &str {
2746 "stub subagent delegation"
2747 }
2748 fn parameters_schema(&self) -> serde_json::Value {
2749 serde_json::json!({ "type": "object" })
2750 }
2751 fn narrate(
2752 &self,
2753 tool_call: &ToolCall,
2754 phase: crate::tool_narration::ToolNarrationPhase,
2755 locale: Option<&str>,
2756 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2757 ) -> Option<String> {
2758 Some(crate::tool_narration::narrate_subagent_spawn(
2759 &tool_call.arguments,
2760 phase,
2761 locale,
2762 ))
2763 }
2764 async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2765 crate::ToolExecutionResult::success(serde_json::json!({}))
2766 }
2767 }
2768
2769 fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2770 ToolCall {
2771 id: "call-1".to_string(),
2772 name: "spawn_agent".to_string(),
2773 arguments,
2774 }
2775 }
2776
2777 #[test]
2780 fn unified_spawn_agent_narration_names_the_agent() {
2781 let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
2782 target_type: "subagent",
2783 tool: Box::new(StubSubagentSpawnTool),
2784 }]);
2785 let ctx = crate::tool_narration::ToolNarrationContext::default();
2786
2787 assert_eq!(
2788 tool.narrate(
2789 &spawn_agent_call(serde_json::json!({
2790 "name": "Orbit Scout",
2791 "target": { "type": "subagent" },
2792 "blueprint": "github_scout"
2793 })),
2794 crate::tool_narration::ToolNarrationPhase::Started,
2795 None,
2796 ctx,
2797 )
2798 .as_deref(),
2799 Some("Launching Orbit Scout subagent (github_scout)")
2800 );
2801
2802 assert_eq!(
2803 tool.narrate(
2804 &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
2805 crate::tool_narration::ToolNarrationPhase::Started,
2806 None,
2807 ctx,
2808 )
2809 .as_deref(),
2810 Some("Launching Orbit Scout subagent")
2811 );
2812 }
2813
2814 struct NoopFixture;
2816
2817 impl Capability for NoopFixture {
2818 fn id(&self) -> &str {
2819 "noop"
2820 }
2821 fn name(&self) -> &str {
2822 "No-Op"
2823 }
2824 fn description(&self) -> &str {
2825 "Contributes nothing."
2826 }
2827 }
2828
2829 struct FeatureFixture;
2831
2832 impl Capability for FeatureFixture {
2833 fn id(&self) -> &str {
2834 "feature_fixture"
2835 }
2836 fn name(&self) -> &str {
2837 "Feature Fixture"
2838 }
2839 fn description(&self) -> &str {
2840 "Declares one test-only feature."
2841 }
2842 fn features(&self) -> Vec<&'static str> {
2843 vec!["fixture_feature"]
2844 }
2845 }
2846
2847 struct FixtureTool(&'static str);
2848
2849 #[async_trait]
2850 impl Tool for FixtureTool {
2851 fn name(&self) -> &str {
2852 self.0
2853 }
2854 fn description(&self) -> &str {
2855 "Fixture tool."
2856 }
2857 fn parameters_schema(&self) -> serde_json::Value {
2858 serde_json::json!({
2859 "type": "object",
2860 "properties": {},
2861 "additionalProperties": false
2862 })
2863 }
2864 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2865 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2866 }
2867 }
2868
2869 struct BackgroundFixtureTool;
2870
2871 #[async_trait]
2872 impl Tool for BackgroundFixtureTool {
2873 fn name(&self) -> &str {
2874 "bash"
2875 }
2876 fn description(&self) -> &str {
2877 "Fixture background-capable shell tool."
2878 }
2879 fn parameters_schema(&self) -> serde_json::Value {
2880 serde_json::json!({"type": "object"})
2881 }
2882 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2883 ToolExecutionResult::success(serde_json::json!({"ok": true}))
2884 }
2885 fn hints(&self) -> crate::tool_types::ToolHints {
2886 crate::tool_types::ToolHints {
2887 supports_background: Some(true),
2888 ..Default::default()
2889 }
2890 }
2891 }
2892
2893 struct FileSystemFixture;
2894
2895 impl Capability for FileSystemFixture {
2896 fn id(&self) -> &str {
2897 "session_file_system"
2898 }
2899 fn name(&self) -> &str {
2900 "Fixture Filesystem"
2901 }
2902 fn description(&self) -> &str {
2903 "Fixture filesystem capability."
2904 }
2905 fn tools(&self) -> Vec<Box<dyn Tool>> {
2906 vec![
2907 Box::new(FixtureTool("read_file")),
2908 Box::new(FixtureTool("write_file")),
2909 ]
2910 }
2911 fn features(&self) -> Vec<&'static str> {
2912 vec!["file_system"]
2913 }
2914 }
2915
2916 struct StorageFixture;
2921
2922 impl Capability for StorageFixture {
2923 fn id(&self) -> &str {
2924 "session_storage"
2925 }
2926 fn name(&self) -> &str {
2927 "Fixture Storage"
2928 }
2929 fn description(&self) -> &str {
2930 "Fixture session storage capability."
2931 }
2932 fn features(&self) -> Vec<&'static str> {
2933 vec!["secrets", "key_value"]
2934 }
2935 }
2936
2937 struct BashFixture;
2938
2939 impl Capability for BashFixture {
2940 fn id(&self) -> &str {
2941 "bashkit_shell"
2942 }
2943 fn aliases(&self) -> Vec<&'static str> {
2944 vec!["virtual_bash"]
2945 }
2946 fn name(&self) -> &str {
2947 "Fixture Bash"
2948 }
2949 fn description(&self) -> &str {
2950 "Fixture shell capability."
2951 }
2952 fn tools(&self) -> Vec<Box<dyn Tool>> {
2953 vec![Box::new(BackgroundFixtureTool)]
2954 }
2955 fn dependencies(&self) -> Vec<&'static str> {
2956 vec!["session_file_system"]
2957 }
2958 fn features(&self) -> Vec<&'static str> {
2959 vec!["file_system"]
2960 }
2961 fn risk_level(&self) -> RiskLevel {
2962 RiskLevel::High
2963 }
2964 }
2965
2966 struct WebFetchFixture;
2967
2968 impl Capability for WebFetchFixture {
2969 fn id(&self) -> &str {
2970 "web_fetch"
2971 }
2972 fn name(&self) -> &str {
2973 "Fixture Web Fetch"
2974 }
2975 fn description(&self) -> &str {
2976 "Fixture web capability."
2977 }
2978 fn risk_level(&self) -> RiskLevel {
2979 RiskLevel::High
2980 }
2981 }
2982
2983 struct DynamicFactFixture;
2986
2987 impl Capability for DynamicFactFixture {
2988 fn id(&self) -> &str {
2989 "current_time"
2990 }
2991 fn name(&self) -> &str {
2992 "Dynamic Fact Fixture"
2993 }
2994 fn description(&self) -> &str {
2995 "Fixture with one dynamic fact and one tool."
2996 }
2997 fn icon(&self) -> Option<&str> {
2998 Some("clock")
2999 }
3000 fn category(&self) -> Option<&str> {
3001 Some("Core")
3002 }
3003 fn tools(&self) -> Vec<Box<dyn Tool>> {
3004 vec![Box::new(FixtureTool("get_current_time"))]
3005 }
3006 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3007 vec![Fact::dynamic("current_time", "fixture-now")]
3008 }
3009 }
3010
3011 struct PromptToolFixture;
3012
3013 impl Capability for PromptToolFixture {
3014 fn id(&self) -> &str {
3015 "prompt_tool_fixture"
3016 }
3017 fn name(&self) -> &str {
3018 "Prompt Tool Fixture"
3019 }
3020 fn description(&self) -> &str {
3021 "Fixture with a static prompt and tool."
3022 }
3023 fn system_prompt_addition(&self) -> Option<&str> {
3024 Some("Task Management uses the write_todos tool.")
3025 }
3026 fn tools(&self) -> Vec<Box<dyn Tool>> {
3027 vec![Box::new(FixtureTool("write_todos"))]
3028 }
3029 }
3030
3031 struct SecondPromptFixture;
3032
3033 impl Capability for SecondPromptFixture {
3034 fn id(&self) -> &str {
3035 "second_prompt_fixture"
3036 }
3037 fn name(&self) -> &str {
3038 "Second Prompt Fixture"
3039 }
3040 fn description(&self) -> &str {
3041 "Fixture with a second static prompt."
3042 }
3043 fn system_prompt_addition(&self) -> Option<&str> {
3044 Some("A second capability prompt contribution.")
3045 }
3046 }
3047
3048 struct DynamicPreviewFixture;
3049
3050 impl Capability for DynamicPreviewFixture {
3051 fn id(&self) -> &str {
3052 "agent_instructions"
3053 }
3054 fn name(&self) -> &str {
3055 "Dynamic Preview Fixture"
3056 }
3057 fn description(&self) -> &str {
3058 "Fixture whose runtime prompt is dynamic."
3059 }
3060 fn system_prompt_preview(&self) -> Option<String> {
3061 Some("Reads AGENTS.md dynamically.".to_string())
3062 }
3063 }
3064
3065 struct MathFixture;
3067
3068 impl Capability for MathFixture {
3069 fn id(&self) -> &str {
3070 "test_math"
3071 }
3072 fn name(&self) -> &str {
3073 "Test Math"
3074 }
3075 fn description(&self) -> &str {
3076 "Fixture: calculator tools."
3077 }
3078 fn tools(&self) -> Vec<Box<dyn Tool>> {
3079 vec![
3080 Box::new(FixtureTool("add")),
3081 Box::new(FixtureTool("subtract")),
3082 Box::new(FixtureTool("multiply")),
3083 Box::new(FixtureTool("divide")),
3084 ]
3085 }
3086 }
3087
3088 struct WeatherFixture;
3090
3091 impl Capability for WeatherFixture {
3092 fn id(&self) -> &str {
3093 "test_weather"
3094 }
3095 fn name(&self) -> &str {
3096 "Test Weather"
3097 }
3098 fn description(&self) -> &str {
3099 "Fixture: weather tools."
3100 }
3101 fn tools(&self) -> Vec<Box<dyn Tool>> {
3102 vec![
3103 Box::new(FixtureTool("get_weather")),
3104 Box::new(FixtureTool("get_forecast")),
3105 ]
3106 }
3107 }
3108
3109 struct SampleDataFixture;
3112
3113 impl Capability for SampleDataFixture {
3114 fn id(&self) -> &str {
3115 "sample_data"
3116 }
3117 fn name(&self) -> &str {
3118 "Sample Data"
3119 }
3120 fn description(&self) -> &str {
3121 "Fixture: mounted sample files."
3122 }
3123 fn system_prompt_addition(&self) -> Option<&str> {
3124 Some("Read-only sample files are mounted at `/samples`.")
3125 }
3126 fn mounts(&self) -> Vec<MountPoint> {
3127 let samples_dir = MountDirectoryBuilder::new()
3128 .file("users.json", "[]")
3129 .build();
3130 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3131 }
3132 fn dependencies(&self) -> Vec<&'static str> {
3133 vec!["session_file_system"]
3134 }
3135 fn features(&self) -> Vec<&'static str> {
3136 vec!["file_system"]
3137 }
3138 }
3139
3140 fn fixture_registry() -> CapabilityRegistry {
3142 let mut registry = CapabilityRegistry::new();
3143 registry.register(NoopFixture);
3144 registry.register(FeatureFixture);
3145 registry.register(MathFixture);
3146 registry.register(WeatherFixture);
3147 registry.register(SampleDataFixture);
3148 registry.register(FileSystemFixture);
3149 registry.register(StorageFixture);
3150 registry.register(BashFixture);
3151 registry.register(WebFetchFixture);
3152 registry.register(DynamicFactFixture);
3153 registry.register(PromptToolFixture);
3154 registry.register(SecondPromptFixture);
3155 registry.register(DynamicPreviewFixture);
3156 registry
3157 }
3158
3159 struct HostAnnotatedCapability;
3161
3162 #[async_trait]
3163 impl Capability for HostAnnotatedCapability {
3164 fn id(&self) -> &str {
3165 "host_annotated"
3166 }
3167 fn name(&self) -> &str {
3168 "Host Annotated"
3169 }
3170 fn description(&self) -> &str {
3171 "Test capability with host-owned metadata."
3172 }
3173 fn metadata(&self) -> Option<serde_json::Value> {
3174 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3175 }
3176 }
3177
3178 #[test]
3179 fn capability_metadata_is_an_opt_in_host_hatch() {
3180 let metadata = HostAnnotatedCapability.metadata().expect("metadata");
3181 assert_eq!(metadata["icon"], "sparkles");
3182 assert_eq!(metadata["group"], "host");
3183 }
3184
3185 #[test]
3186 fn test_capability_registry_get() {
3187 let mut registry = CapabilityRegistry::new();
3188 registry.register(NoopFixture);
3189
3190 let capability = registry.get("noop").unwrap();
3191 assert_eq!(capability.id(), "noop");
3192 assert_eq!(capability.status(), CapabilityStatus::Available);
3193 }
3194
3195 #[test]
3196 fn default_registry_is_empty_and_selects_no_product_preset() {
3197 assert!(CapabilityRegistry::default().is_empty());
3198 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3199 }
3200
3201 #[test]
3202 fn test_capability_registry_blueprint_with_capability() {
3203 struct BlueprintProviderCapability;
3204
3205 impl Capability for BlueprintProviderCapability {
3206 fn id(&self) -> &str {
3207 "blueprint_provider"
3208 }
3209 fn name(&self) -> &str {
3210 "Blueprint Provider"
3211 }
3212 fn description(&self) -> &str {
3213 "Capability that provides a blueprint for tests"
3214 }
3215 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3216 vec![AgentBlueprint {
3217 id: "test_blueprint",
3218 name: "Test Blueprint",
3219 description: "Blueprint for capability registry tests",
3220 model: BlueprintModel::Inherit,
3221 system_prompt: "Test prompt",
3222 tools: vec![],
3223 max_turns: None,
3224 config_schema: None,
3225 }]
3226 }
3227 }
3228
3229 let mut registry = CapabilityRegistry::new();
3230 registry.register(BlueprintProviderCapability);
3231
3232 let (capability_id, blueprint) = registry
3233 .blueprint_with_capability("test_blueprint")
3234 .expect("blueprint should resolve with capability id");
3235 assert_eq!(capability_id, "blueprint_provider");
3236 assert_eq!(blueprint.id, "test_blueprint");
3237 }
3238
3239 #[test]
3240 fn test_capability_registry_builder() {
3241 let registry = CapabilityRegistry::builder()
3242 .capability(NoopFixture)
3243 .build();
3244
3245 assert!(registry.has("noop"));
3246 assert_eq!(registry.len(), 1);
3247 }
3248
3249 #[test]
3250 fn test_capability_status() {
3251 struct ComingSoonFixture;
3252 impl Capability for ComingSoonFixture {
3253 fn id(&self) -> &str {
3254 "coming_soon_fixture"
3255 }
3256 fn name(&self) -> &str {
3257 "Coming Soon Fixture"
3258 }
3259 fn description(&self) -> &str {
3260 "Test-only capability."
3261 }
3262 fn status(&self) -> CapabilityStatus {
3263 CapabilityStatus::ComingSoon
3264 }
3265 }
3266 assert_eq!(ComingSoonFixture.status(), CapabilityStatus::ComingSoon);
3267 }
3268
3269 #[test]
3270 fn test_capability_icons_and_categories_default_none() {
3271 assert!(NoopFixture.icon().is_none());
3272 assert!(NoopFixture.category().is_none());
3273 }
3274
3275 #[test]
3276 fn test_system_prompt_preview_default_delegates_to_addition() {
3277 struct StaticPromptCapability;
3280 impl Capability for StaticPromptCapability {
3281 fn id(&self) -> &str {
3282 "static_prompt"
3283 }
3284 fn name(&self) -> &str {
3285 "Static Prompt"
3286 }
3287 fn description(&self) -> &str {
3288 "Static prompt addition."
3289 }
3290 fn system_prompt_addition(&self) -> Option<&str> {
3291 Some("Use the static prompt.")
3292 }
3293 }
3294
3295 let cap = StaticPromptCapability;
3296 assert_eq!(
3297 cap.system_prompt_preview().as_deref(),
3298 cap.system_prompt_addition()
3299 );
3300
3301 let registry = fixture_registry();
3303 let current_time = registry.get("current_time").unwrap();
3304 assert!(current_time.system_prompt_preview().is_none());
3305 assert!(current_time.system_prompt_addition().is_none());
3306 }
3307
3308 #[test]
3309 fn test_system_prompt_preview_dynamic_capability() {
3310 let registry = fixture_registry();
3311 let cap = registry.get("agent_instructions").unwrap();
3312
3313 assert!(cap.system_prompt_addition().is_none());
3315 assert!(cap.system_prompt_preview().is_some());
3316 assert!(cap.system_prompt_preview().unwrap().contains("AGENTS.md"));
3317 }
3318
3319 #[tokio::test]
3324 async fn test_apply_capabilities_empty() {
3325 let registry = CapabilityRegistry::new();
3326 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3327
3328 let applied =
3329 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3330
3331 assert_eq!(
3332 applied.runtime_agent.system_prompt,
3333 base_runtime_agent.system_prompt
3334 );
3335 assert!(applied.tool_registry.is_empty());
3336 assert!(applied.applied_ids.is_empty());
3337 }
3338
3339 #[tokio::test]
3340 async fn test_apply_capabilities_noop() {
3341 let registry = fixture_registry();
3342 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3343
3344 let applied = apply_capabilities(
3345 base_runtime_agent.clone(),
3346 &["noop".to_string()],
3347 ®istry,
3348 &test_ctx(),
3349 )
3350 .await;
3351
3352 assert_eq!(
3354 applied.runtime_agent.system_prompt,
3355 base_runtime_agent.system_prompt
3356 );
3357 assert!(applied.tool_registry.is_empty());
3358 assert_eq!(applied.applied_ids, vec!["noop"]);
3359 }
3360
3361 #[tokio::test]
3362 async fn test_apply_capabilities_current_time() {
3363 let registry = fixture_registry();
3364 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3365
3366 let applied = apply_capabilities(
3367 base_runtime_agent.clone(),
3368 &["current_time".to_string()],
3369 ®istry,
3370 &test_ctx(),
3371 )
3372 .await;
3373
3374 assert!(
3378 applied
3379 .runtime_agent
3380 .system_prompt
3381 .contains(FACTS_DYNAMIC_NOTE),
3382 "current_time should contribute the dynamic-facts note"
3383 );
3384 assert!(
3385 applied
3386 .runtime_agent
3387 .system_prompt
3388 .contains(&base_runtime_agent.system_prompt),
3389 "base prompt is preserved"
3390 );
3391 assert!(applied.tool_registry.has("get_current_time"));
3392 assert_eq!(applied.tool_registry.len(), 1);
3393 assert_eq!(applied.applied_ids, vec!["current_time"]);
3394 }
3395
3396 #[tokio::test]
3397 async fn test_apply_capabilities_skips_coming_soon() {
3398 struct ComingSoonFixture;
3399 impl Capability for ComingSoonFixture {
3400 fn id(&self) -> &str {
3401 "coming_soon_fixture"
3402 }
3403 fn name(&self) -> &str {
3404 "Coming Soon Fixture"
3405 }
3406 fn description(&self) -> &str {
3407 "Test-only capability."
3408 }
3409 fn status(&self) -> CapabilityStatus {
3410 CapabilityStatus::ComingSoon
3411 }
3412 fn system_prompt_addition(&self) -> Option<&str> {
3413 Some("Not yet available.")
3414 }
3415 }
3416 let mut registry = CapabilityRegistry::new();
3417 registry.register(ComingSoonFixture);
3418 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3419
3420 let applied = apply_capabilities(
3421 base_runtime_agent.clone(),
3422 &["coming_soon_fixture".to_string()],
3423 ®istry,
3424 &test_ctx(),
3425 )
3426 .await;
3427
3428 assert_eq!(
3429 applied.runtime_agent.system_prompt,
3430 base_runtime_agent.system_prompt
3431 );
3432 assert!(applied.applied_ids.is_empty());
3433 }
3434
3435 #[tokio::test]
3436 async fn test_apply_capabilities_multiple() {
3437 let registry = fixture_registry();
3438 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3439
3440 let applied = apply_capabilities(
3441 base_runtime_agent.clone(),
3442 &["noop".to_string(), "current_time".to_string()],
3443 ®istry,
3444 &test_ctx(),
3445 )
3446 .await;
3447
3448 assert!(applied.tool_registry.has("get_current_time"));
3449 assert_eq!(applied.applied_ids, vec!["noop", "current_time"]);
3450 }
3451
3452 #[tokio::test]
3453 async fn test_apply_capabilities_preserves_order() {
3454 let registry = fixture_registry();
3455 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3456
3457 let applied = apply_capabilities(
3459 base_runtime_agent,
3460 &["current_time".to_string(), "noop".to_string()],
3461 ®istry,
3462 &test_ctx(),
3463 )
3464 .await;
3465
3466 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3467 }
3468
3469 #[tokio::test]
3470 async fn test_apply_capabilities_test_math() {
3471 let registry = fixture_registry();
3472 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3473
3474 let applied = apply_capabilities(
3475 base_runtime_agent.clone(),
3476 &["test_math".to_string()],
3477 ®istry,
3478 &test_ctx(),
3479 )
3480 .await;
3481
3482 assert!(
3484 !applied
3485 .runtime_agent
3486 .system_prompt
3487 .contains("<capability id=\"test_math\">")
3488 );
3489 assert!(
3491 applied
3492 .runtime_agent
3493 .system_prompt
3494 .contains("You are a helpful assistant.")
3495 );
3496 assert!(applied.tool_registry.has("add"));
3497 assert!(applied.tool_registry.has("subtract"));
3498 assert!(applied.tool_registry.has("multiply"));
3499 assert!(applied.tool_registry.has("divide"));
3500 assert_eq!(applied.tool_registry.len(), 4);
3501 }
3502
3503 #[tokio::test]
3504 async fn test_apply_capabilities_test_weather() {
3505 let registry = fixture_registry();
3506 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3507
3508 let applied = apply_capabilities(
3509 base_runtime_agent.clone(),
3510 &["test_weather".to_string()],
3511 ®istry,
3512 &test_ctx(),
3513 )
3514 .await;
3515
3516 assert!(
3518 !applied
3519 .runtime_agent
3520 .system_prompt
3521 .contains("<capability id=\"test_weather\">")
3522 );
3523 assert!(applied.tool_registry.has("get_weather"));
3524 assert!(applied.tool_registry.has("get_forecast"));
3525 assert_eq!(applied.tool_registry.len(), 2);
3526 }
3527
3528 #[tokio::test]
3529 async fn test_apply_capabilities_test_math_and_test_weather() {
3530 let registry = fixture_registry();
3531 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3532
3533 let applied = apply_capabilities(
3534 base_runtime_agent.clone(),
3535 &["test_math".to_string(), "test_weather".to_string()],
3536 ®istry,
3537 &test_ctx(),
3538 )
3539 .await;
3540
3541 assert_eq!(applied.tool_registry.len(), 6); assert!(applied.tool_registry.has("add"));
3544 assert!(applied.tool_registry.has("get_weather"));
3545 }
3546
3547 #[tokio::test]
3548 async fn test_apply_capabilities_prompt_tool_fixture() {
3549 let registry = fixture_registry();
3550 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3551
3552 let applied = apply_capabilities(
3553 base_runtime_agent.clone(),
3554 &["prompt_tool_fixture".to_string()],
3555 ®istry,
3556 &test_ctx(),
3557 )
3558 .await;
3559
3560 assert!(
3562 applied
3563 .runtime_agent
3564 .system_prompt
3565 .contains("Task Management")
3566 );
3567 assert!(applied.runtime_agent.system_prompt.contains("write_todos"));
3568 assert!(applied.tool_registry.has("write_todos"));
3569 assert_eq!(applied.tool_registry.len(), 1);
3570 }
3571
3572 #[tokio::test]
3577 async fn test_xml_tags_wrap_capability_prompts() {
3578 let registry = fixture_registry();
3579 let collected =
3580 collect_capabilities(&["prompt_tool_fixture".to_string()], ®istry, &test_ctx())
3581 .await;
3582
3583 assert_eq!(collected.system_prompt_parts.len(), 1);
3584 let part = &collected.system_prompt_parts[0];
3585 assert!(part.starts_with("<capability id=\"prompt_tool_fixture\">"));
3586 assert!(part.ends_with("</capability>"));
3587 assert!(part.contains("Task Management"));
3588 }
3589
3590 #[tokio::test]
3591 async fn test_xml_tags_multiple_capabilities() {
3592 let registry = fixture_registry();
3593 let collected = collect_capabilities(
3594 &[
3595 "prompt_tool_fixture".to_string(),
3596 "second_prompt_fixture".to_string(),
3597 ],
3598 ®istry,
3599 &test_ctx(),
3600 )
3601 .await;
3602
3603 assert_eq!(collected.system_prompt_parts.len(), 2);
3604 assert!(
3605 collected.system_prompt_parts[0].starts_with("<capability id=\"prompt_tool_fixture\">")
3606 );
3607 assert!(
3608 collected.system_prompt_parts[1]
3609 .starts_with("<capability id=\"second_prompt_fixture\">")
3610 );
3611
3612 let prefix = collected.system_prompt_prefix().unwrap();
3613 assert!(prefix.contains("</capability>\n\n<capability"));
3615 }
3616
3617 #[tokio::test]
3618 async fn test_xml_tags_system_prompt_wrapping() {
3619 let registry = fixture_registry();
3620 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3621
3622 let applied = apply_capabilities(
3623 base,
3624 &["prompt_tool_fixture".to_string()],
3625 ®istry,
3626 &test_ctx(),
3627 )
3628 .await;
3629
3630 let prompt = &applied.runtime_agent.system_prompt;
3631 assert!(prompt.starts_with("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3632 assert!(prompt.contains("<capability id=\"prompt_tool_fixture\">"));
3634 assert!(prompt.contains("</capability>"));
3635 assert!(prompt.contains("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3637 }
3638
3639 #[tokio::test]
3640 async fn test_no_xml_wrapping_without_capabilities() {
3641 let registry = CapabilityRegistry::new();
3642 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3643
3644 let applied = apply_capabilities(base, &[], ®istry, &test_ctx()).await;
3645
3646 assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3648 assert!(
3649 !applied
3650 .runtime_agent
3651 .system_prompt
3652 .contains("<system-prompt>")
3653 );
3654 }
3655
3656 #[tokio::test]
3657 async fn test_no_xml_wrapping_for_noop_capability() {
3658 let registry = fixture_registry();
3659 let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3660
3661 let applied = apply_capabilities(base, &["noop".to_string()], ®istry, &test_ctx()).await;
3663
3664 assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3665 assert!(
3666 !applied
3667 .runtime_agent
3668 .system_prompt
3669 .contains("<system-prompt>")
3670 );
3671 }
3672
3673 #[tokio::test]
3678 async fn test_collect_capabilities_includes_mounts() {
3679 let registry = fixture_registry();
3680
3681 let collected =
3682 collect_capabilities(&["sample_data".to_string()], ®istry, &test_ctx()).await;
3683
3684 assert!(!collected.mounts.is_empty());
3685 assert_eq!(collected.mounts.len(), 1);
3686 assert_eq!(collected.mounts[0].path, "/samples");
3687 assert!(collected.mounts[0].is_readonly());
3688 }
3689
3690 #[tokio::test]
3691 async fn test_collect_capabilities_empty_mounts_by_default() {
3692 let registry = fixture_registry();
3693
3694 let collected =
3696 collect_capabilities(&["current_time".to_string()], ®istry, &test_ctx()).await;
3697
3698 assert!(collected.mounts.is_empty());
3699 }
3700
3701 #[tokio::test]
3702 async fn test_dynamic_facts_add_note_without_static_block() {
3703 let registry = fixture_registry();
3707 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3708 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3709 let prompt = collected.system_prompt_parts.join("\n");
3710 assert!(
3711 prompt.contains(FACTS_DYNAMIC_NOTE),
3712 "dynamic-facts note should be in the cached prompt"
3713 );
3714 assert!(
3715 !prompt.contains("<facts>\n"),
3716 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3717 );
3718 }
3719
3720 #[tokio::test]
3721 async fn test_static_facts_fold_into_prompt() {
3722 struct StaticFactCap;
3723 impl Capability for StaticFactCap {
3724 fn id(&self) -> &str {
3725 "test_static_fact"
3726 }
3727 fn name(&self) -> &str {
3728 "Static Fact"
3729 }
3730 fn description(&self) -> &str {
3731 "test"
3732 }
3733 fn status(&self) -> CapabilityStatus {
3734 CapabilityStatus::Available
3735 }
3736 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3737 vec![Fact::stat("workspace_root", "/workspace")]
3738 }
3739 }
3740 let mut registry = CapabilityRegistry::new();
3741 registry.register(StaticFactCap);
3742 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3743 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3744 let prompt = collected.system_prompt_parts.join("\n");
3745 assert!(
3746 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3747 "static fact should fold into the cached prompt; got: {prompt}"
3748 );
3749 assert!(
3750 !prompt.contains(FACTS_DYNAMIC_NOTE),
3751 "no dynamic note when only static facts exist"
3752 );
3753 }
3754
3755 #[test]
3756 fn test_collect_dynamic_facts_returns_current_time() {
3757 let registry = fixture_registry();
3758 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3759 let facts = collect_dynamic_facts(
3760 &configs,
3761 ®istry,
3762 None,
3763 &FactsContext::new(SessionId::new()),
3764 );
3765 assert_eq!(facts.len(), 1);
3766 assert_eq!(facts[0].key, "current_time");
3767 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3768 }
3769
3770 #[tokio::test]
3771 async fn test_collect_capabilities_combines_mounts() {
3772 let registry = fixture_registry();
3773
3774 let collected = collect_capabilities(
3777 &["sample_data".to_string(), "current_time".to_string()],
3778 ®istry,
3779 &test_ctx(),
3780 )
3781 .await;
3782
3783 assert_eq!(collected.mounts.len(), 1);
3784 assert!(
3786 collected
3787 .applied_ids
3788 .iter()
3789 .any(|id| id == "session_file_system")
3790 );
3791 assert!(collected.applied_ids.iter().any(|id| id == "sample_data"));
3792 assert!(collected.applied_ids.iter().any(|id| id == "current_time"));
3793 }
3794
3795 #[test]
3796 fn test_sample_data_capability() {
3797 let registry = fixture_registry();
3798 let cap = registry.get("sample_data").unwrap();
3799
3800 assert_eq!(cap.id(), "sample_data");
3801 assert_eq!(cap.name(), "Sample Data");
3802 assert_eq!(cap.status(), CapabilityStatus::Available);
3803
3804 assert!(cap.system_prompt_addition().is_some());
3806 assert!(cap.tools().is_empty());
3807
3808 assert!(!cap.mounts().is_empty());
3810 }
3811
3812 #[test]
3817 fn test_resolve_dependencies_empty() {
3818 let registry = CapabilityRegistry::new();
3819
3820 let resolved = resolve_dependencies(&[], ®istry).unwrap();
3821
3822 assert!(resolved.resolved_ids.is_empty());
3823 assert!(resolved.added_as_dependencies.is_empty());
3824 assert!(resolved.user_selected.is_empty());
3825 }
3826
3827 #[test]
3828 fn test_resolve_dependencies_no_deps() {
3829 let registry = fixture_registry();
3830
3831 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
3833
3834 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3835 assert!(resolved.added_as_dependencies.is_empty());
3836 }
3837
3838 #[test]
3839 fn test_resolve_dependencies_with_deps() {
3840 let registry = fixture_registry();
3841
3842 let resolved = resolve_dependencies(&["sample_data".to_string()], ®istry).unwrap();
3844
3845 assert_eq!(resolved.resolved_ids.len(), 2);
3847 let fs_pos = resolved
3848 .resolved_ids
3849 .iter()
3850 .position(|id| id == "session_file_system")
3851 .unwrap();
3852 let sd_pos = resolved
3853 .resolved_ids
3854 .iter()
3855 .position(|id| id == "sample_data")
3856 .unwrap();
3857 assert!(fs_pos < sd_pos, "FileSystem should come before SampleData");
3858
3859 assert_eq!(resolved.added_as_dependencies, vec!["session_file_system"]);
3861 }
3862
3863 #[test]
3864 fn test_resolve_dependencies_already_selected() {
3865 let registry = fixture_registry();
3866
3867 let resolved = resolve_dependencies(
3869 &["session_file_system".to_string(), "sample_data".to_string()],
3870 ®istry,
3871 )
3872 .unwrap();
3873
3874 assert_eq!(resolved.resolved_ids.len(), 2);
3875 assert!(resolved.added_as_dependencies.is_empty());
3877 }
3878
3879 #[test]
3880 fn test_resolve_dependencies_preserves_order() {
3881 let registry = fixture_registry();
3882
3883 let resolved =
3885 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
3886 .unwrap();
3887
3888 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3889 }
3890
3891 #[test]
3892 fn test_resolve_dependencies_unknown_capability() {
3893 let registry = CapabilityRegistry::new();
3894
3895 let resolved =
3897 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
3898
3899 assert!(resolved.resolved_ids.is_empty());
3900 }
3901
3902 #[test]
3903 fn test_get_dependencies() {
3904 let registry = fixture_registry();
3905
3906 let deps = get_dependencies("sample_data", ®istry);
3908 assert_eq!(deps, vec!["session_file_system"]);
3909
3910 let deps = get_dependencies("current_time", ®istry);
3912 assert!(deps.is_empty());
3913
3914 let deps = get_dependencies("unknown", ®istry);
3916 assert!(deps.is_empty());
3917 }
3918
3919 #[test]
3920 fn test_sample_data_has_dependency() {
3921 let registry = fixture_registry();
3922 let cap = registry.get("sample_data").unwrap();
3923
3924 let deps = cap.dependencies();
3925 assert_eq!(deps.len(), 1);
3926 assert_eq!(deps[0], "session_file_system");
3927 }
3928
3929 #[test]
3930 fn test_noop_has_no_dependencies() {
3931 let registry = fixture_registry();
3932 let cap = registry.get("noop").unwrap();
3933
3934 assert!(cap.dependencies().is_empty());
3935 }
3936
3937 #[test]
3941 fn test_circular_dependency_error() {
3942 struct CapA;
3944 struct CapB;
3945
3946 impl Capability for CapA {
3947 fn id(&self) -> &str {
3948 "test_cap_a"
3949 }
3950 fn name(&self) -> &str {
3951 "Test A"
3952 }
3953 fn description(&self) -> &str {
3954 "Test capability A"
3955 }
3956 fn dependencies(&self) -> Vec<&'static str> {
3957 vec!["test_cap_b"]
3958 }
3959 }
3960
3961 impl Capability for CapB {
3962 fn id(&self) -> &str {
3963 "test_cap_b"
3964 }
3965 fn name(&self) -> &str {
3966 "Test B"
3967 }
3968 fn description(&self) -> &str {
3969 "Test capability B"
3970 }
3971 fn dependencies(&self) -> Vec<&'static str> {
3972 vec!["test_cap_a"]
3973 }
3974 }
3975
3976 let mut registry = CapabilityRegistry::new();
3977 registry.register(CapA);
3978 registry.register(CapB);
3979
3980 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
3981
3982 assert!(result.is_err());
3983 match result.unwrap_err() {
3984 DependencyError::CircularDependency { capability_id, .. } => {
3985 assert_eq!(capability_id, "test_cap_a");
3986 }
3987 _ => panic!("Expected CircularDependency error"),
3988 }
3989 }
3990
3991 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3996
3997 struct FilterTestCapability {
3999 priority: i32,
4000 }
4001
4002 impl Capability for FilterTestCapability {
4003 fn id(&self) -> &str {
4004 "filter_test"
4005 }
4006 fn name(&self) -> &str {
4007 "Filter Test"
4008 }
4009 fn description(&self) -> &str {
4010 "Test capability with message filter"
4011 }
4012 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4013 Some(Arc::new(FilterTestProvider {
4014 priority: self.priority,
4015 }))
4016 }
4017 }
4018
4019 struct FilterTestProvider {
4020 priority: i32,
4021 }
4022
4023 impl MessageFilterProvider for FilterTestProvider {
4024 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4025 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
4027 query
4028 .filters
4029 .push(MessageFilter::Search(search.to_string()));
4030 }
4031 }
4032
4033 fn priority(&self) -> i32 {
4034 self.priority
4035 }
4036 }
4037
4038 #[tokio::test]
4039 async fn test_collect_capabilities_with_configs_no_filter_providers() {
4040 let registry = fixture_registry();
4041 let configs = vec![AgentCapabilityConfig::with_config(
4042 CapabilityId::new("current_time"),
4043 serde_json::json!({}),
4044 )];
4045
4046 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4047
4048 assert!(collected.message_filter_providers.is_empty());
4049 assert!(!collected.has_message_filters());
4050 }
4051
4052 #[tokio::test]
4053 async fn test_collect_capabilities_with_configs_with_filter_provider() {
4054 let mut registry = CapabilityRegistry::new();
4055 registry.register(FilterTestCapability { priority: 0 });
4056
4057 let configs = vec![AgentCapabilityConfig::with_config(
4058 CapabilityId::new("filter_test"),
4059 serde_json::json!({ "search": "hello" }),
4060 )];
4061
4062 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4063
4064 assert_eq!(collected.message_filter_providers.len(), 1);
4065 assert!(collected.has_message_filters());
4066 }
4067
4068 #[tokio::test]
4069 async fn test_collect_capabilities_with_configs_filter_priority_order() {
4070 struct HighPriorityCapability;
4072 struct LowPriorityCapability;
4073
4074 impl Capability for HighPriorityCapability {
4075 fn id(&self) -> &str {
4076 "high_priority"
4077 }
4078 fn name(&self) -> &str {
4079 "High Priority"
4080 }
4081 fn description(&self) -> &str {
4082 "Test"
4083 }
4084 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4085 Some(Arc::new(FilterTestProvider { priority: 10 }))
4086 }
4087 }
4088
4089 impl Capability for LowPriorityCapability {
4090 fn id(&self) -> &str {
4091 "low_priority"
4092 }
4093 fn name(&self) -> &str {
4094 "Low Priority"
4095 }
4096 fn description(&self) -> &str {
4097 "Test"
4098 }
4099 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4100 Some(Arc::new(FilterTestProvider { priority: -5 }))
4101 }
4102 }
4103
4104 let mut registry = CapabilityRegistry::new();
4105 registry.register(HighPriorityCapability);
4106 registry.register(LowPriorityCapability);
4107
4108 let configs = vec![
4110 AgentCapabilityConfig::with_config(
4111 CapabilityId::new("high_priority"),
4112 serde_json::json!({}),
4113 ),
4114 AgentCapabilityConfig::with_config(
4115 CapabilityId::new("low_priority"),
4116 serde_json::json!({}),
4117 ),
4118 ];
4119
4120 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4121
4122 assert_eq!(collected.message_filter_providers.len(), 2);
4124 assert_eq!(collected.message_filter_providers[0].0.priority(), -5);
4125 assert_eq!(collected.message_filter_providers[1].0.priority(), 10);
4126 }
4127
4128 #[tokio::test]
4129 async fn test_collected_capabilities_apply_message_filters() {
4130 let mut registry = CapabilityRegistry::new();
4131 registry.register(FilterTestCapability { priority: 0 });
4132
4133 let configs = vec![AgentCapabilityConfig::with_config(
4134 CapabilityId::new("filter_test"),
4135 serde_json::json!({ "search": "test_query" }),
4136 )];
4137
4138 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4139
4140 let session_id: SessionId = Uuid::now_v7().into();
4142 let mut query = MessageQuery::new(session_id);
4143
4144 collected.apply_message_filters(&mut query);
4145
4146 assert_eq!(query.filters.len(), 1);
4148 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4149 }
4150
4151 #[tokio::test]
4152 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4153 struct SearchCapability {
4154 id: &'static str,
4155 search_term: &'static str,
4156 priority: i32,
4157 }
4158
4159 struct SearchProvider {
4160 search_term: &'static str,
4161 priority: i32,
4162 }
4163
4164 impl MessageFilterProvider for SearchProvider {
4165 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4166 query
4167 .filters
4168 .push(MessageFilter::Search(self.search_term.to_string()));
4169 }
4170
4171 fn priority(&self) -> i32 {
4172 self.priority
4173 }
4174 }
4175
4176 impl Capability for SearchCapability {
4177 fn id(&self) -> &str {
4178 self.id
4179 }
4180 fn name(&self) -> &str {
4181 "Search"
4182 }
4183 fn description(&self) -> &str {
4184 "Test"
4185 }
4186 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4187 Some(Arc::new(SearchProvider {
4188 search_term: self.search_term,
4189 priority: self.priority,
4190 }))
4191 }
4192 }
4193
4194 let mut registry = CapabilityRegistry::new();
4195 registry.register(SearchCapability {
4196 id: "cap_a",
4197 search_term: "alpha",
4198 priority: 5,
4199 });
4200 registry.register(SearchCapability {
4201 id: "cap_b",
4202 search_term: "beta",
4203 priority: 1,
4204 });
4205 registry.register(SearchCapability {
4206 id: "cap_c",
4207 search_term: "gamma",
4208 priority: 10,
4209 });
4210
4211 let configs = vec![
4212 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4213 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4214 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4215 ];
4216
4217 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4218
4219 let session_id: SessionId = Uuid::now_v7().into();
4220 let mut query = MessageQuery::new(session_id);
4221
4222 collected.apply_message_filters(&mut query);
4223
4224 assert_eq!(query.filters.len(), 3);
4226 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4227 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4228 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4229 }
4230
4231 #[test]
4232 fn test_capability_without_message_filter_returns_none() {
4233 let registry = fixture_registry();
4234
4235 let noop = registry.get("noop").unwrap();
4236 assert!(noop.message_filter_provider().is_none());
4237
4238 let current_time = registry.get("current_time").unwrap();
4239 assert!(current_time.message_filter_provider().is_none());
4240 }
4241
4242 #[tokio::test]
4243 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4244 let mut registry = CapabilityRegistry::new();
4245 registry.register(FilterTestCapability { priority: 0 });
4246
4247 let test_config = serde_json::json!({
4248 "search": "custom_search",
4249 "extra_field": 42
4250 });
4251
4252 let configs = vec![AgentCapabilityConfig::with_config(
4253 CapabilityId::new("filter_test"),
4254 test_config.clone(),
4255 )];
4256
4257 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4258
4259 assert_eq!(collected.message_filter_providers.len(), 1);
4261 let (_, stored_config) = &collected.message_filter_providers[0];
4262 assert_eq!(*stored_config, test_config);
4263 }
4264
4265 #[test]
4270 fn test_collect_message_filters_only_collects_filters() {
4271 let mut registry = CapabilityRegistry::new();
4272 registry.register(FilterTestCapability { priority: 0 });
4273
4274 let configs = vec![AgentCapabilityConfig::with_config(
4275 CapabilityId::new("filter_test"),
4276 serde_json::json!({ "search": "test_query" }),
4277 )];
4278
4279 let collected = collect_message_filters_only(&configs, ®istry);
4280
4281 let session_id: SessionId = Uuid::now_v7().into();
4282 let mut query = MessageQuery::new(session_id);
4283 collected.apply_message_filters(&mut query);
4284
4285 assert_eq!(query.filters.len(), 1);
4286 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4287 }
4288
4289 #[test]
4290 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4291 let registry = CapabilityRegistry::new();
4292
4293 let configs = vec![AgentCapabilityConfig::with_config(
4294 CapabilityId::new("nonexistent"),
4295 serde_json::json!({}),
4296 )];
4297
4298 let collected = collect_message_filters_only(&configs, ®istry);
4299 assert!(collected.message_filter_providers.is_empty());
4300 }
4301
4302 #[test]
4303 fn test_collect_message_filters_only_preserves_priority_order() {
4304 struct PriorityFilterCap {
4305 id: &'static str,
4306 search_term: &'static str,
4307 priority: i32,
4308 }
4309
4310 struct PriorityFilterProvider {
4311 search_term: &'static str,
4312 priority: i32,
4313 }
4314
4315 impl Capability for PriorityFilterCap {
4316 fn id(&self) -> &str {
4317 self.id
4318 }
4319 fn name(&self) -> &str {
4320 self.id
4321 }
4322 fn description(&self) -> &str {
4323 "priority test"
4324 }
4325 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4326 Some(Arc::new(PriorityFilterProvider {
4327 search_term: self.search_term,
4328 priority: self.priority,
4329 }))
4330 }
4331 }
4332
4333 impl MessageFilterProvider for PriorityFilterProvider {
4334 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4335 query
4336 .filters
4337 .push(MessageFilter::Search(self.search_term.to_string()));
4338 }
4339 fn priority(&self) -> i32 {
4340 self.priority
4341 }
4342 }
4343
4344 let mut registry = CapabilityRegistry::new();
4345 registry.register(PriorityFilterCap {
4346 id: "gamma",
4347 search_term: "gamma",
4348 priority: 10,
4349 });
4350 registry.register(PriorityFilterCap {
4351 id: "alpha",
4352 search_term: "alpha",
4353 priority: 5,
4354 });
4355 registry.register(PriorityFilterCap {
4356 id: "beta",
4357 search_term: "beta",
4358 priority: 1,
4359 });
4360
4361 let configs = vec![
4362 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4363 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4364 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4365 ];
4366
4367 let collected = collect_message_filters_only(&configs, ®istry);
4368
4369 let session_id: SessionId = Uuid::now_v7().into();
4370 let mut query = MessageQuery::new(session_id);
4371 collected.apply_message_filters(&mut query);
4372
4373 assert_eq!(query.filters.len(), 3);
4375 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4376 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4377 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4378 }
4379
4380 #[test]
4381 fn test_collect_message_filters_only_post_load_invoked() {
4382 use crate::message::Message;
4383
4384 struct PostLoadCap;
4385 struct PostLoadProvider;
4386
4387 impl Capability for PostLoadCap {
4388 fn id(&self) -> &str {
4389 "post_load_test"
4390 }
4391 fn name(&self) -> &str {
4392 "PostLoad Test"
4393 }
4394 fn description(&self) -> &str {
4395 "test"
4396 }
4397 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4398 Some(Arc::new(PostLoadProvider))
4399 }
4400 }
4401
4402 impl MessageFilterProvider for PostLoadProvider {
4403 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4404 fn priority(&self) -> i32 {
4405 0
4406 }
4407 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4408 messages.reverse();
4410 }
4411 }
4412
4413 let mut registry = CapabilityRegistry::new();
4414 registry.register(PostLoadCap);
4415
4416 let configs = vec![AgentCapabilityConfig::with_config(
4417 CapabilityId::new("post_load_test"),
4418 serde_json::json!({}),
4419 )];
4420
4421 let collected = collect_message_filters_only(&configs, ®istry);
4422
4423 let mut messages = vec![Message::user("first"), Message::user("second")];
4424 collected.apply_post_load_filters(&mut messages);
4425
4426 assert_eq!(messages[0].text(), Some("second"));
4428 assert_eq!(messages[1].text(), Some("first"));
4429 }
4430
4431 struct DelegatingFilterCap {
4434 id: &'static str,
4435 inner: std::sync::Arc<InnerFilterCap>,
4436 }
4437 struct InnerFilterCap;
4438
4439 impl Capability for InnerFilterCap {
4440 fn id(&self) -> &str {
4441 "inner_filter"
4442 }
4443 fn name(&self) -> &str {
4444 "Inner Filter"
4445 }
4446 fn description(&self) -> &str {
4447 "inner"
4448 }
4449 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4450 Some(std::sync::Arc::new(SentinelFilter))
4451 }
4452 }
4453 struct SentinelFilter;
4454 impl MessageFilterProvider for SentinelFilter {
4455 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4456 }
4457 impl Capability for DelegatingFilterCap {
4458 fn id(&self) -> &str {
4459 self.id
4460 }
4461 fn name(&self) -> &str {
4462 "Delegating Filter"
4463 }
4464 fn description(&self) -> &str {
4465 "delegating"
4466 }
4467 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4468 None }
4470 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4471 Some(&*self.inner)
4472 }
4473 }
4474
4475 #[test]
4476 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4477 let inner = std::sync::Arc::new(InnerFilterCap);
4478 let outer = DelegatingFilterCap {
4479 id: "delegating_filter",
4480 inner: inner.clone(),
4481 };
4482
4483 let mut registry = CapabilityRegistry::new();
4484 registry.register(outer);
4485
4486 let configs = vec![AgentCapabilityConfig::with_config(
4487 CapabilityId::new("delegating_filter"),
4488 serde_json::json!({}),
4489 )];
4490
4491 let collected = collect_message_filters_only(&configs, ®istry);
4494 assert_eq!(
4495 collected.message_filter_providers.len(),
4496 1,
4497 "provider from resolved inner capability must be collected"
4498 );
4499 }
4500
4501 struct DelegatingMvpCap {
4502 id: &'static str,
4503 inner: std::sync::Arc<InnerMvpCap>,
4504 }
4505 struct InnerMvpCap;
4506
4507 impl Capability for InnerMvpCap {
4508 fn id(&self) -> &str {
4509 "inner_mvp"
4510 }
4511 fn name(&self) -> &str {
4512 "Inner MVP"
4513 }
4514 fn description(&self) -> &str {
4515 "inner"
4516 }
4517 fn model_view_provider(
4518 &self,
4519 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4520 struct NoopMvp;
4522 impl crate::capabilities::ModelViewProvider for NoopMvp {
4523 fn apply_model_view(
4524 &self,
4525 messages: Vec<Message>,
4526 _config: &serde_json::Value,
4527 _context: &ModelViewContext<'_>,
4528 ) -> Vec<Message> {
4529 messages
4530 }
4531 }
4532 Some(std::sync::Arc::new(NoopMvp))
4533 }
4534 }
4535 impl Capability for DelegatingMvpCap {
4536 fn id(&self) -> &str {
4537 self.id
4538 }
4539 fn name(&self) -> &str {
4540 "Delegating MVP"
4541 }
4542 fn description(&self) -> &str {
4543 "delegating"
4544 }
4545 fn model_view_provider(
4546 &self,
4547 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4548 None }
4550 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4551 Some(&*self.inner)
4552 }
4553 }
4554
4555 #[test]
4556 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4557 let inner = std::sync::Arc::new(InnerMvpCap);
4558 let outer = DelegatingMvpCap {
4559 id: "delegating_mvp",
4560 inner: inner.clone(),
4561 };
4562
4563 let mut registry = CapabilityRegistry::new();
4564 registry.register(outer);
4565
4566 let configs = vec![AgentCapabilityConfig::with_config(
4567 CapabilityId::new("delegating_mvp"),
4568 serde_json::json!({}),
4569 )];
4570
4571 let collected = collect_model_view_providers(&configs, ®istry, None);
4574 assert_eq!(
4575 collected.model_view_providers.len(),
4576 1,
4577 "provider from resolved inner capability must be collected"
4578 );
4579 }
4580
4581 #[tokio::test]
4591 async fn test_bashkit_shell_capability_produces_bash_tool() {
4592 let registry = fixture_registry();
4593 let collected =
4594 collect_capabilities(&["bashkit_shell".to_string()], ®istry, &test_ctx()).await;
4595
4596 let tool_names: Vec<&str> = collected
4597 .tool_definitions
4598 .iter()
4599 .map(|t| t.name())
4600 .collect();
4601 assert!(
4602 tool_names.contains(&"bash"),
4603 "bashkit_shell capability must produce 'bash' tool, got: {:?}",
4604 tool_names
4605 );
4606 assert!(
4607 !collected.tools.is_empty(),
4608 "bashkit_shell must provide tool implementations"
4609 );
4610 }
4611
4612 #[tokio::test]
4613 async fn test_generic_harness_capability_set_produces_bash_tool() {
4614 let generic_harness_caps = vec![
4617 "session_file_system".to_string(),
4618 "bashkit_shell".to_string(),
4619 "web_fetch".to_string(),
4620 "session_storage".to_string(),
4621 "session".to_string(),
4622 "agent_instructions".to_string(),
4623 "skills".to_string(),
4624 "infinity_context".to_string(),
4625 "auto_tool_search".to_string(),
4626 ];
4627
4628 let registry = fixture_registry();
4629 let collected = collect_capabilities(&generic_harness_caps, ®istry, &test_ctx()).await;
4630
4631 let tool_names: Vec<&str> = collected
4632 .tool_definitions
4633 .iter()
4634 .map(|t| t.name())
4635 .collect();
4636 assert!(
4637 tool_names.contains(&"bash"),
4638 "Generic Harness capabilities must produce 'bash' tool, got: {:?}",
4639 tool_names
4640 );
4641 }
4642
4643 #[tokio::test]
4644 async fn test_collect_capabilities_tool_count_matches_definitions() {
4645 let registry = fixture_registry();
4648 let collected =
4649 collect_capabilities(&["bashkit_shell".to_string()], ®istry, &test_ctx()).await;
4650
4651 assert_eq!(
4652 collected.tools.len(),
4653 collected.tool_definitions.len(),
4654 "tool implementations ({}) must match tool definitions ({})",
4655 collected.tools.len(),
4656 collected.tool_definitions.len(),
4657 );
4658 }
4659
4660 #[tokio::test]
4664 async fn test_collect_capabilities_resolves_dependencies() {
4665 let registry = fixture_registry();
4668 let collected =
4669 collect_capabilities(&["sample_data".to_string()], ®istry, &test_ctx()).await;
4670
4671 assert!(
4673 collected
4674 .applied_ids
4675 .iter()
4676 .any(|id| id == "session_file_system"),
4677 "collect_capabilities must apply session_file_system as a dependency; applied_ids: {:?}",
4678 collected.applied_ids
4679 );
4680
4681 let tool_names: Vec<&str> = collected
4682 .tool_definitions
4683 .iter()
4684 .map(|t| t.name())
4685 .collect();
4686
4687 assert!(
4689 tool_names.contains(&"read_file") && tool_names.contains(&"write_file"),
4690 "collect_capabilities must resolve dependencies and include dependency tools, got: {:?}",
4691 tool_names
4692 );
4693
4694 assert_eq!(
4696 collected.tools.len(),
4697 collected.tool_definitions.len(),
4698 "dependency-added tools must have implementations, not just definitions"
4699 );
4700 }
4701
4702 #[test]
4703 fn test_defaults_do_not_include_bash() {
4704 let registry = crate::ToolRegistry::with_defaults();
4707 assert!(
4708 !registry.has("bash"),
4709 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4710 );
4711 }
4712
4713 #[test]
4718 fn test_capability_features_default_empty() {
4719 let registry = fixture_registry();
4720
4721 let noop = registry.get("noop").unwrap();
4723 assert!(noop.features().is_empty());
4724
4725 let current_time = registry.get("current_time").unwrap();
4726 assert!(current_time.features().is_empty());
4727 }
4728
4729 #[test]
4730 fn test_file_system_capability_features() {
4731 let registry = fixture_registry();
4732
4733 let fs = registry.get("session_file_system").unwrap();
4734 assert_eq!(fs.features(), vec!["file_system"]);
4735 }
4736
4737 #[test]
4738 fn test_bashkit_shell_capability_features() {
4739 let registry = fixture_registry();
4740
4741 let bash = registry.get("bashkit_shell").unwrap();
4742 assert_eq!(bash.features(), vec!["file_system"]);
4743 }
4744
4745 #[test]
4746 fn test_alias_resolves_to_canonical_capability() {
4747 let registry = fixture_registry();
4748
4749 let via_alias = registry.get("virtual_bash").unwrap();
4751 assert_eq!(via_alias.id(), "bashkit_shell");
4752 assert!(registry.has("virtual_bash"));
4753 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4754 assert_eq!(
4755 registry.canonical_id("bashkit_shell"),
4756 Some("bashkit_shell")
4757 );
4758 assert_eq!(registry.canonical_id("nonexistent"), None);
4759 }
4760
4761 #[test]
4762 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4763 let registry = fixture_registry();
4764
4765 let resolved = resolve_dependencies(
4768 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4769 ®istry,
4770 )
4771 .unwrap();
4772 let bash_ids: Vec<_> = resolved
4773 .resolved_ids
4774 .iter()
4775 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4776 .collect();
4777 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4778 assert!(
4780 !resolved
4781 .added_as_dependencies
4782 .contains(&"bashkit_shell".to_string())
4783 );
4784 }
4785
4786 #[test]
4787 fn test_alias_preserves_explicit_config_in_resolution() {
4788 let registry = fixture_registry();
4789
4790 let configs = vec![AgentCapabilityConfig::with_config(
4791 "virtual_bash".to_string(),
4792 serde_json::json!({"key": "value"}),
4793 )];
4794 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4795 let bash = resolved
4796 .iter()
4797 .find(|c| c.capability_id() == "bashkit_shell")
4798 .expect("alias must resolve to canonical bashkit_shell config");
4799 assert_eq!(
4800 bash.config_value().clone(),
4801 serde_json::json!({"key": "value"})
4802 );
4803 }
4804
4805 #[test]
4806 fn test_unregister_by_alias_removes_capability_and_aliases() {
4807 let mut registry = fixture_registry();
4808
4809 assert!(registry.unregister("virtual_bash").is_some());
4810 assert!(!registry.has("bashkit_shell"));
4811 assert!(!registry.has("virtual_bash"));
4812 }
4813
4814 #[test]
4815 fn kernel_capability_features_are_declared_on_the_capability() {
4816 let registry = fixture_registry();
4817
4818 let capability = registry
4823 .get("feature_fixture")
4824 .expect("feature fixture must be registered");
4825 assert_eq!(
4826 compute_features(&["feature_fixture".to_string()], ®istry),
4827 capability.features()
4828 );
4829 }
4830
4831 #[test]
4832 fn test_sample_data_capability_features() {
4833 let registry = fixture_registry();
4834
4835 let sample = registry.get("sample_data").unwrap();
4836 assert_eq!(sample.features(), vec!["file_system"]);
4837 }
4838
4839 #[test]
4840 fn test_compute_features_empty() {
4841 let registry = CapabilityRegistry::new();
4842
4843 let features = compute_features(&[], ®istry);
4844 assert!(features.is_empty());
4845 }
4846
4847 #[test]
4848 fn test_compute_features_single_capability() {
4849 let registry = fixture_registry();
4850
4851 let features = compute_features(&["feature_fixture".to_string()], ®istry);
4852 assert_eq!(
4853 features,
4854 registry
4855 .get("feature_fixture")
4856 .expect("feature fixture must be registered")
4857 .features()
4858 );
4859 }
4860
4861 #[test]
4862 fn test_compute_features_multiple_capabilities() {
4863 let registry = fixture_registry();
4864
4865 let features = compute_features(
4866 &[
4867 "session_file_system".to_string(),
4868 "session_storage".to_string(),
4869 ],
4870 ®istry,
4871 );
4872 assert!(features.contains(&"file_system".to_string()));
4873 assert!(features.contains(&"secrets".to_string()));
4874 assert!(features.contains(&"key_value".to_string()));
4875 }
4876
4877 #[test]
4878 fn test_compute_features_deduplicates() {
4879 let registry = fixture_registry();
4880
4881 let features = compute_features(
4883 &[
4884 "session_file_system".to_string(),
4885 "bashkit_shell".to_string(),
4886 ],
4887 ®istry,
4888 );
4889 let file_system_count = features.iter().filter(|f| *f == "file_system").count();
4890 assert_eq!(file_system_count, 1, "file_system should appear only once");
4891 }
4892
4893 #[test]
4894 fn test_compute_features_includes_dependency_features() {
4895 let registry = fixture_registry();
4896
4897 let features = compute_features(&["bashkit_shell".to_string()], ®istry);
4899 assert!(features.contains(&"file_system".to_string()));
4900 }
4901
4902 #[test]
4903 fn test_compute_features_generic_harness_set() {
4904 let registry = fixture_registry();
4905
4906 let features = compute_features(
4908 &[
4909 "session_file_system".to_string(),
4910 "bashkit_shell".to_string(),
4911 "session_storage".to_string(),
4912 "session".to_string(),
4913 ],
4914 ®istry,
4915 );
4916 assert!(features.contains(&"file_system".to_string()));
4917 assert!(features.contains(&"secrets".to_string()));
4918 assert!(features.contains(&"key_value".to_string()));
4919 }
4920
4921 #[test]
4922 fn test_compute_features_unknown_capability_ignored() {
4923 let registry = fixture_registry();
4924
4925 let features = compute_features(
4926 &["unknown_cap".to_string(), "session_storage".to_string()],
4927 ®istry,
4928 );
4929 assert_eq!(features, vec!["secrets", "key_value"]);
4930 }
4931
4932 #[test]
4933 fn test_risk_level_ordering() {
4934 assert!(RiskLevel::Low < RiskLevel::Medium);
4935 assert!(RiskLevel::Medium < RiskLevel::High);
4936 }
4937
4938 #[test]
4939 fn test_risk_level_serde_roundtrip() {
4940 let high = RiskLevel::High;
4941 let json = serde_json::to_string(&high).unwrap();
4942 assert_eq!(json, "\"high\"");
4943 let back: RiskLevel = serde_json::from_str(&json).unwrap();
4944 assert_eq!(back, RiskLevel::High);
4945 }
4946
4947 #[test]
4948 fn test_capability_risk_levels() {
4949 let registry = fixture_registry();
4950
4951 let bash = registry.get("bashkit_shell").unwrap();
4953 assert_eq!(bash.risk_level(), RiskLevel::High);
4954
4955 let fetch = registry.get("web_fetch").unwrap();
4957 assert_eq!(fetch.risk_level(), RiskLevel::High);
4958
4959 let noop = registry.get("noop").unwrap();
4961 assert_eq!(noop.risk_level(), RiskLevel::Low);
4962 }
4963
4964 struct SkillContributingCapability;
4969
4970 impl Capability for SkillContributingCapability {
4971 fn id(&self) -> &str {
4972 "contributes_skills"
4973 }
4974 fn name(&self) -> &str {
4975 "Contributes Skills"
4976 }
4977 fn description(&self) -> &str {
4978 "Test capability that contributes skills."
4979 }
4980 fn contribute_skills(&self) -> Vec<SkillContribution> {
4981 vec![
4982 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4983 .with_files(vec![(
4984 "scripts/a.sh".to_string(),
4985 "#!/bin/sh\necho a\n".to_string(),
4986 )]),
4987 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4988 .with_user_invocable(false),
4989 ]
4990 }
4991 }
4992
4993 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4994 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4995 MountSource::InlineFile { content, .. } => content.as_str(),
4996 _ => panic!("Expected InlineFile for SKILL.md"),
4997 }
4998 }
4999
5000 #[tokio::test]
5001 async fn test_contribute_skills_normalized_to_mounts() {
5002 let mut registry = CapabilityRegistry::new();
5003 registry.register(SkillContributingCapability);
5004
5005 let configs = vec![AgentCapabilityConfig::with_config(
5006 CapabilityId::new("contributes_skills"),
5007 serde_json::json!({}),
5008 )];
5009
5010 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
5011
5012 let skill_mounts: Vec<_> = collected
5013 .mounts
5014 .iter()
5015 .filter(|m| m.path.starts_with("/.agents/skills/"))
5016 .collect();
5017 assert_eq!(skill_mounts.len(), 2);
5018
5019 for m in &skill_mounts {
5022 assert!(m.is_readonly());
5023 assert_eq!(m.capability_id, "contributes_skills");
5024 }
5025
5026 let alpha = skill_mounts
5027 .iter()
5028 .find(|m| m.path == "/.agents/skills/alpha-skill")
5029 .expect("alpha-skill mount missing");
5030 match &alpha.source {
5031 MountSource::InlineDirectory { entries } => {
5032 assert!(entries.contains_key("SKILL.md"));
5033 assert!(entries.contains_key("scripts/a.sh"));
5034 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
5035 assert_eq!(parsed.name, "alpha-skill");
5036 assert!(parsed.user_invocable);
5037 }
5038 _ => panic!("Expected InlineDirectory"),
5039 }
5040
5041 let beta = skill_mounts
5042 .iter()
5043 .find(|m| m.path == "/.agents/skills/beta-skill")
5044 .expect("beta-skill mount missing");
5045 match &beta.source {
5046 MountSource::InlineDirectory { entries } => {
5047 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
5048 assert!(!parsed.user_invocable);
5049 }
5050 _ => panic!("Expected InlineDirectory"),
5051 }
5052 }
5053
5054 #[tokio::test]
5055 async fn test_contribute_skills_default_empty() {
5056 let mut registry = CapabilityRegistry::new();
5059 registry.register(FilterTestCapability { priority: 0 });
5060
5061 let configs = vec![AgentCapabilityConfig::with_config(
5062 CapabilityId::new("filter_test"),
5063 serde_json::json!({}),
5064 )];
5065
5066 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
5067 assert!(
5068 collected
5069 .mounts
5070 .iter()
5071 .all(|m| !m.path.starts_with("/.agents/skills/"))
5072 );
5073 }
5074
5075 struct LocalizedCapability;
5076
5077 impl Capability for LocalizedCapability {
5078 fn id(&self) -> &str {
5079 "localized"
5080 }
5081 fn name(&self) -> &str {
5082 "Localized"
5083 }
5084 fn description(&self) -> &str {
5085 "English description"
5086 }
5087 fn localizations(&self) -> Vec<CapabilityLocalization> {
5088 vec![
5089 CapabilityLocalization {
5090 locale: "en",
5091 name: None,
5092 description: None,
5093 config_description: Some("Controls things."),
5094 config_overlay: None,
5095 },
5096 CapabilityLocalization {
5097 locale: "uk",
5098 name: Some("Локалізована"),
5099 description: Some("Український опис"),
5100 config_description: Some("Керує налаштуваннями."),
5101 config_overlay: None,
5102 },
5103 ]
5104 }
5105 }
5106
5107 #[test]
5108 fn localized_name_falls_back_exact_language_then_base() {
5109 let cap = LocalizedCapability;
5110 assert_eq!(cap.localized_name(Some("uk-UA")), "Локалізована");
5112 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
5113 assert_eq!(cap.localized_name(Some("uk_UA")), "Локалізована");
5115 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
5117 assert_eq!(cap.localized_name(None), "Localized");
5118 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
5119 assert_eq!(cap.localized_description(Some("de")), "English description");
5120 }
5121
5122 #[test]
5123 fn describe_schema_resolves_config_description_per_locale() {
5124 let cap = LocalizedCapability;
5125 assert_eq!(
5126 cap.describe_schema(Some("uk-UA")).as_deref(),
5127 Some("Керує налаштуваннями.")
5128 );
5129 assert_eq!(
5131 cap.describe_schema(Some("pl")).as_deref(),
5132 Some("Controls things.")
5133 );
5134 assert_eq!(
5135 cap.describe_schema(None).as_deref(),
5136 Some("Controls things.")
5137 );
5138 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
5140 }
5141}