first commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
Generated
+218
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user