dsh-llm-verifier 0.3.0 → 0.4.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/README.md +133 -9
- package/lib/auto-B5V49e-A.js +1517 -0
- package/lib/auto-B5V49e-A.js.map +1 -0
- package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
- package/lib/caller-DR_C7ueP.js.map +1 -0
- package/lib/caller.js +2 -2
- package/lib/client.js +804 -29
- package/lib/client.js.map +1 -1
- package/lib/core.js +226 -3
- package/lib/core.js.map +1 -1
- package/lib/index.js +1014 -1337
- package/lib/index.js.map +1 -1
- package/lib/replay.js +157 -0
- package/lib/replay.js.map +1 -0
- package/lib/types/auto.js +1 -1
- package/lib/types/auto.js.map +1 -1
- package/lib/types/caller.d.ts +65 -0
- package/lib/types/caller.d.ts.map +1 -1
- package/lib/types/caller.js +113 -5
- package/lib/types/caller.js.map +1 -1
- package/lib/types/client-i18n.d.ts +163 -6
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +137 -26
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +30 -0
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +94 -9
- package/lib/types/client.js.map +1 -1
- package/lib/types/config.d.ts +33 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/config.js +17 -2
- package/lib/types/config.js.map +1 -1
- package/lib/types/core.d.ts +84 -0
- package/lib/types/core.d.ts.map +1 -1
- package/lib/types/core.js +222 -2
- package/lib/types/core.js.map +1 -1
- package/lib/types/criteria.d.ts +33 -0
- package/lib/types/criteria.d.ts.map +1 -0
- package/lib/types/criteria.js +67 -0
- package/lib/types/criteria.js.map +1 -0
- package/lib/types/decisions.d.ts +84 -0
- package/lib/types/decisions.d.ts.map +1 -0
- package/lib/types/decisions.js +209 -0
- package/lib/types/decisions.js.map +1 -0
- package/lib/types/engine.d.ts +54 -1
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +175 -12
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +392 -38
- package/lib/types/index.js.map +1 -1
- package/lib/types/replay.d.ts +88 -0
- package/lib/types/replay.d.ts.map +1 -0
- package/lib/types/replay.js +136 -0
- package/lib/types/replay.js.map +1 -0
- package/lib/types/router.d.ts +29 -5
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +283 -46
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +10 -1
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +35 -7
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/top-logprobs.d.ts +36 -0
- package/lib/types/top-logprobs.d.ts.map +1 -1
- package/lib/types/top-logprobs.js +41 -14
- package/lib/types/top-logprobs.js.map +1 -1
- package/package.json +6 -1
- package/scripts/eval-replay.mjs +118 -0
- package/src/auto.ts +1 -1
- package/src/caller.test.ts +62 -1
- package/src/caller.ts +124 -4
- package/src/client-i18n.ts +137 -25
- package/src/client.test.ts +21 -0
- package/src/client.tsx +145 -7
- package/src/config.test.ts +32 -0
- package/src/config.ts +49 -2
- package/src/core.test.ts +95 -0
- package/src/core.ts +217 -2
- package/src/criteria.test.ts +60 -0
- package/src/criteria.ts +76 -0
- package/src/decisions.test.ts +150 -0
- package/src/decisions.ts +244 -0
- package/src/engine.test.ts +131 -0
- package/src/engine.ts +167 -10
- package/src/index.test.ts +278 -8
- package/src/index.ts +373 -37
- package/src/replay.test.ts +82 -0
- package/src/replay.ts +155 -0
- package/src/router.test.ts +240 -1
- package/src/router.ts +298 -48
- package/src/statistics.test.ts +49 -1
- package/src/statistics.ts +44 -7
- package/src/top-logprobs.test.ts +28 -0
- package/src/top-logprobs.ts +56 -8
- package/lib/caller-DJd_FlrQ.js.map +0 -1
package/lib/types/index.js
CHANGED
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
2
2
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
3
3
|
import { Config, installVerifierSettings, resolveConfig } from "./config.js";
|
|
4
|
-
import { RequestLimiter, callVerifierText } from "./caller.js";
|
|
4
|
+
import { RequestLimiter, addUsage, callVerifier, callVerifierText, generateCandidate } from "./caller.js";
|
|
5
5
|
import { TopLogprobCapabilityCache, resolveCapabilityFile } from "./top-logprobs.js";
|
|
6
6
|
import { ScoreCache, SingleFlight, resolveCacheFile, stableHash } from "./cache.js";
|
|
7
7
|
import { VerifierEngine, normalizeCriteria } from "./engine.js";
|
|
8
8
|
import { loadVerifierImages } from "./images.js";
|
|
9
9
|
import { extractSession, sanitizeVerifierText, sessionEvents } from "./session.js";
|
|
10
|
+
import { CriteriaResolver } from "./criteria.js";
|
|
10
11
|
import { analyzeAutoTask, automaticFeedback, failedAcceptanceCriteria, isSubagentSession, sessionAccepted } from "./auto.js";
|
|
11
12
|
import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint } from "./router.js";
|
|
12
|
-
import {
|
|
13
|
+
import { DEFAULT_GROUND_TRUTH_NOTE, EMPTY_WORK_BASELINE, buildGenerationPrompt, buildPairwisePrompt, extractScore } from "./core.js";
|
|
13
14
|
import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from "./plan-gate.js";
|
|
14
15
|
import { inspectTeamTasks, buildTeamTaskVerificationPrompt } from "./team-gate.js";
|
|
15
16
|
import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, summarizeVerdict } from "./statistics.js";
|
|
16
17
|
import { resolveTopicDataDir } from "./topic-storage.js";
|
|
18
|
+
import { DecisionStore, boundDecisionCalls, resolveDecisionsFile } from "./decisions.js";
|
|
17
19
|
export const name = 'llm-verifier';
|
|
18
20
|
export const inject = ['tools', 'agents', 'attachments', 'llm', 'connection', 'sessionPersistence'];
|
|
19
21
|
export { Config };
|
|
@@ -22,6 +24,7 @@ export * from "./engine.js";
|
|
|
22
24
|
export * from "./cache.js";
|
|
23
25
|
export * from "./statistics.js";
|
|
24
26
|
export * from "./topic-storage.js";
|
|
27
|
+
export * from "./decisions.js";
|
|
25
28
|
export * from "./auto.js";
|
|
26
29
|
export * from "./router.js";
|
|
27
30
|
export * from "./plan-gate.js";
|
|
@@ -74,11 +77,30 @@ const EXPLICIT_MAX_TOTAL_CHARS = 240_000;
|
|
|
74
77
|
const EXPLICIT_MIN_TOTAL_CHARS = 120_000;
|
|
75
78
|
const MAX_EXPLICIT_CANDIDATES = 16;
|
|
76
79
|
const MAX_EXPLICIT_PLANNED_CALLS = 500;
|
|
80
|
+
/**
|
|
81
|
+
* Best-of-N bounds. The ceiling is 4 because cost grows ~linearly in N while the marginal
|
|
82
|
+
* value of the 5th draft does not, and the tool is meant for a final deliverable rather than
|
|
83
|
+
* routine work. The floor is 2 because "choose the best of one" is not a choice.
|
|
84
|
+
*/
|
|
85
|
+
const MIN_BEST_OF_N = 2;
|
|
86
|
+
const MAX_BEST_OF_N = 4;
|
|
87
|
+
const DEFAULT_BEST_OF_N = 3;
|
|
77
88
|
function explicitItemChars(selected) { return Math.max(selected.autoRouteMaxItemChars, EXPLICIT_MIN_ITEM_CHARS); }
|
|
78
89
|
function explicitBudget(selected) { return Math.min(Math.max(selected.autoRouteMaxInputChars * 2, EXPLICIT_MIN_TOTAL_CHARS), EXPLICIT_MAX_TOTAL_CHARS); }
|
|
79
90
|
function explicitCandidateLimit(selected) { return Math.max(selected.autoRouteMaxCandidates, MAX_EXPLICIT_CANDIDATES); }
|
|
80
91
|
/** Ring + pivot-round comparisons for an explicitly requested selection. */
|
|
81
92
|
function plannedComparisons(count) { return count <= 2 ? 1 : count + Math.max(0, (count - 2) * 2 + 1 - 3); }
|
|
93
|
+
/**
|
|
94
|
+
* Worst-case tournament pairs for a selection of `count` candidates.
|
|
95
|
+
*
|
|
96
|
+
* `plannedComparisons` is an ESTIMATE (it assumes the ring already covers three of the pivot-round
|
|
97
|
+
* pairs), and best-of-N spends N generations first, so its guard has to fail closed on the true
|
|
98
|
+
* bound instead: ring (N) plus every pivot pair (non-pivots x pivots, plus pivot-vs-pivot), since
|
|
99
|
+
* de-duplicating against the ring can only ever remove pairs.
|
|
100
|
+
* @param count - candidate count.
|
|
101
|
+
* @returns The largest number of pairs the tournament can judge.
|
|
102
|
+
*/
|
|
103
|
+
function selectComparisonsUpperBound(count) { return count <= 2 ? 1 : count + (count - 2) * 2 + 1; }
|
|
82
104
|
function numberField(value, fallback) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; }
|
|
83
105
|
function statsFrom(value) { if (typeof value !== 'object' || value === null || !('stats' in value))
|
|
84
106
|
return emptyRunStats(); const source = value.stats; if (typeof source !== 'object' || source === null)
|
|
@@ -94,6 +116,18 @@ const judgeScoreSchema = { type: 'object', additionalProperties: false, properti
|
|
|
94
116
|
const judgesSchema = { type: 'array', items: judgeScoreSchema, required: true };
|
|
95
117
|
function rpcSuccess(value) { return { ok: true, value }; }
|
|
96
118
|
function rpcFailure(message) { return { ok: false, error: { code: 'bad-request', message, details: { issues: [] } } }; }
|
|
119
|
+
/**
|
|
120
|
+
* Small but real pair for the settings-page probe.
|
|
121
|
+
*
|
|
122
|
+
* The probe asks each judge for an actual A–T verdict instead of a "ping": that is what proves
|
|
123
|
+
* the response PARSES, which is the failure a reachability check misses. A pair where one side is
|
|
124
|
+
* obviously better keeps the exercise honest without making the expected score a secret.
|
|
125
|
+
*/
|
|
126
|
+
const PROBE_TASK = 'Fix the failing parser test and prove it passes.';
|
|
127
|
+
const PROBE_A = 'Ran the test: 1 failed. Patched the separator handling. Ran it again: 1 passed, no other failures.';
|
|
128
|
+
const PROBE_B = 'The test should pass now.';
|
|
129
|
+
/** Bound the probe: a diagnostics button must not sit on the configured 5-minute timeout plus retries. */
|
|
130
|
+
const PROBE_TIMEOUT_MS = 30000;
|
|
97
131
|
export function apply(ctx, config = {}) {
|
|
98
132
|
const services = ctx;
|
|
99
133
|
const entry = resolveConfig(config);
|
|
@@ -109,7 +143,7 @@ export function apply(ctx, config = {}) {
|
|
|
109
143
|
if (existing?.dataDir === dataDir)
|
|
110
144
|
return existing;
|
|
111
145
|
const cacheFile = resolveCacheFile(dataDir);
|
|
112
|
-
const created = { dataDir, cache: new ScoreCache(cacheFile, selected.cacheMaxEntries), capabilities: new TopLogprobCapabilityCache(resolveCapabilityFile(dataDir)), flights: new SingleFlight(), statistics: new StatisticsStore(resolveStatisticsFile(cacheFile)) };
|
|
146
|
+
const created = { dataDir, cache: new ScoreCache(cacheFile, selected.cacheMaxEntries), capabilities: new TopLogprobCapabilityCache(resolveCapabilityFile(dataDir)), flights: new SingleFlight(), statistics: new StatisticsStore(resolveStatisticsFile(cacheFile)), decisions: new DecisionStore(resolveDecisionsFile(cacheFile)) };
|
|
113
147
|
topics.set(id, created);
|
|
114
148
|
return created;
|
|
115
149
|
};
|
|
@@ -119,9 +153,40 @@ export function apply(ctx, config = {}) {
|
|
|
119
153
|
throw new Error('llm-verifier: verifier tools require an agent-owned topic so their data can follow topic deletion');
|
|
120
154
|
return selected;
|
|
121
155
|
};
|
|
122
|
-
|
|
156
|
+
// The configured rubric (preset, or a markdown file) is the one every automatic decision and
|
|
157
|
+
// every explicit tool without its own `criteria` argument scores with. Resolved through a
|
|
158
|
+
// cache so an unchanged file costs one read per boundary, and a broken file degrades to the
|
|
159
|
+
// coding preset instead of disabling the gate.
|
|
160
|
+
const criteriaResolver = new CriteriaResolver();
|
|
161
|
+
const configuredCriteria = () => criteriaResolver.resolve(current().criteriaPreset, current().criteriaFile);
|
|
162
|
+
/**
|
|
163
|
+
* Every known topic header, newest first.
|
|
164
|
+
*
|
|
165
|
+
* The statistics and decision dashboards merge all topics; anything that needs exactly ONE
|
|
166
|
+
* topic (the judge probe's per-topic capability memory) takes the newest, because that is where
|
|
167
|
+
* the operator's current work lives. Legacy backends handed back the header directly instead of
|
|
168
|
+
* wrapping it in \`{ header }`, so both shapes are accepted.
|
|
169
|
+
* @returns Session headers, newest first; entries without an id are dropped.
|
|
170
|
+
*/
|
|
171
|
+
const sessionHeaders = async () => {
|
|
172
|
+
const items = await services.sessionPersistence.list();
|
|
173
|
+
return items
|
|
174
|
+
.map(item => item && typeof item === 'object' && 'header' in item ? item.header : item)
|
|
175
|
+
.filter(header => header !== undefined && header.id !== undefined)
|
|
176
|
+
.sort((a, b) => Number(b.createdAt ?? 0) - Number(a.createdAt ?? 0));
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Build the judge ensemble for one topic.
|
|
180
|
+
*
|
|
181
|
+
* Takes a session header rather than an Agent because the judge probe is started from the global
|
|
182
|
+
* statistics dashboard, where there is no current initiator. The diagnostic still needs a topic
|
|
183
|
+
* for the per-topic capability memory, so it attaches to the most recent one (see handleProbe).
|
|
184
|
+
* @param header - session header owning the topic whose sidecars the judges use.
|
|
185
|
+
* @returns The engine plus the resolved configuration it was built from.
|
|
186
|
+
*/
|
|
187
|
+
const engineForHeader = async (header) => {
|
|
123
188
|
const selected = current();
|
|
124
|
-
const topicEntry = topic(
|
|
189
|
+
const topicEntry = topic(header);
|
|
125
190
|
// One client per configured judge: the judge identity is part of the scoring
|
|
126
191
|
// cache key, so each judge caches and de-duplicates independently. `judges[0]`
|
|
127
192
|
// is the primary, which keeps a single-judge configuration on exactly the old path.
|
|
@@ -132,6 +197,7 @@ export function apply(ctx, config = {}) {
|
|
|
132
197
|
}
|
|
133
198
|
return { verifier: new VerifierEngine(clients, selected.maxConcurrency, topicEntry.cache, { input: selected.estimatedInputUsdPerMillion, output: selected.estimatedOutputUsdPerMillion }, topicEntry.flights), selected };
|
|
134
199
|
};
|
|
200
|
+
const engine = async (agent) => engineForHeader(agent.session.header);
|
|
135
201
|
const images = (values, signal) => loadVerifierImages(values, signal);
|
|
136
202
|
const route = (selected) => ({ provider: selected.provider, model: selected.model });
|
|
137
203
|
const requireEnabled = () => { if (!current().enabled)
|
|
@@ -146,12 +212,24 @@ export function apply(ctx, config = {}) {
|
|
|
146
212
|
const record = async (toolName, agent, operation, phase = 'explicit') => {
|
|
147
213
|
const startedAt = Date.now();
|
|
148
214
|
let selected = current();
|
|
149
|
-
const
|
|
215
|
+
const topicEntry = topic(agent.session.header);
|
|
216
|
+
const statistics = topicEntry.statistics;
|
|
217
|
+
// Every real model call is captured here, so the dashboard can answer "why did the
|
|
218
|
+
// judge say that" from the exact prompt and raw answer instead of a replay. Calls
|
|
219
|
+
// are bounded twice (per call, per record) before anything reaches the disk.
|
|
220
|
+
const capture = current().captureDecisions;
|
|
221
|
+
const calls = [];
|
|
222
|
+
const trace = capture ? call => { calls.push(call); } : undefined;
|
|
150
223
|
try {
|
|
151
|
-
const completed = await operation();
|
|
224
|
+
const completed = await operation(trace);
|
|
152
225
|
selected = completed.selected;
|
|
153
226
|
const value = { ...completed.result, ...route(selected) };
|
|
154
227
|
await statistics.record({ toolName, sessionId: String(agent.id), startedAt, success: true, provider: selected.provider, model: selected.model, stats: statsFrom(value), verdict: verdictFrom(toolName, value, phase) }).catch(() => { });
|
|
228
|
+
// Order by label before storing: the engine reports calls as they complete, so a concurrent
|
|
229
|
+
// fan-out reports them in network order. Sorting makes "which calls are in the snapshot"
|
|
230
|
+
// reproducible even when the record has to bound its text.
|
|
231
|
+
if (calls.length > 0)
|
|
232
|
+
await topicEntry.decisions.record({ toolName, phase, startedAt, provider: selected.provider, model: selected.model, calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))) }).catch(() => { });
|
|
155
233
|
return value;
|
|
156
234
|
}
|
|
157
235
|
catch (error) {
|
|
@@ -187,35 +265,160 @@ export function apply(ctx, config = {}) {
|
|
|
187
265
|
verdict: { phase, outcome },
|
|
188
266
|
}).catch(() => { });
|
|
189
267
|
};
|
|
190
|
-
const verifySession = async (agent, options, signal, phase = 'explicit') => record('verifier_current_session', agent, async () => {
|
|
268
|
+
const verifySession = async (agent, options, signal, phase = 'explicit', rubricOverride) => record('verifier_current_session', agent, async (trace) => {
|
|
191
269
|
const extracted = await extractSession(agent, async (ref) => { const stored = await services.attachments.readImage(ref, signal); return { data: stored.data, mediaType: stored.ref.mediaType }; }, { fromSeq: options.fromSeq, toSeq: options.toSeq, includeAssistantText: options.includeAssistantText, redactPatterns: options.redactPatterns, maxChars: options.maxChars });
|
|
192
270
|
if (!extracted.problem.trim())
|
|
193
271
|
throw new Error('llm-verifier: no direct user task found in the selected session range — widen from_seq so the task statement is included');
|
|
194
272
|
const { verifier, selected } = await engine(agent);
|
|
195
|
-
const
|
|
273
|
+
const rubric = rubricOverride ?? await configuredCriteria();
|
|
274
|
+
const compared = await verifier.compare({ problem: extracted.problem, candidateA: extracted.trace, candidateB: EMPTY_WORK_BASELINE, criteria: rubric.criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats: positive(options.repeats, 2, 'repeats'), images: extracted.images, ...(trace ? { trace } : {}) }, signal);
|
|
196
275
|
const result = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, criteria: compared.criteria.map(row => ({ id: row.id, name: row.name, score: row.scoreA })), fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement };
|
|
197
276
|
return { result, selected };
|
|
198
277
|
}, phase);
|
|
199
|
-
const compareCandidates = async (agent, problem, candidateA, candidateB, repeats, signal, routedImages = [], phase = 'explicit') => record('verifier_compare', agent, async () => {
|
|
278
|
+
const compareCandidates = async (agent, problem, candidateA, candidateB, repeats, signal, rubric, routedImages = [], phase = 'explicit') => record('verifier_compare', agent, async (trace) => {
|
|
200
279
|
const { verifier, selected } = await engine(agent);
|
|
201
|
-
return { result: await verifier.compare({ problem, candidateA, candidateB, repeats, images: routedImages }, signal), selected };
|
|
280
|
+
return { result: await verifier.compare({ problem, candidateA, candidateB, criteria: rubric.criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats, images: routedImages, ...(trace ? { trace } : {}) }, signal), selected };
|
|
202
281
|
}, phase);
|
|
203
|
-
const selectCandidates = async (agent, problem, candidates, repeats, signal, routedImages = [], phase = 'explicit') => record('verifier_select', agent, async () => {
|
|
282
|
+
const selectCandidates = async (agent, problem, candidates, repeats, signal, rubric, routedImages = [], phase = 'explicit') => record('verifier_select', agent, async (trace) => {
|
|
204
283
|
const { verifier, selected } = await engine(agent);
|
|
205
|
-
return { result: await verifier.select({ problem, candidates, repeats, pivots: Math.min(2, candidates.length), seed: 0, images: routedImages }, signal), selected };
|
|
284
|
+
return { result: await verifier.select({ problem, candidates, criteria: rubric.criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats, pivots: Math.min(2, candidates.length), seed: 0, images: routedImages, ...(trace ? { trace } : {}) }, signal), selected };
|
|
206
285
|
}, phase);
|
|
207
|
-
const trackProgress = async (agent, problem, steps, checkpoints, repeats, signal, routedImages = [], phase = 'explicit') => record('verifier_track', agent, async () => {
|
|
286
|
+
const trackProgress = async (agent, problem, steps, checkpoints, repeats, signal, routedImages = [], phase = 'explicit') => record('verifier_track', agent, async (trace) => {
|
|
208
287
|
const { verifier, selected } = await engine(agent);
|
|
209
|
-
return { result: await verifier.track(problem, steps, checkpoints, repeats, signal, routedImages), selected };
|
|
288
|
+
return { result: await verifier.track(problem, steps, checkpoints, repeats, signal, routedImages, trace), selected };
|
|
210
289
|
}, phase);
|
|
211
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Explicit best-of-N: draft N candidates with the session model, rank them with the
|
|
292
|
+
* configured judges, then re-measure the winner against the gate's own baseline.
|
|
293
|
+
*
|
|
294
|
+
* The tournament's `scores` are relative preference shares (wins/counts) and cannot be
|
|
295
|
+
* compared with `autoVerifyThreshold`; only the extra winner-vs-baseline comparison produces
|
|
296
|
+
* an absolute score in the gate's units. That comparison is why this tool exists instead of
|
|
297
|
+
* "generate candidates yourself and call verifier_select".
|
|
298
|
+
*
|
|
299
|
+
* Fail-closed by construction: fewer than {@link MIN_BEST_OF_N} surviving drafts is an error
|
|
300
|
+
* that names every failure, never a silent "best" picked out of a single survivor.
|
|
301
|
+
* @param agent - Agent owning the topic, and the session whose model writes the drafts.
|
|
302
|
+
* @param task - the request the drafts answer.
|
|
303
|
+
* @param count - how many drafts to request ({@link MIN_BEST_OF_N}..{@link MAX_BEST_OF_N}).
|
|
304
|
+
* @param repeats - caller's repeat count, or undefined for the default; used by both the
|
|
305
|
+
* tournament and the baseline comparison.
|
|
306
|
+
* @param signal - tool-call abort signal.
|
|
307
|
+
* @param criteriaInput - caller-supplied criteria, or undefined for the configured rubric.
|
|
308
|
+
*/
|
|
309
|
+
const bestOfN = async (agent, task, count, repeats, signal, criteriaInput) => record('verifier_best_of_n', agent, async (trace) => {
|
|
310
|
+
const { verifier, selected } = await engine(agent);
|
|
311
|
+
if (!Number.isSafeInteger(count) || count < MIN_BEST_OF_N || count > MAX_BEST_OF_N)
|
|
312
|
+
throw new Error('llm-verifier: n must be an integer between ' + MIN_BEST_OF_N + ' and ' + MAX_BEST_OF_N);
|
|
313
|
+
const rounds = capped(repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats');
|
|
314
|
+
const rubric = criteriaInput === undefined ? await configuredCriteria() : { criteria: normalizeCriteria(criteriaInput), source: 'explicit' };
|
|
315
|
+
// Bound the plan before spending the generation: with repeats and an ensemble this tool can
|
|
316
|
+
// reach the shared explicit ceiling, and an unbounded custom rubric would blow past it.
|
|
317
|
+
const planned = (selectComparisonsUpperBound(count) + 1) * rubric.criteria.length * rounds * selected.judges.length;
|
|
318
|
+
if (planned > MAX_EXPLICIT_PLANNED_CALLS)
|
|
319
|
+
throw new Error('llm-verifier: best-of-n with n=' + count + ' would issue about ' + planned + ' judge calls; reduce n, repeats, criteria or judges');
|
|
320
|
+
// The drafting model is the session's OWN model, read from the logged request header: no new
|
|
321
|
+
// configuration, and the drafts come from exactly the model the task is being done with.
|
|
322
|
+
const call = agent.session.requestHeader()?.config;
|
|
323
|
+
if (call === undefined)
|
|
324
|
+
throw new Error('llm-verifier: this session has no logged request header yet, so best-of-n cannot tell which model should write the drafts — generate candidates with parallel subagents and rank them with verifier_select instead');
|
|
325
|
+
const target = { provider: call.provider, model: call.model, ...(call.reasoningEffort === undefined ? {} : { reasoningEffort: String(call.reasoningEffort) }) };
|
|
326
|
+
const problem = explicitEvidence([task], explicitItemChars(selected), explicitBudget(selected), 'task')[0];
|
|
327
|
+
// N independent drafts, in parallel. A failure is captured per draft so the error below can
|
|
328
|
+
// name it; a draft that fails is never substituted with another one.
|
|
329
|
+
const attempts = await Promise.all(Array.from({ length: count }, async (_unused, index) => {
|
|
330
|
+
const prompt = buildGenerationPrompt(problem, index, count);
|
|
331
|
+
try {
|
|
332
|
+
const completion = await generateCandidate(verifier.client, target, prompt, signal);
|
|
333
|
+
trace?.({ label: 'draft ' + (index + 1), channel: completion.scoringMode, prompt, output: completion.text });
|
|
334
|
+
return { ok: true, text: completion.text, usage: completion.usage, truncated: completion.truncated };
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
338
|
+
}
|
|
339
|
+
}));
|
|
340
|
+
const survivors = [];
|
|
341
|
+
const failures = [];
|
|
342
|
+
attempts.forEach((attempt, index) => {
|
|
343
|
+
if (attempt.ok)
|
|
344
|
+
survivors.push({ attempt: index + 1, text: attempt.text, usage: attempt.usage, truncated: attempt.truncated });
|
|
345
|
+
else
|
|
346
|
+
failures.push('draft ' + (index + 1) + ': ' + attempt.error);
|
|
347
|
+
});
|
|
348
|
+
if (survivors.length < MIN_BEST_OF_N)
|
|
349
|
+
throw new Error('llm-verifier: best-of-n produced ' + survivors.length + ' usable draft(s) out of ' + count + '; at least ' + MIN_BEST_OF_N + ' are required to choose between them' + (failures.length === 0 ? '' : ' — ' + failures.join('; ')));
|
|
350
|
+
const ranked = await verifier.select({ problem, candidates: survivors.map(survivor => survivor.text), criteria: rubric.criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats: rounds, pivots: Math.min(2, survivors.length), seed: 0, ...(trace ? { trace } : {}) }, signal);
|
|
351
|
+
// The absolute, gate-comparable measurement. Same fixed baseline, same threshold rule as the
|
|
352
|
+
// automatic final acceptance, so "passes" here means the same thing it means at the gate.
|
|
353
|
+
// The label prefix keeps the snapshot readable: this compare reuses the same criteria as the
|
|
354
|
+
// tournament above, so without it two different comparisons share one set of labels.
|
|
355
|
+
const compared = await verifier.compare({ problem, candidateA: ranked.best, candidateB: EMPTY_WORK_BASELINE, criteria: rubric.criteria, traceLabelPrefix: 'baseline: ', ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats: rounds, ...(trace ? { trace } : {}) }, signal);
|
|
356
|
+
const criteria = compared.criteria.map(row => ({ id: row.id, name: row.name, score: row.scoreA }));
|
|
357
|
+
const threshold = selected.autoVerifyThreshold;
|
|
358
|
+
const passesThreshold = sessionAccepted({ score: compared.scoreA, winner: compared.winner, criteria }, threshold);
|
|
359
|
+
// One cost line for the whole invocation, generation included: the drafts are real tokens the
|
|
360
|
+
// operator paid for even though no judge scored them. Their price is estimated with the
|
|
361
|
+
// configured verifier table (the only one the plugin has), which the README states.
|
|
362
|
+
const stats = { ...ranked.stats };
|
|
363
|
+
addUsage(stats, compared.stats);
|
|
364
|
+
stats.cacheHits += compared.stats.cacheHits;
|
|
365
|
+
stats.cacheMisses += compared.stats.cacheMisses;
|
|
366
|
+
stats.topLogprobScores += compared.stats.topLogprobScores;
|
|
367
|
+
stats.explicitTagScores += compared.stats.explicitTagScores;
|
|
368
|
+
for (const survivor of survivors)
|
|
369
|
+
addUsage(stats, survivor.usage);
|
|
370
|
+
stats.estimatedCostUsd = ((stats.inputTokens + stats.cachedInputTokens) * selected.estimatedInputUsdPerMillion + stats.outputTokens * selected.estimatedOutputUsdPerMillion) / 1_000_000;
|
|
371
|
+
const result = {
|
|
372
|
+
best: ranked.best,
|
|
373
|
+
index: ranked.index,
|
|
374
|
+
/** 1-based draft numbers of the survivors, in `scores`/`ranking` order. */
|
|
375
|
+
sources: survivors.map(survivor => survivor.attempt),
|
|
376
|
+
scores: ranked.scores,
|
|
377
|
+
ranking: ranked.ranking,
|
|
378
|
+
comparisons: ranked.comparisons,
|
|
379
|
+
pivots: ranked.pivots,
|
|
380
|
+
generated: survivors.length,
|
|
381
|
+
failed: failures.length,
|
|
382
|
+
failures,
|
|
383
|
+
/** 1-based draft numbers still truncated after their escalated retry; empty is the normal case. */
|
|
384
|
+
truncated: survivors.filter(survivor => survivor.truncated).map(survivor => survivor.attempt),
|
|
385
|
+
score: compared.scoreA,
|
|
386
|
+
baselineScore: compared.scoreB,
|
|
387
|
+
winner: compared.winner,
|
|
388
|
+
criteria: compared.criteria,
|
|
389
|
+
threshold,
|
|
390
|
+
passesThreshold,
|
|
391
|
+
failedCriteria: failedAcceptanceCriteria(criteria, threshold).map(criterion => criterion.id),
|
|
392
|
+
calls: stats.calls,
|
|
393
|
+
stats,
|
|
394
|
+
generatorProvider: target.provider,
|
|
395
|
+
generatorModel: target.model,
|
|
396
|
+
// Every judge call of this invocation, not just the tournament's: the winner-vs-baseline
|
|
397
|
+
// comparison runs on the same clients, and the top-level call count already includes it.
|
|
398
|
+
// `ok` therefore covers both comparisons while `scores`/`ranking` stay tournament-shaped.
|
|
399
|
+
judges: ranked.judges.map((judge, index) => {
|
|
400
|
+
const baseline = compared.judges[index];
|
|
401
|
+
if (baseline === undefined)
|
|
402
|
+
return judge;
|
|
403
|
+
return {
|
|
404
|
+
...judge,
|
|
405
|
+
ok: judge.ok && baseline.ok,
|
|
406
|
+
calls: judge.calls + baseline.calls,
|
|
407
|
+
...(judge.error === undefined && baseline.error !== undefined ? { error: baseline.error } : {}),
|
|
408
|
+
};
|
|
409
|
+
}),
|
|
410
|
+
};
|
|
411
|
+
return { result, selected };
|
|
412
|
+
});
|
|
413
|
+
const classifyRoute = async (agent, prompt, signal, phase) => record('verifier_route_classify', agent, async (trace) => {
|
|
212
414
|
const { verifier, selected } = await engine(agent);
|
|
213
415
|
const completion = await callVerifierText(verifier.client, prompt, signal);
|
|
416
|
+
trace?.({ label: 'route classify', channel: completion.scoringMode, prompt, output: completion.text });
|
|
214
417
|
const stats = { ...completion.usage, cacheHits: 0, cacheMisses: 0, estimatedCostUsd: ((completion.usage.inputTokens + completion.usage.cachedInputTokens) * selected.estimatedInputUsdPerMillion + completion.usage.outputTokens * selected.estimatedOutputUsdPerMillion) / 1_000_000, topLogprobScores: 0, explicitTagScores: 1 };
|
|
215
418
|
return { result: { text: completion.text, stats }, selected };
|
|
216
419
|
}, phase);
|
|
217
420
|
const extractTask = async (agent, fromSeq, toSeq, maxChars, signal) => extractSession(agent, async (ref) => { const stored = await services.attachments.readImage(ref, signal); return { data: stored.data, mediaType: stored.ref.mediaType }; }, { fromSeq, toSeq, includeAssistantText: true, maxChars });
|
|
218
|
-
const routePolicy = (selected) => ({ mode: selected.autoVerifyMode, minConfidence: selected.autoRouteMinConfidence, maxCandidates: selected.autoRouteMaxCandidates,
|
|
421
|
+
const routePolicy = (selected) => ({ mode: selected.autoVerifyMode, minConfidence: selected.autoRouteMinConfidence, maxCandidates: selected.autoRouteMaxCandidates, maxRoutePerTask: selected.autoRouteMaxPerTask, maxRoutePerSession: selected.autoRouteMaxPerSession, maxFinalPerTask: selected.autoVerifyMaxPerTask, maxFinalPerSession: selected.autoVerifyMaxPerSession, maxModelCallsPerTask: selected.autoMaxModelCallsPerTask, maxModelCallsPerSession: selected.autoMaxModelCallsPerSession, maxInputChars: selected.autoRouteMaxInputChars, maxItemChars: selected.autoRouteMaxItemChars });
|
|
219
422
|
const routeFeedback = (decision, detail) => createUserMessage({ content: [{ type: 'text', text: '[Automatic verifier routing: ' + decision.kind + ']\n' + detail + '\nUse this independent result to continue the actual task. Do not merely restate the ranking or progress score; implement, correct, and verify the required work.' }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier', form: 'notice', summary: 'Automatic verifier routed ' + decision.kind } });
|
|
220
423
|
const handleStatisticsQuery = async (payload) => {
|
|
221
424
|
// A malformed range used to be swallowed by the Promise.allSettled fan-out below and
|
|
@@ -225,10 +428,7 @@ export function apply(ctx, config = {}) {
|
|
|
225
428
|
return rpcFailure(parsed.message);
|
|
226
429
|
const query = parsed.query;
|
|
227
430
|
try {
|
|
228
|
-
const
|
|
229
|
-
const headers = items
|
|
230
|
-
.map(item => item && typeof item === 'object' && 'header' in item ? item.header : item)
|
|
231
|
-
.filter(header => header !== undefined && (query.sessionId === undefined || String(header.id) === query.sessionId));
|
|
431
|
+
const headers = (await sessionHeaders()).filter(header => query.sessionId === undefined || String(header.id) === query.sessionId);
|
|
232
432
|
const settled = await Promise.allSettled(headers.map(async (header) => topic(header).statistics.overview(query)));
|
|
233
433
|
const overviews = [];
|
|
234
434
|
for (const result of settled) {
|
|
@@ -244,6 +444,93 @@ export function apply(ctx, config = {}) {
|
|
|
244
444
|
return rpcFailure(error instanceof Error ? error.message : String(error));
|
|
245
445
|
}
|
|
246
446
|
};
|
|
447
|
+
/**
|
|
448
|
+
* One decision snapshot by invocation id, across every topic.
|
|
449
|
+
*
|
|
450
|
+
* The dashboard shows merged topics, so the lookup fans out the same way the
|
|
451
|
+
* statistics query does instead of guessing which topic owns the record.
|
|
452
|
+
* @param id - invocation id the snapshot was stored under.
|
|
453
|
+
*/
|
|
454
|
+
const handleDecisionQuery = async (id) => {
|
|
455
|
+
if (typeof id !== 'string' || id.length === 0)
|
|
456
|
+
return rpcFailure('decision id must be a non-empty string');
|
|
457
|
+
try {
|
|
458
|
+
for (const header of await sessionHeaders()) {
|
|
459
|
+
const found = await topic(header).decisions.find(id).catch(() => undefined);
|
|
460
|
+
if (found)
|
|
461
|
+
return rpcSuccess({ decision: found });
|
|
462
|
+
}
|
|
463
|
+
return rpcFailure('decision snapshot not found: it was pruned, never captured, or belongs to a deleted topic');
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
return rpcFailure(error instanceof Error ? error.message : String(error));
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
/** Whether a payload asks for a decision snapshot instead of a statistics overview. */
|
|
470
|
+
const isDecisionQuery = (payload) => typeof payload === 'object' && payload !== null && payload.kind === 'decision';
|
|
471
|
+
/**
|
|
472
|
+
* Judge diagnostics: one real, bounded judge call per configured judge plus the resolved rubric.
|
|
473
|
+
*
|
|
474
|
+
* Answers the two questions a user actually has after configuring the plugin: "which scoring
|
|
475
|
+
* channel is this judge using" (a silent explicit-tag downgrade is invisible otherwise) and
|
|
476
|
+
* "is the rubric I configured the one in effect" (a broken custom file degrades quietly).
|
|
477
|
+
* Deliberately NOT recorded in the statistics: it is a diagnostic, not a verification.
|
|
478
|
+
* @returns Per-judge probe results and the rubric in effect.
|
|
479
|
+
*/
|
|
480
|
+
const handleProbe = async () => {
|
|
481
|
+
try {
|
|
482
|
+
// The dashboard is a GLOBAL page: there is usually no current initiator, so requiring one
|
|
483
|
+
// made the probe unusable exactly where its button lives. Fall back to the newest topic,
|
|
484
|
+
// which is also where the re-probed channel verdict should be remembered.
|
|
485
|
+
const initiator = ctx.agents.currentInitiator();
|
|
486
|
+
const header = initiator?.session.header ?? (await sessionHeaders())[0];
|
|
487
|
+
if (header === undefined)
|
|
488
|
+
return rpcFailure('llm-verifier: the judge probe needs one session to attach its capability memory to, and no session exists yet — start a session, then probe again');
|
|
489
|
+
const { verifier } = await engineForHeader(header);
|
|
490
|
+
const rubric = await configuredCriteria();
|
|
491
|
+
const criterion = rubric.criteria[0];
|
|
492
|
+
if (criterion === undefined)
|
|
493
|
+
return rpcFailure('llm-verifier: the configured rubric has no criteria');
|
|
494
|
+
const prompt = buildPairwisePrompt(PROBE_TASK, PROBE_A, PROBE_B, criterion, rubric.groundTruthNote ?? DEFAULT_GROUND_TRUTH_NOTE);
|
|
495
|
+
const judges = [];
|
|
496
|
+
for (const client of verifier.clients) {
|
|
497
|
+
const label = client.label ?? client.provider + '/' + client.model;
|
|
498
|
+
const startedAt = Date.now();
|
|
499
|
+
// Forget the cached channel verdict for this judge before probing. A mark written up to 24h
|
|
500
|
+
// ago — possibly before the provider was (re)configured, and certainly before a host
|
|
501
|
+
// restart — would make the probe replay a stale answer instead of checking, which is the one
|
|
502
|
+
// thing a diagnostic must not do. It also means a provider that CAN return logprobs starts
|
|
503
|
+
// using that better channel for subsequent real verifications.
|
|
504
|
+
client.topLogprobCapabilities.forget(client.provider, client.model);
|
|
505
|
+
try {
|
|
506
|
+
const completion = await callVerifier({ ...client, timeoutMs: Math.min(client.timeoutMs, PROBE_TIMEOUT_MS), maxRetries: 0 }, prompt);
|
|
507
|
+
judges.push({
|
|
508
|
+
label,
|
|
509
|
+
provider: client.provider,
|
|
510
|
+
model: client.model,
|
|
511
|
+
ok: true,
|
|
512
|
+
channelProbed: true,
|
|
513
|
+
channel: completion.scoringMode,
|
|
514
|
+
scoreA: extractScore(completion, '<score_A>'),
|
|
515
|
+
scoreB: extractScore(completion, '<score_B>'),
|
|
516
|
+
latencyMs: Date.now() - startedAt,
|
|
517
|
+
...completion.usage,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
judges.push({ label, provider: client.provider, model: client.model, ok: false, latencyMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) });
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return rpcSuccess({
|
|
525
|
+
judges,
|
|
526
|
+
channelProbed: true,
|
|
527
|
+
rubric: { source: rubric.source, count: rubric.criteria.length, ...(rubric.file ? { file: rubric.file } : {}), ...(rubric.error ? { error: rubric.error } : {}) },
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
return rpcFailure(error instanceof Error ? error.message : String(error));
|
|
532
|
+
}
|
|
533
|
+
};
|
|
247
534
|
// 1. 优先注册到官方 /api 共享通道 (connection.fetch.register)
|
|
248
535
|
const anyConn = services.connection;
|
|
249
536
|
if (anyConn && anyConn.fetch && typeof anyConn.fetch.register === 'function') {
|
|
@@ -263,7 +550,8 @@ export function apply(ctx, config = {}) {
|
|
|
263
550
|
const isRpcEnvelope = typeof body === 'object' && body !== null && body.type === 'client-request' && typeof body.rpcId === 'string';
|
|
264
551
|
const rpcId = isRpcEnvelope ? body.rpcId : 'direct';
|
|
265
552
|
const payload = isRpcEnvelope ? body.payload : body;
|
|
266
|
-
const
|
|
553
|
+
const isProbe = typeof payload === 'object' && payload !== null && payload.kind === 'probe';
|
|
554
|
+
const outcome = isDecisionQuery(payload) ? await handleDecisionQuery(payload.id) : isProbe ? await handleProbe() : await handleStatisticsQuery(payload);
|
|
267
555
|
if (isRpcEnvelope) {
|
|
268
556
|
return Response.json({
|
|
269
557
|
type: 'server-response',
|
|
@@ -286,6 +574,10 @@ export function apply(ctx, config = {}) {
|
|
|
286
574
|
if (typeof legacyRpc.rpc?.handle === 'function') {
|
|
287
575
|
try {
|
|
288
576
|
legacyRpc.rpc.handle('/llm-verifier', async (endpoint, payload) => {
|
|
577
|
+
if (endpoint === 'decision')
|
|
578
|
+
return handleDecisionQuery(payload?.id);
|
|
579
|
+
if (endpoint === 'probe')
|
|
580
|
+
return handleProbe();
|
|
289
581
|
if (endpoint !== 'statistics')
|
|
290
582
|
return rpcFailure('unknown llm-verifier endpoint');
|
|
291
583
|
return handleStatisticsQuery(payload);
|
|
@@ -414,13 +706,18 @@ export function apply(ctx, config = {}) {
|
|
|
414
706
|
}
|
|
415
707
|
}
|
|
416
708
|
}
|
|
417
|
-
|
|
418
|
-
|
|
709
|
+
// A previous track route already cleared the completion threshold (preferFinal): the
|
|
710
|
+
// final gate is mandatory and already armed, so buying another route first would only
|
|
711
|
+
// delay it with evidence whose steered text would be identical. The preference is
|
|
712
|
+
// consumed by the final reservation, so a failed acceptance releases routing again.
|
|
713
|
+
const finalPreferred = autoRouter.finalPreferred(agent);
|
|
714
|
+
const structured = finalPreferred ? undefined : analyzeStructuredRoute(snapshot, selected.autoRouteMaxCandidates, selected.autoRouteMaxItemChars, selected.autoRouteMaxInputChars);
|
|
715
|
+
let decision = finalPreferred ? undefined : boundDecision(structured, policy);
|
|
419
716
|
if (structured !== undefined && decision === undefined) {
|
|
420
717
|
ctx.logger.warn('llm-verifier automatic ' + structured.kind + ' route dropped: its evidence exceeds the per-item/total routing caps (' + selected.autoRouteMaxItemChars + '/' + selected.autoRouteMaxInputChars + ' characters)');
|
|
421
718
|
await recordSkippedRoute(agent, structured.kind, 'structured', 'dropped-over-budget');
|
|
422
719
|
}
|
|
423
|
-
if (decision === undefined && selected.autoRouteSemantic && (selected.autoVerifyMode === 'strict' || semanticRouteHint(snapshot))) {
|
|
720
|
+
if (!finalPreferred && decision === undefined && selected.autoRouteSemantic && (selected.autoVerifyMode === 'strict' || semanticRouteHint(snapshot))) {
|
|
424
721
|
const fingerprint = stableHash({ phase: 'semantic', from: evidence.taskStartSeq, to: admittedLastSeq, model: selected.provider + '/' + selected.model });
|
|
425
722
|
const reservation = autoRouter.reserve(agent, 'semantic', fingerprint, 1, policy);
|
|
426
723
|
if (reservation) {
|
|
@@ -462,8 +759,9 @@ export function apply(ctx, config = {}) {
|
|
|
462
759
|
if (decision) {
|
|
463
760
|
// Every judge scores every match, and compare/select round the repeat count up to an
|
|
464
761
|
// even number so the A/B slots get swapped, so the reservation has to cover both.
|
|
465
|
-
const repeats = routedRepeats(decision, selected.autoVerifyRepeats);
|
|
466
|
-
const
|
|
762
|
+
const repeats = routedRepeats(decision, selected.autoVerifyRepeats, selected.autoTrackRepeats);
|
|
763
|
+
const rubric = await configuredCriteria();
|
|
764
|
+
const expectedCalls = estimateRoutedCalls(decision, repeats, rubric.criteria.length) * selected.judges.length;
|
|
467
765
|
const reservation = autoRouter.reserve(agent, decision.kind, decision.fingerprint, expectedCalls, policy);
|
|
468
766
|
if (reservation === undefined) {
|
|
469
767
|
// A refused reservation used to drop the whole routed decision silently.
|
|
@@ -483,7 +781,7 @@ export function apply(ctx, config = {}) {
|
|
|
483
781
|
try {
|
|
484
782
|
const extracted = await extractTask(agent, evidence.taskStartSeq, admittedLastSeq, selected.autoVerifyMaxChars, signal);
|
|
485
783
|
if (decision.kind === 'compare') {
|
|
486
|
-
const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, repeats, signal, extracted.images, 'compare');
|
|
784
|
+
const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, repeats, signal, rubric, extracted.images, 'compare');
|
|
487
785
|
if (!stillCurrent()) {
|
|
488
786
|
autoRouter.fail(agent, reservation, false);
|
|
489
787
|
return;
|
|
@@ -495,7 +793,7 @@ export function apply(ctx, config = {}) {
|
|
|
495
793
|
return;
|
|
496
794
|
}
|
|
497
795
|
if (decision.kind === 'select') {
|
|
498
|
-
const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), repeats, signal, extracted.images, 'select');
|
|
796
|
+
const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), repeats, signal, rubric, extracted.images, 'select');
|
|
499
797
|
if (!stillCurrent()) {
|
|
500
798
|
autoRouter.fail(agent, reservation, false);
|
|
501
799
|
return;
|
|
@@ -519,7 +817,12 @@ export function apply(ctx, config = {}) {
|
|
|
519
817
|
// yet", so "any checkpoint below threshold" made the continue branch
|
|
520
818
|
// unconditional (5/5 live routes) and the threshold meaningless.
|
|
521
819
|
const latest = result.scores.length > 0 ? result.scores[result.scores.length - 1] : 0;
|
|
522
|
-
const
|
|
820
|
+
const completed = latest >= selected.autoTrackCompletionThreshold;
|
|
821
|
+
// The work already reads as done: arm the next boundary to run the mandatory final
|
|
822
|
+
// gate instead of paying for another progress route that would say the same thing.
|
|
823
|
+
if (completed)
|
|
824
|
+
autoRouter.preferFinal(agent);
|
|
825
|
+
const continuation = completed ? '\nPrepare final delivery evidence; final session verification is mandatory.' : '\nContinue the unfinished work.';
|
|
523
826
|
agent.steer(routeFeedback(decision, detail + continuation));
|
|
524
827
|
return;
|
|
525
828
|
}
|
|
@@ -549,7 +852,8 @@ export function apply(ctx, config = {}) {
|
|
|
549
852
|
const finalFingerprint = stableHash({ phase: 'final', from: finalFromSeq, to: admittedLastSeq });
|
|
550
853
|
// Exact planning instead of a flat 4-per-repeat guess: one comparison per
|
|
551
854
|
// criterion per repeat, repeated for every judge in the ensemble.
|
|
552
|
-
const
|
|
855
|
+
const finalRubric = await configuredCriteria();
|
|
856
|
+
const finalReservation = autoRouter.reserve(agent, 'final', finalFingerprint, Math.max(1, finalRubric.criteria.length * selected.autoVerifyFinalRepeats) * selected.judges.length, policy);
|
|
553
857
|
if (!finalReservation) {
|
|
554
858
|
// Same one-shot rule: a spent budget never grants another reservation, so
|
|
555
859
|
// an unconditional steer here would livelock the turn.
|
|
@@ -562,7 +866,7 @@ export function apply(ctx, config = {}) {
|
|
|
562
866
|
return;
|
|
563
867
|
}
|
|
564
868
|
try {
|
|
565
|
-
const result = await verifySession(agent, { fromSeq: finalFromSeq, toSeq: admittedLastSeq, includeAssistantText: true, maxChars: selected.autoVerifyMaxChars, repeats: selected.autoVerifyFinalRepeats }, signal, 'final');
|
|
869
|
+
const result = await verifySession(agent, { fromSeq: finalFromSeq, toSeq: admittedLastSeq, includeAssistantText: true, maxChars: selected.autoVerifyMaxChars, repeats: selected.autoVerifyFinalRepeats }, signal, 'final', finalRubric);
|
|
566
870
|
if (!stillCurrent()) {
|
|
567
871
|
autoRouter.fail(agent, finalReservation, false);
|
|
568
872
|
return;
|
|
@@ -586,12 +890,62 @@ export function apply(ctx, config = {}) {
|
|
|
586
890
|
agent.steer(createUserMessage({ content: [{ type: 'text', text: '[Automatic verifier gate]\nStrict final verification failed: ' + (error instanceof Error ? error.message : String(error)) + '\nDo not conclude until verification succeeds.' }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }));
|
|
587
891
|
}
|
|
588
892
|
});
|
|
589
|
-
ctx.tools.register(defineTool({ name: 'verifier_compare', description: 'Use autonomously when exactly two substantive answers, patches, plans, or execution trajectories need an independent evidence-based comparison and the choice is consequential or uncertain. Do not use for trivial deterministic questions or when there is only one candidate. Uses the verifier model selected in DSH Settings (or the configured judge ensemble) with top-logprob A–T expectations when supported and explicit-tag fallback otherwise.', parameters: { problem: { type: 'string', required: true }, candidate_a: { type: 'string', required: true }, candidate_b: { type: 'string', required: true }, ...commonParams }, output: { schema: { type: 'object', additionalProperties: false, properties: { scoreA: { type: 'number', required: true }, scoreB: { type: 'number', required: true }, winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true }, criteria: { type: 'array', items: criterionResultSchema, required: true }, agreement: { type: 'number', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return record('verifier_compare', agent, async () => { const { verifier, selected } = await engine(agent); const [problem, candidateA, candidateB] = explicitEvidence([args.problem, args.candidate_a, args.candidate_b], explicitItemChars(selected), explicitBudget(selected), 'compare input'); const result = await verifier.compare({ problem, candidateA, candidateB, criteria:
|
|
590
|
-
ctx.tools.register(defineTool({ name: 'verifier_select', description: 'Use autonomously when three or more substantive candidate answers, patches, plans, or trajectories must be ranked and an independent choice is valuable. Use verifier_compare for exactly two candidates
|
|
591
|
-
throw new Error('llm-verifier: candidates must contain at most ' + limit + ' entries'); const candidates = explicitEvidence(args.candidates, explicitItemChars(selected), explicitBudget(selected), 'candidates'); const
|
|
592
|
-
throw new Error('llm-verifier: this selection would issue about ' + planned + ' judge calls; reduce candidates or repeats'); const result = await verifier.select({ problem: sanitizeVerifierText(args.problem, explicitItemChars(selected)), candidates, criteria, repeats, pivots: capped(args.pivots, 2, Math.max(1, candidates.length), 'pivots'), seed: args.seed ?? 0, images: await images(args.images, exec.signal) }, exec.signal); return { result, selected }; }); } }));
|
|
593
|
-
ctx.tools.register(defineTool({ name: 'verifier_track', description: 'Use autonomously for a genuinely multi-step task when progress at explicit checkpoints is uncertain or needs evidence-based measurement. Deterministic goal/workflow orchestrators should call this directly when real checkpoints already exist. Do not use for a single completed answer or invent checkpoints.', parameters: { problem: { type: 'string', required: true }, steps: { type: 'array', items: { type: 'string' }, required: true }, checkpoints: { type: 'array', items: { type: 'integer' }, required: true }, repeats: commonParams.repeats, images: commonParams.images }, output: { schema: { type: 'object', additionalProperties: false, properties: { scores: { type: 'array', items: { type: 'number' }, required: true }, perRepeat: { type: 'array', items: { type: 'array', items: { type: 'number' } }, required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return record('verifier_track', agent, async () => { const { verifier, selected } = await engine(agent); if (args.steps.length > MAX_TRACK_STEPS)
|
|
594
|
-
throw new Error('llm-verifier: steps must contain at most ' + MAX_TRACK_STEPS + ' entries'); const steps = explicitEvidence(args.steps, explicitItemChars(selected), explicitBudget(selected), 'steps'); const result = await verifier.track(sanitizeVerifierText(args.problem, explicitItemChars(selected)), steps, args.checkpoints, capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats'), exec.signal, await images(args.images, exec.signal)); return { result, selected }; }); } }));
|
|
893
|
+
ctx.tools.register(defineTool({ name: 'verifier_compare', description: 'Use autonomously when exactly two substantive answers, patches, plans, or execution trajectories need an independent evidence-based comparison and the choice is consequential or uncertain. Do not use for trivial deterministic questions or when there is only one candidate. Uses the verifier model selected in DSH Settings (or the configured judge ensemble) with top-logprob A–T expectations when supported and explicit-tag fallback otherwise.', parameters: { problem: { type: 'string', required: true }, candidate_a: { type: 'string', required: true }, candidate_b: { type: 'string', required: true }, ...commonParams }, output: { schema: { type: 'object', additionalProperties: false, properties: { scoreA: { type: 'number', required: true }, scoreB: { type: 'number', required: true }, winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true }, criteria: { type: 'array', items: criterionResultSchema, required: true }, agreement: { type: 'number', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return record('verifier_compare', agent, async (trace) => { const { verifier, selected } = await engine(agent); const [problem, candidateA, candidateB] = explicitEvidence([args.problem, args.candidate_a, args.candidate_b], explicitItemChars(selected), explicitBudget(selected), 'compare input'); const rubric = args.criteria === undefined ? await configuredCriteria() : { criteria: normalizeCriteria(args.criteria), source: 'explicit' }; const result = await verifier.compare({ problem, candidateA, candidateB, criteria: rubric.criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats: capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats'), images: await images(args.images, exec.signal), ...(trace ? { trace } : {}) }, exec.signal); return { result, selected }; }); } }));
|
|
894
|
+
ctx.tools.register(defineTool({ name: 'verifier_select', description: 'Use autonomously when three or more substantive candidate answers, patches, plans, or trajectories must be ranked and an independent choice is valuable. Use verifier_compare for exactly two candidates. Generating extra candidates pays off only when the artifact is a final deliverable and choosing wrong is expensive: produce them (for example with parallel subagents), then rank the real ones here. Do not pad the list with near-duplicates. Deterministic orchestrators should call this directly once they have three or more real candidates.', parameters: { problem: { type: 'string', required: true }, candidates: { type: 'array', items: { type: 'string' }, required: true }, ...commonParams, pivots: { type: 'integer' }, seed: { type: 'integer' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { index: { type: 'integer', required: true }, best: { type: 'string', required: true }, scores: { type: 'array', items: { type: 'number' }, required: true }, ranking: { type: 'array', items: { type: 'integer' }, required: true }, pivots: { type: 'array', items: { type: 'integer' }, required: true }, comparisons: { type: 'integer', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 100, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return record('verifier_select', agent, async (trace) => { const { verifier, selected } = await engine(agent); const limit = explicitCandidateLimit(selected); if (args.candidates.length > limit)
|
|
895
|
+
throw new Error('llm-verifier: candidates must contain at most ' + limit + ' entries'); const candidates = explicitEvidence(args.candidates, explicitItemChars(selected), explicitBudget(selected), 'candidates'); const rubric = args.criteria === undefined ? await configuredCriteria() : { criteria: normalizeCriteria(args.criteria), source: 'explicit' }; const criteria = rubric.criteria; const repeats = capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats'); const planned = plannedComparisons(candidates.length) * (criteria?.length || 3) * repeats; if (planned > MAX_EXPLICIT_PLANNED_CALLS)
|
|
896
|
+
throw new Error('llm-verifier: this selection would issue about ' + planned + ' judge calls; reduce candidates or repeats'); const result = await verifier.select({ problem: sanitizeVerifierText(args.problem, explicitItemChars(selected)), candidates, criteria, ...(rubric.groundTruthNote ? { groundTruthNote: rubric.groundTruthNote } : {}), repeats, pivots: capped(args.pivots, 2, Math.max(1, candidates.length), 'pivots'), seed: args.seed ?? 0, images: await images(args.images, exec.signal), ...(trace ? { trace } : {}) }, exec.signal); return { result, selected }; }); } }));
|
|
897
|
+
ctx.tools.register(defineTool({ name: 'verifier_track', description: 'Use autonomously for a genuinely multi-step task when progress at explicit checkpoints is uncertain or needs evidence-based measurement. Deterministic goal/workflow orchestrators should call this directly when real checkpoints already exist. Do not use for a single completed answer or invent checkpoints.', parameters: { problem: { type: 'string', required: true }, steps: { type: 'array', items: { type: 'string' }, required: true }, checkpoints: { type: 'array', items: { type: 'integer' }, required: true }, repeats: commonParams.repeats, images: commonParams.images }, output: { schema: { type: 'object', additionalProperties: false, properties: { scores: { type: 'array', items: { type: 'number' }, required: true }, perRepeat: { type: 'array', items: { type: 'array', items: { type: 'number' } }, required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return record('verifier_track', agent, async (trace) => { const { verifier, selected } = await engine(agent); if (args.steps.length > MAX_TRACK_STEPS)
|
|
898
|
+
throw new Error('llm-verifier: steps must contain at most ' + MAX_TRACK_STEPS + ' entries'); const steps = explicitEvidence(args.steps, explicitItemChars(selected), explicitBudget(selected), 'steps'); const result = await verifier.track(sanitizeVerifierText(args.problem, explicitItemChars(selected)), steps, args.checkpoints, capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats'), exec.signal, await images(args.images, exec.signal), trace); return { result, selected }; }); } }));
|
|
899
|
+
ctx.tools.register(defineTool({
|
|
900
|
+
name: 'verifier_best_of_n',
|
|
901
|
+
description: 'Use ONLY when a final deliverable is expensive to get wrong and no candidate exists yet: this drafts n independent candidates with the current session model, has the independent verifier rank them, and then re-scores the winner against the same fixed empty-work baseline the automatic acceptance gate uses. A draft that hits its output ceiling is kept and listed in truncated — the judge sees the incomplete text and scores it as such, and discarding it would waste a generation already paid for. It is by far the most expensive verifier tool: on the default 3-criterion rubric it costs 2 generations + 12 judge calls at n=2, 3 + 24 at n=3, and 4 + 36..60 at n=4 — never call it per turn, for trivial questions, or to compare candidates you already have (rank those with verifier_select or verifier_compare). scores are RELATIVE tournament shares; only score, criteria, threshold and passesThreshold are absolute and comparable with the acceptance gate. Requires a session whose model is already known; if the session has no logged request header, write the candidates with parallel subagents and call verifier_select instead.',
|
|
902
|
+
parameters: {
|
|
903
|
+
task: { type: 'string', required: true, description: 'The request every draft must answer. Bound to the explicit per-item evidence cap after redaction.' },
|
|
904
|
+
n: { type: 'integer', description: 'How many independent drafts to generate; between 2 and 4, default 3. Cost grows with n.' },
|
|
905
|
+
criteria: commonParams.criteria,
|
|
906
|
+
repeats: commonParams.repeats,
|
|
907
|
+
},
|
|
908
|
+
output: {
|
|
909
|
+
schema: {
|
|
910
|
+
type: 'object',
|
|
911
|
+
additionalProperties: false,
|
|
912
|
+
properties: {
|
|
913
|
+
best: { type: 'string', required: true },
|
|
914
|
+
index: { type: 'integer', required: true },
|
|
915
|
+
sources: { type: 'array', items: { type: 'integer' }, required: true },
|
|
916
|
+
scores: { type: 'array', items: { type: 'number' }, required: true },
|
|
917
|
+
ranking: { type: 'array', items: { type: 'integer' }, required: true },
|
|
918
|
+
comparisons: { type: 'integer', required: true },
|
|
919
|
+
pivots: { type: 'array', items: { type: 'integer' }, required: true },
|
|
920
|
+
generated: { type: 'integer', required: true },
|
|
921
|
+
failed: { type: 'integer', required: true },
|
|
922
|
+
failures: { type: 'array', items: { type: 'string' }, required: true },
|
|
923
|
+
truncated: { type: 'array', items: { type: 'integer' }, required: true },
|
|
924
|
+
score: { type: 'number', required: true },
|
|
925
|
+
baselineScore: { type: 'number', required: true },
|
|
926
|
+
winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true },
|
|
927
|
+
criteria: { type: 'array', items: criterionResultSchema, required: true },
|
|
928
|
+
threshold: { type: 'number', required: true },
|
|
929
|
+
passesThreshold: { type: 'boolean', required: true },
|
|
930
|
+
failedCriteria: { type: 'array', items: { type: 'string' }, required: true },
|
|
931
|
+
calls: { type: 'integer', required: true },
|
|
932
|
+
stats: { ...statsSchema, required: true },
|
|
933
|
+
generatorProvider: { type: 'string', required: true },
|
|
934
|
+
generatorModel: { type: 'string', required: true },
|
|
935
|
+
provider: { type: 'string', required: true },
|
|
936
|
+
model: { type: 'string', required: true },
|
|
937
|
+
judges: judgesSchema,
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
render: (_args, value) => renderJson(value),
|
|
941
|
+
},
|
|
942
|
+
timeoutMs: entry.timeoutMs * 100,
|
|
943
|
+
async execute(args, exec) {
|
|
944
|
+
requireEnabled();
|
|
945
|
+
const agent = requireAgent(exec.agent);
|
|
946
|
+
return bestOfN(agent, args.task, args.n ?? DEFAULT_BEST_OF_N, args.repeats, exec.signal, args.criteria);
|
|
947
|
+
},
|
|
948
|
+
}));
|
|
595
949
|
ctx.tools.register(defineTool({ name: 'verifier_current_session', description: 'Explicitly verify the current DSH session. Smart/strict policy can also invoke this gate automatically at the turn-stopping lifecycle boundary after consequential work with real tool evidence. Extracts the session, applies redaction and bounds, then sends the evidence to the configured verifier model.', parameters: { from_seq: { type: 'integer' }, to_seq: { type: 'integer' }, include_assistant_text: { type: 'boolean' }, redact_patterns: { type: 'array', items: { type: 'string' } }, max_chars: { type: 'integer' }, repeats: { type: 'integer' } }, output: { schema: { type: 'object', additionalProperties: false, properties: { sessionId: { type: 'string', required: true }, problem: { type: 'string', required: true }, score: { type: 'number', required: true }, baselineScore: { type: 'number', required: true }, winner: { type: 'string', enum: ['A', 'B', 'tie'], required: true }, fromSeq: { type: 'integer', required: true }, toSeq: { type: 'integer', required: true }, omittedCharacters: { type: 'integer', required: true }, agreement: { type: 'number', required: true }, calls: { type: 'integer', required: true }, stats: { ...statsSchema, required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, judges: judgesSchema } }, render: (_args, value) => renderJson(value) }, timeoutMs: entry.timeoutMs * 20, async execute(args, exec) { requireEnabled(); const agent = requireAgent(exec.agent); return verifySession(agent, { fromSeq: args.from_seq, toSeq: args.to_seq, includeAssistantText: args.include_assistant_text, redactPatterns: args.redact_patterns, maxChars: args.max_chars === undefined ? undefined : capped(args.max_chars, 200000, MAX_SESSION_CHARS, 'max_chars'), repeats: capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats') }, exec.signal); } }));
|
|
596
950
|
}
|
|
597
951
|
//# sourceMappingURL=index.js.map
|