Skip to main content

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}