use chrono::{DateTime, Utc};
use hashbrown::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
pub const AGENT_TRACE_VERSION: &str = "0.1.0";
pub const AGENT_TRACE_MIME_TYPE: &str = "application/vnd.agent-trace.record+json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TraceRecord {
pub version: String,
#[serde(
serialize_with = "serialize_uuid",
deserialize_with = "deserialize_uuid"
)]
#[cfg_attr(feature = "schema-export", schemars(with = "String"))]
pub id: uuid::Uuid,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub vcs: Option<VcsInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool: Option<ToolInfo>,
pub files: Vec<TraceFile>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<TraceMetadata>,
}
impl TraceRecord {
pub fn new() -> Self {
Self {
version: AGENT_TRACE_VERSION.to_string(),
id: uuid::Uuid::new_v4(),
timestamp: Utc::now(),
vcs: None,
tool: Some(ToolInfo::vtcode()),
files: Vec::new(),
metadata: None,
}
}
pub fn for_git_revision(revision: impl Into<String>) -> Self {
let mut trace = Self::new();
trace.vcs = Some(VcsInfo::git(revision));
trace
}
pub fn add_file(&mut self, file: TraceFile) {
self.files.push(file);
}
pub fn has_attributions(&self) -> bool {
self.files
.iter()
.any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
}
}
impl Default for TraceRecord {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct VcsInfo {
#[serde(rename = "type")]
pub vcs_type: VcsType,
pub revision: String,
}
impl VcsInfo {
pub fn git(revision: impl Into<String>) -> Self {
Self {
vcs_type: VcsType::Git,
revision: revision.into(),
}
}
pub fn jj(change_id: impl Into<String>) -> Self {
Self {
vcs_type: VcsType::Jj,
revision: change_id.into(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum VcsType {
Git,
Jj,
Hg,
Svn,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct ToolInfo {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
impl ToolInfo {
pub fn vtcode() -> Self {
Self {
name: "vtcode".to_string(),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
Self {
name: name.into(),
version,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TraceFile {
pub path: String,
pub conversations: Vec<TraceConversation>,
}
impl TraceFile {
pub fn new(path: impl Into<String>) -> Self {
Self {
path: path.into(),
conversations: Vec::new(),
}
}
pub fn add_conversation(&mut self, conversation: TraceConversation) {
self.conversations.push(conversation);
}
pub fn with_ai_ranges(
path: impl Into<String>,
model_id: impl Into<String>,
ranges: Vec<TraceRange>,
) -> Self {
let mut file = Self::new(path);
file.add_conversation(TraceConversation {
url: None,
contributor: Some(Contributor::ai(model_id)),
ranges,
related: None,
});
file
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TraceConversation {
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contributor: Option<Contributor>,
pub ranges: Vec<TraceRange>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<Vec<RelatedResource>>,
}
impl TraceConversation {
pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
Self {
url: None,
contributor: Some(Contributor::ai(model_id)),
ranges,
related: None,
}
}
pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
self.url = Some(url.into());
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct RelatedResource {
#[serde(rename = "type")]
pub resource_type: String,
pub url: String,
}
impl RelatedResource {
pub fn session(url: impl Into<String>) -> Self {
Self {
resource_type: "session".to_string(),
url: url.into(),
}
}
pub fn prompt(url: impl Into<String>) -> Self {
Self {
resource_type: "prompt".to_string(),
url: url.into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TraceRange {
pub start_line: u32,
pub end_line: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub content_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contributor: Option<Contributor>,
}
impl TraceRange {
pub fn new(start_line: u32, end_line: u32) -> Self {
Self {
start_line,
end_line,
content_hash: None,
contributor: None,
}
}
pub fn single_line(line: u32) -> Self {
Self::new(line, line)
}
pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
self.content_hash = Some(hash.into());
self
}
pub fn with_content_hash(mut self, content: &str) -> Self {
let hash = compute_content_hash(content);
self.content_hash = Some(hash);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct Contributor {
#[serde(rename = "type")]
pub contributor_type: ContributorType,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_id: Option<String>,
}
impl Contributor {
pub fn ai(model_id: impl Into<String>) -> Self {
Self {
contributor_type: ContributorType::Ai,
model_id: Some(model_id.into()),
}
}
pub fn human() -> Self {
Self {
contributor_type: ContributorType::Human,
model_id: None,
}
}
pub fn mixed() -> Self {
Self {
contributor_type: ContributorType::Mixed,
model_id: None,
}
}
pub fn unknown() -> Self {
Self {
contributor_type: ContributorType::Unknown,
model_id: None,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
pub enum ContributorType {
Human,
Ai,
Mixed,
Unknown,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct TraceMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub confidence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub post_processing_tools: Option<Vec<String>>,
#[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
pub vtcode: Option<VtCodeMetadata>,
#[serde(flatten)]
#[cfg_attr(feature = "schema-export", schemars(skip))]
pub extra: HashMap<String, Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
pub struct VtCodeMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub turn_number: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
}
#[derive(Debug, Clone, Copy, Default)]
pub enum HashAlgorithm {
#[default]
MurmurHash3,
Fnv1a,
}
pub fn compute_content_hash(content: &str) -> String {
compute_content_hash_with(content, HashAlgorithm::default())
}
pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
match algorithm {
HashAlgorithm::MurmurHash3 => {
let hash = murmur3_32(content.as_bytes(), 0);
format!("murmur3:{hash:08x}")
}
HashAlgorithm::Fnv1a => {
const FNV_OFFSET: u64 = 14695981039346656037;
const FNV_PRIME: u64 = 1099511628211;
let mut hash = FNV_OFFSET;
for byte in content.bytes() {
hash ^= byte as u64;
hash = hash.wrapping_mul(FNV_PRIME);
}
format!("fnv1a:{hash:016x}")
}
}
}
fn murmur3_32(data: &[u8], seed: u32) -> u32 {
const C1: u32 = 0xcc9e2d51;
const C2: u32 = 0x1b873593;
const R1: u32 = 15;
const R2: u32 = 13;
const M: u32 = 5;
const N: u32 = 0xe6546b64;
let mut hash = seed;
let len = data.len();
let mut chunks = data.chunks_exact(4);
for chunk in &mut chunks {
let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
k = k.wrapping_mul(C1);
k = k.rotate_left(R1);
k = k.wrapping_mul(C2);
hash ^= k;
hash = hash.rotate_left(R2);
hash = hash.wrapping_mul(M).wrapping_add(N);
}
let tail = chunks.remainder();
let mut k1: u32 = 0;
match tail.len() {
3 => {
k1 ^= (tail[2] as u32) << 16;
k1 ^= (tail[1] as u32) << 8;
k1 ^= tail[0] as u32;
}
2 => {
k1 ^= (tail[1] as u32) << 8;
k1 ^= tail[0] as u32;
}
1 => {
k1 ^= tail[0] as u32;
}
_ => {}
}
if !tail.is_empty() {
k1 = k1.wrapping_mul(C1);
k1 = k1.rotate_left(R1);
k1 = k1.wrapping_mul(C2);
hash ^= k1;
}
hash ^= len as u32;
hash ^= hash >> 16;
hash = hash.wrapping_mul(0x85ebca6b);
hash ^= hash >> 13;
hash = hash.wrapping_mul(0xc2b2ae35);
hash ^= hash >> 16;
hash
}
pub fn normalize_model_id(model: &str, provider: &str) -> String {
if model.contains('/') {
model.to_string()
} else {
format!("{provider}/{model}")
}
}
fn serialize_uuid<S>(uuid: &uuid::Uuid, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&uuid.to_string())
}
fn deserialize_uuid<'de, D>(deserializer: D) -> Result<uuid::Uuid, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
uuid::Uuid::parse_str(&s).map_err(serde::de::Error::custom)
}
#[derive(Debug, Default)]
pub struct TraceRecordBuilder {
vcs: Option<VcsInfo>,
tool: Option<ToolInfo>,
files: Vec<TraceFile>,
metadata: Option<TraceMetadata>,
}
impl TraceRecordBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn vcs(mut self, vcs: VcsInfo) -> Self {
self.vcs = Some(vcs);
self
}
pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
self.vcs = Some(VcsInfo::git(revision));
self
}
pub fn tool(mut self, tool: ToolInfo) -> Self {
self.tool = Some(tool);
self
}
pub fn file(mut self, file: TraceFile) -> Self {
self.files.push(file);
self
}
pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
self.metadata = Some(metadata);
self
}
pub fn build(self) -> TraceRecord {
TraceRecord {
version: AGENT_TRACE_VERSION.to_string(),
id: uuid::Uuid::new_v4(),
timestamp: Utc::now(),
vcs: self.vcs,
tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
files: self.files,
metadata: self.metadata,
}
}
}
#[derive(Debug, Clone)]
pub struct TraceContext {
pub revision: Option<String>,
pub session_id: Option<String>,
pub model_id: String,
pub provider: String,
pub turn_number: Option<u32>,
pub workspace_path: Option<PathBuf>,
}
impl TraceContext {
pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
Self {
revision: None,
session_id: None,
model_id: model_id.into(),
provider: provider.into(),
turn_number: None,
workspace_path: None,
}
}
pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
self.revision = Some(revision.into());
self
}
pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
self.session_id = Some(session_id.into());
self
}
pub fn with_turn_number(mut self, turn: u32) -> Self {
self.turn_number = Some(turn);
self
}
pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
self.workspace_path = Some(path.into());
self
}
pub fn normalized_model_id(&self) -> String {
normalize_model_id(&self.model_id, &self.provider)
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn test_trace_record_creation() {
let trace = TraceRecord::new();
assert_eq!(trace.version, AGENT_TRACE_VERSION);
assert!(trace.tool.is_some());
assert!(trace.files.is_empty());
}
#[test]
fn test_trace_record_for_git() {
let trace = TraceRecord::for_git_revision("abc123");
assert!(trace.vcs.is_some());
let vcs = trace.vcs.as_ref().expect("trace.vcs is None");
assert_eq!(vcs.vcs_type, VcsType::Git);
assert_eq!(vcs.revision, "abc123");
}
#[test]
fn test_contributor_types() {
let ai = Contributor::ai("anthropic/claude-opus-4");
assert_eq!(ai.contributor_type, ContributorType::Ai);
assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
let human = Contributor::human();
assert_eq!(human.contributor_type, ContributorType::Human);
assert!(human.model_id.is_none());
}
#[test]
fn test_trace_range() {
let range = TraceRange::new(10, 25);
assert_eq!(range.start_line, 10);
assert_eq!(range.end_line, 25);
let range_with_hash = range.with_content_hash("hello world");
assert!(range_with_hash.content_hash.is_some());
assert!(
range_with_hash
.content_hash
.unwrap()
.starts_with("murmur3:")
);
}
#[test]
fn test_hash_algorithms() {
let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
assert!(murmur.starts_with("murmur3:"));
let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
assert!(fnv.starts_with("fnv1a:"));
let default_hash = compute_content_hash("hello world");
assert_eq!(default_hash, murmur);
}
#[test]
fn test_trace_file_builder() {
let file = TraceFile::with_ai_ranges(
"src/main.rs",
"anthropic/claude-opus-4",
vec![TraceRange::new(1, 50)],
);
assert_eq!(file.path, "src/main.rs");
assert_eq!(file.conversations.len(), 1);
}
#[test]
fn test_normalize_model_id() {
assert_eq!(
normalize_model_id("claude-3-opus", "anthropic"),
"anthropic/claude-3-opus"
);
assert_eq!(
normalize_model_id("anthropic/claude-3-opus", "anthropic"),
"anthropic/claude-3-opus"
);
}
#[test]
fn test_trace_record_builder() {
let trace = TraceRecordBuilder::new()
.git_revision("abc123def456")
.file(TraceFile::with_ai_ranges(
"src/lib.rs",
"openai/gpt-5",
vec![TraceRange::new(10, 20)],
))
.build();
assert!(trace.vcs.is_some());
assert_eq!(trace.files.len(), 1);
assert!(trace.has_attributions());
}
#[test]
fn test_trace_serialization() {
let trace = TraceRecord::for_git_revision("abc123");
let json = serde_json::to_string_pretty(&trace).expect("Failed to serialize trace to JSON");
assert!(json.contains("\"version\": \"0.1.0\""));
assert!(json.contains("abc123"));
let restored: TraceRecord =
serde_json::from_str(&json).expect("Failed to deserialize trace from JSON");
assert_eq!(restored.version, trace.version);
}
#[test]
fn test_content_hash_consistency() {
let hash1 = compute_content_hash("hello world");
let hash2 = compute_content_hash("hello world");
assert_eq!(hash1, hash2);
let hash3 = compute_content_hash("hello world!");
assert_ne!(hash1, hash3);
let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
assert_eq!(fnv1, fnv2);
}
}