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,41 @@
1
+ // src/mode/graph.js - Graph / Workflow 模式
2
+ // 显式工作流编排: 按节点顺序执行, 每个节点走 react 工具循环, 结果 checkpoint 到会话日志。
3
+ // 适合: 企业级、可审计、可恢复的确定性流程。当前为顺序 DAG, 未来可扩展依赖/并行。
4
+ import { buildMessages } from "./index.js";
5
+
6
+ // 归一化 workflow 节点: 接受字符串数组或 { name, task } 对象数组
7
+ export function normalizeNodes(workflow) {
8
+ if (!Array.isArray(workflow)) return [];
9
+ return workflow.map((node) => {
10
+ if (typeof node === "string") return { name: node, task: node };
11
+ return { name: String(node.name || node.task || ""), task: String(node.task || node.name || "") };
12
+ }).filter((n) => n.task);
13
+ }
14
+
15
+ export async function graphExecutor(agent, userMsg, { sessionKey = "default", workflow = null } = {}) {
16
+ if (!agent.llm) {
17
+ return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
18
+ }
19
+ // workflow 来源: opts 传入 > config.agent.workflow > 默认单节点
20
+ const raw = workflow || (agent.config.agent && agent.config.agent.workflow) || [userMsg];
21
+ const nodes = normalizeNodes(raw);
22
+ if (!nodes.length) {
23
+ const messages = await buildMessages(agent, userMsg, sessionKey);
24
+ return agent._llmWithFallback(messages);
25
+ }
26
+
27
+ const base = await buildMessages(agent, userMsg, sessionKey);
28
+ const results = [];
29
+ for (const node of nodes) {
30
+ let r;
31
+ try {
32
+ r = await agent._llmWithFallback([...base, { role: "user", content: `[节点: ${node.name}] ${node.task}` }]);
33
+ } catch (e) {
34
+ r = `(节点 ${node.name} 失败: ${e.message})`;
35
+ }
36
+ // checkpoint: 节点结果落会话日志, 崩溃后可恢复/审计
37
+ try { agent.sessionStore.append(sessionKey, "tool/result", { content: `[workflow:${node.name}] ${r}` }); } catch {}
38
+ results.push(`节点[${node.name}]:\n${r}`);
39
+ }
40
+ return results.join("\n\n");
41
+ }
@@ -0,0 +1,64 @@
1
+ // src/mode/index.js - Agent 模式注册表 (编排策略可插拔)
2
+ // 把「怎么编排」从 chat() 里抽出来, 每种模式是一个 executor(agent, userMsg, opts) -> reply。
3
+ // 内置: react(工具循环) / single(纯对话) / plan-exec / router / blackboard / graph / legion。
4
+ // 默认 react; 用 config.agent.mode 或 chat({ mode }) 切换。
5
+
6
+ export class ModeRegistry {
7
+ constructor() {
8
+ this.modes = new Map(); // name -> executor
9
+ }
10
+
11
+ register(name, executor) {
12
+ if (!name || typeof executor !== "function") throw new Error("模式注册失败: 需 name + executor 函数");
13
+ this.modes.set(name, executor);
14
+ return this;
15
+ }
16
+
17
+ has(name) { return this.modes.has(name); }
18
+ get(name) { return this.modes.get(name); }
19
+ list() { return [...this.modes.keys()]; }
20
+
21
+ // 执行某模式: executor(agent, userMsg, opts) -> reply
22
+ async run(name, agent, userMsg, opts = {}) {
23
+ const exe = this.modes.get(name);
24
+ if (!exe) throw new Error(`[模式] 未知模式: ${name} (可用: ${this.list().join(", ")})`);
25
+ return exe(agent, userMsg, opts);
26
+ }
27
+ }
28
+
29
+ // 组装 [system + 历史 + 当前] 消息 (含防重复), 供各 executor 复用
30
+ export async function buildMessages(agent, userMsg, sessionKey = "default") {
31
+ const system = agent._context(userMsg);
32
+ const history = await agent._loadHistory(sessionKey);
33
+ const messages = [{ role: "system", content: system }, ...history];
34
+ const hist = agent._getSession(sessionKey);
35
+ const isRepeat = hist[hist.length - 1]?.role === "user" && hist[hist.length - 1].content === String(userMsg);
36
+ if (!isRepeat) messages.push({ role: "user", content: agent._userContent(userMsg) });
37
+ return messages;
38
+ }
39
+
40
+ // ---- 内置模式 ----
41
+
42
+ // ReAct: 推理-行动-观察循环 (带工具, 默认模式)
43
+ async function reactExecutor(agent, userMsg, { sessionKey = "default" } = {}) {
44
+ if (!agent.llm) {
45
+ return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider,仅本地记忆 + 工具模式 (配置见 docs/QUICKSTART.md 第 3 节)。";
46
+ }
47
+ const messages = await buildMessages(agent, userMsg, sessionKey);
48
+ return agent._llmWithFallback(messages);
49
+ }
50
+
51
+ // 单Agent: 纯 LLM 对话, 不挂工具 (省 token, 适合纯问答/闲聊)
52
+ async function singleExecutor(agent, userMsg, { sessionKey = "default" } = {}) {
53
+ if (!agent.llm) return "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
54
+ const messages = await buildMessages(agent, userMsg, sessionKey);
55
+ const r = await agent.llm.chat(messages);
56
+ return r.content;
57
+ }
58
+
59
+ // 注册内置模式
60
+ export function registerDefaultModes(registry) {
61
+ registry.register("react", reactExecutor);
62
+ registry.register("single", singleExecutor);
63
+ return registry;
64
+ }
@@ -0,0 +1,51 @@
1
+ // src/mode/legion.js - 多 Agent 军团模式
2
+ // 把 Legion (多进程军团 + DAG 编排) 接入 mode 系统, chat({ mode: "legion" }) 可用。
3
+ // 编排策略:
4
+ // - 有 workflow (DAG 节点数组) 时走 runDag: 按依赖拓扑分层并行, 上游结果流入下游
5
+ // - 无 workflow 时走 broadcast: 同一问题广播给全部 agent, 取第一个有效回复
6
+ // 军团配置: config.agent.legion = { size, workflow } 或 config.orchestrator
7
+ // 可注入: opts.legion (测试/复用已有军团实例, 免重复 spawn)
8
+ import path from "node:path";
9
+ import { Legion } from "../orchestrator/legion.js";
10
+ import { warn } from "../utils/logger.js";
11
+
12
+ export async function legionExecutor(agent, userMsg, { sessionKey = "default", legion = null, workflow = null, size = null } = {}) {
13
+ const cfg = (agent.config && (agent.config.agent?.legion || agent.config.orchestrator)) || {};
14
+
15
+ // 1. 拿或懒建军团 (缓存到 agent, 复用子进程, 不重复 spawn)
16
+ let L = legion || agent._legion;
17
+ if (!L) {
18
+ L = new Legion();
19
+ const n = size || cfg.size || 2;
20
+ for (let i = 0; i < n; i++) {
21
+ // 每个 agent 独立数据目录, 隔离记忆/会话, 互不干扰
22
+ const dataDir = path.join(agent.dataDir, "legion", `agent-${i}`);
23
+ L.spawnAgent(`agent-${i}`, { dataDir });
24
+ }
25
+ agent._legion = L;
26
+ }
27
+
28
+ // 2. 编排: workflow(DAG) 优先, 否则 broadcast
29
+ const wf = workflow || cfg.workflow;
30
+ if (Array.isArray(wf) && wf.length) {
31
+ // v1.0.8: 校验节点结构 (需 {id, task}), 防字符串数组直接 TypeError
32
+ const bad = wf.find((n) => !n || typeof n !== "object" || !n.id || !n.task);
33
+ if (bad) return `[军团] workflow 节点需 {id, task} 对象, 非法节点: ${JSON.stringify(bad).slice(0, 80)}`;
34
+ try {
35
+ const { results } = await L.runDag({ nodes: wf });
36
+ const lines = Object.entries(results).map(([id, r]) => `【${id}】\n${r}`);
37
+ return lines.join("\n\n");
38
+ } catch (e) {
39
+ return `[军团] DAG 编排失败: ${e.message}`;
40
+ }
41
+ }
42
+
43
+ try {
44
+ const results = await L.broadcast("chat", userMsg);
45
+ const ok = results.filter((r) => r.status === "fulfilled" && r.value && r.value.reply);
46
+ if (ok.length) return ok[0].value.reply;
47
+ } catch (e) {
48
+ warn("[legion] broadcast 失败:", e.message);
49
+ }
50
+ return "[军团] 所有 agent 均未返回有效结果 (请确认已 spawnAgent 或配置 config.agent.legion.size)";
51
+ }
@@ -0,0 +1,50 @@
1
+ // src/mode/plan-exec.js - Plan & Execute 模式
2
+ // 先让 LLM 生成完整计划(步骤清单), 再逐步执行(每步走 react 工具循环), 最后汇总。
3
+ // 适合: 可分解的多步任务, 结构化、可预测、减少中间推理浪费。
4
+ import { buildMessages } from "./index.js";
5
+
6
+ // 解析 LLM 返回的步骤清单 (JSON 数组, 容忍 markdown 代码块包裹)
7
+ export function parseSteps(text) {
8
+ const cleaned = String(text || "").replace(/```(?:json|JSON)?\s*/g, "").replace(/```/g, "").trim();
9
+ const m = cleaned.match(/\[[\s\S]*\]/);
10
+ if (!m) return [];
11
+ try {
12
+ const arr = JSON.parse(m[0]);
13
+ return Array.isArray(arr) ? arr.map((x) => String(x).trim()).filter(Boolean) : [];
14
+ } catch { return []; }
15
+ }
16
+
17
+ // 规划: LLM 生成步骤清单 (无 LLM 返回空)
18
+ async function planSteps(agent, userMsg) {
19
+ if (!agent.llm) return [];
20
+ const r = await agent.llm.chat([
21
+ { role: "system", content: "你是任务规划器。把用户的任务分解成 2-6 个可独立执行的具体步骤, 每步一句话(含要做什么)。只输出 JSON 数组, 例如 [\"读取配置文件\", \"修改端口\", \"重启服务\"]。不要解释。" },
22
+ { role: "user", content: String(userMsg).slice(0, 2000) },
23
+ ]);
24
+ return parseSteps(r.content);
25
+ }
26
+
27
+ export async function planExecExecutor(agent, userMsg, { sessionKey = "default", maxSteps = 6 } = {}) {
28
+ if (!agent.llm) {
29
+ return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
30
+ }
31
+ // 1. 规划
32
+ const steps = (await planSteps(agent, userMsg)).slice(0, maxSteps);
33
+ if (!steps.length) {
34
+ // 规划失败: 退回 react 模式
35
+ const messages = await buildMessages(agent, userMsg, sessionKey);
36
+ return agent._llmWithFallback(messages);
37
+ }
38
+ // 2. 逐步执行 (每步走工具循环)
39
+ const base = await buildMessages(agent, userMsg, sessionKey);
40
+ const results = [];
41
+ for (let i = 0; i < steps.length; i++) {
42
+ const stepMsg = [...base, { role: "user", content: `[第 ${i + 1}/${steps.length} 步] ${steps[i]}` }];
43
+ let r;
44
+ try { r = await agent._llmWithFallback(stepMsg); }
45
+ catch (e) { r = `(步骤失败: ${e.message})`; }
46
+ results.push(`步骤${i + 1}: ${steps[i]}\n${r}`);
47
+ }
48
+ // 3. 汇总
49
+ return results.join("\n\n");
50
+ }
@@ -0,0 +1,40 @@
1
+ // src/mode/router.js - Router + Skill 模式
2
+ // 意图路由: 按用户输入匹配已安装技能, 把匹配到的 SKILL.md 注入上下文后执行。
3
+ // 适合: 多领域任务, 快速定位专门能力; 按需加载技能, 省 token。
4
+ import path from "node:path";
5
+ import { SkillLoader } from "../skills/loader.js";
6
+ import { buildMessages } from "./index.js";
7
+
8
+ // 技能匹配: 中文用连续两字(bigram)命中描述, 英文/数字用词命中
9
+ export function matchSkill(loader, text) {
10
+ const skills = loader.list();
11
+ const q = String(text || "").toLowerCase();
12
+ let best = null, bestScore = 0;
13
+ for (const s of skills) {
14
+ const desc = (s.description || "").toLowerCase();
15
+ let score = 0;
16
+ const bigrams = new Set();
17
+ const cjk = q.match(/[\u4e00-\u9fff]+/g) || [];
18
+ for (const seg of cjk) for (let i = 0; i < seg.length - 1; i++) bigrams.add(seg.slice(i, i + 2));
19
+ for (const bg of bigrams) if (desc.includes(bg)) score += 1;
20
+ const words = q.match(/[a-z0-9]+/g) || [];
21
+ for (const w of words) if (w.length >= 2 && desc.includes(w)) score += 1;
22
+ if (score > bestScore) { bestScore = score; best = s; }
23
+ }
24
+ return bestScore >= 1 ? best : null;
25
+ }
26
+
27
+ export async function routerExecutor(agent, userMsg, { sessionKey = "default" } = {}) {
28
+ if (!agent.llm) {
29
+ return (await agent._localIntent(userMsg)) || "[皮皮虾] 未配置模型 provider (配置见 docs/QUICKSTART.md 第 3 节)。";
30
+ }
31
+ // 1. 技能路由: 匹配用户输入到已安装技能
32
+ const loader = new SkillLoader(path.join(agent.root, "skills"));
33
+ const skill = matchSkill(loader, userMsg);
34
+ // 2. 注入技能内容到 system prompt, 再执行 (场景上下文已由 agent._context 注入)
35
+ const system = agent._context(userMsg)
36
+ + (skill ? `\n\n[已激活技能: ${skill.name}]\n${loader.read(skill.id)}` : "");
37
+ const history = await agent._loadHistory(sessionKey);
38
+ const messages = [{ role: "system", content: system }, ...history, { role: "user", content: String(userMsg) }];
39
+ return agent._llmWithFallback(messages);
40
+ }
@@ -0,0 +1,70 @@
1
+ // src/orchestrator/agent-worker.js - 子 agent 进程 (独立 PPXAgent 实例)
2
+ // 通过 stdin 收消息, stdout 回结果. 每个 worker 完全隔离.
3
+ import { PPXAgent } from "../agent/index.js";
4
+
5
+ const root = process.cwd();
6
+ // 可选: 独立数据目录 (多进程军团隔离, 避免互踩同一个 data/)
7
+ // 可选: 全局共享目录 (跨 agent 共享经验库 = ANS 全局记忆)
8
+ const agent = new PPXAgent({
9
+ root,
10
+ dataDir: process.env.PPX_AGENT_DATA_DIR || undefined,
11
+ globalDataDir: process.env.PPX_AGENT_GLOBAL_DATA_DIR || undefined,
12
+ });
13
+
14
+ // 只读审查者模式 (SDD, Superpowers): 禁用一切修改/执行类工具, 只能读/查
15
+ // 供 spawn_agent review 循环的审查者角色使用
16
+ if (process.env.PPX_AGENT_READONLY === "1") {
17
+ agent.enableReadonlyMode();
18
+ }
19
+
20
+ // 从 stdin 读 JSON 行
21
+ process.stdin.setEncoding("utf8");
22
+ let buf = "";
23
+ let currentReqId = null; // 当前正在处理的 chat 请求 id (step 事件路由用)
24
+ // v1.0.8: promise 链串行处理 (防止 data 事件并发时 currentReqId/_perspective 互相覆盖)
25
+ let queue = Promise.resolve();
26
+ // step 事件转发: 把 agent 的推理轮次进度实时回给主进程 (turn/step 可观测性)
27
+ agent.setStepEvent((ev) => {
28
+ if (currentReqId == null) return;
29
+ process.stdout.write(JSON.stringify({ id: currentReqId, type: "step", ...ev }) + "\n");
30
+ });
31
+ process.stdin.on("data", (chunk) => {
32
+ buf += chunk;
33
+ let idx;
34
+ while ((idx = buf.indexOf("\n")) >= 0) {
35
+ const line = buf.slice(0, idx).trim();
36
+ buf = buf.slice(idx + 1);
37
+ if (!line) continue;
38
+ queue = queue.then(() => handleLine(line));
39
+ }
40
+ });
41
+
42
+ async function handleLine(line) {
43
+ try {
44
+ const req = JSON.parse(line);
45
+ if (req.type === "chat") {
46
+ currentReqId = req.id;
47
+ // 差异化上下文 (多 agent 协调): 委派方可注入子 agent 专属视角, 对抗同质失败
48
+ const prevPerspective = agent._perspective;
49
+ agent._perspective = req.perspective ? String(req.perspective).slice(0, 4000) : null;
50
+ let reply;
51
+ try {
52
+ reply = await agent.chat(req.message);
53
+ } finally {
54
+ agent._perspective = prevPerspective;
55
+ }
56
+ currentReqId = null;
57
+ process.stdout.write(JSON.stringify({ id: req.id, type: "reply", reply }) + "\n");
58
+ } else if (req.type === "ping") {
59
+ process.stdout.write(JSON.stringify({ id: req.id, type: "pong", name: agent.config.agent?.name || "ppx" }) + "\n");
60
+ } else if (req.type === "shutdown") {
61
+ agent.shutdown();
62
+ process.stdout.write(JSON.stringify({ id: req.id, type: "bye" }) + "\n");
63
+ process.exit(0);
64
+ }
65
+ } catch (e) {
66
+ // v1.0.8: error 行必须带 req.id, 否则主进程 Legion.pending 匹配不到永不 settle (永久挂起)
67
+ process.stdout.write(JSON.stringify({ id: currentReqId || null, type: "error", error: e.message }) + "\n");
68
+ currentReqId = null;
69
+ }
70
+ }
@@ -0,0 +1,83 @@
1
+ // src/orchestrator/dag.js - DAG 任务编排 (军团从 broadcast/dispatch 升级为任务图)
2
+ // 纯函数零依赖: topoLevels 拓扑分层 + runDag 分层并行执行 + 依赖结果数据流
3
+ // graph.nodes = [{ id, task, dependsOn?: [id], agent?: name }]
4
+
5
+ // 拓扑分层 (Kahn 算法): 返回 [ [同层可并行的节点id], ... ], 检测环
6
+ // v1.0.8: 校验重复 id / 依赖不存在 (原实现静默丢弃不存在的依赖, 重复 id 误报成环)
7
+ export function topoLevels(nodes) {
8
+ const ids = new Set();
9
+ for (const n of nodes) {
10
+ if (!n || !n.id) throw new Error("DAG 节点缺 id");
11
+ if (ids.has(n.id)) throw new Error(`DAG 节点 id 重复: ${n.id}`);
12
+ ids.add(n.id);
13
+ }
14
+ const indeg = new Map();
15
+ const children = new Map();
16
+ for (const n of nodes) {
17
+ const deps = n.dependsOn || [];
18
+ for (const d of deps) {
19
+ if (!ids.has(d)) throw new Error(`DAG 节点 ${n.id} 依赖不存在的节点: ${d}`);
20
+ }
21
+ indeg.set(n.id, deps.length);
22
+ for (const d of deps) {
23
+ if (!children.has(d)) children.set(d, []);
24
+ children.get(d).push(n.id);
25
+ }
26
+ }
27
+ const levels = [];
28
+ let frontier = nodes.filter((n) => indeg.get(n.id) === 0).map((n) => n.id);
29
+ const seen = new Set();
30
+ while (frontier.length) {
31
+ levels.push(frontier);
32
+ const next = [];
33
+ for (const id of frontier) {
34
+ seen.add(id);
35
+ for (const c of children.get(id) || []) {
36
+ indeg.set(c, indeg.get(c) - 1);
37
+ if (indeg.get(c) === 0) next.push(c);
38
+ }
39
+ }
40
+ frontier = next;
41
+ }
42
+ if (seen.size < nodes.length) {
43
+ const cyclic = [...ids].filter((id) => !seen.has(id)).join(",");
44
+ throw new Error("DAG 存在环, 无法编排: " + cyclic);
45
+ }
46
+ return levels;
47
+ }
48
+
49
+ // 执行 DAG: 每层节点并行, 上游结果作为 deps 传给下游 executor。
50
+ // executor(id, node, deps) => Promise<result>, deps = { 依赖id: 结果 }
51
+ // concurrency: 每层并发的上限 (默认 0 = 不设限, 兼容旧调用; Legion 会传入自己的 maxConcurrent)
52
+ export async function runDag(graph, executor, { concurrency = 0 } = {}) {
53
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
54
+ const levels = topoLevels(graph.nodes);
55
+ const results = {};
56
+ const order = [];
57
+ const cap = concurrency > 0 ? concurrency : Infinity;
58
+ for (const level of levels) {
59
+ // 有界并发: 层内若超出 cap, 用轮询槽位的方式逐个交给并行执行, 避免一次性 Promise.all 打爆子进程
60
+ let idx = 0;
61
+ const runOne = async (id) => {
62
+ const node = byId.get(id);
63
+ const deps = {};
64
+ for (const d of node.dependsOn || []) deps[d] = results[d];
65
+ results[id] = await executor(id, node, deps);
66
+ order.push(id);
67
+ };
68
+ await new Promise((resolveAll) => {
69
+ let active = 0;
70
+ let doneCount = 0;
71
+ const tick = () => {
72
+ while (active < cap && idx < level.length) {
73
+ const id = level[idx++];
74
+ active++;
75
+ runOne(id).finally(() => { active--; doneCount++; tick(); });
76
+ }
77
+ if (doneCount >= level.length) resolveAll();
78
+ };
79
+ tick();
80
+ });
81
+ }
82
+ return { results, order };
83
+ }
@@ -0,0 +1,2 @@
1
+ // src/orchestrator/index.js - 军团统一出口
2
+ export { Legion } from "./legion.js";
@@ -0,0 +1,188 @@
1
+ // src/orchestrator/legion.js - Agent 军团编排器
2
+ // 管理多个独立 agent 子进程, 支持并行派发任务、按角色分工
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { EventEmitter } from "node:events";
7
+ import { info, warn, error } from "../utils/logger.js";
8
+ import { runDag } from "./dag.js";
9
+
10
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const WORKER = path.join(ROOT, "src", "orchestrator", "agent-worker.js");
12
+
13
+ export class Legion extends EventEmitter {
14
+ constructor({ workerPath = WORKER, nodeBin = process.execPath, maxConcurrent = 8 } = {}) {
15
+ super();
16
+ this.workerPath = workerPath;
17
+ this.nodeBin = nodeBin;
18
+ this.maxConcurrent = maxConcurrent; // 军团整体并发上限: 防止大 DAG/广播瞬间 spawn 海量子进程
19
+ this.agents = new Map(); // { name: { proc, pending: Map<id,{resolve,reject}>, counter } }
20
+ }
21
+
22
+ // 有界并发执行器: 以固定并发度跑一批 async 任务, 超出上限的排队 (背压而非一次性 Promise.all)
23
+ // 这是 broadcast/runDag 层共享的背压口, 避免"并发无数控、大输入一次性打爆子进程数"
24
+ async _mapBounded(items, fn, { concurrency = this.maxConcurrent } = {}) {
25
+ const results = new Array(items.length);
26
+ let idx = 0;
27
+ const workers = Array.from({ length: Math.max(1, Math.min(concurrency, items.length)) });
28
+ await Promise.all(workers.map(async () => {
29
+ while (idx < items.length) {
30
+ const i = idx++;
31
+ results[i] = await fn(items[i], i);
32
+ }
33
+ }));
34
+ return results;
35
+ }
36
+
37
+ // 创建一个 agent 子进程
38
+ spawnAgent(name, { dataDir, globalDataDir, env = {} } = {}) {
39
+ if (this.agents.has(name)) return this.agents.get(name);
40
+ const env2 = { ...process.env, ...env };
41
+ if (dataDir) env2.PPX_AGENT_DATA_DIR = dataDir;
42
+ // 全局共享目录: 跨 agent 共享经验库 (ANS 全局记忆)
43
+ if (globalDataDir) env2.PPX_AGENT_GLOBAL_DATA_DIR = globalDataDir;
44
+ const proc = spawn(this.nodeBin, [this.workerPath], {
45
+ cwd: ROOT,
46
+ env: env2,
47
+ stdio: ["pipe", "pipe", "inherit"],
48
+ });
49
+ const entry = { proc, pending: new Map(), counter: 0 };
50
+ this.agents.set(name, entry);
51
+
52
+ proc.stdout.setEncoding("utf8");
53
+ let buf = "";
54
+ proc.stdout.on("data", (chunk) => {
55
+ buf += chunk;
56
+ let idx;
57
+ while ((idx = buf.indexOf("\n")) >= 0) {
58
+ const line = buf.slice(0, idx).trim();
59
+ buf = buf.slice(idx + 1);
60
+ if (!line) continue;
61
+ try {
62
+ const msg = JSON.parse(line);
63
+ // step 中间事件: 触发 onProgress 回调, 不消费 pending (等最终 reply)
64
+ if (msg.type === "step" && msg.id && entry.pending.has(msg.id)) {
65
+ const p = entry.pending.get(msg.id);
66
+ if (p && p.onProgress) { try { p.onProgress(msg); } catch {} }
67
+ continue;
68
+ }
69
+ if (msg.id && entry.pending.has(msg.id)) {
70
+ const { resolve, reject } = entry.pending.get(msg.id);
71
+ entry.pending.delete(msg.id);
72
+ if (msg.type === "error") reject(new Error(msg.error));
73
+ else resolve(msg);
74
+ }
75
+ } catch {}
76
+ }
77
+ });
78
+ proc.on("error", (err) => {
79
+ // v1.0.8: spawn 失败 (node bin 不存在等) 兜底, 拒绝所有 pending, 防永久挂起
80
+ error(`agent[${name}] 启动失败: ${err.message}`);
81
+ for (const [, { reject }] of entry.pending) reject(new Error(`agent ${name} 启动失败`));
82
+ entry.pending.clear();
83
+ this.agents.delete(name);
84
+ });
85
+ proc.on("exit", (code) => {
86
+ info(`agent[${name}] 退出 code=${code}`);
87
+ // 拒绝所有 pending
88
+ for (const [, { reject }] of entry.pending) reject(new Error(`agent ${name} 已退出`));
89
+ entry.pending.clear();
90
+ this.agents.delete(name);
91
+ this.emit("exit", name, code);
92
+ });
93
+ info(`agent[${name}] 已启动 (pid=${proc.pid})`);
94
+ return entry;
95
+ }
96
+
97
+ // 向某 agent 发消息, 返回 Promise (onProgress 可选: 接收 step 中间事件)
98
+ // v1.0.8: 超时兜底 (默认 30s), worker 卡死/异常不导致 pending 永久挂起
99
+ send(name, msg, { onProgress, timeout = 30000 } = {}) {
100
+ const entry = this.agents.get(name);
101
+ if (!entry || entry.proc.exitCode !== null) {
102
+ return Promise.reject(new Error(`agent ${name} 未运行`));
103
+ }
104
+ const id = ++entry.counter;
105
+ return new Promise((resolve, reject) => {
106
+ const timer = setTimeout(() => {
107
+ entry.pending.delete(id);
108
+ reject(new Error(`agent ${name} 请求超时 (${timeout}ms)`));
109
+ }, timeout);
110
+ entry.pending.set(id, {
111
+ resolve: (v) => { clearTimeout(timer); resolve(v); },
112
+ reject: (e) => { clearTimeout(timer); reject(e); },
113
+ onProgress,
114
+ });
115
+ try {
116
+ entry.proc.stdin.write(JSON.stringify({ id, ...msg }) + "\n");
117
+ } catch (e) {
118
+ clearTimeout(timer);
119
+ entry.pending.delete(id);
120
+ reject(new Error(`agent ${name} 写入失败: ${e.message}`));
121
+ }
122
+ });
123
+ }
124
+
125
+ // 并行派发: 同一任务广播给多个 agent, 最快返回
126
+ async broadcast(type, message, { timeout = 30000 } = {}) {
127
+ const names = [...this.agents.keys()];
128
+ if (!names.length) throw new Error("军团为空, 先 spawnAgent");
129
+ const results = await this._mapBounded(
130
+ names,
131
+ (n) => this.send(n, { type, message }, { timeout }).catch((e) => ({ type: "error", error: e.message }))
132
+ );
133
+ return names.map((n, i) => ({ agent: n, ...results[i] }));
134
+ }
135
+
136
+ // 按角色分工: 把任务列表分给不同 agent
137
+ // ⚠️ 实验性 API: 生产代码无内置消费方 (详见 docs/ARCHITECTURE.md)。仅供在交互场景/测试里手动调用。
138
+ // 逐条串行派发, 吞吐低但稳定; 若需高并发的角色分工, 优先用 runDag。
139
+ async dispatch(type, tasks) {
140
+ const names = [...this.agents.keys()];
141
+ const results = [];
142
+ for (let i = 0; i < tasks.length; i++) {
143
+ const name = names[i % names.length];
144
+ try {
145
+ const r = await this.send(name, { type, message: tasks[i] });
146
+ results.push({ agent: name, task: tasks[i], ...r });
147
+ } catch (e) {
148
+ results.push({ agent: name, task: tasks[i], type: "error", error: e.message });
149
+ }
150
+ }
151
+ return results;
152
+ }
153
+
154
+ // DAG 任务编排: 按依赖拓扑分层执行, 同层并行, 上游结果传入下游 (P3)
155
+ // graph = { nodes: [{ id, task, dependsOn?: [id], agent?: name }] }
156
+ // 返回 { results: {id: reply}, order: [执行顺序] }
157
+ async runDag(graph) {
158
+ const names = [...this.agents.keys()];
159
+ if (!names.length) throw new Error("军团为空, 先 spawnAgent");
160
+ let rr = 0; // round-robin 派发未指定 agent 的节点
161
+ return runDag(graph, async (id, node, deps) => {
162
+ const agent = node.agent || names[rr++ % names.length];
163
+ const depText = Object.entries(deps)
164
+ .map(([k, v]) => `${k}: ${String(v).slice(0, 500)}`)
165
+ .join("\n");
166
+ const message = node.task + (depText ? "\n\n[上游结果]\n" + depText : "");
167
+ const r = await this.send(agent, { type: "chat", message });
168
+ return r.reply;
169
+ }, { concurrency: this.maxConcurrent });
170
+ }
171
+
172
+ // 关闭所有 agent
173
+ // v1.0.8: 先发 shutdown 优雅退出, 300ms 后兜底 kill 仍存活进程 (worker 无响应/卡死时不残留)
174
+ async shutdownAll() {
175
+ for (const [name] of [...this.agents]) {
176
+ try { this.send(name, { type: "shutdown" }).catch(() => {}); } catch {}
177
+ }
178
+ // 等待退出
179
+ await new Promise((r) => setTimeout(r, 300));
180
+ for (const [name, entry] of [...this.agents]) {
181
+ try { entry.proc.kill(); } catch {}
182
+ }
183
+ }
184
+
185
+ list() {
186
+ return [...this.agents.keys()].map((n) => ({ name: n, pid: this.agents.get(n).proc.pid }));
187
+ }
188
+ }