mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(ai-builder): Harden the eval harness — crash-recovery journal, parity fixes, extension points (no-changelog) (#34747)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
66939681c9
commit
b93a99945b
@@ -41,7 +41,7 @@ instance's Instance AI config and the README's environment-variables section.
|
||||
|
||||
| Mode | How | Produces |
|
||||
|---|---|---|
|
||||
| **Direct loop** | no `LANGSMITH_API_KEY` | `eval-results.json` + HTML report locally |
|
||||
| **Direct driver** | no `LANGSMITH_API_KEY` | `eval-results.json` + HTML report locally — same pipeline and row order as the LangSmith driver (TRUST-261), row concurrency follows `--concurrency` |
|
||||
| **LangSmith** | `LANGSMITH_API_KEY` set | also records an experiment and auto-compares against the baseline |
|
||||
| **Prebuilt** | `--prebuilt-workflows <manifest>` | skips the build; verifies existing workflows (score MCP/hand-built cohorts on the same verifier) |
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
.output/
|
||||
eval-pr-comment.md
|
||||
eval-results.json
|
||||
eval-rows.jsonl
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Eval harness architecture
|
||||
|
||||
One pipeline, two thin drivers (TRUST-261). Every phase of a run lives behind a
|
||||
named seam in `evaluations/run/`; `cli/index.ts` is a ~150-line composition
|
||||
root, and the rest of `cli/` is the sibling entrypoints (`pairwise`,
|
||||
`compare-pairwise`, `report`, `build-mcp-manifest`, `langtracer-push`) plus
|
||||
their helpers (`args.ts`, `mcp-builder.ts`).
|
||||
|
||||
```
|
||||
cli/index.ts (composition root)
|
||||
→ run/case-selection.ts narrow loaded cases (prebuilt coverage, MCP limits)
|
||||
→ run/lane-setup.ts one authenticated lane per --base-url (+ final cleanup)
|
||||
→ driver:
|
||||
run/langsmith-driver.ts LANGSMITH_API_KEY set — evaluate(), experiments,
|
||||
per-run pass metrics, gate/baseline comparison
|
||||
run/direct-driver.ts keyless — the LangTracer dispatcher's mode; same
|
||||
rows, same pipeline, same local artifacts — just
|
||||
no LangSmith experiments/feedback/comparison
|
||||
→ run/eval-session.ts shared assembly: lane wrappers (tracing hook),
|
||||
work-stealing allocator, orchestrator, pipeline,
|
||||
side-band resolution, end-of-run artifact drain
|
||||
→ run/build-orchestrator.ts getOrBuild: per-(iteration, case) build
|
||||
cache, transient retry across lanes,
|
||||
side-band capture (transcript, expectation
|
||||
verdicts incl. artifactContext, run debug)
|
||||
→ run/case-pipeline.ts runRow: sentinel / build-fail / agent /
|
||||
workflow dispatch, transient retry, seed-
|
||||
table serialization, framework_issue
|
||||
guard, eager per-build cleanup
|
||||
→ run/rows.ts single source of row set + order for BOTH drivers
|
||||
→ run/reshape.ts rows → WorkflowTestCaseResult[][] (driver-agnostic)
|
||||
→ run/aggregator.ts pass@k / pass^k aggregation
|
||||
→ run/persist.ts always-write eval-results.json / eval-pr-comment.md,
|
||||
row journal (eval-rows.jsonl) + crash recovery
|
||||
→ comparison/* baseline comparison + the absolute gate
|
||||
→ run/reporters.ts console paths, HTML reports, terminal summary, noise advisory
|
||||
```
|
||||
|
||||
The tracing hook is the only driver-specific piece of the session: the
|
||||
LangSmith driver wraps lane functions with `traceable()` (span names
|
||||
`workflow_build` / `scenario_execution` / `agent_scenario_execution` — the
|
||||
analytics pipeline reads them), the direct driver passes identity.
|
||||
|
||||
## Where to add things
|
||||
|
||||
| You want to… | Touch exactly |
|
||||
|---|---|
|
||||
| Add a grader / judge / check wiring | `run/case-pipeline.ts` (row-side) or `run/build-orchestrator.ts` (build-side capture) — it then runs in CI **and** dispatcher mode |
|
||||
| Add a tier | nothing — tiers are free-form strings in a case's `datasets` field; only a tier that should assert the absolute green bar registers in `run/tiers.ts` |
|
||||
| Add a case source | `data/source.ts` (`--source` dispatch) |
|
||||
| Add a driver | compose `createEvalSession()` + feed rows from `run/rows.ts` |
|
||||
| Change persisted output | `run/persist.ts` — and extend `__tests__/eval-results-dispatcher-contract.test.ts` |
|
||||
|
||||
## External contracts (do not drift silently)
|
||||
|
||||
- **`eval-results.json`** is ingested by the LangTracer dispatcher, which runs
|
||||
this CLI keyless per case. The exact field set is pinned by
|
||||
`__tests__/eval-results-dispatcher-contract.test.ts`.
|
||||
- **`eval-pr-comment.md`** is posted verbatim by CI; the comment is found by its
|
||||
`### Instance AI Workflow Eval` prefix.
|
||||
- **LangSmith feedback keys** (`scenario_pass`, `failure_category`,
|
||||
`evals.workflows.*`, `pass_at_k`, `pass_hat_k`) and the traced span names
|
||||
feed the LangSmith→BigQuery analytics.
|
||||
- **`pnpm eval:*` script names** are invoked by CI workflows and
|
||||
`run-eval-lanes.sh`.
|
||||
|
||||
## Crash recovery
|
||||
|
||||
Both drivers journal every completed row to `<output-dir>/eval-rows.jsonl`
|
||||
(`run/persist.ts`). If the run dies, `runEvalAndPersist` reshapes the journal's
|
||||
*complete* iterations into `eval-results.json` — incomplete iterations are
|
||||
dropped, never stubbed, so a crash artifact cannot fabricate failures for rows
|
||||
that never ran. This is also the merge seam for sharding runs (TRUST-152):
|
||||
concatenate journals, reshape once.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Workflow evaluation framework
|
||||
|
||||
> Module layout, extension points and external contracts: [ARCHITECTURE.md](./ARCHITECTURE.md).
|
||||
|
||||
Tests whether workflows built by Instance AI actually work by executing them with LLM-generated mock HTTP responses. No real credentials or external services are involved.
|
||||
|
||||
Five harnesses live here:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { verifyBuildExpectations } from '../build-expectations/verifier';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { runWorkflowChecks, type BuildResult } from '../harness/runner';
|
||||
import {
|
||||
@@ -18,13 +20,21 @@ import type { WorkflowTestCase } from '../types';
|
||||
|
||||
vi.mock('../harness/runner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../harness/runner')>();
|
||||
return { ...actual, runWorkflowChecks: vi.fn().mockResolvedValue([]) };
|
||||
return {
|
||||
...actual,
|
||||
runWorkflowChecks: vi.fn().mockResolvedValue([]),
|
||||
fetchAgentScenarioContext: vi.fn().mockResolvedValue('AGENT CONTEXT'),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../harness/capture-run-debug', () => ({
|
||||
captureThreadRunDebug: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../harness/artifacts/artifact-context', () => ({
|
||||
resolveArtifactContext: vi.fn().mockResolvedValue('RESOLVED ARTIFACTS'),
|
||||
}));
|
||||
|
||||
vi.mock('../build-expectations/verifier', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../build-expectations/verifier')>();
|
||||
return {
|
||||
@@ -273,3 +283,59 @@ describe('createBuildOrchestrator', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expectation judging context', () => {
|
||||
it('threads the rendered agent artifact into the expectation judge', async () => {
|
||||
const tracedBuild = vi.fn().mockResolvedValue(
|
||||
okBuild({
|
||||
threadId: 'thread-9',
|
||||
transcript: [] as never,
|
||||
artifactRefs: [{ type: 'agent', id: 'agent-1' }] as never,
|
||||
}),
|
||||
);
|
||||
const deps = makeDeps([makeLane(1, tracedBuild)], {
|
||||
testCaseByFileSlug: new Map([
|
||||
['case-a', baseCase({ outcomeExpectations: ['the agent has a Slack tool'] })],
|
||||
]),
|
||||
});
|
||||
const orchestrator = createBuildOrchestrator(deps);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
await deps.buildExpectationsByKey.get('0:case-a');
|
||||
|
||||
expect(vi.mocked(resolveArtifactContext)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactRefs: [{ type: 'agent', id: 'agent-1' }] }),
|
||||
);
|
||||
expect(vi.mocked(verifyBuildExpectations)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ artifactContext: 'RESOLVED ARTIFACTS' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves non-agent artifacts (config-eval) for the judge too — not just agent refs', async () => {
|
||||
const tracedBuild = vi.fn().mockResolvedValue(
|
||||
okBuild({
|
||||
threadId: 'thread-9',
|
||||
transcript: [] as never,
|
||||
artifactRefs: [{ type: 'config-eval', id: 'ce-1' }] as never,
|
||||
}),
|
||||
);
|
||||
const deps = makeDeps([makeLane(1, tracedBuild)], {
|
||||
testCaseByFileSlug: new Map([
|
||||
['case-a', baseCase({ outcomeExpectations: ['a config eval exists'] })],
|
||||
]),
|
||||
});
|
||||
const orchestrator = createBuildOrchestrator(deps);
|
||||
|
||||
await orchestrator.getOrBuild(0, 'case-a');
|
||||
await deps.buildExpectationsByKey.get('0:case-a');
|
||||
|
||||
expect(vi.mocked(resolveArtifactContext)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ artifactRefs: [{ type: 'config-eval', id: 'ce-1' }] }),
|
||||
);
|
||||
expect(vi.mocked(verifyBuildExpectations)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ artifactContext: 'RESOLVED ARTIFACTS' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,3 +316,141 @@ describe('createCasePipeline', () => {
|
||||
expect(lane.tracedExecuteAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
function deferred(): { promise: Promise<unknown>; resolve: (v: unknown) => void } {
|
||||
let resolve!: (v: unknown) => void;
|
||||
const promise = new Promise<unknown>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe('seed-table scenarios (TRUST-311 parity)', () => {
|
||||
function seededCase(names: string[]): WorkflowTestCase {
|
||||
return {
|
||||
...scenarioCase(names),
|
||||
executionScenarios: names.map((name) => ({
|
||||
name,
|
||||
description: 'd',
|
||||
dataSetup: 's',
|
||||
successCriteria: 'c',
|
||||
seedDataTables: [
|
||||
{
|
||||
id: 'seed-table-1',
|
||||
name: 'Jobs',
|
||||
columns: [{ name: 'id', type: 'string' as const }],
|
||||
rows: [{ id: 'row_001' }],
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
it('merges the authored scenario and passes the build seed context to execution', async () => {
|
||||
const lane = makeLane();
|
||||
vi.mocked(lane.tracedExecute).mockResolvedValue({
|
||||
success: true,
|
||||
score: 1,
|
||||
reasoning: 'ok',
|
||||
} as never);
|
||||
const orchestrator = makeOrchestrator({
|
||||
build: okBuild({ threadId: 'thread-1', seededScenarioTableIdsByName: { Jobs: 'dt-real-1' } }),
|
||||
lane,
|
||||
buildDurationMs: 1,
|
||||
});
|
||||
const pipeline = createCasePipeline(
|
||||
makeDeps(orchestrator, {
|
||||
testCaseByFileSlug: new Map([['case-a', seededCase(['happy-path'])]]),
|
||||
}),
|
||||
);
|
||||
|
||||
await pipeline.runRow(rowInputs('happy-path'));
|
||||
|
||||
expect(lane.tracedExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scenario: expect.objectContaining({
|
||||
seedDataTables: [expect.objectContaining({ name: 'Jobs' })],
|
||||
}) as unknown,
|
||||
seedContext: { threadId: 'thread-1', tableIdsByName: { Jobs: 'dt-real-1' } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to run a seeded scenario when the build carries no seeded-table mapping', async () => {
|
||||
const lane = makeLane();
|
||||
// MCP/prebuilt-shaped build: success, but no thread or table mapping —
|
||||
// running would grade the workflow against empty tables.
|
||||
const orchestrator = makeOrchestrator({ build: okBuild(), lane, buildDurationMs: 1 });
|
||||
const pipeline = createCasePipeline(
|
||||
makeDeps(orchestrator, {
|
||||
testCaseByFileSlug: new Map([['case-a', seededCase(['happy-path'])]]),
|
||||
}),
|
||||
);
|
||||
|
||||
const output = await pipeline.runRow(rowInputs('happy-path'));
|
||||
|
||||
expect(lane.tracedExecute).not.toHaveBeenCalled();
|
||||
expect(output).toMatchObject({
|
||||
passed: false,
|
||||
failureCategory: 'framework_issue',
|
||||
reasoning: expect.stringContaining('no seeded-table mapping') as unknown,
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes rows of a seeded case so table reseeding cannot interleave', async () => {
|
||||
const lane = makeLane();
|
||||
const started: string[] = [];
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
vi.mocked(lane.tracedExecute).mockImplementation(((execArgs: {
|
||||
scenario: { name: string };
|
||||
}) => {
|
||||
started.push(execArgs.scenario.name);
|
||||
return (started.length === 1 ? first.promise : second.promise) as never;
|
||||
}) as never);
|
||||
const orchestrator = makeOrchestrator({
|
||||
build: okBuild({ threadId: 'thread-1', seededScenarioTableIdsByName: { Jobs: 'dt-real-1' } }),
|
||||
lane,
|
||||
buildDurationMs: 1,
|
||||
});
|
||||
const pipeline = createCasePipeline(
|
||||
makeDeps(orchestrator, {
|
||||
testCaseByFileSlug: new Map([['case-a', seededCase(['s1', 's2'])]]),
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = Promise.all([pipeline.runRow(rowInputs('s1')), pipeline.runRow(rowInputs('s2'))]);
|
||||
await vi.waitFor(() => expect(started).toHaveLength(1));
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
// The second seeded row must not start while the first still runs.
|
||||
expect(started).toEqual(['s1']);
|
||||
|
||||
first.resolve({ success: true, score: 1, reasoning: 'ok' });
|
||||
await vi.waitFor(() => expect(started).toHaveLength(2));
|
||||
second.resolve({ success: true, score: 1, reasoning: 'ok' });
|
||||
await rows;
|
||||
});
|
||||
|
||||
it('does not serialize rows of an unseeded case', async () => {
|
||||
const lane = makeLane();
|
||||
const started: string[] = [];
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
vi.mocked(lane.tracedExecute).mockImplementation(((execArgs: {
|
||||
scenario: { name: string };
|
||||
}) => {
|
||||
started.push(execArgs.scenario.name);
|
||||
return (started.length === 1 ? first.promise : second.promise) as never;
|
||||
}) as never);
|
||||
const orchestrator = makeOrchestrator({ build: okBuild(), lane, buildDurationMs: 1 });
|
||||
const pipeline = createCasePipeline(
|
||||
makeDeps(orchestrator, {
|
||||
testCaseByFileSlug: new Map([['case-a', scenarioCase(['s1', 's2'])]]),
|
||||
}),
|
||||
);
|
||||
|
||||
const rows = Promise.all([pipeline.runRow(rowInputs('s1')), pipeline.runRow(rowInputs('s2'))]);
|
||||
await vi.waitFor(() => expect(started).toHaveLength(2));
|
||||
first.resolve({ success: true, score: 1, reasoning: 'ok' });
|
||||
second.resolve({ success: true, score: 1, reasoning: 'ok' });
|
||||
await rows;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* runner.
|
||||
*/
|
||||
|
||||
import { loadDiscoveryTestCasesWithFiles } from '../data/discovery';
|
||||
import { discoveryTestCaseSchema, loadDiscoveryTestCasesWithFiles } from '../data/discovery';
|
||||
import { runExpectedToolsInvokedCheck } from '../discovery/expected-tools-invoked';
|
||||
|
||||
describe('loadDiscoveryTestCasesWithFiles', () => {
|
||||
@@ -69,3 +69,40 @@ describe('loadDiscoveryTestCasesWithFiles', () => {
|
||||
expect(negative.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discoveryTestCaseSchema', () => {
|
||||
const valid = {
|
||||
id: 'my-scenario',
|
||||
userMessage: 'do the thing',
|
||||
expectedToolInvocations: { anyOf: ['build-workflow'] },
|
||||
};
|
||||
|
||||
it('accepts a minimal valid case', () => {
|
||||
expect(discoveryTestCaseSchema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a typo-d key instead of passing vacuously', () => {
|
||||
const typo = { ...valid, expectedToolInvocation: { anyOf: ['x'] } };
|
||||
expect(discoveryTestCaseSchema.safeParse(typo).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty expectations — a case must assert something', () => {
|
||||
const empty = { ...valid, expectedToolInvocations: {} };
|
||||
expect(discoveryTestCaseSchema.safeParse(empty).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty expectation list — dead config must fail at load time', () => {
|
||||
const emptyList = { ...valid, expectedToolInvocations: { allOfToolCalls: [] } };
|
||||
expect(discoveryTestCaseSchema.safeParse(emptyList).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['connected with capabilities', { status: 'connected', capabilities: ['screenshot'] }, true],
|
||||
['connected without capabilities', { status: 'connected' }, false],
|
||||
['disabled', { status: 'disabled' }, true],
|
||||
['an unknown status', { status: 'on-fire' }, false],
|
||||
])('validates instanceState.localGateway strictly: %s', (_name, localGateway, ok) => {
|
||||
const withGateway = { ...valid, instanceState: { localGateway } };
|
||||
expect(discoveryTestCaseSchema.safeParse(withGateway).success).toBe(ok);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,12 @@ import type { Mock } from 'vitest';
|
||||
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { ARCHIVED_SPLIT, BUILD_ONLY_SCENARIO_NAME, syncDataset } from '../langsmith/dataset-sync';
|
||||
import {
|
||||
ARCHIVED_SPLIT,
|
||||
BUILD_ONLY_SCENARIO_NAME,
|
||||
syncDataset,
|
||||
ensureExamplesVisible,
|
||||
} from '../langsmith/dataset-sync';
|
||||
|
||||
function scenarioFixture(testCaseFile: string, scenarioName: string): WorkflowTestCaseWithFile {
|
||||
return {
|
||||
@@ -294,3 +299,56 @@ describe('syncDataset', () => {
|
||||
expect(second.createExamples).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureExamplesVisible', () => {
|
||||
const silent = {
|
||||
info: () => {},
|
||||
verbose: () => {},
|
||||
success: () => {},
|
||||
warn: vi.fn(),
|
||||
error: () => {},
|
||||
isVerbose: false,
|
||||
};
|
||||
const caseWithOneScenario = {
|
||||
testCase: {
|
||||
conversation: [{ role: 'user' as const, text: 'build it' }],
|
||||
complexity: 'simple' as const,
|
||||
tags: [],
|
||||
datasets: ['full'],
|
||||
executionScenarios: [{ name: 's1', description: 'd', dataSetup: 's', successCriteria: 'c' }],
|
||||
},
|
||||
fileSlug: 'case-a',
|
||||
};
|
||||
|
||||
function clientListing(counts: number[]): { listExamples: ReturnType<typeof vi.fn> } {
|
||||
let call = 0;
|
||||
return {
|
||||
listExamples: vi.fn().mockImplementation(() => {
|
||||
const n = counts[Math.min(call++, counts.length - 1)];
|
||||
return (async function* () {
|
||||
await Promise.resolve();
|
||||
for (let i = 0; i < n; i++) yield { id: String(i) };
|
||||
})();
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it('passes once the split-scoped count covers the synced rows', async () => {
|
||||
const client = clientListing([0, 1]);
|
||||
await ensureExamplesVisible(client as never, 'ds', [caseWithOneScenario], silent as never, {
|
||||
baseDelayMs: 1,
|
||||
});
|
||||
expect(client.listExamples).toHaveBeenCalledWith({ datasetName: 'ds', splits: ['case-a'] });
|
||||
expect(client.listExamples).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('throws loudly instead of running a partial experiment', async () => {
|
||||
const client = clientListing([0]);
|
||||
await expect(
|
||||
ensureExamplesVisible(client as never, 'ds', [caseWithOneScenario], silent as never, {
|
||||
attempts: 2,
|
||||
baseDelayMs: 1,
|
||||
}),
|
||||
).rejects.toThrow(/0\/1 synced example/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { appendFileSync, mkdtempSync, readFileSync } from 'fs';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import type { ScenarioRowInputs } from '../run/case-pipeline';
|
||||
import { createRowSink, runEvalAndPersist } from '../run/persist';
|
||||
import type { TargetOutput } from '../run/reshape';
|
||||
import type { WorkflowTestCase } from '../types';
|
||||
|
||||
// The row sink is the crash-recovery journal both drivers feed: one JSON line
|
||||
// per completed row. On the crash path runEvalAndPersist reshapes COMPLETE
|
||||
// iterations back into eval-results.json — never-run rows must not surface as
|
||||
// fabricated failures in the artifact.
|
||||
|
||||
const silentLogger: EvalLogger = {
|
||||
info: () => {},
|
||||
verbose: () => {},
|
||||
success: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
isVerbose: false,
|
||||
};
|
||||
|
||||
const testCase: WorkflowTestCase = {
|
||||
conversation: [{ role: 'user', text: 'build it' }],
|
||||
complexity: 'simple',
|
||||
tags: [],
|
||||
datasets: ['full'],
|
||||
executionScenarios: [
|
||||
{ name: 'happy-path', description: 'd', dataSetup: 's', successCriteria: 'c' },
|
||||
],
|
||||
};
|
||||
const testCasesWithFiles: WorkflowTestCaseWithFile[] = [{ testCase, fileSlug: 'case-a' }];
|
||||
|
||||
function rowInputs(iteration: number): ScenarioRowInputs {
|
||||
return {
|
||||
testCaseFile: 'case-a',
|
||||
scenarioName: 'happy-path',
|
||||
scenarioDescription: 'd',
|
||||
dataSetup: 's',
|
||||
successCriteria: 'c',
|
||||
_iteration: iteration,
|
||||
};
|
||||
}
|
||||
|
||||
function rowOutputs(passed: boolean): TargetOutput {
|
||||
return {
|
||||
buildSuccess: true,
|
||||
workflowId: 'wf-1',
|
||||
passed,
|
||||
score: passed ? 1 : 0,
|
||||
reasoning: passed ? 'ok' : 'nope',
|
||||
execErrors: [],
|
||||
buildDurationMs: 1,
|
||||
execDurationMs: 1,
|
||||
nodeCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createRowSink', () => {
|
||||
it('round-trips appended rows and truncates the previous run', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'row-sink-'));
|
||||
const first = createRowSink(dir);
|
||||
first.append({ run: { inputs: rowInputs(0), outputs: rowOutputs(true) } });
|
||||
expect(first.readRows()).toHaveLength(1);
|
||||
|
||||
// A new sink for the same dir starts empty — recovery never mixes runs.
|
||||
const second = createRowSink(dir);
|
||||
expect(second.readRows()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('tolerates a torn final line from a hard crash', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'row-sink-'));
|
||||
const sink = createRowSink(dir);
|
||||
sink.append({ run: { inputs: rowInputs(0), outputs: rowOutputs(true) } });
|
||||
appendFileSync(sink.path, '{"run":{"inputs":{"trunc');
|
||||
|
||||
expect(sink.readRows()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runEvalAndPersist crash recovery from the sink', () => {
|
||||
interface WrittenReport {
|
||||
testCases: Array<{
|
||||
testCaseFile?: string;
|
||||
scenarios: Array<{ runs: unknown[] }>;
|
||||
buildExpectationResultsPerRun?: unknown[];
|
||||
}>;
|
||||
}
|
||||
|
||||
async function crashWith(
|
||||
rows: Array<{ iteration: number }>,
|
||||
outputs: TargetOutput = rowOutputs(true),
|
||||
): Promise<WrittenReport> {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'row-sink-recovery-'));
|
||||
const rowSink = createRowSink(dir);
|
||||
await expect(
|
||||
runEvalAndPersist(
|
||||
{
|
||||
logger: silentLogger,
|
||||
outputDir: dir,
|
||||
startTime: 1,
|
||||
iterations: 3,
|
||||
tier: undefined,
|
||||
commitSha: undefined,
|
||||
rerun: undefined,
|
||||
mcpBuildSpend: [],
|
||||
rowSink,
|
||||
testCasesWithFiles,
|
||||
},
|
||||
async () => {
|
||||
for (const { iteration } of rows) {
|
||||
rowSink.append({ run: { inputs: rowInputs(iteration), outputs } });
|
||||
}
|
||||
return await Promise.reject(new Error('lane meltdown'));
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('lane meltdown');
|
||||
return jsonParse<WrittenReport>(readFileSync(join(dir, 'eval-results.json'), 'utf8'));
|
||||
}
|
||||
|
||||
it('recovers completed iterations into the crash artifact', async () => {
|
||||
const report = await crashWith([{ iteration: 0 }, { iteration: 1 }]);
|
||||
|
||||
expect(report.testCases).toHaveLength(1);
|
||||
expect(report.testCases[0].testCaseFile).toBe('case-a');
|
||||
expect(report.testCases[0].scenarios[0].runs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('writes an empty artifact when no iteration completed — no fabricated failures', async () => {
|
||||
// The single-scenario case needs one row per iteration; an empty sink means
|
||||
// nothing finished, so nothing may be reported.
|
||||
const report = await crashWith([]);
|
||||
|
||||
expect(report.testCases).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('recovers embedded expectation verdicts — reshape reads only the side-band map', async () => {
|
||||
const verdicts = [{ expectation: 'sends a digest', pass: true, reason: 'ok' }];
|
||||
const report = await crashWith([{ iteration: 0 }], {
|
||||
...rowOutputs(true),
|
||||
expectationResults: verdicts,
|
||||
});
|
||||
|
||||
expect(report.testCases[0].buildExpectationResultsPerRun).toEqual([verdicts]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import { roundRobinCaseRows } from '../run/rows';
|
||||
import type { WorkflowTestCase } from '../types';
|
||||
|
||||
// Pins the row set + order both drivers evaluate (run/rows.ts is the single
|
||||
// flattening source for the LangSmith dataset sync AND the direct driver).
|
||||
|
||||
function caseWith(fileSlug: string, scenarioNames: string[]): WorkflowTestCaseWithFile {
|
||||
const testCase: WorkflowTestCase = {
|
||||
conversation: [{ role: 'user', text: `build ${fileSlug}` }],
|
||||
complexity: 'simple',
|
||||
tags: [],
|
||||
datasets: ['full'],
|
||||
executionScenarios: scenarioNames.map((name) => ({
|
||||
name,
|
||||
description: 'd',
|
||||
dataSetup: 's',
|
||||
successCriteria: 'c',
|
||||
})),
|
||||
};
|
||||
return { testCase, fileSlug };
|
||||
}
|
||||
|
||||
describe('roundRobinCaseRows', () => {
|
||||
it('orders scenario #n of every case before scenario #n+1, sentinels last', () => {
|
||||
const rows = roundRobinCaseRows([
|
||||
caseWith('alpha', ['a1', 'a2', 'a3']),
|
||||
caseWith('beta', ['b1', 'b2']),
|
||||
caseWith('gamma', []),
|
||||
]);
|
||||
|
||||
expect(rows.map((r) => `${r.testCaseFile}/${r.scenario?.name ?? '<sentinel>'}`)).toEqual([
|
||||
'alpha/a1',
|
||||
'beta/b1',
|
||||
'alpha/a2',
|
||||
'beta/b2',
|
||||
'alpha/a3',
|
||||
'gamma/<sentinel>',
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits exactly one sentinel row per scenario-less case and none otherwise', () => {
|
||||
const rows = roundRobinCaseRows([caseWith('one', ['s']), caseWith('two', [])]);
|
||||
expect(rows.filter((r) => r.scenario === null)).toHaveLength(1);
|
||||
expect(rows.find((r) => r.scenario === null)?.testCaseFile).toBe('two');
|
||||
});
|
||||
|
||||
it('returns an empty list for no cases', () => {
|
||||
expect(roundRobinCaseRows([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,9 @@ export interface CliArgs {
|
||||
outputDir?: string;
|
||||
/** LangSmith dataset name (synced from JSON test cases before each run) */
|
||||
dataset: string;
|
||||
/** True when `--source langtracer` auto-forked the dataset name off the suite
|
||||
* (no explicit --dataset) — cohort isolation, surfaced loudly by the driver. */
|
||||
datasetAutoForked: boolean;
|
||||
/** Max concurrent target() calls in LangSmith evaluate(). Build concurrency is
|
||||
* enforced separately by the LaneAllocator (cap=4 per lane). */
|
||||
concurrency: number;
|
||||
@@ -212,13 +215,17 @@ export function parseCliArgs(argv: string[]): CliArgs {
|
||||
// In langtracer mode, default the dataset + baseline to a suite-scoped, eval-tagged
|
||||
// name so runs don't pollute the shared cohort and re-runs upsert one stable dataset.
|
||||
let dataset = validated.dataset;
|
||||
let datasetAutoForked = false;
|
||||
let baselinePrefix = validated.baselinePrefix;
|
||||
if (validated.source === 'langtracer' && validated.suite) {
|
||||
const suiteSlug = validated.suite
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
if (!raw.datasetProvided) dataset = `instance-ai-langtracer-${suiteSlug}`;
|
||||
if (!raw.datasetProvided) {
|
||||
dataset = `instance-ai-langtracer-${suiteSlug}`;
|
||||
datasetAutoForked = true;
|
||||
}
|
||||
if (!raw.baselineProvided) baselinePrefix = `instance-ai-langtracer-${suiteSlug}-baseline-`;
|
||||
}
|
||||
|
||||
@@ -235,6 +242,7 @@ export function parseCliArgs(argv: string[]): CliArgs {
|
||||
deletePrebuiltWorkflows: validated.deletePrebuiltWorkflows,
|
||||
outputDir: validated.outputDir,
|
||||
dataset,
|
||||
datasetAutoForked,
|
||||
concurrency: validated.concurrency,
|
||||
experimentName: validated.experimentName,
|
||||
iterations: validated.iterations,
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// Parses args, selects cases, sets up lanes, then hands the run to one of two
|
||||
// drivers over the shared session/pipeline in evaluations/run/: the LangSmith
|
||||
// driver (evaluate() + experiments) when LANGSMITH_API_KEY is set, else the
|
||||
// direct driver (same rows, same pipeline, eval-results.json only — the mode
|
||||
// the LangTracer dispatcher invokes).
|
||||
// direct driver (same rows, same pipeline and local artifacts, no LangSmith
|
||||
// experiment tracking — the mode the LangTracer dispatcher invokes).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdirSync } from 'fs';
|
||||
@@ -20,7 +20,7 @@ import { selectCases } from '../run/case-selection';
|
||||
import { runDirect } from '../run/direct-driver';
|
||||
import { cleanupLanes, setupLanes } from '../run/lane-setup';
|
||||
import { runWithLangSmith } from '../run/langsmith-driver';
|
||||
import { ciRerunHint, runEvalAndPersist } from '../run/persist';
|
||||
import { ciRerunHint, createRowSink, runEvalAndPersist } from '../run/persist';
|
||||
import { emitRunReports } from '../run/reporters';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -52,6 +52,8 @@ async function main(): Promise<void> {
|
||||
args.deletePrebuiltWorkflows || (args.buildViaMcp && !args.keepWorkflows);
|
||||
|
||||
const mcpBuildSpend: McpBuildSpend[] = [];
|
||||
// Every completed row is journaled so a crashed run still persists verdicts.
|
||||
const rowSink = createRowSink(args.outputDir);
|
||||
const commitSha = process.env.LANGSMITH_REVISION_ID ?? process.env.GITHUB_SHA;
|
||||
|
||||
try {
|
||||
@@ -72,6 +74,8 @@ async function main(): Promise<void> {
|
||||
commitSha,
|
||||
rerun: ciRerunHint(),
|
||||
mcpBuildSpend,
|
||||
rowSink,
|
||||
testCasesWithFiles,
|
||||
},
|
||||
async (partialResults) => {
|
||||
if (hasLangSmith) {
|
||||
@@ -85,6 +89,7 @@ async function main(): Promise<void> {
|
||||
cleanupBuiltWorkflows,
|
||||
mcpBuildLogDir,
|
||||
mcpBuildSpend,
|
||||
rowSink,
|
||||
});
|
||||
return {
|
||||
evaluation: langsmithRun.evaluation,
|
||||
@@ -107,6 +112,7 @@ async function main(): Promise<void> {
|
||||
mcpBuildLogDir,
|
||||
mcpBuildSpend,
|
||||
partialResults,
|
||||
rowSink,
|
||||
});
|
||||
return { evaluation: directRun.evaluation, slugByTestCase: directRun.slugByTestCase };
|
||||
},
|
||||
@@ -128,18 +134,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LangSmith mode: evaluate() with dataset sync, tracing, experiments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// eval-results.json output (same shape as CI PR comment expects)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comparison vs the pinned baseline experiment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Only auto-run as the CLI entry point. Importing this module (e.g. from a unit
|
||||
// test that exercises the exported runEvalAndPersist / writeEvalResults seams)
|
||||
// must not kick off a real eval run against process.argv.
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
// nothing to judge.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { GATED_TIER_NAMES } from '../run/tiers';
|
||||
import type { MultiRunEvaluation, WorkflowTestCase } from '../types';
|
||||
|
||||
// Tiers whose runs assert an absolute green bar instead of comparing to a
|
||||
// baseline. Keep this the single source of truth for "is this a gated run".
|
||||
export const GATED_TIERS = new Set(['pr']);
|
||||
// baseline — declared in run/tiers.ts, the single source of truth for
|
||||
// "is this a gated run". Tier names are otherwise free-form (`datasets`).
|
||||
export const GATED_TIERS = GATED_TIER_NAMES;
|
||||
|
||||
export function isGatedTier(tier?: string): boolean {
|
||||
return tier !== undefined && GATED_TIERS.has(tier);
|
||||
|
||||
@@ -1,8 +1,60 @@
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LocalGatewayStatus } from '../../../src/types';
|
||||
import type { DiscoveryTestCase } from '../../discovery/types';
|
||||
|
||||
const forbiddenToolCallSchema = z
|
||||
.object({
|
||||
toolName: z.string().min(1),
|
||||
argsContainAny: z.array(z.string().min(1)).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Mirrors `LocalGatewayStatus` (src/types.ts) — the annotation makes tsc flag
|
||||
* this schema when the source union drifts. */
|
||||
const localGatewayStatusSchema: z.ZodType<LocalGatewayStatus> = z.discriminatedUnion('status', [
|
||||
z.object({ status: z.literal('connected'), capabilities: z.array(z.string()) }).strict(),
|
||||
z.object({ status: z.literal('disabledGlobally') }).strict(),
|
||||
z.object({ status: z.literal('disconnected') }).strict(),
|
||||
z.object({ status: z.literal('disabled') }).strict(),
|
||||
]);
|
||||
|
||||
/** Strict authoring schema for discovery cases — a typo'd key or an empty
|
||||
* expectation must fail at load time, not pass vacuously at run time (the
|
||||
* workflow-case loader has had this guarantee for a while; discovery cases
|
||||
* were a blind JSON.parse cast until TRUST-261's cleanup). */
|
||||
export const discoveryTestCaseSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
userMessage: z.string().min(1),
|
||||
instanceState: z
|
||||
.object({
|
||||
localGateway: localGatewayStatusSchema.optional(),
|
||||
browserAvailable: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
expectedToolInvocations: z
|
||||
.object({
|
||||
// min(1) on every list: an empty expectation array is dead config that
|
||||
// would otherwise pass here and only surface as a run-time failure.
|
||||
anyOf: z.array(z.string().min(1)).min(1).optional(),
|
||||
noneOf: z.array(z.string().min(1)).min(1).optional(),
|
||||
anyOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
|
||||
allOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
|
||||
noneOfToolCalls: z.array(forbiddenToolCallSchema).min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((expectations) => Object.values(expectations).some((v) => v !== undefined), {
|
||||
message: 'expectedToolInvocations needs at least one expectation key',
|
||||
}),
|
||||
rationale: z.string().optional(),
|
||||
maxSteps: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export interface DiscoveryTestCaseWithFile {
|
||||
testCase: DiscoveryTestCase;
|
||||
/** Filename without extension, e.g. "slack-oauth-credential-setup" */
|
||||
@@ -11,13 +63,22 @@ export interface DiscoveryTestCaseWithFile {
|
||||
|
||||
function parseTestCaseFile(filePath: string): DiscoveryTestCase {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
let raw: unknown;
|
||||
try {
|
||||
return JSON.parse(content) as DiscoveryTestCase;
|
||||
raw = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse discovery test case ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const parsed = discoveryTestCaseSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`)
|
||||
.join('; ');
|
||||
throw new Error(`Invalid discovery test case ${filePath}: ${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseSubstringList(value: string | undefined): string[] {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// Public API for the instance-ai workflow evaluation framework
|
||||
//
|
||||
// This module exports the domain logic used by the CLI (evaluations/cli/)
|
||||
// and available for custom orchestration (e.g. LangSmith evaluate).
|
||||
// and available for custom orchestration. The run phases themselves live in
|
||||
// evaluations/run/ — see evaluations/ARCHITECTURE.md.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// -- Client & Auth --
|
||||
|
||||
@@ -20,6 +20,7 @@ import { z } from 'zod';
|
||||
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { BUILD_ONLY_SCENARIO_NAME, roundRobinCaseRows } from '../run/rows';
|
||||
|
||||
/**
|
||||
* Shape of the inputs passed to the target function for each scenario.
|
||||
@@ -102,9 +103,15 @@ export async function syncDataset(
|
||||
logger.info(`Created dataset: ${datasetName}`);
|
||||
}
|
||||
|
||||
// List existing examples, keyed by derived ID (testCaseFile/scenarioName from inputs).
|
||||
// List existing examples, keyed by derived ID (testCaseFile/scenarioName from
|
||||
// inputs). Scoped to the synced cases' slug splits: every mutation below only
|
||||
// touches these slugs, and a scoped read keeps concurrent syncs of disjoint
|
||||
// cases (the LangTracer dispatcher pattern) and sync cost independent of
|
||||
// dataset size. Already-archived examples carry only the 'archived' split, so
|
||||
// they fall out of the read — which keeps re-archiving idempotent for free.
|
||||
const slugSplits = [...new Set(testCasesWithFiles.map((tc) => tc.fileSlug))];
|
||||
const existingByDerivedId = new Map<string, Example>();
|
||||
for await (const example of lsClient.listExamples({ datasetId })) {
|
||||
for await (const example of lsClient.listExamples({ datasetId, splits: slugSplits })) {
|
||||
const inputs = existingInputsSchema.safeParse(example.inputs);
|
||||
if (!inputs.success) continue;
|
||||
existingByDerivedId.set(`${inputs.data.testCaseFile}/${inputs.data.scenarioName}`, example);
|
||||
@@ -229,14 +236,45 @@ export async function syncDataset(
|
||||
return datasetName;
|
||||
}
|
||||
|
||||
/** Read-after-write guard: freshly created examples can lag the immediate
|
||||
* list. Verify the split-scoped count covers what was just synced before a
|
||||
* driver starts an experiment — an invisible example silently produces an
|
||||
* empty or partial run (the dispatcher's historical "no results" failure). */
|
||||
export async function ensureExamplesVisible(
|
||||
lsClient: Client,
|
||||
datasetName: string,
|
||||
testCasesWithFiles: WorkflowTestCaseWithFile[],
|
||||
logger: EvalLogger,
|
||||
opts: { attempts?: number; baseDelayMs?: number } = {},
|
||||
): Promise<void> {
|
||||
const expected = roundRobinCaseRows(testCasesWithFiles).length;
|
||||
if (expected === 0) return;
|
||||
const attempts = opts.attempts ?? 3;
|
||||
const baseDelayMs = opts.baseDelayMs ?? 2_000;
|
||||
const splits = [...new Set(testCasesWithFiles.map((tc) => tc.fileSlug))];
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let count = 0;
|
||||
for await (const _example of lsClient.listExamples({ datasetName, splits })) count++;
|
||||
if (count >= expected) return;
|
||||
if (attempt >= attempts) {
|
||||
throw new Error(
|
||||
`Dataset "${datasetName}" lists ${String(count)}/${String(expected)} synced example(s) after ${String(attempts)} attempt(s) — read-after-write lag or split drift; refusing to run a partial experiment.`,
|
||||
);
|
||||
}
|
||||
logger.warn(
|
||||
`Dataset "${datasetName}" lists ${String(count)}/${String(expected)} synced example(s); retrying (${String(attempt)}/${String(attempts)})…`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, baseDelayMs * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Scenario name for the single "build-only" row a 0-scenario case emits, so the
|
||||
* workflow still builds and its process/outcome expectations get judged. Shared
|
||||
* with target() and reshape so all three agree on the sentinel. */
|
||||
export const BUILD_ONLY_SCENARIO_NAME = '__build_only__';
|
||||
// Home moved to run/rows.ts (single row-flattening source for both drivers);
|
||||
// re-exported here so existing importers keep working.
|
||||
export { BUILD_ONLY_SCENARIO_NAME };
|
||||
|
||||
interface FlatScenario {
|
||||
testCaseFile: string;
|
||||
@@ -258,49 +296,17 @@ interface FlatScenario {
|
||||
* Output: [tc1/s1, tc2/s1, tc3/s1, tc1/s2, tc2/s2, tc1/s3]
|
||||
*/
|
||||
function buildRoundRobinScenarios(testCasesWithFiles: WorkflowTestCaseWithFile[]): FlatScenario[] {
|
||||
const result: FlatScenario[] = [];
|
||||
const maxScenarios = Math.max(
|
||||
...testCasesWithFiles.map((tc) => (tc.testCase.executionScenarios ?? []).length),
|
||||
0,
|
||||
);
|
||||
|
||||
for (let i = 0; i < maxScenarios; i++) {
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
const scenario = testCase.executionScenarios?.[i];
|
||||
if (scenario) {
|
||||
result.push({
|
||||
testCaseFile: fileSlug,
|
||||
scenarioName: scenario.name,
|
||||
scenarioDescription: scenario.description,
|
||||
dataSetup: scenario.dataSetup,
|
||||
successCriteria: scenario.successCriteria,
|
||||
complexity: testCase.complexity,
|
||||
tags: testCase.tags,
|
||||
triggerType: testCase.triggerType,
|
||||
datasets: testCase.datasets,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build-only cases (0 scenarios) emit one sentinel row so the workflow still builds and its expectations get judged.
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
if ((testCase.executionScenarios?.length ?? 0) === 0) {
|
||||
result.push({
|
||||
testCaseFile: fileSlug,
|
||||
scenarioName: BUILD_ONLY_SCENARIO_NAME,
|
||||
scenarioDescription: '',
|
||||
dataSetup: '',
|
||||
successCriteria: '',
|
||||
complexity: testCase.complexity,
|
||||
tags: testCase.tags,
|
||||
triggerType: testCase.triggerType,
|
||||
datasets: testCase.datasets,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return roundRobinCaseRows(testCasesWithFiles).map(({ testCase, testCaseFile, scenario }) => ({
|
||||
testCaseFile,
|
||||
scenarioName: scenario?.name ?? BUILD_ONLY_SCENARIO_NAME,
|
||||
scenarioDescription: scenario?.description ?? '',
|
||||
dataSetup: scenario?.dataSetup ?? '',
|
||||
successCriteria: scenario?.successCriteria ?? '',
|
||||
complexity: testCase.complexity,
|
||||
tags: testCase.tags,
|
||||
triggerType: testCase.triggerType,
|
||||
datasets: testCase.datasets,
|
||||
}));
|
||||
}
|
||||
|
||||
// Schemas for reading existing LangSmith example data, which is typed as an
|
||||
|
||||
@@ -16,6 +16,7 @@ import { allFailVerdicts, verifyBuildExpectations } from '../build-expectations/
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import { buildWorkflowViaMcp, type McpBuildSettings } from '../cli/mcp-builder';
|
||||
import type { N8nClient } from '../clients/n8n-client';
|
||||
import { resolveArtifactContext } from '../harness/artifacts/artifact-context';
|
||||
import { captureThreadRunDebug } from '../harness/capture-run-debug';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import {
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
type BuildResult,
|
||||
type executeAgentScenario,
|
||||
type executeScenario,
|
||||
type ScenarioSeedContext,
|
||||
} from '../harness/runner';
|
||||
import { isTransientNetworkError } from '../harness/transient-error';
|
||||
import type {
|
||||
@@ -96,6 +98,7 @@ export interface LaneState {
|
||||
workflowJsons: BuildResult['workflowJsons'];
|
||||
buildTrace?: BuildResult['buildTrace'];
|
||||
timeoutMs: number;
|
||||
seedContext?: ScenarioSeedContext;
|
||||
}) => Promise<Awaited<ReturnType<typeof executeScenario>>>;
|
||||
tracedExecuteAgent: (execArgs: {
|
||||
agentId: string;
|
||||
@@ -290,6 +293,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
function stashBuildExpectations(
|
||||
key: string,
|
||||
fileSlug: string,
|
||||
client: N8nClient,
|
||||
build: BuildResult,
|
||||
isPrebuilt: boolean,
|
||||
): void {
|
||||
@@ -305,11 +309,21 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
if (expectations.length === 0) return;
|
||||
buildExpectationsByKey.set(
|
||||
key,
|
||||
verifyBuildExpectations(expectations, {
|
||||
transcript,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
metrics: build.conversationMetrics,
|
||||
}).catch((error: unknown) =>
|
||||
(async () =>
|
||||
await verifyBuildExpectations(expectations, {
|
||||
transcript,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
metrics: build.conversationMetrics,
|
||||
// Rendered non-workflow artifacts (agent AND config-eval), sectioned
|
||||
// with "(no <type> produced)" fallbacks, so outcome expectations can
|
||||
// judge artifact existence, absence and content — parity with the
|
||||
// retired direct loop, which always threaded resolveArtifactContext.
|
||||
artifactContext: await resolveArtifactContext({
|
||||
artifactRefs: build.artifactRefs ?? [],
|
||||
client,
|
||||
logger,
|
||||
}),
|
||||
}))().catch((error: unknown) =>
|
||||
allFailVerdicts(
|
||||
expectations,
|
||||
`judge error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -364,7 +378,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
stashTranscript(build);
|
||||
// isPrebuilt=true: MCP builds have no build transcript, so only
|
||||
// outcome expectations are judged (against the workflow), like prebuilt.
|
||||
stashBuildExpectations(key, fileSlug, build, true);
|
||||
stashBuildExpectations(key, fileSlug, lane.runner.client, build, true);
|
||||
stashRunDebug(lane.runner.client, build);
|
||||
if (build.success && !build.workflowChecks) {
|
||||
build.workflowChecks = await runWorkflowChecks({
|
||||
@@ -390,7 +404,7 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
const buildDurationMs = Date.now() - start;
|
||||
buildDurations.set(key, buildDurationMs);
|
||||
stashTranscript(build);
|
||||
stashBuildExpectations(key, fileSlug, build, true);
|
||||
stashBuildExpectations(key, fileSlug, lane.runner.client, build, true);
|
||||
stashRunDebug(lane.runner.client, build);
|
||||
if (build.success && !build.workflowChecks) {
|
||||
// No transcript in prebuilt mode, but the authored conversation still
|
||||
@@ -452,8 +466,8 @@ export function createBuildOrchestrator(deps: BuildOrchestratorDeps): BuildOrche
|
||||
}
|
||||
buildDurations.set(key, buildDurationMs);
|
||||
stashTranscript(build);
|
||||
stashBuildExpectations(key, fileSlug, build, false);
|
||||
stashAgentContext(key, lane.runner.client, build);
|
||||
stashBuildExpectations(key, fileSlug, lane.runner.client, build, false);
|
||||
stashRunDebug(lane.runner.client, build);
|
||||
logger.info(
|
||||
`[lane ${String(lane.laneNum)}] built ${fileSlug} (iteration ${String(iteration)}) thread=${build.threadId ?? 'none'} success=${String(build.success)}`,
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
cleanupBuild,
|
||||
effectiveTimeoutMs,
|
||||
findAgentArtifactRef,
|
||||
scenariosRequireSerialSeeding,
|
||||
warnAgentSeedDataTablesIgnored,
|
||||
type ScenarioSeedContext,
|
||||
} from '../harness/runner';
|
||||
import {
|
||||
classifyScenarioExecutionError,
|
||||
@@ -106,6 +108,21 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline {
|
||||
}
|
||||
}
|
||||
|
||||
// Per-build-key execution chain for seed-table cases (see runScenarioRow).
|
||||
const serialExecutionByKey = new Map<string, Promise<unknown>>();
|
||||
async function withSerialSeeding<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prev = serialExecutionByKey.get(key) ?? Promise.resolve();
|
||||
const next = prev.then(fn, fn);
|
||||
serialExecutionByKey.set(
|
||||
key,
|
||||
next.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
return await next;
|
||||
}
|
||||
|
||||
const runRow = async (inputs: ScenarioRowInputs): Promise<TargetOutput> => {
|
||||
const iteration = inputs._iteration ?? 0;
|
||||
try {
|
||||
@@ -140,7 +157,13 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline {
|
||||
inputs: ScenarioRowInputs,
|
||||
iteration: number,
|
||||
): Promise<TargetOutput> => {
|
||||
const scenario: ExecutionScenario = {
|
||||
// Rows carry only the per-scenario prose; the authored scenario is the
|
||||
// source of truth for typed extras (seedDataTables) — merge it back so
|
||||
// table-backed scenarios seed their declared rows in both drivers.
|
||||
const authoredScenarios = testCaseByFileSlug.get(inputs.testCaseFile)?.executionScenarios ?? [];
|
||||
const scenario: ExecutionScenario = authoredScenarios.find(
|
||||
(s) => s.name === inputs.scenarioName,
|
||||
) ?? {
|
||||
name: inputs.scenarioName,
|
||||
description: inputs.scenarioDescription,
|
||||
dataSetup: inputs.dataSetup,
|
||||
@@ -304,88 +327,128 @@ export function createCasePipeline(deps: CasePipelineDeps): CasePipeline {
|
||||
// and guards the workflow path below.
|
||||
throw new Error(`No runnable artifact for scenario ${scenario.name}`);
|
||||
}
|
||||
// Captured as a const so the narrowing survives into the closure below.
|
||||
const workflowId = build.workflowId;
|
||||
|
||||
const execStart = Date.now();
|
||||
const nodeCount = build.workflowJsons[0]?.nodes.length ?? 0;
|
||||
let result;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
result = await builtOnLane.tracedExecute({
|
||||
workflowId: build.workflowId,
|
||||
scenario,
|
||||
workflowJsons: build.workflowJsons,
|
||||
buildTrace: build.buildTrace,
|
||||
timeoutMs: effectiveTimeoutMs(
|
||||
testCaseByFileSlug.get(inputs.testCaseFile)?.complexity,
|
||||
args.timeoutMs,
|
||||
),
|
||||
});
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
if (shouldRetryScenarioExecution(errorMessage, attempt)) {
|
||||
logger.warn(
|
||||
` [${scenario.name}] execution attempt ${attempt}/${MAX_EXEC_ATTEMPTS} failed (${errorMessage}); retrying`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
||||
continue;
|
||||
}
|
||||
// Mirror direct mode's per-scenario guard — without this, n8n API errors,
|
||||
// verifier timeouts, or a per-iteration budget abort from
|
||||
// executeWithLlmMock / verifyChecklist would escape to the driver, come
|
||||
// back as a Run with null outputs, and be misclassified as builder
|
||||
// regressions by the feedback extractor. classifyScenarioExecutionError
|
||||
// stamps framework_issue + a timeout-flavoured rootCause for budget aborts.
|
||||
logger.error(` ERROR [${scenario.name}]: ${errorMessage}`);
|
||||
const classified = classifyScenarioExecutionError(errorMessage);
|
||||
return await attachExpectations({
|
||||
buildSuccess: true,
|
||||
workflowId: build.workflowId,
|
||||
passed: false,
|
||||
score: 0,
|
||||
reasoning: classified.reasoning,
|
||||
failureCategory: classified.failureCategory,
|
||||
rootCause: classified.rootCause,
|
||||
execErrors: [errorMessage],
|
||||
buildDurationMs,
|
||||
execDurationMs: Date.now() - execStart,
|
||||
nodeCount,
|
||||
threadId: build.threadId,
|
||||
workflowChecks: build.workflowChecks,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
buildTrace: build.buildTrace,
|
||||
planRejections: build.proxyDecisionStats?.rejection ?? 0,
|
||||
});
|
||||
}
|
||||
// Mirrors the retired direct loop (TRUST-311): a seeded row resets + seeds
|
||||
const seedContext: ScenarioSeedContext | undefined =
|
||||
build.threadId && build.seededScenarioTableIdsByName
|
||||
? { threadId: build.threadId, tableIdsByName: build.seededScenarioTableIdsByName }
|
||||
: undefined;
|
||||
// A scenario that declares seed tables must not run without them (MCP and
|
||||
// prebuilt builds never seed data tables) — executing anyway would grade the
|
||||
// workflow against empty tables and report the miss as a builder failure.
|
||||
if ((scenario.seedDataTables?.length ?? 0) > 0 && !seedContext) {
|
||||
const reason =
|
||||
'Scenario declares seedDataTables but the build provided no seeded-table mapping ' +
|
||||
'(MCP/prebuilt builds do not seed data tables) — refusing to run without the declared rows';
|
||||
logger.error(` ERROR [${scenario.name}]: ${reason}`);
|
||||
return await attachExpectations({
|
||||
buildSuccess: true,
|
||||
workflowId,
|
||||
passed: false,
|
||||
score: 0,
|
||||
reasoning: reason,
|
||||
failureCategory: 'framework_issue',
|
||||
execErrors: [reason],
|
||||
buildDurationMs,
|
||||
execDurationMs: 0,
|
||||
nodeCount: build.workflowJsons[0]?.nodes.length ?? 0,
|
||||
threadId: build.threadId,
|
||||
buildTrace: build.buildTrace,
|
||||
planRejections: build.proxyDecisionStats?.rejection ?? 0,
|
||||
});
|
||||
}
|
||||
const execDurationMs = Date.now() - execStart;
|
||||
const runWorkflowScenario = async (): Promise<TargetOutput> => {
|
||||
const execStart = Date.now();
|
||||
const nodeCount = build.workflowJsons[0]?.nodes.length ?? 0;
|
||||
let result;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
result = await builtOnLane.tracedExecute({
|
||||
workflowId,
|
||||
scenario,
|
||||
workflowJsons: build.workflowJsons,
|
||||
buildTrace: build.buildTrace,
|
||||
seedContext,
|
||||
timeoutMs: effectiveTimeoutMs(
|
||||
testCaseByFileSlug.get(inputs.testCaseFile)?.complexity,
|
||||
args.timeoutMs,
|
||||
),
|
||||
});
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = extractErrorMessage(error);
|
||||
if (shouldRetryScenarioExecution(errorMessage, attempt)) {
|
||||
logger.warn(
|
||||
` [${scenario.name}] execution attempt ${attempt}/${MAX_EXEC_ATTEMPTS} failed (${errorMessage}); retrying`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
||||
continue;
|
||||
}
|
||||
// Mirror direct mode's per-scenario guard — without this, n8n API errors,
|
||||
// verifier timeouts, or a per-iteration budget abort from
|
||||
// executeWithLlmMock / verifyChecklist would escape to the driver, come
|
||||
// back as a Run with null outputs, and be misclassified as builder
|
||||
// regressions by the feedback extractor. classifyScenarioExecutionError
|
||||
// stamps framework_issue + a timeout-flavoured rootCause for budget aborts.
|
||||
logger.error(` ERROR [${scenario.name}]: ${errorMessage}`);
|
||||
const classified = classifyScenarioExecutionError(errorMessage);
|
||||
return await attachExpectations({
|
||||
buildSuccess: true,
|
||||
workflowId: build.workflowId,
|
||||
passed: false,
|
||||
score: 0,
|
||||
reasoning: classified.reasoning,
|
||||
failureCategory: classified.failureCategory,
|
||||
rootCause: classified.rootCause,
|
||||
execErrors: [errorMessage],
|
||||
buildDurationMs,
|
||||
execDurationMs: Date.now() - execStart,
|
||||
nodeCount,
|
||||
threadId: build.threadId,
|
||||
workflowChecks: build.workflowChecks,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
buildTrace: build.buildTrace,
|
||||
planRejections: build.proxyDecisionStats?.rejection ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
const execDurationMs = Date.now() - execStart;
|
||||
|
||||
// Strip failure fields on pass: the verifier sometimes returns "."
|
||||
// placeholders instead of omitting them.
|
||||
const failureCategory = result.success ? undefined : result.failureCategory;
|
||||
const rootCause = result.success ? undefined : result.rootCause;
|
||||
// Strip failure fields on pass: the verifier sometimes returns "."
|
||||
// placeholders instead of omitting them.
|
||||
const failureCategory = result.success ? undefined : result.failureCategory;
|
||||
const rootCause = result.success ? undefined : result.rootCause;
|
||||
|
||||
return await attachExpectations({
|
||||
buildSuccess: true,
|
||||
workflowId: build.workflowId,
|
||||
scenarioWorkflowId: result.workflowId,
|
||||
passed: result.success,
|
||||
score: result.score,
|
||||
reasoning: result.reasoning,
|
||||
failureCategory,
|
||||
rootCause,
|
||||
...(result.incomplete ? { incomplete: true } : {}),
|
||||
execErrors: result.evalResult?.errors ?? [],
|
||||
evalResult: result.evalResult,
|
||||
buildDurationMs,
|
||||
execDurationMs,
|
||||
nodeCount,
|
||||
threadId: build.threadId,
|
||||
workflowChecks: build.workflowChecks,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
buildTrace: build.buildTrace,
|
||||
planRejections: build.proxyDecisionStats?.rejection ?? 0,
|
||||
});
|
||||
return await attachExpectations({
|
||||
buildSuccess: true,
|
||||
workflowId: build.workflowId,
|
||||
scenarioWorkflowId: result.workflowId,
|
||||
passed: result.success,
|
||||
score: result.score,
|
||||
reasoning: result.reasoning,
|
||||
failureCategory,
|
||||
rootCause,
|
||||
...(result.incomplete ? { incomplete: true } : {}),
|
||||
execErrors: result.evalResult?.errors ?? [],
|
||||
evalResult: result.evalResult,
|
||||
buildDurationMs,
|
||||
execDurationMs,
|
||||
nodeCount,
|
||||
threadId: build.threadId,
|
||||
workflowChecks: build.workflowChecks,
|
||||
workflowJson: build.workflowJsons[0],
|
||||
buildTrace: build.buildTrace,
|
||||
planRejections: build.proxyDecisionStats?.rejection ?? 0,
|
||||
});
|
||||
};
|
||||
// Scenarios of one case share tables by name, so seeded rows must not
|
||||
// interleave — the retired direct loop ran them at concurrency 1; rows now
|
||||
// arrive independently, so the gate is a per-build-key chain instead.
|
||||
return scenariosRequireSerialSeeding(authoredScenarios)
|
||||
? await withSerialSeeding(cacheKey, runWorkflowScenario)
|
||||
: await runWorkflowScenario();
|
||||
};
|
||||
|
||||
return { runRow };
|
||||
|
||||
@@ -12,61 +12,38 @@ import { aggregateResults } from './aggregator';
|
||||
import type { ScenarioRowInputs } from './case-pipeline';
|
||||
import { createEvalSession, type EvalSessionConfig } from './eval-session';
|
||||
import { expandWithIterations } from './iterations';
|
||||
import { type RowSink } from './persist';
|
||||
import { reshapeLangSmithRuns, type ReshapeRunRow } from './reshape';
|
||||
import { BUILD_ONLY_SCENARIO_NAME, roundRobinCaseRows } from './rows';
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import { runWithConcurrency } from '../harness/runner';
|
||||
import { BUILD_ONLY_SCENARIO_NAME } from '../langsmith/dataset-sync';
|
||||
import type { MultiRunEvaluation, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
|
||||
|
||||
export interface DirectRunConfig extends Omit<EvalSessionConfig, 'wrap'> {
|
||||
/** Sink for per-iteration results as reshape produces them, so an abort in
|
||||
* aggregation/persistence still leaves runEvalAndPersist the completed rows. */
|
||||
partialResults?: WorkflowTestCaseResult[][];
|
||||
/** Journal of completed rows for crash recovery (see run/persist.ts). */
|
||||
rowSink?: RowSink;
|
||||
}
|
||||
|
||||
/** Mirror of the dataset sync's round-robin ordering: scenario #1 of every
|
||||
* case, then scenario #2, …, then one build-only sentinel row per
|
||||
* scenario-less case — so builds diversify early instead of burning all
|
||||
* concurrency slots on one test case. */
|
||||
/** Same flattening as the LangSmith dataset sync (run/rows.ts), projected to
|
||||
* the per-row input shape the pipeline consumes. */
|
||||
function roundRobinRows(testCasesWithFiles: WorkflowTestCaseWithFile[]): ScenarioRowInputs[] {
|
||||
const rows: ScenarioRowInputs[] = [];
|
||||
const maxScenarios = Math.max(
|
||||
...testCasesWithFiles.map(({ testCase }) => (testCase.executionScenarios ?? []).length),
|
||||
0,
|
||||
);
|
||||
for (let i = 0; i < maxScenarios; i++) {
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
const scenario = testCase.executionScenarios?.[i];
|
||||
if (scenario) {
|
||||
rows.push({
|
||||
testCaseFile: fileSlug,
|
||||
scenarioName: scenario.name,
|
||||
scenarioDescription: scenario.description,
|
||||
dataSetup: scenario.dataSetup,
|
||||
successCriteria: scenario.successCriteria,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
if ((testCase.executionScenarios?.length ?? 0) === 0) {
|
||||
rows.push({
|
||||
testCaseFile: fileSlug,
|
||||
scenarioName: BUILD_ONLY_SCENARIO_NAME,
|
||||
scenarioDescription: '',
|
||||
dataSetup: '',
|
||||
successCriteria: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return roundRobinCaseRows(testCasesWithFiles).map(({ testCaseFile, scenario }) => ({
|
||||
testCaseFile,
|
||||
scenarioName: scenario?.name ?? BUILD_ONLY_SCENARIO_NAME,
|
||||
scenarioDescription: scenario?.description ?? '',
|
||||
dataSetup: scenario?.dataSetup ?? '',
|
||||
successCriteria: scenario?.successCriteria ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
export async function runDirect(config: DirectRunConfig): Promise<{
|
||||
evaluation: MultiRunEvaluation;
|
||||
slugByTestCase: Map<WorkflowTestCase, string>;
|
||||
}> {
|
||||
const { args, lanes, logger, testCasesWithFiles, partialResults } = config;
|
||||
const { args, lanes, logger, testCasesWithFiles, partialResults, rowSink } = config;
|
||||
|
||||
if (testCasesWithFiles.length === 0) {
|
||||
console.log('No workflow test cases selected (check --source / --filter / --exclude / --tier)');
|
||||
@@ -97,7 +74,12 @@ export async function runDirect(config: DirectRunConfig): Promise<{
|
||||
// flight, builds capped per lane by the allocator (MAX_CONCURRENT_BUILDS).
|
||||
const completed: ReshapeRunRow[] = await runWithConcurrency(
|
||||
rows,
|
||||
async (row) => ({ run: { inputs: row, outputs: await session.pipeline.runRow(row) } }),
|
||||
async (row) => {
|
||||
const outputs = await session.pipeline.runRow(row);
|
||||
const completedRow = { run: { inputs: row, outputs } };
|
||||
rowSink?.append(completedRow);
|
||||
return completedRow;
|
||||
},
|
||||
args.concurrency,
|
||||
);
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
executeScenario,
|
||||
workflowExpectedForCase,
|
||||
type BuildResult,
|
||||
type ScenarioSeedContext,
|
||||
} from '../harness/runner';
|
||||
import type {
|
||||
BuildExpectationResult,
|
||||
@@ -161,6 +162,7 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
|
||||
workflowJsons: BuildResult['workflowJsons'];
|
||||
buildTrace?: BuildResult['buildTrace'];
|
||||
timeoutMs: number;
|
||||
seedContext?: ScenarioSeedContext;
|
||||
}) =>
|
||||
await executeScenario(
|
||||
lane.client,
|
||||
@@ -172,6 +174,7 @@ export function createEvalSession(config: EvalSessionConfig): EvalSession {
|
||||
undefined,
|
||||
execArgs.buildTrace,
|
||||
args.pinAiRoots,
|
||||
execArgs.seedContext,
|
||||
),
|
||||
),
|
||||
tracedExecuteAgent: wrap(
|
||||
|
||||
@@ -15,11 +15,17 @@ import { traceable } from 'langsmith/traceable';
|
||||
|
||||
import { aggregateResults, passAtK, passHatK } from './aggregator';
|
||||
import { type Lane, type McpBuildSpend } from './build-orchestrator';
|
||||
import type { ScenarioRowInputs } from './case-pipeline';
|
||||
import { buildCIMetadata, computeExperimentPrefix } from './ci-metadata';
|
||||
import { createEvalSession, MAX_CONCURRENT_BUILDS } from './eval-session';
|
||||
import { expandWithIterations } from './iterations';
|
||||
import { computePassRatePerIter, summarizeMcpBuildSpend } from './persist';
|
||||
import { isPlainObject, parseTargetOutput, reshapeLangSmithRuns } from './reshape';
|
||||
import { computePassRatePerIter, summarizeMcpBuildSpend, type RowSink } from './persist';
|
||||
import {
|
||||
isPlainObject,
|
||||
parseTargetOutput,
|
||||
reshapeLangSmithRuns,
|
||||
type TargetOutput,
|
||||
} from './reshape';
|
||||
import { partialIsolationWarning } from '../cli/args';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import { bucketFromEvaluation } from '../comparison/bucket-from-evaluation';
|
||||
@@ -30,8 +36,8 @@ import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import { EVAL_WORKSPACE_NAME, resolveEvalWorkspaceId } from '../harness/langsmith-seed';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import type { PrebuiltManifest } from '../harness/prebuilt-workflows';
|
||||
import { syncDataset } from '../langsmith/dataset-sync';
|
||||
import type { MultiRunEvaluation, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
|
||||
import { ensureExamplesVisible, syncDataset } from '../langsmith/dataset-sync';
|
||||
import type { MultiRunEvaluation, WorkflowTestCase } from '../types';
|
||||
|
||||
export interface RunConfig {
|
||||
args: CliArgs;
|
||||
@@ -51,10 +57,8 @@ export interface RunConfig {
|
||||
* LangSmith experiment metadata and eval-results.json — the run's only spend
|
||||
* record beyond raw session logs, for a suite that's manual-only due to cost. */
|
||||
mcpBuildSpend: McpBuildSpend[];
|
||||
/** Optional sink the direct loop pushes each completed iteration's results into
|
||||
* as they finish, so an abort that rejects the run still leaves the caller
|
||||
* (runEvalAndPersist) with the scenarios that already completed. */
|
||||
partialResults?: WorkflowTestCaseResult[][];
|
||||
/** Journal of completed rows for crash recovery (see run/persist.ts). */
|
||||
rowSink?: RowSink;
|
||||
}
|
||||
|
||||
export async function runWithLangSmith(config: RunConfig): Promise<{
|
||||
@@ -90,6 +94,11 @@ export async function runWithLangSmith(config: RunConfig): Promise<{
|
||||
// isolation; overriding only one silently touches shared Instance AI data.
|
||||
const isolationWarning = partialIsolationWarning(args.dataset, args.baselinePrefix);
|
||||
if (isolationWarning) logger.warn(isolationWarning);
|
||||
if (args.datasetAutoForked) {
|
||||
logger.warn(
|
||||
`--source langtracer auto-forked this run to dataset "${args.dataset}" (baseline prefix "${args.baselinePrefix}") for cohort isolation. Pass --dataset/--baseline-prefix explicitly to target a shared cohort.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pin eval writes to the eval workspace; our PAT would otherwise default to Prod.
|
||||
const workspaceId = await resolveEvalWorkspaceId();
|
||||
@@ -100,6 +109,7 @@ export async function runWithLangSmith(config: RunConfig): Promise<{
|
||||
}
|
||||
const lsClient = new Client(workspaceId ? { workspaceId } : {});
|
||||
const datasetName = await syncDataset(lsClient, args.dataset, logger, testCasesWithFiles);
|
||||
await ensureExamplesVisible(lsClient, datasetName, testCasesWithFiles, logger);
|
||||
|
||||
// Shared per-run assembly (lanes → allocator → build orchestrator → case
|
||||
// pipeline). The traceable() hook around the lane functions is the only
|
||||
@@ -124,7 +134,11 @@ export async function runWithLangSmith(config: RunConfig): Promise<{
|
||||
}) as typeof fn,
|
||||
});
|
||||
const { buildDurations } = session.orchestrator;
|
||||
const target = session.pipeline.runRow;
|
||||
const target = async (inputs: ScenarioRowInputs): Promise<TargetOutput> => {
|
||||
const outputs = await session.pipeline.runRow(inputs);
|
||||
config.rowSink?.append({ run: { inputs, outputs } });
|
||||
return outputs;
|
||||
};
|
||||
|
||||
const feedbackExtractor = ({ run }: { run: Run }): EvaluationResult[] => {
|
||||
const output = parseTargetOutput(run.outputs);
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
// crash path that still writes whatever completed when the run threw.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'fs';
|
||||
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import { aggregateResults } from './aggregator';
|
||||
import type { McpBuildSpend } from './build-orchestrator';
|
||||
import { parseTargetOutput, reshapeLangSmithRuns, type ReshapeRunRow } from './reshape';
|
||||
import { roundRobinCaseRows } from './rows';
|
||||
import { aggregateWorkflowChecks, statusMap } from '../binaryChecks/aggregate';
|
||||
import type { CliArgs } from '../cli/args';
|
||||
import type { ComparisonOutcome, ComparisonResult } from '../comparison/compare';
|
||||
import { formatComparisonMarkdown, type RerunHint } from '../comparison/format';
|
||||
import { evaluateGate, isGatedTier, type GateResult } from '../comparison/gate';
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import type { EvalLogger } from '../harness/logger';
|
||||
import { extractErrorMessage } from '../harness/transient-error';
|
||||
import { rollupCaseVerification } from '../summary';
|
||||
import type { MultiRunEvaluation, WorkflowTestCase, WorkflowTestCaseResult } from '../types';
|
||||
import type {
|
||||
BuildExpectationResult,
|
||||
MultiRunEvaluation,
|
||||
WorkflowTestCase,
|
||||
WorkflowTestCaseResult,
|
||||
} from '../types';
|
||||
import { caseDisplayPrompt } from '../utils/conversation-text';
|
||||
|
||||
/**
|
||||
@@ -127,6 +135,108 @@ export function ciRerunHint(): RerunHint | undefined {
|
||||
|
||||
/** What `runEval` produces on success — the aggregation plus the LangSmith-only
|
||||
* comparison metadata (undefined in direct-loop mode). */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row sink — crash recovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Journals one JSON line per completed row so a run that dies mid-flight
|
||||
* (budget abort, lane meltdown, OOM) still leaves recoverable verdicts.
|
||||
* Both drivers feed it; `runEvalAndPersist` reads it back on the crash path. */
|
||||
export interface RowSink {
|
||||
path: string;
|
||||
append: (row: ReshapeRunRow) => void;
|
||||
readRows: () => ReshapeRunRow[];
|
||||
}
|
||||
|
||||
export function createRowSink(outputDir: string | undefined): RowSink {
|
||||
const dir = outputDir ?? process.cwd();
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const path = join(dir, 'eval-rows.jsonl');
|
||||
// Truncate any previous run's file so recovery never mixes runs.
|
||||
writeFileSync(path, '');
|
||||
return {
|
||||
path,
|
||||
append(row) {
|
||||
try {
|
||||
appendFileSync(path, `${JSON.stringify(row)}\n`);
|
||||
} catch {
|
||||
// The sink must never fail a row.
|
||||
}
|
||||
},
|
||||
readRows() {
|
||||
try {
|
||||
return readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as ReshapeRunRow];
|
||||
} catch {
|
||||
return []; // torn final line from a hard crash
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reshape sink rows back into per-iteration results, keeping only COMPLETE
|
||||
* iterations (every expected row present) — the retired direct loop's
|
||||
* guarantee that persisted iterations are whole and index-aligned, so
|
||||
* never-run scenarios are not misreported as failures in a crash artifact. */
|
||||
function recoverCompleteIterations(
|
||||
sinkRows: ReshapeRunRow[],
|
||||
testCasesWithFiles: WorkflowTestCaseWithFile[],
|
||||
): WorkflowTestCaseResult[][] {
|
||||
const rowsPerIteration = roundRobinCaseRows(testCasesWithFiles).length;
|
||||
if (rowsPerIteration === 0) return [];
|
||||
const byIteration = new Map<number, ReshapeRunRow[]>();
|
||||
for (const row of sinkRows) {
|
||||
const inputs = row.run.inputs as { _iteration?: number } | undefined;
|
||||
const iteration = inputs?._iteration ?? 0;
|
||||
const group = byIteration.get(iteration) ?? [];
|
||||
group.push(row);
|
||||
byIteration.set(iteration, group);
|
||||
}
|
||||
const complete = [...byIteration.entries()]
|
||||
.filter(([, rows]) => rows.length === rowsPerIteration)
|
||||
.sort(([a], [b]) => a - b);
|
||||
if (complete.length === 0) return [];
|
||||
// Renumber to dense iterations so reshape doesn't stub the gaps.
|
||||
const renumbered = complete.flatMap(([, rows], denseIndex) =>
|
||||
rows.map((row) => ({
|
||||
run: {
|
||||
inputs: { ...(row.run.inputs ?? {}), _iteration: denseIndex },
|
||||
outputs: row.run.outputs,
|
||||
},
|
||||
})),
|
||||
);
|
||||
// Rebuild the judge-verdict side band from the verdicts each row embeds —
|
||||
// reshape reads only this map (the in-memory one died with the run), and
|
||||
// without it recovered rows would lose their expectation units.
|
||||
const buildExpectationsByKey = new Map<string, BuildExpectationResult[]>();
|
||||
for (const row of renumbered) {
|
||||
const inputs = row.run.inputs as { _iteration?: number; testCaseFile?: string };
|
||||
const output = parseTargetOutput(row.run.outputs);
|
||||
if (!inputs.testCaseFile || !output?.expectationResults?.length) continue;
|
||||
const key = `${String(inputs._iteration ?? 0)}:${inputs.testCaseFile}`;
|
||||
if (!buildExpectationsByKey.has(key))
|
||||
buildExpectationsByKey.set(key, output.expectationResults);
|
||||
}
|
||||
return reshapeLangSmithRuns(
|
||||
renumbered,
|
||||
testCasesWithFiles,
|
||||
complete.length,
|
||||
new Map(),
|
||||
buildExpectationsByKey,
|
||||
undefined,
|
||||
new Map(),
|
||||
);
|
||||
}
|
||||
|
||||
export interface EvalRunOutput {
|
||||
evaluation: MultiRunEvaluation;
|
||||
experimentName?: string;
|
||||
@@ -144,6 +254,10 @@ export interface PersistEvalConfig {
|
||||
commitSha: string | undefined;
|
||||
rerun: RerunHint | undefined;
|
||||
mcpBuildSpend: McpBuildSpend[];
|
||||
/** Journal of completed rows; read back to recover a crashed run. */
|
||||
rowSink?: RowSink;
|
||||
/** Needed to reshape recovered sink rows on the crash path. */
|
||||
testCasesWithFiles?: WorkflowTestCaseWithFile[];
|
||||
}
|
||||
|
||||
export interface PersistedEval extends EvalRunOutput {
|
||||
@@ -193,10 +307,21 @@ export async function runEvalAndPersist(
|
||||
} finally {
|
||||
if (!persisted) {
|
||||
try {
|
||||
// Prefer sink recovery — row-granular and fed by BOTH drivers; fall
|
||||
// back to the direct driver's per-iteration channel.
|
||||
const sinkRows = config.rowSink?.readRows() ?? [];
|
||||
const recovered =
|
||||
sinkRows.length > 0 && config.testCasesWithFiles
|
||||
? recoverCompleteIterations(sinkRows, config.testCasesWithFiles)
|
||||
: [];
|
||||
const runResults = recovered.length > 0 ? recovered : partialResults;
|
||||
const evaluation: MultiRunEvaluation =
|
||||
partialResults.length > 0
|
||||
? aggregateResults(partialResults, partialResults.length)
|
||||
runResults.length > 0
|
||||
? aggregateResults(runResults, runResults.length)
|
||||
: { totalRuns: config.iterations, testCases: [] };
|
||||
const recoverySlugByTestCase = config.testCasesWithFiles
|
||||
? new Map(config.testCasesWithFiles.map(({ testCase, fileSlug }) => [testCase, fileSlug]))
|
||||
: undefined;
|
||||
const { jsonPath } = writeEvalResults(
|
||||
evaluation,
|
||||
Date.now() - config.startTime,
|
||||
@@ -204,14 +329,14 @@ export async function runEvalAndPersist(
|
||||
undefined,
|
||||
undefined,
|
||||
config.commitSha,
|
||||
undefined,
|
||||
recoverySlugByTestCase,
|
||||
config.rerun,
|
||||
undefined,
|
||||
config.mcpBuildSpend,
|
||||
undefined,
|
||||
);
|
||||
config.logger.error(
|
||||
`Eval run did not finish cleanly — wrote partial results (${String(partialResults.length)} iteration(s)) to ${jsonPath}`,
|
||||
`Eval run did not finish cleanly — wrote partial results (${String(runResults.length)} iteration(s)) to ${jsonPath}`,
|
||||
);
|
||||
} catch (writeError: unknown) {
|
||||
config.logger.error(
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row flattening — the single source of truth for which rows a run evaluates
|
||||
// and in what order. The LangSmith dataset sync and the direct driver both
|
||||
// flatten cases through here, so keyless (dispatcher) and CI runs can never
|
||||
// drift to different row sets or orders.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { WorkflowTestCaseWithFile } from '../data/workflows';
|
||||
import type { ExecutionScenario, WorkflowTestCase } from '../types';
|
||||
|
||||
/** Scenario name for the single "build-only" row a 0-scenario case emits, so
|
||||
* the workflow still builds and its process/outcome expectations get judged.
|
||||
* Shared by the dataset sync, both drivers and reshape so all agree on the
|
||||
* sentinel. */
|
||||
export const BUILD_ONLY_SCENARIO_NAME = '__build_only__';
|
||||
|
||||
export interface CaseRow {
|
||||
testCase: WorkflowTestCase;
|
||||
testCaseFile: string;
|
||||
/** null = the build-only sentinel row of a scenario-less case. */
|
||||
scenario: ExecutionScenario | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten test cases into rows ordered round-robin across cases: scenario #1
|
||||
* of every case, then scenario #2, …, then one build-only sentinel row per
|
||||
* scenario-less case — so builds diversify early instead of burning all
|
||||
* concurrency slots on one test case.
|
||||
*
|
||||
* Input: [tc1(s1,s2,s3), tc2(s1,s2), tc3()]
|
||||
* Output: [tc1/s1, tc2/s1, tc1/s2, tc2/s2, tc1/s3, tc3/sentinel]
|
||||
*/
|
||||
export function roundRobinCaseRows(testCasesWithFiles: WorkflowTestCaseWithFile[]): CaseRow[] {
|
||||
const rows: CaseRow[] = [];
|
||||
const maxScenarios = Math.max(
|
||||
...testCasesWithFiles.map(({ testCase }) => (testCase.executionScenarios ?? []).length),
|
||||
0,
|
||||
);
|
||||
for (let i = 0; i < maxScenarios; i++) {
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
const scenario = testCase.executionScenarios?.[i];
|
||||
if (scenario) rows.push({ testCase, testCaseFile: fileSlug, scenario });
|
||||
}
|
||||
}
|
||||
for (const { testCase, fileSlug } of testCasesWithFiles) {
|
||||
if ((testCase.executionScenarios?.length ?? 0) === 0) {
|
||||
rows.push({ testCase, testCaseFile: fileSlug, scenario: null });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gated tiers — the only tier names that carry behavior.
|
||||
//
|
||||
// Tiers themselves are deliberately free-form: a case opts into any grouping
|
||||
// via its `datasets` field, `--tier <name>` filters by it, and the name flows
|
||||
// to LangSmith as an example split. There is no tier registry on purpose —
|
||||
// a catalog nothing enforces would only drift. The single piece of tier
|
||||
// knowledge the harness carries is which tiers assert the absolute pass@k
|
||||
// green bar (comparison/gate.ts) instead of a baseline comparison; declare
|
||||
// those here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Tiers whose runs assert the absolute gate instead of comparing to a baseline. */
|
||||
export const GATED_TIER_NAMES: ReadonlySet<string> = new Set(['pr']);
|
||||
Reference in New Issue
Block a user