1use serde::{Deserialize, Serialize};
45
46use crate::{Metadata, Transcript, Usage};
47
48pub const ATIF_VERSION: &str = "ATIF-v1.7";
53
54pub const ATIF_FORMAT: &str = "ATIF";
57
58pub fn is_supported_schema_version(version: &str) -> bool {
63 version == "ATIF-v1" || version.starts_with("ATIF-v1.")
64}
65
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct Trajectory {
74 pub schema_version: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub session_id: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub trajectory_id: Option<String>,
84 pub agent: Agent,
86 pub steps: Vec<Step>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub notes: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub final_metrics: Option<FinalMetrics>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub continued_trajectory_ref: Option<String>,
98 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub subagent_trajectories: Vec<Trajectory>,
103 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
106 pub extra: Metadata,
107}
108
109#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct Agent {
113 pub name: String,
115 pub version: String,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub model_name: Option<String>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub tool_definitions: Vec<serde_json::Value>,
124 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
126 pub extra: Metadata,
127}
128
129impl Agent {
130 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
132 Self {
133 name: name.into(),
134 version: version.into(),
135 ..Default::default()
136 }
137 }
138}
139
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(from = "String", into = "String")]
148pub enum StepSource {
149 System,
151 User,
153 Agent,
155 Other(String),
157}
158
159impl StepSource {
160 pub fn as_str(&self) -> &str {
162 match self {
163 StepSource::System => "system",
164 StepSource::User => "user",
165 StepSource::Agent => "agent",
166 StepSource::Other(s) => s,
167 }
168 }
169}
170
171impl From<String> for StepSource {
172 fn from(s: String) -> Self {
173 match s.as_str() {
174 "system" => StepSource::System,
175 "user" => StepSource::User,
176 "agent" => StepSource::Agent,
177 _ => StepSource::Other(s),
178 }
179 }
180}
181
182impl From<StepSource> for String {
183 fn from(s: StepSource) -> Self {
184 s.as_str().to_string()
185 }
186}
187
188#[cfg(feature = "schema")]
192impl schemars::JsonSchema for StepSource {
193 fn inline_schema() -> bool {
194 true
195 }
196 fn schema_name() -> std::borrow::Cow<'static, str> {
197 "StepSource".into()
198 }
199 fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
200 schemars::json_schema!({
201 "type": "string",
202 "description": "Step originator: \"system\", \"user\", or \"agent\" (open vocabulary; unknown values are carried through)."
203 })
204 }
205}
206
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212#[serde(untagged)]
213pub enum StepContent {
214 Text(String),
216 Parts(Vec<ContentPart>),
218}
219
220impl Default for StepContent {
221 fn default() -> Self {
222 StepContent::Text(String::new())
223 }
224}
225
226impl From<String> for StepContent {
227 fn from(s: String) -> Self {
228 StepContent::Text(s)
229 }
230}
231
232impl From<&str> for StepContent {
233 fn from(s: &str) -> Self {
234 StepContent::Text(s.to_string())
235 }
236}
237
238impl StepContent {
239 pub fn text(&self) -> String {
243 match self {
244 StepContent::Text(s) => s.clone(),
245 StepContent::Parts(parts) => parts
246 .iter()
247 .filter_map(|p| p.text.as_deref())
248 .collect::<Vec<_>>()
249 .join("\n"),
250 }
251 }
252
253 pub fn is_empty(&self) -> bool {
255 match self {
256 StepContent::Text(s) => s.is_empty(),
257 StepContent::Parts(parts) => parts.is_empty(),
258 }
259 }
260}
261
262#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268pub struct ContentPart {
269 #[serde(rename = "type")]
271 pub kind: String,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub text: Option<String>,
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub source: Option<ImageSource>,
278 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
280 pub extra: Metadata,
281}
282
283impl ContentPart {
284 pub fn text(text: impl Into<String>) -> Self {
286 Self {
287 kind: "text".into(),
288 text: Some(text.into()),
289 ..Default::default()
290 }
291 }
292
293 pub fn image(media_type: impl Into<String>, path: impl Into<String>) -> Self {
295 Self {
296 kind: "image".into(),
297 source: Some(ImageSource {
298 media_type: media_type.into(),
299 path: path.into(),
300 }),
301 ..Default::default()
302 }
303 }
304}
305
306#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310pub struct ImageSource {
311 pub media_type: String,
313 pub path: String,
316}
317
318#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct Step {
323 pub step_id: u64,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub timestamp: Option<String>,
328 pub source: StepSource,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub model_name: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub reasoning_effort: Option<serde_json::Value>,
337 #[serde(default)]
341 pub message: StepContent,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub reasoning_content: Option<String>,
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
347 pub tool_calls: Vec<ToolCall>,
348 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub observation: Option<Observation>,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub metrics: Option<StepMetrics>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub llm_call_count: Option<u32>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub is_copied_context: Option<bool>,
364 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
366 pub extra: Metadata,
367}
368
369impl Step {
370 pub fn new(step_id: u64, source: StepSource, message: impl Into<StepContent>) -> Self {
372 Self {
373 step_id,
374 timestamp: None,
375 source,
376 model_name: None,
377 reasoning_effort: None,
378 message: message.into(),
379 reasoning_content: None,
380 tool_calls: Vec::new(),
381 observation: None,
382 metrics: None,
383 llm_call_count: None,
384 is_copied_context: None,
385 extra: Metadata::new(),
386 }
387 }
388
389 pub fn is_agent(&self) -> bool {
391 self.source == StepSource::Agent
392 }
393}
394
395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
398pub struct ToolCall {
399 pub tool_call_id: String,
401 pub function_name: String,
403 pub arguments: serde_json::Value,
405 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
407 pub extra: Metadata,
408}
409
410impl ToolCall {
411 pub fn new(
413 tool_call_id: impl Into<String>,
414 function_name: impl Into<String>,
415 arguments: serde_json::Value,
416 ) -> Self {
417 Self {
418 tool_call_id: tool_call_id.into(),
419 function_name: function_name.into(),
420 arguments,
421 extra: Metadata::new(),
422 }
423 }
424}
425
426#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
429pub struct Observation {
430 pub results: Vec<ObservationResult>,
432}
433
434#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437pub struct ObservationResult {
438 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub source_call_id: Option<String>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub content: Option<StepContent>,
446 #[serde(default, skip_serializing_if = "Vec::is_empty")]
449 pub subagent_trajectory_ref: Vec<SubagentTrajectoryRef>,
450 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
452 pub extra: Metadata,
453}
454
455#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460pub struct SubagentTrajectoryRef {
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub trajectory_id: Option<String>,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub trajectory_path: Option<String>,
467 #[serde(default, skip_serializing_if = "Option::is_none")]
469 pub session_id: Option<String>,
470 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
472 pub extra: Metadata,
473}
474
475#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
478#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
479pub struct StepMetrics {
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub prompt_tokens: Option<u64>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub completion_tokens: Option<u64>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub cached_tokens: Option<u64>,
489 #[serde(default, skip_serializing_if = "Option::is_none")]
491 pub cost_usd: Option<f64>,
492 #[serde(default, skip_serializing_if = "Option::is_none")]
494 pub prompt_token_ids: Option<Vec<u64>>,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub completion_token_ids: Option<Vec<u64>>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub logprobs: Option<Vec<f64>>,
501 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
505 pub extra: Metadata,
506}
507
508#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
511pub struct FinalMetrics {
512 #[serde(default, skip_serializing_if = "Option::is_none")]
514 pub total_prompt_tokens: Option<u64>,
515 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub total_completion_tokens: Option<u64>,
518 #[serde(default, skip_serializing_if = "Option::is_none")]
520 pub total_cached_tokens: Option<u64>,
521 #[serde(default, skip_serializing_if = "Option::is_none")]
523 pub total_cost_usd: Option<f64>,
524 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub total_steps: Option<u64>,
527 #[serde(default, skip_serializing_if = "Metadata::is_empty")]
530 pub extra: Metadata,
531}
532
533fn extra_u64(extra: &Metadata, key: &str) -> u64 {
535 extra
536 .get(key)
537 .and_then(serde_json::Value::as_u64)
538 .unwrap_or(0)
539}
540
541impl Trajectory {
542 pub fn new(agent: Agent) -> Self {
544 Self {
545 schema_version: ATIF_VERSION.into(),
546 session_id: None,
547 trajectory_id: None,
548 agent,
549 steps: Vec::new(),
550 notes: None,
551 final_metrics: None,
552 continued_trajectory_ref: None,
553 subagent_trajectories: Vec::new(),
554 extra: Metadata::new(),
555 }
556 }
557
558 pub fn from_json(json: &str) -> Result<Self, String> {
564 let value: serde_json::Value =
565 serde_json::from_str(json).map_err(|e| format!("invalid ATIF trajectory: {e}"))?;
566 Self::from_value(value)
567 }
568
569 pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
571 let trajectory: Trajectory =
572 serde_json::from_value(value).map_err(|e| format!("invalid ATIF trajectory: {e}"))?;
573 if !is_supported_schema_version(&trajectory.schema_version) {
574 return Err(format!(
575 "unsupported ATIF schema_version {:?}: this build reads ATIF-v1.x \
576 (emits {ATIF_VERSION})",
577 trajectory.schema_version,
578 ));
579 }
580 Ok(trajectory)
581 }
582
583 pub fn final_agent_text(&self) -> Option<String> {
586 self.steps
587 .iter()
588 .rev()
589 .find(|s| s.is_agent())
590 .map(|s| s.message.text())
591 }
592
593 pub fn tool_call_names(&self) -> Vec<String> {
596 self.steps
597 .iter()
598 .flat_map(|s| s.tool_calls.iter().map(|c| c.function_name.clone()))
599 .collect()
600 }
601
602 pub fn agent_iterations(&self) -> usize {
606 self.steps
607 .iter()
608 .filter(|s| s.is_agent() && s.llm_call_count != Some(0))
609 .count()
610 }
611
612 pub fn usage(&self) -> Usage {
618 if let Some(fm) = &self.final_metrics {
619 return Usage {
620 input_tokens: fm.total_prompt_tokens.unwrap_or(0),
621 output_tokens: fm.total_completion_tokens.unwrap_or(0),
622 cache_read_tokens: fm.total_cached_tokens.unwrap_or(0),
623 reasoning_tokens: extra_u64(&fm.extra, "reasoning_tokens"),
624 cost_usd: fm.total_cost_usd.unwrap_or(0.0),
625 };
626 }
627 let mut usage = Usage::default();
628 for m in self.steps.iter().filter_map(|s| s.metrics.as_ref()) {
629 usage.input_tokens += m.prompt_tokens.unwrap_or(0);
630 usage.output_tokens += m.completion_tokens.unwrap_or(0);
631 usage.cache_read_tokens += m.cached_tokens.unwrap_or(0);
632 usage.reasoning_tokens += extra_u64(&m.extra, "reasoning_tokens");
633 usage.cost_usd += m.cost_usd.unwrap_or(0.0);
634 }
635 usage
636 }
637
638 pub fn project_into(&self, t: &mut Transcript) {
656 if t.final_response.is_empty()
657 && let Some(text) = self.final_agent_text()
658 {
659 t.final_response = text;
660 }
661 if t.tool_calls.is_empty() {
662 t.tool_calls = self.tool_call_names();
663 }
664 if t.tool_calls_count == 0 {
665 t.tool_calls_count = t.tool_calls.len();
666 }
667 if t.iterations == 0 {
668 t.iterations = self.agent_iterations();
669 }
670 if t.usage == Usage::default() {
671 t.usage = self.usage();
672 }
673 }
674
675 pub fn from_transcript(t: &Transcript) -> Trajectory {
699 let mut traj = Trajectory::new(Agent::new(SYNTH_AGENT_NAME, SYNTH_AGENT_VERSION));
700 let mut step_id = 1u64;
701 for name in &t.tool_calls {
702 let mut step = Step::new(step_id, StepSource::Agent, "");
703 step.tool_calls = vec![ToolCall::new(
704 format!("call-{step_id}"),
705 name.clone(),
706 serde_json::json!({}),
707 )];
708 traj.steps.push(step);
709 step_id += 1;
710 }
711 if !t.final_response.is_empty() {
712 traj.steps.push(Step::new(
713 step_id,
714 StepSource::Agent,
715 t.final_response.clone(),
716 ));
717 }
718 let u = &t.usage;
720 if *u != Usage::default() {
721 let mut extra = Metadata::new();
722 if u.reasoning_tokens > 0 {
723 extra.insert("reasoning_tokens".into(), u.reasoning_tokens.into());
724 }
725 traj.final_metrics = Some(FinalMetrics {
726 total_prompt_tokens: (u.input_tokens != 0).then_some(u.input_tokens),
727 total_completion_tokens: (u.output_tokens != 0).then_some(u.output_tokens),
728 total_cached_tokens: (u.cache_read_tokens != 0).then_some(u.cache_read_tokens),
729 total_cost_usd: (u.cost_usd > 0.0).then_some(u.cost_usd),
730 total_steps: Some(traj.steps.len() as u64),
731 extra,
732 });
733 }
734 traj
735 }
736}
737
738const SYNTH_AGENT_NAME: &str = "mira-export";
742const SYNTH_AGENT_VERSION: &str = "0";
745
746#[derive(Clone, Copy, Debug)]
750pub struct ToolInvocation<'a> {
751 pub name: &'a str,
753 pub arguments: Option<&'a serde_json::Value>,
756 pub result: Option<&'a StepContent>,
758}
759
760impl Transcript {
761 pub fn tool_invocations(&self) -> Vec<ToolInvocation<'_>> {
770 let Some(trajectory) = &self.trajectory else {
771 return self
772 .tool_calls
773 .iter()
774 .map(|name| ToolInvocation {
775 name,
776 arguments: None,
777 result: None,
778 })
779 .collect();
780 };
781 let mut out = Vec::new();
782 for step in &trajectory.steps {
783 for call in &step.tool_calls {
784 let result = step
785 .observation
786 .as_ref()
787 .and_then(|o| {
788 o.results
789 .iter()
790 .find(|r| r.source_call_id.as_deref() == Some(&call.tool_call_id))
791 })
792 .and_then(|r| r.content.as_ref());
793 out.push(ToolInvocation {
794 name: &call.function_name,
795 arguments: Some(&call.arguments),
796 result,
797 });
798 }
799 }
800 out
801 }
802}
803
804#[cfg(test)]
805mod tests {
806 use super::*;
807 use serde_json::json;
808
809 const FIXTURE: &str = include_str!(concat!(
813 env!("CARGO_MANIFEST_DIR"),
814 "/../../schema/v1/conformance/trajectory.json"
815 ));
816
817 fn rfc_example() -> serde_json::Value {
818 let doc: serde_json::Value = serde_json::from_str(FIXTURE).unwrap();
819 doc["cases"]
820 .as_array()
821 .unwrap()
822 .iter()
823 .find(|c| c["name"] == "rfc worked example")
824 .expect("fixture carries the RFC worked example")["trajectory"]
825 .clone()
826 }
827
828 #[test]
829 fn schema_version_gate() {
830 assert!(is_supported_schema_version("ATIF-v1.7"));
831 assert!(is_supported_schema_version("ATIF-v1.0"));
832 assert!(is_supported_schema_version("ATIF-v1.99")); assert!(!is_supported_schema_version("ATIF-v2.0"));
834 assert!(!is_supported_schema_version("v1.7"));
835 assert!(!is_supported_schema_version(""));
836 }
837
838 #[test]
839 fn rfc_example_parses_and_round_trips() {
840 let t = Trajectory::from_value(rfc_example()).unwrap();
841 assert_eq!(t.schema_version, "ATIF-v1.5"); assert_eq!(t.agent.name, "harbor-agent");
843 assert_eq!(t.steps.len(), 3);
844 assert_eq!(t.steps[0].source, StepSource::User);
845 assert_eq!(t.steps[1].tool_calls.len(), 2);
846 assert_eq!(
847 t.steps[1].tool_calls[0].arguments,
848 json!({"ticker": "GOOGL", "metric": "price"})
849 );
850 let m3 = t.steps[2].metrics.as_ref().unwrap();
852 assert_eq!(m3.completion_token_ids.as_ref().unwrap().len(), 37);
853 assert_eq!(m3.logprobs.as_ref().unwrap().len(), 44);
854 assert_eq!(extra_u64(&m3.extra, "reasoning_tokens"), 12);
855
856 let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
858 assert_eq!(back, t);
859 }
860
861 #[test]
862 fn rfc_example_projects_flat_fields() {
863 let t = Trajectory::from_value(rfc_example()).unwrap();
864 let transcript = Transcript::from_trajectory(t);
865 assert!(
866 transcript
867 .final_response
868 .starts_with("As of October 11, 2025")
869 );
870 assert_eq!(
871 transcript.tool_calls,
872 vec!["financial_search", "financial_search"]
873 );
874 assert_eq!(transcript.tool_calls_count, 2);
875 assert_eq!(transcript.iterations, 2);
876 assert_eq!(transcript.usage.input_tokens, 1120);
878 assert_eq!(transcript.usage.output_tokens, 124);
879 assert_eq!(transcript.usage.cache_read_tokens, 200);
880 assert!((transcript.usage.cost_usd - 0.00078).abs() < 1e-12);
881 }
882
883 #[test]
884 fn unknown_fields_and_sources_are_tolerated() {
885 let json = json!({
888 "schema_version": "ATIF-v1.42",
889 "agent": {"name": "a", "version": "1", "future_agent_field": true},
890 "steps": [
891 {"step_id": 1, "source": "environment", "message": "hi",
892 "future_step_field": {"x": 1}},
893 {"step_id": 2, "source": "agent", "message": "ok"}
894 ],
895 "brand_new_root_field": [1, 2, 3]
896 });
897 let t = Trajectory::from_value(json).unwrap();
898 assert_eq!(t.steps[0].source, StepSource::Other("environment".into()));
899 let line = serde_json::to_string(&t).unwrap();
901 assert!(line.contains(r#""source":"environment""#));
902 }
903
904 #[test]
905 fn non_v1_schema_version_is_rejected_gracefully() {
906 let doc = json!({
907 "schema_version": "ATIF-v2.0",
908 "agent": {"name": "a", "version": "1"},
909 "steps": []
910 });
911 let err = Trajectory::from_value(doc).unwrap_err();
912 assert!(err.contains("ATIF-v2.0"), "got: {err}");
913 assert!(Trajectory::from_json("{not json").is_err());
915 assert!(Trajectory::from_json(r#"{"schema_version": 7}"#).is_err());
916 }
917
918 #[test]
919 fn extra_maps_are_preserved() {
920 let mut t = Trajectory::new(Agent::new("a", "1"));
921 t.extra.insert("harness".into(), json!({"run": 3}));
922 let mut step = Step::new(1, StepSource::Agent, "done");
923 step.extra.insert("note".into(), json!("custom"));
924 t.steps.push(step);
925
926 let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
927 assert_eq!(back.extra["harness"]["run"], json!(3));
928 assert_eq!(back.steps[0].extra["note"], json!("custom"));
929 assert_eq!(back, t);
930 }
931
932 #[test]
933 fn multimodal_message_projects_text_parts() {
934 let content = StepContent::Parts(vec![
935 ContentPart::text("a cat"),
936 ContentPart::image("image/png", "images/cat.png"),
937 ContentPart::text("on a mat"),
938 ]);
939 assert_eq!(content.text(), "a cat\non a mat");
940 let s: StepContent = serde_json::from_str(r#""plain""#).unwrap();
942 assert_eq!(s, StepContent::Text("plain".into()));
943 let p: StepContent = serde_json::from_str(r#"[{"type": "text", "text": "hi"}]"#).unwrap();
944 assert_eq!(p, StepContent::Parts(vec![ContentPart::text("hi")]));
945 }
946
947 #[test]
948 fn usage_sums_step_metrics_when_no_final_metrics() {
949 let mut t = Trajectory::new(Agent::new("a", "1"));
950 let mut s1 = Step::new(1, StepSource::Agent, "one");
951 s1.metrics = Some(StepMetrics {
952 prompt_tokens: Some(100),
953 completion_tokens: Some(20),
954 cached_tokens: Some(10),
955 cost_usd: Some(0.001),
956 extra: Metadata::from([("reasoning_tokens".into(), json!(5))]),
957 ..Default::default()
958 });
959 let mut s2 = Step::new(2, StepSource::Agent, "two");
960 s2.metrics = Some(StepMetrics {
961 prompt_tokens: Some(200),
962 completion_tokens: Some(30),
963 cost_usd: Some(0.002),
964 ..Default::default()
965 });
966 t.steps = vec![s1, s2];
967
968 let usage = t.usage();
969 assert_eq!(usage.input_tokens, 300);
970 assert_eq!(usage.output_tokens, 50);
971 assert_eq!(usage.cache_read_tokens, 10);
972 assert_eq!(usage.reasoning_tokens, 5);
973 assert!((usage.cost_usd - 0.003).abs() < 1e-12);
974 }
975
976 #[test]
977 fn iterations_exclude_deterministic_dispatch_steps() {
978 let mut t = Trajectory::new(Agent::new("a", "1"));
979 t.steps = vec![
980 Step::new(1, StepSource::User, "go"),
981 Step::new(2, StepSource::Agent, "inferring"), {
983 let mut s = Step::new(3, StepSource::Agent, "");
984 s.llm_call_count = Some(0); s
986 },
987 {
988 let mut s = Step::new(4, StepSource::Agent, "done");
989 s.llm_call_count = Some(2); s
991 },
992 ];
993 assert_eq!(t.agent_iterations(), 2);
994 }
995
996 #[test]
997 fn project_into_fills_defaults_but_never_overwrites() {
998 let mut t = Trajectory::new(Agent::new("a", "1"));
999 let mut step = Step::new(1, StepSource::Agent, "derived response");
1000 step.tool_calls = vec![ToolCall::new("c1", "grep", json!({"q": "x"}))];
1001 t.steps.push(step);
1002
1003 let mut fresh = Transcript::default();
1005 t.project_into(&mut fresh);
1006 assert_eq!(fresh.final_response, "derived response");
1007 assert_eq!(fresh.tool_calls, vec!["grep"]);
1008 assert_eq!(fresh.tool_calls_count, 1);
1009 assert_eq!(fresh.iterations, 1);
1010
1011 let mut set = Transcript::response("explicit answer");
1013 set.iterations = 7;
1014 set.usage.input_tokens = 9;
1015 t.project_into(&mut set);
1016 assert_eq!(set.final_response, "explicit answer");
1017 assert_eq!(set.iterations, 7);
1018 assert_eq!(set.usage.input_tokens, 9);
1019 assert_eq!(set.tool_calls, vec!["grep"]);
1021 }
1022
1023 #[test]
1024 fn from_trajectory_needs_no_client_calls() {
1025 let mut t = Trajectory::new(Agent::new("a", "1"));
1028 let mut step = Step::new(1, StepSource::Agent, "hi there");
1029 step.tool_calls = vec![ToolCall::new("c1", "search", json!({}))];
1030 t.steps.push(step);
1031
1032 let transcript = Transcript::from_trajectory(t.clone());
1033 assert_eq!(transcript.final_response, "hi there");
1034 assert_eq!(transcript.tool_calls, vec!["search"]);
1035 assert_eq!(transcript.trajectory, Some(t));
1036 assert!(transcript.events.is_empty());
1038 }
1039
1040 #[test]
1041 fn tool_invocations_prefer_trajectory_then_fall_back_to_names() {
1042 let mut t = Trajectory::new(Agent::new("a", "1"));
1044 let mut step = Step::new(1, StepSource::Agent, "");
1045 step.tool_calls = vec![
1046 ToolCall::new("c1", "search", json!({"q": "price"})),
1047 ToolCall::new("c2", "fetch", json!({"url": "u"})),
1048 ];
1049 step.observation = Some(Observation {
1050 results: vec![ObservationResult {
1051 source_call_id: Some("c1".into()),
1052 content: Some("$185.35".into()),
1053 ..Default::default()
1054 }],
1055 });
1056 t.steps.push(step);
1057 let transcript = Transcript::from_trajectory(t);
1058
1059 let calls = transcript.tool_invocations();
1060 assert_eq!(calls.len(), 2);
1061 assert_eq!(calls[0].name, "search");
1062 assert_eq!(calls[0].arguments.unwrap()["q"], "price");
1063 assert_eq!(calls[0].result.unwrap().text(), "$185.35");
1064 assert_eq!(calls[1].name, "fetch");
1065 assert!(calls[1].result.is_none()); let legacy = Transcript {
1069 tool_calls: vec!["read".into(), "calc".into()],
1070 ..Default::default()
1071 };
1072 let calls = legacy.tool_invocations();
1073 assert_eq!(calls.len(), 2);
1074 assert_eq!(calls[0].name, "read");
1075 assert!(calls[0].arguments.is_none());
1076 assert!(calls[0].result.is_none());
1077 }
1078
1079 #[test]
1080 fn from_transcript_synthesizes_and_reprojects_recoverable_fields() {
1081 let summary = Transcript {
1084 final_response: "the answer is 42".into(),
1085 tool_calls: vec!["search".into(), "calc".into()],
1086 tool_calls_count: 2,
1087 iterations: 3,
1088 usage: Usage {
1089 input_tokens: 100,
1090 output_tokens: 20,
1091 cache_read_tokens: 10,
1092 reasoning_tokens: 5,
1093 cost_usd: 0.001,
1094 },
1095 ..Default::default()
1096 };
1097 let traj = Trajectory::from_transcript(&summary);
1098 assert_eq!(traj.schema_version, ATIF_VERSION);
1099 assert_eq!(traj.agent.name, "mira-export");
1100
1101 let back = Trajectory::from_json(&serde_json::to_string(&traj).unwrap()).unwrap();
1103 assert_eq!(back, traj);
1104
1105 let mut reprojected = Transcript::default();
1107 traj.project_into(&mut reprojected);
1108 assert_eq!(reprojected.final_response, "the answer is 42");
1109 assert_eq!(reprojected.tool_calls, vec!["search", "calc"]);
1110 assert_eq!(reprojected.usage, summary.usage); assert_eq!(traj.tool_call_names(), vec!["search", "calc"]);
1113 assert!(
1114 traj.steps
1115 .iter()
1116 .all(|s| s.tool_calls.iter().all(|c| c.arguments == json!({})))
1117 );
1118 }
1119
1120 #[test]
1121 fn from_transcript_handles_empty_summary() {
1122 let traj = Trajectory::from_transcript(&Transcript::response("hi"));
1124 assert_eq!(traj.steps.len(), 1);
1125 assert_eq!(traj.final_agent_text().as_deref(), Some("hi"));
1126 assert!(traj.final_metrics.is_none());
1127
1128 let empty = Trajectory::from_transcript(&Transcript::default());
1130 assert!(empty.steps.is_empty());
1131 assert!(empty.final_agent_text().is_none());
1132 }
1133
1134 #[test]
1135 fn subagent_trajectories_round_trip_but_stay_opaque_to_projections() {
1136 let mut sub = Trajectory::new(Agent::new("sub", "1"));
1137 sub.trajectory_id = Some("sub-1".into());
1138 let mut sub_step = Step::new(1, StepSource::Agent, "sub work");
1139 sub_step.tool_calls = vec![ToolCall::new("s1", "sub_tool", json!({}))];
1140 sub.steps.push(sub_step);
1141
1142 let mut t = Trajectory::new(Agent::new("parent", "1"));
1143 t.steps.push(Step::new(1, StepSource::Agent, "delegated"));
1144 t.subagent_trajectories.push(sub);
1145
1146 let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
1147 assert_eq!(back, t);
1148 let transcript = Transcript::from_trajectory(t);
1150 assert!(transcript.tool_calls.is_empty());
1151 assert_eq!(transcript.iterations, 1);
1152 }
1153}