fix(host): 修掉会打断每一轮的致命 bug —— 0.1.7 的工具参数必须经 defineTool 转成 JSON Schema

- 事故现象:每轮 Invalid schema for function 'float_window': schema must be a JSON Schema of 'type: object', got 'type: null'
- 根因:直接注册 DSL 对象,参数未做 parameterSchemaSpecToJsonSchema 转换,provider 收到 parameters.type=null
- 修复:解析 @deepseek-ai/dsh-tools 的 defineTool 后再注册;解析不到就跳过注册;注册前自检 type==='object'
- 自检证据:状态文件新增 toolSchema 与 toolSchemas(从真实 request/header 抓的模型视角 schema)
  实测 0.1.7:float_window parametersType=object, required=[action]
- 顺带:request/context 的 contextWindow 用于上下文用量;Config 增加 tool 开关
This commit is contained in:
2026-09-27 16:08:45 +08:00
parent 8b74c2d2b4
commit 60474fceea
+67 -17
View File
@@ -33,8 +33,9 @@ function dshHome() {
// 0.1.7 的设置模型是"编辑插件自身的 Config(profile 条目配置)",所以这里导出一个 // 0.1.7 的设置模型是"编辑插件自身的 Config(profile 条目配置)",所以这里导出一个
// schemastery schema,DSH 的「设置 → 插件」就能渲染出悬浮窗的配置卡片。 // schemastery schema,DSH 的「设置 → 插件」就能渲染出悬浮窗的配置卡片。
// 难点:插件用 link: 安装时自身目录没有 node_modules,裸 import 会失败 —— // 难点:插件用 link: 安装时自身目录没有 node_modules,裸 import 会失败 ——
// 用 createRequire 以"宿主入口 / profile 目录"为基准去解析 schemastery。 // 用 createRequire 以"宿主入口 / profile 目录"为基准去解析。
function loadSchemastery() { /** 通用"宿主侧模块"解析:link: 安装的插件自身没有 node_modules,必须借宿主的 */
function loadHostModule(spec) {
const tries = [ const tries = [
() => process.argv[1] && createRequire(process.argv[1]), () => process.argv[1] && createRequire(process.argv[1]),
() => createRequire(path.join(dshHome(), 'profiles', 'desktop', 'package.json')), () => createRequire(path.join(dshHome(), 'profiles', 'desktop', 'package.json')),
@@ -44,14 +45,20 @@ function loadSchemastery() {
try { try {
const req = make() const req = make()
if (!req) continue if (!req) continue
const m = req('@deepseek-ai/schemastery') const m = req(spec)
const z = m?.default || m // 返回模块命名空间本身:default 是类(ToolRuntime)时具名导出 defineTool 会被吞掉
if (z?.object) return z if (m && (m.defineTool || m.default || m.object)) return m
} catch { } } catch { }
} }
return null return null
} }
function loadSchemastery() {
const m = loadHostModule('@deepseek-ai/schemastery')
const z = m?.default ?? m
return z?.object ? z : null
}
const z = loadSchemastery() const z = loadSchemastery()
const floatSchema = z const floatSchema = z
? z.object({ ? z.object({
@@ -62,6 +69,7 @@ const floatSchema = z
opacity: z.number().min(0.3).max(1).default(0.92).description('不透明度(毛玻璃时是霜化浓度)'), opacity: z.number().min(0.3).max(1).default(0.92).description('不透明度(毛玻璃时是霜化浓度)'),
scale: z.number().min(0.8).max(1.5).default(1).description('界面缩放'), scale: z.number().min(0.8).max(1.5).default(1).description('界面缩放'),
backdrop: z.union(['opaque', 'translucent', 'acrylic', 'mica', 'blur']).default('opaque').description('背景效果'), backdrop: z.union(['opaque', 'translucent', 'acrylic', 'mica', 'blur']).default('opaque').description('背景效果'),
tool: z.boolean().default(true).description('给 Agent 注册 float_window 工具(关掉则模型看不到它)'),
}) })
: undefined : undefined
@@ -147,7 +155,7 @@ const SAMPLE_TYPES = new Set(['user/message', 'assistant/chunk', 'assistant/mess
export function apply(ctx, config) { export function apply(ctx, config) {
// 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值 // 浮窗设置:DSH 设置页写进 profile 条目 config 的值 + 默认值
const floatCfg = Object.assign( const floatCfg = Object.assign(
{ topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque' }, { topmost: true, posLock: false, sizeLock: false, exclude: true, opacity: 0.92, scale: 1, backdrop: 'opaque', tool: true },
(config && typeof config === 'object') ? config : {}, (config && typeof config === 'object') ? config : {},
) )
@@ -223,6 +231,7 @@ export function apply(ctx, config) {
const rows = new Map() // sessionId -> row[] const rows = new Map() // sessionId -> row[]
const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字) const counts = new Map() // sessionId -> 事件计数兜底(没有 sessionProjection 也能出数字)
const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件) const titles = new Map() // sessionId -> 会话标题(来自 session/title 事件)
const contextWindows = new Map() // sessionId -> 上下文窗口(来自 request/context 事件)
let lastSessionId = null let lastSessionId = null
function counter(id) { function counter(id) {
@@ -243,6 +252,22 @@ export function apply(ctx, config) {
(samples[type] ||= []).push(sanitize(event)) (samples[type] ||= []).push(sanitize(event))
} }
if (type === 'turn/end') c.turns++ if (type === 'turn/end') c.turns++
else if (type === 'request/header') {
// 记录模型实际会收到的工具 schema 摘要:这是"schema 是否合法"的最终证据
const h = (event?.data || event)?.header
if (Array.isArray(h?.tools)) {
status.toolSchemas = h.tools.slice(0, 12).map((t) => ({
name: t?.name,
parametersType: t?.parameters?.type ?? null,
required: Array.isArray(t?.parameters?.required) ? t.parameters.required : null,
}))
}
}
else if (type === 'request/context') {
// 真机上 provider 会在这条事件里给出模型上下文窗口(实测 deepseek-flash = 1000000)
const cw = (event?.data || event)?.contextWindow
if (typeof cw === 'number' && cw > 0) contextWindows.set(id, cw)
}
else if (type === 'session/title') { else if (type === 'session/title') {
const t = d2(event)?.title const t = d2(event)?.title
if (t) titles.set(id, String(t)) if (t) titles.set(id, String(t))
@@ -304,7 +329,7 @@ export function apply(ctx, config) {
const denom = input + read const denom = input + read
if (denom > 0) out.cacheHit = read / denom if (denom > 0) out.cacheHit = read / denom
} }
let capacity = modelContext() let capacity = contextWindows.get(id) || modelContext()
const used = m.surfaceTokens ?? m.totalTokens const used = m.surfaceTokens ?? m.totalTokens
if (capacity && used) out.contextUsed = Math.min(1, used / capacity) if (capacity && used) out.contextUsed = Math.min(1, used / capacity)
} }
@@ -537,13 +562,29 @@ export function apply(ctx, config) {
} }
// ---------------------------------------------------------------- Agent 工具 // ---------------------------------------------------------------- Agent 工具
// 踩坑(真机事故):0.1.7 的 tools 服务要求模型侧看到的是 **JSON Schema**,
// 参数必须经 `defineTool()`(内部 parameterSchemaSpecToJsonSchema)转换后再注册。
// 直接塞 DSL 对象会让 provider 收到 `parameters.type = null`,于是**每一轮请求都被拒**:
// Invalid schema for function 'float_window': schema must be a JSON Schema of 'type: "object"'
// 所以这里:解析不到 defineTool 就**宁可不注册**;注册前再自检一次 type === 'object'。
status.shell = shellPath() status.shell = shellPath()
writeStatus() writeStatus()
try { ; (async () => {
const tools = ctx.get('tools') try {
if (tools?.register) { const tools = ctx.get('tools')
tools.register({ if (!tools?.register) { log('tools service not available; agent trigger disabled'); return }
if (floatCfg.tool === false) { log('tool disabled by config (tool: false)'); return }
let defineTool = null
try { defineTool = (await import('@deepseek-ai/dsh-tools')).defineTool } catch { }
if (!defineTool) defineTool = loadHostModule('@deepseek-ai/dsh-tools')?.defineTool
if (typeof defineTool !== 'function') {
log('defineTool unresolvable; tool NOT registered (avoid emitting an invalid schema)')
return
}
const tool = defineTool({
name: 'float_window', name: 'float_window',
description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。', description: '控制 DSH 悬浮窗(始终置顶的会话镜像小窗):open 打开/聚焦,close 关闭,pin 切换固定位置,fullscreen 切换全屏,hide 临时隐藏(抓屏前用)。',
parameters: { parameters: {
@@ -555,13 +596,22 @@ export function apply(ctx, config) {
return `float-window: ${r.text}` return `float-window: ${r.text}`
}, },
}) })
// 自检:provider 只接受 object 根 schema,这里挡住任何回归
const schema = tool?.parameters
if (!schema || schema.type !== 'object' || !schema.properties) {
log('tool schema self-check FAILED; not registering:', short(schema, 200))
status.toolSchema = schema ?? null
writeStatus()
return
}
tools.register(tool)
status.tool = true status.tool = true
log('tool float_window registered') status.toolSchema = schema
} else { log('tool float_window registered; schema: ' + short(schema, 240))
log('tools service not available; agent trigger disabled') } catch (e) { log('tool registration failed:', String(e?.message || e)) }
} writeStatus()
} catch (e) { log('tool registration failed:', String(e?.message || e)) } })()
writeStatus()
const broadcast = (msg) => { const broadcast = (msg) => {
const payload = `data: ${JSON.stringify(msg)}\n\n` const payload = `data: ${JSON.stringify(msg)}\n\n`