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