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