dsh-vision-router 2.1.6 → 2.2.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 +8 -14
- package/README.zh.md +8 -14
- package/cordis.patch.yml +14 -0
- package/docs/architecture/compat-inventory.md +1 -1
- package/docs/architecture/dsh-compatibility-matrix.md +4 -1
- package/docs/architecture/dsh-support-window.md +1 -2
- package/docs/releases/v2.1.7.md +13 -0
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +387 -77
- package/lib/artifact-boundary.js +11 -0
- package/lib/artifact-io.js +60 -7
- package/lib/catalog-corrections.js +5 -0
- package/lib/client.js +293 -48
- package/lib/core-primitives.js +122 -7
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +202 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/image-offload-compat.js +38 -0
- package/lib/live-model-discovery.js +125 -15
- package/lib/local-vision-stabilizer.js +15 -6
- package/lib/official-deepseek-catalog.js +120 -0
- package/lib/ollama-cold-start.js +3 -15
- package/lib/remote-settings-bridge.js +28 -5
- package/lib/replay-delegation.js +68 -42
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-config-normalizer.js +271 -1
- package/lib/runtime-i18n-boundary.js +4 -13
- package/lib/runtime-i18n.js +2 -2
- package/lib/session-surface-compat.js +40 -0
- package/lib/session-turn-resolver.js +57 -0
- package/lib/session-vision-index.js +206 -93
- package/lib/session-vision-mode-boundary.js +83 -6
- package/lib/session-vision-runtime.js +8 -2
- package/lib/session-vision-state.js +0 -13
- package/lib/tesseract-exec-compat.js +16 -2
- package/lib/twin-image-capability-fallback.js +2 -11
- package/lib/vision-artifact-store.js +16 -3
- package/lib/vision-attachment-handle-runtime.js +9 -21
- package/lib/vision-backend-runtime-policy.js +7 -6
- package/lib/vision-background-stop-store.js +30 -20
- package/lib/vision-breaker-shadow-health.js +15 -5
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/vision-resilience.js +73 -1
- package/lib/web-capability-boundary.js +85 -22
- package/package.json +14 -7
|
@@ -123,7 +123,6 @@ function createState(key, stable, options, now) {
|
|
|
123
123
|
attachments: new WeightedLruMap({
|
|
124
124
|
maxEntries: options.attachmentMaxEntries,
|
|
125
125
|
}),
|
|
126
|
-
scannedEventSeq: 0,
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
|
|
@@ -356,17 +355,6 @@ export function createSessionVisionStateStore(config = {}) {
|
|
|
356
355
|
return stateFor(session, false)?.attachments.get(id)
|
|
357
356
|
},
|
|
358
357
|
|
|
359
|
-
getScannedEventSeq(session) {
|
|
360
|
-
return stateFor(session, false)?.scannedEventSeq ?? 0
|
|
361
|
-
},
|
|
362
|
-
|
|
363
|
-
setScannedEventSeq(session, seq) {
|
|
364
|
-
const state = stateFor(session, true)
|
|
365
|
-
if (state === undefined) return
|
|
366
|
-
const next = Number.isFinite(Number(seq)) && Number(seq) >= 0 ? Math.floor(Number(seq)) : 0
|
|
367
|
-
state.scannedEventSeq = next
|
|
368
|
-
touchState(state)
|
|
369
|
-
},
|
|
370
358
|
|
|
371
359
|
forgetSession(sessionOrId) {
|
|
372
360
|
const id =
|
|
@@ -389,7 +377,6 @@ export function createSessionVisionStateStore(config = {}) {
|
|
|
389
377
|
descriptions: state.descriptions.size,
|
|
390
378
|
descriptionChars: state.descriptions.weight,
|
|
391
379
|
attachments: state.attachments.size,
|
|
392
|
-
scannedEventSeq: state.scannedEventSeq,
|
|
393
380
|
}
|
|
394
381
|
},
|
|
395
382
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
1
|
+
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { createRequire, syncBuiltinESMExports } from 'node:module'
|
|
@@ -80,9 +80,23 @@ export function createTesseractPromisifyCompat(execFileImpl, originalCustom, opt
|
|
|
80
80
|
const makeTempDir = options.mkdtemp ?? mkdtemp
|
|
81
81
|
const writeTempFile = options.writeFile ?? writeFile
|
|
82
82
|
const removeTempDir = options.rm ?? rm
|
|
83
|
+
const resolveRealPath = options.realpath ?? realpath
|
|
83
84
|
const tempDir = options.tempDir ?? tmpdir()
|
|
85
|
+
let resolvedTempDir
|
|
84
86
|
let active = true
|
|
85
87
|
|
|
88
|
+
const stagingRoot = async () => {
|
|
89
|
+
if (resolvedTempDir !== undefined) return resolvedTempDir
|
|
90
|
+
try {
|
|
91
|
+
resolvedTempDir = await resolveRealPath(tempDir)
|
|
92
|
+
} catch {
|
|
93
|
+
// Some tests/embedders provide a virtual temp root. Preserve the old
|
|
94
|
+
// path when canonicalization is unavailable instead of disabling OCR.
|
|
95
|
+
resolvedTempDir = tempDir
|
|
96
|
+
}
|
|
97
|
+
return resolvedTempDir
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
async function tesseractPromisifyCompat(file, args, execOptions) {
|
|
87
101
|
if (!active || !isTesseractCompatCall(file, args, execOptions)) {
|
|
88
102
|
return delegate(file, args, execOptions)
|
|
@@ -91,7 +105,7 @@ export function createTesseractPromisifyCompat(execFileImpl, originalCustom, opt
|
|
|
91
105
|
const bytes = bytesView(execOptions.input)
|
|
92
106
|
let dir
|
|
93
107
|
try {
|
|
94
|
-
dir = await makeTempDir(path.join(
|
|
108
|
+
dir = await makeTempDir(path.join(await stagingRoot(), 'dsh-vision-router-ocr-'))
|
|
95
109
|
const inputPath = path.join(dir, `input${extensionForBytes(bytes)}`)
|
|
96
110
|
await writeTempFile(inputPath, bytes)
|
|
97
111
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { blocksHaveRetainedImage } from './image-offload-compat.js'
|
|
2
3
|
|
|
3
4
|
const TWIN_SUFFIX = '-vision'
|
|
4
5
|
const DEFAULT_MAIN_WRAPPER_ROUTE = 'deepseek-vision'
|
|
@@ -16,19 +17,9 @@ function nonEmptyString(value) {
|
|
|
16
17
|
return typeof value === 'string' && value.length > 0
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
function blocksHaveImage(content) {
|
|
20
|
-
if (!Array.isArray(content)) return false
|
|
21
|
-
for (const block of content) {
|
|
22
|
-
if (!block) continue
|
|
23
|
-
if (block.type === 'image') return true
|
|
24
|
-
if (Array.isArray(block.content) && blocksHaveImage(block.content)) return true
|
|
25
|
-
}
|
|
26
|
-
return false
|
|
27
|
-
}
|
|
28
|
-
|
|
29
20
|
export function twinRequestHasImage(messages) {
|
|
30
21
|
return (messages ?? []).some(
|
|
31
|
-
(message) => message && Array.isArray(message.content) &&
|
|
22
|
+
(message) => message && Array.isArray(message.content) && blocksHaveRetainedImage(message.content),
|
|
32
23
|
)
|
|
33
24
|
}
|
|
34
25
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
resolveArtifactTarget,
|
|
3
3
|
writeArtifactFile,
|
|
4
|
+
writePersistentArtifactFile,
|
|
4
5
|
} from './artifact-io.js'
|
|
5
6
|
import {
|
|
6
7
|
retainArtifactRun,
|
|
@@ -46,6 +47,17 @@ export function createVisionArtifactStore({ workspace, artifactsDir, deps = {} }
|
|
|
46
47
|
)
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const publishPersistent = (relativePath, data, options = {}) => {
|
|
51
|
+
const current = location()
|
|
52
|
+
return writePersistentArtifactFile(
|
|
53
|
+
current.workspace,
|
|
54
|
+
current.artifactsDir,
|
|
55
|
+
relativePath,
|
|
56
|
+
data,
|
|
57
|
+
callDeps(deps, options),
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
return Object.freeze({
|
|
50
62
|
resolve(relativePath, options = {}) {
|
|
51
63
|
const current = location()
|
|
@@ -58,10 +70,11 @@ export function createVisionArtifactStore({ workspace, artifactsDir, deps = {} }
|
|
|
58
70
|
},
|
|
59
71
|
|
|
60
72
|
publish,
|
|
73
|
+
publishPersistent,
|
|
61
74
|
|
|
62
|
-
// Temporary
|
|
63
|
-
//
|
|
64
|
-
//
|
|
75
|
+
// Temporary placement remains authority/lifetime driven by the ambient
|
|
76
|
+
// random managed artifactRunId. publishPersistent is the narrow handoff
|
|
77
|
+
// exception and uses one stable reserved run that retention still owns.
|
|
65
78
|
publishTemporary(relativePath, data, options = {}) {
|
|
66
79
|
return publish(relativePath, data, options)
|
|
67
80
|
},
|
|
@@ -29,13 +29,11 @@ function attachmentIdOf(ref) {
|
|
|
29
29
|
return id === '' ? undefined : id
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
function
|
|
32
|
+
function sessionMessages(session) {
|
|
33
33
|
try {
|
|
34
|
-
if (session
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
return Array.isArray(session?.events) ? session.events : []
|
|
34
|
+
if (!session || typeof session.deriveMessages !== 'function') return []
|
|
35
|
+
const messages = session.deriveMessages()
|
|
36
|
+
return Array.isArray(messages) ? messages : []
|
|
39
37
|
} catch {
|
|
40
38
|
return []
|
|
41
39
|
}
|
|
@@ -55,18 +53,9 @@ function collectImageRefsFromMessages(messages, out) {
|
|
|
55
53
|
for (const message of messages) collectImageRefsFromBlocks(message?.content, out)
|
|
56
54
|
}
|
|
57
55
|
|
|
58
|
-
function authorizedRefs(
|
|
56
|
+
function authorizedRefs(agent) {
|
|
59
57
|
const refs = []
|
|
60
|
-
|
|
61
|
-
const events = sessionEvents(session)
|
|
62
|
-
if (typeof core?.collectEventAttachmentRefs === 'function') {
|
|
63
|
-
try {
|
|
64
|
-
const durable = core.collectEventAttachmentRefs(events)
|
|
65
|
-
if (Array.isArray(durable)) refs.push(...durable)
|
|
66
|
-
} catch {
|
|
67
|
-
// The resolver remains fail-closed; pending refs below may still suffice.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
58
|
+
collectImageRefsFromMessages(sessionMessages(agent?.session), refs)
|
|
70
59
|
collectImageRefsFromMessages(agent?.inbox?.nextTurn, refs)
|
|
71
60
|
collectImageRefsFromMessages(agent?.inbox?.nextStep, refs)
|
|
72
61
|
|
|
@@ -99,12 +88,12 @@ function isCanonicalSha256Id(value) {
|
|
|
99
88
|
* only when exactly one image authorized by the current Session has that
|
|
100
89
|
* prefix. Zero or multiple matches fail closed.
|
|
101
90
|
*/
|
|
102
|
-
export function resolveProjectedAttachmentHandle(handle, {
|
|
91
|
+
export function resolveProjectedAttachmentHandle(handle, { sessionVisionIndex, agent } = {}) {
|
|
103
92
|
const token = typeof handle === 'string' ? handle.trim() : ''
|
|
104
93
|
if (!isProjectedAttachmentHandle(token)) return { kind: 'not-projected', value: handle }
|
|
105
94
|
|
|
106
95
|
const session = agent?.session
|
|
107
|
-
const refs = authorizedRefs(
|
|
96
|
+
const refs = authorizedRefs(agent)
|
|
108
97
|
const wanted = token.toLowerCase()
|
|
109
98
|
const matches = refs.filter((ref) => {
|
|
110
99
|
const id = attachmentIdOf(ref)
|
|
@@ -124,7 +113,7 @@ export function resolveProjectedAttachmentHandle(handle, { core, sessionVisionIn
|
|
|
124
113
|
try {
|
|
125
114
|
sessionVisionIndex?.recordAttachments?.(session, [candidate])
|
|
126
115
|
} catch {
|
|
127
|
-
//
|
|
116
|
+
// Authorization is still fail-closed if the bounded cache cannot be warmed.
|
|
128
117
|
}
|
|
129
118
|
if (!sessionVisionIndex || typeof sessionVisionIndex.lookupAttachment !== 'function') {
|
|
130
119
|
return { kind: 'unknown', handle: token }
|
|
@@ -201,7 +190,6 @@ export function wrapVisionAttachmentHandleDefinition(def, options = {}) {
|
|
|
201
190
|
...def,
|
|
202
191
|
execute(args, exec) {
|
|
203
192
|
const nextArgs = canonicalizeArgs(args, {
|
|
204
|
-
core: options.core,
|
|
205
193
|
sessionVisionIndex: options.sessionVisionIndex,
|
|
206
194
|
agent: exec?.agent,
|
|
207
195
|
toolName: def.name,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { blocksHaveRetainedImage, isOffloadedImageBlock, offloadedImagePlaceholder } from './image-offload-compat.js'
|
|
2
3
|
|
|
3
4
|
const visionBackendScope = new AsyncLocalStorage()
|
|
4
5
|
const wrappedContexts = new WeakMap()
|
|
@@ -127,12 +128,8 @@ async function resolveCredential(ctx, plan) {
|
|
|
127
128
|
return undefined
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
function contentHasImage(
|
|
131
|
-
return (messages ?? []).some((message) =>
|
|
132
|
-
if (!Array.isArray(message?.content)) return false
|
|
133
|
-
if (typeof core?.blocksHaveImage === 'function') return core.blocksHaveImage(message.content)
|
|
134
|
-
return message.content.some((block) => block?.type === 'image')
|
|
135
|
-
})
|
|
131
|
+
function contentHasImage(_core, messages) {
|
|
132
|
+
return (messages ?? []).some((message) => blocksHaveRetainedImage(message?.content))
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
async function toOpenAIMessages(ctx, messages) {
|
|
@@ -151,6 +148,10 @@ async function toOpenAIMessages(ctx, messages) {
|
|
|
151
148
|
continue
|
|
152
149
|
}
|
|
153
150
|
if (block?.type === 'image' && block.attachment) {
|
|
151
|
+
if (isOffloadedImageBlock(block)) {
|
|
152
|
+
converted.push({ type: 'text', text: offloadedImagePlaceholder(block) })
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
154
155
|
const stored = await attachments.readImage(block.attachment)
|
|
155
156
|
const mediaType = block.attachment.mediaType || stored?.mediaType || 'image/png'
|
|
156
157
|
const data = Buffer.from(stored.data).toString('base64')
|
|
@@ -50,6 +50,25 @@ function recordKey(value) {
|
|
|
50
50
|
return `${value.fingerprint}\u0000${value.axis}`
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function selectRetainedStops(values, at, maxEntries = MAX_ENTRIES) {
|
|
54
|
+
const seen = new Set()
|
|
55
|
+
return [...values]
|
|
56
|
+
.map(cleanStop)
|
|
57
|
+
.filter((item) => item && item.expiresAt > at)
|
|
58
|
+
.sort((a, b) => b.recordedAt - a.recordedAt || recordKey(a).localeCompare(recordKey(b)))
|
|
59
|
+
.filter((item) => {
|
|
60
|
+
const key = recordKey(item)
|
|
61
|
+
if (seen.has(key)) return false
|
|
62
|
+
seen.add(key)
|
|
63
|
+
return true
|
|
64
|
+
})
|
|
65
|
+
.slice(0, maxEntries)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function retainedStopMap(values, at, maxEntries = MAX_ENTRIES) {
|
|
69
|
+
return new Map(selectRetainedStops(values, at, maxEntries).map((item) => [recordKey(item), item]))
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
export function backgroundBenchmarkStopCachePath(dshHome = resolveDshHome()) {
|
|
54
73
|
return path.join(dshHome, 'cache', 'vision-router', 'background-benchmark-stops.json')
|
|
55
74
|
}
|
|
@@ -59,10 +78,7 @@ async function load(file, fsOps, at) {
|
|
|
59
78
|
const raw = await fsOps.readFile(file, 'utf8')
|
|
60
79
|
const body = JSON.parse(raw)
|
|
61
80
|
if (!body || !Array.isArray(body.stops) || ![2, CACHE_VERSION].includes(body.version)) return new Map()
|
|
62
|
-
const records =
|
|
63
|
-
.map(cleanStop)
|
|
64
|
-
.filter((item) => item && item.expiresAt > at)
|
|
65
|
-
.map((item) => [recordKey(item), item]))
|
|
81
|
+
const records = retainedStopMap(body.stops, at)
|
|
66
82
|
if (body.version === 2) await save(file, records, fsOps, at)
|
|
67
83
|
return records
|
|
68
84
|
} catch (error) {
|
|
@@ -73,11 +89,7 @@ async function load(file, fsOps, at) {
|
|
|
73
89
|
|
|
74
90
|
async function save(file, records, fsOps, at) {
|
|
75
91
|
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
76
|
-
const stops =
|
|
77
|
-
.map(cleanStop)
|
|
78
|
-
.filter((item) => item && item.expiresAt > at)
|
|
79
|
-
.sort((a, b) => b.recordedAt - a.recordedAt)
|
|
80
|
-
.slice(0, MAX_ENTRIES)
|
|
92
|
+
const stops = selectRetainedStops(records.values(), at)
|
|
81
93
|
await fsOps.mkdir(path.dirname(file), { recursive: true })
|
|
82
94
|
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, stops }), { encoding: 'utf8', mode: 0o600 })
|
|
83
95
|
await fsOps.rename(temporary, file)
|
|
@@ -105,22 +117,19 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
|
|
|
105
117
|
return saveTail
|
|
106
118
|
}
|
|
107
119
|
|
|
108
|
-
const
|
|
120
|
+
const reconcile = () => {
|
|
109
121
|
const at = Number(now())
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
removed = true
|
|
115
|
-
}
|
|
116
|
-
return removed
|
|
122
|
+
const retained = retainedStopMap(records.values(), at)
|
|
123
|
+
const changed = retained.size !== records.size || [...retained.keys()].some((key) => !records.has(key))
|
|
124
|
+
records = retained
|
|
125
|
+
return changed
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
return {
|
|
120
129
|
file,
|
|
121
130
|
async list() {
|
|
122
131
|
await ready
|
|
123
|
-
if (
|
|
132
|
+
if (reconcile()) await persist()
|
|
124
133
|
return [...records.values()].sort((a, b) => b.recordedAt - a.recordedAt)
|
|
125
134
|
},
|
|
126
135
|
async mark({
|
|
@@ -135,7 +144,7 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
|
|
|
135
144
|
expiresAt,
|
|
136
145
|
} = {}) {
|
|
137
146
|
await ready
|
|
138
|
-
|
|
147
|
+
reconcile()
|
|
139
148
|
const clean = cleanStop({
|
|
140
149
|
fingerprint,
|
|
141
150
|
key,
|
|
@@ -150,8 +159,9 @@ export function createBackgroundBenchmarkStopStore(options = {}) {
|
|
|
150
159
|
})
|
|
151
160
|
if (!clean) return undefined
|
|
152
161
|
records.set(recordKey(clean), clean)
|
|
162
|
+
reconcile()
|
|
153
163
|
await persist()
|
|
154
|
-
return clean
|
|
164
|
+
return records.get(recordKey(clean))
|
|
155
165
|
},
|
|
156
166
|
async clearStop(fingerprint, axis) {
|
|
157
167
|
await ready
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
+
import { createSessionTurnResolver } from './session-turn-resolver.js'
|
|
2
3
|
|
|
3
|
-
function
|
|
4
|
+
function legacyTurnNumberOf(session) {
|
|
4
5
|
try {
|
|
5
6
|
const events = session?.events
|
|
6
7
|
if (!Array.isArray(events)) return 0
|
|
@@ -19,8 +20,15 @@ function sessionIdOf(session) {
|
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export function visionBreakerScopeOf(session) {
|
|
23
|
-
|
|
23
|
+
export function visionBreakerScopeOf(session, turnOf) {
|
|
24
|
+
let turn
|
|
25
|
+
try {
|
|
26
|
+
if (typeof turnOf === 'function') turn = turnOf(session)
|
|
27
|
+
} catch {
|
|
28
|
+
turn = undefined
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(turn) || turn < 0) turn = legacyTurnNumberOf(session)
|
|
31
|
+
return `${sessionIdOf(session)}:${turn}`
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
function credentialFingerprintOf(value) {
|
|
@@ -76,7 +84,9 @@ function healthFromGate(gate) {
|
|
|
76
84
|
* giving shadow code mutation authority. `capture()` is called only while
|
|
77
85
|
* core.apply() constructs the breaker; `healthForCandidate()` uses peek().
|
|
78
86
|
*/
|
|
79
|
-
export function createVisionBreakerShadowHealth(ctx) {
|
|
87
|
+
export function createVisionBreakerShadowHealth(ctx, options = {}) {
|
|
88
|
+
const sessionTurnResolver = options.sessionTurnResolver ?? createSessionTurnResolver(ctx)
|
|
89
|
+
const turnOf = sessionTurnResolver.turnOf
|
|
80
90
|
let breaker
|
|
81
91
|
return {
|
|
82
92
|
capture(value) {
|
|
@@ -88,7 +98,7 @@ export function createVisionBreakerShadowHealth(ctx) {
|
|
|
88
98
|
const key = typeof candidate?.key === 'string' ? candidate.key : undefined
|
|
89
99
|
if (!key) return undefined
|
|
90
100
|
const fingerprint = await visionBreakerFingerprintForCandidate(ctx, candidate)
|
|
91
|
-
const scope = visionBreakerScopeOf(context.session)
|
|
101
|
+
const scope = visionBreakerScopeOf(context.session, turnOf)
|
|
92
102
|
return healthFromGate(breaker.peek(key, fingerprint, scope))
|
|
93
103
|
},
|
|
94
104
|
|
|
@@ -338,21 +338,34 @@ export async function runExactCapabilityBenchmark({
|
|
|
338
338
|
return { record, results }
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
function selectRetainedCapabilityProfiles(values, maxEntries) {
|
|
342
|
+
const seen = new Set()
|
|
343
|
+
return [...values]
|
|
344
|
+
.map(sanitizeCapabilityProfileRecord)
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
|
|
347
|
+
.filter((record) => {
|
|
348
|
+
if (seen.has(record.fingerprint)) return false
|
|
349
|
+
seen.add(record.fingerprint)
|
|
350
|
+
return true
|
|
351
|
+
})
|
|
352
|
+
.slice(0, maxEntries)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function retainedCapabilityProfileMap(values, maxEntries) {
|
|
356
|
+
return new Map(selectRetainedCapabilityProfiles(values, maxEntries).map((record) => [record.fingerprint, record]))
|
|
357
|
+
}
|
|
358
|
+
|
|
341
359
|
function cacheEnvelope(records) {
|
|
342
360
|
return { version: CAPABILITY_PROFILE_CACHE_VERSION, profiles: records }
|
|
343
361
|
}
|
|
344
362
|
|
|
345
|
-
async function loadCache(file, fsOps) {
|
|
363
|
+
async function loadCache(file, fsOps, maxEntries) {
|
|
346
364
|
try {
|
|
347
365
|
const raw = await fsOps.readFile(file, 'utf8')
|
|
348
366
|
const body = JSON.parse(raw)
|
|
349
367
|
if (!body || body.version !== CAPABILITY_PROFILE_CACHE_VERSION || !Array.isArray(body.profiles)) return new Map()
|
|
350
|
-
return
|
|
351
|
-
body.profiles
|
|
352
|
-
.map(sanitizeCapabilityProfileRecord)
|
|
353
|
-
.filter(Boolean)
|
|
354
|
-
.map((record) => [record.fingerprint, record]),
|
|
355
|
-
)
|
|
368
|
+
return retainedCapabilityProfileMap(body.profiles, maxEntries)
|
|
356
369
|
} catch (error) {
|
|
357
370
|
if (error?.code === 'ENOENT') return new Map()
|
|
358
371
|
return new Map()
|
|
@@ -362,11 +375,7 @@ async function loadCache(file, fsOps) {
|
|
|
362
375
|
async function saveCache(file, records, fsOps, maxEntries) {
|
|
363
376
|
const directory = path.dirname(file)
|
|
364
377
|
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
365
|
-
const profiles =
|
|
366
|
-
.map(sanitizeCapabilityProfileRecord)
|
|
367
|
-
.filter(Boolean)
|
|
368
|
-
.sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
|
|
369
|
-
.slice(0, maxEntries)
|
|
378
|
+
const profiles = selectRetainedCapabilityProfiles(records.values(), maxEntries)
|
|
370
379
|
await fsOps.mkdir(directory, { recursive: true })
|
|
371
380
|
await fsOps.writeFile(temporary, JSON.stringify(cacheEnvelope(profiles)), { encoding: 'utf8', mode: 0o600 })
|
|
372
381
|
await fsOps.rename(temporary, file)
|
|
@@ -384,7 +393,7 @@ export function createCapabilityProfileStore(options = {}) {
|
|
|
384
393
|
const logger = options.logger
|
|
385
394
|
let records = new Map()
|
|
386
395
|
let saveTail = Promise.resolve()
|
|
387
|
-
const ready = loadCache(file, fsOps).then((loaded) => { records = loaded })
|
|
396
|
+
const ready = loadCache(file, fsOps, maxEntries).then((loaded) => { records = loaded })
|
|
388
397
|
|
|
389
398
|
const persist = () => {
|
|
390
399
|
saveTail = saveTail
|
|
@@ -412,8 +421,9 @@ export function createCapabilityProfileStore(options = {}) {
|
|
|
412
421
|
if (merged === undefined) throw new TypeError('invalid merged capability profile record')
|
|
413
422
|
if (existing && JSON.stringify(existing) === JSON.stringify(merged)) return existing
|
|
414
423
|
records.set(clean.fingerprint, merged)
|
|
424
|
+
records = retainedCapabilityProfileMap(records.values(), maxEntries)
|
|
415
425
|
await persist()
|
|
416
|
-
return
|
|
426
|
+
return records.get(clean.fingerprint)
|
|
417
427
|
},
|
|
418
428
|
async remove(fingerprint) {
|
|
419
429
|
await ready
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const VISION_SUCCESS_GUIDANCE =
|
|
2
|
+
'Vision Router trusted execution metadata: the preceding vision_describe call SUCCEEDED. ' +
|
|
3
|
+
'This metadata is not image content. If that successful evidence resolves the visual fact needed for the current task, stop making additional vision calls for that fact and continue the remaining user task from this evidence. ' +
|
|
4
|
+
'If no non-visual work remains, answer now in exactly the format the user requested; when the user asks for only one value or short phrase, do not add Markdown, explanation, or a restatement. ' +
|
|
5
|
+
'Call another vision tool only for a specific unresolved visual fact. For verbatim or exact-text tasks such as identifiers, amounts, times, version strings, or code, preserve every visible symbol, prefix, suffix, and punctuation from the most faithful evidence already collected; a later semantic summary must not overwrite a more exact transcription. Focused OCR or localization is still appropriate when character identity remains uncertain.'
|
|
6
|
+
|
|
7
|
+
function isVisionFailureEnvelope(value) {
|
|
8
|
+
const text = value.trim()
|
|
9
|
+
if (!text.startsWith('{')) return false
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(text)
|
|
12
|
+
return (
|
|
13
|
+
parsed !== null &&
|
|
14
|
+
typeof parsed === 'object' &&
|
|
15
|
+
parsed.ok === false &&
|
|
16
|
+
typeof parsed.code === 'string' &&
|
|
17
|
+
/^VISION_[A-Z0-9_]+$/u.test(parsed.code)
|
|
18
|
+
)
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function visionDescribeSuccessContext(exec, result) {
|
|
25
|
+
if (
|
|
26
|
+
exec?.name !== 'vision_describe' ||
|
|
27
|
+
!exec?.agent ||
|
|
28
|
+
result?.isError !== false ||
|
|
29
|
+
typeof result.value !== 'string' ||
|
|
30
|
+
result.value.trim().length === 0 ||
|
|
31
|
+
isVisionFailureEnvelope(result.value)
|
|
32
|
+
) return undefined
|
|
33
|
+
return {
|
|
34
|
+
role: 'user',
|
|
35
|
+
id: `vision-router-evidence-success-${String(exec.callId)}`,
|
|
36
|
+
content: [{ type: 'text', text: VISION_SUCCESS_GUIDANCE }],
|
|
37
|
+
source: { kind: 'plugin', plugin: 'dsh-vision-router' },
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -38,12 +38,30 @@ export function imageInputVerdictCachePath(dshHome = resolveDshHome()) {
|
|
|
38
38
|
return path.join(dshHome, 'cache', 'vision-router', 'image-input-verdicts.json')
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function selectRetainedVerdicts(values, maxEntries = MAX_ENTRIES) {
|
|
42
|
+
const seen = new Set()
|
|
43
|
+
return [...values]
|
|
44
|
+
.map(cleanVerdict)
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.sort((a, b) => b.measuredAt - a.measuredAt || a.fingerprint.localeCompare(b.fingerprint))
|
|
47
|
+
.filter((item) => {
|
|
48
|
+
if (seen.has(item.fingerprint)) return false
|
|
49
|
+
seen.add(item.fingerprint)
|
|
50
|
+
return true
|
|
51
|
+
})
|
|
52
|
+
.slice(0, maxEntries)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function retainedVerdictMap(values, maxEntries = MAX_ENTRIES) {
|
|
56
|
+
return new Map(selectRetainedVerdicts(values, maxEntries).map((item) => [item.fingerprint, item]))
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
async function load(file, fsOps) {
|
|
42
60
|
try {
|
|
43
61
|
const raw = await fsOps.readFile(file, 'utf8')
|
|
44
62
|
const body = JSON.parse(raw)
|
|
45
63
|
if (!body || body.version !== CACHE_VERSION || !Array.isArray(body.verdicts)) return new Map()
|
|
46
|
-
return
|
|
64
|
+
return retainedVerdictMap(body.verdicts)
|
|
47
65
|
} catch (error) {
|
|
48
66
|
if (error?.code === 'ENOENT') return new Map()
|
|
49
67
|
return new Map()
|
|
@@ -52,11 +70,7 @@ async function load(file, fsOps) {
|
|
|
52
70
|
|
|
53
71
|
async function save(file, records, fsOps) {
|
|
54
72
|
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
55
|
-
const verdicts =
|
|
56
|
-
.map(cleanVerdict)
|
|
57
|
-
.filter(Boolean)
|
|
58
|
-
.sort((a, b) => b.measuredAt - a.measuredAt)
|
|
59
|
-
.slice(0, MAX_ENTRIES)
|
|
73
|
+
const verdicts = selectRetainedVerdicts(records.values())
|
|
60
74
|
await fsOps.mkdir(path.dirname(file), { recursive: true })
|
|
61
75
|
await fsOps.writeFile(temporary, JSON.stringify({ version: CACHE_VERSION, verdicts }), { encoding: 'utf8', mode: 0o600 })
|
|
62
76
|
await fsOps.rename(temporary, file)
|
|
@@ -101,8 +115,9 @@ export function createImageInputVerdictStore(options = {}) {
|
|
|
101
115
|
})
|
|
102
116
|
if (!clean) return undefined
|
|
103
117
|
records.set(clean.fingerprint, clean)
|
|
118
|
+
records = retainedVerdictMap(records.values())
|
|
104
119
|
await persist()
|
|
105
|
-
return clean
|
|
120
|
+
return records.get(clean.fingerprint)
|
|
106
121
|
},
|
|
107
122
|
async clear(fingerprint) {
|
|
108
123
|
await ready
|
package/lib/vision-resilience.js
CHANGED
|
@@ -376,12 +376,14 @@ export function createVisionTurnMemory({
|
|
|
376
376
|
maxScopes = 64,
|
|
377
377
|
maxSessions = maxScopes,
|
|
378
378
|
maxAttemptsPerScope = 64,
|
|
379
|
+
maxEvidencePerScope = 32,
|
|
379
380
|
} = {}) {
|
|
380
381
|
const scopes = new Map() // scope -> { failedKinds:Set, attempted: [], allFailed:false }
|
|
381
382
|
const lastScopeBySession = new Map() // bounded LRU sessionId -> scope
|
|
382
383
|
const scopeLimit = Math.max(1, Math.floor(Number(maxScopes) || 64))
|
|
383
384
|
const sessionLimit = Math.max(1, Math.floor(Number(maxSessions) || scopeLimit))
|
|
384
385
|
const attemptLimit = Math.max(1, Math.floor(Number(maxAttemptsPerScope) || 64))
|
|
386
|
+
const evidenceLimit = Math.max(1, Math.floor(Number(maxEvidencePerScope) || 32))
|
|
385
387
|
|
|
386
388
|
const dropScope = (scope) => {
|
|
387
389
|
scopes.delete(scope)
|
|
@@ -410,7 +412,14 @@ export function createVisionTurnMemory({
|
|
|
410
412
|
const entry = (scope) => {
|
|
411
413
|
let hit = scopes.get(scope)
|
|
412
414
|
if (hit === undefined) {
|
|
413
|
-
hit = {
|
|
415
|
+
hit = {
|
|
416
|
+
failedKinds: new Set(),
|
|
417
|
+
attempted: [],
|
|
418
|
+
allFailed: false,
|
|
419
|
+
localOcr: new Map(),
|
|
420
|
+
derivedArtifacts: new Map(),
|
|
421
|
+
degradedRefinements: new Map(),
|
|
422
|
+
}
|
|
414
423
|
scopes.set(scope, hit)
|
|
415
424
|
pruneScopes()
|
|
416
425
|
} else {
|
|
@@ -453,6 +462,69 @@ export function createVisionTurnMemory({
|
|
|
453
462
|
return hit === undefined ? [] : [...hit.attempted]
|
|
454
463
|
},
|
|
455
464
|
|
|
465
|
+
resolveSource(scope, sourceKey) {
|
|
466
|
+
const key = typeof sourceKey === 'string' ? sourceKey : ''
|
|
467
|
+
const hit = scopes.get(scope)
|
|
468
|
+
if (hit === undefined || key === '') return key
|
|
469
|
+
for (const [artifactToken, rootSource] of hit.derivedArtifacts) {
|
|
470
|
+
if (key === artifactToken || key.includes(artifactToken)) return rootSource
|
|
471
|
+
}
|
|
472
|
+
return key
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
recordLocalOcr(scope, sourceKey, evidence = {}) {
|
|
476
|
+
const hit = entry(scope)
|
|
477
|
+
const key = this.resolveSource(scope, sourceKey)
|
|
478
|
+
if (key === '') return
|
|
479
|
+
hit.localOcr.delete(key)
|
|
480
|
+
hit.localOcr.set(key, Object.freeze({ uncertain: evidence.uncertain === true }))
|
|
481
|
+
while (hit.localOcr.size > evidenceLimit) hit.localOcr.delete(hit.localOcr.keys().next().value)
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
hasLocalOcr(scope, sourceKey) {
|
|
485
|
+
const hit = scopes.get(scope)
|
|
486
|
+
if (hit === undefined) return false
|
|
487
|
+
return hit.localOcr.has(this.resolveSource(scope, sourceKey))
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
degradedEvidenceTokens(scope) {
|
|
491
|
+
const hit = scopes.get(scope)
|
|
492
|
+
if (hit === undefined || !hit.allFailed || hit.localOcr.size === 0) return []
|
|
493
|
+
const tokens = [...hit.localOcr.keys()]
|
|
494
|
+
for (const [artifactToken, rootSource] of hit.derivedArtifacts) {
|
|
495
|
+
if (hit.localOcr.has(rootSource)) tokens.push(artifactToken)
|
|
496
|
+
}
|
|
497
|
+
return tokens
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
recordDerivedArtifact(scope, sourceKey, artifactToken) {
|
|
501
|
+
const token = typeof artifactToken === 'string' ? artifactToken : ''
|
|
502
|
+
if (token === '') return
|
|
503
|
+
const hit = entry(scope)
|
|
504
|
+
const rootSource = this.resolveSource(scope, sourceKey)
|
|
505
|
+
if (rootSource === '') return
|
|
506
|
+
hit.derivedArtifacts.delete(token)
|
|
507
|
+
hit.derivedArtifacts.set(token, rootSource)
|
|
508
|
+
while (hit.derivedArtifacts.size > evidenceLimit) hit.derivedArtifacts.delete(hit.derivedArtifacts.keys().next().value)
|
|
509
|
+
},
|
|
510
|
+
|
|
511
|
+
degradedRefinementCount(scope, sourceKey) {
|
|
512
|
+
const hit = scopes.get(scope)
|
|
513
|
+
if (hit === undefined) return 0
|
|
514
|
+
return hit.degradedRefinements.get(this.resolveSource(scope, sourceKey)) ?? 0
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
recordDegradedRefinement(scope, sourceKey) {
|
|
518
|
+
const hit = entry(scope)
|
|
519
|
+
const key = this.resolveSource(scope, sourceKey)
|
|
520
|
+
if (key === '') return 0
|
|
521
|
+
const count = (hit.degradedRefinements.get(key) ?? 0) + 1
|
|
522
|
+
hit.degradedRefinements.delete(key)
|
|
523
|
+
hit.degradedRefinements.set(key, count)
|
|
524
|
+
while (hit.degradedRefinements.size > evidenceLimit) hit.degradedRefinements.delete(hit.degradedRefinements.keys().next().value)
|
|
525
|
+
return count
|
|
526
|
+
},
|
|
527
|
+
|
|
456
528
|
stats() {
|
|
457
529
|
return { scopes: scopes.size, sessions: lastScopeBySession.size }
|
|
458
530
|
},
|