pub struct PluginManager { /* private fields */ }Implementations§
Source§impl PluginManager
impl PluginManager
Sourcepub fn new(config: ManagerConfig) -> Self
pub fn new(config: ManagerConfig) -> Self
Create a new PluginManager with the given configuration.
Sourcepub fn config_generation(&self) -> u64
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.
Sourcepub fn register_factory(
&self,
kind: impl Into<String>,
factory: Box<dyn PluginFactory>,
)
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?
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
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}Sourcepub fn load_config_file(&self, path: &Path) -> Result<(), Box<PluginError>>
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?;Sourcepub fn load_config(
&self,
cpex_config: CpexConfig,
) -> Result<(), Box<PluginError>>
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?
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
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}Sourcepub fn register_visitor(&self, visitor: Arc<dyn ConfigVisitor>)
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.
Sourcepub fn load_config_yaml(
self: &Arc<Self>,
yaml: &str,
) -> Result<(), Box<PluginError>>
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:
visit_global(global_yaml)visit_default(entity_type, default_yaml)perglobal.defaultsentryvisit_policy_bundle(tag, bundle_yaml)perglobal.policiesentryvisit_route(route_yaml, parsed_route)perroutes[]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).
Sourcepub fn from_config(
cpex_config: CpexConfig,
factories: &PluginFactoryRegistry,
) -> Result<Self, Box<PluginError>>
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.
Sourcepub 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,
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 (implementsHookTypeDef).P— the plugin type (implementsPlugin + HookHandler<H>).
§Arguments
plugin— the plugin implementation.config— authoritative config from the config loader.
§Examples
manager.register_handler::<CmfHook, _>(plugin, config)?;Sourcepub 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,
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"],
)?;Sourcepub fn register_raw<H: HookTypeDef>(
&self,
plugin: Arc<dyn Plugin>,
config: PluginConfig,
handler: Arc<dyn AnyHookHandler>,
) -> Result<(), Box<PluginError>>
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.
Sourcepub async fn initialize(&self) -> Result<(), Box<PluginError>>
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?
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
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}Sourcepub async fn shutdown(&self)
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?
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}Sourcepub async fn invoke_by_name(
&self,
hook_name: &str,
payload: Box<dyn PluginPayload>,
extensions: Extensions,
context_table: Option<PluginContextTable>,
) -> (PipelineResult, BackgroundTasks)
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 asBox<dyn PluginPayload>.extensions— the full extensions (filtered per plugin by the executor).context_table— optional context table from a previous hook invocation. PassNoneon 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.
Sourcepub async fn invoke<H: HookTypeDef>(
&self,
payload: H::Payload,
extensions: Extensions,
context_table: Option<PluginContextTable>,
) -> (PipelineResult, BackgroundTasks)
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 (implementsHookTypeDef).
§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?
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}Sourcepub async fn invoke_named<H: HookTypeDef>(
&self,
hook_name: &str,
payload: H::Payload,
extensions: Extensions,
context_table: Option<PluginContextTable>,
) -> (PipelineResult, BackgroundTasks)
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 againstH::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?
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}Sourcepub fn find_plugin_entries(&self, plugin_name: &str) -> Vec<(String, HookEntry)>
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.
Sourcepub async fn invoke_entries<H: HookTypeDef>(
&self,
entries: &[HookEntry],
payload: H::Payload,
extensions: Extensions,
context_table: Option<PluginContextTable>,
) -> (PipelineResult, BackgroundTasks)
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.
Sourcepub 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,
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) →
CmfPluginInvoker → invoke_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.
Sourcepub fn remove_route_annotation(
&self,
entity_type: &str,
entity_name: &str,
scope: Option<&str>,
hook_name: &str,
)
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.
Sourcepub 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)>
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_overrideset (config_overrideisNone): builds newPluginRefs sharing the base pluginArcwith a mergedTrustedConfig(override caps / on_error replace base values) and an independent circuit breaker. Cheap — no factory call. config_overrideset: invokes the registered factory for the plugin’skindwith a mergedPluginConfig(overrideconfigreplaces baseconfigwholesale per unified-config spec — not deep merge), callsinitialize()on the new instance, and wraps every returned handler in a newPluginRefwith a fresh circuit breaker.
Returns an empty Vec when:
- the plugin name isn’t registered in the manager,
- the factory for the plugin’s
kindis missing, - the factory’s
createerrors, - 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.
Sourcepub fn clear_routing_cache(&self)
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.
Sourcepub fn routing_cache_size(&self) -> usize
pub fn routing_cache_size(&self) -> usize
Number of entries in the routing cache.
Sourcepub fn has_hooks_for(&self, hook_name: &str) -> bool
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?
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}Sourcepub fn get_plugin(&self, name: &str) -> Option<Arc<PluginRef>>
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.
Sourcepub fn plugin_count(&self) -> usize
pub fn plugin_count(&self) -> usize
Total number of registered plugins.
Examples found in repository?
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}Sourcepub fn plugin_names(&self) -> Vec<String>
pub fn plugin_names(&self) -> Vec<String>
All registered plugin names (owned, not borrowed from the registry).
Sourcepub fn is_initialized(&self) -> bool
pub fn is_initialized(&self) -> bool
Whether the manager has been initialized.