Skip to main content

everruns_core/capabilities/
mod.rs

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