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)]
243pub struct OcsfEvent {
244 pub metadata: Metadata,
246 pub class_name: EventClass,
248 pub class_uid: u32,
250 pub category_uid: u8,
252 pub activity_id: u8,
254 pub type_uid: u64,
256 pub time: u64,
258 pub time_iso: String,
260 pub severity_id: u8,
262 pub status_id: u8,
264 pub session_uid: String,
266 pub ai_agent: AgentIdentity,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub stop_reason_id: Option<u8>,
271 #[serde(skip_serializing_if = "Option::is_none")]
274 pub stop_reason: Option<String>,
275 pub payload: serde_json::Value,
277 #[serde(skip_serializing_if = "Option::is_none")]
279 pub metrics: Option<EventMetrics>,
280 #[serde(skip_serializing_if = "Option::is_none")]
282 pub inventory: Option<Fingerprint>,
283 #[serde(skip_serializing_if = "Option::is_none")]
285 pub prev_inventory: Option<Fingerprint>,
286 #[serde(default, skip_serializing_if = "serde_json::Map::is_empty", flatten)]
288 pub unmapped: serde_json::Map<String, serde_json::Value>,
289}
290
291#[derive(Debug)]
293pub struct OcsfEventBuilder {
294 class: EventClass,
295 session_uid: String,
296 ai_agent: AgentIdentity,
297 seq: u64,
298 activity_id: u8,
299 severity_id: u8,
300 status: StatusId,
301 stop_reason: Option<crate::StopReason>,
302 native_stop_reason: Option<String>,
303 payload: serde_json::Value,
304 metrics: Option<EventMetrics>,
305 inventory: Option<Fingerprint>,
306 prev_inventory: Option<Fingerprint>,
307}
308
309impl OcsfEventBuilder {
310 pub fn new(class: EventClass, session_uid: impl Into<String>, ai_agent: AgentIdentity, seq: u64) -> Self {
312 Self {
313 class,
314 session_uid: session_uid.into(),
315 ai_agent,
316 seq,
317 activity_id: 1,
318 severity_id: 1,
319 status: StatusId::Success,
320 stop_reason: None,
321 native_stop_reason: None,
322 payload: serde_json::Value::Null,
323 metrics: None,
324 inventory: None,
325 prev_inventory: None,
326 }
327 }
328
329 pub fn severity(mut self, id: u8) -> Self {
331 self.severity_id = id;
332 self
333 }
334
335 pub fn status(mut self, s: StatusId) -> Self {
337 self.status = s;
338 self
339 }
340
341 pub fn stop_reason(mut self, r: crate::StopReason) -> Self {
343 self.stop_reason = Some(r);
344 self
345 }
346
347 pub fn stop_reason_native(mut self, reason: crate::StopReason, native: impl Into<String>) -> Self {
349 self.stop_reason = Some(reason);
350 self.native_stop_reason = Some(native.into());
351 self
352 }
353
354 pub fn payload(mut self, p: serde_json::Value) -> Self {
356 self.payload = p;
357 self
358 }
359
360 pub fn metrics(mut self, m: EventMetrics) -> Self {
362 self.metrics = Some(m);
363 self
364 }
365
366 pub fn inventory(mut self, current: Fingerprint, previous: Option<Fingerprint>) -> Self {
368 self.inventory = Some(current);
369 self.prev_inventory = previous;
370 self
371 }
372
373 pub fn build(self) -> Result<OcsfEvent, av_core::CoreError> {
375 check_jcs_safe(self.seq)?;
376 if let Some(m) = &self.metrics {
377 for v in [
378 m.prompt_tokens,
379 m.completion_tokens,
380 m.cached_tokens,
381 m.pruned_tokens,
382 m.pruning_ratio_millis,
383 ]
384 .into_iter()
385 .flatten()
386 {
387 check_jcs_safe(v)?;
388 }
389 }
390 let now = av_core::time::now_ms();
391 check_jcs_safe(now)?;
392 let class_uid = self.class.class_uid();
393 Ok(OcsfEvent {
394 metadata: Metadata {
395 version: OCSF_VERSION.to_owned(),
396 uid: av_core::new_event_uid(),
397 product: Product {
398 name: PRODUCT_NAME.to_owned(),
399 vendor_name: "AgentVisor AI".to_owned(),
400 version: env!("CARGO_PKG_VERSION").to_owned(),
401 },
402 sequence: self.seq,
403 },
404 class_name: self.class,
405 class_uid,
406 category_uid: CATEGORY_UID,
407 activity_id: self.activity_id,
408 type_uid: u64::from(class_uid) * 100 + u64::from(self.activity_id),
409 time: now,
410 time_iso: av_core::time::iso8601_ms(now),
411 severity_id: self.severity_id,
412 status_id: self.status.id(),
413 session_uid: self.session_uid,
414 ai_agent: self.ai_agent,
415 stop_reason_id: self.stop_reason.map(crate::StopReason::id),
416 stop_reason: self
417 .native_stop_reason
418 .or_else(|| self.stop_reason.map(|reason| reason.caption().to_owned())),
419 payload: self.payload,
420 metrics: self.metrics,
421 inventory: self.inventory,
422 prev_inventory: self.prev_inventory,
423 unmapped: serde_json::Map::new(),
424 })
425 }
426}
427
428#[cfg(test)]
429mod tests {
430 #![allow(
431 clippy::unwrap_used,
432 clippy::expect_used,
433 clippy::panic,
434 clippy::indexing_slicing
435 )]
436
437 use super::*;
438 use crate::StopReason;
439
440 fn identity() -> AgentIdentity {
441 AgentIdentity {
442 version: "2.1.0".into(),
443 charter: "billing-support".into(),
444 instance_uid: "agent-inst-42".into(),
445 ttl_remaining_s: Some(731),
446 }
447 }
448
449 #[test]
450 fn build_binds_config_state() {
451 let ev = OcsfEventBuilder::new(EventClass::StopReason, "sess-1", identity(), 7)
452 .stop_reason(StopReason::LoopDetected)
453 .status(StatusId::Failure)
454 .build()
455 .unwrap();
456 assert_eq!(ev.ai_agent.version, "2.1.0");
457 assert_eq!(ev.ai_agent.charter.name, "billing-support");
458 assert_eq!(ev.ai_agent.instance_uid, "agent-inst-42");
459 assert_eq!(ev.stop_reason_id, Some(91));
460 assert_eq!(ev.stop_reason.as_deref(), Some("Loop Detected"));
461 assert_eq!(ev.class_uid, 9902);
462 assert_eq!(ev.type_uid, 990_201);
463 assert_eq!(ev.metadata.sequence, 7);
464 assert_eq!(ev.category_uid, CATEGORY_UID);
465 assert_eq!(ev.metadata.product.name, PRODUCT_NAME);
466 assert_eq!(ev.metadata.version, OCSF_VERSION);
467 }
468
469 #[test]
470 fn serialized_shape_has_required_fields() {
471 let ev = OcsfEventBuilder::new(EventClass::ToolCall, "sess-2", identity(), 1)
472 .payload(serde_json::json!({"tool": "db_write", "allowed": false}))
473 .status(StatusId::Failure)
474 .build()
475 .unwrap();
476 let v = serde_json::to_value(&ev).unwrap();
477 for key in [
478 "metadata",
479 "class_name",
480 "class_uid",
481 "type_uid",
482 "time",
483 "time_iso",
484 "severity_id",
485 "status_id",
486 "session_uid",
487 "ai_agent",
488 "payload",
489 ] {
490 assert!(v.get(key).is_some(), "missing {key}: {v}");
491 }
492 assert_eq!(v["class_name"], "agent.tool_call");
493 assert_eq!(v["ai_agent"]["instance_uid"], "agent-inst-42");
494 assert!(v.get("stop_reason_id").is_none());
496 }
497
498 #[test]
499 fn unsafe_counter_rejected() {
500 let m = EventMetrics {
501 prompt_tokens: Some((1 << 53) + 1),
502 ..Default::default()
503 };
504 let err = OcsfEventBuilder::new(EventClass::Compression, "s", identity(), 1)
505 .metrics(m)
506 .build();
507 assert!(err.is_err(), "2^53+1 must be rejected for JCS safety");
508 }
509
510 #[test]
511 fn unknown_inbound_fields_preserved() {
512 let ev = OcsfEventBuilder::new(EventClass::Session, "s", identity(), 1)
513 .build()
514 .unwrap();
515 let mut v = serde_json::to_value(&ev).unwrap();
516 v["future_field_from_v2"] = serde_json::json!({"x": 1});
517 let parsed: OcsfEvent = serde_json::from_value(v).unwrap();
518 assert!(
519 parsed.unmapped.contains_key("future_field_from_v2"),
520 "unknown fields must be captured: {:?}",
521 parsed.unmapped
522 );
523 }
524
525 #[test]
526 fn roundtrip_preserves_equality() {
527 let ev = OcsfEventBuilder::new(EventClass::Identity, "sess-9", identity(), 3)
528 .metrics(EventMetrics {
529 prompt_tokens: Some(120),
530 completion_tokens: Some(30),
531 cached_tokens: Some(64),
532 ..Default::default()
533 })
534 .build()
535 .unwrap();
536 let json = serde_json::to_string(&ev).unwrap();
537 let back: OcsfEvent = serde_json::from_str(&json).unwrap();
538 assert_eq!(ev, back);
539 }
540
541 #[test]
542 fn all_classes_have_unique_uids_and_topics() {
543 let classes = EventClass::all();
544 assert!(
546 classes.len() >= 6,
547 "EventClass::all shrank unexpectedly: {}",
548 classes.len()
549 );
550 let mut uids: Vec<u32> = classes.iter().map(|c| c.class_uid()).collect();
551 let mut topics: Vec<&str> = classes.iter().map(|c| c.topic()).collect();
552 uids.sort_unstable();
553 uids.dedup();
554 topics.sort_unstable();
555 topics.dedup();
556 assert_eq!(uids.len(), classes.len());
557 assert_eq!(topics.len(), classes.len());
558 }
559
560 #[test]
561 fn status_id_wire_values_are_ocsf_conformant() {
562 assert_eq!(StatusId::Unknown.id(), 0);
565 assert_eq!(StatusId::Success.id(), 1);
566 assert_eq!(StatusId::Failure.id(), 2);
567 }
568}