Skip to main content

everruns_core/capabilities/
mod.rs

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