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