dsh-plugin-tool-management 0.20.2 → 0.21.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 +60 -1
- package/README.md +4 -1
- package/README_EN.md +4 -1
- package/docs/update.md +3 -1
- package/lib/client.js +225 -54
- package/lib/compat/capability-specs.js +60 -1
- package/lib/compat/host-contracts.js +72 -0
- package/lib/compat/host-probe.js +59 -0
- package/lib/compat/host-roots.js +1 -1
- package/lib/compat/probe.js +84 -13
- package/lib/context-inject-frame.js +134 -26
- package/lib/context-inject.js +12 -9
- package/lib/http-api.js +3 -2
- package/lib/http-fence.js +26 -1
- package/lib/index.js +5 -2
- package/lib/mcp/manager.js +6 -1
- package/lib/mcp/state-section.js +75 -34
- package/lib/memories/constants.js +39 -7
- package/lib/memories/projection.js +18 -18
- package/lib/memories/service.js +8 -7
- package/lib/memories/snapshot.js +27 -17
- package/lib/ops/compat.js +64 -2
- package/lib/ops/memory.js +2 -2
- package/lib/ops/prompts.js +30 -2
- package/lib/request-gate.js +5 -4
- package/lib/skills/catalog.js +2 -2
- package/lib/subagents/catalog.js +4 -4
- package/lib/subagents/child-preset.js +120 -0
- package/lib/subagents/persona-parse.js +9 -9
- package/lib/subagents/run-provider.js +218 -62
- package/lib/subagents/tool-filter.js +12 -12
- package/lib/subagents/tools.js +29 -22
- package/lib/tools/deps.js +4 -4
- package/lib/tools/mcp.js +34 -34
- package/lib/tools/memory.js +34 -34
- package/lib/tools/prompt.js +5 -5
- package/lib/tools/remove.js +17 -17
- package/lib/tools/scene.js +66 -66
- package/lib/tools/skills.js +21 -21
- package/lib/tools/subagent.js +13 -13
- package/lib/tools/table.js +2 -2
- package/package.json +25 -24
|
@@ -8,11 +8,23 @@
|
|
|
8
8
|
* 为什么不能并进 `cue`(那能省下整整一行 ≈16 tok/段):用户 2026-09-18 定过"权威声明单独
|
|
9
9
|
* 成句" —— 它和动作句是两种东西(一句说"什么时候用它",一句说"以哪份为准"),合并后容易
|
|
10
10
|
* 被一眼带过。所以这里的收益只有约 6 tok/轮,**主要收益是消除四种措辞**,不是省字节。
|
|
11
|
+
*
|
|
12
|
+
* 2026-10-18(用户裁定):**模型侧模板一律英文** —— 框架标题、取代声明、`how` 行、清空 /
|
|
13
|
+
* 截断 / 未列出这些提示。**用户自己写的内容一律原样注入**(场景名与说明、记忆正文、MCP
|
|
14
|
+
* 备注、技能与人设的描述、`AGENTS.md` 正文、路径),一个字符都不动。理由:官方两条注入行
|
|
15
|
+
* (`Instructions from:` 与 skill catalog)本来就是英文,混着中文只让模型多付一次对齐成本。
|
|
16
|
+
*
|
|
17
|
+
* 同日晚些时候二次收紧(用户:「让 agent 更能注意到信息,信息高效,也不要冗余」):这一代
|
|
18
|
+
* 每个句子都按「少一个词不改行为就删」过了一遍 —— 权威声明去掉自我指涉与场合限定
|
|
19
|
+
* (`This replaces … in this session` → `Supersedes … of the same kind`),六个 `how` 行
|
|
20
|
+
* 去掉「This preset has no …」这类铺垫、改成先说要做什么。**没有新增任何一行**:2026-09-23
|
|
21
|
+
* 那条原则(注入只讲当前情况、不写解释与动作句)仍然生效,加字只会稀释指令。
|
|
11
22
|
*/
|
|
12
|
-
export const SUPERSEDE_NOTE = '
|
|
23
|
+
export const SUPERSEDE_NOTE = 'Supersedes earlier injected content of the same kind.';
|
|
13
24
|
export const DOMAIN_FRAME = {
|
|
14
25
|
scene: {
|
|
15
|
-
//
|
|
26
|
+
// 场景段此前只有标题 + 正文 + 权威声明(没有 cue 是六个域的共同决定,理由见 `DomainFrame`);
|
|
27
|
+
// 2026-10-18 起多了一句 `how`(定义 + 后果),为什么加、加的是哪一句,见本段末尾。
|
|
16
28
|
//
|
|
17
29
|
// 这一段的演进值得记下来,因为每一次都是被实际注入推着改的:
|
|
18
30
|
// ① 最早它把「场景说明」当**约定**授权("一律照办,覆盖你的默认做法")—— 而它的实例
|
|
@@ -24,33 +36,96 @@ export const DOMAIN_FRAME = {
|
|
|
24
36
|
// 知道没用的信息,反推更是浪费 token」。于是三句全删。
|
|
25
37
|
//
|
|
26
38
|
// 现在这一段只回答一个问题:**当前处在哪个场景、它是什么**(`**「代码」—— 写代码**`)。
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
39
|
+
//
|
|
40
|
+
// 2026-10-18(用户看到实际注入后问「agent 怎么知道这个场景是做什么的」):把②那次删掉的
|
|
41
|
+
// 定义**加回来,但只加一句**,且换掉当时失败的那个写法。
|
|
42
|
+
// - 当时失败的是"场景是用户给这台机器配的工作模式"——**只说是什么,不说它带来了什么**,
|
|
43
|
+
// 模型读完仍无动作可做。这里补上后果:记忆 / 提示词 / 工具 / 人设都已经按它筛过。
|
|
44
|
+
// - 与①的区别(那条授权语不能回来):这里不授权"照办",只说"你拿到的这些东西为什么长这样"。
|
|
45
|
+
// 用户自己写的说明仍然只是注解,不是约定。
|
|
46
|
+
// - 为什么这句能过"不写机制"那条原则:它解释的不是宿主怎么算的,而是**模型眼前这份上下文
|
|
47
|
+
// 为什么缺了几样**(`## Scene: 编程` 分组、看不到别的场景的记忆、工具比别处少)。
|
|
48
|
+
// 这是它做判断时要用的事实,不是反推。
|
|
49
|
+
// - 代价 ≈24 tok/轮,只在这一域。
|
|
50
|
+
title: 'Current scene',
|
|
51
|
+
how: "A scene is the user's working mode: the memories, prompt, tools and personas you are given are already filtered to this one.",
|
|
30
52
|
supersede: SUPERSEDE_NOTE,
|
|
31
53
|
},
|
|
32
54
|
memory: {
|
|
33
|
-
title: '
|
|
55
|
+
title: 'Current memories',
|
|
34
56
|
supersede: SUPERSEDE_NOTE,
|
|
35
57
|
},
|
|
36
58
|
mcp: {
|
|
37
|
-
title: '
|
|
59
|
+
title: 'Current MCP servers',
|
|
38
60
|
// how 只剩"怎么用备注"这半句:前半个分句「工具名是 `mcp__<服务器>__<工具>`」删掉了
|
|
39
61
|
// —— 模型自己的工具表里就是这个命名(`mcp__context7__xxx`),告诉它格式是零信息量。
|
|
40
|
-
|
|
62
|
+
//
|
|
63
|
+
// 2026-10-18 补两句(用户同一轮裁定:三个点了工具的域都要有触发条件):
|
|
64
|
+
// ① **什么时候想起这张表** —— 模型看不到 server 的连线状态,缺工具时它只会两种反应:
|
|
65
|
+
// 说"没有这个工具",或反复重试。这一域存在的意义就是给它第三种(表里有、只是没连上)。
|
|
66
|
+
// ② `not connected now` 那一档的读法 —— 不写清"此刻没有注册任何工具",这一行会被读成
|
|
67
|
+
// "可用但状态存疑",于是它照旧去调一个必然失败的工具名。
|
|
68
|
+
//
|
|
69
|
+
// 同轮二次修正(用户:「mcp 如果用户没写备注不是白写了吗」):两句**都改成按需发**。
|
|
70
|
+
// 上一版是常量串,于是"一台上线服务器、没有备注"这种最常见的情形里,整段 = 三行说明
|
|
71
|
+
// 解释一个不存在的东西 + 一行 `- **github**` —— 说明比内容长,且每轮都付。
|
|
72
|
+
// 现在:备注句只在真有备注时发,离线句只在真有 `not connected now` 档时发,两者都没有
|
|
73
|
+
// 就**不发行**(`undefined`)。正文只有名字时,标题加名字本身已经把话说完了。
|
|
74
|
+
how: ({ text }) => {
|
|
75
|
+
const parts = [];
|
|
76
|
+
if (text.includes('user hint: ')) {
|
|
77
|
+
parts.push("`user hint:` marks the user's own note on that server — read it before choosing one.");
|
|
78
|
+
}
|
|
79
|
+
const offline = text.includes('(not connected now)');
|
|
80
|
+
const muted = text.includes('(no tools enabled here)');
|
|
81
|
+
// 两个状态标记合起来讲一次:各写一句会把"别当成缺失"重复两遍(2026-10-18 审查收口)。
|
|
82
|
+
if (offline && muted) {
|
|
83
|
+
parts.push('Status markers: `not connected now` = no tools registered right now; `no tools enabled here` = up, but every tool is switched off. Neither means the server is missing.');
|
|
84
|
+
}
|
|
85
|
+
else if (offline) {
|
|
86
|
+
parts.push('A server marked `not connected now` registers no tools right now: check this list before telling the user a server is missing or a tool does not exist.');
|
|
87
|
+
}
|
|
88
|
+
else if (muted) {
|
|
89
|
+
parts.push('A server marked `no tools enabled here` is up, but every one of its tools is switched off — do not report it as missing.');
|
|
90
|
+
}
|
|
91
|
+
return parts.length > 0 ? parts.join(' ') : undefined;
|
|
92
|
+
},
|
|
41
93
|
supersede: SUPERSEDE_NOTE,
|
|
42
94
|
},
|
|
43
95
|
skills: {
|
|
44
|
-
title: '
|
|
96
|
+
title: 'Current skills',
|
|
45
97
|
// 两句都只在预设没挂官方 `skill` 工具时出现,差别只在点名不点名那个取正文的工具
|
|
46
98
|
// (工具被用户在兼容页关掉时不点名 —— 点名一个模型手里没有的工具只会让它去猜名字)。
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
99
|
+
//
|
|
100
|
+
// 2026-10-18 补「什么时候动手」这半句(用户对比官方 skill catalog 后提出模板太单薄):
|
|
101
|
+
// 上一版只有"读完再用"(**已经决定用之后**的纪律),没有"任务对得上就去读"(**怎么想起它**)。
|
|
102
|
+
// 官方那条行两者都有(`if … the task clearly matches … call the skill tool … before taking
|
|
103
|
+
// task actions`),而这一域恰恰只在官方那条行**缺席**时才注入 —— 也就是模型手上既没有
|
|
104
|
+
// `skill` 工具、也没人告诉过它技能的调用时机,缺的正是这一句。同理补上"描述不是指令"
|
|
105
|
+
// (官方对应句 `do not infer or follow a skill's instructions until it has been loaded`),
|
|
106
|
+
// 免得它拿 500 字摘要当正文照做。
|
|
107
|
+
// 不点名工具那一支改说"没有装载入口":上一版在那里仍写着"read a skill's body before
|
|
108
|
+
// following it",等于让模型去读一份它根本读不到的东西。
|
|
109
|
+
//
|
|
110
|
+
// 首句定义(用户 2026-10-18 第二轮裁定"加"):官方那条行以 `A skill is a reusable set of
|
|
111
|
+
// task-specific instructions.` 开头 —— 这句不是背景介绍,是后面两半句的**前提**:不懂"skill
|
|
112
|
+
// 是按需加载的任务指令集"时,"任务对得上就读正文"读不出任何动作,只会被理解成"一个名字 +
|
|
113
|
+
// 一句简介"。所以这里破例把定义放在动作句之前(正常纪律是先说要做什么,见 `SUPERSEDE_NOTE`
|
|
114
|
+
// 那段注释);措辞照抄官方,模型对它有现成先验。**只在官方载体缺席时发**(本域的唯一条件),
|
|
115
|
+
// 官方在时整段不注入,不存在两份定义。
|
|
116
|
+
how: ({ toolHidden }) => toolHidden('skill_manager_read')
|
|
117
|
+
? 'A skill is a reusable set of task-specific instructions. No official `skill` tool and no skill reader here: names and summaries only, and no way to load a body — treat them as a capability list, not as instructions.'
|
|
118
|
+
: 'A skill is a reusable set of task-specific instructions. No official `skill` tool here: when a task clearly matches one or the user names one, read its body with `skill_manager_read` before acting. Summaries only — never treat a description as instructions.',
|
|
119
|
+
// 英文下不能再靠 `slice(0, -1)` 拼句(那会把句末句点削掉,接出
|
|
120
|
+
// `…in this session Only skills…`):整句照发,续句自带首字母大写。
|
|
121
|
+
// 边界陈述 2026-10-18 拆出来并改口:原来写 `Only currently callable skills are listed.`,
|
|
122
|
+
// 而取正文的工具被关掉时**一条都调不到**,那句话在那个分支里是假的。改成 `enabled`
|
|
123
|
+
// —— 两支都成立(它们确实都是"启用中"的),也不再依赖工具在不在手里。
|
|
124
|
+
supersede: SUPERSEDE_NOTE,
|
|
125
|
+
boundary: 'Only enabled skills are listed.',
|
|
51
126
|
},
|
|
52
127
|
subagents: {
|
|
53
|
-
title: '
|
|
128
|
+
title: 'Current subagents',
|
|
54
129
|
// 分界规则(2026-09-17 方案 C,本机实测 session-ee722e23 逼出来的):官方那两个
|
|
55
130
|
// 委派工具(`subagent` / `subagent_fork`)不带人设,而此前没有任何一句话说明何时该
|
|
56
131
|
// 用谁 —— 模型在"审查刚读过的 README"时选了 `subagent_fork`(fork 能继承已读内容、
|
|
@@ -62,13 +137,24 @@ export const DOMAIN_FRAME = {
|
|
|
62
137
|
// Modes 段与 `inherit` 参数说明里**各有一份**,三份都在每轮上下文里 = 同一件事付三次
|
|
63
138
|
// token。留这两份(它们就在"要传这个参数"的那一刻),这里只留 how 行独有的那半句 ——
|
|
64
139
|
// 与官方两个委派工具的分界规则。
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
140
|
+
// 2026-10-18 补触发条件(同上):此前只说了"带人设的活走哪条通道"(选哪条),
|
|
141
|
+
// 没说"什么算带人设的活"(要不要委派)。**例子刻意不写**——`subagent_manager_run` 的描述里
|
|
142
|
+
// 已经有 "a review, an investigation, a piece of writing 且细节不必占自己的上下文",两句写在同一份
|
|
143
|
+
// 上下文里就是同一件事付两次。
|
|
144
|
+
// 不点名那一支补"只能参考、调不到":上一版只说官方那两个不带人设,而"目录里有人设 ≠
|
|
145
|
+
// 能委派"这层没人说 —— 模型会拿官方工具硬塞一个人设名进去。
|
|
146
|
+
how: ({ toolHidden }) => toolHidden('subagent_manager_run')
|
|
147
|
+
? 'No persona-carrying delegation tool here; official `subagent` / `subagent_fork` carry no persona — use them only when none fits or for background work. The personas listed here are reference only, not callable.'
|
|
148
|
+
: 'Persona work goes through `subagent_manager_run` — when a task matches one of these personas, delegate it there. Official `subagent` / `subagent_fork` carry no persona; use them only when none fits or for background work.',
|
|
68
149
|
supersede: SUPERSEDE_NOTE,
|
|
69
150
|
},
|
|
70
151
|
prompt: {
|
|
71
|
-
title: '
|
|
152
|
+
title: 'Current prompt',
|
|
153
|
+
// 2026-10-18(审查 E):这一域此前只有 `Instructions from: <path>` 一行文件头,没有一句话
|
|
154
|
+
// 说明**这些规则是要遵循的**。记忆域有授权语、场景域有定义,唯独效力最强的这一域空着 ——
|
|
155
|
+
// 而官方那条行给的是**降权**措辞("可作参考"),我们当初刻意没抄,于是等于什么都没说。
|
|
156
|
+
// 现在补一句正面的:要遵循,且用户当场说的优先。
|
|
157
|
+
how: 'These are the instructions in force for this machine; follow them unless the user says otherwise in this conversation.',
|
|
72
158
|
supersede: SUPERSEDE_NOTE,
|
|
73
159
|
},
|
|
74
160
|
};
|
|
@@ -87,7 +173,7 @@ export const DOMAIN_FRAME = {
|
|
|
87
173
|
export const HOW_NAMED_TOOLS = ['skill_manager_read', 'subagent_manager_run'];
|
|
88
174
|
/** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
|
|
89
175
|
export const fallbackFrame = (label) => ({
|
|
90
|
-
title:
|
|
176
|
+
title: `Current ${label}`,
|
|
91
177
|
supersede: SUPERSEDE_NOTE,
|
|
92
178
|
});
|
|
93
179
|
export const domainFrame = (key, label) => DOMAIN_FRAME[key] ?? fallbackFrame(label);
|
|
@@ -131,34 +217,56 @@ export function escapeFrameBody(body) {
|
|
|
131
217
|
* 同时把**正文也包进标记里**(此前只有引导语在标记内、正文裸奔)—— 官方两条注入行都是
|
|
132
218
|
* 整条包住的,正文里的用户自由文本更需要这层来源标记。
|
|
133
219
|
*/
|
|
134
|
-
export function renderDomainText(section, toolHidden = () => false) {
|
|
220
|
+
export function renderDomainText(section, toolHidden = () => false, replacing = true) {
|
|
135
221
|
const frame = domainFrame(section.key, section.label);
|
|
136
222
|
// 层级(2026-09-23 用户看到实际注入后指出「记忆内的场景怎么都是 ## 标题」):**`#` 一级给板块**
|
|
137
223
|
// (场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词),域正文里的 `##` 才是它的下一层
|
|
138
|
-
// (记忆段的 `##
|
|
224
|
+
// (记忆段的 `## Scene: X`、超预算时的 `## Not injected (…)`)。此前标题也是 `##`,两者平级,
|
|
139
225
|
// 模型读不出主次 —— 而"哪些内容归在哪个板块/场景下"正是它做判断时要用的结构。
|
|
140
226
|
//
|
|
141
227
|
// 开标签后**必须空一行**:markdown 里 `#` 紧跟在一行文字后面只是**段落续行**,不会被渲染成
|
|
142
228
|
// 标题 —— 用户截图里 `## 本机当前的场景` 就是这么被吞掉的(和 `<system-reminder>` 挤成一段)。
|
|
143
229
|
// 收尾同理:正文末尾先归一成单个空行,免得 `</system-reminder>` 粘在最后一行上。
|
|
144
|
-
// 结构:标题 → 补充说明(有才发)→
|
|
230
|
+
// 结构:标题 → 补充说明(有才发)→ 权威声明(只在替换时)→ 边界陈述(有才发)→ 正文。
|
|
231
|
+
// 没有 cue 那一行(见 `DomainFrame` 的注释)。
|
|
232
|
+
const info = { toolHidden, text: section.text, replacing };
|
|
145
233
|
const lines = [FRAME_OPEN, '', `# ${frame.title}`];
|
|
146
|
-
const how = typeof frame.how === 'function' ? frame.how(
|
|
234
|
+
const how = typeof frame.how === 'function' ? frame.how(info) : frame.how;
|
|
147
235
|
if (how !== undefined)
|
|
148
236
|
lines.push(how);
|
|
149
|
-
|
|
237
|
+
// 权威声明只在**替换**时发(2026-10-18 审查 D):本会话的第一份没有旧内容可取代,那句话
|
|
238
|
+
// 只是占一行。边界陈述反过来 —— 它不依赖"有没有旧的",第一份就要发。
|
|
239
|
+
if (replacing)
|
|
240
|
+
lines.push(frame.supersede);
|
|
241
|
+
if (frame.boundary !== undefined)
|
|
242
|
+
lines.push(frame.boundary);
|
|
243
|
+
lines.push('', escapeFrameBody(section.text).replace(/\n+$/, ''), '', FRAME_CLOSE);
|
|
150
244
|
return lines.join('\n');
|
|
151
245
|
}
|
|
152
246
|
/** 「已清空」通知正文:某个域曾经注入过、现在没有内容时发一条(纯函数,测试用)。 */
|
|
153
|
-
export function clearedDomainText(key, label) {
|
|
247
|
+
export function clearedDomainText(key, label, reason) {
|
|
154
248
|
const frame = domainFrame(key, label);
|
|
249
|
+
// 通知的措辞按**原因**分叉(2026-10-18 审查)。原来只有一句「同一类内容不再有效」,
|
|
250
|
+
// 而"这一域为什么消失"有四种,其中一种下那句话是**假话**:
|
|
251
|
+
// - `official`:官方载体接管了(技能目录由 `dsh-tool-skill` 送、提示词由
|
|
252
|
+
// `dsh-agent-instructions` 送)。此时同类内容**仍然有效、而且更强**,只是换了个发送方。
|
|
253
|
+
// 说"不再有效"会让模型把官方那一份也当成作废 —— 标准预设里这正是最常见的一次切换。
|
|
254
|
+
// - `empty`:真的没有内容了(全部删掉 / 场景切走)。原来的话在这里是对的。
|
|
255
|
+
// - `off` / `child`:是**故意不送**(用户关掉这一域,或该域不适用于子会话),内容本身
|
|
256
|
+
// 可能还在,所以只能说"不再展示",不能说"不存在"。
|
|
257
|
+
// - `error` 走不到这里:那条路径刻意不发「已清空」(见调用方注释)。
|
|
258
|
+
const body = reason === 'official'
|
|
259
|
+
? '**Replaced** — the official channel now sends this content; earlier injected content of the same kind is stale.'
|
|
260
|
+
: reason === 'off' || reason === 'child'
|
|
261
|
+
? '**Cleared** — this reminder is not sent here; earlier injected content of the same kind no longer applies.'
|
|
262
|
+
: '**Cleared** — earlier injected content of the same kind no longer applies.';
|
|
155
263
|
// 形状与 `renderDomainText` 一致(开标签后空行、板块 `#`、收尾空行)—— 这两条都是同一个
|
|
156
264
|
// 通道发出去的消息,层级与留白不该有两套。
|
|
157
265
|
return [
|
|
158
266
|
FRAME_OPEN,
|
|
159
267
|
'',
|
|
160
268
|
`# ${frame.title}`,
|
|
161
|
-
|
|
269
|
+
body,
|
|
162
270
|
'',
|
|
163
271
|
FRAME_CLOSE,
|
|
164
272
|
].join('\n');
|
package/lib/context-inject.js
CHANGED
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
// 判定放在域声明的 `applicableTo` 上,通道只负责问一句 —— "哪个域对哪类会话不成立"
|
|
41
41
|
// 是域的语义,不是通道的机制。
|
|
42
42
|
// ⚠️ 2026-09-17 方案 A 纠正:这**不是**"子会话不能委派"。官方 `dsh-tool-subagent`
|
|
43
|
-
// 默认 `maxDepth:
|
|
43
|
+
// 默认 `maxDepth: 1`(0.2.1 实测核对:`dsh-subagent/lib/index.js` 的 Config 默认值),
|
|
44
|
+
// provider 只在传了该值时才校验 —— 子代理本来就能继续嵌套
|
|
44
45
|
// (用户实测确认)。这个抑制只是让常驻目录不进入子会话、减少噪声;委派能力由官方
|
|
45
46
|
// 决定,本插件不干预(我们也不再传 `maxDepth`)。
|
|
46
47
|
// - **采纳遥测**:注入只解决"到没到",不解决"用没用"。本模块按域统计
|
|
@@ -376,12 +377,14 @@ export function createContextInjector(deps) {
|
|
|
376
377
|
const toolHidden = hiddenNow === null ? () => false : (name) => hiddenNow.has(name);
|
|
377
378
|
for (const section of sections) {
|
|
378
379
|
published.add(section.key);
|
|
379
|
-
const text = renderDomainText(section, toolHidden);
|
|
380
|
-
const current = visible.get(section.key);
|
|
381
|
-
if (current !== undefined && current.text === text)
|
|
382
|
-
continue;
|
|
383
380
|
// `current !== undefined` = 该域在可见表面上已有一条更早的己方消息 → 这次是**替换**,
|
|
384
381
|
// 界面上的动作标签据此从「已载入」变成「已更新」(见 domainMessage 的 baseline/changes)。
|
|
382
|
+
// 这个判据同时决定框架里那句 `Supersedes …` 发不发(2026-10-18:第一份没有旧内容可取代)。
|
|
383
|
+
const current = visible.get(section.key);
|
|
384
|
+
const replacing = current !== undefined;
|
|
385
|
+
const text = renderDomainText(section, toolHidden, replacing);
|
|
386
|
+
if (replacing && current.text === text)
|
|
387
|
+
continue;
|
|
385
388
|
let files;
|
|
386
389
|
try {
|
|
387
390
|
files = domainOf.get(section.key)?.files?.();
|
|
@@ -389,7 +392,7 @@ export function createContextInjector(deps) {
|
|
|
389
392
|
catch {
|
|
390
393
|
files = undefined;
|
|
391
394
|
}
|
|
392
|
-
additions.push(domainMessage(section, text, files,
|
|
395
|
+
additions.push(domainMessage(section, text, files, replacing));
|
|
393
396
|
appended.push(section.key);
|
|
394
397
|
injectedKeys.add(section.key);
|
|
395
398
|
}
|
|
@@ -409,7 +412,7 @@ export function createContextInjector(deps) {
|
|
|
409
412
|
const label = labelOf.get(key) ?? key;
|
|
410
413
|
if (previous.form === 'notice')
|
|
411
414
|
continue;
|
|
412
|
-
additions.push(clearedMessage(key, label));
|
|
415
|
+
additions.push(clearedMessage(key, label, lastReasons[key]));
|
|
413
416
|
appended.push(key);
|
|
414
417
|
clearedKeys.add(key);
|
|
415
418
|
}
|
|
@@ -524,9 +527,9 @@ function domainMessage(section, text, files, replacing) {
|
|
|
524
527
|
source,
|
|
525
528
|
});
|
|
526
529
|
}
|
|
527
|
-
function clearedMessage(key, label) {
|
|
530
|
+
function clearedMessage(key, label, reason) {
|
|
528
531
|
return createUserMessage({
|
|
529
|
-
content: [{ type: 'text', text: clearedDomainText(key, label) }],
|
|
532
|
+
content: [{ type: 'text', text: clearedDomainText(key, label, reason) }],
|
|
530
533
|
source: { kind: INJECT_KIND_OF[key], form: 'notice', summary: `${label}已清空` },
|
|
531
534
|
});
|
|
532
535
|
}
|
package/lib/http-api.js
CHANGED
|
@@ -46,9 +46,10 @@ export function installHttpApi(deps) {
|
|
|
46
46
|
req.on('error', reject);
|
|
47
47
|
});
|
|
48
48
|
// 宿主 BrowserAuth 栅栏。宿主的鉴权**只包在它自己注册的路由上**——dsh-host-webserver
|
|
49
|
-
// 没有全局中间件,既定约定是每个注册方自己调 connection.
|
|
49
|
+
// 没有全局中间件,既定约定是每个注册方自己调 connection.admit(req):
|
|
50
50
|
// 它先做 Host/Origin 栅栏(Host 必须是回环或 deployment 派生的 LAN IP 字面量,
|
|
51
|
-
// 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie
|
|
51
|
+
// 这是 DNS rebinding 唯一伪造不了的头),再叠加 browser-session cookie 鉴权,
|
|
52
|
+
// 并交回这次请求所代表的 Peer(本路由是 exact 路由,只服务 owner Peer)。
|
|
52
53
|
// 本插件此前只查公开常量 x-dsh-plugin 与自比对(Origin.host === Host)的 "同源",
|
|
53
54
|
// 两者都挡不住 rebind 后的网页或本机任意进程 —— 等于没鉴权。现在优先走宿主栅栏。
|
|
54
55
|
const connection = (() => {
|
package/lib/http-fence.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// 为什么需要它:宿主的 BrowserAuth 栅栏**只包在宿主自己注册的路由上**
|
|
4
4
|
// (dsh-client-connection 的 register()/upgrade、dsh-api-gateway、
|
|
5
5
|
// dsh-host-open-in-app),dsh-host-webserver 本身没有全局中间件。宿主生态的
|
|
6
|
-
// 既定约定是每个注册方自己调 `connection.
|
|
6
|
+
// 既定约定是每个注册方自己调 `connection.admit(req)`(0.2.1 起的完整版,
|
|
7
|
+
// `requestRejection(req)` 是它只取状态码的那层包装),它做两件事:
|
|
7
8
|
//
|
|
8
9
|
// 1. Host/Origin 栅栏 —— Host 必须是回环地址、deployment 派生的 LAN IP 字面量
|
|
9
10
|
// 或声明的 trustedHosts。Host 是 DNS rebinding 唯一伪造不了的头:攻击者
|
|
@@ -11,6 +12,9 @@
|
|
|
11
12
|
// (origin.host === host)会同时成立,于是被误判为同源。
|
|
12
13
|
// 2. browser-session cookie 鉴权(HttpOnly + SameSite=Strict,Path=/)。
|
|
13
14
|
//
|
|
15
|
+
// `admit()` 还交回这次请求所代表的 **Peer**,而本插件的 exact 路由按官方契约只服务
|
|
16
|
+
// owner Peer(非 owner 一律 403),所以 principal 也要验 —— 只取状态码会漏掉这一层。
|
|
17
|
+
//
|
|
14
18
|
// 只查公开常量 `x-dsh-plugin` 与自比对 Origin 等于没鉴权:本机任意进程都能调写 op,
|
|
15
19
|
// 而写 op 里包含 `mcpm-add`(command/args 无白名单,宿主 stdio 传输会 spawn 它
|
|
16
20
|
// → 任意命令执行)与 `history-delete`(永久删除会话)。
|
|
@@ -33,6 +37,27 @@ const headerValue = (req, name) => {
|
|
|
33
37
|
*/
|
|
34
38
|
export function fenceRejection(req, connection) {
|
|
35
39
|
try {
|
|
40
|
+
// 优先 `admit()`(0.2.1 起存在):它做 Host/Origin 栅栏 + 浏览器 session 鉴权,
|
|
41
|
+
// 并把"这次请求代表谁"作为 Peer 交出来。`requestRejection()` 只给状态码 —— 状态码
|
|
42
|
+
// 足以回答"拦不拦",却不足以回答"这个调用方被授权到什么程度",而本插件的路由是
|
|
43
|
+
// **exact 路由**(`kind: 'exact'`,官方契约:只有 owner Peer 才该拿到响应)。
|
|
44
|
+
// 没有 principal 信息时只能当作非 owner 处理 —— 这正是下面 403 那一支的理由。
|
|
45
|
+
const admission = connection?.admit?.(req);
|
|
46
|
+
if (admission !== undefined) {
|
|
47
|
+
if ('rejection' in admission) {
|
|
48
|
+
const status = admission.rejection;
|
|
49
|
+
return {
|
|
50
|
+
status,
|
|
51
|
+
error: status === 401
|
|
52
|
+
? '未通过宿主的浏览器鉴权:请在 DSH Web GUI 内操作,或为本插件配置访问令牌(config.token / DSH_PLUGIN_TOOL_MANAGEMENT_TOKEN)'
|
|
53
|
+
: 'Host/Origin 栅栏拒绝:该请求不是发往本机地址的同源请求',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (admission.peer?.principal?.kind !== 'owner') {
|
|
57
|
+
return { status: 403, error: '拒绝:调用方不是本机所有者身份' };
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
36
61
|
const rejection = connection?.requestRejection?.(req);
|
|
37
62
|
if (typeof rejection === 'number') {
|
|
38
63
|
return {
|
package/lib/index.js
CHANGED
|
@@ -727,7 +727,7 @@ export default {
|
|
|
727
727
|
// 三个目录是 catalog,提示词是 instructions(与官方 AGENTS.md 那条行同一形态)。
|
|
728
728
|
//
|
|
729
729
|
// 深度判据(`applicableTo`)**逐域写在域声明上**,三个域各一条,语义都**不是**"能不能
|
|
730
|
-
// 委派"。委派可行性由官方决定:`dsh-
|
|
730
|
+
// 委派"。委派可行性由官方决定:`dsh-subagent` 默认 `maxDepth: 1`(0.2.1 实测核对),provider 只在
|
|
731
731
|
// 传了该值时才校验,所以子代理本来就能继续嵌套(用户实测确认)。此前判据叫"还有没有
|
|
732
732
|
// 委派预算"、语义是"还能不能委派",那是错的 —— 详见 service.ts 里 `catalogDepth` 的注释。
|
|
733
733
|
// · 场景与记忆:**恒为"只在顶层"**。这两段说的是**父会话的处境**(此刻在哪个场景、
|
|
@@ -749,7 +749,7 @@ export default {
|
|
|
749
749
|
{ key: 'scene', name: 'tool-management:scene', label: '场景', form: 'snapshot', text: () => memoriesService.sceneCatalogText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
|
|
750
750
|
// 记忆(**内容**):各场景下的条目。与场景段同源、同一条 `applicableTo`。
|
|
751
751
|
{ key: 'memory', name: 'tool-management:memory', label: '记忆', form: 'snapshot', text: () => memoriesService.memoryText(), applicableTo: (agent) => subagentDepthOf(agent) === 0 },
|
|
752
|
-
{ key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: () => mcp.stateCatalog.text() },
|
|
752
|
+
{ key: 'mcp', name: 'tool-management:mcp-state', label: 'MCP 服务器', form: 'catalog', text: (agent) => mcp.stateCatalog.text(agent) },
|
|
753
753
|
{ key: 'skills', name: 'tool-management:skill-catalog', label: '技能目录', form: 'catalog', text: (agent) => skillCatalog.text(agent) },
|
|
754
754
|
{
|
|
755
755
|
key: 'subagents',
|
|
@@ -1395,6 +1395,9 @@ export default {
|
|
|
1395
1395
|
pluginHiddenTools: () => [...toolTableHidden()],
|
|
1396
1396
|
// 有官方等价物时逐 agent 让位(见上面 CARRIER_DUPLICATES 的注释)。
|
|
1397
1397
|
carrierHiddenTools: carrierHiddenToolsFor,
|
|
1398
|
+
// 状态段尾句那句「用 `mcp_manager_list` 查」的前提:与技能 / 子智能体两个目录同一份判据
|
|
1399
|
+
// (2026-10-18 审查 B)。
|
|
1400
|
+
listToolVisible: (agent) => toolVisibleFor('mcp_manager_list', agent),
|
|
1398
1401
|
});
|
|
1399
1402
|
// ---------- ops ----------
|
|
1400
1403
|
async function pluginVersion() {
|
package/lib/mcp/manager.js
CHANGED
|
@@ -113,7 +113,12 @@ export function createMcpManager(deps) {
|
|
|
113
113
|
const { readJsonFile, writeJsonFile, ensurePaths, withWriteLock, message, tools, pluginInventory, settings, readPatch, writePatch, memoriesService, wait, } = deps;
|
|
114
114
|
// MCP 状态段(注入通道的数据源)由本域自己建:它要的 rows 就是本域的 mcpmRowsWithNotes(false),
|
|
115
115
|
// 让 index.ts 建再传进来会形成 index ↔ manager 的循环引用。
|
|
116
|
-
|
|
116
|
+
// `listToolVisible` 是唯一从外面进来的:它要看**这个 agent** 手里有没有 `mcp_manager_list`
|
|
117
|
+
// (兼容页开关 + 人设 toolsDeny),判据在 index.ts 的 `toolVisibleFor`,不在这里复制一份。
|
|
118
|
+
const mcpStateCatalog = createMcpStateCatalog({
|
|
119
|
+
rows: () => mcpmRowsWithNotes(false),
|
|
120
|
+
...(deps.listToolVisible === undefined ? {} : { listToolVisible: deps.listToolVisible }),
|
|
121
|
+
});
|
|
117
122
|
// ---------- duplicate loader-id guard ----------
|
|
118
123
|
// Two rows with the same loader id make the plugin composition fail to
|
|
119
124
|
// boot (upstream hit exactly this after renaming an entry), so duplicates
|
package/lib/mcp/state-section.js
CHANGED
|
@@ -9,26 +9,30 @@
|
|
|
9
9
|
// 这类信息在任何工具 schema 里都拿不到:工具只暴露 `mcp__<server>__<tool>` 的名字与
|
|
10
10
|
// 参数,永远不会说「A 挂了改用 B」。所以「列出 server」只是载体。
|
|
11
11
|
//
|
|
12
|
-
// 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"
|
|
13
|
-
// -
|
|
12
|
+
// 范围(2026-09-15 用户裁定;2026-09-17 按实测修正"可用"的判据;2026-10-18 加第三档):
|
|
13
|
+
// - 分三档,判据全部可验证:
|
|
14
14
|
// ① **可用**:配置上已启用 **且** 当前真实注册了未被停用的工具
|
|
15
15
|
// (`liveEnabledToolCount > 0`;场景档案收窄与手动逐工具开关都会写停用表,这里如实扣减)。
|
|
16
|
-
// ②
|
|
16
|
+
// ② **连着但工具全被停用**:当前注册了工具、但停用表把它们全扣光(`liveToolCount > 0`
|
|
17
|
+
// 而 `liveEnabledToolCount === 0`)。2026-10-18 起列出来:它是"模型手上一条 MCP 工具
|
|
18
|
+
// 都没有,可 server 确实在跑"的唯一线索,而此前不列的理由("留着只会谎报数量")
|
|
19
|
+
// 随工具数一起作废了。
|
|
20
|
+
// ③ **曾经连上、现在不可用**:已启用、当前一个工具都没注册,但「已知工具」缓存非空。
|
|
17
21
|
// 这是**状态变化**,要报 —— 模型据此给"检查网络/命令/凭据"的建议,而不是把
|
|
18
22
|
// "配了但连不上"读成"本机没配"(后者会让它建议用户去装一个)。同时,用户写在那台
|
|
19
23
|
// server 上的备注(placeholder 就是「A 不可用时改用 B 兜底」)恰恰只在此时才有用,
|
|
20
24
|
// 不列就永远送不到。
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// - 只列 **server 名 + 状态(仅②档)+ 备注**;**具体工具名绝不注入** ——
|
|
25
|
+
// 不列的两类:**没开**(用户明确要求)、**从未连上过**(缓存也空 —— 一直没成功过,不该反复
|
|
26
|
+
// 打扰;这条抄的是 Claude Code 的通知策略:一直没连上的不提示,昨天还好今天挂的才提示)。
|
|
27
|
+
// - 只列 **server 名 + 状态(②③档)+ 备注**;**具体工具名绝不注入** ——
|
|
25
28
|
// 已启用 server 的工具本来就在模型自己的工具 schema 里(`mcp__<server>__<tool>`),
|
|
26
29
|
// 段里再列一遍是重复;模型按前缀就能对上号。
|
|
27
30
|
// - **工具数不列**(2026-09-23):模型不能凭"这台有 N 个工具"做任何事(能调哪些在它自己的
|
|
28
31
|
// schema 里),而 17 B/台 × 每轮的开销是白付的。
|
|
29
|
-
// - 无备注 → 只有名字(①档)或名字 +
|
|
30
|
-
// -
|
|
31
|
-
// -
|
|
32
|
+
// - 无备注 → 只有名字(①档)或名字 + 状态(②③档)。
|
|
33
|
+
// - 不写「已启用」字样:能进段的都是已启用的,标状态是废话;②③档则必须标,否则就是谎报。
|
|
34
|
+
// - **三档都空才返回 `''`**(无 server 可讲时零 token 成本)。注意 2026-10-18 的
|
|
35
|
+
// 「无备注就不发」已被同日的用户裁定推翻,理由见 `renderMcpStateSection` 的注释。
|
|
32
36
|
//
|
|
33
37
|
// 一条**测不出来**的边界(写在这里免得后来者以为它是 bug):宿主没有暴露"MCP 已连接"这个信号
|
|
34
38
|
// (插件清单只有 entryId / moduleName / enabled / fiberPhase),所以本模块只能读"工具有没有注册"。
|
|
@@ -49,11 +53,11 @@ export const DEFAULT_MCP_NOTE_MAX_LENGTH = 300;
|
|
|
49
53
|
* 备注的固定前缀(模型侧口径,用户裁定 2026-09-16:字段与段统一叫 user-hint / 用户提示)。
|
|
50
54
|
*
|
|
51
55
|
* 原为「备注:」,取「降低被读成指令的概率」——备注是自由文本,注入后**由插件背书**地到达模型。
|
|
52
|
-
*
|
|
56
|
+
* 改叫 `user hint:` 是有意为之:它本来就该被读成**用户的决策提示**(「A 挂了改用 B」),
|
|
53
57
|
* 而不是系统策略。威胁模型不变(内容由用户自己写、仅本机可写,属**自伤**而非被攻击),
|
|
54
58
|
* 但前缀如实标明来源,模型据此权衡即可。
|
|
55
59
|
*/
|
|
56
|
-
export const MCP_NOTE_PREFIX = '
|
|
60
|
+
export const MCP_NOTE_PREFIX = 'user hint: ';
|
|
57
61
|
/**
|
|
58
62
|
* 备注压成单行(换行 → 空格)并截断。
|
|
59
63
|
*
|
|
@@ -70,12 +74,17 @@ export function normalizeMcpNote(value, maxLength) {
|
|
|
70
74
|
/**
|
|
71
75
|
* 渲染 MCP 状态段(纯函数,便于单独推理)。
|
|
72
76
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
77
|
+
* **整段的取舍**(2026-10-18 用户裁定「无备注不发这样不好」,推翻了同日的 A 方案):
|
|
78
|
+
* 只要有一台该说的 server 就发,不因为"没有备注"而整段省略。理由:
|
|
79
|
+
* - 这一域是**唯一**说明"这台机器上配了哪些 server、各自什么状态"的地方,而模型手里的工具表
|
|
80
|
+
* 只说得出"我现在能调什么"。少了它,"配过但没连上 / 连着但工具被关掉"与"根本没配过"
|
|
81
|
+
* 在模型看来完全等价 —— 那正是本模块存在的理由(见文件头)。
|
|
82
|
+
* - 代价已经不是问题:`how` 行按需发、权威声明只在替换时发之后,只有名字的那种段在上下文里
|
|
83
|
+
* 只剩标题 + 一两行,且内容不变就不重发。
|
|
75
84
|
*
|
|
76
|
-
*
|
|
85
|
+
* @param listToolVisible `mcp_manager_list` 在这个 agent 手里吗(尾句那个出口的前提)
|
|
77
86
|
*/
|
|
78
|
-
export function renderMcpStateSection(rows, opts = {}) {
|
|
87
|
+
export function renderMcpStateSection(rows, opts = {}, listToolVisible = true) {
|
|
79
88
|
const maxEntries = opts.maxEntries ?? DEFAULT_MCP_MAX_ENTRIES;
|
|
80
89
|
const noteMaxLength = opts.noteMaxLength ?? DEFAULT_MCP_NOTE_MAX_LENGTH;
|
|
81
90
|
// 当前真实可用的工具数(停用表扣减后)。**优先读 live 那个数** —— 旧调用方只给
|
|
@@ -91,12 +100,20 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
91
100
|
const liveOf = (r) => (Number.isFinite(Number(r.liveToolCount)) ? Number(r.liveToolCount) : liveEnabledOf(r));
|
|
92
101
|
// 曾经连上过的证据。缺省 0 = 从未连上过(旧调用方给不出这个信号,按"不列"处理 —— 少说不错)。
|
|
93
102
|
const knownOf = (r) => Number(r.knownToolCount) || 0;
|
|
103
|
+
// 三档,判据全部可验证:
|
|
104
|
+
// ① `usable` —— 已启用,当前真注册了未被停用的工具(模型现在就能调)。
|
|
105
|
+
// ② `muted` —— 已启用、也真的连着(注册过工具),但**工具全被停用**:场景档案收窄或
|
|
106
|
+
// 手动逐工具开关的结果。2026-10-18 起列出来并标注(此前不列):它是
|
|
107
|
+
// "模型手上一条工具都没有,但 server 确实在跑"的**唯一**线索,而旧理由
|
|
108
|
+
// ("留着只会谎报数量")随着工具数一起消失了 —— 现在只给名字与状态。
|
|
109
|
+
// ③ `offline` —— 已启用、当前一个工具都没注册,但「已知工具」缓存非空(曾经连上过)。
|
|
94
110
|
const usable = rows.filter((r) => !r.disabled && liveEnabledOf(r) > 0);
|
|
111
|
+
const muted = rows.filter((r) => !r.disabled && liveOf(r) > 0 && liveEnabledOf(r) === 0);
|
|
95
112
|
const offline = rows.filter((r) => !r.disabled && liveOf(r) === 0 && knownOf(r) > 0);
|
|
96
|
-
if (!usable.length && !offline.length)
|
|
113
|
+
if (!usable.length && !muted.length && !offline.length)
|
|
97
114
|
return '';
|
|
98
115
|
// 按展示名排序:即使补丁文件里的行序变化,段文本也保持逐字节稳定(前缀缓存契约)。
|
|
99
|
-
//
|
|
116
|
+
// 三档各自排序,可用的一律排在前面(它才是模型当下能用的东西)。
|
|
100
117
|
const byName = (a, b) => {
|
|
101
118
|
const x = String(a.serverName ?? '');
|
|
102
119
|
const y = String(b.serverName ?? '');
|
|
@@ -104,48 +121,65 @@ export function renderMcpStateSection(rows, opts = {}) {
|
|
|
104
121
|
};
|
|
105
122
|
const cap = Math.max(0, maxEntries);
|
|
106
123
|
const shownUsable = [...usable].sort(byName).slice(0, cap);
|
|
124
|
+
const shownMuted = [...muted].sort(byName).slice(0, cap);
|
|
107
125
|
const shownOffline = [...offline].sort(byName).slice(0, cap);
|
|
108
|
-
const lineOf = (
|
|
109
|
-
const name = String(
|
|
110
|
-
//
|
|
126
|
+
const lineOf = (row, status) => {
|
|
127
|
+
const name = String(row.serverName ?? row.id);
|
|
128
|
+
// 非"可用"的两档必须把状态写出来:不写就等于谎报"可用"。
|
|
111
129
|
//
|
|
112
130
|
// 2026-09-23:两档的**工具数都去掉了**(用户看到实际注入后要求精简)。数字对模型没有
|
|
113
131
|
// 动作价值 —— 它不能凭"这台有 8 个工具"列举出工具名(真正能调的都在它自己的工具
|
|
114
132
|
// schema 里,按 `mcp__<server>__` 前缀就能对上号),而每轮都要付 17 B/台。
|
|
115
133
|
// 顺带消掉一处隐患:②档原来写「上次连上时 N 个工具」,那个数字是**历史事实**,措辞上
|
|
116
134
|
// 必须和现状区分开;数字没了,这个歧义也就不存在了。
|
|
117
|
-
const head =
|
|
118
|
-
|
|
135
|
+
const head = status === 'offline'
|
|
136
|
+
? '- **' + name + '** (not connected now)'
|
|
137
|
+
: status === 'muted'
|
|
138
|
+
? '- **' + name + '** (no tools enabled here)'
|
|
139
|
+
: '- **' + name + '**';
|
|
140
|
+
const note = normalizeMcpNote(row.notes, noteMaxLength);
|
|
119
141
|
return note ? head + ' — ' + MCP_NOTE_PREFIX + note : head;
|
|
120
142
|
};
|
|
121
143
|
const lines = [
|
|
122
|
-
...shownUsable.map((
|
|
123
|
-
...
|
|
144
|
+
...shownUsable.map((row) => lineOf(row, 'ok')),
|
|
145
|
+
...shownMuted.map((row) => lineOf(row, 'muted')),
|
|
146
|
+
...shownOffline.map((row) => lineOf(row, 'offline')),
|
|
124
147
|
];
|
|
125
|
-
// 只有清单:标题(`#
|
|
148
|
+
// 只有清单:标题(`# Current MCP servers`)由注入通道的框架承担
|
|
126
149
|
// (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME)—— 同一件事只有一个出处,
|
|
127
150
|
// 框架已经写了标题,正文再写一遍就是每步多付一行 token。
|
|
128
151
|
const out = [...lines];
|
|
129
|
-
const hidden = (usable.length +
|
|
130
|
-
|
|
131
|
-
|
|
152
|
+
const hidden = (usable.length + muted.length + offline.length)
|
|
153
|
+
- (shownUsable.length + shownMuted.length + shownOffline.length);
|
|
154
|
+
// 超上限时给出口(2026-10-18 审查 B):技能与子智能体两个目录早就这么写了,这是唯一漏掉
|
|
155
|
+
// 的一处。工具被关掉(兼容页 / 人设 toolsDeny)时不点名 —— 点名一个模型手里没有的工具,
|
|
156
|
+
// 只会让它去猜名字;数量照报,那是实话。
|
|
157
|
+
if (hidden > 0) {
|
|
158
|
+
out.push('', listToolVisible
|
|
159
|
+
? `(${hidden} more not listed — see them with \`mcp_manager_list\`.)`
|
|
160
|
+
: `(${hidden} more not listed.)`);
|
|
161
|
+
}
|
|
132
162
|
return out.join('\n');
|
|
133
163
|
}
|
|
134
164
|
/** 创建 SWR 的 MCP 状态目录。计算失败时保留上一次的值,不把段清空。 */
|
|
135
165
|
export function createMcpStateCatalog(deps, opts = {}) {
|
|
136
166
|
const ttlMs = opts.ttlMs ?? 3000;
|
|
137
167
|
const now = opts.now ?? (() => Date.now());
|
|
138
|
-
|
|
168
|
+
// 缓存的是**行数据**而不是渲染结果(与技能 / 子智能体两个目录同一手法):清单只依赖磁盘,
|
|
169
|
+
// 而尾句要不要点名查询工具取决于**这个 agent 手里有没有它**。渲染是纯字符串操作,代价可忽略。
|
|
170
|
+
let data = null;
|
|
139
171
|
let loadedAt = Number.NEGATIVE_INFINITY;
|
|
140
172
|
let inflight = null;
|
|
173
|
+
let rendered = null;
|
|
141
174
|
const recompute = async () => {
|
|
142
175
|
try {
|
|
143
176
|
const result = await deps.rows();
|
|
144
177
|
if (result && result.ok !== false) {
|
|
145
|
-
|
|
178
|
+
data = result.rows ?? [];
|
|
179
|
+
rendered = null;
|
|
146
180
|
}
|
|
147
181
|
}
|
|
148
|
-
catch { /* 保留上一次的值;首次失败则维持
|
|
182
|
+
catch { /* 保留上一次的值;首次失败则维持 null */ }
|
|
149
183
|
loadedAt = now();
|
|
150
184
|
};
|
|
151
185
|
const revalidate = () => {
|
|
@@ -155,10 +189,17 @@ export function createMcpStateCatalog(deps, opts = {}) {
|
|
|
155
189
|
return inflight;
|
|
156
190
|
};
|
|
157
191
|
return {
|
|
158
|
-
text: () => {
|
|
192
|
+
text: (agent) => {
|
|
159
193
|
if (now() - loadedAt > ttlMs)
|
|
160
194
|
void revalidate();
|
|
161
|
-
|
|
195
|
+
if (data === null)
|
|
196
|
+
return '';
|
|
197
|
+
const visible = deps.listToolVisible ? deps.listToolVisible(agent) : true;
|
|
198
|
+
if (rendered && rendered.visible === visible)
|
|
199
|
+
return rendered.text;
|
|
200
|
+
const out = renderMcpStateSection(data, opts, visible);
|
|
201
|
+
rendered = { visible, text: out };
|
|
202
|
+
return out;
|
|
162
203
|
},
|
|
163
204
|
refresh: () => {
|
|
164
205
|
loadedAt = Number.NEGATIVE_INFINITY;
|