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