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