dsh-vision-router 2.1.2 → 2.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/README.zh.md +1 -1
  3. package/docs/architecture/compat-inventory.md +6 -6
  4. package/docs/architecture/dsh-compatibility-matrix.md +7 -6
  5. package/docs/architecture/dsh-support-window.md +36 -16
  6. package/docs/architecture/p3-compat-retirement.md +7 -5
  7. package/docs/architecture/p3-host-native-seams.md +3 -1
  8. package/docs/doctor.md +4 -1
  9. package/docs/releases/v2.1.3.md +34 -0
  10. package/docs/releases/v2.1.4.md +34 -0
  11. package/docs/v2-capability-routing.md +20 -11
  12. package/index.js +156 -129
  13. package/lib/catalog-corrections.js +2 -0
  14. package/lib/client-presentation-boundary-main.js +478 -2
  15. package/lib/client-presentation-boundary.js +121 -1
  16. package/lib/client.js +4 -4
  17. package/lib/depth-guidance.js +4 -4
  18. package/lib/doctor-cli-p0.js +3 -1
  19. package/lib/doctor-cli.js +4 -1
  20. package/lib/doctor-runtime.js +8 -3
  21. package/lib/dsh-support-window.js +15 -7
  22. package/lib/live-model-discovery.js +20 -13
  23. package/lib/local-vision-stabilizer.js +1 -1
  24. package/lib/mixed-router.js +8 -8
  25. package/lib/pi-ai-bridge-wire-compat.js +69 -10
  26. package/lib/runtime-i18n-boundary.js +27 -2
  27. package/lib/runtime-i18n.js +6 -6
  28. package/lib/session-affinity-runtime.js +104 -0
  29. package/lib/session-affinity.js +93 -0
  30. package/lib/settings-ia-client-prelude.js +1 -1
  31. package/lib/structured-bootstrap.js +2 -2
  32. package/lib/structured-flow-hardening.js +159 -158
  33. package/lib/tesseract-exec-compat.js +9 -36
  34. package/lib/vision-backend-runtime-policy.js +1 -0
  35. package/lib/vision-background-benchmark.js +79 -52
  36. package/lib/vision-background-failure-policy.js +1 -24
  37. package/lib/vision-background-stop-store.js +10 -13
  38. package/lib/vision-capability-benchmark-service.js +14 -2
  39. package/lib/vision-tool-runtime-boundary.js +20 -3
  40. package/lib/windows-desktop-capture.js +247 -0
  41. package/package.json +5 -5
  42. package/lib/windows-screenshot-dpi-compat.js +0 -148
@@ -0,0 +1,93 @@
1
+ const MAX_SESSION_AFFINITY_ID_LENGTH = 512
2
+
3
+ /** Preserve DSH's in-process session identity exactly; no wire rules belong here. */
4
+ export function rawSessionIdentity(value) {
5
+ if (value === undefined || value === null) return undefined
6
+ try {
7
+ return String(value)
8
+ } catch {
9
+ return undefined
10
+ }
11
+ }
12
+
13
+ /** Read the live DSH session id across released/alpha session shapes without rewriting it. */
14
+ export function sessionIdentityOf(session) {
15
+ if (!session) return undefined
16
+ try {
17
+ const direct = rawSessionIdentity(session.id)
18
+ if (direct !== undefined) return direct
19
+ } catch {
20
+ /* continue to persisted/header shapes */
21
+ }
22
+ try {
23
+ const header = rawSessionIdentity(session.header?.id)
24
+ if (header !== undefined) return header
25
+ } catch {
26
+ /* continue to requestHeader() */
27
+ }
28
+ try {
29
+ if (typeof session.requestHeader === 'function') {
30
+ return rawSessionIdentity(session.requestHeader()?.id)
31
+ }
32
+ } catch {
33
+ /* no usable session identity */
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /**
39
+ * Admit an opaque identity to an HTTP header without changing it. Fetch/undici
40
+ * requires ByteString-compatible header values; use visible ASCII so behavior
41
+ * is identical across Node 22/24 and HTTP implementations. Leading/trailing
42
+ * whitespace is rejected because Headers would normalize it, violating the
43
+ * requirement to preserve the DSH identity unchanged.
44
+ */
45
+ export function wireSessionAffinityId(value) {
46
+ const text = rawSessionIdentity(value)
47
+ if (text === undefined || text === '' || text.length > MAX_SESSION_AFFINITY_ID_LENGTH) return undefined
48
+ if (text.trim() !== text) return undefined
49
+ if (!/^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(text)) return undefined
50
+ return text
51
+ }
52
+
53
+ export function isOfficialOpenCodeGoUrl(value) {
54
+ try {
55
+ const url = new URL(String(value))
56
+ return (
57
+ url.protocol === 'https:' &&
58
+ url.hostname.toLowerCase() === 'opencode.ai' &&
59
+ /^\/zen\/go(?:\/|$)/i.test(url.pathname)
60
+ )
61
+ } catch {
62
+ return false
63
+ }
64
+ }
65
+
66
+ export function isOpenCodeGoProvider(provider) {
67
+ return isOfficialOpenCodeGoUrl(provider?.baseURL ?? provider?.baseUrl ?? '')
68
+ }
69
+
70
+ function invalidAffinityError(sessionId) {
71
+ const raw = rawSessionIdentity(sessionId)
72
+ const error = new Error(
73
+ raw === undefined || raw === ''
74
+ ? 'OpenCode Go requires a stable session identity; refusing a direct request without one'
75
+ : 'OpenCode Go session identity is not safe for an HTTP header; refusing to rewrite or truncate it',
76
+ )
77
+ error.code = raw === undefined || raw === '' ? 'OPENCODE_SESSION_REQUIRED' : 'OPENCODE_SESSION_INVALID'
78
+ return error
79
+ }
80
+
81
+ export function directSessionAffinityHeaders(provider, sessionId) {
82
+ if (!isOpenCodeGoProvider(provider)) return {}
83
+ const wire = wireSessionAffinityId(sessionId)
84
+ if (wire === undefined) throw invalidAffinityError(sessionId)
85
+ return { 'x-opencode-session': wire }
86
+ }
87
+
88
+ export function openCodeSessionAffinityHeaderForUrl(url, sessionId) {
89
+ if (!isOfficialOpenCodeGoUrl(url)) return undefined
90
+ const wire = wireSessionAffinityId(sessionId)
91
+ if (wire === undefined) throw invalidAffinityError(sessionId)
92
+ return wire
93
+ }
@@ -258,7 +258,7 @@ export const SETTINGS_IA_CLIENT_PRELUDE = String.raw`(function(){
258
258
  function localPageContent(){if(!local)return h(React.Fragment,null,title(tx('本地与设备','Local & device')),card([h('p',{className:'vr-hint',key:'remote'},tx('本地视觉后端和桌面截图只能在运行 DSH 的机器上配置。','Local vision backends and desktop capture can only be configured on the DSH machine.'))]));return h(React.Fragment,null,title(tx('本地与设备','Local & device'),tx('本地运行视觉模型可减少 API 费用和图片上传。','Run vision locally to reduce API cost and image uploads.')),localProviderCard('localOllama','Ollama',{enabled:false,baseURL:'http://127.0.0.1:11434/v1',model:'qwen2.5vl',format:'openai'},'ollama'),localProviderCard('localLmStudio','LM Studio',{enabled:false,baseURL:'http://localhost:1234/v1',model:'',format:'openai'},'lmstudio'),card([toggle('desktopScreenshot',tx('允许 Agent 读取桌面截图','Allow the agent to capture the desktop'),tx('这是独立的隐私权限;macOS 上保存为开启后会立即触发屏幕录制权限检查。','This is a separate privacy permission; on macOS saving it enabled immediately triggers the screen-recording permission check.'))]));}
259
259
  function wrappersEditor(){var rows=wrapperRows();return h('div',{className:'vr-field'},fieldHead('wrappedProviders',tx('哪些聊天模型可以开启识图','Which chat models can use Vision mode')),h('p',{className:'vr-hint'},tx('通常无需修改;模型留空表示整个 Provider。','Usually leave this alone; an empty model means the whole provider.')),rows.map(function(row,index){return modelRow(row,index,rows,setWrapperDraft,true);}),h('button',{type:'button',className:'vr-btn',disabled:!writable||saving,onClick:function(){setWrapperDraft(rows.concat([{provider:'',model:''}]))}},tx('+ 添加范围','+ Add scope')));}
260
260
  function textProviderEditor(){var current=Object.assign({provider:'',model:''},obj(value('textProvider',{}))),ready=groups.length>0;return h('div',{className:'vr-field'},fieldHead('textProvider',tx('文字回退模型','Text fallback model')),h('div',{className:'vr-chain-row'},ready?h('select',{className:'vr-input',value:current.provider||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:event.target.value,model:''});}},providerOptions(current.provider||'')):h('input',{className:'vr-input',value:current.provider||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:event.target.value,model:current.model||''});}}),ready?h('select',{className:'vr-input',value:current.model||'',disabled:!writable||saving||!current.provider,onChange:function(event){setValue('textProvider',{provider:current.provider||'',model:event.target.value});}},modelOptions(current.provider||'',current.model||'',false)):h('input',{className:'vr-input',value:current.model||'',disabled:!writable||saving,onChange:function(event){setValue('textProvider',{provider:current.provider||'',model:event.target.value});}})),invalidKeys.includes('textProvider')?h('p',{className:'vr-failed'},tx('Provider 和 model 必须同时填写,或同时留空恢复默认。','Provider and model must both be filled, or both left empty to restore the default.')):null);}
261
- function advancedPage(){var routing=toggleValue('routing',false),turnBudget=Number(value('visionTurnBudgetMs',0))||0,customBudget=turnBudget>0;return h(React.Fragment,null,title(tx('高级','Advanced'),tx('这些默认值对大多数用户已经合适。','Defaults are suitable for most users.')),card([h('h4',{className:'vr-ia-subtitle',key:'p'},tx('性能与稳定性','Performance & stability')),toggle('downscale',tx('自动缩放','Auto downscale')),numberField('downscaleMaxPixels',tx('图片像素上限','Image pixel limit'),null,1000,100000000),toggle('cache',tx('识图缓存','Vision answer cache')),numberField('cacheTtlSeconds',tx('缓存有效期(秒)','Cache TTL (seconds)'),null,0,31536000),numberField('cacheMaxEntries',tx('最大缓存数量','Maximum cached answers'),null,1,100000),h('h4',{className:'vr-ia-subtitle',key:'t'},tx('超时','Timeouts')),numberField('timeoutMs',tx('单次模型请求','Single model request'),tx('毫秒。','Milliseconds.'),1000,600000),numberField('visionTaskTimeoutMs',tx('单个视觉任务','Single visual task'),tx('包含该任务内部的重试和备用模型;不是每个后端各自一份。','Includes retries and fallbacks inside the task; it is not a fresh budget per backend.'),1000,180000),numberField('ocrTimeoutMs',tx('OCR 任务','OCR task'),null,1000,120000),h('div',{className:'vr-field',key:'budget'},fieldHead('visionTurnBudgetMs',tx('整轮视觉工具上限','Whole-turn vision-tool limit')),h('select',{className:'vr-input',value:customBudget?'custom':'unlimited',disabled:!writable||saving,onChange:function(event){setValue('visionTurnBudgetMs',event.target.value==='unlimited'?0:(turnBudget>0?turnBudget:180000));}},h('option',{value:'unlimited'},tx('不限制(推荐)','Unlimited (recommended)')),h('option',{value:'custom'},tx('自定义','Custom'))),customBudget?numberField('visionTurnBudgetMs',tx('上限(毫秒)','Limit (ms)'),null,10000,600000):null)]),card([h('h4',{className:'vr-ia-subtitle',key:'cost'},tx('模型顺序与成本','Model order & cost')),toggle('freeCloudFirst',tx('免费云模型优先','Try free cloud models first'))]),card([h('h4',{className:'vr-ia-subtitle',key:'scope'},tx('识图模式范围','Vision mode scope')),toggle('autoWrapProviders',tx('自动允许已启用模型使用识图','Automatically allow enabled models to use Vision mode')),wrappersEditor()]),local?card([h('h4',{className:'vr-ia-subtitle',key:'network'},tx('网络与远程','Network & remote')),toggle('allowRemoteSettings',tx('允许可信 Host 远程修改设置','Allow trusted-host remote settings'),tx('默认关闭;trustedHosts 不是身份认证。','Off by default; trustedHosts is not authentication.')),textField('proxy',tx('代理地址','Proxy URL'),tx('留空关闭代理。','Leave empty to disable.')),textareaArray('proxyHosts',tx('走代理的域名','Proxied hosts'),tx('每行一个。','One per line.'))]):null,card([h('h4',{className:'vr-ia-subtitle',key:'compat'},tx('兼容模式','Compatibility')),toggle('rewriteImages',tx('保护纯文本模型','Protect text-only models')),toggle('routing',tx('整轮视觉路由(旧工作流)','Whole-turn vision routing (legacy workflow)')),routing?toggle('reverseRouting',tx('纯文字消息继续使用聊天模型','Keep text-only messages on the chat model')):null,routing?textProviderEditor():null]),card([h('button',{type:'button',className:'vr-btn',key:'dev',onClick:function(){setOpen(Object.assign({},open,{developer:!open.developer}));}},open.developer?tx('隐藏开发者设置','Hide developer settings'):tx('显示开发者设置','Show developer settings')),open.developer?h('div',{className:'vr-ia-dev',key:'body'},toggle('progressiveTools',tx('渐进式工具暴露','Progressive tool exposure')),local?toggle('stealth','Stealth'):null,local?textField('wrapperRoute',tx('包装路由名','Wrapper route name')):null,local?textField('chainRoute',tx('视觉链路由名','Vision chain route name')):null,textareaArray('extraVisionModels',tx('额外视觉能力标记','Extra vision capability labels'),tx('只有诊断发现模型未声明图片能力时才需要。','Needed only when diagnostics show missing image-capability metadata.'))):null]));}
261
+ function advancedPage(){var routing=toggleValue('routing',false),turnBudget=Number(value('visionTurnBudgetMs',0))||0,customBudget=turnBudget>0;return h(React.Fragment,null,title(tx('高级','Advanced'),tx('这些默认值对大多数用户已经合适。','Defaults are suitable for most users.')),card([h('h4',{className:'vr-ia-subtitle',key:'p'},tx('性能与稳定性','Performance & stability')),toggle('downscale',tx('自动缩放','Auto downscale')),numberField('downscaleMaxPixels',tx('图片像素上限','Image pixel limit'),null,1000,100000000),toggle('cache',tx('识图缓存','Vision answer cache')),numberField('cacheTtlSeconds',tx('缓存有效期(秒)','Cache TTL (seconds)'),null,0,31536000),numberField('cacheMaxEntries',tx('最大缓存数量','Maximum cached answers'),null,1,100000),h('h4',{className:'vr-ia-subtitle',key:'t'},tx('超时','Timeouts')),numberField('timeoutMs',tx('单次模型请求','Single model request'),tx('毫秒。','Milliseconds.'),1000,600000),numberField('visionTaskTimeoutMs',tx('单个视觉任务','Single visual task'),tx('包含该任务内部的重试和备用模型;不是每个后端各自一份。','Includes retries and fallbacks inside the task; it is not a fresh budget per backend.'),1000,180000),numberField('ocrTimeoutMs',tx('OCR 任务','OCR task'),null,1000,120000),h('div',{className:'vr-field',key:'budget'},fieldHead('visionTurnBudgetMs',tx('整轮视觉工具上限','Whole-turn vision-tool limit')),h('select',{className:'vr-input',value:customBudget?'custom':'unlimited',disabled:!writable||saving,onChange:function(event){setValue('visionTurnBudgetMs',event.target.value==='unlimited'?0:(turnBudget>0?turnBudget:180000));}},h('option',{value:'unlimited'},tx('不限制(推荐)','Unlimited (recommended)')),h('option',{value:'custom'},tx('自定义','Custom'))),customBudget?numberField('visionTurnBudgetMs',tx('上限(毫秒)','Limit (ms)'),null,10000,600000):null)]),card([h('h4',{className:'vr-ia-subtitle',key:'cost'},tx('模型顺序与成本','Model order & cost')),toggle('freeCloudFirst',tx('免费云模型优先','Try free cloud models first'))]),card([h('h4',{className:'vr-ia-subtitle',key:'scope'},tx('识图模式范围','Vision mode scope')),toggle('autoWrapProviders',tx('自动允许已启用模型使用识图','Automatically allow enabled models to use Vision mode')),wrappersEditor()]),local?card([h('h4',{className:'vr-ia-subtitle',key:'network'},tx('网络与远程','Network & remote')),toggle('allowRemoteSettings',tx('允许可信 Host 远程修改设置','Allow trusted-host remote settings'),tx('默认关闭;trustedHosts 不是身份认证。','Off by default; trustedHosts is not authentication.')),textField('proxy',tx('代理地址','Proxy URL'),tx('留空关闭代理。','Leave empty to disable.')),textareaArray('proxyHosts',tx('走代理的域名','Proxied hosts'),tx('每行一个。','One per line.'))]):null,card([h('h4',{className:'vr-ia-subtitle',key:'compat'},tx('兼容模式','Compatibility')),toggle('rewriteImages',tx('保护纯文本模型','Protect text-only models')),toggle('routing',tx('整轮视觉路由(旧工作流)','Whole-turn vision routing (legacy workflow)')),routing?toggle('reverseRouting',tx('纯文字消息继续使用聊天模型','Keep text-only messages on the chat model')):null,routing?textProviderEditor():null]),card([h('button',{type:'button',className:'vr-btn',key:'dev',onClick:function(){setOpen(Object.assign({},open,{developer:!open.developer}));}},open.developer?tx('隐藏开发者设置','Hide developer settings'):tx('显示开发者设置','Show developer settings')),open.developer?h('div',{className:'vr-ia-dev',key:'body'},toggle('progressiveTools',tx('渐进式工具暴露','Progressive tool exposure')),h('p',{className:'vr-hint',key:'progressive-restart'},tx('保存后需重启 DSH 才生效。','Restart DSH after saving for this change to take effect.')),local?toggle('stealth','Stealth'):null,local?textField('wrapperRoute',tx('包装路由名','Wrapper route name')):null,local?textField('chainRoute',tx('视觉链路由名','Vision chain route name')):null,textareaArray('extraVisionModels',tx('额外视觉能力标记','Extra vision capability labels'),tx('只有诊断发现模型未声明图片能力时才需要。','Needed only when diagnostics show missing image-capability metadata.'))):null]));}
262
262
  function diagnosticValue(label,valueText){return h('div',{className:'vr-ia-diag-row',key:label},h('span',null,label),h('strong',null,valueText));}
263
263
  function updatePanel(){if(!local)return null;var result=updateState.result,auto=result&&result.autoUpdate,current=result&&result.currentVersion?result.currentVersion:tx('检测中','Checking'),latest=result&&result.latestVersion?result.latestVersion:'—',available=result&&result.ok===true&&result.updateAvailable===true,profile=auto&&auto.profile?auto.profile:'web',manualVersion=result&&result.latestVersion?result.latestVersion:'<version>',spec='dsh-vision-router@'+manualVersion,pnpm='pnpm dsh plugin --profile '+profile+' add '+spec,npx='npx @deepseek-ai/dsh plugin --profile '+profile+' add '+spec;return card([h('h4',{className:'vr-ia-subtitle',key:'title'},tx('版本更新','Updates')),h('p',{className:'vr-hint',key:'status'},updateState.status==='running'?tx('正在检查更新…','Checking for updates…'):result&&result.ok===false?tx('更新检查失败:','Update check failed: ')+String(result.error||'unknown'):available?tx('发现新版本:v','Update available: v')+latest+tx('(当前 v',' (current v')+current+')':result&&result.ok===true?tx('已是最新版本 v','Up to date: v')+current:tx('尚未检查','Not checked yet')),h('div',{className:'vr-ia-actions',key:'actions'},h('button',{type:'button',className:'vr-btn',disabled:updateState.status==='running',onClick:function(){void runUpdateCheck(true);}},tx('检查更新','Check for updates')),available&&auto&&auto.supported===true&&auto.token?h('button',{type:'button',className:'vr-btn vr-btn-save',disabled:selfUpdateState.status==='running',onClick:function(){void runSelfUpdate();}},selfUpdateState.status==='running'?tx('更新中…','Updating…'):tx('一键更新','Update now')):null),selfUpdateState.status==='done'&&selfUpdateState.result?h('p',{className:'vr-hint',key:'updated'},tx('更新完成,请重启 DSH。','Update complete. Restart DSH.')):selfUpdateState.error?h('p',{className:'vr-failed',key:'uerr'},String(selfUpdateState.error)):null,available&&(!auto||auto.supported!==true)?h('div',{className:'vr-ia-manual',key:'manual'},h('p',{className:'vr-hint'},tx('当前安装方式不支持安全的一键更新,请使用与你当前 DSH 安装方式一致的命令:','This install method cannot be safely auto-updated. Use the command matching your DSH installation:')),h('code',{className:'vr-ia-code'},pnpm),h('code',{className:'vr-ia-code'},npx)):null]);}
264
264
  function diagnosticsPage(){var providerCount=groups.length,modelCount=groups.reduce(function(total,group){return total+arr(group&&group.models).length;},0),configured=chainRows().filter(function(row){return row&&row.provider&&row.model;}).length,ollama=obj(value('localOllama',{})),lm=obj(value('localLmStudio',{})),testText=!local?tx('仅本机可见','Local only'):testState.status==='running'?tx('检测中','Checking'):testState.status==='done'&&testState.result&&testState.result.ok===true?tx('连接正常','Connected'):testState.status==='done'?tx('连接失败','Failed'):tx('未检测','Not checked'),capsText=caps.status==='ready'?tx('正常','Ready'):caps.status==='loading'?tx('检测中','Checking'):caps.status==='error'?tx('不可用','Unavailable'):tx('未检测','Not checked');var rows=[[tx('设置协议','Settings contract'),String(value('settingsContractRevision','—'))],[tx('模型目录','Model catalog'),catalog.status==='ready'?tx('正常','Ready'):catalog.status==='loading'?tx('检测中','Checking'):tx('不可用','Unavailable')],[tx('图片能力元数据','Image capability metadata'),capsText],[tx('可选 Provider','Selectable providers'),String(providerCount)],[tx('可选模型','Selectable models'),String(modelCount)],[tx('已配置识图模型','Configured vision models'),String(configured)],[tx('内置免费兜底','Built-in free fallback'),toggleValue('freeFallback',true)?(caps.builtinFallback.length?tx('已启用,','Enabled, ')+caps.builtinFallback.length+tx(' 个模型',' models'):tx('已启用','Enabled')):tx('已关闭','Disabled')],[tx('后端连接','Backend connection'),testText],[tx('Ollama','Ollama'),local?(ollama.enabled===true?tx('已启用','Enabled'):tx('未启用','Disabled')):tx('仅本机可见','Local only')],[tx('LM Studio','LM Studio'),local?(lm.enabled===true?tx('已启用','Enabled'):tx('未启用','Disabled')):tx('仅本机可见','Local only')],[tx('桌面截图','Desktop capture'),local?(toggleValue('desktopScreenshot',false)?tx('已启用','Enabled'):tx('未启用','Disabled')):tx('仅本机可见','Local only')],[tx('代理','Proxy'),local?(String(value('proxy','')).trim()?tx('已配置','Configured'):tx('未配置','Not configured')):tx('仅本机可见','Local only')],[tx('远程设置','Remote settings'),local?(toggleValue('allowRemoteSettings',false)?tx('已启用','Enabled'):tx('未启用','Disabled')):tx('当前为远程安全视图','Remote safe view')]];function reportText(){return ['Vision Router diagnostics'].concat(rows.map(function(row){return row[0]+': '+row[1];}),['doctor: dsh-vision-router doctor']).join('\n');}async function copyReport(){try{if(typeof navigator!=='undefined'&&navigator.clipboard&&typeof navigator.clipboard.writeText==='function')await navigator.clipboard.writeText(reportText());setActionState({status:'copied'});}catch(error){setActionState({status:'error',error:error&&error.message?error.message:String(error)});}}return h(React.Fragment,null,title(tx('诊断','Diagnostics'),tx('状态页不会修改你的模型/路由设置;连接测试和更新操作会明确由按钮触发。','The status page does not change model or routing settings; connection tests and updates are explicit actions.')),card(rows.map(function(row){return diagnosticValue(row[0],row[1]);})),card([h('div',{className:'vr-ia-actions',key:'actions'},local?h('button',{type:'button',className:'vr-btn',disabled:testState.status==='running',onClick:function(){void runTestConnection();}},tx('测试连接','Test connection')):null,h('button',{type:'button',className:'vr-btn',onClick:function(){invalidateCatalog();}},tx('重新检测模型','Re-detect models')),local?h('button',{type:'button',className:'vr-btn',onClick:function(){void openLogs();}},tx('打开日志文件夹','Open logs folder')):null,h('button',{type:'button',className:'vr-btn',onClick:function(){void copyReport();}},tx('复制诊断信息','Copy diagnostics'))),h('p',{className:'vr-hint',key:'doctor'},tx('需要完整 DSH 版本、安装、Profile、运行时路由和会话诊断时执行:dsh-vision-router doctor','For the full DSH version, installation, profile, runtime-route, and session report, run: dsh-vision-router doctor')),testState.status==='done'&&testState.result&&testState.result.ok!==true?h('p',{className:'vr-failed',key:'testerr'},String(testState.result.error||tx('连接测试失败','Connection test failed'))):null,actionState.status==='error'?h('p',{className:'vr-failed',key:'aerr'},String(actionState.error||'unknown')):null]),updatePanel());}
@@ -32,7 +32,7 @@ export function structuredBootstrapQuestion() {
32
32
  'The example above must remain valid JSON. For mixed_of: ONLY when visual_kind is "mixed", put 1-2 distinct values chosen from document, ui, code, chat, general; otherwise return an empty array. ' +
33
33
  'Preserve high-information evidence for downstream reasoning: layout, reading order, objects/controls, relationships, visible state, and uncertainty. ' +
34
34
  'Do not prematurely answer any possible user task and do not invent hidden details. ' +
35
- 'Recommend at least one concrete follow-up evidence call. Do NOT recommend vision_ocr merely because text is visible: use OCR only when exact verbatim transcription is genuinely uncertain or would materially add evidence; for UI/screenshots prefer vision_detect or a focused vision_describe when semantic verification is enough. ' +
35
+ 'Recommend at least one concrete task-independent follow-up evidence candidate; downstream agents must treat these as optional suggestions, not a task plan. Do NOT recommend vision_ocr merely because text is visible: use OCR only when exact verbatim transcription is genuinely uncertain or would materially add evidence; for UI/screenshots prefer vision_detect or a focused vision_describe when semantic verification is enough. ' +
36
36
  'OCR is a verbatim transcriber, not a semantic reader: it is systematically unreliable for confusable glyphs (1/l, 0/O), spacing and line breaks, so context-aware semantic reading (vision_describe / vision_detect) usually recovers meaning that raw OCR corrupts. ' +
37
37
  'Use vision_ocr ONLY when verbatim fidelity is genuinely required and cannot be recovered from context: executable code, long-form documents needing exact quotation, forms/contracts, table digits, CAPTCHAs, or glyphs with no semantic anchor. ' +
38
38
  'When you do call vision_ocr, treat its output as evidence to verify against other tools, never as ground truth. ' +
@@ -107,7 +107,7 @@ export function normalizeStructuredBootstrapResult(parsed, raw = '') {
107
107
  recommendedFollowups.push({
108
108
  tool: visualKind === 'ui' ? 'vision_detect' : 'vision_describe',
109
109
  target: 'the most uncertain or information-dense region from the structured baseline',
110
- reason: 'Required x>=1 verification/deepening call after the bootstrap pass; do not default to OCR without a verbatim-text need.',
110
+ reason: 'Task-independent candidate for the required x>=1 verification/deepening step; the downstream agent must still choose based on the user question and must not default to OCR without a verbatim-text need.',
111
111
  })
112
112
  }
113
113
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -20,9 +20,7 @@ const MAX_TURN_BUDGET_MS = 600_000
20
20
  const MAX_GUIDANCE_OVERRIDES = 14
21
21
  const MAX_GUIDANCE_CHARS = 2_000
22
22
  const MAX_NON_PROGRESS_ATTEMPTS = 3
23
- const MAX_EVIDENCE_SIGNATURE_CHARS = 4_096
24
23
  const BUDGETED_TIMEOUT_FIELDS = ['timeoutMs', 'visionTaskTimeoutMs', 'ocrTimeoutMs']
25
- const EVIDENCE_META_KEYS = new Set(['ok', 'code', 'retryable', 'reason', 'phase', 'next'])
26
24
  const GUIDANCE_KINDS = new Set([
27
25
  'code',
28
26
  'document',
@@ -40,25 +38,6 @@ const GUIDANCE_KINDS = new Set([
40
38
  'meme',
41
39
  ])
42
40
 
43
- const BUILTIN_MIXED_GUIDANCE = {
44
- zh: {
45
- document: '语义优先;只有逐字引用、合同/表单字段或表格数字确需保真时才使用 OCR。',
46
- ui: '优先用 vision_detect / vision_ground 验证元素、状态与位置。',
47
- code: '逐字核对代码;对可执行字符、缩进和符号做交叉验证。',
48
- chat: '按气泡顺序核对发言者、消息内容与时间/状态信息。',
49
- general: '聚焦尚未归类的另一部分可见内容,自行选择最能新增证据的视觉工具。',
50
- fallback: '聚焦这个分支新增或验证可见证据。',
51
- },
52
- en: {
53
- document: 'Prioritize semantics; use OCR only when verbatim quotes, contract/form fields, or table numbers require exact transcription.',
54
- ui: 'Prefer vision_detect / vision_ground to verify elements, states, and positions.',
55
- code: 'Verify code verbatim; cross-check executable characters, indentation, and symbols.',
56
- chat: 'Verify speakers, message text, and time/status information in bubble order.',
57
- general: 'Focus on the remaining unclassified visible content and choose the vision tool that adds the most useful evidence.',
58
- fallback: 'Add or verify visible evidence for this branch.',
59
- },
60
- }
61
-
62
41
  function isObject(value) {
63
42
  return value !== null && typeof value === 'object'
64
43
  }
@@ -98,36 +77,143 @@ export function producedStructuredEvidence(value) {
98
77
  return true
99
78
  }
100
79
 
101
- function meaningfulEvidenceValue(value) {
102
- if (value === undefined || value === null) return false
103
- if (typeof value === 'string') return value.trim() !== ''
104
- if (typeof value === 'number') return Number.isFinite(value)
105
- if (typeof value === 'boolean') return true
106
- if (Array.isArray(value)) return value.length > 0 && value.some(meaningfulEvidenceValue)
107
- if (!isObject(value) || value.ok === false) return false
108
- return Object.entries(value).some(([key, child]) => (
109
- !EVIDENCE_META_KEYS.has(key) && meaningfulEvidenceValue(child)
80
+ function finiteNumber(value) {
81
+ return typeof value === 'number' && Number.isFinite(value)
82
+ }
83
+
84
+ function positiveInteger(value) {
85
+ return Number.isInteger(value) && value > 0
86
+ }
87
+
88
+ function nonEmptyText(value) {
89
+ return typeof value === 'string' && value.trim() !== ''
90
+ }
91
+
92
+ function canonicalBox(value, width, height) {
93
+ if (!isObject(value) || Array.isArray(value)) return false
94
+ const { x1, y1, x2, y2 } = value
95
+ if (![x1, y1, x2, y2].every(Number.isInteger)) return false
96
+ if (x1 < 0 || y1 < 0 || x2 <= x1 || y2 <= y1) return false
97
+ if (positiveInteger(width) && x2 > width) return false
98
+ if (positiveInteger(height) && y2 > height) return false
99
+ return true
100
+ }
101
+
102
+ function structuredDescribeEvidence(value) {
103
+ if (!isObject(value) || Array.isArray(value)) return false
104
+ if (nonEmptyText(value.summary) || nonEmptyText(value.text)) return true
105
+ if (Array.isArray(value.layout) && value.layout.some((row) => (
106
+ isObject(row) && nonEmptyText(row.region) && nonEmptyText(row.content)
107
+ ))) return true
108
+ return Array.isArray(value.entities) && value.entities.some((entity) => (
109
+ isObject(entity) && nonEmptyText(entity.type) && nonEmptyText(entity.label)
110
+ ))
111
+ }
112
+
113
+ function groundEvidence(value) {
114
+ if (!isObject(value) || Array.isArray(value)) return false
115
+ const { width, height } = value
116
+ return positiveInteger(width) && positiveInteger(height) && canonicalBox(value, width, height)
117
+ }
118
+
119
+ function detectEvidence(value) {
120
+ if (!isObject(value) || Array.isArray(value)) return false
121
+ const { width, height, elements } = value
122
+ if (!positiveInteger(width) || !positiveInteger(height) || !Array.isArray(elements)) return false
123
+ return elements.every((element, index) => (
124
+ isObject(element) &&
125
+ element.number === index + 1 &&
126
+ nonEmptyText(element.label) &&
127
+ canonicalBox(element.box, width, height)
110
128
  ))
111
129
  }
112
130
 
131
+ function ocrEvidence(value) {
132
+ if (!isObject(value) || Array.isArray(value)) return false
133
+ if (value.engine !== 'tesseract' && value.engine !== 'vision') return false
134
+ return typeof value.text === 'string'
135
+ }
136
+
137
+ function colorEvidence(value) {
138
+ if (!Array.isArray(value)) return false
139
+ return value.every((entry) => (
140
+ isObject(entry) &&
141
+ typeof entry.hex === 'string' && /^#[0-9a-f]{6}$/i.test(entry.hex) &&
142
+ positiveInteger(entry.count) &&
143
+ finiteNumber(entry.share) && entry.share > 0 && entry.share <= 1
144
+ ))
145
+ }
146
+
147
+ function pixelDiffEvidence(value) {
148
+ if (!isObject(value) || Array.isArray(value)) return false
149
+ const { width, height, differingPixels, totalPixels, diffRatio, worstRegions } = value
150
+ if (!positiveInteger(width) || !positiveInteger(height) || totalPixels !== width * height) return false
151
+ if (!Number.isInteger(differingPixels) || differingPixels < 0 || differingPixels > totalPixels) return false
152
+ if (!finiteNumber(diffRatio) || diffRatio < 0 || diffRatio > 1) return false
153
+ if (Math.abs(diffRatio - differingPixels / totalPixels) > 0.000051) return false
154
+ if (!Array.isArray(worstRegions)) return false
155
+ return worstRegions.every((region) => (
156
+ isObject(region) &&
157
+ canonicalBox(region, width, height) &&
158
+ Number.isInteger(region.differing) && region.differing > 0 &&
159
+ positiveInteger(region.total) && region.differing <= region.total &&
160
+ finiteNumber(region.ratio) && region.ratio > 0 && region.ratio <= 1
161
+ ))
162
+ }
163
+
164
+ function longOcrEvidence(value) {
165
+ if (!isObject(value) || Array.isArray(value)) return false
166
+ if (typeof value.text !== 'string' || !positiveInteger(value.chunks)) return false
167
+ if (!isObject(value.engines) || Array.isArray(value.engines)) return false
168
+ if (![value.markdownPath, value.manifestPath, value.artifactsDir].every(nonEmptyText)) return false
169
+ const engineCounts = Object.values(value.engines)
170
+ if (engineCounts.length === 0 || !engineCounts.every((count) => Number.isInteger(count) && count >= 0)) return false
171
+ if (engineCounts.reduce((sum, count) => sum + count, 0) !== value.chunks) return false
172
+ if (nonEmptyText(value.text)) return true
173
+ const completed = (value.engines.tesseract ?? 0) + (value.engines.vision ?? 0)
174
+ return completed > 0
175
+ }
176
+
113
177
  /**
114
- * Evidence that is strong enough to advance the 1+x flow and consume an
115
- * explicit deep-dive call cap. Empty arrays/objects and metadata-only success
116
- * envelopes do not count. Negative facts such as { match: false } or
117
- * { count: 0 } still count because they are real observations, not emptiness.
178
+ * Decide whether one registered evidence tool completed a real observation.
179
+ *
180
+ * This deliberately follows each tool's output contract instead of treating
181
+ * arbitrary non-empty containers as evidence. Valid negative observations
182
+ * (for example zero detections, empty OCR text, or a 0 pixel diff) still count;
183
+ * transport failures, malformed envelopes, and describe's empty-content
184
+ * sentinel do not.
118
185
  */
119
- export function producedUsableStructuredEvidence(value) {
120
- if (value === undefined || value === null) return false
121
- if (typeof value === 'string') {
122
- const text = value.trim()
123
- if (text === '') return false
124
- const parsed = parseJson(text)
125
- if (parsed === undefined) return true
126
- return producedUsableStructuredEvidence(parsed)
186
+ export function producedUsableToolEvidence(toolName, value, args = {}) {
187
+ if (!STRUCTURED_EVIDENCE_TOOLS.has(toolName) || value === undefined || value === null) return false
188
+ const parsed = parseJson(value)
189
+ if (isObject(parsed) && !Array.isArray(parsed) && parsed.ok === false) return false
190
+
191
+ switch (toolName) {
192
+ case 'vision_describe': {
193
+ if (typeof value !== 'string') return false
194
+ const text = value.trim()
195
+ if (text === '' || text === '(the vision model returned empty content)') return false
196
+ if (args?.json === true) {
197
+ if (text.startsWith('vision_describe: the model did not produce valid JSON.')) return false
198
+ return structuredDescribeEvidence(parsed)
199
+ }
200
+ return true
201
+ }
202
+ case 'vision_ground':
203
+ return groundEvidence(parsed)
204
+ case 'vision_detect':
205
+ return detectEvidence(parsed)
206
+ case 'vision_ocr':
207
+ return ocrEvidence(parsed)
208
+ case 'vision_colors':
209
+ return colorEvidence(parsed)
210
+ case 'vision_pixel_diff':
211
+ return pixelDiffEvidence(parsed)
212
+ case 'vision_long_screenshot_ocr':
213
+ return longOcrEvidence(parsed)
214
+ default:
215
+ return false
127
216
  }
128
- if (Array.isArray(value)) return value.length > 0 && value.some(meaningfulEvidenceValue)
129
- if (!isObject(value) || value.ok === false) return false
130
- return meaningfulEvidenceValue(value)
131
217
  }
132
218
 
133
219
  /**
@@ -176,15 +262,6 @@ function languageOf(config) {
176
262
  return runtimeLanguageFor(config?.__visionRouterLocale, 'zh')
177
263
  }
178
264
 
179
- function guidanceFor(kind, overrides, language = 'zh') {
180
- for (let i = overrides.length - 1; i >= 0; i--) {
181
- const entry = overrides[i]
182
- if (entry.kind === kind) return entry.text
183
- }
184
- const copy = BUILTIN_MIXED_GUIDANCE[language] ?? BUILTIN_MIXED_GUIDANCE.zh
185
- return copy[kind] ?? copy.fallback
186
- }
187
-
188
265
  function turnBudgetMs(config) {
189
266
  const value = Number(config?.visionTurnBudgetMs)
190
267
  if (!Number.isFinite(value) || value <= 0) return DEFAULT_TURN_BUDGET_MS
@@ -234,9 +311,7 @@ function freshState(turn, _config) {
234
311
  bootstrapDone: false,
235
312
  bootstrapResult: undefined,
236
313
  successfulEvidenceCalls: 0,
237
- mixedBranches: [],
238
- completedBranches: new Set(),
239
- mixedAttemptSignatures: new Set(),
314
+ postBootstrapEvidenceCalls: 0,
240
315
  consecutiveNonProgressAttempts: 0,
241
316
  budgetExhausted: false,
242
317
  depthExhausted: false,
@@ -284,56 +359,13 @@ function bootstrapEvidence(raw) {
284
359
  return parsed.evidence
285
360
  }
286
361
 
287
- function normalizeMixedBranches(evidence, _depth) {
288
- if (!isObject(evidence) || evidence.visual_kind !== 'mixed' || !Array.isArray(evidence.mixed_of)) return []
289
- const allowed = ['ui', 'document', 'code', 'chat', 'general']
290
- const unique = []
291
- for (const kind of allowed) {
292
- if (evidence.mixed_of.includes(kind)) unique.push(kind)
293
- }
294
- return unique.slice(0, 2)
295
- }
296
-
297
- function evidenceAttemptSignature(name, args) {
298
- let encoded
299
- try {
300
- encoded = JSON.stringify(args ?? null)
301
- } catch {
302
- encoded = String(args ?? '')
303
- }
304
- if (encoded.length <= MAX_EVIDENCE_SIGNATURE_CHARS) return `${name}:${encoded}`
305
- const half = Math.floor(MAX_EVIDENCE_SIGNATURE_CHARS / 2)
306
- return `${name}:${encoded.length}:${encoded.slice(0, half)}:${encoded.slice(-half)}`
307
- }
308
-
309
- function inferBranchForTool(name, pending) {
310
- const prefer = (kinds) => kinds.find((kind) => pending.includes(kind))
311
- if (name === 'vision_detect' || name === 'vision_ground') return prefer(['ui', 'general'])
312
- if (name === 'vision_long_screenshot_ocr') return prefer(['document', 'chat', 'code', 'general'])
313
- if (name === 'vision_ocr') return prefer(['document', 'code', 'chat', 'general'])
314
- if (name === 'vision_colors' || name === 'vision_pixel_diff') return prefer(['general', 'ui'])
315
- return pending.length === 1 ? pending[0] : undefined
316
- }
317
-
318
- function recordEvidenceSuccess(state, name, args) {
319
- state.successfulEvidenceCalls += 1
320
- if (state.mixedBranches.length === 0) return true
321
- const pending = state.mixedBranches.filter((kind) => !state.completedBranches.has(kind))
322
- if (pending.length === 0) return true
323
- const signature = evidenceAttemptSignature(name, args)
324
- if (state.mixedAttemptSignatures.has(signature)) return false
325
- state.mixedAttemptSignatures.add(signature)
326
- const branch = inferBranchForTool(name, pending)
327
- if (!branch) return false
328
- state.completedBranches.add(branch)
329
- return true
330
- }
331
-
362
+ /**
363
+ * Structured 1+x has exactly one hard completion condition after bootstrap:
364
+ * at least one usable task-directed evidence observation. Mixed classifications
365
+ * remain model-visible advisory evidence; they do not create branch quotas.
366
+ */
332
367
  function missingEvidence(state) {
333
- if (state.mixedBranches.length > 0) {
334
- return state.mixedBranches.filter((kind) => !state.completedBranches.has(kind)).length
335
- }
336
- return state.successfulEvidenceCalls >= 1 ? 0 : 1
368
+ return state.postBootstrapEvidenceCalls >= 1 ? 0 : 1
337
369
  }
338
370
 
339
371
  function hasCoreFollowup(messages) {
@@ -347,6 +379,8 @@ function withoutEvidenceFollowups(messages) {
347
379
  const id = message && typeof message.id === 'string' ? message.id : ''
348
380
  return !(
349
381
  id.includes('vision-router-structured-followup-') ||
382
+ // Strip historical Round-0 mixed hard guards if one is still present in
383
+ // a persisted/compatibility message surface. Round 1 never emits them.
350
384
  id.includes('vision-router-structured-mixed-guard-') ||
351
385
  id.includes('vision-router-structured-evidence-guard-')
352
386
  )
@@ -374,30 +408,8 @@ function appendSyntheticGuardOnce(decision, baseMessages, state, message, stripE
374
408
  }
375
409
 
376
410
  function rearmEvidenceGuardAfterAttempt(state) {
377
- const mixedPrefix = `vision-router-structured-mixed-guard-${state.turn}-`
378
411
  const evidenceId = `vision-router-structured-evidence-guard-${state.turn}`
379
- for (const id of state.emittedGuardIds) {
380
- if (id === evidenceId || id.startsWith(mixedPrefix)) state.emittedGuardIds.delete(id)
381
- }
382
- }
383
-
384
- function mixedReminder(state, config) {
385
- const language = languageOf(config)
386
- const overrides = normalizeGuidanceOverrides(config?.guidanceOverrides)
387
- const pending = state.mixedBranches.filter((kind) => !state.completedBranches.has(kind))
388
- const done = state.mixedBranches.filter((kind) => state.completedBranches.has(kind))
389
- const separator = language === 'en' ? ': ' : ':'
390
- const lines = pending.map((kind) => `- ${kind}${separator}${guidanceFor(kind, overrides, language)}`)
391
- if (language === 'en') {
392
- const doneText = done.length > 0 ? `Verified branches: ${done.join(' + ')}. ` : ''
393
- return `${doneText}This mixed image still has unverified branches: ${pending.join(' + ')}. ` +
394
- 'Focus each vision call on one unfinished branch; answer only after each branch has produced at least one useful piece of evidence.\n' +
395
- lines.join('\n')
396
- }
397
- const doneText = done.length > 0 ? `已验证分支:${done.join(' + ')}。` : ''
398
- return `${doneText}混合图片仍有未验证分支:${pending.join(' + ')}。` +
399
- '每次视觉调用只聚焦一个尚未完成的分支;这些分支分别产生至少一次有效证据后再作答。\n' +
400
- lines.join('\n')
412
+ state.emittedGuardIds.delete(evidenceId)
401
413
  }
402
414
 
403
415
  function appendGuardMessage(decision, payload, state, config) {
@@ -421,12 +433,12 @@ function appendGuardMessage(decision, payload, state, config) {
421
433
  ? 'The visual turn time budget is exhausted. Do not call more vision tools; answer from the evidence already collected and state any remaining uncertainty.'
422
434
  : state.depthExhausted
423
435
  ? 'The configured deep-dive call cap has been reached. Do not call more vision tools; answer from the evidence already collected and state any remaining uncertainty.'
424
- : 'Repeated follow-up vision attempts did not produce or advance usable evidence. Do not keep retrying vision tools; answer from the evidence already collected and state the remaining uncertainty.'
436
+ : 'Repeated follow-up vision attempts did not produce usable evidence. Do not keep retrying vision tools; answer from the evidence already collected and state the remaining uncertainty.'
425
437
  : state.budgetExhausted
426
438
  ? '本轮视觉总时间预算已耗尽。不要再调用视觉工具;请基于已经获得的证据作答,并明确仍存在的不确定性。'
427
439
  : state.depthExhausted
428
440
  ? '已达到本轮设置的深挖次数上限。不要再调用视觉工具;请基于已经获得的证据作答,并明确仍存在的不确定性。'
429
- : '连续多次后续视觉调用都没有产出或推进可用证据。不要继续重复调用视觉工具;请基于已经获得的证据作答,并明确仍存在的不确定性。',
441
+ : '连续多次后续视觉调用都没有产出可用证据。不要继续重复调用视觉工具;请基于已经获得的证据作答,并明确仍存在的不确定性。',
430
442
  }],
431
443
  source: { kind: 'plugin', plugin: 'dsh-vision-router' },
432
444
  }
@@ -435,16 +447,10 @@ function appendGuardMessage(decision, payload, state, config) {
435
447
 
436
448
  if (!state.bootstrapDone || missingEvidence(state) === 0) return decision
437
449
 
438
- if (state.mixedBranches.length > 0) {
439
- const message = {
440
- role: 'user',
441
- id: `vision-router-structured-mixed-guard-${state.turn}-${state.successfulEvidenceCalls}`,
442
- content: [{ type: 'text', text: mixedReminder(state, config) }],
443
- source: { kind: 'plugin', plugin: 'dsh-vision-router' },
444
- }
445
- return appendSyntheticGuardOnce(decision, baseMessages, state, message, true)
446
- }
447
-
450
+ // Core owns bootstrap ordering and its one-shot follow-up guidance. This
451
+ // outer boundary owns the hard x>=1 completion rule. If Core's guidance is
452
+ // already present, do not duplicate it; if Core's legacy presentation latch
453
+ // stopped emitting it after an empty/failed attempt, re-assert the hard guard.
448
454
  if (hasCoreFollowup(baseMessages)) return decision
449
455
  const message = {
450
456
  role: 'user',
@@ -452,8 +458,8 @@ function appendGuardMessage(decision, payload, state, config) {
452
458
  content: [{
453
459
  type: 'text',
454
460
  text: language === 'en'
455
- ? 'The structured bootstrap is complete, but the previous follow-up tool did not produce usable evidence. Call at least one more vision tool that can add or verify evidence, then answer after it succeeds.'
456
- : '结构化预识别已经完成,但上一条后续工具没有产出可用证据。请再调用至少一个能新增或验证证据的视觉工具,成功后再作答。',
461
+ ? 'The structured bootstrap is complete, but no usable task-directed visual evidence has been produced yet. Call at least one vision tool that can add or verify evidence, then answer after it succeeds.'
462
+ : '结构化预识别已经完成,但目前还没有产出可用的任务定向视觉证据。请至少调用一个能新增或验证证据的视觉工具,成功后再作答。',
457
463
  }],
458
464
  source: { kind: 'plugin', plugin: 'dsh-vision-router' },
459
465
  }
@@ -542,9 +548,11 @@ function wrapRegisteredTool(def, states, getConfig) {
542
548
  if (evidence !== undefined) {
543
549
  state.bootstrapDone = true
544
550
  state.bootstrapResult = result
545
- state.mixedBranches = normalizeMixedBranches(evidence, depthOf(getConfig()))
546
- state.completedBranches.clear()
547
- state.mixedAttemptSignatures.clear()
551
+ // Structured 1+x is explicitly ordered: x starts only after the
552
+ // bootstrap succeeds. Keep the all-turn successful-evidence count
553
+ // intact for the user's explicit call cap; post-bootstrap progress
554
+ // has its own counter so an early model call cannot satisfy x>=1.
555
+ state.postBootstrapEvidenceCalls = 0
548
556
  resetNonProgress(state)
549
557
  state.budgetExhausted = false
550
558
  refreshDepthExhaustion(state, getConfig())
@@ -565,7 +573,7 @@ function wrapRegisteredTool(def, states, getConfig) {
565
573
  if (state.bootstrapDone && missingEvidence(state) > 0 && state.nonProgressExhausted) {
566
574
  return failure(
567
575
  'VISION_NO_PROGRESS_LIMIT',
568
- `the structured follow-up stopped after ${MAX_NON_PROGRESS_ATTEMPTS} consecutive attempts without usable evidence progress`,
576
+ `the structured follow-up stopped after ${MAX_NON_PROGRESS_ATTEMPTS} consecutive attempts without usable evidence`,
569
577
  )
570
578
  }
571
579
 
@@ -577,18 +585,11 @@ function wrapRegisteredTool(def, states, getConfig) {
577
585
  )
578
586
  const code = resultCode(result)
579
587
  if (code === 'VISION_TURN_BUDGET_EXCEEDED') state.budgetExhausted = true
580
- if (producedUsableStructuredEvidence(result)) {
581
- const progressed = recordEvidenceSuccess(state, def.name, args)
588
+ if (producedUsableToolEvidence(def.name, result, args)) {
589
+ state.successfulEvidenceCalls += 1
590
+ if (state.bootstrapDone) state.postBootstrapEvidenceCalls += 1
582
591
  refreshDepthExhaustion(state, active)
583
- if (state.bootstrapDone && missingEvidence(state) > 0) {
584
- if (progressed) resetNonProgress(state)
585
- else {
586
- recordNonProgress(state)
587
- if (!state.nonProgressExhausted) rearmEvidenceGuardAfterAttempt(state)
588
- }
589
- } else {
590
- resetNonProgress(state)
591
- }
592
+ resetNonProgress(state)
592
593
  } else if (code !== 'VISION_TURN_BUDGET_EXCEEDED' && state.bootstrapDone && missingEvidence(state) > 0) {
593
594
  recordNonProgress(state)
594
595
  if (!state.nonProgressExhausted) rearmEvidenceGuardAfterAttempt(state)