1use av_core::error::check_jcs_safe;
4use serde::{Deserialize, Serialize};
5
6pub const OCSF_VERSION: &str = "1.10.0";
9
10pub const PRODUCT_NAME: &str = "agentvisor-ai";
12
13pub const CATEGORY_UID: u8 = 6;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[non_exhaustive]
19pub enum EventClass {
20 #[serde(rename = "agent.tool_call")]
22 ToolCall,
23 #[serde(rename = "agent.stop_reason")]
25 StopReason,
26 #[serde(rename = "agent.receipt")]
28 Receipt,
29 #[serde(rename = "agent.compression")]
31 Compression,
32 #[serde(rename = "agent.identity")]
34 Identity,
35 #[serde(rename = "agent.session")]
37 Session,
38}
39
40impl EventClass {
41 pub fn class_uid(self) -> u32 {
43 match self {
44 Self::ToolCall => 9901,
45 Self::StopReason => 9902,
46 Self::Receipt => 9903,
47 Self::Compression => 9904,
48 Self::Identity => 9905,
49 Self::Session => 9906,
50 }
51 }
52
53 pub fn topic(self) -> &'static str {
55 match self {
56 Self::ToolCall => "agent.tool_call",
57 Self::StopReason => "agent.stop_reason",
58 Self::Receipt => "agent.receipt",
59 Self::Compression => "agent.compression",
60 Self::Identity => "agent.identity",
61 Self::Session => "agent.session",
62 }
63 }
64
65 pub fn all() -> &'static [EventClass] {
67 &[
68 Self::ToolCall,
69 Self::StopReason,
70 Self::Receipt,
71 Self::Compression,
72 Self::Identity,
73 Self::Session,
74 ]
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81#[non_exhaustive]
82pub enum StatusId {
83 Unknown,
85 Success,
87 Failure,
89}
90
91impl StatusId {
92 pub fn id(self) -> u8 {
94 match self {
95 Self::Unknown => 0,
96 Self::Success => 1,
97 Self::Failure => 2,
98 }
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct CharterFile {
107 pub name: String,
109 pub type_id: u8,
111}
112
113impl From<String> for CharterFile {
114 fn from(name: String) -> Self {
115 Self { name, type_id: 1 }
116 }
117}
118
119impl From<&str> for CharterFile {
120 fn from(name: &str) -> Self {
121 Self::from(name.to_owned())
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct Product {
129 pub name: String,
131 pub vendor_name: String,
133 pub version: String,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(deny_unknown_fields)]
140pub struct AgentIdentity {
141 pub version: String,
143 pub charter: CharterFile,
145 pub instance_uid: String,
147 #[serde(skip_serializing_if = "Option::is_none")]
150 pub ttl_remaining_s: Option<u64>,
151}
152
153#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(deny_unknown_fields)]
157pub struct EventMetrics {
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub prompt_tokens: Option<u64>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub completion_tokens: Option<u64>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub cached_tokens: Option<u64>,
167 #[serde(skip_serializing_if = "Option::is_none")]
169 pub pruned_tokens: Option<u64>,
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub pruning_ratio_millis: Option<u64>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(deny_unknown_fields)]
179pub struct Fingerprint {
180 pub algorithm_id: u8,
182 pub algorithm: String,
184 pub serialization_id: u8,
186 pub serialization: String,
188 pub value: String,
190}
191
192impl Fingerprint {
193 pub fn sha256_jcs(value_hex: String) -> Self {
195 Self {
196 algorithm_id: 3,
197 algorithm: "SHA-256".to_owned(),
198 serialization_id: 2,
199 serialization: "JCS".to_owned(),
200 value: value_hex,
201 }
202 }
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(deny_unknown_fields)]
208pub struct Metadata {
209 pub version: String,
211 pub uid: String,
213 pub product: Product,
215 pub sequence: u64,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249pub struct OcsfEvent {
250 pub metadata: Metadata,
252 pub class_name: EventClass,
254 pub class_uid: u32,
256 pub category_uid: u8,
258 pub activity_id: u8,
260 pub type_uid: u64,
262 pub time: u64,
264 pub time_iso: String,
266 pub severity_id: u8,
268 pub status_id: u8,
270 pub session_uid: String,
272 pub ai_agent: AgentIdentity,
274 #[serde(skip_serializing_if = "Option::is_none")]
276 pub stop_reason_id: Option<u8>,
277 #[serde(skip_serializing_if = "Option::is_none")]
280 pub stop_reason: Option<String>,
281 pub payload: serde_json::Value,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub metrics: Option<EventMetrics>,
286 #[serde(skip_serializing_if = "Option::is_none")]
288 pub inventory: Option<Fingerprint>,
289 #[serde(skip_serializing_if = "Option::is_none")]
291 pub prev_inventory: Option<Fingerprint>,
292 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty", flatten)]
294 pub unmapped: serde_json::Map<String, serde_json::Value>,
295}
296
297#[derive(Debug)]
299pub struct OcsfEventBuilder {
300 class: EventClass,
301 session_uid: String,
302 ai_agent: AgentIdentity,
303 seq: u64,
304 activity_id: u8,
305 severity_id: u8,
306 status: StatusId,
307 stop_reason: Option<crate::StopReason>,
308 native_stop_reason: Option<String>,
309 payload: serde_json::Value,
310 metrics: Option<EventMetrics>,
311 inventory: Option<Fingerprint>,
312 prev_inventory: Option<Fingerprint>,
313}
314
315impl OcsfEventBuilder {
316 pub fn new(class: EventClass, session_uid: impl Into<String>, ai_agent: AgentIdentity, seq: u64) -> Self {
318 Self {
319 class,
320 session_uid: session_uid.into(),
321 ai_agent,
322 seq,
323 activity_id: 1,
324 severity_id: 1,
325 status: StatusId::Success,
326 stop_reason: None,
327 native_stop_reason: None,
328 payload: serde_json::Value::Null,
329 metrics: None,
330 inventory: None,
331 prev_inventory: None,
332 }
333 }
334
335 pub fn severity(mut self, id: u8) -> Self {
337 self.severity_id = id;
338 self
339 }
340
341 pub fn status(mut self, s: StatusId) -> Self {
343 self.status = s;
344 self
345 }
346
347 pub fn stop_reason(mut self, r: crate::StopReason) -> Self {
355 self.stop_reason = Some(r);
356 self.native_stop_reason = None;
357 self
358 }
359
360 pub fn stop_reason_native(mut self, reason: crate::StopReason, native: impl Into<String>) -> Self {
362 self.stop_reason = Some(reason);
363 self.native_stop_reason = Some(native.into());
364 self
365 }
366
367 pub fn payload(mut self, p: serde_json::Value) -> Self {
369 self.payload = p;
370 self
371 }
372
373 pub fn metrics(mut self, m: EventMetrics) -> Self {
375 self.metrics = Some(m);
376 self
377 }
378
379 pub fn inventory(mut self, current: Fingerprint, previous: Option<Fingerprint>) -> Self {
381 self.inventory = Some(current);
382 self.prev_inventory = previous;
383 self
384 }
385
386 pub fn build(self) -> Result<OcsfEvent, av_core::CoreError> {
388 check_jcs_safe(self.seq)?;
389 if let Some(m) = &self.metrics {
390 for v in [
391 m.prompt_tokens,
392 m.completion_tokens,
393 m.cached_tokens,
394 m.pruned_tokens,
395 m.pruning_ratio_millis,
396 ]
397 .into_iter()
398 .flatten()
399 {
400 check_jcs_safe(v)?;
401 }
402 if let Some(ratio) = m.pruning_ratio_millis {
410 if ratio > 1000 {
411 return Err(av_core::CoreError::UnsafeInteger(ratio));
417 }
418 }
419 }
420 if let Some(ttl) = self.ai_agent.ttl_remaining_s {
425 check_jcs_safe(ttl)?;
426 }
427 if !(1..=6).contains(&self.severity_id) {
436 return Err(av_core::CoreError::UnsafeInteger(u64::from(self.severity_id)));
437 }
438 let now = av_core::time::now_ms();
439 check_jcs_safe(now)?;
440 let class_uid = self.class.class_uid();
441 Ok(OcsfEvent {
442 metadata: Metadata {
443 version: OCSF_VERSION.to_owned(),
444 uid: av_core::new_event_uid(),
445 product: Product {
446 name: PRODUCT_NAME.to_owned(),
447 vendor_name: "AgentVisor AI".to_owned(),
448 version: env!("CARGO_PKG_VERSION").to_owned(),
449 },
450 sequence: self.seq,
451 },
452 class_name: self.class,
453 class_uid,
454 category_uid: CATEGORY_UID,
455 activity_id: self.activity_id,
456 type_uid: u64::from(class_uid) * 100 + u64::from(self.activity_id),
457 time: now,
458 time_iso: av_core::time::iso8601_ms(now),
459 severity_id: self.severity_id,
460 status_id: self.status.id(),
461 session_uid: self.session_uid,
462 ai_agent: self.ai_agent,
463 stop_reason_id: self.stop_reason.map(crate::StopReason::id),
464 stop_reason: self
465 .native_stop_reason
466 .or_else(|| self.stop_reason.map(|reason| reason.caption().to_owned())),
467 payload: self.payload,
468 metrics: self.metrics,
469 inventory: self.inventory,
470 prev_inventory: self.prev_inventory,
471 unmapped: serde_json::Map::new(),
472 })
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 #![allow(
479 clippy::unwrap_used,
480 clippy::expect_used,
481 clippy::panic,
482 clippy::indexing_slicing
483 )]
484
485 use super::*;
486 use crate::StopReason;
487
488 fn identity() -> AgentIdentity {
489 AgentIdentity {
490 version: "2.1.0".into(),
491 charter: "billing-support".into(),
492 instance_uid: "agent-inst-42".into(),
493 ttl_remaining_s: Some(731),
494 }
495 }
496
497 #[test]
502 fn stop_reason_rebind_clears_stale_native_text() {
503 let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
504 .stop_reason_native(StopReason::Stop, "stop")
505 .stop_reason(StopReason::PolicyBlocked)
506 .build()
507 .unwrap();
508 assert_eq!(ev.stop_reason_id, Some(StopReason::PolicyBlocked.id()));
509 assert_eq!(
510 ev.stop_reason.as_deref(),
511 Some(StopReason::PolicyBlocked.caption()),
512 "caption must re-derive from the rebound reason, not the stale native text"
513 );
514 }
515
516 #[test]
517 fn build_binds_config_state() {
518 let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
519 .stop_reason(StopReason::LoopDetected)
520 .status(StatusId::Failure)
521 .build()
522 .unwrap();
523 assert_eq!(ev.ai_agent.version, "2.1.0");
524 assert_eq!(ev.ai_agent.charter.name, "billing-support");
525 assert_eq!(ev.ai_agent.instance_uid, "agent-inst-42");
526 assert_eq!(ev.stop_reason_id, Some(91));
527 assert_eq!(ev.stop_reason.as_deref(), Some("Loop Detected"));
528 assert_eq!(ev.class_uid, 9902);
529 assert_eq!(ev.type_uid, 990_201);
530 assert_eq!(ev.metadata.sequence, 7);
531 assert_eq!(ev.category_uid, CATEGORY_UID);
532 assert_eq!(ev.metadata.product.name, PRODUCT_NAME);
533 assert_eq!(ev.metadata.version, OCSF_VERSION);
534 }
535
536 #[test]
542 fn build_refuses_pruning_ratio_millis_above_1000() {
543 let outcome = OcsfEventBuilder::new(EventClass::ToolCall, "sess-x", identity(), 1)
544 .metrics(EventMetrics {
545 prompt_tokens: Some(10),
546 completion_tokens: Some(10),
547 cached_tokens: Some(0),
548 pruned_tokens: Some(5),
549 pruning_ratio_millis: Some(5000),
550 })
551 .build();
552 assert!(outcome.is_err(), "over-cap ratio must fail build");
553 let outcome = OcsfEventBuilder::new(EventClass::ToolCall, "sess-x", identity(), 1)
555 .metrics(EventMetrics {
556 pruning_ratio_millis: Some(1000),
557 ..EventMetrics::default()
558 })
559 .build();
560 assert!(outcome.is_ok(), "boundary ratio 1000 must still build");
561 }
562
563 #[test]
564 fn serialized_shape_has_required_fields() {
565 let ev = OcsfEventBuilder::new(EventClass::ToolCall, "sess-2", identity(), 1)
566 .payload(serde_json::json!({"tool": "db_write", "allowed": false}))
567 .status(StatusId::Failure)
568 .build()
569 .unwrap();
570 let v = serde_json::to_value(&ev).unwrap();
571 for key in [
572 "metadata",
573 "class_name",
574 "class_uid",
575 "type_uid",
576 "time",
577 "time_iso",
578 "severity_id",
579 "status_id",
580 "session_uid",
581 "ai_agent",
582 "payload",
583 ] {
584 assert!(v.get(key).is_some(), "missing {key}: {v}");
585 }
586 assert_eq!(v["class_name"], "agent.tool_call");
587 assert_eq!(v["ai_agent"]["instance_uid"], "agent-inst-42");
588 assert!(v.get("stop_reason_id").is_none());
590 }
591
592 #[test]
593 fn unsafe_counter_rejected() {
594 let m = EventMetrics {
595 prompt_tokens: Some((1 << 53) + 1),
596 ..Default::default()
597 };
598 let err = OcsfEventBuilder::new(EventClass::Compression, "s", identity(), 1)
599 .metrics(m)
600 .build();
601 assert!(err.is_err(), "2^53+1 must be rejected for JCS safety");
602 }
603
604 #[test]
605 fn unknown_inbound_fields_preserved() {
606 let ev = OcsfEventBuilder::new(EventClass::Session, "s", identity(), 1)
607 .build()
608 .unwrap();
609 let mut v = serde_json::to_value(&ev).unwrap();
610 v["future_field_from_v2"] = serde_json::json!({"x": 1});
611 let parsed: OcsfEvent = serde_json::from_value(v).unwrap();
612 assert!(
613 parsed.unmapped.contains_key("future_field_from_v2"),
614 "unknown fields must be captured: {:?}",
615 parsed.unmapped
616 );
617 }
618
619 #[test]
620 fn roundtrip_preserves_equality() {
621 let ev = OcsfEventBuilder::new(EventClass::Identity, "sess-9", identity(), 3)
622 .metrics(EventMetrics {
623 prompt_tokens: Some(120),
624 completion_tokens: Some(30),
625 cached_tokens: Some(64),
626 ..Default::default()
627 })
628 .build()
629 .unwrap();
630 let json = serde_json::to_string(&ev).unwrap();
631 let back: OcsfEvent = serde_json::from_str(&json).unwrap();
632 assert_eq!(ev, back);
633 }
634
635 #[test]
636 fn all_classes_have_unique_uids_and_topics() {
637 let classes = EventClass::all();
638 assert!(
640 classes.len() >= 6,
641 "EventClass::all shrank unexpectedly: {}",
642 classes.len()
643 );
644 let mut uids: Vec<u32> = classes.iter().map(|c| c.class_uid()).collect();
645 let mut topics: Vec<&str> = classes.iter().map(|c| c.topic()).collect();
646 uids.sort_unstable();
647 uids.dedup();
648 topics.sort_unstable();
649 topics.dedup();
650 assert_eq!(uids.len(), classes.len());
651 assert_eq!(topics.len(), classes.len());
652 }
653
654 #[test]
655 fn status_id_wire_values_are_ocsf_conformant() {
656 assert_eq!(StatusId::Unknown.id(), 0);
659 assert_eq!(StatusId::Success.id(), 1);
660 assert_eq!(StatusId::Failure.id(), 2);
661 }
662}