dsh-llm-verifier 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -11
- package/lib/client.js +137 -13
- package/lib/client.js.map +1 -1
- package/lib/index.js +377 -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 +50 -1
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +83 -4
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +2 -2
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +81 -32
- 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 +181 -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 +93 -4
- package/src/client.test.ts +134 -3
- package/src/client.tsx +61 -4
- 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 +191 -3
- package/src/router.ts +171 -14
- package/src/statistics.test.ts +116 -0
- package/src/statistics.ts +112 -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,133 @@ 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('keeps a wrapper that dispatched real work', () => {
|
|
166
|
+
const value = session()
|
|
167
|
+
value.append('tool/call', { turn: 1, step: 1, callId: 'wrap' as never, name: 'run_code', arguments: '{}' })
|
|
168
|
+
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)
|
|
169
|
+
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' })
|
|
170
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'in_progress' }, { content: 'Test', status: 'pending' }] })
|
|
171
|
+
value.append('todo/write', { todos: [{ content: 'Implement', status: 'completed' }, { content: 'Test', status: 'completed' }] })
|
|
172
|
+
const decision = analyzeStructuredRoute(value.events, 8, 20000, 60000)
|
|
173
|
+
expect(decision?.kind).toBe('track')
|
|
174
|
+
if (decision?.kind !== 'track') return
|
|
175
|
+
expect(decision.steps[1]).toContain('tests passed in the wrapper output')
|
|
176
|
+
})
|
|
177
|
+
|
|
48
178
|
it('deduplicates identical todo snapshots', () => {
|
|
49
179
|
const value = session()
|
|
50
180
|
const todos = [{ content: 'Implement', status: 'in_progress' as const }, { content: 'Test', status: 'pending' as const }]
|
|
@@ -99,6 +229,30 @@ describe('semantic evidence references', () => {
|
|
|
99
229
|
expect(semanticDecision(parsed, value.events)).toMatchObject({ kind: 'compare', source: 'semantic' })
|
|
100
230
|
})
|
|
101
231
|
|
|
232
|
+
it('never offers bookkeeping calls as semantic candidates', () => {
|
|
233
|
+
// Regression: the artifact list carried every paired tool result, so the classifier
|
|
234
|
+
// could cite two goal/agent-control calls as "competing alternatives".
|
|
235
|
+
const value = session()
|
|
236
|
+
tool(value, 'subagent', 'a', 'candidate A from a real subagent')
|
|
237
|
+
tool(value, 'create_goal', 'goal', '{"goal":{"id":"g1","phase":"active"}}')
|
|
238
|
+
tool(value, 'skill', 'skill', 'loaded review skill body')
|
|
239
|
+
const prompt = buildSemanticRoutePrompt('pick the better one', value.events, 8, 20000, 60000)
|
|
240
|
+
expect(prompt).toContain('candidate A from a real subagent')
|
|
241
|
+
expect(prompt).not.toContain('"phase":"active"')
|
|
242
|
+
expect(prompt).not.toContain('loaded review skill body')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('rejects a semantic decision that cites bookkeeping evidence', () => {
|
|
246
|
+
const value = session()
|
|
247
|
+
tool(value, 'create_goal', 'goal-1', '{"goal":{"id":"g1"}}')
|
|
248
|
+
tool(value, 'create_goal', 'goal-2', '{"goal":{"id":"g2"}}')
|
|
249
|
+
const parsed = parseSemanticRoute(JSON.stringify({ kind: 'compare', confidence: .95, reason: 'alternatives', candidateCallIds: ['goal-1', 'goal-2'], checkpointSeqs: [] }))
|
|
250
|
+
expect(parsed?.kind).toBe('compare')
|
|
251
|
+
// Fail closed: the whole decision is dropped, and the caller records the reference
|
|
252
|
+
// as invalid instead of comparing metadata.
|
|
253
|
+
expect(semanticDecision(parsed!, value.events)).toBeUndefined()
|
|
254
|
+
})
|
|
255
|
+
|
|
102
256
|
it('resolves candidates emitted via Session V3 tool/ptc-dispatch', () => {
|
|
103
257
|
const value = session()
|
|
104
258
|
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 +364,28 @@ describe('semantic evidence references', () => {
|
|
|
210
364
|
const value = session()
|
|
211
365
|
value.append('team/task' as never, { task: { id: 'task-1', revision: 1, subject: 'Backend API', status: 'in_progress' } } as never)
|
|
212
366
|
value.append('team/task' as never, { task: { id: 'task-1', revision: 2, subject: 'Backend API', status: 'completed' } } as never)
|
|
213
|
-
|
|
367
|
+
// Snapshots are structured material, not a semantic hint: the structured pass claims
|
|
368
|
+
// them first, so asking the classifier about them would only ever pay for a route
|
|
369
|
+
// that could not have been produced anyway.
|
|
370
|
+
expect(semanticRouteHint(value.events)).toBe(false)
|
|
214
371
|
const decision = analyzeStructuredRoute(value.events)
|
|
215
372
|
expect(decision).toMatchObject({ kind: 'track', source: 'structured', reason: 'changed durable team tasks' })
|
|
216
373
|
})
|
|
374
|
+
|
|
375
|
+
it('hints a classification only for material the structured pass never consumes', () => {
|
|
376
|
+
const subagent = session(); tool(subagent, 'subagent', 'a', 'candidate A')
|
|
377
|
+
expect(semanticRouteHint(subagent.events)).toBe(true)
|
|
378
|
+
const plan = session()
|
|
379
|
+
plan.append('tool/call', { turn: 1, step: 1, callId: 'plan' as never, name: 'exit_plan_mode', arguments: '{}' })
|
|
380
|
+
plan.append('tool/result', { turn: 1, step: 1, message: createToolResultMessage({ callId: 'plan' as never, content: [{ type: 'text', text: 'approved' }], isError: false }) }, { surfaceOp: 'append' })
|
|
381
|
+
expect(semanticRouteHint(plan.events)).toBe(true)
|
|
382
|
+
// Todo snapshots alone (all lists shorter than two items) are still structured-only.
|
|
383
|
+
const todos = session()
|
|
384
|
+
todos.append('todo/write', { todos: [{ content: 'Only step', status: 'in_progress' }] })
|
|
385
|
+
todos.append('todo/write', { todos: [{ content: 'Only step', status: 'completed' }] })
|
|
386
|
+
expect(analyzeStructuredRoute(todos.events)).toBeUndefined()
|
|
387
|
+
expect(semanticRouteHint(todos.events)).toBe(false)
|
|
388
|
+
})
|
|
217
389
|
})
|
|
218
390
|
|
|
219
391
|
describe('transactional router state', () => {
|
|
@@ -256,6 +428,22 @@ describe('transactional router state', () => {
|
|
|
256
428
|
value.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'next task' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
|
257
429
|
expect(router.claimExhaustedNotice(agent)).toBe(true)
|
|
258
430
|
})
|
|
431
|
+
it('rounds compare repeats up to an even count and leaves the other routes alone', () => {
|
|
432
|
+
// compare judges ONE pair, so an odd count leaves its A/B preference uncorrected
|
|
433
|
+
// (the engine only swaps positions on odd repeat indices). select is already
|
|
434
|
+
// symmetric per pair and track has no slots, so both keep the configured count.
|
|
435
|
+
const compare = { kind: 'compare', candidates: [{}, {}] } as never
|
|
436
|
+
const select = { kind: 'select', candidates: [{}, {}, {}] } as never
|
|
437
|
+
const track = { kind: 'track', steps: [] } as never
|
|
438
|
+
expect(routedRepeats(compare, 1)).toBe(2)
|
|
439
|
+
expect(routedRepeats(compare, 2)).toBe(2)
|
|
440
|
+
expect(routedRepeats(compare, 3)).toBe(4)
|
|
441
|
+
expect(routedRepeats(select, 1)).toBe(1)
|
|
442
|
+
expect(routedRepeats(select, 3)).toBe(3)
|
|
443
|
+
expect(routedRepeats(track, 1)).toBe(1)
|
|
444
|
+
expect(routedRepeats(track, 3)).toBe(3)
|
|
445
|
+
})
|
|
446
|
+
|
|
259
447
|
it('estimates routed calls from the real tournament shape', () => {
|
|
260
448
|
const candidate = { id: 'c', groupId: 'g', label: 'c', content: 'x', callId: 'a', fromSeq: 1, toSeq: 2 }
|
|
261
449
|
const base = { source: 'structured' as const, confidence: 1, reason: 'r', fingerprint: 'f' }
|