Skip to main content

everruns_core/capabilities/
mod.rs

1//! Capabilities Module for Agent Loop
2//!
3//! This module provides the capabilities abstraction that allows composing
4//! agent functionality through modular units. Each capability can contribute:
5//! - System prompt additions
6//! - Tools for the agent
7//! - Behavior modifications (future)
8//!
9//! Design decisions:
10//! - Capabilities are defined via the Capability trait for flexibility
11//! - CapabilityRegistry holds all available capability implementations
12//! - apply_capabilities() merges capability contributions into RuntimeAgent
13//! - The agent-loop remains execution-focused; capabilities are applied before execution
14//! - System prompt sections use XML tags for clear boundaries between components.
15//!   This follows Anthropic's recommendation for multi-component prompts and reduces
16//!   misattribution between capability instructions, user-provided AGENTS.md, and the
17//!   agent's base system prompt. See specs/xml-prompt-formatting.md for rationale.
18//!
19//! Each capability is in its own file with collocated tools.
20
21use crate::capability_types::is_plugin_capability;
22use crate::command::{
23    CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
24};
25use crate::deployment::DeploymentGrade;
26use crate::events::TokenUsage;
27use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
28use crate::message::Message;
29use crate::message_filter::MessageFilterProvider;
30use crate::runtime_agent::RuntimeAgent;
31use crate::tool_types::{ToolCall, ToolDefinition};
32use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
33use crate::traits::{SessionFileSystem, ToolContext};
34use crate::typed_id::SessionId;
35use async_trait::async_trait;
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::sync::Arc;
39
40// ============================================================================
41// Integration Plugin System
42// ============================================================================
43
44/// Plugin registration point for external integration crates.
45///
46/// Integration crates use `inventory::submit!` to register their capabilities
47/// without requiring `everruns-core` to know about them at compile time.
48/// The `CapabilityRegistry::with_builtins_for_grade()` method iterates all
49/// registered plugins and includes those matching the current deployment grade.
50///
51/// # Example
52///
53/// ```ignore
54/// // In integrations/daytona/src/lib.rs:
55/// inventory::submit! {
56///     everruns_core::capabilities::IntegrationPlugin {
57///         experimental_only: false,
58///         feature_flag: None,
59///         factory: || Box::new(DaytonaCapability),
60///     }
61/// }
62/// ```
63pub struct IntegrationPlugin {
64    /// If true, only registered when `DeploymentGrade::experimental_features_enabled()` is true.
65    pub experimental_only: bool,
66    /// If set, only registered when the named internal feature flag is enabled.
67    /// Checked via `InternalFeatureFlags::is_enabled()` at registry build time.
68    pub feature_flag: Option<&'static str>,
69    /// Factory function that creates the capability instance.
70    pub factory: fn() -> Box<dyn Capability>,
71}
72
73inventory::collect!(IntegrationPlugin);
74
75// Re-export capability types from capability_types module
76pub use crate::capability_types::{
77    AgentCapabilityConfig, CapabilityId, CapabilityStatus, MountAccess, MountDirectoryBuilder,
78    MountEntry, MountPoint, MountSource,
79};
80
81// ============================================================================
82// Capability Modules
83// ============================================================================
84
85#[cfg(feature = "a2a")]
86mod a2a_delegation;
87#[cfg(feature = "ui-capabilities")]
88mod a2ui;
89mod agent_handoff;
90mod agent_instructions;
91pub mod attach_skill;
92mod auto_tool_search;
93mod background_execution;
94mod bashkit_shell;
95mod btw;
96mod budgeting;
97mod citation_retrieval;
98mod citation_verification;
99mod claude_tool_search;
100pub mod compaction;
101mod current_time;
102mod data_knowledge;
103mod declarative;
104mod delegation_result;
105mod error_disclosure;
106pub mod facts;
107mod fake_aws;
108mod fake_crm;
109mod fake_financial;
110mod fake_warehouse;
111mod file_system;
112mod guardrails;
113mod human_intent;
114mod infinity_context;
115mod knowledge_base;
116mod knowledge_index;
117mod loop_detection;
118mod lua;
119mod lua_code_mode;
120pub mod mcp;
121mod memory;
122mod message_metadata;
123mod model_scout;
124mod monitors;
125mod noop;
126mod openai_tool_search;
127mod openrouter_server_tools;
128mod openrouter_workspace;
129#[cfg(feature = "ui-capabilities")]
130mod openui;
131mod parallel_tool_calls;
132mod platform_management;
133mod prompt_caching;
134mod prompt_canary_guardrail;
135mod research;
136mod sample_data;
137mod self_budget;
138mod session;
139mod session_sandbox;
140mod session_schedule;
141mod session_sql_database;
142mod session_storage;
143mod session_tasks;
144mod skills;
145mod skills_scoped;
146mod stateless_todo_list;
147mod subagents;
148mod system_commands;
149mod test_math;
150mod test_weather;
151mod tool_call_repair;
152mod tool_output_distillation;
153mod tool_output_persistence;
154mod tool_search;
155mod usage_limit_auto_continue;
156pub mod user_hooks;
157mod util;
158#[cfg(feature = "web-fetch")]
159mod web_fetch;
160
161// Re-export capabilities
162/// Capability ID for outbound A2A agent delegation. Defined ungated so session
163/// attachment logic can reference it even when the `a2a` feature (and the
164/// delegation implementation) is compiled out.
165pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
166/// KV key prefix for A2A delegation run records. Defined ungated so the
167/// session-storage internal-prefix reservation (a TM-TOOL/TM-AGENT mitigation
168/// against forged attachments) holds even when the `a2a` feature is compiled out.
169pub(crate) const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
170#[cfg(feature = "a2a")]
171pub use a2a_delegation::{A2aAgentDelegationCapability, SpawnAgentTool};
172#[cfg(feature = "ui-capabilities")]
173pub use a2ui::{A2UI_CAPABILITY_ID, A2UiCapability};
174pub use agent_handoff::{
175    AGENT_HANDOFF_CAPABILITY_ID, AgentHandoffCapability, SpawnAgentHandoffTool,
176};
177pub use agent_instructions::{
178    AGENT_INSTRUCTIONS_CAPABILITY_ID, AGENTS_MD_PATH, AgentInstructionsCapability,
179    AgentInstructionsConfig, DEFAULT_AGENT_INSTRUCTIONS_FILE, MAX_AGENT_INSTRUCTIONS_FILES,
180    MAX_AGENTS_MD_SIZE, format_agents_md_content, format_instruction_file_content,
181};
182pub use attach_skill::{
183    AttachSkillCapability, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH, SkillContribution,
184    SkillInstructions, SkillMeta, SkillSource, discover_skills_from_entries, is_skill_capability,
185    parse_skill_capability_id, reconstruct_skill_md, skill_capability_id,
186};
187pub use auto_tool_search::{AUTO_TOOL_SEARCH_CAPABILITY_ID, AutoToolSearchCapability};
188pub use background_execution::{BACKGROUND_EXECUTION_CAPABILITY_ID, BackgroundExecutionCapability};
189pub use btw::{BTW_CAPABILITY_ID, BtwCapability};
190pub use budgeting::{BUDGETING_CAPABILITY_ID, BudgetingCapability};
191pub use citation_retrieval::{
192    CITATION_RETRIEVAL_CAPABILITY_ID, CitationRetrievalCapability, CitationRetrievalConfig,
193};
194pub use citation_verification::{
195    CITATION_VERIFICATION_CAPABILITY_ID, CitationVerificationCapability,
196    CitationVerificationConfig, VerificationMode,
197};
198pub use claude_tool_search::{CLAUDE_TOOL_SEARCH_CAPABILITY_ID, ClaudeToolSearchCapability};
199pub use compaction::{
200    COMPACTION_CAPABILITY_ID, CompactionCapability, CompactionConfig, CompactionStep,
201    CompactionStrategy, CostControlConfig, CostControlMaskingResult, HierarchicalMemoryConfig,
202    MaskingSummaryFormat, MemoryTier, ObservationMaskingConfig, ObservationMaskingResult,
203    SessionCompactionMetrics, SummarizationConfig, aggressive_trim, apply_cost_control_masking,
204    apply_hierarchical_memory, apply_observation_masking, build_model_view_messages,
205    build_summarization_prompt, build_summary_message, classify_memory_tiers,
206    compose_summary_with_recent, estimate_tokens, estimate_total_tokens,
207    format_messages_for_summarization, should_compact_proactively,
208};
209pub use current_time::{CURRENT_TIME_CAPABILITY_ID, CurrentTimeCapability, GetCurrentTimeTool};
210pub use data_knowledge::{DATA_KNOWLEDGE_CAPABILITY_ID, DataKnowledgeCapability};
211pub use declarative::{
212    DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
213    DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
214    declarative_capability_info, hydrate_declarative_capability_config,
215    hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
216    plugin_capability_info, validate_declarative_capability_definition,
217};
218pub use delegation_result::{
219    ReportResultTool, ReportTaskProgressTool, report_result_tool_for_child_session,
220    report_task_progress_tool_for_child_session,
221};
222pub use error_disclosure::{
223    ERROR_DISCLOSURE_CAPABILITY_ID, ErrorDisclosureCapability, resolve_error_disclosure,
224};
225pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
226pub use fake_aws::{
227    AwsCreateEc2InstanceTool, AwsCreateIamUserTool, AwsCreateRdsDatabaseTool,
228    AwsCreateS3BucketTool, AwsGetCloudWatchMetricsTool, AwsListEc2InstancesTool,
229    AwsListIamUsersTool, AwsListRdsDatabasesTool, AwsListS3BucketsTool, AwsListSecurityGroupsTool,
230    AwsStopEc2InstanceTool, FAKE_AWS_CAPABILITY_ID, FakeAwsCapability,
231};
232pub use fake_crm::{
233    CrmAddInteractionTool, CrmCreateCustomerTool, CrmCreateTicketTool, CrmGetCustomerTool,
234    CrmListCustomersTool, CrmListTicketsTool, CrmSearchCustomersTool, CrmUpdateTicketTool,
235    FAKE_CRM_CAPABILITY_ID, FakeCrmCapability,
236};
237pub use fake_financial::{
238    FAKE_FINANCIAL_CAPABILITY_ID, FakeFinancialCapability, FinanceCreateBudgetTool,
239    FinanceCreateTransactionTool, FinanceForecastCashFlowTool, FinanceGetBalanceTool,
240    FinanceGetExpenseReportTool, FinanceGetRevenueReportTool, FinanceListBudgetsTool,
241    FinanceListTransactionsTool,
242};
243pub use fake_warehouse::{
244    FAKE_WAREHOUSE_CAPABILITY_ID, FakeWarehouseCapability, WarehouseCreateInvoiceTool,
245    WarehouseCreateOrderTool, WarehouseCreateShipmentTool, WarehouseGetInventoryTool,
246    WarehouseInventoryReportTool, WarehouseListOrdersTool, WarehouseListShipmentsTool,
247    WarehouseProcessReturnTool, WarehouseUpdateInventoryTool, WarehouseUpdateShipmentStatusTool,
248};
249pub use file_system::{
250    DeleteFileTool, EditFileTool, FileSystemCapability, GrepFilesTool, ListDirectoryTool,
251    ReadFileTool, SESSION_FILE_SYSTEM_CAPABILITY_ID, StatFileTool, WriteFileTool,
252};
253pub use guardrails::{GUARDRAILS_CAPABILITY_ID, GuardrailsCapability};
254pub use human_intent::{HUMAN_INTENT_CAPABILITY_ID, HumanIntentCapability};
255pub use infinity_context::{
256    INFINITY_CONTEXT_CAPABILITY_ID, InfinityContextCapability, QueryHistoryTool,
257};
258pub use knowledge_base::{
259    KNOWLEDGE_BASE_CAPABILITY_ID, KnowledgeBaseCapability, KnowledgeBaseConfig,
260    validate_knowledge_base_config,
261};
262pub use knowledge_index::{
263    KNOWLEDGE_INDEX_CAPABILITY_ID, KnowledgeIndexCapability, KnowledgeIndexConfig,
264    validate_knowledge_index_config,
265};
266pub use loop_detection::{LOOP_DETECTION_CAPABILITY_ID, LoopDetectionCapability};
267pub use lua::{LUA_CAPABILITY_ID, LuaCapability, LuaTool, LuaVfs, is_code_mode_eligible};
268pub use lua_code_mode::{LUA_CODE_MODE_CAPABILITY_ID, LuaCodeModeCapability};
269pub use mcp::{
270    MCP_CAPABILITY_PREFIX, McpCapability, is_mcp_capability, mcp_capability_id,
271    parse_mcp_capability_id,
272};
273pub use memory::{MEMORY_CAPABILITY_ID, MemoryCapability};
274pub use message_metadata::{
275    MESSAGE_METADATA_CAPABILITY_ID, MessageMetadataCapability, MessageMetadataConfig,
276    MessageMetadataField, render_annotation, strip_leading_timestamp_annotations,
277};
278pub use model_scout::{
279    MODEL_SCOUT_CAPABILITY_ID, ModelRanking, ModelScoutCapability, ProbeResult, ProbeTask,
280    RouterUpdateProposal, compute_score, rank_results,
281};
282pub use noop::{NOOP_CAPABILITY_ID, NoopCapability};
283pub use openai_tool_search::{
284    DEFAULT_TOOL_SEARCH_THRESHOLD, OPENAI_TOOL_SEARCH_CAPABILITY_ID, OpenAiToolSearchCapability,
285    model_supports_native_tool_search,
286};
287pub use openrouter_server_tools::{
288    OPENROUTER_SERVER_TOOLS_CAPABILITY_ID, OpenRouterServerToolsCapability,
289};
290pub use openrouter_workspace::{
291    OPENROUTER_WORKSPACE_CAPABILITY_ID, OpenRouterKeyInfo, OpenRouterRateLimit,
292    OpenRouterWorkspaceCapability, PolicyCompatibilityReport, WorkspacePolicyDrift,
293    detect_policy_drift,
294};
295#[cfg(feature = "ui-capabilities")]
296pub use openui::{OPENUI_CAPABILITY_ID, OpenUiCapability};
297pub use parallel_tool_calls::{
298    PARALLEL_TOOL_CALLS_CAPABILITY_ID, ParallelToolCallsCapability, ParallelToolCallsMode,
299    parallel_tool_calls_from_config,
300};
301pub use platform_management::{
302    ManageAgentsTool, ManageHarnessesTool, ManageSessionsTool, PLATFORM_MANAGEMENT_CAPABILITY_ID,
303    PlatformManagementCapability, ReadAgentsTool, ReadCapabilitiesTool, ReadHarnessesTool,
304    ReadSessionsTool, SessionReadMessagesTool, SessionReadResponseTool, SessionSendMessageTool,
305};
306pub use prompt_caching::{PROMPT_CACHING_CAPABILITY_ID, PromptCachingCapability};
307pub use prompt_canary_guardrail::{
308    DEFAULT_REPLACEMENT as PROMPT_CANARY_DEFAULT_REPLACEMENT,
309    PROMPT_CANARY_GUARDRAIL_CAPABILITY_ID, PromptCanaryGuardrailCapability,
310    REASON_CODE_SYSTEM_PROMPT_LEAK,
311};
312pub use research::{RESEARCH_CAPABILITY_ID, ResearchCapability};
313pub use sample_data::{SAMPLE_DATA_CAPABILITY_ID, SampleDataCapability};
314pub use self_budget::{SELF_BUDGET_CAPABILITY_ID, SelfBudgetCapability};
315pub use session::{
316    GetSessionInfoTool, SESSION_CAPABILITY_ID, SessionCapability, SessionCapabilityConfig,
317    SessionTitleMutation, WriteSessionTitleTool, session_title_updated_event,
318    update_session_title_with_event,
319};
320pub use session_sandbox::{
321    SESSION_SANDBOX_CAPABILITY_ID, SandboxExecTool, SandboxManageTool, SandboxReadFileTool,
322    SandboxStatusTool, SandboxWriteFileTool, SessionSandboxCapability,
323};
324pub use session_schedule::{
325    CancelScheduleTool, CreateScheduleTool, ListSchedulesTool, SESSION_SCHEDULE_CAPABILITY_ID,
326    SessionScheduleCapability,
327};
328pub use session_sql_database::{
329    SESSION_SQL_DATABASE_CAPABILITY_ID, SessionSqlDatabaseCapability, SqlExecuteTool, SqlQueryTool,
330    SqlSchemaTool,
331};
332pub use session_storage::{
333    KvStoreTool, SESSION_STORAGE_CAPABILITY_ID, SecretStoreTool, SessionStorageCapability,
334    is_internal_session_kv_key,
335};
336pub use session_tasks::{SESSION_TASKS_CAPABILITY_ID, SessionTasksCapability};
337pub use skills::{SKILLS_CAPABILITY_ID, SkillsCapability};
338pub use skills_scoped::{
339    ScopedSkillsCapability, SkillDirResolver, SkillScope, SkillsConfig, VfsSkillDirResolver,
340};
341pub use stateless_todo_list::{
342    STATELESS_TODO_LIST_CAPABILITY_ID, StatelessTodoListCapability, WriteTodosTool,
343};
344pub(crate) use subagents::SPAWN_AGENT_CONCURRENCY_CLASS;
345pub use subagents::{SUBAGENTS_CAPABILITY_ID, SpawnSubagentAsAgentTool, SubagentCapability};
346pub use usage_limit_auto_continue::{
347    AutoContinueConfig, USAGE_LIMIT_AUTO_CONTINUE_CAPABILITY_ID, UsageLimitAutoContinueCapability,
348    resolve_usage_limit_auto_continue,
349};
350// Blueprint types are exported directly from the trait definitions above
351pub use bashkit_shell::{
352    BASHKIT_SHELL_CAPABILITY_ID, BashTool, BashkitShellCapability, SessionFileSystemAdapter,
353};
354pub use system_commands::{SYSTEM_COMMANDS_CAPABILITY_ID, SystemCommandsCapability};
355pub use test_math::{
356    AddTool, DivideTool, MultiplyTool, SubtractTool, TEST_MATH_CAPABILITY_ID, TestMathCapability,
357};
358pub use test_weather::{
359    GetForecastTool, GetWeatherTool, TEST_WEATHER_CAPABILITY_ID, TestWeatherCapability,
360};
361pub use tool_call_repair::{
362    DEFAULT_MAX_REPROMPTS, MAX_SALVAGE_INPUT_BYTES, RepairOutcome, SalvageResult,
363    TOOL_CALL_REPAIR_CAPABILITY_ID, ToolCallRepairCapability, ToolCallRepairConfig,
364    salvage_tool_arguments, tool_call_repair_capability,
365};
366pub use tool_output_distillation::{
367    DistillOutputHook, TOOL_OUTPUT_DISTILLATION_CAPABILITY_ID, ToolOutputDistillationCapability,
368};
369pub use tool_output_persistence::{
370    PersistOutputHook, TOOL_OUTPUT_PERSISTENCE_CAPABILITY_ID, ToolOutputPersistenceCapability,
371};
372pub use tool_search::{
373    TOOL_SEARCH_CAPABILITY_ID, TOOL_SEARCH_TOOL_NAME, ToolSearchCapability, ToolSearchTool,
374};
375pub use user_hooks::{USER_HOOKS_CAPABILITY_ID, UserHooksCapability};
376#[cfg(feature = "web-fetch")]
377pub use web_fetch::{
378    BotAuthPublicKey, WEB_FETCH_CAPABILITY_ID, WebFetchCapability, WebFetchTool,
379    derive_bot_auth_public_key,
380};
381
382// ============================================================================
383// System Prompt Context
384// ============================================================================
385
386/// Context provided to capabilities when resolving dynamic system prompt contributions.
387///
388/// This gives capabilities access to session-specific resources (filesystem, etc.)
389/// so they can generate system prompt content at runtime rather than returning
390/// only static text.
391pub struct SystemPromptContext {
392    /// The current session ID
393    pub session_id: SessionId,
394    /// Optional locale for localized prompts and tool behavior.
395    pub locale: Option<String>,
396    /// Optional file store for reading session files (e.g., AGENTS.md)
397    pub file_store: Option<Arc<dyn SessionFileSystem>>,
398    /// The model the agent will run on, when known at collection time.
399    ///
400    /// Enables model-adaptive capabilities (see [`Capability::resolve_for_model`],
401    /// e.g. `auto_tool_search`). `None` when the model is not yet resolved; such
402    /// capabilities then fall back to their provider-agnostic behavior.
403    pub model: Option<String>,
404}
405
406impl SystemPromptContext {
407    /// Create context with no file store (for callers that don't need filesystem access)
408    pub fn without_file_store(session_id: SessionId) -> Self {
409        Self {
410            session_id,
411            locale: None,
412            file_store: None,
413            model: None,
414        }
415    }
416
417    /// Set the model the agent will run on (drives model-adaptive capabilities).
418    pub fn with_model(mut self, model: impl Into<String>) -> Self {
419        self.model = Some(model.into());
420        self
421    }
422}
423
424// ============================================================================
425// Capability Trait
426// ============================================================================
427
428/// Trait for implementing capabilities that extend agent functionality.
429///
430/// A capability can contribute:
431/// - System prompt additions (appended after the agent's base system prompt)
432/// - Tools (added to agent's available tools)
433///
434/// # System Prompt Contributions
435///
436/// Capabilities provide system prompt content via `system_prompt_contribution()`.
437/// This async method receives a `SystemPromptContext` with access to the session
438/// filesystem, allowing capabilities to generate dynamic content (e.g., reading
439/// AGENTS.md or scanning for skills).
440///
441/// The default implementation wraps the static `system_prompt_addition()` text
442/// in `<capability id="...">` XML tags. Capabilities that need dynamic content
443/// override `system_prompt_contribution()` directly.
444///
445/// # Example
446///
447/// ```ignore
448/// use everruns_core::capabilities::Capability;
449///
450/// struct CurrentTimeCapability;
451///
452/// impl Capability for CurrentTimeCapability {
453///     fn id(&self) -> &str {
454///         "current_time"
455///     }
456///
457///     fn name(&self) -> &str {
458///         "Current Time"
459///     }
460///
461///     fn description(&self) -> &str {
462///         "Provides tools to get the current date and time."
463///     }
464///
465///     fn tools(&self) -> Vec<Box<dyn Tool>> {
466///         vec![Box::new(GetCurrentTimeTool)]
467///     }
468/// }
469/// ```
470/// Localized display strings for one locale.
471///
472/// Base English strings stay in `name()` / `description()` / `config_schema()`;
473/// localizations are additive overlays, so adding a locale never changes the
474/// `Capability` trait contract for existing implementations.
475#[derive(Debug, Clone)]
476pub struct CapabilityLocalization {
477    /// Language tag this entry applies to, lowercase (e.g. `"uk"` or `"uk-ua"`).
478    pub locale: &'static str,
479    /// Localized display name; `None` falls back to `name()`.
480    pub name: Option<&'static str>,
481    /// Localized description; `None` falls back to `description()`.
482    pub description: Option<&'static str>,
483    /// One-line summary of what this capability's config controls.
484    ///
485    /// Provide an `"en"` entry for the base locale; capabilities without
486    /// config leave this `None` everywhere.
487    pub config_description: Option<&'static str>,
488    /// Overlay merged into `config_schema()` by clients before rendering.
489    ///
490    /// Mirrors JSON Schema structure (`properties` / `items` nesting); nodes
491    /// carry `title`, `description`, and `enum_labels` (map from enum value
492    /// to localized label, applied to `oneOf` `const`/`title` entries).
493    pub config_overlay: Option<serde_json::Value>,
494}
495
496impl CapabilityLocalization {
497    /// Entry with only display strings (no config).
498    pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
499        Self {
500            locale,
501            name: Some(name),
502            description: Some(description),
503            config_description: None,
504            config_overlay: None,
505        }
506    }
507}
508
509/// Resolve a localized field with the standard fallback chain:
510/// exact tag → language family → `"en"`. Returns `None` when no entry
511/// provides the field; callers fall back to the unlocalized trait values.
512pub fn resolve_localized_field<T>(
513    localizations: &[CapabilityLocalization],
514    locale: Option<&str>,
515    field: impl Fn(&CapabilityLocalization) -> Option<T>,
516) -> Option<T> {
517    let mut candidates: Vec<String> = Vec::new();
518    if let Some(raw) = locale {
519        let normalized = raw.trim().replace('_', "-").to_lowercase();
520        if !normalized.is_empty() {
521            if let Some((language, _)) = normalized.split_once('-') {
522                let language = language.to_string();
523                candidates.push(normalized);
524                candidates.push(language);
525            } else {
526                candidates.push(normalized);
527            }
528        }
529    }
530    candidates.push("en".to_string());
531
532    for candidate in candidates {
533        let hit = localizations
534            .iter()
535            .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
536            .and_then(&field);
537        if hit.is_some() {
538            return hit;
539        }
540    }
541    None
542}
543
544#[async_trait]
545pub trait Capability: Send + Sync {
546    /// Returns the unique capability identifier as a string
547    fn id(&self) -> &str;
548
549    /// Returns legacy identifiers that resolve to this capability.
550    ///
551    /// Aliases exist so a capability can be renamed without breaking persisted
552    /// agent configs: registry lookups (`get`, `has`) and dependency resolution
553    /// treat an alias exactly like the canonical `id()`. Resolution always
554    /// normalizes aliases to the canonical ID, so an alias and its canonical
555    /// ID never activate the capability twice. New code must use `id()`;
556    /// aliases are a compatibility surface only.
557    fn aliases(&self) -> Vec<&'static str> {
558        vec![]
559    }
560
561    /// Returns the display name
562    fn name(&self) -> &str;
563
564    /// Returns a description of what this capability provides
565    fn description(&self) -> &str;
566
567    /// Returns localization overlays for this capability's display strings.
568    ///
569    /// Include an `"en"` entry when providing `config_description` for the
570    /// base locale. Lookup follows `resolve_localized_field` fallback rules.
571    fn localizations(&self) -> Vec<CapabilityLocalization> {
572        vec![]
573    }
574
575    /// Display name resolved for `locale`; `None` or unknown locales fall
576    /// back to `name()`.
577    fn localized_name(&self, locale: Option<&str>) -> String {
578        resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
579            .unwrap_or_else(|| self.name())
580            .to_string()
581    }
582
583    /// Description resolved for `locale`; falls back to `description()`.
584    fn localized_description(&self, locale: Option<&str>) -> String {
585        resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
586            .unwrap_or_else(|| self.description())
587            .to_string()
588    }
589
590    /// One-line human-readable summary of what this capability's config
591    /// controls, resolved for `locale`. `None` when the capability exposes
592    /// no per-agent config.
593    fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
594        resolve_localized_field(&self.localizations(), locale, |entry| {
595            entry.config_description
596        })
597        .map(str::to_string)
598    }
599
600    /// Returns the current status of this capability
601    fn status(&self) -> CapabilityStatus {
602        CapabilityStatus::Available
603    }
604
605    /// Returns the icon name for UI rendering (optional)
606    fn icon(&self) -> Option<&str> {
607        None
608    }
609
610    /// Returns the category for grouping in UI (optional)
611    fn category(&self) -> Option<&str> {
612        None
613    }
614
615    /// Whether this capability is a guardrail — a constraint on agent
616    /// behavior (content checks, tool restrictions) rather than a grant of
617    /// new abilities. Structural marker for UI sections and catalog
618    /// filtering; carries no runtime semantics. See specs/guardrails.md.
619    fn is_guardrail(&self) -> bool {
620        false
621    }
622
623    /// Model-adaptive dispatch: delegate this capability's contributions to a
624    /// different underlying capability based on the agent's model.
625    ///
626    /// Capability collection (which knows the model via
627    /// [`SystemPromptContext::model`]) calls this and, when it returns `Some`,
628    /// collects the returned capability's contributions in place of this one's.
629    /// The default returns `None` (no delegation). `auto_tool_search` overrides
630    /// it to pick hosted vs client-side tool search. `model` is `None` when not
631    /// yet resolved; implementations should choose a safe provider-agnostic
632    /// default in that case.
633    fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
634        None
635    }
636
637    /// Returns static text to include in the agent's system prompt (optional).
638    ///
639    /// This is the simple sync path for capabilities with static prompts.
640    /// For dynamic content that requires filesystem access, override
641    /// `system_prompt_contribution()` instead.
642    ///
643    /// **Contract: no duplication with tool definitions.** System prompt
644    /// additions must NOT repeat information already present in tool names,
645    /// descriptions, or parameter schemas. Only include content that cannot
646    /// be inferred from tool definitions alone:
647    ///
648    /// - High-level semantics (when to use which tool, behavioral guidance)
649    /// - Constraints the model cannot discover from schemas (row limits,
650    ///   naming rules, workspace root paths, scheduling limits)
651    /// - Data layout (filesystem paths for state files)
652    /// - Cross-tool relationships or ordering not evident from descriptions
653    ///
654    /// If every piece of information in the prompt is already covered by the
655    /// tool definitions, return `None` instead.
656    fn system_prompt_addition(&self) -> Option<&str> {
657        None
658    }
659
660    /// Returns the system prompt contribution for this capability, with access
661    /// to session context (filesystem, etc.).
662    ///
663    /// This is the primary method for contributing to the system prompt.
664    /// The returned string is included as-is in the final prompt (the capability
665    /// is responsible for its own XML wrapping).
666    ///
667    /// The default implementation wraps `system_prompt_addition()` in
668    /// `<capability id="...">` XML tags. Capabilities with dynamic content
669    /// (e.g., `agent_instructions`, `skills`) override this to read from the
670    /// session filesystem.
671    async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
672        self.system_prompt_addition().map(|addition| {
673            format!(
674                "<capability id=\"{}\">\n{}\n</capability>",
675                self.id(),
676                addition
677            )
678        })
679    }
680
681    /// Returns a preview of the system prompt addition for UI display.
682    ///
683    /// For most capabilities this is identical to `system_prompt_addition()`.
684    /// Capabilities with dynamic content (e.g. `agent_instructions` which reads
685    /// AGENTS.md at runtime) override this to return a representative preview.
686    fn system_prompt_preview(&self) -> Option<String> {
687        self.system_prompt_addition().map(|s| s.to_string())
688    }
689
690    /// Returns tool implementations provided by this capability
691    fn tools(&self) -> Vec<Box<dyn Tool>> {
692        vec![]
693    }
694
695    /// Returns tool implementations configured by per-capability config.
696    ///
697    /// Called during capability collection with the per-agent config for this
698    /// capability (from `AgentCapabilityConfig.config`). Capabilities that adapt
699    /// their tools based on config override this method.
700    ///
701    /// Default delegates to `tools()`.
702    fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
703        self.tools()
704    }
705
706    /// Returns system prompt contribution adapted to per-capability config.
707    ///
708    /// Called during capability collection. Capabilities whose system prompt
709    /// content depends on config override this method.
710    ///
711    /// Default delegates to `system_prompt_contribution(ctx)`.
712    async fn system_prompt_contribution_with_config(
713        &self,
714        ctx: &SystemPromptContext,
715        _config: &serde_json::Value,
716    ) -> Option<String> {
717        self.system_prompt_contribution(ctx).await
718    }
719
720    /// Returns tool definitions for the agent config
721    /// By default, converts tools() to definitions
722    fn tool_definitions(&self) -> Vec<ToolDefinition> {
723        self.tools().iter().map(|t| t.to_definition()).collect()
724    }
725
726    /// Returns mount points to populate in the session filesystem
727    ///
728    /// Mount points allow capabilities to provide files and directories
729    /// that are automatically created when a session starts. This is useful
730    /// for providing sample data, documentation, or configuration files.
731    ///
732    /// By default, returns an empty vector (no mounts).
733    fn mounts(&self) -> Vec<MountPoint> {
734        vec![]
735    }
736
737    /// Returns capability IDs that this capability depends on.
738    ///
739    /// Dependencies are automatically resolved at runtime when applying
740    /// capabilities. If capability A depends on capability B, then B's
741    /// contributions (tools, system prompt, mounts) will be included
742    /// when A is selected, even if B is not explicitly selected.
743    ///
744    /// By default, returns an empty vector (no dependencies).
745    fn dependencies(&self) -> Vec<&'static str> {
746        vec![]
747    }
748
749    /// Returns UI feature strings that this capability contributes to.
750    ///
751    /// Features are open-ended strings indicating what user-facing functionality
752    /// this capability enables. Multiple capabilities can contribute the same
753    /// feature (e.g., both `session_schedule` and a future `signals` capability
754    /// might contribute `"schedules"`).
755    ///
756    /// The UI uses the aggregated set of features from all active capabilities
757    /// to decide which tabs/sections to render.
758    ///
759    /// Known features: `"file_system"`, `"schedules"`, `"secrets"`,
760    /// `"key_value"`, `"sql_database"`, `"leased_resources"`.
761    ///
762    /// By default, returns an empty vector (no features).
763    fn features(&self) -> Vec<&'static str> {
764        vec![]
765    }
766
767    /// Returns the JSON Schema for this capability's per-agent config.
768    ///
769    /// The schema is exposed through `CapabilityInfo` so clients can render a
770    /// generic settings editor for capabilities without hard-coding capability
771    /// IDs. Capabilities without configurable settings return `None`.
772    fn config_schema(&self) -> Option<serde_json::Value> {
773        None
774    }
775
776    /// Returns UI hints for rendering `config_schema`.
777    ///
778    /// This follows the react-jsonschema-form `uiSchema` shape. The server owns
779    /// durable config semantics; clients own the generic component implementation.
780    fn config_ui_schema(&self) -> Option<serde_json::Value> {
781        None
782    }
783
784    /// Validates per-capability config before it is persisted.
785    ///
786    /// Default accepts any config for backward compatibility. Capabilities with
787    /// a `config_schema()` should reject invalid values here so HTTP, CLI, and
788    /// MCP write paths share the same server-side guardrail.
789    fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
790        Ok(())
791    }
792
793    /// Returns remote MCP servers contributed by this capability.
794    ///
795    /// These are merged into harness/agent/session scoped MCP config at runtime.
796    /// Explicit scoped MCP config overrides capability-contributed defaults by
797    /// logical server name.
798    fn mcp_servers(&self) -> ScopedMcpServers {
799        ScopedMcpServers::default()
800    }
801
802    /// Returns config-aware remote MCP server contributions.
803    fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
804        self.mcp_servers()
805    }
806
807    /// Returns a message filter provider if this capability modifies message retrieval.
808    ///
809    /// Capabilities can contribute filters that modify how messages are loaded
810    /// from the database. This enables features like:
811    /// - Time-based filtering (recent messages only)
812    /// - Event type filtering
813    /// - Tool result filtering by tool name
814    /// - Ephemeral message injection (summaries, reminders)
815    ///
816    /// Filters are applied in capability priority order (by `MessageFilterProvider::priority()`).
817    ///
818    /// By default, returns None (no message filtering).
819    fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
820        None
821    }
822
823    /// Returns a provider that can build a prompt-facing model view from
824    /// lossless stored messages before provider serialization.
825    ///
826    /// This is for capability-owned context transformations such as compaction
827    /// cost-control masking. Storage messages remain unchanged.
828    ///
829    /// By default, returns None (no model-view transformation).
830    fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
831        None
832    }
833
834    /// Returns an in-process hook invoked when a turn fails with a *terminal*
835    /// LLM error (one that will not be retried), before the user-facing error
836    /// message is emitted. The hook may perform a side effect (e.g. schedule a
837    /// continuation) and/or return extra fields to augment the user-facing error
838    /// copy. This is the platform seam for capability-owned error recovery — the
839    /// same in-process hook family as [`Self::tool_call_hooks`] and
840    /// [`Self::message_filter_provider`]; the reason atom invokes it generically
841    /// and knows nothing about any specific capability's behavior. See
842    /// [`crate::llm_error_hook`].
843    ///
844    /// By default, returns None (no error hook).
845    fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
846        None
847    }
848
849    /// Returns key/value [`Fact`]s this capability contributes to the model.
850    ///
851    /// Facts are routed by their [`Volatility`] so prompt caching is preserved:
852    /// [`Volatility::Static`] facts fold into the cached system-prompt prefix at
853    /// build time; [`Volatility::Dynamic`] facts are appended at the
854    /// conversation tail on every turn (outside the cached prefix). This is the
855    /// generic seam for "changing facts" such as the current time — see
856    /// [`crate::capabilities::facts`].
857    ///
858    /// Called both at prompt-assembly time (to fold static facts and detect
859    /// whether any dynamic facts exist) and per request (to render the live
860    /// tail block), so implementations must be cheap and side-effect free.
861    ///
862    /// By default, returns an empty vector (no facts).
863    fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
864        vec![]
865    }
866
867    /// Returns pre-tool execution hooks provided by this capability.
868    ///
869    /// These hooks run before each individual tool is executed — for *every*
870    /// tool the agent calls (built-in, MCP, or client-side), not just this
871    /// capability's own tools. A hook can mutate the tool call or block it
872    /// outright (returning [`crate::atoms::PreToolUseDecision::Block`]), which
873    /// makes this the seam for cross-cutting policy such as approval gating.
874    /// The first hook to block wins.
875    ///
876    /// By default, returns an empty vector (no hooks).
877    fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::atoms::PreToolUseHook>> {
878        vec![]
879    }
880
881    /// Returns pre-tool execution hooks adapted to per-capability config.
882    ///
883    /// Default delegates to `pre_tool_use_hooks()`. Capabilities whose hook
884    /// behavior depends on config (e.g. `guardrails`) override this.
885    fn pre_tool_use_hooks_with_config(
886        &self,
887        _config: &serde_json::Value,
888    ) -> Vec<Arc<dyn crate::atoms::PreToolUseHook>> {
889        self.pre_tool_use_hooks()
890    }
891
892    /// Returns post-tool execution hooks provided by this capability.
893    ///
894    /// These hooks run after each individual tool completes execution.
895    /// They can persist output, inject metadata, or transform results.
896    /// Capability-contributed hooks run before infrastructure (final) hooks.
897    ///
898    /// By default, returns an empty vector (no hooks).
899    fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::atoms::PostToolExecHook>> {
900        vec![]
901    }
902
903    /// Returns post-tool execution hooks adapted to per-capability config.
904    ///
905    /// Default delegates to `post_tool_exec_hooks()`. Capabilities whose hook
906    /// behavior depends on config (e.g. `guardrails`) override this.
907    fn post_tool_exec_hooks_with_config(
908        &self,
909        _config: &serde_json::Value,
910    ) -> Vec<Arc<dyn crate::atoms::PostToolExecHook>> {
911        self.post_tool_exec_hooks()
912    }
913
914    /// Returns tool definition hooks provided by this capability.
915    ///
916    /// These hooks run after the runtime agent has merged and deduplicated its
917    /// final tool list, before the tool schemas are sent to the LLM. They let
918    /// capabilities apply cross-cutting schema changes to all active tools,
919    /// including tools contributed by other capabilities, MCP, or clients.
920    ///
921    /// By default, returns an empty vector (no tool definition transforms).
922    fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
923        vec![]
924    }
925
926    /// Returns tool definition hooks adapted to per-capability config.
927    ///
928    /// Default delegates to `tool_definition_hooks()`. Capabilities whose
929    /// schema transforms depend on config override this method.
930    fn tool_definition_hooks_with_config(
931        &self,
932        _config: &serde_json::Value,
933    ) -> Vec<Arc<dyn ToolDefinitionHook>> {
934        self.tool_definition_hooks()
935    }
936
937    /// Returns tool definition hooks adapted to per-capability config and the
938    /// collection context (session id, model, ...).
939    ///
940    /// Default delegates to [`Self::tool_definition_hooks_with_config`], which
941    /// ignores the context. Capabilities whose hooks carry session-scoped state
942    /// override this to capture `ctx` — e.g. `tool_search` keys its
943    /// progressive-disclosure reveal set by `ctx.session_id`, since the
944    /// capability is a process-global singleton shared across sessions and a
945    /// `ToolDefinitionHook::transform` has no session context of its own.
946    fn tool_definition_hooks_with_context(
947        &self,
948        _ctx: &SystemPromptContext,
949        config: &serde_json::Value,
950    ) -> Vec<Arc<dyn ToolDefinitionHook>> {
951        self.tool_definition_hooks_with_config(config)
952    }
953
954    /// Returns tool call hooks provided by this capability.
955    ///
956    /// These hooks run after the model has produced a tool call. They can read
957    /// model-authored metadata for UI display and transform the tool call used
958    /// for actual execution.
959    ///
960    /// By default, returns an empty vector (no tool call handling).
961    fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
962        vec![]
963    }
964
965    /// Contribute human-readable narration for one of *this capability's* tool
966    /// calls (e.g. "Read AGENTS.md", "Searched tools: router").
967    ///
968    /// The **default** dispatches to the matching tool's
969    /// [`crate::tools::Tool::narrate`], so a capability narrates its tools for
970    /// free — narration lives on the tool that owns it. Override this only when
971    /// narration is config-driven or spans tools, or when the tools are dynamic
972    /// (e.g. proxied MCP tools that have no local `Tool` struct).
973    ///
974    /// Returns `None` for tool names this capability does not provide, so other
975    /// capabilities — or the generic fallback in [`crate::tool_narration`] —
976    /// can handle them. The framework consults this for every applied
977    /// capability (see `assemble`/`CapabilityNarrationHook`) on the act path.
978    fn narrate(
979        &self,
980        _tool_def: Option<&ToolDefinition>,
981        tool_call: &ToolCall,
982        phase: crate::tool_narration::ToolNarrationPhase,
983        locale: Option<&str>,
984        ctx: crate::tool_narration::ToolNarrationContext<'_>,
985    ) -> Option<String> {
986        self.tools()
987            .iter()
988            .find(|tool| tool.name() == tool_call.name)
989            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
990    }
991
992    /// Returns user-defined hook specifications contributed by this capability.
993    ///
994    /// User hooks are JSON-serializable specs (see
995    /// `crate::user_hook_types::UserHookSpec` and `specs/user-hooks.md`) that
996    /// the `HookAdapterBuilder` validates and turns into per-event
997    /// `Arc<dyn …Hook>` adapters during capability collection. Capabilities
998    /// that ship reusable hook bundles (formatters, security guards, audit
999    /// commands) override this; the user-facing `user_hooks` capability also
1000    /// uses this hook to surface user-config-authored entries.
1001    ///
1002    /// Contributors return *data only* — the executor is constructed
1003    /// centrally by the core so global timeout/output/sandbox limits cannot
1004    /// be bypassed.
1005    ///
1006    /// By default, returns an empty vector (no contributed hooks).
1007    fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
1008        vec![]
1009    }
1010
1011    /// Returns user-defined hook specifications adapted to per-capability
1012    /// config.
1013    ///
1014    /// Default delegates to `user_hooks()`. The `user_hooks` capability
1015    /// overrides this to parse hook entries out of its config.
1016    fn user_hooks_with_config(
1017        &self,
1018        _config: &serde_json::Value,
1019    ) -> Vec<crate::user_hook_types::UserHookSpec> {
1020        self.user_hooks()
1021    }
1022
1023    /// Returns the risk level of this capability.
1024    ///
1025    /// TM-AGENT-005: High-risk capabilities (code execution, network access)
1026    /// require admin approval when assigned to agents/harnesses. Capabilities
1027    /// that combine execution + network access enable data exfiltration.
1028    ///
1029    /// By default, returns `RiskLevel::Low`.
1030    fn risk_level(&self) -> RiskLevel {
1031        RiskLevel::Low
1032    }
1033
1034    /// Returns system commands this capability provides.
1035    ///
1036    /// System commands are user-invocable /slash commands that execute directly
1037    /// without involving the LLM. They are surfaced in the UI command palette
1038    /// alongside invocable skills.
1039    ///
1040    /// By default, returns an empty vector (no commands).
1041    fn commands(&self) -> Vec<CommandDescriptor> {
1042        vec![]
1043    }
1044
1045    /// Execute a system command declared by [`Self::commands`].
1046    ///
1047    /// Capabilities that declare commands MUST override this. The default
1048    /// implementation returns an error so that misconfigurations surface at
1049    /// invocation time rather than silently succeeding. Capabilities should
1050    /// match on `request.name`, validate `request.arguments`, and use the
1051    /// references they captured at construction time to mutate any external
1052    /// state (provider store, file system, etc.).
1053    ///
1054    /// Commands that need the session's assembled context or an out-of-band
1055    /// LLM call (e.g. `/btw`) use the host facilities on
1056    /// [`CommandExecutionContext::host`] — see
1057    /// [`crate::command_host::CommandHost`] and specs/commands.md.
1058    async fn execute_command(
1059        &self,
1060        request: &ExecuteCommandRequest,
1061        _ctx: &CommandExecutionContext,
1062    ) -> crate::error::Result<CommandResult> {
1063        Err(crate::error::AgentLoopError::config(format!(
1064            "capability {} declared command /{} but does not implement execute_command",
1065            self.id(),
1066            request.name,
1067        )))
1068    }
1069
1070    /// Returns agent blueprints contributed by this capability.
1071    ///
1072    /// Blueprints are pre-built agent definitions with private tools, baked-in prompts,
1073    /// and fixed/default models. They are spawned via `spawn_agent` with a subagent target
1074    /// and `blueprint`.
1075    /// Blueprint tools never appear in the host agent's tool list.
1076    ///
1077    /// By default, returns an empty vector (no blueprints).
1078    fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
1079        vec![]
1080    }
1081
1082    /// Returns skills contributed by this capability in code.
1083    ///
1084    /// Contributions are normalized during capability collection into read-only
1085    /// mount points at `/.agents/skills/{name}/` so the built-in `skills`
1086    /// capability discovers them alongside user-uploaded and registry-based
1087    /// skills. This keeps discovery, prompt listing, and activation in one
1088    /// place rather than adding a parallel skill pipeline.
1089    ///
1090    /// By default, returns an empty vector (no contributed skills).
1091    fn contribute_skills(&self) -> Vec<SkillContribution> {
1092        vec![]
1093    }
1094
1095    /// Returns streaming output guardrails contributed by this capability.
1096    ///
1097    /// Each provider is armed once per assistant message stream with the
1098    /// fully assembled system prompt and per-capability config; the returned
1099    /// per-stream `OutputGuardrailRun` is invoked after every batched delta
1100    /// in the streaming hot path. Returning `Block` aborts the stream and
1101    /// the client is told to replace the accumulated text with a canned
1102    /// message. See [`crate::output_guardrail`].
1103    ///
1104    /// Default: no guardrails.
1105    fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
1106        vec![]
1107    }
1108
1109    /// Async, end-of-message output guardrails (EVE-573).
1110    ///
1111    /// Unlike [`Self::output_guardrails`] (synchronous, per-delta, hot path),
1112    /// these providers run **once** on the fully assembled assistant message
1113    /// after streaming completes and before the message is finalized into
1114    /// context. They receive an LLM-capable context and may perform I/O (e.g.
1115    /// a moderation classifier). The per-agent capability config is passed so a
1116    /// capability contributes nothing unless it has an applicable check
1117    /// configured — keeping the common (no-output-check) case free of work.
1118    ///
1119    /// Default: no guardrails.
1120    fn post_output_guardrails_with_config(
1121        &self,
1122        _config: &serde_json::Value,
1123    ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
1124        vec![]
1125    }
1126
1127    /// Returns end-of-message citation annotation hooks contributed by this
1128    /// capability, adapted to per-agent config.
1129    ///
1130    /// Like [`Self::post_output_guardrails_with_config`], these run once on the
1131    /// fully assembled assistant message after streaming completes. But instead
1132    /// of a block/allow decision they attach citation [`crate::message::TextAnnotation`]s
1133    /// to the message text (optionally rewriting it first, e.g. to strip inline
1134    /// citation markers). This is the seam citation capabilities use to turn
1135    /// retrieved sources into claim-level provenance. See
1136    /// [`crate::annotation_hook`] and `specs/citations.md`.
1137    ///
1138    /// A capability contributes nothing unless a citation feed is configured,
1139    /// keeping the common (no-citations) case free of work.
1140    ///
1141    /// Default: no annotation hooks.
1142    fn post_output_annotation_hooks_with_config(
1143        &self,
1144        _config: &serde_json::Value,
1145    ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1146        vec![]
1147    }
1148
1149    /// Returns a citation verifier contributed by this capability, if any.
1150    ///
1151    /// Runs once after all citation feeds have attached annotations, over the
1152    /// collected set, stamping a [`crate::message::VerificationVerdict`] on each
1153    /// citation. Decoupled from the feeds so any feed can be paired with any
1154    /// verifier. The `citation_verification` capability implements this. See
1155    /// [`crate::annotation_hook::CitationVerifier`] and `specs/citations.md`.
1156    ///
1157    /// Default: no verifier.
1158    fn citation_verifier_with_config(
1159        &self,
1160        _config: &serde_json::Value,
1161    ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1162        None
1163    }
1164}
1165
1166pub trait ToolDefinitionHook: Send + Sync {
1167    fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1168
1169    /// Whether this hook should still run when the agent's model uses native
1170    /// (hosted) tool_search. Client-side deferral hooks return `false` so they
1171    /// don't strip schemas the hosted tool_search index needs (the two are
1172    /// mutually exclusive). Defaults to `true`.
1173    fn applies_with_native_tool_search(&self) -> bool {
1174        true
1175    }
1176}
1177
1178pub trait ToolCallHook: Send + Sync {
1179    fn narration(
1180        &self,
1181        _tool_def: Option<&ToolDefinition>,
1182        _tool_call: &ToolCall,
1183        _phase: crate::tool_narration::ToolNarrationPhase,
1184        _locale: Option<&str>,
1185        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1186    ) -> Option<String> {
1187        None
1188    }
1189
1190    fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1191        tool_call
1192    }
1193}
1194
1195/// Adapts a [`Capability`]'s [`Capability::narrate`] into a [`ToolCallHook`] so
1196/// capability-owned narration flows through the same hook channel the act atom
1197/// already consults. One is registered per applied capability during
1198/// `assemble`, after every explicit tool-call hook, so model-authored
1199/// narration (e.g. `human_intent`) still takes precedence.
1200pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1201
1202impl ToolCallHook for CapabilityNarrationHook {
1203    fn narration(
1204        &self,
1205        tool_def: Option<&ToolDefinition>,
1206        tool_call: &ToolCall,
1207        phase: crate::tool_narration::ToolNarrationPhase,
1208        locale: Option<&str>,
1209        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1210    ) -> Option<String> {
1211        self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1212    }
1213}
1214
1215/// Risk classification for capabilities (TM-AGENT-005).
1216///
1217/// Used to enforce approval requirements when assigning capabilities.
1218#[derive(
1219    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1220)]
1221#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1222#[cfg_attr(feature = "openapi", schema(example = "low"))]
1223#[serde(rename_all = "lowercase")]
1224pub enum RiskLevel {
1225    /// No special approval needed
1226    Low,
1227    /// Logged but allowed for org members
1228    Medium,
1229    /// Requires org admin role to assign
1230    High,
1231}
1232
1233// ============================================================================
1234// Agent Blueprints
1235// ============================================================================
1236
1237/// Model selection strategy for agent blueprints.
1238#[derive(Debug, Clone, Serialize, Deserialize)]
1239#[serde(rename_all = "snake_case")]
1240pub enum BlueprintModel {
1241    /// Always use this model. Host cannot override.
1242    Fixed(String),
1243    /// Use this model unless host provides override via config.
1244    Default(String),
1245    /// Use whatever model the host agent uses.
1246    Inherit,
1247}
1248
1249/// Pre-built agent definition with private tools, baked-in prompt, and model selection.
1250///
1251/// Contributed by capabilities via `agent_blueprints()`. Spawned via
1252/// `spawn_agent` with a subagent target and `blueprint`. Blueprint tools never appear in the
1253/// host agent's tool list — they exist only inside the spawned child session.
1254pub struct AgentBlueprint {
1255    /// Unique identifier (e.g. `"github_scout"`)
1256    pub id: &'static str,
1257    /// Human-readable display name
1258    pub name: &'static str,
1259    /// When to use this blueprint (LLM reads this for delegation decisions)
1260    pub description: &'static str,
1261    /// Model selection strategy
1262    pub model: BlueprintModel,
1263    /// Baked-in system prompt for the child agent
1264    pub system_prompt: &'static str,
1265    /// Private tools — only available inside the blueprint's session
1266    pub tools: Vec<Box<dyn Tool>>,
1267    /// Iteration limit (default: 20)
1268    pub max_turns: Option<usize>,
1269    /// JSON Schema for allowed host-provided config. `None` = no config accepted.
1270    pub config_schema: Option<serde_json::Value>,
1271}
1272
1273impl AgentBlueprint {
1274    /// Convert blueprint tools to tool definitions (for RuntimeAgent building).
1275    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1276        self.tools.iter().map(|t| t.to_definition()).collect()
1277    }
1278}
1279
1280impl std::fmt::Debug for AgentBlueprint {
1281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1282        f.debug_struct("AgentBlueprint")
1283            .field("id", &self.id)
1284            .field("name", &self.name)
1285            .field("model", &self.model)
1286            .field("tool_count", &self.tools.len())
1287            .field("max_turns", &self.max_turns)
1288            .finish()
1289    }
1290}
1291
1292// ============================================================================
1293// Capability Registry
1294// ============================================================================
1295
1296/// Registry that holds all available capability implementations.
1297///
1298/// The registry provides access to capabilities by ID and allows
1299/// applying multiple capabilities to build a RuntimeAgent.
1300///
1301/// # Example
1302///
1303/// ```
1304/// use everruns_core::capabilities::CapabilityRegistry;
1305///
1306/// let registry = CapabilityRegistry::with_builtins();
1307///
1308/// // Get a capability by ID
1309/// if let Some(cap) = registry.get("current_time") {
1310///     println!("Capability: {}", cap.name());
1311/// }
1312///
1313/// // List all available capabilities
1314/// for cap in registry.list() {
1315///     println!("{}: {}", cap.id(), cap.name());
1316/// }
1317/// ```
1318#[derive(Clone)]
1319pub struct CapabilityRegistry {
1320    capabilities: HashMap<String, Arc<dyn Capability>>,
1321    /// Alias ID -> canonical ID (see [`Capability::aliases`]).
1322    aliases: HashMap<String, String>,
1323}
1324
1325impl CapabilityRegistry {
1326    /// Create a new empty registry
1327    pub fn new() -> Self {
1328        Self {
1329            capabilities: HashMap::new(),
1330            aliases: HashMap::new(),
1331        }
1332    }
1333
1334    /// Create a registry with all built-in capabilities registered
1335    ///
1336    /// Uses `DeploymentGrade::from_env()` to determine which capabilities to include.
1337    /// For explicit control, use `with_builtins_for_grade()`.
1338    pub fn with_builtins() -> Self {
1339        Self::with_builtins_for_grade(DeploymentGrade::from_env())
1340    }
1341
1342    /// Create a registry with capabilities that are usable in the public
1343    /// in-process runtime with its default host services.
1344    ///
1345    /// This intentionally excludes hosted Everruns product capabilities,
1346    /// demos/tests, and capabilities whose tools require optional host backends
1347    /// such as `platform_store`, `session_task_registry`, `schedule_store`, SQL
1348    /// databases, provider credentials, or knowledge stores. Embedders can
1349    /// still opt into those capabilities by supplying an explicit
1350    /// [`PlatformDefinition`](crate::PlatformDefinition) with the required
1351    /// backends.
1352    pub fn runtime_builtins() -> Self {
1353        let mut registry = Self::new();
1354
1355        registry.register(AgentInstructionsCapability);
1356        registry.register(HumanIntentCapability);
1357        registry.register(NoopCapability);
1358        registry.register(CurrentTimeCapability);
1359        registry.register(MessageMetadataCapability);
1360        registry.register(FileSystemCapability);
1361        registry.register(SessionStorageCapability);
1362        registry.register(SessionCapability);
1363        registry.register(StatelessTodoListCapability);
1364        #[cfg(feature = "web-fetch")]
1365        registry.register(WebFetchCapability::from_env());
1366        registry.register(BashkitShellCapability);
1367        registry.register(BtwCapability);
1368        registry.register(InfinityContextCapability);
1369        registry.register(budgeting::BudgetingCapability);
1370        registry.register(SelfBudgetCapability);
1371        registry.register(CompactionCapability);
1372        registry.register(ErrorDisclosureCapability);
1373        registry.register(OpenAiToolSearchCapability::new());
1374        registry.register(ClaudeToolSearchCapability::new());
1375        registry.register(ToolSearchCapability::new());
1376        registry.register(AutoToolSearchCapability::new());
1377        registry.register(PromptCachingCapability::new());
1378        registry.register(ParallelToolCallsCapability);
1379        registry.register(SkillsCapability);
1380        registry.register(SystemCommandsCapability);
1381        registry.register(tool_output_persistence::ToolOutputPersistenceCapability);
1382        registry.register(tool_output_distillation::ToolOutputDistillationCapability);
1383        registry.register(LoopDetectionCapability);
1384        registry.register(ToolCallRepairCapability);
1385        registry.register(PromptCanaryGuardrailCapability);
1386        registry.register(GuardrailsCapability);
1387        registry.register(user_hooks::UserHooksCapability);
1388
1389        let internal_flags = crate::InternalFeatureFlags::from_env();
1390        if internal_flags.lua {
1391            registry.register(LuaCapability);
1392            registry.register(LuaCodeModeCapability);
1393        }
1394
1395        registry
1396    }
1397
1398    /// Create a registry with built-in capabilities for a specific deployment grade
1399    ///
1400    /// Experimental capabilities are included via integration plugins in dev environments.
1401    /// Non-experimental integration plugins (like Daytona) are included in all environments.
1402    pub fn with_builtins_for_grade(grade: DeploymentGrade) -> Self {
1403        let mut registry = Self::new();
1404
1405        // Core capabilities (all environments)
1406        registry.register(AgentInstructionsCapability);
1407        registry.register(HumanIntentCapability);
1408        registry.register(NoopCapability);
1409        registry.register(CurrentTimeCapability);
1410        registry.register(MessageMetadataCapability);
1411        registry.register(ResearchCapability);
1412        registry.register(ModelScoutCapability);
1413        registry.register(OpenRouterWorkspaceCapability);
1414        registry.register(OpenRouterServerToolsCapability);
1415        registry.register(PlatformManagementCapability);
1416        registry.register(FileSystemCapability);
1417        registry.register(MemoryCapability);
1418        registry.register(SessionStorageCapability);
1419        registry.register(SessionCapability);
1420        registry.register(SessionSqlDatabaseCapability);
1421        registry.register(TestMathCapability);
1422        registry.register(TestWeatherCapability);
1423        registry.register(StatelessTodoListCapability);
1424        #[cfg(feature = "web-fetch")]
1425        registry.register(WebFetchCapability::from_env());
1426        registry.register(BashkitShellCapability);
1427        registry.register(BackgroundExecutionCapability);
1428        registry.register(SessionScheduleCapability);
1429        registry.register(BtwCapability);
1430        registry.register(InfinityContextCapability);
1431        registry.register(budgeting::BudgetingCapability);
1432        registry.register(SelfBudgetCapability);
1433        registry.register(CompactionCapability);
1434        registry.register(ErrorDisclosureCapability);
1435
1436        // OpenAI tool_search (deferred tool loading, all environments)
1437        registry.register(OpenAiToolSearchCapability::new());
1438        // Claude (Anthropic) tool_search (hosted deferred tool loading)
1439        registry.register(ClaudeToolSearchCapability::new());
1440        // Generic, provider-agnostic tool_search (client-side deferred loading)
1441        registry.register(ToolSearchCapability::new());
1442        // Model-adaptive tool_search (hosted on capable models, generic elsewhere)
1443        registry.register(AutoToolSearchCapability::new());
1444        registry.register(PromptCachingCapability::new());
1445
1446        // Request-level parallel tool calls preference (none/prefer/avoid).
1447        registry.register(ParallelToolCallsCapability);
1448
1449        // Skills (filesystem-based discovery + activation, all environments)
1450        registry.register(SkillsCapability);
1451
1452        // Subagents (spawn child agent sessions, all environments)
1453        registry.register(SubagentCapability);
1454
1455        // Session tasks (inspect/steer background work, all environments)
1456        registry.register(SessionTasksCapability);
1457
1458        // Outbound agent delegation — experimental (dev-only by default).
1459        // Risk: exfil, SSRF-adjacent reach, cost/recursion fan-out.
1460        // Gated by FEATURE_AGENT_DELEGATION; auto-enabled in dev, off in prod.
1461        if crate::FeatureFlags::from_env(&grade).agent_delegation {
1462            registry.register(AgentHandoffCapability);
1463            // Additionally compile-gated behind the `a2a` cargo feature: in
1464            // provider builds that disable core defaults the delegation
1465            // capability is absent even when FEATURE_AGENT_DELEGATION is set.
1466            #[cfg(feature = "a2a")]
1467            registry.register(A2aAgentDelegationCapability);
1468        }
1469
1470        // System commands (/clear, /status, /compact, /model)
1471        registry.register(SystemCommandsCapability);
1472
1473        // Tool output persistence (EVE-222: persist exec output to VFS)
1474        registry.register(tool_output_persistence::ToolOutputPersistenceCapability);
1475        registry.register(tool_output_distillation::ToolOutputDistillationCapability);
1476
1477        // User hooks (see specs/user-hooks.md): user-authored shell commands
1478        // at lifecycle/tool events. Risk: High.
1479        registry.register(user_hooks::UserHooksCapability);
1480
1481        // Loop detection (EVE-227: detect repeated identical tool calls)
1482        registry.register(LoopDetectionCapability);
1483
1484        // Auto-continue after an LLM usage limit resets: resumes interrupted
1485        // work once the provider limit clears. Behavior-only (no tools).
1486        // Grade-only (not in `runtime_builtins`): its error hook needs the
1487        // `schedule_store` host service to create the continuation and a schedule
1488        // poller to fire it — neither is in the default in-process runtime — so it
1489        // sits with `session_schedule` rather than the runtime-safe preset.
1490        registry.register(UsageLimitAutoContinueCapability);
1491
1492        // Tool-call repair (EVE-600): opt-in salvage of malformed tool-call
1493        // arguments. Disabled by default — registered so agents can enable it,
1494        // but contributes nothing unless explicitly selected.
1495        registry.register(ToolCallRepairCapability);
1496
1497        // Prompt canary guardrail: replace assistant output if it leaks the
1498        // first sentence of the system prompt. Streaming-output guardrail.
1499        registry.register(PromptCanaryGuardrailCapability);
1500
1501        // Declarative guardrails (specs/guardrails.md): config-driven
1502        // deterministic checks over model output and tool calls.
1503        registry.register(GuardrailsCapability);
1504
1505        // OpenUI/A2UI prompt helpers are product features, not required by embedders.
1506        #[cfg(feature = "ui-capabilities")]
1507        {
1508            registry.register(OpenUiCapability);
1509            registry.register(A2UiCapability);
1510        }
1511
1512        // Demo capability with mount points (all environments)
1513        registry.register(SampleDataCapability);
1514
1515        // Data knowledge scaffold (all environments)
1516        registry.register(DataKnowledgeCapability);
1517
1518        // Knowledge bases (curated org knowledge — see specs/knowledge-bases.md)
1519        registry.register(KnowledgeBaseCapability);
1520
1521        // Knowledge indexes (source-backed embedded collections — see specs/knowledge-indexes.md)
1522        registry.register(KnowledgeIndexCapability);
1523
1524        // Retrieval citations (claim-level provenance from search results — see specs/citations.md)
1525        registry.register(CitationRetrievalCapability);
1526
1527        // Citation verification (stamps faithfulness verdicts — see specs/citations.md)
1528        registry.register(CitationVerificationCapability);
1529
1530        // Fake demo capabilities (all environments)
1531        registry.register(FakeWarehouseCapability);
1532        registry.register(FakeAwsCapability);
1533        registry.register(FakeCrmCapability);
1534        registry.register(FakeFinancialCapability);
1535
1536        // External integration plugins (registered via inventory::submit! in integration crates)
1537        let internal_flags = crate::InternalFeatureFlags::from_env();
1538        if internal_flags.session_sandbox {
1539            registry.register(SessionSandboxCapability);
1540        }
1541
1542        // Experimental sandboxed Lua execution (specs/lua-execution.md). High
1543        // risk, admin-gated. Gated by FEATURE_LUA; scripts only actually run
1544        // when the `lua` cargo feature is also compiled in.
1545        if internal_flags.lua {
1546            registry.register(LuaCapability);
1547            // Routes non-essential tool calls through the Lua sandbox by hiding
1548            // them from the model's direct tool list. Depends on `lua`.
1549            registry.register(LuaCodeModeCapability);
1550        }
1551        for plugin in inventory::iter::<IntegrationPlugin>() {
1552            if (!plugin.experimental_only || grade.experimental_features_enabled())
1553                && plugin
1554                    .feature_flag
1555                    .is_none_or(|f| internal_flags.is_enabled(f))
1556            {
1557                registry.register_boxed((plugin.factory)());
1558            }
1559        }
1560
1561        registry
1562    }
1563
1564    /// Register a capability
1565    pub fn register(&mut self, capability: impl Capability + 'static) {
1566        self.register_arc(Arc::new(capability));
1567    }
1568
1569    /// Register a boxed capability
1570    pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1571        self.register_arc(Arc::from(capability));
1572    }
1573
1574    /// Register an Arc-wrapped capability
1575    pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1576        let canonical = capability.id().to_string();
1577        for alias in capability.aliases() {
1578            self.aliases.insert(alias.to_string(), canonical.clone());
1579        }
1580        self.capabilities.insert(canonical, capability);
1581    }
1582
1583    /// Get a capability by ID or alias
1584    pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1585        self.capabilities
1586            .get(id)
1587            .or_else(|| self.aliases.get(id).and_then(|c| self.capabilities.get(c)))
1588    }
1589
1590    /// Resolve an ID or alias to the canonical capability ID.
1591    ///
1592    /// Returns `None` for IDs that are neither registered nor an alias of a
1593    /// registered capability (e.g. declarative or MCP refs).
1594    pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1595        if self.capabilities.contains_key(id) {
1596            Some(id)
1597        } else {
1598            self.aliases
1599                .get(id)
1600                .filter(|c| self.capabilities.contains_key(*c))
1601                .map(String::as_str)
1602        }
1603    }
1604
1605    /// Remove a capability from the registry by ID or alias.
1606    pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1607        let canonical = self.canonical_id(id)?.to_string();
1608        let removed = self.capabilities.remove(&canonical);
1609        self.aliases.retain(|_, target| *target != canonical);
1610        removed
1611    }
1612
1613    /// Check if a capability is registered (by ID or alias)
1614    pub fn has(&self, id: &str) -> bool {
1615        self.get(id).is_some()
1616    }
1617
1618    /// Get all registered capabilities
1619    pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1620        self.capabilities.values().collect()
1621    }
1622
1623    /// Get the number of registered capabilities
1624    pub fn len(&self) -> usize {
1625        self.capabilities.len()
1626    }
1627
1628    /// Check if the registry is empty
1629    pub fn is_empty(&self) -> bool {
1630        self.capabilities.is_empty()
1631    }
1632
1633    /// Create a builder for fluent capability registration
1634    pub fn builder() -> CapabilityRegistryBuilder {
1635        CapabilityRegistryBuilder::new()
1636    }
1637
1638    /// Find a blueprint by ID across all registered capabilities.
1639    ///
1640    /// Returns a fresh `AgentBlueprint` (with new tool instances) each time.
1641    pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1642        for cap in self.capabilities.values() {
1643            for bp in cap.agent_blueprints() {
1644                if bp.id == id {
1645                    return Some(bp);
1646                }
1647            }
1648        }
1649        None
1650    }
1651
1652    /// Find a blueprint and the capability that registered it.
1653    ///
1654    /// Returns `(capability_id, blueprint)` with fresh tool instances.
1655    pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1656        for (capability_id, cap) in &self.capabilities {
1657            for bp in cap.agent_blueprints() {
1658                if bp.id == id {
1659                    return Some((capability_id.clone(), bp));
1660                }
1661            }
1662        }
1663        None
1664    }
1665
1666    /// Collect all blueprints from all registered capabilities.
1667    pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1668        self.capabilities
1669            .values()
1670            .flat_map(|cap| cap.agent_blueprints())
1671            .collect()
1672    }
1673}
1674
1675impl Default for CapabilityRegistry {
1676    fn default() -> Self {
1677        Self::with_builtins()
1678    }
1679}
1680
1681impl std::fmt::Debug for CapabilityRegistry {
1682    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1683        let ids: Vec<_> = self.capabilities.keys().collect();
1684        f.debug_struct("CapabilityRegistry")
1685            .field("capabilities", &ids)
1686            .finish()
1687    }
1688}
1689
1690/// Builder for creating a CapabilityRegistry with a fluent API
1691pub struct CapabilityRegistryBuilder {
1692    registry: CapabilityRegistry,
1693}
1694
1695impl CapabilityRegistryBuilder {
1696    /// Create a new builder with an empty registry
1697    pub fn new() -> Self {
1698        Self {
1699            registry: CapabilityRegistry::new(),
1700        }
1701    }
1702
1703    /// Create a new builder with built-in capabilities
1704    pub fn with_builtins() -> Self {
1705        Self {
1706            registry: CapabilityRegistry::with_builtins(),
1707        }
1708    }
1709
1710    /// Add a capability
1711    pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1712        self.registry.register(capability);
1713        self
1714    }
1715
1716    /// Build the registry
1717    pub fn build(self) -> CapabilityRegistry {
1718        self.registry
1719    }
1720}
1721
1722impl Default for CapabilityRegistryBuilder {
1723    fn default() -> Self {
1724        Self::new()
1725    }
1726}
1727
1728// ============================================================================
1729// Collect Capabilities Helper
1730// ============================================================================
1731
1732/// Context available to capability-owned model-view transforms.
1733pub struct ModelViewContext<'a> {
1734    pub session_id: SessionId,
1735    pub prior_usage: Option<&'a TokenUsage>,
1736}
1737
1738/// Provider-side hook for building prompt-facing model views.
1739///
1740/// Providers receive the output of earlier providers and return the messages
1741/// that should be sent into provider serialization. Lower priority providers
1742/// run earlier.
1743pub trait ModelViewProvider: Send + Sync {
1744    fn apply_model_view(
1745        &self,
1746        messages: Vec<Message>,
1747        config: &serde_json::Value,
1748        context: &ModelViewContext<'_>,
1749    ) -> Vec<Message>;
1750
1751    fn priority(&self) -> i32 {
1752        0
1753    }
1754}
1755
1756/// Collected data from capabilities before applying to config.
1757///
1758/// This intermediate struct allows sharing the capability collection logic
1759/// between `apply_capabilities` and `apply_capabilities_to_builder`.
1760pub struct CollectedCapabilities {
1761    /// System prompt additions (in order)
1762    pub system_prompt_parts: Vec<String>,
1763    /// Source attribution for each system prompt addition.
1764    pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1765    /// Tool implementations for the registry
1766    pub tools: Vec<Box<dyn Tool>>,
1767    /// Tool definitions for config
1768    pub tool_definitions: Vec<ToolDefinition>,
1769    /// Mount points from capabilities
1770    pub mounts: Vec<MountPoint>,
1771    /// Message filter providers with their configs (in priority order)
1772    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1773    /// IDs of capabilities that were collected
1774    pub applied_ids: Vec<String>,
1775    /// Tool search configuration (set when openai_tool_search capability is present)
1776    pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1777    /// Prompt caching configuration (set when prompt_caching capability is present)
1778    pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1779    /// OpenRouter routing controls (set when the `openrouter_server_tools`
1780    /// capability is present). Carries provider-executed server tools.
1781    pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
1782    /// Request-level parallel tool calls preference (set when the
1783    /// `parallel_tool_calls` capability is present with mode `prefer`/`avoid`).
1784    /// `None` when absent or mode `none`.
1785    pub parallel_tool_calls: Option<bool>,
1786    /// Hooks that transform the final runtime tool definition list.
1787    pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1788    /// Hooks that inspect or transform model-produced tool calls.
1789    pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1790    /// Scoped remote MCP servers contributed by capabilities.
1791    pub mcp_servers: ScopedMcpServers,
1792    // NOTE: output guardrails are intentionally NOT collected here. They are
1793    // re-derived per turn in `ReasonAtom` directly from the resolved capability
1794    // configs + registry, because they need the assembled system prompt at
1795    // arming time (which only exists once the runtime agent is built). Storing
1796    // them here would duplicate that work for callers that don't run a stream.
1797}
1798
1799#[derive(Debug, Clone, PartialEq, Eq)]
1800pub struct SystemPromptAttribution {
1801    pub capability_id: String,
1802    pub content: String,
1803}
1804
1805impl CollectedCapabilities {
1806    /// Returns the combined system prompt prefix from all capabilities.
1807    /// Returns None if no capabilities contributed system prompt additions.
1808    pub fn system_prompt_prefix(&self) -> Option<String> {
1809        if self.system_prompt_parts.is_empty() {
1810            None
1811        } else {
1812            Some(self.system_prompt_parts.join("\n\n"))
1813        }
1814    }
1815
1816    /// Apply all collected message filter providers to a query.
1817    ///
1818    /// Providers are applied in priority order (lower priority first).
1819    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1820        // Providers are already sorted by priority during collection
1821        for (provider, config) in &self.message_filter_providers {
1822            provider.apply_filters(query, config);
1823        }
1824    }
1825
1826    /// Apply post-load transforms from all message filter providers.
1827    /// Called after messages are loaded, filtered, and injected.
1828    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1829        for (provider, config) in &self.message_filter_providers {
1830            provider.post_load(messages, config);
1831        }
1832    }
1833
1834    /// Check if any capabilities contribute message filters.
1835    pub fn has_message_filters(&self) -> bool {
1836        !self.message_filter_providers.is_empty()
1837    }
1838}
1839
1840struct SpawnAgentTargetProvider {
1841    target_type: &'static str,
1842    tool: Box<dyn Tool>,
1843}
1844
1845/// Shared execution mode accepted natively by every `spawn_agent` provider.
1846#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1847#[serde(rename_all = "snake_case")]
1848pub(crate) enum SpawnMode {
1849    Background,
1850    Foreground,
1851}
1852
1853impl SpawnMode {
1854    pub(crate) fn parse(value: &str) -> Option<Self> {
1855        match value {
1856            "background" => Some(Self::Background),
1857            "foreground" => Some(Self::Foreground),
1858            _ => None,
1859        }
1860    }
1861
1862    pub(crate) fn as_str(self) -> &'static str {
1863        match self {
1864            Self::Background => "background",
1865            Self::Foreground => "foreground",
1866        }
1867    }
1868}
1869
1870struct UnifiedSpawnAgentTool {
1871    providers: Vec<SpawnAgentTargetProvider>,
1872}
1873
1874impl UnifiedSpawnAgentTool {
1875    fn new(providers: Vec<SpawnAgentTargetProvider>) -> Self {
1876        Self { providers }
1877    }
1878
1879    fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1880        self.providers
1881            .iter()
1882            .find(|provider| provider.target_type == target_type)
1883            .map(|provider| provider.tool.as_ref())
1884    }
1885
1886    fn target_types(&self) -> Vec<&'static str> {
1887        ["subagent", "agent", "external_a2a"]
1888            .into_iter()
1889            .filter(|target_type| {
1890                self.providers
1891                    .iter()
1892                    .any(|provider| provider.target_type == *target_type)
1893            })
1894            .collect()
1895    }
1896
1897    /// Per-`target.type` constraint branches, nested inside the `target`
1898    /// property. Anthropic rejects `oneOf`/`allOf`/`anyOf` at the top level
1899    /// of a tool `input_schema`, so provider-specific requirements must live
1900    /// below the root (nested composition is accepted).
1901    fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1902        self.target_types()
1903            .into_iter()
1904            .filter_map(|target_type| match target_type {
1905                "subagent" => Some(serde_json::json!({
1906                    "properties": {
1907                        "type": {"const": "subagent"}
1908                    }
1909                })),
1910                "agent" => Some(serde_json::json!({
1911                    "properties": {
1912                        "type": {"const": "agent"}
1913                    },
1914                    "required": ["type", "id"]
1915                })),
1916                "external_a2a" => Some(serde_json::json!({
1917                    "properties": {
1918                        "type": {"const": "external_a2a"}
1919                    },
1920                    "anyOf": [
1921                        {"required": ["id"]},
1922                        {"required": ["external_agent_id"]}
1923                    ]
1924                })),
1925                _ => None,
1926            })
1927            .collect()
1928    }
1929
1930    // NOTE: subagent and agent providers require `name` at execution
1931    // (`require_str`), while external_a2a ignores it. A schema that required
1932    // `name` only for the local targets would need a top-level
1933    // `oneOf`/`if`/`allOf`, which Anthropic rejects in a tool `input_schema`.
1934    // `name` is therefore required at the root unconditionally: requiring a
1935    // field external_a2a merely ignores is safe (the schema never permits a
1936    // call execution would reject), whereas omitting it would let a
1937    // `name`-less subagent call pass validation and then fail at dispatch —
1938    // exactly the mismatch #2787 set out to close.
1939}
1940
1941#[async_trait]
1942impl Tool for UnifiedSpawnAgentTool {
1943    fn narrate(
1944        &self,
1945        tool_call: &ToolCall,
1946        phase: crate::tool_narration::ToolNarrationPhase,
1947        locale: Option<&str>,
1948        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1949    ) -> Option<String> {
1950        let target_type = tool_call
1951            .arguments
1952            .get("target")
1953            .and_then(|target| target.get("type"))
1954            .and_then(serde_json::Value::as_str)?;
1955        self.provider_for(target_type)
1956            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
1957    }
1958
1959    fn name(&self) -> &str {
1960        "spawn_agent"
1961    }
1962
1963    fn display_name(&self) -> Option<&str> {
1964        Some("Spawn Agent")
1965    }
1966
1967    fn description(&self) -> &str {
1968        "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."
1969    }
1970
1971    fn parameters_schema(&self) -> serde_json::Value {
1972        serde_json::json!({
1973            "type": "object",
1974            "properties": {
1975                "name": {
1976                    "type": "string",
1977                    "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1978                },
1979                "instructions": {
1980                    "type": "string",
1981                    "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1982                },
1983                "goal": {
1984                    "type": "string",
1985                    "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1986                },
1987                "lifetime": {
1988                    "type": "string",
1989                    "enum": ["linked", "detached"],
1990                    "default": "linked",
1991                    "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1992                },
1993                "seed": {
1994                    "type": "string",
1995                    "enum": ["fresh", "fork", "workspace"],
1996                    "default": "fresh",
1997                    "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1998                },
1999                "target": {
2000                    "type": "object",
2001                    "properties": {
2002                        "type": {
2003                            "type": "string",
2004                            "enum": self.target_types(),
2005                            "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."
2006                        },
2007                        "id": {
2008                            "type": "string",
2009                            "description": "Configured target id for first-party handoffs or external A2A agents."
2010                        },
2011                        "external_agent_id": {
2012                            "type": "string",
2013                            "description": "Configured external A2A agent id."
2014                        }
2015                    },
2016                    "required": ["type"],
2017                    "oneOf": self.target_constraint_branches(),
2018                    "additionalProperties": false
2019                },
2020                "mode": {
2021                    "type": "string",
2022                    "enum": ["background", "foreground"],
2023                    "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."
2024                },
2025                "blueprint": {
2026                    "type": "string",
2027                    "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
2028                },
2029                "config": {
2030                    "type": "object",
2031                    "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
2032                },
2033                "result_schema": {
2034                    "type": "object",
2035                    "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."
2036                },
2037                "message_schema": {
2038                    "type": "object",
2039                    "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."
2040                },
2041                "public_context": {
2042                    "type": "object",
2043                    "description": "Agent-handoff-only non-secret structured context to include with the instructions."
2044                },
2045                "wait_timeout_secs": {
2046                    "type": "integer",
2047                    "minimum": 1,
2048                    "maximum": 86400,
2049                    "description": "External-A2A-only foreground timeout."
2050                },
2051                "wake_on_completion": {
2052                    "type": "boolean",
2053                    "description": "External-A2A-only control for background completion wake-ups."
2054                }
2055            },
2056            "required": ["name", "instructions", "target"],
2057            "additionalProperties": false
2058        })
2059    }
2060
2061    fn hints(&self) -> crate::tool_types::ToolHints {
2062        let mut hints = crate::tool_types::ToolHints::default()
2063            .with_long_running(true)
2064            .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
2065        if self.provider_for("external_a2a").is_some() {
2066            hints = hints.with_open_world(true);
2067        }
2068        hints
2069    }
2070
2071    async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2072        ToolExecutionResult::tool_error(
2073            "spawn_agent requires context. This tool must be executed with session context.",
2074        )
2075    }
2076
2077    async fn execute_with_context(
2078        &self,
2079        arguments: serde_json::Value,
2080        context: &ToolContext,
2081    ) -> ToolExecutionResult {
2082        let target_type = match arguments
2083            .get("target")
2084            .and_then(|target| target.get("type"))
2085            .and_then(serde_json::Value::as_str)
2086        {
2087            Some(target_type) => target_type,
2088            None => {
2089                return ToolExecutionResult::tool_error("Missing required parameter: target.type");
2090            }
2091        };
2092
2093        let Some(provider) = self.provider_for(target_type) else {
2094            let supported = self.target_types().join(", ");
2095            return ToolExecutionResult::tool_error(format!(
2096                "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
2097            ));
2098        };
2099        if target_type == "external_a2a"
2100            && arguments
2101                .get("lifetime")
2102                .and_then(serde_json::Value::as_str)
2103                .is_some_and(|value| value == "detached")
2104        {
2105            return ToolExecutionResult::tool_error(
2106                "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
2107            );
2108        }
2109        if target_type == "external_a2a"
2110            && arguments
2111                .get("message_schema")
2112                .is_some_and(|schema| !schema.is_null())
2113        {
2114            return ToolExecutionResult::tool_error(
2115                "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
2116            );
2117        }
2118
2119        provider.execute_with_context(arguments, context).await
2120    }
2121
2122    fn requires_context(&self) -> bool {
2123        true
2124    }
2125}
2126
2127/// Compose the model-visible system prompt from the stable base prompt and
2128/// collected capability contributions. Keep the base prompt first so changes in
2129/// dynamic capabilities (for example AGENTS.md reads or environment context)
2130/// do not invalidate provider prefix caches for the agent's core instructions.
2131pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
2132    let Some(additions) = additions.filter(|value| !value.is_empty()) else {
2133        return base_system_prompt.to_string();
2134    };
2135
2136    if base_system_prompt.is_empty() {
2137        return additions.to_string();
2138    }
2139
2140    if base_system_prompt.contains("<system-prompt>") {
2141        format!("{base_system_prompt}\n\n{additions}")
2142    } else {
2143        format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
2144    }
2145}
2146
2147/// Lightweight result containing only message filter providers.
2148///
2149/// Used when callers only need message filtering (e.g., message loading in
2150/// ReasonAtom) without paying the cost of system prompt contribution or tool
2151/// collection. This avoids unnecessary filesystem reads (AGENTS.md) and tool
2152/// instantiation on the message-filter-only path.
2153pub struct CollectedMessageFilters {
2154    /// Message filter providers with their configs (in priority order)
2155    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
2156}
2157
2158/// Lightweight result containing only model-view providers.
2159pub struct CollectedModelViewProviders {
2160    /// Model-view providers with their configs (in priority order).
2161    pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
2162}
2163
2164// Note: apply_message_filters/apply_post_load_filters mirror the same methods
2165// on CollectedCapabilities. The duplication is intentional — extracting a trait
2166// would add indirection for 3 lines of loop body, and the two structs serve
2167// different purposes (lightweight vs full collection).
2168
2169impl CollectedMessageFilters {
2170    /// Apply all collected message filter providers to a query.
2171    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
2172        for (provider, config) in &self.message_filter_providers {
2173            provider.apply_filters(query, config);
2174        }
2175    }
2176
2177    /// Apply post-load transforms from all message filter providers.
2178    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
2179        for (provider, config) in &self.message_filter_providers {
2180            provider.post_load(messages, config);
2181        }
2182    }
2183}
2184
2185impl CollectedModelViewProviders {
2186    /// Apply all collected model-view providers in priority order.
2187    pub fn apply_model_view(
2188        &self,
2189        mut messages: Vec<Message>,
2190        context: &ModelViewContext<'_>,
2191    ) -> Vec<Message> {
2192        for (provider, config) in &self.model_view_providers {
2193            messages = provider.apply_model_view(messages, config, context);
2194        }
2195        messages
2196    }
2197}
2198
2199/// True when the `compaction` capability is present and available in this set.
2200///
2201/// Infinity context defers token-budget eviction to compaction when both are
2202/// enabled (see specs/infinity-context.md) so that compaction's summary — not a
2203/// bare "hidden" notice — covers trimmed history.
2204fn compaction_is_enabled(
2205    capability_configs: &[AgentCapabilityConfig],
2206    registry: &CapabilityRegistry,
2207) -> bool {
2208    capability_configs.iter().any(|cap_config| {
2209        cap_config.capability_ref.as_str() == COMPACTION_CAPABILITY_ID
2210            && registry
2211                .get(cap_config.capability_ref.as_str())
2212                .is_some_and(|cap| cap.status() == CapabilityStatus::Available)
2213    })
2214}
2215
2216/// Per-agent message-filter config for a capability, injecting the derived
2217/// `compaction_active` signal into infinity context when compaction is enabled.
2218///
2219/// This is the one place capability composition is encoded: infinity context and
2220/// compaction are otherwise independent, but if infinity context evicts history
2221/// before compaction can summarize it, compaction only ever sees the recent
2222/// window. The flag tells infinity context to anchor + provide `query_history`
2223/// and let compaction own reduction.
2224fn message_filter_config_for(
2225    cap_id: &str,
2226    base: &serde_json::Value,
2227    compaction_on: bool,
2228) -> serde_json::Value {
2229    if cap_id != INFINITY_CONTEXT_CAPABILITY_ID || !compaction_on {
2230        return base.clone();
2231    }
2232    let mut config = base.clone();
2233    match config.as_object_mut() {
2234        Some(map) => {
2235            map.insert(
2236                "compaction_active".to_string(),
2237                serde_json::Value::Bool(true),
2238            );
2239        }
2240        None => {
2241            config = serde_json::json!({ "compaction_active": true });
2242        }
2243    }
2244    config
2245}
2246
2247/// Collect only message filter providers from capabilities, skipping system
2248/// prompt contributions, tools, mounts, and other expensive work.
2249///
2250/// This is a fast path for callers that only need message filtering (e.g.,
2251/// the message-loading step in ReasonAtom before RuntimeAgent is built).
2252pub fn collect_message_filters_only(
2253    capability_configs: &[AgentCapabilityConfig],
2254    registry: &CapabilityRegistry,
2255) -> CollectedMessageFilters {
2256    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2257        Vec::new();
2258    let compaction_on = compaction_is_enabled(capability_configs, registry);
2259
2260    for cap_config in capability_configs {
2261        let cap_id = cap_config.capability_ref.as_str();
2262        if let Some(capability) = registry.get(cap_id) {
2263            if capability.status() != CapabilityStatus::Available {
2264                continue;
2265            }
2266            // Resolve against None: no model is known at message-filter collection
2267            // time, so fall back to the model-agnostic variant if present.
2268            let effective: &dyn Capability = capability
2269                .resolve_for_model(None)
2270                .unwrap_or_else(|| capability.as_ref());
2271            if let Some(provider) = effective.message_filter_provider() {
2272                let config = message_filter_config_for(cap_id, &cap_config.config, compaction_on);
2273                message_filter_providers.push((provider, config));
2274            }
2275        }
2276    }
2277
2278    message_filter_providers.sort_by_key(|(p, _)| p.priority());
2279
2280    CollectedMessageFilters {
2281        message_filter_providers,
2282    }
2283}
2284
2285/// Collect only model-view providers from capabilities.
2286///
2287/// `model` should be the LLM model name when it is known at call time (e.g. the
2288/// ReasonAtom already holds `model_with_provider`). Pass `None` only when the
2289/// model is genuinely unavailable so capabilities fall back to the model-agnostic
2290/// variant.
2291pub fn collect_model_view_providers(
2292    capability_configs: &[AgentCapabilityConfig],
2293    registry: &CapabilityRegistry,
2294    model: Option<&str>,
2295) -> CollectedModelViewProviders {
2296    let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
2297
2298    for cap_config in capability_configs {
2299        let cap_id = cap_config.capability_ref.as_str();
2300        if let Some(capability) = registry.get(cap_id) {
2301            if capability.status() != CapabilityStatus::Available {
2302                continue;
2303            }
2304            let effective: &dyn Capability = capability
2305                .resolve_for_model(model)
2306                .unwrap_or_else(|| capability.as_ref());
2307            if let Some(provider) = effective.model_view_provider() {
2308                model_view_providers.push((provider, cap_config.config.clone()));
2309            }
2310        }
2311    }
2312
2313    model_view_providers.sort_by_key(|(p, _)| p.priority());
2314
2315    CollectedModelViewProviders {
2316        model_view_providers,
2317    }
2318}
2319
2320/// Collect [`Volatility::Dynamic`] facts from every active capability, in
2321/// configured order. Called by `ReasonAtom` once per request so live values
2322/// (e.g. the current time) are fresh, then rendered into the trailing `<facts>`
2323/// block. Static facts are ignored here — they already live in the cached
2324/// system prompt.
2325pub fn collect_dynamic_facts(
2326    capability_configs: &[AgentCapabilityConfig],
2327    registry: &CapabilityRegistry,
2328    model: Option<&str>,
2329    ctx: &FactsContext,
2330) -> Vec<Fact> {
2331    let mut dynamic = Vec::new();
2332    for cap_config in capability_configs {
2333        let cap_id = cap_config.capability_ref.as_str();
2334        if let Some(capability) = registry.get(cap_id) {
2335            if capability.status() != CapabilityStatus::Available {
2336                continue;
2337            }
2338            let effective: &dyn Capability = capability
2339                .resolve_for_model(model)
2340                .unwrap_or_else(|| capability.as_ref());
2341            for fact in effective.facts(&cap_config.config, ctx) {
2342                if fact.volatility == Volatility::Dynamic {
2343                    dynamic.push(fact);
2344                }
2345            }
2346        }
2347    }
2348    dynamic
2349}
2350
2351pub fn collect_capability_mcp_servers(
2352    capability_configs: &[AgentCapabilityConfig],
2353    registry: &CapabilityRegistry,
2354) -> ScopedMcpServers {
2355    let mut servers = ScopedMcpServers::default();
2356
2357    for cap_config in capability_configs {
2358        let cap_id = cap_config.capability_ref.as_str();
2359        // Both `declarative:` and `plugin:` carry a serialized
2360        // `DeclarativeCapabilityDefinition`; handle them the same way.
2361        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2362            if let Ok(definition) =
2363                serde_json::from_value::<DeclarativeCapabilityDefinition>(cap_config.config.clone())
2364            {
2365                if definition.status != CapabilityStatus::Available {
2366                    continue;
2367                }
2368                if let Some(contributed) = definition.mcp_servers {
2369                    servers = merge_scoped_mcp_servers(&servers, &contributed);
2370                }
2371            }
2372            continue;
2373        }
2374        if let Some(capability) = registry.get(cap_id) {
2375            if capability.status() != CapabilityStatus::Available {
2376                continue;
2377            }
2378            servers = merge_scoped_mcp_servers(
2379                &servers,
2380                &capability.mcp_servers_with_config(&cap_config.config),
2381            );
2382        }
2383    }
2384
2385    servers
2386}
2387
2388// ============================================================================
2389// Dependency Resolution
2390// ============================================================================
2391
2392/// Maximum number of capabilities after dependency resolution.
2393/// This prevents runaway dependency chains and resource exhaustion.
2394pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2395
2396/// Error type for dependency resolution failures
2397#[derive(Debug, Clone, PartialEq, Eq)]
2398pub enum DependencyError {
2399    /// Circular dependency detected in the capability graph
2400    CircularDependency {
2401        /// The capability where the cycle was detected
2402        capability_id: String,
2403        /// The dependency chain leading to the cycle
2404        chain: Vec<String>,
2405    },
2406    /// Too many capabilities after resolution
2407    TooManyCapabilities {
2408        /// Number of capabilities requested
2409        count: usize,
2410        /// Maximum allowed
2411        max: usize,
2412    },
2413}
2414
2415impl std::fmt::Display for DependencyError {
2416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2417        match self {
2418            DependencyError::CircularDependency {
2419                capability_id,
2420                chain,
2421            } => {
2422                write!(
2423                    f,
2424                    "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2425                    capability_id,
2426                    chain.join(" -> "),
2427                    capability_id
2428                )
2429            }
2430            DependencyError::TooManyCapabilities { count, max } => {
2431                write!(
2432                    f,
2433                    "Too many capabilities after resolution: {} (max: {})",
2434                    count, max
2435                )
2436            }
2437        }
2438    }
2439}
2440
2441impl std::error::Error for DependencyError {}
2442
2443/// Result of resolving capability dependencies
2444#[derive(Debug, Clone)]
2445pub struct ResolvedCapabilities {
2446    /// All capability IDs after resolving dependencies (in topological order)
2447    /// Dependencies come before dependents.
2448    pub resolved_ids: Vec<String>,
2449    /// IDs that were added as dependencies (not in the original selection)
2450    pub added_as_dependencies: Vec<String>,
2451    /// Original user-selected capability IDs
2452    pub user_selected: Vec<String>,
2453}
2454
2455/// Resolve capability dependencies, returning all required capability IDs.
2456///
2457/// This function:
2458/// 1. Takes the user-selected capability IDs
2459/// 2. Recursively collects all dependencies
2460/// 3. Returns them in topological order (dependencies before dependents)
2461/// 4. Detects circular dependencies and returns an error
2462/// 5. Enforces a maximum capability limit
2463///
2464/// # Arguments
2465///
2466/// * `selected_ids` - User-selected capability IDs
2467/// * `registry` - The capability registry to look up dependencies
2468///
2469/// # Returns
2470///
2471/// `Ok(ResolvedCapabilities)` with all required capabilities in order,
2472/// or `Err(DependencyError)` if circular dependencies are detected or
2473/// the limit is exceeded.
2474pub fn resolve_dependencies(
2475    selected_ids: &[String],
2476    registry: &CapabilityRegistry,
2477) -> Result<ResolvedCapabilities, DependencyError> {
2478    use std::collections::HashSet;
2479
2480    // Canonicalize so capabilities selected via alias match their resolved IDs.
2481    let user_selected: HashSet<String> = selected_ids
2482        .iter()
2483        .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2484        .collect();
2485    let mut resolved: Vec<String> = Vec::new();
2486    let mut resolved_set: HashSet<String> = HashSet::new();
2487    let mut added_as_dependencies: Vec<String> = Vec::new();
2488
2489    // Process each selected capability and its dependencies using DFS
2490    for cap_id in selected_ids {
2491        resolve_single_capability(
2492            cap_id,
2493            registry,
2494            &mut resolved,
2495            &mut resolved_set,
2496            &mut added_as_dependencies,
2497            &user_selected,
2498            &mut Vec::new(), // visiting chain for cycle detection
2499        )?;
2500    }
2501
2502    // Check max limit
2503    if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2504        return Err(DependencyError::TooManyCapabilities {
2505            count: resolved.len(),
2506            max: MAX_RESOLVED_CAPABILITIES,
2507        });
2508    }
2509
2510    Ok(ResolvedCapabilities {
2511        resolved_ids: resolved,
2512        added_as_dependencies,
2513        user_selected: selected_ids.to_vec(),
2514    })
2515}
2516
2517/// Resolve dependency-expanded capability configs, preserving explicit config on selected IDs.
2518///
2519/// Dependencies are inserted with empty configs. If the same capability is provided more than
2520/// once, the last explicit config wins.
2521pub fn resolve_capability_configs(
2522    selected_configs: &[AgentCapabilityConfig],
2523    registry: &CapabilityRegistry,
2524) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2525    let mut selected_ids: Vec<String> = Vec::new();
2526    for config in selected_configs {
2527        // Both `declarative:` and `plugin:` carry a `DeclarativeCapabilityDefinition`
2528        // config that may declare dependencies.
2529        if (is_declarative_capability(config.capability_id())
2530            || is_plugin_capability(config.capability_id()))
2531            && let Ok(definition) =
2532                serde_json::from_value::<DeclarativeCapabilityDefinition>(config.config.clone())
2533        {
2534            selected_ids.extend(definition.dependencies);
2535        }
2536        selected_ids.push(config.capability_id().to_string());
2537    }
2538    let resolved = resolve_dependencies(&selected_ids, registry)?;
2539
2540    // Key explicit configs by canonical ID so config supplied under an alias
2541    // still attaches to the (canonical) resolved capability ID.
2542    let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2543        .iter()
2544        .map(|config| {
2545            let id = config.capability_id();
2546            let id = registry.canonical_id(id).unwrap_or(id);
2547            (id.to_string(), config.config.clone())
2548        })
2549        .collect();
2550
2551    Ok(resolved
2552        .resolved_ids
2553        .into_iter()
2554        .map(|capability_id| {
2555            explicit_configs
2556                .get(&capability_id)
2557                .cloned()
2558                .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2559                .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2560        })
2561        .collect())
2562}
2563
2564/// Helper function to resolve a single capability and its dependencies recursively.
2565fn resolve_single_capability(
2566    cap_id: &str,
2567    registry: &CapabilityRegistry,
2568    resolved: &mut Vec<String>,
2569    resolved_set: &mut std::collections::HashSet<String>,
2570    added_as_dependencies: &mut Vec<String>,
2571    user_selected: &std::collections::HashSet<String>,
2572    visiting: &mut Vec<String>,
2573) -> Result<(), DependencyError> {
2574    // Normalize aliases to the canonical ID so an alias and its canonical ID
2575    // resolve (and dedupe) to the same capability. Unknown IDs (declarative,
2576    // MCP, skill refs) pass through unchanged.
2577    let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2578
2579    // Already resolved
2580    if resolved_set.contains(cap_id) {
2581        return Ok(());
2582    }
2583
2584    // Check for circular dependency
2585    if visiting.contains(&cap_id.to_string()) {
2586        return Err(DependencyError::CircularDependency {
2587            capability_id: cap_id.to_string(),
2588            chain: visiting.clone(),
2589        });
2590    }
2591
2592    // Get capability from registry
2593    let capability = match registry.get(cap_id) {
2594        Some(cap) => cap,
2595        None => {
2596            // `declarative:` and `plugin:` refs carry their full definition in
2597            // the config payload — they don't need a registry entry. Pass them
2598            // through so `collect_capabilities_with_configs` can process them.
2599            if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2600                && !resolved_set.contains(cap_id)
2601            {
2602                resolved.push(cap_id.to_string());
2603                resolved_set.insert(cap_id.to_string());
2604                if !user_selected.contains(cap_id) {
2605                    added_as_dependencies.push(cap_id.to_string());
2606                }
2607            }
2608            return Ok(());
2609        }
2610    };
2611
2612    // Mark as visiting
2613    visiting.push(cap_id.to_string());
2614
2615    // Resolve dependencies first (depth-first)
2616    for dep_id in capability.dependencies() {
2617        resolve_single_capability(
2618            dep_id,
2619            registry,
2620            resolved,
2621            resolved_set,
2622            added_as_dependencies,
2623            user_selected,
2624            visiting,
2625        )?;
2626    }
2627
2628    // Remove from visiting
2629    visiting.pop();
2630
2631    // Add to resolved
2632    if !resolved_set.contains(cap_id) {
2633        resolved.push(cap_id.to_string());
2634        resolved_set.insert(cap_id.to_string());
2635
2636        // Track if this was added as a dependency (not user-selected)
2637        if !user_selected.contains(cap_id) {
2638            added_as_dependencies.push(cap_id.to_string());
2639        }
2640    }
2641
2642    Ok(())
2643}
2644
2645/// Compute the aggregated set of UI features from a list of capability IDs.
2646///
2647/// Resolves dependencies, collects features from all resolved capabilities,
2648/// and returns deduplicated feature strings.
2649pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2650    use std::collections::HashSet;
2651
2652    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2653        Ok(resolved) => resolved.resolved_ids,
2654        Err(_) => capability_ids.to_vec(),
2655    };
2656
2657    let mut seen = HashSet::new();
2658    let mut features = Vec::new();
2659    for cap_id in &resolved_ids {
2660        if let Some(cap) = registry.get(cap_id) {
2661            for feature in cap.features() {
2662                if seen.insert(feature) {
2663                    features.push(feature.to_string());
2664                }
2665            }
2666        }
2667    }
2668    features
2669}
2670
2671/// Get direct dependencies for a capability ID.
2672/// Returns empty vec if capability not found.
2673pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2674    registry
2675        .get(cap_id)
2676        .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2677        .unwrap_or_default()
2678}
2679
2680/// Collect contributions from capabilities without applying them.
2681///
2682/// Resolves dependencies first, then calls `system_prompt_contribution()` (async)
2683/// on each capability, enabling dynamic content generation based on session context
2684/// (e.g., reading AGENTS.md, discovering skills).
2685///
2686/// Note: This function does not collect message filter providers since it doesn't
2687/// have access to per-agent capability configs. Use `collect_capabilities_with_configs`
2688/// if you need message filter providers.
2689///
2690/// # Arguments
2691///
2692/// * `capability_ids` - Ordered list of capability IDs to collect
2693/// * `registry` - The capability registry containing implementations
2694/// * `ctx` - Session context for dynamic prompt resolution
2695pub async fn collect_capabilities(
2696    capability_ids: &[String],
2697    registry: &CapabilityRegistry,
2698    ctx: &SystemPromptContext,
2699) -> CollectedCapabilities {
2700    // Resolve dependencies so that transitive capabilities (e.g. session_storage
2701    // via browserless) are included automatically.
2702    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2703        Ok(resolved) => resolved.resolved_ids,
2704        Err(e) => {
2705            tracing::warn!("Failed to resolve capability dependencies: {}", e);
2706            capability_ids.to_vec()
2707        }
2708    };
2709
2710    // Convert to AgentCapabilityConfig with empty configs
2711    let configs: Vec<AgentCapabilityConfig> = resolved_ids
2712        .iter()
2713        .map(|id| AgentCapabilityConfig {
2714            capability_ref: CapabilityId::new(id),
2715            config: serde_json::Value::Object(serde_json::Map::new()),
2716        })
2717        .collect();
2718
2719    collect_capabilities_with_configs(&configs, registry, ctx).await
2720}
2721
2722/// Collect contributions from capabilities with their per-agent configurations.
2723///
2724/// Calls `system_prompt_contribution()` (async) on each capability, enabling
2725/// dynamic content generation based on session context.
2726///
2727/// # Arguments
2728///
2729/// * `capability_configs` - Ordered list of capability configs (ID + per-agent config)
2730/// * `registry` - The capability registry containing implementations
2731/// * `ctx` - Session context for dynamic prompt resolution
2732pub async fn collect_capabilities_with_configs(
2733    capability_configs: &[AgentCapabilityConfig],
2734    registry: &CapabilityRegistry,
2735    ctx: &SystemPromptContext,
2736) -> CollectedCapabilities {
2737    let mut system_prompt_parts: Vec<String> = Vec::new();
2738    let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2739    let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2740    let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2741    let mut mounts: Vec<MountPoint> = Vec::new();
2742    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2743        Vec::new();
2744    let mut applied_ids: Vec<String> = Vec::new();
2745    let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2746    let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2747    let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2748    let mut parallel_tool_calls: Option<bool> = None;
2749    let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2750    let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2751    // Per-capability narration adapters, appended after explicit tool-call
2752    // hooks so model-authored narration (human_intent) keeps precedence.
2753    let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2754    let mut mcp_servers = ScopedMcpServers::default();
2755    // Facts contributed by capabilities. Static facts fold into the cached
2756    // system prompt below; a single note is added when any dynamic fact exists,
2757    // explaining the live `<facts>` block that `ReasonAtom` appends per turn.
2758    let mut static_facts: Vec<Fact> = Vec::new();
2759    let mut has_dynamic_facts = false;
2760    let facts_ctx = FactsContext::new(ctx.session_id);
2761    let compaction_on = compaction_is_enabled(capability_configs, registry);
2762    let mut agent_handoff_spawn_config: Option<serde_json::Value> = None;
2763    let mut spawn_agent_providers: Vec<SpawnAgentTargetProvider> = Vec::new();
2764
2765    for cap_config in capability_configs {
2766        let cap_id = cap_config.capability_ref.as_str();
2767        // `declarative:` and `plugin:` refs both carry a serialized
2768        // `DeclarativeCapabilityDefinition` in their config and execute through
2769        // the same runtime path. `plugin:` is handled first (more specific
2770        // prefix), then `declarative:`, then the registry lookup.
2771        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2772            match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2773                cap_config.config.clone(),
2774            ) {
2775                Ok(definition) => {
2776                    if definition.status != CapabilityStatus::Available {
2777                        continue;
2778                    }
2779
2780                    if let Some(prompt) = definition.system_prompt.as_deref() {
2781                        let contribution =
2782                            format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2783                        system_prompt_attributions.push(SystemPromptAttribution {
2784                            capability_id: cap_id.to_string(),
2785                            content: contribution.clone(),
2786                        });
2787                        system_prompt_parts.push(contribution);
2788                    }
2789
2790                    mounts.extend(definition.mounts(cap_id));
2791                    if let Some(ref servers) = definition.mcp_servers {
2792                        mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2793                    }
2794                    for skill in definition.skill_contributions() {
2795                        mounts.push(skill.to_mount(cap_id));
2796                    }
2797
2798                    applied_ids.push(cap_id.to_string());
2799                }
2800                Err(error) => {
2801                    tracing::warn!(
2802                        capability_id = %cap_id,
2803                        error = %error,
2804                        "Skipping invalid declarative/plugin capability config"
2805                    );
2806                }
2807            }
2808            continue;
2809        }
2810        if let Some(capability) = registry.get(cap_id) {
2811            // Only collect from available capabilities
2812            if capability.status() != CapabilityStatus::Available {
2813                continue;
2814            }
2815
2816            // Model-adaptive dispatch: a capability may delegate its contributions
2817            // to a different underlying capability based on the agent's model
2818            // (e.g. `auto_tool_search` picks hosted vs client-side tool search).
2819            // Every contribution below is collected from `effective` (system prompt,
2820            // tools, hooks, tool definitions, mounts, MCP servers, skills, message
2821            // filters); for the common non-delegating case `effective` is just
2822            // `capability`. The tool_search special case below therefore keys on
2823            // `effective.id()` rather than the configured `cap_id`, so a resolved
2824            // `auto_tool_search` is treated as whichever mechanism it became.
2825            // Attribution stays on the configured `cap_id`/`capability` so tools
2826            // surface under the capability the user actually configured.
2827            let effective: &dyn Capability =
2828                match capability.resolve_for_model(ctx.model.as_deref()) {
2829                    Some(inner) => inner,
2830                    None => capability.as_ref(),
2831                };
2832            let effective_id = effective.id();
2833            if cap_id == AGENT_HANDOFF_CAPABILITY_ID {
2834                agent_handoff_spawn_config = Some(cap_config.config.clone());
2835            }
2836
2837            // Collect dynamic system prompt contribution (config-aware, may read from filesystem)
2838            if let Some(contribution) = effective
2839                .system_prompt_contribution_with_config(ctx, &cap_config.config)
2840                .await
2841            {
2842                system_prompt_attributions.push(SystemPromptAttribution {
2843                    capability_id: cap_id.to_string(),
2844                    content: contribution.clone(),
2845                });
2846                system_prompt_parts.push(contribution);
2847            }
2848
2849            // Collect declared facts. Static facts fold into the cached prompt
2850            // below; dynamic facts are re-collected per request by `ReasonAtom`
2851            // and appended at the conversation tail, so here we only note their
2852            // presence to add the explanatory system-prompt line.
2853            for fact in effective.facts(&cap_config.config, &facts_ctx) {
2854                match fact.volatility {
2855                    Volatility::Static => static_facts.push(fact),
2856                    Volatility::Dynamic => has_dynamic_facts = true,
2857                }
2858            }
2859
2860            // Collect tools and hooks (config-aware: capabilities can adapt based on per-agent config)
2861            for tool in effective.tools_with_config(&cap_config.config) {
2862                if cap_id == A2A_AGENT_DELEGATION_CAPABILITY_ID && tool.name() == "spawn_agent" {
2863                    spawn_agent_providers.push(SpawnAgentTargetProvider {
2864                        target_type: "external_a2a",
2865                        tool,
2866                    });
2867                } else {
2868                    tools.push(tool);
2869                }
2870            }
2871            tool_definition_hooks
2872                .extend(effective.tool_definition_hooks_with_context(ctx, &cap_config.config));
2873            tool_call_hooks.extend(effective.tool_call_hooks());
2874            // Route this capability's `narrate()` through the hook channel.
2875            narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2876            // Output guardrails are NOT collected here — see CollectedCapabilities
2877            // for rationale. ReasonAtom re-derives them at stream-arming time.
2878
2879            // Collect tool definitions, propagating capability category if not already set
2880            let cap_category = effective.category();
2881            for def in effective.tool_definitions() {
2882                if cap_id == A2A_AGENT_DELEGATION_CAPABILITY_ID && def.name() == "spawn_agent" {
2883                    continue;
2884                }
2885                let def = match (def.category(), cap_category) {
2886                    (None, Some(cat)) => def.with_category(cat),
2887                    _ => def,
2888                }
2889                .with_capability_attribution(cap_id, Some(capability.name()));
2890                tool_definitions.push(def);
2891            }
2892
2893            // Detect a hosted tool_search mechanism (OpenAI or Anthropic). Both
2894            // hosted capabilities produce the same provider-agnostic
2895            // `ToolSearchConfig`; the driver that handles the request picks the
2896            // wire format. `auto_tool_search` resolves to one of these ids only on
2897            // models with native support; on every other model it resolves to the
2898            // generic `tool_search`, which sets no hosted config and instead
2899            // contributes the hook + tool above.
2900            if effective_id == OPENAI_TOOL_SEARCH_CAPABILITY_ID
2901                || effective_id == CLAUDE_TOOL_SEARCH_CAPABILITY_ID
2902            {
2903                // Parse threshold from config, fall back to default
2904                let threshold = cap_config
2905                    .config
2906                    .get("threshold")
2907                    .and_then(|v| v.as_u64())
2908                    .map(|v| v as usize)
2909                    .unwrap_or(DEFAULT_TOOL_SEARCH_THRESHOLD);
2910                tool_search = Some(crate::driver_registry::ToolSearchConfig {
2911                    enabled: true,
2912                    threshold,
2913                });
2914            }
2915
2916            if cap_id == PROMPT_CACHING_CAPABILITY_ID {
2917                let strategy = cap_config
2918                    .config
2919                    .get("strategy")
2920                    .and_then(|v| v.as_str())
2921                    .map(|value| match value {
2922                        "auto" => crate::driver_registry::PromptCacheStrategy::Auto,
2923                        _ => crate::driver_registry::PromptCacheStrategy::Auto,
2924                    })
2925                    .unwrap_or(crate::driver_registry::PromptCacheStrategy::Auto);
2926                let gemini_cached_content = cap_config
2927                    .config
2928                    .get("gemini_cached_content")
2929                    .and_then(|v| v.as_str())
2930                    .map(str::to_string);
2931                prompt_cache = Some(crate::driver_registry::PromptCacheConfig {
2932                    enabled: true,
2933                    strategy,
2934                    gemini_cached_content,
2935                });
2936            }
2937
2938            if cap_id == PARALLEL_TOOL_CALLS_CAPABILITY_ID {
2939                parallel_tool_calls =
2940                    parallel_tool_calls::parallel_tool_calls_from_config(&cap_config.config);
2941            }
2942
2943            if cap_id == OPENROUTER_SERVER_TOOLS_CAPABILITY_ID {
2944                let server_tools =
2945                    openrouter_server_tools::server_tools_from_config(&cap_config.config);
2946                if !server_tools.is_empty() {
2947                    openrouter_routing = Some(crate::driver_registry::OpenRouterRoutingConfig {
2948                        server_tools,
2949                        ..Default::default()
2950                    });
2951                }
2952            }
2953
2954            // Collect mount points
2955            mounts.extend(effective.mounts());
2956
2957            mcp_servers = merge_scoped_mcp_servers(
2958                &mcp_servers,
2959                &effective.mcp_servers_with_config(&cap_config.config),
2960            );
2961
2962            // Normalize capability-contributed skills into mount points under
2963            // `/.agents/skills/{name}/`. Discovery/activation stays with the
2964            // built-in `skills` capability — see specs/skills-registry.md.
2965            for skill in effective.contribute_skills() {
2966                mounts.push(skill.to_mount(cap_id));
2967            }
2968
2969            // Collect message filter provider
2970            if let Some(provider) = effective.message_filter_provider() {
2971                let config = message_filter_config_for(cap_id, &cap_config.config, compaction_on);
2972                message_filter_providers.push((provider, config));
2973            }
2974
2975            applied_ids.push(cap_id.to_string());
2976        }
2977    }
2978
2979    // EVE-677 migration: known delegation providers now share one model-facing
2980    // `spawn_agent` dispatcher so subagents, first-party handoffs, and external
2981    // A2A agents can coexist in the same session. Unknown third-party
2982    // `spawn_agent` owners still win to avoid changing their contract.
2983    if applied_ids.iter().any(|id| id == SUBAGENTS_CAPABILITY_ID) {
2984        spawn_agent_providers.push(SpawnAgentTargetProvider {
2985            target_type: "subagent",
2986            tool: Box::new(SpawnSubagentAsAgentTool),
2987        });
2988    }
2989    if let Some(config) = agent_handoff_spawn_config.as_ref() {
2990        spawn_agent_providers.push(SpawnAgentTargetProvider {
2991            target_type: "agent",
2992            tool: Box::new(SpawnAgentHandoffTool::new(config)),
2993        });
2994    }
2995    if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !spawn_agent_providers.is_empty() {
2996        let tool = UnifiedSpawnAgentTool::new(spawn_agent_providers);
2997        let def = tool
2998            .to_definition()
2999            .with_category("Orchestration")
3000            .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
3001        tools.push(Box::new(tool));
3002        tool_definitions.push(def);
3003    }
3004
3005    // Auto-activate `background_execution` whenever any collected tool
3006    // declares background support via `ToolHints::supports_background`.
3007    //
3008    // This is the generic cross-cutting capability contract — meta-tools that
3009    // wrap other tools based on hints should hook in here, not attach to a
3010    // single owner capability (e.g. `bashkit_shell`).
3011    //
3012    // Lockstep: we extend both `tools` (execution registry) and
3013    // `tool_definitions` (model-visible) so the model can see and the worker
3014    // can dispatch `spawn_background` from the same activation event. See
3015    // `specs/background-execution.md`.
3016    if !applied_ids
3017        .iter()
3018        .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID)
3019        && tool_definitions
3020            .iter()
3021            .any(|def| def.hints().supports_background == Some(true))
3022        && let Some(bg_cap) = registry.get(BACKGROUND_EXECUTION_CAPABILITY_ID)
3023        && bg_cap.status() == CapabilityStatus::Available
3024    {
3025        tools.extend(bg_cap.tools());
3026        let cap_category = bg_cap.category();
3027        for def in bg_cap.tool_definitions() {
3028            let def = match (def.category(), cap_category) {
3029                (None, Some(cat)) => def.with_category(cat),
3030                _ => def,
3031            }
3032            .with_capability_attribution(BACKGROUND_EXECUTION_CAPABILITY_ID, Some(bg_cap.name()));
3033            tool_definitions.push(def);
3034        }
3035        narration_hooks.push(Arc::new(CapabilityNarrationHook(bg_cap.clone())));
3036        applied_ids.push(BACKGROUND_EXECUTION_CAPABILITY_ID.to_string());
3037    }
3038
3039    // Fold static facts into the cached system-prompt prefix, and add the
3040    // dynamic-facts note once when any capability declared a dynamic fact. Both
3041    // are stable across turns, so they stay in the cached prefix; the live
3042    // dynamic values are appended at the conversation tail per request.
3043    if let Some(block) = facts::render_facts_block(&static_facts) {
3044        system_prompt_attributions.push(SystemPromptAttribution {
3045            capability_id: "facts".to_string(),
3046            content: block.clone(),
3047        });
3048        system_prompt_parts.push(block);
3049    }
3050    if has_dynamic_facts {
3051        system_prompt_attributions.push(SystemPromptAttribution {
3052            capability_id: "facts".to_string(),
3053            content: FACTS_DYNAMIC_NOTE.to_string(),
3054        });
3055        system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
3056    }
3057
3058    // Append per-capability narration adapters after every explicit tool-call
3059    // hook so capability-owned narration is consulted only once model-authored
3060    // hooks (human_intent) have had their say.
3061    tool_call_hooks.extend(narration_hooks);
3062
3063    // Sort message filter providers by priority (lower = earlier)
3064    message_filter_providers.sort_by_key(|(p, _)| p.priority());
3065
3066    CollectedCapabilities {
3067        system_prompt_parts,
3068        system_prompt_attributions,
3069        tools,
3070        tool_definitions,
3071        mounts,
3072        message_filter_providers,
3073        applied_ids,
3074        tool_search,
3075        prompt_cache,
3076        openrouter_routing,
3077        parallel_tool_calls,
3078        tool_definition_hooks,
3079        tool_call_hooks,
3080        mcp_servers,
3081    }
3082}
3083
3084// ============================================================================
3085// Apply Capabilities to RuntimeAgent
3086// ============================================================================
3087
3088/// Result of applying capabilities to a base runtime agent
3089pub struct AppliedCapabilities {
3090    /// The modified runtime agent with capability contributions merged
3091    pub runtime_agent: RuntimeAgent,
3092    /// Tool registry containing all capability tools
3093    pub tool_registry: ToolRegistry,
3094    /// IDs of capabilities that were applied
3095    pub applied_ids: Vec<String>,
3096}
3097
3098/// Apply capabilities to a base runtime agent configuration.
3099///
3100/// This function:
3101/// 1. Collects system prompt contributions from capabilities (in order)
3102/// 2. Appends them after the agent's base system prompt
3103/// 3. Collects all tools from capabilities
3104/// 4. Returns the modified runtime agent and a tool registry
3105///
3106/// # Arguments
3107///
3108/// * `base_runtime_agent` - The agent's base runtime configuration
3109/// * `capability_ids` - Ordered list of capability IDs to apply
3110/// * `registry` - The capability registry containing implementations
3111/// * `ctx` - Session context for dynamic prompt resolution
3112///
3113/// # Returns
3114///
3115/// An `AppliedCapabilities` struct containing the modified runtime agent,
3116/// tool registry, and list of applied capability IDs.
3117///
3118/// # Example
3119///
3120/// ```ignore
3121/// use everruns_core::capabilities::{apply_capabilities, CapabilityRegistry, SystemPromptContext};
3122/// use everruns_core::runtime_agent::RuntimeAgent;
3123///
3124/// let registry = CapabilityRegistry::with_builtins();
3125/// let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3126/// let ctx = SystemPromptContext::without_file_store(SessionId::new());
3127///
3128/// let capability_ids = vec!["current_time".to_string()];
3129/// let applied = apply_capabilities(base_runtime_agent, &capability_ids, &registry, &ctx).await;
3130///
3131/// // The runtime agent now includes CurrentTime tool
3132/// assert!(!applied.tool_registry.is_empty());
3133/// ```
3134pub async fn apply_capabilities(
3135    base_runtime_agent: RuntimeAgent,
3136    capability_ids: &[String],
3137    registry: &CapabilityRegistry,
3138    ctx: &SystemPromptContext,
3139) -> AppliedCapabilities {
3140    let collected = collect_capabilities(capability_ids, registry, ctx).await;
3141
3142    // Build final system prompt: base prompt first, then capability additions.
3143    let final_system_prompt = compose_system_prompt(
3144        &base_runtime_agent.system_prompt,
3145        collected.system_prompt_prefix().as_deref(),
3146    );
3147
3148    // Build tool registry from collected tools
3149    let mut tool_registry = ToolRegistry::new();
3150    for tool in collected.tools {
3151        tool_registry.register_boxed(tool);
3152    }
3153
3154    // Create modified runtime agent
3155    let mut tools = collected.tool_definitions;
3156    for hook in &collected.tool_definition_hooks {
3157        tools = hook.transform(tools);
3158    }
3159
3160    let runtime_agent = RuntimeAgent {
3161        system_prompt: final_system_prompt,
3162        model: base_runtime_agent.model,
3163        tools,
3164        max_iterations: base_runtime_agent.max_iterations,
3165        temperature: base_runtime_agent.temperature,
3166        max_tokens: base_runtime_agent.max_tokens,
3167        tool_search: collected.tool_search,
3168        prompt_cache: collected.prompt_cache,
3169        openrouter_routing: collected.openrouter_routing,
3170        network_access: base_runtime_agent.network_access,
3171        // Explicit request-level preference (escape hatch) wins; otherwise the
3172        // `parallel_tool_calls` capability supplies the preference.
3173        parallel_tool_calls: base_runtime_agent
3174            .parallel_tool_calls
3175            .or(collected.parallel_tool_calls),
3176    };
3177
3178    AppliedCapabilities {
3179        runtime_agent,
3180        tool_registry,
3181        applied_ids: collected.applied_ids,
3182    }
3183}
3184
3185// ============================================================================
3186// Tests
3187// ============================================================================
3188
3189#[cfg(test)]
3190mod tests {
3191    use super::*;
3192    use crate::typed_id::SessionId;
3193    use std::collections::BTreeSet;
3194    use uuid::Uuid;
3195
3196    // Env-var-mutating tests must not run in parallel.
3197    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
3198
3199    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
3200        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
3201    }
3202
3203    /// Test helper: dummy context with no file store
3204    fn test_ctx() -> SystemPromptContext {
3205        SystemPromptContext::without_file_store(SessionId::new())
3206    }
3207
3208    /// Base set of built-in capabilities present in all environments (no experimental delegation).
3209    fn expected_core_builtin_ids() -> BTreeSet<&'static str> {
3210        let mut ids = [
3211            "agent_instructions",
3212            "human_intent",
3213            "budgeting",
3214            "self_budget",
3215            "noop",
3216            "current_time",
3217            "research",
3218            "platform_management",
3219            "session_file_system",
3220            "session_storage",
3221            "session",
3222            "session_sql_database",
3223            "test_math",
3224            "test_weather",
3225            "stateless_todo_list",
3226            "web_fetch",
3227            "bashkit_shell",
3228            "background_execution",
3229            "session_schedule",
3230            "btw",
3231            "infinity_context",
3232            "compaction",
3233            "memory",
3234            "message_metadata",
3235            "openai_tool_search",
3236            "claude_tool_search",
3237            "tool_search",
3238            "auto_tool_search",
3239            "prompt_caching",
3240            "parallel_tool_calls",
3241            "session_tasks",
3242            "skills",
3243            "subagents",
3244            "system_commands",
3245            "sample_data",
3246            "data_knowledge",
3247            "knowledge_base",
3248            "knowledge_index",
3249            "citation_retrieval",
3250            "citation_verification",
3251            "tool_output_persistence",
3252            "tool_output_distillation",
3253            "fake_warehouse",
3254            "fake_aws",
3255            "fake_crm",
3256            "fake_financial",
3257            "loop_detection",
3258            "usage_limit_auto_continue",
3259            "tool_call_repair",
3260            "error_disclosure",
3261            "prompt_canary_guardrail",
3262            "guardrails",
3263            "user_hooks",
3264            "model_scout",
3265            "openrouter_workspace",
3266            "openrouter_server_tools",
3267        ]
3268        .into_iter()
3269        .collect::<BTreeSet<_>>();
3270        if cfg!(feature = "ui-capabilities") {
3271            ids.insert("openui");
3272            ids.insert("a2ui");
3273        }
3274        ids
3275    }
3276
3277    /// Capabilities present in the default in-process runtime registry.
3278    fn expected_runtime_builtin_ids() -> BTreeSet<&'static str> {
3279        let mut ids = [
3280            "agent_instructions",
3281            "human_intent",
3282            "budgeting",
3283            "self_budget",
3284            "noop",
3285            "current_time",
3286            "session_file_system",
3287            "session_storage",
3288            "session",
3289            "stateless_todo_list",
3290            "bashkit_shell",
3291            "btw",
3292            "infinity_context",
3293            "compaction",
3294            "message_metadata",
3295            "openai_tool_search",
3296            "claude_tool_search",
3297            "tool_search",
3298            "auto_tool_search",
3299            "prompt_caching",
3300            "parallel_tool_calls",
3301            "skills",
3302            "system_commands",
3303            "tool_output_persistence",
3304            "tool_output_distillation",
3305            "loop_detection",
3306            "tool_call_repair",
3307            "error_disclosure",
3308            "prompt_canary_guardrail",
3309            "guardrails",
3310            "user_hooks",
3311        ]
3312        .into_iter()
3313        .collect::<BTreeSet<_>>();
3314        if cfg!(feature = "web-fetch") {
3315            ids.insert("web_fetch");
3316        }
3317        ids
3318    }
3319
3320    /// Full set for dev: base + experimental delegation capabilities.
3321    fn expected_dev_builtin_ids() -> BTreeSet<&'static str> {
3322        let mut ids = expected_core_builtin_ids();
3323        ids.insert("agent_handoff");
3324        ids.insert("a2a_agent_delegation");
3325        ids
3326    }
3327
3328    fn registry_ids(registry: &CapabilityRegistry) -> BTreeSet<&str> {
3329        registry.capabilities.keys().map(String::as_str).collect()
3330    }
3331
3332    // =========================================================================
3333    // CapabilityRegistry tests
3334    // =========================================================================
3335
3336    // Note: Integration plugins (docker, daytona, etc.) are registered via inventory::submit!
3337    // in external crates. They only appear in the registry when the integration crate is
3338    // linked into the final binary. Core tests verify only built-in capabilities.
3339    // Integration crates have their own tests for plugin registration.
3340
3341    #[test]
3342    fn test_capability_registry_with_builtins_dev() {
3343        // Dev mode includes all built-in capabilities including experimental delegation
3344        let _lock = lock_env();
3345        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
3346        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
3347        assert_eq!(registry_ids(&registry), expected_dev_builtin_ids());
3348        assert!(registry.has("agent_handoff"));
3349        assert!(registry.has("a2a_agent_delegation"));
3350    }
3351
3352    #[test]
3353    fn test_capability_registry_with_builtins_prod() {
3354        // Prod mode excludes experimental capabilities including delegation
3355        let _lock = lock_env();
3356        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
3357        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Prod);
3358        assert_eq!(registry_ids(&registry), expected_core_builtin_ids());
3359        // Experimental capabilities NOT included in prod
3360        assert!(!registry.has("docker_container"));
3361        assert!(!registry.has("agent_handoff"));
3362        assert!(!registry.has("a2a_agent_delegation"));
3363    }
3364
3365    #[test]
3366    fn test_capability_registry_runtime_builtins() {
3367        let _lock = lock_env();
3368        unsafe { std::env::remove_var("FEATURE_LUA") };
3369        let registry = CapabilityRegistry::runtime_builtins();
3370        assert_eq!(registry_ids(&registry), expected_runtime_builtin_ids());
3371        assert!(registry.has("session_file_system"));
3372        #[cfg(feature = "web-fetch")]
3373        assert!(registry.has("web_fetch"));
3374        assert!(registry.has("bashkit_shell"));
3375
3376        for platform_only in [
3377            "platform_management",
3378            "model_scout",
3379            "openrouter_workspace",
3380            "openrouter_server_tools",
3381            "session_tasks",
3382            "session_schedule",
3383            "subagents",
3384            "background_execution",
3385            "session_sql_database",
3386            "knowledge_base",
3387            "knowledge_index",
3388            "sample_data",
3389            "data_knowledge",
3390            "fake_aws",
3391            "fake_crm",
3392            "fake_financial",
3393            "fake_warehouse",
3394            "test_math",
3395            "test_weather",
3396            "research",
3397        ] {
3398            assert!(
3399                !registry.has(platform_only),
3400                "`{platform_only}` should not be in the runtime default registry"
3401            );
3402        }
3403    }
3404
3405    #[test]
3406    fn test_agent_delegation_enabled_by_env_in_prod() {
3407        // FEATURE_AGENT_DELEGATION=true enables delegation caps even in prod
3408        let _lock = lock_env();
3409        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
3410        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Prod);
3411        assert!(registry.has("agent_handoff"));
3412        assert!(registry.has("a2a_agent_delegation"));
3413        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
3414    }
3415
3416    #[test]
3417    fn test_agent_delegation_disabled_by_env_in_dev() {
3418        // FEATURE_AGENT_DELEGATION=false disables delegation caps even in dev
3419        let _lock = lock_env();
3420        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "false") };
3421        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
3422        assert!(!registry.has("agent_handoff"));
3423        assert!(!registry.has("a2a_agent_delegation"));
3424        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
3425    }
3426
3427    #[test]
3428    fn test_capability_registry_get() {
3429        let registry = CapabilityRegistry::with_builtins();
3430
3431        let noop = registry.get("noop").unwrap();
3432        assert_eq!(noop.id(), "noop");
3433        assert_eq!(noop.name(), "No-Op");
3434        assert_eq!(noop.status(), CapabilityStatus::Available);
3435    }
3436
3437    /// Registry-wide invariants for every built-in capability. This replaces the
3438    /// per-capability `test_capability_metadata` / `has_tools` / `in_registry`
3439    /// boilerplate that only restated hardcoded constants: instead of pinning
3440    /// each id/name/tool-list literal, it enforces the properties that actually
3441    /// matter across the whole set and would catch a real defect (a blank id, a
3442    /// duplicate or dangling dependency, colliding tool names) that constant
3443    /// mirrors never could.
3444    #[test]
3445    fn builtin_capabilities_satisfy_registry_invariants() {
3446        let registry = CapabilityRegistry::with_builtins();
3447
3448        for cap in registry.list() {
3449            let id = cap.id();
3450            assert!(!id.is_empty(), "capability has an empty id");
3451            assert!(
3452                !cap.name().trim().is_empty(),
3453                "capability `{id}` has an empty name"
3454            );
3455
3456            // The registration key is `id()`, so every capability must resolve
3457            // by its own id (guards against an id()/registration mismatch).
3458            assert!(
3459                registry.get(id).is_some(),
3460                "capability `{id}` does not resolve by its own id"
3461            );
3462
3463            // Every declared dependency must resolve to a registered capability
3464            // (or alias) in the same registry — a typo or removed dependency
3465            // would otherwise silently break dependency resolution at runtime.
3466            for dep in cap.dependencies() {
3467                assert!(
3468                    registry.get(dep).is_some(),
3469                    "capability `{id}` depends on `{dep}`, which is not registered"
3470                );
3471            }
3472
3473            // Tool names must be non-empty and unique within a capability, so
3474            // dispatch by name is unambiguous.
3475            let mut seen = std::collections::HashSet::new();
3476            for tool in cap.tools() {
3477                let name = tool.name().to_string();
3478                assert!(
3479                    !name.is_empty(),
3480                    "capability `{id}` exposes a tool with an empty name"
3481                );
3482                assert!(
3483                    seen.insert(name.clone()),
3484                    "capability `{id}` exposes duplicate tool name `{name}`"
3485                );
3486            }
3487
3488            // Advertised tool definitions must likewise carry non-empty, unique
3489            // names so the tool schema a client sees is unambiguous.
3490            let mut def_seen = std::collections::HashSet::new();
3491            for def in cap.tool_definitions() {
3492                let name = def.name().to_string();
3493                assert!(
3494                    !name.is_empty(),
3495                    "capability `{id}` advertises a tool definition with an empty name"
3496                );
3497                assert!(
3498                    def_seen.insert(name.clone()),
3499                    "capability `{id}` advertises duplicate tool definition name `{name}`"
3500                );
3501            }
3502        }
3503    }
3504
3505    #[test]
3506    fn test_capability_registry_blueprint_with_capability() {
3507        struct BlueprintProviderCapability;
3508
3509        impl Capability for BlueprintProviderCapability {
3510            fn id(&self) -> &str {
3511                "blueprint_provider"
3512            }
3513            fn name(&self) -> &str {
3514                "Blueprint Provider"
3515            }
3516            fn description(&self) -> &str {
3517                "Capability that provides a blueprint for tests"
3518            }
3519            fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3520                vec![AgentBlueprint {
3521                    id: "test_blueprint",
3522                    name: "Test Blueprint",
3523                    description: "Blueprint for capability registry tests",
3524                    model: BlueprintModel::Inherit,
3525                    system_prompt: "Test prompt",
3526                    tools: vec![],
3527                    max_turns: None,
3528                    config_schema: None,
3529                }]
3530            }
3531        }
3532
3533        let mut registry = CapabilityRegistry::new();
3534        registry.register(BlueprintProviderCapability);
3535
3536        let (capability_id, blueprint) = registry
3537            .blueprint_with_capability("test_blueprint")
3538            .expect("blueprint should resolve with capability id");
3539        assert_eq!(capability_id, "blueprint_provider");
3540        assert_eq!(blueprint.id, "test_blueprint");
3541    }
3542
3543    #[test]
3544    fn test_capability_registry_builder() {
3545        let registry = CapabilityRegistry::builder()
3546            .capability(NoopCapability)
3547            .capability(CurrentTimeCapability)
3548            .build();
3549
3550        assert!(registry.has("noop"));
3551        assert!(registry.has("current_time"));
3552        assert_eq!(registry.len(), 2);
3553    }
3554
3555    #[test]
3556    fn test_capability_status() {
3557        let registry = CapabilityRegistry::with_builtins();
3558
3559        let current_time = registry.get("current_time").unwrap();
3560        assert_eq!(current_time.status(), CapabilityStatus::Available);
3561
3562        let research = registry.get("research").unwrap();
3563        assert_eq!(research.status(), CapabilityStatus::ComingSoon);
3564    }
3565
3566    #[test]
3567    fn test_capability_icons_and_categories() {
3568        let registry = CapabilityRegistry::with_builtins();
3569
3570        let noop = registry.get("noop").unwrap();
3571        assert_eq!(noop.icon(), Some("circle-off"));
3572        assert_eq!(noop.category(), Some("Testing"));
3573
3574        let current_time = registry.get("current_time").unwrap();
3575        assert_eq!(current_time.icon(), Some("clock"));
3576        assert_eq!(current_time.category(), Some("Core"));
3577    }
3578
3579    #[test]
3580    fn test_system_prompt_preview_default_delegates_to_addition() {
3581        let registry = CapabilityRegistry::with_builtins();
3582
3583        // test_math has a static system_prompt_addition — preview should match
3584        let test_math = registry.get("test_math").unwrap();
3585        assert_eq!(
3586            test_math.system_prompt_preview().as_deref(),
3587            test_math.system_prompt_addition()
3588        );
3589
3590        // current_time has no system_prompt_addition — preview should be None
3591        let current_time = registry.get("current_time").unwrap();
3592        assert!(current_time.system_prompt_preview().is_none());
3593        assert!(current_time.system_prompt_addition().is_none());
3594    }
3595
3596    #[test]
3597    fn test_system_prompt_preview_dynamic_capability() {
3598        let registry = CapabilityRegistry::with_builtins();
3599        let cap = registry.get("agent_instructions").unwrap();
3600
3601        // No static addition, but preview exists
3602        assert!(cap.system_prompt_addition().is_none());
3603        assert!(cap.system_prompt_preview().is_some());
3604        assert!(cap.system_prompt_preview().unwrap().contains("AGENTS.md"));
3605    }
3606
3607    // =========================================================================
3608    // apply_capabilities tests
3609    // =========================================================================
3610
3611    #[tokio::test]
3612    async fn test_apply_capabilities_empty() {
3613        let registry = CapabilityRegistry::with_builtins();
3614        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3615
3616        let applied =
3617            apply_capabilities(base_runtime_agent.clone(), &[], &registry, &test_ctx()).await;
3618
3619        assert_eq!(
3620            applied.runtime_agent.system_prompt,
3621            base_runtime_agent.system_prompt
3622        );
3623        assert!(applied.tool_registry.is_empty());
3624        assert!(applied.applied_ids.is_empty());
3625    }
3626
3627    #[tokio::test]
3628    async fn test_apply_capabilities_noop() {
3629        let registry = CapabilityRegistry::with_builtins();
3630        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3631
3632        let applied = apply_capabilities(
3633            base_runtime_agent.clone(),
3634            &["noop".to_string()],
3635            &registry,
3636            &test_ctx(),
3637        )
3638        .await;
3639
3640        // Noop has no system prompt addition or tools
3641        assert_eq!(
3642            applied.runtime_agent.system_prompt,
3643            base_runtime_agent.system_prompt
3644        );
3645        assert!(applied.tool_registry.is_empty());
3646        assert_eq!(applied.applied_ids, vec!["noop"]);
3647    }
3648
3649    #[tokio::test]
3650    async fn test_apply_capabilities_current_time() {
3651        let registry = CapabilityRegistry::with_builtins();
3652        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3653
3654        let applied = apply_capabilities(
3655            base_runtime_agent.clone(),
3656            &["current_time".to_string()],
3657            &registry,
3658            &test_ctx(),
3659        )
3660        .await;
3661
3662        // CurrentTime contributes a dynamic `current_time` fact, so the cached
3663        // prompt gains the explanatory facts note (the live value is appended at
3664        // the conversation tail per request). It also keeps its tool.
3665        assert!(
3666            applied
3667                .runtime_agent
3668                .system_prompt
3669                .contains(FACTS_DYNAMIC_NOTE),
3670            "current_time should contribute the dynamic-facts note"
3671        );
3672        assert!(
3673            applied
3674                .runtime_agent
3675                .system_prompt
3676                .contains(&base_runtime_agent.system_prompt),
3677            "base prompt is preserved"
3678        );
3679        assert!(applied.tool_registry.has("get_current_time"));
3680        assert_eq!(applied.tool_registry.len(), 1);
3681        assert_eq!(applied.applied_ids, vec!["current_time"]);
3682    }
3683
3684    #[tokio::test]
3685    async fn test_apply_capabilities_skips_coming_soon() {
3686        let registry = CapabilityRegistry::with_builtins();
3687        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3688
3689        // Research is ComingSoon, so it should be skipped
3690        let applied = apply_capabilities(
3691            base_runtime_agent.clone(),
3692            &["research".to_string()],
3693            &registry,
3694            &test_ctx(),
3695        )
3696        .await;
3697
3698        // System prompt should not have the research addition
3699        assert_eq!(
3700            applied.runtime_agent.system_prompt,
3701            base_runtime_agent.system_prompt
3702        );
3703        assert!(applied.applied_ids.is_empty()); // Research was not applied
3704    }
3705
3706    #[tokio::test]
3707    async fn test_apply_capabilities_multiple() {
3708        let registry = CapabilityRegistry::with_builtins();
3709        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3710
3711        let applied = apply_capabilities(
3712            base_runtime_agent.clone(),
3713            &["noop".to_string(), "current_time".to_string()],
3714            &registry,
3715            &test_ctx(),
3716        )
3717        .await;
3718
3719        assert!(applied.tool_registry.has("get_current_time"));
3720        assert_eq!(applied.applied_ids, vec!["noop", "current_time"]);
3721    }
3722
3723    #[tokio::test]
3724    async fn test_apply_capabilities_preserves_order() {
3725        let registry = CapabilityRegistry::with_builtins();
3726        let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3727
3728        // Order should be preserved in applied_ids
3729        let applied = apply_capabilities(
3730            base_runtime_agent,
3731            &["current_time".to_string(), "noop".to_string()],
3732            &registry,
3733            &test_ctx(),
3734        )
3735        .await;
3736
3737        assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3738    }
3739
3740    #[tokio::test]
3741    async fn test_apply_capabilities_test_math() {
3742        let registry = CapabilityRegistry::with_builtins();
3743        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3744
3745        let applied = apply_capabilities(
3746            base_runtime_agent.clone(),
3747            &["test_math".to_string()],
3748            &registry,
3749            &test_ctx(),
3750        )
3751        .await;
3752
3753        // TestMath has no system prompt addition (tool defs are sufficient)
3754        assert!(
3755            !applied
3756                .runtime_agent
3757                .system_prompt
3758                .contains("<capability id=\"test_math\">")
3759        );
3760        // No capability prompt prefix, so base prompt is used as-is (no XML wrapping)
3761        assert!(
3762            applied
3763                .runtime_agent
3764                .system_prompt
3765                .contains("You are a helpful assistant.")
3766        );
3767        assert!(applied.tool_registry.has("add"));
3768        assert!(applied.tool_registry.has("subtract"));
3769        assert!(applied.tool_registry.has("multiply"));
3770        assert!(applied.tool_registry.has("divide"));
3771        assert_eq!(applied.tool_registry.len(), 4);
3772    }
3773
3774    #[tokio::test]
3775    async fn test_apply_capabilities_test_weather() {
3776        let registry = CapabilityRegistry::with_builtins();
3777        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3778
3779        let applied = apply_capabilities(
3780            base_runtime_agent.clone(),
3781            &["test_weather".to_string()],
3782            &registry,
3783            &test_ctx(),
3784        )
3785        .await;
3786
3787        // TestWeather has no system prompt addition (tool defs are sufficient)
3788        assert!(
3789            !applied
3790                .runtime_agent
3791                .system_prompt
3792                .contains("<capability id=\"test_weather\">")
3793        );
3794        assert!(applied.tool_registry.has("get_weather"));
3795        assert!(applied.tool_registry.has("get_forecast"));
3796        assert_eq!(applied.tool_registry.len(), 2);
3797    }
3798
3799    #[tokio::test]
3800    async fn test_apply_capabilities_test_math_and_test_weather() {
3801        let registry = CapabilityRegistry::with_builtins();
3802        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3803
3804        let applied = apply_capabilities(
3805            base_runtime_agent.clone(),
3806            &["test_math".to_string(), "test_weather".to_string()],
3807            &registry,
3808            &test_ctx(),
3809        )
3810        .await;
3811
3812        // Should have both sets of tools
3813        assert_eq!(applied.tool_registry.len(), 6); // 4 math + 2 weather
3814        assert!(applied.tool_registry.has("add"));
3815        assert!(applied.tool_registry.has("get_weather"));
3816    }
3817
3818    #[tokio::test]
3819    async fn test_apply_capabilities_stateless_todo_list() {
3820        let registry = CapabilityRegistry::with_builtins();
3821        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3822
3823        let applied = apply_capabilities(
3824            base_runtime_agent.clone(),
3825            &["stateless_todo_list".to_string()],
3826            &registry,
3827            &test_ctx(),
3828        )
3829        .await;
3830
3831        // StatelessTodoList has system prompt addition and 1 tool
3832        assert!(
3833            applied
3834                .runtime_agent
3835                .system_prompt
3836                .contains("Task Management")
3837        );
3838        assert!(applied.runtime_agent.system_prompt.contains("write_todos"));
3839        assert!(applied.tool_registry.has("write_todos"));
3840        assert_eq!(applied.tool_registry.len(), 1);
3841    }
3842
3843    #[tokio::test]
3844    async fn test_apply_capabilities_web_fetch() {
3845        let registry = CapabilityRegistry::with_builtins();
3846        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3847
3848        let applied = apply_capabilities(
3849            base_runtime_agent.clone(),
3850            &["web_fetch".to_string()],
3851            &registry,
3852            &test_ctx(),
3853        )
3854        .await;
3855
3856        // WebFetch has system prompt from fetchkit's TOOL_LLMTXT and 1 tool
3857        assert!(
3858            applied
3859                .runtime_agent
3860                .system_prompt
3861                .contains(&base_runtime_agent.system_prompt)
3862        );
3863        assert!(applied.runtime_agent.system_prompt.contains("web_fetch"));
3864        assert!(applied.tool_registry.has("web_fetch"));
3865        assert_eq!(applied.tool_registry.len(), 1);
3866    }
3867
3868    // =========================================================================
3869    // XML prompt formatting tests
3870    // =========================================================================
3871
3872    #[tokio::test]
3873    async fn test_xml_tags_wrap_capability_prompts() {
3874        let registry = CapabilityRegistry::with_builtins();
3875        let collected =
3876            collect_capabilities(&["stateless_todo_list".to_string()], &registry, &test_ctx())
3877                .await;
3878
3879        assert_eq!(collected.system_prompt_parts.len(), 1);
3880        let part = &collected.system_prompt_parts[0];
3881        assert!(part.starts_with("<capability id=\"stateless_todo_list\">"));
3882        assert!(part.ends_with("</capability>"));
3883        assert!(part.contains("Task Management"));
3884    }
3885
3886    #[tokio::test]
3887    async fn test_xml_tags_multiple_capabilities() {
3888        let registry = CapabilityRegistry::with_builtins();
3889        let collected = collect_capabilities(
3890            &[
3891                "stateless_todo_list".to_string(),
3892                "session_schedule".to_string(),
3893            ],
3894            &registry,
3895            &test_ctx(),
3896        )
3897        .await;
3898
3899        assert_eq!(collected.system_prompt_parts.len(), 2);
3900        assert!(
3901            collected.system_prompt_parts[0].starts_with("<capability id=\"stateless_todo_list\">")
3902        );
3903        assert!(
3904            collected.system_prompt_parts[1].starts_with("<capability id=\"session_schedule\">")
3905        );
3906
3907        let prefix = collected.system_prompt_prefix().unwrap();
3908        // Both capability sections separated by double newline
3909        assert!(prefix.contains("</capability>\n\n<capability"));
3910    }
3911
3912    #[tokio::test]
3913    async fn test_xml_tags_system_prompt_wrapping() {
3914        let registry = CapabilityRegistry::with_builtins();
3915        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3916
3917        let applied = apply_capabilities(
3918            base,
3919            &["stateless_todo_list".to_string()],
3920            &registry,
3921            &test_ctx(),
3922        )
3923        .await;
3924
3925        let prompt = &applied.runtime_agent.system_prompt;
3926        assert!(prompt.starts_with("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3927        // Capability wrapped
3928        assert!(prompt.contains("<capability id=\"stateless_todo_list\">"));
3929        assert!(prompt.contains("</capability>"));
3930        // Base prompt wrapped
3931        assert!(prompt.contains("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3932    }
3933
3934    #[tokio::test]
3935    async fn test_no_xml_wrapping_without_capabilities() {
3936        let registry = CapabilityRegistry::with_builtins();
3937        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3938
3939        let applied = apply_capabilities(base, &[], &registry, &test_ctx()).await;
3940
3941        // No capabilities = no XML wrapping (plain base prompt)
3942        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3943        assert!(
3944            !applied
3945                .runtime_agent
3946                .system_prompt
3947                .contains("<system-prompt>")
3948        );
3949    }
3950
3951    #[tokio::test]
3952    async fn test_no_xml_wrapping_for_noop_capability() {
3953        let registry = CapabilityRegistry::with_builtins();
3954        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3955
3956        // Noop has no system_prompt_addition, so no XML wrapping should occur
3957        let applied = apply_capabilities(base, &["noop".to_string()], &registry, &test_ctx()).await;
3958
3959        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3960        assert!(
3961            !applied
3962                .runtime_agent
3963                .system_prompt
3964                .contains("<system-prompt>")
3965        );
3966    }
3967
3968    // =========================================================================
3969    // Mount collection tests
3970    // =========================================================================
3971
3972    #[tokio::test]
3973    async fn test_collect_capabilities_includes_mounts() {
3974        let registry = CapabilityRegistry::with_builtins();
3975
3976        let collected =
3977            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
3978
3979        assert!(!collected.mounts.is_empty());
3980        assert_eq!(collected.mounts.len(), 1);
3981        assert_eq!(collected.mounts[0].path, "/samples");
3982        assert!(collected.mounts[0].is_readonly());
3983    }
3984
3985    #[tokio::test]
3986    async fn test_collect_capabilities_empty_mounts_by_default() {
3987        let registry = CapabilityRegistry::with_builtins();
3988
3989        // Most capabilities don't have mounts
3990        let collected =
3991            collect_capabilities(&["current_time".to_string()], &registry, &test_ctx()).await;
3992
3993        assert!(collected.mounts.is_empty());
3994    }
3995
3996    #[tokio::test]
3997    async fn test_dynamic_facts_add_note_without_static_block() {
3998        // `current_time` contributes a Dynamic fact, so the cached prompt gets
3999        // the explanatory note but NOT a static `<facts>` block (the live value
4000        // is appended at the conversation tail per request instead).
4001        let registry = CapabilityRegistry::with_builtins();
4002        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
4003        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4004        let prompt = collected.system_prompt_parts.join("\n");
4005        assert!(
4006            prompt.contains(FACTS_DYNAMIC_NOTE),
4007            "dynamic-facts note should be in the cached prompt"
4008        );
4009        assert!(
4010            !prompt.contains("<facts>\n"),
4011            "no static <facts> block for a purely-dynamic fact; got: {prompt}"
4012        );
4013    }
4014
4015    #[tokio::test]
4016    async fn test_static_facts_fold_into_prompt() {
4017        struct StaticFactCap;
4018        impl Capability for StaticFactCap {
4019            fn id(&self) -> &str {
4020                "test_static_fact"
4021            }
4022            fn name(&self) -> &str {
4023                "Static Fact"
4024            }
4025            fn description(&self) -> &str {
4026                "test"
4027            }
4028            fn status(&self) -> CapabilityStatus {
4029                CapabilityStatus::Available
4030            }
4031            fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
4032                vec![Fact::stat("workspace_root", "/workspace")]
4033            }
4034        }
4035        let mut registry = CapabilityRegistry::new();
4036        registry.register(StaticFactCap);
4037        let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
4038        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4039        let prompt = collected.system_prompt_parts.join("\n");
4040        assert!(
4041            prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
4042            "static fact should fold into the cached prompt; got: {prompt}"
4043        );
4044        assert!(
4045            !prompt.contains(FACTS_DYNAMIC_NOTE),
4046            "no dynamic note when only static facts exist"
4047        );
4048    }
4049
4050    #[test]
4051    fn test_collect_dynamic_facts_returns_current_time() {
4052        let registry = CapabilityRegistry::with_builtins();
4053        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
4054        let facts = collect_dynamic_facts(
4055            &configs,
4056            &registry,
4057            None,
4058            &FactsContext::new(SessionId::new()),
4059        );
4060        assert_eq!(facts.len(), 1);
4061        assert_eq!(facts[0].key, "current_time");
4062        assert_eq!(facts[0].volatility, Volatility::Dynamic);
4063    }
4064
4065    #[tokio::test]
4066    async fn test_collect_capabilities_combines_mounts() {
4067        let registry = CapabilityRegistry::with_builtins();
4068
4069        // Collect from multiple capabilities - only sample_data has mounts.
4070        // sample_data depends on session_file_system, which is auto-resolved.
4071        let collected = collect_capabilities(
4072            &["sample_data".to_string(), "current_time".to_string()],
4073            &registry,
4074            &test_ctx(),
4075        )
4076        .await;
4077
4078        assert_eq!(collected.mounts.len(), 1);
4079        // Verify expected capabilities were applied (including auto-resolved dependency)
4080        assert!(
4081            collected
4082                .applied_ids
4083                .iter()
4084                .any(|id| id == "session_file_system")
4085        );
4086        assert!(collected.applied_ids.iter().any(|id| id == "sample_data"));
4087        assert!(collected.applied_ids.iter().any(|id| id == "current_time"));
4088    }
4089
4090    #[test]
4091    fn test_sample_data_capability() {
4092        let registry = CapabilityRegistry::with_builtins();
4093        let cap = registry.get("sample_data").unwrap();
4094
4095        assert_eq!(cap.id(), "sample_data");
4096        assert_eq!(cap.name(), "Sample Data");
4097        assert_eq!(cap.status(), CapabilityStatus::Available);
4098
4099        // Has system prompt but no tools
4100        assert!(cap.system_prompt_addition().is_some());
4101        assert!(cap.tools().is_empty());
4102
4103        // Has mounts
4104        assert!(!cap.mounts().is_empty());
4105    }
4106
4107    // =========================================================================
4108    // Dependency resolution tests
4109    // =========================================================================
4110
4111    #[test]
4112    fn test_resolve_dependencies_empty() {
4113        let registry = CapabilityRegistry::with_builtins();
4114
4115        let resolved = resolve_dependencies(&[], &registry).unwrap();
4116
4117        assert!(resolved.resolved_ids.is_empty());
4118        assert!(resolved.added_as_dependencies.is_empty());
4119        assert!(resolved.user_selected.is_empty());
4120    }
4121
4122    #[test]
4123    fn test_resolve_dependencies_no_deps() {
4124        let registry = CapabilityRegistry::with_builtins();
4125
4126        // CurrentTime has no dependencies
4127        let resolved = resolve_dependencies(&["current_time".to_string()], &registry).unwrap();
4128
4129        assert_eq!(resolved.resolved_ids, vec!["current_time"]);
4130        assert!(resolved.added_as_dependencies.is_empty());
4131    }
4132
4133    #[test]
4134    fn test_resolve_dependencies_with_deps() {
4135        let registry = CapabilityRegistry::with_builtins();
4136
4137        // SampleData depends on FileSystem
4138        let resolved = resolve_dependencies(&["sample_data".to_string()], &registry).unwrap();
4139
4140        // FileSystem should be resolved before SampleData
4141        assert_eq!(resolved.resolved_ids.len(), 2);
4142        let fs_pos = resolved
4143            .resolved_ids
4144            .iter()
4145            .position(|id| id == "session_file_system")
4146            .unwrap();
4147        let sd_pos = resolved
4148            .resolved_ids
4149            .iter()
4150            .position(|id| id == "sample_data")
4151            .unwrap();
4152        assert!(fs_pos < sd_pos, "FileSystem should come before SampleData");
4153
4154        // FileSystem was added as a dependency
4155        assert_eq!(resolved.added_as_dependencies, vec!["session_file_system"]);
4156    }
4157
4158    #[test]
4159    fn test_resolve_dependencies_already_selected() {
4160        let registry = CapabilityRegistry::with_builtins();
4161
4162        // If dependency is already selected, it shouldn't be duplicated
4163        let resolved = resolve_dependencies(
4164            &["session_file_system".to_string(), "sample_data".to_string()],
4165            &registry,
4166        )
4167        .unwrap();
4168
4169        assert_eq!(resolved.resolved_ids.len(), 2);
4170        // FileSystem was user-selected, not added as dependency
4171        assert!(resolved.added_as_dependencies.is_empty());
4172    }
4173
4174    #[test]
4175    fn test_resolve_dependencies_preserves_order() {
4176        let registry = CapabilityRegistry::with_builtins();
4177
4178        // Multiple independent capabilities should maintain their relative order
4179        let resolved =
4180            resolve_dependencies(&["current_time".to_string(), "noop".to_string()], &registry)
4181                .unwrap();
4182
4183        assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
4184    }
4185
4186    #[test]
4187    fn test_resolve_dependencies_unknown_capability() {
4188        let registry = CapabilityRegistry::with_builtins();
4189
4190        // Unknown capabilities are silently skipped
4191        let resolved =
4192            resolve_dependencies(&["unknown_capability".to_string()], &registry).unwrap();
4193
4194        assert!(resolved.resolved_ids.is_empty());
4195    }
4196
4197    #[test]
4198    fn test_get_dependencies() {
4199        let registry = CapabilityRegistry::with_builtins();
4200
4201        // SampleData depends on FileSystem
4202        let deps = get_dependencies("sample_data", &registry);
4203        assert_eq!(deps, vec!["session_file_system"]);
4204
4205        // CurrentTime has no dependencies
4206        let deps = get_dependencies("current_time", &registry);
4207        assert!(deps.is_empty());
4208
4209        // Unknown capability
4210        let deps = get_dependencies("unknown", &registry);
4211        assert!(deps.is_empty());
4212    }
4213
4214    #[test]
4215    fn test_sample_data_has_dependency() {
4216        let registry = CapabilityRegistry::with_builtins();
4217        let cap = registry.get("sample_data").unwrap();
4218
4219        let deps = cap.dependencies();
4220        assert_eq!(deps.len(), 1);
4221        assert_eq!(deps[0], "session_file_system");
4222    }
4223
4224    #[test]
4225    fn test_noop_has_no_dependencies() {
4226        let registry = CapabilityRegistry::with_builtins();
4227        let cap = registry.get("noop").unwrap();
4228
4229        assert!(cap.dependencies().is_empty());
4230    }
4231
4232    // Test for circular dependency detection
4233    // Note: We can't easily test this with built-in capabilities since they don't have cycles.
4234    // This test uses a custom registry to create a cycle.
4235    #[test]
4236    fn test_circular_dependency_error() {
4237        // Create capabilities that form a cycle: A -> B -> A
4238        struct CapA;
4239        struct CapB;
4240
4241        impl Capability for CapA {
4242            fn id(&self) -> &str {
4243                "test_cap_a"
4244            }
4245            fn name(&self) -> &str {
4246                "Test A"
4247            }
4248            fn description(&self) -> &str {
4249                "Test capability A"
4250            }
4251            fn dependencies(&self) -> Vec<&'static str> {
4252                vec!["test_cap_b"]
4253            }
4254        }
4255
4256        impl Capability for CapB {
4257            fn id(&self) -> &str {
4258                "test_cap_b"
4259            }
4260            fn name(&self) -> &str {
4261                "Test B"
4262            }
4263            fn description(&self) -> &str {
4264                "Test capability B"
4265            }
4266            fn dependencies(&self) -> Vec<&'static str> {
4267                vec!["test_cap_a"]
4268            }
4269        }
4270
4271        let mut registry = CapabilityRegistry::new();
4272        registry.register(CapA);
4273        registry.register(CapB);
4274
4275        let result = resolve_dependencies(&["test_cap_a".to_string()], &registry);
4276
4277        assert!(result.is_err());
4278        match result.unwrap_err() {
4279            DependencyError::CircularDependency { capability_id, .. } => {
4280                assert_eq!(capability_id, "test_cap_a");
4281            }
4282            _ => panic!("Expected CircularDependency error"),
4283        }
4284    }
4285
4286    // =========================================================================
4287    // Message filter provider tests
4288    // =========================================================================
4289
4290    use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
4291
4292    /// Test capability that provides a message filter
4293    struct FilterTestCapability {
4294        priority: i32,
4295    }
4296
4297    impl Capability for FilterTestCapability {
4298        fn id(&self) -> &str {
4299            "filter_test"
4300        }
4301        fn name(&self) -> &str {
4302            "Filter Test"
4303        }
4304        fn description(&self) -> &str {
4305            "Test capability with message filter"
4306        }
4307        fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4308            Some(Arc::new(FilterTestProvider {
4309                priority: self.priority,
4310            }))
4311        }
4312    }
4313
4314    struct FilterTestProvider {
4315        priority: i32,
4316    }
4317
4318    impl MessageFilterProvider for FilterTestProvider {
4319        fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4320            // Add a search filter based on config
4321            if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
4322                query
4323                    .filters
4324                    .push(MessageFilter::Search(search.to_string()));
4325            }
4326        }
4327
4328        fn priority(&self) -> i32 {
4329            self.priority
4330        }
4331    }
4332
4333    #[tokio::test]
4334    async fn test_collect_capabilities_with_configs_no_filter_providers() {
4335        let registry = CapabilityRegistry::with_builtins();
4336        let configs = vec![AgentCapabilityConfig {
4337            capability_ref: CapabilityId::new("current_time"),
4338            config: serde_json::json!({}),
4339        }];
4340
4341        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4342
4343        assert!(collected.message_filter_providers.is_empty());
4344        assert!(!collected.has_message_filters());
4345    }
4346
4347    #[tokio::test]
4348    async fn test_collect_capabilities_with_configs_with_filter_provider() {
4349        let mut registry = CapabilityRegistry::new();
4350        registry.register(FilterTestCapability { priority: 0 });
4351
4352        let configs = vec![AgentCapabilityConfig {
4353            capability_ref: CapabilityId::new("filter_test"),
4354            config: serde_json::json!({ "search": "hello" }),
4355        }];
4356
4357        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4358
4359        assert_eq!(collected.message_filter_providers.len(), 1);
4360        assert!(collected.has_message_filters());
4361    }
4362
4363    #[tokio::test]
4364    async fn test_collect_capabilities_with_configs_filter_priority_order() {
4365        // Create capabilities with different priorities
4366        struct HighPriorityCapability;
4367        struct LowPriorityCapability;
4368
4369        impl Capability for HighPriorityCapability {
4370            fn id(&self) -> &str {
4371                "high_priority"
4372            }
4373            fn name(&self) -> &str {
4374                "High Priority"
4375            }
4376            fn description(&self) -> &str {
4377                "Test"
4378            }
4379            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4380                Some(Arc::new(FilterTestProvider { priority: 10 }))
4381            }
4382        }
4383
4384        impl Capability for LowPriorityCapability {
4385            fn id(&self) -> &str {
4386                "low_priority"
4387            }
4388            fn name(&self) -> &str {
4389                "Low Priority"
4390            }
4391            fn description(&self) -> &str {
4392                "Test"
4393            }
4394            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4395                Some(Arc::new(FilterTestProvider { priority: -5 }))
4396            }
4397        }
4398
4399        let mut registry = CapabilityRegistry::new();
4400        registry.register(HighPriorityCapability);
4401        registry.register(LowPriorityCapability);
4402
4403        // Add in order: high priority first, low priority second
4404        let configs = vec![
4405            AgentCapabilityConfig {
4406                capability_ref: CapabilityId::new("high_priority"),
4407                config: serde_json::json!({}),
4408            },
4409            AgentCapabilityConfig {
4410                capability_ref: CapabilityId::new("low_priority"),
4411                config: serde_json::json!({}),
4412            },
4413        ];
4414
4415        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4416
4417        // Should be sorted by priority (lower first)
4418        assert_eq!(collected.message_filter_providers.len(), 2);
4419        assert_eq!(collected.message_filter_providers[0].0.priority(), -5);
4420        assert_eq!(collected.message_filter_providers[1].0.priority(), 10);
4421    }
4422
4423    #[tokio::test]
4424    async fn test_collected_capabilities_apply_message_filters() {
4425        let mut registry = CapabilityRegistry::new();
4426        registry.register(FilterTestCapability { priority: 0 });
4427
4428        let configs = vec![AgentCapabilityConfig {
4429            capability_ref: CapabilityId::new("filter_test"),
4430            config: serde_json::json!({ "search": "test_query" }),
4431        }];
4432
4433        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4434
4435        // Apply filters to a query
4436        let session_id: SessionId = Uuid::now_v7().into();
4437        let mut query = MessageQuery::new(session_id);
4438
4439        collected.apply_message_filters(&mut query);
4440
4441        // Should have added the search filter
4442        assert_eq!(query.filters.len(), 1);
4443        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4444    }
4445
4446    #[tokio::test]
4447    async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4448        struct SearchCapability {
4449            id: &'static str,
4450            search_term: &'static str,
4451            priority: i32,
4452        }
4453
4454        struct SearchProvider {
4455            search_term: &'static str,
4456            priority: i32,
4457        }
4458
4459        impl MessageFilterProvider for SearchProvider {
4460            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4461                query
4462                    .filters
4463                    .push(MessageFilter::Search(self.search_term.to_string()));
4464            }
4465
4466            fn priority(&self) -> i32 {
4467                self.priority
4468            }
4469        }
4470
4471        impl Capability for SearchCapability {
4472            fn id(&self) -> &str {
4473                self.id
4474            }
4475            fn name(&self) -> &str {
4476                "Search"
4477            }
4478            fn description(&self) -> &str {
4479                "Test"
4480            }
4481            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4482                Some(Arc::new(SearchProvider {
4483                    search_term: self.search_term,
4484                    priority: self.priority,
4485                }))
4486            }
4487        }
4488
4489        let mut registry = CapabilityRegistry::new();
4490        registry.register(SearchCapability {
4491            id: "cap_a",
4492            search_term: "alpha",
4493            priority: 5,
4494        });
4495        registry.register(SearchCapability {
4496            id: "cap_b",
4497            search_term: "beta",
4498            priority: 1,
4499        });
4500        registry.register(SearchCapability {
4501            id: "cap_c",
4502            search_term: "gamma",
4503            priority: 10,
4504        });
4505
4506        let configs = vec![
4507            AgentCapabilityConfig {
4508                capability_ref: CapabilityId::new("cap_a"),
4509                config: serde_json::json!({}),
4510            },
4511            AgentCapabilityConfig {
4512                capability_ref: CapabilityId::new("cap_b"),
4513                config: serde_json::json!({}),
4514            },
4515            AgentCapabilityConfig {
4516                capability_ref: CapabilityId::new("cap_c"),
4517                config: serde_json::json!({}),
4518            },
4519        ];
4520
4521        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4522
4523        let session_id: SessionId = Uuid::now_v7().into();
4524        let mut query = MessageQuery::new(session_id);
4525
4526        collected.apply_message_filters(&mut query);
4527
4528        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4529        assert_eq!(query.filters.len(), 3);
4530        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4531        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4532        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4533    }
4534
4535    #[test]
4536    fn test_capability_without_message_filter_returns_none() {
4537        let registry = CapabilityRegistry::with_builtins();
4538
4539        let noop = registry.get("noop").unwrap();
4540        assert!(noop.message_filter_provider().is_none());
4541
4542        let current_time = registry.get("current_time").unwrap();
4543        assert!(current_time.message_filter_provider().is_none());
4544    }
4545
4546    #[tokio::test]
4547    async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4548        let mut registry = CapabilityRegistry::new();
4549        registry.register(FilterTestCapability { priority: 0 });
4550
4551        let test_config = serde_json::json!({
4552            "search": "custom_search",
4553            "extra_field": 42
4554        });
4555
4556        let configs = vec![AgentCapabilityConfig {
4557            capability_ref: CapabilityId::new("filter_test"),
4558            config: test_config.clone(),
4559        }];
4560
4561        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4562
4563        // Verify the config is preserved
4564        assert_eq!(collected.message_filter_providers.len(), 1);
4565        let (_, stored_config) = &collected.message_filter_providers[0];
4566        assert_eq!(*stored_config, test_config);
4567    }
4568
4569    // =========================================================================
4570    // collect_message_filters_only tests
4571    // =========================================================================
4572
4573    #[test]
4574    fn test_collect_message_filters_only_collects_filters() {
4575        let mut registry = CapabilityRegistry::new();
4576        registry.register(FilterTestCapability { priority: 0 });
4577
4578        let configs = vec![AgentCapabilityConfig {
4579            capability_ref: CapabilityId::new("filter_test"),
4580            config: serde_json::json!({ "search": "test_query" }),
4581        }];
4582
4583        let collected = collect_message_filters_only(&configs, &registry);
4584
4585        let session_id: SessionId = Uuid::now_v7().into();
4586        let mut query = MessageQuery::new(session_id);
4587        collected.apply_message_filters(&mut query);
4588
4589        assert_eq!(query.filters.len(), 1);
4590        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4591    }
4592
4593    #[test]
4594    fn test_message_filter_config_injects_compaction_active_for_infinity_context() {
4595        let base = serde_json::json!({ "context_budget_tokens": 1000 });
4596
4597        // Infinity context gets the derived flag only when compaction is enabled.
4598        let with = message_filter_config_for(INFINITY_CONTEXT_CAPABILITY_ID, &base, true);
4599        assert_eq!(with["compaction_active"], serde_json::json!(true));
4600        assert_eq!(with["context_budget_tokens"], serde_json::json!(1000));
4601
4602        let without = message_filter_config_for(INFINITY_CONTEXT_CAPABILITY_ID, &base, false);
4603        assert!(without.get("compaction_active").is_none());
4604
4605        // Other capabilities are never touched.
4606        let other = message_filter_config_for("other", &base, true);
4607        assert!(other.get("compaction_active").is_none());
4608
4609        // A null base is upgraded to an object carrying the flag.
4610        let null_base = message_filter_config_for(
4611            INFINITY_CONTEXT_CAPABILITY_ID,
4612            &serde_json::Value::Null,
4613            true,
4614        );
4615        assert_eq!(null_base["compaction_active"], serde_json::json!(true));
4616    }
4617
4618    #[test]
4619    fn test_infinity_context_defers_to_compaction_end_to_end() {
4620        use crate::message::Message;
4621
4622        let mut registry = CapabilityRegistry::new();
4623        registry.register(InfinityContextCapability);
4624        registry.register(CompactionCapability);
4625
4626        let tight = serde_json::json!({
4627            "context_budget_tokens": 1,
4628            "min_recent_messages": 1
4629        });
4630
4631        // Infinity context alone (tight budget): it trims and injects a notice.
4632        let solo = vec![AgentCapabilityConfig {
4633            capability_ref: CapabilityId::new(INFINITY_CONTEXT_CAPABILITY_ID),
4634            config: tight.clone(),
4635        }];
4636        let mut messages = vec![
4637            Message::user("task"),
4638            Message::assistant("old ".repeat(400)),
4639            Message::user("recent"),
4640        ];
4641        collect_message_filters_only(&solo, &registry).apply_post_load_filters(&mut messages);
4642        assert!(
4643            messages
4644                .iter()
4645                .any(|m| m.text().is_some_and(|t| t.contains("NOT visible"))),
4646            "infinity context alone should trim and notice"
4647        );
4648
4649        // Infinity context + compaction: infinity context defers, no eviction.
4650        let both = vec![
4651            AgentCapabilityConfig {
4652                capability_ref: CapabilityId::new(INFINITY_CONTEXT_CAPABILITY_ID),
4653                config: tight,
4654            },
4655            AgentCapabilityConfig {
4656                capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
4657                config: serde_json::json!({}),
4658            },
4659        ];
4660        let mut messages = vec![
4661            Message::user("task"),
4662            Message::assistant("old ".repeat(400)),
4663            Message::user("recent"),
4664        ];
4665        collect_message_filters_only(&both, &registry).apply_post_load_filters(&mut messages);
4666        assert_eq!(messages.len(), 3, "compaction owns reduction; no eviction");
4667        assert!(
4668            messages
4669                .iter()
4670                .all(|m| !m.text().is_some_and(|t| t.contains("NOT visible"))),
4671            "no hidden-history notice when compaction is the active reducer"
4672        );
4673    }
4674
4675    #[test]
4676    fn test_compaction_is_enabled_detects_compaction() {
4677        let mut registry = CapabilityRegistry::new();
4678        registry.register(CompactionCapability);
4679
4680        let with_compaction = vec![AgentCapabilityConfig {
4681            capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
4682            config: serde_json::json!({}),
4683        }];
4684        assert!(compaction_is_enabled(&with_compaction, &registry));
4685
4686        let without = vec![AgentCapabilityConfig {
4687            capability_ref: CapabilityId::new("current_time"),
4688            config: serde_json::json!({}),
4689        }];
4690        assert!(!compaction_is_enabled(&without, &registry));
4691    }
4692
4693    #[test]
4694    fn test_collect_message_filters_only_skips_unknown_capabilities() {
4695        let registry = CapabilityRegistry::new();
4696
4697        let configs = vec![AgentCapabilityConfig {
4698            capability_ref: CapabilityId::new("nonexistent"),
4699            config: serde_json::json!({}),
4700        }];
4701
4702        let collected = collect_message_filters_only(&configs, &registry);
4703        assert!(collected.message_filter_providers.is_empty());
4704    }
4705
4706    #[test]
4707    fn test_collect_message_filters_only_preserves_priority_order() {
4708        struct PriorityFilterCap {
4709            id: &'static str,
4710            search_term: &'static str,
4711            priority: i32,
4712        }
4713
4714        struct PriorityFilterProvider {
4715            search_term: &'static str,
4716            priority: i32,
4717        }
4718
4719        impl Capability for PriorityFilterCap {
4720            fn id(&self) -> &str {
4721                self.id
4722            }
4723            fn name(&self) -> &str {
4724                self.id
4725            }
4726            fn description(&self) -> &str {
4727                "priority test"
4728            }
4729            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4730                Some(Arc::new(PriorityFilterProvider {
4731                    search_term: self.search_term,
4732                    priority: self.priority,
4733                }))
4734            }
4735        }
4736
4737        impl MessageFilterProvider for PriorityFilterProvider {
4738            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4739                query
4740                    .filters
4741                    .push(MessageFilter::Search(self.search_term.to_string()));
4742            }
4743            fn priority(&self) -> i32 {
4744                self.priority
4745            }
4746        }
4747
4748        let mut registry = CapabilityRegistry::new();
4749        registry.register(PriorityFilterCap {
4750            id: "gamma",
4751            search_term: "gamma",
4752            priority: 10,
4753        });
4754        registry.register(PriorityFilterCap {
4755            id: "alpha",
4756            search_term: "alpha",
4757            priority: 5,
4758        });
4759        registry.register(PriorityFilterCap {
4760            id: "beta",
4761            search_term: "beta",
4762            priority: 1,
4763        });
4764
4765        let configs = vec![
4766            AgentCapabilityConfig {
4767                capability_ref: CapabilityId::new("gamma"),
4768                config: serde_json::json!({}),
4769            },
4770            AgentCapabilityConfig {
4771                capability_ref: CapabilityId::new("alpha"),
4772                config: serde_json::json!({}),
4773            },
4774            AgentCapabilityConfig {
4775                capability_ref: CapabilityId::new("beta"),
4776                config: serde_json::json!({}),
4777            },
4778        ];
4779
4780        let collected = collect_message_filters_only(&configs, &registry);
4781
4782        let session_id: SessionId = Uuid::now_v7().into();
4783        let mut query = MessageQuery::new(session_id);
4784        collected.apply_message_filters(&mut query);
4785
4786        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4787        assert_eq!(query.filters.len(), 3);
4788        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4789        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4790        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4791    }
4792
4793    #[test]
4794    fn test_collect_message_filters_only_post_load_invoked() {
4795        use crate::message::Message;
4796
4797        struct PostLoadCap;
4798        struct PostLoadProvider;
4799
4800        impl Capability for PostLoadCap {
4801            fn id(&self) -> &str {
4802                "post_load_test"
4803            }
4804            fn name(&self) -> &str {
4805                "PostLoad Test"
4806            }
4807            fn description(&self) -> &str {
4808                "test"
4809            }
4810            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4811                Some(Arc::new(PostLoadProvider))
4812            }
4813        }
4814
4815        impl MessageFilterProvider for PostLoadProvider {
4816            fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4817            fn priority(&self) -> i32 {
4818                0
4819            }
4820            fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4821                // Reverse messages to prove post_load was called
4822                messages.reverse();
4823            }
4824        }
4825
4826        let mut registry = CapabilityRegistry::new();
4827        registry.register(PostLoadCap);
4828
4829        let configs = vec![AgentCapabilityConfig {
4830            capability_ref: CapabilityId::new("post_load_test"),
4831            config: serde_json::json!({}),
4832        }];
4833
4834        let collected = collect_message_filters_only(&configs, &registry);
4835
4836        let mut messages = vec![Message::user("first"), Message::user("second")];
4837        collected.apply_post_load_filters(&mut messages);
4838
4839        // post_load reversed the messages
4840        assert_eq!(messages[0].text(), Some("second"));
4841        assert_eq!(messages[1].text(), Some("first"));
4842    }
4843
4844    #[test]
4845    fn test_collect_model_view_providers_respects_compaction_capability_boundary() {
4846        use crate::tool_types::ToolCall;
4847
4848        fn tool_heavy_messages() -> Vec<Message> {
4849            let mut messages = vec![Message::user("inspect files repeatedly")];
4850            for index in 0..9 {
4851                let call_id = format!("call_{index}");
4852                messages.push(Message::assistant_with_tools(
4853                    "",
4854                    vec![ToolCall {
4855                        id: call_id.clone(),
4856                        name: "read_file".to_string(),
4857                        arguments: serde_json::json!({"path": "/workspace/src/lib.rs"}),
4858                    }],
4859                ));
4860                messages.push(Message::tool_result(
4861                    call_id,
4862                    Some(serde_json::json!({
4863                        "path": "/workspace/src/lib.rs",
4864                        "content": format!("{}{}", "large file line\n".repeat(1000), index),
4865                        "total_lines": 1000,
4866                        "lines_shown": {"start": 1, "end": 1000},
4867                        "truncated": false
4868                    })),
4869                    None,
4870                ));
4871            }
4872            messages
4873        }
4874
4875        fn first_tool_result_is_masked(messages: &[Message]) -> bool {
4876            messages[2]
4877                .tool_result_content()
4878                .and_then(|result| result.result.as_ref())
4879                .and_then(|result| result.get("masked"))
4880                .and_then(|masked| masked.as_bool())
4881                .unwrap_or(false)
4882        }
4883
4884        let mut registry = CapabilityRegistry::new();
4885        registry.register(CompactionCapability);
4886        let context = ModelViewContext {
4887            session_id: SessionId::new(),
4888            prior_usage: None,
4889        };
4890
4891        let no_compaction = collect_model_view_providers(&[], &registry, None);
4892        let unmasked = no_compaction.apply_model_view(tool_heavy_messages(), &context);
4893        assert!(!first_tool_result_is_masked(&unmasked));
4894
4895        let compaction = collect_model_view_providers(
4896            &[AgentCapabilityConfig {
4897                capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
4898                config: serde_json::json!({}),
4899            }],
4900            &registry,
4901            None,
4902        );
4903        let masked = compaction.apply_model_view(tool_heavy_messages(), &context);
4904        assert!(first_tool_result_is_masked(&masked));
4905        let last_tool = masked.last().unwrap().tool_result_content().unwrap();
4906        assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
4907    }
4908
4909    // Tests for resolve_for_model delegation in fast-path collectors
4910
4911    struct DelegatingFilterCap {
4912        id: &'static str,
4913        inner: std::sync::Arc<InnerFilterCap>,
4914    }
4915    struct InnerFilterCap;
4916
4917    impl Capability for InnerFilterCap {
4918        fn id(&self) -> &str {
4919            "inner_filter"
4920        }
4921        fn name(&self) -> &str {
4922            "Inner Filter"
4923        }
4924        fn description(&self) -> &str {
4925            "inner"
4926        }
4927        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4928            Some(std::sync::Arc::new(SentinelFilter))
4929        }
4930    }
4931    struct SentinelFilter;
4932    impl MessageFilterProvider for SentinelFilter {
4933        fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4934    }
4935    impl Capability for DelegatingFilterCap {
4936        fn id(&self) -> &str {
4937            self.id
4938        }
4939        fn name(&self) -> &str {
4940            "Delegating Filter"
4941        }
4942        fn description(&self) -> &str {
4943            "delegating"
4944        }
4945        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4946            None // outer provides nothing
4947        }
4948        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4949            Some(&*self.inner)
4950        }
4951    }
4952
4953    #[test]
4954    fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4955        let inner = std::sync::Arc::new(InnerFilterCap);
4956        let outer = DelegatingFilterCap {
4957            id: "delegating_filter",
4958            inner: inner.clone(),
4959        };
4960
4961        let mut registry = CapabilityRegistry::new();
4962        registry.register(outer);
4963
4964        let configs = vec![AgentCapabilityConfig {
4965            capability_ref: CapabilityId::new("delegating_filter"),
4966            config: serde_json::json!({}),
4967        }];
4968
4969        // Outer has no message_filter_provider; inner does. resolve_for_model
4970        // delegates to inner so the provider should be collected.
4971        let collected = collect_message_filters_only(&configs, &registry);
4972        assert_eq!(
4973            collected.message_filter_providers.len(),
4974            1,
4975            "provider from resolved inner capability must be collected"
4976        );
4977    }
4978
4979    struct DelegatingMvpCap {
4980        id: &'static str,
4981        inner: std::sync::Arc<InnerMvpCap>,
4982    }
4983    struct InnerMvpCap;
4984
4985    impl Capability for InnerMvpCap {
4986        fn id(&self) -> &str {
4987            "inner_mvp"
4988        }
4989        fn name(&self) -> &str {
4990            "Inner MVP"
4991        }
4992        fn description(&self) -> &str {
4993            "inner"
4994        }
4995        fn model_view_provider(
4996            &self,
4997        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4998            // Return a no-op provider to prove delegation reached here.
4999            struct NoopMvp;
5000            impl crate::capabilities::ModelViewProvider for NoopMvp {
5001                fn apply_model_view(
5002                    &self,
5003                    messages: Vec<Message>,
5004                    _config: &serde_json::Value,
5005                    _context: &ModelViewContext<'_>,
5006                ) -> Vec<Message> {
5007                    messages
5008                }
5009            }
5010            Some(std::sync::Arc::new(NoopMvp))
5011        }
5012    }
5013    impl Capability for DelegatingMvpCap {
5014        fn id(&self) -> &str {
5015            self.id
5016        }
5017        fn name(&self) -> &str {
5018            "Delegating MVP"
5019        }
5020        fn description(&self) -> &str {
5021            "delegating"
5022        }
5023        fn model_view_provider(
5024            &self,
5025        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
5026            None // outer provides nothing
5027        }
5028        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
5029            Some(&*self.inner)
5030        }
5031    }
5032
5033    #[test]
5034    fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
5035        let inner = std::sync::Arc::new(InnerMvpCap);
5036        let outer = DelegatingMvpCap {
5037            id: "delegating_mvp",
5038            inner: inner.clone(),
5039        };
5040
5041        let mut registry = CapabilityRegistry::new();
5042        registry.register(outer);
5043
5044        let configs = vec![AgentCapabilityConfig {
5045            capability_ref: CapabilityId::new("delegating_mvp"),
5046            config: serde_json::json!({}),
5047        }];
5048
5049        // Outer has no model_view_provider; inner does. resolve_for_model
5050        // delegates to inner so the provider should be collected.
5051        let collected = collect_model_view_providers(&configs, &registry, None);
5052        assert_eq!(
5053            collected.model_view_providers.len(),
5054            1,
5055            "provider from resolved inner capability must be collected"
5056        );
5057    }
5058
5059    // =========================================================================
5060    // Harness capability tool registration tests
5061    //
5062    // Regression tests for the "Tool not found: bash" bug where harness
5063    // capabilities were not used for tool registration when agent_id was absent.
5064    // These tests verify that capability-provided tools (especially bash) are
5065    // correctly produced by collect_capabilities.
5066    // =========================================================================
5067
5068    #[tokio::test]
5069    async fn test_bashkit_shell_capability_produces_bash_tool() {
5070        let registry = CapabilityRegistry::with_builtins();
5071        let collected =
5072            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
5073
5074        let tool_names: Vec<&str> = collected
5075            .tool_definitions
5076            .iter()
5077            .map(|t| t.name())
5078            .collect();
5079        assert!(
5080            tool_names.contains(&"bash"),
5081            "bashkit_shell capability must produce 'bash' tool, got: {:?}",
5082            tool_names
5083        );
5084        assert!(
5085            !collected.tools.is_empty(),
5086            "bashkit_shell must provide tool implementations"
5087        );
5088    }
5089
5090    #[tokio::test]
5091    async fn test_generic_harness_capability_set_produces_bash_tool() {
5092        // These are the exact capability IDs from the Generic Harness seed data.
5093        // If any are renamed or removed, this test catches the regression.
5094        let generic_harness_caps = vec![
5095            "session_file_system".to_string(),
5096            "bashkit_shell".to_string(),
5097            "web_fetch".to_string(),
5098            "session_storage".to_string(),
5099            "session".to_string(),
5100            "agent_instructions".to_string(),
5101            "skills".to_string(),
5102            "infinity_context".to_string(),
5103            "auto_tool_search".to_string(),
5104        ];
5105
5106        let registry = CapabilityRegistry::with_builtins();
5107        let collected = collect_capabilities(&generic_harness_caps, &registry, &test_ctx()).await;
5108
5109        let tool_names: Vec<&str> = collected
5110            .tool_definitions
5111            .iter()
5112            .map(|t| t.name())
5113            .collect();
5114        assert!(
5115            tool_names.contains(&"bash"),
5116            "Generic Harness capabilities must produce 'bash' tool, got: {:?}",
5117            tool_names
5118        );
5119    }
5120
5121    #[tokio::test]
5122    async fn test_collect_capabilities_tool_count_matches_definitions() {
5123        // Ensure collected tools (implementations) match tool_definitions count.
5124        // A mismatch means some tools won't be executable at runtime.
5125        let registry = CapabilityRegistry::with_builtins();
5126        let collected =
5127            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
5128
5129        assert_eq!(
5130            collected.tools.len(),
5131            collected.tool_definitions.len(),
5132            "tool implementations ({}) must match tool definitions ({})",
5133            collected.tools.len(),
5134            collected.tool_definitions.len(),
5135        );
5136    }
5137
5138    /// Regression test for EVE-189: collect_capabilities must resolve dependencies
5139    /// so that transitive capabilities register their tools even when not explicitly
5140    /// listed. Uses sample_data (depends on session_file_system) as the test case.
5141    #[tokio::test]
5142    async fn test_collect_capabilities_resolves_dependencies() {
5143        // sample_data depends on session_file_system
5144        // Passing only sample_data should still include session_file_system tools
5145        let registry = CapabilityRegistry::with_builtins();
5146        let collected =
5147            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
5148
5149        // Verify the transitive dependency capability itself was applied
5150        assert!(
5151            collected
5152                .applied_ids
5153                .iter()
5154                .any(|id| id == "session_file_system"),
5155            "collect_capabilities must apply session_file_system as a dependency; applied_ids: {:?}",
5156            collected.applied_ids
5157        );
5158
5159        let tool_names: Vec<&str> = collected
5160            .tool_definitions
5161            .iter()
5162            .map(|t| t.name())
5163            .collect();
5164
5165        // session_file_system provides these tools; both should be present
5166        assert!(
5167            tool_names.contains(&"read_file") && tool_names.contains(&"write_file"),
5168            "collect_capabilities must resolve dependencies and include dependency tools, got: {:?}",
5169            tool_names
5170        );
5171
5172        // Also verify tool implementations match definitions (dependency tools are executable)
5173        assert_eq!(
5174            collected.tools.len(),
5175            collected.tool_definitions.len(),
5176            "dependency-added tools must have implementations, not just definitions"
5177        );
5178    }
5179
5180    #[test]
5181    fn test_defaults_do_not_include_bash() {
5182        // ToolRegistry::with_defaults() must NOT include bash — it comes from
5183        // capabilities only. This documents the invariant that the bug violated.
5184        let registry = crate::ToolRegistry::with_defaults();
5185        assert!(
5186            !registry.has("bash"),
5187            "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
5188        );
5189    }
5190
5191    // =========================================================================
5192    // EVE-501: background_execution auto-activation
5193    // =========================================================================
5194
5195    /// Auto-activation: any collected tool with `supports_background=true`
5196    /// causes `spawn_background` to appear in both tool_definitions and tools.
5197    #[tokio::test]
5198    async fn test_background_execution_auto_activates_with_bashkit_shell() {
5199        let registry = CapabilityRegistry::with_builtins();
5200        let collected =
5201            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
5202
5203        let tool_names: Vec<&str> = collected
5204            .tool_definitions
5205            .iter()
5206            .map(|t| t.name())
5207            .collect();
5208        assert!(
5209            tool_names.contains(&"spawn_background"),
5210            "spawn_background must be auto-activated when bashkit_shell (a \
5211             background-capable tool) is in the agent's capability set; got: {:?}",
5212            tool_names
5213        );
5214        assert!(
5215            collected
5216                .applied_ids
5217                .iter()
5218                .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID),
5219            "background_execution must be in applied_ids when auto-activated; \
5220             got: {:?}",
5221            collected.applied_ids
5222        );
5223
5224        // Lockstep: implementations match definitions (executable in the worker).
5225        assert!(
5226            collected
5227                .tools
5228                .iter()
5229                .any(|t| t.name() == "spawn_background"),
5230            "spawn_background tool implementation must be present alongside the \
5231             definition (lockstep contract)"
5232        );
5233    }
5234
5235    /// Negative: when no collected tool declares background support, the
5236    /// capability must NOT auto-activate.
5237    #[tokio::test]
5238    async fn test_background_execution_does_not_auto_activate_without_hint() {
5239        let registry = CapabilityRegistry::with_builtins();
5240        // current_time has no background-capable tool.
5241        let collected =
5242            collect_capabilities(&["current_time".to_string()], &registry, &test_ctx()).await;
5243
5244        let tool_names: Vec<&str> = collected
5245            .tool_definitions
5246            .iter()
5247            .map(|t| t.name())
5248            .collect();
5249        assert!(
5250            !tool_names.contains(&"spawn_background"),
5251            "spawn_background must NOT be activated without a background-capable \
5252             tool; got: {:?}",
5253            tool_names
5254        );
5255        assert!(
5256            !collected
5257                .applied_ids
5258                .iter()
5259                .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID),
5260            "background_execution must not appear in applied_ids when no \
5261             background-capable tool is present; got: {:?}",
5262            collected.applied_ids
5263        );
5264    }
5265
5266    #[tokio::test]
5267    async fn test_subagents_collect_unified_spawn_agent_adapter() {
5268        let registry = CapabilityRegistry::with_builtins();
5269        let collected = collect_capabilities(
5270            &[SUBAGENTS_CAPABILITY_ID.to_string()],
5271            &registry,
5272            &test_ctx(),
5273        )
5274        .await;
5275
5276        assert!(
5277            collected
5278                .tools
5279                .iter()
5280                .any(|tool| tool.name() == "spawn_agent"),
5281            "subagent-only sessions should get the unified spawn_agent adapter"
5282        );
5283        let spawn_agent = collected
5284            .tool_definitions
5285            .iter()
5286            .find(|tool| tool.name() == "spawn_agent")
5287            .expect("spawn_agent definition");
5288        assert_eq!(
5289            spawn_agent.parameters()["properties"]["target"]["properties"]["type"]["enum"],
5290            serde_json::json!(["subagent"])
5291        );
5292        assert_eq!(
5293            spawn_agent.concurrency_class(),
5294            Some(SPAWN_AGENT_CONCURRENCY_CLASS),
5295            "unified spawn_agent must serialize same-batch spawns before cap checks"
5296        );
5297    }
5298
5299    #[tokio::test]
5300    async fn test_agent_handoff_collects_unified_spawn_agent_adapter() {
5301        let mut registry = CapabilityRegistry::new();
5302        registry.register(AgentHandoffCapability);
5303        let agent_id = crate::typed_id::AgentId::new();
5304        let harness_id = crate::typed_id::HarnessId::new();
5305        let configs = vec![AgentCapabilityConfig {
5306            capability_ref: CapabilityId::new(AGENT_HANDOFF_CAPABILITY_ID),
5307            config: serde_json::json!({
5308                "targets": [{
5309                    "id": "aws_operator",
5310                    "name": "AWS Operator",
5311                    "agent_id": agent_id,
5312                    "harness_id": harness_id
5313                }]
5314            }),
5315        }];
5316        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5317
5318        assert!(
5319            collected
5320                .tools
5321                .iter()
5322                .any(|tool| tool.name() == "spawn_agent"),
5323            "agent_handoff-only sessions should get the unified spawn_agent adapter"
5324        );
5325        let spawn_agent = collected
5326            .tool_definitions
5327            .iter()
5328            .find(|tool| tool.name() == "spawn_agent")
5329            .expect("spawn_agent definition");
5330        assert_eq!(
5331            spawn_agent.parameters()["properties"]["target"]["properties"]["type"]["enum"],
5332            serde_json::json!(["agent"])
5333        );
5334    }
5335
5336    #[tokio::test]
5337    async fn test_spawn_agent_dispatcher_combines_known_target_providers() {
5338        let mut registry = CapabilityRegistry::new();
5339        registry.register(SubagentCapability);
5340        registry.register(AgentHandoffCapability);
5341
5342        let agent_id = crate::typed_id::AgentId::new();
5343        let harness_id = crate::typed_id::HarnessId::new();
5344        let configs = vec![
5345            AgentCapabilityConfig {
5346                capability_ref: CapabilityId::new(SUBAGENTS_CAPABILITY_ID),
5347                config: serde_json::json!({}),
5348            },
5349            AgentCapabilityConfig {
5350                capability_ref: CapabilityId::new(AGENT_HANDOFF_CAPABILITY_ID),
5351                config: serde_json::json!({
5352                    "targets": [{
5353                        "id": "aws_operator",
5354                        "name": "AWS Operator",
5355                        "agent_id": agent_id,
5356                        "harness_id": harness_id
5357                    }]
5358                }),
5359            },
5360        ];
5361
5362        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5363        let spawn_agent_defs: Vec<_> = collected
5364            .tool_definitions
5365            .iter()
5366            .filter(|tool| tool.name() == "spawn_agent")
5367            .collect();
5368
5369        assert_eq!(spawn_agent_defs.len(), 1);
5370        let schema = spawn_agent_defs[0].parameters();
5371        assert_eq!(
5372            schema["properties"]["target"]["properties"]["type"]["enum"],
5373            serde_json::json!(["subagent", "agent"])
5374        );
5375        // Anthropic rejects top-level oneOf/allOf/anyOf in input_schema, so
5376        // the per-target constraints must live inside the target property.
5377        assert!(schema.get("oneOf").is_none());
5378        assert!(schema.get("anyOf").is_none());
5379        assert!(schema.get("allOf").is_none());
5380        assert_eq!(
5381            schema["required"],
5382            serde_json::json!(["name", "instructions", "target"])
5383        );
5384        assert_eq!(
5385            schema["properties"]["target"]["oneOf"],
5386            serde_json::json!([
5387                {
5388                    "properties": {"type": {"const": "subagent"}}
5389                },
5390                {
5391                    "properties": {"type": {"const": "agent"}},
5392                    "required": ["type", "id"]
5393                }
5394            ])
5395        );
5396    }
5397
5398    #[cfg(feature = "a2a")]
5399    #[tokio::test]
5400    async fn test_spawn_agent_dispatcher_includes_external_a2a_provider() {
5401        let mut registry = CapabilityRegistry::new();
5402        registry.register(SubagentCapability);
5403        registry.register(A2aAgentDelegationCapability);
5404
5405        let configs = vec![
5406            AgentCapabilityConfig {
5407                capability_ref: CapabilityId::new(SUBAGENTS_CAPABILITY_ID),
5408                config: serde_json::json!({}),
5409            },
5410            AgentCapabilityConfig {
5411                capability_ref: CapabilityId::new(A2A_AGENT_DELEGATION_CAPABILITY_ID),
5412                config: serde_json::json!({
5413                    "agents": [{
5414                        "id": "local_app",
5415                        "name": "Local App",
5416                        "base_url": "https://example.com"
5417                    }]
5418                }),
5419            },
5420        ];
5421
5422        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5423        let spawn_agent_defs: Vec<_> = collected
5424            .tool_definitions
5425            .iter()
5426            .filter(|tool| tool.name() == "spawn_agent")
5427            .collect();
5428
5429        assert_eq!(spawn_agent_defs.len(), 1);
5430        assert_eq!(
5431            spawn_agent_defs[0].parameters()["properties"]["target"]["properties"]["type"]["enum"],
5432            serde_json::json!(["subagent", "external_a2a"])
5433        );
5434        assert_eq!(
5435            spawn_agent_defs[0].parameters()["properties"]["mode"]["enum"],
5436            serde_json::json!(["background", "foreground"])
5437        );
5438        assert!(
5439            !spawn_agent_defs[0].parameters()["properties"]["mode"]["description"]
5440                .as_str()
5441                .expect("mode description")
5442                .contains("wait")
5443        );
5444        let schema = spawn_agent_defs[0].parameters();
5445        assert!(schema.get("oneOf").is_none());
5446        // name is required at the root even with external_a2a present: the
5447        // local providers demand it and requiring a field external_a2a ignores
5448        // is safe, whereas top-level conditional requirements are rejected.
5449        assert_eq!(
5450            schema["required"],
5451            serde_json::json!(["name", "instructions", "target"])
5452        );
5453        assert_eq!(
5454            schema["properties"]["target"]["oneOf"],
5455            serde_json::json!([
5456                {
5457                    "properties": {"type": {"const": "subagent"}}
5458                },
5459                {
5460                    "properties": {"type": {"const": "external_a2a"}},
5461                    "anyOf": [
5462                        {"required": ["id"]},
5463                        {"required": ["external_agent_id"]}
5464                    ]
5465                }
5466            ])
5467        );
5468    }
5469
5470    struct ExistingSpawnAgentCapability;
5471
5472    impl Capability for ExistingSpawnAgentCapability {
5473        fn id(&self) -> &str {
5474            "existing_spawn_agent"
5475        }
5476
5477        fn name(&self) -> &str {
5478            "Existing Spawn Agent"
5479        }
5480
5481        fn description(&self) -> &str {
5482            "Test capability that already owns spawn_agent"
5483        }
5484
5485        fn tools(&self) -> Vec<Box<dyn Tool>> {
5486            vec![Box::new(ExistingSpawnAgentTool)]
5487        }
5488    }
5489
5490    struct ExistingSpawnAgentTool;
5491
5492    #[async_trait]
5493    impl Tool for ExistingSpawnAgentTool {
5494        fn name(&self) -> &str {
5495            "spawn_agent"
5496        }
5497
5498        fn description(&self) -> &str {
5499            "Existing spawn_agent test tool"
5500        }
5501
5502        fn parameters_schema(&self) -> serde_json::Value {
5503            serde_json::json!({
5504                "type": "object",
5505                "properties": {
5506                    "target": {
5507                        "type": "object",
5508                        "properties": {
5509                            "type": {"type": "string", "enum": ["external_a2a"]}
5510                        },
5511                        "required": ["type"]
5512                    }
5513                },
5514                "required": ["target"]
5515            })
5516        }
5517
5518        async fn execute(
5519            &self,
5520            _arguments: serde_json::Value,
5521        ) -> crate::tools::ToolExecutionResult {
5522            crate::tools::ToolExecutionResult::success(serde_json::json!({"ok": true}))
5523        }
5524    }
5525
5526    #[tokio::test]
5527    async fn test_subagents_do_not_shadow_existing_spawn_agent_provider() {
5528        let mut registry = CapabilityRegistry::new();
5529        registry.register(SubagentCapability);
5530        registry.register(ExistingSpawnAgentCapability);
5531
5532        let collected = collect_capabilities(
5533            &[
5534                SUBAGENTS_CAPABILITY_ID.to_string(),
5535                "existing_spawn_agent".to_string(),
5536            ],
5537            &registry,
5538            &test_ctx(),
5539        )
5540        .await;
5541
5542        let spawn_agent_defs: Vec<_> = collected
5543            .tool_definitions
5544            .iter()
5545            .filter(|tool| tool.name() == "spawn_agent")
5546            .collect();
5547        assert_eq!(spawn_agent_defs.len(), 1);
5548        assert_eq!(
5549            spawn_agent_defs[0].parameters()["properties"]["target"]["properties"]["type"]["enum"],
5550            serde_json::json!(["external_a2a"])
5551        );
5552    }
5553
5554    #[tokio::test]
5555    async fn test_agent_handoff_does_not_shadow_existing_spawn_agent_provider() {
5556        let mut registry = CapabilityRegistry::new();
5557        registry.register(AgentHandoffCapability);
5558        registry.register(ExistingSpawnAgentCapability);
5559
5560        let agent_id = crate::typed_id::AgentId::new();
5561        let harness_id = crate::typed_id::HarnessId::new();
5562        let configs = vec![
5563            AgentCapabilityConfig {
5564                capability_ref: CapabilityId::new(AGENT_HANDOFF_CAPABILITY_ID),
5565                config: serde_json::json!({
5566                    "targets": [{
5567                        "id": "aws_operator",
5568                        "name": "AWS Operator",
5569                        "agent_id": agent_id,
5570                        "harness_id": harness_id
5571                    }]
5572                }),
5573            },
5574            AgentCapabilityConfig {
5575                capability_ref: CapabilityId::new("existing_spawn_agent"),
5576                config: serde_json::json!({}),
5577            },
5578        ];
5579
5580        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5581
5582        let spawn_agent_defs: Vec<_> = collected
5583            .tool_definitions
5584            .iter()
5585            .filter(|tool| tool.name() == "spawn_agent")
5586            .collect();
5587        assert_eq!(spawn_agent_defs.len(), 1);
5588        assert_eq!(
5589            spawn_agent_defs[0].parameters()["properties"]["target"]["properties"]["type"]["enum"],
5590            serde_json::json!(["external_a2a"])
5591        );
5592    }
5593
5594    /// Idempotence: explicitly selecting `background_execution` plus a
5595    /// background-capable tool must not produce duplicate spawn_background
5596    /// entries.
5597    #[tokio::test]
5598    async fn test_background_execution_explicit_selection_is_idempotent() {
5599        let registry = CapabilityRegistry::with_builtins();
5600        let collected = collect_capabilities(
5601            &[
5602                "bashkit_shell".to_string(),
5603                BACKGROUND_EXECUTION_CAPABILITY_ID.to_string(),
5604            ],
5605            &registry,
5606            &test_ctx(),
5607        )
5608        .await;
5609
5610        let spawn_background_count = collected
5611            .tool_definitions
5612            .iter()
5613            .filter(|t| t.name() == "spawn_background")
5614            .count();
5615        assert_eq!(
5616            spawn_background_count, 1,
5617            "spawn_background must appear exactly once even when \
5618             background_execution is selected explicitly alongside a \
5619             background-capable tool"
5620        );
5621        let applied_count = collected
5622            .applied_ids
5623            .iter()
5624            .filter(|id| id.as_str() == BACKGROUND_EXECUTION_CAPABILITY_ID)
5625            .count();
5626        assert_eq!(
5627            applied_count, 1,
5628            "background_execution must appear exactly once in applied_ids"
5629        );
5630    }
5631
5632    /// Lockstep: with_defaults() must NOT include spawn_background — it only
5633    /// reaches the worker registry through the auto-activated capability.
5634    /// This proves the executor cannot dispatch spawn_background without the
5635    /// model having seen it.
5636    #[test]
5637    fn test_defaults_do_not_include_spawn_background() {
5638        let registry = crate::ToolRegistry::with_defaults();
5639        assert!(
5640            !registry.has("spawn_background"),
5641            "with_defaults() must not include 'spawn_background' — it comes \
5642             from the background_execution capability (EVE-501)"
5643        );
5644    }
5645
5646    // =========================================================================
5647    // Feature tests
5648    // =========================================================================
5649
5650    #[test]
5651    fn test_capability_features_default_empty() {
5652        let registry = CapabilityRegistry::with_builtins();
5653
5654        // Most capabilities have no features
5655        let noop = registry.get("noop").unwrap();
5656        assert!(noop.features().is_empty());
5657
5658        let current_time = registry.get("current_time").unwrap();
5659        assert!(current_time.features().is_empty());
5660    }
5661
5662    #[test]
5663    fn test_file_system_capability_features() {
5664        let registry = CapabilityRegistry::with_builtins();
5665
5666        let fs = registry.get("session_file_system").unwrap();
5667        assert_eq!(fs.features(), vec!["file_system"]);
5668    }
5669
5670    #[test]
5671    fn test_bashkit_shell_capability_features() {
5672        let registry = CapabilityRegistry::with_builtins();
5673
5674        let bash = registry.get("bashkit_shell").unwrap();
5675        assert_eq!(bash.features(), vec!["file_system"]);
5676    }
5677
5678    #[test]
5679    fn test_alias_resolves_to_canonical_capability() {
5680        let registry = CapabilityRegistry::with_builtins();
5681
5682        // Legacy `virtual_bash` ID (persisted agent configs) must keep working.
5683        let via_alias = registry.get("virtual_bash").unwrap();
5684        assert_eq!(via_alias.id(), "bashkit_shell");
5685        assert!(registry.has("virtual_bash"));
5686        assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
5687        assert_eq!(
5688            registry.canonical_id("bashkit_shell"),
5689            Some("bashkit_shell")
5690        );
5691        assert_eq!(registry.canonical_id("nonexistent"), None);
5692    }
5693
5694    #[test]
5695    fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
5696        let registry = CapabilityRegistry::with_builtins();
5697
5698        // Selecting both the alias and the canonical ID must resolve to a
5699        // single activation under the canonical ID.
5700        let resolved = resolve_dependencies(
5701            &["virtual_bash".to_string(), "bashkit_shell".to_string()],
5702            &registry,
5703        )
5704        .unwrap();
5705        let bash_ids: Vec<_> = resolved
5706            .resolved_ids
5707            .iter()
5708            .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
5709            .collect();
5710        assert_eq!(bash_ids, vec!["bashkit_shell"]);
5711        // Selected via alias => not reported as "added as dependency".
5712        assert!(
5713            !resolved
5714                .added_as_dependencies
5715                .contains(&"bashkit_shell".to_string())
5716        );
5717    }
5718
5719    #[test]
5720    fn test_alias_preserves_explicit_config_in_resolution() {
5721        let registry = CapabilityRegistry::with_builtins();
5722
5723        let configs = vec![AgentCapabilityConfig::with_config(
5724            "virtual_bash".to_string(),
5725            serde_json::json!({"key": "value"}),
5726        )];
5727        let resolved = resolve_capability_configs(&configs, &registry).unwrap();
5728        let bash = resolved
5729            .iter()
5730            .find(|c| c.capability_id() == "bashkit_shell")
5731            .expect("alias must resolve to canonical bashkit_shell config");
5732        assert_eq!(bash.config, serde_json::json!({"key": "value"}));
5733    }
5734
5735    #[test]
5736    fn test_unregister_by_alias_removes_capability_and_aliases() {
5737        let mut registry = CapabilityRegistry::with_builtins();
5738
5739        assert!(registry.unregister("virtual_bash").is_some());
5740        assert!(!registry.has("bashkit_shell"));
5741        assert!(!registry.has("virtual_bash"));
5742    }
5743
5744    #[test]
5745    fn test_session_storage_capability_features() {
5746        let registry = CapabilityRegistry::with_builtins();
5747
5748        let storage = registry.get("session_storage").unwrap();
5749        let features = storage.features();
5750        assert!(features.contains(&"secrets"));
5751        assert!(features.contains(&"key_value"));
5752    }
5753
5754    #[test]
5755    fn test_session_schedule_capability_features() {
5756        let registry = CapabilityRegistry::with_builtins();
5757
5758        let schedule = registry.get("session_schedule").unwrap();
5759        assert_eq!(schedule.features(), vec!["schedules"]);
5760    }
5761
5762    #[test]
5763    fn test_session_sql_database_capability_features() {
5764        let registry = CapabilityRegistry::with_builtins();
5765
5766        let sql = registry.get("session_sql_database").unwrap();
5767        assert_eq!(sql.features(), vec!["sql_database"]);
5768    }
5769
5770    #[test]
5771    fn test_sample_data_capability_features() {
5772        let registry = CapabilityRegistry::with_builtins();
5773
5774        let sample = registry.get("sample_data").unwrap();
5775        assert_eq!(sample.features(), vec!["file_system"]);
5776    }
5777
5778    #[test]
5779    fn test_compute_features_empty() {
5780        let registry = CapabilityRegistry::with_builtins();
5781
5782        let features = compute_features(&[], &registry);
5783        assert!(features.is_empty());
5784    }
5785
5786    #[test]
5787    fn test_compute_features_single_capability() {
5788        let registry = CapabilityRegistry::with_builtins();
5789
5790        let features = compute_features(&["session_schedule".to_string()], &registry);
5791        assert_eq!(features, vec!["schedules"]);
5792    }
5793
5794    #[test]
5795    fn test_compute_features_multiple_capabilities() {
5796        let registry = CapabilityRegistry::with_builtins();
5797
5798        let features = compute_features(
5799            &[
5800                "session_file_system".to_string(),
5801                "session_storage".to_string(),
5802                "session_schedule".to_string(),
5803            ],
5804            &registry,
5805        );
5806        assert!(features.contains(&"file_system".to_string()));
5807        assert!(features.contains(&"secrets".to_string()));
5808        assert!(features.contains(&"key_value".to_string()));
5809        assert!(features.contains(&"schedules".to_string()));
5810    }
5811
5812    #[test]
5813    fn test_compute_features_deduplicates() {
5814        let registry = CapabilityRegistry::with_builtins();
5815
5816        // Both session_file_system and bashkit_shell contribute "file_system"
5817        let features = compute_features(
5818            &[
5819                "session_file_system".to_string(),
5820                "bashkit_shell".to_string(),
5821            ],
5822            &registry,
5823        );
5824        let file_system_count = features.iter().filter(|f| *f == "file_system").count();
5825        assert_eq!(file_system_count, 1, "file_system should appear only once");
5826    }
5827
5828    #[test]
5829    fn test_compute_features_includes_dependency_features() {
5830        let registry = CapabilityRegistry::with_builtins();
5831
5832        // bashkit_shell depends on session_file_system; both contribute "file_system"
5833        let features = compute_features(&["bashkit_shell".to_string()], &registry);
5834        assert!(features.contains(&"file_system".to_string()));
5835    }
5836
5837    #[test]
5838    fn test_compute_features_generic_harness_set() {
5839        let registry = CapabilityRegistry::with_builtins();
5840
5841        // Typical Generic Harness capabilities
5842        let features = compute_features(
5843            &[
5844                "session_file_system".to_string(),
5845                "bashkit_shell".to_string(),
5846                "session_storage".to_string(),
5847                "session".to_string(),
5848                "session_schedule".to_string(),
5849            ],
5850            &registry,
5851        );
5852        assert!(features.contains(&"file_system".to_string()));
5853        assert!(features.contains(&"secrets".to_string()));
5854        assert!(features.contains(&"key_value".to_string()));
5855        assert!(features.contains(&"schedules".to_string()));
5856    }
5857
5858    #[test]
5859    fn test_compute_features_unknown_capability_ignored() {
5860        let registry = CapabilityRegistry::with_builtins();
5861
5862        let features = compute_features(
5863            &["unknown_cap".to_string(), "session_schedule".to_string()],
5864            &registry,
5865        );
5866        assert_eq!(features, vec!["schedules"]);
5867    }
5868
5869    #[test]
5870    fn test_risk_level_ordering() {
5871        assert!(RiskLevel::Low < RiskLevel::Medium);
5872        assert!(RiskLevel::Medium < RiskLevel::High);
5873    }
5874
5875    #[test]
5876    fn test_risk_level_serde_roundtrip() {
5877        let high = RiskLevel::High;
5878        let json = serde_json::to_string(&high).unwrap();
5879        assert_eq!(json, "\"high\"");
5880        let back: RiskLevel = serde_json::from_str(&json).unwrap();
5881        assert_eq!(back, RiskLevel::High);
5882    }
5883
5884    #[test]
5885    fn test_capability_risk_levels() {
5886        let registry = CapabilityRegistry::with_builtins();
5887
5888        // bashkit_shell is High (code execution requires admin gating)
5889        let bash = registry.get("bashkit_shell").unwrap();
5890        assert_eq!(bash.risk_level(), RiskLevel::High);
5891
5892        // web_fetch is High (network access requires admin gating)
5893        let fetch = registry.get("web_fetch").unwrap();
5894        assert_eq!(fetch.risk_level(), RiskLevel::High);
5895
5896        // Default capabilities should be Low
5897        let noop = registry.get("noop").unwrap();
5898        assert_eq!(noop.risk_level(), RiskLevel::Low);
5899    }
5900
5901    // =========================================================================
5902    // OpenAI tool_search capability collection tests
5903    // =========================================================================
5904
5905    #[tokio::test]
5906    async fn test_apply_capabilities_openai_tool_search() {
5907        let registry = CapabilityRegistry::with_builtins();
5908        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
5909
5910        let applied = apply_capabilities(
5911            base_runtime_agent.clone(),
5912            &["openai_tool_search".to_string()],
5913            &registry,
5914            &test_ctx(),
5915        )
5916        .await;
5917
5918        // OpenAiToolSearchCapability provides no tools and no system prompt
5919        assert_eq!(
5920            applied.runtime_agent.system_prompt,
5921            base_runtime_agent.system_prompt
5922        );
5923        assert!(applied.tool_registry.is_empty());
5924        assert_eq!(applied.applied_ids, vec!["openai_tool_search"]);
5925
5926        // tool_search config should be set on the runtime agent
5927        let ts = applied.runtime_agent.tool_search.as_ref().unwrap();
5928        assert!(ts.enabled);
5929        assert_eq!(ts.threshold, DEFAULT_TOOL_SEARCH_THRESHOLD);
5930    }
5931
5932    #[tokio::test]
5933    async fn test_apply_capabilities_openai_tool_search_with_other_capabilities() {
5934        let registry = CapabilityRegistry::with_builtins();
5935        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
5936
5937        let applied = apply_capabilities(
5938            base_runtime_agent,
5939            &[
5940                "current_time".to_string(),
5941                "openai_tool_search".to_string(),
5942                "test_math".to_string(),
5943            ],
5944            &registry,
5945            &test_ctx(),
5946        )
5947        .await;
5948
5949        // Should have tools from current_time and test_math
5950        assert!(applied.tool_registry.has("get_current_time"));
5951        assert!(applied.tool_registry.has("add"));
5952        assert!(applied.tool_registry.has("subtract"));
5953        assert!(applied.tool_registry.has("multiply"));
5954        assert!(applied.tool_registry.has("divide"));
5955
5956        // tool_search should still be configured
5957        let ts = applied.runtime_agent.tool_search.as_ref().unwrap();
5958        assert!(ts.enabled);
5959        assert_eq!(ts.threshold, DEFAULT_TOOL_SEARCH_THRESHOLD);
5960    }
5961
5962    #[tokio::test]
5963    async fn test_collect_capabilities_tool_search_custom_threshold() {
5964        let registry = CapabilityRegistry::with_builtins();
5965
5966        let configs = vec![AgentCapabilityConfig {
5967            capability_ref: CapabilityId::new("openai_tool_search"),
5968            config: serde_json::json!({"threshold": 5}),
5969        }];
5970
5971        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5972
5973        let ts = collected.tool_search.as_ref().unwrap();
5974        assert!(ts.enabled);
5975        assert_eq!(ts.threshold, 5);
5976    }
5977
5978    #[tokio::test]
5979    async fn test_collect_capabilities_auto_tool_search_resolves_to_generic_off_native() {
5980        let registry = CapabilityRegistry::with_builtins();
5981
5982        let configs = vec![
5983            AgentCapabilityConfig {
5984                capability_ref: CapabilityId::new("auto_tool_search"),
5985                config: serde_json::json!({"threshold": 2}),
5986            },
5987            AgentCapabilityConfig {
5988                capability_ref: CapabilityId::new("test_math"),
5989                config: serde_json::json!({}),
5990            },
5991        ];
5992
5993        // No native support (pre-4 Claude) → resolves to the generic client-side
5994        // mechanism: no hosted config, but the tool_search tool + DeferSchemaHook
5995        // are collected.
5996        let ctx = test_ctx().with_model("claude-3-5-haiku");
5997        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
5998
5999        assert!(
6000            collected.tool_search.is_none(),
6001            "auto_tool_search must not set a hosted config on a non-native model"
6002        );
6003        assert!(
6004            collected
6005                .tools
6006                .iter()
6007                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
6008            "auto_tool_search must contribute the client-side tool_search tool"
6009        );
6010        assert!(
6011            !collected.tool_definition_hooks.is_empty(),
6012            "auto_tool_search must contribute a client-side deferral hook"
6013        );
6014
6015        let mut transformed = collected.tool_definitions.clone();
6016        for hook in &collected.tool_definition_hooks {
6017            transformed = hook.transform(transformed);
6018        }
6019        let add_tool = transformed
6020            .iter()
6021            .find(|tool| tool.name() == "add")
6022            .expect("test_math contributes add");
6023        assert!(
6024            add_tool.parameters().get("properties").is_none(),
6025            "generic auto_tool_search must honor the configured threshold"
6026        );
6027    }
6028
6029    #[tokio::test]
6030    async fn test_collect_capabilities_auto_tool_search_resolves_to_hosted_on_native() {
6031        let registry = CapabilityRegistry::with_builtins();
6032
6033        let configs = vec![AgentCapabilityConfig {
6034            capability_ref: CapabilityId::new("auto_tool_search"),
6035            config: serde_json::json!({"threshold": 7}),
6036        }];
6037
6038        // Native support → resolves to the hosted OpenAI mechanism: a hosted
6039        // config (honoring the configured threshold) and no client-side tool/hook.
6040        let ctx = test_ctx().with_model("gpt-5.4");
6041        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
6042
6043        let ts = collected
6044            .tool_search
6045            .as_ref()
6046            .expect("auto_tool_search must set a hosted config on a native model");
6047        assert!(ts.enabled);
6048        assert_eq!(ts.threshold, 7);
6049        assert!(
6050            !collected
6051                .tools
6052                .iter()
6053                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
6054            "hosted mechanism must not contribute the client-side tool_search tool"
6055        );
6056        assert!(
6057            collected.tool_definition_hooks.is_empty(),
6058            "hosted mechanism must not contribute a client-side deferral hook"
6059        );
6060    }
6061
6062    #[tokio::test]
6063    async fn test_collect_capabilities_auto_tool_search_resolves_to_hosted_on_anthropic() {
6064        let registry = CapabilityRegistry::with_builtins();
6065
6066        let configs = vec![AgentCapabilityConfig {
6067            capability_ref: CapabilityId::new("auto_tool_search"),
6068            config: serde_json::json!({"threshold": 9}),
6069        }];
6070
6071        // Native Claude support → resolves to the hosted Anthropic mechanism: a
6072        // hosted config (honoring the threshold) and no client-side tool/hook.
6073        let ctx = test_ctx().with_model("claude-opus-4-8");
6074        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
6075
6076        let ts = collected
6077            .tool_search
6078            .as_ref()
6079            .expect("auto_tool_search must set a hosted config on a native Claude model");
6080        assert!(ts.enabled);
6081        assert_eq!(ts.threshold, 9);
6082        assert!(
6083            !collected
6084                .tools
6085                .iter()
6086                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
6087            "hosted mechanism must not contribute the client-side tool_search tool"
6088        );
6089        assert!(
6090            collected.tool_definition_hooks.is_empty(),
6091            "hosted mechanism must not contribute a client-side deferral hook"
6092        );
6093    }
6094
6095    #[tokio::test]
6096    async fn test_collect_capabilities_no_tool_search_without_capability() {
6097        let registry = CapabilityRegistry::with_builtins();
6098
6099        let configs = vec![AgentCapabilityConfig {
6100            capability_ref: CapabilityId::new("current_time"),
6101            config: serde_json::json!({}),
6102        }];
6103
6104        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6105
6106        assert!(collected.tool_search.is_none());
6107    }
6108
6109    #[tokio::test]
6110    async fn test_collect_capabilities_tool_search_category_propagation() {
6111        let registry = CapabilityRegistry::with_builtins();
6112
6113        // test_math capability has category "Testing"
6114        let configs = vec![
6115            AgentCapabilityConfig {
6116                capability_ref: CapabilityId::new("test_math"),
6117                config: serde_json::json!({}),
6118            },
6119            AgentCapabilityConfig {
6120                capability_ref: CapabilityId::new("openai_tool_search"),
6121                config: serde_json::json!({}),
6122            },
6123        ];
6124
6125        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6126
6127        // Verify tool_search is configured
6128        assert!(collected.tool_search.is_some());
6129
6130        // Verify tools have categories from their capability
6131        for tool_def in &collected.tool_definitions {
6132            // test_math tools should have the Math category
6133            if ["add", "subtract", "multiply", "divide"].contains(&tool_def.name()) {
6134                assert!(
6135                    tool_def.category().is_some(),
6136                    "Tool {} should have a category from its capability",
6137                    tool_def.name()
6138                );
6139            }
6140        }
6141    }
6142
6143    #[tokio::test]
6144    async fn test_apply_capabilities_prompt_caching() {
6145        let registry = CapabilityRegistry::with_builtins();
6146        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
6147
6148        let applied = apply_capabilities(
6149            base_runtime_agent.clone(),
6150            &["prompt_caching".to_string()],
6151            &registry,
6152            &test_ctx(),
6153        )
6154        .await;
6155
6156        assert_eq!(
6157            applied.runtime_agent.system_prompt,
6158            base_runtime_agent.system_prompt
6159        );
6160        assert!(applied.tool_registry.is_empty());
6161        assert_eq!(applied.applied_ids, vec!["prompt_caching"]);
6162
6163        let prompt_cache = applied.runtime_agent.prompt_cache.as_ref().unwrap();
6164        assert!(prompt_cache.enabled);
6165        assert_eq!(
6166            prompt_cache.strategy,
6167            crate::driver_registry::PromptCacheStrategy::Auto
6168        );
6169        assert!(prompt_cache.gemini_cached_content.is_none());
6170    }
6171
6172    #[tokio::test]
6173    async fn test_apply_capabilities_openrouter_server_tools() {
6174        let registry = CapabilityRegistry::with_builtins();
6175        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
6176
6177        let configs = vec![AgentCapabilityConfig {
6178            capability_ref: CapabilityId::new("openrouter_server_tools"),
6179            config: serde_json::json!({
6180                "tools": ["web_search", "datetime"],
6181                "web_search_max_results": 4,
6182            }),
6183        }];
6184
6185        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6186        let routing = collected
6187            .openrouter_routing
6188            .as_ref()
6189            .expect("server tools produce routing config");
6190        let kinds: Vec<_> = routing.server_tools.iter().map(|t| t.kind).collect();
6191        assert_eq!(
6192            kinds,
6193            vec![
6194                crate::driver_registry::OpenRouterServerToolKind::WebSearch,
6195                crate::driver_registry::OpenRouterServerToolKind::Datetime,
6196            ]
6197        );
6198
6199        // The capability contributes request intent only — no executable tools.
6200        // With no tools selected (bare id, empty config) it is a no-op.
6201        let applied = apply_capabilities(
6202            base_runtime_agent,
6203            &["openrouter_server_tools".to_string()],
6204            &registry,
6205            &test_ctx(),
6206        )
6207        .await;
6208        assert!(applied.tool_registry.is_empty());
6209        assert!(applied.runtime_agent.openrouter_routing.is_none());
6210    }
6211
6212    #[tokio::test]
6213    async fn test_collect_capabilities_prompt_caching_custom_strategy() {
6214        let registry = CapabilityRegistry::with_builtins();
6215
6216        let configs = vec![AgentCapabilityConfig {
6217            capability_ref: CapabilityId::new("prompt_caching"),
6218            config: serde_json::json!({"strategy": "auto"}),
6219        }];
6220
6221        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6222
6223        let prompt_cache = collected.prompt_cache.as_ref().unwrap();
6224        assert!(prompt_cache.enabled);
6225        assert_eq!(
6226            prompt_cache.strategy,
6227            crate::driver_registry::PromptCacheStrategy::Auto
6228        );
6229        assert!(prompt_cache.gemini_cached_content.is_none());
6230    }
6231
6232    #[tokio::test]
6233    async fn test_collect_capabilities_prompt_caching_gemini_cached_content() {
6234        let registry = CapabilityRegistry::with_builtins();
6235
6236        let configs = vec![AgentCapabilityConfig {
6237            capability_ref: CapabilityId::new("prompt_caching"),
6238            config: serde_json::json!({
6239                "strategy": "auto",
6240                "gemini_cached_content": "cachedContents/demo-cache"
6241            }),
6242        }];
6243
6244        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6245
6246        let prompt_cache = collected.prompt_cache.as_ref().unwrap();
6247        assert_eq!(
6248            prompt_cache.gemini_cached_content.as_deref(),
6249            Some("cachedContents/demo-cache")
6250        );
6251    }
6252
6253    #[tokio::test]
6254    async fn test_collect_capabilities_parallel_tool_calls_modes() {
6255        let registry = CapabilityRegistry::with_builtins();
6256
6257        // Default (no explicit mode) => prefer => Some(true).
6258        let collected = collect_capabilities_with_configs(
6259            &[AgentCapabilityConfig::new("parallel_tool_calls")],
6260            &registry,
6261            &test_ctx(),
6262        )
6263        .await;
6264        assert_eq!(collected.parallel_tool_calls, Some(true));
6265
6266        // avoid => Some(false).
6267        let collected = collect_capabilities_with_configs(
6268            &[AgentCapabilityConfig {
6269                capability_ref: CapabilityId::new("parallel_tool_calls"),
6270                config: serde_json::json!({"mode": "avoid"}),
6271            }],
6272            &registry,
6273            &test_ctx(),
6274        )
6275        .await;
6276        assert_eq!(collected.parallel_tool_calls, Some(false));
6277
6278        // none => None (provider default).
6279        let collected = collect_capabilities_with_configs(
6280            &[AgentCapabilityConfig {
6281                capability_ref: CapabilityId::new("parallel_tool_calls"),
6282                config: serde_json::json!({"mode": "none"}),
6283            }],
6284            &registry,
6285            &test_ctx(),
6286        )
6287        .await;
6288        assert_eq!(collected.parallel_tool_calls, None);
6289
6290        // Capability absent => None.
6291        let collected = collect_capabilities_with_configs(&[], &registry, &test_ctx()).await;
6292        assert_eq!(collected.parallel_tool_calls, None);
6293    }
6294
6295    #[tokio::test]
6296    async fn test_apply_capabilities_parallel_tool_calls_precedence() {
6297        let registry = CapabilityRegistry::with_builtins();
6298
6299        // Capability supplies the preference when no explicit field is set.
6300        let applied = apply_capabilities(
6301            RuntimeAgent::new("p", "gpt-5.2"),
6302            &["parallel_tool_calls".to_string()],
6303            &registry,
6304            &test_ctx(),
6305        )
6306        .await;
6307        assert_eq!(applied.runtime_agent.parallel_tool_calls, Some(true));
6308
6309        // Explicit field (escape hatch) wins over the capability.
6310        let mut base = RuntimeAgent::new("p", "gpt-5.2");
6311        base.parallel_tool_calls = Some(false);
6312        let applied = apply_capabilities(
6313            base,
6314            &["parallel_tool_calls".to_string()],
6315            &registry,
6316            &test_ctx(),
6317        )
6318        .await;
6319        assert_eq!(applied.runtime_agent.parallel_tool_calls, Some(false));
6320    }
6321
6322    // ========================================================================
6323    // contribute_skills() collection — EVE-311
6324    // ========================================================================
6325
6326    struct SkillContributingCapability;
6327
6328    impl Capability for SkillContributingCapability {
6329        fn id(&self) -> &str {
6330            "contributes_skills"
6331        }
6332        fn name(&self) -> &str {
6333            "Contributes Skills"
6334        }
6335        fn description(&self) -> &str {
6336            "Test capability that contributes skills."
6337        }
6338        fn contribute_skills(&self) -> Vec<SkillContribution> {
6339            vec![
6340                SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
6341                    .with_files(vec![(
6342                        "scripts/a.sh".to_string(),
6343                        "#!/bin/sh\necho a\n".to_string(),
6344                    )]),
6345                SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
6346                    .with_user_invocable(false),
6347            ]
6348        }
6349    }
6350
6351    fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
6352        match &entries.get("SKILL.md").expect("SKILL.md missing").source {
6353            MountSource::InlineFile { content, .. } => content.as_str(),
6354            _ => panic!("Expected InlineFile for SKILL.md"),
6355        }
6356    }
6357
6358    #[tokio::test]
6359    async fn test_contribute_skills_normalized_to_mounts() {
6360        let mut registry = CapabilityRegistry::new();
6361        registry.register(SkillContributingCapability);
6362
6363        let configs = vec![AgentCapabilityConfig {
6364            capability_ref: CapabilityId::new("contributes_skills"),
6365            config: serde_json::json!({}),
6366        }];
6367
6368        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6369
6370        let skill_mounts: Vec<_> = collected
6371            .mounts
6372            .iter()
6373            .filter(|m| m.path.starts_with("/.agents/skills/"))
6374            .collect();
6375        assert_eq!(skill_mounts.len(), 2);
6376
6377        // Every contributed skill mount is read-only and owned by the contributing
6378        // capability so the VFS layer can attribute skill files correctly.
6379        for m in &skill_mounts {
6380            assert!(m.is_readonly());
6381            assert_eq!(m.capability_id, "contributes_skills");
6382        }
6383
6384        let alpha = skill_mounts
6385            .iter()
6386            .find(|m| m.path == "/.agents/skills/alpha-skill")
6387            .expect("alpha-skill mount missing");
6388        match &alpha.source {
6389            MountSource::InlineDirectory { entries } => {
6390                assert!(entries.contains_key("SKILL.md"));
6391                assert!(entries.contains_key("scripts/a.sh"));
6392                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
6393                assert_eq!(parsed.name, "alpha-skill");
6394                assert!(parsed.user_invocable);
6395            }
6396            _ => panic!("Expected InlineDirectory"),
6397        }
6398
6399        let beta = skill_mounts
6400            .iter()
6401            .find(|m| m.path == "/.agents/skills/beta-skill")
6402            .expect("beta-skill mount missing");
6403        match &beta.source {
6404            MountSource::InlineDirectory { entries } => {
6405                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
6406                assert!(!parsed.user_invocable);
6407            }
6408            _ => panic!("Expected InlineDirectory"),
6409        }
6410    }
6411
6412    #[tokio::test]
6413    async fn test_contribute_skills_default_empty() {
6414        // Registry-resident capability without a contribute_skills override
6415        // must not add skill mounts.
6416        let mut registry = CapabilityRegistry::new();
6417        registry.register(FilterTestCapability { priority: 0 });
6418
6419        let configs = vec![AgentCapabilityConfig {
6420            capability_ref: CapabilityId::new("filter_test"),
6421            config: serde_json::json!({}),
6422        }];
6423
6424        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
6425        assert!(
6426            collected
6427                .mounts
6428                .iter()
6429                .all(|m| !m.path.starts_with("/.agents/skills/"))
6430        );
6431    }
6432
6433    struct LocalizedCapability;
6434
6435    impl Capability for LocalizedCapability {
6436        fn id(&self) -> &str {
6437            "localized"
6438        }
6439        fn name(&self) -> &str {
6440            "Localized"
6441        }
6442        fn description(&self) -> &str {
6443            "English description"
6444        }
6445        fn localizations(&self) -> Vec<CapabilityLocalization> {
6446            vec![
6447                CapabilityLocalization {
6448                    locale: "en",
6449                    name: None,
6450                    description: None,
6451                    config_description: Some("Controls things."),
6452                    config_overlay: None,
6453                },
6454                CapabilityLocalization {
6455                    locale: "uk",
6456                    name: Some("Локалізована"),
6457                    description: Some("Український опис"),
6458                    config_description: Some("Керує налаштуваннями."),
6459                    config_overlay: None,
6460                },
6461            ]
6462        }
6463    }
6464
6465    #[test]
6466    fn localized_name_falls_back_exact_language_then_base() {
6467        let cap = LocalizedCapability;
6468        // Region tag resolves through the language family.
6469        assert_eq!(cap.localized_name(Some("uk-UA")), "Локалізована");
6470        assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
6471        // Underscore-separated tags are normalized.
6472        assert_eq!(cap.localized_name(Some("uk_UA")), "Локалізована");
6473        // Unsupported locales and None fall back to the base name.
6474        assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
6475        assert_eq!(cap.localized_name(None), "Localized");
6476        assert_eq!(cap.localized_description(Some("uk")), "Український опис");
6477        assert_eq!(cap.localized_description(Some("de")), "English description");
6478    }
6479
6480    #[test]
6481    fn describe_schema_resolves_config_description_per_locale() {
6482        let cap = LocalizedCapability;
6483        assert_eq!(
6484            cap.describe_schema(Some("uk-UA")).as_deref(),
6485            Some("Керує налаштуваннями.")
6486        );
6487        // Unsupported locales fall back to the "en" entry.
6488        assert_eq!(
6489            cap.describe_schema(Some("pl")).as_deref(),
6490            Some("Controls things.")
6491        );
6492        assert_eq!(
6493            cap.describe_schema(None).as_deref(),
6494            Some("Controls things.")
6495        );
6496        // Capabilities without localizations have no config description.
6497        assert_eq!(NoopCapability.describe_schema(Some("uk")), None);
6498    }
6499}