Skip to content

Commit 1c2988b

Browse files
authored
fix(artifacts): flatten metrics sidecar (#1685)
1 parent 24315bc commit 1c2988b

12 files changed

Lines changed: 407 additions & 72 deletions

File tree

apps/cli/src/commands/results/manifest.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import path from 'node:path';
44
import {
55
type EvaluationResult,
66
type ExternalTraceMetadataWire,
7+
type MetricsArtifactWire,
78
type ResultArtifactPointersWire,
89
type RunRuntimeSourceMetadata,
910
type TraceSummary,
1011
buildTraceFromMessages,
1112
fromTraceEnvelopeWire,
13+
normalizeMetricsArtifactWire,
1214
toCamelCaseDeep,
1315
traceEnvelopeToTraceSummary,
1416
traceEnvelopeToTranscriptMessages,
@@ -88,20 +90,6 @@ export interface ResultManifestRecord {
8890
readonly metadata?: Record<string, unknown>;
8991
}
9092

91-
interface MetricsUsageArtifact {
92-
readonly duration?: {
93-
readonly total_ms?: number;
94-
};
95-
readonly tokens?: {
96-
readonly input?: number;
97-
readonly output?: number;
98-
readonly reasoning?: number;
99-
};
100-
readonly cost?: {
101-
readonly usd?: number | null;
102-
};
103-
}
104-
10593
interface LegacyTimingArtifact {
10694
readonly duration_ms?: number;
10795
readonly token_usage?: {
@@ -390,7 +378,9 @@ function hydrateManifestRecord(
390378
options: ManifestHydrationOptions,
391379
): EvaluationResult {
392380
const grading = readOptionalJson<GradingArtifact>(baseDir, record.grading_path);
393-
const metrics = readOptionalJson<MetricsUsageArtifact>(baseDir, record.metrics_path);
381+
const metrics = normalizeMetricsArtifactWire(
382+
readOptionalJson<MetricsArtifactWire | Record<string, unknown>>(baseDir, record.metrics_path),
383+
);
394384
const timing = metrics ?? readOptionalJson<LegacyTimingArtifact>(baseDir, record.timing_path);
395385
const testId = record.test_id ?? 'unknown';
396386
const gradingAssertions = grading

apps/cli/src/commands/results/serve.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
loadConfig,
6363
loadProjectRegistry,
6464
normalizeCategoryPath,
65+
normalizeMetricsArtifactWire,
6566
normalizeTraceArtifactToTraceSessionResponse,
6667
omitExternalTraceMetadataKeys,
6768
readGitResultArtifact,
@@ -1154,7 +1155,7 @@ function buildRepeatTrialReadModels(
11541155
const answerPath = caseTrialArtifactPath(resultDir, runPath, 'outputs/answer.md');
11551156
const resultPath = caseTrialArtifactPath(resultDir, runPath, 'result.json');
11561157
const runResult = readArtifactJsonObject(baseDir, resultPath);
1157-
const metrics = readArtifactJsonObject(baseDir, metricsPath);
1158+
const metrics = normalizeMetricsArtifactWire(readArtifactJsonObject(baseDir, metricsPath));
11581159
const timing = readArtifactJsonObject(baseDir, timingPath);
11591160
const toolCalls = objectField(metrics, 'tool_calls');
11601161
const tokenUsage = objectField(metrics, 'tokens') ?? objectField(timing, 'token_usage');

apps/cli/src/commands/results/validate.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
import { existsSync, readFileSync, statSync } from 'node:fs';
1919
import path from 'node:path';
2020

21+
import {
22+
LEGACY_METRICS_SCHEMA_VERSION,
23+
METRICS_SCHEMA_VERSION,
24+
normalizeMetricsArtifactWire,
25+
} from '@agentv/core';
2126
import { command, positional, string } from 'cmd-ts';
2227

2328
import { RESULT_INDEX_FILENAME, resolveExistingRunPrimaryPath } from '../eval/result-layout.js';
@@ -114,6 +119,50 @@ function validateNoLegacyGradingFields(value: unknown, pathLabel: string): strin
114119
return errors;
115120
}
116121

122+
function validateMetricsArtifact(value: unknown): Diagnostic[] {
123+
if (!isRecord(value)) {
124+
return [{ severity: 'error', message: 'metrics.json must be an object' }];
125+
}
126+
127+
const diagnostics: Diagnostic[] = [];
128+
const schemaVersion = value.schema_version;
129+
const isCurrentSchema = schemaVersion === METRICS_SCHEMA_VERSION;
130+
const isLegacySchema = schemaVersion === LEGACY_METRICS_SCHEMA_VERSION;
131+
132+
if (isCurrentSchema && Object.hasOwn(value, 'trace')) {
133+
diagnostics.push({
134+
severity: 'error',
135+
message: 'metrics.json must not include trace in agentv.metrics.v2',
136+
});
137+
} else if (isLegacySchema && Object.hasOwn(value, 'trace')) {
138+
diagnostics.push({
139+
severity: 'warning',
140+
message: 'metrics.json uses legacy trace identity; readers ignore it',
141+
});
142+
}
143+
144+
if (isCurrentSchema && Object.hasOwn(value, 'metrics')) {
145+
diagnostics.push({
146+
severity: 'error',
147+
message: 'metrics.json must not include nested metrics in agentv.metrics.v2',
148+
});
149+
} else if (isLegacySchema && Object.hasOwn(value, 'metrics')) {
150+
diagnostics.push({
151+
severity: 'warning',
152+
message: 'metrics.json uses legacy nested metrics; readers flatten it',
153+
});
154+
}
155+
156+
if (!normalizeMetricsArtifactWire(value)) {
157+
diagnostics.push({
158+
severity: 'error',
159+
message: 'metrics.json does not match a supported metrics artifact shape',
160+
});
161+
}
162+
163+
return diagnostics;
164+
}
165+
117166
// ── Checks ───────────────────────────────────────────────────────────────
118167

119168
function checkDirectoryNaming(runDir: string): Diagnostic[] {
@@ -372,6 +421,21 @@ function checkArtifactFiles(runDir: string, entries: IndexEntry[]): Diagnostic[]
372421
severity: 'warning',
373422
message: `${testId}: metrics.json not found at '${entry.metrics_path}'`,
374423
});
424+
} else {
425+
try {
426+
const metrics = JSON.parse(readFileSync(metricsPath, 'utf8'));
427+
for (const diagnostic of validateMetricsArtifact(metrics)) {
428+
diagnostics.push({
429+
severity: diagnostic.severity,
430+
message: `${testId}: ${diagnostic.message}`,
431+
});
432+
}
433+
} catch {
434+
diagnostics.push({
435+
severity: 'error',
436+
message: `${testId}: metrics.json is not valid JSON`,
437+
});
438+
}
375439
}
376440
}
377441
}

apps/cli/test/commands/eval/artifact-writer.test.ts

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2019,12 +2019,8 @@ describe('writeArtifactsFromResults', () => {
20192019
);
20202020

20212021
expect(summary.schema_version).toBe(METRICS_SCHEMA_VERSION);
2022-
expect(summary.trace).toMatchObject({
2023-
schema_version: 'agentv.trace.v1',
2024-
trace_id: expect.any(String),
2025-
root_span_id: expect.any(String),
2026-
});
2027-
expect(summary.trace).not.toHaveProperty('path');
2022+
expect(summary).not.toHaveProperty('trace');
2023+
expect(summary).not.toHaveProperty('metrics');
20282024
expect(summary.source_artifacts).toMatchObject({
20292025
transcript_path: 'transcript.json',
20302026
grading_path: 'grading.json',
@@ -2034,29 +2030,29 @@ describe('writeArtifactsFromResults', () => {
20342030
await expect(
20352031
readFile(runArtifactPath(testDir, indexLine, 'sample-1', 'trace.json'), 'utf8'),
20362032
).rejects.toThrow();
2037-
expect(summary.metrics.total_turns).toBe(2);
2038-
expect(summary.metrics.total_tool_calls).toBe(4);
2039-
expect(summary.metrics.total_steps).toBe(2);
2040-
expect(summary.metrics.tool_calls).toMatchObject({
2033+
expect(summary.total_turns).toBe(2);
2034+
expect(summary.total_tool_calls).toBe(4);
2035+
expect(summary.total_steps).toBe(2);
2036+
expect(summary.tool_calls).toMatchObject({
20412037
Read: 1,
20422038
Bash: 1,
20432039
WebFetch: 1,
20442040
Edit: 1,
20452041
});
2046-
expect(summary.metrics.tool_call_counts).toMatchObject({
2042+
expect(summary.tool_call_counts).toMatchObject({
20472043
Read: 1,
20482044
Bash: 1,
20492045
WebFetch: 1,
20502046
Edit: 1,
20512047
});
2052-
expect(summary.metrics.tool_category_counts).toMatchObject({
2048+
expect(summary.tool_category_counts).toMatchObject({
20532049
file_read: 1,
20542050
shell: 1,
20552051
web_fetch: 1,
20562052
file_edit: 1,
20572053
});
2058-
expect(summary.metrics.tool_call_events).toHaveLength(4);
2059-
expect(summary.metrics.shell_commands).toEqual([
2054+
expect(summary.tool_call_events).toHaveLength(4);
2055+
expect(summary.shell_commands).toEqual([
20602056
{
20612057
command: 'bun test apps/cli/test/commands/eval/artifact-writer.test.ts',
20622058
tool_call_id: 'bash-1',
@@ -2065,25 +2061,25 @@ describe('writeArtifactsFromResults', () => {
20652061
duration_ms: 1200,
20662062
},
20672063
]);
2068-
expect(summary.metrics.files_read).toContainEqual({
2064+
expect(summary.files_read).toContainEqual({
20692065
path: 'src/input.ts',
20702066
tool_call_id: 'read-1',
20712067
source: 'tool_input',
20722068
});
2073-
expect(summary.metrics.files_modified).toContainEqual({
2069+
expect(summary.files_modified).toContainEqual({
20742070
path: 'src/output.ts',
20752071
operation: 'edit',
20762072
tool_call_id: 'edit-1',
20772073
source: 'tool_input',
20782074
});
2079-
expect(summary.metrics.files_modified).toContainEqual({
2075+
expect(summary.files_modified).toContainEqual({
20802076
path: 'src/output.ts',
20812077
operation: 'workspace_diff',
20822078
source: 'file_changes',
20832079
});
2084-
expect(summary.metrics.files_created).toEqual(['src/new.ts']);
2085-
expect(summary.metrics.files_deleted).toEqual(['src/gone.ts']);
2086-
expect(summary.metrics.web_fetches).toEqual([
2080+
expect(summary.files_created).toEqual(['src/new.ts']);
2081+
expect(summary.files_deleted).toEqual(['src/gone.ts']);
2082+
expect(summary.web_fetches).toEqual([
20872083
{
20882084
url: 'https://example.com/spec',
20892085
method: 'GET',
@@ -2092,17 +2088,14 @@ describe('writeArtifactsFromResults', () => {
20922088
tool_call_id: 'web-1',
20932089
},
20942090
]);
2095-
expect(summary.metrics.errors).toContainEqual({
2091+
expect(summary.errors).toContainEqual({
20962092
message: 'quality gate failed',
20972093
});
2098-
expect(summary.metrics.errors_encountered).toBe(1);
2099-
expect(summary.metrics.output_chars).toBe('Editing output.'.length);
2100-
expect(summary.metrics.transcript_chars).toBeGreaterThan(0);
2101-
expect(summary.metrics.thinking_blocks).toBe(2);
2102-
expect(summary.metrics.reasoning_blocks.map((block) => block.kind)).toEqual([
2103-
'reasoning',
2104-
'thinking',
2105-
]);
2094+
expect(summary.errors_encountered).toBe(1);
2095+
expect(summary.output_chars).toBe('Editing output.'.length);
2096+
expect(summary.transcript_chars).toBeGreaterThan(0);
2097+
expect(summary.thinking_blocks).toBe(2);
2098+
expect(summary.reasoning_blocks.map((block) => block.kind)).toEqual(['reasoning', 'thinking']);
21062099
expect(summary).not.toHaveProperty('usage_summary');
21072100

21082101
expect(summary).toMatchObject({

apps/cli/test/commands/results/export-e2e-providers.test.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -532,9 +532,11 @@ describe('export e2e — multi-provider metrics verification', () => {
532532
'utf8',
533533
),
534534
);
535-
expect(metrics.metrics.total_tool_calls).toBe(3);
536-
expect(metrics.metrics.tool_call_counts.Read).toBe(2);
537-
expect(metrics.metrics.tool_call_counts.Write).toBe(1);
535+
expect(metrics).not.toHaveProperty('trace');
536+
expect(metrics).not.toHaveProperty('metrics');
537+
expect(metrics.total_tool_calls).toBe(3);
538+
expect(metrics.tool_call_counts.Read).toBe(2);
539+
expect(metrics.tool_call_counts.Write).toBe(1);
538540

539541
expect(grading.component_results?.[0].component_results?.[0].assertion?.value).toBe(
540542
'Contains 42',
@@ -559,7 +561,9 @@ describe('export e2e — multi-provider metrics verification', () => {
559561
const metrics = JSON.parse(
560562
readFileSync(path.join(runArtifactDir(outputDir, COPILOT_RESULT), 'metrics.json'), 'utf8'),
561563
);
562-
expect(metrics.metrics.total_tool_calls).toBe(0);
564+
expect(metrics).not.toHaveProperty('trace');
565+
expect(metrics).not.toHaveProperty('metrics');
566+
expect(metrics.total_tool_calls).toBe(0);
563567
});
564568

565569
it('should handle error result in grading', async () => {
@@ -577,7 +581,9 @@ describe('export e2e — multi-provider metrics verification', () => {
577581
const metrics = JSON.parse(
578582
readFileSync(path.join(runArtifactDir(outputDir, ERROR_RESULT), 'metrics.json'), 'utf8'),
579583
);
580-
expect(metrics.metrics.errors_encountered).toBe(1);
584+
expect(metrics).not.toHaveProperty('trace');
585+
expect(metrics).not.toHaveProperty('metrics');
586+
expect(metrics.errors_encountered).toBe(1);
581587
});
582588

583589
it('should produce grading files for all test IDs in multi-target run', async () => {

apps/cli/test/commands/results/serve.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2293,6 +2293,80 @@ describe('serve app', () => {
22932293
path.join(runDir, resultDir, 'attempt-2', 'result.json'),
22942294
`${JSON.stringify({ transcript_summary: secondSummary })}\n`,
22952295
);
2296+
writeFileSync(
2297+
path.join(runDir, resultDir, 'attempt-1', 'metrics.json'),
2298+
`${JSON.stringify({
2299+
schema_version: 'agentv.metrics.v2',
2300+
artifact_id: 'metrics-repeat-case-1',
2301+
generated_at: '2026-03-25T10:05:00.000Z',
2302+
test_id: 'repeat-case',
2303+
target: 'gpt-4o',
2304+
source_artifacts: {},
2305+
duration: { total_ms: 1000, total_seconds: 1, source: 'provider_reported' },
2306+
tokens: { total: 3, input: 1, output: 2, reasoning: 0, source: 'provider_reported' },
2307+
cost: { usd: 0.01, source: 'provider_reported' },
2308+
tool_calls: { Bash: 1 },
2309+
tool_call_counts: { Bash: 1 },
2310+
tool_category_counts: { shell: 1 },
2311+
total_tool_calls: 1,
2312+
total_steps: 1,
2313+
total_turns: 1,
2314+
tool_call_events: [],
2315+
shell_commands: [],
2316+
files_read: [],
2317+
files_modified: [],
2318+
files_created: [],
2319+
files_deleted: [],
2320+
web_fetches: [],
2321+
errors: [],
2322+
errors_encountered: 0,
2323+
output_chars: 0,
2324+
transcript_chars: 0,
2325+
reasoning_blocks: [],
2326+
thinking_blocks: 0,
2327+
})}\n`,
2328+
);
2329+
writeFileSync(
2330+
path.join(runDir, resultDir, 'attempt-2', 'metrics.json'),
2331+
`${JSON.stringify({
2332+
schema_version: 'agentv.metrics.v1',
2333+
artifact_id: 'metrics-repeat-case-2',
2334+
generated_at: '2026-03-25T10:05:01.000Z',
2335+
test_id: 'repeat-case',
2336+
target: 'gpt-4o',
2337+
trace: {
2338+
schema_version: 'agentv.trace.v1',
2339+
artifact_id: 'execution-trace-repeat-case-2',
2340+
trace_id: 'trace-repeat-case-2',
2341+
root_span_id: 'span-repeat-case-2',
2342+
},
2343+
source_artifacts: {},
2344+
duration: { total_ms: 2000, total_seconds: 2, source: 'provider_reported' },
2345+
tokens: { total: 7, input: 3, output: 4, reasoning: 0, source: 'provider_reported' },
2346+
cost: { usd: 0.02, source: 'provider_reported' },
2347+
metrics: {
2348+
tool_calls: { Read: 2 },
2349+
tool_call_counts: { Read: 2 },
2350+
tool_category_counts: { file_read: 2 },
2351+
total_tool_calls: 2,
2352+
total_steps: 1,
2353+
total_turns: 1,
2354+
tool_call_events: [],
2355+
shell_commands: [],
2356+
files_read: [],
2357+
files_modified: [],
2358+
files_created: [],
2359+
files_deleted: [],
2360+
web_fetches: [],
2361+
errors: [],
2362+
errors_encountered: 0,
2363+
output_chars: 0,
2364+
transcript_chars: 0,
2365+
reasoning_blocks: [],
2366+
thinking_blocks: 0,
2367+
},
2368+
})}\n`,
2369+
);
22962370
writeFileSync(
22972371
path.join(runDir, 'index.jsonl'),
22982372
toJsonl({
@@ -2320,6 +2394,8 @@ describe('serve app', () => {
23202394
attempts?: Array<{
23212395
transcript_path?: string;
23222396
transcript_summary?: Record<string, unknown>;
2397+
total_tool_calls?: number;
2398+
tool_calls?: Record<string, number>;
23232399
}>;
23242400
}>;
23252401
};
@@ -2330,6 +2406,9 @@ describe('serve app', () => {
23302406
`${resultDir}/attempt-1/transcript.json`,
23312407
`${resultDir}/attempt-2/transcript.json`,
23322408
]);
2409+
expect(data.results[0]?.attempts?.map((trial) => trial.total_tool_calls)).toEqual([1, 2]);
2410+
expect(data.results[0]?.attempts?.[0]?.tool_calls).toEqual({ Bash: 1 });
2411+
expect(data.results[0]?.attempts?.[1]?.tool_calls).toEqual({ Read: 2 });
23332412
});
23342413

23352414
it('loads historical runs without test bundle metadata', async () => {

0 commit comments

Comments
 (0)