1use crate::model::{Agent, FinalMetrics, Metrics, Step, Trajectory, ATIF_VERSION};
4use std::path::Path;
5
6#[derive(Debug, thiserror::Error)]
8#[non_exhaustive]
9pub enum WriterError {
10 #[error("io error writing trajectory: {0}")]
12 Io(#[from] std::io::Error),
13 #[error("serialization error: {0}")]
15 Serde(#[from] serde_json::Error),
16 #[error("built trajectory failed validation: {0:?}")]
18 Invalid(Vec<crate::validate::ValidationIssue>),
19}
20
21#[derive(Debug, Clone)]
27pub struct TrajectoryBuilder {
28 session_id: Option<String>,
29 trajectory_id: Option<String>,
30 agent: Agent,
31 steps: Vec<Step>,
32 total_prompt: u64,
33 total_completion: u64,
34 total_cached: u64,
35 total_cost: f64,
36}
37
38impl TrajectoryBuilder {
39 pub fn new(agent: Agent, session_id: Option<String>) -> Self {
41 Self {
42 session_id,
43 trajectory_id: Some(av_core::new_event_uid()),
44 agent,
45 steps: Vec::new(),
46 total_prompt: 0,
47 total_completion: 0,
48 total_cached: 0,
49 total_cost: 0.0,
50 }
51 }
52
53 pub fn len(&self) -> usize {
55 self.steps.len()
56 }
57
58 pub fn is_empty(&self) -> bool {
60 self.steps.is_empty()
61 }
62
63 pub fn push_step(&mut self, mut step: Step) -> Result<(), av_core::CoreError> {
67 step.step_id = self.steps.len() as u64 + 1;
68 if step.source == crate::model::Source::Agent {
77 if step.llm_call_count == Some(0) {
78 if step.metrics.is_some() || step.reasoning_content.is_some() {
79 return Err(av_core::CoreError::InvalidId(
80 "agent step with llm_call_count 0 must not carry metrics or reasoning_content"
81 .to_owned(),
82 ));
83 }
84 } else {
85 let complete = step.metrics.as_ref().is_some_and(|m| {
86 m.prompt_tokens.is_some() && m.completion_tokens.is_some() && m.cached_tokens.is_some()
87 });
88 if !complete {
89 return Err(av_core::CoreError::InvalidId(
90 "agent step requires metrics with prompt/completion/cached token counts \
91 (strict ATIF-v1.7 agent-step fidelity)"
92 .to_owned(),
93 ));
94 }
95 }
96 }
97 let (next_prompt, next_completion, next_cached, next_cost) = if let Some(m) = &step.metrics {
101 let next_prompt = self
102 .total_prompt
103 .checked_add(m.prompt_tokens.unwrap_or(0))
104 .ok_or(av_core::CoreError::Overflow {
105 context: "total_prompt_tokens",
106 })?;
107 let next_completion = self
108 .total_completion
109 .checked_add(m.completion_tokens.unwrap_or(0))
110 .ok_or(av_core::CoreError::Overflow {
111 context: "total_completion_tokens",
112 })?;
113 let next_cached = self
114 .total_cached
115 .checked_add(m.cached_tokens.unwrap_or(0))
116 .ok_or(av_core::CoreError::Overflow {
117 context: "total_cached_tokens",
118 })?;
119 av_core::error::check_jcs_safe(next_prompt)?;
125 av_core::error::check_jcs_safe(next_completion)?;
126 av_core::error::check_jcs_safe(next_cached)?;
127 let step_cost = m.cost_usd.unwrap_or(0.0);
137 if !step_cost.is_finite() || step_cost < 0.0 {
138 return Err(av_core::CoreError::Overflow {
139 context: "step_cost_usd",
140 });
141 }
142 if step_cost > crate::validate::MAX_COST_USD {
152 return Err(av_core::CoreError::Overflow {
153 context: "step_cost_usd",
154 });
155 }
156 let next_cost = self.total_cost + step_cost;
157 if !next_cost.is_finite() {
158 return Err(av_core::CoreError::Overflow {
159 context: "total_cost_usd",
160 });
161 }
162 if next_cost > crate::validate::MAX_COST_USD {
163 return Err(av_core::CoreError::Overflow {
164 context: "total_cost_usd",
165 });
166 }
167 (next_prompt, next_completion, next_cached, next_cost)
168 } else {
169 (
170 self.total_prompt,
171 self.total_completion,
172 self.total_cached,
173 self.total_cost,
174 )
175 };
176 self.steps.push(step);
177 self.total_prompt = next_prompt;
178 self.total_completion = next_completion;
179 self.total_cached = next_cached;
180 self.total_cost = next_cost;
181 Ok(())
182 }
183
184 pub fn finish(self) -> Trajectory {
186 let total_steps = self.steps.len() as u64;
187 Trajectory {
188 schema_version: ATIF_VERSION.to_owned(),
189 session_id: self.session_id,
190 trajectory_id: self.trajectory_id,
191 agent: self.agent,
192 steps: self.steps,
193 notes: None,
194 final_metrics: Some(FinalMetrics {
195 total_prompt_tokens: Some(self.total_prompt),
196 total_completion_tokens: Some(self.total_completion),
197 total_cached_tokens: Some(self.total_cached),
198 total_cost_usd: Some(self.total_cost),
199 total_steps: Some(total_steps),
200 extra: None,
201 }),
202 continued_trajectory_ref: None,
203 subagent_trajectories: None,
204 extra: None,
205 }
206 }
207}
208
209pub fn metrics(prompt: u64, completion: u64, cached: u64, cost_usd: f64) -> Metrics {
213 Metrics {
214 prompt_tokens: Some(prompt),
215 completion_tokens: Some(completion),
216 cached_tokens: Some(cached),
217 cost_usd: Some(cost_usd),
218 logprobs: None,
219 completion_token_ids: None,
220 prompt_token_ids: None,
221 extra: None,
222 }
223}
224
225pub fn write_atomic(trajectory: &Trajectory, path: &Path) -> Result<(), WriterError> {
236 let issues = crate::validate::validate_trajectory(trajectory, crate::validate::Mode::Strict);
237 if !issues.is_empty() {
238 return Err(WriterError::Invalid(issues));
239 }
240 let json = serde_json::to_vec_pretty(trajectory)?;
241 av_core::fsutil::write_atomic(path, &json).map_err(WriterError::Io)?;
256 Ok(())
257}
258
259#[cfg(test)]
260mod tests {
261 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
262
263 use super::*;
264 use crate::model::Source;
265
266 fn agent() -> Agent {
267 Agent {
268 name: "harness".into(),
269 version: "0.1.0".into(),
270 model_name: Some("claude-sonnet-4".into()),
271 tool_definitions: None,
272 extra: None,
273 }
274 }
275
276 fn step(source: Source) -> Step {
277 Step {
278 step_id: 999, timestamp: Some(av_core::time::now_iso8601()),
280 source,
281 message: serde_json::json!("hello"),
282 reasoning_effort: None,
283 reasoning_content: None,
284 model_name: None,
285 tool_calls: None,
286 observation: None,
287 metrics: matches!(source, Source::Agent).then(|| metrics(100, 20, 60, 0.001)),
288 is_copied_context: None,
289 llm_call_count: None,
290 extra: None,
291 }
292 }
293
294 #[test]
299 fn partial_metrics_and_zero_call_couplings_are_refused() {
300 for missing in ["prompt", "completion", "cached"] {
302 let mut s = step(Source::Agent);
303 let mut m = metrics(100, 20, 60, 0.001);
304 match missing {
305 "prompt" => m.prompt_tokens = None,
306 "completion" => m.completion_tokens = None,
307 _ => m.cached_tokens = None,
308 }
309 s.metrics = Some(m);
310 let mut b = TrajectoryBuilder::new(agent(), None);
311 assert!(
312 b.push_step(s).is_err(),
313 "agent step missing {missing}_tokens must be refused"
314 );
315 }
316 let mut s = step(Source::Agent);
318 s.llm_call_count = Some(0);
319 let mut b = TrajectoryBuilder::new(agent(), None);
320 assert!(b.push_step(s).is_err(), "metrics with zero calls must be refused");
321 let mut s = step(Source::Agent);
323 s.llm_call_count = Some(0);
324 s.metrics = None;
325 s.reasoning_content = Some("thinking".into());
326 let mut b = TrajectoryBuilder::new(agent(), None);
327 assert!(
328 b.push_step(s).is_err(),
329 "reasoning_content with zero calls must be refused"
330 );
331 let mut s = step(Source::Agent);
333 s.llm_call_count = Some(0);
334 s.metrics = None;
335 let mut b = TrajectoryBuilder::new(agent(), None);
336 assert!(b.push_step(s).is_ok());
337 }
338
339 #[test]
340 fn builder_assigns_sequential_ids_and_aggregates() {
341 let mut b = TrajectoryBuilder::new(agent(), Some("sess-1".into()));
342 b.push_step(step(Source::User)).unwrap();
343 b.push_step(step(Source::Agent)).unwrap();
344 b.push_step(step(Source::Agent)).unwrap();
345 let t = b.finish();
346 assert_eq!(
347 t.steps.iter().map(|s| s.step_id).collect::<Vec<_>>(),
348 vec![1, 2, 3]
349 );
350 let fm = t.final_metrics.unwrap();
351 assert_eq!(fm.total_prompt_tokens, Some(200));
352 assert_eq!(fm.total_completion_tokens, Some(40));
353 assert_eq!(fm.total_cached_tokens, Some(120));
354 assert_eq!(fm.total_steps, Some(3));
355 assert!((fm.total_cost_usd.unwrap() - 0.002).abs() < 1e-12);
356 }
357
358 #[test]
359 fn aggregate_overflow_is_loud() {
360 let mut b = TrajectoryBuilder::new(agent(), None);
361 let mut s = step(Source::Agent);
362 s.metrics = Some(metrics(u64::MAX, 0, 0, 0.0));
363 assert!(
368 b.push_step(s).is_err(),
369 "JCS-unsafe totals must refuse at the builder"
370 );
371 let mut s_ok = step(Source::Agent);
374 s_ok.metrics = Some(metrics(9_007_199_254_740_992, 0, 0, 0.0));
375 b.push_step(s_ok).unwrap();
376 let mut s2 = step(Source::Agent);
377 s2.metrics = Some(metrics(1, 0, 0, 0.0));
378 assert!(b.push_step(s2).is_err(), "overflow must not wrap silently");
379 }
380
381 #[test]
388 fn non_finite_and_negative_costs_are_each_refused() {
389 for bad_cost in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.001] {
390 let mut b = TrajectoryBuilder::new(agent(), None);
391 let mut s = step(Source::Agent);
392 s.metrics = Some(metrics(1, 1, 0, bad_cost));
393 assert!(
394 b.push_step(s).is_err(),
395 "cost {bad_cost} must be refused before it can reach serialization",
396 );
397 let mut ok = step(Source::Agent);
399 ok.metrics = Some(metrics(1, 1, 0, 0.5));
400 b.push_step(ok).unwrap();
401 let fm = b.finish().final_metrics.unwrap();
402 assert_eq!(fm.total_prompt_tokens, Some(1));
403 assert!((fm.total_cost_usd.unwrap() - 0.5).abs() < 1e-12);
404 }
405 }
406
407 #[test]
408 fn atomic_write_produces_valid_file() {
409 let dir = tempfile::tempdir().unwrap();
410 let path = dir.path().join("nested").join("trajectory.json");
411 let mut b = TrajectoryBuilder::new(agent(), Some("sess-2".into()));
412 b.push_step(step(Source::User)).unwrap();
413 b.push_step(step(Source::Agent)).unwrap();
414 write_atomic(&b.finish(), &path).unwrap();
415 let raw = std::fs::read_to_string(&path).unwrap();
416 let value: serde_json::Value = serde_json::from_str(&raw).unwrap();
417 let issues = crate::validate::validate_value(&value, crate::validate::Mode::Strict);
418 assert!(issues.is_empty(), "{issues:?}");
419 let litter: Vec<_> = std::fs::read_dir(path.parent().unwrap())
421 .unwrap()
422 .filter_map(Result::ok)
423 .filter(|e| e.path() != path)
424 .collect();
425 assert!(litter.is_empty(), "temp files left behind: {litter:?}");
426 }
427
428 #[test]
429 fn invalid_trajectory_never_written() {
430 let dir = tempfile::tempdir().unwrap();
431 let path = dir.path().join("bad.json");
432 let mut t = TrajectoryBuilder::new(agent(), None);
433 t.push_step(step(Source::Agent)).unwrap();
434 let mut t = t.finish();
435 if let Some(s) = t.steps.first_mut() {
437 s.source = Source::User;
438 s.reasoning_content = Some("should not be here".into());
439 }
440 let err = write_atomic(&t, &path);
441 assert!(matches!(err, Err(WriterError::Invalid(_))));
442 assert!(!path.exists(), "invalid file must not be created");
443 }
444
445 #[test]
452 fn push_step_overflow_leaves_builder_state_unchanged() {
453 let mut b = TrajectoryBuilder::new(agent(), None);
454 b.push_step(step(Source::Agent)).unwrap();
456 let before_prompt = b.total_prompt;
457 let before_completion = b.total_completion;
458 let before_cached = b.total_cached;
459 let before_len = b.steps.len();
460
461 let mut hostile = step(Source::Agent);
466 hostile.metrics = Some(metrics(1, u64::MAX, 0, 0.0));
467 let err = b.push_step(hostile).expect_err("must refuse overflowing totals");
468 assert!(
469 matches!(err, av_core::CoreError::Overflow { .. }),
470 "wrong error variant: {err:?}",
471 );
472
473 assert_eq!(b.total_prompt, before_prompt, "total_prompt leaked");
474 assert_eq!(b.total_completion, before_completion, "total_completion leaked");
475 assert_eq!(b.total_cached, before_cached, "total_cached leaked");
476 assert_eq!(b.steps.len(), before_len, "steps len leaked");
477 }
478}