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 native_async_tools(
286 &self,
287 _config: &serde_json::Value,
288 ) -> Option<std::collections::BTreeMap<String, Option<serde_json::Value>>> {
289 None
290 }
291 fn id(&self) -> &str;
293
294 fn aliases(&self) -> Vec<&'static str> {
303 vec![]
304 }
305
306 fn name(&self) -> &str;
308
309 fn description(&self) -> &str;
311
312 fn localizations(&self) -> Vec<CapabilityLocalization> {
317 vec![]
318 }
319
320 fn localized_name(&self, locale: Option<&str>) -> String {
323 resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
324 .unwrap_or_else(|| self.name())
325 .to_string()
326 }
327
328 fn localized_description(&self, locale: Option<&str>) -> String {
330 resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
331 .unwrap_or_else(|| self.description())
332 .to_string()
333 }
334
335 fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
339 resolve_localized_field(&self.localizations(), locale, |entry| {
340 entry.config_description
341 })
342 .map(str::to_string)
343 }
344
345 fn status(&self) -> CapabilityStatus {
347 CapabilityStatus::Available
348 }
349
350 fn icon(&self) -> Option<&str> {
352 None
353 }
354
355 fn category(&self) -> Option<&str> {
357 None
358 }
359
360 fn metadata(&self) -> Option<serde_json::Value> {
372 None
373 }
374
375 fn is_guardrail(&self) -> bool {
380 false
381 }
382
383 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
394 None
395 }
396
397 fn system_prompt_addition(&self) -> Option<&str> {
417 None
418 }
419
420 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
432 self.system_prompt_addition().map(|addition| {
433 format!(
434 "<capability id=\"{}\">\n{}\n</capability>",
435 self.id(),
436 addition
437 )
438 })
439 }
440
441 fn system_prompt_preview(&self) -> Option<String> {
447 self.system_prompt_addition().map(|s| s.to_string())
448 }
449
450 fn tools(&self) -> Vec<Box<dyn Tool>> {
452 vec![]
453 }
454
455 fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
463 self.tools()
464 }
465
466 fn delegation_target_with_config(
472 &self,
473 _config: &serde_json::Value,
474 ) -> Option<DelegationTargetProvider> {
475 None
476 }
477
478 fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
482 false
483 }
484
485 async fn system_prompt_contribution_with_config(
492 &self,
493 ctx: &SystemPromptContext,
494 _config: &serde_json::Value,
495 ) -> Option<String> {
496 self.system_prompt_contribution(ctx).await
497 }
498
499 async fn conversation_context_contribution(
510 &self,
511 _ctx: &SystemPromptContext,
512 ) -> Option<String> {
513 None
514 }
515
516 async fn conversation_context_contribution_with_config(
521 &self,
522 ctx: &SystemPromptContext,
523 _config: &serde_json::Value,
524 ) -> Option<String> {
525 self.conversation_context_contribution(ctx).await
526 }
527
528 fn tool_definitions(&self) -> Vec<ToolDefinition> {
531 self.tools().iter().map(|t| t.to_definition()).collect()
532 }
533
534 fn mounts(&self) -> Vec<MountPoint> {
542 vec![]
543 }
544
545 fn dependencies(&self) -> Vec<&'static str> {
554 vec![]
555 }
556
557 fn features(&self) -> Vec<&'static str> {
572 vec![]
573 }
574
575 fn config_schema(&self) -> Option<serde_json::Value> {
581 None
582 }
583
584 fn config_ui_schema(&self) -> Option<serde_json::Value> {
589 None
590 }
591
592 fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
598 Ok(())
599 }
600
601 fn mcp_servers(&self) -> ScopedMcpServers {
607 ScopedMcpServers::default()
608 }
609
610 fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
612 self.mcp_servers()
613 }
614
615 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
628 None
629 }
630
631 fn message_filter_config(
636 &self,
637 config: &serde_json::Value,
638 _compaction_enabled: bool,
639 ) -> serde_json::Value {
640 config.clone()
641 }
642
643 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
651 None
652 }
653
654 fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
666 None
667 }
668
669 fn tool_search_config(
673 &self,
674 _config: &serde_json::Value,
675 ) -> Option<crate::driver_registry::ToolSearchConfig> {
676 None
677 }
678
679 fn prompt_cache_config(
682 &self,
683 _config: &serde_json::Value,
684 ) -> Option<crate::driver_registry::PromptCacheConfig> {
685 None
686 }
687
688 fn driver_options(&self, _config: &serde_json::Value) -> Vec<(String, serde_json::Value)> {
692 Vec::new()
693 }
694
695 fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
698 None
699 }
700
701 fn error_disclosure(
703 &self,
704 _config: &serde_json::Value,
705 ) -> Option<crate::user_facing_error::ErrorDisclosure> {
706 None
707 }
708
709 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
712 text
713 }
714
715 fn compaction_policy(
719 &self,
720 _config: &serde_json::Value,
721 ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
722 None
723 }
724
725 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
740 vec![]
741 }
742
743 fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
754 vec![]
755 }
756
757 fn pre_tool_use_hooks_with_config(
762 &self,
763 _config: &serde_json::Value,
764 ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
765 self.pre_tool_use_hooks()
766 }
767
768 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
776 vec![]
777 }
778
779 fn post_tool_exec_hooks_with_config(
784 &self,
785 _config: &serde_json::Value,
786 ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
787 self.post_tool_exec_hooks()
788 }
789
790 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
799 vec![]
800 }
801
802 fn tool_definition_hooks_with_config(
807 &self,
808 _config: &serde_json::Value,
809 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
810 self.tool_definition_hooks()
811 }
812
813 fn tool_definition_hooks_with_context(
823 &self,
824 _ctx: &SystemPromptContext,
825 config: &serde_json::Value,
826 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
827 self.tool_definition_hooks_with_config(config)
828 }
829
830 fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
838 vec![]
839 }
840
841 fn finalized_tool_calls_hook(
845 &self,
846 _config: &serde_json::Value,
847 ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
848 None
849 }
850
851 fn narrate(
865 &self,
866 _tool_def: Option<&ToolDefinition>,
867 tool_call: &ToolCall,
868 phase: crate::tool_narration::ToolNarrationPhase,
869 locale: Option<&str>,
870 ctx: crate::tool_narration::ToolNarrationContext<'_>,
871 ) -> Option<String> {
872 self.tools()
873 .iter()
874 .find(|tool| tool.name() == tool_call.name)
875 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
876 }
877
878 fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
894 vec![]
895 }
896
897 fn user_hooks_with_config(
903 &self,
904 _config: &serde_json::Value,
905 ) -> Vec<crate::user_hook_types::UserHookSpec> {
906 self.user_hooks()
907 }
908
909 fn risk_level(&self) -> RiskLevel {
917 RiskLevel::Low
918 }
919
920 fn commands(&self) -> Vec<CommandDescriptor> {
928 vec![]
929 }
930
931 async fn execute_command(
945 &self,
946 request: &ExecuteCommandRequest,
947 _ctx: &CommandExecutionContext,
948 ) -> crate::error::Result<CommandResult> {
949 Err(crate::error::AgentLoopError::config(format!(
950 "capability {} declared command /{} but does not implement execute_command",
951 self.id(),
952 request.name,
953 )))
954 }
955
956 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
965 vec![]
966 }
967
968 fn contribute_skills(&self) -> Vec<SkillContribution> {
978 vec![]
979 }
980
981 fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
992 vec![]
993 }
994
995 fn post_output_guardrails_with_config(
1007 &self,
1008 _config: &serde_json::Value,
1009 ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
1010 vec![]
1011 }
1012
1013 fn post_output_annotation_hooks_with_config(
1029 &self,
1030 _config: &serde_json::Value,
1031 ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1032 vec![]
1033 }
1034
1035 fn citation_verifier_with_config(
1045 &self,
1046 _config: &serde_json::Value,
1047 ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1048 None
1049 }
1050}
1051
1052pub trait ToolDefinitionHook: Send + Sync {
1053 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1054
1055 fn applies_with_native_tool_search(&self) -> bool {
1060 true
1061 }
1062}
1063
1064pub trait ToolCallHook: Send + Sync {
1065 fn narration(
1066 &self,
1067 _tool_def: Option<&ToolDefinition>,
1068 _tool_call: &ToolCall,
1069 _phase: crate::tool_narration::ToolNarrationPhase,
1070 _locale: Option<&str>,
1071 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1072 ) -> Option<String> {
1073 None
1074 }
1075
1076 fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1077 tool_call
1078 }
1079}
1080
1081pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1087
1088impl ToolCallHook for CapabilityNarrationHook {
1089 fn narration(
1090 &self,
1091 tool_def: Option<&ToolDefinition>,
1092 tool_call: &ToolCall,
1093 phase: crate::tool_narration::ToolNarrationPhase,
1094 locale: Option<&str>,
1095 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1096 ) -> Option<String> {
1097 self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1098 }
1099}
1100
1101#[derive(
1105 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1106)]
1107#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1108#[cfg_attr(feature = "openapi", schema(example = "low"))]
1109#[serde(rename_all = "lowercase")]
1110pub enum RiskLevel {
1111 Low,
1113 Medium,
1115 High,
1117}
1118
1119#[derive(Debug, Clone, Serialize, Deserialize)]
1125#[serde(rename_all = "snake_case")]
1126pub enum BlueprintModel {
1127 Fixed(String),
1129 Default(String),
1131 Inherit,
1133}
1134
1135pub struct AgentBlueprint {
1141 pub id: &'static str,
1143 pub name: &'static str,
1145 pub description: &'static str,
1147 pub model: BlueprintModel,
1149 pub system_prompt: &'static str,
1151 pub tools: Vec<Box<dyn Tool>>,
1153 pub max_turns: Option<usize>,
1155 pub config_schema: Option<serde_json::Value>,
1157}
1158
1159impl AgentBlueprint {
1160 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1162 self.tools.iter().map(|t| t.to_definition()).collect()
1163 }
1164}
1165
1166impl std::fmt::Debug for AgentBlueprint {
1167 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1168 f.debug_struct("AgentBlueprint")
1169 .field("id", &self.id)
1170 .field("name", &self.name)
1171 .field("model", &self.model)
1172 .field("tool_count", &self.tools.len())
1173 .field("max_turns", &self.max_turns)
1174 .finish()
1175 }
1176}
1177
1178#[derive(Clone)]
1196pub struct CapabilityRegistry {
1197 capabilities: HashMap<String, Arc<dyn Capability>>,
1198 index: everruns_capability::CapabilityIdIndex,
1202}
1203
1204impl CapabilityRegistry {
1205 pub fn new() -> Self {
1207 Self {
1208 capabilities: HashMap::new(),
1209 index: everruns_capability::CapabilityIdIndex::new(),
1210 }
1211 }
1212
1213 pub fn register(&mut self, capability: impl Capability + 'static) {
1215 self.register_arc(Arc::new(capability));
1216 }
1217
1218 pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1220 self.register_arc(Arc::from(capability));
1221 }
1222
1223 pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1229 let canonical = capability.id().to_string();
1230 self.index
1231 .insert_or_replace(canonical.clone(), &capability.aliases());
1232 self.capabilities.insert(canonical, capability);
1233 }
1234
1235 pub fn try_register_arc(
1238 &mut self,
1239 capability: Arc<dyn Capability>,
1240 ) -> Result<(), everruns_capability::CapabilityError> {
1241 let canonical = capability.id().to_string();
1242 self.index
1243 .insert(canonical.clone(), &capability.aliases())?;
1244 self.capabilities.insert(canonical, capability);
1245 Ok(())
1246 }
1247
1248 pub fn register_inventory_plugins(
1253 &mut self,
1254 mut include: impl FnMut(&IntegrationPlugin) -> bool,
1255 ) {
1256 for plugin in inventory::iter::<IntegrationPlugin>() {
1257 if include(plugin) {
1258 self.register_boxed((plugin.factory)());
1259 }
1260 }
1261 }
1262
1263 pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1265 self.capabilities.get(self.index.canonical_of(id)?)
1266 }
1267
1268 pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1273 self.index.canonical_of(id)
1274 }
1275
1276 pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1278 let canonical = self.index.remove(id)?;
1279 self.capabilities.remove(&canonical)
1280 }
1281
1282 pub fn has(&self, id: &str) -> bool {
1284 self.get(id).is_some()
1285 }
1286
1287 pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1289 self.capabilities.values().collect()
1290 }
1291
1292 pub fn len(&self) -> usize {
1294 self.capabilities.len()
1295 }
1296
1297 pub fn is_empty(&self) -> bool {
1299 self.capabilities.is_empty()
1300 }
1301
1302 pub fn builder() -> CapabilityRegistryBuilder {
1304 CapabilityRegistryBuilder::new()
1305 }
1306
1307 pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1311 for cap in self.capabilities.values() {
1312 for bp in cap.agent_blueprints() {
1313 if bp.id == id {
1314 return Some(bp);
1315 }
1316 }
1317 }
1318 None
1319 }
1320
1321 pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1325 for (capability_id, cap) in &self.capabilities {
1326 for bp in cap.agent_blueprints() {
1327 if bp.id == id {
1328 return Some((capability_id.clone(), bp));
1329 }
1330 }
1331 }
1332 None
1333 }
1334
1335 pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1337 self.capabilities
1338 .values()
1339 .flat_map(|cap| cap.agent_blueprints())
1340 .collect()
1341 }
1342}
1343
1344impl Default for CapabilityRegistry {
1345 fn default() -> Self {
1346 Self::new()
1347 }
1348}
1349
1350impl std::fmt::Debug for CapabilityRegistry {
1351 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1352 let ids: Vec<_> = self.capabilities.keys().collect();
1353 f.debug_struct("CapabilityRegistry")
1354 .field("capabilities", &ids)
1355 .finish()
1356 }
1357}
1358
1359pub struct CapabilityRegistryBuilder {
1361 registry: CapabilityRegistry,
1362}
1363
1364impl CapabilityRegistryBuilder {
1365 pub fn new() -> Self {
1367 Self {
1368 registry: CapabilityRegistry::new(),
1369 }
1370 }
1371
1372 pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1374 self.registry.register(capability);
1375 self
1376 }
1377
1378 pub fn build(self) -> CapabilityRegistry {
1380 self.registry
1381 }
1382}
1383
1384impl Default for CapabilityRegistryBuilder {
1385 fn default() -> Self {
1386 Self::new()
1387 }
1388}
1389
1390pub struct ModelViewContext<'a> {
1396 pub session_id: SessionId,
1397 pub prior_usage: Option<&'a TokenUsage>,
1398}
1399
1400pub trait ModelViewProvider: Send + Sync {
1406 fn apply_model_view(
1407 &self,
1408 messages: Vec<Message>,
1409 config: &serde_json::Value,
1410 context: &ModelViewContext<'_>,
1411 ) -> Vec<Message>;
1412
1413 fn priority(&self) -> i32 {
1414 0
1415 }
1416}
1417
1418pub struct CollectedCapabilities {
1423 pub system_prompt_parts: Vec<String>,
1425 pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1427 pub conversation_context_parts: Vec<String>,
1434 pub conversation_context_attributions: Vec<SystemPromptAttribution>,
1436 pub tools: Vec<Box<dyn Tool>>,
1438 pub tool_definitions: Vec<ToolDefinition>,
1440 pub mounts: Vec<MountPoint>,
1442 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1444 pub applied_ids: Vec<String>,
1446 pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1448 pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1450 pub driver_options: HashMap<String, serde_json::Value>,
1454 pub parallel_tool_calls: Option<bool>,
1458 pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1460 pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1462 pub mcp_servers: ScopedMcpServers,
1464 }
1470
1471#[derive(Debug, Clone, PartialEq, Eq)]
1472pub struct SystemPromptAttribution {
1473 pub capability_id: String,
1474 pub content: String,
1475}
1476
1477impl CollectedCapabilities {
1478 pub fn system_prompt_prefix(&self) -> Option<String> {
1481 if self.system_prompt_parts.is_empty() {
1482 None
1483 } else {
1484 Some(self.system_prompt_parts.join("\n\n"))
1485 }
1486 }
1487
1488 pub fn conversation_context(&self) -> Option<String> {
1492 if self.conversation_context_parts.is_empty() {
1493 None
1494 } else {
1495 Some(self.conversation_context_parts.join("\n\n"))
1496 }
1497 }
1498
1499 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1503 for (provider, config) in &self.message_filter_providers {
1505 provider.apply_filters(query, config);
1506 }
1507 }
1508
1509 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1512 for (provider, config) in &self.message_filter_providers {
1513 provider.post_load(messages, config);
1514 }
1515 }
1516
1517 pub fn has_message_filters(&self) -> bool {
1519 !self.message_filter_providers.is_empty()
1520 }
1521}
1522
1523pub struct DelegationTargetProvider {
1524 pub target_type: &'static str,
1525 pub tool: Box<dyn Tool>,
1526}
1527
1528#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1530#[serde(rename_all = "snake_case")]
1531pub enum SpawnMode {
1532 Background,
1533 Foreground,
1534}
1535
1536impl SpawnMode {
1537 pub fn parse(value: &str) -> Option<Self> {
1538 match value {
1539 "background" => Some(Self::Background),
1540 "foreground" => Some(Self::Foreground),
1541 _ => None,
1542 }
1543 }
1544
1545 pub fn as_str(self) -> &'static str {
1546 match self {
1547 Self::Background => "background",
1548 Self::Foreground => "foreground",
1549 }
1550 }
1551}
1552
1553struct UnifiedSpawnAgentTool {
1554 providers: Vec<DelegationTargetProvider>,
1555}
1556
1557fn validate_spawn_agent_target_fields(
1558 arguments: &serde_json::Value,
1559 target_type: &str,
1560) -> Result<(), String> {
1561 for field in ["blueprint", "config"] {
1562 if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
1563 return Err(format!(
1564 "{field} is only valid for subagent targets, not {target_type}."
1565 ));
1566 }
1567 }
1568 Ok(())
1569}
1570
1571impl UnifiedSpawnAgentTool {
1572 fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1573 Self { providers }
1574 }
1575
1576 fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1577 self.providers
1578 .iter()
1579 .find(|provider| provider.target_type == target_type)
1580 .map(|provider| provider.tool.as_ref())
1581 }
1582
1583 fn target_types(&self) -> Vec<&'static str> {
1584 ["subagent", "agent", "external_a2a"]
1585 .into_iter()
1586 .filter(|target_type| {
1587 self.providers
1588 .iter()
1589 .any(|provider| provider.target_type == *target_type)
1590 })
1591 .collect()
1592 }
1593
1594 fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1599 self.target_types()
1600 .into_iter()
1601 .filter_map(|target_type| match target_type {
1602 "subagent" => Some(serde_json::json!({
1603 "properties": {
1604 "type": {"const": "subagent"}
1605 }
1606 })),
1607 "agent" => Some(serde_json::json!({
1608 "properties": {
1609 "type": {"const": "agent"}
1610 },
1611 "required": ["type", "id"]
1612 })),
1613 "external_a2a" => Some(serde_json::json!({
1614 "properties": {
1615 "type": {"const": "external_a2a"}
1616 },
1617 "anyOf": [
1618 {"required": ["id"]},
1619 {"required": ["external_agent_id"]}
1620 ]
1621 })),
1622 _ => None,
1623 })
1624 .collect()
1625 }
1626
1627 }
1637
1638#[async_trait]
1639impl Tool for UnifiedSpawnAgentTool {
1640 fn narrate(
1641 &self,
1642 tool_call: &ToolCall,
1643 phase: crate::tool_narration::ToolNarrationPhase,
1644 locale: Option<&str>,
1645 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1646 ) -> Option<String> {
1647 let from_provider = tool_call
1651 .arguments
1652 .get("target")
1653 .and_then(|target| target.get("type"))
1654 .and_then(serde_json::Value::as_str)
1655 .and_then(|target_type| self.provider_for(target_type))
1656 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1657 Some(from_provider.unwrap_or_else(|| {
1658 crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1659 }))
1660 }
1661
1662 fn name(&self) -> &str {
1663 "spawn_agent"
1664 }
1665
1666 fn display_name(&self) -> Option<&str> {
1667 Some("Spawn Agent")
1668 }
1669
1670 fn description(&self) -> &str {
1671 "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."
1672 }
1673
1674 fn parameters_schema(&self) -> serde_json::Value {
1675 serde_json::json!({
1676 "type": "object",
1677 "properties": {
1678 "name": {
1679 "type": "string",
1680 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1681 },
1682 "instructions": {
1683 "type": "string",
1684 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1685 },
1686 "goal": {
1687 "type": "string",
1688 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1689 },
1690 "lifetime": {
1691 "type": "string",
1692 "enum": ["linked", "detached"],
1693 "default": "linked",
1694 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1695 },
1696 "seed": {
1697 "type": "string",
1698 "enum": ["fresh", "fork", "workspace"],
1699 "default": "fresh",
1700 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1701 },
1702 "target": {
1703 "type": "object",
1704 "properties": {
1705 "type": {
1706 "type": "string",
1707 "enum": self.target_types(),
1708 "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."
1709 },
1710 "id": {
1711 "type": "string",
1712 "description": "Configured target id for first-party handoffs or external A2A agents."
1713 },
1714 "external_agent_id": {
1715 "type": "string",
1716 "description": "Configured external A2A agent id."
1717 }
1718 },
1719 "required": ["type"],
1720 "oneOf": self.target_constraint_branches(),
1721 "additionalProperties": false
1722 },
1723 "mode": {
1724 "type": "string",
1725 "enum": ["background", "foreground"],
1726 "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."
1727 },
1728 "blueprint": {
1729 "type": "string",
1730 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1731 },
1732 "config": {
1733 "type": "object",
1734 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1735 },
1736 "result_schema": {
1737 "type": "object",
1738 "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."
1739 },
1740 "message_schema": {
1741 "type": "object",
1742 "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."
1743 },
1744 "public_context": {
1745 "type": "object",
1746 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1747 },
1748 "wait_timeout_secs": {
1749 "type": "integer",
1750 "minimum": 1,
1751 "maximum": 86400,
1752 "description": "External-A2A-only foreground timeout."
1753 },
1754 "wake_on_completion": {
1755 "type": "boolean",
1756 "description": "External-A2A-only control for background completion wake-ups."
1757 }
1758 },
1759 "required": ["name", "instructions", "target"],
1760 "additionalProperties": false
1761 })
1762 }
1763
1764 fn hints(&self) -> crate::tool_types::ToolHints {
1765 let mut hints = crate::tool_types::ToolHints::default()
1766 .with_long_running(true)
1767 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1768 if self.provider_for("external_a2a").is_some() {
1769 hints = hints.with_open_world(true);
1770 }
1771 hints
1772 }
1773
1774 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1775 ToolExecutionResult::tool_error(
1776 "spawn_agent requires context. This tool must be executed with session context.",
1777 )
1778 }
1779
1780 async fn execute_with_context(
1781 &self,
1782 arguments: serde_json::Value,
1783 context: &ToolContext,
1784 ) -> ToolExecutionResult {
1785 let target_type = match arguments
1786 .get("target")
1787 .and_then(|target| target.get("type"))
1788 .and_then(serde_json::Value::as_str)
1789 {
1790 Some(target_type) => target_type,
1791 None => {
1792 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1793 }
1794 };
1795
1796 let Some(provider) = self.provider_for(target_type) else {
1797 let supported = self.target_types().join(", ");
1798 return ToolExecutionResult::tool_error(format!(
1799 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1800 ));
1801 };
1802 if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
1803 return ToolExecutionResult::tool_error(error);
1804 }
1805 if target_type == "external_a2a"
1806 && arguments
1807 .get("lifetime")
1808 .and_then(serde_json::Value::as_str)
1809 .is_some_and(|value| value == "detached")
1810 {
1811 return ToolExecutionResult::tool_error(
1812 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1813 );
1814 }
1815 if target_type == "external_a2a"
1816 && arguments
1817 .get("message_schema")
1818 .is_some_and(|schema| !schema.is_null())
1819 {
1820 return ToolExecutionResult::tool_error(
1821 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1822 );
1823 }
1824
1825 provider.execute_with_context(arguments, context).await
1826 }
1827
1828 fn requires_context(&self) -> bool {
1829 true
1830 }
1831}
1832
1833pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1838 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1839 return base_system_prompt.to_string();
1840 };
1841
1842 if base_system_prompt.is_empty() {
1843 return additions.to_string();
1844 }
1845
1846 if base_system_prompt.contains("<system-prompt>") {
1847 format!("{base_system_prompt}\n\n{additions}")
1848 } else {
1849 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1850 }
1851}
1852
1853pub struct CollectedMessageFilters {
1860 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1862}
1863
1864pub struct CollectedModelViewProviders {
1866 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1868}
1869
1870impl CollectedMessageFilters {
1876 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1878 for (provider, config) in &self.message_filter_providers {
1879 provider.apply_filters(query, config);
1880 }
1881 }
1882
1883 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1885 for (provider, config) in &self.message_filter_providers {
1886 provider.post_load(messages, config);
1887 }
1888 }
1889}
1890
1891impl CollectedModelViewProviders {
1892 pub fn apply_model_view(
1894 &self,
1895 mut messages: Vec<Message>,
1896 context: &ModelViewContext<'_>,
1897 ) -> Vec<Message> {
1898 for (provider, config) in &self.model_view_providers {
1899 messages = provider.apply_model_view(messages, config, context);
1900 }
1901 messages
1902 }
1903}
1904
1905fn compaction_is_enabled(
1911 capability_configs: &[AgentCapabilityConfig],
1912 registry: &CapabilityRegistry,
1913) -> bool {
1914 capability_configs.iter().any(|cap_config| {
1915 registry.get(cap_config.capability_id()).is_some_and(|cap| {
1916 cap.status().is_active() && cap.compaction_policy(cap_config.config_value()).is_some()
1917 })
1918 })
1919}
1920
1921pub fn collect_message_filters_only(
1927 capability_configs: &[AgentCapabilityConfig],
1928 registry: &CapabilityRegistry,
1929) -> CollectedMessageFilters {
1930 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1931 Vec::new();
1932 let compaction_on = compaction_is_enabled(capability_configs, registry);
1933
1934 for cap_config in capability_configs {
1935 let cap_id = cap_config.capability_id();
1936 if let Some(capability) = registry.get(cap_id) {
1937 if !capability.status().is_active() {
1938 continue;
1939 }
1940 let effective: &dyn Capability = capability
1943 .resolve_for_model(None)
1944 .unwrap_or_else(|| capability.as_ref());
1945 if let Some(provider) = effective.message_filter_provider() {
1946 let config =
1947 effective.message_filter_config(cap_config.config_value(), compaction_on);
1948 message_filter_providers.push((provider, config));
1949 }
1950 }
1951 }
1952
1953 message_filter_providers.sort_by_key(|(p, _)| p.priority());
1954
1955 CollectedMessageFilters {
1956 message_filter_providers,
1957 }
1958}
1959
1960pub fn collect_model_view_providers(
1967 capability_configs: &[AgentCapabilityConfig],
1968 registry: &CapabilityRegistry,
1969 model: Option<&str>,
1970) -> CollectedModelViewProviders {
1971 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1972
1973 for cap_config in capability_configs {
1974 let cap_id = cap_config.capability_id();
1975 if let Some(capability) = registry.get(cap_id) {
1976 if !capability.status().is_active() {
1977 continue;
1978 }
1979 let effective: &dyn Capability = capability
1980 .resolve_for_model(model)
1981 .unwrap_or_else(|| capability.as_ref());
1982 if let Some(provider) = effective.model_view_provider() {
1983 model_view_providers.push((provider, cap_config.config_value().clone()));
1984 }
1985 }
1986 }
1987
1988 model_view_providers.sort_by_key(|(p, _)| p.priority());
1989
1990 CollectedModelViewProviders {
1991 model_view_providers,
1992 }
1993}
1994
1995pub fn collect_dynamic_facts(
2001 capability_configs: &[AgentCapabilityConfig],
2002 registry: &CapabilityRegistry,
2003 model: Option<&str>,
2004 ctx: &FactsContext,
2005) -> Vec<Fact> {
2006 let mut dynamic = Vec::new();
2007 for cap_config in capability_configs {
2008 let cap_id = cap_config.capability_id();
2009 if let Some(capability) = registry.get(cap_id) {
2010 if !capability.status().is_active() {
2011 continue;
2012 }
2013 let effective: &dyn Capability = capability
2014 .resolve_for_model(model)
2015 .unwrap_or_else(|| capability.as_ref());
2016 for fact in effective.facts(cap_config.config_value(), ctx) {
2017 if fact.volatility == Volatility::Dynamic {
2018 dynamic.push(fact);
2019 }
2020 }
2021 }
2022 }
2023 dynamic
2024}
2025
2026pub fn collect_capability_mcp_servers(
2027 capability_configs: &[AgentCapabilityConfig],
2028 registry: &CapabilityRegistry,
2029) -> ScopedMcpServers {
2030 let mut servers = ScopedMcpServers::default();
2031
2032 for cap_config in capability_configs {
2033 let cap_id = cap_config.capability_id();
2034 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2037 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2038 cap_config.config_value().clone(),
2039 ) {
2040 if !definition.status.is_active() {
2041 continue;
2042 }
2043 if let Some(contributed) = definition.mcp_servers {
2044 servers = merge_scoped_mcp_servers(&servers, &contributed);
2045 }
2046 }
2047 continue;
2048 }
2049 if let Some(capability) = registry.get(cap_id) {
2050 if !capability.status().is_active() {
2051 continue;
2052 }
2053 servers = merge_scoped_mcp_servers(
2054 &servers,
2055 &capability.mcp_servers_with_config(cap_config.config_value()),
2056 );
2057 }
2058 }
2059
2060 servers
2061}
2062
2063pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2070
2071#[derive(Debug, Clone, PartialEq, Eq)]
2073pub enum DependencyError {
2074 CircularDependency {
2076 capability_id: String,
2078 chain: Vec<String>,
2080 },
2081 TooManyCapabilities {
2083 count: usize,
2085 max: usize,
2087 },
2088}
2089
2090impl std::fmt::Display for DependencyError {
2091 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2092 match self {
2093 DependencyError::CircularDependency {
2094 capability_id,
2095 chain,
2096 } => {
2097 write!(
2098 f,
2099 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2100 capability_id,
2101 chain.join(" -> "),
2102 capability_id
2103 )
2104 }
2105 DependencyError::TooManyCapabilities { count, max } => {
2106 write!(
2107 f,
2108 "Too many capabilities after resolution: {} (max: {})",
2109 count, max
2110 )
2111 }
2112 }
2113 }
2114}
2115
2116impl std::error::Error for DependencyError {}
2117
2118#[derive(Debug, Clone)]
2120pub struct ResolvedCapabilities {
2121 pub resolved_ids: Vec<String>,
2124 pub added_as_dependencies: Vec<String>,
2126 pub user_selected: Vec<String>,
2128}
2129
2130pub fn resolve_dependencies(
2150 selected_ids: &[String],
2151 registry: &CapabilityRegistry,
2152) -> Result<ResolvedCapabilities, DependencyError> {
2153 use std::collections::HashSet;
2154
2155 let user_selected: HashSet<String> = selected_ids
2157 .iter()
2158 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2159 .collect();
2160 let mut resolved: Vec<String> = Vec::new();
2161 let mut resolved_set: HashSet<String> = HashSet::new();
2162 let mut added_as_dependencies: Vec<String> = Vec::new();
2163
2164 for cap_id in selected_ids {
2166 resolve_single_capability(
2167 cap_id,
2168 registry,
2169 &mut resolved,
2170 &mut resolved_set,
2171 &mut added_as_dependencies,
2172 &user_selected,
2173 &mut Vec::new(), )?;
2175 }
2176
2177 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2179 return Err(DependencyError::TooManyCapabilities {
2180 count: resolved.len(),
2181 max: MAX_RESOLVED_CAPABILITIES,
2182 });
2183 }
2184
2185 Ok(ResolvedCapabilities {
2186 resolved_ids: resolved,
2187 added_as_dependencies,
2188 user_selected: selected_ids.to_vec(),
2189 })
2190}
2191
2192pub fn resolve_capability_configs(
2197 selected_configs: &[AgentCapabilityConfig],
2198 registry: &CapabilityRegistry,
2199) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2200 let mut selected_ids: Vec<String> = Vec::new();
2201 for config in selected_configs {
2202 if (is_declarative_capability(config.capability_id())
2205 || is_plugin_capability(config.capability_id()))
2206 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2207 config.config_value().clone(),
2208 )
2209 {
2210 selected_ids.extend(definition.dependencies);
2211 }
2212 selected_ids.push(config.capability_id().to_string());
2213 }
2214 let resolved = resolve_dependencies(&selected_ids, registry)?;
2215
2216 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2219 .iter()
2220 .map(|config| {
2221 let id = config.capability_id();
2222 let id = registry.canonical_id(id).unwrap_or(id);
2223 (id.to_string(), config.config_value().clone())
2224 })
2225 .collect();
2226
2227 Ok(resolved
2228 .resolved_ids
2229 .into_iter()
2230 .map(|capability_id| {
2231 explicit_configs
2232 .get(&capability_id)
2233 .cloned()
2234 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2235 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2236 })
2237 .collect())
2238}
2239
2240fn resolve_single_capability(
2242 cap_id: &str,
2243 registry: &CapabilityRegistry,
2244 resolved: &mut Vec<String>,
2245 resolved_set: &mut std::collections::HashSet<String>,
2246 added_as_dependencies: &mut Vec<String>,
2247 user_selected: &std::collections::HashSet<String>,
2248 visiting: &mut Vec<String>,
2249) -> Result<(), DependencyError> {
2250 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2254
2255 if resolved_set.contains(cap_id) {
2257 return Ok(());
2258 }
2259
2260 if visiting.contains(&cap_id.to_string()) {
2262 return Err(DependencyError::CircularDependency {
2263 capability_id: cap_id.to_string(),
2264 chain: visiting.clone(),
2265 });
2266 }
2267
2268 let capability = match registry.get(cap_id) {
2270 Some(cap) => cap,
2271 None => {
2272 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2276 && !resolved_set.contains(cap_id)
2277 {
2278 resolved.push(cap_id.to_string());
2279 resolved_set.insert(cap_id.to_string());
2280 if !user_selected.contains(cap_id) {
2281 added_as_dependencies.push(cap_id.to_string());
2282 }
2283 }
2284 return Ok(());
2285 }
2286 };
2287
2288 visiting.push(cap_id.to_string());
2290
2291 for dep_id in capability.dependencies() {
2293 resolve_single_capability(
2294 dep_id,
2295 registry,
2296 resolved,
2297 resolved_set,
2298 added_as_dependencies,
2299 user_selected,
2300 visiting,
2301 )?;
2302 }
2303
2304 visiting.pop();
2306
2307 if !resolved_set.contains(cap_id) {
2309 resolved.push(cap_id.to_string());
2310 resolved_set.insert(cap_id.to_string());
2311
2312 if !user_selected.contains(cap_id) {
2314 added_as_dependencies.push(cap_id.to_string());
2315 }
2316 }
2317
2318 Ok(())
2319}
2320
2321pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2326 use std::collections::HashSet;
2327
2328 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2329 Ok(resolved) => resolved.resolved_ids,
2330 Err(_) => capability_ids.to_vec(),
2331 };
2332
2333 let mut seen = HashSet::new();
2334 let mut features = Vec::new();
2335 for cap_id in &resolved_ids {
2336 if let Some(cap) = registry.get(cap_id) {
2337 for feature in cap.features() {
2338 if seen.insert(feature) {
2339 features.push(feature.to_string());
2340 }
2341 }
2342 }
2343 }
2344 features
2345}
2346
2347pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2350 registry
2351 .get(cap_id)
2352 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2353 .unwrap_or_default()
2354}
2355
2356pub async fn collect_capabilities(
2372 capability_ids: &[String],
2373 registry: &CapabilityRegistry,
2374 ctx: &SystemPromptContext,
2375) -> CollectedCapabilities {
2376 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2379 Ok(resolved) => resolved.resolved_ids,
2380 Err(e) => {
2381 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2382 capability_ids.to_vec()
2383 }
2384 };
2385
2386 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2388 .iter()
2389 .map(|id| {
2390 AgentCapabilityConfig::with_config(
2391 CapabilityId::new(id),
2392 serde_json::Value::Object(serde_json::Map::new()),
2393 )
2394 })
2395 .collect();
2396
2397 collect_capabilities_with_configs(&configs, registry, ctx).await
2398}
2399
2400pub async fn collect_capabilities_with_configs(
2411 capability_configs: &[AgentCapabilityConfig],
2412 registry: &CapabilityRegistry,
2413 ctx: &SystemPromptContext,
2414) -> CollectedCapabilities {
2415 let mut system_prompt_parts: Vec<String> = Vec::new();
2416 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2417 let mut conversation_context_parts: Vec<String> = Vec::new();
2418 let mut conversation_context_attributions: Vec<SystemPromptAttribution> = Vec::new();
2419 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2420 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2421 let mut mounts: Vec<MountPoint> = Vec::new();
2422 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2423 Vec::new();
2424 let mut applied_ids: Vec<String> = Vec::new();
2425 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2426 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2427 let mut driver_options: HashMap<String, serde_json::Value> = HashMap::new();
2428 let mut parallel_tool_calls: Option<bool> = None;
2429 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2430 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2431 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2434 let mut mcp_servers = ScopedMcpServers::default();
2435 let mut static_facts: Vec<Fact> = Vec::new();
2439 let mut has_dynamic_facts = false;
2440 let facts_ctx = FactsContext::new(ctx.session_id);
2441 let compaction_on = compaction_is_enabled(capability_configs, registry);
2442 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2443
2444 for cap_config in capability_configs {
2445 let cap_id = cap_config.capability_id();
2446 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2451 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2452 cap_config.config_value().clone(),
2453 ) {
2454 Ok(definition) => {
2455 if !definition.status.is_active() {
2456 continue;
2457 }
2458
2459 if let Some(prompt) = definition.system_prompt.as_deref() {
2460 let contribution =
2461 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2462 system_prompt_attributions.push(SystemPromptAttribution {
2463 capability_id: cap_id.to_string(),
2464 content: contribution.clone(),
2465 });
2466 system_prompt_parts.push(contribution);
2467 }
2468
2469 mounts.extend(definition.mounts(cap_id));
2470 if let Some(ref servers) = definition.mcp_servers {
2471 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2472 }
2473 for skill in definition.skill_contributions() {
2474 mounts.push(skill.to_mount(cap_id));
2475 }
2476
2477 applied_ids.push(cap_id.to_string());
2478 }
2479 Err(error) => {
2480 tracing::warn!(
2481 capability_id = %cap_id,
2482 error = %error,
2483 "Skipping invalid declarative/plugin capability config"
2484 );
2485 }
2486 }
2487 continue;
2488 }
2489 if let Some(capability) = registry.get(cap_id) {
2490 if !capability.status().is_active() {
2494 continue;
2495 }
2496
2497 let effective: &dyn Capability =
2509 match capability.resolve_for_model(ctx.model.as_deref()) {
2510 Some(inner) => inner,
2511 None => capability.as_ref(),
2512 };
2513 let delegation_target =
2514 effective.delegation_target_with_config(cap_config.config_value());
2515
2516 if let Some(contribution) = effective
2518 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2519 .await
2520 {
2521 system_prompt_attributions.push(SystemPromptAttribution {
2522 capability_id: cap_id.to_string(),
2523 content: contribution.clone(),
2524 });
2525 system_prompt_parts.push(contribution);
2526 }
2527
2528 if let Some(contribution) = effective
2533 .conversation_context_contribution_with_config(ctx, cap_config.config_value())
2534 .await
2535 {
2536 conversation_context_attributions.push(SystemPromptAttribution {
2537 capability_id: cap_id.to_string(),
2538 content: contribution.clone(),
2539 });
2540 conversation_context_parts.push(contribution);
2541 }
2542
2543 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2548 match fact.volatility {
2549 Volatility::Static => static_facts.push(fact),
2550 Volatility::Dynamic => has_dynamic_facts = true,
2551 }
2552 }
2553
2554 tools.extend(effective.tools_with_config(cap_config.config_value()));
2556 if let Some(target) = delegation_target {
2557 delegation_targets.push(target);
2558 }
2559 tool_definition_hooks.extend(
2560 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2561 );
2562 tool_call_hooks.extend(effective.tool_call_hooks());
2563 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2565 let cap_category = effective.category();
2570 for def in effective.tool_definitions() {
2571 let def = match (def.category(), cap_category) {
2572 (None, Some(cat)) => def.with_category(cat),
2573 _ => def,
2574 }
2575 .with_capability_attribution(cap_id, Some(capability.name()));
2576 tool_definitions.push(def);
2577 }
2578
2579 tool_search = effective
2580 .tool_search_config(cap_config.config_value())
2581 .or(tool_search);
2582 prompt_cache = effective
2583 .prompt_cache_config(cap_config.config_value())
2584 .or(prompt_cache);
2585 parallel_tool_calls = effective
2586 .parallel_tool_calls_preference(cap_config.config_value())
2587 .or(parallel_tool_calls);
2588
2589 for (key, value) in effective.driver_options(cap_config.config_value()) {
2590 driver_options.entry(key).or_insert(value);
2591 }
2592
2593 mounts.extend(effective.mounts());
2595
2596 mcp_servers = merge_scoped_mcp_servers(
2597 &mcp_servers,
2598 &effective.mcp_servers_with_config(cap_config.config_value()),
2599 );
2600
2601 for skill in effective.contribute_skills() {
2605 mounts.push(skill.to_mount(cap_id));
2606 }
2607
2608 if let Some(provider) = effective.message_filter_provider() {
2610 let config =
2611 effective.message_filter_config(cap_config.config_value(), compaction_on);
2612 message_filter_providers.push((provider, config));
2613 }
2614
2615 applied_ids.push(cap_id.to_string());
2616 }
2617 }
2618
2619 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2622 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2623 let def = tool
2624 .to_definition()
2625 .with_category("Orchestration")
2626 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2627 tools.push(Box::new(tool));
2628 tool_definitions.push(def);
2629 }
2630
2631 let auto_activated: Vec<_> = registry
2634 .list()
2635 .into_iter()
2636 .filter(|cap| {
2637 !applied_ids.iter().any(|id| id == cap.id())
2638 && cap.status().is_active()
2639 && cap.auto_activates_for(&tool_definitions)
2640 })
2641 .cloned()
2642 .collect();
2643 for cap in auto_activated {
2644 tools.extend(cap.tools());
2645 let cap_category = cap.category();
2646 for def in cap.tool_definitions() {
2647 let def = match (def.category(), cap_category) {
2648 (None, Some(cat)) => def.with_category(cat),
2649 _ => def,
2650 }
2651 .with_capability_attribution(cap.id(), Some(cap.name()));
2652 tool_definitions.push(def);
2653 }
2654 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2655 applied_ids.push(cap.id().to_string());
2656 }
2657
2658 if let Some(block) = facts::render_facts_block(&static_facts) {
2663 system_prompt_attributions.push(SystemPromptAttribution {
2664 capability_id: "facts".to_string(),
2665 content: block.clone(),
2666 });
2667 system_prompt_parts.push(block);
2668 }
2669 if has_dynamic_facts {
2670 system_prompt_attributions.push(SystemPromptAttribution {
2671 capability_id: "facts".to_string(),
2672 content: FACTS_DYNAMIC_NOTE.to_string(),
2673 });
2674 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2675 }
2676
2677 tool_call_hooks.extend(narration_hooks);
2681
2682 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2684
2685 CollectedCapabilities {
2686 system_prompt_parts,
2687 system_prompt_attributions,
2688 conversation_context_parts,
2689 conversation_context_attributions,
2690 tools,
2691 tool_definitions,
2692 mounts,
2693 message_filter_providers,
2694 applied_ids,
2695 tool_search,
2696 prompt_cache,
2697 driver_options,
2698 parallel_tool_calls,
2699 tool_definition_hooks,
2700 tool_call_hooks,
2701 mcp_servers,
2702 }
2703}
2704
2705pub struct AppliedCapabilities {
2711 pub runtime_agent: RuntimeAgent,
2713 pub tool_registry: ToolRegistry,
2715 pub applied_ids: Vec<String>,
2717}
2718
2719pub async fn apply_capabilities(
2755 base_runtime_agent: RuntimeAgent,
2756 capability_ids: &[String],
2757 registry: &CapabilityRegistry,
2758 ctx: &SystemPromptContext,
2759) -> AppliedCapabilities {
2760 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2761
2762 let final_system_prompt = compose_system_prompt(
2764 &base_runtime_agent.system_prompt,
2765 collected.system_prompt_prefix().as_deref(),
2766 );
2767
2768 let conversation_context = collected.conversation_context();
2772 let mut tool_registry = ToolRegistry::new();
2774 for tool in collected.tools {
2775 tool_registry.register_boxed(tool);
2776 }
2777
2778 let mut tools = collected.tool_definitions;
2780 for hook in &collected.tool_definition_hooks {
2781 tools = hook.transform(tools);
2782 }
2783
2784 let runtime_agent = RuntimeAgent {
2785 system_prompt: final_system_prompt,
2786 model: base_runtime_agent.model,
2787 tools,
2788 max_iterations: base_runtime_agent.max_iterations,
2789 temperature: base_runtime_agent.temperature,
2790 max_tokens: base_runtime_agent.max_tokens,
2791 tool_search: collected.tool_search,
2792 prompt_cache: collected.prompt_cache,
2793 driver_options: collected.driver_options,
2794 network_access: base_runtime_agent.network_access,
2795 parallel_tool_calls: base_runtime_agent
2798 .parallel_tool_calls
2799 .or(collected.parallel_tool_calls),
2800 conversation_context,
2803 };
2804
2805 AppliedCapabilities {
2806 runtime_agent,
2807 tool_registry,
2808 applied_ids: collected.applied_ids,
2809 }
2810}
2811
2812#[cfg(test)]
2817mod tests {
2818 use super::*;
2819 use crate::typed_id::SessionId;
2820 use uuid::Uuid;
2821
2822 fn test_ctx() -> SystemPromptContext {
2824 SystemPromptContext::without_file_store(SessionId::new())
2825 }
2826
2827 struct StubSubagentSpawnTool;
2836
2837 #[async_trait]
2838 impl Tool for StubSubagentSpawnTool {
2839 fn name(&self) -> &str {
2840 "spawn_agent"
2841 }
2842 fn description(&self) -> &str {
2843 "stub subagent delegation"
2844 }
2845 fn parameters_schema(&self) -> serde_json::Value {
2846 serde_json::json!({ "type": "object" })
2847 }
2848 fn narrate(
2849 &self,
2850 tool_call: &ToolCall,
2851 phase: crate::tool_narration::ToolNarrationPhase,
2852 locale: Option<&str>,
2853 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2854 ) -> Option<String> {
2855 Some(crate::tool_narration::narrate_subagent_spawn(
2856 &tool_call.arguments,
2857 phase,
2858 locale,
2859 ))
2860 }
2861 async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2862 crate::ToolExecutionResult::success(serde_json::json!({}))
2863 }
2864 }
2865
2866 fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2867 ToolCall {
2868 id: "call-1".to_string(),
2869 name: "spawn_agent".to_string(),
2870 arguments,
2871 }
2872 }
2873
2874 #[test]
2877 fn unified_spawn_agent_narration_names_the_agent() {
2878 let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
2879 target_type: "subagent",
2880 tool: Box::new(StubSubagentSpawnTool),
2881 }]);
2882 let ctx = crate::tool_narration::ToolNarrationContext::default();
2883
2884 assert_eq!(
2885 tool.narrate(
2886 &spawn_agent_call(serde_json::json!({
2887 "name": "Orbit Scout",
2888 "target": { "type": "subagent" },
2889 "blueprint": "github_scout"
2890 })),
2891 crate::tool_narration::ToolNarrationPhase::Started,
2892 None,
2893 ctx,
2894 )
2895 .as_deref(),
2896 Some("Launching Orbit Scout subagent (github_scout)")
2897 );
2898
2899 assert_eq!(
2900 tool.narrate(
2901 &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
2902 crate::tool_narration::ToolNarrationPhase::Started,
2903 None,
2904 ctx,
2905 )
2906 .as_deref(),
2907 Some("Launching Orbit Scout subagent")
2908 );
2909 }
2910
2911 #[test]
2912 fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
2913 for target_type in ["agent", "external_a2a"] {
2914 let arguments = serde_json::json!({
2915 "target": { "type": target_type, "id": "actual-target" },
2916 "blueprint": "decoy-target"
2917 });
2918 assert_eq!(
2919 validate_spawn_agent_target_fields(&arguments, target_type),
2920 Err(format!(
2921 "blueprint is only valid for subagent targets, not {target_type}."
2922 ))
2923 );
2924
2925 let arguments = serde_json::json!({
2926 "target": { "type": target_type, "id": "actual-target" },
2927 "config": { "model": "decoy" }
2928 });
2929 assert_eq!(
2930 validate_spawn_agent_target_fields(&arguments, target_type),
2931 Err(format!(
2932 "config is only valid for subagent targets, not {target_type}."
2933 ))
2934 );
2935 }
2936 }
2937
2938 struct NoopFixture;
2940
2941 impl Capability for NoopFixture {
2942 fn id(&self) -> &str {
2943 "noop"
2944 }
2945 fn name(&self) -> &str {
2946 "No-Op"
2947 }
2948 fn description(&self) -> &str {
2949 "Contributes nothing."
2950 }
2951 }
2952
2953 struct FeatureFixture;
2955
2956 impl Capability for FeatureFixture {
2957 fn id(&self) -> &str {
2958 "feature_fixture"
2959 }
2960 fn name(&self) -> &str {
2961 "Feature Fixture"
2962 }
2963 fn description(&self) -> &str {
2964 "Declares one test-only feature."
2965 }
2966 fn features(&self) -> Vec<&'static str> {
2967 vec!["fixture_feature"]
2968 }
2969 }
2970
2971 struct FixtureTool(&'static str);
2972
2973 #[async_trait]
2974 impl Tool for FixtureTool {
2975 fn name(&self) -> &str {
2976 self.0
2977 }
2978 fn description(&self) -> &str {
2979 "Fixture tool."
2980 }
2981 fn parameters_schema(&self) -> serde_json::Value {
2982 serde_json::json!({
2983 "type": "object",
2984 "properties": {},
2985 "additionalProperties": false
2986 })
2987 }
2988 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2989 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2990 }
2991 }
2992
2993 struct BackgroundFixtureTool;
2994
2995 #[async_trait]
2996 impl Tool for BackgroundFixtureTool {
2997 fn name(&self) -> &str {
2998 "bash"
2999 }
3000 fn description(&self) -> &str {
3001 "Fixture background-capable shell tool."
3002 }
3003 fn parameters_schema(&self) -> serde_json::Value {
3004 serde_json::json!({"type": "object"})
3005 }
3006 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3007 ToolExecutionResult::success(serde_json::json!({"ok": true}))
3008 }
3009 fn hints(&self) -> crate::tool_types::ToolHints {
3010 crate::tool_types::ToolHints {
3011 supports_background: Some(true),
3012 ..Default::default()
3013 }
3014 }
3015 }
3016
3017 struct FileSystemFixture;
3018
3019 impl Capability for FileSystemFixture {
3020 fn id(&self) -> &str {
3021 "session_file_system"
3022 }
3023 fn name(&self) -> &str {
3024 "Fixture Filesystem"
3025 }
3026 fn description(&self) -> &str {
3027 "Fixture filesystem capability."
3028 }
3029 fn tools(&self) -> Vec<Box<dyn Tool>> {
3030 vec![
3031 Box::new(FixtureTool("read_file")),
3032 Box::new(FixtureTool("write_file")),
3033 ]
3034 }
3035 fn features(&self) -> Vec<&'static str> {
3036 vec!["file_system"]
3037 }
3038 }
3039
3040 struct StorageFixture;
3045
3046 impl Capability for StorageFixture {
3047 fn id(&self) -> &str {
3048 "session_storage"
3049 }
3050 fn name(&self) -> &str {
3051 "Fixture Storage"
3052 }
3053 fn description(&self) -> &str {
3054 "Fixture session storage capability."
3055 }
3056 fn features(&self) -> Vec<&'static str> {
3057 vec!["secrets", "key_value"]
3058 }
3059 }
3060
3061 struct BashFixture;
3062
3063 impl Capability for BashFixture {
3064 fn id(&self) -> &str {
3065 "bashkit_shell"
3066 }
3067 fn aliases(&self) -> Vec<&'static str> {
3068 vec!["virtual_bash"]
3069 }
3070 fn name(&self) -> &str {
3071 "Fixture Bash"
3072 }
3073 fn description(&self) -> &str {
3074 "Fixture shell capability."
3075 }
3076 fn tools(&self) -> Vec<Box<dyn Tool>> {
3077 vec![Box::new(BackgroundFixtureTool)]
3078 }
3079 fn dependencies(&self) -> Vec<&'static str> {
3080 vec!["session_file_system"]
3081 }
3082 fn features(&self) -> Vec<&'static str> {
3083 vec!["file_system"]
3084 }
3085 fn risk_level(&self) -> RiskLevel {
3086 RiskLevel::High
3087 }
3088 }
3089
3090 struct WebFetchFixture;
3091
3092 impl Capability for WebFetchFixture {
3093 fn id(&self) -> &str {
3094 "web_fetch"
3095 }
3096 fn name(&self) -> &str {
3097 "Fixture Web Fetch"
3098 }
3099 fn description(&self) -> &str {
3100 "Fixture web capability."
3101 }
3102 fn risk_level(&self) -> RiskLevel {
3103 RiskLevel::High
3104 }
3105 }
3106
3107 struct DynamicFactFixture;
3110
3111 impl Capability for DynamicFactFixture {
3112 fn id(&self) -> &str {
3113 "current_time"
3114 }
3115 fn name(&self) -> &str {
3116 "Dynamic Fact Fixture"
3117 }
3118 fn description(&self) -> &str {
3119 "Fixture with one dynamic fact and one tool."
3120 }
3121 fn icon(&self) -> Option<&str> {
3122 Some("clock")
3123 }
3124 fn category(&self) -> Option<&str> {
3125 Some("Core")
3126 }
3127 fn tools(&self) -> Vec<Box<dyn Tool>> {
3128 vec![Box::new(FixtureTool("get_current_time"))]
3129 }
3130 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3131 vec![Fact::dynamic("current_time", "fixture-now")]
3132 }
3133 }
3134
3135 struct PromptToolFixture;
3136
3137 impl Capability for PromptToolFixture {
3138 fn id(&self) -> &str {
3139 "prompt_tool_fixture"
3140 }
3141 fn name(&self) -> &str {
3142 "Prompt Tool Fixture"
3143 }
3144 fn description(&self) -> &str {
3145 "Fixture with a static prompt and tool."
3146 }
3147 fn system_prompt_addition(&self) -> Option<&str> {
3148 Some("Task Management uses the write_todos tool.")
3149 }
3150 fn tools(&self) -> Vec<Box<dyn Tool>> {
3151 vec![Box::new(FixtureTool("write_todos"))]
3152 }
3153 }
3154
3155 struct SecondPromptFixture;
3156
3157 impl Capability for SecondPromptFixture {
3158 fn id(&self) -> &str {
3159 "second_prompt_fixture"
3160 }
3161 fn name(&self) -> &str {
3162 "Second Prompt Fixture"
3163 }
3164 fn description(&self) -> &str {
3165 "Fixture with a second static prompt."
3166 }
3167 fn system_prompt_addition(&self) -> Option<&str> {
3168 Some("A second capability prompt contribution.")
3169 }
3170 }
3171
3172 struct DynamicPreviewFixture;
3173
3174 impl Capability for DynamicPreviewFixture {
3175 fn id(&self) -> &str {
3176 "agent_instructions"
3177 }
3178 fn name(&self) -> &str {
3179 "Dynamic Preview Fixture"
3180 }
3181 fn description(&self) -> &str {
3182 "Fixture whose runtime prompt is dynamic."
3183 }
3184 fn system_prompt_preview(&self) -> Option<String> {
3185 Some("Reads AGENTS.md dynamically.".to_string())
3186 }
3187 }
3188
3189 struct MathFixture;
3191
3192 impl Capability for MathFixture {
3193 fn id(&self) -> &str {
3194 "test_math"
3195 }
3196 fn name(&self) -> &str {
3197 "Test Math"
3198 }
3199 fn description(&self) -> &str {
3200 "Fixture: calculator tools."
3201 }
3202 fn tools(&self) -> Vec<Box<dyn Tool>> {
3203 vec![
3204 Box::new(FixtureTool("add")),
3205 Box::new(FixtureTool("subtract")),
3206 Box::new(FixtureTool("multiply")),
3207 Box::new(FixtureTool("divide")),
3208 ]
3209 }
3210 }
3211
3212 struct WeatherFixture;
3214
3215 impl Capability for WeatherFixture {
3216 fn id(&self) -> &str {
3217 "test_weather"
3218 }
3219 fn name(&self) -> &str {
3220 "Test Weather"
3221 }
3222 fn description(&self) -> &str {
3223 "Fixture: weather tools."
3224 }
3225 fn tools(&self) -> Vec<Box<dyn Tool>> {
3226 vec![
3227 Box::new(FixtureTool("get_weather")),
3228 Box::new(FixtureTool("get_forecast")),
3229 ]
3230 }
3231 }
3232
3233 struct SampleDataFixture;
3236
3237 impl Capability for SampleDataFixture {
3238 fn id(&self) -> &str {
3239 "sample_data"
3240 }
3241 fn name(&self) -> &str {
3242 "Sample Data"
3243 }
3244 fn description(&self) -> &str {
3245 "Fixture: mounted sample files."
3246 }
3247 fn system_prompt_addition(&self) -> Option<&str> {
3248 Some("Read-only sample files are mounted at `/samples`.")
3249 }
3250 fn mounts(&self) -> Vec<MountPoint> {
3251 let samples_dir = MountDirectoryBuilder::new()
3252 .file("users.json", "[]")
3253 .build();
3254 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3255 }
3256 fn dependencies(&self) -> Vec<&'static str> {
3257 vec!["session_file_system"]
3258 }
3259 fn features(&self) -> Vec<&'static str> {
3260 vec!["file_system"]
3261 }
3262 }
3263
3264 fn fixture_registry() -> CapabilityRegistry {
3266 let mut registry = CapabilityRegistry::new();
3267 registry.register(NoopFixture);
3268 registry.register(FeatureFixture);
3269 registry.register(MathFixture);
3270 registry.register(WeatherFixture);
3271 registry.register(SampleDataFixture);
3272 registry.register(FileSystemFixture);
3273 registry.register(StorageFixture);
3274 registry.register(BashFixture);
3275 registry.register(WebFetchFixture);
3276 registry.register(DynamicFactFixture);
3277 registry.register(PromptToolFixture);
3278 registry.register(SecondPromptFixture);
3279 registry.register(DynamicPreviewFixture);
3280 registry
3281 }
3282
3283 struct HostAnnotatedCapability;
3285
3286 #[async_trait]
3287 impl Capability for HostAnnotatedCapability {
3288 fn id(&self) -> &str {
3289 "host_annotated"
3290 }
3291 fn name(&self) -> &str {
3292 "Host Annotated"
3293 }
3294 fn description(&self) -> &str {
3295 "Test capability with host-owned metadata."
3296 }
3297 fn metadata(&self) -> Option<serde_json::Value> {
3298 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3299 }
3300 }
3301
3302 #[test]
3303 fn test_capability_registry_get() {
3304 let mut registry = CapabilityRegistry::new();
3305 registry.register(NoopFixture);
3306
3307 let capability = registry.get("noop").unwrap();
3308 assert_eq!(capability.id(), "noop");
3309 assert_eq!(capability.status(), CapabilityStatus::Available);
3310 }
3311
3312 #[test]
3313 fn default_registry_is_empty_and_selects_no_product_preset() {
3314 assert!(CapabilityRegistry::default().is_empty());
3315 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3316 }
3317
3318 #[tokio::test]
3319 async fn test_capability_registry_blueprint_with_capability() {
3320 struct BlueprintProviderCapability;
3321
3322 impl Capability for BlueprintProviderCapability {
3323 fn id(&self) -> &str {
3324 "blueprint_provider"
3325 }
3326 fn name(&self) -> &str {
3327 "Blueprint Provider"
3328 }
3329 fn description(&self) -> &str {
3330 "Capability that provides a blueprint for tests"
3331 }
3332 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3333 vec![AgentBlueprint {
3334 id: "test_blueprint",
3335 name: "Test Blueprint",
3336 description: "Blueprint for capability registry tests",
3337 model: BlueprintModel::Fixed("specialist-model".into()),
3338 system_prompt: "Test prompt",
3339 tools: vec![Box::new(FixtureTool("private_lookup"))],
3340 max_turns: Some(7),
3341 config_schema: Some(
3342 serde_json::json!({"type":"object", "required":["repository"]}),
3343 ),
3344 }]
3345 }
3346 }
3347
3348 let mut registry = CapabilityRegistry::new();
3349 registry.register(BlueprintProviderCapability);
3350
3351 let (capability_id, blueprint) = registry
3352 .blueprint_with_capability("test_blueprint")
3353 .expect("blueprint should resolve with capability id");
3354 assert_eq!(capability_id, "blueprint_provider");
3355 assert_eq!(blueprint.id, "test_blueprint");
3356 assert_eq!(blueprint.name, "Test Blueprint");
3357 assert_eq!(
3358 blueprint.description,
3359 "Blueprint for capability registry tests"
3360 );
3361 assert_eq!(blueprint.system_prompt, "Test prompt");
3362 assert!(
3363 matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
3364 );
3365 assert_eq!(blueprint.max_turns, Some(7));
3366 assert_eq!(
3367 blueprint.config_schema,
3368 Some(serde_json::json!({"type":"object", "required":["repository"]}))
3369 );
3370 let definitions = blueprint.tool_definitions();
3371 assert_eq!(definitions.len(), 1);
3372 assert_eq!(definitions[0].name(), "private_lookup");
3373 assert_eq!(
3374 registry.blueprint("test_blueprint").unwrap().tools[0].name(),
3375 "private_lookup"
3376 );
3377 assert_eq!(
3378 registry
3379 .all_blueprints()
3380 .iter()
3381 .map(|b| b.id)
3382 .collect::<Vec<_>>(),
3383 ["test_blueprint"]
3384 );
3385 assert!(registry.blueprint_with_capability("missing").is_none());
3386 assert!(registry.blueprint("missing").is_none());
3387 let host =
3388 collect_capabilities(&["blueprint_provider".into()], ®istry, &test_ctx()).await;
3389 assert!(host.tools.is_empty());
3390 assert!(host.tool_definitions.is_empty());
3391 }
3392
3393 #[test]
3394 fn test_capability_registry_builder() {
3395 let registry = CapabilityRegistry::builder()
3396 .capability(NoopFixture)
3397 .build();
3398
3399 assert!(registry.has("noop"));
3400 assert_eq!(registry.len(), 1);
3401 }
3402
3403 #[test]
3404 fn test_system_prompt_preview_default_delegates_to_addition() {
3405 struct StaticPromptCapability;
3408 impl Capability for StaticPromptCapability {
3409 fn id(&self) -> &str {
3410 "static_prompt"
3411 }
3412 fn name(&self) -> &str {
3413 "Static Prompt"
3414 }
3415 fn description(&self) -> &str {
3416 "Static prompt addition."
3417 }
3418 fn system_prompt_addition(&self) -> Option<&str> {
3419 Some("Use the static prompt.")
3420 }
3421 }
3422
3423 let cap = StaticPromptCapability;
3424 assert_eq!(
3425 cap.system_prompt_preview().as_deref(),
3426 Some("Use the static prompt.")
3427 );
3428
3429 let registry = fixture_registry();
3431 let current_time = registry.get("current_time").unwrap();
3432 assert!(current_time.system_prompt_preview().is_none());
3433 assert!(current_time.system_prompt_addition().is_none());
3434 }
3435
3436 #[tokio::test]
3441 async fn test_apply_capabilities_empty() {
3442 let registry = CapabilityRegistry::new();
3443 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3444
3445 let applied =
3446 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3447
3448 assert_eq!(
3449 applied.runtime_agent.system_prompt,
3450 base_runtime_agent.system_prompt
3451 );
3452 assert!(applied.tool_registry.is_empty());
3453 assert!(applied.applied_ids.is_empty());
3454 }
3455
3456 #[tokio::test]
3457 async fn test_apply_capabilities_noop() {
3458 let registry = fixture_registry();
3459 let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3460
3461 base_runtime_agent.max_iterations = 13;
3462 base_runtime_agent.temperature = Some(0.25);
3463 base_runtime_agent.max_tokens = Some(1234);
3464 base_runtime_agent.parallel_tool_calls = Some(false);
3465 let applied = apply_capabilities(
3466 base_runtime_agent.clone(),
3467 &["noop".to_string()],
3468 ®istry,
3469 &test_ctx(),
3470 )
3471 .await;
3472
3473 assert_eq!(
3475 applied.runtime_agent.system_prompt,
3476 base_runtime_agent.system_prompt
3477 );
3478 assert!(applied.tool_registry.is_empty());
3479 assert_eq!(applied.applied_ids, vec!["noop"]);
3480 assert_eq!(
3481 serde_json::to_value(&applied.runtime_agent).unwrap(),
3482 serde_json::to_value(&base_runtime_agent).unwrap()
3483 );
3484 let collected = collect_capabilities(&["noop".into()], ®istry, &test_ctx()).await;
3485 assert!(collected.mounts.is_empty());
3486 assert!(collected.message_filter_providers.is_empty());
3487 assert!(compute_features(&["noop".into()], ®istry).is_empty());
3488 }
3489
3490 #[tokio::test]
3491 async fn test_apply_capabilities_current_time() {
3492 let registry = fixture_registry();
3493 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3494
3495 let applied = apply_capabilities(
3496 base_runtime_agent.clone(),
3497 &["current_time".to_string()],
3498 ®istry,
3499 &test_ctx(),
3500 )
3501 .await;
3502
3503 assert!(
3507 applied
3508 .runtime_agent
3509 .system_prompt
3510 .contains(FACTS_DYNAMIC_NOTE),
3511 "current_time should contribute the dynamic-facts note"
3512 );
3513 assert!(
3514 applied
3515 .runtime_agent
3516 .system_prompt
3517 .contains(&base_runtime_agent.system_prompt),
3518 "base prompt is preserved"
3519 );
3520 assert!(applied.tool_registry.has("get_current_time"));
3521 assert_eq!(applied.tool_registry.len(), 1);
3522 assert_eq!(applied.applied_ids, vec!["current_time"]);
3523 }
3524
3525 #[tokio::test]
3526 async fn test_apply_capabilities_skips_coming_soon() {
3527 struct ComingSoonFixture;
3528 impl Capability for ComingSoonFixture {
3529 fn id(&self) -> &str {
3530 "coming_soon_fixture"
3531 }
3532 fn name(&self) -> &str {
3533 "Coming Soon Fixture"
3534 }
3535 fn description(&self) -> &str {
3536 "Test-only capability."
3537 }
3538 fn status(&self) -> CapabilityStatus {
3539 CapabilityStatus::ComingSoon
3540 }
3541 fn system_prompt_addition(&self) -> Option<&str> {
3542 Some("Not yet available.")
3543 }
3544 }
3545 let mut registry = CapabilityRegistry::new();
3546 registry.register(ComingSoonFixture);
3547 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3548
3549 let applied = apply_capabilities(
3550 base_runtime_agent.clone(),
3551 &["coming_soon_fixture".to_string()],
3552 ®istry,
3553 &test_ctx(),
3554 )
3555 .await;
3556
3557 assert_eq!(
3558 applied.runtime_agent.system_prompt,
3559 base_runtime_agent.system_prompt
3560 );
3561 assert!(applied.applied_ids.is_empty());
3562 }
3563
3564 #[tokio::test]
3568 async fn test_apply_capabilities_keeps_deprecated_fully_functional() {
3569 struct DeprecatedFixture;
3570 impl Capability for DeprecatedFixture {
3571 fn id(&self) -> &str {
3572 "deprecated_fixture"
3573 }
3574 fn name(&self) -> &str {
3575 "Deprecated Fixture"
3576 }
3577 fn description(&self) -> &str {
3578 "Test-only capability."
3579 }
3580 fn status(&self) -> CapabilityStatus {
3581 CapabilityStatus::Deprecated
3582 }
3583 fn system_prompt_addition(&self) -> Option<&str> {
3584 Some("Still working.")
3585 }
3586 }
3587 let mut registry = CapabilityRegistry::new();
3588 registry.register(DeprecatedFixture);
3589 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3590
3591 let applied = apply_capabilities(
3592 base_runtime_agent,
3593 &["deprecated_fixture".to_string()],
3594 ®istry,
3595 &test_ctx(),
3596 )
3597 .await;
3598
3599 assert!(
3600 applied
3601 .runtime_agent
3602 .system_prompt
3603 .contains("Still working.")
3604 );
3605 assert_eq!(applied.applied_ids, vec!["deprecated_fixture"]);
3606 }
3607
3608 #[tokio::test]
3612 async fn test_apply_capabilities_skips_retired_without_failing() {
3613 struct RetiredFixture;
3614 impl Capability for RetiredFixture {
3615 fn id(&self) -> &str {
3616 "retired_fixture"
3617 }
3618 fn name(&self) -> &str {
3619 "Retired Fixture"
3620 }
3621 fn description(&self) -> &str {
3622 "Test-only capability."
3623 }
3624 fn status(&self) -> CapabilityStatus {
3625 CapabilityStatus::Retired
3626 }
3627 fn system_prompt_addition(&self) -> Option<&str> {
3628 Some("Should never be applied.")
3629 }
3630 }
3631 let mut registry = fixture_registry();
3632 registry.register(RetiredFixture);
3633 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3634
3635 let applied = apply_capabilities(
3636 base_runtime_agent,
3637 &["retired_fixture".to_string(), "current_time".to_string()],
3638 ®istry,
3639 &test_ctx(),
3640 )
3641 .await;
3642
3643 assert!(
3644 !applied
3645 .runtime_agent
3646 .system_prompt
3647 .contains("Should never be applied.")
3648 );
3649 assert_eq!(applied.applied_ids, vec!["current_time"]);
3651 assert!(applied.tool_registry.has("get_current_time"));
3652 }
3653
3654 #[tokio::test]
3655 async fn test_apply_capabilities_preserves_order() {
3656 let registry = fixture_registry();
3657 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3658
3659 let applied = apply_capabilities(
3661 base_runtime_agent,
3662 &["current_time".to_string(), "noop".to_string()],
3663 ®istry,
3664 &test_ctx(),
3665 )
3666 .await;
3667
3668 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3669 assert_eq!(applied.tool_registry.len(), 1);
3670 assert!(applied.tool_registry.has("get_current_time"));
3671 }
3672
3673 #[tokio::test]
3682 async fn test_dynamic_facts_add_note_without_static_block() {
3683 let registry = fixture_registry();
3687 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3688 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3689 let prompt = collected.system_prompt_parts.join("\n");
3690 assert!(
3691 prompt.contains(FACTS_DYNAMIC_NOTE),
3692 "dynamic-facts note should be in the cached prompt"
3693 );
3694 assert!(
3695 !prompt.contains("<facts>\n"),
3696 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3697 );
3698 }
3699
3700 #[tokio::test]
3701 async fn test_static_facts_fold_into_prompt() {
3702 struct StaticFactCap;
3703 impl Capability for StaticFactCap {
3704 fn id(&self) -> &str {
3705 "test_static_fact"
3706 }
3707 fn name(&self) -> &str {
3708 "Static Fact"
3709 }
3710 fn description(&self) -> &str {
3711 "test"
3712 }
3713 fn status(&self) -> CapabilityStatus {
3714 CapabilityStatus::Available
3715 }
3716 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3717 vec![Fact::stat("workspace_root", "/workspace")]
3718 }
3719 }
3720 let mut registry = CapabilityRegistry::new();
3721 registry.register(StaticFactCap);
3722 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3723 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3724 let prompt = collected.system_prompt_parts.join("\n");
3725 assert!(
3726 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3727 "static fact should fold into the cached prompt; got: {prompt}"
3728 );
3729 assert!(
3730 !prompt.contains(FACTS_DYNAMIC_NOTE),
3731 "no dynamic note when only static facts exist"
3732 );
3733 }
3734
3735 #[test]
3736 fn test_collect_dynamic_facts_returns_current_time() {
3737 let registry = fixture_registry();
3738 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3739 let facts = collect_dynamic_facts(
3740 &configs,
3741 ®istry,
3742 None,
3743 &FactsContext::new(SessionId::new()),
3744 );
3745 assert_eq!(facts.len(), 1);
3746 assert_eq!(facts[0].key, "current_time");
3747 assert_eq!(facts[0].value, "fixture-now");
3748 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3749 }
3750
3751 #[tokio::test]
3752 async fn test_collect_capabilities_combines_mounts() {
3753 struct Notes;
3754 impl Capability for Notes {
3755 fn id(&self) -> &str {
3756 "notes"
3757 }
3758 fn name(&self) -> &str {
3759 "Notes"
3760 }
3761 fn description(&self) -> &str {
3762 "Writable notes"
3763 }
3764 fn mounts(&self) -> Vec<MountPoint> {
3765 vec![MountPoint::readwrite(
3766 "/notes.txt",
3767 MountSource::text_file("Note α"),
3768 "notes",
3769 )]
3770 }
3771 }
3772 let mut registry = fixture_registry();
3773 registry.register(Notes);
3774 let collected = collect_capabilities(
3775 &["sample_data".into(), "notes".into(), "current_time".into()],
3776 ®istry,
3777 &test_ctx(),
3778 )
3779 .await;
3780 assert_eq!(
3781 collected.applied_ids,
3782 [
3783 "session_file_system",
3784 "sample_data",
3785 "notes",
3786 "current_time"
3787 ]
3788 );
3789 assert_eq!(
3790 collected.mounts,
3791 vec![
3792 MountPoint::readonly(
3793 "/samples",
3794 MountDirectoryBuilder::new()
3795 .file("users.json", "[]")
3796 .build(),
3797 "sample_data"
3798 ),
3799 MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
3800 ]
3801 );
3802 }
3803
3804 #[test]
3809 fn test_resolve_dependencies_empty() {
3810 let registry = CapabilityRegistry::new();
3811
3812 let resolved = resolve_dependencies(&[], ®istry).unwrap();
3813
3814 assert!(resolved.resolved_ids.is_empty());
3815 assert!(resolved.added_as_dependencies.is_empty());
3816 assert!(resolved.user_selected.is_empty());
3817 }
3818
3819 #[test]
3820 fn test_resolve_dependencies_no_deps() {
3821 let registry = fixture_registry();
3822
3823 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
3825
3826 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3827 assert!(resolved.added_as_dependencies.is_empty());
3828 }
3829
3830 #[test]
3831 fn test_resolve_dependencies_with_deps() {
3832 let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
3833 assert_eq!(
3834 resolved.resolved_ids,
3835 ["session_file_system", "sample_data"]
3836 );
3837 assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
3838 assert_eq!(resolved.user_selected, ["sample_data"]);
3839 }
3840
3841 #[test]
3842 fn test_resolve_dependencies_already_selected() {
3843 let registry = fixture_registry();
3844
3845 let resolved = resolve_dependencies(
3847 &["session_file_system".to_string(), "sample_data".to_string()],
3848 ®istry,
3849 )
3850 .unwrap();
3851
3852 assert_eq!(resolved.resolved_ids.len(), 2);
3853 assert!(resolved.added_as_dependencies.is_empty());
3855 }
3856
3857 #[test]
3858 fn test_resolve_dependencies_preserves_order() {
3859 let registry = fixture_registry();
3860
3861 let resolved =
3863 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
3864 .unwrap();
3865
3866 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3867 }
3868
3869 #[test]
3870 fn test_resolve_dependencies_unknown_capability() {
3871 let registry = CapabilityRegistry::new();
3872
3873 let resolved =
3875 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
3876
3877 assert!(resolved.resolved_ids.is_empty());
3878 }
3879
3880 #[test]
3881 fn test_get_dependencies() {
3882 let registry = fixture_registry();
3883
3884 let deps = get_dependencies("sample_data", ®istry);
3886 assert_eq!(deps, vec!["session_file_system"]);
3887
3888 let deps = get_dependencies("current_time", ®istry);
3890 assert!(deps.is_empty());
3891
3892 let deps = get_dependencies("unknown", ®istry);
3894 assert!(deps.is_empty());
3895 }
3896
3897 #[test]
3901 fn test_circular_dependency_error() {
3902 struct CapA;
3904 struct CapB;
3905
3906 impl Capability for CapA {
3907 fn id(&self) -> &str {
3908 "test_cap_a"
3909 }
3910 fn name(&self) -> &str {
3911 "Test A"
3912 }
3913 fn description(&self) -> &str {
3914 "Test capability A"
3915 }
3916 fn dependencies(&self) -> Vec<&'static str> {
3917 vec!["test_cap_b"]
3918 }
3919 }
3920
3921 impl Capability for CapB {
3922 fn id(&self) -> &str {
3923 "test_cap_b"
3924 }
3925 fn name(&self) -> &str {
3926 "Test B"
3927 }
3928 fn description(&self) -> &str {
3929 "Test capability B"
3930 }
3931 fn dependencies(&self) -> Vec<&'static str> {
3932 vec!["test_cap_a"]
3933 }
3934 }
3935
3936 let mut registry = CapabilityRegistry::new();
3937 registry.register(CapA);
3938 registry.register(CapB);
3939
3940 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
3941
3942 assert!(result.is_err());
3943 match result.unwrap_err() {
3944 DependencyError::CircularDependency { capability_id, .. } => {
3945 assert_eq!(capability_id, "test_cap_a");
3946 }
3947 _ => panic!("Expected CircularDependency error"),
3948 }
3949 }
3950
3951 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3956
3957 struct FilterTestCapability {
3959 priority: i32,
3960 }
3961
3962 impl Capability for FilterTestCapability {
3963 fn id(&self) -> &str {
3964 "filter_test"
3965 }
3966 fn name(&self) -> &str {
3967 "Filter Test"
3968 }
3969 fn description(&self) -> &str {
3970 "Test capability with message filter"
3971 }
3972 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3973 Some(Arc::new(FilterTestProvider {
3974 priority: self.priority,
3975 }))
3976 }
3977 }
3978
3979 struct FilterTestProvider {
3980 priority: i32,
3981 }
3982
3983 impl MessageFilterProvider for FilterTestProvider {
3984 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3985 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3987 query
3988 .filters
3989 .push(MessageFilter::Search(search.to_string()));
3990 }
3991 }
3992
3993 fn priority(&self) -> i32 {
3994 self.priority
3995 }
3996 }
3997
3998 #[tokio::test]
3999 async fn test_collect_capabilities_with_configs_no_filter_providers() {
4000 let registry = fixture_registry();
4001 let configs = vec![AgentCapabilityConfig::with_config(
4002 CapabilityId::new("current_time"),
4003 serde_json::json!({}),
4004 )];
4005
4006 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4007
4008 assert!(collected.message_filter_providers.is_empty());
4009 assert!(!collected.has_message_filters());
4010 }
4011
4012 #[tokio::test]
4013 async fn test_collected_capabilities_apply_message_filters() {
4014 let mut registry = CapabilityRegistry::new();
4015 registry.register(FilterTestCapability { priority: 0 });
4016
4017 let configs = vec![AgentCapabilityConfig::with_config(
4018 CapabilityId::new("filter_test"),
4019 serde_json::json!({ "search": "test_query" }),
4020 )];
4021
4022 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4023
4024 assert!(collected.has_message_filters());
4025
4026 let session_id: SessionId = Uuid::now_v7().into();
4028 let mut query = MessageQuery::new(session_id);
4029
4030 collected.apply_message_filters(&mut query);
4031
4032 assert_eq!(query.filters.len(), 1);
4034 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4035 }
4036
4037 #[tokio::test]
4038 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4039 struct SearchCapability {
4040 id: &'static str,
4041 search_term: &'static str,
4042 priority: i32,
4043 }
4044
4045 struct SearchProvider {
4046 search_term: &'static str,
4047 priority: i32,
4048 }
4049
4050 impl MessageFilterProvider for SearchProvider {
4051 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4052 query
4053 .filters
4054 .push(MessageFilter::Search(self.search_term.to_string()));
4055 }
4056
4057 fn priority(&self) -> i32 {
4058 self.priority
4059 }
4060 }
4061
4062 impl Capability for SearchCapability {
4063 fn id(&self) -> &str {
4064 self.id
4065 }
4066 fn name(&self) -> &str {
4067 "Search"
4068 }
4069 fn description(&self) -> &str {
4070 "Test"
4071 }
4072 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4073 Some(Arc::new(SearchProvider {
4074 search_term: self.search_term,
4075 priority: self.priority,
4076 }))
4077 }
4078 }
4079
4080 let mut registry = CapabilityRegistry::new();
4081 registry.register(SearchCapability {
4082 id: "cap_a",
4083 search_term: "alpha",
4084 priority: 5,
4085 });
4086 registry.register(SearchCapability {
4087 id: "cap_b",
4088 search_term: "beta",
4089 priority: 1,
4090 });
4091 registry.register(SearchCapability {
4092 id: "cap_c",
4093 search_term: "gamma",
4094 priority: 10,
4095 });
4096
4097 let configs = vec![
4098 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4099 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4100 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4101 ];
4102
4103 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4104
4105 let session_id: SessionId = Uuid::now_v7().into();
4106 let mut query = MessageQuery::new(session_id);
4107
4108 collected.apply_message_filters(&mut query);
4109
4110 assert_eq!(query.filters.len(), 3);
4112 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4113 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4114 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4115 }
4116
4117 #[tokio::test]
4118 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4119 let mut registry = CapabilityRegistry::new();
4120 registry.register(FilterTestCapability { priority: 0 });
4121
4122 let test_config = serde_json::json!({
4123 "search": "custom_search",
4124 "extra_field": 42
4125 });
4126
4127 let configs = vec![AgentCapabilityConfig::with_config(
4128 CapabilityId::new("filter_test"),
4129 test_config.clone(),
4130 )];
4131
4132 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4133
4134 assert_eq!(collected.message_filter_providers.len(), 1);
4136 let (_, stored_config) = &collected.message_filter_providers[0];
4137 assert_eq!(*stored_config, test_config);
4138 }
4139
4140 #[test]
4145 fn test_collect_message_filters_only_collects_filters() {
4146 let mut registry = CapabilityRegistry::new();
4147 registry.register(FilterTestCapability { priority: 0 });
4148
4149 let configs = vec![AgentCapabilityConfig::with_config(
4150 CapabilityId::new("filter_test"),
4151 serde_json::json!({ "search": "test_query" }),
4152 )];
4153
4154 let collected = collect_message_filters_only(&configs, ®istry);
4155
4156 let session_id: SessionId = Uuid::now_v7().into();
4157 let mut query = MessageQuery::new(session_id);
4158 collected.apply_message_filters(&mut query);
4159
4160 assert_eq!(query.filters.len(), 1);
4161 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4162 }
4163
4164 #[test]
4165 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4166 let registry = CapabilityRegistry::new();
4167
4168 let configs = vec![AgentCapabilityConfig::with_config(
4169 CapabilityId::new("nonexistent"),
4170 serde_json::json!({}),
4171 )];
4172
4173 let collected = collect_message_filters_only(&configs, ®istry);
4174 assert!(collected.message_filter_providers.is_empty());
4175 }
4176
4177 #[test]
4178 fn test_collect_message_filters_only_preserves_priority_order() {
4179 struct PriorityFilterCap {
4180 id: &'static str,
4181 search_term: &'static str,
4182 priority: i32,
4183 }
4184
4185 struct PriorityFilterProvider {
4186 search_term: &'static str,
4187 priority: i32,
4188 }
4189
4190 impl Capability for PriorityFilterCap {
4191 fn id(&self) -> &str {
4192 self.id
4193 }
4194 fn name(&self) -> &str {
4195 self.id
4196 }
4197 fn description(&self) -> &str {
4198 "priority test"
4199 }
4200 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4201 Some(Arc::new(PriorityFilterProvider {
4202 search_term: self.search_term,
4203 priority: self.priority,
4204 }))
4205 }
4206 }
4207
4208 impl MessageFilterProvider for PriorityFilterProvider {
4209 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4210 query
4211 .filters
4212 .push(MessageFilter::Search(self.search_term.to_string()));
4213 }
4214 fn priority(&self) -> i32 {
4215 self.priority
4216 }
4217 }
4218
4219 let mut registry = CapabilityRegistry::new();
4220 registry.register(PriorityFilterCap {
4221 id: "gamma",
4222 search_term: "gamma",
4223 priority: 10,
4224 });
4225 registry.register(PriorityFilterCap {
4226 id: "alpha",
4227 search_term: "alpha",
4228 priority: 5,
4229 });
4230 registry.register(PriorityFilterCap {
4231 id: "beta",
4232 search_term: "beta",
4233 priority: 1,
4234 });
4235
4236 let configs = vec![
4237 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4238 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4239 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4240 ];
4241
4242 let collected = collect_message_filters_only(&configs, ®istry);
4243
4244 let session_id: SessionId = Uuid::now_v7().into();
4245 let mut query = MessageQuery::new(session_id);
4246 collected.apply_message_filters(&mut query);
4247
4248 assert_eq!(query.filters.len(), 3);
4250 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4251 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4252 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4253 }
4254
4255 #[test]
4256 fn test_collect_message_filters_only_post_load_invoked() {
4257 use crate::message::Message;
4258
4259 struct PostLoadCap;
4260 struct PostLoadProvider;
4261
4262 impl Capability for PostLoadCap {
4263 fn id(&self) -> &str {
4264 "post_load_test"
4265 }
4266 fn name(&self) -> &str {
4267 "PostLoad Test"
4268 }
4269 fn description(&self) -> &str {
4270 "test"
4271 }
4272 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4273 Some(Arc::new(PostLoadProvider))
4274 }
4275 }
4276
4277 impl MessageFilterProvider for PostLoadProvider {
4278 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4279 fn priority(&self) -> i32 {
4280 0
4281 }
4282 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4283 messages.reverse();
4285 }
4286 }
4287
4288 let mut registry = CapabilityRegistry::new();
4289 registry.register(PostLoadCap);
4290
4291 let configs = vec![AgentCapabilityConfig::with_config(
4292 CapabilityId::new("post_load_test"),
4293 serde_json::json!({}),
4294 )];
4295
4296 let collected = collect_message_filters_only(&configs, ®istry);
4297
4298 let mut messages = vec![Message::user("first"), Message::user("second")];
4299 collected.apply_post_load_filters(&mut messages);
4300
4301 assert_eq!(messages[0].text(), Some("second"));
4303 assert_eq!(messages[1].text(), Some("first"));
4304 }
4305
4306 struct DelegatingFilterCap {
4309 id: &'static str,
4310 inner: std::sync::Arc<InnerFilterCap>,
4311 }
4312 struct InnerFilterCap;
4313
4314 impl Capability for InnerFilterCap {
4315 fn id(&self) -> &str {
4316 "inner_filter"
4317 }
4318 fn tools(&self) -> Vec<Box<dyn Tool>> {
4319 panic!("fast-path collection must not instantiate tools")
4320 }
4321 fn system_prompt_addition(&self) -> Option<&str> {
4322 panic!("fast-path collection must not collect prompts")
4323 }
4324 fn name(&self) -> &str {
4325 "Inner Filter"
4326 }
4327 fn description(&self) -> &str {
4328 "inner"
4329 }
4330 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4331 Some(std::sync::Arc::new(SentinelFilter))
4332 }
4333 }
4334 struct SentinelFilter;
4335 impl MessageFilterProvider for SentinelFilter {
4336 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4337 query.limit = config["limit"].as_i64();
4338 }
4339 }
4340 impl Capability for DelegatingFilterCap {
4341 fn id(&self) -> &str {
4342 self.id
4343 }
4344 fn name(&self) -> &str {
4345 "Delegating Filter"
4346 }
4347 fn description(&self) -> &str {
4348 "delegating"
4349 }
4350 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4351 None }
4353 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4354 Some(&*self.inner)
4355 }
4356 }
4357
4358 #[test]
4359 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4360 let inner = std::sync::Arc::new(InnerFilterCap);
4361 let outer = DelegatingFilterCap {
4362 id: "delegating_filter",
4363 inner: inner.clone(),
4364 };
4365
4366 let mut registry = CapabilityRegistry::new();
4367 registry.register(outer);
4368
4369 let configs = vec![AgentCapabilityConfig::with_config(
4370 CapabilityId::new("delegating_filter"),
4371 serde_json::json!({"limit": 17}),
4372 )];
4373
4374 let collected = collect_message_filters_only(&configs, ®istry);
4377 assert_eq!(
4378 collected.message_filter_providers.len(),
4379 1,
4380 "provider from resolved inner capability must be collected"
4381 );
4382 let mut query = MessageQuery::default();
4383 collected.apply_message_filters(&mut query);
4384 assert_eq!(query.limit, Some(17));
4385 }
4386
4387 struct DelegatingMvpCap {
4388 id: &'static str,
4389 inner: std::sync::Arc<InnerMvpCap>,
4390 }
4391 struct InnerMvpCap;
4392
4393 impl Capability for InnerMvpCap {
4394 fn id(&self) -> &str {
4395 "inner_mvp"
4396 }
4397 fn tools(&self) -> Vec<Box<dyn Tool>> {
4398 panic!("fast-path collection must not instantiate tools")
4399 }
4400 fn system_prompt_addition(&self) -> Option<&str> {
4401 panic!("fast-path collection must not collect prompts")
4402 }
4403 fn name(&self) -> &str {
4404 "Inner MVP"
4405 }
4406 fn description(&self) -> &str {
4407 "inner"
4408 }
4409 fn model_view_provider(
4410 &self,
4411 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4412 struct AppendingMvp;
4414 impl crate::capabilities::ModelViewProvider for AppendingMvp {
4415 fn apply_model_view(
4416 &self,
4417 mut messages: Vec<Message>,
4418 config: &serde_json::Value,
4419 context: &ModelViewContext<'_>,
4420 ) -> Vec<Message> {
4421 messages.push(Message::user(format!(
4422 "{}:{}",
4423 config["suffix"].as_str().unwrap(),
4424 context.session_id
4425 )));
4426 messages
4427 }
4428 }
4429 Some(std::sync::Arc::new(AppendingMvp))
4430 }
4431 }
4432 impl Capability for DelegatingMvpCap {
4433 fn id(&self) -> &str {
4434 self.id
4435 }
4436 fn name(&self) -> &str {
4437 "Delegating MVP"
4438 }
4439 fn description(&self) -> &str {
4440 "delegating"
4441 }
4442 fn model_view_provider(
4443 &self,
4444 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4445 None }
4447 fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
4448 (model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
4449 }
4450 }
4451
4452 #[test]
4453 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4454 let inner = std::sync::Arc::new(InnerMvpCap);
4455 let outer = DelegatingMvpCap {
4456 id: "delegating_mvp",
4457 inner: inner.clone(),
4458 };
4459
4460 let mut registry = CapabilityRegistry::new();
4461 registry.register(outer);
4462
4463 let configs = vec![AgentCapabilityConfig::with_config(
4464 CapabilityId::new("delegating_mvp"),
4465 serde_json::json!({"suffix": "delegated"}),
4466 )];
4467
4468 let collected = collect_model_view_providers(&configs, ®istry, Some("selected-model"));
4471 assert_eq!(
4472 collected.model_view_providers.len(),
4473 1,
4474 "provider from resolved inner capability must be collected"
4475 );
4476 assert!(
4477 collect_model_view_providers(&configs, ®istry, Some("other-model"))
4478 .model_view_providers
4479 .is_empty()
4480 );
4481 let session_id = SessionId::from_seed(42);
4482 let output = collected.apply_model_view(
4483 vec![Message::user("original")],
4484 &ModelViewContext {
4485 session_id,
4486 prior_usage: None,
4487 },
4488 );
4489 assert_eq!(
4490 output.iter().map(Message::text).collect::<Vec<_>>(),
4491 [
4492 Some("original"),
4493 Some(format!("delegated:{session_id}").as_str())
4494 ]
4495 );
4496 }
4497
4498 #[test]
4503 fn test_defaults_do_not_include_bash() {
4504 let registry = crate::ToolRegistry::with_defaults();
4507 assert!(
4508 !registry.has("bash"),
4509 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4510 );
4511 }
4512
4513 #[test]
4518 fn test_alias_resolves_to_canonical_capability() {
4519 let registry = fixture_registry();
4520
4521 let via_alias = registry.get("virtual_bash").unwrap();
4523 assert_eq!(via_alias.id(), "bashkit_shell");
4524 assert!(registry.has("virtual_bash"));
4525 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4526 assert_eq!(
4527 registry.canonical_id("bashkit_shell"),
4528 Some("bashkit_shell")
4529 );
4530 assert_eq!(registry.canonical_id("nonexistent"), None);
4531 }
4532
4533 #[test]
4534 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4535 let registry = fixture_registry();
4536
4537 let resolved = resolve_dependencies(
4540 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4541 ®istry,
4542 )
4543 .unwrap();
4544 let bash_ids: Vec<_> = resolved
4545 .resolved_ids
4546 .iter()
4547 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4548 .collect();
4549 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4550 assert!(
4552 !resolved
4553 .added_as_dependencies
4554 .contains(&"bashkit_shell".to_string())
4555 );
4556 }
4557
4558 #[test]
4559 fn test_alias_preserves_explicit_config_in_resolution() {
4560 let registry = fixture_registry();
4561
4562 let configs = vec![AgentCapabilityConfig::with_config(
4563 "virtual_bash".to_string(),
4564 serde_json::json!({"key": "value"}),
4565 )];
4566 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4567 let bash = resolved
4568 .iter()
4569 .find(|c| c.capability_id() == "bashkit_shell")
4570 .expect("alias must resolve to canonical bashkit_shell config");
4571 assert_eq!(
4572 bash.config_value().clone(),
4573 serde_json::json!({"key": "value"})
4574 );
4575 }
4576
4577 #[test]
4578 fn test_unregister_by_alias_removes_capability_and_aliases() {
4579 let mut registry = fixture_registry();
4580
4581 assert!(registry.unregister("virtual_bash").is_some());
4582 assert!(!registry.has("bashkit_shell"));
4583 assert!(!registry.has("virtual_bash"));
4584 }
4585
4586 #[test]
4587 fn test_compute_features_empty() {
4588 let registry = CapabilityRegistry::new();
4589
4590 let features = compute_features(&[], ®istry);
4591 assert!(features.is_empty());
4592 }
4593
4594 #[test]
4595 fn test_compute_features_unknown_capability_ignored() {
4596 let registry = fixture_registry();
4597
4598 let features = compute_features(
4599 &["unknown_cap".to_string(), "session_storage".to_string()],
4600 ®istry,
4601 );
4602 assert_eq!(features, vec!["secrets", "key_value"]);
4603 }
4604
4605 #[test]
4606 fn test_risk_level_ordering() {
4607 assert!(RiskLevel::Low < RiskLevel::Medium);
4608 assert!(RiskLevel::Medium < RiskLevel::High);
4609 }
4610
4611 #[test]
4612 fn test_risk_level_serde_roundtrip() {
4613 for (level, wire) in [
4614 (RiskLevel::Low, "\"low\""),
4615 (RiskLevel::Medium, "\"medium\""),
4616 (RiskLevel::High, "\"high\""),
4617 ] {
4618 assert_eq!(serde_json::to_string(&level).unwrap(), wire);
4619 assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
4620 }
4621 assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
4622 }
4623
4624 struct SkillContributingCapability;
4629
4630 impl Capability for SkillContributingCapability {
4631 fn id(&self) -> &str {
4632 "contributes_skills"
4633 }
4634 fn name(&self) -> &str {
4635 "Contributes Skills"
4636 }
4637 fn description(&self) -> &str {
4638 "Test capability that contributes skills."
4639 }
4640 fn contribute_skills(&self) -> Vec<SkillContribution> {
4641 vec![
4642 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4643 .with_files(vec![(
4644 "scripts/a.sh".to_string(),
4645 "#!/bin/sh\necho a\n".to_string(),
4646 )]),
4647 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4648 .with_user_invocable(false),
4649 ]
4650 }
4651 }
4652
4653 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4654 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4655 MountSource::InlineFile { content, .. } => content.as_str(),
4656 _ => panic!("Expected InlineFile for SKILL.md"),
4657 }
4658 }
4659
4660 #[tokio::test]
4661 async fn test_contribute_skills_normalized_to_mounts() {
4662 let mut registry = CapabilityRegistry::new();
4663 registry.register(SkillContributingCapability);
4664
4665 let configs = vec![AgentCapabilityConfig::with_config(
4666 CapabilityId::new("contributes_skills"),
4667 serde_json::json!({}),
4668 )];
4669
4670 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4671
4672 let skill_mounts: Vec<_> = collected
4673 .mounts
4674 .iter()
4675 .filter(|m| m.path.starts_with("/.agents/skills/"))
4676 .collect();
4677 assert_eq!(skill_mounts.len(), 2);
4678
4679 for m in &skill_mounts {
4682 assert!(m.is_readonly());
4683 assert_eq!(m.capability_id, "contributes_skills");
4684 }
4685
4686 let alpha = skill_mounts
4687 .iter()
4688 .find(|m| m.path == "/.agents/skills/alpha-skill")
4689 .expect("alpha-skill mount missing");
4690 match &alpha.source {
4691 MountSource::InlineDirectory { entries } => {
4692 assert!(entries.contains_key("SKILL.md"));
4693 assert!(entries.contains_key("scripts/a.sh"));
4694 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4695 assert_eq!(parsed.name, "alpha-skill");
4696 assert_eq!(parsed.description, "Alpha skill desc");
4697 assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
4698 assert!(parsed.user_invocable);
4699 }
4700 _ => panic!("Expected InlineDirectory"),
4701 }
4702
4703 let beta = skill_mounts
4704 .iter()
4705 .find(|m| m.path == "/.agents/skills/beta-skill")
4706 .expect("beta-skill mount missing");
4707 match &beta.source {
4708 MountSource::InlineDirectory { entries } => {
4709 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4710 assert!(!parsed.user_invocable);
4711 assert_eq!(parsed.name, "beta-skill");
4712 assert_eq!(parsed.instructions, "# Beta\nDo beta.");
4713 }
4714 _ => panic!("Expected InlineDirectory"),
4715 }
4716 }
4717
4718 #[tokio::test]
4719 async fn test_contribute_skills_default_empty() {
4720 let mut registry = CapabilityRegistry::new();
4723 registry.register(FilterTestCapability { priority: 0 });
4724
4725 let configs = vec![AgentCapabilityConfig::with_config(
4726 CapabilityId::new("filter_test"),
4727 serde_json::json!({}),
4728 )];
4729
4730 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4731 assert!(
4732 collected
4733 .mounts
4734 .iter()
4735 .all(|m| !m.path.starts_with("/.agents/skills/"))
4736 );
4737 }
4738
4739 struct LocalizedCapability;
4740
4741 impl Capability for LocalizedCapability {
4742 fn id(&self) -> &str {
4743 "localized"
4744 }
4745 fn name(&self) -> &str {
4746 "Localized"
4747 }
4748 fn description(&self) -> &str {
4749 "English description"
4750 }
4751 fn localizations(&self) -> Vec<CapabilityLocalization> {
4752 vec![
4753 CapabilityLocalization {
4754 locale: "en",
4755 name: None,
4756 description: None,
4757 config_description: Some("Controls things."),
4758 config_overlay: None,
4759 },
4760 CapabilityLocalization {
4761 locale: "uk-UA",
4762 name: Some("Регіональна"),
4763 description: None,
4764 config_description: None,
4765 config_overlay: None,
4766 },
4767 CapabilityLocalization {
4768 locale: "uk",
4769 name: Some("Локалізована"),
4770 description: Some("Український опис"),
4771 config_description: Some("Керує налаштуваннями."),
4772 config_overlay: None,
4773 },
4774 ]
4775 }
4776 }
4777
4778 #[test]
4779 fn localized_name_falls_back_exact_language_then_base() {
4780 let cap = LocalizedCapability;
4781 assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
4783 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
4784 assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
4785 assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
4786 assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
4787 assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
4789 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
4791 assert_eq!(cap.localized_name(None), "Localized");
4792 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
4793 assert_eq!(cap.localized_description(Some("de")), "English description");
4794 }
4795
4796 #[test]
4797 fn describe_schema_resolves_config_description_per_locale() {
4798 let cap = LocalizedCapability;
4799 assert_eq!(
4800 cap.describe_schema(Some("uk-UA")).as_deref(),
4801 Some("Керує налаштуваннями.")
4802 );
4803 assert_eq!(
4805 cap.describe_schema(Some("pl")).as_deref(),
4806 Some("Controls things.")
4807 );
4808 assert_eq!(
4809 cap.describe_schema(None).as_deref(),
4810 Some("Controls things.")
4811 );
4812 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
4814 }
4815
4816 #[tokio::test]
4817 async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
4818 let registry = fixture_registry();
4819 for (ids, expected) in [
4820 (
4821 vec!["test_math"],
4822 vec![
4823 ("add", "test_math", "Test Math"),
4824 ("subtract", "test_math", "Test Math"),
4825 ("multiply", "test_math", "Test Math"),
4826 ("divide", "test_math", "Test Math"),
4827 ],
4828 ),
4829 (
4830 vec!["test_weather"],
4831 vec![
4832 ("get_weather", "test_weather", "Test Weather"),
4833 ("get_forecast", "test_weather", "Test Weather"),
4834 ],
4835 ),
4836 (
4837 vec!["sample_data"],
4838 vec![
4839 ("read_file", "session_file_system", "Fixture Filesystem"),
4840 ("write_file", "session_file_system", "Fixture Filesystem"),
4841 ],
4842 ),
4843 (
4844 vec!["bashkit_shell", "test_weather"],
4845 vec![
4846 ("read_file", "session_file_system", "Fixture Filesystem"),
4847 ("write_file", "session_file_system", "Fixture Filesystem"),
4848 ("bash", "bashkit_shell", "Fixture Bash"),
4849 ("get_weather", "test_weather", "Test Weather"),
4850 ("get_forecast", "test_weather", "Test Weather"),
4851 ],
4852 ),
4853 ] {
4854 let ids: Vec<_> = ids.into_iter().map(String::from).collect();
4855 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4856 assert_eq!(
4857 collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
4858 expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
4859 );
4860 assert_eq!(collected.tool_definitions.len(), expected.len());
4861 for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
4862 assert_eq!(definition.name(), name);
4863 let hints = definition.hints();
4864 assert_eq!(hints.capability_id.as_deref(), Some(id));
4865 assert_eq!(hints.capability_name.as_deref(), Some(label));
4866 let ToolDefinition::Builtin(tool) = definition else {
4867 panic!("expected builtin")
4868 };
4869 let schema = if name == "bash" {
4870 serde_json::json!({"type":"object"})
4871 } else {
4872 serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
4873 };
4874 assert_eq!(tool.parameters, schema);
4875 }
4876 }
4877 }
4878
4879 #[tokio::test]
4880 async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
4881 let registry = fixture_registry();
4882 let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
4883 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
4884 let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
4885 let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
4886 assert_eq!(collected.system_prompt_parts, vec![first, second]);
4887 assert_eq!(
4888 collected.system_prompt_attributions,
4889 vec![
4890 SystemPromptAttribution {
4891 capability_id: ids[0].clone(),
4892 content: first.into()
4893 },
4894 SystemPromptAttribution {
4895 capability_id: ids[1].clone(),
4896 content: second.into()
4897 }
4898 ]
4899 );
4900 assert_eq!(
4901 collected.system_prompt_prefix(),
4902 Some(format!("{first}\n\n{second}"))
4903 );
4904 let applied = apply_capabilities(
4905 RuntimeAgent::new("Base.", "fixture-model"),
4906 &ids,
4907 ®istry,
4908 &test_ctx(),
4909 )
4910 .await;
4911 assert_eq!(
4912 applied.runtime_agent.system_prompt,
4913 format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
4914 );
4915 assert!(applied.tool_registry.has("write_todos"));
4916 assert_eq!(applied.tool_registry.len(), 1);
4917 for (base, addition, expected) in [
4918 ("Base.", None, "Base."),
4919 ("Base.", Some(""), "Base."),
4920 ("", Some("Extra."), "Extra."),
4921 (
4922 "<system-prompt>Base.</system-prompt>",
4923 Some("Extra."),
4924 "<system-prompt>Base.</system-prompt>\n\nExtra.",
4925 ),
4926 ] {
4927 assert_eq!(compose_system_prompt(base, addition), expected);
4928 }
4929 }
4930
4931 struct DependencyFixture {
4932 id: String,
4933 deps: Vec<&'static str>,
4934 features: Vec<&'static str>,
4935 }
4936 impl Capability for DependencyFixture {
4937 fn id(&self) -> &str {
4938 &self.id
4939 }
4940 fn name(&self) -> &str {
4941 &self.id
4942 }
4943 fn description(&self) -> &str {
4944 "Dependency fixture"
4945 }
4946 fn dependencies(&self) -> Vec<&'static str> {
4947 self.deps.clone()
4948 }
4949 fn features(&self) -> Vec<&'static str> {
4950 self.features.clone()
4951 }
4952 }
4953
4954 #[test]
4955 fn feature_projection_preserves_order_and_distinct_dependency_features() {
4956 let mut registry = CapabilityRegistry::new();
4957 registry.register(DependencyFixture {
4958 id: "base".into(),
4959 deps: vec![],
4960 features: vec!["base-only", "shared"],
4961 });
4962 registry.register(DependencyFixture {
4963 id: "parent".into(),
4964 deps: vec!["base"],
4965 features: vec!["parent-only", "shared"],
4966 });
4967 registry.register(DependencyFixture {
4968 id: "other".into(),
4969 deps: vec![],
4970 features: vec!["other-only"],
4971 });
4972 assert_eq!(
4973 compute_features(&["parent".into()], ®istry),
4974 vec!["base-only", "shared", "parent-only"]
4975 );
4976 assert_eq!(
4977 compute_features(
4978 &[
4979 "other".into(),
4980 "parent".into(),
4981 "base".into(),
4982 "parent".into()
4983 ],
4984 ®istry
4985 ),
4986 vec!["other-only", "base-only", "shared", "parent-only"]
4987 );
4988 }
4989
4990 #[test]
4991 fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
4992 let mut registry = CapabilityRegistry::new();
4993 let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
4994 for id in &ids {
4995 registry.register(DependencyFixture {
4996 id: id.clone(),
4997 deps: vec![],
4998 features: vec![],
4999 });
5000 }
5001 let resolved = resolve_dependencies(&ids[..100], ®istry).unwrap();
5002 assert_eq!(resolved.resolved_ids, ids[..100]);
5003 assert_eq!(resolved.user_selected, ids[..100]);
5004 assert!(resolved.added_as_dependencies.is_empty());
5005 assert_eq!(
5006 resolve_dependencies(&ids, ®istry).unwrap_err(),
5007 DependencyError::TooManyCapabilities {
5008 count: 101,
5009 max: 100
5010 }
5011 );
5012 }
5013}