Skip to main content

everruns_core/capabilities/
mod.rs

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