1use 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
22pub const CONTEXT_PROVIDER_ID_MAX_BYTES: usize = DECLARED_NAME_MAX_BYTES;
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub struct ContextProviderId(String);
41
42impl ContextProviderId {
43 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 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#[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 pub fn new(
92 extension_id: &str,
93 declared: &DeclaredExtensionContextProvider,
94 ) -> Result<Self, &'static str> {
95 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 pub fn extension_id(&self) -> &str {
138 &self.extension_id
139 }
140
141 pub fn runtime_address(&self) -> String {
144 format!("extension:{}:{}", self.extension_id, self.id.as_str())
145 }
146}
147
148pub(crate) type SharedContextProviderCatalog =
157 std::sync::Arc<std::sync::Mutex<Vec<RegisteredContextProviderDescriptor>>>;
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum ContextProviderLookupError {
163 NotRegistered,
167 Ambiguous,
171}
172
173pub 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
202pub 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 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 #[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 assert_ne!(id, ContextProviderId::parse("project-memory2").unwrap());
252 assert_ne!(id, ContextProviderId::parse("Project-memory").unwrap());
253 assert_eq!(id.to_string(), "project-memory");
255 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 #[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 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 for bad in ["", "has:colon", "ctrl\u{7}"] {
319 assert!(RegisteredContextProviderDescriptor::new(bad, &declared("p")).is_err());
320 }
321 assert!(RegisteredContextProviderDescriptor::new("plug", &declared("bad:id")).is_err());
323 let mut zero = declared("p");
325 zero.schema_version = 0;
326 assert!(RegisteredContextProviderDescriptor::new("plug", &zero).is_err());
327 }
328
329 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 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 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 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}