Trajectory Format
A trajectory is the complete record of a single agent run. Saved as JSON via --output-dir, trajectories can be re-graded, compared, and analyzed.
ATIF tool evidence
Section titled “ATIF tool evidence”fromAtif and validateAtifTrajectory are exported from @microsoft/vally and
@microsoft/vally/trajectory. Validate the document before conversion. Integrations
requiring explicit tool outcomes and distinct subagent instances must depend on a
Vally package version supporting these options, then opt in:
import { fromAtif, validateAtifTrajectory } from "@microsoft/vally";
const trajectory = fromAtif(validateAtifTrajectory(input), { stimulusName: "example", stimulusPrompt: "Read a file", workDir: ".", toolResultStatus: "require-explicit", subagentIdentity: "instance",});Producers should put Vally tool evidence in the microsoft.vally namespace:
{ "extra": { "microsoft.vally": { "tool_call_result": "failure", "tool_error_reason": "FILE_NOT_FOUND" } }}Tool observations match source_call_id to tool_call_id within the same
agent step. The matched call’s extra["microsoft.vally"].tool_call_result is authoritative:
"success" maps to success: true; "failure", "rejected", "denied", and
"timeout" map to success: false. Result text is never used to infer status;
an explicitly successful empty result is valid. No observation means no
tool_result, even if a call has status metadata. These rules apply to root and
inlined subagent calls. Existing flat extra.tool_call_result metadata is accepted
as a compatibility alias only when the microsoft.vally namespace is absent.
When present, the namespace takes precedence, even if its status is missing or
unknown; conversion never falls back to a conflicting flat key.
The default toolResultStatus: "legacy" retains the historical success: true
fallback for observations without recognized status metadata, including
producer-defined values such as "cached", for compatibility with existing ATIF.
General validateAtifTrajectory validation keeps tool-call extra opaque.
This fallback is not evidence of
successful execution. "require-explicit" instead throws for missing, unknown,
or malformed observed status (including null and booleans), an unmatched observed
call, or duplicate call IDs within a step.
Unknown status cannot be represented by the internal boolean success field;
strict mode does not turn unknowns into either successes or failures.
Observation content and order are retained (multimodal parts use the usual text
flattening). Producer metadata such as extra["microsoft.vally"].tool_error_reason remains in
trajectory.raw, which is in-process only and is not persisted in JSONL. Error
messages in observation content remain in the converted result.
The default subagentIdentity: "name" preserves name-based scopes, including
aggregation of same-named children and repeated inlining for repeated references.
"instance" uses encodeURIComponent(trajectory_id) as each direct child’s
agentId; nested children append / and their encoded local trajectory ID.
For example, two worker agents linked as first and second remain separate;
their nested leaf agents become first/leaf and second/leaf. Literal / and
% characters are encoded, preventing path collisions. session_id is not used
because distinct child instances may share it.
Instance mode inlines each referenced child path once, at its first reference,
without duplicating tool calls or tokens on subsequent references.
tool_call.data.launchedAgentIds uses those same IDs on every reference.
scope: { agent: "first" } selects that instance, not the display name worker;
parent and subagent scopes are unchanged. Root events have no agentId.
Readable names remain available in the source ATIF document.
Top-level structure
Section titled “Top-level structure”interface Trajectory { id: string; // Unique run identifier stimulus: Stimulus; // The prompt + config that produced this events: TrajectoryEvent[]; // Flat array of typed events metrics: TrajectoryMetrics; // Computed aggregates output: string; // Final agent output text workDir: string; // Workspace path during run artifactDir?: string; // Artifact dir outside the workspace; some graders (e.g. custom-metrics) read it before workDir artifactDirStrict?: boolean; // When true, artifactDir is authoritative: artifact-reading graders do NOT fall back to workDir assetsDir?: string; // Setup-input dir staged outside the workspace (the EVALUATE_ASSETS dir); host path, absent when re-graded elsewhere diff?: string; // Cumulative workspace diff diffPath?: string; // Cumulative diff sidecar path diffError?: string; // Trusted error resolving/capturing cumulative diff evidence turnDiffs?: Array< { turn: number } & ({ diff: string } | { diffPath: string } | { error: string }) >; workspacePatchSource?: "fast-path" | "snapshot" | "reused-diff"; // Which strategy produced workspace.patch workspacePatchCaptureError?: string; // Set when workspace.patch capture was attempted but failed metadata: TrajectoryMetadata; // Execution context raw?: unknown; // Escape hatch: unconverted source doc (e.g. raw ATIF). In-process only — stripped from JSONL output, never persisted.}workspace.patch — per-trial workspace diff artifact
Section titled “workspace.patch — per-trial workspace diff artifact”Written to <session-dir>/workspace.patch for every trial with a workspace, regardless of whether diff-based graders are configured. An empty file means the workspace was unchanged.
The content is stripped from results.jsonl (it’s already on disk). If capture fails — including when the diff exceeds the 64 MiB cap — workspacePatchCaptureError appears in the JSONL record and a workspace-patch-capture-failed warning is emitted; the trial still succeeds. workspacePatchSource records which strategy produced the patch (fast-path, snapshot, or reused-diff).
Workspace diff artifacts
Section titled “Workspace diff artifacts”diff / diffPath represent the cumulative workspace change from the start of a run to its
final state. When no valid cumulative diff is available, diffError preserves the exact trusted
capture or sidecar-resolution failure; a diff grader reports it as an error rather than treating
the missing evidence as an empty diff. A local run may retry a failed capture while its baseline
is still available; if that succeeds, the recovered diff replaces diffError before saving the
trajectory. Multi-turn runs that use a turn-scoped static diff grader
also persist turnDiffs:
one 0-based record per turn Vally attempted to bound, containing exactly one of an inline diff,
a relative diffPath sidecar, or an error when Vally could not safely capture that boundary.
These diffs may contain raw workspace content and should be treated as sensitive.
An absent, malformed, or error turn record fails a scoped diff grader; Vally never substitutes
the cumulative diff except for turn 0 when both the caller-supplied and recorded stimuli are
explicitly single-turn, where the two are equivalent by construction. JSONL sidecar paths resolve relative to
the run directory supplied to
vally grade with --run-dir.
Library users calling sliceTrajectoryToTurn(trajectory, turn) receive an event/output/metrics
view that preserves the trajectory’s other fields, including cumulative diff evidence. A live
view also retains local diff-retry eligibility; successful recovery updates both the view and
its source trajectory. The grading pipeline uses a separate internal projection for turn-scoped
static diff graders, which strips cumulative diff sources before injecting only the selected
turnDiffs record.
raw — the escape hatch
Section titled “raw — the escape hatch”raw carries the original, unconverted source document a trajectory was built
from (e.g. the raw ATIF object) for custom graders that need fields the
normalized events array doesn’t capture. It is populated only by offline
adapters like fromAtif — live executor runs leave it undefined — and is
stripped from JSONL output, so it reaches graders during a run but is never
persisted. Because it isn’t persisted, a grader that reads raw works only on
the live pass: re-grading from JSONL (vally grade <file.jsonl>) will see
undefined.
artifactDir / artifactDirStrict — where artifact graders read
Section titled “artifactDir / artifactDirStrict — where artifact graders read”artifactDir is a directory of run artifacts written outside the graded
workDir (e.g. a benchmark harness’s output dir). Artifact-reading graders such
as custom-metrics look up their files
there first, then fall back to workDir when the file is absent from the
artifact dir. A live executor run typically sets artifactDir and keeps this
lenient fallback; the exception is a relocating backend whose artifact dir the
runner could not materialize to a host-readable path (see
Remote backends below), which makes it strict.
artifactDirStrict makes artifactDir authoritative: when it is true,
those graders do not fall back to workDir, so a file missing from the
artifact dir fails the grader instead of silently resolving against a (possibly
stale) workspace copy. It is set in two cases: by
grade --artifact-dir for offline re-grade, where the
workspace is usually absent or stale and a silent fallback would report a
misleading result; and by the runner when a relocating backend’s artifact dir
could not be materialized to a host-readable path (see below). Unlike raw, it
is a normal additive field that round-trips through JSONL.
Remote backends
Section titled “Remote backends”When a trial runs on a relocating backend (Docker/remote) the executor writes
artifactDir on the far side, so the raw path is not readable from the grading
host. The runner mirrors what it already does for workDir: it materializes
the artifact dir back to a host-readable location under the run’s report
directory and rewrites artifactDir to that placed path, so on success the
value recorded in results.jsonl points at a location the host can read
(including for offline re-grade). Materialization is opt-in: a backend requests
it by listing "artifact-dir" in DisposableTrialResult.supportedExportKinds.
A backend that omits it — including the local backend, whose artifact dir is
already host-readable — is left untouched (materialization is driven by the
declared capability, never inferred from a stat of the recorded path, since a
relocating worker can report a far-side path that coincidentally exists on the
host). When materialization cannot deliver readable artifacts, artifactDir
follows the fallback rules below instead.
If materialization can’t deliver readable artifacts to the host, the runner
emits an artifact-dir-materialize-skipped / artifact-dir-materialize-failed
diagnostic and never leaves a stale far-side path behind. When the artifacts
survive at a host-readable recovery location, artifactDir points there.
Otherwise, when a host destination was resolved, artifactDir is set to that
destination with artifactDirStrict = true so a later offline re-grade fails
clearly against the missing artifact location instead of silently falling back
to a stale workspace copy; only when no host destination exists at all is
artifactDir dropped.
assetsDir — where setup inputs were staged
Section titled “assetsDir — where setup inputs were staged”assetsDir is the managed per-trial directory holding files staged with
agent_environment.files[].dest_root: "assets".
It sits outside the graded workspace, so its contents never appear in
diff / turnDiffs or to tree-scanning graders, and it is removed when the
trial ends.
It holds run inputs — recorded session logs, fixtures a grader reads —
which is what distinguishes it from artifactDir, the run outputs
directory. The two are never conflated.
Graders read it as trajectory.assetsDir; the
program grader also receives it as the
EVALUATE_ASSETS environment variable, the same variable the agent sees.
Because it is a host path allocated fresh per trial, it is normally absent
when re-grading a stored trajectory — on another host, or after the trial’s
cleanup removed it. Treat an unset or non-existent assetsDir as the normal
case. vally grade --assets-dir <path> stamps it
explicitly for an offline re-grade.
Assets are agent-visible and writable — the agent receives the path as
EVALUATE_ASSETS — so a grader must not treat their contents as trusted or
assume they survived the run unmodified.
Events
Section titled “Events”Events are a discriminated union on the type field. Every event has a timestamp.
tool_call
Section titled “tool_call”Agent invoked a tool.
{ "type": "tool_call", "timestamp": "2025-01-15T10:30:00.000Z", "data": { "toolName": "write_file", "toolCallId": "call_abc123", "turnId": "0", "arguments": { "path": "add.test.js", "content": "..." } }}data.arguments is optional and may be any JSON root shape (object, array, string, number,
boolean, or null); keyed objects remain common when tools accept named parameters.
{ "type": "tool_call", "timestamp": "2025-01-15T10:30:00.000Z", "data": { "toolName": "apply_patch", "toolCallId": "call_def456", "arguments": "*** Begin Patch\n*** Update File: foo.txt\n@@\n-old\n+new\n*** End Patch" }}Consumers that read named keys from data.arguments should first narrow/check that the
value is a non-null, non-array object before accessing properties.
An explicit null is a present argument value and remains distinct from an omitted
arguments field. Graders preserve that distinction: name-args fingerprints them
differently, and the tool-calls pattern matcher serializes null as "null" while
omitted arguments serialize as the empty string.
turnId (optional) identifies the model-response turn that issued the call, matching the
surrounding turn_start/turn_end events when present (subagent-inlined calls carry a
turn id but no surrounding boundaries). Graders use it to group parallel tool-call
batches by turn identity (see the tool-calls grader);
it is absent for executors that don’t carry a turn id.
launchedAgentIds (optional) identifies subagents linked to the call by the source
trajectory. Offline adapters use it to preserve launch boundaries when events are
scoped to a named subagent.
When tool-call simulation intercepts a call, the
event carries "simulated": true so graders and reports can distinguish it from a
real invocation.
tool_result
Section titled “tool_result”Tool returned a result.
{ "type": "tool_result", "timestamp": "2025-01-15T10:30:01.000Z", "data": { "toolName": "write_file", "toolCallId": "call_abc123", "success": true, "result": "File written successfully" }}Failed tool results may omit result and include an error object instead. The
message is required and code is optional.
{ "type": "tool_result", "timestamp": "2025-01-15T10:30:01.000Z", "data": { "toolName": "write_file", "toolCallId": "call_abc123", "success": false, "error": { "message": "The requested path is not writable", "code": "permission_denied" } }}token_usage
Section titled “token_usage”LLM token counts from a single API call.
{ "type": "token_usage", "timestamp": "2025-01-15T10:30:00.500Z", "data": { "inputTokens": 1500, "outputTokens": 350, "model": "gpt-5.5", "cacheReadTokens": 200, "cacheWriteTokens": 0, "cost": { "provider": "github-copilot", "unit": "nano-aiu", "amount": 1250000000 } }}Copilot SDK sources can include an optional provider-labelled cost. Live SDK events report a request cost; saved Copilot logs are reconstructed from per-model session totals. Other executors and older Copilot logs may omit cost.
cost_unavailable
Section titled “cost_unavailable”The source could not provide a complete usage summary for a provider and unit.
This event prevents tokenUsage.cost from being reported as a misleading
subtotal while preserving token metrics from other valid events.
{ "type": "cost_unavailable", "timestamp": "2025-01-15T10:30:00.500Z", "data": { "provider": "github-copilot", "unit": "nano-aiu", "model": "gpt-5.5", "reason": "malformed_usage" }}turn_start / turn_end
Section titled “turn_start / turn_end”Conversation turn boundaries.
{ "type": "turn_start", "timestamp": "...", "data": { "turnId": "turn-1" } }{ "type": "turn_end", "timestamp": "...", "data": { "turnId": "turn-1" } }assistant_message / user_message
Section titled “assistant_message / user_message”Text messages in the conversation.
{ "type": "assistant_message", "timestamp": "...", "data": { "content": "I'll write tests for..." } }{ "type": "user_message", "timestamp": "...", "data": { "content": "Write tests for add()" } }skill_activation
Section titled “skill_activation”A skill was loaded by the agent.
{ "type": "skill_activation", "timestamp": "...", "data": { "name": "test-writer", "path": "/skills/test-writer/SKILL.md", "pluginName": "copilot", "allowedTools": ["write_file", "read_file"] }}reasoning
Section titled “reasoning”The agent’s reasoning (thinking) for a step, when the source captures it. Emitted before the step’s assistant_message.
{ "type": "reasoning", "timestamp": "...", "data": { "content": "First I'll inspect the failing test, then trace the null deref." }}system
Section titled “system”A typed system-level event, such as context compaction. eventType carries the semantic kind (e.g. "compaction") so graders can match on it rather than parsing free text. message and observation are optional and omitted when the source provides no text. details carries structured, source-defined fields for the eventType (e.g. tokensRemoved); field names are only stable within a given eventType, not across all system events.
A system event is emitted only when the source marks the step with a typed kind. Other system steps remain custom events, so consumers should not expect every system step to appear as a system event. eventType values are not a fixed platform enum — new ones can appear as executors add support for more agent lifecycle events. Known values include:
compaction— the conversation was successfully compacted (summarized) to free up contexttruncation— the conversation was hard-truncated to fit the context windowsnapshot_rewind— the session was rewound to an earlier point, discarding later eventsmode_changed— the agent’s operating mode changed (e.g. interactive, plan, autopilot)model_set— the session selected its initial modelmodel_change— the session transitioned from one model to anothertask_complete— the agent’s request to complete the task was acceptedhandoff— the session was handed off from another sessionsession_limits_changed— the session’s usage/rate limits were set or cleared
{ "type": "system", "timestamp": "...", "data": { "eventType": "compaction", "message": "Compacted 40 messages", "observation": "Reduced to 12 messages, removed 8000 tokens", "details": { "success": true, "messagesRemoved": 28, "tokensRemoved": 8000 } }}Something went wrong during the run.
{ "type": "error", "timestamp": "...", "data": { "message": "Request timed out", "type": "TimeoutError", "code": 408 }}Metrics
Section titled “Metrics”Computed from events after the run completes:
interface TrajectoryMetrics { tokenUsage: { inputTokens: number; outputTokens: number; totalTokens: number; cacheReadTokens: number; cacheWriteTokens: number; callCount: number; cost?: ProviderCost; byModel: Record< string, { inputTokens: number; outputTokens: number; callCount: number; } >; }; toolCallCount: number; toolCallBreakdown: Record<string, number>; // { "write_file": 3, "read_file": 1 } simulatedToolCallCount: number; // tool calls that were simulated, not run for real skillActivationCount: number; skillActivationBreakdown: Record<string, number>; // { "test-writer": 1 } turnCount: number; wallTimeMs: number; errorCount: number;}
type ProviderCost = { provider: "github-copilot"; unit: "nano-aiu"; amount: number;};tokenUsage.cost is the provider-labelled usage cost for the evaluated agent
across the trajectory’s LLM calls. It is present only when every
token_usage event reports the same provider and unit with a safe,
non-negative integer amount; an explicit 0 is preserved, while an omitted
field means the complete total is unknown. It does not include LLM grader or
judge usage, and Vally does not convert nano-AI units to displayed AI Credits.
Metadata
Section titled “Metadata”interface TrajectoryMetadata { model: string; // Model used for execution skillsLoaded: string[]; // Names of skills that were loaded startedAt: Date; completedAt: Date; executor: string; // Which executor ran this sessionID: string;}Working with trajectories
Section titled “Working with trajectories”# Save trajectories (one trial-result record per trial, in results.jsonl)vally eval --eval-spec eval.yaml --output-dir ./vally-results
# Re-grade trajectories from a previous runcat ./vally-results/*/results.jsonl | vally grade --eval-spec eval.yaml
# Inspect trial trajectories with jq. `results.jsonl` interleaves# `trial-result` records (with a nested `trajectory` field) with a final# `run-summary` line, so filter on `.type == "trial-result"` first.cat ./vally-results/*/results.jsonl | jq 'select(.type == "trial-result") | .trajectory.metrics'cat ./vally-results/*/results.jsonl | jq 'select(.type == "trial-result") | .trajectory.events[] | select(.type == "tool_call") | .data.toolName'cat ./vally-results/*/results.jsonl | jq 'select(.type == "trial-result") | .trajectory.events | length'