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,427 @@
|
|
|
1
|
+
// 构造 eval 作者拿到的高层上下文 t。这里把会话驱动(SessionManager)、
|
|
2
|
+
// 断言收集(AssertionCollector)、作用域断言、judge 命名空间接到一起。
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { basename, extname } from "node:path";
|
|
6
|
+
import { SessionManager, RunSession, lastAssistantText } from "./session.ts";
|
|
7
|
+
import { AssertionCollector } from "../scoring/collector.ts";
|
|
8
|
+
import type { Spec } from "../scoring/collector.ts";
|
|
9
|
+
import * as Scoped from "../scoring/scoped.ts";
|
|
10
|
+
import { buildJudge } from "../scoring/judge.ts";
|
|
11
|
+
import { EvalSkipped, EvalRequirementFailed, TurnFailed } from "./control-flow.ts";
|
|
12
|
+
import { deriveRunFacts } from "../o11y/derive.ts";
|
|
13
|
+
import { t } from "../i18n/index.ts";
|
|
14
|
+
import type {
|
|
15
|
+
Agent,
|
|
16
|
+
DiffData,
|
|
17
|
+
DiffView,
|
|
18
|
+
InputFile,
|
|
19
|
+
InputRequest,
|
|
20
|
+
InputRequestFilter,
|
|
21
|
+
JudgeConfig,
|
|
22
|
+
Sandbox,
|
|
23
|
+
SandboxHandle,
|
|
24
|
+
ScoringContext,
|
|
25
|
+
ScriptResult,
|
|
26
|
+
SessionHandle,
|
|
27
|
+
StreamEvent,
|
|
28
|
+
Telemetry,
|
|
29
|
+
TestContext,
|
|
30
|
+
Turn,
|
|
31
|
+
TurnHandle,
|
|
32
|
+
Usage,
|
|
33
|
+
ValueAssertion,
|
|
34
|
+
} from "../types.ts";
|
|
35
|
+
|
|
36
|
+
/** t.file(path) 返回它,延迟到 finalize 再读沙箱文件;t.check 识别并解析它。 */
|
|
37
|
+
export class FileRef {
|
|
38
|
+
constructor(public readonly path: string) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 运行器在 test 跑完后填进来的「迟到结果」(diff / 脚本),供 finalize 用。 */
|
|
42
|
+
export interface LateResult {
|
|
43
|
+
diff: DiffData;
|
|
44
|
+
scripts: Record<string, ScriptResult>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContextState {
|
|
48
|
+
readonly collector: AssertionCollector;
|
|
49
|
+
readonly manager: SessionManager;
|
|
50
|
+
skipReason?: string;
|
|
51
|
+
readonly late: LateResult;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ContextDeps {
|
|
55
|
+
agent: Agent;
|
|
56
|
+
sandbox: Sandbox;
|
|
57
|
+
model?: string;
|
|
58
|
+
flags: Record<string, unknown>;
|
|
59
|
+
signal: AbortSignal;
|
|
60
|
+
log(msg: string): void;
|
|
61
|
+
judge: JudgeConfig | undefined;
|
|
62
|
+
/** tracing agent 的 OTLP 端点(运行器起接收器后注入);经 send ctx 透给 adapter。 */
|
|
63
|
+
telemetry?: Telemetry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createEvalContext(deps: ContextDeps): { context: TestContext; state: ContextState } {
|
|
67
|
+
const manager = new SessionManager({
|
|
68
|
+
agent: deps.agent,
|
|
69
|
+
sandbox: deps.sandbox,
|
|
70
|
+
model: deps.model,
|
|
71
|
+
flags: deps.flags,
|
|
72
|
+
signal: deps.signal,
|
|
73
|
+
log: deps.log,
|
|
74
|
+
telemetry: deps.telemetry,
|
|
75
|
+
});
|
|
76
|
+
const collector = new AssertionCollector();
|
|
77
|
+
const state: ContextState = {
|
|
78
|
+
collector,
|
|
79
|
+
manager,
|
|
80
|
+
late: { diff: { generatedFiles: {}, deletedFiles: [] }, scripts: {} },
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
async function resolveValue(value: unknown, sc: ScoringContext): Promise<unknown> {
|
|
84
|
+
if (value instanceof FileRef) return (await sc.readFile(value.path)) ?? "";
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const diffView: DiffView = {
|
|
89
|
+
get: (path) => state.late.diff.generatedFiles[path],
|
|
90
|
+
isEmpty: () =>
|
|
91
|
+
Object.keys(state.late.diff.generatedFiles).length === 0 &&
|
|
92
|
+
state.late.diff.deletedFiles.length === 0,
|
|
93
|
+
matches: (re) =>
|
|
94
|
+
Object.entries(state.late.diff.generatedFiles).some(([p, c]) => re.test(p) || re.test(c)),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const sandboxHandle: SandboxHandle = {
|
|
98
|
+
get sandboxId() {
|
|
99
|
+
return deps.sandbox.sandboxId;
|
|
100
|
+
},
|
|
101
|
+
get diff() {
|
|
102
|
+
return diffView;
|
|
103
|
+
},
|
|
104
|
+
runCommand: (cmd, args, opts) => deps.sandbox.runCommand(cmd, args, opts),
|
|
105
|
+
runShell: (script, opts) => deps.sandbox.runShell(script, opts),
|
|
106
|
+
readFile: (path) => deps.sandbox.readFile(path),
|
|
107
|
+
fileExists: (path) => deps.sandbox.fileExists(path),
|
|
108
|
+
readSourceFiles: (opts) => deps.sandbox.readSourceFiles(opts),
|
|
109
|
+
writeFiles: (files, targetDir) => deps.sandbox.writeFiles(files, targetDir),
|
|
110
|
+
uploadFiles: (files, targetDir) => deps.sandbox.uploadFiles(files, targetDir),
|
|
111
|
+
uploadDirectory: (localDir, targetDir, opts) => deps.sandbox.uploadDirectory(localDir, targetDir, opts),
|
|
112
|
+
downloadFile: (path) => deps.sandbox.downloadFile(path),
|
|
113
|
+
uploadFile: (path, content) => deps.sandbox.uploadFile(path, content),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
function recordScoped(
|
|
117
|
+
spec: Spec,
|
|
118
|
+
getEvents: () => readonly StreamEvent[],
|
|
119
|
+
getStatus: () => "completed" | "failed" | "waiting",
|
|
120
|
+
getUsage: () => Usage,
|
|
121
|
+
) {
|
|
122
|
+
return collector.record({
|
|
123
|
+
...spec,
|
|
124
|
+
evaluate: (ctx) => {
|
|
125
|
+
const events = getEvents();
|
|
126
|
+
return spec.evaluate({
|
|
127
|
+
...ctx,
|
|
128
|
+
events,
|
|
129
|
+
facts: deriveRunFacts(events),
|
|
130
|
+
status: getStatus(),
|
|
131
|
+
usage: getUsage(),
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function makeJudge(session: RunSession) {
|
|
138
|
+
return buildJudge({
|
|
139
|
+
record: (spec) => collector.record(spec),
|
|
140
|
+
judge: deps.judge,
|
|
141
|
+
getOutput: () => conversationText(session.events),
|
|
142
|
+
getInput: () => session.lastInput,
|
|
143
|
+
signal: deps.signal,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function makeSessionHandle(session: RunSession): SessionHandle {
|
|
148
|
+
const scoped = (spec: Spec) =>
|
|
149
|
+
recordScoped(spec, () => session.events, () => session.lastStatus, () => session.usage);
|
|
150
|
+
|
|
151
|
+
const handle: SessionHandle = {
|
|
152
|
+
send: async (text) => makeTurnHandle(await manager.send(session, text), collector, deps),
|
|
153
|
+
sendFile: async (path, text) =>
|
|
154
|
+
makeTurnHandle(await manager.send(session, text ?? "", [await readInputFile(path)]), collector, deps),
|
|
155
|
+
requireInputRequest: (filter) => requireInputRequest(session, filter),
|
|
156
|
+
respond: async (...responses) => {
|
|
157
|
+
if (responses.length === 0) throw new Error("respond() requires at least one response");
|
|
158
|
+
session.pendingInputRequests.length = 0;
|
|
159
|
+
return makeTurnHandle(await manager.send(session, responses.join("\n")), collector, deps);
|
|
160
|
+
},
|
|
161
|
+
respondAll: async (optionId) => {
|
|
162
|
+
if (session.pendingInputRequests.length === 0) {
|
|
163
|
+
throw new Error("respondAll() requires at least one pending input request");
|
|
164
|
+
}
|
|
165
|
+
const responses = session.pendingInputRequests.map(() => optionId);
|
|
166
|
+
session.pendingInputRequests.length = 0;
|
|
167
|
+
return makeTurnHandle(await manager.send(session, responses.join("\n")), collector, deps);
|
|
168
|
+
},
|
|
169
|
+
get reply() {
|
|
170
|
+
return session.lastMessage;
|
|
171
|
+
},
|
|
172
|
+
get sessionId() {
|
|
173
|
+
return session.id;
|
|
174
|
+
},
|
|
175
|
+
get events() {
|
|
176
|
+
return session.events.slice();
|
|
177
|
+
},
|
|
178
|
+
succeeded: () => scoped(Scoped.succeeded()),
|
|
179
|
+
parked: () => scoped(Scoped.parked()),
|
|
180
|
+
messageIncludes: (token) => scoped(Scoped.messageIncludes(token)),
|
|
181
|
+
calledTool: (name, match) => scoped(Scoped.calledTool(name, match)),
|
|
182
|
+
notCalledTool: (name, match) => scoped(Scoped.notCalledTool(name, match)),
|
|
183
|
+
toolOrder: (names) => scoped(Scoped.toolOrder(names)),
|
|
184
|
+
usedNoTools: () => scoped(Scoped.usedNoTools()),
|
|
185
|
+
maxToolCalls: (max) => scoped(Scoped.maxToolCalls(max)),
|
|
186
|
+
loadedSkill: (skill) => scoped(Scoped.loadedSkill(skill)),
|
|
187
|
+
noFailedActions: () => scoped(Scoped.noFailedActions()),
|
|
188
|
+
event: (type, opts) => scoped(Scoped.eventOfType(type, opts)),
|
|
189
|
+
notEvent: (type) => scoped(Scoped.notEventOfType(type)),
|
|
190
|
+
calledSubagent: (name, match) => scoped(Scoped.calledSubagent(name, match)),
|
|
191
|
+
eventOrder: (types) => scoped(Scoped.eventOrder(types)),
|
|
192
|
+
eventsSatisfy: (predicate, label) => scoped(Scoped.eventsSatisfy(predicate, label)),
|
|
193
|
+
maxTokens: (max) => scoped(Scoped.maxTokens(max)),
|
|
194
|
+
maxCost: (usd) => scoped(Scoped.maxCost(usd)),
|
|
195
|
+
get usage() {
|
|
196
|
+
return session.usage;
|
|
197
|
+
},
|
|
198
|
+
get judge() {
|
|
199
|
+
return makeJudge(session);
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
return handle;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const primary = makeSessionHandle(manager.primary);
|
|
206
|
+
// primary.reply/sessionId/events/usage/judge 是 getter,读的是 manager.primary 的实时状态。
|
|
207
|
+
// 不能 `{ ...primary, ... }` 展开——对象展开会在展开的那一刻把每个 getter 求值成静态值,
|
|
208
|
+
// 之后 t.reply 就永远冻结在「还没 send 过」的初始状态(空字符串)。改用
|
|
209
|
+
// Object.getOwnPropertyDescriptors 搬运属性描述符,getter 保持 getter,照常读到最新状态。
|
|
210
|
+
const extra = {
|
|
211
|
+
newSession: () => makeSessionHandle(manager.newSession()),
|
|
212
|
+
signal: deps.signal,
|
|
213
|
+
model: deps.model,
|
|
214
|
+
flags: deps.flags,
|
|
215
|
+
log: deps.log,
|
|
216
|
+
skip: (reason: string) => {
|
|
217
|
+
if (reason.trim().length === 0) throw new Error(t("context.skipEmpty"));
|
|
218
|
+
state.skipReason = reason;
|
|
219
|
+
throw new EvalSkipped(reason);
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
check: (value: unknown, assertion: ValueAssertion) =>
|
|
223
|
+
collector.record({
|
|
224
|
+
name: assertion.name,
|
|
225
|
+
severity: assertion.severity,
|
|
226
|
+
threshold: assertion.threshold,
|
|
227
|
+
evaluate: async (sc) => assertion.score(await resolveValue(value, sc)),
|
|
228
|
+
}),
|
|
229
|
+
group: <T,>(title: string, fn: () => Promise<T> | T) => collector.withGroup(title, fn),
|
|
230
|
+
require: async (value: unknown, assertion: ValueAssertion) => {
|
|
231
|
+
const v = value instanceof FileRef ? await deps.sandbox.readFile(value.path).catch(() => "") : value;
|
|
232
|
+
const score = await assertion.score(v);
|
|
233
|
+
const passed = assertion.threshold === undefined ? score > 0 : score >= assertion.threshold;
|
|
234
|
+
collector.record({
|
|
235
|
+
name: assertion.name,
|
|
236
|
+
severity: "gate",
|
|
237
|
+
threshold: assertion.threshold,
|
|
238
|
+
evaluate: () => score,
|
|
239
|
+
});
|
|
240
|
+
if (!passed) throw new EvalRequirementFailed(assertion.name);
|
|
241
|
+
return value;
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
sandbox: sandboxHandle,
|
|
245
|
+
file: (path: string) => new FileRef(path) as unknown as string,
|
|
246
|
+
fileChanged: (path: string) => collector.record(Scoped.fileChanged(path)),
|
|
247
|
+
fileDeleted: (path: string) => collector.record(Scoped.fileDeleted(path)),
|
|
248
|
+
notInDiff: (re: RegExp) => collector.record(Scoped.notInDiff(re)),
|
|
249
|
+
noFailedShellCommands: () => collector.record(Scoped.noFailedShellCommands()),
|
|
250
|
+
};
|
|
251
|
+
const context = Object.defineProperties(
|
|
252
|
+
{},
|
|
253
|
+
{
|
|
254
|
+
...Object.getOwnPropertyDescriptors(primary),
|
|
255
|
+
...Object.getOwnPropertyDescriptors(extra),
|
|
256
|
+
},
|
|
257
|
+
) as TestContext;
|
|
258
|
+
|
|
259
|
+
return { context, state };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** 读本地文件(相对项目根)成 InputFile:推断 MIME + base64 编码,供 t.sendFile。 */
|
|
263
|
+
async function readInputFile(path: string): Promise<InputFile> {
|
|
264
|
+
const buf = await readFile(path);
|
|
265
|
+
return { filename: basename(path), mimeType: mimeTypeFor(path), dataBase64: buf.toString("base64") };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mimeTypeFor(path: string): string {
|
|
269
|
+
switch (extname(path).toLowerCase()) {
|
|
270
|
+
case ".png":
|
|
271
|
+
return "image/png";
|
|
272
|
+
case ".jpg":
|
|
273
|
+
case ".jpeg":
|
|
274
|
+
return "image/jpeg";
|
|
275
|
+
case ".gif":
|
|
276
|
+
return "image/gif";
|
|
277
|
+
case ".webp":
|
|
278
|
+
return "image/webp";
|
|
279
|
+
default:
|
|
280
|
+
return "application/octet-stream";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function makeTurnHandle(turn: Turn, collector: AssertionCollector, deps: ContextDeps): TurnHandle {
|
|
285
|
+
const message = lastAssistantText(turn.events) ?? "";
|
|
286
|
+
const facts = deriveRunFacts(turn.events);
|
|
287
|
+
const usage = turn.usage ?? { inputTokens: 0, outputTokens: 0 };
|
|
288
|
+
|
|
289
|
+
const scoped = (spec: Spec) =>
|
|
290
|
+
collector.record({
|
|
291
|
+
...spec,
|
|
292
|
+
evaluate: (ctx) =>
|
|
293
|
+
spec.evaluate({
|
|
294
|
+
...ctx,
|
|
295
|
+
events: turn.events,
|
|
296
|
+
facts,
|
|
297
|
+
status: turn.status,
|
|
298
|
+
usage,
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const handle: TurnHandle = {
|
|
303
|
+
events: turn.events,
|
|
304
|
+
toolCalls: facts.toolCalls,
|
|
305
|
+
status: turn.status,
|
|
306
|
+
message,
|
|
307
|
+
data: turn.data,
|
|
308
|
+
usage: turn.usage,
|
|
309
|
+
expectOk() {
|
|
310
|
+
if (turn.status === "failed") {
|
|
311
|
+
const lastError = [...turn.events]
|
|
312
|
+
.reverse()
|
|
313
|
+
.find((e): e is Extract<StreamEvent, { type: "error" }> => e.type === "error");
|
|
314
|
+
throw new TurnFailed(
|
|
315
|
+
lastError ? t("context.turnFailed", { message: lastError.message }) : undefined,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return handle;
|
|
319
|
+
},
|
|
320
|
+
outputEquals: (value) =>
|
|
321
|
+
collector.record({
|
|
322
|
+
name: "outputEquals",
|
|
323
|
+
severity: "gate",
|
|
324
|
+
evaluate: () => (deepEqual(turn.data, value) ? 1 : 0),
|
|
325
|
+
}),
|
|
326
|
+
outputMatches: (schema) =>
|
|
327
|
+
collector.record({
|
|
328
|
+
name: "outputMatches",
|
|
329
|
+
severity: "gate",
|
|
330
|
+
evaluate: () => (validateSchema(turn.data, schema) ? 1 : 0),
|
|
331
|
+
}),
|
|
332
|
+
messageIncludes: (token) => scoped(Scoped.messageIncludes(token)),
|
|
333
|
+
succeeded: () => scoped(Scoped.succeeded()),
|
|
334
|
+
calledTool: (name, match) => scoped(Scoped.calledTool(name, match)),
|
|
335
|
+
notCalledTool: (name, match) => scoped(Scoped.notCalledTool(name, match)),
|
|
336
|
+
toolOrder: (names) => scoped(Scoped.toolOrder(names)),
|
|
337
|
+
usedNoTools: () => scoped(Scoped.usedNoTools()),
|
|
338
|
+
maxToolCalls: (max) => scoped(Scoped.maxToolCalls(max)),
|
|
339
|
+
event: (type, opts) => scoped(Scoped.eventOfType(type, opts)),
|
|
340
|
+
notEvent: (type) => scoped(Scoped.notEventOfType(type)),
|
|
341
|
+
calledSubagent: (name, match) => scoped(Scoped.calledSubagent(name, match)),
|
|
342
|
+
eventOrder: (types) => scoped(Scoped.eventOrder(types)),
|
|
343
|
+
eventsSatisfy: (predicate, label) => scoped(Scoped.eventsSatisfy(predicate, label)),
|
|
344
|
+
judge: buildJudge({
|
|
345
|
+
record: (spec) => collector.record(spec),
|
|
346
|
+
judge: deps.judge,
|
|
347
|
+
getOutput: () => message,
|
|
348
|
+
getInput: () => "",
|
|
349
|
+
signal: deps.signal,
|
|
350
|
+
}),
|
|
351
|
+
};
|
|
352
|
+
return handle;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function conversationText(events: readonly StreamEvent[]): string {
|
|
356
|
+
return events
|
|
357
|
+
.filter((e): e is Extract<StreamEvent, { type: "message" }> => e.type === "message")
|
|
358
|
+
.map((e) => `${e.role}: ${e.text}`)
|
|
359
|
+
.join("\n");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function requireInputRequest(session: RunSession, filter?: InputRequestFilter): InputRequest {
|
|
363
|
+
const matches = session.pendingInputRequests.filter((request) => inputRequestMatches(request, filter));
|
|
364
|
+
if (matches.length !== 1) {
|
|
365
|
+
throw new Error(`Expected exactly one pending input request, found ${matches.length}`);
|
|
366
|
+
}
|
|
367
|
+
return matches[0] as InputRequest;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function inputRequestMatches(request: InputRequest, filter?: InputRequestFilter): boolean {
|
|
371
|
+
if (!filter) return true;
|
|
372
|
+
if (filter.id !== undefined && !stringMatches(request.id ?? "", filter.id)) return false;
|
|
373
|
+
if (filter.prompt !== undefined && !stringMatches(request.prompt ?? "", filter.prompt)) return false;
|
|
374
|
+
if (filter.display !== undefined && !stringMatches(request.display ?? "", filter.display)) return false;
|
|
375
|
+
if (filter.action !== undefined && !stringMatches(request.action ?? "", filter.action)) return false;
|
|
376
|
+
if (filter.optionIds !== undefined) {
|
|
377
|
+
const optionIds = new Set((request.options ?? []).map((o) => o.id));
|
|
378
|
+
if (!filter.optionIds.every((id) => optionIds.has(id))) return false;
|
|
379
|
+
}
|
|
380
|
+
if (filter.input !== undefined && !partialObjectMatches(request.input, filter.input)) return false;
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function stringMatches(actual: string, expected: string | RegExp): boolean {
|
|
385
|
+
return expected instanceof RegExp ? expected.test(actual) : actual === expected;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function partialObjectMatches(actual: unknown, expected: Record<string, unknown>): boolean {
|
|
389
|
+
if (actual === null || typeof actual !== "object") return false;
|
|
390
|
+
const obj = actual as Record<string, unknown>;
|
|
391
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
392
|
+
if (!deepEqual(obj[key], value)) return false;
|
|
393
|
+
}
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
398
|
+
if (a === b) return true;
|
|
399
|
+
if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") return false;
|
|
400
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
401
|
+
const ak = Object.keys(a as object);
|
|
402
|
+
const bk = Object.keys(b as object);
|
|
403
|
+
if (ak.length !== bk.length) return false;
|
|
404
|
+
for (const k of ak) {
|
|
405
|
+
if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
|
|
406
|
+
}
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function validateSchema(value: unknown, schema: unknown): boolean {
|
|
411
|
+
try {
|
|
412
|
+
const std = (schema as { ["~standard"]?: { validate(v: unknown): { issues?: unknown } } })["~standard"];
|
|
413
|
+
if (std && typeof std.validate === "function") {
|
|
414
|
+
const r = std.validate(value) as { issues?: unknown };
|
|
415
|
+
return !(r && r.issues);
|
|
416
|
+
}
|
|
417
|
+
const zodLike = schema as { safeParse?(v: unknown): { success: boolean }; parse?(v: unknown): unknown };
|
|
418
|
+
if (typeof zodLike.safeParse === "function") return zodLike.safeParse(value).success;
|
|
419
|
+
if (typeof zodLike.parse === "function") {
|
|
420
|
+
zodLike.parse(value);
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// test(t) 里的非错误控制流信号。运行器据类型分流:跳过不是失败,断言失败不是异常。
|
|
2
|
+
|
|
3
|
+
import { t } from "../i18n/index.ts";
|
|
4
|
+
|
|
5
|
+
/** t.skip(reason):该 eval 不构成有效测试,记 skipped(不计入,不算 agent 挂)。 */
|
|
6
|
+
export class EvalSkipped extends Error {
|
|
7
|
+
constructor(public readonly reason: string) {
|
|
8
|
+
super(`eval skipped: ${reason}`);
|
|
9
|
+
this.name = "EvalSkipped";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** t.require / turn.expectOk 不过:正常的断言失败,中止后续,但已记录的断言决定判决。 */
|
|
14
|
+
export class EvalRequirementFailed extends Error {
|
|
15
|
+
constructor(public readonly assertionName: string) {
|
|
16
|
+
super(`requirement failed: ${assertionName}`);
|
|
17
|
+
this.name = "EvalRequirementFailed";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 本轮 send 返回 failed,作者调了 expectOk():视为执行错误(eval failed)。 */
|
|
22
|
+
export class TurnFailed extends Error {
|
|
23
|
+
constructor(message = t("context.turnFailedDefault")) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "TurnFailed";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 会话驱动:把 t.send(text) 翻成 agent.send(input, ctx),在同一沙箱里多轮 resume /
|
|
2
|
+
// newSession,并把每轮的标准事件流与用量累加进整次运行(供作用域断言 / o11y)。
|
|
3
|
+
|
|
4
|
+
import type { Agent, AgentContext, InputFile, InputRequest, Sandbox, StreamEvent, Telemetry, Turn, Usage } from "../types.ts";
|
|
5
|
+
import { captureLoc } from "../source-loc.ts";
|
|
6
|
+
import { t } from "../i18n/index.ts";
|
|
7
|
+
|
|
8
|
+
/** 一条会话线的可变状态。adapter 读 isNew 决定是否 --resume,写 id 供下轮续接。 */
|
|
9
|
+
export class RunSession {
|
|
10
|
+
id: string | undefined = undefined;
|
|
11
|
+
isNew = true;
|
|
12
|
+
index = 1;
|
|
13
|
+
lastMessage = "";
|
|
14
|
+
lastInput = "";
|
|
15
|
+
lastStatus: "completed" | "failed" | "waiting" = "completed";
|
|
16
|
+
readonly events: StreamEvent[] = [];
|
|
17
|
+
readonly pendingInputRequests: InputRequest[] = [];
|
|
18
|
+
readonly usage: Usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, requests: 0 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SessionDeps {
|
|
22
|
+
agent: Agent;
|
|
23
|
+
sandbox: Sandbox;
|
|
24
|
+
model?: string;
|
|
25
|
+
flags: Record<string, unknown>;
|
|
26
|
+
signal: AbortSignal;
|
|
27
|
+
log(msg: string): void;
|
|
28
|
+
/** tracing agent 的 OTLP 端点(经 send ctx 透给 adapter,用于注入导出 env)。 */
|
|
29
|
+
telemetry?: Telemetry;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class SessionManager {
|
|
33
|
+
/** 整次运行(所有会话、所有轮)累计的标准事件流。 */
|
|
34
|
+
readonly allEvents: StreamEvent[] = [];
|
|
35
|
+
readonly usage: Usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, requests: 0 };
|
|
36
|
+
lastStatus: "completed" | "failed" | "waiting" = "completed";
|
|
37
|
+
|
|
38
|
+
readonly primary: RunSession;
|
|
39
|
+
private readonly sessions: RunSession[] = [];
|
|
40
|
+
private turnCount = 0;
|
|
41
|
+
private sessionCount = 0;
|
|
42
|
+
|
|
43
|
+
constructor(private readonly deps: SessionDeps) {
|
|
44
|
+
this.primary = this.newSession();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
newSession(): RunSession {
|
|
48
|
+
const s = new RunSession();
|
|
49
|
+
s.index = ++this.sessionCount;
|
|
50
|
+
this.sessions.push(s);
|
|
51
|
+
return s;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async send(session: RunSession, text: string, files?: readonly InputFile[]): Promise<Turn> {
|
|
55
|
+
// 抓住作者调 t.send / t.sendFile 那一行(view 把回复叠回这一行)。
|
|
56
|
+
const loc = captureLoc();
|
|
57
|
+
const ctx: AgentContext = {
|
|
58
|
+
signal: this.deps.signal,
|
|
59
|
+
model: this.deps.model,
|
|
60
|
+
flags: this.deps.flags,
|
|
61
|
+
sandbox: this.deps.sandbox,
|
|
62
|
+
session: session as unknown as AgentContext["session"],
|
|
63
|
+
telemetry: this.deps.telemetry,
|
|
64
|
+
log: this.deps.log,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const n = ++this.turnCount;
|
|
68
|
+
const attach = files?.length ? ` 📎${files.length}` : "";
|
|
69
|
+
const preview = (text.replace(/\s+/g, " ").slice(0, 36) || (files?.[0]?.filename ?? t("session.fileFallback"))) + attach;
|
|
70
|
+
const turnLabel = session.index === 1
|
|
71
|
+
? t("session.turn.primary", { turn: n })
|
|
72
|
+
: t("session.turn.secondary", { session: session.index, turn: n });
|
|
73
|
+
this.deps.log(`${turnLabel} → "${preview}…"`);
|
|
74
|
+
const t0 = Date.now();
|
|
75
|
+
|
|
76
|
+
session.lastInput = text;
|
|
77
|
+
const userEvent: StreamEvent = { type: "message", role: "user", text, loc };
|
|
78
|
+
this.allEvents.push(userEvent);
|
|
79
|
+
session.events.push(userEvent);
|
|
80
|
+
session.pendingInputRequests.length = 0;
|
|
81
|
+
const turn = await this.deps.agent.send({ text, files }, ctx);
|
|
82
|
+
|
|
83
|
+
this.allEvents.push(...turn.events);
|
|
84
|
+
session.events.push(...turn.events);
|
|
85
|
+
session.pendingInputRequests.push(
|
|
86
|
+
...turn.events
|
|
87
|
+
.filter((e): e is Extract<StreamEvent, { type: "input.requested" }> => e.type === "input.requested")
|
|
88
|
+
.map((e) => e.request),
|
|
89
|
+
);
|
|
90
|
+
if (turn.usage) {
|
|
91
|
+
accumulateUsage(this.usage, turn.usage);
|
|
92
|
+
accumulateUsage(session.usage, turn.usage);
|
|
93
|
+
}
|
|
94
|
+
session.isNew = false;
|
|
95
|
+
session.lastStatus = turn.status;
|
|
96
|
+
this.lastStatus = turn.status;
|
|
97
|
+
const reply = lastAssistantText(turn.events);
|
|
98
|
+
if (reply !== undefined) session.lastMessage = reply;
|
|
99
|
+
|
|
100
|
+
const tok = (turn.usage?.inputTokens ?? 0) + (turn.usage?.outputTokens ?? 0);
|
|
101
|
+
const tools = turn.events.filter((e) => e.type === "action.called").length;
|
|
102
|
+
this.deps.log(
|
|
103
|
+
`${turnLabel} ← ${turn.status} · ${t("session.tools", { count: tools })} · ${tok} tok · ${Math.round((Date.now() - t0) / 1000)}s`,
|
|
104
|
+
);
|
|
105
|
+
return turn;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function lastAssistantText(events: readonly StreamEvent[]): string | undefined {
|
|
110
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
111
|
+
const e = events[i];
|
|
112
|
+
if (e.type === "message" && e.role === "assistant" && e.text.trim()) return e.text;
|
|
113
|
+
}
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function accumulateUsage(acc: Usage, add: Usage): void {
|
|
118
|
+
acc.inputTokens += add.inputTokens ?? 0;
|
|
119
|
+
acc.outputTokens += add.outputTokens ?? 0;
|
|
120
|
+
acc.cacheReadTokens = (acc.cacheReadTokens ?? 0) + (add.cacheReadTokens ?? 0);
|
|
121
|
+
acc.cacheWriteTokens = (acc.cacheWriteTokens ?? 0) + (add.cacheWriteTokens ?? 0);
|
|
122
|
+
acc.requests = (acc.requests ?? 0) + (add.requests ?? 1);
|
|
123
|
+
if (add.costUSD !== undefined) acc.costUSD = (acc.costUSD ?? 0) + add.costUSD;
|
|
124
|
+
}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 定义入口:把用户对象规格化成核心认得的形状。路径即身份 —— 这里禁止手写 id,
|
|
2
|
+
// 由发现阶段从文件路径推导(见 runner/discover.ts)。
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
Agent,
|
|
6
|
+
Config,
|
|
7
|
+
CustomSandboxSpec,
|
|
8
|
+
DockerSandboxSpec,
|
|
9
|
+
E2BSandboxSpec,
|
|
10
|
+
EvalDef,
|
|
11
|
+
ExperimentDef,
|
|
12
|
+
RemoteAgentDef,
|
|
13
|
+
SandboxAgentDef,
|
|
14
|
+
VercelSandboxSpec,
|
|
15
|
+
} from "./types.ts";
|
|
16
|
+
import { t } from "./i18n/index.ts";
|
|
17
|
+
|
|
18
|
+
const SANDBOX_DEFAULT_CAPS = {
|
|
19
|
+
conversation: true,
|
|
20
|
+
toolObservability: true,
|
|
21
|
+
workspace: true,
|
|
22
|
+
sandbox: true,
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const REMOTE_DEFAULT_CAPS = {
|
|
26
|
+
conversation: true,
|
|
27
|
+
toolObservability: true,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
/** 沙箱型 agent:在沙箱里 spawn 一个 coding agent 的 CLI,跑完读回 transcript。 */
|
|
31
|
+
export function defineSandboxAgent(def: SandboxAgentDef): Agent {
|
|
32
|
+
if (!def.name) throw new Error(t("define.sandboxAgentNameRequired"));
|
|
33
|
+
return {
|
|
34
|
+
name: def.name,
|
|
35
|
+
capabilities: { ...SANDBOX_DEFAULT_CAPS, ...(def.capabilities ?? {}) },
|
|
36
|
+
setup: def.setup,
|
|
37
|
+
tracing: def.tracing,
|
|
38
|
+
send: def.send,
|
|
39
|
+
teardown: def.teardown,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 远程 / 进程内 agent:在 send 里直接驱动你的函数 / 服务。 */
|
|
44
|
+
export function defineAgent(def: RemoteAgentDef): Agent {
|
|
45
|
+
if (!def.name) throw new Error(t("define.agentNameRequired"));
|
|
46
|
+
return {
|
|
47
|
+
name: def.name,
|
|
48
|
+
capabilities: { ...REMOTE_DEFAULT_CAPS, ...(def.capabilities ?? {}) },
|
|
49
|
+
setup: def.setup,
|
|
50
|
+
tracing: def.tracing,
|
|
51
|
+
send: def.send,
|
|
52
|
+
teardown: def.teardown,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 会话型 eval。禁止提供 id —— 从路径推导。 */
|
|
57
|
+
export function defineEval(def: EvalDef): EvalDef {
|
|
58
|
+
if ((def as { id?: unknown }).id !== undefined) {
|
|
59
|
+
throw new Error(t("define.evalIdRejected"));
|
|
60
|
+
}
|
|
61
|
+
if (typeof def.test !== "function") {
|
|
62
|
+
throw new Error(t("define.evalTestRequired"));
|
|
63
|
+
}
|
|
64
|
+
return def;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 实验:可签入的运行配置(怎么跑这批 eval)。 */
|
|
68
|
+
export function defineExperiment(def: ExperimentDef): ExperimentDef {
|
|
69
|
+
if ((def as { id?: unknown }).id !== undefined) {
|
|
70
|
+
throw new Error(t("define.experimentIdRejected"));
|
|
71
|
+
}
|
|
72
|
+
if (!def.agent) throw new Error(t("define.experimentAgentRequired"));
|
|
73
|
+
return def;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 项目级配置。 */
|
|
77
|
+
export function defineConfig(config: Config): Config {
|
|
78
|
+
return config;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ───────────────────────── Sandbox 工厂 ─────────────────────────
|
|
82
|
+
// Sandbox 与 agent 一样用数据结构带参数(见 docs/sandbox.md)。这些工厂只是把
|
|
83
|
+
// 后端 + 参数包成 spec 对象;真正的行为在 sandbox/<backend>.ts 里,由 resolve.ts 派发。
|
|
84
|
+
|
|
85
|
+
/** Docker 沙箱:本地容器。`image` 可覆盖默认 `node:*-slim`(预制模板:烘焙好 agent CLI 的镜像)。 */
|
|
86
|
+
export function dockerSandbox(opts: Omit<DockerSandboxSpec, "backend"> = {}): DockerSandboxSpec {
|
|
87
|
+
return { backend: "docker", ...opts };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Vercel Sandbox:microVM。`snapshotId` 从已有快照起(预制模板:烘焙好 agent CLI 的快照)。 */
|
|
91
|
+
export function vercelSandbox(opts: Omit<VercelSandboxSpec, "backend"> = {}): VercelSandboxSpec {
|
|
92
|
+
return { backend: "vercel", ...opts };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** E2B 沙箱。`template` 选 e2b 模板名/ID(预制模板:如 `"niceeval-agents"`);省略用 e2b 默认 `"base"`。 */
|
|
96
|
+
export function e2bSandbox(opts: Omit<E2BSandboxSpec, "backend"> = {}): E2BSandboxSpec {
|
|
97
|
+
return { backend: "e2b", ...opts };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 自定义沙箱后端:`create` 直接返回一个实现 `Sandbox` 接口的实例,不需要 niceeval 内置支持
|
|
102
|
+
* 这个后端名字。用于接入 docker/vercel/e2b 之外的运行环境(自建 VM、Modal、Fly 等)。
|
|
103
|
+
*/
|
|
104
|
+
export function defineSandbox(def: {
|
|
105
|
+
name: string;
|
|
106
|
+
create: CustomSandboxSpec["create"];
|
|
107
|
+
recommendedConcurrency?: number;
|
|
108
|
+
}): CustomSandboxSpec {
|
|
109
|
+
if (!def.name) throw new Error(t("define.sandboxNameRequired"));
|
|
110
|
+
if (typeof def.create !== "function") throw new Error(t("define.sandboxCreateRequired"));
|
|
111
|
+
return { backend: def.name, create: def.create, recommendedConcurrency: def.recommendedConcurrency };
|
|
112
|
+
}
|