Skip to main content

everruns_core/capabilities/
mod.rs

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