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