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 PathAccessRequested {
109 id: uuid::Uuid,
111 tool_name: String,
113 path: String,
115 mode: String,
117 agent_name: String,
119 reason: String,
121 session_id: Option<String>,
123 },
124 MemoryStored {
126 id: String,
128 memory_type: String,
130 source: String,
132 },
133 MemoryRecalled {
135 query: String,
137 count: usize,
139 },
140 AgentGroupCreated {
142 group_id: uuid::Uuid,
144 agent_count: usize,
146 },
147 AgentGroupMemberCompleted {
149 group_id: uuid::Uuid,
151 agent_id: uuid::Uuid,
153 success: bool,
155 },
156 ProjectCreated {
158 project_id: uuid::Uuid,
160 name: String,
162 source: String,
164 },
165 ProjectActivated {
167 project_id: uuid::Uuid,
169 name: String,
171 },
172
173 ToolExecutionStarted {
178 session_id: String,
180 tool_name: String,
182 tool_call_id: String,
184 tool_args: serde_json::Value,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
189 context: Option<serde_json::Value>,
190 },
191 ToolExecutionFinished {
193 session_id: String,
195 tool_call_id: String,
197 tool_name: String,
199 duration_ms: u64,
201 is_error: bool,
203 output_summary: String,
205 },
206 ToolExecutionProgress {
208 session_id: String,
210 tool_call_id: String,
212 tool_name: String,
214 progress: String,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
220 tab_id: Option<Uuid>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
227 context: Option<serde_json::Value>,
228 },
229 MemoryRecallUsed {
231 session_id: String,
233 query: String,
235 count: usize,
237 source: String,
239 },
240 TokenUsageUpdate {
242 session_id: String,
244 input_tokens: u64,
246 output_tokens: u64,
248 },
249 ReasoningFragment {
251 session_id: String,
253 content: String,
255 source: String,
257 },
258 ToolArgsDelta {
265 session_id: String,
267 tool_call_id: String,
269 args_delta: String,
271 },
272
273 CompactionTriggered {
283 session_id: Option<String>,
285 source: String,
287 },
288 CalendarEventCreated {
291 uid: String,
293 title: String,
295 start: String,
297 end: String,
299 },
300 CalendarEventUpdated {
302 uid: String,
304 title: String,
306 },
307 CalendarEventDeleted {
309 uid: String,
311 title: String,
313 },
314 EmailSent {
316 subject: String,
318 message_id: String,
320 #[serde(default, skip_serializing_if = "Option::is_none")]
322 template_name: Option<String>,
323 },
324
325 KnowledgePersisted {
328 session_id: String,
329 message_index: usize,
330 path: String,
331 source: String, },
333 KnowledgeRemoved {
335 session_id: String,
336 message_index: usize,
337 },
338 AskUserRequest {
342 id: String,
345 question: String,
347 options: Vec<String>,
349 },
350 PersonaCreated {
353 id: String,
355 name: String,
357 enabled: bool,
359 source: String,
361 },
362 PersonaUpdated {
364 id: String,
366 name: String,
368 source: String,
370 },
371 IntegrationInstallStarted {
377 job_id: String,
379 integration_id: String,
381 label: String,
383 },
384 IntegrationInstallProgress {
386 job_id: String,
387 integration_id: String,
388 line: String,
390 },
391 IntegrationInstallCompleted {
393 job_id: String,
394 integration_id: String,
395 command: String,
397 output: String,
398 exit_code: Option<i32>,
399 },
400 IntegrationInstallFailed {
402 job_id: String,
403 integration_id: String,
404 error: String,
405 },
406 CompressionDelta {
408 session_id: String,
410 delta: String,
412 },
413 CompressionDone {
415 session_id: String,
417 },
418 CompressionFailed {
420 session_id: String,
422 error: String,
424 },
425}
426
427pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
429 match event {
430 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
431 task_type: name.clone(),
432 },
433 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
434 task_type: "started".to_string(),
435 },
436 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
437 reason: if *success {
438 "completed".to_string()
439 } else {
440 "stopped".to_string()
441 },
442 },
443 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
444 reason: error.clone(),
445 },
446 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
447 detail: format!("message: {content}"),
448 },
449 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
450 detail: format!("agent_output:{output}"),
451 },
452 KernelEvent::ApprovalRequested {
453 id,
454 action,
455 resource,
456 ..
457 } => AuditAction::Other {
458 detail: format!("approval_requested:{id}:{action}:{resource}"),
459 },
460 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
461 detail: format!("approval_resolved:{id}:{approved}"),
462 },
463 KernelEvent::PathAccessRequested {
464 id,
465 path,
466 tool_name,
467 ..
468 } => AuditAction::Other {
469 detail: format!("path_access_requested:{id}:{tool_name}:{path}"),
470 },
471 KernelEvent::MemoryStored {
472 id, memory_type, ..
473 } => AuditAction::MemoryWrite {
474 entry_id: format!("{id}:{memory_type}"),
475 },
476 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
477 entry_id: format!("query:{query}:{count}results"),
478 },
479 KernelEvent::AgentGroupCreated {
480 group_id,
481 agent_count,
482 } => AuditAction::Other {
483 detail: format!("group_created:{group_id}:{agent_count}agents"),
484 },
485 KernelEvent::AgentGroupMemberCompleted {
486 group_id,
487 agent_id,
488 success,
489 } => AuditAction::Other {
490 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
491 },
492 KernelEvent::ProjectCreated {
493 project_id: _,
494 name,
495 source,
496 } => AuditAction::Other {
497 detail: format!("project_created:{name}:{source}"),
498 },
499 KernelEvent::ProjectActivated {
500 project_id: _,
501 name,
502 } => AuditAction::Other {
503 detail: format!("project_activated:{name}"),
504 },
505 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
507 detail: format!("tool_started:{tool_name}"),
508 },
509 KernelEvent::ToolExecutionFinished {
510 tool_name,
511 is_error,
512 ..
513 } => AuditAction::Other {
514 detail: format!(
515 "tool_finished:{tool_name}:{}",
516 if *is_error { "error" } else { "ok" }
517 ),
518 },
519 KernelEvent::ToolExecutionProgress {
520 tool_name,
521 tab_id,
522 context,
523 ..
524 } => AuditAction::Other {
525 detail: {
526 let mut d = format!("tool_progress:{tool_name}");
527 if let Some(id) = tab_id {
528 d.push_str(&format!(":tab={id}"));
529 }
530 if let Some(ctx) = context
531 .as_ref()
532 .and_then(|c| c.get("kind"))
533 .and_then(|k| k.as_str())
534 {
535 d.push_str(&format!(":{ctx}"));
536 }
537 d
538 },
539 },
540 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
541 entry_id: format!("recall:{query}:{count}results"),
542 },
543 KernelEvent::TokenUsageUpdate {
544 input_tokens,
545 output_tokens,
546 ..
547 } => AuditAction::Other {
548 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
549 },
550 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
551 detail: format!("reasoning:{source}"),
552 },
553 KernelEvent::ToolArgsDelta { tool_call_id, .. } => AuditAction::Other {
554 detail: format!("tool_args_delta:{tool_call_id}"),
555 },
556 KernelEvent::CompactionTriggered { source, .. } => AuditAction::Other {
557 detail: format!("compaction:triggered:{source}"),
558 },
559 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
560 detail: format!("calendar:created:{uid}:{title}"),
561 },
562 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
563 detail: format!("calendar:updated:{uid}:{title}"),
564 },
565 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
566 detail: format!("calendar:deleted:{uid}:{title}"),
567 },
568 KernelEvent::EmailSent {
569 subject,
570 message_id,
571 template_name,
572 } => AuditAction::Other {
573 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
574 },
575 KernelEvent::KnowledgePersisted {
576 session_id,
577 message_index,
578 path,
579 source,
580 } => AuditAction::Other {
581 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
582 },
583 KernelEvent::KnowledgeRemoved {
584 session_id,
585 message_index,
586 } => AuditAction::Other {
587 detail: format!("knowledge:removed:{session_id}:{message_index}"),
588 },
589 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
590 detail: format!("ask_user:{id}:{question}"),
591 },
592 KernelEvent::PersonaCreated {
593 id, name, source, ..
594 } => AuditAction::Other {
595 detail: format!("persona:created:{id}:{name}:{source}"),
596 },
597 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
598 detail: format!("persona:updated:{id}:{name}:{source}"),
599 },
600 KernelEvent::IntegrationInstallStarted {
601 job_id,
602 integration_id,
603 ..
604 } => AuditAction::Other {
605 detail: format!("install:started:{integration_id}:{job_id}"),
606 },
607 KernelEvent::IntegrationInstallProgress {
608 job_id,
609 integration_id,
610 ..
611 } => AuditAction::Other {
612 detail: format!("install:progress:{integration_id}:{job_id}"),
613 },
614 KernelEvent::IntegrationInstallCompleted {
615 job_id,
616 integration_id,
617 exit_code,
618 ..
619 } => AuditAction::Other {
620 detail: format!(
621 "install:completed:{integration_id}:{job_id}:exit={}",
622 exit_code
623 .map(|c| c.to_string())
624 .unwrap_or_else(|| "?".into())
625 ),
626 },
627 KernelEvent::IntegrationInstallFailed {
628 job_id,
629 integration_id,
630 ..
631 } => AuditAction::Other {
632 detail: format!("install:failed:{integration_id}:{job_id}"),
633 },
634 KernelEvent::CompressionDelta { .. }
635 | KernelEvent::CompressionDone { .. }
636 | KernelEvent::CompressionFailed { .. } => AuditAction::Other {
637 detail: "compression".to_string(),
638 },
639 }
640}
641
642fn extract_agent_id(event: &KernelEvent) -> String {
644 match event {
645 KernelEvent::AgentCreated { id, .. } => id.to_string(),
646 KernelEvent::AgentStarted { id, .. } => id.to_string(),
647 KernelEvent::AgentStopped { id, .. } => id.to_string(),
648 KernelEvent::AgentFailed { id, .. } => id.to_string(),
649 KernelEvent::MessageReceived { from, .. } => from.to_string(),
650 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
651 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
652 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
653 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
655 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
656 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
657 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
658 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
659 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
660 KernelEvent::ToolArgsDelta { session_id, .. } => format!("session:{session_id}"),
661 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
662 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
663 KernelEvent::CompactionTriggered { session_id, .. } => session_id
664 .as_ref()
665 .map(|s| format!("session:{s}"))
666 .unwrap_or_else(|| "system".to_string()),
667 _ => "system".to_string(),
668 }
669}
670
671pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
677 let mut rx = bus.subscribe();
678 tokio::spawn(async move {
679 loop {
680 match rx.recv().await {
681 Ok(event) => {
682 if matches!(event, KernelEvent::ToolArgsDelta { .. }) {
687 continue;
688 }
689 let actor = extract_agent_id(&event);
690 let action = kernel_event_to_audit_action(&event);
691 let resource = format!("{event:?}");
692 audit.append(actor, action, resource);
693 }
694 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
695 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
699 tracing::warn!(
700 skipped = n,
701 "Audit trail subscriber lagged, skipping events"
702 );
703 continue;
704 }
705 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
706 tracing::info!("Audit trail event bus closed, exiting");
707 break;
708 }
709 }
710 }
711 });
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 fn sample_event(name: &str) -> KernelEvent {
719 KernelEvent::AgentCreated {
720 id: AgentId::new_v4(),
721 name: name.to_string(),
722 }
723 }
724
725 #[test]
726 fn test_event_bus_uses_sdk() {
727 let bus: EventBus = EventBus::new(256);
728 assert!(format!("{:?}", bus).contains("EventBus"));
729 }
730
731 #[tokio::test]
732 async fn test_publish_no_subscribers_ok() {
733 let bus = EventBus::new(16);
734 let result = bus.publish(sample_event("orphan"));
735 assert!(result.is_ok());
736 }
737
738 #[tokio::test]
739 async fn test_single_subscriber_receives_event() {
740 let bus = EventBus::new(16);
741 let mut rx = bus.subscribe();
742
743 let event = sample_event("test-agent");
744 bus.publish(event.clone()).unwrap();
745
746 let received = rx.try_recv().expect("should receive event");
747 match received {
748 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
749 _ => panic!("wrong event type"),
750 }
751 }
752
753 #[tokio::test]
754 async fn test_multiple_subscribers_receive_events() {
755 let bus = EventBus::new(16);
756 let mut rx1 = bus.subscribe();
757 let mut rx2 = bus.subscribe();
758
759 let event = sample_event("multi");
760 bus.publish(event.clone()).unwrap();
761
762 let r1 = rx1.try_recv().expect("rx1 should receive event");
763 let r2 = rx2.try_recv().expect("rx2 should receive event");
764
765 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
766 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
767 }
768
769 #[tokio::test]
770 async fn test_kernel_event_to_audit_action() {
771 let event = KernelEvent::AgentFailed {
772 id: AgentId::new_v4(),
773 error: "boom".to_string(),
774 };
775 let action = kernel_event_to_audit_action(&event);
776 match action {
777 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
778 other => panic!("expected AgentExit, got {other:?}"),
779 }
780 }
781
782 #[test]
788 fn test_rfc015_event_round_trip_json() {
789 let cases: Vec<KernelEvent> = vec![
790 KernelEvent::ToolExecutionStarted {
791 session_id: "s1".into(),
792 tool_name: "read_file".into(),
793 tool_call_id: "call_1".into(),
794 tool_args: serde_json::json!({"path": "/src/main.rs"}),
795 context: None,
796 },
797 KernelEvent::ToolExecutionFinished {
798 session_id: "s1".into(),
799 tool_call_id: "call_1".into(),
800 tool_name: "read_file".into(),
801 duration_ms: 234,
802 is_error: false,
803 output_summary: "fn main() {}".into(),
804 },
805 KernelEvent::ToolExecutionProgress {
806 session_id: "s1".into(),
807 tool_call_id: "call_1".into(),
808 tool_name: "read_file".into(),
809 progress: "reading line 42/100".into(),
810 tab_id: None,
811 context: None,
812 },
813 KernelEvent::MemoryRecallUsed {
814 session_id: "s1".into(),
815 query: "rust errors".into(),
816 count: 3,
817 source: "warm".into(),
818 },
819 KernelEvent::TokenUsageUpdate {
820 session_id: "s1".into(),
821 input_tokens: 1234,
822 output_tokens: 567,
823 },
824 KernelEvent::ReasoningFragment {
825 session_id: "s1".into(),
826 content: "compaction done".into(),
827 source: "compaction".into(),
828 },
829 ];
830 for event in cases {
831 let json = serde_json::to_string(&event).expect("serialize");
832 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
833 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
834 assert_eq!(json, json2, "round-trip should be stable");
835 }
836 }
837
838 #[test]
841 fn test_tool_execution_progress_serde_round_trip() {
842 let event = KernelEvent::ToolExecutionProgress {
843 session_id: "s-abc".into(),
844 tool_call_id: "call_42".into(),
845 tool_name: "browse".into(),
846 progress: "loading https://example.com".into(),
847 tab_id: Some(Uuid::new_v4()),
848 context: None,
849 };
850 let json = serde_json::to_string(&event).expect("serialize");
851 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
852 match back {
853 KernelEvent::ToolExecutionProgress {
854 ref session_id,
855 ref tool_call_id,
856 ref tool_name,
857 ref progress,
858 tab_id,
859 ..
860 } => {
861 assert_eq!(session_id, "s-abc");
862 assert_eq!(tool_call_id, "call_42");
863 assert_eq!(tool_name, "browse");
864 assert_eq!(progress, "loading https://example.com");
865 assert!(tab_id.is_some(), "tab_id should round-trip when present");
866 }
867 other => panic!("expected ToolExecutionProgress, got {other:?}"),
868 }
869 }
870
871 #[test]
877 fn test_tool_execution_progress_audit_action() {
878 let with_tab = KernelEvent::ToolExecutionProgress {
879 session_id: "s1".into(),
880 tool_call_id: "c1".into(),
881 tool_name: "browse".into(),
882 progress: "navigating".into(),
883 tab_id: Some(Uuid::new_v4()),
884 context: None,
885 };
886 match kernel_event_to_audit_action(&with_tab) {
887 AuditAction::Other { detail } => {
888 assert!(detail.contains("tool_progress"), "detail: {detail}");
889 assert!(detail.contains("browse"), "detail: {detail}");
890 assert!(
891 detail.contains(":tab="),
892 "detail should include tab id: {detail}"
893 );
894 }
895 other => panic!("expected Other, got {other:?}"),
896 }
897 let without_tab = KernelEvent::ToolExecutionProgress {
898 session_id: "s1".into(),
899 tool_call_id: "c1".into(),
900 tool_name: "browse".into(),
901 progress: "navigating".into(),
902 tab_id: None,
903 context: None,
904 };
905 match kernel_event_to_audit_action(&without_tab) {
906 AuditAction::Other { detail } => {
907 assert_eq!(detail, "tool_progress:browse");
908 }
909 other => panic!("expected Other, got {other:?}"),
910 }
911 }
912
913 #[test]
917 fn test_tool_execution_progress_tab_id_optional_in_serde() {
918 let legacy_json = r#"{
921 "ToolExecutionProgress": {
922 "session_id": "s-old",
923 "tool_call_id": "call_legacy",
924 "tool_name": "browse",
925 "progress": "step 1"
926 }
927 }"#;
928 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
929 match &event {
930 KernelEvent::ToolExecutionProgress {
931 session_id,
932 tool_call_id,
933 tool_name,
934 progress,
935 tab_id,
936 ..
937 } => {
938 assert_eq!(session_id, "s-old");
939 assert_eq!(tool_call_id, "call_legacy");
940 assert_eq!(tool_name, "browse");
941 assert_eq!(progress, "step 1");
942 assert!(tab_id.is_none(), "missing field should default to None");
943 }
944 other => panic!("expected ToolExecutionProgress, got {other:?}"),
945 }
946 let json = serde_json::to_string(&event).expect("serialize");
949 assert!(
950 !json.contains("tab_id"),
951 "tab_id should be omitted when None: {json}"
952 );
953 }
954
955 #[test]
959 fn test_rfc015_extract_agent_id() {
960 let event = KernelEvent::ToolExecutionStarted {
961 session_id: "abc-123".into(),
962 tool_name: "bash".into(),
963 tool_call_id: "c1".into(),
964 tool_args: serde_json::Value::Null,
965 context: None,
966 };
967 let action = kernel_event_to_audit_action(&event);
970 match action {
971 AuditAction::Other { detail } => {
972 assert!(
973 detail.contains("bash"),
974 "tool name in audit detail: {detail}"
975 );
976 }
977 other => panic!("expected Other, got {other:?}"),
978 }
979 }
980}