@tangle-network/agent-bench 0.3.6 → 0.3.7
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 +7 -0
- package/dist/adapters.js +2 -2
- package/dist/benchmarks/humaneval.d.ts +10 -1
- package/dist/benchmarks/humaneval.js +5 -3
- package/dist/{chunk-PPYSEKFM.js → chunk-5H5XV76F.js} +73 -15
- package/dist/chunk-5H5XV76F.js.map +1 -0
- package/dist/{chunk-5SBJCB6W.js → chunk-PWQVGAJB.js} +2 -2
- package/dist/index.js +2 -2
- package/package.json +5 -4
- package/scripts/run-package-tests.mjs +30 -8
- package/scripts/verify-pier-agent.mts +1 -0
- package/src/benchmarks/humaneval.test.mts +122 -0
- package/src/benchmarks/humaneval.ts +100 -27
- package/src/david-attribution.mts +78 -0
- package/src/david-goliath.mts +149 -0
- package/src/hev-improve.mts +25 -6
- package/src/humaneval-object-ablation.mts +201 -0
- package/src/live-improve-campaign-mbpp.mts +641 -0
- package/src/live-improve-campaign.mts +500 -0
- package/src/mbpp-structural.mts +12 -7
- package/src/stream-observe.py +45 -0
- package/src/stream-observe.tpl.html +247 -0
- package/src/supervisor-arena.mts +816 -0
- package/src/swe-arena/analyze.ts +211 -0
- package/src/swe-arena/arms.ts +788 -0
- package/src/swe-arena/bootstrap-meta.mts +188 -0
- package/src/swe-arena/bootstrap-meta.test.mts +51 -0
- package/src/swe-arena/calibrate.ts +116 -0
- package/src/swe-arena/capabilities.mts +76 -0
- package/src/swe-arena/capabilities.test.mts +57 -0
- package/src/swe-arena/capacity.ts +194 -0
- package/src/swe-arena/cell-evidence.mts +405 -0
- package/src/swe-arena/cell-evidence.test.mts +248 -0
- package/src/swe-arena/diagnosis-ensemble.test.mts +210 -0
- package/src/swe-arena/diagnosis-ensemble.ts +520 -0
- package/src/swe-arena/execution.test.mts +1170 -0
- package/src/swe-arena/fixtures/analyze.py +80 -0
- package/src/swe-arena/fixtures/excludes.txt +8 -0
- package/src/swe-arena/fixtures/gen1-salvage/README.md +45 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand0-e6d7361.diff +116 -0
- package/src/swe-arena/fixtures/gen1-salvage/cand1-76a8590.diff +293 -0
- package/src/swe-arena/fixtures/holdout-preregister.log +12 -0
- package/src/swe-arena/fixtures/holdout.json +44 -0
- package/src/swe-arena/fixtures/instances.json +146 -0
- package/src/swe-arena/fixtures/ledger.jsonl +12 -0
- package/src/swe-arena/fixtures/patches/pallets__flask-5014.solo.patch +36 -0
- package/src/swe-arena/fixtures/patches/pydata__xarray-4687.sup.patch +33 -0
- package/src/swe-arena/fixtures/rejudge.jsonl +15 -0
- package/src/swe-arena/fixtures/rematch.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch2.jsonl +3 -0
- package/src/swe-arena/fixtures/rematch3.jsonl +3 -0
- package/src/swe-arena/fixtures/sup-journal-true.json +19 -0
- package/src/swe-arena/fixtures/verify/astropy__astropy-13033.sh +48 -0
- package/src/swe-arena/fixtures/verify/django__django-11532.sh +50 -0
- package/src/swe-arena/fixtures/verify/matplotlib__matplotlib-20826.sh +76 -0
- package/src/swe-arena/fixtures/verify/pydata__xarray-4687.sh +44 -0
- package/src/swe-arena/fixtures/verify/pytest-dev__pytest-6197.sh +32 -0
- package/src/swe-arena/fixtures/verify/sphinx-doc__sphinx-9658.sh +51 -0
- package/src/swe-arena/fixtures/worker-tokens.json +42 -0
- package/src/swe-arena/fixtures.ts +104 -0
- package/src/swe-arena/holdout-certify.mts +408 -0
- package/src/swe-arena/holdout-certify.test.mts +160 -0
- package/src/swe-arena/judge-child.mts +37 -0
- package/src/swe-arena/manifest.mts +293 -0
- package/src/swe-arena/manifest.test.mts +169 -0
- package/src/swe-arena/materialize.ts +142 -0
- package/src/swe-arena/outer-loop.mts +2145 -0
- package/src/swe-arena/outer-loop.test.mts +696 -0
- package/src/swe-arena/parity.test.mts +87 -0
- package/src/swe-arena/proc.test.mts +174 -0
- package/src/swe-arena/proc.ts +260 -0
- package/src/swe-arena/profiles/default-author.profile.json +4 -0
- package/src/swe-arena/proposer-fanout.mts +489 -0
- package/src/swe-arena/proposer-fanout.test.mts +372 -0
- package/src/swe-arena/reconcile.ts +0 -0
- package/src/swe-arena/replay.mts +183 -0
- package/src/swe-arena/replay.test.mts +300 -0
- package/src/swe-arena/run-experiment.mts +361 -0
- package/src/swe-arena/run-supervisor.mjs +297 -0
- package/src/swe-arena/run-supervisor.test.mts +498 -0
- package/src/swe-arena/serialized-judge.ts +414 -0
- package/src/swe-arena/types.ts +166 -0
- package/src/swe-code-improve.mts +328 -0
- package/src/swe-emit-patch.mts +104 -0
- package/src/swe-improve.mts +232 -0
- package/src/swe-jail.ts +2 -2
- package/src/swe-local-proof.mts +169 -0
- package/src/swe-repro-calibrate.mts +446 -0
- package/src/swe-stream.mts +1497 -0
- package/dist/chunk-PPYSEKFM.js.map +0 -1
- /package/dist/{chunk-5SBJCB6W.js.map → chunk-PWQVGAJB.js.map} +0 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnosis ensemble — N BLIND failure analysts over the SAME supervisor-run
|
|
3
|
+
* artifacts (brain.jsonl, driver.log, workers/*.ndjson + *.patch, verify.log,
|
|
4
|
+
* result.json, the delivered patch, and the official-judge outcome), each a
|
|
5
|
+
* different model routed through router.tangle.tools, fused by union +
|
|
6
|
+
* cross-analyst agreement ranking. Minority findings SURVIVE fusion marked as
|
|
7
|
+
* competing hypotheses — the round-4 protocol treats them as candidate
|
|
8
|
+
* proposal seeds, not noise.
|
|
9
|
+
*
|
|
10
|
+
* Design constraints (from supervisor-lab .evolve/state.json round4_design):
|
|
11
|
+
* - the model list is CONFIG — glm-5.2 is the only model proven routed today;
|
|
12
|
+
* gpt-5.5 / opus-4.8 slot in by editing the analyst spec list, never by a
|
|
13
|
+
* hardcoded requirement on an unrouted model.
|
|
14
|
+
* - analysts are blind: same bundle, no cross-talk, independent calls.
|
|
15
|
+
* - secrets discipline matches capacity.ts: the API key is referenced by NAME
|
|
16
|
+
* inside a dotenvx child shell; the response body lands in a file (dotenvx
|
|
17
|
+
* writes its banner to stdout, so stdout is only trusted for the marker).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { type AnalystFinding, makeFinding } from '@tangle-network/agent-eval'
|
|
23
|
+
import { findSupervisorRunDir, type SecretsEnv } from './arms'
|
|
24
|
+
import { ROUTER_ENDPOINT, sleepWithSignal } from './capacity'
|
|
25
|
+
import { run } from './proc'
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Analyst specs.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export interface AnalystSpec {
|
|
32
|
+
/** Stable label, e.g. 'glm-5.2#1'. Used in fusion attribution + filenames. */
|
|
33
|
+
id: string
|
|
34
|
+
model: string
|
|
35
|
+
/** Chat-completions endpoint. Default: the Tangle router. */
|
|
36
|
+
url?: string
|
|
37
|
+
/** NAME of the env var holding the bearer key (resolved in the dotenvx child). */
|
|
38
|
+
apiKeyEnv?: string
|
|
39
|
+
/** glm-5.2 returns empty content when starved below ~8000, and at sampled
|
|
40
|
+
* temperatures its REASONING alone can consume a full 8000 ceiling (both
|
|
41
|
+
* measured — the calibration smoke saw out=8000 with empty content).
|
|
42
|
+
* Default 16_000 so reasoning + the JSON answer always fit. */
|
|
43
|
+
maxTokens?: number
|
|
44
|
+
temperature?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** N same-model analysts (the smoke default). Real rounds replace entries with
|
|
48
|
+
* other routed models — diversity comes from the config, never a hardcode.
|
|
49
|
+
* Same-model analysts at temperature 0 are byte-identical duplicates (measured
|
|
50
|
+
* in the calibration smoke: #2 and #3 returned the same 2823 output tokens),
|
|
51
|
+
* which silently inflates agreement — so only the FIRST same-model analyst
|
|
52
|
+
* runs at 0; the rest sample at 0.7 to buy real diversity. */
|
|
53
|
+
export function defaultAnalysts(n = 3, model = 'glm-5.2'): AnalystSpec[] {
|
|
54
|
+
return Array.from({ length: n }, (_, i) => ({
|
|
55
|
+
id: `${model}#${i + 1}`,
|
|
56
|
+
model,
|
|
57
|
+
temperature: i === 0 ? 0 : 0.7,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Artifact bundle — the ONE shared context every blind analyst reads.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** One supervisor arm run to diagnose. `dir` is the run dir layout the arms
|
|
66
|
+
* write: brain.jsonl / driver.log / result.json / verify.log / ws/. */
|
|
67
|
+
export interface SupRunArtifacts {
|
|
68
|
+
iid: string
|
|
69
|
+
arm: string
|
|
70
|
+
dir: string
|
|
71
|
+
/** The delivered (extracted) arm patch, when it exists. */
|
|
72
|
+
patchPath?: string
|
|
73
|
+
/** Official-judge outcome for this run, when known. `resolved: null` =
|
|
74
|
+
* inconclusive judge — shown to analysts as such, never coerced. */
|
|
75
|
+
judge?: { resolved: boolean | null; score?: number; note?: string }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BundleOptions {
|
|
79
|
+
/** Total bundle ceiling (chars). Default 60_000 (~15-20k tokens). */
|
|
80
|
+
maxChars?: number
|
|
81
|
+
/** Max workers whose ndjson/patch are excerpted per run. Default 6. */
|
|
82
|
+
maxWorkers?: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const head = (s: string, n: number): string => (s.length <= n ? s : `${s.slice(0, n)}\n…[truncated head]`)
|
|
86
|
+
const tail = (s: string, n: number): string => (s.length <= n ? s : `…[truncated tail]\n${s.slice(-n)}`)
|
|
87
|
+
|
|
88
|
+
async function safeRead(path: string): Promise<string> {
|
|
89
|
+
return readFile(path, 'utf8').catch(() => '')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function section(title: string, body: string): string {
|
|
93
|
+
const trimmed = body.trim()
|
|
94
|
+
if (trimmed.length === 0) return ''
|
|
95
|
+
return `--- ${title} ---\n${trimmed}\n`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Build the bounded shared bundle. Every section is capped so one megabyte
|
|
99
|
+
* brain log cannot crowd out the patch the analysts must actually read. */
|
|
100
|
+
export async function buildArtifactBundle(
|
|
101
|
+
runs: SupRunArtifacts[],
|
|
102
|
+
opts: BundleOptions = {},
|
|
103
|
+
): Promise<string> {
|
|
104
|
+
const maxChars = opts.maxChars ?? 60_000
|
|
105
|
+
const maxWorkers = opts.maxWorkers ?? 6
|
|
106
|
+
const parts: string[] = []
|
|
107
|
+
for (const r of runs) {
|
|
108
|
+
const chunks: string[] = [`### RUN ${r.iid} arm=${r.arm}\nrun dir: ${r.dir}`]
|
|
109
|
+
if (r.judge) {
|
|
110
|
+
chunks.push(
|
|
111
|
+
`official judge: resolved=${r.judge.resolved === null ? 'INCONCLUSIVE' : r.judge.resolved}` +
|
|
112
|
+
(r.judge.score !== undefined ? ` score=${r.judge.score}` : '') +
|
|
113
|
+
(r.judge.note ? ` (${r.judge.note})` : ''),
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
chunks.push(section('result.json', head(await safeRead(join(r.dir, 'result.json')), 1_500)))
|
|
117
|
+
chunks.push(section('verify.log (tail)', tail(await safeRead(join(r.dir, 'verify.log')), 2_000)))
|
|
118
|
+
const driver = await safeRead(join(r.dir, 'driver.log'))
|
|
119
|
+
chunks.push(section('driver.log (head)', head(driver, 800)))
|
|
120
|
+
chunks.push(section('driver.log (tail)', tail(driver, 4_000)))
|
|
121
|
+
chunks.push(section('brain.jsonl (tail)', tail(await safeRead(join(r.dir, 'brain.jsonl')), 4_000)))
|
|
122
|
+
|
|
123
|
+
const supRunDir = await findSupervisorRunDir(join(r.dir, 'ws'))
|
|
124
|
+
if (supRunDir) {
|
|
125
|
+
chunks.push(section('supervisor state.json', head(await safeRead(join(supRunDir, 'state.json')), 3_500)))
|
|
126
|
+
chunks.push(section('supervisor journal.jsonl (tail)', tail(await safeRead(join(supRunDir, 'journal.jsonl')), 3_000)))
|
|
127
|
+
const workerFiles = (await readdir(join(supRunDir, 'workers')).catch(() => [] as string[])).sort()
|
|
128
|
+
const ndjson = workerFiles.filter((f) => f.endsWith('.ndjson')).slice(0, maxWorkers)
|
|
129
|
+
const patches = workerFiles.filter((f) => f.endsWith('.patch')).slice(0, maxWorkers)
|
|
130
|
+
for (const f of ndjson) {
|
|
131
|
+
chunks.push(section(`workers/${f} (tail)`, tail(await safeRead(join(supRunDir, 'workers', f)), 2_500)))
|
|
132
|
+
}
|
|
133
|
+
for (const f of patches) {
|
|
134
|
+
chunks.push(section(`workers/${f} (head)`, head(await safeRead(join(supRunDir, 'workers', f)), 3_000)))
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (r.patchPath) {
|
|
138
|
+
chunks.push(section('DELIVERED PATCH (head)', head(await safeRead(r.patchPath), 6_000)))
|
|
139
|
+
}
|
|
140
|
+
parts.push(chunks.filter(Boolean).join('\n'))
|
|
141
|
+
}
|
|
142
|
+
const bundle = parts.join('\n\n')
|
|
143
|
+
if (bundle.length <= maxChars) return bundle
|
|
144
|
+
// Keep the head (earliest runs) and the tail (latest run's patch) — the
|
|
145
|
+
// middle is the least diagnostic. Marked loudly so analysts know.
|
|
146
|
+
const keep = Math.floor(maxChars / 2)
|
|
147
|
+
return `${bundle.slice(0, keep)}\n\n…[BUNDLE TRUNCATED: ${bundle.length - maxChars} chars removed]…\n\n${bundle.slice(-keep)}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// One blind analyst call.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export interface AnalystRawFinding {
|
|
155
|
+
failure_class: string
|
|
156
|
+
evidence_quote: string
|
|
157
|
+
proposed_direction: string
|
|
158
|
+
/** 0..1, clamped. Defaults to 0.5 when the model omits it. */
|
|
159
|
+
confidence: number
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface AnalystReport {
|
|
163
|
+
analystId: string
|
|
164
|
+
model: string
|
|
165
|
+
ok: boolean
|
|
166
|
+
findings: AnalystRawFinding[]
|
|
167
|
+
error?: string
|
|
168
|
+
/** Raw model text, kept for the audit trail. */
|
|
169
|
+
rawText?: string
|
|
170
|
+
tokens?: { input: number; output: number }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The blind-analyst instruction. Exported so the calibration smoke and tests
|
|
174
|
+
* pin the exact contract the parser expects. */
|
|
175
|
+
export function analystPrompt(bundle: string): string {
|
|
176
|
+
return [
|
|
177
|
+
'You are one independent, BLIND failure analyst (other analysts see the same artifacts; you cannot see them).',
|
|
178
|
+
'The artifacts below come from runs of an automated software-engineering SUPERVISOR agent on SWE-bench Verified instances:',
|
|
179
|
+
'- a supervisor "brain" plans, spawns sandboxed workers (each in a clone of the instance repo), and settles a delivered patch;',
|
|
180
|
+
'- each run has a self-authored verify script (worker-visible reproduction check);',
|
|
181
|
+
'- after the run locks, the OFFICIAL hidden maintainer test suite grades the delivered patch (worker-blind);',
|
|
182
|
+
'- "verify_pass=true but official resolved=false" means the self-check passed while the maintainers\' tests did not.',
|
|
183
|
+
'',
|
|
184
|
+
'Diagnose the DOMINANT reasons these runs failed to resolve. Ground every finding in a verbatim quote from the artifacts.',
|
|
185
|
+
'Respond with STRICT JSON only (no markdown fences, no commentary):',
|
|
186
|
+
'{"findings":[{"failure_class":"2-6 word category","evidence_quote":"verbatim from artifacts","proposed_direction":"concrete change direction","confidence":0.0}]}',
|
|
187
|
+
'Return 1 to 5 findings, most important first.',
|
|
188
|
+
'',
|
|
189
|
+
'===== ARTIFACTS =====',
|
|
190
|
+
bundle,
|
|
191
|
+
].join('\n')
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Extract + validate the strict-JSON findings contract from model text.
|
|
195
|
+
* Tolerates code fences and leading/trailing prose; throws on anything that
|
|
196
|
+
* does not contain one parseable findings object (the caller records the
|
|
197
|
+
* analyst as failed — never a silently-empty diagnosis). */
|
|
198
|
+
export function parseAnalystFindings(text: string): AnalystRawFinding[] {
|
|
199
|
+
const stripped = text.replace(/```(?:json)?/gi, '').trim()
|
|
200
|
+
const start = stripped.indexOf('{')
|
|
201
|
+
if (start === -1) throw new Error('analyst output contains no JSON object')
|
|
202
|
+
// Balanced-brace scan from the first '{' — models love trailing prose.
|
|
203
|
+
let depth = 0
|
|
204
|
+
let end = -1
|
|
205
|
+
let inString = false
|
|
206
|
+
let escaped = false
|
|
207
|
+
for (let i = start; i < stripped.length; i++) {
|
|
208
|
+
const ch = stripped[i]
|
|
209
|
+
if (inString) {
|
|
210
|
+
if (escaped) escaped = false
|
|
211
|
+
else if (ch === '\\') escaped = true
|
|
212
|
+
else if (ch === '"') inString = false
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
if (ch === '"') inString = true
|
|
216
|
+
else if (ch === '{') depth += 1
|
|
217
|
+
else if (ch === '}') {
|
|
218
|
+
depth -= 1
|
|
219
|
+
if (depth === 0) {
|
|
220
|
+
end = i
|
|
221
|
+
break
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (end === -1) throw new Error('analyst output JSON object never closes')
|
|
226
|
+
let parsed: unknown
|
|
227
|
+
try {
|
|
228
|
+
parsed = JSON.parse(stripped.slice(start, end + 1))
|
|
229
|
+
} catch (cause) {
|
|
230
|
+
throw new Error(`analyst output is not valid JSON: ${(cause as Error).message}`)
|
|
231
|
+
}
|
|
232
|
+
const findings = (parsed as { findings?: unknown }).findings
|
|
233
|
+
if (!Array.isArray(findings) || findings.length === 0) {
|
|
234
|
+
throw new Error('analyst output has no findings array')
|
|
235
|
+
}
|
|
236
|
+
return findings.map((f, i) => {
|
|
237
|
+
const o = f as Record<string, unknown>
|
|
238
|
+
const failureClass = typeof o.failure_class === 'string' ? o.failure_class.trim() : ''
|
|
239
|
+
if (!failureClass) throw new Error(`finding ${i} missing failure_class`)
|
|
240
|
+
const conf = typeof o.confidence === 'number' && Number.isFinite(o.confidence) ? o.confidence : 0.5
|
|
241
|
+
return {
|
|
242
|
+
failure_class: failureClass,
|
|
243
|
+
evidence_quote: typeof o.evidence_quote === 'string' ? o.evidence_quote : '',
|
|
244
|
+
proposed_direction: typeof o.proposed_direction === 'string' ? o.proposed_direction : '',
|
|
245
|
+
confidence: Math.min(1, Math.max(0, conf)),
|
|
246
|
+
}
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** POST one blind analyst call through the router via dotenvx (key stays in
|
|
251
|
+
* the child; response body lands in `outFile`, never on shared stdout).
|
|
252
|
+
* ONE retry on transport failure (5xx/524/timeout) — an edge flake was
|
|
253
|
+
* observed live in the calibration smoke; a parse failure is NOT retried
|
|
254
|
+
* (same prompt, same model ⇒ same bad shape). */
|
|
255
|
+
export async function runAnalyst(
|
|
256
|
+
spec: AnalystSpec,
|
|
257
|
+
bundle: string,
|
|
258
|
+
secrets: SecretsEnv,
|
|
259
|
+
scratchDir: string,
|
|
260
|
+
opts: { timeoutMs?: number; retries?: number; retryDelayMs?: number; signal?: AbortSignal } = {},
|
|
261
|
+
): Promise<AnalystReport> {
|
|
262
|
+
opts.signal?.throwIfAborted()
|
|
263
|
+
const apiKeyEnv = spec.apiKeyEnv ?? 'TANGLE_API_KEY'
|
|
264
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(apiKeyEnv)) {
|
|
265
|
+
return { analystId: spec.id, model: spec.model, ok: false, findings: [], error: `invalid apiKeyEnv name: ${apiKeyEnv}` }
|
|
266
|
+
}
|
|
267
|
+
const url = spec.url ?? ROUTER_ENDPOINT
|
|
268
|
+
const body = JSON.stringify({
|
|
269
|
+
model: spec.model,
|
|
270
|
+
messages: [{ role: 'user', content: analystPrompt(bundle) }],
|
|
271
|
+
temperature: spec.temperature ?? 0,
|
|
272
|
+
max_tokens: spec.maxTokens ?? 16_000,
|
|
273
|
+
})
|
|
274
|
+
opts.signal?.throwIfAborted()
|
|
275
|
+
await mkdir(scratchDir, { recursive: true })
|
|
276
|
+
const outFile = join(scratchDir, `analyst-${spec.id.replace(/[^a-zA-Z0-9._-]/g, '_')}.response.json`)
|
|
277
|
+
const timeoutMs = opts.timeoutMs ?? 600_000
|
|
278
|
+
// Body via stdin (--data @-) so the payload never sits on a command line;
|
|
279
|
+
// the HTTP code is marker-anchored on stdout (dotenvx banners share stdout).
|
|
280
|
+
const script =
|
|
281
|
+
`curl -sS -o "$DIAG_OUT" -w "HTTP_CODE=%{http_code}" --max-time ${Math.ceil(timeoutMs / 1000) - 20} ` +
|
|
282
|
+
`-X POST "$DIAG_URL" -H "Authorization: Bearer $${apiKeyEnv}" -H "Content-Type: application/json" --data @-`
|
|
283
|
+
const argv = ['run', ...secrets.envFiles.flatMap((f) => ['-f', f]), '--', 'bash', '-c', script]
|
|
284
|
+
const attempts = 1 + (opts.retries ?? 1)
|
|
285
|
+
let transportError = ''
|
|
286
|
+
let transported = false
|
|
287
|
+
for (let attempt = 0; attempt < attempts && !transported; attempt++) {
|
|
288
|
+
opts.signal?.throwIfAborted()
|
|
289
|
+
if (attempt > 0) await sleepWithSignal(opts.retryDelayMs ?? 5_000, opts.signal)
|
|
290
|
+
const res = await run('dotenvx', argv, {
|
|
291
|
+
cwd: secrets.secretsDir,
|
|
292
|
+
timeoutMs,
|
|
293
|
+
stdin: body,
|
|
294
|
+
env: { ...process.env, DIAG_URL: url, DIAG_OUT: outFile },
|
|
295
|
+
signal: opts.signal,
|
|
296
|
+
})
|
|
297
|
+
opts.signal?.throwIfAborted()
|
|
298
|
+
const codeMatch = res.stdout.match(/HTTP_CODE=(\d{3})\s*$/)
|
|
299
|
+
if (codeMatch && codeMatch[1] === '200') {
|
|
300
|
+
transported = true
|
|
301
|
+
} else {
|
|
302
|
+
transportError = `router call failed (http=${codeMatch?.[1] ?? 'none'}, rc=${res.code}${res.timedOut ? ', timeout' : ''}, attempt=${attempt + 1}/${attempts})`
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!transported) {
|
|
306
|
+
return { analystId: spec.id, model: spec.model, ok: false, findings: [], error: transportError }
|
|
307
|
+
}
|
|
308
|
+
let content = ''
|
|
309
|
+
let tokens: AnalystReport['tokens']
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(await readFile(outFile, 'utf8')) as {
|
|
312
|
+
choices?: Array<{ message?: { content?: string } }>
|
|
313
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number }
|
|
314
|
+
}
|
|
315
|
+
content = parsed.choices?.[0]?.message?.content ?? ''
|
|
316
|
+
if (parsed.usage) {
|
|
317
|
+
tokens = { input: parsed.usage.prompt_tokens ?? 0, output: parsed.usage.completion_tokens ?? 0 }
|
|
318
|
+
}
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
return { analystId: spec.id, model: spec.model, ok: false, findings: [], error: `unparseable response body: ${(cause as Error).message}` }
|
|
321
|
+
}
|
|
322
|
+
if (content.trim().length === 0) {
|
|
323
|
+
return { analystId: spec.id, model: spec.model, ok: false, findings: [], error: 'empty content (max_tokens starvation?)', tokens }
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const findings = parseAnalystFindings(content)
|
|
327
|
+
return { analystId: spec.id, model: spec.model, ok: true, findings, rawText: content, tokens }
|
|
328
|
+
} catch (cause) {
|
|
329
|
+
return { analystId: spec.id, model: spec.model, ok: false, findings: [], error: (cause as Error).message, rawText: content, tokens }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Fusion — union + agreement rank; minority findings survive flagged.
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export interface FusedFinding {
|
|
338
|
+
/** Representative label (the first-seen member's failure_class). */
|
|
339
|
+
failure_class: string
|
|
340
|
+
/** Distinct analysts whose findings landed in this cluster. */
|
|
341
|
+
analysts: string[]
|
|
342
|
+
agreement: number
|
|
343
|
+
/** True when only ONE analyst surfaced it while others reported ok — a
|
|
344
|
+
* surviving minority view, kept as an explicit competing hypothesis. */
|
|
345
|
+
competingHypothesis: boolean
|
|
346
|
+
meanConfidence: number
|
|
347
|
+
evidence: Array<{ analyst: string; quote: string }>
|
|
348
|
+
directions: Array<{ analyst: string; direction: string }>
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const CLASS_STOPWORDS = new Set([
|
|
352
|
+
'the', 'a', 'an', 'of', 'in', 'on', 'to', 'and', 'or', 'is', 'for', 'with', 'by', 'at', 'not', 'no',
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
/** Normalized token set of a failure-class label. */
|
|
356
|
+
export function classTokens(label: string): string[] {
|
|
357
|
+
return [
|
|
358
|
+
...new Set(
|
|
359
|
+
label
|
|
360
|
+
.toLowerCase()
|
|
361
|
+
.split(/[^a-z0-9]+/)
|
|
362
|
+
.filter((t) => t.length > 1 && !CLASS_STOPWORDS.has(t)),
|
|
363
|
+
),
|
|
364
|
+
].sort()
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Jaccard similarity of two class labels' token sets. */
|
|
368
|
+
export function classSimilarity(a: string, b: string): number {
|
|
369
|
+
const ta = classTokens(a)
|
|
370
|
+
const tb = new Set(classTokens(b))
|
|
371
|
+
if (ta.length === 0 || tb.size === 0) return 0
|
|
372
|
+
let inter = 0
|
|
373
|
+
for (const t of ta) if (tb.has(t)) inter += 1
|
|
374
|
+
return inter / (ta.length + tb.size - inter)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Fuse per-analyst findings: greedy clustering on failure-class similarity
|
|
379
|
+
* (>= threshold joins the first matching cluster), then rank by agreement
|
|
380
|
+
* (desc) and mean confidence (desc). Every input finding survives — union, not
|
|
381
|
+
* intersection; a single-analyst cluster is marked `competingHypothesis` when
|
|
382
|
+
* at least one OTHER analyst produced a successful report.
|
|
383
|
+
*/
|
|
384
|
+
export function fuseFindings(
|
|
385
|
+
reports: AnalystReport[],
|
|
386
|
+
opts: { similarityThreshold?: number } = {},
|
|
387
|
+
): FusedFinding[] {
|
|
388
|
+
const threshold = opts.similarityThreshold ?? 0.5
|
|
389
|
+
const okReports = reports.filter((r) => r.ok)
|
|
390
|
+
interface Cluster {
|
|
391
|
+
representative: string
|
|
392
|
+
members: Array<{ analyst: string; finding: AnalystRawFinding }>
|
|
393
|
+
}
|
|
394
|
+
const clusters: Cluster[] = []
|
|
395
|
+
for (const report of okReports) {
|
|
396
|
+
for (const finding of report.findings) {
|
|
397
|
+
const match = clusters.find((c) => classSimilarity(c.representative, finding.failure_class) >= threshold)
|
|
398
|
+
if (match) {
|
|
399
|
+
match.members.push({ analyst: report.analystId, finding })
|
|
400
|
+
} else {
|
|
401
|
+
clusters.push({ representative: finding.failure_class, members: [{ analyst: report.analystId, finding }] })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const fused = clusters.map((c): FusedFinding => {
|
|
406
|
+
const analysts = [...new Set(c.members.map((m) => m.analyst))]
|
|
407
|
+
return {
|
|
408
|
+
failure_class: c.representative,
|
|
409
|
+
analysts,
|
|
410
|
+
agreement: analysts.length,
|
|
411
|
+
competingHypothesis: analysts.length === 1 && okReports.length > 1,
|
|
412
|
+
meanConfidence:
|
|
413
|
+
c.members.reduce((s, m) => s + m.finding.confidence, 0) / c.members.length,
|
|
414
|
+
evidence: c.members
|
|
415
|
+
.filter((m) => m.finding.evidence_quote.length > 0)
|
|
416
|
+
.map((m) => ({ analyst: m.analyst, quote: m.finding.evidence_quote })),
|
|
417
|
+
directions: c.members
|
|
418
|
+
.filter((m) => m.finding.proposed_direction.length > 0)
|
|
419
|
+
.map((m) => ({ analyst: m.analyst, direction: m.finding.proposed_direction })),
|
|
420
|
+
}
|
|
421
|
+
})
|
|
422
|
+
return fused.sort((a, b) => b.agreement - a.agreement || b.meanConfidence - a.meanConfidence)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// The ensemble.
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
export interface DiagnosisEnsembleResult {
|
|
430
|
+
bundleChars: number
|
|
431
|
+
reports: AnalystReport[]
|
|
432
|
+
fused: FusedFinding[]
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Run every analyst (sequentially — gentle on the shared router key) over the
|
|
436
|
+
* SAME bundle and fuse. A failed analyst is recorded, never fabricated. */
|
|
437
|
+
export async function runDiagnosisEnsemble(input: {
|
|
438
|
+
analysts: AnalystSpec[]
|
|
439
|
+
runs: SupRunArtifacts[]
|
|
440
|
+
secrets: SecretsEnv
|
|
441
|
+
scratchDir: string
|
|
442
|
+
maxBundleChars?: number
|
|
443
|
+
timeoutMsPerAnalyst?: number
|
|
444
|
+
/** Transport retries per analyst (router 524 storms are a measured, known
|
|
445
|
+
* infra class — the loops brain itself runs with LOOPS_BRAIN_RETRIES=30). */
|
|
446
|
+
retriesPerAnalyst?: number
|
|
447
|
+
retryDelayMs?: number
|
|
448
|
+
onStatus?: (msg: string) => void
|
|
449
|
+
signal?: AbortSignal
|
|
450
|
+
}): Promise<DiagnosisEnsembleResult> {
|
|
451
|
+
input.signal?.throwIfAborted()
|
|
452
|
+
if (input.analysts.length === 0) throw new Error('diagnosis ensemble: no analysts configured')
|
|
453
|
+
if (input.runs.length === 0) throw new Error('diagnosis ensemble: no run artifacts to diagnose')
|
|
454
|
+
const bundle = await buildArtifactBundle(
|
|
455
|
+
input.runs,
|
|
456
|
+
input.maxBundleChars !== undefined ? { maxChars: input.maxBundleChars } : {},
|
|
457
|
+
)
|
|
458
|
+
input.signal?.throwIfAborted()
|
|
459
|
+
await mkdir(input.scratchDir, { recursive: true })
|
|
460
|
+
await writeFile(join(input.scratchDir, 'bundle.txt'), bundle)
|
|
461
|
+
input.signal?.throwIfAborted()
|
|
462
|
+
const reports: AnalystReport[] = []
|
|
463
|
+
for (const spec of input.analysts) {
|
|
464
|
+
input.signal?.throwIfAborted()
|
|
465
|
+
input.onStatus?.(`analyst ${spec.id} (${spec.model}) reading ${bundle.length} chars…`)
|
|
466
|
+
const report = await runAnalyst(spec, bundle, input.secrets, input.scratchDir, {
|
|
467
|
+
...(input.timeoutMsPerAnalyst !== undefined ? { timeoutMs: input.timeoutMsPerAnalyst } : {}),
|
|
468
|
+
...(input.retriesPerAnalyst !== undefined ? { retries: input.retriesPerAnalyst } : {}),
|
|
469
|
+
...(input.retryDelayMs !== undefined ? { retryDelayMs: input.retryDelayMs } : {}),
|
|
470
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
471
|
+
})
|
|
472
|
+
input.signal?.throwIfAborted()
|
|
473
|
+
input.onStatus?.(
|
|
474
|
+
`analyst ${spec.id}: ${report.ok ? `${report.findings.length} finding(s)` : `FAILED (${report.error})`}` +
|
|
475
|
+
(report.tokens ? ` [tokens in=${report.tokens.input} out=${report.tokens.output}]` : ''),
|
|
476
|
+
)
|
|
477
|
+
reports.push(report)
|
|
478
|
+
}
|
|
479
|
+
return { bundleChars: bundle.length, reports, fused: fuseFindings(reports) }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Calibration grading: does a finding surface FIX PLACEMENT (the known truth
|
|
483
|
+
* of the round-2 django run — a locally-authored helper where the gold fix
|
|
484
|
+
* extends django/utils/encoding.py)? Assistive keyword net; the smoke always
|
|
485
|
+
* prints the raw findings so a miss here is auditable, never load-bearing. */
|
|
486
|
+
export function surfacesPlacementRegex(): RegExp {
|
|
487
|
+
return /placement|misplac|wrong\s+(file|location|module|place)|different\s+(file|location|module)|expected\s+(location|file|module)|local\s+(helper|copy|implementation)|duplicat|encoding\.py|django\.utils\.encoding|utils\/encoding|belongs\s+in|should\s+(live|be\s+placed|be\s+located|go)\s+in|reimplement|instead\s+of\s+(the\s+)?(existing|shared|upstream)/i
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Map fused findings onto the substrate's `AnalystFinding` envelope so they
|
|
491
|
+
* drop into the same `analyzeGeneration` slot the raw-trace distiller uses. */
|
|
492
|
+
export function fusedToAnalystFindings(
|
|
493
|
+
fused: FusedFinding[],
|
|
494
|
+
evidence: { dirs: string[]; totalAnalysts: number },
|
|
495
|
+
): AnalystFinding[] {
|
|
496
|
+
return fused.map((f) => {
|
|
497
|
+
const directions = f.directions.map((d) => `[${d.analyst}] ${d.direction}`).join(' | ')
|
|
498
|
+
const quotes = f.evidence
|
|
499
|
+
.slice(0, 3)
|
|
500
|
+
.map((e) => `[${e.analyst}] "${e.quote.slice(0, 240)}"`)
|
|
501
|
+
.join('; ')
|
|
502
|
+
return makeFinding({
|
|
503
|
+
analyst_id: 'diagnosis-ensemble',
|
|
504
|
+
severity: f.agreement >= 2 ? 'high' : 'medium',
|
|
505
|
+
area: 'failure-diagnosis',
|
|
506
|
+
confidence: Math.min(1, Math.max(0, f.meanConfidence)),
|
|
507
|
+
claim:
|
|
508
|
+
`${f.competingHypothesis ? '[competing hypothesis] ' : ''}` +
|
|
509
|
+
`(${f.agreement}/${evidence.totalAnalysts} analysts) ${f.failure_class}` +
|
|
510
|
+
(quotes ? ` — evidence: ${quotes}` : ''),
|
|
511
|
+
...(directions ? { recommended_action: directions } : {}),
|
|
512
|
+
evidence_refs: evidence.dirs.map((d) => ({ kind: 'artifact' as const, uri: d })),
|
|
513
|
+
metadata: {
|
|
514
|
+
agreement: f.agreement,
|
|
515
|
+
competingHypothesis: f.competingHypothesis,
|
|
516
|
+
analysts: f.analysts,
|
|
517
|
+
},
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
}
|