dsh-tool-content-guard 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +167 -2
- package/cordis.patch.yml +20 -0
- package/icon.svg +4 -0
- package/index.js +188 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
- package/test/content-guard.test.mjs +153 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# 变更记录(Changelog)
|
|
2
|
+
|
|
3
|
+
本文件记录 `dsh-tool-content-guard` 的对外变更。版本号语义:修 bug → patch,
|
|
4
|
+
行为/配置新增 → minor,不兼容的挂载点或默认行为变更 → major。
|
|
5
|
+
|
|
6
|
+
## [1.0.0] — 2026-10-13
|
|
7
|
+
|
|
8
|
+
首个发布版。
|
|
9
|
+
|
|
10
|
+
- **兜底归一化**:工具 content 块里 `text` 不是字符串时,在结果落进会话日志**之前**强制归一化——
|
|
11
|
+
数组逐元素取文本、`\n` 拼接(嵌套数组 `flat(Infinity)`;`null`/`undefined` → 空行;标量 → `String`;
|
|
12
|
+
对象 → JSON);数字/布尔/对象/`null` 同样变成可读字符串。
|
|
13
|
+
- **挂载点**:官方扩展点 `tools/post-execute`(`prepend`,最外层:先 `await next()` 让其它策略跑完,
|
|
14
|
+
再归一化**最终** content);PTC 子调用的 `tools/ptc-dispatch-log` 瀑布同样处理。
|
|
15
|
+
- **三种模式**:`coerce`(默认:修好 + `logger.warn` 点名工具/callId/形状/片段,不打断调用)、
|
|
16
|
+
`block`(不修,直接把这次调用判失败并把诊断回给模型,插件开发者自查用)、`off`(不挂监听);
|
|
17
|
+
`warnChars` 控制告警里回显的字符数(默认 160,0=不回显)。
|
|
18
|
+
- **无副作用**:合法 content 原样返回**同一个 decision 对象**(不拷贝、不告警);只重建被改到的块
|
|
19
|
+
(`{ ...block, text }`),未改的块保持同一引用,深冻结安全。
|
|
20
|
+
- **自检**:35 项纯 Node 单测(`npm test`,末行 `CONTENT GUARD: OK`)。
|
|
21
|
+
|
|
22
|
+
**背景(为什么要它)**:2026-10-13 `patent_invalidation_verify` 的 `render()` 把 `text` 写成了
|
|
23
|
+
30 行数组,请求体里出现 `{"type":"text","text":[…]}`,DeepSeek Messages 网关整包 422
|
|
24
|
+
(`messages[N].content: invalid type: sequence, expected a string`),而这条结果已原样落进会话日志——
|
|
25
|
+
该会话之后**每一次**请求都被拒、重试无效,只能对会话日志做单帧修复。插件侧已改成 `].join('\n')`,
|
|
26
|
+
本插件是防复发。
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZHC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,168 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-tool-content-guard — 工具 content 兜底守卫
|
|
2
|
+
|
|
3
|
+
host 侧的保险:**工具 content 块里的 `text` 必须是字符串**。插件(或任何工具作者)把
|
|
4
|
+
`text` 写成行数组时,这条守卫在结果落进会话日志**之前**把它强制拼成字符串,并点名是哪个
|
|
5
|
+
工具干的。
|
|
6
|
+
|
|
7
|
+
## 它防的是什么
|
|
8
|
+
|
|
9
|
+
DSH 对工具 `output.render()` 返回的 content 只做**无损 JSON 快照**,不会替插件拼接:
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
render: () => [{ type: 'text', text: [ '第 1 行', '第 2 行' ] }] // ✗
|
|
13
|
+
render: () => [{ type: 'text', text: [ '第 1 行', '第 2 行' ].join('\n') }] // ✓
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
前者会让请求体里出现 `{"type":"text","text":[…]}`,DeepSeek Messages 网关整包 422:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
Failed to deserialize the JSON body into the target type:
|
|
20
|
+
messages[887].content: invalid type: sequence, expected a string
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
而且这条工具结果会**原样落进会话日志**——只要它在历史里,该会话之后**每一次**请求都被拒,
|
|
24
|
+
重试无效(2026-10-13 真实事故:`patent_invalidation_verify` 的第一次成功返回把
|
|
25
|
+
「制作照明装置无效口审PPT」那个会话打毒;插件侧已改成 `].join('\n')`,这条守卫是防复发)。
|
|
26
|
+
|
|
27
|
+
## 挂在哪儿
|
|
28
|
+
|
|
29
|
+
官方扩展点 `tools/post-execute`(`prepend`,最外层):先 `await next()` 让其它
|
|
30
|
+
post-execute 策略跑完,再归一化**最终** content;PTC 子调用的 `tools/ptc-dispatch-log`
|
|
31
|
+
瀑布同样处理(它也是持久记录)。
|
|
32
|
+
|
|
33
|
+
归一化规则:
|
|
34
|
+
|
|
35
|
+
| 进来的 `text` | 出去 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| 字符串 | 原样(且原样返回**同一个 decision 对象**,不做无谓拷贝) |
|
|
38
|
+
| 数组 | 逐元素取文本、`\n` 拼接;嵌套数组 `flat(Infinity)`;`null`/`undefined` → 空行;标量 → `String`;对象 → JSON |
|
|
39
|
+
| 数字 / 布尔 / 对象 / `null` | 变成可读字符串 |
|
|
40
|
+
| `content` 不是数组 / 没有非法块 | 原样透传 |
|
|
41
|
+
|
|
42
|
+
深冻结安全:只重建被改到的块(`{ ...block, text }`),未改的块保持同一引用,不改写原数组。
|
|
43
|
+
|
|
44
|
+
## 配置
|
|
45
|
+
|
|
46
|
+
`mode`:
|
|
47
|
+
|
|
48
|
+
| 值 | 行为 |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `coerce`(默认) | 修好 + `logger.warn` 点名工具/callId/形状/片段,**不打断**这次调用 |
|
|
51
|
+
| `block` | 不修,直接把这次工具调用判失败(`isError`),把诊断文本回给模型——插件开发者自查用 |
|
|
52
|
+
| `off` | 完全不挂监听 |
|
|
53
|
+
|
|
54
|
+
`warnChars`:告警里回显被修文本的字符数(默认 160,0=不回显)。
|
|
55
|
+
|
|
56
|
+
## 安装
|
|
57
|
+
|
|
58
|
+
**方式一:npm(推荐,不需要本机安装 git)**
|
|
59
|
+
|
|
60
|
+
DSH 桌面端 → 侧栏「插件」→「添加插件」,输入:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
dsh-tool-content-guard
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**方式二:从 Gitee 仓库安装**(需要本机已安装 git;地址**注意结尾的 `.git`**)
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
https://gitee.com/hc905/dsh-tool-content-guard.git
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
> 结尾不带 `.git` 的裸地址(`https://gitee.com/hc905/dsh-tool-content-guard`)会被 pnpm
|
|
73
|
+
> 当成 tarball 去解包,报 `ERR_PNPM_TARBALL_EXTRACT`;写 `git+https://…git` 也等价。
|
|
74
|
+
> 实测(pnpm 11.7.0):`.git` 结尾 → 装成 `dsh-tool-content-guard 1.0.0` ✓。
|
|
75
|
+
|
|
76
|
+
**方式三:本地路径**(开发用)
|
|
77
|
+
|
|
78
|
+
```jsonc
|
|
79
|
+
// ~/.dsh/profiles/desktop/package.json
|
|
80
|
+
"dependencies": { "dsh-tool-content-guard": "link:<插件目录的绝对路径>" },
|
|
81
|
+
"dsh": { "profile": { "bundles": [ /* … */ "dsh-tool-content-guard" ] } }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
无论哪种方式,都要在 profile 的 `cordis.patch.yml` 里留一行挂载项(默认行为已经够用,
|
|
85
|
+
只有要改 `mode` 才需要写 `config`):
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
# ~/.dsh/profiles/desktop/cordis.patch.yml(改 mode 就按 id 覆盖)
|
|
89
|
+
- id: tool-content-guard
|
|
90
|
+
disabled: false
|
|
91
|
+
# config: { mode: block } # coerce(默认)| block | off
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**必须重启 DSH**:loader 只在启动时读 profile 的 bundle 层,本次会话内不会热挂载
|
|
95
|
+
(市场的 activation 会写 `restart`)。
|
|
96
|
+
|
|
97
|
+
> 同一台机器不要同时装「npm 版」「git 版」「本地 link 版」——三者的挂载 id 都是
|
|
98
|
+
> `tool-content-guard`,会冲突。
|
|
99
|
+
|
|
100
|
+
## 卸载
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
# 在 profile 目录执行(npm / git / 本地 link 安装都用这个包名)
|
|
104
|
+
pnpm remove dsh-tool-content-guard
|
|
105
|
+
# 再把 profile 的 dsh.profile.bundles 里的 dsh-tool-content-guard 删掉,然后重启 DSH
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 已知边界
|
|
109
|
+
|
|
110
|
+
- 工具自己的 `finalizeContent` 在 post-execute **之后**执行,它引入的非法 `text` 拦不到
|
|
111
|
+
(现网没有工具用它;`tools/result` 是只读观察点)。
|
|
112
|
+
- 别的策略替换了 `value` 时,注册表会重新 `render()` 得到新 content,那条路径不经过
|
|
113
|
+
post-execute,也拦不到。
|
|
114
|
+
- **已经落进会话日志**的坏记录不会再次经过这里:那是日志修复的事
|
|
115
|
+
(`D:\DWork\dsfix\repair-session-text.mjs`)。
|
|
116
|
+
- 这是兜底,不是许可:插件 `render()` 仍然要写 `[ ... ].join('\n')`。
|
|
117
|
+
|
|
118
|
+
## 自检
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
# 纯 Node 单测:35 项,末行 CONTENT GUARD: OK(随包发布;任何 Node ≥18 都能跑)
|
|
122
|
+
node test/content-guard.test.mjs
|
|
123
|
+
npm test # 等价写法
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
本机另有一份**真数据交叉验证**(不随包发布):拿被污染会话里那条真实坏记录
|
|
127
|
+
(`seq=2856`,数组 30 行)喂给守卫,断言输出与「插件侧 join」逐字符一致(1551 字):
|
|
128
|
+
|
|
129
|
+
```powershell
|
|
130
|
+
node D:\DWork\dsfix\verify-guard-vs-log.mjs # 末行 GUARD vs LOG: OK
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## 活体验证记录(2026-10-13)
|
|
134
|
+
|
|
135
|
+
重启 DSH 后在本机做过一次真实链路核对(当时用的临时探针插件已删除,只留结论):
|
|
136
|
+
|
|
137
|
+
- 市场接口 `/dsh-market/installed` 对本插件读作 **`state: live` / `hot: true`**(bundle 层已挂上);
|
|
138
|
+
- 用**真实案件文件**跑 `patent_invalidation_verify`(正是当年把会话打毒的那次调用),
|
|
139
|
+
这条工具结果在会话日志里落成 `content[0].text` = **string,1551 字 / 30 行**,
|
|
140
|
+
与插件侧 `].join('\n')` 的结果一致;
|
|
141
|
+
- 同一份坏数据(日志备份 seq=2856 的数组 30 行)走守卫,输出同样是这 1551 字(见上面「自检」里的真数据交叉验证)。
|
|
142
|
+
|
|
143
|
+
## 发版(维护者)
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
# 1) 改 package.json 的 version(唯一版本源;修 bug → patch,行为/配置新增 → minor)
|
|
147
|
+
# 同步在 CHANGELOG.md 顶部加一条
|
|
148
|
+
npm test # 35 项,必须末行 CONTENT GUARD: OK
|
|
149
|
+
npm pack --dry-run # 看 tarball 内容,确认没多带东西
|
|
150
|
+
# 2) 提交并推 Gitee,打 tag
|
|
151
|
+
git add -A; git commit -m "release: v1.0.0"; git tag v1.0.0
|
|
152
|
+
git push origin main --tags
|
|
153
|
+
# 3) 发 npm(首发 2026-10-13 已完成;此后就是 npm publish)
|
|
154
|
+
npm publish
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
发布物白名单 = `package.json` 的 `files`:`cordis.patch.yml`、`index.js`、`locale`、
|
|
158
|
+
`test`、`icon.svg`、`CHANGELOG.md`,外加 npm 强制包含的 `README.md` / `LICENSE` /
|
|
159
|
+
`package.json`。`node_modules/`(本地 junction)与 `*.bak-*` 由 `.gitignore` 挡住,
|
|
160
|
+
既不进仓库也不进包。
|
|
161
|
+
|
|
162
|
+
> 本机是 `link:` 安装(指向上面的工作目录):**改了 `index.js` 必须重启 DSH 才生效**,
|
|
163
|
+
> loader 按模块 URL 缓存已加载的插件。
|
|
164
|
+
|
|
165
|
+
## 变更记录
|
|
166
|
+
|
|
167
|
+
见 [CHANGELOG.md](./CHANGELOG.md)。
|
|
2
168
|
|
|
3
|
-
This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# 工具 content 兜底守卫 — bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# One Host row: `tool-content-guard` mounts the `tools/post-execute` guard from
|
|
4
|
+
# `index.js`. `config` is validated against the plugin's `Config` schema at
|
|
5
|
+
# activation, and this profile layer survives plugin upgrades.
|
|
6
|
+
#
|
|
7
|
+
# 说明:这里**故意不带 `config:`**——loader 的「热挂载」只支持**纯 insert**,行里一写
|
|
8
|
+
# config/表达式就必须重启才生效(市场面板的 activation 会写 `restart`)。插件的
|
|
9
|
+
# `Config` 自带默认值(mode: coerce、warnChars: 160),所以空 config 的行为就是默认行为。
|
|
10
|
+
#
|
|
11
|
+
# 要改 mode/warnChars,在 profile 的 `cordis.patch.yml` 里按 id 覆盖(patch 层在 bundle
|
|
12
|
+
# 层之后应用),例如:
|
|
13
|
+
#
|
|
14
|
+
# - id: tool-content-guard
|
|
15
|
+
# config:
|
|
16
|
+
# mode: block # coerce(默认)| block(开发自查:直接判失败)| off
|
|
17
|
+
# warnChars: 160
|
|
18
|
+
- insert:
|
|
19
|
+
- id: tool-content-guard
|
|
20
|
+
name: 'dsh-tool-content-guard'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#2563EB" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<path d="M12 3l7 3v5.5c0 4.2-2.9 7.9-7 9.5-4.1-1.6-7-5.3-7-9.5V6z" />
|
|
3
|
+
<path d="M9.6 9.6H8.3v4.8h1.3M14.4 9.6h1.3v4.8h-1.3M9.6 12h4.8" />
|
|
4
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-tool-content-guard — host 侧兜底:工具 content 的 `text` 必须是字符串。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么要有它
|
|
5
|
+
*
|
|
6
|
+
* DSH 对工具 `output.render()` 返回的 content 只做**无损 JSON 快照**,不会替插件拼接。
|
|
7
|
+
* 插件一旦把 `text` 写成行数组:
|
|
8
|
+
*
|
|
9
|
+
* ```js
|
|
10
|
+
* { type: 'text', text: ['第 1 行', '第 2 行'] } // ✗
|
|
11
|
+
* { type: 'text', text: ['第 1 行', '第 2 行'].join('\n') } // ✓
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* 请求体里就会出现 `{"type":"text","text":[…]}`,DeepSeek Messages 网关整包 422:
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* Failed to deserialize the JSON body into the target type:
|
|
18
|
+
* messages[887].content: invalid type: sequence, expected a string
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* 更糟的是这条工具结果会**原样落进会话日志**——只要它在历史里,该会话之后**每一次**请求
|
|
22
|
+
* 都被拒,重试也没有用(2026-10-13 的真实事故:`patent_invalidation_verify` 的第一次成功
|
|
23
|
+
* 返回把「制作照明装置无效口审PPT」那个会话打毒)。
|
|
24
|
+
*
|
|
25
|
+
* ## 它做什么
|
|
26
|
+
*
|
|
27
|
+
* 挂在官方扩展点 `tools/post-execute`(`prepend`,最外层):先让其它 post-execute 策略
|
|
28
|
+
* 跑完(`await next()`),再对**最终** content 做一次归一化;PTC 子调用的
|
|
29
|
+
* `tools/ptc-dispatch-log` 瀑布同样处理(它也是持久记录):
|
|
30
|
+
*
|
|
31
|
+
* - `text` 是数组 → 逐元素取文本、用 `\n` 拼成字符串(与插件自己该写的 `join('\n')` 等价,
|
|
32
|
+
* 嵌套数组按 `flat(Infinity)` 展开;`null`/`undefined` → 空行;标量 → `String`;对象 → JSON);
|
|
33
|
+
* - `text` 是数字/布尔/对象/`null` → 变成可读字符串;
|
|
34
|
+
* - `text` 已经是字符串 → **原样返回同一个 decision**(不做无谓的拷贝);
|
|
35
|
+
* - 只重建被改到的块(content 与块在管线里是深冻结的,就地改会抛)。
|
|
36
|
+
*
|
|
37
|
+
* `mode`:
|
|
38
|
+
*
|
|
39
|
+
* | 值 | 行为 |
|
|
40
|
+
* |---|---|
|
|
41
|
+
* | `coerce`(默认) | 修好 + `logger.warn` 点名工具/callId/形状/片段,**不打断**这次调用 |
|
|
42
|
+
* | `block` | 不修,直接把这次工具调用判失败(`isError`),把诊断文本回给模型——插件开发者自查用 |
|
|
43
|
+
* | `off` | 完全不挂监听 |
|
|
44
|
+
*
|
|
45
|
+
* ## 已知边界
|
|
46
|
+
*
|
|
47
|
+
* - 工具自己的 `finalizeContent` 在 post-execute **之后**执行,它引入的非法 `text` 这里拦不到
|
|
48
|
+
* (现网没有工具用它;`tools/result` 是只读观察点,改不了内容)。
|
|
49
|
+
* - 别的策略替换了 `value` 时,注册表会重新 `render()` 得到新 content,那条路径不再经过
|
|
50
|
+
* post-execute,也拦不到。
|
|
51
|
+
* - **已经落进会话日志**的坏记录不会再次经过这里:那是日志修复的事,
|
|
52
|
+
* 用 `D:\DWork\dsfix\repair-session-text.mjs` 修。
|
|
53
|
+
* - 这条守卫是兜底,不是许可:真正的修法仍然是插件 `render()` 里写 `[ ... ].join('\n')`。
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import z from '@deepseek-ai/schemastery'
|
|
57
|
+
|
|
58
|
+
/** Stable Loader identity. */
|
|
59
|
+
const name = 'tool-content-guard'
|
|
60
|
+
|
|
61
|
+
/** Services the plugin needs; without the tool registry it stays inactive. */
|
|
62
|
+
const inject = ['tools']
|
|
63
|
+
|
|
64
|
+
/** Validated configuration, overridable in the profile's cordis.patch.yml. */
|
|
65
|
+
const Config = z.object({
|
|
66
|
+
/** coerce=修好并告警(默认);block=判失败并把诊断回给模型;off=不挂监听。 */
|
|
67
|
+
mode: z.union(['coerce', 'block', 'off']).default('coerce'),
|
|
68
|
+
/** 告警里回显被修文本的字符数(0=不回显)。 */
|
|
69
|
+
warnChars: z.number().default(160),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/* ------------------------------------------------------------------ helpers */
|
|
73
|
+
|
|
74
|
+
/** 数组元素 → 一行文本:字符串原样;null/undefined 视作空行;标量 String;其余 JSON。 */
|
|
75
|
+
function elementText(value) {
|
|
76
|
+
if (typeof value === 'string') return value
|
|
77
|
+
if (value === null || value === undefined) return ''
|
|
78
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') return String(value)
|
|
79
|
+
try {
|
|
80
|
+
return JSON.stringify(value) ?? String(value)
|
|
81
|
+
} catch {
|
|
82
|
+
return String(value) // 循环引用等
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 任意 `text` 值 → 字符串。
|
|
88
|
+
* @param value - 插件 render 放进 text 字段的任意 JSON 值。
|
|
89
|
+
* @returns 可直接发给 provider 的字符串。
|
|
90
|
+
*/
|
|
91
|
+
function coerceText(value) {
|
|
92
|
+
if (typeof value === 'string') return value
|
|
93
|
+
if (Array.isArray(value)) return value.flat(Infinity).map(elementText).join('\n')
|
|
94
|
+
return elementText(value)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** 形状描述(告警用)。 */
|
|
98
|
+
function shapeOf(value) {
|
|
99
|
+
if (Array.isArray(value)) return `数组(${value.length} 行)`
|
|
100
|
+
if (value === null) return 'null'
|
|
101
|
+
return typeof value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 归一化一个 content 数组。
|
|
106
|
+
* @param content - 有序 content 块(可能是深冻结的)。
|
|
107
|
+
* @returns 无非法块时 `undefined`(调用方原样透传 decision);否则新的 content 与改动清单。
|
|
108
|
+
*/
|
|
109
|
+
function normalizeContent(content) {
|
|
110
|
+
if (!Array.isArray(content)) return undefined
|
|
111
|
+
const found = []
|
|
112
|
+
content.forEach((block, index) => {
|
|
113
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text !== 'string') {
|
|
114
|
+
found.push({ index, block })
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
if (found.length === 0) return undefined
|
|
118
|
+
const next = content.slice()
|
|
119
|
+
for (const { index, block } of found) next[index] = { ...block, text: coerceText(block.text) }
|
|
120
|
+
return { content: next, found }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* -------------------------------------------------------------------- apply */
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Install the guard.
|
|
127
|
+
* @param ctx - agent-scoped services (`tools`, `logger`).
|
|
128
|
+
* @param config - mode / warnChars.
|
|
129
|
+
*/
|
|
130
|
+
function apply(ctx, config = {}) {
|
|
131
|
+
const mode = config.mode ?? 'coerce'
|
|
132
|
+
if (mode === 'off') return
|
|
133
|
+
const warnChars = Number.isFinite(config.warnChars) ? Math.max(0, config.warnChars) : 160
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 归一化一段 content 并记账;没有非法块返回 `undefined`。
|
|
137
|
+
* @param where - 诊断里的位置描述(工具名 + callId)。
|
|
138
|
+
*/
|
|
139
|
+
const normalize = (content, where) => {
|
|
140
|
+
const normalized = normalizeContent(content)
|
|
141
|
+
if (normalized === undefined) return undefined
|
|
142
|
+
const shapes = normalized.found.map(({ index, block }) => `content[${index}].text=${shapeOf(block.text)}`).join('、')
|
|
143
|
+
const diagnosis =
|
|
144
|
+
`${where} 的 content 里有非字符串 text(${shapes}):DSH 只做无损 JSON 快照,原样发给 provider 会被拒` +
|
|
145
|
+
`(DeepSeek Messages: messages[N].content: invalid type: sequence, expected a string),并且会落进会话日志毒掉整个会话。` +
|
|
146
|
+
`请把 render() 里的 text 写成 [ ... ].join('\\n')。`
|
|
147
|
+
if (mode === 'block') return { block: diagnosis }
|
|
148
|
+
const preview =
|
|
149
|
+
warnChars === 0 ? '' : ` 片段:${normalized.content[normalized.found[0].index].text.slice(0, warnChars).replace(/\n/gu, ' ⏎ ')}`
|
|
150
|
+
ctx.logger?.warn?.(`tool-content-guard(coerce): 已强制字符串化 — ${diagnosis}${preview}`)
|
|
151
|
+
return { content: normalized.content }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ctx.on(
|
|
155
|
+
'tools/post-execute',
|
|
156
|
+
async (exec, result, next) => {
|
|
157
|
+
const decision = await next()
|
|
158
|
+
// 只处理「接受」且没被换 value 的路径;block/失败原样透传。
|
|
159
|
+
if (decision?.kind !== 'accept' || Object.hasOwn(decision, 'value')) return decision
|
|
160
|
+
const where = `工具 ${exec?.name ?? '(unknown tool)'}${exec?.callId === undefined ? '' : `(callId ${exec.callId})`}`
|
|
161
|
+
const verdict = normalize(decision.content ?? result?.content, where)
|
|
162
|
+
if (verdict === undefined) return decision
|
|
163
|
+
if (verdict.block !== undefined) {
|
|
164
|
+
ctx.logger?.warn?.(`tool-content-guard(block): ${verdict.block}`)
|
|
165
|
+
return { kind: 'block', feedback: [{ type: 'text', text: `Error: ${verdict.block}` }] }
|
|
166
|
+
}
|
|
167
|
+
return { ...decision, content: verdict.content }
|
|
168
|
+
},
|
|
169
|
+
{ prepend: true },
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
// PTC 子调用的 dispatch 日志走另一条瀑布,同样归一化(它也是持久记录)。
|
|
173
|
+
ctx.on(
|
|
174
|
+
'tools/ptc-dispatch-log',
|
|
175
|
+
async (dispatch, next) => {
|
|
176
|
+
const content = await next()
|
|
177
|
+
const verdict = normalize(content, `PTC 子调用 ${dispatch?.name ?? '(unknown tool)'}`)
|
|
178
|
+
return verdict?.content ?? content
|
|
179
|
+
},
|
|
180
|
+
{ prepend: true },
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { Config, apply, coerceText, inject, name, normalizeContent }
|
|
185
|
+
|
|
186
|
+
/* ------------------------------------------------------------- test surface */
|
|
187
|
+
|
|
188
|
+
export { elementText }
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Tool content guard",
|
|
4
|
+
"description": "Host-side safety net: a tool whose render() puts a line array into a content block's text gets it joined into a string (prevents the DeepSeek Messages 422 that poisons a session); a dev mode can fail the call instead."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tool-content-guard",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "DSH 插件:host 侧兜底——把工具 content 里非字符串的 `text` 强制字符串化(默认),或在开发模式下直接把这次调用判失败。挂在官方扩展点 tools/post-execute,先让其它策略跑完再归一化,避免 `{\"type\":\"text\",\"text\":[…]}` 这种非法块把 DeepSeek Messages 请求打成 422(invalid type: sequence, expected a string),也避免它落进会话日志、毒掉整个会话。",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"icon": "./icon.svg",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"dsh",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"dsh-plugin",
|
|
11
|
+
"cordis",
|
|
12
|
+
"tools",
|
|
13
|
+
"post-execute",
|
|
14
|
+
"content",
|
|
15
|
+
"text",
|
|
16
|
+
"guard",
|
|
17
|
+
"422",
|
|
18
|
+
"session",
|
|
19
|
+
"工具兜底"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://gitee.com/hc905/dsh-tool-content-guard.git"
|
|
25
|
+
},
|
|
26
|
+
"homepage": "https://gitee.com/hc905/dsh-tool-content-guard",
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://gitee.com/hc905/dsh-tool-content-guard/issues"
|
|
29
|
+
},
|
|
30
|
+
"main": "index.js",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.js",
|
|
33
|
+
"./package.json": "./package.json",
|
|
34
|
+
"./locale/*.json": "./locale/*.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"cordis.patch.yml",
|
|
38
|
+
"index.js",
|
|
39
|
+
"locale",
|
|
40
|
+
"test",
|
|
41
|
+
"icon.svg",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node test/content-guard.test.mjs"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@deepseek-ai/schemastery": "~3.18.4"
|
|
49
|
+
},
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 离线契约测试:不起 host,直接把插件挂到一个 stub ctx 上,然后按 DSH 的
|
|
3
|
+
* `tools/post-execute` / `tools/ptc-dispatch-log` 调用约定驱动真实监听器。
|
|
4
|
+
*
|
|
5
|
+
* node test/content-guard.test.mjs
|
|
6
|
+
*
|
|
7
|
+
* 退出码 0=全部通过,1=有失败。
|
|
8
|
+
*/
|
|
9
|
+
const { apply, coerceText, normalizeContent } = await import(new URL('../index.js', import.meta.url).href)
|
|
10
|
+
|
|
11
|
+
let failures = 0
|
|
12
|
+
const check = (label, condition, detail) => {
|
|
13
|
+
if (condition) {
|
|
14
|
+
console.log(`✓ ${label}`)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
failures += 1
|
|
18
|
+
console.log(`✗ ${label}${detail === undefined ? '' : ` — ${detail}`}`)
|
|
19
|
+
}
|
|
20
|
+
const equal = (label, actual, expected) =>
|
|
21
|
+
check(label, JSON.stringify(actual) === JSON.stringify(expected), `实际 ${JSON.stringify(actual)} ≠ 期望 ${JSON.stringify(expected)}`)
|
|
22
|
+
|
|
23
|
+
/** 挂一次插件,返回按事件名分组的监听器与告警。 */
|
|
24
|
+
function mount(config) {
|
|
25
|
+
const handlers = []
|
|
26
|
+
const warnings = []
|
|
27
|
+
const ctx = {
|
|
28
|
+
on: (event, listener, options) => handlers.push({ event, listener, options }),
|
|
29
|
+
logger: { warn: (message) => warnings.push(message) },
|
|
30
|
+
}
|
|
31
|
+
apply(ctx, config)
|
|
32
|
+
return {
|
|
33
|
+
handlers,
|
|
34
|
+
warnings,
|
|
35
|
+
events: handlers.map((item) => item.event),
|
|
36
|
+
on: (event) => handlers.find((item) => item.event === event),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const textBlock = (text) => ({ type: 'text', text })
|
|
41
|
+
const accept = (content) => ({ kind: 'accept', content })
|
|
42
|
+
const frozen = (value) => {
|
|
43
|
+
const freeze = (node) => {
|
|
44
|
+
if (node !== null && typeof node === 'object') {
|
|
45
|
+
for (const child of Object.values(node)) freeze(child)
|
|
46
|
+
Object.freeze(node)
|
|
47
|
+
}
|
|
48
|
+
return node
|
|
49
|
+
}
|
|
50
|
+
return freeze(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ---------------------------------------------------------------- 纯函数层 */
|
|
54
|
+
|
|
55
|
+
equal('coerceText:字符串原样', coerceText('a\nb'), 'a\nb')
|
|
56
|
+
equal('coerceText:行数组 join', coerceText(['a', 'b', '']), 'a\nb\n')
|
|
57
|
+
equal('coerceText:嵌套数组展平', coerceText([['a', 'b'], 'c']), 'a\nb\nc')
|
|
58
|
+
equal('coerceText:混合类型', coerceText(['a', 3, null, undefined, true, { x: 1 }]), 'a\n3\n\n\ntrue\n{"x":1}')
|
|
59
|
+
equal('coerceText:非数组标量', coerceText(42), '42')
|
|
60
|
+
equal('coerceText:null → 空串', coerceText(null), '')
|
|
61
|
+
check('normalizeContent:合法内容返回 undefined', normalizeContent([textBlock('ok'), { type: 'image', attachment: {} }]) === undefined)
|
|
62
|
+
{
|
|
63
|
+
const content = frozen([textBlock(['a', 'b']), textBlock('keep')])
|
|
64
|
+
const result = normalizeContent(content)
|
|
65
|
+
equal('normalizeContent:只重建被改的块', result.found.map((item) => item.index), [0])
|
|
66
|
+
equal('normalizeContent:改后 text 是字符串', result.content[0].text, 'a\nb')
|
|
67
|
+
check('normalizeContent:不改写原数组(深冻结安全)', Array.isArray(content[0].text))
|
|
68
|
+
check('normalizeContent:未改的块保持同一引用', result.content[1] === content[1])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* --------------------------------------------------------- 监听器 · coerce */
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
const mounted = mount({ mode: 'coerce' })
|
|
75
|
+
equal('注册了 post-execute 与 ptc-dispatch-log 两个监听器', mounted.events, ['tools/post-execute', 'tools/ptc-dispatch-log'])
|
|
76
|
+
check('post-execute 监听器 prepend(保证在其它策略之后归一化)', mounted.on('tools/post-execute').options?.prepend === true)
|
|
77
|
+
check('ptc-dispatch-log 监听器 prepend', mounted.on('tools/ptc-dispatch-log').options?.prepend === true)
|
|
78
|
+
const listener = mounted.on('tools/post-execute').listener
|
|
79
|
+
const dispatchListener = mounted.on('tools/ptc-dispatch-log').listener
|
|
80
|
+
|
|
81
|
+
// 正常 content:原样透传同一个 decision
|
|
82
|
+
const passThrough = accept([textBlock('fine')])
|
|
83
|
+
const kept = await listener({ name: 'demo' }, { content: [textBlock('fine')] }, async () => passThrough)
|
|
84
|
+
check('合法 content:原样透传同一个 decision 对象', kept === passThrough)
|
|
85
|
+
equal('合法 content:不产生告警', mounted.warnings.length, 0)
|
|
86
|
+
|
|
87
|
+
// 事故现场:数组 text
|
|
88
|
+
const input = frozen({ content: [textBlock(['第 1 行', '第 2 行', '第 3 行'])] })
|
|
89
|
+
const decision = await listener({ name: 'patent_invalidation_verify', callId: 'call_x' }, input, async () => accept(input.content))
|
|
90
|
+
equal('数组 text:被拼成字符串', decision.content[0].text, '第 1 行\n第 2 行\n第 3 行')
|
|
91
|
+
equal('数组 text:decision 仍是 accept', decision.kind, 'accept')
|
|
92
|
+
equal('数组 text:告警点名叫工具与 callId', mounted.warnings.length === 1 && mounted.warnings[0].includes('patent_invalidation_verify') && mounted.warnings[0].includes('call_x'), true)
|
|
93
|
+
|
|
94
|
+
// additionalContexts 必须保留
|
|
95
|
+
const withContexts = accept([textBlock([1, 2])])
|
|
96
|
+
withContexts.additionalContexts = [{ marker: 'ctx' }]
|
|
97
|
+
const preserved = await listener({ name: 'demo' }, { content: withContexts.content }, async () => withContexts)
|
|
98
|
+
equal('additionalContexts 原样保留', preserved.additionalContexts, [{ marker: 'ctx' }])
|
|
99
|
+
|
|
100
|
+
// 别的策略已经 block / 换 value:一律不碰
|
|
101
|
+
const blocked = { kind: 'block', feedback: [textBlock('denied')] }
|
|
102
|
+
check('block decision:原样透传', (await listener({ name: 'demo' }, { content: [textBlock(['x'])] }, async () => blocked)) === blocked)
|
|
103
|
+
const valueReplaced = { kind: 'accept', value: { anything: true } }
|
|
104
|
+
check('换 value 的 decision:原样透传(注册表会重新 render)', (await listener({ name: 'demo' }, { content: [textBlock(['x'])] }, async () => valueReplaced)) === valueReplaced)
|
|
105
|
+
|
|
106
|
+
// 其它 post-execute 策略替换过 content:以 decision.content 为准
|
|
107
|
+
const replaced = accept([textBlock(['left', 'right'])])
|
|
108
|
+
const result = await listener({ name: 'demo' }, { content: [textBlock('原始')] }, async () => replaced)
|
|
109
|
+
equal('以 decision.content 为准(尊重其它策略的替换)', result.content[0].text, 'left\nright')
|
|
110
|
+
|
|
111
|
+
// text 是对象/null/数字
|
|
112
|
+
equal('text=对象:JSON 化', (await listener({ name: 'demo' }, { content: [textBlock({ a: 1 })] }, async () => accept([textBlock({ a: 1 })]))).content[0].text, '{"a":1}')
|
|
113
|
+
equal('text=null:空串', (await listener({ name: 'demo' }, { content: [textBlock(null)] }, async () => accept([textBlock(null)]))).content[0].text, '')
|
|
114
|
+
|
|
115
|
+
// PTC 子调用日志:同样归一化;合法内容原样返回同一数组
|
|
116
|
+
const dispatchFixed = await dispatchListener({ name: 'read_image', subCallId: 'demo:ptc:1' }, async () => [textBlock(['x', 'y'])])
|
|
117
|
+
equal('ptc-dispatch-log:数组 text 被拼成字符串', dispatchFixed[0].text, 'x\ny')
|
|
118
|
+
const dispatchContent = [textBlock('fine')]
|
|
119
|
+
check('ptc-dispatch-log:合法内容原样返回同一数组', (await dispatchListener({ name: 'read_image' }, async () => dispatchContent)) === dispatchContent)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ---------------------------------------------------------- 监听器 · block */
|
|
123
|
+
|
|
124
|
+
{
|
|
125
|
+
const mounted = mount({ mode: 'block' })
|
|
126
|
+
const listener = mounted.on('tools/post-execute').listener
|
|
127
|
+
const decision = await listener({ name: 'bad_tool', callId: 'call_y' }, { content: [textBlock(['a', 'b'])] }, async () => accept([textBlock(['a', 'b'])]))
|
|
128
|
+
equal('block 模式:返回 block', decision.kind, 'block')
|
|
129
|
+
check('block 模式:feedback 是合法 content 块', Array.isArray(decision.feedback) && decision.feedback.every((block) => block.type === 'text' && typeof block.text === 'string'))
|
|
130
|
+
check('block 模式:feedback 点名叫工具并要求 join', decision.feedback[0].text.includes('bad_tool') && decision.feedback[0].text.includes("join('\\n')"))
|
|
131
|
+
equal('block 模式:告警 1 条', mounted.warnings.length, 1)
|
|
132
|
+
const fine = accept([textBlock('ok')])
|
|
133
|
+
check('block 模式:合法内容仍透传', (await listener({ name: 'ok_tool' }, { content: fine.content }, async () => fine)) === fine)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/* ------------------------------------------------------------------ off */
|
|
137
|
+
|
|
138
|
+
{
|
|
139
|
+
const mounted = mount({ mode: 'off' })
|
|
140
|
+
equal('off 模式:不注册任何监听器', mounted.handlers.length, 0)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ---------------------------------------------------------------- 默认值 */
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
const mounted = mount({})
|
|
147
|
+
equal('缺省 config:默认 coerce(挂两个监听)', mounted.handlers.length, 2)
|
|
148
|
+
const none = mount(undefined)
|
|
149
|
+
equal('config 为 undefined:默认 coerce', none.handlers.length, 2)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log(failures === 0 ? '\nCONTENT GUARD: OK' : `\nCONTENT GUARD: FAIL (${failures})`)
|
|
153
|
+
process.exit(failures === 0 ? 0 : 1)
|