@iamsamyiok/agents-chat 3.18.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 +42 -0
- package/app/lib/agent.js +487 -0
- package/app/lib/aux-llm.js +107 -0
- package/app/lib/cards.js +479 -0
- package/app/lib/env.js +47 -0
- package/app/lib/memory.js +202 -0
- package/app/lib/oc.js +244 -0
- package/app/lib/orchestrator.js +1177 -0
- package/app/lib/store.js +686 -0
- package/app/mock/mock-agent.js +125 -0
- package/app/public/cards.html +628 -0
- package/app/public/index.html +3279 -0
- package/app/server.js +1334 -0
- package/bin/agents-chat.js +263 -0
- package/lib/start.js +10 -0
- package/package.json +39 -0
|
@@ -0,0 +1,1177 @@
|
|
|
1
|
+
// 调度引擎:
|
|
2
|
+
// 1) 未点名 → 管家:先向用户交付简洁工作计划 → 按阶段(外层串行/内层并行)下发子智能体
|
|
3
|
+
// → 验收(不合格则把要求+完善建议返回相应子智能体返工,循环至合格或达轮数上限)→ 汇总交付
|
|
4
|
+
// 2) @点名 → 按点名顺序串行流水线
|
|
5
|
+
// 3) 任务队列 → 每个任务一次完整管家调度(任务会话独立)
|
|
6
|
+
// 上下文规则:子智能体的正式产出作为「工作背景」传给后续智能体;
|
|
7
|
+
// 调度过程(规划卡片/阶段提示/验收意见)只展示在界面上,不进入上下文。
|
|
8
|
+
const { runAgent, resolveCwd } = require('./agent');
|
|
9
|
+
const memory = require('./memory');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
const { execFile, exec } = require('child_process');
|
|
14
|
+
|
|
15
|
+
const CTX_PER_OUTPUT = 4000; // 单份产出作为背景的截断长度
|
|
16
|
+
const CTX_TOTAL = 12000; // 背景累计截断长度
|
|
17
|
+
const PARALLEL_CAP = 3; // 阶段内并行进程上限
|
|
18
|
+
const MAX_REWORK = Number(process.env.AGENTS_CHAT_MAX_VERIFY) > 0
|
|
19
|
+
? Number(process.env.AGENTS_CHAT_MAX_VERIFY) : 2; // 验收不通过时最大返工轮数
|
|
20
|
+
|
|
21
|
+
// ---------- 自动核查:验收前由系统本地执行的确定性检查(不依赖任何智能体自述) ----------
|
|
22
|
+
// 检查项:产出文件存在性/非空、产出中 JS 代码块语法(node --check)、JSON 代码块可解析、
|
|
23
|
+
// 占位符残留(TODO/此处省略等)、自定义验证命令(.env AGENTS_CHAT_VERIFY_CMD)
|
|
24
|
+
// 全部检查只读不写(临时文件除外),语法检查不执行代码;.env AGENTS_CHAT_AUTOVERIFY=0 可整体关闭
|
|
25
|
+
function nodeSyntaxCheck(code, tag) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
try {
|
|
28
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ac-'));
|
|
29
|
+
const file = path.join(dir, `block.${tag || 'js'}`);
|
|
30
|
+
fs.writeFileSync(file, code);
|
|
31
|
+
execFile(process.execPath, ['--check', file], { timeout: 15000 }, (err, _so, se) => {
|
|
32
|
+
try { fs.unlinkSync(file); fs.rmdirSync(dir); } catch { /* ignore */ }
|
|
33
|
+
resolve({ pass: !err, note: err ? String(se || err.message).split('\n')[0].slice(0, 300) : '语法正确' });
|
|
34
|
+
});
|
|
35
|
+
} catch (e) {
|
|
36
|
+
resolve({ pass: true, note: `检查器异常(跳过):${String(e.message || e).slice(0, 120)}` });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runVerifyCmd(cmd) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const timeoutMs = Number(process.env.AGENTS_CHAT_VERIFY_TIMEOUT_MS) > 0
|
|
44
|
+
? Number(process.env.AGENTS_CHAT_VERIFY_TIMEOUT_MS) : 120000;
|
|
45
|
+
try {
|
|
46
|
+
exec(cmd, { cwd: resolveCwd(), timeout: timeoutMs, maxBuffer: 512 * 1024, killSignal: 'SIGKILL' }, (err, so, se) => {
|
|
47
|
+
const tail = String((so || '') + (se || '')).trim().slice(-1200);
|
|
48
|
+
const code = err ? (err.code === undefined ? 1 : err.code) : 0;
|
|
49
|
+
const killed = err && err.killed ? '(超时被终止)' : '';
|
|
50
|
+
resolve({ pass: !err, note: `退出码 ${code}${killed}${tail ? `,输出末尾:\n${tail}` : '(无输出)'}` });
|
|
51
|
+
});
|
|
52
|
+
} catch (e) {
|
|
53
|
+
resolve({ pass: true, note: `命令无法启动(跳过):${String(e.message || e).slice(0, 120)}` });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 从产出文本提取带语言标注的代码块
|
|
59
|
+
function codeBlocks(output) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const re = /```([a-zA-Z0-9+#-]*)\r?\n([\s\S]*?)```/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = re.exec(String(output || ''))) !== null) out.push({ lang: (m[1] || '').toLowerCase(), code: m[2] });
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const PLACEHOLDER_RE = /(此处省略|此处略|内容略|TODO[::]?\s*待补|待补充[::]?\s*$|«+\s*略\s*»+|\.{6,}省略)/;
|
|
68
|
+
|
|
69
|
+
async function runAutoChecks(results) {
|
|
70
|
+
if (process.env.AGENTS_CHAT_AUTOVERIFY === '0') return null;
|
|
71
|
+
const items = []; // {name, pass, note}
|
|
72
|
+
const add = (name, pass, note) => items.push({ name, pass: !!pass, note: String(note).slice(0, 800) });
|
|
73
|
+
|
|
74
|
+
for (const r of results) {
|
|
75
|
+
const label = r.agent && r.agent.name;
|
|
76
|
+
// 1) 产出文件
|
|
77
|
+
if (r.outputPath) {
|
|
78
|
+
try {
|
|
79
|
+
const st = fs.statSync(r.outputPath);
|
|
80
|
+
const content = st.size <= 2 * 1024 * 1024 ? fs.readFileSync(r.outputPath, 'utf8') : '';
|
|
81
|
+
const lines = content ? content.split('\n').length : 0;
|
|
82
|
+
add(`产出文件[${label}]`, st.size > 0, `${r.outputPath}(${st.size} 字节,${lines} 行)`);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
add(`产出文件[${label}]`, false, `无法读取 ${r.outputPath}:${String(e.message || e).slice(0, 120)}`);
|
|
85
|
+
}
|
|
86
|
+
} else if (r.output) {
|
|
87
|
+
add(`产出文件[${label}]`, true, '纯文本产出(无落盘文件,仅记录于会话)');
|
|
88
|
+
}
|
|
89
|
+
if (!r.output) continue;
|
|
90
|
+
// 2) 代码块:JS 语法 + JSON 可解析(每份产出最多查 6 块,防大产出拖慢)
|
|
91
|
+
const blocks = codeBlocks(r.output).slice(0, 6);
|
|
92
|
+
let jsN = 0, jsonN = 0;
|
|
93
|
+
for (const b of blocks) {
|
|
94
|
+
if (['js', 'javascript', 'mjs', 'cjs', 'node'].includes(b.lang) && b.code.trim()) {
|
|
95
|
+
jsN++;
|
|
96
|
+
const c = await nodeSyntaxCheck(b.code, b.lang === 'mjs' ? 'mjs' : 'js');
|
|
97
|
+
add(`JS 语法[${label}·第${jsN}块]`, c.pass, c.note);
|
|
98
|
+
} else if (b.lang === 'json' && b.code.trim()) {
|
|
99
|
+
jsonN++;
|
|
100
|
+
try { JSON.parse(b.code); add(`JSON 校验[${label}·第${jsonN}块]`, true, '合法 JSON'); }
|
|
101
|
+
catch (e) { add(`JSON 校验[${label}·第${jsonN}块]`, false, `解析失败:${String(e.message || e).slice(0, 200)}`); }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// 3) 占位符残留(未完成的信号)
|
|
105
|
+
const ph = String(r.output).match(PLACEHOLDER_RE);
|
|
106
|
+
if (ph) add(`完整性[${label}]`, false, `产出中疑似存在未完成占位内容:「${ph[0]}」`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 4) 自定义验证命令(用户在 .env 配置,如 npm test;在工作目录执行)
|
|
110
|
+
const cmd = String(process.env.AGENTS_CHAT_VERIFY_CMD || '').trim();
|
|
111
|
+
if (cmd) {
|
|
112
|
+
const c = await runVerifyCmd(cmd);
|
|
113
|
+
add(`验证命令(${cmd.slice(0, 60)})`, c.pass, c.note);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const passCount = items.filter(i => i.pass).length;
|
|
117
|
+
const failCount = items.length - passCount;
|
|
118
|
+
const text = items.length
|
|
119
|
+
? items.map(i => `- ${i.pass ? '✅' : '❌'} ${i.name}:${i.note}`).join('\n') + `\n(共 ${passCount} 项通过、${failCount} 项未通过)`
|
|
120
|
+
: '';
|
|
121
|
+
return { items, text, passCount, failCount };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------- 产出归档:正式产出落盘,后续智能体与用户都能拿到完整版 ----------
|
|
125
|
+
const OUT_ROOT = path.join(process.env.AGENTS_CHAT_DATA || path.join(__dirname, '..', '..', '.data'), 'outputs');
|
|
126
|
+
const dirCounters = new Map(); // 会话目录 -> 已写文件数(重启后基于现有文件续编)
|
|
127
|
+
|
|
128
|
+
function sessionOutDir(taskId) {
|
|
129
|
+
const name = taskId ? String(taskId).replace(/[^\w-]/g, '_') : 'main';
|
|
130
|
+
return path.join(OUT_ROOT, name);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function saveOutput(sessionDir, agentName, phase, text) {
|
|
134
|
+
try {
|
|
135
|
+
if (!text || !String(text).trim()) return '';
|
|
136
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
137
|
+
if (!dirCounters.has(sessionDir)) {
|
|
138
|
+
let max = 0;
|
|
139
|
+
try {
|
|
140
|
+
for (const f of fs.readdirSync(sessionDir)) {
|
|
141
|
+
const m = f.match(/^(\d+)-/);
|
|
142
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
143
|
+
}
|
|
144
|
+
} catch { /* 空目录 */ }
|
|
145
|
+
dirCounters.set(sessionDir, max);
|
|
146
|
+
}
|
|
147
|
+
const n = dirCounters.get(sessionDir) + 1;
|
|
148
|
+
dirCounters.set(sessionDir, n);
|
|
149
|
+
const safeName = String(agentName).replace(/[\\/:*?"<>|\s]/g, '_').slice(0, 30);
|
|
150
|
+
const file = path.join(sessionDir, `${String(n).padStart(2, '0')}-${safeName}-${phase}.md`);
|
|
151
|
+
fs.writeFileSync(file, `【${agentName} · ${phase} 阶段产出】\n\n${text}\n`);
|
|
152
|
+
return file;
|
|
153
|
+
} catch { return ''; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------- 基础:运行单个 agent 一轮,流式回调 ----------
|
|
157
|
+
// 错误绝不静默:出错时把错误文本作为消息流入聊天流,用户必须能看到
|
|
158
|
+
// 正式产出落盘后通过 saved 事件告知前端完整文件路径
|
|
159
|
+
function runAgentOnce(agent, prompt, emit, phase, role, taskId, sessionDir, scope) {
|
|
160
|
+
return new Promise((resolve) => {
|
|
161
|
+
let output = '';
|
|
162
|
+
let error = undefined;
|
|
163
|
+
emit({ type: 'stage', phase, role, agentId: agent.id, agentName: agent.name, taskId: taskId || '' });
|
|
164
|
+
runAgent(agent, prompt, (chunk) => {
|
|
165
|
+
if (chunk.content) {
|
|
166
|
+
output += chunk.content;
|
|
167
|
+
emit({ type: 'text', content: chunk.content, phase, role, agentId: agent.id, agentName: agent.name, taskId: taskId || '' });
|
|
168
|
+
}
|
|
169
|
+
if (chunk.done) {
|
|
170
|
+
if (chunk.error) {
|
|
171
|
+
error = chunk.error;
|
|
172
|
+
emit({ type: 'text', content: `\n[执行出错]\n${chunk.error}\n`, phase, role, agentId: agent.id, agentName: agent.name, taskId: taskId || '' });
|
|
173
|
+
}
|
|
174
|
+
const outputPath = error ? '' : saveOutput(sessionDir, agent.name, phase, output);
|
|
175
|
+
if (outputPath) emit({ type: 'saved', path: outputPath, agentId: agent.id, agentName: agent.name, phase, taskId: taskId || '' });
|
|
176
|
+
resolve({ output, error, outputPath });
|
|
177
|
+
}
|
|
178
|
+
}, scope);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------- 上下文传递:前序智能体产出 → 工作背景 ----------
|
|
183
|
+
// 两种模式(.env AGENTS_CHAT_HANDOFF,默认 doc):
|
|
184
|
+
// - doc:借鉴 handoff skill 的「文档型交接」——组结构化交接文档(任务/进度/产出摘要/文件指针/建议),
|
|
185
|
+
// 下游用文件读取工具自取完整产出,省 token 且保留决策线索
|
|
186
|
+
// - full:旧模式,前序产出全文(截断)拼接
|
|
187
|
+
function buildContext(results) {
|
|
188
|
+
const parts = [];
|
|
189
|
+
let total = 0;
|
|
190
|
+
for (const r of results.slice().reverse()) {
|
|
191
|
+
if (!r.output && !r.error) continue;
|
|
192
|
+
const body = r.output || `(执行失败:${r.error})`;
|
|
193
|
+
const cut = body.length > CTX_PER_OUTPUT ? body.slice(0, CTX_PER_OUTPUT) + '…(截断)' : body;
|
|
194
|
+
total += cut.length;
|
|
195
|
+
if (total > CTX_TOTAL) break;
|
|
196
|
+
parts.unshift(`【${r.agent.name} 的产出】\n${cut}`);
|
|
197
|
+
}
|
|
198
|
+
return parts.join('\n\n');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const HANDOFF_MODE = () => (process.env.AGENTS_CHAT_HANDOFF || 'doc').toLowerCase();
|
|
202
|
+
|
|
203
|
+
// 从智能体输出中提取「交接说明」段(要求其输出末尾附 1~3 条给下游的关键信息)
|
|
204
|
+
function extractHandoffNote(output) {
|
|
205
|
+
if (!output) return '';
|
|
206
|
+
const m = output.match(/【交接说?\s*[明册]?】([\s\S]{1,1200}?)(?=\n\s*\n【|$)/);
|
|
207
|
+
return m ? m[1].trim() : '';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------- 成果文件:智能体在工作目录里真实创建/修改的文件(最终交付物) ----------
|
|
211
|
+
// 与「过程存档」(data/outputs 下系统自动保存的各阶段完整输出)区分:
|
|
212
|
+
// 交给用户的必须是工作目录中成果文件的完整绝对路径
|
|
213
|
+
function deliverAsk() {
|
|
214
|
+
return `\n\n【文件产出要求】\n你与所有智能体共用的工作目录(所有工作记录与产出文件都保存在这里):${resolveCwd()}\n若你在工作目录中创建或修改了文件,必须在输出最末尾附「【产出文件】」一节,逐行列出每个成果文件的完整绝对路径(以工作目录为基准拼成从根目录开始的完整路径,不要只写文件名或相对路径);没有创建文件则不要添加这一节。`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 解析输出末尾的【产出文件】节:提取路径行 → 相对路径补全为绝对 → 过滤不存在的 → 去重
|
|
218
|
+
function extractDeliverFiles(output, cwd) {
|
|
219
|
+
const m = String(output || '').match(/【产出文件】([\s\S]{0,2000}?)(?=\n\s*\n【|$)/);
|
|
220
|
+
if (!m) return [];
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
223
|
+
const pm = line.match(/(?:^|[\s::])((?:[A-Za-z]:)?[\\/][^\s"''',。;,;))】]+)/);
|
|
224
|
+
if (!pm) continue;
|
|
225
|
+
const abs = path.resolve(cwd, pm[1]);
|
|
226
|
+
try { if (fs.existsSync(abs) && fs.statSync(abs).isFile() && !out.includes(abs)) out.push(abs); } catch { /* ignore */ }
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 汇总全部产出中的真实成果文件(去重、按时间序)
|
|
232
|
+
function collectRealFiles(results) {
|
|
233
|
+
const cwdNow = resolveCwd();
|
|
234
|
+
const files = [];
|
|
235
|
+
for (const r of results) {
|
|
236
|
+
for (const f of extractDeliverFiles(r.output, cwdNow)) {
|
|
237
|
+
if (!files.includes(f)) files.push(f);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return files;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const PHASE_LABEL_CN = (ph) => ({ plan: '规划', work: '执行', review: '验收', report: '汇总', talk: '发言', task: '任务' }[ph] || '');
|
|
244
|
+
|
|
245
|
+
// 结构化交接文档:给下游智能体看的「上游留下了什么」
|
|
246
|
+
function buildHandoffDoc(r) {
|
|
247
|
+
const status = r.output ? '已完成' : `执行失败:${String(r.error || '').slice(0, 200)}`;
|
|
248
|
+
const note = extractHandoffNote(r.output);
|
|
249
|
+
const lines = [
|
|
250
|
+
`━━━ 交接文档|来自 ${r.agent.name} ━━━`,
|
|
251
|
+
`■ 任务:${String(r.instruction || '(见上游指派)').slice(0, 300)}`,
|
|
252
|
+
`■ 进度:${status}`
|
|
253
|
+
];
|
|
254
|
+
if (r.output) {
|
|
255
|
+
const brief = (note || r.output).replace(/\s+/g, ' ').slice(0, 500);
|
|
256
|
+
lines.push(`■ 产出摘要:${brief}${r.output.length > 500 && !note ? '…(完整内容见下方文件)' : ''}`);
|
|
257
|
+
}
|
|
258
|
+
if (r.outputPath) lines.push(`■ 成果文件:${r.outputPath}(完整产出,建议先用文件读取工具查看全文)`);
|
|
259
|
+
if (note) lines.push(`■ 给下游的建议:\n${note}`);
|
|
260
|
+
return lines.join('\n');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 要求智能体输出末尾附交接说明(仅 doc 模式注入)
|
|
264
|
+
const HANDOFF_ASK = '\n\n另外:你处于多智能体协作流程中,请在输出末尾附加一段「【交接说明】」,用 1~3 条要点告诉接手的协作者关键信息(重要决策、踩过的坑、注意事项或建议);若确实无可奉告可省略。';
|
|
265
|
+
|
|
266
|
+
// ---------- 共享黑板:任务级共享状态文件(借鉴黑板架构;同轮编排全体协作者可读可写) ----------
|
|
267
|
+
// 与交接文档的分工:交接文档是「前序产出给直接下游」的全文通道;看板是「全体协作者共享」的轻量进展/决定/提醒流
|
|
268
|
+
const BOARD_ASK = '\n另外:若本步工作做出了影响后续工作的关键决定、或发现需要注意的风险,请在输出末尾附加「【看板更新】」用 1~2 条要点写明(会同步到全体协作者共享的看板);无则省略。';
|
|
269
|
+
|
|
270
|
+
function boardPathOf(sessionDir) { return path.join(sessionDir, 'BOARD.md'); }
|
|
271
|
+
|
|
272
|
+
function boardInit(sessionDir, message, roster) {
|
|
273
|
+
try {
|
|
274
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
275
|
+
fs.writeFileSync(boardPathOf(sessionDir),
|
|
276
|
+
`# 共享看板\n\n【任务】${String(message).replace(/\s+/g, ' ').slice(0, 200)}\n【团队】${roster}\n\n## 进展\n`, 'utf8');
|
|
277
|
+
return boardPathOf(sessionDir);
|
|
278
|
+
} catch { return ''; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// 从产出中提取「看板更新」块(智能体主动写给全体协作者的提醒/决定)
|
|
282
|
+
function extractBoardNote(output) {
|
|
283
|
+
if (!output) return '';
|
|
284
|
+
const m = String(output).match(/【看板更新】([\s\S]{1,600}?)(?=\n\s*\n【|$)/);
|
|
285
|
+
return m ? m[1].replace(/\s+/g, ' ').trim() : '';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function boardAppend(sessionDir, stageLabel, agentName, summary, note, files) {
|
|
289
|
+
try {
|
|
290
|
+
const p = boardPathOf(sessionDir);
|
|
291
|
+
if (!fs.existsSync(p)) return;
|
|
292
|
+
const lines = [`- [${stageLabel}] ${agentName}:${String(summary || '').replace(/\s+/g, ' ').slice(0, 120)}`];
|
|
293
|
+
if (files && files.length) lines.push(` - 成果文件:${files.join('、')}`);
|
|
294
|
+
if (note) lines.push(` - 看板更新:${note.slice(0, 300)}`);
|
|
295
|
+
fs.appendFileSync(p, lines.join('\n') + '\n', 'utf8');
|
|
296
|
+
} catch { /* 看板写失败不影响主流程 */ }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function boardRead(sessionDir, maxLen) {
|
|
300
|
+
const limit = maxLen || 1600;
|
|
301
|
+
try {
|
|
302
|
+
const t = fs.readFileSync(boardPathOf(sessionDir), 'utf8');
|
|
303
|
+
return t.length > limit ? t.slice(0, limit) + '\n…(更早已截断)' : t;
|
|
304
|
+
} catch { return ''; }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---------- 中途委派:子智能体自认职责错配时改派名单内他人(借鉴 OpenAI Swarm handoff) ----------
|
|
308
|
+
// 约定输出 {"handoff":"智能体名称","reason":"原因"};只认「产出主体就是委派 JSON」的情况,防止误判正常产出
|
|
309
|
+
function parseHandoff(output) {
|
|
310
|
+
const text = String(output || '').trim();
|
|
311
|
+
if (!text || text.length > 800 || !text.includes('handoff')) return null;
|
|
312
|
+
const blocks = [...text.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map(m => m[1]);
|
|
313
|
+
const s = text.indexOf('{');
|
|
314
|
+
const e = text.lastIndexOf('}');
|
|
315
|
+
if (s >= 0 && e > s) blocks.push(text.slice(s, e + 1));
|
|
316
|
+
for (const b of blocks) {
|
|
317
|
+
try {
|
|
318
|
+
const j = JSON.parse(b);
|
|
319
|
+
if (j && typeof j.handoff === 'string' && j.handoff.trim()) {
|
|
320
|
+
return { to: j.handoff.trim(), reason: String(j.reason || '').slice(0, 500) };
|
|
321
|
+
}
|
|
322
|
+
} catch { /* 尝试下一个块 */ }
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const HANDOFF_DELEGATE_ASK = `\n\n【中途委派】若你判断这个任务更适合名单中的另一位智能体完成(职责错配、你缺乏相应能力或信息),且你尚未开展实质工作,可以只输出一行 JSON 放弃接手:{"handoff":"目标智能体名称","reason":"简要原因"};管家会把任务连同你的说明转交给对方。能胜任时严禁使用。`;
|
|
328
|
+
|
|
329
|
+
// 带防循环上限的委派执行:返回最终执行的 {agent, res, delegated};链上限 2 次(A→B→C 封顶)
|
|
330
|
+
async function runWithHandoff(agent, prompt, roster, opts, emit, phase, role, taskId, sessionDir, scope, isStopped) {
|
|
331
|
+
let current = agent;
|
|
332
|
+
let p = prompt + HANDOFF_DELEGATE_ASK;
|
|
333
|
+
let chain = 0;
|
|
334
|
+
let res;
|
|
335
|
+
const trail = [];
|
|
336
|
+
while (true) {
|
|
337
|
+
res = await runAgentOnce(current, p, emit, phase, role, taskId, sessionDir, scope);
|
|
338
|
+
const ho = chain < 2 && !isStopped() ? parseHandoff(res.output) : null;
|
|
339
|
+
if (!ho) break;
|
|
340
|
+
const target = roster.find(a => (a.name === ho.to || a.id === ho.to) && a.id !== current.id);
|
|
341
|
+
if (!target) {
|
|
342
|
+
emit({ type: 'notice', content: `↪ 委派目标「${ho.to}」不在名单内,忽略委派、沿用当前产出`, taskId });
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
chain++;
|
|
346
|
+
trail.push({ from: current.name, to: target.name, reason: ho.reason });
|
|
347
|
+
emit({ type: 'notice', content: `↪ ${current.name} 请求委派:${ho.reason} → 改派 ${target.name}(第 ${chain} 次转交)`, taskId });
|
|
348
|
+
p = prompt + `\n\n【委派背景】${current.name} 已接手但判断此任务更适合你,原因:${ho.reason}\n其已产出的参考内容:\n${String(res.output || '').slice(0, 2000)}` + HANDOFF_DELEGATE_ASK;
|
|
349
|
+
current = target;
|
|
350
|
+
}
|
|
351
|
+
return { agent: current, res, trail };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------- 人工审批关卡:规划后 / 交付前暂停等待用户放行(借鉴 LangGraph interrupt / OpenAI 审批模式) ----------
|
|
355
|
+
// 审批等待期间用户点「停止」或审批超时都视为拒绝;全部走 opts.requestApproval(由 server 注入),orchestrator 不感知 HTTP
|
|
356
|
+
async function approvalGate(kind, label, opts, emit, isStopped, taskId) {
|
|
357
|
+
const mode = String((opts && opts.approval) || 'off');
|
|
358
|
+
if (mode !== 'all' && mode !== kind) return true;
|
|
359
|
+
if (!opts || typeof opts.requestApproval !== 'function') return true;
|
|
360
|
+
emit({ type: 'notice', content: `⏸ ${label} — 已暂停,等待人工审批`, taskId });
|
|
361
|
+
let settled = false;
|
|
362
|
+
const stopWatcher = (async () => {
|
|
363
|
+
while (!settled && !isStopped()) await new Promise(r => setTimeout(r, 800));
|
|
364
|
+
return false;
|
|
365
|
+
})();
|
|
366
|
+
const approved = await Promise.race([
|
|
367
|
+
Promise.resolve(opts.requestApproval(kind, label, taskId)),
|
|
368
|
+
stopWatcher
|
|
369
|
+
]).finally(() => { settled = true; });
|
|
370
|
+
if (approved) {
|
|
371
|
+
emit({ type: 'notice', content: `✔ 审批通过:${label}`, taskId });
|
|
372
|
+
} else {
|
|
373
|
+
emit({ type: 'notice', content: `✘ 审批未通过(拒绝或超时):${label},编排终止`, taskId });
|
|
374
|
+
}
|
|
375
|
+
return approved;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function appendWorkContext(prompt, results, history) {
|
|
379
|
+
let p = prompt;
|
|
380
|
+
if (HANDOFF_MODE() === 'full') {
|
|
381
|
+
const ctx = buildContext(results);
|
|
382
|
+
if (ctx) p += `\n\n【工作背景:前序智能体的产出摘要(完整版见下方文件)】\n${ctx}`;
|
|
383
|
+
} else {
|
|
384
|
+
const docs = results.filter(r => r.output || r.error).map(r => buildHandoffDoc(r)).join('\n\n');
|
|
385
|
+
if (docs) p += `\n\n【交接文档(前序智能体留给你的,含任务进度与成果文件位置)】\n${docs}\n\n请先通过「成果文件」路径读取上游完整产出后再开工,避免仅凭摘要行事。`;
|
|
386
|
+
}
|
|
387
|
+
const files = results.filter(r => r.outputPath).map(r => `- ${r.agent.name}(${r.phase || 'work'}):${r.outputPath}`);
|
|
388
|
+
if (files.length) p += `\n\n【完整产出文件】\n${files.join('\n')}`;
|
|
389
|
+
if (history) p += `\n\n【会话背景(本会话此前的对话)】\n${history}`;
|
|
390
|
+
return p;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------- 流转事件记录(供流转视图绘制,失败静默不影响主流程) ----------
|
|
394
|
+
let storeFlow = null;
|
|
395
|
+
try { storeFlow = require('./store'); } catch { storeFlow = null; }
|
|
396
|
+
function logFlow(ev) {
|
|
397
|
+
try { if (storeFlow) storeFlow.addFlowEvent(ev); } catch { /* 忽略 */ }
|
|
398
|
+
}
|
|
399
|
+
function newRunId() {
|
|
400
|
+
return 'r' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------- JSON 提取/引用解析 ----------
|
|
404
|
+
function extractPlanJSON(text) {
|
|
405
|
+
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
406
|
+
if (fence) {
|
|
407
|
+
try { return JSON.parse(fence[1].trim()); } catch { /* 尝试裸 JSON */ }
|
|
408
|
+
}
|
|
409
|
+
const s = text.indexOf('{');
|
|
410
|
+
const e = text.lastIndexOf('}');
|
|
411
|
+
if (s >= 0 && e > s) {
|
|
412
|
+
try { return JSON.parse(text.slice(s, e + 1)); } catch { /* 非法 */ }
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function planThought(text) {
|
|
418
|
+
return text.replace(/```[\s\S]*?```/g, '').trim();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 智能体引用解析:兼容多种写法
|
|
422
|
+
// 「工程师」名称 / 「oc-2」ID / 「工程师(oc-2)」组合 / @前缀 / 含引号
|
|
423
|
+
function resolveAgentRef(ref, agents) {
|
|
424
|
+
if (typeof ref !== 'string') return null;
|
|
425
|
+
let t = ref.trim().replace(/^["'「【((\s]+|["'」】))\s]+$/g, '');
|
|
426
|
+
if (t.startsWith('@')) t = t.slice(1).trim();
|
|
427
|
+
if (!t) return null;
|
|
428
|
+
// 1) 精确匹配名称或 ID
|
|
429
|
+
let hit = agents.find(a => a.id === t) || agents.find(a => a.name === t);
|
|
430
|
+
if (hit) return hit;
|
|
431
|
+
// 2) 「名称(ID)」组合:拆出括号内 ID 与括号外名称分别试
|
|
432
|
+
const m = t.match(/^(.+?)[((]\s*([^()()]+?)\s*[))]$/);
|
|
433
|
+
if (m) {
|
|
434
|
+
hit = agents.find(a => a.id === m[2].trim()) || agents.find(a => a.name === m[1].trim());
|
|
435
|
+
if (hit) return hit;
|
|
436
|
+
}
|
|
437
|
+
// 3) 名称包含匹配(如「资深工程师」→「工程师」)
|
|
438
|
+
hit = agents.find(a => a.name && t.includes(a.name));
|
|
439
|
+
if (hit) return hit;
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 同一阶段内若某步骤指令引用了同组其他智能体(存在依赖),拆到后续阶段串行
|
|
444
|
+
function splitByDependency(groups) {
|
|
445
|
+
const out = [];
|
|
446
|
+
for (const group of groups) {
|
|
447
|
+
let cur = group;
|
|
448
|
+
while (cur.length > 1) {
|
|
449
|
+
const moved = cur.filter(s => cur.some(o => o !== s && s.instruction.includes(o.agentName)));
|
|
450
|
+
const keep = cur.filter(s => !moved.includes(s));
|
|
451
|
+
if (!moved.length || !keep.length) break; // 无依赖或循环引用,保持原样
|
|
452
|
+
out.push(keep);
|
|
453
|
+
cur = moved;
|
|
454
|
+
}
|
|
455
|
+
out.push(cur);
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 校验并归一化为 [{agentId, agentName, instruction}] 的阶段数组
|
|
461
|
+
function normalizePhases(raw, agents, warn) {
|
|
462
|
+
const phases = [];
|
|
463
|
+
if (!raw || !Array.isArray(raw.steps)) return phases;
|
|
464
|
+
for (const group of raw.steps) {
|
|
465
|
+
const list = Array.isArray(group) ? group : [group];
|
|
466
|
+
const steps = [];
|
|
467
|
+
for (const s of list) {
|
|
468
|
+
if (!s || typeof s !== 'object') continue;
|
|
469
|
+
const agent = resolveAgentRef(s.agent, agents);
|
|
470
|
+
if (!agent) { warn(`调度方案中引用了未知智能体(${JSON.stringify(s.agent)}),已忽略该项`); continue; }
|
|
471
|
+
if (!s.instruction || !String(s.instruction).trim()) continue;
|
|
472
|
+
steps.push({ agentId: agent.id, agentName: agent.name, instruction: String(s.instruction).slice(0, 3000) });
|
|
473
|
+
}
|
|
474
|
+
if (steps.length) phases.push(steps);
|
|
475
|
+
}
|
|
476
|
+
return splitByDependency(phases);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function planToText({ thought, phases }) {
|
|
480
|
+
const lines = [];
|
|
481
|
+
if (thought) lines.push(`调度思路:${thought}`);
|
|
482
|
+
phases.forEach((g, i) => {
|
|
483
|
+
lines.push(`阶段 ${i + 1}(${g.length > 1 ? g.length + ' 项并行' : '单执行'}):`);
|
|
484
|
+
for (const s of g) lines.push(` - ${s.agentName}:${s.instruction}`);
|
|
485
|
+
});
|
|
486
|
+
return lines.join('\n');
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------- 验收结论解析 ----------
|
|
490
|
+
// 结构化:{"verdict":"ACCEPT"} 或 {"verdict":"REJECT","issues":[{"agent":"x","requirement":"…","suggestion":"…"}]}
|
|
491
|
+
// 兜底:[ACCEPT]/[REJECT] 前缀标记;无法判定视为通过
|
|
492
|
+
function parseVerdict(text, agents, warn) {
|
|
493
|
+
const j = extractPlanJSON(text);
|
|
494
|
+
if (j && typeof j === 'object' && (j.verdict || Array.isArray(j.issues))) {
|
|
495
|
+
const rawIssues = Array.isArray(j.issues) ? j.issues : [];
|
|
496
|
+
const issues = [];
|
|
497
|
+
for (const it of rawIssues) {
|
|
498
|
+
if (!it || typeof it !== 'object') continue;
|
|
499
|
+
const agent = resolveAgentRef(it.agent, agents);
|
|
500
|
+
if (!agent) { warn(`验收意见引用了未知智能体(${JSON.stringify(it.agent)}),已忽略`); continue; }
|
|
501
|
+
issues.push({
|
|
502
|
+
agentId: agent.id, agentName: agent.name,
|
|
503
|
+
requirement: String(it.requirement || '').slice(0, 2000),
|
|
504
|
+
suggestion: String(it.suggestion || '').slice(0, 3000)
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const accepted = String(j.verdict || '').toUpperCase() === 'ACCEPT' || (String(j.verdict || '').toUpperCase() !== 'REJECT' && issues.length === 0 && rawIssues.length === 0);
|
|
508
|
+
return { accepted, issues: accepted ? [] : issues };
|
|
509
|
+
}
|
|
510
|
+
if (/^\s*\[ACCEPT\]/m.test(text)) return { accepted: true, issues: [] };
|
|
511
|
+
if (/^\s*\[REJECT\]/m.test(text)) return { accepted: false, issues: [] };
|
|
512
|
+
return { accepted: true, issues: [] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---------- 管家调度主流程 ----------
|
|
516
|
+
// opts.resume(断点重跑):{ phases, priorResults, fromStage, baseRun }
|
|
517
|
+
// 跳过规划,前 fromStage-1 个阶段的产出直接复用(priorResults 从落盘文件读回),从 fromStage 起重新执行
|
|
518
|
+
async function runButler(butler, subAgents, message, opts, emit, onMessage) {
|
|
519
|
+
const taskId = opts.taskId || '';
|
|
520
|
+
const scope = opts.scope || 'chat';
|
|
521
|
+
const isStopped = opts.isStopped || (() => false);
|
|
522
|
+
const sessionDir = sessionOutDir(taskId);
|
|
523
|
+
const warnings = [];
|
|
524
|
+
const warn = (w) => { warnings.push(w); emit({ type: 'notice', content: `⚠ ${w}`, taskId }); };
|
|
525
|
+
const runId = newRunId();
|
|
526
|
+
const handoffDoc = HANDOFF_MODE() !== 'full';
|
|
527
|
+
const resume = opts.resume && Array.isArray(opts.resume.phases) && opts.resume.phases.length ? opts.resume : null;
|
|
528
|
+
|
|
529
|
+
// 管家长期记忆开启时:超长会话背景先经辅助小模型压缩(失败自动确定性截断),
|
|
530
|
+
// 压缩结果写回 opts.history 供后续工作背景复用,全链路只压一次
|
|
531
|
+
const memOn = memory.memoryEnabled();
|
|
532
|
+
if (memOn && opts.history && String(opts.history).length > 4000) {
|
|
533
|
+
const c = await memory.compressHistory(opts.history, { limit: 1200, minLen: 4000 });
|
|
534
|
+
opts.history = c.text;
|
|
535
|
+
}
|
|
536
|
+
// 跨会话回忆:从全部历史消息按关键词检索相关片段(确定性、零 token)
|
|
537
|
+
let recallText = '';
|
|
538
|
+
if (memOn) {
|
|
539
|
+
try {
|
|
540
|
+
const all = (storeFlow && storeFlow.getMessages && storeFlow.getMessages('')) || [];
|
|
541
|
+
const curEpoch = taskId ? undefined : (Number((storeFlow && storeFlow.getConfig && storeFlow.getConfig().mainEpoch)) || 0);
|
|
542
|
+
recallText = memory.recallFromMessages(all, message, { excludeTaskId: taskId, excludeEpoch: curEpoch });
|
|
543
|
+
} catch { /* 检索失败静默跳过 */ }
|
|
544
|
+
}
|
|
545
|
+
logFlow({
|
|
546
|
+
run: runId, type: 'start', from: butler.name, summary: String(message).replace(/\s+/g, ' ').slice(0, 200),
|
|
547
|
+
detail: { taskId, mode: resume ? 'rerun' : 'butler', baseRun: resume ? resume.baseRun : undefined, message: String(message).slice(0, 20000) }
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const roster = subAgents.length
|
|
551
|
+
? subAgents.map(a => `- ${a.name}(${a.id}):${a.desc || String(a.systemPrompt || '').slice(0, 60) || '(无描述)'}`).join('\n')
|
|
552
|
+
: '(当前没有任何子智能体,可在右上角「智能体配置」中添加)';
|
|
553
|
+
|
|
554
|
+
// results 按 agent 维度保存最新产出
|
|
555
|
+
const results = [];
|
|
556
|
+
const setResult = (agent, output, error, instruction, phase, outputPath) => {
|
|
557
|
+
const i = results.findIndex(r => r.agent.id === agent.id);
|
|
558
|
+
const entry = { agent, output, error, instruction, phase: phase || 'work', outputPath: outputPath || '' };
|
|
559
|
+
if (i >= 0) results[i] = entry; else results.push(entry);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// ---- 1. 规划(resume 时跳过:直接复用原方案与前置产出) ----
|
|
563
|
+
let phases = [];
|
|
564
|
+
let startStage = 1;
|
|
565
|
+
if (resume) {
|
|
566
|
+
phases = resume.phases;
|
|
567
|
+
results.push(...(resume.priorResults || []));
|
|
568
|
+
startStage = Math.min(Math.max(1, resume.fromStage || 1), phases.length);
|
|
569
|
+
const thought = `从第 ${startStage} 阶段重跑:前 ${startStage - 1} 个阶段的 ${results.length} 份产出直接复用,本阶段起重新执行并验收`;
|
|
570
|
+
emit({ type: 'plan', agentId: butler.id, agentName: butler.name, taskId, thought, phases });
|
|
571
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'plan', content: thought, plan: { thought, phases } });
|
|
572
|
+
logFlow({ run: runId, type: 'plan', from: butler.name, summary: thought.replace(/\s+/g, ' ').slice(0, 200), detail: { phases, rerun: true, baseRun: resume.baseRun } });
|
|
573
|
+
} else {
|
|
574
|
+
|
|
575
|
+
let planPrompt = `你是「管家」调度智能体。请针对下面的用户需求制定调度方案。
|
|
576
|
+
|
|
577
|
+
【用户需求】
|
|
578
|
+
${message}
|
|
579
|
+
|
|
580
|
+
【可用子智能体名单】
|
|
581
|
+
${roster}
|
|
582
|
+
|
|
583
|
+
调度规则:
|
|
584
|
+
- 只能使用名单内的智能体,不得虚构;若缺少所需职能,可在回复中提示用户添加
|
|
585
|
+
- 严格区分串行与并行:只有多项工作【互不依赖、互不需要参考彼此产出】才可放同一阶段并行;只要 B 需要基于/参考 A 的结果,B 必须放在 A 之后的后续阶段串行执行
|
|
586
|
+
- 例如「先方案设计→再编码实现→最后审查」必须拆为多个串行阶段,严禁合并进同一阶段
|
|
587
|
+
- 你只负责规划与调度,自己不动手干活:不要调用任何工具、不要写代码或文件,只输出计划文本与 JSON
|
|
588
|
+
`;
|
|
589
|
+
if (memOn) {
|
|
590
|
+
const memText = memory.memoryBlock(['memory', 'user']);
|
|
591
|
+
if (memText) planPrompt += `\n【管家记忆(跨会话积累的笔记与用户偏好,规划时参考)】\n${memText}\n`;
|
|
592
|
+
}
|
|
593
|
+
if (recallText) planPrompt += `\n【历史回忆(关键词检索到的往期相关片段,仅供背景参考,其结论可能已过时)】\n${recallText}\n`;
|
|
594
|
+
if (opts.history) planPrompt += `\n【会话背景(本会话此前的对话)】\n${opts.history}\n`;
|
|
595
|
+
planPrompt += `
|
|
596
|
+
输出格式(严格遵守):
|
|
597
|
+
1. 先用简洁的中文(2~4 句)向用户说明你的工作计划与安排(这是给用户看的,会直接展示)
|
|
598
|
+
2. 再输出一个 JSON 代码块:
|
|
599
|
+
{"steps": [[{"agent": "智能体名称", "instruction": "给它的具体工作指令"}], ...]}
|
|
600
|
+
外层数组 = 串行阶段(按顺序执行);内层数组 = 该阶段并行执行的多个智能体。
|
|
601
|
+
agent 字段只填智能体名称或 ID 之一(如 "工程师" 或 "oc-2",严禁写 "工程师(oc-2)" 这种组合形式)。
|
|
602
|
+
若是闲聊、简单问答、需要向用户澄清,或无需任何子智能体参与,steps 输出 [],并在第 1 部分直接给出回答。`;
|
|
603
|
+
|
|
604
|
+
// 规划阶段文本不直接流式展示(避免计划展示两次):内部缓冲,解析后只展示一次
|
|
605
|
+
const planEmit = (e) => { if (e.type === 'text') return; emit(e); };
|
|
606
|
+
let planRes = await runAgentOnce(butler, planPrompt, planEmit, 'plan', 'butler', taskId, sessionDir, scope);
|
|
607
|
+
let rawPlan = planRes.output ? extractPlanJSON(planRes.output) : null;
|
|
608
|
+
phases = planRes.output ? normalizePhases(rawPlan, subAgents, warn) : [];
|
|
609
|
+
|
|
610
|
+
// 解析失败或引用全部无效(原始 steps 非空)→ 纠正提示重试一次,避免零阶段直接跳汇总
|
|
611
|
+
const rawHadSteps = !!(rawPlan && Array.isArray(rawPlan.steps) && rawPlan.steps.length > 0);
|
|
612
|
+
if (planRes.output && phases.length === 0 && (rawHadSteps || !rawPlan) && subAgents.length > 0) {
|
|
613
|
+
emit({ type: 'notice', content: '调度方案未能解析,正在让管家重新输出…', taskId });
|
|
614
|
+
const retryPrompt = `你上次的输出无法解析为有效调度方案(常见原因:agent 字段写法不对、引用了名单外的智能体、或没输出 JSON)。
|
|
615
|
+
可用智能体:${subAgents.map(a => a.name).join(' / ')}
|
|
616
|
+
|
|
617
|
+
【用户需求】
|
|
618
|
+
${message}
|
|
619
|
+
|
|
620
|
+
请重新输出:先用 1~3 句中文说明计划,再输出 JSON 代码块:
|
|
621
|
+
{"steps": [[{"agent": "智能体名称", "instruction": "具体指令"}]]}
|
|
622
|
+
若确实无需子智能体(闲聊/澄清),输出 {"steps": []} 并直接回答。`;
|
|
623
|
+
const retryRes = await runAgentOnce(butler, retryPrompt, planEmit, 'plan', 'butler', taskId, sessionDir, scope);
|
|
624
|
+
if (retryRes.output) {
|
|
625
|
+
const raw2 = extractPlanJSON(retryRes.output);
|
|
626
|
+
const phases2 = normalizePhases(raw2, subAgents, warn);
|
|
627
|
+
if (phases2.length > 0 || (raw2 && Array.isArray(raw2.steps) && raw2.steps.length === 0)) {
|
|
628
|
+
planRes = retryRes;
|
|
629
|
+
phases = phases2;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (!planRes.output) {
|
|
635
|
+
const errText = planRes.error || '(管家规划无输出)';
|
|
636
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'plan', content: errText });
|
|
637
|
+
return { ok: false, finalText: errText };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 无需调度:规划输出中的说明文字即最终回答(一次性展示,含被缓冲的说明)
|
|
641
|
+
if (phases.length === 0) {
|
|
642
|
+
const answer = planThought(planRes.output) || planRes.output;
|
|
643
|
+
emit({ type: 'text', content: answer, phase: 'report', role: 'butler', agentId: butler.id, agentName: butler.name, taskId });
|
|
644
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'report', content: answer.slice(0, 20000) });
|
|
645
|
+
// 直答也自省:用户偏好常在闲聊/澄清中表达(失败静默)
|
|
646
|
+
if (memOn) {
|
|
647
|
+
try {
|
|
648
|
+
const r = await memory.reflectOnRun(`用户消息:${String(message).slice(0, 800)}\n\n管家直答(节选):${String(answer).slice(0, 800)}`);
|
|
649
|
+
if (r && r.applied) emit({ type: 'notice', content: `💾 管家记忆已更新:${r.note || `记录 ${r.applied} 条`}`, taskId });
|
|
650
|
+
} catch { /* 自省失败静默 */ }
|
|
651
|
+
}
|
|
652
|
+
return { ok: true, finalText: answer };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// 推送并持久化调度方案(仅界面展示,不进入后续上下文)
|
|
656
|
+
const planMsg = { thought: planThought(planRes.output), phases };
|
|
657
|
+
emit({ type: 'plan', agentId: butler.id, agentName: butler.name, taskId, thought: planMsg.thought, phases: planMsg.phases });
|
|
658
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'plan', content: planToText(planMsg), plan: planMsg });
|
|
659
|
+
logFlow({ run: runId, type: 'plan', from: butler.name, summary: (planMsg.thought || '').replace(/\s+/g, ' ').slice(0, 200), detail: { phases: planMsg.phases } });
|
|
660
|
+
} // endif 非重跑的规划分支
|
|
661
|
+
|
|
662
|
+
// 方案审批关卡:规划确定后、动工前等待用户放行(approval=plan/all 时启用)
|
|
663
|
+
if (phases.length > 0 && !(await approvalGate('plan', `调度方案:${phases.length} 个阶段,涉及 ${[...new Set(phases.flat().map(s => s.agentName || s.agent))].filter(Boolean).join('、')}`, opts, emit, isStopped, taskId))) {
|
|
664
|
+
return { ok: false, finalText: '用户否决了调度方案,编排已终止(可修改需求后重新发起)', stopped: true };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// 共享看板:本轮编排全体协作者的进展/决定/提醒(每轮独立,重跑时前序产出概要一并写入)
|
|
668
|
+
boardInit(sessionDir, message, subAgents.map(a => a.name).join('、') || '(管家独自处理)');
|
|
669
|
+
if (resume && results.length) {
|
|
670
|
+
for (const r of results) boardAppend(sessionDir, `复用·${PHASE_LABEL_CN(r.phase) || r.phase}`, r.agent.name, String(r.output || '').replace(/\s+/g, ' ').slice(0, 100), extractBoardNote(r.output), r.outputPath ? [r.outputPath] : []);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---- 2. 执行各阶段(阶段内并行,分批限流;重跑从 startStage 起步) ----
|
|
674
|
+
for (let i = startStage - 1; i < phases.length; i++) {
|
|
675
|
+
if (isStopped()) {
|
|
676
|
+
emit({ type: 'notice', content: '已手动停止,跳过剩余阶段', taskId });
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
const group = phases[i];
|
|
680
|
+
emit({ type: 'phase', index: i + 1, total: phases.length, parallel: group.length > 1, names: group.map(s => s.agentName).join('、'), taskId });
|
|
681
|
+
// 阶段间交接事件:上阶段产出者 → 本阶段执行者(流转视图的 handoff 边)
|
|
682
|
+
if (i > 0) {
|
|
683
|
+
const upsters = results.filter(r => r.output || r.error);
|
|
684
|
+
for (const up of upsters) {
|
|
685
|
+
for (const step of group) {
|
|
686
|
+
if (step.agentId === up.agent.id) continue;
|
|
687
|
+
logFlow({
|
|
688
|
+
run: runId, type: 'handoff', from: up.agent.name, to: step.agentName, stage: i + 1,
|
|
689
|
+
summary: (extractHandoffNote(up.output) || String(up.instruction || '').replace(/\s+/g, ' ')).slice(0, 200),
|
|
690
|
+
files: up.outputPath ? [up.outputPath] : [],
|
|
691
|
+
detail: { handoffDoc: handoffDoc }
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
for (let j = 0; j < group.length; j += PARALLEL_CAP) {
|
|
697
|
+
const batch = group.slice(j, j + PARALLEL_CAP);
|
|
698
|
+
await Promise.all(batch.map(step => (async () => {
|
|
699
|
+
const agent = subAgents.find(a => a.id === step.agentId);
|
|
700
|
+
logFlow({ run: runId, type: 'dispatch', from: butler.name, to: agent.name, stage: i + 1, summary: String(step.instruction).replace(/\s+/g, ' ').slice(0, 200) });
|
|
701
|
+
let p = `【来自管家的指派】\n${step.instruction}\n\n【用户原始需求】\n${message}`;
|
|
702
|
+
p = appendWorkContext(p, results, opts.history);
|
|
703
|
+
const boardText = boardRead(sessionDir);
|
|
704
|
+
if (boardText) p += `\n\n【共享看板(本轮任务全体协作者的进展与提醒,含并行同伴的已完成阶段)】\n${boardText}`;
|
|
705
|
+
p += '\n\n请输出你的正式结果。' + deliverAsk() + (handoffDoc ? HANDOFF_ASK : '') + BOARD_ASK;
|
|
706
|
+
const { agent: finalAgent, res, trail } = await runWithHandoff(agent, p, subAgents, opts, emit, 'work', 'worker', taskId, sessionDir, scope, isStopped);
|
|
707
|
+
for (const t of trail) {
|
|
708
|
+
logFlow({ run: runId, type: 'handoff', from: t.from, to: t.to, stage: i + 1, summary: `中途委派:${t.reason}`, detail: { delegate: true } });
|
|
709
|
+
}
|
|
710
|
+
setResult(finalAgent, res.output, res.error, step.instruction, 'work', res.outputPath);
|
|
711
|
+
onMessage({ role: 'assistant', agentId: finalAgent.id, agentName: finalAgent.name, actor: 'assistant', phase: 'work', content: (res.output || `[执行出错] ${res.error}`).slice(0, 20000), outputPath: res.outputPath || '' });
|
|
712
|
+
boardAppend(sessionDir, `阶段${i + 1}`, finalAgent.name,
|
|
713
|
+
res.output ? String(res.output).replace(/\s+/g, ' ').slice(0, 100) : `执行失败:${String(res.error || '').slice(0, 80)}`,
|
|
714
|
+
extractBoardNote(res.output),
|
|
715
|
+
extractDeliverFiles(res.output, resolveCwd()));
|
|
716
|
+
logFlow({
|
|
717
|
+
run: runId, type: 'done', to: finalAgent.name, stage: i + 1,
|
|
718
|
+
summary: (res.output ? '完成' : `失败:${String(res.error || '').slice(0, 120)}`) + (trail.length ? `(经 ${trail.length} 次委派)` : ''),
|
|
719
|
+
files: res.outputPath ? [res.outputPath] : [],
|
|
720
|
+
detail: { ok: !!res.output, delegatedFrom: trail.length ? trail[0].from : '' }
|
|
721
|
+
});
|
|
722
|
+
})()));
|
|
723
|
+
}
|
|
724
|
+
if (isStopped()) break;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// ---- 3. 验收 → 返工循环 ----
|
|
728
|
+
let accepted = false;
|
|
729
|
+
let reworks = 0;
|
|
730
|
+
while (true) {
|
|
731
|
+
if (isStopped()) {
|
|
732
|
+
emit({ type: 'notice', content: '已手动停止,跳过验收与汇总', taskId });
|
|
733
|
+
return { ok: false, finalText: '已手动停止(各智能体的阶段产出已保存在会话产出目录)', stopped: true };
|
|
734
|
+
}
|
|
735
|
+
const round = reworks + 1;
|
|
736
|
+
const workText = results.map(r =>
|
|
737
|
+
`【${r.agent.name} 的任务】\n${r.instruction}\n【${r.agent.name} 的产出】\n${(r.output || `(执行失败:${r.error})`).slice(0, CTX_PER_OUTPUT)}`
|
|
738
|
+
).join('\n\n');
|
|
739
|
+
// 产出文件清单:真实成果文件(工作目录)优先,过程存档作为补充供核验
|
|
740
|
+
const realFilesNow = collectRealFiles(results);
|
|
741
|
+
const outFiles = results.filter(r => r.outputPath);
|
|
742
|
+
const filesSection = (realFilesNow.length || outFiles.length)
|
|
743
|
+
? `\n【产出文件(如需核验细节可按路径读取)】\n${[
|
|
744
|
+
...realFilesNow.map(f => `- 成果文件(最终交付物):${f}`),
|
|
745
|
+
...outFiles.map(r => `- ${r.agent.name}的过程存档(系统保存的完整输出,非最终成果):${r.outputPath}`)
|
|
746
|
+
].join('\n')}\n`
|
|
747
|
+
: '';
|
|
748
|
+
|
|
749
|
+
// 自动核查:验收前由系统本地执行的确定性检查(文件/语法/JSON/占位符/自定义命令)
|
|
750
|
+
let autoText = '';
|
|
751
|
+
try {
|
|
752
|
+
const auto = await runAutoChecks(results);
|
|
753
|
+
if (auto && auto.text) {
|
|
754
|
+
autoText = `\n【客观核查结果(系统自动执行的事实核查,非智能体自述,验收必须参考)】\n${auto.text}\n`;
|
|
755
|
+
emit({ type: 'notice', content: `🔬 自动核查:${auto.passCount} 项通过${auto.failCount ? `,${auto.failCount} 项未通过(详情已交给验收)` : ''}`, taskId });
|
|
756
|
+
logFlow({ run: runId, type: 'autocheck', from: butler.name, round, summary: `${auto.passCount} 项通过${auto.failCount ? `,${auto.failCount} 项未通过` : ''}`, detail: { items: auto.items.slice(0, 40) } });
|
|
757
|
+
}
|
|
758
|
+
} catch { /* 核查异常不影响验收 */ }
|
|
759
|
+
|
|
760
|
+
const verifyPrompt = `你是「管家」。第 ${round} 轮验收:请核对各子智能体的工作成果是否满足用户需求。
|
|
761
|
+
|
|
762
|
+
【用户原始需求】
|
|
763
|
+
${message}
|
|
764
|
+
${memOn && memory.memoryBlock(['user']) ? `\n【用户偏好(验收标准参考,如语言、格式、风格偏好)】\n${memory.memoryBlock(['user'])}\n` : ''}
|
|
765
|
+
${boardRead(sessionDir, 1200) ? `【共享看板(各智能体自报的进展与提醒,仅供交叉参照)】\n${boardRead(sessionDir, 1200)}\n` : ''}
|
|
766
|
+
【各智能体的任务与产出】
|
|
767
|
+
${workText.slice(0, 24000)}
|
|
768
|
+
${filesSection}${autoText}
|
|
769
|
+
${reworks > 0 ? '\n(注:此前已反馈过问题,请重点核对是否已按建议完善)\n' : ''}
|
|
770
|
+
输出格式(严格遵守):
|
|
771
|
+
1. 先用 1~2 句中文向用户说明验收结论(若客观核查有未通过项,必须在结论中点名说明)
|
|
772
|
+
2. 再输出 JSON:全部合格输出 {"verdict":"ACCEPT"};存在问题输出 {"verdict":"REJECT","issues":[{"agent":"智能体名称","requirement":"必须满足的要求","suggestion":"具体完善建议"}]}
|
|
773
|
+
只有确有问题才 REJECT;issues 只列需要返工的智能体,不要把合格的也列进去。客观核查未通过的项,对应的智能体必须列入 issues(除非与用户需求确实无关)。`;
|
|
774
|
+
|
|
775
|
+
const verifyRes = await runAgentOnce(butler, verifyPrompt, emit, 'review', 'butler', taskId, sessionDir, scope);
|
|
776
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'review', content: (verifyRes.output || `[验收出错] ${verifyRes.error}`).slice(0, 20000), outputPath: verifyRes.outputPath || '' });
|
|
777
|
+
if (!verifyRes.output) break; // 验收失败无法判定,直接交付
|
|
778
|
+
|
|
779
|
+
const verdict = parseVerdict(verifyRes.output, subAgents, warn);
|
|
780
|
+
if (verdict.accepted) {
|
|
781
|
+
accepted = true;
|
|
782
|
+
emit({ type: 'verify', round, accepted: true, taskId });
|
|
783
|
+
logFlow({ run: runId, type: 'verify', from: butler.name, round, summary: '验收通过' });
|
|
784
|
+
break;
|
|
785
|
+
}
|
|
786
|
+
if (reworks >= MAX_REWORK) {
|
|
787
|
+
emit({ type: 'verify', round, accepted: false, taskId, note: `已达最大返工轮数(${MAX_REWORK}),交付当前版本` });
|
|
788
|
+
logFlow({ run: runId, type: 'verify', from: butler.name, round, summary: `验收未通过(已达最大返工轮数 ${MAX_REWORK},交付当前版本)` });
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
reworks++;
|
|
792
|
+
|
|
793
|
+
// 返工名单:有结构化 issues 用之;否则全部产出者带整段验收意见返工
|
|
794
|
+
let reworkList;
|
|
795
|
+
if (verdict.issues.length > 0) {
|
|
796
|
+
reworkList = verdict.issues.map(it => {
|
|
797
|
+
const r = results.find(x => x.agent.id === it.agentId);
|
|
798
|
+
return r ? { ...it, instruction: r.instruction } : null;
|
|
799
|
+
}).filter(Boolean);
|
|
800
|
+
} else {
|
|
801
|
+
reworkList = results.filter(r => r.output || r.error).map(r => ({
|
|
802
|
+
agentId: r.agent.id, agentName: r.agent.name, requirement: '按验收意见完善',
|
|
803
|
+
suggestion: planThought(verifyRes.output).slice(0, 3000), instruction: r.instruction
|
|
804
|
+
}));
|
|
805
|
+
}
|
|
806
|
+
if (reworkList.length === 0) break;
|
|
807
|
+
|
|
808
|
+
emit({
|
|
809
|
+
type: 'verify', round, accepted: false, taskId,
|
|
810
|
+
note: `验收未通过,第 ${reworks} 轮返工:${reworkList.map(x => x.agentName).join('、')}`
|
|
811
|
+
});
|
|
812
|
+
logFlow({ run: runId, type: 'verify', from: butler.name, round, summary: `验收未通过 → 第 ${reworks} 轮返工:${reworkList.map(x => x.agentName).join('、')}` });
|
|
813
|
+
for (const it of reworkList) {
|
|
814
|
+
const r = results.find(x => x.agent.id === it.agentId);
|
|
815
|
+
let p = `【来自管家的返工要求】${it.requirement}\n【完善建议】${it.suggestion}\n\n【你上次的产出】\n${(r.output || `(执行失败:${r.error})`).slice(0, CTX_PER_OUTPUT)}\n\n【你上次的任务】\n${it.instruction}\n\n【用户原始需求】\n${message}`;
|
|
816
|
+
if (r.outputPath) p += `\n\n【你上次的完整产出文件】${r.outputPath}(建议先读取完整版再修改)`;
|
|
817
|
+
const ctxOthers = buildContext(results.filter(x => x.agent.id !== it.agentId));
|
|
818
|
+
if (ctxOthers) p += `\n\n【工作背景:其他智能体的产出】\n${ctxOthers}`;
|
|
819
|
+
const otherFiles = results.filter(x => x.agent.id !== it.agentId && x.outputPath).map(x => `- ${x.agent.name}:${x.outputPath}`);
|
|
820
|
+
if (otherFiles.length) p += `\n\n【其他智能体的完整产出文件】\n${otherFiles.join('\n')}`;
|
|
821
|
+
p += '\n\n请在原有产出基础上完善,不要从零重复劳动。' + deliverAsk() + (handoffDoc ? HANDOFF_ASK : '');
|
|
822
|
+
logFlow({ run: runId, type: 'rework', from: butler.name, to: r.agent.name, round: reworks, summary: `${it.requirement}|建议:${String(it.suggestion || '').replace(/\s+/g, ' ').slice(0, 150)}` });
|
|
823
|
+
const res = await runAgentOnce(r.agent, p, emit, 'work', 'worker', taskId, sessionDir, scope);
|
|
824
|
+
setResult(r.agent, res.output || r.output, res.output ? undefined : (res.error || r.error), it.instruction, 'work', res.outputPath || r.outputPath);
|
|
825
|
+
onMessage({ role: 'assistant', agentId: r.agent.id, agentName: r.agent.name, actor: 'assistant', phase: 'work', content: (res.output || `[返工出错] ${res.error}`).slice(0, 20000), outputPath: res.outputPath || '' });
|
|
826
|
+
if (res.output) boardAppend(sessionDir, `返工${reworks}`, r.agent.name, String(res.output).replace(/\s+/g, ' ').slice(0, 100), extractBoardNote(res.output), extractDeliverFiles(res.output, resolveCwd()));
|
|
827
|
+
logFlow({
|
|
828
|
+
run: runId, type: 'done', to: r.agent.name, round: reworks,
|
|
829
|
+
summary: (res.output ? `第 ${reworks} 轮返工完成` : `返工失败:${String(res.error || '').slice(0, 120)}`),
|
|
830
|
+
files: res.outputPath ? [res.outputPath] : [],
|
|
831
|
+
detail: { ok: !!res.output, rework: true }
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ---- 4. 汇总:面向用户的正式回答 ----
|
|
837
|
+
// 交付审批关卡:验收通过后、正式交付前等待用户放行(approval=verify/all 时启用)
|
|
838
|
+
if (!(await approvalGate('verify', `交付确认:${accepted ? '验收通过' : `经 ${reworks} 轮返工仍有残留问题`}`, opts, emit, isStopped, taskId))) {
|
|
839
|
+
return { ok: false, finalText: '用户否决了本次交付,编排已终止(各智能体产出已保存在会话产出目录,可在流转视图中断点重跑)', stopped: true };
|
|
840
|
+
}
|
|
841
|
+
const outs = results.map(r => `【${r.agent.name}】\n${r.output || `(执行失败:${r.error})`}`).join('\n\n');
|
|
842
|
+
const deliverFiles = results.filter(r => r.outputPath);
|
|
843
|
+
// 真实成果文件:智能体在工作目录中创建/修改的文件(用户最终要的东西,完整绝对路径)
|
|
844
|
+
const realFiles = collectRealFiles(results);
|
|
845
|
+
const realSection = realFiles.length
|
|
846
|
+
? `\n【成果文件完整路径(智能体在工作目录中真实创建的文件,务必原样照抄给用户,一个都不能漏)】\n${realFiles.map(f => `- ${f}`).join('\n')}`
|
|
847
|
+
: (deliverFiles.length
|
|
848
|
+
? `\n【本次没有在工作目录中创建文件;以下是系统自动保存的各智能体过程存档(完整输出记录,非最终成果文件,如需提及请注明是过程记录)】\n${deliverFiles.map(r => `- ${r.agent.name} 的${PHASE_LABEL_CN(r.phase) || '阶段'}存档:${r.outputPath}`).join('\n')}`
|
|
849
|
+
: '\n(本次工作没有落盘的文件)');
|
|
850
|
+
const sumPrompt = `你是「管家」。各子智能体已完成工作${accepted ? `(验收通过,共 ${reworks} 轮返工)` : `(经 ${reworks} 轮返工仍未完全达标,请如实向用户说明残留问题)`}。请向用户输出最终正式回答,用中文,要求:
|
|
851
|
+
- 开头用简明准确的 3~5 句概括最终结果,直接回应用户需求,让用户一眼看懂做成了什么
|
|
852
|
+
- 回答末尾给出「成果文件」一节:原样照抄上方成果文件的完整路径(从根目录开始,不要改写、不要省略、不要缩写),并各用一句话说明文件内容
|
|
853
|
+
- 路径中禁止出现相对路径或单独文件名;如智能体产出中提到的文件不在上方清单里,不要列入
|
|
854
|
+
- 没有任何成果文件时,不要编造「成果文件」一节
|
|
855
|
+
- 结构清晰、结论明确,不要输出 JSON
|
|
856
|
+
|
|
857
|
+
【用户原始需求】
|
|
858
|
+
${message}
|
|
859
|
+
|
|
860
|
+
【各智能体产出】
|
|
861
|
+
${outs.slice(0, 24000)}
|
|
862
|
+
${realSection}`;
|
|
863
|
+
const sumRes = await runAgentOnce(butler, sumPrompt, emit, 'report', 'butler', taskId, sessionDir, scope);
|
|
864
|
+
const finalText = sumRes.output || outs || sumRes.error || '';
|
|
865
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'report', content: (finalText || '(无输出)').slice(0, 20000), outputPath: sumRes.outputPath || '' });
|
|
866
|
+
logFlow({
|
|
867
|
+
run: runId, type: 'finish', from: butler.name,
|
|
868
|
+
summary: String(finalText).replace(/\s+/g, ' ').slice(0, 200),
|
|
869
|
+
files: realFiles.length ? realFiles : deliverFiles.map(r => r.outputPath),
|
|
870
|
+
detail: { accepted, reworks, realFiles }
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
// ---- 5. 自省:辅助小模型从本轮提取值得长期记住的偏好/事实(失败静默,绝不阻塞交付) ----
|
|
874
|
+
if (memOn) {
|
|
875
|
+
try {
|
|
876
|
+
const digest = `用户需求:${String(message).slice(0, 800)}\n\n调度安排:${phases.map((st, i) => `第${i + 1}阶段 ${st.map(s => s.agent).join('、')}`).join(';').slice(0, 300)}\n\n最终交付(节选):${String(finalText).slice(0, 1200)}\n\n验收情况:${accepted ? '通过' : '未完全通过'}`;
|
|
877
|
+
const r = await memory.reflectOnRun(digest);
|
|
878
|
+
if (r && r.applied) emit({ type: 'notice', content: `💾 管家记忆已更新:${r.note || `记录 ${r.applied} 条`}`, taskId });
|
|
879
|
+
} catch { /* 自省失败静默 */ }
|
|
880
|
+
}
|
|
881
|
+
return { ok: !sumRes.error || results.some(r => r.output), finalText };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------- @点名:按点名顺序串行流水线(产出作为后续背景) ----------
|
|
885
|
+
async function runMentioned(mentionAgents, message, opts, emit, onMessage) {
|
|
886
|
+
const taskId = opts.taskId || '';
|
|
887
|
+
const scope = opts.scope || 'chat';
|
|
888
|
+
const isStopped = opts.isStopped || (() => false);
|
|
889
|
+
const sessionDir = sessionOutDir(taskId);
|
|
890
|
+
const results = [];
|
|
891
|
+
const runId = newRunId();
|
|
892
|
+
const handoffDoc = HANDOFF_MODE() !== 'full';
|
|
893
|
+
logFlow({ run: runId, type: 'start', from: mentionAgents[0] && mentionAgents[0].name, summary: String(message).replace(/\s+/g, ' ').slice(0, 200), detail: { taskId, mode: 'pipeline', members: mentionAgents.map(a => a.name) } });
|
|
894
|
+
for (let i = 0; i < mentionAgents.length; i++) {
|
|
895
|
+
const agent = mentionAgents[i];
|
|
896
|
+
if (isStopped()) {
|
|
897
|
+
emit({ type: 'notice', content: '已手动停止,跳过剩余智能体', taskId });
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
const role = agent.id === 'butler' ? 'butler' : 'worker';
|
|
901
|
+
// 流水线交接事件:上一个智能体 → 当前智能体
|
|
902
|
+
if (i > 0) {
|
|
903
|
+
const up = results[results.length - 1];
|
|
904
|
+
if (up && (up.output || up.error)) {
|
|
905
|
+
logFlow({
|
|
906
|
+
run: runId, type: 'handoff', from: up.agent.name, to: agent.name, stage: i + 1,
|
|
907
|
+
summary: (extractHandoffNote(up.output) || String(message).replace(/\s+/g, ' ')).slice(0, 200),
|
|
908
|
+
files: up.outputPath ? [up.outputPath] : []
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
logFlow({ run: runId, type: 'dispatch', from: i === 0 ? '用户' : mentionAgents[i - 1].name, to: agent.name, stage: i + 1, summary: String(message).replace(/\s+/g, ' ').slice(0, 200) });
|
|
913
|
+
const p = appendWorkContext(message, results, opts.history) + (handoffDoc && role === 'worker' ? HANDOFF_ASK : '');
|
|
914
|
+
// 中途委派:@点名流水线同样允许转交给名单内其他智能体(含管家改派子智能体之外的对象)
|
|
915
|
+
const { agent: finalAgent, res, trail } = await runWithHandoff(agent, p, mentionAgents, opts, emit, 'work', role, taskId, sessionDir, scope, isStopped);
|
|
916
|
+
for (const t of trail) logFlow({ run: runId, type: 'handoff', from: t.from, to: t.to, stage: i + 1, summary: `中途委派:${t.reason}`, detail: { delegate: true } });
|
|
917
|
+
results.push({ agent: finalAgent, output: res.output, error: res.error, instruction: String(message).slice(0, 300), phase: 'work', outputPath: res.outputPath });
|
|
918
|
+
if (res.output || res.error) {
|
|
919
|
+
onMessage({ role: 'assistant', agentId: finalAgent.id, agentName: finalAgent.name, actor: 'assistant', phase: 'work', content: (res.output || `[执行出错] ${res.error}`).slice(0, 20000), outputPath: res.outputPath || '' });
|
|
920
|
+
}
|
|
921
|
+
logFlow({
|
|
922
|
+
run: runId, type: 'done', to: finalAgent.name, stage: i + 1,
|
|
923
|
+
summary: (res.output ? '完成' : `失败:${String(res.error || '').slice(0, 120)}`) + (trail.length ? `(经 ${trail.length} 次委派)` : ''),
|
|
924
|
+
files: res.outputPath ? [res.outputPath] : [],
|
|
925
|
+
detail: { ok: !!res.output }
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
const finalText = results.map(r => r.output).filter(Boolean).join('\n\n');
|
|
929
|
+
logFlow({ run: runId, type: 'finish', from: mentionAgents[mentionAgents.length - 1] && mentionAgents[mentionAgents.length - 1].name, summary: String(finalText).replace(/\s+/g, ' ').slice(0, 200), files: results.filter(r => r.outputPath).map(r => r.outputPath), detail: { mode: 'pipeline' } });
|
|
930
|
+
return { ok: results.some(r => r.output), finalText, stopped: isStopped() };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// ---------- 圆桌讨论:多智能体自由发言 + 管家主持人(借鉴 AutoGen 群聊辩论 / ChatDev 双智能体对话对) ----------
|
|
934
|
+
// 与管家调度的区别:没有派活与验收,成员围绕主题轮流发言(看得到彼此观点,可反驳),
|
|
935
|
+
// 每轮结束由主持人判定「收敛 / 继续深入(带聚焦问题)」,最后输出结构化总结。
|
|
936
|
+
// Token 护栏:发言限字数、记录截断、最多 N 轮(.env AGENTS_CHAT_ROUNDTABLE_ROUNDS,默认 2)、可提前收敛
|
|
937
|
+
const ROUNDTABLE_MAX_ROUNDS = Number(process.env.AGENTS_CHAT_ROUNDTABLE_ROUNDS) > 0
|
|
938
|
+
? Number(process.env.AGENTS_CHAT_ROUNDTABLE_ROUNDS) : 2;
|
|
939
|
+
const ROUNDTABLE_TRANSCRIPT_LIMIT = 9000; // 发言记录传给每个发言者的截断长度
|
|
940
|
+
|
|
941
|
+
function transcriptText(transcript) {
|
|
942
|
+
const s = transcript.filter(t => t.text).map(t => `【${t.name}】\n${t.text}`).join('\n\n');
|
|
943
|
+
return s.length > ROUNDTABLE_TRANSCRIPT_LIMIT ? s.slice(s.length - ROUNDTABLE_TRANSCRIPT_LIMIT) + '…(更早发言已截断)' : s;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// 主持人判定:CONVERGED / CONTINUE(带聚焦问题);解析失败按已收敛处理,避免无谓续轮
|
|
947
|
+
function parseModerate(text) {
|
|
948
|
+
const j = extractPlanJSON(text);
|
|
949
|
+
if (j && typeof j === 'object' && typeof j.verdict === 'string') {
|
|
950
|
+
const v = j.verdict.toUpperCase();
|
|
951
|
+
if (v === 'CONTINUE') {
|
|
952
|
+
return { converged: false, question: String(j.question || '').slice(0, 300) };
|
|
953
|
+
}
|
|
954
|
+
return { converged: true, question: '' };
|
|
955
|
+
}
|
|
956
|
+
return { converged: true, question: '' };
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function runRoundtable(butler, participants, message, opts, emit, onMessage) {
|
|
960
|
+
const taskId = opts.taskId || '';
|
|
961
|
+
const scope = opts.scope || 'chat';
|
|
962
|
+
const isStopped = opts.isStopped || (() => false);
|
|
963
|
+
const sessionDir = sessionOutDir(taskId);
|
|
964
|
+
const runId = newRunId();
|
|
965
|
+
const members = participants.map(a => a.name);
|
|
966
|
+
|
|
967
|
+
const memBlockRT = memory.memoryEnabled() ? memory.memoryBlock(['memory', 'user']) : '';
|
|
968
|
+
const topicBlock = `【讨论主题】\n${message}`
|
|
969
|
+
+ (memBlockRT ? `\n\n【管家记忆(跨会话笔记与用户偏好,讨论时参考)】\n${memBlockRT}` : '')
|
|
970
|
+
+ (opts.history ? `\n\n【会话背景(本会话此前的对话)】\n${String(opts.history).slice(0, 3000)}` : '');
|
|
971
|
+
|
|
972
|
+
logFlow({
|
|
973
|
+
run: runId, type: 'start', from: butler.name, summary: String(message).replace(/\s+/g, ' ').slice(0, 200),
|
|
974
|
+
detail: { taskId, mode: 'roundtable', members, message: String(message).slice(0, 20000) }
|
|
975
|
+
});
|
|
976
|
+
emit({ type: 'notice', content: `💬 圆桌讨论开始:${members.join('、')}(最多 ${ROUNDTABLE_MAX_ROUNDS} 轮,主持人可在达成共识后提前结束)`, taskId });
|
|
977
|
+
|
|
978
|
+
const transcript = [];
|
|
979
|
+
let focusing = '';
|
|
980
|
+
let stopped = false;
|
|
981
|
+
|
|
982
|
+
for (let round = 1; round <= ROUNDTABLE_MAX_ROUNDS; round++) {
|
|
983
|
+
for (let i = 0; i < participants.length; i++) {
|
|
984
|
+
const agent = participants[i];
|
|
985
|
+
if (isStopped()) { stopped = true; break; }
|
|
986
|
+
emit({
|
|
987
|
+
type: 'phase', index: (round - 1) * participants.length + i + 1,
|
|
988
|
+
total: ROUNDTABLE_MAX_ROUNDS * participants.length,
|
|
989
|
+
parallel: false, names: `第${round}轮 · ${agent.name} 发言`, taskId
|
|
990
|
+
});
|
|
991
|
+
logFlow({ run: runId, type: 'dispatch', from: butler.name, to: agent.name, stage: round, summary: `第${round}轮发言` });
|
|
992
|
+
const p = `【圆桌讨论 · 第 ${round} 轮】\n你是「${agent.name}」,正与多位协作者围绕同一主题讨论。
|
|
993
|
+
|
|
994
|
+
${topicBlock}
|
|
995
|
+
|
|
996
|
+
【发言记录(按时间序)】
|
|
997
|
+
${transcript.length ? transcriptText(transcript) : '(你第一个发言)'}
|
|
998
|
+
${focusing ? `\n【主持人聚焦问题】\n${focusing}\n` : ''}
|
|
999
|
+
请发表你的观点,600 字以内:明确表态(认同/不认同谁、为什么),补充新信息或提出反驳,不要重复已说过的内容。直接输出发言内容。`;
|
|
1000
|
+
const res = await runAgentOnce(agent, p, emit, 'talk', 'worker', taskId, sessionDir, scope);
|
|
1001
|
+
if (res.output || res.error) {
|
|
1002
|
+
transcript.push({ name: agent.name, text: res.output || `(发言失败:${res.error})` });
|
|
1003
|
+
// 发言仅展示与存档(phase=talk 不进入后续会话上下文),结论由总结承载
|
|
1004
|
+
onMessage({ role: 'assistant', agentId: agent.id, agentName: agent.name, actor: 'assistant', phase: 'talk', content: (res.output || `[发言出错] ${res.error}`).slice(0, 20000), outputPath: res.outputPath || '' });
|
|
1005
|
+
}
|
|
1006
|
+
logFlow({
|
|
1007
|
+
run: runId, type: 'done', to: agent.name, stage: round,
|
|
1008
|
+
summary: (res.output ? `第${round}轮发言完成` : `发言失败:${String(res.error || '').slice(0, 120)}`),
|
|
1009
|
+
files: res.outputPath ? [res.outputPath] : [],
|
|
1010
|
+
detail: { ok: !!res.output, talk: true }
|
|
1011
|
+
});
|
|
1012
|
+
}
|
|
1013
|
+
if (stopped || isStopped()) { stopped = true; break; }
|
|
1014
|
+
|
|
1015
|
+
// 每轮结束后主持人判定(最后一轮无需判定,直接总结)
|
|
1016
|
+
if (round < ROUNDTABLE_MAX_ROUNDS) {
|
|
1017
|
+
const modPrompt = `你是「管家」,本次圆桌讨论的主持人。第 ${round} 轮发言结束,请判定讨论是否已经收敛。
|
|
1018
|
+
|
|
1019
|
+
${topicBlock}
|
|
1020
|
+
|
|
1021
|
+
【发言记录(按时间序)】
|
|
1022
|
+
${transcriptText(transcript)}
|
|
1023
|
+
|
|
1024
|
+
输出格式(严格遵守):
|
|
1025
|
+
1. 先用 1 句中文说明判定理由
|
|
1026
|
+
2. 再输出 JSON:观点已充分交锋、可以总结,输出 {"verdict":"CONVERGED"};仍存在重要分歧或信息缺口需要深入,输出 {"verdict":"CONTINUE","question":"给下一轮讨论的聚焦问题(一句话)"}`;
|
|
1027
|
+
const modRes = await runAgentOnce(butler, modPrompt, (e) => { if (e.type === 'text') return; emit(e); }, 'review', 'butler', taskId, sessionDir, scope);
|
|
1028
|
+
const mv = parseModerate(modRes.output || '');
|
|
1029
|
+
if (mv.converged) {
|
|
1030
|
+
emit({ type: 'notice', content: `⚖ 主持人判定:讨论已收敛,进入总结`, taskId });
|
|
1031
|
+
logFlow({ run: runId, type: 'moderate', from: butler.name, round, summary: '判定:已收敛,进入总结' });
|
|
1032
|
+
break;
|
|
1033
|
+
}
|
|
1034
|
+
focusing = mv.question || '请围绕核心分歧继续深入';
|
|
1035
|
+
emit({ type: 'notice', content: `⚖ 主持人判定:继续深入 → ${focusing}`, taskId });
|
|
1036
|
+
logFlow({ run: runId, type: 'moderate', from: butler.name, round, summary: `判定:继续深入(第 ${round + 1} 轮聚焦:${focusing.replace(/\s+/g, ' ').slice(0, 120)})` });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
if (stopped) {
|
|
1041
|
+
emit({ type: 'notice', content: '已手动停止,圆桌讨论中止(已发言内容保存在会话记录)', taskId });
|
|
1042
|
+
return { ok: false, finalText: '圆桌讨论已手动停止', stopped: true };
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// 总结:共识 / 分歧 / 建议行动(phase=report,进入后续会话上下文)
|
|
1046
|
+
const sumPrompt = `你是「管家」。圆桌讨论已结束,请向用户输出圆桌讨论总结。
|
|
1047
|
+
|
|
1048
|
+
${topicBlock}
|
|
1049
|
+
|
|
1050
|
+
【发言记录(按时间序)】
|
|
1051
|
+
${transcriptText(transcript)}
|
|
1052
|
+
|
|
1053
|
+
要求:
|
|
1054
|
+
- 开头 2~3 句概括讨论整体走向与最终共识
|
|
1055
|
+
- 分节列出:「共识」各方一致同意的结论;「分歧」仍无定论的争议点(注明持方);「建议行动」接下来建议怎么做
|
|
1056
|
+
- 结论明确,不编造任何发言者未说过的内容`;
|
|
1057
|
+
const sumRes = await runAgentOnce(butler, sumPrompt, emit, 'report', 'butler', taskId, sessionDir, scope);
|
|
1058
|
+
const finalText = sumRes.output || transcriptText(transcript) || sumRes.error || '';
|
|
1059
|
+
onMessage({ role: 'assistant', agentId: butler.id, agentName: butler.name, actor: 'butler', phase: 'report', content: (finalText || '(无输出)').slice(0, 20000), outputPath: sumRes.outputPath || '' });
|
|
1060
|
+
logFlow({
|
|
1061
|
+
run: runId, type: 'finish', from: butler.name,
|
|
1062
|
+
summary: String(finalText).replace(/\s+/g, ' ').slice(0, 200),
|
|
1063
|
+
files: sumRes.outputPath ? [sumRes.outputPath] : [],
|
|
1064
|
+
detail: { mode: 'roundtable', members, rounds: Math.min(ROUNDTABLE_MAX_ROUNDS, transcript.length ? Math.ceil(transcript.length / participants.length) : 0) }
|
|
1065
|
+
});
|
|
1066
|
+
return { ok: !!sumRes.output, finalText };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ---------- 任务队列:每个任务一次完整调度(独立会话) ----------
|
|
1070
|
+
// 末尾 @子智能体 的任务由该智能体独立完成;未指派/@管家 则由管家调度
|
|
1071
|
+
// 手动停止:当前任务复位为待执行,剩余任务不再启动
|
|
1072
|
+
async function runTasks(tasks, butler, subAgents, opts, emit, onMessage, onTaskStart, onTaskDone) {
|
|
1073
|
+
const isStopped = opts.isStopped || (() => false);
|
|
1074
|
+
for (const task of tasks) {
|
|
1075
|
+
if (isStopped()) {
|
|
1076
|
+
emit({ type: 'notice', content: '已手动停止,剩余任务保持待执行状态' });
|
|
1077
|
+
break;
|
|
1078
|
+
}
|
|
1079
|
+
const prompt = `请完成以下任务并给出结果:\n${task.title}${task.notes ? `\n\n补充说明:${task.notes}` : ''}`;
|
|
1080
|
+
// 先建历史背景(不含本任务的起始消息),再写入任务会话首条消息
|
|
1081
|
+
const history = opts.getHistory ? opts.getHistory(task.id) : '';
|
|
1082
|
+
if (onTaskStart) onTaskStart(task);
|
|
1083
|
+
// 该任务产生的全部消息都归入对应任务会话
|
|
1084
|
+
const persistTask = (m) => onMessage({ ...m, taskId: task.id });
|
|
1085
|
+
|
|
1086
|
+
const assigned = opts.resolveAssign ? opts.resolveAssign(task) : null;
|
|
1087
|
+
let r;
|
|
1088
|
+
if (assigned && assigned.id !== butler.id) {
|
|
1089
|
+
// 指派子智能体:独立完成,无管家编排
|
|
1090
|
+
emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: assigned.name });
|
|
1091
|
+
emit({ type: 'notice', content: `本任务由 @${assigned.name} 独立完成(无管家调度)`, taskId: task.id });
|
|
1092
|
+
r = await runMentioned([assigned], prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
|
|
1093
|
+
} else {
|
|
1094
|
+
emit({ type: 'task_start', taskId: task.id, title: task.title, agentName: butler.name });
|
|
1095
|
+
r = await runButler(butler, subAgents, prompt, { taskId: task.id, history, scope: opts.scope, isStopped }, emit, persistTask);
|
|
1096
|
+
}
|
|
1097
|
+
let status = r.ok ? 'done' : 'failed';
|
|
1098
|
+
let resultText = (r.finalText || '').trim() || '执行失败';
|
|
1099
|
+
if (isStopped() && !r.ok) {
|
|
1100
|
+
status = 'pending'; // 手动停止的任务回到待执行,可随时重跑
|
|
1101
|
+
resultText = '已手动停止,可重新执行';
|
|
1102
|
+
}
|
|
1103
|
+
onTaskDone(task.id, { status, result: resultText.slice(0, 10000) });
|
|
1104
|
+
emit({ type: 'task_done', taskId: task.id, status, title: task.title });
|
|
1105
|
+
}
|
|
1106
|
+
emit({ type: 'all_done' });
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// ---------- 断点重跑:从流转日志还原编排现场 ----------
|
|
1110
|
+
// 读取一次 run 的全部事件,构造 runButler 的 opts.resume:
|
|
1111
|
+
// - phases:取 plan 事件 detail.phases(v3.7.0 起存完整指令;旧记录截断过,无法安全重跑)
|
|
1112
|
+
// - priorResults:< fromStage 各步骤的产出,从 done 事件的成果文件读回全文(同智能体多阶段取最新)
|
|
1113
|
+
// - message/taskId:取 start 事件(detail.message 完整,兜底 summary)
|
|
1114
|
+
function prepareRerun(events, fromStage, subAgents) {
|
|
1115
|
+
const start = events.find(e => e.type === 'start');
|
|
1116
|
+
const plan = events.find(e => e.type === 'plan');
|
|
1117
|
+
if (!start || !plan) throw new Error('该记录缺少完整的规划信息,无法重跑');
|
|
1118
|
+
const rawPhases = plan.detail && Array.isArray(plan.detail.phases) ? plan.detail.phases : null;
|
|
1119
|
+
if (!rawPhases || !rawPhases.length) throw new Error('该记录为旧版本格式(调度指令不完整),仅 v3.7.0 之后的编排支持重跑');
|
|
1120
|
+
fromStage = Math.max(1, Math.min(Number(fromStage) || 1, rawPhases.length));
|
|
1121
|
+
|
|
1122
|
+
// 校验各步骤的智能体仍然存在(已被删除的剔除并收集提示)
|
|
1123
|
+
const phases = [];
|
|
1124
|
+
const dropped = [];
|
|
1125
|
+
for (const group of rawPhases) {
|
|
1126
|
+
const keep = [];
|
|
1127
|
+
for (const s of (Array.isArray(group) ? group : [group])) {
|
|
1128
|
+
if (!s || !s.agentId || !String(s.instruction || '').trim()) continue;
|
|
1129
|
+
const agent = subAgents.find(a => a.id === s.agentId);
|
|
1130
|
+
if (agent) keep.push({ agentId: agent.id, agentName: agent.name, instruction: String(s.instruction).slice(0, 3000) });
|
|
1131
|
+
else dropped.push(String(s.agentName || s.agentId));
|
|
1132
|
+
}
|
|
1133
|
+
if (keep.length) phases.push(keep);
|
|
1134
|
+
}
|
|
1135
|
+
if (!phases.length) throw new Error('调度方案中引用的智能体已全部不存在,无法重跑');
|
|
1136
|
+
fromStage = Math.min(fromStage, phases.length);
|
|
1137
|
+
|
|
1138
|
+
// 每个智能体最新一次落盘产出(含返工后的版本)
|
|
1139
|
+
const lastFile = new Map(); // agentName -> outputPath
|
|
1140
|
+
for (const e of events) {
|
|
1141
|
+
if (e.type === 'done' && e.to && e.files && e.files[0]) lastFile.set(e.to, e.files[0]);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// < fromStage 的步骤 → priorResults(同智能体多阶段时后一阶段覆盖前一阶段)
|
|
1145
|
+
const byAgent = new Map();
|
|
1146
|
+
for (let i = 0; i < fromStage - 1 && i < phases.length; i++) {
|
|
1147
|
+
for (const s of phases[i]) {
|
|
1148
|
+
const agent = subAgents.find(a => a.id === s.agentId) || { id: s.agentId, name: s.agentName };
|
|
1149
|
+
const f = lastFile.get(s.agentName);
|
|
1150
|
+
let output = '';
|
|
1151
|
+
let outputPath = '';
|
|
1152
|
+
if (f) {
|
|
1153
|
+
try { output = fs.readFileSync(f, 'utf8'); outputPath = f; } catch { /* 文件丢失 */ }
|
|
1154
|
+
}
|
|
1155
|
+
byAgent.set(agent.id, {
|
|
1156
|
+
agent, output,
|
|
1157
|
+
error: output ? undefined : (f ? `(产出文件已丢失:${f})` : '(该智能体当时无落盘产出)'),
|
|
1158
|
+
instruction: s.instruction, phase: 'work', outputPath
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
message: (start.detail && start.detail.message) || start.summary || '',
|
|
1165
|
+
taskId: (start.detail && start.detail.taskId) || '',
|
|
1166
|
+
phases, fromStage, dropped,
|
|
1167
|
+
priorResults: [...byAgent.values()]
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
module.exports = {
|
|
1172
|
+
runButler, runMentioned, runRoundtable, runTasks, prepareRerun, runAutoChecks,
|
|
1173
|
+
// 测试导出(单测用,业务代码请勿依赖)
|
|
1174
|
+
testBoardInit: boardInit, testBoardAppend: boardAppend, testBoardRead: boardRead,
|
|
1175
|
+
testExtractBoardNote: extractBoardNote, testParseHandoff: parseHandoff,
|
|
1176
|
+
testApprovalGate: approvalGate
|
|
1177
|
+
};
|