dsh-vision-router 2.1.7 → 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/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.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +355 -64
- 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 +22 -17
- package/lib/core-primitives.js +115 -5
- 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/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 +19 -1
- package/lib/replay-delegation.js +41 -21
- package/lib/runtime-composition.js +15 -2
- 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-breaker-shadow-health.js +15 -5
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-resilience.js +73 -1
- package/package.json +11 -6
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { currentSessionSurfacePolicy } from './session-surface-policy.js'
|
|
2
|
+
import { sessionSurfaceReplacementIntent } from './session-surface-compat.js'
|
|
2
3
|
import { createSessionVisionStateStore } from './session-vision-state.js'
|
|
3
4
|
|
|
4
5
|
function isObject(value) {
|
|
5
6
|
return value !== null && typeof value === 'object'
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
function
|
|
9
|
+
function legacySessionEvents(session) {
|
|
9
10
|
try {
|
|
10
11
|
// dsh 0.1.2-alpha.4 removed the bare `session.events` array in favor of
|
|
11
12
|
// `session.snapshotEvents()`; both are handled so one build runs on both
|
|
@@ -42,19 +43,21 @@ function boundedMessage(error) {
|
|
|
42
43
|
* Session visual data-plane index.
|
|
43
44
|
*
|
|
44
45
|
* SessionVisionStateStore remains the bounded storage owner. This object owns
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* surface repair.
|
|
46
|
+
* target-only durable attachment recovery after bounded-cache eviction,
|
|
47
|
+
* bounded Host-backed tool-result image surface repair and expired structured
|
|
48
|
+
* guard-stop surface repair.
|
|
48
49
|
*
|
|
49
|
-
* The index never replaces stateStore.lookupAttachment().
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* The index never replaces stateStore.lookupAttachment(). Cache lookup stays
|
|
51
|
+
* synchronous through lookupAttachment(); cold durable recovery is requested
|
|
52
|
+
* explicitly through async resolveAttachment(), so I/O ownership remains visible.
|
|
52
53
|
*/
|
|
53
54
|
export function createSessionVisionIndex({
|
|
54
55
|
stateStore,
|
|
55
56
|
core,
|
|
56
57
|
config = {},
|
|
57
58
|
logger,
|
|
59
|
+
readSessionEvent,
|
|
60
|
+
readSessionLog,
|
|
58
61
|
} = {}) {
|
|
59
62
|
if (!core || typeof core !== 'object') throw new TypeError('session vision index requires core helpers')
|
|
60
63
|
|
|
@@ -70,59 +73,99 @@ export function createSessionVisionIndex({
|
|
|
70
73
|
: undefined
|
|
71
74
|
const toolSurfaceScans = new WeakMap()
|
|
72
75
|
const guardSurfaceScans = new WeakMap()
|
|
76
|
+
const unsupportedSurfaceContracts = new WeakSet()
|
|
77
|
+
const repairReadWarnings = new WeakMap()
|
|
78
|
+
const attachmentRecoveryWarnings = new WeakMap()
|
|
73
79
|
|
|
74
80
|
const recordAttachments = (session, refs) => {
|
|
75
81
|
if (!session || !Array.isArray(refs) || refs.length === 0) return
|
|
76
82
|
store.recordAttachments(session, refs)
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
const scanEventLog = (session) => {
|
|
80
|
-
if (!session) return
|
|
81
|
-
const events = sessionEvents(session)
|
|
82
|
-
if (!events || events.length === 0) return
|
|
83
|
-
const last = store.getScannedEventSeq(session)
|
|
84
|
-
if (last >= events.length) return
|
|
85
|
-
const refs = typeof core.collectEventAttachmentRefs === 'function'
|
|
86
|
-
? core.collectEventAttachmentRefs(events.slice(last))
|
|
87
|
-
: []
|
|
88
|
-
// Advance even when no refs are present so irrelevant durable history is
|
|
89
|
-
// never rescanned indefinitely.
|
|
90
|
-
store.setScannedEventSeq(session, events.length)
|
|
91
|
-
if (Array.isArray(refs) && refs.length > 0) store.recordAttachments(session, refs)
|
|
92
|
-
}
|
|
93
85
|
|
|
86
|
+
// Synchronous lookup is cache-only. Projected short handles first warm this
|
|
87
|
+
// cache from current derived messages and must not trigger historical I/O.
|
|
94
88
|
const lookupAttachment = (session, id) => {
|
|
95
89
|
if (!primitiveLookup) return undefined
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (session === undefined) return undefined
|
|
99
|
-
|
|
100
|
-
scanEventLog(session)
|
|
101
|
-
hit = primitiveLookup(session, id)
|
|
102
|
-
if (hit !== undefined) return hit
|
|
90
|
+
return primitiveLookup(session, id)
|
|
91
|
+
}
|
|
103
92
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
93
|
+
const recoverAttachmentsFromEvents = (session, ids, events) => {
|
|
94
|
+
const found = new Map()
|
|
95
|
+
if (!Array.isArray(events) || events.length === 0 || typeof core.collectEventAttachmentRefs !== 'function') {
|
|
96
|
+
return found
|
|
97
|
+
}
|
|
98
|
+
const wanted = new Set(ids.map((id) => String(id)))
|
|
99
|
+
for (const ref of core.collectEventAttachmentRefs(events)) {
|
|
100
|
+
if (!ref) continue
|
|
101
|
+
const id = String(ref.attachmentId ?? ref.id)
|
|
102
|
+
if (wanted.has(id) && !found.has(id)) found.set(id, ref)
|
|
110
103
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
104
|
+
if (found.size > 0) store.recordAttachments(session, [...found.values()])
|
|
105
|
+
return found
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const warnAttachmentRecoveryFailure = (session, id, error) => {
|
|
109
|
+
const message = boundedMessage(error)
|
|
110
|
+
const key = `${String(id)}:${message}`
|
|
111
|
+
if (attachmentRecoveryWarnings.get(session) === key) return
|
|
112
|
+
attachmentRecoveryWarnings.set(session, key)
|
|
113
|
+
logger?.warn?.(
|
|
114
|
+
'vision-router: session attachment recovery failed id=%s error=%s',
|
|
115
|
+
String(id),
|
|
116
|
+
message,
|
|
114
117
|
)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resolveAttachments = async (session, ids) => {
|
|
121
|
+
const requested = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id)))]
|
|
122
|
+
const resolved = new Map()
|
|
123
|
+
const missing = []
|
|
124
|
+
for (const id of requested) {
|
|
125
|
+
const cached = lookupAttachment(session, id)
|
|
126
|
+
if (cached !== undefined) resolved.set(id, cached)
|
|
127
|
+
else missing.push(id)
|
|
118
128
|
}
|
|
119
|
-
return
|
|
129
|
+
if (missing.length === 0 || session === undefined) return resolved
|
|
130
|
+
|
|
131
|
+
let events
|
|
132
|
+
if (typeof readSessionLog === 'function') {
|
|
133
|
+
let result
|
|
134
|
+
try {
|
|
135
|
+
result = await readSessionLog(session)
|
|
136
|
+
} catch (error) {
|
|
137
|
+
warnAttachmentRecoveryFailure(session, missing[0], error)
|
|
138
|
+
return resolved
|
|
139
|
+
}
|
|
140
|
+
if (result?.supported === true) {
|
|
141
|
+
attachmentRecoveryWarnings.delete(session)
|
|
142
|
+
events = result.events
|
|
143
|
+
} else if (result?.supported !== false) {
|
|
144
|
+
warnAttachmentRecoveryFailure(
|
|
145
|
+
session,
|
|
146
|
+
missing[0],
|
|
147
|
+
new Error('Session log reader returned an invalid capability result'),
|
|
148
|
+
)
|
|
149
|
+
return resolved
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Historical/partial Hosts without SessionQuery keep the released fallback.
|
|
154
|
+
// Public rc8+ Hosts use readSessionLog above and never touch sync history.
|
|
155
|
+
if (events === undefined) events = legacySessionEvents(session)
|
|
156
|
+
const recovered = recoverAttachmentsFromEvents(session, missing, events)
|
|
157
|
+
for (const [id, ref] of recovered) resolved.set(id, ref)
|
|
158
|
+
return resolved
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resolveAttachment = async (session, id) => {
|
|
162
|
+
return (await resolveAttachments(session, [id])).get(String(id))
|
|
120
163
|
}
|
|
121
164
|
|
|
122
|
-
const
|
|
123
|
-
if (!session) return
|
|
165
|
+
const pendingSurfaceScan = (scans, session) => {
|
|
166
|
+
if (!session) return undefined
|
|
124
167
|
const nodes = surfaceNodes(session)
|
|
125
|
-
if (!nodes || nodes.length === 0) return
|
|
168
|
+
if (!nodes || nodes.length === 0) return undefined
|
|
126
169
|
let scan = scans.get(session)
|
|
127
170
|
if (!scan) {
|
|
128
171
|
scan = { count: 0 }
|
|
@@ -131,9 +174,54 @@ export function createSessionVisionIndex({
|
|
|
131
174
|
// Compaction/resume may shrink/rebuild the surface. Reset the incremental
|
|
132
175
|
// cursor rather than assuming old indices still refer to the new surface.
|
|
133
176
|
if (scan.count > nodes.length) scan.count = 0
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
return
|
|
177
|
+
const count = nodes.length
|
|
178
|
+
const seqs = nodes.slice(scan.count)
|
|
179
|
+
if (seqs.length === 0) return undefined
|
|
180
|
+
return {
|
|
181
|
+
seqs,
|
|
182
|
+
advance() {
|
|
183
|
+
if (scan.count < count) scan.count += 1
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const warnRepairReadFailure = (session, seq, error) => {
|
|
189
|
+
const message = boundedMessage(error)
|
|
190
|
+
const previous = repairReadWarnings.get(session)
|
|
191
|
+
if (previous?.seq === seq && previous.message === message) return
|
|
192
|
+
repairReadWarnings.set(session, { seq, message })
|
|
193
|
+
logger?.warn?.(
|
|
194
|
+
'vision-router: session surface event read failed seq=%s error=%s',
|
|
195
|
+
seq,
|
|
196
|
+
message,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const eventForSurfaceRepair = async (session, seq) => {
|
|
201
|
+
if (typeof readSessionEvent === 'function') {
|
|
202
|
+
let result
|
|
203
|
+
try {
|
|
204
|
+
result = await readSessionEvent(session, seq)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
warnRepairReadFailure(session, seq, error)
|
|
207
|
+
return { readable: false }
|
|
208
|
+
}
|
|
209
|
+
if (result?.supported === true) {
|
|
210
|
+
repairReadWarnings.delete(session)
|
|
211
|
+
return { readable: true, event: result.event }
|
|
212
|
+
}
|
|
213
|
+
if (result?.supported !== false) {
|
|
214
|
+
warnRepairReadFailure(session, seq, new Error('Session event reader returned an invalid capability result'))
|
|
215
|
+
return { readable: false }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Isolated/partial Hosts that do not expose SessionQuery retain the released
|
|
220
|
+
// Session-local compatibility path. Supported production Hosts use the
|
|
221
|
+
// bounded async reader above and never silently fall back after a read error.
|
|
222
|
+
const events = legacySessionEvents(session)
|
|
223
|
+
if (!events) return { readable: false }
|
|
224
|
+
return { readable: true, event: events[seq] }
|
|
137
225
|
}
|
|
138
226
|
|
|
139
227
|
const keepToolResultImages = () => {
|
|
@@ -143,60 +231,85 @@ export function createSessionVisionIndex({
|
|
|
143
231
|
return policy.ownership === 'native-image' || policy.ownership === 'vision-router-owned'
|
|
144
232
|
}
|
|
145
233
|
|
|
234
|
+
const replacementIntent = (session, seq) => {
|
|
235
|
+
const intent = sessionSurfaceReplacementIntent(session, seq)
|
|
236
|
+
if (intent !== undefined) return intent
|
|
237
|
+
if (!unsupportedSurfaceContracts.has(session)) {
|
|
238
|
+
unsupportedSurfaceContracts.add(session)
|
|
239
|
+
logger?.warn?.(
|
|
240
|
+
'vision-router: skipping durable session surface repair for unsupported Session format version=%s',
|
|
241
|
+
session?.header?.version ?? 'unknown',
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return undefined
|
|
245
|
+
}
|
|
246
|
+
|
|
146
247
|
const repairToolResultSurface = async (session) => {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (seqs.length === 0 || typeof core.planToolResultImageShadows !== 'function') return 0
|
|
248
|
+
if (typeof session?.append !== 'function') return 0
|
|
249
|
+
const scan = pendingSurfaceScan(toolSurfaceScans, session)
|
|
250
|
+
if (!scan || typeof core.planToolResultImageShadows !== 'function') return 0
|
|
151
251
|
const preserve = keepToolResultImages()
|
|
152
|
-
const plans = core.planToolResultImageShadows(events, seqs, () => !preserve)
|
|
153
252
|
let repaired = 0
|
|
154
|
-
for (const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
253
|
+
for (const seq of scan.seqs) {
|
|
254
|
+
const read = await eventForSurfaceRepair(session, seq)
|
|
255
|
+
if (!read.readable) break
|
|
256
|
+
// A readable node is settled for this repair pass even when it needs no
|
|
257
|
+
// replacement or append later fails; only an unread node remains pending.
|
|
258
|
+
scan.advance()
|
|
259
|
+
const events = []
|
|
260
|
+
events[seq] = read.event
|
|
261
|
+
const plans = core.planToolResultImageShadows(events, [seq], () => !preserve)
|
|
262
|
+
for (const plan of plans) {
|
|
263
|
+
try {
|
|
264
|
+
const intent = replacementIntent(session, plan.seq)
|
|
265
|
+
if (intent === undefined) continue
|
|
266
|
+
await session.append(
|
|
267
|
+
'tool/result',
|
|
268
|
+
{ ...plan.event.data, message: plan.message },
|
|
269
|
+
intent,
|
|
270
|
+
)
|
|
271
|
+
repaired += 1
|
|
272
|
+
} catch (error) {
|
|
273
|
+
logger?.warn?.(
|
|
274
|
+
'vision-router: session tool-result surface repair failed seq=%s error=%s',
|
|
275
|
+
plan.seq,
|
|
276
|
+
boundedMessage(error),
|
|
277
|
+
)
|
|
278
|
+
}
|
|
171
279
|
}
|
|
172
280
|
}
|
|
173
281
|
return repaired
|
|
174
282
|
}
|
|
175
283
|
|
|
176
284
|
const repairGuardStopSurface = async (session) => {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (seqs.length === 0 || typeof core.planGuardStopShadows !== 'function') return 0
|
|
181
|
-
const plans = core.planGuardStopShadows(events, seqs)
|
|
285
|
+
if (typeof session?.append !== 'function') return 0
|
|
286
|
+
const scan = pendingSurfaceScan(guardSurfaceScans, session)
|
|
287
|
+
if (!scan || typeof core.planGuardStopShadows !== 'function') return 0
|
|
182
288
|
let repaired = 0
|
|
183
|
-
for (const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
289
|
+
for (const seq of scan.seqs) {
|
|
290
|
+
const read = await eventForSurfaceRepair(session, seq)
|
|
291
|
+
if (!read.readable) break
|
|
292
|
+
scan.advance()
|
|
293
|
+
const events = []
|
|
294
|
+
events[seq] = read.event
|
|
295
|
+
const plans = core.planGuardStopShadows(events, [seq])
|
|
296
|
+
for (const plan of plans) {
|
|
297
|
+
try {
|
|
298
|
+
const intent = replacementIntent(session, plan.seq)
|
|
299
|
+
if (intent === undefined) continue
|
|
300
|
+
await session.append(
|
|
301
|
+
'user/message',
|
|
302
|
+
plan.data,
|
|
303
|
+
intent,
|
|
304
|
+
)
|
|
305
|
+
repaired += 1
|
|
306
|
+
} catch (error) {
|
|
307
|
+
logger?.warn?.(
|
|
308
|
+
'vision-router: session guard-stop surface repair failed seq=%s error=%s',
|
|
309
|
+
plan.seq,
|
|
310
|
+
boundedMessage(error),
|
|
311
|
+
)
|
|
312
|
+
}
|
|
200
313
|
}
|
|
201
314
|
}
|
|
202
315
|
return repaired
|
|
@@ -213,7 +326,6 @@ export function createSessionVisionIndex({
|
|
|
213
326
|
store.recordAttachments(session, found.attachments)
|
|
214
327
|
}
|
|
215
328
|
}
|
|
216
|
-
scanEventLog(session)
|
|
217
329
|
await repairToolResultSurface(session)
|
|
218
330
|
await repairGuardStopSurface(session)
|
|
219
331
|
return decision
|
|
@@ -221,8 +333,9 @@ export function createSessionVisionIndex({
|
|
|
221
333
|
|
|
222
334
|
return Object.freeze({
|
|
223
335
|
recordAttachments,
|
|
224
|
-
scanEventLog,
|
|
225
336
|
lookupAttachment,
|
|
337
|
+
resolveAttachment,
|
|
338
|
+
resolveAttachments,
|
|
226
339
|
repairToolResultSurface,
|
|
227
340
|
repairGuardStopSurface,
|
|
228
341
|
prepareDecision,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from './session-vision-mode-authority.js'
|
|
10
10
|
|
|
11
11
|
const MODE_SYNC_VARIABLE = 'vision_router_mode_sync'
|
|
12
|
+
const VISION_ROUTER_TOOL_OWNER = Symbol.for('dsh-vision-router.tool-owner')
|
|
12
13
|
|
|
13
14
|
function isObject(value) {
|
|
14
15
|
return value !== null && typeof value === 'object'
|
|
@@ -59,6 +60,58 @@ function ownedVisionToolNames(owned) {
|
|
|
59
60
|
return [...owned.keys()].sort()
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
function restrictableOwnedVisionToolNames(tools, owned) {
|
|
64
|
+
const names = ownedVisionToolNames(owned)
|
|
65
|
+
const get = tools?.get
|
|
66
|
+
if (typeof get !== 'function') return names
|
|
67
|
+
|
|
68
|
+
const active = []
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
try {
|
|
71
|
+
// DSH restrict() accepts only names that exist in the current global
|
|
72
|
+
// registry. Logical DVR ownership is broader: an intermediate boundary
|
|
73
|
+
// may capture a conditional definition without mounting it yet
|
|
74
|
+
// (vision_screenshot is the canonical example).
|
|
75
|
+
const definition = get.call(tools, name)
|
|
76
|
+
if (definition?.[VISION_ROUTER_TOOL_OWNER] === true) active.push(name)
|
|
77
|
+
} catch {
|
|
78
|
+
// Older/partial Hosts may not offer a safely readable registry view.
|
|
79
|
+
// Preserve the historical fail-closed path there; per-name fallback
|
|
80
|
+
// below still prevents one bad entry from exposing every other tool.
|
|
81
|
+
return names
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return active
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assemblyOwnedVisionToolNames(registryTools, scopedTools, owned) {
|
|
88
|
+
const active = restrictableOwnedVisionToolNames(registryTools, owned)
|
|
89
|
+
const scopedGet = scopedTools?.get
|
|
90
|
+
if (typeof scopedGet !== 'function') return new Set(active)
|
|
91
|
+
|
|
92
|
+
const projected = new Set()
|
|
93
|
+
for (const name of active) {
|
|
94
|
+
try {
|
|
95
|
+
const visible = scopedGet.call(scopedTools, name)
|
|
96
|
+
// A foreign Agent-scoped definition is allowed to shadow DVR's global
|
|
97
|
+
// name. DSH restrictions deliberately leave scoped registrations visible,
|
|
98
|
+
// so the final assembly projection must preserve the same boundary.
|
|
99
|
+
if (
|
|
100
|
+
visible === undefined
|
|
101
|
+
|| visible?.[VISION_ROUTER_TOOL_OWNER] === true
|
|
102
|
+
) {
|
|
103
|
+
projected.add(name)
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// If the scoped view cannot be read, keep the conservative global DVR
|
|
107
|
+
// projection. This support-window fallback hides only a name whose global
|
|
108
|
+
// registration is proven DVR-owned.
|
|
109
|
+
projected.add(name)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return projected
|
|
113
|
+
}
|
|
114
|
+
|
|
62
115
|
function rememberOwnedTool(owned, name) {
|
|
63
116
|
owned.set(name, (owned.get(name) ?? 0) + 1)
|
|
64
117
|
}
|
|
@@ -89,18 +142,30 @@ function releaseAgentToolRestriction(agent, restrictions) {
|
|
|
89
142
|
restrictions.delete(agent)
|
|
90
143
|
}
|
|
91
144
|
|
|
145
|
+
function restrictionFailureLabel(error) {
|
|
146
|
+
const name =
|
|
147
|
+
typeof error?.name === 'string' && error.name !== ''
|
|
148
|
+
? error.name
|
|
149
|
+
: 'Error'
|
|
150
|
+
const code =
|
|
151
|
+
typeof error?.code === 'string' && error.code !== ''
|
|
152
|
+
? `/${error.code}`
|
|
153
|
+
: ''
|
|
154
|
+
return `${name}${code}`
|
|
155
|
+
}
|
|
156
|
+
|
|
92
157
|
function warnRestrictionFailure(agent, error, deny, failed = deny) {
|
|
93
158
|
try {
|
|
94
159
|
agent?.ctx?.logger?.warn?.(
|
|
95
|
-
'vision-router: failed to restrict Session vision tools while Vision mode is off: %s (
|
|
96
|
-
|
|
97
|
-
deny.
|
|
160
|
+
'vision-router: failed to restrict Session vision tools while Vision mode is off: %s (attempted=%s failed=%s)',
|
|
161
|
+
restrictionFailureLabel(error),
|
|
162
|
+
String(deny.length),
|
|
98
163
|
failed.join(','),
|
|
99
164
|
)
|
|
100
165
|
} catch {}
|
|
101
166
|
}
|
|
102
167
|
|
|
103
|
-
function syncAgentToolRestriction(agent, enabled, restrictions, owned) {
|
|
168
|
+
function syncAgentToolRestriction(agent, enabled, restrictions, owned, registryTools) {
|
|
104
169
|
if (!isObject(agent)) return
|
|
105
170
|
if (enabled) {
|
|
106
171
|
releaseAgentToolRestriction(agent, restrictions)
|
|
@@ -109,7 +174,11 @@ function syncAgentToolRestriction(agent, enabled, restrictions, owned) {
|
|
|
109
174
|
|
|
110
175
|
const tools = agent?.ctx?.tools
|
|
111
176
|
const restrict = tools?.restrict
|
|
112
|
-
|
|
177
|
+
// Registration truth must come from the unscoped/global registry view.
|
|
178
|
+
// agent.ctx.tools.get() is restriction-aware: after this boundary denies a
|
|
179
|
+
// DVR tool it reads as absent for that Agent, so using it here would make a
|
|
180
|
+
// later sync tear down its own restriction and re-expose the capability.
|
|
181
|
+
const deny = restrictableOwnedVisionToolNames(registryTools, owned)
|
|
113
182
|
if (typeof restrict !== 'function') return
|
|
114
183
|
if (deny.length === 0) {
|
|
115
184
|
releaseAgentToolRestriction(agent, restrictions)
|
|
@@ -185,6 +254,7 @@ function wrapTools(tools, ctx, config, owned) {
|
|
|
185
254
|
const execute = definition.execute
|
|
186
255
|
const registered = register.call(target, {
|
|
187
256
|
...definition,
|
|
257
|
+
[VISION_ROUTER_TOOL_OWNER]: true,
|
|
188
258
|
async execute(args, exec) {
|
|
189
259
|
if (
|
|
190
260
|
exec?.agent &&
|
|
@@ -264,6 +334,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
|
|
|
264
334
|
visionToolSurfaceEnabled(ctx, config, authority),
|
|
265
335
|
restrictions,
|
|
266
336
|
ownedVisionTools,
|
|
337
|
+
ctx?.tools,
|
|
267
338
|
)
|
|
268
339
|
}
|
|
269
340
|
|
|
@@ -282,6 +353,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
|
|
|
282
353
|
visionToolSurfaceEnabled(ctx, config, authority),
|
|
283
354
|
restrictions,
|
|
284
355
|
ownedVisionTools,
|
|
356
|
+
ctx?.tools,
|
|
285
357
|
)
|
|
286
358
|
}
|
|
287
359
|
return authority
|
|
@@ -314,10 +386,14 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
|
|
|
314
386
|
if (!authority && isObject(context)) authority = captureForAssembly(context)
|
|
315
387
|
if (!authority && isObject(agent)) authority = resolveForAgent(agent)
|
|
316
388
|
const transformed = typeof next === 'function' ? await next() : assembly
|
|
389
|
+
const projectedOwned =
|
|
390
|
+
ctx?.tools
|
|
391
|
+
? assemblyOwnedVisionToolNames(ctx.tools, agent?.ctx?.tools, ownedVisionTools)
|
|
392
|
+
: ownedVisionTools
|
|
317
393
|
return projectAssemblyTools(
|
|
318
394
|
transformed,
|
|
319
395
|
visionToolSurfaceEnabled(ctx, config, authority),
|
|
320
|
-
|
|
396
|
+
projectedOwned,
|
|
321
397
|
)
|
|
322
398
|
})
|
|
323
399
|
} catch {
|
|
@@ -390,6 +466,7 @@ export function installSessionVisionModeBoundary(ctx, config = {}) {
|
|
|
390
466
|
visionToolSurfaceEnabled(target, config, authority),
|
|
391
467
|
restrictions,
|
|
392
468
|
ownedVisionTools,
|
|
469
|
+
target?.tools,
|
|
393
470
|
)
|
|
394
471
|
return runWithSessionVisionModeAuthority(
|
|
395
472
|
authority,
|
|
@@ -16,13 +16,17 @@ export const DEFAULT_SESSION_VISION_STATE_OPTIONS = Object.freeze({
|
|
|
16
16
|
* It is not a general runtime service locator. Production composition creates
|
|
17
17
|
* one instance and gives the same owner to the Session boundary and Core.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* Cache lookup stays synchronous through index.lookupAttachment(); cold durable
|
|
20
|
+
* recovery is explicit through index.resolveAttachment(). The index never
|
|
21
|
+
* monkey-patches stateStore.lookupAttachment(), and receives only narrow Host
|
|
22
|
+
* Session readers rather than the full Context.
|
|
21
23
|
*/
|
|
22
24
|
export function createSessionVisionRuntime({
|
|
23
25
|
core,
|
|
24
26
|
config = {},
|
|
25
27
|
logger,
|
|
28
|
+
readSessionEvent,
|
|
29
|
+
readSessionLog,
|
|
26
30
|
stateStore,
|
|
27
31
|
stateOptions = {},
|
|
28
32
|
} = {}) {
|
|
@@ -39,6 +43,8 @@ export function createSessionVisionRuntime({
|
|
|
39
43
|
core,
|
|
40
44
|
config,
|
|
41
45
|
logger,
|
|
46
|
+
readSessionEvent,
|
|
47
|
+
readSessionLog,
|
|
42
48
|
})
|
|
43
49
|
|
|
44
50
|
return Object.freeze({
|
|
@@ -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
|
|