@xingular/harness 2.1.2 → 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 +12 -0
- package/NOTICE.md +22 -0
- package/README.md +67 -36
- package/dist/cli.js +6023 -6621
- package/dist/terminal-pi.js +19 -24
- package/docs/terminal-tui.md +27 -7
- 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/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 +6 -1
- package/src/sessions/conversation.test.ts +2 -27
- package/src/sessions/policy.ts +1 -1
- package/src/sessions/pricing.ts +3 -1
- package/src/sessions/registry.ts +5 -1
- package/src/sessions/runStore.ts +3 -1
- package/src/sessions/runner.ts +10 -5
- package/src/sessions/sessions.test.ts +45 -6
- package/src/sessions/sessions.ts +40 -17
- 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/pluginControl.test.ts +7 -8
- package/src/terminal/socket.ts +3 -3
- package/src/terminal/terminal.test.ts +21 -39
- package/src/terminal/terminals.ts +20 -13
- package/src/terminal/tui/application.test.ts +58 -0
- package/src/terminal/tui/application.ts +10 -4
- package/src/terminal/tui/clipboardFiles.test.ts +94 -0
- package/src/terminal/tui/clipboardFiles.ts +86 -0
- package/src/terminal/tui/piAdapter.ts +7 -4
- package/src/terminal/tui/runtime.ts +2 -0
- 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 +1 -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/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,205 @@
|
|
|
1
|
+
// One refinement: k shared base executions on the current harness, one recursion mode, blind
|
|
2
|
+
// selection of a final, reflection, and a library entry. Everything is written under
|
|
3
|
+
// `<data>/refine/<id>/` as it happens; `summary.json` is the record a person or an application
|
|
4
|
+
// reads.
|
|
5
|
+
//
|
|
6
|
+
// Shaped after Harness² (Apache-2.0, Google LLC; `harness2/core/schedule.py::run_task`).
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
import { now, readJson, safeId, writeJson } from '../core/fs'
|
|
10
|
+
import * as store from '../tree/store'
|
|
11
|
+
import { PI_SURFACE, type Tree } from '../tree/surface'
|
|
12
|
+
import { isUnchanged, writeTree } from '../tree/tree'
|
|
13
|
+
import { Library, hasExperience, type Experience } from './library'
|
|
14
|
+
import type { LedgerRecord } from './ledger'
|
|
15
|
+
import { mapLimit, modeRecord, runMode, type ModeRecord, type RecursionEvent } from './recursion'
|
|
16
|
+
import { reflectAdoption, runSelection, selectionPool, trivialSelection, type Selection } from './selection'
|
|
17
|
+
import type { Executor, Improver, RecursionMode, RefineTask, Rollout } from './types'
|
|
18
|
+
|
|
19
|
+
export type RefineStatus = 'running' | 'ok' | 'partial' | 'failed' | 'cancelled'
|
|
20
|
+
|
|
21
|
+
export interface RefineSummary {
|
|
22
|
+
id: string
|
|
23
|
+
taskId: string
|
|
24
|
+
scope: string
|
|
25
|
+
area: string
|
|
26
|
+
k: number
|
|
27
|
+
mode: RecursionMode
|
|
28
|
+
status: RefineStatus
|
|
29
|
+
startedAt: string
|
|
30
|
+
endedAt: string | null
|
|
31
|
+
/** The head the refinement started from. */
|
|
32
|
+
baseFingerprint: string
|
|
33
|
+
base: { label: string; runId: string; ok: boolean; delivered: boolean; error: string }[]
|
|
34
|
+
recursion: ModeRecord | null
|
|
35
|
+
selection: Selection | null
|
|
36
|
+
/** The chosen harness, saved as a version of the scope without moving its head; null when it is the base. */
|
|
37
|
+
chosenFingerprint: string | null
|
|
38
|
+
chosenIsBase: boolean
|
|
39
|
+
libraryEntry: string | null
|
|
40
|
+
experience: Partial<Experience> | null
|
|
41
|
+
usd: number
|
|
42
|
+
errors: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RefineEvent =
|
|
46
|
+
| RecursionEvent
|
|
47
|
+
| { type: 'phase'; phase: 'base' | 'recursion' | 'selection' | 'reflection' | 'done' }
|
|
48
|
+
| { type: 'summary'; summary: RefineSummary }
|
|
49
|
+
|
|
50
|
+
export interface RefineRunInput {
|
|
51
|
+
root: string
|
|
52
|
+
id: string
|
|
53
|
+
task: RefineTask
|
|
54
|
+
k: number
|
|
55
|
+
mode: RecursionMode
|
|
56
|
+
executor: Executor
|
|
57
|
+
improver: Improver
|
|
58
|
+
/** The selector; defaults to the improver. */
|
|
59
|
+
selector?: Improver
|
|
60
|
+
/** The harness to start from. Defaults to the scope's head. */
|
|
61
|
+
baseTree?: Tree
|
|
62
|
+
proposeWidth?: number
|
|
63
|
+
rolloutWidth?: number
|
|
64
|
+
onEvent?: (event: RefineEvent) => void
|
|
65
|
+
signal?: AbortSignal
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function refineDir(root: string, id: string): string {
|
|
69
|
+
return join(root, 'refine', safeId(id))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function readSummary(root: string, id: string): RefineSummary | null {
|
|
73
|
+
return readJson<RefineSummary>(join(refineDir(root, id), 'summary.json'))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const modeToken = (mode: RecursionMode): 'agg' | 'seq' => (mode === 'parallel' ? 'agg' : 'seq')
|
|
77
|
+
|
|
78
|
+
/** Runs one refinement to the end. Failures are recorded on the summary; only bad input throws. */
|
|
79
|
+
export async function runRefinement(input: RefineRunInput): Promise<RefineSummary> {
|
|
80
|
+
const { root, id, task, k, mode, executor, improver } = input
|
|
81
|
+
if (!Number.isInteger(k) || k < 1 || k > 8) throw new Error('k must be a whole number from 1 to 8.')
|
|
82
|
+
safeId(task.scope)
|
|
83
|
+
const workDir = refineDir(root, id)
|
|
84
|
+
const area = task.domain ?? task.scope
|
|
85
|
+
const baseTree = input.baseTree ?? store.headTree(root, task.scope)?.tree
|
|
86
|
+
if (!baseTree) throw new Error(`The harness '${task.scope}' has no version to start from.`)
|
|
87
|
+
const baseFingerprint = store.fingerprint(baseTree)
|
|
88
|
+
const library = new Library(root, task.scope, area)
|
|
89
|
+
const runIdOf = (label: string) => `${id}-${label}`
|
|
90
|
+
const emit = (event: RefineEvent) => input.onEvent?.(event)
|
|
91
|
+
|
|
92
|
+
const summary: RefineSummary = {
|
|
93
|
+
id,
|
|
94
|
+
taskId: task.id,
|
|
95
|
+
scope: task.scope,
|
|
96
|
+
area,
|
|
97
|
+
k,
|
|
98
|
+
mode,
|
|
99
|
+
status: 'running',
|
|
100
|
+
startedAt: now(),
|
|
101
|
+
endedAt: null,
|
|
102
|
+
baseFingerprint,
|
|
103
|
+
base: [],
|
|
104
|
+
recursion: null,
|
|
105
|
+
selection: null,
|
|
106
|
+
chosenFingerprint: null,
|
|
107
|
+
chosenIsBase: false,
|
|
108
|
+
libraryEntry: null,
|
|
109
|
+
experience: null,
|
|
110
|
+
usd: 0,
|
|
111
|
+
errors: []
|
|
112
|
+
}
|
|
113
|
+
const save = () => {
|
|
114
|
+
writeJson(join(workDir, 'summary.json'), summary)
|
|
115
|
+
emit({ type: 'summary', summary: structuredClone(summary) })
|
|
116
|
+
}
|
|
117
|
+
const finish = (status: RefineStatus) => {
|
|
118
|
+
summary.status = input.signal?.aborted ? 'cancelled' : status
|
|
119
|
+
summary.endedAt = now()
|
|
120
|
+
save()
|
|
121
|
+
emit({ type: 'phase', phase: 'done' })
|
|
122
|
+
return summary
|
|
123
|
+
}
|
|
124
|
+
writeJson(join(workDir, 'task.json'), { task, k, mode })
|
|
125
|
+
save()
|
|
126
|
+
|
|
127
|
+
// The library a refinement reads is the one before it, so the record of what it read is stable.
|
|
128
|
+
const libraryDir = library.contextDir()
|
|
129
|
+
|
|
130
|
+
emit({ type: 'phase', phase: 'base' })
|
|
131
|
+
const baseDir = writeTree(PI_SURFACE, baseTree, join(workDir, 'harnesses', 'base'))
|
|
132
|
+
const labels = Array.from({ length: k }, (_, i) => `base${i + 1}`)
|
|
133
|
+
const base: Rollout[] = await mapLimit(labels, input.rolloutWidth ?? 4, async (label) => {
|
|
134
|
+
emit({ type: 'rollout.started', label })
|
|
135
|
+
let rollout: Rollout
|
|
136
|
+
try {
|
|
137
|
+
if (input.signal?.aborted) throw new Error('Refinement was cancelled.')
|
|
138
|
+
rollout = await executor.execute(task, baseDir, label, runIdOf(label))
|
|
139
|
+
} catch (error) {
|
|
140
|
+
rollout = { label, runId: runIdOf(label), dir: join(workDir, 'missing', label), ok: false, delivered: false, costUsd: 0, wallMs: 0, error: error instanceof Error ? error.message : String(error) }
|
|
141
|
+
}
|
|
142
|
+
emit({ type: 'rollout.finished', label, ok: rollout.ok, delivered: rollout.delivered, error: rollout.error })
|
|
143
|
+
return rollout
|
|
144
|
+
})
|
|
145
|
+
summary.base = base.map((r) => ({ label: r.label, runId: r.runId, ok: r.ok, delivered: r.delivered, error: r.error }))
|
|
146
|
+
if (!base.some((rollout) => rollout.ok)) {
|
|
147
|
+
summary.errors.push('no base rollout produced anything')
|
|
148
|
+
summary.usd = base.reduce((sum, r) => sum + r.costUsd, 0)
|
|
149
|
+
return finish('failed')
|
|
150
|
+
}
|
|
151
|
+
save()
|
|
152
|
+
|
|
153
|
+
emit({ type: 'phase', phase: 'recursion' })
|
|
154
|
+
let result
|
|
155
|
+
try {
|
|
156
|
+
result = await runMode(mode, { task, executor, improver, workDir, runIdOf, baseTree, libraryDir, proposeWidth: input.proposeWidth, rolloutWidth: input.rolloutWidth, onEvent: emit, signal: input.signal }, k, base)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
summary.errors.push(`${mode}: ${error instanceof Error ? error.message : String(error)}`)
|
|
159
|
+
return finish(input.signal?.aborted ? 'cancelled' : 'failed')
|
|
160
|
+
}
|
|
161
|
+
summary.recursion = modeRecord(result)
|
|
162
|
+
summary.usd = result.ledger.usd()
|
|
163
|
+
save()
|
|
164
|
+
|
|
165
|
+
emit({ type: 'phase', phase: 'selection' })
|
|
166
|
+
const token = modeToken(mode)
|
|
167
|
+
const candidates = selectionPool(token, k, result.rollouts)
|
|
168
|
+
const sel = k === 1
|
|
169
|
+
? trivialSelection({ task, k, mode: token, candidates, executor, workDir })
|
|
170
|
+
: await runSelection({ task, k, mode: token, candidates, executor, improver: input.selector ?? improver, workDir, signal: input.signal })
|
|
171
|
+
summary.selection = sel
|
|
172
|
+
if (!sel.ok) summary.errors.push(`${mode}: selection failed: ${sel.error}`)
|
|
173
|
+
const chosen = candidates[sel.chosenLabel]
|
|
174
|
+
if (chosen && !chosen.delivered) summary.errors.push(`${mode}: selected run ${chosen.runId} produced no deliverable`)
|
|
175
|
+
|
|
176
|
+
const chosenTree = result.trees[sel.chosenLabel] ?? baseTree
|
|
177
|
+
summary.chosenIsBase = isUnchanged(PI_SURFACE, baseTree, chosenTree)
|
|
178
|
+
if (sel.chosenLabel && !summary.chosenIsBase) {
|
|
179
|
+
try {
|
|
180
|
+
summary.chosenFingerprint = store.commit(root, task.scope, chosenTree, { kind: 'refinement', refineId: id, label: sel.chosenLabel, note: sel.why.slice(0, 500) }, { moveHead: false }).fingerprint
|
|
181
|
+
} catch (error) {
|
|
182
|
+
summary.errors.push(`could not save the chosen harness: ${error instanceof Error ? error.message : String(error)}`)
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
save()
|
|
186
|
+
|
|
187
|
+
if (sel.ok) {
|
|
188
|
+
emit({ type: 'phase', phase: 'reflection' })
|
|
189
|
+
const decisions = Object.fromEntries(result.stages.map((stage) => [stage.label, stage.notes || stage.error]))
|
|
190
|
+
const experience = await reflectAdoption({ task, selection: sel, rollouts: result.rollouts, executor, improver: input.selector ?? improver, workDir, baseTree, decisions, signal: input.signal })
|
|
191
|
+
summary.experience = Object.keys(experience).length ? experience : null
|
|
192
|
+
if (hasExperience(experience)) {
|
|
193
|
+
try {
|
|
194
|
+
summary.libraryEntry = library.add(task.id, chosenTree, { why: sel.why, label: sel.chosenLabel, runId: sel.chosenRunId, experience })
|
|
195
|
+
} catch (error) {
|
|
196
|
+
summary.errors.push(`could not add to the library: ${error instanceof Error ? error.message : String(error)}`)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
summary.usd = Math.round((result.ledger.usd() + sel.usd) * 1e6) / 1e6
|
|
201
|
+
const ok = summary.recursion.ok && sel.ok && Boolean(sel.chosenLabel) && !summary.errors.length
|
|
202
|
+
return finish(ok ? 'ok' : sel.chosenLabel ? 'partial' : 'failed')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export type { LedgerRecord }
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
// Choosing one anonymised final output, then reflecting on the refinement that produced it.
|
|
2
|
+
// The selector sees only the request, the source and the outputs, shuffled under arbitrary ids;
|
|
3
|
+
// it never sees a harness, a trajectory, or any verdict.
|
|
4
|
+
//
|
|
5
|
+
// Ported from Harness² (Apache-2.0, Google LLC; `harness2/core/selection.py`).
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { cpSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
import { writeJson } from '../core/fs'
|
|
11
|
+
import { PI_SURFACE, type Tree } from '../tree/surface'
|
|
12
|
+
import { writeTree } from '../tree/tree'
|
|
13
|
+
import * as evidence from './evidence'
|
|
14
|
+
import type { Experience } from './library'
|
|
15
|
+
import * as P from './prompts'
|
|
16
|
+
import { defaultPrompts } from './recursion'
|
|
17
|
+
import { ImproverError, type Executor, type Improver, type RefineTask, type Rollout } from './types'
|
|
18
|
+
|
|
19
|
+
export interface Selection {
|
|
20
|
+
taskId: string
|
|
21
|
+
mode: 'agg' | 'seq'
|
|
22
|
+
k: number
|
|
23
|
+
chosenId: string
|
|
24
|
+
chosenLabel: string
|
|
25
|
+
chosenRunId: string
|
|
26
|
+
chosenDir: string
|
|
27
|
+
why: string
|
|
28
|
+
idMap: Record<string, string>
|
|
29
|
+
dropped: string[]
|
|
30
|
+
candidates: number
|
|
31
|
+
ok: boolean
|
|
32
|
+
fallback: boolean
|
|
33
|
+
trivial: boolean
|
|
34
|
+
error: string
|
|
35
|
+
usd: number
|
|
36
|
+
experience: Partial<Experience>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A seeded mulberry32, so a shuffle is stable across reruns of the same task. */
|
|
40
|
+
function seeded(seed: number): () => number {
|
|
41
|
+
let a = seed >>> 0
|
|
42
|
+
return () => {
|
|
43
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
44
|
+
let t = a
|
|
45
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
46
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
47
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A..Z, AA..AZ, BA...: always accepted by the choice parser. */
|
|
52
|
+
export function anonId(index: number): string {
|
|
53
|
+
let out = ''
|
|
54
|
+
let n = index + 1
|
|
55
|
+
while (n) {
|
|
56
|
+
const rem = (n - 1) % 26
|
|
57
|
+
out = String.fromCharCode(65 + rem) + out
|
|
58
|
+
n = Math.floor((n - 1) / 26)
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** anon id → label, by canonical sort then a seeded shuffle. */
|
|
64
|
+
export function assignIds(labels: readonly string[], taskId: string, k: number, mode: string): Record<string, string> {
|
|
65
|
+
const seed = parseInt(createHash('sha1').update(`${taskId}|${k}|${mode}`).digest('hex').slice(0, 8), 16)
|
|
66
|
+
const ordered = [...labels].sort()
|
|
67
|
+
const random = seeded(seed)
|
|
68
|
+
for (let i = ordered.length - 1; i > 0; i--) {
|
|
69
|
+
const j = Math.floor(random() * (i + 1))
|
|
70
|
+
;[ordered[i], ordered[j]] = [ordered[j]!, ordered[i]!]
|
|
71
|
+
}
|
|
72
|
+
return Object.fromEntries(ordered.map((label, i) => [anonId(i), label]))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Exactly the k finals of a mode: agg1..aggk or c1..ck. The base executions are never in it. */
|
|
76
|
+
export function selectionPool(mode: 'agg' | 'seq', k: number, rollouts: Record<string, Rollout>): Record<string, Rollout> {
|
|
77
|
+
const labels = Array.from({ length: k }, (_, i) => (mode === 'agg' ? `agg${i + 1}` : `c${i + 1}`))
|
|
78
|
+
return Object.fromEntries(labels.filter((label) => rollouts[label]).map((label) => [label, rollouts[label]!]))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function newSelection(taskId: string, mode: 'agg' | 'seq', k: number): Selection {
|
|
82
|
+
return { taskId, mode, k, chosenId: '', chosenLabel: '', chosenRunId: '', chosenDir: '', why: '', idMap: {}, dropped: [], candidates: 0, ok: false, fallback: false, trivial: false, error: '', usd: 0, experience: {} }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Drops non-answers before anonymising, recording each drop and its cause. */
|
|
86
|
+
function renderPool(candidates: Record<string, Rollout>, executor: Executor, sel: Selection): { pool: Record<string, Rollout>; artifacts: Record<string, string> } {
|
|
87
|
+
const pool: Record<string, Rollout> = {}
|
|
88
|
+
const artifacts: Record<string, string> = {}
|
|
89
|
+
for (const [label, rollout] of Object.entries(candidates)) {
|
|
90
|
+
if (!(rollout.ok && rollout.delivered)) {
|
|
91
|
+
sel.dropped.push(label)
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
let text = ''
|
|
95
|
+
try {
|
|
96
|
+
text = evidence.renderDeliverable(executor, rollout)
|
|
97
|
+
} catch (error) {
|
|
98
|
+
sel.dropped.push(`${label} (render failed: ${error instanceof Error ? error.message : String(error)})`.slice(0, 200))
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
if (!text.trim()) {
|
|
102
|
+
sel.dropped.push(label)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
pool[label] = rollout
|
|
106
|
+
artifacts[label] = text
|
|
107
|
+
}
|
|
108
|
+
return { pool, artifacts }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function persist(sel: Selection, root: string): void {
|
|
112
|
+
try {
|
|
113
|
+
writeJson(join(root, 'selection.json'), sel)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
sel.ok = false
|
|
116
|
+
sel.error = `${sel.error} | could not persist selection: ${error instanceof Error ? error.message : String(error)}`.slice(0, 400)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function choose(sel: Selection, label: string, pool: Record<string, Rollout>): void {
|
|
121
|
+
sel.chosenLabel = label
|
|
122
|
+
sel.chosenId = Object.entries(sel.idMap).find(([, l]) => l === label)?.[0] ?? ''
|
|
123
|
+
sel.chosenRunId = pool[label]?.runId ?? ''
|
|
124
|
+
sel.chosenDir = pool[label]?.dir ?? ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SelectInput {
|
|
128
|
+
task: RefineTask
|
|
129
|
+
k: number
|
|
130
|
+
mode: 'agg' | 'seq'
|
|
131
|
+
candidates: Record<string, Rollout>
|
|
132
|
+
executor: Executor
|
|
133
|
+
improver: Improver
|
|
134
|
+
workDir: string
|
|
135
|
+
signal?: AbortSignal
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Picks one output. A selector failure falls back to the first final and never throws. */
|
|
139
|
+
export async function runSelection(input: SelectInput): Promise<Selection> {
|
|
140
|
+
const { task, k, mode, executor } = input
|
|
141
|
+
const sel = newSelection(task.id, mode, k)
|
|
142
|
+
const { pool, artifacts } = renderPool(input.candidates, executor, sel)
|
|
143
|
+
sel.candidates = Object.keys(pool).length
|
|
144
|
+
if (!sel.candidates) {
|
|
145
|
+
sel.error = 'no candidate produced an artifact'
|
|
146
|
+
return sel
|
|
147
|
+
}
|
|
148
|
+
sel.idMap = assignIds(Object.keys(pool), task.id, k, mode)
|
|
149
|
+
const root = join(input.workDir, 'selection', `${mode}${k}`)
|
|
150
|
+
rmSync(root, { recursive: true, force: true })
|
|
151
|
+
mkdirSync(join(root, 'candidates'), { recursive: true })
|
|
152
|
+
writeFileSync(join(root, 'task.md'), task.prompt)
|
|
153
|
+
evidence.copySource(task, root)
|
|
154
|
+
for (const [anon, label] of Object.entries(sel.idMap)) {
|
|
155
|
+
const text = artifacts[label]!
|
|
156
|
+
writeFileSync(join(root, 'candidates', `${anon}.md`), `<!-- candidate ${anon}: ${text.length.toLocaleString('en-US')} chars, ${(text.split('\n').length).toLocaleString('en-US')} lines. COMPLETE -- nothing trimmed. Navigate with grep/offsets; evaluating a candidate means opening the part that determines whether it works. -->\n${text}`)
|
|
157
|
+
}
|
|
158
|
+
const ids = Object.keys(sel.idMap).sort().join(', ')
|
|
159
|
+
const mission = [
|
|
160
|
+
P.select(task.prompts ?? defaultPrompts(), sel.candidates, { poolKind: 'finals' }),
|
|
161
|
+
`# YOUR EVIDENCE\n\nIt is on disk. Read it -- do not answer from this message alone.\n\ncontext/task.md the instructions\ncontext/source/ task source materials, when supplied\ncontext/candidates/<id>.md one file per candidate: ${ids}\n\nOpen every candidate before choosing. Choose exactly one of: ${ids}.`
|
|
162
|
+
].join('\n\n')
|
|
163
|
+
evidence.assertNoGraderVerdicts(root)
|
|
164
|
+
try {
|
|
165
|
+
const reply = await input.improver.call({ mission, contextDir: root, label: `select.${mode}${k}`, signal: input.signal })
|
|
166
|
+
sel.usd += reply.costUsd
|
|
167
|
+
const choice = /```choice\s*\n?\s*([A-Za-z0-9_-]+)\s*\n?\s*```/.exec(reply.text)?.[1]?.trim()
|
|
168
|
+
if (!choice) throw new Error('no ```choice block in the reply')
|
|
169
|
+
if (!sel.idMap[choice]) throw new Error(`chose '${choice}', which is not one of ${ids}`)
|
|
170
|
+
sel.why = (/<why>([\s\S]*?)<\/why>/.exec(reply.text)?.[1]?.trim() ?? '').slice(0, 2000)
|
|
171
|
+
sel.ok = true
|
|
172
|
+
choose(sel, sel.idMap[choice]!, pool)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof ImproverError) sel.usd += error.costUsd
|
|
175
|
+
// The neutral stand-in: the first final in canonical order. It is delivered, but the
|
|
176
|
+
// selection did not succeed, so nothing is reflected on or added to the library.
|
|
177
|
+
choose(sel, Object.keys(pool).sort()[0]!, pool)
|
|
178
|
+
sel.fallback = true
|
|
179
|
+
sel.ok = false
|
|
180
|
+
sel.error = (error instanceof Error ? error.message : String(error)).slice(0, 400)
|
|
181
|
+
}
|
|
182
|
+
persist(sel, root)
|
|
183
|
+
return sel
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Delivers the single k=1 final without a selector session. */
|
|
187
|
+
export function trivialSelection(input: Omit<SelectInput, 'improver' | 'signal'>): Selection {
|
|
188
|
+
const { task, k, mode } = input
|
|
189
|
+
if (Object.keys(input.candidates).length > 1) throw new Error(`trivialSelection expects at most one candidate, got ${Object.keys(input.candidates).sort().join(', ')}`)
|
|
190
|
+
const sel = newSelection(task.id, mode, k)
|
|
191
|
+
sel.trivial = true
|
|
192
|
+
const { pool } = renderPool(input.candidates, input.executor, sel)
|
|
193
|
+
sel.candidates = Object.keys(pool).length
|
|
194
|
+
if (!sel.candidates) {
|
|
195
|
+
sel.error = 'no candidate produced an artifact (the finals-only pool has no base fallback)'
|
|
196
|
+
return sel
|
|
197
|
+
}
|
|
198
|
+
const label = Object.keys(pool)[0]!
|
|
199
|
+
sel.idMap = { A: label }
|
|
200
|
+
choose(sel, label, pool)
|
|
201
|
+
sel.ok = true
|
|
202
|
+
sel.why = 'trivial pool: single final at k=1; delivered without a selector session'
|
|
203
|
+
const root = join(input.workDir, 'selection', `${mode}${k}`)
|
|
204
|
+
rmSync(root, { recursive: true, force: true })
|
|
205
|
+
mkdirSync(root, { recursive: true })
|
|
206
|
+
persist(sel, root)
|
|
207
|
+
return sel
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ReflectInput {
|
|
211
|
+
task: RefineTask
|
|
212
|
+
selection: Selection
|
|
213
|
+
rollouts: Record<string, Rollout>
|
|
214
|
+
executor: Executor
|
|
215
|
+
improver: Improver
|
|
216
|
+
workDir: string
|
|
217
|
+
baseTree: Tree
|
|
218
|
+
decisions?: Record<string, string>
|
|
219
|
+
signal?: AbortSignal
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Learns from the edits, decisions, trajectories and outputs after adoption. Never throws. */
|
|
223
|
+
export async function reflectAdoption(input: ReflectInput): Promise<Partial<Experience>> {
|
|
224
|
+
const { task, selection: sel } = input
|
|
225
|
+
const chosen = input.rollouts[sel.chosenLabel]
|
|
226
|
+
if (!sel.ok || !chosen) return {}
|
|
227
|
+
const root = join(input.workDir, 'reflection', `${sel.mode}${sel.k}`)
|
|
228
|
+
const harnesses = join(input.workDir, 'harnesses')
|
|
229
|
+
let layout = ''
|
|
230
|
+
try {
|
|
231
|
+
const built = evidence.build(root, {
|
|
232
|
+
task,
|
|
233
|
+
executor: input.executor,
|
|
234
|
+
rollouts: Object.values(input.rollouts),
|
|
235
|
+
harnessDir: join(harnesses, sel.chosenLabel),
|
|
236
|
+
pool: Object.entries(input.rollouts).filter(([label]) => !label.startsWith('base')).map(([label, rollout]) => ({ label, harnessDir: join(harnesses, label), rollout })),
|
|
237
|
+
decisions: input.decisions
|
|
238
|
+
})
|
|
239
|
+
layout = evidence.layout(built)
|
|
240
|
+
writeTree(PI_SURFACE, input.baseTree, join(root, 'base_harness'), { stubs: false })
|
|
241
|
+
writeFileSync(join(root, 'adoption.json'), `${JSON.stringify({ chosen_label: sel.chosenLabel, candidate_ids: sel.idMap, reason: sel.why }, null, 2)}\n`)
|
|
242
|
+
evidence.assertNoLeakPaths(root, task.leakGlobs)
|
|
243
|
+
evidence.assertNoGraderVerdicts(root)
|
|
244
|
+
} catch {
|
|
245
|
+
return {}
|
|
246
|
+
}
|
|
247
|
+
const mission = [
|
|
248
|
+
P.experience(task.prompts ?? defaultPrompts()),
|
|
249
|
+
`# WHAT HAPPENED\n\nHarness ${sel.chosenLabel} was adopted. The selection reasoning was:\n\n${sel.why}\n\ncontext/adoption.json maps any anonymous IDs in that reasoning to rollout labels. Compare context/harness/ with context/base_harness/ and the candidates in context/pool/. Read the decisions and execution contrasts before writing.\n\n${layout}`
|
|
250
|
+
].join('\n\n')
|
|
251
|
+
const expRoot = `${root}.experience`
|
|
252
|
+
rmSync(expRoot, { recursive: true, force: true })
|
|
253
|
+
cpSync(root, expRoot, { recursive: true })
|
|
254
|
+
let text: string
|
|
255
|
+
try {
|
|
256
|
+
const reply = await input.improver.call({ mission, contextDir: expRoot, label: `experience.${sel.mode}${sel.k}`, signal: input.signal })
|
|
257
|
+
sel.usd += reply.costUsd
|
|
258
|
+
text = reply.text
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (error instanceof ImproverError) sel.usd += error.costUsd
|
|
261
|
+
return {}
|
|
262
|
+
}
|
|
263
|
+
const out = parseExperience(text)
|
|
264
|
+
if (out.edits) writeFileSync(join(root, 'edits.md'), `${out.edits}\n`)
|
|
265
|
+
try {
|
|
266
|
+
evidence.assertTreeHasNoGraderVerdicts({ 'EXPERIENCE.md': JSON.stringify(out) })
|
|
267
|
+
} catch {
|
|
268
|
+
return {}
|
|
269
|
+
}
|
|
270
|
+
sel.experience = out
|
|
271
|
+
persist(sel, join(input.workDir, 'selection', `${sel.mode}${sel.k}`))
|
|
272
|
+
return out
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Reads `<edits>` and `<experience>` blocks. A reply without a supported lesson gives only the edits. */
|
|
276
|
+
export function parseExperience(text: string): Partial<Experience> {
|
|
277
|
+
const edits = /<edits>([\s\S]*?)<\/edits>/.exec(text)?.[1]?.trim()
|
|
278
|
+
const block = /<experience>([\s\S]*?)<\/experience>/.exec(text)?.[1]
|
|
279
|
+
const out: Partial<Experience> = {}
|
|
280
|
+
if (block) {
|
|
281
|
+
for (const line of block.trim().split('\n')) {
|
|
282
|
+
const at = line.indexOf(':')
|
|
283
|
+
if (at < 0) continue
|
|
284
|
+
const key = line.slice(0, at).trim().toLowerCase()
|
|
285
|
+
if (key === 'cue' || key === 'lesson' || key === 'evidence' || key === 'confidence') out[key] = line.slice(at + 1).trim()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (!out.cue || !out.lesson || !out.evidence) return edits ? { edits } : {}
|
|
289
|
+
if (edits) out.edits = edits
|
|
290
|
+
return out
|
|
291
|
+
}
|