dsh-llm-verifier 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/lib/client.js +23 -3
- package/lib/client.js.map +1 -1
- package/lib/index.js +389 -115
- package/lib/index.js.map +1 -1
- package/lib/types/auto.d.ts +32 -12
- package/lib/types/auto.d.ts.map +1 -1
- package/lib/types/auto.js +50 -38
- package/lib/types/auto.js.map +1 -1
- package/lib/types/client-i18n.d.ts +21 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +22 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +1 -1
- package/lib/types/client.js.map +1 -1
- package/lib/types/engine.d.ts +19 -0
- package/lib/types/engine.d.ts.map +1 -1
- package/lib/types/engine.js +54 -4
- package/lib/types/engine.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +32 -43
- package/lib/types/index.js.map +1 -1
- package/lib/types/router.d.ts +31 -0
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +192 -17
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +28 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +101 -0
- package/lib/types/statistics.js.map +1 -1
- package/package.json +1 -1
- package/src/auto.test.ts +42 -23
- package/src/auto.ts +55 -46
- package/src/client-i18n.ts +32 -4
- package/src/client.test.ts +48 -3
- package/src/client.tsx +2 -0
- package/src/config.test.ts +6 -3
- package/src/engine.test.ts +40 -1
- package/src/engine.ts +56 -4
- package/src/index.ts +34 -44
- package/src/router.test.ts +218 -3
- package/src/router.ts +182 -14
- package/src/statistics.test.ts +116 -0
- package/src/statistics.ts +112 -0
package/src/engine.test.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
5
|
import type { VerifierClientConfig } from './caller.ts'
|
|
6
6
|
import { ScoreCache, SingleFlight, type CachedPairScore } from './cache.ts'
|
|
7
|
-
import { VerifierEngine } from './engine.ts'
|
|
7
|
+
import { VerifierEngine, orientRoundPairs } from './engine.ts'
|
|
8
|
+
import { pivotRoundPairs } from './core.ts'
|
|
8
9
|
import { TopLogprobCapabilityCache } from './top-logprobs.ts'
|
|
9
10
|
|
|
10
11
|
function chunks(text: string) { return [{ type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text }, { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 7, cacheReadTokens: 3, outputTokens: 4, reasoningTokens: 2 } }, { type: 'finish', reason: { kind: 'stop' } }] as any[] }
|
|
@@ -72,6 +73,44 @@ describe('VerifierEngine tournament', () => {
|
|
|
72
73
|
})
|
|
73
74
|
})
|
|
74
75
|
|
|
76
|
+
describe('pivot round orientation', () => {
|
|
77
|
+
it('gives every ring leader both slots instead of always trajectory B', () => {
|
|
78
|
+
// Regression: the pivot round emitted [candidate, pivot] for every edge, so the ring
|
|
79
|
+
// leaders sat in trajectory B in ALL their extra matches — a judge that merely
|
|
80
|
+
// prefers slot A then averaged 0.36 for the pivots against 0.60 for everyone else
|
|
81
|
+
// and pushed the leaders to the bottom of the final ranking.
|
|
82
|
+
const key = ([a, b]: readonly [number, number]) => (a < b ? a + ',' + b : b + ',' + a)
|
|
83
|
+
for (const count of [4, 5, 6, 8]) {
|
|
84
|
+
const pivots = [0, 1]
|
|
85
|
+
const raw = pivotRoundPairs(count, pivots)
|
|
86
|
+
const oriented = orientRoundPairs(raw)
|
|
87
|
+
// Same unordered pairs — only the presentation order may change.
|
|
88
|
+
expect(oriented).toHaveLength(raw.length)
|
|
89
|
+
expect(oriented.map(key).sort()).toEqual(raw.map(key).sort())
|
|
90
|
+
expect(new Set(oriented.map(key)).size).toBe(oriented.length)
|
|
91
|
+
const slotA = new Map<number, number>()
|
|
92
|
+
const slotB = new Map<number, number>()
|
|
93
|
+
for (const [a, b] of oriented) {
|
|
94
|
+
slotA.set(a, (slotA.get(a) ?? 0) + 1)
|
|
95
|
+
slotB.set(b, (slotB.get(b) ?? 0) + 1)
|
|
96
|
+
}
|
|
97
|
+
for (const pivot of pivots) {
|
|
98
|
+
const a = slotA.get(pivot) ?? 0
|
|
99
|
+
const b = slotB.get(pivot) ?? 0
|
|
100
|
+
expect(a, 'count=' + count + ' pivot=' + pivot + ' must not always sit in slot B').toBeGreaterThan(0)
|
|
101
|
+
expect(Math.abs(a - b), 'count=' + count + ' pivot=' + pivot + ' slot balance').toBeLessThanOrEqual(1)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('leaves a two-candidate select with exactly one match', () => {
|
|
107
|
+
// The orientation must not invent or duplicate matches.
|
|
108
|
+
const oriented = orientRoundPairs([[0, 1]])
|
|
109
|
+
expect(oriented).toHaveLength(1)
|
|
110
|
+
expect([oriented[0]![0], oriented[0]![1]].sort()).toEqual([0, 1])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
75
114
|
describe('VerifierEngine cache identity', () => {
|
|
76
115
|
it('does not reuse an explicit-tag cache entry for top-logprobs scoring', async () => {
|
|
77
116
|
const dir = mkdtempSync(join(tmpdir(), 'dsh-verifier-engine-'))
|
package/src/engine.ts
CHANGED
|
@@ -60,6 +60,56 @@ function median(values: readonly number[]): number {
|
|
|
60
60
|
function blankStats(): RunStats { return { ...emptyUsage(), cacheHits: 0, cacheMisses: 0, estimatedCostUsd: 0, topLogprobScores: 0, explicitTagScores: 0 } }
|
|
61
61
|
function unorderedPair(a: number, b: number): string { return a < b ? a + ',' + b : b + ',' + a }
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Deterministic A/B slot for one pivot-round pair, balanced by construction.
|
|
65
|
+
*
|
|
66
|
+
* The round used to emit `[candidate, pivot]` for every edge, so the ring leaders sat
|
|
67
|
+
* in trajectory B in all of their extra matches: with a judge that merely prefers slot A
|
|
68
|
+
* the pivots averaged 0.36 against 0.60 for everyone else and sank in the final ranking.
|
|
69
|
+
* Alternating on the sum of the two RANKS — not the raw indices: the pivot set is
|
|
70
|
+
* selected by score, so its indices can share a parity and a parity rule would then
|
|
71
|
+
* handicap half the field — gives every pivot and every candidate both slots within one
|
|
72
|
+
* edge of each other, without a second model call.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately NOT in core.ts: `pivotRoundPairs` is compared against the upstream Python
|
|
75
|
+
* reference in `parity.test.ts`, so the orientation stays an orchestration-side decision.
|
|
76
|
+
* @param pair - one unordered pair from the pivot round.
|
|
77
|
+
* @param pivotRanks - pivot index → its rank among the pivots.
|
|
78
|
+
* @param nonPivotRanks - candidate index → its rank among the non-pivots.
|
|
79
|
+
* @returns The pair in the order it should be presented.
|
|
80
|
+
*/
|
|
81
|
+
export function orientRoundPairs(pairs: readonly (readonly [number, number])[]): Array<[number, number]> {
|
|
82
|
+
const balance = new Map<number, number>()
|
|
83
|
+
return pairs.map(([a, b]) => {
|
|
84
|
+
const first = balance.get(a) ?? 0
|
|
85
|
+
const second = balance.get(b) ?? 0
|
|
86
|
+
// Slot A goes to whichever endpoint has held it less often; an equal count keeps the
|
|
87
|
+
// incoming order, so the result is deterministic for a given pair sequence.
|
|
88
|
+
const [x, y] = second < first ? [b, a] : [a, b]
|
|
89
|
+
balance.set(x, (balance.get(x) ?? 0) + 1)
|
|
90
|
+
balance.set(y, (balance.get(y) ?? 0) - 1)
|
|
91
|
+
return [x, y] as [number, number]
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Arbitrary but fixed A/B slot for the single pair of a two-candidate select.
|
|
97
|
+
*
|
|
98
|
+
* There is only one match, so nothing can cancel a preference; the mix at least stops
|
|
99
|
+
* candidate 0 from always sitting in slot A. Real comparisons go through `compare`,
|
|
100
|
+
* where `routedRepeats` runs an even number of rounds instead.
|
|
101
|
+
* @param a - first candidate index of the pair.
|
|
102
|
+
* @param b - second candidate index of the pair.
|
|
103
|
+
* @returns The pair in the order it should be presented.
|
|
104
|
+
*/
|
|
105
|
+
function orientPair(a: number, b: number): [number, number] {
|
|
106
|
+
const lo = Math.min(a, b)
|
|
107
|
+
const hi = Math.max(a, b)
|
|
108
|
+
let mixed = Math.imul(lo + 1, 0x9e3779b1) ^ Math.imul(hi + 1, 0x85ebca77)
|
|
109
|
+
mixed = Math.imul(mixed ^ (mixed >>> 15), 0x2545f491)
|
|
110
|
+
return ((mixed ^ (mixed >>> 13)) & 1) === 0 ? [a, b] : [b, a]
|
|
111
|
+
}
|
|
112
|
+
|
|
63
113
|
function judgeLabel(client: VerifierClientConfig): string {
|
|
64
114
|
return client.label?.trim() || client.provider + '/' + client.model
|
|
65
115
|
}
|
|
@@ -422,10 +472,12 @@ export class VerifierEngine {
|
|
|
422
472
|
return { index: 0, best: options.candidates[0]!, scores: [1], ranking: [0], pivots: [0], comparisons: 0, calls: 0, stats: blankStats(), judges }
|
|
423
473
|
}
|
|
424
474
|
if (options.candidates.length === 2) {
|
|
425
|
-
// ringCycle(2) would judge the single unordered pair in both directions; play it
|
|
426
|
-
|
|
475
|
+
// ringCycle(2) would judge the single unordered pair in both directions; play it
|
|
476
|
+
// once, in the orientation orientPair() picks so the slot is not always candidate 0.
|
|
477
|
+
const single = orientPair(0, 1)
|
|
478
|
+
const { rewards, judgeRewards, judgeOk, judgeErrors, judgeCalls, stats } = await this.scorePairs(options, [single], signal)
|
|
427
479
|
const wins = [0, 0]; const counts = [0, 0]
|
|
428
|
-
accumulatePairs([
|
|
480
|
+
accumulatePairs([single], rewards, wins, counts)
|
|
429
481
|
const ranked = rankScores(wins, counts); const index = ranked[0]!.index
|
|
430
482
|
const judges: JudgeScore[] = this.clients.map((client, k) => {
|
|
431
483
|
const isOk = judgeOk[k]!
|
|
@@ -464,7 +516,7 @@ export class VerifierEngine {
|
|
|
464
516
|
// pivot→neighbour edges). Dropping those duplicates keeps each unordered pair to a
|
|
465
517
|
// single match so wins/counts are not double-weighted and no pair is judged twice.
|
|
466
518
|
const ringPairs = new Set(ring.map(pair => unorderedPair(pair[0], pair[1])))
|
|
467
|
-
const rounds = pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1])))
|
|
519
|
+
const rounds = orientRoundPairs(pivotRoundPairs(options.candidates.length, pivots).filter(pair => !ringPairs.has(unorderedPair(pair[0], pair[1]))))
|
|
468
520
|
const roundScores = await this.scorePairs(options, rounds, signal)
|
|
469
521
|
const allRewards = new Map([...ringScores.rewards, ...roundScores.rewards])
|
|
470
522
|
const wins = new Array<number>(options.candidates.length).fill(0)
|
package/src/index.ts
CHANGED
|
@@ -12,12 +12,12 @@ import { ScoreCache, SingleFlight, resolveCacheFile, stableHash, type CachedPair
|
|
|
12
12
|
import { VerifierEngine, normalizeCriteria, type JudgeScore, type RunStats } from './engine.ts'
|
|
13
13
|
import { loadVerifierImages } from './images.ts'
|
|
14
14
|
import { extractSession, sanitizeVerifierText, sessionEvents } from './session.ts'
|
|
15
|
-
import { analyzeAutoTask, automaticFeedback, isSubagentSession } from './auto.ts'
|
|
16
|
-
import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
|
|
15
|
+
import { analyzeAutoTask, automaticFeedback, failedAcceptanceCriteria, isSubagentSession, sessionAccepted, type AcceptanceCriterion } from './auto.ts'
|
|
16
|
+
import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouteDecision, type RoutedVerifierKind } from './router.ts'
|
|
17
17
|
import { DEFAULT_CRITERIA } from './core.ts'
|
|
18
18
|
import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from './plan-gate.ts'
|
|
19
19
|
import { inspectTeamTasks, buildTeamTaskVerificationPrompt } from './team-gate.ts'
|
|
20
|
-
import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, type StatisticsOverview, type VerifierToolName
|
|
20
|
+
import { StatisticsStore, emptyRunStats, errorDetails, mergeStatisticsOverviews, parseStatisticsQuery, resolveStatisticsFile, summarizeVerdict, type StatisticsOverview, type VerifierToolName } from './statistics.ts'
|
|
21
21
|
import { resolveTopicDataDir, type SessionArtifactLocator } from './topic-storage.ts'
|
|
22
22
|
|
|
23
23
|
export const name = 'llm-verifier'
|
|
@@ -98,7 +98,7 @@ function rpcSuccess<T>(value: T) { return { ok: true as const, value } }
|
|
|
98
98
|
function rpcFailure(message: string) { return { ok: false as const, error: { code: 'bad-request' as const, message, details: { issues: [] } } } }
|
|
99
99
|
|
|
100
100
|
interface SessionVerificationOptions { fromSeq?: number; toSeq?: number; includeAssistantText?: boolean; redactPatterns?: readonly string[]; maxChars?: number; repeats?: number }
|
|
101
|
-
interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
|
|
101
|
+
interface SessionVerificationResult { sessionId: string; problem: string; score: number; baselineScore: number; winner: 'A' | 'B' | 'tie'; criteria: AcceptanceCriterion[]; fromSeq: number; toSeq: number; omittedCharacters: number; calls: number; stats: RunStats; judges: JudgeScore[]; agreement: number }
|
|
102
102
|
|
|
103
103
|
export function apply(ctx: Context, config: Config = {}): void {
|
|
104
104
|
const services = ctx as Context & { attachments: AttachmentStore; connection: HostConnectionHandle; sessionPersistence: SessionArtifactLocator & { list(signal?: AbortSignal): Promise<readonly unknown[]> } }
|
|
@@ -139,36 +139,12 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
139
139
|
const images = (values: readonly string[] | undefined, signal: AbortSignal) => loadVerifierImages(values, signal)
|
|
140
140
|
const route = (selected: { provider: string; model: string }) => ({ provider: selected.provider, model: selected.model })
|
|
141
141
|
const requireEnabled = () => { if (!current().enabled) throw new Error('llm-verifier: verifier tools are disabled — enable them in Settings → LLM Verifier') }
|
|
142
|
-
|
|
143
|
-
* Compact summary of what the judges decided, stored beside the call counters so
|
|
144
|
-
* the dashboard can answer "why did this fail?" instead of only "how much did it cost".
|
|
145
|
-
* @param toolName - the verifier tool that produced the value.
|
|
146
|
-
* @param value - its rendered result.
|
|
147
|
-
* @param phase - which stage produced it (explicit | compare | select | track | final | ...).
|
|
148
|
-
* @returns A verdict summary; score fields are omitted when the tool has none.
|
|
149
|
-
*/
|
|
150
|
-
const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string): VerdictSummary => {
|
|
142
|
+
const verdictFrom = (toolName: VerifierToolName, value: unknown, phase: string) => {
|
|
151
143
|
const selected = current()
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (toolName === 'verifier_route_classify') return { phase, outcome: 'classified' }
|
|
157
|
-
if (toolName === 'verifier_select') {
|
|
158
|
-
const index = numberAt('index')
|
|
159
|
-
const best = index === undefined ? undefined : scores[index]
|
|
160
|
-
return { phase, outcome: 'ranked', ...(best !== undefined ? { score: best } : {}) }
|
|
161
|
-
}
|
|
162
|
-
if (toolName === 'verifier_track') {
|
|
163
|
-
const worst = scores.length > 0 ? Math.min(...scores) : undefined
|
|
164
|
-
const threshold = selected.autoTrackCompletionThreshold
|
|
165
|
-
return { phase, outcome: worst !== undefined && worst >= threshold ? 'passed' : 'below-threshold', ...(worst !== undefined ? { score: worst } : {}), threshold }
|
|
166
|
-
}
|
|
167
|
-
const score = numberAt('score') ?? numberAt('scoreA')
|
|
168
|
-
const baselineScore = numberAt('baselineScore')
|
|
169
|
-
const threshold = selected.autoVerifyThreshold
|
|
170
|
-
const outcome = winner === 'tie' ? 'tie' : winner === 'A' && score !== undefined && score >= threshold ? 'passed' : 'below-threshold'
|
|
171
|
-
return { phase, outcome, ...(score !== undefined ? { score } : {}), ...(baselineScore !== undefined ? { baselineScore } : {}), ...(winner !== undefined ? { winner } : {}), threshold }
|
|
144
|
+
return summarizeVerdict(toolName, value, phase, {
|
|
145
|
+
autoVerifyThreshold: selected.autoVerifyThreshold,
|
|
146
|
+
autoTrackCompletionThreshold: selected.autoTrackCompletionThreshold,
|
|
147
|
+
})
|
|
172
148
|
}
|
|
173
149
|
const record = async <T>(toolName: VerifierToolName, agent: Agent, operation: () => Promise<{ result: T; selected: { provider: string; model: string } }>, phase = 'explicit'): Promise<T & { provider: string; model: string }> => {
|
|
174
150
|
const startedAt = Date.now()
|
|
@@ -218,7 +194,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
218
194
|
if (!extracted.problem.trim()) throw new Error('llm-verifier: no direct user task found in the selected session range — widen from_seq so the task statement is included')
|
|
219
195
|
const { verifier, selected } = await engine(agent)
|
|
220
196
|
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)
|
|
221
|
-
const result: SessionVerificationResult = { sessionId: extracted.sessionId, problem: extracted.problem, score: compared.scoreA, baselineScore: compared.scoreB, winner: compared.winner, fromSeq: extracted.fromSeq, toSeq: extracted.toSeq, omittedCharacters: extracted.omittedCharacters, calls: compared.calls, stats: compared.stats, judges: compared.judges, agreement: compared.agreement }
|
|
197
|
+
const result: SessionVerificationResult = { 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 }
|
|
222
198
|
return { result, selected }
|
|
223
199
|
}, phase)
|
|
224
200
|
const compareCandidates = async (agent: Agent, problem: string, candidateA: string, candidateB: string, repeats: number, signal: AbortSignal, routedImages: readonly import('./caller.ts').VerifierImage[] = [], phase = 'explicit') => record('verifier_compare', agent, async () => {
|
|
@@ -393,8 +369,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
393
369
|
if (!stillCurrent()) { autoRouter.fail(agent, taskReservation, false); return }
|
|
394
370
|
const verdict = parseVerdictLetter(classified.text)
|
|
395
371
|
if (verdict === undefined) {
|
|
396
|
-
|
|
397
|
-
|
|
372
|
+
// Fail closed in strict mode like every other routed phase: an unparseable
|
|
373
|
+
// verdict must not silently leave the task ungated.
|
|
374
|
+
const strict = selected.autoVerifyMode === 'strict'
|
|
375
|
+
autoRouter.fail(agent, taskReservation, strict)
|
|
376
|
+
ctx.logger.warn('llm-verifier team task verification produced no verdict line; task ' + task.id + (strict ? ' stays blocked' : ' was not gated'))
|
|
398
377
|
continue
|
|
399
378
|
}
|
|
400
379
|
if (verdict.score >= selected.autoVerifyThreshold) {
|
|
@@ -462,8 +441,10 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
462
441
|
|
|
463
442
|
if (!stillCurrent()) return
|
|
464
443
|
if (decision) {
|
|
465
|
-
// Every judge scores every match,
|
|
466
|
-
|
|
444
|
+
// Every judge scores every match, and compare/select round the repeat count up to an
|
|
445
|
+
// even number so the A/B slots get swapped, so the reservation has to cover both.
|
|
446
|
+
const repeats = routedRepeats(decision, selected.autoVerifyRepeats)
|
|
447
|
+
const expectedCalls = estimateRoutedCalls(decision, repeats, DEFAULT_CRITERIA.length) * selected.judges.length
|
|
467
448
|
const reservation = autoRouter.reserve(agent, decision.kind, decision.fingerprint, expectedCalls, policy)
|
|
468
449
|
if (reservation === undefined) {
|
|
469
450
|
// A refused reservation used to drop the whole routed decision silently.
|
|
@@ -483,7 +464,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
483
464
|
try {
|
|
484
465
|
const extracted = await extractTask(agent, evidence.taskStartSeq, admittedLastSeq, selected.autoVerifyMaxChars, signal)
|
|
485
466
|
if (decision.kind === 'compare') {
|
|
486
|
-
const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content,
|
|
467
|
+
const result = await compareCandidates(agent, extracted.problem, decision.candidates[0].content, decision.candidates[1].content, repeats, signal, extracted.images, 'compare')
|
|
487
468
|
if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
|
|
488
469
|
if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
|
|
489
470
|
const winner = result.winner === 'A' ? decision.candidates[0].label : result.winner === 'B' ? decision.candidates[1].label : 'tie'
|
|
@@ -491,18 +472,23 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
491
472
|
return
|
|
492
473
|
}
|
|
493
474
|
if (decision.kind === 'select') {
|
|
494
|
-
const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content),
|
|
475
|
+
const result = await selectCandidates(agent, extracted.problem, decision.candidates.map(candidate => candidate.content), repeats, signal, extracted.images, 'select')
|
|
495
476
|
if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
|
|
496
477
|
if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
|
|
497
478
|
const ranking = result.ranking.map((index, rank) => (rank + 1) + '. ' + decision.candidates[index]!.label).join('\n')
|
|
498
479
|
agent.steer(routeFeedback(decision, 'Ranking:\n' + ranking + '\nProceed with ' + decision.candidates[result.index]!.label + '.'))
|
|
499
480
|
return
|
|
500
481
|
}
|
|
501
|
-
const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints,
|
|
482
|
+
const result = await trackProgress(agent, extracted.problem, decision.steps, decision.checkpoints, repeats, signal, extracted.images, 'track')
|
|
502
483
|
if (!stillCurrent()) { autoRouter.fail(agent, reservation, false); return }
|
|
503
484
|
if (!autoRouter.commit(agent, reservation, admittedLastSeq)) return
|
|
504
485
|
const detail = result.scores.map((score, index) => 'Checkpoint step ' + decision.checkpoints[index] + ': ' + (score * 100).toFixed(1) + '%').join('\n')
|
|
505
|
-
|
|
486
|
+
// Judge the CURRENT state, not the whole history: the first checkpoint is the
|
|
487
|
+
// state right after the first todo snapshot, which is always "nothing done
|
|
488
|
+
// yet", so "any checkpoint below threshold" made the continue branch
|
|
489
|
+
// unconditional (5/5 live routes) and the threshold meaningless.
|
|
490
|
+
const latest = result.scores.length > 0 ? result.scores[result.scores.length - 1]! : 0
|
|
491
|
+
const continuation = latest < selected.autoTrackCompletionThreshold ? '\nContinue the unfinished work.' : '\nPrepare final delivery evidence; final session verification is mandatory.'
|
|
506
492
|
agent.steer(routeFeedback(decision, detail + continuation))
|
|
507
493
|
return
|
|
508
494
|
} catch (error) {
|
|
@@ -542,11 +528,15 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
542
528
|
try {
|
|
543
529
|
const result = await verifySession(agent, { fromSeq: finalFromSeq, toSeq: admittedLastSeq, includeAssistantText: true, maxChars: selected.autoVerifyMaxChars, repeats: selected.autoVerifyFinalRepeats }, signal, 'final')
|
|
544
530
|
if (!stillCurrent()) { autoRouter.fail(agent, finalReservation, false); return }
|
|
545
|
-
|
|
531
|
+
// The mean over criteria used to hide a single failed requirement, and the fixed
|
|
532
|
+
// empty-work baseline can never win on its own, so the gate is (a) the winner,
|
|
533
|
+
// (b) the mean score and (c) every criterion on its own.
|
|
534
|
+
const failed = failedAcceptanceCriteria(result.criteria, selected.autoVerifyThreshold)
|
|
535
|
+
const passed = sessionAccepted({ score: result.score, winner: result.winner, criteria: result.criteria }, selected.autoVerifyThreshold)
|
|
546
536
|
if (passed) autoRouter.commit(agent, finalReservation)
|
|
547
537
|
else {
|
|
548
538
|
autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
|
|
549
|
-
agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
|
|
539
|
+
agent.steer(createUserMessage({ content: [{ type: 'text', text: automaticFeedback(result.score, result.baselineScore, result.winner, selected.autoVerifyThreshold, failed) }], source: { kind: 'plugin', plugin: 'dsh-llm-verifier' } }))
|
|
550
540
|
}
|
|
551
541
|
} catch (error) {
|
|
552
542
|
autoRouter.fail(agent, finalReservation, selected.autoVerifyMode === 'strict')
|
package/src/router.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { Session } from '@deepseek-ai/dsh-session'
|
|
3
|
-
import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
4
|
-
import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
|
|
3
|
+
import { createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
4
|
+
import { analyzeStructuredRoute, AutoVerifierRouter, boundDecision, buildSemanticRoutePrompt, estimateRoutedCalls, latestDirectUserSeq, MAX_ROUTED_CHECKPOINTS, parseSemanticRoute, routedRepeats, semanticDecision, semanticRouteHint, type RouterPolicy } from './router.ts'
|
|
5
5
|
import { sanitizeVerifierText } from './session.ts'
|
|
6
6
|
|
|
7
7
|
function session() {
|
|
@@ -13,6 +13,9 @@ function tool(value: ReturnType<typeof session>, name: string, id: string, text:
|
|
|
13
13
|
value.append('tool/call', { turn, step, callId: id as never, name, arguments: '{}' })
|
|
14
14
|
value.append('tool/result', { turn, step, message: createToolResultMessage({ callId: id as never, content: [{ type: 'text', text }], isError: false }) }, { surfaceOp: 'append' })
|
|
15
15
|
}
|
|
16
|
+
function assistant(value: ReturnType<typeof session>, text: string, turn = 1, step = 1) {
|
|
17
|
+
value.append('assistant/message', { turn, step, message: createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) }, { surfaceOp: 'append' })
|
|
18
|
+
}
|
|
16
19
|
const policy: RouterPolicy = { mode: 'smart', minConfidence: .9, maxCandidates: 8, maxPerTask: 5, maxPerSession: 20, maxModelCallsPerTask: 48, maxModelCallsPerSession: 160, maxInputChars: 60000, maxItemChars: 20000 }
|
|
17
20
|
const envelope = (count: number) => JSON.stringify({ protocol: 'dsh-verifier-candidates', version: 1, groupId: 'auth', candidates: Array.from({ length: count }, (_, i) => ({ id: String(i + 1), label: 'C' + (i + 1), status: 'completed', content: 'candidate ' + (i + 1) })) })
|
|
18
21
|
|
|
@@ -45,6 +48,158 @@ describe('production structured routing', () => {
|
|
|
45
48
|
expect(decision.steps.every(step => step.length <= 400)).toBe(true)
|
|
46
49
|
}
|
|
47
50
|
})
|
|
51
|
+
it('never attaches bookkeeping output as checkpoint evidence', () => {
|
|
52
|
+
// Regression: the newest successful result before a todo snapshot is usually the
|
|
53
|
+
// bookkeeping call that wrote it (create_goal/update_goal/todo_write/interrupt_agent),
|
|
54
|
+
// so every checkpoint showed the router its own metadata instead of the work that
|
|
55
|
+
// had just run the task's tests.
|
|
56
|
+
const value = session()
|
|
57
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
58
|
+
tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
|
|
59
|
+
tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
|
|
60
|
+
tool(value, 'interrupt_agent', 'stop', 'interrupt requested for agent abc')
|
|
61
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
62
|
+
const decision = analyzeStructuredRoute(value.events, 8, 400, 800)
|
|
63
|
+
expect(decision?.kind).toBe('track')
|
|
64
|
+
if (decision?.kind !== 'track') return
|
|
65
|
+
// The real run is older than both bookkeeping results, and must still win.
|
|
66
|
+
expect(decision.steps[1]).toContain('all tests passed')
|
|
67
|
+
expect(decision.steps[1]).not.toContain('"phase":"active"')
|
|
68
|
+
expect(decision.steps[1]).not.toContain('interrupt requested')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('attaches the newest narration to the current checkpoint only', () => {
|
|
72
|
+
// A prose deliverable (review, analysis) never reaches a tool result, so every
|
|
73
|
+
// checkpoint scored "certainly NO" while the same session's final acceptance
|
|
74
|
+
// passed. The narration is labelled as a claim, and only the checkpoint the route
|
|
75
|
+
// actually judges carries it.
|
|
76
|
+
const value = session()
|
|
77
|
+
assistant(value, 'Early plan: read the parser first.')
|
|
78
|
+
value.append('todo/write', { todos: [{ content: 'Review', status: 'in_progress' }, { content: 'Report', status: 'pending' }] })
|
|
79
|
+
assistant(value, 'Deliverable: the review found two blocking issues.', 1, 2)
|
|
80
|
+
value.append('todo/write', { todos: [{ content: 'Review', status: 'completed' }, { content: 'Report', status: 'completed' }] })
|
|
81
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
82
|
+
expect(decision?.kind).toBe('track')
|
|
83
|
+
if (decision?.kind !== 'track') return
|
|
84
|
+
expect(decision.steps[0]).not.toContain('Newest agent narration')
|
|
85
|
+
expect(decision.steps[1]).toContain('Newest agent narration')
|
|
86
|
+
expect(decision.steps[1]).toContain('two blocking issues')
|
|
87
|
+
expect(decision.steps[1]).not.toContain('Early plan')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps narration and evidence inside the checkpoint caps', () => {
|
|
91
|
+
const value = session()
|
|
92
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
93
|
+
tool(value, 'pwsh', 'run', 'Z'.repeat(4000))
|
|
94
|
+
assistant(value, 'Q'.repeat(4000))
|
|
95
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
96
|
+
for (const maxItemChars of [128, 200, 2000, 20000]) {
|
|
97
|
+
const maxInputChars = Math.max(60000, maxItemChars * 2)
|
|
98
|
+
const decision = analyzeStructuredRoute(value.events, 8, maxItemChars, maxInputChars)
|
|
99
|
+
expect(decision, 'maxItemChars=' + maxItemChars).toMatchObject({ kind: 'track' })
|
|
100
|
+
if (decision?.kind !== 'track') continue
|
|
101
|
+
for (const step of decision.steps) expect(step.length, 'maxItemChars=' + maxItemChars).toBeLessThanOrEqual(maxItemChars)
|
|
102
|
+
expect(decision.steps.reduce((sum, step) => sum + step.length, 0)).toBeLessThanOrEqual(maxInputChars)
|
|
103
|
+
// Regression guard for the caps that boundDecision() enforces on the whole route.
|
|
104
|
+
expect(boundDecision(decision, { ...policy, maxItemChars, maxInputChars }), 'maxItemChars=' + maxItemChars).toBeDefined()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('re-routes the same todo snapshots once the rendered evidence changed', () => {
|
|
109
|
+
// Regression: the fingerprint covered only the todo snapshots, so a route whose
|
|
110
|
+
// prompt had changed (new evidence, new narration) was refused as "already routed".
|
|
111
|
+
const value = session()
|
|
112
|
+
tool(value, 'pwsh', 'run-a', 'first output')
|
|
113
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
114
|
+
tool(value, 'pwsh', 'run-b', 'second output')
|
|
115
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
116
|
+
const first = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
117
|
+
const again = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
118
|
+
expect(first?.kind).toBe('track')
|
|
119
|
+
// An unchanged log keeps its identity, so the budget guard still works.
|
|
120
|
+
expect(again?.fingerprint).toBe(first?.fingerprint)
|
|
121
|
+
assistant(value, 'Report: the deliverable is ready.', 2, 1)
|
|
122
|
+
const afterNarration = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
123
|
+
expect(afterNarration?.fingerprint).not.toBe(first?.fingerprint)
|
|
124
|
+
if (first?.kind !== 'track' || afterNarration?.kind !== 'track') return
|
|
125
|
+
expect(afterNarration.steps.at(-1)).toContain('Report: the deliverable is ready.')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders the newest checkpoint as the state at routing time', () => {
|
|
129
|
+
// Regression: evidence was frozen at the last todo snapshot's seq, so work done
|
|
130
|
+
// after it — often the actual verification run — never reached the judge.
|
|
131
|
+
const value = session()
|
|
132
|
+
tool(value, 'pwsh', 'first', 'first run output')
|
|
133
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
134
|
+
tool(value, 'pwsh', 'second', 'verification run output')
|
|
135
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
136
|
+
tool(value, 'pwsh', 'third', 'output after the last snapshot')
|
|
137
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
138
|
+
expect(decision?.kind).toBe('track')
|
|
139
|
+
if (decision?.kind !== 'track') return
|
|
140
|
+
// Historical checkpoints keep the output that was current for them...
|
|
141
|
+
expect(decision.steps[0]).toContain('first run output')
|
|
142
|
+
expect(decision.steps[0]).not.toContain('verification run output')
|
|
143
|
+
// ...while the newest one carries the newest output and says so.
|
|
144
|
+
expect(decision.steps[1]).toContain('at routing time')
|
|
145
|
+
expect(decision.steps[1]).toContain('output after the last snapshot')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('treats a wrapper that only dispatched bookkeeping as bookkeeping', () => {
|
|
149
|
+
// Regression: a PTC program that only called todo_write produced a run_code result
|
|
150
|
+
// whose text was the todo list echoed back, and the wrapper's name hid what it did.
|
|
151
|
+
const value = session()
|
|
152
|
+
tool(value, 'pwsh', 'run', 'all tests passed: 91 passed')
|
|
153
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
154
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
155
|
+
value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'todo_write', arguments: '{}', isError: false, content: [{ type: 'text', text: '{todos:[{content:"Implement",status:"completed"}]}' }] } as never)
|
|
156
|
+
value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: '{ todos: [ { content: "Implement", status: "completed" } ], counts: {} }' }], isError: false }) }, { surfaceOp: 'append' })
|
|
157
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
158
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
159
|
+
expect(decision?.kind).toBe('track')
|
|
160
|
+
if (decision?.kind !== 'track') return
|
|
161
|
+
expect(decision.steps[1]).toContain('all tests passed')
|
|
162
|
+
expect(decision.steps[1]).not.toContain('counts:')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('never attaches a deliverable presentation as checkpoint evidence', () => {
|
|
166
|
+
// Regression (live session, four routes in a row): `present` is the last call of a
|
|
167
|
+
// turn, so the newest "observed tool output" reaching the judge was the wrapper's
|
|
168
|
+
// `presented: 1` — the paths the agent had just declared — while the typecheck and
|
|
169
|
+
// test run sat one call earlier. The judge's own rule ("a state without real
|
|
170
|
+
// verification should not exceed K") then capped the newest checkpoint at exactly
|
|
171
|
+
// K = 52.6% against a 0.8 threshold, so every route steered "continue the
|
|
172
|
+
// unfinished work" for work that was finished and verified, until the per-task
|
|
173
|
+
// route budget ran out.
|
|
174
|
+
const value = session()
|
|
175
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
176
|
+
tool(value, 'pwsh', 'verify', 'TYPECHECK_EXIT=0\nTests 309 passed')
|
|
177
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
178
|
+
value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'present', arguments: '{}', isError: false, content: [{ type: 'text', text: 'Presented C:\\repo\\src\\mapper.ts' }] } as never)
|
|
179
|
+
value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'presented: 1' }], isError: false }) }, { surfaceOp: 'append' })
|
|
180
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
181
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
182
|
+
expect(decision?.kind).toBe('track')
|
|
183
|
+
if (decision?.kind !== 'track') return
|
|
184
|
+
// The verification run is older than the presentation, and must still win.
|
|
185
|
+
expect(decision.steps[1]).toContain('309 passed')
|
|
186
|
+
expect(decision.steps[1]).not.toContain('Presented C:')
|
|
187
|
+
expect(decision.steps[1]).not.toContain('presented: 1')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('keeps a wrapper that dispatched real work', () => {
|
|
191
|
+
const value = session()
|
|
192
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
193
|
+
value.append('tool/ptc-dispatch' as never, { rootCallId: 'wrap', subCallId: 'wrap:ptc:1', name: 'pwsh', arguments: '{}', isError: false, content: [{ type: 'text', text: 'tests passed' }] } as never)
|
|
194
|
+
value.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'wrap' as never, content: [{ type: 'text', text: 'tests passed in the wrapper output' }], isError: false }) }, { surfaceOp: 'append' })
|
|
195
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
196
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
197
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
198
|
+
expect(decision?.kind).toBe('track')
|
|
199
|
+
if (decision?.kind !== 'track') return
|
|
200
|
+
expect(decision.steps[1]).toContain('tests passed in the wrapper output')
|
|
201
|
+
})
|
|
202
|
+
|
|
48
203
|
it('deduplicates identical todo snapshots', () => {
|
|
49
204
|
const value = session()
|
|
50
205
|
const todos = [{ content: 'Implement', status: 'in_progress' as const }, { content: 'Test', status: 'pending' as const }]
|
|
@@ -99,6 +254,32 @@ describe('semantic evidence references', () => {
|
|
|
99
254
|
expect(semanticDecision(parsed, value.events)).toMatchObject({ kind: 'compare', source: 'semantic' })
|
|
100
255
|
})
|
|
101
256
|
|
|
257
|
+
it('never offers bookkeeping calls as semantic candidates', () => {
|
|
258
|
+
// Regression: the artifact list carried every paired tool result, so the classifier
|
|
259
|
+
// could cite two goal/agent-control calls as "competing alternatives".
|
|
260
|
+
const value = session()
|
|
261
|
+
tool(value, 'subagent', 'a', 'candidate A from a real subagent')
|
|
262
|
+
tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
|
|
263
|
+
tool(value, 'skill', 'skill', 'loaded review skill body')
|
|
264
|
+
tool(value, 'present', 'present', 'Presented C:\\repo\\src\\mapper.ts')
|
|
265
|
+
const prompt = buildSemanticRoutePrompt('pick the better one', value.events, 8, 20000, 60000)
|
|
266
|
+
expect(prompt).toContain('candidate A from a real subagent')
|
|
267
|
+
expect(prompt).not.toContain('"phase":"active"')
|
|
268
|
+
expect(prompt).not.toContain('loaded review skill body')
|
|
269
|
+
expect(prompt).not.toContain('Presented C:')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('rejects a semantic decision that cites bookkeeping evidence', () => {
|
|
273
|
+
const value = session()
|
|
274
|
+
tool(value, 'create_goal', 'goal-1', '{"goal":{"id":"g1"}}')
|
|
275
|
+
tool(value, 'create_goal', 'goal-2', '{"goal":{"id":"g2"}}')
|
|
276
|
+
const parsed = parseSemanticRoute(JSON.stringify({ kind: 'compare', confidence: .95, reason: 'alternatives', candidateCallIds: ['goal-1', 'goal-2'], checkpointSeqs: [] }))
|
|
277
|
+
expect(parsed?.kind).toBe('compare')
|
|
278
|
+
// Fail closed: the whole decision is dropped, and the caller records the reference
|
|
279
|
+
// as invalid instead of comparing metadata.
|
|
280
|
+
expect(semanticDecision(parsed!, value.events)).toBeUndefined()
|
|
281
|
+
})
|
|
282
|
+
|
|
102
283
|
it('resolves candidates emitted via Session V3 tool/ptc-dispatch', () => {
|
|
103
284
|
const value = session()
|
|
104
285
|
value.append('tool/ptc-dispatch' as never, { subCallId: 'ptc-1', name: 'subagent', arguments: '{}', isError: false, content: [{ type: 'text', text: 'candidate A from ptc' }] } as never)
|
|
@@ -210,10 +391,28 @@ describe('semantic evidence references', () => {
|
|
|
210
391
|
const value = session()
|
|
211
392
|
value.append('team/task' as never, { task: { id: 'task-1', revision: 1, subject: 'Backend API', status: 'in_progress' } } as never)
|
|
212
393
|
value.append('team/task' as never, { task: { id: 'task-1', revision: 2, subject: 'Backend API', status: 'completed' } } as never)
|
|
213
|
-
|
|
394
|
+
// Snapshots are structured material, not a semantic hint: the structured pass claims
|
|
395
|
+
// them first, so asking the classifier about them would only ever pay for a route
|
|
396
|
+
// that could not have been produced anyway.
|
|
397
|
+
expect(semanticRouteHint(value.events)).toBe(false)
|
|
214
398
|
const decision = analyzeStructuredRoute(value.events)
|
|
215
399
|
expect(decision).toMatchObject({ kind: 'track', source: 'structured', reason: 'changed durable team tasks' })
|
|
216
400
|
})
|
|
401
|
+
|
|
402
|
+
it('hints a classification only for material the structured pass never consumes', () => {
|
|
403
|
+
const subagent = session(); tool(subagent, 'subagent', 'a', 'candidate A')
|
|
404
|
+
expect(semanticRouteHint(subagent.events)).toBe(true)
|
|
405
|
+
const plan = session()
|
|
406
|
+
plan.append('tool/call', { turn: 1, step: 1, callId: 'plan' as never, name: 'exit_plan_mode', arguments: '{}' })
|
|
407
|
+
plan.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'plan' as never, content: [{ type: 'text', text: 'approved' }], isError: false }) }, { surfaceOp: 'append' })
|
|
408
|
+
expect(semanticRouteHint(plan.events)).toBe(true)
|
|
409
|
+
// Todo snapshots alone (all lists shorter than two items) are still structured-only.
|
|
410
|
+
const todos = session()
|
|
411
|
+
todos.append('todo/write', { todos: [{ content: 'Only step', status: 'in_progress' }] })
|
|
412
|
+
todos.append('todo/write', { todos: [{ content: 'Only step', status: 'completed' }] })
|
|
413
|
+
expect(analyzeStructuredRoute(todos.events)).toBeUndefined()
|
|
414
|
+
expect(semanticRouteHint(todos.events)).toBe(false)
|
|
415
|
+
})
|
|
217
416
|
})
|
|
218
417
|
|
|
219
418
|
describe('transactional router state', () => {
|
|
@@ -256,6 +455,22 @@ describe('transactional router state', () => {
|
|
|
256
455
|
value.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'next task' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
|
257
456
|
expect(router.claimExhaustedNotice(agent)).toBe(true)
|
|
258
457
|
})
|
|
458
|
+
it('rounds compare repeats up to an even count and leaves the other routes alone', () => {
|
|
459
|
+
// compare judges ONE pair, so an odd count leaves its A/B preference uncorrected
|
|
460
|
+
// (the engine only swaps positions on odd repeat indices). select is already
|
|
461
|
+
// symmetric per pair and track has no slots, so both keep the configured count.
|
|
462
|
+
const compare = { kind: 'compare', candidates: [{}, {}] } as never
|
|
463
|
+
const select = { kind: 'select', candidates: [{}, {}, {}] } as never
|
|
464
|
+
const track = { kind: 'track', steps: [] } as never
|
|
465
|
+
expect(routedRepeats(compare, 1)).toBe(2)
|
|
466
|
+
expect(routedRepeats(compare, 2)).toBe(2)
|
|
467
|
+
expect(routedRepeats(compare, 3)).toBe(4)
|
|
468
|
+
expect(routedRepeats(select, 1)).toBe(1)
|
|
469
|
+
expect(routedRepeats(select, 3)).toBe(3)
|
|
470
|
+
expect(routedRepeats(track, 1)).toBe(1)
|
|
471
|
+
expect(routedRepeats(track, 3)).toBe(3)
|
|
472
|
+
})
|
|
473
|
+
|
|
259
474
|
it('estimates routed calls from the real tournament shape', () => {
|
|
260
475
|
const candidate = { id: 'c', groupId: 'g', label: 'c', content: 'x', callId: 'a', fromSeq: 1, toSeq: 2 }
|
|
261
476
|
const base = { source: 'structured' as const, confidence: 1, reason: 'r', fingerprint: 'f' }
|