dsh-vision-router 2.1.6 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -14
  2. package/README.zh.md +8 -14
  3. package/cordis.patch.yml +14 -0
  4. package/docs/architecture/compat-inventory.md +1 -1
  5. package/docs/architecture/dsh-compatibility-matrix.md +4 -1
  6. package/docs/architecture/dsh-support-window.md +1 -2
  7. package/docs/releases/v2.1.7.md +13 -0
  8. package/docs/releases/v2.2.0.md +25 -0
  9. package/docs/remote-settings.md +2 -0
  10. package/entry.js +2 -0
  11. package/index.js +387 -77
  12. package/lib/artifact-boundary.js +11 -0
  13. package/lib/artifact-io.js +60 -7
  14. package/lib/catalog-corrections.js +5 -0
  15. package/lib/client.js +293 -48
  16. package/lib/core-primitives.js +122 -7
  17. package/lib/degraded-local-evidence.js +39 -0
  18. package/lib/dsh-contract-compat.js +202 -0
  19. package/lib/dsh-support-window.js +1 -1
  20. package/lib/image-offload-compat.js +38 -0
  21. package/lib/live-model-discovery.js +125 -15
  22. package/lib/local-vision-stabilizer.js +15 -6
  23. package/lib/official-deepseek-catalog.js +120 -0
  24. package/lib/ollama-cold-start.js +3 -15
  25. package/lib/remote-settings-bridge.js +28 -5
  26. package/lib/replay-delegation.js +68 -42
  27. package/lib/runtime-composition.js +15 -2
  28. package/lib/runtime-config-normalizer.js +271 -1
  29. package/lib/runtime-i18n-boundary.js +4 -13
  30. package/lib/runtime-i18n.js +2 -2
  31. package/lib/session-surface-compat.js +40 -0
  32. package/lib/session-turn-resolver.js +57 -0
  33. package/lib/session-vision-index.js +206 -93
  34. package/lib/session-vision-mode-boundary.js +83 -6
  35. package/lib/session-vision-runtime.js +8 -2
  36. package/lib/session-vision-state.js +0 -13
  37. package/lib/tesseract-exec-compat.js +16 -2
  38. package/lib/twin-image-capability-fallback.js +2 -11
  39. package/lib/vision-artifact-store.js +16 -3
  40. package/lib/vision-attachment-handle-runtime.js +9 -21
  41. package/lib/vision-backend-runtime-policy.js +7 -6
  42. package/lib/vision-background-stop-store.js +30 -20
  43. package/lib/vision-breaker-shadow-health.js +15 -5
  44. package/lib/vision-capability-probe.js +24 -14
  45. package/lib/vision-evidence-guidance.js +39 -0
  46. package/lib/vision-image-input-verdict.js +22 -7
  47. package/lib/vision-resilience.js +73 -1
  48. package/lib/web-capability-boundary.js +85 -22
  49. package/package.json +14 -7
package/index.js CHANGED
@@ -17,10 +17,10 @@
17
17
  // advanced vision-only override for `proxyHosts`; Host-owned visual adapters use
18
18
  // a scoped compatibility wrapper, never configuration-wide process routing.
19
19
 
20
- // Compatibility shim: dsh 0.1.2-alpha.4 removed `session.events` in favor of
21
- // `session.snapshotEvents()`. This helper returns an array (or undefined) that
22
- // works on both old and new harness versions.
23
- function getSessionEvents(session) {
20
+ // Legacy compatibility shim only. Modern supported Hosts use turnBoundary +
21
+ // SessionQuery for ordinary runtime reads; this path remains for older/partial
22
+ // Hosts that do not expose those capabilities.
23
+ function legacySessionEvents(session) {
24
24
  if (!session) return undefined
25
25
  // alpha.4+ : snapshotEvents() returns a frozen array of the event log
26
26
  if (typeof session.snapshotEvents === 'function') {
@@ -60,6 +60,7 @@ import {
60
60
  anthropicMediaType,
61
61
  } from './lib/catalog-corrections.js'
62
62
  import { createCachedUpdateChecker } from './lib/update-check.js'
63
+ import { getOfficialDeepSeekCatalog } from './lib/official-deepseek-catalog.js'
63
64
  import { probeLocalBackends } from './lib/local-connection-probe.js'
64
65
  import { detectDshSelfUpdatePlan, runDshPluginUpdate } from './lib/self-update.js'
65
66
  import {
@@ -95,6 +96,7 @@ import {
95
96
  scaleBox,
96
97
  scaledDimensions,
97
98
  } from './lib/image-resource-governor.js'
99
+ import { createSessionEventReader, createSessionEventTailReader, createSessionLogReader, hostOwnsOfficialDeepSeekProvider } from './lib/dsh-contract-compat.js'
98
100
  import { createSessionVisionIndex } from './lib/session-vision-index.js'
99
101
  import { createSessionVisionStateStore } from './lib/session-vision-state.js'
100
102
  import {
@@ -104,12 +106,22 @@ import {
104
106
  readResponseJsonBounded,
105
107
  readResponseTextBounded,
106
108
  } from './lib/http-body-limit.js'
107
- import { writeArtifactFile } from './lib/artifact-boundary.js'
109
+ import {
110
+ ARTIFACT_HANDOFF_RUN_ID,
111
+ ARTIFACT_RUNS_DIR,
112
+ normalizeArtifactsDir,
113
+ writeArtifactFile,
114
+ writePersistentArtifactFile,
115
+ } from './lib/artifact-boundary.js'
116
+ import { visionDescribeSuccessContext } from './lib/vision-evidence-guidance.js'
108
117
  import { stripTrailingSlashes } from './lib/string-normalization.js'
109
118
  import { streamWithLegacyGlobalProxyScope } from './lib/legacy-global-proxy-boundary.js'
110
119
  import { parseVersionComparator } from './lib/version-range.js'
111
120
  import { createCoalescingRunner } from './lib/adapter-update-coalescer.js'
112
121
  import { captureWindowsDesktop } from './lib/windows-desktop-capture.js'
122
+ import { blocksHaveRetainedImage, isOffloadedImageBlock, offloadedImagePlaceholder } from './lib/image-offload-compat.js'
123
+ import { createSessionTurnResolver } from './lib/session-turn-resolver.js'
124
+ import { shouldBlockDegradedHostTool } from './lib/degraded-local-evidence.js'
113
125
 
114
126
  import {
115
127
  sharpPromise,
@@ -168,7 +180,7 @@ export const Config = z.object({
168
180
  wrapperRoute: z.string().default('deepseek-vision'),
169
181
  chainRoute: z.string().default('vision-chain'),
170
182
  // 默认关闭(issue #34 明确 opt-in):关闭时官方 deepseek-official 路由
171
- // 原样保留;唯一例外见 apply 里的 keep-alive 兜底(官方行被禁用时)。
183
+ // 原样保留;仅 legacy Host 保留 keep-alive 接管兼容(官方行被禁用时)。
172
184
  stealth: z.boolean().default(false),
173
185
  textProvider: z
174
186
  .object({
@@ -386,6 +398,7 @@ import {
386
398
  posterizeSvgColor,
387
399
  resolveVisionOcrEngine,
388
400
  ocrWithTesseract,
401
+ ocrWithTesseractAdaptive,
389
402
  estimateTokens,
390
403
  estimateMessages,
391
404
  trimMessagesToBudget,
@@ -482,6 +495,7 @@ export {
482
495
  posterizeSvgColor,
483
496
  resolveVisionOcrEngine,
484
497
  ocrWithTesseract,
498
+ ocrWithTesseractAdaptive,
485
499
  estimateTokens,
486
500
  estimateMessages,
487
501
  trimMessagesToBudget,
@@ -549,6 +563,8 @@ export function apply(ctx, config = {}, runtime = {}) {
549
563
  // adapter boundaries that do not expose a Session; ambiguous attachment ids
550
564
  // deliberately miss instead of crossing conversations.
551
565
  const sessionVisionRuntime = runtime?.sessionVision
566
+ const hostOwnsOfficialDeepSeek = runtime?.hostOwnsOfficialDeepSeek
567
+ ?? hostOwnsOfficialDeepSeekProvider(ctx)
552
568
  const visionState = sessionVisionRuntime?.stateStore ?? createSessionVisionStateStore({
553
569
  maxSessions: 64,
554
570
  idleTtlMs: 60 * 60 * 1000,
@@ -566,6 +582,8 @@ export function apply(ctx, config = {}, runtime = {}) {
566
582
  },
567
583
  config: () => current(),
568
584
  logger: ctx.logger,
585
+ readSessionEvent: createSessionEventReader(ctx),
586
+ readSessionLog: createSessionLogReader(ctx),
569
587
  })
570
588
  const imageMemory = visionState.descriptionFacade
571
589
  // #208 follow-up complete: session-visible paths use scoped memory; only
@@ -696,11 +714,18 @@ export function apply(ctx, config = {}, runtime = {}) {
696
714
  const visionBreaker = createVisionCircuitBreaker()
697
715
  const visionTurnMemory = createVisionTurnMemory()
698
716
 
699
- // Same turn derivation the harness loop uses (dsh-agent-loop reads the last
700
- // turn/start event), so tool-side memory and pre-step bindings agree.
717
+ const sessionTurnResolver = runtime?.sessionTurnResolver ?? createSessionTurnResolver(ctx)
718
+ const sessionEventTailReader = runtime?.sessionEventTailReader ?? createSessionEventTailReader(ctx)
719
+
720
+ // Current stable/preview Hosts expose the Agent loop's `turnBoundary`
721
+ // Session projection. Runtime composition shares one resolver with shadow
722
+ // health so breaker scopes cannot diverge; rc.8 retains the resolver's
723
+ // explicit legacy event fallback.
701
724
  const turnNumberOf = (session) => {
702
725
  try {
703
- const events = getSessionEvents(session)
726
+ const projected = sessionTurnResolver.turnOf(session)
727
+ if (Number.isInteger(projected) && projected >= 0) return projected
728
+ const events = legacySessionEvents(session)
704
729
  if (!Array.isArray(events)) return 0
705
730
  const last = events.findLast((event) => event && event.type === 'turn/start')
706
731
  return last && Number.isInteger(last.data && last.data.turn) ? last.data.turn : 0
@@ -710,6 +735,41 @@ export function apply(ctx, config = {}, runtime = {}) {
710
735
  }
711
736
  const sessionIdOf = (session) => sessionIdentityOf(session) ?? 'anon'
712
737
  const visionScopeOf = (session) => `${sessionIdOf(session)}:${turnNumberOf(session)}`
738
+ const DEGRADED_LOCAL_REFINEMENT_LIMIT = 2
739
+ const visionEvidenceSourceKey = (value) => String(value ?? '').trim()
740
+ const degradedLocalFailure = (code, reason) => JSON.stringify({
741
+ ok: false,
742
+ code,
743
+ retryable: false,
744
+ reason,
745
+ })
746
+ const degradedLocalState = (session, source) => {
747
+ if (!session) return { active: false, scope: undefined, sourceKey: visionEvidenceSourceKey(source), used: 0 }
748
+ const scope = visionScopeOf(session)
749
+ const sourceKey = visionEvidenceSourceKey(source)
750
+ const active = visionTurnMemory.allFailed(scope) && visionTurnMemory.hasLocalOcr(scope, sourceKey)
751
+ return {
752
+ active,
753
+ scope,
754
+ sourceKey,
755
+ used: active ? visionTurnMemory.degradedRefinementCount(scope, sourceKey) : 0,
756
+ }
757
+ }
758
+
759
+ // DSH rc.8+ exposes a monotonic tool guard. Keep ordinary Host tools fully
760
+ // available, but do not let the same Agent rebuild an OCR/pixel-analysis
761
+ // pipeline from the current image bytes or Vision Router artifacts after every
762
+ // visual backend already failed and local OCR evidence exists for this turn.
763
+ if (typeof ctx.tools?.guard === 'function') {
764
+ ctx.tools.guard((exec) => {
765
+ const session = exec?.agent?.session
766
+ if (!session) return undefined
767
+ const scope = visionScopeOf(session)
768
+ const evidenceTokens = visionTurnMemory.degradedEvidenceTokens(scope)
769
+ if (!shouldBlockDegradedHostTool(exec.name, exec.arguments, evidenceTokens)) return undefined
770
+ return 'vision degraded-local evidence guard: do not reconstruct or re-parse this degraded image with Host tools after the visual backends failed; answer from the existing OCR evidence and state any remaining uncertainty'
771
+ })
772
+ }
713
773
 
714
774
  /** Stable, never-logged fingerprint of the credential a backend will use. */
715
775
  const credentialFingerprintOf = (value) => {
@@ -797,12 +857,10 @@ export function apply(ctx, config = {}, runtime = {}) {
797
857
  // image turns work. If the stock row is still active, taking over the route
798
858
  // throws DUPLICATE_ADAPTER and we fall back to the visible wrapper below.
799
859
  const stealthEnabled = current().stealth !== false
800
- // Keep-alive fallback: stealth off leaves the stock `deepseek-official`
801
- // route untouched — but when that route is dead (e.g. the official
802
- // llm-deepseek row is disabled in the profile patch layer), serving it
803
- // ourselves is the only way to keep the DeepSeek models in the picker.
804
- // The settings card surfaces this condition as a hint, and re-enabling
805
- // the stock row restores the fully official route.
860
+ // Legacy keep-alive fallback: older Hosts let DVR rebuild a missing stock
861
+ // `deepseek-official` route for compatibility. Newer Host generations own
862
+ // the provider's attachment/file lifecycle, so a missing official row is a
863
+ // Host configuration problem: DVR reports it and never reconstructs it.
806
864
  //
807
865
  // The takeover decision runs AFTER a short settle window, never inside
808
866
  // apply(): entry activation is service-driven, so this row can apply
@@ -888,11 +946,21 @@ export function apply(ctx, config = {}, runtime = {}) {
888
946
  if (adapterAvailable(ctx.llm, 'deepseek-official')) {
889
947
  if (stealthEnabled) {
890
948
  ctx.logger?.warn(
891
- 'vision-router: stealth is enabled but the stock deepseek-official route is alive; disable the llm-deepseek row to take it over',
949
+ hostOwnsOfficialDeepSeek
950
+ ? 'vision-router: stealth takeover is unavailable because this DSH Host owns deepseek-official; using the auto-vision wrapper instead'
951
+ : 'vision-router: legacy stealth takeover is enabled but the stock deepseek-official route is alive; disable llm-deepseek only on this legacy Host contract to take it over',
892
952
  )
893
953
  }
894
954
  return
895
955
  }
956
+ if (hostOwnsOfficialDeepSeek) {
957
+ takeoverAttempted = true
958
+ takeoverReason = 'host-owned-official-unavailable'
959
+ ctx.logger?.warn(
960
+ 'vision-router: deepseek-official is unavailable on a Host-owned provider contract; re-enable the llm-deepseek row because Vision Router will not recreate it',
961
+ )
962
+ return
963
+ }
896
964
  attemptTakeover(stealthEnabled ? 'stealth' : 'official-unavailable')
897
965
  }
898
966
  let takeoverSettled = false
@@ -1020,6 +1088,10 @@ export function apply(ctx, config = {}, runtime = {}) {
1020
1088
  const content = []
1021
1089
  for (const block of message.content) {
1022
1090
  if (block && block.type === 'image' && block.attachment) {
1091
+ if (isOffloadedImageBlock(block)) {
1092
+ content.push({ type: 'text', text: offloadedImagePlaceholder(block) })
1093
+ continue
1094
+ }
1023
1095
  if (attachments === undefined) continue
1024
1096
  try {
1025
1097
  const stored = await attachments.readImage(block.attachment)
@@ -1047,12 +1119,16 @@ export function apply(ctx, config = {}, runtime = {}) {
1047
1119
  if (nested && nested.type === 'text' && typeof nested.text === 'string') {
1048
1120
  parts.push(nested.text)
1049
1121
  } else if (nested && nested.type === 'image') {
1050
- const attachment = nested.attachment || {}
1051
- const id = attachment.attachmentId || attachment.id || 'unknown'
1052
- parts.push(
1053
- `[attached image: ${id}] this tool result contained an image; ` +
1054
- 'inspect it with vision_describe (or re-read it with read_image)',
1055
- )
1122
+ if (isOffloadedImageBlock(nested)) {
1123
+ parts.push(offloadedImagePlaceholder(nested))
1124
+ } else {
1125
+ const attachment = nested.attachment || {}
1126
+ const id = attachment.attachmentId || attachment.id || 'unknown'
1127
+ parts.push(
1128
+ `[attached image: ${id}] this tool result contained an image; ` +
1129
+ 'inspect it with vision_describe (or re-read it with read_image)',
1130
+ )
1131
+ }
1056
1132
  }
1057
1133
  }
1058
1134
  if (parts.length > 0) {
@@ -1131,8 +1207,8 @@ export function apply(ctx, config = {}, runtime = {}) {
1131
1207
  // session model does not declare image input, and the DeepSeek adapter
1132
1208
  // hardcodes text-only. This wrapper route (`deepseek-vision` by default)
1133
1209
  // declares image input so the admission passes, shows up in the model
1134
- // picker as "DeepSeek + 自动识图", and delegates to the real text-provider
1135
- // adapter for anything the waterfalls did not rewrite.
1210
+ // picker as "DeepSeek + 自动识图", and delegates only to the official
1211
+ // DeepSeek adapter (or the hidden native route during stealth takeover).
1136
1212
  //
1137
1213
  // The adapter is built unconditionally; whether (and under which name) it
1138
1214
  // mounts is reconciled reactively against the resolved settings document by
@@ -1140,12 +1216,16 @@ export function apply(ctx, config = {}, runtime = {}) {
1140
1216
  // take effect without a restart.
1141
1217
  let wrapperAdapter
1142
1218
  {
1143
- const WRAPPER_MODEL_IDS = ['deepseek-v4-pro', 'deepseek-v4-flash']
1144
1219
  const wrapName = (name) => name ?? 'DeepSeek'
1145
- const textProviderRoute = () => (stealthActive ? nativeRoute : textProvider().provider)
1220
+ // The row is explicitly branded as DeepSeek, so its metadata and network
1221
+ // authority must come from DeepSeek as well. `textProvider` is legacy
1222
+ // configuration and must never let an arbitrary relay masquerade behind
1223
+ // the special wrapper. During stealth takeover old wrapper sessions keep
1224
+ // delegating to the hidden native DeepSeek route.
1225
+ const wrapperDelegateRoute = () => (stealthActive ? nativeRoute : 'deepseek-official')
1146
1226
  const delegateAdapter = () => {
1147
1227
  try {
1148
- return ctx.llm.registration(textProviderRoute()).adapter
1228
+ return ctx.llm.registration(wrapperDelegateRoute()).adapter
1149
1229
  } catch {
1150
1230
  return undefined
1151
1231
  }
@@ -1156,7 +1236,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1156
1236
  },
1157
1237
  providerRetryPolicy() {
1158
1238
  try {
1159
- return ctx.llm.registration(textProviderRoute()).retryPolicy
1239
+ return ctx.llm.registration(wrapperDelegateRoute()).retryPolicy
1160
1240
  } catch {
1161
1241
  return undefined
1162
1242
  }
@@ -1167,12 +1247,12 @@ export function apply(ctx, config = {}, runtime = {}) {
1167
1247
  if (stealthActive) return []
1168
1248
  const entries = []
1169
1249
  const real = delegateAdapter()
1170
- if (real !== undefined) {
1250
+ if (real !== undefined && typeof real.listModels === 'function') {
1171
1251
  try {
1172
- const listed = await real.listModels(textProviderRoute())
1252
+ const listed = await getOfficialDeepSeekCatalog(real)
1173
1253
  entries.push(
1174
- ...listed
1175
- .filter((model) => WRAPPER_MODEL_IDS.includes(model.id))
1254
+ ...(Array.isArray(listed) ? listed : [])
1255
+ .filter((model) => model && typeof model.id === 'string' && model.id !== '')
1176
1256
  .map((model) => ({
1177
1257
  ...model,
1178
1258
  provider: wrapperRoute(),
@@ -1221,14 +1301,29 @@ export function apply(ctx, config = {}, runtime = {}) {
1221
1301
  inputModalities: ['text', 'image'],
1222
1302
  }
1223
1303
  } catch {
1224
- /* fall through to the text-provider path */
1304
+ /* fall through to the official DeepSeek path */
1225
1305
  }
1226
1306
  }
1227
1307
  const real = delegateAdapter()
1228
- if (real === undefined) {
1229
- throw new Error('vision-router: the text provider adapter is not available')
1308
+ if (real === undefined || typeof real.resolveModel !== 'function') {
1309
+ throw new Error('vision-router: the official DeepSeek adapter is not available')
1310
+ }
1311
+ // Outside stealth mode, accept only models the live official catalog
1312
+ // actually publishes. Some adapters can resolve arbitrary ids; that is
1313
+ // not permission to expose them under the DeepSeek product identity.
1314
+ if (!stealthActive) {
1315
+ if (typeof real.listModels !== 'function') {
1316
+ throw new Error('vision-router: the official DeepSeek catalog is not available')
1317
+ }
1318
+ const listed = await getOfficialDeepSeekCatalog(real)
1319
+ const admitted = Array.isArray(listed) && listed.some(
1320
+ (entry) => entry && entry.id === model,
1321
+ )
1322
+ if (!admitted) {
1323
+ throw new Error(`vision-router: DeepSeek model "${model}" is not in the live official catalog`)
1324
+ }
1230
1325
  }
1231
- const base = await real.resolveModel(textProviderRoute(), model)
1326
+ const base = await real.resolveModel(wrapperDelegateRoute(), model)
1232
1327
  return {
1233
1328
  ...base,
1234
1329
  provider: wrapperRoute(),
@@ -1238,7 +1333,7 @@ export function apply(ctx, config = {}, runtime = {}) {
1238
1333
  },
1239
1334
  ...createWrapperStreamBody(ctx, {
1240
1335
  imageMemory,
1241
- delegateProvider: textProviderRoute(),
1336
+ delegateProvider: wrapperDelegateRoute,
1242
1337
  instantLocal: instantLocalProvider,
1243
1338
  instantLocalStyle,
1244
1339
  instantLocalTimeoutMs: timeoutMs,
@@ -1965,7 +2060,9 @@ export function apply(ctx, config = {}, runtime = {}) {
1965
2060
  if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue
1966
2061
  // Deep collection: images nested inside tool-result blocks also
1967
2062
  // identify this turn's subject and deserve memory recording.
1968
- for (const found of collectImageBlocks([message])) imageIds.push(found.id)
2063
+ for (const found of collectImageBlocks([message])) {
2064
+ if (!isOffloadedImageBlock(found.block)) imageIds.push(found.id)
2065
+ }
1969
2066
  if (imageIds.length > 0) break
1970
2067
  }
1971
2068
  let finalText = ''
@@ -2238,16 +2335,100 @@ export function apply(ctx, config = {}, runtime = {}) {
2238
2335
  },
2239
2336
  'vision-router: reactive routing mounts',
2240
2337
  )
2241
- // #208: attachment refs, description memory and the event-log cursor are
2242
- // owned by the same bounded SessionVisionStateStore above.
2338
+ // #208: attachment refs and description memory are owned by the same
2339
+ // bounded SessionVisionStateStore above. Historical ref recovery stays
2340
+ // target-only inside SessionVisionIndex rather than maintaining a log cursor.
2243
2341
 
2244
2342
  // Host-owned proxy overrides are scoped by lib/legacy-global-proxy-boundary.js.
2245
2343
  // Core no longer owns or installs a process-wide proxy fetch implementation.
2246
2344
 
2247
- const lookupAttachment = (session, id) => sessionVisionIndex.lookupAttachment(session, id)
2345
+ const resolveAttachment = (session, id) => sessionVisionIndex.resolveAttachment(session, id)
2346
+ const resolveAttachments = (session, ids) => sessionVisionIndex.resolveAttachments(session, ids)
2248
2347
 
2249
- // session -> { turn, startIndex, hasImage, routed, failures, lastError }
2348
+ // Session-local routing handoff between pre-step and agent/request. Modern
2349
+ // Hosts store one exact async raw-log tail seq; legacy Hosts retain only the
2350
+ // released synchronous array index fallback.
2250
2351
  const turnState = new WeakMap()
2352
+ const midTurnReadWarnings = new WeakMap()
2353
+
2354
+ const warnMidTurnReadFailure = (session, error) => {
2355
+ const message = String(error?.message ?? error ?? '').slice(0, 400)
2356
+ if (midTurnReadWarnings.get(session) === message) return
2357
+ midTurnReadWarnings.set(session, message)
2358
+ ctx.logger?.warn?.('vision-router: mid-turn Session event read failed; routing conservatively to vision: %s', message)
2359
+ }
2360
+
2361
+ const legacyTurnCapture = (session) => ({
2362
+ legacyStartIndex: (legacySessionEvents(session) ?? []).length,
2363
+ })
2364
+
2365
+ const captureTurnTail = async (session) => {
2366
+ const anchorSeq = sessionTurnResolver?.eventAnchorOf?.(session)
2367
+ if (!Number.isSafeInteger(anchorSeq) || anchorSeq < 0) return legacyTurnCapture(session)
2368
+ if (typeof sessionEventTailReader !== 'function') return legacyTurnCapture(session)
2369
+ try {
2370
+ const tail = await sessionEventTailReader(session, anchorSeq, { collect: false })
2371
+ if (tail?.supported === false) return legacyTurnCapture(session)
2372
+ if (tail?.supported !== true || !Number.isSafeInteger(tail.capturedThroughSeq)) {
2373
+ warnMidTurnReadFailure(session, new Error('Session tail reader returned an invalid capability result'))
2374
+ return { scanUnknown: true }
2375
+ }
2376
+ midTurnReadWarnings.delete(session)
2377
+ return tail.truncated === true
2378
+ ? { capturedThroughSeq: tail.capturedThroughSeq, scanUnknown: true }
2379
+ : { capturedThroughSeq: tail.capturedThroughSeq }
2380
+ } catch (error) {
2381
+ warnMidTurnReadFailure(session, error)
2382
+ return { scanUnknown: true }
2383
+ }
2384
+ }
2385
+
2386
+ const refreshTurnImageState = async (session, state) => {
2387
+ if (state.hasImage) return
2388
+ if (state.scanUnknown === true) {
2389
+ state.hasImage = true
2390
+ return
2391
+ }
2392
+ if (Number.isSafeInteger(state.capturedThroughSeq)) {
2393
+ try {
2394
+ const tail = await sessionEventTailReader(session, state.capturedThroughSeq)
2395
+ if (tail?.supported !== true || !Number.isSafeInteger(tail.capturedThroughSeq)) {
2396
+ state.hasImage = true
2397
+ warnMidTurnReadFailure(session, new Error('Session tail reader became unavailable after capture'))
2398
+ return
2399
+ }
2400
+ midTurnReadWarnings.delete(session)
2401
+ state.capturedThroughSeq = tail.capturedThroughSeq
2402
+ if (tail.truncated === true || tail.events.some((event) => eventHasImage(event))) state.hasImage = true
2403
+ return
2404
+ } catch (error) {
2405
+ state.hasImage = true
2406
+ warnMidTurnReadFailure(session, error)
2407
+ return
2408
+ }
2409
+ }
2410
+
2411
+ const events = legacySessionEvents(session) ?? []
2412
+ const startIndex = Number.isSafeInteger(state.legacyStartIndex) ? state.legacyStartIndex : 0
2413
+ for (let i = startIndex; i < events.length; i++) {
2414
+ if (eventHasImage(events[i])) {
2415
+ state.hasImage = true
2416
+ break
2417
+ }
2418
+ }
2419
+ state.legacyStartIndex = events.length
2420
+ }
2421
+
2422
+ ctx.on('tools/post-execute', async (exec, result, next) => {
2423
+ const downstream = await next()
2424
+ if (downstream?.kind !== 'accept') return downstream
2425
+ const context = visionDescribeSuccessContext(exec, result)
2426
+ if (!context) return downstream
2427
+ return {
2428
+ ...downstream,
2429
+ additionalContexts: [context, ...(downstream.additionalContexts ?? [])],
2430
+ }
2431
+ })
2251
2432
 
2252
2433
  ctx.on('agent/pre-step', async (payload, next) => {
2253
2434
  let decision = await next()
@@ -2272,7 +2453,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2272
2453
  // settings 无该 key)一眼可见 "instant=off"。
2273
2454
  if (ctx.logger) {
2274
2455
  const hasImage = rawMessages.some(
2275
- (message) => message && Array.isArray(message.content) && blocksHaveImage(message.content),
2456
+ (message) => message && Array.isArray(message.content) && blocksHaveRetainedImage(message.content),
2276
2457
  )
2277
2458
  if (hasImage) {
2278
2459
  ctx.logger.info(
@@ -2289,18 +2470,18 @@ export function apply(ctx, config = {}, runtime = {}) {
2289
2470
  // repair; Core retains only the current inbox sanitizer.
2290
2471
  const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
2291
2472
  const messages = sanitizedToolResults.messages
2292
- const hasImage = messages.some((message) => blocksHaveImage(message && message.content))
2473
+ const hasImage = messages.some((message) => blocksHaveRetainedImage(message && message.content))
2293
2474
 
2294
2475
  // Register the turn state BEFORE the image-turn branches below: those
2295
2476
  // branches return early (auto-mount reminder, history rewrite), and the
2296
2477
  // agent/request hook must still see the state, otherwise an image turn is
2297
2478
  // served by the text provider and rejected (issue #74, second root cause).
2298
2479
  if (routingEnabled()) {
2299
- const events = getSessionEvents(session) ?? []
2480
+ const capture = hasImage ? {} : await captureTurnTail(session)
2300
2481
  turnState.set(session, {
2301
2482
  turn: payload.turn,
2302
- startIndex: events.length,
2303
2483
  hasImage,
2484
+ ...capture,
2304
2485
  })
2305
2486
  }
2306
2487
  let bootstrapState = structuredBootstrapTurnState.get(session)
@@ -2517,15 +2698,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2517
2698
  if (!session) return config0
2518
2699
  const state = turnState.get(session)
2519
2700
  if (!state || state.turn !== payload.turn) return config0
2520
- if (!state.hasImage) {
2521
- const events = getSessionEvents(session) ?? []
2522
- for (let i = state.startIndex; i < events.length; i++) {
2523
- if (eventHasImage(events[i])) {
2524
- state.hasImage = true
2525
- break
2526
- }
2527
- }
2528
- }
2701
+ if (!state.hasImage) await refreshTurnImageState(session, state)
2529
2702
  if (!state.hasImage) {
2530
2703
  // Reverse routing: the session's entry model is a vision provider
2531
2704
  // (needed to pass the prompt admission); send text-only turns back
@@ -2635,6 +2808,10 @@ export function apply(ctx, config = {}, runtime = {}) {
2635
2808
  ...attachmentIds.map((id) => String(id)).filter((id) => isAttachmentIdInput(id)),
2636
2809
  ...paths.map((item) => String(item)).filter((item) => isAttachmentIdInput(item)),
2637
2810
  ])]
2811
+ const attachmentSession = exec && exec.agent && exec.agent.session
2812
+ const resolvedAttachmentRefs = materializableAttachmentIds.length > 0
2813
+ ? await resolveAttachments(attachmentSession, materializableAttachmentIds)
2814
+ : new Map()
2638
2815
 
2639
2816
  for (const path of paths) {
2640
2817
  let bytes
@@ -2643,7 +2820,7 @@ export function apply(ctx, config = {}, runtime = {}) {
2643
2820
  // readImageBytes accepts both filesystem paths and attachment ids
2644
2821
  // ("sha256:..."), so a model that passes an uploaded image's id as
2645
2822
  // a path gets the right pixels instead of a not-found error.
2646
- ;({ bytes, mediaType } = await readImageBytes(exec, path))
2823
+ ;({ bytes, mediaType } = await readImageBytes(exec, path, resolvedAttachmentRefs))
2647
2824
  } catch (error) {
2648
2825
  throw new Error(
2649
2826
  `vision_describe: failed to read ${path} (${error && error.message ? error.message : String(error)})`,
@@ -2675,8 +2852,10 @@ export function apply(ctx, config = {}, runtime = {}) {
2675
2852
  }
2676
2853
 
2677
2854
  for (const id of attachmentIds) {
2678
- const session = exec && exec.agent && exec.agent.session
2679
- const ref = lookupAttachment(session, String(id))
2855
+ const attachmentId = String(id)
2856
+ const ref = isAttachmentIdInput(attachmentId)
2857
+ ? resolvedAttachmentRefs.get(attachmentId)
2858
+ : await resolveAttachment(attachmentSession, attachmentId)
2680
2859
  if (ref === undefined) {
2681
2860
  throw new Error(
2682
2861
  `vision_describe: unknown attachment id "${id}" (it must come from an image uploaded in this conversation)`,
@@ -3059,7 +3238,7 @@ ctx.logger?.info(
3059
3238
  tool: 'vision_materialize',
3060
3239
  attachmentIds: materializableAttachmentIds,
3061
3240
  advice:
3062
- 'If another local/OCR tool requires a filesystem path, call vision_materialize for the uploaded attachment id. Do not guess a filename or the attachment store path.',
3241
+ 'For text transcription, call vision_ocr with {"image":"<attachment id>","engine":"tesseract"}; vision_ocr accepts uploaded attachment ids directly. Use vision_materialize only when a separate non-Vision-Router local parser genuinely requires a filesystem path. Do not guess a filename or the attachment store path.',
3063
3242
  }
3064
3243
  }
3065
3244
  return JSON.stringify(baseFailure)
@@ -3170,7 +3349,31 @@ ctx.logger?.info(
3170
3349
  ? config.artifactsDir
3171
3350
  : '.dsh-vision-router/artifacts'
3172
3351
 
3173
- const readImageBytes = async (exec, imagePath) => {
3352
+ const resolveOcrImageInput = (args = {}) => {
3353
+ const hasImageField = Object.prototype.hasOwnProperty.call(args, 'image')
3354
+ const image = typeof args.image === 'string' ? args.image : undefined
3355
+ const hasAttachmentIdsField = Object.prototype.hasOwnProperty.call(args, 'attachmentIds')
3356
+ const attachmentIds = Array.isArray(args.attachmentIds) ? args.attachmentIds : []
3357
+
3358
+ if (hasImageField && (image === undefined || image.trim() === '')) {
3359
+ throw new Error('vision_ocr: image must be a non-empty path or attachment id')
3360
+ }
3361
+ if (hasAttachmentIdsField && !Array.isArray(args.attachmentIds)) {
3362
+ throw new Error('vision_ocr: attachmentIds must be an array containing exactly one uploaded attachment id')
3363
+ }
3364
+ if (hasImageField && hasAttachmentIdsField) {
3365
+ throw new Error('vision_ocr: provide exactly one image using image or attachmentIds, not both')
3366
+ }
3367
+ if (hasImageField) return image
3368
+ if (attachmentIds.length !== 1 || !isAttachmentIdInput(attachmentIds[0])) {
3369
+ throw new Error(
3370
+ 'vision_ocr: provide one image via image or exactly one uploaded attachment id via attachmentIds',
3371
+ )
3372
+ }
3373
+ return String(attachmentIds[0]).trim()
3374
+ }
3375
+
3376
+ const readImageBytes = async (exec, imagePath, resolvedAttachmentRefs) => {
3174
3377
  const input = String(imagePath ?? '')
3175
3378
  let bytes
3176
3379
  let storedMediaType
@@ -3183,7 +3386,10 @@ ctx.logger?.info(
3183
3386
  throw new Error('vision-router: the attachment service is not available in this deployment')
3184
3387
  }
3185
3388
  const session = exec && exec.agent && exec.agent.session
3186
- const ref = lookupAttachment(session, input.trim())
3389
+ const canonicalInput = input.trim()
3390
+ const ref = resolvedAttachmentRefs instanceof Map
3391
+ ? resolvedAttachmentRefs.get(canonicalInput)
3392
+ : await resolveAttachment(session, canonicalInput)
3187
3393
  if (ref === undefined) {
3188
3394
  throw new Error(
3189
3395
  `vision-router: unknown attachment id "${input}" (it must come from an image uploaded in this conversation)`,
@@ -3231,6 +3437,8 @@ ctx.logger?.info(
3231
3437
 
3232
3438
  const saveArtifact = async (exec, relPath, data) =>
3233
3439
  writeArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
3440
+ const savePersistentArtifact = async (exec, relPath, data) =>
3441
+ writePersistentArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
3234
3442
 
3235
3443
  const artifactStem = (imagePath, suffix) => artifactStemOf(imagePath, suffix)
3236
3444
 
@@ -3244,7 +3452,8 @@ ctx.logger?.info(
3244
3452
  deepToolDefs.push({
3245
3453
  name: 'vision_materialize',
3246
3454
  description:
3247
- 'Copy an uploaded image attachment (sha256:...) or readable local image into the session workspace and return a real filesystem path. ' +
3455
+ 'Copy an uploaded image attachment (sha256:...) or readable local image into a stable content-addressed file in the session workspace. ' +
3456
+ 'Returns both an absolute path and a shorter workspaceRelativePath; prefer workspaceRelativePath in later tool or shell calls to avoid copying long internal paths. ' +
3248
3457
  'This tool performs NO vision model/network call. Use it after vision_describe/vision_bootstrap returns ok:false when a local OCR/parser accepts only file_path. ' +
3249
3458
  'Never guess the attachment store path or search for a same-named file.',
3250
3459
  parameters: {
@@ -3258,6 +3467,14 @@ ctx.logger?.info(
3258
3467
  output: stringOutput,
3259
3468
  async execute(args, exec) {
3260
3469
  const source = String(args.image ?? '')
3470
+ const session = exec?.agent?.session
3471
+ const degraded = degradedLocalState(session, source)
3472
+ if (degraded.active) {
3473
+ return degradedLocalFailure(
3474
+ 'VISION_LOCAL_EVIDENCE_AVAILABLE',
3475
+ 'local OCR evidence already exists for this image and every configured vision backend has failed this turn; do not materialize the image to rebuild another parser/OCR pipeline',
3476
+ )
3477
+ }
3261
3478
  const { bytes, mediaType } = await readImageBytes(exec, source)
3262
3479
  const extension = mediaType === 'image/jpeg'
3263
3480
  ? 'jpg'
@@ -3266,9 +3483,26 @@ ctx.logger?.info(
3266
3483
  : mediaType === 'image/gif'
3267
3484
  ? 'gif'
3268
3485
  : 'png'
3269
- const target = await saveArtifact(exec, `${artifactStem(source, 'materialized')}.${extension}`, bytes)
3486
+ const fingerprint = createHash('sha256').update(bytes).digest('hex').slice(0, 20)
3487
+ const relativeArtifactPath = path.join('materialized', `${fingerprint}.${extension}`)
3488
+ const artifactName = path.basename(relativeArtifactPath)
3489
+ const target = await savePersistentArtifact(exec, relativeArtifactPath, bytes)
3490
+ const workspaceRelativePath = path.join(
3491
+ normalizeArtifactsDir(artifactsRel),
3492
+ ARTIFACT_RUNS_DIR,
3493
+ ARTIFACT_HANDOFF_RUN_ID,
3494
+ relativeArtifactPath,
3495
+ ).split(path.sep).join('/')
3496
+ if (session) {
3497
+ visionTurnMemory.recordDerivedArtifact(
3498
+ visionScopeOf(session),
3499
+ visionEvidenceSourceKey(source),
3500
+ artifactName,
3501
+ )
3502
+ }
3270
3503
  return JSON.stringify({
3271
3504
  path: target,
3505
+ workspaceRelativePath,
3272
3506
  mediaType,
3273
3507
  bytes: bytes.length,
3274
3508
  ...(isAttachmentIdInput(source) ? { source } : {}),
@@ -3659,6 +3893,14 @@ ctx.logger?.info(
3659
3893
  },
3660
3894
  output: stringOutput,
3661
3895
  async execute(args, exec) {
3896
+ const session = exec?.agent?.session
3897
+ const degraded = degradedLocalState(session, args.image)
3898
+ if (degraded.active && degraded.used >= DEGRADED_LOCAL_REFINEMENT_LIMIT) {
3899
+ return degradedLocalFailure(
3900
+ 'VISION_DEGRADED_LOCAL_LIMIT',
3901
+ `the degraded local evidence budget for this image is exhausted after ${DEGRADED_LOCAL_REFINEMENT_LIMIT} refinement call(s); answer from existing evidence and state any remaining uncertainty`,
3902
+ )
3903
+ }
3662
3904
  const { bytes } = await readImageBytes(exec, args.image)
3663
3905
  const { width, height } = await imageDims(bytes)
3664
3906
  const box = parseBox(args.region)
@@ -3690,11 +3932,17 @@ ctx.logger?.info(
3690
3932
  } finally {
3691
3933
  releaseCrop()
3692
3934
  }
3693
- const target = await saveArtifact(
3694
- exec,
3695
- `${artifactStem(args.image, `crop-${box.x1}-${box.y1}-${box.x2}-${box.y2}`)}.png`,
3696
- cropped,
3697
- )
3935
+ const artifactName = `${artifactStem(args.image, `crop-${box.x1}-${box.y1}-${box.x2}-${box.y2}`)}.png`
3936
+ const target = await saveArtifact(exec, artifactName, cropped)
3937
+ if (session) {
3938
+ const scope = visionScopeOf(session)
3939
+ visionTurnMemory.recordDerivedArtifact(
3940
+ scope,
3941
+ visionEvidenceSourceKey(args.image),
3942
+ artifactName,
3943
+ )
3944
+ if (degraded.active) visionTurnMemory.recordDegradedRefinement(scope, degraded.sourceKey)
3945
+ }
3698
3946
  const meta = await sharp(cropped).metadata()
3699
3947
  return JSON.stringify({
3700
3948
  path: target,
@@ -3959,34 +4207,83 @@ ctx.logger?.info(
3959
4207
  'ACCURACY: OCR transcribes characters verbatim and is systematically unreliable for confusable ' +
3960
4208
  'glyphs (1/l, 0/O), spacing and line breaks; prefer vision_describe / vision_detect for semantic ' +
3961
4209
  'understanding and use OCR only when exact verbatim text is required (executable code, exact ' +
3962
- 'quotation, forms/contracts, table digits, CAPTCHAs). Treat OCR output as evidence to verify, ' +
3963
- 'never as ground truth.',
4210
+ 'quotation, forms/contracts, table digits, CAPTCHAs). Local OCR uses a bounded layout review when ' +
4211
+ 'the first pass looks weak. If the result has uncertain:true, cross-check the ambiguous text when ' +
4212
+ 'another visual backend is available; otherwise state the remaining uncertainty. If uncertain:false ' +
4213
+ 'and the text directly answers the user, do not call more tools merely to re-prove the same text. ' +
4214
+ 'INPUT: `image` is the canonical single-image argument. For compatibility with other Vision Router ' +
4215
+ 'tools, one uploaded image may instead be passed as `attachmentIds: [id]`; do not pass both forms ' +
4216
+ 'or more than one attachment id.',
3964
4217
  parameters: {
3965
4218
  type: 'object',
3966
4219
  properties: {
3967
- image: { type: 'string', description: 'Local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
4220
+ image: { type: 'string', description: 'Canonical single-image input: local image path (png/jpeg/webp/gif), workspace-relative or absolute; or the attachment id (e.g. "sha256:...") of an image uploaded in this conversation' },
4221
+ attachmentIds: {
4222
+ type: 'array',
4223
+ items: { type: 'string' },
4224
+ minItems: 1,
4225
+ maxItems: 1,
4226
+ description: 'Compatibility alias for one uploaded image attachment id. Use exactly one sha256:... id. Do not combine with image.',
4227
+ },
3968
4228
  engine: {
3969
4229
  type: 'string',
3970
4230
  description: '"auto" (default): always try local Tesseract first, then fall back to the vision model if local OCR fails or returns no text. Structured 1+x does not change this order; use explicit "tesseract"/"vision" to force an engine.',
3971
4231
  },
3972
4232
  },
3973
- required: ['image'],
3974
4233
  additionalProperties: false,
3975
4234
  },
3976
4235
  output: stringOutput,
3977
4236
  async execute(args, exec) {
3978
- const { bytes, mediaType } = await readImageBytes(exec, args.image)
4237
+ const imageInput = resolveOcrImageInput(args)
4238
+ const session = exec?.agent?.session
3979
4239
  const engine = resolveVisionOcrEngine(args.engine)
4240
+ const degraded = degradedLocalState(session, imageInput)
4241
+ if (
4242
+ engine !== 'vision' &&
4243
+ degraded.active &&
4244
+ degraded.used >= DEGRADED_LOCAL_REFINEMENT_LIMIT
4245
+ ) {
4246
+ return degradedLocalFailure(
4247
+ 'VISION_DEGRADED_LOCAL_LIMIT',
4248
+ `the degraded local evidence budget for this image is exhausted after ${DEGRADED_LOCAL_REFINEMENT_LIMIT} refinement call(s); answer from existing evidence and state any remaining uncertainty`,
4249
+ )
4250
+ }
4251
+ const { bytes, mediaType } = await readImageBytes(exec, imageInput)
3980
4252
  // ONE OCR budget shared by tesseract AND the vision fallback: tesseract
3981
4253
  // gets a capped slice (never more than 12s), the vision model only the
3982
4254
  // remainder. The two timeouts can never stack into a multi-minute wait.
3983
4255
  const deadline = createDeadline(ocrBudgetMs())
3984
4256
  const tesseractSlice = Math.min(12000, deadline.remaining())
3985
4257
  if (engine !== 'vision') {
4258
+ let localAttempted = false
3986
4259
  try {
3987
- const text = await ocrWithTesseract(bytes, tesseractSlice)
3988
- if (text.trim() !== '') return JSON.stringify({ engine: 'tesseract', text: text.trim() })
3989
- if (engine === 'tesseract') return JSON.stringify({ engine: 'tesseract', text: '' })
4260
+ localAttempted = true
4261
+ const local = await ocrWithTesseractAdaptive(bytes, tesseractSlice)
4262
+ if (local.text.trim() !== '') {
4263
+ if (session) {
4264
+ visionTurnMemory.recordLocalOcr(
4265
+ visionScopeOf(session),
4266
+ visionEvidenceSourceKey(imageInput),
4267
+ { uncertain: local.uncertain === true },
4268
+ )
4269
+ }
4270
+ return JSON.stringify({
4271
+ engine: 'tesseract',
4272
+ text: local.text.trim(),
4273
+ uncertain: local.uncertain === true,
4274
+ ...(local.uncertain === true
4275
+ ? {
4276
+ review: {
4277
+ psm: local.psm,
4278
+ attemptedPsms: local.attemptedPsms,
4279
+ quality: Number(local.quality.toFixed(2)),
4280
+ riskyTokens: local.riskyTokens,
4281
+ },
4282
+ }
4283
+ : {}),
4284
+ })
4285
+ }
4286
+ if (engine === 'tesseract') return JSON.stringify({ engine: 'tesseract', text: '', uncertain: true })
3990
4287
  } catch (error) {
3991
4288
  if (engine === 'tesseract') {
3992
4289
  throw new Error(
@@ -3994,6 +4291,13 @@ ctx.logger?.info(
3994
4291
  )
3995
4292
  }
3996
4293
  ctx.logger?.warn('vision-router: tesseract OCR unavailable, falling back to vision model')
4294
+ } finally {
4295
+ if (degraded.active && localAttempted && session) {
4296
+ visionTurnMemory.recordDegradedRefinement(
4297
+ visionScopeOf(session),
4298
+ degraded.sourceKey,
4299
+ )
4300
+ }
3997
4301
  }
3998
4302
  }
3999
4303
  if (deadline.expired()) {
@@ -4831,10 +5135,16 @@ ctx.logger?.info(
4831
5135
  // Runtime takeover state: lets the settings card explain the
4832
5136
  // keep-alive fallback when stealth is off but the stock route is
4833
5137
  // disabled at the composition layer.
5138
+ const officialRouteAvailable = adapterAvailable(ctx.llm, 'deepseek-official')
4834
5139
  result.stealth = {
4835
5140
  configured: stealthEnabled,
4836
5141
  active: stealthActive,
4837
- reason: stealthActive ? takeoverReason : undefined,
5142
+ reason: stealthActive
5143
+ ? takeoverReason
5144
+ : hostOwnsOfficialDeepSeek && !officialRouteAvailable
5145
+ ? 'host-owned-official-unavailable'
5146
+ : undefined,
5147
+ hostOwned: hostOwnsOfficialDeepSeek,
4838
5148
  }
4839
5149
  res.writeHead(result.ok ? 200 : 502, { 'content-type': 'application/json' })
4840
5150
  res.end(JSON.stringify(result))