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