first commit

This commit is contained in:
张可成
2026-09-05 00:27:41 +08:00
commit a9175e42de
9 changed files with 1406 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
coverage/
*.log
.DS_Store
.idea/
.vscode/
*.tsbuildinfo
+22
View File
@@ -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.
+109
View File
@@ -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 ./<path-to>/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` / `<EPSE`,后跟任意本地名、甚至本地名为空;
- **闭合标签**:前缀 `</|EPSE` / `</|epse` / `</EPSE`,后跟任意本地名、甚至本地名为空。
仅判定模型生成的**文本 chunk**(`text-delta` 与 text 型 `block-end`);不处理写入文件、工具结果、工具参数、reasoning、日志事件。
## 适用范围与边界
只有普通的循环内会话请求会被守护,以下一律原样放行:
- `purpose` 非空的辅助调用(compaction、会话标题);
- 手搭的一次性调用 —— 判定见下文《如何识别循环请求》;
- 找不到打开中 step 的请求(没有可重试的位置);
- `targetProviders` 未覆盖的路由;
- `reason.kind` 不是 `stop` 的 finish —— `tool-calls` 说明模型**确实**发起了原生调用(格式正确,不是本插件的事),而已失败、已中止、被 max-tokens 截断的尝试各自保留自己的结局与恢复归属。
**防死循环**:按 `(session, turn, step)` 计数,每步最多强制失败 `maxRegenerationsPerTurn` 次(默认 2)。预算耗尽后坏回复正常落地,而不是让该轮永远失败。
## Token 与缓存影响
- 坏回复的输出 token 已经产生并计费,无法追回;但它**不进入**输入历史,所以重生成的请求与产生它的那次大小相同。
- 派生历史未发生任何表层替换,因此 KV cache 前缀完全可复用 —— 这优于旧方案(一次表面替换会使从首个被遮蔽消息起的复用失效)。
## 测试
```sh
node test.mjs
```
用真实 `Session`(表层与不变量规则原样生效)加最小假 Cordis context 驱动两个 listener,覆盖:坏回复转为被认领的请求失败并写下持久重试记录、干净回复与外部失败码原样放行、每步预算封顶、`targetProviders` 收窄范围、以及辅助调用 / 未冻结 / 手搭消息列表 / 无 session / 无打开 step 的请求原样放行。
## 已知限制
- 默认全局生效:`targetProviders` 留空意味着它作用于进程内所有 agent,包括运行 harness 自身的会话。
- 检测基于前缀:任何文本只要(归一化后)同时含 EPSE 开/闭合标签,就会被当作坏的工具调用框架,即使只是提到这些标签的普通叙述。但需要同时命中开、闭标签,已尽量避免误伤。
- 判定在流式过程中进行,早先的 chunk 已经推给了前端;用户可能短暂看到坏回复的前缀,随后被 retry 重置。
- 若同时挂载了 `@deepseek-ai/dsh-llm-retry` 且某路由配了 `always` mode,该插件可能先认领这次失败并按自己的策略重试。结果依然是原地重生成、无重复指令,只是退避与计数归它所有。
## License
MIT
+320
View File
@@ -0,0 +1,320 @@
/**
* Browser half of the EPSE regeneration guard — a settings card on the
* 插件配置 page. Plain-JS bundle (no build step): it registers through the
* client module loader and draws its own card, mirroring the stock Terminal /
* Agent loop / Web search cards so the layout and controls look identical.
*
* Registered into `settings.plugin.item` keyed by the guard's settings
* namespace; the configurable-plugins tab only dispatches a card when the Host
* also serves that namespace, which the settings registration in index.js
* provides (via the settings service's `installSection`, when mounted). The
* two inputs write straight to the durable settings document.
*/
window.__ModuleLoader__.load({
id: "@kevin/dsh-enhance",
factory: (require) => {
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 };
},
});
+18
View File
@@ -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'
+356
View File
@@ -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 <epse; Closing: </|epse or </epse.
const hasEpseFrame = (text) => {
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 },
);
}
+218
View File
@@ -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
}
}
}
+1
View File
@@ -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"}
+353
View File
@@ -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|> ... </|EPSE|>' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'sure, calling <|EPSEtool|> ... </|EPSE|>' } },
{ 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');