dsh-vision-router 2.0.0 → 2.0.1

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.
@@ -0,0 +1,17 @@
1
+ # v2.0.1
2
+
3
+ 这是一个针对长任务识图设置与诊断的补丁版本。
4
+
5
+ ## 修复
6
+
7
+ - 修复 `visionTaskTimeoutMs` 在浏览器设置页与 Host schema 之间的校验漂移:客户端现在会在写入前正确检查范围和步进,避免 Host 拒绝后只留下 `readback-mismatch`。
8
+ - 明确区分“单次识图任务超时”和“首次识图后的整轮时间上限”;整轮限制继续沿用既有 wall-clock 语义。
9
+ - 保持 v2 默认 `visionTurnBudgetMs=0`(不限制);不会自动迁移或清除用户已有的显式正数配置。
10
+ - 当整轮视觉时间上限耗尽时,guard 与日志会显示实际生效的限制;Diagnostics 与 Doctor 也会显示有效值及其来源,便于定位旧 profile / composition 覆盖。
11
+ - 增加针对设置边界、预算诊断、Doctor 输出和运行时结构不变量的回归测试。
12
+
13
+ ## Compatibility
14
+
15
+ - No change to #220/#295 lazy activation or wall-clock accounting.
16
+ - No change to the v2 default: `visionTurnBudgetMs=0` remains unlimited.
17
+ - Existing explicit positive whole-turn limits remain honored.
package/entry.js CHANGED
@@ -34,6 +34,7 @@ import { installLocalMutationRouteBoundary } from './lib/web-capability-boundary
34
34
  import { installScreenshotSourceBoundary } from './lib/screenshot-source-boundary.js'
35
35
  import { installVisionToolRuntimeBoundary } from './lib/vision-tool-runtime-boundary.js'
36
36
  import { installVisionRouterRemoteSettingsBridge } from './lib/remote-settings-bridge.js'
37
+ import { installSettingsLimitClientPrelude } from './lib/settings-limit-client-prelude.js'
37
38
  import { installSettingsRc8ClientLifecycle } from './lib/settings-client-rc8-lifecycle.js'
38
39
  import { installCapabilityShadowRuntime } from './lib/vision-capability-shadow.js'
39
40
  import { createCapabilityProfileStore } from './lib/vision-capability-probe.js'
@@ -56,6 +57,7 @@ import {
56
57
  installStructuredFlowHardening,
57
58
  normalizeGuidanceOverrides,
58
59
  } from './lib/structured-flow-hardening.js'
60
+ import { installVisionLimitDiagnostics } from './lib/vision-limit-diagnostics.js'
59
61
  import {
60
62
  attachmentContextForContract,
61
63
  hasBatchAttachmentContract,
@@ -205,6 +207,9 @@ export function apply(ctx, config = {}) {
205
207
  }
206
208
  const batchAttachmentHost = hasBatchAttachmentContract(stabilizedCtx)
207
209
  if (batchAttachmentHost) installVisionAttachmentAdmissionPolicy(stabilizedCtx, logging.logger)
210
+ // Install this before the consolidated Settings IA transform registered by
211
+ // the remote-settings bridge so the numeric fence remains the outer wrapper.
212
+ installSettingsLimitClientPrelude(stabilizedCtx)
208
213
  installVisionRouterRemoteSettingsBridge(stabilizedCtx, logging.logger)
209
214
  installSettingsRc8ClientLifecycle(stabilizedCtx)
210
215
  const ownershipCtx = batchAttachmentHost ? protectHostProviderOwnership(stabilizedCtx) : stabilizedCtx
@@ -230,8 +235,15 @@ export function apply(ctx, config = {}) {
230
235
  // actual tool registrations and pre-step listener. It makes bootstrap
231
236
  // one-shot, enforces fast/standard/deep/custom quotas, tracks mixed branches,
232
237
  // rejects empty/non-evidence results, and applies the optional turn deadline
233
- // only when the user explicitly configures one.
234
- const structuredCtx = installStructuredFlowHardening(legacyCoreCompat.ctx, legacyCoreCompat.config)
238
+ // only when the user explicitly configures one. The diagnostic observer sits
239
+ // immediately inside it so it can inspect the final budget result/guard while
240
+ // leaving #220/#295 wall-clock semantics untouched.
241
+ const limitDiagnosticCtx = installVisionLimitDiagnostics(
242
+ legacyCoreCompat.ctx,
243
+ legacyCoreCompat.config,
244
+ logging.logger,
245
+ )
246
+ const structuredCtx = installStructuredFlowHardening(limitDiagnosticCtx, legacyCoreCompat.config)
235
247
  const backgroundProfiling = installBackgroundCapabilityProfiling(
236
248
  structuredCtx,
237
249
  runtimeConfig,
@@ -369,4 +381,4 @@ export function apply(ctx, config = {}) {
369
381
  )
370
382
  throw error
371
383
  }
372
- }
384
+ }
package/lib/doctor-cli.js CHANGED
@@ -4,6 +4,7 @@ import { realpathSync } from 'node:fs'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { doctorProfiles, resolveDshHome } from './doctor.js'
6
6
  import { inspectPlatform, probeRuntime, supportReport } from './doctor-runtime.js'
7
+ import { inspectDoctorVisionLimits, formatDoctorVisionLimits } from './doctor-vision-limits.js'
7
8
  import { repairLegacySessionLogs } from './legacy-session-repair.js'
8
9
 
9
10
  function usage() {
@@ -206,6 +207,7 @@ export async function run(argv = process.argv.slice(2), io = console, env = proc
206
207
  .filter((item) => item.installation?.applicable && item.profileDir)
207
208
  .map((item) => item.profileDir)
208
209
  const platform = inspectPlatform({ profileDirs: applicableDirs })
210
+ const visionLimits = inspectDoctorVisionLimits(profileReport.log?.file)
209
211
  let sessions
210
212
  if (options.sessions) sessions = repairLegacySessionLogs({ dshHome, fix: false })
211
213
 
@@ -215,6 +217,7 @@ export async function run(argv = process.argv.slice(2), io = console, env = proc
215
217
 
216
218
  if (options.json) {
217
219
  const report = supportReport({ profileReport, runtime, platform, sessions })
220
+ report.visionLimits = visionLimits
218
221
  report.ok = overallOk
219
222
  io.log(JSON.stringify(report, null, 2))
220
223
  return overallOk ? 0 : 1
@@ -231,6 +234,10 @@ export async function run(argv = process.argv.slice(2), io = console, env = proc
231
234
  if (profileReport.log?.historicalSettingsSaveFailures?.length > 0) {
232
235
  io.log(`! Historical settings save failures exist (${profileReport.log.historicalSettingsSaveFailures.length}); none are attributed to the latest plugin start.`)
233
236
  }
237
+ for (const line of formatDoctorVisionLimits(visionLimits)) {
238
+ if (line.startsWith('WARN:')) io.log(`! ${line.slice('WARN:'.length).trim()}`)
239
+ else io.log(`✓ ${line}`)
240
+ }
234
241
 
235
242
  if (runtime) {
236
243
  if (!runtime.reachable) io.log(`– Runtime probe: DSH not reachable at ${runtime.baseUrl}; offline checks still completed.`)
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs'
2
+
3
+ const LIMIT_LINE = /vision-router: effective vision limits\s+taskTimeoutMs=(\d+)\s+taskSource=([^\s]+)\s+turnBudgetMs=(\d+)\s+turnSource=([^\s]+)/i
4
+ const EXHAUSTED_LINE = /vision-router: vision turn deadline exhausted\s+turn=([^\s]+)\s+budgetMs=(\d+)\s+elapsedMs=([^\s]+)/i
5
+
6
+ function readTail(file, maxBytes = 2 * 1024 * 1024) {
7
+ if (!file || !existsSync(file)) return ''
8
+ let fd
9
+ try {
10
+ const stat = statSync(file)
11
+ const size = Math.min(stat.size, maxBytes)
12
+ if (size <= 0) return ''
13
+ const buffer = Buffer.alloc(size)
14
+ fd = openSync(file, 'r')
15
+ readSync(fd, buffer, 0, size, Math.max(0, stat.size - size))
16
+ return buffer.toString('utf8')
17
+ } catch {
18
+ try { return readFileSync(file, 'utf8') } catch { return '' }
19
+ } finally {
20
+ if (fd !== undefined) closeSync(fd)
21
+ }
22
+ }
23
+
24
+ export function parseVisionLimitLog(text) {
25
+ let latest
26
+ let latestExhaustion
27
+ for (const line of String(text || '').split(/\r?\n/)) {
28
+ const limit = LIMIT_LINE.exec(line)
29
+ if (limit) {
30
+ latest = {
31
+ taskTimeoutMs: Number(limit[1]),
32
+ taskSource: limit[2],
33
+ turnBudgetMs: Number(limit[3]),
34
+ turnSource: limit[4],
35
+ }
36
+ }
37
+ const exhausted = EXHAUSTED_LINE.exec(line)
38
+ if (exhausted) {
39
+ latestExhaustion = {
40
+ turn: exhausted[1],
41
+ budgetMs: Number(exhausted[2]),
42
+ elapsedMs: exhausted[3] === 'unknown' ? undefined : Number(exhausted[3]),
43
+ }
44
+ }
45
+ }
46
+ if (!latest && !latestExhaustion) return undefined
47
+ return {
48
+ ...latest,
49
+ latestExhaustion,
50
+ explicitTurnLimit: Number(latest?.turnBudgetMs) > 0,
51
+ }
52
+ }
53
+
54
+ export function inspectDoctorVisionLimits(logFile) {
55
+ return parseVisionLimitLog(readTail(logFile))
56
+ }
57
+
58
+ export function formatDoctorVisionLimits(limits) {
59
+ if (!limits) return []
60
+ const task = Number.isFinite(limits.taskTimeoutMs) ? `${limits.taskTimeoutMs / 1000}s` : 'unknown'
61
+ const turn = Number.isFinite(limits.turnBudgetMs)
62
+ ? limits.turnBudgetMs === 0 ? 'unlimited' : `${limits.turnBudgetMs / 1000}s`
63
+ : 'unknown'
64
+ const lines = [
65
+ `Vision task timeout: ${task}${limits.taskSource ? ` (${limits.taskSource})` : ''}`,
66
+ `Vision turn deadline: ${turn}${limits.turnSource ? ` (${limits.turnSource})` : ''}`,
67
+ ]
68
+ if (limits.explicitTurnLimit) {
69
+ lines.push('WARN: an explicit whole-turn vision deadline is active; v2 defaults to unlimited and long multi-image tasks may stop after this deadline.')
70
+ }
71
+ return lines
72
+ }
@@ -0,0 +1,110 @@
1
+ import { SETTINGS_NUMBER_META } from './settings-number-contract.js'
2
+
3
+ const CLIENT_MARK = 'data-vision-router-settings-limit-hardening'
4
+ const META = JSON.stringify(SETTINGS_NUMBER_META)
5
+
6
+ export const SETTINGS_LIMIT_CLIENT_PRELUDE = String.raw`(function(){
7
+ 'use strict';
8
+ var TARGET='dsh-vision-router';
9
+ var SECTION_ID='vision-router';
10
+ var META=${META};
11
+
12
+ function obj(value){return value&&typeof value==='object'&&!Array.isArray(value)?value:{};}
13
+ function own(value,key){return !!value&&typeof value==='object'&&Object.prototype.hasOwnProperty.call(value,key);}
14
+ function validNumber(key,raw){var meta=META[key];if(!meta)return true;var n=Number(raw);if(!Number.isFinite(n)||!Number.isInteger(n)||n<meta.min||n>meta.max)return false;return (n-meta.min)%meta.step===0;}
15
+ function source(snapshot,key){var user=obj(snapshot&&snapshot.user),base=obj(snapshot&&snapshot.base);if(own(user,key))return ['用户设置','User override'];if(own(base,key))return ['Profile / Composition','Profile / Composition'];return ['默认','Default'];}
16
+ function seconds(value,unlimited){var n=Number(value);if(unlimited&&n===0)return ['不限制','Unlimited'];if(!Number.isFinite(n))return ['未知','Unknown'];return [String(n/1000)+' 秒',String(n/1000)+'s'];}
17
+ function zh(){try{return String(document.documentElement.lang||'').toLowerCase().startsWith('zh');}catch(_){return true;}}
18
+ function tx(pair){return zh()?pair[0]:pair[1];}
19
+
20
+ function wrapScope(scope){
21
+ if(!scope||typeof scope!=='object')return scope;
22
+ return new Proxy(scope,{get:function(target,property){
23
+ if(property==='set'){
24
+ var set=Reflect.get(target,property,target);if(typeof set!=='function')return set;
25
+ return function(key,value){
26
+ if(META[key]&&!validNumber(key,value)){
27
+ var meta=META[key],error=new Error('value must be an integer between '+meta.min+' and '+meta.max+' in steps of '+meta.step);
28
+ error.code='settings-client-validation';
29
+ return Promise.reject(error);
30
+ }
31
+ return set.call(target,key,value);
32
+ };
33
+ }
34
+ var value=Reflect.get(target,property,target);return typeof value==='function'?value.bind(target):value;
35
+ }});
36
+ }
37
+
38
+ function textOf(React,node){
39
+ if(node===null||node===undefined||node===false)return '';
40
+ if(typeof node==='string'||typeof node==='number')return String(node);
41
+ if(!React.isValidElement||!React.isValidElement(node))return '';
42
+ var out='';React.Children.forEach(node.props&&node.props.children,function(child){out+=textOf(React,child)+' ';});return out;
43
+ }
44
+
45
+ function replacement(text){
46
+ var map={
47
+ '单个视觉任务':'单次识图任务超时',
48
+ 'Single visual task':'Single vision-task timeout',
49
+ '包含该任务内部的重试和备用模型;不是每个后端各自一份。':'包含本次识图内部的重试和备用模型;最长 180 秒。它不会增加本轮视觉时间上限。',
50
+ 'Includes retries and fallbacks inside the task; it is not a fresh budget per backend.':'Includes retries and fallbacks for this vision task; maximum 180s. It does not extend the whole-turn vision deadline.',
51
+ '整轮视觉工具上限':'首次识图后的整轮时间上限',
52
+ 'Whole-turn vision-tool limit':'Whole-turn limit after first vision call',
53
+ '部分配置没有写入,未写入的修改已保留。':'Host 未接受或未持久化这项设置,修改已保留。请检查允许范围/步进,或确认 DSH 与 Vision Router 版本匹配。',
54
+ 'Some settings were not written; unwritten changes were kept.':'The Host did not accept or persist this setting. The edit was kept; check the allowed range/step and DSH/Vision Router version compatibility.'
55
+ };
56
+ return Object.prototype.hasOwnProperty.call(map,text)?map[text]:text;
57
+ }
58
+
59
+ function transformTree(React,node){
60
+ if(typeof node==='string')return replacement(node);
61
+ if(node===null||node===undefined||node===false||!React.isValidElement||!React.isValidElement(node))return node;
62
+ var originalText=textOf(React,node),props={};
63
+ if(node.props&&node.props.children!==undefined){props.children=React.Children.map(node.props.children,function(child){return transformTree(React,child);});}
64
+ if(node.type==='input'&&node.props&&node.props.type==='number'){
65
+ var min=Number(node.props.min),max=Number(node.props.max),match;
66
+ Object.keys(META).some(function(key){var meta=META[key];if(meta.min===min&&meta.max===max){match=meta;return true;}return false;});
67
+ if(match)props.step=match.step;
68
+ }
69
+ var next=React.cloneElement(node,props);
70
+ if(node.type==='div'&&node.props&&String(node.props.className||'').includes('vr-field')&&originalText.includes('整轮视觉工具上限')){
71
+ var hint=React.createElement('p',{className:'vr-hint',key:'issue307-budget-hint'},zh()
72
+ ?'从本轮第一次实际 Vision Router 识图调用开始按墙钟计时;模型思考和两次识图之间的等待也计入。长任务建议保持“不限制”。'
73
+ :'Wall-clock time starts at the first actual Vision Router vision call; model reasoning and waits between calls also count. Keep Unlimited for long tasks.');
74
+ var children=React.Children.toArray(next.props.children);return React.cloneElement(next,{children:children.concat([hint])});
75
+ }
76
+ return next;
77
+ }
78
+
79
+ function diagnosticsCard(React,snapshot){
80
+ var value=obj(snapshot&&snapshot.value),task=seconds(value.visionTaskTimeoutMs===undefined?120000:value.visionTaskTimeoutMs,false),budget=seconds(value.visionTurnBudgetMs===undefined?0:value.visionTurnBudgetMs,true),taskSource=source(snapshot,'visionTaskTimeoutMs'),budgetSource=source(snapshot,'visionTurnBudgetMs');
81
+ function row(label,valueText){return React.createElement('div',{className:'vr-ia-diag-row'},React.createElement('span',null,label),React.createElement('strong',null,valueText));}
82
+ return React.createElement('div',{className:'vr-card vr-card-open vr-ia-card','data-vr-limit-diagnostics':'1'},React.createElement('div',{className:'vr-body',style:{borderTop:0}},[
83
+ row(zh()?'单次识图任务超时':'Single vision-task timeout',tx(task)+' · '+tx(taskSource)),
84
+ row(zh()?'首次识图后的整轮时间上限':'Whole-turn limit after first vision call',tx(budget)+' · '+tx(budgetSource)),
85
+ Number(value.visionTurnBudgetMs)>0?React.createElement('p',{className:'vr-hint',key:'warning'},zh()?'当前启用了显式整轮视觉时间上限;v2 默认是不限制,长任务可能在该时间后停止继续识图。':'An explicit whole-turn vision limit is active. v2 defaults to Unlimited; long tasks may stop making vision calls after this time.'):null
86
+ ]));
87
+ }
88
+
89
+ function wrapSlots(slots,React){if(!slots||(typeof slots!=='object'&&typeof slots!=='function'))return slots;return new Proxy(slots,{get:function(target,property){if(property==='register'){var register=Reflect.get(target,property,target);if(typeof register!=='function')return register;return function(options,component){var args=Array.prototype.slice.call(arguments);if(options&&options.name==='settings.section'&&options.id===SECTION_ID&&typeof component==='function'){var Original=component;args[1]=function VisionRouterIssue307SettingsBoundary(props){var scope=wrapScope(props&&props.scope),nextProps=Object.assign({},props,{scope:scope}),tree=transformTree(React,Original(nextProps)),text=textOf(React,tree),snapshot;try{snapshot=scope&&scope.getSnapshot?scope.getSnapshot():undefined;}catch(_){snapshot=undefined;}if(text.includes('设置协议')||text.includes('Settings contract'))return React.createElement(React.Fragment,null,tree,diagnosticsCard(React,snapshot));return tree;};}return register.apply(target,args);};}var value=Reflect.get(target,property,target);return typeof value==='function'?value.bind(target):value;}});}
90
+ function wrapContext(ctx,React){if(!ctx||typeof ctx!=='object')return ctx;var slots=wrapSlots(ctx.slots,React);return new Proxy(ctx,{get:function(target,property){if(property==='slots')return slots;var value=Reflect.get(target,property,target);return typeof value==='function'?value.bind(target):value;}});}
91
+ function patchLoader(loader){if(!loader||(typeof loader!=='object'&&typeof loader!=='function'))return;if(typeof loader.load==='function'&&!loader.load.__visionRouterLimitHardening){var original=loader.load;function load(spec){if(spec&&spec.id===TARGET&&typeof spec.factory==='function'){var factory=spec.factory;spec=Object.assign({},spec,{factory:function(require){var exports=factory(require),React;try{React=require('react');}catch(_){React=undefined;}if(React&&exports&&typeof exports.apply==='function'&&!exports.apply.__visionRouterLimitHardening){var apply=exports.apply;var wrappedApply=function(ctx){var rest=Array.prototype.slice.call(arguments,1);return apply.apply(exports,[wrapContext(ctx,React)].concat(rest));};Object.defineProperty(wrappedApply,'__visionRouterLimitHardening',{value:true});exports.apply=wrappedApply;}return exports;}});}return original.call(loader,spec);}Object.defineProperty(load,'__visionRouterLimitHardening',{value:true});loader.load=load;}}
92
+ function install(){if(window.__ModuleLoader__){patchLoader(window.__ModuleLoader__);return;}var descriptor=Object.getOwnPropertyDescriptor(window,'__ModuleLoader__');if(descriptor&&descriptor.configurable===false)return;var stored;Object.defineProperty(window,'__ModuleLoader__',{configurable:true,enumerable:true,get:function(){return stored;},set:function(value){stored=value;patchLoader(value);Object.defineProperty(window,'__ModuleLoader__',{configurable:true,enumerable:true,writable:true,value:stored});}});}
93
+ try{install();}catch(_){}
94
+ })();`
95
+
96
+ export function injectSettingsLimitClientPrelude(html) {
97
+ if (typeof html !== 'string' || html.includes(CLIENT_MARK)) return html
98
+ const script = `<script ${CLIENT_MARK}>${SETTINGS_LIMIT_CLIENT_PRELUDE.replace(/<\/script/gi, '<\\/script')}</script>`
99
+ const closeHead = html.indexOf('</head>')
100
+ return closeHead === -1 ? `${html}${script}` : `${html.slice(0, closeHead)}${script}${html.slice(closeHead)}`
101
+ }
102
+
103
+ export function installSettingsLimitClientPrelude(ctx) {
104
+ ctx?.inject?.(['webServer'], (webCtx) => {
105
+ webCtx.effect(
106
+ () => webCtx.webServer.tapIndex(injectSettingsLimitClientPrelude),
107
+ 'vision-router: settings numeric contract and limit diagnostics prelude',
108
+ )
109
+ })
110
+ }
@@ -0,0 +1,29 @@
1
+ export const SETTINGS_NUMBER_META = Object.freeze({
2
+ timeoutMs: Object.freeze({ min: 1000, max: 600000, step: 1 }),
3
+ visionTaskTimeoutMs: Object.freeze({ min: 1000, max: 180000, step: 1000 }),
4
+ ocrTimeoutMs: Object.freeze({ min: 1000, max: 120000, step: 1 }),
5
+ downscaleMaxPixels: Object.freeze({ min: 1000, max: 100000000, step: 1 }),
6
+ cacheTtlSeconds: Object.freeze({ min: 0, max: 31536000, step: 1 }),
7
+ cacheMaxEntries: Object.freeze({ min: 1, max: 100000, step: 1 }),
8
+ visionDepthMaxCalls: Object.freeze({ min: 0, max: 100, step: 1 }),
9
+ visionTurnBudgetMs: Object.freeze({ min: 0, max: 600000, step: 1000 }),
10
+ })
11
+
12
+ export function parseSettingsNumber(key, raw, { allowClear = false } = {}) {
13
+ const meta = SETTINGS_NUMBER_META[key]
14
+ if (!meta) return undefined
15
+ if (raw === '' || raw === null || raw === undefined) return allowClear ? { clear: true } : undefined
16
+ const value = Number(raw)
17
+ if (!Number.isFinite(value) || !Number.isInteger(value)) return undefined
18
+ if (value < meta.min || value > meta.max) return undefined
19
+ if ((value - meta.min) % meta.step !== 0) return undefined
20
+ return { value }
21
+ }
22
+
23
+ export function formatDurationMs(value, { unlimitedZero = false } = {}) {
24
+ const number = Number(value)
25
+ if (!Number.isFinite(number)) return undefined
26
+ if (unlimitedZero && number === 0) return 'unlimited'
27
+ if (number % 1000 === 0) return `${number / 1000}s`
28
+ return `${number}ms`
29
+ }
@@ -0,0 +1,202 @@
1
+ const SETTINGS_NS = 'vision-router'
2
+ const TURN_BUDGET_CODE = 'VISION_TURN_BUDGET_EXCEEDED'
3
+ const GUARD_PREFIX = 'vision-router-structured-guard-stop-'
4
+
5
+ function isObject(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
7
+ }
8
+
9
+ function hasOwn(value, key) {
10
+ return isObject(value) && Object.prototype.hasOwnProperty.call(value, key)
11
+ }
12
+
13
+ function parseResult(value) {
14
+ if (isObject(value)) return value
15
+ if (typeof value !== 'string' || value.trim() === '') return undefined
16
+ try { return JSON.parse(value) } catch { return undefined }
17
+ }
18
+
19
+ function settingsService(ctx) {
20
+ try {
21
+ const settings = ctx?.get?.('settings')
22
+ return settings && typeof settings === 'object' ? settings : undefined
23
+ } catch {
24
+ return undefined
25
+ }
26
+ }
27
+
28
+ function sourceFor(descriptor, key) {
29
+ if (hasOwn(descriptor?.user, key)) return 'user'
30
+ if (hasOwn(descriptor?.base, key)) return 'composition'
31
+ return 'default'
32
+ }
33
+
34
+ export function resolveVisionLimitDiagnostics(ctx, fallbackConfig = {}) {
35
+ const settings = settingsService(ctx)
36
+ let value
37
+ let descriptor
38
+ try { value = settings?.get?.(SETTINGS_NS) } catch { value = undefined }
39
+ try {
40
+ const described = settings?.describe?.({ redactSecrets: true })
41
+ descriptor = Array.isArray(described)
42
+ ? described.find((entry) => entry?.ns === SETTINGS_NS)
43
+ : undefined
44
+ } catch {
45
+ descriptor = undefined
46
+ }
47
+ const effective = isObject(value) ? value : isObject(descriptor?.value) ? descriptor.value : fallbackConfig
48
+ const taskTimeoutMs = Number.isFinite(Number(effective?.visionTaskTimeoutMs))
49
+ ? Number(effective.visionTaskTimeoutMs)
50
+ : 120000
51
+ const turnBudgetMs = Number.isFinite(Number(effective?.visionTurnBudgetMs))
52
+ ? Number(effective.visionTurnBudgetMs)
53
+ : 0
54
+ return {
55
+ taskTimeoutMs,
56
+ turnBudgetMs,
57
+ taskSource: descriptor ? sourceFor(descriptor, 'visionTaskTimeoutMs') : 'unknown',
58
+ turnSource: descriptor ? sourceFor(descriptor, 'visionTurnBudgetMs') : 'unknown',
59
+ }
60
+ }
61
+
62
+ export function formatVisionTurnGuard(turnBudgetMs) {
63
+ const ms = Number(turnBudgetMs)
64
+ const seconds = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 1000) : undefined
65
+ const limit = seconds === undefined ? '' : `(${seconds} 秒)`
66
+ return `Vision Router 本轮视觉时间上限${limit}已耗尽。不要再调用视觉工具;请基于已经获得的证据作答,并明确仍存在的不确定性。默认配置为“不限制”;长任务如需继续识图,请检查“设置 → Vision Router → 高级 → 首次识图后的整轮时间上限”。`
67
+ }
68
+
69
+ function replaceGuardText(decision, turnBudgetMs) {
70
+ if (!isObject(decision) || !Array.isArray(decision.messages)) return decision
71
+ let anyChanged = false
72
+ const messages = decision.messages.map((message) => {
73
+ if (!isObject(message) || typeof message.id !== 'string' || !message.id.startsWith(GUARD_PREFIX)) return message
74
+ if (!Array.isArray(message.content)) return message
75
+ let messageChanged = false
76
+ const content = message.content.map((block) => {
77
+ if (!isObject(block) || block.type !== 'text' || typeof block.text !== 'string') return block
78
+ if (!block.text.includes('本轮视觉总时间预算已耗尽')) return block
79
+ messageChanged = true
80
+ anyChanged = true
81
+ return { ...block, text: formatVisionTurnGuard(turnBudgetMs) }
82
+ })
83
+ return messageChanged ? { ...message, content } : message
84
+ })
85
+ return anyChanged ? { ...decision, messages } : decision
86
+ }
87
+
88
+ export function installVisionLimitDiagnostics(ctx, fallbackConfig = {}, logger) {
89
+ if (!ctx || typeof ctx !== 'object') return ctx
90
+ const turnState = new WeakMap()
91
+ let startupLogged = false
92
+
93
+ const snapshot = () => resolveVisionLimitDiagnostics(ctx, fallbackConfig)
94
+ const logStartup = () => {
95
+ if (startupLogged) return
96
+ const limits = snapshot()
97
+ startupLogged = true
98
+ logger?.info?.(
99
+ 'vision-router: effective vision limits taskTimeoutMs=%d taskSource=%s turnBudgetMs=%d turnSource=%s',
100
+ limits.taskTimeoutMs,
101
+ limits.taskSource,
102
+ limits.turnBudgetMs,
103
+ limits.turnSource,
104
+ )
105
+ }
106
+
107
+ try {
108
+ ctx.inject?.(['settings'], (settingsCtx) => {
109
+ settingsCtx.effect?.(() => {
110
+ queueMicrotask(logStartup)
111
+ return () => {}
112
+ }, 'vision-router: report effective vision limits')
113
+ })
114
+ } catch {
115
+ // Diagnostics are best-effort and must never block plugin startup.
116
+ }
117
+
118
+ const stateForVisualCall = (session, turnHint) => {
119
+ if (!session) return undefined
120
+ let state = turnState.get(session)
121
+ if (!state || (turnHint !== undefined && state.turn !== turnHint)) {
122
+ state = { turn: turnHint, startedAt: undefined, exhaustionLogged: false }
123
+ turnState.set(session, state)
124
+ }
125
+ if (state.startedAt === undefined) state.startedAt = Date.now()
126
+ return state
127
+ }
128
+
129
+ const wrapTool = (def) => {
130
+ if (!def || typeof def.execute !== 'function') return def
131
+ if (def.name !== 'vision_bootstrap' && !String(def.name || '').startsWith('vision_')) return def
132
+ return {
133
+ ...def,
134
+ async execute(args, exec) {
135
+ const session = exec?.agent?.session
136
+ const state = stateForVisualCall(session, exec?.agent?.turn ?? exec?.turn)
137
+ const result = await def.execute(args, exec)
138
+ const parsed = parseResult(result)
139
+ if (parsed?.code !== TURN_BUDGET_CODE) return result
140
+ if (state?.exhaustionLogged) return result
141
+ if (state) state.exhaustionLogged = true
142
+ const limits = snapshot()
143
+ const elapsedMs = state?.startedAt === undefined
144
+ ? undefined
145
+ : Math.max(0, Date.now() - state.startedAt)
146
+ logger?.warn?.(
147
+ 'vision-router: vision turn deadline exhausted turn=%s budgetMs=%d elapsedMs=%s',
148
+ state?.turn === undefined ? 'unknown' : String(state.turn),
149
+ limits.turnBudgetMs,
150
+ elapsedMs === undefined ? 'unknown' : String(elapsedMs),
151
+ )
152
+ return result
153
+ },
154
+ }
155
+ }
156
+
157
+ return new Proxy(ctx, {
158
+ get(target, property) {
159
+ if (property === 'tools') {
160
+ const tools = target.tools
161
+ if (!tools || typeof tools !== 'object') return tools
162
+ return new Proxy(tools, {
163
+ get(toolTarget, toolProperty) {
164
+ if (toolProperty !== 'register') {
165
+ const value = Reflect.get(toolTarget, toolProperty, toolTarget)
166
+ return typeof value === 'function' ? value.bind(toolTarget) : value
167
+ }
168
+ const register = Reflect.get(toolTarget, toolProperty, toolTarget)
169
+ return (def) => register.call(toolTarget, wrapTool(def))
170
+ },
171
+ })
172
+ }
173
+ if (property === 'on') {
174
+ const on = Reflect.get(target, property, target)
175
+ if (typeof on !== 'function') return on
176
+ return (event, handler, ...rest) => {
177
+ if (event !== 'agent/pre-step' || typeof handler !== 'function') {
178
+ return on.call(target, event, handler, ...rest)
179
+ }
180
+ return on.call(target, event, async function issue307GuardDiagnostics(payload, next) {
181
+ const session = payload?.agent?.session
182
+ if (session) {
183
+ const current = turnState.get(session)
184
+ if (!current || current.turn !== payload?.turn) {
185
+ turnState.set(session, {
186
+ turn: payload?.turn,
187
+ startedAt: undefined,
188
+ exhaustionLogged: false,
189
+ })
190
+ }
191
+ }
192
+ const decision = await handler.call(this, payload, next)
193
+ const limits = snapshot()
194
+ return replaceGuardText(decision, limits.turnBudgetMs)
195
+ }, ...rest)
196
+ }
197
+ }
198
+ const value = Reflect.get(target, property, target)
199
+ return typeof value === 'function' ? value.bind(target) : value
200
+ },
201
+ })
202
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -69,7 +69,7 @@
69
69
  "sharp": "^0.35.3"
70
70
  },
71
71
  "scripts": {
72
- "test": "node --test tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/vision-breaker-readonly.test.js tests/client.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/replay-delegation.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/local-ollama.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/pi-ai-bridge-wire-compat.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/image-resource-governor.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-shadow.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js"
72
+ "test": "node --test tests/adapter-prepare-call-compat.test.js tests/core.test.js tests/capability-advisory.test.js tests/vision-execution-policy.test.js tests/vision-backend-diagnostics.test.js tests/live-model-discovery.test.js tests/live-model-credential-resolution.test.js tests/vision-model-registry.test.js tests/live-model-client-stability.test.js tests/providers-persistence.test.js tests/provider-directory-fallback.test.js tests/model-catalog-policy.test.js tests/settings-ia-client-prelude.test.js tests/settings-ia-fj.test.js tests/settings-ia-acceptance-regression.test.js tests/settings-migration.test.js tests/settings-section-order.test.js tests/attachment-admission-policy.test.js tests/vision-resilience.test.js tests/vision-breaker-readonly.test.js tests/client.test.js tests/issue-307-regression.test.js tests/issue-284-vision-mode-toggle.test.js tests/issue-284-vision-mode-hardening.test.js tests/issue-284-attachment-id-hint.test.js tests/issue-284-model-visibility.test.js tests/issue-284-vision-selection-effort.test.js tests/client-presentation-boundary.test.js tests/remote-settings-bridge.test.js tests/remote-settings-v2-contract.test.js tests/remote-settings-risk-confirmation.test.js tests/local-remote-settings-permission.test.js tests/rc6-real-settings-persistence.test.js tests/artifact-path-security.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/legacy-session-repair.test.js tests/profile-pnpm-diagnostics.test.js tests/doctor-v2.test.js tests/doctor-cli-v2.test.js tests/doctor-runtime.test.js tests/session-repair-v2.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/replay-delegation.test.js tests/adversarial-hardening.test.js tests/vision-adversarial-hardening-v2.test.js tests/vision-capability-benchmark-visual-proof.test.js tests/wrapper-directory.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js tests/structured-bootstrap.test.js tests/structured-bootstrap-gate.test.js tests/local-ollama.test.js tests/ollama-cold-start.test.js tests/ollama-cold-start-loaded.test.js tests/zero-regression-gate.test.js tests/runtime-e2e.test.js tests/local-vision-stabilizer.test.js tests/local-connection-probe.test.js tests/repetition-guard.test.js tests/guide-scroll-gate.test.js tests/settings-scroll-jank-regression.test.js tests/android-attachment-compat.test.js tests/free-cloud-first.test.js tests/rc6-rc7-compat.test.js tests/native-image-coexistence.test.js tests/issue-276-session-image-ownership.test.js tests/issue-276-entry-policy-bridge.test.js tests/issue-276-policy-hardening.test.js tests/issue-289-native-nonintervention.test.js tests/pi-ai-bridge-wire-compat.test.js tests/mixed-router.test.js tests/depth-tier.test.js tests/depth-quota-behavior.test.js tests/structured-flow-hardening.test.js tests/vision-budget-activation.test.js tests/runtime-boundary-fixes.test.js tests/tesseract-node24-boot.test.js tests/session-vision-state.test.js tests/session-vision-state-integration.test.js tests/image-resource-governor.test.js tests/pixel-diff-stream.test.js tests/large-image-resource-integration.test.js tests/structured-guard-idempotency.test.js tests/vision-routing-product.test.js tests/vision-runtime-performance.test.js tests/vision-routing-settings-prelude.test.js tests/v2-settings-ia-integration.test.js tests/vision-capability-router.test.js tests/vision-capability-reference.test.js tests/vision-capability-benchmark.test.js tests/vision-capability-grounding-proof-contract.test.js tests/vision-capability-probe.test.js tests/vision-capability-shadow.test.js tests/vision-capability-axis-freshness.test.js tests/vision-capability-shadow-health.test.js tests/vision-capability-benchmark-service.test.js tests/vision-capability-preflight-hardening.test.js tests/vision-capability-benchmark-contract.test.js tests/vision-capability-benchmark-client.test.js tests/vision-capability-benchmark-client-productization.test.js tests/vision-capability-adapter-route.test.js tests/vision-background-benchmark.test.js tests/vision-background-benchmark-productization.test.js tests/vision-capability-benchmark-budget.test.js tests/vision-image-input-verdict.test.js tests/vision-capability-failure-classification.test.js tests/vision-capability-gate-client.test.js tests/v2-product-review-fixes.test.js tests/qa-top5-reliability.test.js tests/qa-screenshot-runtime.test.js tests/qa-runtime-identity.test.js tests/qa-turn-budget-cancellation.test.js tests/qa-cancellation-publication.test.js tests/qa-reliability-tail.test.js tests/qa-endpoint-route-alias.test.js tests/vision-toggle-root-hardening.test.js && node --test tests/vision-backend-runtime-policy.test.js"
73
73
  },
74
74
  "pnpm": {
75
75
  "onlyBuiltDependencies": ["sharp"]