@xingular/harness 2.1.1 → 3.0.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/CHANGELOG.md +18 -0
- package/NOTICE.md +22 -0
- package/README.md +70 -37
- package/dist/cli.js +6572 -6700
- package/dist/terminal-pi.js +51 -38
- package/docs/terminal-tui.md +50 -12
- package/package.json +2 -2
- package/src/accounts/accounts.ts +4 -3
- package/src/capabilities/api.ts +1 -1
- package/src/cli.ts +7 -3
- package/src/core/bus.test.ts +15 -1
- package/src/core/bus.ts +39 -0
- package/src/core/caller.ts +34 -0
- package/src/core/fs.ts +17 -1
- package/src/harness.ts +55 -66
- package/src/{memory → history}/recall.test.ts +1 -1
- package/src/{memory → history}/recall.ts +1 -1
- package/src/{memory → history}/trajectory.test.ts +4 -4
- package/src/{memory → history}/trajectory.ts +1 -1
- package/src/hub/hub.test.ts +4 -5
- package/src/hub/hub.ts +2 -2
- package/src/index.ts +28 -14
- package/src/mcp/connections.ts +1 -1
- package/src/mcp/service.ts +1 -1
- package/src/pi/proxy.test.ts +68 -0
- package/src/pi/runtime.test.ts +61 -0
- package/src/pi/runtime.ts +50 -0
- package/src/plugins/api.ts +11 -9
- package/src/plugins/builtin.ts +22 -40
- package/src/plugins/catalogue.test.ts +13 -41
- package/src/plugins/catalogue.ts +3 -32
- package/src/plugins/discover.ts +2 -1
- package/src/plugins/firstParty.test.ts +1 -1
- package/src/plugins/hooks.test.ts +21 -71
- package/src/plugins/hooks.ts +6 -7
- package/src/plugins/identity.ts +16 -2
- package/src/plugins/install.test.ts +1 -1
- package/src/plugins/install.ts +2 -1
- package/src/plugins/inventory.ts +19 -18
- package/src/plugins/plugins.ts +38 -2
- package/src/prompt/compile.test.ts +82 -193
- package/src/prompt/compile.ts +98 -363
- package/src/refine/cli.ts +114 -0
- package/src/refine/demo.ts +124 -0
- package/src/refine/evidence.ts +349 -0
- package/src/refine/host.ts +102 -0
- package/src/refine/ledger.ts +104 -0
- package/src/refine/library.ts +192 -0
- package/src/refine/pi.test.ts +90 -0
- package/src/refine/pi.ts +218 -0
- package/src/refine/prompts.ts +430 -0
- package/src/refine/recursion.ts +353 -0
- package/src/refine/refine.test.ts +140 -0
- package/src/refine/runner.ts +205 -0
- package/src/refine/selection.ts +291 -0
- package/src/refine/service.ts +194 -0
- package/src/refine/trajectory.ts +71 -0
- package/src/refine/types.ts +91 -0
- package/src/server/methods.ts +137 -123
- package/src/server/server.test.ts +37 -30
- package/src/sessions/contracts.ts +7 -2
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/planMode.test.ts +8 -1
- package/src/sessions/planMode.ts +17 -9
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/questionBatches.test.ts +15 -3
- package/src/sessions/registry.ts +13 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +60 -13
- package/src/sessions/sessions.ts +43 -18
- package/src/sessions/subagentService.test.ts +22 -1
- package/src/sessions/subagentService.ts +14 -1
- package/src/sessions/subagents.ts +2 -0
- package/src/terminal/cli.ts +24 -48
- package/src/terminal/logs.test.ts +26 -0
- package/src/terminal/logs.ts +67 -0
- package/src/terminal/piExtension.lifecycle.test.ts +1 -1
- package/src/terminal/piExtension.ts +3 -3
- package/src/terminal/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +41 -39
- package/src/terminal/terminals.ts +31 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +15 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/commands.test.ts +13 -1
- package/src/terminal/tui/commands.ts +16 -0
- package/src/terminal/tui/logPanel.test.ts +61 -0
- package/src/terminal/tui/logPanel.ts +175 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/questionPicker.ts +168 -0
- package/src/terminal/tui/questions.test.ts +86 -19
- package/src/terminal/tui/questions.ts +27 -22
- package/src/terminal/tui/runtime.ts +2 -0
- package/src/tools/askUser.test.ts +23 -2
- package/src/tools/askUser.ts +15 -5
- package/src/tools/definitions.ts +49 -132
- package/src/tools/dispatch.ts +78 -0
- package/src/tools/harnessTools.ts +23 -0
- package/src/tools/spawnSubagent.ts +3 -3
- package/src/tools/types.ts +3 -1
- package/src/tree/components.ts +221 -0
- package/src/tree/materialise.ts +122 -0
- package/src/tree/seed.ts +123 -0
- package/src/tree/store.ts +193 -0
- package/src/tree/surface.ts +135 -0
- package/src/tree/tree.test.ts +126 -0
- package/src/tree/tree.ts +346 -0
- package/src/tree/trust.ts +86 -0
- package/src/web/tools.ts +27 -5
- package/src/web/web.test.ts +26 -1
- package/src/wiki/notes.ts +313 -0
- package/src/memory/api.test.ts +0 -82
- package/src/memory/api.ts +0 -303
- package/src/memory/blocks.test.ts +0 -78
- package/src/memory/blocks.ts +0 -408
- package/src/memory/caller.ts +0 -69
- package/src/memory/curation.test.ts +0 -206
- package/src/memory/curation.ts +0 -64
- package/src/memory/dispatch.ts +0 -64
- package/src/memory/facts.test.ts +0 -41
- package/src/memory/facts.ts +0 -248
- package/src/memory/graph.ts +0 -103
- package/src/memory/health.test.ts +0 -130
- package/src/memory/health.ts +0 -74
- package/src/memory/maintainer.ts +0 -272
- package/src/memory/maintenance.test.ts +0 -192
- package/src/memory/maintenance.ts +0 -679
- package/src/memory/migrate.ts +0 -79
- package/src/memory/operations.ts +0 -150
- package/src/memory/proposals.test.ts +0 -168
- package/src/memory/proposals.ts +0 -584
- package/src/memory/references.ts +0 -77
- package/src/memory/runContext.ts +0 -93
- package/src/memory/store.test.ts +0 -66
- package/src/memory/store.ts +0 -385
- package/src/memory/wiki.test.ts +0 -47
- package/src/memory/wiki.ts +0 -423
- package/src/memory/wikiIndex.test.ts +0 -88
- package/src/memory/wikiIndex.ts +0 -117
- package/src/plugins/proposal.ts +0 -121
- package/src/skills/registry.test.ts +0 -104
- package/src/skills/registry.ts +0 -413
- package/src/tools/maintenance.ts +0 -29
- package/src/tools/memoryTools.ts +0 -22
- package/src/tools/pluginPropose.ts +0 -41
- package/src/tools/registryWrite.test.ts +0 -101
- package/src/tools/registryWrite.ts +0 -182
- /package/src/{memory → core}/secrets.ts +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Task-level recursion, parallel and sequential. Each mode schedules 4k rollouts and 3k
|
|
2
|
+
// improvement stages, including the shared base executions. An invalid proposal gets one retry
|
|
3
|
+
// with the validator's message, then inherits the current harness.
|
|
4
|
+
//
|
|
5
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/recursion.py`).
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { PI_SURFACE, type Tree } from '../tree/surface'
|
|
10
|
+
import { autotrigger, diversity, isUnchanged, readTree, TreeError, validate, writeTree } from '../tree/tree'
|
|
11
|
+
import * as evidence from './evidence'
|
|
12
|
+
import { Ledger, type LedgerRecord } from './ledger'
|
|
13
|
+
import * as P from './prompts'
|
|
14
|
+
import { ImproverError, type Executor, type Improver, type RefineTask, type Rollout } from './types'
|
|
15
|
+
|
|
16
|
+
export const STAGE_ATTEMPTS = 2
|
|
17
|
+
|
|
18
|
+
export type StageDim = 'INV' | 'DOM' | 'AGG' | 'COMPOSE'
|
|
19
|
+
|
|
20
|
+
export interface Stage {
|
|
21
|
+
label: string
|
|
22
|
+
dim: StageDim
|
|
23
|
+
tree: Tree
|
|
24
|
+
keptBase: boolean
|
|
25
|
+
fellBack: boolean
|
|
26
|
+
error: string
|
|
27
|
+
usd: number
|
|
28
|
+
attempts: number
|
|
29
|
+
cached: boolean
|
|
30
|
+
notes: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StageRecord extends Omit<Stage, 'tree'> {}
|
|
34
|
+
|
|
35
|
+
export interface ModeResult {
|
|
36
|
+
mode: 'agg' | 'seq'
|
|
37
|
+
taskId: string
|
|
38
|
+
k: number
|
|
39
|
+
stages: Stage[]
|
|
40
|
+
trees: Record<string, Tree>
|
|
41
|
+
rollouts: Record<string, Rollout>
|
|
42
|
+
finals: string[]
|
|
43
|
+
ledger: Ledger
|
|
44
|
+
similarity: ReturnType<typeof diversity> | null
|
|
45
|
+
errors: string[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ModeRecord {
|
|
49
|
+
mode: 'agg' | 'seq'
|
|
50
|
+
taskId: string
|
|
51
|
+
k: number
|
|
52
|
+
finals: string[]
|
|
53
|
+
ok: boolean
|
|
54
|
+
fellBack: string[]
|
|
55
|
+
keptBase: string[]
|
|
56
|
+
stages: StageRecord[]
|
|
57
|
+
similarity: ReturnType<typeof diversity> | null
|
|
58
|
+
ledger: LedgerRecord
|
|
59
|
+
errors: string[]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function modeRecord(result: ModeResult): ModeRecord {
|
|
63
|
+
return {
|
|
64
|
+
mode: result.mode,
|
|
65
|
+
taskId: result.taskId,
|
|
66
|
+
k: result.k,
|
|
67
|
+
finals: result.finals,
|
|
68
|
+
ok: result.finals.length > 0 && result.errors.length === 0,
|
|
69
|
+
fellBack: result.stages.filter((stage) => stage.fellBack).map((stage) => stage.label),
|
|
70
|
+
keptBase: result.stages.filter((stage) => stage.keptBase).map((stage) => stage.label),
|
|
71
|
+
stages: result.stages.map(({ tree: _tree, ...rest }) => ({ ...rest, error: rest.error.slice(0, 400) })),
|
|
72
|
+
similarity: result.similarity,
|
|
73
|
+
ledger: result.ledger.toRecord(),
|
|
74
|
+
errors: result.errors
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Progress a caller may show while a mode runs. */
|
|
79
|
+
export type RecursionEvent =
|
|
80
|
+
| { type: 'stage.started'; label: string; dim: StageDim }
|
|
81
|
+
| { type: 'stage.finished'; label: string; dim: StageDim; fellBack: boolean; keptBase: boolean; error: string }
|
|
82
|
+
| { type: 'rollout.started'; label: string }
|
|
83
|
+
| { type: 'rollout.finished'; label: string; ok: boolean; delivered: boolean; error: string }
|
|
84
|
+
|
|
85
|
+
export interface RecursionContext {
|
|
86
|
+
task: RefineTask
|
|
87
|
+
executor: Executor
|
|
88
|
+
improver: Improver
|
|
89
|
+
workDir: string
|
|
90
|
+
runIdOf: (label: string) => string
|
|
91
|
+
baseTree: Tree
|
|
92
|
+
libraryDir?: string | null
|
|
93
|
+
/** How many improver sessions run at once. */
|
|
94
|
+
proposeWidth?: number
|
|
95
|
+
/** How many rollouts run at once. */
|
|
96
|
+
rolloutWidth?: number
|
|
97
|
+
onEvent?: (event: RecursionEvent) => void
|
|
98
|
+
signal?: AbortSignal
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Runs `fn` over `items` with at most `width` at a time, keeping input order in the result. */
|
|
102
|
+
export async function mapLimit<T, R>(items: readonly T[], width: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
103
|
+
const out = new Array<R>(items.length)
|
|
104
|
+
let next = 0
|
|
105
|
+
const workers = Array.from({ length: Math.max(1, Math.min(width, items.length)) }, async () => {
|
|
106
|
+
while (next < items.length) {
|
|
107
|
+
const i = next++
|
|
108
|
+
out[i] = await fn(items[i]!)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
await Promise.all(workers)
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function checkAborted(signal?: AbortSignal): void {
|
|
116
|
+
if (signal?.aborted) throw new Error('Refinement was cancelled.')
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Repairs, then validates, a proposed tree. Returns whether it equals the fallback and any error. */
|
|
120
|
+
export function acceptTree(task: RefineTask, executor: Executor, next: Tree, fallback: Tree): { keptBase: boolean; error: string } {
|
|
121
|
+
autotrigger(PI_SURFACE, next)
|
|
122
|
+
const keptBase = isUnchanged(PI_SURFACE, fallback, next)
|
|
123
|
+
try {
|
|
124
|
+
validate(PI_SURFACE, next, { allowSubagents: task.allowSubagents !== false })
|
|
125
|
+
executor.validateTree?.(task, next)
|
|
126
|
+
evidence.assertTreeHasNoGraderVerdicts(next)
|
|
127
|
+
} catch (error) {
|
|
128
|
+
return { keptBase, error: error instanceof TreeError ? error.problems.join(' | ') : error instanceof Error ? error.message : String(error) }
|
|
129
|
+
}
|
|
130
|
+
return { keptBase, error: '' }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface StageInput {
|
|
134
|
+
label: string
|
|
135
|
+
dim: StageDim
|
|
136
|
+
job: string
|
|
137
|
+
fallback: Tree
|
|
138
|
+
rollouts: readonly Rollout[]
|
|
139
|
+
harnessDir: string
|
|
140
|
+
pool?: readonly evidence.PoolEntry[]
|
|
141
|
+
decisions?: Record<string, string>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Builds evidence, asks for an edit, and retries an invalid one once. Never throws. */
|
|
145
|
+
export async function runStage(ctx: RecursionContext, input: StageInput): Promise<Stage> {
|
|
146
|
+
const { label, dim, fallback } = input
|
|
147
|
+
ctx.onEvent?.({ type: 'stage.started', label, dim })
|
|
148
|
+
const finish = (stage: Stage): Stage => {
|
|
149
|
+
ctx.onEvent?.({ type: 'stage.finished', label, dim, fellBack: stage.fellBack, keptBase: stage.keptBase, error: stage.error })
|
|
150
|
+
return stage
|
|
151
|
+
}
|
|
152
|
+
let tree: evidence.EvidenceTree
|
|
153
|
+
let mission: string
|
|
154
|
+
try {
|
|
155
|
+
tree = evidence.build(join(ctx.workDir, 'context', label), {
|
|
156
|
+
task: ctx.task,
|
|
157
|
+
executor: ctx.executor,
|
|
158
|
+
rollouts: input.rollouts,
|
|
159
|
+
harnessDir: input.harnessDir,
|
|
160
|
+
libraryDir: ctx.libraryDir,
|
|
161
|
+
pool: input.pool,
|
|
162
|
+
decisions: input.decisions
|
|
163
|
+
})
|
|
164
|
+
mission = evidence.mission(tree, input.job)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return finish({ label, dim, tree: { ...fallback }, keptBase: false, fellBack: true, error: `could not build evidence: ${error instanceof Error ? error.message : String(error)}`.slice(0, 400), usd: 0, attempts: 0, cached: false, notes: '' })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let err = ''
|
|
170
|
+
let usd = 0
|
|
171
|
+
let attempts = 0
|
|
172
|
+
let allCached = true
|
|
173
|
+
for (let attempt = 1; attempt <= STAGE_ATTEMPTS; attempt++) {
|
|
174
|
+
if (ctx.signal?.aborted) {
|
|
175
|
+
err = 'cancelled'
|
|
176
|
+
break
|
|
177
|
+
}
|
|
178
|
+
let reply
|
|
179
|
+
try {
|
|
180
|
+
reply = await ctx.improver.call({
|
|
181
|
+
mission: mission + (err ? `\n\n# YOUR PREVIOUS ATTEMPT WAS REJECTED\n\n${err}\n\nFix it in \`context/harness/\` and reply again.` : ''),
|
|
182
|
+
contextDir: tree.root,
|
|
183
|
+
label: `${label}.a${attempt}`,
|
|
184
|
+
harvest: 'context/harness',
|
|
185
|
+
signal: ctx.signal
|
|
186
|
+
})
|
|
187
|
+
} catch (error) {
|
|
188
|
+
err = error instanceof Error ? error.message : String(error)
|
|
189
|
+
attempts += error instanceof ImproverError ? error.attempts || 1 : 1
|
|
190
|
+
usd += error instanceof ImproverError ? error.costUsd : 0
|
|
191
|
+
allCached = false
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
usd += reply.costUsd
|
|
195
|
+
attempts += reply.cached ? 0 : Math.max(1, reply.attempts)
|
|
196
|
+
allCached = allCached && reply.cached
|
|
197
|
+
if (!reply.workspace || !existsSync(reply.workspace)) {
|
|
198
|
+
err = 'the harness directory did not come back from the session; edit the files in `context/harness/` in place'
|
|
199
|
+
continue
|
|
200
|
+
}
|
|
201
|
+
const rejected: string[] = []
|
|
202
|
+
const next = readTree(PI_SURFACE, reply.workspace, rejected)
|
|
203
|
+
if (!Object.keys(next).length) {
|
|
204
|
+
err = '`context/harness/` came back empty -- edit the files, do not delete them all'
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (rejected.length) {
|
|
208
|
+
err = `these files are off the editable surface and were not applied: ${rejected.sort().slice(0, 6).join(', ')}. Component names must be lowercase letters, digits, \`_\` or \`-\` (e.g. \`skills/doc_review/SKILL.md\`, \`scripts/check_totals.py\`).`
|
|
209
|
+
continue
|
|
210
|
+
}
|
|
211
|
+
const accepted = acceptTree(ctx.task, ctx.executor, next, fallback)
|
|
212
|
+
if (accepted.error) {
|
|
213
|
+
err = accepted.error
|
|
214
|
+
// The next attempt edits what came back, so it fixes rather than restarts.
|
|
215
|
+
writeTree(PI_SURFACE, next, join(tree.root, 'harness'))
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const notes = /<notes>([\s\S]*?)<\/notes>/.exec(reply.text)?.[1]?.trim() ?? reply.text.trim()
|
|
219
|
+
return finish({ label, dim, tree: next, keptBase: accepted.keptBase, fellBack: false, error: '', usd, attempts, cached: allCached, notes })
|
|
220
|
+
}
|
|
221
|
+
return finish({ label, dim, tree: { ...fallback }, keptBase: false, fellBack: true, error: err.slice(0, 400), usd, attempts, cached: allCached, notes: '' })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function recordSessions(ledger: Ledger, stages: readonly Stage[]): void {
|
|
225
|
+
for (const stage of stages) ledger.session(stage.label, stage.usd, { cached: stage.cached, attempts: stage.attempts })
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Writes a harness and rolls it once. A dead rollout is recorded, never thrown. */
|
|
229
|
+
async function roll(ctx: RecursionContext, label: string, tree: Tree): Promise<Rollout> {
|
|
230
|
+
const harnessDir = writeTree(PI_SURFACE, tree, join(ctx.workDir, 'harnesses', label))
|
|
231
|
+
const runId = ctx.runIdOf(label)
|
|
232
|
+
ctx.onEvent?.({ type: 'rollout.started', label })
|
|
233
|
+
let rollout: Rollout
|
|
234
|
+
try {
|
|
235
|
+
checkAborted(ctx.signal)
|
|
236
|
+
rollout = await ctx.executor.execute(ctx.task, harnessDir, label, runId)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
rollout = { label, runId, dir: join(ctx.workDir, 'missing', label), ok: false, delivered: false, costUsd: 0, wallMs: 0, error: error instanceof Error ? error.message : String(error) }
|
|
239
|
+
}
|
|
240
|
+
ctx.onEvent?.({ type: 'rollout.finished', label, ok: rollout.ok, delivered: rollout.delivered, error: rollout.error })
|
|
241
|
+
return rollout
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Rolls independent harnesses concurrently, then records them in the caller's order. */
|
|
245
|
+
async function rollMany(ctx: RecursionContext, ledger: Ledger, items: readonly (readonly [string, Tree])[]): Promise<Record<string, Rollout>> {
|
|
246
|
+
const got = await mapLimit(items, ctx.rolloutWidth ?? 4, ([label, tree]) => roll(ctx, label, tree))
|
|
247
|
+
const out: Record<string, Rollout> = {}
|
|
248
|
+
items.forEach(([label], i) => {
|
|
249
|
+
ledger.rollout(label, got[i]!.costUsd)
|
|
250
|
+
out[label] = got[i]!
|
|
251
|
+
})
|
|
252
|
+
return out
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function newResult(mode: 'agg' | 'seq', ctx: RecursionContext, k: number, base: readonly Rollout[]): ModeResult {
|
|
256
|
+
const ledger = new Ledger(mode, ctx.task.id, k)
|
|
257
|
+
for (const rollout of base) ledger.rollout(rollout.label, rollout.costUsd)
|
|
258
|
+
return { mode, taskId: ctx.task.id, k, stages: [], trees: {}, rollouts: Object.fromEntries(base.map((rollout) => [rollout.label, rollout])), finals: [], ledger, similarity: null, errors: [] }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const harnessPath = (ctx: RecursionContext, label: string): string => join(ctx.workDir, 'harnesses', label)
|
|
262
|
+
|
|
263
|
+
/** WIDTH: 2k independent candidates from shared evidence, then k independent compositions. */
|
|
264
|
+
export async function runParallel(ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
|
|
265
|
+
const result = newResult('agg', ctx, k, base)
|
|
266
|
+
const prompts = ctx.task.prompts ?? defaultPrompts()
|
|
267
|
+
const baseDir = writeTree(PI_SURFACE, ctx.baseTree, harnessPath(ctx, 'base'))
|
|
268
|
+
|
|
269
|
+
const specs: [string, 'INV' | 'DOM', number][] = []
|
|
270
|
+
for (let i = 1; i <= k; i++) for (const dim of ['INV', 'DOM'] as const) specs.push([`${dim.toLowerCase()}${i}`, dim, i])
|
|
271
|
+
const stages = await mapLimit(specs, ctx.proposeWidth ?? 4, ([label, dim, index]) =>
|
|
272
|
+
runStage(ctx, { label, dim, job: P.propose(prompts, dim, { k, index }), fallback: ctx.baseTree, rollouts: base, harnessDir: baseDir })
|
|
273
|
+
)
|
|
274
|
+
recordSessions(result.ledger, stages)
|
|
275
|
+
for (const stage of stages) {
|
|
276
|
+
result.stages.push(stage)
|
|
277
|
+
result.trees[stage.label] = stage.tree
|
|
278
|
+
}
|
|
279
|
+
Object.assign(result.rollouts, await rollMany(ctx, result.ledger, stages.map((stage) => [stage.label, stage.tree] as const)))
|
|
280
|
+
|
|
281
|
+
const pool = stages.map((stage) => ({ label: stage.label, harnessDir: harnessPath(ctx, stage.label), rollout: result.rollouts[stage.label] ?? null }))
|
|
282
|
+
const decisions = Object.fromEntries(stages.map((stage) => [stage.label, stage.notes || stage.error]))
|
|
283
|
+
const aggs = await mapLimit(Array.from({ length: k }, (_, i) => i + 1), ctx.proposeWidth ?? 4, (index) =>
|
|
284
|
+
runStage(ctx, { label: `agg${index}`, dim: 'AGG', job: P.aggregate(prompts, { k, index }), fallback: ctx.baseTree, rollouts: base, harnessDir: baseDir, pool, decisions })
|
|
285
|
+
)
|
|
286
|
+
recordSessions(result.ledger, aggs)
|
|
287
|
+
for (const stage of aggs) {
|
|
288
|
+
result.stages.push(stage)
|
|
289
|
+
result.trees[stage.label] = stage.tree
|
|
290
|
+
result.finals.push(stage.label)
|
|
291
|
+
}
|
|
292
|
+
Object.assign(result.rollouts, await rollMany(ctx, result.ledger, aggs.map((stage) => [stage.label, stage.tree] as const)))
|
|
293
|
+
result.similarity = diversity(Object.fromEntries(aggs.map((stage) => [stage.label, stage.tree])))
|
|
294
|
+
result.ledger.assertIdentity()
|
|
295
|
+
return result
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** DEPTH: k nested passes over the same shared base evidence. */
|
|
299
|
+
export async function runSequential(ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
|
|
300
|
+
const result = newResult('seq', ctx, k, base)
|
|
301
|
+
const prompts = ctx.task.prompts ?? defaultPrompts()
|
|
302
|
+
let current = { ...ctx.baseTree }
|
|
303
|
+
let history: evidence.PoolEntry[] = []
|
|
304
|
+
for (let t = 1; t <= k; t++) {
|
|
305
|
+
checkAborted(ctx.signal)
|
|
306
|
+
const currentLabel = t > 1 ? `c${t - 1}` : 'base'
|
|
307
|
+
const currentDir = writeTree(PI_SURFACE, current, harnessPath(ctx, currentLabel))
|
|
308
|
+
const decisions = () => Object.fromEntries(result.stages.map((stage) => [stage.label, stage.notes || stage.error]))
|
|
309
|
+
const specs: [string, 'INV' | 'DOM'][] = [[`inv${t}`, 'INV'], [`dom${t}`, 'DOM']]
|
|
310
|
+
const before = decisions()
|
|
311
|
+
const fallback = current
|
|
312
|
+
const stages = await mapLimit(specs, Math.min(2, ctx.proposeWidth ?? 2), ([label, dim]) =>
|
|
313
|
+
runStage(ctx, { label, dim, job: P.propose(prompts, dim, { k, index: t, roundT: k > 1 ? t : null }), fallback, rollouts: base, harnessDir: currentDir, pool: [...history], decisions: before })
|
|
314
|
+
)
|
|
315
|
+
recordSessions(result.ledger, stages)
|
|
316
|
+
for (const stage of stages) {
|
|
317
|
+
result.stages.push(stage)
|
|
318
|
+
result.trees[stage.label] = stage.tree
|
|
319
|
+
}
|
|
320
|
+
Object.assign(result.rollouts, await rollMany(ctx, result.ledger, stages.map((stage) => [stage.label, stage.tree] as const)))
|
|
321
|
+
const roundPool = [...history, ...stages.map((stage) => ({ label: stage.label, harnessDir: harnessPath(ctx, stage.label), rollout: result.rollouts[stage.label] ?? null }))]
|
|
322
|
+
const ck = `c${t}`
|
|
323
|
+
const job = k === 1 ? P.aggregate(prompts, { k: 1, index: 1 }) : P.composeRound(prompts, { t, k })
|
|
324
|
+
const comp = await runStage(ctx, { label: ck, dim: 'COMPOSE', job, fallback: current, rollouts: base, harnessDir: currentDir, pool: roundPool, decisions: decisions() })
|
|
325
|
+
recordSessions(result.ledger, [comp])
|
|
326
|
+
result.stages.push(comp)
|
|
327
|
+
result.trees[ck] = comp.tree
|
|
328
|
+
const rolled = await roll(ctx, ck, comp.tree)
|
|
329
|
+
result.ledger.rollout(ck, rolled.costUsd)
|
|
330
|
+
result.rollouts[ck] = rolled
|
|
331
|
+
history = [...roundPool, { label: ck, harnessDir: harnessPath(ctx, ck), rollout: rolled }]
|
|
332
|
+
current = comp.tree
|
|
333
|
+
}
|
|
334
|
+
result.finals = Array.from({ length: k }, (_, i) => `c${i + 1}`)
|
|
335
|
+
result.ledger.assertIdentity()
|
|
336
|
+
return result
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function defaultPrompts(): P.WorkPrompts {
|
|
340
|
+
return {
|
|
341
|
+
work: 'The work is the request in `context/task.md`, done for the person who asked for it, with the tools this agent has.',
|
|
342
|
+
domainWord: 'area'
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function runMode(mode: 'parallel' | 'sequential', ctx: RecursionContext, k: number, base: readonly Rollout[]): Promise<ModeResult> {
|
|
347
|
+
return mode === 'parallel' ? runParallel(ctx, k, base) : runSequential(ctx, k, base)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Reads a harness a stage wrote, for callers that hold only labels. */
|
|
351
|
+
export function stageTree(workDir: string, label: string): Tree {
|
|
352
|
+
return readTree(PI_SURFACE, harnessPath({ workDir } as RecursionContext, label))
|
|
353
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { describe, expect, it } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import * as store from '../tree/store'
|
|
7
|
+
import { DemoExecutor, DemoImprover, demoBaseTree, demoTasks } from './demo'
|
|
8
|
+
import * as evidence from './evidence'
|
|
9
|
+
import { Library } from './library'
|
|
10
|
+
import { Ledger, BudgetError } from './ledger'
|
|
11
|
+
import * as P from './prompts'
|
|
12
|
+
import { runRefinement } from './runner'
|
|
13
|
+
import { anonId, assignIds, parseExperience } from './selection'
|
|
14
|
+
import type { Improver, ImproverCall, RefineTask } from './types'
|
|
15
|
+
|
|
16
|
+
function setup() {
|
|
17
|
+
const root = mkdtempSync(join(tmpdir(), 'refine-'))
|
|
18
|
+
store.seed(root, 'demo', demoBaseTree())
|
|
19
|
+
return { root, tasks: demoTasks(root), executor: new DemoExecutor(root), improver: new DemoImprover() }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('refinement end to end', () => {
|
|
23
|
+
for (const mode of ['parallel', 'sequential'] as const) {
|
|
24
|
+
for (const k of [1, 2]) {
|
|
25
|
+
it(`${mode} k=${k} keeps the budget identity and delivers the signed total`, async () => {
|
|
26
|
+
const { root, tasks, executor, improver } = setup()
|
|
27
|
+
const expected: Record<string, string> = { 'ledger-1': 'Total: 85.15\n', 'ledger-2': 'Total: 20.25\n' }
|
|
28
|
+
for (const task of tasks) {
|
|
29
|
+
const summary = await runRefinement({ root, id: `${task.id}-${mode}-${k}`, task, k, mode, executor, improver })
|
|
30
|
+
expect(summary.status).toBe('ok')
|
|
31
|
+
expect(summary.recursion?.ledger).toMatchObject({ R: 4 * k, P: 3 * k })
|
|
32
|
+
const chosen = summary.selection!
|
|
33
|
+
expect(readFileSync(join(chosen.chosenDir, 'answer.txt'), 'utf8')).toBe(expected[task.id])
|
|
34
|
+
expect(summary.chosenIsBase).toBe(false)
|
|
35
|
+
expect(store.exists(root, 'demo', summary.chosenFingerprint!)).toBe(true)
|
|
36
|
+
// A refinement never moves the head; adopting is a separate step.
|
|
37
|
+
expect(store.head(root, 'demo')?.fingerprint).toBe(summary.baseFingerprint)
|
|
38
|
+
expect(summary.libraryEntry).toBeTruthy()
|
|
39
|
+
}
|
|
40
|
+
const library = new Library(root, 'demo', 'accounting')
|
|
41
|
+
expect(library.entries()).toEqual(['ledger-1', 'ledger-2'])
|
|
42
|
+
expect(readFileSync(library.index, 'utf8')).toContain('When a ledger includes credits and debits')
|
|
43
|
+
// No selector session at k=1.
|
|
44
|
+
expect(improver.calls.some((label) => label.startsWith('select.'))).toBe(k > 1)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it('gives the second task the first task\'s library, and never the answer key', async () => {
|
|
50
|
+
const { root, tasks, executor } = setup()
|
|
51
|
+
const seen: string[] = []
|
|
52
|
+
const improver = new DemoImprover()
|
|
53
|
+
const spy: Improver = {
|
|
54
|
+
call: (input: ImproverCall) => {
|
|
55
|
+
if (input.label.startsWith('inv1')) {
|
|
56
|
+
seen.push(existsSync(join(input.contextDir, 'library', 'INDEX.md')) ? 'library' : 'empty')
|
|
57
|
+
expect(existsSync(join(input.contextDir, 'source', 'reference_answer.txt'))).toBe(false)
|
|
58
|
+
}
|
|
59
|
+
return improver.call(input)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
for (const task of tasks) await runRefinement({ root, id: task.id, task, k: 1, mode: 'parallel', executor, improver: spy })
|
|
63
|
+
expect(seen).toEqual(['empty', 'library'])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('retries an invalid proposal once with the validator\'s message, then falls back', async () => {
|
|
67
|
+
const { root, tasks, executor } = setup()
|
|
68
|
+
const missions: string[] = []
|
|
69
|
+
const bad: Improver = {
|
|
70
|
+
async call(input) {
|
|
71
|
+
missions.push(input.mission)
|
|
72
|
+
if (input.label.startsWith('experience.')) return { label: input.label, text: 'NO_ENTRY', costUsd: 0, attempts: 1, cached: false, workspace: null }
|
|
73
|
+
const dir = mkdtempSync(join(tmpdir(), 'bad-'))
|
|
74
|
+
writeFileSync(join(dir, 'systemprompt.md'), 'x')
|
|
75
|
+
writeFileSync(join(dir, 'guardrails.md'), '')
|
|
76
|
+
// Drops the script the executor requires.
|
|
77
|
+
return { label: input.label, text: '<notes>x</notes>', costUsd: 0.01, attempts: 1, cached: false, workspace: dir }
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const summary = await runRefinement({ root, id: 'bad', task: tasks[0]!, k: 1, mode: 'parallel', executor, improver: bad })
|
|
81
|
+
expect(summary.recursion?.fellBack.sort()).toEqual(['agg1', 'dom1', 'inv1'])
|
|
82
|
+
expect(missions.some((mission) => mission.includes('YOUR PREVIOUS ATTEMPT WAS REJECTED') && mission.includes('requires scripts/summarize.mjs'))).toBe(true)
|
|
83
|
+
expect(summary.recursion?.ledger).toMatchObject({ R: 4, P: 3 })
|
|
84
|
+
expect(summary.chosenIsBase).toBe(true)
|
|
85
|
+
expect(summary.libraryEntry).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('records a failed refinement when no base rollout works', async () => {
|
|
89
|
+
const { root, tasks, improver } = setup()
|
|
90
|
+
const broken = new DemoExecutor(root)
|
|
91
|
+
broken.execute = async (_task, _dir, label, runId) => ({ label, runId, dir: root, ok: false, delivered: false, costUsd: 0, wallMs: 0, error: 'down' })
|
|
92
|
+
const summary = await runRefinement({ root, id: 'down', task: tasks[0]!, k: 1, mode: 'parallel', executor: broken, improver })
|
|
93
|
+
expect(summary.status).toBe('failed')
|
|
94
|
+
expect(summary.errors).toContain('no base rollout produced anything')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('guards and parsing', () => {
|
|
99
|
+
it('refuses grader output and answer keys in evidence', () => {
|
|
100
|
+
const dir = mkdtempSync(join(tmpdir(), 'guard-'))
|
|
101
|
+
writeFileSync(join(dir, 'notes.json'), '{"pass_rate": 1}')
|
|
102
|
+
expect(() => evidence.assertNoGraderVerdicts(dir)).toThrow(/grader verdict/)
|
|
103
|
+
expect(() => evidence.assertTreeHasNoGraderVerdicts({ 'memory.md': 'review_verdict: approved' })).toThrow()
|
|
104
|
+
expect(() => evidence.assertNoLeakPaths(dir, ['notes.json'])).toThrow(/answer-key/)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('refuses work prompts that describe measurement', () => {
|
|
108
|
+
expect(() => P.checkWorkPrompts({ work: 'maximise the rubric score' })).toThrow(/must describe the WORK/)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('shuffles candidate ids deterministically', () => {
|
|
112
|
+
expect(anonId(0)).toBe('A')
|
|
113
|
+
expect(anonId(26)).toBe('AA')
|
|
114
|
+
const a = assignIds(['agg1', 'agg2', 'agg3'], 't', 3, 'agg')
|
|
115
|
+
expect(assignIds(['agg3', 'agg1', 'agg2'], 't', 3, 'agg')).toEqual(a)
|
|
116
|
+
expect(Object.values(a).sort()).toEqual(['agg1', 'agg2', 'agg3'])
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('reads an experience block, and keeps only edits when there is no lesson', () => {
|
|
120
|
+
expect(parseExperience('<edits>e</edits>\n<experience>\ncue: c\nlesson: l\nevidence: v\nconfidence: low\n</experience>')).toEqual({ cue: 'c', lesson: 'l', evidence: 'v', confidence: 'low', edits: 'e' })
|
|
121
|
+
expect(parseExperience('<edits>e</edits>\nNO_ENTRY')).toEqual({ edits: 'e' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('catches a broken budget identity', () => {
|
|
125
|
+
const ledger = new Ledger('agg', 't', 1)
|
|
126
|
+
ledger.rollout('base1')
|
|
127
|
+
ledger.rollout('base1')
|
|
128
|
+
expect(() => ledger.assertIdentity()).toThrow(BudgetError)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('builds missions that point at the evidence on disk', () => {
|
|
132
|
+
const root = mkdtempSync(join(tmpdir(), 'mission-'))
|
|
133
|
+
const task: RefineTask = { id: 't', scope: 's', prompt: 'Do it.' }
|
|
134
|
+
const tree = evidence.build(join(root, 'ctx'), { task, executor: new DemoExecutor(root) })
|
|
135
|
+
const text = evidence.mission(tree, P.propose({ work: 'drafting' }, 'INV'))
|
|
136
|
+
expect(text).toContain('# YOUR EVIDENCE')
|
|
137
|
+
expect(text).toContain('context/task.md')
|
|
138
|
+
expect(text).toContain('`plugins/<name>.ts`')
|
|
139
|
+
})
|
|
140
|
+
})
|