Skip to main content

vtcode_exec_events/atif/
mod.rs

1//! Agent Trajectory Interchange Format (ATIF) types and builder.
2//!
3//! Implements the [ATIF specification](https://github.com/laude-institute/harbor/blob/main/docs/rfcs/0001-trajectory-format.md)
4//! v1.4 for logging complete agent interaction histories in a standardized,
5//! JSON-based format usable across debugging, visualization, SFT, and RL
6//! pipelines.
7//!
8//! # Overview
9//!
10//! ATIF provides a complete session trajectory: user messages, agent responses,
11//! tool executions, observations, and per-step/aggregate LLM metrics. The
12//! [`AtifTrajectoryBuilder`] converts live [`ThreadEvent`]
13//! streams into a finished [`Trajectory`].
14//!
15//! # Example
16//!
17//! ```rust
18//! use vtcode_exec_events::atif::*;
19//!
20//! let agent = AtifAgent::new("vtcode", env!("CARGO_PKG_VERSION"));
21//! let mut builder = AtifTrajectoryBuilder::new(agent);
22//!
23//! // Feed ThreadEvents as they arrive …
24//! // builder.process_event(&event);
25//!
26//! let trajectory = builder.finish(None);
27//! let json = serde_json::to_string_pretty(&trajectory).unwrap();
28//! ```
29
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::{ThreadEvent, ThreadItemDetails, ToolCallStatus};
35
36/// Current ATIF schema version supported by this implementation.
37const ATIF_SCHEMA_VERSION: &str = "ATIF-v1.4";
38
39// ============================================================================
40// Core ATIF Types
41// ============================================================================
42
43/// Root-level ATIF trajectory object.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Trajectory {
46    /// ATIF schema version (e.g., "ATIF-v1.4").
47    schema_version: String,
48    /// Unique identifier for the entire agent run.
49    session_id: String,
50    /// Agent configuration for this trajectory.
51    agent: AtifAgent,
52    /// Ordered interaction steps.
53    steps: Vec<Step>,
54    /// Optional developer notes.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    notes: Option<String>,
57    /// Aggregate metrics for the full trajectory.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub final_metrics: Option<FinalMetrics>,
60    /// Optional custom root-level metadata.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    extra: Option<Value>,
63}
64
65/// Agent configuration metadata.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AtifAgent {
68    /// Agent system name (e.g., "vtcode").
69    name: String,
70    /// Agent system version.
71    version: String,
72    /// Default LLM model used. Step-level `model_name` overrides this.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    model_name: Option<String>,
75    /// Optional custom agent metadata.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    extra: Option<Value>,
78}
79
80impl AtifAgent {
81    /// Create a new agent descriptor.
82    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
83        Self {
84            name: name.into(),
85            version: version.into(),
86            model_name: None,
87            extra: None,
88        }
89    }
90
91    /// Create a vtcode agent descriptor using the crate version.
92    pub fn vtcode() -> Self {
93        Self::new("vtcode", env!("CARGO_PKG_VERSION"))
94    }
95
96    /// Set the default model name.
97    pub fn with_model(mut self, model: impl Into<String>) -> Self {
98        self.model_name = Some(model.into());
99        self
100    }
101}
102
103/// The originator of a step.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum StepSource {
107    /// System prompt or system-initiated operation.
108    System,
109    /// User message.
110    User,
111    /// Agent response.
112    Agent,
113}
114
115/// Individual interaction step.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Step {
118    /// Ordinal index (starting from 1).
119    step_id: u64,
120    /// ISO 8601 timestamp.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    timestamp: Option<String>,
123    /// Originator of this step.
124    source: StepSource,
125    /// LLM model used for this step (agent steps only).
126    #[serde(skip_serializing_if = "Option::is_none")]
127    model_name: Option<String>,
128    /// Step content — text message or array.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    message: Option<String>,
131    /// Agent internal reasoning content.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    reasoning_content: Option<String>,
134    /// Tool/function invocations (agent steps only).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    tool_calls: Option<Vec<AtifToolCall>>,
137    /// Environment feedback after actions.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    observation: Option<Observation>,
140    /// LLM operational metrics (agent steps only).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    metrics: Option<StepMetrics>,
143    /// Custom step-level metadata.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    extra: Option<Value>,
146}
147
148impl Step {
149    /// Create a user step.
150    fn user(step_id: u64, message: impl Into<String>) -> Self {
151        Self {
152            step_id,
153            timestamp: Some(Utc::now().to_rfc3339()),
154            source: StepSource::User,
155            model_name: None,
156            message: Some(message.into()),
157            reasoning_content: None,
158            tool_calls: None,
159            observation: None,
160            metrics: None,
161            extra: None,
162        }
163    }
164
165    /// Create an agent step.
166    fn agent(step_id: u64, message: impl Into<String>) -> Self {
167        Self {
168            step_id,
169            timestamp: Some(Utc::now().to_rfc3339()),
170            source: StepSource::Agent,
171            model_name: None,
172            message: Some(message.into()),
173            reasoning_content: None,
174            tool_calls: None,
175            observation: None,
176            metrics: None,
177            extra: None,
178        }
179    }
180
181    /// Create a system step.
182    fn system(step_id: u64, message: impl Into<String>) -> Self {
183        Self {
184            step_id,
185            timestamp: Some(Utc::now().to_rfc3339()),
186            source: StepSource::System,
187            model_name: None,
188            message: Some(message.into()),
189            reasoning_content: None,
190            tool_calls: None,
191            observation: None,
192            metrics: None,
193            extra: None,
194        }
195    }
196}
197
198/// Structured tool/function invocation.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct AtifToolCall {
201    /// Unique identifier for the tool call.
202    tool_call_id: String,
203    /// Function/tool name.
204    function_name: String,
205    /// Arguments passed to the tool.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    arguments: Option<Value>,
208}
209
210/// Environment feedback container.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Observation {
213    /// Results from tool calls or system operations.
214    results: Vec<ObservationResult>,
215}
216
217/// Individual observation result tied to a tool call.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ObservationResult {
220    /// Identifier of the originating tool call.
221    source_call_id: String,
222    /// Content/output of the observation.
223    content: String,
224}
225
226/// Per-step LLM operational metrics.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct StepMetrics {
229    /// Total input tokens for this step (cached + non-cached).
230    #[serde(skip_serializing_if = "Option::is_none")]
231    prompt_tokens: Option<u64>,
232    /// Completion tokens generated.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    completion_tokens: Option<u64>,
235    /// Subset of prompt_tokens that were cache hits.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    cached_tokens: Option<u64>,
238    /// Estimated cost in USD for this step.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    cost_usd: Option<f64>,
241    /// Log probabilities for completion tokens.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    logprobs: Option<Vec<f64>>,
244    /// Completion token IDs for RL training.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    completion_token_ids: Option<Vec<u64>>,
247    /// Prompt token IDs.
248    #[serde(skip_serializing_if = "Option::is_none")]
249    prompt_token_ids: Option<Vec<u64>>,
250    /// Custom metrics.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    extra: Option<Value>,
253}
254
255impl StepMetrics {
256    /// Create metrics from vtcode Usage.
257    fn from_usage(usage: &crate::Usage) -> Self {
258        Self {
259            prompt_tokens: Some(usage.input_tokens),
260            completion_tokens: Some(usage.output_tokens),
261            cached_tokens: if usage.cached_input_tokens > 0 {
262                Some(usage.cached_input_tokens)
263            } else {
264                None
265            },
266            cost_usd: None,
267            logprobs: None,
268            completion_token_ids: None,
269            prompt_token_ids: None,
270            extra: if usage.cache_creation_tokens > 0 {
271                Some(serde_json::json!({
272                    "cache_creation_tokens": usage.cache_creation_tokens
273                }))
274            } else {
275                None
276            },
277        }
278    }
279}
280
281/// Trajectory-level aggregate metrics.
282#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct FinalMetrics {
284    /// Sum of all prompt tokens across steps.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub total_prompt_tokens: Option<u64>,
287    /// Sum of all completion tokens across steps.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub total_completion_tokens: Option<u64>,
290    /// Sum of all cached tokens across steps.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub total_cached_tokens: Option<u64>,
293    /// Total estimated cost in USD.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    total_cost_usd: Option<f64>,
296    /// Total number of steps.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    total_steps: Option<u64>,
299    /// Custom aggregate metrics.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    extra: Option<Value>,
302}
303
304// ============================================================================
305// Builder — converts live ThreadEvent streams into ATIF Trajectory
306// ============================================================================
307
308/// Stateful collector that converts a live [`ThreadEvent`] stream into an
309/// ATIF-compliant [`Trajectory`].
310///
311/// Feed events via [`process_event`](Self::process_event) (timestamps at
312/// observation time) or [`process_event_at`](Self::process_event_at)
313/// (deterministic timestamps for tests). Call [`finish`](Self::finish) to
314/// produce the final trajectory.
315#[must_use = "finish the trajectory before discarding the builder"]
316pub struct AtifTrajectoryBuilder {
317    completed_at: Option<String>,
318    agent: AtifAgent,
319    session_id: Option<String>,
320    steps: Vec<Step>,
321    next_step_id: u64,
322    // Running token accumulators for final metrics
323    total_input_tokens: u64,
324    total_output_tokens: u64,
325    total_cached_tokens: u64,
326    num_turns: usize,
327    /// Whether any per-turn `TurnCompleted`/`TurnFailed` usage was observed.
328    /// Guards the `ThreadCompleted` aggregate from double-counting usage.
329    saw_per_turn_usage: bool,
330    /// Pending tool invocations awaiting matching ToolOutput.
331    pending_tool_calls: Vec<PendingToolCall>,
332}
333
334struct PendingToolCall {
335    call_id: String,
336    tool_call_id: Option<String>,
337    tool_name: String,
338    arguments: Option<Value>,
339    timestamp: String,
340}
341
342impl AtifTrajectoryBuilder {
343    /// Create a new builder for the given agent.
344    pub fn new(agent: AtifAgent) -> Self {
345        Self {
346            agent,
347            completed_at: None,
348            session_id: None,
349            steps: Vec::new(),
350            next_step_id: 1,
351            total_input_tokens: 0,
352            total_output_tokens: 0,
353            total_cached_tokens: 0,
354            num_turns: 0,
355            saw_per_turn_usage: false,
356            pending_tool_calls: Vec::new(),
357        }
358    }
359
360    /// Set the session ID explicitly. If not set, it will be derived from
361    /// `ThreadStarted` or `ThreadCompleted` events.
362    pub fn set_session_id(&mut self, id: impl Into<String>) {
363        self.session_id = Some(id.into());
364    }
365
366    /// Process a thread event using the current wall-clock time.
367    pub fn process_event(&mut self, event: &ThreadEvent) {
368        self.process_event_at(event, Utc::now());
369    }
370
371    /// Process a thread event with an explicit timestamp (for deterministic tests).
372    pub fn process_event_at(&mut self, event: &ThreadEvent, ts: DateTime<Utc>) {
373        let first_step = self.steps.len();
374        let ts_str = ts.to_rfc3339();
375        match event {
376            ThreadEvent::ThreadStarted(e) => {
377                if self.session_id.is_none() {
378                    self.session_id = Some(e.thread_id.clone());
379                }
380            }
381            ThreadEvent::ThreadCompleted(e) => {
382                self.completed_at = e.completed_at.clone();
383                if self.session_id.is_none() {
384                    self.session_id = Some(e.session_id.clone());
385                }
386                self.num_turns = e.num_turns;
387                // `ThreadCompleted` carries the *aggregate* usage, which equals
388                // the sum of per-turn usage already accumulated by the
389                // `TurnCompleted`/`TurnFailed` arms. Only fall back to it when
390                // no per-turn usage was observed (e.g. a harness that emits
391                // only the thread aggregate), otherwise totals double-count.
392                if !self.saw_per_turn_usage {
393                    self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
394                    self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
395                    self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
396                }
397            }
398            ThreadEvent::TurnCompleted(e) => {
399                self.saw_per_turn_usage = true;
400                self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
401                self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
402                self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
403                self.num_turns += 1;
404
405                let mut step = Step::system(self.next_step_id, "turn_completed");
406                step.timestamp = Some(ts_str);
407                step.metrics = Some(StepMetrics::from_usage(&e.usage));
408                if !e.in_progress_exec_sessions.is_empty() {
409                    step.extra = Some(serde_json::json!({
410                        "in_progress_exec_sessions": e.in_progress_exec_sessions,
411                    }));
412                }
413                self.push_step(step);
414            }
415            ThreadEvent::TurnFailed(e) => {
416                if let Some(usage) = &e.usage {
417                    self.saw_per_turn_usage = true;
418                    self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
419                    self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
420                    self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
421                }
422                let mut step = Step::system(self.next_step_id, &e.message);
423                step.timestamp = Some(ts_str);
424                step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
425                self.push_step(step);
426            }
427            ThreadEvent::TurnBlocked(e) => {
428                if let Some(usage) = &e.usage {
429                    self.saw_per_turn_usage = true;
430                    self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
431                    self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
432                    self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
433                }
434                let mut step = Step::system(self.next_step_id, &e.message);
435                step.timestamp = Some(ts_str);
436                step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
437                step.extra = Some(serde_json::json!({
438                    "last_tool": e.last_tool,
439                    "blocked_streak": e.blocked_streak,
440                    "blocked_total": e.blocked_total,
441                    "consecutive_cap": e.consecutive_cap,
442                    "total_cap": e.total_cap,
443                    "recovery_active": e.recovery_active,
444                }));
445                self.push_step(step);
446            }
447            ThreadEvent::ItemCompleted(e) => {
448                self.process_item_completed(&e.item.id, &e.item.details, &ts_str);
449            }
450            ThreadEvent::AppSandboxDenied(e) => {
451                let mut step = Step::system(
452                    self.next_step_id,
453                    format!("app_sandbox_denied: {} refused {}", e.tool, e.uris.join(", ")),
454                );
455                step.timestamp = Some(ts_str);
456                step.extra = Some(serde_json::json!({
457                    "tool": e.tool,
458                    "tier": e.tier,
459                    "uris": e.uris,
460                }));
461                self.push_step(step);
462            }
463            ThreadEvent::ThreadCompactBoundary(e) => {
464                let msg = format!(
465                    "context_compaction: {} messages -> {} messages ({})",
466                    e.original_message_count,
467                    e.compacted_message_count,
468                    e.trigger.as_str()
469                );
470                let mut step = Step::system(self.next_step_id, msg);
471                step.timestamp = Some(ts_str);
472                self.push_step(step);
473            }
474            ThreadEvent::ContextReset(e) => {
475                let msg = format!(
476                    "context_reset: {}% context used; plan preserved: {}; tool budget reset: {}",
477                    e.previous_context_usage_percent, e.plan_preserved, e.tool_budget_reset
478                );
479                let mut step = Step::system(self.next_step_id, msg);
480                step.timestamp = Some(ts_str);
481                step.extra = Some(serde_json::json!({
482                    "thread_id": e.thread_id,
483                    "turn_id": e.turn_id,
484                    "trigger": e.trigger,
485                    "plan_preserved": e.plan_preserved,
486                    "previous_context_usage_percent": e.previous_context_usage_percent,
487                    "tool_budget_reset": e.tool_budget_reset,
488                }));
489                self.push_step(step);
490            }
491            ThreadEvent::Error(e) => {
492                let mut step = Step::system(self.next_step_id, &e.message);
493                step.timestamp = Some(ts_str);
494                self.push_step(step);
495            }
496            // Skip streaming/lifecycle events that don't map to ATIF steps
497            ThreadEvent::TurnStarted(e) => {
498                if let Some(context) = &e.context {
499                    let mut step = Step::agent(self.next_step_id, context.goal.as_deref().unwrap_or("Turn started"));
500                    step.source = StepSource::User;
501                    step.timestamp = Some(context.timestamp.clone());
502                    step.extra = Some(serde_json::json!({"execution_context": context}));
503                    self.push_step(step);
504                }
505            }
506            ThreadEvent::MatrixUpdated(_)
507            | ThreadEvent::ItemStarted(_)
508            | ThreadEvent::ItemUpdated(_)
509            | ThreadEvent::PlanDelta(_)
510            | ThreadEvent::PlanApprovalRequested(_)
511            | ThreadEvent::PlanApprovalResolved(_)
512            | ThreadEvent::PermissionRequested(_)
513            | ThreadEvent::PermissionResolved(_)
514            | ThreadEvent::Interjected(_)
515            | ThreadEvent::Unknown => {}
516        }
517        for step in self.steps.iter_mut().skip(first_step) {
518            let context = match event {
519                ThreadEvent::ItemCompleted(e) => e.item.context.as_deref(),
520                _ => None,
521            };
522            if let Some(context) = context {
523                step.timestamp = Some(context.timestamp.clone());
524                let extra = step.extra.get_or_insert_with(|| serde_json::json!({}));
525                if let Some(extra) = extra.as_object_mut() {
526                    let _ = extra.insert("item_context".into(), serde_json::json!(context));
527                }
528            }
529            match event {
530                ThreadEvent::TurnCompleted(e) => {
531                    if let Some(ts) = &e.completed_at {
532                        step.timestamp = Some(ts.to_string());
533                    }
534                }
535                ThreadEvent::TurnFailed(e) => {
536                    if let Some(ts) = &e.completed_at {
537                        step.timestamp = Some(ts.to_string());
538                    }
539                }
540                ThreadEvent::TurnBlocked(e) => {
541                    if let Some(ts) = &e.completed_at {
542                        step.timestamp = Some(ts.clone());
543                    }
544                }
545                _ => {}
546            }
547        }
548    }
549
550    fn process_item_completed(&mut self, item_id: &str, details: &ThreadItemDetails, ts: &str) {
551        match details {
552            ThreadItemDetails::Decision(d) => {
553                let mut step = Step::agent(self.next_step_id, &d.summary);
554                step.timestamp = Some(ts.to_owned());
555                step.extra = Some(
556                    serde_json::json!({"vtcode_item_type": "decision", "public_rationale": d.rationale, "alternatives": d.alternatives, "evidence_ids": d.evidence_ids}),
557                );
558                self.push_step(step);
559            }
560            ThreadItemDetails::AgentMessage(msg) => {
561                let mut step = Step::agent(self.next_step_id, &msg.text);
562                step.timestamp = Some(ts.to_string());
563                self.push_step(step);
564            }
565            ThreadItemDetails::Plan(plan) => {
566                let mut step = Step::agent(self.next_step_id, &plan.text);
567                step.timestamp = Some(ts.to_string());
568                step.extra = Some(serde_json::json!({ "vtcode_item_type": "plan" }));
569                self.push_step(step);
570            }
571            ThreadItemDetails::Reasoning(r) => {
572                let mut step = Step::agent(self.next_step_id, "");
573                step.timestamp = Some(ts.to_string());
574                if let Some(diagnostic) = &r.diagnostic {
575                    step.message = Some(diagnostic.summary.clone());
576                    step.extra = Some(serde_json::json!({ "diagnostic": diagnostic }));
577                } else {
578                    step.reasoning_content = Some(r.text.clone());
579                    step.message = None;
580                }
581                self.push_step(step);
582            }
583            ThreadItemDetails::ToolInvocation(inv) => {
584                // Buffer the invocation; we'll pair it with the ToolOutput
585                self.pending_tool_calls.push(PendingToolCall {
586                    call_id: item_id.to_string(),
587                    tool_call_id: inv.tool_call_id.clone(),
588                    tool_name: inv.tool_name.clone(),
589                    arguments: inv.arguments.clone(),
590                    timestamp: ts.to_string(),
591                });
592            }
593            ThreadItemDetails::ToolOutput(output) => {
594                // Find the matching pending invocation
595                let pending_idx = self.pending_tool_calls.iter().position(|p| p.call_id == output.call_id);
596
597                let (tool_name, arguments, tool_call_id, inv_ts) = if let Some(idx) = pending_idx {
598                    let p = self.pending_tool_calls.remove(idx);
599                    (p.tool_name, p.arguments, p.tool_call_id, p.timestamp)
600                } else {
601                    ("unknown".to_string(), None, output.tool_call_id.clone(), ts.to_string())
602                };
603
604                let call_id = tool_call_id.clone().unwrap_or_else(|| output.call_id.clone());
605
606                let mut step = Step::agent(self.next_step_id, "");
607                step.timestamp = Some(inv_ts);
608                step.message = None;
609                step.tool_calls = Some(vec![AtifToolCall {
610                    tool_call_id: call_id.clone(),
611                    function_name: tool_name,
612                    arguments,
613                }]);
614
615                let status_suffix = match output.status {
616                    ToolCallStatus::Failed => " [FAILED]",
617                    ToolCallStatus::InProgress => " [IN_PROGRESS]",
618                    ToolCallStatus::Completed => "",
619                };
620                let content = format!("{}{}", output.output, status_suffix);
621                step.observation = Some(Observation {
622                    results: vec![ObservationResult { source_call_id: call_id, content }],
623                });
624                self.push_step(step);
625            }
626            ThreadItemDetails::CommandExecution(cmd) => {
627                let call_id = item_id.to_string();
628                let mut step = Step::agent(self.next_step_id, "");
629                step.timestamp = Some(ts.to_string());
630                step.message = None;
631                step.tool_calls = Some(vec![AtifToolCall {
632                    tool_call_id: call_id.clone(),
633                    function_name: "command_execution".to_string(),
634                    arguments: Some(serde_json::json!({
635                        "command": cmd.command,
636                        "arguments": cmd.arguments,
637                    })),
638                }]);
639                step.observation = Some(Observation {
640                    results: vec![ObservationResult {
641                        source_call_id: call_id,
642                        content: cmd.aggregated_output.clone(),
643                    }],
644                });
645                if let Some(exit_code) = cmd.exit_code {
646                    step.extra = Some(serde_json::json!({ "exit_code": exit_code }));
647                }
648                self.push_step(step);
649            }
650            ThreadItemDetails::McpToolCall(mcp) => {
651                let call_id = item_id.to_string();
652                let mut step = Step::agent(self.next_step_id, "");
653                step.timestamp = Some(ts.to_string());
654                step.message = None;
655                step.tool_calls = Some(vec![AtifToolCall {
656                    tool_call_id: call_id.clone(),
657                    function_name: mcp.tool_name.clone(),
658                    arguments: mcp.arguments.clone(),
659                }]);
660                if let Some(result) = &mcp.result {
661                    step.observation = Some(Observation {
662                        results: vec![ObservationResult { source_call_id: call_id, content: result.clone() }],
663                    });
664                }
665                self.push_step(step);
666            }
667            ThreadItemDetails::FileChange(fc) => {
668                let changes: Vec<String> = fc.changes.iter().map(|c| format!("{}: {:?}", c.path, c.kind)).collect();
669                let msg = format!("file_changes: {}", changes.join(", "));
670                let mut step = Step::system(self.next_step_id, msg);
671                step.timestamp = Some(ts.to_string());
672                self.push_step(step);
673            }
674            ThreadItemDetails::WebSearch(ws) => {
675                let mut step = Step::system(self.next_step_id, format!("web_search: {}", ws.query));
676                step.timestamp = Some(ts.to_string());
677                if let Some(results) = &ws.results {
678                    step.observation = Some(Observation {
679                        results: results
680                            .iter()
681                            .enumerate()
682                            .map(|(i, r)| ObservationResult {
683                                source_call_id: format!("search_{i}"),
684                                content: r.clone(),
685                            })
686                            .collect(),
687                    });
688                }
689                self.push_step(step);
690            }
691            ThreadItemDetails::Harness(h) => {
692                let msg = format!("harness: {:?}", h.event);
693                let mut step = Step::system(self.next_step_id, msg);
694                step.timestamp = Some(ts.to_string());
695                let mut extra = serde_json::Map::new();
696                if let Some(m) = &h.message {
697                    let _ = extra.insert("harness_message".to_string(), Value::String(m.clone()));
698                }
699                if matches!(
700                    h.event,
701                    crate::HarnessEventKind::BackgroundSubprocessCompleted
702                        | crate::HarnessEventKind::SessionMessageSent
703                        | crate::HarnessEventKind::SessionMessageReceived
704                        | crate::HarnessEventKind::SessionMessageExpired
705                ) {
706                    for (key, value) in [
707                        ("task_id", h.task_id.as_ref()),
708                        ("session_id", h.session_id.as_ref()),
709                        ("correlation_id", h.correlation_id.as_ref()),
710                        ("exec_session_id", h.exec_session_id.as_ref()),
711                        ("status", h.status.as_ref()),
712                        ("transcript_path", h.transcript_path.as_ref()),
713                        ("archive_path", h.archive_path.as_ref()),
714                        ("error_category", h.error_category.as_ref()),
715                    ] {
716                        if let Some(value) = value {
717                            let _ = extra.insert(key.to_string(), Value::String(value.clone()));
718                        }
719                    }
720                    if let Some(exit_code) = h.exit_code {
721                        let _ = extra.insert("exit_code".to_string(), Value::from(exit_code));
722                    }
723                }
724                if !extra.is_empty() {
725                    step.extra = Some(Value::Object(extra));
726                }
727                self.push_step(step);
728            }
729            ThreadItemDetails::Error(e) => {
730                let mut step = Step::system(self.next_step_id, &e.message);
731                step.timestamp = Some(ts.to_string());
732                self.push_step(step);
733            }
734        }
735    }
736
737    fn push_step(&mut self, step: Step) {
738        self.next_step_id = step.step_id + 1;
739        self.steps.push(step);
740    }
741
742    /// Consume the builder and produce the final ATIF trajectory.
743    ///
744    /// Pass optional `FinalMetrics` to override the accumulated values.
745    /// If `None`, final metrics are derived from observed events.
746    #[must_use]
747    pub fn finish(self, override_metrics: Option<FinalMetrics>) -> Trajectory {
748        let final_metrics = override_metrics.unwrap_or_else(|| FinalMetrics {
749            total_prompt_tokens: Some(self.total_input_tokens),
750            total_completion_tokens: Some(self.total_output_tokens),
751            total_cached_tokens: if self.total_cached_tokens > 0 {
752                Some(self.total_cached_tokens)
753            } else {
754                None
755            },
756            total_cost_usd: None,
757            total_steps: Some(self.steps.len() as u64),
758            extra: Some(serde_json::json!({ "num_turns": self.num_turns })),
759        });
760
761        Trajectory {
762            schema_version: ATIF_SCHEMA_VERSION.to_string(),
763            session_id: self.session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
764            agent: self.agent,
765            steps: self.steps,
766            notes: None,
767            final_metrics: Some(final_metrics),
768            extra: self.completed_at.map(|timestamp| serde_json::json!({"completed_at":timestamp})),
769        }
770    }
771
772    /// Returns the number of steps collected so far.
773    pub fn step_count(&self) -> usize {
774        self.steps.len()
775    }
776}
777
778impl crate::EventEmitter for AtifTrajectoryBuilder {
779    fn emit(&mut self, event: &ThreadEvent) {
780        self.process_event(event);
781    }
782}
783
784#[cfg(test)]
785mod tests;