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
1050pub trait ToolDefinitionHook: Send + Sync {
1051    fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1052
1053    /// Whether this hook should still run when the agent's model uses native
1054    /// (hosted) tool_search. Client-side deferral hooks return `false` so they
1055    /// don't strip schemas the hosted tool_search index needs (the two are
1056    /// mutually exclusive). Defaults to `true`.
1057    fn applies_with_native_tool_search(&self) -> bool {
1058        true
1059    }
1060}
1061
1062pub trait ToolCallHook: Send + Sync {
1063    fn narration(
1064        &self,
1065        _tool_def: Option<&ToolDefinition>,
1066        _tool_call: &ToolCall,
1067        _phase: crate::tool_narration::ToolNarrationPhase,
1068        _locale: Option<&str>,
1069    ) -> Option<String> {
1070        None
1071    }
1072
1073    fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1074        tool_call
1075    }
1076}
1077
1078/// Adapts a [`Capability`]'s [`Capability::narrate`] into a [`ToolCallHook`] so
1079/// capability-owned narration flows through the same hook channel the act atom
1080/// already consults. One is registered per applied capability during
1081/// `assemble`, after every explicit tool-call hook, so model-authored
1082/// narration (e.g. `human_intent`) still takes precedence.
1083pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1084
1085impl ToolCallHook for CapabilityNarrationHook {
1086    fn narration(
1087        &self,
1088        tool_def: Option<&ToolDefinition>,
1089        tool_call: &ToolCall,
1090        phase: crate::tool_narration::ToolNarrationPhase,
1091        locale: Option<&str>,
1092    ) -> Option<String> {
1093        self.0.narrate(tool_def, tool_call, phase, locale)
1094    }
1095}
1096
1097/// Risk classification for capabilities (TM-AGENT-005).
1098///
1099/// Used to enforce approval requirements when assigning capabilities.
1100#[derive(
1101    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1102)]
1103#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1104#[cfg_attr(feature = "openapi", schema(example = "low"))]
1105#[serde(rename_all = "lowercase")]
1106pub enum RiskLevel {
1107    /// No special approval needed
1108    Low,
1109    /// Logged but allowed for org members
1110    Medium,
1111    /// Requires org admin role to assign
1112    High,
1113}
1114
1115// ============================================================================
1116// Agent Blueprints
1117// ============================================================================
1118
1119/// Model selection strategy for agent blueprints.
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121#[serde(rename_all = "snake_case")]
1122pub enum BlueprintModel {
1123    /// Always use this model. Host cannot override.
1124    Fixed(String),
1125    /// Use this model unless host provides override via config.
1126    Default(String),
1127    /// Use whatever model the host agent uses.
1128    Inherit,
1129}
1130
1131/// Pre-built agent definition with private tools, baked-in prompt, and model selection.
1132///
1133/// Contributed by capabilities via `agent_blueprints()`. Spawned via
1134/// `spawn_subagent(blueprint: "<id>")`. Blueprint tools never appear in the
1135/// host agent's tool list — they exist only inside the spawned child session.
1136pub struct AgentBlueprint {
1137    /// Unique identifier (e.g. `"github_scout"`)
1138    pub id: &'static str,
1139    /// Human-readable display name
1140    pub name: &'static str,
1141    /// When to use this blueprint (LLM reads this for delegation decisions)
1142    pub description: &'static str,
1143    /// Model selection strategy
1144    pub model: BlueprintModel,
1145    /// Baked-in system prompt for the child agent
1146    pub system_prompt: &'static str,
1147    /// Private tools — only available inside the blueprint's session
1148    pub tools: Vec<Box<dyn Tool>>,
1149    /// Iteration limit (default: 20)
1150    pub max_turns: Option<usize>,
1151    /// JSON Schema for allowed host-provided config. `None` = no config accepted.
1152    pub config_schema: Option<serde_json::Value>,
1153}
1154
1155impl AgentBlueprint {
1156    /// Convert blueprint tools to tool definitions (for RuntimeAgent building).
1157    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1158        self.tools.iter().map(|t| t.to_definition()).collect()
1159    }
1160}
1161
1162impl std::fmt::Debug for AgentBlueprint {
1163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1164        f.debug_struct("AgentBlueprint")
1165            .field("id", &self.id)
1166            .field("name", &self.name)
1167            .field("model", &self.model)
1168            .field("tool_count", &self.tools.len())
1169            .field("max_turns", &self.max_turns)
1170            .finish()
1171    }
1172}
1173
1174// ============================================================================
1175// Capability Registry
1176// ============================================================================
1177
1178/// Registry that holds all available capability implementations.
1179///
1180/// The registry provides access to capabilities by ID and allows
1181/// applying multiple capabilities to build a RuntimeAgent.
1182///
1183/// # Example
1184///
1185/// ```
1186/// use everruns_core::capabilities::CapabilityRegistry;
1187///
1188/// let registry = CapabilityRegistry::with_builtins();
1189///
1190/// // Get a capability by ID
1191/// if let Some(cap) = registry.get("current_time") {
1192///     println!("Capability: {}", cap.name());
1193/// }
1194///
1195/// // List all available capabilities
1196/// for cap in registry.list() {
1197///     println!("{}: {}", cap.id(), cap.name());
1198/// }
1199/// ```
1200#[derive(Clone)]
1201pub struct CapabilityRegistry {
1202    capabilities: HashMap<String, Arc<dyn Capability>>,
1203    /// Alias ID -> canonical ID (see [`Capability::aliases`]).
1204    aliases: HashMap<String, String>,
1205}
1206
1207impl CapabilityRegistry {
1208    /// Create a new empty registry
1209    pub fn new() -> Self {
1210        Self {
1211            capabilities: HashMap::new(),
1212            aliases: HashMap::new(),
1213        }
1214    }
1215
1216    /// Create a registry with all built-in capabilities registered
1217    ///
1218    /// Uses `DeploymentGrade::from_env()` to determine which capabilities to include.
1219    /// For explicit control, use `with_builtins_for_grade()`.
1220    pub fn with_builtins() -> Self {
1221        Self::with_builtins_for_grade(DeploymentGrade::from_env())
1222    }
1223
1224    /// Create a registry with built-in capabilities for a specific deployment grade
1225    ///
1226    /// Experimental capabilities are included via integration plugins in dev environments.
1227    /// Non-experimental integration plugins (like Daytona) are included in all environments.
1228    pub fn with_builtins_for_grade(grade: DeploymentGrade) -> Self {
1229        let mut registry = Self::new();
1230
1231        // Core capabilities (all environments)
1232        registry.register(AgentInstructionsCapability);
1233        registry.register(HumanIntentCapability);
1234        registry.register(NoopCapability);
1235        registry.register(CurrentTimeCapability);
1236        registry.register(MessageMetadataCapability);
1237        registry.register(ResearchCapability);
1238        registry.register(ModelScoutCapability);
1239        registry.register(OpenRouterWorkspaceCapability);
1240        registry.register(OpenRouterServerToolsCapability);
1241        registry.register(PlatformManagementCapability);
1242        registry.register(FileSystemCapability);
1243        registry.register(MemoryCapability);
1244        registry.register(SessionStorageCapability);
1245        registry.register(SessionCapability);
1246        registry.register(SessionSqlDatabaseCapability);
1247        registry.register(TestMathCapability);
1248        registry.register(TestWeatherCapability);
1249        registry.register(StatelessTodoListCapability);
1250        #[cfg(feature = "web-fetch")]
1251        registry.register(WebFetchCapability::from_env());
1252        registry.register(BashkitShellCapability);
1253        registry.register(BackgroundExecutionCapability);
1254        registry.register(SessionScheduleCapability);
1255        registry.register(BtwCapability);
1256        registry.register(InfinityContextCapability);
1257        registry.register(budgeting::BudgetingCapability);
1258        registry.register(SelfBudgetCapability);
1259        registry.register(CompactionCapability);
1260        registry.register(ErrorDisclosureCapability);
1261
1262        // OpenAI tool_search (deferred tool loading, all environments)
1263        registry.register(OpenAiToolSearchCapability::new());
1264        // Claude (Anthropic) tool_search (hosted deferred tool loading)
1265        registry.register(ClaudeToolSearchCapability::new());
1266        // Generic, provider-agnostic tool_search (client-side deferred loading)
1267        registry.register(ToolSearchCapability::new());
1268        // Model-adaptive tool_search (hosted on capable models, generic elsewhere)
1269        registry.register(AutoToolSearchCapability::new());
1270        registry.register(PromptCachingCapability::new());
1271
1272        // Request-level parallel tool calls preference (none/prefer/avoid).
1273        registry.register(ParallelToolCallsCapability);
1274
1275        // Skills (filesystem-based discovery + activation, all environments)
1276        registry.register(SkillsCapability);
1277
1278        // Subagents (spawn child agent sessions, all environments)
1279        registry.register(SubagentCapability);
1280
1281        // Session tasks (inspect/steer background work, all environments)
1282        registry.register(SessionTasksCapability);
1283
1284        // Outbound agent delegation — experimental (dev-only by default).
1285        // Risk: exfil, SSRF-adjacent reach, cost/recursion fan-out.
1286        // Gated by FEATURE_AGENT_DELEGATION; auto-enabled in dev, off in prod.
1287        if crate::FeatureFlags::from_env(&grade).agent_delegation {
1288            registry.register(AgentHandoffCapability);
1289            // Additionally compile-gated behind the `a2a` cargo feature: in
1290            // provider builds that disable core defaults the delegation
1291            // capability is absent even when FEATURE_AGENT_DELEGATION is set.
1292            #[cfg(feature = "a2a")]
1293            registry.register(A2aAgentDelegationCapability);
1294        }
1295
1296        // System commands (/clear, /status, /compact, /model)
1297        registry.register(SystemCommandsCapability);
1298
1299        // Tool output persistence (EVE-222: persist exec output to VFS)
1300        registry.register(tool_output_persistence::ToolOutputPersistenceCapability);
1301        registry.register(tool_output_distillation::ToolOutputDistillationCapability);
1302
1303        // User hooks (see specs/user-hooks.md): user-authored shell commands
1304        // at lifecycle/tool events. Risk: High.
1305        registry.register(user_hooks::UserHooksCapability);
1306
1307        // Loop detection (EVE-227: detect repeated identical tool calls)
1308        registry.register(LoopDetectionCapability);
1309
1310        // Tool-call repair (EVE-600): opt-in salvage of malformed tool-call
1311        // arguments. Disabled by default — registered so agents can enable it,
1312        // but contributes nothing unless explicitly selected.
1313        registry.register(ToolCallRepairCapability);
1314
1315        // Prompt canary guardrail: replace assistant output if it leaks the
1316        // first sentence of the system prompt. Streaming-output guardrail.
1317        registry.register(PromptCanaryGuardrailCapability);
1318
1319        // Declarative guardrails (specs/guardrails.md): config-driven
1320        // deterministic checks over model output and tool calls.
1321        registry.register(GuardrailsCapability);
1322
1323        // OpenUI/A2UI prompt helpers are product features, not required by embedders.
1324        #[cfg(feature = "ui-capabilities")]
1325        {
1326            registry.register(OpenUiCapability);
1327            registry.register(A2UiCapability);
1328        }
1329
1330        // Demo capability with mount points (all environments)
1331        registry.register(SampleDataCapability);
1332
1333        // Data knowledge scaffold (all environments)
1334        registry.register(DataKnowledgeCapability);
1335
1336        // Knowledge bases (curated org knowledge — see specs/knowledge-bases.md)
1337        registry.register(KnowledgeBaseCapability);
1338
1339        // Knowledge indexes (source-backed embedded collections — see specs/knowledge-indexes.md)
1340        registry.register(KnowledgeIndexCapability);
1341
1342        // Fake demo capabilities (all environments)
1343        registry.register(FakeWarehouseCapability);
1344        registry.register(FakeAwsCapability);
1345        registry.register(FakeCrmCapability);
1346        registry.register(FakeFinancialCapability);
1347
1348        // External integration plugins (registered via inventory::submit! in integration crates)
1349        let internal_flags = crate::InternalFeatureFlags::from_env();
1350        if internal_flags.session_sandbox {
1351            registry.register(SessionSandboxCapability);
1352        }
1353
1354        // Experimental sandboxed Lua execution (specs/lua-execution.md). High
1355        // risk, admin-gated. Gated by FEATURE_LUA; scripts only actually run
1356        // when the `lua` cargo feature is also compiled in.
1357        if internal_flags.lua {
1358            registry.register(LuaCapability);
1359            // Routes non-essential tool calls through the Lua sandbox by hiding
1360            // them from the model's direct tool list. Depends on `lua`.
1361            registry.register(LuaCodeModeCapability);
1362        }
1363        for plugin in inventory::iter::<IntegrationPlugin>() {
1364            if (!plugin.experimental_only || grade.experimental_features_enabled())
1365                && plugin
1366                    .feature_flag
1367                    .is_none_or(|f| internal_flags.is_enabled(f))
1368            {
1369                registry.register_boxed((plugin.factory)());
1370            }
1371        }
1372
1373        registry
1374    }
1375
1376    /// Register a capability
1377    pub fn register(&mut self, capability: impl Capability + 'static) {
1378        self.register_arc(Arc::new(capability));
1379    }
1380
1381    /// Register a boxed capability
1382    pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1383        self.register_arc(Arc::from(capability));
1384    }
1385
1386    /// Register an Arc-wrapped capability
1387    pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1388        let canonical = capability.id().to_string();
1389        for alias in capability.aliases() {
1390            self.aliases.insert(alias.to_string(), canonical.clone());
1391        }
1392        self.capabilities.insert(canonical, capability);
1393    }
1394
1395    /// Get a capability by ID or alias
1396    pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1397        self.capabilities
1398            .get(id)
1399            .or_else(|| self.aliases.get(id).and_then(|c| self.capabilities.get(c)))
1400    }
1401
1402    /// Resolve an ID or alias to the canonical capability ID.
1403    ///
1404    /// Returns `None` for IDs that are neither registered nor an alias of a
1405    /// registered capability (e.g. declarative or MCP refs).
1406    pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1407        if self.capabilities.contains_key(id) {
1408            Some(id)
1409        } else {
1410            self.aliases
1411                .get(id)
1412                .filter(|c| self.capabilities.contains_key(*c))
1413                .map(String::as_str)
1414        }
1415    }
1416
1417    /// Remove a capability from the registry by ID or alias.
1418    pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1419        let canonical = self.canonical_id(id)?.to_string();
1420        let removed = self.capabilities.remove(&canonical);
1421        self.aliases.retain(|_, target| *target != canonical);
1422        removed
1423    }
1424
1425    /// Check if a capability is registered (by ID or alias)
1426    pub fn has(&self, id: &str) -> bool {
1427        self.get(id).is_some()
1428    }
1429
1430    /// Get all registered capabilities
1431    pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1432        self.capabilities.values().collect()
1433    }
1434
1435    /// Get the number of registered capabilities
1436    pub fn len(&self) -> usize {
1437        self.capabilities.len()
1438    }
1439
1440    /// Check if the registry is empty
1441    pub fn is_empty(&self) -> bool {
1442        self.capabilities.is_empty()
1443    }
1444
1445    /// Create a builder for fluent capability registration
1446    pub fn builder() -> CapabilityRegistryBuilder {
1447        CapabilityRegistryBuilder::new()
1448    }
1449
1450    /// Find a blueprint by ID across all registered capabilities.
1451    ///
1452    /// Returns a fresh `AgentBlueprint` (with new tool instances) each time.
1453    pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1454        for cap in self.capabilities.values() {
1455            for bp in cap.agent_blueprints() {
1456                if bp.id == id {
1457                    return Some(bp);
1458                }
1459            }
1460        }
1461        None
1462    }
1463
1464    /// Find a blueprint and the capability that registered it.
1465    ///
1466    /// Returns `(capability_id, blueprint)` with fresh tool instances.
1467    pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1468        for (capability_id, cap) in &self.capabilities {
1469            for bp in cap.agent_blueprints() {
1470                if bp.id == id {
1471                    return Some((capability_id.clone(), bp));
1472                }
1473            }
1474        }
1475        None
1476    }
1477
1478    /// Collect all blueprints from all registered capabilities.
1479    pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1480        self.capabilities
1481            .values()
1482            .flat_map(|cap| cap.agent_blueprints())
1483            .collect()
1484    }
1485}
1486
1487impl Default for CapabilityRegistry {
1488    fn default() -> Self {
1489        Self::with_builtins()
1490    }
1491}
1492
1493impl std::fmt::Debug for CapabilityRegistry {
1494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1495        let ids: Vec<_> = self.capabilities.keys().collect();
1496        f.debug_struct("CapabilityRegistry")
1497            .field("capabilities", &ids)
1498            .finish()
1499    }
1500}
1501
1502/// Builder for creating a CapabilityRegistry with a fluent API
1503pub struct CapabilityRegistryBuilder {
1504    registry: CapabilityRegistry,
1505}
1506
1507impl CapabilityRegistryBuilder {
1508    /// Create a new builder with an empty registry
1509    pub fn new() -> Self {
1510        Self {
1511            registry: CapabilityRegistry::new(),
1512        }
1513    }
1514
1515    /// Create a new builder with built-in capabilities
1516    pub fn with_builtins() -> Self {
1517        Self {
1518            registry: CapabilityRegistry::with_builtins(),
1519        }
1520    }
1521
1522    /// Add a capability
1523    pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1524        self.registry.register(capability);
1525        self
1526    }
1527
1528    /// Build the registry
1529    pub fn build(self) -> CapabilityRegistry {
1530        self.registry
1531    }
1532}
1533
1534impl Default for CapabilityRegistryBuilder {
1535    fn default() -> Self {
1536        Self::new()
1537    }
1538}
1539
1540// ============================================================================
1541// Collect Capabilities Helper
1542// ============================================================================
1543
1544/// Context available to capability-owned model-view transforms.
1545pub struct ModelViewContext<'a> {
1546    pub session_id: SessionId,
1547    pub prior_usage: Option<&'a TokenUsage>,
1548}
1549
1550/// Provider-side hook for building prompt-facing model views.
1551///
1552/// Providers receive the output of earlier providers and return the messages
1553/// that should be sent into provider serialization. Lower priority providers
1554/// run earlier.
1555pub trait ModelViewProvider: Send + Sync {
1556    fn apply_model_view(
1557        &self,
1558        messages: Vec<Message>,
1559        config: &serde_json::Value,
1560        context: &ModelViewContext<'_>,
1561    ) -> Vec<Message>;
1562
1563    fn priority(&self) -> i32 {
1564        0
1565    }
1566}
1567
1568/// Collected data from capabilities before applying to config.
1569///
1570/// This intermediate struct allows sharing the capability collection logic
1571/// between `apply_capabilities` and `apply_capabilities_to_builder`.
1572pub struct CollectedCapabilities {
1573    /// System prompt additions (in order)
1574    pub system_prompt_parts: Vec<String>,
1575    /// Source attribution for each system prompt addition.
1576    pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1577    /// Tool implementations for the registry
1578    pub tools: Vec<Box<dyn Tool>>,
1579    /// Tool definitions for config
1580    pub tool_definitions: Vec<ToolDefinition>,
1581    /// Mount points from capabilities
1582    pub mounts: Vec<MountPoint>,
1583    /// Message filter providers with their configs (in priority order)
1584    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1585    /// IDs of capabilities that were collected
1586    pub applied_ids: Vec<String>,
1587    /// Tool search configuration (set when openai_tool_search capability is present)
1588    pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1589    /// Prompt caching configuration (set when prompt_caching capability is present)
1590    pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1591    /// OpenRouter routing controls (set when the `openrouter_server_tools`
1592    /// capability is present). Carries provider-executed server tools.
1593    pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
1594    /// Request-level parallel tool calls preference (set when the
1595    /// `parallel_tool_calls` capability is present with mode `prefer`/`avoid`).
1596    /// `None` when absent or mode `none`.
1597    pub parallel_tool_calls: Option<bool>,
1598    /// Hooks that transform the final runtime tool definition list.
1599    pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1600    /// Hooks that inspect or transform model-produced tool calls.
1601    pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1602    /// Scoped remote MCP servers contributed by capabilities.
1603    pub mcp_servers: ScopedMcpServers,
1604    // NOTE: output guardrails are intentionally NOT collected here. They are
1605    // re-derived per turn in `ReasonAtom` directly from the resolved capability
1606    // configs + registry, because they need the assembled system prompt at
1607    // arming time (which only exists once the runtime agent is built). Storing
1608    // them here would duplicate that work for callers that don't run a stream.
1609}
1610
1611#[derive(Debug, Clone, PartialEq, Eq)]
1612pub struct SystemPromptAttribution {
1613    pub capability_id: String,
1614    pub content: String,
1615}
1616
1617impl CollectedCapabilities {
1618    /// Returns the combined system prompt prefix from all capabilities.
1619    /// Returns None if no capabilities contributed system prompt additions.
1620    pub fn system_prompt_prefix(&self) -> Option<String> {
1621        if self.system_prompt_parts.is_empty() {
1622            None
1623        } else {
1624            Some(self.system_prompt_parts.join("\n\n"))
1625        }
1626    }
1627
1628    /// Apply all collected message filter providers to a query.
1629    ///
1630    /// Providers are applied in priority order (lower priority first).
1631    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1632        // Providers are already sorted by priority during collection
1633        for (provider, config) in &self.message_filter_providers {
1634            provider.apply_filters(query, config);
1635        }
1636    }
1637
1638    /// Apply post-load transforms from all message filter providers.
1639    /// Called after messages are loaded, filtered, and injected.
1640    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1641        for (provider, config) in &self.message_filter_providers {
1642            provider.post_load(messages, config);
1643        }
1644    }
1645
1646    /// Check if any capabilities contribute message filters.
1647    pub fn has_message_filters(&self) -> bool {
1648        !self.message_filter_providers.is_empty()
1649    }
1650}
1651
1652/// Compose the model-visible system prompt from the stable base prompt and
1653/// collected capability contributions. Keep the base prompt first so changes in
1654/// dynamic capabilities (for example AGENTS.md reads or environment context)
1655/// do not invalidate provider prefix caches for the agent's core instructions.
1656pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1657    let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1658        return base_system_prompt.to_string();
1659    };
1660
1661    if base_system_prompt.is_empty() {
1662        return additions.to_string();
1663    }
1664
1665    if base_system_prompt.contains("<system-prompt>") {
1666        format!("{base_system_prompt}\n\n{additions}")
1667    } else {
1668        format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1669    }
1670}
1671
1672/// Lightweight result containing only message filter providers.
1673///
1674/// Used when callers only need message filtering (e.g., message loading in
1675/// ReasonAtom) without paying the cost of system prompt contribution or tool
1676/// collection. This avoids unnecessary filesystem reads (AGENTS.md) and tool
1677/// instantiation on the message-filter-only path.
1678pub struct CollectedMessageFilters {
1679    /// Message filter providers with their configs (in priority order)
1680    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1681}
1682
1683/// Lightweight result containing only model-view providers.
1684pub struct CollectedModelViewProviders {
1685    /// Model-view providers with their configs (in priority order).
1686    pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1687}
1688
1689// Note: apply_message_filters/apply_post_load_filters mirror the same methods
1690// on CollectedCapabilities. The duplication is intentional — extracting a trait
1691// would add indirection for 3 lines of loop body, and the two structs serve
1692// different purposes (lightweight vs full collection).
1693
1694impl CollectedMessageFilters {
1695    /// Apply all collected message filter providers to a query.
1696    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1697        for (provider, config) in &self.message_filter_providers {
1698            provider.apply_filters(query, config);
1699        }
1700    }
1701
1702    /// Apply post-load transforms from all message filter providers.
1703    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1704        for (provider, config) in &self.message_filter_providers {
1705            provider.post_load(messages, config);
1706        }
1707    }
1708}
1709
1710impl CollectedModelViewProviders {
1711    /// Apply all collected model-view providers in priority order.
1712    pub fn apply_model_view(
1713        &self,
1714        mut messages: Vec<Message>,
1715        context: &ModelViewContext<'_>,
1716    ) -> Vec<Message> {
1717        for (provider, config) in &self.model_view_providers {
1718            messages = provider.apply_model_view(messages, config, context);
1719        }
1720        messages
1721    }
1722}
1723
1724/// True when the `compaction` capability is present and available in this set.
1725///
1726/// Infinity context defers token-budget eviction to compaction when both are
1727/// enabled (see specs/infinity-context.md) so that compaction's summary — not a
1728/// bare "hidden" notice — covers trimmed history.
1729fn compaction_is_enabled(
1730    capability_configs: &[AgentCapabilityConfig],
1731    registry: &CapabilityRegistry,
1732) -> bool {
1733    capability_configs.iter().any(|cap_config| {
1734        cap_config.capability_ref.as_str() == COMPACTION_CAPABILITY_ID
1735            && registry
1736                .get(cap_config.capability_ref.as_str())
1737                .is_some_and(|cap| cap.status() == CapabilityStatus::Available)
1738    })
1739}
1740
1741/// Per-agent message-filter config for a capability, injecting the derived
1742/// `compaction_active` signal into infinity context when compaction is enabled.
1743///
1744/// This is the one place capability composition is encoded: infinity context and
1745/// compaction are otherwise independent, but if infinity context evicts history
1746/// before compaction can summarize it, compaction only ever sees the recent
1747/// window. The flag tells infinity context to anchor + provide `query_history`
1748/// and let compaction own reduction.
1749fn message_filter_config_for(
1750    cap_id: &str,
1751    base: &serde_json::Value,
1752    compaction_on: bool,
1753) -> serde_json::Value {
1754    if cap_id != INFINITY_CONTEXT_CAPABILITY_ID || !compaction_on {
1755        return base.clone();
1756    }
1757    let mut config = base.clone();
1758    match config.as_object_mut() {
1759        Some(map) => {
1760            map.insert(
1761                "compaction_active".to_string(),
1762                serde_json::Value::Bool(true),
1763            );
1764        }
1765        None => {
1766            config = serde_json::json!({ "compaction_active": true });
1767        }
1768    }
1769    config
1770}
1771
1772/// Collect only message filter providers from capabilities, skipping system
1773/// prompt contributions, tools, mounts, and other expensive work.
1774///
1775/// This is a fast path for callers that only need message filtering (e.g.,
1776/// the message-loading step in ReasonAtom before RuntimeAgent is built).
1777pub fn collect_message_filters_only(
1778    capability_configs: &[AgentCapabilityConfig],
1779    registry: &CapabilityRegistry,
1780) -> CollectedMessageFilters {
1781    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1782        Vec::new();
1783    let compaction_on = compaction_is_enabled(capability_configs, registry);
1784
1785    for cap_config in capability_configs {
1786        let cap_id = cap_config.capability_ref.as_str();
1787        if let Some(capability) = registry.get(cap_id) {
1788            if capability.status() != CapabilityStatus::Available {
1789                continue;
1790            }
1791            // Resolve against None: no model is known at message-filter collection
1792            // time, so fall back to the model-agnostic variant if present.
1793            let effective: &dyn Capability = capability
1794                .resolve_for_model(None)
1795                .unwrap_or_else(|| capability.as_ref());
1796            if let Some(provider) = effective.message_filter_provider() {
1797                let config = message_filter_config_for(cap_id, &cap_config.config, compaction_on);
1798                message_filter_providers.push((provider, config));
1799            }
1800        }
1801    }
1802
1803    message_filter_providers.sort_by_key(|(p, _)| p.priority());
1804
1805    CollectedMessageFilters {
1806        message_filter_providers,
1807    }
1808}
1809
1810/// Collect only model-view providers from capabilities.
1811///
1812/// `model` should be the LLM model name when it is known at call time (e.g. the
1813/// ReasonAtom already holds `model_with_provider`). Pass `None` only when the
1814/// model is genuinely unavailable so capabilities fall back to the model-agnostic
1815/// variant.
1816pub fn collect_model_view_providers(
1817    capability_configs: &[AgentCapabilityConfig],
1818    registry: &CapabilityRegistry,
1819    model: Option<&str>,
1820) -> CollectedModelViewProviders {
1821    let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1822
1823    for cap_config in capability_configs {
1824        let cap_id = cap_config.capability_ref.as_str();
1825        if let Some(capability) = registry.get(cap_id) {
1826            if capability.status() != CapabilityStatus::Available {
1827                continue;
1828            }
1829            let effective: &dyn Capability = capability
1830                .resolve_for_model(model)
1831                .unwrap_or_else(|| capability.as_ref());
1832            if let Some(provider) = effective.model_view_provider() {
1833                model_view_providers.push((provider, cap_config.config.clone()));
1834            }
1835        }
1836    }
1837
1838    model_view_providers.sort_by_key(|(p, _)| p.priority());
1839
1840    CollectedModelViewProviders {
1841        model_view_providers,
1842    }
1843}
1844
1845pub fn collect_capability_mcp_servers(
1846    capability_configs: &[AgentCapabilityConfig],
1847    registry: &CapabilityRegistry,
1848) -> ScopedMcpServers {
1849    let mut servers = ScopedMcpServers::default();
1850
1851    for cap_config in capability_configs {
1852        let cap_id = cap_config.capability_ref.as_str();
1853        // Both `declarative:` and `plugin:` carry a serialized
1854        // `DeclarativeCapabilityDefinition`; handle them the same way.
1855        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
1856            if let Ok(definition) =
1857                serde_json::from_value::<DeclarativeCapabilityDefinition>(cap_config.config.clone())
1858            {
1859                if definition.status != CapabilityStatus::Available {
1860                    continue;
1861                }
1862                if let Some(contributed) = definition.mcp_servers {
1863                    servers = merge_scoped_mcp_servers(&servers, &contributed);
1864                }
1865            }
1866            continue;
1867        }
1868        if let Some(capability) = registry.get(cap_id) {
1869            if capability.status() != CapabilityStatus::Available {
1870                continue;
1871            }
1872            servers = merge_scoped_mcp_servers(
1873                &servers,
1874                &capability.mcp_servers_with_config(&cap_config.config),
1875            );
1876        }
1877    }
1878
1879    servers
1880}
1881
1882// ============================================================================
1883// Dependency Resolution
1884// ============================================================================
1885
1886/// Maximum number of capabilities after dependency resolution.
1887/// This prevents runaway dependency chains and resource exhaustion.
1888pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
1889
1890/// Error type for dependency resolution failures
1891#[derive(Debug, Clone, PartialEq, Eq)]
1892pub enum DependencyError {
1893    /// Circular dependency detected in the capability graph
1894    CircularDependency {
1895        /// The capability where the cycle was detected
1896        capability_id: String,
1897        /// The dependency chain leading to the cycle
1898        chain: Vec<String>,
1899    },
1900    /// Too many capabilities after resolution
1901    TooManyCapabilities {
1902        /// Number of capabilities requested
1903        count: usize,
1904        /// Maximum allowed
1905        max: usize,
1906    },
1907}
1908
1909impl std::fmt::Display for DependencyError {
1910    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1911        match self {
1912            DependencyError::CircularDependency {
1913                capability_id,
1914                chain,
1915            } => {
1916                write!(
1917                    f,
1918                    "Circular dependency detected: {} depends on itself via chain: {} -> {}",
1919                    capability_id,
1920                    chain.join(" -> "),
1921                    capability_id
1922                )
1923            }
1924            DependencyError::TooManyCapabilities { count, max } => {
1925                write!(
1926                    f,
1927                    "Too many capabilities after resolution: {} (max: {})",
1928                    count, max
1929                )
1930            }
1931        }
1932    }
1933}
1934
1935impl std::error::Error for DependencyError {}
1936
1937/// Result of resolving capability dependencies
1938#[derive(Debug, Clone)]
1939pub struct ResolvedCapabilities {
1940    /// All capability IDs after resolving dependencies (in topological order)
1941    /// Dependencies come before dependents.
1942    pub resolved_ids: Vec<String>,
1943    /// IDs that were added as dependencies (not in the original selection)
1944    pub added_as_dependencies: Vec<String>,
1945    /// Original user-selected capability IDs
1946    pub user_selected: Vec<String>,
1947}
1948
1949/// Resolve capability dependencies, returning all required capability IDs.
1950///
1951/// This function:
1952/// 1. Takes the user-selected capability IDs
1953/// 2. Recursively collects all dependencies
1954/// 3. Returns them in topological order (dependencies before dependents)
1955/// 4. Detects circular dependencies and returns an error
1956/// 5. Enforces a maximum capability limit
1957///
1958/// # Arguments
1959///
1960/// * `selected_ids` - User-selected capability IDs
1961/// * `registry` - The capability registry to look up dependencies
1962///
1963/// # Returns
1964///
1965/// `Ok(ResolvedCapabilities)` with all required capabilities in order,
1966/// or `Err(DependencyError)` if circular dependencies are detected or
1967/// the limit is exceeded.
1968pub fn resolve_dependencies(
1969    selected_ids: &[String],
1970    registry: &CapabilityRegistry,
1971) -> Result<ResolvedCapabilities, DependencyError> {
1972    use std::collections::HashSet;
1973
1974    // Canonicalize so capabilities selected via alias match their resolved IDs.
1975    let user_selected: HashSet<String> = selected_ids
1976        .iter()
1977        .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
1978        .collect();
1979    let mut resolved: Vec<String> = Vec::new();
1980    let mut resolved_set: HashSet<String> = HashSet::new();
1981    let mut added_as_dependencies: Vec<String> = Vec::new();
1982
1983    // Process each selected capability and its dependencies using DFS
1984    for cap_id in selected_ids {
1985        resolve_single_capability(
1986            cap_id,
1987            registry,
1988            &mut resolved,
1989            &mut resolved_set,
1990            &mut added_as_dependencies,
1991            &user_selected,
1992            &mut Vec::new(), // visiting chain for cycle detection
1993        )?;
1994    }
1995
1996    // Check max limit
1997    if resolved.len() > MAX_RESOLVED_CAPABILITIES {
1998        return Err(DependencyError::TooManyCapabilities {
1999            count: resolved.len(),
2000            max: MAX_RESOLVED_CAPABILITIES,
2001        });
2002    }
2003
2004    Ok(ResolvedCapabilities {
2005        resolved_ids: resolved,
2006        added_as_dependencies,
2007        user_selected: selected_ids.to_vec(),
2008    })
2009}
2010
2011/// Resolve dependency-expanded capability configs, preserving explicit config on selected IDs.
2012///
2013/// Dependencies are inserted with empty configs. If the same capability is provided more than
2014/// once, the last explicit config wins.
2015pub fn resolve_capability_configs(
2016    selected_configs: &[AgentCapabilityConfig],
2017    registry: &CapabilityRegistry,
2018) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2019    let mut selected_ids: Vec<String> = Vec::new();
2020    for config in selected_configs {
2021        // Both `declarative:` and `plugin:` carry a `DeclarativeCapabilityDefinition`
2022        // config that may declare dependencies.
2023        if (is_declarative_capability(config.capability_id())
2024            || is_plugin_capability(config.capability_id()))
2025            && let Ok(definition) =
2026                serde_json::from_value::<DeclarativeCapabilityDefinition>(config.config.clone())
2027        {
2028            selected_ids.extend(definition.dependencies);
2029        }
2030        selected_ids.push(config.capability_id().to_string());
2031    }
2032    let resolved = resolve_dependencies(&selected_ids, registry)?;
2033
2034    // Key explicit configs by canonical ID so config supplied under an alias
2035    // still attaches to the (canonical) resolved capability ID.
2036    let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2037        .iter()
2038        .map(|config| {
2039            let id = config.capability_id();
2040            let id = registry.canonical_id(id).unwrap_or(id);
2041            (id.to_string(), config.config.clone())
2042        })
2043        .collect();
2044
2045    Ok(resolved
2046        .resolved_ids
2047        .into_iter()
2048        .map(|capability_id| {
2049            explicit_configs
2050                .get(&capability_id)
2051                .cloned()
2052                .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2053                .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2054        })
2055        .collect())
2056}
2057
2058/// Helper function to resolve a single capability and its dependencies recursively.
2059fn resolve_single_capability(
2060    cap_id: &str,
2061    registry: &CapabilityRegistry,
2062    resolved: &mut Vec<String>,
2063    resolved_set: &mut std::collections::HashSet<String>,
2064    added_as_dependencies: &mut Vec<String>,
2065    user_selected: &std::collections::HashSet<String>,
2066    visiting: &mut Vec<String>,
2067) -> Result<(), DependencyError> {
2068    // Normalize aliases to the canonical ID so an alias and its canonical ID
2069    // resolve (and dedupe) to the same capability. Unknown IDs (declarative,
2070    // MCP, skill refs) pass through unchanged.
2071    let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2072
2073    // Already resolved
2074    if resolved_set.contains(cap_id) {
2075        return Ok(());
2076    }
2077
2078    // Check for circular dependency
2079    if visiting.contains(&cap_id.to_string()) {
2080        return Err(DependencyError::CircularDependency {
2081            capability_id: cap_id.to_string(),
2082            chain: visiting.clone(),
2083        });
2084    }
2085
2086    // Get capability from registry
2087    let capability = match registry.get(cap_id) {
2088        Some(cap) => cap,
2089        None => {
2090            // `declarative:` and `plugin:` refs carry their full definition in
2091            // the config payload — they don't need a registry entry. Pass them
2092            // through so `collect_capabilities_with_configs` can process them.
2093            if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2094                && !resolved_set.contains(cap_id)
2095            {
2096                resolved.push(cap_id.to_string());
2097                resolved_set.insert(cap_id.to_string());
2098                if !user_selected.contains(cap_id) {
2099                    added_as_dependencies.push(cap_id.to_string());
2100                }
2101            }
2102            return Ok(());
2103        }
2104    };
2105
2106    // Mark as visiting
2107    visiting.push(cap_id.to_string());
2108
2109    // Resolve dependencies first (depth-first)
2110    for dep_id in capability.dependencies() {
2111        resolve_single_capability(
2112            dep_id,
2113            registry,
2114            resolved,
2115            resolved_set,
2116            added_as_dependencies,
2117            user_selected,
2118            visiting,
2119        )?;
2120    }
2121
2122    // Remove from visiting
2123    visiting.pop();
2124
2125    // Add to resolved
2126    if !resolved_set.contains(cap_id) {
2127        resolved.push(cap_id.to_string());
2128        resolved_set.insert(cap_id.to_string());
2129
2130        // Track if this was added as a dependency (not user-selected)
2131        if !user_selected.contains(cap_id) {
2132            added_as_dependencies.push(cap_id.to_string());
2133        }
2134    }
2135
2136    Ok(())
2137}
2138
2139/// Compute the aggregated set of UI features from a list of capability IDs.
2140///
2141/// Resolves dependencies, collects features from all resolved capabilities,
2142/// and returns deduplicated feature strings.
2143pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2144    use std::collections::HashSet;
2145
2146    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2147        Ok(resolved) => resolved.resolved_ids,
2148        Err(_) => capability_ids.to_vec(),
2149    };
2150
2151    let mut seen = HashSet::new();
2152    let mut features = Vec::new();
2153    for cap_id in &resolved_ids {
2154        if let Some(cap) = registry.get(cap_id) {
2155            for feature in cap.features() {
2156                if seen.insert(feature) {
2157                    features.push(feature.to_string());
2158                }
2159            }
2160        }
2161    }
2162    features
2163}
2164
2165/// Get direct dependencies for a capability ID.
2166/// Returns empty vec if capability not found.
2167pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2168    registry
2169        .get(cap_id)
2170        .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2171        .unwrap_or_default()
2172}
2173
2174/// Collect contributions from capabilities without applying them.
2175///
2176/// Resolves dependencies first, then calls `system_prompt_contribution()` (async)
2177/// on each capability, enabling dynamic content generation based on session context
2178/// (e.g., reading AGENTS.md, discovering skills).
2179///
2180/// Note: This function does not collect message filter providers since it doesn't
2181/// have access to per-agent capability configs. Use `collect_capabilities_with_configs`
2182/// if you need message filter providers.
2183///
2184/// # Arguments
2185///
2186/// * `capability_ids` - Ordered list of capability IDs to collect
2187/// * `registry` - The capability registry containing implementations
2188/// * `ctx` - Session context for dynamic prompt resolution
2189pub async fn collect_capabilities(
2190    capability_ids: &[String],
2191    registry: &CapabilityRegistry,
2192    ctx: &SystemPromptContext,
2193) -> CollectedCapabilities {
2194    // Resolve dependencies so that transitive capabilities (e.g. session_storage
2195    // via browserless) are included automatically.
2196    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2197        Ok(resolved) => resolved.resolved_ids,
2198        Err(e) => {
2199            tracing::warn!("Failed to resolve capability dependencies: {}", e);
2200            capability_ids.to_vec()
2201        }
2202    };
2203
2204    // Convert to AgentCapabilityConfig with empty configs
2205    let configs: Vec<AgentCapabilityConfig> = resolved_ids
2206        .iter()
2207        .map(|id| AgentCapabilityConfig {
2208            capability_ref: CapabilityId::new(id),
2209            config: serde_json::Value::Object(serde_json::Map::new()),
2210        })
2211        .collect();
2212
2213    collect_capabilities_with_configs(&configs, registry, ctx).await
2214}
2215
2216/// Collect contributions from capabilities with their per-agent configurations.
2217///
2218/// Calls `system_prompt_contribution()` (async) on each capability, enabling
2219/// dynamic content generation based on session context.
2220///
2221/// # Arguments
2222///
2223/// * `capability_configs` - Ordered list of capability configs (ID + per-agent config)
2224/// * `registry` - The capability registry containing implementations
2225/// * `ctx` - Session context for dynamic prompt resolution
2226pub async fn collect_capabilities_with_configs(
2227    capability_configs: &[AgentCapabilityConfig],
2228    registry: &CapabilityRegistry,
2229    ctx: &SystemPromptContext,
2230) -> CollectedCapabilities {
2231    let mut system_prompt_parts: Vec<String> = Vec::new();
2232    let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2233    let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2234    let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2235    let mut mounts: Vec<MountPoint> = Vec::new();
2236    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2237        Vec::new();
2238    let mut applied_ids: Vec<String> = Vec::new();
2239    let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2240    let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2241    let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2242    let mut parallel_tool_calls: Option<bool> = None;
2243    let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2244    let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2245    // Per-capability narration adapters, appended after explicit tool-call
2246    // hooks so model-authored narration (human_intent) keeps precedence.
2247    let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2248    let mut mcp_servers = ScopedMcpServers::default();
2249    let compaction_on = compaction_is_enabled(capability_configs, registry);
2250
2251    for cap_config in capability_configs {
2252        let cap_id = cap_config.capability_ref.as_str();
2253        // `declarative:` and `plugin:` refs both carry a serialized
2254        // `DeclarativeCapabilityDefinition` in their config and execute through
2255        // the same runtime path. `plugin:` is handled first (more specific
2256        // prefix), then `declarative:`, then the registry lookup.
2257        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2258            match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2259                cap_config.config.clone(),
2260            ) {
2261                Ok(definition) => {
2262                    if definition.status != CapabilityStatus::Available {
2263                        continue;
2264                    }
2265
2266                    if let Some(prompt) = definition.system_prompt.as_deref() {
2267                        let contribution =
2268                            format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2269                        system_prompt_attributions.push(SystemPromptAttribution {
2270                            capability_id: cap_id.to_string(),
2271                            content: contribution.clone(),
2272                        });
2273                        system_prompt_parts.push(contribution);
2274                    }
2275
2276                    mounts.extend(definition.mounts(cap_id));
2277                    if let Some(ref servers) = definition.mcp_servers {
2278                        mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2279                    }
2280                    for skill in definition.skill_contributions() {
2281                        mounts.push(skill.to_mount(cap_id));
2282                    }
2283
2284                    applied_ids.push(cap_id.to_string());
2285                }
2286                Err(error) => {
2287                    tracing::warn!(
2288                        capability_id = %cap_id,
2289                        error = %error,
2290                        "Skipping invalid declarative/plugin capability config"
2291                    );
2292                }
2293            }
2294            continue;
2295        }
2296        if let Some(capability) = registry.get(cap_id) {
2297            // Only collect from available capabilities
2298            if capability.status() != CapabilityStatus::Available {
2299                continue;
2300            }
2301
2302            // Model-adaptive dispatch: a capability may delegate its contributions
2303            // to a different underlying capability based on the agent's model
2304            // (e.g. `auto_tool_search` picks hosted vs client-side tool search).
2305            // Every contribution below is collected from `effective` (system prompt,
2306            // tools, hooks, tool definitions, mounts, MCP servers, skills, message
2307            // filters); for the common non-delegating case `effective` is just
2308            // `capability`. The tool_search special case below therefore keys on
2309            // `effective.id()` rather than the configured `cap_id`, so a resolved
2310            // `auto_tool_search` is treated as whichever mechanism it became.
2311            // Attribution stays on the configured `cap_id`/`capability` so tools
2312            // surface under the capability the user actually configured.
2313            let effective: &dyn Capability =
2314                match capability.resolve_for_model(ctx.model.as_deref()) {
2315                    Some(inner) => inner,
2316                    None => capability.as_ref(),
2317                };
2318            let effective_id = effective.id();
2319
2320            // Collect dynamic system prompt contribution (config-aware, may read from filesystem)
2321            if let Some(contribution) = effective
2322                .system_prompt_contribution_with_config(ctx, &cap_config.config)
2323                .await
2324            {
2325                system_prompt_attributions.push(SystemPromptAttribution {
2326                    capability_id: cap_id.to_string(),
2327                    content: contribution.clone(),
2328                });
2329                system_prompt_parts.push(contribution);
2330            }
2331
2332            // Collect tools and hooks (config-aware: capabilities can adapt based on per-agent config)
2333            tools.extend(effective.tools_with_config(&cap_config.config));
2334            tool_definition_hooks
2335                .extend(effective.tool_definition_hooks_with_context(ctx, &cap_config.config));
2336            tool_call_hooks.extend(effective.tool_call_hooks());
2337            // Route this capability's `narrate()` through the hook channel.
2338            narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2339            // Output guardrails are NOT collected here — see CollectedCapabilities
2340            // for rationale. ReasonAtom re-derives them at stream-arming time.
2341
2342            // Collect tool definitions, propagating capability category if not already set
2343            let cap_category = effective.category();
2344            for def in effective.tool_definitions() {
2345                let def = match (def.category(), cap_category) {
2346                    (None, Some(cat)) => def.with_category(cat),
2347                    _ => def,
2348                }
2349                .with_capability_attribution(cap_id, Some(capability.name()));
2350                tool_definitions.push(def);
2351            }
2352
2353            // Detect a hosted tool_search mechanism (OpenAI or Anthropic). Both
2354            // hosted capabilities produce the same provider-agnostic
2355            // `ToolSearchConfig`; the driver that handles the request picks the
2356            // wire format. `auto_tool_search` resolves to one of these ids only on
2357            // models with native support; on every other model it resolves to the
2358            // generic `tool_search`, which sets no hosted config and instead
2359            // contributes the hook + tool above.
2360            if effective_id == OPENAI_TOOL_SEARCH_CAPABILITY_ID
2361                || effective_id == CLAUDE_TOOL_SEARCH_CAPABILITY_ID
2362            {
2363                // Parse threshold from config, fall back to default
2364                let threshold = cap_config
2365                    .config
2366                    .get("threshold")
2367                    .and_then(|v| v.as_u64())
2368                    .map(|v| v as usize)
2369                    .unwrap_or(DEFAULT_TOOL_SEARCH_THRESHOLD);
2370                tool_search = Some(crate::driver_registry::ToolSearchConfig {
2371                    enabled: true,
2372                    threshold,
2373                });
2374            }
2375
2376            if cap_id == PROMPT_CACHING_CAPABILITY_ID {
2377                let strategy = cap_config
2378                    .config
2379                    .get("strategy")
2380                    .and_then(|v| v.as_str())
2381                    .map(|value| match value {
2382                        "auto" => crate::driver_registry::PromptCacheStrategy::Auto,
2383                        _ => crate::driver_registry::PromptCacheStrategy::Auto,
2384                    })
2385                    .unwrap_or(crate::driver_registry::PromptCacheStrategy::Auto);
2386                let gemini_cached_content = cap_config
2387                    .config
2388                    .get("gemini_cached_content")
2389                    .and_then(|v| v.as_str())
2390                    .map(str::to_string);
2391                prompt_cache = Some(crate::driver_registry::PromptCacheConfig {
2392                    enabled: true,
2393                    strategy,
2394                    gemini_cached_content,
2395                });
2396            }
2397
2398            if cap_id == PARALLEL_TOOL_CALLS_CAPABILITY_ID {
2399                parallel_tool_calls =
2400                    parallel_tool_calls::parallel_tool_calls_from_config(&cap_config.config);
2401            }
2402
2403            if cap_id == OPENROUTER_SERVER_TOOLS_CAPABILITY_ID {
2404                let server_tools =
2405                    openrouter_server_tools::server_tools_from_config(&cap_config.config);
2406                if !server_tools.is_empty() {
2407                    openrouter_routing = Some(crate::driver_registry::OpenRouterRoutingConfig {
2408                        server_tools,
2409                        ..Default::default()
2410                    });
2411                }
2412            }
2413
2414            // Collect mount points
2415            mounts.extend(effective.mounts());
2416
2417            mcp_servers = merge_scoped_mcp_servers(
2418                &mcp_servers,
2419                &effective.mcp_servers_with_config(&cap_config.config),
2420            );
2421
2422            // Normalize capability-contributed skills into mount points under
2423            // `/.agents/skills/{name}/`. Discovery/activation stays with the
2424            // built-in `skills` capability — see specs/skills-registry.md.
2425            for skill in effective.contribute_skills() {
2426                mounts.push(skill.to_mount(cap_id));
2427            }
2428
2429            // Collect message filter provider
2430            if let Some(provider) = effective.message_filter_provider() {
2431                let config = message_filter_config_for(cap_id, &cap_config.config, compaction_on);
2432                message_filter_providers.push((provider, config));
2433            }
2434
2435            applied_ids.push(cap_id.to_string());
2436        }
2437    }
2438
2439    // Auto-activate `background_execution` whenever any collected tool
2440    // declares background support via `ToolHints::supports_background`.
2441    //
2442    // This is the generic cross-cutting capability contract — meta-tools that
2443    // wrap other tools based on hints should hook in here, not attach to a
2444    // single owner capability (e.g. `bashkit_shell`).
2445    //
2446    // Lockstep: we extend both `tools` (execution registry) and
2447    // `tool_definitions` (model-visible) so the model can see and the worker
2448    // can dispatch `spawn_background` from the same activation event. See
2449    // `specs/background-execution.md`.
2450    if !applied_ids
2451        .iter()
2452        .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID)
2453        && tool_definitions
2454            .iter()
2455            .any(|def| def.hints().supports_background == Some(true))
2456        && let Some(bg_cap) = registry.get(BACKGROUND_EXECUTION_CAPABILITY_ID)
2457        && bg_cap.status() == CapabilityStatus::Available
2458    {
2459        tools.extend(bg_cap.tools());
2460        let cap_category = bg_cap.category();
2461        for def in bg_cap.tool_definitions() {
2462            let def = match (def.category(), cap_category) {
2463                (None, Some(cat)) => def.with_category(cat),
2464                _ => def,
2465            }
2466            .with_capability_attribution(BACKGROUND_EXECUTION_CAPABILITY_ID, Some(bg_cap.name()));
2467            tool_definitions.push(def);
2468        }
2469        narration_hooks.push(Arc::new(CapabilityNarrationHook(bg_cap.clone())));
2470        applied_ids.push(BACKGROUND_EXECUTION_CAPABILITY_ID.to_string());
2471    }
2472
2473    // Append per-capability narration adapters after every explicit tool-call
2474    // hook so capability-owned narration is consulted only once model-authored
2475    // hooks (human_intent) have had their say.
2476    tool_call_hooks.extend(narration_hooks);
2477
2478    // Sort message filter providers by priority (lower = earlier)
2479    message_filter_providers.sort_by_key(|(p, _)| p.priority());
2480
2481    CollectedCapabilities {
2482        system_prompt_parts,
2483        system_prompt_attributions,
2484        tools,
2485        tool_definitions,
2486        mounts,
2487        message_filter_providers,
2488        applied_ids,
2489        tool_search,
2490        prompt_cache,
2491        openrouter_routing,
2492        parallel_tool_calls,
2493        tool_definition_hooks,
2494        tool_call_hooks,
2495        mcp_servers,
2496    }
2497}
2498
2499// ============================================================================
2500// Apply Capabilities to RuntimeAgent
2501// ============================================================================
2502
2503/// Result of applying capabilities to a base runtime agent
2504pub struct AppliedCapabilities {
2505    /// The modified runtime agent with capability contributions merged
2506    pub runtime_agent: RuntimeAgent,
2507    /// Tool registry containing all capability tools
2508    pub tool_registry: ToolRegistry,
2509    /// IDs of capabilities that were applied
2510    pub applied_ids: Vec<String>,
2511}
2512
2513/// Apply capabilities to a base runtime agent configuration.
2514///
2515/// This function:
2516/// 1. Collects system prompt contributions from capabilities (in order)
2517/// 2. Appends them after the agent's base system prompt
2518/// 3. Collects all tools from capabilities
2519/// 4. Returns the modified runtime agent and a tool registry
2520///
2521/// # Arguments
2522///
2523/// * `base_runtime_agent` - The agent's base runtime configuration
2524/// * `capability_ids` - Ordered list of capability IDs to apply
2525/// * `registry` - The capability registry containing implementations
2526/// * `ctx` - Session context for dynamic prompt resolution
2527///
2528/// # Returns
2529///
2530/// An `AppliedCapabilities` struct containing the modified runtime agent,
2531/// tool registry, and list of applied capability IDs.
2532///
2533/// # Example
2534///
2535/// ```ignore
2536/// use everruns_core::capabilities::{apply_capabilities, CapabilityRegistry, SystemPromptContext};
2537/// use everruns_core::runtime_agent::RuntimeAgent;
2538///
2539/// let registry = CapabilityRegistry::with_builtins();
2540/// let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2541/// let ctx = SystemPromptContext::without_file_store(SessionId::new());
2542///
2543/// let capability_ids = vec!["current_time".to_string()];
2544/// let applied = apply_capabilities(base_runtime_agent, &capability_ids, &registry, &ctx).await;
2545///
2546/// // The runtime agent now includes CurrentTime tool
2547/// assert!(!applied.tool_registry.is_empty());
2548/// ```
2549pub async fn apply_capabilities(
2550    base_runtime_agent: RuntimeAgent,
2551    capability_ids: &[String],
2552    registry: &CapabilityRegistry,
2553    ctx: &SystemPromptContext,
2554) -> AppliedCapabilities {
2555    let collected = collect_capabilities(capability_ids, registry, ctx).await;
2556
2557    // Build final system prompt: base prompt first, then capability additions.
2558    let final_system_prompt = compose_system_prompt(
2559        &base_runtime_agent.system_prompt,
2560        collected.system_prompt_prefix().as_deref(),
2561    );
2562
2563    // Build tool registry from collected tools
2564    let mut tool_registry = ToolRegistry::new();
2565    for tool in collected.tools {
2566        tool_registry.register_boxed(tool);
2567    }
2568
2569    // Create modified runtime agent
2570    let mut tools = collected.tool_definitions;
2571    for hook in &collected.tool_definition_hooks {
2572        tools = hook.transform(tools);
2573    }
2574
2575    let runtime_agent = RuntimeAgent {
2576        system_prompt: final_system_prompt,
2577        model: base_runtime_agent.model,
2578        tools,
2579        max_iterations: base_runtime_agent.max_iterations,
2580        temperature: base_runtime_agent.temperature,
2581        max_tokens: base_runtime_agent.max_tokens,
2582        tool_search: collected.tool_search,
2583        prompt_cache: collected.prompt_cache,
2584        openrouter_routing: collected.openrouter_routing,
2585        network_access: base_runtime_agent.network_access,
2586        // Explicit request-level preference (escape hatch) wins; otherwise the
2587        // `parallel_tool_calls` capability supplies the preference.
2588        parallel_tool_calls: base_runtime_agent
2589            .parallel_tool_calls
2590            .or(collected.parallel_tool_calls),
2591    };
2592
2593    AppliedCapabilities {
2594        runtime_agent,
2595        tool_registry,
2596        applied_ids: collected.applied_ids,
2597    }
2598}
2599
2600// ============================================================================
2601// Tests
2602// ============================================================================
2603
2604#[cfg(test)]
2605mod tests {
2606    use super::*;
2607    use crate::typed_id::SessionId;
2608    use std::collections::BTreeSet;
2609    use uuid::Uuid;
2610
2611    // Env-var-mutating tests must not run in parallel.
2612    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2613
2614    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
2615        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
2616    }
2617
2618    /// Test helper: dummy context with no file store
2619    fn test_ctx() -> SystemPromptContext {
2620        SystemPromptContext::without_file_store(SessionId::new())
2621    }
2622
2623    /// Base set of built-in capabilities present in all environments (no experimental delegation).
2624    fn expected_core_builtin_ids() -> BTreeSet<&'static str> {
2625        let mut ids = [
2626            "agent_instructions",
2627            "human_intent",
2628            "budgeting",
2629            "self_budget",
2630            "noop",
2631            "current_time",
2632            "research",
2633            "platform_management",
2634            "session_file_system",
2635            "session_storage",
2636            "session",
2637            "session_sql_database",
2638            "test_math",
2639            "test_weather",
2640            "stateless_todo_list",
2641            "web_fetch",
2642            "bashkit_shell",
2643            "background_execution",
2644            "session_schedule",
2645            "btw",
2646            "infinity_context",
2647            "compaction",
2648            "memory",
2649            "message_metadata",
2650            "openai_tool_search",
2651            "claude_tool_search",
2652            "tool_search",
2653            "auto_tool_search",
2654            "prompt_caching",
2655            "parallel_tool_calls",
2656            "session_tasks",
2657            "skills",
2658            "subagents",
2659            "system_commands",
2660            "sample_data",
2661            "data_knowledge",
2662            "knowledge_base",
2663            "knowledge_index",
2664            "tool_output_persistence",
2665            "tool_output_distillation",
2666            "fake_warehouse",
2667            "fake_aws",
2668            "fake_crm",
2669            "fake_financial",
2670            "loop_detection",
2671            "tool_call_repair",
2672            "error_disclosure",
2673            "prompt_canary_guardrail",
2674            "guardrails",
2675            "user_hooks",
2676            "model_scout",
2677            "openrouter_workspace",
2678            "openrouter_server_tools",
2679        ]
2680        .into_iter()
2681        .collect::<BTreeSet<_>>();
2682        if cfg!(feature = "ui-capabilities") {
2683            ids.insert("openui");
2684            ids.insert("a2ui");
2685        }
2686        ids
2687    }
2688
2689    /// Full set for dev: base + experimental delegation capabilities.
2690    fn expected_dev_builtin_ids() -> BTreeSet<&'static str> {
2691        let mut ids = expected_core_builtin_ids();
2692        ids.insert("agent_handoff");
2693        ids.insert("a2a_agent_delegation");
2694        ids
2695    }
2696
2697    fn registry_ids(registry: &CapabilityRegistry) -> BTreeSet<&str> {
2698        registry.capabilities.keys().map(String::as_str).collect()
2699    }
2700
2701    // =========================================================================
2702    // CapabilityRegistry tests
2703    // =========================================================================
2704
2705    // Note: Integration plugins (docker, daytona, etc.) are registered via inventory::submit!
2706    // in external crates. They only appear in the registry when the integration crate is
2707    // linked into the final binary. Core tests verify only built-in capabilities.
2708    // Integration crates have their own tests for plugin registration.
2709
2710    #[test]
2711    fn test_capability_registry_with_builtins_dev() {
2712        // Dev mode includes all built-in capabilities including experimental delegation
2713        let _lock = lock_env();
2714        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
2715        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
2716        assert_eq!(registry_ids(&registry), expected_dev_builtin_ids());
2717        assert!(registry.has("agent_handoff"));
2718        assert!(registry.has("a2a_agent_delegation"));
2719    }
2720
2721    #[test]
2722    fn test_capability_registry_with_builtins_prod() {
2723        // Prod mode excludes experimental capabilities including delegation
2724        let _lock = lock_env();
2725        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
2726        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Prod);
2727        assert_eq!(registry_ids(&registry), expected_core_builtin_ids());
2728        // Experimental capabilities NOT included in prod
2729        assert!(!registry.has("docker_container"));
2730        assert!(!registry.has("agent_handoff"));
2731        assert!(!registry.has("a2a_agent_delegation"));
2732    }
2733
2734    #[test]
2735    fn test_agent_delegation_enabled_by_env_in_prod() {
2736        // FEATURE_AGENT_DELEGATION=true enables delegation caps even in prod
2737        let _lock = lock_env();
2738        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
2739        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Prod);
2740        assert!(registry.has("agent_handoff"));
2741        assert!(registry.has("a2a_agent_delegation"));
2742        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
2743    }
2744
2745    #[test]
2746    fn test_agent_delegation_disabled_by_env_in_dev() {
2747        // FEATURE_AGENT_DELEGATION=false disables delegation caps even in dev
2748        let _lock = lock_env();
2749        unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "false") };
2750        let registry = CapabilityRegistry::with_builtins_for_grade(DeploymentGrade::Dev);
2751        assert!(!registry.has("agent_handoff"));
2752        assert!(!registry.has("a2a_agent_delegation"));
2753        unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
2754    }
2755
2756    #[test]
2757    fn test_capability_registry_get() {
2758        let registry = CapabilityRegistry::with_builtins();
2759
2760        let noop = registry.get("noop").unwrap();
2761        assert_eq!(noop.id(), "noop");
2762        assert_eq!(noop.name(), "No-Op");
2763        assert_eq!(noop.status(), CapabilityStatus::Available);
2764    }
2765
2766    #[test]
2767    fn test_capability_registry_blueprint_with_capability() {
2768        struct BlueprintProviderCapability;
2769
2770        impl Capability for BlueprintProviderCapability {
2771            fn id(&self) -> &str {
2772                "blueprint_provider"
2773            }
2774            fn name(&self) -> &str {
2775                "Blueprint Provider"
2776            }
2777            fn description(&self) -> &str {
2778                "Capability that provides a blueprint for tests"
2779            }
2780            fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
2781                vec![AgentBlueprint {
2782                    id: "test_blueprint",
2783                    name: "Test Blueprint",
2784                    description: "Blueprint for capability registry tests",
2785                    model: BlueprintModel::Inherit,
2786                    system_prompt: "Test prompt",
2787                    tools: vec![],
2788                    max_turns: None,
2789                    config_schema: None,
2790                }]
2791            }
2792        }
2793
2794        let mut registry = CapabilityRegistry::new();
2795        registry.register(BlueprintProviderCapability);
2796
2797        let (capability_id, blueprint) = registry
2798            .blueprint_with_capability("test_blueprint")
2799            .expect("blueprint should resolve with capability id");
2800        assert_eq!(capability_id, "blueprint_provider");
2801        assert_eq!(blueprint.id, "test_blueprint");
2802    }
2803
2804    #[test]
2805    fn test_capability_registry_builder() {
2806        let registry = CapabilityRegistry::builder()
2807            .capability(NoopCapability)
2808            .capability(CurrentTimeCapability)
2809            .build();
2810
2811        assert!(registry.has("noop"));
2812        assert!(registry.has("current_time"));
2813        assert_eq!(registry.len(), 2);
2814    }
2815
2816    #[test]
2817    fn test_capability_status() {
2818        let registry = CapabilityRegistry::with_builtins();
2819
2820        let current_time = registry.get("current_time").unwrap();
2821        assert_eq!(current_time.status(), CapabilityStatus::Available);
2822
2823        let research = registry.get("research").unwrap();
2824        assert_eq!(research.status(), CapabilityStatus::ComingSoon);
2825    }
2826
2827    #[test]
2828    fn test_capability_icons_and_categories() {
2829        let registry = CapabilityRegistry::with_builtins();
2830
2831        let noop = registry.get("noop").unwrap();
2832        assert_eq!(noop.icon(), Some("circle-off"));
2833        assert_eq!(noop.category(), Some("Testing"));
2834
2835        let current_time = registry.get("current_time").unwrap();
2836        assert_eq!(current_time.icon(), Some("clock"));
2837        assert_eq!(current_time.category(), Some("Core"));
2838    }
2839
2840    #[test]
2841    fn test_system_prompt_preview_default_delegates_to_addition() {
2842        let registry = CapabilityRegistry::with_builtins();
2843
2844        // test_math has a static system_prompt_addition — preview should match
2845        let test_math = registry.get("test_math").unwrap();
2846        assert_eq!(
2847            test_math.system_prompt_preview().as_deref(),
2848            test_math.system_prompt_addition()
2849        );
2850
2851        // current_time has no system_prompt_addition — preview should be None
2852        let current_time = registry.get("current_time").unwrap();
2853        assert!(current_time.system_prompt_preview().is_none());
2854        assert!(current_time.system_prompt_addition().is_none());
2855    }
2856
2857    #[test]
2858    fn test_system_prompt_preview_dynamic_capability() {
2859        let registry = CapabilityRegistry::with_builtins();
2860        let cap = registry.get("agent_instructions").unwrap();
2861
2862        // No static addition, but preview exists
2863        assert!(cap.system_prompt_addition().is_none());
2864        assert!(cap.system_prompt_preview().is_some());
2865        assert!(cap.system_prompt_preview().unwrap().contains("AGENTS.md"));
2866    }
2867
2868    // =========================================================================
2869    // apply_capabilities tests
2870    // =========================================================================
2871
2872    #[tokio::test]
2873    async fn test_apply_capabilities_empty() {
2874        let registry = CapabilityRegistry::with_builtins();
2875        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2876
2877        let applied =
2878            apply_capabilities(base_runtime_agent.clone(), &[], &registry, &test_ctx()).await;
2879
2880        assert_eq!(
2881            applied.runtime_agent.system_prompt,
2882            base_runtime_agent.system_prompt
2883        );
2884        assert!(applied.tool_registry.is_empty());
2885        assert!(applied.applied_ids.is_empty());
2886    }
2887
2888    #[tokio::test]
2889    async fn test_apply_capabilities_noop() {
2890        let registry = CapabilityRegistry::with_builtins();
2891        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2892
2893        let applied = apply_capabilities(
2894            base_runtime_agent.clone(),
2895            &["noop".to_string()],
2896            &registry,
2897            &test_ctx(),
2898        )
2899        .await;
2900
2901        // Noop has no system prompt addition or tools
2902        assert_eq!(
2903            applied.runtime_agent.system_prompt,
2904            base_runtime_agent.system_prompt
2905        );
2906        assert!(applied.tool_registry.is_empty());
2907        assert_eq!(applied.applied_ids, vec!["noop"]);
2908    }
2909
2910    #[tokio::test]
2911    async fn test_apply_capabilities_current_time() {
2912        let registry = CapabilityRegistry::with_builtins();
2913        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2914
2915        let applied = apply_capabilities(
2916            base_runtime_agent.clone(),
2917            &["current_time".to_string()],
2918            &registry,
2919            &test_ctx(),
2920        )
2921        .await;
2922
2923        // CurrentTime has no system prompt addition but has a tool
2924        assert_eq!(
2925            applied.runtime_agent.system_prompt,
2926            base_runtime_agent.system_prompt
2927        );
2928        assert!(applied.tool_registry.has("get_current_time"));
2929        assert_eq!(applied.tool_registry.len(), 1);
2930        assert_eq!(applied.applied_ids, vec!["current_time"]);
2931    }
2932
2933    #[tokio::test]
2934    async fn test_apply_capabilities_skips_coming_soon() {
2935        let registry = CapabilityRegistry::with_builtins();
2936        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2937
2938        // Research is ComingSoon, so it should be skipped
2939        let applied = apply_capabilities(
2940            base_runtime_agent.clone(),
2941            &["research".to_string()],
2942            &registry,
2943            &test_ctx(),
2944        )
2945        .await;
2946
2947        // System prompt should not have the research addition
2948        assert_eq!(
2949            applied.runtime_agent.system_prompt,
2950            base_runtime_agent.system_prompt
2951        );
2952        assert!(applied.applied_ids.is_empty()); // Research was not applied
2953    }
2954
2955    #[tokio::test]
2956    async fn test_apply_capabilities_multiple() {
2957        let registry = CapabilityRegistry::with_builtins();
2958        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2959
2960        let applied = apply_capabilities(
2961            base_runtime_agent.clone(),
2962            &["noop".to_string(), "current_time".to_string()],
2963            &registry,
2964            &test_ctx(),
2965        )
2966        .await;
2967
2968        assert!(applied.tool_registry.has("get_current_time"));
2969        assert_eq!(applied.applied_ids, vec!["noop", "current_time"]);
2970    }
2971
2972    #[tokio::test]
2973    async fn test_apply_capabilities_preserves_order() {
2974        let registry = CapabilityRegistry::with_builtins();
2975        let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
2976
2977        // Order should be preserved in applied_ids
2978        let applied = apply_capabilities(
2979            base_runtime_agent,
2980            &["current_time".to_string(), "noop".to_string()],
2981            &registry,
2982            &test_ctx(),
2983        )
2984        .await;
2985
2986        assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
2987    }
2988
2989    #[tokio::test]
2990    async fn test_apply_capabilities_test_math() {
2991        let registry = CapabilityRegistry::with_builtins();
2992        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2993
2994        let applied = apply_capabilities(
2995            base_runtime_agent.clone(),
2996            &["test_math".to_string()],
2997            &registry,
2998            &test_ctx(),
2999        )
3000        .await;
3001
3002        // TestMath has no system prompt addition (tool defs are sufficient)
3003        assert!(
3004            !applied
3005                .runtime_agent
3006                .system_prompt
3007                .contains("<capability id=\"test_math\">")
3008        );
3009        // No capability prompt prefix, so base prompt is used as-is (no XML wrapping)
3010        assert!(
3011            applied
3012                .runtime_agent
3013                .system_prompt
3014                .contains("You are a helpful assistant.")
3015        );
3016        assert!(applied.tool_registry.has("add"));
3017        assert!(applied.tool_registry.has("subtract"));
3018        assert!(applied.tool_registry.has("multiply"));
3019        assert!(applied.tool_registry.has("divide"));
3020        assert_eq!(applied.tool_registry.len(), 4);
3021    }
3022
3023    #[tokio::test]
3024    async fn test_apply_capabilities_test_weather() {
3025        let registry = CapabilityRegistry::with_builtins();
3026        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3027
3028        let applied = apply_capabilities(
3029            base_runtime_agent.clone(),
3030            &["test_weather".to_string()],
3031            &registry,
3032            &test_ctx(),
3033        )
3034        .await;
3035
3036        // TestWeather has no system prompt addition (tool defs are sufficient)
3037        assert!(
3038            !applied
3039                .runtime_agent
3040                .system_prompt
3041                .contains("<capability id=\"test_weather\">")
3042        );
3043        assert!(applied.tool_registry.has("get_weather"));
3044        assert!(applied.tool_registry.has("get_forecast"));
3045        assert_eq!(applied.tool_registry.len(), 2);
3046    }
3047
3048    #[tokio::test]
3049    async fn test_apply_capabilities_test_math_and_test_weather() {
3050        let registry = CapabilityRegistry::with_builtins();
3051        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3052
3053        let applied = apply_capabilities(
3054            base_runtime_agent.clone(),
3055            &["test_math".to_string(), "test_weather".to_string()],
3056            &registry,
3057            &test_ctx(),
3058        )
3059        .await;
3060
3061        // Should have both sets of tools
3062        assert_eq!(applied.tool_registry.len(), 6); // 4 math + 2 weather
3063        assert!(applied.tool_registry.has("add"));
3064        assert!(applied.tool_registry.has("get_weather"));
3065    }
3066
3067    #[tokio::test]
3068    async fn test_apply_capabilities_stateless_todo_list() {
3069        let registry = CapabilityRegistry::with_builtins();
3070        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3071
3072        let applied = apply_capabilities(
3073            base_runtime_agent.clone(),
3074            &["stateless_todo_list".to_string()],
3075            &registry,
3076            &test_ctx(),
3077        )
3078        .await;
3079
3080        // StatelessTodoList has system prompt addition and 1 tool
3081        assert!(
3082            applied
3083                .runtime_agent
3084                .system_prompt
3085                .contains("Task Management")
3086        );
3087        assert!(applied.runtime_agent.system_prompt.contains("write_todos"));
3088        assert!(applied.tool_registry.has("write_todos"));
3089        assert_eq!(applied.tool_registry.len(), 1);
3090    }
3091
3092    #[tokio::test]
3093    async fn test_apply_capabilities_web_fetch() {
3094        let registry = CapabilityRegistry::with_builtins();
3095        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3096
3097        let applied = apply_capabilities(
3098            base_runtime_agent.clone(),
3099            &["web_fetch".to_string()],
3100            &registry,
3101            &test_ctx(),
3102        )
3103        .await;
3104
3105        // WebFetch has system prompt from fetchkit's TOOL_LLMTXT and 1 tool
3106        assert!(
3107            applied
3108                .runtime_agent
3109                .system_prompt
3110                .contains(&base_runtime_agent.system_prompt)
3111        );
3112        assert!(applied.runtime_agent.system_prompt.contains("web_fetch"));
3113        assert!(applied.tool_registry.has("web_fetch"));
3114        assert_eq!(applied.tool_registry.len(), 1);
3115    }
3116
3117    // =========================================================================
3118    // XML prompt formatting tests
3119    // =========================================================================
3120
3121    #[tokio::test]
3122    async fn test_xml_tags_wrap_capability_prompts() {
3123        let registry = CapabilityRegistry::with_builtins();
3124        let collected =
3125            collect_capabilities(&["stateless_todo_list".to_string()], &registry, &test_ctx())
3126                .await;
3127
3128        assert_eq!(collected.system_prompt_parts.len(), 1);
3129        let part = &collected.system_prompt_parts[0];
3130        assert!(part.starts_with("<capability id=\"stateless_todo_list\">"));
3131        assert!(part.ends_with("</capability>"));
3132        assert!(part.contains("Task Management"));
3133    }
3134
3135    #[tokio::test]
3136    async fn test_xml_tags_multiple_capabilities() {
3137        let registry = CapabilityRegistry::with_builtins();
3138        let collected = collect_capabilities(
3139            &[
3140                "stateless_todo_list".to_string(),
3141                "session_schedule".to_string(),
3142            ],
3143            &registry,
3144            &test_ctx(),
3145        )
3146        .await;
3147
3148        assert_eq!(collected.system_prompt_parts.len(), 2);
3149        assert!(
3150            collected.system_prompt_parts[0].starts_with("<capability id=\"stateless_todo_list\">")
3151        );
3152        assert!(
3153            collected.system_prompt_parts[1].starts_with("<capability id=\"session_schedule\">")
3154        );
3155
3156        let prefix = collected.system_prompt_prefix().unwrap();
3157        // Both capability sections separated by double newline
3158        assert!(prefix.contains("</capability>\n\n<capability"));
3159    }
3160
3161    #[tokio::test]
3162    async fn test_xml_tags_system_prompt_wrapping() {
3163        let registry = CapabilityRegistry::with_builtins();
3164        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3165
3166        let applied = apply_capabilities(
3167            base,
3168            &["stateless_todo_list".to_string()],
3169            &registry,
3170            &test_ctx(),
3171        )
3172        .await;
3173
3174        let prompt = &applied.runtime_agent.system_prompt;
3175        assert!(prompt.starts_with("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3176        // Capability wrapped
3177        assert!(prompt.contains("<capability id=\"stateless_todo_list\">"));
3178        assert!(prompt.contains("</capability>"));
3179        // Base prompt wrapped
3180        assert!(prompt.contains("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3181    }
3182
3183    #[tokio::test]
3184    async fn test_no_xml_wrapping_without_capabilities() {
3185        let registry = CapabilityRegistry::with_builtins();
3186        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3187
3188        let applied = apply_capabilities(base, &[], &registry, &test_ctx()).await;
3189
3190        // No capabilities = no XML wrapping (plain base prompt)
3191        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3192        assert!(
3193            !applied
3194                .runtime_agent
3195                .system_prompt
3196                .contains("<system-prompt>")
3197        );
3198    }
3199
3200    #[tokio::test]
3201    async fn test_no_xml_wrapping_for_noop_capability() {
3202        let registry = CapabilityRegistry::with_builtins();
3203        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3204
3205        // Noop has no system_prompt_addition, so no XML wrapping should occur
3206        let applied = apply_capabilities(base, &["noop".to_string()], &registry, &test_ctx()).await;
3207
3208        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3209        assert!(
3210            !applied
3211                .runtime_agent
3212                .system_prompt
3213                .contains("<system-prompt>")
3214        );
3215    }
3216
3217    // =========================================================================
3218    // Mount collection tests
3219    // =========================================================================
3220
3221    #[tokio::test]
3222    async fn test_collect_capabilities_includes_mounts() {
3223        let registry = CapabilityRegistry::with_builtins();
3224
3225        let collected =
3226            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
3227
3228        assert!(!collected.mounts.is_empty());
3229        assert_eq!(collected.mounts.len(), 1);
3230        assert_eq!(collected.mounts[0].path, "/samples");
3231        assert!(collected.mounts[0].is_readonly());
3232    }
3233
3234    #[tokio::test]
3235    async fn test_collect_capabilities_empty_mounts_by_default() {
3236        let registry = CapabilityRegistry::with_builtins();
3237
3238        // Most capabilities don't have mounts
3239        let collected =
3240            collect_capabilities(&["current_time".to_string()], &registry, &test_ctx()).await;
3241
3242        assert!(collected.mounts.is_empty());
3243    }
3244
3245    #[tokio::test]
3246    async fn test_collect_capabilities_combines_mounts() {
3247        let registry = CapabilityRegistry::with_builtins();
3248
3249        // Collect from multiple capabilities - only sample_data has mounts.
3250        // sample_data depends on session_file_system, which is auto-resolved.
3251        let collected = collect_capabilities(
3252            &["sample_data".to_string(), "current_time".to_string()],
3253            &registry,
3254            &test_ctx(),
3255        )
3256        .await;
3257
3258        assert_eq!(collected.mounts.len(), 1);
3259        // Verify expected capabilities were applied (including auto-resolved dependency)
3260        assert!(
3261            collected
3262                .applied_ids
3263                .iter()
3264                .any(|id| id == "session_file_system")
3265        );
3266        assert!(collected.applied_ids.iter().any(|id| id == "sample_data"));
3267        assert!(collected.applied_ids.iter().any(|id| id == "current_time"));
3268    }
3269
3270    #[test]
3271    fn test_sample_data_capability() {
3272        let registry = CapabilityRegistry::with_builtins();
3273        let cap = registry.get("sample_data").unwrap();
3274
3275        assert_eq!(cap.id(), "sample_data");
3276        assert_eq!(cap.name(), "Sample Data");
3277        assert_eq!(cap.status(), CapabilityStatus::Available);
3278
3279        // Has system prompt but no tools
3280        assert!(cap.system_prompt_addition().is_some());
3281        assert!(cap.tools().is_empty());
3282
3283        // Has mounts
3284        assert!(!cap.mounts().is_empty());
3285    }
3286
3287    // =========================================================================
3288    // Dependency resolution tests
3289    // =========================================================================
3290
3291    #[test]
3292    fn test_resolve_dependencies_empty() {
3293        let registry = CapabilityRegistry::with_builtins();
3294
3295        let resolved = resolve_dependencies(&[], &registry).unwrap();
3296
3297        assert!(resolved.resolved_ids.is_empty());
3298        assert!(resolved.added_as_dependencies.is_empty());
3299        assert!(resolved.user_selected.is_empty());
3300    }
3301
3302    #[test]
3303    fn test_resolve_dependencies_no_deps() {
3304        let registry = CapabilityRegistry::with_builtins();
3305
3306        // CurrentTime has no dependencies
3307        let resolved = resolve_dependencies(&["current_time".to_string()], &registry).unwrap();
3308
3309        assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3310        assert!(resolved.added_as_dependencies.is_empty());
3311    }
3312
3313    #[test]
3314    fn test_resolve_dependencies_with_deps() {
3315        let registry = CapabilityRegistry::with_builtins();
3316
3317        // SampleData depends on FileSystem
3318        let resolved = resolve_dependencies(&["sample_data".to_string()], &registry).unwrap();
3319
3320        // FileSystem should be resolved before SampleData
3321        assert_eq!(resolved.resolved_ids.len(), 2);
3322        let fs_pos = resolved
3323            .resolved_ids
3324            .iter()
3325            .position(|id| id == "session_file_system")
3326            .unwrap();
3327        let sd_pos = resolved
3328            .resolved_ids
3329            .iter()
3330            .position(|id| id == "sample_data")
3331            .unwrap();
3332        assert!(fs_pos < sd_pos, "FileSystem should come before SampleData");
3333
3334        // FileSystem was added as a dependency
3335        assert_eq!(resolved.added_as_dependencies, vec!["session_file_system"]);
3336    }
3337
3338    #[test]
3339    fn test_resolve_dependencies_already_selected() {
3340        let registry = CapabilityRegistry::with_builtins();
3341
3342        // If dependency is already selected, it shouldn't be duplicated
3343        let resolved = resolve_dependencies(
3344            &["session_file_system".to_string(), "sample_data".to_string()],
3345            &registry,
3346        )
3347        .unwrap();
3348
3349        assert_eq!(resolved.resolved_ids.len(), 2);
3350        // FileSystem was user-selected, not added as dependency
3351        assert!(resolved.added_as_dependencies.is_empty());
3352    }
3353
3354    #[test]
3355    fn test_resolve_dependencies_preserves_order() {
3356        let registry = CapabilityRegistry::with_builtins();
3357
3358        // Multiple independent capabilities should maintain their relative order
3359        let resolved =
3360            resolve_dependencies(&["current_time".to_string(), "noop".to_string()], &registry)
3361                .unwrap();
3362
3363        assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3364    }
3365
3366    #[test]
3367    fn test_resolve_dependencies_unknown_capability() {
3368        let registry = CapabilityRegistry::with_builtins();
3369
3370        // Unknown capabilities are silently skipped
3371        let resolved =
3372            resolve_dependencies(&["unknown_capability".to_string()], &registry).unwrap();
3373
3374        assert!(resolved.resolved_ids.is_empty());
3375    }
3376
3377    #[test]
3378    fn test_get_dependencies() {
3379        let registry = CapabilityRegistry::with_builtins();
3380
3381        // SampleData depends on FileSystem
3382        let deps = get_dependencies("sample_data", &registry);
3383        assert_eq!(deps, vec!["session_file_system"]);
3384
3385        // CurrentTime has no dependencies
3386        let deps = get_dependencies("current_time", &registry);
3387        assert!(deps.is_empty());
3388
3389        // Unknown capability
3390        let deps = get_dependencies("unknown", &registry);
3391        assert!(deps.is_empty());
3392    }
3393
3394    #[test]
3395    fn test_sample_data_has_dependency() {
3396        let registry = CapabilityRegistry::with_builtins();
3397        let cap = registry.get("sample_data").unwrap();
3398
3399        let deps = cap.dependencies();
3400        assert_eq!(deps.len(), 1);
3401        assert_eq!(deps[0], "session_file_system");
3402    }
3403
3404    #[test]
3405    fn test_noop_has_no_dependencies() {
3406        let registry = CapabilityRegistry::with_builtins();
3407        let cap = registry.get("noop").unwrap();
3408
3409        assert!(cap.dependencies().is_empty());
3410    }
3411
3412    // Test for circular dependency detection
3413    // Note: We can't easily test this with built-in capabilities since they don't have cycles.
3414    // This test uses a custom registry to create a cycle.
3415    #[test]
3416    fn test_circular_dependency_error() {
3417        // Create capabilities that form a cycle: A -> B -> A
3418        struct CapA;
3419        struct CapB;
3420
3421        impl Capability for CapA {
3422            fn id(&self) -> &str {
3423                "test_cap_a"
3424            }
3425            fn name(&self) -> &str {
3426                "Test A"
3427            }
3428            fn description(&self) -> &str {
3429                "Test capability A"
3430            }
3431            fn dependencies(&self) -> Vec<&'static str> {
3432                vec!["test_cap_b"]
3433            }
3434        }
3435
3436        impl Capability for CapB {
3437            fn id(&self) -> &str {
3438                "test_cap_b"
3439            }
3440            fn name(&self) -> &str {
3441                "Test B"
3442            }
3443            fn description(&self) -> &str {
3444                "Test capability B"
3445            }
3446            fn dependencies(&self) -> Vec<&'static str> {
3447                vec!["test_cap_a"]
3448            }
3449        }
3450
3451        let mut registry = CapabilityRegistry::new();
3452        registry.register(CapA);
3453        registry.register(CapB);
3454
3455        let result = resolve_dependencies(&["test_cap_a".to_string()], &registry);
3456
3457        assert!(result.is_err());
3458        match result.unwrap_err() {
3459            DependencyError::CircularDependency { capability_id, .. } => {
3460                assert_eq!(capability_id, "test_cap_a");
3461            }
3462            _ => panic!("Expected CircularDependency error"),
3463        }
3464    }
3465
3466    // =========================================================================
3467    // Message filter provider tests
3468    // =========================================================================
3469
3470    use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3471
3472    /// Test capability that provides a message filter
3473    struct FilterTestCapability {
3474        priority: i32,
3475    }
3476
3477    impl Capability for FilterTestCapability {
3478        fn id(&self) -> &str {
3479            "filter_test"
3480        }
3481        fn name(&self) -> &str {
3482            "Filter Test"
3483        }
3484        fn description(&self) -> &str {
3485            "Test capability with message filter"
3486        }
3487        fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3488            Some(Arc::new(FilterTestProvider {
3489                priority: self.priority,
3490            }))
3491        }
3492    }
3493
3494    struct FilterTestProvider {
3495        priority: i32,
3496    }
3497
3498    impl MessageFilterProvider for FilterTestProvider {
3499        fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3500            // Add a search filter based on config
3501            if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3502                query
3503                    .filters
3504                    .push(MessageFilter::Search(search.to_string()));
3505            }
3506        }
3507
3508        fn priority(&self) -> i32 {
3509            self.priority
3510        }
3511    }
3512
3513    #[tokio::test]
3514    async fn test_collect_capabilities_with_configs_no_filter_providers() {
3515        let registry = CapabilityRegistry::with_builtins();
3516        let configs = vec![AgentCapabilityConfig {
3517            capability_ref: CapabilityId::new("current_time"),
3518            config: serde_json::json!({}),
3519        }];
3520
3521        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3522
3523        assert!(collected.message_filter_providers.is_empty());
3524        assert!(!collected.has_message_filters());
3525    }
3526
3527    #[tokio::test]
3528    async fn test_collect_capabilities_with_configs_with_filter_provider() {
3529        let mut registry = CapabilityRegistry::new();
3530        registry.register(FilterTestCapability { priority: 0 });
3531
3532        let configs = vec![AgentCapabilityConfig {
3533            capability_ref: CapabilityId::new("filter_test"),
3534            config: serde_json::json!({ "search": "hello" }),
3535        }];
3536
3537        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3538
3539        assert_eq!(collected.message_filter_providers.len(), 1);
3540        assert!(collected.has_message_filters());
3541    }
3542
3543    #[tokio::test]
3544    async fn test_collect_capabilities_with_configs_filter_priority_order() {
3545        // Create capabilities with different priorities
3546        struct HighPriorityCapability;
3547        struct LowPriorityCapability;
3548
3549        impl Capability for HighPriorityCapability {
3550            fn id(&self) -> &str {
3551                "high_priority"
3552            }
3553            fn name(&self) -> &str {
3554                "High Priority"
3555            }
3556            fn description(&self) -> &str {
3557                "Test"
3558            }
3559            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3560                Some(Arc::new(FilterTestProvider { priority: 10 }))
3561            }
3562        }
3563
3564        impl Capability for LowPriorityCapability {
3565            fn id(&self) -> &str {
3566                "low_priority"
3567            }
3568            fn name(&self) -> &str {
3569                "Low Priority"
3570            }
3571            fn description(&self) -> &str {
3572                "Test"
3573            }
3574            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3575                Some(Arc::new(FilterTestProvider { priority: -5 }))
3576            }
3577        }
3578
3579        let mut registry = CapabilityRegistry::new();
3580        registry.register(HighPriorityCapability);
3581        registry.register(LowPriorityCapability);
3582
3583        // Add in order: high priority first, low priority second
3584        let configs = vec![
3585            AgentCapabilityConfig {
3586                capability_ref: CapabilityId::new("high_priority"),
3587                config: serde_json::json!({}),
3588            },
3589            AgentCapabilityConfig {
3590                capability_ref: CapabilityId::new("low_priority"),
3591                config: serde_json::json!({}),
3592            },
3593        ];
3594
3595        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3596
3597        // Should be sorted by priority (lower first)
3598        assert_eq!(collected.message_filter_providers.len(), 2);
3599        assert_eq!(collected.message_filter_providers[0].0.priority(), -5);
3600        assert_eq!(collected.message_filter_providers[1].0.priority(), 10);
3601    }
3602
3603    #[tokio::test]
3604    async fn test_collected_capabilities_apply_message_filters() {
3605        let mut registry = CapabilityRegistry::new();
3606        registry.register(FilterTestCapability { priority: 0 });
3607
3608        let configs = vec![AgentCapabilityConfig {
3609            capability_ref: CapabilityId::new("filter_test"),
3610            config: serde_json::json!({ "search": "test_query" }),
3611        }];
3612
3613        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3614
3615        // Apply filters to a query
3616        let session_id: SessionId = Uuid::now_v7().into();
3617        let mut query = MessageQuery::new(session_id);
3618
3619        collected.apply_message_filters(&mut query);
3620
3621        // Should have added the search filter
3622        assert_eq!(query.filters.len(), 1);
3623        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
3624    }
3625
3626    #[tokio::test]
3627    async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
3628        struct SearchCapability {
3629            id: &'static str,
3630            search_term: &'static str,
3631            priority: i32,
3632        }
3633
3634        struct SearchProvider {
3635            search_term: &'static str,
3636            priority: i32,
3637        }
3638
3639        impl MessageFilterProvider for SearchProvider {
3640            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
3641                query
3642                    .filters
3643                    .push(MessageFilter::Search(self.search_term.to_string()));
3644            }
3645
3646            fn priority(&self) -> i32 {
3647                self.priority
3648            }
3649        }
3650
3651        impl Capability for SearchCapability {
3652            fn id(&self) -> &str {
3653                self.id
3654            }
3655            fn name(&self) -> &str {
3656                "Search"
3657            }
3658            fn description(&self) -> &str {
3659                "Test"
3660            }
3661            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3662                Some(Arc::new(SearchProvider {
3663                    search_term: self.search_term,
3664                    priority: self.priority,
3665                }))
3666            }
3667        }
3668
3669        let mut registry = CapabilityRegistry::new();
3670        registry.register(SearchCapability {
3671            id: "cap_a",
3672            search_term: "alpha",
3673            priority: 5,
3674        });
3675        registry.register(SearchCapability {
3676            id: "cap_b",
3677            search_term: "beta",
3678            priority: 1,
3679        });
3680        registry.register(SearchCapability {
3681            id: "cap_c",
3682            search_term: "gamma",
3683            priority: 10,
3684        });
3685
3686        let configs = vec![
3687            AgentCapabilityConfig {
3688                capability_ref: CapabilityId::new("cap_a"),
3689                config: serde_json::json!({}),
3690            },
3691            AgentCapabilityConfig {
3692                capability_ref: CapabilityId::new("cap_b"),
3693                config: serde_json::json!({}),
3694            },
3695            AgentCapabilityConfig {
3696                capability_ref: CapabilityId::new("cap_c"),
3697                config: serde_json::json!({}),
3698            },
3699        ];
3700
3701        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3702
3703        let session_id: SessionId = Uuid::now_v7().into();
3704        let mut query = MessageQuery::new(session_id);
3705
3706        collected.apply_message_filters(&mut query);
3707
3708        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
3709        assert_eq!(query.filters.len(), 3);
3710        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
3711        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
3712        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
3713    }
3714
3715    #[test]
3716    fn test_capability_without_message_filter_returns_none() {
3717        let registry = CapabilityRegistry::with_builtins();
3718
3719        let noop = registry.get("noop").unwrap();
3720        assert!(noop.message_filter_provider().is_none());
3721
3722        let current_time = registry.get("current_time").unwrap();
3723        assert!(current_time.message_filter_provider().is_none());
3724    }
3725
3726    #[tokio::test]
3727    async fn test_collect_capabilities_preserves_config_for_filter_provider() {
3728        let mut registry = CapabilityRegistry::new();
3729        registry.register(FilterTestCapability { priority: 0 });
3730
3731        let test_config = serde_json::json!({
3732            "search": "custom_search",
3733            "extra_field": 42
3734        });
3735
3736        let configs = vec![AgentCapabilityConfig {
3737            capability_ref: CapabilityId::new("filter_test"),
3738            config: test_config.clone(),
3739        }];
3740
3741        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3742
3743        // Verify the config is preserved
3744        assert_eq!(collected.message_filter_providers.len(), 1);
3745        let (_, stored_config) = &collected.message_filter_providers[0];
3746        assert_eq!(*stored_config, test_config);
3747    }
3748
3749    // =========================================================================
3750    // collect_message_filters_only tests
3751    // =========================================================================
3752
3753    #[test]
3754    fn test_collect_message_filters_only_collects_filters() {
3755        let mut registry = CapabilityRegistry::new();
3756        registry.register(FilterTestCapability { priority: 0 });
3757
3758        let configs = vec![AgentCapabilityConfig {
3759            capability_ref: CapabilityId::new("filter_test"),
3760            config: serde_json::json!({ "search": "test_query" }),
3761        }];
3762
3763        let collected = collect_message_filters_only(&configs, &registry);
3764
3765        let session_id: SessionId = Uuid::now_v7().into();
3766        let mut query = MessageQuery::new(session_id);
3767        collected.apply_message_filters(&mut query);
3768
3769        assert_eq!(query.filters.len(), 1);
3770        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
3771    }
3772
3773    #[test]
3774    fn test_message_filter_config_injects_compaction_active_for_infinity_context() {
3775        let base = serde_json::json!({ "context_budget_tokens": 1000 });
3776
3777        // Infinity context gets the derived flag only when compaction is enabled.
3778        let with = message_filter_config_for(INFINITY_CONTEXT_CAPABILITY_ID, &base, true);
3779        assert_eq!(with["compaction_active"], serde_json::json!(true));
3780        assert_eq!(with["context_budget_tokens"], serde_json::json!(1000));
3781
3782        let without = message_filter_config_for(INFINITY_CONTEXT_CAPABILITY_ID, &base, false);
3783        assert!(without.get("compaction_active").is_none());
3784
3785        // Other capabilities are never touched.
3786        let other = message_filter_config_for("other", &base, true);
3787        assert!(other.get("compaction_active").is_none());
3788
3789        // A null base is upgraded to an object carrying the flag.
3790        let null_base = message_filter_config_for(
3791            INFINITY_CONTEXT_CAPABILITY_ID,
3792            &serde_json::Value::Null,
3793            true,
3794        );
3795        assert_eq!(null_base["compaction_active"], serde_json::json!(true));
3796    }
3797
3798    #[test]
3799    fn test_infinity_context_defers_to_compaction_end_to_end() {
3800        use crate::message::Message;
3801
3802        let mut registry = CapabilityRegistry::new();
3803        registry.register(InfinityContextCapability);
3804        registry.register(CompactionCapability);
3805
3806        let tight = serde_json::json!({
3807            "context_budget_tokens": 1,
3808            "min_recent_messages": 1
3809        });
3810
3811        // Infinity context alone (tight budget): it trims and injects a notice.
3812        let solo = vec![AgentCapabilityConfig {
3813            capability_ref: CapabilityId::new(INFINITY_CONTEXT_CAPABILITY_ID),
3814            config: tight.clone(),
3815        }];
3816        let mut messages = vec![
3817            Message::user("task"),
3818            Message::assistant("old ".repeat(400)),
3819            Message::user("recent"),
3820        ];
3821        collect_message_filters_only(&solo, &registry).apply_post_load_filters(&mut messages);
3822        assert!(
3823            messages
3824                .iter()
3825                .any(|m| m.text().is_some_and(|t| t.contains("NOT visible"))),
3826            "infinity context alone should trim and notice"
3827        );
3828
3829        // Infinity context + compaction: infinity context defers, no eviction.
3830        let both = vec![
3831            AgentCapabilityConfig {
3832                capability_ref: CapabilityId::new(INFINITY_CONTEXT_CAPABILITY_ID),
3833                config: tight,
3834            },
3835            AgentCapabilityConfig {
3836                capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
3837                config: serde_json::json!({}),
3838            },
3839        ];
3840        let mut messages = vec![
3841            Message::user("task"),
3842            Message::assistant("old ".repeat(400)),
3843            Message::user("recent"),
3844        ];
3845        collect_message_filters_only(&both, &registry).apply_post_load_filters(&mut messages);
3846        assert_eq!(messages.len(), 3, "compaction owns reduction; no eviction");
3847        assert!(
3848            messages
3849                .iter()
3850                .all(|m| !m.text().is_some_and(|t| t.contains("NOT visible"))),
3851            "no hidden-history notice when compaction is the active reducer"
3852        );
3853    }
3854
3855    #[test]
3856    fn test_compaction_is_enabled_detects_compaction() {
3857        let mut registry = CapabilityRegistry::new();
3858        registry.register(CompactionCapability);
3859
3860        let with_compaction = vec![AgentCapabilityConfig {
3861            capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
3862            config: serde_json::json!({}),
3863        }];
3864        assert!(compaction_is_enabled(&with_compaction, &registry));
3865
3866        let without = vec![AgentCapabilityConfig {
3867            capability_ref: CapabilityId::new("current_time"),
3868            config: serde_json::json!({}),
3869        }];
3870        assert!(!compaction_is_enabled(&without, &registry));
3871    }
3872
3873    #[test]
3874    fn test_collect_message_filters_only_skips_unknown_capabilities() {
3875        let registry = CapabilityRegistry::new();
3876
3877        let configs = vec![AgentCapabilityConfig {
3878            capability_ref: CapabilityId::new("nonexistent"),
3879            config: serde_json::json!({}),
3880        }];
3881
3882        let collected = collect_message_filters_only(&configs, &registry);
3883        assert!(collected.message_filter_providers.is_empty());
3884    }
3885
3886    #[test]
3887    fn test_collect_message_filters_only_preserves_priority_order() {
3888        struct PriorityFilterCap {
3889            id: &'static str,
3890            search_term: &'static str,
3891            priority: i32,
3892        }
3893
3894        struct PriorityFilterProvider {
3895            search_term: &'static str,
3896            priority: i32,
3897        }
3898
3899        impl Capability for PriorityFilterCap {
3900            fn id(&self) -> &str {
3901                self.id
3902            }
3903            fn name(&self) -> &str {
3904                self.id
3905            }
3906            fn description(&self) -> &str {
3907                "priority test"
3908            }
3909            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3910                Some(Arc::new(PriorityFilterProvider {
3911                    search_term: self.search_term,
3912                    priority: self.priority,
3913                }))
3914            }
3915        }
3916
3917        impl MessageFilterProvider for PriorityFilterProvider {
3918            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
3919                query
3920                    .filters
3921                    .push(MessageFilter::Search(self.search_term.to_string()));
3922            }
3923            fn priority(&self) -> i32 {
3924                self.priority
3925            }
3926        }
3927
3928        let mut registry = CapabilityRegistry::new();
3929        registry.register(PriorityFilterCap {
3930            id: "gamma",
3931            search_term: "gamma",
3932            priority: 10,
3933        });
3934        registry.register(PriorityFilterCap {
3935            id: "alpha",
3936            search_term: "alpha",
3937            priority: 5,
3938        });
3939        registry.register(PriorityFilterCap {
3940            id: "beta",
3941            search_term: "beta",
3942            priority: 1,
3943        });
3944
3945        let configs = vec![
3946            AgentCapabilityConfig {
3947                capability_ref: CapabilityId::new("gamma"),
3948                config: serde_json::json!({}),
3949            },
3950            AgentCapabilityConfig {
3951                capability_ref: CapabilityId::new("alpha"),
3952                config: serde_json::json!({}),
3953            },
3954            AgentCapabilityConfig {
3955                capability_ref: CapabilityId::new("beta"),
3956                config: serde_json::json!({}),
3957            },
3958        ];
3959
3960        let collected = collect_message_filters_only(&configs, &registry);
3961
3962        let session_id: SessionId = Uuid::now_v7().into();
3963        let mut query = MessageQuery::new(session_id);
3964        collected.apply_message_filters(&mut query);
3965
3966        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
3967        assert_eq!(query.filters.len(), 3);
3968        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
3969        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
3970        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
3971    }
3972
3973    #[test]
3974    fn test_collect_message_filters_only_post_load_invoked() {
3975        use crate::message::Message;
3976
3977        struct PostLoadCap;
3978        struct PostLoadProvider;
3979
3980        impl Capability for PostLoadCap {
3981            fn id(&self) -> &str {
3982                "post_load_test"
3983            }
3984            fn name(&self) -> &str {
3985                "PostLoad Test"
3986            }
3987            fn description(&self) -> &str {
3988                "test"
3989            }
3990            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3991                Some(Arc::new(PostLoadProvider))
3992            }
3993        }
3994
3995        impl MessageFilterProvider for PostLoadProvider {
3996            fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
3997            fn priority(&self) -> i32 {
3998                0
3999            }
4000            fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4001                // Reverse messages to prove post_load was called
4002                messages.reverse();
4003            }
4004        }
4005
4006        let mut registry = CapabilityRegistry::new();
4007        registry.register(PostLoadCap);
4008
4009        let configs = vec![AgentCapabilityConfig {
4010            capability_ref: CapabilityId::new("post_load_test"),
4011            config: serde_json::json!({}),
4012        }];
4013
4014        let collected = collect_message_filters_only(&configs, &registry);
4015
4016        let mut messages = vec![Message::user("first"), Message::user("second")];
4017        collected.apply_post_load_filters(&mut messages);
4018
4019        // post_load reversed the messages
4020        assert_eq!(messages[0].text(), Some("second"));
4021        assert_eq!(messages[1].text(), Some("first"));
4022    }
4023
4024    #[test]
4025    fn test_collect_model_view_providers_respects_compaction_capability_boundary() {
4026        use crate::tool_types::ToolCall;
4027
4028        fn tool_heavy_messages() -> Vec<Message> {
4029            let mut messages = vec![Message::user("inspect files repeatedly")];
4030            for index in 0..9 {
4031                let call_id = format!("call_{index}");
4032                messages.push(Message::assistant_with_tools(
4033                    "",
4034                    vec![ToolCall {
4035                        id: call_id.clone(),
4036                        name: "read_file".to_string(),
4037                        arguments: serde_json::json!({"path": "/workspace/src/lib.rs"}),
4038                    }],
4039                ));
4040                messages.push(Message::tool_result(
4041                    call_id,
4042                    Some(serde_json::json!({
4043                        "path": "/workspace/src/lib.rs",
4044                        "content": format!("{}{}", "large file line\n".repeat(1000), index),
4045                        "total_lines": 1000,
4046                        "lines_shown": {"start": 1, "end": 1000},
4047                        "truncated": false
4048                    })),
4049                    None,
4050                ));
4051            }
4052            messages
4053        }
4054
4055        fn first_tool_result_is_masked(messages: &[Message]) -> bool {
4056            messages[2]
4057                .tool_result_content()
4058                .and_then(|result| result.result.as_ref())
4059                .and_then(|result| result.get("masked"))
4060                .and_then(|masked| masked.as_bool())
4061                .unwrap_or(false)
4062        }
4063
4064        let mut registry = CapabilityRegistry::new();
4065        registry.register(CompactionCapability);
4066        let context = ModelViewContext {
4067            session_id: SessionId::new(),
4068            prior_usage: None,
4069        };
4070
4071        let no_compaction = collect_model_view_providers(&[], &registry, None);
4072        let unmasked = no_compaction.apply_model_view(tool_heavy_messages(), &context);
4073        assert!(!first_tool_result_is_masked(&unmasked));
4074
4075        let compaction = collect_model_view_providers(
4076            &[AgentCapabilityConfig {
4077                capability_ref: CapabilityId::new(COMPACTION_CAPABILITY_ID),
4078                config: serde_json::json!({}),
4079            }],
4080            &registry,
4081            None,
4082        );
4083        let masked = compaction.apply_model_view(tool_heavy_messages(), &context);
4084        assert!(first_tool_result_is_masked(&masked));
4085        let last_tool = masked.last().unwrap().tool_result_content().unwrap();
4086        assert!(last_tool.result.as_ref().unwrap().get("content").is_some());
4087    }
4088
4089    // Tests for resolve_for_model delegation in fast-path collectors
4090
4091    struct DelegatingFilterCap {
4092        id: &'static str,
4093        inner: std::sync::Arc<InnerFilterCap>,
4094    }
4095    struct InnerFilterCap;
4096
4097    impl Capability for InnerFilterCap {
4098        fn id(&self) -> &str {
4099            "inner_filter"
4100        }
4101        fn name(&self) -> &str {
4102            "Inner Filter"
4103        }
4104        fn description(&self) -> &str {
4105            "inner"
4106        }
4107        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4108            Some(std::sync::Arc::new(SentinelFilter))
4109        }
4110    }
4111    struct SentinelFilter;
4112    impl MessageFilterProvider for SentinelFilter {
4113        fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4114    }
4115    impl Capability for DelegatingFilterCap {
4116        fn id(&self) -> &str {
4117            self.id
4118        }
4119        fn name(&self) -> &str {
4120            "Delegating Filter"
4121        }
4122        fn description(&self) -> &str {
4123            "delegating"
4124        }
4125        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4126            None // outer provides nothing
4127        }
4128        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4129            Some(&*self.inner)
4130        }
4131    }
4132
4133    #[test]
4134    fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4135        let inner = std::sync::Arc::new(InnerFilterCap);
4136        let outer = DelegatingFilterCap {
4137            id: "delegating_filter",
4138            inner: inner.clone(),
4139        };
4140
4141        let mut registry = CapabilityRegistry::new();
4142        registry.register(outer);
4143
4144        let configs = vec![AgentCapabilityConfig {
4145            capability_ref: CapabilityId::new("delegating_filter"),
4146            config: serde_json::json!({}),
4147        }];
4148
4149        // Outer has no message_filter_provider; inner does. resolve_for_model
4150        // delegates to inner so the provider should be collected.
4151        let collected = collect_message_filters_only(&configs, &registry);
4152        assert_eq!(
4153            collected.message_filter_providers.len(),
4154            1,
4155            "provider from resolved inner capability must be collected"
4156        );
4157    }
4158
4159    struct DelegatingMvpCap {
4160        id: &'static str,
4161        inner: std::sync::Arc<InnerMvpCap>,
4162    }
4163    struct InnerMvpCap;
4164
4165    impl Capability for InnerMvpCap {
4166        fn id(&self) -> &str {
4167            "inner_mvp"
4168        }
4169        fn name(&self) -> &str {
4170            "Inner MVP"
4171        }
4172        fn description(&self) -> &str {
4173            "inner"
4174        }
4175        fn model_view_provider(
4176            &self,
4177        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4178            // Return a no-op provider to prove delegation reached here.
4179            struct NoopMvp;
4180            impl crate::capabilities::ModelViewProvider for NoopMvp {
4181                fn apply_model_view(
4182                    &self,
4183                    messages: Vec<Message>,
4184                    _config: &serde_json::Value,
4185                    _context: &ModelViewContext<'_>,
4186                ) -> Vec<Message> {
4187                    messages
4188                }
4189            }
4190            Some(std::sync::Arc::new(NoopMvp))
4191        }
4192    }
4193    impl Capability for DelegatingMvpCap {
4194        fn id(&self) -> &str {
4195            self.id
4196        }
4197        fn name(&self) -> &str {
4198            "Delegating MVP"
4199        }
4200        fn description(&self) -> &str {
4201            "delegating"
4202        }
4203        fn model_view_provider(
4204            &self,
4205        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4206            None // outer provides nothing
4207        }
4208        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4209            Some(&*self.inner)
4210        }
4211    }
4212
4213    #[test]
4214    fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4215        let inner = std::sync::Arc::new(InnerMvpCap);
4216        let outer = DelegatingMvpCap {
4217            id: "delegating_mvp",
4218            inner: inner.clone(),
4219        };
4220
4221        let mut registry = CapabilityRegistry::new();
4222        registry.register(outer);
4223
4224        let configs = vec![AgentCapabilityConfig {
4225            capability_ref: CapabilityId::new("delegating_mvp"),
4226            config: serde_json::json!({}),
4227        }];
4228
4229        // Outer has no model_view_provider; inner does. resolve_for_model
4230        // delegates to inner so the provider should be collected.
4231        let collected = collect_model_view_providers(&configs, &registry, None);
4232        assert_eq!(
4233            collected.model_view_providers.len(),
4234            1,
4235            "provider from resolved inner capability must be collected"
4236        );
4237    }
4238
4239    // =========================================================================
4240    // Harness capability tool registration tests
4241    //
4242    // Regression tests for the "Tool not found: bash" bug where harness
4243    // capabilities were not used for tool registration when agent_id was absent.
4244    // These tests verify that capability-provided tools (especially bash) are
4245    // correctly produced by collect_capabilities.
4246    // =========================================================================
4247
4248    #[tokio::test]
4249    async fn test_bashkit_shell_capability_produces_bash_tool() {
4250        let registry = CapabilityRegistry::with_builtins();
4251        let collected =
4252            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
4253
4254        let tool_names: Vec<&str> = collected
4255            .tool_definitions
4256            .iter()
4257            .map(|t| t.name())
4258            .collect();
4259        assert!(
4260            tool_names.contains(&"bash"),
4261            "bashkit_shell capability must produce 'bash' tool, got: {:?}",
4262            tool_names
4263        );
4264        assert!(
4265            !collected.tools.is_empty(),
4266            "bashkit_shell must provide tool implementations"
4267        );
4268    }
4269
4270    #[tokio::test]
4271    async fn test_generic_harness_capability_set_produces_bash_tool() {
4272        // These are the exact capability IDs from the Generic Harness seed data.
4273        // If any are renamed or removed, this test catches the regression.
4274        let generic_harness_caps = vec![
4275            "session_file_system".to_string(),
4276            "bashkit_shell".to_string(),
4277            "web_fetch".to_string(),
4278            "session_storage".to_string(),
4279            "session".to_string(),
4280            "agent_instructions".to_string(),
4281            "skills".to_string(),
4282            "infinity_context".to_string(),
4283            "auto_tool_search".to_string(),
4284        ];
4285
4286        let registry = CapabilityRegistry::with_builtins();
4287        let collected = collect_capabilities(&generic_harness_caps, &registry, &test_ctx()).await;
4288
4289        let tool_names: Vec<&str> = collected
4290            .tool_definitions
4291            .iter()
4292            .map(|t| t.name())
4293            .collect();
4294        assert!(
4295            tool_names.contains(&"bash"),
4296            "Generic Harness capabilities must produce 'bash' tool, got: {:?}",
4297            tool_names
4298        );
4299    }
4300
4301    #[tokio::test]
4302    async fn test_collect_capabilities_tool_count_matches_definitions() {
4303        // Ensure collected tools (implementations) match tool_definitions count.
4304        // A mismatch means some tools won't be executable at runtime.
4305        let registry = CapabilityRegistry::with_builtins();
4306        let collected =
4307            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
4308
4309        assert_eq!(
4310            collected.tools.len(),
4311            collected.tool_definitions.len(),
4312            "tool implementations ({}) must match tool definitions ({})",
4313            collected.tools.len(),
4314            collected.tool_definitions.len(),
4315        );
4316    }
4317
4318    /// Regression test for EVE-189: collect_capabilities must resolve dependencies
4319    /// so that transitive capabilities register their tools even when not explicitly
4320    /// listed. Uses sample_data (depends on session_file_system) as the test case.
4321    #[tokio::test]
4322    async fn test_collect_capabilities_resolves_dependencies() {
4323        // sample_data depends on session_file_system
4324        // Passing only sample_data should still include session_file_system tools
4325        let registry = CapabilityRegistry::with_builtins();
4326        let collected =
4327            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
4328
4329        // Verify the transitive dependency capability itself was applied
4330        assert!(
4331            collected
4332                .applied_ids
4333                .iter()
4334                .any(|id| id == "session_file_system"),
4335            "collect_capabilities must apply session_file_system as a dependency; applied_ids: {:?}",
4336            collected.applied_ids
4337        );
4338
4339        let tool_names: Vec<&str> = collected
4340            .tool_definitions
4341            .iter()
4342            .map(|t| t.name())
4343            .collect();
4344
4345        // session_file_system provides these tools; both should be present
4346        assert!(
4347            tool_names.contains(&"read_file") && tool_names.contains(&"write_file"),
4348            "collect_capabilities must resolve dependencies and include dependency tools, got: {:?}",
4349            tool_names
4350        );
4351
4352        // Also verify tool implementations match definitions (dependency tools are executable)
4353        assert_eq!(
4354            collected.tools.len(),
4355            collected.tool_definitions.len(),
4356            "dependency-added tools must have implementations, not just definitions"
4357        );
4358    }
4359
4360    #[test]
4361    fn test_defaults_do_not_include_bash() {
4362        // ToolRegistry::with_defaults() must NOT include bash — it comes from
4363        // capabilities only. This documents the invariant that the bug violated.
4364        let registry = crate::ToolRegistry::with_defaults();
4365        assert!(
4366            !registry.has("bash"),
4367            "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4368        );
4369    }
4370
4371    // =========================================================================
4372    // EVE-501: background_execution auto-activation
4373    // =========================================================================
4374
4375    /// Auto-activation: any collected tool with `supports_background=true`
4376    /// causes `spawn_background` to appear in both tool_definitions and tools.
4377    #[tokio::test]
4378    async fn test_background_execution_auto_activates_with_bashkit_shell() {
4379        let registry = CapabilityRegistry::with_builtins();
4380        let collected =
4381            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
4382
4383        let tool_names: Vec<&str> = collected
4384            .tool_definitions
4385            .iter()
4386            .map(|t| t.name())
4387            .collect();
4388        assert!(
4389            tool_names.contains(&"spawn_background"),
4390            "spawn_background must be auto-activated when bashkit_shell (a \
4391             background-capable tool) is in the agent's capability set; got: {:?}",
4392            tool_names
4393        );
4394        assert!(
4395            collected
4396                .applied_ids
4397                .iter()
4398                .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID),
4399            "background_execution must be in applied_ids when auto-activated; \
4400             got: {:?}",
4401            collected.applied_ids
4402        );
4403
4404        // Lockstep: implementations match definitions (executable in the worker).
4405        assert!(
4406            collected
4407                .tools
4408                .iter()
4409                .any(|t| t.name() == "spawn_background"),
4410            "spawn_background tool implementation must be present alongside the \
4411             definition (lockstep contract)"
4412        );
4413    }
4414
4415    /// Negative: when no collected tool declares background support, the
4416    /// capability must NOT auto-activate.
4417    #[tokio::test]
4418    async fn test_background_execution_does_not_auto_activate_without_hint() {
4419        let registry = CapabilityRegistry::with_builtins();
4420        // current_time has no background-capable tool.
4421        let collected =
4422            collect_capabilities(&["current_time".to_string()], &registry, &test_ctx()).await;
4423
4424        let tool_names: Vec<&str> = collected
4425            .tool_definitions
4426            .iter()
4427            .map(|t| t.name())
4428            .collect();
4429        assert!(
4430            !tool_names.contains(&"spawn_background"),
4431            "spawn_background must NOT be activated without a background-capable \
4432             tool; got: {:?}",
4433            tool_names
4434        );
4435        assert!(
4436            !collected
4437                .applied_ids
4438                .iter()
4439                .any(|id| id == BACKGROUND_EXECUTION_CAPABILITY_ID),
4440            "background_execution must not appear in applied_ids when no \
4441             background-capable tool is present; got: {:?}",
4442            collected.applied_ids
4443        );
4444    }
4445
4446    /// Idempotence: explicitly selecting `background_execution` plus a
4447    /// background-capable tool must not produce duplicate spawn_background
4448    /// entries.
4449    #[tokio::test]
4450    async fn test_background_execution_explicit_selection_is_idempotent() {
4451        let registry = CapabilityRegistry::with_builtins();
4452        let collected = collect_capabilities(
4453            &[
4454                "bashkit_shell".to_string(),
4455                BACKGROUND_EXECUTION_CAPABILITY_ID.to_string(),
4456            ],
4457            &registry,
4458            &test_ctx(),
4459        )
4460        .await;
4461
4462        let spawn_background_count = collected
4463            .tool_definitions
4464            .iter()
4465            .filter(|t| t.name() == "spawn_background")
4466            .count();
4467        assert_eq!(
4468            spawn_background_count, 1,
4469            "spawn_background must appear exactly once even when \
4470             background_execution is selected explicitly alongside a \
4471             background-capable tool"
4472        );
4473        let applied_count = collected
4474            .applied_ids
4475            .iter()
4476            .filter(|id| id.as_str() == BACKGROUND_EXECUTION_CAPABILITY_ID)
4477            .count();
4478        assert_eq!(
4479            applied_count, 1,
4480            "background_execution must appear exactly once in applied_ids"
4481        );
4482    }
4483
4484    /// Lockstep: with_defaults() must NOT include spawn_background — it only
4485    /// reaches the worker registry through the auto-activated capability.
4486    /// This proves the executor cannot dispatch spawn_background without the
4487    /// model having seen it.
4488    #[test]
4489    fn test_defaults_do_not_include_spawn_background() {
4490        let registry = crate::ToolRegistry::with_defaults();
4491        assert!(
4492            !registry.has("spawn_background"),
4493            "with_defaults() must not include 'spawn_background' — it comes \
4494             from the background_execution capability (EVE-501)"
4495        );
4496    }
4497
4498    // =========================================================================
4499    // Feature tests
4500    // =========================================================================
4501
4502    #[test]
4503    fn test_capability_features_default_empty() {
4504        let registry = CapabilityRegistry::with_builtins();
4505
4506        // Most capabilities have no features
4507        let noop = registry.get("noop").unwrap();
4508        assert!(noop.features().is_empty());
4509
4510        let current_time = registry.get("current_time").unwrap();
4511        assert!(current_time.features().is_empty());
4512    }
4513
4514    #[test]
4515    fn test_file_system_capability_features() {
4516        let registry = CapabilityRegistry::with_builtins();
4517
4518        let fs = registry.get("session_file_system").unwrap();
4519        assert_eq!(fs.features(), vec!["file_system"]);
4520    }
4521
4522    #[test]
4523    fn test_bashkit_shell_capability_features() {
4524        let registry = CapabilityRegistry::with_builtins();
4525
4526        let bash = registry.get("bashkit_shell").unwrap();
4527        assert_eq!(bash.features(), vec!["file_system"]);
4528    }
4529
4530    #[test]
4531    fn test_alias_resolves_to_canonical_capability() {
4532        let registry = CapabilityRegistry::with_builtins();
4533
4534        // Legacy `virtual_bash` ID (persisted agent configs) must keep working.
4535        let via_alias = registry.get("virtual_bash").unwrap();
4536        assert_eq!(via_alias.id(), "bashkit_shell");
4537        assert!(registry.has("virtual_bash"));
4538        assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4539        assert_eq!(
4540            registry.canonical_id("bashkit_shell"),
4541            Some("bashkit_shell")
4542        );
4543        assert_eq!(registry.canonical_id("nonexistent"), None);
4544    }
4545
4546    #[test]
4547    fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4548        let registry = CapabilityRegistry::with_builtins();
4549
4550        // Selecting both the alias and the canonical ID must resolve to a
4551        // single activation under the canonical ID.
4552        let resolved = resolve_dependencies(
4553            &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4554            &registry,
4555        )
4556        .unwrap();
4557        let bash_ids: Vec<_> = resolved
4558            .resolved_ids
4559            .iter()
4560            .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4561            .collect();
4562        assert_eq!(bash_ids, vec!["bashkit_shell"]);
4563        // Selected via alias => not reported as "added as dependency".
4564        assert!(
4565            !resolved
4566                .added_as_dependencies
4567                .contains(&"bashkit_shell".to_string())
4568        );
4569    }
4570
4571    #[test]
4572    fn test_alias_preserves_explicit_config_in_resolution() {
4573        let registry = CapabilityRegistry::with_builtins();
4574
4575        let configs = vec![AgentCapabilityConfig::with_config(
4576            "virtual_bash".to_string(),
4577            serde_json::json!({"key": "value"}),
4578        )];
4579        let resolved = resolve_capability_configs(&configs, &registry).unwrap();
4580        let bash = resolved
4581            .iter()
4582            .find(|c| c.capability_id() == "bashkit_shell")
4583            .expect("alias must resolve to canonical bashkit_shell config");
4584        assert_eq!(bash.config, serde_json::json!({"key": "value"}));
4585    }
4586
4587    #[test]
4588    fn test_unregister_by_alias_removes_capability_and_aliases() {
4589        let mut registry = CapabilityRegistry::with_builtins();
4590
4591        assert!(registry.unregister("virtual_bash").is_some());
4592        assert!(!registry.has("bashkit_shell"));
4593        assert!(!registry.has("virtual_bash"));
4594    }
4595
4596    #[test]
4597    fn test_session_storage_capability_features() {
4598        let registry = CapabilityRegistry::with_builtins();
4599
4600        let storage = registry.get("session_storage").unwrap();
4601        let features = storage.features();
4602        assert!(features.contains(&"secrets"));
4603        assert!(features.contains(&"key_value"));
4604    }
4605
4606    #[test]
4607    fn test_session_schedule_capability_features() {
4608        let registry = CapabilityRegistry::with_builtins();
4609
4610        let schedule = registry.get("session_schedule").unwrap();
4611        assert_eq!(schedule.features(), vec!["schedules"]);
4612    }
4613
4614    #[test]
4615    fn test_session_sql_database_capability_features() {
4616        let registry = CapabilityRegistry::with_builtins();
4617
4618        let sql = registry.get("session_sql_database").unwrap();
4619        assert_eq!(sql.features(), vec!["sql_database"]);
4620    }
4621
4622    #[test]
4623    fn test_sample_data_capability_features() {
4624        let registry = CapabilityRegistry::with_builtins();
4625
4626        let sample = registry.get("sample_data").unwrap();
4627        assert_eq!(sample.features(), vec!["file_system"]);
4628    }
4629
4630    #[test]
4631    fn test_compute_features_empty() {
4632        let registry = CapabilityRegistry::with_builtins();
4633
4634        let features = compute_features(&[], &registry);
4635        assert!(features.is_empty());
4636    }
4637
4638    #[test]
4639    fn test_compute_features_single_capability() {
4640        let registry = CapabilityRegistry::with_builtins();
4641
4642        let features = compute_features(&["session_schedule".to_string()], &registry);
4643        assert_eq!(features, vec!["schedules"]);
4644    }
4645
4646    #[test]
4647    fn test_compute_features_multiple_capabilities() {
4648        let registry = CapabilityRegistry::with_builtins();
4649
4650        let features = compute_features(
4651            &[
4652                "session_file_system".to_string(),
4653                "session_storage".to_string(),
4654                "session_schedule".to_string(),
4655            ],
4656            &registry,
4657        );
4658        assert!(features.contains(&"file_system".to_string()));
4659        assert!(features.contains(&"secrets".to_string()));
4660        assert!(features.contains(&"key_value".to_string()));
4661        assert!(features.contains(&"schedules".to_string()));
4662    }
4663
4664    #[test]
4665    fn test_compute_features_deduplicates() {
4666        let registry = CapabilityRegistry::with_builtins();
4667
4668        // Both session_file_system and bashkit_shell contribute "file_system"
4669        let features = compute_features(
4670            &[
4671                "session_file_system".to_string(),
4672                "bashkit_shell".to_string(),
4673            ],
4674            &registry,
4675        );
4676        let file_system_count = features.iter().filter(|f| *f == "file_system").count();
4677        assert_eq!(file_system_count, 1, "file_system should appear only once");
4678    }
4679
4680    #[test]
4681    fn test_compute_features_includes_dependency_features() {
4682        let registry = CapabilityRegistry::with_builtins();
4683
4684        // bashkit_shell depends on session_file_system; both contribute "file_system"
4685        let features = compute_features(&["bashkit_shell".to_string()], &registry);
4686        assert!(features.contains(&"file_system".to_string()));
4687    }
4688
4689    #[test]
4690    fn test_compute_features_generic_harness_set() {
4691        let registry = CapabilityRegistry::with_builtins();
4692
4693        // Typical Generic Harness capabilities
4694        let features = compute_features(
4695            &[
4696                "session_file_system".to_string(),
4697                "bashkit_shell".to_string(),
4698                "session_storage".to_string(),
4699                "session".to_string(),
4700                "session_schedule".to_string(),
4701            ],
4702            &registry,
4703        );
4704        assert!(features.contains(&"file_system".to_string()));
4705        assert!(features.contains(&"secrets".to_string()));
4706        assert!(features.contains(&"key_value".to_string()));
4707        assert!(features.contains(&"schedules".to_string()));
4708    }
4709
4710    #[test]
4711    fn test_compute_features_unknown_capability_ignored() {
4712        let registry = CapabilityRegistry::with_builtins();
4713
4714        let features = compute_features(
4715            &["unknown_cap".to_string(), "session_schedule".to_string()],
4716            &registry,
4717        );
4718        assert_eq!(features, vec!["schedules"]);
4719    }
4720
4721    #[test]
4722    fn test_risk_level_ordering() {
4723        assert!(RiskLevel::Low < RiskLevel::Medium);
4724        assert!(RiskLevel::Medium < RiskLevel::High);
4725    }
4726
4727    #[test]
4728    fn test_risk_level_serde_roundtrip() {
4729        let high = RiskLevel::High;
4730        let json = serde_json::to_string(&high).unwrap();
4731        assert_eq!(json, "\"high\"");
4732        let back: RiskLevel = serde_json::from_str(&json).unwrap();
4733        assert_eq!(back, RiskLevel::High);
4734    }
4735
4736    #[test]
4737    fn test_capability_risk_levels() {
4738        let registry = CapabilityRegistry::with_builtins();
4739
4740        // bashkit_shell is High (code execution requires admin gating)
4741        let bash = registry.get("bashkit_shell").unwrap();
4742        assert_eq!(bash.risk_level(), RiskLevel::High);
4743
4744        // web_fetch is High (network access requires admin gating)
4745        let fetch = registry.get("web_fetch").unwrap();
4746        assert_eq!(fetch.risk_level(), RiskLevel::High);
4747
4748        // Default capabilities should be Low
4749        let noop = registry.get("noop").unwrap();
4750        assert_eq!(noop.risk_level(), RiskLevel::Low);
4751    }
4752
4753    // =========================================================================
4754    // OpenAI tool_search capability collection tests
4755    // =========================================================================
4756
4757    #[tokio::test]
4758    async fn test_apply_capabilities_openai_tool_search() {
4759        let registry = CapabilityRegistry::with_builtins();
4760        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
4761
4762        let applied = apply_capabilities(
4763            base_runtime_agent.clone(),
4764            &["openai_tool_search".to_string()],
4765            &registry,
4766            &test_ctx(),
4767        )
4768        .await;
4769
4770        // OpenAiToolSearchCapability provides no tools and no system prompt
4771        assert_eq!(
4772            applied.runtime_agent.system_prompt,
4773            base_runtime_agent.system_prompt
4774        );
4775        assert!(applied.tool_registry.is_empty());
4776        assert_eq!(applied.applied_ids, vec!["openai_tool_search"]);
4777
4778        // tool_search config should be set on the runtime agent
4779        let ts = applied.runtime_agent.tool_search.as_ref().unwrap();
4780        assert!(ts.enabled);
4781        assert_eq!(ts.threshold, DEFAULT_TOOL_SEARCH_THRESHOLD);
4782    }
4783
4784    #[tokio::test]
4785    async fn test_apply_capabilities_openai_tool_search_with_other_capabilities() {
4786        let registry = CapabilityRegistry::with_builtins();
4787        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
4788
4789        let applied = apply_capabilities(
4790            base_runtime_agent,
4791            &[
4792                "current_time".to_string(),
4793                "openai_tool_search".to_string(),
4794                "test_math".to_string(),
4795            ],
4796            &registry,
4797            &test_ctx(),
4798        )
4799        .await;
4800
4801        // Should have tools from current_time and test_math
4802        assert!(applied.tool_registry.has("get_current_time"));
4803        assert!(applied.tool_registry.has("add"));
4804        assert!(applied.tool_registry.has("subtract"));
4805        assert!(applied.tool_registry.has("multiply"));
4806        assert!(applied.tool_registry.has("divide"));
4807
4808        // tool_search should still be configured
4809        let ts = applied.runtime_agent.tool_search.as_ref().unwrap();
4810        assert!(ts.enabled);
4811        assert_eq!(ts.threshold, DEFAULT_TOOL_SEARCH_THRESHOLD);
4812    }
4813
4814    #[tokio::test]
4815    async fn test_collect_capabilities_tool_search_custom_threshold() {
4816        let registry = CapabilityRegistry::with_builtins();
4817
4818        let configs = vec![AgentCapabilityConfig {
4819            capability_ref: CapabilityId::new("openai_tool_search"),
4820            config: serde_json::json!({"threshold": 5}),
4821        }];
4822
4823        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4824
4825        let ts = collected.tool_search.as_ref().unwrap();
4826        assert!(ts.enabled);
4827        assert_eq!(ts.threshold, 5);
4828    }
4829
4830    #[tokio::test]
4831    async fn test_collect_capabilities_auto_tool_search_resolves_to_generic_off_native() {
4832        let registry = CapabilityRegistry::with_builtins();
4833
4834        let configs = vec![
4835            AgentCapabilityConfig {
4836                capability_ref: CapabilityId::new("auto_tool_search"),
4837                config: serde_json::json!({"threshold": 2}),
4838            },
4839            AgentCapabilityConfig {
4840                capability_ref: CapabilityId::new("test_math"),
4841                config: serde_json::json!({}),
4842            },
4843        ];
4844
4845        // No native support (pre-4 Claude) → resolves to the generic client-side
4846        // mechanism: no hosted config, but the tool_search tool + DeferSchemaHook
4847        // are collected.
4848        let ctx = test_ctx().with_model("claude-3-5-haiku");
4849        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
4850
4851        assert!(
4852            collected.tool_search.is_none(),
4853            "auto_tool_search must not set a hosted config on a non-native model"
4854        );
4855        assert!(
4856            collected
4857                .tools
4858                .iter()
4859                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
4860            "auto_tool_search must contribute the client-side tool_search tool"
4861        );
4862        assert!(
4863            !collected.tool_definition_hooks.is_empty(),
4864            "auto_tool_search must contribute a client-side deferral hook"
4865        );
4866
4867        let mut transformed = collected.tool_definitions.clone();
4868        for hook in &collected.tool_definition_hooks {
4869            transformed = hook.transform(transformed);
4870        }
4871        let add_tool = transformed
4872            .iter()
4873            .find(|tool| tool.name() == "add")
4874            .expect("test_math contributes add");
4875        assert!(
4876            add_tool.parameters().get("properties").is_none(),
4877            "generic auto_tool_search must honor the configured threshold"
4878        );
4879    }
4880
4881    #[tokio::test]
4882    async fn test_collect_capabilities_auto_tool_search_resolves_to_hosted_on_native() {
4883        let registry = CapabilityRegistry::with_builtins();
4884
4885        let configs = vec![AgentCapabilityConfig {
4886            capability_ref: CapabilityId::new("auto_tool_search"),
4887            config: serde_json::json!({"threshold": 7}),
4888        }];
4889
4890        // Native support → resolves to the hosted OpenAI mechanism: a hosted
4891        // config (honoring the configured threshold) and no client-side tool/hook.
4892        let ctx = test_ctx().with_model("gpt-5.4");
4893        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
4894
4895        let ts = collected
4896            .tool_search
4897            .as_ref()
4898            .expect("auto_tool_search must set a hosted config on a native model");
4899        assert!(ts.enabled);
4900        assert_eq!(ts.threshold, 7);
4901        assert!(
4902            !collected
4903                .tools
4904                .iter()
4905                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
4906            "hosted mechanism must not contribute the client-side tool_search tool"
4907        );
4908        assert!(
4909            collected.tool_definition_hooks.is_empty(),
4910            "hosted mechanism must not contribute a client-side deferral hook"
4911        );
4912    }
4913
4914    #[tokio::test]
4915    async fn test_collect_capabilities_auto_tool_search_resolves_to_hosted_on_anthropic() {
4916        let registry = CapabilityRegistry::with_builtins();
4917
4918        let configs = vec![AgentCapabilityConfig {
4919            capability_ref: CapabilityId::new("auto_tool_search"),
4920            config: serde_json::json!({"threshold": 9}),
4921        }];
4922
4923        // Native Claude support → resolves to the hosted Anthropic mechanism: a
4924        // hosted config (honoring the threshold) and no client-side tool/hook.
4925        let ctx = test_ctx().with_model("claude-opus-4-8");
4926        let collected = collect_capabilities_with_configs(&configs, &registry, &ctx).await;
4927
4928        let ts = collected
4929            .tool_search
4930            .as_ref()
4931            .expect("auto_tool_search must set a hosted config on a native Claude model");
4932        assert!(ts.enabled);
4933        assert_eq!(ts.threshold, 9);
4934        assert!(
4935            !collected
4936                .tools
4937                .iter()
4938                .any(|t| t.name() == TOOL_SEARCH_TOOL_NAME),
4939            "hosted mechanism must not contribute the client-side tool_search tool"
4940        );
4941        assert!(
4942            collected.tool_definition_hooks.is_empty(),
4943            "hosted mechanism must not contribute a client-side deferral hook"
4944        );
4945    }
4946
4947    #[tokio::test]
4948    async fn test_collect_capabilities_no_tool_search_without_capability() {
4949        let registry = CapabilityRegistry::with_builtins();
4950
4951        let configs = vec![AgentCapabilityConfig {
4952            capability_ref: CapabilityId::new("current_time"),
4953            config: serde_json::json!({}),
4954        }];
4955
4956        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4957
4958        assert!(collected.tool_search.is_none());
4959    }
4960
4961    #[tokio::test]
4962    async fn test_collect_capabilities_tool_search_category_propagation() {
4963        let registry = CapabilityRegistry::with_builtins();
4964
4965        // test_math capability has category "Testing"
4966        let configs = vec![
4967            AgentCapabilityConfig {
4968                capability_ref: CapabilityId::new("test_math"),
4969                config: serde_json::json!({}),
4970            },
4971            AgentCapabilityConfig {
4972                capability_ref: CapabilityId::new("openai_tool_search"),
4973                config: serde_json::json!({}),
4974            },
4975        ];
4976
4977        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4978
4979        // Verify tool_search is configured
4980        assert!(collected.tool_search.is_some());
4981
4982        // Verify tools have categories from their capability
4983        for tool_def in &collected.tool_definitions {
4984            // test_math tools should have the Math category
4985            if ["add", "subtract", "multiply", "divide"].contains(&tool_def.name()) {
4986                assert!(
4987                    tool_def.category().is_some(),
4988                    "Tool {} should have a category from its capability",
4989                    tool_def.name()
4990                );
4991            }
4992        }
4993    }
4994
4995    #[tokio::test]
4996    async fn test_apply_capabilities_prompt_caching() {
4997        let registry = CapabilityRegistry::with_builtins();
4998        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
4999
5000        let applied = apply_capabilities(
5001            base_runtime_agent.clone(),
5002            &["prompt_caching".to_string()],
5003            &registry,
5004            &test_ctx(),
5005        )
5006        .await;
5007
5008        assert_eq!(
5009            applied.runtime_agent.system_prompt,
5010            base_runtime_agent.system_prompt
5011        );
5012        assert!(applied.tool_registry.is_empty());
5013        assert_eq!(applied.applied_ids, vec!["prompt_caching"]);
5014
5015        let prompt_cache = applied.runtime_agent.prompt_cache.as_ref().unwrap();
5016        assert!(prompt_cache.enabled);
5017        assert_eq!(
5018            prompt_cache.strategy,
5019            crate::driver_registry::PromptCacheStrategy::Auto
5020        );
5021        assert!(prompt_cache.gemini_cached_content.is_none());
5022    }
5023
5024    #[tokio::test]
5025    async fn test_apply_capabilities_openrouter_server_tools() {
5026        let registry = CapabilityRegistry::with_builtins();
5027        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.4");
5028
5029        let configs = vec![AgentCapabilityConfig {
5030            capability_ref: CapabilityId::new("openrouter_server_tools"),
5031            config: serde_json::json!({
5032                "tools": ["web_search", "datetime"],
5033                "web_search_max_results": 4,
5034            }),
5035        }];
5036
5037        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5038        let routing = collected
5039            .openrouter_routing
5040            .as_ref()
5041            .expect("server tools produce routing config");
5042        let kinds: Vec<_> = routing.server_tools.iter().map(|t| t.kind).collect();
5043        assert_eq!(
5044            kinds,
5045            vec![
5046                crate::driver_registry::OpenRouterServerToolKind::WebSearch,
5047                crate::driver_registry::OpenRouterServerToolKind::Datetime,
5048            ]
5049        );
5050
5051        // The capability contributes request intent only — no executable tools.
5052        // With no tools selected (bare id, empty config) it is a no-op.
5053        let applied = apply_capabilities(
5054            base_runtime_agent,
5055            &["openrouter_server_tools".to_string()],
5056            &registry,
5057            &test_ctx(),
5058        )
5059        .await;
5060        assert!(applied.tool_registry.is_empty());
5061        assert!(applied.runtime_agent.openrouter_routing.is_none());
5062    }
5063
5064    #[tokio::test]
5065    async fn test_collect_capabilities_prompt_caching_custom_strategy() {
5066        let registry = CapabilityRegistry::with_builtins();
5067
5068        let configs = vec![AgentCapabilityConfig {
5069            capability_ref: CapabilityId::new("prompt_caching"),
5070            config: serde_json::json!({"strategy": "auto"}),
5071        }];
5072
5073        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5074
5075        let prompt_cache = collected.prompt_cache.as_ref().unwrap();
5076        assert!(prompt_cache.enabled);
5077        assert_eq!(
5078            prompt_cache.strategy,
5079            crate::driver_registry::PromptCacheStrategy::Auto
5080        );
5081        assert!(prompt_cache.gemini_cached_content.is_none());
5082    }
5083
5084    #[tokio::test]
5085    async fn test_collect_capabilities_prompt_caching_gemini_cached_content() {
5086        let registry = CapabilityRegistry::with_builtins();
5087
5088        let configs = vec![AgentCapabilityConfig {
5089            capability_ref: CapabilityId::new("prompt_caching"),
5090            config: serde_json::json!({
5091                "strategy": "auto",
5092                "gemini_cached_content": "cachedContents/demo-cache"
5093            }),
5094        }];
5095
5096        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5097
5098        let prompt_cache = collected.prompt_cache.as_ref().unwrap();
5099        assert_eq!(
5100            prompt_cache.gemini_cached_content.as_deref(),
5101            Some("cachedContents/demo-cache")
5102        );
5103    }
5104
5105    #[tokio::test]
5106    async fn test_collect_capabilities_parallel_tool_calls_modes() {
5107        let registry = CapabilityRegistry::with_builtins();
5108
5109        // Default (no explicit mode) => prefer => Some(true).
5110        let collected = collect_capabilities_with_configs(
5111            &[AgentCapabilityConfig::new("parallel_tool_calls")],
5112            &registry,
5113            &test_ctx(),
5114        )
5115        .await;
5116        assert_eq!(collected.parallel_tool_calls, Some(true));
5117
5118        // avoid => Some(false).
5119        let collected = collect_capabilities_with_configs(
5120            &[AgentCapabilityConfig {
5121                capability_ref: CapabilityId::new("parallel_tool_calls"),
5122                config: serde_json::json!({"mode": "avoid"}),
5123            }],
5124            &registry,
5125            &test_ctx(),
5126        )
5127        .await;
5128        assert_eq!(collected.parallel_tool_calls, Some(false));
5129
5130        // none => None (provider default).
5131        let collected = collect_capabilities_with_configs(
5132            &[AgentCapabilityConfig {
5133                capability_ref: CapabilityId::new("parallel_tool_calls"),
5134                config: serde_json::json!({"mode": "none"}),
5135            }],
5136            &registry,
5137            &test_ctx(),
5138        )
5139        .await;
5140        assert_eq!(collected.parallel_tool_calls, None);
5141
5142        // Capability absent => None.
5143        let collected = collect_capabilities_with_configs(&[], &registry, &test_ctx()).await;
5144        assert_eq!(collected.parallel_tool_calls, None);
5145    }
5146
5147    #[tokio::test]
5148    async fn test_apply_capabilities_parallel_tool_calls_precedence() {
5149        let registry = CapabilityRegistry::with_builtins();
5150
5151        // Capability supplies the preference when no explicit field is set.
5152        let applied = apply_capabilities(
5153            RuntimeAgent::new("p", "gpt-5.2"),
5154            &["parallel_tool_calls".to_string()],
5155            &registry,
5156            &test_ctx(),
5157        )
5158        .await;
5159        assert_eq!(applied.runtime_agent.parallel_tool_calls, Some(true));
5160
5161        // Explicit field (escape hatch) wins over the capability.
5162        let mut base = RuntimeAgent::new("p", "gpt-5.2");
5163        base.parallel_tool_calls = Some(false);
5164        let applied = apply_capabilities(
5165            base,
5166            &["parallel_tool_calls".to_string()],
5167            &registry,
5168            &test_ctx(),
5169        )
5170        .await;
5171        assert_eq!(applied.runtime_agent.parallel_tool_calls, Some(false));
5172    }
5173
5174    // ========================================================================
5175    // contribute_skills() collection — EVE-311
5176    // ========================================================================
5177
5178    struct SkillContributingCapability;
5179
5180    impl Capability for SkillContributingCapability {
5181        fn id(&self) -> &str {
5182            "contributes_skills"
5183        }
5184        fn name(&self) -> &str {
5185            "Contributes Skills"
5186        }
5187        fn description(&self) -> &str {
5188            "Test capability that contributes skills."
5189        }
5190        fn contribute_skills(&self) -> Vec<SkillContribution> {
5191            vec![
5192                SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
5193                    .with_files(vec![(
5194                        "scripts/a.sh".to_string(),
5195                        "#!/bin/sh\necho a\n".to_string(),
5196                    )]),
5197                SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
5198                    .with_user_invocable(false),
5199            ]
5200        }
5201    }
5202
5203    fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
5204        match &entries.get("SKILL.md").expect("SKILL.md missing").source {
5205            MountSource::InlineFile { content, .. } => content.as_str(),
5206            _ => panic!("Expected InlineFile for SKILL.md"),
5207        }
5208    }
5209
5210    #[tokio::test]
5211    async fn test_contribute_skills_normalized_to_mounts() {
5212        let mut registry = CapabilityRegistry::new();
5213        registry.register(SkillContributingCapability);
5214
5215        let configs = vec![AgentCapabilityConfig {
5216            capability_ref: CapabilityId::new("contributes_skills"),
5217            config: serde_json::json!({}),
5218        }];
5219
5220        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5221
5222        let skill_mounts: Vec<_> = collected
5223            .mounts
5224            .iter()
5225            .filter(|m| m.path.starts_with("/.agents/skills/"))
5226            .collect();
5227        assert_eq!(skill_mounts.len(), 2);
5228
5229        // Every contributed skill mount is read-only and owned by the contributing
5230        // capability so the VFS layer can attribute skill files correctly.
5231        for m in &skill_mounts {
5232            assert!(m.is_readonly());
5233            assert_eq!(m.capability_id, "contributes_skills");
5234        }
5235
5236        let alpha = skill_mounts
5237            .iter()
5238            .find(|m| m.path == "/.agents/skills/alpha-skill")
5239            .expect("alpha-skill mount missing");
5240        match &alpha.source {
5241            MountSource::InlineDirectory { entries } => {
5242                assert!(entries.contains_key("SKILL.md"));
5243                assert!(entries.contains_key("scripts/a.sh"));
5244                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
5245                assert_eq!(parsed.name, "alpha-skill");
5246                assert!(parsed.user_invocable);
5247            }
5248            _ => panic!("Expected InlineDirectory"),
5249        }
5250
5251        let beta = skill_mounts
5252            .iter()
5253            .find(|m| m.path == "/.agents/skills/beta-skill")
5254            .expect("beta-skill mount missing");
5255        match &beta.source {
5256            MountSource::InlineDirectory { entries } => {
5257                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
5258                assert!(!parsed.user_invocable);
5259            }
5260            _ => panic!("Expected InlineDirectory"),
5261        }
5262    }
5263
5264    #[tokio::test]
5265    async fn test_contribute_skills_default_empty() {
5266        // Registry-resident capability without a contribute_skills override
5267        // must not add skill mounts.
5268        let mut registry = CapabilityRegistry::new();
5269        registry.register(FilterTestCapability { priority: 0 });
5270
5271        let configs = vec![AgentCapabilityConfig {
5272            capability_ref: CapabilityId::new("filter_test"),
5273            config: serde_json::json!({}),
5274        }];
5275
5276        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
5277        assert!(
5278            collected
5279                .mounts
5280                .iter()
5281                .all(|m| !m.path.starts_with("/.agents/skills/"))
5282        );
5283    }
5284
5285    struct LocalizedCapability;
5286
5287    impl Capability for LocalizedCapability {
5288        fn id(&self) -> &str {
5289            "localized"
5290        }
5291        fn name(&self) -> &str {
5292            "Localized"
5293        }
5294        fn description(&self) -> &str {
5295            "English description"
5296        }
5297        fn localizations(&self) -> Vec<CapabilityLocalization> {
5298            vec![
5299                CapabilityLocalization {
5300                    locale: "en",
5301                    name: None,
5302                    description: None,
5303                    config_description: Some("Controls things."),
5304                    config_overlay: None,
5305                },
5306                CapabilityLocalization {
5307                    locale: "uk",
5308                    name: Some("Локалізована"),
5309                    description: Some("Український опис"),
5310                    config_description: Some("Керує налаштуваннями."),
5311                    config_overlay: None,
5312                },
5313            ]
5314        }
5315    }
5316
5317    #[test]
5318    fn localized_name_falls_back_exact_language_then_base() {
5319        let cap = LocalizedCapability;
5320        // Region tag resolves through the language family.
5321        assert_eq!(cap.localized_name(Some("uk-UA")), "Локалізована");
5322        assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
5323        // Underscore-separated tags are normalized.
5324        assert_eq!(cap.localized_name(Some("uk_UA")), "Локалізована");
5325        // Unsupported locales and None fall back to the base name.
5326        assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
5327        assert_eq!(cap.localized_name(None), "Localized");
5328        assert_eq!(cap.localized_description(Some("uk")), "Український опис");
5329        assert_eq!(cap.localized_description(Some("de")), "English description");
5330    }
5331
5332    #[test]
5333    fn describe_schema_resolves_config_description_per_locale() {
5334        let cap = LocalizedCapability;
5335        assert_eq!(
5336            cap.describe_schema(Some("uk-UA")).as_deref(),
5337            Some("Керує налаштуваннями.")
5338        );
5339        // Unsupported locales fall back to the "en" entry.
5340        assert_eq!(
5341            cap.describe_schema(Some("pl")).as_deref(),
5342            Some("Controls things.")
5343        );
5344        assert_eq!(
5345            cap.describe_schema(None).as_deref(),
5346            Some("Controls things.")
5347        );
5348        // Capabilities without localizations have no config description.
5349        assert_eq!(NoopCapability.describe_schema(Some("uk")), None);
5350    }
5351}