Skip to main content

mira/
trajectory.rs

1//! ATIF trajectories: the **primary structured trajectory contract** of the
2//! Mira eval protocol.
3//!
4//! These types mirror the [Agent Trajectory Interchange Format][atif]
5//! (ATIF-v1.7, harbor RFC 0001) field-for-field: an ordered list of [`Step`]s
6//! (user/system/agent turns) carrying structured [`ToolCall`]s, correlated
7//! [`Observation`]s, per-step [`StepMetrics`], and aggregate [`FinalMetrics`].
8//! A subject that can produce one attaches it as
9//! [`Transcript::trajectory`](crate::Transcript::trajectory); scorers and
10//! consumers read it — never the raw `events` channel — for anything the
11//! trajectory models (tool arguments, observations, reasoning, per-step
12//! metrics).
13//!
14//! Design decisions (kept here, on the module):
15//! * **Interchange fidelity beats internal uniformity.** ATIF's shapes are
16//!   carried verbatim ([`ContentPart`]/[`ImageSource`] are *not* unified with
17//!   Mira's [`Part`](crate::Part)/[`Source`](crate::Source)), so a Mira
18//!   trajectory is a valid ATIF document for external SFT/RL/visualization
19//!   tooling, byte-compatible with what harbor-ecosystem agents emit.
20//! * **Lenient by construction.** Mira emits [`ATIF_VERSION`] and reads any
21//!   `ATIF-v1.x` (v1 additions are optional fields); unknown fields are
22//!   ignored everywhere (no `deny_unknown_fields`); [`Step::source`] is an
23//!   open vocabulary ([`StepSource::Other`] carries unknown originators
24//!   through). A non-v1 `schema_version` is rejected gracefully by
25//!   [`Trajectory::from_json`] — an error, never a panic — because trajectory
26//!   JSON is untrusted study output.
27//! * **Zero client burden.** The flat `Transcript` fields (`final_response`,
28//!   `tool_calls`, `iterations`, `usage`) are *projections* of the trajectory:
29//!   [`Trajectory::project_into`] derives them, and the framework applies it
30//!   wherever a transcript is produced or received, so a subject may set
31//!   `trajectory` alone and everything else — including every existing
32//!   name-based scorer — keeps working. `events` is never required alongside.
33//! * **Reward stays Mira-side.** Mira's verdicts live in `Score`/`RunResult`
34//!   and are computed (and re-computed) after the transcript exists; Mira
35//!   never reads or writes `trajectory.extra.reward` on the wire.
36//!
37//! Cross-SDK parity: `sdks/python/mira/trajectory.py` and
38//! `sdks/typescript/src/trajectory.ts` hand-mirror the projection; behaviour
39//! is pinned by the shared vectors in `schema/v1/conformance/trajectory.json`
40//! (three runners, one fixture — like `scorers.json`).
41//!
42//! [atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md
43
44use serde::{Deserialize, Serialize};
45
46use crate::{Metadata, Transcript, Usage};
47
48/// The ATIF schema version Mira **emits** (`schema_version` on new
49/// trajectories). Parsing is more lenient: any `ATIF-v1.x` document is read
50/// (see [`is_supported_schema_version`]) — this constant is not a ceiling on
51/// what Mira accepts.
52pub const ATIF_VERSION: &str = "ATIF-v1.7";
53
54/// The trajectory format name advertised in `capability_params`
55/// (`{"trajectory": {"format": "ATIF", "version": "1.7"}}`).
56pub const ATIF_FORMAT: &str = "ATIF";
57
58/// True when `schema_version` names an ATIF v1 document this build can read.
59/// Every `ATIF-v1.x` is accepted — v1 minor additions are optional fields, and
60/// unknown fields are ignored crate-wide — while other prefixes (a future
61/// `ATIF-v2.0`, garbage) are rejected gracefully by [`Trajectory::from_json`].
62pub fn is_supported_schema_version(version: &str) -> bool {
63    version == "ATIF-v1" || version.starts_with("ATIF-v1.")
64}
65
66/// One ATIF trajectory document: the root object (RFC 0001, ATIF-v1.7).
67///
68/// Attach it via [`Transcript::from_trajectory`] (or set
69/// [`Transcript::trajectory`] directly): the flat transcript fields are then
70/// derived automatically — see [`Trajectory::project_into`].
71#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub struct Trajectory {
74    /// ATIF compatibility marker, e.g. `"ATIF-v1.7"`. Required by ATIF; Mira
75    /// emits [`ATIF_VERSION`] and parses any `ATIF-v1.x`.
76    pub schema_version: String,
77    /// Run-scoped identifier (may be shared by a parent and its subagents).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub session_id: Option<String>,
80    /// Per-document identifier; required on embedded subagent trajectories,
81    /// where it is the resolution key for [`SubagentTrajectoryRef`]s.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub trajectory_id: Option<String>,
84    /// The agent system that produced this trajectory.
85    pub agent: Agent,
86    /// The complete interaction history, ordered by `step_id` (1-based).
87    pub steps: Vec<Step>,
88    /// Free-form producer notes (design notes, format discrepancies).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub notes: Option<String>,
91    /// Aggregate token/cost totals for the whole trajectory.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub final_metrics: Option<FinalMetrics>,
94    /// Reference to a continuation trajectory file, when context management
95    /// split the run across documents.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub continued_trajectory_ref: Option<String>,
98    /// Embedded subagent trajectories (each a complete, independently valid
99    /// ATIF document). Parsed and round-tripped; projections do **not**
100    /// recurse into them — subagents are opaque-but-preserved.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub subagent_trajectories: Vec<Trajectory>,
103    /// Custom root-level metadata. Mira never reads or writes `extra.reward`
104    /// on the wire (verdicts live in `Score`/`RunResult`).
105    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
106    pub extra: Metadata,
107}
108
109/// The agent system a [`Trajectory`] was produced by (ATIF _AgentSchema_).
110#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
111#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
112pub struct Agent {
113    /// Agent system name (e.g. `"claude-code"`, `"mini-swe-agent"`).
114    pub name: String,
115    /// Agent system version (e.g. `"1.0.0"`).
116    pub version: String,
117    /// Default LLM for the trajectory; a step-level `model_name` overrides it.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub model_name: Option<String>,
120    /// Tool/function definitions available to the agent (OpenAI function
121    /// schema). Opaque to Mira — carried, never introspected.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub tool_definitions: Vec<serde_json::Value>,
124    /// Custom agent configuration not covered by the core schema.
125    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
126    pub extra: Metadata,
127}
128
129impl Agent {
130    /// An agent identity with just the required `name` + `version`.
131    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/// The originator of a [`Step`]: `system`, `user`, or `agent`.
141///
142/// An **open** vocabulary, like `EventParams::kind`: a value this build
143/// doesn't know parses into [`StepSource::Other`] and round-trips verbatim
144/// (forward compatibility with future ATIF sources), rather than failing.
145/// Serializes as a plain string.
146#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(from = "String", into = "String")]
148pub enum StepSource {
149    /// A system prompt or system-initiated operation.
150    System,
151    /// A (real or simulated) user message.
152    User,
153    /// An agent turn: LLM inference, tool calls, observations.
154    Agent,
155    /// A source this build doesn't recognise, carried through verbatim.
156    Other(String),
157}
158
159impl StepSource {
160    /// The wire string: `"system"` / `"user"` / `"agent"` / the raw value.
161    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// The schema is a plain (open-vocabulary) string, not a closed enum — the
189// derive would freeze the variant set and hide `Other`. Inlined at use sites so
190// SDK codegens see an ordinary string field.
191#[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/// A step's `message` (or an observation's `content`): plain text, or — since
208/// ATIF v1.6 — an ordered list of multimodal [`ContentPart`]s. Untagged on the
209/// wire: a JSON string or a JSON array.
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
212#[serde(untagged)]
213pub enum StepContent {
214    /// Plain text content.
215    Text(String),
216    /// Multimodal content (text and image parts).
217    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    /// The text projection: the string itself, or the `text` parts joined by
240    /// newlines (image parts are skipped) — the same rule as
241    /// [`crate::content::text_of`].
242    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    /// True when there is no content at all (empty string or no parts).
254    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/// One multimodal content piece (ATIF _ContentPartSchema_, v1.6+): `type` is
263/// `"text"` (with `text` set) or `"image"` (with `source` set). Kept an open
264/// struct — not a tagged enum — so a future ATIF content type parses instead
265/// of failing.
266#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
267#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
268pub struct ContentPart {
269    /// `"text"` or `"image"` (open vocabulary).
270    #[serde(rename = "type")]
271    pub kind: String,
272    /// The text content, when `type == "text"`.
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub text: Option<String>,
275    /// The image reference, when `type == "image"`.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub source: Option<ImageSource>,
278    /// Custom part-level metadata.
279    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
280    pub extra: Metadata,
281}
282
283impl ContentPart {
284    /// A text part.
285    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    /// An image part referencing a stored file (see [`ImageSource`]).
294    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/// An image stored alongside the trajectory and referenced by path/URL (ATIF
307/// _ImageSourceSchema_) — never embedded bytes.
308#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
309#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
310pub struct ImageSource {
311    /// MIME type (`image/png`, `image/jpeg`, …).
312    pub media_type: String,
313    /// Relative or absolute file path, or a URL (conventionally an `images/`
314    /// sidecar next to the trajectory file).
315    pub path: String,
316}
317
318/// One interaction turn (ATIF _StepObject_): a system prompt, a user message,
319/// or a complete agent turn (inference, tool calls, observation).
320#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
321#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
322pub struct Step {
323    /// 1-based ordinal of this step.
324    pub step_id: u64,
325    /// ISO 8601 timestamp, carried as an opaque string.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub timestamp: Option<String>,
328    /// Who produced this step (open vocabulary — see [`StepSource`]).
329    pub source: StepSource,
330    /// The LLM used for this turn, overriding [`Agent::model_name`].
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub model_name: Option<String>,
333    /// Qualitative or quantitative effort (`"low"` / `0.3` / …). Carried as
334    /// opaque JSON — ATIF allows string or float.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub reasoning_effort: Option<serde_json::Value>,
337    /// The dialogue message. Required by ATIF (it may be an empty string) and
338    /// always serialized; parsing is lenient — a step that omits it defaults
339    /// to empty text instead of rejecting the whole document.
340    #[serde(default)]
341    pub message: StepContent,
342    /// The agent's explicit internal reasoning, when surfaced.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub reasoning_content: Option<String>,
345    /// Structured tool/function invocations made in this step.
346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
347    pub tool_calls: Vec<ToolCall>,
348    /// Environment feedback for this step's actions, correlated to
349    /// `tool_calls` via `source_call_id`.
350    #[serde(default, skip_serializing_if = "Option::is_none")]
351    pub observation: Option<Observation>,
352    /// LLM operational metrics for this step.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub metrics: Option<StepMetrics>,
355    /// Number of LLM inferences this step represents. `Some(0)` on an agent
356    /// step marks a deterministic (non-LLM) dispatch; `None` means the
357    /// producer didn't track it.
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub llm_call_count: Option<u32>,
360    /// True when this step was copied from a prior trajectory for context
361    /// (e.g. retained across a compaction boundary) — not a new interaction.
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub is_copied_context: Option<bool>,
364    /// Custom step-level metadata (e.g. the `context_management` convention).
365    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
366    pub extra: Metadata,
367}
368
369impl Step {
370    /// A step with the required fields; everything else at its default.
371    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    /// True for an agent step (the only kind projections count).
390    pub fn is_agent(&self) -> bool {
391        self.source == StepSource::Agent
392    }
393}
394
395/// One structured tool/function invocation (ATIF _ToolCallSchema_).
396#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
397#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
398pub struct ToolCall {
399    /// Unique id, correlated with [`ObservationResult::source_call_id`].
400    pub tool_call_id: String,
401    /// The tool/function name (what `tool_called(...)` and friends grade).
402    pub function_name: String,
403    /// The invocation arguments — a JSON object per ATIF (may be `{}`).
404    pub arguments: serde_json::Value,
405    /// Custom call-level metadata (timeout, retry count, …).
406    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
407    pub extra: Metadata,
408}
409
410impl ToolCall {
411    /// A tool call with the required fields.
412    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/// Environment feedback for one step (ATIF _ObservationSchema_).
427#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
428#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
429pub struct Observation {
430    /// One result per tool call or action.
431    pub results: Vec<ObservationResult>,
432}
433
434/// One observation result (ATIF _ObservationResultSchema_).
435#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
436#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
437pub struct ObservationResult {
438    /// The [`ToolCall::tool_call_id`] this result answers; absent for actions
439    /// outside the standard tool-calling format.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub source_call_id: Option<String>,
442    /// The tool/action output (text or multimodal parts). May be omitted when
443    /// `subagent_trajectory_ref` carries the full detail.
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub content: Option<StepContent>,
446    /// References to delegated subagent trajectories (embedded via
447    /// `trajectory_id` or external via `trajectory_path`).
448    #[serde(default, skip_serializing_if = "Vec::is_empty")]
449    pub subagent_trajectory_ref: Vec<SubagentTrajectoryRef>,
450    /// Custom result-level metadata (retrieval score, source doc id, …).
451    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
452    pub extra: Metadata,
453}
454
455/// A reference to a delegated subagent trajectory (ATIF
456/// _SubagentTrajectoryRefSchema_). At least one of `trajectory_id` (embedded
457/// form) or `trajectory_path` (file-ref form) must be set to be resolvable.
458#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
459#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
460pub struct SubagentTrajectoryRef {
461    /// Resolution key against the parent's `subagent_trajectories` array.
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub trajectory_id: Option<String>,
464    /// External location of the subagent trajectory (file path, URL, …).
465    #[serde(default, skip_serializing_if = "Option::is_none")]
466    pub trajectory_path: Option<String>,
467    /// The subagent's run identity — informational only, never a resolution key.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub session_id: Option<String>,
470    /// Custom metadata about the subagent execution (summary, exit status, …).
471    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
472    pub extra: Metadata,
473}
474
475/// Per-step LLM metrics (ATIF _MetricsSchema_). All optional. Token ids and
476/// logprobs are parsed and round-tripped, never interpreted by Mira.
477#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
478#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
479pub struct StepMetrics {
480    /// Total input tokens for this turn, **including** cached tokens.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub prompt_tokens: Option<u64>,
483    /// Tokens generated by the response (reasoning + tool calls included).
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub completion_tokens: Option<u64>,
486    /// The subset of `prompt_tokens` served from cache.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub cached_tokens: Option<u64>,
489    /// Monetary cost of this step's API call, in USD.
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub cost_usd: Option<f64>,
492    /// Prompt token ids (carried opaquely for RL pipelines).
493    #[serde(default, skip_serializing_if = "Option::is_none")]
494    pub prompt_token_ids: Option<Vec<u64>>,
495    /// Completion token ids (carried opaquely for RL pipelines).
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub completion_token_ids: Option<Vec<u64>>,
498    /// Per-completion-token log probabilities (carried opaquely).
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub logprobs: Option<Vec<f64>>,
501    /// Provider-specific extras. ATIF has no first-class reasoning-token
502    /// field; providers stash it here as `reasoning_tokens`, and the usage
503    /// projection reads that key when present.
504    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
505    pub extra: Metadata,
506}
507
508/// Aggregate trajectory metrics (ATIF _FinalMetricsSchema_). All optional.
509#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
510#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
511pub struct FinalMetrics {
512    /// Sum of `prompt_tokens` across all steps.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub total_prompt_tokens: Option<u64>,
515    /// Sum of `completion_tokens` across all steps.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub total_completion_tokens: Option<u64>,
518    /// Sum of `cached_tokens` across all steps.
519    #[serde(default, skip_serializing_if = "Option::is_none")]
520    pub total_cached_tokens: Option<u64>,
521    /// Total cost for the whole trajectory, in USD.
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub total_cost_usd: Option<f64>,
524    /// Total steps (may differ from `steps.len()` when explained in `notes`).
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub total_steps: Option<u64>,
527    /// Custom aggregate metrics (`reasoning_tokens` is read from here by the
528    /// usage projection when present).
529    #[serde(default, skip_serializing_if = "Metadata::is_empty")]
530    pub extra: Metadata,
531}
532
533/// A `u64` stashed in an ATIF `extra` map (e.g. `reasoning_tokens`), or 0.
534fn 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    /// A new, empty trajectory for `agent`, stamped with [`ATIF_VERSION`].
543    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    /// Parse one ATIF JSON document. Lenient within v1 — any `ATIF-v1.x`
559    /// parses, unknown fields are ignored — but a non-v1 `schema_version`
560    /// (e.g. a future `ATIF-v2.0`) is rejected with an error, never a panic:
561    /// trajectory JSON is untrusted study/agent output, so malformed input
562    /// must degrade to a message the caller can put on `transcript.error`.
563    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    /// [`from_json`](Self::from_json) over an already-parsed JSON value.
570    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    /// The text of the last agent step's message — the trajectory's
584    /// `final_response` projection.
585    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    /// Every `tool_calls[].function_name`, in step order (top level only —
594    /// subagent trajectories are not recursed).
595    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    /// The `iterations` projection: agent steps that performed LLM inference —
603    /// `llm_call_count != Some(0)` (`Some(0)` marks a deterministic dispatch;
604    /// `None` counts, the producer just didn't track it).
605    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    /// The [`Usage`] projection: `final_metrics` when present, else the sum of
613    /// per-step metrics. ATIF `prompt`/`completion`/`cached` map onto
614    /// `input`/`output`/`cache_read`; `reasoning_tokens` is read from the
615    /// corresponding `extra` map when a provider stashed it there (ATIF has no
616    /// first-class slot for it).
617    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    /// Fill a transcript's flat fields from this trajectory:
639    /// `final_response` = the last agent step's text ([`final_agent_text`]);
640    /// `tool_calls` = every `function_name` in step order
641    /// ([`tool_call_names`]); `tool_calls_count` = that length; `iterations` =
642    /// agent steps with `llm_call_count != Some(0)` ([`agent_iterations`]);
643    /// `usage` = [`usage`](Self::usage).
644    ///
645    /// **Fills, never overwrites**: only fields still at their defaults are
646    /// touched, so a subject that set a flat field explicitly wins. This is
647    /// the zero-burden contract — set `trajectory` alone and the rest is
648    /// derived; the framework calls this wherever a transcript is produced or
649    /// received (see [`Transcript::project_trajectory`]), so neither studies
650    /// nor SDKs need to. Top level only: subagent trajectories are opaque.
651    ///
652    /// [`final_agent_text`]: Self::final_agent_text
653    /// [`tool_call_names`]: Self::tool_call_names
654    /// [`agent_iterations`]: Self::agent_iterations
655    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    /// Synthesize a minimal ATIF trajectory from a transcript's flat projected
676    /// fields — the **lossy inverse** of [`project_into`](Self::project_into),
677    /// for a transcript that carries no real [`trajectory`] of its own (e.g. a
678    /// saved run's summary, which keeps only `final_response` / `tool_calls` /
679    /// `iterations` / `usage`).
680    ///
681    /// The reconstruction is deliberately faithful only in the projections it
682    /// can recover, and lossy everywhere else:
683    /// * one agent [`Step`] per recorded tool name, each carrying a single
684    ///   [`ToolCall`] with a synthesized id and **empty** arguments (`{}`) — a
685    ///   names-only summary has no arguments, observations, per-step reasoning,
686    ///   or timestamps to restore;
687    /// * a final agent step carrying `final_response`, so
688    ///   [`final_agent_text`](Self::final_agent_text) round-trips;
689    /// * [`usage`](Self::usage) is carried exactly on
690    ///   [`final_metrics`](Trajectory::final_metrics), so it reproduces 1:1;
691    /// * `iterations` is **not** reproduced — the synthesized step count reflects
692    ///   the tool calls, not the subject's reported turn count.
693    ///
694    /// A caller that has the real trajectory (e.g. from an `execute` artifact)
695    /// should emit that instead; this is the fallback path.
696    ///
697    /// [`trajectory`]: Transcript::trajectory
698    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        // Carry usage verbatim on final_metrics so `usage()` reproduces it.
719        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
738/// Agent identity stamped on a trajectory synthesized by
739/// [`Trajectory::from_transcript`] from flat summary fields (there is no real
740/// producer to name).
741const SYNTH_AGENT_NAME: &str = "mira-export";
742/// Version marker for a [`from_transcript`](Trajectory::from_transcript)
743/// synthesis — `"0"` signals "reconstructed, not produced".
744const SYNTH_AGENT_VERSION: &str = "0";
745
746/// One structured tool invocation as seen by scorers: the name, the arguments
747/// (when a trajectory carries them), and the correlated observation content.
748/// Produced by [`Transcript::tool_invocations`].
749#[derive(Clone, Copy, Debug)]
750pub struct ToolInvocation<'a> {
751    /// The tool/function name.
752    pub name: &'a str,
753    /// The call arguments — `None` when synthesized from the legacy
754    /// name-only `tool_calls` list.
755    pub arguments: Option<&'a serde_json::Value>,
756    /// The observation content correlated via `source_call_id`, when any.
757    pub result: Option<&'a StepContent>,
758}
759
760impl Transcript {
761    /// Structured tool invocations, ATIF-first: from [`trajectory`] when
762    /// present (names + arguments + observation content joined on
763    /// `source_call_id`), else synthesized name-only from the legacy
764    /// [`tool_calls`] list. Scorers use this — never `events`, whose
765    /// producer-shaped walking stays quarantined in the adapters.
766    ///
767    /// [`trajectory`]: Transcript::trajectory
768    /// [`tool_calls`]: Transcript::tool_calls
769    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    /// The conformance fixture (also run by both SDKs); the RFC's worked
810    /// example lives there, so these unit tests reuse it instead of inlining a
811    /// second copy.
812    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")); // future v1 minor
833        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"); // older v1 minor: accepted
842        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        // Opaque carries: token ids, logprobs, metrics.extra.reasoning_tokens.
851        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        // Round-trip: serialize and re-parse to the same value.
857        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        // Usage from final_metrics (not the per-step sum).
877        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        // Forward compat: a future ATIF v1 minor adds fields and a new source
886        // kind; this build must parse it, not reject it.
887        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        // The unknown source round-trips verbatim.
900        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        // Malformed JSON errors too (never panics).
914        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        // Untagged on the wire: a string parses as Text, an array as Parts.
941        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"), // None: counts
982            {
983                let mut s = Step::new(3, StepSource::Agent, "");
984                s.llm_call_count = Some(0); // deterministic dispatch: excluded
985                s
986            },
987            {
988                let mut s = Step::new(4, StepSource::Agent, "done");
989                s.llm_call_count = Some(2); // aggregated inference: counts
990                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        // Defaults are filled…
1004        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        // …but a field the subject set explicitly is never overwritten.
1012        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        // The still-default fields were filled alongside.
1020        assert_eq!(set.tool_calls, vec!["grep"]);
1021    }
1022
1023    #[test]
1024    fn from_trajectory_needs_no_client_calls() {
1025        // The zero-burden path: hand over a trajectory, get a fully projected
1026        // transcript — nothing else to call.
1027        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        // `events` stays empty — it is independent, never required alongside.
1037        assert!(transcript.events.is_empty());
1038    }
1039
1040    #[test]
1041    fn tool_invocations_prefer_trajectory_then_fall_back_to_names() {
1042        // ATIF-first: names + arguments + observation joined on source_call_id.
1043        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()); // no correlated observation
1066
1067        // Legacy fallback: names only, from the flat tool_calls list.
1068        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        // The lossy inverse of project_into: a names-only summary becomes a
1082        // valid ATIF document whose recoverable projections round-trip.
1083        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        // Valid ATIF: parses back losslessly.
1102        let back = Trajectory::from_json(&serde_json::to_string(&traj).unwrap()).unwrap();
1103        assert_eq!(back, traj);
1104
1105        // Recoverable projections reproduce the summary's fields.
1106        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); // carried exactly
1111        // Tool-call arguments are unknown to a names-only summary.
1112        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        // A response-only summary (no tools, no usage) yields a one-step doc.
1123        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        // A fully empty transcript yields a valid, empty-step document.
1129        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        // Projections count only the top level; subagent tool calls stay out.
1149        let transcript = Transcript::from_trajectory(t);
1150        assert!(transcript.tool_calls.is_empty());
1151        assert_eq!(transcript.iterations, 1);
1152    }
1153}