From a9175e42deb072b61ec83f7661d0b06e8bfc1d16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=8F=AF=E6=88=90?= Date: Sat, 5 Sep 2026 00:27:41 +0800 Subject: [PATCH] first commit --- .gitignore | 9 ++ LICENSE | 22 +++ README.md | 109 ++++++++++++++ client.js | 320 +++++++++++++++++++++++++++++++++++++++++ cordis.patch.yml | 18 +++ index.js | 356 ++++++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 218 ++++++++++++++++++++++++++++ package.json | 1 + test.mjs | 353 +++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 1406 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 client.js create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00ff63c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +coverage/ +*.log +.DS_Store +.idea/ +.vscode/ +*.tsbuildinfo + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5025984 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 kevin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2baa762 --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# @kevin/dsh-enhance + +给 kevin 适配用的 DeepSeek Harness(DSH)插件。当模型把 EPSE 工具调用框架(开标签 + 闭合标签成对出现)写进**文本回复**里、而不是发起原生工具调用时,agent 循环本会因「本轮没调用工具」而直接停止。本插件把这种回复**当作一次失败的模型请求**,让循环在原地重试。 + +## 行为 + +把坏回复判定为「请求失败」。这条原生路径由两半组成: + +1. **`llm/stream`**:包裹提供方流,只观察模型的文本增量;一旦出现 EPSE 框架,就把末尾成功的 `finish` 换成携带 `EPSE_TOOL_CALL_FRAME` 的 `{ kind: 'error' }` finish。因为这次尝试以失败结束,循环**不会**为它追加 `assistant/message` —— 坏回复从未进入模型可见表面,所以事后无需删除任何东西。 +2. **`agent/request-error`**:认领这个专属失败码,写下持久的 `llm/retry` / `llm/retry-started` 记录,返回 `{ kind: 'retry' }`。循环在**同一 turn、同一 step** 内基于未变的派生历史重新请求 —— 原始用户请求不会被重放,因此前端不会出现第二个用户气泡。 + + +## 前端表现 + +- `llm/retry` 会让会话 UI 对该 step 的 assistant 节点执行 `resetForRetry`(清空 blocks 并置为 hidden),所以坏回复从对话中消失,重生成的回复渲染在它原来的位置。 +- 该 step 会多出一行「已重试模型请求」的 retry 提示行,附带失败原因。这是 DSH 原生的重试呈现,也是这次重生成留下的唯一痕迹。 + + +## 安装 + +从远程仓库安装(需 dsh 命令行): + +```bash +dsh plugin --profile web add https://git.kevinllm.v6.army/kevin/kevin-dsh-enhance.git +``` + +或从本地副本安装: + +```bash +dsh plugin --profile web add .//dsh-2api +``` + +装完重启dsh后即生效。 + + +可选:若某个部署想为这两个参数钉一个组合层 base(例如收窄到特定 provider,且不希望依赖用户设置),在 profile 的 `cordis.patch.yml` 里覆盖该行的 `config`: + +```yaml +- id: kevin-dsh-enhance-guard + config: + maxRegenerationsPerTurn: 2 + targetProviders: [ 'kevin' ] +``` + +用户在 UI 里保存的值仍然覆盖这个 base。 + + +## 配置 + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `targetProviders` | `string[]` | `[]` | 限定命中的 provider / model 路由。**留空 = 对所有 agent 生效。** | +| `maxRegenerationsPerTurn` | `number` | `2` | 每个 step 最多强制重生成次数,防死循环。 | + + +## 用户设置(插件配置页) + +插件自带一个设置卡片,显示在 **设置 → 插件 → 插件配置** 页(与「终端」「Agent 循环」「网页搜索」并列)。卡片提供两个输入框,保存后写入 `~/.dsh/settings.yaml` 并在下次请求立即生效(无需重启): + + +`cordis.patch.yml` 里的 `config` 只是**组合层 base**;用户在 UI 里保存的值会覆盖它,留空并保存则恢复继承 base / 默认值。 + + +## 检测规则 + +文本中**同时存在**以下两者即判定命中(经归一化:全角形式如 `<`、`EPSE` 经 NFKC 归一到 ASCII,再 lowercase 处理大小写): + +- **开标签**:前缀 `<|EPSE` / `<|epse` / ` { + const React = require("react"); + const { createElement: h, useState } = React; + const { IconChevronDownOutline14 } = require("@deepseek-ai/dsh-client-ui-primitives"); + + const NS = "kevin-dsh-enhance-guard"; + const LOCALE_NS = "kevin-dsh-enhance-guard"; + + const cardCss = ` +.epg_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s} +.epg_card:hover{border-color:var(--dsw-alias-label-dimmed)} +.epg_cardOpen{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)} +.epg_header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex} +.epg_header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px} +.epg_headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex} +.epg_name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4} +.epg_description{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5} +.epg_chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s} +.epg_chevronOpen{transform:rotate(180deg)} +.epg_body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px} +.epg_readOnly{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:1.5} +.epg_pending{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;flex:none;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px} +.epg_footer{border-top:1px solid var(--dsw-alias-border-l2);justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px;display:flex} +.epg_failed{min-width:0;color:var(--dsw-alias-label-error);flex:1;margin:0;font-size:12px;line-height:1.5} +.epg_discard,.epg_save{appearance:none;font:inherit;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;font-size:13px;line-height:1.5} +.epg_discard{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:0 0} +.epg_discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)} +.epg_save{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)} +.epg_discard:disabled,.epg_save:disabled{opacity:.4;cursor:default} +.epg_discard:focus-visible,.epg_save:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px} +`; + const fieldCss = ` +.epg_field{flex-direction:column;gap:6px;padding:12px 0;display:flex} +.epg_field+.epg_field{border-top:1px solid var(--dsw-alias-border-l2)} +.epg_head{align-items:center;gap:8px;display:flex} +.epg_label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5} +.epg_badges{align-items:center;gap:8px;display:inline-flex} +.epg_badge{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px} +.epg_badgeMuted{white-space:nowrap;color:var(--dsw-alias-label-tertiary);border-radius:999px;padding:1px 8px;font-size:11px;line-height:17px} +.epg_reset{font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0;font-size:12px;line-height:1.5} +.epg_reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)} +.epg_reset:disabled{cursor:default} +.epg_input{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);height:34px;font:inherit;color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px;line-height:1.5} +.epg_input:focus-visible{border-color:var(--dsw-alias-brand-primary);outline:none} +.epg_input:disabled{color:var(--dsw-alias-label-tertiary);cursor:default} +.epg_inputInvalid{border-color:var(--dsw-alias-label-error)} +.epg_invalid{color:var(--dsw-alias-label-error);margin:0;font-size:12px;line-height:1.5} +.epg_hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5} +`; + const TAG = "@kevin/dsh-enhance/card.module.css"; + if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(TAG) + "]") === null) { + const tag = document.createElement("style"); + tag.dataset.plugin = "@kevin/dsh-enhance"; + tag.dataset.pluginCss = TAG; + tag.textContent = cardCss + fieldCss; + document.head.appendChild(tag); + } + + const c = { + card: "epg_card", cardOpen: "epg_cardOpen", header: "epg_header", + headText: "epg_headText", name: "epg_name", description: "epg_description", + chevron: "epg_chevron", chevronOpen: "epg_chevronOpen", body: "epg_body", + readOnly: "epg_readOnly", pending: "epg_pending", footer: "epg_footer", + failed: "epg_failed", discard: "epg_discard", save: "epg_save", + field: "epg_field", head: "epg_head", label: "epg_label", + badges: "epg_badges", badge: "epg_badge", badgeMuted: "epg_badgeMuted", + reset: "epg_reset", input: "epg_input", inputInvalid: "epg_inputInvalid", + invalid: "epg_invalid", hint: "epg_hint", + }; + + const en = { + title: "EPSE leakage guard", + description: "Companion Plugin Settings.", + maxRegenerations: "Regenerations per step", + maxRegenerationsHint: "How many times one step may be forced to regenerate after a framed reply (min 1).", + targetProviders: "Target providers / models", + targetProvidersHint: "Comma-separated provider or model names. Leave blank to apply to every agent.", + unsaved: "Unsaved", + readOnly: "This deployment stores settings read-only.", + expand: "Show settings", + collapse: "Hide settings", + save: "Save", + saving: "Saving…", + discard: "Discard", + saveFailed: "The deployment did not accept these values; they were left for you to correct.", + overridden: "Overridden", + reset: "Reset to default", + invalidNumber: "Enter a whole number, or leave blank to use the default.", + }; + const zh = { + title: "EPSE 泄露守护", + description: "DS2api 辅助插件设置。", + maxRegenerations: "每步重生成次数", + maxRegenerationsHint: "模型把工具调用框架写进文本时,同一 step 最多强制重生成多少次(最小 1)。", + targetProviders: "限定的 provider / model", + targetProvidersHint: "逗号分隔的 provider 或 model 名。留空表示对所有 agent 生效。", + unsaved: "未保存", + readOnly: "本部署的设置为只读。", + expand: "展开设置", + collapse: "收起设置", + save: "保存", + saving: "保存中…", + discard: "放弃修改", + saveFailed: "本部署没有接受这些值,已保留供你修改。", + overridden: "已覆盖", + reset: "恢复默认", + invalidNumber: "请填整数;留空表示使用默认值。", + }; + + const cls = (...parts) => parts.filter(Boolean).join(" "); + const formatMax = (value) => (value == null ? "" : String(value)); + const formatProviders = (value) => (Array.isArray(value) ? value.join(", ") : ""); + const parseMax = (text) => { + if (text.trim() === "") return { kind: "clear" }; + const n = Number(text); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) return undefined; + return { kind: "set", value: n }; + }; + const parseProviders = (text) => { + const parts = text.split(/[,,\s]+/).map((s) => s.trim()).filter(Boolean); + return { kind: "set", value: parts }; + }; + + class CardController { + constructor(scope) { + this.scope = scope; + this.staged = {}; + this.saving = false; + this.failed = false; + this.listeners = new Set(); + this.parsers = { maxRegenerationsPerTurn: parseMax, targetProviders: parseProviders }; + this.state = this.compute(); + this.unsubscribe = scope.subscribe(() => { + this.state = this.compute(); + this.publish(); + }); + } + compute() { + const snap = this.scope.getSnapshot(); + const available = snap.status === "ready"; + const writable = snap.writable === true; + const user = snap.user && typeof snap.user === "object" ? snap.user : {}; + const value = snap.value && typeof snap.value === "object" ? snap.value : {}; + const staged = this.staged; + const maxText = Object.hasOwn(staged, "maxRegenerationsPerTurn") ? staged.maxRegenerationsPerTurn : formatMax(value.maxRegenerationsPerTurn); + const maxOverridden = Object.hasOwn(staged, "maxRegenerationsPerTurn") || Object.hasOwn(user, "maxRegenerationsPerTurn"); + const maxInvalid = Object.hasOwn(staged, "maxRegenerationsPerTurn") ? parseMax(staged.maxRegenerationsPerTurn) === undefined : false; + const provText = Object.hasOwn(staged, "targetProviders") ? staged.targetProviders : formatProviders(value.targetProviders); + const provOverridden = Object.hasOwn(staged, "targetProviders") || Object.hasOwn(user, "targetProviders"); + const provInvalid = false; + const dirty = Object.keys(staged).length > 0; + return { + available, + writable, + dirty, + invalid: maxInvalid || provInvalid, + saving: this.saving, + failed: this.failed, + maxRegenerationsPerTurn: { text: maxText, overridden: maxOverridden, invalid: maxInvalid }, + targetProviders: { text: provText, overridden: provOverridden, invalid: provInvalid }, + }; + } + getSnapshot() { return this.state; } + subscribe(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } + publish() { for (const listener of this.listeners) listener(); } + dispose() { this.unsubscribe(); this.listeners.clear(); } + edit(field, text) { this.staged[field] = text; this.state = this.compute(); this.publish(); } + resetField(field) { this.staged[field] = ""; this.state = this.compute(); this.publish(); } + discard() { this.staged = {}; this.state = this.compute(); this.publish(); } + async save() { + if (this.saving) return; + this.saving = true; + this.failed = false; + this.state = this.compute(); + this.publish(); + try { + for (const field of Object.keys(this.staged)) { + const result = this.parsers[field](this.staged[field]); + if (result === undefined) continue; + if (result.kind === "set") await this.scope.set(field, result.value); + else if (result.kind === "clear") await this.scope.unset(field); + } + this.staged = {}; + this.saving = false; + this.failed = false; + this.state = this.compute(); + this.publish(); + } catch (error) { + this.saving = false; + this.failed = true; + this.state = this.compute(); + this.publish(); + } + } + inject() { + return { + hooks: { epseCard: this }, + edit: (field, text) => this.edit(field, text), + resetField: (field) => this.resetField(field), + save: () => this.save(), + discard: () => this.discard(), + }; + } + } + + function ValueField(props) { + return h("div", { className: c.field }, [ + h("div", { className: c.head }, [ + h("label", { className: c.label, htmlFor: props.id }, props.label), + props.overridden ? h("span", { className: c.badges }, [ + h("span", { className: c.badge }, props.overriddenLabel), + h("button", { type: "button", className: c.reset, disabled: props.disabled, onClick: props.onReset }, props.resetLabel), + ]) : null, + ]), + h("input", { + id: props.id, + className: props.invalid ? c.inputInvalid : c.input, + type: "text", + ...(props.numeric === true ? { inputMode: "numeric" } : {}), + ...(props.invalid ? { "aria-invalid": true } : {}), + value: props.text, + placeholder: props.placeholder ?? "", + disabled: props.disabled, + onChange: (event) => props.onEdit(event.target.value), + }), + h("p", { className: props.invalid ? c.invalid : c.hint }, props.invalid ? props.invalidLabel : props.hint), + ]); + } + + function EpseGuardCard(props) { + const [open, setOpen] = useState(false); + const state = props.useEpseCard((snapshot) => snapshot); + if (!state.available) return null; + const title = props.t("title"); + const blocked = !state.dirty || state.invalid || state.saving; + return h("li", { className: cls(c.card, open && c.cardOpen) }, [ + h("button", { + type: "button", + className: c.header, + "aria-expanded": open, + "aria-label": `${props.t(open ? "collapse" : "expand")}: ${title}`, + onClick: () => setOpen(!open), + }, [ + h("span", { className: c.headText }, [ + h("span", { className: c.name }, title), + h("span", { className: c.description }, props.t("description")), + ]), + state.dirty ? h("span", { className: c.pending }, props.t("unsaved")) : null, + h(IconChevronDownOutline14, { className: cls(c.chevron, open && c.chevronOpen) }), + ]), + open ? h("div", { className: c.body }, [ + !state.writable ? h("p", { className: c.readOnly, role: "status" }, props.t("readOnly")) : null, + h(ValueField, { + id: "plugin-config-epse-max", + label: props.t("maxRegenerations"), + hint: props.t("maxRegenerationsHint"), + overriddenLabel: props.t("overridden"), + resetLabel: props.t("reset"), + invalidLabel: props.t("invalidNumber"), + numeric: true, + disabled: !state.writable, + ...state.maxRegenerationsPerTurn, + onEdit: (text) => props.edit("maxRegenerationsPerTurn", text), + onReset: () => props.resetField("maxRegenerationsPerTurn"), + }), + h(ValueField, { + id: "plugin-config-epse-targets", + label: props.t("targetProviders"), + hint: props.t("targetProvidersHint"), + overriddenLabel: props.t("overridden"), + resetLabel: props.t("reset"), + invalidLabel: "", + numeric: false, + disabled: !state.writable, + ...state.targetProviders, + onEdit: (text) => props.edit("targetProviders", text), + onReset: () => props.resetField("targetProviders"), + }), + h("div", { className: c.footer }, [ + state.failed ? h("p", { className: c.failed, role: "status" }, props.t("saveFailed")) : null, + h("button", { type: "button", className: c.discard, disabled: !state.dirty || state.saving, onClick: props.discard }, props.t("discard")), + h("button", { type: "button", className: c.save, disabled: blocked, onClick: props.save }, props.t(state.saving ? "saving" : "save")), + ]), + ]) : null, + ]); + } + + const inject = ["slots", "settingsScope", "locale"]; + + function apply(ctx) { + ctx.effect(() => ctx.locale.register(LOCALE_NS, { zh, en }), "kevin-dsh-enhance-guard: locale"); + const scope = ctx.settingsScope.bind({ namespace: NS }); + const controller = new CardController(scope); + ctx.effect(() => () => controller.dispose(), "kevin-dsh-enhance-guard: card controller"); + ctx.effect(() => ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({ + name: "settings.plugin.item", + key: NS, + locale: LOCALE_NS, + inject: () => controller.inject(), + }, EpseGuardCard)), "kevin-dsh-enhance-guard: settings card"); + } + + return { apply, inject }; + }, +}); diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..b81e37b --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,18 @@ +# EPSE tool-call regeneration guard — DSH bundle patch. +# Inserts the plugin row into the profile composition. With no `config`, the +# guard applies to ALL agents (targetProviders empty) and forces at most two +# regenerations per step (maxRegenerationsPerTurn: 2). +# +# These two knobs are ALSO user-editable on 插件 → 插件配置 via the guard's +# settings card: the `config` here is only the composition BASE layer, and a +# value the user sets in the UI overrides it. +# +# Optional per-deployment overrides: +# config: +# maxRegenerationsPerTurn: 2 +# targetProviders: [] # e.g. ['my-model' ] to narrow scope + +- insert: + - id: kevin-dsh-enhance-guard + name: '@kevin/dsh-enhance' + \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..fb07bfa --- /dev/null +++ b/index.js @@ -0,0 +1,356 @@ +/** + * EPSE tool-call regeneration guard — Host-side Cordis plugin. + * + * When the model writes an EPSE tool-call frame (an opening tag and a closing + * tag, paired) into its TEXT reply instead of issuing a native tool call, this + * guard treats that response as a failed model request and lets the agent loop + * retry it in place. + * + * Mechanism (two halves of one native path, no deletion and no re-prompting): + * + * 1. `llm/stream` — the guard wraps the provider stream, watches only the + * model's text deltas, and when the frame appears it swaps the terminal + * `finish` chunk for a `{ kind: 'error' }` finish carrying + * {@link FAILURE_CODE}. Because the attempt now ends in failure, the loop + * never appends an `assistant/message` for it: the malformed reply never + * reaches the model-visible surface, so nothing has to be removed later. + * + * 2. `agent/request-error` — the guard claims that exact failure code, records + * the durable `llm/retry` / `llm/retry-started` pair, and returns + * `{ kind: 'retry' }`. The loop re-requests inside the SAME turn and step + * from the unchanged derived history, so the original user request is not + * replayed and no second user bubble appears. The `llm/retry` record also + * makes the conversation UI reset that step's assistant node, so the + * malformed reply disappears from the transcript and the regenerated reply + * renders in its place. + * + * Detection scope: ONLY model-generated text chunks. It never inspects written + * files, tool results, tool arguments, reasoning, or log-only events. + * + * Eligibility is judged from request DATA, never from dsh-llm's process-local + * `isAgentLoopRequest` marker — see {@link apply}'s `eligible`. A plugin + * installed by path resolves its own dsh-llm copy, so that marker's WeakSet is + * not the one the loop wrote to and the predicate is false for every real + * request. + */ + +import z from '@deepseek-ai/schemastery'; + +export const name = 'kevin-dsh-enhance-guard'; +export const inject = ['sessions', 'agents']; + +/** Provider-neutral failure code this guard mints and exclusively claims. */ +const FAILURE_CODE = 'EPSE_TOOL_CALL_FRAME'; +/** Canonical policy identity separating this guard's retry chains from real ones. */ +const POLICY_KEY = 'kevin-dsh-enhance-guard/v1'; +/** Human-readable failure recorded with every claimed attempt. */ +const FAILURE_MESSAGE = 'model emitted an EPSE tool-call frame as text instead of a native tool call'; +/** Forced regenerations allowed per step when the config does not say otherwise. */ +const DEFAULT_MAX_PER_STEP = 2; + +/** User-editable settings namespace for the two knobs (插件配置 page). */ +const SETTINGS_NAMESPACE = 'kevin-dsh-enhance-guard'; +/** Schema of the user-owned section, layered over the composition entry. */ +const SETTINGS_SCHEMA = z.object({ + maxRegenerationsPerTurn: z.number().step(1).min(1).default(DEFAULT_MAX_PER_STEP), + targetProviders: z.array(z.string()).default([]), +}); + +export function apply(ctx, config) { + const entry = config || {}; + // Composition entry stays the default source; the settings card swaps in a + // resolved-settings thunk once a settings service is mounted. + let configSource = () => entry; + + /** Resolve the live knobs from the current source (entry or user settings). */ + const effectiveConfig = () => { + const cfg = configSource() || {}; + return { + // Provider/model routes to restrict to; empty set = apply to ALL agents. + targets: new Set(Array.isArray(cfg.targetProviders) ? cfg.targetProviders : []), + // Max forced regenerations per (session, turn, step) to avoid infinite loops. + maxPerStep: + typeof cfg.maxRegenerationsPerTurn === 'number' && cfg.maxRegenerationsPerTurn >= 1 + ? Math.floor(cfg.maxRegenerationsPerTurn) + : DEFAULT_MAX_PER_STEP, + }; + }; + + // Expose the knobs as a user-editable settings section so the 插件配置 page + // can render a card for them; the base layer is this composition entry. + // Register through the settings service the SAME way the stock plugins do + // (agent-loop, web-search, terminal) — `ctx.inject(["settings"], ...)` gives + // a child context whose `.settings` is the live SettingsProvider; a plain + // `ctx.get("settings")` is not how a plugin context reaches it. + try { + ctx.inject(['settings'], (settingsCtx) => { + settingsCtx.settings.installSection( + ctx, + SETTINGS_NAMESPACE, + SETTINGS_SCHEMA, + entry, + { + setSource: (current) => { + configSource = current; + }, + onChange: () => {}, + }, + ); + }); + } catch (error) { + ctx.logger?.warn?.( + 'kevin-dsh-enhance-guard: settings section unavailable; using composition config: %o', + error, + ); + } + + /** session -> Map<`turn:step`, forced failures already injected>. */ + const forced = new WeakMap(); + + const normalize = (s) => String(s == null ? '' : s).normalize('NFKC').toLowerCase(); + + // Rule: the text contains BOTH an EPSE opening tag and an EPSE closing tag. + // NFKC maps full-width forms (<, EPSE) to ASCII; lowercasing handles case. + // Opening: <|epse or { + const t = normalize(text); + return /<\|?epse/.test(t) && /<\/\|?epse/.test(t); + }; + + const routeInScope = (provider, model) => { + const { targets } = effectiveConfig(); + return targets.size === 0 || targets.has(provider) || targets.has(model); + }; + + const agentInScope = (agent) => { + const { targets } = effectiveConfig(); + if (targets.size === 0) return true; // empty = every agent + const opt = (agent && agent.options) || {}; + return targets.has(opt.provider) || targets.has(opt.model); + }; + + /** The turn/step currently open in a session log, or undefined outside one. */ + const openStep = (session) => { + const events = + typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events; + if (!Array.isArray(events)) return undefined; + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.type === 'step/start') return { turn: event.data.turn, step: event.data.step }; + if (event.type === 'step/end' || event.type === 'turn/end' || event.type === 'turn/start') return; + } + return undefined; + }; + + const budget = (session) => { + let counts = forced.get(session); + if (!counts) { + counts = new Map(); + forced.set(session, counts); + } + return counts; + }; + + const mintRetryId = () => + `epse-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + + /** Accumulate one chunk's model-facing TEXT, ignoring every other block kind. */ + const textOf = (chunk) => { + if (chunk.type === 'text-delta') return typeof chunk.text === 'string' ? chunk.text : ''; + if (chunk.type === 'block-end') { + const block = chunk.block; + return block && block.type === 'text' && typeof block.text === 'string' ? block.text : ''; + } + return ''; + }; + + /** + * Whether a request's `messages` array IS the session's current derived + * history — the same array contents, element for element. + * + * `deriveMessages()` returns a fresh array of SHARED, deep-frozen `Message` + * objects, and the loop passes that array straight into its request, so + * reference equality per element holds for a loop-built request and fails for + * a hand-assembled message list. Comparing objects (not their content) also + * keeps this O(n) over a handful of pointers instead of a deep walk. + * @param session - the live session the request names. + * @param messages - the request's message list. + * @returns whether the list is exactly the derived history. + */ + const isDerivedHistory = (session, messages) => { + if (!Array.isArray(messages)) return false; + const derived = session.deriveMessages(); + if (derived.length !== messages.length) return false; + for (let index = 0; index < derived.length; index++) { + if (derived[index] !== messages[index]) return false; + } + return true; + }; + + /** + * Whether this request is an ordinary in-loop conversation request, judged + * WITHOUT dsh-llm's `isAgentLoopRequest` marker. + * + * That marker is a WeakSet living inside ONE dsh-llm module instance. A plugin + * installed by path (`link:` / junction) resolves its own copy of dsh-llm + * through its own `node_modules`, so the loop's mark is written into a + * different WeakSet than the one this code would read: the predicate answers + * false for EVERY real request and the guard silently never fires. Object + * identity cannot be shared across duplicated module instances, so this + * checks the two properties dsh-llm documents for a loop-built request + * instead — both plain data, both duplication-proof: + * + * - it arrives deep-frozen (`GenerateOptions` docs: mutation throws); + * - its content is a pure function of the session log, so its `messages` + * array is that session's current derived history. + * + * The remaining conditions are the guard's own preconditions: an auxiliary + * call (`purpose`) is not its business, and the route must be in scope. + * + * Any throw means "not eligible": a guard must never break a model call to + * decide it should not have guarded it. + * @param options - the request observed at the `llm/stream` waterfall. + * @param session - the live session the request names. + * @returns whether the guard may force this attempt to fail. + */ + const eligible = (options, session) => { + try { + return ( + options.purpose === undefined && + Object.isFrozen(options) && + isDerivedHistory(session, options.messages) && + routeInScope(options.provider, options.model) + ); + } catch (error) { + ctx.logger?.warn?.('kevin-dsh-enhance-guard: eligibility check failed: %o', error); + return false; + } + }; + + /** + * Wrap one provider stream and end a framed attempt as a request failure. + * @param options - the frozen request being streamed. + * @param next - the downstream stream this listener wraps. + */ + async function* guardStream(options, next) { + const source = next(); + + // Only ordinary loop-built conversation requests are eligible: auxiliary + // calls (compaction, session titles) and hand-built one-shots are not this + // guard's business, and neither owns a turn/step to retry inside. + const session = + options.sessionId === undefined ? undefined : ctx.sessions.get(options.sessionId); + if (session === undefined || !eligible(options, session)) { + yield* source; + return; + } + + const position = openStep(session); + if (position === undefined) { + yield* source; + return; + } + + const counts = budget(session); + const key = `${position.turn}:${position.step}`; + const used = counts.get(key) || 0; + // Budget exhausted: let the reply land normally rather than failing the turn. + if (used >= effectiveConfig().maxPerStep) { + yield* source; + return; + } + + let text = ''; + let framed = false; + let finished = false; + const failureFinish = () => ({ + type: 'finish', + reason: { kind: 'error', failure: { message: FAILURE_MESSAGE, code: FAILURE_CODE } }, + }); + for await (const chunk of source) { + if (!framed) { + const added = textOf(chunk); + if (added !== '') { + text += added; + framed = hasEpseFrame(text); + } + } + // Replace only a plain successful finish. `tool-calls` means the model DID + // issue a native call, which is the correct format and never this guard's + // business; an already-failed, aborted, or truncated attempt keeps its own + // outcome and its own recovery owner. + if (framed && chunk.type === 'finish' && chunk.reason?.kind === 'stop') { + counts.set(key, used + 1); + yield failureFinish(); + return; + } + if (chunk.type === 'finish') finished = true; + yield chunk; + } + // A stream that ended without any terminal finish would be assembled as a + // successful `stop`, landing the malformed reply. Close it as this guard's + // failure instead. + if (framed && !finished) { + counts.set(key, used + 1); + yield failureFinish(); + } + } + + ctx.on('llm/stream', (options, next) => guardStream(options, next)); + + /** + * Claim the guard's own failure code and retry the step from unchanged history. + * Any other failure is delegated downstream verbatim. + */ + ctx.on( + 'agent/request-error', + async (payload, next) => { + const { agent, turn, step, provider, failure } = payload; + if (!failure || failure.code !== FAILURE_CODE) return next(); + if (!agent || !agentInScope(agent)) return next(); + const session = agent.session; + if (!session) return next(); + + // Durably record the forced retry so the conversation UI resets this + // step's assistant node (dropping the malformed reply from the + // transcript) and renders the regenerated reply in its place. + try { + const sessionEvents = + typeof session.snapshotEvents === 'function' ? session.snapshotEvents() : session.events; + if (!Array.isArray(sessionEvents) || typeof sessionEvents.findLast !== 'function') { + ctx.logger?.warn?.('kevin-dsh-enhance-guard: session events unavailable; skipping durable retry record'); + return { kind: 'retry' }; + } + const prior = sessionEvents.findLast( + (event) => + event.type === 'llm/retry' && + event.data.turn === turn && + event.data.step === step && + event.data.provider === provider && + event.data.policyKey === POLICY_KEY, + ); + const retry = (prior?.data.retry ?? 0) + 1; + const retryId = prior?.data.retryId ?? mintRetryId(); + session.append('llm/retry', { + retryId, + turn, + step, + provider, + mode: 'normal', + policyKey: POLICY_KEY, + retry, + maxRetries: Math.max(effectiveConfig().maxPerStep, retry), + delayMs: 0, + failure, + }); + session.append('llm/retry-started', { retryId, turn, step, retry }); + } catch (error) { + // The durable notice is presentation, not correctness: regenerate anyway. + ctx.logger?.warn?.('kevin-dsh-enhance-guard: could not record the forced retry: %o', error); + } + + return { kind: 'retry' }; + }, + { prepend: true }, + ); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..14060b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,218 @@ +{ + "name": "@kevin/dsh-enhance", + "version": "0.5.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kevin/dsh-enhance", + "version": "0.5.0", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.1-rc.2", + "@deepseek-ai/dsh-llm": "^0.1.1-rc.2", + "@deepseek-ai/dsh-session": "^0.1.1-rc.2", + "@deepseek-ai/dsh-settings": "^0.1.1-rc.2", + "@deepseek-ai/schemastery": "^3.18.1" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.2.tgz", + "integrity": "sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.3", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.7", + "@deepseek-ai/cordis-plugin-loader": "^1.0.3" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.3.tgz", + "integrity": "sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@deepseek-ai/dsh-agent": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.1-rc.2.tgz", + "integrity": "sha512-cC7lnJe7JgPFcreNXxcxLMxQd78LnpVO9ZXROjZsGRQN1zGH6i/DduI892F1am85IfzzO+XTxMwwUHmfwamb0g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/dsh-llm": "^0.1.1-rc.2", + "@deepseek-ai/dsh-scope": "^0.1.1-rc.2", + "@deepseek-ai/dsh-session": "^0.1.1-rc.2", + "@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2", + "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.1-rc.2.tgz", + "integrity": "sha512-rCYAt8QsawP1yfDCU7XxNwYT/XWvyFsxYrkwhLLkdfW83QVD0CQHizSkTQE7RFX74nKUD1z3sTLfnLr7xneArw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.1-rc.2", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.1-rc.2.tgz", + "integrity": "sha512-8vXsAXoUdzKAgvd/E9DgyT6HKmR6ZM4rtJ3fs/XoJ6n2kBk4tWil8Lv+jxCMWJeMOnTJF55gu2+NWQ3ECFPtJw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.1-rc.2.tgz", + "integrity": "sha512-l+1Om/EDFyMjhgSuEx2WDLLA2fia/+ga9mBTCoT/MMslsnWaK5G0/lWwbwlTBSaJ6OfmYc3DuBgox8DbgIGHRQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.1-rc.2.tgz", + "integrity": "sha512-ASJfjIdZbIXvLwi3rGo+eZb/GxMVV/WO5/XVD3B96mT8EIzrlw3+nMR6/CvmJVzcycKQ2XN0wj7jD6TasPRySA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.1-rc.2", + "@deepseek-ai/dsh-brand": "^0.1.1-rc.2", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-scope": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.1-rc.2.tgz", + "integrity": "sha512-Xy3ejL6dwVSluZL7XOWy76ya4pCw1uHwxodDK4O9XiQUiUV4FBXnt0aNJUtMeAFN0c1YujxxCmRniMvuuNn1Nw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-session": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.1-rc.2.tgz", + "integrity": "sha512-4/cv6X9HPhm47eyRhCu/WZwzrtJKegk5J+0xaxcZ9i8S0smdxP57tqy8a0jkSshLQn7BzMFxneQrlYExrLrDhQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.1-rc.2", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/dsh-llm": "^0.1.1-rc.2", + "@deepseek-ai/dsh-scope": "^0.1.1-rc.2", + "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-settings": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.1.1-rc.2.tgz", + "integrity": "sha512-iGdKEt91Im3gE7xA9CzRfTJsPcFcxDeDOCLhAjzbpjEv7TAjx/EoYP7lPtiy+QmOjKSKy206SEtAKol9PXWbOw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.1-rc.2", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/schemastery": "^3.18.1" + } + }, + "node_modules/@deepseek-ai/dsh-system-prompt": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.1-rc.2.tgz", + "integrity": "sha512-on4hjAlYI5uX9q7Sf95YkMMBVe6heywtA/H50ksrIMUub8U2B98hO9iQpHhjwIO1F1vu+5pLcPvRr6yUGGmtXQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2", + "@deepseek-ai/dsh-llm": "^0.1.1-rc.2", + "@deepseek-ai/dsh-scope": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.1-rc.2.tgz", + "integrity": "sha512-RrouVgU3G5gXr9zHhpThkMG6YKdcRJzXXdPm1dq3ioBxbvxlfMSfNY4tN8lWMJxLyGtvWkPra0HQX+YWxvdOOA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-typert-protocol": { + "version": "0.1.1-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.1-rc.2.tgz", + "integrity": "sha512-lxBssDc5Pz1qBE5kuIyaArA7AvIPq9rpaVclylodiSzVJe95e2xruBg73tflyjtd8y00toet+DLgQ6tSSsq6Kw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.1-rc.2" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.2.tgz", + "integrity": "sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.3", + "@standard-schema/spec": "^1.1.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c856eca --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"description":"DeepSeek Harness plugin: detect EPSE tool-call frames the model emitted as text, fail that request attempt before it lands, and regenerate in place with no duplicate prompt. Ships a user-editable settings card on the plugin configuration page.","dsh":{"bundle":{"patch":"./cordis.patch.yml"},"client":{"inject":["@deepseek-ai/dsh-client-runtime","@deepseek-ai/dsh-client-ui-settings","@deepseek-ai/dsh-client-locale"],"platform":"web"}},"exports":{".":"./index.js","./client":"./client.js","./cordis.patch.yml":"./cordis.patch.yml","./package.json":"./package.json"},"files":["index.js","client.js","cordis.patch.yml","README.md","README.zh.md","LICENSE"],"keywords":["deepseek","harness","dsh","plugin","epse","tool-call","regeneration","kevin"],"license":"MIT","main":"index.js","name":"@kevin/dsh-enhance","peerDependencies":{"@deepseek-ai/cordis":"^4.0.1","@deepseek-ai/dsh-agent":"^0.1.1-rc.2","@deepseek-ai/dsh-llm":"^0.1.1-rc.2","@deepseek-ai/dsh-session":"^0.1.1-rc.2","@deepseek-ai/dsh-settings":"^0.1.1-rc.2","@deepseek-ai/schemastery":"^3.18.1"},"publishConfig":{"access":"public"},"scripts":{"test":"node test.mjs"},"type":"module","version":"0.5.0"} diff --git a/test.mjs b/test.mjs new file mode 100644 index 0000000..71d0b27 --- /dev/null +++ b/test.mjs @@ -0,0 +1,353 @@ +/** + * Standalone behaviour check for the EPSE regeneration guard. + * + * Runs the plugin's two listeners against a REAL `Session` (so surface and + * invariant rules apply verbatim) and a minimal fake Cordis context. Verifies: + * 1. a framed reply is converted into a request failure, so the loop never + * appends an `assistant/message` for it; + * 2. the guard claims that failure and returns `{ kind: 'retry' }`, recording + * the `llm/retry` / `llm/retry-started` pair the UI resets the step on; + * 3. a clean reply passes through untouched; + * 4. the per-step budget stops a second forced failure; + * 5. requests that are NOT ordinary in-loop conversation calls pass through. + * + * Deliberately does NOT import `markAgentLoopRequest`: that marker is a WeakSet + * private to one dsh-llm module instance, and a path-installed plugin resolves + * its own copy of dsh-llm. Marking a request here would only prove the test can + * talk to itself — the exact blind spot that let a guard consulting + * `isAgentLoopRequest` ship while never firing in production. The requests below + * are shaped the way dsh-agent-loop shapes them (deep-frozen, messages are the + * session's derived history) and carry no marker at all. + */ + +import assert from 'node:assert/strict'; +import { Session, SessionId } from '@deepseek-ai/dsh-session'; +import { createUserMessage } from '@deepseek-ai/dsh-llm'; +import { apply } from './index.js'; + +/** Collect the plugin's listeners without a real Cordis fiber. */ +function fakeContext(session) { + const listeners = { 'llm/stream': [], 'agent/request-error': [] }; + const ctx = { + sessions: { get: (id) => (id === session.id ? session : undefined) }, + logger: { warn: () => {} }, + on: (event, listener) => { + listeners[event].push(listener); + return () => {}; + }, + // No settings service is mounted here, so Cordis-style `inject` must accept + // the registration and never run its callback — exactly the production path + // when `dsh-settings` is absent, which keeps installSettingsSection inert. + inject: () => () => {}, + }; + return { ctx, listeners }; +} + +/** Open a turn and step so the guard can locate its retry position. */ +function openTurnAndStep(session, turn, step) { + session.append('turn/start', { turn }); + session.append('step/start', { turn, step }); + session.append( + 'user/message', + createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), + { surfaceOp: 'append' }, + ); + session.append('request/header', { + header: { config: { provider: 'kevin', model: 'test-model' } }, + reason: 'initial', + }); +} + +/** + * One request shaped exactly as dsh-agent-loop shapes it: deep-frozen, naming + * its session, and carrying that session's derived history verbatim. No + * process-local marker — the guard must recognize it from these facts alone. + */ +function request(session, overrides = {}) { + return Object.freeze({ + provider: 'kevin', + model: 'test-model', + messages: session.deriveMessages(), + sessionId: session.id, + ...overrides, + }); +} + +async function drain(iterable) { + const out = []; + for await (const chunk of iterable) out.push(chunk); + return out; +} + +const framedChunks = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'sure, calling <|EPSE' }, + { type: 'text-delta', index: 0, text: 'tool|> ... ' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'sure, calling <|EPSEtool|> ... ' } }, + { type: 'usage', usage: { inputTokens: 10, outputTokens: 20 } }, + { type: 'finish', reason: { kind: 'stop' } }, +]; + +const cleanChunks = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'an ordinary answer' }, + { type: 'block-end', index: 0, block: { type: 'text', text: 'an ordinary answer' } }, + { type: 'finish', reason: { kind: 'stop' } }, +]; + +async function* stream(chunks) { + for (const chunk of chunks) yield chunk; +} + +// ---------------------------------------------------------------- framed reply +{ + const session = Session.create(SessionId('sess-framed')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, {}); + openTurnAndStep(session, 1, 1); + + const guardStream = listeners['llm/stream'][0]; + const chunks = await drain(guardStream(request(session), () => stream(framedChunks))); + const finish = chunks.at(-1); + + assert.equal(finish.type, 'finish', 'stream must still end with a finish chunk'); + assert.equal(finish.reason.kind, 'error', 'a framed reply must end as a request failure'); + assert.equal(finish.reason.failure.code, 'EPSE_TOOL_CALL_FRAME'); + assert.ok( + chunks.some((chunk) => chunk.type === 'text-delta'), + 'earlier chunks pass through so streaming stays live', + ); + assert.ok( + !chunks.some((chunk) => chunk.type === 'finish' && chunk.reason.kind === 'stop'), + 'the successful finish must not reach the assembler', + ); + + const recover = listeners['agent/request-error'][0]; + const decision = await recover( + { + agent: { session, options: { provider: 'kevin', model: 'test-model' } }, + turn: 1, + step: 1, + provider: 'kevin', + failure: finish.reason.failure, + retryPolicy: undefined, + signal: new AbortController().signal, + }, + () => Promise.resolve(undefined), + ); + + assert.deepEqual(decision, { kind: 'retry' }, 'the guard must claim its own failure and retry'); + + const retry = session.events.findLast((event) => event.type === 'llm/retry'); + const started = session.events.findLast((event) => event.type === 'llm/retry-started'); + assert.ok(retry, 'a durable llm/retry record must exist'); + assert.equal(retry.data.turn, 1); + assert.equal(retry.data.step, 1); + assert.equal(retry.data.retry, 1); + assert.equal(retry.data.provider, 'kevin'); + assert.equal(retry.data.delayMs, 0); + assert.ok(started, 'a durable llm/retry-started record must exist'); + assert.equal(started.data.retryId, retry.data.retryId); + assert.equal(started.data.retry, 1); + + assert.ok( + !session.events.some((event) => event.type === 'assistant/message'), + 'the malformed reply never becomes a surface message', + ); + assert.equal(session.deriveMessages().length, 1, 'model history keeps only the user request'); + + console.log('ok an UNMARKED loop-shaped request is guarded: claimed failure + durable retry'); +} + +// ----------------------------------------------------------------- clean reply +{ + const session = Session.create(SessionId('sess-clean')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, {}); + openTurnAndStep(session, 1, 1); + + const guardStream = listeners['llm/stream'][0]; + const chunks = await drain(guardStream(request(session), () => stream(cleanChunks))); + assert.deepEqual(chunks, cleanChunks, 'a clean reply must pass through byte-for-byte'); + + const recover = listeners['agent/request-error'][0]; + let delegated = false; + const decision = await recover( + { + agent: { session, options: {} }, + turn: 1, + step: 1, + provider: 'kevin', + failure: { message: 'rate limited', code: 'RATE_LIMIT' }, + retryPolicy: undefined, + signal: new AbortController().signal, + }, + () => { + delegated = true; + return Promise.resolve(undefined); + }, + ); + assert.ok(delegated, 'a foreign failure must be delegated downstream'); + assert.equal(decision, undefined); + + console.log('ok clean replies and foreign failures are untouched'); +} + +// ----------------------------------------------- explicit budget ceiling of 1 +{ + const session = Session.create(SessionId('sess-budget')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, { maxRegenerationsPerTurn: 1 }); + openTurnAndStep(session, 1, 1); + + const guardStream = listeners['llm/stream'][0]; + const first = await drain(guardStream(request(session), () => stream(framedChunks))); + assert.equal(first.at(-1).reason.kind, 'error', 'first framed attempt is failed'); + + const second = await drain(guardStream(request(session), () => stream(framedChunks))); + assert.equal( + second.at(-1).reason.kind, + 'stop', + 'budget exhausted: the reply lands instead of failing the turn forever', + ); + + console.log('ok an explicit budget of 1 forces exactly one regeneration'); +} + +// ------------------------------------------------------- default budget of two +{ + const session = Session.create(SessionId('sess-default-budget')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, {}); // no maxRegenerationsPerTurn: the default applies + openTurnAndStep(session, 1, 1); + + const guardStream = listeners['llm/stream'][0]; + const outcomes = []; + for (let attempt = 0; attempt < 3; attempt++) { + const chunks = await drain(guardStream(request(session), () => stream(framedChunks))); + outcomes.push(chunks.at(-1).reason.kind); + } + assert.deepEqual( + outcomes, + ['error', 'error', 'stop'], + 'the default budget forces two regenerations, then lets the third reply land', + ); + + // Each forced failure is claimed and recorded, numbering 1 then 2 on one chain. + const recover = listeners['agent/request-error'][0]; + for (const expected of [1, 2]) { + const decision = await recover( + { + agent: { session, options: { provider: 'kevin', model: 'test-model' } }, + turn: 1, + step: 1, + provider: 'kevin', + failure: { message: 'framed', code: 'EPSE_TOOL_CALL_FRAME' }, + retryPolicy: undefined, + signal: new AbortController().signal, + }, + () => Promise.resolve(undefined), + ); + assert.deepEqual(decision, { kind: 'retry' }); + const retry = session.events.findLast((event) => event.type === 'llm/retry'); + assert.equal(retry.data.retry, expected, 'retry numbering must increment on one chain'); + assert.ok(retry.data.maxRetries >= expected, 'maxRetries must cover the attempt number'); + } + const chain = new Set( + session.events.filter((e) => e.type === 'llm/retry').map((e) => e.data.retryId), + ); + assert.equal(chain.size, 1, 'both attempts share one retryId chain'); + + console.log('ok the default budget is two regenerations on a single retry chain'); +} + +// --------------------------------------------------- out-of-scope route bypass +{ + const session = Session.create(SessionId('sess-scope')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, { targetProviders: ['some-other-provider'] }); + openTurnAndStep(session, 1, 1); + + const guardStream = listeners['llm/stream'][0]; + const chunks = await drain(guardStream(request(session), () => stream(framedChunks))); + assert.equal(chunks.at(-1).reason.kind, 'stop', 'an out-of-scope route is not guarded'); + + console.log('ok targetProviders narrows the guard to declared routes'); +} + +// ------------------------------------------ requests that are not loop calls +{ + const session = Session.create(SessionId('sess-not-loop')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, {}); + openTurnAndStep(session, 1, 1); + const guardStream = listeners['llm/stream'][0]; + + const passesThrough = async (label, options) => { + const chunks = await drain(guardStream(options, () => stream(framedChunks))); + assert.equal(chunks.at(-1).reason.kind, 'stop', label); + }; + + // An auxiliary call: compaction and session titles own their own recovery. + await passesThrough( + 'a request with a purpose is not guarded', + request(session, { purpose: 'compaction' }), + ); + // A hand-built one-shot: not frozen, so not a loop-assembled request. + await passesThrough('an unfrozen request is not guarded', { + provider: 'kevin', + model: 'test-model', + messages: session.deriveMessages(), + sessionId: session.id, + }); + // A hand-built message list: same shape, but not this session's history. + await passesThrough( + 'a request whose messages are not the derived history is not guarded', + request(session, { + messages: [ + createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), + ], + }), + ); + // No session named at all: nothing to locate a retry position in. + await passesThrough('a sessionless request is not guarded', { + ...request(session), + sessionId: undefined, + }); + + // The comparison is element-wise, so a fresh array over the same shared + // frozen messages remains the derived history and stays guarded. + const copied = await drain( + guardStream(request(session, { messages: [...session.deriveMessages()] }), () => + stream(framedChunks), + ), + ); + assert.equal( + copied.at(-1).reason.failure?.code, + 'EPSE_TOOL_CALL_FRAME', + 'a re-wrapped derived history is still the derived history', + ); + + console.log('ok auxiliary, unfrozen, hand-built and sessionless requests pass through'); +} + +// -------------------------------------- no open step means no retry position +{ + const session = Session.create(SessionId('sess-no-step')); + const { ctx, listeners } = fakeContext(session); + apply(ctx, {}); + openTurnAndStep(session, 1, 1); + session.append('step/end', { turn: 1, step: 1 }); + + const guardStream = listeners['llm/stream'][0]; + const chunks = await drain(guardStream(request(session), () => stream(framedChunks))); + assert.equal( + chunks.at(-1).reason.kind, + 'stop', + 'outside an open step there is no position to regenerate at', + ); + + console.log('ok a request outside an open step passes through'); +} + +console.log('\nall checks passed');