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    /// Validate host-provided config against this blueprint's `config_schema`.
1172    ///
1173    /// Spawn paths call this before creating a child session, so a blueprint's
1174    /// declared schema is an enforced contract rather than prompt-level advice.
1175    /// Blueprints that declare no schema accept no config at all.
1176    pub fn validate_config(
1177        &self,
1178        config: Option<&serde_json::Value>,
1179    ) -> Result<(), BlueprintConfigError> {
1180        let Some(schema) = self.config_schema.as_ref() else {
1181            return match config {
1182                Some(_) => Err(BlueprintConfigError::NotAccepted { id: self.id }),
1183                None => Ok(()),
1184            };
1185        };
1186
1187        let Some(config) = config else {
1188            // Only a schema with required properties makes config mandatory.
1189            let required = schema
1190                .get("required")
1191                .and_then(|r| r.as_array())
1192                .is_some_and(|required| !required.is_empty());
1193            return if required {
1194                Err(BlueprintConfigError::Required { id: self.id })
1195            } else {
1196                Ok(())
1197            };
1198        };
1199
1200        let validator = jsonschema::validator_for(schema).map_err(|error| {
1201            BlueprintConfigError::InvalidSchema {
1202                id: self.id,
1203                reason: error.to_string(),
1204            }
1205        })?;
1206
1207        let issues: Vec<String> = validator
1208            .iter_errors(config)
1209            .map(|error| {
1210                let path = error.instance_path().to_string();
1211                if path.is_empty() {
1212                    error.to_string()
1213                } else {
1214                    format!("{path}: {error}")
1215                }
1216            })
1217            .collect();
1218
1219        if issues.is_empty() {
1220            Ok(())
1221        } else {
1222            Err(BlueprintConfigError::Invalid {
1223                id: self.id,
1224                issues,
1225            })
1226        }
1227    }
1228}
1229
1230/// Why host-provided blueprint configuration was rejected.
1231///
1232/// `#[non_exhaustive]` keeps new rejection reasons a patch-sized addition for
1233/// downstream crates rather than a breaking change.
1234#[derive(Debug, Clone, PartialEq, Eq)]
1235#[non_exhaustive]
1236pub enum BlueprintConfigError {
1237    /// The blueprint declares no config schema, so it accepts no config.
1238    NotAccepted {
1239        /// The blueprint that was asked to take config.
1240        id: &'static str,
1241    },
1242    /// The schema has required properties but no config was supplied.
1243    Required {
1244        /// The blueprint whose schema requires config.
1245        id: &'static str,
1246    },
1247    /// The config did not validate against the schema.
1248    Invalid {
1249        /// The blueprint the config was addressed to.
1250        id: &'static str,
1251        /// One entry per schema violation, prefixed with its instance path.
1252        issues: Vec<String>,
1253    },
1254    /// The blueprint's own schema is not a usable JSON Schema document.
1255    InvalidSchema {
1256        /// The blueprint carrying the unusable schema.
1257        id: &'static str,
1258        /// Why the schema could not be compiled.
1259        reason: String,
1260    },
1261}
1262
1263impl std::fmt::Display for BlueprintConfigError {
1264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1265        match self {
1266            Self::NotAccepted { id } => {
1267                write!(f, "Blueprint \"{id}\" accepts no config.")
1268            }
1269            Self::Required { id } => {
1270                write!(f, "Blueprint \"{id}\" requires config.")
1271            }
1272            Self::Invalid { id, issues } => {
1273                write!(
1274                    f,
1275                    "Blueprint \"{id}\" received invalid config: {}",
1276                    issues.join("; ")
1277                )
1278            }
1279            Self::InvalidSchema { id, reason } => {
1280                write!(
1281                    f,
1282                    "Blueprint \"{id}\" has an invalid config schema: {reason}"
1283                )
1284            }
1285        }
1286    }
1287}
1288
1289impl std::error::Error for BlueprintConfigError {}
1290
1291impl std::fmt::Debug for AgentBlueprint {
1292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1293        f.debug_struct("AgentBlueprint")
1294            .field("id", &self.id)
1295            .field("name", &self.name)
1296            .field("model", &self.model)
1297            .field("tool_count", &self.tools.len())
1298            .field("max_turns", &self.max_turns)
1299            .finish()
1300    }
1301}
1302
1303// ============================================================================
1304// Capability Registry
1305// ============================================================================
1306
1307/// Registry that holds all available capability implementations.
1308///
1309/// The registry provides access to capabilities by ID and allows
1310/// applying multiple capabilities to build a RuntimeAgent.
1311///
1312/// # Example
1313///
1314/// ```
1315/// use everruns_core::capabilities::CapabilityRegistry;
1316///
1317/// let registry = CapabilityRegistry::new();
1318/// assert!(registry.is_empty());
1319/// ```
1320#[derive(Clone)]
1321pub struct CapabilityRegistry {
1322    capabilities: HashMap<String, Arc<dyn Capability>>,
1323    /// Canonical-id/alias bookkeeping delegated to the neutral capability
1324    /// contract so the Framework and product resolve identity identically
1325    /// (see [`Capability::aliases`]).
1326    index: everruns_capability::CapabilityIdIndex,
1327}
1328
1329impl CapabilityRegistry {
1330    /// Create a new empty registry
1331    pub fn new() -> Self {
1332        Self {
1333            capabilities: HashMap::new(),
1334            index: everruns_capability::CapabilityIdIndex::new(),
1335        }
1336    }
1337
1338    /// Register a capability
1339    pub fn register(&mut self, capability: impl Capability + 'static) {
1340        self.register_arc(Arc::new(capability));
1341    }
1342
1343    /// Register a boxed capability
1344    pub fn register_boxed(&mut self, capability: Box<dyn Capability>) {
1345        self.register_arc(Arc::from(capability));
1346    }
1347
1348    /// Register an Arc-wrapped capability.
1349    ///
1350    /// Re-registering the same canonical ID replaces the previous
1351    /// implementation (legacy override semantics); use
1352    /// [`CapabilityRegistry::try_register_arc`] to reject collisions instead.
1353    pub fn register_arc(&mut self, capability: Arc<dyn Capability>) {
1354        let canonical = capability.id().to_string();
1355        self.index
1356            .insert_or_replace(canonical.clone(), &capability.aliases());
1357        self.capabilities.insert(canonical, capability);
1358    }
1359
1360    /// Register an Arc-wrapped capability, rejecting duplicate IDs and alias
1361    /// collisions via the neutral contract's registry rules.
1362    pub fn try_register_arc(
1363        &mut self,
1364        capability: Arc<dyn Capability>,
1365    ) -> Result<(), everruns_capability::CapabilityError> {
1366        let canonical = capability.id().to_string();
1367        self.index
1368            .insert(canonical.clone(), &capability.aliases())?;
1369        self.capabilities.insert(canonical, capability);
1370        Ok(())
1371    }
1372
1373    /// Register inventory-submitted integrations accepted by a caller-owned policy.
1374    ///
1375    /// Core owns the registry mutation algorithm; host and product composition
1376    /// own every deployment-grade and feature decision supplied by `include`.
1377    pub fn register_inventory_plugins(
1378        &mut self,
1379        mut include: impl FnMut(&IntegrationPlugin) -> bool,
1380    ) {
1381        for plugin in inventory::iter::<IntegrationPlugin>() {
1382            if include(plugin) {
1383                self.register_boxed((plugin.factory)());
1384            }
1385        }
1386    }
1387
1388    /// Get a capability by ID or alias
1389    pub fn get(&self, id: &str) -> Option<&Arc<dyn Capability>> {
1390        self.capabilities.get(self.index.canonical_of(id)?)
1391    }
1392
1393    /// Resolve an ID or alias to the canonical capability ID.
1394    ///
1395    /// Returns `None` for IDs that are neither registered nor an alias of a
1396    /// registered capability (e.g. declarative or MCP refs).
1397    pub fn canonical_id<'a>(&'a self, id: &'a str) -> Option<&'a str> {
1398        self.index.canonical_of(id)
1399    }
1400
1401    /// Remove a capability from the registry by ID or alias.
1402    pub fn unregister(&mut self, id: &str) -> Option<Arc<dyn Capability>> {
1403        let canonical = self.index.remove(id)?;
1404        self.capabilities.remove(&canonical)
1405    }
1406
1407    /// Check if a capability is registered (by ID or alias)
1408    pub fn has(&self, id: &str) -> bool {
1409        self.get(id).is_some()
1410    }
1411
1412    /// Get all registered capabilities
1413    pub fn list(&self) -> Vec<&Arc<dyn Capability>> {
1414        self.capabilities.values().collect()
1415    }
1416
1417    /// Get the number of registered capabilities
1418    pub fn len(&self) -> usize {
1419        self.capabilities.len()
1420    }
1421
1422    /// Check if the registry is empty
1423    pub fn is_empty(&self) -> bool {
1424        self.capabilities.is_empty()
1425    }
1426
1427    /// Create a builder for fluent capability registration
1428    pub fn builder() -> CapabilityRegistryBuilder {
1429        CapabilityRegistryBuilder::new()
1430    }
1431
1432    /// Find a blueprint by ID across all registered capabilities.
1433    ///
1434    /// Returns a fresh `AgentBlueprint` (with new tool instances) each time.
1435    pub fn blueprint(&self, id: &str) -> Option<AgentBlueprint> {
1436        for cap in self.capabilities.values() {
1437            for bp in cap.agent_blueprints() {
1438                if bp.id == id {
1439                    return Some(bp);
1440                }
1441            }
1442        }
1443        None
1444    }
1445
1446    /// Find a blueprint and the capability that registered it.
1447    ///
1448    /// Returns `(capability_id, blueprint)` with fresh tool instances.
1449    pub fn blueprint_with_capability(&self, id: &str) -> Option<(String, AgentBlueprint)> {
1450        for (capability_id, cap) in &self.capabilities {
1451            for bp in cap.agent_blueprints() {
1452                if bp.id == id {
1453                    return Some((capability_id.clone(), bp));
1454                }
1455            }
1456        }
1457        None
1458    }
1459
1460    /// Collect all blueprints from all registered capabilities.
1461    pub fn all_blueprints(&self) -> Vec<AgentBlueprint> {
1462        self.capabilities
1463            .values()
1464            .flat_map(|cap| cap.agent_blueprints())
1465            .collect()
1466    }
1467}
1468
1469impl Default for CapabilityRegistry {
1470    fn default() -> Self {
1471        Self::new()
1472    }
1473}
1474
1475impl std::fmt::Debug for CapabilityRegistry {
1476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1477        let ids: Vec<_> = self.capabilities.keys().collect();
1478        f.debug_struct("CapabilityRegistry")
1479            .field("capabilities", &ids)
1480            .finish()
1481    }
1482}
1483
1484/// Builder for creating a CapabilityRegistry with a fluent API
1485pub struct CapabilityRegistryBuilder {
1486    registry: CapabilityRegistry,
1487}
1488
1489impl CapabilityRegistryBuilder {
1490    /// Create a new builder with an empty registry
1491    pub fn new() -> Self {
1492        Self {
1493            registry: CapabilityRegistry::new(),
1494        }
1495    }
1496
1497    /// Add a capability
1498    pub fn capability(mut self, capability: impl Capability + 'static) -> Self {
1499        self.registry.register(capability);
1500        self
1501    }
1502
1503    /// Build the registry
1504    pub fn build(self) -> CapabilityRegistry {
1505        self.registry
1506    }
1507}
1508
1509impl Default for CapabilityRegistryBuilder {
1510    fn default() -> Self {
1511        Self::new()
1512    }
1513}
1514
1515// ============================================================================
1516// Collect Capabilities Helper
1517// ============================================================================
1518
1519/// Context available to capability-owned model-view transforms.
1520pub struct ModelViewContext<'a> {
1521    pub session_id: SessionId,
1522    pub prior_usage: Option<&'a TokenUsage>,
1523}
1524
1525/// Provider-side hook for building prompt-facing model views.
1526///
1527/// Providers receive the output of earlier providers and return the messages
1528/// that should be sent into provider serialization. Lower priority providers
1529/// run earlier.
1530pub trait ModelViewProvider: Send + Sync {
1531    fn apply_model_view(
1532        &self,
1533        messages: Vec<Message>,
1534        config: &serde_json::Value,
1535        context: &ModelViewContext<'_>,
1536    ) -> Vec<Message>;
1537
1538    fn priority(&self) -> i32 {
1539        0
1540    }
1541}
1542
1543/// Collected data from capabilities before applying to config.
1544///
1545/// This intermediate struct allows sharing the capability collection logic
1546/// between `apply_capabilities` and `apply_capabilities_to_builder`.
1547pub struct CollectedCapabilities {
1548    /// System prompt additions (in order)
1549    pub system_prompt_parts: Vec<String>,
1550    /// Source attribution for each system prompt addition.
1551    pub system_prompt_attributions: Vec<SystemPromptAttribution>,
1552    /// Conversation-context additions (in order). Unlike system prompt parts,
1553    /// these render as the leading user-role message of every turn:
1554    /// model-visible and re-resolved alongside the system prompt, but never
1555    /// folded into the cached system prompt. Untrusted workspace content
1556    /// (e.g. AGENTS.md hierarchies) belongs here, below harness safety
1557    /// instructions in the instruction hierarchy.
1558    pub conversation_context_parts: Vec<String>,
1559    /// Source attribution for each conversation-context addition.
1560    pub conversation_context_attributions: Vec<SystemPromptAttribution>,
1561    /// Tool implementations for the registry
1562    pub tools: Vec<Box<dyn Tool>>,
1563    /// Tool definitions for config
1564    pub tool_definitions: Vec<ToolDefinition>,
1565    /// Mount points from capabilities
1566    pub mounts: Vec<MountPoint>,
1567    /// Message filter providers with their configs (in priority order)
1568    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1569    /// IDs of capabilities that were collected
1570    pub applied_ids: Vec<String>,
1571    /// Tool search configuration (set when openai_tool_search capability is present)
1572    pub tool_search: Option<crate::driver_registry::ToolSearchConfig>,
1573    /// Prompt caching configuration (set when prompt_caching capability is present)
1574    pub prompt_cache: Option<crate::driver_registry::PromptCacheConfig>,
1575    /// Driver-namespaced opaque per-call options (e.g. provider-executed
1576    /// server tools contributed by the `openrouter_server_tools` capability).
1577    /// First contributor wins per key.
1578    pub driver_options: HashMap<String, serde_json::Value>,
1579    /// Request-level parallel tool calls preference (set when the
1580    /// `parallel_tool_calls` capability is present with mode `prefer`/`avoid`).
1581    /// `None` when absent or mode `none`.
1582    pub parallel_tool_calls: Option<bool>,
1583    /// Hooks that transform the final runtime tool definition list.
1584    pub tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>>,
1585    /// Hooks that inspect or transform model-produced tool calls.
1586    pub tool_call_hooks: Vec<Arc<dyn ToolCallHook>>,
1587    /// Scoped remote MCP servers contributed by capabilities.
1588    pub mcp_servers: ScopedMcpServers,
1589    // NOTE: output guardrails are intentionally NOT collected here. They are
1590    // re-derived per turn in `ReasonAtom` directly from the resolved capability
1591    // configs + registry, because they need the assembled system prompt at
1592    // arming time (which only exists once the runtime agent is built). Storing
1593    // them here would duplicate that work for callers that don't run a stream.
1594}
1595
1596#[derive(Debug, Clone, PartialEq, Eq)]
1597pub struct SystemPromptAttribution {
1598    pub capability_id: String,
1599    pub content: String,
1600}
1601
1602impl CollectedCapabilities {
1603    /// Returns the combined system prompt prefix from all capabilities.
1604    /// Returns None if no capabilities contributed system prompt additions.
1605    pub fn system_prompt_prefix(&self) -> Option<String> {
1606        if self.system_prompt_parts.is_empty() {
1607            None
1608        } else {
1609            Some(self.system_prompt_parts.join("\n\n"))
1610        }
1611    }
1612
1613    /// Combined conversation context from all capabilities (joined with blank
1614    /// lines), or `None` when no capability contributed any. Renders as the
1615    /// leading user-role message of every turn, never as system prompt.
1616    pub fn conversation_context(&self) -> Option<String> {
1617        if self.conversation_context_parts.is_empty() {
1618            None
1619        } else {
1620            Some(self.conversation_context_parts.join("\n\n"))
1621        }
1622    }
1623
1624    /// Apply all collected message filter providers to a query.
1625    ///
1626    /// Providers are applied in priority order (lower priority first).
1627    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
1628        // Providers are already sorted by priority during collection
1629        for (provider, config) in &self.message_filter_providers {
1630            provider.apply_filters(query, config);
1631        }
1632    }
1633
1634    /// Apply post-load transforms from all message filter providers.
1635    /// Called after messages are loaded, filtered, and injected.
1636    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
1637        for (provider, config) in &self.message_filter_providers {
1638            provider.post_load(messages, config);
1639        }
1640    }
1641
1642    /// Check if any capabilities contribute message filters.
1643    pub fn has_message_filters(&self) -> bool {
1644        !self.message_filter_providers.is_empty()
1645    }
1646}
1647
1648pub struct DelegationTargetProvider {
1649    pub target_type: &'static str,
1650    pub tool: Box<dyn Tool>,
1651}
1652
1653/// Shared execution mode accepted natively by every `spawn_agent` provider.
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1655#[serde(rename_all = "snake_case")]
1656pub enum SpawnMode {
1657    Background,
1658    Foreground,
1659}
1660
1661impl SpawnMode {
1662    pub fn parse(value: &str) -> Option<Self> {
1663        match value {
1664            "background" => Some(Self::Background),
1665            "foreground" => Some(Self::Foreground),
1666            _ => None,
1667        }
1668    }
1669
1670    pub fn as_str(self) -> &'static str {
1671        match self {
1672            Self::Background => "background",
1673            Self::Foreground => "foreground",
1674        }
1675    }
1676}
1677
1678struct UnifiedSpawnAgentTool {
1679    providers: Vec<DelegationTargetProvider>,
1680}
1681
1682fn validate_spawn_agent_target_fields(
1683    arguments: &serde_json::Value,
1684    target_type: &str,
1685) -> Result<(), String> {
1686    for field in ["blueprint", "config"] {
1687        if target_type != "subagent" && arguments.get(field).is_some_and(|value| !value.is_null()) {
1688            return Err(format!(
1689                "{field} is only valid for subagent targets, not {target_type}."
1690            ));
1691        }
1692    }
1693    Ok(())
1694}
1695
1696impl UnifiedSpawnAgentTool {
1697    fn new(providers: Vec<DelegationTargetProvider>) -> Self {
1698        Self { providers }
1699    }
1700
1701    fn provider_for(&self, target_type: &str) -> Option<&dyn Tool> {
1702        self.providers
1703            .iter()
1704            .find(|provider| provider.target_type == target_type)
1705            .map(|provider| provider.tool.as_ref())
1706    }
1707
1708    fn target_types(&self) -> Vec<&'static str> {
1709        ["subagent", "agent", "external_a2a"]
1710            .into_iter()
1711            .filter(|target_type| {
1712                self.providers
1713                    .iter()
1714                    .any(|provider| provider.target_type == *target_type)
1715            })
1716            .collect()
1717    }
1718
1719    /// Per-`target.type` constraint branches, nested inside the `target`
1720    /// property. Anthropic rejects `oneOf`/`allOf`/`anyOf` at the top level
1721    /// of a tool `input_schema`, so provider-specific requirements must live
1722    /// below the root (nested composition is accepted).
1723    fn target_constraint_branches(&self) -> Vec<serde_json::Value> {
1724        self.target_types()
1725            .into_iter()
1726            .filter_map(|target_type| match target_type {
1727                "subagent" => Some(serde_json::json!({
1728                    "properties": {
1729                        "type": {"const": "subagent"}
1730                    }
1731                })),
1732                "agent" => Some(serde_json::json!({
1733                    "properties": {
1734                        "type": {"const": "agent"}
1735                    },
1736                    "required": ["type", "id"]
1737                })),
1738                "external_a2a" => Some(serde_json::json!({
1739                    "properties": {
1740                        "type": {"const": "external_a2a"}
1741                    },
1742                    "anyOf": [
1743                        {"required": ["id"]},
1744                        {"required": ["external_agent_id"]}
1745                    ]
1746                })),
1747                _ => None,
1748            })
1749            .collect()
1750    }
1751
1752    // NOTE: subagent and agent providers require `name` at execution
1753    // (`require_str`), while external_a2a ignores it. A schema that required
1754    // `name` only for the local targets would need a top-level
1755    // `oneOf`/`if`/`allOf`, which Anthropic rejects in a tool `input_schema`.
1756    // `name` is therefore required at the root unconditionally: requiring a
1757    // field external_a2a merely ignores is safe (the schema never permits a
1758    // call execution would reject), whereas omitting it would let a
1759    // `name`-less subagent call pass validation and then fail at dispatch —
1760    // exactly the mismatch #2787 set out to close.
1761}
1762
1763#[async_trait]
1764impl Tool for UnifiedSpawnAgentTool {
1765    fn narrate(
1766        &self,
1767        tool_call: &ToolCall,
1768        phase: crate::tool_narration::ToolNarrationPhase,
1769        locale: Option<&str>,
1770        ctx: crate::tool_narration::ToolNarrationContext<'_>,
1771    ) -> Option<String> {
1772        // A call still streaming its arguments, or one naming an unknown
1773        // target, must not fall back to "Running Spawn Agent": narrate the
1774        // delegation directly so the line always names the agent being spawned.
1775        let from_provider = tool_call
1776            .arguments
1777            .get("target")
1778            .and_then(|target| target.get("type"))
1779            .and_then(serde_json::Value::as_str)
1780            .and_then(|target_type| self.provider_for(target_type))
1781            .and_then(|tool| tool.narrate(tool_call, phase, locale, ctx));
1782        Some(from_provider.unwrap_or_else(|| {
1783            crate::tool_narration::narrate_subagent_spawn(&tool_call.arguments, phase, locale)
1784        }))
1785    }
1786
1787    fn name(&self) -> &str {
1788        "spawn_agent"
1789    }
1790
1791    fn display_name(&self) -> Option<&str> {
1792        Some("Spawn Agent")
1793    }
1794
1795    fn description(&self) -> &str {
1796        "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."
1797    }
1798
1799    fn parameters_schema(&self) -> serde_json::Value {
1800        serde_json::json!({
1801            "type": "object",
1802            "properties": {
1803                "name": {
1804                    "type": "string",
1805                    "description": "Human-readable name for the delegated run (subagent, first-party handoff, or external delegation). Used as the task label."
1806                },
1807                "instructions": {
1808                    "type": "string",
1809                    "description": "Instructions for the delegated agent. Do not include credentials or bearer tokens."
1810                },
1811                "goal": {
1812                    "type": "string",
1813                    "description": "Optional objective stored on the spawned session and made visible at system-prompt level."
1814                },
1815                "lifetime": {
1816                    "type": "string",
1817                    "enum": ["linked", "detached"],
1818                    "default": "linked",
1819                    "description": "linked creates a lifecycle child; detached creates an independent top-level peer session. Not valid for external_a2a."
1820                },
1821                "seed": {
1822                    "type": "string",
1823                    "enum": ["fresh", "fork", "workspace"],
1824                    "default": "fresh",
1825                    "description": "Detached-session seed mode: fresh starts blank, fork copies history/workspace/session storage, workspace copies workspace files only."
1826                },
1827                "target": {
1828                    "type": "object",
1829                    "properties": {
1830                        "type": {
1831                            "type": "string",
1832                            "enum": self.target_types(),
1833                            "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."
1834                        },
1835                        "id": {
1836                            "type": "string",
1837                            "description": "Configured target id for first-party handoffs or external A2A agents."
1838                        },
1839                        "external_agent_id": {
1840                            "type": "string",
1841                            "description": "Configured external A2A agent id."
1842                        }
1843                    },
1844                    "required": ["type"],
1845                    "oneOf": self.target_constraint_branches(),
1846                    "additionalProperties": false
1847                },
1848                "mode": {
1849                    "type": "string",
1850                    "enum": ["background", "foreground"],
1851                    "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."
1852                },
1853                "blueprint": {
1854                    "type": "string",
1855                    "description": "Subagent-only blueprint ID to spawn a specialist agent with its own tools and model."
1856                },
1857                "config": {
1858                    "type": "object",
1859                    "description": "Subagent-only blueprint configuration. Only valid when blueprint is set."
1860                },
1861                "result_schema": {
1862                    "type": "object",
1863                    "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."
1864                },
1865                "message_schema": {
1866                    "type": "object",
1867                    "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."
1868                },
1869                "public_context": {
1870                    "type": "object",
1871                    "description": "Agent-handoff-only non-secret structured context to include with the instructions."
1872                },
1873                "wait_timeout_secs": {
1874                    "type": "integer",
1875                    "minimum": 1,
1876                    "maximum": 86400,
1877                    "description": "External-A2A-only foreground timeout."
1878                },
1879                "wake_on_completion": {
1880                    "type": "boolean",
1881                    "description": "External-A2A-only control for background completion wake-ups."
1882                }
1883            },
1884            "required": ["name", "instructions", "target"],
1885            "additionalProperties": false
1886        })
1887    }
1888
1889    fn hints(&self) -> crate::tool_types::ToolHints {
1890        let mut hints = crate::tool_types::ToolHints::default()
1891            .with_long_running(true)
1892            .with_concurrency_class(SPAWN_AGENT_CONCURRENCY_CLASS);
1893        if self.provider_for("external_a2a").is_some() {
1894            hints = hints.with_open_world(true);
1895        }
1896        hints
1897    }
1898
1899    async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
1900        ToolExecutionResult::tool_error(
1901            "spawn_agent requires context. This tool must be executed with session context.",
1902        )
1903    }
1904
1905    async fn execute_with_context(
1906        &self,
1907        arguments: serde_json::Value,
1908        context: &ToolContext,
1909    ) -> ToolExecutionResult {
1910        let target_type = match arguments
1911            .get("target")
1912            .and_then(|target| target.get("type"))
1913            .and_then(serde_json::Value::as_str)
1914        {
1915            Some(target_type) => target_type,
1916            None => {
1917                return ToolExecutionResult::tool_error("Missing required parameter: target.type");
1918            }
1919        };
1920
1921        let Some(provider) = self.provider_for(target_type) else {
1922            let supported = self.target_types().join(", ");
1923            return ToolExecutionResult::tool_error(format!(
1924                "Unsupported spawn_agent target.type: \"{target_type}\". Supported target types: {supported}"
1925            ));
1926        };
1927        if let Err(error) = validate_spawn_agent_target_fields(&arguments, target_type) {
1928            return ToolExecutionResult::tool_error(error);
1929        }
1930        if target_type == "external_a2a"
1931            && arguments
1932                .get("lifetime")
1933                .and_then(serde_json::Value::as_str)
1934                .is_some_and(|value| value == "detached")
1935        {
1936            return ToolExecutionResult::tool_error(
1937                "lifetime=\"detached\" is only valid for local session targets (subagent or agent), not external_a2a.",
1938            );
1939        }
1940        if target_type == "external_a2a"
1941            && arguments
1942                .get("message_schema")
1943                .is_some_and(|schema| !schema.is_null())
1944        {
1945            return ToolExecutionResult::tool_error(
1946                "message_schema is not supported for external_a2a targets because remote agents cannot receive report_task_progress.",
1947            );
1948        }
1949
1950        provider.execute_with_context(arguments, context).await
1951    }
1952
1953    fn requires_context(&self) -> bool {
1954        true
1955    }
1956}
1957
1958/// Compose the model-visible system prompt from the stable base prompt and
1959/// collected capability contributions. Keep the base prompt first so changes in
1960/// dynamic capabilities (for example AGENTS.md reads or environment context)
1961/// do not invalidate provider prefix caches for the agent's core instructions.
1962pub fn compose_system_prompt(base_system_prompt: &str, additions: Option<&str>) -> String {
1963    let Some(additions) = additions.filter(|value| !value.is_empty()) else {
1964        return base_system_prompt.to_string();
1965    };
1966
1967    if base_system_prompt.is_empty() {
1968        return additions.to_string();
1969    }
1970
1971    if base_system_prompt.contains("<system-prompt>") {
1972        format!("{base_system_prompt}\n\n{additions}")
1973    } else {
1974        format!("<system-prompt>\n{base_system_prompt}\n</system-prompt>\n\n{additions}")
1975    }
1976}
1977
1978/// Lightweight result containing only message filter providers.
1979///
1980/// Used when callers only need message filtering (e.g., message loading in
1981/// ReasonAtom) without paying the cost of system prompt contribution or tool
1982/// collection. This avoids unnecessary filesystem reads (AGENTS.md) and tool
1983/// instantiation on the message-filter-only path.
1984pub struct CollectedMessageFilters {
1985    /// Message filter providers with their configs (in priority order)
1986    pub message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)>,
1987}
1988
1989/// Lightweight result containing only model-view providers.
1990pub struct CollectedModelViewProviders {
1991    /// Model-view providers with their configs (in priority order).
1992    pub model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)>,
1993}
1994
1995// Note: apply_message_filters/apply_post_load_filters mirror the same methods
1996// on CollectedCapabilities. The duplication is intentional — extracting a trait
1997// would add indirection for 3 lines of loop body, and the two structs serve
1998// different purposes (lightweight vs full collection).
1999
2000impl CollectedMessageFilters {
2001    /// Apply all collected message filter providers to a query.
2002    pub fn apply_message_filters(&self, query: &mut crate::message_filter::MessageQuery) {
2003        for (provider, config) in &self.message_filter_providers {
2004            provider.apply_filters(query, config);
2005        }
2006    }
2007
2008    /// Apply post-load transforms from all message filter providers.
2009    pub fn apply_post_load_filters(&self, messages: &mut Vec<crate::message::Message>) {
2010        for (provider, config) in &self.message_filter_providers {
2011            provider.post_load(messages, config);
2012        }
2013    }
2014}
2015
2016impl CollectedModelViewProviders {
2017    /// Apply all collected model-view providers in priority order.
2018    pub fn apply_model_view(
2019        &self,
2020        mut messages: Vec<Message>,
2021        context: &ModelViewContext<'_>,
2022    ) -> Vec<Message> {
2023        for (provider, config) in &self.model_view_providers {
2024            messages = provider.apply_model_view(messages, config, context);
2025        }
2026        messages
2027    }
2028}
2029
2030/// True when an available capability contributes compaction policy in this set.
2031///
2032/// Infinity context defers token-budget eviction to compaction when both are
2033/// enabled (see knowledge/runtime-resources/infinity-context.md) so that compaction's summary — not a
2034/// bare "hidden" notice — covers trimmed history.
2035fn compaction_is_enabled(
2036    capability_configs: &[AgentCapabilityConfig],
2037    registry: &CapabilityRegistry,
2038) -> bool {
2039    capability_configs.iter().any(|cap_config| {
2040        registry.get(cap_config.capability_id()).is_some_and(|cap| {
2041            cap.status().is_active() && cap.compaction_policy(cap_config.config_value()).is_some()
2042        })
2043    })
2044}
2045
2046/// Collect only message filter providers from capabilities, skipping system
2047/// prompt contributions, tools, mounts, and other expensive work.
2048///
2049/// This is a fast path for callers that only need message filtering (e.g.,
2050/// the message-loading step in ReasonAtom before RuntimeAgent is built).
2051pub fn collect_message_filters_only(
2052    capability_configs: &[AgentCapabilityConfig],
2053    registry: &CapabilityRegistry,
2054) -> CollectedMessageFilters {
2055    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2056        Vec::new();
2057    let compaction_on = compaction_is_enabled(capability_configs, registry);
2058
2059    for cap_config in capability_configs {
2060        let cap_id = cap_config.capability_id();
2061        if let Some(capability) = registry.get(cap_id) {
2062            if !capability.status().is_active() {
2063                continue;
2064            }
2065            // Resolve against None: no model is known at message-filter collection
2066            // time, so fall back to the model-agnostic variant if present.
2067            let effective: &dyn Capability = capability
2068                .resolve_for_model(None)
2069                .unwrap_or_else(|| capability.as_ref());
2070            if let Some(provider) = effective.message_filter_provider() {
2071                let config =
2072                    effective.message_filter_config(cap_config.config_value(), compaction_on);
2073                message_filter_providers.push((provider, config));
2074            }
2075        }
2076    }
2077
2078    message_filter_providers.sort_by_key(|(p, _)| p.priority());
2079
2080    CollectedMessageFilters {
2081        message_filter_providers,
2082    }
2083}
2084
2085/// Collect only model-view providers from capabilities.
2086///
2087/// `model` should be the LLM model name when it is known at call time (e.g. the
2088/// ReasonAtom already holds a resolved model execution). Pass `None` only when the
2089/// model is genuinely unavailable so capabilities fall back to the model-agnostic
2090/// variant.
2091pub fn collect_model_view_providers(
2092    capability_configs: &[AgentCapabilityConfig],
2093    registry: &CapabilityRegistry,
2094    model: Option<&str>,
2095) -> CollectedModelViewProviders {
2096    let mut model_view_providers: Vec<(Arc<dyn ModelViewProvider>, serde_json::Value)> = Vec::new();
2097
2098    for cap_config in capability_configs {
2099        let cap_id = cap_config.capability_id();
2100        if let Some(capability) = registry.get(cap_id) {
2101            if !capability.status().is_active() {
2102                continue;
2103            }
2104            let effective: &dyn Capability = capability
2105                .resolve_for_model(model)
2106                .unwrap_or_else(|| capability.as_ref());
2107            if let Some(provider) = effective.model_view_provider() {
2108                model_view_providers.push((provider, cap_config.config_value().clone()));
2109            }
2110        }
2111    }
2112
2113    model_view_providers.sort_by_key(|(p, _)| p.priority());
2114
2115    CollectedModelViewProviders {
2116        model_view_providers,
2117    }
2118}
2119
2120/// Collect [`Volatility::Dynamic`] facts from every active capability, in
2121/// configured order. Called by `ReasonAtom` once per request so live values
2122/// (e.g. the current time) are fresh, then rendered into the trailing `<facts>`
2123/// block. Static facts are ignored here — they already live in the cached
2124/// system prompt.
2125pub fn collect_dynamic_facts(
2126    capability_configs: &[AgentCapabilityConfig],
2127    registry: &CapabilityRegistry,
2128    model: Option<&str>,
2129    ctx: &FactsContext,
2130) -> Vec<Fact> {
2131    let mut dynamic = Vec::new();
2132    for cap_config in capability_configs {
2133        let cap_id = cap_config.capability_id();
2134        if let Some(capability) = registry.get(cap_id) {
2135            if !capability.status().is_active() {
2136                continue;
2137            }
2138            let effective: &dyn Capability = capability
2139                .resolve_for_model(model)
2140                .unwrap_or_else(|| capability.as_ref());
2141            for fact in effective.facts(cap_config.config_value(), ctx) {
2142                if fact.volatility == Volatility::Dynamic {
2143                    dynamic.push(fact);
2144                }
2145            }
2146        }
2147    }
2148    dynamic
2149}
2150
2151pub fn collect_capability_mcp_servers(
2152    capability_configs: &[AgentCapabilityConfig],
2153    registry: &CapabilityRegistry,
2154) -> ScopedMcpServers {
2155    let mut servers = ScopedMcpServers::default();
2156
2157    for cap_config in capability_configs {
2158        let cap_id = cap_config.capability_id();
2159        // Both `declarative:` and `plugin:` carry a serialized
2160        // `DeclarativeCapabilityDefinition`; handle them the same way.
2161        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2162            if let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2163                cap_config.config_value().clone(),
2164            ) {
2165                if !definition.status.is_active() {
2166                    continue;
2167                }
2168                if let Some(contributed) = definition.mcp_servers {
2169                    servers = merge_scoped_mcp_servers(&servers, &contributed);
2170                }
2171            }
2172            continue;
2173        }
2174        if let Some(capability) = registry.get(cap_id) {
2175            if !capability.status().is_active() {
2176                continue;
2177            }
2178            servers = merge_scoped_mcp_servers(
2179                &servers,
2180                &capability.mcp_servers_with_config(cap_config.config_value()),
2181            );
2182        }
2183    }
2184
2185    servers
2186}
2187
2188// ============================================================================
2189// Dependency Resolution
2190// ============================================================================
2191
2192/// Maximum number of capabilities after dependency resolution.
2193/// This prevents runaway dependency chains and resource exhaustion.
2194pub const MAX_RESOLVED_CAPABILITIES: usize = 100;
2195
2196/// Error type for dependency resolution failures
2197#[derive(Debug, Clone, PartialEq, Eq)]
2198pub enum DependencyError {
2199    /// Circular dependency detected in the capability graph
2200    CircularDependency {
2201        /// The capability where the cycle was detected
2202        capability_id: String,
2203        /// The dependency chain leading to the cycle
2204        chain: Vec<String>,
2205    },
2206    /// Too many capabilities after resolution
2207    TooManyCapabilities {
2208        /// Number of capabilities requested
2209        count: usize,
2210        /// Maximum allowed
2211        max: usize,
2212    },
2213}
2214
2215impl std::fmt::Display for DependencyError {
2216    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2217        match self {
2218            DependencyError::CircularDependency {
2219                capability_id,
2220                chain,
2221            } => {
2222                write!(
2223                    f,
2224                    "Circular dependency detected: {} depends on itself via chain: {} -> {}",
2225                    capability_id,
2226                    chain.join(" -> "),
2227                    capability_id
2228                )
2229            }
2230            DependencyError::TooManyCapabilities { count, max } => {
2231                write!(
2232                    f,
2233                    "Too many capabilities after resolution: {} (max: {})",
2234                    count, max
2235                )
2236            }
2237        }
2238    }
2239}
2240
2241impl std::error::Error for DependencyError {}
2242
2243/// Result of resolving capability dependencies
2244#[derive(Debug, Clone)]
2245pub struct ResolvedCapabilities {
2246    /// All capability IDs after resolving dependencies (in topological order)
2247    /// Dependencies come before dependents.
2248    pub resolved_ids: Vec<String>,
2249    /// IDs that were added as dependencies (not in the original selection)
2250    pub added_as_dependencies: Vec<String>,
2251    /// Original user-selected capability IDs
2252    pub user_selected: Vec<String>,
2253}
2254
2255/// Resolve capability dependencies, returning all required capability IDs.
2256///
2257/// This function:
2258/// 1. Takes the user-selected capability IDs
2259/// 2. Recursively collects all dependencies
2260/// 3. Returns them in topological order (dependencies before dependents)
2261/// 4. Detects circular dependencies and returns an error
2262/// 5. Enforces a maximum capability limit
2263///
2264/// # Arguments
2265///
2266/// * `selected_ids` - User-selected capability IDs
2267/// * `registry` - The capability registry to look up dependencies
2268///
2269/// # Returns
2270///
2271/// `Ok(ResolvedCapabilities)` with all required capabilities in order,
2272/// or `Err(DependencyError)` if circular dependencies are detected or
2273/// the limit is exceeded.
2274pub fn resolve_dependencies(
2275    selected_ids: &[String],
2276    registry: &CapabilityRegistry,
2277) -> Result<ResolvedCapabilities, DependencyError> {
2278    use std::collections::HashSet;
2279
2280    // Canonicalize so capabilities selected via alias match their resolved IDs.
2281    let user_selected: HashSet<String> = selected_ids
2282        .iter()
2283        .map(|id| registry.canonical_id(id).unwrap_or(id).to_string())
2284        .collect();
2285    let mut resolved: Vec<String> = Vec::new();
2286    let mut resolved_set: HashSet<String> = HashSet::new();
2287    let mut added_as_dependencies: Vec<String> = Vec::new();
2288
2289    // Process each selected capability and its dependencies using DFS
2290    for cap_id in selected_ids {
2291        resolve_single_capability(
2292            cap_id,
2293            registry,
2294            &mut resolved,
2295            &mut resolved_set,
2296            &mut added_as_dependencies,
2297            &user_selected,
2298            &mut Vec::new(), // visiting chain for cycle detection
2299        )?;
2300    }
2301
2302    // Check max limit
2303    if resolved.len() > MAX_RESOLVED_CAPABILITIES {
2304        return Err(DependencyError::TooManyCapabilities {
2305            count: resolved.len(),
2306            max: MAX_RESOLVED_CAPABILITIES,
2307        });
2308    }
2309
2310    Ok(ResolvedCapabilities {
2311        resolved_ids: resolved,
2312        added_as_dependencies,
2313        user_selected: selected_ids.to_vec(),
2314    })
2315}
2316
2317/// Resolve dependency-expanded capability configs, preserving explicit config on selected IDs.
2318///
2319/// Dependencies are inserted with empty configs. If the same capability is provided more than
2320/// once, the last explicit config wins.
2321pub fn resolve_capability_configs(
2322    selected_configs: &[AgentCapabilityConfig],
2323    registry: &CapabilityRegistry,
2324) -> Result<Vec<AgentCapabilityConfig>, DependencyError> {
2325    let mut selected_ids: Vec<String> = Vec::new();
2326    for config in selected_configs {
2327        // Both `declarative:` and `plugin:` carry a `DeclarativeCapabilityDefinition`
2328        // config that may declare dependencies.
2329        if (is_declarative_capability(config.capability_id())
2330            || is_plugin_capability(config.capability_id()))
2331            && let Ok(definition) = serde_json::from_value::<DeclarativeCapabilityDefinition>(
2332                config.config_value().clone(),
2333            )
2334        {
2335            selected_ids.extend(definition.dependencies);
2336        }
2337        selected_ids.push(config.capability_id().to_string());
2338    }
2339    let resolved = resolve_dependencies(&selected_ids, registry)?;
2340
2341    // Key explicit configs by canonical ID so config supplied under an alias
2342    // still attaches to the (canonical) resolved capability ID.
2343    let explicit_configs: std::collections::HashMap<String, serde_json::Value> = selected_configs
2344        .iter()
2345        .map(|config| {
2346            let id = config.capability_id();
2347            let id = registry.canonical_id(id).unwrap_or(id);
2348            (id.to_string(), config.config_value().clone())
2349        })
2350        .collect();
2351
2352    Ok(resolved
2353        .resolved_ids
2354        .into_iter()
2355        .map(|capability_id| {
2356            explicit_configs
2357                .get(&capability_id)
2358                .cloned()
2359                .map(|config| AgentCapabilityConfig::with_config(capability_id.clone(), config))
2360                .unwrap_or_else(|| AgentCapabilityConfig::new(capability_id))
2361        })
2362        .collect())
2363}
2364
2365/// Helper function to resolve a single capability and its dependencies recursively.
2366fn resolve_single_capability(
2367    cap_id: &str,
2368    registry: &CapabilityRegistry,
2369    resolved: &mut Vec<String>,
2370    resolved_set: &mut std::collections::HashSet<String>,
2371    added_as_dependencies: &mut Vec<String>,
2372    user_selected: &std::collections::HashSet<String>,
2373    visiting: &mut Vec<String>,
2374) -> Result<(), DependencyError> {
2375    // Normalize aliases to the canonical ID so an alias and its canonical ID
2376    // resolve (and dedupe) to the same capability. Unknown IDs (declarative,
2377    // MCP, skill refs) pass through unchanged.
2378    let cap_id = registry.canonical_id(cap_id).unwrap_or(cap_id);
2379
2380    // Already resolved
2381    if resolved_set.contains(cap_id) {
2382        return Ok(());
2383    }
2384
2385    // Check for circular dependency
2386    if visiting.contains(&cap_id.to_string()) {
2387        return Err(DependencyError::CircularDependency {
2388            capability_id: cap_id.to_string(),
2389            chain: visiting.clone(),
2390        });
2391    }
2392
2393    // Get capability from registry
2394    let capability = match registry.get(cap_id) {
2395        Some(cap) => cap,
2396        None => {
2397            // `declarative:` and `plugin:` refs carry their full definition in
2398            // the config payload — they don't need a registry entry. Pass them
2399            // through so `collect_capabilities_with_configs` can process them.
2400            if (is_declarative_capability(cap_id) || is_plugin_capability(cap_id))
2401                && !resolved_set.contains(cap_id)
2402            {
2403                resolved.push(cap_id.to_string());
2404                resolved_set.insert(cap_id.to_string());
2405                if !user_selected.contains(cap_id) {
2406                    added_as_dependencies.push(cap_id.to_string());
2407                }
2408            }
2409            return Ok(());
2410        }
2411    };
2412
2413    // Mark as visiting
2414    visiting.push(cap_id.to_string());
2415
2416    // Resolve dependencies first (depth-first)
2417    for dep_id in capability.dependencies() {
2418        resolve_single_capability(
2419            dep_id,
2420            registry,
2421            resolved,
2422            resolved_set,
2423            added_as_dependencies,
2424            user_selected,
2425            visiting,
2426        )?;
2427    }
2428
2429    // Remove from visiting
2430    visiting.pop();
2431
2432    // Add to resolved
2433    if !resolved_set.contains(cap_id) {
2434        resolved.push(cap_id.to_string());
2435        resolved_set.insert(cap_id.to_string());
2436
2437        // Track if this was added as a dependency (not user-selected)
2438        if !user_selected.contains(cap_id) {
2439            added_as_dependencies.push(cap_id.to_string());
2440        }
2441    }
2442
2443    Ok(())
2444}
2445
2446/// Compute the aggregated set of UI features from a list of capability IDs.
2447///
2448/// Resolves dependencies, collects features from all resolved capabilities,
2449/// and returns deduplicated feature strings.
2450pub fn compute_features(capability_ids: &[String], registry: &CapabilityRegistry) -> Vec<String> {
2451    use std::collections::HashSet;
2452
2453    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2454        Ok(resolved) => resolved.resolved_ids,
2455        Err(_) => capability_ids.to_vec(),
2456    };
2457
2458    let mut seen = HashSet::new();
2459    let mut features = Vec::new();
2460    for cap_id in &resolved_ids {
2461        if let Some(cap) = registry.get(cap_id) {
2462            for feature in cap.features() {
2463                if seen.insert(feature) {
2464                    features.push(feature.to_string());
2465                }
2466            }
2467        }
2468    }
2469    features
2470}
2471
2472/// Get direct dependencies for a capability ID.
2473/// Returns empty vec if capability not found.
2474pub fn get_dependencies(cap_id: &str, registry: &CapabilityRegistry) -> Vec<String> {
2475    registry
2476        .get(cap_id)
2477        .map(|cap| cap.dependencies().iter().map(|s| s.to_string()).collect())
2478        .unwrap_or_default()
2479}
2480
2481/// Collect contributions from capabilities without applying them.
2482///
2483/// Resolves dependencies first, then calls `system_prompt_contribution()` (async)
2484/// on each capability, enabling dynamic content generation based on session context
2485/// (e.g., reading AGENTS.md, discovering skills).
2486///
2487/// Note: This function does not collect message filter providers since it doesn't
2488/// have access to per-agent capability configs. Use `collect_capabilities_with_configs`
2489/// if you need message filter providers.
2490///
2491/// # Arguments
2492///
2493/// * `capability_ids` - Ordered list of capability IDs to collect
2494/// * `registry` - The capability registry containing implementations
2495/// * `ctx` - Session context for dynamic prompt resolution
2496pub async fn collect_capabilities(
2497    capability_ids: &[String],
2498    registry: &CapabilityRegistry,
2499    ctx: &SystemPromptContext,
2500) -> CollectedCapabilities {
2501    // Resolve dependencies so that transitive capabilities (e.g. session_storage
2502    // via browserless) are included automatically.
2503    let resolved_ids = match resolve_dependencies(capability_ids, registry) {
2504        Ok(resolved) => resolved.resolved_ids,
2505        Err(e) => {
2506            tracing::warn!("Failed to resolve capability dependencies: {}", e);
2507            capability_ids.to_vec()
2508        }
2509    };
2510
2511    // Convert to AgentCapabilityConfig with empty configs
2512    let configs: Vec<AgentCapabilityConfig> = resolved_ids
2513        .iter()
2514        .map(|id| {
2515            AgentCapabilityConfig::with_config(
2516                CapabilityId::new(id),
2517                serde_json::Value::Object(serde_json::Map::new()),
2518            )
2519        })
2520        .collect();
2521
2522    collect_capabilities_with_configs(&configs, registry, ctx).await
2523}
2524
2525/// Collect contributions from capabilities with their per-agent configurations.
2526///
2527/// Calls `system_prompt_contribution()` (async) on each capability, enabling
2528/// dynamic content generation based on session context.
2529///
2530/// # Arguments
2531///
2532/// * `capability_configs` - Ordered list of capability configs (ID + per-agent config)
2533/// * `registry` - The capability registry containing implementations
2534/// * `ctx` - Session context for dynamic prompt resolution
2535pub async fn collect_capabilities_with_configs(
2536    capability_configs: &[AgentCapabilityConfig],
2537    registry: &CapabilityRegistry,
2538    ctx: &SystemPromptContext,
2539) -> CollectedCapabilities {
2540    let mut system_prompt_parts: Vec<String> = Vec::new();
2541    let mut system_prompt_attributions: Vec<SystemPromptAttribution> = Vec::new();
2542    let mut conversation_context_parts: Vec<String> = Vec::new();
2543    let mut conversation_context_attributions: Vec<SystemPromptAttribution> = Vec::new();
2544    let mut tools: Vec<Box<dyn Tool>> = Vec::new();
2545    let mut tool_definitions: Vec<ToolDefinition> = Vec::new();
2546    let mut mounts: Vec<MountPoint> = Vec::new();
2547    let mut message_filter_providers: Vec<(Arc<dyn MessageFilterProvider>, serde_json::Value)> =
2548        Vec::new();
2549    let mut applied_ids: Vec<String> = Vec::new();
2550    let mut tool_search: Option<crate::driver_registry::ToolSearchConfig> = None;
2551    let mut prompt_cache: Option<crate::driver_registry::PromptCacheConfig> = None;
2552    let mut driver_options: HashMap<String, serde_json::Value> = HashMap::new();
2553    let mut parallel_tool_calls: Option<bool> = None;
2554    let mut tool_definition_hooks: Vec<Arc<dyn ToolDefinitionHook>> = Vec::new();
2555    let mut tool_call_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2556    // Per-capability narration adapters, appended after explicit tool-call
2557    // hooks so model-authored narration (human_intent) keeps precedence.
2558    let mut narration_hooks: Vec<Arc<dyn ToolCallHook>> = Vec::new();
2559    let mut mcp_servers = ScopedMcpServers::default();
2560    // Facts contributed by capabilities. Static facts fold into the cached
2561    // system prompt below; a single note is added when any dynamic fact exists,
2562    // explaining the live `<facts>` block that `ReasonAtom` appends per turn.
2563    let mut static_facts: Vec<Fact> = Vec::new();
2564    let mut has_dynamic_facts = false;
2565    let facts_ctx = FactsContext::new(ctx.session_id);
2566    let compaction_on = compaction_is_enabled(capability_configs, registry);
2567    let mut delegation_targets: Vec<DelegationTargetProvider> = Vec::new();
2568
2569    for cap_config in capability_configs {
2570        let cap_id = cap_config.capability_id();
2571        // `declarative:` and `plugin:` refs both carry a serialized
2572        // `DeclarativeCapabilityDefinition` in their config and execute through
2573        // the same runtime path. `plugin:` is handled first (more specific
2574        // prefix), then `declarative:`, then the registry lookup.
2575        if is_declarative_capability(cap_id) || is_plugin_capability(cap_id) {
2576            match serde_json::from_value::<DeclarativeCapabilityDefinition>(
2577                cap_config.config_value().clone(),
2578            ) {
2579                Ok(definition) => {
2580                    if !definition.status.is_active() {
2581                        continue;
2582                    }
2583
2584                    if let Some(prompt) = definition.system_prompt.as_deref() {
2585                        let contribution =
2586                            format!("<capability id=\"{}\">\n{}\n</capability>", cap_id, prompt);
2587                        system_prompt_attributions.push(SystemPromptAttribution {
2588                            capability_id: cap_id.to_string(),
2589                            content: contribution.clone(),
2590                        });
2591                        system_prompt_parts.push(contribution);
2592                    }
2593
2594                    mounts.extend(definition.mounts(cap_id));
2595                    if let Some(ref servers) = definition.mcp_servers {
2596                        mcp_servers = merge_scoped_mcp_servers(&mcp_servers, servers);
2597                    }
2598                    for skill in definition.skill_contributions() {
2599                        mounts.push(skill.to_mount(cap_id));
2600                    }
2601
2602                    applied_ids.push(cap_id.to_string());
2603                }
2604                Err(error) => {
2605                    tracing::warn!(
2606                        capability_id = %cap_id,
2607                        error = %error,
2608                        "Skipping invalid declarative/plugin capability config"
2609                    );
2610                }
2611            }
2612            continue;
2613        }
2614        if let Some(capability) = registry.get(cap_id) {
2615            // Skip inert capabilities: `ComingSoon` is not implemented yet and
2616            // `Retired` has been removed. Both resolve to a no-op rather than an
2617            // error so an agent that still references one keeps running.
2618            if !capability.status().is_active() {
2619                continue;
2620            }
2621
2622            // Model-adaptive dispatch: a capability may delegate its contributions
2623            // to a different underlying capability based on the agent's model
2624            // (e.g. `auto_tool_search` picks hosted vs client-side tool search).
2625            // Every contribution below is collected from `effective` (system prompt,
2626            // tools, hooks, tool definitions, mounts, MCP servers, skills, message
2627            // filters); for the common non-delegating case `effective` is just
2628            // `capability`. Driver preferences are also contributed through the
2629            // effective implementation's neutral trait methods, so a resolved
2630            // `auto_tool_search` behaves as whichever mechanism it became.
2631            // Attribution stays on the configured `cap_id`/`capability` so tools
2632            // surface under the capability the user actually configured.
2633            let effective: &dyn Capability =
2634                match capability.resolve_for_model(ctx.model.as_deref()) {
2635                    Some(inner) => inner,
2636                    None => capability.as_ref(),
2637                };
2638            let delegation_target =
2639                effective.delegation_target_with_config(cap_config.config_value());
2640
2641            // Collect dynamic system prompt contribution (config-aware, may read from filesystem)
2642            if let Some(contribution) = effective
2643                .system_prompt_contribution_with_config(ctx, cap_config.config_value())
2644                .await
2645            {
2646                system_prompt_attributions.push(SystemPromptAttribution {
2647                    capability_id: cap_id.to_string(),
2648                    content: contribution.clone(),
2649                });
2650                system_prompt_parts.push(contribution);
2651            }
2652
2653            // Collect conversation-context contribution (config-aware, may read
2654            // from filesystem). Renders as the leading user-role message of
2655            // every turn, never as system prompt, so untrusted workspace
2656            // content cannot share privilege with harness safety instructions.
2657            if let Some(contribution) = effective
2658                .conversation_context_contribution_with_config(ctx, cap_config.config_value())
2659                .await
2660            {
2661                conversation_context_attributions.push(SystemPromptAttribution {
2662                    capability_id: cap_id.to_string(),
2663                    content: contribution.clone(),
2664                });
2665                conversation_context_parts.push(contribution);
2666            }
2667
2668            // Collect declared facts. Static facts fold into the cached prompt
2669            // below; dynamic facts are re-collected per request by `ReasonAtom`
2670            // and appended at the conversation tail, so here we only note their
2671            // presence to add the explanatory system-prompt line.
2672            for fact in effective.facts(cap_config.config_value(), &facts_ctx) {
2673                match fact.volatility {
2674                    Volatility::Static => static_facts.push(fact),
2675                    Volatility::Dynamic => has_dynamic_facts = true,
2676                }
2677            }
2678
2679            // Collect tools and hooks (config-aware: capabilities can adapt based on per-agent config)
2680            tools.extend(effective.tools_with_config(cap_config.config_value()));
2681            if let Some(target) = delegation_target {
2682                delegation_targets.push(target);
2683            }
2684            tool_definition_hooks.extend(
2685                effective.tool_definition_hooks_with_context(ctx, cap_config.config_value()),
2686            );
2687            tool_call_hooks.extend(effective.tool_call_hooks());
2688            // Route this capability's `narrate()` through the hook channel.
2689            narration_hooks.push(Arc::new(CapabilityNarrationHook(capability.clone())));
2690            // Output guardrails are NOT collected here — see CollectedCapabilities
2691            // for rationale. ReasonAtom re-derives them at stream-arming time.
2692
2693            // Collect tool definitions, propagating capability category if not already set
2694            let cap_category = effective.category();
2695            for def in effective.tool_definitions() {
2696                let def = match (def.category(), cap_category) {
2697                    (None, Some(cat)) => def.with_category(cat),
2698                    _ => def,
2699                }
2700                .with_capability_attribution(cap_id, Some(capability.name()));
2701                tool_definitions.push(def);
2702            }
2703
2704            tool_search = effective
2705                .tool_search_config(cap_config.config_value())
2706                .or(tool_search);
2707            prompt_cache = effective
2708                .prompt_cache_config(cap_config.config_value())
2709                .or(prompt_cache);
2710            parallel_tool_calls = effective
2711                .parallel_tool_calls_preference(cap_config.config_value())
2712                .or(parallel_tool_calls);
2713
2714            for (key, value) in effective.driver_options(cap_config.config_value()) {
2715                driver_options.entry(key).or_insert(value);
2716            }
2717
2718            // Collect mount points
2719            mounts.extend(effective.mounts());
2720
2721            mcp_servers = merge_scoped_mcp_servers(
2722                &mcp_servers,
2723                &effective.mcp_servers_with_config(cap_config.config_value()),
2724            );
2725
2726            // Normalize capability-contributed skills into mount points under
2727            // `/.agents/skills/{name}/`. Discovery/activation stays with the
2728            // built-in `skills` capability — see knowledge/project/skills-registry.md.
2729            for skill in effective.contribute_skills() {
2730                mounts.push(skill.to_mount(cap_id));
2731            }
2732
2733            // Collect message filter provider
2734            if let Some(provider) = effective.message_filter_provider() {
2735                let config =
2736                    effective.message_filter_config(cap_config.config_value(), compaction_on);
2737                message_filter_providers.push((provider, config));
2738            }
2739
2740            applied_ids.push(cap_id.to_string());
2741        }
2742    }
2743
2744    // Delegation providers share one model-facing `spawn_agent` dispatcher.
2745    // Unknown tools with that name still win to preserve their contract.
2746    if !tools.iter().any(|tool| tool.name() == "spawn_agent") && !delegation_targets.is_empty() {
2747        let tool = UnifiedSpawnAgentTool::new(delegation_targets);
2748        let def = tool
2749            .to_definition()
2750            .with_category("Orchestration")
2751            .with_capability_attribution("agent_delegation", Some("Agent Delegation"));
2752        tools.push(Box::new(tool));
2753        tool_definitions.push(def);
2754    }
2755
2756    // Auto-activated adapters are selected through a neutral capability hook;
2757    // core does not name or own the hosted implementation.
2758    let auto_activated: Vec<_> = registry
2759        .list()
2760        .into_iter()
2761        .filter(|cap| {
2762            !applied_ids.iter().any(|id| id == cap.id())
2763                && cap.status().is_active()
2764                && cap.auto_activates_for(&tool_definitions)
2765        })
2766        .cloned()
2767        .collect();
2768    for cap in auto_activated {
2769        tools.extend(cap.tools());
2770        let cap_category = cap.category();
2771        for def in cap.tool_definitions() {
2772            let def = match (def.category(), cap_category) {
2773                (None, Some(cat)) => def.with_category(cat),
2774                _ => def,
2775            }
2776            .with_capability_attribution(cap.id(), Some(cap.name()));
2777            tool_definitions.push(def);
2778        }
2779        narration_hooks.push(Arc::new(CapabilityNarrationHook(cap.clone())));
2780        applied_ids.push(cap.id().to_string());
2781    }
2782
2783    // Fold static facts into the cached system-prompt prefix, and add the
2784    // dynamic-facts note once when any capability declared a dynamic fact. Both
2785    // are stable across turns, so they stay in the cached prefix; the live
2786    // dynamic values are appended at the conversation tail per request.
2787    if let Some(block) = facts::render_facts_block(&static_facts) {
2788        system_prompt_attributions.push(SystemPromptAttribution {
2789            capability_id: "facts".to_string(),
2790            content: block.clone(),
2791        });
2792        system_prompt_parts.push(block);
2793    }
2794    if has_dynamic_facts {
2795        system_prompt_attributions.push(SystemPromptAttribution {
2796            capability_id: "facts".to_string(),
2797            content: FACTS_DYNAMIC_NOTE.to_string(),
2798        });
2799        system_prompt_parts.push(FACTS_DYNAMIC_NOTE.to_string());
2800    }
2801
2802    // Append per-capability narration adapters after every explicit tool-call
2803    // hook so capability-owned narration is consulted only once model-authored
2804    // hooks (human_intent) have had their say.
2805    tool_call_hooks.extend(narration_hooks);
2806
2807    // Sort message filter providers by priority (lower = earlier)
2808    message_filter_providers.sort_by_key(|(p, _)| p.priority());
2809
2810    CollectedCapabilities {
2811        system_prompt_parts,
2812        system_prompt_attributions,
2813        conversation_context_parts,
2814        conversation_context_attributions,
2815        tools,
2816        tool_definitions,
2817        mounts,
2818        message_filter_providers,
2819        applied_ids,
2820        tool_search,
2821        prompt_cache,
2822        driver_options,
2823        parallel_tool_calls,
2824        tool_definition_hooks,
2825        tool_call_hooks,
2826        mcp_servers,
2827    }
2828}
2829
2830// ============================================================================
2831// Apply Capabilities to RuntimeAgent
2832// ============================================================================
2833
2834/// Result of applying capabilities to a base runtime agent
2835pub struct AppliedCapabilities {
2836    /// The modified runtime agent with capability contributions merged
2837    pub runtime_agent: RuntimeAgent,
2838    /// Tool registry containing all capability tools
2839    pub tool_registry: ToolRegistry,
2840    /// IDs of capabilities that were applied
2841    pub applied_ids: Vec<String>,
2842}
2843
2844/// Apply capabilities to a base runtime agent configuration.
2845///
2846/// This function:
2847/// 1. Collects system prompt contributions from capabilities (in order)
2848/// 2. Appends them after the agent's base system prompt
2849/// 3. Collects all tools from capabilities
2850/// 4. Returns the modified runtime agent and a tool registry
2851///
2852/// # Arguments
2853///
2854/// * `base_runtime_agent` - The agent's base runtime configuration
2855/// * `capability_ids` - Ordered list of capability IDs to apply
2856/// * `registry` - The capability registry containing implementations
2857/// * `ctx` - Session context for dynamic prompt resolution
2858///
2859/// # Returns
2860///
2861/// An `AppliedCapabilities` struct containing the modified runtime agent,
2862/// tool registry, and list of applied capability IDs.
2863///
2864/// # Example
2865///
2866/// ```ignore
2867/// use everruns_core::capabilities::{apply_capabilities, CapabilityRegistry, SystemPromptContext};
2868/// use everruns_core::runtime_agent::RuntimeAgent;
2869///
2870/// let registry = CapabilityRegistry::new();
2871/// let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
2872/// let ctx = SystemPromptContext::without_file_store(SessionId::new());
2873///
2874/// let capability_ids = Vec::new();
2875/// let applied = apply_capabilities(base_runtime_agent, &capability_ids, &registry, &ctx).await;
2876///
2877/// assert!(applied.applied_ids.is_empty());
2878/// ```
2879pub async fn apply_capabilities(
2880    base_runtime_agent: RuntimeAgent,
2881    capability_ids: &[String],
2882    registry: &CapabilityRegistry,
2883    ctx: &SystemPromptContext,
2884) -> AppliedCapabilities {
2885    let collected = collect_capabilities(capability_ids, registry, ctx).await;
2886
2887    // Build final system prompt: base prompt first, then capability additions.
2888    let final_system_prompt = compose_system_prompt(
2889        &base_runtime_agent.system_prompt,
2890        collected.system_prompt_prefix().as_deref(),
2891    );
2892
2893    // Conversation context (e.g. hierarchical AGENTS.md) renders as the
2894    // leading user-role message, never as system prompt. Bound before fields
2895    // move out of `collected` below.
2896    let conversation_context = collected.conversation_context();
2897    // Build tool registry from collected tools
2898    let mut tool_registry = ToolRegistry::new();
2899    for tool in collected.tools {
2900        tool_registry.register_boxed(tool);
2901    }
2902
2903    // Create modified runtime agent
2904    let mut tools = collected.tool_definitions;
2905    for hook in &collected.tool_definition_hooks {
2906        tools = hook.transform(tools);
2907    }
2908
2909    let runtime_agent = RuntimeAgent {
2910        system_prompt: final_system_prompt,
2911        model: base_runtime_agent.model,
2912        tools,
2913        max_iterations: base_runtime_agent.max_iterations,
2914        temperature: base_runtime_agent.temperature,
2915        max_tokens: base_runtime_agent.max_tokens,
2916        tool_search: collected.tool_search,
2917        prompt_cache: collected.prompt_cache,
2918        driver_options: collected.driver_options,
2919        network_access: base_runtime_agent.network_access,
2920        // Explicit request-level preference (escape hatch) wins; otherwise the
2921        // `parallel_tool_calls` capability supplies the preference.
2922        parallel_tool_calls: base_runtime_agent
2923            .parallel_tool_calls
2924            .or(collected.parallel_tool_calls),
2925        // Conversation context (e.g. hierarchical AGENTS.md) renders as the
2926        // leading user-role message, never as system prompt.
2927        conversation_context,
2928    };
2929
2930    AppliedCapabilities {
2931        runtime_agent,
2932        tool_registry,
2933        applied_ids: collected.applied_ids,
2934    }
2935}
2936
2937// ============================================================================
2938// Tests
2939// ============================================================================
2940
2941#[cfg(test)]
2942mod tests {
2943    use super::*;
2944    use crate::typed_id::SessionId;
2945    use uuid::Uuid;
2946
2947    /// Test helper: dummy context with no file store
2948    fn test_ctx() -> SystemPromptContext {
2949        SystemPromptContext::without_file_store(SessionId::new())
2950    }
2951
2952    // -------------------------------------------------------------------------
2953    // Local stand-ins for the fixture capabilities that moved to the
2954    // `everruns-test-support` crate (EVE-875). The registry/apply/dependency
2955    // mechanics tested here only need capabilities with these shapes: one
2956    // that contributes nothing, one that contributes plain tools, and one
2957    // that carries mounts plus a dependency.
2958    // -------------------------------------------------------------------------
2959
2960    struct StubSubagentSpawnTool;
2961
2962    #[async_trait]
2963    impl Tool for StubSubagentSpawnTool {
2964        fn name(&self) -> &str {
2965            "spawn_agent"
2966        }
2967        fn description(&self) -> &str {
2968            "stub subagent delegation"
2969        }
2970        fn parameters_schema(&self) -> serde_json::Value {
2971            serde_json::json!({ "type": "object" })
2972        }
2973        fn narrate(
2974            &self,
2975            tool_call: &ToolCall,
2976            phase: crate::tool_narration::ToolNarrationPhase,
2977            locale: Option<&str>,
2978            _ctx: crate::tool_narration::ToolNarrationContext<'_>,
2979        ) -> Option<String> {
2980            Some(crate::tool_narration::narrate_subagent_spawn(
2981                &tool_call.arguments,
2982                phase,
2983                locale,
2984            ))
2985        }
2986        async fn execute(&self, _arguments: serde_json::Value) -> crate::ToolExecutionResult {
2987            crate::ToolExecutionResult::success(serde_json::json!({}))
2988        }
2989    }
2990
2991    fn spawn_agent_call(arguments: serde_json::Value) -> ToolCall {
2992        ToolCall {
2993            id: "call-1".to_string(),
2994            name: "spawn_agent".to_string(),
2995            arguments,
2996        }
2997    }
2998
2999    /// The dispatcher always names the agent being spawned, including when the
3000    /// call carries no usable `target.type` yet.
3001    #[test]
3002    fn unified_spawn_agent_narration_names_the_agent() {
3003        let tool = UnifiedSpawnAgentTool::new(vec![DelegationTargetProvider {
3004            target_type: "subagent",
3005            tool: Box::new(StubSubagentSpawnTool),
3006        }]);
3007        let ctx = crate::tool_narration::ToolNarrationContext::default();
3008
3009        assert_eq!(
3010            tool.narrate(
3011                &spawn_agent_call(serde_json::json!({
3012                    "name": "Orbit Scout",
3013                    "target": { "type": "subagent" },
3014                    "blueprint": "github_scout"
3015                })),
3016                crate::tool_narration::ToolNarrationPhase::Started,
3017                None,
3018                ctx,
3019            )
3020            .as_deref(),
3021            Some("Launching Orbit Scout subagent (github_scout)")
3022        );
3023
3024        assert_eq!(
3025            tool.narrate(
3026                &spawn_agent_call(serde_json::json!({ "name": "Orbit Scout" })),
3027                crate::tool_narration::ToolNarrationPhase::Started,
3028                None,
3029                ctx,
3030            )
3031            .as_deref(),
3032            Some("Launching Orbit Scout subagent")
3033        );
3034    }
3035
3036    #[test]
3037    fn unified_spawn_agent_rejects_subagent_fields_for_configured_targets() {
3038        for target_type in ["agent", "external_a2a"] {
3039            let arguments = serde_json::json!({
3040                "target": { "type": target_type, "id": "actual-target" },
3041                "blueprint": "decoy-target"
3042            });
3043            assert_eq!(
3044                validate_spawn_agent_target_fields(&arguments, target_type),
3045                Err(format!(
3046                    "blueprint is only valid for subagent targets, not {target_type}."
3047                ))
3048            );
3049
3050            let arguments = serde_json::json!({
3051                "target": { "type": target_type, "id": "actual-target" },
3052                "config": { "model": "decoy" }
3053            });
3054            assert_eq!(
3055                validate_spawn_agent_target_fields(&arguments, target_type),
3056                Err(format!(
3057                    "config is only valid for subagent targets, not {target_type}."
3058                ))
3059            );
3060        }
3061    }
3062
3063    /// Contributes nothing: no tools, no prompt, no dependencies.
3064    struct NoopFixture;
3065
3066    impl Capability for NoopFixture {
3067        fn id(&self) -> &str {
3068            "noop"
3069        }
3070        fn name(&self) -> &str {
3071            "No-Op"
3072        }
3073        fn description(&self) -> &str {
3074            "Contributes nothing."
3075        }
3076    }
3077
3078    /// Declares one arbitrary feature to exercise core's neutral projection.
3079    struct FeatureFixture;
3080
3081    impl Capability for FeatureFixture {
3082        fn id(&self) -> &str {
3083            "feature_fixture"
3084        }
3085        fn name(&self) -> &str {
3086            "Feature Fixture"
3087        }
3088        fn description(&self) -> &str {
3089            "Declares one test-only feature."
3090        }
3091        fn features(&self) -> Vec<&'static str> {
3092            vec!["fixture_feature"]
3093        }
3094    }
3095
3096    struct FixtureTool(&'static str);
3097
3098    #[async_trait]
3099    impl Tool for FixtureTool {
3100        fn name(&self) -> &str {
3101            self.0
3102        }
3103        fn description(&self) -> &str {
3104            "Fixture tool."
3105        }
3106        fn parameters_schema(&self) -> serde_json::Value {
3107            serde_json::json!({
3108                "type": "object",
3109                "properties": {},
3110                "additionalProperties": false
3111            })
3112        }
3113        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3114            ToolExecutionResult::success(serde_json::json!({ "ok": true }))
3115        }
3116    }
3117
3118    struct BackgroundFixtureTool;
3119
3120    #[async_trait]
3121    impl Tool for BackgroundFixtureTool {
3122        fn name(&self) -> &str {
3123            "bash"
3124        }
3125        fn description(&self) -> &str {
3126            "Fixture background-capable shell tool."
3127        }
3128        fn parameters_schema(&self) -> serde_json::Value {
3129            serde_json::json!({"type": "object"})
3130        }
3131        async fn execute(&self, _arguments: serde_json::Value) -> ToolExecutionResult {
3132            ToolExecutionResult::success(serde_json::json!({"ok": true}))
3133        }
3134        fn hints(&self) -> crate::tool_types::ToolHints {
3135            crate::tool_types::ToolHints {
3136                supports_background: Some(true),
3137                ..Default::default()
3138            }
3139        }
3140    }
3141
3142    struct FileSystemFixture;
3143
3144    impl Capability for FileSystemFixture {
3145        fn id(&self) -> &str {
3146            "session_file_system"
3147        }
3148        fn name(&self) -> &str {
3149            "Fixture Filesystem"
3150        }
3151        fn description(&self) -> &str {
3152            "Fixture filesystem capability."
3153        }
3154        fn tools(&self) -> Vec<Box<dyn Tool>> {
3155            vec![
3156                Box::new(FixtureTool("read_file")),
3157                Box::new(FixtureTool("write_file")),
3158            ]
3159        }
3160        fn features(&self) -> Vec<&'static str> {
3161            vec!["file_system"]
3162        }
3163    }
3164
3165    /// Stands in for the product `session_storage` capability, which moved to
3166    /// `everruns-platform` with the other service-backed families (EVE-886).
3167    /// Feature computation is core's mechanism, so it is exercised here against
3168    /// a fixture rather than a product implementation.
3169    struct StorageFixture;
3170
3171    impl Capability for StorageFixture {
3172        fn id(&self) -> &str {
3173            "session_storage"
3174        }
3175        fn name(&self) -> &str {
3176            "Fixture Storage"
3177        }
3178        fn description(&self) -> &str {
3179            "Fixture session storage capability."
3180        }
3181        fn features(&self) -> Vec<&'static str> {
3182            vec!["secrets", "key_value"]
3183        }
3184    }
3185
3186    struct BashFixture;
3187
3188    impl Capability for BashFixture {
3189        fn id(&self) -> &str {
3190            "bashkit_shell"
3191        }
3192        fn aliases(&self) -> Vec<&'static str> {
3193            vec!["virtual_bash"]
3194        }
3195        fn name(&self) -> &str {
3196            "Fixture Bash"
3197        }
3198        fn description(&self) -> &str {
3199            "Fixture shell capability."
3200        }
3201        fn tools(&self) -> Vec<Box<dyn Tool>> {
3202            vec![Box::new(BackgroundFixtureTool)]
3203        }
3204        fn dependencies(&self) -> Vec<&'static str> {
3205            vec!["session_file_system"]
3206        }
3207        fn features(&self) -> Vec<&'static str> {
3208            vec!["file_system"]
3209        }
3210        fn risk_level(&self) -> RiskLevel {
3211            RiskLevel::High
3212        }
3213    }
3214
3215    struct WebFetchFixture;
3216
3217    impl Capability for WebFetchFixture {
3218        fn id(&self) -> &str {
3219            "web_fetch"
3220        }
3221        fn name(&self) -> &str {
3222            "Fixture Web Fetch"
3223        }
3224        fn description(&self) -> &str {
3225            "Fixture web capability."
3226        }
3227        fn risk_level(&self) -> RiskLevel {
3228            RiskLevel::High
3229        }
3230    }
3231
3232    /// Portable-policy-shaped stand-ins used only to exercise neutral core
3233    /// collection mechanics after policy implementations moved out of core.
3234    struct DynamicFactFixture;
3235
3236    impl Capability for DynamicFactFixture {
3237        fn id(&self) -> &str {
3238            "current_time"
3239        }
3240        fn name(&self) -> &str {
3241            "Dynamic Fact Fixture"
3242        }
3243        fn description(&self) -> &str {
3244            "Fixture with one dynamic fact and one tool."
3245        }
3246        fn icon(&self) -> Option<&str> {
3247            Some("clock")
3248        }
3249        fn category(&self) -> Option<&str> {
3250            Some("Core")
3251        }
3252        fn tools(&self) -> Vec<Box<dyn Tool>> {
3253            vec![Box::new(FixtureTool("get_current_time"))]
3254        }
3255        fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3256            vec![Fact::dynamic("current_time", "fixture-now")]
3257        }
3258    }
3259
3260    struct PromptToolFixture;
3261
3262    impl Capability for PromptToolFixture {
3263        fn id(&self) -> &str {
3264            "prompt_tool_fixture"
3265        }
3266        fn name(&self) -> &str {
3267            "Prompt Tool Fixture"
3268        }
3269        fn description(&self) -> &str {
3270            "Fixture with a static prompt and tool."
3271        }
3272        fn system_prompt_addition(&self) -> Option<&str> {
3273            Some("Task Management uses the write_todos tool.")
3274        }
3275        fn tools(&self) -> Vec<Box<dyn Tool>> {
3276            vec![Box::new(FixtureTool("write_todos"))]
3277        }
3278    }
3279
3280    struct SecondPromptFixture;
3281
3282    impl Capability for SecondPromptFixture {
3283        fn id(&self) -> &str {
3284            "second_prompt_fixture"
3285        }
3286        fn name(&self) -> &str {
3287            "Second Prompt Fixture"
3288        }
3289        fn description(&self) -> &str {
3290            "Fixture with a second static prompt."
3291        }
3292        fn system_prompt_addition(&self) -> Option<&str> {
3293            Some("A second capability prompt contribution.")
3294        }
3295    }
3296
3297    struct DynamicPreviewFixture;
3298
3299    impl Capability for DynamicPreviewFixture {
3300        fn id(&self) -> &str {
3301            "agent_instructions"
3302        }
3303        fn name(&self) -> &str {
3304            "Dynamic Preview Fixture"
3305        }
3306        fn description(&self) -> &str {
3307            "Fixture whose runtime prompt is dynamic."
3308        }
3309        fn system_prompt_preview(&self) -> Option<String> {
3310            Some("Reads AGENTS.md dynamically.".to_string())
3311        }
3312    }
3313
3314    /// Contributes four plain calculator-style tools and no prompt addition.
3315    struct MathFixture;
3316
3317    impl Capability for MathFixture {
3318        fn id(&self) -> &str {
3319            "test_math"
3320        }
3321        fn name(&self) -> &str {
3322            "Test Math"
3323        }
3324        fn description(&self) -> &str {
3325            "Fixture: calculator tools."
3326        }
3327        fn tools(&self) -> Vec<Box<dyn Tool>> {
3328            vec![
3329                Box::new(FixtureTool("add")),
3330                Box::new(FixtureTool("subtract")),
3331                Box::new(FixtureTool("multiply")),
3332                Box::new(FixtureTool("divide")),
3333            ]
3334        }
3335    }
3336
3337    /// Contributes two plain tools.
3338    struct WeatherFixture;
3339
3340    impl Capability for WeatherFixture {
3341        fn id(&self) -> &str {
3342            "test_weather"
3343        }
3344        fn name(&self) -> &str {
3345            "Test Weather"
3346        }
3347        fn description(&self) -> &str {
3348            "Fixture: weather tools."
3349        }
3350        fn tools(&self) -> Vec<Box<dyn Tool>> {
3351            vec![
3352                Box::new(FixtureTool("get_weather")),
3353                Box::new(FixtureTool("get_forecast")),
3354            ]
3355        }
3356    }
3357
3358    /// Carries a read-only mount, a prompt addition, a feature, and a
3359    /// dependency on `session_file_system`.
3360    struct SampleDataFixture;
3361
3362    impl Capability for SampleDataFixture {
3363        fn id(&self) -> &str {
3364            "sample_data"
3365        }
3366        fn name(&self) -> &str {
3367            "Sample Data"
3368        }
3369        fn description(&self) -> &str {
3370            "Fixture: mounted sample files."
3371        }
3372        fn system_prompt_addition(&self) -> Option<&str> {
3373            Some("Read-only sample files are mounted at `/samples`.")
3374        }
3375        fn mounts(&self) -> Vec<MountPoint> {
3376            let samples_dir = MountDirectoryBuilder::new()
3377                .file("users.json", "[]")
3378                .build();
3379            vec![MountPoint::readonly("/samples", samples_dir, self.id())]
3380        }
3381        fn dependencies(&self) -> Vec<&'static str> {
3382            vec!["session_file_system"]
3383        }
3384        fn features(&self) -> Vec<&'static str> {
3385            vec!["file_system"]
3386        }
3387    }
3388
3389    /// Registry of local contribution fixtures.
3390    fn fixture_registry() -> CapabilityRegistry {
3391        let mut registry = CapabilityRegistry::new();
3392        registry.register(NoopFixture);
3393        registry.register(FeatureFixture);
3394        registry.register(MathFixture);
3395        registry.register(WeatherFixture);
3396        registry.register(SampleDataFixture);
3397        registry.register(FileSystemFixture);
3398        registry.register(StorageFixture);
3399        registry.register(BashFixture);
3400        registry.register(WebFetchFixture);
3401        registry.register(DynamicFactFixture);
3402        registry.register(PromptToolFixture);
3403        registry.register(SecondPromptFixture);
3404        registry.register(DynamicPreviewFixture);
3405        registry
3406    }
3407
3408    /// A host-defined capability carrying annotations core knows nothing about.
3409    struct HostAnnotatedCapability;
3410
3411    #[async_trait]
3412    impl Capability for HostAnnotatedCapability {
3413        fn id(&self) -> &str {
3414            "host_annotated"
3415        }
3416        fn name(&self) -> &str {
3417            "Host Annotated"
3418        }
3419        fn description(&self) -> &str {
3420            "Test capability with host-owned metadata."
3421        }
3422        fn metadata(&self) -> Option<serde_json::Value> {
3423            Some(serde_json::json!({"icon": "sparkles", "group": "host"}))
3424        }
3425    }
3426
3427    #[test]
3428    fn test_capability_registry_get() {
3429        let mut registry = CapabilityRegistry::new();
3430        registry.register(NoopFixture);
3431
3432        let capability = registry.get("noop").unwrap();
3433        assert_eq!(capability.id(), "noop");
3434        assert_eq!(capability.status(), CapabilityStatus::Available);
3435    }
3436
3437    #[test]
3438    fn default_registry_is_empty_and_selects_no_product_preset() {
3439        assert!(CapabilityRegistry::default().is_empty());
3440        assert!(CapabilityRegistryBuilder::default().build().is_empty());
3441    }
3442
3443    fn blueprint_with_schema(config_schema: Option<serde_json::Value>) -> AgentBlueprint {
3444        AgentBlueprint {
3445            id: "test_blueprint",
3446            name: "Test Blueprint",
3447            description: "Blueprint for config validation tests",
3448            model: BlueprintModel::Inherit,
3449            system_prompt: "Test prompt",
3450            tools: vec![],
3451            max_turns: None,
3452            config_schema,
3453        }
3454    }
3455
3456    #[test]
3457    fn blueprint_without_schema_accepts_no_config() {
3458        let blueprint = blueprint_with_schema(None);
3459
3460        assert!(blueprint.validate_config(None).is_ok());
3461        assert!(matches!(
3462            blueprint.validate_config(Some(&serde_json::json!({"depth": "focused"}))),
3463            Err(BlueprintConfigError::NotAccepted { .. })
3464        ));
3465    }
3466
3467    #[test]
3468    fn blueprint_config_is_required_only_when_the_schema_says_so() {
3469        let required = blueprint_with_schema(Some(
3470            serde_json::json!({"type": "object", "required": ["repository"]}),
3471        ));
3472        assert!(matches!(
3473            required.validate_config(None),
3474            Err(BlueprintConfigError::Required { .. })
3475        ));
3476
3477        let optional = blueprint_with_schema(Some(serde_json::json!({"type": "object"})));
3478        assert!(optional.validate_config(None).is_ok());
3479    }
3480
3481    #[test]
3482    fn blueprint_config_is_validated_against_the_schema() {
3483        let blueprint = blueprint_with_schema(Some(serde_json::json!({
3484            "type": "object",
3485            "properties": {
3486                "max_candidates": {"type": "integer", "minimum": 1, "maximum": 50}
3487            },
3488            "additionalProperties": false
3489        })));
3490
3491        assert!(
3492            blueprint
3493                .validate_config(Some(&serde_json::json!({"max_candidates": 10})))
3494                .is_ok()
3495        );
3496
3497        // Out-of-range values and unknown keys are both contract violations.
3498        let Err(BlueprintConfigError::Invalid { issues, .. }) =
3499            blueprint.validate_config(Some(&serde_json::json!({"max_candidates": 500})))
3500        else {
3501            panic!("out-of-range config should be rejected");
3502        };
3503        assert!(
3504            issues.iter().any(|issue| issue.contains("max_candidates")),
3505            "issue should name the offending property: {issues:?}"
3506        );
3507
3508        assert!(matches!(
3509            blueprint.validate_config(Some(&serde_json::json!({"unknown": true}))),
3510            Err(BlueprintConfigError::Invalid { .. })
3511        ));
3512    }
3513
3514    #[test]
3515    fn blueprint_with_unusable_schema_reports_it() {
3516        let blueprint = blueprint_with_schema(Some(serde_json::json!({"type": 42})));
3517
3518        assert!(matches!(
3519            blueprint.validate_config(Some(&serde_json::json!({}))),
3520            Err(BlueprintConfigError::InvalidSchema { .. })
3521        ));
3522    }
3523
3524    #[tokio::test]
3525    async fn test_capability_registry_blueprint_with_capability() {
3526        struct BlueprintProviderCapability;
3527
3528        impl Capability for BlueprintProviderCapability {
3529            fn id(&self) -> &str {
3530                "blueprint_provider"
3531            }
3532            fn name(&self) -> &str {
3533                "Blueprint Provider"
3534            }
3535            fn description(&self) -> &str {
3536                "Capability that provides a blueprint for tests"
3537            }
3538            fn agent_blueprints(&self) -> Vec<AgentBlueprint> {
3539                vec![AgentBlueprint {
3540                    id: "test_blueprint",
3541                    name: "Test Blueprint",
3542                    description: "Blueprint for capability registry tests",
3543                    model: BlueprintModel::Fixed("specialist-model".into()),
3544                    system_prompt: "Test prompt",
3545                    tools: vec![Box::new(FixtureTool("private_lookup"))],
3546                    max_turns: Some(7),
3547                    config_schema: Some(
3548                        serde_json::json!({"type":"object", "required":["repository"]}),
3549                    ),
3550                }]
3551            }
3552        }
3553
3554        let mut registry = CapabilityRegistry::new();
3555        registry.register(BlueprintProviderCapability);
3556
3557        let (capability_id, blueprint) = registry
3558            .blueprint_with_capability("test_blueprint")
3559            .expect("blueprint should resolve with capability id");
3560        assert_eq!(capability_id, "blueprint_provider");
3561        assert_eq!(blueprint.id, "test_blueprint");
3562        assert_eq!(blueprint.name, "Test Blueprint");
3563        assert_eq!(
3564            blueprint.description,
3565            "Blueprint for capability registry tests"
3566        );
3567        assert_eq!(blueprint.system_prompt, "Test prompt");
3568        assert!(
3569            matches!(&blueprint.model, BlueprintModel::Fixed(model) if model == "specialist-model")
3570        );
3571        assert_eq!(blueprint.max_turns, Some(7));
3572        assert_eq!(
3573            blueprint.config_schema,
3574            Some(serde_json::json!({"type":"object", "required":["repository"]}))
3575        );
3576        let definitions = blueprint.tool_definitions();
3577        assert_eq!(definitions.len(), 1);
3578        assert_eq!(definitions[0].name(), "private_lookup");
3579        assert_eq!(
3580            registry.blueprint("test_blueprint").unwrap().tools[0].name(),
3581            "private_lookup"
3582        );
3583        assert_eq!(
3584            registry
3585                .all_blueprints()
3586                .iter()
3587                .map(|b| b.id)
3588                .collect::<Vec<_>>(),
3589            ["test_blueprint"]
3590        );
3591        assert!(registry.blueprint_with_capability("missing").is_none());
3592        assert!(registry.blueprint("missing").is_none());
3593        let host =
3594            collect_capabilities(&["blueprint_provider".into()], &registry, &test_ctx()).await;
3595        assert!(host.tools.is_empty());
3596        assert!(host.tool_definitions.is_empty());
3597    }
3598
3599    #[test]
3600    fn test_capability_registry_builder() {
3601        let registry = CapabilityRegistry::builder()
3602            .capability(NoopFixture)
3603            .build();
3604
3605        assert!(registry.has("noop"));
3606        assert_eq!(registry.len(), 1);
3607    }
3608
3609    #[test]
3610    fn test_system_prompt_preview_default_delegates_to_addition() {
3611        // A capability with a static system_prompt_addition — preview should
3612        // match the addition by default.
3613        struct StaticPromptCapability;
3614        impl Capability for StaticPromptCapability {
3615            fn id(&self) -> &str {
3616                "static_prompt"
3617            }
3618            fn name(&self) -> &str {
3619                "Static Prompt"
3620            }
3621            fn description(&self) -> &str {
3622                "Static prompt addition."
3623            }
3624            fn system_prompt_addition(&self) -> Option<&str> {
3625                Some("Use the static prompt.")
3626            }
3627        }
3628
3629        let cap = StaticPromptCapability;
3630        assert_eq!(
3631            cap.system_prompt_preview().as_deref(),
3632            Some("Use the static prompt.")
3633        );
3634
3635        // current_time has no system_prompt_addition — preview should be None
3636        let registry = fixture_registry();
3637        let current_time = registry.get("current_time").unwrap();
3638        assert!(current_time.system_prompt_preview().is_none());
3639        assert!(current_time.system_prompt_addition().is_none());
3640    }
3641
3642    // =========================================================================
3643    // apply_capabilities tests
3644    // =========================================================================
3645
3646    #[tokio::test]
3647    async fn test_apply_capabilities_empty() {
3648        let registry = CapabilityRegistry::new();
3649        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3650
3651        let applied =
3652            apply_capabilities(base_runtime_agent.clone(), &[], &registry, &test_ctx()).await;
3653
3654        assert_eq!(
3655            applied.runtime_agent.system_prompt,
3656            base_runtime_agent.system_prompt
3657        );
3658        assert!(applied.tool_registry.is_empty());
3659        assert!(applied.applied_ids.is_empty());
3660    }
3661
3662    #[tokio::test]
3663    async fn test_apply_capabilities_noop() {
3664        let registry = fixture_registry();
3665        let mut base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3666
3667        base_runtime_agent.max_iterations = 13;
3668        base_runtime_agent.temperature = Some(0.25);
3669        base_runtime_agent.max_tokens = Some(1234);
3670        base_runtime_agent.parallel_tool_calls = Some(false);
3671        let applied = apply_capabilities(
3672            base_runtime_agent.clone(),
3673            &["noop".to_string()],
3674            &registry,
3675            &test_ctx(),
3676        )
3677        .await;
3678
3679        // Noop has no system prompt addition or tools
3680        assert_eq!(
3681            applied.runtime_agent.system_prompt,
3682            base_runtime_agent.system_prompt
3683        );
3684        assert!(applied.tool_registry.is_empty());
3685        assert_eq!(applied.applied_ids, vec!["noop"]);
3686        assert_eq!(
3687            serde_json::to_value(&applied.runtime_agent).unwrap(),
3688            serde_json::to_value(&base_runtime_agent).unwrap()
3689        );
3690        let collected = collect_capabilities(&["noop".into()], &registry, &test_ctx()).await;
3691        assert!(collected.mounts.is_empty());
3692        assert!(collected.message_filter_providers.is_empty());
3693        assert!(compute_features(&["noop".into()], &registry).is_empty());
3694    }
3695
3696    #[tokio::test]
3697    async fn test_apply_capabilities_current_time() {
3698        let registry = fixture_registry();
3699        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3700
3701        let applied = apply_capabilities(
3702            base_runtime_agent.clone(),
3703            &["current_time".to_string()],
3704            &registry,
3705            &test_ctx(),
3706        )
3707        .await;
3708
3709        // CurrentTime contributes a dynamic `current_time` fact, so the cached
3710        // prompt gains the explanatory facts note (the live value is appended at
3711        // the conversation tail per request). It also keeps its tool.
3712        assert!(
3713            applied
3714                .runtime_agent
3715                .system_prompt
3716                .contains(FACTS_DYNAMIC_NOTE),
3717            "current_time should contribute the dynamic-facts note"
3718        );
3719        assert!(
3720            applied
3721                .runtime_agent
3722                .system_prompt
3723                .contains(&base_runtime_agent.system_prompt),
3724            "base prompt is preserved"
3725        );
3726        assert!(applied.tool_registry.has("get_current_time"));
3727        assert_eq!(applied.tool_registry.len(), 1);
3728        assert_eq!(applied.applied_ids, vec!["current_time"]);
3729    }
3730
3731    #[tokio::test]
3732    async fn test_apply_capabilities_skips_coming_soon() {
3733        struct ComingSoonFixture;
3734        impl Capability for ComingSoonFixture {
3735            fn id(&self) -> &str {
3736                "coming_soon_fixture"
3737            }
3738            fn name(&self) -> &str {
3739                "Coming Soon Fixture"
3740            }
3741            fn description(&self) -> &str {
3742                "Test-only capability."
3743            }
3744            fn status(&self) -> CapabilityStatus {
3745                CapabilityStatus::ComingSoon
3746            }
3747            fn system_prompt_addition(&self) -> Option<&str> {
3748                Some("Not yet available.")
3749            }
3750        }
3751        let mut registry = CapabilityRegistry::new();
3752        registry.register(ComingSoonFixture);
3753        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3754
3755        let applied = apply_capabilities(
3756            base_runtime_agent.clone(),
3757            &["coming_soon_fixture".to_string()],
3758            &registry,
3759            &test_ctx(),
3760        )
3761        .await;
3762
3763        assert_eq!(
3764            applied.runtime_agent.system_prompt,
3765            base_runtime_agent.system_prompt
3766        );
3767        assert!(applied.applied_ids.is_empty());
3768    }
3769
3770    /// A deprecated capability has only *announced* its removal, so it must keep
3771    /// behaving exactly as before. This is the regression guard for the gating
3772    /// switch from `status() == Available` to `status().is_active()`.
3773    #[tokio::test]
3774    async fn test_apply_capabilities_keeps_deprecated_fully_functional() {
3775        struct DeprecatedFixture;
3776        impl Capability for DeprecatedFixture {
3777            fn id(&self) -> &str {
3778                "deprecated_fixture"
3779            }
3780            fn name(&self) -> &str {
3781                "Deprecated Fixture"
3782            }
3783            fn description(&self) -> &str {
3784                "Test-only capability."
3785            }
3786            fn status(&self) -> CapabilityStatus {
3787                CapabilityStatus::Deprecated
3788            }
3789            fn system_prompt_addition(&self) -> Option<&str> {
3790                Some("Still working.")
3791            }
3792        }
3793        let mut registry = CapabilityRegistry::new();
3794        registry.register(DeprecatedFixture);
3795        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3796
3797        let applied = apply_capabilities(
3798            base_runtime_agent,
3799            &["deprecated_fixture".to_string()],
3800            &registry,
3801            &test_ctx(),
3802        )
3803        .await;
3804
3805        assert!(
3806            applied
3807                .runtime_agent
3808                .system_prompt
3809                .contains("Still working.")
3810        );
3811        assert_eq!(applied.applied_ids, vec!["deprecated_fixture"]);
3812    }
3813
3814    /// A retired capability is inert, but an agent that still references one must
3815    /// keep running: the reference resolves to a no-op and every other capability
3816    /// in the list still applies.
3817    #[tokio::test]
3818    async fn test_apply_capabilities_skips_retired_without_failing() {
3819        struct RetiredFixture;
3820        impl Capability for RetiredFixture {
3821            fn id(&self) -> &str {
3822                "retired_fixture"
3823            }
3824            fn name(&self) -> &str {
3825                "Retired Fixture"
3826            }
3827            fn description(&self) -> &str {
3828                "Test-only capability."
3829            }
3830            fn status(&self) -> CapabilityStatus {
3831                CapabilityStatus::Retired
3832            }
3833            fn system_prompt_addition(&self) -> Option<&str> {
3834                Some("Should never be applied.")
3835            }
3836        }
3837        let mut registry = fixture_registry();
3838        registry.register(RetiredFixture);
3839        let base_runtime_agent = RuntimeAgent::new("You are a helpful assistant.", "gpt-5.2");
3840
3841        let applied = apply_capabilities(
3842            base_runtime_agent,
3843            &["retired_fixture".to_string(), "current_time".to_string()],
3844            &registry,
3845            &test_ctx(),
3846        )
3847        .await;
3848
3849        assert!(
3850            !applied
3851                .runtime_agent
3852                .system_prompt
3853                .contains("Should never be applied.")
3854        );
3855        // The surviving capability in the same list is unaffected.
3856        assert_eq!(applied.applied_ids, vec!["current_time"]);
3857        assert!(applied.tool_registry.has("get_current_time"));
3858    }
3859
3860    #[tokio::test]
3861    async fn test_apply_capabilities_preserves_order() {
3862        let registry = fixture_registry();
3863        let base_runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.2");
3864
3865        // Order should be preserved in applied_ids
3866        let applied = apply_capabilities(
3867            base_runtime_agent,
3868            &["current_time".to_string(), "noop".to_string()],
3869            &registry,
3870            &test_ctx(),
3871        )
3872        .await;
3873
3874        assert_eq!(applied.applied_ids, vec!["current_time", "noop"]);
3875        assert_eq!(applied.tool_registry.len(), 1);
3876        assert!(applied.tool_registry.has("get_current_time"));
3877    }
3878
3879    // =========================================================================
3880    // XML prompt formatting tests
3881    // =========================================================================
3882
3883    // =========================================================================
3884    // Mount collection tests
3885    // =========================================================================
3886
3887    #[tokio::test]
3888    async fn test_dynamic_facts_add_note_without_static_block() {
3889        // `current_time` contributes a Dynamic fact, so the cached prompt gets
3890        // the explanatory note but NOT a static `<facts>` block (the live value
3891        // is appended at the conversation tail per request instead).
3892        let registry = fixture_registry();
3893        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3894        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3895        let prompt = collected.system_prompt_parts.join("\n");
3896        assert!(
3897            prompt.contains(FACTS_DYNAMIC_NOTE),
3898            "dynamic-facts note should be in the cached prompt"
3899        );
3900        assert!(
3901            !prompt.contains("<facts>\n"),
3902            "no static <facts> block for a purely-dynamic fact; got: {prompt}"
3903        );
3904    }
3905
3906    #[tokio::test]
3907    async fn test_static_facts_fold_into_prompt() {
3908        struct StaticFactCap;
3909        impl Capability for StaticFactCap {
3910            fn id(&self) -> &str {
3911                "test_static_fact"
3912            }
3913            fn name(&self) -> &str {
3914                "Static Fact"
3915            }
3916            fn description(&self) -> &str {
3917                "test"
3918            }
3919            fn status(&self) -> CapabilityStatus {
3920                CapabilityStatus::Available
3921            }
3922            fn facts(&self, _config: &serde_json::Value, _ctx: &FactsContext) -> Vec<Fact> {
3923                vec![Fact::stat("workspace_root", "/workspace")]
3924            }
3925        }
3926        let mut registry = CapabilityRegistry::new();
3927        registry.register(StaticFactCap);
3928        let configs = vec![AgentCapabilityConfig::new("test_static_fact".to_string())];
3929        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
3930        let prompt = collected.system_prompt_parts.join("\n");
3931        assert!(
3932            prompt.contains("<facts>\n- workspace_root: /workspace\n</facts>"),
3933            "static fact should fold into the cached prompt; got: {prompt}"
3934        );
3935        assert!(
3936            !prompt.contains(FACTS_DYNAMIC_NOTE),
3937            "no dynamic note when only static facts exist"
3938        );
3939    }
3940
3941    #[test]
3942    fn test_collect_dynamic_facts_returns_current_time() {
3943        let registry = fixture_registry();
3944        let configs = vec![AgentCapabilityConfig::new("current_time".to_string())];
3945        let facts = collect_dynamic_facts(
3946            &configs,
3947            &registry,
3948            None,
3949            &FactsContext::new(SessionId::new()),
3950        );
3951        assert_eq!(facts.len(), 1);
3952        assert_eq!(facts[0].key, "current_time");
3953        assert_eq!(facts[0].value, "fixture-now");
3954        assert_eq!(facts[0].volatility, Volatility::Dynamic);
3955    }
3956
3957    #[tokio::test]
3958    async fn test_collect_capabilities_combines_mounts() {
3959        struct Notes;
3960        impl Capability for Notes {
3961            fn id(&self) -> &str {
3962                "notes"
3963            }
3964            fn name(&self) -> &str {
3965                "Notes"
3966            }
3967            fn description(&self) -> &str {
3968                "Writable notes"
3969            }
3970            fn mounts(&self) -> Vec<MountPoint> {
3971                vec![MountPoint::readwrite(
3972                    "/notes.txt",
3973                    MountSource::text_file("Note α"),
3974                    "notes",
3975                )]
3976            }
3977        }
3978        let mut registry = fixture_registry();
3979        registry.register(Notes);
3980        let collected = collect_capabilities(
3981            &["sample_data".into(), "notes".into(), "current_time".into()],
3982            &registry,
3983            &test_ctx(),
3984        )
3985        .await;
3986        assert_eq!(
3987            collected.applied_ids,
3988            [
3989                "session_file_system",
3990                "sample_data",
3991                "notes",
3992                "current_time"
3993            ]
3994        );
3995        assert_eq!(
3996            collected.mounts,
3997            vec![
3998                MountPoint::readonly(
3999                    "/samples",
4000                    MountDirectoryBuilder::new()
4001                        .file("users.json", "[]")
4002                        .build(),
4003                    "sample_data"
4004                ),
4005                MountPoint::readwrite("/notes.txt", MountSource::text_file("Note α"), "notes"),
4006            ]
4007        );
4008    }
4009
4010    // =========================================================================
4011    // Dependency resolution tests
4012    // =========================================================================
4013
4014    #[test]
4015    fn test_resolve_dependencies_empty() {
4016        let registry = CapabilityRegistry::new();
4017
4018        let resolved = resolve_dependencies(&[], &registry).unwrap();
4019
4020        assert!(resolved.resolved_ids.is_empty());
4021        assert!(resolved.added_as_dependencies.is_empty());
4022        assert!(resolved.user_selected.is_empty());
4023    }
4024
4025    #[test]
4026    fn test_resolve_dependencies_no_deps() {
4027        let registry = fixture_registry();
4028
4029        // CurrentTime has no dependencies
4030        let resolved = resolve_dependencies(&["current_time".to_string()], &registry).unwrap();
4031
4032        assert_eq!(resolved.resolved_ids, vec!["current_time"]);
4033        assert!(resolved.added_as_dependencies.is_empty());
4034    }
4035
4036    #[test]
4037    fn test_resolve_dependencies_with_deps() {
4038        let resolved = resolve_dependencies(&["sample_data".into()], &fixture_registry()).unwrap();
4039        assert_eq!(
4040            resolved.resolved_ids,
4041            ["session_file_system", "sample_data"]
4042        );
4043        assert_eq!(resolved.added_as_dependencies, ["session_file_system"]);
4044        assert_eq!(resolved.user_selected, ["sample_data"]);
4045    }
4046
4047    #[test]
4048    fn test_resolve_dependencies_already_selected() {
4049        let registry = fixture_registry();
4050
4051        // If dependency is already selected, it shouldn't be duplicated
4052        let resolved = resolve_dependencies(
4053            &["session_file_system".to_string(), "sample_data".to_string()],
4054            &registry,
4055        )
4056        .unwrap();
4057
4058        assert_eq!(resolved.resolved_ids.len(), 2);
4059        // FileSystem was user-selected, not added as dependency
4060        assert!(resolved.added_as_dependencies.is_empty());
4061    }
4062
4063    #[test]
4064    fn test_resolve_dependencies_preserves_order() {
4065        let registry = fixture_registry();
4066
4067        // Multiple independent capabilities should maintain their relative order
4068        let resolved =
4069            resolve_dependencies(&["current_time".to_string(), "noop".to_string()], &registry)
4070                .unwrap();
4071
4072        assert_eq!(resolved.resolved_ids, vec!["current_time", "noop"]);
4073    }
4074
4075    #[test]
4076    fn test_resolve_dependencies_unknown_capability() {
4077        let registry = CapabilityRegistry::new();
4078
4079        // Unknown capabilities are silently skipped
4080        let resolved =
4081            resolve_dependencies(&["unknown_capability".to_string()], &registry).unwrap();
4082
4083        assert!(resolved.resolved_ids.is_empty());
4084    }
4085
4086    #[test]
4087    fn test_get_dependencies() {
4088        let registry = fixture_registry();
4089
4090        // SampleData depends on FileSystem
4091        let deps = get_dependencies("sample_data", &registry);
4092        assert_eq!(deps, vec!["session_file_system"]);
4093
4094        // CurrentTime has no dependencies
4095        let deps = get_dependencies("current_time", &registry);
4096        assert!(deps.is_empty());
4097
4098        // Unknown capability
4099        let deps = get_dependencies("unknown", &registry);
4100        assert!(deps.is_empty());
4101    }
4102
4103    // Test for circular dependency detection
4104    // Note: We can't easily test this with built-in capabilities since they don't have cycles.
4105    // This test uses a custom registry to create a cycle.
4106    #[test]
4107    fn test_circular_dependency_error() {
4108        // Create capabilities that form a cycle: A -> B -> A
4109        struct CapA;
4110        struct CapB;
4111
4112        impl Capability for CapA {
4113            fn id(&self) -> &str {
4114                "test_cap_a"
4115            }
4116            fn name(&self) -> &str {
4117                "Test A"
4118            }
4119            fn description(&self) -> &str {
4120                "Test capability A"
4121            }
4122            fn dependencies(&self) -> Vec<&'static str> {
4123                vec!["test_cap_b"]
4124            }
4125        }
4126
4127        impl Capability for CapB {
4128            fn id(&self) -> &str {
4129                "test_cap_b"
4130            }
4131            fn name(&self) -> &str {
4132                "Test B"
4133            }
4134            fn description(&self) -> &str {
4135                "Test capability B"
4136            }
4137            fn dependencies(&self) -> Vec<&'static str> {
4138                vec!["test_cap_a"]
4139            }
4140        }
4141
4142        let mut registry = CapabilityRegistry::new();
4143        registry.register(CapA);
4144        registry.register(CapB);
4145
4146        let result = resolve_dependencies(&["test_cap_a".to_string()], &registry);
4147
4148        assert!(result.is_err());
4149        match result.unwrap_err() {
4150            DependencyError::CircularDependency { capability_id, .. } => {
4151                assert_eq!(capability_id, "test_cap_a");
4152            }
4153            _ => panic!("Expected CircularDependency error"),
4154        }
4155    }
4156
4157    // =========================================================================
4158    // Message filter provider tests
4159    // =========================================================================
4160
4161    use crate::message_filter::{MessageFilter, MessageFilterProvider, MessageQuery};
4162
4163    /// Test capability that provides a message filter
4164    struct FilterTestCapability {
4165        priority: i32,
4166    }
4167
4168    impl Capability for FilterTestCapability {
4169        fn id(&self) -> &str {
4170            "filter_test"
4171        }
4172        fn name(&self) -> &str {
4173            "Filter Test"
4174        }
4175        fn description(&self) -> &str {
4176            "Test capability with message filter"
4177        }
4178        fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4179            Some(Arc::new(FilterTestProvider {
4180                priority: self.priority,
4181            }))
4182        }
4183    }
4184
4185    struct FilterTestProvider {
4186        priority: i32,
4187    }
4188
4189    impl MessageFilterProvider for FilterTestProvider {
4190        fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4191            // Add a search filter based on config
4192            if let Some(search) = config.get("search").and_then(|v| v.as_str()) {
4193                query
4194                    .filters
4195                    .push(MessageFilter::Search(search.to_string()));
4196            }
4197        }
4198
4199        fn priority(&self) -> i32 {
4200            self.priority
4201        }
4202    }
4203
4204    #[tokio::test]
4205    async fn test_collect_capabilities_with_configs_no_filter_providers() {
4206        let registry = fixture_registry();
4207        let configs = vec![AgentCapabilityConfig::with_config(
4208            CapabilityId::new("current_time"),
4209            serde_json::json!({}),
4210        )];
4211
4212        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4213
4214        assert!(collected.message_filter_providers.is_empty());
4215        assert!(!collected.has_message_filters());
4216    }
4217
4218    #[tokio::test]
4219    async fn test_collected_capabilities_apply_message_filters() {
4220        let mut registry = CapabilityRegistry::new();
4221        registry.register(FilterTestCapability { priority: 0 });
4222
4223        let configs = vec![AgentCapabilityConfig::with_config(
4224            CapabilityId::new("filter_test"),
4225            serde_json::json!({ "search": "test_query" }),
4226        )];
4227
4228        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4229
4230        assert!(collected.has_message_filters());
4231
4232        // Apply filters to a query
4233        let session_id: SessionId = Uuid::now_v7().into();
4234        let mut query = MessageQuery::new(session_id);
4235
4236        collected.apply_message_filters(&mut query);
4237
4238        // Should have added the search filter
4239        assert_eq!(query.filters.len(), 1);
4240        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4241    }
4242
4243    #[tokio::test]
4244    async fn test_collected_capabilities_apply_multiple_filters_in_priority_order() {
4245        struct SearchCapability {
4246            id: &'static str,
4247            search_term: &'static str,
4248            priority: i32,
4249        }
4250
4251        struct SearchProvider {
4252            search_term: &'static str,
4253            priority: i32,
4254        }
4255
4256        impl MessageFilterProvider for SearchProvider {
4257            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4258                query
4259                    .filters
4260                    .push(MessageFilter::Search(self.search_term.to_string()));
4261            }
4262
4263            fn priority(&self) -> i32 {
4264                self.priority
4265            }
4266        }
4267
4268        impl Capability for SearchCapability {
4269            fn id(&self) -> &str {
4270                self.id
4271            }
4272            fn name(&self) -> &str {
4273                "Search"
4274            }
4275            fn description(&self) -> &str {
4276                "Test"
4277            }
4278            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4279                Some(Arc::new(SearchProvider {
4280                    search_term: self.search_term,
4281                    priority: self.priority,
4282                }))
4283            }
4284        }
4285
4286        let mut registry = CapabilityRegistry::new();
4287        registry.register(SearchCapability {
4288            id: "cap_a",
4289            search_term: "alpha",
4290            priority: 5,
4291        });
4292        registry.register(SearchCapability {
4293            id: "cap_b",
4294            search_term: "beta",
4295            priority: 1,
4296        });
4297        registry.register(SearchCapability {
4298            id: "cap_c",
4299            search_term: "gamma",
4300            priority: 10,
4301        });
4302
4303        let configs = vec![
4304            AgentCapabilityConfig::with_config(CapabilityId::new("cap_a"), serde_json::json!({})),
4305            AgentCapabilityConfig::with_config(CapabilityId::new("cap_b"), serde_json::json!({})),
4306            AgentCapabilityConfig::with_config(CapabilityId::new("cap_c"), serde_json::json!({})),
4307        ];
4308
4309        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4310
4311        let session_id: SessionId = Uuid::now_v7().into();
4312        let mut query = MessageQuery::new(session_id);
4313
4314        collected.apply_message_filters(&mut query);
4315
4316        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4317        assert_eq!(query.filters.len(), 3);
4318        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4319        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4320        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4321    }
4322
4323    #[tokio::test]
4324    async fn test_collect_capabilities_preserves_config_for_filter_provider() {
4325        let mut registry = CapabilityRegistry::new();
4326        registry.register(FilterTestCapability { priority: 0 });
4327
4328        let test_config = serde_json::json!({
4329            "search": "custom_search",
4330            "extra_field": 42
4331        });
4332
4333        let configs = vec![AgentCapabilityConfig::with_config(
4334            CapabilityId::new("filter_test"),
4335            test_config.clone(),
4336        )];
4337
4338        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4339
4340        // Verify the config is preserved
4341        assert_eq!(collected.message_filter_providers.len(), 1);
4342        let (_, stored_config) = &collected.message_filter_providers[0];
4343        assert_eq!(*stored_config, test_config);
4344    }
4345
4346    // =========================================================================
4347    // collect_message_filters_only tests
4348    // =========================================================================
4349
4350    #[test]
4351    fn test_collect_message_filters_only_collects_filters() {
4352        let mut registry = CapabilityRegistry::new();
4353        registry.register(FilterTestCapability { priority: 0 });
4354
4355        let configs = vec![AgentCapabilityConfig::with_config(
4356            CapabilityId::new("filter_test"),
4357            serde_json::json!({ "search": "test_query" }),
4358        )];
4359
4360        let collected = collect_message_filters_only(&configs, &registry);
4361
4362        let session_id: SessionId = Uuid::now_v7().into();
4363        let mut query = MessageQuery::new(session_id);
4364        collected.apply_message_filters(&mut query);
4365
4366        assert_eq!(query.filters.len(), 1);
4367        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "test_query"));
4368    }
4369
4370    #[test]
4371    fn test_collect_message_filters_only_skips_unknown_capabilities() {
4372        let registry = CapabilityRegistry::new();
4373
4374        let configs = vec![AgentCapabilityConfig::with_config(
4375            CapabilityId::new("nonexistent"),
4376            serde_json::json!({}),
4377        )];
4378
4379        let collected = collect_message_filters_only(&configs, &registry);
4380        assert!(collected.message_filter_providers.is_empty());
4381    }
4382
4383    #[test]
4384    fn test_collect_message_filters_only_preserves_priority_order() {
4385        struct PriorityFilterCap {
4386            id: &'static str,
4387            search_term: &'static str,
4388            priority: i32,
4389        }
4390
4391        struct PriorityFilterProvider {
4392            search_term: &'static str,
4393            priority: i32,
4394        }
4395
4396        impl Capability for PriorityFilterCap {
4397            fn id(&self) -> &str {
4398                self.id
4399            }
4400            fn name(&self) -> &str {
4401                self.id
4402            }
4403            fn description(&self) -> &str {
4404                "priority test"
4405            }
4406            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4407                Some(Arc::new(PriorityFilterProvider {
4408                    search_term: self.search_term,
4409                    priority: self.priority,
4410                }))
4411            }
4412        }
4413
4414        impl MessageFilterProvider for PriorityFilterProvider {
4415            fn apply_filters(&self, query: &mut MessageQuery, _config: &serde_json::Value) {
4416                query
4417                    .filters
4418                    .push(MessageFilter::Search(self.search_term.to_string()));
4419            }
4420            fn priority(&self) -> i32 {
4421                self.priority
4422            }
4423        }
4424
4425        let mut registry = CapabilityRegistry::new();
4426        registry.register(PriorityFilterCap {
4427            id: "gamma",
4428            search_term: "gamma",
4429            priority: 10,
4430        });
4431        registry.register(PriorityFilterCap {
4432            id: "alpha",
4433            search_term: "alpha",
4434            priority: 5,
4435        });
4436        registry.register(PriorityFilterCap {
4437            id: "beta",
4438            search_term: "beta",
4439            priority: 1,
4440        });
4441
4442        let configs = vec![
4443            AgentCapabilityConfig::with_config(CapabilityId::new("gamma"), serde_json::json!({})),
4444            AgentCapabilityConfig::with_config(CapabilityId::new("alpha"), serde_json::json!({})),
4445            AgentCapabilityConfig::with_config(CapabilityId::new("beta"), serde_json::json!({})),
4446        ];
4447
4448        let collected = collect_message_filters_only(&configs, &registry);
4449
4450        let session_id: SessionId = Uuid::now_v7().into();
4451        let mut query = MessageQuery::new(session_id);
4452        collected.apply_message_filters(&mut query);
4453
4454        // Filters should be applied in priority order: beta (1), alpha (5), gamma (10)
4455        assert_eq!(query.filters.len(), 3);
4456        assert!(matches!(&query.filters[0], MessageFilter::Search(s) if s == "beta"));
4457        assert!(matches!(&query.filters[1], MessageFilter::Search(s) if s == "alpha"));
4458        assert!(matches!(&query.filters[2], MessageFilter::Search(s) if s == "gamma"));
4459    }
4460
4461    #[test]
4462    fn test_collect_message_filters_only_post_load_invoked() {
4463        use crate::message::Message;
4464
4465        struct PostLoadCap;
4466        struct PostLoadProvider;
4467
4468        impl Capability for PostLoadCap {
4469            fn id(&self) -> &str {
4470                "post_load_test"
4471            }
4472            fn name(&self) -> &str {
4473                "PostLoad Test"
4474            }
4475            fn description(&self) -> &str {
4476                "test"
4477            }
4478            fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
4479                Some(Arc::new(PostLoadProvider))
4480            }
4481        }
4482
4483        impl MessageFilterProvider for PostLoadProvider {
4484            fn apply_filters(&self, _query: &mut MessageQuery, _config: &serde_json::Value) {}
4485            fn priority(&self) -> i32 {
4486                0
4487            }
4488            fn post_load(&self, messages: &mut Vec<Message>, _config: &serde_json::Value) {
4489                // Reverse messages to prove post_load was called
4490                messages.reverse();
4491            }
4492        }
4493
4494        let mut registry = CapabilityRegistry::new();
4495        registry.register(PostLoadCap);
4496
4497        let configs = vec![AgentCapabilityConfig::with_config(
4498            CapabilityId::new("post_load_test"),
4499            serde_json::json!({}),
4500        )];
4501
4502        let collected = collect_message_filters_only(&configs, &registry);
4503
4504        let mut messages = vec![Message::user("first"), Message::user("second")];
4505        collected.apply_post_load_filters(&mut messages);
4506
4507        // post_load reversed the messages
4508        assert_eq!(messages[0].text(), Some("second"));
4509        assert_eq!(messages[1].text(), Some("first"));
4510    }
4511
4512    // Tests for resolve_for_model delegation in fast-path collectors
4513
4514    struct DelegatingFilterCap {
4515        id: &'static str,
4516        inner: std::sync::Arc<InnerFilterCap>,
4517    }
4518    struct InnerFilterCap;
4519
4520    impl Capability for InnerFilterCap {
4521        fn id(&self) -> &str {
4522            "inner_filter"
4523        }
4524        fn tools(&self) -> Vec<Box<dyn Tool>> {
4525            panic!("fast-path collection must not instantiate tools")
4526        }
4527        fn system_prompt_addition(&self) -> Option<&str> {
4528            panic!("fast-path collection must not collect prompts")
4529        }
4530        fn name(&self) -> &str {
4531            "Inner Filter"
4532        }
4533        fn description(&self) -> &str {
4534            "inner"
4535        }
4536        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4537            Some(std::sync::Arc::new(SentinelFilter))
4538        }
4539    }
4540    struct SentinelFilter;
4541    impl MessageFilterProvider for SentinelFilter {
4542        fn apply_filters(&self, query: &mut MessageQuery, config: &serde_json::Value) {
4543            query.limit = config["limit"].as_i64();
4544        }
4545    }
4546    impl Capability for DelegatingFilterCap {
4547        fn id(&self) -> &str {
4548            self.id
4549        }
4550        fn name(&self) -> &str {
4551            "Delegating Filter"
4552        }
4553        fn description(&self) -> &str {
4554            "delegating"
4555        }
4556        fn message_filter_provider(&self) -> Option<std::sync::Arc<dyn MessageFilterProvider>> {
4557            None // outer provides nothing
4558        }
4559        fn resolve_for_model(&self, _model: Option<&str>) -> Option<&dyn Capability> {
4560            Some(&*self.inner)
4561        }
4562    }
4563
4564    #[test]
4565    fn test_collect_message_filters_only_honors_resolve_for_model_delegation() {
4566        let inner = std::sync::Arc::new(InnerFilterCap);
4567        let outer = DelegatingFilterCap {
4568            id: "delegating_filter",
4569            inner: inner.clone(),
4570        };
4571
4572        let mut registry = CapabilityRegistry::new();
4573        registry.register(outer);
4574
4575        let configs = vec![AgentCapabilityConfig::with_config(
4576            CapabilityId::new("delegating_filter"),
4577            serde_json::json!({"limit": 17}),
4578        )];
4579
4580        // Outer has no message_filter_provider; inner does. resolve_for_model
4581        // delegates to inner so the provider should be collected.
4582        let collected = collect_message_filters_only(&configs, &registry);
4583        assert_eq!(
4584            collected.message_filter_providers.len(),
4585            1,
4586            "provider from resolved inner capability must be collected"
4587        );
4588        let mut query = MessageQuery::default();
4589        collected.apply_message_filters(&mut query);
4590        assert_eq!(query.limit, Some(17));
4591    }
4592
4593    struct DelegatingMvpCap {
4594        id: &'static str,
4595        inner: std::sync::Arc<InnerMvpCap>,
4596    }
4597    struct InnerMvpCap;
4598
4599    impl Capability for InnerMvpCap {
4600        fn id(&self) -> &str {
4601            "inner_mvp"
4602        }
4603        fn tools(&self) -> Vec<Box<dyn Tool>> {
4604            panic!("fast-path collection must not instantiate tools")
4605        }
4606        fn system_prompt_addition(&self) -> Option<&str> {
4607            panic!("fast-path collection must not collect prompts")
4608        }
4609        fn name(&self) -> &str {
4610            "Inner MVP"
4611        }
4612        fn description(&self) -> &str {
4613            "inner"
4614        }
4615        fn model_view_provider(
4616            &self,
4617        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4618            // Retain the input and expose the forwarded config and context.
4619            struct AppendingMvp;
4620            impl crate::capabilities::ModelViewProvider for AppendingMvp {
4621                fn apply_model_view(
4622                    &self,
4623                    mut messages: Vec<Message>,
4624                    config: &serde_json::Value,
4625                    context: &ModelViewContext<'_>,
4626                ) -> Vec<Message> {
4627                    messages.push(Message::user(format!(
4628                        "{}:{}",
4629                        config["suffix"].as_str().unwrap(),
4630                        context.session_id
4631                    )));
4632                    messages
4633                }
4634            }
4635            Some(std::sync::Arc::new(AppendingMvp))
4636        }
4637    }
4638    impl Capability for DelegatingMvpCap {
4639        fn id(&self) -> &str {
4640            self.id
4641        }
4642        fn name(&self) -> &str {
4643            "Delegating MVP"
4644        }
4645        fn description(&self) -> &str {
4646            "delegating"
4647        }
4648        fn model_view_provider(
4649            &self,
4650        ) -> Option<std::sync::Arc<dyn crate::capabilities::ModelViewProvider>> {
4651            None // outer provides nothing
4652        }
4653        fn resolve_for_model(&self, model: Option<&str>) -> Option<&dyn Capability> {
4654            (model == Some("selected-model")).then_some(&*self.inner as &dyn Capability)
4655        }
4656    }
4657
4658    #[test]
4659    fn test_collect_model_view_providers_honors_resolve_for_model_delegation() {
4660        let inner = std::sync::Arc::new(InnerMvpCap);
4661        let outer = DelegatingMvpCap {
4662            id: "delegating_mvp",
4663            inner: inner.clone(),
4664        };
4665
4666        let mut registry = CapabilityRegistry::new();
4667        registry.register(outer);
4668
4669        let configs = vec![AgentCapabilityConfig::with_config(
4670            CapabilityId::new("delegating_mvp"),
4671            serde_json::json!({"suffix": "delegated"}),
4672        )];
4673
4674        // Outer has no model_view_provider; inner does. resolve_for_model
4675        // delegates to inner so the provider should be collected.
4676        let collected = collect_model_view_providers(&configs, &registry, Some("selected-model"));
4677        assert_eq!(
4678            collected.model_view_providers.len(),
4679            1,
4680            "provider from resolved inner capability must be collected"
4681        );
4682        assert!(
4683            collect_model_view_providers(&configs, &registry, Some("other-model"))
4684                .model_view_providers
4685                .is_empty()
4686        );
4687        let session_id = SessionId::from_seed(42);
4688        let output = collected.apply_model_view(
4689            vec![Message::user("original")],
4690            &ModelViewContext {
4691                session_id,
4692                prior_usage: None,
4693            },
4694        );
4695        assert_eq!(
4696            output.iter().map(Message::text).collect::<Vec<_>>(),
4697            [
4698                Some("original"),
4699                Some(format!("delegated:{session_id}").as_str())
4700            ]
4701        );
4702    }
4703
4704    // =========================================================================
4705    // Default selection and alias resolution
4706    // =========================================================================
4707
4708    #[test]
4709    fn test_defaults_do_not_include_bash() {
4710        // ToolRegistry::with_defaults() must NOT include bash — it comes from
4711        // capabilities only. This documents the invariant that the bug violated.
4712        let registry = crate::ToolRegistry::with_defaults();
4713        assert!(
4714            !registry.has("bash"),
4715            "with_defaults() must not include 'bash' — it comes from bashkit_shell capability"
4716        );
4717    }
4718
4719    // =========================================================================
4720    // Feature tests
4721    // =========================================================================
4722
4723    #[test]
4724    fn test_alias_resolves_to_canonical_capability() {
4725        let registry = fixture_registry();
4726
4727        // Legacy `virtual_bash` ID (persisted agent configs) must keep working.
4728        let via_alias = registry.get("virtual_bash").unwrap();
4729        assert_eq!(via_alias.id(), "bashkit_shell");
4730        assert!(registry.has("virtual_bash"));
4731        assert_eq!(registry.canonical_id("virtual_bash"), Some("bashkit_shell"));
4732        assert_eq!(
4733            registry.canonical_id("bashkit_shell"),
4734            Some("bashkit_shell")
4735        );
4736        assert_eq!(registry.canonical_id("nonexistent"), None);
4737    }
4738
4739    #[test]
4740    fn test_alias_dedupes_with_canonical_in_dependency_resolution() {
4741        let registry = fixture_registry();
4742
4743        // Selecting both the alias and the canonical ID must resolve to a
4744        // single activation under the canonical ID.
4745        let resolved = resolve_dependencies(
4746            &["virtual_bash".to_string(), "bashkit_shell".to_string()],
4747            &registry,
4748        )
4749        .unwrap();
4750        let bash_ids: Vec<_> = resolved
4751            .resolved_ids
4752            .iter()
4753            .filter(|id| id.as_str() == "bashkit_shell" || id.as_str() == "virtual_bash")
4754            .collect();
4755        assert_eq!(bash_ids, vec!["bashkit_shell"]);
4756        // Selected via alias => not reported as "added as dependency".
4757        assert!(
4758            !resolved
4759                .added_as_dependencies
4760                .contains(&"bashkit_shell".to_string())
4761        );
4762    }
4763
4764    #[test]
4765    fn test_alias_preserves_explicit_config_in_resolution() {
4766        let registry = fixture_registry();
4767
4768        let configs = vec![AgentCapabilityConfig::with_config(
4769            "virtual_bash".to_string(),
4770            serde_json::json!({"key": "value"}),
4771        )];
4772        let resolved = resolve_capability_configs(&configs, &registry).unwrap();
4773        let bash = resolved
4774            .iter()
4775            .find(|c| c.capability_id() == "bashkit_shell")
4776            .expect("alias must resolve to canonical bashkit_shell config");
4777        assert_eq!(
4778            bash.config_value().clone(),
4779            serde_json::json!({"key": "value"})
4780        );
4781    }
4782
4783    #[test]
4784    fn test_unregister_by_alias_removes_capability_and_aliases() {
4785        let mut registry = fixture_registry();
4786
4787        assert!(registry.unregister("virtual_bash").is_some());
4788        assert!(!registry.has("bashkit_shell"));
4789        assert!(!registry.has("virtual_bash"));
4790    }
4791
4792    #[test]
4793    fn test_compute_features_empty() {
4794        let registry = CapabilityRegistry::new();
4795
4796        let features = compute_features(&[], &registry);
4797        assert!(features.is_empty());
4798    }
4799
4800    #[test]
4801    fn test_compute_features_unknown_capability_ignored() {
4802        let registry = fixture_registry();
4803
4804        let features = compute_features(
4805            &["unknown_cap".to_string(), "session_storage".to_string()],
4806            &registry,
4807        );
4808        assert_eq!(features, vec!["secrets", "key_value"]);
4809    }
4810
4811    #[test]
4812    fn test_risk_level_ordering() {
4813        assert!(RiskLevel::Low < RiskLevel::Medium);
4814        assert!(RiskLevel::Medium < RiskLevel::High);
4815    }
4816
4817    #[test]
4818    fn test_risk_level_serde_roundtrip() {
4819        for (level, wire) in [
4820            (RiskLevel::Low, "\"low\""),
4821            (RiskLevel::Medium, "\"medium\""),
4822            (RiskLevel::High, "\"high\""),
4823        ] {
4824            assert_eq!(serde_json::to_string(&level).unwrap(), wire);
4825            assert_eq!(serde_json::from_str::<RiskLevel>(wire).unwrap(), level);
4826        }
4827        assert!(serde_json::from_str::<RiskLevel>("\"critical\"").is_err());
4828    }
4829
4830    // ========================================================================
4831    // contribute_skills() collection — EVE-311
4832    // ========================================================================
4833
4834    struct SkillContributingCapability;
4835
4836    impl Capability for SkillContributingCapability {
4837        fn id(&self) -> &str {
4838            "contributes_skills"
4839        }
4840        fn name(&self) -> &str {
4841            "Contributes Skills"
4842        }
4843        fn description(&self) -> &str {
4844            "Test capability that contributes skills."
4845        }
4846        fn contribute_skills(&self) -> Vec<SkillContribution> {
4847            vec![
4848                SkillContribution::new("alpha-skill", "Alpha skill desc", "# Alpha\nDo alpha.")
4849                    .with_files(vec![(
4850                        "scripts/a.sh".to_string(),
4851                        "#!/bin/sh\necho a\n".to_string(),
4852                    )]),
4853                SkillContribution::new("beta-skill", "Beta skill desc", "# Beta\nDo beta.")
4854                    .with_user_invocable(false),
4855            ]
4856        }
4857    }
4858
4859    fn skill_md_from_entries(entries: &HashMap<String, MountEntry>) -> &str {
4860        match &entries.get("SKILL.md").expect("SKILL.md missing").source {
4861            MountSource::InlineFile { content, .. } => content.as_str(),
4862            _ => panic!("Expected InlineFile for SKILL.md"),
4863        }
4864    }
4865
4866    #[tokio::test]
4867    async fn test_contribute_skills_normalized_to_mounts() {
4868        let mut registry = CapabilityRegistry::new();
4869        registry.register(SkillContributingCapability);
4870
4871        let configs = vec![AgentCapabilityConfig::with_config(
4872            CapabilityId::new("contributes_skills"),
4873            serde_json::json!({}),
4874        )];
4875
4876        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4877
4878        let skill_mounts: Vec<_> = collected
4879            .mounts
4880            .iter()
4881            .filter(|m| m.path.starts_with("/.agents/skills/"))
4882            .collect();
4883        assert_eq!(skill_mounts.len(), 2);
4884
4885        // Every contributed skill mount is read-only and owned by the contributing
4886        // capability so the VFS layer can attribute skill files correctly.
4887        for m in &skill_mounts {
4888            assert!(m.is_readonly());
4889            assert_eq!(m.capability_id, "contributes_skills");
4890        }
4891
4892        let alpha = skill_mounts
4893            .iter()
4894            .find(|m| m.path == "/.agents/skills/alpha-skill")
4895            .expect("alpha-skill mount missing");
4896        match &alpha.source {
4897            MountSource::InlineDirectory { entries } => {
4898                assert!(entries.contains_key("SKILL.md"));
4899                assert!(entries.contains_key("scripts/a.sh"));
4900                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4901                assert_eq!(parsed.name, "alpha-skill");
4902                assert_eq!(parsed.description, "Alpha skill desc");
4903                assert_eq!(parsed.instructions, "# Alpha\nDo alpha.");
4904                assert!(parsed.user_invocable);
4905            }
4906            _ => panic!("Expected InlineDirectory"),
4907        }
4908
4909        let beta = skill_mounts
4910            .iter()
4911            .find(|m| m.path == "/.agents/skills/beta-skill")
4912            .expect("beta-skill mount missing");
4913        match &beta.source {
4914            MountSource::InlineDirectory { entries } => {
4915                let parsed = crate::skill::parse_skill_md(skill_md_from_entries(entries)).unwrap();
4916                assert!(!parsed.user_invocable);
4917                assert_eq!(parsed.name, "beta-skill");
4918                assert_eq!(parsed.instructions, "# Beta\nDo beta.");
4919            }
4920            _ => panic!("Expected InlineDirectory"),
4921        }
4922    }
4923
4924    #[tokio::test]
4925    async fn test_contribute_skills_default_empty() {
4926        // Registry-resident capability without a contribute_skills override
4927        // must not add skill mounts.
4928        let mut registry = CapabilityRegistry::new();
4929        registry.register(FilterTestCapability { priority: 0 });
4930
4931        let configs = vec![AgentCapabilityConfig::with_config(
4932            CapabilityId::new("filter_test"),
4933            serde_json::json!({}),
4934        )];
4935
4936        let collected = collect_capabilities_with_configs(&configs, &registry, &test_ctx()).await;
4937        assert!(
4938            collected
4939                .mounts
4940                .iter()
4941                .all(|m| !m.path.starts_with("/.agents/skills/"))
4942        );
4943    }
4944
4945    struct LocalizedCapability;
4946
4947    impl Capability for LocalizedCapability {
4948        fn id(&self) -> &str {
4949            "localized"
4950        }
4951        fn name(&self) -> &str {
4952            "Localized"
4953        }
4954        fn description(&self) -> &str {
4955            "English description"
4956        }
4957        fn localizations(&self) -> Vec<CapabilityLocalization> {
4958            vec![
4959                CapabilityLocalization {
4960                    locale: "en",
4961                    name: None,
4962                    description: None,
4963                    config_description: Some("Controls things."),
4964                    config_overlay: None,
4965                },
4966                CapabilityLocalization {
4967                    locale: "uk-UA",
4968                    name: Some("Регіональна"),
4969                    description: None,
4970                    config_description: None,
4971                    config_overlay: None,
4972                },
4973                CapabilityLocalization {
4974                    locale: "uk",
4975                    name: Some("Локалізована"),
4976                    description: Some("Український опис"),
4977                    config_description: Some("Керує налаштуваннями."),
4978                    config_overlay: None,
4979                },
4980            ]
4981        }
4982    }
4983
4984    #[test]
4985    fn localized_name_falls_back_exact_language_then_base() {
4986        let cap = LocalizedCapability;
4987        // Exact region wins; an absent regional field still falls back by language.
4988        assert_eq!(cap.localized_name(Some("uk-UA")), "Регіональна");
4989        assert_eq!(cap.localized_name(Some("uk")), "Локалізована");
4990        assert_eq!(cap.localized_name(Some("uk-CA")), "Локалізована");
4991        assert_eq!(cap.localized_name(Some(" UK_ua ")), "Регіональна");
4992        assert_eq!(cap.localized_description(Some("uk-UA")), "Український опис");
4993        // Underscore-separated tags are normalized.
4994        assert_eq!(cap.localized_name(Some("uk_UA")), "Регіональна");
4995        // Unsupported locales and None fall back to the base name.
4996        assert_eq!(cap.localized_name(Some("fr-FR")), "Localized");
4997        assert_eq!(cap.localized_name(None), "Localized");
4998        assert_eq!(cap.localized_description(Some("uk")), "Український опис");
4999        assert_eq!(cap.localized_description(Some("de")), "English description");
5000    }
5001
5002    #[test]
5003    fn describe_schema_resolves_config_description_per_locale() {
5004        let cap = LocalizedCapability;
5005        assert_eq!(
5006            cap.describe_schema(Some("uk-UA")).as_deref(),
5007            Some("Керує налаштуваннями.")
5008        );
5009        // Unsupported locales fall back to the "en" entry.
5010        assert_eq!(
5011            cap.describe_schema(Some("pl")).as_deref(),
5012            Some("Controls things.")
5013        );
5014        assert_eq!(
5015            cap.describe_schema(None).as_deref(),
5016            Some("Controls things.")
5017        );
5018        // Capabilities without localizations have no config description.
5019        assert_eq!(HostAnnotatedCapability.describe_schema(Some("uk")), None);
5020    }
5021
5022    #[tokio::test]
5023    async fn collection_preserves_exact_tool_identity_schema_and_attribution() {
5024        let registry = fixture_registry();
5025        for (ids, expected) in [
5026            (
5027                vec!["test_math"],
5028                vec![
5029                    ("add", "test_math", "Test Math"),
5030                    ("subtract", "test_math", "Test Math"),
5031                    ("multiply", "test_math", "Test Math"),
5032                    ("divide", "test_math", "Test Math"),
5033                ],
5034            ),
5035            (
5036                vec!["test_weather"],
5037                vec![
5038                    ("get_weather", "test_weather", "Test Weather"),
5039                    ("get_forecast", "test_weather", "Test Weather"),
5040                ],
5041            ),
5042            (
5043                vec!["sample_data"],
5044                vec![
5045                    ("read_file", "session_file_system", "Fixture Filesystem"),
5046                    ("write_file", "session_file_system", "Fixture Filesystem"),
5047                ],
5048            ),
5049            (
5050                vec!["bashkit_shell", "test_weather"],
5051                vec![
5052                    ("read_file", "session_file_system", "Fixture Filesystem"),
5053                    ("write_file", "session_file_system", "Fixture Filesystem"),
5054                    ("bash", "bashkit_shell", "Fixture Bash"),
5055                    ("get_weather", "test_weather", "Test Weather"),
5056                    ("get_forecast", "test_weather", "Test Weather"),
5057                ],
5058            ),
5059        ] {
5060            let ids: Vec<_> = ids.into_iter().map(String::from).collect();
5061            let collected = collect_capabilities(&ids, &registry, &test_ctx()).await;
5062            assert_eq!(
5063                collected.tools.iter().map(|t| t.name()).collect::<Vec<_>>(),
5064                expected.iter().map(|(n, _, _)| *n).collect::<Vec<_>>()
5065            );
5066            assert_eq!(collected.tool_definitions.len(), expected.len());
5067            for (definition, (name, id, label)) in collected.tool_definitions.iter().zip(expected) {
5068                assert_eq!(definition.name(), name);
5069                let hints = definition.hints();
5070                assert_eq!(hints.capability_id.as_deref(), Some(id));
5071                assert_eq!(hints.capability_name.as_deref(), Some(label));
5072                let ToolDefinition::Builtin(tool) = definition else {
5073                    panic!("expected builtin")
5074                };
5075                let schema = if name == "bash" {
5076                    serde_json::json!({"type":"object"})
5077                } else {
5078                    serde_json::json!({"type":"object","properties":{},"additionalProperties":false})
5079                };
5080                assert_eq!(tool.parameters, schema);
5081            }
5082        }
5083    }
5084
5085    #[tokio::test]
5086    async fn prompt_collection_preserves_exact_sections_attribution_and_base_order() {
5087        let registry = fixture_registry();
5088        let ids = vec!["prompt_tool_fixture".into(), "second_prompt_fixture".into()];
5089        let collected = collect_capabilities(&ids, &registry, &test_ctx()).await;
5090        let first = "<capability id=\"prompt_tool_fixture\">\nTask Management uses the write_todos tool.\n</capability>";
5091        let second = "<capability id=\"second_prompt_fixture\">\nA second capability prompt contribution.\n</capability>";
5092        assert_eq!(collected.system_prompt_parts, vec![first, second]);
5093        assert_eq!(
5094            collected.system_prompt_attributions,
5095            vec![
5096                SystemPromptAttribution {
5097                    capability_id: ids[0].clone(),
5098                    content: first.into()
5099                },
5100                SystemPromptAttribution {
5101                    capability_id: ids[1].clone(),
5102                    content: second.into()
5103                }
5104            ]
5105        );
5106        assert_eq!(
5107            collected.system_prompt_prefix(),
5108            Some(format!("{first}\n\n{second}"))
5109        );
5110        let applied = apply_capabilities(
5111            RuntimeAgent::new("Base.", "fixture-model"),
5112            &ids,
5113            &registry,
5114            &test_ctx(),
5115        )
5116        .await;
5117        assert_eq!(
5118            applied.runtime_agent.system_prompt,
5119            format!("<system-prompt>\nBase.\n</system-prompt>\n\n{first}\n\n{second}")
5120        );
5121        assert!(applied.tool_registry.has("write_todos"));
5122        assert_eq!(applied.tool_registry.len(), 1);
5123        for (base, addition, expected) in [
5124            ("Base.", None, "Base."),
5125            ("Base.", Some(""), "Base."),
5126            ("", Some("Extra."), "Extra."),
5127            (
5128                "<system-prompt>Base.</system-prompt>",
5129                Some("Extra."),
5130                "<system-prompt>Base.</system-prompt>\n\nExtra.",
5131            ),
5132        ] {
5133            assert_eq!(compose_system_prompt(base, addition), expected);
5134        }
5135    }
5136
5137    struct DependencyFixture {
5138        id: String,
5139        deps: Vec<&'static str>,
5140        features: Vec<&'static str>,
5141    }
5142    impl Capability for DependencyFixture {
5143        fn id(&self) -> &str {
5144            &self.id
5145        }
5146        fn name(&self) -> &str {
5147            &self.id
5148        }
5149        fn description(&self) -> &str {
5150            "Dependency fixture"
5151        }
5152        fn dependencies(&self) -> Vec<&'static str> {
5153            self.deps.clone()
5154        }
5155        fn features(&self) -> Vec<&'static str> {
5156            self.features.clone()
5157        }
5158    }
5159
5160    #[test]
5161    fn feature_projection_preserves_order_and_distinct_dependency_features() {
5162        let mut registry = CapabilityRegistry::new();
5163        registry.register(DependencyFixture {
5164            id: "base".into(),
5165            deps: vec![],
5166            features: vec!["base-only", "shared"],
5167        });
5168        registry.register(DependencyFixture {
5169            id: "parent".into(),
5170            deps: vec!["base"],
5171            features: vec!["parent-only", "shared"],
5172        });
5173        registry.register(DependencyFixture {
5174            id: "other".into(),
5175            deps: vec![],
5176            features: vec!["other-only"],
5177        });
5178        assert_eq!(
5179            compute_features(&["parent".into()], &registry),
5180            vec!["base-only", "shared", "parent-only"]
5181        );
5182        assert_eq!(
5183            compute_features(
5184                &[
5185                    "other".into(),
5186                    "parent".into(),
5187                    "base".into(),
5188                    "parent".into()
5189                ],
5190                &registry
5191            ),
5192            vec!["other-only", "base-only", "shared", "parent-only"]
5193        );
5194    }
5195
5196    #[test]
5197    fn dependency_limit_accepts_one_hundred_and_rejects_one_hundred_one() {
5198        let mut registry = CapabilityRegistry::new();
5199        let ids: Vec<_> = (0..101).map(|i| format!("cap-{i}")).collect();
5200        for id in &ids {
5201            registry.register(DependencyFixture {
5202                id: id.clone(),
5203                deps: vec![],
5204                features: vec![],
5205            });
5206        }
5207        let resolved = resolve_dependencies(&ids[..100], &registry).unwrap();
5208        assert_eq!(resolved.resolved_ids, ids[..100]);
5209        assert_eq!(resolved.user_selected, ids[..100]);
5210        assert!(resolved.added_as_dependencies.is_empty());
5211        assert_eq!(
5212            resolve_dependencies(&ids, &registry).unwrap_err(),
5213            DependencyError::TooManyCapabilities {
5214                count: 101,
5215                max: 100
5216            }
5217        );
5218    }
5219}