Skip to main content

everruns_core/capabilities/
mod.rs

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