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