Skip to main content

everruns_core/capabilities/
mod.rs

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