@lythos/skill-arena 0.15.7 → 0.16.1
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 +12 -12
- package/package.json +7 -7
- package/src/arena-toml.test.ts +19 -19
- package/src/cli.test.ts +118 -0
- package/src/cli.ts +121 -98
- package/src/comparative-judge.test.ts +31 -31
- package/src/player.test.ts +11 -11
- package/src/preflight.test.ts +56 -56
- package/src/runner.test.ts +269 -10
- package/src/runner.ts +88 -35
- package/src/stats.test.ts +10 -10
package/src/runner.test.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe,
|
|
2
|
-
import { buildArenaPrompt, formatPlanOutput } from './runner'
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { buildArenaPrompt, formatPlanOutput, runArenaFromToml, type ArenaIO } from './runner'
|
|
3
3
|
import { buildExecutionPlan, parseArenaToml } from './arena-toml'
|
|
4
4
|
|
|
5
5
|
describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
6
|
-
|
|
6
|
+
it('includes CWD, Deck, and output directory', () => {
|
|
7
7
|
const prompt = buildArenaPrompt({
|
|
8
8
|
brief: 'Write a hello world function',
|
|
9
9
|
cwd: '/tmp/arena-cell',
|
|
@@ -14,7 +14,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
14
14
|
expect(prompt).toContain('Produce output to: /tmp/arena-cell/')
|
|
15
15
|
})
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
it('includes decision-log.jsonl mandatory instructions', () => {
|
|
18
18
|
const prompt = buildArenaPrompt({
|
|
19
19
|
brief: 'test',
|
|
20
20
|
cwd: '/tmp/arena-cell',
|
|
@@ -27,7 +27,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
27
27
|
expect(prompt).toContain('reason (why)')
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
it('includes robustness and tools instructions', () => {
|
|
31
31
|
const prompt = buildArenaPrompt({
|
|
32
32
|
brief: 'test',
|
|
33
33
|
cwd: '/tmp/arena-cell',
|
|
@@ -38,7 +38,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
38
38
|
expect(prompt).toContain('.claude/skills/')
|
|
39
39
|
})
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
it('task brief appears at the end', () => {
|
|
42
42
|
const prompt = buildArenaPrompt({
|
|
43
43
|
brief: 'Generate a dark-mode CSS theme',
|
|
44
44
|
cwd: '/tmp/arena-cell',
|
|
@@ -54,7 +54,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
54
54
|
expect(briefIdx).toBeGreaterThan(toolsIdx)
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
it('outputDir overrides default output path', () => {
|
|
58
58
|
const prompt = buildArenaPrompt({
|
|
59
59
|
brief: 'test',
|
|
60
60
|
cwd: '/tmp/arena-cell',
|
|
@@ -64,7 +64,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
64
64
|
expect(prompt).toContain('Produce output to: /custom/output/')
|
|
65
65
|
})
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
it('preflightReport included when provided', () => {
|
|
68
68
|
const prompt = buildArenaPrompt({
|
|
69
69
|
brief: 'test',
|
|
70
70
|
cwd: '/tmp/arena-cell',
|
|
@@ -75,7 +75,7 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
75
75
|
expect(prompt).toContain('✅ 3 skills linked, 0 missing')
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
it('no preflight section when report not provided', () => {
|
|
79
79
|
const prompt = buildArenaPrompt({
|
|
80
80
|
brief: 'test',
|
|
81
81
|
cwd: '/tmp/arena-cell',
|
|
@@ -84,8 +84,267 @@ describe('buildArenaPrompt — plan-mode (pure, no IO)', () => {
|
|
|
84
84
|
expect(prompt).not.toContain('Preflight:')
|
|
85
85
|
})
|
|
86
86
|
|
|
87
|
-
|
|
87
|
+
it('prompt is deterministic — same inputs = same output', () => {
|
|
88
88
|
const opts = { brief: 'test', cwd: '/tmp/a', deckPath: '/tmp/d.toml' }
|
|
89
89
|
expect(buildArenaPrompt(opts)).toBe(buildArenaPrompt(opts))
|
|
90
90
|
})
|
|
91
91
|
})
|
|
92
|
+
|
|
93
|
+
describe('runArenaFromToml — dry-run mode (no fs/spawn/agent calls)', () => {
|
|
94
|
+
it('returns plan without executing cells', async () => {
|
|
95
|
+
const toml = parseArenaToml(`
|
|
96
|
+
[arena]
|
|
97
|
+
task = "Write a hello world function"
|
|
98
|
+
criteria = ["completeness", "correctness"]
|
|
99
|
+
runs_per_side = 1
|
|
100
|
+
|
|
101
|
+
[[side]]
|
|
102
|
+
name = "claude"
|
|
103
|
+
player = "claude"
|
|
104
|
+
deck = "/tmp/deck.toml"
|
|
105
|
+
|
|
106
|
+
[[side]]
|
|
107
|
+
name = "kimi"
|
|
108
|
+
player = "kimi"
|
|
109
|
+
deck = "/tmp/deck.toml"
|
|
110
|
+
`)
|
|
111
|
+
|
|
112
|
+
const logs: string[] = []
|
|
113
|
+
const result = await runArenaFromToml({
|
|
114
|
+
toml,
|
|
115
|
+
taskPath: '/tmp/task.md',
|
|
116
|
+
dryRun: true,
|
|
117
|
+
log: (msg) => logs.push(msg),
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect('plan' in result).toBe(true)
|
|
121
|
+
const plan = (result as { plan: ReturnType<typeof buildExecutionPlan> }).plan
|
|
122
|
+
expect(plan.total_runs).toBe(2)
|
|
123
|
+
expect(plan.cells.length).toBe(2)
|
|
124
|
+
expect(logs.length).toBeGreaterThan(0)
|
|
125
|
+
expect(logs.some(l => l.includes('Dry-run'))).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('dry-run does not call io.mkdir or io.agentSpawn', async () => {
|
|
129
|
+
const toml = parseArenaToml(`
|
|
130
|
+
[arena]
|
|
131
|
+
task = "Write a hello world function"
|
|
132
|
+
criteria = ["completeness"]
|
|
133
|
+
runs_per_side = 1
|
|
134
|
+
|
|
135
|
+
[[side]]
|
|
136
|
+
name = "claude"
|
|
137
|
+
player = "claude"
|
|
138
|
+
deck = "/tmp/deck.toml"
|
|
139
|
+
|
|
140
|
+
[[side]]
|
|
141
|
+
name = "kimi"
|
|
142
|
+
player = "kimi"
|
|
143
|
+
deck = "/tmp/deck.toml"
|
|
144
|
+
`)
|
|
145
|
+
|
|
146
|
+
let mkdirCalled = false
|
|
147
|
+
let agentSpawnCalled = false
|
|
148
|
+
|
|
149
|
+
const mockIO: ArenaIO = {
|
|
150
|
+
mkdir: () => { mkdirCalled = true },
|
|
151
|
+
agentSpawn: async () => { agentSpawnCalled = true; return { stdout: '', stderr: '', durationMs: 0 } },
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await runArenaFromToml({
|
|
155
|
+
toml,
|
|
156
|
+
taskPath: '/tmp/task.md',
|
|
157
|
+
dryRun: true,
|
|
158
|
+
io: mockIO,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
expect(mkdirCalled).toBe(false)
|
|
162
|
+
expect(agentSpawnCalled).toBe(false)
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
describe('runArenaFromToml — single cell execution with mock agentSpawn', () => {
|
|
167
|
+
it('executes one cell and writes outputs via io', async () => {
|
|
168
|
+
const toml = parseArenaToml(`
|
|
169
|
+
[arena]
|
|
170
|
+
task = "Write a hello world function"
|
|
171
|
+
criteria = ["completeness"]
|
|
172
|
+
runs_per_side = 1
|
|
173
|
+
|
|
174
|
+
[[side]]
|
|
175
|
+
name = "claude"
|
|
176
|
+
player = "claude"
|
|
177
|
+
deck = "/tmp/deck.toml"
|
|
178
|
+
|
|
179
|
+
[[side]]
|
|
180
|
+
name = "kimi"
|
|
181
|
+
player = "kimi"
|
|
182
|
+
deck = "/tmp/deck.toml"
|
|
183
|
+
`)
|
|
184
|
+
|
|
185
|
+
const files = new Map<string, string>()
|
|
186
|
+
const dirs = new Set<string>()
|
|
187
|
+
|
|
188
|
+
const mockIO: ArenaIO = {
|
|
189
|
+
log: () => {},
|
|
190
|
+
mkdir: (path: string) => { dirs.add(path) },
|
|
191
|
+
writeFile: (path: string, data: string) => { files.set(path, data) },
|
|
192
|
+
readFile: (path: string) => {
|
|
193
|
+
if (path === '/tmp/deck.toml') return 'skills = ["test"]'
|
|
194
|
+
throw new Error(`Unexpected read: ${path}`)
|
|
195
|
+
},
|
|
196
|
+
readdir: () => ['decision-log.jsonl'],
|
|
197
|
+
cp: () => {},
|
|
198
|
+
spawn: async () => ({ exitCode: 0, stderr: '' }),
|
|
199
|
+
agentSpawn: async () => ({
|
|
200
|
+
stdout: 'I wrote hello world',
|
|
201
|
+
stderr: '',
|
|
202
|
+
durationMs: 1234,
|
|
203
|
+
}),
|
|
204
|
+
exists: (path: string) => path === '/tmp/deck.toml',
|
|
205
|
+
chdir: () => {},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const result = await runArenaFromToml({
|
|
209
|
+
toml,
|
|
210
|
+
taskPath: '/tmp/task.md',
|
|
211
|
+
outDir: '/tmp/arena-out',
|
|
212
|
+
io: mockIO,
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
expect('manifest' in result).toBe(true)
|
|
216
|
+
const r = result as { manifest: { id: string; status: string }; artifactsDir: string }
|
|
217
|
+
expect(r.manifest.status).toBe('completed')
|
|
218
|
+
expect(r.artifactsDir).toBe('/tmp/arena-out')
|
|
219
|
+
|
|
220
|
+
// arena.json written twice (initial + final)
|
|
221
|
+
expect(files.has('/tmp/arena-out/arena.json')).toBe(true)
|
|
222
|
+
// agent stdout persisted
|
|
223
|
+
expect(files.has('/tmp/arena-out/runs/claude/run-1/agent-stdout.txt')).toBe(true)
|
|
224
|
+
expect(files.get('/tmp/arena-out/runs/claude/run-1/agent-stdout.txt')).toContain('I wrote hello world')
|
|
225
|
+
// judge-verdict.json written
|
|
226
|
+
expect(files.has('/tmp/arena-out/runs/claude/run-1/judge-verdict.json')).toBe(true)
|
|
227
|
+
// report.md written
|
|
228
|
+
expect(files.has('/tmp/arena-out/report.md')).toBe(true)
|
|
229
|
+
// directories created
|
|
230
|
+
expect(dirs.has('/tmp/arena-out')).toBe(true)
|
|
231
|
+
expect(dirs.has('/tmp/arena-out/runs/claude/run-1')).toBe(true)
|
|
232
|
+
expect(dirs.has('/tmp/arena-out/work/claude')).toBe(true)
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
describe('runArenaFromToml — error recovery path', () => {
|
|
237
|
+
it('cell exception produces ERROR verdict and continues', async () => {
|
|
238
|
+
const toml = parseArenaToml(`
|
|
239
|
+
[arena]
|
|
240
|
+
task = "Write a hello world function"
|
|
241
|
+
criteria = ["completeness"]
|
|
242
|
+
runs_per_side = 1
|
|
243
|
+
|
|
244
|
+
[[side]]
|
|
245
|
+
name = "claude"
|
|
246
|
+
player = "claude"
|
|
247
|
+
deck = "/tmp/deck.toml"
|
|
248
|
+
|
|
249
|
+
[[side]]
|
|
250
|
+
name = "kimi"
|
|
251
|
+
player = "kimi"
|
|
252
|
+
deck = "/tmp/deck.toml"
|
|
253
|
+
`)
|
|
254
|
+
|
|
255
|
+
const files = new Map<string, string>()
|
|
256
|
+
|
|
257
|
+
let spawnCount = 0
|
|
258
|
+
const mockIO: ArenaIO = {
|
|
259
|
+
log: () => {},
|
|
260
|
+
mkdir: () => {},
|
|
261
|
+
writeFile: (path: string, data: string) => { files.set(path, data) },
|
|
262
|
+
readFile: (path: string) => {
|
|
263
|
+
if (path === '/tmp/deck.toml') return 'skills = ["test"]'
|
|
264
|
+
throw new Error(`Unexpected read: ${path}`)
|
|
265
|
+
},
|
|
266
|
+
readdir: () => [],
|
|
267
|
+
cp: () => {},
|
|
268
|
+
spawn: async () => ({ exitCode: 0, stderr: '' }),
|
|
269
|
+
agentSpawn: async () => {
|
|
270
|
+
spawnCount++
|
|
271
|
+
if (spawnCount === 1) throw new Error('Agent spawn failed')
|
|
272
|
+
return { stdout: 'ok', stderr: '', durationMs: 100 }
|
|
273
|
+
},
|
|
274
|
+
exists: (path: string) => path === '/tmp/deck.toml',
|
|
275
|
+
chdir: () => {},
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const result = await runArenaFromToml({
|
|
279
|
+
toml,
|
|
280
|
+
taskPath: '/tmp/task.md',
|
|
281
|
+
outDir: '/tmp/arena-out',
|
|
282
|
+
io: mockIO,
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
expect('manifest' in result).toBe(true)
|
|
286
|
+
const r = result as { manifest: { status: string }; stats: { sideName: string; errorRate: number }[] }
|
|
287
|
+
expect(r.manifest.status).toBe('completed')
|
|
288
|
+
expect(r.stats.length).toBe(2)
|
|
289
|
+
const claudeStats = r.stats.find(s => s.sideName === 'claude')
|
|
290
|
+
expect(claudeStats!.errorRate).toBe(1)
|
|
291
|
+
|
|
292
|
+
const verdictPath = '/tmp/arena-out/runs/claude/run-1/judge-verdict.json'
|
|
293
|
+
expect(files.has(verdictPath)).toBe(true)
|
|
294
|
+
const verdict = JSON.parse(files.get(verdictPath)!)
|
|
295
|
+
expect(verdict.verdict).toBe('ERROR')
|
|
296
|
+
expect(verdict.reason).toContain('Agent spawn failed')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('spawn non-zero exit logs warning but continues', async () => {
|
|
300
|
+
const toml = parseArenaToml(`
|
|
301
|
+
[arena]
|
|
302
|
+
task = "Write a hello world function"
|
|
303
|
+
criteria = ["completeness"]
|
|
304
|
+
runs_per_side = 1
|
|
305
|
+
|
|
306
|
+
[[side]]
|
|
307
|
+
name = "claude"
|
|
308
|
+
player = "claude"
|
|
309
|
+
deck = "/tmp/deck.toml"
|
|
310
|
+
|
|
311
|
+
[[side]]
|
|
312
|
+
name = "kimi"
|
|
313
|
+
player = "kimi"
|
|
314
|
+
deck = "/tmp/deck.toml"
|
|
315
|
+
`)
|
|
316
|
+
|
|
317
|
+
const logs: string[] = []
|
|
318
|
+
const files = new Map<string, string>()
|
|
319
|
+
|
|
320
|
+
const mockIO: ArenaIO = {
|
|
321
|
+
log: (msg: string) => logs.push(msg),
|
|
322
|
+
mkdir: () => {},
|
|
323
|
+
writeFile: (path: string, data: string) => { files.set(path, data) },
|
|
324
|
+
readFile: (path: string) => {
|
|
325
|
+
if (path === '/tmp/deck.toml') return 'skills = ["test"]'
|
|
326
|
+
throw new Error(`Unexpected read: ${path}`)
|
|
327
|
+
},
|
|
328
|
+
readdir: () => [],
|
|
329
|
+
cp: () => {},
|
|
330
|
+
spawn: async () => ({ exitCode: 1, stderr: 'link failed' }),
|
|
331
|
+
agentSpawn: async () => ({
|
|
332
|
+
stdout: 'done',
|
|
333
|
+
stderr: '',
|
|
334
|
+
durationMs: 100,
|
|
335
|
+
}),
|
|
336
|
+
exists: (path: string) => path === '/tmp/deck.toml',
|
|
337
|
+
chdir: () => {},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const result = await runArenaFromToml({
|
|
341
|
+
toml,
|
|
342
|
+
taskPath: '/tmp/task.md',
|
|
343
|
+
outDir: '/tmp/arena-out',
|
|
344
|
+
io: mockIO,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
expect('manifest' in result).toBe(true)
|
|
348
|
+
expect(logs.some(l => l.includes('deck link') && l.includes('exit 1'))).toBe(true)
|
|
349
|
+
})
|
|
350
|
+
})
|
package/src/runner.ts
CHANGED
|
@@ -17,6 +17,42 @@ import { aggregateAllStats } from './stats'
|
|
|
17
17
|
import type { SideStats } from './stats'
|
|
18
18
|
import { buildCopyPlan } from './preflight'
|
|
19
19
|
|
|
20
|
+
// ── ArenaIO interface (Intent/Plan/Execute fractal pattern) ───────────────
|
|
21
|
+
|
|
22
|
+
export interface ArenaIO {
|
|
23
|
+
log?: (msg: string) => void
|
|
24
|
+
mkdir?: (path: string, opts?: { recursive?: boolean }) => void
|
|
25
|
+
writeFile?: (path: string, data: string) => void
|
|
26
|
+
readFile?: (path: string) => string
|
|
27
|
+
readdir?: (path: string) => string[]
|
|
28
|
+
cp?: (src: string, dest: string, opts?: { recursive?: boolean }) => void
|
|
29
|
+
spawn?: (cmd: string[], opts: { cwd: string; env?: Record<string, string> }) => Promise<{ exitCode: number | null; stderr: string }>
|
|
30
|
+
agentSpawn?: (opts: { player: string; cwd: string; brief: string; timeoutMs: number }) => Promise<{ stdout: string; stderr: string; durationMs: number }>
|
|
31
|
+
exists?: (path: string) => boolean
|
|
32
|
+
chdir?: (cwd: string) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const defaultArenaIO: ArenaIO = {
|
|
36
|
+
log: (msg: string) => { console.log(msg) },
|
|
37
|
+
mkdir: (path: string, opts?: { recursive?: boolean }) => mkdirSync(path, opts),
|
|
38
|
+
writeFile: (path: string, data: string) => writeFileSync(path, data, 'utf-8'),
|
|
39
|
+
readFile: (path: string) => readFileSync(path, 'utf-8'),
|
|
40
|
+
readdir: (path: string) => readdirSync(path),
|
|
41
|
+
cp: (src: string, dest: string, opts?: { recursive?: boolean }) => cpSync(src, dest, opts),
|
|
42
|
+
spawn: async (cmd: string[], opts: { cwd: string; env?: Record<string, string> }) => {
|
|
43
|
+
const proc = Bun.spawn(cmd, { cwd: opts.cwd, env: opts.env })
|
|
44
|
+
await proc.exited
|
|
45
|
+
const stderr = await new Response(proc.stderr).text()
|
|
46
|
+
return { exitCode: proc.exitCode, stderr }
|
|
47
|
+
},
|
|
48
|
+
agentSpawn: async (opts: { player: string; cwd: string; brief: string; timeoutMs: number }) => {
|
|
49
|
+
const agent = useAgent(opts.player)
|
|
50
|
+
return agent.spawn({ cwd: opts.cwd, brief: opts.brief, timeoutMs: opts.timeoutMs })
|
|
51
|
+
},
|
|
52
|
+
exists: (path: string) => existsSync(path),
|
|
53
|
+
chdir: (cwd: string) => { process.chdir(cwd) },
|
|
54
|
+
}
|
|
55
|
+
|
|
20
56
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
21
57
|
|
|
22
58
|
function stamp(): string {
|
|
@@ -33,18 +69,18 @@ export interface ArenaResult {
|
|
|
33
69
|
|
|
34
70
|
// ── Task + judge text resolution (no parsing — natural language) ──────────
|
|
35
71
|
|
|
36
|
-
function resolveTaskText(toml: ArenaToml, configDir?: string): string {
|
|
72
|
+
function resolveTaskText(toml: ArenaToml, io: Required<Pick<ArenaIO, 'exists' | 'readFile'>>, configDir?: string): string {
|
|
37
73
|
const p = toml.arena.task
|
|
38
74
|
const candidate = configDir ? resolve(configDir, p) : resolve(p)
|
|
39
|
-
if (
|
|
75
|
+
if (io.exists(candidate)) return io.readFile(candidate)
|
|
40
76
|
return p
|
|
41
77
|
}
|
|
42
78
|
|
|
43
|
-
function resolveJudgeText(toml: ArenaToml, configDir?: string): string | null {
|
|
79
|
+
function resolveJudgeText(toml: ArenaToml, io: Required<Pick<ArenaIO, 'exists' | 'readFile'>>, configDir?: string): string | null {
|
|
44
80
|
if (toml.arena.judge) {
|
|
45
81
|
const p = toml.arena.judge
|
|
46
82
|
const candidate = configDir ? resolve(configDir, p) : resolve(p)
|
|
47
|
-
if (
|
|
83
|
+
if (io.exists(candidate)) return io.readFile(candidate)
|
|
48
84
|
return p
|
|
49
85
|
}
|
|
50
86
|
if (toml.arena.criteria && toml.arena.criteria.length > 0) {
|
|
@@ -116,8 +152,14 @@ export async function runArenaFromToml(opts: {
|
|
|
116
152
|
dryRun?: boolean
|
|
117
153
|
log?: (msg: string) => void
|
|
118
154
|
configDir?: string
|
|
155
|
+
io?: ArenaIO
|
|
119
156
|
}): Promise<ArenaResult | { plan: ReturnType<typeof buildExecutionPlan> }> {
|
|
120
157
|
const { toml, taskPath, outDir, dryRun, log, configDir } = opts
|
|
158
|
+
const io: ArenaIO = {
|
|
159
|
+
...defaultArenaIO,
|
|
160
|
+
...opts.io,
|
|
161
|
+
log: log ?? opts.io?.log ?? defaultArenaIO.log,
|
|
162
|
+
}
|
|
121
163
|
|
|
122
164
|
const resolvePath = (p: string) => {
|
|
123
165
|
if (p.startsWith('/')) return p
|
|
@@ -125,7 +167,19 @@ export async function runArenaFromToml(opts: {
|
|
|
125
167
|
return resolve(p)
|
|
126
168
|
}
|
|
127
169
|
|
|
128
|
-
const
|
|
170
|
+
const ioWithDefaults = {
|
|
171
|
+
exists: io.exists!,
|
|
172
|
+
readFile: io.readFile!,
|
|
173
|
+
writeFile: io.writeFile!,
|
|
174
|
+
mkdir: io.mkdir!,
|
|
175
|
+
readdir: io.readdir!,
|
|
176
|
+
cp: io.cp!,
|
|
177
|
+
spawn: io.spawn!,
|
|
178
|
+
agentSpawn: io.agentSpawn!,
|
|
179
|
+
chdir: io.chdir!,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const taskText = resolveTaskText(toml, ioWithDefaults, configDir)
|
|
129
183
|
const resolvedToml: ArenaToml = {
|
|
130
184
|
...toml,
|
|
131
185
|
side: toml.side.map(s => ({ ...s, deck: resolvePath(s.deck) })),
|
|
@@ -134,7 +188,7 @@ export async function runArenaFromToml(opts: {
|
|
|
134
188
|
const plan = buildExecutionPlan(resolvedToml)
|
|
135
189
|
|
|
136
190
|
if (dryRun) {
|
|
137
|
-
for (const line of formatPlanOutput(plan)) log?.(line)
|
|
191
|
+
for (const line of formatPlanOutput(plan)) io.log?.(line)
|
|
138
192
|
return { plan }
|
|
139
193
|
}
|
|
140
194
|
|
|
@@ -158,29 +212,29 @@ export async function runArenaFromToml(opts: {
|
|
|
158
212
|
status: 'running',
|
|
159
213
|
})
|
|
160
214
|
|
|
161
|
-
|
|
162
|
-
|
|
215
|
+
ioWithDefaults.mkdir(artifactsDir, { recursive: true })
|
|
216
|
+
ioWithDefaults.writeFile(join(artifactsDir, 'arena.json'), JSON.stringify(manifest, null, 2) + '\n')
|
|
163
217
|
|
|
164
|
-
const judgeText = resolveJudgeText(resolvedToml, configDir)
|
|
218
|
+
const judgeText = resolveJudgeText(resolvedToml, ioWithDefaults, configDir)
|
|
165
219
|
const judgeInput: JudgeInput | undefined = judgeText
|
|
166
220
|
? { criteria: judgeText, task_context: taskText.slice(0, 500) }
|
|
167
221
|
: undefined
|
|
168
222
|
|
|
169
|
-
// ── Per-cell: agent.spawn
|
|
223
|
+
// ── Per-cell: agent.spawn via IO injection ─────────────────────────────
|
|
170
224
|
const verdictsBySide = new Map<string, JudgeVerdict[]>()
|
|
171
225
|
|
|
172
226
|
for (const cell of plan.cells) {
|
|
173
227
|
const cellDir = join(artifactsDir, 'runs', cell.side, `run-${cell.run}`)
|
|
174
|
-
|
|
228
|
+
ioWithDefaults.mkdir(cellDir, { recursive: true })
|
|
175
229
|
|
|
176
230
|
const workDir = join(artifactsDir, 'work', cell.side)
|
|
177
|
-
|
|
231
|
+
ioWithDefaults.mkdir(workDir, { recursive: true })
|
|
178
232
|
const originalCwd = process.cwd()
|
|
179
233
|
|
|
180
234
|
try {
|
|
181
235
|
// Setup: deck + AGENTS.md + link
|
|
182
|
-
|
|
183
|
-
|
|
236
|
+
ioWithDefaults.writeFile(join(workDir, 'skill-deck.toml'), ioWithDefaults.readFile(cell.deck))
|
|
237
|
+
ioWithDefaults.writeFile(join(workDir, 'AGENTS.md'), [
|
|
184
238
|
'# Arena Test Environment',
|
|
185
239
|
`**Side**: ${cell.side}`, `**Player**: ${cell.player}`, `**Run**: ${cell.run}`,
|
|
186
240
|
'## How This Works',
|
|
@@ -188,55 +242,54 @@ export async function runArenaFromToml(opts: {
|
|
|
188
242
|
'- Complete the task using available skills. Output to this directory.',
|
|
189
243
|
'- MANDATORY: write decision-log.jsonl (see prompt for schema).',
|
|
190
244
|
].join('\n'))
|
|
191
|
-
const linkProc =
|
|
245
|
+
const linkProc = await ioWithDefaults.spawn(
|
|
192
246
|
['bunx', '@lythos/skill-deck', 'link'],
|
|
193
247
|
{ cwd: workDir, env: { ...process.env, HOME: process.env.HOME! } },
|
|
194
248
|
)
|
|
195
|
-
|
|
196
|
-
log?.(`[arena] deck link for ${cell.side}: exit ${linkProc.exitCode}`)
|
|
249
|
+
io.log?.(`[arena] deck link for ${cell.side}: exit ${linkProc.exitCode}`)
|
|
197
250
|
|
|
198
|
-
|
|
251
|
+
ioWithDefaults.chdir(workDir)
|
|
199
252
|
|
|
200
|
-
//
|
|
201
|
-
const agent = useAgent(resolvePlayer(cell.player))
|
|
253
|
+
// Agent spawn via IO injection
|
|
202
254
|
const fullPrompt = buildArenaPrompt({
|
|
203
255
|
brief: taskText,
|
|
204
256
|
cwd: workDir,
|
|
205
257
|
deckPath: cell.deck,
|
|
206
258
|
outputDir: workDir,
|
|
207
259
|
})
|
|
208
|
-
const agentResult = await
|
|
260
|
+
const agentResult = await ioWithDefaults.agentSpawn({
|
|
261
|
+
player: resolvePlayer(cell.player),
|
|
209
262
|
cwd: workDir,
|
|
210
263
|
brief: fullPrompt,
|
|
211
264
|
timeoutMs: 300000,
|
|
212
265
|
})
|
|
213
266
|
|
|
214
|
-
|
|
267
|
+
ioWithDefaults.chdir(originalCwd)
|
|
215
268
|
|
|
216
269
|
// Persist agent output
|
|
217
270
|
const sanitizer = createSanitizer({ projectRoot: process.cwd(), homeDir: homedir(), workDir })
|
|
218
|
-
|
|
219
|
-
if (agentResult.stderr)
|
|
271
|
+
ioWithDefaults.writeFile(join(cellDir, 'agent-stdout.txt'), sanitizer.sanitize(agentResult.stdout))
|
|
272
|
+
if (agentResult.stderr) ioWithDefaults.writeFile(join(cellDir, 'agent-stderr.txt'), sanitizer.sanitize(agentResult.stderr))
|
|
220
273
|
|
|
221
274
|
// Copy artifacts
|
|
222
275
|
const skipSet = new Set(['.claude', 'skill-deck.toml', 'skill-deck.lock', 'AGENTS.md'])
|
|
223
276
|
try {
|
|
224
|
-
const entries =
|
|
277
|
+
const entries = ioWithDefaults.readdir(workDir)
|
|
225
278
|
const copyPlan = buildCopyPlan(workDir, cellDir, entries, skipSet)
|
|
226
279
|
for (const { src, dest, name } of copyPlan) {
|
|
227
|
-
try {
|
|
228
|
-
log?.(`⚠️ Failed to copy agent output: ${name} — ${e instanceof Error ? e.message : e}`)
|
|
280
|
+
try { ioWithDefaults.cp(src, dest, { recursive: true }) } catch (e) {
|
|
281
|
+
io.log?.(`⚠️ Failed to copy agent output: ${name} — ${e instanceof Error ? e.message : e}`)
|
|
229
282
|
}
|
|
230
283
|
}
|
|
231
284
|
} catch (e) {
|
|
232
|
-
log?.(`⚠️ Failed to read agent workdir for copy: ${e instanceof Error ? e.message : e}`)
|
|
285
|
+
io.log?.(`⚠️ Failed to read agent workdir for copy: ${e instanceof Error ? e.message : e}`)
|
|
233
286
|
}
|
|
234
287
|
|
|
235
288
|
// Evidence
|
|
236
289
|
const checkpoints = readCheckpoints(workDir)
|
|
237
290
|
let artifactFiles: string[] = []
|
|
238
291
|
try {
|
|
239
|
-
for (const e of
|
|
292
|
+
for (const e of ioWithDefaults.readdir(workDir)) {
|
|
240
293
|
if (!e.startsWith('.') && !skipSet.has(e) && e !== 'agent-stdout.txt' && e !== 'agent-stderr.txt' && e !== 'judge-verdict.json' && e !== '_checkpoints') {
|
|
241
294
|
artifactFiles.push(e)
|
|
242
295
|
}
|
|
@@ -259,7 +312,7 @@ export async function runArenaFromToml(opts: {
|
|
|
259
312
|
v = { verdict: 'ERROR' as const, reason: 'No judge criteria provided', criteria: [] }
|
|
260
313
|
}
|
|
261
314
|
|
|
262
|
-
|
|
315
|
+
ioWithDefaults.writeFile(join(cellDir, 'judge-verdict.json'), JSON.stringify({
|
|
263
316
|
...v,
|
|
264
317
|
agent_stdout: agentResult.stdout.slice(0, 5000),
|
|
265
318
|
agent_stderr: agentResult.stderr.slice(0, 1000),
|
|
@@ -274,7 +327,7 @@ export async function runArenaFromToml(opts: {
|
|
|
274
327
|
reason: `Runner exception: ${e instanceof Error ? e.message : String(e)}`,
|
|
275
328
|
criteria: [],
|
|
276
329
|
}
|
|
277
|
-
|
|
330
|
+
ioWithDefaults.writeFile(join(cellDir, 'judge-verdict.json'), JSON.stringify(errVerdict, null, 2) + '\n')
|
|
278
331
|
if (!verdictsBySide.has(cell.side)) verdictsBySide.set(cell.side, [])
|
|
279
332
|
verdictsBySide.get(cell.side)!.push(errVerdict)
|
|
280
333
|
}
|
|
@@ -288,10 +341,10 @@ export async function runArenaFromToml(opts: {
|
|
|
288
341
|
}
|
|
289
342
|
const judge = useAgent(resolved[0]?.platform ?? 'claude')
|
|
290
343
|
const report = await runComparativeJudge({ manifest, verdicts: flatVerdicts, judge, workdir: artifactsDir })
|
|
291
|
-
writeReport(artifactsDir, manifest, report, stats)
|
|
344
|
+
writeReport(artifactsDir, manifest, report, stats, ioWithDefaults)
|
|
292
345
|
|
|
293
346
|
const finalManifest = ArenaManifest.parse({ ...manifest, status: 'completed' })
|
|
294
|
-
|
|
347
|
+
ioWithDefaults.writeFile(join(artifactsDir, 'arena.json'), JSON.stringify(finalManifest, null, 2) + '\n')
|
|
295
348
|
|
|
296
349
|
return { manifest: finalManifest, report, stats, artifactsDir }
|
|
297
350
|
}
|
|
@@ -319,7 +372,7 @@ export async function runArena(opts: {
|
|
|
319
372
|
|
|
320
373
|
// ── Report ────────────────────────────────────────────────────────────────
|
|
321
374
|
|
|
322
|
-
function writeReport(dir: string, manifest: ArenaManifestType, report: any, stats: SideStats[]): void {
|
|
375
|
+
function writeReport(dir: string, manifest: ArenaManifestType, report: any, stats: SideStats[], io: Required<Pick<ArenaIO, 'writeFile'>>): void {
|
|
323
376
|
const lines: string[] = [
|
|
324
377
|
`# Arena Report: ${manifest.id}`, '',
|
|
325
378
|
`**Task**: ${manifest.task}`,
|
|
@@ -331,7 +384,7 @@ function writeReport(dir: string, manifest: ArenaManifestType, report: any, stat
|
|
|
331
384
|
'## Key Findings', '', ...(report.key_findings ?? []).map((f: string) => `- ${f}`), '',
|
|
332
385
|
'## Recommendations', '', ...(report.recommendations ?? []).map((r: any) => `- **${r.audience}**: ${r.recommendation}`),
|
|
333
386
|
]
|
|
334
|
-
|
|
387
|
+
io.writeFile(join(dir, 'report.md'), lines.join('\n') + '\n')
|
|
335
388
|
}
|
|
336
389
|
|
|
337
390
|
function renderStatsTable(stats: SideStats[]): string {
|
package/src/stats.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
2
|
import { aggregateSideStats, aggregateAllStats } from './stats'
|
|
3
3
|
import type { JudgeVerdict } from '@lythos/test-utils/schema'
|
|
4
4
|
|
|
@@ -14,7 +14,7 @@ function makeVerdict(overrides?: Partial<JudgeVerdict>): JudgeVerdict {
|
|
|
14
14
|
// ── aggregateSideStats ─────────────────────────────────────────────────────
|
|
15
15
|
|
|
16
16
|
describe('aggregateSideStats', () => {
|
|
17
|
-
|
|
17
|
+
it('single run: passRate=1, no variance', () => {
|
|
18
18
|
const stats = aggregateSideStats('test', [makeVerdict()])
|
|
19
19
|
expect(stats.sideName).toBe('test')
|
|
20
20
|
expect(stats.runs).toBe(1)
|
|
@@ -23,7 +23,7 @@ describe('aggregateSideStats', () => {
|
|
|
23
23
|
expect(stats.errorRate).toBe(0)
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
it('3 runs: 2 PASS, 1 FAIL', () => {
|
|
27
27
|
const verdicts = [
|
|
28
28
|
makeVerdict(),
|
|
29
29
|
makeVerdict(),
|
|
@@ -34,7 +34,7 @@ describe('aggregateSideStats', () => {
|
|
|
34
34
|
expect(stats.failRate).toBeCloseTo(1 / 3)
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
it('confidence: mean across runs', () => {
|
|
38
38
|
const verdicts = [
|
|
39
39
|
makeVerdict({ confidence: 90 }),
|
|
40
40
|
makeVerdict({ confidence: 80 }),
|
|
@@ -45,13 +45,13 @@ describe('aggregateSideStats', () => {
|
|
|
45
45
|
expect(stats.confidenceVariance).toBeCloseTo(100) // (100+0+100)/2 = 100
|
|
46
46
|
})
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
it('confidence: null when no verdict has it', () => {
|
|
49
49
|
const stats = aggregateSideStats('test', [makeVerdict(), makeVerdict()])
|
|
50
50
|
expect(stats.meanConfidence).toBeNull()
|
|
51
51
|
expect(stats.confidenceVariance).toBeNull()
|
|
52
52
|
})
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
it('per-criterion pass rate', () => {
|
|
55
55
|
const verdicts = [
|
|
56
56
|
makeVerdict({ criteria: [{ name: 'accuracy', passed: true }] }),
|
|
57
57
|
makeVerdict({ criteria: [{ name: 'accuracy', passed: false }] }),
|
|
@@ -63,7 +63,7 @@ describe('aggregateSideStats', () => {
|
|
|
63
63
|
expect(stats.criteria[0].mean).toBeCloseTo(2 / 3)
|
|
64
64
|
})
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
it('per-criterion scores: mean and variance', () => {
|
|
67
67
|
const verdicts = [
|
|
68
68
|
makeVerdict({ scores: { coverage: 5, relevance: 4 } }),
|
|
69
69
|
makeVerdict({ scores: { coverage: 3, relevance: 4 } }),
|
|
@@ -75,14 +75,14 @@ describe('aggregateSideStats', () => {
|
|
|
75
75
|
expect(stats.scoreByCriterion.relevance.variance).toBe(0) // all 4s
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
it('zero runs: all zeros', () => {
|
|
79
79
|
const stats = aggregateSideStats('empty', [])
|
|
80
80
|
expect(stats.runs).toBe(0)
|
|
81
81
|
expect(stats.passRate).toBe(0)
|
|
82
82
|
expect(stats.meanConfidence).toBeNull()
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
it('handles ERROR verdicts correctly', () => {
|
|
86
86
|
const verdicts = [
|
|
87
87
|
makeVerdict(),
|
|
88
88
|
makeVerdict({ verdict: 'ERROR', reason: 'parse failed' }),
|
|
@@ -96,7 +96,7 @@ describe('aggregateSideStats', () => {
|
|
|
96
96
|
// ── aggregateAllStats ──────────────────────────────────────────────────────
|
|
97
97
|
|
|
98
98
|
describe('aggregateAllStats', () => {
|
|
99
|
-
|
|
99
|
+
it('aggregates multiple sides', () => {
|
|
100
100
|
const map = new Map<string, JudgeVerdict[]>()
|
|
101
101
|
map.set('side-a', [makeVerdict(), makeVerdict()])
|
|
102
102
|
map.set('side-b', [makeVerdict({ verdict: 'FAIL', reason: 'nope' })])
|