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