openclaw-sc 5.38.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/.env.example +13 -0
- package/INSTALL.md +13 -0
- package/LICENSE +233 -0
- package/README.md +123 -0
- package/SECURITY.md +27 -0
- package/index.js +3479 -0
- package/lib/code-review-shared.js +164 -0
- package/lib/config.js +164 -0
- package/lib/constants.js +438 -0
- package/lib/decomposer.js +896 -0
- package/lib/dialog-recall.js +389 -0
- package/lib/env.js +59 -0
- package/lib/level-rules.js +72 -0
- package/lib/log-manager.js +258 -0
- package/lib/preemption.js +278 -0
- package/lib/priority-calc.js +134 -0
- package/lib/prompt-injection.js +748 -0
- package/lib/route-evidence.js +701 -0
- package/lib/shared-fs.js +244 -0
- package/lib/steward-rules.js +648 -0
- package/lib/task-center.js +602 -0
- package/lib/task-chain.js +713 -0
- package/lib/task-checker.js +317 -0
- package/lib/task-profiles.js +255 -0
- package/lib/tcell.js +411 -0
- package/lib/tool-auto-discover.js +522 -0
- package/lib/tool-enrich-subagent.js +375 -0
- package/lib/tool-handlers.js +178 -0
- package/lib/tool-selector.js +459 -0
- package/openclaw.plugin.json +55 -0
- package/package.json +63 -0
- package/security.js +141 -0
- package/tools/bridge.js +3288 -0
- package/tools/dashboard/tk-dashboard.py +262 -0
- package/tools/hippocampus-multi-search.js +1038 -0
- package/tools/hippocampus-sqlite.js +738 -0
- package/tools/hippocampus-store.js +262 -0
- package/tools/mcp-tools.config.json +653 -0
- package/tools/pipeline-engine.js +667 -0
- package/tools/sidecar/_check_tools.py +34 -0
- package/tools/sidecar/sidecar-server.cjs +1360 -0
- package/tools/sidecar/subagent-runner.cjs +1471 -0
- package/tools/system-tools.js +619 -0
- package/vector/README.md +100 -0
- package/vector/usearch-bridge.js +639 -0
- package/vector/usearch-http.js +74 -0
- package/vector/usearch-serve.js +156 -0
- package/workers/worker.js +1419 -0
|
@@ -0,0 +1,1471 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 🦞 sc 子Agent运行器
|
|
4
|
+
*
|
|
5
|
+
* 由 sidecar-server.cjs 通过 spawn 启动的独立Node.js进程。
|
|
6
|
+
* 生命周期:接收参数 → LLM推理 → (可选)工具调用 → 写结果 → 退出
|
|
7
|
+
*
|
|
8
|
+
* 传入参数:JSON字符串(命令行参数或stdin)
|
|
9
|
+
* 结果输出:写到 outputPath 指定的文件
|
|
10
|
+
* 日记输出:写到 diaryPath 指定的文件
|
|
11
|
+
*
|
|
12
|
+
* 安全约束:
|
|
13
|
+
* maxDepth=0, maxChildren=2
|
|
14
|
+
* 禁止危险工具(core_abort/cpu_safetyKill等)
|
|
15
|
+
* 工具失败3次熔断换路
|
|
16
|
+
* 关原生工具(exec/write/edit/web_search等),CPU有的用CPU平替
|
|
17
|
+
* 保留原生工具:browser(登录态)、message(发通知)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ── 配置 ───────────────────────────────────────────────────────────────
|
|
21
|
+
// 子agent禁止调用的工具列表 — 从 mcp-tools.config.json 动态加载
|
|
22
|
+
// 原生工具硬拦截(子Agent无原生工具,此处为双保险)
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
const ACS_LLM_BASE_URL = 'http://127.0.0.1:18801/v1/chat/completions';
|
|
27
|
+
let SUBAGENT_BLOCKED = [];
|
|
28
|
+
try {
|
|
29
|
+
const pkgDir = path.dirname(__dirname);
|
|
30
|
+
const configPath = path.join(pkgDir, 'mcp-tools.config.json');
|
|
31
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
32
|
+
SUBAGENT_BLOCKED = cfg.subagent?.blocked || [];
|
|
33
|
+
console.log('[subagent] mcp-tools.config.json 加载成功:', SUBAGENT_BLOCKED.length, '个blocked工具');
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error('[subagent] ⚠️ 无法加载mcp-tools.config.json,使用安全兜底列表:', e.message);
|
|
36
|
+
SUBAGENT_BLOCKED = [
|
|
37
|
+
'emergencyStop', 'spawnAutonomousAgentCluster',
|
|
38
|
+
'spawnEmergencyApiAgents'
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DANGEROUS_NATIVE_TOOLS = [
|
|
43
|
+
// 原生工具兜底拦截(子Agent不自带原生工具,此处为安全冗余)
|
|
44
|
+
'exec', 'write', 'read', 'edit',
|
|
45
|
+
'sessions_spawn',
|
|
46
|
+
'web_search', 'web_fetch',
|
|
47
|
+
'memory_search', 'memory_get'
|
|
48
|
+
];
|
|
49
|
+
const MAX_TOOL_RETRIES = 3;
|
|
50
|
+
const MCP_URL = 'http://127.0.0.1:18790';
|
|
51
|
+
const SIDECAR_URL = 'http://127.0.0.1:18792';
|
|
52
|
+
const toolCacheByDepth = {}; // 按depth分级的工具列表缓存
|
|
53
|
+
|
|
54
|
+
function positiveInt(value, fallback) {
|
|
55
|
+
const n = Number(value);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const SUBAGENT_LLM_TIMEOUT_MS = positiveInt(process.env.SC_SUBAGENT_LLM_TIMEOUT_MS, 300000);
|
|
60
|
+
const SUBAGENT_MCP_CONNECT_TIMEOUT_MS = positiveInt(process.env.SC_SUBAGENT_MCP_CONNECT_TIMEOUT_MS, 15000);
|
|
61
|
+
const SUBAGENT_MCP_TOOL_TIMEOUT_MS = positiveInt(process.env.SC_SUBAGENT_MCP_TOOL_TIMEOUT_MS, 120000);
|
|
62
|
+
const SUBAGENT_NOTIFY_TIMEOUT_MS = positiveInt(process.env.SC_SUBAGENT_NOTIFY_TIMEOUT_MS, 2000);
|
|
63
|
+
|
|
64
|
+
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
if (e?.name === 'AbortError' || e?.name === 'TimeoutError') {
|
|
71
|
+
throw new Error(`fetch timeout after ${timeoutMs}ms: ${url}`);
|
|
72
|
+
}
|
|
73
|
+
throw e;
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readWithTimeout(reader, timeoutMs, label) {
|
|
80
|
+
let timer;
|
|
81
|
+
const timeout = new Promise((_, reject) => {
|
|
82
|
+
timer = setTimeout(() => reject(new Error(`${label} timeout after ${timeoutMs}ms`)), timeoutMs);
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
return await Promise.race([reader.read(), timeout]);
|
|
86
|
+
} finally {
|
|
87
|
+
if (timer) clearTimeout(timer);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value) {
|
|
92
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function asArray(value) {
|
|
96
|
+
if (Array.isArray(value)) return value;
|
|
97
|
+
if (value === undefined || value === null || value === '') return [];
|
|
98
|
+
return [value];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toAsciiSafeHeaderValue(value, fallback = '', maxChars = 160) {
|
|
102
|
+
const raw = value === undefined || value === null ? '' : String(value);
|
|
103
|
+
const cleaned = raw.replace(/[\u0000-\u001F\u007F]+/g, ' ').trim();
|
|
104
|
+
if (!cleaned) return fallback;
|
|
105
|
+
|
|
106
|
+
if (/^[A-Za-z0-9._~:-]+$/.test(cleaned)) {
|
|
107
|
+
return cleaned.length <= maxChars
|
|
108
|
+
? cleaned
|
|
109
|
+
: `sha256.${crypto.createHash('sha256').update(cleaned, 'utf8').digest('hex').slice(0, 32)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (/^[\x20-\x7E]+$/.test(cleaned)) {
|
|
113
|
+
const compact = cleaned.replace(/[^A-Za-z0-9._~:-]+/g, '_').replace(/^_+|_+$/g, '');
|
|
114
|
+
if (compact && compact.length <= maxChars) return compact;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const encoded = Buffer.from(cleaned, 'utf8').toString('base64url');
|
|
118
|
+
if (encoded.length <= maxChars - 5) return `b64u.${encoded}`;
|
|
119
|
+
|
|
120
|
+
return `sha256.${crypto.createHash('sha256').update(cleaned, 'utf8').digest('hex').slice(0, 32)}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizePolicyPath(value) {
|
|
124
|
+
if (typeof value !== 'string' || value.trim() === '') return '';
|
|
125
|
+
try {
|
|
126
|
+
return path.resolve(value).replace(/\\/g, '/').toLowerCase();
|
|
127
|
+
} catch {
|
|
128
|
+
return value.replace(/\\/g, '/').toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeToolRule(rule) {
|
|
133
|
+
if (typeof rule === 'string') {
|
|
134
|
+
const [tool, action] = rule.split('.', 2);
|
|
135
|
+
return {
|
|
136
|
+
tool: tool || '',
|
|
137
|
+
actions: action ? [action] : [],
|
|
138
|
+
paths: [],
|
|
139
|
+
maxCalls: null,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
if (!isPlainObject(rule)) return null;
|
|
143
|
+
const tool = rule.tool || rule.name || rule.toolName || '';
|
|
144
|
+
const actions = asArray(rule.actions || rule.action).map(String).filter(Boolean);
|
|
145
|
+
const paths = asArray(rule.paths || rule.path || rule.allowedPaths || rule.allowed_paths).map(normalizePolicyPath).filter(Boolean);
|
|
146
|
+
const maxCalls = Number(rule.maxCalls || rule.max_calls || 0) || null;
|
|
147
|
+
return { tool, actions, paths, maxCalls };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function normalizeToolPolicy(policy) {
|
|
151
|
+
if (!isPlainObject(policy)) return { enabled: false, mode: 'default', rules: [], maxToolCalls: null, violation: 'fail_task' };
|
|
152
|
+
const rawRules = [
|
|
153
|
+
...asArray(policy.allowed || policy.allow || policy.rules),
|
|
154
|
+
...asArray(policy.allowedTools || policy.allowed_tools),
|
|
155
|
+
];
|
|
156
|
+
const rules = rawRules.map(normalizeToolRule).filter(r => r && r.tool);
|
|
157
|
+
const allowedPaths = asArray(policy.allowedPaths || policy.allowed_paths).map(normalizePolicyPath).filter(Boolean);
|
|
158
|
+
if (allowedPaths.length > 0) {
|
|
159
|
+
for (const rule of rules) {
|
|
160
|
+
if (rule.tool === 'fileManager' && rule.paths.length === 0) rule.paths = allowedPaths;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const mode = policy.mode || (rules.length > 0 ? 'allowlist' : 'default');
|
|
164
|
+
return {
|
|
165
|
+
enabled: mode === 'allowlist' || rules.length > 0,
|
|
166
|
+
mode,
|
|
167
|
+
rules,
|
|
168
|
+
maxToolCalls: Number(policy.maxToolCalls || policy.max_tool_calls || 0) || null,
|
|
169
|
+
violation: policy.violation || policy.onViolation || policy.on_violation || 'fail_task',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toolPolicySummary(policy) {
|
|
174
|
+
const normalized = normalizeToolPolicy(policy);
|
|
175
|
+
if (!normalized.enabled) return null;
|
|
176
|
+
return {
|
|
177
|
+
mode: normalized.mode,
|
|
178
|
+
allowed: normalized.rules.map(rule => ({
|
|
179
|
+
tool: rule.tool,
|
|
180
|
+
actions: rule.actions,
|
|
181
|
+
paths: rule.paths,
|
|
182
|
+
maxCalls: rule.maxCalls,
|
|
183
|
+
})),
|
|
184
|
+
maxToolCalls: normalized.maxToolCalls,
|
|
185
|
+
violation: normalized.violation,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function filterToolsByPolicy(tools, policy) {
|
|
190
|
+
const normalized = normalizeToolPolicy(policy);
|
|
191
|
+
if (!normalized.enabled) return tools;
|
|
192
|
+
const allowedToolNames = new Set(normalized.rules.map(rule => rule.tool));
|
|
193
|
+
return tools
|
|
194
|
+
.filter(tool => allowedToolNames.has(tool.name))
|
|
195
|
+
.map(tool => {
|
|
196
|
+
const matchingRules = normalized.rules.filter(rule => rule.tool === tool.name);
|
|
197
|
+
const allowedActions = [...new Set(matchingRules.flatMap(rule => rule.actions))].filter(Boolean);
|
|
198
|
+
if (allowedActions.length === 0) return tool;
|
|
199
|
+
const schema = JSON.parse(JSON.stringify(tool.inputSchema || { type: 'object', properties: {} }));
|
|
200
|
+
if (schema.properties?.action) {
|
|
201
|
+
schema.properties.action.enum = allowedActions;
|
|
202
|
+
}
|
|
203
|
+
return { ...tool, inputSchema: schema };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function validateToolPolicyCall(policy, toolName, args, state) {
|
|
208
|
+
const normalized = normalizeToolPolicy(policy);
|
|
209
|
+
if (!normalized.enabled) return { ok: true };
|
|
210
|
+
if (normalized.maxToolCalls !== null && state.totalCalls >= normalized.maxToolCalls) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
reason: `tool_policy_violation:max_tool_calls:${state.totalCalls + 1}>${normalized.maxToolCalls}`,
|
|
214
|
+
detail: { toolName, args },
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const matchingRules = normalized.rules.filter(rule => rule.tool === toolName);
|
|
218
|
+
if (matchingRules.length === 0) {
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
reason: `tool_policy_violation:tool_not_allowed:${toolName}`,
|
|
222
|
+
detail: { toolName, args },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const action = args?.action ? String(args.action) : '';
|
|
226
|
+
const actionRules = matchingRules.filter(rule => rule.actions.length === 0 || rule.actions.includes(action));
|
|
227
|
+
if (actionRules.length === 0) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
reason: `tool_policy_violation:action_not_allowed:${toolName}.${action || 'call'}`,
|
|
231
|
+
detail: { toolName, action, args },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const rulesWithPaths = actionRules.filter(rule => rule.paths.length > 0);
|
|
235
|
+
if (rulesWithPaths.length > 0) {
|
|
236
|
+
const actualPath = normalizePolicyPath(args?.path || args?.dest || args?.target || '');
|
|
237
|
+
const pathAllowed = rulesWithPaths.some(rule => rule.paths.includes(actualPath));
|
|
238
|
+
if (!pathAllowed) {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
reason: `tool_policy_violation:path_not_allowed:${toolName}.${action || 'call'}`,
|
|
242
|
+
detail: { toolName, action, path: args?.path || args?.dest || args?.target || '' },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const specificMax = actionRules.map(rule => rule.maxCalls).filter(v => v !== null);
|
|
247
|
+
if (specificMax.length > 0) {
|
|
248
|
+
const max = Math.min(...specificMax);
|
|
249
|
+
const key = `${toolName}.${action || 'call'}`;
|
|
250
|
+
const count = state.byRule.get(key) || 0;
|
|
251
|
+
if (count >= max) {
|
|
252
|
+
return {
|
|
253
|
+
ok: false,
|
|
254
|
+
reason: `tool_policy_violation:rule_max_calls:${key}:${count + 1}>${max}`,
|
|
255
|
+
detail: { toolName, action, args },
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, action: action || 'call' };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function recordToolPolicyCall(state, toolName, action) {
|
|
263
|
+
state.totalCalls++;
|
|
264
|
+
const key = `${toolName}.${action || 'call'}`;
|
|
265
|
+
state.byRule.set(key, (state.byRule.get(key) || 0) + 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function buildToolBudgetExhaustedHint(policy, state) {
|
|
269
|
+
const normalized = normalizeToolPolicy(policy);
|
|
270
|
+
if (!normalized.enabled || normalized.maxToolCalls === null) return '';
|
|
271
|
+
if (state.totalCalls < normalized.maxToolCalls) return '';
|
|
272
|
+
return [
|
|
273
|
+
'',
|
|
274
|
+
'[TOOL_POLICY_BUDGET_EXHAUSTED]',
|
|
275
|
+
`Allowed tool calls used: ${state.totalCalls}/${normalized.maxToolCalls}.`,
|
|
276
|
+
'Do not call any tool again. Return the final bounded JSON answer now.',
|
|
277
|
+
'If required evidence was read, use that evidence. If not, report not_inspected according to the task contract.',
|
|
278
|
+
].join('\n');
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function isToolBudgetExhausted(policy, state) {
|
|
282
|
+
const normalized = normalizeToolPolicy(policy);
|
|
283
|
+
return normalized.enabled &&
|
|
284
|
+
normalized.maxToolCalls !== null &&
|
|
285
|
+
state.totalCalls >= normalized.maxToolCalls;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function appendToolPolicyHint(content, hint, maxLength = 50000) {
|
|
289
|
+
if (!hint) return content;
|
|
290
|
+
const text = typeof content === 'string' ? content : JSON.stringify(content);
|
|
291
|
+
if (text.length + hint.length <= maxLength) return `${text}${hint}`;
|
|
292
|
+
const keep = Math.max(0, maxLength - hint.length - 20);
|
|
293
|
+
return `${text.substring(0, keep)}\n[tool output clipped]\n${hint}`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function throwToolPolicyViolation(check) {
|
|
297
|
+
const err = new Error(check.reason || 'tool_policy_violation');
|
|
298
|
+
err.toolPolicyViolation = true;
|
|
299
|
+
err.toolPolicyDetail = check.detail || {};
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── MCP 并发控制(令牌桶)───────────────
|
|
304
|
+
// 限制同一子agent同时最多2个MCP请求在跑
|
|
305
|
+
// 避免MCP SSE单连接被并发冲垮
|
|
306
|
+
const MCP_MAX_CONCURRENCY = 3; // 单子agent并发MCP调用上限(session复用后够用)
|
|
307
|
+
const STALL_TIMEOUT_MS = 180_000; // 僵死检测: 3分钟无工具成功→自动熔断
|
|
308
|
+
const HEARTBEAT_INTERVAL_ROUNDS = 1; // 心跳间隔(轮)
|
|
309
|
+
let _mcpActiveCalls = 0;
|
|
310
|
+
const _mcpWaitQueue = [];
|
|
311
|
+
|
|
312
|
+
function acquireMCP() {
|
|
313
|
+
return new Promise(resolve => {
|
|
314
|
+
if (_mcpActiveCalls < MCP_MAX_CONCURRENCY) {
|
|
315
|
+
_mcpActiveCalls++;
|
|
316
|
+
resolve();
|
|
317
|
+
} else {
|
|
318
|
+
_mcpWaitQueue.push(resolve);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function releaseMCP() {
|
|
324
|
+
if (_mcpWaitQueue.length > 0) {
|
|
325
|
+
const next = _mcpWaitQueue.shift();
|
|
326
|
+
next();
|
|
327
|
+
} else {
|
|
328
|
+
_mcpActiveCalls--;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── 路径穿越防护 ──────────────────────────────────────────────────────
|
|
333
|
+
/**
|
|
334
|
+
* 校验 outputPath 是否在工作区白名单内,防止路径穿越写任意位置。
|
|
335
|
+
* @param {string} outputPath 用户传入的输出路径
|
|
336
|
+
* @returns {{ safe: boolean, resolvedPath: string, wsRoot: string }}
|
|
337
|
+
*/
|
|
338
|
+
function validateOutputPath(outputPath) {
|
|
339
|
+
const resolvedPath = path.resolve(outputPath);
|
|
340
|
+
const wsRoot = process.env.WORKSPACE || path.resolve(__dirname, '..', '..', '..', '..');
|
|
341
|
+
const resolvedWsRoot = path.resolve(wsRoot);
|
|
342
|
+
return { safe: resolvedPath.startsWith(resolvedWsRoot), resolvedPath, wsRoot: resolvedWsRoot };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function clipText(value, max = 1200) {
|
|
346
|
+
if (value === undefined || value === null) return '';
|
|
347
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value);
|
|
348
|
+
return text.replace(/[\r\n\t]+/g, ' ').trim().slice(0, max);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function writeFileAtomic(filePath, content) {
|
|
352
|
+
ensureDir(path.dirname(filePath));
|
|
353
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
354
|
+
fs.writeFileSync(tmp, content);
|
|
355
|
+
fs.renameSync(tmp, filePath);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function writeJsonAtomic(filePath, value) {
|
|
359
|
+
writeFileAtomic(filePath, JSON.stringify(value, null, 2));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function safeEventId(value, fallback = 'event') {
|
|
363
|
+
const text = String(value || '').replace(/[^a-zA-Z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
364
|
+
return (text || fallback).slice(0, 160);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function summarizeCompletion(result) {
|
|
368
|
+
if (!result) return '';
|
|
369
|
+
if (typeof result === 'string') return clipText(result, 1200);
|
|
370
|
+
if (result.summary) return clipText(result.summary, 1200);
|
|
371
|
+
if (result.final) return clipText(result.final, 1200);
|
|
372
|
+
if (result.answer) return clipText(result.answer, 1200);
|
|
373
|
+
return clipText(result, 1200);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function resolveRawReportPath({ runDir, taskId, outputPath }) {
|
|
377
|
+
const wsRoot = process.env.WORKSPACE || path.resolve(__dirname, '..', '..', '..', '..');
|
|
378
|
+
const defaultRoot = path.join(wsRoot, 'memory', 'task-states', 'raw-reports', taskId);
|
|
379
|
+
const outputCheck = outputPath ? validateOutputPath(outputPath) : null;
|
|
380
|
+
const outputRoot = outputCheck?.safe ? path.join(path.dirname(outputCheck.resolvedPath), taskId) : defaultRoot;
|
|
381
|
+
const candidateRoot = runDir ? path.resolve(runDir) : outputRoot;
|
|
382
|
+
const reportFile = runDir ? `${safeEventId(taskId, 'task')}.raw-report.json` : 'raw-report.json';
|
|
383
|
+
const check = validateOutputPath(path.join(candidateRoot, reportFile));
|
|
384
|
+
const root = check.safe ? candidateRoot : defaultRoot;
|
|
385
|
+
return path.join(root, reportFile);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildBoundedEnvelope({ status, taskId, result, error, completedAt, rawReportPath, params, budgetExceeded = false }) {
|
|
389
|
+
const rawOutputPolicy = params.rawOutputPolicy || params.raw_output_policy || params.budgets?.raw_output_policy || 'no_full_dump';
|
|
390
|
+
const summary = error ? clipText(error, 1200) : summarizeCompletion(result);
|
|
391
|
+
return {
|
|
392
|
+
status,
|
|
393
|
+
taskId,
|
|
394
|
+
summary,
|
|
395
|
+
completedAt,
|
|
396
|
+
budgetUsed: {
|
|
397
|
+
max_tool_output_chars: Number(params.budgets?.max_tool_output_chars || params.budgets?.maxToolOutputChars || 8000),
|
|
398
|
+
max_total_tool_output_chars: Number(params.budgets?.max_total_tool_output_chars || params.budgets?.maxTotalToolOutputChars || 30000),
|
|
399
|
+
raw_output_policy: rawOutputPolicy,
|
|
400
|
+
},
|
|
401
|
+
budgetExceeded,
|
|
402
|
+
artifactPath: rawReportPath,
|
|
403
|
+
rawReportPath,
|
|
404
|
+
evidence_paths_read: Array.isArray(params.evidence?.paths_read) ? params.evidence.paths_read : [],
|
|
405
|
+
evidence_paths_not_read: Array.isArray(params.evidence?.paths_not_read) ? params.evidence.paths_not_read : [],
|
|
406
|
+
not_inspected: Array.isArray(params.evidence?.not_inspected) ? params.evidence.not_inspected : [],
|
|
407
|
+
tool_usage_summary: {
|
|
408
|
+
maxRounds: params.maxRounds,
|
|
409
|
+
toolsConfigured: Array.isArray(params.tools) ? params.tools.length : 0,
|
|
410
|
+
toolPolicy: toolPolicySummary(params.toolPolicy),
|
|
411
|
+
},
|
|
412
|
+
sensitive_scan_result: 'not_run',
|
|
413
|
+
raw_output_policy: rawOutputPolicy,
|
|
414
|
+
error: error ? clipText(error, 800) : undefined,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function writeCompletionFallback(event) {
|
|
419
|
+
const inboxDir = path.join(__dirname, 'inbox');
|
|
420
|
+
ensureDir(inboxDir);
|
|
421
|
+
const eventId = event.eventId || `sce-${safeEventId(event.taskId, 'task')}-${safeEventId(event.status || 'done')}`;
|
|
422
|
+
const fp = path.join(inboxDir, `${eventId}.json`);
|
|
423
|
+
const tmp = `${fp}.${process.pid}.${Date.now()}.tmp`;
|
|
424
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
425
|
+
id: eventId,
|
|
426
|
+
type: 'sc.completion',
|
|
427
|
+
lifecycle: 'pending',
|
|
428
|
+
receivedAt: new Date().toISOString(),
|
|
429
|
+
source: 'subagent-runner-fallback',
|
|
430
|
+
sources: ['subagent-runner-fallback'],
|
|
431
|
+
...event,
|
|
432
|
+
}, null, 2));
|
|
433
|
+
fs.renameSync(tmp, fp);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function postCompletionEvent(event) {
|
|
437
|
+
const body = {
|
|
438
|
+
...event,
|
|
439
|
+
type: 'sc.completion',
|
|
440
|
+
source: event.source || 'subagent-runner',
|
|
441
|
+
};
|
|
442
|
+
try {
|
|
443
|
+
const resp = await fetchWithTimeout(`${SIDECAR_URL}/inbox/completion`, {
|
|
444
|
+
method: 'POST',
|
|
445
|
+
headers: { 'Content-Type': 'application/json' },
|
|
446
|
+
body: JSON.stringify(body),
|
|
447
|
+
}, SUBAGENT_NOTIFY_TIMEOUT_MS);
|
|
448
|
+
await resp.text().catch(() => '');
|
|
449
|
+
if (!resp.ok) throw new Error(`sidecar inbox HTTP ${resp.status}`);
|
|
450
|
+
} catch (e) {
|
|
451
|
+
try {
|
|
452
|
+
writeCompletionFallback({ ...body, postError: e.message });
|
|
453
|
+
} catch (fallbackErr) {
|
|
454
|
+
process.stderr.write('[subagent] SC inbox fallback error: ' + fallbackErr.message + '\n');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── 主入口 ────────────────────────────────────────────────────────────
|
|
460
|
+
async function main() {
|
|
461
|
+
// 解析参数(优先命令行参数,回退stdin)
|
|
462
|
+
let params;
|
|
463
|
+
if (process.argv[2]) {
|
|
464
|
+
params = JSON.parse(process.argv[2]);
|
|
465
|
+
} else {
|
|
466
|
+
params = await readStdin();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const {
|
|
470
|
+
taskId = 'sa-' + Date.now().toString(36),
|
|
471
|
+
prompt,
|
|
472
|
+
tools = [],
|
|
473
|
+
outputPath,
|
|
474
|
+
diaryPath,
|
|
475
|
+
flagDir,
|
|
476
|
+
depth = 0,
|
|
477
|
+
maxDepth = 0,
|
|
478
|
+
maxChildren = 2,
|
|
479
|
+
apiKey,
|
|
480
|
+
model = 'deepseek/deepseek-v4-flash',
|
|
481
|
+
timeout = 300,
|
|
482
|
+
codeMode = false,
|
|
483
|
+
taskName = '',
|
|
484
|
+
batchName = '',
|
|
485
|
+
groupName = '',
|
|
486
|
+
runId = '',
|
|
487
|
+
runDir = '',
|
|
488
|
+
rawOutputPolicy = 'no_full_dump',
|
|
489
|
+
budgets = {},
|
|
490
|
+
collector = null,
|
|
491
|
+
acceptance = null,
|
|
492
|
+
evidence = null,
|
|
493
|
+
notifyPolicy = 'notify-only',
|
|
494
|
+
guardMode = '',
|
|
495
|
+
guardWarnings = [],
|
|
496
|
+
taskCard = null,
|
|
497
|
+
toolPolicy: directToolPolicy = null,
|
|
498
|
+
tool_policy: directToolPolicySnake = null
|
|
499
|
+
} = params;
|
|
500
|
+
params.rawOutputPolicy = rawOutputPolicy;
|
|
501
|
+
params.budgets = budgets || {};
|
|
502
|
+
params.collector = collector;
|
|
503
|
+
params.acceptance = acceptance;
|
|
504
|
+
params.evidence = evidence;
|
|
505
|
+
params.notifyPolicy = notifyPolicy;
|
|
506
|
+
params.guardMode = guardMode;
|
|
507
|
+
params.guardWarnings = guardWarnings;
|
|
508
|
+
params.taskCard = isPlainObject(taskCard) ? taskCard : null;
|
|
509
|
+
params.toolPolicy = normalizeToolPolicy(directToolPolicy || directToolPolicySnake || params.taskCard?.toolPolicy || params.taskCard?.tool_policy);
|
|
510
|
+
// 代码改造模式:由下面的 effectiveMaxRounds 统一限制(当前上限100),靠策略失败熔断
|
|
511
|
+
const maxRounds = codeMode ? 100 : (params.maxRounds || 35);
|
|
512
|
+
|
|
513
|
+
// ====== x0x 实时通信桥接 ======
|
|
514
|
+
let _x0xCancelled = false;
|
|
515
|
+
const x0xApiUrl = params.x0xApiUrl || process.env.X0X_API_URL || '';
|
|
516
|
+
const x0xToken = params.x0xToken || process.env.X0X_TOKEN || '';
|
|
517
|
+
const x0xTopic = params.x0xTopic || process.env.X0X_TOPIC || '';
|
|
518
|
+
|
|
519
|
+
// 用x0x REST API(同步HTTP POST /publish),不依赖WebSocket异步连接
|
|
520
|
+
function reportX0x(step, data) {
|
|
521
|
+
if (!x0xApiUrl || !x0xToken) return;
|
|
522
|
+
try {
|
|
523
|
+
const payload = Buffer.from(JSON.stringify({
|
|
524
|
+
event: 'progress', taskId: x0xTopic, step, ...data, timestamp: new Date().toISOString()
|
|
525
|
+
})).toString('base64');
|
|
526
|
+
// Debug: write to stderr so Sidecar collects it
|
|
527
|
+
process.stderr.write('[x0x] publish: ' + x0xApiUrl + ' |len=' + x0xToken.length + ' |topic=' + (x0xTopic || 'none') + '\n');
|
|
528
|
+
fetchWithTimeout(x0xApiUrl + '/publish', {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
headers: { 'Authorization': 'B' + 'earer ' + x0xToken, 'Content-Type': 'application/json' },
|
|
531
|
+
body: JSON.stringify({ topic: 'sa-events', payload }),
|
|
532
|
+
}, SUBAGENT_NOTIFY_TIMEOUT_MS).then(async r => {
|
|
533
|
+
await r.text().catch(() => '');
|
|
534
|
+
process.stderr.write('[x0x] publish status: ' + r.status + '\n');
|
|
535
|
+
}).catch(e => process.stderr.write('[x0x] publish error: ' + e.message + '\n'));
|
|
536
|
+
} catch (e) { process.stderr.write(`[x0x] error: ${e.message}\n`); }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 发一启动消息
|
|
540
|
+
reportX0x('started', { prompt: (prompt || '').substring(0,200) });
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
// 超时保护(codeMode不设硬时限)
|
|
544
|
+
const effectiveTimeout = codeMode ? 7200 : timeout;
|
|
545
|
+
let taskTimeoutTimer = null;
|
|
546
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
547
|
+
taskTimeoutTimer = setTimeout(() => reject(new Error(`Subagent timeout after ${effectiveTimeout}s`)), effectiveTimeout * 1000);
|
|
548
|
+
});
|
|
549
|
+
const clearTaskTimeout = () => {
|
|
550
|
+
if (taskTimeoutTimer) {
|
|
551
|
+
clearTimeout(taskTimeoutTimer);
|
|
552
|
+
taskTimeoutTimer = null;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
// 通过x0x报告启动
|
|
557
|
+
reportX0x('running', { status: 'executing', model });
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
const result = await Promise.race([
|
|
561
|
+
runAgent({ taskId, prompt, tools, outputPath, diaryPath, flagDir, depth, maxDepth, maxChildren, apiKey, model, maxRounds, codeMode, taskName, batchName, groupName, taskCard: params.taskCard, toolPolicy: params.toolPolicy }),
|
|
562
|
+
timeoutPromise
|
|
563
|
+
]);
|
|
564
|
+
clearTaskTimeout();
|
|
565
|
+
const completedAt = new Date().toISOString();
|
|
566
|
+
const rawReportPath = resolveRawReportPath({ runDir, taskId, outputPath });
|
|
567
|
+
writeJsonAtomic(rawReportPath, {
|
|
568
|
+
status: 'success',
|
|
569
|
+
taskId,
|
|
570
|
+
runId,
|
|
571
|
+
taskName,
|
|
572
|
+
batchName,
|
|
573
|
+
groupName,
|
|
574
|
+
completedAt,
|
|
575
|
+
data: result,
|
|
576
|
+
});
|
|
577
|
+
const envelope = buildBoundedEnvelope({ status: 'success', taskId, result, completedAt, rawReportPath, params });
|
|
578
|
+
|
|
579
|
+
// 写结果文件(成功路径:路径穿越防护)
|
|
580
|
+
if (outputPath) {
|
|
581
|
+
const check = validateOutputPath(outputPath);
|
|
582
|
+
if (!check.safe) {
|
|
583
|
+
process.stderr.write(JSON.stringify({ error: '路径穿越拦截', path: check.resolvedPath, ws: check.wsRoot }));
|
|
584
|
+
} else {
|
|
585
|
+
ensureDir(path.dirname(check.resolvedPath));
|
|
586
|
+
writeJsonAtomic(check.resolvedPath, envelope);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// 写日记
|
|
591
|
+
if (diaryPath) {
|
|
592
|
+
const resolvedDiary = path.resolve(diaryPath);
|
|
593
|
+
ensureDir(path.dirname(resolvedDiary));
|
|
594
|
+
writeFileAtomic(resolvedDiary,
|
|
595
|
+
`# 子Agent执行日记 - ${taskId}\n` +
|
|
596
|
+
`时间: ${new Date().toISOString()}\n` +
|
|
597
|
+
`深度: ${depth}/${maxDepth}\n` +
|
|
598
|
+
`提示词: ${prompt?.substring(0, 200)}...\n\n` +
|
|
599
|
+
`## 结果\n\`\`\`json\n${JSON.stringify(envelope, null, 2)}\n\`\`\`\n`
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// 写完成标记文件(零Token,让主脑知道兵回来了)
|
|
604
|
+
try {
|
|
605
|
+
const doneFlagDir = flagDir ? path.resolve(flagDir) : path.resolve(__dirname, '../../../../memory/dialog/subagent');
|
|
606
|
+
ensureDir(doneFlagDir);
|
|
607
|
+
writeFileAtomic(path.join(doneFlagDir, 'DONE_' + taskId + '_success'), new Date().toISOString());
|
|
608
|
+
} catch(e) { process.stderr.write('[subagent] DONE flag error: ' + e.message + '\n'); }
|
|
609
|
+
|
|
610
|
+
await postCompletionEvent({
|
|
611
|
+
taskId,
|
|
612
|
+
status: 'success',
|
|
613
|
+
taskName,
|
|
614
|
+
batchName,
|
|
615
|
+
groupName,
|
|
616
|
+
runId,
|
|
617
|
+
outputPath,
|
|
618
|
+
diaryPath,
|
|
619
|
+
rawReportPath,
|
|
620
|
+
completedAt,
|
|
621
|
+
summary: envelope.summary,
|
|
622
|
+
budgetUsed: envelope.budgetUsed,
|
|
623
|
+
budgetExceeded: envelope.budgetExceeded,
|
|
624
|
+
rawOutputPolicy: envelope.raw_output_policy,
|
|
625
|
+
evidence_paths_read: envelope.evidence_paths_read,
|
|
626
|
+
evidence_paths_not_read: envelope.evidence_paths_not_read,
|
|
627
|
+
not_inspected: envelope.not_inspected,
|
|
628
|
+
tool_usage_summary: envelope.tool_usage_summary,
|
|
629
|
+
sensitive_scan_result: envelope.sensitive_scan_result,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
// stdout输出供parent捕获
|
|
633
|
+
// x0x报告完成
|
|
634
|
+
reportX0x('completed', { status: 'success' });
|
|
635
|
+
process.stdout.write(JSON.stringify(envelope));
|
|
636
|
+
process.exit(0);
|
|
637
|
+
|
|
638
|
+
} catch (err) {
|
|
639
|
+
clearTaskTimeout();
|
|
640
|
+
// codeMode熔断:恢复备份文件
|
|
641
|
+
if (err.restoreBackup) {
|
|
642
|
+
try {
|
|
643
|
+
const wsRoot = path.resolve(__dirname, '..', '..', '..', '..', '..');
|
|
644
|
+
restoreBackups(wsRoot);
|
|
645
|
+
process.stderr.write('[subagent] 🔄 已恢复备份文件\n');
|
|
646
|
+
} catch (e) { process.stderr.write('[subagent] 备份恢复失败: ' + e.message + '\n'); }
|
|
647
|
+
}
|
|
648
|
+
const errorAt = new Date().toISOString();
|
|
649
|
+
const errorStatus = err.toolPolicyViolation ? 'tool_policy_violation' : (err.stalled ? 'stalled' : 'error');
|
|
650
|
+
const errorResult = { status: errorStatus, taskId, error: err.message, stalled: err.stalled || false, toolPolicyViolation: err.toolPolicyViolation === true, errorAt };
|
|
651
|
+
const rawReportPath = resolveRawReportPath({ runDir, taskId, outputPath });
|
|
652
|
+
writeJsonAtomic(rawReportPath, errorResult);
|
|
653
|
+
const envelope = buildBoundedEnvelope({
|
|
654
|
+
status: errorStatus,
|
|
655
|
+
taskId,
|
|
656
|
+
error: err.message,
|
|
657
|
+
completedAt: errorAt,
|
|
658
|
+
rawReportPath,
|
|
659
|
+
params,
|
|
660
|
+
budgetExceeded: /budget/i.test(err.message || ''),
|
|
661
|
+
});
|
|
662
|
+
const exitCode = err.toolPolicyViolation ? 43 : (err.stalled ? 42 : 1); // 43=工具策略违规, 42=僵死
|
|
663
|
+
// 写错误结果文件(失败路径:路径穿越防护)
|
|
664
|
+
if (outputPath) {
|
|
665
|
+
const check = validateOutputPath(outputPath);
|
|
666
|
+
if (!check.safe) {
|
|
667
|
+
process.stderr.write(JSON.stringify({ error: '路径穿越拦截', path: check.resolvedPath, ws: check.wsRoot }));
|
|
668
|
+
} else {
|
|
669
|
+
ensureDir(path.dirname(check.resolvedPath));
|
|
670
|
+
writeJsonAtomic(check.resolvedPath, envelope);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
// 写失败标记文件
|
|
674
|
+
try {
|
|
675
|
+
const doneFlagDir = flagDir ? path.resolve(flagDir) : path.resolve(__dirname, '../../../../memory/dialog/subagent');
|
|
676
|
+
ensureDir(doneFlagDir);
|
|
677
|
+
// 🔥 区分僵死 vs 普通失败
|
|
678
|
+
const doneStatus = err.toolPolicyViolation ? 'tool_policy_violation' : (err.stalled ? 'stalled' : 'failed');
|
|
679
|
+
writeJsonAtomic(path.join(doneFlagDir, 'DONE_' + taskId + '_' + doneStatus), {
|
|
680
|
+
error: err.message,
|
|
681
|
+
stalled: err.stalled || false,
|
|
682
|
+
completedAt: new Date().toISOString()
|
|
683
|
+
});
|
|
684
|
+
// B方案: 通知MCP Server兵完成(含stalled状态)
|
|
685
|
+
const notifyPath = path.join(doneFlagDir, 'NOTIFY_' + taskId + '_' + doneStatus + '.json');
|
|
686
|
+
writeJsonAtomic(notifyPath, { taskId, status: doneStatus, completedAt: errorAt, error: err.message });
|
|
687
|
+
fetchWithTimeout('http://127.0.0.1:18790/notify-done', {
|
|
688
|
+
method: 'POST',
|
|
689
|
+
headers: { 'Content-Type': 'application/json' },
|
|
690
|
+
body: JSON.stringify({ taskId, status: doneStatus }),
|
|
691
|
+
}, SUBAGENT_NOTIFY_TIMEOUT_MS).then(r => r.text().catch(() => '')).catch(() => {});
|
|
692
|
+
} catch(e) { process.stderr.write('[subagent] DONE flag error: ' + e.message + '\n'); }
|
|
693
|
+
|
|
694
|
+
await postCompletionEvent({
|
|
695
|
+
taskId,
|
|
696
|
+
status: err.toolPolicyViolation ? 'tool_policy_violation' : (err.stalled ? 'stalled' : 'failed'),
|
|
697
|
+
taskName,
|
|
698
|
+
batchName,
|
|
699
|
+
groupName,
|
|
700
|
+
runId,
|
|
701
|
+
outputPath,
|
|
702
|
+
diaryPath,
|
|
703
|
+
rawReportPath,
|
|
704
|
+
completedAt: errorAt,
|
|
705
|
+
summary: envelope.summary,
|
|
706
|
+
error: err.message,
|
|
707
|
+
budgetUsed: envelope.budgetUsed,
|
|
708
|
+
budgetExceeded: envelope.budgetExceeded,
|
|
709
|
+
rawOutputPolicy: envelope.raw_output_policy,
|
|
710
|
+
evidence_paths_read: envelope.evidence_paths_read,
|
|
711
|
+
evidence_paths_not_read: envelope.evidence_paths_not_read,
|
|
712
|
+
not_inspected: envelope.not_inspected,
|
|
713
|
+
tool_usage_summary: envelope.tool_usage_summary,
|
|
714
|
+
sensitive_scan_result: envelope.sensitive_scan_result,
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// x0x报告失败
|
|
718
|
+
reportX0x('failed', { status: 'error', error: err.message });
|
|
719
|
+
process.stderr.write(JSON.stringify(errorResult));
|
|
720
|
+
process.exit(exitCode); // 🔥 僵死=42, 失败=1
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ── Agent循环 ─────────────────────────────────────────────────────────
|
|
725
|
+
let currentTaskId = null; // 模块级,供 _callLLMImpl 带上 x-task-id
|
|
726
|
+
let currentTaskName = '';
|
|
727
|
+
let currentBatchName = '';
|
|
728
|
+
let currentGroupName = '';
|
|
729
|
+
async function runAgent(params) {
|
|
730
|
+
const { taskId, prompt, tools: allowedTools, flagDir, depth, maxDepth, maxChildren, apiKey, model, maxRounds = 20, codeMode = false, taskName = '', batchName = '', groupName = '', toolPolicy = null } = params;
|
|
731
|
+
|
|
732
|
+
// 存到模块级变量,供 _callLLMImpl 带上 x-task-id 请求头
|
|
733
|
+
currentTaskId = taskId;
|
|
734
|
+
currentTaskName = taskName;
|
|
735
|
+
currentBatchName = batchName;
|
|
736
|
+
currentGroupName = groupName;
|
|
737
|
+
|
|
738
|
+
// 代码改造模式:不限轮次,靠策略失败熔断
|
|
739
|
+
const effectiveMaxRounds = codeMode ? 100 : maxRounds;
|
|
740
|
+
|
|
741
|
+
// 获取允许的工具列表(带function calling schema) + 共享MCP session
|
|
742
|
+
const effectiveToolPolicy = normalizeToolPolicy(toolPolicy);
|
|
743
|
+
const { tools: safeTools, sessionId } = await getAllowedTools(depth, maxDepth, maxChildren);
|
|
744
|
+
const visibleTools = filterToolsByPolicy(safeTools, effectiveToolPolicy);
|
|
745
|
+
process.stderr.write(`[subagent] session复用: ${sessionId}, tools=${visibleTools.length}个
|
|
746
|
+
`);
|
|
747
|
+
const sharedSession = { id: sessionId }; // 用对象包装,callTool内可更新重连后的新id
|
|
748
|
+
|
|
749
|
+
// 确保session最终关闭
|
|
750
|
+
const cleanupSession = async () => {
|
|
751
|
+
if (sharedSession.id) {
|
|
752
|
+
try { await closeMCPSession(sharedSession.id); } catch {}
|
|
753
|
+
sharedSession.id = null;
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
try {
|
|
758
|
+
const toolSchemas = visibleTools.map(t => ({
|
|
759
|
+
type: 'function',
|
|
760
|
+
function: {
|
|
761
|
+
name: t.name,
|
|
762
|
+
description: (t.description || '').substring(0, 200),
|
|
763
|
+
parameters: t.inputSchema || { type: 'object', properties: {} }
|
|
764
|
+
}
|
|
765
|
+
}));
|
|
766
|
+
|
|
767
|
+
// 系统提示词
|
|
768
|
+
const systemPrompt = `你是杉杉的子Agent(taskId: ${taskId},深度: ${depth}/${maxDepth})。
|
|
769
|
+
|
|
770
|
+
## 可用工具
|
|
771
|
+
${visibleTools.map(t => '- \\`' + t.name + '\\`: ' + (t.description || '').substring(0, 100)).join('\n')}
|
|
772
|
+
|
|
773
|
+
## 安全规则
|
|
774
|
+
- 禁止调危险工具: ${SUBAGENT_BLOCKED.join(', ')}
|
|
775
|
+
- 同一工具失败3次停止使用
|
|
776
|
+
- 禁止调原生工具(exec/write/edit/web_search等)。保留:browser(登录态操作/网页)、message(发通知)
|
|
777
|
+
- 文件操作限工作区目录
|
|
778
|
+
${effectiveToolPolicy.enabled ? `- 本任务启用工具硬限制: ${JSON.stringify(toolPolicySummary(effectiveToolPolicy))}` : ''}
|
|
779
|
+
|
|
780
|
+
## 输出要求
|
|
781
|
+
返回最终结果。如果任务需要调工具,先调工具再根据工具结果给出最终答案。
|
|
782
|
+
|
|
783
|
+
## 工具说明书
|
|
784
|
+
如果看不懂某个工具、或不确定这工具你让不让用,去工作区找 skills/core-file-search/SKILL.md 读一下。工具名前有 ✅ 的就是你能用的。别一个个试,先查再说。`;
|
|
785
|
+
|
|
786
|
+
const messages = [
|
|
787
|
+
{ role: 'system', content: systemPrompt },
|
|
788
|
+
{ role: 'user', content: prompt }
|
|
789
|
+
];
|
|
790
|
+
|
|
791
|
+
let result = null;
|
|
792
|
+
let toolFailCount = 0;
|
|
793
|
+
let toolSuccessCount = 0; // 成功工具调用计数(僵死检测用)
|
|
794
|
+
let lastToolSuccessTime = Date.now(); // 最后一次工具成功时间
|
|
795
|
+
let validateFailCount = 0; // codeMode下validate连续失败追踪
|
|
796
|
+
const toolPolicyState = { totalCalls: 0, byRule: new Map() };
|
|
797
|
+
let toolBudgetHardStopInjected = false;
|
|
798
|
+
// 同一工具+action连续失败追踪 → 自动注入换路建议(杉哥2026-06-11)
|
|
799
|
+
let lastFailedTool = null;
|
|
800
|
+
let lastFailedAction = null;
|
|
801
|
+
let lastFailedToolConsecutive = 0;
|
|
802
|
+
const TOOL_ALTERNATIVES = {
|
|
803
|
+
'codeEditor': 'fileManager(读文件用action=read, 写文件用action=write)',
|
|
804
|
+
'fileManager': 'codeEditor(用action=edit_code精准修改文件)',
|
|
805
|
+
'webSearch': '换个搜索词重试, 或改用webSearch(action=web_fetch)直接抓取页面',
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
// 方案C: 消息快照 — 每轮LLM调用前保存快照,工具处理完成后校验
|
|
810
|
+
let lastSnapshot = null;
|
|
811
|
+
function saveSnapshot() {
|
|
812
|
+
lastSnapshot = messages.map(m => JSON.parse(JSON.stringify(m)));
|
|
813
|
+
}
|
|
814
|
+
function rollbackMessages() {
|
|
815
|
+
if (lastSnapshot) {
|
|
816
|
+
messages.length = 0;
|
|
817
|
+
messages.push(...JSON.parse(JSON.stringify(lastSnapshot)));
|
|
818
|
+
process.stderr.write('[subagent] 回滚messages到上一轮快照\n');
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
for (let round = 0; round < effectiveMaxRounds; round++) {
|
|
823
|
+
// 🔥 僵死检测: 超过STALL_TIMEOUT_MS无工具成功 → 自动熔断(防僵尸兵)
|
|
824
|
+
const stallMs = Date.now() - lastToolSuccessTime;
|
|
825
|
+
if (stallMs > STALL_TIMEOUT_MS) {
|
|
826
|
+
process.stderr.write(`[subagent] ⚠️ 僵死熔断: ${Math.round(stallMs/1000)}s无工具成功, 已执行${round}轮, 成功${toolSuccessCount}次, 失败${toolFailCount}次\n`);
|
|
827
|
+
const stallErr = new Error(`僵死熔断(僵尸兵): ${Math.round(stallMs/1000)}s无进展 (${round}轮, 成功${toolSuccessCount}次, 失败${toolFailCount}次)`);
|
|
828
|
+
stallErr.stalled = true; // 标记为僵死,区别于普通失败
|
|
829
|
+
throw stallErr;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 消息完整性保护:确保tool_calls都有tool回应
|
|
833
|
+
guardMessages(messages);
|
|
834
|
+
// 拍快照(在callLLM之前,快照里包含补全后的filler)
|
|
835
|
+
saveSnapshot();
|
|
836
|
+
|
|
837
|
+
// 方案B: LLM调用(带重试+400错误自愈)
|
|
838
|
+
let response;
|
|
839
|
+
for (let llmRetry = 0; llmRetry <= 2; llmRetry++) {
|
|
840
|
+
// 🔥 排除API重试时间: LLM正在工作中,不算僵死
|
|
841
|
+
lastToolSuccessTime = Date.now();
|
|
842
|
+
try {
|
|
843
|
+
const toolBudgetExhausted = isToolBudgetExhausted(effectiveToolPolicy, toolPolicyState);
|
|
844
|
+
if (toolBudgetExhausted && !toolBudgetHardStopInjected) {
|
|
845
|
+
messages.push({
|
|
846
|
+
role: 'system',
|
|
847
|
+
content: '[TOOL_POLICY_HARD_STOP] 工具预算已耗尽。必须严格遵守本任务工具策略。禁止再次请求、暗示或尝试任何工具调用;任何 tool_call 都会被系统判定为工具策略违规并使任务失败。现在只允许输出最终 bounded JSON,不允许输出解释、计划、请求更多工具或继续检查。',
|
|
848
|
+
});
|
|
849
|
+
toolBudgetHardStopInjected = true;
|
|
850
|
+
}
|
|
851
|
+
response = await callLLM(messages, model, apiKey, toolBudgetExhausted ? [] : toolSchemas);
|
|
852
|
+
break; // 成功
|
|
853
|
+
} catch (err) {
|
|
854
|
+
const is400ToolMsg = err.message.includes('insufficient tool messages') ||
|
|
855
|
+
err.message.includes('tool messages responding');
|
|
856
|
+
// 方案B: 400错误 → 回滚+重试
|
|
857
|
+
if (is400ToolMsg && llmRetry < 2) {
|
|
858
|
+
process.stderr.write(`[subagent] LLM 400错误(tool消息完整性), 回滚重试第${llmRetry+1}次\n`);
|
|
859
|
+
rollbackMessages();
|
|
860
|
+
// 回滚后再次运行guardMessages确保完整性
|
|
861
|
+
guardMessages(messages);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
// 其他错误或重试耗尽 → 抛出
|
|
865
|
+
if (llmRetry >= 2) {
|
|
866
|
+
process.stderr.write(`[subagent] LLM重试${llmRetry+1}次均失败, 放弃\n`);
|
|
867
|
+
}
|
|
868
|
+
throw err;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const choice = response.choices?.[0];
|
|
872
|
+
if (!choice) throw new Error(`LLM returned empty response: ${JSON.stringify(response)}`);
|
|
873
|
+
|
|
874
|
+
const msg = choice.message;
|
|
875
|
+
|
|
876
|
+
// 检查是否有工具调用
|
|
877
|
+
const toolCalls = msg.tool_calls || [];
|
|
878
|
+
if (toolCalls.length === 0) {
|
|
879
|
+
// 无工具调用 = 最终答案
|
|
880
|
+
result = msg.content;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// 先把assistant消息加入对话历史
|
|
885
|
+
messages.push({ role: 'assistant', content: msg.content || null, tool_calls: msg.tool_calls });
|
|
886
|
+
|
|
887
|
+
// 方案D: 分批约束 — 每轮最多处理3个tool_call,减少批处理风险
|
|
888
|
+
// 剩余的会在工具响应中提示LLM下轮继续
|
|
889
|
+
const MAX_TOOLS_PER_ROUND = 3;
|
|
890
|
+
const activeToolCalls = toolCalls.slice(0, MAX_TOOLS_PER_ROUND);
|
|
891
|
+
const deferredToolCalls = toolCalls.slice(MAX_TOOLS_PER_ROUND);
|
|
892
|
+
if (deferredToolCalls.length > 0) {
|
|
893
|
+
process.stderr.write(`[subagent] 分批: 本轮${toolCalls.length}个, 处理${activeToolCalls.length}个, ${deferredToolCalls.length}个延到下轮\n`);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// 记录延期工具的名称(用于在最后一条tool消息中提示)
|
|
897
|
+
const deferredNames = deferredToolCalls.map(tc => tc.function?.name);
|
|
898
|
+
|
|
899
|
+
// 处理每个工具调用
|
|
900
|
+
for (const tc of activeToolCalls) {
|
|
901
|
+
const toolName = tc.function?.name;
|
|
902
|
+
let toolArgs = {};
|
|
903
|
+
try {
|
|
904
|
+
toolArgs = JSON.parse(tc.function?.arguments || '{}');
|
|
905
|
+
} catch { toolArgs = {}; }
|
|
906
|
+
|
|
907
|
+
const policyCheck = validateToolPolicyCall(effectiveToolPolicy, toolName, toolArgs, toolPolicyState);
|
|
908
|
+
if (!policyCheck.ok) {
|
|
909
|
+
process.stderr.write(`[subagent] 工具策略违规: ${policyCheck.reason}\n`);
|
|
910
|
+
throwToolPolicyViolation(policyCheck);
|
|
911
|
+
}
|
|
912
|
+
recordToolPolicyCall(toolPolicyState, toolName, policyCheck.action);
|
|
913
|
+
|
|
914
|
+
// 安全过滤(config + 原生兜底)
|
|
915
|
+
if (SUBAGENT_BLOCKED.includes(toolName) || DANGEROUS_NATIVE_TOOLS.includes(toolName)) {
|
|
916
|
+
messages.push({
|
|
917
|
+
role: 'tool',
|
|
918
|
+
tool_call_id: tc.id,
|
|
919
|
+
content: JSON.stringify({ error: `工具 ${toolName} 被禁止使用(危险工具)` })
|
|
920
|
+
});
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// 递归深度限制
|
|
925
|
+
if (toolName === 'cpu_spawn_subagent') {
|
|
926
|
+
if (depth >= maxDepth) {
|
|
927
|
+
messages.push({
|
|
928
|
+
role: 'tool',
|
|
929
|
+
tool_call_id: tc.id,
|
|
930
|
+
content: JSON.stringify({ error: `已达最大递归深度 ${maxDepth},无法派生子Agent` })
|
|
931
|
+
});
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
// 计算当前深度已派生的子agent数
|
|
935
|
+
const children = toolArgs.children || [];
|
|
936
|
+
if (children.length > maxChildren) {
|
|
937
|
+
messages.push({
|
|
938
|
+
role: 'tool',
|
|
939
|
+
tool_call_id: tc.id,
|
|
940
|
+
content: JSON.stringify({ error: `每层最多 ${maxChildren} 个子Agent` })
|
|
941
|
+
});
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// 调用工具(带retry+熔断),透传当前深度
|
|
947
|
+
// ⚠️ 先占位再执行:确保即使异常中断,tool消息也不丢失
|
|
948
|
+
const toolMsgIdx = messages.length;
|
|
949
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: '' });
|
|
950
|
+
let toolResult = null;
|
|
951
|
+
let lastError = null;
|
|
952
|
+
for (let retry = 0; retry <= MAX_TOOL_RETRIES; retry++) {
|
|
953
|
+
try {
|
|
954
|
+
toolResult = await callTool(toolName, toolArgs, { depth, sharedSession });
|
|
955
|
+
lastError = null;
|
|
956
|
+
toolSuccessCount++;
|
|
957
|
+
lastToolSuccessTime = Date.now();
|
|
958
|
+
// 成功重置同工具追踪
|
|
959
|
+
lastFailedTool = null;
|
|
960
|
+
lastFailedAction = null;
|
|
961
|
+
lastFailedToolConsecutive = 0;
|
|
962
|
+
break;
|
|
963
|
+
} catch (err) {
|
|
964
|
+
lastError = err;
|
|
965
|
+
toolFailCount++;
|
|
966
|
+
|
|
967
|
+
// codeMode: 跟踪validate连续失败 → 2次策略失败熔断
|
|
968
|
+
if (codeMode && toolName === 'validate') {
|
|
969
|
+
validateFailCount++;
|
|
970
|
+
if (validateFailCount >= 2) {
|
|
971
|
+
// 先更新占位消息为失败信息
|
|
972
|
+
messages[toolMsgIdx].content = JSON.stringify({ error: `代码修复方案失败: validate连续失败${validateFailCount}次` });
|
|
973
|
+
const restoreErr = new Error(`代码修复方案失败: validate连续失败${validateFailCount}次, 策略无效`);
|
|
974
|
+
restoreErr.restoreBackup = true;
|
|
975
|
+
throw restoreErr;
|
|
976
|
+
}
|
|
977
|
+
} else if (codeMode) {
|
|
978
|
+
validateFailCount = 0; // 非validate失败则不累计
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 追踪同工具+action连续失败
|
|
982
|
+
const curAction = toolArgs?.action || 'call';
|
|
983
|
+
if (lastFailedTool === toolName && lastFailedAction === curAction) {
|
|
984
|
+
lastFailedToolConsecutive++;
|
|
985
|
+
} else {
|
|
986
|
+
lastFailedTool = toolName;
|
|
987
|
+
lastFailedAction = curAction;
|
|
988
|
+
lastFailedToolConsecutive = 1;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// 同工具+action连续失败3次 → 注入换路建议
|
|
992
|
+
if (lastFailedToolConsecutive >= 3 && TOOL_ALTERNATIVES[toolName]) {
|
|
993
|
+
const alt = TOOL_ALTERNATIVES[toolName];
|
|
994
|
+
messages.push({
|
|
995
|
+
role: 'system',
|
|
996
|
+
content: `[🔧 自动提示] ${toolName}(${curAction}) 已连续失败${lastFailedToolConsecutive}次。建议换用替代方案: ${alt}。当前任务的其他部分不受影响,继续执行。`
|
|
997
|
+
});
|
|
998
|
+
lastFailedToolConsecutive = 0;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (toolFailCount >= 5) {
|
|
1002
|
+
const toolFailDetail = `工具=${toolName}, action=${toolArgs?.action||'call'}, 最后错误=${lastError?.message||'未知'}`;
|
|
1003
|
+
process.stderr.write(`[subagent] 熔断前信息: ${toolFailDetail}
|
|
1004
|
+
`);
|
|
1005
|
+
throw new Error(`工具调用失败次数过多(${toolFailCount}),熔断终止。${toolFailDetail}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// 更新占位消息为实际结果
|
|
1011
|
+
if (lastError) {
|
|
1012
|
+
messages[toolMsgIdx].content = JSON.stringify({ error: `工具 ${toolName} 调用失败: ${lastError.message}` });
|
|
1013
|
+
} else {
|
|
1014
|
+
messages[toolMsgIdx].content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult).substring(0, 50000);
|
|
1015
|
+
messages[toolMsgIdx].content = appendToolPolicyHint(
|
|
1016
|
+
messages[toolMsgIdx].content,
|
|
1017
|
+
buildToolBudgetExhaustedHint(effectiveToolPolicy, toolPolicyState)
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// 方案D: 延期工具提示 — 在最后一条tool消息里追加未处理信息
|
|
1022
|
+
if (deferredNames.length > 0 && tc === activeToolCalls[activeToolCalls.length - 1]) {
|
|
1023
|
+
const remainingSummary = deferredNames.join(', ');
|
|
1024
|
+
const existingContent = messages[toolMsgIdx].content;
|
|
1025
|
+
const hint = `\n\n[分批执行] 以下tool_calls因分批约束未在当前轮执行: ${remainingSummary}。请在新一轮中重新发起这些工具调用。`;
|
|
1026
|
+
// 如果内容太长,截断保留头部信息再追加提示
|
|
1027
|
+
if (existingContent.length > 30000) {
|
|
1028
|
+
messages[toolMsgIdx].content = existingContent.substring(0, 28000) + hint;
|
|
1029
|
+
} else {
|
|
1030
|
+
messages[toolMsgIdx].content = existingContent + hint;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 🔥 心跳: 每轮报告状态(零LLM开销,只写stderr管道)
|
|
1036
|
+
if (round % HEARTBEAT_INTERVAL_ROUNDS === 0) {
|
|
1037
|
+
const stallSec = Math.round((Date.now() - lastToolSuccessTime) / 1000);
|
|
1038
|
+
process.stderr.write(`[HB] R${round} OK:${toolSuccessCount} FAIL:${toolFailCount} STALL:${stallSec}s\n`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
if (!result) {
|
|
1043
|
+
result = { warning: `达到最大轮次(${effectiveMaxRounds})未得到最终结果`, partial: messages[messages.length - 1]?.content };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
return result;
|
|
1047
|
+
} finally {
|
|
1048
|
+
await cleanupSession();
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ── 获取安全工具列表(从MCP拉取,过滤危险工具)───────────────
|
|
1053
|
+
async function getAllowedTools(depth, maxDepth, maxChildren) {
|
|
1054
|
+
// 按depth分级缓存:不同深度的工具列表不同(maxDepth决定了是否含cpu_spawn_subagent)
|
|
1055
|
+
if (toolCacheByDepth[depth]) return toolCacheByDepth[depth];
|
|
1056
|
+
|
|
1057
|
+
const sessionId = await createMCPSession();
|
|
1058
|
+
process.stderr.write(`[subagent] 创建新MCP session: ${sessionId}
|
|
1059
|
+
`);
|
|
1060
|
+
try {
|
|
1061
|
+
// 调MCP tools/list获取所有工具定义
|
|
1062
|
+
const msgUrl = `${MCP_URL}/messages?sessionId=${sessionId}`;
|
|
1063
|
+
const listResponse = await fetchWithTimeout(msgUrl, {
|
|
1064
|
+
method: 'POST',
|
|
1065
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1066
|
+
body: JSON.stringify({
|
|
1067
|
+
jsonrpc: '2.0', id: 'list-tools', method: 'tools/list', params: { role: 'subagent' }
|
|
1068
|
+
})
|
|
1069
|
+
}, SUBAGENT_MCP_TOOL_TIMEOUT_MS);
|
|
1070
|
+
if (!listResponse.ok) throw new Error(`MCP tools/list HTTP ${listResponse.status}`);
|
|
1071
|
+
|
|
1072
|
+
// 从SSE流读取结果
|
|
1073
|
+
const session = mcpSessions.get(sessionId);
|
|
1074
|
+
let tools = await readMCPListResult(session.reader);
|
|
1075
|
+
|
|
1076
|
+
// 过滤:去掉危险工具(config + 原生兜底)
|
|
1077
|
+
tools = tools.filter(t => !SUBAGENT_BLOCKED.includes(t.name) && !DANGEROUS_NATIVE_TOOLS.includes(t.name));
|
|
1078
|
+
// 去重(按name)
|
|
1079
|
+
const seen = new Set();
|
|
1080
|
+
tools = tools.filter(t => {
|
|
1081
|
+
if (seen.has(t.name)) return false;
|
|
1082
|
+
seen.add(t.name);
|
|
1083
|
+
return true;
|
|
1084
|
+
});
|
|
1085
|
+
// 简化inputSchema:只保留properties+required,去掉additionalProperties等DeepSeek不认的字段
|
|
1086
|
+
tools = tools.map(t => {
|
|
1087
|
+
const schema = t.inputSchema || {};
|
|
1088
|
+
return {
|
|
1089
|
+
name: t.name,
|
|
1090
|
+
description: t.description || '',
|
|
1091
|
+
inputSchema: {
|
|
1092
|
+
type: 'object',
|
|
1093
|
+
properties: schema.properties || {},
|
|
1094
|
+
required: schema.required || []
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
// 如果没到最大深度且MCP未提供cpu_spawn_subagent,再手动加
|
|
1100
|
+
if (depth < maxDepth && !tools.some(t => t.name === 'cpu_spawn_subagent')) {
|
|
1101
|
+
tools.push({
|
|
1102
|
+
name: 'cpu_spawn_subagent',
|
|
1103
|
+
description: `派生子Agent执行子任务(当前深度${depth+1}/${maxDepth},最多${maxChildren}个)`,
|
|
1104
|
+
inputSchema: {
|
|
1105
|
+
type: 'object',
|
|
1106
|
+
properties: {
|
|
1107
|
+
prompt: { type: 'string', description: '子任务描述' },
|
|
1108
|
+
timeout: { type: 'number', description: '超时s数(可选)' }
|
|
1109
|
+
},
|
|
1110
|
+
required: ['prompt']
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// tavily_search不再手动注入 — webSearch(MCP统一入口)已含搜索+提取
|
|
1116
|
+
|
|
1117
|
+
// 按depth存缓存,不同深度互不影响
|
|
1118
|
+
toolCacheByDepth[depth] = tools;
|
|
1119
|
+
return { tools, sessionId }; // session不关,留给调用方复用
|
|
1120
|
+
} catch (err) {
|
|
1121
|
+
// 如果获取tools/list失败,关闭session后抛出
|
|
1122
|
+
try { await closeMCPSession(sessionId); } catch {}
|
|
1123
|
+
throw err;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// 从SSE流读取tools/list的结果
|
|
1128
|
+
async function readMCPListResult(reader) {
|
|
1129
|
+
const decoder = new TextDecoder();
|
|
1130
|
+
let buffer = '';
|
|
1131
|
+
|
|
1132
|
+
while (true) {
|
|
1133
|
+
const { done, value } = await readWithTimeout(reader, SUBAGENT_MCP_TOOL_TIMEOUT_MS, 'MCP tools/list');
|
|
1134
|
+
if (done) break;
|
|
1135
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1136
|
+
const lines = buffer.split('\n');
|
|
1137
|
+
buffer = lines.pop() || '';
|
|
1138
|
+
|
|
1139
|
+
for (const line of lines) {
|
|
1140
|
+
if (line.startsWith('data: ')) {
|
|
1141
|
+
try {
|
|
1142
|
+
const data = JSON.parse(line.slice(6));
|
|
1143
|
+
if (data.id === 'list-tools' && data.result?.tools) {
|
|
1144
|
+
return data.result.tools;
|
|
1145
|
+
}
|
|
1146
|
+
if (data.id === 'list-tools' && data.error) {
|
|
1147
|
+
throw new Error('MCP tools/list error: ' + (data.error.message || JSON.stringify(data.error)));
|
|
1148
|
+
}
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
if (e.message?.startsWith('MCP tools/list error')) throw e;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
throw new Error('MCP tools/list returned no tools');
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// ── LLM调用(含function calling,自带令牌桶排队)───────────────
|
|
1159
|
+
// 令牌桶:同一子agent最多3个API并发请求,超出的排队
|
|
1160
|
+
const API_MAX_CONCURRENCY = 3;
|
|
1161
|
+
let _apiActiveCalls = 0;
|
|
1162
|
+
const _apiWaitQueue = [];
|
|
1163
|
+
|
|
1164
|
+
function acquireAPI() {
|
|
1165
|
+
return new Promise(resolve => {
|
|
1166
|
+
if (_apiActiveCalls < API_MAX_CONCURRENCY) {
|
|
1167
|
+
_apiActiveCalls++;
|
|
1168
|
+
resolve();
|
|
1169
|
+
} else {
|
|
1170
|
+
_apiWaitQueue.push(resolve);
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function releaseAPI() {
|
|
1176
|
+
if (_apiWaitQueue.length > 0) {
|
|
1177
|
+
const next = _apiWaitQueue.shift();
|
|
1178
|
+
next();
|
|
1179
|
+
} else {
|
|
1180
|
+
_apiActiveCalls--;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function callLLM(messages, model, apiKey, tools) {
|
|
1185
|
+
// 先获取API令牌,排队等
|
|
1186
|
+
await acquireAPI();
|
|
1187
|
+
try {
|
|
1188
|
+
return await _callLLMImpl(messages, model, apiKey, tools);
|
|
1189
|
+
} finally {
|
|
1190
|
+
releaseAPI();
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
async function _callLLMImpl(messages, model, apiKey, tools) {
|
|
1195
|
+
// 从环境变量或参数中获取API Key
|
|
1196
|
+
const key = apiKey || process.env.DEEPSEEK_SUB_API_KEY || process.env.DEEPSEEK_API_KEY;
|
|
1197
|
+
if (!key) throw new Error('DEEPSEEK_API_KEY 未设置');
|
|
1198
|
+
|
|
1199
|
+
// 支持 LLM_BASE_URL 重定向所有模型(含 DeepSeek)到 ACS
|
|
1200
|
+
const url = process.env.LLM_BASE_URL || ACS_LLM_BASE_URL;
|
|
1201
|
+
|
|
1202
|
+
const modelId = model.startsWith('deepseek/') ? model.replace('deepseek/', '') : model;
|
|
1203
|
+
|
|
1204
|
+
const body = {
|
|
1205
|
+
model: modelId,
|
|
1206
|
+
messages,
|
|
1207
|
+
max_tokens: 8192,
|
|
1208
|
+
temperature: 0.3
|
|
1209
|
+
};
|
|
1210
|
+
|
|
1211
|
+
// 如果传入了工具定义,加到请求里(function calling)
|
|
1212
|
+
if (tools && tools.length > 0) {
|
|
1213
|
+
body.tools = tools;
|
|
1214
|
+
// DeepSeek V4 Flash需要tool_choice让模型知道可以调工具
|
|
1215
|
+
body.tool_choice = 'auto';
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const acsTaskIdHeader = toAsciiSafeHeaderValue(currentTaskId, '');
|
|
1219
|
+
|
|
1220
|
+
const response = await fetchWithTimeout(url, {
|
|
1221
|
+
method: 'POST',
|
|
1222
|
+
headers: {
|
|
1223
|
+
'Content-Type': 'application/json',
|
|
1224
|
+
'x-task-id': acsTaskIdHeader || 'main',
|
|
1225
|
+
'x-acs-lane': 'SUB',
|
|
1226
|
+
'x-acs-task-id': acsTaskIdHeader,
|
|
1227
|
+
'x-acs-task-name': toAsciiSafeHeaderValue(currentTaskName, ''),
|
|
1228
|
+
'x-acs-batch-name': toAsciiSafeHeaderValue(currentBatchName, ''),
|
|
1229
|
+
'x-acs-group-name': toAsciiSafeHeaderValue(currentGroupName, ''),
|
|
1230
|
+
'Authorization': `Bearer ${key}`
|
|
1231
|
+
},
|
|
1232
|
+
body: JSON.stringify(body),
|
|
1233
|
+
}, SUBAGENT_LLM_TIMEOUT_MS);
|
|
1234
|
+
|
|
1235
|
+
if (!response.ok) {
|
|
1236
|
+
const text = await response.text();
|
|
1237
|
+
throw new Error(`LLM API error ${response.status}: ${text.substring(0, 200)}`);
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
return response.json();
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ── 工具调用 ──────────────────────────────────────────────────────────
|
|
1244
|
+
async function callTool(toolName, args, context = {}) {
|
|
1245
|
+
// 特殊处理:递归派兵走Sidecar
|
|
1246
|
+
if (toolName === 'cpu_spawn_subagent') {
|
|
1247
|
+
// 用调用者传入的当前深度 +1,而不是从LLM参数里猜
|
|
1248
|
+
// 这样不管DeepSeek传不传depth,深度都正确传递
|
|
1249
|
+
const currentDepth = (context && context.depth !== undefined) ? context.depth : 0;
|
|
1250
|
+
const response = await fetchWithTimeout(`${SIDECAR_URL}/spawn_subagent`, {
|
|
1251
|
+
method: 'POST',
|
|
1252
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1253
|
+
body: JSON.stringify({
|
|
1254
|
+
...args,
|
|
1255
|
+
taskName: args.taskName || args.name || currentTaskName,
|
|
1256
|
+
batchName: args.batchName || currentBatchName,
|
|
1257
|
+
groupName: args.groupName || currentGroupName || currentTaskName,
|
|
1258
|
+
depth: currentDepth + 1
|
|
1259
|
+
})
|
|
1260
|
+
}, SUBAGENT_MCP_CONNECT_TIMEOUT_MS);
|
|
1261
|
+
if (!response.ok) throw new Error(`spawn_subagent error: ${response.status}`);
|
|
1262
|
+
return response.json();
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// 其他工具走sc MCP,用共享session(断了自动重连)
|
|
1266
|
+
const sharedSession = context.sharedSession;
|
|
1267
|
+
if (!sharedSession || !sharedSession.id) throw new Error('MCP session未初始化');
|
|
1268
|
+
|
|
1269
|
+
await acquireMCP();
|
|
1270
|
+
try {
|
|
1271
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1272
|
+
try {
|
|
1273
|
+
process.stderr.write(`[subagent] 调工具 ${toolName} (session复用: ${sharedSession.id})
|
|
1274
|
+
`);
|
|
1275
|
+
return await callMCPTool(sharedSession.id, toolName, args);
|
|
1276
|
+
} catch (err) {
|
|
1277
|
+
if (attempt === 0 && (err.message.includes('SSE') || err.message.includes('session') || err.message.includes('reader'))) {
|
|
1278
|
+
process.stderr.write(`[subagent] MCP session断连,重连中: ${err.message}\n`);
|
|
1279
|
+
try { await closeMCPSession(sharedSession.id); } catch {}
|
|
1280
|
+
sharedSession.id = await createMCPSession();
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1283
|
+
throw err;
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
} finally {
|
|
1287
|
+
releaseMCP();
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ── MCP Session管理 ──────────────────────────────────────────────────
|
|
1292
|
+
const mcpSessions = new Map(); // sessionId -> { response, reader }
|
|
1293
|
+
|
|
1294
|
+
async function createMCPSession() {
|
|
1295
|
+
let response;
|
|
1296
|
+
try {
|
|
1297
|
+
response = await fetchWithTimeout(`${MCP_URL}/sse?role=subagent`, {}, SUBAGENT_MCP_CONNECT_TIMEOUT_MS);
|
|
1298
|
+
} catch (e) {
|
|
1299
|
+
throw new Error(`SC bridge unavailable at ${MCP_URL} (MCP/SSE). Check that the 18790 bridge is running before spawning SC subagents. Original error: ${e.message}`);
|
|
1300
|
+
}
|
|
1301
|
+
if (!response.ok) throw new Error(`MCP SSE error: ${response.status}`);
|
|
1302
|
+
const reader = response.body.getReader();
|
|
1303
|
+
|
|
1304
|
+
// 读第一块数据,提取sessionId
|
|
1305
|
+
const { value } = await readWithTimeout(reader, SUBAGENT_MCP_CONNECT_TIMEOUT_MS, 'MCP session init');
|
|
1306
|
+
const text = new TextDecoder().decode(value);
|
|
1307
|
+
const sessionId = text.match(/sessionId=([^\s&]+)/)?.[1];
|
|
1308
|
+
if (!sessionId) throw new Error('Failed to get MCP sessionId');
|
|
1309
|
+
|
|
1310
|
+
mcpSessions.set(sessionId, { response, reader });
|
|
1311
|
+
return sessionId;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async function closeMCPSession(sessionId) {
|
|
1315
|
+
const session = mcpSessions.get(sessionId);
|
|
1316
|
+
if (session) {
|
|
1317
|
+
try { session.reader.cancel(); } catch (e) { process.stderr.write(`[subagent] reader cancel error: ${e.message}\n`); }
|
|
1318
|
+
mcpSessions.delete(sessionId);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
async function callMCPTool(sessionId, toolName, args) {
|
|
1323
|
+
const msgUrl = `${MCP_URL}/messages?sessionId=${sessionId}`;
|
|
1324
|
+
const session = mcpSessions.get(sessionId);
|
|
1325
|
+
if (!session) throw new Error(`MCP session ${sessionId} not found`);
|
|
1326
|
+
|
|
1327
|
+
// 发工具调用请求
|
|
1328
|
+
const msgResponse = await fetchWithTimeout(msgUrl, {
|
|
1329
|
+
method: 'POST',
|
|
1330
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1331
|
+
body: JSON.stringify({
|
|
1332
|
+
jsonrpc: '2.0',
|
|
1333
|
+
id: 'req-' + Date.now().toString(36),
|
|
1334
|
+
method: 'tools/call',
|
|
1335
|
+
params: { name: toolName, arguments: args }
|
|
1336
|
+
})
|
|
1337
|
+
}, SUBAGENT_MCP_TOOL_TIMEOUT_MS);
|
|
1338
|
+
if (!msgResponse.ok) throw new Error(`MCP call error: ${msgResponse.status}`);
|
|
1339
|
+
|
|
1340
|
+
// 从SSE流中读取结果
|
|
1341
|
+
const result = await readMCPSessionResult(sessionId, session.reader, toolName);
|
|
1342
|
+
return result;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
async function readMCPSessionResult(sessionId, reader, toolName) {
|
|
1346
|
+
const decoder = new TextDecoder();
|
|
1347
|
+
let buffer = '';
|
|
1348
|
+
|
|
1349
|
+
while (true) {
|
|
1350
|
+
const { done, value } = await readWithTimeout(reader, SUBAGENT_MCP_TOOL_TIMEOUT_MS, `MCP tool ${toolName}`);
|
|
1351
|
+
if (done) break;
|
|
1352
|
+
|
|
1353
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1354
|
+
const lines = buffer.split('\n');
|
|
1355
|
+
buffer = lines.pop() || '';
|
|
1356
|
+
|
|
1357
|
+
for (const line of lines) {
|
|
1358
|
+
// SSE格式: data: {...json...}
|
|
1359
|
+
if (line.startsWith('data: ')) {
|
|
1360
|
+
try {
|
|
1361
|
+
const data = JSON.parse(line.slice(6));
|
|
1362
|
+
// MCP结果格式: { jsonrpc, id, result } 或 { jsonrpc, id, error }
|
|
1363
|
+
if (data.id && data.id.startsWith('req-')) {
|
|
1364
|
+
if (data.result) return data.result;
|
|
1365
|
+
if (data.error) throw new Error(`MCP error: ${data.error.message || JSON.stringify(data.error)}`);
|
|
1366
|
+
}
|
|
1367
|
+
} catch (e) {
|
|
1368
|
+
if (e.message?.startsWith('MCP error')) throw e;
|
|
1369
|
+
// 非JSON行忽略
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
throw new Error(`MCP tool ${toolName} returned no result`);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// ── 工具函数 ──────────────────────────────────────────────────────────
|
|
1379
|
+
function ensureDir(dirPath) {
|
|
1380
|
+
if (!fs.existsSync(dirPath)) {
|
|
1381
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/** 方案A: 消息邻接完整性保护
|
|
1386
|
+
* 按消息顺序逐条扫描,确保每个assistant(tool_calls)后面紧跟正确数量的tool响应
|
|
1387
|
+
* 缺口自动补filler(插到正确位置,不丢到数组末尾)
|
|
1388
|
+
* 额外(非对应assistant的)tool消息自动移除
|
|
1389
|
+
*/
|
|
1390
|
+
function guardMessages(messages) {
|
|
1391
|
+
const fixed = [];
|
|
1392
|
+
let i = 0;
|
|
1393
|
+
while (i < messages.length) {
|
|
1394
|
+
const msg = messages[i];
|
|
1395
|
+
fixed.push(msg);
|
|
1396
|
+
i++;
|
|
1397
|
+
|
|
1398
|
+
// 发现assistant(tool_calls) → 消费紧跟在后面的N个tool消息
|
|
1399
|
+
if (msg.role === 'assistant' && msg.tool_calls?.length > 0) {
|
|
1400
|
+
const expectedIds = new Set(msg.tool_calls.map(tc => tc.id));
|
|
1401
|
+
let consumed = 0;
|
|
1402
|
+
const expectedCount = msg.tool_calls.length;
|
|
1403
|
+
|
|
1404
|
+
// 消费紧跟在后面的tool消息(只消费属于这个assistant的)
|
|
1405
|
+
while (i < messages.length && consumed < expectedCount) {
|
|
1406
|
+
const next = messages[i];
|
|
1407
|
+
if (next.role === 'tool' && next.tool_call_id && expectedIds.has(next.tool_call_id)) {
|
|
1408
|
+
fixed.push(next);
|
|
1409
|
+
i++;
|
|
1410
|
+
consumed++;
|
|
1411
|
+
} else {
|
|
1412
|
+
break; // 遇到了非tool消息或不属于本assistant的tool → 缺口
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// 补上缺失的filler(插到缺口位置,不是数组末尾)
|
|
1417
|
+
while (consumed < expectedCount) {
|
|
1418
|
+
const missingId = msg.tool_calls[consumed]?.id || 'unknown_' + consumed;
|
|
1419
|
+
fixed.push({
|
|
1420
|
+
role: 'tool',
|
|
1421
|
+
tool_call_id: missingId,
|
|
1422
|
+
content: JSON.stringify({ error: '工具调用中断(系统自动补全)' })
|
|
1423
|
+
});
|
|
1424
|
+
consumed++;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// 用修复后的数组替换原数组
|
|
1430
|
+
messages.length = 0;
|
|
1431
|
+
messages.push(...fixed);
|
|
1432
|
+
return messages;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
/** codeMode熔断: 递归恢复workspace下所有最近2h内创建的.bak文件 */
|
|
1436
|
+
function restoreBackups(wsRoot) {
|
|
1437
|
+
const cutoff = Date.now() - 2 * 60 * 60 * 1000; // 2小时以内的备份
|
|
1438
|
+
function walk(dir) {
|
|
1439
|
+
let items; try { items = fs.readdirSync(dir); } catch { return; }
|
|
1440
|
+
for (const item of items) {
|
|
1441
|
+
if (item.startsWith('node_modules') || item.startsWith('.git')) continue;
|
|
1442
|
+
const fp = path.join(dir, item);
|
|
1443
|
+
let stat; try { stat = fs.statSync(fp); } catch { continue; }
|
|
1444
|
+
if (stat.isDirectory()) { walk(fp); }
|
|
1445
|
+
else if (item.endsWith('.bak') && stat.mtimeMs > cutoff) {
|
|
1446
|
+
const orig = fp.replace(/\.bak$/, '');
|
|
1447
|
+
try { fs.copyFileSync(fp, orig); process.stderr.write('[subagent] 恢复: ' + fp + ' → ' + orig + '\n'); }
|
|
1448
|
+
catch (e) { process.stderr.write('[subagent] 恢复失败: ' + fp + ': ' + e.message + '\n'); }
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
walk(wsRoot);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function readStdin() {
|
|
1456
|
+
return new Promise((resolve, reject) => {
|
|
1457
|
+
let data = '';
|
|
1458
|
+
process.stdin.on('data', chunk => data += chunk);
|
|
1459
|
+
process.stdin.on('end', () => {
|
|
1460
|
+
try { resolve(data ? JSON.parse(data) : {}); }
|
|
1461
|
+
catch { reject(new Error('Invalid JSON from stdin')); }
|
|
1462
|
+
});
|
|
1463
|
+
process.stdin.on('error', reject);
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// ── 启动 ──────────────────────────────────────────────────────────────
|
|
1468
|
+
main().catch(err => {
|
|
1469
|
+
process.stderr.write(JSON.stringify({ status: 'error', error: err.message }));
|
|
1470
|
+
process.exit(1);
|
|
1471
|
+
});
|