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.
Files changed (97) hide show
  1. package/README.md +133 -9
  2. package/lib/auto-B5V49e-A.js +1517 -0
  3. package/lib/auto-B5V49e-A.js.map +1 -0
  4. package/lib/{caller-DJd_FlrQ.js → caller-DR_C7ueP.js} +154 -20
  5. package/lib/caller-DR_C7ueP.js.map +1 -0
  6. package/lib/caller.js +2 -2
  7. package/lib/client.js +804 -29
  8. package/lib/client.js.map +1 -1
  9. package/lib/core.js +226 -3
  10. package/lib/core.js.map +1 -1
  11. package/lib/index.js +1014 -1337
  12. package/lib/index.js.map +1 -1
  13. package/lib/replay.js +157 -0
  14. package/lib/replay.js.map +1 -0
  15. package/lib/types/auto.js +1 -1
  16. package/lib/types/auto.js.map +1 -1
  17. package/lib/types/caller.d.ts +65 -0
  18. package/lib/types/caller.d.ts.map +1 -1
  19. package/lib/types/caller.js +113 -5
  20. package/lib/types/caller.js.map +1 -1
  21. package/lib/types/client-i18n.d.ts +163 -6
  22. package/lib/types/client-i18n.d.ts.map +1 -1
  23. package/lib/types/client-i18n.js +137 -26
  24. package/lib/types/client-i18n.js.map +1 -1
  25. package/lib/types/client.d.ts +30 -0
  26. package/lib/types/client.d.ts.map +1 -1
  27. package/lib/types/client.js +94 -9
  28. package/lib/types/client.js.map +1 -1
  29. package/lib/types/config.d.ts +33 -0
  30. package/lib/types/config.d.ts.map +1 -1
  31. package/lib/types/config.js +17 -2
  32. package/lib/types/config.js.map +1 -1
  33. package/lib/types/core.d.ts +84 -0
  34. package/lib/types/core.d.ts.map +1 -1
  35. package/lib/types/core.js +222 -2
  36. package/lib/types/core.js.map +1 -1
  37. package/lib/types/criteria.d.ts +33 -0
  38. package/lib/types/criteria.d.ts.map +1 -0
  39. package/lib/types/criteria.js +67 -0
  40. package/lib/types/criteria.js.map +1 -0
  41. package/lib/types/decisions.d.ts +84 -0
  42. package/lib/types/decisions.d.ts.map +1 -0
  43. package/lib/types/decisions.js +209 -0
  44. package/lib/types/decisions.js.map +1 -0
  45. package/lib/types/engine.d.ts +54 -1
  46. package/lib/types/engine.d.ts.map +1 -1
  47. package/lib/types/engine.js +175 -12
  48. package/lib/types/engine.js.map +1 -1
  49. package/lib/types/index.d.ts +1 -0
  50. package/lib/types/index.d.ts.map +1 -1
  51. package/lib/types/index.js +392 -38
  52. package/lib/types/index.js.map +1 -1
  53. package/lib/types/replay.d.ts +88 -0
  54. package/lib/types/replay.d.ts.map +1 -0
  55. package/lib/types/replay.js +136 -0
  56. package/lib/types/replay.js.map +1 -0
  57. package/lib/types/router.d.ts +29 -5
  58. package/lib/types/router.d.ts.map +1 -1
  59. package/lib/types/router.js +283 -46
  60. package/lib/types/router.js.map +1 -1
  61. package/lib/types/statistics.d.ts +10 -1
  62. package/lib/types/statistics.d.ts.map +1 -1
  63. package/lib/types/statistics.js +35 -7
  64. package/lib/types/statistics.js.map +1 -1
  65. package/lib/types/top-logprobs.d.ts +36 -0
  66. package/lib/types/top-logprobs.d.ts.map +1 -1
  67. package/lib/types/top-logprobs.js +41 -14
  68. package/lib/types/top-logprobs.js.map +1 -1
  69. package/package.json +6 -1
  70. package/scripts/eval-replay.mjs +118 -0
  71. package/src/auto.ts +1 -1
  72. package/src/caller.test.ts +62 -1
  73. package/src/caller.ts +124 -4
  74. package/src/client-i18n.ts +137 -25
  75. package/src/client.test.ts +21 -0
  76. package/src/client.tsx +145 -7
  77. package/src/config.test.ts +32 -0
  78. package/src/config.ts +49 -2
  79. package/src/core.test.ts +95 -0
  80. package/src/core.ts +217 -2
  81. package/src/criteria.test.ts +60 -0
  82. package/src/criteria.ts +76 -0
  83. package/src/decisions.test.ts +150 -0
  84. package/src/decisions.ts +244 -0
  85. package/src/engine.test.ts +131 -0
  86. package/src/engine.ts +167 -10
  87. package/src/index.test.ts +278 -8
  88. package/src/index.ts +373 -37
  89. package/src/replay.test.ts +82 -0
  90. package/src/replay.ts +155 -0
  91. package/src/router.test.ts +240 -1
  92. package/src/router.ts +298 -48
  93. package/src/statistics.test.ts +49 -1
  94. package/src/statistics.ts +44 -7
  95. package/src/top-logprobs.test.ts +28 -0
  96. package/src/top-logprobs.ts +56 -8
  97. package/lib/caller-DJd_FlrQ.js.map +0 -1
@@ -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 { DEFAULT_CRITERIA } from "./core.js";
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
- const engine = async (agent) => {
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(agent.session.header);
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 statistics = topic(agent.session.header).statistics;
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 compared = await verifier.compare({ problem: extracted.problem, candidateA: extracted.trace, candidateB: '(No useful work or verification was performed.)', repeats: positive(options.repeats, 2, 'repeats'), images: extracted.images }, signal);
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
- const classifyRoute = async (agent, prompt, signal, phase) => record('verifier_route_classify', agent, async () => {
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, maxPerTask: selected.autoRouteMaxPerTask + selected.autoVerifyMaxPerTask, maxPerSession: selected.autoRouteMaxPerSession + selected.autoVerifyMaxPerSession, maxModelCallsPerTask: selected.autoMaxModelCallsPerTask, maxModelCallsPerSession: selected.autoMaxModelCallsPerSession, maxInputChars: selected.autoRouteMaxInputChars, maxItemChars: selected.autoRouteMaxItemChars });
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 items = await services.sessionPersistence.list();
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 outcome = await handleStatisticsQuery(payload);
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
- const structured = analyzeStructuredRoute(snapshot, selected.autoRouteMaxCandidates, selected.autoRouteMaxItemChars, selected.autoRouteMaxInputChars);
418
- let decision = boundDecision(structured, policy);
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 expectedCalls = estimateRoutedCalls(decision, repeats, DEFAULT_CRITERIA.length) * selected.judges.length;
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 continuation = latest < selected.autoTrackCompletionThreshold ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.';
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 finalReservation = autoRouter.reserve(agent, 'final', finalFingerprint, Math.max(1, DEFAULT_CRITERIA.length * selected.autoVerifyFinalRepeats) * selected.judges.length, policy);
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: normalizeCriteria(args.criteria), repeats: capped(args.repeats, 2, MAX_EXPLICIT_REPEATS, 'repeats'), images: await images(args.images, exec.signal) }, exec.signal); return { result, selected }; }); } }));
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; do not generate extra candidates merely to invoke this tool. 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 () => { const { verifier, selected } = await engine(agent); const limit = explicitCandidateLimit(selected); if (args.candidates.length > limit)
591
- throw new Error('llm-verifier: candidates must contain at most ' + limit + ' entries'); const candidates = explicitEvidence(args.candidates, explicitItemChars(selected), explicitBudget(selected), 'candidates'); const criteria = normalizeCriteria(args.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)
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