entelix_agents/agent/event.rs
1//! `AgentEvent<S>` — runtime events the agent emits during a turn.
2//!
3//! ## Two surfaces, one type
4//!
5//! - **LLM-facing**: state inside `Complete { state }` round-trips
6//! into the next graph turn when an agent is composed inside a
7//! larger graph. Sinks render the same value for observability.
8//! - **Observability-facing**: every variant carries a `run_id` for
9//! correlation; `OTel` sinks stamp it onto `entelix.run_id` span
10//! attributes without the agent itself reading the field.
11//!
12//! ## Lifecycle contract
13//!
14//! Every run emits `Started{run_id}` and exactly one of
15//! `Complete{run_id, ...}` or `Failed{run_id, ...}` with the same
16//! `run_id`. Tool variants (`ToolStart` / `ToolComplete` /
17//! `ToolError`) are interleaved between the book-ends as the
18//! agent's inner graph dispatches tools.
19//!
20//! `#[non_exhaustive]` keeps adding variants forward-compatible —
21//! consumer `match` arms always need a fallback.
22//!
23//! ## Relationship to [`entelix_session::GraphEvent`]
24//!
25//! `AgentEvent<S>` is the **runtime-side superset** of the durable
26//! audit log entry [`entelix_session::GraphEvent`]:
27//!
28//! - **Runtime-only variants** — `Started`, `Complete`, `Failed`,
29//! plus the `tool_version` / `duration_ms` metric fields on the
30//! tool variants — exist for telemetry and per-run correlation.
31//! They have no audit projection.
32//! - **Audit-projecting variants** — `ToolStart` / `ToolComplete` /
33//! `ToolError` — map onto `GraphEvent::ToolCall` /
34//! `GraphEvent::ToolResult` via [`AgentEvent::to_graph_event`].
35//!
36//! The projection is the single source of truth: an operator
37//! wiring both an `AgentEventSink` (for telemetry) and a
38//! `SessionGraph` (for durable audit) routes tool emissions
39//! through this method rather than constructing `GraphEvent`
40//! independently — the two channels record the same fact through
41//! one construction path.
42
43use chrono::{DateTime, Utc};
44use serde_json::Value;
45
46use entelix_core::ErrorEnvelope;
47use entelix_core::RenderedForLlm;
48use entelix_core::TenantId;
49use entelix_core::UsageSnapshot;
50use entelix_core::ir::ToolResultContent;
51use entelix_session::GraphEvent;
52
53/// Runtime events emitted by the agent during a single
54/// `execute` / `execute_stream` call.
55#[derive(Clone, Debug, PartialEq, Eq)]
56#[non_exhaustive]
57pub enum AgentEvent<S> {
58 /// Run opened. Sinks use this to mark span beginnings, allocate
59 /// per-run state, and emit "session opened" telemetry.
60 Started {
61 /// Per-run correlation id (UUID v7). Stable for the
62 /// duration of the run; matches the id on every subsequent
63 /// event for this same call.
64 run_id: String,
65 /// Tenant scope this event belongs to (invariant 11 —
66 /// every emit site stamps `ctx.tenant_id().clone()`). Audit /
67 /// billing / replay consumers key off this field directly
68 /// instead of correlating through a separate `run_id` →
69 /// `tenant_id` lookup.
70 tenant_id: TenantId,
71 /// Run id of the calling agent when this run was dispatched
72 /// from a parent (sub-agent fan-out, supervisor handoff).
73 /// `None` for top-level runs. LangSmith-style trace-tree
74 /// consumers reconstruct the hierarchy from
75 /// `(run_id, parent_run_id)` edges across these events.
76 parent_run_id: Option<String>,
77 /// Agent identifier configured on `AgentBuilder::name(...)`.
78 agent: String,
79 },
80
81 /// One tool dispatch began. Emitted by
82 /// [`crate::agent::tool_event_layer::ToolEventLayer`] when wired
83 /// into the tool registry. Absent when the layer is not wired
84 /// (the agent runtime itself does not generate tool events).
85 ToolStart {
86 /// Run correlation id.
87 run_id: String,
88 /// Tenant scope this event belongs to (see `Started`).
89 tenant_id: TenantId,
90 /// Stable tool-use id matching the originating
91 /// `ContentPart::ToolUse`.
92 tool_use_id: String,
93 /// Tool name being dispatched.
94 tool: String,
95 /// Tool version (`Tool::version()`) when the tool advertises
96 /// one — useful for distinguishing behaviour changes between
97 /// otherwise-identically-named tool revisions.
98 tool_version: Option<String>,
99 /// Tool input (already JSON-validated by the tool's schema).
100 input: Value,
101 },
102
103 /// One tool dispatch finished successfully.
104 ToolComplete {
105 /// Run correlation id.
106 run_id: String,
107 /// Tenant scope this event belongs to (see `Started`).
108 tenant_id: TenantId,
109 /// Stable tool-use id matching the corresponding `ToolStart`.
110 tool_use_id: String,
111 /// Tool name (echoed for sink convenience).
112 tool: String,
113 /// Tool version echoed from the matching `ToolStart` so sinks
114 /// can correlate completion telemetry without retaining
115 /// per-`tool_use_id` state.
116 tool_version: Option<String>,
117 /// Wall-clock duration measured by the layer.
118 duration_ms: u64,
119 /// JSON output the tool produced. Sinks that persist tool
120 /// audit logs read this directly; PII redaction happens at
121 /// the policy layer before this event is emitted, so the
122 /// payload is safe for storage.
123 output: Value,
124 },
125
126 /// One tool dispatch failed.
127 ToolError {
128 /// Run correlation id.
129 run_id: String,
130 /// Tenant scope this event belongs to (see `Started`).
131 tenant_id: TenantId,
132 /// Stable tool-use id matching the corresponding `ToolStart`.
133 tool_use_id: String,
134 /// Tool name (echoed for sink convenience).
135 tool: String,
136 /// Tool version echoed from the matching `ToolStart` so sinks
137 /// see the same provenance on the failure path as on success.
138 tool_version: Option<String>,
139 /// Operator-facing error message (`Display` form, includes
140 /// vendor status, source chain). Sinks, OTel, and log
141 /// destinations consume this.
142 error: String,
143 /// LLM-facing error message wrapped in a sealed
144 /// [`RenderedForLlm`] carrier. The carrier's constructor is
145 /// `pub(crate)` to `entelix-core`, so the only path from a
146 /// raw `String` to this field is
147 /// [`entelix_core::LlmRenderable::for_llm`] — emit sites
148 /// cannot fabricate model-facing content. The audit-log
149 /// projection ([`Self::to_graph_event`]) extracts the inner
150 /// rendering into `GraphEvent::ToolResult` so replay
151 /// reconstructs the model's view without re-leaking
152 /// operator content (invariant #16).
153 error_for_llm: RenderedForLlm<String>,
154 /// Typed wire shape produced by
155 /// [`entelix_core::Error::envelope`]. Bundles `wire_code`
156 /// (i18n key / metric label), `wire_class` (responsibility
157 /// split), `retry_after_secs` (vendor `Retry-After` hint),
158 /// and `provider_status` (raw HTTP status) so sinks, audit
159 /// replay, SSE adapters, and FE rate-limit timers all read
160 /// one `Copy` value instead of pattern-matching the inner
161 /// error variant. Patch-version-stable.
162 envelope: ErrorEnvelope,
163 /// Wall-clock duration measured by the layer.
164 duration_ms: u64,
165 },
166
167 /// Run terminated with the inner runnable's error. The matching
168 /// `Started{run_id}` is always present in the same stream.
169 /// Caller-facing streams additionally surface the typed error
170 /// via `Result::Err`; sinks see only this event.
171 Failed {
172 /// Run correlation id.
173 run_id: String,
174 /// Tenant scope this event belongs to (see `Started`).
175 tenant_id: TenantId,
176 /// Lean error message (`Display` form).
177 error: String,
178 /// Typed wire shape produced by
179 /// [`entelix_core::Error::envelope`] — see `ToolError` for
180 /// the field roster. Replay / audit / metric / SSE consumers
181 /// route off this field instead of parsing `error` prose.
182 envelope: ErrorEnvelope,
183 },
184
185 /// Run terminated successfully with the agent's terminal state.
186 Complete {
187 /// Run correlation id.
188 run_id: String,
189 /// Tenant scope this event belongs to (see `Started`).
190 tenant_id: TenantId,
191 /// Final state returned by the inner runnable.
192 state: S,
193 /// Frozen [`UsageSnapshot`] of the [`entelix_core::RunBudget`]
194 /// counters at the moment the inner runnable returned.
195 /// `None` when no budget was attached to the
196 /// [`entelix_core::ExecutionContext`]. Mirrors the
197 /// `usage` field on
198 /// [`crate::AgentRunResult`] so streaming and one-shot
199 /// surfaces observe the same terminal artifact.
200 usage: Option<UsageSnapshot>,
201 },
202
203 /// HITL approver decided to permit one tool dispatch. Emitted by
204 /// [`crate::agent::ApprovalLayer`] before the matching `ToolStart`
205 /// fires. Only present when an `Approver` is wired (default
206 /// agents skip approval and never emit this variant).
207 ToolCallApproved {
208 /// Run correlation id.
209 run_id: String,
210 /// Tenant scope this event belongs to (see `Started`).
211 tenant_id: TenantId,
212 /// Stable tool-use id matching the originating
213 /// `ContentPart::ToolUse`. Pairs with the subsequent
214 /// `ToolStart` / `ToolComplete` / `ToolError`.
215 tool_use_id: String,
216 /// Tool name being approved.
217 tool: String,
218 },
219
220 /// HITL approver decided to reject one tool dispatch. The
221 /// matching `ToolStart` does NOT fire — denial short-circuits
222 /// the dispatch path. The agent observes the rejection as
223 /// `Error::InvalidRequest` carrying the same reason.
224 ToolCallDenied {
225 /// Run correlation id.
226 run_id: String,
227 /// Tenant scope this event belongs to (see `Started`).
228 tenant_id: TenantId,
229 /// Stable tool-use id of the rejected dispatch.
230 tool_use_id: String,
231 /// Tool name being denied.
232 tool: String,
233 /// Approver-supplied rationale.
234 reason: String,
235 },
236}
237
238impl<S> AgentEvent<S> {
239 /// Project this runtime event onto the durable audit-log shape
240 /// `GraphEvent`. Returns `None` when the variant has no audit
241 /// projection — `Started`, `Complete`, `Failed` are runtime-only
242 /// lifecycle markers that do not belong in the per-thread audit
243 /// trail.
244 ///
245 /// The `timestamp` argument is supplied by the caller (typically
246 /// `Utc::now()` at emit time) so this method stays pure: a single
247 /// runtime event projected at two different points in time
248 /// produces two distinct (but otherwise equal) `GraphEvent`s.
249 ///
250 /// Lossy projection notes — `run_id`, `tool_version`, and
251 /// `duration_ms` are dropped because the audit log keys
252 /// correlation by `tool_use_id` + `timestamp` and is not the
253 /// home for runtime metrics. Operators who need run-level
254 /// correlation in audit do it at the sink layer (e.g. by
255 /// stamping a thread tag prior to append).
256 ///
257 /// `ToolError` is mapped onto a `GraphEvent::ToolResult` with
258 /// `is_error: true` and the error message carried as text
259 /// content — preserving the same correlation key
260 /// (`tool_use_id`) so a session replay can pair the failed
261 /// dispatch back with the originating `ToolCall`.
262 pub fn to_graph_event(&self, timestamp: DateTime<Utc>) -> Option<GraphEvent> {
263 match self {
264 // Lifecycle / approval markers are runtime-only — the
265 // audit log records the actual `ToolCall` / `ToolResult`
266 // pair, not the surrounding gate decisions.
267 Self::Started { .. }
268 | Self::Complete { .. }
269 | Self::Failed { .. }
270 | Self::ToolCallApproved { .. }
271 | Self::ToolCallDenied { .. } => None,
272 Self::ToolStart {
273 tool_use_id,
274 tool,
275 input,
276 ..
277 } => Some(GraphEvent::ToolCall {
278 id: tool_use_id.clone(),
279 name: tool.clone(),
280 input: input.clone(),
281 timestamp,
282 }),
283 Self::ToolComplete {
284 tool_use_id,
285 tool,
286 output,
287 ..
288 } => Some(GraphEvent::ToolResult {
289 tool_use_id: tool_use_id.clone(),
290 name: tool.clone(),
291 content: ToolResultContent::Json(output.clone()),
292 is_error: false,
293 timestamp,
294 }),
295 Self::ToolError {
296 tool_use_id,
297 tool,
298 error_for_llm,
299 ..
300 } => Some(GraphEvent::ToolResult {
301 tool_use_id: tool_use_id.clone(),
302 name: tool.clone(),
303 // Audit log carries the LLM-facing rendering — replay
304 // and resume paths reconstruct conversation history
305 // from `GraphEvent::ToolResult`, so the content here
306 // becomes the model's view (invariant #16). The full
307 // operator-facing `error` continues to flow through
308 // the event sink and OTel.
309 content: ToolResultContent::Text(error_for_llm.as_inner().clone()),
310 is_error: true,
311 timestamp,
312 }),
313 }
314 }
315
316 /// Erase the agent-state type parameter, replacing
317 /// [`Self::Complete::state`] with the unit value. Every other
318 /// variant rebuilds with identical field values — they carry no
319 /// state. Enables a single audit / SSE / OTel sink (typed
320 /// [`AgentEventSink<()>`](crate::agent::AgentEventSink)) to fan
321 /// in from heterogeneous agents (`Agent<ReActState>`,
322 /// `Agent<SupervisorState>`, …) through the
323 /// [`StateErasureSink`](crate::agent::StateErasureSink) adapter.
324 ///
325 /// Operators consuming the post-erasure event tree retain access
326 /// to every header field (`run_id`, `tenant_id`, `parent_run_id`)
327 /// and every per-variant payload (tool inputs / outputs, error
328 /// envelope, usage snapshot) — only the agent's terminal state
329 /// is dropped, which is the field a state-agnostic sink could
330 /// not type-erase anyway.
331 ///
332 /// # Examples
333 ///
334 /// ```
335 /// use entelix_agents::AgentEvent;
336 /// use entelix_core::TenantId;
337 ///
338 /// let typed: AgentEvent<u32> = AgentEvent::Complete {
339 /// run_id: "r1".into(),
340 /// tenant_id: TenantId::new("t1"),
341 /// state: 42_u32,
342 /// usage: None,
343 /// };
344 /// let erased: AgentEvent<()> = typed.erase_state();
345 /// match erased {
346 /// AgentEvent::Complete { state, .. } => assert_eq!(state, ()),
347 /// _ => unreachable!(),
348 /// }
349 /// ```
350 #[allow(clippy::too_many_lines)]
351 // 1-to-1 exhaustive variant rebuild — splitting hurts readability and the line count is structural, not accidental.
352 #[must_use]
353 pub fn erase_state(self) -> AgentEvent<()> {
354 match self {
355 Self::Started {
356 run_id,
357 tenant_id,
358 parent_run_id,
359 agent,
360 } => AgentEvent::Started {
361 run_id,
362 tenant_id,
363 parent_run_id,
364 agent,
365 },
366 Self::ToolStart {
367 run_id,
368 tenant_id,
369 tool_use_id,
370 tool,
371 tool_version,
372 input,
373 } => AgentEvent::ToolStart {
374 run_id,
375 tenant_id,
376 tool_use_id,
377 tool,
378 tool_version,
379 input,
380 },
381 Self::ToolComplete {
382 run_id,
383 tenant_id,
384 tool_use_id,
385 tool,
386 tool_version,
387 duration_ms,
388 output,
389 } => AgentEvent::ToolComplete {
390 run_id,
391 tenant_id,
392 tool_use_id,
393 tool,
394 tool_version,
395 duration_ms,
396 output,
397 },
398 Self::ToolError {
399 run_id,
400 tenant_id,
401 tool_use_id,
402 tool,
403 tool_version,
404 error,
405 error_for_llm,
406 envelope,
407 duration_ms,
408 } => AgentEvent::ToolError {
409 run_id,
410 tenant_id,
411 tool_use_id,
412 tool,
413 tool_version,
414 error,
415 error_for_llm,
416 envelope,
417 duration_ms,
418 },
419 Self::Failed {
420 run_id,
421 tenant_id,
422 error,
423 envelope,
424 } => AgentEvent::Failed {
425 run_id,
426 tenant_id,
427 error,
428 envelope,
429 },
430 Self::Complete {
431 run_id,
432 tenant_id,
433 state: _,
434 usage,
435 } => AgentEvent::Complete {
436 run_id,
437 tenant_id,
438 state: (),
439 usage,
440 },
441 Self::ToolCallApproved {
442 run_id,
443 tenant_id,
444 tool_use_id,
445 tool,
446 } => AgentEvent::ToolCallApproved {
447 run_id,
448 tenant_id,
449 tool_use_id,
450 tool,
451 },
452 Self::ToolCallDenied {
453 run_id,
454 tenant_id,
455 tool_use_id,
456 tool,
457 reason,
458 } => AgentEvent::ToolCallDenied {
459 run_id,
460 tenant_id,
461 tool_use_id,
462 tool,
463 reason,
464 },
465 }
466 }
467}
468
469#[cfg(test)]
470#[allow(clippy::unwrap_used)]
471mod tests {
472 use super::*;
473 use serde_json::json;
474
475 fn ts() -> DateTime<Utc> {
476 chrono::DateTime::parse_from_rfc3339("2026-04-29T12:00:00Z")
477 .unwrap()
478 .with_timezone(&Utc)
479 }
480
481 #[test]
482 fn lifecycle_variants_have_no_audit_projection() {
483 let tenant = TenantId::new("t-test");
484 let started: AgentEvent<u32> = AgentEvent::Started {
485 run_id: "r1".into(),
486 tenant_id: tenant.clone(),
487 parent_run_id: None,
488 agent: "a".into(),
489 };
490 let complete: AgentEvent<u32> = AgentEvent::Complete {
491 run_id: "r1".into(),
492 tenant_id: tenant.clone(),
493 state: 7,
494 usage: None,
495 };
496 let failed: AgentEvent<u32> = AgentEvent::Failed {
497 run_id: "r1".into(),
498 tenant_id: tenant,
499 error: "boom".into(),
500 envelope: entelix_core::Error::config("boom").envelope(),
501 };
502 assert!(started.to_graph_event(ts()).is_none());
503 assert!(complete.to_graph_event(ts()).is_none());
504 assert!(failed.to_graph_event(ts()).is_none());
505 }
506
507 #[test]
508 fn tool_start_projects_to_graph_event_tool_call() {
509 let event: AgentEvent<u32> = AgentEvent::ToolStart {
510 run_id: "r1".into(),
511 tenant_id: TenantId::new("t-test"),
512 tool_use_id: "tu-1".into(),
513 tool: "double".into(),
514 tool_version: Some("1.2.0".into()),
515 input: json!({"n": 21}),
516 };
517 let projected = event.to_graph_event(ts()).unwrap();
518 match projected {
519 GraphEvent::ToolCall {
520 id,
521 name,
522 input,
523 timestamp,
524 } => {
525 assert_eq!(id, "tu-1");
526 assert_eq!(name, "double");
527 assert_eq!(input, json!({"n": 21}));
528 assert_eq!(timestamp, ts());
529 }
530 other => panic!("expected ToolCall, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn tool_complete_projects_to_successful_tool_result() {
536 let event: AgentEvent<u32> = AgentEvent::ToolComplete {
537 run_id: "r1".into(),
538 tenant_id: TenantId::new("t-test"),
539 tool_use_id: "tu-1".into(),
540 tool: "double".into(),
541 tool_version: Some("1.2.0".into()),
542 duration_ms: 42,
543 output: json!({"doubled": 42}),
544 };
545 let projected = event.to_graph_event(ts()).unwrap();
546 match projected {
547 GraphEvent::ToolResult {
548 tool_use_id,
549 name,
550 content,
551 is_error,
552 timestamp,
553 } => {
554 assert_eq!(tool_use_id, "tu-1");
555 assert_eq!(name, "double");
556 assert!(!is_error, "successful tool dispatch must not flag is_error");
557 assert_eq!(timestamp, ts());
558 match content {
559 ToolResultContent::Json(v) => assert_eq!(v, json!({"doubled": 42})),
560 other => panic!("expected Json content, got {other:?}"),
561 }
562 }
563 other => panic!("expected ToolResult, got {other:?}"),
564 }
565 }
566
567 #[test]
568 fn tool_error_projects_to_error_flagged_tool_result_using_llm_facing_text() {
569 use entelix_core::{Error, LlmRenderable};
570 // The carrier `RenderedForLlm<String>` is sealed to
571 // `entelix-core` — there is no way to fabricate one from a
572 // raw `String` here. The only path to populate
573 // `error_for_llm` is `LlmRenderable::for_llm` on a value
574 // that implements the trait. `Error::provider_http(503,
575 // ...).for_llm()` produces the canonical "upstream model
576 // error" rendering through the same code path the
577 // production tool-event layer uses, so the test exercises
578 // the real boundary instead of stubbing past it.
579 let source = Error::provider_http(503, "vendor down");
580 let envelope = source.envelope();
581 let llm_facing = source.for_llm();
582 let event: AgentEvent<u32> = AgentEvent::ToolError {
583 run_id: "r1".into(),
584 tenant_id: TenantId::new("t-test"),
585 tool_use_id: "tu-1".into(),
586 tool: "double".into(),
587 tool_version: None,
588 // Operator-facing text — full Display, includes vendor
589 // status / source chain. The audit projection MUST NOT
590 // surface this to the model channel.
591 error: "provider returned 503: vendor down".into(),
592 // LLM-facing rendering — short, actionable, no vendor
593 // identifiers. The audit projection picks this.
594 error_for_llm: llm_facing,
595 envelope,
596 duration_ms: 7,
597 };
598 let projected = event.to_graph_event(ts()).unwrap();
599 match projected {
600 GraphEvent::ToolResult {
601 tool_use_id,
602 name,
603 content,
604 is_error,
605 ..
606 } => {
607 assert_eq!(tool_use_id, "tu-1");
608 assert_eq!(name, "double");
609 assert!(is_error, "ToolError must surface as is_error: true");
610 match content {
611 ToolResultContent::Text(s) => {
612 assert_eq!(s, "upstream model error");
613 assert!(
614 !s.contains("provider returned"),
615 "audit log content must use the LLM-facing rendering, not the operator-facing one: {s}"
616 );
617 assert!(
618 !s.contains("503"),
619 "audit log must not leak vendor status code: {s}"
620 );
621 }
622 other => panic!("expected Text content for error, got {other:?}"),
623 }
624 }
625 other => panic!("expected ToolResult, got {other:?}"),
626 }
627 }
628
629 #[test]
630 fn projection_is_deterministic_across_calls() {
631 // Same event projected with the same timestamp produces the
632 // same GraphEvent — required for replay coherence (two
633 // operators running the same projection at the same wall
634 // clock get the same audit row).
635 let event: AgentEvent<u32> = AgentEvent::ToolStart {
636 run_id: "r1".into(),
637 tenant_id: TenantId::new("t-test"),
638 tool_use_id: "tu-1".into(),
639 tool: "double".into(),
640 tool_version: None,
641 input: json!({"n": 21}),
642 };
643 let a = event.to_graph_event(ts()).unwrap();
644 let b = event.to_graph_event(ts()).unwrap();
645 assert_eq!(a, b);
646 }
647}