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