niceeval 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -0
- package/README.zh.md +160 -0
- package/bin/niceeval.js +11 -0
- package/package.json +96 -0
- package/src/agents/bub.ts +223 -0
- package/src/agents/builtin.ts +6 -0
- package/src/agents/claude-code.ts +96 -0
- package/src/agents/codex.ts +122 -0
- package/src/agents/index.ts +25 -0
- package/src/agents/shared.ts +145 -0
- package/src/cli.ts +421 -0
- package/src/context/context.test.ts +96 -0
- package/src/context/context.ts +427 -0
- package/src/context/control-flow.ts +27 -0
- package/src/context/session.ts +124 -0
- package/src/define.ts +112 -0
- package/src/expect/index.ts +243 -0
- package/src/i18n/en.ts +131 -0
- package/src/i18n/index.ts +39 -0
- package/src/i18n/zh-CN.ts +132 -0
- package/src/index.ts +39 -0
- package/src/loaders/index.ts +56 -0
- package/src/o11y/cost.ts +57 -0
- package/src/o11y/derive.ts +304 -0
- package/src/o11y/otlp/canonical.ts +112 -0
- package/src/o11y/otlp/mappers/bub.ts +30 -0
- package/src/o11y/otlp/mappers/codex.ts +38 -0
- package/src/o11y/otlp/mappers/index.ts +25 -0
- package/src/o11y/otlp/parse.ts +330 -0
- package/src/o11y/otlp/receiver.ts +100 -0
- package/src/o11y/otlp/sandbox-receiver.ts +113 -0
- package/src/o11y/otlp/select.ts +82 -0
- package/src/o11y/parsers/bub.ts +240 -0
- package/src/o11y/parsers/claude-code.ts +270 -0
- package/src/o11y/parsers/codex.ts +480 -0
- package/src/o11y/parsers/index.ts +37 -0
- package/src/o11y/prices.json +9873 -0
- package/src/runner/discover.ts +77 -0
- package/src/runner/reporters/artifacts.ts +81 -0
- package/src/runner/reporters/console.ts +76 -0
- package/src/runner/reporters/index.ts +5 -0
- package/src/runner/reporters/json.ts +52 -0
- package/src/runner/reporters/live.ts +178 -0
- package/src/runner/reporters/table.ts +328 -0
- package/src/runner/run.ts +860 -0
- package/src/runner/sandbox-prep.ts +91 -0
- package/src/sandbox/checkpoint.ts +39 -0
- package/src/sandbox/docker.ts +539 -0
- package/src/sandbox/e2b.ts +203 -0
- package/src/sandbox/index.ts +25 -0
- package/src/sandbox/registry.ts +60 -0
- package/src/sandbox/resolve.ts +131 -0
- package/src/sandbox/source-files.ts +30 -0
- package/src/sandbox/vercel.ts +236 -0
- package/src/scoring/collector.ts +113 -0
- package/src/scoring/judge.ts +236 -0
- package/src/scoring/scoped.ts +289 -0
- package/src/scoring/verdict.ts +20 -0
- package/src/source-loc.ts +53 -0
- package/src/types.ts +913 -0
- package/src/util.test.ts +31 -0
- package/src/util.ts +50 -0
- package/src/view/app/App.tsx +189 -0
- package/src/view/app/components/AttemptModal.tsx +66 -0
- package/src/view/app/components/CodeView.tsx +272 -0
- package/src/view/app/components/CopyControls.tsx +89 -0
- package/src/view/app/components/ExperimentTable.tsx +266 -0
- package/src/view/app/components/GroupSelector.tsx +61 -0
- package/src/view/app/components/LazyArtifact.tsx +50 -0
- package/src/view/app/components/Trace.tsx +100 -0
- package/src/view/app/components/Transcript.tsx +130 -0
- package/src/view/app/components/primitives.tsx +43 -0
- package/src/view/app/components/ui/badge.tsx +21 -0
- package/src/view/app/components/ui/dialog.tsx +34 -0
- package/src/view/app/components/ui/tabs.tsx +30 -0
- package/src/view/app/i18n.ts +341 -0
- package/src/view/app/index.html +13 -0
- package/src/view/app/lib/cn.ts +7 -0
- package/src/view/app/lib/format.ts +73 -0
- package/src/view/app/lib/guards.ts +61 -0
- package/src/view/app/lib/outcome.ts +96 -0
- package/src/view/app/lib/rows.ts +63 -0
- package/src/view/app/lib/transcript-data.tsx +121 -0
- package/src/view/app/main.tsx +17 -0
- package/src/view/app/pages/RunsPage.tsx +83 -0
- package/src/view/app/pages/TracesPage.tsx +40 -0
- package/src/view/app/shared.ts +10 -0
- package/src/view/app/types.ts +114 -0
- package/src/view/app/vite.config.ts +26 -0
- package/src/view/client-dist/app.css +2 -0
- package/src/view/client-dist/app.js +56 -0
- package/src/view/index.ts +406 -0
- package/src/view/styles.css +1074 -0
- package/src/view/template.html +15 -0
|
@@ -0,0 +1,860 @@
|
|
|
1
|
+
// 运行器:发现产出的 eval × agent × runs → attempt,有界并发调度,把每个 attempt
|
|
2
|
+
// 跑成一个 EvalResult。沙箱编排的固定段在这里(起沙箱→上传→基线→setup→驱动 agent→
|
|
3
|
+
// 采 diff→跑脚本→评分→判决→停沙箱),adapter 只填「把 agent 跑起来」一段。
|
|
4
|
+
|
|
5
|
+
import { resolve as resolvePath } from "node:path";
|
|
6
|
+
import { readFile as readSourceFile } from "node:fs/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { Effect, Cause, Duration, Exit } from "effect";
|
|
9
|
+
import { createSandbox, sandboxLabel } from "../sandbox/resolve.ts";
|
|
10
|
+
import { createTraceReceiver, type TraceReceiver } from "../o11y/otlp/receiver.ts";
|
|
11
|
+
import { createInSandboxTraceReceiver } from "../o11y/otlp/sandbox-receiver.ts";
|
|
12
|
+
import { selectTraceSpans, enrichTraceWithIO } from "../o11y/otlp/select.ts";
|
|
13
|
+
import { mapSpansToCanonical } from "../o11y/otlp/mappers/index.ts";
|
|
14
|
+
import { createEvalContext } from "../context/context.ts";
|
|
15
|
+
import { EvalRequirementFailed, EvalSkipped, TurnFailed } from "../context/control-flow.ts";
|
|
16
|
+
import { computeOutcome } from "../scoring/verdict.ts";
|
|
17
|
+
import { probeJudge } from "../scoring/judge.ts";
|
|
18
|
+
import { deriveRunFacts, buildO11ySummary } from "../o11y/derive.ts";
|
|
19
|
+
import { estimateCost } from "../o11y/cost.ts";
|
|
20
|
+
import { t } from "../i18n/index.ts";
|
|
21
|
+
import { formatThrown } from "../util.ts";
|
|
22
|
+
import {
|
|
23
|
+
captureGeneratedFiles,
|
|
24
|
+
initGitAndCommit,
|
|
25
|
+
} from "./sandbox-prep.ts";
|
|
26
|
+
import type {
|
|
27
|
+
Agent,
|
|
28
|
+
AgentContext,
|
|
29
|
+
Cleanup,
|
|
30
|
+
Config,
|
|
31
|
+
DiscoveredEval,
|
|
32
|
+
EvalResult,
|
|
33
|
+
JudgeConfig,
|
|
34
|
+
LocalizedText,
|
|
35
|
+
Reporter,
|
|
36
|
+
ReporterEvent,
|
|
37
|
+
RunShape,
|
|
38
|
+
RunSummary,
|
|
39
|
+
Sandbox,
|
|
40
|
+
SandboxOption,
|
|
41
|
+
ScoringContext,
|
|
42
|
+
ScriptResult,
|
|
43
|
+
SourceArtifact,
|
|
44
|
+
StreamEvent,
|
|
45
|
+
Telemetry,
|
|
46
|
+
TraceSpan,
|
|
47
|
+
} from "../types.ts";
|
|
48
|
+
|
|
49
|
+
/** 一个 (agent, model, flags) 的运行配置 —— 由 CLI / 实验展开。 */
|
|
50
|
+
export interface AgentRun {
|
|
51
|
+
agent: Agent;
|
|
52
|
+
model?: string;
|
|
53
|
+
flags: Record<string, unknown>;
|
|
54
|
+
runs: number;
|
|
55
|
+
earlyExit: boolean;
|
|
56
|
+
sandbox?: SandboxOption;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
budget?: number;
|
|
59
|
+
evalFilter: (id: string) => boolean;
|
|
60
|
+
experimentId?: string;
|
|
61
|
+
strict?: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface RunOptions {
|
|
65
|
+
config: Config;
|
|
66
|
+
evals: DiscoveredEval[];
|
|
67
|
+
agentRuns: AgentRun[];
|
|
68
|
+
reporters: Reporter[];
|
|
69
|
+
maxConcurrency: number;
|
|
70
|
+
signal?: AbortSignal;
|
|
71
|
+
/** TTY live display 的进度回调;设置后 attempt 的 log 消息路由到它而不是 stderr。 */
|
|
72
|
+
onProgress?: (evalId: string, who: string, msg: string) => void;
|
|
73
|
+
/** 上次运行的结果。outcome === "passed" 的 (experimentId, evalId) 组合跳过重跑,结果直接合入本次汇总。 */
|
|
74
|
+
priorResults?: EvalResult[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface Attempt {
|
|
78
|
+
evalDef: DiscoveredEval;
|
|
79
|
+
run: AgentRun;
|
|
80
|
+
attempt: number;
|
|
81
|
+
key: string; // agent+model+evalId,用于早停
|
|
82
|
+
fingerprint: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function runEvals(opts: RunOptions): Promise<RunSummary> {
|
|
86
|
+
const startedAt = new Date().toISOString();
|
|
87
|
+
const t0 = Date.now();
|
|
88
|
+
|
|
89
|
+
// 预检 judge:有显式配置时在第一步验证 API key + 端点可达,避免跑完 agent 才发现 judge 不通。
|
|
90
|
+
{
|
|
91
|
+
const seen = new Set<string>();
|
|
92
|
+
const toProbe: JudgeConfig[] = [];
|
|
93
|
+
for (const jc of [opts.config.judge, ...opts.evals.map((e) => e.judge)]) {
|
|
94
|
+
if (!jc) continue;
|
|
95
|
+
const key = `${jc.model ?? ""}|${jc.baseUrl ?? ""}|${jc.apiKeyEnv ?? ""}`;
|
|
96
|
+
if (seen.has(key)) continue;
|
|
97
|
+
seen.add(key);
|
|
98
|
+
toProbe.push(jc);
|
|
99
|
+
}
|
|
100
|
+
if (toProbe.length > 0) {
|
|
101
|
+
process.stderr.write(t("runner.judgePrecheck"));
|
|
102
|
+
for (const jc of toProbe) {
|
|
103
|
+
const err = await probeJudge(jc, opts.signal);
|
|
104
|
+
if (err) throw new Error(err);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const plannedFingerprints = new Map<string, string>();
|
|
110
|
+
for (const run of opts.agentRuns) {
|
|
111
|
+
for (const evalDef of opts.evals.filter((e) => run.evalFilter(e.id))) {
|
|
112
|
+
plannedFingerprints.set(cacheKey(run, evalDef.id), await computeFingerprint(evalDef, run));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 跨实验结果复用:只有上次 passed 且 fingerprint 匹配的 (experimentId, evalId) 组合直接携入。
|
|
117
|
+
// 失败/错误/跳过/fingerprint 不匹配都会重跑。--force 跳过此逻辑。
|
|
118
|
+
const priorRunKeys = new Set<string>();
|
|
119
|
+
const carriedResults: EvalResult[] = [];
|
|
120
|
+
if (opts.priorResults?.length) {
|
|
121
|
+
for (const r of opts.priorResults) {
|
|
122
|
+
if (!r.experimentId) continue;
|
|
123
|
+
const key = `${r.experimentId}|${r.id}`;
|
|
124
|
+
if (r.outcome === "passed" && r.fingerprint !== undefined && r.fingerprint === plannedFingerprints.get(key)) {
|
|
125
|
+
priorRunKeys.add(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const r of opts.priorResults) {
|
|
129
|
+
if (!r.experimentId || !priorRunKeys.has(`${r.experimentId}|${r.id}`)) continue;
|
|
130
|
+
// 去掉工件引用:工件文件在旧 run 目录,新 summary 里的相对路径会失效。
|
|
131
|
+
carriedResults.push({ ...r, artifactsDir: undefined, artifactBase: undefined, artifactAbsBase: undefined });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 展开 attempts
|
|
136
|
+
// 外层按「round」(run index)迭代,内层按 eval 迭代,目的是让同一 key 的第 i+1 次
|
|
137
|
+
// attempt 排在所有 eval 的第 i 次之后 —— 这样当 earlyExit 开启时,第 0 轮某 eval 通过后、
|
|
138
|
+
// 第 1 轮该 eval 才进入队列,earlyExit 检查能生效。若内外层相反(先 eval 后 round),
|
|
139
|
+
// 则同一 eval 的所有 runs 连续入队,高并发下会全部同时启动,earlyExit 永远来不及跳过。
|
|
140
|
+
const attempts: Attempt[] = [];
|
|
141
|
+
for (const run of opts.agentRuns) {
|
|
142
|
+
const evals = opts.evals.filter((e) => run.evalFilter(e.id));
|
|
143
|
+
for (let i = 0; i < run.runs; i++) {
|
|
144
|
+
for (const evalDef of evals) {
|
|
145
|
+
if (run.experimentId && priorRunKeys.has(`${run.experimentId}|${evalDef.id}`)) continue;
|
|
146
|
+
const key = `${run.agent.name}|${run.model ?? ""}|${evalDef.id}`;
|
|
147
|
+
attempts.push({
|
|
148
|
+
evalDef,
|
|
149
|
+
run,
|
|
150
|
+
attempt: i,
|
|
151
|
+
key,
|
|
152
|
+
fingerprint: plannedFingerprints.get(cacheKey(run, evalDef.id)) ?? "",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (carriedResults.length > 0) {
|
|
159
|
+
const retryCount = new Set(attempts.map((a) => `${a.run.experimentId ?? ""}|${a.evalDef.id}`)).size;
|
|
160
|
+
process.stderr.write(t("runner.resumeCarry", { carried: carriedResults.length, retry: retryCount }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// onRunStart 报「本次实际要跑的 eval」(过滤 + 去重),不是发现到的全部 —— 否则计数误导。
|
|
164
|
+
const runningIds = new Set(attempts.map((a) => a.evalDef.id));
|
|
165
|
+
const runningEvals = [...runningIds].map((id) => ({ id }));
|
|
166
|
+
const firstAgent = opts.agentRuns[0]?.agent;
|
|
167
|
+
const shape: RunShape = {
|
|
168
|
+
evals: runningEvals.length,
|
|
169
|
+
configs: opts.agentRuns.length,
|
|
170
|
+
totalRuns: attempts.length,
|
|
171
|
+
};
|
|
172
|
+
for (const r of opts.reporters) {
|
|
173
|
+
// reporter 只是结果消费方:单个 reporter 抛错记 diagnostic,不能让整次调度崩(P2)。
|
|
174
|
+
await runReporter("onRunStart", () => r.onRunStart?.(runningEvals, firstAgent as Agent, shape));
|
|
175
|
+
}
|
|
176
|
+
await emitReporterEvent(opts.reporters, {
|
|
177
|
+
type: "run:start",
|
|
178
|
+
evals: runningEvals,
|
|
179
|
+
agent: firstAgent as Agent,
|
|
180
|
+
shape,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const results: EvalResult[] = [];
|
|
184
|
+
const passedKeys = new Set<string>();
|
|
185
|
+
// errored = 框架/环境层面的意外(超时、adapter 崩、eval 脚本抛异常……),不是 agent 表现的信号。
|
|
186
|
+
// 同 key 一旦 errored 就会确定性地重复 error,再跑 runs 里剩下的次数纯烧钱;只有 failed(断言
|
|
187
|
+
// 真的没过)才代表 agent 行为的样本,值得跑满 runs 去测通过率。earlyExit 开时两者都提前收尾。
|
|
188
|
+
const erroredKeys = new Set<string>();
|
|
189
|
+
const budgetSpent = new Map<string, number>();
|
|
190
|
+
const budgetReported = new Set<string>();
|
|
191
|
+
|
|
192
|
+
// reporter 的 onEvalComplete 要「每个 attempt 完成即时触发」(保流式输出),又不能让
|
|
193
|
+
// 并发 worker 交错写 → 用一个 permit=1 的信号量串起来(替代原先手搓的 reportQueue 链)。
|
|
194
|
+
const reportMutex = Effect.runSync(Effect.makeSemaphore(1));
|
|
195
|
+
// 沙箱启动单独限流:与 agent 并发(maxConcurrency)解耦,防高并发下 daemon/API 过载。
|
|
196
|
+
// 未显式指定时跟 maxConcurrency 走——各 backend 的推荐值已在 cli 层写进 maxConcurrency 默认值。
|
|
197
|
+
const sandboxSem = Effect.runSync(Effect.makeSemaphore(opts.maxConcurrency));
|
|
198
|
+
|
|
199
|
+
// earlyExit:为每个 key 各建一个 AbortController。某 attempt 通过或 errored 时 abort 它,
|
|
200
|
+
// 让并发进行中的同 key attempt 通过 signal 尽早退出,而不只是等排队的才能被跳过。
|
|
201
|
+
const evalAbortControllers = new Map<string, AbortController>();
|
|
202
|
+
for (const a of attempts) {
|
|
203
|
+
if (a.run.earlyExit && !evalAbortControllers.has(a.key)) {
|
|
204
|
+
evalAbortControllers.set(a.key, new AbortController());
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 有界并发调度:Effect.forEach({ concurrency }) 取代手写 queue / inFlight / Promise.race。
|
|
209
|
+
// 每个 attempt 跑在自己的 fiber;runAttemptEffect 只把「执行错误」收进 EvalResult.error(不 fail),
|
|
210
|
+
// 但中断(Ctrl+C / kill)照常向上传播 —— 所以一条挂掉不会中断其它 attempt,而中断能停掉全部。
|
|
211
|
+
//
|
|
212
|
+
// signal:把 opts.signal 喂给 run → abort 触发根 fiber 中断 → forEach 中断所有子 fiber
|
|
213
|
+
// → 每个 attempt 的 Scope 跑 release(sb.stop)→ 容器全部停掉(治孤儿)。Effect 保证
|
|
214
|
+
// 所有 finalizer 跑完后才结算,所以下面 summarize 时容器已清理干净。
|
|
215
|
+
//
|
|
216
|
+
// 用 runPromiseExit 而非 runPromise:{ signal } 触发的中断会让整个 Exit 标记为 interrupted,
|
|
217
|
+
// 即便内层 catchAllCause 已把中断咽下 —— runPromise 这种情况下会直接 reject,把 Ctrl+C 变成
|
|
218
|
+
// 一条「niceeval 出错」崩溃栈、并跳过下面的部分汇总。runPromiseExit 返回 Exit 不抛,我们据此
|
|
219
|
+
// 把「中断/signal 已 abort」当正常的部分结果收尾,只有真·非中断缺陷才上抛。
|
|
220
|
+
let interrupted = false;
|
|
221
|
+
const exit = await Effect.runPromiseExit(
|
|
222
|
+
Effect.forEach(
|
|
223
|
+
attempts,
|
|
224
|
+
(a) =>
|
|
225
|
+
Effect.gen(function* () {
|
|
226
|
+
// 早停:同 key 已通过,或已 errored(重跑只会重复同一个框架错误)且开了 earlyExit
|
|
227
|
+
// → 跳过未启动的 attempt。
|
|
228
|
+
if (a.run.earlyExit && (passedKeys.has(a.key) || erroredKeys.has(a.key))) {
|
|
229
|
+
yield* Effect.promise(() =>
|
|
230
|
+
emitReporterEvent(opts.reporters, {
|
|
231
|
+
type: "run:earlyExit",
|
|
232
|
+
evalId: a.evalDef.id,
|
|
233
|
+
experimentId: a.run.experimentId,
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const budget = a.run.budget;
|
|
240
|
+
const budgetKey = a.run.experimentId ?? a.run.agent.name;
|
|
241
|
+
const spent = budgetSpent.get(budgetKey) ?? 0;
|
|
242
|
+
if (budget !== undefined && spent >= budget) {
|
|
243
|
+
if (!budgetReported.has(budgetKey)) {
|
|
244
|
+
budgetReported.add(budgetKey);
|
|
245
|
+
yield* Effect.promise(() =>
|
|
246
|
+
emitReporterEvent(opts.reporters, { type: "run:budgetExceeded", budget, spent }),
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 合并全局信号与本 eval 的早停信号:任一 abort → 本 attempt 的信号 abort。
|
|
253
|
+
const evalAc = evalAbortControllers.get(a.key);
|
|
254
|
+
const attemptSignal =
|
|
255
|
+
evalAc && opts.signal
|
|
256
|
+
? AbortSignal.any([opts.signal, evalAc.signal])
|
|
257
|
+
: (evalAc?.signal ?? opts.signal);
|
|
258
|
+
|
|
259
|
+
yield* Effect.promise(() =>
|
|
260
|
+
emitReporterEvent(opts.reporters, {
|
|
261
|
+
type: "eval:start",
|
|
262
|
+
eval: { id: a.evalDef.id },
|
|
263
|
+
agent: a.run.agent,
|
|
264
|
+
attempt: a.attempt,
|
|
265
|
+
experimentId: a.run.experimentId,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
268
|
+
const result = yield* runAttemptEffect(a, opts, sandboxSem, attemptSignal);
|
|
269
|
+
budgetSpent.set(budgetKey, (budgetSpent.get(budgetKey) ?? 0) + (result.estimatedCostUSD ?? 0));
|
|
270
|
+
|
|
271
|
+
if (result.outcome === "passed") {
|
|
272
|
+
passedKeys.add(a.key);
|
|
273
|
+
evalAc?.abort(); // 让同 key 并发 attempt 尽早退出
|
|
274
|
+
} else if (a.run.earlyExit && (passedKeys.has(a.key) || erroredKeys.has(a.key))) {
|
|
275
|
+
// 并发情况:同 key 另一个 attempt 已通过/已 errored 后本 attempt 才完成
|
|
276
|
+
// (被 abort 后产出 errored),不计入结果。
|
|
277
|
+
return;
|
|
278
|
+
} else if (result.outcome === "errored") {
|
|
279
|
+
erroredKeys.add(a.key);
|
|
280
|
+
evalAc?.abort(); // 框架层面的错误会确定性重复,让同 key 剩余 attempt 尽早退出
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
results.push(result);
|
|
284
|
+
yield* reportMutex.withPermits(1)(
|
|
285
|
+
// 每个 reporter 单独兜错:一个写文件失败 / 自定义 reporter 抛错只记 diagnostic,
|
|
286
|
+
// 不让 Promise.all 整体 reject —— 否则 Effect.promise 把它当 defect,fail 掉 forEach、
|
|
287
|
+
// 停掉后续 attempt(P2)。
|
|
288
|
+
Effect.promise(() =>
|
|
289
|
+
Promise.all(
|
|
290
|
+
opts.reporters.map((r) =>
|
|
291
|
+
runReporter("onEvalComplete", () => r.onEvalComplete?.(result)),
|
|
292
|
+
),
|
|
293
|
+
),
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
yield* Effect.promise(() => emitReporterEvent(opts.reporters, { type: "eval:complete", result }));
|
|
297
|
+
}),
|
|
298
|
+
{ concurrency: opts.maxConcurrency, discard: true },
|
|
299
|
+
).pipe(
|
|
300
|
+
// 中断(用户 Ctrl+C):finalizer 已在中断过程中跑完(容器已停),这里只是把它咽下,
|
|
301
|
+
// 好让流程走到 summarize / onRunComplete,用已完成的 results 出一份部分汇总,而不是抛栈。
|
|
302
|
+
Effect.catchAllCause((cause) => {
|
|
303
|
+
if (Cause.isInterrupted(cause)) {
|
|
304
|
+
interrupted = true;
|
|
305
|
+
return Effect.void;
|
|
306
|
+
}
|
|
307
|
+
return Effect.failCause(cause); // 非中断的意外缺陷:照常抛出
|
|
308
|
+
}),
|
|
309
|
+
),
|
|
310
|
+
{ signal: opts.signal },
|
|
311
|
+
);
|
|
312
|
+
if (Exit.isFailure(exit)) {
|
|
313
|
+
// signal abort 或 cause 含中断 → 当作用户中断,走部分汇总;否则是真·缺陷,照常抛出。
|
|
314
|
+
if (opts.signal?.aborted || Cause.isInterrupted(exit.cause)) {
|
|
315
|
+
interrupted = true;
|
|
316
|
+
} else {
|
|
317
|
+
throw Cause.squash(exit.cause);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (interrupted) process.stderr.write(t("runner.interrupted"));
|
|
321
|
+
|
|
322
|
+
// 稳定排序:按发现顺序 + attempt;携带结果并入后一起排
|
|
323
|
+
const order = new Map(opts.evals.map((e, i) => [e.id, i]));
|
|
324
|
+
const allResults = [...carriedResults, ...results];
|
|
325
|
+
allResults.sort(
|
|
326
|
+
(a, b) =>
|
|
327
|
+
(order.get(a.id) ?? 0) - (order.get(b.id) ?? 0) ||
|
|
328
|
+
a.agent.localeCompare(b.agent) ||
|
|
329
|
+
a.attempt - b.attempt,
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const summary = summarize(allResults, firstAgent?.name ?? "", startedAt, Date.now() - t0, opts.config.name);
|
|
333
|
+
await emitReporterEvent(opts.reporters, { type: "run:summary", summary });
|
|
334
|
+
for (const r of opts.reporters) {
|
|
335
|
+
await runReporter("onRunComplete", () => r.onRunComplete?.(summary));
|
|
336
|
+
}
|
|
337
|
+
await emitReporterEvent(opts.reporters, { type: "run:saved", summary });
|
|
338
|
+
return summary;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// reporter / 生命周期钩子调用的统一兜错:它们是「消费方 / 资源起停」,单个失败只记 diagnostic
|
|
342
|
+
// 到 stderr,不能让整次调度崩。返回 void,永不 reject(供 Promise.all 安全聚合)。
|
|
343
|
+
async function runReporter(stage: string, fn: () => unknown): Promise<void> {
|
|
344
|
+
try {
|
|
345
|
+
await fn();
|
|
346
|
+
} catch (e) {
|
|
347
|
+
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
|
348
|
+
process.stderr.write(t("runner.reporterDiagnostic", { stage, message: msg }));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function emitReporterEvent(reporters: readonly Reporter[], event: ReporterEvent): Promise<void> {
|
|
353
|
+
await Promise.all(reporters.map((r) => runReporter(`event:${event.type}`, () => r.onEvent?.(event))));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function summarize(
|
|
357
|
+
results: EvalResult[],
|
|
358
|
+
agent: string,
|
|
359
|
+
startedAt: string,
|
|
360
|
+
durationMs: number,
|
|
361
|
+
name?: LocalizedText,
|
|
362
|
+
): RunSummary {
|
|
363
|
+
const counts = { passed: 0, failed: 0, skipped: 0, errored: 0 };
|
|
364
|
+
let inTok = 0;
|
|
365
|
+
let outTok = 0;
|
|
366
|
+
let cost = 0;
|
|
367
|
+
for (const r of results) {
|
|
368
|
+
counts[r.outcome] += 1;
|
|
369
|
+
inTok += r.usage?.inputTokens ?? 0;
|
|
370
|
+
outTok += r.usage?.outputTokens ?? 0;
|
|
371
|
+
cost += r.estimatedCostUSD ?? 0;
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
name,
|
|
375
|
+
agent,
|
|
376
|
+
startedAt,
|
|
377
|
+
completedAt: new Date().toISOString(),
|
|
378
|
+
passed: counts.passed,
|
|
379
|
+
failed: counts.failed,
|
|
380
|
+
skipped: counts.skipped,
|
|
381
|
+
errored: counts.errored,
|
|
382
|
+
durationMs,
|
|
383
|
+
usage: { inputTokens: inTok, outputTokens: outTok },
|
|
384
|
+
estimatedCostUSD: cost || undefined,
|
|
385
|
+
results,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// 单个 attempt 的资源生命周期用 Effect.Scope 接管:沙箱 + OTLP 接收器经 acquireRelease
|
|
390
|
+
// 注册,无论 body 成功 / 抛错 / 被中断,stop() / close() 都保证执行(治容器与端口泄漏)。
|
|
391
|
+
// remote agent 没有沙箱资源,但仍走同一条 Promise 边界 / 超时 / 评分路径。
|
|
392
|
+
function runAttemptEffect(
|
|
393
|
+
a: Attempt,
|
|
394
|
+
opts: RunOptions,
|
|
395
|
+
sandboxSem: Effect.Semaphore,
|
|
396
|
+
parentSignal?: AbortSignal,
|
|
397
|
+
): Effect.Effect<EvalResult> {
|
|
398
|
+
const config = opts.config;
|
|
399
|
+
const { evalDef, run, attempt } = a;
|
|
400
|
+
const t0 = Date.now();
|
|
401
|
+
|
|
402
|
+
const base: EvalResult = {
|
|
403
|
+
id: evalDef.id,
|
|
404
|
+
description: evalDef.description,
|
|
405
|
+
experimentId: run.experimentId,
|
|
406
|
+
experiment: experimentRunInfo(run),
|
|
407
|
+
agent: run.agent.name,
|
|
408
|
+
model: run.model,
|
|
409
|
+
outcome: "errored",
|
|
410
|
+
fingerprint: a.fingerprint,
|
|
411
|
+
attempt,
|
|
412
|
+
startedAt: new Date(t0).toISOString(),
|
|
413
|
+
durationMs: 0,
|
|
414
|
+
assertions: [],
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const timeoutMs = run.timeoutMs ?? evalDef.timeoutMs ?? config.timeoutMs ?? 600_000;
|
|
418
|
+
// timeoutSignal:给协作式 adapter / docker 命令的「软」截止信号(到点 abort,让能看 signal 的
|
|
419
|
+
// 提前优雅停)。但它【不是】attempt 总超时的硬保证 —— 真正的硬边界是下面的 Effect.timeoutTo:
|
|
420
|
+
// 它中断整段 body,触发 Scope release(停容器),从而即便 adapter 完全无视 signal 也能停掉(P1)。
|
|
421
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
422
|
+
const signal = parentSignal ? AbortSignal.any([parentSignal, timeoutSignal]) : timeoutSignal;
|
|
423
|
+
|
|
424
|
+
// 流式进度打到宿主 stderr(结果走 stdout,互不干扰)。容器主日志【不】放这些进度标记 ——
|
|
425
|
+
// 那里留给 agent 的原始输出(adapter 给 agent 命令开 { stream: true })。
|
|
426
|
+
const who = run.model ? `${run.agent.name}/${run.model}` : run.agent.name;
|
|
427
|
+
// 同时保留最近 20 条进度消息,timeout 时嵌入 error 字段方便定位卡在哪一步。
|
|
428
|
+
const recentLogs: string[] = [];
|
|
429
|
+
const log = (m: string) => {
|
|
430
|
+
recentLogs.push(m);
|
|
431
|
+
if (recentLogs.length > 20) recentLogs.shift();
|
|
432
|
+
if (opts.onProgress) {
|
|
433
|
+
opts.onProgress(evalDef.id, who, m);
|
|
434
|
+
} else {
|
|
435
|
+
process.stderr.write(` · ${evalDef.id} [${who}] ${m}\n`);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
return Effect.scoped(
|
|
440
|
+
Effect.gen(function* () {
|
|
441
|
+
const sandbox =
|
|
442
|
+
run.agent.capabilities.sandbox === true
|
|
443
|
+
? yield* sandboxSem.withPermits(1)(
|
|
444
|
+
Effect.gen(function* () {
|
|
445
|
+
// ── 沙箱:acquire=起,release=stop(成功 / 失败 / 中断都跑)──
|
|
446
|
+
// sandboxSem 只覆盖「容器创建」阶段;容器起好后立即释放,后续 npm install / agent 不占位。
|
|
447
|
+
log(t("runner.startSandbox"));
|
|
448
|
+
return yield* createSandbox({
|
|
449
|
+
sandbox: run.sandbox ?? config.sandbox,
|
|
450
|
+
timeout: timeoutMs,
|
|
451
|
+
runtime: "node24",
|
|
452
|
+
});
|
|
453
|
+
}),
|
|
454
|
+
)
|
|
455
|
+
: createRemoteSandbox();
|
|
456
|
+
if (run.agent.capabilities.sandbox !== true) log(t("runner.useRemoteAgent"));
|
|
457
|
+
|
|
458
|
+
// ── tracing ──────────────────────────────────────────────────────────────────
|
|
459
|
+
// sandbox.otlpHost:
|
|
460
|
+
// string → docker 类沙箱,宿主开本地接收器,container 经 host.docker.internal 回连
|
|
461
|
+
// null → 远程云端沙箱(e2b / vercel),宿主端口不可达 → 改在沙箱内起 collector
|
|
462
|
+
// NICEEVAL_OTLP_HOST 可强制覆盖(如配好 tunnel 时)。
|
|
463
|
+
let receiver: TraceReceiver | undefined;
|
|
464
|
+
let telemetry: Telemetry | undefined;
|
|
465
|
+
if (run.agent.capabilities.tracing) {
|
|
466
|
+
const forcedHost = process.env.NICEEVAL_OTLP_HOST;
|
|
467
|
+
if (forcedHost) {
|
|
468
|
+
// 显式覆盖:走本地接收器,把指定 host 交给 agent
|
|
469
|
+
receiver = yield* createTraceReceiver();
|
|
470
|
+
const endpoint = receiver.endpoint(forcedHost);
|
|
471
|
+
const env = run.agent.tracing?.env?.(endpoint);
|
|
472
|
+
telemetry = env ? { endpoint, env } : { endpoint };
|
|
473
|
+
log(t("runner.otlpOverride", { endpoint }));
|
|
474
|
+
} else if (sandbox.otlpHost !== null) {
|
|
475
|
+
// 本地/docker 沙箱:宿主开接收器
|
|
476
|
+
receiver = yield* createTraceReceiver();
|
|
477
|
+
const endpoint = receiver.endpoint(sandbox.otlpHost);
|
|
478
|
+
const env = run.agent.tracing?.env?.(endpoint);
|
|
479
|
+
telemetry = env ? { endpoint, env } : { endpoint };
|
|
480
|
+
const proto = run.agent.tracing?.protocol;
|
|
481
|
+
log(t("runner.otlpReceiver", { endpoint, proto: proto ? ` (${proto})` : "" }));
|
|
482
|
+
} else {
|
|
483
|
+
// 远程沙箱(e2b / vercel):在沙箱内起 collector,agent 往 localhost:4318 发
|
|
484
|
+
receiver = yield* createInSandboxTraceReceiver(sandbox);
|
|
485
|
+
const endpoint = receiver.endpoint("");
|
|
486
|
+
const env = run.agent.tracing?.env?.(endpoint);
|
|
487
|
+
telemetry = env ? { endpoint, env } : { endpoint };
|
|
488
|
+
const proto = run.agent.tracing?.protocol;
|
|
489
|
+
log(t("runner.otlpInSandbox", { endpoint, proto: proto ? ` (${proto})` : "" }));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// body 是 Promise(adapter 边界)。Effect.promise 给的 AbortSignal 在本 fiber 被中断
|
|
494
|
+
//(用户 Ctrl+C / 下面 timeoutTo 到点)时 abort —— 并进 signal,让真正观察 signal 的
|
|
495
|
+
// adapter / docker 命令随中断一起停,而不只靠 Scope release 兜底。
|
|
496
|
+
return yield* Effect.promise((interruptSignal) =>
|
|
497
|
+
runAttemptBody(a, config, t0, base, {
|
|
498
|
+
sandbox,
|
|
499
|
+
receiver,
|
|
500
|
+
telemetry,
|
|
501
|
+
signal: AbortSignal.any([signal, interruptSignal]),
|
|
502
|
+
log,
|
|
503
|
+
}),
|
|
504
|
+
);
|
|
505
|
+
}),
|
|
506
|
+
).pipe(
|
|
507
|
+
// ── attempt 总超时的硬边界(P1)──
|
|
508
|
+
// timeoutMs 是「整个 attempt(setup+agent+脚本+评分)」的上限,不是 docker 单条命令的。
|
|
509
|
+
// 到点 → 中断整段 body → Scope 跑 release(停容器、关接收器)→ 产出一条 errored 结果。
|
|
510
|
+
// 即便 adapter / test 完全无视 signal 挂死,这一层也能把它停下来并回收资源。
|
|
511
|
+
Effect.timeoutTo({
|
|
512
|
+
duration: Duration.millis(timeoutMs),
|
|
513
|
+
onSuccess: (r: EvalResult) => r,
|
|
514
|
+
onTimeout: (): EvalResult => ({
|
|
515
|
+
...base,
|
|
516
|
+
durationMs: Date.now() - t0,
|
|
517
|
+
error: t("runner.timeout", {
|
|
518
|
+
timeoutMs,
|
|
519
|
+
recentLogs: recentLogs.map((l) => ` · ${l}`).join("\n"),
|
|
520
|
+
}),
|
|
521
|
+
}),
|
|
522
|
+
}),
|
|
523
|
+
// body 自己已兜了 agent 执行错;这里兜的是资源获取 / Scope 层的意外(起沙箱失败等)。
|
|
524
|
+
// 中断【不】吞:此时 Scope 已跑完 release(容器已停),把中断继续上抛,让 forEach 整体停掉,
|
|
525
|
+
// 否则会把中断「恢复」成一条 errored 结果、并让后续 attempt 继续起 —— 那就停不下来了。
|
|
526
|
+
Effect.catchAllCause((cause) =>
|
|
527
|
+
Cause.isInterrupted(cause)
|
|
528
|
+
? Effect.failCause(cause)
|
|
529
|
+
: Effect.succeed({ ...base, durationMs: Date.now() - t0, error: causeToError(cause) }),
|
|
530
|
+
),
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function causeToError(cause: Cause.Cause<never>): string {
|
|
535
|
+
return formatThrown(Cause.squash(cause));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
interface AttemptResources {
|
|
539
|
+
sandbox: Sandbox;
|
|
540
|
+
receiver?: TraceReceiver;
|
|
541
|
+
telemetry?: Telemetry;
|
|
542
|
+
signal: AbortSignal;
|
|
543
|
+
log: (m: string) => void;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// attempt 的固定段(上传→基线→setup→驱动 agent→采 diff→脚本→评分→判决)。
|
|
547
|
+
// 资源已由 runAttemptEffect 的 Scope 持有;这里只在 finally 跑 agent 自己的 cleanup/teardown。
|
|
548
|
+
async function runAttemptBody(
|
|
549
|
+
a: Attempt,
|
|
550
|
+
config: Config,
|
|
551
|
+
t0: number,
|
|
552
|
+
base: EvalResult,
|
|
553
|
+
res: AttemptResources,
|
|
554
|
+
): Promise<EvalResult> {
|
|
555
|
+
const { evalDef, run, attempt } = a;
|
|
556
|
+
const { sandbox, receiver, telemetry, signal, log } = res;
|
|
557
|
+
const usesSandbox = run.agent.capabilities.sandbox === true;
|
|
558
|
+
// 整个 attempt 共用一份 agent ctx(sandbox 钩子 / agent setup / tracing configure / teardown 都用它)。
|
|
559
|
+
const attemptCtx: AgentContext = {
|
|
560
|
+
signal,
|
|
561
|
+
model: run.model,
|
|
562
|
+
flags: run.flags,
|
|
563
|
+
sandbox,
|
|
564
|
+
session: { id: undefined, isNew: true },
|
|
565
|
+
telemetry,
|
|
566
|
+
log,
|
|
567
|
+
};
|
|
568
|
+
let agentCleanup: Cleanup | void = undefined;
|
|
569
|
+
let agentDidSetup = false;
|
|
570
|
+
try {
|
|
571
|
+
if (usesSandbox) {
|
|
572
|
+
await initGitAndCommit(sandbox);
|
|
573
|
+
|
|
574
|
+
// eval 级 setup(starter prep:npm install / 装系统依赖等)。命令默认非 root;
|
|
575
|
+
// setup 里需要 root 的(apt/pip)自己传 { root: true }。
|
|
576
|
+
if (evalDef.setup) {
|
|
577
|
+
log(t("runner.evalSetup"));
|
|
578
|
+
await evalDef.setup(sandbox);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// agent 自己的 lifecycle:装 CLI、写 config(每个沙箱一次,不在每轮 send 里)。
|
|
583
|
+
if (run.agent.setup) {
|
|
584
|
+
log(t("runner.startAgentSetup"));
|
|
585
|
+
agentDidSetup = true;
|
|
586
|
+
agentCleanup = await run.agent.setup(sandbox, attemptCtx);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// OTLP 导出配置(file-based,如 codex 的 config.toml [otel] 块):与 setup 分开,
|
|
590
|
+
// 在主配置写完后追加。仅当 tracing 开 + 有 endpoint 时调一次(env-based 的不实现 configure)。
|
|
591
|
+
if (telemetry && run.agent.tracing?.configure) {
|
|
592
|
+
log(t("runner.startAgentTracing"));
|
|
593
|
+
await run.agent.tracing.configure(sandbox, attemptCtx);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 构造 t,跑 test
|
|
597
|
+
log(t("runner.driveAgent"));
|
|
598
|
+
const judge = resolveJudge(evalDef.judge, config.judge);
|
|
599
|
+
const { context, state } = createEvalContext({
|
|
600
|
+
agent: run.agent,
|
|
601
|
+
sandbox,
|
|
602
|
+
model: run.model,
|
|
603
|
+
flags: run.flags,
|
|
604
|
+
signal,
|
|
605
|
+
log,
|
|
606
|
+
judge,
|
|
607
|
+
telemetry,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
let error: string | undefined;
|
|
611
|
+
let skipReason: string | undefined;
|
|
612
|
+
try {
|
|
613
|
+
await evalDef.test(context);
|
|
614
|
+
} catch (e) {
|
|
615
|
+
if (e instanceof EvalSkipped) skipReason = e.reason;
|
|
616
|
+
else if (e instanceof EvalRequirementFailed) {
|
|
617
|
+
/* 断言已记录,非执行错误 */
|
|
618
|
+
} else if (e instanceof TurnFailed) {
|
|
619
|
+
error = e.message;
|
|
620
|
+
} else {
|
|
621
|
+
// 带 stack——eval 脚本(比如引用了已改名/删掉的 API)抛出的 TypeError 只有
|
|
622
|
+
// "name: message" 完全定位不到是哪一行,报告里必须能看见 eval 文件的 file:line。
|
|
623
|
+
error = formatThrown(e);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (skipReason) log(t("runner.skip", { reason: skipReason }));
|
|
628
|
+
|
|
629
|
+
// 采 diff(脚本如 next build 在采集后才跑,避免 .next 污染 diff)。remote agent 没有 workspace。
|
|
630
|
+
const diff =
|
|
631
|
+
skipReason || !usesSandbox
|
|
632
|
+
? { generatedFiles: {}, deletedFiles: [] }
|
|
633
|
+
: await captureGeneratedFiles(sandbox);
|
|
634
|
+
state.late.diff = diff;
|
|
635
|
+
if (!skipReason && usesSandbox) {
|
|
636
|
+
log(t("runner.diffProgress", {
|
|
637
|
+
changed: Object.keys(diff.generatedFiles).length,
|
|
638
|
+
deleted: diff.deletedFiles.length,
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const scripts: Record<string, ScriptResult> = {};
|
|
643
|
+
state.late.scripts = scripts;
|
|
644
|
+
|
|
645
|
+
// 评分
|
|
646
|
+
const events = state.manager.allEvents;
|
|
647
|
+
const usage = state.manager.usage;
|
|
648
|
+
const facts = deriveRunFacts(events);
|
|
649
|
+
const scoringContext: ScoringContext = {
|
|
650
|
+
events,
|
|
651
|
+
facts,
|
|
652
|
+
diff,
|
|
653
|
+
scripts,
|
|
654
|
+
usage,
|
|
655
|
+
status: state.manager.lastStatus,
|
|
656
|
+
readFile: async (path) => {
|
|
657
|
+
try {
|
|
658
|
+
return await sandbox!.readFile(path);
|
|
659
|
+
} catch {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
if (!skipReason) log(t("runner.scoreJudge"));
|
|
665
|
+
const assertions = skipReason ? [] : await state.collector.finalize(scoringContext);
|
|
666
|
+
const outcome = computeOutcome({ error, assertions, skipReason, strict: run.strict });
|
|
667
|
+
|
|
668
|
+
// 收 OTLP trace:给最后一批导出留点落地时间,再 collect(空则不挂)。
|
|
669
|
+
// codex 的 OTLP 把内部 Rust tracing 全导出来(handle_responses / append_items … 上万条);
|
|
670
|
+
// 先经【每-agent mapper】把原生 span 归一到 canonical GenAI semconv(定 SpanKind),
|
|
671
|
+
// 再 selectTraceSpans 按 kind 挑出回合/模型/工具,丢掉 "other" 噪声(干净小 trace 整段保留)。
|
|
672
|
+
let trace: TraceSpan[] | undefined;
|
|
673
|
+
if (receiver) {
|
|
674
|
+
await receiver.settle(250, 1500);
|
|
675
|
+
const spans = receiver.collect();
|
|
676
|
+
if (spans.length) {
|
|
677
|
+
// 归一 → 选语义 span → 按 call_id 把 transcript 的工具入参/出参 join 上去(span 自身不带命令文本)。
|
|
678
|
+
const canonical = mapSpansToCanonical(spans, run.agent.name);
|
|
679
|
+
trace = enrichTraceWithIO(selectTraceSpans(canonical), facts.toolCalls);
|
|
680
|
+
const note = spans.length > trace.length ? t("runner.traceSelected", { count: trace.length }) : "";
|
|
681
|
+
log(`trace:${spans.length} span${note}`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const durationMs = Date.now() - t0;
|
|
686
|
+
const o11y = buildO11ySummary(events, usage, durationMs);
|
|
687
|
+
// 实测成本(网关带回)优先,缺则按 model + 用量查价格表估算(见 o11y/cost.ts)。
|
|
688
|
+
const cost = usage.costUSD ?? estimateCost(run.model, usage);
|
|
689
|
+
if (cost !== undefined) o11y.estimatedCostUSD = cost;
|
|
690
|
+
|
|
691
|
+
// 收 test 引用到的 eval 源码(按 send / 断言的 loc 去重),供 view 渲染代码视图。
|
|
692
|
+
const sources = await collectSources(events, assertions);
|
|
693
|
+
|
|
694
|
+
return {
|
|
695
|
+
id: evalDef.id,
|
|
696
|
+
description: evalDef.description,
|
|
697
|
+
experimentId: run.experimentId,
|
|
698
|
+
experiment: experimentRunInfo(run),
|
|
699
|
+
agent: run.agent.name,
|
|
700
|
+
model: run.model,
|
|
701
|
+
outcome,
|
|
702
|
+
fingerprint: a.fingerprint,
|
|
703
|
+
attempt,
|
|
704
|
+
startedAt: new Date(t0).toISOString(),
|
|
705
|
+
durationMs,
|
|
706
|
+
assertions,
|
|
707
|
+
usage,
|
|
708
|
+
estimatedCostUSD: cost,
|
|
709
|
+
error,
|
|
710
|
+
skipReason,
|
|
711
|
+
events,
|
|
712
|
+
sources,
|
|
713
|
+
o11y,
|
|
714
|
+
trace,
|
|
715
|
+
diff,
|
|
716
|
+
};
|
|
717
|
+
} catch (e) {
|
|
718
|
+
return {
|
|
719
|
+
...base,
|
|
720
|
+
durationMs: Date.now() - t0,
|
|
721
|
+
error: formatThrown(e),
|
|
722
|
+
};
|
|
723
|
+
} finally {
|
|
724
|
+
// teardown / cleanup 一律在 finally 跑(失败也跑),不改判决,各自兜错(diagnostic)。
|
|
725
|
+
// LIFO:先 agent(setup 最晚),再沙箱 Scope。
|
|
726
|
+
// 沙箱 stop / 接收器 close 不在这里 —— 由 runAttemptEffect 的 Scope 在本函数返回后回收。
|
|
727
|
+
try {
|
|
728
|
+
if (typeof agentCleanup === "function") await agentCleanup();
|
|
729
|
+
if (agentDidSetup) await run.agent.teardown?.(sandbox, attemptCtx);
|
|
730
|
+
} catch {
|
|
731
|
+
// teardown 失败只是 diagnostic,不影响已出的结果
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function createRemoteSandbox(): Sandbox {
|
|
737
|
+
const unavailable = (method: string): never => {
|
|
738
|
+
throw new Error(t("runner.remoteSandboxUnavailable", { method }));
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
return {
|
|
742
|
+
sandboxId: "remote",
|
|
743
|
+
otlpHost: "127.0.0.1",
|
|
744
|
+
async runCommand() {
|
|
745
|
+
return unavailable("runCommand");
|
|
746
|
+
},
|
|
747
|
+
async runShell() {
|
|
748
|
+
return unavailable("runShell");
|
|
749
|
+
},
|
|
750
|
+
async readFile() {
|
|
751
|
+
return unavailable("readFile");
|
|
752
|
+
},
|
|
753
|
+
async fileExists() {
|
|
754
|
+
return unavailable("fileExists");
|
|
755
|
+
},
|
|
756
|
+
async readSourceFiles() {
|
|
757
|
+
return unavailable("readSourceFiles");
|
|
758
|
+
},
|
|
759
|
+
async writeFiles() {
|
|
760
|
+
unavailable("writeFiles");
|
|
761
|
+
},
|
|
762
|
+
async uploadFiles() {
|
|
763
|
+
unavailable("uploadFiles");
|
|
764
|
+
},
|
|
765
|
+
async uploadDirectory() {
|
|
766
|
+
unavailable("uploadDirectory");
|
|
767
|
+
},
|
|
768
|
+
async stop() {
|
|
769
|
+
// no-op:remote agent 生命周期由它自己的进程管理。
|
|
770
|
+
},
|
|
771
|
+
async downloadFile() {
|
|
772
|
+
return unavailable("downloadFile");
|
|
773
|
+
},
|
|
774
|
+
async uploadFile() {
|
|
775
|
+
unavailable("uploadFile");
|
|
776
|
+
},
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* 收集 test 引用到的 eval 源码:从 send(user message)与断言的 loc 去重出文件集,逐个读回。
|
|
782
|
+
* loc.file 相对项目根(= 进程 cwd,CLI 从那儿发现 / 跑 eval),所以按 cwd 解析。读不到就跳过。
|
|
783
|
+
*/
|
|
784
|
+
async function collectSources(
|
|
785
|
+
events: readonly StreamEvent[],
|
|
786
|
+
assertions: readonly EvalResult["assertions"][number][],
|
|
787
|
+
): Promise<SourceArtifact[]> {
|
|
788
|
+
const paths = new Set<string>();
|
|
789
|
+
for (const e of events) if (e.type === "message" && e.loc) paths.add(e.loc.file);
|
|
790
|
+
for (const a of assertions) if (a.loc) paths.add(a.loc.file);
|
|
791
|
+
const out: SourceArtifact[] = [];
|
|
792
|
+
for (const path of paths) {
|
|
793
|
+
try {
|
|
794
|
+
out.push({ path, content: await readSourceFile(resolvePath(process.cwd(), path), "utf-8") });
|
|
795
|
+
} catch {
|
|
796
|
+
// 源码读不到(路径在沙箱内 / 已删 / 权限)——跳过,view 用 loc 也能降级显示行号。
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
return out;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function experimentRunInfo(run: AgentRun): EvalResult["experiment"] {
|
|
803
|
+
return {
|
|
804
|
+
id: run.experimentId,
|
|
805
|
+
flags: run.flags,
|
|
806
|
+
runs: run.runs,
|
|
807
|
+
earlyExit: run.earlyExit,
|
|
808
|
+
sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
|
|
809
|
+
timeoutMs: run.timeoutMs,
|
|
810
|
+
budget: run.budget,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function cacheKey(run: AgentRun, evalId: string): string {
|
|
815
|
+
return `${run.experimentId ?? ""}|${evalId}`;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
async function computeFingerprint(evalDef: DiscoveredEval, run: AgentRun): Promise<string> {
|
|
819
|
+
const source = await readSourceFile(evalDef.sourcePath, "utf-8");
|
|
820
|
+
const payload = {
|
|
821
|
+
source,
|
|
822
|
+
eval: {
|
|
823
|
+
id: evalDef.id,
|
|
824
|
+
tags: evalDef.tags ?? [],
|
|
825
|
+
metadata: evalDef.metadata ?? {},
|
|
826
|
+
timeoutMs: evalDef.timeoutMs,
|
|
827
|
+
},
|
|
828
|
+
run: {
|
|
829
|
+
experimentId: run.experimentId,
|
|
830
|
+
agent: run.agent.name,
|
|
831
|
+
model: run.model,
|
|
832
|
+
flags: run.flags,
|
|
833
|
+
sandbox: run.sandbox === undefined ? undefined : sandboxLabel(run.sandbox),
|
|
834
|
+
timeoutMs: run.timeoutMs,
|
|
835
|
+
strict: run.strict,
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
return createHash("sha256").update(stableJson(payload)).digest("hex");
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function stableJson(value: unknown): string {
|
|
842
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
843
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
844
|
+
const obj = value as Record<string, unknown>;
|
|
845
|
+
return `{${Object.keys(obj)
|
|
846
|
+
.sort()
|
|
847
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(obj[key])}`)
|
|
848
|
+
.join(",")}}`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function resolveJudge(
|
|
852
|
+
evalJudge: JudgeConfig | undefined,
|
|
853
|
+
configJudge: JudgeConfig | undefined,
|
|
854
|
+
): JudgeConfig | undefined {
|
|
855
|
+
return evalJudge ?? configJudge;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function tail(s: string, lines = 40): string {
|
|
859
|
+
return s.trim().split("\n").slice(-lines).join("\n");
|
|
860
|
+
}
|