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;
33
34#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
36pub struct DeferredDeclarations {
37 #[serde(default)]
39 pub tools: Vec<DeclaredExtensionTool>,
40 #[serde(default)]
44 pub providers: Vec<DeclaredExtensionProvider>,
45 #[serde(default)]
47 pub lifecycle: Option<DeferredLifecycle>,
48 #[serde(default, skip_serializing_if = "Value::is_null")]
54 pub context_providers: Value,
55}
56
57#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
61pub struct DeclaredExtensionProvider {
62 pub id: String,
63 pub display_name: String,
64 #[serde(default)]
65 pub description: String,
66 #[serde(default)]
67 pub models: Vec<DeclaredExtensionProviderModel>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub config_schema: Option<Value>,
70}
71
72#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
74pub struct DeclaredExtensionProviderModel {
75 pub id: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub display_name: Option<String>,
78 #[serde(default)]
79 pub capabilities: Value,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub context_window: Option<u64>,
82}
83
84pub const DECLARED_MAX_PROVIDER_MODELS: usize = 64;
86pub const DECLARED_DISPLAY_MAX_BYTES: usize = 256;
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum DeferredLifecycle {
91 Hook,
93 User,
95}
96
97#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
101pub struct DeclaredExtensionTool {
102 pub name: String,
103 #[serde(default)]
104 pub description: String,
105 pub input_schema: Value,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum ExtensionClass {
111 LegacyEager,
113 ToolOnly,
114 Provider,
115 HookLifecycle,
116 UiSidecar,
117 Mixed,
118}
119
120pub fn ignored_context_provider_notice(id: &str, manifest: &ExtensionManifest) -> Option<String> {
127 let declared_field = manifest.deferred.as_ref().is_some_and(|deferred| {
128 match &deferred.context_providers {
129 Value::Null => false,
130 Value::Array(items) => !items.is_empty(),
131 Value::Object(fields) => !fields.is_empty(),
132 _ => true,
133 }
134 });
135 let declared_permission = manifest
136 .permissions
137 .iter()
138 .any(|permission| permission == "context_providers.register");
139 (declared_field || declared_permission).then(|| {
140 format!(
141 "extension {id} declares a context provider; built-in automatic memory was removed \
142 in {}, the declaration is ignored",
143 agent_core::AXEL_BACKEND_REMOVED_IN
144 )
145 })
146}
147
148pub fn classify(manifest: &ExtensionManifest) -> ExtensionClass {
153 let Some(deferred) = &manifest.deferred else {
154 return ExtensionClass::LegacyEager;
155 };
156 let tools = !deferred.tools.is_empty();
157 let providers = !deferred.providers.is_empty();
158 let hooks = !manifest.hooks.is_empty() || deferred.lifecycle == Some(DeferredLifecycle::Hook);
159 let user = deferred.lifecycle == Some(DeferredLifecycle::User);
160 match (tools, providers, hooks, user) {
161 (false, false, false, false) => ExtensionClass::LegacyEager,
162 (true, false, false, false) => ExtensionClass::ToolOnly,
163 (false, true, false, false) => ExtensionClass::Provider,
164 (false, false, true, false) => ExtensionClass::HookLifecycle,
165 (false, false, false, true) => ExtensionClass::UiSidecar,
166 _ => ExtensionClass::Mixed,
167 }
168}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176pub enum ActivationTrigger {
177 Eager,
178 FirstAuthorizedHookEvent,
179 ProviderSelection,
180 ExactToolActivation,
181 UserAction,
182}
183
184pub fn earliest_trigger(manifest: &ExtensionManifest) -> ActivationTrigger {
185 match classify(manifest) {
186 ExtensionClass::LegacyEager => ActivationTrigger::Eager,
187 ExtensionClass::ToolOnly => ActivationTrigger::ExactToolActivation,
188 ExtensionClass::Provider => ActivationTrigger::ProviderSelection,
189 ExtensionClass::HookLifecycle => ActivationTrigger::FirstAuthorizedHookEvent,
190 ExtensionClass::UiSidecar => ActivationTrigger::UserAction,
191 ExtensionClass::Mixed => {
192 let deferred = manifest.deferred.as_ref();
193 let hooks = !manifest.hooks.is_empty()
194 || deferred.and_then(|d| d.lifecycle) == Some(DeferredLifecycle::Hook);
195 let providers = deferred.map(|d| !d.providers.is_empty()).unwrap_or(false);
196 if hooks {
197 ActivationTrigger::FirstAuthorizedHookEvent
198 } else if providers {
199 ActivationTrigger::ProviderSelection
200 } else {
201 ActivationTrigger::ExactToolActivation
202 }
203 }
204 }
205}
206
207fn valid_name(name: &str) -> bool {
208 !name.is_empty() && name.len() <= DECLARED_NAME_MAX_BYTES && !name.chars().any(char::is_control)
209}
210
211fn valid_capability_id(name: &str) -> bool {
226 validate_id_segment(name).is_ok()
227}
228
229pub fn validate_deferred(deferred: &DeferredDeclarations) -> Result<(), &'static str> {
232 if deferred.tools.len() > DECLARED_MAX_TOOLS {
233 return Err("too_many_declared_tools");
234 }
235 let mut seen: HashSet<&str> = HashSet::new();
236 for tool in &deferred.tools {
237 validate_tool_shape(&tool.name, &tool.description, &tool.input_schema)?;
238 if !seen.insert(tool.name.as_str()) {
239 return Err("duplicate_declared_tool_name");
240 }
241 }
242 if deferred.providers.len() > DECLARED_MAX_PROVIDERS {
243 return Err("too_many_declared_providers");
244 }
245 let mut provider_ids: HashSet<&str> = HashSet::new();
246 for provider in &deferred.providers {
247 if !valid_capability_id(&provider.id) {
248 return Err("invalid_declared_provider_id");
249 }
250 if !provider_ids.insert(provider.id.as_str()) {
251 return Err("duplicate_declared_provider_id");
252 }
253 if provider.display_name.is_empty()
254 || provider.display_name.len() > DECLARED_DISPLAY_MAX_BYTES
255 || provider.display_name.chars().any(char::is_control)
256 {
257 return Err("invalid_declared_provider_display_name");
258 }
259 if provider.description.is_empty()
260 || provider.description.len() > DECLARED_DESCRIPTION_MAX_BYTES
261 {
262 return Err("invalid_declared_provider_description");
263 }
264 if provider.models.is_empty() || provider.models.len() > DECLARED_MAX_PROVIDER_MODELS {
265 return Err("invalid_declared_provider_model_count");
266 }
267 let mut model_ids: HashSet<&str> = HashSet::new();
268 for model in &provider.models {
269 if !valid_capability_id(&model.id) {
270 return Err("invalid_declared_provider_model_id");
271 }
272 if !model_ids.insert(model.id.as_str()) {
273 return Err("duplicate_declared_provider_model_id");
274 }
275 if let Some(display) = &model.display_name {
276 if display.len() > DECLARED_DISPLAY_MAX_BYTES
277 || display.chars().any(char::is_control)
278 {
279 return Err("invalid_declared_provider_model_display_name");
280 }
281 }
282 match &model.capabilities {
284 Value::Null => {}
285 Value::Object(map) => {
286 for (key, value) in map {
287 if !valid_name(key) || !value.is_boolean() {
288 return Err("invalid_declared_provider_model_capabilities");
289 }
290 }
291 }
292 _ => return Err("invalid_declared_provider_model_capabilities"),
293 }
294 }
295 if let Some(config_schema) = &provider.config_schema {
296 let len = serde_json::to_vec(config_schema)
297 .map(|b| b.len())
298 .unwrap_or(usize::MAX);
299 if !config_schema.is_object() || len > DECLARED_SCHEMA_MAX_BYTES {
300 return Err("invalid_declared_provider_config_schema");
301 }
302 }
303 }
304 if deferred.lifecycle == Some(DeferredLifecycle::User)
307 && (!deferred.tools.is_empty() || !deferred.providers.is_empty())
308 {
309 return Err("user_lifecycle_conflicts_with_active_capabilities");
310 }
311 Ok(())
312}
313
314pub fn validate_manifest_deferred(manifest: &ExtensionManifest) -> Result<(), &'static str> {
327 let Some(deferred) = &manifest.deferred else {
328 return Ok(());
329 };
330 validate_deferred(deferred)?;
331 let has_permission = |wire: &str| manifest.permissions.iter().any(|p| p == wire);
332 if !deferred.tools.is_empty() && !has_permission("tools.register") {
333 return Err("deferred_tools_require_tools_register_permission");
334 }
335 if !deferred.providers.is_empty() && !has_permission("providers.register") {
336 return Err("deferred_providers_require_providers_register_permission");
337 }
338 if deferred.lifecycle == Some(DeferredLifecycle::Hook) && manifest.hooks.is_empty() {
339 return Err("hook_lifecycle_requires_hook_subscriptions");
340 }
341 Ok(())
342}
343
344fn validate_tool_shape(
348 name: &str,
349 description: &str,
350 input_schema: &Value,
351) -> Result<(), &'static str> {
352 if !valid_capability_id(name) {
353 return Err("invalid_declared_tool_name");
354 }
355 if description.trim().is_empty() || description.len() > DECLARED_DESCRIPTION_MAX_BYTES {
359 return Err("invalid_declared_tool_description");
360 }
361 if !input_schema.is_object() {
362 return Err("declared_tool_schema_not_object");
363 }
364 let schema_len = serde_json::to_vec(input_schema)
365 .map(|b| b.len())
366 .unwrap_or(usize::MAX);
367 if schema_len > DECLARED_SCHEMA_MAX_BYTES {
368 return Err("oversized_declared_tool_schema");
369 }
370 Ok(())
371}
372
373pub fn validate_runtime_tool_declarations(
379 declared: &[DeclaredExtensionTool],
380 registered: &[RegisteredExtensionToolSpec],
381) -> Result<(), &'static str> {
382 if declared.len() != registered.len() {
383 return Err("declared_and_registered_tool_counts_differ");
384 }
385 for spec in registered {
386 validate_tool_shape(&spec.name, &spec.description, &spec.input_schema)?;
389 }
390 for decl in declared {
391 let Some(live) = registered.iter().find(|spec| spec.name == decl.name) else {
392 return Err("declared_tool_not_registered");
393 };
394 if SchemaDigest::of_schema(&live.input_schema)
395 != SchemaDigest::of_schema(&decl.input_schema)
396 {
397 return Err("registered_tool_schema_digest_mismatch");
398 }
399 if live.description != decl.description {
402 return Err("registered_tool_description_mismatch");
403 }
404 }
405 let mut live_names: HashSet<&str> = HashSet::new();
409 for spec in registered {
410 if !live_names.insert(spec.name.as_str()) {
411 return Err("registered_tool_names_duplicate");
412 }
413 }
414 Ok(())
415}
416
417pub fn validate_runtime_provider_declarations(
426 declared: &[DeclaredExtensionProvider],
427 registered: &[crate::extensions::runtime::process::RegisteredProviderSpec],
428) -> Result<(), &'static str> {
429 if declared.len() != registered.len() {
430 return Err("declared_and_registered_provider_counts_differ");
431 }
432 let mut live_ids: HashSet<&str> = HashSet::new();
433 for spec in registered {
434 if !live_ids.insert(spec.id.as_str()) {
435 return Err("registered_provider_ids_duplicate");
436 }
437 }
438 for decl in declared {
439 let Some(live) = registered.iter().find(|spec| spec.id == decl.id) else {
440 return Err("declared_provider_not_registered");
441 };
442 if live.display_name != decl.display_name {
443 return Err("registered_provider_display_name_mismatch");
444 }
445 if live.description != decl.description {
446 return Err("registered_provider_description_mismatch");
447 }
448 if live.config_schema != decl.config_schema {
449 return Err("registered_provider_config_schema_mismatch");
450 }
451 if live.models.len() != decl.models.len() {
452 return Err("registered_provider_model_counts_differ");
453 }
454 let mut live_model_ids: HashSet<&str> = HashSet::new();
455 for model in &live.models {
456 if !live_model_ids.insert(model.id.as_str()) {
457 return Err("registered_provider_model_ids_duplicate");
458 }
459 }
460 for declared_model in &decl.models {
461 let Some(live_model) = live.models.iter().find(|m| m.id == declared_model.id) else {
462 return Err("declared_provider_model_not_registered");
463 };
464 if live_model.display_name != declared_model.display_name {
465 return Err("registered_provider_model_display_name_mismatch");
466 }
467 if live_model.context_window != declared_model.context_window {
468 return Err("registered_provider_model_context_window_mismatch");
469 }
470 if live_model.capabilities != declared_model.capabilities {
471 return Err("registered_provider_model_capabilities_mismatch");
472 }
473 }
474 }
475 Ok(())
476}
477
478impl DeclaredExtensionProvider {
479 pub fn to_registered_spec(
484 &self,
485 ) -> crate::extensions::runtime::process::RegisteredProviderSpec {
486 crate::extensions::runtime::process::RegisteredProviderSpec {
487 id: self.id.clone(),
488 display_name: self.display_name.clone(),
489 description: self.description.clone(),
490 models: self
491 .models
492 .iter()
493 .map(
494 |model| crate::extensions::runtime::process::RegisteredProviderModelSpec {
495 id: model.id.clone(),
496 display_name: model.display_name.clone(),
497 capabilities: model.capabilities.clone(),
498 context_window: model.context_window,
499 },
500 )
501 .collect(),
502 config_schema: self.config_schema.clone(),
503 }
504 }
505}
506
507pub struct DeferredExtensionTool {
516 plugin_id: String,
517 runtime_name: String,
518 tool_name: String,
519 description: String,
520 input_schema: Value,
521 expected_digest: SchemaDigest,
522}
523
524impl DeferredExtensionTool {
525 fn new(plugin_id: &str, declared: &DeclaredExtensionTool) -> Self {
526 Self {
527 plugin_id: plugin_id.to_string(),
528 runtime_name: format!("{}:{}", plugin_id, declared.name),
530 tool_name: declared.name.clone(),
531 description: BoundedText::new(&declared.description, DECLARED_DESCRIPTION_MAX_BYTES)
532 .text,
533 input_schema: declared.input_schema.clone(),
534 expected_digest: SchemaDigest::of_schema(&declared.input_schema),
535 }
536 }
537
538 pub fn plugin_id(&self) -> &str {
539 &self.plugin_id
540 }
541
542 pub fn tool_name(&self) -> &str {
543 &self.tool_name
544 }
545
546 pub fn expected_digest(&self) -> &SchemaDigest {
549 &self.expected_digest
550 }
551}
552
553#[async_trait::async_trait]
554impl Tool for DeferredExtensionTool {
555 fn origin(&self) -> ToolOrigin {
556 ToolOrigin::Extension {
557 extension_id: self.plugin_id.clone(),
558 }
559 }
560
561 fn source_operation(&self) -> Option<&str> {
562 Some(&self.tool_name)
563 }
564
565 fn name(&self) -> &str {
566 &self.runtime_name
567 }
568
569 fn description(&self) -> &str {
570 &self.description
571 }
572
573 fn parameters(&self) -> Value {
574 self.input_schema.clone()
575 }
576
577 async fn execute(&self, params: Value, ctx: ToolContext) -> crate::Result<String> {
578 let caller = ctx
582 .capabilities
583 .memory_backend
584 .as_ref()
585 .map(|binding| binding.author().clone());
586 let Some(leases) = ctx.capabilities.extension_leases.clone() else {
589 return Err(crate::RuntimeError::Tool(format!(
590 "extension tool '{}' (plugin '{}') is activation-deferred and no extension \
591 runtime lease capability is available in this context; no process was started",
592 self.runtime_name, self.plugin_id
593 )));
594 };
595 match leases
596 .call_exact_as(
597 &self.plugin_id,
598 &self.tool_name,
599 &self.expected_digest,
600 params,
601 ctx.capabilities.session_id.as_deref(),
602 ctx.capabilities.cwd.as_deref(),
603 caller.as_ref(),
604 )
605 .await
606 {
607 Ok(value) => Ok(render_tool_result(&value)),
608 Err(err) => {
609 if err.revokes_exact_grant() {
617 if let Some(activation) = ctx.capabilities.tool_activation.as_ref() {
618 if activation.revoke_exact_extension_grant(
619 leases.session(),
620 &self.plugin_id,
621 &self.tool_name,
622 ) {
623 tracing::debug!(
624 "revoked exact extension activation after declaration invalidation"
625 );
626 }
627 }
628 }
629 Err(crate::RuntimeError::Tool(err.to_string()))
630 }
631 }
632 }
633}
634
635fn render_tool_result(value: &Value) -> String {
639 if let Some(text) = value.as_str() {
640 text.to_string()
641 } else if let Some(text) = value.get("content").and_then(Value::as_str) {
642 text.to_string()
643 } else {
644 value.to_string()
645 }
646}
647
648impl DeclaredExtensionTool {
649 pub fn schema_digest(&self) -> SchemaDigest {
653 SchemaDigest::of_schema(&self.input_schema)
654 }
655}
656
657pub fn dormant_extension_tools(
661 plugin_id: &str,
662 manifest: &ExtensionManifest,
663) -> Vec<Arc<dyn Tool>> {
664 if plugin_id.is_empty() || plugin_id.contains(':') || plugin_id.chars().any(char::is_control) {
666 return Vec::new();
667 }
668 let Some(deferred) = &manifest.deferred else {
669 return Vec::new();
670 };
671 if validate_manifest_deferred(manifest).is_err() {
675 return Vec::new();
676 }
677 deferred
678 .tools
679 .iter()
680 .map(|declared| Arc::new(DeferredExtensionTool::new(plugin_id, declared)) as Arc<dyn Tool>)
681 .collect()
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687 use serde_json::json;
688
689 fn base_manifest(permissions: &[&str]) -> ExtensionManifest {
690 serde_json::from_value(json!({
691 "runtime": "process",
692 "command": "/bin/false",
693 "permissions": permissions,
694 }))
695 .unwrap()
696 }
697
698 fn declared_tool(name: &str) -> DeclaredExtensionTool {
699 DeclaredExtensionTool {
700 name: name.to_string(),
701 description: format!("declared {name}"),
702 input_schema: json!({"type": "object"}),
703 }
704 }
705
706 #[test]
707 fn a_deferred_extension_tool_names_its_registered_operation() {
708 let tool = DeferredExtensionTool::new("plug", &declared_tool("memory_fetch"));
709 assert_eq!(tool.name(), "plug:memory_fetch");
710 assert_eq!(tool.source_operation(), Some("memory_fetch"));
711 }
712
713 #[test]
714 fn absent_or_empty_deferred_blocks_classify_exactly_as_before() {
715 let legacy = base_manifest(&["tools.register"]);
717 assert!(legacy.deferred.is_none());
718 assert_eq!(classify(&legacy), ExtensionClass::LegacyEager);
719 assert_eq!(earliest_trigger(&legacy), ActivationTrigger::Eager);
720 legacy.validate("legacy").unwrap();
721
722 let mut empty = base_manifest(&["tools.register"]);
724 empty.deferred = Some(DeferredDeclarations::default());
725 assert!(ignored_context_provider_notice("empty", &empty).is_none());
726 assert_eq!(classify(&empty), ExtensionClass::LegacyEager);
727 assert_eq!(earliest_trigger(&empty), ActivationTrigger::Eager);
728 empty.validate("empty").unwrap();
729
730 let d: DeferredDeclarations = serde_json::from_value(json!({
733 "tools": [], "providers": []
734 }))
735 .unwrap();
736 assert!(d.context_providers.is_null());
737 let round = serde_json::to_value(&d).unwrap();
738 assert!(round.get("context_providers").is_none());
739
740 let mut tool_only = base_manifest(&["tools.register"]);
742 tool_only.deferred = Some(DeferredDeclarations {
743 tools: vec![declared_tool("t")],
744 ..DeferredDeclarations::default()
745 });
746 assert_eq!(classify(&tool_only), ExtensionClass::ToolOnly);
747 let mut sidecar = base_manifest(&["tools.register"]);
749 sidecar.deferred = Some(DeferredDeclarations {
750 lifecycle: Some(DeferredLifecycle::User),
751 ..DeferredDeclarations::default()
752 });
753 assert_eq!(classify(&sidecar), ExtensionClass::UiSidecar);
754 let mut hooky: ExtensionManifest = serde_json::from_value(json!({
756 "runtime": "process",
757 "command": "/bin/false",
758 "permissions": ["tools.intercept"],
759 "hooks": [{"hook": "before_tool_call"}]
760 }))
761 .unwrap();
762 hooky.deferred = Some(DeferredDeclarations::default());
763 assert_eq!(classify(&hooky), ExtensionClass::HookLifecycle);
764 }
765
766 fn provider_declaration() -> Value {
769 json!([{
770 "id": "project-memory",
771 "capability": "project-memory",
772 "description": "Project memory context contributions",
773 "schema_version": 1
774 }])
775 }
776
777 fn notice(id: &str) -> String {
778 format!(
779 "extension {id} declares a context provider; built-in automatic memory was removed \
780 in {}, the declaration is ignored",
781 agent_core::AXEL_BACKEND_REMOVED_IN
782 )
783 }
784
785 #[test]
790 fn a_declared_context_provider_does_not_classify_the_extension() {
791 let only: ExtensionManifest = serde_json::from_value(json!({
792 "runtime": "process",
793 "command": "/bin/false",
794 "permissions": ["context_providers.register"],
795 "deferred": {"context_providers": provider_declaration()}
796 }))
797 .unwrap();
798 only.validate("axel-memory-manager").unwrap();
799 assert_eq!(classify(&only), ExtensionClass::LegacyEager);
800 assert_eq!(earliest_trigger(&only), ActivationTrigger::Eager);
801 assert_ne!(earliest_trigger(&only), ActivationTrigger::UserAction);
802
803 let with_tools: ExtensionManifest = serde_json::from_value(json!({
805 "runtime": "process",
806 "command": "/bin/false",
807 "permissions": ["tools.register", "context_providers.register"],
808 "deferred": {
809 "tools": [{"name": "t", "description": "declared t", "input_schema": {"type": "object"}}],
810 "context_providers": provider_declaration()
811 }
812 }))
813 .unwrap();
814 with_tools.validate("plug").unwrap();
815 assert_eq!(classify(&with_tools), ExtensionClass::ToolOnly);
816 assert_eq!(earliest_trigger(&with_tools), ActivationTrigger::ExactToolActivation);
817
818 let with_hook: ExtensionManifest = serde_json::from_value(json!({
820 "runtime": "process",
821 "command": "/bin/false",
822 "permissions": ["tools.intercept", "context_providers.register"],
823 "hooks": [{"hook": "before_tool_call"}],
824 "deferred": {"context_providers": provider_declaration()}
825 }))
826 .unwrap();
827 with_hook.validate("plug").unwrap();
828 assert_eq!(classify(&with_hook), ExtensionClass::HookLifecycle);
829 assert_eq!(
830 earliest_trigger(&with_hook),
831 ActivationTrigger::FirstAuthorizedHookEvent
832 );
833 }
834
835 #[test]
838 fn a_declared_context_provider_is_reported_as_ignored() {
839 let with = |permissions: &[&str], deferred: Option<Value>| -> ExtensionManifest {
840 let mut manifest = json!({
841 "runtime": "process",
842 "command": "/bin/false",
843 "permissions": permissions,
844 });
845 if let Some(deferred) = deferred {
846 manifest["deferred"] = deferred;
847 }
848 serde_json::from_value(manifest).unwrap()
849 };
850 for permissions in [&["context_providers.register"][..], &["tools.register"][..]] {
852 let m = with(permissions, Some(json!({"context_providers": provider_declaration()})));
853 assert_eq!(ignored_context_provider_notice("mem", &m), Some(notice("mem")));
854 }
855 let m = with(&["context_providers.register"], None);
857 assert_eq!(ignored_context_provider_notice("mem", &m), Some(notice("mem")));
858 for deferred in [
860 None,
861 Some(json!({})),
862 Some(json!({"context_providers": []})),
863 Some(json!({"context_providers": null})),
864 ] {
865 let m = with(&["tools.register"], deferred);
866 assert_eq!(ignored_context_provider_notice("plain", &m), None);
867 }
868 }
869
870 #[test]
874 fn a_declared_context_provider_can_never_fail_a_manifest() {
875 let many: Vec<Value> = (0..64).map(|i| json!({"id": format!("p{i}")})).collect();
876 for declaration in [
877 provider_declaration(),
878 json!([{"id": "has:colon", "capability": "", "schema_version": 0}]),
879 json!([{"id": "dup"}, {"id": "dup"}]),
880 Value::Array(many),
881 json!("project-memory"),
882 json!({"id": "project-memory"}),
883 json!(7),
884 ] {
885 let m: ExtensionManifest = serde_json::from_value(json!({
886 "runtime": "process",
887 "command": "/bin/false",
888 "permissions": ["tools.register"],
889 "deferred": {"context_providers": declaration.clone()}
890 }))
891 .unwrap_or_else(|e| panic!("{declaration} must parse: {e}"));
892 m.validate("plug")
893 .unwrap_or_else(|e| panic!("{declaration} must validate: {e}"));
894 assert_eq!(classify(&m), ExtensionClass::LegacyEager, "{declaration}");
895 }
896 }
897
898 #[test]
906 fn declared_capability_ids_agree_with_runtime_validation() {
907 let cases = [
908 "ok",
909 "with-dash",
910 "with_underscore",
911 "MiXedCase123",
912 "",
913 "has space",
914 "has\ttab",
915 "colon:name",
916 "trailing ",
917 &"a".repeat(super::super::validation::MAX_ID_LENGTH),
918 &"a".repeat(super::super::validation::MAX_ID_LENGTH + 1),
919 &"a".repeat(DECLARED_NAME_MAX_BYTES),
920 ];
921 for name in cases {
922 let manifest_ok = validate_tool_shape(name, "d", &json!({"type": "object"})).is_ok();
923 let runtime_ok = validate_id_segment(name).is_ok();
924 assert_eq!(
925 manifest_ok, runtime_ok,
926 "manifest/runtime disagree on declared tool name {name:?}: \
927 manifest_ok={manifest_ok} runtime_ok={runtime_ok}"
928 );
929 }
930 }
931
932 #[test]
937 fn declared_tool_name_over_runtime_bound_fails_at_manifest() {
938 let name = "a".repeat(super::super::validation::MAX_ID_LENGTH + 1);
939 assert_eq!(
940 validate_deferred(&DeferredDeclarations {
941 tools: vec![declared_tool(&name)],
942 ..Default::default()
943 }),
944 Err("invalid_declared_tool_name"),
945 );
946 assert!(name.len() <= DECLARED_NAME_MAX_BYTES);
949 }
950
951 #[test]
953 fn declared_capability_ids_reject_whitespace_at_manifest() {
954 assert_eq!(
955 validate_deferred(&DeferredDeclarations {
956 tools: vec![declared_tool("my tool")],
957 ..Default::default()
958 }),
959 Err("invalid_declared_tool_name"),
960 );
961 }
962
963 async fn call_deferred(plugin: &str, operation: &str) -> String {
966 let dir = tempfile::tempdir().unwrap();
967 let binding = crate::tools::test_helpers::memory_binding(dir.path());
968 let tool = DeferredExtensionTool::new(plugin, &declared_tool(operation));
969 match tool
970 .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
971 .await
972 {
973 Err(crate::RuntimeError::Tool(message)) => message,
974 other => panic!("expected a tool error, got {other:?}"),
975 }
976 }
977
978 #[tokio::test]
981 async fn the_suffixed_wire_name_of_a_deferred_alias_reaches_the_lease_check() {
982 let dir = tempfile::tempdir().unwrap();
983 let registry = crate::tools::ToolRegistry::from_tools_for_tests(vec![
984 Arc::new(crate::tools::memory::MemorySearchTool),
985 Arc::new(DeferredExtensionTool::new("memory", &declared_tool("search"))),
986 ]);
987 assert_eq!(registry.runtime_name_for_api("memory_search"), "memory_search");
988 assert_eq!(registry.runtime_name_for_api("memory_search_2"), "memory:search");
989 let alias = registry
990 .get(registry.runtime_name_for_api("memory_search_2"))
991 .expect("alias registered");
992 let binding = crate::tools::test_helpers::memory_binding(dir.path());
993 match alias
994 .execute(json!({}), crate::tools::test_helpers::context_with_memory(binding))
995 .await
996 {
997 Err(crate::RuntimeError::Tool(message)) => {
998 assert!(message.contains("is activation-deferred"), "{message}")
999 }
1000 other => panic!("expected the lease refusal, got {other:?}"),
1001 }
1002 }
1003
1004 #[test]
1008 fn a_nested_colon_name_never_becomes_a_deferred_tool() {
1009 assert_eq!(
1010 validate_deferred(&DeferredDeclarations {
1011 tools: vec![declared_tool("memory:fetch")],
1012 ..Default::default()
1013 }),
1014 Err("invalid_declared_tool_name"),
1015 );
1016 let mut manifest = base_manifest(&["tools.register"]);
1017 manifest.deferred = Some(DeferredDeclarations {
1018 tools: vec![declared_tool("fetch")],
1019 ..DeferredDeclarations::default()
1020 });
1021 assert_eq!(dormant_extension_tools("plug", &manifest).len(), 1);
1022 assert!(dormant_extension_tools("plug:memory", &manifest).is_empty());
1023 }
1024
1025 #[tokio::test]
1029 async fn deferred_tools_named_like_memory_tools_reach_the_lease_check() {
1030 for (plugin, operation) in [
1031 ("weather", "search"),
1032 ("memory", "summarize"),
1033 ("memory", "search"),
1034 ("memory", "fetch"),
1035 ("Memory", "Search"),
1036 ("plug", "memory.store"),
1037 ("plug", "MEMORY_STORE"),
1038 ("axel-memory-manager", "memory_search"),
1039 ("axel-memory-manager", "memory_forget"),
1040 ] {
1041 let message = call_deferred(plugin, operation).await;
1042 assert!(
1043 message.contains("is activation-deferred"),
1044 "{plugin}:{operation}: {message}"
1045 );
1046 }
1047 }
1048}