@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/README.md
CHANGED
|
@@ -16,20 +16,20 @@
|
|
|
16
16
|
```bash
|
|
17
17
|
bun add -d @lythos/skill-arena
|
|
18
18
|
# or use directly
|
|
19
|
-
bunx @lythos/skill-arena@0.
|
|
19
|
+
bunx @lythos/skill-arena@0.16.1 <command>
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
## Quick Start
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
25
|
# single — test one deck (most common)
|
|
26
|
-
bunx @lythos/skill-arena@0.
|
|
26
|
+
bunx @lythos/skill-arena@0.16.1 single \
|
|
27
27
|
--deck ./examples/decks/scout.toml \
|
|
28
28
|
--brief "Generate auth flow diagram" \
|
|
29
29
|
--out ./output
|
|
30
30
|
|
|
31
31
|
# single with explicit player
|
|
32
|
-
bunx @lythos/skill-arena@0.
|
|
32
|
+
bunx @lythos/skill-arena@0.16.1 single \
|
|
33
33
|
--deck ./examples/decks/scout.toml \
|
|
34
34
|
--brief "Generate auth flow diagram" \
|
|
35
35
|
--player kimi \
|
|
@@ -37,10 +37,10 @@ bunx @lythos/skill-arena@0.15.7 single \
|
|
|
37
37
|
|
|
38
38
|
# cross-deck vs — compare two decks (agent-orchestrated)
|
|
39
39
|
# Create arena.toml declaring sides with different decks, then:
|
|
40
|
-
bunx @lythos/skill-arena@0.
|
|
40
|
+
bunx @lythos/skill-arena@0.16.1 vs --config ./arena.toml
|
|
41
41
|
|
|
42
42
|
# cross-player vs — compare kimi vs codex (CLI only)
|
|
43
|
-
bunx @lythos/skill-arena@0.
|
|
43
|
+
bunx @lythos/skill-arena@0.16.1 vs --config ./arena.toml --player kimi
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
**What happens**: Agent creates isolated `/tmp` workdir per side, `deck link` skills, spawns parallel subagents, collects artifacts, judge scores outputs. Parent deck restored after.
|
|
@@ -50,14 +50,14 @@ bunx @lythos/skill-arena@0.15.7 vs --config ./arena.toml --player kimi
|
|
|
50
50
|
### `single` — one deck, one task
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
bunx @lythos/skill-arena@0.
|
|
53
|
+
bunx @lythos/skill-arena@0.16.1 single \
|
|
54
54
|
--deck ./deck.toml \
|
|
55
55
|
--brief "Produce a .docx report with radar chart" \
|
|
56
56
|
--timeout 600000 \
|
|
57
57
|
--out ./output
|
|
58
58
|
|
|
59
59
|
# with explicit player
|
|
60
|
-
bunx @lythos/skill-arena@0.
|
|
60
|
+
bunx @lythos/skill-arena@0.16.1 single \
|
|
61
61
|
--deck ./deck.toml \
|
|
62
62
|
--brief "Produce a .docx report with radar chart" \
|
|
63
63
|
--player kimi \
|
|
@@ -67,14 +67,14 @@ bunx @lythos/skill-arena@0.15.7 single \
|
|
|
67
67
|
### `vs` — multi-deck comparison
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
|
-
bunx @lythos/skill-arena@0.
|
|
71
|
-
bunx @lythos/skill-arena@0.
|
|
70
|
+
bunx @lythos/skill-arena@0.16.1 vs --config ./arena.toml
|
|
71
|
+
bunx @lythos/skill-arena@0.16.1 vs --config ./arena.toml --dry-run
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
### `prepare-workdir` — isolate + link skills (agent-orchestrated)
|
|
75
75
|
|
|
76
76
|
```bash
|
|
77
|
-
bunx @lythos/skill-arena@0.
|
|
77
|
+
bunx @lythos/skill-arena@0.16.1 prepare-workdir \
|
|
78
78
|
--deck ./skill-deck.toml \
|
|
79
79
|
--out /tmp/arena-side-a \
|
|
80
80
|
--brief "task description"
|
|
@@ -85,7 +85,7 @@ Creates `/tmp`-isolated workdir with deck copied, AGENTS.md written, and `deck l
|
|
|
85
85
|
### `archive` — collect agent outputs (agent-orchestrated)
|
|
86
86
|
|
|
87
87
|
```bash
|
|
88
|
-
bunx @lythos/skill-arena@0.
|
|
88
|
+
bunx @lythos/skill-arena@0.16.1 archive \
|
|
89
89
|
--from /tmp/arena-side-a \
|
|
90
90
|
--to ./playground/output \
|
|
91
91
|
--sides side-a
|
|
@@ -96,7 +96,7 @@ Copies agent artifacts from workdir(s) to output, skipping internal files (`.cla
|
|
|
96
96
|
### `viz` — render results (WIP — HTML report generation pending)
|
|
97
97
|
|
|
98
98
|
```bash
|
|
99
|
-
bunx @lythos/skill-arena@0.
|
|
99
|
+
bunx @lythos/skill-arena@0.16.1 viz runs/arena-<id>/
|
|
100
100
|
```
|
|
101
101
|
|
|
102
102
|
## Parameters
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lythos/skill-arena",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Skill Arena — benchmark skill effectiveness with controlled-variable comparison",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agent",
|
|
@@ -42,15 +42,15 @@
|
|
|
42
42
|
"bun": ">=1.0.0"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@lythos/cold-pool": "^0.
|
|
46
|
-
"@lythos/infra": "^0.
|
|
47
|
-
"@lythos/test-utils": "^0.
|
|
45
|
+
"@lythos/cold-pool": "^0.16.1",
|
|
46
|
+
"@lythos/infra": "^0.16.1",
|
|
47
|
+
"@lythos/test-utils": "^0.16.1",
|
|
48
48
|
"zod": "^3.24.0",
|
|
49
49
|
"zod-to-json-schema": "^3.25.2"
|
|
50
50
|
},
|
|
51
51
|
"optionalDependencies": {
|
|
52
|
-
"@lythos/agent-adapter-claude-sdk": "^0.
|
|
53
|
-
"@lythos/agent-adapter-deepseek-serve": "^0.
|
|
54
|
-
"@lythos/agent-adapter-codex": "^0.
|
|
52
|
+
"@lythos/agent-adapter-claude-sdk": "^0.16.1",
|
|
53
|
+
"@lythos/agent-adapter-deepseek-serve": "^0.16.1",
|
|
54
|
+
"@lythos/agent-adapter-codex": "^0.16.1"
|
|
55
55
|
}
|
|
56
56
|
}
|
package/src/arena-toml.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe,
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
2
|
import { parseArenaToml, buildExecutionPlan, ArenaToml } from './arena-toml'
|
|
3
3
|
import { formatPlanOutput } from './runner'
|
|
4
4
|
|
|
@@ -63,7 +63,7 @@ working_dir = "/workspace"
|
|
|
63
63
|
`
|
|
64
64
|
|
|
65
65
|
describe('parseArenaToml', () => {
|
|
66
|
-
|
|
66
|
+
it('parses minimal two-side arena with criteria', () => {
|
|
67
67
|
const result = parseArenaToml(minimalToml)
|
|
68
68
|
expect(result.arena.task).toBe('Test task')
|
|
69
69
|
expect(result.arena.criteria).toEqual(['a', 'b'])
|
|
@@ -75,14 +75,14 @@ describe('parseArenaToml', () => {
|
|
|
75
75
|
expect(result.side[0].control).toBe(false)
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
it('parses arena with judge field (preferred over criteria)', () => {
|
|
79
79
|
const result = parseArenaToml(judgeToml)
|
|
80
80
|
expect(result.arena.judge).toContain('Evaluate completeness')
|
|
81
81
|
expect(result.arena.criteria).toBeUndefined()
|
|
82
82
|
expect(result.side).toHaveLength(2)
|
|
83
83
|
})
|
|
84
84
|
|
|
85
|
-
|
|
85
|
+
it('parses full arena with runs_per_side and control', () => {
|
|
86
86
|
const result = parseArenaToml(fullToml)
|
|
87
87
|
expect(result.arena.runs_per_side).toBe(3)
|
|
88
88
|
expect(result.side).toHaveLength(3)
|
|
@@ -90,7 +90,7 @@ describe('parseArenaToml', () => {
|
|
|
90
90
|
expect(result.side[2].control).toBe(true)
|
|
91
91
|
})
|
|
92
92
|
|
|
93
|
-
|
|
93
|
+
it('parses side env block', () => {
|
|
94
94
|
const result = parseArenaToml(fullToml)
|
|
95
95
|
const env = result.side[2].env
|
|
96
96
|
expect(env.container).toBe('node:20-alpine')
|
|
@@ -99,47 +99,47 @@ describe('parseArenaToml', () => {
|
|
|
99
99
|
expect(env.env_vars).toEqual({})
|
|
100
100
|
})
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
it('rejects fewer than 2 sides', () => {
|
|
103
103
|
const bad = `[arena]\ntask = "x"\ncriteria = ["a"]\n\n[[side]]\nname = "only"\nplayer = "c"\ndeck = "x.toml"`
|
|
104
104
|
expect(() => parseArenaToml(bad)).toThrow()
|
|
105
105
|
})
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
it('rejects neither judge nor criteria provided', () => {
|
|
108
108
|
const bad = `[arena]\ntask = "x"\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
109
109
|
expect(() => parseArenaToml(bad)).toThrow()
|
|
110
110
|
})
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
it('accepts judge without criteria (either is sufficient)', () => {
|
|
113
113
|
const toml = `[arena]\ntask = "x"\njudge = "Evaluate this."\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
114
114
|
expect(() => parseArenaToml(toml)).not.toThrow()
|
|
115
115
|
})
|
|
116
116
|
|
|
117
|
-
|
|
117
|
+
it('rejects empty criteria and no judge', () => {
|
|
118
118
|
const bad = `[arena]\ntask = "x"\ncriteria = []\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
119
119
|
expect(() => parseArenaToml(bad)).toThrow()
|
|
120
120
|
})
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
it('rejects non-object input', () => {
|
|
123
123
|
expect(() => ArenaToml.parse('not valid')).toThrow()
|
|
124
124
|
})
|
|
125
125
|
|
|
126
|
-
|
|
126
|
+
it('rejects missing arena section', () => {
|
|
127
127
|
expect(() => parseArenaToml('[[side]]\nname = "a"')).toThrow()
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
it('rejects runs_per_side = 0', () => {
|
|
131
131
|
const bad = `[arena]\ntask = "x"\ncriteria = ["a"]\nruns_per_side = 0\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
132
132
|
expect(() => parseArenaToml(bad)).toThrow()
|
|
133
133
|
})
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
it('parses integer and boolean values correctly', () => {
|
|
136
136
|
const toml = `[arena]\ntask = "x"\ncriteria = ["a"]\nruns_per_side = 2\nmax_participants = 5\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
137
137
|
const result = parseArenaToml(toml)
|
|
138
138
|
expect(result.arena.runs_per_side).toBe(2)
|
|
139
139
|
expect(result.arena.max_participants).toBe(5)
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
it('comments are stripped', () => {
|
|
143
143
|
const toml = `[arena]\n# this is a comment\ntask = "x"\ncriteria = ["a"]\n\n[[side]]\nname = "a"\nplayer = "c"\ndeck = "a.toml"\n\n[[side]]\nname = "b"\nplayer = "c"\ndeck = "b.toml"`
|
|
144
144
|
const result = parseArenaToml(toml)
|
|
145
145
|
expect(result.arena.task).toBe('x')
|
|
@@ -147,7 +147,7 @@ describe('parseArenaToml', () => {
|
|
|
147
147
|
})
|
|
148
148
|
|
|
149
149
|
describe('buildExecutionPlan', () => {
|
|
150
|
-
|
|
150
|
+
it('generates plan: 2 sides × 1 run = 2 cells', () => {
|
|
151
151
|
const toml = parseArenaToml(minimalToml)
|
|
152
152
|
const plan = buildExecutionPlan(toml)
|
|
153
153
|
expect(plan.task).toBe('Test task')
|
|
@@ -156,20 +156,20 @@ describe('buildExecutionPlan', () => {
|
|
|
156
156
|
expect(plan.total_runs).toBe(2)
|
|
157
157
|
})
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
it('generates plan with judge field populated', () => {
|
|
160
160
|
const toml = parseArenaToml(judgeToml)
|
|
161
161
|
const plan = buildExecutionPlan(toml)
|
|
162
162
|
expect(plan.judge).toContain('Evaluate completeness')
|
|
163
163
|
})
|
|
164
164
|
|
|
165
|
-
|
|
165
|
+
it('generates plan: 3 sides × 3 runs = 9 cells', () => {
|
|
166
166
|
const toml = parseArenaToml(fullToml)
|
|
167
167
|
const plan = buildExecutionPlan(toml)
|
|
168
168
|
expect(plan.cells).toHaveLength(9)
|
|
169
169
|
expect(plan.total_runs).toBe(9)
|
|
170
170
|
})
|
|
171
171
|
|
|
172
|
-
|
|
172
|
+
it('control flag preserved in plan cells', () => {
|
|
173
173
|
const toml = parseArenaToml(fullToml)
|
|
174
174
|
const plan = buildExecutionPlan(toml)
|
|
175
175
|
const baselineCells = plan.cells.filter(c => c.side === 'baseline')
|
|
@@ -177,7 +177,7 @@ describe('buildExecutionPlan', () => {
|
|
|
177
177
|
expect(baselineCells.every(c => c.control)).toBe(true)
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
-
|
|
180
|
+
it('dry-run: plan is pure data, no side effects', () => {
|
|
181
181
|
const toml = parseArenaToml(fullToml)
|
|
182
182
|
const plan = buildExecutionPlan(toml)
|
|
183
183
|
expect(plan.total_runs).toBeGreaterThan(0)
|
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'bun:test'
|
|
2
|
+
import { main, defaultArenaCliIO, type ArenaCliIO } from './cli'
|
|
3
|
+
|
|
4
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
async function catchExitAsync(fn: () => Promise<void>): Promise<number | undefined> {
|
|
7
|
+
let exitCode: number | undefined
|
|
8
|
+
try { await fn() } catch (e: any) {
|
|
9
|
+
if (!String(e).includes('EXIT:')) throw e
|
|
10
|
+
const m = String(e).match(/EXIT:(\d+)/)
|
|
11
|
+
if (m) exitCode = parseInt(m[1], 10)
|
|
12
|
+
}
|
|
13
|
+
return exitCode
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mockIO(): { io: Required<ArenaCliIO>; logs: string[]; errors: string[]; warns: string[] } {
|
|
17
|
+
const logs: string[] = []
|
|
18
|
+
const errors: string[] = []
|
|
19
|
+
const warns: string[] = []
|
|
20
|
+
const io: Required<ArenaCliIO> = {
|
|
21
|
+
log: (msg: string) => logs.push(msg),
|
|
22
|
+
error: (msg: string) => errors.push(msg),
|
|
23
|
+
warn: (msg: string) => warns.push(msg),
|
|
24
|
+
exit: (code: number) => { throw new Error(`EXIT:${code}`) },
|
|
25
|
+
}
|
|
26
|
+
return { io, logs, errors, warns }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── Tests ─────────────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
describe('main', () => {
|
|
32
|
+
it('shows help on --help', async () => {
|
|
33
|
+
const { io, logs, errors } = mockIO()
|
|
34
|
+
const exitCode = await catchExitAsync(async () => {
|
|
35
|
+
await main(['--help'], io)
|
|
36
|
+
})
|
|
37
|
+
expect(exitCode).toBe(0)
|
|
38
|
+
expect(logs.length).toBeGreaterThan(0)
|
|
39
|
+
expect(logs[0]).toContain('lythoskill-arena')
|
|
40
|
+
expect(errors.length).toBe(0)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('shows help on -h', async () => {
|
|
44
|
+
const { io, logs, errors } = mockIO()
|
|
45
|
+
const exitCode = await catchExitAsync(async () => {
|
|
46
|
+
await main(['-h'], io)
|
|
47
|
+
})
|
|
48
|
+
expect(exitCode).toBe(0)
|
|
49
|
+
expect(logs.length).toBeGreaterThan(0)
|
|
50
|
+
expect(logs[0]).toContain('lythoskill-arena')
|
|
51
|
+
expect(errors.length).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('shows help on no args', async () => {
|
|
55
|
+
const { io, logs, errors } = mockIO()
|
|
56
|
+
const exitCode = await catchExitAsync(async () => {
|
|
57
|
+
await main([], io)
|
|
58
|
+
})
|
|
59
|
+
expect(exitCode).toBe(0)
|
|
60
|
+
expect(logs.length).toBeGreaterThan(0)
|
|
61
|
+
expect(errors.length).toBe(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('errors on unknown command', async () => {
|
|
65
|
+
const { io, logs, errors } = mockIO()
|
|
66
|
+
const exitCode = await catchExitAsync(async () => {
|
|
67
|
+
await main(['bogus'], io)
|
|
68
|
+
})
|
|
69
|
+
expect(exitCode).toBe(1)
|
|
70
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
71
|
+
expect(errors[0]).toContain('Unknown command')
|
|
72
|
+
expect(errors[0]).toContain('bogus')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('singleRun', () => {
|
|
77
|
+
it('errors when --deck is missing', async () => {
|
|
78
|
+
const { io, logs, errors } = mockIO()
|
|
79
|
+
const exitCode = await catchExitAsync(async () => {
|
|
80
|
+
await main(['single', '--brief', 'test task'], io)
|
|
81
|
+
})
|
|
82
|
+
expect(exitCode).toBe(1)
|
|
83
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
84
|
+
expect(errors[0]).toContain('--deck')
|
|
85
|
+
expect(errors[0]).toContain('required')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('errors when both --task and --brief are missing', async () => {
|
|
89
|
+
const { io, logs, errors } = mockIO()
|
|
90
|
+
const exitCode = await catchExitAsync(async () => {
|
|
91
|
+
await main(['single', '--deck', './some-deck.toml'], io)
|
|
92
|
+
})
|
|
93
|
+
expect(exitCode).toBe(1)
|
|
94
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
95
|
+
expect(errors[0]).toContain('--task')
|
|
96
|
+
expect(errors[0]).toContain('--brief')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('errors when --brief is empty string', async () => {
|
|
100
|
+
const { io, logs, errors } = mockIO()
|
|
101
|
+
const exitCode = await catchExitAsync(async () => {
|
|
102
|
+
await main(['single', '--deck', './some-deck.toml', '--brief', ''], io)
|
|
103
|
+
})
|
|
104
|
+
expect(exitCode).toBe(1)
|
|
105
|
+
expect(errors.length).toBeGreaterThan(0)
|
|
106
|
+
expect(errors[0]).toContain('--task')
|
|
107
|
+
expect(errors[0]).toContain('--brief')
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('defaultArenaCliIO', () => {
|
|
112
|
+
it('has all required functions', () => {
|
|
113
|
+
expect(typeof defaultArenaCliIO.log).toBe('function')
|
|
114
|
+
expect(typeof defaultArenaCliIO.error).toBe('function')
|
|
115
|
+
expect(typeof defaultArenaCliIO.warn).toBe('function')
|
|
116
|
+
expect(typeof defaultArenaCliIO.exit).toBe('function')
|
|
117
|
+
})
|
|
118
|
+
})
|