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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
function nonNegativeSafeInteger(value) {
|
|
2
|
+
return Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build the Host-owned Session surface replacement intent for one exact node.
|
|
7
|
+
*
|
|
8
|
+
* DSH Session format v3 renamed replacement endpoints from start/end to
|
|
9
|
+
* startSeq/endSeq. The field change belongs to the Session format contract,
|
|
10
|
+
* not to a DSH package-version heuristic, so dispatch on session.header.version
|
|
11
|
+
* and refuse unknown future formats instead of guessing forward compatibility.
|
|
12
|
+
*
|
|
13
|
+
* Returns undefined when the Host does not expose a supported logical Session
|
|
14
|
+
* format. Callers can then keep request-local safety behavior while skipping
|
|
15
|
+
* optional durable surface hygiene.
|
|
16
|
+
*/
|
|
17
|
+
export function sessionSurfaceReplacementIntent(session, seq) {
|
|
18
|
+
if (!nonNegativeSafeInteger(seq)) {
|
|
19
|
+
throw new TypeError('session surface replacement seq must be a non-negative safe integer')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const version = session?.header?.version
|
|
23
|
+
if (!nonNegativeSafeInteger(version)) return undefined
|
|
24
|
+
|
|
25
|
+
if (version <= 2) {
|
|
26
|
+
return {
|
|
27
|
+
surfaceOp: { op: 'replace', start: seq, end: seq },
|
|
28
|
+
sourceEventSeqs: [seq],
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (version === 3) {
|
|
33
|
+
return {
|
|
34
|
+
surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq },
|
|
35
|
+
sourceEventSeqs: [seq],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
function projectionServiceOf(ctx) {
|
|
2
|
+
try {
|
|
3
|
+
const service = ctx?.sessionProjections
|
|
4
|
+
if (service && typeof service.stateOf === 'function') return service
|
|
5
|
+
} catch {
|
|
6
|
+
// Some compatibility contexts expose services only through get().
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const service = ctx?.get?.('sessionProjections')
|
|
10
|
+
return service && typeof service.stateOf === 'function' ? service : undefined
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve the current Agent turn without making modern Hosts expose arbitrary
|
|
18
|
+
* Session history. DSH 0.1.5+ owns `turnBoundary` as a Session projection;
|
|
19
|
+
* older supported Hosts receive `undefined` so their existing compatibility
|
|
20
|
+
* path remains authoritative.
|
|
21
|
+
*/
|
|
22
|
+
function validSeq(value) {
|
|
23
|
+
return Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0) ? value : undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createSessionTurnResolver(ctx) {
|
|
27
|
+
const boundaryOf = (session) => {
|
|
28
|
+
if (!session) return undefined
|
|
29
|
+
const projections = projectionServiceOf(ctx)
|
|
30
|
+
if (projections === undefined) return undefined
|
|
31
|
+
try {
|
|
32
|
+
const state = projections.stateOf(session, 'turnBoundary')
|
|
33
|
+
if (!state || typeof state !== 'object') return undefined
|
|
34
|
+
return state
|
|
35
|
+
} catch {
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
boundaryOf,
|
|
42
|
+
turnOf(session) {
|
|
43
|
+
const state = boundaryOf(session)
|
|
44
|
+
return Number.isInteger(state?.lastTurn) && state.lastTurn >= 0 ? state.lastTurn : undefined
|
|
45
|
+
},
|
|
46
|
+
eventAnchorOf(session) {
|
|
47
|
+
const state = boundaryOf(session)
|
|
48
|
+
const openTurnStartSeq = validSeq(state?.openTurnStartSeq)
|
|
49
|
+
if (openTurnStartSeq === undefined) return undefined
|
|
50
|
+
const lastBoundarySeq = validSeq(state?.lastStepBoundary?.seq)
|
|
51
|
+
if (lastBoundarySeq !== undefined && lastBoundarySeq >= openTurnStartSeq) return lastBoundarySeq
|
|
52
|
+
const lastStepStartSeq = validSeq(state?.lastStepStartSeq)
|
|
53
|
+
if (lastStepStartSeq !== undefined && lastStepStartSeq >= openTurnStartSeq) return lastStepStartSeq
|
|
54
|
+
return openTurnStartSeq
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
@@ -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({
|