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