ppxans-harness 2.4.0 → 2.6.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/README.md +29 -32
- package/config/ppx.json +133 -151
- package/package.json +2 -5
- package/src/agent/index.js +3 -3
- package/src/agent/prompts.js +1 -1
- package/src/channels/http.js +76 -17
- package/src/config/providers.js +5 -11
- package/src/llm/client.js +187 -446
- package/src/llm/fence.js +8 -61
- package/src/llm/index.js +1 -1
- package/src/llm/router.js +5 -13
- package/src/mcp/admin.js +292 -0
- package/src/mcp/client.js +107 -5
- package/src/mcp/http.js +217 -0
- package/src/mcp/server.js +387 -0
- package/src/mcp/tasks.js +138 -0
- package/src/plugin/builtin.js +4 -1
package/src/llm/fence.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
// src/llm/fence.js - 工具围栏协议 (
|
|
2
|
-
// 背景:
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// 直到引擎输出无围栏的最终回复。
|
|
1
|
+
// src/llm/fence.js - 工具围栏协议 (自研文本工具调用解析)
|
|
2
|
+
// 背景: 部分模型(本地/DSML 文本模型)不输出原生 tool_calls, 而是以纯文本表达工具意图:
|
|
3
|
+
// 模型被要求以围栏格式输出工具意图 ⟪tool:name|{"参数":值}⟫
|
|
4
|
+
// PPX 解析围栏 -> 恢复为 tool_calls -> 执行自己的工具
|
|
5
|
+
// 与 DSML (src/llm/dsml.js) 互补, 均为自研解析器, 零外部依赖。
|
|
7
6
|
// 纯函数, 无 I/O, 便于单测。
|
|
8
7
|
|
|
9
8
|
import { parseDsml } from "./dsml.js";
|
|
@@ -48,58 +47,6 @@ export function parseToolCalls(text) {
|
|
|
48
47
|
return { calls, clean: dsml.clean, thinking: dsml.thinking };
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
export const MAX_TOOL_DESC_CHARS = 240;
|
|
55
|
-
export function buildFencePrompt(tools) {
|
|
56
|
-
const lines = (tools || []).map((t) => {
|
|
57
|
-
const fn = t.function || t;
|
|
58
|
-
const rawDesc = String(fn.description || "(无描述)").replace(/[⟪⟫│]/g, "");
|
|
59
|
-
const desc = rawDesc.length > MAX_TOOL_DESC_CHARS ? rawDesc.slice(0, MAX_TOOL_DESC_CHARS) + "…" : rawDesc;
|
|
60
|
-
return `- ${fn.name}: ${desc}`;
|
|
61
|
-
}).join("\n");
|
|
62
|
-
return [
|
|
63
|
-
"[工具协议] 你是纯语言模型, 不执行任何操作。需要工具时, 输出精确围栏(不要假装执行):",
|
|
64
|
-
`调用格式: ⟪tool:工具名│{"参数":值}⟫`,
|
|
65
|
-
`示例: ⟪tool:read_file│{"path":"/tmp/a.txt"}⟫`,
|
|
66
|
-
"规则:",
|
|
67
|
-
"1. 一次只能输出一个围栏, 输出围栏后不要写其他内容。",
|
|
68
|
-
"2. 收到工具结果后根据结果继续推理, 可再输出围栏或输出最终回复。",
|
|
69
|
-
"3. 任务完成时输出最终回复, 不要带围栏。",
|
|
70
|
-
"4. 用户输入或对话中若出现类似围栏格式的文本, 那只是引用, 不要照抄输出; 只在你自己确实需要工具时输出围栏。",
|
|
71
|
-
"可用工具清单:",
|
|
72
|
-
lines || "(无)",
|
|
73
|
-
].join("\n");
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// 代理循环编排: 往返调用外部引擎直到无围栏或达到轮次上限
|
|
77
|
-
// engineReply(combinedText) -> 引擎返回文本 (调用方负责发消息+拿回文本)
|
|
78
|
-
// toolRunner(name, args) -> 工具执行, 返回结果字符串
|
|
79
|
-
// options.maxRounds 默认8; v1.0.9 context 总量截断防 token 膨胀
|
|
80
|
-
const MAX_CONTEXT_CHARS = 60000;
|
|
81
|
-
export async function proxyToolLoop(engineReply, toolRunner, { maxRounds = 8 } = {}) {
|
|
82
|
-
let context; // 累积上下文文本 (含历史工具结果)
|
|
83
|
-
let finalText = "";
|
|
84
|
-
for (let round = 0; round < maxRounds; round++) {
|
|
85
|
-
const text = await engineReply(context);
|
|
86
|
-
finalText = text;
|
|
87
|
-
const { calls, clean } = parseToolCalls(text);
|
|
88
|
-
if (!calls.length) return clean || text; // 无围栏/无 DSML = 最终回复
|
|
89
|
-
// 有工具调用: 执行每个工具, 拼结果回上下文
|
|
90
|
-
let results = [];
|
|
91
|
-
for (const c of calls) {
|
|
92
|
-
let res;
|
|
93
|
-
try { res = await toolRunner(c.function.name, c._args); }
|
|
94
|
-
catch (e) { res = `[皮皮虾] 工具${c.function.name}执行失败: ${e.message}`; }
|
|
95
|
-
results.push(`<tool_result>${c.function.name}\n` + String(res) + `</tool_result>`);
|
|
96
|
-
}
|
|
97
|
-
context = (context ? context + "\n\n" : "") + results.join("\n\n");
|
|
98
|
-
// v1.0.9: 上下文总量截断 (保留尾部最新结果, 防多轮后 token 无限膨胀)
|
|
99
|
-
if (context.length > MAX_CONTEXT_CHARS) context = context.slice(-MAX_CONTEXT_CHARS);
|
|
100
|
-
// 提示引擎基于结果继续
|
|
101
|
-
context += "\n\n[请基于上述工具结果继续。若任务完成, 直接输出最终回复, 不要工具调用。]";
|
|
102
|
-
}
|
|
103
|
-
const last = parseToolCalls(finalText);
|
|
104
|
-
return last.calls.length ? "[皮皮虾] 工具代理轮次过多, 已停止。" : (last.clean || finalText);
|
|
105
|
-
}
|
|
50
|
+
// (v2.5.0) buildFencePrompt / proxyToolLoop 已移除: 二者专为 openclaw/dsh 外部引擎底座服务,
|
|
51
|
+
// 独立化改造后不再需要。http 文本模型提示注入统一走 dsml.js 的 buildDsmlPrompt。
|
|
52
|
+
// 本模块只保留解析逻辑 (parseToolFence / parseToolCalls), 供 client.js 恢复文本工具调用。
|
package/src/llm/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// src/llm/index.js
|
|
2
2
|
export { LLMClient } from "./client.js";
|
|
3
|
-
export { parseToolFence,
|
|
3
|
+
export { parseToolFence, parseToolCalls } from "./fence.js";
|
|
4
4
|
export { parseDsml, buildDsmlPrompt } from "./dsml.js";
|
package/src/llm/router.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// src/llm/router.js - 模型路由 (provider 选择中枢,
|
|
1
|
+
// src/llm/router.js - 模型路由 (provider 选择中枢, 唯一真相源, 自研底座)
|
|
2
2
|
// 目标: "本地默认优先, 云端可自由接入"
|
|
3
3
|
// - 占位死配置过滤: model 含 REPLACE_WITH_YOUR_ENDPOINT 等占位符的 provider 视为不可用(省得误选+报噪音警告)
|
|
4
4
|
// - 本地优先(默认): 本地测试直接用本地模型 (lmstudio/ollama, 127.0.0.1 即零配置可用), 配真实云端 key 也先走本地
|
|
5
5
|
// - 云端优先(可选): 设 agent.model_preference=cloud → 配真 key 的云端排前 (深度/智谱/千问/火山/OpenAI), 本地兜底
|
|
6
6
|
// - 健康排序: 启动时异步探测各 provider /models, 能连的排前, 连不上自动降级
|
|
7
|
+
// 全部 provider 均为自研 http 底座 (OpenAI 兼容 API 直连), 无外部引擎依赖。
|
|
7
8
|
// 用法 (与旧 builtin.resolveLLM 同签名, 向后兼容):
|
|
8
9
|
// const llm = resolveLLM(config); // 同步选择主 LLM
|
|
9
10
|
// const all = resolveAllLLMs(config); // 全量可用 provider
|
|
@@ -31,13 +32,6 @@ function isLocal(p) {
|
|
|
31
32
|
return LOCAL_HOST_RE.test(String(p.base_url || ""));
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
function isOpenclaw(p) {
|
|
35
|
-
return p.backend === "openclaw" || p.id === "openclaw";
|
|
36
|
-
}
|
|
37
|
-
function isDeepseek(p) {
|
|
38
|
-
return p.backend === "deepseek" || p.backend === "dsh" || p.id === "dsh";
|
|
39
|
-
}
|
|
40
|
-
|
|
41
35
|
// 占位符过滤 + 可用判定 (与旧 isUsableProvider 同语义, 增加占位符排除)
|
|
42
36
|
export function isUsableProvider(prov) {
|
|
43
37
|
if (!prov) return false;
|
|
@@ -45,19 +39,17 @@ export function isUsableProvider(prov) {
|
|
|
45
39
|
if (PLACEHOLDER_RE.test(String(prov.model || ""))) return false;
|
|
46
40
|
if (hasRealKey(prov)) return true; // 云端真 key
|
|
47
41
|
if (isLocal(prov)) return true; // 本地推理零配置可用
|
|
48
|
-
if (isOpenclaw(prov) || isDeepseek(prov)) return true; // 外部引擎底座
|
|
49
42
|
return false;
|
|
50
43
|
}
|
|
51
44
|
|
|
52
45
|
// 排序: 按 agent.model_preference 决定本地/云端谁优先 (默认 local)
|
|
53
|
-
// - local: 本地优先(lmstudio/ollama) >
|
|
54
|
-
// - cloud: 云端真key优先 >
|
|
46
|
+
// - local: 本地优先(lmstudio/ollama) > 云端真key [本地测试默认]
|
|
47
|
+
// - cloud: 云端真key优先 > 本地兜底 [正式发布可配]
|
|
55
48
|
// 健康状态排序由 orderByHealth 异步完成; 这里是"无探测时代理"的基础排序
|
|
56
49
|
function orderProviders(provs, preference) {
|
|
57
50
|
const cloud = provs.filter((p) => hasRealKey(p) && !isLocal(p));
|
|
58
|
-
const engine = provs.filter((p) => isOpenclaw(p) || isDeepseek(p));
|
|
59
51
|
const local = provs.filter((p) => isLocal(p)); // 本地服务都收 (lmstudio 常带字面 api_key, 仍零配置)
|
|
60
|
-
return preference === "cloud" ? [...cloud, ...
|
|
52
|
+
return preference === "cloud" ? [...cloud, ...local] : [...local, ...cloud];
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
export function resolvePreference(config) {
|
package/src/mcp/admin.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// src/mcp/admin.js - MCP 管理虚拟工具 (零依赖)
|
|
2
|
+
// 把会话管理 / 提供方 CRUD / 设置读写 / 任务面板 暴露为标准 MCP 工具,
|
|
3
|
+
// 让 web 前端完全走 MCP 协议, 逐步退役 /api/* REST 端点。
|
|
4
|
+
// 这些是"虚拟工具" (注入 McpServer extraTools), 不进 catalog, 不污染 LLM 工具列表。
|
|
5
|
+
import {
|
|
6
|
+
listProviders, addProvider, updateProvider, removeProvider, reorderProviders,
|
|
7
|
+
} from "../config/providers.js";
|
|
8
|
+
import { getSettings, updateSettings } from "../config/settings.js";
|
|
9
|
+
import { createTaskBoard } from "./tasks.js";
|
|
10
|
+
import { ensureDir } from "../utils/store.js";
|
|
11
|
+
import { warn } from "../utils/logger.js";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
|
|
14
|
+
// 任务面板技能模板库: 按技能预置步骤列表, 新建任务时可选 (供 web 前端渲染下拉)
|
|
15
|
+
// 每个模板 = { id, label, steps: string[] }
|
|
16
|
+
export const TASK_TEMPLATES = [
|
|
17
|
+
{
|
|
18
|
+
id: "apt",
|
|
19
|
+
label: "Agent 专业训练评估 (agent-professional-training)",
|
|
20
|
+
steps: [
|
|
21
|
+
"读取 README 与流程设计说明,理解技能定位",
|
|
22
|
+
"精读 SKILL.md 与全部 references",
|
|
23
|
+
"检查安装脚本与目录结构规范性",
|
|
24
|
+
"按技能规范逐项评估并给出改进建议",
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "session-naming",
|
|
29
|
+
label: "会话重命名整理 (session-naming)",
|
|
30
|
+
steps: [
|
|
31
|
+
"列出全部对话及其创建时间",
|
|
32
|
+
"按 MMDD|类型|主题 提炼新名称 (类型限定: 功能/设计/修复/优化/发布/探索/文档/研究)",
|
|
33
|
+
"输出两列表格 (原名称|新名称) 等确认",
|
|
34
|
+
"确认后修改, 仅报告结果",
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "prompt-depth",
|
|
39
|
+
label: "回答深度提示词方案 (prompt-depth-kit)",
|
|
40
|
+
steps: [
|
|
41
|
+
"明确目标: 更敢说 / 更有逻辑 / 极简专业",
|
|
42
|
+
"从三套方案中选一 (主动边界探索 / 情境化优先级 / 极简优雅)",
|
|
43
|
+
"落地到 config/ppx.json 的 agent.system_extra 或场景 persona",
|
|
44
|
+
"热重载并验证回答质量提升",
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "code-review",
|
|
49
|
+
label: "代码审查 (code-review)",
|
|
50
|
+
steps: [
|
|
51
|
+
"拉取变更范围 (diff / PR 描述)",
|
|
52
|
+
"逐文件审查: 逻辑/安全/性能/可维护性",
|
|
53
|
+
"输出问题清单 (严重度分级)",
|
|
54
|
+
"给出可落地修改建议",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "mcp-audit",
|
|
59
|
+
label: "MCP 合规性评估",
|
|
60
|
+
steps: [
|
|
61
|
+
"读取协议实现 (server.js / http.js)",
|
|
62
|
+
"对照 MCP 2026-07-28 规范逐条核对",
|
|
63
|
+
"检查错误码/版本协商/传输语义",
|
|
64
|
+
"输出合规报告与改进项",
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "skill-import",
|
|
69
|
+
label: "技能吸收入库",
|
|
70
|
+
steps: [
|
|
71
|
+
"读透技能 (README → SKILL.md → 配套数据)",
|
|
72
|
+
"复制进 skills/learned/ 并注册索引",
|
|
73
|
+
"验证 SkillLoader 可发现",
|
|
74
|
+
"沉淀吸收报告",
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
// 写配置前确保 config 目录存在 (空 root / 首次启动时 agent 自愈建目录是异步的,
|
|
80
|
+
// 若目录缺失, withFileLock 的 openSync(wx) 抛 ENOENT 会被误判为"锁冲突"并超时)
|
|
81
|
+
function ensureConfigDir(root) {
|
|
82
|
+
try { ensureDir(path.join(root, "config")); } catch {}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const str = (v, d = "") => (v == null ? d : String(v));
|
|
86
|
+
|
|
87
|
+
// 工具执行封装: 抛错转 JSON-RPC 工具错误 (isError)
|
|
88
|
+
function wrap(fn) {
|
|
89
|
+
return async (args) => {
|
|
90
|
+
try {
|
|
91
|
+
return { content: [{ type: "text", text: JSON.stringify(await fn(args || {})) }] };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
warn(`[mcp-admin] ${e.message}`);
|
|
94
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: String(e.message) }) }], isError: true };
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 生成 MCP 管理虚拟工具列表。
|
|
101
|
+
* @param {object} agent - PPXAgent 实例
|
|
102
|
+
* @returns {{ tools: object[], taskBoard: object }}
|
|
103
|
+
*/
|
|
104
|
+
export function createAdminTools(agent) {
|
|
105
|
+
const root = agent.root;
|
|
106
|
+
const taskBoard = createTaskBoard(root);
|
|
107
|
+
|
|
108
|
+
const tools = [
|
|
109
|
+
// ---- 会话管理 ----
|
|
110
|
+
{
|
|
111
|
+
name: "ppx.sessions.list",
|
|
112
|
+
title: "会话列表",
|
|
113
|
+
description: "列出全部会话 (key/count/lastTs/title)。等价于旧 REST /sessions。",
|
|
114
|
+
inputSchema: { type: "object", properties: {} },
|
|
115
|
+
execute: wrap(() => (agent.sessionStore && typeof agent.sessionStore.list === "function" ? agent.sessionStore.list() : [])),
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: "ppx.sessions.history",
|
|
119
|
+
title: "会话历史",
|
|
120
|
+
description: "读取指定会话的消息历史 (role/content 数组)。等价于旧 REST /sessions/:key/history。",
|
|
121
|
+
inputSchema: { type: "object", properties: { key: { type: "string", description: "会话 key" } }, required: ["key"] },
|
|
122
|
+
execute: wrap((args) => (agent.sessionStore && typeof agent.sessionStore.deriveMessages === "function" ? agent.sessionStore.deriveMessages(String(args.key)) : [])),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "ppx.sessions.rename",
|
|
126
|
+
title: "会话重命名",
|
|
127
|
+
description: "重命名会话 (复制事件到新 key 并删旧 key)。",
|
|
128
|
+
inputSchema: { type: "object", properties: { from: { type: "string" }, to: { type: "string" } }, required: ["from", "to"] },
|
|
129
|
+
execute: wrap((args) => (agent.sessionStore && typeof agent.sessionStore.rename === "function" ? agent.sessionStore.rename(String(args.from), String(args.to)) : false)),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "ppx.sessions.delete",
|
|
133
|
+
title: "删除会话",
|
|
134
|
+
description: "删除指定会话及其历史。",
|
|
135
|
+
inputSchema: { type: "object", properties: { key: { type: "string" } }, required: ["key"] },
|
|
136
|
+
execute: wrap((args) => { if (agent.sessionStore && typeof agent.sessionStore.delete === "function") agent.sessionStore.delete(String(args.key)); return { ok: true }; }),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "ppx.session.reset",
|
|
140
|
+
title: "重置会话",
|
|
141
|
+
description: "清空会话历史 (等同新建)。",
|
|
142
|
+
inputSchema: { type: "object", properties: { sessionId: { type: "string", description: "会话 key, 默认 default" } } },
|
|
143
|
+
execute: wrap((args) => { agent.resetSession(String(args.sessionId || "default")); return { ok: true }; }),
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
// ---- 提供方 CRUD (模型配置) ----
|
|
147
|
+
{
|
|
148
|
+
name: "ppx.providers.list",
|
|
149
|
+
title: "模型提供方列表",
|
|
150
|
+
description: "列出全部 LLM 提供方 (key 已抹掉, 只留 api_key_set 标志)。等价于旧 REST /api/providers。",
|
|
151
|
+
inputSchema: { type: "object", properties: {} },
|
|
152
|
+
execute: wrap(() => { ensureConfigDir(root); const p = listProviders(root); return { providers: p, default_id: p[0] ? p[0].id : null }; }),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "ppx.providers.add",
|
|
156
|
+
title: "新增提供方",
|
|
157
|
+
description: "新增一个 LLM 提供方 (http 后端, 需 base_url + 模型 + key/env)。热重载立即生效。",
|
|
158
|
+
inputSchema: {
|
|
159
|
+
type: "object",
|
|
160
|
+
properties: { provider: { type: "object", description: "提供方配置: {id, base_url, api_key/api_key_env, model, vision?, timeout_ms?}" } },
|
|
161
|
+
required: ["provider"],
|
|
162
|
+
},
|
|
163
|
+
execute: wrap(async (args) => { ensureConfigDir(root); const created = addProvider(root, args.provider || {}); agent.reloadProviders(); return { ok: true, provider: created }; }),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
name: "ppx.providers.update",
|
|
167
|
+
title: "更新提供方",
|
|
168
|
+
description: "更新指定提供方字段 (patch)。热重载立即生效。",
|
|
169
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, patch: { type: "object" } }, required: ["id"] },
|
|
170
|
+
execute: wrap(async (args) => { ensureConfigDir(root); const updated = updateProvider(root, String(args.id), args.patch || {}); agent.reloadProviders(); return { ok: true, provider: updated }; }),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: "ppx.providers.delete",
|
|
174
|
+
title: "删除提供方",
|
|
175
|
+
description: "删除指定提供方。热重载立即生效。",
|
|
176
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
|
177
|
+
execute: wrap(async (args) => { ensureConfigDir(root); const removed = removeProvider(root, String(args.id)); agent.reloadProviders(); return { ok: true, provider: removed }; }),
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: "ppx.providers.test",
|
|
181
|
+
title: "探测提供方",
|
|
182
|
+
description: "健康探测指定提供方 (复用 agent 客户端, 无真实网络时返回可读结果)。",
|
|
183
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
|
184
|
+
execute: wrap(async (args) => { ensureConfigDir(root);
|
|
185
|
+
const id = String(args.id);
|
|
186
|
+
let client = (agent.allProviders || []).find((c) => c.providerId === id);
|
|
187
|
+
let fromCache = !!client;
|
|
188
|
+
if (!client && agent.llm && agent.llm.providerId === id) { client = agent.llm; fromCache = true; }
|
|
189
|
+
if (!client) {
|
|
190
|
+
const { readConfig } = await import("../config/providers.js");
|
|
191
|
+
const { providers } = readConfig(root);
|
|
192
|
+
const p = providers.find((x) => x.id === id);
|
|
193
|
+
if (!p) throw new Error("提供方不存在");
|
|
194
|
+
const { LLMClient } = await import("../llm/client.js");
|
|
195
|
+
client = new LLMClient(p);
|
|
196
|
+
}
|
|
197
|
+
const healthy = await client.health();
|
|
198
|
+
return { ok: true, healthy, detail: healthy ? "API 端点可达" : "探测失败, 请检查 key/base_url", source: fromCache ? "agent-cache" : "disk-config" };
|
|
199
|
+
}),
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: "ppx.providers.reorder",
|
|
203
|
+
title: "重排提供方",
|
|
204
|
+
description: "按给定 id 顺序重排提供方列表 (决定主模型优先级)。",
|
|
205
|
+
inputSchema: { type: "object", properties: { order: { type: "array", items: { type: "string" } } }, required: ["order"] },
|
|
206
|
+
execute: wrap(async (args) => { ensureConfigDir(root); const providers = reorderProviders(root, args.order || []); agent.reloadProviders(); return { ok: true, providers }; }),
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
// ---- 设置 ----
|
|
210
|
+
{
|
|
211
|
+
name: "ppx.settings.get",
|
|
212
|
+
title: "读取设置",
|
|
213
|
+
description: "读取可编辑设置 (用户名/HTTP 端口/安全/agent 预设)。敏感字段只回 set 标志。",
|
|
214
|
+
inputSchema: { type: "object", properties: {} },
|
|
215
|
+
execute: wrap(() => { ensureConfigDir(root); return { settings: getSettings(root) }; }),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "ppx.settings.update",
|
|
219
|
+
title: "更新设置",
|
|
220
|
+
description: "更新设置 (patch: {user?, http?, security?, agent?, mcp?, tools?})。热重载立即生效。",
|
|
221
|
+
inputSchema: { type: "object", properties: { patch: { type: "object" } }, required: ["patch"] },
|
|
222
|
+
execute: wrap(async (args) => { ensureConfigDir(root); const settings = updateSettings(root, args.patch || {}); agent.reloadSettings(); return { ok: true, settings }; }),
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
// ---- 任务面板 ----
|
|
226
|
+
{
|
|
227
|
+
name: "ppx.task.templates",
|
|
228
|
+
title: "任务模板列表",
|
|
229
|
+
description: "列出任务面板内置技能模板 (按技能预置步骤), 供新建任务时选用。含 agent 训练评估/会话重命名/提示词方案/代码审查/MCP 合规/技能吸收。",
|
|
230
|
+
inputSchema: { type: "object", properties: {} },
|
|
231
|
+
execute: wrap(() => TASK_TEMPLATES),
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "ppx.task.create",
|
|
235
|
+
title: "创建任务",
|
|
236
|
+
description: "创建任务面板条目 (title/description/steps/template_id)。steps 为步骤标题数组, 每步初始 pending; 也可传 template_id 使用内置模板的步骤。",
|
|
237
|
+
inputSchema: { type: "object", properties: { title: { type: "string" }, description: { type: "string" }, steps: { type: "array", items: { type: "string" } }, template_id: { type: "string", description: "内置模板 id (见 ppx.task.templates)" } }, required: ["title"] },
|
|
238
|
+
execute: wrap((args) => {
|
|
239
|
+
const tpl = args.template_id ? TASK_TEMPLATES.find((t) => t.id === args.template_id) : null;
|
|
240
|
+
const steps = tpl ? tpl.steps : (args.steps || []);
|
|
241
|
+
return taskBoard.create({ title: args.title, description: args.description || (tpl ? tpl.label : ""), steps });
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: "ppx.task.list",
|
|
246
|
+
title: "任务列表",
|
|
247
|
+
description: "列出全部任务 (含每步状态)。等价于任务面板读操作。",
|
|
248
|
+
inputSchema: { type: "object", properties: {} },
|
|
249
|
+
execute: wrap(() => taskBoard.list()),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: "ppx.task.update",
|
|
253
|
+
title: "更新任务",
|
|
254
|
+
description: "更新任务状态或字段: {id, status?} status 为 todo|running|done|failed。",
|
|
255
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, status: { type: "string" } }, required: ["id"] },
|
|
256
|
+
execute: wrap((args) => taskBoard.update(args)),
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
name: "ppx.task.step",
|
|
260
|
+
title: "更新任务步骤",
|
|
261
|
+
description: "更新某任务某步骤: {id, index, status?, detail?} index 从 0 开始, status 为 pending|running|done|failed。",
|
|
262
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, index: { type: "number" }, status: { type: "string" }, detail: { type: "string" } }, required: ["id", "index"] },
|
|
263
|
+
execute: wrap((args) => taskBoard.step(args)),
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
name: "ppx.task.delete",
|
|
267
|
+
title: "删除任务",
|
|
268
|
+
description: "删除任务面板条目。",
|
|
269
|
+
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
|
|
270
|
+
execute: wrap((args) => taskBoard.delete(args)),
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
name: "ppx.task.run",
|
|
274
|
+
title: "运行任务",
|
|
275
|
+
description: "把任务交给 agent 执行: 标 running → 调 agent.chat 处理任务描述 → 全部步骤标 done → 结果回填任务 result。返回 agent 回复。",
|
|
276
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, prompt: { type: "string", description: "给 agent 的执行指令 (默认用任务 title+description)" } }, required: ["id"] },
|
|
277
|
+
execute: wrap(async (args) => {
|
|
278
|
+
const id = String(args.id);
|
|
279
|
+
const t = taskBoard.get(id);
|
|
280
|
+
if (!t) throw new Error("任务不存在");
|
|
281
|
+
taskBoard.update({ id, status: "running" });
|
|
282
|
+
const prompt = str(args.prompt) || `执行任务「${t.title}」: ${t.description || ""}`;
|
|
283
|
+
const reply = await agent.chat(prompt, { sessionKey: "task:" + id });
|
|
284
|
+
// 全部步骤置 done, 任务置 done
|
|
285
|
+
taskBoard.complete(id, reply);
|
|
286
|
+
return { ok: true, id, result: reply };
|
|
287
|
+
}),
|
|
288
|
+
},
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
return { tools, taskBoard };
|
|
292
|
+
}
|
package/src/mcp/client.js
CHANGED
|
@@ -50,6 +50,63 @@ function parseHttpBody(text, contentType) {
|
|
|
50
50
|
} catch { return []; }
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
// ---- x-mcp-header 标注解析 (MCP 2026-07-28) ----
|
|
54
|
+
// 从工具 inputSchema 提取 x-mcp-header 标注: 参数路径 (纯 properties 链) -> header 名。
|
|
55
|
+
// 约束校验 (违反任一 → 返回 null 表示该工具定义非法, 应排除):
|
|
56
|
+
// - header 值非空, 匹配 HTTP token 语法 (RFC 9110 1*tchar)
|
|
57
|
+
// - header 名在 schema 内大小写不敏感唯一
|
|
58
|
+
// - 只允许 primitive 类型 (integer/string/boolean; number 不允许)
|
|
59
|
+
// - 静态可达: 路径只能经过 properties, 不得穿过 items/composition/conditional/$ref
|
|
60
|
+
// - 未经数组/组合/条件/$ref 嵌套的纯 properties 链
|
|
61
|
+
const HTTP_TOKEN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
62
|
+
|
|
63
|
+
function parseXMcpHeaders(toolName, schema) {
|
|
64
|
+
const found = []; // { path, header }
|
|
65
|
+
const seen = new Set(); // header (小写去重)
|
|
66
|
+
const walk = (node, path, depth) => {
|
|
67
|
+
if (!node || typeof node !== "object" || depth > 8) return;
|
|
68
|
+
// 检查当前属性是否有 x-mcp-header
|
|
69
|
+
if (node["x-mcp-header"] !== undefined) {
|
|
70
|
+
const hv = String(node["x-mcp-header"]);
|
|
71
|
+
// 校验: 非空 + HTTP token
|
|
72
|
+
if (!hv || !HTTP_TOKEN.test(hv)) throw new Error(`header 非法: ${JSON.stringify(hv)}`);
|
|
73
|
+
// 校验: 大小写不敏感唯一
|
|
74
|
+
const lk = hv.toLowerCase();
|
|
75
|
+
if (seen.has(lk)) throw new Error(`header 重复: ${hv}`);
|
|
76
|
+
seen.add(lk);
|
|
77
|
+
// 校验: 类型必须 primitive 且非 number
|
|
78
|
+
const type = node.type;
|
|
79
|
+
if (!["integer", "string", "boolean"].includes(type)) throw new Error(`类型不允许: ${type || "(无)"}`);
|
|
80
|
+
found.push({ path: [...path], header: hv });
|
|
81
|
+
}
|
|
82
|
+
// 递归 properties (纯链)
|
|
83
|
+
if (node.properties && typeof node.properties === "object") {
|
|
84
|
+
for (const [k, v] of Object.entries(node.properties)) {
|
|
85
|
+
if (v && typeof v === "object") walk(v, [...path, k], depth + 1);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// 非法穿透: items/oneOf/anyOf/allOf/not/if/then/else/$ref → x-mcp-header 出现在其中 = 非法
|
|
89
|
+
for (const bad of ["items", "oneOf", "anyOf", "allOf", "not", "if", "then", "else", "$ref"]) {
|
|
90
|
+
if (node[bad] !== undefined && hasXMcpHeader(node[bad])) {
|
|
91
|
+
throw new Error(`x-mcp-header 出现在 ${bad} 内 (非法)`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
walk(schema, [], 0);
|
|
97
|
+
return found;
|
|
98
|
+
} catch (e) {
|
|
99
|
+
warn(`[mcp] 工具 ${toolName} x-mcp-header 标注非法: ${e.message}`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasXMcpHeader(node) {
|
|
105
|
+
if (!node || typeof node !== "object") return false;
|
|
106
|
+
if (node["x-mcp-header"] !== undefined) return true;
|
|
107
|
+
return Object.values(node).some((v) => v && typeof v === "object" && hasXMcpHeader(v));
|
|
108
|
+
}
|
|
109
|
+
|
|
53
110
|
// ---- stdio 传输 ----
|
|
54
111
|
// 启动本地服务器子进程, 从 stdout 读换行分隔 JSON, 请求写 stdin。
|
|
55
112
|
class StdioTransport {
|
|
@@ -144,11 +201,12 @@ class HttpTransport {
|
|
|
144
201
|
|
|
145
202
|
async start() { /* 单次 POST 流即可, 无需预开连接 */ }
|
|
146
203
|
|
|
147
|
-
async send(msg) {
|
|
204
|
+
async send(msg, extraHeaders = {}) {
|
|
148
205
|
const headers = {
|
|
149
206
|
"content-type": "application/json",
|
|
150
207
|
"accept": "application/json, text/event-stream",
|
|
151
208
|
...this.headers,
|
|
209
|
+
...extraHeaders,
|
|
152
210
|
};
|
|
153
211
|
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
154
212
|
|
|
@@ -234,7 +292,23 @@ export class McpClient {
|
|
|
234
292
|
|
|
235
293
|
async listTools() {
|
|
236
294
|
const r = await this._request("tools/list", {});
|
|
237
|
-
|
|
295
|
+
const tools = (r && r.tools) || [];
|
|
296
|
+
// v2.6.0: x-mcp-header 客户端支持 — 解析每把工具的 inputSchema 里的 x-mcp-header 标注,
|
|
297
|
+
// 存为 { 参数路径 -> headerName }; 非法标注 (约束见 MCP 2026-07-28 规范) 的工具整体排除
|
|
298
|
+
this._toolHeaders = new Map(); // toolName -> [{ path: string[], header: string }]
|
|
299
|
+
const kept = [];
|
|
300
|
+
for (const t of tools) {
|
|
301
|
+
if (!t || !t.name || !t.inputSchema) { kept.push(t); continue; }
|
|
302
|
+
const parsed = parseXMcpHeaders(t.name, t.inputSchema);
|
|
303
|
+
if (parsed === null) {
|
|
304
|
+
// 非法标注: 排除该工具 (规范: 单个坏工具不影响其他有效工具)
|
|
305
|
+
warn(`[mcp] 工具 ${t.name} 的 x-mcp-header 标注非法, 已排除`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (parsed.length) this._toolHeaders.set(t.name, parsed);
|
|
309
|
+
kept.push(t);
|
|
310
|
+
}
|
|
311
|
+
return kept;
|
|
238
312
|
}
|
|
239
313
|
|
|
240
314
|
// 调用工具 -> 提取纯文本结果 (向后兼容)
|
|
@@ -244,7 +318,33 @@ export class McpClient {
|
|
|
244
318
|
|
|
245
319
|
// 调用工具 -> 返回原始 result (含 content/isError, 供上层判断)
|
|
246
320
|
async callToolRaw(name, args = {}) {
|
|
247
|
-
|
|
321
|
+
const msg = { name, arguments: args };
|
|
322
|
+
// x-mcp-header 镜像: 工具定义里有标注时, 把参数值转成 Mcp-Param-* 头 (仅 HTTP 传输生效)
|
|
323
|
+
const extraHeaders = this._resolveParamHeaders(name, args);
|
|
324
|
+
return this._request("tools/call", msg, extraHeaders);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 解析工具参数 -> Mcp-Param-{Header} HTTP 头 (x-mcp-header 标注)
|
|
328
|
+
// 按规范: 仅 primitive (string/integer/boolean) 且静态可达路径 (纯 properties 链) 可镜像
|
|
329
|
+
_resolveParamHeaders(name, args) {
|
|
330
|
+
const out = {};
|
|
331
|
+
const anns = this._toolHeaders && this._toolHeaders.get(name);
|
|
332
|
+
if (!anns || !args || typeof args !== "object") return out;
|
|
333
|
+
for (const { path, header } of anns) {
|
|
334
|
+
let v = args;
|
|
335
|
+
for (const seg of path) {
|
|
336
|
+
if (v == null || typeof v !== "object") { v = undefined; break; }
|
|
337
|
+
v = v[seg];
|
|
338
|
+
}
|
|
339
|
+
if (v === undefined) continue; // 参数未提供 -> 省略该头
|
|
340
|
+
let sv;
|
|
341
|
+
if (typeof v === "string") sv = v;
|
|
342
|
+
else if (typeof v === "number" && Number.isInteger(v)) sv = String(v);
|
|
343
|
+
else if (typeof v === "boolean") sv = v ? "true" : "false";
|
|
344
|
+
else continue; // 非 primitive 不镜像
|
|
345
|
+
out[`Mcp-Param-${header}`] = sv;
|
|
346
|
+
}
|
|
347
|
+
return out;
|
|
248
348
|
}
|
|
249
349
|
|
|
250
350
|
async listResources() {
|
|
@@ -275,7 +375,7 @@ export class McpClient {
|
|
|
275
375
|
}
|
|
276
376
|
|
|
277
377
|
// ---- 内部 ----
|
|
278
|
-
_request(method, params = {}) {
|
|
378
|
+
_request(method, params = {}, extraHeaders = {}) {
|
|
279
379
|
if (this._dead) return Promise.reject(new Error("MCP 连接已关闭"));
|
|
280
380
|
const id = ++this._nextId;
|
|
281
381
|
const msg = { jsonrpc: "2.0", id, method, params };
|
|
@@ -286,7 +386,7 @@ export class McpClient {
|
|
|
286
386
|
}, this.timeout);
|
|
287
387
|
this._pending.set(id, { resolve, reject, timer });
|
|
288
388
|
Promise.resolve()
|
|
289
|
-
.then(() => this.transport.send(msg))
|
|
389
|
+
.then(() => this.transport.send(msg, extraHeaders))
|
|
290
390
|
.catch((e) => this._settle(id, null, e));
|
|
291
391
|
});
|
|
292
392
|
}
|
|
@@ -373,3 +473,5 @@ export function extractResourceText(result) {
|
|
|
373
473
|
if (typeof result === "string") return result;
|
|
374
474
|
return JSON.stringify(result);
|
|
375
475
|
}
|
|
476
|
+
|
|
477
|
+
export { parseXMcpHeaders, hasXMcpHeader };
|