Skip to main content

PluginManager

Struct PluginManager 

Source
pub struct PluginManager { /* private fields */ }

Implementations§

Source§

impl PluginManager

Source

pub fn new(config: ManagerConfig) -> Self

Create a new PluginManager with the given configuration.

Source

pub fn config_generation(&self) -> u64

Monotonic counter that increments on every runtime snapshot swap (registry mutation, config (re)load). External orchestrators (e.g. apl-cpex’s dispatch-plan cache) pair their cached values with the generation seen at build time; a mismatch on lookup signals “evict + rebuild.” Acquire pairs with the Release fetch_add in mutate_runtime / try_mutate_runtime so observing a higher generation guarantees visibility of the new snapshot.

Source

pub fn register_factory( &self, kind: impl Into<String>, factory: Box<dyn PluginFactory>, )

Register a plugin factory for a given kind name.

The host calls this to tell the manager how to create plugins of a specific kind. Must be called before load_config().

§Examples
let mut manager = PluginManager::default();
manager.register_factory("builtin", Box::new(BuiltinFactory));
manager.register_factory("security/rate_limit", Box::new(RateLimiterFactory));
manager.load_config(Path::new("plugins.yaml"))?;
Examples found in repository?
examples/plugin_demo.rs (line 422)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
More examples
Hide additional examples
examples/cmf_capabilities_demo.rs (line 337)
326async fn main() {
327    println!("=== CMF Capabilities Demo ===\n");
328
329    // Load config from YAML file — capabilities declared per plugin
330    let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml";
331    println!("--- Loading config from {} ---\n", config_path);
332    let yaml = std::fs::read_to_string(config_path)
333        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
334    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
335
336    let mgr = PluginManager::default();
337    mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory));
338    mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory));
339    mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory));
340    mgr.load_config(cpex_config).unwrap();
341    mgr.initialize().await.unwrap();
342
343    // --- Build CMF Message ---
344    let payload = MessagePayload {
345        message: Message {
346            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
347            role: Role::Assistant,
348            content: vec![
349                ContentPart::Text {
350                    text: "Looking up compensation.".into(),
351                },
352                ContentPart::ToolCall {
353                    content: ToolCall {
354                        tool_call_id: "tc_001".into(),
355                        name: "get_compensation".into(),
356                        arguments: [("employee_id".to_string(), serde_json::json!(42))].into(),
357                        namespace: None,
358                    },
359                },
360            ],
361            channel: None,
362        },
363    };
364
365    // --- Build Extensions with security, HTTP, meta ---
366    let mut security = SecurityExtension::default();
367    security.add_label("PII");
368    security.add_label("HR_DATA");
369    security.classification = Some("confidential".into());
370    security.subject = Some(cpex_core::extensions::security::SubjectExtension {
371        id: Some("alice".into()),
372        subject_type: Some(cpex_core::extensions::security::SubjectType::User),
373        roles: ["hr_admin".to_string()].into(),
374        permissions: ["read_compensation".to_string()].into(),
375        ..Default::default()
376    });
377
378    let mut http = HttpExtension::default();
379    http.set_header("Authorization", "Bearer eyJ...");
380    http.set_header("X-Request-ID", "req-abc-123");
381
382    let ext = Extensions {
383        request: Some(Arc::new(RequestExtension {
384            environment: Some("production".into()),
385            request_id: Some("req-abc-123".into()),
386            ..Default::default()
387        })),
388        security: Some(Arc::new(security)),
389        http: Some(Arc::new(http)),
390        meta: Some(Arc::new(MetaExtension {
391            entity_type: Some("tool".into()),
392            entity_name: Some("get_compensation".into()),
393            tags: ["pii".to_string(), "hr".to_string()].into(),
394            ..Default::default()
395        })),
396        ..Default::default()
397    };
398
399    // --- Pre-invoke: type-safe dispatch via invoke_named ---
400    println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
401
402    // invoke_named<CmfHook> gives compile-time payload type checking
403    // while routing to the specific "cmf.tool_pre_invoke" hook name
404    let (pre_result, bg) = mgr
405        .invoke_named::<CmfHook>(
406            "cmf.tool_pre_invoke",
407            payload,
408            ext,
409            None, // first hook — no context table
410        )
411        .await;
412
413    println!();
414    if pre_result.continue_processing {
415        println!("Pre-invoke result: ALLOWED");
416        if let Some(ref modified_ext) = pre_result.modified_extensions {
417            if let Some(ref sec) = modified_ext.security {
418                let labels: Vec<&String> = sec.labels.iter().collect();
419                println!("  Labels after pre-invoke: {:?}", labels);
420            }
421            if let Some(ref http) = modified_ext.http {
422                println!("  Headers after pre-invoke: {:?}", http.request_headers);
423            }
424        }
425    } else {
426        println!(
427            "Pre-invoke result: DENIED — {}",
428            pre_result.violation.as_ref().unwrap().reason
429        );
430        bg.wait_for_background_tasks().await;
431        println!("\n=== Demo complete ===");
432        return;
433    }
434    bg.wait_for_background_tasks().await;
435
436    // --- Simulate tool execution ---
437    println!("\n--- Tool 'get_compensation' executes... ---");
438    println!("  Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
439
440    // --- Post-invoke: different CMF message with tool result ---
441    println!("=== Phase 2: cmf.tool_post_invoke ===\n");
442
443    let post_payload = MessagePayload {
444        message: Message {
445            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
446            role: Role::Tool,
447            content: vec![ContentPart::ToolResult {
448                content: cpex_core::cmf::ToolResult {
449                    tool_call_id: "tc_001".into(),
450                    tool_name: "get_compensation".into(),
451                    content: serde_json::json!({"salary": 150000, "currency": "USD"}),
452                    is_error: false,
453                },
454            }],
455            channel: None,
456        },
457    };
458
459    // Build post-invoke extensions — carry forward any modifications
460    // from pre-invoke via the context table
461    let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
462        // Rebuild if no modifications
463        let mut security = SecurityExtension::default();
464        security.add_label("PII");
465        Extensions {
466            security: Some(Arc::new(security)),
467            meta: Some(Arc::new(MetaExtension {
468                entity_type: Some("tool".into()),
469                entity_name: Some("get_compensation".into()),
470                ..Default::default()
471            })),
472            ..Default::default()
473        }
474    });
475
476    // Thread the context table from pre-invoke to preserve plugin state
477    let (post_result, post_bg) = mgr
478        .invoke_named::<CmfHook>(
479            "cmf.tool_post_invoke",
480            post_payload,
481            post_ext,
482            Some(pre_result.context_table),
483        )
484        .await;
485
486    println!();
487    if post_result.continue_processing {
488        println!("Post-invoke result: ALLOWED");
489    } else {
490        println!(
491            "Post-invoke result: DENIED — {}",
492            post_result.violation.as_ref().unwrap().reason
493        );
494    }
495
496    post_bg.wait_for_background_tasks().await;
497    println!("\n=== Demo complete ===");
498}
Source

pub fn load_config_file(&self, path: &Path) -> Result<(), Box<PluginError>>

Load plugins from a YAML config file.

Parses the config, looks up each plugin’s kind in the factory registry, instantiates the plugins, and registers them. Factories must be registered via register_factory() before calling this method.

§Examples
let mut manager = PluginManager::default();
manager.register_factory("builtin", Box::new(BuiltinFactory));
manager.load_config_file(Path::new("plugins/config.yaml"))?;
manager.initialize().await?;
Source

pub fn load_config( &self, cpex_config: CpexConfig, ) -> Result<(), Box<PluginError>>

Load plugins from a parsed config.

Looks up each plugin’s kind in the factory registry, instantiates the plugins, and registers them with their hook names from the config.

Examples found in repository?
examples/plugin_demo.rs (line 426)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
More examples
Hide additional examples
examples/cmf_capabilities_demo.rs (line 340)
326async fn main() {
327    println!("=== CMF Capabilities Demo ===\n");
328
329    // Load config from YAML file — capabilities declared per plugin
330    let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml";
331    println!("--- Loading config from {} ---\n", config_path);
332    let yaml = std::fs::read_to_string(config_path)
333        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
334    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
335
336    let mgr = PluginManager::default();
337    mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory));
338    mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory));
339    mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory));
340    mgr.load_config(cpex_config).unwrap();
341    mgr.initialize().await.unwrap();
342
343    // --- Build CMF Message ---
344    let payload = MessagePayload {
345        message: Message {
346            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
347            role: Role::Assistant,
348            content: vec![
349                ContentPart::Text {
350                    text: "Looking up compensation.".into(),
351                },
352                ContentPart::ToolCall {
353                    content: ToolCall {
354                        tool_call_id: "tc_001".into(),
355                        name: "get_compensation".into(),
356                        arguments: [("employee_id".to_string(), serde_json::json!(42))].into(),
357                        namespace: None,
358                    },
359                },
360            ],
361            channel: None,
362        },
363    };
364
365    // --- Build Extensions with security, HTTP, meta ---
366    let mut security = SecurityExtension::default();
367    security.add_label("PII");
368    security.add_label("HR_DATA");
369    security.classification = Some("confidential".into());
370    security.subject = Some(cpex_core::extensions::security::SubjectExtension {
371        id: Some("alice".into()),
372        subject_type: Some(cpex_core::extensions::security::SubjectType::User),
373        roles: ["hr_admin".to_string()].into(),
374        permissions: ["read_compensation".to_string()].into(),
375        ..Default::default()
376    });
377
378    let mut http = HttpExtension::default();
379    http.set_header("Authorization", "Bearer eyJ...");
380    http.set_header("X-Request-ID", "req-abc-123");
381
382    let ext = Extensions {
383        request: Some(Arc::new(RequestExtension {
384            environment: Some("production".into()),
385            request_id: Some("req-abc-123".into()),
386            ..Default::default()
387        })),
388        security: Some(Arc::new(security)),
389        http: Some(Arc::new(http)),
390        meta: Some(Arc::new(MetaExtension {
391            entity_type: Some("tool".into()),
392            entity_name: Some("get_compensation".into()),
393            tags: ["pii".to_string(), "hr".to_string()].into(),
394            ..Default::default()
395        })),
396        ..Default::default()
397    };
398
399    // --- Pre-invoke: type-safe dispatch via invoke_named ---
400    println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
401
402    // invoke_named<CmfHook> gives compile-time payload type checking
403    // while routing to the specific "cmf.tool_pre_invoke" hook name
404    let (pre_result, bg) = mgr
405        .invoke_named::<CmfHook>(
406            "cmf.tool_pre_invoke",
407            payload,
408            ext,
409            None, // first hook — no context table
410        )
411        .await;
412
413    println!();
414    if pre_result.continue_processing {
415        println!("Pre-invoke result: ALLOWED");
416        if let Some(ref modified_ext) = pre_result.modified_extensions {
417            if let Some(ref sec) = modified_ext.security {
418                let labels: Vec<&String> = sec.labels.iter().collect();
419                println!("  Labels after pre-invoke: {:?}", labels);
420            }
421            if let Some(ref http) = modified_ext.http {
422                println!("  Headers after pre-invoke: {:?}", http.request_headers);
423            }
424        }
425    } else {
426        println!(
427            "Pre-invoke result: DENIED — {}",
428            pre_result.violation.as_ref().unwrap().reason
429        );
430        bg.wait_for_background_tasks().await;
431        println!("\n=== Demo complete ===");
432        return;
433    }
434    bg.wait_for_background_tasks().await;
435
436    // --- Simulate tool execution ---
437    println!("\n--- Tool 'get_compensation' executes... ---");
438    println!("  Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
439
440    // --- Post-invoke: different CMF message with tool result ---
441    println!("=== Phase 2: cmf.tool_post_invoke ===\n");
442
443    let post_payload = MessagePayload {
444        message: Message {
445            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
446            role: Role::Tool,
447            content: vec![ContentPart::ToolResult {
448                content: cpex_core::cmf::ToolResult {
449                    tool_call_id: "tc_001".into(),
450                    tool_name: "get_compensation".into(),
451                    content: serde_json::json!({"salary": 150000, "currency": "USD"}),
452                    is_error: false,
453                },
454            }],
455            channel: None,
456        },
457    };
458
459    // Build post-invoke extensions — carry forward any modifications
460    // from pre-invoke via the context table
461    let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
462        // Rebuild if no modifications
463        let mut security = SecurityExtension::default();
464        security.add_label("PII");
465        Extensions {
466            security: Some(Arc::new(security)),
467            meta: Some(Arc::new(MetaExtension {
468                entity_type: Some("tool".into()),
469                entity_name: Some("get_compensation".into()),
470                ..Default::default()
471            })),
472            ..Default::default()
473        }
474    });
475
476    // Thread the context table from pre-invoke to preserve plugin state
477    let (post_result, post_bg) = mgr
478        .invoke_named::<CmfHook>(
479            "cmf.tool_post_invoke",
480            post_payload,
481            post_ext,
482            Some(pre_result.context_table),
483        )
484        .await;
485
486    println!();
487    if post_result.continue_processing {
488        println!("Post-invoke result: ALLOWED");
489    } else {
490        println!(
491            "Post-invoke result: DENIED — {}",
492            post_result.violation.as_ref().unwrap().reason
493        );
494    }
495
496    post_bg.wait_for_background_tasks().await;
497    println!("\n=== Demo complete ===");
498}
Source

pub fn register_visitor(&self, visitor: Arc<dyn ConfigVisitor>)

Register an external config visitor. Visitors run during load_config_yaml (after plugin instantiation) and can install per-route handler overrides via annotate_route. Visitor order matches registration order. Multiple visitors are allowed — they typically don’t share state, so order rarely matters.

Source

pub fn load_config_yaml( self: &Arc<Self>, yaml: &str, ) -> Result<(), Box<PluginError>>

Load a unified-config YAML string. Parses the YAML twice — once into a typed CpexConfig for plugin instantiation, once into a raw serde_yaml::Value so visitors can inspect orchestrator- specific blocks (e.g. apl:) that cpex-core itself doesn’t model. Calls existing load_config(cpex_config) first, then walks each registered visitor over the raw YAML’s sections in the documented hierarchy order:

  1. visit_global(global_yaml)
  2. visit_default(entity_type, default_yaml) per global.defaults entry
  3. visit_policy_bundle(tag, bundle_yaml) per global.policies entry
  4. visit_route(route_yaml, parsed_route) per routes[] entry

All sections for one visitor run before the next visitor starts, giving each visitor a consistent view of its own accumulated state. A visitor returning Err aborts the load — the plugin snapshot stays at the post-load_config state (partial load is not rolled back; operators should treat any error from this method as a hard stop).

Source

pub fn from_config( cpex_config: CpexConfig, factories: &PluginFactoryRegistry, ) -> Result<Self, Box<PluginError>>

Create a PluginManager from a parsed config (convenience).

Uses the passed factory registry for initial instantiation. Note: for route-level config overrides to create new instances at runtime, use register_factory() + load_config() instead so the manager owns the factories.

Source

pub fn register_handler<H, P>( &self, plugin: Arc<P>, config: PluginConfig, ) -> Result<(), Box<PluginError>>
where H: HookTypeDef, H::Result: Into<PluginResult<H::Payload>>, P: Plugin + HookHandler<H> + 'static,

Register a plugin handler for its primary hook name.

This is the preferred registration method. The framework creates the type-erased adapter internally — no AnyHookHandler needed.

§Type Parameters
  • H — the hook type (implements HookTypeDef).
  • P — the plugin type (implements Plugin + HookHandler<H>).
§Arguments
  • plugin — the plugin implementation.
  • config — authoritative config from the config loader.
§Examples
manager.register_handler::<CmfHook, _>(plugin, config)?;
Source

pub fn register_handler_for_names<H, P>( &self, plugin: Arc<P>, config: PluginConfig, names: &[&str], ) -> Result<(), Box<PluginError>>
where H: HookTypeDef, H::Result: Into<PluginResult<H::Payload>>, P: Plugin + HookHandler<H> + 'static,

Register a plugin handler for multiple hook names.

This is the CMF pattern — one handler covers multiple hook names (cmf.tool_pre_invoke, cmf.llm_input, etc.).

§Examples
manager.register_handler_for_names::<CmfHook, _>(
    plugin, config,
    &["cmf.tool_pre_invoke", "cmf.llm_input", "cmf.llm_output"],
)?;
Source

pub fn register_raw<H: HookTypeDef>( &self, plugin: Arc<dyn Plugin>, config: PluginConfig, handler: Arc<dyn AnyHookHandler>, ) -> Result<(), Box<PluginError>>

Register with an explicit AnyHookHandler (advanced use).

For cases where the automatic adapter doesn’t fit — e.g., Python/WASM bridge hosts that implement AnyHookHandler directly. Most callers should use register_handler instead.

Source

pub async fn initialize(&self) -> Result<(), Box<PluginError>>

Initialize all registered plugins.

Calls plugin.initialize() on each registered plugin. Must be called before invoking any hooks. Idempotent — calling twice has no effect.

Examples found in repository?
examples/plugin_demo.rs (line 429)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
More examples
Hide additional examples
examples/cmf_capabilities_demo.rs (line 341)
326async fn main() {
327    println!("=== CMF Capabilities Demo ===\n");
328
329    // Load config from YAML file — capabilities declared per plugin
330    let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml";
331    println!("--- Loading config from {} ---\n", config_path);
332    let yaml = std::fs::read_to_string(config_path)
333        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
334    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
335
336    let mgr = PluginManager::default();
337    mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory));
338    mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory));
339    mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory));
340    mgr.load_config(cpex_config).unwrap();
341    mgr.initialize().await.unwrap();
342
343    // --- Build CMF Message ---
344    let payload = MessagePayload {
345        message: Message {
346            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
347            role: Role::Assistant,
348            content: vec![
349                ContentPart::Text {
350                    text: "Looking up compensation.".into(),
351                },
352                ContentPart::ToolCall {
353                    content: ToolCall {
354                        tool_call_id: "tc_001".into(),
355                        name: "get_compensation".into(),
356                        arguments: [("employee_id".to_string(), serde_json::json!(42))].into(),
357                        namespace: None,
358                    },
359                },
360            ],
361            channel: None,
362        },
363    };
364
365    // --- Build Extensions with security, HTTP, meta ---
366    let mut security = SecurityExtension::default();
367    security.add_label("PII");
368    security.add_label("HR_DATA");
369    security.classification = Some("confidential".into());
370    security.subject = Some(cpex_core::extensions::security::SubjectExtension {
371        id: Some("alice".into()),
372        subject_type: Some(cpex_core::extensions::security::SubjectType::User),
373        roles: ["hr_admin".to_string()].into(),
374        permissions: ["read_compensation".to_string()].into(),
375        ..Default::default()
376    });
377
378    let mut http = HttpExtension::default();
379    http.set_header("Authorization", "Bearer eyJ...");
380    http.set_header("X-Request-ID", "req-abc-123");
381
382    let ext = Extensions {
383        request: Some(Arc::new(RequestExtension {
384            environment: Some("production".into()),
385            request_id: Some("req-abc-123".into()),
386            ..Default::default()
387        })),
388        security: Some(Arc::new(security)),
389        http: Some(Arc::new(http)),
390        meta: Some(Arc::new(MetaExtension {
391            entity_type: Some("tool".into()),
392            entity_name: Some("get_compensation".into()),
393            tags: ["pii".to_string(), "hr".to_string()].into(),
394            ..Default::default()
395        })),
396        ..Default::default()
397    };
398
399    // --- Pre-invoke: type-safe dispatch via invoke_named ---
400    println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
401
402    // invoke_named<CmfHook> gives compile-time payload type checking
403    // while routing to the specific "cmf.tool_pre_invoke" hook name
404    let (pre_result, bg) = mgr
405        .invoke_named::<CmfHook>(
406            "cmf.tool_pre_invoke",
407            payload,
408            ext,
409            None, // first hook — no context table
410        )
411        .await;
412
413    println!();
414    if pre_result.continue_processing {
415        println!("Pre-invoke result: ALLOWED");
416        if let Some(ref modified_ext) = pre_result.modified_extensions {
417            if let Some(ref sec) = modified_ext.security {
418                let labels: Vec<&String> = sec.labels.iter().collect();
419                println!("  Labels after pre-invoke: {:?}", labels);
420            }
421            if let Some(ref http) = modified_ext.http {
422                println!("  Headers after pre-invoke: {:?}", http.request_headers);
423            }
424        }
425    } else {
426        println!(
427            "Pre-invoke result: DENIED — {}",
428            pre_result.violation.as_ref().unwrap().reason
429        );
430        bg.wait_for_background_tasks().await;
431        println!("\n=== Demo complete ===");
432        return;
433    }
434    bg.wait_for_background_tasks().await;
435
436    // --- Simulate tool execution ---
437    println!("\n--- Tool 'get_compensation' executes... ---");
438    println!("  Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
439
440    // --- Post-invoke: different CMF message with tool result ---
441    println!("=== Phase 2: cmf.tool_post_invoke ===\n");
442
443    let post_payload = MessagePayload {
444        message: Message {
445            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
446            role: Role::Tool,
447            content: vec![ContentPart::ToolResult {
448                content: cpex_core::cmf::ToolResult {
449                    tool_call_id: "tc_001".into(),
450                    tool_name: "get_compensation".into(),
451                    content: serde_json::json!({"salary": 150000, "currency": "USD"}),
452                    is_error: false,
453                },
454            }],
455            channel: None,
456        },
457    };
458
459    // Build post-invoke extensions — carry forward any modifications
460    // from pre-invoke via the context table
461    let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
462        // Rebuild if no modifications
463        let mut security = SecurityExtension::default();
464        security.add_label("PII");
465        Extensions {
466            security: Some(Arc::new(security)),
467            meta: Some(Arc::new(MetaExtension {
468                entity_type: Some("tool".into()),
469                entity_name: Some("get_compensation".into()),
470                ..Default::default()
471            })),
472            ..Default::default()
473        }
474    });
475
476    // Thread the context table from pre-invoke to preserve plugin state
477    let (post_result, post_bg) = mgr
478        .invoke_named::<CmfHook>(
479            "cmf.tool_post_invoke",
480            post_payload,
481            post_ext,
482            Some(pre_result.context_table),
483        )
484        .await;
485
486    println!();
487    if post_result.continue_processing {
488        println!("Post-invoke result: ALLOWED");
489    } else {
490        println!(
491            "Post-invoke result: DENIED — {}",
492            post_result.violation.as_ref().unwrap().reason
493        );
494    }
495
496    post_bg.wait_for_background_tasks().await;
497    println!("\n=== Demo complete ===");
498}
Source

pub async fn shutdown(&self)

Shutdown all registered plugins.

Calls plugin.shutdown() on each registered plugin in reverse registration order. Errors are logged but do not halt the shutdown process — all plugins get a chance to clean up. Shut the manager down. Terminal: after shutdown() returns, no further register_* / invoke_* should be called. New fire-and-forget tasks spawned after close() will not be tracked (the TaskTracker is single-shot by design).

Examples found in repository?
examples/plugin_demo.rs (line 553)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
Source

pub async fn invoke_by_name( &self, hook_name: &str, payload: Box<dyn PluginPayload>, extensions: Extensions, context_table: Option<PluginContextTable>, ) -> (PipelineResult, BackgroundTasks)

Invoke a hook by name with a type-erased payload.

This is the dynamic dispatch path used by Python/Go/WASM callers via FFI or PyO3 bindings. The hook name is resolved from the registry and dispatched through the 5-phase executor.

§Arguments
  • hook_name — the hook name string (e.g., "cmf.tool_pre_invoke").
  • payload — the payload as Box<dyn PluginPayload>.
  • extensions — the full extensions (filtered per plugin by the executor).
  • context_table — optional context table from a previous hook invocation. Pass None on the first hook call; thread the returned table into subsequent calls to preserve per-plugin state.
§Returns

A tuple of (PipelineResult, BackgroundTasks). The result contains the final payload, extensions, violation, and context table. Background tasks can be awaited or dropped.

Source

pub async fn invoke<H: HookTypeDef>( &self, payload: H::Payload, extensions: Extensions, context_table: Option<PluginContextTable>, ) -> (PipelineResult, BackgroundTasks)

Invoke a typed hook.

This is the compile-time dispatch path used by Rust callers. The hook type H determines the payload and result types. Dispatch goes through the same registry and 5-phase executor as invoke_by_name().

When routing is enabled, the entity is identified from extensions.meta (entity_type + entity_name). Only plugins matching the resolved route fire. When routing is disabled or meta is absent, all registered plugins fire.

§Type Parameters
  • H — the hook type (implements HookTypeDef).
§Arguments
  • payload — the typed payload.
  • extensions — the full extensions (includes meta for routing).
  • context_table — optional context table from a previous hook.
§Returns

A tuple of (PipelineResult, BackgroundTasks).

Examples found in repository?
examples/plugin_demo.rs (line 446)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
Source

pub async fn invoke_named<H: HookTypeDef>( &self, hook_name: &str, payload: H::Payload, extensions: Extensions, context_table: Option<PluginContextTable>, ) -> (PipelineResult, BackgroundTasks)

Invoke a typed hook by explicit name.

Combines compile-time payload type checking (from H) with runtime hook name routing (from hook_name). Use this when a single hook type (e.g., CmfHook) covers multiple hook names (e.g., cmf.tool_pre_invoke, cmf.tool_post_invoke).

§Type Parameters
  • H — the hook type (provides payload type checking).
§Arguments
  • hook_name — the hook name for dispatch routing.
  • payload — the typed payload (compile-time checked against H::Payload).
  • extensions — the full extensions.
  • context_table — optional context table from a previous hook.
§Examples
// Compile-time: payload must be MessagePayload (from CmfHook)
// Runtime: dispatches to plugins registered under "cmf.tool_pre_invoke"
let (result, bg) = mgr.invoke_named::<CmfHook>(
    "cmf.tool_pre_invoke", payload, ext, None,
).await;
Examples found in repository?
examples/cmf_capabilities_demo.rs (lines 405-410)
326async fn main() {
327    println!("=== CMF Capabilities Demo ===\n");
328
329    // Load config from YAML file — capabilities declared per plugin
330    let config_path = "crates/cpex-core/examples/cmf_capabilities_demo.yaml";
331    println!("--- Loading config from {} ---\n", config_path);
332    let yaml = std::fs::read_to_string(config_path)
333        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
334    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
335
336    let mgr = PluginManager::default();
337    mgr.register_factory("builtin/identity-checker", Box::new(IdentityCheckerFactory));
338    mgr.register_factory("builtin/header-injector", Box::new(HeaderInjectorFactory));
339    mgr.register_factory("builtin/audit-logger", Box::new(AuditLoggerFactory));
340    mgr.load_config(cpex_config).unwrap();
341    mgr.initialize().await.unwrap();
342
343    // --- Build CMF Message ---
344    let payload = MessagePayload {
345        message: Message {
346            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
347            role: Role::Assistant,
348            content: vec![
349                ContentPart::Text {
350                    text: "Looking up compensation.".into(),
351                },
352                ContentPart::ToolCall {
353                    content: ToolCall {
354                        tool_call_id: "tc_001".into(),
355                        name: "get_compensation".into(),
356                        arguments: [("employee_id".to_string(), serde_json::json!(42))].into(),
357                        namespace: None,
358                    },
359                },
360            ],
361            channel: None,
362        },
363    };
364
365    // --- Build Extensions with security, HTTP, meta ---
366    let mut security = SecurityExtension::default();
367    security.add_label("PII");
368    security.add_label("HR_DATA");
369    security.classification = Some("confidential".into());
370    security.subject = Some(cpex_core::extensions::security::SubjectExtension {
371        id: Some("alice".into()),
372        subject_type: Some(cpex_core::extensions::security::SubjectType::User),
373        roles: ["hr_admin".to_string()].into(),
374        permissions: ["read_compensation".to_string()].into(),
375        ..Default::default()
376    });
377
378    let mut http = HttpExtension::default();
379    http.set_header("Authorization", "Bearer eyJ...");
380    http.set_header("X-Request-ID", "req-abc-123");
381
382    let ext = Extensions {
383        request: Some(Arc::new(RequestExtension {
384            environment: Some("production".into()),
385            request_id: Some("req-abc-123".into()),
386            ..Default::default()
387        })),
388        security: Some(Arc::new(security)),
389        http: Some(Arc::new(http)),
390        meta: Some(Arc::new(MetaExtension {
391            entity_type: Some("tool".into()),
392            entity_name: Some("get_compensation".into()),
393            tags: ["pii".to_string(), "hr".to_string()].into(),
394            ..Default::default()
395        })),
396        ..Default::default()
397    };
398
399    // --- Pre-invoke: type-safe dispatch via invoke_named ---
400    println!("=== Phase 1: cmf.tool_pre_invoke ===\n");
401
402    // invoke_named<CmfHook> gives compile-time payload type checking
403    // while routing to the specific "cmf.tool_pre_invoke" hook name
404    let (pre_result, bg) = mgr
405        .invoke_named::<CmfHook>(
406            "cmf.tool_pre_invoke",
407            payload,
408            ext,
409            None, // first hook — no context table
410        )
411        .await;
412
413    println!();
414    if pre_result.continue_processing {
415        println!("Pre-invoke result: ALLOWED");
416        if let Some(ref modified_ext) = pre_result.modified_extensions {
417            if let Some(ref sec) = modified_ext.security {
418                let labels: Vec<&String> = sec.labels.iter().collect();
419                println!("  Labels after pre-invoke: {:?}", labels);
420            }
421            if let Some(ref http) = modified_ext.http {
422                println!("  Headers after pre-invoke: {:?}", http.request_headers);
423            }
424        }
425    } else {
426        println!(
427            "Pre-invoke result: DENIED — {}",
428            pre_result.violation.as_ref().unwrap().reason
429        );
430        bg.wait_for_background_tasks().await;
431        println!("\n=== Demo complete ===");
432        return;
433    }
434    bg.wait_for_background_tasks().await;
435
436    // --- Simulate tool execution ---
437    println!("\n--- Tool 'get_compensation' executes... ---");
438    println!("  Result: {{\"salary\": 150000, \"currency\": \"USD\"}}\n");
439
440    // --- Post-invoke: different CMF message with tool result ---
441    println!("=== Phase 2: cmf.tool_post_invoke ===\n");
442
443    let post_payload = MessagePayload {
444        message: Message {
445            schema_version: cpex_core::cmf::constants::SCHEMA_VERSION.into(),
446            role: Role::Tool,
447            content: vec![ContentPart::ToolResult {
448                content: cpex_core::cmf::ToolResult {
449                    tool_call_id: "tc_001".into(),
450                    tool_name: "get_compensation".into(),
451                    content: serde_json::json!({"salary": 150000, "currency": "USD"}),
452                    is_error: false,
453                },
454            }],
455            channel: None,
456        },
457    };
458
459    // Build post-invoke extensions — carry forward any modifications
460    // from pre-invoke via the context table
461    let post_ext = pre_result.modified_extensions.unwrap_or_else(|| {
462        // Rebuild if no modifications
463        let mut security = SecurityExtension::default();
464        security.add_label("PII");
465        Extensions {
466            security: Some(Arc::new(security)),
467            meta: Some(Arc::new(MetaExtension {
468                entity_type: Some("tool".into()),
469                entity_name: Some("get_compensation".into()),
470                ..Default::default()
471            })),
472            ..Default::default()
473        }
474    });
475
476    // Thread the context table from pre-invoke to preserve plugin state
477    let (post_result, post_bg) = mgr
478        .invoke_named::<CmfHook>(
479            "cmf.tool_post_invoke",
480            post_payload,
481            post_ext,
482            Some(pre_result.context_table),
483        )
484        .await;
485
486    println!();
487    if post_result.continue_processing {
488        println!("Post-invoke result: ALLOWED");
489    } else {
490        println!(
491            "Post-invoke result: DENIED — {}",
492            post_result.violation.as_ref().unwrap().reason
493        );
494    }
495
496    post_bg.wait_for_background_tasks().await;
497    println!("\n=== Demo complete ===");
498}
Source

pub fn find_plugin_entries(&self, plugin_name: &str) -> Vec<(String, HookEntry)>

Find every (hook_name, HookEntry) pair belonging to the named plugin. Returns an empty Vec if the plugin isn’t registered.

Used by external orchestrators (notably apl-cpex) that decide the per-route plugin lineup themselves and need handler refs + trusted_config to build pre-resolved dispatch plans. Cheaper than going through invoke_named per request because the caller can cache the resulting entries — pair the result with config_generation to invalidate the cache on snapshot swaps.

Bypasses route/entity filtering — caller has already decided this plugin should run. APL’s routes: is itself the authoritative lineup; cpex-core’s condition-based routing is a parallel model for non-APL hosts.

Source

pub async fn invoke_entries<H: HookTypeDef>( &self, entries: &[HookEntry], payload: H::Payload, extensions: Extensions, context_table: Option<PluginContextTable>, ) -> (PipelineResult, BackgroundTasks)

Dispatch a caller-supplied slice of HookEntries through the executor’s full 5-phase pipeline (sequential, transform, audit, concurrent, fire-and-forget). All on_error / timeout / mode / write-token machinery applies.

Bypasses hook-name lookup and route/entity filtering — caller has already resolved the lineup (typically via find_plugin_entries + a per-route dispatch plan). The H: HookTypeDef parameter enforces payload type at compile time; mismatched payloads fail to compile, same as invoke_named.

Returns (PipelineResult, BackgroundTasks) identical in shape to invoke_named so callers can swap between the two paths without rewriting downstream result handling.

Source

pub fn annotate_route<H>( &self, entity_type: impl Into<String>, entity_name: impl Into<String>, scope: Option<String>, hook_name: impl Into<String>, handler: Arc<H>, config: PluginConfig, )
where H: Plugin + AnyHookHandler + 'static,

Override the resolved plugin list for one (entity_type, entity_name) pair on the listed hooks with a single synthetic handler. The handler takes responsibility for any further plugin dispatch within itself (typically by calling invoke_entries against the same registry’s other entries — i.e. APL’s plugin(name)CmfPluginInvokerinvoke_entries flow).

This is the integration point external orchestrators (APL, future Rego/Cedar-direct/Custom) use to drive plugins via their own semantics instead of cpex-core’s imperative routes.*.plugins: chain. Bumps the config generation so cached dispatch plans in downstream caches invalidate.

config provides the trusted_config for the synthetic plugin — the executor reads mode, on_error, capabilities, etc. from it the same way it does for any other registered plugin. Capabilities should be a superset of what the orchestrator needs to read from Extensions (cpex-core’s per-plugin filter still applies to the synthetic handler).

The underlying plugins: chain for this route is not removed — those plugins stay discoverable via find_plugin_entries so the orchestrator can dispatch into them by name.

Source

pub fn remove_route_annotation( &self, entity_type: &str, entity_name: &str, scope: Option<&str>, hook_name: &str, )

Remove a route annotation for a specific hook. No-op when no annotation exists for the key. Bumps the generation so downstream caches invalidate.

Source

pub async fn build_override_entries( &self, plugin_name: &str, config_override: Option<&Value>, capabilities_override: Option<&HashSet<String>>, on_error_override: Option<OnError>, ) -> Vec<(String, HookEntry)>

Build per-hook HookEntrys for a plugin with optional route- level overrides. Used by external orchestrators (notably apl-cpex’s dispatch plan) that need to splice per-route plugin variants — different config, narrower capabilities, different on_error — into the dispatch lineup while keeping cpex-core the source of truth for instantiation and isolation.

Behavior:

  • All three overrides None: returns the base entries unchanged. Caller can use them as-is.
  • Only capabilities_override / on_error_override set (config_override is None): builds new PluginRefs sharing the base plugin Arc with a merged TrustedConfig (override caps / on_error replace base values) and an independent circuit breaker. Cheap — no factory call.
  • config_override set: invokes the registered factory for the plugin’s kind with a merged PluginConfig (override config replaces base config wholesale per unified-config spec — not deep merge), calls initialize() on the new instance, and wraps every returned handler in a new PluginRef with a fresh circuit breaker.

Returns an empty Vec when:

  • the plugin name isn’t registered in the manager,
  • the factory for the plugin’s kind is missing,
  • the factory’s create errors,
  • or initialize() fails on the new instance.

Each of those is a configuration / wiring fault the caller should treat as NotFound at dispatch time. The method logs the underlying error before returning empty so debugging surfaces in operator logs rather than as a silent miss.

Source

pub fn clear_routing_cache(&self)

Clear the routing cache. Call when config is reloaded or plugins are registered/unregistered. Also resets the “cache full” warn-once latch so the next fill cycle can warn again.

Source

pub fn routing_cache_size(&self) -> usize

Number of entries in the routing cache.

Source

pub fn has_hooks_for(&self, hook_name: &str) -> bool

Whether anything would run for the given hook name — either a registered plugin handler OR a route annotation targeting that hook.

Route annotations (installed by APL from a route’s policy: / args: / result: blocks) must be counted here: a route whose only handler for a phase is an annotation (e.g. a response-side result: { ssn: redact(...) } on cmf.tool_post_invoke, with no globally-registered post-invoke plugin) would otherwise report “no hooks” and be skipped by out-of-process hosts that use this as a fast-skip gate — silently dropping the route’s policy for that phase.

Examples found in repository?
examples/plugin_demo.rs (line 434)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
Source

pub fn get_plugin(&self, name: &str) -> Option<Arc<PluginRef>>

Look up a plugin by name. Returns an Arc<PluginRef> clone — works with the snapshot-based dispatch model where the registry sits behind a transient Arc<RuntimeSnapshot> guard. Arc<PluginRef> derefs to PluginRef, so callers can chain methods directly: mgr.get_plugin("name").unwrap().is_disabled() still compiles.

Source

pub fn plugin_count(&self) -> usize

Total number of registered plugins.

Examples found in repository?
examples/plugin_demo.rs (line 431)
411async fn main() {
412    println!("=== CPEX Plugin Demo ===\n");
413
414    // --- Load config from YAML file ---
415    let config_path = "crates/cpex-core/examples/plugin_demo.yaml";
416    println!("--- Loading config from {} ---\n", config_path);
417    let yaml = std::fs::read_to_string(config_path)
418        .unwrap_or_else(|e| panic!("Failed to read {}: {}", config_path, e));
419    let cpex_config = cpex_core::config::parse_config(&yaml).unwrap();
420
421    let mgr = PluginManager::default();
422    mgr.register_factory("builtin/identity", Box::new(IdentityFactory));
423    mgr.register_factory("builtin/pii", Box::new(PiiGuardFactory));
424    mgr.register_factory("builtin/audit", Box::new(AuditLoggerFactory));
425    mgr.register_factory("builtin/remote_authz", Box::new(RemoteAuthzFactory));
426    mgr.load_config(cpex_config).unwrap();
427
428    println!("\n--- Initializing plugins ---\n");
429    mgr.initialize().await.unwrap();
430
431    println!("\nPlugins loaded: {}", mgr.plugin_count());
432    println!(
433        "Hooks registered: tool_pre_invoke={}, tool_post_invoke={}\n",
434        mgr.has_hooks_for("tool_pre_invoke"),
435        mgr.has_hooks_for("tool_post_invoke"),
436    );
437
438    // --- Scenario 1: PII tool without clearance ---
439    println!("=== Scenario 1: get_compensation (PII tool, no clearance) ===\n");
440    let payload = ToolInvokePayload {
441        tool_name: "get_compensation".into(),
442        user: "alice".into(),
443        arguments: "employee_id=42".into(),
444    };
445    let ext = make_tool_extensions("get_compensation", &[]);
446    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
447    print_result("get_compensation (no clearance)", &result);
448    // Wait for any fire-and-forget tasks
449    bg.wait_for_background_tasks().await;
450
451    // --- Scenario 2: PII tool with clearance ---
452    println!("=== Scenario 2: get_compensation (PII tool, with clearance) ===\n");
453    let payload = ToolInvokePayload {
454        tool_name: "get_compensation".into(),
455        user: "alice".into(),
456        arguments: "employee_id=42".into(),
457    };
458    let ext = make_tool_extensions("get_compensation", &[]);
459    // Simulate clearance by pre-populating global_state
460    // (In production, an earlier hook would set this from a token claim)
461    let mut ctx_table = cpex_core::context::PluginContextTable::new();
462    ctx_table
463        .global_state
464        .insert("pii_clearance".into(), serde_json::Value::Bool(true));
465    let (result, bg) = mgr
466        .invoke::<ToolPreInvoke>(payload, ext, Some(ctx_table))
467        .await;
468    print_result("get_compensation (with clearance)", &result);
469    bg.wait_for_background_tasks().await;
470
471    // Now call post-invoke — threads the context table from pre-invoke
472    println!("  --- post-invoke for get_compensation ---\n");
473    let payload = ToolInvokePayload {
474        tool_name: "get_compensation".into(),
475        user: "alice".into(),
476        arguments: "employee_id=42".into(),
477    };
478    let ext = make_tool_extensions("get_compensation", &[]);
479    let (post_result, bg) = mgr
480        .invoke::<ToolPostInvoke>(payload, ext, Some(result.context_table))
481        .await;
482    print_result("get_compensation post-invoke", &post_result);
483    bg.wait_for_background_tasks().await;
484
485    // --- Scenario 3: Non-PII tool ---
486    println!("=== Scenario 3: list_departments (non-PII tool) ===\n");
487    let payload = ToolInvokePayload {
488        tool_name: "list_departments".into(),
489        user: "bob".into(),
490        arguments: "".into(),
491    };
492    let ext = make_tool_extensions("list_departments", &[]);
493    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
494    print_result("list_departments", &result);
495    bg.wait_for_background_tasks().await;
496
497    // --- Scenario 4: Unknown tool (wildcard route) ---
498    println!("=== Scenario 4: some_other_tool (wildcard route) ===\n");
499    let payload = ToolInvokePayload {
500        tool_name: "some_other_tool".into(),
501        user: "charlie".into(),
502        arguments: "foo=bar".into(),
503    };
504    let ext = make_tool_extensions("some_other_tool", &[]);
505    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
506    print_result("some_other_tool (wildcard)", &result);
507    bg.wait_for_background_tasks().await;
508
509    // --- Scenario 5: Awaiting plugin — cache hit ---
510    // RemoteAuthz's `handle` is `async fn` and reads from a tokio
511    // RwLock. Its initialize() pre-loaded an ACL containing "alice"
512    // and "bob"; this call exercises the cache-hit fast path.
513    println!("=== Scenario 5: query_external_data (async plugin, cache hit) ===\n");
514    let payload = ToolInvokePayload {
515        tool_name: "query_external_data".into(),
516        user: "alice".into(),
517        arguments: "dataset=sales".into(),
518    };
519    let ext = make_tool_extensions("query_external_data", &[]);
520    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
521    print_result("query_external_data (alice — in ACL)", &result);
522    bg.wait_for_background_tasks().await;
523
524    // --- Scenario 6: Awaiting plugin — cache miss path with .await ---
525    // "charlie" is not in the cached ACL, so RemoteAuthz takes the
526    // cache-miss branch and `.await`s a simulated remote call before
527    // denying.
528    println!("=== Scenario 6: query_external_data (async plugin, cache miss) ===\n");
529    let payload = ToolInvokePayload {
530        tool_name: "query_external_data".into(),
531        user: "charlie".into(),
532        arguments: "dataset=sales".into(),
533    };
534    let ext = make_tool_extensions("query_external_data", &[]);
535    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
536    print_result("query_external_data (charlie — not in ACL)", &result);
537    bg.wait_for_background_tasks().await;
538
539    // --- Scenario 7: No user identity ---
540    println!("=== Scenario 7: list_departments (no user identity) ===\n");
541    let payload = ToolInvokePayload {
542        tool_name: "list_departments".into(),
543        user: "".into(),
544        arguments: "".into(),
545    };
546    let ext = make_tool_extensions("list_departments", &[]);
547    let (result, bg) = mgr.invoke::<ToolPreInvoke>(payload, ext, None).await;
548    print_result("list_departments (no user)", &result);
549    bg.wait_for_background_tasks().await;
550
551    // --- Shutdown ---
552    println!("--- Shutting down ---\n");
553    mgr.shutdown().await;
554
555    println!("=== Demo complete ===");
556}
Source

pub fn plugin_names(&self) -> Vec<String>

All registered plugin names (owned, not borrowed from the registry).

Source

pub fn is_initialized(&self) -> bool

Whether the manager has been initialized.

Source

pub fn unregister(&self, name: &str) -> Option<Arc<PluginRef>>

Unregister a plugin by name.

Trait Implementations§

Source§

impl Default for PluginManager

Source§

fn default() -> Self

Returns the “default value” for a type. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T> Instrument for T

Source§

fn instrument(self, span: Span) -> Instrumented<Self>

Instruments this type with the provided Span, returning an Instrumented wrapper. Read more
Source§

fn in_current_span(self) -> Instrumented<Self>

Instruments this type with the current Span, returning an Instrumented wrapper. Read more
Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.
Source§

impl<T> WithSubscriber for T

Source§

fn with_subscriber<S>(self, subscriber: S) -> WithDispatch<Self>
where S: Into<Dispatch>,

Attaches the provided Subscriber to this type, returning a WithDispatch wrapper. Read more
Source§

fn with_current_subscriber(self) -> WithDispatch<Self>

Attaches the current default Subscriber to this type, returning a WithDispatch wrapper. Read more