dsh-llm-verifier 0.6.0 → 0.6.2
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 +3 -0
- package/lib/{auto-DAMeKRkK.js → auto-caXDU2pw.js} +19 -1
- package/lib/{auto-DAMeKRkK.js.map → auto-caXDU2pw.js.map} +1 -1
- package/lib/client.js +493 -331
- package/lib/client.js.map +1 -1
- package/lib/index.js +201 -52
- package/lib/index.js.map +1 -1
- package/lib/replay.js +1 -1
- package/lib/types/client-i18n.d.ts +36 -11
- package/lib/types/client-i18n.d.ts.map +1 -1
- package/lib/types/client-i18n.js +50 -9
- package/lib/types/client-i18n.js.map +1 -1
- package/lib/types/client.d.ts +17 -7
- package/lib/types/client.d.ts.map +1 -1
- package/lib/types/client.js +165 -44
- package/lib/types/client.js.map +1 -1
- package/lib/types/decisions.d.ts +8 -0
- package/lib/types/decisions.d.ts.map +1 -1
- package/lib/types/decisions.js +13 -1
- package/lib/types/decisions.js.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +47 -31
- package/lib/types/index.js.map +1 -1
- package/lib/types/process-selection.d.ts +27 -5
- package/lib/types/process-selection.d.ts.map +1 -1
- package/lib/types/process-selection.js +17 -8
- package/lib/types/process-selection.js.map +1 -1
- package/lib/types/router.d.ts +22 -0
- package/lib/types/router.d.ts.map +1 -1
- package/lib/types/router.js +20 -0
- package/lib/types/router.js.map +1 -1
- package/lib/types/statistics.d.ts +8 -0
- package/lib/types/statistics.d.ts.map +1 -1
- package/lib/types/statistics.js +1 -1
- package/lib/types/statistics.js.map +1 -1
- package/lib/types/verifier-activity.d.ts +189 -0
- package/lib/types/verifier-activity.d.ts.map +1 -0
- package/lib/types/verifier-activity.js +223 -0
- package/lib/types/verifier-activity.js.map +1 -0
- package/package.json +1 -1
- package/src/client-i18n.ts +55 -16
- package/src/client.test.ts +101 -16
- package/src/client.tsx +238 -103
- package/src/decisions.test.ts +16 -0
- package/src/decisions.ts +22 -1
- package/src/index.test.ts +32 -0
- package/src/index.ts +48 -33
- package/src/process-selection.test.ts +13 -0
- package/src/process-selection.ts +39 -14
- package/src/router.test.ts +46 -0
- package/src/router.ts +31 -0
- package/src/statistics.test.ts +16 -0
- package/src/statistics.ts +9 -1
- package/src/verifier-activity.test.ts +164 -0
- package/src/verifier-activity.ts +321 -0
- package/lib/types/process-activity.d.ts +0 -109
- package/lib/types/process-activity.d.ts.map +0 -1
- package/lib/types/process-activity.js +0 -135
- package/lib/types/process-activity.js.map +0 -1
- package/src/process-activity.test.ts +0 -91
- package/src/process-activity.ts +0 -177
package/src/decisions.test.ts
CHANGED
|
@@ -113,6 +113,22 @@ describe('DecisionStore', () => {
|
|
|
113
113
|
expect(document.records).toHaveLength(1)
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
it('files a snapshot under the caller\'s invocation id, so the statistics row can resolve it', async () => {
|
|
117
|
+
// Regression: the dashboard lists statistics rows and asks for a row's snapshot BY the id it
|
|
118
|
+
// listed the row under. Two independently generated uuids made every lookup miss, and the panel
|
|
119
|
+
// then blamed pruning or a deleted topic while the snapshot sat in this very file.
|
|
120
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
121
|
+
const file = join(root, 'decisions.json')
|
|
122
|
+
const store = new DecisionStore(file)
|
|
123
|
+
const record = await store.record({ id: 'invocation-7', toolName: 'verifier_current_session', phase: 'final', startedAt: 1, provider: 'p', model: 'm', calls: [call()] })
|
|
124
|
+
expect(record?.id).toBe('invocation-7')
|
|
125
|
+
expect(await store.find('invocation-7')).toMatchObject({ toolName: 'verifier_current_session' })
|
|
126
|
+
// An unusable id falls back to a fresh uuid instead of merging two invocations.
|
|
127
|
+
const fallback = await store.record({ id: '', toolName: 'verifier_track', phase: 'track', startedAt: 2, provider: 'p', model: 'm', calls: [call()] })
|
|
128
|
+
expect(fallback?.id).toBeTruthy()
|
|
129
|
+
expect(fallback?.id).not.toBe('')
|
|
130
|
+
})
|
|
131
|
+
|
|
116
132
|
it('writes nothing when no call was captured', async () => {
|
|
117
133
|
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-decisions-'))
|
|
118
134
|
const file = join(root, 'decisions.json')
|
package/src/decisions.ts
CHANGED
|
@@ -148,6 +148,14 @@ export interface DecisionInput {
|
|
|
148
148
|
provider: string
|
|
149
149
|
model: string
|
|
150
150
|
calls: readonly DecisionCall[]
|
|
151
|
+
/**
|
|
152
|
+
* Invocation id to file the snapshot under; a fresh uuid when omitted.
|
|
153
|
+
*
|
|
154
|
+
* The dashboard lists STATISTICS rows and asks for the snapshot of one row by ITS id, so the
|
|
155
|
+
* caller passes the id the statistics row was stored with. Two independent uuids made every
|
|
156
|
+
* lookup miss and the panel claim the snapshot was pruned or never captured.
|
|
157
|
+
*/
|
|
158
|
+
id?: string
|
|
151
159
|
}
|
|
152
160
|
|
|
153
161
|
/**
|
|
@@ -171,7 +179,7 @@ export class DecisionStore {
|
|
|
171
179
|
const calls = boundDecisionCalls(input.calls)
|
|
172
180
|
if (calls.length === 0) return undefined
|
|
173
181
|
const record: DecisionRecord = {
|
|
174
|
-
id:
|
|
182
|
+
id: idOf(input.id),
|
|
175
183
|
toolName: input.toolName,
|
|
176
184
|
phase: input.phase,
|
|
177
185
|
startedAt: input.startedAt,
|
|
@@ -228,6 +236,19 @@ export class DecisionStore {
|
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
/**
|
|
240
|
+
* The id a snapshot is filed under.
|
|
241
|
+
*
|
|
242
|
+
* A caller-supplied id is the statistics row's own id ({@link DecisionInput.id}), which is what
|
|
243
|
+
* makes the dashboard's "one snapshot per row" lookup resolve; anything unusable falls back to a
|
|
244
|
+
* fresh uuid so a malformed argument can never merge two invocations into one record.
|
|
245
|
+
* @param candidate - id supplied by the caller, if any.
|
|
246
|
+
* @returns The id to store the record under.
|
|
247
|
+
*/
|
|
248
|
+
function idOf(candidate: string | undefined): string {
|
|
249
|
+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : randomUUID()
|
|
250
|
+
}
|
|
251
|
+
|
|
231
252
|
/**
|
|
232
253
|
* Loose validation, so a record written by a newer/older plugin still loads.
|
|
233
254
|
* @param value - parsed JSON entry.
|
package/src/index.test.ts
CHANGED
|
@@ -723,6 +723,38 @@ describe('usage diagnostics in tool outputs', () => {
|
|
|
723
723
|
})
|
|
724
724
|
})
|
|
725
725
|
|
|
726
|
+
/**
|
|
727
|
+
* The dashboard's "why did the judge decide that" path.
|
|
728
|
+
*
|
|
729
|
+
* The statistics row and its decision snapshot must share ONE id: the row is listed under it, the
|
|
730
|
+
* snapshot is filed under it, and the decision endpoint resolves "the snapshot of this row" by
|
|
731
|
+
* exactly that value. Two independently generated uuids made every click answer "pruned, never
|
|
732
|
+
* captured, or belongs to a deleted topic" while the snapshot sat in the topic sidecar.
|
|
733
|
+
*/
|
|
734
|
+
describe('decision snapshots behind a dashboard row', () => {
|
|
735
|
+
it('resolves the snapshot from the invocation id the row was listed under', async () => {
|
|
736
|
+
const { tools, rpc } = assemble(JUDGE, {
|
|
737
|
+
stream: () => textStream('reasoning\n<score_A> A </score_A>\n<score_B> T </score_B>'),
|
|
738
|
+
sessions: [{ id: 'session-1', createdAt: 1 }],
|
|
739
|
+
})
|
|
740
|
+
await tools.get('verifier_compare')!.execute({ problem: 'pick the better plan', candidate_a: 'AAA', candidate_b: 'BBB', repeats: 1 }, exec)
|
|
741
|
+
const handler = rpc.get('/llm-verifier')!
|
|
742
|
+
const overview = await handler('statistics', { fromMs: 0, toMs: Date.now() + 60_000 }) as { value: { recent: Array<{ id: string; toolName: string; success: boolean }> } }
|
|
743
|
+
const row = overview.value.recent.find(entry => entry.success)
|
|
744
|
+
expect(row).toBeDefined()
|
|
745
|
+
|
|
746
|
+
const found = await handler('decision', { id: row!.id }) as { ok: boolean; value?: { decision: { id: string; toolName: string; calls: Array<{ prompt: string; output: string }> } } }
|
|
747
|
+
expect(found.ok).toBe(true)
|
|
748
|
+
expect(found.value?.decision.id).toBe(row!.id)
|
|
749
|
+
expect(found.value?.decision.toolName).toBe('verifier_compare')
|
|
750
|
+
expect(found.value?.decision.calls.length).toBeGreaterThan(0)
|
|
751
|
+
expect(found.value?.decision.calls[0]?.prompt).toContain('pick the better plan')
|
|
752
|
+
|
|
753
|
+
// A genuinely unknown id still fails closed instead of inventing a snapshot.
|
|
754
|
+
expect(await handler('decision', { id: 'never-recorded' })).toMatchObject({ ok: false })
|
|
755
|
+
})
|
|
756
|
+
})
|
|
757
|
+
|
|
726
758
|
/**
|
|
727
759
|
* The turn-stopping gate is the one place a manual pass would previously be honoured even
|
|
728
760
|
* when it reviewed only an older slice of the task. These drive the REAL registered hook
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { extractSession, sanitizeVerifierText, sessionEvents } from './session.t
|
|
|
15
15
|
import { CriteriaResolver, type ResolvedCriteria } from './criteria.ts'
|
|
16
16
|
import { analyzeAutoTask, automaticFeedback, compareRouteFeedbackDetail, failedAcceptanceCriteria, isSubagentSession, selectRouteFeedbackDetail, sessionAccepted, MAX_ROUTE_FEEDBACK_CHARS, type AcceptanceCriterion, type RoutedCandidateRef } from './auto.ts'
|
|
17
17
|
import { AutoVerifierRouter, analyzeStructuredRoute, boundDecision, buildSemanticRouteView, estimateRoutedCalls, inspectDeliveryPhase, inspectRecoverySignal, latestDirectUserSeq, nextDiagnosticCycleId, parseSemanticRoute, routedRepeats, semanticDecision, semanticReferencesVisible, semanticRouteHint, type CandidateArtifact, type Reservation, type RouteDecision, type RoutedVerifierKind, type SemanticRouteView } from './router.ts'
|
|
18
|
-
import type
|
|
18
|
+
import { VerifierActivities, createActivityObserver, type ActivityView } from './verifier-activity.ts'
|
|
19
19
|
import { ProcessCycleStore, ProcessSelector, resolveProcessFile, type ProcessCycleReport } from './process-selection.ts'
|
|
20
20
|
import { DEFAULT_GROUND_TRUTH_NOTE, EMPTY_WORK_BASELINE, PROPOSAL_CRITERIA, buildGenerationPrompt, buildPairwisePrompt, extractScore, renderDiagnostics, renderReferenceContext, type Diagnostic, type ReviewStage } from './core.ts'
|
|
21
21
|
import { buildPlanPreReviewPrompt, parseVerdictLetter, planFromArguments } from './plan-gate.ts'
|
|
@@ -198,7 +198,11 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
198
198
|
// this closure, so the callback goes through a holder instead of capturing a TDZ binding.
|
|
199
199
|
let clearProcessIntents: () => void = () => {}
|
|
200
200
|
const current = installVerifierSettings(ctx, entry, () => { limiter = new RequestLimiter(current().maxConcurrency); clearProcessIntents() })
|
|
201
|
-
|
|
201
|
+
// ONE activity table per plugin instance: the router publishes every granted cycle into it, P06
|
|
202
|
+
// adds its own richer records, and the chat chip's RPC reads it. It is UI state only — never a
|
|
203
|
+
// verdict, never the model history (see `verifier-activity.ts` for why it is not a session event).
|
|
204
|
+
const activities = new VerifierActivities()
|
|
205
|
+
const autoRouter = new AutoVerifierRouter(createActivityObserver(activities))
|
|
202
206
|
const topics = new Map<string, { dataDir: string; cache: ScoreCache; capabilities: TopLogprobCapabilityCache; flights: SingleFlight<{ value: CachedPairScore; hit: boolean }>; statistics: StatisticsStore; decisions: DecisionStore; process: ProcessCycleStore }>()
|
|
203
207
|
const topic = (header: SessionHeader) => {
|
|
204
208
|
const selected = current()
|
|
@@ -326,11 +330,15 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
326
330
|
const completed = await operation(trace)
|
|
327
331
|
selected = completed.selected
|
|
328
332
|
const value = { ...completed.result as T & object, ...route(selected) } as T & { provider: string; model: string }
|
|
329
|
-
|
|
333
|
+
// The statistics row and its decision snapshot share ONE id: the dashboard lists statistics
|
|
334
|
+
// rows and asks for "the snapshot of this row" by the id it was listed under. Two
|
|
335
|
+
// independently generated uuids made every lookup miss, and the panel then reported the
|
|
336
|
+
// snapshot as pruned / never captured even though it was sitting in the topic's sidecar.
|
|
337
|
+
const invocation = 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), ...(observation ? { route: observation } : {}) }).catch(() => undefined)
|
|
330
338
|
// Order by label before storing: the engine reports calls as they complete, so a concurrent
|
|
331
339
|
// fan-out reports them in network order. Sorting makes "which calls are in the snapshot"
|
|
332
340
|
// reproducible even when the record has to bound its text.
|
|
333
|
-
if (calls.length > 0) 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(() => {})
|
|
341
|
+
if (calls.length > 0) await topicEntry.decisions.record({ ...(invocation === undefined ? {} : { id: invocation.id }), 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(() => {})
|
|
334
342
|
return value
|
|
335
343
|
} catch (error) {
|
|
336
344
|
const details = errorDetails(error)
|
|
@@ -754,6 +762,7 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
754
762
|
sanitize: (text, maxChars) => sanitizeVerifierText(text, maxChars),
|
|
755
763
|
policy: processPolicy,
|
|
756
764
|
router: () => autoRouter,
|
|
765
|
+
activities,
|
|
757
766
|
store: agent => topic((agent as Agent).session.header).process,
|
|
758
767
|
// The whole task, not just the user's question: the judge has to judge whether the next step
|
|
759
768
|
// addresses the REAL failure, which needs the constraints the request was made under, the
|
|
@@ -785,17 +794,18 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
785
794
|
}, request.signal)
|
|
786
795
|
// The decision snapshot is written here, but the STATISTICS row is written once for the whole
|
|
787
796
|
// cycle (see record below): a second row would count the same judge calls twice.
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
797
|
+
const snapshot = calls.length === 0 ? undefined : await topic(agent.session.header).decisions.record({
|
|
798
|
+
toolName: 'verifier_compare',
|
|
799
|
+
phase: 'process',
|
|
800
|
+
startedAt,
|
|
801
|
+
provider: selected.provider,
|
|
802
|
+
model: selected.model,
|
|
803
|
+
calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
|
|
804
|
+
}).catch(() => undefined)
|
|
805
|
+
// This cycle's statistics row is written later (once the winner is known), so the snapshot
|
|
806
|
+
// hands its id forward and that row is stored under it: the dashboard asks for a row's
|
|
807
|
+
// snapshot BY the id it listed the row with.
|
|
808
|
+
return snapshot === undefined ? result : { ...result, decisionId: snapshot.id }
|
|
799
809
|
},
|
|
800
810
|
// The tournament seam. Optional by construction: it is only reached when autoProcessCandidates
|
|
801
811
|
// asks for more than two candidates, and a cycle without it compares one pair instead.
|
|
@@ -815,17 +825,16 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
815
825
|
...(trace ? { trace } : {}),
|
|
816
826
|
}, request.signal)
|
|
817
827
|
// One decision snapshot, one statistics row for the whole cycle — exactly like the pair path.
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
return result
|
|
828
|
+
const snapshot = calls.length === 0 ? undefined : await topic(agent.session.header).decisions.record({
|
|
829
|
+
toolName: 'verifier_select',
|
|
830
|
+
phase: 'process',
|
|
831
|
+
startedAt,
|
|
832
|
+
provider: selected.provider,
|
|
833
|
+
model: selected.model,
|
|
834
|
+
calls: boundDecisionCalls([...calls].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))),
|
|
835
|
+
}).catch(() => undefined)
|
|
836
|
+
// Same id hand-off as the pairwise path: one statistics row, one snapshot, one shared id.
|
|
837
|
+
return snapshot === undefined ? result : { ...result, decisionId: snapshot.id }
|
|
829
838
|
},
|
|
830
839
|
record: async (report: ProcessCycleReport) => {
|
|
831
840
|
const agent = report.agent as Agent
|
|
@@ -849,6 +858,9 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
849
858
|
: { phase: 'process', outcome: report.outcome }
|
|
850
859
|
const failedCall = report.outcome === 'generation-failed' || report.outcome === 'comparison-failed'
|
|
851
860
|
await topic(agent.session.header).statistics.record({
|
|
861
|
+
// The snapshot of this cycle was already written while the comparison ran; the row adopts
|
|
862
|
+
// its id so the dashboard can fetch that snapshot from the row it lists.
|
|
863
|
+
...(report.decisionId === undefined ? {} : { id: report.decisionId }),
|
|
852
864
|
toolName: report.select === undefined ? 'verifier_compare' : 'verifier_select',
|
|
853
865
|
sessionId: String(agent.id),
|
|
854
866
|
startedAt: report.startedAt,
|
|
@@ -953,23 +965,26 @@ export function apply(ctx: Context, config: Config = {}): void {
|
|
|
953
965
|
const isDecisionQuery = (payload: unknown): boolean => typeof payload === 'object' && payload !== null && (payload as { kind?: unknown }).kind === 'decision'
|
|
954
966
|
|
|
955
967
|
/**
|
|
956
|
-
* Whether a payload asks what
|
|
968
|
+
* Whether a payload asks what this plugin is doing for one session.
|
|
957
969
|
*
|
|
958
|
-
* This is what the chat chip polls while a turn runs
|
|
959
|
-
*
|
|
960
|
-
*
|
|
970
|
+
* This is what the chat chip polls while a turn runs: a P06 cycle (which buffers the reply), a
|
|
971
|
+
* routed review, or the final acceptance. The wire name is historical — it covers every stage now,
|
|
972
|
+
* and keeping it means a page holding the older client bundle keeps working.
|
|
961
973
|
*/
|
|
962
974
|
const isProcessQuery = (payload: unknown): boolean => typeof payload === 'object' && payload !== null && (payload as { kind?: unknown }).kind === 'process'
|
|
963
975
|
|
|
964
976
|
/**
|
|
965
|
-
* Read the in-flight / just-settled
|
|
977
|
+
* Read the in-flight / just-settled cycle of one session.
|
|
978
|
+
*
|
|
979
|
+
* In-memory state only: no sidecar read, no model call, nothing durable, so polling it cannot cost
|
|
980
|
+
* anything or change a verdict. A session with nothing to show answers with an empty object.
|
|
966
981
|
* @param payload - the RPC payload; `sessionId` is required.
|
|
967
982
|
* @returns The activity view, or a bad-request failure.
|
|
968
983
|
*/
|
|
969
|
-
const handleProcessQuery = async (payload: unknown): Promise<{ ok: true; value: { sessionId: string } &
|
|
984
|
+
const handleProcessQuery = async (payload: unknown): Promise<{ ok: true; value: { sessionId: string } & ActivityView } | ReturnType<typeof rpcFailure>> => {
|
|
970
985
|
const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId
|
|
971
986
|
if (typeof sessionId !== 'string' || sessionId === '') return rpcFailure('a sessionId is required')
|
|
972
|
-
return rpcSuccess({ sessionId, ...
|
|
987
|
+
return rpcSuccess({ sessionId, ...activities.read(sessionId, Date.now()) })
|
|
973
988
|
}
|
|
974
989
|
|
|
975
990
|
/**
|
|
@@ -457,6 +457,19 @@ describe('process cycle execution', () => {
|
|
|
457
457
|
expect(report?.observation.replayed).toBe('candidate')
|
|
458
458
|
})
|
|
459
459
|
|
|
460
|
+
it('carries the snapshot id the judging seam reported, so the statistics row can link to it', async () => {
|
|
461
|
+
// The seam files the decision snapshot while it judges, but the statistics row only exists once
|
|
462
|
+
// the winner is known: the id travels with the report so that row can adopt it (the dashboard
|
|
463
|
+
// asks for "the snapshot of this row" by the id it listed the row under).
|
|
464
|
+
const linked = harness({ compare: async () => compareResult('B', { decisionId: 'snapshot-1' }) })
|
|
465
|
+
const { report } = await run(linked, { original: textChunks('ORIGINAL') })
|
|
466
|
+
expect(report?.decisionId).toBe('snapshot-1')
|
|
467
|
+
|
|
468
|
+
// Nothing captured (every judge call was a cache hit): the row keeps its own generated id.
|
|
469
|
+
const plain = harness({ compare: async () => compareResult('B') })
|
|
470
|
+
expect((await run(plain, { original: textChunks('ORIGINAL') })).report?.decisionId).toBeUndefined()
|
|
471
|
+
})
|
|
472
|
+
|
|
460
473
|
it('replays the original verbatim on a tie, on an A win, and records which', async () => {
|
|
461
474
|
for (const winner of ['A', 'tie'] as const) {
|
|
462
475
|
const original = textChunks('ORIGINAL')
|
package/src/process-selection.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { addUsage, emptyUsage, GENERATION_TEMPERATURE, type UsageStats } from '.
|
|
|
22
22
|
import { stableHash } from './cache.ts'
|
|
23
23
|
import { PROCESS_CRITERIA, type Criterion } from './core.ts'
|
|
24
24
|
import { mergeRunStats, partialStats, type CompareResult, type RunStats, type SelectResult } from './engine.ts'
|
|
25
|
-
import {
|
|
25
|
+
import { VerifierActivities, classifyProcessOutcome, type ActivityView } from './verifier-activity.ts'
|
|
26
26
|
import { estimateRoutedCalls, itemBudget, type AutoVerifierRouter, type Reservation, type RouterPolicy, type RoutedAgent } from './router.ts'
|
|
27
27
|
import type { RouteObservation } from './statistics.ts'
|
|
28
28
|
|
|
@@ -147,6 +147,14 @@ export interface ProcessCycleReport {
|
|
|
147
147
|
compare?: CompareResult
|
|
148
148
|
/** Present when the cycle ran the tournament (N>2). */
|
|
149
149
|
select?: SelectResult
|
|
150
|
+
/**
|
|
151
|
+
* Id of the decision snapshot the judging seam filed, when it filed one.
|
|
152
|
+
*
|
|
153
|
+
* The seam writes the snapshot while it judges, but the statistics row only exists once the
|
|
154
|
+
* winner is known. Reporting the id lets that row adopt it, so the dashboard can fetch a row's
|
|
155
|
+
* snapshot by the id it listed the row under.
|
|
156
|
+
*/
|
|
157
|
+
decisionId?: string
|
|
150
158
|
error?: string
|
|
151
159
|
}
|
|
152
160
|
|
|
@@ -209,6 +217,13 @@ export interface ProcessSelectorDeps {
|
|
|
209
217
|
/** The routing policy in force, including the final-acceptance floor and the process allowance. */
|
|
210
218
|
policy(): Promise<RouterPolicy>
|
|
211
219
|
router(): AutoVerifierRouter
|
|
220
|
+
/**
|
|
221
|
+
* Shared activity table the router's cycles and these P06 cycles are published to.
|
|
222
|
+
*
|
|
223
|
+
* Injected so one plugin instance has ONE table: the chip's read must see a routed review and a
|
|
224
|
+
* process cycle in the same place. Defaults to a private table, which is all a focused test needs.
|
|
225
|
+
*/
|
|
226
|
+
activities?: VerifierActivities
|
|
212
227
|
/** Durable cycle log of one topic. */
|
|
213
228
|
store(agent: unknown): ProcessCycleStore
|
|
214
229
|
/** Newest session state, used for staleness and for the comparison's task evidence. */
|
|
@@ -217,14 +232,14 @@ export interface ProcessSelectorDeps {
|
|
|
217
232
|
current(intent: ProcessIntent): boolean
|
|
218
233
|
/** Independent dispatch for the alternative reply (a fresh request object). */
|
|
219
234
|
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
|
220
|
-
compare(request: ProcessCompareRequest): Promise<CompareResult>
|
|
235
|
+
compare(request: ProcessCompareRequest): Promise<CompareResult & { decisionId?: string }>
|
|
221
236
|
/**
|
|
222
237
|
* Tournament over 3+ candidates; required only when `settings.candidates` is above 2.
|
|
223
238
|
*
|
|
224
239
|
* Optional on purpose: an embedder that never raises the count needs no tournament seam, and a
|
|
225
240
|
* cycle that asks for one without it falls back to the pairwise path with a warning.
|
|
226
241
|
*/
|
|
227
|
-
select?(request: ProcessSelectRequest): Promise<SelectResult>
|
|
242
|
+
select?(request: ProcessSelectRequest): Promise<SelectResult & { decisionId?: string }>
|
|
228
243
|
record(report: ProcessCycleReport): Promise<void>
|
|
229
244
|
/**
|
|
230
245
|
* Rewrite the STATISTICS row of one already-recorded cycle because its delivery changed.
|
|
@@ -812,17 +827,22 @@ export class ProcessSelector {
|
|
|
812
827
|
*
|
|
813
828
|
* Host-only and in memory: a session event would carry the same information, but the persistence
|
|
814
829
|
* read path refuses unknown event types for an out-of-repo plugin, and `Session.append` cannot
|
|
815
|
-
* set the `ignorable` marker that would make one loadable (see `
|
|
830
|
+
* set the `ignorable` marker that would make one loadable (see `verifier-activity.ts`).
|
|
816
831
|
*/
|
|
817
|
-
private readonly activities
|
|
832
|
+
private readonly activities: VerifierActivities
|
|
818
833
|
|
|
819
|
-
constructor(private readonly deps: ProcessSelectorDeps) {
|
|
834
|
+
constructor(private readonly deps: ProcessSelectorDeps) {
|
|
835
|
+
this.activities = deps.activities ?? new VerifierActivities()
|
|
836
|
+
}
|
|
820
837
|
|
|
821
838
|
/**
|
|
822
839
|
* The cycle one session has to show right now, for the UI chip (in flight, or just settled).
|
|
840
|
+
*
|
|
841
|
+
* Reads the SAME table the router publishes into, so a routed review and a process cycle are never
|
|
842
|
+
* two different answers to "what is this session doing".
|
|
823
843
|
* @param sessionId - the session to read.
|
|
824
844
|
*/
|
|
825
|
-
activity(sessionId: string):
|
|
845
|
+
activity(sessionId: string): ActivityView { return this.activities.read(sessionId, this.deps.now()) }
|
|
826
846
|
|
|
827
847
|
/** Register (or replace) the pending intent of one session. */
|
|
828
848
|
register(intent: ProcessIntent): void { this.intents.set(intent.sessionId, intent) }
|
|
@@ -966,6 +986,7 @@ export class ProcessSelector {
|
|
|
966
986
|
// streaming text of its own, so the chip is the only thing that can explain it.
|
|
967
987
|
this.activities.begin(intent.sessionId, {
|
|
968
988
|
cycleId: reservation.id,
|
|
989
|
+
stage: 'process',
|
|
969
990
|
phase: 'generating',
|
|
970
991
|
candidates: count,
|
|
971
992
|
...(observation.alternativeModel === undefined ? {} : { alternativeModel: observation.alternativeModel }),
|
|
@@ -1230,8 +1251,8 @@ export class ProcessSelector {
|
|
|
1230
1251
|
}
|
|
1231
1252
|
// The generation and the buffering are behind us; everything this cycle still owes the user
|
|
1232
1253
|
// is judge latency.
|
|
1233
|
-
this.activities.
|
|
1234
|
-
let judged: { judgeCalls: number; winner: BufferedCandidate | undefined; tie: boolean; stats: RunStats; compare?: CompareResult; select?: SelectResult }
|
|
1254
|
+
this.activities.update(intent.sessionId, reservation.id, { phase: 'comparing' })
|
|
1255
|
+
let judged: { judgeCalls: number; winner: BufferedCandidate | undefined; tie: boolean; stats: RunStats; compare?: CompareResult; select?: SelectResult; decisionId?: string }
|
|
1235
1256
|
try {
|
|
1236
1257
|
if ('candidateA' in view) {
|
|
1237
1258
|
const compared = await this.deps.compare({
|
|
@@ -1244,7 +1265,7 @@ export class ProcessSelector {
|
|
|
1244
1265
|
repeats: PROCESS_REPEATS,
|
|
1245
1266
|
signal: phase.signal,
|
|
1246
1267
|
})
|
|
1247
|
-
judged = { judgeCalls: compared.calls, winner: compared.winner === 'B' ? alternatives[0] : undefined, tie: compared.winner === 'tie', stats: compared.stats, compare: compared }
|
|
1268
|
+
judged = { judgeCalls: compared.calls, winner: compared.winner === 'B' ? alternatives[0] : undefined, tie: compared.winner === 'tie', stats: compared.stats, compare: compared, ...(compared.decisionId === undefined ? {} : { decisionId: compared.decisionId }) }
|
|
1248
1269
|
} else {
|
|
1249
1270
|
const selected = await this.deps.select!({
|
|
1250
1271
|
agent: intent.agent,
|
|
@@ -1255,7 +1276,7 @@ export class ProcessSelector {
|
|
|
1255
1276
|
repeats: PROCESS_REPEATS,
|
|
1256
1277
|
signal: phase.signal,
|
|
1257
1278
|
})
|
|
1258
|
-
judged = { judgeCalls: selected.calls, winner: selected.index > 0 ? alternatives[selected.index - 1] : undefined, tie: false, stats: selected.stats, select: selected }
|
|
1279
|
+
judged = { judgeCalls: selected.calls, winner: selected.index > 0 ? alternatives[selected.index - 1] : undefined, tie: false, stats: selected.stats, select: selected, ...(selected.decisionId === undefined ? {} : { decisionId: selected.decisionId }) }
|
|
1259
1280
|
}
|
|
1260
1281
|
} catch (error) {
|
|
1261
1282
|
// The judge run really happened before it failed: fold in every call the engine attached to
|
|
@@ -1306,7 +1327,7 @@ export class ProcessSelector {
|
|
|
1306
1327
|
// dep. Both must state the delivery, and `finish` overwrites the cycle's own record.
|
|
1307
1328
|
await this.deps.store(intent.agent).finish(reservation.id, outcome, 'original')
|
|
1308
1329
|
// The chip follows the same correction: it was already settled as a replacement.
|
|
1309
|
-
this.activities.finish(intent.sessionId, reservation.id, outcome, 'original', this.deps.now())
|
|
1330
|
+
this.activities.finish(intent.sessionId, reservation.id, classifyProcessOutcome(outcome, 'original'), this.deps.now())
|
|
1310
1331
|
try {
|
|
1311
1332
|
await this.deps.correctDelivery({ agent: intent.agent, cycleId: reservation.id, outcome, replayed: 'original' })
|
|
1312
1333
|
} catch (error) {
|
|
@@ -1366,16 +1387,20 @@ export class ProcessSelector {
|
|
|
1366
1387
|
}
|
|
1367
1388
|
|
|
1368
1389
|
/** Record a purchased cycle and stamp its durable outcome. */
|
|
1369
|
-
private async report(input: { intent: ProcessIntent; reservation: Reservation; observation: RouteObservation; startedAt: number; outcome: string; replayed: 'original' | 'candidate'; generatedCalls: number; judgeCalls: number; sameCandidate: boolean; usage: RunStats; compare?: CompareResult; select?: SelectResult; error?: string }): Promise<void> {
|
|
1390
|
+
private async report(input: { intent: ProcessIntent; reservation: Reservation; observation: RouteObservation; startedAt: number; outcome: string; replayed: 'original' | 'candidate'; generatedCalls: number; judgeCalls: number; sameCandidate: boolean; usage: RunStats; compare?: CompareResult & { decisionId?: string }; select?: SelectResult & { decisionId?: string }; error?: string }): Promise<void> {
|
|
1370
1391
|
// The chip is told first: it is live UI state, and it must not depend on the cycle log or the
|
|
1371
1392
|
// statistics row being writable (a read-only topic still gets an honest indicator).
|
|
1372
|
-
this.activities.finish(input.intent.sessionId, input.reservation.id, input.outcome, input.replayed, this.deps.now())
|
|
1393
|
+
this.activities.finish(input.intent.sessionId, input.reservation.id, classifyProcessOutcome(input.outcome, input.replayed), this.deps.now())
|
|
1373
1394
|
await this.deps.store(input.intent.agent).finish(input.reservation.id, input.outcome, input.replayed)
|
|
1395
|
+
// The judging seam filed the snapshot under an id of its own; the statistics row adopts it, so
|
|
1396
|
+
// "the snapshot of this row" resolves from the dashboard. Absent when nothing was captured.
|
|
1397
|
+
const decisionId = input.compare?.decisionId ?? input.select?.decisionId
|
|
1374
1398
|
await this.deps.record({
|
|
1375
1399
|
agent: input.intent.agent,
|
|
1376
1400
|
cycleId: input.reservation.id,
|
|
1377
1401
|
purchased: true,
|
|
1378
1402
|
startedAt: input.startedAt,
|
|
1403
|
+
...(decisionId === undefined ? {} : { decisionId }),
|
|
1379
1404
|
outcome: input.outcome,
|
|
1380
1405
|
replayed: input.replayed,
|
|
1381
1406
|
generatedCalls: input.generatedCalls,
|
package/src/router.test.ts
CHANGED
|
@@ -913,6 +913,52 @@ describe('structured route dedup and selection', () => {
|
|
|
913
913
|
})
|
|
914
914
|
})
|
|
915
915
|
|
|
916
|
+
describe('cycle observer for the chat chip', () => {
|
|
917
|
+
it('reports the grant, the promotion and the settlement of one cycle', () => {
|
|
918
|
+
const value = session(); const agent = { id: value.id, session: value }
|
|
919
|
+
const seen: string[] = []
|
|
920
|
+
const router = new AutoVerifierRouter({
|
|
921
|
+
begin: (_agent, reservation) => { seen.push('begin:' + reservation.phase + ':' + String(reservation.expectedCalls)) },
|
|
922
|
+
promoted: (_agent, reservation) => { seen.push('promoted:' + reservation.phase + ':' + String(reservation.expectedCalls)) },
|
|
923
|
+
settled: (_agent, reservation, outcome) => { seen.push('settled:' + reservation.phase + ':' + outcome) },
|
|
924
|
+
})
|
|
925
|
+
const classified = router.reserve(agent, 'semantic', 'classify', 1, policy)!
|
|
926
|
+
router.promote(agent, classified, 'compare', 'resolve', 5, policy)
|
|
927
|
+
expect(router.commit(agent, classified, 3)).toBe(true)
|
|
928
|
+
// The promotion is reported on the SAME reservation, which is what lets the chip move from
|
|
929
|
+
// "classifying" to "reviewing" instead of leaving a second cycle behind.
|
|
930
|
+
expect(seen).toEqual(['begin:semantic:1', 'promoted:compare:6', 'settled:compare:committed'])
|
|
931
|
+
|
|
932
|
+
const final = router.reserve(agent, 'final', 'final', 4, policy)!
|
|
933
|
+
router.fail(agent, final, false)
|
|
934
|
+
expect(seen.at(-1)).toBe('settled:final:failed')
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
it('reports nothing twice when a settlement is refused', () => {
|
|
938
|
+
const value = session(); const agent = { id: value.id, session: value }
|
|
939
|
+
let settled = 0
|
|
940
|
+
const router = new AutoVerifierRouter({ settled: () => { settled += 1 } })
|
|
941
|
+
const route = router.reserve(agent, 'compare', 'route', 4, policy)!
|
|
942
|
+
expect(router.commit(agent, route, 1)).toBe(true)
|
|
943
|
+
// A second commit for the same reservation is a no-op, and must not fake an edge.
|
|
944
|
+
expect(router.commit(agent, route, 1)).toBe(false)
|
|
945
|
+
expect(settled).toBe(1)
|
|
946
|
+
})
|
|
947
|
+
|
|
948
|
+
it('lets a broken observer throw without changing routing', () => {
|
|
949
|
+
// The observer is a reporting channel: a bug in the UI path must never cost a reservation, a
|
|
950
|
+
// verdict or the ability to route again.
|
|
951
|
+
const value = session(); const agent = { id: value.id, session: value }
|
|
952
|
+
const explode = () => { throw new Error('observer exploded') }
|
|
953
|
+
const router = new AutoVerifierRouter({ begin: explode, promoted: explode, settled: explode })
|
|
954
|
+
const classified = router.reserve(agent, 'semantic', 'classify', 1, policy)!
|
|
955
|
+
expect(router.promote(agent, classified, 'track', 'resolve', 2, policy)).toBe(true)
|
|
956
|
+
expect(router.fail(agent, classified, false)).toBeUndefined()
|
|
957
|
+
// The failed cycle released the in-flight slot, so routing continues.
|
|
958
|
+
expect(router.reserve(agent, 'compare', 'next', 4, policy)).toBeDefined()
|
|
959
|
+
})
|
|
960
|
+
})
|
|
961
|
+
|
|
916
962
|
describe('transactional router state', () => {
|
|
917
963
|
it('commits, requires final verification, and clears it only after final commit', () => {
|
|
918
964
|
const value = session(); const agent = { id: value.id, session: value }; const router = new AutoVerifierRouter()
|
package/src/router.ts
CHANGED
|
@@ -1412,6 +1412,22 @@ export function boundDecision(decision: RouteDecision | undefined, policy: Route
|
|
|
1412
1412
|
return decision
|
|
1413
1413
|
}
|
|
1414
1414
|
|
|
1415
|
+
/**
|
|
1416
|
+
* Advisory observer of granted routing cycles, used by the chat indicator.
|
|
1417
|
+
*
|
|
1418
|
+
* The router is the only component that knows a cycle was GRANTED and when it settled, but it must
|
|
1419
|
+
* not know what the UI says about it. Every callback is optional and exception-safe: an observer is
|
|
1420
|
+
* a reporting channel, and it must never be able to change routing, budgets or verdicts.
|
|
1421
|
+
*/
|
|
1422
|
+
export interface RouterCycleObserver {
|
|
1423
|
+
/** A cycle was granted (the reservation now owns the agent's in-flight slot). */
|
|
1424
|
+
begin?(agent: RoutedAgent, reservation: Reservation): void
|
|
1425
|
+
/** A classification cycle was promoted into the decision it resolved, on the same reservation. */
|
|
1426
|
+
promoted?(agent: RoutedAgent, reservation: Reservation): void
|
|
1427
|
+
/** The cycle was committed (accepted) or failed (rejected / abandoned). */
|
|
1428
|
+
settled?(agent: RoutedAgent, reservation: Reservation, outcome: 'committed' | 'failed'): void
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1415
1431
|
export class AutoVerifierRouter {
|
|
1416
1432
|
private readonly states = new Map<string, RouterState>()
|
|
1417
1433
|
/** Agent ids that already received this task's budget-exhaustion notice. */
|
|
@@ -1420,6 +1436,17 @@ export class AutoVerifierRouter {
|
|
|
1420
1436
|
/** Namespace for this router's cycle ids; unique per router and per process incarnation. */
|
|
1421
1437
|
private readonly instance = ROUTER_EPOCH + '-' + (++routerInstanceSerial)
|
|
1422
1438
|
|
|
1439
|
+
/**
|
|
1440
|
+
* @param observer - optional reporting channel for the chat indicator.
|
|
1441
|
+
*/
|
|
1442
|
+
constructor(private readonly observer?: RouterCycleObserver) {}
|
|
1443
|
+
|
|
1444
|
+
/** Report one lifecycle edge, swallowing anything the observer throws. */
|
|
1445
|
+
private observe(report: (observer: RouterCycleObserver) => void): void {
|
|
1446
|
+
if (this.observer === undefined) return
|
|
1447
|
+
try { report(this.observer) } catch { /* an advisory UI observer must never change routing */ }
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1423
1450
|
private state(agent: RoutedAgent): RouterState | undefined {
|
|
1424
1451
|
const taskStartSeq = latestDirectUserSeq(sessionEvents(agent.session))
|
|
1425
1452
|
if (taskStartSeq === undefined) return undefined
|
|
@@ -1472,6 +1499,7 @@ export class AutoVerifierRouter {
|
|
|
1472
1499
|
}
|
|
1473
1500
|
state.taskModelCalls += expectedCalls
|
|
1474
1501
|
state.sessionModelCalls += expectedCalls
|
|
1502
|
+
this.observe(observer => observer.begin?.(agent, reservation))
|
|
1475
1503
|
return reservation
|
|
1476
1504
|
}
|
|
1477
1505
|
|
|
@@ -1519,6 +1547,7 @@ export class AutoVerifierRouter {
|
|
|
1519
1547
|
reservation.expectedCalls += expectedCalls
|
|
1520
1548
|
state.taskModelCalls += expectedCalls
|
|
1521
1549
|
state.sessionModelCalls += expectedCalls
|
|
1550
|
+
this.observe(observer => observer.promoted?.(agent, reservation))
|
|
1522
1551
|
return true
|
|
1523
1552
|
}
|
|
1524
1553
|
|
|
@@ -1531,6 +1560,7 @@ export class AutoVerifierRouter {
|
|
|
1531
1560
|
// (and, in strict mode, burn an attempt and set strictBlocked on that empty review).
|
|
1532
1561
|
if (reservation.phase !== 'semantic' && reservation.phase !== 'final' && reservation.phase !== 'plan_review') state.finalRequiredFromSeq = Math.max(state.finalRequiredFromSeq ?? 0, evidenceSeq ?? reservation.taskStartSeq)
|
|
1533
1562
|
if (reservation.phase === 'final') { state.finalRequiredFromSeq = undefined; state.finalPreferred = false }
|
|
1563
|
+
this.observe(observer => observer.settled?.(agent, reservation, 'committed'))
|
|
1534
1564
|
return true
|
|
1535
1565
|
}
|
|
1536
1566
|
|
|
@@ -1539,6 +1569,7 @@ export class AutoVerifierRouter {
|
|
|
1539
1569
|
if (!state || state.inFlight?.id !== reservation.id) return
|
|
1540
1570
|
state.inFlight = undefined; state.failed.add(reservation.fingerprint); if (strict) state.strictBlocked = true
|
|
1541
1571
|
if (reservation.phase === 'final') state.finalPreferred = false
|
|
1572
|
+
this.observe(observer => observer.settled?.(agent, reservation, 'failed'))
|
|
1542
1573
|
}
|
|
1543
1574
|
|
|
1544
1575
|
/**
|
package/src/statistics.test.ts
CHANGED
|
@@ -31,6 +31,22 @@ function stats(overrides: Partial<ReturnType<typeof emptyRunStats>> = {}) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
describe('StatisticsStore', () => {
|
|
34
|
+
it('stores a caller-supplied invocation id so the dashboard row can find its decision snapshot', async () => {
|
|
35
|
+
// The row and its snapshot must share ONE id: the "recall" button sends the id the row was
|
|
36
|
+
// listed under, and an independently generated uuid made that lookup miss every time.
|
|
37
|
+
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
|
|
38
|
+
const file = join(root, 'statistics.json')
|
|
39
|
+
const store = new StatisticsStore(file, 100)
|
|
40
|
+
const stored = await store.record({ id: 'invocation-9', toolName: 'verifier_current_session', startedAt: 1, success: true, provider: 'p', model: 'm', stats: stats() })
|
|
41
|
+
expect(stored.id).toBe('invocation-9')
|
|
42
|
+
const reopened = new StatisticsStore(file, 100)
|
|
43
|
+
const overview = await reopened.overview({ fromMs: 0, toMs: 10 })
|
|
44
|
+
expect(overview.recent[0]?.id).toBe('invocation-9')
|
|
45
|
+
// An unusable id falls back to a generated one instead of producing a nameless row.
|
|
46
|
+
const fallback = await store.record({ id: '', toolName: 'verifier_track', startedAt: 2, success: true, provider: 'p', model: 'm', stats: stats() })
|
|
47
|
+
expect(fallback.id).not.toBe('')
|
|
48
|
+
})
|
|
49
|
+
|
|
34
50
|
it('records and aggregates invocations by day, tool, model, and session', async () => {
|
|
35
51
|
const root = await mkdtemp(join(tmpdir(), 'dsh-verifier-statistics-'))
|
|
36
52
|
const file = join(root, 'statistics.json')
|
package/src/statistics.ts
CHANGED
|
@@ -336,6 +336,14 @@ export interface StatisticsQuery {
|
|
|
336
336
|
|
|
337
337
|
export interface InvocationInput {
|
|
338
338
|
toolName: VerifierToolName
|
|
339
|
+
/**
|
|
340
|
+
* Invocation id; a fresh uuid when omitted.
|
|
341
|
+
*
|
|
342
|
+
* Shared with the decision snapshot of the same invocation: the dashboard lists these rows and
|
|
343
|
+
* asks for "the snapshot of THIS row" by the id it was listed under, so the caller passes the
|
|
344
|
+
* id the snapshot was filed with (see `decisions.ts`).
|
|
345
|
+
*/
|
|
346
|
+
id?: string
|
|
339
347
|
sessionId?: string
|
|
340
348
|
startedAt: number
|
|
341
349
|
finishedAt?: number
|
|
@@ -581,7 +589,7 @@ export class StatisticsStore {
|
|
|
581
589
|
const verdict = cleanVerdict(input.verdict)
|
|
582
590
|
const route = cleanRoute(input.route)
|
|
583
591
|
const record: InvocationRecord = {
|
|
584
|
-
id: randomUUID(),
|
|
592
|
+
id: typeof input.id === 'string' && input.id.length > 0 ? input.id : randomUUID(),
|
|
585
593
|
toolName: input.toolName,
|
|
586
594
|
...(input.sessionId ? { sessionId: input.sessionId } : {}),
|
|
587
595
|
startedAt: input.startedAt,
|