Skip to content
This repository was archived by the owner on Oct 16, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ agentv eval evals/my-eval.yaml

**5. Compare results across targets:**
```bash
agentv compare .agentv/results/runs/<timestamp>/index.jsonl
agentv compare .agentv/results/default/<timestamp>/index.jsonl
```

## Output formats
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/eval/artifact-writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export function buildIndexArtifactEntry(
timingPath: string;
outputPath?: string;
answerPath?: string;
tracePath?: string;
transcriptPath?: string;
metricsPath?: string;
rawProviderLogPath?: string;
Expand Down
50 changes: 41 additions & 9 deletions apps/cli/src/commands/eval/result-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import { existsSync, statSync } from 'node:fs';
import path from 'node:path';

export const RESULT_INDEX_FILENAME = 'index.jsonl';
export const RESULT_RUNS_DIRNAME = 'runs';
export const RESULTS_DIRNAME = 'results';
export const DEFAULT_EXPERIMENT_NAME = 'default';
export const RESERVED_RESULTS_NAMESPACES = new Set(['export', 'metadata', 'runs']);

export function isReservedResultsNamespace(value: string | undefined): boolean {
return value !== undefined && RESERVED_RESULTS_NAMESPACES.has(value);
}

export function normalizeExperimentName(experiment?: string): string {
const trimmed = experiment?.trim();
Expand All @@ -15,26 +20,39 @@ export function normalizeExperimentName(experiment?: string): string {
`Invalid experiment name "${trimmed}". Use only letters, numbers, ".", "_" and "-".`,
);
}
if (isReservedResultsNamespace(trimmed)) {
throw new Error(`Invalid experiment name "${trimmed}". This results namespace is reserved.`);
}
return trimmed;
}

export function createRunDirName(timestamp = new Date()): string {
return timestamp.toISOString().replace(/[:.]/g, '-');
}

function defaultRunPathSegments(experiment: string | undefined, runDirName: string): string[] {
const normalizedExperiment = normalizeExperimentName(experiment);
return [normalizedExperiment, runDirName];
}

export function buildResultsRootDir(cwd: string): string {
return path.join(cwd, '.agentv', RESULTS_DIRNAME);
}

export function buildDefaultRunDirFromName(
cwd: string,
experiment: string | undefined,
runDirName: string,
): string {
return path.join(buildResultsRootDir(cwd), ...defaultRunPathSegments(experiment, runDirName));
}

export function buildDefaultRunDir(
cwd: string,
experiment?: string,
timestamp = new Date(),
): string {
return path.join(
cwd,
'.agentv',
'results',
RESULT_RUNS_DIRNAME,
normalizeExperimentName(experiment),
createRunDirName(timestamp),
);
return buildDefaultRunDirFromName(cwd, experiment, createRunDirName(timestamp));
}

export function buildDefaultIndexPath(cwd: string, experiment?: string): string {
Expand Down Expand Up @@ -92,3 +110,17 @@ export function resolveRunManifestPath(filePath: string): string {

return filePath;
}

export function relativeRunPathFromCwd(cwd: string, runDir: string): string | undefined {
const relative = path.relative(path.resolve(buildResultsRootDir(cwd)), path.resolve(runDir));
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) {
return undefined;
}

const parts = relative.split(path.sep).filter(Boolean);
if (parts.length < 2 || isReservedResultsNamespace(parts[0])) {
return undefined;
}

return parts.join(path.posix.sep);
}
2 changes: 1 addition & 1 deletion apps/cli/src/commands/eval/run-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const CACHE_FILENAME = 'cache.json';
* Keys use camelCase (not snake_case) for backward compat with pre-existing cache files.
*/
export interface RunCache {
/** Directory path for new per-run directory format (e.g. .agentv/results/runs/<ts>/) */
/** Directory path for the run workspace (e.g. .agentv/results/default/<ts>/) */
readonly lastRunDir?: string;
/** @deprecated Legacy flat-file pointer from old cache files. Ignored on read. */
readonly lastResultFile?: string;
Expand Down
31 changes: 18 additions & 13 deletions apps/cli/src/commands/eval/run-eval.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ import { loadEnvFromHierarchy } from './env.js';
import { resolveOtelBackend } from './otel-backends.js';
import { type OutputWriter, createOutputWriter } from './output-writer.js';
import { ProgressDisplay, type Verdict, type WorkerProgress } from './progress-display.js';
import { buildDefaultRunDir, normalizeExperimentName } from './result-layout.js';
import {
buildDefaultRunDirFromName,
createRunDirName,
normalizeExperimentName,
} from './result-layout.js';
import {
buildExclusionFilter,
loadErrorTestIds,
Expand Down Expand Up @@ -540,8 +544,12 @@ async function ensureFileExists(filePath: string, description: string): Promise<
}
}

function buildDefaultOutputPathForExperiment(cwd: string, experiment?: string): string {
const runDir = buildDefaultRunDir(cwd, experiment);
function buildDefaultOutputPathForExperiment(
cwd: string,
experiment: string | undefined,
runDirName: string,
): string {
const runDir = buildDefaultRunDirFromName(cwd, experiment, runDirName);
mkdirSync(runDir, { recursive: true });
return path.join(runDir, 'index.jsonl');
}
Expand Down Expand Up @@ -1132,14 +1140,6 @@ export async function runEvalCommand(
): Promise<RunEvalResult | undefined> {
const cwd = process.cwd();

// Set AGENTV_RUN_TIMESTAMP so CLI targets can group artifacts under the same run folder.
if (!process.env.AGENTV_RUN_TIMESTAMP) {
process.env.AGENTV_RUN_TIMESTAMP = new Date()
.toISOString()
.replace(/:/g, '-')
.replace(/\./g, '-');
}

// Load agentv.config.ts (if present) for default values
let config: Awaited<ReturnType<typeof loadTsConfig>> = null;
try {
Expand Down Expand Up @@ -1296,16 +1296,21 @@ export async function runEvalCommand(
const explicitDir = options.outputDir;
let runDir: string;
let outputPath: string;
const runDirName = process.env.AGENTV_RUN_TIMESTAMP?.trim() || createRunDirName();

if (explicitDir) {
runDir = path.resolve(explicitDir);
mkdirSync(runDir, { recursive: true });
outputPath = path.join(runDir, 'index.jsonl');
} else {
// Default: .agentv/results/runs/<experiment>/<timestamp>/
outputPath = buildDefaultOutputPathForExperiment(cwd, options.experiment);
// Default: .agentv/results/<experiment>/<timestamp>/, using "default" when unspecified.
outputPath = buildDefaultOutputPathForExperiment(cwd, options.experiment, runDirName);
runDir = path.dirname(outputPath);
}
if (!process.env.AGENTV_RUN_TIMESTAMP) {
process.env.AGENTV_RUN_TIMESTAMP = path.basename(runDir);
}
process.env.AGENTV_RUN_DIR = runDir;

// Initialize OTel exporter if --export-otel or --otel-file is set
let otelExporter: OtelTraceExporterType | null = null;
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/src/commands/eval/wip-checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
* Manual recovery from a WIP branch:
* git clone <results-repo> /tmp/recovery
* cd /tmp/recovery && git checkout agentv/wip/<hostname>/<run-dir>
* cp -r runs/<run-dir> <project>/.agentv/results/runs/
* agentv eval <eval-file> --output <project>/.agentv/results/runs/<run-dir> --resume
* cp -r <experiment>/<run-dir> <project>/.agentv/results/<experiment>/
* agentv eval <eval-file> --output <project>/.agentv/results/<experiment>/<run-dir> --resume
*
* All checkpoint operations are best-effort: failures are logged as warnings
* and never propagate to the eval run.
Expand Down
55 changes: 41 additions & 14 deletions apps/cli/src/commands/inspect/filter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* `agentv inspect filter` — filter evaluation results by metadata criteria.
*
* Scans JSONL index files in `.agentv/results/runs/` and applies filters
* Scans JSONL index files in `.agentv/results/` and applies filters
* such as target name, experiment name, score thresholds, execution status,
* and tool usage. Outputs matching test IDs with summary info.
*
Expand All @@ -14,6 +14,7 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { command, number, oneOf, option, optional, positional, string } from 'cmd-ts';
import { isReservedResultsNamespace } from '../eval/result-layout.js';
import { normalizeResultRow } from '../results/result-row-schema.js';
import { c, formatScore, padLeft, padRight } from './utils.js';

Expand Down Expand Up @@ -53,6 +54,26 @@ function collectIndexFiles(dir: string): string[] {
return files;
}

function collectCurrentResultIndexFiles(cwd: string): string[] {
const resultsDir = path.join(cwd, '.agentv', 'results');
const files: string[] = [];
try {
const entries = readdirSync(resultsDir, { withFileTypes: true });
for (const entry of entries) {
if (isReservedResultsNamespace(entry.name)) continue;
const fullPath = path.join(resultsDir, entry.name);
if (entry.isDirectory()) {
files.push(...collectIndexFiles(fullPath));
} else if (entry.name === 'index.jsonl') {
files.push(fullPath);
}
}
} catch {
// Directory may not exist
}
return files;
}

/**
* Extract tool names from a result record.
* Looks in trace.tool_calls (Record<string, number>) and output messages (tool_calls[].tool).
Expand Down Expand Up @@ -92,6 +113,22 @@ function extractToolNames(record: Record<string, unknown>): string[] {
return [...tools];
}

function inferExperimentFromPath(filePath: string): string | undefined {
const parts = filePath.split(path.sep);
const resultsIdx = parts.lastIndexOf('results');
if (resultsIdx === -1) return undefined;

const runPath = parts.slice(resultsIdx + 1, -1);
if (isReservedResultsNamespace(runPath[0])) return undefined;
if (runPath.length < 2) return undefined;

const candidate = runPath[0];
if (candidate && !/^\d{4}-\d{2}-\d{2}T/.test(candidate)) {
return candidate;
}
return undefined;
}

/**
* Parse a single JSONL index file into filterable records.
*/
Expand Down Expand Up @@ -119,17 +156,7 @@ export function parseFilterableRecords(filePath: string): FilterableRecord[] {
// Determine experiment from record or from directory path
let experiment = typeof raw.experiment === 'string' ? raw.experiment : undefined;
if (!experiment) {
// Infer from path: .agentv/results/runs/<experiment>/<timestamp>/index.jsonl
const parts = filePath.split(path.sep);
const runsIdx = parts.indexOf('runs');
// If there are 2+ segments between "runs" and the file, the first is the experiment
if (runsIdx !== -1 && parts.length - runsIdx >= 3) {
const candidate = parts[runsIdx + 1];
// "default" experiment or named experiments; skip if it looks like a timestamp
if (candidate && !/^\d{4}-\d{2}-\d{2}T/.test(candidate)) {
experiment = candidate;
}
}
experiment = inferExperimentFromPath(filePath);
}

records.push({
Expand Down Expand Up @@ -208,7 +235,7 @@ function discoverFilterSources(searchPath: string | undefined, cwd: string): str
return [resolved];
}

return collectIndexFiles(path.join(cwd, '.agentv', 'results', 'runs'));
return collectCurrentResultIndexFiles(cwd);
}

function formatFilterTable(records: FilterableRecord[]): string {
Expand Down Expand Up @@ -268,7 +295,7 @@ export const inspectFilterCommand = command({
path: positional({
type: optional(string),
displayName: 'path',
description: 'Directory or file to filter (default: .agentv/results/runs/)',
description: 'Directory or file to filter (default: .agentv/results/)',
}),
target: option({
type: optional(string),
Expand Down
6 changes: 3 additions & 3 deletions apps/cli/src/commands/inspect/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ function formatListTable(metas: ResultFileMeta[]): string {
const lines: string[] = [];

if (metas.length === 0) {
lines.push(`${c.yellow}No run workspaces found in .agentv/results/runs/${c.reset}`);
lines.push(`${c.yellow}No run workspaces found in .agentv/results/${c.reset}`);
lines.push(`${c.dim}Run an evaluation first: agentv run <eval-file>${c.reset}`);
return lines.join('\n');
}

lines.push('');
lines.push(`${c.bold}Evaluation Runs${c.reset} ${c.dim}(.agentv/results/runs/)${c.reset}`);
lines.push(`${c.bold}Evaluation Runs${c.reset} ${c.dim}(.agentv/results/)${c.reset}`);
lines.push('');

// Column widths
Expand Down Expand Up @@ -52,7 +52,7 @@ function formatListTable(metas: ResultFileMeta[]): string {

export const traceListCommand = command({
name: 'list',
description: 'List recent evaluation run workspaces from .agentv/results/runs/',
description: 'List recent evaluation run workspaces from .agentv/results/',
args: {
limit: option({
type: optional(number),
Expand Down
29 changes: 25 additions & 4 deletions apps/cli/src/commands/inspect/search.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* `agentv inspect search` — regex search across evaluation results and transcripts.
*
* Scans JSONL files in `.agentv/results/runs/` and `.agentv/transcripts/` for
* Scans JSONL files in `.agentv/results/` and `.agentv/transcripts/` for
* lines matching a regex pattern. Outputs file path, test_id, and matching
* content with surrounding context.
*
Expand All @@ -17,6 +17,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { command, oneOf, option, optional, positional, string } from 'cmd-ts';
import { toSnakeCaseDeep } from '../../utils/case-conversion.js';
import { isReservedResultsNamespace } from '../eval/result-layout.js';
import { c, padRight } from './utils.js';

/** A single search match within a JSONL line. */
Expand Down Expand Up @@ -56,6 +57,26 @@ function collectJsonlFiles(dir: string): string[] {
return files;
}

function collectCurrentResultJsonlFiles(cwd: string): string[] {
const resultsDir = path.join(cwd, '.agentv', 'results');
const files: string[] = [];
try {
const entries = readdirSync(resultsDir, { withFileTypes: true });
for (const entry of entries) {
if (isReservedResultsNamespace(entry.name)) continue;
const fullPath = path.join(resultsDir, entry.name);
if (entry.isDirectory()) {
files.push(...collectJsonlFiles(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
files.push(fullPath);
}
}
} catch {
// Directory may not exist
}
return files;
}

/**
* Extract a human-readable snippet around a regex match within a string.
* Returns up to `contextChars` characters on each side of the match.
Expand Down Expand Up @@ -142,7 +163,7 @@ export function searchJsonlFile(
* Discover all searchable JSONL sources under a base path.
* If the path is a file, search that single file.
* If it's a directory, recursively find all .jsonl files.
* If not specified, scan both .agentv/results/runs/ and .agentv/transcripts/.
* If not specified, scan both .agentv/results/ and .agentv/transcripts/.
*/
function discoverSources(basePath: string | undefined, cwd: string): string[] {
if (basePath) {
Expand All @@ -163,7 +184,7 @@ function discoverSources(basePath: string | undefined, cwd: string): string[] {

// Default: scan both results and transcripts
const sources: string[] = [];
sources.push(...collectJsonlFiles(path.join(cwd, '.agentv', 'results', 'runs')));
sources.push(...collectCurrentResultJsonlFiles(cwd));
sources.push(...collectJsonlFiles(path.join(cwd, '.agentv', 'transcripts')));
return sources;
}
Expand Down Expand Up @@ -226,7 +247,7 @@ export const inspectSearchCommand = command({
type: optional(string),
displayName: 'path',
description:
'Directory or file to search (default: .agentv/results/runs/ and .agentv/transcripts/)',
'Directory or file to search (default: .agentv/results/ and .agentv/transcripts/)',
}),
target: option({
type: optional(string),
Expand Down
Loading
Loading