use serde::{Deserialize, Serialize};
use serde_json::Value;
pub mod atif;
pub mod trace;
pub const EVENT_SCHEMA_VERSION: &str = "0.4.0";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct VersionedThreadEvent {
pub schema_version: String,
pub event: ThreadEvent,
}
impl VersionedThreadEvent {
pub fn new(event: ThreadEvent) -> Self {
Self {
schema_version: EVENT_SCHEMA_VERSION.to_string(),
event,
}
}
pub fn into_event(self) -> ThreadEvent {
self.event
}
}
impl From<ThreadEvent> for VersionedThreadEvent {
fn from(event: ThreadEvent) -> Self {
Self::new(event)
}
}
pub trait EventEmitter {
fn emit(&mut self, event: &ThreadEvent);
}
impl<F> EventEmitter for F
where
F: FnMut(&ThreadEvent),
{
fn emit(&mut self, event: &ThreadEvent) {
self(event);
}
}
#[cfg(feature = "serde-json")]
pub mod json {
use super::{ThreadEvent, VersionedThreadEvent};
pub fn to_value(event: &ThreadEvent) -> serde_json::Result<serde_json::Value> {
serde_json::to_value(event)
}
pub fn to_string(event: &ThreadEvent) -> serde_json::Result<String> {
serde_json::to_string(event)
}
pub fn from_str(payload: &str) -> serde_json::Result<ThreadEvent> {
serde_json::from_str(payload)
}
pub fn versioned_to_string(event: &ThreadEvent) -> serde_json::Result<String> {
serde_json::to_string(&VersionedThreadEvent::new(event.clone()))
}
pub fn versioned_from_str(payload: &str) -> serde_json::Result<VersionedThreadEvent> {
serde_json::from_str(payload)
}
}
#[cfg(feature = "telemetry-log")]
mod log_support {
use log::Level;
use super::{EventEmitter, ThreadEvent, json};
#[derive(Debug, Clone)]
pub struct LogEmitter {
level: Level,
}
impl LogEmitter {
pub fn new(level: Level) -> Self {
Self { level }
}
}
impl Default for LogEmitter {
fn default() -> Self {
Self { level: Level::Info }
}
}
impl EventEmitter for LogEmitter {
fn emit(&mut self, event: &ThreadEvent) {
if log::log_enabled!(self.level) {
match json::to_string(event) {
Ok(serialized) => log::log!(self.level, "{}", serialized),
Err(err) => log::log!(
self.level,
"failed to serialize vtcode exec event for logging: {err}"
),
}
}
}
}
pub use LogEmitter as PublicLogEmitter;
}
#[cfg(feature = "telemetry-log")]
pub use log_support::PublicLogEmitter as LogEmitter;
#[cfg(feature = "telemetry-tracing")]
mod tracing_support {
use tracing::Level;
use super::{EVENT_SCHEMA_VERSION, EventEmitter, ThreadEvent, VersionedThreadEvent};
#[derive(Debug, Clone)]
pub struct TracingEmitter {
level: Level,
}
impl TracingEmitter {
pub fn new(level: Level) -> Self {
Self { level }
}
}
impl Default for TracingEmitter {
fn default() -> Self {
Self { level: Level::INFO }
}
}
impl EventEmitter for TracingEmitter {
fn emit(&mut self, event: &ThreadEvent) {
match self.level {
Level::TRACE => tracing::event!(
target: "vtcode_exec_events",
Level::TRACE,
schema_version = EVENT_SCHEMA_VERSION,
event = ?VersionedThreadEvent::new(event.clone()),
"vtcode_exec_event"
),
Level::DEBUG => tracing::event!(
target: "vtcode_exec_events",
Level::DEBUG,
schema_version = EVENT_SCHEMA_VERSION,
event = ?VersionedThreadEvent::new(event.clone()),
"vtcode_exec_event"
),
Level::INFO => tracing::event!(
target: "vtcode_exec_events",
Level::INFO,
schema_version = EVENT_SCHEMA_VERSION,
event = ?VersionedThreadEvent::new(event.clone()),
"vtcode_exec_event"
),
Level::WARN => tracing::event!(
target: "vtcode_exec_events",
Level::WARN,
schema_version = EVENT_SCHEMA_VERSION,
event = ?VersionedThreadEvent::new(event.clone()),
"vtcode_exec_event"
),
Level::ERROR => tracing::event!(
target: "vtcode_exec_events",
Level::ERROR,
schema_version = EVENT_SCHEMA_VERSION,
event = ?VersionedThreadEvent::new(event.clone()),
"vtcode_exec_event"
),
}
}
}
pub use TracingEmitter as PublicTracingEmitter;
}
#[cfg(feature = "telemetry-tracing")]
pub use tracing_support::PublicTracingEmitter as TracingEmitter;
#[cfg(feature = "schema-export")]
pub mod schema {
use schemars::{Schema, schema_for};
use super::{ThreadEvent, VersionedThreadEvent};
pub fn thread_event_schema() -> Schema {
schema_for!(ThreadEvent)
}
pub fn versioned_thread_event_schema() -> Schema {
schema_for!(VersionedThreadEvent)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(tag = "type")]
pub enum ThreadEvent {
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
#[serde(rename = "thread.completed")]
ThreadCompleted(ThreadCompletedEvent),
#[serde(rename = "thread.compact_boundary")]
ThreadCompactBoundary(ThreadCompactBoundaryEvent),
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent),
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
#[serde(rename = "plan.delta")]
PlanDelta(PlanDeltaEvent),
#[serde(rename = "error")]
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ThreadStartedEvent {
pub thread_id: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ThreadCompletionSubtype {
Success,
ErrorMaxTurns,
ErrorMaxBudgetUsd,
ErrorDuringExecution,
Cancelled,
}
impl ThreadCompletionSubtype {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Success => "success",
Self::ErrorMaxTurns => "error_max_turns",
Self::ErrorMaxBudgetUsd => "error_max_budget_usd",
Self::ErrorDuringExecution => "error_during_execution",
Self::Cancelled => "cancelled",
}
}
pub const fn is_success(self) -> bool {
matches!(self, Self::Success)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompactionTrigger {
Manual,
Auto,
Recovery,
}
impl CompactionTrigger {
pub const fn as_str(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Auto => "auto",
Self::Recovery => "recovery",
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CompactionMode {
Provider,
Local,
}
impl CompactionMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Provider => "provider",
Self::Local => "local",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ThreadCompletedEvent {
pub thread_id: String,
pub session_id: String,
pub subtype: ThreadCompletionSubtype,
pub outcome_code: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop_reason: Option<String>,
pub usage: Usage,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_cost_usd: Option<serde_json::Number>,
pub num_turns: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ThreadCompactBoundaryEvent {
pub thread_id: String,
pub trigger: CompactionTrigger,
pub mode: CompactionMode,
pub original_message_count: usize,
pub compacted_message_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub history_artifact_path: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TurnStartedEvent {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TurnCompletedEvent {
pub usage: Usage,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TurnFailedEvent {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ThreadErrorEvent {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Usage {
pub input_tokens: u64,
pub cached_input_tokens: u64,
pub cache_creation_tokens: u64,
pub output_tokens: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ItemCompletedEvent {
pub item: ThreadItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ItemStartedEvent {
pub item: ThreadItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ItemUpdatedEvent {
pub item: ThreadItem,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PlanDeltaEvent {
pub thread_id: String,
pub turn_id: String,
pub item_id: String,
pub delta: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ThreadItem {
pub id: String,
#[serde(flatten)]
pub details: ThreadItemDetails,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ThreadItemDetails {
AgentMessage(AgentMessageItem),
Plan(PlanItem),
Reasoning(ReasoningItem),
CommandExecution(Box<CommandExecutionItem>),
ToolInvocation(ToolInvocationItem),
ToolOutput(ToolOutputItem),
FileChange(Box<FileChangeItem>),
McpToolCall(McpToolCallItem),
WebSearch(WebSearchItem),
Harness(HarnessEventItem),
Error(ErrorItem),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct AgentMessageItem {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct PlanItem {
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ReasoningItem {
pub text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub stage: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CommandExecutionStatus {
#[default]
Completed,
Failed,
InProgress,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct CommandExecutionItem {
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
#[serde(default)]
pub aggregated_output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub status: CommandExecutionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ToolCallStatus {
#[default]
Completed,
Failed,
InProgress,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ToolInvocationItem {
pub tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
pub status: ToolCallStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ToolOutputItem {
pub call_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub spool_path: Option<String>,
#[serde(default)]
pub output: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
pub status: ToolCallStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FileChangeItem {
pub changes: Vec<FileUpdateChange>,
pub status: PatchApplyStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct FileUpdateChange {
pub path: String,
pub kind: PatchChangeKind,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum PatchChangeKind {
Add,
Delete,
Update,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct McpToolCallItem {
pub tool_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<McpToolCallStatus>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum McpToolCallStatus {
Started,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct WebSearchItem {
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub results: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum HarnessEventKind {
PlanningStarted,
PlanningCompleted,
ContinuationStarted,
ContinuationSkipped,
BlockedHandoffWritten,
EvaluationStarted,
EvaluationPassed,
EvaluationFailed,
RevisionStarted,
VerificationStarted,
VerificationPassed,
VerificationFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct HarnessEventItem {
pub event: HarnessEventKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ErrorItem {
pub message: String,
}
#[cfg(test)]
mod tests {
use super::*;
use std::error::Error;
#[test]
fn thread_event_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
usage: Usage {
input_tokens: 1,
cached_input_tokens: 2,
cache_creation_tokens: 0,
output_tokens: 3,
},
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
#[test]
fn versioned_event_wraps_schema_version() {
let event = ThreadEvent::ThreadStarted(ThreadStartedEvent {
thread_id: "abc".to_string(),
});
let versioned = VersionedThreadEvent::new(event.clone());
assert_eq!(versioned.schema_version, EVENT_SCHEMA_VERSION);
assert_eq!(versioned.event, event);
assert_eq!(versioned.into_event(), event);
}
#[cfg(feature = "serde-json")]
#[test]
fn versioned_json_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "item-1".to_string(),
details: ThreadItemDetails::AgentMessage(AgentMessageItem {
text: "hello".to_string(),
}),
},
});
let payload = json::versioned_to_string(&event)?;
let restored = json::versioned_from_str(&payload)?;
assert_eq!(restored.schema_version, EVENT_SCHEMA_VERSION);
assert_eq!(restored.event, event);
Ok(())
}
#[test]
fn tool_invocation_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "tool_1".to_string(),
details: ThreadItemDetails::ToolInvocation(ToolInvocationItem {
tool_name: "read_file".to_string(),
arguments: Some(serde_json::json!({ "path": "README.md" })),
tool_call_id: Some("tool_call_0".to_string()),
status: ToolCallStatus::Completed,
}),
},
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
#[test]
fn tool_output_round_trip_preserves_raw_tool_call_id() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "tool_1:output".to_string(),
details: ThreadItemDetails::ToolOutput(ToolOutputItem {
call_id: "tool_1".to_string(),
tool_call_id: Some("tool_call_0".to_string()),
spool_path: None,
output: "done".to_string(),
exit_code: Some(0),
status: ToolCallStatus::Completed,
}),
},
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
#[test]
fn harness_item_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
item: ThreadItem {
id: "harness_1".to_string(),
details: ThreadItemDetails::Harness(HarnessEventItem {
event: HarnessEventKind::VerificationFailed,
message: Some("cargo check failed".to_string()),
command: Some("cargo check".to_string()),
path: None,
exit_code: Some(101),
}),
},
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
#[test]
fn thread_completed_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
thread_id: "thread-1".to_string(),
session_id: "session-1".to_string(),
subtype: ThreadCompletionSubtype::ErrorMaxBudgetUsd,
outcome_code: "budget_limit_reached".to_string(),
result: None,
stop_reason: Some("max_tokens".to_string()),
usage: Usage {
input_tokens: 10,
cached_input_tokens: 4,
cache_creation_tokens: 2,
output_tokens: 5,
},
total_cost_usd: serde_json::Number::from_f64(1.25),
num_turns: 3,
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
#[test]
fn compact_boundary_round_trip() -> Result<(), Box<dyn Error>> {
let event = ThreadEvent::ThreadCompactBoundary(ThreadCompactBoundaryEvent {
thread_id: "thread-1".to_string(),
trigger: CompactionTrigger::Recovery,
mode: CompactionMode::Provider,
original_message_count: 12,
compacted_message_count: 5,
history_artifact_path: Some("/tmp/history.jsonl".to_string()),
});
let json = serde_json::to_string(&event)?;
let restored: ThreadEvent = serde_json::from_str(&json)?;
assert_eq!(restored, event);
Ok(())
}
}