1use std::collections::HashSet;
15use std::sync::Arc;
16
17use serde_json::Value;
18
19use super::manifest::ExtensionManifest;
20use super::runtime::process::RegisteredExtensionToolSpec;
21use super::validation::validate_id_segment;
22use crate::tools::catalog::SchemaDigest;
23use crate::tools::{Tool, ToolContext, ToolOrigin};
24use agent_core::BoundedText;
25
26pub const DECLARED_MAX_TOOLS: usize = 256;
29pub const DECLARED_MAX_PROVIDERS: usize = 16;
30pub const DECLARED_NAME_MAX_BYTES: usize = 128;
31pub const DECLARED_DESCRIPTION_MAX_BYTES: usize = 1024;
32pub const DECLARED_SCHEMA_MAX_BYTES: usize = 64 * 1024;
33pub const DECLARED_MAX_CONTEXT_PROVIDERS: usize = 8;
37
38#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
40pub struct DeferredDeclarations {
41 #[serde(default)]
43 pub tools: Vec<DeclaredExtensionTool>,
44 #[serde(default)]
48 pub providers: Vec<DeclaredExtensionProvider>,
49 #[serde(default)]
51 pub lifecycle: Option<DeferredLifecycle>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub context_providers: Vec<DeclaredExtensionContextProvider>,
62}
63
64#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
68pub struct DeclaredExtensionProvider {
69 pub id: String,
70 pub display_name: String,
71 #[serde(default)]
72 pub description: String,
73 #[serde(default)]
74 pub models: Vec<DeclaredExtensionProviderModel>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub config_schema: Option<Value>,
77}
78
79#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
81pub struct DeclaredExtensionProviderModel {
82 pub id: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub display_name: Option<String>,
85 #[serde(default)]
86 pub capabilities: Value,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub context_window: Option<u64>,
89}
90
91pub const DECLARED_MAX_PROVIDER_MODELS: usize = 64;
93pub const DECLARED_DISPLAY_MAX_BYTES: usize = 256;
94
95#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
104pub struct DeclaredExtensionContextProvider {
105 pub id: String,
108 pub capability: String,
110 #[serde(default)]
112 pub description: String,
113 pub schema_version: u32,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "lowercase")]
119pub enum DeferredLifecycle {
120 Hook,
122 User,
124}
125
126#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
130pub struct DeclaredExtensionTool {
131 pub name: String,
132 #[serde(default)]
133 pub description: String,
134 pub input_schema: Value,
135}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum ExtensionClass {
140 LegacyEager,
142 ToolOnly,
143 Provider,
144 HookLifecycle,
145 UiSidecar,
146 ContextProvider,
150 Mixed,
151}
152
153pub fn has_context_provider(declared: &DeferredDeclarations) -> bool {
156 !declared.context_providers.is_empty()
157}
158
159pub fn classify(manifest: &ExtensionManifest) -> ExtensionClass {
161 let Some(deferred) = &manifest.deferred else {
162 return ExtensionClass::LegacyEager;
163 };
164 let tools = !deferred.tools.is_empty();
165 let providers = !deferred.providers.is_empty();
166 let context = has_context_provider(deferred);
167 let hooks = !manifest.hooks.is_empty() || deferred.lifecycle == Some(DeferredLifecycle::Hook);
168 let user = deferred.lifecycle == Some(DeferredLifecycle::User);
169 match (tools, providers, context, hooks, user) {
172 (false, false, false, false, false) => ExtensionClass::LegacyEager,
173 (true, false, false, false, false) => ExtensionClass::ToolOnly,
174 (false, true, false, false, false) => ExtensionClass::Provider,
175 (false, false, true, false, false) => ExtensionClass::ContextProvider,
176 (false, false, false, true, false) => ExtensionClass::HookLifecycle,
177 (false, false, false, false, true) => ExtensionClass::UiSidecar,
178 _ => ExtensionClass::Mixed,
179 }
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum ActivationTrigger {
189 Eager,
190 FirstAuthorizedHookEvent,
191 ProviderSelection,
192 ExactToolActivation,
193 UserAction,
194}
195
196pub fn earliest_trigger(manifest: &ExtensionManifest) -> ActivationTrigger {
197 match classify(manifest) {
198 ExtensionClass::LegacyEager => ActivationTrigger::Eager,
199 ExtensionClass::ToolOnly => ActivationTrigger::ExactToolActivation,
200 ExtensionClass::Provider => ActivationTrigger::ProviderSelection,
201 ExtensionClass::HookLifecycle => ActivationTrigger::FirstAuthorizedHookEvent,
202 ExtensionClass::UiSidecar => ActivationTrigger::UserAction,
203 ExtensionClass::ContextProvider => ActivationTrigger::UserAction,
208 ExtensionClass::Mixed => {
209 let deferred = manifest.deferred.as_ref();
210 let hooks = !manifest.hooks.is_empty()
211 || deferred.and_then(|d| d.lifecycle) == Some(DeferredLifecycle::Hook);
212 let providers = deferred.map(|d| !d.providers.is_empty()).unwrap_or(false);
213 if hooks {
214 ActivationTrigger::FirstAuthorizedHookEvent
215 } else if providers {
216 ActivationTrigger::ProviderSelection
217 } else {
218 ActivationTrigger::ExactToolActivation
219 }
220 }
221 }
222}
223
224fn valid_name(name: &str) -> bool {
225 !name.is_empty() && name.len() <= DECLARED_NAME_MAX_BYTES && !name.chars().any(char::is_control)
226}
227
228fn valid_capability_id(name: &str) -> bool {
243 validate_id_segment(name).is_ok()
244}
245
246pub fn validate_deferred(deferred: &DeferredDeclarations) -> Result<(), &'static str> {
249 if deferred.tools.len() > DECLARED_MAX_TOOLS {
250 return Err("too_many_declared_tools");
251 }
252 let mut seen: HashSet<&str> = HashSet::new();
253 for tool in &deferred.tools {
254 validate_tool_shape(&tool.name, &tool.description, &tool.input_schema)?;
255 if !seen.insert(tool.name.as_str()) {
256 return Err("duplicate_declared_tool_name");
257 }
258 }
259 if deferred.providers.len() > DECLARED_MAX_PROVIDERS {
260 return Err("too_many_declared_providers");
261 }
262 let mut provider_ids: HashSet<&str> = HashSet::new();
263 for provider in &deferred.providers {
264 if !valid_capability_id(&provider.id) {
265 return Err("invalid_declared_provider_id");
266 }
267 if !provider_ids.insert(provider.id.as_str()) {
268 return Err("duplicate_declared_provider_id");
269 }
270 if provider.display_name.is_empty()
271 || provider.display_name.len() > DECLARED_DISPLAY_MAX_BYTES
272 || provider.display_name.chars().any(char::is_control)
273 {
274 return Err("invalid_declared_provider_display_name");
275 }
276 if provider.description.is_empty()
277 || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
278 {
279 return Err("invalid_declared_provider_description");
280 }
281 if provider.models.is_empty() || provider.models.len() > DECLARED_MAX_PROVIDER_MODELS {
282 return Err("invalid_declared_provider_model_count");
283 }
284 let mut model_ids: HashSet<&str> = HashSet::new();
285 for model in &provider.models {
286 if !valid_capability_id(&model.id) {
287 return Err("invalid_declared_provider_model_id");
288 }
289 if !model_ids.insert(model.id.as_str()) {
290 return Err("duplicate_declared_provider_model_id");
291 }
292 if let Some(display) = &model.display_name {
293 if display.len() > DECLARED_DISPLAY_MAX_BYTES
294 || display.chars().any(char::is_control)
295 {
296 return Err("invalid_declared_provider_model_display_name");
297 }
298 }
299 match &model.capabilities {
301 Value::Null => {}
302 Value::Object(map) => {
303 for (key, value) in map {
304 if !valid_name(key) || !value.is_boolean() {
305 return Err("invalid_declared_provider_model_capabilities");
306 }
307 }
308 }
309 _ => return Err("invalid_declared_provider_model_capabilities"),
310 }
311 }
312 if let Some(config_schema) = &provider.config_schema {
313 let len = serde_json::to_vec(config_schema)
314 .map(|b| b.len())
315 .unwrap_or(usize::MAX);
316 if !config_schema.is_object() || len > DECLARED_SCHEMA_MAX_BYTES {
317 return Err("invalid_declared_provider_config_schema");
318 }
319 }
320 }
321 if deferred.context_providers.len() > DECLARED_MAX_CONTEXT_PROVIDERS {
325 return Err("too_many_declared_context_providers");
326 }
327 let mut context_provider_ids: HashSet<&str> = HashSet::new();
328 for provider in &deferred.context_providers {
329 if super::context_provider::ContextProviderId::parse(&provider.id).is_err() {
332 return Err("invalid_declared_context_provider_id");
333 }
334 if !context_provider_ids.insert(provider.id.as_str()) {
335 return Err("duplicate_declared_context_provider_id");
336 }
337 if !valid_name(&provider.capability) {
338 return Err("invalid_declared_context_provider_capability");
339 }
340 if provider.description.is_empty()
341 || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
342 {
343 return Err("invalid_declared_context_provider_description");
344 }
345 if provider.schema_version == 0 {
346 return Err("invalid_declared_context_provider_schema_version");
347 }
348 }
349 if deferred.lifecycle == Some(DeferredLifecycle::User)
352 && (!deferred.tools.is_empty() || !deferred.providers.is_empty())
353 {
354 return Err("user_lifecycle_conflicts_with_active_capabilities");
355 }
356 Ok(())
357}
358
359pub fn validate_manifest_deferred(manifest: &ExtensionManifest) -> Result<(), &'static str> {
372 let Some(deferred) = &manifest.deferred else {
373 return Ok(());
374 };
375 validate_deferred(deferred)?;
376 let has_permission = |wire: &str| manifest.permissions.iter().any(|p| p == wire);
377 if !deferred.tools.is_empty() && !has_permission("tools.register") {
378 return Err("deferred_tools_require_tools_register_permission");
379 }
380 if !deferred.providers.is_empty() && !has_permission("providers.register") {
381 return Err("deferred_providers_require_providers_register_permission");
382 }
383 if !deferred.context_providers.is_empty() && !has_permission("context_providers.register") {
388 return Err("deferred_context_providers_require_context_providers_register_permission");
389 }
390 if deferred.lifecycle == Some(DeferredLifecycle::Hook) && manifest.hooks.is_empty() {
391 return Err("hook_lifecycle_requires_hook_subscriptions");
392 }
393 Ok(())
394}
395
396fn validate_tool_shape(
400 name: &str,
401 description: &str,
402 input_schema: &Value,
403) -> Result<(), &'static str> {
404 if !valid_capability_id(name) {
405 return Err("invalid_declared_tool_name");
406 }
407 if description.trim().is_empty() || description.len() > DECLARED_DESCRIPTION_MAX_BYTES {
411 return Err("invalid_declared_tool_description");
412 }
413 if !input_schema.is_object() {
414 return Err("declared_tool_schema_not_object");
415 }
416 let schema_len = serde_json::to_vec(input_schema)
417 .map(|b| b.len())
418 .unwrap_or(usize::MAX);
419 if schema_len > DECLARED_SCHEMA_MAX_BYTES {
420 return Err("oversized_declared_tool_schema");
421 }
422 Ok(())
423}
424
425pub fn validate_runtime_tool_declarations(
431 declared: &[DeclaredExtensionTool],
432 registered: &[RegisteredExtensionToolSpec],
433) -> Result<(), &'static str> {
434 if declared.len() != registered.len() {
435 return Err("declared_and_registered_tool_counts_differ");
436 }
437 for spec in registered {
438 validate_tool_shape(&spec.name, &spec.description, &spec.input_schema)?;
441 }
442 for decl in declared {
443 let Some(live) = registered.iter().find(|spec| spec.name == decl.name) else {
444 return Err("declared_tool_not_registered");
445 };
446 if SchemaDigest::of_schema(&live.input_schema)
447 != SchemaDigest::of_schema(&decl.input_schema)
448 {
449 return Err("registered_tool_schema_digest_mismatch");
450 }
451 if live.description != decl.description {
454 return Err("registered_tool_description_mismatch");
455 }
456 }
457 let mut live_names: HashSet<&str> = HashSet::new();
461 for spec in registered {
462 if !live_names.insert(spec.name.as_str()) {
463 return Err("registered_tool_names_duplicate");
464 }
465 }
466 Ok(())
467}
468
469pub fn validate_runtime_provider_declarations(
478 declared: &[DeclaredExtensionProvider],
479 registered: &[crate::extensions::runtime::process::RegisteredProviderSpec],
480) -> Result<(), &'static str> {
481 if declared.len() != registered.len() {
482 return Err("declared_and_registered_provider_counts_differ");
483 }
484 let mut live_ids: HashSet<&str> = HashSet::new();
485 for spec in registered {
486 if !live_ids.insert(spec.id.as_str()) {
487 return Err("registered_provider_ids_duplicate");
488 }
489 }
490 for decl in declared {
491 let Some(live) = registered.iter().find(|spec| spec.id == decl.id) else {
492 return Err("declared_provider_not_registered");
493 };
494 if live.display_name != decl.display_name {
495 return Err("registered_provider_display_name_mismatch");
496 }
497 if live.description != decl.description {
498 return Err("registered_provider_description_mismatch");
499 }
500 if live.config_schema != decl.config_schema {
501 return Err("registered_provider_config_schema_mismatch");
502 }
503 if live.models.len() != decl.models.len() {
504 return Err("registered_provider_model_counts_differ");
505 }
506 let mut live_model_ids: HashSet<&str> = HashSet::new();
507 for model in &live.models {
508 if !live_model_ids.insert(model.id.as_str()) {
509 return Err("registered_provider_model_ids_duplicate");
510 }
511 }
512 for declared_model in &decl.models {
513 let Some(live_model) = live.models.iter().find(|m| m.id == declared_model.id) else {
514 return Err("declared_provider_model_not_registered");
515 };
516 if live_model.display_name != declared_model.display_name {
517 return Err("registered_provider_model_display_name_mismatch");
518 }
519 if live_model.context_window != declared_model.context_window {
520 return Err("registered_provider_model_context_window_mismatch");
521 }
522 if live_model.capabilities != declared_model.capabilities {
523 return Err("registered_provider_model_capabilities_mismatch");
524 }
525 }
526 }
527 Ok(())
528}
529
530impl DeclaredExtensionProvider {
531 pub fn to_registered_spec(
536 &self,
537 ) -> crate::extensions::runtime::process::RegisteredProviderSpec {
538 crate::extensions::runtime::process::RegisteredProviderSpec {
539 id: self.id.clone(),
540 display_name: self.display_name.clone(),
541 description: self.description.clone(),
542 models: self
543 .models
544 .iter()
545 .map(
546 |model| crate::extensions::runtime::process::RegisteredProviderModelSpec {
547 id: model.id.clone(),
548 display_name: model.display_name.clone(),
549 capabilities: model.capabilities.clone(),
550 context_window: model.context_window,
551 },
552 )
553 .collect(),
554 config_schema: self.config_schema.clone(),
555 }
556 }
557}
558
559pub struct DeferredExtensionTool {
568 plugin_id: String,
569 runtime_name: String,
570 tool_name: String,
571 description: String,
572 input_schema: Value,
573 expected_digest: SchemaDigest,
574}
575
576impl DeferredExtensionTool {
577 fn new(plugin_id: &str, declared: &DeclaredExtensionTool) -> Self {
578 Self {
579 plugin_id: plugin_id.to_string(),
580 runtime_name: format!("{}:{}", plugin_id, declared.name),
582 tool_name: declared.name.clone(),
583 description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
584 .text,
585 input_schema: declared.input_schema.clone(),
586 expected_digest: SchemaDigest::of_schema(&declared.input_schema),
587 }
588 }
589
590 pub fn plugin_id(&self) -> &str {
591 &self.plugin_id
592 }
593
594 pub fn tool_name(&self) -> &str {
595 &self.tool_name
596 }
597
598 pub fn expected_digest(&self) -> &SchemaDigest {
601 &self.expected_digest
602 }
603}
604
605#[async_trait::async_trait]
606impl Tool for DeferredExtensionTool {
607 fn origin(&self) -> ToolOrigin {
608 ToolOrigin::Extension {
609 extension_id: self.plugin_id.clone(),
610 }
611 }
612
613 fn source_operation(&self) -> Option<&str> {
614 Some(&self.tool_name)
615 }
616
617 fn name(&self) -> &str {
618 &self.runtime_name
619 }
620
621 fn description(&self) -> &str {
622 &self.description
623 }
624
625 fn parameters(&self) -> Value {
626 self.input_schema.clone()
627 }
628
629 async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
630 let binding = ctx
632 .capabilities
633 .memory_backend
634 .clone()
635 .unwrap_or_else(crate::memory_backend::MemoryBinding::configured_current);
636 if binding.exclusive() && crate::memory_backend::competing_memory_tool(self) {
637 return Err(crate::RuntimeError::Tool("independent extension memory is disabled by the selected host backend; use short memory tools".into()));
638 }
639 let Some(leases) = ctx.capabilities.extension_leases.clone() else {
642 return Err(crate::RuntimeError::Tool(format!(
643 "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
644 runtime lease capability is available in this context; no process was started",
645 self.runtime_name, self.plugin_id
646 )));
647 };
648 match leases
649 .call_exact_for(
650 &self.plugin_id,
651 &self.tool_name,
652 &self.expected_digest,
653 params,
654 ctx.capabilities.session_id.as_deref(),
655 )
656 .await
657 {
658 Ok(value) => Ok(render_tool_result(&value)),
659 Err(err) => {
660 if err.revokes_exact_grant() {
668 if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
669 if activation.revoke_exact_extension_grant(
670 leases.session(),
671 &self.plugin_id,
672 &self.tool_name,
673 ) {
674 tracing::debug!(
675 "revoked exact extension activation after declaration invalidation"
676 );
677 }
678 }
679 }
680 Err(crate::RuntimeError::Tool(err.to_string()))
681 }
682 }
683 }
684}
685
686fn render_tool_result(value: &Value) -> String {
690 if let Some(text) = value.as_str() {
691 text.to_string()
692 } else if let Some(text) = value.get("content").and_then(Value::as_str) {
693 text.to_string()
694 } else {
695 value.to_string()
696 }
697}
698
699impl DeclaredExtensionTool {
700 pub fn schema_digest(&self) -> SchemaDigest {
704 SchemaDigest::of_schema(&self.input_schema)
705 }
706}
707
708pub fn dormant_extension_tools(
712 plugin_id: &str,
713 manifest: &ExtensionManifest,
714) -> Vec<Arc<dyn Tool>> {
715 if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
717 return Vec::new();
718 }
719 let Some(deferred) = &manifest.deferred else {
720 return Vec::new();
721 };
722 if validate_manifest_deferred(manifest).is_err() {
726 return Vec::new();
727 }
728 deferred
729 .tools
730 .iter()
731 .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
732 .collect()
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738 use serde_json::json;
739
740 fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
741 serde_json::from_value(json!({
742 "runtime": "process",
743 "command": "/bin/false",
744 "permissions": permissions,
745 }))
746 .unwrap()
747 }
748
749 fn declared_tool(name: &str) -> DeclaredExtensionTool {
750 DeclaredExtensionTool {
751 name: name.to_string(),
752 description: format!("declared {name}"),
753 input_schema: json!({"type": "object"}),
754 }
755 }
756
757 fn declared_context_provider(id: &str) -> DeclaredExtensionContextProvider {
758 DeclaredExtensionContextProvider {
759 id: id.to_string(),
760 capability: "project-memory".to_string(),
761 description: "Project memory context contributions".to_string(),
762 schema_version: 1,
763 }
764 }
765
766 fn with_context_providers(
767 permissions: &[&str],
768 context_providers: Vec<DeclaredExtensionContextProvider>,
769 ) -> ExtensionManifest {
770 let mut m = base_manifest(permissions);
771 m.deferred = Some(DeferredDeclarations {
772 context_providers,
773 ..DeferredDeclarations::default()
774 });
775 m
776 }
777
778 #[test]
781 fn a_deferred_extension_tool_names_its_registered_operation() {
782 let tool = DeferredExtensionTool::new("plug", &declared_tool("memory_fetch"));
783 assert_eq!(tool.name(), "plug:memory_fetch");
784 assert_eq!(tool.source_operation(), Some("memory_fetch"));
785 }
786
787 #[test]
788 fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
789 let legacy = base_manifest(&["tools.register"]);
791 assert!(legacy.deferred.is_none());
792 assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
793 assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
794 legacy.validate("legacy").unwrap();
795
796 let mut empty = base_manifest(&["tools.register"]);
798 empty.deferred = Some(DeferredDeclarations::default());
799 assert!(empty
800 .deferred
801 .as_ref()
802 .is_some_and(|d| d.context_providers.is_empty() && !has_context_provider(d)));
803 assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
804 assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
805 empty.validate("empty").unwrap();
806
807 let d: DeferredDeclarations = serde_json::from_value(json!({
811 "tools": [], "providers": []
812 }))
813 .unwrap();
814 assert!(d.context_providers.is_empty());
815 let round = serde_json::to_value(&d).unwrap();
816 assert!(round.get("context_providers").is_none());
817
818 let mut tool_only = base_manifest(&["tools.register"]);
821 tool_only.deferred = Some(DeferredDeclarations {
822 tools: vec![declared_tool("t")],
823 ..DeferredDeclarations::default()
824 });
825 assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
826 let mut sidecar = base_manifest(&["tools.register"]);
828 sidecar.deferred = Some(DeferredDeclarations {
829 lifecycle: Some(DeferredLifecycle::User),
830 ..DeferredDeclarations::default()
831 });
832 assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
833 let mut hooky: ExtensionManifest = serde_json::from_value(json!({
835 "runtime": "process",
836 "command": "/bin/false",
837 "permissions": ["tools.intercept"],
838 "hooks": [{"hook": "before_tool_call"}]
839 }))
840 .unwrap();
841 hooky.deferred = Some(DeferredDeclarations::default());
842 assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
843 }
844
845 #[test]
848 fn context_provider_only_manifest_classifies_distinctly_and_requires_permission() {
849 let m = with_context_providers(
850 &["context_providers.register"],
851 vec![declared_context_provider("project-memory")],
852 );
853 assert!(m.deferred.as_ref().is_some_and(has_context_provider));
854 assert_eq!(classify(&m), ExtensionClass::ContextProvider);
855 assert_eq!(earliest_trigger(&m), ActivationTrigger::UserAction);
858 m.validate("axel-memory-manager").unwrap();
859
860 let ungated = with_context_providers(
862 &["tools.register"],
863 vec![declared_context_provider("project-memory")],
864 );
865 let err = ungated.validate("axel-memory-manager").unwrap_err();
866 assert!(
867 err.contains(
868 "deferred_context_providers_require_context_providers_register_permission"
869 ),
870 "{err}"
871 );
872 let wrong_gate = with_context_providers(
874 &["providers.register"],
875 vec![declared_context_provider("project-memory")],
876 );
877 assert!(wrong_gate.validate("axel-memory-manager").is_err());
878 }
879
880 #[test]
881 fn mixed_combinations_with_context_providers_still_classify_mixed() {
882 let mut m = with_context_providers(
884 &["tools.register", "context_providers.register"],
885 vec![declared_context_provider("project-memory")],
886 );
887 m.deferred.as_mut().unwrap().tools = vec![declared_tool("t")];
888 assert_eq!(classify(&m), ExtensionClass::Mixed);
889 m.validate("plug").unwrap();
890 assert_eq!(earliest_trigger(&m), ActivationTrigger::ExactToolActivation);
893 }
894
895 #[test]
898 fn too_many_declared_context_providers_are_rejected() {
899 let at_cap: Vec<_> = (0..DECLARED_MAX_CONTEXT_PROVIDERS)
900 .map(|i| declared_context_provider(&format!("p{i}")))
901 .collect();
902 with_context_providers(&["context_providers.register"], at_cap)
903 .validate("plug")
904 .unwrap();
905
906 let over: Vec<_> = (0..=DECLARED_MAX_CONTEXT_PROVIDERS)
907 .map(|i| declared_context_provider(&format!("p{i}")))
908 .collect();
909 let m = with_context_providers(&["context_providers.register"], over);
910 assert_eq!(
911 validate_deferred(m.deferred.as_ref().unwrap()),
912 Err("too_many_declared_context_providers")
913 );
914 assert!(m.validate("plug").is_err());
915 }
916
917 #[test]
918 fn oversized_or_hostile_context_provider_fields_are_rejected() {
919 let check = |p: DeclaredExtensionContextProvider, reason: &'static str| {
920 let m = with_context_providers(&["context_providers.register"], vec![p]);
921 assert_eq!(validate_deferred(m.deferred.as_ref().unwrap()), Err(reason));
922 assert!(m.validate("plug").is_err());
923 };
924 check(
926 declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES + 1)),
927 "invalid_declared_context_provider_id",
928 );
929 check(
931 declared_context_provider(""),
932 "invalid_declared_context_provider_id",
933 );
934 check(
935 declared_context_provider("has:colon"),
936 "invalid_declared_context_provider_id",
937 );
938 let mut p = declared_context_provider("p");
940 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES + 1);
941 check(p, "invalid_declared_context_provider_capability");
942 let mut p = declared_context_provider("p");
943 p.capability = String::new();
944 check(p, "invalid_declared_context_provider_capability");
945 let mut p = declared_context_provider("p");
946 p.capability = "ctrl\u{7}".to_string();
947 check(p, "invalid_declared_context_provider_capability");
948 let mut p = declared_context_provider("p");
950 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES + 1);
951 check(p, "invalid_declared_context_provider_description");
952 let mut p = declared_context_provider("p");
953 p.description = String::new();
954 check(p, "invalid_declared_context_provider_description");
955 let mut p = declared_context_provider("p");
957 p.schema_version = 0;
958 check(p, "invalid_declared_context_provider_schema_version");
959 let m = with_context_providers(
961 &["context_providers.register"],
962 vec![
963 declared_context_provider("dup"),
964 declared_context_provider("dup"),
965 ],
966 );
967 assert_eq!(
968 validate_deferred(m.deferred.as_ref().unwrap()),
969 Err("duplicate_declared_context_provider_id")
970 );
971 let mut p = declared_context_provider(&"i".repeat(DECLARED_NAME_MAX_BYTES));
973 p.capability = "c".repeat(DECLARED_NAME_MAX_BYTES);
974 p.description = "d".repeat(DECLARED_DESCRIPTION_MAX_BYTES);
975 with_context_providers(&["context_providers.register"], vec![p])
976 .validate("plug")
977 .unwrap();
978 }
979
980 #[test]
983 fn dormant_descriptors_are_minted_only_for_validated_gated_manifests() {
984 use super::super::context_provider::dormant_context_provider_descriptors;
985 let m = with_context_providers(
986 &["context_providers.register"],
987 vec![declared_context_provider("project-memory")],
988 );
989 let descriptors = dormant_context_provider_descriptors("axel-memory-manager", &m);
990 assert_eq!(descriptors.len(), 1);
991 assert_eq!(
992 descriptors[0].runtime_address(),
993 "extension:axel-memory-manager:project-memory"
994 );
995 assert_eq!(descriptors[0].schema_version(), 1);
996
997 let ungated = with_context_providers(
1000 &["tools.register"],
1001 vec![declared_context_provider("project-memory")],
1002 );
1003 assert!(dormant_context_provider_descriptors("axel-memory-manager", &ungated).is_empty());
1004 assert!(dormant_context_provider_descriptors("bad:plugin", &m).is_empty());
1006 assert!(
1008 dormant_context_provider_descriptors("p", &base_manifest(&["tools.register"]))
1009 .is_empty()
1010 );
1011 }
1012
1013 #[test]
1021 fn declared_capability_ids_agree_with_runtime_validation() {
1022 let cases = [
1023 "ok",
1024 "with-dash",
1025 "with_underscore",
1026 "MiXedCase123",
1027 "",
1028 "has space",
1029 "has\ttab",
1030 "colon:name",
1031 "trailing ",
1032 &"a".repeat(super::super::validation::MAX_ID_LENGTH),
1033 &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
1034 &"a".repeat(DECLARED_NAME_MAX_BYTES),
1035 ];
1036 for name in cases {
1037 let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
1038 let runtime_ok = validate_id_segment(name).is_ok();
1039 assert_eq!(
1040 manifest_ok, runtime_ok,
1041 "manifest/runtime disagree on declared tool name {name:?}: \
1042 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
1043 );
1044 }
1045 }
1046
1047 #[test]
1052 fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
1053 let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
1054 assert_eq!(
1055 validate_deferred(&DeferredDeclarations {
1056 tools: vec![declared_tool(&name)],
1057 ..Default::default()
1058 }),
1059 Err("invalid_declared_tool_name"),
1060 );
1061 assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
1064 }
1065
1066 #[test]
1068 fn declared_capability_ids_reject_whitespace_at_manifest() {
1069 assert_eq!(
1070 validate_deferred(&DeferredDeclarations {
1071 tools: vec![declared_tool("my tool")],
1072 ..Default::default()
1073 }),
1074 Err("invalid_declared_tool_name"),
1075 );
1076 }
1077
1078 const MEMORY_REFUSAL: &str = "independent extension memory is disabled by the selected host backend; use short memory tools";
1081
1082 async fn call_deferred(
1083 plugin: &str,
1084 operation: &str,
1085 kind: agent_core::config::MemoryBackendKind,
1086 ) -> String {
1087 let dir = tempfile::tempdir().unwrap();
1088 let binding = crate::tools::test_helpers::memory_binding(kind, dir.path());
1089 let tool = DeferredExtensionTool::new(plugin, &declared_tool(operation));
1090 match tool
1091 .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
1092 .await
1093 {
1094 Err(crate::RuntimeError::Tool(message)) => message,
1095 other => panic!("expected a tool error, got {other:?}"),
1096 }
1097 }
1098
1099 #[tokio::test]
1102 async fn an_exclusive_binding_refuses_deferred_extension_memory_aliases() {
1103 use agent_core::config::MemoryBackendKind::Axel;
1104 for (plugin, operation) in [
1105 ("memory", "search"),
1106 ("memory", "fetch"),
1107 ("Memory", "Search"),
1108 ("plug", "memory.store"),
1109 ("plug", "MEMORY_STORE"),
1110 ("axel-memory-manager", "memory_search"),
1111 ("axel-memory-manager", "memory_forget"),
1112 ] {
1113 assert_eq!(
1114 call_deferred(plugin, operation, Axel).await,
1115 MEMORY_REFUSAL,
1116 "{plugin}:{operation}"
1117 );
1118 }
1119 }
1120
1121 #[tokio::test]
1124 async fn the_suffixed_wire_name_of_a_deferred_alias_is_refused() {
1125 let dir = tempfile::tempdir().unwrap();
1126 let registry = crate::tools::ToolRegistry::from_tools_for_tests(vec![
1127 Arc::new(crate::tools::memory::MemorySearchTool),
1128 Arc::new(DeferredExtensionTool::new("memory", &declared_tool("search"))),
1129 ]);
1130 assert_eq!(registry.runtime_name_for_api("memory_search"), "memory_search");
1131 assert_eq!(registry.runtime_name_for_api("memory_search_2"), "memory:search");
1132 let alias = registry
1133 .get(registry.runtime_name_for_api("memory_search_2"))
1134 .expect("alias registered");
1135 let binding = crate::tools::test_helpers::memory_binding(
1136 agent_core::config::MemoryBackendKind::Axel,
1137 dir.path(),
1138 );
1139 match alias
1140 .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
1141 .await
1142 {
1143 Err(crate::RuntimeError::Tool(message)) => assert_eq!(message, MEMORY_REFUSAL),
1144 other => panic!("expected the memory refusal, got {other:?}"),
1145 }
1146 }
1147
1148 #[test]
1153 fn a_nested_colon_name_never_becomes_a_deferred_tool() {
1154 assert_eq!(
1155 validate_deferred(&DeferredDeclarations {
1156 tools: vec![declared_tool("memory:fetch")],
1157 ..Default::default()
1158 }),
1159 Err("invalid_declared_tool_name"),
1160 );
1161 let mut manifest = base_manifest(&["tools.register"]);
1162 manifest.deferred = Some(DeferredDeclarations {
1163 tools: vec![declared_tool("fetch")],
1164 ..DeferredDeclarations::default()
1165 });
1166 assert_eq!(dormant_extension_tools("plug", &manifest).len(), 1);
1167 assert!(dormant_extension_tools("plug:memory", &manifest).is_empty());
1168 }
1169
1170 #[tokio::test]
1173 async fn other_deferred_tools_and_legacy_bindings_reach_the_lease_check() {
1174 use agent_core::config::MemoryBackendKind::{Axel, Legacy};
1175 for (plugin, operation, kind) in [
1176 ("weather", "search", Axel),
1177 ("memory", "summarize", Axel),
1178 ("memory", "search", Legacy),
1179 ("axel-memory-manager", "memory_search", Legacy),
1180 ] {
1181 let message = call_deferred(plugin, operation, kind).await;
1182 assert!(
1183 message.contains("is activation-deferred"),
1184 "{plugin}:{operation}: {message}"
1185 );
1186 }
1187 }
1188}