dsh-vision-router 2.0.0 → 2.1.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.
Files changed (92) hide show
  1. package/README.md +33 -16
  2. package/README.zh.md +34 -17
  3. package/cordis.patch.yml +19 -9
  4. package/docs/architecture/2x-contract-ledger.md +165 -0
  5. package/docs/architecture/compat-inventory.md +121 -0
  6. package/docs/architecture/dsh-compatibility-matrix.md +57 -0
  7. package/docs/architecture/dsh-support-window.md +50 -0
  8. package/docs/architecture/final-closure-audit.md +148 -0
  9. package/docs/architecture/p2-jobs-feasibility.md +94 -0
  10. package/docs/architecture/p3-compat-retirement.md +46 -0
  11. package/docs/architecture/p3-host-native-seams.md +43 -0
  12. package/docs/architecture/p3-native-recovery-evaluation.md +65 -0
  13. package/docs/architecture/runtime-boundaries.md +124 -0
  14. package/docs/releases/v2.0.1.md +17 -0
  15. package/docs/releases/v2.1.0-support-window.md +29 -0
  16. package/docs/releases/v2.1.0.md +83 -0
  17. package/entry.js +13 -307
  18. package/index.js +85 -352
  19. package/lib/abort-signal-compat.js +191 -0
  20. package/lib/adapter-update-coalescer.js +113 -29
  21. package/lib/adversarial-hardening.js +24 -9
  22. package/lib/artifact-boundary.js +16 -175
  23. package/lib/artifact-io.js +220 -0
  24. package/lib/artifact-retention.js +305 -53
  25. package/lib/catalog-corrections.js +6 -1
  26. package/lib/client-host-compat-prelude.js +249 -0
  27. package/lib/client-presentation-boundary.js +61 -3
  28. package/lib/client.js +71 -72
  29. package/lib/core-vision-surface.js +124 -0
  30. package/lib/depth-guidance.js +114 -90
  31. package/lib/doctor-cli-p0.js +168 -0
  32. package/lib/doctor-cli.js +7 -0
  33. package/lib/doctor-vision-limits.js +72 -0
  34. package/lib/dsh-contract-compat.js +207 -39
  35. package/lib/dsh-host-capabilities.js +107 -0
  36. package/lib/dsh-support-window.js +48 -0
  37. package/lib/http-compat.js +22 -6
  38. package/lib/image-resource-governor.js +35 -10
  39. package/lib/legacy-core-vision-policy-bridge.js +25 -226
  40. package/lib/legacy-global-proxy-boundary.js +127 -0
  41. package/lib/live-model-client-prelude.js +0 -12
  42. package/lib/mixed-router.js +81 -81
  43. package/lib/public-entry.js +42 -4
  44. package/lib/runtime-composition.js +348 -0
  45. package/lib/runtime-i18n-boundary.js +683 -0
  46. package/lib/runtime-i18n-core-scope.js +56 -0
  47. package/lib/runtime-i18n-core.js +34 -0
  48. package/lib/runtime-i18n.js +121 -0
  49. package/lib/session-surface-policy.js +97 -0
  50. package/lib/session-vision-index.js +282 -0
  51. package/lib/session-vision-runtime.js +48 -0
  52. package/lib/settings-factory-lifecycle.js +144 -0
  53. package/lib/settings-ia-client-prelude.js +74 -2
  54. package/lib/settings-limit-client-prelude.js +110 -0
  55. package/lib/settings-native-card-layout.js +297 -0
  56. package/lib/settings-number-contract.js +29 -0
  57. package/lib/structured-flow-hardening.js +207 -76
  58. package/lib/tesseract-exec-compat.js +58 -32
  59. package/lib/update-check.js +17 -5
  60. package/lib/v2-settings-ia-integration.js +9 -4
  61. package/lib/vision-artifact-store.js +77 -0
  62. package/lib/vision-backend-runtime-policy.js +6 -2
  63. package/lib/vision-background-benchmark.js +130 -41
  64. package/lib/vision-background-failure-policy.js +70 -0
  65. package/lib/vision-background-stop-store.js +54 -9
  66. package/lib/vision-capability-benchmark-client.js +57 -66
  67. package/lib/vision-capability-benchmark-presentation.js +97 -0
  68. package/lib/vision-capability-benchmark-service.js +3 -3
  69. package/lib/vision-execution-order-apply.js +61 -0
  70. package/lib/vision-execution-order-plan.js +64 -0
  71. package/lib/vision-execution-order.js +38 -0
  72. package/lib/vision-limit-diagnostics.js +202 -0
  73. package/lib/vision-product-presentation.js +284 -0
  74. package/lib/vision-provider-transport.js +197 -0
  75. package/lib/vision-resilience.js +87 -45
  76. package/lib/vision-routing-evidence.js +366 -0
  77. package/lib/vision-routing-runtime.js +278 -0
  78. package/lib/vision-tool-runtime-boundary.js +27 -21
  79. package/lib/vision-turn-budget-client-prelude.js +9 -9
  80. package/lib/web/benchmark-panel.js +6 -0
  81. package/lib/web/diagnostics-panel.js +44 -0
  82. package/lib/web/index.js +60 -0
  83. package/lib/web/model-picker.js +22 -0
  84. package/lib/web/onboarding.js +6 -0
  85. package/lib/web/product-state.js +59 -0
  86. package/lib/web/remote-settings-client.js +8 -0
  87. package/lib/web/routing-section.js +6 -0
  88. package/lib/web/settings-controller.js +6 -0
  89. package/lib/web-capability-boundary.js +65 -9
  90. package/lib/windows-screenshot-dpi-compat.js +148 -0
  91. package/package.json +14 -6
  92. package/lib/vision-capability-shadow.js +0 -576
package/index.js CHANGED
@@ -16,6 +16,19 @@
16
16
  // process fetch to route only the `proxyHosts` domains through it; everything
17
17
  // else (DeepSeek and the rest) stays on the direct connection.
18
18
 
19
+ // Compatibility shim: dsh 0.1.2-alpha.4 removed `session.events` in favor of
20
+ // `session.snapshotEvents()`. This helper returns an array (or undefined) that
21
+ // works on both old and new harness versions.
22
+ function getSessionEvents(session) {
23
+ if (!session) return undefined
24
+ // alpha.4+ : snapshotEvents() returns a frozen array of the event log
25
+ if (typeof session.snapshotEvents === 'function') {
26
+ try { return session.snapshotEvents() } catch { return undefined }
27
+ }
28
+ // pre-alpha.4 fallback
29
+ try { return session.events } catch { return undefined }
30
+ }
31
+
19
32
  export * from './lib/vision-resilience.js'
20
33
 
21
34
  import z from '@deepseek-ai/schemastery'
@@ -54,6 +67,8 @@ import {
54
67
  VISION_FAILURE_KINDS,
55
68
  VISION_RESULT_CODES,
56
69
  } from './lib/vision-resilience.js'
70
+ import { currentVisionExecutionOrder } from './lib/vision-execution-order.js'
71
+ import { applyVisionExecutionOrder } from './lib/vision-execution-order-apply.js'
57
72
  import { createHash, randomBytes } from 'node:crypto'
58
73
  import {
59
74
  normalizeStructuredBootstrapResult,
@@ -61,7 +76,7 @@ import {
61
76
  structuredBootstrapQuestion,
62
77
  } from './lib/structured-bootstrap.js'
63
78
  import { planMixedBranches, renderMixedGuidance } from './lib/mixed-router.js'
64
- import { depthLimitFor, renderDepthGuidance } from './lib/depth-guidance.js'
79
+ import { renderDepthGuidance } from './lib/depth-guidance.js'
65
80
  import { assertNoRepetitionLoop } from './lib/repetition-guard.js'
66
81
  import { compareRgbaStreams } from './lib/pixel-diff-stream.js'
67
82
  import {
@@ -71,6 +86,7 @@ import {
71
86
  scaleBox,
72
87
  scaledDimensions,
73
88
  } from './lib/image-resource-governor.js'
89
+ import { createSessionVisionIndex } from './lib/session-vision-index.js'
74
90
  import { createSessionVisionStateStore } from './lib/session-vision-state.js'
75
91
  import {
76
92
  ERROR_RESPONSE_MAX_BYTES,
@@ -286,11 +302,9 @@ export const Config = z.object({
286
302
  // evidence/deepening vision-tool call before answering (x >= 1). Off by
287
303
  // default because it adds at least two visual/tool calls to image turns.
288
304
  structuredVisionBootstrap: z.boolean().default(false),
289
- // 看图深度档位(移植自 dsh-vision 的 PRECISION 档位概念):
290
- // fast = 本轮视觉调用上限 1 次(快速);standard = 上限 2 次(bootstrap+1,
291
- // 与现状等价);deep = 上限 3-4 次(完整证据链)。档位只定「深度上限」,
292
- // 模型在档位内按用户问题自选工具与轮次(保留 x 的自由度)。默认 standard
293
- // = 现状行为逐字节不变。与场景路由正交:场景管出口、档位管深度。
305
+ // 看图深度档位只决定查证策略,不隐式限制调用次数:fast 整体优先,
306
+ // standard 围绕问题按需查证,deep 主动检查局部并交叉验证。独立的
307
+ // visionDepthMaxCalls 安全阀由 structured-flow hardening 统一执行。
294
308
  visionDepth: z.union(['fast', 'standard', 'deep']).default('standard'),
295
309
  // 引导文案覆盖(引导表可配置化):kind = visual_kind(code/document/ui/chat)
296
310
  // 或 content_kind(person/animal/…/meme),text = 覆盖引导文案。
@@ -448,11 +462,11 @@ export function mediaTypeOf(path) {
448
462
  }
449
463
 
450
464
  /**
451
- * 看图深度档位(移植自 dsh-vision 的 PRECISION 概念):档位定「深挖轮数
452
- * 上限」,bootstrap 那一遍不计入。fast=1、deep=4、standard=不硬拦(现状
453
- * 行为,仅提示词引导)。undefined = 不设硬上限。
465
+ * 兼容导出:depthLimitFor 仍保留给历史直接 index.js 使用者。当前 fast /
466
+ * standard / deep 只选择查证策略;只有显式 visionDepthMaxCalls > 0 时才
467
+ * 返回独立调用上限,0 / 未设置表示不限。
454
468
  */
455
- export { depthLimitFor }
469
+ export { depthLimitFor } from './lib/depth-guidance.js'
456
470
 
457
471
 
458
472
  /**
@@ -3058,7 +3072,7 @@ export function isOpenAIHttpBridgeTransport(transport) {
3058
3072
  }
3059
3073
  }
3060
3074
 
3061
- export function apply(ctx, config = {}) {
3075
+ export function apply(ctx, config = {}, runtime = {}) {
3062
3076
  // Route sharp version diagnostics (issue #75) through the harness logger
3063
3077
  // instead of console.warn, so the warning lands in the server log.
3064
3078
  registerSharpWarningHook((message) => {
@@ -3067,18 +3081,41 @@ export function apply(ctx, config = {}) {
3067
3081
  // Live configuration: composition entry at boot, then the resolved settings
3068
3082
  // section once the settings service mounts (installSettingsSection below).
3069
3083
  let current = () => config
3084
+ const coreVisionSurfaceRuntime = runtime?.coreVisionSurface
3085
+ const coreVisionFlag = (name, fallback) => {
3086
+ if (
3087
+ coreVisionSurfaceRuntime &&
3088
+ typeof coreVisionSurfaceRuntime.current === 'function'
3089
+ ) {
3090
+ const surface = coreVisionSurfaceRuntime.current()
3091
+ if (surface && typeof surface[name] === 'boolean') return surface[name]
3092
+ }
3093
+ return fallback()
3094
+ }
3070
3095
  const pairs = () => providersOf(current())
3071
3096
  // #208: cross-turn visual knowledge belongs to a bounded session owner,
3072
3097
  // not to the plugin process. The compatibility facade is used only at
3073
3098
  // adapter boundaries that do not expose a Session; ambiguous attachment ids
3074
3099
  // deliberately miss instead of crossing conversations.
3075
- const visionState = createSessionVisionStateStore({
3100
+ const sessionVisionRuntime = runtime?.sessionVision
3101
+ const visionState = sessionVisionRuntime?.stateStore ?? createSessionVisionStateStore({
3076
3102
  maxSessions: 64,
3077
3103
  idleTtlMs: 60 * 60 * 1000,
3078
3104
  descriptionMaxEntries: 64,
3079
3105
  descriptionMaxChars: 256 * 1024,
3080
3106
  attachmentMaxEntries: 256,
3081
3107
  })
3108
+ const sessionVisionIndex = sessionVisionRuntime?.index ?? createSessionVisionIndex({
3109
+ stateStore: visionState,
3110
+ core: {
3111
+ collectEventAttachmentRefs,
3112
+ rewriteImageBlocks,
3113
+ planToolResultImageShadows,
3114
+ planGuardStopShadows,
3115
+ },
3116
+ config: () => current(),
3117
+ logger: ctx.logger,
3118
+ })
3082
3119
  const imageMemory = visionState.descriptionFacade
3083
3120
  // #208 follow-up complete: session-visible paths use scoped memory; only
3084
3121
  // session-less adapter boundaries use the ambiguity-safe facade.
@@ -3123,8 +3160,20 @@ export function apply(ctx, config = {}) {
3123
3160
  text && typeof text.model === 'string' && text.model !== '' ? text.model : 'deepseek-v4-pro',
3124
3161
  }
3125
3162
  }
3126
- const toolEnabled = () => current().tool !== false
3127
- const structuredBootstrapEnabled = () => current().structuredVisionBootstrap === true
3163
+ const toolEnabled = () =>
3164
+ coreVisionFlag('toolAvailable', () => current().tool !== false)
3165
+ const structuredBootstrapEnabled = () =>
3166
+ coreVisionFlag(
3167
+ 'structuredBootstrap',
3168
+ () => current().structuredVisionBootstrap === true,
3169
+ )
3170
+ const instantDescribeEnabled = () =>
3171
+ coreVisionFlag('instantDescribe', () => current().instantDescribe === true)
3172
+ const autoActivateOnImageEnabled = () =>
3173
+ coreVisionFlag(
3174
+ 'autoActivateOnImage',
3175
+ () => current().autoActivateOnImage !== false,
3176
+ )
3128
3177
  const visionDepth = () => (current().visionDepth === 'fast' || current().visionDepth === 'deep' ? current().visionDepth : 'standard')
3129
3178
  // 档位提示(注入 bootstrapReminder / followupReminder):
3130
3179
  // - bootstrapReminder(bootstrap 执行前,visual_kind 未知):只给档位句
@@ -3140,7 +3189,8 @@ export function apply(ctx, config = {}) {
3140
3189
  // Per-session turn gate: pass 1 is the actual universal structured visual call.
3141
3190
  // The gate opens only after vision_bootstrap has completed that visual request.
3142
3191
  const structuredBootstrapTurnState = new WeakMap()
3143
- const rewriteEnabled = () => current().rewriteImages !== false
3192
+ const rewriteEnabled = () =>
3193
+ coreVisionFlag('rewriteEnabled', () => current().rewriteImages !== false)
3144
3194
  const downscaleEnabled = () => current().downscale !== false
3145
3195
  const downscaleMaxPixels = () => {
3146
3196
  const value = current().downscaleMaxPixels
@@ -3165,7 +3215,7 @@ export function apply(ctx, config = {}) {
3165
3215
  // The main 1+x structured bootstrap owns the first visual pass.
3166
3216
  // Never stack instantDescribe in front of it (that would silently become 2+x).
3167
3217
  const instantLocalProvider = () =>
3168
- current().instantDescribe === true && !structuredBootstrapEnabled()
3218
+ instantDescribeEnabled() && !structuredBootstrapEnabled()
3169
3219
  ? localProvidersOf(current())
3170
3220
  : undefined
3171
3221
  const instantLocalStyle = () =>
@@ -3199,7 +3249,7 @@ export function apply(ctx, config = {}) {
3199
3249
  // turn/start event), so tool-side memory and pre-step bindings agree.
3200
3250
  const turnNumberOf = (session) => {
3201
3251
  try {
3202
- const events = session && session.events
3252
+ const events = getSessionEvents(session)
3203
3253
  if (!Array.isArray(events)) return 0
3204
3254
  const last = events.findLast((event) => event && event.type === 'turn/start')
3205
3255
  return last && Number.isInteger(last.data && last.data.turn) ? last.data.turn : 0
@@ -3464,12 +3514,13 @@ export function apply(ctx, config = {}) {
3464
3514
  (pair) => pair && pair.provider === HTTP_ROUTE && availableHttp.has(pair.model),
3465
3515
  )
3466
3516
  const seen = new Set()
3467
- return [...native, ...local, ...http].filter((pair) => {
3517
+ const base = [...native, ...local, ...http].filter((pair) => {
3468
3518
  const key = `${pair.provider}/${pair.model}`
3469
3519
  if (seen.has(key)) return false
3470
3520
  seen.add(key)
3471
3521
  return true
3472
3522
  })
3523
+ return applyVisionExecutionOrder(base, currentVisionExecutionOrder())
3473
3524
  }
3474
3525
 
3475
3526
  const routingPairWeight = (pair, entriesById) => {
@@ -4330,7 +4381,7 @@ export function apply(ctx, config = {}) {
4330
4381
  if (capability && capability.attemptable !== false && capability.image) add(provider, model)
4331
4382
  }
4332
4383
  }
4333
- return out
4384
+ return applyVisionExecutionOrder(out, currentVisionExecutionOrder())
4334
4385
  }
4335
4386
 
4336
4387
  // ── vision chain route: fallback under our own control ─────────────────────
@@ -4726,284 +4777,16 @@ export function apply(ctx, config = {}) {
4726
4777
  }, 'vision-router: proxy fetch')
4727
4778
  }
4728
4779
 
4729
- const recordUploadedAttachments = (session, attachments) => {
4730
- visionState.recordAttachments(session, attachments)
4731
- }
4732
-
4733
- /**
4734
- * Index image attachments recorded anywhere in the session event log, not
4735
- * just in the inbox-claim message stream `agent/pre-step` hands us
4736
- * (issue #72). Host-produced images (the built-in `read_image` tool's
4737
- * re-uploads, persisted as `tool/result` events) never enter that stream,
4738
- * so the harness-announced attachment id stayed unresolvable even though
4739
- * the UI showed the image and the bytes are durably stored. `session.events`
4740
- * is the complete append-only log (seeded from storage on resume), so
4741
- * scanning it — incrementally, per session id — finds every ref with full
4742
- * metadata for a later `attachments.readImage(ref)`.
4743
- */
4744
- const scanSessionEventLog = (session) => {
4745
- if (!session) return
4746
- let events
4747
- try {
4748
- events = session.events
4749
- } catch {
4750
- return // not a host Session (or the getter is unavailable): nothing to scan
4751
- }
4752
- if (!Array.isArray(events) || events.length === 0) return
4753
- const last = visionState.getScannedEventSeq(session)
4754
- if (last >= events.length) return
4755
- const refs = collectEventAttachmentRefs(events.slice(last))
4756
- visionState.setScannedEventSeq(session, events.length)
4757
- if (refs.length > 0) recordUploadedAttachments(session, refs)
4758
- }
4759
-
4760
- const lookupAttachment = (session, id) => {
4761
- let hit = visionState.lookupAttachment(session, id)
4762
- if (hit !== undefined) return hit
4763
- // Cache eviction is a performance event, never a correctness event. First
4764
- // consume any newly appended log entries; if the requested ref was older
4765
- // than the bounded working set, perform a target-only recovery from the
4766
- // durable session log instead of rebuilding an unbounded index.
4767
- if (session !== undefined) {
4768
- scanSessionEventLog(session)
4769
- hit = visionState.lookupAttachment(session, id)
4770
- if (hit !== undefined) return hit
4771
- let events
4772
- try {
4773
- events = session.events
4774
- } catch {
4775
- events = undefined
4776
- }
4777
- if (Array.isArray(events) && events.length > 0) {
4778
- const wanted = String(id)
4779
- const recovered = collectEventAttachmentRefs(events).find(
4780
- (ref) => ref && String(ref.attachmentId) === wanted,
4781
- )
4782
- if (recovered !== undefined) {
4783
- visionState.recordAttachments(session, [recovered])
4784
- return recovered
4785
- }
4786
- }
4787
- }
4788
- return undefined
4789
- }
4790
-
4791
- // ── issue #74: shadow-sanitize tool-result image blocks on the surface ────
4792
- //
4793
- // A tool result that renders an image block (vision_present, or the host
4794
- // read_image on image-capable routes) is persisted as a durable
4795
- // `tool/result` event and then flows into EVERY later request through
4796
- // `Session.deriveMessages()`. A text-only adapter (DeepSeek native, pi-ai
4797
- // text routes) rejects nested images with UNSUPPORTED_CONTENT and the
4798
- // session is locked forever. The pre-step inbox sanitizer cannot see these
4799
- // historical events, so the surface itself is rewritten instead: each
4800
- // offending event is shadowed by a sanitized replacement event
4801
- // (`surfaceOp: {op:'replace'}`, the same mechanism the host compaction
4802
- // pruner uses). The Web UI transcript renders append-origin events, so the
4803
- // user still sees the image; only the model-visible surface is sanitized.
4804
- // The decision is route-aware: an image-capable route legitimately consumes
4805
- // read_image's result image, mirroring the host's own gate
4806
- // (`assertImageCapableRoute` in dsh-tool-fs).
4807
-
4808
- // session -> { count: nodes examined, done: Set<seqs already decided> }
4809
- const sessionSurfaceScans = new WeakMap()
4810
-
4811
- const sessionRouteHandlesImages = async (session) => {
4812
- let provider
4813
- let model
4814
- try {
4815
- const header = typeof session.requestHeader === 'function' ? session.requestHeader() : undefined
4816
- provider = header && header.config ? header.config.provider : undefined
4817
- model = header && header.config ? header.config.model : undefined
4818
- } catch {
4819
- return false
4820
- }
4821
- if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') {
4822
- return false
4823
- }
4824
- // Plugin-owned routes handle image blocks at their stream boundary: the
4825
- // wrapper and the provider twins rewrite them into markers/descriptions,
4826
- // the stealth adapter does the same, and the vision-chain route serves
4827
- // image-capable models.
4828
- if (provider === wrapperRoute() || provider === chainRoute() || provider.endsWith('-vision')) {
4829
- return true
4830
- }
4831
- if (stealthActive && provider === 'deepseek-official') return true
4832
- // Routing mode reverse-routes text-only turns back to the text provider;
4833
- // when neither the wrapper nor the stealth adapter is there to rewrite
4834
- // images, the reverse target is the bare text adapter, which rejects
4835
- // tool-result images. Sanitize so text turns stay usable.
4836
- if (routingEnabled() && reverseRoutingEnabled() && !wrapperRegistered && !stealthActive) {
4837
- return false
4838
- }
4839
- // Same probe the host uses to gate read_image: only routes whose models
4840
- // declare image input may keep tool-result images.
4841
- try {
4842
- const info = await ctx.llm.resolveModelInfo(provider, model)
4843
- return Array.isArray(info && info.inputModalities) && info.inputModalities.includes('image')
4844
- } catch {
4845
- return false // unknown route: fail safe and sanitize
4846
- }
4847
- }
4848
-
4849
- const sanitizeSessionToolResults = async (session) => {
4850
- if (!session) return
4851
- let events
4852
- let nodes
4853
- try {
4854
- events = session.events
4855
- nodes = session.surface && session.surface.nodes
4856
- } catch {
4857
- return // not a host Session: nothing to sanitize
4858
- }
4859
- if (!Array.isArray(events) || !Array.isArray(nodes) || nodes.length === 0) return
4860
- let scan = sessionSurfaceScans.get(session)
4861
- if (!scan) {
4862
- scan = { count: 0, done: new Set() }
4863
- sessionSurfaceScans.set(session, scan)
4864
- }
4865
- // Compaction replaces the surface wholesale; a shrunk node list means the
4866
- // positional cursor is stale, so restart from the head. Kept decisions are
4867
- // memoized in `done`, so a restart is a cheap no-op for examined events.
4868
- if (nodes.length < scan.count) {
4869
- scan.count = 0
4870
- scan.done = new Set()
4871
- }
4872
- if (nodes.length === scan.count) return
4873
- let routeHandlesImages
4874
- const newSeqs = nodes.slice(scan.count)
4875
- for (const seq of newSeqs) {
4876
- const event = events[seq]
4877
- if (!event || event.type !== 'tool/result' || scan.done.has(seq)) continue
4878
- const message = event.data && event.data.message
4879
- if (!message || !Array.isArray(message.content) || !blocksHaveImage(message.content)) {
4880
- scan.done.add(seq)
4881
- continue
4882
- }
4883
- if (routeHandlesImages === undefined) {
4884
- routeHandlesImages = await sessionRouteHandlesImages(session)
4885
- }
4886
- if (routeHandlesImages) {
4887
- // Image-capable route: keep the image (read_image's result is the
4888
- // model's view of the file). The wrapper/twin/stealth routes rewrite
4889
- // it at stream time anyway.
4890
- scan.done.add(seq)
4891
- continue
4892
- }
4893
- const sanitized = sanitizeToolResultMessage(message)
4894
- if (sanitized === message) {
4895
- scan.done.add(seq)
4896
- continue
4897
- }
4898
- try {
4899
- session.append(
4900
- 'tool/result',
4901
- { ...event.data, message: sanitized },
4902
- {
4903
- surfaceOp: { op: 'replace', start: seq, end: seq },
4904
- sourceEventSeqs: [seq],
4905
- },
4906
- )
4907
- scan.done.add(seq)
4908
- ctx.logger?.info(
4909
- 'vision-router: sanitized a tool-result image block out of the model surface (event seq %s)',
4910
- seq,
4911
- )
4912
- } catch (error) {
4913
- // A failed shadow leaves the original event on the surface: the
4914
- // session stays usable (today's behavior) instead of crashing the
4915
- // pre-step.
4916
- ctx.logger?.warn(
4917
- 'vision-router: could not sanitize tool-result image at event seq %s (%s)',
4918
- seq,
4919
- error && error.message ? error.message : String(error),
4920
- )
4921
- }
4922
- }
4923
- scan.count += newSeqs.length
4924
- }
4925
-
4926
- /**
4927
- * Shadow-sanitize persisted guard-stop messages off the model surface.
4928
- *
4929
- * Guard-stop orders (turn-budget / depth-quota exhausted) were injected as
4930
- * `user/message` events and persisted into session history. `agent/pre-step`
4931
- * only sees the inbox claim — never the historical surface — so a pre-step
4932
- * rewrite cannot catch them before `Session.deriveMessages()` feeds history
4933
- * to the adapter. A persisted guard-stop is then replayed on EVERY later
4934
- * turn as a standing "never call vision tools again" order, even though the
4935
- * per-turn budget/depth quota resets every turn: the first image in a
4936
- * session is recognized, but every later image answers "本轮视觉总时间预算
4937
- * 已耗尽…" without calling any vision tool.
4938
- *
4939
- * This uses the same harness surface-shadow mechanism as the tool-result
4940
- * image sanitizer above: replace the surface node with an inert note via
4941
- * `surfaceOp: {op:'replace'}` + `sourceEventSeqs`, so the human transcript
4942
- * keeps the original while every later `deriveMessages()` projection sees
4943
- * the replacement. Durable, replayable, survives session resume.
4944
- */
4945
- const guardStopSurfaceScans = new WeakMap()
4946
- const sanitizeSessionGuardStops = async (session) => {
4947
- if (!session) return
4948
- let events
4949
- let nodes
4950
- try {
4951
- events = session.events
4952
- nodes = session.surface && session.surface.nodes
4953
- } catch {
4954
- return // not a host Session: nothing to sanitize
4955
- }
4956
- if (!Array.isArray(events) || !Array.isArray(nodes) || nodes.length === 0) return
4957
- let scan = guardStopSurfaceScans.get(session)
4958
- if (!scan) {
4959
- scan = { count: 0, done: new Set() }
4960
- guardStopSurfaceScans.set(session, scan)
4961
- }
4962
- // Compaction replaces the surface wholesale; a shrunk node list means the
4963
- // positional cursor is stale, so restart from the head (same as the
4964
- // tool-result sanitizer above).
4965
- if (nodes.length < scan.count) {
4966
- scan.count = 0
4967
- scan.done = new Set()
4968
- }
4969
- if (nodes.length === scan.count) return
4970
- const newSeqs = nodes.slice(scan.count)
4971
- const plans = planGuardStopShadows(events, newSeqs)
4972
- for (const plan of plans) {
4973
- if (scan.done.has(plan.seq)) continue
4974
- scan.done.add(plan.seq)
4975
- try {
4976
- session.append(
4977
- 'user/message',
4978
- plan.data,
4979
- {
4980
- surfaceOp: { op: 'replace', start: plan.seq, end: plan.seq },
4981
- sourceEventSeqs: [plan.seq],
4982
- },
4983
- )
4984
- ctx.logger?.info(
4985
- 'vision-router: shadowed a persisted guard-stop message off the model surface (event seq %s)',
4986
- plan.seq,
4987
- )
4988
- } catch (error) {
4989
- // A failed shadow leaves the original event on the surface: the
4990
- // session stays usable (today's behavior) instead of crashing the
4991
- // pre-step.
4992
- ctx.logger?.warn(
4993
- 'vision-router: could not shadow guard-stop at event seq %s (%s)',
4994
- plan.seq,
4995
- error && error.message ? error.message : String(error),
4996
- )
4997
- }
4998
- }
4999
- scan.count += newSeqs.length
5000
- }
4780
+ const lookupAttachment = (session, id) => sessionVisionIndex.lookupAttachment(session, id)
5001
4781
 
5002
4782
  // session -> { turn, startIndex, hasImage, routed, failures, lastError }
5003
4783
  const turnState = new WeakMap()
5004
4784
 
5005
4785
  ctx.on('agent/pre-step', async (payload, next) => {
5006
- const decision = await next()
4786
+ let decision = await next()
4787
+ if (sessionVisionRuntime?.index === undefined) {
4788
+ decision = await sessionVisionIndex.prepareDecision(payload, decision)
4789
+ }
5007
4790
  if (decision && decision.kind === 'reject') return decision
5008
4791
  const session = payload.agent && payload.agent.session
5009
4792
  if (!session) return decision
@@ -5027,53 +4810,26 @@ export function apply(ctx, config = {}) {
5027
4810
  if (hasImage) {
5028
4811
  ctx.logger.info(
5029
4812
  'vision-router: image turn — instantDescribe=%s localBackends=%s',
5030
- current().instantDescribe === true ? 'on' : 'off',
4813
+ instantDescribeEnabled() ? 'on' : 'off',
5031
4814
  localProvidersOf(current())
5032
4815
  .map((p) => p.name)
5033
4816
  .join(',') || 'none',
5034
4817
  )
5035
4818
  }
5036
4819
  }
5037
- // Record every raw image reference before nested tool-result images are
5038
- // sanitized. This preserves attachment lookup for a later vision_describe.
5039
- const rawImageRefs = rewriteImageBlocks(rawMessages)
5040
- recordUploadedAttachments(session, rawImageRefs.attachments)
5041
- // Also index image attachments that live only in the session event log
5042
- // (read_image re-uploads never cross the inbox-claim message stream).
5043
- // Incremental: scans only new events (issue #72).
5044
- scanSessionEventLog(session)
5045
4820
  // Hard invariant: tool-produced image blocks never reach a model request.
5046
- // Two layers: (1) sanitize tool-result images in the inbox claim, and
5047
- // (2) shadow-sanitize historical tool/result events on the session
5048
- // surface (issue #74) — the only lever that can rewrite durable history.
4821
+ // SessionVisionIndex owns durable-log indexing and historical surface
4822
+ // repair; Core retains only the current inbox sanitizer.
5049
4823
  const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
5050
4824
  const messages = sanitizedToolResults.messages
5051
4825
  const hasImage = messages.some((message) => blocksHaveImage(message && message.content))
5052
- try {
5053
- await sanitizeSessionToolResults(session)
5054
- } catch (error) {
5055
- ctx.logger?.warn(
5056
- 'vision-router: session-surface sanitization failed (%s)',
5057
- error && error.message ? error.message : String(error),
5058
- )
5059
- }
5060
- // Guard-stop leftovers are persisted user messages, so they can only be
5061
- // removed from the model surface through the same shadow mechanism as
5062
- // tool-result images (issue #74-style surface rewrite).
5063
- try {
5064
- await sanitizeSessionGuardStops(session)
5065
- } catch (error) {
5066
- ctx.logger?.warn(
5067
- 'vision-router: guard-stop surface sanitization failed (%s)',
5068
- error && error.message ? error.message : String(error),
5069
- )
5070
- }
4826
+
5071
4827
  // Register the turn state BEFORE the image-turn branches below: those
5072
4828
  // branches return early (auto-mount reminder, history rewrite), and the
5073
4829
  // agent/request hook must still see the state, otherwise an image turn is
5074
4830
  // served by the text provider and rejected (issue #74, second root cause).
5075
4831
  if (routingEnabled()) {
5076
- const events = session.events ?? []
4832
+ const events = getSessionEvents(session) ?? []
5077
4833
  turnState.set(session, {
5078
4834
  turn: payload.turn,
5079
4835
  startIndex: events.length,
@@ -5166,7 +4922,7 @@ export function apply(ctx, config = {}) {
5166
4922
  // 都先尝试本地识别并把结果写入 imageMemory:后续 rewriteHistoryImages
5167
4923
  // / wrapper 改写时缓存命中,模型第一轮即"看懂"。失败(无本地后端 /
5168
4924
  // 连接失败 / 超时)静默回退原有标记,绝不阻塞图片轮。
5169
- if (rewriteEnabled() && !routingEnabled() && current().instantDescribe === true) {
4925
+ if (rewriteEnabled() && !routingEnabled() && instantDescribeEnabled()) {
5170
4926
  const localProviders = instantLocalProvider()
5171
4927
  if (localProviders !== undefined && localProviders.length > 0) {
5172
4928
  try {
@@ -5193,7 +4949,7 @@ export function apply(ctx, config = {}) {
5193
4949
  }
5194
4950
  // Auto-mount the deep vision tools on image turns: the model can use
5195
4951
  // them from its very first step without the user asking for them.
5196
- if (toolEnabled() && current().autoActivateOnImage !== false) {
4952
+ if (toolEnabled() && autoActivateOnImageEnabled()) {
5197
4953
  const outcome = activateDeepTools()
5198
4954
  if (!autoMountNotified && outcome.includes('已挂载')) {
5199
4955
  autoMountNotified = true
@@ -5287,7 +5043,7 @@ export function apply(ctx, config = {}) {
5287
5043
  const state = turnState.get(session)
5288
5044
  if (!state || state.turn !== payload.turn) return config0
5289
5045
  if (!state.hasImage) {
5290
- const events = session.events ?? []
5046
+ const events = getSessionEvents(session) ?? []
5291
5047
  for (let i = state.startIndex; i < events.length; i++) {
5292
5048
  if (eventHasImage(events[i])) {
5293
5049
  state.hasImage = true
@@ -7373,30 +7129,8 @@ ctx.logger?.info(
7373
7129
  : 'call vision_bootstrap and wait for its universal structured visual result before any other vision tool',
7374
7130
  })
7375
7131
  }
7376
- // 档位深度上限:fast/deep 硬拦、standard 不拦(现状行为)。
7377
- // bootstrap 那 1 遍不计入;只数 evidence 深挖工具(structuredFollowupEvidenceTools)。
7378
- if (
7379
- structuredBootstrapEnabled() &&
7380
- state &&
7381
- state.required &&
7382
- state.completed === true &&
7383
- def.name !== 'vision_bootstrap' &&
7384
- structuredFollowupEvidenceTools.has(def.name)
7385
- ) {
7386
- const limit = depthLimitFor(visionDepth())
7387
- const used = state.deepCalls || 0
7388
- if (limit !== undefined && used >= limit) {
7389
- return JSON.stringify({
7390
- ok: false,
7391
- code: 'VISION_DEPTH_LIMIT',
7392
- retryable: false,
7393
- reason: `本轮深度档位为 ${visionDepth()},深挖调用已达上限 ${limit} 次;请基于已有证据作答`,
7394
- })
7395
- }
7396
- // 配额不在调用前预扣:失败调用(ok:false)不烧掉档位的
7397
- // 深挖配额——模型保有"至少一次证据调用"提醒并可重试。
7398
- // 计数移到 execute 成功后(仅产出证据才 +1)。
7399
- }
7132
+ // 识图档位不在这里做调用次数拦截;显式 visionDepthMaxCalls 由
7133
+ // structured-flow hardening 统一执行,避免与 evidence 完成状态重复计数。
7400
7134
  let effectiveArgs = args
7401
7135
  if (
7402
7136
  structuredBootstrapEnabled() &&
@@ -7438,7 +7172,6 @@ ctx.logger?.info(
7438
7172
  }
7439
7173
  }
7440
7174
  if (!evidenceFailure) {
7441
- state.deepCalls = (state.deepCalls || 0) + 1
7442
7175
  state.followupCompleted = true
7443
7176
  }
7444
7177
  }