Skip to main content

agent_base/types/
events.rs

1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4use super::approval::ApprovalRequest;
5use super::checkpoint::CheckpointData;
6use super::plan_update::PlanItem;
7use super::session::SessionId;
8
9// ---------------------------------------------------------------------------
10// UserEvent — user-space events produced by tools
11// ---------------------------------------------------------------------------
12
13/// User-space events produced by tools during execution.
14///
15/// Tools send these through `ToolContext::emit_user_event()`. The framework
16/// wraps them in [`RuntimeEvent::UserEvent`] before delivering to external
17/// consumers.
18#[derive(Clone, Debug, Serialize, Deserialize)]
19#[serde(tag = "userEventType", rename_all = "camelCase")]
20pub enum UserEvent {
21    /// Tool progress notification.
22    Progress { text: String },
23    /// User-defined structured event for custom business semantics.
24    Structured { event_type: String, data: Value },
25    /// Tool partial result — emitted during long-running tool execution.
26    /// `is_partial: true` means more output is coming; `false` means this is the final chunk.
27    ToolPartialResult {
28        tool_call_id: String,
29        content: String,
30        is_partial: bool,
31    },
32}
33
34// ---------------------------------------------------------------------------
35// RuntimeEvent — unified event stream for all consumers
36// ---------------------------------------------------------------------------
37
38/// Unified runtime event — the single event type for both internal and external
39/// consumers (frontends, CLIs, tests).
40#[derive(Clone, Debug, Serialize, Deserialize)]
41#[serde(tag = "runtimeEventType", rename_all = "camelCase")]
42pub enum RuntimeEvent {
43    // --- Framework system events ---
44    TextDelta {
45        session_id: SessionId,
46        text: String,
47        /// Identifies the agent that produced this event (root / sub-agent path).
48        #[serde(default, skip_serializing_if = "Option::is_none")]
49        agent_id: Option<String>,
50        /// Distributed tracing context carried across systems (e.g. MCP caller → phi-agent).
51        #[serde(default, skip_serializing_if = "Option::is_none")]
52        trace_id: Option<String>,
53    },
54    ThoughtDelta {
55        session_id: SessionId,
56        text: String,
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        agent_id: Option<String>,
59        #[serde(default, skip_serializing_if = "Option::is_none")]
60        trace_id: Option<String>,
61    },
62    ToolCallStarted {
63        session_id: SessionId,
64        tool_name: String,
65        args_json: String,
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        agent_id: Option<String>,
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        trace_id: Option<String>,
70    },
71    ToolCallFinished {
72        session_id: SessionId,
73        tool_name: String,
74        summary: String,
75        #[serde(default, skip_serializing_if = "Option::is_none")]
76        agent_id: Option<String>,
77        #[serde(default, skip_serializing_if = "Option::is_none")]
78        trace_id: Option<String>,
79        /// `true` when this finish is the result of an approval denial (not a
80        /// normal tool result or execution error).
81        #[serde(default)]
82        denied: bool,
83        /// Structured metadata from the tool result (e.g. edit line numbers).
84        /// Carried through to UI consumers; never sent to the LLM.
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        details: Option<Value>,
87    },
88    AwaitingApproval {
89        session_id: SessionId,
90        request: ApprovalRequest,
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        agent_id: Option<String>,
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        trace_id: Option<String>,
95    },
96    Checkpoint {
97        session_id: SessionId,
98        checkpoint: CheckpointData,
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        agent_id: Option<String>,
101        #[serde(default, skip_serializing_if = "Option::is_none")]
102        trace_id: Option<String>,
103    },
104    RunFinished {
105        session_id: SessionId,
106        #[serde(default, skip_serializing_if = "Option::is_none")]
107        agent_id: Option<String>,
108        #[serde(default, skip_serializing_if = "Option::is_none")]
109        trace_id: Option<String>,
110    },
111    RunCancelled {
112        session_id: SessionId,
113        #[serde(default, skip_serializing_if = "Option::is_none")]
114        agent_id: Option<String>,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        trace_id: Option<String>,
117    },
118    // --- Lightweight plan update (display-only, no execution semantics) ---
119    PlanUpdated {
120        session_id: SessionId,
121        objective: String,
122        explanation: Option<String>,
123        plan: Vec<PlanItem>,
124        #[serde(default, skip_serializing_if = "Option::is_none")]
125        agent_id: Option<String>,
126        #[serde(default, skip_serializing_if = "Option::is_none")]
127        trace_id: Option<String>,
128    },
129    // --- User-space events ---
130    /// A user-space event produced by a tool.
131    UserEvent {
132        session_id: SessionId,
133        event: UserEvent,
134        #[serde(default, skip_serializing_if = "Option::is_none")]
135        agent_id: Option<String>,
136        #[serde(default, skip_serializing_if = "Option::is_none")]
137        trace_id: Option<String>,
138    },
139}
140
141impl RuntimeEvent {
142    /// Get the session ID associated with this event.
143    pub fn session_id(&self) -> &SessionId {
144        match self {
145            RuntimeEvent::TextDelta { session_id, .. } => session_id,
146            RuntimeEvent::ThoughtDelta { session_id, .. } => session_id,
147            RuntimeEvent::ToolCallStarted { session_id, .. } => session_id,
148            RuntimeEvent::ToolCallFinished { session_id, .. } => session_id,
149            RuntimeEvent::AwaitingApproval { session_id, .. } => session_id,
150            RuntimeEvent::Checkpoint { session_id, .. } => session_id,
151            RuntimeEvent::RunFinished { session_id, .. } => session_id,
152            RuntimeEvent::RunCancelled { session_id, .. } => session_id,
153            RuntimeEvent::PlanUpdated { session_id, .. } => session_id,
154            RuntimeEvent::UserEvent { session_id, .. } => session_id,
155        }
156    }
157
158    /// Get the agent ID associated with this event, if any.
159    pub fn agent_id(&self) -> Option<&str> {
160        match self {
161            RuntimeEvent::TextDelta { agent_id, .. } => agent_id.as_deref(),
162            RuntimeEvent::ThoughtDelta { agent_id, .. } => agent_id.as_deref(),
163            RuntimeEvent::ToolCallStarted { agent_id, .. } => agent_id.as_deref(),
164            RuntimeEvent::ToolCallFinished { agent_id, .. } => agent_id.as_deref(),
165            RuntimeEvent::AwaitingApproval { agent_id, .. } => agent_id.as_deref(),
166            RuntimeEvent::Checkpoint { agent_id, .. } => agent_id.as_deref(),
167            RuntimeEvent::RunFinished { agent_id, .. } => agent_id.as_deref(),
168            RuntimeEvent::RunCancelled { agent_id, .. } => agent_id.as_deref(),
169            RuntimeEvent::PlanUpdated { agent_id, .. } => agent_id.as_deref(),
170            RuntimeEvent::UserEvent { agent_id, .. } => agent_id.as_deref(),
171        }
172    }
173
174    /// Get the trace ID associated with this event, if any.
175    pub fn trace_id(&self) -> Option<&str> {
176        match self {
177            RuntimeEvent::TextDelta { trace_id, .. } => trace_id.as_deref(),
178            RuntimeEvent::ThoughtDelta { trace_id, .. } => trace_id.as_deref(),
179            RuntimeEvent::ToolCallStarted { trace_id, .. } => trace_id.as_deref(),
180            RuntimeEvent::ToolCallFinished { trace_id, .. } => trace_id.as_deref(),
181            RuntimeEvent::AwaitingApproval { trace_id, .. } => trace_id.as_deref(),
182            RuntimeEvent::Checkpoint { trace_id, .. } => trace_id.as_deref(),
183            RuntimeEvent::RunFinished { trace_id, .. } => trace_id.as_deref(),
184            RuntimeEvent::RunCancelled { trace_id, .. } => trace_id.as_deref(),
185            RuntimeEvent::PlanUpdated { trace_id, .. } => trace_id.as_deref(),
186            RuntimeEvent::UserEvent { trace_id, .. } => trace_id.as_deref(),
187        }
188    }
189
190    /// Set the agent ID (sub-agent path) on this event.
191    pub fn with_agent_id(mut self, id: impl Into<String>) -> Self {
192        let id = id.into();
193        match &mut self {
194            RuntimeEvent::TextDelta { agent_id, .. }
195            | RuntimeEvent::ThoughtDelta { agent_id, .. }
196            | RuntimeEvent::ToolCallStarted { agent_id, .. }
197            | RuntimeEvent::ToolCallFinished { agent_id, .. }
198            | RuntimeEvent::AwaitingApproval { agent_id, .. }
199            | RuntimeEvent::Checkpoint { agent_id, .. }
200            | RuntimeEvent::RunFinished { agent_id, .. }
201            | RuntimeEvent::RunCancelled { agent_id, .. }
202            | RuntimeEvent::PlanUpdated { agent_id, .. }
203            | RuntimeEvent::UserEvent { agent_id, .. } => *agent_id = Some(id),
204        }
205        self
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::types::{CheckpointStep, PlanStepStatus, RiskLevel};
213
214    fn sid(id: u64) -> SessionId {
215        SessionId::new(id)
216    }
217
218    fn approval_request() -> ApprovalRequest {
219        ApprovalRequest {
220            title: "title".to_string(),
221            message: "message".to_string(),
222            action_key: None,
223            risk_level: RiskLevel::Safe,
224            raw: None,
225            source: None,
226        }
227    }
228
229    fn checkpoint() -> CheckpointData {
230        CheckpointData {
231            session_id: sid(42),
232            user_input: "input".to_string(),
233            step: CheckpointStep::AfterUserInput,
234            turn_count: 0,
235        }
236    }
237
238    #[test]
239    fn accessors_return_embedded_ids_for_all_variants() {
240        let events: Vec<RuntimeEvent> = vec![
241            RuntimeEvent::TextDelta {
242                session_id: sid(1),
243                text: "hi".into(),
244                agent_id: Some("a".into()),
245                trace_id: Some("t".into()),
246            },
247            RuntimeEvent::ThoughtDelta {
248                session_id: sid(2),
249                text: "hmm".into(),
250                agent_id: Some("a".into()),
251                trace_id: Some("t".into()),
252            },
253            RuntimeEvent::ToolCallStarted {
254                session_id: sid(3),
255                tool_name: "read".into(),
256                args_json: "{}".into(),
257                agent_id: Some("a".into()),
258                trace_id: Some("t".into()),
259            },
260            RuntimeEvent::ToolCallFinished {
261                session_id: sid(4),
262                tool_name: "read".into(),
263                summary: "ok".into(),
264                agent_id: Some("a".into()),
265                trace_id: Some("t".into()),
266                denied: false,
267                details: None,
268            },
269            RuntimeEvent::AwaitingApproval {
270                session_id: sid(5),
271                request: approval_request(),
272                agent_id: Some("a".into()),
273                trace_id: Some("t".into()),
274            },
275            RuntimeEvent::Checkpoint {
276                session_id: sid(6),
277                checkpoint: checkpoint(),
278                agent_id: Some("a".into()),
279                trace_id: Some("t".into()),
280            },
281            RuntimeEvent::RunFinished {
282                session_id: sid(7),
283                agent_id: Some("a".into()),
284                trace_id: Some("t".into()),
285            },
286            RuntimeEvent::RunCancelled {
287                session_id: sid(8),
288                agent_id: Some("a".into()),
289                trace_id: Some("t".into()),
290            },
291            RuntimeEvent::PlanUpdated {
292                session_id: sid(9),
293                objective: "goal".into(),
294                explanation: None,
295                plan: vec![PlanItem {
296                    step: "s".into(),
297                    status: PlanStepStatus::Pending,
298                }],
299                agent_id: Some("a".into()),
300                trace_id: Some("t".into()),
301            },
302            RuntimeEvent::UserEvent {
303                session_id: sid(10),
304                event: UserEvent::Progress { text: "p".into() },
305                agent_id: Some("a".into()),
306                trace_id: Some("t".into()),
307            },
308        ];
309
310        for (i, ev) in events.iter().enumerate() {
311            let expected = i as u64 + 1;
312            assert_eq!(ev.session_id(), &sid(expected), "variant {i}");
313            assert_eq!(ev.agent_id(), Some("a"), "variant {i}");
314            assert_eq!(ev.trace_id(), Some("t"), "variant {i}");
315        }
316    }
317
318    #[test]
319    fn with_agent_id_sets_id_on_all_variants() {
320        let events: Vec<RuntimeEvent> = vec![
321            RuntimeEvent::TextDelta {
322                session_id: sid(1),
323                text: "hi".into(),
324                agent_id: None,
325                trace_id: None,
326            },
327            RuntimeEvent::ThoughtDelta {
328                session_id: sid(2),
329                text: "hmm".into(),
330                agent_id: None,
331                trace_id: None,
332            },
333            RuntimeEvent::ToolCallStarted {
334                session_id: sid(3),
335                tool_name: "read".into(),
336                args_json: "{}".into(),
337                agent_id: None,
338                trace_id: None,
339            },
340            RuntimeEvent::ToolCallFinished {
341                session_id: sid(4),
342                tool_name: "read".into(),
343                summary: "ok".into(),
344                agent_id: None,
345                trace_id: None,
346                denied: false,
347                details: None,
348            },
349            RuntimeEvent::AwaitingApproval {
350                session_id: sid(5),
351                request: approval_request(),
352                agent_id: None,
353                trace_id: None,
354            },
355            RuntimeEvent::Checkpoint {
356                session_id: sid(6),
357                checkpoint: checkpoint(),
358                agent_id: None,
359                trace_id: None,
360            },
361            RuntimeEvent::RunFinished {
362                session_id: sid(7),
363                agent_id: None,
364                trace_id: None,
365            },
366            RuntimeEvent::RunCancelled {
367                session_id: sid(8),
368                agent_id: None,
369                trace_id: None,
370            },
371            RuntimeEvent::PlanUpdated {
372                session_id: sid(9),
373                objective: "goal".into(),
374                explanation: None,
375                plan: vec![PlanItem {
376                    step: "s".into(),
377                    status: PlanStepStatus::Pending,
378                }],
379                agent_id: None,
380                trace_id: None,
381            },
382            RuntimeEvent::UserEvent {
383                session_id: sid(10),
384                event: UserEvent::Progress { text: "p".into() },
385                agent_id: None,
386                trace_id: None,
387            },
388        ];
389
390        for (i, ev) in events.into_iter().enumerate() {
391            let tagged = ev.with_agent_id("sub/1");
392            assert_eq!(tagged.agent_id(), Some("sub/1"), "variant {i}");
393            assert_eq!(tagged.session_id(), &sid(i as u64 + 1), "variant {i}");
394        }
395    }
396
397    #[test]
398    fn accessors_return_none_when_ids_absent() {
399        let ev = RuntimeEvent::RunFinished {
400            session_id: sid(1),
401            agent_id: None,
402            trace_id: None,
403        };
404        assert_eq!(ev.session_id(), &sid(1));
405        assert_eq!(ev.agent_id(), None);
406        assert_eq!(ev.trace_id(), None);
407    }
408
409    #[test]
410    fn runtime_event_serde_uses_camel_case_tag() {
411        let ev = RuntimeEvent::ToolCallStarted {
412            session_id: SessionId::with_external_id(7, "ext"),
413            tool_name: "read".into(),
414            args_json: "{}".into(),
415            agent_id: Some("a".into()),
416            trace_id: None,
417        };
418        let v = serde_json::to_value(&ev).unwrap();
419        assert_eq!(v["runtimeEventType"], "toolCallStarted");
420        assert_eq!(v["session_id"]["id"], serde_json::json!(7));
421        assert_eq!(v["session_id"]["external_id"], "ext");
422        // trace_id is None → field skipped
423        assert!(v.get("trace_id").is_none());
424
425        let back: RuntimeEvent = serde_json::from_value(v).unwrap();
426        assert_eq!(back.session_id(), &SessionId::with_external_id(7, "ext"));
427        assert_eq!(back.agent_id(), Some("a"));
428        assert_eq!(back.trace_id(), None);
429    }
430
431    #[test]
432    fn user_event_serde_tag() {
433        let v = serde_json::to_value(UserEvent::Progress {
434            text: "working".into(),
435        })
436        .unwrap();
437        assert_eq!(v["userEventType"], "progress");
438        assert_eq!(v["text"], "working");
439
440        let v = serde_json::to_value(UserEvent::Structured {
441            event_type: "custom".into(),
442            data: serde_json::json!({"k": 1}),
443        })
444        .unwrap();
445        assert_eq!(v["userEventType"], "structured");
446        assert_eq!(v["data"]["k"], serde_json::json!(1));
447    }
448}