dsh-vision-router 2.1.1 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/releases/v2.1.2.md +30 -0
- package/lib/agent-request-route-authority.js +94 -0
- package/lib/delegated-call-config.js +27 -0
- package/lib/grounding-coordinate-runtime.js +10 -8
- package/lib/legacy-core-vision-policy-bridge.js +17 -146
- package/lib/runtime-composition.js +30 -8
- package/lib/session-surface-policy.js +59 -32
- package/lib/session-vision-mode-authority.js +254 -0
- package/lib/session-vision-mode-boundary.js +408 -0
- package/lib/twin-image-capability-fallback.js +428 -0
- package/lib/vision-attachment-handle-runtime.js +212 -0
- package/lib/vision-tool-runtime-boundary.js +224 -5
- package/package.json +5 -5
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
|
|
3
|
+
const VISION_ROUTER_ADAPTER_OWNER = Symbol.for('dsh-vision-router.adapter-owner')
|
|
4
|
+
const VISION_ROUTER_OWNERSHIP = 'vision-router-owned'
|
|
5
|
+
const DEFAULT_WRAPPER_ROUTE = 'deepseek-vision'
|
|
6
|
+
const DEFAULT_CHAIN_ROUTE = 'vision-chain'
|
|
7
|
+
const DEEPSEEK_SOURCE = 'deepseek-official'
|
|
8
|
+
|
|
9
|
+
const authorityTurn = new AsyncLocalStorage()
|
|
10
|
+
const agentAuthorities = new WeakMap()
|
|
11
|
+
const assemblyAuthorities = new WeakMap()
|
|
12
|
+
|
|
13
|
+
function isObject(value) {
|
|
14
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isWeakKey(value) {
|
|
18
|
+
return (typeof value === 'object' && value !== null) || typeof value === 'function'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function routeFrom(value) {
|
|
22
|
+
if (!isObject(value)) return undefined
|
|
23
|
+
const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
|
|
24
|
+
const model = typeof value.model === 'string' ? value.model.trim() : ''
|
|
25
|
+
return provider !== '' && model !== '' ? { provider, model } : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function selectionProjection(ctx, session) {
|
|
29
|
+
let projections
|
|
30
|
+
try {
|
|
31
|
+
projections = ctx?.sessionProjections
|
|
32
|
+
} catch {
|
|
33
|
+
projections = undefined
|
|
34
|
+
}
|
|
35
|
+
if (!projections) {
|
|
36
|
+
try {
|
|
37
|
+
projections = ctx?.get?.('sessionProjections')
|
|
38
|
+
} catch {
|
|
39
|
+
projections = undefined
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!projections || typeof projections.stateOf !== 'function') return undefined
|
|
43
|
+
try {
|
|
44
|
+
const state = projections.stateOf(session, 'modelSelection')
|
|
45
|
+
if (!isObject(state)) return undefined
|
|
46
|
+
return routeFrom(state.pending ?? state.next ?? state.lastUsed)
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fallbackRoute(session, agent) {
|
|
53
|
+
try {
|
|
54
|
+
const header = typeof session?.requestHeader === 'function' ? session.requestHeader() : undefined
|
|
55
|
+
const direct = routeFrom(header?.config)
|
|
56
|
+
if (direct) return direct
|
|
57
|
+
} catch {
|
|
58
|
+
// Cold or partially restored Sessions may not expose a request header yet.
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const candidates = [
|
|
62
|
+
agent?.options?.config,
|
|
63
|
+
agent?.options,
|
|
64
|
+
agent?.config,
|
|
65
|
+
session?.agent?.options?.config,
|
|
66
|
+
session?.agent?.options,
|
|
67
|
+
session?.options?.config,
|
|
68
|
+
session?.options,
|
|
69
|
+
session?.config,
|
|
70
|
+
session?.header?.config,
|
|
71
|
+
session?.header,
|
|
72
|
+
]
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
const route = routeFrom(candidate)
|
|
75
|
+
if (route) return route
|
|
76
|
+
}
|
|
77
|
+
return undefined
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function effectiveSessionModelSelection(ctx, agent) {
|
|
81
|
+
const session = agent?.session
|
|
82
|
+
if (!session) return undefined
|
|
83
|
+
return selectionProjection(ctx, session) ?? fallbackRoute(session, agent)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function liveConfig(ctx, fallback) {
|
|
87
|
+
try {
|
|
88
|
+
const settings = ctx?.get?.('settings')
|
|
89
|
+
const value = settings?.get?.('vision-router')
|
|
90
|
+
if (isObject(value)) return value
|
|
91
|
+
} catch {
|
|
92
|
+
// Composition config remains authoritative while Settings is unavailable.
|
|
93
|
+
}
|
|
94
|
+
return isObject(fallback) ? fallback : {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function registrationLookup(ctx) {
|
|
98
|
+
const registration = ctx?.llm?.registration
|
|
99
|
+
return typeof registration === 'function' ? registration : undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function currentAdapter(ctx, provider) {
|
|
103
|
+
const registration = registrationLookup(ctx)
|
|
104
|
+
if (!registration) return undefined
|
|
105
|
+
try {
|
|
106
|
+
return registration.call(ctx.llm, provider)?.adapter
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function adapterOwnedByVisionRouter(adapter) {
|
|
113
|
+
if (!isWeakKey(adapter)) return false
|
|
114
|
+
try {
|
|
115
|
+
return adapter[VISION_ROUTER_ADAPTER_OWNER] !== undefined
|
|
116
|
+
} catch {
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function sameRoute(left, right) {
|
|
122
|
+
return !!left && !!right && left.provider === right.provider && left.model === right.model
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function configuredRoute(config, key, fallback) {
|
|
126
|
+
const value = typeof config?.[key] === 'string' ? config[key].trim() : ''
|
|
127
|
+
return value || fallback
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function routeOwnedByVisionRouter(ctx, route, config, visionPolicy) {
|
|
131
|
+
if (!route) return false
|
|
132
|
+
|
|
133
|
+
const registration = registrationLookup(ctx)
|
|
134
|
+
if (registration) {
|
|
135
|
+
// On current DSH, the final Host-visible adapter marker is authoritative.
|
|
136
|
+
// If the configured route was replaced by a foreign adapter, fail closed
|
|
137
|
+
// rather than granting mode from a matching route name.
|
|
138
|
+
return adapterOwnedByVisionRouter(currentAdapter(ctx, route.provider))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Minimum Host shims may not expose registration(). Native-image-coexistence
|
|
142
|
+
// already proved exact registration ownership for the same selected route;
|
|
143
|
+
// accept only that turn-local proof, never a generic -vision suffix.
|
|
144
|
+
if (
|
|
145
|
+
visionPolicy?.ownership === VISION_ROUTER_OWNERSHIP &&
|
|
146
|
+
sameRoute(route, visionPolicy.route)
|
|
147
|
+
) {
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Compatibility fallback for explicit historical routes only when the Host
|
|
152
|
+
// offers no registration identity at all. Current DSH never reaches this.
|
|
153
|
+
const wrapperRoute = configuredRoute(config, 'wrapperRoute', DEFAULT_WRAPPER_ROUTE)
|
|
154
|
+
const chainRoute = configuredRoute(config, 'chainRoute', DEFAULT_CHAIN_ROUTE)
|
|
155
|
+
if (route.provider === wrapperRoute || route.provider === chainRoute || route.provider === 'vision-http') {
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
if (config?.stealth === true && route.provider === DEEPSEEK_SOURCE) return true
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Resolve the one authoritative Vision-mode snapshot for the next Agent step.
|
|
164
|
+
*
|
|
165
|
+
* The current DSH modelSelection projection is checked before requestHeader(),
|
|
166
|
+
* because a composer toggle writes a pending model/selection event for the next
|
|
167
|
+
* request while the last durable request header still names the previous route.
|
|
168
|
+
* This prevents a stale ON header from granting tools after the user switched
|
|
169
|
+
* Vision off (and the inverse when turning it back on).
|
|
170
|
+
*/
|
|
171
|
+
export function resolveSessionVisionModeAuthority(
|
|
172
|
+
ctx,
|
|
173
|
+
agent,
|
|
174
|
+
fallbackConfig = {},
|
|
175
|
+
options = {},
|
|
176
|
+
) {
|
|
177
|
+
const route = effectiveSessionModelSelection(ctx, agent)
|
|
178
|
+
const config = liveConfig(ctx, fallbackConfig)
|
|
179
|
+
const enabled = routeOwnedByVisionRouter(ctx, route, config, options.visionPolicy)
|
|
180
|
+
return Object.freeze({
|
|
181
|
+
enabled,
|
|
182
|
+
route: route ? Object.freeze({ ...route }) : undefined,
|
|
183
|
+
reason: enabled ? 'vision-router-route' : route ? 'ordinary-route' : 'unknown-route',
|
|
184
|
+
...(options.turn === undefined ? {} : { turn: options.turn }),
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function normalizedAuthority(authority) {
|
|
189
|
+
return isObject(authority)
|
|
190
|
+
? authority
|
|
191
|
+
: Object.freeze({ enabled: false, reason: 'unknown-route' })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Persist the immutable authority for the currently entered step. */
|
|
195
|
+
export function rememberSessionVisionModeAuthorityForAgent(authority, agent) {
|
|
196
|
+
const snapshot = normalizedAuthority(authority)
|
|
197
|
+
if (isWeakKey(agent)) agentAuthorities.set(agent, snapshot)
|
|
198
|
+
return snapshot
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Stage the authority captured by prompt assembly for exactly the immediately
|
|
203
|
+
* following pre-step. Keeping this separate from the active-step snapshot is
|
|
204
|
+
* important: a minimum/test Host may call pre-step directly more than once,
|
|
205
|
+
* and an old completed-step snapshot must never masquerade as a new assembly.
|
|
206
|
+
*/
|
|
207
|
+
export function rememberSessionVisionModeAssemblyAuthorityForAgent(authority, agent) {
|
|
208
|
+
const snapshot = normalizedAuthority(authority)
|
|
209
|
+
if (isWeakKey(agent)) assemblyAuthorities.set(agent, snapshot)
|
|
210
|
+
return snapshot
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Consume at most one prompt-assembly snapshot for the following pre-step. */
|
|
214
|
+
export function takeSessionVisionModeAssemblyAuthorityForAgent(agent) {
|
|
215
|
+
if (!isWeakKey(agent)) return undefined
|
|
216
|
+
const snapshot = assemblyAuthorities.get(agent)
|
|
217
|
+
assemblyAuthorities.delete(agent)
|
|
218
|
+
return snapshot
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Store one immutable step snapshot and keep it ambient while Core assembles it. */
|
|
222
|
+
export function runWithSessionVisionModeAuthority(authority, agent, callback) {
|
|
223
|
+
const snapshot = rememberSessionVisionModeAuthorityForAgent(authority, agent)
|
|
224
|
+
return authorityTurn.run({ authority: snapshot }, callback)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function currentSessionVisionModeAuthority() {
|
|
228
|
+
return authorityTurn.getStore()?.authority
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function sessionVisionModeAuthorityForAgent(agent) {
|
|
232
|
+
return isWeakKey(agent) ? agentAuthorities.get(agent) : undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Drop completed/abandoned step and staged assembly snapshots. */
|
|
236
|
+
export function clearSessionVisionModeAuthorityForAgent(agent) {
|
|
237
|
+
if (!isWeakKey(agent)) return
|
|
238
|
+
agentAuthorities.delete(agent)
|
|
239
|
+
assemblyAuthorities.delete(agent)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Execution-time fail-closed check. Real Agent steps use the prompt-assembly
|
|
244
|
+
* snapshot after pre-step promotes it to the active step, so a concurrent
|
|
245
|
+
* picker change applies to the next assembled step instead of tearing the
|
|
246
|
+
* current request in half. Direct/internal calls with an Agent but no captured
|
|
247
|
+
* step resolve the live DSH selection synchronously.
|
|
248
|
+
*/
|
|
249
|
+
export function visionModeEnabledForAgent(ctx, agent, fallbackConfig = {}) {
|
|
250
|
+
if (!isWeakKey(agent)) return true
|
|
251
|
+
const snapshot = agentAuthorities.get(agent)
|
|
252
|
+
if (snapshot) return snapshot.enabled === true
|
|
253
|
+
return resolveSessionVisionModeAuthority(ctx, agent, fallbackConfig).enabled === true
|
|
254
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { currentSessionVisionPolicy } from './native-image-coexistence.js'
|
|
2
|
+
import {
|
|
3
|
+
clearSessionVisionModeAuthorityForAgent,
|
|
4
|
+
rememberSessionVisionModeAssemblyAuthorityForAgent,
|
|
5
|
+
resolveSessionVisionModeAuthority,
|
|
6
|
+
runWithSessionVisionModeAuthority,
|
|
7
|
+
takeSessionVisionModeAssemblyAuthorityForAgent,
|
|
8
|
+
visionModeEnabledForAgent,
|
|
9
|
+
} from './session-vision-mode-authority.js'
|
|
10
|
+
|
|
11
|
+
const MODE_SYNC_VARIABLE = 'vision_router_mode_sync'
|
|
12
|
+
|
|
13
|
+
function isObject(value) {
|
|
14
|
+
return value !== null && typeof value === 'object'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function assemblyAgent(context) {
|
|
18
|
+
if (isObject(context?.agent) && isObject(context.agent.session)) return context.agent
|
|
19
|
+
// DSH rc.7/rc.8 pass the Agent through AssembleContext.scope. Newer Hosts
|
|
20
|
+
// declaration-merge context.agent as well. Accept scope only when it is
|
|
21
|
+
// unmistakably an Agent so standing/scopeless assemblies cannot manufacture
|
|
22
|
+
// Session authority.
|
|
23
|
+
if (isObject(context?.scope) && isObject(context.scope.session)) return context.scope
|
|
24
|
+
return undefined
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isStepAssembly(context) {
|
|
28
|
+
// The Agent loop supplies the turn's AbortSignal to SystemPrompt. Diagnostic,
|
|
29
|
+
// presentation and standing assemblies may carry the same Agent/scope but no
|
|
30
|
+
// request signal. Only a real loop assembly may stage next-step authority or
|
|
31
|
+
// mutate that Agent's scoped tool mask.
|
|
32
|
+
return isObject(context) && context.signal !== undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function liveConfig(ctx, fallback) {
|
|
36
|
+
try {
|
|
37
|
+
const settings = ctx?.get?.('settings')
|
|
38
|
+
const value = settings?.get?.('vision-router')
|
|
39
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) return value
|
|
40
|
+
} catch {
|
|
41
|
+
// Composition config stays authoritative while Settings is unavailable.
|
|
42
|
+
}
|
|
43
|
+
return fallback && typeof fallback === 'object' && !Array.isArray(fallback) ? fallback : {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function visionToolSurfaceEnabled(ctx, config, authority) {
|
|
47
|
+
return authority?.enabled === true && liveConfig(ctx, config).tool !== false
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function visionModeDisabledError(name) {
|
|
51
|
+
const error = new Error(
|
|
52
|
+
`${name}: Vision mode is off for this Session; enable the composer Vision control before using Vision Router tools`,
|
|
53
|
+
)
|
|
54
|
+
error.code = 'VISION_MODE_DISABLED'
|
|
55
|
+
return error
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ownedVisionToolNames(owned) {
|
|
59
|
+
return [...owned.keys()].sort()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rememberOwnedTool(owned, name) {
|
|
63
|
+
owned.set(name, (owned.get(name) ?? 0) + 1)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function forgetOwnedTool(owned, name) {
|
|
67
|
+
const count = owned.get(name) ?? 0
|
|
68
|
+
if (count <= 1) owned.delete(name)
|
|
69
|
+
else owned.set(name, count - 1)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function disposeHeldRestriction(held) {
|
|
73
|
+
if (!held) return
|
|
74
|
+
const disposers = Array.isArray(held.disposers)
|
|
75
|
+
? held.disposers
|
|
76
|
+
: typeof held.dispose === 'function'
|
|
77
|
+
? [held.dispose]
|
|
78
|
+
: []
|
|
79
|
+
for (let index = disposers.length - 1; index >= 0; index -= 1) {
|
|
80
|
+
try { disposers[index]?.() } catch {}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function releaseAgentToolRestriction(agent, restrictions) {
|
|
85
|
+
if (!isObject(agent)) return
|
|
86
|
+
const held = restrictions.get(agent)
|
|
87
|
+
if (!held) return
|
|
88
|
+
disposeHeldRestriction(held)
|
|
89
|
+
restrictions.delete(agent)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function warnRestrictionFailure(agent, error, deny, failed = deny) {
|
|
93
|
+
try {
|
|
94
|
+
agent?.ctx?.logger?.warn?.(
|
|
95
|
+
'vision-router: failed to restrict Session vision tools while Vision mode is off: %s (deny=%s failed=%s)',
|
|
96
|
+
error?.message ?? String(error),
|
|
97
|
+
deny.join(','),
|
|
98
|
+
failed.join(','),
|
|
99
|
+
)
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function syncAgentToolRestriction(agent, enabled, restrictions, owned) {
|
|
104
|
+
if (!isObject(agent)) return
|
|
105
|
+
if (enabled) {
|
|
106
|
+
releaseAgentToolRestriction(agent, restrictions)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const tools = agent?.ctx?.tools
|
|
111
|
+
const restrict = tools?.restrict
|
|
112
|
+
const deny = ownedVisionToolNames(owned)
|
|
113
|
+
if (typeof restrict !== 'function') return
|
|
114
|
+
if (deny.length === 0) {
|
|
115
|
+
releaseAgentToolRestriction(agent, restrictions)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const held = restrictions.get(agent)
|
|
120
|
+
const signature = deny.join('\u0000')
|
|
121
|
+
if (held?.signature === signature) return
|
|
122
|
+
releaseAgentToolRestriction(agent, restrictions)
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
// Current DSH can mask all inherited plugin tools in one scoped operation.
|
|
126
|
+
const dispose = restrict.call(tools, { deny })
|
|
127
|
+
restrictions.set(agent, {
|
|
128
|
+
signature,
|
|
129
|
+
disposers: typeof dispose === 'function' ? [dispose] : [],
|
|
130
|
+
})
|
|
131
|
+
return
|
|
132
|
+
} catch (combinedError) {
|
|
133
|
+
// Some support-window Hosts validate a deny list all-or-nothing. One
|
|
134
|
+
// temporarily non-restrictable registration must not keep every other
|
|
135
|
+
// Vision Router capability visible, so retry each owned name separately.
|
|
136
|
+
const disposers = []
|
|
137
|
+
const failed = []
|
|
138
|
+
for (const name of deny) {
|
|
139
|
+
try {
|
|
140
|
+
const dispose = restrict.call(tools, { deny: [name] })
|
|
141
|
+
if (typeof dispose === 'function') disposers.push(dispose)
|
|
142
|
+
} catch {
|
|
143
|
+
failed.push(name)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (disposers.length > 0) restrictions.set(agent, { signature, disposers })
|
|
147
|
+
if (failed.length > 0 || disposers.length === 0) {
|
|
148
|
+
warnRestrictionFailure(agent, combinedError, deny, failed.length > 0 ? failed : deny)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function projectAssemblyTools(assembly, enabled, owned) {
|
|
154
|
+
if (enabled || !isObject(assembly) || !Array.isArray(assembly.tools) || owned.size === 0) {
|
|
155
|
+
return assembly
|
|
156
|
+
}
|
|
157
|
+
const tools = assembly.tools.filter((tool) => {
|
|
158
|
+
const name = typeof tool?.name === 'string' ? tool.name : undefined
|
|
159
|
+
return name === undefined || !owned.has(name)
|
|
160
|
+
})
|
|
161
|
+
if (tools.length === assembly.tools.length) return assembly
|
|
162
|
+
return { ...assembly, tools }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function wrapTools(tools, ctx, config, owned) {
|
|
166
|
+
if (!isObject(tools)) return tools
|
|
167
|
+
return new Proxy(tools, {
|
|
168
|
+
get(target, property) {
|
|
169
|
+
if (property !== 'register') {
|
|
170
|
+
const value = Reflect.get(target, property, target)
|
|
171
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
172
|
+
}
|
|
173
|
+
const register = Reflect.get(target, property, target)
|
|
174
|
+
if (typeof register !== 'function') return register
|
|
175
|
+
return (definition, ...rest) => {
|
|
176
|
+
if (
|
|
177
|
+
!definition ||
|
|
178
|
+
typeof definition !== 'object' ||
|
|
179
|
+
typeof definition.name !== 'string' ||
|
|
180
|
+
!definition.name.startsWith('vision_') ||
|
|
181
|
+
typeof definition.execute !== 'function'
|
|
182
|
+
) {
|
|
183
|
+
return register.call(target, definition, ...rest)
|
|
184
|
+
}
|
|
185
|
+
const execute = definition.execute
|
|
186
|
+
const registered = register.call(target, {
|
|
187
|
+
...definition,
|
|
188
|
+
async execute(args, exec) {
|
|
189
|
+
if (
|
|
190
|
+
exec?.agent &&
|
|
191
|
+
!visionModeEnabledForAgent(ctx, exec.agent, liveConfig(ctx, config))
|
|
192
|
+
) {
|
|
193
|
+
throw visionModeDisabledError(definition.name)
|
|
194
|
+
}
|
|
195
|
+
return execute.call(definition, args, exec)
|
|
196
|
+
},
|
|
197
|
+
}, ...rest)
|
|
198
|
+
rememberOwnedTool(owned, definition.name)
|
|
199
|
+
if (typeof registered !== 'function') return registered
|
|
200
|
+
let active = true
|
|
201
|
+
return () => {
|
|
202
|
+
if (!active) return
|
|
203
|
+
active = false
|
|
204
|
+
try {
|
|
205
|
+
return registered()
|
|
206
|
+
} finally {
|
|
207
|
+
forgetOwnedTool(owned, definition.name)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Explicit Session Vision-mode authority boundary.
|
|
217
|
+
*
|
|
218
|
+
* DSH's per-Session model selection is the product switch: an adapter route
|
|
219
|
+
* owned by Vision Router means ON; an ordinary Host route means OFF. This
|
|
220
|
+
* boundary snapshots that fact at prompt assembly, keeps registration global,
|
|
221
|
+
* and projects visibility/execution authority per Agent. Global `tool=false`
|
|
222
|
+
* remains a second independent gate: selecting a Router route never expands a
|
|
223
|
+
* capability the user disabled in Settings.
|
|
224
|
+
*
|
|
225
|
+
* There are two model-facing gates on purpose. Current DSH exposes
|
|
226
|
+
* agent-scoped tools.restrict(), and a private prompt variable applies that mask
|
|
227
|
+
* before tool providers / Code Mode SDK rendering. The authoritative
|
|
228
|
+
* system-prompt/assemble waterfall then removes any owned native schemas that a
|
|
229
|
+
* support-window Host still contributed despite the mask. Execution is guarded
|
|
230
|
+
* again at the tool body, so stale schemas/direct dispatch cannot read images.
|
|
231
|
+
*
|
|
232
|
+
* Assembly snapshots are keyed by the concrete AssembleContext, not by Agent.
|
|
233
|
+
* An out-of-band diagnostic assembly can therefore render the next live
|
|
234
|
+
* selection without clearing or overwriting the immutable authority already
|
|
235
|
+
* active for a running step. Only a request assembly carrying DSH's turn signal
|
|
236
|
+
* is promoted for the immediately following agent/pre-step.
|
|
237
|
+
*
|
|
238
|
+
* It deliberately lives outside the retired legacy-core policy bridge, whose
|
|
239
|
+
* identity-only closure contract remains frozen.
|
|
240
|
+
*/
|
|
241
|
+
export function installSessionVisionModeBoundary(ctx, config = {}) {
|
|
242
|
+
if (!isObject(ctx)) return { ctx, config }
|
|
243
|
+
const restrictions = new WeakMap()
|
|
244
|
+
const assemblySnapshots = new WeakMap()
|
|
245
|
+
const ownedVisionTools = new Map()
|
|
246
|
+
let toolsView
|
|
247
|
+
|
|
248
|
+
const resolveForAgent = (agent, options = {}) => resolveSessionVisionModeAuthority(
|
|
249
|
+
ctx,
|
|
250
|
+
agent,
|
|
251
|
+
liveConfig(ctx, config),
|
|
252
|
+
options,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
const clearAgent = (agent) => {
|
|
256
|
+
clearSessionVisionModeAuthorityForAgent(agent)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const syncForAgent = (agent) => {
|
|
260
|
+
if (!isObject(agent)) return
|
|
261
|
+
const authority = resolveForAgent(agent)
|
|
262
|
+
syncAgentToolRestriction(
|
|
263
|
+
agent,
|
|
264
|
+
visionToolSurfaceEnabled(ctx, config, authority),
|
|
265
|
+
restrictions,
|
|
266
|
+
ownedVisionTools,
|
|
267
|
+
)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const captureForAssembly = (context) => {
|
|
271
|
+
if (!isObject(context)) return undefined
|
|
272
|
+
const agent = assemblyAgent(context)
|
|
273
|
+
if (!isObject(agent)) return undefined
|
|
274
|
+
const authority = resolveForAgent(agent)
|
|
275
|
+
assemblySnapshots.set(context, authority)
|
|
276
|
+
if (isStepAssembly(context)) {
|
|
277
|
+
// Stage the next step without touching agentAuthorities: a previous step
|
|
278
|
+
// may still be executing tools under its already-promoted snapshot.
|
|
279
|
+
rememberSessionVisionModeAssemblyAuthorityForAgent(authority, agent)
|
|
280
|
+
syncAgentToolRestriction(
|
|
281
|
+
agent,
|
|
282
|
+
visionToolSurfaceEnabled(ctx, config, authority),
|
|
283
|
+
restrictions,
|
|
284
|
+
ownedVisionTools,
|
|
285
|
+
)
|
|
286
|
+
}
|
|
287
|
+
return authority
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// DSH evaluates prompt variables synchronously before tool providers. The
|
|
291
|
+
// side effect therefore updates scoped visibility before native schemas and
|
|
292
|
+
// Code Mode's tools:sdk are generated for real Agent-loop assemblies. The
|
|
293
|
+
// returned variable is unused and never reaches the model.
|
|
294
|
+
try {
|
|
295
|
+
const systemPrompt = ctx?.systemPrompt ?? ctx?.get?.('systemPrompt')
|
|
296
|
+
if (typeof systemPrompt?.variable === 'function') {
|
|
297
|
+
systemPrompt.variable(MODE_SYNC_VARIABLE, (context) => {
|
|
298
|
+
captureForAssembly(context)
|
|
299
|
+
return ''
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
} catch {
|
|
303
|
+
// The final assembly projection and execution guard below remain available
|
|
304
|
+
// on minimum/support-window Hosts without prompt variables.
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// The returned assembly is the model-facing authority in DSH. Filter only
|
|
308
|
+
// definitions that were registered through this plugin boundary; a foreign
|
|
309
|
+
// plugin is free to own a vision_* name without being coupled to our switch.
|
|
310
|
+
try {
|
|
311
|
+
ctx.on?.('system-prompt/assemble', async (assembly, context, next) => {
|
|
312
|
+
const agent = assemblyAgent(context)
|
|
313
|
+
let authority = isObject(context) ? assemblySnapshots.get(context) : undefined
|
|
314
|
+
if (!authority && isObject(context)) authority = captureForAssembly(context)
|
|
315
|
+
if (!authority && isObject(agent)) authority = resolveForAgent(agent)
|
|
316
|
+
const transformed = typeof next === 'function' ? await next() : assembly
|
|
317
|
+
return projectAssemblyTools(
|
|
318
|
+
transformed,
|
|
319
|
+
visionToolSurfaceEnabled(ctx, config, authority),
|
|
320
|
+
ownedVisionTools,
|
|
321
|
+
)
|
|
322
|
+
})
|
|
323
|
+
} catch {
|
|
324
|
+
// Pre-step/execution guards still fail closed on Hosts without this event.
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// A waking Agent enters `running` before its first prompt/tool assembly.
|
|
328
|
+
// Refresh visibility there as an early fallback, but do not persist the step
|
|
329
|
+
// snapshot until prompt assembly itself: a picker change between wakeup and
|
|
330
|
+
// assembly must still win for that request.
|
|
331
|
+
try {
|
|
332
|
+
ctx.on?.('agent/status', ({ agent, status }) => {
|
|
333
|
+
if (status === 'running') {
|
|
334
|
+
clearAgent(agent)
|
|
335
|
+
syncForAgent(agent)
|
|
336
|
+
} else if (status === 'idle') {
|
|
337
|
+
clearAgent(agent)
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
ctx.on?.('agent/created', ({ agent }) => {
|
|
341
|
+
clearAgent(agent)
|
|
342
|
+
syncForAgent(agent)
|
|
343
|
+
})
|
|
344
|
+
ctx.on?.('agent/disposed', ({ agent }) => {
|
|
345
|
+
clearAgent(agent)
|
|
346
|
+
releaseAgentToolRestriction(agent, restrictions)
|
|
347
|
+
})
|
|
348
|
+
} catch {
|
|
349
|
+
// Minimum Hosts may not expose every lifecycle event. Prompt assembly and
|
|
350
|
+
// execution guards preserve the safety boundary without making apply fail.
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const wrapped = new Proxy(ctx, {
|
|
354
|
+
get(target, property) {
|
|
355
|
+
if (property === 'tools') {
|
|
356
|
+
if (!toolsView) {
|
|
357
|
+
toolsView = wrapTools(
|
|
358
|
+
Reflect.get(target, property, target),
|
|
359
|
+
target,
|
|
360
|
+
config,
|
|
361
|
+
ownedVisionTools,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
return toolsView
|
|
365
|
+
}
|
|
366
|
+
if (property === 'on') {
|
|
367
|
+
const on = Reflect.get(target, property, target)
|
|
368
|
+
if (typeof on !== 'function') return on
|
|
369
|
+
return (event, handler, ...rest) => {
|
|
370
|
+
if (event !== 'agent/pre-step' || typeof handler !== 'function') {
|
|
371
|
+
return on.call(target, event, handler, ...rest)
|
|
372
|
+
}
|
|
373
|
+
return on.call(target, event, async function sessionVisionModeAwarePreStep(payload, next) {
|
|
374
|
+
const agent = payload?.agent
|
|
375
|
+
// Prompt assembly is the DSH selection-snapshot boundary. Consume
|
|
376
|
+
// exactly that staged snapshot here; direct/minimum Host pre-step
|
|
377
|
+
// calls resolve fresh authority instead of reusing an old step.
|
|
378
|
+
const authority = takeSessionVisionModeAssemblyAuthorityForAgent(agent)
|
|
379
|
+
?? resolveSessionVisionModeAuthority(
|
|
380
|
+
target,
|
|
381
|
+
agent,
|
|
382
|
+
liveConfig(target, config),
|
|
383
|
+
{
|
|
384
|
+
turn: payload?.turn,
|
|
385
|
+
visionPolicy: currentSessionVisionPolicy(),
|
|
386
|
+
},
|
|
387
|
+
)
|
|
388
|
+
syncAgentToolRestriction(
|
|
389
|
+
agent,
|
|
390
|
+
visionToolSurfaceEnabled(target, config, authority),
|
|
391
|
+
restrictions,
|
|
392
|
+
ownedVisionTools,
|
|
393
|
+
)
|
|
394
|
+
return runWithSessionVisionModeAuthority(
|
|
395
|
+
authority,
|
|
396
|
+
agent,
|
|
397
|
+
() => handler.call(this, payload, next),
|
|
398
|
+
)
|
|
399
|
+
}, ...rest)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const value = Reflect.get(target, property, target)
|
|
403
|
+
return typeof value === 'function' ? value.bind(target) : value
|
|
404
|
+
},
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
return { ctx: wrapped, config }
|
|
408
|
+
}
|