Skip to main content

agent_engine/extensions/
context_provider.rs

1//! Task A3 (continuous-memory spec §7.1) — typed memory/context
2//! contribution provider identities and DORMANT descriptors.
3//!
4//! A context provider is a passive, additive manifest capability
5//! (`deferred.context_providers`) that lets an extension — e.g.
6//! `extension:axel-memory-manager:project-memory` — offer bounded context
7//! contributions to the host. Everything in this module is metadata only:
8//! nothing here spawns a process, touches the network, or acquires a
9//! lease. Descriptors mirror how [`super::lifecycle::DeferredExtensionTool`]
10//! pins dormant deferred-tool metadata before any
11//! [`super::lease::ExtensionRuntimeManager`] lease exists — activation
12//! (exact memory-context lease acquisition) is task A6 and is NOT
13//! implemented here.
14
15use super::lifecycle::{
16    validate_manifest_deferred, DeclaredExtensionContextProvider, DECLARED_DESCRIPTION_MAX_BYTES,
17    DECLARED_NAME_MAX_BYTES,
18};
19use super::manifest::ExtensionManifest;
20use agent_core::BoundedText;
21
22/// Hard byte bound on a context provider id (parity with the declared-name
23/// budget in [`super::lifecycle`]).
24pub const CONTEXT_PROVIDER_ID_MAX_BYTES: usize = DECLARED_NAME_MAX_BYTES;
25
26/// Validated context provider identity: the LOCAL id an extension declares
27/// (the full runtime address is composed as `extension:<plugin>:<id>`).
28///
29/// Newtype invariants (enforced by [`ContextProviderId::parse`], the ONLY
30/// constructor — fail closed on any violation):
31/// - non-empty;
32/// - at most [`CONTEXT_PROVIDER_ID_MAX_BYTES`] bytes;
33/// - ASCII-safe: printable ASCII only (no control chars, no whitespace,
34///   no non-ASCII);
35/// - no `':'` so the composed runtime address stays reverse-parseable.
36///
37/// Comparison is EXACT byte equality; there is no public mutable access to
38/// the inner string.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct ContextProviderId(String);
41
42impl ContextProviderId {
43    /// Parse a candidate id, failing closed on invalid input. Static
44    /// reasons only.
45    pub fn parse(candidate: &str) -> Result<Self, &'static str> {
46        if candidate.is_empty() {
47            return Err("empty_context_provider_id");
48        }
49        if candidate.len() > CONTEXT_PROVIDER_ID_MAX_BYTES {
50            return Err("oversized_context_provider_id");
51        }
52        if !candidate.chars().all(|c| c.is_ascii_graphic() && c != ':') {
53            return Err("invalid_context_provider_id_characters");
54        }
55        Ok(Self(candidate.to_string()))
56    }
57
58    /// Read-only view of the exact validated id.
59    pub fn as_str(&self) -> &str {
60        &self.0
61    }
62}
63
64impl std::fmt::Display for ContextProviderId {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.write_str(&self.0)
67    }
68}
69
70/// A DORMANT, manifest-declared context provider descriptor: bounded
71/// metadata pinned at load, exactly like the dormant deferred-tool
72/// descriptors the lease manager later validates against ([`super::
73/// lifecycle::DeferredExtensionTool`] / `ExtensionRuntimeManager`).
74/// Holding one grants NOTHING: discovery, schema export, and status
75/// inspection read this metadata without ever starting the owning
76/// extension. Lease acquisition (task A6) is the only activation path and
77/// is not part of this type.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct RegisteredContextProviderDescriptor {
80    id: ContextProviderId,
81    capability: String,
82    description: String,
83    schema_version: u32,
84    extension_id: String,
85}
86
87impl RegisteredContextProviderDescriptor {
88    /// Pin one VALIDATED declaration for one owning extension. Fails
89    /// closed (static reason) if either identity violates its bound —
90    /// mirrors `DeferredExtensionTool::new`'s bounded pinning.
91    pub fn new(
92        extension_id: &str,
93        declared: &DeclaredExtensionContextProvider,
94    ) -> Result<Self, &'static str> {
95        // Plugin ids with ':' would break `extension:<plugin>:<id>`
96        // reverse identity (same rule as dormant_extension_tools).
97        if extension_id.is_empty()
98            || extension_id.contains(':')
99            || extension_id.chars().any(char::is_control)
100        {
101            return Err("invalid_context_provider_extension_id");
102        }
103        let id = ContextProviderId::parse(&declared.id)?;
104        if declared.capability.is_empty() || declared.capability.len() > DECLARED_NAME_MAX_BYTES {
105            return Err("invalid_context_provider_capability");
106        }
107        if declared.schema_version == 0 {
108            return Err("invalid_context_provider_schema_version");
109        }
110        Ok(Self {
111            id,
112            capability: BoundedText::new(&declared.capability, DECLARED_NAME_MAX_BYTES).text,
113            description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
114                .text,
115            schema_version: declared.schema_version,
116            extension_id: extension_id.to_string(),
117        })
118    }
119
120    pub fn id(&self) -> &ContextProviderId {
121        &self.id
122    }
123
124    pub fn capability(&self) -> &str {
125        &self.capability
126    }
127
128    pub fn description(&self) -> &str {
129        &self.description
130    }
131
132    pub fn schema_version(&self) -> u32 {
133        self.schema_version
134    }
135
136    /// The owning extension (plugin) id.
137    pub fn extension_id(&self) -> &str {
138        &self.extension_id
139    }
140
141    /// The composed runtime address, e.g.
142    /// `extension:axel-memory-manager:project-memory` (spec §7.1).
143    pub fn runtime_address(&self) -> String {
144        format!("extension:{}:{}", self.extension_id, self.id.as_str())
145    }
146}
147
148/// Shared handle to the PUBLISHED context-provider catalog snapshot (task
149/// A6). Mirrors [`super::manager::SharedDeferredRecords`]: the
150/// [`super::manager::ExtensionManager`] owns the catalog and republishes
151/// this snapshot on every load/unload mutation; the
152/// [`super::lease::ExtensionRuntimeManager`] (and, through it, the
153/// `Runtime` memory-context enable path) reads the CURRENT snapshot with a
154/// sync lock held only for slice operations — never across I/O, and never
155/// spawning anything.
156pub(crate) type SharedContextProviderCatalog =
157    std::sync::Arc<std::sync::Mutex<Vec<RegisteredContextProviderDescriptor>>>;
158
159/// Typed fail-closed context-provider resolution failure (task A6).
160/// Static-reason style like every other identity gate in this module.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum ContextProviderLookupError {
163    /// No loaded extension declares the requested provider identity (or,
164    /// for an id-less request, no loaded extension declares any context
165    /// provider at all).
166    NotRegistered,
167    /// More than one loaded extension declaration matches: overlapping
168    /// capability surfaces with no exact disambiguation. Fail closed —
169    /// the host never picks one arbitrarily.
170    Ambiguous,
171}
172
173/// Resolve one context-provider request against the loaded catalog.
174/// EXACT-SCOPE, FAIL-CLOSED matching — the same discipline as
175/// [`super::lifecycle::validate_manifest_deferred`] and
176/// `crate::orchestration::validate_user_authorizable_model`: no partial,
177/// prefix, or fuzzy matches, and no arbitrary tie-breaking.
178///
179/// * `requested = Some(id)` — Ok iff EXACTLY ONE catalog entry declares
180///   that exact local id; zero matches fail `NotRegistered`, two or more
181///   (the same id declared by multiple installed extensions) fail
182///   `Ambiguous`.
183/// * `requested = None` — the request names no explicit provider: Ok iff
184///   the whole catalog holds EXACTLY ONE descriptor; an empty catalog
185///   fails `NotRegistered` and overlapping declarations fail `Ambiguous`.
186///
187/// Pure slice read: never spawns, never touches lease state.
188pub fn resolve_context_provider<'a>(
189    catalog: &'a [RegisteredContextProviderDescriptor],
190    requested: Option<&ContextProviderId>,
191) -> Result<&'a RegisteredContextProviderDescriptor, ContextProviderLookupError> {
192    let mut matches = catalog
193        .iter()
194        .filter(|descriptor| requested.map_or(true, |id| descriptor.id() == id));
195    match (matches.next(), matches.next()) {
196        (None, _) => Err(ContextProviderLookupError::NotRegistered),
197        (Some(only), None) => Ok(only),
198        (Some(_), Some(_)) => Err(ContextProviderLookupError::Ambiguous),
199    }
200}
201
202/// Build the dormant context provider descriptors for one manifest.
203/// Deterministic (declaration order); returns nothing for manifests
204/// without validated declarations — descriptors are never invented, and a
205/// manifest that fails the full deferred policy (bounds + the
206/// `context_providers.register` permission gate) yields NOTHING. Mirrors
207/// [`super::lifecycle::dormant_extension_tools`]. Never spawns.
208pub fn dormant_context_provider_descriptors(
209    plugin_id: &str,
210    manifest: &ExtensionManifest,
211) -> Vec<RegisteredContextProviderDescriptor> {
212    let Some(deferred) = &manifest.deferred else {
213        return Vec::new();
214    };
215    if validate_manifest_deferred(manifest).is_err() {
216        return Vec::new();
217    }
218    let mut descriptors = Vec::with_capacity(deferred.context_providers.len());
219    for declared in &deferred.context_providers {
220        match RegisteredContextProviderDescriptor::new(plugin_id, declared) {
221            Ok(descriptor) => descriptors.push(descriptor),
222            // Any single invalid declaration poisons the set: partial
223            // capability surfaces are never minted.
224            Err(_) => return Vec::new(),
225        }
226    }
227    descriptors
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn declared(id: &str) -> DeclaredExtensionContextProvider {
235        DeclaredExtensionContextProvider {
236            id: id.to_string(),
237            capability: "project-memory".to_string(),
238            description: "Project memory context contributions".to_string(),
239            schema_version: 1,
240        }
241    }
242
243    // ── ContextProviderId::parse ───────────────────────────────────────
244
245    #[test]
246    fn parse_accepts_bounded_ascii_ids_exactly() {
247        let id = ContextProviderId::parse("project-memory").unwrap();
248        assert_eq!(id.as_str(), "project-memory");
249        assert_eq!(id, ContextProviderId::parse("project-memory").unwrap());
250        // Exact equality: near-misses are different identities.
251        assert_ne!(id, ContextProviderId::parse("project-memory2").unwrap());
252        assert_ne!(id, ContextProviderId::parse("Project-memory").unwrap());
253        // Display renders the exact id.
254        assert_eq!(id.to_string(), "project-memory");
255        // Max-length id is accepted; one past the bound is not.
256        let max = "a".repeat(CONTEXT_PROVIDER_ID_MAX_BYTES);
257        assert!(ContextProviderId::parse(&max).is_ok());
258    }
259
260    #[test]
261    fn parse_rejects_empty_and_over_bound() {
262        assert_eq!(
263            ContextProviderId::parse(""),
264            Err("empty_context_provider_id")
265        );
266        let over = "a".repeat(CONTEXT_PROVIDER_ID_MAX_BYTES + 1);
267        assert_eq!(
268            ContextProviderId::parse(&over),
269            Err("oversized_context_provider_id")
270        );
271    }
272
273    #[test]
274    fn parse_rejects_unsafe_characters_fail_closed() {
275        for bad in [
276            "has:colon",
277            "has space",
278            "ctrl\u{7}char",
279            "tab\there",
280            "new\nline",
281            "unicodé",
282            "emoji💾",
283        ] {
284            assert!(
285                ContextProviderId::parse(bad).is_err(),
286                "must reject {bad:?}"
287            );
288        }
289    }
290
291    // ── dormant descriptors ────────────────────────────────────────────
292
293    #[test]
294    fn descriptor_pins_bounded_dormant_metadata_only() {
295        let descriptor = RegisteredContextProviderDescriptor::new(
296            "axel-memory-manager",
297            &declared("project-memory"),
298        )
299        .unwrap();
300        assert_eq!(descriptor.id().as_str(), "project-memory");
301        assert_eq!(descriptor.capability(), "project-memory");
302        assert_eq!(
303            descriptor.description(),
304            "Project memory context contributions"
305        );
306        assert_eq!(descriptor.schema_version(), 1);
307        assert_eq!(descriptor.extension_id(), "axel-memory-manager");
308        // Spec §7.1 runtime address composition.
309        assert_eq!(
310            descriptor.runtime_address(),
311            "extension:axel-memory-manager:project-memory"
312        );
313    }
314
315    #[test]
316    fn descriptor_fails_closed_on_invalid_identities() {
317        // Bad owning extension ids.
318        for bad in ["", "has:colon", "ctrl\u{7}"] {
319            assert!(RegisteredContextProviderDescriptor::new(bad, &declared("p")).is_err());
320        }
321        // Bad declared id flows through ContextProviderId::parse.
322        assert!(RegisteredContextProviderDescriptor::new("plug", &declared("bad:id")).is_err());
323        // Zero schema version is invalid.
324        let mut zero = declared("p");
325        zero.schema_version = 0;
326        assert!(RegisteredContextProviderDescriptor::new("plug", &zero).is_err());
327    }
328
329    // ── task A6: exact-scope, fail-closed catalog resolution ───────────
330
331    fn descriptor(plugin: &str, id: &str) -> RegisteredContextProviderDescriptor {
332        RegisteredContextProviderDescriptor::new(plugin, &declared(id)).unwrap()
333    }
334
335    #[test]
336    fn resolve_empty_catalog_fails_not_registered() {
337        let id = ContextProviderId::parse("project-memory").unwrap();
338        assert_eq!(
339            resolve_context_provider(&[], Some(&id)).unwrap_err(),
340            ContextProviderLookupError::NotRegistered
341        );
342        assert_eq!(
343            resolve_context_provider(&[], None).unwrap_err(),
344            ContextProviderLookupError::NotRegistered
345        );
346    }
347
348    #[test]
349    fn resolve_exactly_one_match_succeeds_with_and_without_explicit_id() {
350        let catalog = vec![descriptor("axel-memory-manager", "project-memory")];
351        let id = ContextProviderId::parse("project-memory").unwrap();
352        for requested in [Some(&id), None] {
353            let found = resolve_context_provider(&catalog, requested).unwrap();
354            assert_eq!(
355                found.runtime_address(),
356                "extension:axel-memory-manager:project-memory"
357            );
358        }
359        // Exact matching only: a near-miss id is NotRegistered, never a
360        // partial match.
361        let near_miss = ContextProviderId::parse("project-memory2").unwrap();
362        assert_eq!(
363            resolve_context_provider(&catalog, Some(&near_miss)).unwrap_err(),
364            ContextProviderLookupError::NotRegistered
365        );
366    }
367
368    #[test]
369    fn resolve_overlapping_declarations_fail_closed_ambiguous() {
370        // Two installed extensions declare the SAME local id.
371        let same_id = vec![
372            descriptor("mem-a", "project-memory"),
373            descriptor("mem-b", "project-memory"),
374        ];
375        let id = ContextProviderId::parse("project-memory").unwrap();
376        assert_eq!(
377            resolve_context_provider(&same_id, Some(&id)).unwrap_err(),
378            ContextProviderLookupError::Ambiguous
379        );
380        assert_eq!(
381            resolve_context_provider(&same_id, None).unwrap_err(),
382            ContextProviderLookupError::Ambiguous
383        );
384
385        // Distinct ids: an id-less request is still ambiguous (the host
386        // never picks one arbitrarily), but an exact id resolves uniquely.
387        let distinct = vec![descriptor("mem-a", "alpha"), descriptor("mem-b", "beta")];
388        assert_eq!(
389            resolve_context_provider(&distinct, None).unwrap_err(),
390            ContextProviderLookupError::Ambiguous
391        );
392        let beta = ContextProviderId::parse("beta").unwrap();
393        let found = resolve_context_provider(&distinct, Some(&beta)).unwrap();
394        assert_eq!(found.extension_id(), "mem-b");
395    }
396}