vtcode_exec_events/
lib.rs

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