@tduyng/pi-harness-loop 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +178 -0
- package/README.md +380 -2
- package/package.json +41 -4
- package/src/apply.ts +257 -0
- package/src/atomic.ts +7 -0
- package/src/auto.ts +483 -0
- package/src/check.ts +73 -0
- package/src/coordination.ts +30 -0
- package/src/corpus.ts +641 -0
- package/src/diversity.ts +73 -0
- package/src/effect.ts +134 -0
- package/src/evaluate.ts +81 -0
- package/src/findings.ts +141 -0
- package/src/index.ts +761 -0
- package/src/install.ts +297 -0
- package/src/ledger.ts +89 -0
- package/src/lesson-tool.ts +39 -0
- package/src/lint.ts +87 -0
- package/src/match.ts +192 -0
- package/src/producer.ts +236 -0
- package/src/prompt.ts +162 -0
- package/src/replay.ts +173 -0
- package/src/store.ts +268 -0
- package/src/target.ts +76 -0
- package/src/transcript.ts +139 -0
- package/src/tree.ts +307 -0
- package/src/types.ts +77 -0
- package/src/writer.ts +134 -0
package/src/effect.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { SignatureUse } from './corpus.js'
|
|
2
|
+
import type { Install } from './types.js'
|
|
3
|
+
|
|
4
|
+
export interface Window {
|
|
5
|
+
sessions: number
|
|
6
|
+
occurrences: number
|
|
7
|
+
perSession: number
|
|
8
|
+
/** Median calls of every tool per session. Absent when the cache never counted tool calls. */
|
|
9
|
+
callsPerSession?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type Verdict = 'held' | 'flat' | 'worse' | 'early' | 'out-of-window' | 'confounded' | 'avoided'
|
|
13
|
+
|
|
14
|
+
export interface Effect {
|
|
15
|
+
install: string
|
|
16
|
+
finding: string
|
|
17
|
+
key: string
|
|
18
|
+
before: Window
|
|
19
|
+
after: Window
|
|
20
|
+
verdict: Verdict
|
|
21
|
+
commit?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type Confound = (key: string, since: number) => string | null
|
|
25
|
+
|
|
26
|
+
export const HELD_RATIO = 0.75
|
|
27
|
+
export const WORSE_RATIO = 1.25
|
|
28
|
+
export const MIN_SESSIONS_AFTER = 3
|
|
29
|
+
/** The session has to do at least this much of its old work for the failure's fall to be the part's. */
|
|
30
|
+
export const USED_RATIO = 0.5
|
|
31
|
+
|
|
32
|
+
/** A session can do no work at all, so one session does not move the middle of the window. */
|
|
33
|
+
function median(counts: number[]): number {
|
|
34
|
+
const sorted = counts.toSorted((a, b) => a - b)
|
|
35
|
+
const middle = Math.floor(sorted.length / 2)
|
|
36
|
+
if (sorted.length % 2 === 1) return sorted[middle] ?? 0
|
|
37
|
+
return ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function windowOf(uses: SignatureUse[], at: number, side: 'before' | 'after', calls?: SignatureUse[]): Window {
|
|
41
|
+
const picked = uses.filter((use) => (side === 'before' ? use.at < at : use.at >= at))
|
|
42
|
+
const occurrences = picked.reduce((total, use) => total + use.count, 0)
|
|
43
|
+
const callPicked = calls?.filter((use) => (side === 'before' ? use.at < at : use.at >= at))
|
|
44
|
+
const window: Window = {
|
|
45
|
+
sessions: picked.length,
|
|
46
|
+
occurrences,
|
|
47
|
+
perSession: picked.length === 0 ? 0 : occurrences / picked.length
|
|
48
|
+
}
|
|
49
|
+
if (callPicked !== undefined && callPicked.length > 0) {
|
|
50
|
+
window.callsPerSession = median(callPicked.map((use) => use.count))
|
|
51
|
+
}
|
|
52
|
+
return window
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A failure that stopped because the agent stopped working was not prevented. The rate of the
|
|
57
|
+
* failure fell, and the work the session did fell with it, so the part is not what carried.
|
|
58
|
+
*/
|
|
59
|
+
function avoided(before: Window, after: Window): boolean {
|
|
60
|
+
const was = before.callsPerSession
|
|
61
|
+
const now = after.callsPerSession
|
|
62
|
+
if (was === undefined || now === undefined || was === 0) return false
|
|
63
|
+
return now < was * USED_RATIO
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A part is a claim about the future. The sessions after it are the only evidence
|
|
68
|
+
* the loop has, so the claim is read against them: fewer failures a session is a
|
|
69
|
+
* part that held, and the same rate or worse is a part that did not.
|
|
70
|
+
*/
|
|
71
|
+
export function verdictOf(before: Window, after: Window): Verdict {
|
|
72
|
+
if (before.sessions === 0) return 'out-of-window'
|
|
73
|
+
if (after.sessions < MIN_SESSIONS_AFTER) return 'early'
|
|
74
|
+
if (before.perSession === 0) return after.occurrences === 0 ? 'held' : 'worse'
|
|
75
|
+
const ratio = after.perSession / before.perSession
|
|
76
|
+
if (ratio > HELD_RATIO) return ratio >= WORSE_RATIO ? 'worse' : 'flat'
|
|
77
|
+
return avoided(before, after) ? 'avoided' : 'held'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function effectFor(
|
|
81
|
+
installs: Install[],
|
|
82
|
+
history: Map<string, SignatureUse[]>,
|
|
83
|
+
finding: string,
|
|
84
|
+
key: string,
|
|
85
|
+
confound?: Confound,
|
|
86
|
+
calls?: SignatureUse[]
|
|
87
|
+
): Effect | null {
|
|
88
|
+
const install = installs
|
|
89
|
+
.filter((entry) => entry.ok && !entry.reverted && entry.finding === finding)
|
|
90
|
+
.toSorted((a, b) => b.at - a.at)[0]
|
|
91
|
+
if (install === undefined) return null
|
|
92
|
+
const uses = history.get(install.key ?? key)
|
|
93
|
+
if (uses === undefined) return null
|
|
94
|
+
const before = windowOf(uses, install.at, 'before', calls)
|
|
95
|
+
const after = windowOf(uses, install.at, 'after', calls)
|
|
96
|
+
const verdict = verdictOf(before, after)
|
|
97
|
+
const commit =
|
|
98
|
+
verdict === 'early' || verdict === 'out-of-window' ? null : confound?.(install.key ?? key, install.at)
|
|
99
|
+
return {
|
|
100
|
+
install: install.id,
|
|
101
|
+
finding,
|
|
102
|
+
key: install.key ?? key,
|
|
103
|
+
before,
|
|
104
|
+
after,
|
|
105
|
+
verdict: commit === null || commit === undefined ? verdict : 'confounded',
|
|
106
|
+
commit: commit ?? undefined
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One effect per finding, from the newest install. `keys` carries the signature the
|
|
112
|
+
* finding is about, for the installs written before the key was recorded.
|
|
113
|
+
*/
|
|
114
|
+
export function effectsOf(
|
|
115
|
+
installs: Install[],
|
|
116
|
+
history: Map<string, SignatureUse[]>,
|
|
117
|
+
keys: Map<string, string>,
|
|
118
|
+
confound?: Confound,
|
|
119
|
+
calls?: SignatureUse[]
|
|
120
|
+
): Effect[] {
|
|
121
|
+
const findings = new Set(installs.filter((entry) => entry.ok && !entry.reverted).map((entry) => entry.finding))
|
|
122
|
+
const out: Effect[] = []
|
|
123
|
+
for (const finding of findings) {
|
|
124
|
+
const key = keys.get(finding)
|
|
125
|
+
if (key === undefined) continue
|
|
126
|
+
const effect = effectFor(installs, history, finding, key, confound, calls)
|
|
127
|
+
if (effect !== null) out.push(effect)
|
|
128
|
+
}
|
|
129
|
+
return out.toSorted((a, b) => a.verdict.localeCompare(b.verdict) || a.finding.localeCompare(b.finding))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function rate(window: Window): string {
|
|
133
|
+
return `${window.perSession.toFixed(1)}/session`
|
|
134
|
+
}
|
package/src/evaluate.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { corpusCachePath, signatureHistory, callHistory } from './corpus.js'
|
|
2
|
+
import { effectsOf, rate, type Effect } from './effect.js'
|
|
3
|
+
import { commitInWindow } from './producer.js'
|
|
4
|
+
import { readScan } from './auto.js'
|
|
5
|
+
import { revertApplied } from './apply.js'
|
|
6
|
+
import { installRoots } from './install.js'
|
|
7
|
+
import { appendInstall, failLesson, readInstalls, readLessons } from './store.js'
|
|
8
|
+
import { appendSpend } from './ledger.js'
|
|
9
|
+
import type { InstallContext } from './apply.js'
|
|
10
|
+
import type { Install } from './types.js'
|
|
11
|
+
|
|
12
|
+
export function failLessonsFor(cwd: string, effect: Effect, installs: Install[]): void {
|
|
13
|
+
const linked = installs
|
|
14
|
+
.filter((install) => install.finding === effect.finding)
|
|
15
|
+
.map((install) => install.lesson)
|
|
16
|
+
.filter((id): id is string => id !== undefined)
|
|
17
|
+
const prefixed = readLessons(cwd)
|
|
18
|
+
.filter((lesson) => lesson.id.startsWith(effect.finding))
|
|
19
|
+
.map((lesson) => lesson.id)
|
|
20
|
+
for (const id of new Set([...linked, ...prefixed])) failLesson(cwd, id)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function evaluateInstalls(cwd: string): Effect[] {
|
|
24
|
+
const scan = readScan(cwd)
|
|
25
|
+
const keys = new Map(scan?.recurrences.map((recurrence) => [recurrence.id, recurrence.key]) ?? [])
|
|
26
|
+
const installs = readInstalls(cwd)
|
|
27
|
+
const effects = effectsOf(
|
|
28
|
+
installs,
|
|
29
|
+
signatureHistory(corpusCachePath(cwd)),
|
|
30
|
+
keys,
|
|
31
|
+
(key, since) => commitInWindow(cwd, key, since),
|
|
32
|
+
callHistory(corpusCachePath(cwd))
|
|
33
|
+
)
|
|
34
|
+
for (const effect of effects) {
|
|
35
|
+
if (effect.verdict === 'flat' || effect.verdict === 'worse' || effect.verdict === 'avoided') {
|
|
36
|
+
failLessonsFor(cwd, effect, installs)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return effects
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A part that did not lower the rate it was written for is taken back out. The install is
|
|
44
|
+
* always reversible because it records the file it replaced, so a rule nobody can show a use
|
|
45
|
+
* for does not stay in the always-on context.
|
|
46
|
+
*/
|
|
47
|
+
export function autoRevert(cwd: string, context: InstallContext, effects: Effect[]): string[] {
|
|
48
|
+
const installs = readInstalls(cwd)
|
|
49
|
+
const lines: string[] = []
|
|
50
|
+
for (const effect of effects) {
|
|
51
|
+
if (effect.verdict !== 'flat' && effect.verdict !== 'worse' && effect.verdict !== 'avoided') continue
|
|
52
|
+
const install = installs.find((entry) => entry.id === effect.install)
|
|
53
|
+
if (install === undefined || install.reverted) continue
|
|
54
|
+
const result = revertApplied(install.files, installRoots(context))
|
|
55
|
+
const kept = result.refused.length > 0
|
|
56
|
+
appendInstall(cwd, {
|
|
57
|
+
...install,
|
|
58
|
+
at: Date.now(),
|
|
59
|
+
ok: false,
|
|
60
|
+
reverted: true,
|
|
61
|
+
note: kept
|
|
62
|
+
? `the file changed since the install: ${result.refused[0]}`
|
|
63
|
+
: `auto-reverted, the part was ${effect.verdict} at ${rate(effect.after)}`
|
|
64
|
+
})
|
|
65
|
+
appendSpend(cwd, {
|
|
66
|
+
at: Date.now(),
|
|
67
|
+
target: effect.finding,
|
|
68
|
+
turns: 0,
|
|
69
|
+
input: 0,
|
|
70
|
+
output: 0,
|
|
71
|
+
cost: 0,
|
|
72
|
+
outcome: 'auto-reverted'
|
|
73
|
+
})
|
|
74
|
+
lines.push(
|
|
75
|
+
kept
|
|
76
|
+
? `harness: kept ${effect.finding}, the file changed since the install`
|
|
77
|
+
: `harness: reverted ${effect.finding}, the part was ${effect.verdict} at ${rate(effect.after)}`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
return lines
|
|
81
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Transcript } from './transcript.js'
|
|
2
|
+
import { idFor } from './corpus.js'
|
|
3
|
+
|
|
4
|
+
export type FindingKindName = 'error' | 'command' | 'read' | 'no-change' | 'unanswered'
|
|
5
|
+
|
|
6
|
+
export interface Finding {
|
|
7
|
+
id: string
|
|
8
|
+
kind: FindingKindName
|
|
9
|
+
title: string
|
|
10
|
+
count: number
|
|
11
|
+
evidence: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const REPEAT_THRESHOLD = 3
|
|
15
|
+
const MIN_SIGNATURE_TOKENS = 1
|
|
16
|
+
const GENERIC_SIGNATURE =
|
|
17
|
+
/^(script failed|command failed|error|failed|ok|done|total n|command aborted|command timed out after n seconds|traceback \(most recent call last\))[:!.]?$/i
|
|
18
|
+
const TRACEBACK = /^Traceback \(most recent call last\)/
|
|
19
|
+
const NOT_AN_ERROR = /not an error/i
|
|
20
|
+
const EXIT_LINE = /\n*command exited with code \d+\s*$/i
|
|
21
|
+
|
|
22
|
+
export function usableSignature(value: string): boolean {
|
|
23
|
+
if (GENERIC_SIGNATURE.test(value.trim())) return false
|
|
24
|
+
const tokens = value
|
|
25
|
+
.split(/\s+/)
|
|
26
|
+
.filter(
|
|
27
|
+
(token) => token !== '' && token !== '<path>' && !/^['"]?<path>['"]?$/.test(token) && !/^N+$/.test(token)
|
|
28
|
+
)
|
|
29
|
+
if (tokens.length < MIN_SIGNATURE_TOKENS) return false
|
|
30
|
+
return tokens.some((token) => /[a-zA-Z]{4,}/.test(token))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function signature(body: string): string {
|
|
34
|
+
const lines = body.split('\n').filter((line) => line.trim() !== '')
|
|
35
|
+
const head = lines[0] ?? ''
|
|
36
|
+
const pick = TRACEBACK.test(head) ? (lines[lines.length - 1] ?? head) : head
|
|
37
|
+
return pick
|
|
38
|
+
.replace(/[^\s'"]*\/[^\s'"]*/g, '<path>')
|
|
39
|
+
.replace(/\d+/g, 'N')
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim()
|
|
42
|
+
.slice(0, 90)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function failureSignature(body: string): string {
|
|
46
|
+
if (NOT_AN_ERROR.test(body)) return ''
|
|
47
|
+
const output = body.replace(EXIT_LINE, '').trim()
|
|
48
|
+
if (output === '' || output === '(no output)') return ''
|
|
49
|
+
const key = signature(output)
|
|
50
|
+
return usableSignature(key) ? key : ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function topRepeat(values: string[]): { key: string; count: number } | null {
|
|
54
|
+
const counts = new Map<string, number>()
|
|
55
|
+
for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1)
|
|
56
|
+
let best: { key: string; count: number } | null = null
|
|
57
|
+
for (const [key, count] of counts) {
|
|
58
|
+
if (!best || count > best.count) best = { key, count }
|
|
59
|
+
}
|
|
60
|
+
return best
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function findFindings(transcript: Transcript): Finding[] {
|
|
64
|
+
const findings: Finding[] = []
|
|
65
|
+
|
|
66
|
+
const errors = transcript.results.filter((result) => result.isError)
|
|
67
|
+
const repeatedError = topRepeat(errors.map((result) => failureSignature(result.body)).filter((body) => body !== ''))
|
|
68
|
+
if (repeatedError && repeatedError.count >= REPEAT_THRESHOLD) {
|
|
69
|
+
findings.push({
|
|
70
|
+
id: idFor(`error:${repeatedError.key}`),
|
|
71
|
+
kind: 'error',
|
|
72
|
+
title: `the same failure came back ${repeatedError.count} times: ${repeatedError.key}`,
|
|
73
|
+
count: repeatedError.count,
|
|
74
|
+
evidence: errors
|
|
75
|
+
.filter((result) => failureSignature(result.body) === repeatedError.key)
|
|
76
|
+
.slice(0, 4)
|
|
77
|
+
.map((result) => `${result.toolName}: ${result.body.split('\n')[0]?.slice(0, 120) ?? ''}`)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const repeatedCommand = topRepeat(
|
|
82
|
+
transcript.calls
|
|
83
|
+
.filter((call) => call.name === 'bash')
|
|
84
|
+
.map((call) => call.command)
|
|
85
|
+
.filter((command) => command !== '')
|
|
86
|
+
)
|
|
87
|
+
if (repeatedCommand && repeatedCommand.count >= REPEAT_THRESHOLD) {
|
|
88
|
+
findings.push({
|
|
89
|
+
id: idFor(`command:${repeatedCommand.key}`),
|
|
90
|
+
kind: 'command',
|
|
91
|
+
title: `the same command ran ${repeatedCommand.count} times: ${repeatedCommand.key.slice(0, 120)}`,
|
|
92
|
+
count: repeatedCommand.count,
|
|
93
|
+
evidence: repeatedCommand.key
|
|
94
|
+
.split('\n')
|
|
95
|
+
.slice(0, 3)
|
|
96
|
+
.map((line) => `bash: ${line}`)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const repeatedRead = topRepeat(
|
|
101
|
+
transcript.calls
|
|
102
|
+
.filter((call) => call.name === 'read')
|
|
103
|
+
.map((call) => call.path)
|
|
104
|
+
.filter((path) => path !== '')
|
|
105
|
+
)
|
|
106
|
+
if (repeatedRead && repeatedRead.count >= REPEAT_THRESHOLD) {
|
|
107
|
+
findings.push({
|
|
108
|
+
id: idFor(`read:${repeatedRead.key}`),
|
|
109
|
+
kind: 'read',
|
|
110
|
+
title: `the same file was read ${repeatedRead.count} times: ${repeatedRead.key}`,
|
|
111
|
+
count: repeatedRead.count,
|
|
112
|
+
evidence: [`read: ${repeatedRead.key}`]
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const changed = transcript.calls.some((call) => call.name === 'edit' || call.name === 'write')
|
|
117
|
+
const failed = transcript.results.some((result) => result.isError)
|
|
118
|
+
if (!changed && failed && transcript.calls.length >= 8) {
|
|
119
|
+
findings.push({
|
|
120
|
+
id: idFor('no-change'),
|
|
121
|
+
kind: 'no-change',
|
|
122
|
+
title: `the run made ${transcript.calls.length} tool calls, hit a failure and changed nothing`,
|
|
123
|
+
count: transcript.calls.length,
|
|
124
|
+
evidence: transcript.calls.slice(-4).map((call) => `${call.name} ${call.command || call.path}`)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const answered = new Set(transcript.results.map((result) => result.toolCallId))
|
|
129
|
+
const unanswered = transcript.calls.filter((call) => !answered.has(call.id))
|
|
130
|
+
if (unanswered.length > 0) {
|
|
131
|
+
findings.push({
|
|
132
|
+
id: idFor('unanswered'),
|
|
133
|
+
kind: 'unanswered',
|
|
134
|
+
title: `${unanswered.length} tool calls have no result in the transcript`,
|
|
135
|
+
count: unanswered.length,
|
|
136
|
+
evidence: unanswered.slice(0, 3).map((call) => `${call.name} ${call.command || call.path}`)
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return findings
|
|
141
|
+
}
|