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.
@@ -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
+ }