1use oxi_sdk::EventBus as SdkEventBus;
15use oxi_sdk::observability::{AuditAction, AuditTrail};
16use serde::{Deserialize, Serialize};
17use std::sync::Arc;
18use uuid::Uuid;
19
20use crate::types::AgentId;
21
22pub type EventBus = SdkEventBus<KernelEvent>;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub enum KernelEvent {
32 AgentCreated {
34 id: AgentId,
36 name: String,
38 },
39 AgentStarted {
41 id: AgentId,
43 },
44 AgentStopped {
51 id: AgentId,
53 #[serde(default)]
57 success: bool,
58 },
59 AgentFailed {
61 id: AgentId,
63 error: String,
65 },
66 MessageReceived {
68 from: AgentId,
70 content: String,
72 },
73 AgentOutput {
75 session_id: String,
77 agent_id: AgentId,
79 output: String,
81 },
82 ApprovalRequested {
84 id: uuid::Uuid,
86 tool_name: String,
88 action: String,
90 resource: String,
92 reason: String,
94 session_id: Option<String>,
96 },
97 ApprovalResolved {
99 id: uuid::Uuid,
101 approved: bool,
103 },
104 MemoryStored {
106 id: String,
108 memory_type: String,
110 source: String,
112 },
113 MemoryRecalled {
115 query: String,
117 count: usize,
119 },
120 AgentGroupCreated {
122 group_id: uuid::Uuid,
124 agent_count: usize,
126 },
127 AgentGroupMemberCompleted {
129 group_id: uuid::Uuid,
131 agent_id: uuid::Uuid,
133 success: bool,
135 },
136 ProjectCreated {
138 project_id: uuid::Uuid,
140 name: String,
142 source: String,
144 },
145 ProjectActivated {
147 project_id: uuid::Uuid,
149 name: String,
151 },
152
153 ToolExecutionStarted {
158 session_id: String,
160 tool_name: String,
162 tool_call_id: String,
164 tool_args: serde_json::Value,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
169 context: Option<serde_json::Value>,
170 },
171 ToolExecutionFinished {
173 session_id: String,
175 tool_call_id: String,
177 tool_name: String,
179 duration_ms: u64,
181 is_error: bool,
183 output_summary: String,
185 },
186 ToolExecutionProgress {
188 session_id: String,
190 tool_call_id: String,
192 tool_name: String,
194 progress: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
200 tab_id: Option<Uuid>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
207 context: Option<serde_json::Value>,
208 },
209 MemoryRecallUsed {
211 session_id: String,
213 query: String,
215 count: usize,
217 source: String,
219 },
220 TokenUsageUpdate {
222 session_id: String,
224 input_tokens: u64,
226 output_tokens: u64,
228 },
229 ReasoningFragment {
231 session_id: String,
233 content: String,
235 source: String,
237 },
238
239 CalendarEventCreated {
242 uid: String,
244 title: String,
246 start: String,
248 end: String,
250 },
251 CalendarEventUpdated {
253 uid: String,
255 title: String,
257 },
258 CalendarEventDeleted {
260 uid: String,
262 title: String,
264 },
265 EmailSent {
267 subject: String,
269 message_id: String,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 template_name: Option<String>,
274 },
275
276 KnowledgePersisted {
279 session_id: String,
280 message_index: usize,
281 path: String,
282 source: String, },
284 KnowledgeRemoved {
286 session_id: String,
287 message_index: usize,
288 },
289 AskUserRequest {
293 id: String,
296 question: String,
298 options: Vec<String>,
300 },
301 PersonaCreated {
304 id: String,
306 name: String,
308 enabled: bool,
310 source: String,
312 },
313 PersonaUpdated {
315 id: String,
317 name: String,
319 source: String,
321 },
322}
323
324pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
326 match event {
327 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
328 task_type: name.clone(),
329 },
330 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
331 task_type: "started".to_string(),
332 },
333 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
334 reason: if *success {
335 "completed".to_string()
336 } else {
337 "stopped".to_string()
338 },
339 },
340 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
341 reason: error.clone(),
342 },
343 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
344 detail: format!("message: {content}"),
345 },
346 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
347 detail: format!("agent_output:{output}"),
348 },
349 KernelEvent::ApprovalRequested {
350 id,
351 action,
352 resource,
353 ..
354 } => AuditAction::Other {
355 detail: format!("approval_requested:{id}:{action}:{resource}"),
356 },
357 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
358 detail: format!("approval_resolved:{id}:{approved}"),
359 },
360 KernelEvent::MemoryStored {
361 id, memory_type, ..
362 } => AuditAction::MemoryWrite {
363 entry_id: format!("{id}:{memory_type}"),
364 },
365 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
366 entry_id: format!("query:{query}:{count}results"),
367 },
368 KernelEvent::AgentGroupCreated {
369 group_id,
370 agent_count,
371 } => AuditAction::Other {
372 detail: format!("group_created:{group_id}:{agent_count}agents"),
373 },
374 KernelEvent::AgentGroupMemberCompleted {
375 group_id,
376 agent_id,
377 success,
378 } => AuditAction::Other {
379 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
380 },
381 KernelEvent::ProjectCreated {
382 project_id: _,
383 name,
384 source,
385 } => AuditAction::Other {
386 detail: format!("project_created:{name}:{source}"),
387 },
388 KernelEvent::ProjectActivated {
389 project_id: _,
390 name,
391 } => AuditAction::Other {
392 detail: format!("project_activated:{name}"),
393 },
394 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
396 detail: format!("tool_started:{tool_name}"),
397 },
398 KernelEvent::ToolExecutionFinished {
399 tool_name,
400 is_error,
401 ..
402 } => AuditAction::Other {
403 detail: format!(
404 "tool_finished:{tool_name}:{}",
405 if *is_error { "error" } else { "ok" }
406 ),
407 },
408 KernelEvent::ToolExecutionProgress {
409 tool_name,
410 tab_id,
411 context,
412 ..
413 } => AuditAction::Other {
414 detail: {
415 let mut d = format!("tool_progress:{tool_name}");
416 if let Some(id) = tab_id {
417 d.push_str(&format!(":tab={id}"));
418 }
419 if let Some(ctx) = context
420 .as_ref()
421 .and_then(|c| c.get("kind"))
422 .and_then(|k| k.as_str())
423 {
424 d.push_str(&format!(":{ctx}"));
425 }
426 d
427 },
428 },
429 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
430 entry_id: format!("recall:{query}:{count}results"),
431 },
432 KernelEvent::TokenUsageUpdate {
433 input_tokens,
434 output_tokens,
435 ..
436 } => AuditAction::Other {
437 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
438 },
439 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
440 detail: format!("reasoning:{source}"),
441 },
442 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
443 detail: format!("calendar:created:{uid}:{title}"),
444 },
445 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
446 detail: format!("calendar:updated:{uid}:{title}"),
447 },
448 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
449 detail: format!("calendar:deleted:{uid}:{title}"),
450 },
451 KernelEvent::EmailSent {
452 subject,
453 message_id,
454 template_name,
455 } => AuditAction::Other {
456 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
457 },
458 KernelEvent::KnowledgePersisted {
459 session_id,
460 message_index,
461 path,
462 source,
463 } => AuditAction::Other {
464 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
465 },
466 KernelEvent::KnowledgeRemoved {
467 session_id,
468 message_index,
469 } => AuditAction::Other {
470 detail: format!("knowledge:removed:{session_id}:{message_index}"),
471 },
472 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
473 detail: format!("ask_user:{id}:{question}"),
474 },
475 KernelEvent::PersonaCreated {
476 id, name, source, ..
477 } => AuditAction::Other {
478 detail: format!("persona:created:{id}:{name}:{source}"),
479 },
480 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
481 detail: format!("persona:updated:{id}:{name}:{source}"),
482 },
483 }
484}
485
486fn extract_agent_id(event: &KernelEvent) -> String {
488 match event {
489 KernelEvent::AgentCreated { id, .. } => id.to_string(),
490 KernelEvent::AgentStarted { id, .. } => id.to_string(),
491 KernelEvent::AgentStopped { id, .. } => id.to_string(),
492 KernelEvent::AgentFailed { id, .. } => id.to_string(),
493 KernelEvent::MessageReceived { from, .. } => from.to_string(),
494 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
495 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
496 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
497 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
499 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
500 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
501 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
502 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
503 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
504 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
505 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
506 _ => "system".to_string(),
507 }
508}
509
510pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
516 let mut rx = bus.subscribe();
517 tokio::spawn(async move {
518 loop {
519 match rx.recv().await {
520 Ok(event) => {
521 let actor = extract_agent_id(&event);
522 let action = kernel_event_to_audit_action(&event);
523 let resource = format!("{event:?}");
524 audit.append(actor, action, resource);
525 }
526 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
527 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
531 tracing::warn!(
532 skipped = n,
533 "Audit trail subscriber lagged, skipping events"
534 );
535 continue;
536 }
537 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
538 tracing::info!("Audit trail event bus closed, exiting");
539 break;
540 }
541 }
542 }
543 });
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 fn sample_event(name: &str) -> KernelEvent {
551 KernelEvent::AgentCreated {
552 id: AgentId::new_v4(),
553 name: name.to_string(),
554 }
555 }
556
557 #[test]
558 fn test_event_bus_uses_sdk() {
559 let bus: EventBus = EventBus::new(256);
560 assert!(format!("{:?}", bus).contains("EventBus"));
561 }
562
563 #[tokio::test]
564 async fn test_publish_no_subscribers_ok() {
565 let bus = EventBus::new(16);
566 let result = bus.publish(sample_event("orphan"));
567 assert!(result.is_ok());
568 }
569
570 #[tokio::test]
571 async fn test_single_subscriber_receives_event() {
572 let bus = EventBus::new(16);
573 let mut rx = bus.subscribe();
574
575 let event = sample_event("test-agent");
576 bus.publish(event.clone()).unwrap();
577
578 let received = rx.try_recv().expect("should receive event");
579 match received {
580 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
581 _ => panic!("wrong event type"),
582 }
583 }
584
585 #[tokio::test]
586 async fn test_multiple_subscribers_receive_events() {
587 let bus = EventBus::new(16);
588 let mut rx1 = bus.subscribe();
589 let mut rx2 = bus.subscribe();
590
591 let event = sample_event("multi");
592 bus.publish(event.clone()).unwrap();
593
594 let r1 = rx1.try_recv().expect("rx1 should receive event");
595 let r2 = rx2.try_recv().expect("rx2 should receive event");
596
597 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
598 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
599 }
600
601 #[tokio::test]
602 async fn test_kernel_event_to_audit_action() {
603 let event = KernelEvent::AgentFailed {
604 id: AgentId::new_v4(),
605 error: "boom".to_string(),
606 };
607 let action = kernel_event_to_audit_action(&event);
608 match action {
609 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
610 other => panic!("expected AgentExit, got {other:?}"),
611 }
612 }
613
614 #[test]
620 fn test_rfc015_event_round_trip_json() {
621 let cases: Vec<KernelEvent> = vec![
622 KernelEvent::ToolExecutionStarted {
623 session_id: "s1".into(),
624 tool_name: "read_file".into(),
625 tool_call_id: "call_1".into(),
626 tool_args: serde_json::json!({"path": "/src/main.rs"}),
627 context: None,
628 },
629 KernelEvent::ToolExecutionFinished {
630 session_id: "s1".into(),
631 tool_call_id: "call_1".into(),
632 tool_name: "read_file".into(),
633 duration_ms: 234,
634 is_error: false,
635 output_summary: "fn main() {}".into(),
636 },
637 KernelEvent::ToolExecutionProgress {
638 session_id: "s1".into(),
639 tool_call_id: "call_1".into(),
640 tool_name: "read_file".into(),
641 progress: "reading line 42/100".into(),
642 tab_id: None,
643 context: None,
644 },
645 KernelEvent::MemoryRecallUsed {
646 session_id: "s1".into(),
647 query: "rust errors".into(),
648 count: 3,
649 source: "warm".into(),
650 },
651 KernelEvent::TokenUsageUpdate {
652 session_id: "s1".into(),
653 input_tokens: 1234,
654 output_tokens: 567,
655 },
656 KernelEvent::ReasoningFragment {
657 session_id: "s1".into(),
658 content: "compaction done".into(),
659 source: "compaction".into(),
660 },
661 ];
662 for event in cases {
663 let json = serde_json::to_string(&event).expect("serialize");
664 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
665 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
666 assert_eq!(json, json2, "round-trip should be stable");
667 }
668 }
669
670 #[test]
673 fn test_tool_execution_progress_serde_round_trip() {
674 let event = KernelEvent::ToolExecutionProgress {
675 session_id: "s-abc".into(),
676 tool_call_id: "call_42".into(),
677 tool_name: "browse".into(),
678 progress: "loading https://example.com".into(),
679 tab_id: Some(Uuid::new_v4()),
680 context: None,
681 };
682 let json = serde_json::to_string(&event).expect("serialize");
683 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
684 match back {
685 KernelEvent::ToolExecutionProgress {
686 ref session_id,
687 ref tool_call_id,
688 ref tool_name,
689 ref progress,
690 tab_id,
691 ..
692 } => {
693 assert_eq!(session_id, "s-abc");
694 assert_eq!(tool_call_id, "call_42");
695 assert_eq!(tool_name, "browse");
696 assert_eq!(progress, "loading https://example.com");
697 assert!(tab_id.is_some(), "tab_id should round-trip when present");
698 }
699 other => panic!("expected ToolExecutionProgress, got {other:?}"),
700 }
701 }
702
703 #[test]
709 fn test_tool_execution_progress_audit_action() {
710 let with_tab = KernelEvent::ToolExecutionProgress {
711 session_id: "s1".into(),
712 tool_call_id: "c1".into(),
713 tool_name: "browse".into(),
714 progress: "navigating".into(),
715 tab_id: Some(Uuid::new_v4()),
716 context: None,
717 };
718 match kernel_event_to_audit_action(&with_tab) {
719 AuditAction::Other { detail } => {
720 assert!(detail.contains("tool_progress"), "detail: {detail}");
721 assert!(detail.contains("browse"), "detail: {detail}");
722 assert!(
723 detail.contains(":tab="),
724 "detail should include tab id: {detail}"
725 );
726 }
727 other => panic!("expected Other, got {other:?}"),
728 }
729 let without_tab = KernelEvent::ToolExecutionProgress {
730 session_id: "s1".into(),
731 tool_call_id: "c1".into(),
732 tool_name: "browse".into(),
733 progress: "navigating".into(),
734 tab_id: None,
735 context: None,
736 };
737 match kernel_event_to_audit_action(&without_tab) {
738 AuditAction::Other { detail } => {
739 assert_eq!(detail, "tool_progress:browse");
740 }
741 other => panic!("expected Other, got {other:?}"),
742 }
743 }
744
745 #[test]
749 fn test_tool_execution_progress_tab_id_optional_in_serde() {
750 let legacy_json = r#"{
753 "ToolExecutionProgress": {
754 "session_id": "s-old",
755 "tool_call_id": "call_legacy",
756 "tool_name": "browse",
757 "progress": "step 1"
758 }
759 }"#;
760 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
761 match &event {
762 KernelEvent::ToolExecutionProgress {
763 session_id,
764 tool_call_id,
765 tool_name,
766 progress,
767 tab_id,
768 ..
769 } => {
770 assert_eq!(session_id, "s-old");
771 assert_eq!(tool_call_id, "call_legacy");
772 assert_eq!(tool_name, "browse");
773 assert_eq!(progress, "step 1");
774 assert!(tab_id.is_none(), "missing field should default to None");
775 }
776 other => panic!("expected ToolExecutionProgress, got {other:?}"),
777 }
778 let json = serde_json::to_string(&event).expect("serialize");
781 assert!(
782 !json.contains("tab_id"),
783 "tab_id should be omitted when None: {json}"
784 );
785 }
786
787 #[test]
791 fn test_rfc015_extract_agent_id() {
792 let event = KernelEvent::ToolExecutionStarted {
793 session_id: "abc-123".into(),
794 tool_name: "bash".into(),
795 tool_call_id: "c1".into(),
796 tool_args: serde_json::Value::Null,
797 context: None,
798 };
799 let action = kernel_event_to_audit_action(&event);
802 match action {
803 AuditAction::Other { detail } => {
804 assert!(
805 detail.contains("bash"),
806 "tool name in audit detail: {detail}"
807 );
808 }
809 other => panic!("expected Other, got {other:?}"),
810 }
811 }
812}