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