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 PhaseStarted {
246 session_id: String,
248 phase: String,
250 summary: Option<String>,
252 },
253 PhaseCompleted {
255 session_id: String,
257 phase: String,
259 },
260
261 CalendarEventCreated {
264 uid: String,
266 title: String,
268 start: String,
270 end: String,
272 },
273 CalendarEventUpdated {
275 uid: String,
277 title: String,
279 },
280 CalendarEventDeleted {
282 uid: String,
284 title: String,
286 },
287 EmailSent {
289 subject: String,
291 message_id: String,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 template_name: Option<String>,
296 },
297
298 KnowledgePersisted {
301 session_id: String,
302 message_index: usize,
303 path: String,
304 source: String, },
306 KnowledgeRemoved {
308 session_id: String,
309 message_index: usize,
310 },
311 AskUserRequest {
315 id: String,
318 question: String,
320 options: Vec<String>,
322 },
323 PersonaCreated {
326 id: String,
328 name: String,
330 enabled: bool,
332 source: String,
334 },
335 PersonaUpdated {
337 id: String,
339 name: String,
341 source: String,
343 },
344}
345
346pub fn kernel_event_to_audit_action(event: &KernelEvent) -> AuditAction {
348 match event {
349 KernelEvent::AgentCreated { name, .. } => AuditAction::AgentSpawn {
350 task_type: name.clone(),
351 },
352 KernelEvent::AgentStarted { .. } => AuditAction::AgentSpawn {
353 task_type: "started".to_string(),
354 },
355 KernelEvent::AgentStopped { success, .. } => AuditAction::AgentExit {
356 reason: if *success {
357 "completed".to_string()
358 } else {
359 "stopped".to_string()
360 },
361 },
362 KernelEvent::AgentFailed { error, .. } => AuditAction::AgentExit {
363 reason: error.clone(),
364 },
365 KernelEvent::MessageReceived { content, .. } => AuditAction::Other {
366 detail: format!("message: {content}"),
367 },
368 KernelEvent::AgentOutput { output, .. } => AuditAction::Other {
369 detail: format!("agent_output:{output}"),
370 },
371 KernelEvent::ApprovalRequested {
372 id,
373 action,
374 resource,
375 ..
376 } => AuditAction::Other {
377 detail: format!("approval_requested:{id}:{action}:{resource}"),
378 },
379 KernelEvent::ApprovalResolved { id, approved } => AuditAction::Other {
380 detail: format!("approval_resolved:{id}:{approved}"),
381 },
382 KernelEvent::MemoryStored {
383 id, memory_type, ..
384 } => AuditAction::MemoryWrite {
385 entry_id: format!("{id}:{memory_type}"),
386 },
387 KernelEvent::MemoryRecalled { query, count } => AuditAction::MemoryRead {
388 entry_id: format!("query:{query}:{count}results"),
389 },
390 KernelEvent::AgentGroupCreated {
391 group_id,
392 agent_count,
393 } => AuditAction::Other {
394 detail: format!("group_created:{group_id}:{agent_count}agents"),
395 },
396 KernelEvent::AgentGroupMemberCompleted {
397 group_id,
398 agent_id,
399 success,
400 } => AuditAction::Other {
401 detail: format!("group_member_completed:{group_id}:{agent_id}:{success}"),
402 },
403 KernelEvent::ProjectCreated {
404 project_id: _,
405 name,
406 source,
407 } => AuditAction::Other {
408 detail: format!("project_created:{name}:{source}"),
409 },
410 KernelEvent::ProjectActivated {
411 project_id: _,
412 name,
413 } => AuditAction::Other {
414 detail: format!("project_activated:{name}"),
415 },
416 KernelEvent::ToolExecutionStarted { tool_name, .. } => AuditAction::Other {
418 detail: format!("tool_started:{tool_name}"),
419 },
420 KernelEvent::ToolExecutionFinished {
421 tool_name,
422 is_error,
423 ..
424 } => AuditAction::Other {
425 detail: format!(
426 "tool_finished:{tool_name}:{}",
427 if *is_error { "error" } else { "ok" }
428 ),
429 },
430 KernelEvent::ToolExecutionProgress {
431 tool_name,
432 tab_id,
433 context,
434 ..
435 } => AuditAction::Other {
436 detail: {
437 let mut d = format!("tool_progress:{tool_name}");
438 if let Some(id) = tab_id {
439 d.push_str(&format!(":tab={id}"));
440 }
441 if let Some(ctx) = context
442 .as_ref()
443 .and_then(|c| c.get("kind"))
444 .and_then(|k| k.as_str())
445 {
446 d.push_str(&format!(":{ctx}"));
447 }
448 d
449 },
450 },
451 KernelEvent::MemoryRecallUsed { query, count, .. } => AuditAction::MemoryRead {
452 entry_id: format!("recall:{query}:{count}results"),
453 },
454 KernelEvent::TokenUsageUpdate {
455 input_tokens,
456 output_tokens,
457 ..
458 } => AuditAction::Other {
459 detail: format!("tokens:in={input_tokens}:out={output_tokens}"),
460 },
461 KernelEvent::ReasoningFragment { source, .. } => AuditAction::Other {
462 detail: format!("reasoning:{source}"),
463 },
464 KernelEvent::CalendarEventCreated { uid, title, .. } => AuditAction::Other {
465 detail: format!("calendar:created:{uid}:{title}"),
466 },
467 KernelEvent::CalendarEventUpdated { uid, title } => AuditAction::Other {
468 detail: format!("calendar:updated:{uid}:{title}"),
469 },
470 KernelEvent::CalendarEventDeleted { uid, title } => AuditAction::Other {
471 detail: format!("calendar:deleted:{uid}:{title}"),
472 },
473 KernelEvent::EmailSent {
474 subject,
475 message_id,
476 template_name,
477 } => AuditAction::Other {
478 detail: format!("email:sent:{subject} (msg={message_id}, tpl={template_name:?})"),
479 },
480 KernelEvent::KnowledgePersisted {
481 session_id,
482 message_index,
483 path,
484 source,
485 } => AuditAction::Other {
486 detail: format!("knowledge:persisted:{session_id}:{message_index}:{path}:{source}"),
487 },
488 KernelEvent::KnowledgeRemoved {
489 session_id,
490 message_index,
491 } => AuditAction::Other {
492 detail: format!("knowledge:removed:{session_id}:{message_index}"),
493 },
494 KernelEvent::AskUserRequest { id, question, .. } => AuditAction::Other {
495 detail: format!("ask_user:{id}:{question}"),
496 },
497 KernelEvent::PersonaCreated {
498 id, name, source, ..
499 } => AuditAction::Other {
500 detail: format!("persona:created:{id}:{name}:{source}"),
501 },
502 KernelEvent::PersonaUpdated { id, name, source } => AuditAction::Other {
503 detail: format!("persona:updated:{id}:{name}:{source}"),
504 },
505 KernelEvent::PhaseStarted { phase, .. } => AuditAction::Other {
506 detail: format!("phase_started:{phase}"),
507 },
508 KernelEvent::PhaseCompleted { phase, .. } => AuditAction::Other {
509 detail: format!("phase_completed:{phase}"),
510 },
511 }
512}
513
514fn extract_agent_id(event: &KernelEvent) -> String {
516 match event {
517 KernelEvent::AgentCreated { id, .. } => id.to_string(),
518 KernelEvent::AgentStarted { id, .. } => id.to_string(),
519 KernelEvent::AgentStopped { id, .. } => id.to_string(),
520 KernelEvent::AgentFailed { id, .. } => id.to_string(),
521 KernelEvent::MessageReceived { from, .. } => from.to_string(),
522 KernelEvent::AgentOutput { agent_id, .. } => agent_id.to_string(),
523 KernelEvent::AgentGroupMemberCompleted { agent_id, .. } => agent_id.to_string(),
524 KernelEvent::ProjectActivated { project_id, .. } => format!("project:{project_id}"),
525 KernelEvent::ToolExecutionStarted { session_id, .. } => format!("session:{session_id}"),
527 KernelEvent::ToolExecutionFinished { session_id, .. } => format!("session:{session_id}"),
528 KernelEvent::ToolExecutionProgress { session_id, .. } => format!("session:{session_id}"),
529 KernelEvent::MemoryRecallUsed { session_id, .. } => format!("session:{session_id}"),
530 KernelEvent::TokenUsageUpdate { session_id, .. } => format!("session:{session_id}"),
531 KernelEvent::ReasoningFragment { session_id, .. } => format!("session:{session_id}"),
532 KernelEvent::PhaseStarted { session_id, .. } => format!("session:{session_id}"),
533 KernelEvent::PhaseCompleted { session_id, .. } => format!("session:{session_id}"),
534 KernelEvent::KnowledgePersisted { session_id, .. } => format!("session:{session_id}"),
535 KernelEvent::KnowledgeRemoved { session_id, .. } => format!("session:{session_id}"),
536 _ => "system".to_string(),
537 }
538}
539
540pub fn attach_audit_trail(bus: &EventBus, audit: Arc<AuditTrail>) {
546 let mut rx = bus.subscribe();
547 tokio::spawn(async move {
548 loop {
549 match rx.recv().await {
550 Ok(event) => {
551 let actor = extract_agent_id(&event);
552 let action = kernel_event_to_audit_action(&event);
553 let resource = format!("{event:?}");
554 audit.append(actor, action, resource);
555 }
556 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
557 crate::metrics::get_metrics().audit_lagged_events.inc_by(n);
561 tracing::warn!(
562 skipped = n,
563 "Audit trail subscriber lagged, skipping events"
564 );
565 continue;
566 }
567 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
568 tracing::info!("Audit trail event bus closed, exiting");
569 break;
570 }
571 }
572 }
573 });
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 fn sample_event(name: &str) -> KernelEvent {
581 KernelEvent::AgentCreated {
582 id: AgentId::new_v4(),
583 name: name.to_string(),
584 }
585 }
586
587 #[test]
588 fn test_event_bus_uses_sdk() {
589 let bus: EventBus = EventBus::new(256);
590 assert!(format!("{:?}", bus).contains("EventBus"));
591 }
592
593 #[tokio::test]
594 async fn test_publish_no_subscribers_ok() {
595 let bus = EventBus::new(16);
596 let result = bus.publish(sample_event("orphan"));
597 assert!(result.is_ok());
598 }
599
600 #[tokio::test]
601 async fn test_single_subscriber_receives_event() {
602 let bus = EventBus::new(16);
603 let mut rx = bus.subscribe();
604
605 let event = sample_event("test-agent");
606 bus.publish(event.clone()).unwrap();
607
608 let received = rx.try_recv().expect("should receive event");
609 match received {
610 KernelEvent::AgentCreated { name, .. } => assert_eq!(name, "test-agent"),
611 _ => panic!("wrong event type"),
612 }
613 }
614
615 #[tokio::test]
616 async fn test_multiple_subscribers_receive_events() {
617 let bus = EventBus::new(16);
618 let mut rx1 = bus.subscribe();
619 let mut rx2 = bus.subscribe();
620
621 let event = sample_event("multi");
622 bus.publish(event.clone()).unwrap();
623
624 let r1 = rx1.try_recv().expect("rx1 should receive event");
625 let r2 = rx2.try_recv().expect("rx2 should receive event");
626
627 assert!(matches!(r1, KernelEvent::AgentCreated { .. }));
628 assert!(matches!(r2, KernelEvent::AgentCreated { .. }));
629 }
630
631 #[tokio::test]
632 async fn test_kernel_event_to_audit_action() {
633 let event = KernelEvent::AgentFailed {
634 id: AgentId::new_v4(),
635 error: "boom".to_string(),
636 };
637 let action = kernel_event_to_audit_action(&event);
638 match action {
639 AuditAction::AgentExit { reason } => assert_eq!(reason, "boom"),
640 other => panic!("expected AgentExit, got {other:?}"),
641 }
642 }
643
644 #[test]
650 fn test_rfc015_event_round_trip_json() {
651 let cases: Vec<KernelEvent> = vec![
652 KernelEvent::ToolExecutionStarted {
653 session_id: "s1".into(),
654 tool_name: "read_file".into(),
655 tool_call_id: "call_1".into(),
656 tool_args: serde_json::json!({"path": "/src/main.rs"}),
657 context: None,
658 },
659 KernelEvent::ToolExecutionFinished {
660 session_id: "s1".into(),
661 tool_call_id: "call_1".into(),
662 tool_name: "read_file".into(),
663 duration_ms: 234,
664 is_error: false,
665 output_summary: "fn main() {}".into(),
666 },
667 KernelEvent::ToolExecutionProgress {
668 session_id: "s1".into(),
669 tool_call_id: "call_1".into(),
670 tool_name: "read_file".into(),
671 progress: "reading line 42/100".into(),
672 tab_id: None,
673 context: None,
674 },
675 KernelEvent::MemoryRecallUsed {
676 session_id: "s1".into(),
677 query: "rust errors".into(),
678 count: 3,
679 source: "warm".into(),
680 },
681 KernelEvent::TokenUsageUpdate {
682 session_id: "s1".into(),
683 input_tokens: 1234,
684 output_tokens: 567,
685 },
686 KernelEvent::ReasoningFragment {
687 session_id: "s1".into(),
688 content: "compaction done".into(),
689 source: "compaction".into(),
690 },
691 ];
692 for event in cases {
693 let json = serde_json::to_string(&event).expect("serialize");
694 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
695 let json2 = serde_json::to_string(&back).expect("serialize round-trip");
696 assert_eq!(json, json2, "round-trip should be stable");
697 }
698 }
699
700 #[test]
703 fn test_tool_execution_progress_serde_round_trip() {
704 let event = KernelEvent::ToolExecutionProgress {
705 session_id: "s-abc".into(),
706 tool_call_id: "call_42".into(),
707 tool_name: "browse".into(),
708 progress: "loading https://example.com".into(),
709 tab_id: Some(Uuid::new_v4()),
710 context: None,
711 };
712 let json = serde_json::to_string(&event).expect("serialize");
713 let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
714 match back {
715 KernelEvent::ToolExecutionProgress {
716 ref session_id,
717 ref tool_call_id,
718 ref tool_name,
719 ref progress,
720 tab_id,
721 ..
722 } => {
723 assert_eq!(session_id, "s-abc");
724 assert_eq!(tool_call_id, "call_42");
725 assert_eq!(tool_name, "browse");
726 assert_eq!(progress, "loading https://example.com");
727 assert!(tab_id.is_some(), "tab_id should round-trip when present");
728 }
729 other => panic!("expected ToolExecutionProgress, got {other:?}"),
730 }
731 }
732
733 #[test]
739 fn test_tool_execution_progress_audit_action() {
740 let with_tab = KernelEvent::ToolExecutionProgress {
741 session_id: "s1".into(),
742 tool_call_id: "c1".into(),
743 tool_name: "browse".into(),
744 progress: "navigating".into(),
745 tab_id: Some(Uuid::new_v4()),
746 context: None,
747 };
748 match kernel_event_to_audit_action(&with_tab) {
749 AuditAction::Other { detail } => {
750 assert!(detail.contains("tool_progress"), "detail: {detail}");
751 assert!(detail.contains("browse"), "detail: {detail}");
752 assert!(
753 detail.contains(":tab="),
754 "detail should include tab id: {detail}"
755 );
756 }
757 other => panic!("expected Other, got {other:?}"),
758 }
759 let without_tab = KernelEvent::ToolExecutionProgress {
760 session_id: "s1".into(),
761 tool_call_id: "c1".into(),
762 tool_name: "browse".into(),
763 progress: "navigating".into(),
764 tab_id: None,
765 context: None,
766 };
767 match kernel_event_to_audit_action(&without_tab) {
768 AuditAction::Other { detail } => {
769 assert_eq!(detail, "tool_progress:browse");
770 }
771 other => panic!("expected Other, got {other:?}"),
772 }
773 }
774
775 #[test]
779 fn test_tool_execution_progress_tab_id_optional_in_serde() {
780 let legacy_json = r#"{
783 "ToolExecutionProgress": {
784 "session_id": "s-old",
785 "tool_call_id": "call_legacy",
786 "tool_name": "browse",
787 "progress": "step 1"
788 }
789 }"#;
790 let event: KernelEvent = serde_json::from_str(legacy_json).expect("deserialize legacy");
791 match &event {
792 KernelEvent::ToolExecutionProgress {
793 session_id,
794 tool_call_id,
795 tool_name,
796 progress,
797 tab_id,
798 ..
799 } => {
800 assert_eq!(session_id, "s-old");
801 assert_eq!(tool_call_id, "call_legacy");
802 assert_eq!(tool_name, "browse");
803 assert_eq!(progress, "step 1");
804 assert!(tab_id.is_none(), "missing field should default to None");
805 }
806 other => panic!("expected ToolExecutionProgress, got {other:?}"),
807 }
808 let json = serde_json::to_string(&event).expect("serialize");
811 assert!(
812 !json.contains("tab_id"),
813 "tab_id should be omitted when None: {json}"
814 );
815 }
816
817 #[test]
821 fn test_rfc015_extract_agent_id() {
822 let event = KernelEvent::ToolExecutionStarted {
823 session_id: "abc-123".into(),
824 tool_name: "bash".into(),
825 tool_call_id: "c1".into(),
826 tool_args: serde_json::Value::Null,
827 context: None,
828 };
829 let action = kernel_event_to_audit_action(&event);
832 match action {
833 AuditAction::Other { detail } => {
834 assert!(
835 detail.contains("bash"),
836 "tool name in audit detail: {detail}"
837 );
838 }
839 other => panic!("expected Other, got {other:?}"),
840 }
841 }
842}