@llm4ts/flow 0.10.0 → 0.11.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/dist/Context.d.ts +63 -0
- package/dist/Context.d.ts.map +1 -0
- package/dist/Context.js +127 -0
- package/dist/Context.js.map +1 -0
- package/dist/GitTool.d.ts +1 -0
- package/dist/GitTool.d.ts.map +1 -1
- package/dist/GitTool.js +10 -0
- package/dist/GitTool.js.map +1 -1
- package/dist/Pack.d.ts +2 -0
- package/dist/Pack.d.ts.map +1 -1
- package/dist/Pack.js +27 -1
- package/dist/Pack.js.map +1 -1
- package/dist/ProgramJudge.d.ts +52 -0
- package/dist/ProgramJudge.d.ts.map +1 -0
- package/dist/ProgramJudge.js +100 -0
- package/dist/ProgramJudge.js.map +1 -0
- package/dist/Provenance.d.ts +1 -0
- package/dist/Provenance.d.ts.map +1 -1
- package/dist/Provenance.js +7 -1
- package/dist/Provenance.js.map +1 -1
- package/dist/Survey.d.ts +10 -0
- package/dist/Survey.d.ts.map +1 -1
- package/dist/Survey.js +28 -0
- package/dist/Survey.js.map +1 -1
- package/dist/TransientRetry.d.ts +2 -0
- package/dist/TransientRetry.d.ts.map +1 -1
- package/dist/TransientRetry.js +35 -1
- package/dist/TransientRetry.js.map +1 -1
- package/package.json +4 -2
- package/src/Context.ts +207 -0
- package/src/GitTool.ts +21 -0
- package/src/Pack.ts +37 -1
- package/src/ProgramJudge.ts +186 -0
- package/src/Provenance.ts +10 -1
- package/src/Survey.ts +37 -0
- package/src/TransientRetry.ts +45 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect"
|
|
2
|
+
import { Sample, type Dimension, type EvalResult } from "@llm4ts/core/eval/Eval"
|
|
3
|
+
import type { Evaluator } from "@llm4ts/core/eval/Evaluator"
|
|
4
|
+
import { capped, withShrink } from "./Context.ts"
|
|
5
|
+
import { FlowLlmError, type FlowError } from "./FlowError.ts"
|
|
6
|
+
import { FlowEvents, Info } from "./FlowEvents.ts"
|
|
7
|
+
import type { GitToolShape } from "./GitTool.ts"
|
|
8
|
+
import type { Pack } from "./Pack.ts"
|
|
9
|
+
import type { PlainFileStoreShape } from "./Persistence.ts"
|
|
10
|
+
import { ReviewIssue, ReviewResult, mergeReviewResults } from "./Review.ts"
|
|
11
|
+
import { cachedReview } from "./ReviewCache.ts"
|
|
12
|
+
|
|
13
|
+
// Per-program spec-compliance judging, shared by the implement and review
|
|
14
|
+
// phases. A whole-branch judge call carries every spec and every diff hunk in
|
|
15
|
+
// the estate, which is what blows a provider's input window. Judging one
|
|
16
|
+
// program at a time against only that program's slice of the diff keeps each
|
|
17
|
+
// call small, and — wrapped in the review cache — makes the gate resumable: an
|
|
18
|
+
// unchanged program reuses its stored verdict with no LLM call.
|
|
19
|
+
|
|
20
|
+
export interface GroupedFiles {
|
|
21
|
+
readonly byProgram: Readonly<Record<string, ReadonlyArray<string>>>
|
|
22
|
+
readonly unassigned: ReadonlyArray<string>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Partition `changed` by which program's file regex matches. A file matching
|
|
27
|
+
* several programs is judged with each of them (a shared bridge class is
|
|
28
|
+
* genuinely part of both). The remainder — build files, shared utilities — is
|
|
29
|
+
* returned separately for the unassigned pass.
|
|
30
|
+
*/
|
|
31
|
+
export const groupFiles = (
|
|
32
|
+
pack: Pack,
|
|
33
|
+
programs: ReadonlyArray<string>,
|
|
34
|
+
changed: ReadonlyArray<string>
|
|
35
|
+
): GroupedFiles => {
|
|
36
|
+
const byProgram = Object.fromEntries(
|
|
37
|
+
programs.map((program) => {
|
|
38
|
+
const matches = pack.filesFor(program)
|
|
39
|
+
return [program, changed.filter((file) => matches.test(file))] as const
|
|
40
|
+
})
|
|
41
|
+
)
|
|
42
|
+
const assigned = new Set(Object.values(byProgram).flat())
|
|
43
|
+
return {
|
|
44
|
+
byProgram,
|
|
45
|
+
unassigned: changed.filter((file) => !assigned.has(file))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ProgramJudgeOptions {
|
|
50
|
+
readonly pack: Pack
|
|
51
|
+
readonly judge: Evaluator<Sample>
|
|
52
|
+
readonly dimensions: ReadonlyArray<Dimension>
|
|
53
|
+
readonly git: GitToolShape
|
|
54
|
+
readonly files: PlainFileStoreShape
|
|
55
|
+
/** Directory holding the per-program verdict cache (`<gateDir>/<NAME>.json`). */
|
|
56
|
+
readonly gateDir: string
|
|
57
|
+
readonly base: string
|
|
58
|
+
readonly programs: ReadonlyArray<string>
|
|
59
|
+
/** A program's spec text; the caller owns where specs live. */
|
|
60
|
+
readonly specFor: (program: string) => Effect.Effect<string, FlowError>
|
|
61
|
+
readonly query: string
|
|
62
|
+
/** Content fingerprint for the verdict cache (hashing lives outside flow). */
|
|
63
|
+
readonly fingerprint: (...parts: ReadonlyArray<string>) => string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const join = (root: string, path: string): string =>
|
|
67
|
+
`${root.replace(/[\\/]+$/, "")}/${path.replace(/^[\\/]+/, "")}`
|
|
68
|
+
|
|
69
|
+
const rubricText = (dimensions: ReadonlyArray<Dimension>): string =>
|
|
70
|
+
dimensions
|
|
71
|
+
.map((dimension) => `${dimension.name} (0..${dimension.maxScore}): ${dimension.rubric}`)
|
|
72
|
+
.join("\n")
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Sub-bar dimensions as Critical review issues, titled with the program they
|
|
76
|
+
* belong to — the same shape the extract gate produces, so fix loops and
|
|
77
|
+
* `ReviewResult.isClean` work unchanged.
|
|
78
|
+
*/
|
|
79
|
+
const issues = (
|
|
80
|
+
scored: EvalResult,
|
|
81
|
+
dimensions: ReadonlyArray<Dimension>,
|
|
82
|
+
program: string
|
|
83
|
+
): ReviewResult => {
|
|
84
|
+
const subBar = scored.scores.filter(
|
|
85
|
+
(score) =>
|
|
86
|
+
score.score < (dimensions.find((dimension) => dimension.name === score.name)?.maxScore ?? 2)
|
|
87
|
+
)
|
|
88
|
+
return ReviewResult.make({
|
|
89
|
+
issues: subBar.map((score) =>
|
|
90
|
+
ReviewIssue.make({
|
|
91
|
+
severity: "Critical",
|
|
92
|
+
title: `judge[${program}]: ${score.name} scored ${score.score}`,
|
|
93
|
+
description: score.reasoning
|
|
94
|
+
})
|
|
95
|
+
),
|
|
96
|
+
summary: `judge:${program}`
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A spec'd program with NO matching changed file is a deterministic gate
|
|
102
|
+
* failure, not a silent pass. Skipping it would let the branch clear a bar the
|
|
103
|
+
* old whole-branch judge would have failed. It also surfaces a mis-set
|
|
104
|
+
* `programFiles:` immediately — the top documented risk of the per-program
|
|
105
|
+
* design — instead of quietly degrading coverage.
|
|
106
|
+
*/
|
|
107
|
+
export const unimplemented = (programs: ReadonlyArray<string>): ReviewResult =>
|
|
108
|
+
ReviewResult.make({
|
|
109
|
+
issues: programs.map((program) =>
|
|
110
|
+
ReviewIssue.make({
|
|
111
|
+
severity: "Critical",
|
|
112
|
+
title: `judge[${program}]: spec'd but no implementation files changed`,
|
|
113
|
+
description:
|
|
114
|
+
`${program} has a committed spec but no file on this branch matches the pack's ` +
|
|
115
|
+
"programFiles regex for it. Either the program is unimplemented, or the pack's " +
|
|
116
|
+
"`programFiles:` template does not match this repo's layout — check that before " +
|
|
117
|
+
"assuming the former."
|
|
118
|
+
})
|
|
119
|
+
),
|
|
120
|
+
summary: "judge:unimplemented"
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const judgeSlice = Effect.fn("@llm4ts/flow/ProgramJudge.judgeSlice")(function* (
|
|
124
|
+
options: ProgramJudgeOptions,
|
|
125
|
+
label: string,
|
|
126
|
+
spec: string,
|
|
127
|
+
diff: string
|
|
128
|
+
): Effect.fn.Return<ReviewResult, FlowError, FlowEvents> {
|
|
129
|
+
const events = yield* FlowEvents
|
|
130
|
+
const rubric = rubricText(options.dimensions)
|
|
131
|
+
return yield* cachedReview(
|
|
132
|
+
options.files,
|
|
133
|
+
join(options.gateDir, `${label}.json`),
|
|
134
|
+
options.fingerprint(spec, diff, rubric),
|
|
135
|
+
events.publish(Info.make({ message: `judging ${label}` })).pipe(
|
|
136
|
+
Effect.andThen(
|
|
137
|
+
withShrink(`judge[${label}]`, (cap) =>
|
|
138
|
+
Effect.gen(function* () {
|
|
139
|
+
const cappedSpec = yield* capped(`spec[${label}]`, spec, cap)
|
|
140
|
+
const cappedDiff = yield* capped(`diff[${label}]`, diff, cap)
|
|
141
|
+
return yield* options.judge
|
|
142
|
+
.evaluate(
|
|
143
|
+
Sample.make({ response: cappedDiff, context: cappedSpec, query: options.query })
|
|
144
|
+
)
|
|
145
|
+
.pipe(Effect.mapError(FlowLlmError.from))
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
),
|
|
149
|
+
Effect.map((scored) => issues(scored, options.dimensions, label))
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Judge every program whose files changed, plus one pass over the unassigned
|
|
156
|
+
* remainder. Each verdict is cached at `gateDir/<NAME>.json`, fingerprinted
|
|
157
|
+
* over the spec, the diff slice, and the rubric it judged — so re-running
|
|
158
|
+
* after a crash re-judges only what changed.
|
|
159
|
+
*/
|
|
160
|
+
export const judgeAllPrograms = Effect.fn("@llm4ts/flow/ProgramJudge.judgeAll")(function* (
|
|
161
|
+
options: ProgramJudgeOptions
|
|
162
|
+
): Effect.fn.Return<ReviewResult, FlowError, FlowEvents> {
|
|
163
|
+
const changed = yield* options.git.changedFilesVsBase(options.base)
|
|
164
|
+
const { byProgram, unassigned } = groupFiles(options.pack, options.programs, changed)
|
|
165
|
+
const active = options.programs.filter((program) => (byProgram[program] ?? []).length > 0)
|
|
166
|
+
const untouched = options.programs.filter((program) => (byProgram[program] ?? []).length === 0)
|
|
167
|
+
|
|
168
|
+
const perProgram: Array<ReviewResult> = []
|
|
169
|
+
for (const program of active) {
|
|
170
|
+
const spec = yield* options.specFor(program)
|
|
171
|
+
// The file slice comes from groupFiles — re-deriving it per program would
|
|
172
|
+
// be N+1 git invocations for the same answer.
|
|
173
|
+
const diff = yield* options.git.diffVsBaseScoped(options.base, byProgram[program] ?? [])
|
|
174
|
+
perProgram.push(yield* judgeSlice(options, program, spec, diff))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (unassigned.length > 0) {
|
|
178
|
+
const diff = yield* options.git.diffVsBaseScoped(options.base, unassigned)
|
|
179
|
+
const specs = yield* Effect.forEach(options.programs, options.specFor).pipe(
|
|
180
|
+
Effect.map((all) => all.join("\n\n"))
|
|
181
|
+
)
|
|
182
|
+
perProgram.push(yield* judgeSlice(options, "unassigned", specs, diff))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return mergeReviewResults([...perProgram, unimplemented(untouched)])
|
|
186
|
+
})
|
package/src/Provenance.ts
CHANGED
|
@@ -13,7 +13,16 @@ export class Provenance extends Schema.Class<Provenance>("Provenance")({
|
|
|
13
13
|
specs: Schema.Record(Schema.String, Schema.String),
|
|
14
14
|
gateVerdicts: Schema.Record(Schema.String, Schema.String),
|
|
15
15
|
equivalenceReport: Schema.optionalKey(Schema.String),
|
|
16
|
-
fixSpecs: Schema.Array(Schema.String)
|
|
16
|
+
fixSpecs: Schema.Array(Schema.String),
|
|
17
|
+
// Context truncations recorded while producing this evidence
|
|
18
|
+
// (Context.renderTruncation strings). A gate verdict rendered on a
|
|
19
|
+
// partially-read spec pack says so HERE — that visibility is the whole
|
|
20
|
+
// reason truncation is allowed at all. Defaulted so manifests written
|
|
21
|
+
// before this field still load.
|
|
22
|
+
contextTruncations: Schema.Array(Schema.String).pipe(
|
|
23
|
+
Schema.withDecodingDefaultKey(Effect.succeed([])),
|
|
24
|
+
Schema.withConstructorDefault(Effect.succeed([]))
|
|
25
|
+
)
|
|
17
26
|
}) {}
|
|
18
27
|
|
|
19
28
|
const join = (root: string, path: string): string =>
|
package/src/Survey.ts
CHANGED
|
@@ -29,6 +29,43 @@ export class SurveyGraph extends Schema.Class<SurveyGraph>("SurveyGraph")({
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The transitive dependency closure of `program` as repo-relative paths,
|
|
34
|
+
* breadth-first, excluding the program itself. The `seen` set is required, not
|
|
35
|
+
* optional bookkeeping: COBOL copybook graphs genuinely contain cycles (a
|
|
36
|
+
* copybook that COPYs something which eventually COPYs back), and without it
|
|
37
|
+
* this walk would never terminate on a real estate. Truncated to `maxFiles` so
|
|
38
|
+
* a program pulling hundreds of copybooks still gets a bounded, visible subset
|
|
39
|
+
* instead of an unbounded read.
|
|
40
|
+
*/
|
|
41
|
+
export const closureFor = (
|
|
42
|
+
graph: SurveyGraph,
|
|
43
|
+
program: string,
|
|
44
|
+
maxFiles: number
|
|
45
|
+
): ReadonlyArray<string> => {
|
|
46
|
+
const pathOf = new Map(graph.nodes.map((node) => [node.name, node.path]))
|
|
47
|
+
const walk = (
|
|
48
|
+
frontier: ReadonlyArray<string>,
|
|
49
|
+
seen: ReadonlySet<string>,
|
|
50
|
+
acc: ReadonlyArray<string>
|
|
51
|
+
): ReadonlyArray<string> => {
|
|
52
|
+
if (frontier.length === 0 || acc.length >= maxFiles) {
|
|
53
|
+
return acc.slice(0, maxFiles)
|
|
54
|
+
}
|
|
55
|
+
const next = [
|
|
56
|
+
...new Set(frontier.flatMap((from) => graph.outgoing(from).map((edge) => edge.to)))
|
|
57
|
+
].filter((name) => !seen.has(name))
|
|
58
|
+
return walk(next, new Set([...seen, ...next]), [
|
|
59
|
+
...acc,
|
|
60
|
+
...next.flatMap((name) => {
|
|
61
|
+
const path = pathOf.get(name)
|
|
62
|
+
return path === undefined ? [] : [path]
|
|
63
|
+
})
|
|
64
|
+
])
|
|
65
|
+
}
|
|
66
|
+
return walk([program], new Set([program]), [])
|
|
67
|
+
}
|
|
68
|
+
|
|
32
69
|
const unitName = (path: string): string => {
|
|
33
70
|
const base = path.split("/").at(-1) ?? path
|
|
34
71
|
const dot = base.lastIndexOf(".")
|
package/src/TransientRetry.ts
CHANGED
|
@@ -27,12 +27,40 @@ const transientSignals: ReadonlyArray<string> = [
|
|
|
27
27
|
" 504"
|
|
28
28
|
]
|
|
29
29
|
|
|
30
|
+
// An "empty response" is ambiguous: gemini returns one both for a random mid-stream
|
|
31
|
+
// flake and for a prompt too large to start on. This classifier sees only the message,
|
|
32
|
+
// so it deliberately treats both as flaky — a fresh process fixes the common (flake)
|
|
33
|
+
// case, and the oversized-prompt case is resolved a layer up by Context.withShrink.
|
|
34
|
+
// Do not route empty responses into isContextOverflow.
|
|
30
35
|
const flakyStreamSignals: ReadonlyArray<string> = [
|
|
31
36
|
"empty response",
|
|
32
37
|
"malformed tool call",
|
|
33
38
|
"invalid stream"
|
|
34
39
|
]
|
|
35
40
|
|
|
41
|
+
// Deterministic client errors a retry can never fix. Gemini wraps every error in
|
|
42
|
+
// "[API Error: {...}]", including 400s, so the "api error" transient signal would
|
|
43
|
+
// otherwise swallow them.
|
|
44
|
+
const deterministic4xxSignals: ReadonlyArray<string> = [
|
|
45
|
+
"invalid_argument",
|
|
46
|
+
'"code": 400',
|
|
47
|
+
'"code":400',
|
|
48
|
+
"code=400",
|
|
49
|
+
"exceeds the maximum number of tokens"
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
// The message signatures of a prompt-too-large failure, in one place: isContextOverflow
|
|
53
|
+
// matches them on a typed LlmError, and Context.withShrink matches them on a bare
|
|
54
|
+
// message string when no typed cause survived. Two copies of this list would drift.
|
|
55
|
+
const contextOverflowSignals: ReadonlyArray<string> = [
|
|
56
|
+
"exceeds the maximum number of tokens",
|
|
57
|
+
"input token count exceeds",
|
|
58
|
+
"context length exceeded",
|
|
59
|
+
"maximum context length",
|
|
60
|
+
"prompt is too long",
|
|
61
|
+
"request too large"
|
|
62
|
+
]
|
|
63
|
+
|
|
36
64
|
const includesSignal = (message: string, signals: ReadonlyArray<string>): boolean => {
|
|
37
65
|
const normalized = message.toLowerCase()
|
|
38
66
|
return signals.some((signal) => normalized.includes(signal))
|
|
@@ -41,13 +69,29 @@ const includesSignal = (message: string, signals: ReadonlyArray<string>): boolea
|
|
|
41
69
|
export const isFlakyStream = (error: LlmError): boolean =>
|
|
42
70
|
error._tag === "ProviderError" && includesSignal(error.message, flakyStreamSignals)
|
|
43
71
|
|
|
72
|
+
const isDeterministic4xx = (message: string): boolean =>
|
|
73
|
+
includesSignal(message, deterministic4xxSignals)
|
|
74
|
+
|
|
75
|
+
export const isContextOverflowMessage = (message: string): boolean =>
|
|
76
|
+
includesSignal(message, contextOverflowSignals)
|
|
77
|
+
|
|
78
|
+
// The prompt was larger than the model's input window. Deterministic: the same prompt
|
|
79
|
+
// always fails, so it is not transient — it routes to Context.withShrink, which retries
|
|
80
|
+
// at a smaller budget.
|
|
81
|
+
export const isContextOverflow = (error: LlmError): boolean =>
|
|
82
|
+
error._tag === "ProviderError" && isContextOverflowMessage(error.message)
|
|
83
|
+
|
|
44
84
|
export const isTransient = (error: LlmError): boolean => {
|
|
45
85
|
switch (error._tag) {
|
|
46
86
|
case "TimeoutError":
|
|
47
87
|
case "RateLimitError":
|
|
48
88
|
return true
|
|
49
89
|
case "ProviderError":
|
|
50
|
-
return
|
|
90
|
+
return (
|
|
91
|
+
!isFlakyStream(error) &&
|
|
92
|
+
!isDeterministic4xx(error.message) &&
|
|
93
|
+
includesSignal(error.message, transientSignals)
|
|
94
|
+
)
|
|
51
95
|
default:
|
|
52
96
|
return false
|
|
53
97
|
}
|