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