'use strict' // ============================================================================ // dupguard —— DSH (DeepSeek Harness) 插件(npm / 组合常驻形式,CJS 入口) // // 实时检测大模型流式输出中的重复内容:当最新的输出中同一字符串连续重复 // CONFIG.threshold 次及以上时,立即停止本次生成。 // // 本文件是 package.json 的 main 入口,导出 Cordis 插件对象 // { name, apply },供 DSH 组合(cordis.yml / preset)挂载; // 与 plugin/host.js(动态 cordis_define 形式)行为完全一致, // tests/detector.test.js 会对两者同时跑同一套用例,防止两份代码漂移。 // // 设置集成(仅本常驻版):通过 DSH 的 settings 服务注册命名空间 // "dsh-dupguard",白名单(ignoredChars)可在 Web 设置页修改并持久化 // (见 lib/client.js),修改即时热生效。 // ============================================================================ const name = 'dupguard' /** 设置命名空间:小写 kebab-case(dsh-settings 的命名规范)。 */ const SETTINGS_NS = 'dsh-dupguard' /** * 惰性加载 schemastery 并构建设置 schema。 * * 不能用顶层 require:schemastery 3.x 的 CJS shim 内部 require 纯 ESM 的 * cosmokit,在 Node < 20.19(不支持 require(esm))上会抛 ERR_REQUIRE_ESM, * 导致整个插件无法加载。动态 import 的 ESM 入口在所有受支持版本可用, * 且解析到与 DSH 相同的模块实例(同一 realpath),schema 领域一致。 */ let settingsSchemaPromise function loadSettingsSchema() { if (settingsSchemaPromise === undefined) { settingsSchemaPromise = import('@deepseek-ai/schemastery').then(({ default: z }) => // 设置 schema:白名单字符数组(其余检测参数保持代码常量)。 z.object({ ignoredChars: z.array(z.string()).default(['-', '|']), }) ) } return settingsSchemaPromise } const CONFIG = { // 触发阈值:同一字符串连续重复次数达到该值时停止输出(用户需求:重复十次以上)。 // 语义为「>= threshold」,即第 10 次重复出现时就触发。 threshold: 10, // 参与检测的重复单元的最小/最大长度(字符数)。 // minUnitLength=1 意味着 "aaaaaaaaaa" 这类单字符循环也会触发。 minUnitLength: 1, maxUnitLength: 80, // 检测用滚动窗口(字符数,去除空白后)。 // 只需要容纳 threshold * maxUnitLength(默认 10 * 80 = 800),留足余量即可。 detectionWindow: 8192, // 检测前先移除所有空白字符(含换行): // 让 "重复 重复 重复"、"重复\n重复\n重复" 这类带分隔符的复读也能被识别。 stripWhitespace: true, // 检测时忽略的字符(白名单):Markdown 表格的分隔行由连字符与竖线组成 // (如 "|---|---|"),正常表格输出会大量连续出现,不应视为复读。 // 默认忽略连字符与竖线;需要更严格的检测时可改为空数组 []。 ignoredChars: ['-', '|'], // 是否同时检测思考(reasoning)文本。默认开启:思考中的复读同样消耗 token, // 应立即截停。注意:正常思考中若连续重复同一字符串 10 次以上(如"等等等等"), // 也会被截停,属预期行为;需要只检测可见输出时可置为 false。 monitorReasoning: true, // 是否同时检测工具调用参数(JSON 片段)。默认关闭:JSON / base64 中重复字符很常见。 monitorToolArguments: false, // DSH ≤ 0.1.2-rc.1 兼容补丁(默认开启;实测 0.1.1-rc.1 / 0.1.2-rc.1 仍未修复):preset 的 standing mount // 在 composition 文件变化后 // 会新建一代而旧代永不销毁,tool-cordis 每次挂载都向进程全局的 cordisInspect 注册表 // 注册 Service/Event/Builtin/Tool provider,两代并存即抛 // "Host Cordis inspect provider ... is already registered"(表现为:截停后模型操作报 // resume failed,且必须重启 DSH 才能恢复)。开启后本插件把 cordisInspect.register // 幂等化(同 id 已注册时共享注册),消除多代冲突。补丁进程内常驻(卸载本插件后 // 依然生效,重启 DSH 后由本插件重新安装);DSH 升级修复后可关闭。 fixStandingMountConflict: true, } /** * 尾部连续重复检测:text 是否以某个 unit(长度 minUnitLength..maxUnitLength) * 连续重复 >= threshold 次结尾。是则返回 { unit, count, span },否则返回 null。 * * 说明:由于本插件对每个增量实时调用本函数,模型一旦陷入复读循环, * 循环必然发生在文本尾部,因此尾部检测即可覆盖所有循环场景; * 不做全窗口词频统计,是为了避免正常文本(例如中文里高频出现的"的") * 被误判为重复。 */ function findRepeatedTail(text, threshold, minUnitLength, maxUnitLength) { const n = text.length if (n < threshold * minUnitLength) return null const maxP = Math.min(maxUnitLength, Math.floor(n / threshold)) for (let p = minUnitLength; p <= maxP; p++) { const unit = text.slice(n - p) // 最后一个候选单元 let ok = true for (let k = 1; k < threshold; k++) { // 向前逐段比较前 threshold-1 个副本 if (text.slice(n - p * (k + 1), n - p * k) !== unit) { ok = false break } } if (ok) return { unit, count: threshold, span: p * threshold } } return null } /** 移除所有空白字符(与 CONFIG.stripWhitespace 配合)。 */ function stripWhitespace(text) { return text.replace(/\s+/g, '') } /** 移除白名单字符(与 CONFIG.ignoredChars 配合)。 */ function stripIgnoredChars(text, ignored) { if (ignored.length === 0) return text let out = '' for (let i = 0; i < text.length; i++) { const ch = text[i] if (ignored.indexOf(ch) === -1) out += ch } return out } /** 供检测使用的增量清洗:去空白 + 移除白名单字符。 */ function sanitizeDelta(delta, config) { let piece = config.stripWhitespace ? stripWhitespace(delta) : delta if (config.ignoredChars.length > 0) piece = stripIgnoredChars(piece, config.ignoredChars) return piece } /** * 为一次 llm/stream 调用创建守卫。 * 每次模型调用都会新建一份状态,互不干扰。 * @param options - llm/stream 的 GenerateOptions。 * @param runtime - 运行时配置(ignoredChars 可被设置页热更新)。 */ function createStreamGuard(options, runtime) { // index -> 块状态。用 Map 按块索引累积,避免多个文本块交替输出时互相打断检测。 const blocks = new Map() let stopped = null const provider = typeof options === 'object' && options !== null ? String(options.provider ?? '?') : '?' const model = typeof options === 'object' && options !== null ? String(options.model ?? '?') : '?' function ensure(index, blockType) { let b = blocks.get(index) if (b === undefined) { b = { blockType, text: '', // 完整文本:停止时需要用它闭合块,不能只保留窗口 stripped: '', // 去空白后的滚动窗口:仅用于检测 toolCallId: undefined, toolCallName: undefined, toolCallArguments: '', } blocks.set(index, b) } return b } /** 把一段文本增量喂给检测缓冲,返回命中结果(null 表示未命中)。 */ function feedText(b, delta) { b.text += delta const piece = sanitizeDelta(delta, runtime) b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow) return findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength) } /** 依据 StreamChunk 协议累积状态;命中时置 stopped。 */ function feed(chunk) { switch (chunk.type) { case 'block-start': { ensure(chunk.index, chunk.blockType) return } case 'text-delta': { const b = ensure(chunk.index, 'text') const hit = feedText(b, chunk.text) if (hit !== null) stopped = hit return } case 'reasoning-delta': { const b = ensure(chunk.index, 'reasoning') if (runtime.monitorReasoning) { // feedText 内部负责累积完整文本与检测缓冲,这里不得重复 += const hit = feedText(b, chunk.text) if (hit !== null) stopped = hit } else { b.text += chunk.text // 不检测时仍须累积完整内容以便停止时闭合块 } return } case 'tool-call-delta': { const b = ensure(chunk.index, 'tool-call') if (chunk.id !== undefined) b.toolCallId = chunk.id if (chunk.name !== undefined) b.toolCallName = chunk.name b.toolCallArguments += chunk.argumentsDelta if (runtime.monitorToolArguments) { const piece = sanitizeDelta(chunk.argumentsDelta, runtime) b.stripped = (b.stripped + piece).slice(-runtime.detectionWindow) const hit = findRepeatedTail(b.stripped, runtime.threshold, runtime.minUnitLength, runtime.maxUnitLength) if (hit !== null) stopped = hit } return } case 'block-end': { blocks.delete(chunk.index) return } case 'usage': case 'finish': default: return } } /** 把块状态装配为 ContentBlock(与 BlockAssembler 的 open-block 装配规则一致)。 */ function closeBlock(b, index) { let block if (b.blockType === 'text') { block = { type: 'text', text: b.text } } else if (b.blockType === 'reasoning') { block = { type: 'reasoning', text: b.text } } else { block = { type: 'tool-call', id: b.toolCallId !== undefined ? b.toolCallId : 'call-' + index, name: b.toolCallName !== undefined ? b.toolCallName : '', arguments: b.toolCallArguments, } } return { type: 'block-end', index, block } } /** * 满足 llm/stream 协议地收尾: * 1) 闭合所有仍打开的块(否则 llm-invariant 校验器会报 * "LLM stream finished with N open block(s)"); * 2) 以 finish(stop) 结尾(否则报 "LLM stream ended without a terminal finish chunk")。 * agent-loop 因此把已生成内容正常提交为助手消息,本轮干净结束。 */ function* closingChunks() { for (const [index, b] of blocks) yield closeBlock(b, index) yield { type: 'finish', reason: { kind: 'stop' } } } /** 包装上游流:逐块透传 + 检测;命中后补发闭合块并提前结束。 */ async function* guarded(source) { for await (const chunk of source) { yield chunk feed(chunk) if (stopped !== null) { console.log( '[dupguard] 检测到重复输出,已停止生成:provider=' + provider + ' model=' + model + ' unit=' + JSON.stringify(stopped.unit) + ' repeat>=' + String(stopped.count) + ' span=' + String(stopped.span) + 'chars' ) yield* closingChunks() // 提前 return:for-await 会调用上游 iterator.return(), // 适配器的 finally 随即 consumer.abort() 中断 HTTP 连接, // 从而在服务端真正停止生成。这里绝不调用 options.signal.abort(), // 因为 loop 请求的 options.signal 就是整个 agent 步骤的信号, // 直接中止会让本轮以 aborted 结束并丢弃已生成的消息。 return } } } return guarded } /** * DSH ≤ 0.1.2-rc.1 standing-mount 兼容补丁:把 cordisInspect.register 幂等化。 * * 背景:preset 的 standing mount 在 composition 文件变化后新建一代、旧代永不销毁; * tool-cordis 每次挂载都向进程全局的 cordisInspect 注册 Service/Event/Builtin/Tool * provider,两代并存即抛 "already registered"。幂等化后同 id 的后续注册共享已有 * 注册(返回 no-op disposer),多代并存不再冲突。 * * 说明:补丁有意不做撤销(常驻进程,重启后由本插件重新安装);依赖 * cordisInspect.providers 为可读 Map(实测 rc.6 / rc.7 / 0.1.1-rc.1 / 0.1.2-rc.1 如此)。 * DSH 升级修复后可将 CONFIG.fixStandingMountConflict 置为 false。 */ function installStandingMountPatch(ctx) { const inspect = ctx.cordisInspect if (inspect === undefined || typeof inspect.register !== 'function') return if (inspect.register.__dupguardIdempotent === true) return const original = inspect.register.bind(inspect) const patched = (registration) => { const id = registration && typeof registration === 'object' ? registration.manifest?.id : undefined if (typeof id === 'string' && inspect.providers !== undefined && inspect.providers.has(id)) { // 同 id 已有注册(旧代仍活着):共享它,返回 no-op disposer, // 新代卸载时不得注销共享注册。 return () => {} } return original(registration) } patched.__dupguardIdempotent = true inspect.register = patched console.log('[dupguard] cordisInspect.register 已幂等化(DSH standing-mount 多代并存兼容补丁)') } /** * 把 CONFIG 拷贝为运行时配置:ignoredChars 会被设置页热更新, * 其余字段保持代码常量(设置页只暴露白名单)。 */ function createRuntime() { return { ...CONFIG, ignoredChars: [...CONFIG.ignoredChars], } } /** * 从 DSH 的 settings 服务注册白名单设置(仅常驻版)。 * 返回注销器;settings 服务缺失(无头环境)时不做任何事。 */ async function installSettings(ctx, runtime) { const settings = ctx.settings if (settings === undefined || typeof settings.register !== 'function') return try { const schema = await loadSettingsSchema() const scope = settings.register(SETTINGS_NS, schema, { base: { ignoredChars: CONFIG.ignoredChars }, }) const sync = () => { runtime.ignoredChars = [...scope.get().ignoredChars] } sync() const stop = scope.watch(sync) ctx.effect(() => () => stop(), 'dupguard: settings watch') console.log('[dupguard] 已注册设置命名空间 ' + SETTINGS_NS + '(白名单可在设置页修改)') } catch (error) { // 设置注册失败不得破坏检测主体:白名单保持代码常量,仅损失设置页能力。 console.error('[dupguard] 设置命名空间注册失败(白名单将使用默认值):' + (error && error.message ? error.message : String(error))) } } function apply(ctx) { // 启动自检:把关键服务可见性直接打到宿主日志,便于诊断挂载问题。 console.log('[dupguard] 常驻插件 apply 开始') const runtime = createRuntime() // loader entry 的 ctx 无法用 ctx.get 直接解析 root 服务(DSH 宿主行的约定), // 必须经 ctx.inject 等待服务注入后在回调中访问 ctx.settings / ctx.cordisInspect。 ctx.inject(['cordisInspect'], (inspectCtx) => { if (CONFIG.fixStandingMountConflict) installStandingMountPatch(inspectCtx) }) ctx.inject(['settings'], (settingsCtx) => { installSettings(settingsCtx, runtime) }) // llm/stream:包裹每次流式模型调用的瀑布事件。 // 监听器返回包装后的 AsyncIterable,即成为本次调用对消费方可见的流。 // (与 @deepseek-ai/dsh-llm 的 invariant、dsh-session-checkpoint-policy 同款接入方式) ctx.on('llm/stream', (options, next) => createStreamGuard(options, runtime)(next())) } module.exports = { name, apply }