1use crate::command::{
22 CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
23};
24use crate::events::TokenUsage;
25use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
26use crate::message::Message;
27use crate::message_filter::MessageFilterProvider;
28use crate::runtime_agent::RuntimeAgent;
29use crate::tool_types::{ToolCall, ToolDefinition};
30use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
31use crate::typed_id::SessionId;
32use crate::{session_files::SessionFileSystem, tool_context::ToolContext};
33use async_trait::async_trait;
34use everruns_capability::is_plugin_capability;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::sync::Arc;
38
39pub struct IntegrationPlugin {
64 pub experimental_only: bool,
66 pub feature_flag: Option<&'static str>,
71 pub factory: fn() -> Box<dyn Capability>,
73}
74
75inventory::collect!(IntegrationPlugin);
76
77pub use crate::capability_types::{
78 CapabilityStatus, MountAccess, MountDirectoryBuilder, MountEntry, MountPoint, MountSource,
79};
80use everruns_capability::{CapabilityId, CapabilityRef as AgentCapabilityConfig};
81
82mod declarative;
87pub mod facts;
88pub mod skill_contribution;
89pub mod util;
90
91pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
96pub const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
100pub const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
104pub use declarative::{
105 DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
106 DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
107 declarative_capability_info, hydrate_declarative_capability_config,
108 hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
109 plugin_capability_info, validate_declarative_capability_definition,
110};
111pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
112pub use skill_contribution::{
113 MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
114 SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
115 discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
116 reconstruct_skill_md, skill_capability_id,
117};
118
119pub struct SystemPromptContext {
129 pub session_id: SessionId,
131 pub locale: Option<String>,
133 pub file_store: Option<Arc<dyn SessionFileSystem>>,
135 pub model: Option<String>,
141 pub session_storage: Option<Arc<dyn crate::session_services::SessionStorageStore>>,
146}
147
148impl SystemPromptContext {
149 pub fn without_file_store(session_id: SessionId) -> Self {
151 Self {
152 session_id,
153 locale: None,
154 file_store: None,
155 model: None,
156 session_storage: None,
157 }
158 }
159
160 pub fn with_model(mut self, model: impl Into<String>) -> Self {
162 self.model = Some(model.into());
163 self
164 }
165}
166
167#[derive(Debug, Clone)]
219pub struct CapabilityLocalization {
220 pub locale: &'static str,
222 pub name: Option<&'static str>,
224 pub description: Option<&'static str>,
226 pub config_description: Option<&'static str>,
231 pub config_overlay: Option<serde_json::Value>,
237}
238
239impl CapabilityLocalization {
240 pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
242 Self {
243 locale,
244 name: Some(name),
245 description: Some(description),
246 config_description: None,
247 config_overlay: None,
248 }
249 }
250}
251
252pub fn resolve_localized_field<T>(
256 localizations: &[CapabilityLocalization],
257 locale: Option<&str>,
258 field: impl Fn(&CapabilityLocalization) -> Option<T>,
259) -> Option<T> {
260 let mut candidates: Vec<String> = Vec::new();
261 if let Some(raw) = locale {
262 let normalized = raw.trim().replace('_', "-").to_lowercase();
263 if !normalized.is_empty() {
264 if let Some((language, _)) = normalized.split_once('-') {
265 let language = language.to_string();
266 candidates.push(normalized);
267 candidates.push(language);
268 } else {
269 candidates.push(normalized);
270 }
271 }
272 }
273 candidates.push("en".to_string());
274
275 for candidate in candidates {
276 let hit = localizations
277 .iter()
278 .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
279 .and_then(&field);
280 if hit.is_some() {
281 return hit;
282 }
283 }
284 None
285}
286
287#[async_trait]
288pub trait Capability: Send + Sync {
289 fn native_async_tools(
292 &self,
293 _config: &serde_json::Value,
294 ) -> Option<std::collections::BTreeMap<String, Option<serde_json::Value>>> {
295 None
296 }
297 fn id(&self) -> &str;
299
300 fn aliases(&self) -> Vec<&'static str> {
309 vec![]
310 }
311
312 fn name(&self) -> &str;
314
315 fn description(&self) -> &str;
317
318 fn localizations(&self) -> Vec<CapabilityLocalization> {
323 vec![]
324 }
325
326 fn localized_name(&self, locale: Option<&str>) -> String {
329 resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
330 .unwrap_or_else(|| self.name())
331 .to_string()
332 }
333
334 fn localized_description(&self, locale: Option<&str>) -> String {
336 resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
337 .unwrap_or_else(|| self.description())
338 .to_string()
339 }
340
341 fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
345 resolve_localized_field(&self.localizations(), locale, |entry| {
346 entry.config_description
347 })
348 .map(str::to_string)
349 }
350
351 fn status(&self) -> CapabilityStatus {
353 CapabilityStatus::Available
354 }
355
356 fn icon(&self) -> Option<&str> {
358 None
359 }
360
361 fn category(&self) -> Option<&str> {
363 None
364 }
365
366 fn metadata(&self) -> Option<serde_json::Value> {
378 None
379 }
380
381 fn is_guardrail(&self) -> bool {
386 false
387 }
388
389 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
400 None
401 }
402
403 fn system_prompt_addition(&self) -> Option<&str> {
423 None
424 }
425
426 async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
438 self.system_prompt_addition().map(|addition| {
439 format!(
440 "<capability id=\"{}\">\n{}\n</capability>",
441 self.id(),
442 addition
443 )
444 })
445 }
446
447 fn system_prompt_preview(&self) -> Option<String> {
453 self.system_prompt_addition().map(|s| s.to_string())
454 }
455
456 fn tools(&self) -> Vec<Box<dyn Tool>> {
458 vec![]
459 }
460
461 fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
469 self.tools()
470 }
471
472 fn delegation_target_with_config(
478 &self,
479 _config: &serde_json::Value,
480 ) -> Option<DelegationTargetProvider> {
481 None
482 }
483
484 fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
488 false
489 }
490
491 async fn system_prompt_contribution_with_config(
498 &self,
499 ctx: &SystemPromptContext,
500 _config: &serde_json::Value,
501 ) -> Option<String> {
502 self.system_prompt_contribution(ctx).await
503 }
504
505 async fn conversation_context_contribution(
516 &self,
517 _ctx: &SystemPromptContext,
518 ) -> Option<String> {
519 None
520 }
521
522 async fn conversation_context_contribution_with_config(
527 &self,
528 ctx: &SystemPromptContext,
529 _config: &serde_json::Value,
530 ) -> Option<String> {
531 self.conversation_context_contribution(ctx).await
532 }
533
534 fn tool_definitions(&self) -> Vec<ToolDefinition> {
537 self.tools().iter().map(|t| t.to_definition()).collect()
538 }
539
540 fn mounts(&self) -> Vec<MountPoint> {
548 vec![]
549 }
550
551 fn dependencies(&self) -> Vec<&'static str> {
560 vec![]
561 }
562
563 fn features(&self) -> Vec<&'static str> {
578 vec![]
579 }
580
581 fn config_schema(&self) -> Option<serde_json::Value> {
587 None
588 }
589
590 fn config_ui_schema(&self) -> Option<serde_json::Value> {
595 None
596 }
597
598 fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
604 Ok(())
605 }
606
607 fn mcp_servers(&self) -> ScopedMcpServers {
613 ScopedMcpServers::default()
614 }
615
616 fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
618 self.mcp_servers()
619 }
620
621 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
634 None
635 }
636
637 fn message_filter_config(
642 &self,
643 config: &serde_json::Value,
644 _compaction_enabled: bool,
645 ) -> serde_json::Value {
646 config.clone()
647 }
648
649 fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
657 None
658 }
659
660 fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
672 None
673 }
674
675 fn tool_search_config(
679 &self,
680 _config: &serde_json::Value,
681 ) -> Option<crate::driver_registry::ToolSearchConfig> {
682 None
683 }
684
685 fn prompt_cache_config(
688 &self,
689 _config: &serde_json::Value,
690 ) -> Option<crate::driver_registry::PromptCacheConfig> {
691 None
692 }
693
694 fn driver_options(&self, _config: &serde_json::Value) -> Vec<(String, serde_json::Value)> {
698 Vec::new()
699 }
700
701 fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
704 None
705 }
706
707 fn error_disclosure(
709 &self,
710 _config: &serde_json::Value,
711 ) -> Option<crate::user_facing_error::ErrorDisclosure> {
712 None
713 }
714
715 fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
718 text
719 }
720
721 fn compaction_policy(
725 &self,
726 _config: &serde_json::Value,
727 ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
728 None
729 }
730
731 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
746 vec![]
747 }
748
749 fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
760 vec![]
761 }
762
763 fn pre_tool_use_hooks_with_config(
768 &self,
769 _config: &serde_json::Value,
770 ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
771 self.pre_tool_use_hooks()
772 }
773
774 fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
782 vec![]
783 }
784
785 fn post_tool_exec_hooks_with_config(
790 &self,
791 _config: &serde_json::Value,
792 ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
793 self.post_tool_exec_hooks()
794 }
795
796 fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
805 vec![]
806 }
807
808 fn tool_definition_hooks_with_config(
813 &self,
814 _config: &serde_json::Value,
815 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
816 self.tool_definition_hooks()
817 }
818
819 fn tool_definition_hooks_with_context(
829 &self,
830 _ctx: &SystemPromptContext,
831 config: &serde_json::Value,
832 ) -> Vec<Arc<dyn ToolDefinitionHook>> {
833 self.tool_definition_hooks_with_config(config)
834 }
835
836 fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
844 vec![]
845 }
846
847 fn finalized_tool_calls_hook(
851 &self,
852 _config: &serde_json::Value,
853 ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
854 None
855 }
856
857 fn narrate(
871 &self,
872 _tool_def: Option<&ToolDefinition>,
873 tool_call: &ToolCall,
874 phase: crate::tool_narration::ToolNarrationPhase,
875 locale: Option<&str>,
876 ctx: crate::tool_narration::ToolNarrationContext<'_>,
877 ) -> Option<String> {
878 self.tools()
879 .iter()
880 .find(|tool| tool.name() == tool_call.name)
881 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
882 }
883
884 fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
900 vec![]
901 }
902
903 fn user_hooks_with_config(
909 &self,
910 _config: &serde_json::Value,
911 ) -> Vec<crate::user_hook_types::UserHookSpec> {
912 self.user_hooks()
913 }
914
915 fn risk_level(&self) -> RiskLevel {
923 RiskLevel::Low
924 }
925
926 fn commands(&self) -> Vec<CommandDescriptor> {
934 vec![]
935 }
936
937 async fn execute_command(
951 &self,
952 request: &ExecuteCommandRequest,
953 _ctx: &CommandExecutionContext,
954 ) -> crate::error::Result<CommandResult> {
955 Err(crate::error::AgentLoopError::config(format!(
956 "capability {} declared command /{} but does not implement execute_command",
957 self.id(),
958 request.name,
959 )))
960 }
961
962 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
971 vec![]
972 }
973
974 fn contribute_skills(&self) -> Vec<SkillContribution> {
984 vec![]
985 }
986
987 fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
998 vec![]
999 }
1000
1001 fn post_output_guardrails_with_config(
1013 &self,
1014 _config: &serde_json::Value,
1015 ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
1016 vec![]
1017 }
1018
1019 fn post_output_annotation_hooks_with_config(
1035 &self,
1036 _config: &serde_json::Value,
1037 ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1038 vec![]
1039 }
1040
1041 fn citation_verifier_with_config(
1051 &self,
1052 _config: &serde_json::Value,
1053 ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1054 None
1055 }
1056}
1057
1058pub trait ToolDefinitionHook: Send + Sync {
1059 fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1060
1061 fn applies_with_native_tool_search(&self) -> bool {
1066 true
1067 }
1068}
1069
1070pub trait ToolCallHook: Send + Sync {
1071 fn narration(
1072 &self,
1073 _tool_def: Option<&ToolDefinition>,
1074 _tool_call: &ToolCall,
1075 _phase: crate::tool_narration::ToolNarrationPhase,
1076 _locale: Option<&str>,
1077 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1078 ) -> Option<String> {
1079 None
1080 }
1081
1082 fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1083 tool_call
1084 }
1085}
1086
1087pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1093
1094impl ToolCallHook for CapabilityNarrationHook {
1095 fn narration(
1096 &self,
1097 tool_def: Option<&ToolDefinition>,
1098 tool_call: &ToolCall,
1099 phase: crate::tool_narration::ToolNarrationPhase,
1100 locale: Option<&str>,
1101 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1102 ) -> Option<String> {
1103 self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1104 }
1105}
1106
1107#[derive(
1111 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1112)]
1113#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1114#[cfg_attr(feature = "openapi", schema(example = "low"))]
1115#[serde(rename_all = "lowercase")]
1116pub enum RiskLevel {
1117 Low,
1119 Medium,
1121 High,
1123}
1124
1125#[derive(Debug, Clone, Serialize, Deserialize)]
1131#[serde(rename_all = "snake_case")]
1132pub enum BlueprintModel {
1133 Fixed(String),
1135 Default(String),
1137 Inherit,
1139}
1140
1141pub struct AgentBlueprint {
1147 pub id: &'static str,
1149 pub name: &'static str,
1151 pub description: &'static str,
1153 pub model: BlueprintModel,
1155 pub system_prompt: &'static str,
1157 pub tools: Vec<Box<dyn Tool>>,
1159 pub max_turns: Option<usize>,
1161 pub config_schema: Option<serde_json::Value>,
1163}
1164
1165impl AgentBlueprint {
1166 pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1168 self.tools.iter().map(|t| t.to_definition()).collect()
1169 }
1170
1171 pub fn validate_config(
1177 &self,
1178 config: Option<&serde_json::Value>,
1179 ) -> Result<(), BlueprintConfigError> {
1180 let Some(schema) = self.config_schema.as_ref() else {
1181 return match config {
1182 Some(_) => Err(BlueprintConfigError::NotAccepted { id: self.id }),
1183 None => Ok(()),
1184 };
1185 };
1186
1187 let Some(config) = config else {
1188 let required = schema
1190 .get("required")
1191 .and_then(|r| r.as_array())
1192 .is_some_and(|required| !required.is_empty());
1193 return if required {
1194 Err(BlueprintConfigError::Required { id: self.id })
1195 } else {
1196 Ok(())
1197 };
1198 };
1199
1200 let validator = jsonschema::validator_for(schema).map_err(|error| {
1201 BlueprintConfigError::InvalidSchema {
1202 id: self.id,
1203 reason: error.to_string(),
1204 }
1205 })?;
1206
1207 let issues: Vec<String> = validator
1208 .iter_errors(config)
1209 .map(|error| {
1210 let path = error.instance_path().to_string();
1211 if path.is_empty() {
1212 error.to_string()
1213 } else {
1214 format!("{path}: {error}")
1215 }
1216 })
1217 .collect();
1218
1219 if issues.is_empty() {
1220 Ok(())
1221 } else {
1222 Err(BlueprintConfigError::Invalid {
1223 id: self.id,
1224 issues,
1225 })
1226 }
1227 }
1228}
1229
1230#[derive(Debug, Clone, PartialEq, Eq)]
1235#[non_exhaustive]
1236pub enum BlueprintConfigError {
1237 NotAccepted {
1239 id: &'static str,
1241 },
1242 Required {
1244 id: &'static str,
1246 },
1247 Invalid {
1249 id: &'static str,
1251 issues: Vec<String>,
1253 },
1254 InvalidSchema {
1256 id: &'static str,
1258 reason: String,
1260 },
1261}
1262
1263impl std::fmt::Display for BlueprintConfigError {
1264 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1265 match self {
1266 Self::NotAccepted { id } => {
1267 write!(f, "Blueprint \"{id}\" accepts no config.")
1268 }
1269 Self::Required { id } => {
1270 write!(f, "Blueprint \"{id}\" requires config.")
1271 }
1272 Self::Invalid { id, issues } => {
1273 write!(
1274 f,
1275 "Blueprint \"{id}\" received invalid config: {}",
1276 issues.join("; ")
1277 )
1278 }
1279 Self::InvalidSchema { id, reason } => {
1280 write!(
1281 f,
1282 "Blueprint \"{id}\" has an invalid config schema: {reason}"
1283 )
1284 }
1285 }
1286 }
1287}
1288
1289impl std::error::Error for BlueprintConfigError {}
1290
1291impl std::fmt::Debug for AgentBlueprint {
1292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1293 f.debug_struct("AgentBlueprint")
1294 .field("id", &self.id)
1295 .field("name", &self.name)
1296 .field("model", &self.model)
1297 .field("tool_count", &self.tools.len())
1298 .field("max_turns", &self.max_turns)
1299 .finish()
1300 }
1301}
1302
1303#[derive(Clone)]
1321pub struct CapabilityRegistry {
1322 capabilities: HashMap<String, Arc<dyn Capability>>,
1323 index: everruns_capability::CapabilityIdIndex,
1327}
1328
1329impl CapabilityRegistry {
1330 pub fn new() -> Self {
1332 Self {
1333 capabilities: HashMap::new(),
1334 index: everruns_capability::CapabilityIdIndex::new(),
1335 }
1336 }
1337
1338 pub fn register(&mut self, capability: impl Capability + 'static) {
1340 self.register_arc(Arc::new(capability));
1341 }
1342
1343 pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1345 self.register_arc(Arc::from(capability));
1346 }
1347
1348 pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1354 let canonical = capability.id().to_string();
1355 self.index
1356 .insert_or_replace(canonical.clone(), &capability.aliases());
1357 self.capabilities.insert(canonical, capability);
1358 }
1359
1360 pub fn try_register_arc(
1363 &mut self,
1364 capability: Arc<dyn Capability>,
1365 ) -> Result<(), everruns_capability::CapabilityError> {
1366 let canonical = capability.id().to_string();
1367 self.index
1368 .insert(canonical.clone(), &capability.aliases())?;
1369 self.capabilities.insert(canonical, capability);
1370 Ok(())
1371 }
1372
1373 pub fn register_inventory_plugins(
1378 &mut self,
1379 mut include: impl FnMut(&IntegrationPlugin) -> bool,
1380 ) {
1381 for plugin in inventory::iter::<IntegrationPlugin>() {
1382 if include(plugin) {
1383 self.register_boxed((plugin.factory)());
1384 }
1385 }
1386 }
1387
1388 pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1390 self.capabilities.get(self.index.canonical_of(id)?)
1391 }
1392
1393 pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1398 self.index.canonical_of(id)
1399 }
1400
1401 pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1403 let canonical = self.index.remove(id)?;
1404 self.capabilities.remove(&canonical)
1405 }
1406
1407 pub fn has(&self, id: &str) -> bool {
1409 self.get(id).is_some()
1410 }
1411
1412 pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1414 self.capabilities.values().collect()
1415 }
1416
1417 pub fn len(&self) -> usize {
1419 self.capabilities.len()
1420 }
1421
1422 pub fn is_empty(&self) -> bool {
1424 self.capabilities.is_empty()
1425 }
1426
1427 pub fn builder() -> CapabilityRegistryBuilder {
1429 CapabilityRegistryBuilder::new()
1430 }
1431
1432 pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1436 for cap in self.capabilities.values() {
1437 for bp in cap.agent_blueprints() {
1438 if bp.id == id {
1439 return Some(bp);
1440 }
1441 }
1442 }
1443 None
1444 }
1445
1446 pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1450 for (capability_id, cap) in &self.capabilities {
1451 for bp in cap.agent_blueprints() {
1452 if bp.id == id {
1453 return Some((capability_id.clone(), bp));
1454 }
1455 }
1456 }
1457 None
1458 }
1459
1460 pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1462 self.capabilities
1463 .values()
1464 .flat_map(|cap| cap.agent_blueprints())
1465 .collect()
1466 }
1467}
1468
1469impl Default for CapabilityRegistry {
1470 fn default() -> Self {
1471 Self::new()
1472 }
1473}
1474
1475impl std::fmt::Debug for CapabilityRegistry {
1476 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1477 let ids: Vec<_> = self.capabilities.keys().collect();
1478 f.debug_struct("CapabilityRegistry")
1479 .field("capabilities", &ids)
1480 .finish()
1481 }
1482}
1483
1484pub struct CapabilityRegistryBuilder {
1486 registry: CapabilityRegistry,
1487}
1488
1489impl CapabilityRegistryBuilder {
1490 pub fn new() -> Self {
1492 Self {
1493 registry: CapabilityRegistry::new(),
1494 }
1495 }
1496
1497 pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1499 self.registry.register(capability);
1500 self
1501 }
1502
1503 pub fn build(self) -> CapabilityRegistry {
1505 self.registry
1506 }
1507}
1508
1509impl Default for CapabilityRegistryBuilder {
1510 fn default() -> Self {
1511 Self::new()
1512 }
1513}
1514
1515pub struct ModelViewContext<'a> {
1521 pub session_id: SessionId,
1522 pub prior_usage: Option<&'a TokenUsage>,
1523}
1524
1525pub trait ModelViewProvider: Send + Sync {
1531 fn apply_model_view(
1532 &self,
1533 messages: Vec<Message>,
1534 config: &serde_json::Value,
1535 context: &ModelViewContext<'_>,
1536 ) -> Vec<Message>;
1537
1538 fn priority(&self) -> i32 {
1539 0
1540 }
1541}
1542
1543pub struct CollectedCapabilities {
1548 pub system_prompt_parts: Vec<String>,
1550 pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1552 pub conversation_context_parts: Vec<String>,
1559 pub conversation_context_attributions: Vec<SystemPromptAttribution>,
1561 pub tools: Vec<Box<dyn Tool>>,
1563 pub tool_definitions: Vec<ToolDefinition>,
1565 pub mounts: Vec<MountPoint>,
1567 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1569 pub applied_ids: Vec<String>,
1571 pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1573 pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1575 pub driver_options: HashMap<String, serde_json::Value>,
1579 pub parallel_tool_calls: Option<bool>,
1583 pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1585 pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1587 pub mcp_servers: ScopedMcpServers,
1589 }
1595
1596#[derive(Debug, Clone, PartialEq, Eq)]
1597pub struct SystemPromptAttribution {
1598 pub capability_id: String,
1599 pub content: String,
1600}
1601
1602impl CollectedCapabilities {
1603 pub fn system_prompt_prefix(&self) -> Option<String> {
1606 if self.system_prompt_parts.is_empty() {
1607 None
1608 } else {
1609 Some(self.system_prompt_parts.join("\n\n"))
1610 }
1611 }
1612
1613 pub fn conversation_context(&self) -> Option<String> {
1617 if self.conversation_context_parts.is_empty() {
1618 None
1619 } else {
1620 Some(self.conversation_context_parts.join("\n\n"))
1621 }
1622 }
1623
1624 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1628 for (provider, config) in &self.message_filter_providers {
1630 provider.apply_filters(query, config);
1631 }
1632 }
1633
1634 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1637 for (provider, config) in &self.message_filter_providers {
1638 provider.post_load(messages, config);
1639 }
1640 }
1641
1642 pub fn has_message_filters(&self) -> bool {
1644 !self.message_filter_providers.is_empty()
1645 }
1646}
1647
1648pub struct DelegationTargetProvider {
1649 pub target_type: &'static str,
1650 pub tool: Box<dyn Tool>,
1651}
1652
1653#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1655#[serde(rename_all = "snake_case")]
1656pub enum SpawnMode {
1657 Background,
1658 Foreground,
1659}
1660
1661impl SpawnMode {
1662 pub fn parse(value: &str) -> Option<Self> {
1663 match value {
1664 "background" => Some(Self::Background),
1665 "foreground" => Some(Self::Foreground),
1666 _ => None,
1667 }
1668 }
1669
1670 pub fn as_str(self) -> &'static str {
1671 match self {
1672 Self::Background => "background",
1673 Self::Foreground => "foreground",
1674 }
1675 }
1676}
1677
1678struct UnifiedSpawnAgentTool {
1679 providers: Vec<DelegationTargetProvider>,
1680}
1681
1682fn validate_spawn_agent_target_fields(
1683 arguments: &serde_json::Value,
1684 target_type: &str,
1685) -> Result<(), String> {
1686 for field in ["blueprint", "config"] {
1687 if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
1688 return Err(format!(
1689 "{field} is only valid for subagent targets, not {target_type}."
1690 ));
1691 }
1692 }
1693 Ok(())
1694}
1695
1696impl UnifiedSpawnAgentTool {
1697 fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1698 Self { providers }
1699 }
1700
1701 fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1702 self.providers
1703 .iter()
1704 .find(|provider| provider.target_type == target_type)
1705 .map(|provider| provider.tool.as_ref())
1706 }
1707
1708 fn target_types(&self) -> Vec<&'static str> {
1709 ["subagent", "agent", "external_a2a"]
1710 .into_iter()
1711 .filter(|target_type| {
1712 self.providers
1713 .iter()
1714 .any(|provider| provider.target_type == *target_type)
1715 })
1716 .collect()
1717 }
1718
1719 fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1724 self.target_types()
1725 .into_iter()
1726 .filter_map(|target_type| match target_type {
1727 "subagent" => Some(serde_json::json!({
1728 "properties": {
1729 "type": {"const": "subagent"}
1730 }
1731 })),
1732 "agent" => Some(serde_json::json!({
1733 "properties": {
1734 "type": {"const": "agent"}
1735 },
1736 "required": ["type", "id"]
1737 })),
1738 "external_a2a" => Some(serde_json::json!({
1739 "properties": {
1740 "type": {"const": "external_a2a"}
1741 },
1742 "anyOf": [
1743 {"required": ["id"]},
1744 {"required": ["external_agent_id"]}
1745 ]
1746 })),
1747 _ => None,
1748 })
1749 .collect()
1750 }
1751
1752 }
1762
1763#[async_trait]
1764impl Tool for UnifiedSpawnAgentTool {
1765 fn narrate(
1766 &self,
1767 tool_call: &ToolCall,
1768 phase: crate::tool_narration::ToolNarrationPhase,
1769 locale: Option<&str>,
1770 ctx: crate::tool_narration::ToolNarrationContext<'_>,
1771 ) -> Option<String> {
1772 let from_provider = tool_call
1776 .arguments
1777 .get("target")
1778 .and_then(|target| target.get("type"))
1779 .and_then(serde_json::Value::as_str)
1780 .and_then(|target_type| self.provider_for(target_type))
1781 .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1782 Some(from_provider.unwrap_or_else(|| {
1783 crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1784 }))
1785 }
1786
1787 fn name(&self) -> &str {
1788 "spawn_agent"
1789 }
1790
1791 fn display_name(&self) -> Option<&str> {
1792 Some("Spawn Agent")
1793 }
1794
1795 fn description(&self) -> &str {
1796 "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."
1797 }
1798
1799 fn parameters_schema(&self) -> serde_json::Value {
1800 serde_json::json!({
1801 "type": "object",
1802 "properties": {
1803 "name": {
1804 "type": "string",
1805 "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1806 },
1807 "instructions": {
1808 "type": "string",
1809 "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1810 },
1811 "goal": {
1812 "type": "string",
1813 "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1814 },
1815 "lifetime": {
1816 "type": "string",
1817 "enum": ["linked", "detached"],
1818 "default": "linked",
1819 "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1820 },
1821 "seed": {
1822 "type": "string",
1823 "enum": ["fresh", "fork", "workspace"],
1824 "default": "fresh",
1825 "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1826 },
1827 "target": {
1828 "type": "object",
1829 "properties": {
1830 "type": {
1831 "type": "string",
1832 "enum": self.target_types(),
1833 "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."
1834 },
1835 "id": {
1836 "type": "string",
1837 "description": "Configured target id for first-party handoffs or external A2A agents."
1838 },
1839 "external_agent_id": {
1840 "type": "string",
1841 "description": "Configured external A2A agent id."
1842 }
1843 },
1844 "required": ["type"],
1845 "oneOf": self.target_constraint_branches(),
1846 "additionalProperties": false
1847 },
1848 "mode": {
1849 "type": "string",
1850 "enum": ["background", "foreground"],
1851 "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."
1852 },
1853 "blueprint": {
1854 "type": "string",
1855 "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1856 },
1857 "config": {
1858 "type": "object",
1859 "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1860 },
1861 "result_schema": {
1862 "type": "object",
1863 "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."
1864 },
1865 "message_schema": {
1866 "type": "object",
1867 "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."
1868 },
1869 "public_context": {
1870 "type": "object",
1871 "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1872 },
1873 "wait_timeout_secs": {
1874 "type": "integer",
1875 "minimum": 1,
1876 "maximum": 86400,
1877 "description": "External-A2A-only foreground timeout."
1878 },
1879 "wake_on_completion": {
1880 "type": "boolean",
1881 "description": "External-A2A-only control for background completion wake-ups."
1882 }
1883 },
1884 "required": ["name", "instructions", "target"],
1885 "additionalProperties": false
1886 })
1887 }
1888
1889 fn hints(&self) -> crate::tool_types::ToolHints {
1890 let mut hints = crate::tool_types::ToolHints::default()
1891 .with_long_running(true)
1892 .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1893 if self.provider_for("external_a2a").is_some() {
1894 hints = hints.with_open_world(true);
1895 }
1896 hints
1897 }
1898
1899 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1900 ToolExecutionResult::tool_error(
1901 "spawn_agent requires context. This tool must be executed with session context.",
1902 )
1903 }
1904
1905 async fn execute_with_context(
1906 &self,
1907 arguments: serde_json::Value,
1908 context: &ToolContext,
1909 ) -> ToolExecutionResult {
1910 let target_type = match arguments
1911 .get("target")
1912 .and_then(|target| target.get("type"))
1913 .and_then(serde_json::Value::as_str)
1914 {
1915 Some(target_type) => target_type,
1916 None => {
1917 return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1918 }
1919 };
1920
1921 let Some(provider) = self.provider_for(target_type) else {
1922 let supported = self.target_types().join(", ");
1923 return ToolExecutionResult::tool_error(format!(
1924 "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1925 ));
1926 };
1927 if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
1928 return ToolExecutionResult::tool_error(error);
1929 }
1930 if target_type == "external_a2a"
1931 && arguments
1932 .get("lifetime")
1933 .and_then(serde_json::Value::as_str)
1934 .is_some_and(|value| value == "detached")
1935 {
1936 return ToolExecutionResult::tool_error(
1937 "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1938 );
1939 }
1940 if target_type == "external_a2a"
1941 && arguments
1942 .get("message_schema")
1943 .is_some_and(|schema| !schema.is_null())
1944 {
1945 return ToolExecutionResult::tool_error(
1946 "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1947 );
1948 }
1949
1950 provider.execute_with_context(arguments, context).await
1951 }
1952
1953 fn requires_context(&self) -> bool {
1954 true
1955 }
1956}
1957
1958pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1963 let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1964 return base_system_prompt.to_string();
1965 };
1966
1967 if base_system_prompt.is_empty() {
1968 return additions.to_string();
1969 }
1970
1971 if base_system_prompt.contains("<system-prompt>") {
1972 format!("{base_system_prompt}\n\n{additions}")
1973 } else {
1974 format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1975 }
1976}
1977
1978pub struct CollectedMessageFilters {
1985 pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1987}
1988
1989pub struct CollectedModelViewProviders {
1991 pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1993}
1994
1995impl CollectedMessageFilters {
2001 pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
2003 for (provider, config) in &self.message_filter_providers {
2004 provider.apply_filters(query, config);
2005 }
2006 }
2007
2008 pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
2010 for (provider, config) in &self.message_filter_providers {
2011 provider.post_load(messages, config);
2012 }
2013 }
2014}
2015
2016impl CollectedModelViewProviders {
2017 pub fn apply_model_view(
2019 &self,
2020 mut messages: Vec<Message>,
2021 context: &ModelViewContext<'_>,
2022 ) -> Vec<Message> {
2023 for (provider, config) in &self.model_view_providers {
2024 messages = provider.apply_model_view(messages, config, context);
2025 }
2026 messages
2027 }
2028}
2029
2030fn compaction_is_enabled(
2036 capability_configs: &[AgentCapabilityConfig],
2037 registry: &CapabilityRegistry,
2038) -> bool {
2039 capability_configs.iter().any(|cap_config| {
2040 registry.get(cap_config.capability_id()).is_some_and(|cap| {
2041 cap.status().is_active() && cap.compaction_policy(cap_config.config_value()).is_some()
2042 })
2043 })
2044}
2045
2046pub fn collect_message_filters_only(
2052 capability_configs: &[AgentCapabilityConfig],
2053 registry: &CapabilityRegistry,
2054) -> CollectedMessageFilters {
2055 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2056 Vec::new();
2057 let compaction_on = compaction_is_enabled(capability_configs, registry);
2058
2059 for cap_config in capability_configs {
2060 let cap_id = cap_config.capability_id();
2061 if let Some(capability) = registry.get(cap_id) {
2062 if !capability.status().is_active() {
2063 continue;
2064 }
2065 let effective: &dyn Capability = capability
2068 .resolve_for_model(None)
2069 .unwrap_or_else(|| capability.as_ref());
2070 if let Some(provider) = effective.message_filter_provider() {
2071 let config =
2072 effective.message_filter_config(cap_config.config_value(), compaction_on);
2073 message_filter_providers.push((provider, config));
2074 }
2075 }
2076 }
2077
2078 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2079
2080 CollectedMessageFilters {
2081 message_filter_providers,
2082 }
2083}
2084
2085pub fn collect_model_view_providers(
2092 capability_configs: &[AgentCapabilityConfig],
2093 registry: &CapabilityRegistry,
2094 model: Option<&str>,
2095) -> CollectedModelViewProviders {
2096 let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
2097
2098 for cap_config in capability_configs {
2099 let cap_id = cap_config.capability_id();
2100 if let Some(capability) = registry.get(cap_id) {
2101 if !capability.status().is_active() {
2102 continue;
2103 }
2104 let effective: &dyn Capability = capability
2105 .resolve_for_model(model)
2106 .unwrap_or_else(|| capability.as_ref());
2107 if let Some(provider) = effective.model_view_provider() {
2108 model_view_providers.push((provider, cap_config.config_value().clone()));
2109 }
2110 }
2111 }
2112
2113 model_view_providers.sort_by_key(|(p, _)| p.priority());
2114
2115 CollectedModelViewProviders {
2116 model_view_providers,
2117 }
2118}
2119
2120pub fn collect_dynamic_facts(
2126 capability_configs: &[AgentCapabilityConfig],
2127 registry: &CapabilityRegistry,
2128 model: Option<&str>,
2129 ctx: &FactsContext,
2130) -> Vec<Fact> {
2131 let mut dynamic = Vec::new();
2132 for cap_config in capability_configs {
2133 let cap_id = cap_config.capability_id();
2134 if let Some(capability) = registry.get(cap_id) {
2135 if !capability.status().is_active() {
2136 continue;
2137 }
2138 let effective: &dyn Capability = capability
2139 .resolve_for_model(model)
2140 .unwrap_or_else(|| capability.as_ref());
2141 for fact in effective.facts(cap_config.config_value(), ctx) {
2142 if fact.volatility == Volatility::Dynamic {
2143 dynamic.push(fact);
2144 }
2145 }
2146 }
2147 }
2148 dynamic
2149}
2150
2151pub fn collect_capability_mcp_servers(
2152 capability_configs: &[AgentCapabilityConfig],
2153 registry: &CapabilityRegistry,
2154) -> ScopedMcpServers {
2155 let mut servers = ScopedMcpServers::default();
2156
2157 for cap_config in capability_configs {
2158 let cap_id = cap_config.capability_id();
2159 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2162 if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2163 cap_config.config_value().clone(),
2164 ) {
2165 if !definition.status.is_active() {
2166 continue;
2167 }
2168 if let Some(contributed) = definition.mcp_servers {
2169 servers = merge_scoped_mcp_servers(&servers, &contributed);
2170 }
2171 }
2172 continue;
2173 }
2174 if let Some(capability) = registry.get(cap_id) {
2175 if !capability.status().is_active() {
2176 continue;
2177 }
2178 servers = merge_scoped_mcp_servers(
2179 &servers,
2180 &capability.mcp_servers_with_config(cap_config.config_value()),
2181 );
2182 }
2183 }
2184
2185 servers
2186}
2187
2188pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2195
2196#[derive(Debug, Clone, PartialEq, Eq)]
2198pub enum DependencyError {
2199 CircularDependency {
2201 capability_id: String,
2203 chain: Vec<String>,
2205 },
2206 TooManyCapabilities {
2208 count: usize,
2210 max: usize,
2212 },
2213}
2214
2215impl std::fmt::Display for DependencyError {
2216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2217 match self {
2218 DependencyError::CircularDependency {
2219 capability_id,
2220 chain,
2221 } => {
2222 write!(
2223 f,
2224 "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2225 capability_id,
2226 chain.join(" -> "),
2227 capability_id
2228 )
2229 }
2230 DependencyError::TooManyCapabilities { count, max } => {
2231 write!(
2232 f,
2233 "Too many capabilities after resolution: {} (max: {})",
2234 count, max
2235 )
2236 }
2237 }
2238 }
2239}
2240
2241impl std::error::Error for DependencyError {}
2242
2243#[derive(Debug, Clone)]
2245pub struct ResolvedCapabilities {
2246 pub resolved_ids: Vec<String>,
2249 pub added_as_dependencies: Vec<String>,
2251 pub user_selected: Vec<String>,
2253}
2254
2255pub fn resolve_dependencies(
2275 selected_ids: &[String],
2276 registry: &CapabilityRegistry,
2277) -> Result<ResolvedCapabilities, DependencyError> {
2278 use std::collections::HashSet;
2279
2280 let user_selected: HashSet<String> = selected_ids
2282 .iter()
2283 .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2284 .collect();
2285 let mut resolved: Vec<String> = Vec::new();
2286 let mut resolved_set: HashSet<String> = HashSet::new();
2287 let mut added_as_dependencies: Vec<String> = Vec::new();
2288
2289 for cap_id in selected_ids {
2291 resolve_single_capability(
2292 cap_id,
2293 registry,
2294 &mut resolved,
2295 &mut resolved_set,
2296 &mut added_as_dependencies,
2297 &user_selected,
2298 &mut Vec::new(), )?;
2300 }
2301
2302 if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2304 return Err(DependencyError::TooManyCapabilities {
2305 count: resolved.len(),
2306 max: MAX_RESOLVED_CAPABILITIES,
2307 });
2308 }
2309
2310 Ok(ResolvedCapabilities {
2311 resolved_ids: resolved,
2312 added_as_dependencies,
2313 user_selected: selected_ids.to_vec(),
2314 })
2315}
2316
2317pub fn resolve_capability_configs(
2322 selected_configs: &[AgentCapabilityConfig],
2323 registry: &CapabilityRegistry,
2324) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2325 let mut selected_ids: Vec<String> = Vec::new();
2326 for config in selected_configs {
2327 if (is_declarative_capability(config.capability_id())
2330 || is_plugin_capability(config.capability_id()))
2331 && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2332 config.config_value().clone(),
2333 )
2334 {
2335 selected_ids.extend(definition.dependencies);
2336 }
2337 selected_ids.push(config.capability_id().to_string());
2338 }
2339 let resolved = resolve_dependencies(&selected_ids, registry)?;
2340
2341 let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2344 .iter()
2345 .map(|config| {
2346 let id = config.capability_id();
2347 let id = registry.canonical_id(id).unwrap_or(id);
2348 (id.to_string(), config.config_value().clone())
2349 })
2350 .collect();
2351
2352 Ok(resolved
2353 .resolved_ids
2354 .into_iter()
2355 .map(|capability_id| {
2356 explicit_configs
2357 .get(&capability_id)
2358 .cloned()
2359 .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2360 .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2361 })
2362 .collect())
2363}
2364
2365fn resolve_single_capability(
2367 cap_id: &str,
2368 registry: &CapabilityRegistry,
2369 resolved: &mut Vec<String>,
2370 resolved_set: &mut std::collections::HashSet<String>,
2371 added_as_dependencies: &mut Vec<String>,
2372 user_selected: &std::collections::HashSet<String>,
2373 visiting: &mut Vec<String>,
2374) -> Result<(), DependencyError> {
2375 let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2379
2380 if resolved_set.contains(cap_id) {
2382 return Ok(());
2383 }
2384
2385 if visiting.contains(&cap_id.to_string()) {
2387 return Err(DependencyError::CircularDependency {
2388 capability_id: cap_id.to_string(),
2389 chain: visiting.clone(),
2390 });
2391 }
2392
2393 let capability = match registry.get(cap_id) {
2395 Some(cap) => cap,
2396 None => {
2397 if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2401 && !resolved_set.contains(cap_id)
2402 {
2403 resolved.push(cap_id.to_string());
2404 resolved_set.insert(cap_id.to_string());
2405 if !user_selected.contains(cap_id) {
2406 added_as_dependencies.push(cap_id.to_string());
2407 }
2408 }
2409 return Ok(());
2410 }
2411 };
2412
2413 visiting.push(cap_id.to_string());
2415
2416 for dep_id in capability.dependencies() {
2418 resolve_single_capability(
2419 dep_id,
2420 registry,
2421 resolved,
2422 resolved_set,
2423 added_as_dependencies,
2424 user_selected,
2425 visiting,
2426 )?;
2427 }
2428
2429 visiting.pop();
2431
2432 if !resolved_set.contains(cap_id) {
2434 resolved.push(cap_id.to_string());
2435 resolved_set.insert(cap_id.to_string());
2436
2437 if !user_selected.contains(cap_id) {
2439 added_as_dependencies.push(cap_id.to_string());
2440 }
2441 }
2442
2443 Ok(())
2444}
2445
2446pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2451 use std::collections::HashSet;
2452
2453 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2454 Ok(resolved) => resolved.resolved_ids,
2455 Err(_) => capability_ids.to_vec(),
2456 };
2457
2458 let mut seen = HashSet::new();
2459 let mut features = Vec::new();
2460 for cap_id in &resolved_ids {
2461 if let Some(cap) = registry.get(cap_id) {
2462 for feature in cap.features() {
2463 if seen.insert(feature) {
2464 features.push(feature.to_string());
2465 }
2466 }
2467 }
2468 }
2469 features
2470}
2471
2472pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2475 registry
2476 .get(cap_id)
2477 .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2478 .unwrap_or_default()
2479}
2480
2481pub async fn collect_capabilities(
2497 capability_ids: &[String],
2498 registry: &CapabilityRegistry,
2499 ctx: &SystemPromptContext,
2500) -> CollectedCapabilities {
2501 let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2504 Ok(resolved) => resolved.resolved_ids,
2505 Err(e) => {
2506 tracing::warn!("Failed to resolve capability dependencies: {}", e);
2507 capability_ids.to_vec()
2508 }
2509 };
2510
2511 let configs: Vec<AgentCapabilityConfig> = resolved_ids
2513 .iter()
2514 .map(|id| {
2515 AgentCapabilityConfig::with_config(
2516 CapabilityId::new(id),
2517 serde_json::Value::Object(serde_json::Map::new()),
2518 )
2519 })
2520 .collect();
2521
2522 collect_capabilities_with_configs(&configs, registry, ctx).await
2523}
2524
2525pub async fn collect_capabilities_with_configs(
2536 capability_configs: &[AgentCapabilityConfig],
2537 registry: &CapabilityRegistry,
2538 ctx: &SystemPromptContext,
2539) -> CollectedCapabilities {
2540 let mut system_prompt_parts: Vec<String> = Vec::new();
2541 let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2542 let mut conversation_context_parts: Vec<String> = Vec::new();
2543 let mut conversation_context_attributions: Vec<SystemPromptAttribution> = Vec::new();
2544 let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2545 let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2546 let mut mounts: Vec<MountPoint> = Vec::new();
2547 let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2548 Vec::new();
2549 let mut applied_ids: Vec<String> = Vec::new();
2550 let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2551 let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2552 let mut driver_options: HashMap<String, serde_json::Value> = HashMap::new();
2553 let mut parallel_tool_calls: Option<bool> = None;
2554 let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2555 let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2556 let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2559 let mut mcp_servers = ScopedMcpServers::default();
2560 let mut static_facts: Vec<Fact> = Vec::new();
2564 let mut has_dynamic_facts = false;
2565 let facts_ctx = FactsContext::new(ctx.session_id);
2566 let compaction_on = compaction_is_enabled(capability_configs, registry);
2567 let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2568
2569 for cap_config in capability_configs {
2570 let cap_id = cap_config.capability_id();
2571 if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2576 match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2577 cap_config.config_value().clone(),
2578 ) {
2579 Ok(definition) => {
2580 if !definition.status.is_active() {
2581 continue;
2582 }
2583
2584 if let Some(prompt) = definition.system_prompt.as_deref() {
2585 let contribution =
2586 format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2587 system_prompt_attributions.push(SystemPromptAttribution {
2588 capability_id: cap_id.to_string(),
2589 content: contribution.clone(),
2590 });
2591 system_prompt_parts.push(contribution);
2592 }
2593
2594 mounts.extend(definition.mounts(cap_id));
2595 if let Some(ref servers) = definition.mcp_servers {
2596 mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2597 }
2598 for skill in definition.skill_contributions() {
2599 mounts.push(skill.to_mount(cap_id));
2600 }
2601
2602 applied_ids.push(cap_id.to_string());
2603 }
2604 Err(error) => {
2605 tracing::warn!(
2606 capability_id = %cap_id,
2607 error = %error,
2608 "Skipping invalid declarative/plugin capability config"
2609 );
2610 }
2611 }
2612 continue;
2613 }
2614 if let Some(capability) = registry.get(cap_id) {
2615 if !capability.status().is_active() {
2619 continue;
2620 }
2621
2622 let effective: &dyn Capability =
2634 match capability.resolve_for_model(ctx.model.as_deref()) {
2635 Some(inner) => inner,
2636 None => capability.as_ref(),
2637 };
2638 let delegation_target =
2639 effective.delegation_target_with_config(cap_config.config_value());
2640
2641 if let Some(contribution) = effective
2643 .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2644 .await
2645 {
2646 system_prompt_attributions.push(SystemPromptAttribution {
2647 capability_id: cap_id.to_string(),
2648 content: contribution.clone(),
2649 });
2650 system_prompt_parts.push(contribution);
2651 }
2652
2653 if let Some(contribution) = effective
2658 .conversation_context_contribution_with_config(ctx, cap_config.config_value())
2659 .await
2660 {
2661 conversation_context_attributions.push(SystemPromptAttribution {
2662 capability_id: cap_id.to_string(),
2663 content: contribution.clone(),
2664 });
2665 conversation_context_parts.push(contribution);
2666 }
2667
2668 for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2673 match fact.volatility {
2674 Volatility::Static => static_facts.push(fact),
2675 Volatility::Dynamic => has_dynamic_facts = true,
2676 }
2677 }
2678
2679 tools.extend(effective.tools_with_config(cap_config.config_value()));
2681 if let Some(target) = delegation_target {
2682 delegation_targets.push(target);
2683 }
2684 tool_definition_hooks.extend(
2685 effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2686 );
2687 tool_call_hooks.extend(effective.tool_call_hooks());
2688 narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2690 let cap_category = effective.category();
2695 for def in effective.tool_definitions() {
2696 let def = match (def.category(), cap_category) {
2697 (None, Some(cat)) => def.with_category(cat),
2698 _ => def,
2699 }
2700 .with_capability_attribution(cap_id, Some(capability.name()));
2701 tool_definitions.push(def);
2702 }
2703
2704 tool_search = effective
2705 .tool_search_config(cap_config.config_value())
2706 .or(tool_search);
2707 prompt_cache = effective
2708 .prompt_cache_config(cap_config.config_value())
2709 .or(prompt_cache);
2710 parallel_tool_calls = effective
2711 .parallel_tool_calls_preference(cap_config.config_value())
2712 .or(parallel_tool_calls);
2713
2714 for (key, value) in effective.driver_options(cap_config.config_value()) {
2715 driver_options.entry(key).or_insert(value);
2716 }
2717
2718 mounts.extend(effective.mounts());
2720
2721 mcp_servers = merge_scoped_mcp_servers(
2722 &mcp_servers,
2723 &effective.mcp_servers_with_config(cap_config.config_value()),
2724 );
2725
2726 for skill in effective.contribute_skills() {
2730 mounts.push(skill.to_mount(cap_id));
2731 }
2732
2733 if let Some(provider) = effective.message_filter_provider() {
2735 let config =
2736 effective.message_filter_config(cap_config.config_value(), compaction_on);
2737 message_filter_providers.push((provider, config));
2738 }
2739
2740 applied_ids.push(cap_id.to_string());
2741 }
2742 }
2743
2744 if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2747 let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2748 let def = tool
2749 .to_definition()
2750 .with_category("Orchestration")
2751 .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2752 tools.push(Box::new(tool));
2753 tool_definitions.push(def);
2754 }
2755
2756 let auto_activated: Vec<_> = registry
2759 .list()
2760 .into_iter()
2761 .filter(|cap| {
2762 !applied_ids.iter().any(|id| id == cap.id())
2763 && cap.status().is_active()
2764 && cap.auto_activates_for(&tool_definitions)
2765 })
2766 .cloned()
2767 .collect();
2768 for cap in auto_activated {
2769 tools.extend(cap.tools());
2770 let cap_category = cap.category();
2771 for def in cap.tool_definitions() {
2772 let def = match (def.category(), cap_category) {
2773 (None, Some(cat)) => def.with_category(cat),
2774 _ => def,
2775 }
2776 .with_capability_attribution(cap.id(), Some(cap.name()));
2777 tool_definitions.push(def);
2778 }
2779 narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2780 applied_ids.push(cap.id().to_string());
2781 }
2782
2783 if let Some(block) = facts::render_facts_block(&static_facts) {
2788 system_prompt_attributions.push(SystemPromptAttribution {
2789 capability_id: "facts".to_string(),
2790 content: block.clone(),
2791 });
2792 system_prompt_parts.push(block);
2793 }
2794 if has_dynamic_facts {
2795 system_prompt_attributions.push(SystemPromptAttribution {
2796 capability_id: "facts".to_string(),
2797 content: FACTS_DYNAMIC_NOTE.to_string(),
2798 });
2799 system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2800 }
2801
2802 tool_call_hooks.extend(narration_hooks);
2806
2807 message_filter_providers.sort_by_key(|(p, _)| p.priority());
2809
2810 CollectedCapabilities {
2811 system_prompt_parts,
2812 system_prompt_attributions,
2813 conversation_context_parts,
2814 conversation_context_attributions,
2815 tools,
2816 tool_definitions,
2817 mounts,
2818 message_filter_providers,
2819 applied_ids,
2820 tool_search,
2821 prompt_cache,
2822 driver_options,
2823 parallel_tool_calls,
2824 tool_definition_hooks,
2825 tool_call_hooks,
2826 mcp_servers,
2827 }
2828}
2829
2830pub struct AppliedCapabilities {
2836 pub runtime_agent: RuntimeAgent,
2838 pub tool_registry: ToolRegistry,
2840 pub applied_ids: Vec<String>,
2842}
2843
2844pub async fn apply_capabilities(
2880 base_runtime_agent: RuntimeAgent,
2881 capability_ids: &[String],
2882 registry: &CapabilityRegistry,
2883 ctx: &SystemPromptContext,
2884) -> AppliedCapabilities {
2885 let collected = collect_capabilities(capability_ids, registry, ctx).await;
2886
2887 let final_system_prompt = compose_system_prompt(
2889 &base_runtime_agent.system_prompt,
2890 collected.system_prompt_prefix().as_deref(),
2891 );
2892
2893 let conversation_context = collected.conversation_context();
2897 let mut tool_registry = ToolRegistry::new();
2899 for tool in collected.tools {
2900 tool_registry.register_boxed(tool);
2901 }
2902
2903 let mut tools = collected.tool_definitions;
2905 for hook in &collected.tool_definition_hooks {
2906 tools = hook.transform(tools);
2907 }
2908
2909 let runtime_agent = RuntimeAgent {
2910 system_prompt: final_system_prompt,
2911 model: base_runtime_agent.model,
2912 tools,
2913 max_iterations: base_runtime_agent.max_iterations,
2914 temperature: base_runtime_agent.temperature,
2915 max_tokens: base_runtime_agent.max_tokens,
2916 tool_search: collected.tool_search,
2917 prompt_cache: collected.prompt_cache,
2918 driver_options: collected.driver_options,
2919 network_access: base_runtime_agent.network_access,
2920 parallel_tool_calls: base_runtime_agent
2923 .parallel_tool_calls
2924 .or(collected.parallel_tool_calls),
2925 conversation_context,
2928 };
2929
2930 AppliedCapabilities {
2931 runtime_agent,
2932 tool_registry,
2933 applied_ids: collected.applied_ids,
2934 }
2935}
2936
2937#[cfg(test)]
2942mod tests {
2943 use super::*;
2944 use crate::typed_id::SessionId;
2945 use uuid::Uuid;
2946
2947 fn test_ctx() -> SystemPromptContext {
2949 SystemPromptContext::without_file_store(SessionId::new())
2950 }
2951
2952 struct StubSubagentSpawnTool;
2961
2962 #[async_trait]
2963 impl Tool for StubSubagentSpawnTool {
2964 fn name(&self) -> &str {
2965 "spawn_agent"
2966 }
2967 fn description(&self) -> &str {
2968 "stub subagent delegation"
2969 }
2970 fn parameters_schema(&self) -> serde_json::Value {
2971 serde_json::json!({ "type": "object" })
2972 }
2973 fn narrate(
2974 &self,
2975 tool_call: &ToolCall,
2976 phase: crate::tool_narration::ToolNarrationPhase,
2977 locale: Option<&str>,
2978 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2979 ) -> Option<String> {
2980 Some(crate::tool_narration::narrate_subagent_spawn(
2981 &tool_call.arguments,
2982 phase,
2983 locale,
2984 ))
2985 }
2986 async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2987 crate::ToolExecutionResult::success(serde_json::json!({}))
2988 }
2989 }
2990
2991 fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2992 ToolCall {
2993 id: "call-1".to_string(),
2994 name: "spawn_agent".to_string(),
2995 arguments,
2996 }
2997 }
2998
2999 #[test]
3002 fn unified_spawn_agent_narration_names_the_agent() {
3003 let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
3004 target_type: "subagent",
3005 tool: Box::new(StubSubagentSpawnTool),
3006 }]);
3007 let ctx = crate::tool_narration::ToolNarrationContext::default();
3008
3009 assert_eq!(
3010 tool.narrate(
3011 &spawn_agent_call(serde_json::json!({
3012 "name": "Orbit Scout",
3013 "target": { "type": "subagent" },
3014 "blueprint": "github_scout"
3015 })),
3016 crate::tool_narration::ToolNarrationPhase::Started,
3017 None,
3018 ctx,
3019 )
3020 .as_deref(),
3021 Some("Launching Orbit Scout subagent (github_scout)")
3022 );
3023
3024 assert_eq!(
3025 tool.narrate(
3026 &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
3027 crate::tool_narration::ToolNarrationPhase::Started,
3028 None,
3029 ctx,
3030 )
3031 .as_deref(),
3032 Some("Launching Orbit Scout subagent")
3033 );
3034 }
3035
3036 #[test]
3037 fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
3038 for target_type in ["agent", "external_a2a"] {
3039 let arguments = serde_json::json!({
3040 "target": { "type": target_type, "id": "actual-target" },
3041 "blueprint": "decoy-target"
3042 });
3043 assert_eq!(
3044 validate_spawn_agent_target_fields(&arguments, target_type),
3045 Err(format!(
3046 "blueprint is only valid for subagent targets, not {target_type}."
3047 ))
3048 );
3049
3050 let arguments = serde_json::json!({
3051 "target": { "type": target_type, "id": "actual-target" },
3052 "config": { "model": "decoy" }
3053 });
3054 assert_eq!(
3055 validate_spawn_agent_target_fields(&arguments, target_type),
3056 Err(format!(
3057 "config is only valid for subagent targets, not {target_type}."
3058 ))
3059 );
3060 }
3061 }
3062
3063 struct NoopFixture;
3065
3066 impl Capability for NoopFixture {
3067 fn id(&self) -> &str {
3068 "noop"
3069 }
3070 fn name(&self) -> &str {
3071 "No-Op"
3072 }
3073 fn description(&self) -> &str {
3074 "Contributes nothing."
3075 }
3076 }
3077
3078 struct FeatureFixture;
3080
3081 impl Capability for FeatureFixture {
3082 fn id(&self) -> &str {
3083 "feature_fixture"
3084 }
3085 fn name(&self) -> &str {
3086 "Feature Fixture"
3087 }
3088 fn description(&self) -> &str {
3089 "Declares one test-only feature."
3090 }
3091 fn features(&self) -> Vec<&'static str> {
3092 vec!["fixture_feature"]
3093 }
3094 }
3095
3096 struct FixtureTool(&'static str);
3097
3098 #[async_trait]
3099 impl Tool for FixtureTool {
3100 fn name(&self) -> &str {
3101 self.0
3102 }
3103 fn description(&self) -> &str {
3104 "Fixture tool."
3105 }
3106 fn parameters_schema(&self) -> serde_json::Value {
3107 serde_json::json!({
3108 "type": "object",
3109 "properties": {},
3110 "additionalProperties": false
3111 })
3112 }
3113 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3114 ToolExecutionResult::success(serde_json::json!({ "ok": true }))
3115 }
3116 }
3117
3118 struct BackgroundFixtureTool;
3119
3120 #[async_trait]
3121 impl Tool for BackgroundFixtureTool {
3122 fn name(&self) -> &str {
3123 "bash"
3124 }
3125 fn description(&self) -> &str {
3126 "Fixture background-capable shell tool."
3127 }
3128 fn parameters_schema(&self) -> serde_json::Value {
3129 serde_json::json!({"type": "object"})
3130 }
3131 async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3132 ToolExecutionResult::success(serde_json::json!({"ok": true}))
3133 }
3134 fn hints(&self) -> crate::tool_types::ToolHints {
3135 crate::tool_types::ToolHints {
3136 supports_background: Some(true),
3137 ..Default::default()
3138 }
3139 }
3140 }
3141
3142 struct FileSystemFixture;
3143
3144 impl Capability for FileSystemFixture {
3145 fn id(&self) -> &str {
3146 "session_file_system"
3147 }
3148 fn name(&self) -> &str {
3149 "Fixture Filesystem"
3150 }
3151 fn description(&self) -> &str {
3152 "Fixture filesystem capability."
3153 }
3154 fn tools(&self) -> Vec<Box<dyn Tool>> {
3155 vec![
3156 Box::new(FixtureTool("read_file")),
3157 Box::new(FixtureTool("write_file")),
3158 ]
3159 }
3160 fn features(&self) -> Vec<&'static str> {
3161 vec!["file_system"]
3162 }
3163 }
3164
3165 struct StorageFixture;
3170
3171 impl Capability for StorageFixture {
3172 fn id(&self) -> &str {
3173 "session_storage"
3174 }
3175 fn name(&self) -> &str {
3176 "Fixture Storage"
3177 }
3178 fn description(&self) -> &str {
3179 "Fixture session storage capability."
3180 }
3181 fn features(&self) -> Vec<&'static str> {
3182 vec!["secrets", "key_value"]
3183 }
3184 }
3185
3186 struct BashFixture;
3187
3188 impl Capability for BashFixture {
3189 fn id(&self) -> &str {
3190 "bashkit_shell"
3191 }
3192 fn aliases(&self) -> Vec<&'static str> {
3193 vec!["virtual_bash"]
3194 }
3195 fn name(&self) -> &str {
3196 "Fixture Bash"
3197 }
3198 fn description(&self) -> &str {
3199 "Fixture shell capability."
3200 }
3201 fn tools(&self) -> Vec<Box<dyn Tool>> {
3202 vec![Box::new(BackgroundFixtureTool)]
3203 }
3204 fn dependencies(&self) -> Vec<&'static str> {
3205 vec!["session_file_system"]
3206 }
3207 fn features(&self) -> Vec<&'static str> {
3208 vec!["file_system"]
3209 }
3210 fn risk_level(&self) -> RiskLevel {
3211 RiskLevel::High
3212 }
3213 }
3214
3215 struct WebFetchFixture;
3216
3217 impl Capability for WebFetchFixture {
3218 fn id(&self) -> &str {
3219 "web_fetch"
3220 }
3221 fn name(&self) -> &str {
3222 "Fixture Web Fetch"
3223 }
3224 fn description(&self) -> &str {
3225 "Fixture web capability."
3226 }
3227 fn risk_level(&self) -> RiskLevel {
3228 RiskLevel::High
3229 }
3230 }
3231
3232 struct DynamicFactFixture;
3235
3236 impl Capability for DynamicFactFixture {
3237 fn id(&self) -> &str {
3238 "current_time"
3239 }
3240 fn name(&self) -> &str {
3241 "Dynamic Fact Fixture"
3242 }
3243 fn description(&self) -> &str {
3244 "Fixture with one dynamic fact and one tool."
3245 }
3246 fn icon(&self) -> Option<&str> {
3247 Some("clock")
3248 }
3249 fn category(&self) -> Option<&str> {
3250 Some("Core")
3251 }
3252 fn tools(&self) -> Vec<Box<dyn Tool>> {
3253 vec![Box::new(FixtureTool("get_current_time"))]
3254 }
3255 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3256 vec![Fact::dynamic("current_time", "fixture-now")]
3257 }
3258 }
3259
3260 struct PromptToolFixture;
3261
3262 impl Capability for PromptToolFixture {
3263 fn id(&self) -> &str {
3264 "prompt_tool_fixture"
3265 }
3266 fn name(&self) -> &str {
3267 "Prompt Tool Fixture"
3268 }
3269 fn description(&self) -> &str {
3270 "Fixture with a static prompt and tool."
3271 }
3272 fn system_prompt_addition(&self) -> Option<&str> {
3273 Some("Task Management uses the write_todos tool.")
3274 }
3275 fn tools(&self) -> Vec<Box<dyn Tool>> {
3276 vec![Box::new(FixtureTool("write_todos"))]
3277 }
3278 }
3279
3280 struct SecondPromptFixture;
3281
3282 impl Capability for SecondPromptFixture {
3283 fn id(&self) -> &str {
3284 "second_prompt_fixture"
3285 }
3286 fn name(&self) -> &str {
3287 "Second Prompt Fixture"
3288 }
3289 fn description(&self) -> &str {
3290 "Fixture with a second static prompt."
3291 }
3292 fn system_prompt_addition(&self) -> Option<&str> {
3293 Some("A second capability prompt contribution.")
3294 }
3295 }
3296
3297 struct DynamicPreviewFixture;
3298
3299 impl Capability for DynamicPreviewFixture {
3300 fn id(&self) -> &str {
3301 "agent_instructions"
3302 }
3303 fn name(&self) -> &str {
3304 "Dynamic Preview Fixture"
3305 }
3306 fn description(&self) -> &str {
3307 "Fixture whose runtime prompt is dynamic."
3308 }
3309 fn system_prompt_preview(&self) -> Option<String> {
3310 Some("Reads AGENTS.md dynamically.".to_string())
3311 }
3312 }
3313
3314 struct MathFixture;
3316
3317 impl Capability for MathFixture {
3318 fn id(&self) -> &str {
3319 "test_math"
3320 }
3321 fn name(&self) -> &str {
3322 "Test Math"
3323 }
3324 fn description(&self) -> &str {
3325 "Fixture: calculator tools."
3326 }
3327 fn tools(&self) -> Vec<Box<dyn Tool>> {
3328 vec![
3329 Box::new(FixtureTool("add")),
3330 Box::new(FixtureTool("subtract")),
3331 Box::new(FixtureTool("multiply")),
3332 Box::new(FixtureTool("divide")),
3333 ]
3334 }
3335 }
3336
3337 struct WeatherFixture;
3339
3340 impl Capability for WeatherFixture {
3341 fn id(&self) -> &str {
3342 "test_weather"
3343 }
3344 fn name(&self) -> &str {
3345 "Test Weather"
3346 }
3347 fn description(&self) -> &str {
3348 "Fixture: weather tools."
3349 }
3350 fn tools(&self) -> Vec<Box<dyn Tool>> {
3351 vec![
3352 Box::new(FixtureTool("get_weather")),
3353 Box::new(FixtureTool("get_forecast")),
3354 ]
3355 }
3356 }
3357
3358 struct SampleDataFixture;
3361
3362 impl Capability for SampleDataFixture {
3363 fn id(&self) -> &str {
3364 "sample_data"
3365 }
3366 fn name(&self) -> &str {
3367 "Sample Data"
3368 }
3369 fn description(&self) -> &str {
3370 "Fixture: mounted sample files."
3371 }
3372 fn system_prompt_addition(&self) -> Option<&str> {
3373 Some("Read-only sample files are mounted at `/samples`.")
3374 }
3375 fn mounts(&self) -> Vec<MountPoint> {
3376 let samples_dir = MountDirectoryBuilder::new()
3377 .file("users.json", "[]")
3378 .build();
3379 vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3380 }
3381 fn dependencies(&self) -> Vec<&'static str> {
3382 vec!["session_file_system"]
3383 }
3384 fn features(&self) -> Vec<&'static str> {
3385 vec!["file_system"]
3386 }
3387 }
3388
3389 fn fixture_registry() -> CapabilityRegistry {
3391 let mut registry = CapabilityRegistry::new();
3392 registry.register(NoopFixture);
3393 registry.register(FeatureFixture);
3394 registry.register(MathFixture);
3395 registry.register(WeatherFixture);
3396 registry.register(SampleDataFixture);
3397 registry.register(FileSystemFixture);
3398 registry.register(StorageFixture);
3399 registry.register(BashFixture);
3400 registry.register(WebFetchFixture);
3401 registry.register(DynamicFactFixture);
3402 registry.register(PromptToolFixture);
3403 registry.register(SecondPromptFixture);
3404 registry.register(DynamicPreviewFixture);
3405 registry
3406 }
3407
3408 struct HostAnnotatedCapability;
3410
3411 #[async_trait]
3412 impl Capability for HostAnnotatedCapability {
3413 fn id(&self) -> &str {
3414 "host_annotated"
3415 }
3416 fn name(&self) -> &str {
3417 "Host Annotated"
3418 }
3419 fn description(&self) -> &str {
3420 "Test capability with host-owned metadata."
3421 }
3422 fn metadata(&self) -> Option<serde_json::Value> {
3423 Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3424 }
3425 }
3426
3427 #[test]
3428 fn test_capability_registry_get() {
3429 let mut registry = CapabilityRegistry::new();
3430 registry.register(NoopFixture);
3431
3432 let capability = registry.get("noop").unwrap();
3433 assert_eq!(capability.id(), "noop");
3434 assert_eq!(capability.status(), CapabilityStatus::Available);
3435 }
3436
3437 #[test]
3438 fn default_registry_is_empty_and_selects_no_product_preset() {
3439 assert!(CapabilityRegistry::default().is_empty());
3440 assert!(CapabilityRegistryBuilder::default().build().is_empty());
3441 }
3442
3443 fn blueprint_with_schema(config_schema: Option<serde_json::Value>) -> AgentBlueprint {
3444 AgentBlueprint {
3445 id: "test_blueprint",
3446 name: "Test Blueprint",
3447 description: "Blueprint for config validation tests",
3448 model: BlueprintModel::Inherit,
3449 system_prompt: "Test prompt",
3450 tools: vec![],
3451 max_turns: None,
3452 config_schema,
3453 }
3454 }
3455
3456 #[test]
3457 fn blueprint_without_schema_accepts_no_config() {
3458 let blueprint = blueprint_with_schema(None);
3459
3460 assert!(blueprint.validate_config(None).is_ok());
3461 assert!(matches!(
3462 blueprint.validate_config(Some(&serde_json::json!({"depth": "focused"}))),
3463 Err(BlueprintConfigError::NotAccepted { .. })
3464 ));
3465 }
3466
3467 #[test]
3468 fn blueprint_config_is_required_only_when_the_schema_says_so() {
3469 let required = blueprint_with_schema(Some(
3470 serde_json::json!({"type": "object", "required": ["repository"]}),
3471 ));
3472 assert!(matches!(
3473 required.validate_config(None),
3474 Err(BlueprintConfigError::Required { .. })
3475 ));
3476
3477 let optional = blueprint_with_schema(Some(serde_json::json!({"type": "object"})));
3478 assert!(optional.validate_config(None).is_ok());
3479 }
3480
3481 #[test]
3482 fn blueprint_config_is_validated_against_the_schema() {
3483 let blueprint = blueprint_with_schema(Some(serde_json::json!({
3484 "type": "object",
3485 "properties": {
3486 "max_candidates": {"type": "integer", "minimum": 1, "maximum": 50}
3487 },
3488 "additionalProperties": false
3489 })));
3490
3491 assert!(
3492 blueprint
3493 .validate_config(Some(&serde_json::json!({"max_candidates": 10})))
3494 .is_ok()
3495 );
3496
3497 let Err(BlueprintConfigError::Invalid { issues, .. }) =
3499 blueprint.validate_config(Some(&serde_json::json!({"max_candidates": 500})))
3500 else {
3501 panic!("out-of-range config should be rejected");
3502 };
3503 assert!(
3504 issues.iter().any(|issue| issue.contains("max_candidates")),
3505 "issue should name the offending property: {issues:?}"
3506 );
3507
3508 assert!(matches!(
3509 blueprint.validate_config(Some(&serde_json::json!({"unknown": true}))),
3510 Err(BlueprintConfigError::Invalid { .. })
3511 ));
3512 }
3513
3514 #[test]
3515 fn blueprint_with_unusable_schema_reports_it() {
3516 let blueprint = blueprint_with_schema(Some(serde_json::json!({"type": 42})));
3517
3518 assert!(matches!(
3519 blueprint.validate_config(Some(&serde_json::json!({}))),
3520 Err(BlueprintConfigError::InvalidSchema { .. })
3521 ));
3522 }
3523
3524 #[tokio::test]
3525 async fn test_capability_registry_blueprint_with_capability() {
3526 struct BlueprintProviderCapability;
3527
3528 impl Capability for BlueprintProviderCapability {
3529 fn id(&self) -> &str {
3530 "blueprint_provider"
3531 }
3532 fn name(&self) -> &str {
3533 "Blueprint Provider"
3534 }
3535 fn description(&self) -> &str {
3536 "Capability that provides a blueprint for tests"
3537 }
3538 fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3539 vec![AgentBlueprint {
3540 id: "test_blueprint",
3541 name: "Test Blueprint",
3542 description: "Blueprint for capability registry tests",
3543 model: BlueprintModel::Fixed("specialist-model".into()),
3544 system_prompt: "Test prompt",
3545 tools: vec![Box::new(FixtureTool("private_lookup"))],
3546 max_turns: Some(7),
3547 config_schema: Some(
3548 serde_json::json!({"type":"object", "required":["repository"]}),
3549 ),
3550 }]
3551 }
3552 }
3553
3554 let mut registry = CapabilityRegistry::new();
3555 registry.register(BlueprintProviderCapability);
3556
3557 let (capability_id, blueprint) = registry
3558 .blueprint_with_capability("test_blueprint")
3559 .expect("blueprint should resolve with capability id");
3560 assert_eq!(capability_id, "blueprint_provider");
3561 assert_eq!(blueprint.id, "test_blueprint");
3562 assert_eq!(blueprint.name, "Test Blueprint");
3563 assert_eq!(
3564 blueprint.description,
3565 "Blueprint for capability registry tests"
3566 );
3567 assert_eq!(blueprint.system_prompt, "Test prompt");
3568 assert!(
3569 matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
3570 );
3571 assert_eq!(blueprint.max_turns, Some(7));
3572 assert_eq!(
3573 blueprint.config_schema,
3574 Some(serde_json::json!({"type":"object", "required":["repository"]}))
3575 );
3576 let definitions = blueprint.tool_definitions();
3577 assert_eq!(definitions.len(), 1);
3578 assert_eq!(definitions[0].name(), "private_lookup");
3579 assert_eq!(
3580 registry.blueprint("test_blueprint").unwrap().tools[0].name(),
3581 "private_lookup"
3582 );
3583 assert_eq!(
3584 registry
3585 .all_blueprints()
3586 .iter()
3587 .map(|b| b.id)
3588 .collect::<Vec<_>>(),
3589 ["test_blueprint"]
3590 );
3591 assert!(registry.blueprint_with_capability("missing").is_none());
3592 assert!(registry.blueprint("missing").is_none());
3593 let host =
3594 collect_capabilities(&["blueprint_provider".into()], ®istry, &test_ctx()).await;
3595 assert!(host.tools.is_empty());
3596 assert!(host.tool_definitions.is_empty());
3597 }
3598
3599 #[test]
3600 fn test_capability_registry_builder() {
3601 let registry = CapabilityRegistry::builder()
3602 .capability(NoopFixture)
3603 .build();
3604
3605 assert!(registry.has("noop"));
3606 assert_eq!(registry.len(), 1);
3607 }
3608
3609 #[test]
3610 fn test_system_prompt_preview_default_delegates_to_addition() {
3611 struct StaticPromptCapability;
3614 impl Capability for StaticPromptCapability {
3615 fn id(&self) -> &str {
3616 "static_prompt"
3617 }
3618 fn name(&self) -> &str {
3619 "Static Prompt"
3620 }
3621 fn description(&self) -> &str {
3622 "Static prompt addition."
3623 }
3624 fn system_prompt_addition(&self) -> Option<&str> {
3625 Some("Use the static prompt.")
3626 }
3627 }
3628
3629 let cap = StaticPromptCapability;
3630 assert_eq!(
3631 cap.system_prompt_preview().as_deref(),
3632 Some("Use the static prompt.")
3633 );
3634
3635 let registry = fixture_registry();
3637 let current_time = registry.get("current_time").unwrap();
3638 assert!(current_time.system_prompt_preview().is_none());
3639 assert!(current_time.system_prompt_addition().is_none());
3640 }
3641
3642 #[tokio::test]
3647 async fn test_apply_capabilities_empty() {
3648 let registry = CapabilityRegistry::new();
3649 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3650
3651 let applied =
3652 apply_capabilities(base_runtime_agent.clone(), &[], ®istry, &test_ctx()).await;
3653
3654 assert_eq!(
3655 applied.runtime_agent.system_prompt,
3656 base_runtime_agent.system_prompt
3657 );
3658 assert!(applied.tool_registry.is_empty());
3659 assert!(applied.applied_ids.is_empty());
3660 }
3661
3662 #[tokio::test]
3663 async fn test_apply_capabilities_noop() {
3664 let registry = fixture_registry();
3665 let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3666
3667 base_runtime_agent.max_iterations = 13;
3668 base_runtime_agent.temperature = Some(0.25);
3669 base_runtime_agent.max_tokens = Some(1234);
3670 base_runtime_agent.parallel_tool_calls = Some(false);
3671 let applied = apply_capabilities(
3672 base_runtime_agent.clone(),
3673 &["noop".to_string()],
3674 ®istry,
3675 &test_ctx(),
3676 )
3677 .await;
3678
3679 assert_eq!(
3681 applied.runtime_agent.system_prompt,
3682 base_runtime_agent.system_prompt
3683 );
3684 assert!(applied.tool_registry.is_empty());
3685 assert_eq!(applied.applied_ids, vec!["noop"]);
3686 assert_eq!(
3687 serde_json::to_value(&applied.runtime_agent).unwrap(),
3688 serde_json::to_value(&base_runtime_agent).unwrap()
3689 );
3690 let collected = collect_capabilities(&["noop".into()], ®istry, &test_ctx()).await;
3691 assert!(collected.mounts.is_empty());
3692 assert!(collected.message_filter_providers.is_empty());
3693 assert!(compute_features(&["noop".into()], ®istry).is_empty());
3694 }
3695
3696 #[tokio::test]
3697 async fn test_apply_capabilities_current_time() {
3698 let registry = fixture_registry();
3699 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3700
3701 let applied = apply_capabilities(
3702 base_runtime_agent.clone(),
3703 &["current_time".to_string()],
3704 ®istry,
3705 &test_ctx(),
3706 )
3707 .await;
3708
3709 assert!(
3713 applied
3714 .runtime_agent
3715 .system_prompt
3716 .contains(FACTS_DYNAMIC_NOTE),
3717 "current_time should contribute the dynamic-facts note"
3718 );
3719 assert!(
3720 applied
3721 .runtime_agent
3722 .system_prompt
3723 .contains(&base_runtime_agent.system_prompt),
3724 "base prompt is preserved"
3725 );
3726 assert!(applied.tool_registry.has("get_current_time"));
3727 assert_eq!(applied.tool_registry.len(), 1);
3728 assert_eq!(applied.applied_ids, vec!["current_time"]);
3729 }
3730
3731 #[tokio::test]
3732 async fn test_apply_capabilities_skips_coming_soon() {
3733 struct ComingSoonFixture;
3734 impl Capability for ComingSoonFixture {
3735 fn id(&self) -> &str {
3736 "coming_soon_fixture"
3737 }
3738 fn name(&self) -> &str {
3739 "Coming Soon Fixture"
3740 }
3741 fn description(&self) -> &str {
3742 "Test-only capability."
3743 }
3744 fn status(&self) -> CapabilityStatus {
3745 CapabilityStatus::ComingSoon
3746 }
3747 fn system_prompt_addition(&self) -> Option<&str> {
3748 Some("Not yet available.")
3749 }
3750 }
3751 let mut registry = CapabilityRegistry::new();
3752 registry.register(ComingSoonFixture);
3753 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3754
3755 let applied = apply_capabilities(
3756 base_runtime_agent.clone(),
3757 &["coming_soon_fixture".to_string()],
3758 ®istry,
3759 &test_ctx(),
3760 )
3761 .await;
3762
3763 assert_eq!(
3764 applied.runtime_agent.system_prompt,
3765 base_runtime_agent.system_prompt
3766 );
3767 assert!(applied.applied_ids.is_empty());
3768 }
3769
3770 #[tokio::test]
3774 async fn test_apply_capabilities_keeps_deprecated_fully_functional() {
3775 struct DeprecatedFixture;
3776 impl Capability for DeprecatedFixture {
3777 fn id(&self) -> &str {
3778 "deprecated_fixture"
3779 }
3780 fn name(&self) -> &str {
3781 "Deprecated Fixture"
3782 }
3783 fn description(&self) -> &str {
3784 "Test-only capability."
3785 }
3786 fn status(&self) -> CapabilityStatus {
3787 CapabilityStatus::Deprecated
3788 }
3789 fn system_prompt_addition(&self) -> Option<&str> {
3790 Some("Still working.")
3791 }
3792 }
3793 let mut registry = CapabilityRegistry::new();
3794 registry.register(DeprecatedFixture);
3795 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3796
3797 let applied = apply_capabilities(
3798 base_runtime_agent,
3799 &["deprecated_fixture".to_string()],
3800 ®istry,
3801 &test_ctx(),
3802 )
3803 .await;
3804
3805 assert!(
3806 applied
3807 .runtime_agent
3808 .system_prompt
3809 .contains("Still working.")
3810 );
3811 assert_eq!(applied.applied_ids, vec!["deprecated_fixture"]);
3812 }
3813
3814 #[tokio::test]
3818 async fn test_apply_capabilities_skips_retired_without_failing() {
3819 struct RetiredFixture;
3820 impl Capability for RetiredFixture {
3821 fn id(&self) -> &str {
3822 "retired_fixture"
3823 }
3824 fn name(&self) -> &str {
3825 "Retired Fixture"
3826 }
3827 fn description(&self) -> &str {
3828 "Test-only capability."
3829 }
3830 fn status(&self) -> CapabilityStatus {
3831 CapabilityStatus::Retired
3832 }
3833 fn system_prompt_addition(&self) -> Option<&str> {
3834 Some("Should never be applied.")
3835 }
3836 }
3837 let mut registry = fixture_registry();
3838 registry.register(RetiredFixture);
3839 let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3840
3841 let applied = apply_capabilities(
3842 base_runtime_agent,
3843 &["retired_fixture".to_string(), "current_time".to_string()],
3844 ®istry,
3845 &test_ctx(),
3846 )
3847 .await;
3848
3849 assert!(
3850 !applied
3851 .runtime_agent
3852 .system_prompt
3853 .contains("Should never be applied.")
3854 );
3855 assert_eq!(applied.applied_ids, vec!["current_time"]);
3857 assert!(applied.tool_registry.has("get_current_time"));
3858 }
3859
3860 #[tokio::test]
3861 async fn test_apply_capabilities_preserves_order() {
3862 let registry = fixture_registry();
3863 let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3864
3865 let applied = apply_capabilities(
3867 base_runtime_agent,
3868 &["current_time".to_string(), "noop".to_string()],
3869 ®istry,
3870 &test_ctx(),
3871 )
3872 .await;
3873
3874 assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3875 assert_eq!(applied.tool_registry.len(), 1);
3876 assert!(applied.tool_registry.has("get_current_time"));
3877 }
3878
3879 #[tokio::test]
3888 async fn test_dynamic_facts_add_note_without_static_block() {
3889 let registry = fixture_registry();
3893 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3894 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3895 let prompt = collected.system_prompt_parts.join("\n");
3896 assert!(
3897 prompt.contains(FACTS_DYNAMIC_NOTE),
3898 "dynamic-facts note should be in the cached prompt"
3899 );
3900 assert!(
3901 !prompt.contains("<facts>\n"),
3902 "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3903 );
3904 }
3905
3906 #[tokio::test]
3907 async fn test_static_facts_fold_into_prompt() {
3908 struct StaticFactCap;
3909 impl Capability for StaticFactCap {
3910 fn id(&self) -> &str {
3911 "test_static_fact"
3912 }
3913 fn name(&self) -> &str {
3914 "Static Fact"
3915 }
3916 fn description(&self) -> &str {
3917 "test"
3918 }
3919 fn status(&self) -> CapabilityStatus {
3920 CapabilityStatus::Available
3921 }
3922 fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3923 vec![Fact::stat("workspace_root", "/workspace")]
3924 }
3925 }
3926 let mut registry = CapabilityRegistry::new();
3927 registry.register(StaticFactCap);
3928 let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3929 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
3930 let prompt = collected.system_prompt_parts.join("\n");
3931 assert!(
3932 prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3933 "static fact should fold into the cached prompt; got: {prompt}"
3934 );
3935 assert!(
3936 !prompt.contains(FACTS_DYNAMIC_NOTE),
3937 "no dynamic note when only static facts exist"
3938 );
3939 }
3940
3941 #[test]
3942 fn test_collect_dynamic_facts_returns_current_time() {
3943 let registry = fixture_registry();
3944 let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3945 let facts = collect_dynamic_facts(
3946 &configs,
3947 ®istry,
3948 None,
3949 &FactsContext::new(SessionId::new()),
3950 );
3951 assert_eq!(facts.len(), 1);
3952 assert_eq!(facts[0].key, "current_time");
3953 assert_eq!(facts[0].value, "fixture-now");
3954 assert_eq!(facts[0].volatility, Volatility::Dynamic);
3955 }
3956
3957 #[tokio::test]
3958 async fn test_collect_capabilities_combines_mounts() {
3959 struct Notes;
3960 impl Capability for Notes {
3961 fn id(&self) -> &str {
3962 "notes"
3963 }
3964 fn name(&self) -> &str {
3965 "Notes"
3966 }
3967 fn description(&self) -> &str {
3968 "Writable notes"
3969 }
3970 fn mounts(&self) -> Vec<MountPoint> {
3971 vec![MountPoint::readwrite(
3972 "/notes.txt",
3973 MountSource::text_file("Note α"),
3974 "notes",
3975 )]
3976 }
3977 }
3978 let mut registry = fixture_registry();
3979 registry.register(Notes);
3980 let collected = collect_capabilities(
3981 &["sample_data".into(), "notes".into(), "current_time".into()],
3982 ®istry,
3983 &test_ctx(),
3984 )
3985 .await;
3986 assert_eq!(
3987 collected.applied_ids,
3988 [
3989 "session_file_system",
3990 "sample_data",
3991 "notes",
3992 "current_time"
3993 ]
3994 );
3995 assert_eq!(
3996 collected.mounts,
3997 vec![
3998 MountPoint::readonly(
3999 "/samples",
4000 MountDirectoryBuilder::new()
4001 .file("users.json", "[]")
4002 .build(),
4003 "sample_data"
4004 ),
4005 MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
4006 ]
4007 );
4008 }
4009
4010 #[test]
4015 fn test_resolve_dependencies_empty() {
4016 let registry = CapabilityRegistry::new();
4017
4018 let resolved = resolve_dependencies(&[], ®istry).unwrap();
4019
4020 assert!(resolved.resolved_ids.is_empty());
4021 assert!(resolved.added_as_dependencies.is_empty());
4022 assert!(resolved.user_selected.is_empty());
4023 }
4024
4025 #[test]
4026 fn test_resolve_dependencies_no_deps() {
4027 let registry = fixture_registry();
4028
4029 let resolved = resolve_dependencies(&["current_time".to_string()], ®istry).unwrap();
4031
4032 assert_eq!(resolved.resolved_ids, vec!["current_time"]);
4033 assert!(resolved.added_as_dependencies.is_empty());
4034 }
4035
4036 #[test]
4037 fn test_resolve_dependencies_with_deps() {
4038 let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
4039 assert_eq!(
4040 resolved.resolved_ids,
4041 ["session_file_system", "sample_data"]
4042 );
4043 assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
4044 assert_eq!(resolved.user_selected, ["sample_data"]);
4045 }
4046
4047 #[test]
4048 fn test_resolve_dependencies_already_selected() {
4049 let registry = fixture_registry();
4050
4051 let resolved = resolve_dependencies(
4053 &["session_file_system".to_string(), "sample_data".to_string()],
4054 ®istry,
4055 )
4056 .unwrap();
4057
4058 assert_eq!(resolved.resolved_ids.len(), 2);
4059 assert!(resolved.added_as_dependencies.is_empty());
4061 }
4062
4063 #[test]
4064 fn test_resolve_dependencies_preserves_order() {
4065 let registry = fixture_registry();
4066
4067 let resolved =
4069 resolve_dependencies(&["current_time".to_string(), "noop".to_string()], ®istry)
4070 .unwrap();
4071
4072 assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
4073 }
4074
4075 #[test]
4076 fn test_resolve_dependencies_unknown_capability() {
4077 let registry = CapabilityRegistry::new();
4078
4079 let resolved =
4081 resolve_dependencies(&["unknown_capability".to_string()], ®istry).unwrap();
4082
4083 assert!(resolved.resolved_ids.is_empty());
4084 }
4085
4086 #[test]
4087 fn test_get_dependencies() {
4088 let registry = fixture_registry();
4089
4090 let deps = get_dependencies("sample_data", ®istry);
4092 assert_eq!(deps, vec!["session_file_system"]);
4093
4094 let deps = get_dependencies("current_time", ®istry);
4096 assert!(deps.is_empty());
4097
4098 let deps = get_dependencies("unknown", ®istry);
4100 assert!(deps.is_empty());
4101 }
4102
4103 #[test]
4107 fn test_circular_dependency_error() {
4108 struct CapA;
4110 struct CapB;
4111
4112 impl Capability for CapA {
4113 fn id(&self) -> &str {
4114 "test_cap_a"
4115 }
4116 fn name(&self) -> &str {
4117 "Test A"
4118 }
4119 fn description(&self) -> &str {
4120 "Test capability A"
4121 }
4122 fn dependencies(&self) -> Vec<&'static str> {
4123 vec!["test_cap_b"]
4124 }
4125 }
4126
4127 impl Capability for CapB {
4128 fn id(&self) -> &str {
4129 "test_cap_b"
4130 }
4131 fn name(&self) -> &str {
4132 "Test B"
4133 }
4134 fn description(&self) -> &str {
4135 "Test capability B"
4136 }
4137 fn dependencies(&self) -> Vec<&'static str> {
4138 vec!["test_cap_a"]
4139 }
4140 }
4141
4142 let mut registry = CapabilityRegistry::new();
4143 registry.register(CapA);
4144 registry.register(CapB);
4145
4146 let result = resolve_dependencies(&["test_cap_a".to_string()], ®istry);
4147
4148 assert!(result.is_err());
4149 match result.unwrap_err() {
4150 DependencyError::CircularDependency { capability_id, .. } => {
4151 assert_eq!(capability_id, "test_cap_a");
4152 }
4153 _ => panic!("Expected CircularDependency error"),
4154 }
4155 }
4156
4157 use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
4162
4163 struct FilterTestCapability {
4165 priority: i32,
4166 }
4167
4168 impl Capability for FilterTestCapability {
4169 fn id(&self) -> &str {
4170 "filter_test"
4171 }
4172 fn name(&self) -> &str {
4173 "Filter Test"
4174 }
4175 fn description(&self) -> &str {
4176 "Test capability with message filter"
4177 }
4178 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4179 Some(Arc::new(FilterTestProvider {
4180 priority: self.priority,
4181 }))
4182 }
4183 }
4184
4185 struct FilterTestProvider {
4186 priority: i32,
4187 }
4188
4189 impl MessageFilterProvider for FilterTestProvider {
4190 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4191 if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
4193 query
4194 .filters
4195 .push(MessageFilter::Search(search.to_string()));
4196 }
4197 }
4198
4199 fn priority(&self) -> i32 {
4200 self.priority
4201 }
4202 }
4203
4204 #[tokio::test]
4205 async fn test_collect_capabilities_with_configs_no_filter_providers() {
4206 let registry = fixture_registry();
4207 let configs = vec![AgentCapabilityConfig::with_config(
4208 CapabilityId::new("current_time"),
4209 serde_json::json!({}),
4210 )];
4211
4212 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4213
4214 assert!(collected.message_filter_providers.is_empty());
4215 assert!(!collected.has_message_filters());
4216 }
4217
4218 #[tokio::test]
4219 async fn test_collected_capabilities_apply_message_filters() {
4220 let mut registry = CapabilityRegistry::new();
4221 registry.register(FilterTestCapability { priority: 0 });
4222
4223 let configs = vec![AgentCapabilityConfig::with_config(
4224 CapabilityId::new("filter_test"),
4225 serde_json::json!({ "search": "test_query" }),
4226 )];
4227
4228 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4229
4230 assert!(collected.has_message_filters());
4231
4232 let session_id: SessionId = Uuid::now_v7().into();
4234 let mut query = MessageQuery::new(session_id);
4235
4236 collected.apply_message_filters(&mut query);
4237
4238 assert_eq!(query.filters.len(), 1);
4240 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4241 }
4242
4243 #[tokio::test]
4244 async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4245 struct SearchCapability {
4246 id: &'static str,
4247 search_term: &'static str,
4248 priority: i32,
4249 }
4250
4251 struct SearchProvider {
4252 search_term: &'static str,
4253 priority: i32,
4254 }
4255
4256 impl MessageFilterProvider for SearchProvider {
4257 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4258 query
4259 .filters
4260 .push(MessageFilter::Search(self.search_term.to_string()));
4261 }
4262
4263 fn priority(&self) -> i32 {
4264 self.priority
4265 }
4266 }
4267
4268 impl Capability for SearchCapability {
4269 fn id(&self) -> &str {
4270 self.id
4271 }
4272 fn name(&self) -> &str {
4273 "Search"
4274 }
4275 fn description(&self) -> &str {
4276 "Test"
4277 }
4278 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4279 Some(Arc::new(SearchProvider {
4280 search_term: self.search_term,
4281 priority: self.priority,
4282 }))
4283 }
4284 }
4285
4286 let mut registry = CapabilityRegistry::new();
4287 registry.register(SearchCapability {
4288 id: "cap_a",
4289 search_term: "alpha",
4290 priority: 5,
4291 });
4292 registry.register(SearchCapability {
4293 id: "cap_b",
4294 search_term: "beta",
4295 priority: 1,
4296 });
4297 registry.register(SearchCapability {
4298 id: "cap_c",
4299 search_term: "gamma",
4300 priority: 10,
4301 });
4302
4303 let configs = vec![
4304 AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4305 AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4306 AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4307 ];
4308
4309 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4310
4311 let session_id: SessionId = Uuid::now_v7().into();
4312 let mut query = MessageQuery::new(session_id);
4313
4314 collected.apply_message_filters(&mut query);
4315
4316 assert_eq!(query.filters.len(), 3);
4318 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4319 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4320 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4321 }
4322
4323 #[tokio::test]
4324 async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4325 let mut registry = CapabilityRegistry::new();
4326 registry.register(FilterTestCapability { priority: 0 });
4327
4328 let test_config = serde_json::json!({
4329 "search": "custom_search",
4330 "extra_field": 42
4331 });
4332
4333 let configs = vec![AgentCapabilityConfig::with_config(
4334 CapabilityId::new("filter_test"),
4335 test_config.clone(),
4336 )];
4337
4338 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4339
4340 assert_eq!(collected.message_filter_providers.len(), 1);
4342 let (_, stored_config) = &collected.message_filter_providers[0];
4343 assert_eq!(*stored_config, test_config);
4344 }
4345
4346 #[test]
4351 fn test_collect_message_filters_only_collects_filters() {
4352 let mut registry = CapabilityRegistry::new();
4353 registry.register(FilterTestCapability { priority: 0 });
4354
4355 let configs = vec![AgentCapabilityConfig::with_config(
4356 CapabilityId::new("filter_test"),
4357 serde_json::json!({ "search": "test_query" }),
4358 )];
4359
4360 let collected = collect_message_filters_only(&configs, ®istry);
4361
4362 let session_id: SessionId = Uuid::now_v7().into();
4363 let mut query = MessageQuery::new(session_id);
4364 collected.apply_message_filters(&mut query);
4365
4366 assert_eq!(query.filters.len(), 1);
4367 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4368 }
4369
4370 #[test]
4371 fn test_collect_message_filters_only_skips_unknown_capabilities() {
4372 let registry = CapabilityRegistry::new();
4373
4374 let configs = vec![AgentCapabilityConfig::with_config(
4375 CapabilityId::new("nonexistent"),
4376 serde_json::json!({}),
4377 )];
4378
4379 let collected = collect_message_filters_only(&configs, ®istry);
4380 assert!(collected.message_filter_providers.is_empty());
4381 }
4382
4383 #[test]
4384 fn test_collect_message_filters_only_preserves_priority_order() {
4385 struct PriorityFilterCap {
4386 id: &'static str,
4387 search_term: &'static str,
4388 priority: i32,
4389 }
4390
4391 struct PriorityFilterProvider {
4392 search_term: &'static str,
4393 priority: i32,
4394 }
4395
4396 impl Capability for PriorityFilterCap {
4397 fn id(&self) -> &str {
4398 self.id
4399 }
4400 fn name(&self) -> &str {
4401 self.id
4402 }
4403 fn description(&self) -> &str {
4404 "priority test"
4405 }
4406 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4407 Some(Arc::new(PriorityFilterProvider {
4408 search_term: self.search_term,
4409 priority: self.priority,
4410 }))
4411 }
4412 }
4413
4414 impl MessageFilterProvider for PriorityFilterProvider {
4415 fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4416 query
4417 .filters
4418 .push(MessageFilter::Search(self.search_term.to_string()));
4419 }
4420 fn priority(&self) -> i32 {
4421 self.priority
4422 }
4423 }
4424
4425 let mut registry = CapabilityRegistry::new();
4426 registry.register(PriorityFilterCap {
4427 id: "gamma",
4428 search_term: "gamma",
4429 priority: 10,
4430 });
4431 registry.register(PriorityFilterCap {
4432 id: "alpha",
4433 search_term: "alpha",
4434 priority: 5,
4435 });
4436 registry.register(PriorityFilterCap {
4437 id: "beta",
4438 search_term: "beta",
4439 priority: 1,
4440 });
4441
4442 let configs = vec![
4443 AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4444 AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4445 AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4446 ];
4447
4448 let collected = collect_message_filters_only(&configs, ®istry);
4449
4450 let session_id: SessionId = Uuid::now_v7().into();
4451 let mut query = MessageQuery::new(session_id);
4452 collected.apply_message_filters(&mut query);
4453
4454 assert_eq!(query.filters.len(), 3);
4456 assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4457 assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4458 assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4459 }
4460
4461 #[test]
4462 fn test_collect_message_filters_only_post_load_invoked() {
4463 use crate::message::Message;
4464
4465 struct PostLoadCap;
4466 struct PostLoadProvider;
4467
4468 impl Capability for PostLoadCap {
4469 fn id(&self) -> &str {
4470 "post_load_test"
4471 }
4472 fn name(&self) -> &str {
4473 "PostLoad Test"
4474 }
4475 fn description(&self) -> &str {
4476 "test"
4477 }
4478 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4479 Some(Arc::new(PostLoadProvider))
4480 }
4481 }
4482
4483 impl MessageFilterProvider for PostLoadProvider {
4484 fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4485 fn priority(&self) -> i32 {
4486 0
4487 }
4488 fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4489 messages.reverse();
4491 }
4492 }
4493
4494 let mut registry = CapabilityRegistry::new();
4495 registry.register(PostLoadCap);
4496
4497 let configs = vec![AgentCapabilityConfig::with_config(
4498 CapabilityId::new("post_load_test"),
4499 serde_json::json!({}),
4500 )];
4501
4502 let collected = collect_message_filters_only(&configs, ®istry);
4503
4504 let mut messages = vec![Message::user("first"), Message::user("second")];
4505 collected.apply_post_load_filters(&mut messages);
4506
4507 assert_eq!(messages[0].text(), Some("second"));
4509 assert_eq!(messages[1].text(), Some("first"));
4510 }
4511
4512 struct DelegatingFilterCap {
4515 id: &'static str,
4516 inner: std::sync::Arc<InnerFilterCap>,
4517 }
4518 struct InnerFilterCap;
4519
4520 impl Capability for InnerFilterCap {
4521 fn id(&self) -> &str {
4522 "inner_filter"
4523 }
4524 fn tools(&self) -> Vec<Box<dyn Tool>> {
4525 panic!("fast-path collection must not instantiate tools")
4526 }
4527 fn system_prompt_addition(&self) -> Option<&str> {
4528 panic!("fast-path collection must not collect prompts")
4529 }
4530 fn name(&self) -> &str {
4531 "Inner Filter"
4532 }
4533 fn description(&self) -> &str {
4534 "inner"
4535 }
4536 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4537 Some(std::sync::Arc::new(SentinelFilter))
4538 }
4539 }
4540 struct SentinelFilter;
4541 impl MessageFilterProvider for SentinelFilter {
4542 fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4543 query.limit = config["limit"].as_i64();
4544 }
4545 }
4546 impl Capability for DelegatingFilterCap {
4547 fn id(&self) -> &str {
4548 self.id
4549 }
4550 fn name(&self) -> &str {
4551 "Delegating Filter"
4552 }
4553 fn description(&self) -> &str {
4554 "delegating"
4555 }
4556 fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4557 None }
4559 fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4560 Some(&*self.inner)
4561 }
4562 }
4563
4564 #[test]
4565 fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4566 let inner = std::sync::Arc::new(InnerFilterCap);
4567 let outer = DelegatingFilterCap {
4568 id: "delegating_filter",
4569 inner: inner.clone(),
4570 };
4571
4572 let mut registry = CapabilityRegistry::new();
4573 registry.register(outer);
4574
4575 let configs = vec![AgentCapabilityConfig::with_config(
4576 CapabilityId::new("delegating_filter"),
4577 serde_json::json!({"limit": 17}),
4578 )];
4579
4580 let collected = collect_message_filters_only(&configs, ®istry);
4583 assert_eq!(
4584 collected.message_filter_providers.len(),
4585 1,
4586 "provider from resolved inner capability must be collected"
4587 );
4588 let mut query = MessageQuery::default();
4589 collected.apply_message_filters(&mut query);
4590 assert_eq!(query.limit, Some(17));
4591 }
4592
4593 struct DelegatingMvpCap {
4594 id: &'static str,
4595 inner: std::sync::Arc<InnerMvpCap>,
4596 }
4597 struct InnerMvpCap;
4598
4599 impl Capability for InnerMvpCap {
4600 fn id(&self) -> &str {
4601 "inner_mvp"
4602 }
4603 fn tools(&self) -> Vec<Box<dyn Tool>> {
4604 panic!("fast-path collection must not instantiate tools")
4605 }
4606 fn system_prompt_addition(&self) -> Option<&str> {
4607 panic!("fast-path collection must not collect prompts")
4608 }
4609 fn name(&self) -> &str {
4610 "Inner MVP"
4611 }
4612 fn description(&self) -> &str {
4613 "inner"
4614 }
4615 fn model_view_provider(
4616 &self,
4617 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4618 struct AppendingMvp;
4620 impl crate::capabilities::ModelViewProvider for AppendingMvp {
4621 fn apply_model_view(
4622 &self,
4623 mut messages: Vec<Message>,
4624 config: &serde_json::Value,
4625 context: &ModelViewContext<'_>,
4626 ) -> Vec<Message> {
4627 messages.push(Message::user(format!(
4628 "{}:{}",
4629 config["suffix"].as_str().unwrap(),
4630 context.session_id
4631 )));
4632 messages
4633 }
4634 }
4635 Some(std::sync::Arc::new(AppendingMvp))
4636 }
4637 }
4638 impl Capability for DelegatingMvpCap {
4639 fn id(&self) -> &str {
4640 self.id
4641 }
4642 fn name(&self) -> &str {
4643 "Delegating MVP"
4644 }
4645 fn description(&self) -> &str {
4646 "delegating"
4647 }
4648 fn model_view_provider(
4649 &self,
4650 ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4651 None }
4653 fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
4654 (model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
4655 }
4656 }
4657
4658 #[test]
4659 fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4660 let inner = std::sync::Arc::new(InnerMvpCap);
4661 let outer = DelegatingMvpCap {
4662 id: "delegating_mvp",
4663 inner: inner.clone(),
4664 };
4665
4666 let mut registry = CapabilityRegistry::new();
4667 registry.register(outer);
4668
4669 let configs = vec![AgentCapabilityConfig::with_config(
4670 CapabilityId::new("delegating_mvp"),
4671 serde_json::json!({"suffix": "delegated"}),
4672 )];
4673
4674 let collected = collect_model_view_providers(&configs, ®istry, Some("selected-model"));
4677 assert_eq!(
4678 collected.model_view_providers.len(),
4679 1,
4680 "provider from resolved inner capability must be collected"
4681 );
4682 assert!(
4683 collect_model_view_providers(&configs, ®istry, Some("other-model"))
4684 .model_view_providers
4685 .is_empty()
4686 );
4687 let session_id = SessionId::from_seed(42);
4688 let output = collected.apply_model_view(
4689 vec![Message::user("original")],
4690 &ModelViewContext {
4691 session_id,
4692 prior_usage: None,
4693 },
4694 );
4695 assert_eq!(
4696 output.iter().map(Message::text).collect::<Vec<_>>(),
4697 [
4698 Some("original"),
4699 Some(format!("delegated:{session_id}").as_str())
4700 ]
4701 );
4702 }
4703
4704 #[test]
4709 fn test_defaults_do_not_include_bash() {
4710 let registry = crate::ToolRegistry::with_defaults();
4713 assert!(
4714 !registry.has("bash"),
4715 "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4716 );
4717 }
4718
4719 #[test]
4724 fn test_alias_resolves_to_canonical_capability() {
4725 let registry = fixture_registry();
4726
4727 let via_alias = registry.get("virtual_bash").unwrap();
4729 assert_eq!(via_alias.id(), "bashkit_shell");
4730 assert!(registry.has("virtual_bash"));
4731 assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4732 assert_eq!(
4733 registry.canonical_id("bashkit_shell"),
4734 Some("bashkit_shell")
4735 );
4736 assert_eq!(registry.canonical_id("nonexistent"), None);
4737 }
4738
4739 #[test]
4740 fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4741 let registry = fixture_registry();
4742
4743 let resolved = resolve_dependencies(
4746 &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4747 ®istry,
4748 )
4749 .unwrap();
4750 let bash_ids: Vec<_> = resolved
4751 .resolved_ids
4752 .iter()
4753 .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4754 .collect();
4755 assert_eq!(bash_ids, vec!["bashkit_shell"]);
4756 assert!(
4758 !resolved
4759 .added_as_dependencies
4760 .contains(&"bashkit_shell".to_string())
4761 );
4762 }
4763
4764 #[test]
4765 fn test_alias_preserves_explicit_config_in_resolution() {
4766 let registry = fixture_registry();
4767
4768 let configs = vec![AgentCapabilityConfig::with_config(
4769 "virtual_bash".to_string(),
4770 serde_json::json!({"key": "value"}),
4771 )];
4772 let resolved = resolve_capability_configs(&configs, ®istry).unwrap();
4773 let bash = resolved
4774 .iter()
4775 .find(|c| c.capability_id() == "bashkit_shell")
4776 .expect("alias must resolve to canonical bashkit_shell config");
4777 assert_eq!(
4778 bash.config_value().clone(),
4779 serde_json::json!({"key": "value"})
4780 );
4781 }
4782
4783 #[test]
4784 fn test_unregister_by_alias_removes_capability_and_aliases() {
4785 let mut registry = fixture_registry();
4786
4787 assert!(registry.unregister("virtual_bash").is_some());
4788 assert!(!registry.has("bashkit_shell"));
4789 assert!(!registry.has("virtual_bash"));
4790 }
4791
4792 #[test]
4793 fn test_compute_features_empty() {
4794 let registry = CapabilityRegistry::new();
4795
4796 let features = compute_features(&[], ®istry);
4797 assert!(features.is_empty());
4798 }
4799
4800 #[test]
4801 fn test_compute_features_unknown_capability_ignored() {
4802 let registry = fixture_registry();
4803
4804 let features = compute_features(
4805 &["unknown_cap".to_string(), "session_storage".to_string()],
4806 ®istry,
4807 );
4808 assert_eq!(features, vec!["secrets", "key_value"]);
4809 }
4810
4811 #[test]
4812 fn test_risk_level_ordering() {
4813 assert!(RiskLevel::Low < RiskLevel::Medium);
4814 assert!(RiskLevel::Medium < RiskLevel::High);
4815 }
4816
4817 #[test]
4818 fn test_risk_level_serde_roundtrip() {
4819 for (level, wire) in [
4820 (RiskLevel::Low, "\"low\""),
4821 (RiskLevel::Medium, "\"medium\""),
4822 (RiskLevel::High, "\"high\""),
4823 ] {
4824 assert_eq!(serde_json::to_string(&level).unwrap(), wire);
4825 assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
4826 }
4827 assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
4828 }
4829
4830 struct SkillContributingCapability;
4835
4836 impl Capability for SkillContributingCapability {
4837 fn id(&self) -> &str {
4838 "contributes_skills"
4839 }
4840 fn name(&self) -> &str {
4841 "Contributes Skills"
4842 }
4843 fn description(&self) -> &str {
4844 "Test capability that contributes skills."
4845 }
4846 fn contribute_skills(&self) -> Vec<SkillContribution> {
4847 vec![
4848 SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4849 .with_files(vec![(
4850 "scripts/a.sh".to_string(),
4851 "#!/bin/sh\necho a\n".to_string(),
4852 )]),
4853 SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4854 .with_user_invocable(false),
4855 ]
4856 }
4857 }
4858
4859 fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4860 match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4861 MountSource::InlineFile { content, .. } => content.as_str(),
4862 _ => panic!("Expected InlineFile for SKILL.md"),
4863 }
4864 }
4865
4866 #[tokio::test]
4867 async fn test_contribute_skills_normalized_to_mounts() {
4868 let mut registry = CapabilityRegistry::new();
4869 registry.register(SkillContributingCapability);
4870
4871 let configs = vec![AgentCapabilityConfig::with_config(
4872 CapabilityId::new("contributes_skills"),
4873 serde_json::json!({}),
4874 )];
4875
4876 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4877
4878 let skill_mounts: Vec<_> = collected
4879 .mounts
4880 .iter()
4881 .filter(|m| m.path.starts_with("/.agents/skills/"))
4882 .collect();
4883 assert_eq!(skill_mounts.len(), 2);
4884
4885 for m in &skill_mounts {
4888 assert!(m.is_readonly());
4889 assert_eq!(m.capability_id, "contributes_skills");
4890 }
4891
4892 let alpha = skill_mounts
4893 .iter()
4894 .find(|m| m.path == "/.agents/skills/alpha-skill")
4895 .expect("alpha-skill mount missing");
4896 match &alpha.source {
4897 MountSource::InlineDirectory { entries } => {
4898 assert!(entries.contains_key("SKILL.md"));
4899 assert!(entries.contains_key("scripts/a.sh"));
4900 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4901 assert_eq!(parsed.name, "alpha-skill");
4902 assert_eq!(parsed.description, "Alpha skill desc");
4903 assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
4904 assert!(parsed.user_invocable);
4905 }
4906 _ => panic!("Expected InlineDirectory"),
4907 }
4908
4909 let beta = skill_mounts
4910 .iter()
4911 .find(|m| m.path == "/.agents/skills/beta-skill")
4912 .expect("beta-skill mount missing");
4913 match &beta.source {
4914 MountSource::InlineDirectory { entries } => {
4915 let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4916 assert!(!parsed.user_invocable);
4917 assert_eq!(parsed.name, "beta-skill");
4918 assert_eq!(parsed.instructions, "# Beta\nDo beta.");
4919 }
4920 _ => panic!("Expected InlineDirectory"),
4921 }
4922 }
4923
4924 #[tokio::test]
4925 async fn test_contribute_skills_default_empty() {
4926 let mut registry = CapabilityRegistry::new();
4929 registry.register(FilterTestCapability { priority: 0 });
4930
4931 let configs = vec![AgentCapabilityConfig::with_config(
4932 CapabilityId::new("filter_test"),
4933 serde_json::json!({}),
4934 )];
4935
4936 let collected = collect_capabilities_with_configs(&configs, ®istry, &test_ctx()).await;
4937 assert!(
4938 collected
4939 .mounts
4940 .iter()
4941 .all(|m| !m.path.starts_with("/.agents/skills/"))
4942 );
4943 }
4944
4945 struct LocalizedCapability;
4946
4947 impl Capability for LocalizedCapability {
4948 fn id(&self) -> &str {
4949 "localized"
4950 }
4951 fn name(&self) -> &str {
4952 "Localized"
4953 }
4954 fn description(&self) -> &str {
4955 "English description"
4956 }
4957 fn localizations(&self) -> Vec<CapabilityLocalization> {
4958 vec![
4959 CapabilityLocalization {
4960 locale: "en",
4961 name: None,
4962 description: None,
4963 config_description: Some("Controls things."),
4964 config_overlay: None,
4965 },
4966 CapabilityLocalization {
4967 locale: "uk-UA",
4968 name: Some("Регіональна"),
4969 description: None,
4970 config_description: None,
4971 config_overlay: None,
4972 },
4973 CapabilityLocalization {
4974 locale: "uk",
4975 name: Some("Локалізована"),
4976 description: Some("Український опис"),
4977 config_description: Some("Керує налаштуваннями."),
4978 config_overlay: None,
4979 },
4980 ]
4981 }
4982 }
4983
4984 #[test]
4985 fn localized_name_falls_back_exact_language_then_base() {
4986 let cap = LocalizedCapability;
4987 assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
4989 assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
4990 assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
4991 assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
4992 assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
4993 assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
4995 assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
4997 assert_eq!(cap.localized_name(None), "Localized");
4998 assert_eq!(cap.localized_description(Some("uk")), "Український опис");
4999 assert_eq!(cap.localized_description(Some("de")), "English description");
5000 }
5001
5002 #[test]
5003 fn describe_schema_resolves_config_description_per_locale() {
5004 let cap = LocalizedCapability;
5005 assert_eq!(
5006 cap.describe_schema(Some("uk-UA")).as_deref(),
5007 Some("Керує налаштуваннями.")
5008 );
5009 assert_eq!(
5011 cap.describe_schema(Some("pl")).as_deref(),
5012 Some("Controls things.")
5013 );
5014 assert_eq!(
5015 cap.describe_schema(None).as_deref(),
5016 Some("Controls things.")
5017 );
5018 assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
5020 }
5021
5022 #[tokio::test]
5023 async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
5024 let registry = fixture_registry();
5025 for (ids, expected) in [
5026 (
5027 vec!["test_math"],
5028 vec![
5029 ("add", "test_math", "Test Math"),
5030 ("subtract", "test_math", "Test Math"),
5031 ("multiply", "test_math", "Test Math"),
5032 ("divide", "test_math", "Test Math"),
5033 ],
5034 ),
5035 (
5036 vec!["test_weather"],
5037 vec![
5038 ("get_weather", "test_weather", "Test Weather"),
5039 ("get_forecast", "test_weather", "Test Weather"),
5040 ],
5041 ),
5042 (
5043 vec!["sample_data"],
5044 vec![
5045 ("read_file", "session_file_system", "Fixture Filesystem"),
5046 ("write_file", "session_file_system", "Fixture Filesystem"),
5047 ],
5048 ),
5049 (
5050 vec!["bashkit_shell", "test_weather"],
5051 vec![
5052 ("read_file", "session_file_system", "Fixture Filesystem"),
5053 ("write_file", "session_file_system", "Fixture Filesystem"),
5054 ("bash", "bashkit_shell", "Fixture Bash"),
5055 ("get_weather", "test_weather", "Test Weather"),
5056 ("get_forecast", "test_weather", "Test Weather"),
5057 ],
5058 ),
5059 ] {
5060 let ids: Vec<_> = ids.into_iter().map(String::from).collect();
5061 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
5062 assert_eq!(
5063 collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
5064 expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
5065 );
5066 assert_eq!(collected.tool_definitions.len(), expected.len());
5067 for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
5068 assert_eq!(definition.name(), name);
5069 let hints = definition.hints();
5070 assert_eq!(hints.capability_id.as_deref(), Some(id));
5071 assert_eq!(hints.capability_name.as_deref(), Some(label));
5072 let ToolDefinition::Builtin(tool) = definition else {
5073 panic!("expected builtin")
5074 };
5075 let schema = if name == "bash" {
5076 serde_json::json!({"type":"object"})
5077 } else {
5078 serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
5079 };
5080 assert_eq!(tool.parameters, schema);
5081 }
5082 }
5083 }
5084
5085 #[tokio::test]
5086 async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
5087 let registry = fixture_registry();
5088 let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
5089 let collected = collect_capabilities(&ids, ®istry, &test_ctx()).await;
5090 let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
5091 let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
5092 assert_eq!(collected.system_prompt_parts, vec![first, second]);
5093 assert_eq!(
5094 collected.system_prompt_attributions,
5095 vec![
5096 SystemPromptAttribution {
5097 capability_id: ids[0].clone(),
5098 content: first.into()
5099 },
5100 SystemPromptAttribution {
5101 capability_id: ids[1].clone(),
5102 content: second.into()
5103 }
5104 ]
5105 );
5106 assert_eq!(
5107 collected.system_prompt_prefix(),
5108 Some(format!("{first}\n\n{second}"))
5109 );
5110 let applied = apply_capabilities(
5111 RuntimeAgent::new("Base.", "fixture-model"),
5112 &ids,
5113 ®istry,
5114 &test_ctx(),
5115 )
5116 .await;
5117 assert_eq!(
5118 applied.runtime_agent.system_prompt,
5119 format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
5120 );
5121 assert!(applied.tool_registry.has("write_todos"));
5122 assert_eq!(applied.tool_registry.len(), 1);
5123 for (base, addition, expected) in [
5124 ("Base.", None, "Base."),
5125 ("Base.", Some(""), "Base."),
5126 ("", Some("Extra."), "Extra."),
5127 (
5128 "<system-prompt>Base.</system-prompt>",
5129 Some("Extra."),
5130 "<system-prompt>Base.</system-prompt>\n\nExtra.",
5131 ),
5132 ] {
5133 assert_eq!(compose_system_prompt(base, addition), expected);
5134 }
5135 }
5136
5137 struct DependencyFixture {
5138 id: String,
5139 deps: Vec<&'static str>,
5140 features: Vec<&'static str>,
5141 }
5142 impl Capability for DependencyFixture {
5143 fn id(&self) -> &str {
5144 &self.id
5145 }
5146 fn name(&self) -> &str {
5147 &self.id
5148 }
5149 fn description(&self) -> &str {
5150 "Dependency fixture"
5151 }
5152 fn dependencies(&self) -> Vec<&'static str> {
5153 self.deps.clone()
5154 }
5155 fn features(&self) -> Vec<&'static str> {
5156 self.features.clone()
5157 }
5158 }
5159
5160 #[test]
5161 fn feature_projection_preserves_order_and_distinct_dependency_features() {
5162 let mut registry = CapabilityRegistry::new();
5163 registry.register(DependencyFixture {
5164 id: "base".into(),
5165 deps: vec![],
5166 features: vec!["base-only", "shared"],
5167 });
5168 registry.register(DependencyFixture {
5169 id: "parent".into(),
5170 deps: vec!["base"],
5171 features: vec!["parent-only", "shared"],
5172 });
5173 registry.register(DependencyFixture {
5174 id: "other".into(),
5175 deps: vec![],
5176 features: vec!["other-only"],
5177 });
5178 assert_eq!(
5179 compute_features(&["parent".into()], ®istry),
5180 vec!["base-only", "shared", "parent-only"]
5181 );
5182 assert_eq!(
5183 compute_features(
5184 &[
5185 "other".into(),
5186 "parent".into(),
5187 "base".into(),
5188 "parent".into()
5189 ],
5190 ®istry
5191 ),
5192 vec!["other-only", "base-only", "shared", "parent-only"]
5193 );
5194 }
5195
5196 #[test]
5197 fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
5198 let mut registry = CapabilityRegistry::new();
5199 let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
5200 for id in &ids {
5201 registry.register(DependencyFixture {
5202 id: id.clone(),
5203 deps: vec![],
5204 features: vec![],
5205 });
5206 }
5207 let resolved = resolve_dependencies(&ids[..100], ®istry).unwrap();
5208 assert_eq!(resolved.resolved_ids, ids[..100]);
5209 assert_eq!(resolved.user_selected, ids[..100]);
5210 assert!(resolved.added_as_dependencies.is_empty());
5211 assert_eq!(
5212 resolve_dependencies(&ids, ®istry).unwrap_err(),
5213 DependencyError::TooManyCapabilities {
5214 count: 101,
5215 max: 100
5216 }
5217 );
5218 }
5219}