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