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