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