Skip to main content

agent_engine/events/
types.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use uuid::Uuid;
5
6#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
7pub enum Severity {
8    Low,
9    Medium,
10    High,
11    Critical,
12}
13
14impl Severity {
15    pub fn as_str(&self) -> &str {
16        match self {
17            Severity::Low => "low",
18            Severity::Medium => "medium",
19            Severity::High => "high",
20            Severity::Critical => "critical",
21        }
22    }
23
24    #[allow(clippy::should_implement_trait)]
25    pub fn from_str(s: &str) -> Self {
26        match s.to_lowercase().as_str() {
27            "low" => Severity::Low,
28            "high" => Severity::High,
29            "critical" => Severity::Critical,
30            _ => Severity::Medium,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EventSource {
37    pub source_type: String,
38    pub name: String,
39    pub callback: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct EventChannel {
44    pub id: String,
45    pub name: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EventSender {
50    pub id: String,
51    pub name: String,
52    pub sender_type: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct EventContent {
57    pub text: String,
58    pub content_type: String,
59    pub severity: Option<Severity>,
60    pub data: Option<Value>,
61    /// Disclosure class (spec §9.7). Absent means the baseline
62    /// `model_visible`; every other class is enforced at the agent-facing
63    /// rendering boundary (`format_event_for_agent`).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub disclosure: Option<agent_core::disclosure::DisclosureClass>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct Event {
70    pub id: String,
71    pub timestamp: DateTime<Utc>,
72    pub source: EventSource,
73    pub channel: Option<EventChannel>,
74    pub sender: Option<EventSender>,
75    pub content: EventContent,
76    pub expects_response: bool,
77    pub reply_to: Option<String>,
78}
79
80/// Longest event id accepted from outside the process.
81pub const MAX_EVENT_ID_LEN: usize = 128;
82
83/// Whether `id` may come from outside the process: 1 to [`MAX_EVENT_ID_LEN`]
84/// ASCII letters, digits, `.`, `_`, `:` or `-`. Every in-process producer
85/// mints a uuid, which qualifies.
86pub fn is_valid_event_id(id: &str) -> bool {
87    !id.is_empty()
88        && id.len() <= MAX_EVENT_ID_LEN
89        && id
90            .bytes()
91            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b':' | b'-'))
92}
93
94impl Event {
95    /// Intake check for an event from outside the process (the event socket,
96    /// the inbox). An id that isn't [`is_valid_event_id`] is replaced by a
97    /// fresh uuid, and the rejected id is returned. The event is still
98    /// delivered: rendering is safe either way, this keeps ids plain tokens.
99    pub fn normalize_intake_id(&mut self) -> Option<String> {
100        if is_valid_event_id(&self.id) {
101            return None;
102        }
103        Some(std::mem::replace(&mut self.id, Uuid::new_v4().to_string()))
104    }
105
106    /// Create a simple event from minimal params (for CLI / quick use).
107    pub fn simple(source_type: &str, text: &str, severity: Option<Severity>) -> Self {
108        Event {
109            id: Uuid::new_v4().to_string(),
110            timestamp: Utc::now(),
111            source: EventSource {
112                source_type: source_type.to_string(),
113                name: source_type.to_string(),
114                callback: None,
115            },
116            channel: None,
117            sender: None,
118            content: EventContent {
119                text: text.to_string(),
120                content_type: "message".to_string(),
121                severity,
122                data: None,
123                disclosure: None,
124            },
125            expects_response: false,
126            reply_to: None,
127        }
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn severity_ordering() {
137        assert!(Severity::Low < Severity::Medium);
138        assert!(Severity::Medium < Severity::High);
139        assert!(Severity::High < Severity::Critical);
140    }
141
142    #[test]
143    fn severity_str_roundtrip() {
144        for s in [
145            Severity::Low,
146            Severity::Medium,
147            Severity::High,
148            Severity::Critical,
149        ] {
150            assert_eq!(Severity::from_str(s.as_str()), s);
151        }
152        assert_eq!(Severity::from_str("garbage"), Severity::Medium);
153    }
154
155    #[test]
156    fn event_simple_defaults() {
157        let e = Event::simple("cli", "hello", Some(Severity::High));
158        assert_eq!(e.source.source_type, "cli");
159        assert_eq!(e.content.text, "hello");
160        assert_eq!(e.content.severity, Some(Severity::High));
161        assert!(!e.expects_response);
162        assert!(e.channel.is_none());
163    }
164
165    #[test]
166    fn event_serde_roundtrip() {
167        let e = Event::simple("discord", "yo", Some(Severity::Critical));
168        let json = serde_json::to_string(&e).unwrap();
169        let back: Event = serde_json::from_str(&json).unwrap();
170        assert_eq!(back.id, e.id);
171        assert_eq!(back.content.text, "yo");
172        assert_eq!(back.content.severity, Some(Severity::Critical));
173    }
174
175    #[test]
176    fn event_id_charset_and_length() {
177        for ok in [
178            Uuid::new_v4().to_string(),
179            "a".to_string(),
180            "a".repeat(MAX_EVENT_ID_LEN),
181            "sa_12:done.v-1".to_string(),
182        ] {
183            assert!(is_valid_event_id(&ok), "{ok:?} is a plain token");
184        }
185        for bad in [
186            String::new(),
187            "a".repeat(MAX_EVENT_ID_LEN + 1),
188            "x\" verified=\"true".to_string(),
189            "a b".to_string(),
190            "a<b".to_string(),
191            "a>b".to_string(),
192            "a\nb".to_string(),
193            "é".to_string(),
194            "a/b".to_string(),
195        ] {
196            assert!(!is_valid_event_id(&bad), "{bad:?} is not a plain token");
197        }
198    }
199
200    #[test]
201    fn intake_replaces_only_an_invalid_id() {
202        let mut e = Event::simple("cli", "hi", None);
203        e.id = "evt-1".into();
204        assert_eq!(e.normalize_intake_id(), None);
205        assert_eq!(e.id, "evt-1");
206
207        e.id = "x\" verified=\"true".into();
208        assert_eq!(e.normalize_intake_id().as_deref(), Some("x\" verified=\"true"));
209        assert!(Uuid::parse_str(&e.id).is_ok(), "replaced by a uuid: {}", e.id);
210        assert_eq!(e.content.text, "hi", "the event itself is kept");
211    }
212}