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