Skip to main content

vtcode_exec_events/
lib.rs

1//! Structured execution telemetry events shared across VT Code crates.
2//!
3//! This crate exposes the serialized schema for thread lifecycle updates,
4//! command execution results, and other timeline artifacts emitted by the
5//! automation runtime. Downstream applications can deserialize these
6//! structures to drive dashboards, logging, or auditing pipelines without
7//! depending on the full `vtcode-core` crate.
8//!
9//! # Agent Trace Support
10//!
11//! This crate implements the [Agent Trace](https://agent-trace.dev/) specification
12//! for tracking AI-generated code attribution. See the [`trace`] module for details.
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17pub mod trace;
18
19/// Semantic version of the serialized event schema exported by this crate.
20pub const EVENT_SCHEMA_VERSION: &str = "0.1.0";
21
22/// Wraps a [`ThreadEvent`] with schema metadata so downstream consumers can
23/// negotiate compatibility before processing an event stream.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
26pub struct VersionedThreadEvent {
27    /// Semantic version describing the schema of the nested event payload.
28    pub schema_version: String,
29    /// Concrete event emitted by the agent runtime.
30    pub event: ThreadEvent,
31}
32
33impl VersionedThreadEvent {
34    /// Creates a new [`VersionedThreadEvent`] using the current
35    /// [`EVENT_SCHEMA_VERSION`].
36    pub fn new(event: ThreadEvent) -> Self {
37        Self {
38            schema_version: EVENT_SCHEMA_VERSION.to_string(),
39            event,
40        }
41    }
42
43    /// Returns the nested [`ThreadEvent`], consuming the wrapper.
44    pub fn into_event(self) -> ThreadEvent {
45        self.event
46    }
47}
48
49impl From<ThreadEvent> for VersionedThreadEvent {
50    fn from(event: ThreadEvent) -> Self {
51        Self::new(event)
52    }
53}
54
55/// Sink for processing [`ThreadEvent`] instances.
56pub trait EventEmitter {
57    /// Invoked for each event emitted by the automation runtime.
58    fn emit(&mut self, event: &ThreadEvent);
59}
60
61impl<F> EventEmitter for F
62where
63    F: FnMut(&ThreadEvent),
64{
65    fn emit(&mut self, event: &ThreadEvent) {
66        self(event);
67    }
68}
69
70/// JSON helper utilities for serializing and deserializing thread events.
71#[cfg(feature = "serde-json")]
72pub mod json {
73    use super::{ThreadEvent, VersionedThreadEvent};
74
75    /// Converts an event into a `serde_json::Value`.
76    pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
77        serde_json::to_value(event)
78    }
79
80    /// Serializes an event into a JSON string.
81    pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
82        serde_json::to_string(event)
83    }
84
85    /// Deserializes an event from a JSON string.
86    pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
87        serde_json::from_str(payload)
88    }
89
90    /// Serializes a [`VersionedThreadEvent`] wrapper.
91    pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
92        serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
93    }
94
95    /// Deserializes a [`VersionedThreadEvent`] wrapper.
96    pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
97        serde_json::from_str(payload)
98    }
99}
100
101#[cfg(feature = "telemetry-log")]
102mod log_support {
103    use log::Level;
104
105    use super::{EventEmitter, ThreadEvent, json};
106
107    /// Emits JSON serialized events to the `log` facade at the configured level.
108    #[derive(Debug, Clone)]
109    pub struct LogEmitter {
110        level: Level,
111    }
112
113    impl LogEmitter {
114        /// Creates a new [`LogEmitter`] that logs at the provided [`Level`].
115        pub fn new(level: Level) -> Self {
116            Self { level }
117        }
118    }
119
120    impl Default for LogEmitter {
121        fn default() -> Self {
122            Self { level: Level::Info }
123        }
124    }
125
126    impl EventEmitter for LogEmitter {
127        fn emit(&mut self, event: &ThreadEvent) {
128            if log::log_enabled!(self.level) {
129                match json::to_string(event) {
130                    Ok(serialized) => log::log!(self.level, "{}", serialized),
131                    Err(err) => log::log!(
132                        self.level,
133                        "failed to serialize vtcode exec event for logging: {err}"
134                    ),
135                }
136            }
137        }
138    }
139
140    pub use LogEmitter as PublicLogEmitter;
141}
142
143#[cfg(feature = "telemetry-log")]
144pub use log_support::PublicLogEmitter as LogEmitter;
145
146#[cfg(feature = "telemetry-tracing")]
147mod tracing_support {
148    use tracing::Level;
149
150    use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
151
152    /// Emits structured events as `tracing` events at the specified level.
153    #[derive(Debug, Clone)]
154    pub struct TracingEmitter {
155        level: Level,
156    }
157
158    impl TracingEmitter {
159        /// Creates a new [`TracingEmitter`] with the provided [`Level`].
160        pub fn new(level: Level) -> Self {
161            Self { level }
162        }
163    }
164
165    impl Default for TracingEmitter {
166        fn default() -> Self {
167            Self { level: Level::INFO }
168        }
169    }
170
171    impl EventEmitter for TracingEmitter {
172        fn emit(&mut self, event: &ThreadEvent) {
173            match self.level {
174                Level::TRACE => tracing::event!(
175                    target: "vtcode_exec_events",
176                    Level::TRACE,
177                    schema_version = EVENT_SCHEMA_VERSION,
178                    event = ?VersionedThreadEvent::new(event.clone()),
179                    "vtcode_exec_event"
180                ),
181                Level::DEBUG => tracing::event!(
182                    target: "vtcode_exec_events",
183                    Level::DEBUG,
184                    schema_version = EVENT_SCHEMA_VERSION,
185                    event = ?VersionedThreadEvent::new(event.clone()),
186                    "vtcode_exec_event"
187                ),
188                Level::INFO => tracing::event!(
189                    target: "vtcode_exec_events",
190                    Level::INFO,
191                    schema_version = EVENT_SCHEMA_VERSION,
192                    event = ?VersionedThreadEvent::new(event.clone()),
193                    "vtcode_exec_event"
194                ),
195                Level::WARN => tracing::event!(
196                    target: "vtcode_exec_events",
197                    Level::WARN,
198                    schema_version = EVENT_SCHEMA_VERSION,
199                    event = ?VersionedThreadEvent::new(event.clone()),
200                    "vtcode_exec_event"
201                ),
202                Level::ERROR => tracing::event!(
203                    target: "vtcode_exec_events",
204                    Level::ERROR,
205                    schema_version = EVENT_SCHEMA_VERSION,
206                    event = ?VersionedThreadEvent::new(event.clone()),
207                    "vtcode_exec_event"
208                ),
209            }
210        }
211    }
212
213    pub use TracingEmitter as PublicTracingEmitter;
214}
215
216#[cfg(feature = "telemetry-tracing")]
217pub use tracing_support::PublicTracingEmitter as TracingEmitter;
218
219#[cfg(feature = "schema-export")]
220pub mod schema {
221    use schemars::{schema::RootSchema, schema_for};
222
223    use super::{ThreadEvent, VersionedThreadEvent};
224
225    /// Generates a JSON Schema describing [`ThreadEvent`].
226    pub fn thread_event_schema() -> RootSchema {
227        schema_for!(ThreadEvent)
228    }
229
230    /// Generates a JSON Schema describing [`VersionedThreadEvent`].
231    pub fn versioned_thread_event_schema() -> RootSchema {
232        schema_for!(VersionedThreadEvent)
233    }
234}
235
236/// Structured events emitted during autonomous execution.
237#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
238#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
239#[serde(tag = "type")]
240pub enum ThreadEvent {
241    /// Indicates that a new execution thread has started.
242    #[serde(rename = "thread.started")]
243    ThreadStarted(ThreadStartedEvent),
244    /// Marks the beginning of an execution turn.
245    #[serde(rename = "turn.started")]
246    TurnStarted(TurnStartedEvent),
247    /// Marks the completion of an execution turn.
248    #[serde(rename = "turn.completed")]
249    TurnCompleted(TurnCompletedEvent),
250    /// Marks a turn as failed with additional context.
251    #[serde(rename = "turn.failed")]
252    TurnFailed(TurnFailedEvent),
253    /// Indicates that an item has started processing.
254    #[serde(rename = "item.started")]
255    ItemStarted(ItemStartedEvent),
256    /// Indicates that an item has been updated.
257    #[serde(rename = "item.updated")]
258    ItemUpdated(ItemUpdatedEvent),
259    /// Indicates that an item reached a terminal state.
260    #[serde(rename = "item.completed")]
261    ItemCompleted(ItemCompletedEvent),
262    /// Represents a fatal error.
263    #[serde(rename = "error")]
264    Error(ThreadErrorEvent),
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
268#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
269pub struct ThreadStartedEvent {
270    /// Unique identifier for the thread that was started.
271    pub thread_id: String,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
275#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
276pub struct TurnStartedEvent {}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
280pub struct TurnCompletedEvent {
281    /// Token usage summary for the completed turn.
282    pub usage: Usage,
283}
284
285#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
286#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
287pub struct TurnFailedEvent {
288    /// Human-readable explanation describing why the turn failed.
289    pub message: String,
290    /// Optional token usage that was consumed before the failure occurred.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub usage: Option<Usage>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
297pub struct ThreadErrorEvent {
298    /// Fatal error message associated with the thread.
299    pub message: String,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
304pub struct Usage {
305    /// Number of prompt tokens processed during the turn.
306    pub input_tokens: u64,
307    /// Number of cached prompt tokens reused from previous turns.
308    pub cached_input_tokens: u64,
309    /// Number of completion tokens generated by the model.
310    pub output_tokens: u64,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
314#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
315pub struct ItemCompletedEvent {
316    /// Snapshot of the thread item that completed.
317    pub item: ThreadItem,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
321#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
322pub struct ItemStartedEvent {
323    /// Snapshot of the thread item that began processing.
324    pub item: ThreadItem,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
328#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
329pub struct ItemUpdatedEvent {
330    /// Snapshot of the thread item after it was updated.
331    pub item: ThreadItem,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
336pub struct ThreadItem {
337    /// Stable identifier associated with the item.
338    pub id: String,
339    /// Embedded event details for the item type.
340    #[serde(flatten)]
341    pub details: ThreadItemDetails,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
345#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
346#[serde(tag = "type", rename_all = "snake_case")]
347pub enum ThreadItemDetails {
348    /// Message authored by the agent.
349    AgentMessage(AgentMessageItem),
350    /// Free-form reasoning text produced during a turn.
351    Reasoning(ReasoningItem),
352    /// Command execution lifecycle update.
353    CommandExecution(CommandExecutionItem),
354    /// File change summary associated with the turn.
355    FileChange(FileChangeItem),
356    /// MCP tool invocation status.
357    McpToolCall(McpToolCallItem),
358    /// Web search event emitted by a registered search provider.
359    WebSearch(WebSearchItem),
360    /// General error captured for auditing.
361    Error(ErrorItem),
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
365#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
366pub struct AgentMessageItem {
367    /// Textual content of the agent message.
368    pub text: String,
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
372#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
373pub struct ReasoningItem {
374    /// Free-form reasoning content captured during planning.
375    pub text: String,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
379#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
380#[serde(rename_all = "snake_case")]
381pub enum CommandExecutionStatus {
382    /// Command finished successfully.
383    #[default]
384    Completed,
385    /// Command failed (non-zero exit code or runtime error).
386    Failed,
387    /// Command is still running and may emit additional output.
388    InProgress,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
393pub struct CommandExecutionItem {
394    /// Command string executed by the runner.
395    pub command: String,
396    /// Aggregated output emitted by the command.
397    #[serde(default)]
398    pub aggregated_output: String,
399    /// Exit code reported by the process, when available.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub exit_code: Option<i32>,
402    /// Current status of the command execution.
403    pub status: CommandExecutionStatus,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
407#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
408pub struct FileChangeItem {
409    /// List of individual file updates included in the change set.
410    pub changes: Vec<FileUpdateChange>,
411    /// Whether the patch application succeeded.
412    pub status: PatchApplyStatus,
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
416#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
417pub struct FileUpdateChange {
418    /// Path of the file that was updated.
419    pub path: String,
420    /// Type of change applied to the file.
421    pub kind: PatchChangeKind,
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
425#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
426#[serde(rename_all = "snake_case")]
427pub enum PatchApplyStatus {
428    /// Patch successfully applied.
429    Completed,
430    /// Patch application failed.
431    Failed,
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
435#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
436#[serde(rename_all = "snake_case")]
437pub enum PatchChangeKind {
438    /// File addition.
439    Add,
440    /// File deletion.
441    Delete,
442    /// File update in place.
443    Update,
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
447#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
448pub struct McpToolCallItem {
449    /// Name of the MCP tool invoked by the agent.
450    pub tool_name: String,
451    /// Arguments passed to the tool invocation, if any.
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub arguments: Option<Value>,
454    /// Result payload returned by the tool, if captured.
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub result: Option<String>,
457    /// Lifecycle status for the tool call.
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub status: Option<McpToolCallStatus>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
464#[serde(rename_all = "snake_case")]
465pub enum McpToolCallStatus {
466    /// Tool invocation has started.
467    Started,
468    /// Tool invocation completed successfully.
469    Completed,
470    /// Tool invocation failed.
471    Failed,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
476pub struct WebSearchItem {
477    /// Query that triggered the search.
478    pub query: String,
479    /// Search provider identifier, when known.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub provider: Option<String>,
482    /// Optional raw search results captured for auditing.
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub results: Option<Vec<String>>,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
488#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
489pub struct ErrorItem {
490    /// Error message displayed to the user or logs.
491    pub message: String,
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use std::error::Error;
498
499    #[test]
500    fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
501        let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
502            usage: Usage {
503                input_tokens: 1,
504                cached_input_tokens: 2,
505                output_tokens: 3,
506            },
507        });
508
509        let json = serde_json::to_string(&event)?;
510        let restored: ThreadEvent = serde_json::from_str(&json)?;
511
512        assert_eq!(restored, event);
513        Ok(())
514    }
515
516    #[test]
517    fn versioned_event_wraps_schema_version() {
518        let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
519            thread_id: "abc".to_string(),
520        });
521
522        let versioned = VersionedThreadEvent::new(event.clone());
523
524        assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
525        assert_eq!(versioned.event, event);
526        assert_eq!(versioned.into_event(), event);
527    }
528
529    #[cfg(feature = "serde-json")]
530    #[test]
531    fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
532        let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
533            item: ThreadItem {
534                id: "item-1".to_string(),
535                details: ThreadItemDetails::AgentMessage(AgentMessageItem {
536                    text: "hello".to_string(),
537                }),
538            },
539        });
540
541        let payload = crate::json::versioned_to_string(&event)?;
542        let restored = crate::json::versioned_from_str(&payload)?;
543
544        assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
545        assert_eq!(restored.event, event);
546        Ok(())
547    }
548}