1use chrono::{DateTime, Utc};
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42use std::collections::HashMap;
43use std::path::PathBuf;
44
45pub const AGENT_TRACE_VERSION: &str = "0.1.0";
47
48pub const AGENT_TRACE_MIME_TYPE: &str = "application/vnd.agent-trace.record+json";
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
61pub struct TraceRecord {
62 pub version: String,
64
65 #[serde(with = "uuid_serde")]
67 pub id: uuid::Uuid,
68
69 pub timestamp: DateTime<Utc>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub vcs: Option<VcsInfo>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub tool: Option<ToolInfo>,
79
80 pub files: Vec<TraceFile>,
82
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub metadata: Option<TraceMetadata>,
86}
87
88impl TraceRecord {
89 pub fn new() -> Self {
91 Self {
92 version: AGENT_TRACE_VERSION.to_string(),
93 id: uuid::Uuid::new_v4(),
94 timestamp: Utc::now(),
95 vcs: None,
96 tool: Some(ToolInfo::vtcode()),
97 files: Vec::new(),
98 metadata: None,
99 }
100 }
101
102 pub fn for_git_revision(revision: impl Into<String>) -> Self {
104 let mut trace = Self::new();
105 trace.vcs = Some(VcsInfo::git(revision));
106 trace
107 }
108
109 pub fn add_file(&mut self, file: TraceFile) {
111 self.files.push(file);
112 }
113
114 pub fn has_attributions(&self) -> bool {
116 self.files
117 .iter()
118 .any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
119 }
120}
121
122impl Default for TraceRecord {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
135pub struct VcsInfo {
136 #[serde(rename = "type")]
138 pub vcs_type: VcsType,
139
140 pub revision: String,
142}
143
144impl VcsInfo {
145 pub fn git(revision: impl Into<String>) -> Self {
147 Self {
148 vcs_type: VcsType::Git,
149 revision: revision.into(),
150 }
151 }
152
153 pub fn jj(change_id: impl Into<String>) -> Self {
155 Self {
156 vcs_type: VcsType::Jj,
157 revision: change_id.into(),
158 }
159 }
160}
161
162#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
164#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
165#[serde(rename_all = "lowercase")]
166pub enum VcsType {
167 Git,
169 Jj,
171 Hg,
173 Svn,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
183#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
184pub struct ToolInfo {
185 pub name: String,
187
188 #[serde(skip_serializing_if = "Option::is_none")]
190 pub version: Option<String>,
191}
192
193impl ToolInfo {
194 pub fn vtcode() -> Self {
196 Self {
197 name: "vtcode".to_string(),
198 version: Some(env!("CARGO_PKG_VERSION").to_string()),
199 }
200 }
201
202 pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
204 Self {
205 name: name.into(),
206 version,
207 }
208 }
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
217#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
218pub struct TraceFile {
219 pub path: String,
221
222 pub conversations: Vec<TraceConversation>,
224}
225
226impl TraceFile {
227 pub fn new(path: impl Into<String>) -> Self {
229 Self {
230 path: path.into(),
231 conversations: Vec::new(),
232 }
233 }
234
235 pub fn add_conversation(&mut self, conversation: TraceConversation) {
237 self.conversations.push(conversation);
238 }
239
240 pub fn with_ai_ranges(
242 path: impl Into<String>,
243 model_id: impl Into<String>,
244 ranges: Vec<TraceRange>,
245 ) -> Self {
246 let mut file = Self::new(path);
247 file.add_conversation(TraceConversation {
248 url: None,
249 contributor: Some(Contributor::ai(model_id)),
250 ranges,
251 related: None,
252 });
253 file
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
260pub struct TraceConversation {
261 #[serde(skip_serializing_if = "Option::is_none")]
263 pub url: Option<String>,
264
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub contributor: Option<Contributor>,
268
269 pub ranges: Vec<TraceRange>,
271
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub related: Option<Vec<RelatedResource>>,
275}
276
277impl TraceConversation {
278 pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
280 Self {
281 url: None,
282 contributor: Some(Contributor::ai(model_id)),
283 ranges,
284 related: None,
285 }
286 }
287
288 pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
290 self.url = Some(url.into());
291 self
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
297#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
298pub struct RelatedResource {
299 #[serde(rename = "type")]
301 pub resource_type: String,
302
303 pub url: String,
305}
306
307impl RelatedResource {
308 pub fn session(url: impl Into<String>) -> Self {
310 Self {
311 resource_type: "session".to_string(),
312 url: url.into(),
313 }
314 }
315
316 pub fn prompt(url: impl Into<String>) -> Self {
318 Self {
319 resource_type: "prompt".to_string(),
320 url: url.into(),
321 }
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
332pub struct TraceRange {
333 pub start_line: u32,
335
336 pub end_line: u32,
338
339 #[serde(skip_serializing_if = "Option::is_none")]
341 pub content_hash: Option<String>,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub contributor: Option<Contributor>,
346}
347
348impl TraceRange {
349 pub fn new(start_line: u32, end_line: u32) -> Self {
351 Self {
352 start_line,
353 end_line,
354 content_hash: None,
355 contributor: None,
356 }
357 }
358
359 pub fn single_line(line: u32) -> Self {
361 Self::new(line, line)
362 }
363
364 pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
366 self.content_hash = Some(hash.into());
367 self
368 }
369
370 pub fn with_content_hash(mut self, content: &str) -> Self {
372 let hash = compute_content_hash(content);
373 self.content_hash = Some(hash);
374 self
375 }
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
384#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
385pub struct Contributor {
386 #[serde(rename = "type")]
388 pub contributor_type: ContributorType,
389
390 #[serde(skip_serializing_if = "Option::is_none")]
392 pub model_id: Option<String>,
393}
394
395impl Contributor {
396 pub fn ai(model_id: impl Into<String>) -> Self {
398 Self {
399 contributor_type: ContributorType::Ai,
400 model_id: Some(model_id.into()),
401 }
402 }
403
404 pub fn human() -> Self {
406 Self {
407 contributor_type: ContributorType::Human,
408 model_id: None,
409 }
410 }
411
412 pub fn mixed() -> Self {
414 Self {
415 contributor_type: ContributorType::Mixed,
416 model_id: None,
417 }
418 }
419
420 pub fn unknown() -> Self {
422 Self {
423 contributor_type: ContributorType::Unknown,
424 model_id: None,
425 }
426 }
427}
428
429#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
431#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
432#[serde(rename_all = "lowercase")]
433pub enum ContributorType {
434 Human,
436 Ai,
438 Mixed,
440 Unknown,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
450#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
451pub struct TraceMetadata {
452 #[serde(skip_serializing_if = "Option::is_none")]
454 pub confidence: Option<f64>,
455
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub post_processing_tools: Option<Vec<String>>,
459
460 #[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
462 pub vtcode: Option<VtCodeMetadata>,
463
464 #[serde(flatten)]
466 pub extra: HashMap<String, Value>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
472pub struct VtCodeMetadata {
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub session_id: Option<String>,
476
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub turn_number: Option<u32>,
480
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub workspace_path: Option<String>,
484
485 #[serde(skip_serializing_if = "Option::is_none")]
487 pub provider: Option<String>,
488}
489
490#[derive(Debug, Clone, Copy, Default)]
496pub enum HashAlgorithm {
497 #[default]
499 MurmurHash3,
500 Fnv1a,
502}
503
504pub fn compute_content_hash(content: &str) -> String {
508 compute_content_hash_with(content, HashAlgorithm::default())
509}
510
511pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
513 match algorithm {
514 HashAlgorithm::MurmurHash3 => {
515 let hash = murmur3_32(content.as_bytes(), 0);
517 format!("murmur3:{hash:08x}")
518 }
519 HashAlgorithm::Fnv1a => {
520 const FNV_OFFSET: u64 = 14695981039346656037;
521 const FNV_PRIME: u64 = 1099511628211;
522 let mut hash = FNV_OFFSET;
523 for byte in content.bytes() {
524 hash ^= byte as u64;
525 hash = hash.wrapping_mul(FNV_PRIME);
526 }
527 format!("fnv1a:{hash:016x}")
528 }
529 }
530}
531
532fn murmur3_32(data: &[u8], seed: u32) -> u32 {
534 const C1: u32 = 0xcc9e2d51;
535 const C2: u32 = 0x1b873593;
536 const R1: u32 = 15;
537 const R2: u32 = 13;
538 const M: u32 = 5;
539 const N: u32 = 0xe6546b64;
540
541 let mut hash = seed;
542 let len = data.len();
543 let chunks = len / 4;
544
545 for i in 0..chunks {
547 let idx = i * 4;
548 let mut k = u32::from_le_bytes([data[idx], data[idx + 1], data[idx + 2], data[idx + 3]]);
549 k = k.wrapping_mul(C1);
550 k = k.rotate_left(R1);
551 k = k.wrapping_mul(C2);
552 hash ^= k;
553 hash = hash.rotate_left(R2);
554 hash = hash.wrapping_mul(M).wrapping_add(N);
555 }
556
557 let tail = &data[chunks * 4..];
559 let mut k1: u32 = 0;
560 match tail.len() {
561 3 => {
562 k1 ^= (tail[2] as u32) << 16;
563 k1 ^= (tail[1] as u32) << 8;
564 k1 ^= tail[0] as u32;
565 }
566 2 => {
567 k1 ^= (tail[1] as u32) << 8;
568 k1 ^= tail[0] as u32;
569 }
570 1 => {
571 k1 ^= tail[0] as u32;
572 }
573 _ => {}
574 }
575 if !tail.is_empty() {
576 k1 = k1.wrapping_mul(C1);
577 k1 = k1.rotate_left(R1);
578 k1 = k1.wrapping_mul(C2);
579 hash ^= k1;
580 }
581
582 hash ^= len as u32;
584 hash ^= hash >> 16;
585 hash = hash.wrapping_mul(0x85ebca6b);
586 hash ^= hash >> 13;
587 hash = hash.wrapping_mul(0xc2b2ae35);
588 hash ^= hash >> 16;
589
590 hash
591}
592
593pub fn normalize_model_id(model: &str, provider: &str) -> String {
605 if model.contains('/') {
606 model.to_string()
607 } else {
608 format!("{provider}/{model}")
609 }
610}
611
612mod uuid_serde {
617 use serde::{self, Deserialize, Deserializer, Serializer};
618 use uuid::Uuid;
619
620 pub fn serialize<S>(uuid: &Uuid, serializer: S) -> Result<S::Ok, S::Error>
621 where
622 S: Serializer,
623 {
624 serializer.serialize_str(&uuid.to_string())
625 }
626
627 pub fn deserialize<'de, D>(deserializer: D) -> Result<Uuid, D::Error>
628 where
629 D: Deserializer<'de>,
630 {
631 let s = String::deserialize(deserializer)?;
632 Uuid::parse_str(&s).map_err(serde::de::Error::custom)
633 }
634}
635
636#[derive(Debug, Default)]
642pub struct TraceRecordBuilder {
643 vcs: Option<VcsInfo>,
644 tool: Option<ToolInfo>,
645 files: Vec<TraceFile>,
646 metadata: Option<TraceMetadata>,
647}
648
649impl TraceRecordBuilder {
650 pub fn new() -> Self {
652 Self::default()
653 }
654
655 pub fn vcs(mut self, vcs: VcsInfo) -> Self {
657 self.vcs = Some(vcs);
658 self
659 }
660
661 pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
663 self.vcs = Some(VcsInfo::git(revision));
664 self
665 }
666
667 pub fn tool(mut self, tool: ToolInfo) -> Self {
669 self.tool = Some(tool);
670 self
671 }
672
673 pub fn file(mut self, file: TraceFile) -> Self {
675 self.files.push(file);
676 self
677 }
678
679 pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
681 self.metadata = Some(metadata);
682 self
683 }
684
685 pub fn build(self) -> TraceRecord {
687 TraceRecord {
688 version: AGENT_TRACE_VERSION.to_string(),
689 id: uuid::Uuid::new_v4(),
690 timestamp: Utc::now(),
691 vcs: self.vcs,
692 tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
693 files: self.files,
694 metadata: self.metadata,
695 }
696 }
697}
698
699#[derive(Debug, Clone)]
705pub struct TraceContext {
706 pub revision: Option<String>,
708 pub session_id: Option<String>,
710 pub model_id: String,
712 pub provider: String,
714 pub turn_number: Option<u32>,
716 pub workspace_path: Option<PathBuf>,
718}
719
720impl TraceContext {
721 pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
723 Self {
724 revision: None,
725 session_id: None,
726 model_id: model_id.into(),
727 provider: provider.into(),
728 turn_number: None,
729 workspace_path: None,
730 }
731 }
732
733 pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
735 self.revision = Some(revision.into());
736 self
737 }
738
739 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
741 self.session_id = Some(session_id.into());
742 self
743 }
744
745 pub fn with_turn_number(mut self, turn: u32) -> Self {
747 self.turn_number = Some(turn);
748 self
749 }
750
751 pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
753 self.workspace_path = Some(path.into());
754 self
755 }
756
757 pub fn normalized_model_id(&self) -> String {
759 normalize_model_id(&self.model_id, &self.provider)
760 }
761}
762
763#[cfg(test)]
764mod tests {
765 use super::*;
766
767 #[test]
768 fn test_trace_record_creation() {
769 let trace = TraceRecord::new();
770 assert_eq!(trace.version, AGENT_TRACE_VERSION);
771 assert!(trace.tool.is_some());
772 assert!(trace.files.is_empty());
773 }
774
775 #[test]
776 fn test_trace_record_for_git() {
777 let trace = TraceRecord::for_git_revision("abc123");
778 assert!(trace.vcs.is_some());
779 let vcs = trace.vcs.unwrap();
780 assert_eq!(vcs.vcs_type, VcsType::Git);
781 assert_eq!(vcs.revision, "abc123");
782 }
783
784 #[test]
785 fn test_contributor_types() {
786 let ai = Contributor::ai("anthropic/claude-opus-4");
787 assert_eq!(ai.contributor_type, ContributorType::Ai);
788 assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
789
790 let human = Contributor::human();
791 assert_eq!(human.contributor_type, ContributorType::Human);
792 assert!(human.model_id.is_none());
793 }
794
795 #[test]
796 fn test_trace_range() {
797 let range = TraceRange::new(10, 25);
798 assert_eq!(range.start_line, 10);
799 assert_eq!(range.end_line, 25);
800
801 let range_with_hash = range.with_content_hash("hello world");
802 assert!(range_with_hash.content_hash.is_some());
803 assert!(range_with_hash.content_hash.unwrap().starts_with("murmur3:"));
805 }
806
807 #[test]
808 fn test_hash_algorithms() {
809 let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
810 assert!(murmur.starts_with("murmur3:"));
811
812 let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
813 assert!(fnv.starts_with("fnv1a:"));
814
815 let default_hash = compute_content_hash("hello world");
817 assert_eq!(default_hash, murmur);
818 }
819
820 #[test]
821 fn test_trace_file_builder() {
822 let file = TraceFile::with_ai_ranges(
823 "src/main.rs",
824 "anthropic/claude-opus-4",
825 vec![TraceRange::new(1, 50)],
826 );
827 assert_eq!(file.path, "src/main.rs");
828 assert_eq!(file.conversations.len(), 1);
829 }
830
831 #[test]
832 fn test_normalize_model_id() {
833 assert_eq!(
834 normalize_model_id("claude-3-opus", "anthropic"),
835 "anthropic/claude-3-opus"
836 );
837 assert_eq!(
838 normalize_model_id("anthropic/claude-3-opus", "anthropic"),
839 "anthropic/claude-3-opus"
840 );
841 }
842
843 #[test]
844 fn test_trace_record_builder() {
845 let trace = TraceRecordBuilder::new()
846 .git_revision("abc123def456")
847 .file(TraceFile::with_ai_ranges(
848 "src/lib.rs",
849 "openai/gpt-4o",
850 vec![TraceRange::new(10, 20)],
851 ))
852 .build();
853
854 assert!(trace.vcs.is_some());
855 assert_eq!(trace.files.len(), 1);
856 assert!(trace.has_attributions());
857 }
858
859 #[test]
860 fn test_trace_serialization() {
861 let trace = TraceRecord::for_git_revision("abc123");
862 let json = serde_json::to_string_pretty(&trace).unwrap();
863 assert!(json.contains("\"version\": \"0.1.0\""));
864 assert!(json.contains("abc123"));
865
866 let restored: TraceRecord = serde_json::from_str(&json).unwrap();
867 assert_eq!(restored.version, trace.version);
868 }
869
870 #[test]
871 fn test_content_hash_consistency() {
872 let hash1 = compute_content_hash("hello world");
874 let hash2 = compute_content_hash("hello world");
875 assert_eq!(hash1, hash2);
876
877 let hash3 = compute_content_hash("hello world!");
878 assert_ne!(hash1, hash3);
879
880 let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
882 let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
883 assert_eq!(fnv1, fnv2);
884 }
885}