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