ppxans-harness 2.4.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.
Files changed (106) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +265 -0
  3. package/bin/ppx-channels.js +3 -0
  4. package/bin/ppx-serve.js +6 -0
  5. package/bin/ppx.js +3 -0
  6. package/config/identity.md +6 -0
  7. package/config/ishiki.md +16 -0
  8. package/config/ppx.json +151 -0
  9. package/package.json +69 -0
  10. package/src/agent/context.js +179 -0
  11. package/src/agent/index.js +717 -0
  12. package/src/agent/prompts.js +107 -0
  13. package/src/aml-server.js +151 -0
  14. package/src/ans/eviction.js +144 -0
  15. package/src/ans/guard.js +120 -0
  16. package/src/ans/lifecycle.js +93 -0
  17. package/src/ans/proactive.js +129 -0
  18. package/src/ans/reward.js +112 -0
  19. package/src/ans/values.js +15 -0
  20. package/src/audit/audit-chain.js +167 -0
  21. package/src/audit/verifier.js +120 -0
  22. package/src/bus/circuit-breaker.js +115 -0
  23. package/src/bus/runtime-bus.js +94 -0
  24. package/src/channels/base.js +35 -0
  25. package/src/channels/feishu.js +127 -0
  26. package/src/channels/http.js +592 -0
  27. package/src/channels/index.js +110 -0
  28. package/src/channels/log.js +29 -0
  29. package/src/channels/wechat-crypto.js +74 -0
  30. package/src/channels/wechat.js +197 -0
  31. package/src/channels-cli.js +124 -0
  32. package/src/cli.js +120 -0
  33. package/src/config/channels.js +170 -0
  34. package/src/config/index.js +224 -0
  35. package/src/config/providers.js +189 -0
  36. package/src/config/settings.js +182 -0
  37. package/src/core/policy.js +272 -0
  38. package/src/core/trace.js +89 -0
  39. package/src/evolve/playbook.js +194 -0
  40. package/src/llm/client.js +446 -0
  41. package/src/llm/dsml.js +74 -0
  42. package/src/llm/embedder.js +35 -0
  43. package/src/llm/fence.js +105 -0
  44. package/src/llm/index.js +4 -0
  45. package/src/llm/retry.js +73 -0
  46. package/src/llm/router.js +98 -0
  47. package/src/mcp/client.js +375 -0
  48. package/src/mcp/index.js +116 -0
  49. package/src/memory/asset-hub.js +131 -0
  50. package/src/memory/canvas.js +131 -0
  51. package/src/memory/compaction.js +28 -0
  52. package/src/memory/experience.js +122 -0
  53. package/src/memory/fact-store.js +699 -0
  54. package/src/memory/failure-episode.js +99 -0
  55. package/src/memory/fork.js +83 -0
  56. package/src/memory/index.js +7 -0
  57. package/src/memory/l0.js +52 -0
  58. package/src/memory/l2.js +131 -0
  59. package/src/memory/l3.js +112 -0
  60. package/src/memory/memory-ticker.js +240 -0
  61. package/src/memory/session.js +398 -0
  62. package/src/mode/blackboard.js +49 -0
  63. package/src/mode/graph.js +41 -0
  64. package/src/mode/index.js +64 -0
  65. package/src/mode/legion.js +51 -0
  66. package/src/mode/plan-exec.js +50 -0
  67. package/src/mode/router.js +40 -0
  68. package/src/orchestrator/agent-worker.js +70 -0
  69. package/src/orchestrator/dag.js +83 -0
  70. package/src/orchestrator/index.js +2 -0
  71. package/src/orchestrator/legion.js +188 -0
  72. package/src/orchestrator/supervisor.js +177 -0
  73. package/src/persona/index.js +29 -0
  74. package/src/plugin/builtin.js +212 -0
  75. package/src/plugin/context.js +79 -0
  76. package/src/plugin/index.js +62 -0
  77. package/src/seam/registry.js +98 -0
  78. package/src/seam/shell.js +55 -0
  79. package/src/selfheal/evolve.js +68 -0
  80. package/src/selfheal/healer.js +167 -0
  81. package/src/selfheal/run.js +9 -0
  82. package/src/server.js +60 -0
  83. package/src/services/learning-service.js +177 -0
  84. package/src/services/memory-health.js +99 -0
  85. package/src/services/memory-service.js +160 -0
  86. package/src/skills/loader.js +150 -0
  87. package/src/skills/verify.js +100 -0
  88. package/src/tools/advanced.js +353 -0
  89. package/src/tools/builtin.js +298 -0
  90. package/src/tools/catalog.js +159 -0
  91. package/src/tools/command-guard.js +112 -0
  92. package/src/tools/custom.js +47 -0
  93. package/src/tools/delegate.js +297 -0
  94. package/src/tools/document.js +253 -0
  95. package/src/tools/governance.js +260 -0
  96. package/src/tools/index.js +11 -0
  97. package/src/tools/methods.js +178 -0
  98. package/src/tools/ocr.js +59 -0
  99. package/src/tools/seam.js +125 -0
  100. package/src/tools/selfmod.js +176 -0
  101. package/src/utils/logger.js +17 -0
  102. package/src/utils/pii.js +42 -0
  103. package/src/utils/store.js +108 -0
  104. package/src/utils/text.js +16 -0
  105. package/src/utils/trace.js +153 -0
  106. package/src/utils/winutf8.js +15 -0
@@ -0,0 +1,159 @@
1
+ // src/tools/catalog.js - 工具注册表 (参考 openhanako tool-catalog + deepseek Capability Seam)
2
+ // 升级: 能力缝三分法(Definition元数据/Provider实现/Consumer策略) + 热挂载(enable/disable/unregister) + 元数据枚举
3
+ // P0 (2026-09-15): 策略订阅者链 + deny-wins 合并 (吸收 Aegis/HookBus 治理语义) ——
4
+ // 安全策略 (命令守卫/免疫闸门/防注入) 挂到工具执行唯一收口, 成为架构不变量而非可选行为
5
+ import { info } from "../utils/logger.js";
6
+ import { normalizeMeta, runWithPolicy, toDescriptor, TOOL_ERROR_PREFIX } from "./seam.js";
7
+
8
+ export { TOOL_ERROR_PREFIX };
9
+
10
+ // ---- Deny-Wins 决策合并 (HookBus consolidate 语义) ----
11
+ // 多个策略订阅者对同一工具调用给出冲突决策时:
12
+ // 任一 deny 一票否决 (取最高优先级 reason); 否则任一 ask → ask; 否则 allow
13
+ // 保证安全策略不能被低优先级 allow 投票覆盖 (纵深防御: 治理/合规/预算可叠加互不干扰)
14
+ export function consolidateDecisions(decisions) {
15
+ const denies = decisions.filter((d) => d && d.decision === "deny");
16
+ if (denies.length > 0) {
17
+ const top = denies.reduce((a, b) => ((a.priority || 0) >= (b.priority || 0) ? a : b));
18
+ return { decision: "deny", reason: top.reason || "策略拒绝", priority: top.priority || 0 };
19
+ }
20
+ const asks = decisions.filter((d) => d && d.decision === "ask");
21
+ if (asks.length > 0) {
22
+ const top = asks.reduce((a, b) => ((a.priority || 0) >= (b.priority || 0) ? a : b));
23
+ return { decision: "ask", reason: top.reason || "需要审批", priority: top.priority || 0 };
24
+ }
25
+ return { decision: "allow", reason: null, priority: 0 };
26
+ }
27
+
28
+ export class ToolCatalog {
29
+ constructor() {
30
+ this.tools = new Map(); // name -> meta (Definition + Provider)
31
+ this.policySubscribers = []; // 策略订阅者: { fn(name,args,ctx)->Decision|null, priority, name }
32
+ }
33
+
34
+ // ---- Definition + Provider 注册 ----
35
+ register(def) {
36
+ if (!def || typeof def.execute !== "function") {
37
+ throw new Error(`工具注册失败: 需 name + execute (got ${def && def.name})`);
38
+ }
39
+ const meta = normalizeMeta(def); // 关键: execute 缺失由 normalizeMeta 的 name 校验兜底
40
+ this.tools.set(meta.name, meta);
41
+ info(`能力已注册: ${meta.name} [${meta.category}/${meta.power}]`);
42
+ return this;
43
+ }
44
+
45
+ // ---- 热挂载: 卸载 ----
46
+ unregister(name) {
47
+ const had = this.tools.delete(name);
48
+ if (had) info(`能力已卸载: ${name}`);
49
+ return had;
50
+ }
51
+
52
+ // ---- 热挂载: 启用/禁用 ----
53
+ enable(name) {
54
+ const t = this.tools.get(name);
55
+ if (!t) return false;
56
+ t.enabled = true;
57
+ return true;
58
+ }
59
+
60
+ disable(name) {
61
+ const t = this.tools.get(name);
62
+ if (!t) return false;
63
+ t.enabled = false;
64
+ return true;
65
+ }
66
+
67
+ // ---- OpenAI 兼容的 tools 格式 (给 LLM 用, 只含启用项) ----
68
+ toOpenAI() {
69
+ return [...this.tools.values()]
70
+ .filter((t) => t.enabled)
71
+ .map((t) => ({
72
+ type: "function",
73
+ function: { name: t.name, description: t.description, parameters: t.parameters },
74
+ }));
75
+ }
76
+
77
+ // ---- 审计: 可选注入审计哈希链 (未注入时零开销, 保持向后兼容) ----
78
+ // 吸收自 ppx-v2: 每次工具调用落一条 append-only + SHA-256 链式记录, 防审计日志被悄悄改写
79
+ setAudit(auditLog) {
80
+ this.audit = auditLog || null;
81
+ return this;
82
+ }
83
+
84
+ // ---- 策略订阅者 (P0): 工具执行唯一收口上的安全策略链 ----
85
+ // fn(name, args, ctx) -> Promise<{decision:'allow'|'deny'|'ask', reason?, priority?}> | null (null/undefined = 弃权)
86
+ // priority: 高者优先 (合并冲突决策时取高优先级 reason); 默认 0
87
+ // 订阅者异常不拖垮工具执行: 记日志并视同弃权 (fail-open), 但可被上层熔断器保护 (见 src/bus/circuit-breaker.js)
88
+ addPolicySubscriber(fn, { priority = 0, name = "" } = {}) {
89
+ if (typeof fn !== "function") throw new Error("策略订阅者需为函数");
90
+ const sub = { fn, priority: Number(priority) || 0, name: name || `policy-${this.policySubscribers.length + 1}` };
91
+ this.policySubscribers.push(sub);
92
+ return () => {
93
+ const i = this.policySubscribers.indexOf(sub);
94
+ if (i >= 0) this.policySubscribers.splice(i, 1);
95
+ };
96
+ }
97
+
98
+ // 未注入策略订阅者时零开销 (空数组循环天然跳过)
99
+ async _runPolicyChain(name, args, ctx) {
100
+ if (!this.policySubscribers.length) return { decision: "allow", reason: null, priority: 0 };
101
+ const results = await Promise.all(this.policySubscribers.map(async (sub) => {
102
+ try {
103
+ const d = await sub.fn(name, args, ctx);
104
+ if (!d || !d.decision) return null;
105
+ return { decision: d.decision, reason: d.reason || null, priority: d.priority ?? sub.priority };
106
+ } catch (e) {
107
+ info(`[policy] 订阅者 ${sub.name} 异常, 视同弃权: ${e?.message || e}`);
108
+ return null;
109
+ }
110
+ }));
111
+ return consolidateDecisions(results.filter(Boolean));
112
+ }
113
+
114
+ // ---- Consumer: 统一策略执行 ----
115
+ async call(name, args, ctx = {}) {
116
+ const meta = this.tools.get(name);
117
+ if (!meta) {
118
+ return `${TOOL_ERROR_PREFIX} 未知工具: ${name}`;
119
+ }
120
+ info(`tool: ${name}(${JSON.stringify(args)})`);
121
+ // P0: 策略链先行 (deny-wins) —— 免疫闸门/命令守卫/防注入在此拦截, 不可被旁路
122
+ const policy = await this._runPolicyChain(name, args, ctx);
123
+ if (policy.decision === "deny") {
124
+ return `${TOOL_ERROR_PREFIX} ${name}: 策略拦截: ${policy.reason || "未授权"}`;
125
+ }
126
+ if (policy.decision === "ask") {
127
+ return `${TOOL_ERROR_PREFIX} ${name}: 需要人工审批: ${policy.reason || "敏感操作"}`;
128
+ }
129
+ if (!this.audit) return runWithPolicy(meta, args, ctx);
130
+ const t0 = Date.now();
131
+ try {
132
+ const r = await runWithPolicy(meta, args, ctx);
133
+ const failed = typeof r === "string" && r.startsWith(TOOL_ERROR_PREFIX);
134
+ this.audit.append({ tool: name, args, ok: !failed, error: failed ? String(r).slice(0, 200) : null, ms: Date.now() - t0 });
135
+ return r;
136
+ } catch (e) {
137
+ this.audit.append({ tool: name, args, ok: false, error: e?.message || String(e), ms: Date.now() - t0 });
138
+ throw e;
139
+ }
140
+ }
141
+
142
+ has(name) {
143
+ return this.tools.has(name);
144
+ }
145
+
146
+ // 元数据查询 (v1.6.0 第四刀: 超时重试需要知道工具是否幂等/超时预算)
147
+ metaOf(name) {
148
+ return this.tools.get(name) || null;
149
+ }
150
+
151
+ list() {
152
+ return [...this.tools.keys()];
153
+ }
154
+
155
+ // ---- 元数据枚举 (供 selfmod / 追踪) ----
156
+ listDetailed() {
157
+ return [...this.tools.values()].map(toDescriptor);
158
+ }
159
+ }
@@ -0,0 +1,112 @@
1
+ // src/tools/command-guard.js - 命令守卫 (吸收 Hermes Agent approval 机制)
2
+ // 三层防线:
3
+ // 1. 用户 deny 规则 (config.security.deny, glob 风格) — 最高优先级, 即使 allow_all 也拦
4
+ // 2. 硬黑名单 HARD_BLOCK (rm -rf /、fork bomb、写裸设备、管道到 shell 等) — allow_all 也拦
5
+ // 3. 常规高危黑名单 DEFAULT_DENY (继承皮皮虾 P0) — allow_all 放行
6
+ // 4. 白名单前缀 (allow_all=false 时) — 只放行已知安全命令前缀
7
+ // 反混淆: normalizeCommand 先去引号再规范化空白, 防 `rm ""-rf` / `bash <(curl)` 引号技巧绕过
8
+
9
+ // ---- 反混淆规范化: 仅用于检测, 不用于实际执行 ----
10
+ export function normalizeCommand(cmd) {
11
+ let s = String(cmd || "");
12
+ s = s.replace(/["'`]/g, ""); // 去引号防绕过
13
+ s = s.replace(/\s+/g, " ").trim(); // 合并空白
14
+ return s;
15
+ }
16
+
17
+ // ---- 硬黑名单: allow_all 也无法放行 (破坏宿主 / 不可逆 / 远程代码落地执行) ----
18
+ // v1.0.9: rm 类正则去掉行首/符号前缀限制 — `env rm --no-preserve-root /` 等前缀变体 (sudo/env/&&) 曾绕过
19
+ export const HARD_BLOCK = [
20
+ { pattern: /rm\s+(-[a-z]*r[a-z]*\s+)*--?no-preserve-root(\s|$)/i, reason: "rm --no-preserve-root 破坏根目录" },
21
+ { pattern: /rm\s+(-[a-z]*r[a-z]*\s+)*\/\s*$/i, reason: "rm -rf / 类删除根目录" },
22
+ { pattern: /:\s*\(\s*\)\s*\{\s*:\s*\|/, reason: "fork bomb 进程炸弹" },
23
+ { pattern: /mkfs[.\s][^\n;]*\/dev\/(sd[a-z]|nvme[0-9])/i, reason: "格式化磁盘设备" },
24
+ { pattern: /dd\s+[^\n;]*of=\/dev\/(sd[a-z]|nvme[0-9])/i, reason: "dd 写裸磁盘设备" },
25
+ { pattern: /(curl|wget)\s+[^\s|;&]+\s*\|[\s]*(ba|z|k)?sh/i, reason: "管道下载内容到 shell 执行 (不可信代码)" },
26
+ { pattern: /(ba|z|k)?sh\s*<\s*\(\s*(curl|wget)/i, reason: "进程替换执行远程内容" },
27
+ ];
28
+
29
+ // ---- 常规高危黑名单 (继承皮皮虾 P0: 删除/格式化/关机/强杀/强制推送等) ----
30
+ export const DEFAULT_DENY = [
31
+ /delete|erase|rmdir|rd \/s|deltree/i,
32
+ /format\s/i, /mkfs/i, /fdisk/i, /diskpart/i, /shutdown/i,
33
+ /restart/i, /reboot/i, /halt/i, /poweroff/i,
34
+ /reg\s+delete/i, /taskkill/i, /pkill/i, /kill\s+-9/i,
35
+ /rm\s+-rf/i, /rm\s+-fr/i,
36
+ /curl|wget|Invoke-WebRequest|iwr/i,
37
+ /git\s+push.*--force/i, /git\s+reset.*--hard/i,
38
+ ];
39
+
40
+ export const DEFAULT_ALLOW_PREFIX = [
41
+ "git", "npm", "npx", "yarn", "pnpm", "node", "python", "python3",
42
+ "ls", "dir", "pwd", "cat", "type", "echo", "head", "tail", "grep",
43
+ "find", "wc", "cp", "copy", "mv", "move", "mkdir", "touch", "tree",
44
+ "cd", "help", "ipconfig", "netstat", "tasklist", "whoami", "date", "time", "tsc",
45
+ ];
46
+
47
+ // 命中拦截后附加的指引: 明确告知不要重试/改写绕过 (Hermes approval 同款约束)
48
+ export const DENY_HINT = " 命中后不要重试或改写命令绕过 — 确需执行请让用户调整 security 配置。";
49
+
50
+ // glob 风格规则 ('git push --force*') -> 正则
51
+ export function globToRegExp(glob) {
52
+ let s = String(glob || "").trim();
53
+ if (!s) return null;
54
+ s = s.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
55
+ try { return new RegExp("^" + s + "$", "i"); } catch { return null; }
56
+ }
57
+
58
+ // 统一检查入口:
59
+ // opts = { allowAll|allow_all, allowPrefix, denyList, deny, hardBlock }
60
+ // v1.0.9: 兼容 snake 配置键 allow_all (config.security.allow_all 是 snake, 原只认 camel 导致 allow_all=true 永不生效)
61
+ // 返回 { ok: true, normalized } | { ok: false, reason, hard }
62
+ export function checkCommand(cmd, opts = {}) {
63
+ const normalized = normalizeCommand(cmd);
64
+ if (!normalized) return { ok: false, hard: false, reason: "空命令" };
65
+
66
+ // 1. 用户 deny 规则 (最高优先级, allow_all 也拦)
67
+ const userDeny = (opts && opts.deny) || [];
68
+ for (const d of userDeny) {
69
+ const re = d instanceof RegExp ? d : globToRegExp(d);
70
+ if (re && re.test(normalized)) {
71
+ return { ok: false, hard: true, reason: "用户 deny 规则拦截: " + String(d).slice(0, 60) };
72
+ }
73
+ }
74
+
75
+ // 2. 硬黑名单 (allow_all 也拦)
76
+ const hardBlock = (opts && opts.hardBlock) || HARD_BLOCK;
77
+ for (const { pattern, reason } of hardBlock) {
78
+ if (pattern.test(normalized)) return { ok: false, hard: true, reason: "硬黑名单拦截: " + reason };
79
+ }
80
+
81
+ // 3. 常规高危黑名单 (allow_all 放行)
82
+ const deny = (opts && opts.denyList) || DEFAULT_DENY;
83
+ for (const re of deny) {
84
+ if (re.test(normalized)) return { ok: false, hard: false, reason: "命令被拒绝: 命中高危黑名单 (delete/format/shutdown/curl等)" };
85
+ }
86
+
87
+ // 4. 白名单前缀 (allow_all=false 时)
88
+ const allowAll = !!(opts && (opts.allowAll || opts.allow_all));
89
+ if (!allowAll) {
90
+ const allowPrefix = (opts && opts.allowPrefix) || DEFAULT_ALLOW_PREFIX;
91
+ const first = normalized.split(/[\s|&;>]+/)[0];
92
+ const hit = allowPrefix.some((a) => first.toLowerCase().replace(/\.exe$/i, "") === a.toLowerCase());
93
+ if (!hit) {
94
+ return { ok: false, hard: false, reason: `命令不在白名单: ${first}. 允许: git/npm/node/python/cat/cp/mkdir 等, 或设置 security.allow_all.` };
95
+ }
96
+ }
97
+
98
+ return { ok: true, normalized };
99
+ }
100
+
101
+ // 兼容导出 (旧 isDeniedCommand 语义: 只查常规高危 + 硬黑名单 + 用户 deny, 不看白名单)
102
+ export function isDeniedCommand(cmd, options) {
103
+ return !checkCommand(cmd, { ...(options || {}), allowAll: true }).ok;
104
+ }
105
+
106
+ // 兼容导出 (旧 isAllowedCommand 语义: allow_all 直接放行, 否则查前缀白名单; 不查 deny)
107
+ export function isAllowedCommand(cmd, options) {
108
+ if (options && (options.allowAll || options.allow_all)) return true;
109
+ const allowPrefix = (options && options.allowPrefix) || DEFAULT_ALLOW_PREFIX;
110
+ const first = String(cmd || "").trim().split(/[\s|&;>]+/)[0];
111
+ return allowPrefix.some((a) => first.toLowerCase().replace(/\.exe$/i, "") === a.toLowerCase());
112
+ }
@@ -0,0 +1,47 @@
1
+ // src/tools/custom.js - 用户自定义工具注册
2
+ // 目标: 用户不改源码即可扩展 agent 能力。
3
+ // 扫描 <customDir>/*.cjs (或 .js), 每个文件导出工具定义 { name, description, parameters, execute },
4
+ // 用 ToolCatalog.register 注册, 与内置工具同权 (可被 LLM 调用、可 enable/disable)。
5
+ // 示例 (custom-tools/hello.cjs):
6
+ // module.exports = {
7
+ // name: "hello",
8
+ // description: "返回问候语。",
9
+ // parameters: { type: "object", properties: { name: { type: "string" } }, required: [] },
10
+ // execute: async (args) => `你好, ${args.name || "世界"}!`,
11
+ // };
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import { createRequire } from "node:module";
15
+ import { info, warn } from "../utils/logger.js";
16
+
17
+ function isToolDef(v) {
18
+ return v && typeof v === "object" && typeof v.name === "string" && v.name && typeof v.execute === "function";
19
+ }
20
+
21
+ // 同步注册 (构造函数内可用): 用 createRequire 加载 CommonJS 工具文件
22
+ export function registerCustomTools(catalog, customDir) {
23
+ if (!customDir || !fs.existsSync(customDir)) return 0;
24
+ const require = createRequire(import.meta.url);
25
+ let files = [];
26
+ try { files = fs.readdirSync(customDir); } catch { return 0; }
27
+ const candidates = files.filter((f) => f.endsWith(".cjs") || f.endsWith(".js")).sort();
28
+
29
+ let count = 0;
30
+ for (const f of candidates) {
31
+ const full = path.join(customDir, f);
32
+ try {
33
+ const mod = require(full);
34
+ const def = mod && mod.default ? mod.default : mod;
35
+ if (!isToolDef(def)) {
36
+ warn(`[custom-tools] ${f} 需导出 { name, description, parameters, execute }, 跳过`);
37
+ continue;
38
+ }
39
+ catalog.register(def);
40
+ count += 1;
41
+ } catch (e) {
42
+ warn(`[custom-tools] 加载 ${f} 失败: ${e.message}`);
43
+ }
44
+ }
45
+ if (count) info(`[custom-tools] 已注册 ${count} 个自定义工具`);
46
+ return count;
47
+ }
@@ -0,0 +1,297 @@
1
+ // src/tools/delegate.js - 多 agent 自主协作工具 (spawn_agent)
2
+ // 让 agent 在工具循环里自主决定 spawn 子 agent 分工 (ANS 神经元中枢的最小实现):
3
+ // - 主 agent 分析任务 → 认为需要专门角色/并行/隔离 → 调 spawn_agent
4
+ // - 子 agent: 独立会话目录 (隔离) + 共享全局经验库 (ANS 全局记忆)
5
+ // - 子 agent 复用懒建军团 (agent._legion), 不重复 spawn 进程
6
+ // v2 (2026-08-17): 吸收 Anthropic 多智能体研究洞察
7
+ // - 并行任务: tasks 数组并行派发多个子 agent (专才 + 并行化)
8
+ // - 差异化上下文: perspectives 注入每个子 agent 专属视角, 对抗同质失败
9
+ // - 仲裁聚合: arbitrate 时主 agent LLM 综合各子结果做最终裁决
10
+ // v3 (2026-08-17): 吸收 Superpowers SDD 子代理驱动开发
11
+ // - review 循环: 实施者 -> 只读审查者 -> (发现问题 -> 修复 -> 复审) * fixRounds -> 熔断停放
12
+ // - 审查者只读 (PPX_AGENT_READONLY), 实施者修复复用原进程 (上下文完整)
13
+ // - 账本 ledger: 全程记录审查/修复轮次, 熔断时未决发现交主 agent 裁定
14
+ import path from "node:path";
15
+ import { Legion } from "../orchestrator/legion.js";
16
+
17
+ const DELEGATE_TIMEOUT_MS = 120000; // 子任务最长等待 (防卡死主 agent 工具循环)
18
+
19
+ // ---- SDD review 循环: 纯函数 (可测) ----
20
+
21
+ // 严重级标签映射 (v1.0.8): 内部表示 Critical/Important/Minor, 展示用中文, 解析兼容中英
22
+ export function severityLabel(s) {
23
+ if (s === "Critical" || s === "严重" || s === "P0") return "严重";
24
+ if (s === "Important" || s === "重要" || s === "P1") return "重要";
25
+ return "次要";
26
+ }
27
+ function severityOf(token) {
28
+ if (token === "严重" || token === "P0") return "Critical";
29
+ if (token === "重要" || token === "P1") return "Important";
30
+ if (token === "Critical" || token === "Important") return token; // 英文 token 原样映射
31
+ return "Minor";
32
+ }
33
+
34
+ // 解析审查者输出 -> 发现列表 [{ severity, finding }]
35
+ // 期望格式: 每行 "[严重|重要|次要] 描述" (兼容英文 Critical/Important/Minor), 无发现为 "(无发现)"
36
+ export function parseReviewFindings(text) {
37
+ if (!text) return [];
38
+ const out = [];
39
+ const re = /\[(严重|重要|次要|Critical|Important|Minor)\]\s*([^\n]+)/g;
40
+ let m;
41
+ while ((m = re.exec(String(text)))) {
42
+ const finding = m[2].trim();
43
+ if (finding) out.push({ severity: severityOf(m[1]), finding });
44
+ }
45
+ return out;
46
+ }
47
+
48
+ // 是否需要触发修复: 有 Critical/Important
49
+ export function needsFix(findings) {
50
+ return findings.some((f) => f.severity === "Critical" || f.severity === "Important");
51
+ }
52
+
53
+ // 组装审查者提示词 (只读审查契约)
54
+ export function buildReviewPrompt(workDesc, judge, perspective) {
55
+ const p = perspective ? `\n【审查视角】${perspective}` : "";
56
+ return `你是只读审查者。审查下面"产出"中实施者的结果, 找出问题。严格遵守: 只读审查, 禁止修改/写入任何文件, 禁止执行命令。
57
+
58
+ 【任务要求】${workDesc}
59
+ 【审查准则】${judge || "对照任务要求检查: 功能正确性 / 需求满足度 / 边界情况 / 明显风险"}${p}
60
+
61
+ 【产出】
62
+ ${"<产出内容>"}
63
+
64
+ 输出发现清单, 每行一条, 格式 "[严重级] 描述", 严重级用:
65
+ - [严重] 功能错误 / 需求未满足 / 会导致失败
66
+ - [重要] 质量缺陷 / 边界情况 / 明显风险
67
+ - [次要] 小改进 / 风格
68
+ 没有任何问题时只输出一行 "(无发现)"。不要输出其他内容。`;
69
+ }
70
+
71
+ // 修复提示词: 把未决发现交给实施者修复
72
+ export function buildFixPrompt(task, findings) {
73
+ const open = findings.filter((f) => f.severity === "Critical" || f.severity === "Important");
74
+ const lines = open.map((f) => `[${severityLabel(f.severity)}] ${f.finding}`).join("\n");
75
+ return `上一轮产出存在以下 ${open.length} 项问题, 请逐一修复 (只解决这些问题, 不要引入新问题):\n${lines}\n\n原始任务: ${task}`;
76
+ }
77
+
78
+ // ---- 纯函数: 组装多子结果 + 视角 (仲裁输入) ----
79
+ export function buildArbitrationInput(tasks, results, perspectives) {
80
+ return tasks.map((t, i) => {
81
+ const p = perspectives?.[i] ? ` (视角: ${perspectives[i]})` : "";
82
+ return `【子任务${i + 1}${p}】${t}\n【结果${i + 1}】${String(results[i] || "").slice(0, 2000)}`;
83
+ }).join("\n\n");
84
+ }
85
+
86
+ // 主 agent 聚合评审 (仲裁者模式): 综合各子结果, 输出最终裁决
87
+ // 无 LLM 或评审失败时退化为简单拼接 (不阻塞)
88
+ export async function arbitrate(agent, tasks, results, perspectives, judge) {
89
+ const input = buildArbitrationInput(tasks, results, perspectives);
90
+ const system = "你是多 agent 结果的仲裁者。综合各方结果, 识别分歧与共识, 给出一个整合后的最终答案。直接输出最终答案, 不要复述过程。";
91
+ const user = input + (judge ? `\n\n【评审要求】${judge}` : "");
92
+ try {
93
+ const r = await agent.llm.chat([
94
+ { role: "system", content: system },
95
+ { role: "user", content: user.slice(0, 6000) },
96
+ ]);
97
+ const text = String(r?.content || "").trim();
98
+ return text || `(仲裁无输出)\n\n${input}`;
99
+ } catch (e) {
100
+ return `(仲裁失败, 直出各方结果)\n\n${input}`;
101
+ }
102
+ }
103
+
104
+ // 带超时等待 (防子 agent 卡死) — 定时器必须清理, 否则快速 resolve 后仍挂起 120s 阻止进程退出
105
+ function withTimeout(p, ms, label) {
106
+ let timer;
107
+ const timeout = new Promise((_, rej) => {
108
+ timer = setTimeout(() => rej(new Error(`${label}超时 (${ms / 1000}s)`)), ms);
109
+ });
110
+ return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
111
+ }
112
+
113
+ // ---- SDD review 循环: 实施 -> 只读审查 -> (修复 -> 复审) * N -> 熔断 ----
114
+ // 返回: 通过时 "✅ 审查通过..." + 产出; 熔断时 "⚠️ 未决发现停放..." + 产出
115
+ // namePrefix: 多任务 review 时传入 `${role}_${ts}_${i}`, 保证每对 agent 名唯一
116
+ async function runReviewLoop({ agent, L, task, perspective, role, judge, fixRounds, namePrefix = null }) {
117
+ const ts = Date.now().toString(36);
118
+ const implName = namePrefix ? `${namePrefix}_impl` : `${role}_impl_${ts}`;
119
+ const revName = namePrefix ? `${namePrefix}_rev` : `${role}_rev_${ts}`;
120
+ const mkOpts = (n) => ({ dataDir: path.join(agent.dataDir, "legion", n), globalDataDir: agent.globalDataDir });
121
+ L.spawnAgent(implName, mkOpts(implName));
122
+ // 审查者只读: PPX_AGENT_READONLY=1 时 worker 禁用全部修改/执行工具
123
+ L.spawnAgent(revName, { ...mkOpts(revName), env: { PPX_AGENT_READONLY: "1" } });
124
+ if (agent.lifecycle) agent.lifecycle.reproduce(2);
125
+
126
+ const max = (() => { // v1.0.8: fix_rounds=0 应能设 0 (原 `|| 3` 把 0 变 3)
127
+ const parsed = Number(fixRounds);
128
+ return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 5) : 3;
129
+ })(); // 熔断上限 (Superpowers 5 轮, 默认 3 控成本; 0 = 不修复直接停放)
130
+ const ledger = [];
131
+ let findings = [];
132
+ let result = "";
133
+
134
+ // 1. 实施
135
+ try {
136
+ const r = await withTimeout(L.send(implName, { type: "chat", message: task, perspective }, { timeout: DELEGATE_TIMEOUT_MS + 5000 }), DELEGATE_TIMEOUT_MS, "实施");
137
+ result = String(r?.reply || "").trim() || "(实施者无回复)";
138
+ } catch (e) {
139
+ return `[工具错误] spawn_agent(review): 实施失败: ${e.message}`;
140
+ }
141
+ ledger.push({ round: 0, step: "implement" });
142
+
143
+ // 2. 审查 + 修复循环
144
+ let round = 0;
145
+ while (true) {
146
+ const reviewText = await (async () => {
147
+ try {
148
+ const r = await withTimeout(L.send(revName, { type: "chat", message: buildReviewPrompt(task, judge, perspective) + `\n\n【产出】\n${result.slice(0, 6000)}`, perspective }, { timeout: DELEGATE_TIMEOUT_MS + 5000 }), DELEGATE_TIMEOUT_MS, "审查");
149
+ return String(r?.reply || "");
150
+ } catch (e) {
151
+ return `[Critical] 审查者不可用: ${e.message}`;
152
+ }
153
+ })();
154
+ findings = parseReviewFindings(reviewText);
155
+ ledger.push({ round, step: "review", findings });
156
+ if (!needsFix(findings)) break; // 通过
157
+ if (round >= max) break; // 熔断
158
+ round++;
159
+ try {
160
+ const r = await withTimeout(L.send(implName, { type: "chat", message: buildFixPrompt(task, findings), perspective }, { timeout: DELEGATE_TIMEOUT_MS + 5000 }), DELEGATE_TIMEOUT_MS, `修复第${round}轮`);
161
+ result = String(r?.reply || "").trim() || "(实施者无回复)";
162
+ } catch (e) {
163
+ ledger.push({ round, step: "fix", error: e.message });
164
+ break;
165
+ }
166
+ ledger.push({ round, step: "fix" });
167
+ }
168
+
169
+ // 3. 汇总: 通过 or 熔断停放 (账本交主 agent 裁定)
170
+ const open = findings.filter((f) => f.severity === "Critical" || f.severity === "Important");
171
+ if (open.length) {
172
+ return `⚠️ 审查未通过: 达到修复上限 (${max} 轮), 以下 ${open.length} 项未决发现已停放, 请主 agent 裁定是否接受当前产出:\n`
173
+ + open.map((f) => `- [${severityLabel(f.severity)}] ${f.finding}`).join("\n")
174
+ + `\n\n当前产出:\n${result}`;
175
+ }
176
+ const summary = findings.length
177
+ ? findings.map((f) => `[${severityLabel(f.severity)}]`).join(" ")
178
+ : "无";
179
+ return `✅ 审查通过 (审查发现: ${summary})\n\n${result}`;
180
+ }
181
+
182
+ export function registerDelegateTools(catalog, _opts = {}) {
183
+ catalog.register({
184
+ name: "spawn_agent",
185
+ description: "派生子 agent 处理子任务并等待结果。适合需要专门角色、并行、或隔离执行的任务 (如数据分析、代码审查、多角度论证)。子 agent 共享全局经验库。支持: 单个 task; 或 tasks 数组并行派发多个子 agent + perspectives 差异化视角; arbitrate=true 时主 agent 仲裁聚合各方结果; review=true 时走 SDD 审查循环: 实施者干活 -> 只读审查者挑问题 -> 修复 -> 复审, 达上限熔断停放交主 agent 裁定 (单任务直接审查; 多任务每个子任务独立一对实施+审查, 可配 arbitrate 聚合)。",
186
+ parameters: {
187
+ type: "object",
188
+ properties: {
189
+ task: { type: "string", description: "单个子任务描述 (清晰完整, 含上下文); 与 tasks 二选一" },
190
+ tasks: { type: "array", items: { type: "string" }, description: "并行子任务列表 (每个子 agent 一个), 适合多角度论证/并行处理; 与 task 二选一" },
191
+ perspectives: { type: "array", items: { type: "string" }, description: "差异化视角列表, 与 tasks 一一对应, 注入每个子 agent 专属视角 (对抗同质失败), 可缺省" },
192
+ role: { type: "string", description: "子 agent 角色名 (如 数据分析师/代码审查员), 默认 helper" },
193
+ arbitrate: { type: "boolean", description: "是否由主 agent 仲裁聚合所有子结果 (并行/多任务 review 时推荐), 默认 false 直接返回拼接结果" },
194
+ judge: { type: "string", description: "仲裁评审指令 (arbitrate=true 时生效, 如 找出最可靠结论/合并去重); review=true 时为审查准则, 可缺省" },
195
+ review: { type: "boolean", description: "SDD 审查循环: 实施者 -> 只读审查者 -> 发现问题自动修复复审, 达上限熔断, 默认 false。单 task 与多 tasks 均支持" },
196
+ fix_rounds: { type: "number", description: "审查循环最大修复轮数 (review=true 时生效, 默认 3, 上限 5)" },
197
+ },
198
+ },
199
+ execute: async (args, ctx) => {
200
+ const agent = ctx?.agent;
201
+ if (!agent) return "[工具错误] spawn_agent: 无 agent 上下文";
202
+ // 先校验参数, 再校验环境 (输入校验优先)
203
+ let tasks = null;
204
+ if (Array.isArray(args.tasks) && args.tasks.length) {
205
+ tasks = args.tasks.map((t) => String(t).slice(0, 4000));
206
+ } else if (args.task) {
207
+ tasks = [String(args.task).slice(0, 4000)];
208
+ }
209
+ if (!tasks) return "[工具错误] spawn_agent: 需要 task 或 tasks";
210
+ if (!agent.llm) return "[工具错误] spawn_agent: 主 agent 未配置模型, 无法委派";
211
+ // 懒建军团 (复用已有, 避免重复 spawn 进程)
212
+ let L = agent._legion;
213
+ if (!L) { L = new Legion(); agent._legion = L; }
214
+ const role = String(args.role || "helper").replace(/[^\w-]/g, "_").slice(0, 24);
215
+
216
+ const perspectives = Array.isArray(args.perspectives) ? args.perspectives.map((p) => String(p)).slice(0, tasks.length) : [];
217
+
218
+ try {
219
+ // SDD review 循环: 实施 -> 审查 -> 修复 -> 熔断
220
+ // 单任务: 直接跑; 多任务: 每个任务独立一对 (实施者+只读审查者), 并行跑, 可仲裁聚合
221
+ if (args.review) {
222
+ const prefix = `${role}_${Date.now().toString(36)}`;
223
+ if (tasks.length === 1) {
224
+ return await runReviewLoop({
225
+ agent, L, task: tasks[0],
226
+ perspective: perspectives[0],
227
+ role, judge: args.judge,
228
+ fixRounds: args.fix_rounds,
229
+ namePrefix: `${prefix}_0`,
230
+ });
231
+ }
232
+ // 多任务: 并行各任务 review, 各自独立 (agent 名唯一, 不冲突)
233
+ const settled = await Promise.all(tasks.map(async (task, i) => {
234
+ try {
235
+ return await runReviewLoop({
236
+ agent, L, task,
237
+ perspective: perspectives[i],
238
+ role, judge: args.judge,
239
+ fixRounds: args.fix_rounds,
240
+ namePrefix: `${prefix}_${i}`,
241
+ });
242
+ } catch (e) {
243
+ return `[子任务${i + 1} review 失败] ${e.message}`;
244
+ }
245
+ }));
246
+ if (args.arbitrate) {
247
+ return await arbitrate(agent, tasks, settled, perspectives, args.judge);
248
+ }
249
+ return tasks.map((t, i) => {
250
+ const p = perspectives?.[i] ? ` (${perspectives[i]})` : "";
251
+ return `【子任务${i + 1}${p}】${t}\n${settled[i]}`;
252
+ }).join("\n\n");
253
+ }
254
+ // 并行 spawn 子 agent: 每个独立数据目录 + 独立视角
255
+ const names = tasks.map((_, i) => `${role}_${i}_${Date.now().toString(36)}`);
256
+ for (const n of names) {
257
+ L.spawnAgent(n, {
258
+ dataDir: path.join(agent.dataDir, "legion", n),
259
+ globalDataDir: agent.globalDataDir,
260
+ });
261
+ }
262
+ // 生命周期: 繁衍计数 (ANS: reproducing)
263
+ if (agent.lifecycle) agent.lifecycle.reproduce(names.length);
264
+
265
+ // 并行派发, 全部等结果 (各自独立超时)
266
+ const settled = await Promise.all(tasks.map(async (task, i) => {
267
+ try {
268
+ const reply = await withTimeout(
269
+ L.send(names[i], { type: "chat", message: task, perspective: perspectives[i] }, { timeout: DELEGATE_TIMEOUT_MS + 5000 }),
270
+ DELEGATE_TIMEOUT_MS,
271
+ `子任务${i + 1}`
272
+ );
273
+ return { ok: true, reply: reply.reply || "(子 agent 无回复)" };
274
+ } catch (e) {
275
+ return { ok: false, reply: `[子任务${i + 1}失败] ${e.message}` };
276
+ }
277
+ }));
278
+ const results = settled.map((s) => s.reply);
279
+
280
+ // 单任务: 保持旧行为, 直接返回子 agent 回复
281
+ if (tasks.length === 1) return results[0];
282
+
283
+ // 多任务: 有 arbitrate 走主 agent 仲裁聚合, 否则拼接各方结果
284
+ if (args.arbitrate) {
285
+ const out = await arbitrate(agent, tasks, results, perspectives, args.judge);
286
+ return out;
287
+ }
288
+ return tasks.map((t, i) => {
289
+ const p = perspectives?.[i] ? ` (${perspectives[i]})` : "";
290
+ return `【子任务${i + 1}${p}】${t}\n${results[i]}`;
291
+ }).join("\n\n");
292
+ } catch (e) {
293
+ return `[工具错误] spawn_agent: ${e.message}`;
294
+ }
295
+ },
296
+ });
297
+ }