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