dsh-vision-router 2.1.7 → 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.
- package/README.md +8 -14
- package/README.zh.md +8 -14
- package/docs/architecture/compat-inventory.md +1 -1
- package/docs/architecture/dsh-compatibility-matrix.md +4 -1
- package/docs/architecture/dsh-support-window.md +1 -2
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +355 -64
- package/lib/artifact-boundary.js +11 -0
- package/lib/artifact-io.js +60 -7
- package/lib/catalog-corrections.js +5 -0
- package/lib/client.js +22 -17
- package/lib/core-primitives.js +115 -5
- package/lib/degraded-local-evidence.js +39 -0
- package/lib/dsh-contract-compat.js +202 -0
- package/lib/dsh-support-window.js +1 -1
- package/lib/image-offload-compat.js +38 -0
- package/lib/local-vision-stabilizer.js +15 -6
- package/lib/official-deepseek-catalog.js +120 -0
- package/lib/ollama-cold-start.js +3 -15
- package/lib/remote-settings-bridge.js +19 -1
- package/lib/replay-delegation.js +41 -21
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-i18n-boundary.js +4 -13
- package/lib/runtime-i18n.js +2 -2
- package/lib/session-surface-compat.js +40 -0
- package/lib/session-turn-resolver.js +57 -0
- package/lib/session-vision-index.js +206 -93
- package/lib/session-vision-mode-boundary.js +83 -6
- package/lib/session-vision-runtime.js +8 -2
- package/lib/session-vision-state.js +0 -13
- package/lib/tesseract-exec-compat.js +16 -2
- package/lib/twin-image-capability-fallback.js +2 -11
- package/lib/vision-artifact-store.js +16 -3
- package/lib/vision-attachment-handle-runtime.js +9 -21
- package/lib/vision-backend-runtime-policy.js +7 -6
- package/lib/vision-breaker-shadow-health.js +15 -5
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-resilience.js +73 -1
- package/package.json +11 -6
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
function
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
700
|
-
|
|
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
|
|
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
|
-
//
|
|
801
|
-
// route
|
|
802
|
-
//
|
|
803
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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) {
|
|
@@ -1173,7 +1249,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1173
1249
|
const real = delegateAdapter()
|
|
1174
1250
|
if (real !== undefined && typeof real.listModels === 'function') {
|
|
1175
1251
|
try {
|
|
1176
|
-
const listed = await real
|
|
1252
|
+
const listed = await getOfficialDeepSeekCatalog(real)
|
|
1177
1253
|
entries.push(
|
|
1178
1254
|
...(Array.isArray(listed) ? listed : [])
|
|
1179
1255
|
.filter((model) => model && typeof model.id === 'string' && model.id !== '')
|
|
@@ -1239,7 +1315,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1239
1315
|
if (typeof real.listModels !== 'function') {
|
|
1240
1316
|
throw new Error('vision-router: the official DeepSeek catalog is not available')
|
|
1241
1317
|
}
|
|
1242
|
-
const listed = await real
|
|
1318
|
+
const listed = await getOfficialDeepSeekCatalog(real)
|
|
1243
1319
|
const admitted = Array.isArray(listed) && listed.some(
|
|
1244
1320
|
(entry) => entry && entry.id === model,
|
|
1245
1321
|
)
|
|
@@ -1984,7 +2060,9 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
1984
2060
|
if (!message || message.role !== 'user' || !Array.isArray(message.content)) continue
|
|
1985
2061
|
// Deep collection: images nested inside tool-result blocks also
|
|
1986
2062
|
// identify this turn's subject and deserve memory recording.
|
|
1987
|
-
for (const found of collectImageBlocks([message]))
|
|
2063
|
+
for (const found of collectImageBlocks([message])) {
|
|
2064
|
+
if (!isOffloadedImageBlock(found.block)) imageIds.push(found.id)
|
|
2065
|
+
}
|
|
1988
2066
|
if (imageIds.length > 0) break
|
|
1989
2067
|
}
|
|
1990
2068
|
let finalText = ''
|
|
@@ -2257,16 +2335,100 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2257
2335
|
},
|
|
2258
2336
|
'vision-router: reactive routing mounts',
|
|
2259
2337
|
)
|
|
2260
|
-
// #208: attachment refs
|
|
2261
|
-
//
|
|
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.
|
|
2262
2341
|
|
|
2263
2342
|
// Host-owned proxy overrides are scoped by lib/legacy-global-proxy-boundary.js.
|
|
2264
2343
|
// Core no longer owns or installs a process-wide proxy fetch implementation.
|
|
2265
2344
|
|
|
2266
|
-
const
|
|
2345
|
+
const resolveAttachment = (session, id) => sessionVisionIndex.resolveAttachment(session, id)
|
|
2346
|
+
const resolveAttachments = (session, ids) => sessionVisionIndex.resolveAttachments(session, ids)
|
|
2267
2347
|
|
|
2268
|
-
//
|
|
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.
|
|
2269
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
|
+
})
|
|
2270
2432
|
|
|
2271
2433
|
ctx.on('agent/pre-step', async (payload, next) => {
|
|
2272
2434
|
let decision = await next()
|
|
@@ -2291,7 +2453,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2291
2453
|
// settings 无该 key)一眼可见 "instant=off"。
|
|
2292
2454
|
if (ctx.logger) {
|
|
2293
2455
|
const hasImage = rawMessages.some(
|
|
2294
|
-
(message) => message && Array.isArray(message.content) &&
|
|
2456
|
+
(message) => message && Array.isArray(message.content) && blocksHaveRetainedImage(message.content),
|
|
2295
2457
|
)
|
|
2296
2458
|
if (hasImage) {
|
|
2297
2459
|
ctx.logger.info(
|
|
@@ -2308,18 +2470,18 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2308
2470
|
// repair; Core retains only the current inbox sanitizer.
|
|
2309
2471
|
const sanitizedToolResults = sanitizeToolResultImages(rawMessages)
|
|
2310
2472
|
const messages = sanitizedToolResults.messages
|
|
2311
|
-
const hasImage = messages.some((message) =>
|
|
2473
|
+
const hasImage = messages.some((message) => blocksHaveRetainedImage(message && message.content))
|
|
2312
2474
|
|
|
2313
2475
|
// Register the turn state BEFORE the image-turn branches below: those
|
|
2314
2476
|
// branches return early (auto-mount reminder, history rewrite), and the
|
|
2315
2477
|
// agent/request hook must still see the state, otherwise an image turn is
|
|
2316
2478
|
// served by the text provider and rejected (issue #74, second root cause).
|
|
2317
2479
|
if (routingEnabled()) {
|
|
2318
|
-
const
|
|
2480
|
+
const capture = hasImage ? {} : await captureTurnTail(session)
|
|
2319
2481
|
turnState.set(session, {
|
|
2320
2482
|
turn: payload.turn,
|
|
2321
|
-
startIndex: events.length,
|
|
2322
2483
|
hasImage,
|
|
2484
|
+
...capture,
|
|
2323
2485
|
})
|
|
2324
2486
|
}
|
|
2325
2487
|
let bootstrapState = structuredBootstrapTurnState.get(session)
|
|
@@ -2536,15 +2698,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2536
2698
|
if (!session) return config0
|
|
2537
2699
|
const state = turnState.get(session)
|
|
2538
2700
|
if (!state || state.turn !== payload.turn) return config0
|
|
2539
|
-
if (!state.hasImage)
|
|
2540
|
-
const events = getSessionEvents(session) ?? []
|
|
2541
|
-
for (let i = state.startIndex; i < events.length; i++) {
|
|
2542
|
-
if (eventHasImage(events[i])) {
|
|
2543
|
-
state.hasImage = true
|
|
2544
|
-
break
|
|
2545
|
-
}
|
|
2546
|
-
}
|
|
2547
|
-
}
|
|
2701
|
+
if (!state.hasImage) await refreshTurnImageState(session, state)
|
|
2548
2702
|
if (!state.hasImage) {
|
|
2549
2703
|
// Reverse routing: the session's entry model is a vision provider
|
|
2550
2704
|
// (needed to pass the prompt admission); send text-only turns back
|
|
@@ -2654,6 +2808,10 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2654
2808
|
...attachmentIds.map((id) => String(id)).filter((id) => isAttachmentIdInput(id)),
|
|
2655
2809
|
...paths.map((item) => String(item)).filter((item) => isAttachmentIdInput(item)),
|
|
2656
2810
|
])]
|
|
2811
|
+
const attachmentSession = exec && exec.agent && exec.agent.session
|
|
2812
|
+
const resolvedAttachmentRefs = materializableAttachmentIds.length > 0
|
|
2813
|
+
? await resolveAttachments(attachmentSession, materializableAttachmentIds)
|
|
2814
|
+
: new Map()
|
|
2657
2815
|
|
|
2658
2816
|
for (const path of paths) {
|
|
2659
2817
|
let bytes
|
|
@@ -2662,7 +2820,7 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2662
2820
|
// readImageBytes accepts both filesystem paths and attachment ids
|
|
2663
2821
|
// ("sha256:..."), so a model that passes an uploaded image's id as
|
|
2664
2822
|
// a path gets the right pixels instead of a not-found error.
|
|
2665
|
-
;({ bytes, mediaType } = await readImageBytes(exec, path))
|
|
2823
|
+
;({ bytes, mediaType } = await readImageBytes(exec, path, resolvedAttachmentRefs))
|
|
2666
2824
|
} catch (error) {
|
|
2667
2825
|
throw new Error(
|
|
2668
2826
|
`vision_describe: failed to read ${path} (${error && error.message ? error.message : String(error)})`,
|
|
@@ -2694,8 +2852,10 @@ export function apply(ctx, config = {}, runtime = {}) {
|
|
|
2694
2852
|
}
|
|
2695
2853
|
|
|
2696
2854
|
for (const id of attachmentIds) {
|
|
2697
|
-
const
|
|
2698
|
-
const ref =
|
|
2855
|
+
const attachmentId = String(id)
|
|
2856
|
+
const ref = isAttachmentIdInput(attachmentId)
|
|
2857
|
+
? resolvedAttachmentRefs.get(attachmentId)
|
|
2858
|
+
: await resolveAttachment(attachmentSession, attachmentId)
|
|
2699
2859
|
if (ref === undefined) {
|
|
2700
2860
|
throw new Error(
|
|
2701
2861
|
`vision_describe: unknown attachment id "${id}" (it must come from an image uploaded in this conversation)`,
|
|
@@ -3078,7 +3238,7 @@ ctx.logger?.info(
|
|
|
3078
3238
|
tool: 'vision_materialize',
|
|
3079
3239
|
attachmentIds: materializableAttachmentIds,
|
|
3080
3240
|
advice:
|
|
3081
|
-
'
|
|
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.',
|
|
3082
3242
|
}
|
|
3083
3243
|
}
|
|
3084
3244
|
return JSON.stringify(baseFailure)
|
|
@@ -3189,7 +3349,31 @@ ctx.logger?.info(
|
|
|
3189
3349
|
? config.artifactsDir
|
|
3190
3350
|
: '.dsh-vision-router/artifacts'
|
|
3191
3351
|
|
|
3192
|
-
const
|
|
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) => {
|
|
3193
3377
|
const input = String(imagePath ?? '')
|
|
3194
3378
|
let bytes
|
|
3195
3379
|
let storedMediaType
|
|
@@ -3202,7 +3386,10 @@ ctx.logger?.info(
|
|
|
3202
3386
|
throw new Error('vision-router: the attachment service is not available in this deployment')
|
|
3203
3387
|
}
|
|
3204
3388
|
const session = exec && exec.agent && exec.agent.session
|
|
3205
|
-
const
|
|
3389
|
+
const canonicalInput = input.trim()
|
|
3390
|
+
const ref = resolvedAttachmentRefs instanceof Map
|
|
3391
|
+
? resolvedAttachmentRefs.get(canonicalInput)
|
|
3392
|
+
: await resolveAttachment(session, canonicalInput)
|
|
3206
3393
|
if (ref === undefined) {
|
|
3207
3394
|
throw new Error(
|
|
3208
3395
|
`vision-router: unknown attachment id "${input}" (it must come from an image uploaded in this conversation)`,
|
|
@@ -3250,6 +3437,8 @@ ctx.logger?.info(
|
|
|
3250
3437
|
|
|
3251
3438
|
const saveArtifact = async (exec, relPath, data) =>
|
|
3252
3439
|
writeArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
|
|
3440
|
+
const savePersistentArtifact = async (exec, relPath, data) =>
|
|
3441
|
+
writePersistentArtifactFile(workspaceOf(exec), artifactsRel, relPath, data)
|
|
3253
3442
|
|
|
3254
3443
|
const artifactStem = (imagePath, suffix) => artifactStemOf(imagePath, suffix)
|
|
3255
3444
|
|
|
@@ -3263,7 +3452,8 @@ ctx.logger?.info(
|
|
|
3263
3452
|
deepToolDefs.push({
|
|
3264
3453
|
name: 'vision_materialize',
|
|
3265
3454
|
description:
|
|
3266
|
-
'Copy an uploaded image attachment (sha256:...) or readable local image into
|
|
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. ' +
|
|
3267
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. ' +
|
|
3268
3458
|
'Never guess the attachment store path or search for a same-named file.',
|
|
3269
3459
|
parameters: {
|
|
@@ -3277,6 +3467,14 @@ ctx.logger?.info(
|
|
|
3277
3467
|
output: stringOutput,
|
|
3278
3468
|
async execute(args, exec) {
|
|
3279
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
|
+
}
|
|
3280
3478
|
const { bytes, mediaType } = await readImageBytes(exec, source)
|
|
3281
3479
|
const extension = mediaType === 'image/jpeg'
|
|
3282
3480
|
? 'jpg'
|
|
@@ -3285,9 +3483,26 @@ ctx.logger?.info(
|
|
|
3285
3483
|
: mediaType === 'image/gif'
|
|
3286
3484
|
? 'gif'
|
|
3287
3485
|
: 'png'
|
|
3288
|
-
const
|
|
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
|
+
}
|
|
3289
3503
|
return JSON.stringify({
|
|
3290
3504
|
path: target,
|
|
3505
|
+
workspaceRelativePath,
|
|
3291
3506
|
mediaType,
|
|
3292
3507
|
bytes: bytes.length,
|
|
3293
3508
|
...(isAttachmentIdInput(source) ? { source } : {}),
|
|
@@ -3678,6 +3893,14 @@ ctx.logger?.info(
|
|
|
3678
3893
|
},
|
|
3679
3894
|
output: stringOutput,
|
|
3680
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
|
+
}
|
|
3681
3904
|
const { bytes } = await readImageBytes(exec, args.image)
|
|
3682
3905
|
const { width, height } = await imageDims(bytes)
|
|
3683
3906
|
const box = parseBox(args.region)
|
|
@@ -3709,11 +3932,17 @@ ctx.logger?.info(
|
|
|
3709
3932
|
} finally {
|
|
3710
3933
|
releaseCrop()
|
|
3711
3934
|
}
|
|
3712
|
-
const
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
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
|
+
}
|
|
3717
3946
|
const meta = await sharp(cropped).metadata()
|
|
3718
3947
|
return JSON.stringify({
|
|
3719
3948
|
path: target,
|
|
@@ -3978,34 +4207,83 @@ ctx.logger?.info(
|
|
|
3978
4207
|
'ACCURACY: OCR transcribes characters verbatim and is systematically unreliable for confusable ' +
|
|
3979
4208
|
'glyphs (1/l, 0/O), spacing and line breaks; prefer vision_describe / vision_detect for semantic ' +
|
|
3980
4209
|
'understanding and use OCR only when exact verbatim text is required (executable code, exact ' +
|
|
3981
|
-
'quotation, forms/contracts, table digits, CAPTCHAs).
|
|
3982
|
-
'
|
|
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.',
|
|
3983
4217
|
parameters: {
|
|
3984
4218
|
type: 'object',
|
|
3985
4219
|
properties: {
|
|
3986
|
-
image: { type: 'string', description: '
|
|
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
|
+
},
|
|
3987
4228
|
engine: {
|
|
3988
4229
|
type: 'string',
|
|
3989
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.',
|
|
3990
4231
|
},
|
|
3991
4232
|
},
|
|
3992
|
-
required: ['image'],
|
|
3993
4233
|
additionalProperties: false,
|
|
3994
4234
|
},
|
|
3995
4235
|
output: stringOutput,
|
|
3996
4236
|
async execute(args, exec) {
|
|
3997
|
-
const
|
|
4237
|
+
const imageInput = resolveOcrImageInput(args)
|
|
4238
|
+
const session = exec?.agent?.session
|
|
3998
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)
|
|
3999
4252
|
// ONE OCR budget shared by tesseract AND the vision fallback: tesseract
|
|
4000
4253
|
// gets a capped slice (never more than 12s), the vision model only the
|
|
4001
4254
|
// remainder. The two timeouts can never stack into a multi-minute wait.
|
|
4002
4255
|
const deadline = createDeadline(ocrBudgetMs())
|
|
4003
4256
|
const tesseractSlice = Math.min(12000, deadline.remaining())
|
|
4004
4257
|
if (engine !== 'vision') {
|
|
4258
|
+
let localAttempted = false
|
|
4005
4259
|
try {
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
if (
|
|
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 })
|
|
4009
4287
|
} catch (error) {
|
|
4010
4288
|
if (engine === 'tesseract') {
|
|
4011
4289
|
throw new Error(
|
|
@@ -4013,6 +4291,13 @@ ctx.logger?.info(
|
|
|
4013
4291
|
)
|
|
4014
4292
|
}
|
|
4015
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
|
+
}
|
|
4016
4301
|
}
|
|
4017
4302
|
}
|
|
4018
4303
|
if (deadline.expired()) {
|
|
@@ -4850,10 +5135,16 @@ ctx.logger?.info(
|
|
|
4850
5135
|
// Runtime takeover state: lets the settings card explain the
|
|
4851
5136
|
// keep-alive fallback when stealth is off but the stock route is
|
|
4852
5137
|
// disabled at the composition layer.
|
|
5138
|
+
const officialRouteAvailable = adapterAvailable(ctx.llm, 'deepseek-official')
|
|
4853
5139
|
result.stealth = {
|
|
4854
5140
|
configured: stealthEnabled,
|
|
4855
5141
|
active: stealthActive,
|
|
4856
|
-
reason: stealthActive
|
|
5142
|
+
reason: stealthActive
|
|
5143
|
+
? takeoverReason
|
|
5144
|
+
: hostOwnsOfficialDeepSeek && !officialRouteAvailable
|
|
5145
|
+
? 'host-owned-official-unavailable'
|
|
5146
|
+
: undefined,
|
|
5147
|
+
hostOwned: hostOwnsOfficialDeepSeek,
|
|
4857
5148
|
}
|
|
4858
5149
|
res.writeHead(result.ok ? 200 : 502, { 'content-type': 'application/json' })
|
|
4859
5150
|
res.end(JSON.stringify(result))
|