dsh-vision-router 2.3.0 → 3.0.1

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.
Files changed (105) hide show
  1. package/README.md +18 -3
  2. package/README.zh.md +16 -3
  3. package/docs/architecture/3.0-architecture-plan.md +252 -0
  4. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  5. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  6. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  7. package/docs/architecture/3.0-r11-adversarial-review.md +447 -0
  8. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  9. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  10. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  11. package/docs/architecture/3.0-r7-public-api.md +151 -0
  12. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  13. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  14. package/docs/architecture/dsh-support-window.md +13 -3
  15. package/docs/releases/v3.0.0.md +85 -0
  16. package/docs/releases/v3.0.1.md +77 -0
  17. package/entry.js +1 -1
  18. package/index.js +110 -71
  19. package/lib/agent-request-route-authority.js +1 -20
  20. package/lib/core-apply-lifecycle.js +37 -43
  21. package/lib/core-cache.js +87 -0
  22. package/lib/core-primitives.js +11 -144
  23. package/lib/core-vision-surface-domain.js +63 -0
  24. package/lib/core-vision-surface.js +11 -77
  25. package/lib/delegated-call-config.js +15 -24
  26. package/lib/doctor-cli.js +0 -0
  27. package/lib/doctor-runtime.js +4 -1
  28. package/lib/dsh-contract-compat.js +26 -230
  29. package/lib/dsh-host-capabilities.js +3 -9
  30. package/lib/dsh-settings-017-compat.js +17 -10
  31. package/lib/file-logger.js +8 -3
  32. package/lib/grounding-coordinate-runtime.js +1 -2
  33. package/lib/http-body-limit.js +97 -79
  34. package/lib/image-offload-compat.js +30 -21
  35. package/lib/image-resource-governor.js +210 -228
  36. package/lib/legacy-global-proxy-boundary.js +46 -3
  37. package/lib/live-model-client-prelude.js +12 -4
  38. package/lib/live-model-discovery.js +134 -43
  39. package/lib/local-vision-stabilizer.js +33 -18
  40. package/lib/maintenance-routes.js +1 -3
  41. package/lib/native-image-coexistence.js +15 -3
  42. package/lib/ollama-cold-start.js +87 -23
  43. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  44. package/lib/proxy-dispatcher-pool.js +151 -92
  45. package/lib/proxy-routing.js +75 -51
  46. package/lib/proxy-url-compat.js +3 -10
  47. package/lib/public-config.js +23 -44
  48. package/lib/public-entry.d.ts +148 -0
  49. package/lib/public-entry.js +17 -4
  50. package/lib/replay-delegation.js +12 -7
  51. package/lib/replay-envelope-v2-compat.js +12 -7
  52. package/lib/runtime-composition.js +30 -10
  53. package/lib/screenshot-source-boundary.js +2 -2
  54. package/lib/session-affinity-runtime.js +113 -97
  55. package/lib/session-affinity.js +89 -76
  56. package/lib/session-event-history-compat.js +30 -0
  57. package/lib/session-query-readers.js +263 -0
  58. package/lib/session-surface-compat.js +30 -31
  59. package/lib/session-surface-policy-domain.js +85 -0
  60. package/lib/session-surface-policy.js +2 -111
  61. package/lib/session-turn-resolver.js +2 -6
  62. package/lib/session-vision-attachment-index.js +143 -0
  63. package/lib/session-vision-delegation-boundary.js +34 -0
  64. package/lib/session-vision-delegation.js +22 -0
  65. package/lib/session-vision-index.js +25 -107
  66. package/lib/session-vision-mode-authority.js +229 -192
  67. package/lib/session-vision-mode-boundary.js +65 -22
  68. package/lib/session-vision-policy-domain.js +304 -0
  69. package/lib/session-vision-policy.js +104 -0
  70. package/lib/session-vision-state.js +368 -378
  71. package/lib/settings-client-017-compat.js +16 -10
  72. package/lib/structured-flow-hardening.js +22 -4
  73. package/lib/tesseract-exec-compat.js +9 -1
  74. package/lib/turn-budget-context.js +47 -41
  75. package/lib/update-check.js +11 -2
  76. package/lib/vision-attachment-handle-runtime.js +176 -156
  77. package/lib/vision-backend-runtime-policy.js +16 -0
  78. package/lib/vision-background-benchmark.js +55 -6
  79. package/lib/vision-background-failure-policy.js +41 -41
  80. package/lib/vision-background-stop-store-core.js +217 -0
  81. package/lib/vision-background-stop-store.js +8 -179
  82. package/lib/vision-capability-benchmark.js +11 -61
  83. package/lib/vision-capability-evidence.js +35 -0
  84. package/lib/vision-capability-fingerprint.js +72 -0
  85. package/lib/vision-capability-identity.js +116 -61
  86. package/lib/vision-capability-router.js +13 -33
  87. package/lib/vision-chunk-assembler.js +95 -0
  88. package/lib/vision-diagnostics-routes.js +42 -4
  89. package/lib/vision-execution-order-apply.js +45 -55
  90. package/lib/vision-execution-order.js +19 -19
  91. package/lib/vision-execution-policy.js +12 -7
  92. package/lib/vision-model-registry.js +30 -17
  93. package/lib/vision-model-visibility-boundary-main.js +15 -16
  94. package/lib/vision-provider-transport.js +137 -147
  95. package/lib/vision-routing-authority.js +47 -48
  96. package/lib/vision-routing-product.js +26 -29
  97. package/lib/vision-routing-settings-prelude.js +8 -3
  98. package/lib/vision-runtime-performance-store.js +166 -0
  99. package/lib/vision-runtime-performance.js +13 -142
  100. package/lib/vision-tool-runtime-boundary.js +99 -30
  101. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  102. package/lib/web/remote-settings-client.js +4 -7
  103. package/lib/web-capability-boundary.js +17 -7
  104. package/lib/wrapper-directory.js +14 -23
  105. package/package.json +37 -11
@@ -14,14 +14,11 @@ export const SETTINGS_017_CLIENT_PRELUDE = String.raw`(function(){
14
14
  var connectionCache = typeof WeakMap === 'function' ? new WeakMap() : undefined;
15
15
 
16
16
  function safeGet(ctx, name) {
17
- if (!ctx) return undefined;
17
+ if (!ctx || typeof ctx.get !== 'function') return undefined;
18
18
  try {
19
- if (typeof ctx.get === 'function') {
20
- var value = ctx.get(name);
21
- if (value !== undefined && value !== null) return value;
22
- }
23
- } catch (_) {}
24
- try { return ctx[name]; } catch (_) { return undefined; }
19
+ var value = ctx.get(name);
20
+ return value === undefined || value === null ? undefined : value;
21
+ } catch (_) { return undefined; }
25
22
  }
26
23
 
27
24
  function isLoopbackLocation(locationLike) {
@@ -200,18 +197,27 @@ export const SETTINGS_017_CLIENT_PRELUDE = String.raw`(function(){
200
197
  get: function(target, property) {
201
198
  if (property === 'settingsScope') {
202
199
  var original = safeGet(target, 'settingsScope');
203
- if (original && typeof original.bind === 'function') return original;
204
200
  // DSH 0.1.7 configForms only mirrors Config fields declared volatile.
205
201
  // DVR must keep its public Config ordinary for the older supported Host
206
202
  // window, so its namespace intentionally uses the local-only ConfigEditor
207
- // bridge while other namespaces may still delegate to native configForms.
203
+ // bridge. Even when a Host exposes a legacy-shaped settingsScope, its
204
+ // vision-router view may be empty; keep it only as the delegate for
205
+ // every other namespace instead of letting it bypass the local bridge.
208
206
  var official = configFormsBinder(target);
209
- return syntheticBinder(ctx, official);
207
+ return syntheticBinder(
208
+ ctx,
209
+ original && typeof original.bind === 'function' ? original : official
210
+ );
210
211
  }
211
212
  if (property === 'get') {
212
213
  var getter = Reflect.get(target, property, target);
213
214
  if (typeof getter !== 'function') return getter;
214
215
  return function(name) {
216
+ if (name === 'settingsScope') {
217
+ var original = safeGet(target, 'settingsScope');
218
+ if (original && typeof original.bind === 'function') return original;
219
+ return syntheticBinder(ctx, configFormsBinder(target));
220
+ }
215
221
  var value = getter.call(target, name);
216
222
  return name === 'connection' ? normalizeConnection(value) : value;
217
223
  };
@@ -630,7 +630,7 @@ function wrapSettingsScope(scope) {
630
630
  })
631
631
  }
632
632
 
633
- function wrapSettingsService(settings, setScope) {
633
+ function wrapSettingsService(settings, bindScope) {
634
634
  if (!settings || typeof settings !== 'object') return settings
635
635
  return new Proxy(settings, {
636
636
  get(target, property) {
@@ -643,7 +643,7 @@ function wrapSettingsService(settings, setScope) {
643
643
  return (namespace, ...args) => {
644
644
  const scope = register.call(target, namespace, ...args)
645
645
  if (namespace !== 'vision-router') return scope
646
- setScope(scope)
646
+ if (bindScope(scope) !== true) return scope
647
647
  return wrapSettingsScope(scope)
648
648
  }
649
649
  },
@@ -652,7 +652,8 @@ function wrapSettingsService(settings, setScope) {
652
652
 
653
653
  function wrapSettingsContext(childCtx, setScope) {
654
654
  if (!childCtx || typeof childCtx !== 'object' || !childCtx.settings) return childCtx
655
- const settings = wrapSettingsService(childCtx.settings, setScope)
655
+ const bindScope = (scope) => setScope(scope, childCtx)
656
+ const settings = wrapSettingsService(childCtx.settings, bindScope)
656
657
  return new Proxy(childCtx, {
657
658
  get(target, property) {
658
659
  if (property === 'settings') return settings
@@ -666,7 +667,24 @@ export function installStructuredFlowHardening(ctx, config = {}) {
666
667
  if (!ctx || typeof ctx !== 'object') return ctx
667
668
  const states = new WeakMap()
668
669
  let settingsScope
669
- const setSettingsScope = (scope) => { settingsScope = scope }
670
+ const setSettingsScope = (scope, ownerCtx) => {
671
+ if (!scope || (typeof scope !== 'object' && typeof scope !== 'function')) return false
672
+ // Settings is a dynamic child service. Never retain a scope unless the
673
+ // child fiber owns its lifetime; otherwise an HMR gap can read stale config.
674
+ try {
675
+ if (typeof ownerCtx?.effect !== 'function') return false
676
+ ownerCtx.effect(
677
+ () => () => {
678
+ if (settingsScope === scope) settingsScope = undefined
679
+ },
680
+ 'vision-router: structured flow settings lifecycle',
681
+ )
682
+ } catch {
683
+ return false
684
+ }
685
+ settingsScope = scope
686
+ return true
687
+ }
670
688
  const activeConfig = () => {
671
689
  try {
672
690
  const value = settingsScope && typeof settingsScope.get === 'function' ? settingsScope.get() : config
@@ -326,7 +326,15 @@ export function installVisionRouterExecFileCompat(ctx, options = {}) {
326
326
  }
327
327
 
328
328
  if (ctx && typeof ctx.effect === 'function') {
329
- ctx.effect(() => dispose, 'vision-router: execFile compatibility')
329
+ try {
330
+ ctx.effect(() => dispose, 'vision-router: execFile compatibility')
331
+ } catch (error) {
332
+ // The process-global execFile seam was already mutated above. If Cordis
333
+ // cannot own this generation, undo exactly this installation before the
334
+ // failure escapes so no orphan patch survives HMR/startup failure.
335
+ dispose()
336
+ throw error
337
+ }
330
338
  }
331
339
  return dispose
332
340
  }
@@ -1,58 +1,64 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
-
3
- const turnBudgetScope = new AsyncLocalStorage()
4
-
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ const turnBudgetScope = new AsyncLocalStorage();
5
3
  function usableSignal(value) {
6
- return value && typeof value === 'object' && typeof value.aborted === 'boolean' ? value : undefined
4
+ return value !== null
5
+ && typeof value === 'object'
6
+ && typeof value.aborted === 'boolean'
7
+ ? value
8
+ : undefined;
7
9
  }
8
-
9
10
  function finiteDeadline(value) {
10
- const number = Number(value)
11
- return Number.isFinite(number) ? number : undefined
11
+ const number = Number(value);
12
+ return Number.isFinite(number) ? number : undefined;
13
+ }
14
+ function objectBudget(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
16
+ ? value
17
+ : undefined;
12
18
  }
13
-
14
19
  /**
15
20
  * Run one async tool execution inside an ambient turn-level budget.
16
21
  * Nested budgets may tighten the parent, but may not discard an earlier parent
17
22
  * deadline, cancellation signal or artifact-run identity.
18
23
  */
19
24
  export function runWithVisionTurnBudget(budget, fn) {
20
- if (typeof fn !== 'function') throw new TypeError('runWithVisionTurnBudget: fn must be a function')
21
- const parent = turnBudgetScope.getStore()
22
- const parentSignal = usableSignal(parent?.signal)
23
- const childSignal = usableSignal(budget?.signal)
24
- const parentDeadline = finiteDeadline(parent?.deadlineAt)
25
- const childDeadline = finiteDeadline(budget?.deadlineAt)
26
- let next = budget
27
- if (parentSignal && childSignal && parentSignal !== childSignal) {
28
- next = { ...(next ?? {}), signal: AbortSignal.any([parentSignal, childSignal]) }
29
- } else if (parentSignal && !childSignal) {
30
- next = { ...(next ?? {}), signal: parentSignal }
31
- }
32
- if (parentDeadline !== undefined && (childDeadline === undefined || parentDeadline < childDeadline)) {
33
- next = { ...(next ?? {}), deadlineAt: parentDeadline }
34
- }
35
- if (
36
- typeof parent?.artifactRunId === 'string' &&
37
- parent.artifactRunId !== '' &&
38
- !(typeof next?.artifactRunId === 'string' && next.artifactRunId !== '')
39
- ) {
40
- next = { ...(next ?? {}), artifactRunId: parent.artifactRunId }
41
- }
42
- return turnBudgetScope.run(next, fn)
25
+ if (typeof fn !== 'function')
26
+ throw new TypeError('runWithVisionTurnBudget: fn must be a function');
27
+ const parent = turnBudgetScope.getStore();
28
+ const parentSignal = usableSignal(parent?.signal);
29
+ const childSignal = usableSignal(budget?.signal);
30
+ const parentDeadline = finiteDeadline(parent?.deadlineAt);
31
+ const childDeadline = finiteDeadline(budget?.deadlineAt);
32
+ let next = objectBudget(budget);
33
+ if (parentSignal && childSignal && parentSignal !== childSignal) {
34
+ next = { ...(next ?? {}), signal: AbortSignal.any([parentSignal, childSignal]) };
35
+ }
36
+ else if (parentSignal && !childSignal) {
37
+ next = { ...(next ?? {}), signal: parentSignal };
38
+ }
39
+ if (parentDeadline !== undefined && (childDeadline === undefined || parentDeadline < childDeadline)) {
40
+ next = { ...(next ?? {}), deadlineAt: parentDeadline };
41
+ }
42
+ if (typeof parent?.artifactRunId === 'string'
43
+ && parent.artifactRunId !== ''
44
+ && !(typeof next?.artifactRunId === 'string' && next.artifactRunId !== '')) {
45
+ next = { ...(next ?? {}), artifactRunId: parent.artifactRunId };
46
+ }
47
+ return turnBudgetScope.run(next, fn);
43
48
  }
44
-
45
49
  export function currentVisionTurnBudget() {
46
- return turnBudgetScope.getStore()
50
+ return turnBudgetScope.getStore();
47
51
  }
48
-
49
52
  export function currentVisionTurnBudgetSignal() {
50
- return turnBudgetScope.getStore()?.signal
53
+ return usableSignal(turnBudgetScope.getStore()?.signal);
51
54
  }
52
-
53
55
  export function remainingVisionTurnBudgetMs(now = Date.now) {
54
- const budget = turnBudgetScope.getStore()
55
- if (!budget || !Number.isFinite(Number(budget.deadlineAt))) return undefined
56
- const clock = typeof now === 'function' ? now : Date.now
57
- return Math.max(0, Number(budget.deadlineAt) - Number(clock()))
56
+ const budget = turnBudgetScope.getStore();
57
+ const deadlineAt = finiteDeadline(budget?.deadlineAt);
58
+ if (deadlineAt === undefined)
59
+ return undefined;
60
+ const clock = typeof now === 'function'
61
+ ? now
62
+ : Date.now;
63
+ return Math.max(0, deadlineAt - Number(clock()));
58
64
  }
@@ -5,6 +5,15 @@ import { stripTrailingSlashes } from './string-normalization.js'
5
5
  const require = createRequire(import.meta.url)
6
6
  const packageJson = require('../package.json')
7
7
 
8
+ // Capture the process fetch once, at module evaluation, exactly like the
9
+ // Router-owned provider transport. Reading `globalThis.fetch` per call would
10
+ // re-admit DVR's own later-installed process-wide wrappers (legacy proxy,
11
+ // i18n/pi-ai bridge) into DVR's product-metadata requests, and would make the
12
+ // transport used for a request depend on when the request happens to run.
13
+ const moduleFetch = typeof globalThis.fetch === 'function'
14
+ ? globalThis.fetch.bind(globalThis)
15
+ : undefined
16
+
8
17
  export const PACKAGE_NAME = 'dsh-vision-router'
9
18
  export const CURRENT_VERSION = String(packageJson.version ?? '')
10
19
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
@@ -149,7 +158,7 @@ async function fetchLatestReleaseVersion({ fetchImpl, releaseApi, signal, timeou
149
158
  * read-only metadata request against npmjs instead of showing a false failure.
150
159
  */
151
160
  export async function checkPackageUpdate({
152
- fetchImpl = globalThis.fetch,
161
+ fetchImpl = moduleFetch,
153
162
  currentVersion = CURRENT_VERSION,
154
163
  registry = registryBaseFromEnv(),
155
164
  fallbackRegistry = DEFAULT_NPM_REGISTRY,
@@ -247,7 +256,7 @@ export async function checkPackageUpdate({
247
256
  * settings card reuses that result, and the manual button can force a refresh.
248
257
  */
249
258
  export function createCachedUpdateChecker({
250
- fetchImpl = (...args) => globalThis.fetch(...args),
259
+ fetchImpl = moduleFetch,
251
260
  currentVersion = CURRENT_VERSION,
252
261
  registry = registryBaseFromEnv(),
253
262
  fallbackRegistry = DEFAULT_NPM_REGISTRY,
@@ -1,84 +1,91 @@
1
- const SHA256_HANDLE_PREFIX = /^sha256:/i
2
- const CANONICAL_SHA256_ID = /^sha256:[0-9a-f]{64}$/i
3
- const PROJECTED_SHA256_HANDLE = /^sha256:[0-9a-f]{8,63}$/i
4
-
1
+ const SHA256_HANDLE_PREFIX = /^sha256:/i;
2
+ const CANONICAL_SHA256_ID = /^sha256:[0-9a-f]{64}$/i;
3
+ const PROJECTED_SHA256_HANDLE = /^sha256:[0-9a-f]{8,63}$/i;
5
4
  const TOOL_FIELDS = Object.freeze({
6
- vision_describe: { arrays: ['attachmentIds', 'paths'] },
7
- vision_bootstrap: { arrays: ['attachmentIds', 'paths'] },
8
- vision_materialize: { scalars: ['image'] },
9
- vision_ground: { scalars: ['image'] },
10
- vision_detect: { scalars: ['image'] },
11
- vision_crop: { scalars: ['image'] },
12
- vision_present: { scalars: ['image'] },
13
- vision_pixel_diff: { scalars: ['original', 'rebuilt'] },
14
- vision_colors: { scalars: ['image'] },
15
- vision_ocr: { scalars: ['image'] },
16
- vision_trace: { scalars: ['image'] },
17
- vision_extract_foreground: { scalars: ['image'] },
18
- })
19
-
20
- function isObject(value) {
21
- return value !== null && typeof value === 'object'
5
+ vision_describe: { arrays: ['attachmentIds', 'paths'] },
6
+ vision_bootstrap: { arrays: ['attachmentIds', 'paths'] },
7
+ vision_materialize: { scalars: ['image'] },
8
+ vision_ground: { scalars: ['image'] },
9
+ vision_detect: { scalars: ['image'] },
10
+ vision_crop: { scalars: ['image'] },
11
+ vision_present: { scalars: ['image'] },
12
+ vision_pixel_diff: { scalars: ['original', 'rebuilt'] },
13
+ vision_colors: { scalars: ['image'] },
14
+ vision_ocr: { scalars: ['image'] },
15
+ vision_trace: { scalars: ['image'] },
16
+ vision_extract_foreground: { scalars: ['image'] },
17
+ });
18
+ function objectRecord(value) {
19
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
20
+ ? value
21
+ : undefined;
22
22
  }
23
-
24
23
  function attachmentIdOf(ref) {
25
- if (!ref || typeof ref !== 'object') return undefined
26
- const value = ref.attachmentId ?? ref.id
27
- if (value === undefined || value === null) return undefined
28
- const id = String(value).trim()
29
- return id === '' ? undefined : id
24
+ const value = objectRecord(ref);
25
+ if (value === undefined)
26
+ return undefined;
27
+ const raw = value.attachmentId ?? value.id;
28
+ if (raw === undefined || raw === null)
29
+ return undefined;
30
+ const id = String(raw).trim();
31
+ return id === '' ? undefined : id;
30
32
  }
31
-
32
33
  function sessionMessages(session) {
33
- try {
34
- if (!session || typeof session.deriveMessages !== 'function') return []
35
- const messages = session.deriveMessages()
36
- return Array.isArray(messages) ? messages : []
37
- } catch {
38
- return []
39
- }
34
+ try {
35
+ if (!session || typeof session.deriveMessages !== 'function')
36
+ return [];
37
+ const messages = session.deriveMessages();
38
+ return Array.isArray(messages) ? messages : [];
39
+ }
40
+ catch {
41
+ return [];
42
+ }
40
43
  }
41
-
42
44
  function collectImageRefsFromBlocks(blocks, out) {
43
- if (!Array.isArray(blocks)) return
44
- for (const block of blocks) {
45
- if (!block || typeof block !== 'object') continue
46
- if (block.type === 'image' && block.attachment) out.push(block.attachment)
47
- if (Array.isArray(block.content)) collectImageRefsFromBlocks(block.content, out)
48
- }
45
+ if (!Array.isArray(blocks))
46
+ return;
47
+ for (const block of blocks) {
48
+ const value = objectRecord(block);
49
+ if (value === undefined)
50
+ continue;
51
+ if (value.type === 'image') {
52
+ const attachment = objectRecord(value.attachment);
53
+ if (attachment !== undefined)
54
+ out.push(attachment);
55
+ }
56
+ if (Array.isArray(value.content))
57
+ collectImageRefsFromBlocks(value.content, out);
58
+ }
49
59
  }
50
-
51
60
  function collectImageRefsFromMessages(messages, out) {
52
- if (!Array.isArray(messages)) return
53
- for (const message of messages) collectImageRefsFromBlocks(message?.content, out)
61
+ if (!Array.isArray(messages))
62
+ return;
63
+ for (const message of messages) {
64
+ collectImageRefsFromBlocks(objectRecord(message)?.content, out);
65
+ }
54
66
  }
55
-
56
67
  function authorizedRefs(agent) {
57
- const refs = []
58
- collectImageRefsFromMessages(sessionMessages(agent?.session), refs)
59
- collectImageRefsFromMessages(agent?.inbox?.nextTurn, refs)
60
- collectImageRefsFromMessages(agent?.inbox?.nextStep, refs)
61
-
62
- const unique = new Map()
63
- for (const ref of refs) {
64
- const id = attachmentIdOf(ref)
65
- if (id !== undefined && !unique.has(id)) unique.set(id, ref)
66
- }
67
- return [...unique.values()]
68
+ const refs = [];
69
+ collectImageRefsFromMessages(sessionMessages(agent?.session), refs);
70
+ collectImageRefsFromMessages(agent?.inbox?.nextTurn, refs);
71
+ collectImageRefsFromMessages(agent?.inbox?.nextStep, refs);
72
+ const unique = new Map();
73
+ for (const ref of refs) {
74
+ const id = attachmentIdOf(ref);
75
+ if (id !== undefined && !unique.has(id))
76
+ unique.set(id, ref);
77
+ }
78
+ return [...unique.values()];
68
79
  }
69
-
70
80
  export function isProjectedAttachmentHandle(value) {
71
- return typeof value === 'string' && PROJECTED_SHA256_HANDLE.test(value.trim())
81
+ return typeof value === 'string' && PROJECTED_SHA256_HANDLE.test(value.trim());
72
82
  }
73
-
74
83
  function isSha256HandleLike(value) {
75
- return typeof value === 'string' && SHA256_HANDLE_PREFIX.test(value.trim())
84
+ return typeof value === 'string' && SHA256_HANDLE_PREFIX.test(value.trim());
76
85
  }
77
-
78
86
  function isCanonicalSha256Id(value) {
79
- return typeof value === 'string' && CANONICAL_SHA256_ID.test(value.trim())
87
+ return CANONICAL_SHA256_ID.test(value.trim());
80
88
  }
81
-
82
89
  /**
83
90
  * Resolve DSH's model-facing text-only image alias back to one canonical
84
91
  * attachment ref without changing durable attachment identity semantics.
@@ -89,91 +96,95 @@ function isCanonicalSha256Id(value) {
89
96
  * prefix. Zero or multiple matches fail closed.
90
97
  */
91
98
  export function resolveProjectedAttachmentHandle(handle, { sessionVisionIndex, agent } = {}) {
92
- const token = typeof handle === 'string' ? handle.trim() : ''
93
- if (!isProjectedAttachmentHandle(token)) return { kind: 'not-projected', value: handle }
94
-
95
- const session = agent?.session
96
- const refs = authorizedRefs(agent)
97
- const wanted = token.toLowerCase()
98
- const matches = refs.filter((ref) => {
99
- const id = attachmentIdOf(ref)
100
- return id !== undefined && id.toLowerCase().startsWith(wanted)
101
- })
102
-
103
- if (matches.length === 0) return { kind: 'unknown', handle: token }
104
- if (matches.length > 1) return { kind: 'ambiguous', handle: token }
105
-
106
- const candidate = matches[0]
107
- const canonicalId = attachmentIdOf(candidate)
108
- if (canonicalId === undefined) return { kind: 'unknown', handle: token }
109
-
110
- // The Session event/inbox proves authorization; warm the canonical bounded
111
- // index with that exact Host-owned ref, then require the index to hand it
112
- // back. This keeps all downstream tools on the same recovery/identity seam.
113
- try {
114
- sessionVisionIndex?.recordAttachments?.(session, [candidate])
115
- } catch {
116
- // Authorization is still fail-closed if the bounded cache cannot be warmed.
117
- }
118
- if (!sessionVisionIndex || typeof sessionVisionIndex.lookupAttachment !== 'function') {
119
- return { kind: 'unknown', handle: token }
120
- }
121
- const ref = sessionVisionIndex.lookupAttachment(session, canonicalId)
122
- if (ref === undefined) return { kind: 'unknown', handle: token }
123
- return { kind: 'resolved', handle: token, canonicalId, ref }
99
+ const token = typeof handle === 'string' ? handle.trim() : '';
100
+ if (!isProjectedAttachmentHandle(token))
101
+ return { kind: 'not-projected', value: handle };
102
+ const session = agent?.session;
103
+ const refs = authorizedRefs(agent);
104
+ const wanted = token.toLowerCase();
105
+ const matches = refs.filter((ref) => {
106
+ const id = attachmentIdOf(ref);
107
+ return id !== undefined && id.toLowerCase().startsWith(wanted);
108
+ });
109
+ if (matches.length === 0)
110
+ return { kind: 'unknown', handle: token };
111
+ if (matches.length > 1)
112
+ return { kind: 'ambiguous', handle: token };
113
+ const candidate = matches[0];
114
+ const canonicalId = attachmentIdOf(candidate);
115
+ if (candidate === undefined || canonicalId === undefined)
116
+ return { kind: 'unknown', handle: token };
117
+ // The Session event/inbox proves authorization; warm the canonical bounded
118
+ // index with that exact Host-owned ref, then require the index to hand it
119
+ // back. This keeps all downstream tools on the same recovery/identity seam.
120
+ try {
121
+ sessionVisionIndex?.recordAttachments?.(session, [candidate]);
122
+ }
123
+ catch {
124
+ // Authorization is still fail-closed if the bounded cache cannot be warmed.
125
+ }
126
+ if (!sessionVisionIndex || typeof sessionVisionIndex.lookupAttachment !== 'function') {
127
+ return { kind: 'unknown', handle: token };
128
+ }
129
+ const ref = sessionVisionIndex.lookupAttachment(session, canonicalId);
130
+ if (ref === undefined)
131
+ return { kind: 'unknown', handle: token };
132
+ return { kind: 'resolved', handle: token, canonicalId, ref };
124
133
  }
125
-
126
134
  function canonicalizeValue(value, context) {
127
- if (!isSha256HandleLike(value)) return value
128
- if (isCanonicalSha256Id(value)) return value
129
- if (!isProjectedAttachmentHandle(value)) {
130
- throw new Error(
131
- `${context.toolName}: invalid attachment handle "${String(value).trim()}" ` +
132
- '(sha256 attachment handles must be a canonical id or a DSH-projected 8+ hex prefix)',
133
- )
134
- }
135
- const result = resolveProjectedAttachmentHandle(value, context)
136
- if (result.kind === 'resolved') return result.canonicalId
137
- if (result.kind === 'ambiguous') {
138
- throw new Error(
139
- `${context.toolName}: ambiguous attachment handle "${result.handle}" ` +
140
- '(multiple images in this conversation share that prefix; use a full attachment id or attach the image again)',
141
- )
142
- }
143
- throw new Error(
144
- `${context.toolName}: unknown attachment handle "${result.handle}" ` +
145
- '(it is not authorized by an image in this conversation; attach the image again if needed)',
146
- )
135
+ if (!isSha256HandleLike(value))
136
+ return value;
137
+ if (isCanonicalSha256Id(value))
138
+ return value;
139
+ if (!isProjectedAttachmentHandle(value)) {
140
+ throw new Error(`${context.toolName}: invalid attachment handle "${value.trim()}" ` +
141
+ '(sha256 attachment handles must be a canonical id or a DSH-projected 8+ hex prefix)');
142
+ }
143
+ const result = resolveProjectedAttachmentHandle(value, context);
144
+ if (result.kind === 'resolved')
145
+ return result.canonicalId;
146
+ if (result.kind === 'ambiguous') {
147
+ throw new Error(`${context.toolName}: ambiguous attachment handle "${result.handle}" ` +
148
+ '(multiple images in this conversation share that prefix; use a full attachment id or attach the image again)');
149
+ }
150
+ if (result.kind === 'unknown') {
151
+ throw new Error(`${context.toolName}: unknown attachment handle "${result.handle}" ` +
152
+ '(it is not authorized by an image in this conversation; attach the image again if needed)');
153
+ }
154
+ return value;
147
155
  }
148
-
149
156
  function canonicalizeArgs(args, context, fields) {
150
- if (!isObject(args) || Array.isArray(args)) return args
151
- let next
152
- for (const field of fields.scalars ?? []) {
153
- if (!Object.hasOwn(args, field)) continue
154
- const value = canonicalizeValue(args[field], context)
155
- if (value !== args[field]) {
156
- next ??= { ...args }
157
- next[field] = value
157
+ const record = objectRecord(args);
158
+ if (record === undefined)
159
+ return args;
160
+ let next;
161
+ for (const field of fields.scalars ?? []) {
162
+ if (!Object.hasOwn(record, field))
163
+ continue;
164
+ const value = canonicalizeValue(record[field], context);
165
+ if (value !== record[field]) {
166
+ next ??= { ...record };
167
+ next[field] = value;
168
+ }
158
169
  }
159
- }
160
- for (const field of fields.arrays ?? []) {
161
- const values = args[field]
162
- if (!Array.isArray(values)) continue
163
- let changed = false
164
- const mapped = values.map((value) => {
165
- const canonical = canonicalizeValue(value, context)
166
- if (canonical !== value) changed = true
167
- return canonical
168
- })
169
- if (changed) {
170
- next ??= { ...args }
171
- next[field] = mapped
170
+ for (const field of fields.arrays ?? []) {
171
+ const values = record[field];
172
+ if (!Array.isArray(values))
173
+ continue;
174
+ let changed = false;
175
+ const mapped = values.map((value) => {
176
+ const canonical = canonicalizeValue(value, context);
177
+ if (canonical !== value)
178
+ changed = true;
179
+ return canonical;
180
+ });
181
+ if (changed) {
182
+ next ??= { ...record };
183
+ next[field] = mapped;
184
+ }
172
185
  }
173
- }
174
- return next ?? args
186
+ return next ?? args;
175
187
  }
176
-
177
188
  /**
178
189
  * Canonicalize only image-source arguments of Vision Router tools. Ordinary
179
190
  * prose fields are never scanned, so a user mentioning `sha256:deadbeef` in a
@@ -182,19 +193,28 @@ function canonicalizeArgs(args, context, fields) {
182
193
  * fails closed rather than falling through to cwd-relative filesystem lookup.
183
194
  */
184
195
  export function wrapVisionAttachmentHandleDefinition(def, options = {}) {
185
- if (!def || typeof def !== 'object' || typeof def.execute !== 'function') return def
186
- const fields = TOOL_FIELDS[def.name]
187
- if (!fields || !options.sessionVisionIndex) return def
188
- const execute = def.execute
189
- return {
190
- ...def,
191
- execute(args, exec) {
192
- const nextArgs = canonicalizeArgs(args, {
193
- sessionVisionIndex: options.sessionVisionIndex,
194
- agent: exec?.agent,
195
- toolName: def.name,
196
- }, fields)
197
- return execute.call(def, nextArgs, exec)
198
- },
199
- }
196
+ // Tool definitions arrive from the Host registry at runtime. Static generic
197
+ // constraints cannot replace this boundary guard: partial/legacy Hosts may
198
+ // still pass nullish or malformed values before their own validation runs.
199
+ if (def === null || typeof def !== 'object' || typeof def.execute !== 'function')
200
+ return def;
201
+ const toolName = def.name;
202
+ const fields = typeof toolName === 'string'
203
+ ? TOOL_FIELDS[toolName]
204
+ : undefined;
205
+ const sessionVisionIndex = options.sessionVisionIndex;
206
+ if (!fields || !sessionVisionIndex || typeof toolName !== 'string')
207
+ return def;
208
+ const execute = def.execute;
209
+ return {
210
+ ...def,
211
+ execute(args, exec) {
212
+ const nextArgs = canonicalizeArgs(args, {
213
+ sessionVisionIndex,
214
+ ...(exec?.agent === undefined ? {} : { agent: exec.agent }),
215
+ toolName,
216
+ }, fields);
217
+ return execute.call(def, nextArgs, exec);
218
+ },
219
+ };
200
220
  }