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.
- package/README.md +8 -14
- package/README.zh.md +8 -14
- package/cordis.patch.yml +14 -0
- 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.1.7.md +13 -0
- package/docs/releases/v2.2.0.md +25 -0
- package/docs/remote-settings.md +2 -0
- package/entry.js +2 -0
- package/index.js +387 -77
- 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 +293 -48
- package/lib/core-primitives.js +122 -7
- 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/live-model-discovery.js +125 -15
- 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 +28 -5
- package/lib/replay-delegation.js +68 -42
- package/lib/runtime-composition.js +15 -2
- package/lib/runtime-config-normalizer.js +271 -1
- 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-background-stop-store.js +30 -20
- package/lib/vision-breaker-shadow-health.js +15 -5
- package/lib/vision-capability-probe.js +24 -14
- package/lib/vision-evidence-guidance.js +39 -0
- package/lib/vision-image-input-verdict.js +22 -7
- package/lib/vision-resilience.js +73 -1
- package/lib/web-capability-boundary.js +85 -22
- 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
|
-
//
|
|
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) {
|
|
@@ -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
|
|
1135
|
-
// adapter
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
1252
|
+
const listed = await getOfficialDeepSeekCatalog(real)
|
|
1173
1253
|
entries.push(
|
|
1174
|
-
...listed
|
|
1175
|
-
.filter((model) =>
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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]))
|
|
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
|
|
2242
|
-
//
|
|
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
|
|
2345
|
+
const resolveAttachment = (session, id) => sessionVisionIndex.resolveAttachment(session, id)
|
|
2346
|
+
const resolveAttachments = (session, ids) => sessionVisionIndex.resolveAttachments(session, ids)
|
|
2248
2347
|
|
|
2249
|
-
//
|
|
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) &&
|
|
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) =>
|
|
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
|
|
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
|
|
2679
|
-
const ref =
|
|
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
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
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).
|
|
3963
|
-
'
|
|
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: '
|
|
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
|
|
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
|
-
|
|
3988
|
-
|
|
3989
|
-
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 })
|
|
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
|
|
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))
|