Skip to main content

everruns_core/capabilities/
mod.rs

1//! Capabilities Module for Agent Loop
2//!
3//! This module provides the capabilities abstraction that allows composing
4//! agent functionality through modular units. Each capability can contribute:
5//! - System prompt additions
6//! - Tools for the agent
7//! - Behavior modifications (future)
8//!
9//! Design decisions:
10//! - Capabilities are defined via the Capability trait for flexibility
11//! - CapabilityRegistry holds all available capability implementations
12//! - apply_capabilities() merges capability contributions into RuntimeAgent
13//! - The agent-loop remains execution-focused; capabilities are applied before execution
14//! - System prompt sections use XML tags for clear boundaries between components.
15//!   This follows Anthropic's recommendation for multi-component prompts and reduces
16//!   misattribution between capability instructions, user-provided AGENTS.md, and the
17//!   agent's base system prompt. See knowledge/project/xml-prompt-formatting.md for rationale.
18//!
19//! Each capability is in its own file with collocated tools.
20
21use crate::command::{
22    CommandDescriptor, CommandExecutionContext, CommandResult, ExecuteCommandRequest,
23};
24use crate::events::TokenUsage;
25use crate::mcp_server::{ScopedMcpServers, merge_scoped_mcp_servers};
26use crate::message::Message;
27use crate::message_filter::MessageFilterProvider;
28use crate::runtime_agent::RuntimeAgent;
29use crate::tool_types::{ToolCall, ToolDefinition};
30use crate::tools::{Tool, ToolExecutionResult, ToolRegistry};
31use crate::typed_id::SessionId;
32use crate::{session_files::SessionFileSystem, tool_context::ToolContext};
33use async_trait::async_trait;
34use everruns_capability::is_plugin_capability;
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::sync::Arc;
38
39// ============================================================================
40// Integration Plugin System
41// ============================================================================
42
43/// Plugin registration point for external integration crates.
44///
45/// Integration crates use `inventory::submit!` to register their capabilities
46/// without requiring `everruns-core` to know about them at compile time.
47/// Host or product composition iterates these descriptors and applies its
48/// deployment-grade and feature-selection policy. Core only owns the neutral
49/// registration contract.
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, product composition registers this only for experimental grades.
65    pub experimental_only: bool,
66    /// If set, only registered when the named deployment feature flag is enabled.
67    /// Resolved at registry build time via `ExecutionFeatureDecisions`: internal
68    /// infrastructure flags first, otherwise the explicit `FEATURE_<NAME>` env
69    /// var (fail-closed — no grade-based default for registration gates).
70    pub feature_flag: Option<&'static str>,
71    /// Factory function that creates the capability instance.
72    pub factory: fn() -> Box<dyn Capability>,
73}
74
75inventory::collect!(IntegrationPlugin);
76
77pub use crate::capability_types::{
78    CapabilityStatus, MountAccess, MountDirectoryBuilder, MountEntry, MountPoint, MountSource,
79};
80use everruns_capability::{CapabilityId, CapabilityRef as AgentCapabilityConfig};
81
82// ============================================================================
83// Capability contract modules
84// ============================================================================
85
86mod declarative;
87pub mod facts;
88pub mod skill_contribution;
89pub mod util;
90
91// Re-export capabilities
92/// Capability ID for outbound A2A agent delegation. Defined ungated so session
93/// attachment logic can reference it even when the `a2a` feature (and the
94/// delegation implementation) is compiled out.
95pub const A2A_AGENT_DELEGATION_CAPABILITY_ID: &str = "a2a_agent_delegation";
96/// KV key prefix for A2A delegation run records. Defined ungated so the
97/// session-storage internal-prefix reservation (a TM-TOOL/TM-AGENT mitigation
98/// against forged attachments) holds even when the `a2a` feature is compiled out.
99pub const AGENT_RUN_KEY_PREFIX: &str = "agent_run:";
100/// Shared concurrency class for every provider of the model-visible
101/// `spawn_agent` tool. Implementations live in host/integration crates, while
102/// collection keeps the merged tool serialized through this neutral key.
103pub const SPAWN_AGENT_CONCURRENCY_CLASS: &str = "spawn_agent";
104pub use declarative::{
105    DECLARATIVE_CAPABILITY_PREFIX, DeclarativeCapabilityDefinition, DeclarativeCapabilityFile,
106    DeclarativeCapabilitySkill, DeclarativeCapabilitySkillFile, declarative_capability_id,
107    declarative_capability_info, hydrate_declarative_capability_config,
108    hydrate_plugin_capability_config, is_declarative_capability, parse_declarative_capability_id,
109    plugin_capability_info, validate_declarative_capability_definition,
110};
111pub use facts::{FACTS_DYNAMIC_NOTE, Fact, FactsContext, Volatility, render_facts_block};
112pub use skill_contribution::{
113    MAX_SKILLS_PER_CAPABILITY, SKILL_CAPABILITY_PREFIX, SKILLS_DISCOVERY_PATH,
114    SkillCapabilityIdExt, SkillContribution, SkillInstructions, SkillMeta, SkillSource,
115    discover_skills_from_entries, is_skill_capability, parse_skill_capability_id,
116    reconstruct_skill_md, skill_capability_id,
117};
118
119// ============================================================================
120// System Prompt Context
121// ============================================================================
122
123/// Context provided to capabilities when resolving dynamic system prompt contributions.
124///
125/// This gives capabilities access to session-specific resources (filesystem, etc.)
126/// so they can generate system prompt content at runtime rather than returning
127/// only static text.
128pub struct SystemPromptContext {
129    /// The current session ID
130    pub session_id: SessionId,
131    /// Optional locale for localized prompts and tool behavior.
132    pub locale: Option<String>,
133    /// Optional file store for reading session files (e.g., AGENTS.md)
134    pub file_store: Option<Arc<dyn SessionFileSystem>>,
135    /// The model the agent will run on, when known at collection time.
136    ///
137    /// Enables model-adaptive capabilities (see [`Capability::resolve_for_model`],
138    /// e.g. `auto_tool_search`). `None` when the model is not yet resolved; such
139    /// capabilities then fall back to their provider-agnostic behavior.
140    pub model: Option<String>,
141}
142
143impl SystemPromptContext {
144    /// Create context with no file store (for callers that don't need filesystem access)
145    pub fn without_file_store(session_id: SessionId) -> Self {
146        Self {
147            session_id,
148            locale: None,
149            file_store: None,
150            model: None,
151        }
152    }
153
154    /// Set the model the agent will run on (drives model-adaptive capabilities).
155    pub fn with_model(mut self, model: impl Into<String>) -> Self {
156        self.model = Some(model.into());
157        self
158    }
159}
160
161// ============================================================================
162// Capability Trait
163// ============================================================================
164
165/// Trait for implementing capabilities that extend agent functionality.
166///
167/// A capability can contribute:
168/// - System prompt additions (appended after the agent's base system prompt)
169/// - Tools (added to agent's available tools)
170///
171/// # System Prompt Contributions
172///
173/// Capabilities provide system prompt content via `system_prompt_contribution()`.
174/// This async method receives a `SystemPromptContext` with access to the session
175/// filesystem, allowing capabilities to generate dynamic content (e.g., reading
176/// AGENTS.md or scanning for skills).
177///
178/// The default implementation wraps the static `system_prompt_addition()` text
179/// in `<capability id="...">` XML tags. Capabilities that need dynamic content
180/// override `system_prompt_contribution()` directly.
181///
182/// # Example
183///
184/// ```ignore
185/// use everruns_core::capabilities::Capability;
186///
187/// struct CurrentTimeCapability;
188///
189/// impl Capability for CurrentTimeCapability {
190///     fn id(&self) -> &str {
191///         "current_time"
192///     }
193///
194///     fn name(&self) -> &str {
195///         "Current Time"
196///     }
197///
198///     fn description(&self) -> &str {
199///         "Provides tools to get the current date and time."
200///     }
201///
202///     fn tools(&self) -> Vec<Box<dyn Tool>> {
203///         vec![Box::new(GetCurrentTimeTool)]
204///     }
205/// }
206/// ```
207/// Localized display strings for one locale.
208///
209/// Base English strings stay in `name()` / `description()` / `config_schema()`;
210/// localizations are additive overlays, so adding a locale never changes the
211/// `Capability` trait contract for existing implementations.
212#[derive(Debug, Clone)]
213pub struct CapabilityLocalization {
214    /// Language tag this entry applies to, lowercase (e.g. `"uk"` or `"uk-ua"`).
215    pub locale: &'static str,
216    /// Localized display name; `None` falls back to `name()`.
217    pub name: Option<&'static str>,
218    /// Localized description; `None` falls back to `description()`.
219    pub description: Option<&'static str>,
220    /// One-line summary of what this capability's config controls.
221    ///
222    /// Provide an `"en"` entry for the base locale; capabilities without
223    /// config leave this `None` everywhere.
224    pub config_description: Option<&'static str>,
225    /// Overlay merged into `config_schema()` by clients before rendering.
226    ///
227    /// Mirrors JSON Schema structure (`properties` / `items` nesting); nodes
228    /// carry `title`, `description`, and `enum_labels` (map from enum value
229    /// to localized label, applied to `oneOf` `const`/`title` entries).
230    pub config_overlay: Option<serde_json::Value>,
231}
232
233impl CapabilityLocalization {
234    /// Entry with only display strings (no config).
235    pub fn text(locale: &'static str, name: &'static str, description: &'static str) -> Self {
236        Self {
237            locale,
238            name: Some(name),
239            description: Some(description),
240            config_description: None,
241            config_overlay: None,
242        }
243    }
244}
245
246/// Resolve a localized field with the standard fallback chain:
247/// exact tag → language family → `"en"`. Returns `None` when no entry
248/// provides the field; callers fall back to the unlocalized trait values.
249pub fn resolve_localized_field<T>(
250    localizations: &[CapabilityLocalization],
251    locale: Option<&str>,
252    field: impl Fn(&CapabilityLocalization) -> Option<T>,
253) -> Option<T> {
254    let mut candidates: Vec<String> = Vec::new();
255    if let Some(raw) = locale {
256        let normalized = raw.trim().replace('_', "-").to_lowercase();
257        if !normalized.is_empty() {
258            if let Some((language, _)) = normalized.split_once('-') {
259                let language = language.to_string();
260                candidates.push(normalized);
261                candidates.push(language);
262            } else {
263                candidates.push(normalized);
264            }
265        }
266    }
267    candidates.push("en".to_string());
268
269    for candidate in candidates {
270        let hit = localizations
271            .iter()
272            .find(|entry| entry.locale.eq_ignore_ascii_case(&candidate))
273            .and_then(&field);
274        if hit.is_some() {
275            return hit;
276        }
277    }
278    None
279}
280
281#[async_trait]
282pub trait Capability: Send + Sync {
283    /// Returns the unique capability identifier as a string
284    fn id(&self) -> &str;
285
286    /// Returns legacy identifiers that resolve to this capability.
287    ///
288    /// Aliases exist so a capability can be renamed without breaking persisted
289    /// agent configs: registry lookups (`get`, `has`) and dependency resolution
290    /// treat an alias exactly like the canonical `id()`. Resolution always
291    /// normalizes aliases to the canonical ID, so an alias and its canonical
292    /// ID never activate the capability twice. New code must use `id()`;
293    /// aliases are a compatibility surface only.
294    fn aliases(&self) -> Vec<&'static str> {
295        vec![]
296    }
297
298    /// Returns the display name
299    fn name(&self) -> &str;
300
301    /// Returns a description of what this capability provides
302    fn description(&self) -> &str;
303
304    /// Returns localization overlays for this capability's display strings.
305    ///
306    /// Include an `"en"` entry when providing `config_description` for the
307    /// base locale. Lookup follows `resolve_localized_field` fallback rules.
308    fn localizations(&self) -> Vec<CapabilityLocalization> {
309        vec![]
310    }
311
312    /// Display name resolved for `locale`; `None` or unknown locales fall
313    /// back to `name()`.
314    fn localized_name(&self, locale: Option<&str>) -> String {
315        resolve_localized_field(&self.localizations(), locale, |entry| entry.name)
316            .unwrap_or_else(|| self.name())
317            .to_string()
318    }
319
320    /// Description resolved for `locale`; falls back to `description()`.
321    fn localized_description(&self, locale: Option<&str>) -> String {
322        resolve_localized_field(&self.localizations(), locale, |entry| entry.description)
323            .unwrap_or_else(|| self.description())
324            .to_string()
325    }
326
327    /// One-line human-readable summary of what this capability's config
328    /// controls, resolved for `locale`. `None` when the capability exposes
329    /// no per-agent config.
330    fn describe_schema(&self, locale: Option<&str>) -> Option<String> {
331        resolve_localized_field(&self.localizations(), locale, |entry| {
332            entry.config_description
333        })
334        .map(str::to_string)
335    }
336
337    /// Returns the current status of this capability
338    fn status(&self) -> CapabilityStatus {
339        CapabilityStatus::Available
340    }
341
342    /// Returns the icon name for UI rendering (optional)
343    fn icon(&self) -> Option<&str> {
344        None
345    }
346
347    /// Returns the category for grouping in UI (optional)
348    fn category(&self) -> Option<&str> {
349        None
350    }
351
352    /// Host-owned annotations that core does not interpret.
353    ///
354    /// The typed accessors above (`category`, `status`, `is_guardrail`, …) are
355    /// the vocabulary core itself reasons about. This is the escape hatch for
356    /// everything a *host* wants to carry alongside a capability — a UI icon,
357    /// an embedder's grouping key, deployment provenance — without adding a
358    /// field to core for each one. Core reads nothing here.
359    ///
360    /// The schema belongs to whoever writes it. Never put credentials or other
361    /// sensitive payload here: it is surfaced to clients alongside the rest of
362    /// the capability descriptor.
363    fn metadata(&self) -> Option<serde_json::Value> {
364        None
365    }
366
367    /// Whether this capability is a guardrail — a constraint on agent
368    /// behavior (content checks, tool restrictions) rather than a grant of
369    /// new abilities. Structural marker for UI sections and catalog
370    /// filtering; carries no runtime semantics. See knowledge/execution/guardrails.md.
371    fn is_guardrail(&self) -> bool {
372        false
373    }
374
375    /// Model-adaptive dispatch: delegate this capability's contributions to a
376    /// different underlying capability based on the agent's model.
377    ///
378    /// Capability collection (which knows the model via
379    /// [`SystemPromptContext::model`]) calls this and, when it returns `Some`,
380    /// collects the returned capability's contributions in place of this one's.
381    /// The default returns `None` (no delegation). `auto_tool_search` overrides
382    /// it to pick hosted vs client-side tool search. `model` is `None` when not
383    /// yet resolved; implementations should choose a safe provider-agnostic
384    /// default in that case.
385    fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
386        None
387    }
388
389    /// Returns static text to include in the agent's system prompt (optional).
390    ///
391    /// This is the simple sync path for capabilities with static prompts.
392    /// For dynamic content that requires filesystem access, override
393    /// `system_prompt_contribution()` instead.
394    ///
395    /// **Contract: no duplication with tool definitions.** System prompt
396    /// additions must NOT repeat information already present in tool names,
397    /// descriptions, or parameter schemas. Only include content that cannot
398    /// be inferred from tool definitions alone:
399    ///
400    /// - High-level semantics (when to use which tool, behavioral guidance)
401    /// - Constraints the model cannot discover from schemas (row limits,
402    ///   naming rules, workspace root paths, scheduling limits)
403    /// - Data layout (filesystem paths for state files)
404    /// - Cross-tool relationships or ordering not evident from descriptions
405    ///
406    /// If every piece of information in the prompt is already covered by the
407    /// tool definitions, return `None` instead.
408    fn system_prompt_addition(&self) -> Option<&str> {
409        None
410    }
411
412    /// Returns the system prompt contribution for this capability, with access
413    /// to session context (filesystem, etc.).
414    ///
415    /// This is the primary method for contributing to the system prompt.
416    /// The returned string is included as-is in the final prompt (the capability
417    /// is responsible for its own XML wrapping).
418    ///
419    /// The default implementation wraps `system_prompt_addition()` in
420    /// `<capability id="...">` XML tags. Capabilities with dynamic content
421    /// (e.g., `agent_instructions`, `skills`) override this to read from the
422    /// session filesystem.
423    async fn system_prompt_contribution(&self, _ctx: &SystemPromptContext) -> Option<String> {
424        self.system_prompt_addition().map(|addition| {
425            format!(
426                "<capability id=\"{}\">\n{}\n</capability>",
427                self.id(),
428                addition
429            )
430        })
431    }
432
433    /// Returns a preview of the system prompt addition for UI display.
434    ///
435    /// For most capabilities this is identical to `system_prompt_addition()`.
436    /// Capabilities with dynamic content (e.g. `agent_instructions` which reads
437    /// AGENTS.md at runtime) override this to return a representative preview.
438    fn system_prompt_preview(&self) -> Option<String> {
439        self.system_prompt_addition().map(|s| s.to_string())
440    }
441
442    /// Returns tool implementations provided by this capability
443    fn tools(&self) -> Vec<Box<dyn Tool>> {
444        vec![]
445    }
446
447    /// Returns tool implementations configured by per-capability config.
448    ///
449    /// Called during capability collection with the per-agent config for this
450    /// capability (from `AgentCapabilityConfig.config`). Capabilities that adapt
451    /// their tools based on config override this method.
452    ///
453    /// Default delegates to `tools()`.
454    fn tools_with_config(&self, _config: &serde_json::Value) -> Vec<Box<dyn Tool>> {
455        self.tools()
456    }
457
458    /// Returns a provider for one target of the neutral `spawn_agent` router.
459    ///
460    /// Hosted delegation implementations use this seam so core can assemble a
461    /// single model-facing tool without knowing capability IDs or product
462    /// configuration. The provider tool receives the original call unchanged.
463    fn delegation_target_with_config(
464        &self,
465        _config: &serde_json::Value,
466    ) -> Option<DelegationTargetProvider> {
467        None
468    }
469
470    /// Whether this capability should be activated from the tools collected so
471    /// far. This generic hook supports cross-cutting adapters without teaching
472    /// core their IDs or deployment ownership.
473    fn auto_activates_for(&self, _tool_definitions: &[ToolDefinition]) -> bool {
474        false
475    }
476
477    /// Returns system prompt contribution adapted to per-capability config.
478    ///
479    /// Called during capability collection. Capabilities whose system prompt
480    /// content depends on config override this method.
481    ///
482    /// Default delegates to `system_prompt_contribution(ctx)`.
483    async fn system_prompt_contribution_with_config(
484        &self,
485        ctx: &SystemPromptContext,
486        _config: &serde_json::Value,
487    ) -> Option<String> {
488        self.system_prompt_contribution(ctx).await
489    }
490
491    /// Returns tool definitions for the agent config
492    /// By default, converts tools() to definitions
493    fn tool_definitions(&self) -> Vec<ToolDefinition> {
494        self.tools().iter().map(|t| t.to_definition()).collect()
495    }
496
497    /// Returns mount points to populate in the session filesystem
498    ///
499    /// Mount points allow capabilities to provide files and directories
500    /// that are automatically created when a session starts. This is useful
501    /// for providing sample data, documentation, or configuration files.
502    ///
503    /// By default, returns an empty vector (no mounts).
504    fn mounts(&self) -> Vec<MountPoint> {
505        vec![]
506    }
507
508    /// Returns capability IDs that this capability depends on.
509    ///
510    /// Dependencies are automatically resolved at runtime when applying
511    /// capabilities. If capability A depends on capability B, then B's
512    /// contributions (tools, system prompt, mounts) will be included
513    /// when A is selected, even if B is not explicitly selected.
514    ///
515    /// By default, returns an empty vector (no dependencies).
516    fn dependencies(&self) -> Vec<&'static str> {
517        vec![]
518    }
519
520    /// Returns UI feature strings that this capability contributes to.
521    ///
522    /// Features are open-ended strings indicating what user-facing functionality
523    /// this capability enables. Multiple capabilities can contribute the same
524    /// feature (e.g., both `session_schedule` and a future `signals` capability
525    /// might contribute `"schedules"`).
526    ///
527    /// The UI uses the aggregated set of features from all active capabilities
528    /// to decide which tabs/sections to render.
529    ///
530    /// Known features: `"file_system"`, `"schedules"`, `"secrets"`,
531    /// `"key_value"`, `"sql_database"`, `"leased_resources"`.
532    ///
533    /// By default, returns an empty vector (no features).
534    fn features(&self) -> Vec<&'static str> {
535        vec![]
536    }
537
538    /// Returns the JSON Schema for this capability's per-agent config.
539    ///
540    /// The schema is exposed through `CapabilityInfo` so clients can render a
541    /// generic settings editor for capabilities without hard-coding capability
542    /// IDs. Capabilities without configurable settings return `None`.
543    fn config_schema(&self) -> Option<serde_json::Value> {
544        None
545    }
546
547    /// Returns UI hints for rendering `config_schema`.
548    ///
549    /// This follows the react-jsonschema-form `uiSchema` shape. The server owns
550    /// durable config semantics; clients own the generic component implementation.
551    fn config_ui_schema(&self) -> Option<serde_json::Value> {
552        None
553    }
554
555    /// Validates per-capability config before it is persisted.
556    ///
557    /// Default accepts any config for backward compatibility. Capabilities with
558    /// a `config_schema()` should reject invalid values here so HTTP, CLI, and
559    /// MCP write paths share the same server-side guardrail.
560    fn validate_config(&self, _config: &serde_json::Value) -> Result<(), String> {
561        Ok(())
562    }
563
564    /// Returns remote MCP servers contributed by this capability.
565    ///
566    /// These are merged into harness/agent/session scoped MCP config at runtime.
567    /// Explicit scoped MCP config overrides capability-contributed defaults by
568    /// logical server name.
569    fn mcp_servers(&self) -> ScopedMcpServers {
570        ScopedMcpServers::default()
571    }
572
573    /// Returns config-aware remote MCP server contributions.
574    fn mcp_servers_with_config(&self, _config: &serde_json::Value) -> ScopedMcpServers {
575        self.mcp_servers()
576    }
577
578    /// Returns a message filter provider if this capability modifies message retrieval.
579    ///
580    /// Capabilities can contribute filters that modify how messages are loaded
581    /// from the database. This enables features like:
582    /// - Time-based filtering (recent messages only)
583    /// - Event type filtering
584    /// - Tool result filtering by tool name
585    /// - Ephemeral message injection (summaries, reminders)
586    ///
587    /// Filters are applied in capability priority order (by `MessageFilterProvider::priority()`).
588    ///
589    /// By default, returns None (no message filtering).
590    fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
591        None
592    }
593
594    /// Derive the config passed to this capability's message filter.
595    ///
596    /// `compaction_enabled` lets a filter coordinate with a separately selected
597    /// compaction policy without core matching on either capability's ID.
598    fn message_filter_config(
599        &self,
600        config: &serde_json::Value,
601        _compaction_enabled: bool,
602    ) -> serde_json::Value {
603        config.clone()
604    }
605
606    /// Returns a provider that can build a prompt-facing model view from
607    /// lossless stored messages before provider serialization.
608    ///
609    /// This is for capability-owned context transformations such as compaction
610    /// cost-control masking. Storage messages remain unchanged.
611    ///
612    /// By default, returns None (no model-view transformation).
613    fn model_view_provider(&self) -> Option<Arc<dyn ModelViewProvider>> {
614        None
615    }
616
617    /// Returns an in-process hook invoked when a turn fails with a *terminal*
618    /// LLM error (one that will not be retried), before the user-facing error
619    /// message is emitted. The hook may perform a side effect (e.g. schedule a
620    /// continuation) and/or return extra fields to augment the user-facing error
621    /// copy. This is the platform seam for capability-owned error recovery — the
622    /// same in-process hook family as [`Self::tool_call_hooks`] and
623    /// [`Self::message_filter_provider`]; the reason atom invokes it generically
624    /// and knows nothing about any specific capability's behavior. See
625    /// [`crate::llm_error_hook`].
626    ///
627    /// By default, returns None (no error hook).
628    fn llm_error_hook(&self) -> Option<Arc<dyn crate::llm_error_hook::LlmErrorHook>> {
629        None
630    }
631
632    /// Provider-facing deferred tool-loading configuration contributed by
633    /// this capability. The execution engine consumes this generically and
634    /// does not match on implementation-owned capability IDs.
635    fn tool_search_config(
636        &self,
637        _config: &serde_json::Value,
638    ) -> Option<crate::driver_registry::ToolSearchConfig> {
639        None
640    }
641
642    /// Provider-facing prompt-cache configuration contributed by this
643    /// capability.
644    fn prompt_cache_config(
645        &self,
646        _config: &serde_json::Value,
647    ) -> Option<crate::driver_registry::PromptCacheConfig> {
648        None
649    }
650
651    /// Provider routing requested by this capability.
652    ///
653    /// The OpenRouter integration implements this seam for provider-executed
654    /// server tools. Core transports the provider contract without naming a
655    /// capability ID or parsing a first-party capability schema.
656    fn openrouter_routing_config(
657        &self,
658        _config: &serde_json::Value,
659    ) -> Option<crate::driver_registry::OpenRouterRoutingConfig> {
660        None
661    }
662
663    /// Request-level parallel tool-call preference contributed by this
664    /// capability. `None` leaves the runtime/provider default unchanged.
665    fn parallel_tool_calls_preference(&self, _config: &serde_json::Value) -> Option<bool> {
666        None
667    }
668
669    /// User-facing terminal-error disclosure selected by this capability.
670    fn error_disclosure(
671        &self,
672        _config: &serde_json::Value,
673    ) -> Option<crate::user_facing_error::ErrorDisclosure> {
674        None
675    }
676
677    /// Filter assistant text before it is persisted or returned. This is a
678    /// deterministic, config-aware seam for capability-owned annotations.
679    fn filter_response_text(&self, text: String, _config: &serde_json::Value) -> String {
680        text
681    }
682
683    /// Context-compaction policy configured by this capability. The reason
684    /// atom owns orchestration and invokes the returned implementation without
685    /// matching on a capability ID.
686    fn compaction_policy(
687        &self,
688        _config: &serde_json::Value,
689    ) -> Option<Arc<dyn crate::compaction_policy::CompactionPolicy>> {
690        None
691    }
692
693    /// Returns key/value [`Fact`]s this capability contributes to the model.
694    ///
695    /// Facts are routed by their [`Volatility`] so prompt caching is preserved:
696    /// [`Volatility::Static`] facts fold into the cached system-prompt prefix at
697    /// build time; [`Volatility::Dynamic`] facts are appended at the
698    /// conversation tail on every turn (outside the cached prefix). This is the
699    /// generic seam for "changing facts" such as the current time — see
700    /// [`crate::capabilities::facts`].
701    ///
702    /// Called both at prompt-assembly time (to fold static facts and detect
703    /// whether any dynamic facts exist) and per request (to render the live
704    /// tail block), so implementations must be cheap and side-effect free.
705    ///
706    /// By default, returns an empty vector (no facts).
707    fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
708        vec![]
709    }
710
711    /// Returns pre-tool execution hooks provided by this capability.
712    ///
713    /// These hooks run before each individual tool is executed — for *every*
714    /// tool the agent calls (built-in, MCP, or client-side), not just this
715    /// capability's own tools. A hook can mutate the tool call or block it
716    /// outright (returning [`crate::tool_hooks::PreToolUseDecision::Block`]), which
717    /// makes this the seam for cross-cutting policy such as approval gating.
718    /// The first hook to block wins.
719    ///
720    /// By default, returns an empty vector (no hooks).
721    fn pre_tool_use_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
722        vec![]
723    }
724
725    /// Returns pre-tool execution hooks adapted to per-capability config.
726    ///
727    /// Default delegates to `pre_tool_use_hooks()`. Capabilities whose hook
728    /// behavior depends on config (e.g. `guardrails`) override this.
729    fn pre_tool_use_hooks_with_config(
730        &self,
731        _config: &serde_json::Value,
732    ) -> Vec<Arc<dyn crate::tool_hooks::PreToolUseHook>> {
733        self.pre_tool_use_hooks()
734    }
735
736    /// Returns post-tool execution hooks provided by this capability.
737    ///
738    /// These hooks run after each individual tool completes execution.
739    /// They can persist output, inject metadata, or transform results.
740    /// Capability-contributed hooks run before infrastructure (final) hooks.
741    ///
742    /// By default, returns an empty vector (no hooks).
743    fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
744        vec![]
745    }
746
747    /// Returns post-tool execution hooks adapted to per-capability config.
748    ///
749    /// Default delegates to `post_tool_exec_hooks()`. Capabilities whose hook
750    /// behavior depends on config (e.g. `guardrails`) override this.
751    fn post_tool_exec_hooks_with_config(
752        &self,
753        _config: &serde_json::Value,
754    ) -> Vec<Arc<dyn crate::tool_hooks::PostToolExecHook>> {
755        self.post_tool_exec_hooks()
756    }
757
758    /// Returns tool definition hooks provided by this capability.
759    ///
760    /// These hooks run after the runtime agent has merged and deduplicated its
761    /// final tool list, before the tool schemas are sent to the LLM. They let
762    /// capabilities apply cross-cutting schema changes to all active tools,
763    /// including tools contributed by other capabilities, MCP, or clients.
764    ///
765    /// By default, returns an empty vector (no tool definition transforms).
766    fn tool_definition_hooks(&self) -> Vec<Arc<dyn ToolDefinitionHook>> {
767        vec![]
768    }
769
770    /// Returns tool definition hooks adapted to per-capability config.
771    ///
772    /// Default delegates to `tool_definition_hooks()`. Capabilities whose
773    /// schema transforms depend on config override this method.
774    fn tool_definition_hooks_with_config(
775        &self,
776        _config: &serde_json::Value,
777    ) -> Vec<Arc<dyn ToolDefinitionHook>> {
778        self.tool_definition_hooks()
779    }
780
781    /// Returns tool definition hooks adapted to per-capability config and the
782    /// collection context (session id, model, ...).
783    ///
784    /// Default delegates to [`Self::tool_definition_hooks_with_config`], which
785    /// ignores the context. Capabilities whose hooks carry session-scoped state
786    /// override this to capture `ctx` — e.g. `tool_search` keys its
787    /// progressive-disclosure reveal set by `ctx.session_id`, since the
788    /// capability is a process-global singleton shared across sessions and a
789    /// `ToolDefinitionHook::transform` has no session context of its own.
790    fn tool_definition_hooks_with_context(
791        &self,
792        _ctx: &SystemPromptContext,
793        config: &serde_json::Value,
794    ) -> Vec<Arc<dyn ToolDefinitionHook>> {
795        self.tool_definition_hooks_with_config(config)
796    }
797
798    /// Returns tool call hooks provided by this capability.
799    ///
800    /// These hooks run after the model has produced a tool call. They can read
801    /// model-authored metadata for UI display and transform the tool call used
802    /// for actual execution.
803    ///
804    /// By default, returns an empty vector (no tool call handling).
805    fn tool_call_hooks(&self) -> Vec<Arc<dyn ToolCallHook>> {
806        vec![]
807    }
808
809    /// Returns a configured hook over the finalized model tool-call batch.
810    /// This later seam is suitable for policy that needs all calls plus their
811    /// final schemas before the assistant message is persisted.
812    fn finalized_tool_calls_hook(
813        &self,
814        _config: &serde_json::Value,
815    ) -> Option<Arc<dyn crate::finalized_tool_calls::FinalizedToolCallsHook>> {
816        None
817    }
818
819    /// Contribute human-readable narration for one of *this capability's* tool
820    /// calls (e.g. "Read AGENTS.md", "Searched tools: router").
821    ///
822    /// The **default** dispatches to the matching tool's
823    /// [`crate::tools::Tool::narrate`], so a capability narrates its tools for
824    /// free — narration lives on the tool that owns it. Override this only when
825    /// narration is config-driven or spans tools, or when the tools are dynamic
826    /// (e.g. proxied MCP tools that have no local `Tool` struct).
827    ///
828    /// Returns `None` for tool names this capability does not provide, so other
829    /// capabilities — or the generic fallback in [`crate::tool_narration`] —
830    /// can handle them. The framework consults this for every applied
831    /// capability (see `assemble`/`CapabilityNarrationHook`) on the act path.
832    fn narrate(
833        &self,
834        _tool_def: Option<&ToolDefinition>,
835        tool_call: &ToolCall,
836        phase: crate::tool_narration::ToolNarrationPhase,
837        locale: Option<&str>,
838        ctx: crate::tool_narration::ToolNarrationContext<'_>,
839    ) -> Option<String> {
840        self.tools()
841            .iter()
842            .find(|tool| tool.name() == tool_call.name)
843            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
844    }
845
846    /// Returns user-defined hook specifications contributed by this capability.
847    ///
848    /// User hooks are JSON-serializable specs (see
849    /// `crate::user_hook_types::UserHookSpec` and `knowledge/runtime-resources/user-hooks.md`) that
850    /// the `HookAdapterBuilder` validates and turns into per-event
851    /// `Arc<dyn …Hook>` adapters during capability collection. Capabilities
852    /// that ship reusable hook bundles (formatters, security guards, audit
853    /// commands) override this; the user-facing `user_hooks` capability also
854    /// uses this hook to surface user-config-authored entries.
855    ///
856    /// Contributors return *data only* — the executor is constructed
857    /// centrally by the core so global timeout/output/sandbox limits cannot
858    /// be bypassed.
859    ///
860    /// By default, returns an empty vector (no contributed hooks).
861    fn user_hooks(&self) -> Vec<crate::user_hook_types::UserHookSpec> {
862        vec![]
863    }
864
865    /// Returns user-defined hook specifications adapted to per-capability
866    /// config.
867    ///
868    /// Default delegates to `user_hooks()`. The `user_hooks` capability
869    /// overrides this to parse hook entries out of its config.
870    fn user_hooks_with_config(
871        &self,
872        _config: &serde_json::Value,
873    ) -> Vec<crate::user_hook_types::UserHookSpec> {
874        self.user_hooks()
875    }
876
877    /// Returns the risk level of this capability.
878    ///
879    /// TM-AGENT-005: High-risk capabilities (code execution, network access)
880    /// require admin approval when assigned to agents/harnesses. Capabilities
881    /// that combine execution + network access enable data exfiltration.
882    ///
883    /// By default, returns `RiskLevel::Low`.
884    fn risk_level(&self) -> RiskLevel {
885        RiskLevel::Low
886    }
887
888    /// Returns system commands this capability provides.
889    ///
890    /// System commands are user-invocable /slash commands that execute directly
891    /// without involving the LLM. They are surfaced in the UI command palette
892    /// alongside invocable skills.
893    ///
894    /// By default, returns an empty vector (no commands).
895    fn commands(&self) -> Vec<CommandDescriptor> {
896        vec![]
897    }
898
899    /// Execute a system command declared by [`Self::commands`].
900    ///
901    /// Capabilities that declare commands MUST override this. The default
902    /// implementation returns an error so that misconfigurations surface at
903    /// invocation time rather than silently succeeding. Capabilities should
904    /// match on `request.name`, validate `request.arguments`, and use the
905    /// references they captured at construction time to mutate any external
906    /// state (provider store, file system, etc.).
907    ///
908    /// Commands that need the session's assembled context or an out-of-band
909    /// LLM call (e.g. `/btw`) use the host facilities on
910    /// [`CommandExecutionContext::host`] — see
911    /// [`crate::command_host::CommandHost`] and knowledge/project/commands.md.
912    async fn execute_command(
913        &self,
914        request: &ExecuteCommandRequest,
915        _ctx: &CommandExecutionContext,
916    ) -> crate::error::Result<CommandResult> {
917        Err(crate::error::AgentLoopError::config(format!(
918            "capability {} declared command /{} but does not implement execute_command",
919            self.id(),
920            request.name,
921        )))
922    }
923
924    /// Returns agent blueprints contributed by this capability.
925    ///
926    /// Blueprints are pre-built agent definitions with private tools, baked-in prompts,
927    /// and fixed/default models. They are spawned via `spawn_agent` with a subagent target
928    /// and `blueprint`.
929    /// Blueprint tools never appear in the host agent's tool list.
930    ///
931    /// By default, returns an empty vector (no blueprints).
932    fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
933        vec![]
934    }
935
936    /// Returns skills contributed by this capability in code.
937    ///
938    /// Contributions are normalized during capability collection into read-only
939    /// mount points at `/.agents/skills/{name}/` so the built-in `skills`
940    /// capability discovers them alongside user-uploaded and registry-based
941    /// skills. This keeps discovery, prompt listing, and activation in one
942    /// place rather than adding a parallel skill pipeline.
943    ///
944    /// By default, returns an empty vector (no contributed skills).
945    fn contribute_skills(&self) -> Vec<SkillContribution> {
946        vec![]
947    }
948
949    /// Returns streaming output guardrails contributed by this capability.
950    ///
951    /// Each provider is armed once per assistant message stream with the
952    /// fully assembled system prompt and per-capability config; the returned
953    /// per-stream `OutputGuardrailRun` is invoked after every batched delta
954    /// in the streaming hot path. Returning `Block` aborts the stream and
955    /// the client is told to replace the accumulated text with a canned
956    /// message. See [`crate::output_guardrail`].
957    ///
958    /// Default: no guardrails.
959    fn output_guardrails(&self) -> Vec<Arc<dyn crate::output_guardrail::OutputGuardrail>> {
960        vec![]
961    }
962
963    /// Async, end-of-message output guardrails (EVE-573).
964    ///
965    /// Unlike [`Self::output_guardrails`] (synchronous, per-delta, hot path),
966    /// these providers run **once** on the fully assembled assistant message
967    /// after streaming completes and before the message is finalized into
968    /// context. They receive an LLM-capable context and may perform I/O (e.g.
969    /// a moderation classifier). The per-agent capability config is passed so a
970    /// capability contributes nothing unless it has an applicable check
971    /// configured — keeping the common (no-output-check) case free of work.
972    ///
973    /// Default: no guardrails.
974    fn post_output_guardrails_with_config(
975        &self,
976        _config: &serde_json::Value,
977    ) -> Vec<Arc<dyn crate::output_guardrail::PostGenerationOutputGuardrail>> {
978        vec![]
979    }
980
981    /// Returns end-of-message citation annotation hooks contributed by this
982    /// capability, adapted to per-agent config.
983    ///
984    /// Like [`Self::post_output_guardrails_with_config`], these run once on the
985    /// fully assembled assistant message after streaming completes. But instead
986    /// of a block/allow decision they attach citation [`crate::message::TextAnnotation`]s
987    /// to the message text (optionally rewriting it first, e.g. to strip inline
988    /// citation markers). This is the seam citation capabilities use to turn
989    /// retrieved sources into claim-level provenance. See
990    /// [`crate::annotation_hook`] and `knowledge/runtime-resources/citations.md`.
991    ///
992    /// A capability contributes nothing unless a citation feed is configured,
993    /// keeping the common (no-citations) case free of work.
994    ///
995    /// Default: no annotation hooks.
996    fn post_output_annotation_hooks_with_config(
997        &self,
998        _config: &serde_json::Value,
999    ) -> Vec<Arc<dyn crate::annotation_hook::PostGenerationAnnotationHook>> {
1000        vec![]
1001    }
1002
1003    /// Returns a citation verifier contributed by this capability, if any.
1004    ///
1005    /// Runs once after all citation feeds have attached annotations, over the
1006    /// collected set, stamping a [`crate::message::VerificationVerdict`] on each
1007    /// citation. Decoupled from the feeds so any feed can be paired with any
1008    /// verifier. The `citation_verification` capability implements this. See
1009    /// [`crate::annotation_hook::CitationVerifier`] and `knowledge/runtime-resources/citations.md`.
1010    ///
1011    /// Default: no verifier.
1012    fn citation_verifier_with_config(
1013        &self,
1014        _config: &serde_json::Value,
1015    ) -> Option<Arc<dyn crate::annotation_hook::CitationVerifier>> {
1016        None
1017    }
1018}
1019
1020pub trait ToolDefinitionHook: Send + Sync {
1021    fn transform(&self, tools: Vec<ToolDefinition>) -> Vec<ToolDefinition>;
1022
1023    /// Whether this hook should still run when the agent's model uses native
1024    /// (hosted) tool_search. Client-side deferral hooks return `false` so they
1025    /// don't strip schemas the hosted tool_search index needs (the two are
1026    /// mutually exclusive). Defaults to `true`.
1027    fn applies_with_native_tool_search(&self) -> bool {
1028        true
1029    }
1030}
1031
1032pub trait ToolCallHook: Send + Sync {
1033    fn narration(
1034        &self,
1035        _tool_def: Option<&ToolDefinition>,
1036        _tool_call: &ToolCall,
1037        _phase: crate::tool_narration::ToolNarrationPhase,
1038        _locale: Option<&str>,
1039        _ctx: crate::tool_narration::ToolNarrationContext<'_>,
1040    ) -> Option<String> {
1041        None
1042    }
1043
1044    fn transform_for_execution(&self, tool_call: ToolCall) -> ToolCall {
1045        tool_call
1046    }
1047}
1048
1049/// Adapts a [`Capability`]'s [`Capability::narrate`] into a [`ToolCallHook`] so
1050/// capability-owned narration flows through the same hook channel the act atom
1051/// already consults. One is registered per applied capability during
1052/// `assemble`, after every explicit tool-call hook, so model-authored
1053/// narration (e.g. `human_intent`) still takes precedence.
1054pub struct CapabilityNarrationHook(pub Arc<dyn Capability>);
1055
1056impl ToolCallHook for CapabilityNarrationHook {
1057    fn narration(
1058        &self,
1059        tool_def: Option<&ToolDefinition>,
1060        tool_call: &ToolCall,
1061        phase: crate::tool_narration::ToolNarrationPhase,
1062        locale: Option<&str>,
1063        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1064    ) -> Option<String> {
1065        self.0.narrate(tool_def, tool_call, phase, locale, ctx)
1066    }
1067}
1068
1069/// Risk classification for capabilities (TM-AGENT-005).
1070///
1071/// Used to enforce approval requirements when assigning capabilities.
1072#[derive(
1073    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
1074)]
1075#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
1076#[cfg_attr(feature = "openapi", schema(example = "low"))]
1077#[serde(rename_all = "lowercase")]
1078pub enum RiskLevel {
1079    /// No special approval needed
1080    Low,
1081    /// Logged but allowed for org members
1082    Medium,
1083    /// Requires org admin role to assign
1084    High,
1085}
1086
1087// ============================================================================
1088// Agent Blueprints
1089// ============================================================================
1090
1091/// Model selection strategy for agent blueprints.
1092#[derive(Debug, Clone, Serialize, Deserialize)]
1093#[serde(rename_all = "snake_case")]
1094pub enum BlueprintModel {
1095    /// Always use this model. Host cannot override.
1096    Fixed(String),
1097    /// Use this model unless host provides override via config.
1098    Default(String),
1099    /// Use whatever model the host agent uses.
1100    Inherit,
1101}
1102
1103/// Pre-built agent definition with private tools, baked-in prompt, and model selection.
1104///
1105/// Contributed by capabilities via `agent_blueprints()`. Spawned via
1106/// `spawn_agent` with a subagent target and `blueprint`. Blueprint tools never appear in the
1107/// host agent's tool list — they exist only inside the spawned child session.
1108pub struct AgentBlueprint {
1109    /// Unique identifier (e.g. `"github_scout"`)
1110    pub id: &'static str,
1111    /// Human-readable display name
1112    pub name: &'static str,
1113    /// When to use this blueprint (LLM reads this for delegation decisions)
1114    pub description: &'static str,
1115    /// Model selection strategy
1116    pub model: BlueprintModel,
1117    /// Baked-in system prompt for the child agent
1118    pub system_prompt: &'static str,
1119    /// Private tools — only available inside the blueprint's session
1120    pub tools: Vec<Box<dyn Tool>>,
1121    /// Iteration limit (default: 20)
1122    pub max_turns: Option<usize>,
1123    /// JSON Schema for allowed host-provided config. `None` = no config accepted.
1124    pub config_schema: Option<serde_json::Value>,
1125}
1126
1127impl AgentBlueprint {
1128    /// Convert blueprint tools to tool definitions (for RuntimeAgent building).
1129    pub fn tool_definitions(&self) -> Vec<ToolDefinition> {
1130        self.tools.iter().map(|t| t.to_definition()).collect()
1131    }
1132}
1133
1134impl std::fmt::Debug for AgentBlueprint {
1135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1136        f.debug_struct("AgentBlueprint")
1137            .field("id", &self.id)
1138            .field("name", &self.name)
1139            .field("model", &self.model)
1140            .field("tool_count", &self.tools.len())
1141            .field("max_turns", &self.max_turns)
1142            .finish()
1143    }
1144}
1145
1146// ============================================================================
1147// Capability Registry
1148// ============================================================================
1149
1150/// Registry that holds all available capability implementations.
1151///
1152/// The registry provides access to capabilities by ID and allows
1153/// applying multiple capabilities to build a RuntimeAgent.
1154///
1155/// # Example
1156///
1157/// ```
1158/// use everruns_core::capabilities::CapabilityRegistry;
1159///
1160/// let registry = CapabilityRegistry::new();
1161/// assert!(registry.is_empty());
1162/// ```
1163#[derive(Clone)]
1164pub struct CapabilityRegistry {
1165    capabilities: HashMap<String, Arc<dyn Capability>>,
1166    /// Canonical-id/alias bookkeeping delegated to the neutral capability
1167    /// contract so the Framework and product resolve identity identically
1168    /// (see [`Capability::aliases`]).
1169    index: everruns_capability::CapabilityIdIndex,
1170}
1171
1172impl CapabilityRegistry {
1173    /// Create a new empty registry
1174    pub fn new() -> Self {
1175        Self {
1176            capabilities: HashMap::new(),
1177            index: everruns_capability::CapabilityIdIndex::new(),
1178        }
1179    }
1180
1181    /// Register a capability
1182    pub fn register(&mut self, capability: impl Capability + 'static) {
1183        self.register_arc(Arc::new(capability));
1184    }
1185
1186    /// Register a boxed capability
1187    pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1188        self.register_arc(Arc::from(capability));
1189    }
1190
1191    /// Register an Arc-wrapped capability.
1192    ///
1193    /// Re-registering the same canonical ID replaces the previous
1194    /// implementation (legacy override semantics); use
1195    /// [`CapabilityRegistry::try_register_arc`] to reject collisions instead.
1196    pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1197        let canonical = capability.id().to_string();
1198        self.index
1199            .insert_or_replace(canonical.clone(), &capability.aliases());
1200        self.capabilities.insert(canonical, capability);
1201    }
1202
1203    /// Register an Arc-wrapped capability, rejecting duplicate IDs and alias
1204    /// collisions via the neutral contract's registry rules.
1205    pub fn try_register_arc(
1206        &mut self,
1207        capability: Arc<dyn Capability>,
1208    ) -> Result<(), everruns_capability::CapabilityError> {
1209        let canonical = capability.id().to_string();
1210        self.index
1211            .insert(canonical.clone(), &capability.aliases())?;
1212        self.capabilities.insert(canonical, capability);
1213        Ok(())
1214    }
1215
1216    /// Register inventory-submitted integrations accepted by a caller-owned policy.
1217    ///
1218    /// Core owns the registry mutation algorithm; host and product composition
1219    /// own every deployment-grade and feature decision supplied by `include`.
1220    pub fn register_inventory_plugins(
1221        &mut self,
1222        mut include: impl FnMut(&IntegrationPlugin) -> bool,
1223    ) {
1224        for plugin in inventory::iter::<IntegrationPlugin>() {
1225            if include(plugin) {
1226                self.register_boxed((plugin.factory)());
1227            }
1228        }
1229    }
1230
1231    /// Get a capability by ID or alias
1232    pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1233        self.capabilities.get(self.index.canonical_of(id)?)
1234    }
1235
1236    /// Resolve an ID or alias to the canonical capability ID.
1237    ///
1238    /// Returns `None` for IDs that are neither registered nor an alias of a
1239    /// registered capability (e.g. declarative or MCP refs).
1240    pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1241        self.index.canonical_of(id)
1242    }
1243
1244    /// Remove a capability from the registry by ID or alias.
1245    pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1246        let canonical = self.index.remove(id)?;
1247        self.capabilities.remove(&canonical)
1248    }
1249
1250    /// Check if a capability is registered (by ID or alias)
1251    pub fn has(&self, id: &str) -> bool {
1252        self.get(id).is_some()
1253    }
1254
1255    /// Get all registered capabilities
1256    pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1257        self.capabilities.values().collect()
1258    }
1259
1260    /// Get the number of registered capabilities
1261    pub fn len(&self) -> usize {
1262        self.capabilities.len()
1263    }
1264
1265    /// Check if the registry is empty
1266    pub fn is_empty(&self) -> bool {
1267        self.capabilities.is_empty()
1268    }
1269
1270    /// Create a builder for fluent capability registration
1271    pub fn builder() -> CapabilityRegistryBuilder {
1272        CapabilityRegistryBuilder::new()
1273    }
1274
1275    /// Find a blueprint by ID across all registered capabilities.
1276    ///
1277    /// Returns a fresh `AgentBlueprint` (with new tool instances) each time.
1278    pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1279        for cap in self.capabilities.values() {
1280            for bp in cap.agent_blueprints() {
1281                if bp.id == id {
1282                    return Some(bp);
1283                }
1284            }
1285        }
1286        None
1287    }
1288
1289    /// Find a blueprint and the capability that registered it.
1290    ///
1291    /// Returns `(capability_id, blueprint)` with fresh tool instances.
1292    pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1293        for (capability_id, cap) in &self.capabilities {
1294            for bp in cap.agent_blueprints() {
1295                if bp.id == id {
1296                    return Some((capability_id.clone(), bp));
1297                }
1298            }
1299        }
1300        None
1301    }
1302
1303    /// Collect all blueprints from all registered capabilities.
1304    pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1305        self.capabilities
1306            .values()
1307            .flat_map(|cap| cap.agent_blueprints())
1308            .collect()
1309    }
1310}
1311
1312impl Default for CapabilityRegistry {
1313    fn default() -> Self {
1314        Self::new()
1315    }
1316}
1317
1318impl std::fmt::Debug for CapabilityRegistry {
1319    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1320        let ids: Vec<_> = self.capabilities.keys().collect();
1321        f.debug_struct("CapabilityRegistry")
1322            .field("capabilities", &ids)
1323            .finish()
1324    }
1325}
1326
1327/// Builder for creating a CapabilityRegistry with a fluent API
1328pub struct CapabilityRegistryBuilder {
1329    registry: CapabilityRegistry,
1330}
1331
1332impl CapabilityRegistryBuilder {
1333    /// Create a new builder with an empty registry
1334    pub fn new() -> Self {
1335        Self {
1336            registry: CapabilityRegistry::new(),
1337        }
1338    }
1339
1340    /// Add a capability
1341    pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1342        self.registry.register(capability);
1343        self
1344    }
1345
1346    /// Build the registry
1347    pub fn build(self) -> CapabilityRegistry {
1348        self.registry
1349    }
1350}
1351
1352impl Default for CapabilityRegistryBuilder {
1353    fn default() -> Self {
1354        Self::new()
1355    }
1356}
1357
1358// ============================================================================
1359// Collect Capabilities Helper
1360// ============================================================================
1361
1362/// Context available to capability-owned model-view transforms.
1363pub struct ModelViewContext<'a> {
1364    pub session_id: SessionId,
1365    pub prior_usage: Option<&'a TokenUsage>,
1366}
1367
1368/// Provider-side hook for building prompt-facing model views.
1369///
1370/// Providers receive the output of earlier providers and return the messages
1371/// that should be sent into provider serialization. Lower priority providers
1372/// run earlier.
1373pub trait ModelViewProvider: Send + Sync {
1374    fn apply_model_view(
1375        &self,
1376        messages: Vec<Message>,
1377        config: &serde_json::Value,
1378        context: &ModelViewContext<'_>,
1379    ) -> Vec<Message>;
1380
1381    fn priority(&self) -> i32 {
1382        0
1383    }
1384}
1385
1386/// Collected data from capabilities before applying to config.
1387///
1388/// This intermediate struct allows sharing the capability collection logic
1389/// between `apply_capabilities` and `apply_capabilities_to_builder`.
1390pub struct CollectedCapabilities {
1391    /// System prompt additions (in order)
1392    pub system_prompt_parts: Vec<String>,
1393    /// Source attribution for each system prompt addition.
1394    pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1395    /// Tool implementations for the registry
1396    pub tools: Vec<Box<dyn Tool>>,
1397    /// Tool definitions for config
1398    pub tool_definitions: Vec<ToolDefinition>,
1399    /// Mount points from capabilities
1400    pub mounts: Vec<MountPoint>,
1401    /// Message filter providers with their configs (in priority order)
1402    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1403    /// IDs of capabilities that were collected
1404    pub applied_ids: Vec<String>,
1405    /// Tool search configuration (set when openai_tool_search capability is present)
1406    pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1407    /// Prompt caching configuration (set when prompt_caching capability is present)
1408    pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1409    /// OpenRouter routing controls (set when the `openrouter_server_tools`
1410    /// capability is present). Carries provider-executed server tools.
1411    pub openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig>,
1412    /// Request-level parallel tool calls preference (set when the
1413    /// `parallel_tool_calls` capability is present with mode `prefer`/`avoid`).
1414    /// `None` when absent or mode `none`.
1415    pub parallel_tool_calls: Option<bool>,
1416    /// Hooks that transform the final runtime tool definition list.
1417    pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1418    /// Hooks that inspect or transform model-produced tool calls.
1419    pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1420    /// Scoped remote MCP servers contributed by capabilities.
1421    pub mcp_servers: ScopedMcpServers,
1422    // NOTE: output guardrails are intentionally NOT collected here. They are
1423    // re-derived per turn in `ReasonAtom` directly from the resolved capability
1424    // configs + registry, because they need the assembled system prompt at
1425    // arming time (which only exists once the runtime agent is built). Storing
1426    // them here would duplicate that work for callers that don't run a stream.
1427}
1428
1429#[derive(Debug, Clone, PartialEq, Eq)]
1430pub struct SystemPromptAttribution {
1431    pub capability_id: String,
1432    pub content: String,
1433}
1434
1435impl CollectedCapabilities {
1436    /// Returns the combined system prompt prefix from all capabilities.
1437    /// Returns None if no capabilities contributed system prompt additions.
1438    pub fn system_prompt_prefix(&self) -> Option<String> {
1439        if self.system_prompt_parts.is_empty() {
1440            None
1441        } else {
1442            Some(self.system_prompt_parts.join("\n\n"))
1443        }
1444    }
1445
1446    /// Apply all collected message filter providers to a query.
1447    ///
1448    /// Providers are applied in priority order (lower priority first).
1449    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1450        // Providers are already sorted by priority during collection
1451        for (provider, config) in &self.message_filter_providers {
1452            provider.apply_filters(query, config);
1453        }
1454    }
1455
1456    /// Apply post-load transforms from all message filter providers.
1457    /// Called after messages are loaded, filtered, and injected.
1458    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1459        for (provider, config) in &self.message_filter_providers {
1460            provider.post_load(messages, config);
1461        }
1462    }
1463
1464    /// Check if any capabilities contribute message filters.
1465    pub fn has_message_filters(&self) -> bool {
1466        !self.message_filter_providers.is_empty()
1467    }
1468}
1469
1470pub struct DelegationTargetProvider {
1471    pub target_type: &'static str,
1472    pub tool: Box<dyn Tool>,
1473}
1474
1475/// Shared execution mode accepted natively by every `spawn_agent` provider.
1476#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1477#[serde(rename_all = "snake_case")]
1478pub enum SpawnMode {
1479    Background,
1480    Foreground,
1481}
1482
1483impl SpawnMode {
1484    pub fn parse(value: &str) -> Option<Self> {
1485        match value {
1486            "background" => Some(Self::Background),
1487            "foreground" => Some(Self::Foreground),
1488            _ => None,
1489        }
1490    }
1491
1492    pub fn as_str(self) -> &'static str {
1493        match self {
1494            Self::Background => "background",
1495            Self::Foreground => "foreground",
1496        }
1497    }
1498}
1499
1500struct UnifiedSpawnAgentTool {
1501    providers: Vec<DelegationTargetProvider>,
1502}
1503
1504impl UnifiedSpawnAgentTool {
1505    fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1506        Self { providers }
1507    }
1508
1509    fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1510        self.providers
1511            .iter()
1512            .find(|provider| provider.target_type == target_type)
1513            .map(|provider| provider.tool.as_ref())
1514    }
1515
1516    fn target_types(&self) -> Vec<&'static str> {
1517        ["subagent", "agent", "external_a2a"]
1518            .into_iter()
1519            .filter(|target_type| {
1520                self.providers
1521                    .iter()
1522                    .any(|provider| provider.target_type == *target_type)
1523            })
1524            .collect()
1525    }
1526
1527    /// Per-`target.type` constraint branches, nested inside the `target`
1528    /// property. Anthropic rejects `oneOf`/`allOf`/`anyOf` at the top level
1529    /// of a tool `input_schema`, so provider-specific requirements must live
1530    /// below the root (nested composition is accepted).
1531    fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1532        self.target_types()
1533            .into_iter()
1534            .filter_map(|target_type| match target_type {
1535                "subagent" => Some(serde_json::json!({
1536                    "properties": {
1537                        "type": {"const": "subagent"}
1538                    }
1539                })),
1540                "agent" => Some(serde_json::json!({
1541                    "properties": {
1542                        "type": {"const": "agent"}
1543                    },
1544                    "required": ["type", "id"]
1545                })),
1546                "external_a2a" => Some(serde_json::json!({
1547                    "properties": {
1548                        "type": {"const": "external_a2a"}
1549                    },
1550                    "anyOf": [
1551                        {"required": ["id"]},
1552                        {"required": ["external_agent_id"]}
1553                    ]
1554                })),
1555                _ => None,
1556            })
1557            .collect()
1558    }
1559
1560    // NOTE: subagent and agent providers require `name` at execution
1561    // (`require_str`), while external_a2a ignores it. A schema that required
1562    // `name` only for the local targets would need a top-level
1563    // `oneOf`/`if`/`allOf`, which Anthropic rejects in a tool `input_schema`.
1564    // `name` is therefore required at the root unconditionally: requiring a
1565    // field external_a2a merely ignores is safe (the schema never permits a
1566    // call execution would reject), whereas omitting it would let a
1567    // `name`-less subagent call pass validation and then fail at dispatch —
1568    // exactly the mismatch #2787 set out to close.
1569}
1570
1571#[async_trait]
1572impl Tool for UnifiedSpawnAgentTool {
1573    fn narrate(
1574        &self,
1575        tool_call: &ToolCall,
1576        phase: crate::tool_narration::ToolNarrationPhase,
1577        locale: Option<&str>,
1578        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1579    ) -> Option<String> {
1580        let target_type = tool_call
1581            .arguments
1582            .get("target")
1583            .and_then(|target| target.get("type"))
1584            .and_then(serde_json::Value::as_str)?;
1585        self.provider_for(target_type)
1586            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx))
1587    }
1588
1589    fn name(&self) -> &str {
1590        "spawn_agent"
1591    }
1592
1593    fn display_name(&self) -> Option<&str> {
1594        Some("Spawn Agent")
1595    }
1596
1597    fn description(&self) -> &str {
1598        "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."
1599    }
1600
1601    fn parameters_schema(&self) -> serde_json::Value {
1602        serde_json::json!({
1603            "type": "object",
1604            "properties": {
1605                "name": {
1606                    "type": "string",
1607                    "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1608                },
1609                "instructions": {
1610                    "type": "string",
1611                    "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1612                },
1613                "goal": {
1614                    "type": "string",
1615                    "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1616                },
1617                "lifetime": {
1618                    "type": "string",
1619                    "enum": ["linked", "detached"],
1620                    "default": "linked",
1621                    "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1622                },
1623                "seed": {
1624                    "type": "string",
1625                    "enum": ["fresh", "fork", "workspace"],
1626                    "default": "fresh",
1627                    "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1628                },
1629                "target": {
1630                    "type": "object",
1631                    "properties": {
1632                        "type": {
1633                            "type": "string",
1634                            "enum": self.target_types(),
1635                            "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."
1636                        },
1637                        "id": {
1638                            "type": "string",
1639                            "description": "Configured target id for first-party handoffs or external A2A agents."
1640                        },
1641                        "external_agent_id": {
1642                            "type": "string",
1643                            "description": "Configured external A2A agent id."
1644                        }
1645                    },
1646                    "required": ["type"],
1647                    "oneOf": self.target_constraint_branches(),
1648                    "additionalProperties": false
1649                },
1650                "mode": {
1651                    "type": "string",
1652                    "enum": ["background", "foreground"],
1653                    "description": "Execution mode. Use background to return immediately with a task_id, or foreground to block until the delegated work reaches a terminal state or timeout."
1654                },
1655                "blueprint": {
1656                    "type": "string",
1657                    "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1658                },
1659                "config": {
1660                    "type": "object",
1661                    "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1662                },
1663                "result_schema": {
1664                    "type": "object",
1665                    "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."
1666                },
1667                "message_schema": {
1668                    "type": "object",
1669                    "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."
1670                },
1671                "public_context": {
1672                    "type": "object",
1673                    "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1674                },
1675                "wait_timeout_secs": {
1676                    "type": "integer",
1677                    "minimum": 1,
1678                    "maximum": 86400,
1679                    "description": "External-A2A-only foreground timeout."
1680                },
1681                "wake_on_completion": {
1682                    "type": "boolean",
1683                    "description": "External-A2A-only control for background completion wake-ups."
1684                }
1685            },
1686            "required": ["name", "instructions", "target"],
1687            "additionalProperties": false
1688        })
1689    }
1690
1691    fn hints(&self) -> crate::tool_types::ToolHints {
1692        let mut hints = crate::tool_types::ToolHints::default()
1693            .with_long_running(true)
1694            .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1695        if self.provider_for("external_a2a").is_some() {
1696            hints = hints.with_open_world(true);
1697        }
1698        hints
1699    }
1700
1701    async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1702        ToolExecutionResult::tool_error(
1703            "spawn_agent requires context. This tool must be executed with session context.",
1704        )
1705    }
1706
1707    async fn execute_with_context(
1708        &self,
1709        arguments: serde_json::Value,
1710        context: &ToolContext,
1711    ) -> ToolExecutionResult {
1712        let target_type = match arguments
1713            .get("target")
1714            .and_then(|target| target.get("type"))
1715            .and_then(serde_json::Value::as_str)
1716        {
1717            Some(target_type) => target_type,
1718            None => {
1719                return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1720            }
1721        };
1722
1723        let Some(provider) = self.provider_for(target_type) else {
1724            let supported = self.target_types().join(", ");
1725            return ToolExecutionResult::tool_error(format!(
1726                "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1727            ));
1728        };
1729        if target_type == "external_a2a"
1730            && arguments
1731                .get("lifetime")
1732                .and_then(serde_json::Value::as_str)
1733                .is_some_and(|value| value == "detached")
1734        {
1735            return ToolExecutionResult::tool_error(
1736                "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1737            );
1738        }
1739        if target_type == "external_a2a"
1740            && arguments
1741                .get("message_schema")
1742                .is_some_and(|schema| !schema.is_null())
1743        {
1744            return ToolExecutionResult::tool_error(
1745                "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1746            );
1747        }
1748
1749        provider.execute_with_context(arguments, context).await
1750    }
1751
1752    fn requires_context(&self) -> bool {
1753        true
1754    }
1755}
1756
1757/// Compose the model-visible system prompt from the stable base prompt and
1758/// collected capability contributions. Keep the base prompt first so changes in
1759/// dynamic capabilities (for example AGENTS.md reads or environment context)
1760/// do not invalidate provider prefix caches for the agent's core instructions.
1761pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1762    let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1763        return base_system_prompt.to_string();
1764    };
1765
1766    if base_system_prompt.is_empty() {
1767        return additions.to_string();
1768    }
1769
1770    if base_system_prompt.contains("<system-prompt>") {
1771        format!("{base_system_prompt}\n\n{additions}")
1772    } else {
1773        format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1774    }
1775}
1776
1777/// Lightweight result containing only message filter providers.
1778///
1779/// Used when callers only need message filtering (e.g., message loading in
1780/// ReasonAtom) without paying the cost of system prompt contribution or tool
1781/// collection. This avoids unnecessary filesystem reads (AGENTS.md) and tool
1782/// instantiation on the message-filter-only path.
1783pub struct CollectedMessageFilters {
1784    /// Message filter providers with their configs (in priority order)
1785    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1786}
1787
1788/// Lightweight result containing only model-view providers.
1789pub struct CollectedModelViewProviders {
1790    /// Model-view providers with their configs (in priority order).
1791    pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1792}
1793
1794// Note: apply_message_filters/apply_post_load_filters mirror the same methods
1795// on CollectedCapabilities. The duplication is intentional — extracting a trait
1796// would add indirection for 3 lines of loop body, and the two structs serve
1797// different purposes (lightweight vs full collection).
1798
1799impl CollectedMessageFilters {
1800    /// Apply all collected message filter providers to a query.
1801    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1802        for (provider, config) in &self.message_filter_providers {
1803            provider.apply_filters(query, config);
1804        }
1805    }
1806
1807    /// Apply post-load transforms from all message filter providers.
1808    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1809        for (provider, config) in &self.message_filter_providers {
1810            provider.post_load(messages, config);
1811        }
1812    }
1813}
1814
1815impl CollectedModelViewProviders {
1816    /// Apply all collected model-view providers in priority order.
1817    pub fn apply_model_view(
1818        &self,
1819        mut messages: Vec<Message>,
1820        context: &ModelViewContext<'_>,
1821    ) -> Vec<Message> {
1822        for (provider, config) in &self.model_view_providers {
1823            messages = provider.apply_model_view(messages, config, context);
1824        }
1825        messages
1826    }
1827}
1828
1829/// True when an available capability contributes compaction policy in this set.
1830///
1831/// Infinity context defers token-budget eviction to compaction when both are
1832/// enabled (see knowledge/runtime-resources/infinity-context.md) so that compaction's summary — not a
1833/// bare "hidden" notice — covers trimmed history.
1834fn compaction_is_enabled(
1835    capability_configs: &[AgentCapabilityConfig],
1836    registry: &CapabilityRegistry,
1837) -> bool {
1838    capability_configs.iter().any(|cap_config| {
1839        registry.get(cap_config.capability_id()).is_some_and(|cap| {
1840            cap.status() == CapabilityStatus::Available
1841                && cap.compaction_policy(cap_config.config_value()).is_some()
1842        })
1843    })
1844}
1845
1846/// Collect only message filter providers from capabilities, skipping system
1847/// prompt contributions, tools, mounts, and other expensive work.
1848///
1849/// This is a fast path for callers that only need message filtering (e.g.,
1850/// the message-loading step in ReasonAtom before RuntimeAgent is built).
1851pub fn collect_message_filters_only(
1852    capability_configs: &[AgentCapabilityConfig],
1853    registry: &CapabilityRegistry,
1854) -> CollectedMessageFilters {
1855    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
1856        Vec::new();
1857    let compaction_on = compaction_is_enabled(capability_configs, registry);
1858
1859    for cap_config in capability_configs {
1860        let cap_id = cap_config.capability_id();
1861        if let Some(capability) = registry.get(cap_id) {
1862            if capability.status() != CapabilityStatus::Available {
1863                continue;
1864            }
1865            // Resolve against None: no model is known at message-filter collection
1866            // time, so fall back to the model-agnostic variant if present.
1867            let effective: &dyn Capability = capability
1868                .resolve_for_model(None)
1869                .unwrap_or_else(|| capability.as_ref());
1870            if let Some(provider) = effective.message_filter_provider() {
1871                let config =
1872                    effective.message_filter_config(cap_config.config_value(), compaction_on);
1873                message_filter_providers.push((provider, config));
1874            }
1875        }
1876    }
1877
1878    message_filter_providers.sort_by_key(|(p, _)| p.priority());
1879
1880    CollectedMessageFilters {
1881        message_filter_providers,
1882    }
1883}
1884
1885/// Collect only model-view providers from capabilities.
1886///
1887/// `model` should be the LLM model name when it is known at call time (e.g. the
1888/// ReasonAtom already holds a resolved model execution). Pass `None` only when the
1889/// model is genuinely unavailable so capabilities fall back to the model-agnostic
1890/// variant.
1891pub fn collect_model_view_providers(
1892    capability_configs: &[AgentCapabilityConfig],
1893    registry: &CapabilityRegistry,
1894    model: Option<&str>,
1895) -> CollectedModelViewProviders {
1896    let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
1897
1898    for cap_config in capability_configs {
1899        let cap_id = cap_config.capability_id();
1900        if let Some(capability) = registry.get(cap_id) {
1901            if capability.status() != CapabilityStatus::Available {
1902                continue;
1903            }
1904            let effective: &dyn Capability = capability
1905                .resolve_for_model(model)
1906                .unwrap_or_else(|| capability.as_ref());
1907            if let Some(provider) = effective.model_view_provider() {
1908                model_view_providers.push((provider, cap_config.config_value().clone()));
1909            }
1910        }
1911    }
1912
1913    model_view_providers.sort_by_key(|(p, _)| p.priority());
1914
1915    CollectedModelViewProviders {
1916        model_view_providers,
1917    }
1918}
1919
1920/// Collect [`Volatility::Dynamic`] facts from every active capability, in
1921/// configured order. Called by `ReasonAtom` once per request so live values
1922/// (e.g. the current time) are fresh, then rendered into the trailing `<facts>`
1923/// block. Static facts are ignored here — they already live in the cached
1924/// system prompt.
1925pub fn collect_dynamic_facts(
1926    capability_configs: &[AgentCapabilityConfig],
1927    registry: &CapabilityRegistry,
1928    model: Option<&str>,
1929    ctx: &FactsContext,
1930) -> Vec<Fact> {
1931    let mut dynamic = Vec::new();
1932    for cap_config in capability_configs {
1933        let cap_id = cap_config.capability_id();
1934        if let Some(capability) = registry.get(cap_id) {
1935            if capability.status() != CapabilityStatus::Available {
1936                continue;
1937            }
1938            let effective: &dyn Capability = capability
1939                .resolve_for_model(model)
1940                .unwrap_or_else(|| capability.as_ref());
1941            for fact in effective.facts(cap_config.config_value(), ctx) {
1942                if fact.volatility == Volatility::Dynamic {
1943                    dynamic.push(fact);
1944                }
1945            }
1946        }
1947    }
1948    dynamic
1949}
1950
1951pub fn collect_capability_mcp_servers(
1952    capability_configs: &[AgentCapabilityConfig],
1953    registry: &CapabilityRegistry,
1954) -> ScopedMcpServers {
1955    let mut servers = ScopedMcpServers::default();
1956
1957    for cap_config in capability_configs {
1958        let cap_id = cap_config.capability_id();
1959        // Both `declarative:` and `plugin:` carry a serialized
1960        // `DeclarativeCapabilityDefinition`; handle them the same way.
1961        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
1962            if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
1963                cap_config.config_value().clone(),
1964            ) {
1965                if definition.status != CapabilityStatus::Available {
1966                    continue;
1967                }
1968                if let Some(contributed) = definition.mcp_servers {
1969                    servers = merge_scoped_mcp_servers(&servers, &contributed);
1970                }
1971            }
1972            continue;
1973        }
1974        if let Some(capability) = registry.get(cap_id) {
1975            if capability.status() != CapabilityStatus::Available {
1976                continue;
1977            }
1978            servers = merge_scoped_mcp_servers(
1979                &servers,
1980                &capability.mcp_servers_with_config(cap_config.config_value()),
1981            );
1982        }
1983    }
1984
1985    servers
1986}
1987
1988// ============================================================================
1989// Dependency Resolution
1990// ============================================================================
1991
1992/// Maximum number of capabilities after dependency resolution.
1993/// This prevents runaway dependency chains and resource exhaustion.
1994pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
1995
1996/// Error type for dependency resolution failures
1997#[derive(Debug, Clone, PartialEq, Eq)]
1998pub enum DependencyError {
1999    /// Circular dependency detected in the capability graph
2000    CircularDependency {
2001        /// The capability where the cycle was detected
2002        capability_id: String,
2003        /// The dependency chain leading to the cycle
2004        chain: Vec<String>,
2005    },
2006    /// Too many capabilities after resolution
2007    TooManyCapabilities {
2008        /// Number of capabilities requested
2009        count: usize,
2010        /// Maximum allowed
2011        max: usize,
2012    },
2013}
2014
2015impl std::fmt::Display for DependencyError {
2016    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2017        match self {
2018            DependencyError::CircularDependency {
2019                capability_id,
2020                chain,
2021            } => {
2022                write!(
2023                    f,
2024                    "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2025                    capability_id,
2026                    chain.join(" -> "),
2027                    capability_id
2028                )
2029            }
2030            DependencyError::TooManyCapabilities { count, max } => {
2031                write!(
2032                    f,
2033                    "Too many capabilities after resolution: {} (max: {})",
2034                    count, max
2035                )
2036            }
2037        }
2038    }
2039}
2040
2041impl std::error::Error for DependencyError {}
2042
2043/// Result of resolving capability dependencies
2044#[derive(Debug, Clone)]
2045pub struct ResolvedCapabilities {
2046    /// All capability IDs after resolving dependencies (in topological order)
2047    /// Dependencies come before dependents.
2048    pub resolved_ids: Vec<String>,
2049    /// IDs that were added as dependencies (not in the original selection)
2050    pub added_as_dependencies: Vec<String>,
2051    /// Original user-selected capability IDs
2052    pub user_selected: Vec<String>,
2053}
2054
2055/// Resolve capability dependencies, returning all required capability IDs.
2056///
2057/// This function:
2058/// 1. Takes the user-selected capability IDs
2059/// 2. Recursively collects all dependencies
2060/// 3. Returns them in topological order (dependencies before dependents)
2061/// 4. Detects circular dependencies and returns an error
2062/// 5. Enforces a maximum capability limit
2063///
2064/// # Arguments
2065///
2066/// * `selected_ids` - User-selected capability IDs
2067/// * `registry` - The capability registry to look up dependencies
2068///
2069/// # Returns
2070///
2071/// `Ok(ResolvedCapabilities)` with all required capabilities in order,
2072/// or `Err(DependencyError)` if circular dependencies are detected or
2073/// the limit is exceeded.
2074pub fn resolve_dependencies(
2075    selected_ids: &[String],
2076    registry: &CapabilityRegistry,
2077) -> Result<ResolvedCapabilities, DependencyError> {
2078    use std::collections::HashSet;
2079
2080    // Canonicalize so capabilities selected via alias match their resolved IDs.
2081    let user_selected: HashSet<String> = selected_ids
2082        .iter()
2083        .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2084        .collect();
2085    let mut resolved: Vec<String> = Vec::new();
2086    let mut resolved_set: HashSet<String> = HashSet::new();
2087    let mut added_as_dependencies: Vec<String> = Vec::new();
2088
2089    // Process each selected capability and its dependencies using DFS
2090    for cap_id in selected_ids {
2091        resolve_single_capability(
2092            cap_id,
2093            registry,
2094            &mut resolved,
2095            &mut resolved_set,
2096            &mut added_as_dependencies,
2097            &user_selected,
2098            &mut Vec::new(), // visiting chain for cycle detection
2099        )?;
2100    }
2101
2102    // Check max limit
2103    if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2104        return Err(DependencyError::TooManyCapabilities {
2105            count: resolved.len(),
2106            max: MAX_RESOLVED_CAPABILITIES,
2107        });
2108    }
2109
2110    Ok(ResolvedCapabilities {
2111        resolved_ids: resolved,
2112        added_as_dependencies,
2113        user_selected: selected_ids.to_vec(),
2114    })
2115}
2116
2117/// Resolve dependency-expanded capability configs, preserving explicit config on selected IDs.
2118///
2119/// Dependencies are inserted with empty configs. If the same capability is provided more than
2120/// once, the last explicit config wins.
2121pub fn resolve_capability_configs(
2122    selected_configs: &[AgentCapabilityConfig],
2123    registry: &CapabilityRegistry,
2124) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2125    let mut selected_ids: Vec<String> = Vec::new();
2126    for config in selected_configs {
2127        // Both `declarative:` and `plugin:` carry a `DeclarativeCapabilityDefinition`
2128        // config that may declare dependencies.
2129        if (is_declarative_capability(config.capability_id())
2130            || is_plugin_capability(config.capability_id()))
2131            && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2132                config.config_value().clone(),
2133            )
2134        {
2135            selected_ids.extend(definition.dependencies);
2136        }
2137        selected_ids.push(config.capability_id().to_string());
2138    }
2139    let resolved = resolve_dependencies(&selected_ids, registry)?;
2140
2141    // Key explicit configs by canonical ID so config supplied under an alias
2142    // still attaches to the (canonical) resolved capability ID.
2143    let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2144        .iter()
2145        .map(|config| {
2146            let id = config.capability_id();
2147            let id = registry.canonical_id(id).unwrap_or(id);
2148            (id.to_string(), config.config_value().clone())
2149        })
2150        .collect();
2151
2152    Ok(resolved
2153        .resolved_ids
2154        .into_iter()
2155        .map(|capability_id| {
2156            explicit_configs
2157                .get(&capability_id)
2158                .cloned()
2159                .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2160                .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2161        })
2162        .collect())
2163}
2164
2165/// Helper function to resolve a single capability and its dependencies recursively.
2166fn resolve_single_capability(
2167    cap_id: &str,
2168    registry: &CapabilityRegistry,
2169    resolved: &mut Vec<String>,
2170    resolved_set: &mut std::collections::HashSet<String>,
2171    added_as_dependencies: &mut Vec<String>,
2172    user_selected: &std::collections::HashSet<String>,
2173    visiting: &mut Vec<String>,
2174) -> Result<(), DependencyError> {
2175    // Normalize aliases to the canonical ID so an alias and its canonical ID
2176    // resolve (and dedupe) to the same capability. Unknown IDs (declarative,
2177    // MCP, skill refs) pass through unchanged.
2178    let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2179
2180    // Already resolved
2181    if resolved_set.contains(cap_id) {
2182        return Ok(());
2183    }
2184
2185    // Check for circular dependency
2186    if visiting.contains(&cap_id.to_string()) {
2187        return Err(DependencyError::CircularDependency {
2188            capability_id: cap_id.to_string(),
2189            chain: visiting.clone(),
2190        });
2191    }
2192
2193    // Get capability from registry
2194    let capability = match registry.get(cap_id) {
2195        Some(cap) => cap,
2196        None => {
2197            // `declarative:` and `plugin:` refs carry their full definition in
2198            // the config payload — they don't need a registry entry. Pass them
2199            // through so `collect_capabilities_with_configs` can process them.
2200            if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2201                && !resolved_set.contains(cap_id)
2202            {
2203                resolved.push(cap_id.to_string());
2204                resolved_set.insert(cap_id.to_string());
2205                if !user_selected.contains(cap_id) {
2206                    added_as_dependencies.push(cap_id.to_string());
2207                }
2208            }
2209            return Ok(());
2210        }
2211    };
2212
2213    // Mark as visiting
2214    visiting.push(cap_id.to_string());
2215
2216    // Resolve dependencies first (depth-first)
2217    for dep_id in capability.dependencies() {
2218        resolve_single_capability(
2219            dep_id,
2220            registry,
2221            resolved,
2222            resolved_set,
2223            added_as_dependencies,
2224            user_selected,
2225            visiting,
2226        )?;
2227    }
2228
2229    // Remove from visiting
2230    visiting.pop();
2231
2232    // Add to resolved
2233    if !resolved_set.contains(cap_id) {
2234        resolved.push(cap_id.to_string());
2235        resolved_set.insert(cap_id.to_string());
2236
2237        // Track if this was added as a dependency (not user-selected)
2238        if !user_selected.contains(cap_id) {
2239            added_as_dependencies.push(cap_id.to_string());
2240        }
2241    }
2242
2243    Ok(())
2244}
2245
2246/// Compute the aggregated set of UI features from a list of capability IDs.
2247///
2248/// Resolves dependencies, collects features from all resolved capabilities,
2249/// and returns deduplicated feature strings.
2250pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2251    use std::collections::HashSet;
2252
2253    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2254        Ok(resolved) => resolved.resolved_ids,
2255        Err(_) => capability_ids.to_vec(),
2256    };
2257
2258    let mut seen = HashSet::new();
2259    let mut features = Vec::new();
2260    for cap_id in &resolved_ids {
2261        if let Some(cap) = registry.get(cap_id) {
2262            for feature in cap.features() {
2263                if seen.insert(feature) {
2264                    features.push(feature.to_string());
2265                }
2266            }
2267        }
2268    }
2269    features
2270}
2271
2272/// Get direct dependencies for a capability ID.
2273/// Returns empty vec if capability not found.
2274pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2275    registry
2276        .get(cap_id)
2277        .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2278        .unwrap_or_default()
2279}
2280
2281/// Collect contributions from capabilities without applying them.
2282///
2283/// Resolves dependencies first, then calls `system_prompt_contribution()` (async)
2284/// on each capability, enabling dynamic content generation based on session context
2285/// (e.g., reading AGENTS.md, discovering skills).
2286///
2287/// Note: This function does not collect message filter providers since it doesn't
2288/// have access to per-agent capability configs. Use `collect_capabilities_with_configs`
2289/// if you need message filter providers.
2290///
2291/// # Arguments
2292///
2293/// * `capability_ids` - Ordered list of capability IDs to collect
2294/// * `registry` - The capability registry containing implementations
2295/// * `ctx` - Session context for dynamic prompt resolution
2296pub async fn collect_capabilities(
2297    capability_ids: &[String],
2298    registry: &CapabilityRegistry,
2299    ctx: &SystemPromptContext,
2300) -> CollectedCapabilities {
2301    // Resolve dependencies so that transitive capabilities (e.g. session_storage
2302    // via browserless) are included automatically.
2303    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2304        Ok(resolved) => resolved.resolved_ids,
2305        Err(e) => {
2306            tracing::warn!("Failed to resolve capability dependencies: {}", e);
2307            capability_ids.to_vec()
2308        }
2309    };
2310
2311    // Convert to AgentCapabilityConfig with empty configs
2312    let configs: Vec<AgentCapabilityConfig> = resolved_ids
2313        .iter()
2314        .map(|id| {
2315            AgentCapabilityConfig::with_config(
2316                CapabilityId::new(id),
2317                serde_json::Value::Object(serde_json::Map::new()),
2318            )
2319        })
2320        .collect();
2321
2322    collect_capabilities_with_configs(&configs, registry, ctx).await
2323}
2324
2325/// Collect contributions from capabilities with their per-agent configurations.
2326///
2327/// Calls `system_prompt_contribution()` (async) on each capability, enabling
2328/// dynamic content generation based on session context.
2329///
2330/// # Arguments
2331///
2332/// * `capability_configs` - Ordered list of capability configs (ID + per-agent config)
2333/// * `registry` - The capability registry containing implementations
2334/// * `ctx` - Session context for dynamic prompt resolution
2335pub async fn collect_capabilities_with_configs(
2336    capability_configs: &[AgentCapabilityConfig],
2337    registry: &CapabilityRegistry,
2338    ctx: &SystemPromptContext,
2339) -> CollectedCapabilities {
2340    let mut system_prompt_parts: Vec<String> = Vec::new();
2341    let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2342    let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2343    let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2344    let mut mounts: Vec<MountPoint> = Vec::new();
2345    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2346        Vec::new();
2347    let mut applied_ids: Vec<String> = Vec::new();
2348    let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2349    let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2350    let mut openrouter_routing: Option<crate::driver_registry::OpenRouterRoutingConfig> = None;
2351    let mut parallel_tool_calls: Option<bool> = None;
2352    let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2353    let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2354    // Per-capability narration adapters, appended after explicit tool-call
2355    // hooks so model-authored narration (human_intent) keeps precedence.
2356    let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2357    let mut mcp_servers = ScopedMcpServers::default();
2358    // Facts contributed by capabilities. Static facts fold into the cached
2359    // system prompt below; a single note is added when any dynamic fact exists,
2360    // explaining the live `<facts>` block that `ReasonAtom` appends per turn.
2361    let mut static_facts: Vec<Fact> = Vec::new();
2362    let mut has_dynamic_facts = false;
2363    let facts_ctx = FactsContext::new(ctx.session_id);
2364    let compaction_on = compaction_is_enabled(capability_configs, registry);
2365    let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2366
2367    for cap_config in capability_configs {
2368        let cap_id = cap_config.capability_id();
2369        // `declarative:` and `plugin:` refs both carry a serialized
2370        // `DeclarativeCapabilityDefinition` in their config and execute through
2371        // the same runtime path. `plugin:` is handled first (more specific
2372        // prefix), then `declarative:`, then the registry lookup.
2373        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2374            match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2375                cap_config.config_value().clone(),
2376            ) {
2377                Ok(definition) => {
2378                    if definition.status != CapabilityStatus::Available {
2379                        continue;
2380                    }
2381
2382                    if let Some(prompt) = definition.system_prompt.as_deref() {
2383                        let contribution =
2384                            format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2385                        system_prompt_attributions.push(SystemPromptAttribution {
2386                            capability_id: cap_id.to_string(),
2387                            content: contribution.clone(),
2388                        });
2389                        system_prompt_parts.push(contribution);
2390                    }
2391
2392                    mounts.extend(definition.mounts(cap_id));
2393                    if let Some(ref servers) = definition.mcp_servers {
2394                        mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2395                    }
2396                    for skill in definition.skill_contributions() {
2397                        mounts.push(skill.to_mount(cap_id));
2398                    }
2399
2400                    applied_ids.push(cap_id.to_string());
2401                }
2402                Err(error) => {
2403                    tracing::warn!(
2404                        capability_id = %cap_id,
2405                        error = %error,
2406                        "Skipping invalid declarative/plugin capability config"
2407                    );
2408                }
2409            }
2410            continue;
2411        }
2412        if let Some(capability) = registry.get(cap_id) {
2413            // Only collect from available capabilities
2414            if capability.status() != CapabilityStatus::Available {
2415                continue;
2416            }
2417
2418            // Model-adaptive dispatch: a capability may delegate its contributions
2419            // to a different underlying capability based on the agent's model
2420            // (e.g. `auto_tool_search` picks hosted vs client-side tool search).
2421            // Every contribution below is collected from `effective` (system prompt,
2422            // tools, hooks, tool definitions, mounts, MCP servers, skills, message
2423            // filters); for the common non-delegating case `effective` is just
2424            // `capability`. Driver preferences are also contributed through the
2425            // effective implementation's neutral trait methods, so a resolved
2426            // `auto_tool_search` behaves as whichever mechanism it became.
2427            // Attribution stays on the configured `cap_id`/`capability` so tools
2428            // surface under the capability the user actually configured.
2429            let effective: &dyn Capability =
2430                match capability.resolve_for_model(ctx.model.as_deref()) {
2431                    Some(inner) => inner,
2432                    None => capability.as_ref(),
2433                };
2434            let delegation_target =
2435                effective.delegation_target_with_config(cap_config.config_value());
2436
2437            // Collect dynamic system prompt contribution (config-aware, may read from filesystem)
2438            if let Some(contribution) = effective
2439                .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2440                .await
2441            {
2442                system_prompt_attributions.push(SystemPromptAttribution {
2443                    capability_id: cap_id.to_string(),
2444                    content: contribution.clone(),
2445                });
2446                system_prompt_parts.push(contribution);
2447            }
2448
2449            // Collect declared facts. Static facts fold into the cached prompt
2450            // below; dynamic facts are re-collected per request by `ReasonAtom`
2451            // and appended at the conversation tail, so here we only note their
2452            // presence to add the explanatory system-prompt line.
2453            for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2454                match fact.volatility {
2455                    Volatility::Static => static_facts.push(fact),
2456                    Volatility::Dynamic => has_dynamic_facts = true,
2457                }
2458            }
2459
2460            // Collect tools and hooks (config-aware: capabilities can adapt based on per-agent config)
2461            tools.extend(effective.tools_with_config(cap_config.config_value()));
2462            if let Some(target) = delegation_target {
2463                delegation_targets.push(target);
2464            }
2465            tool_definition_hooks.extend(
2466                effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2467            );
2468            tool_call_hooks.extend(effective.tool_call_hooks());
2469            // Route this capability's `narrate()` through the hook channel.
2470            narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2471            // Output guardrails are NOT collected here — see CollectedCapabilities
2472            // for rationale. ReasonAtom re-derives them at stream-arming time.
2473
2474            // Collect tool definitions, propagating capability category if not already set
2475            let cap_category = effective.category();
2476            for def in effective.tool_definitions() {
2477                let def = match (def.category(), cap_category) {
2478                    (None, Some(cat)) => def.with_category(cat),
2479                    _ => def,
2480                }
2481                .with_capability_attribution(cap_id, Some(capability.name()));
2482                tool_definitions.push(def);
2483            }
2484
2485            tool_search = effective
2486                .tool_search_config(cap_config.config_value())
2487                .or(tool_search);
2488            prompt_cache = effective
2489                .prompt_cache_config(cap_config.config_value())
2490                .or(prompt_cache);
2491            parallel_tool_calls = effective
2492                .parallel_tool_calls_preference(cap_config.config_value())
2493                .or(parallel_tool_calls);
2494
2495            openrouter_routing = effective
2496                .openrouter_routing_config(cap_config.config_value())
2497                .or(openrouter_routing);
2498
2499            // Collect mount points
2500            mounts.extend(effective.mounts());
2501
2502            mcp_servers = merge_scoped_mcp_servers(
2503                &mcp_servers,
2504                &effective.mcp_servers_with_config(cap_config.config_value()),
2505            );
2506
2507            // Normalize capability-contributed skills into mount points under
2508            // `/.agents/skills/{name}/`. Discovery/activation stays with the
2509            // built-in `skills` capability — see knowledge/project/skills-registry.md.
2510            for skill in effective.contribute_skills() {
2511                mounts.push(skill.to_mount(cap_id));
2512            }
2513
2514            // Collect message filter provider
2515            if let Some(provider) = effective.message_filter_provider() {
2516                let config =
2517                    effective.message_filter_config(cap_config.config_value(), compaction_on);
2518                message_filter_providers.push((provider, config));
2519            }
2520
2521            applied_ids.push(cap_id.to_string());
2522        }
2523    }
2524
2525    // Delegation providers share one model-facing `spawn_agent` dispatcher.
2526    // Unknown tools with that name still win to preserve their contract.
2527    if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2528        let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2529        let def = tool
2530            .to_definition()
2531            .with_category("Orchestration")
2532            .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2533        tools.push(Box::new(tool));
2534        tool_definitions.push(def);
2535    }
2536
2537    // Auto-activated adapters are selected through a neutral capability hook;
2538    // core does not name or own the hosted implementation.
2539    let auto_activated: Vec<_> = registry
2540        .list()
2541        .into_iter()
2542        .filter(|cap| {
2543            !applied_ids.iter().any(|id| id == cap.id())
2544                && cap.status() == CapabilityStatus::Available
2545                && cap.auto_activates_for(&tool_definitions)
2546        })
2547        .cloned()
2548        .collect();
2549    for cap in auto_activated {
2550        tools.extend(cap.tools());
2551        let cap_category = cap.category();
2552        for def in cap.tool_definitions() {
2553            let def = match (def.category(), cap_category) {
2554                (None, Some(cat)) => def.with_category(cat),
2555                _ => def,
2556            }
2557            .with_capability_attribution(cap.id(), Some(cap.name()));
2558            tool_definitions.push(def);
2559        }
2560        narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2561        applied_ids.push(cap.id().to_string());
2562    }
2563
2564    // Fold static facts into the cached system-prompt prefix, and add the
2565    // dynamic-facts note once when any capability declared a dynamic fact. Both
2566    // are stable across turns, so they stay in the cached prefix; the live
2567    // dynamic values are appended at the conversation tail per request.
2568    if let Some(block) = facts::render_facts_block(&static_facts) {
2569        system_prompt_attributions.push(SystemPromptAttribution {
2570            capability_id: "facts".to_string(),
2571            content: block.clone(),
2572        });
2573        system_prompt_parts.push(block);
2574    }
2575    if has_dynamic_facts {
2576        system_prompt_attributions.push(SystemPromptAttribution {
2577            capability_id: "facts".to_string(),
2578            content: FACTS_DYNAMIC_NOTE.to_string(),
2579        });
2580        system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2581    }
2582
2583    // Append per-capability narration adapters after every explicit tool-call
2584    // hook so capability-owned narration is consulted only once model-authored
2585    // hooks (human_intent) have had their say.
2586    tool_call_hooks.extend(narration_hooks);
2587
2588    // Sort message filter providers by priority (lower = earlier)
2589    message_filter_providers.sort_by_key(|(p, _)| p.priority());
2590
2591    CollectedCapabilities {
2592        system_prompt_parts,
2593        system_prompt_attributions,
2594        tools,
2595        tool_definitions,
2596        mounts,
2597        message_filter_providers,
2598        applied_ids,
2599        tool_search,
2600        prompt_cache,
2601        openrouter_routing,
2602        parallel_tool_calls,
2603        tool_definition_hooks,
2604        tool_call_hooks,
2605        mcp_servers,
2606    }
2607}
2608
2609// ============================================================================
2610// Apply Capabilities to RuntimeAgent
2611// ============================================================================
2612
2613/// Result of applying capabilities to a base runtime agent
2614pub struct AppliedCapabilities {
2615    /// The modified runtime agent with capability contributions merged
2616    pub runtime_agent: RuntimeAgent,
2617    /// Tool registry containing all capability tools
2618    pub tool_registry: ToolRegistry,
2619    /// IDs of capabilities that were applied
2620    pub applied_ids: Vec<String>,
2621}
2622
2623/// Apply capabilities to a base runtime agent configuration.
2624///
2625/// This function:
2626/// 1. Collects system prompt contributions from capabilities (in order)
2627/// 2. Appends them after the agent's base system prompt
2628/// 3. Collects all tools from capabilities
2629/// 4. Returns the modified runtime agent and a tool registry
2630///
2631/// # Arguments
2632///
2633/// * `base_runtime_agent` - The agent's base runtime configuration
2634/// * `capability_ids` - Ordered list of capability IDs to apply
2635/// * `registry` - The capability registry containing implementations
2636/// * `ctx` - Session context for dynamic prompt resolution
2637///
2638/// # Returns
2639///
2640/// An `AppliedCapabilities` struct containing the modified runtime agent,
2641/// tool registry, and list of applied capability IDs.
2642///
2643/// # Example
2644///
2645/// ```ignore
2646/// use everruns_core::capabilities::{apply_capabilities, CapabilityRegistry, SystemPromptContext};
2647/// use everruns_core::runtime_agent::RuntimeAgent;
2648///
2649/// let registry = CapabilityRegistry::new();
2650/// let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2651/// let ctx = SystemPromptContext::without_file_store(SessionId::new());
2652///
2653/// let capability_ids = Vec::new();
2654/// let applied = apply_capabilities(base_runtime_agent, &capability_ids, &registry, &ctx).await;
2655///
2656/// assert!(applied.applied_ids.is_empty());
2657/// ```
2658pub async fn apply_capabilities(
2659    base_runtime_agent: RuntimeAgent,
2660    capability_ids: &[String],
2661    registry: &CapabilityRegistry,
2662    ctx: &SystemPromptContext,
2663) -> AppliedCapabilities {
2664    let collected = collect_capabilities(capability_ids, registry, ctx).await;
2665
2666    // Build final system prompt: base prompt first, then capability additions.
2667    let final_system_prompt = compose_system_prompt(
2668        &base_runtime_agent.system_prompt,
2669        collected.system_prompt_prefix().as_deref(),
2670    );
2671
2672    // Build tool registry from collected tools
2673    let mut tool_registry = ToolRegistry::new();
2674    for tool in collected.tools {
2675        tool_registry.register_boxed(tool);
2676    }
2677
2678    // Create modified runtime agent
2679    let mut tools = collected.tool_definitions;
2680    for hook in &collected.tool_definition_hooks {
2681        tools = hook.transform(tools);
2682    }
2683
2684    let runtime_agent = RuntimeAgent {
2685        system_prompt: final_system_prompt,
2686        model: base_runtime_agent.model,
2687        tools,
2688        max_iterations: base_runtime_agent.max_iterations,
2689        temperature: base_runtime_agent.temperature,
2690        max_tokens: base_runtime_agent.max_tokens,
2691        tool_search: collected.tool_search,
2692        prompt_cache: collected.prompt_cache,
2693        openrouter_routing: collected.openrouter_routing,
2694        network_access: base_runtime_agent.network_access,
2695        // Explicit request-level preference (escape hatch) wins; otherwise the
2696        // `parallel_tool_calls` capability supplies the preference.
2697        parallel_tool_calls: base_runtime_agent
2698            .parallel_tool_calls
2699            .or(collected.parallel_tool_calls),
2700    };
2701
2702    AppliedCapabilities {
2703        runtime_agent,
2704        tool_registry,
2705        applied_ids: collected.applied_ids,
2706    }
2707}
2708
2709// ============================================================================
2710// Tests
2711// ============================================================================
2712
2713#[cfg(test)]
2714mod tests {
2715    use super::*;
2716    use crate::typed_id::SessionId;
2717    use uuid::Uuid;
2718
2719    /// Test helper: dummy context with no file store
2720    fn test_ctx() -> SystemPromptContext {
2721        SystemPromptContext::without_file_store(SessionId::new())
2722    }
2723
2724    // -------------------------------------------------------------------------
2725    // Local stand-ins for the fixture capabilities that moved to the
2726    // `everruns-test-support` crate (EVE-875). The registry/apply/dependency
2727    // mechanics tested here only need capabilities with these shapes: one
2728    // that contributes nothing, one that contributes plain tools, and one
2729    // that carries mounts plus a dependency.
2730    // -------------------------------------------------------------------------
2731
2732    /// Contributes nothing: no tools, no prompt, no dependencies.
2733    struct NoopFixture;
2734
2735    impl Capability for NoopFixture {
2736        fn id(&self) -> &str {
2737            "noop"
2738        }
2739        fn name(&self) -> &str {
2740            "No-Op"
2741        }
2742        fn description(&self) -> &str {
2743            "Contributes nothing."
2744        }
2745    }
2746
2747    /// Declares one arbitrary feature to exercise core's neutral projection.
2748    struct FeatureFixture;
2749
2750    impl Capability for FeatureFixture {
2751        fn id(&self) -> &str {
2752            "feature_fixture"
2753        }
2754        fn name(&self) -> &str {
2755            "Feature Fixture"
2756        }
2757        fn description(&self) -> &str {
2758            "Declares one test-only feature."
2759        }
2760        fn features(&self) -> Vec<&'static str> {
2761            vec!["fixture_feature"]
2762        }
2763    }
2764
2765    struct FixtureTool(&'static str);
2766
2767    #[async_trait]
2768    impl Tool for FixtureTool {
2769        fn name(&self) -> &str {
2770            self.0
2771        }
2772        fn description(&self) -> &str {
2773            "Fixture tool."
2774        }
2775        fn parameters_schema(&self) -> serde_json::Value {
2776            serde_json::json!({
2777                "type": "object",
2778                "properties": {},
2779                "additionalProperties": false
2780            })
2781        }
2782        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2783            ToolExecutionResult::success(serde_json::json!({ "ok": true }))
2784        }
2785    }
2786
2787    struct BackgroundFixtureTool;
2788
2789    #[async_trait]
2790    impl Tool for BackgroundFixtureTool {
2791        fn name(&self) -> &str {
2792            "bash"
2793        }
2794        fn description(&self) -> &str {
2795            "Fixture background-capable shell tool."
2796        }
2797        fn parameters_schema(&self) -> serde_json::Value {
2798            serde_json::json!({"type": "object"})
2799        }
2800        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
2801            ToolExecutionResult::success(serde_json::json!({"ok": true}))
2802        }
2803        fn hints(&self) -> crate::tool_types::ToolHints {
2804            crate::tool_types::ToolHints {
2805                supports_background: Some(true),
2806                ..Default::default()
2807            }
2808        }
2809    }
2810
2811    struct FileSystemFixture;
2812
2813    impl Capability for FileSystemFixture {
2814        fn id(&self) -> &str {
2815            "session_file_system"
2816        }
2817        fn name(&self) -> &str {
2818            "Fixture Filesystem"
2819        }
2820        fn description(&self) -> &str {
2821            "Fixture filesystem capability."
2822        }
2823        fn tools(&self) -> Vec<Box<dyn Tool>> {
2824            vec![
2825                Box::new(FixtureTool("read_file")),
2826                Box::new(FixtureTool("write_file")),
2827            ]
2828        }
2829        fn features(&self) -> Vec<&'static str> {
2830            vec!["file_system"]
2831        }
2832    }
2833
2834    /// Stands in for the product `session_storage` capability, which moved to
2835    /// `everruns-platform` with the other service-backed families (EVE-886).
2836    /// Feature computation is core's mechanism, so it is exercised here against
2837    /// a fixture rather than a product implementation.
2838    struct StorageFixture;
2839
2840    impl Capability for StorageFixture {
2841        fn id(&self) -> &str {
2842            "session_storage"
2843        }
2844        fn name(&self) -> &str {
2845            "Fixture Storage"
2846        }
2847        fn description(&self) -> &str {
2848            "Fixture session storage capability."
2849        }
2850        fn features(&self) -> Vec<&'static str> {
2851            vec!["secrets", "key_value"]
2852        }
2853    }
2854
2855    struct BashFixture;
2856
2857    impl Capability for BashFixture {
2858        fn id(&self) -> &str {
2859            "bashkit_shell"
2860        }
2861        fn aliases(&self) -> Vec<&'static str> {
2862            vec!["virtual_bash"]
2863        }
2864        fn name(&self) -> &str {
2865            "Fixture Bash"
2866        }
2867        fn description(&self) -> &str {
2868            "Fixture shell capability."
2869        }
2870        fn tools(&self) -> Vec<Box<dyn Tool>> {
2871            vec![Box::new(BackgroundFixtureTool)]
2872        }
2873        fn dependencies(&self) -> Vec<&'static str> {
2874            vec!["session_file_system"]
2875        }
2876        fn features(&self) -> Vec<&'static str> {
2877            vec!["file_system"]
2878        }
2879        fn risk_level(&self) -> RiskLevel {
2880            RiskLevel::High
2881        }
2882    }
2883
2884    struct WebFetchFixture;
2885
2886    impl Capability for WebFetchFixture {
2887        fn id(&self) -> &str {
2888            "web_fetch"
2889        }
2890        fn name(&self) -> &str {
2891            "Fixture Web Fetch"
2892        }
2893        fn description(&self) -> &str {
2894            "Fixture web capability."
2895        }
2896        fn risk_level(&self) -> RiskLevel {
2897            RiskLevel::High
2898        }
2899    }
2900
2901    /// Portable-policy-shaped stand-ins used only to exercise neutral core
2902    /// collection mechanics after policy implementations moved out of core.
2903    struct DynamicFactFixture;
2904
2905    impl Capability for DynamicFactFixture {
2906        fn id(&self) -> &str {
2907            "current_time"
2908        }
2909        fn name(&self) -> &str {
2910            "Dynamic Fact Fixture"
2911        }
2912        fn description(&self) -> &str {
2913            "Fixture with one dynamic fact and one tool."
2914        }
2915        fn icon(&self) -> Option<&str> {
2916            Some("clock")
2917        }
2918        fn category(&self) -> Option<&str> {
2919            Some("Core")
2920        }
2921        fn tools(&self) -> Vec<Box<dyn Tool>> {
2922            vec![Box::new(FixtureTool("get_current_time"))]
2923        }
2924        fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
2925            vec![Fact::dynamic("current_time", "fixture-now")]
2926        }
2927    }
2928
2929    struct PromptToolFixture;
2930
2931    impl Capability for PromptToolFixture {
2932        fn id(&self) -> &str {
2933            "prompt_tool_fixture"
2934        }
2935        fn name(&self) -> &str {
2936            "Prompt Tool Fixture"
2937        }
2938        fn description(&self) -> &str {
2939            "Fixture with a static prompt and tool."
2940        }
2941        fn system_prompt_addition(&self) -> Option<&str> {
2942            Some("Task Management uses the write_todos tool.")
2943        }
2944        fn tools(&self) -> Vec<Box<dyn Tool>> {
2945            vec![Box::new(FixtureTool("write_todos"))]
2946        }
2947    }
2948
2949    struct SecondPromptFixture;
2950
2951    impl Capability for SecondPromptFixture {
2952        fn id(&self) -> &str {
2953            "second_prompt_fixture"
2954        }
2955        fn name(&self) -> &str {
2956            "Second Prompt Fixture"
2957        }
2958        fn description(&self) -> &str {
2959            "Fixture with a second static prompt."
2960        }
2961        fn system_prompt_addition(&self) -> Option<&str> {
2962            Some("A second capability prompt contribution.")
2963        }
2964    }
2965
2966    struct DynamicPreviewFixture;
2967
2968    impl Capability for DynamicPreviewFixture {
2969        fn id(&self) -> &str {
2970            "agent_instructions"
2971        }
2972        fn name(&self) -> &str {
2973            "Dynamic Preview Fixture"
2974        }
2975        fn description(&self) -> &str {
2976            "Fixture whose runtime prompt is dynamic."
2977        }
2978        fn system_prompt_preview(&self) -> Option<String> {
2979            Some("Reads AGENTS.md dynamically.".to_string())
2980        }
2981    }
2982
2983    /// Contributes four plain calculator-style tools and no prompt addition.
2984    struct MathFixture;
2985
2986    impl Capability for MathFixture {
2987        fn id(&self) -> &str {
2988            "test_math"
2989        }
2990        fn name(&self) -> &str {
2991            "Test Math"
2992        }
2993        fn description(&self) -> &str {
2994            "Fixture: calculator tools."
2995        }
2996        fn tools(&self) -> Vec<Box<dyn Tool>> {
2997            vec![
2998                Box::new(FixtureTool("add")),
2999                Box::new(FixtureTool("subtract")),
3000                Box::new(FixtureTool("multiply")),
3001                Box::new(FixtureTool("divide")),
3002            ]
3003        }
3004    }
3005
3006    /// Contributes two plain tools.
3007    struct WeatherFixture;
3008
3009    impl Capability for WeatherFixture {
3010        fn id(&self) -> &str {
3011            "test_weather"
3012        }
3013        fn name(&self) -> &str {
3014            "Test Weather"
3015        }
3016        fn description(&self) -> &str {
3017            "Fixture: weather tools."
3018        }
3019        fn tools(&self) -> Vec<Box<dyn Tool>> {
3020            vec![
3021                Box::new(FixtureTool("get_weather")),
3022                Box::new(FixtureTool("get_forecast")),
3023            ]
3024        }
3025    }
3026
3027    /// Carries a read-only mount, a prompt addition, a feature, and a
3028    /// dependency on `session_file_system`.
3029    struct SampleDataFixture;
3030
3031    impl Capability for SampleDataFixture {
3032        fn id(&self) -> &str {
3033            "sample_data"
3034        }
3035        fn name(&self) -> &str {
3036            "Sample Data"
3037        }
3038        fn description(&self) -> &str {
3039            "Fixture: mounted sample files."
3040        }
3041        fn system_prompt_addition(&self) -> Option<&str> {
3042            Some("Read-only sample files are mounted at `/samples`.")
3043        }
3044        fn mounts(&self) -> Vec<MountPoint> {
3045            let samples_dir = MountDirectoryBuilder::new()
3046                .file("users.json", "[]")
3047                .build();
3048            vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3049        }
3050        fn dependencies(&self) -> Vec<&'static str> {
3051            vec!["session_file_system"]
3052        }
3053        fn features(&self) -> Vec<&'static str> {
3054            vec!["file_system"]
3055        }
3056    }
3057
3058    /// Built-in registry plus the local fixture stand-ins above.
3059    fn fixture_registry() -> CapabilityRegistry {
3060        let mut registry = CapabilityRegistry::new();
3061        registry.register(NoopFixture);
3062        registry.register(FeatureFixture);
3063        registry.register(MathFixture);
3064        registry.register(WeatherFixture);
3065        registry.register(SampleDataFixture);
3066        registry.register(FileSystemFixture);
3067        registry.register(StorageFixture);
3068        registry.register(BashFixture);
3069        registry.register(WebFetchFixture);
3070        registry.register(DynamicFactFixture);
3071        registry.register(PromptToolFixture);
3072        registry.register(SecondPromptFixture);
3073        registry.register(DynamicPreviewFixture);
3074        registry
3075    }
3076
3077    /// A host-defined capability carrying annotations core knows nothing about.
3078    struct HostAnnotatedCapability;
3079
3080    #[async_trait]
3081    impl Capability for HostAnnotatedCapability {
3082        fn id(&self) -> &str {
3083            "host_annotated"
3084        }
3085        fn name(&self) -> &str {
3086            "Host Annotated"
3087        }
3088        fn description(&self) -> &str {
3089            "Test capability with host-owned metadata."
3090        }
3091        fn metadata(&self) -> Option<serde_json::Value> {
3092            Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3093        }
3094    }
3095
3096    #[test]
3097    fn capability_metadata_is_an_opt_in_host_hatch() {
3098        let metadata = HostAnnotatedCapability.metadata().expect("metadata");
3099        assert_eq!(metadata["icon"], "sparkles");
3100        assert_eq!(metadata["group"], "host");
3101    }
3102
3103    #[test]
3104    fn test_capability_registry_get() {
3105        let mut registry = CapabilityRegistry::new();
3106        registry.register(NoopFixture);
3107
3108        let capability = registry.get("noop").unwrap();
3109        assert_eq!(capability.id(), "noop");
3110        assert_eq!(capability.status(), CapabilityStatus::Available);
3111    }
3112
3113    #[test]
3114    fn default_registry_is_empty_and_selects_no_product_preset() {
3115        assert!(CapabilityRegistry::default().is_empty());
3116        assert!(CapabilityRegistryBuilder::default().build().is_empty());
3117    }
3118
3119    #[test]
3120    fn test_capability_registry_blueprint_with_capability() {
3121        struct BlueprintProviderCapability;
3122
3123        impl Capability for BlueprintProviderCapability {
3124            fn id(&self) -> &str {
3125                "blueprint_provider"
3126            }
3127            fn name(&self) -> &str {
3128                "Blueprint Provider"
3129            }
3130            fn description(&self) -> &str {
3131                "Capability that provides a blueprint for tests"
3132            }
3133            fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3134                vec![AgentBlueprint {
3135                    id: "test_blueprint",
3136                    name: "Test Blueprint",
3137                    description: "Blueprint for capability registry tests",
3138                    model: BlueprintModel::Inherit,
3139                    system_prompt: "Test prompt",
3140                    tools: vec![],
3141                    max_turns: None,
3142                    config_schema: None,
3143                }]
3144            }
3145        }
3146
3147        let mut registry = CapabilityRegistry::new();
3148        registry.register(BlueprintProviderCapability);
3149
3150        let (capability_id, blueprint) = registry
3151            .blueprint_with_capability("test_blueprint")
3152            .expect("blueprint should resolve with capability id");
3153        assert_eq!(capability_id, "blueprint_provider");
3154        assert_eq!(blueprint.id, "test_blueprint");
3155    }
3156
3157    #[test]
3158    fn test_capability_registry_builder() {
3159        let registry = CapabilityRegistry::builder()
3160            .capability(NoopFixture)
3161            .build();
3162
3163        assert!(registry.has("noop"));
3164        assert_eq!(registry.len(), 1);
3165    }
3166
3167    #[test]
3168    fn test_capability_status() {
3169        struct ComingSoonFixture;
3170        impl Capability for ComingSoonFixture {
3171            fn id(&self) -> &str {
3172                "coming_soon_fixture"
3173            }
3174            fn name(&self) -> &str {
3175                "Coming Soon Fixture"
3176            }
3177            fn description(&self) -> &str {
3178                "Test-only capability."
3179            }
3180            fn status(&self) -> CapabilityStatus {
3181                CapabilityStatus::ComingSoon
3182            }
3183        }
3184        assert_eq!(ComingSoonFixture.status(), CapabilityStatus::ComingSoon);
3185    }
3186
3187    #[test]
3188    fn test_capability_icons_and_categories_default_none() {
3189        assert!(NoopFixture.icon().is_none());
3190        assert!(NoopFixture.category().is_none());
3191    }
3192
3193    #[test]
3194    fn test_system_prompt_preview_default_delegates_to_addition() {
3195        // A capability with a static system_prompt_addition — preview should
3196        // match the addition by default.
3197        struct StaticPromptCapability;
3198        impl Capability for StaticPromptCapability {
3199            fn id(&self) -> &str {
3200                "static_prompt"
3201            }
3202            fn name(&self) -> &str {
3203                "Static Prompt"
3204            }
3205            fn description(&self) -> &str {
3206                "Static prompt addition."
3207            }
3208            fn system_prompt_addition(&self) -> Option<&str> {
3209                Some("Use the static prompt.")
3210            }
3211        }
3212
3213        let cap = StaticPromptCapability;
3214        assert_eq!(
3215            cap.system_prompt_preview().as_deref(),
3216            cap.system_prompt_addition()
3217        );
3218
3219        // current_time has no system_prompt_addition — preview should be None
3220        let registry = fixture_registry();
3221        let current_time = registry.get("current_time").unwrap();
3222        assert!(current_time.system_prompt_preview().is_none());
3223        assert!(current_time.system_prompt_addition().is_none());
3224    }
3225
3226    #[test]
3227    fn test_system_prompt_preview_dynamic_capability() {
3228        let registry = fixture_registry();
3229        let cap = registry.get("agent_instructions").unwrap();
3230
3231        // No static addition, but preview exists
3232        assert!(cap.system_prompt_addition().is_none());
3233        assert!(cap.system_prompt_preview().is_some());
3234        assert!(cap.system_prompt_preview().unwrap().contains("AGENTS.md"));
3235    }
3236
3237    // =========================================================================
3238    // apply_capabilities tests
3239    // =========================================================================
3240
3241    #[tokio::test]
3242    async fn test_apply_capabilities_empty() {
3243        let registry = CapabilityRegistry::new();
3244        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3245
3246        let applied =
3247            apply_capabilities(base_runtime_agent.clone(), &[], &registry, &test_ctx()).await;
3248
3249        assert_eq!(
3250            applied.runtime_agent.system_prompt,
3251            base_runtime_agent.system_prompt
3252        );
3253        assert!(applied.tool_registry.is_empty());
3254        assert!(applied.applied_ids.is_empty());
3255    }
3256
3257    #[tokio::test]
3258    async fn test_apply_capabilities_noop() {
3259        let registry = fixture_registry();
3260        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3261
3262        let applied = apply_capabilities(
3263            base_runtime_agent.clone(),
3264            &["noop".to_string()],
3265            &registry,
3266            &test_ctx(),
3267        )
3268        .await;
3269
3270        // Noop has no system prompt addition or tools
3271        assert_eq!(
3272            applied.runtime_agent.system_prompt,
3273            base_runtime_agent.system_prompt
3274        );
3275        assert!(applied.tool_registry.is_empty());
3276        assert_eq!(applied.applied_ids, vec!["noop"]);
3277    }
3278
3279    #[tokio::test]
3280    async fn test_apply_capabilities_current_time() {
3281        let registry = fixture_registry();
3282        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3283
3284        let applied = apply_capabilities(
3285            base_runtime_agent.clone(),
3286            &["current_time".to_string()],
3287            &registry,
3288            &test_ctx(),
3289        )
3290        .await;
3291
3292        // CurrentTime contributes a dynamic `current_time` fact, so the cached
3293        // prompt gains the explanatory facts note (the live value is appended at
3294        // the conversation tail per request). It also keeps its tool.
3295        assert!(
3296            applied
3297                .runtime_agent
3298                .system_prompt
3299                .contains(FACTS_DYNAMIC_NOTE),
3300            "current_time should contribute the dynamic-facts note"
3301        );
3302        assert!(
3303            applied
3304                .runtime_agent
3305                .system_prompt
3306                .contains(&base_runtime_agent.system_prompt),
3307            "base prompt is preserved"
3308        );
3309        assert!(applied.tool_registry.has("get_current_time"));
3310        assert_eq!(applied.tool_registry.len(), 1);
3311        assert_eq!(applied.applied_ids, vec!["current_time"]);
3312    }
3313
3314    #[tokio::test]
3315    async fn test_apply_capabilities_skips_coming_soon() {
3316        struct ComingSoonFixture;
3317        impl Capability for ComingSoonFixture {
3318            fn id(&self) -> &str {
3319                "coming_soon_fixture"
3320            }
3321            fn name(&self) -> &str {
3322                "Coming Soon Fixture"
3323            }
3324            fn description(&self) -> &str {
3325                "Test-only capability."
3326            }
3327            fn status(&self) -> CapabilityStatus {
3328                CapabilityStatus::ComingSoon
3329            }
3330            fn system_prompt_addition(&self) -> Option<&str> {
3331                Some("Not yet available.")
3332            }
3333        }
3334        let mut registry = CapabilityRegistry::new();
3335        registry.register(ComingSoonFixture);
3336        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3337
3338        let applied = apply_capabilities(
3339            base_runtime_agent.clone(),
3340            &["coming_soon_fixture".to_string()],
3341            &registry,
3342            &test_ctx(),
3343        )
3344        .await;
3345
3346        assert_eq!(
3347            applied.runtime_agent.system_prompt,
3348            base_runtime_agent.system_prompt
3349        );
3350        assert!(applied.applied_ids.is_empty());
3351    }
3352
3353    #[tokio::test]
3354    async fn test_apply_capabilities_multiple() {
3355        let registry = fixture_registry();
3356        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3357
3358        let applied = apply_capabilities(
3359            base_runtime_agent.clone(),
3360            &["noop".to_string(), "current_time".to_string()],
3361            &registry,
3362            &test_ctx(),
3363        )
3364        .await;
3365
3366        assert!(applied.tool_registry.has("get_current_time"));
3367        assert_eq!(applied.applied_ids, vec!["noop", "current_time"]);
3368    }
3369
3370    #[tokio::test]
3371    async fn test_apply_capabilities_preserves_order() {
3372        let registry = fixture_registry();
3373        let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3374
3375        // Order should be preserved in applied_ids
3376        let applied = apply_capabilities(
3377            base_runtime_agent,
3378            &["current_time".to_string(), "noop".to_string()],
3379            &registry,
3380            &test_ctx(),
3381        )
3382        .await;
3383
3384        assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3385    }
3386
3387    #[tokio::test]
3388    async fn test_apply_capabilities_test_math() {
3389        let registry = fixture_registry();
3390        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3391
3392        let applied = apply_capabilities(
3393            base_runtime_agent.clone(),
3394            &["test_math".to_string()],
3395            &registry,
3396            &test_ctx(),
3397        )
3398        .await;
3399
3400        // TestMath has no system prompt addition (tool defs are sufficient)
3401        assert!(
3402            !applied
3403                .runtime_agent
3404                .system_prompt
3405                .contains("<capability id=\"test_math\">")
3406        );
3407        // No capability prompt prefix, so base prompt is used as-is (no XML wrapping)
3408        assert!(
3409            applied
3410                .runtime_agent
3411                .system_prompt
3412                .contains("You are a helpful assistant.")
3413        );
3414        assert!(applied.tool_registry.has("add"));
3415        assert!(applied.tool_registry.has("subtract"));
3416        assert!(applied.tool_registry.has("multiply"));
3417        assert!(applied.tool_registry.has("divide"));
3418        assert_eq!(applied.tool_registry.len(), 4);
3419    }
3420
3421    #[tokio::test]
3422    async fn test_apply_capabilities_test_weather() {
3423        let registry = fixture_registry();
3424        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3425
3426        let applied = apply_capabilities(
3427            base_runtime_agent.clone(),
3428            &["test_weather".to_string()],
3429            &registry,
3430            &test_ctx(),
3431        )
3432        .await;
3433
3434        // TestWeather has no system prompt addition (tool defs are sufficient)
3435        assert!(
3436            !applied
3437                .runtime_agent
3438                .system_prompt
3439                .contains("<capability id=\"test_weather\">")
3440        );
3441        assert!(applied.tool_registry.has("get_weather"));
3442        assert!(applied.tool_registry.has("get_forecast"));
3443        assert_eq!(applied.tool_registry.len(), 2);
3444    }
3445
3446    #[tokio::test]
3447    async fn test_apply_capabilities_test_math_and_test_weather() {
3448        let registry = fixture_registry();
3449        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3450
3451        let applied = apply_capabilities(
3452            base_runtime_agent.clone(),
3453            &["test_math".to_string(), "test_weather".to_string()],
3454            &registry,
3455            &test_ctx(),
3456        )
3457        .await;
3458
3459        // Should have both sets of tools
3460        assert_eq!(applied.tool_registry.len(), 6); // 4 math + 2 weather
3461        assert!(applied.tool_registry.has("add"));
3462        assert!(applied.tool_registry.has("get_weather"));
3463    }
3464
3465    #[tokio::test]
3466    async fn test_apply_capabilities_prompt_tool_fixture() {
3467        let registry = fixture_registry();
3468        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3469
3470        let applied = apply_capabilities(
3471            base_runtime_agent.clone(),
3472            &["prompt_tool_fixture".to_string()],
3473            &registry,
3474            &test_ctx(),
3475        )
3476        .await;
3477
3478        // The fixture has a system prompt addition and one tool.
3479        assert!(
3480            applied
3481                .runtime_agent
3482                .system_prompt
3483                .contains("Task Management")
3484        );
3485        assert!(applied.runtime_agent.system_prompt.contains("write_todos"));
3486        assert!(applied.tool_registry.has("write_todos"));
3487        assert_eq!(applied.tool_registry.len(), 1);
3488    }
3489
3490    // =========================================================================
3491    // XML prompt formatting tests
3492    // =========================================================================
3493
3494    #[tokio::test]
3495    async fn test_xml_tags_wrap_capability_prompts() {
3496        let registry = fixture_registry();
3497        let collected =
3498            collect_capabilities(&["prompt_tool_fixture".to_string()], &registry, &test_ctx())
3499                .await;
3500
3501        assert_eq!(collected.system_prompt_parts.len(), 1);
3502        let part = &collected.system_prompt_parts[0];
3503        assert!(part.starts_with("<capability id=\"prompt_tool_fixture\">"));
3504        assert!(part.ends_with("</capability>"));
3505        assert!(part.contains("Task Management"));
3506    }
3507
3508    #[tokio::test]
3509    async fn test_xml_tags_multiple_capabilities() {
3510        let registry = fixture_registry();
3511        let collected = collect_capabilities(
3512            &[
3513                "prompt_tool_fixture".to_string(),
3514                "second_prompt_fixture".to_string(),
3515            ],
3516            &registry,
3517            &test_ctx(),
3518        )
3519        .await;
3520
3521        assert_eq!(collected.system_prompt_parts.len(), 2);
3522        assert!(
3523            collected.system_prompt_parts[0].starts_with("<capability id=\"prompt_tool_fixture\">")
3524        );
3525        assert!(
3526            collected.system_prompt_parts[1]
3527                .starts_with("<capability id=\"second_prompt_fixture\">")
3528        );
3529
3530        let prefix = collected.system_prompt_prefix().unwrap();
3531        // Both capability sections separated by double newline
3532        assert!(prefix.contains("</capability>\n\n<capability"));
3533    }
3534
3535    #[tokio::test]
3536    async fn test_xml_tags_system_prompt_wrapping() {
3537        let registry = fixture_registry();
3538        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3539
3540        let applied = apply_capabilities(
3541            base,
3542            &["prompt_tool_fixture".to_string()],
3543            &registry,
3544            &test_ctx(),
3545        )
3546        .await;
3547
3548        let prompt = &applied.runtime_agent.system_prompt;
3549        assert!(prompt.starts_with("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3550        // Capability wrapped
3551        assert!(prompt.contains("<capability id=\"prompt_tool_fixture\">"));
3552        assert!(prompt.contains("</capability>"));
3553        // Base prompt wrapped
3554        assert!(prompt.contains("<system-prompt>\nYou are helpful.\n</system-prompt>"));
3555    }
3556
3557    #[tokio::test]
3558    async fn test_no_xml_wrapping_without_capabilities() {
3559        let registry = CapabilityRegistry::new();
3560        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3561
3562        let applied = apply_capabilities(base, &[], &registry, &test_ctx()).await;
3563
3564        // No capabilities = no XML wrapping (plain base prompt)
3565        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3566        assert!(
3567            !applied
3568                .runtime_agent
3569                .system_prompt
3570                .contains("<system-prompt>")
3571        );
3572    }
3573
3574    #[tokio::test]
3575    async fn test_no_xml_wrapping_for_noop_capability() {
3576        let registry = fixture_registry();
3577        let base = RuntimeAgent::new("You are helpful.", "gpt-5.2");
3578
3579        // Noop has no system_prompt_addition, so no XML wrapping should occur
3580        let applied = apply_capabilities(base, &["noop".to_string()], &registry, &test_ctx()).await;
3581
3582        assert_eq!(applied.runtime_agent.system_prompt, "You are helpful.");
3583        assert!(
3584            !applied
3585                .runtime_agent
3586                .system_prompt
3587                .contains("<system-prompt>")
3588        );
3589    }
3590
3591    // =========================================================================
3592    // Mount collection tests
3593    // =========================================================================
3594
3595    #[tokio::test]
3596    async fn test_collect_capabilities_includes_mounts() {
3597        let registry = fixture_registry();
3598
3599        let collected =
3600            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
3601
3602        assert!(!collected.mounts.is_empty());
3603        assert_eq!(collected.mounts.len(), 1);
3604        assert_eq!(collected.mounts[0].path, "/samples");
3605        assert!(collected.mounts[0].is_readonly());
3606    }
3607
3608    #[tokio::test]
3609    async fn test_collect_capabilities_empty_mounts_by_default() {
3610        let registry = fixture_registry();
3611
3612        // Most capabilities don't have mounts
3613        let collected =
3614            collect_capabilities(&["current_time".to_string()], &registry, &test_ctx()).await;
3615
3616        assert!(collected.mounts.is_empty());
3617    }
3618
3619    #[tokio::test]
3620    async fn test_dynamic_facts_add_note_without_static_block() {
3621        // `current_time` contributes a Dynamic fact, so the cached prompt gets
3622        // the explanatory note but NOT a static `<facts>` block (the live value
3623        // is appended at the conversation tail per request instead).
3624        let registry = fixture_registry();
3625        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3626        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3627        let prompt = collected.system_prompt_parts.join("\n");
3628        assert!(
3629            prompt.contains(FACTS_DYNAMIC_NOTE),
3630            "dynamic-facts note should be in the cached prompt"
3631        );
3632        assert!(
3633            !prompt.contains("<facts>\n"),
3634            "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3635        );
3636    }
3637
3638    #[tokio::test]
3639    async fn test_static_facts_fold_into_prompt() {
3640        struct StaticFactCap;
3641        impl Capability for StaticFactCap {
3642            fn id(&self) -> &str {
3643                "test_static_fact"
3644            }
3645            fn name(&self) -> &str {
3646                "Static Fact"
3647            }
3648            fn description(&self) -> &str {
3649                "test"
3650            }
3651            fn status(&self) -> CapabilityStatus {
3652                CapabilityStatus::Available
3653            }
3654            fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3655                vec![Fact::stat("workspace_root", "/workspace")]
3656            }
3657        }
3658        let mut registry = CapabilityRegistry::new();
3659        registry.register(StaticFactCap);
3660        let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3661        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3662        let prompt = collected.system_prompt_parts.join("\n");
3663        assert!(
3664            prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3665            "static fact should fold into the cached prompt; got: {prompt}"
3666        );
3667        assert!(
3668            !prompt.contains(FACTS_DYNAMIC_NOTE),
3669            "no dynamic note when only static facts exist"
3670        );
3671    }
3672
3673    #[test]
3674    fn test_collect_dynamic_facts_returns_current_time() {
3675        let registry = fixture_registry();
3676        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3677        let facts = collect_dynamic_facts(
3678            &configs,
3679            &registry,
3680            None,
3681            &FactsContext::new(SessionId::new()),
3682        );
3683        assert_eq!(facts.len(), 1);
3684        assert_eq!(facts[0].key, "current_time");
3685        assert_eq!(facts[0].volatility, Volatility::Dynamic);
3686    }
3687
3688    #[tokio::test]
3689    async fn test_collect_capabilities_combines_mounts() {
3690        let registry = fixture_registry();
3691
3692        // Collect from multiple capabilities - only sample_data has mounts.
3693        // sample_data depends on session_file_system, which is auto-resolved.
3694        let collected = collect_capabilities(
3695            &["sample_data".to_string(), "current_time".to_string()],
3696            &registry,
3697            &test_ctx(),
3698        )
3699        .await;
3700
3701        assert_eq!(collected.mounts.len(), 1);
3702        // Verify expected capabilities were applied (including auto-resolved dependency)
3703        assert!(
3704            collected
3705                .applied_ids
3706                .iter()
3707                .any(|id| id == "session_file_system")
3708        );
3709        assert!(collected.applied_ids.iter().any(|id| id == "sample_data"));
3710        assert!(collected.applied_ids.iter().any(|id| id == "current_time"));
3711    }
3712
3713    #[test]
3714    fn test_sample_data_capability() {
3715        let registry = fixture_registry();
3716        let cap = registry.get("sample_data").unwrap();
3717
3718        assert_eq!(cap.id(), "sample_data");
3719        assert_eq!(cap.name(), "Sample Data");
3720        assert_eq!(cap.status(), CapabilityStatus::Available);
3721
3722        // Has system prompt but no tools
3723        assert!(cap.system_prompt_addition().is_some());
3724        assert!(cap.tools().is_empty());
3725
3726        // Has mounts
3727        assert!(!cap.mounts().is_empty());
3728    }
3729
3730    // =========================================================================
3731    // Dependency resolution tests
3732    // =========================================================================
3733
3734    #[test]
3735    fn test_resolve_dependencies_empty() {
3736        let registry = CapabilityRegistry::new();
3737
3738        let resolved = resolve_dependencies(&[], &registry).unwrap();
3739
3740        assert!(resolved.resolved_ids.is_empty());
3741        assert!(resolved.added_as_dependencies.is_empty());
3742        assert!(resolved.user_selected.is_empty());
3743    }
3744
3745    #[test]
3746    fn test_resolve_dependencies_no_deps() {
3747        let registry = fixture_registry();
3748
3749        // CurrentTime has no dependencies
3750        let resolved = resolve_dependencies(&["current_time".to_string()], &registry).unwrap();
3751
3752        assert_eq!(resolved.resolved_ids, vec!["current_time"]);
3753        assert!(resolved.added_as_dependencies.is_empty());
3754    }
3755
3756    #[test]
3757    fn test_resolve_dependencies_with_deps() {
3758        let registry = fixture_registry();
3759
3760        // SampleData depends on FileSystem
3761        let resolved = resolve_dependencies(&["sample_data".to_string()], &registry).unwrap();
3762
3763        // FileSystem should be resolved before SampleData
3764        assert_eq!(resolved.resolved_ids.len(), 2);
3765        let fs_pos = resolved
3766            .resolved_ids
3767            .iter()
3768            .position(|id| id == "session_file_system")
3769            .unwrap();
3770        let sd_pos = resolved
3771            .resolved_ids
3772            .iter()
3773            .position(|id| id == "sample_data")
3774            .unwrap();
3775        assert!(fs_pos < sd_pos, "FileSystem should come before SampleData");
3776
3777        // FileSystem was added as a dependency
3778        assert_eq!(resolved.added_as_dependencies, vec!["session_file_system"]);
3779    }
3780
3781    #[test]
3782    fn test_resolve_dependencies_already_selected() {
3783        let registry = fixture_registry();
3784
3785        // If dependency is already selected, it shouldn't be duplicated
3786        let resolved = resolve_dependencies(
3787            &["session_file_system".to_string(), "sample_data".to_string()],
3788            &registry,
3789        )
3790        .unwrap();
3791
3792        assert_eq!(resolved.resolved_ids.len(), 2);
3793        // FileSystem was user-selected, not added as dependency
3794        assert!(resolved.added_as_dependencies.is_empty());
3795    }
3796
3797    #[test]
3798    fn test_resolve_dependencies_preserves_order() {
3799        let registry = fixture_registry();
3800
3801        // Multiple independent capabilities should maintain their relative order
3802        let resolved =
3803            resolve_dependencies(&["current_time".to_string(), "noop".to_string()], &registry)
3804                .unwrap();
3805
3806        assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
3807    }
3808
3809    #[test]
3810    fn test_resolve_dependencies_unknown_capability() {
3811        let registry = CapabilityRegistry::new();
3812
3813        // Unknown capabilities are silently skipped
3814        let resolved =
3815            resolve_dependencies(&["unknown_capability".to_string()], &registry).unwrap();
3816
3817        assert!(resolved.resolved_ids.is_empty());
3818    }
3819
3820    #[test]
3821    fn test_get_dependencies() {
3822        let registry = fixture_registry();
3823
3824        // SampleData depends on FileSystem
3825        let deps = get_dependencies("sample_data", &registry);
3826        assert_eq!(deps, vec!["session_file_system"]);
3827
3828        // CurrentTime has no dependencies
3829        let deps = get_dependencies("current_time", &registry);
3830        assert!(deps.is_empty());
3831
3832        // Unknown capability
3833        let deps = get_dependencies("unknown", &registry);
3834        assert!(deps.is_empty());
3835    }
3836
3837    #[test]
3838    fn test_sample_data_has_dependency() {
3839        let registry = fixture_registry();
3840        let cap = registry.get("sample_data").unwrap();
3841
3842        let deps = cap.dependencies();
3843        assert_eq!(deps.len(), 1);
3844        assert_eq!(deps[0], "session_file_system");
3845    }
3846
3847    #[test]
3848    fn test_noop_has_no_dependencies() {
3849        let registry = fixture_registry();
3850        let cap = registry.get("noop").unwrap();
3851
3852        assert!(cap.dependencies().is_empty());
3853    }
3854
3855    // Test for circular dependency detection
3856    // Note: We can't easily test this with built-in capabilities since they don't have cycles.
3857    // This test uses a custom registry to create a cycle.
3858    #[test]
3859    fn test_circular_dependency_error() {
3860        // Create capabilities that form a cycle: A -> B -> A
3861        struct CapA;
3862        struct CapB;
3863
3864        impl Capability for CapA {
3865            fn id(&self) -> &str {
3866                "test_cap_a"
3867            }
3868            fn name(&self) -> &str {
3869                "Test A"
3870            }
3871            fn description(&self) -> &str {
3872                "Test capability A"
3873            }
3874            fn dependencies(&self) -> Vec<&'static str> {
3875                vec!["test_cap_b"]
3876            }
3877        }
3878
3879        impl Capability for CapB {
3880            fn id(&self) -> &str {
3881                "test_cap_b"
3882            }
3883            fn name(&self) -> &str {
3884                "Test B"
3885            }
3886            fn description(&self) -> &str {
3887                "Test capability B"
3888            }
3889            fn dependencies(&self) -> Vec<&'static str> {
3890                vec!["test_cap_a"]
3891            }
3892        }
3893
3894        let mut registry = CapabilityRegistry::new();
3895        registry.register(CapA);
3896        registry.register(CapB);
3897
3898        let result = resolve_dependencies(&["test_cap_a".to_string()], &registry);
3899
3900        assert!(result.is_err());
3901        match result.unwrap_err() {
3902            DependencyError::CircularDependency { capability_id, .. } => {
3903                assert_eq!(capability_id, "test_cap_a");
3904            }
3905            _ => panic!("Expected CircularDependency error"),
3906        }
3907    }
3908
3909    // =========================================================================
3910    // Message filter provider tests
3911    // =========================================================================
3912
3913    use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
3914
3915    /// Test capability that provides a message filter
3916    struct FilterTestCapability {
3917        priority: i32,
3918    }
3919
3920    impl Capability for FilterTestCapability {
3921        fn id(&self) -> &str {
3922            "filter_test"
3923        }
3924        fn name(&self) -> &str {
3925            "Filter Test"
3926        }
3927        fn description(&self) -> &str {
3928            "Test capability with message filter"
3929        }
3930        fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
3931            Some(Arc::new(FilterTestProvider {
3932                priority: self.priority,
3933            }))
3934        }
3935    }
3936
3937    struct FilterTestProvider {
3938        priority: i32,
3939    }
3940
3941    impl MessageFilterProvider for FilterTestProvider {
3942        fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
3943            // Add a search filter based on config
3944            if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
3945                query
3946                    .filters
3947                    .push(MessageFilter::Search(search.to_string()));
3948            }
3949        }
3950
3951        fn priority(&self) -> i32 {
3952            self.priority
3953        }
3954    }
3955
3956    #[tokio::test]
3957    async fn test_collect_capabilities_with_configs_no_filter_providers() {
3958        let registry = fixture_registry();
3959        let configs = vec![AgentCapabilityConfig::with_config(
3960            CapabilityId::new("current_time"),
3961            serde_json::json!({}),
3962        )];
3963
3964        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3965
3966        assert!(collected.message_filter_providers.is_empty());
3967        assert!(!collected.has_message_filters());
3968    }
3969
3970    #[tokio::test]
3971    async fn test_collect_capabilities_with_configs_with_filter_provider() {
3972        let mut registry = CapabilityRegistry::new();
3973        registry.register(FilterTestCapability { priority: 0 });
3974
3975        let configs = vec![AgentCapabilityConfig::with_config(
3976            CapabilityId::new("filter_test"),
3977            serde_json::json!({ "search": "hello" }),
3978        )];
3979
3980        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3981
3982        assert_eq!(collected.message_filter_providers.len(), 1);
3983        assert!(collected.has_message_filters());
3984    }
3985
3986    #[tokio::test]
3987    async fn test_collect_capabilities_with_configs_filter_priority_order() {
3988        // Create capabilities with different priorities
3989        struct HighPriorityCapability;
3990        struct LowPriorityCapability;
3991
3992        impl Capability for HighPriorityCapability {
3993            fn id(&self) -> &str {
3994                "high_priority"
3995            }
3996            fn name(&self) -> &str {
3997                "High Priority"
3998            }
3999            fn description(&self) -> &str {
4000                "Test"
4001            }
4002            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4003                Some(Arc::new(FilterTestProvider { priority: 10 }))
4004            }
4005        }
4006
4007        impl Capability for LowPriorityCapability {
4008            fn id(&self) -> &str {
4009                "low_priority"
4010            }
4011            fn name(&self) -> &str {
4012                "Low Priority"
4013            }
4014            fn description(&self) -> &str {
4015                "Test"
4016            }
4017            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4018                Some(Arc::new(FilterTestProvider { priority: -5 }))
4019            }
4020        }
4021
4022        let mut registry = CapabilityRegistry::new();
4023        registry.register(HighPriorityCapability);
4024        registry.register(LowPriorityCapability);
4025
4026        // Add in order: high priority first, low priority second
4027        let configs = vec![
4028            AgentCapabilityConfig::with_config(
4029                CapabilityId::new("high_priority"),
4030                serde_json::json!({}),
4031            ),
4032            AgentCapabilityConfig::with_config(
4033                CapabilityId::new("low_priority"),
4034                serde_json::json!({}),
4035            ),
4036        ];
4037
4038        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4039
4040        // Should be sorted by priority (lower first)
4041        assert_eq!(collected.message_filter_providers.len(), 2);
4042        assert_eq!(collected.message_filter_providers[0].0.priority(), -5);
4043        assert_eq!(collected.message_filter_providers[1].0.priority(), 10);
4044    }
4045
4046    #[tokio::test]
4047    async fn test_collected_capabilities_apply_message_filters() {
4048        let mut registry = CapabilityRegistry::new();
4049        registry.register(FilterTestCapability { priority: 0 });
4050
4051        let configs = vec![AgentCapabilityConfig::with_config(
4052            CapabilityId::new("filter_test"),
4053            serde_json::json!({ "search": "test_query" }),
4054        )];
4055
4056        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4057
4058        // Apply filters to a query
4059        let session_id: SessionId = Uuid::now_v7().into();
4060        let mut query = MessageQuery::new(session_id);
4061
4062        collected.apply_message_filters(&mut query);
4063
4064        // Should have added the search filter
4065        assert_eq!(query.filters.len(), 1);
4066        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4067    }
4068
4069    #[tokio::test]
4070    async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4071        struct SearchCapability {
4072            id: &'static str,
4073            search_term: &'static str,
4074            priority: i32,
4075        }
4076
4077        struct SearchProvider {
4078            search_term: &'static str,
4079            priority: i32,
4080        }
4081
4082        impl MessageFilterProvider for SearchProvider {
4083            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4084                query
4085                    .filters
4086                    .push(MessageFilter::Search(self.search_term.to_string()));
4087            }
4088
4089            fn priority(&self) -> i32 {
4090                self.priority
4091            }
4092        }
4093
4094        impl Capability for SearchCapability {
4095            fn id(&self) -> &str {
4096                self.id
4097            }
4098            fn name(&self) -> &str {
4099                "Search"
4100            }
4101            fn description(&self) -> &str {
4102                "Test"
4103            }
4104            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4105                Some(Arc::new(SearchProvider {
4106                    search_term: self.search_term,
4107                    priority: self.priority,
4108                }))
4109            }
4110        }
4111
4112        let mut registry = CapabilityRegistry::new();
4113        registry.register(SearchCapability {
4114            id: "cap_a",
4115            search_term: "alpha",
4116            priority: 5,
4117        });
4118        registry.register(SearchCapability {
4119            id: "cap_b",
4120            search_term: "beta",
4121            priority: 1,
4122        });
4123        registry.register(SearchCapability {
4124            id: "cap_c",
4125            search_term: "gamma",
4126            priority: 10,
4127        });
4128
4129        let configs = vec![
4130            AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4131            AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4132            AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4133        ];
4134
4135        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4136
4137        let session_id: SessionId = Uuid::now_v7().into();
4138        let mut query = MessageQuery::new(session_id);
4139
4140        collected.apply_message_filters(&mut query);
4141
4142        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4143        assert_eq!(query.filters.len(), 3);
4144        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4145        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4146        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4147    }
4148
4149    #[test]
4150    fn test_capability_without_message_filter_returns_none() {
4151        let registry = fixture_registry();
4152
4153        let noop = registry.get("noop").unwrap();
4154        assert!(noop.message_filter_provider().is_none());
4155
4156        let current_time = registry.get("current_time").unwrap();
4157        assert!(current_time.message_filter_provider().is_none());
4158    }
4159
4160    #[tokio::test]
4161    async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4162        let mut registry = CapabilityRegistry::new();
4163        registry.register(FilterTestCapability { priority: 0 });
4164
4165        let test_config = serde_json::json!({
4166            "search": "custom_search",
4167            "extra_field": 42
4168        });
4169
4170        let configs = vec![AgentCapabilityConfig::with_config(
4171            CapabilityId::new("filter_test"),
4172            test_config.clone(),
4173        )];
4174
4175        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4176
4177        // Verify the config is preserved
4178        assert_eq!(collected.message_filter_providers.len(), 1);
4179        let (_, stored_config) = &collected.message_filter_providers[0];
4180        assert_eq!(*stored_config, test_config);
4181    }
4182
4183    // =========================================================================
4184    // collect_message_filters_only tests
4185    // =========================================================================
4186
4187    #[test]
4188    fn test_collect_message_filters_only_collects_filters() {
4189        let mut registry = CapabilityRegistry::new();
4190        registry.register(FilterTestCapability { priority: 0 });
4191
4192        let configs = vec![AgentCapabilityConfig::with_config(
4193            CapabilityId::new("filter_test"),
4194            serde_json::json!({ "search": "test_query" }),
4195        )];
4196
4197        let collected = collect_message_filters_only(&configs, &registry);
4198
4199        let session_id: SessionId = Uuid::now_v7().into();
4200        let mut query = MessageQuery::new(session_id);
4201        collected.apply_message_filters(&mut query);
4202
4203        assert_eq!(query.filters.len(), 1);
4204        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4205    }
4206
4207    #[test]
4208    fn test_collect_message_filters_only_skips_unknown_capabilities() {
4209        let registry = CapabilityRegistry::new();
4210
4211        let configs = vec![AgentCapabilityConfig::with_config(
4212            CapabilityId::new("nonexistent"),
4213            serde_json::json!({}),
4214        )];
4215
4216        let collected = collect_message_filters_only(&configs, &registry);
4217        assert!(collected.message_filter_providers.is_empty());
4218    }
4219
4220    #[test]
4221    fn test_collect_message_filters_only_preserves_priority_order() {
4222        struct PriorityFilterCap {
4223            id: &'static str,
4224            search_term: &'static str,
4225            priority: i32,
4226        }
4227
4228        struct PriorityFilterProvider {
4229            search_term: &'static str,
4230            priority: i32,
4231        }
4232
4233        impl Capability for PriorityFilterCap {
4234            fn id(&self) -> &str {
4235                self.id
4236            }
4237            fn name(&self) -> &str {
4238                self.id
4239            }
4240            fn description(&self) -> &str {
4241                "priority test"
4242            }
4243            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4244                Some(Arc::new(PriorityFilterProvider {
4245                    search_term: self.search_term,
4246                    priority: self.priority,
4247                }))
4248            }
4249        }
4250
4251        impl MessageFilterProvider for PriorityFilterProvider {
4252            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4253                query
4254                    .filters
4255                    .push(MessageFilter::Search(self.search_term.to_string()));
4256            }
4257            fn priority(&self) -> i32 {
4258                self.priority
4259            }
4260        }
4261
4262        let mut registry = CapabilityRegistry::new();
4263        registry.register(PriorityFilterCap {
4264            id: "gamma",
4265            search_term: "gamma",
4266            priority: 10,
4267        });
4268        registry.register(PriorityFilterCap {
4269            id: "alpha",
4270            search_term: "alpha",
4271            priority: 5,
4272        });
4273        registry.register(PriorityFilterCap {
4274            id: "beta",
4275            search_term: "beta",
4276            priority: 1,
4277        });
4278
4279        let configs = vec![
4280            AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4281            AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4282            AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4283        ];
4284
4285        let collected = collect_message_filters_only(&configs, &registry);
4286
4287        let session_id: SessionId = Uuid::now_v7().into();
4288        let mut query = MessageQuery::new(session_id);
4289        collected.apply_message_filters(&mut query);
4290
4291        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4292        assert_eq!(query.filters.len(), 3);
4293        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4294        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4295        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4296    }
4297
4298    #[test]
4299    fn test_collect_message_filters_only_post_load_invoked() {
4300        use crate::message::Message;
4301
4302        struct PostLoadCap;
4303        struct PostLoadProvider;
4304
4305        impl Capability for PostLoadCap {
4306            fn id(&self) -> &str {
4307                "post_load_test"
4308            }
4309            fn name(&self) -> &str {
4310                "PostLoad Test"
4311            }
4312            fn description(&self) -> &str {
4313                "test"
4314            }
4315            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4316                Some(Arc::new(PostLoadProvider))
4317            }
4318        }
4319
4320        impl MessageFilterProvider for PostLoadProvider {
4321            fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4322            fn priority(&self) -> i32 {
4323                0
4324            }
4325            fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4326                // Reverse messages to prove post_load was called
4327                messages.reverse();
4328            }
4329        }
4330
4331        let mut registry = CapabilityRegistry::new();
4332        registry.register(PostLoadCap);
4333
4334        let configs = vec![AgentCapabilityConfig::with_config(
4335            CapabilityId::new("post_load_test"),
4336            serde_json::json!({}),
4337        )];
4338
4339        let collected = collect_message_filters_only(&configs, &registry);
4340
4341        let mut messages = vec![Message::user("first"), Message::user("second")];
4342        collected.apply_post_load_filters(&mut messages);
4343
4344        // post_load reversed the messages
4345        assert_eq!(messages[0].text(), Some("second"));
4346        assert_eq!(messages[1].text(), Some("first"));
4347    }
4348
4349    // Tests for resolve_for_model delegation in fast-path collectors
4350
4351    struct DelegatingFilterCap {
4352        id: &'static str,
4353        inner: std::sync::Arc<InnerFilterCap>,
4354    }
4355    struct InnerFilterCap;
4356
4357    impl Capability for InnerFilterCap {
4358        fn id(&self) -> &str {
4359            "inner_filter"
4360        }
4361        fn name(&self) -> &str {
4362            "Inner Filter"
4363        }
4364        fn description(&self) -> &str {
4365            "inner"
4366        }
4367        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4368            Some(std::sync::Arc::new(SentinelFilter))
4369        }
4370    }
4371    struct SentinelFilter;
4372    impl MessageFilterProvider for SentinelFilter {
4373        fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4374    }
4375    impl Capability for DelegatingFilterCap {
4376        fn id(&self) -> &str {
4377            self.id
4378        }
4379        fn name(&self) -> &str {
4380            "Delegating Filter"
4381        }
4382        fn description(&self) -> &str {
4383            "delegating"
4384        }
4385        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4386            None // outer provides nothing
4387        }
4388        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4389            Some(&*self.inner)
4390        }
4391    }
4392
4393    #[test]
4394    fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4395        let inner = std::sync::Arc::new(InnerFilterCap);
4396        let outer = DelegatingFilterCap {
4397            id: "delegating_filter",
4398            inner: inner.clone(),
4399        };
4400
4401        let mut registry = CapabilityRegistry::new();
4402        registry.register(outer);
4403
4404        let configs = vec![AgentCapabilityConfig::with_config(
4405            CapabilityId::new("delegating_filter"),
4406            serde_json::json!({}),
4407        )];
4408
4409        // Outer has no message_filter_provider; inner does. resolve_for_model
4410        // delegates to inner so the provider should be collected.
4411        let collected = collect_message_filters_only(&configs, &registry);
4412        assert_eq!(
4413            collected.message_filter_providers.len(),
4414            1,
4415            "provider from resolved inner capability must be collected"
4416        );
4417    }
4418
4419    struct DelegatingMvpCap {
4420        id: &'static str,
4421        inner: std::sync::Arc<InnerMvpCap>,
4422    }
4423    struct InnerMvpCap;
4424
4425    impl Capability for InnerMvpCap {
4426        fn id(&self) -> &str {
4427            "inner_mvp"
4428        }
4429        fn name(&self) -> &str {
4430            "Inner MVP"
4431        }
4432        fn description(&self) -> &str {
4433            "inner"
4434        }
4435        fn model_view_provider(
4436            &self,
4437        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4438            // Return a no-op provider to prove delegation reached here.
4439            struct NoopMvp;
4440            impl crate::capabilities::ModelViewProvider for NoopMvp {
4441                fn apply_model_view(
4442                    &self,
4443                    messages: Vec<Message>,
4444                    _config: &serde_json::Value,
4445                    _context: &ModelViewContext<'_>,
4446                ) -> Vec<Message> {
4447                    messages
4448                }
4449            }
4450            Some(std::sync::Arc::new(NoopMvp))
4451        }
4452    }
4453    impl Capability for DelegatingMvpCap {
4454        fn id(&self) -> &str {
4455            self.id
4456        }
4457        fn name(&self) -> &str {
4458            "Delegating MVP"
4459        }
4460        fn description(&self) -> &str {
4461            "delegating"
4462        }
4463        fn model_view_provider(
4464            &self,
4465        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4466            None // outer provides nothing
4467        }
4468        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4469            Some(&*self.inner)
4470        }
4471    }
4472
4473    #[test]
4474    fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4475        let inner = std::sync::Arc::new(InnerMvpCap);
4476        let outer = DelegatingMvpCap {
4477            id: "delegating_mvp",
4478            inner: inner.clone(),
4479        };
4480
4481        let mut registry = CapabilityRegistry::new();
4482        registry.register(outer);
4483
4484        let configs = vec![AgentCapabilityConfig::with_config(
4485            CapabilityId::new("delegating_mvp"),
4486            serde_json::json!({}),
4487        )];
4488
4489        // Outer has no model_view_provider; inner does. resolve_for_model
4490        // delegates to inner so the provider should be collected.
4491        let collected = collect_model_view_providers(&configs, &registry, None);
4492        assert_eq!(
4493            collected.model_view_providers.len(),
4494            1,
4495            "provider from resolved inner capability must be collected"
4496        );
4497    }
4498
4499    // =========================================================================
4500    // Harness capability tool registration tests
4501    //
4502    // Regression tests for the "Tool not found: bash" bug where harness
4503    // capabilities were not used for tool registration when agent_id was absent.
4504    // These tests verify that capability-provided tools (especially bash) are
4505    // correctly produced by collect_capabilities.
4506    // =========================================================================
4507
4508    #[tokio::test]
4509    async fn test_bashkit_shell_capability_produces_bash_tool() {
4510        let registry = fixture_registry();
4511        let collected =
4512            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
4513
4514        let tool_names: Vec<&str> = collected
4515            .tool_definitions
4516            .iter()
4517            .map(|t| t.name())
4518            .collect();
4519        assert!(
4520            tool_names.contains(&"bash"),
4521            "bashkit_shell capability must produce 'bash' tool, got: {:?}",
4522            tool_names
4523        );
4524        assert!(
4525            !collected.tools.is_empty(),
4526            "bashkit_shell must provide tool implementations"
4527        );
4528    }
4529
4530    #[tokio::test]
4531    async fn test_generic_harness_capability_set_produces_bash_tool() {
4532        // These are the exact capability IDs from the Generic Harness seed data.
4533        // If any are renamed or removed, this test catches the regression.
4534        let generic_harness_caps = vec![
4535            "session_file_system".to_string(),
4536            "bashkit_shell".to_string(),
4537            "web_fetch".to_string(),
4538            "session_storage".to_string(),
4539            "session".to_string(),
4540            "agent_instructions".to_string(),
4541            "skills".to_string(),
4542            "infinity_context".to_string(),
4543            "auto_tool_search".to_string(),
4544        ];
4545
4546        let registry = fixture_registry();
4547        let collected = collect_capabilities(&generic_harness_caps, &registry, &test_ctx()).await;
4548
4549        let tool_names: Vec<&str> = collected
4550            .tool_definitions
4551            .iter()
4552            .map(|t| t.name())
4553            .collect();
4554        assert!(
4555            tool_names.contains(&"bash"),
4556            "Generic Harness capabilities must produce 'bash' tool, got: {:?}",
4557            tool_names
4558        );
4559    }
4560
4561    #[tokio::test]
4562    async fn test_collect_capabilities_tool_count_matches_definitions() {
4563        // Ensure collected tools (implementations) match tool_definitions count.
4564        // A mismatch means some tools won't be executable at runtime.
4565        let registry = fixture_registry();
4566        let collected =
4567            collect_capabilities(&["bashkit_shell".to_string()], &registry, &test_ctx()).await;
4568
4569        assert_eq!(
4570            collected.tools.len(),
4571            collected.tool_definitions.len(),
4572            "tool implementations ({}) must match tool definitions ({})",
4573            collected.tools.len(),
4574            collected.tool_definitions.len(),
4575        );
4576    }
4577
4578    /// Regression test for EVE-189: collect_capabilities must resolve dependencies
4579    /// so that transitive capabilities register their tools even when not explicitly
4580    /// listed. Uses sample_data (depends on session_file_system) as the test case.
4581    #[tokio::test]
4582    async fn test_collect_capabilities_resolves_dependencies() {
4583        // sample_data depends on session_file_system
4584        // Passing only sample_data should still include session_file_system tools
4585        let registry = fixture_registry();
4586        let collected =
4587            collect_capabilities(&["sample_data".to_string()], &registry, &test_ctx()).await;
4588
4589        // Verify the transitive dependency capability itself was applied
4590        assert!(
4591            collected
4592                .applied_ids
4593                .iter()
4594                .any(|id| id == "session_file_system"),
4595            "collect_capabilities must apply session_file_system as a dependency; applied_ids: {:?}",
4596            collected.applied_ids
4597        );
4598
4599        let tool_names: Vec<&str> = collected
4600            .tool_definitions
4601            .iter()
4602            .map(|t| t.name())
4603            .collect();
4604
4605        // session_file_system provides these tools; both should be present
4606        assert!(
4607            tool_names.contains(&"read_file") && tool_names.contains(&"write_file"),
4608            "collect_capabilities must resolve dependencies and include dependency tools, got: {:?}",
4609            tool_names
4610        );
4611
4612        // Also verify tool implementations match definitions (dependency tools are executable)
4613        assert_eq!(
4614            collected.tools.len(),
4615            collected.tool_definitions.len(),
4616            "dependency-added tools must have implementations, not just definitions"
4617        );
4618    }
4619
4620    #[test]
4621    fn test_defaults_do_not_include_bash() {
4622        // ToolRegistry::with_defaults() must NOT include bash — it comes from
4623        // capabilities only. This documents the invariant that the bug violated.
4624        let registry = crate::ToolRegistry::with_defaults();
4625        assert!(
4626            !registry.has("bash"),
4627            "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4628        );
4629    }
4630
4631    // =========================================================================
4632    // Feature tests
4633    // =========================================================================
4634
4635    #[test]
4636    fn test_capability_features_default_empty() {
4637        let registry = fixture_registry();
4638
4639        // Most capabilities have no features
4640        let noop = registry.get("noop").unwrap();
4641        assert!(noop.features().is_empty());
4642
4643        let current_time = registry.get("current_time").unwrap();
4644        assert!(current_time.features().is_empty());
4645    }
4646
4647    #[test]
4648    fn test_file_system_capability_features() {
4649        let registry = fixture_registry();
4650
4651        let fs = registry.get("session_file_system").unwrap();
4652        assert_eq!(fs.features(), vec!["file_system"]);
4653    }
4654
4655    #[test]
4656    fn test_bashkit_shell_capability_features() {
4657        let registry = fixture_registry();
4658
4659        let bash = registry.get("bashkit_shell").unwrap();
4660        assert_eq!(bash.features(), vec!["file_system"]);
4661    }
4662
4663    #[test]
4664    fn test_alias_resolves_to_canonical_capability() {
4665        let registry = fixture_registry();
4666
4667        // Legacy `virtual_bash` ID (persisted agent configs) must keep working.
4668        let via_alias = registry.get("virtual_bash").unwrap();
4669        assert_eq!(via_alias.id(), "bashkit_shell");
4670        assert!(registry.has("virtual_bash"));
4671        assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4672        assert_eq!(
4673            registry.canonical_id("bashkit_shell"),
4674            Some("bashkit_shell")
4675        );
4676        assert_eq!(registry.canonical_id("nonexistent"), None);
4677    }
4678
4679    #[test]
4680    fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4681        let registry = fixture_registry();
4682
4683        // Selecting both the alias and the canonical ID must resolve to a
4684        // single activation under the canonical ID.
4685        let resolved = resolve_dependencies(
4686            &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4687            &registry,
4688        )
4689        .unwrap();
4690        let bash_ids: Vec<_> = resolved
4691            .resolved_ids
4692            .iter()
4693            .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4694            .collect();
4695        assert_eq!(bash_ids, vec!["bashkit_shell"]);
4696        // Selected via alias => not reported as "added as dependency".
4697        assert!(
4698            !resolved
4699                .added_as_dependencies
4700                .contains(&"bashkit_shell".to_string())
4701        );
4702    }
4703
4704    #[test]
4705    fn test_alias_preserves_explicit_config_in_resolution() {
4706        let registry = fixture_registry();
4707
4708        let configs = vec![AgentCapabilityConfig::with_config(
4709            "virtual_bash".to_string(),
4710            serde_json::json!({"key": "value"}),
4711        )];
4712        let resolved = resolve_capability_configs(&configs, &registry).unwrap();
4713        let bash = resolved
4714            .iter()
4715            .find(|c| c.capability_id() == "bashkit_shell")
4716            .expect("alias must resolve to canonical bashkit_shell config");
4717        assert_eq!(
4718            bash.config_value().clone(),
4719            serde_json::json!({"key": "value"})
4720        );
4721    }
4722
4723    #[test]
4724    fn test_unregister_by_alias_removes_capability_and_aliases() {
4725        let mut registry = fixture_registry();
4726
4727        assert!(registry.unregister("virtual_bash").is_some());
4728        assert!(!registry.has("bashkit_shell"));
4729        assert!(!registry.has("virtual_bash"));
4730    }
4731
4732    #[test]
4733    fn kernel_capability_features_are_declared_on_the_capability() {
4734        let registry = fixture_registry();
4735
4736        // `session_storage`/`session_sql_database` moved to the product crate
4737        // (EVE-886); their feature declarations are covered there. What core
4738        // owns is the mechanism: a registered capability's `features()` is what
4739        // `compute_features` reports.
4740        let capability = registry
4741            .get("feature_fixture")
4742            .expect("feature fixture must be registered");
4743        assert_eq!(
4744            compute_features(&["feature_fixture".to_string()], &registry),
4745            capability.features()
4746        );
4747    }
4748
4749    #[test]
4750    fn test_sample_data_capability_features() {
4751        let registry = fixture_registry();
4752
4753        let sample = registry.get("sample_data").unwrap();
4754        assert_eq!(sample.features(), vec!["file_system"]);
4755    }
4756
4757    #[test]
4758    fn test_compute_features_empty() {
4759        let registry = CapabilityRegistry::new();
4760
4761        let features = compute_features(&[], &registry);
4762        assert!(features.is_empty());
4763    }
4764
4765    #[test]
4766    fn test_compute_features_single_capability() {
4767        let registry = fixture_registry();
4768
4769        let features = compute_features(&["feature_fixture".to_string()], &registry);
4770        assert_eq!(
4771            features,
4772            registry
4773                .get("feature_fixture")
4774                .expect("feature fixture must be registered")
4775                .features()
4776        );
4777    }
4778
4779    #[test]
4780    fn test_compute_features_multiple_capabilities() {
4781        let registry = fixture_registry();
4782
4783        let features = compute_features(
4784            &[
4785                "session_file_system".to_string(),
4786                "session_storage".to_string(),
4787            ],
4788            &registry,
4789        );
4790        assert!(features.contains(&"file_system".to_string()));
4791        assert!(features.contains(&"secrets".to_string()));
4792        assert!(features.contains(&"key_value".to_string()));
4793    }
4794
4795    #[test]
4796    fn test_compute_features_deduplicates() {
4797        let registry = fixture_registry();
4798
4799        // Both session_file_system and bashkit_shell contribute "file_system"
4800        let features = compute_features(
4801            &[
4802                "session_file_system".to_string(),
4803                "bashkit_shell".to_string(),
4804            ],
4805            &registry,
4806        );
4807        let file_system_count = features.iter().filter(|f| *f == "file_system").count();
4808        assert_eq!(file_system_count, 1, "file_system should appear only once");
4809    }
4810
4811    #[test]
4812    fn test_compute_features_includes_dependency_features() {
4813        let registry = fixture_registry();
4814
4815        // bashkit_shell depends on session_file_system; both contribute "file_system"
4816        let features = compute_features(&["bashkit_shell".to_string()], &registry);
4817        assert!(features.contains(&"file_system".to_string()));
4818    }
4819
4820    #[test]
4821    fn test_compute_features_generic_harness_set() {
4822        let registry = fixture_registry();
4823
4824        // Typical Generic Harness capabilities
4825        let features = compute_features(
4826            &[
4827                "session_file_system".to_string(),
4828                "bashkit_shell".to_string(),
4829                "session_storage".to_string(),
4830                "session".to_string(),
4831            ],
4832            &registry,
4833        );
4834        assert!(features.contains(&"file_system".to_string()));
4835        assert!(features.contains(&"secrets".to_string()));
4836        assert!(features.contains(&"key_value".to_string()));
4837    }
4838
4839    #[test]
4840    fn test_compute_features_unknown_capability_ignored() {
4841        let registry = fixture_registry();
4842
4843        let features = compute_features(
4844            &["unknown_cap".to_string(), "session_storage".to_string()],
4845            &registry,
4846        );
4847        assert_eq!(features, vec!["secrets", "key_value"]);
4848    }
4849
4850    #[test]
4851    fn test_risk_level_ordering() {
4852        assert!(RiskLevel::Low < RiskLevel::Medium);
4853        assert!(RiskLevel::Medium < RiskLevel::High);
4854    }
4855
4856    #[test]
4857    fn test_risk_level_serde_roundtrip() {
4858        let high = RiskLevel::High;
4859        let json = serde_json::to_string(&high).unwrap();
4860        assert_eq!(json, "\"high\"");
4861        let back: RiskLevel = serde_json::from_str(&json).unwrap();
4862        assert_eq!(back, RiskLevel::High);
4863    }
4864
4865    #[test]
4866    fn test_capability_risk_levels() {
4867        let registry = fixture_registry();
4868
4869        // bashkit_shell is High (code execution requires admin gating)
4870        let bash = registry.get("bashkit_shell").unwrap();
4871        assert_eq!(bash.risk_level(), RiskLevel::High);
4872
4873        // web_fetch is High (network access requires admin gating)
4874        let fetch = registry.get("web_fetch").unwrap();
4875        assert_eq!(fetch.risk_level(), RiskLevel::High);
4876
4877        // Default capabilities should be Low
4878        let noop = registry.get("noop").unwrap();
4879        assert_eq!(noop.risk_level(), RiskLevel::Low);
4880    }
4881
4882    // ========================================================================
4883    // contribute_skills() collection — EVE-311
4884    // ========================================================================
4885
4886    struct SkillContributingCapability;
4887
4888    impl Capability for SkillContributingCapability {
4889        fn id(&self) -> &str {
4890            "contributes_skills"
4891        }
4892        fn name(&self) -> &str {
4893            "Contributes Skills"
4894        }
4895        fn description(&self) -> &str {
4896            "Test capability that contributes skills."
4897        }
4898        fn contribute_skills(&self) -> Vec<SkillContribution> {
4899            vec![
4900                SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4901                    .with_files(vec![(
4902                        "scripts/a.sh".to_string(),
4903                        "#!/bin/sh\necho a\n".to_string(),
4904                    )]),
4905                SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4906                    .with_user_invocable(false),
4907            ]
4908        }
4909    }
4910
4911    fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4912        match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4913            MountSource::InlineFile { content, .. } => content.as_str(),
4914            _ => panic!("Expected InlineFile for SKILL.md"),
4915        }
4916    }
4917
4918    #[tokio::test]
4919    async fn test_contribute_skills_normalized_to_mounts() {
4920        let mut registry = CapabilityRegistry::new();
4921        registry.register(SkillContributingCapability);
4922
4923        let configs = vec![AgentCapabilityConfig::with_config(
4924            CapabilityId::new("contributes_skills"),
4925            serde_json::json!({}),
4926        )];
4927
4928        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4929
4930        let skill_mounts: Vec<_> = collected
4931            .mounts
4932            .iter()
4933            .filter(|m| m.path.starts_with("/.agents/skills/"))
4934            .collect();
4935        assert_eq!(skill_mounts.len(), 2);
4936
4937        // Every contributed skill mount is read-only and owned by the contributing
4938        // capability so the VFS layer can attribute skill files correctly.
4939        for m in &skill_mounts {
4940            assert!(m.is_readonly());
4941            assert_eq!(m.capability_id, "contributes_skills");
4942        }
4943
4944        let alpha = skill_mounts
4945            .iter()
4946            .find(|m| m.path == "/.agents/skills/alpha-skill")
4947            .expect("alpha-skill mount missing");
4948        match &alpha.source {
4949            MountSource::InlineDirectory { entries } => {
4950                assert!(entries.contains_key("SKILL.md"));
4951                assert!(entries.contains_key("scripts/a.sh"));
4952                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4953                assert_eq!(parsed.name, "alpha-skill");
4954                assert!(parsed.user_invocable);
4955            }
4956            _ => panic!("Expected InlineDirectory"),
4957        }
4958
4959        let beta = skill_mounts
4960            .iter()
4961            .find(|m| m.path == "/.agents/skills/beta-skill")
4962            .expect("beta-skill mount missing");
4963        match &beta.source {
4964            MountSource::InlineDirectory { entries } => {
4965                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4966                assert!(!parsed.user_invocable);
4967            }
4968            _ => panic!("Expected InlineDirectory"),
4969        }
4970    }
4971
4972    #[tokio::test]
4973    async fn test_contribute_skills_default_empty() {
4974        // Registry-resident capability without a contribute_skills override
4975        // must not add skill mounts.
4976        let mut registry = CapabilityRegistry::new();
4977        registry.register(FilterTestCapability { priority: 0 });
4978
4979        let configs = vec![AgentCapabilityConfig::with_config(
4980            CapabilityId::new("filter_test"),
4981            serde_json::json!({}),
4982        )];
4983
4984        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4985        assert!(
4986            collected
4987                .mounts
4988                .iter()
4989                .all(|m| !m.path.starts_with("/.agents/skills/"))
4990        );
4991    }
4992
4993    struct LocalizedCapability;
4994
4995    impl Capability for LocalizedCapability {
4996        fn id(&self) -> &str {
4997            "localized"
4998        }
4999        fn name(&self) -> &str {
5000            "Localized"
5001        }
5002        fn description(&self) -> &str {
5003            "English description"
5004        }
5005        fn localizations(&self) -> Vec<CapabilityLocalization> {
5006            vec![
5007                CapabilityLocalization {
5008                    locale: "en",
5009                    name: None,
5010                    description: None,
5011                    config_description: Some("Controls things."),
5012                    config_overlay: None,
5013                },
5014                CapabilityLocalization {
5015                    locale: "uk",
5016                    name: Some("Локалізована"),
5017                    description: Some("Український опис"),
5018                    config_description: Some("Керує налаштуваннями."),
5019                    config_overlay: None,
5020                },
5021            ]
5022        }
5023    }
5024
5025    #[test]
5026    fn localized_name_falls_back_exact_language_then_base() {
5027        let cap = LocalizedCapability;
5028        // Region tag resolves through the language family.
5029        assert_eq!(cap.localized_name(Some("uk-UA")), "Локалізована");
5030        assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
5031        // Underscore-separated tags are normalized.
5032        assert_eq!(cap.localized_name(Some("uk_UA")), "Локалізована");
5033        // Unsupported locales and None fall back to the base name.
5034        assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
5035        assert_eq!(cap.localized_name(None), "Localized");
5036        assert_eq!(cap.localized_description(Some("uk")), "Український опис");
5037        assert_eq!(cap.localized_description(Some("de")), "English description");
5038    }
5039
5040    #[test]
5041    fn describe_schema_resolves_config_description_per_locale() {
5042        let cap = LocalizedCapability;
5043        assert_eq!(
5044            cap.describe_schema(Some("uk-UA")).as_deref(),
5045            Some("Керує налаштуваннями.")
5046        );
5047        // Unsupported locales fall back to the "en" entry.
5048        assert_eq!(
5049            cap.describe_schema(Some("pl")).as_deref(),
5050            Some("Controls things.")
5051        );
5052        assert_eq!(
5053            cap.describe_schema(None).as_deref(),
5054            Some("Controls things.")
5055        );
5056        // Capabilities without localizations have no config description.
5057        assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
5058    }
5059}