@lwmxiaobei/xbcode 1.0.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/LICENSE +21 -0
- package/README.md +631 -0
- package/README.zh-CN.md +542 -0
- package/dist/agent.js +1450 -0
- package/dist/busy-status.js +29 -0
- package/dist/clipboard-image.js +97 -0
- package/dist/commands.js +109 -0
- package/dist/compact.js +262 -0
- package/dist/config.js +516 -0
- package/dist/error-log.js +80 -0
- package/dist/http.js +89 -0
- package/dist/idle-watchdog.js +88 -0
- package/dist/index.js +2031 -0
- package/dist/input-submit.js +41 -0
- package/dist/mcp/client.js +466 -0
- package/dist/mcp/manager.js +275 -0
- package/dist/mcp/runtime.js +420 -0
- package/dist/mcp/types.js +12 -0
- package/dist/message-bus.js +180 -0
- package/dist/oauth/openai.js +326 -0
- package/dist/prompt.js +156 -0
- package/dist/session-store.js +186 -0
- package/dist/skills/frontmatter.js +85 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.js +88 -0
- package/dist/skills/render.js +35 -0
- package/dist/skills/types.js +1 -0
- package/dist/subagents.js +64 -0
- package/dist/supervisor.js +58 -0
- package/dist/task-manager.js +280 -0
- package/dist/team-types.js +1 -0
- package/dist/teammate-manager.js +266 -0
- package/dist/tools.js +1068 -0
- package/dist/trust-store.js +42 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +226 -0
- package/dist/utils.js +21 -0
- package/package.json +67 -0
- package/scripts/postinstall.mjs +30 -0
- package/skills/code-review/SKILL.md +22 -0
- package/skills/pdf/SKILL.md +18 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,1068 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { exec, execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { MessageBus } from "./message-bus.js";
|
|
7
|
+
import { TeammateManager } from "./teammate-manager.js";
|
|
8
|
+
import { TaskManager } from "./task-manager.js";
|
|
9
|
+
import { SkillLoader } from "./skills/index.js";
|
|
10
|
+
import { handleListMcpResources, handleMcpCall, handleReadMcpResource } from "./mcp/runtime.js";
|
|
11
|
+
import { describeSubagentsForHumans } from "./subagents.js";
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
// execFile 不走 shell,把参数以数组形式传给进程,正则 pattern 里的特殊字符不会被 shell 二次解析。
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
const WORKDIR = process.cwd();
|
|
16
|
+
export const LEAD_NAME = "lead";
|
|
17
|
+
export const TEAM_DIR = path.join(WORKDIR, ".team");
|
|
18
|
+
// 技能分为全局技能和仓库本地技能,本地技能可以覆盖同名全局技能。
|
|
19
|
+
// 全局目录优先使用 ~/.xbcode/skills,同时兼容 Claude 的 ~/.claude/skills。
|
|
20
|
+
const GLOBAL_SKILLS_DIR = path.join(os.homedir(), ".xbcode", "skills");
|
|
21
|
+
const CLAUDE_SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
|
|
22
|
+
const LOCAL_SKILLS_DIR = path.join(WORKDIR, "skills");
|
|
23
|
+
// Load order: xbcode global -> claude-compatible global -> local override
|
|
24
|
+
export const skillLoader = new SkillLoader([GLOBAL_SKILLS_DIR, CLAUDE_SKILLS_DIR, LOCAL_SKILLS_DIR]);
|
|
25
|
+
// 这些单例对象组成了 CLI agent 的工具运行时。
|
|
26
|
+
export const taskManager = new TaskManager(path.join(WORKDIR, ".tasks"));
|
|
27
|
+
export const messageBus = new MessageBus(TEAM_DIR);
|
|
28
|
+
export const teammateManager = new TeammateManager(TEAM_DIR, messageBus, LEAD_NAME);
|
|
29
|
+
// 路径解析(不再限制在工作区内)。
|
|
30
|
+
function safePath(relativePath) {
|
|
31
|
+
return path.resolve(WORKDIR, relativePath);
|
|
32
|
+
}
|
|
33
|
+
// child_process 的超时错误没有稳定类型,这里单独抽成守卫函数做识别。
|
|
34
|
+
function isExecTimeout(error) {
|
|
35
|
+
return typeof error === "object" && error !== null && "killed" in error && Boolean(error.killed);
|
|
36
|
+
}
|
|
37
|
+
// exec 抛出的错误对象通常附带 stdout/stderr,取出来可以保留更多诊断信息。
|
|
38
|
+
function toExecError(error) {
|
|
39
|
+
if (typeof error === "object" && error !== null) {
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
// 命令分隔符或行首,作为大多数模式的左边界;防止 `pseudo` 误伤 `sudo` 之类的子串匹配。
|
|
45
|
+
const COMMAND_BOUNDARY = "(?:^|[\\s;&|`(])";
|
|
46
|
+
const DANGEROUS_COMMAND_PATTERNS = [
|
|
47
|
+
// 删除根 / 系统目录 / 家目录 / 裸通配符。工作区里的 rm -rf node_modules、./dist 不命中。
|
|
48
|
+
{
|
|
49
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}rm\\s+(?:-[a-zA-Z]*[rRfF][a-zA-Z]*\\s+)+(?:/(?:\\s|$)|/(?:etc|usr|var|home|root|bin|sbin|opt|System|Library|private|boot|dev|proc|sys)(?:\\b|/)|~(?:\\s|/|$)|\\$HOME\\b|\\*(?:\\s|$))`),
|
|
50
|
+
reason: "rm targeting root, a system directory, home, or bare wildcard",
|
|
51
|
+
},
|
|
52
|
+
// sudo 提权
|
|
53
|
+
{
|
|
54
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}sudo(?:\\s|$)`),
|
|
55
|
+
reason: "sudo (privilege escalation)",
|
|
56
|
+
},
|
|
57
|
+
// 关机 / 重启 / 停机
|
|
58
|
+
{
|
|
59
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}(?:shutdown|reboot|halt|poweroff)(?:\\s|$)`),
|
|
60
|
+
reason: "system power command",
|
|
61
|
+
},
|
|
62
|
+
// chmod 777 在根 / 家目录 / 裸通配符上。`chmod 755 script.sh`、`chmod 777 ./tmp` 不命中。
|
|
63
|
+
{
|
|
64
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}chmod\\s+(?:-R\\s+)?[0-7]?777\\s+(?:/|~|\\$HOME|\\*)`),
|
|
65
|
+
reason: "chmod 777 on root, home, or wildcard",
|
|
66
|
+
},
|
|
67
|
+
// dd 写入物理块设备
|
|
68
|
+
{
|
|
69
|
+
pattern: /(?:^|[\s;&|`(])dd\s[^;&|]*\bof=\/dev\/(?:sd|nvme|hd|vd|disk|mmcblk)/,
|
|
70
|
+
reason: "dd writing to a block device",
|
|
71
|
+
},
|
|
72
|
+
// 直接 `> /dev/sda` 重定向到块设备。`> /dev/null`、`> /tmp/out` 不命中。
|
|
73
|
+
{
|
|
74
|
+
pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|disk|mmcblk)/,
|
|
75
|
+
reason: "redirect into a block device",
|
|
76
|
+
},
|
|
77
|
+
// 文件系统格式化
|
|
78
|
+
{
|
|
79
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}mkfs(?:\\.[a-zA-Z0-9]+)?\\s`),
|
|
80
|
+
reason: "filesystem format",
|
|
81
|
+
},
|
|
82
|
+
// curl/wget 直接管道到 shell,等同于无审查地执行远程脚本
|
|
83
|
+
{
|
|
84
|
+
pattern: /\b(?:curl|wget|fetch)\b[^|;&]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh|ksh|fish|dash)\b/,
|
|
85
|
+
reason: "piping remote content into a shell",
|
|
86
|
+
},
|
|
87
|
+
// fork bomb
|
|
88
|
+
{
|
|
89
|
+
pattern: /:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;?\s*:/,
|
|
90
|
+
reason: "fork bomb",
|
|
91
|
+
},
|
|
92
|
+
// 强制推送会覆盖共享分支历史
|
|
93
|
+
{
|
|
94
|
+
pattern: new RegExp(`${COMMAND_BOUNDARY}git\\s+push\\b[^;&|]*\\s(?:-f|--force(?:-with-lease)?)\\b`),
|
|
95
|
+
reason: "git push --force (affects shared remote)",
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
// 多个连续空白合并成单空格,避免 `rm -rf /` 这种多空格写法绕过子串匹配。
|
|
99
|
+
// 不去掉引号 / 转义,因为那会改变命令语义。
|
|
100
|
+
function normalizeForDangerCheck(command) {
|
|
101
|
+
return command.replace(/\s+/g, " ").trim();
|
|
102
|
+
}
|
|
103
|
+
// 抽成独立纯函数是为了能直接做单元测试,而不必经过 child_process。
|
|
104
|
+
export function detectDangerousCommand(command) {
|
|
105
|
+
const normalized = normalizeForDangerCheck(command);
|
|
106
|
+
if (!normalized)
|
|
107
|
+
return null;
|
|
108
|
+
for (const { pattern, reason } of DANGEROUS_COMMAND_PATTERNS) {
|
|
109
|
+
if (pattern.test(normalized))
|
|
110
|
+
return reason;
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// 工具输出统一截断阈值。50K 字符大约对应 12K~15K tokens,足够覆盖一次合理工具调用,
|
|
115
|
+
// 又不至于把上下文塞满。所有截断都走 `appendTruncationNotice`,保证模型能看到一致的提示。
|
|
116
|
+
const TOOL_OUTPUT_MAX_BYTES = 50_000;
|
|
117
|
+
// 模型不读字节数,所以截断提示用「行数」而不是「字节数」更直观。
|
|
118
|
+
// 格式参考 Claude Code 的 BashTool/utils.ts:在 kept 后追加 `\n\n... [N lines truncated] ...`,
|
|
119
|
+
// 模型流式从前往后读,看到末尾的提示行就知道内容被截断、可以选择再读 / 缩小范围。
|
|
120
|
+
export function appendTruncationNotice(content, maxBytes = TOOL_OUTPUT_MAX_BYTES) {
|
|
121
|
+
if (content.length <= maxBytes)
|
|
122
|
+
return content;
|
|
123
|
+
const kept = content.slice(0, maxBytes);
|
|
124
|
+
// dropped 部分里的换行数 + 1 = 被截掉的行数(最后一行可能不完整也算一行)。
|
|
125
|
+
const dropped = content.slice(maxBytes);
|
|
126
|
+
const remainingLines = (dropped.match(/\n/g)?.length ?? 0) + 1;
|
|
127
|
+
return `${kept}\n\n... [${remainingLines} lines truncated] ...`;
|
|
128
|
+
}
|
|
129
|
+
async function runBash(command, signal) {
|
|
130
|
+
const dangerReason = detectDangerousCommand(command);
|
|
131
|
+
if (dangerReason) {
|
|
132
|
+
return `Error: Dangerous command blocked (${dangerReason})`;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
136
|
+
cwd: WORKDIR,
|
|
137
|
+
timeout: 120_000,
|
|
138
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
139
|
+
shell: process.env.SHELL,
|
|
140
|
+
signal,
|
|
141
|
+
});
|
|
142
|
+
const combined = `${stdout}${stderr}`.trim();
|
|
143
|
+
return combined ? appendTruncationNotice(combined) : "(no output)";
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error?.name === "AbortError" || signal?.aborted) {
|
|
147
|
+
return "Error: Command aborted";
|
|
148
|
+
}
|
|
149
|
+
if (isExecTimeout(error)) {
|
|
150
|
+
return "Error: Timeout (120s)";
|
|
151
|
+
}
|
|
152
|
+
const execError = toExecError(error);
|
|
153
|
+
const combined = `${execError.stdout ?? ""}${execError.stderr ?? ""}`.trim();
|
|
154
|
+
return combined ? appendTruncationNotice(combined) : `Error: ${String(error)}`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// 下面三个文件工具是最基础的工作区读写能力。
|
|
158
|
+
function runRead(filePath, limit) {
|
|
159
|
+
try {
|
|
160
|
+
const text = fs.readFileSync(safePath(filePath), "utf8");
|
|
161
|
+
let lines = text.split(/\r?\n/);
|
|
162
|
+
if (limit && limit < lines.length) {
|
|
163
|
+
lines = [...lines.slice(0, limit), `... (${lines.length - limit} more lines)`];
|
|
164
|
+
}
|
|
165
|
+
return appendTruncationNotice(lines.join("\n"));
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function runWrite(filePath, content) {
|
|
172
|
+
try {
|
|
173
|
+
const fullPath = safePath(filePath);
|
|
174
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
175
|
+
fs.writeFileSync(fullPath, content, "utf8");
|
|
176
|
+
return `Wrote ${Buffer.byteLength(content, "utf8")} bytes to ${filePath}`;
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 弯引号 / 直引号互转。
|
|
183
|
+
//
|
|
184
|
+
// 模型看不到弯引号会输出直引号,但很多文档(README、注释、字符串字面量)里
|
|
185
|
+
// 真实存在的是弯引号;反之亦然。空白模糊匹配会改变代码语义(Python/YAML),
|
|
186
|
+
// 这里只对引号这种「无歧义可逆」的字符做归一化。
|
|
187
|
+
const LEFT_SINGLE_CURLY_QUOTE = "‘";
|
|
188
|
+
const RIGHT_SINGLE_CURLY_QUOTE = "’";
|
|
189
|
+
const LEFT_DOUBLE_CURLY_QUOTE = "“";
|
|
190
|
+
const RIGHT_DOUBLE_CURLY_QUOTE = "”";
|
|
191
|
+
export function normalizeQuotes(input) {
|
|
192
|
+
return input
|
|
193
|
+
.replaceAll(LEFT_SINGLE_CURLY_QUOTE, "'")
|
|
194
|
+
.replaceAll(RIGHT_SINGLE_CURLY_QUOTE, "'")
|
|
195
|
+
.replaceAll(LEFT_DOUBLE_CURLY_QUOTE, '"')
|
|
196
|
+
.replaceAll(RIGHT_DOUBLE_CURLY_QUOTE, '"');
|
|
197
|
+
}
|
|
198
|
+
// 给定文件内容和模型给的 oldText,返回真正能用于替换的、来自文件的实际子串。
|
|
199
|
+
// 失败则返回 null。命中规则只有两条:精确匹配 → 引号归一化匹配。
|
|
200
|
+
export function findActualOldText(content, oldText) {
|
|
201
|
+
if (content.includes(oldText))
|
|
202
|
+
return oldText;
|
|
203
|
+
const normalizedContent = normalizeQuotes(content);
|
|
204
|
+
const normalizedOld = normalizeQuotes(oldText);
|
|
205
|
+
const idx = normalizedContent.indexOf(normalizedOld);
|
|
206
|
+
if (idx === -1)
|
|
207
|
+
return null;
|
|
208
|
+
// 归一化是字符级一一替换,长度不变,所以可以按相同偏移和长度从原文中切片。
|
|
209
|
+
return content.substring(idx, idx + oldText.length);
|
|
210
|
+
}
|
|
211
|
+
// 去掉每行末尾的水平空白,但保留行尾的 \r? \n。
|
|
212
|
+
// markdown 的双空格行尾 = 硬换行,剥掉会改变渲染结果,因此 .md/.mdx 文件不调用这个函数。
|
|
213
|
+
export function stripTrailingWhitespacePerLine(input) {
|
|
214
|
+
return input.replace(/[ \t]+(\r?\n|$)/g, "$1");
|
|
215
|
+
}
|
|
216
|
+
function isMarkdownFile(filePath) {
|
|
217
|
+
return /\.(md|mdx)$/i.test(filePath);
|
|
218
|
+
}
|
|
219
|
+
function runEdit(filePath, oldText, newText) {
|
|
220
|
+
try {
|
|
221
|
+
const fullPath = safePath(filePath);
|
|
222
|
+
const content = fs.readFileSync(fullPath, "utf8");
|
|
223
|
+
const actualOldText = findActualOldText(content, oldText);
|
|
224
|
+
if (actualOldText === null) {
|
|
225
|
+
return `Error: String not found in ${filePath}. Read the file again to confirm exact content.`;
|
|
226
|
+
}
|
|
227
|
+
const normalizedNewText = isMarkdownFile(filePath)
|
|
228
|
+
? newText
|
|
229
|
+
: stripTrailingWhitespacePerLine(newText);
|
|
230
|
+
// 用回调形式的 replace 避免 $&、$1 等替换序列被解释成捕获组。
|
|
231
|
+
const updated = content.replace(actualOldText, () => normalizedNewText);
|
|
232
|
+
if (updated === content) {
|
|
233
|
+
return `Error: Edit produced no changes in ${filePath}. old_text and new_text appear to match the same content.`;
|
|
234
|
+
}
|
|
235
|
+
fs.writeFileSync(fullPath, updated, "utf8");
|
|
236
|
+
return `Edited ${filePath}`;
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// --- web_fetch ---------------------------------------------------------------
|
|
243
|
+
// MVP 版:拉取 URL -> 粗糙把 HTML 脱成文本 -> 按字符数截断。
|
|
244
|
+
// 对齐 Claude Code WebFetch 的几条硬约束(URL 合法性、http→https 升级、10MB body、30s 超时),
|
|
245
|
+
// 但不做二次总结、缓存、手动重定向防护等更重的事情。想升级再加。
|
|
246
|
+
// 只做最基本的 URL 校验:协议白名单、长度、禁止携带账号密码、禁止明显的内网/非公共域名。
|
|
247
|
+
// 所有更复杂的安全策略(domain blocklist、IP 范围校验)留给后续版本。
|
|
248
|
+
export function validateFetchUrl(input) {
|
|
249
|
+
if (input.length > 2000)
|
|
250
|
+
throw new Error("URL too long (>2000 chars)");
|
|
251
|
+
const url = new URL(input); // URL 构造函数会在格式非法时抛错
|
|
252
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
253
|
+
throw new Error(`Unsupported protocol: ${url.protocol}`);
|
|
254
|
+
}
|
|
255
|
+
if (url.username || url.password) {
|
|
256
|
+
throw new Error("URL must not contain credentials");
|
|
257
|
+
}
|
|
258
|
+
// hostname 至少两段,过滤掉 'localhost' 这种内网名(127.0.0.1 有四段不受影响)。
|
|
259
|
+
const parts = url.hostname.split(".");
|
|
260
|
+
if (parts.length < 2 || parts.some((p) => p === "")) {
|
|
261
|
+
throw new Error("Hostname must be a publicly resolvable domain");
|
|
262
|
+
}
|
|
263
|
+
// http -> https 自动升级,减少明文流量。
|
|
264
|
+
if (url.protocol === "http:")
|
|
265
|
+
url.protocol = "https:";
|
|
266
|
+
return url;
|
|
267
|
+
}
|
|
268
|
+
// 常见命名 HTML 实体映射。数字/十六进制实体通过正则单独处理。
|
|
269
|
+
const HTML_NAMED_ENTITIES = {
|
|
270
|
+
"&": "&",
|
|
271
|
+
"<": "<",
|
|
272
|
+
">": ">",
|
|
273
|
+
""": '"',
|
|
274
|
+
"'": "'",
|
|
275
|
+
"'": "'",
|
|
276
|
+
" ": " ",
|
|
277
|
+
};
|
|
278
|
+
// 粗糙 HTML -> 文本转换。没有引入 turndown / cheerio 等依赖,
|
|
279
|
+
// 只是尽量保留段落感 + 去掉脚本样式 + 还原常见实体。
|
|
280
|
+
// 够模型读懂文档类页面,但不保证结构复杂的页面能还原出理想 markdown。
|
|
281
|
+
export function stripHtml(html) {
|
|
282
|
+
let text = html
|
|
283
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
284
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
285
|
+
.replace(/<!--[\s\S]*?-->/g, "");
|
|
286
|
+
// 块级标签边界转换成换行,保留阅读节奏。
|
|
287
|
+
text = text
|
|
288
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
289
|
+
.replace(/<\/(p|li|div|tr|h[1-6]|section|article|header|footer)>/gi, "\n")
|
|
290
|
+
.replace(/<[^>]+>/g, "");
|
|
291
|
+
// 解实体:先处理命名实体,再处理 { 和 。
|
|
292
|
+
text = text.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/g, (m) => HTML_NAMED_ENTITIES[m] ?? m);
|
|
293
|
+
text = text.replace(/&#(\d+);/g, (_, n) => {
|
|
294
|
+
const code = Number(n);
|
|
295
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
|
|
296
|
+
});
|
|
297
|
+
text = text.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => {
|
|
298
|
+
const code = parseInt(n, 16);
|
|
299
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
|
|
300
|
+
});
|
|
301
|
+
// 折叠多余空白:水平空白合并为单空格,连续空行最多两行。
|
|
302
|
+
return text
|
|
303
|
+
.replace(/\r\n?/g, "\n")
|
|
304
|
+
.replace(/[ \t]+/g, " ")
|
|
305
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
306
|
+
.trim();
|
|
307
|
+
}
|
|
308
|
+
const WEB_FETCH_TIMEOUT_MS = 30_000;
|
|
309
|
+
const WEB_FETCH_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
310
|
+
const WEB_FETCH_MAX_OUTPUT_CHARS = 100_000;
|
|
311
|
+
async function runWebFetch(rawUrl, signal) {
|
|
312
|
+
let url;
|
|
313
|
+
try {
|
|
314
|
+
url = validateFetchUrl(rawUrl);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
318
|
+
}
|
|
319
|
+
// Node 自带 fetch 没有内建超时,用 AbortController 加一个 30s 硬上限。
|
|
320
|
+
// 上层已经传入 signal 时通过 AbortSignal.any 合并,这样任一触发都会中断请求。
|
|
321
|
+
const timeoutController = new AbortController();
|
|
322
|
+
const timer = setTimeout(() => timeoutController.abort(), WEB_FETCH_TIMEOUT_MS);
|
|
323
|
+
const combinedSignal = signal
|
|
324
|
+
? AbortSignal.any([signal, timeoutController.signal])
|
|
325
|
+
: timeoutController.signal;
|
|
326
|
+
let response;
|
|
327
|
+
try {
|
|
328
|
+
response = await fetch(url.toString(), {
|
|
329
|
+
signal: combinedSignal,
|
|
330
|
+
redirect: "follow",
|
|
331
|
+
headers: {
|
|
332
|
+
"user-agent": "code-agent-web-fetch/1.0",
|
|
333
|
+
accept: "text/html, text/markdown, text/plain, */*",
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
clearTimeout(timer);
|
|
339
|
+
if (signal?.aborted)
|
|
340
|
+
return "Error: Aborted";
|
|
341
|
+
if (timeoutController.signal.aborted)
|
|
342
|
+
return `Error: Timeout (${WEB_FETCH_TIMEOUT_MS / 1000}s)`;
|
|
343
|
+
return `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
344
|
+
}
|
|
345
|
+
clearTimeout(timer);
|
|
346
|
+
if (!response.ok) {
|
|
347
|
+
return `Error: HTTP ${response.status} ${response.statusText || ""}`.trim();
|
|
348
|
+
}
|
|
349
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
350
|
+
let bodyText;
|
|
351
|
+
try {
|
|
352
|
+
bodyText = await response.text();
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return `Error: failed to read body: ${error instanceof Error ? error.message : String(error)}`;
|
|
356
|
+
}
|
|
357
|
+
if (Buffer.byteLength(bodyText, "utf8") > WEB_FETCH_MAX_BODY_BYTES) {
|
|
358
|
+
return `Error: response body exceeds ${WEB_FETCH_MAX_BODY_BYTES / (1024 * 1024)}MB limit`;
|
|
359
|
+
}
|
|
360
|
+
// 粗判 HTML:要么 content-type 明确,要么开头有 <html>/<!doctype>。
|
|
361
|
+
// 非 HTML 内容(JSON、纯文本、markdown)原样返回,避免误伤结构化数据。
|
|
362
|
+
const looksLikeHtml = contentType.includes("text/html") || /^\s*<!doctype html|^\s*<html[\s>]/i.test(bodyText);
|
|
363
|
+
const content = looksLikeHtml ? stripHtml(bodyText) : bodyText;
|
|
364
|
+
const body = appendTruncationNotice(content, WEB_FETCH_MAX_OUTPUT_CHARS);
|
|
365
|
+
const header = [
|
|
366
|
+
`URL: ${response.url || url.toString()}`,
|
|
367
|
+
`Status: ${response.status}`,
|
|
368
|
+
`Content-Type: ${contentType || "unknown"}`,
|
|
369
|
+
`Bytes: ${Buffer.byteLength(bodyText, "utf8")}`,
|
|
370
|
+
].join("\n");
|
|
371
|
+
return `${header}\n---\n${body}`;
|
|
372
|
+
}
|
|
373
|
+
function describeRgFailure(error, signal) {
|
|
374
|
+
const err = error;
|
|
375
|
+
if (err?.code === "ENOENT") {
|
|
376
|
+
return "Error: ripgrep (rg) is not installed. Install via `brew install ripgrep` or fall back to bash grep/find.";
|
|
377
|
+
}
|
|
378
|
+
if (signal?.aborted || err?.name === "AbortError") {
|
|
379
|
+
return "Error: Aborted";
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
// glob 工具:用 `rg --files --glob <pattern>` 列出匹配文件,再按 mtime 倒序截断到 100 条。
|
|
384
|
+
// 选择 rg 而不是 Node 自己遍历,是因为 rg 尊重 .gitignore,并且在大仓库里快一个数量级。
|
|
385
|
+
async function runGlob(pattern, relPath, signal) {
|
|
386
|
+
const searchDir = relPath ? safePath(relPath) : WORKDIR;
|
|
387
|
+
const args = [
|
|
388
|
+
"--files",
|
|
389
|
+
"--glob",
|
|
390
|
+
pattern,
|
|
391
|
+
// 排除典型噪音目录;用户真需要时可以显式在 pattern 里覆盖。
|
|
392
|
+
"--glob",
|
|
393
|
+
"!.git",
|
|
394
|
+
"--glob",
|
|
395
|
+
"!node_modules",
|
|
396
|
+
];
|
|
397
|
+
let files = [];
|
|
398
|
+
try {
|
|
399
|
+
const { stdout } = await execFileAsync("rg", args, {
|
|
400
|
+
cwd: searchDir,
|
|
401
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
402
|
+
signal,
|
|
403
|
+
});
|
|
404
|
+
files = stdout.split("\n").filter(Boolean);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const friendly = describeRgFailure(error, signal);
|
|
408
|
+
if (friendly)
|
|
409
|
+
return friendly;
|
|
410
|
+
const err = error;
|
|
411
|
+
// rg exit code 1 表示「没有匹配」,不是错误。
|
|
412
|
+
if (err.code === 1) {
|
|
413
|
+
files = (err.stdout ?? "").split("\n").filter(Boolean);
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
return `Error: ${err.message ?? String(err)}`;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (files.length === 0)
|
|
420
|
+
return "No files found";
|
|
421
|
+
// 按 mtime 倒排,最近改过的文件最有可能是用户关心的目标。
|
|
422
|
+
const stats = await Promise.all(files.map(async (rel) => {
|
|
423
|
+
try {
|
|
424
|
+
const st = await fs.promises.stat(path.join(searchDir, rel));
|
|
425
|
+
return { rel, mtime: st.mtimeMs };
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return { rel, mtime: 0 };
|
|
429
|
+
}
|
|
430
|
+
}));
|
|
431
|
+
stats.sort((a, b) => b.mtime - a.mtime);
|
|
432
|
+
const LIMIT = 100;
|
|
433
|
+
const kept = stats.slice(0, LIMIT).map((x) => x.rel);
|
|
434
|
+
const truncated = stats.length > LIMIT;
|
|
435
|
+
const lines = [
|
|
436
|
+
...kept,
|
|
437
|
+
...(truncated ? [`(Results truncated to first ${LIMIT} of ${stats.length} matches. Narrow the pattern.)`] : []),
|
|
438
|
+
];
|
|
439
|
+
return lines.join("\n");
|
|
440
|
+
}
|
|
441
|
+
// grep 工具:把结构化参数翻译成 ripgrep flags,再对 stdout 做 head_limit 截断。
|
|
442
|
+
// 这里只暴露最常用的一组开关,复杂检索仍可通过 bash 里手写 rg 完成。
|
|
443
|
+
async function runGrep(args, signal) {
|
|
444
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
445
|
+
if (!pattern)
|
|
446
|
+
return "Error: pattern is required";
|
|
447
|
+
const outputMode = args.output_mode ?? "files_with_matches";
|
|
448
|
+
const rgArgs = [];
|
|
449
|
+
if (outputMode === "files_with_matches")
|
|
450
|
+
rgArgs.push("--files-with-matches");
|
|
451
|
+
else if (outputMode === "count")
|
|
452
|
+
rgArgs.push("--count");
|
|
453
|
+
// content 模式走 rg 默认输出,加 -n 显示行号。
|
|
454
|
+
if (args.case_insensitive === true)
|
|
455
|
+
rgArgs.push("-i");
|
|
456
|
+
if (args.multiline === true)
|
|
457
|
+
rgArgs.push("-U", "--multiline-dotall");
|
|
458
|
+
if (outputMode === "content") {
|
|
459
|
+
rgArgs.push("-n");
|
|
460
|
+
if (typeof args.context === "number" && args.context > 0) {
|
|
461
|
+
rgArgs.push("-C", String(args.context));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (typeof args.glob === "string")
|
|
465
|
+
rgArgs.push("--glob", args.glob);
|
|
466
|
+
if (typeof args.type === "string")
|
|
467
|
+
rgArgs.push("--type", args.type);
|
|
468
|
+
// 排除 VCS / 依赖目录,避免噪音。和 Claude Code GrepTool 保持一致。
|
|
469
|
+
for (const d of [".git", ".svn", ".hg", ".bzr", ".jj", ".sl", "node_modules"]) {
|
|
470
|
+
rgArgs.push("--glob", `!${d}`);
|
|
471
|
+
}
|
|
472
|
+
// `--` 明确 pattern 的边界,防止以 `-` 开头的正则被当作 flag。
|
|
473
|
+
rgArgs.push("--", pattern);
|
|
474
|
+
let searchPath;
|
|
475
|
+
if (typeof args.path === "string") {
|
|
476
|
+
searchPath = safePath(args.path);
|
|
477
|
+
rgArgs.push(searchPath);
|
|
478
|
+
}
|
|
479
|
+
let stdout = "";
|
|
480
|
+
try {
|
|
481
|
+
const result = await execFileAsync("rg", rgArgs, {
|
|
482
|
+
cwd: WORKDIR,
|
|
483
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
484
|
+
signal,
|
|
485
|
+
});
|
|
486
|
+
stdout = result.stdout;
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const friendly = describeRgFailure(error, signal);
|
|
490
|
+
if (friendly)
|
|
491
|
+
return friendly;
|
|
492
|
+
const err = error;
|
|
493
|
+
if (err.code === 1) {
|
|
494
|
+
stdout = err.stdout ?? ""; // 没命中
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
return `Error: ${err.message ?? String(err)}`;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!stdout.trim())
|
|
501
|
+
return "No matches found";
|
|
502
|
+
// head_limit: 默认 250 行,0 表示不限制。
|
|
503
|
+
const headLimit = typeof args.head_limit === "number" ? args.head_limit : 250;
|
|
504
|
+
const lines = stdout.split("\n");
|
|
505
|
+
// rg 输出末尾通常有空行,去掉免得占配额。
|
|
506
|
+
while (lines.length && lines[lines.length - 1] === "")
|
|
507
|
+
lines.pop();
|
|
508
|
+
if (headLimit === 0)
|
|
509
|
+
return stdout.slice(0, 500_000);
|
|
510
|
+
const total = lines.length;
|
|
511
|
+
const kept = lines.slice(0, headLimit);
|
|
512
|
+
if (total <= headLimit)
|
|
513
|
+
return kept.join("\n");
|
|
514
|
+
return `${kept.join("\n")}\n(Output truncated to first ${headLimit} of ${total} lines. Pass head_limit=0 to disable.)`;
|
|
515
|
+
}
|
|
516
|
+
function toOptionalNumber(value) {
|
|
517
|
+
return typeof value === "number" ? value : undefined;
|
|
518
|
+
}
|
|
519
|
+
function toOptionalString(value) {
|
|
520
|
+
return typeof value === "string" ? value : undefined;
|
|
521
|
+
}
|
|
522
|
+
// BASE_TOOLS 是所有 agent 都共享的最小工具集合。
|
|
523
|
+
// MCP 的 resource/prompt 仍通过 `mcp_call` 访问;tool 会在运行时动态展开成独立 function tool。
|
|
524
|
+
export const BASE_TOOLS = [
|
|
525
|
+
{
|
|
526
|
+
type: "function",
|
|
527
|
+
name: "bash",
|
|
528
|
+
description: "Run a shell command.",
|
|
529
|
+
parameters: {
|
|
530
|
+
type: "object",
|
|
531
|
+
properties: {
|
|
532
|
+
command: { type: "string" },
|
|
533
|
+
},
|
|
534
|
+
required: ["command"],
|
|
535
|
+
additionalProperties: false,
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
type: "function",
|
|
540
|
+
name: "read_file",
|
|
541
|
+
description: "Read file contents.",
|
|
542
|
+
parameters: {
|
|
543
|
+
type: "object",
|
|
544
|
+
properties: {
|
|
545
|
+
path: { type: "string" },
|
|
546
|
+
limit: { type: "integer" },
|
|
547
|
+
},
|
|
548
|
+
required: ["path"],
|
|
549
|
+
additionalProperties: false,
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
type: "function",
|
|
554
|
+
name: "write_file",
|
|
555
|
+
description: "Write content to file.",
|
|
556
|
+
parameters: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties: {
|
|
559
|
+
path: { type: "string" },
|
|
560
|
+
content: { type: "string" },
|
|
561
|
+
},
|
|
562
|
+
required: ["path", "content"],
|
|
563
|
+
additionalProperties: false,
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
type: "function",
|
|
568
|
+
name: "edit_file",
|
|
569
|
+
description: "Replace exact text in file.",
|
|
570
|
+
parameters: {
|
|
571
|
+
type: "object",
|
|
572
|
+
properties: {
|
|
573
|
+
path: { type: "string" },
|
|
574
|
+
old_text: { type: "string" },
|
|
575
|
+
new_text: { type: "string" },
|
|
576
|
+
},
|
|
577
|
+
required: ["path", "old_text", "new_text"],
|
|
578
|
+
additionalProperties: false,
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
type: "function",
|
|
583
|
+
name: "glob",
|
|
584
|
+
description: "Find files by glob pattern (powered by ripgrep). Returns up to 100 paths sorted by modification time (newest first). Prefer this over `bash find` or `ls`: respects .gitignore, consistent output, truncation built in.",
|
|
585
|
+
parameters: {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
pattern: {
|
|
589
|
+
type: "string",
|
|
590
|
+
description: "Glob pattern, e.g. '**/*.ts', 'src/**/*.tsx', '*.json'.",
|
|
591
|
+
},
|
|
592
|
+
path: {
|
|
593
|
+
type: "string",
|
|
594
|
+
description: "Directory to search in (relative to workspace). Omit to search the whole workspace.",
|
|
595
|
+
},
|
|
596
|
+
},
|
|
597
|
+
required: ["pattern"],
|
|
598
|
+
additionalProperties: false,
|
|
599
|
+
},
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
type: "function",
|
|
603
|
+
name: "grep",
|
|
604
|
+
description: "Search file contents with ripgrep (full regex, .gitignore-aware). Prefer this over `bash grep`: the output is capped (default 250 lines) so your context cannot be flooded. For open-ended multi-round exploration, dispatch the `task` tool with the explore subagent instead.",
|
|
605
|
+
parameters: {
|
|
606
|
+
type: "object",
|
|
607
|
+
properties: {
|
|
608
|
+
pattern: {
|
|
609
|
+
type: "string",
|
|
610
|
+
description: "Regex pattern (ripgrep syntax). Literal braces need escaping: use '\\{\\}' to match '{}'.",
|
|
611
|
+
},
|
|
612
|
+
path: {
|
|
613
|
+
type: "string",
|
|
614
|
+
description: "File or directory to search in. Omit to search the whole workspace.",
|
|
615
|
+
},
|
|
616
|
+
glob: {
|
|
617
|
+
type: "string",
|
|
618
|
+
description: "Glob filter, e.g. '*.ts', '*.{js,tsx}'.",
|
|
619
|
+
},
|
|
620
|
+
type: {
|
|
621
|
+
type: "string",
|
|
622
|
+
description: "File type shortcut, e.g. 'js', 'py', 'rust'. More efficient than `glob` for standard types.",
|
|
623
|
+
},
|
|
624
|
+
output_mode: {
|
|
625
|
+
type: "string",
|
|
626
|
+
enum: ["content", "files_with_matches", "count"],
|
|
627
|
+
description: "Default 'files_with_matches'. Use 'content' to see matching lines, 'count' for per-file match counts.",
|
|
628
|
+
},
|
|
629
|
+
case_insensitive: {
|
|
630
|
+
type: "boolean",
|
|
631
|
+
description: "Case-insensitive match (rg -i).",
|
|
632
|
+
},
|
|
633
|
+
context: {
|
|
634
|
+
type: "integer",
|
|
635
|
+
description: "Lines of context before and after each match. Only applied when output_mode='content'.",
|
|
636
|
+
},
|
|
637
|
+
head_limit: {
|
|
638
|
+
type: "integer",
|
|
639
|
+
description: "Max output lines. Defaults to 250. Pass 0 to disable the cap (use sparingly).",
|
|
640
|
+
},
|
|
641
|
+
multiline: {
|
|
642
|
+
type: "boolean",
|
|
643
|
+
description: "Enable multiline matching (rg -U --multiline-dotall).",
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
required: ["pattern"],
|
|
647
|
+
additionalProperties: false,
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
{
|
|
651
|
+
type: "function",
|
|
652
|
+
name: "task_create",
|
|
653
|
+
description: "Create a new task. Returns the created task as JSON.",
|
|
654
|
+
parameters: {
|
|
655
|
+
type: "object",
|
|
656
|
+
properties: {
|
|
657
|
+
subject: { type: "string", description: "Short title of the task" },
|
|
658
|
+
description: { type: "string", description: "Detailed description (optional)" },
|
|
659
|
+
owner: { type: "string", description: "Task creator or owner. Defaults to lead." },
|
|
660
|
+
assignee: { type: "string", description: "Optional teammate assigned to this task." },
|
|
661
|
+
},
|
|
662
|
+
required: ["subject"],
|
|
663
|
+
additionalProperties: false,
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
type: "function",
|
|
668
|
+
name: "task_update",
|
|
669
|
+
description: "Update a task's status, assignment, summary, blocked reason, or dependencies. When a task is marked completed, all tasks blocked by it are automatically unblocked.",
|
|
670
|
+
parameters: {
|
|
671
|
+
type: "object",
|
|
672
|
+
properties: {
|
|
673
|
+
task_id: { type: "integer", description: "ID of the task to update" },
|
|
674
|
+
status: { type: "string", enum: ["pending", "assigned", "in_progress", "blocked", "completed", "failed"] },
|
|
675
|
+
blocked_by: {
|
|
676
|
+
type: "array",
|
|
677
|
+
items: { type: "integer" },
|
|
678
|
+
description: "Task IDs that this task depends on (add to existing)",
|
|
679
|
+
},
|
|
680
|
+
blocks: {
|
|
681
|
+
type: "array",
|
|
682
|
+
items: { type: "integer" },
|
|
683
|
+
description: "Task IDs that depend on this task (add to existing)",
|
|
684
|
+
},
|
|
685
|
+
assignee: { type: "string", description: "Teammate assigned to this task." },
|
|
686
|
+
result_summary: { type: "string", description: "Summary of the task result." },
|
|
687
|
+
blocked_reason: { type: "string", description: "Reason the task is blocked." },
|
|
688
|
+
},
|
|
689
|
+
required: ["task_id"],
|
|
690
|
+
additionalProperties: false,
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
{
|
|
694
|
+
type: "function",
|
|
695
|
+
name: "task_assign",
|
|
696
|
+
description: "Assign a task to a teammate, notify them, and wake their runtime.",
|
|
697
|
+
parameters: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
task_id: { type: "integer", description: "ID of the task to assign" },
|
|
701
|
+
assignee: { type: "string", description: "Teammate assigned to this task." },
|
|
702
|
+
},
|
|
703
|
+
required: ["task_id", "assignee"],
|
|
704
|
+
additionalProperties: false,
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
type: "function",
|
|
709
|
+
name: "task_complete",
|
|
710
|
+
description: "Mark a task completed with a result summary and emit a structured completion event.",
|
|
711
|
+
parameters: {
|
|
712
|
+
type: "object",
|
|
713
|
+
properties: {
|
|
714
|
+
task_id: { type: "integer", description: "ID of the task to complete" },
|
|
715
|
+
result_summary: { type: "string", description: "Summary of the completed task result." },
|
|
716
|
+
},
|
|
717
|
+
required: ["task_id", "result_summary"],
|
|
718
|
+
additionalProperties: false,
|
|
719
|
+
},
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
type: "function",
|
|
723
|
+
name: "task_block",
|
|
724
|
+
description: "Mark a task blocked with a reason and emit a structured blocked event.",
|
|
725
|
+
parameters: {
|
|
726
|
+
type: "object",
|
|
727
|
+
properties: {
|
|
728
|
+
task_id: { type: "integer", description: "ID of the blocked task" },
|
|
729
|
+
reason: { type: "string", description: "Reason the task is blocked." },
|
|
730
|
+
},
|
|
731
|
+
required: ["task_id", "reason"],
|
|
732
|
+
additionalProperties: false,
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
type: "function",
|
|
737
|
+
name: "task_fail",
|
|
738
|
+
description: "Mark a task failed and emit a structured failure event.",
|
|
739
|
+
parameters: {
|
|
740
|
+
type: "object",
|
|
741
|
+
properties: {
|
|
742
|
+
task_id: { type: "integer", description: "ID of the failed task" },
|
|
743
|
+
reason: { type: "string", description: "Reason the task failed." },
|
|
744
|
+
},
|
|
745
|
+
required: ["task_id", "reason"],
|
|
746
|
+
additionalProperties: false,
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
type: "function",
|
|
751
|
+
name: "task_list",
|
|
752
|
+
description: "List all tasks with their status and dependencies.",
|
|
753
|
+
parameters: {
|
|
754
|
+
type: "object",
|
|
755
|
+
properties: {},
|
|
756
|
+
additionalProperties: false,
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
type: "function",
|
|
761
|
+
name: "task_get",
|
|
762
|
+
description: "Get full details of a single task by ID.",
|
|
763
|
+
parameters: {
|
|
764
|
+
type: "object",
|
|
765
|
+
properties: {
|
|
766
|
+
task_id: { type: "integer", description: "ID of the task" },
|
|
767
|
+
},
|
|
768
|
+
required: ["task_id"],
|
|
769
|
+
additionalProperties: false,
|
|
770
|
+
},
|
|
771
|
+
},
|
|
772
|
+
{
|
|
773
|
+
type: "function",
|
|
774
|
+
name: "list_mcp_resources",
|
|
775
|
+
description: "List cached MCP resources for one server or for all configured servers.",
|
|
776
|
+
parameters: {
|
|
777
|
+
type: "object",
|
|
778
|
+
properties: {
|
|
779
|
+
server: { type: "string", description: "Optional MCP server name. Omit to list resources across all servers." },
|
|
780
|
+
},
|
|
781
|
+
additionalProperties: false,
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
{
|
|
785
|
+
type: "function",
|
|
786
|
+
name: "read_mcp_resource",
|
|
787
|
+
description: "Read a cached MCP resource by exact server name and resource URI.",
|
|
788
|
+
parameters: {
|
|
789
|
+
type: "object",
|
|
790
|
+
properties: {
|
|
791
|
+
server: { type: "string", description: "Configured MCP server name" },
|
|
792
|
+
uri: { type: "string", description: "Exact resource URI from list_mcp_resources" },
|
|
793
|
+
},
|
|
794
|
+
required: ["server", "uri"],
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
type: "function",
|
|
800
|
+
name: "mcp_call",
|
|
801
|
+
description: "Get a configured MCP prompt by exact server name and prompt name. Do not use this for MCP tools or resources.",
|
|
802
|
+
parameters: {
|
|
803
|
+
type: "object",
|
|
804
|
+
properties: {
|
|
805
|
+
server: { type: "string", description: "Configured MCP server name" },
|
|
806
|
+
kind: { type: "string", enum: ["prompt"] },
|
|
807
|
+
name: { type: "string", description: "Prompt name" },
|
|
808
|
+
arguments: {
|
|
809
|
+
type: "object",
|
|
810
|
+
description: "Arguments for the prompt",
|
|
811
|
+
additionalProperties: true,
|
|
812
|
+
},
|
|
813
|
+
},
|
|
814
|
+
required: ["server", "kind"],
|
|
815
|
+
additionalProperties: false,
|
|
816
|
+
},
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
type: "function",
|
|
820
|
+
name: "load_skill",
|
|
821
|
+
description: "Load specialized knowledge by name. Use this to access domain-specific guidance before tackling unfamiliar topics.",
|
|
822
|
+
parameters: {
|
|
823
|
+
type: "object",
|
|
824
|
+
properties: {
|
|
825
|
+
name: { type: "string", description: "Skill name to load" },
|
|
826
|
+
args: { type: "string", description: "Optional arguments or scope for the skill" },
|
|
827
|
+
},
|
|
828
|
+
required: ["name"],
|
|
829
|
+
additionalProperties: false,
|
|
830
|
+
},
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
type: "function",
|
|
834
|
+
name: "web_fetch",
|
|
835
|
+
description: "Fetch the content of a web page. Automatically upgrades HTTP to HTTPS, strips HTML tags to plain text, and truncates the output if it exceeds limits. Use this to read documentation or reference pages online.",
|
|
836
|
+
parameters: {
|
|
837
|
+
type: "object",
|
|
838
|
+
properties: {
|
|
839
|
+
url: {
|
|
840
|
+
type: "string",
|
|
841
|
+
description: "The absolute URL to fetch, e.g. 'https://example.com/docs'.",
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
required: ["url"],
|
|
845
|
+
additionalProperties: false,
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
];
|
|
849
|
+
// `task` 是主 agent 独有的能力,用于派生一次性子代理,不下放给 teammate。
|
|
850
|
+
export const TASK_TOOL = {
|
|
851
|
+
type: "function",
|
|
852
|
+
name: "task",
|
|
853
|
+
description: `Dispatch a subtask to an independent sub-agent with a clean context. Returns only the sub-agent's final summary.
|
|
854
|
+
Available subagent types:
|
|
855
|
+
${describeSubagentsForHumans()}`,
|
|
856
|
+
parameters: {
|
|
857
|
+
type: "object",
|
|
858
|
+
properties: {
|
|
859
|
+
description: {
|
|
860
|
+
type: "string",
|
|
861
|
+
description: "A clear, self-contained description of the task for the sub-agent to perform.",
|
|
862
|
+
},
|
|
863
|
+
subagent_type: {
|
|
864
|
+
type: "string",
|
|
865
|
+
enum: ["general-purpose", "explore"],
|
|
866
|
+
description: "Optional specialized sub-agent type. Omit to use general-purpose.",
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
required: ["description"],
|
|
870
|
+
additionalProperties: false,
|
|
871
|
+
},
|
|
872
|
+
};
|
|
873
|
+
// 团队协作消息工具。P1 阶段只保留最小字段:to + content。
|
|
874
|
+
// 协议消息(shutdown / approval)将在 P3 用独立工具实现,不再混在 message_send 里。
|
|
875
|
+
export const TEAM_MESSAGE_TOOL = {
|
|
876
|
+
type: "function",
|
|
877
|
+
name: "message_send",
|
|
878
|
+
description: "Send an asynchronous message to lead or another teammate.",
|
|
879
|
+
parameters: {
|
|
880
|
+
type: "object",
|
|
881
|
+
properties: {
|
|
882
|
+
to: { type: "string", description: "Target teammate name or 'lead'" },
|
|
883
|
+
content: { type: "string", description: "Message body" },
|
|
884
|
+
},
|
|
885
|
+
required: ["to", "content"],
|
|
886
|
+
additionalProperties: false,
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
export const TEAMMATE_SPAWN_TOOL = {
|
|
890
|
+
type: "function",
|
|
891
|
+
name: "teammate_spawn",
|
|
892
|
+
description: "Create a persistent teammate with its own inbox and runtime.",
|
|
893
|
+
parameters: {
|
|
894
|
+
type: "object",
|
|
895
|
+
properties: {
|
|
896
|
+
name: { type: "string", description: "Stable teammate name, for example alice" },
|
|
897
|
+
role: { type: "string", description: "Teammate role or specialty" },
|
|
898
|
+
prompt: { type: "string", description: "Initial task to deliver to the new teammate" },
|
|
899
|
+
},
|
|
900
|
+
required: ["name", "role", "prompt"],
|
|
901
|
+
additionalProperties: false,
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
export const TEAMMATE_LIST_TOOL = {
|
|
905
|
+
type: "function",
|
|
906
|
+
name: "teammate_list",
|
|
907
|
+
description: "List current teammates and their statuses.",
|
|
908
|
+
parameters: {
|
|
909
|
+
type: "object",
|
|
910
|
+
properties: {},
|
|
911
|
+
additionalProperties: false,
|
|
912
|
+
},
|
|
913
|
+
};
|
|
914
|
+
export const TEAMMATE_SHUTDOWN_TOOL = {
|
|
915
|
+
type: "function",
|
|
916
|
+
name: "teammate_shutdown",
|
|
917
|
+
description: "Request a graceful shutdown for one teammate or all teammates.",
|
|
918
|
+
parameters: {
|
|
919
|
+
type: "object",
|
|
920
|
+
properties: {
|
|
921
|
+
name: { type: "string", description: "Teammate name. Omit to stop all teammates." },
|
|
922
|
+
},
|
|
923
|
+
additionalProperties: false,
|
|
924
|
+
},
|
|
925
|
+
};
|
|
926
|
+
// 模型在需要用户拍板(方案选型、歧义澄清、确认偏好)时调用此工具。
|
|
927
|
+
// 它不在 BASE_TOOL_HANDLERS 注册:执行需要 UiBridge,由 agent loop 层拦截分发。
|
|
928
|
+
export const ASK_USER_QUESTION_TOOL = {
|
|
929
|
+
type: "function",
|
|
930
|
+
name: "ask_user_question",
|
|
931
|
+
description: "Ask the user one or more multiple-choice questions when you need a decision you cannot make from the request, the code, or sensible defaults. Use only when the answer changes what you do next — not for choices with an obvious default. The user can always pick 'Other' to type a custom answer.",
|
|
932
|
+
parameters: {
|
|
933
|
+
type: "object",
|
|
934
|
+
properties: {
|
|
935
|
+
questions: {
|
|
936
|
+
type: "array",
|
|
937
|
+
description: "1-4 questions to ask the user.",
|
|
938
|
+
items: {
|
|
939
|
+
type: "object",
|
|
940
|
+
properties: {
|
|
941
|
+
header: { type: "string", description: "Very short label for the question (max ~12 chars)." },
|
|
942
|
+
question: { type: "string", description: "The full question text, ending with a question mark." },
|
|
943
|
+
multiSelect: { type: "boolean", description: "Allow selecting multiple options. Defaults to false (single choice)." },
|
|
944
|
+
options: {
|
|
945
|
+
type: "array",
|
|
946
|
+
description: "2-4 distinct choices.",
|
|
947
|
+
items: {
|
|
948
|
+
type: "object",
|
|
949
|
+
properties: {
|
|
950
|
+
label: { type: "string", description: "Concise display text for the choice." },
|
|
951
|
+
description: { type: "string", description: "Optional explanation of what this choice means or implies." },
|
|
952
|
+
},
|
|
953
|
+
required: ["label"],
|
|
954
|
+
additionalProperties: false,
|
|
955
|
+
},
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
required: ["header", "question", "options"],
|
|
959
|
+
additionalProperties: false,
|
|
960
|
+
},
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
required: ["questions"],
|
|
964
|
+
additionalProperties: false,
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
// 主 agent 可以看到全部工具:基础工具 + 子任务 + 团队协作。
|
|
968
|
+
// P1 删除 LEAD_INBOX_TOOL:lead 邮箱由 runOneTurn 自动注入,不再需要 lead 主动「查邮箱」。
|
|
969
|
+
export const TOOLS = [
|
|
970
|
+
...BASE_TOOLS,
|
|
971
|
+
ASK_USER_QUESTION_TOOL,
|
|
972
|
+
TASK_TOOL,
|
|
973
|
+
TEAM_MESSAGE_TOOL,
|
|
974
|
+
TEAMMATE_SPAWN_TOOL,
|
|
975
|
+
TEAMMATE_LIST_TOOL,
|
|
976
|
+
TEAMMATE_SHUTDOWN_TOOL,
|
|
977
|
+
];
|
|
978
|
+
// teammate 只能使用基础工具和消息发送,避免无限扩张权限。
|
|
979
|
+
export const TEAMMATE_TOOLS = [
|
|
980
|
+
...BASE_TOOLS,
|
|
981
|
+
TEAM_MESSAGE_TOOL,
|
|
982
|
+
];
|
|
983
|
+
// Chat Completions API 需要另一种 tool 结构,这里把内部定义转换成兼容格式。
|
|
984
|
+
function toChatTools(tools) {
|
|
985
|
+
return tools.map((tool) => ({
|
|
986
|
+
type: "function",
|
|
987
|
+
function: {
|
|
988
|
+
name: tool.name,
|
|
989
|
+
description: tool.description,
|
|
990
|
+
parameters: tool.parameters,
|
|
991
|
+
},
|
|
992
|
+
}));
|
|
993
|
+
}
|
|
994
|
+
export const CHAT_TOOLS = toChatTools(TOOLS);
|
|
995
|
+
export const TEAMMATE_CHAT_TOOLS = toChatTools(TEAMMATE_TOOLS);
|
|
996
|
+
export const BASE_CHAT_TOOLS = toChatTools(BASE_TOOLS);
|
|
997
|
+
// 这里是“工具名 -> 实际执行函数”的路由表。
|
|
998
|
+
// `mcp_call` 在这一层接入到 MCP runtime,由后者继续完成初始化、校验和分发。
|
|
999
|
+
export const BASE_TOOL_HANDLERS = {
|
|
1000
|
+
bash: ({ command }, control) => runBash(String(command), control?.signal),
|
|
1001
|
+
read_file: ({ path: filePath, limit }) => runRead(String(filePath), toOptionalNumber(limit)),
|
|
1002
|
+
write_file: ({ path: filePath, content }) => runWrite(String(filePath), String(content)),
|
|
1003
|
+
edit_file: ({ path: filePath, old_text, new_text }) => runEdit(String(filePath), String(old_text), String(new_text)),
|
|
1004
|
+
glob: ({ pattern, path: p }, control) => runGlob(String(pattern), toOptionalString(p), control?.signal),
|
|
1005
|
+
grep: (args, control) => runGrep(args, control?.signal),
|
|
1006
|
+
list_mcp_resources: (args) => handleListMcpResources(args),
|
|
1007
|
+
read_mcp_resource: (args) => handleReadMcpResource(args),
|
|
1008
|
+
mcp_call: (args) => handleMcpCall(args),
|
|
1009
|
+
task_create: async ({ subject, description, owner, assignee }) => await taskManager.create(String(subject), toOptionalString(description), toOptionalString(owner) ?? LEAD_NAME, toOptionalString(assignee)),
|
|
1010
|
+
task_update: async ({ task_id, status, blocked_by, blocks, assignee, result_summary, blocked_reason }) => await taskManager.update(Number(task_id), toOptionalString(status), blocked_by, blocks, toOptionalString(assignee), toOptionalString(result_summary), toOptionalString(blocked_reason)),
|
|
1011
|
+
task_assign: async ({ task_id, assignee }) => {
|
|
1012
|
+
const task = await taskManager.getTask(Number(task_id));
|
|
1013
|
+
if (!task) {
|
|
1014
|
+
return `Error: Task ${Number(task_id)} not found.`;
|
|
1015
|
+
}
|
|
1016
|
+
const normalizedAssignee = String(assignee ?? "").trim();
|
|
1017
|
+
if (!normalizedAssignee) {
|
|
1018
|
+
return "Error: assignee is required.";
|
|
1019
|
+
}
|
|
1020
|
+
const updated = await taskManager.update(task.id, "assigned", undefined, undefined, normalizedAssignee);
|
|
1021
|
+
// P1:保留「lead 把任务通知到 assignee」的真实消息,简化为只 from/to/content。
|
|
1022
|
+
// 任务详情拼到 content 里方便 assignee 直接看到,不再依赖 payload 字段。
|
|
1023
|
+
// task_started 回执在此处删除(属于协议事件,P3 用独立 schema 重做)。
|
|
1024
|
+
void messageBus.send({
|
|
1025
|
+
from: LEAD_NAME,
|
|
1026
|
+
to: normalizedAssignee,
|
|
1027
|
+
content: `Assigned task #${task.id}: ${task.subject}\n${task.description}`,
|
|
1028
|
+
});
|
|
1029
|
+
teammateManager.wake(normalizedAssignee);
|
|
1030
|
+
return updated;
|
|
1031
|
+
},
|
|
1032
|
+
task_complete: async ({ task_id, result_summary }) => {
|
|
1033
|
+
const task = await taskManager.getTask(Number(task_id));
|
|
1034
|
+
if (!task) {
|
|
1035
|
+
return `Error: Task ${Number(task_id)} not found.`;
|
|
1036
|
+
}
|
|
1037
|
+
// P1 阶段不再向 lead 发协议消息(task_completed)。task manager 的状态变更
|
|
1038
|
+
// 是 lead 通过 task_list/task_get 自查的依据;P3 协议消息阶段会用独立 schema 重做。
|
|
1039
|
+
const updated = await taskManager.update(task.id, "completed", undefined, undefined, undefined, String(result_summary ?? ""));
|
|
1040
|
+
return updated;
|
|
1041
|
+
},
|
|
1042
|
+
task_block: async ({ task_id, reason }) => {
|
|
1043
|
+
const task = await taskManager.getTask(Number(task_id));
|
|
1044
|
+
if (!task) {
|
|
1045
|
+
return `Error: Task ${Number(task_id)} not found.`;
|
|
1046
|
+
}
|
|
1047
|
+
// P1:同 task_complete,删除 messageBus.send;仅写入 task manager 状态。
|
|
1048
|
+
const text = String(reason ?? "");
|
|
1049
|
+
const updated = await taskManager.update(task.id, "blocked", undefined, undefined, undefined, undefined, text);
|
|
1050
|
+
return updated;
|
|
1051
|
+
},
|
|
1052
|
+
task_fail: async ({ task_id, reason }) => {
|
|
1053
|
+
const task = await taskManager.getTask(Number(task_id));
|
|
1054
|
+
if (!task) {
|
|
1055
|
+
return `Error: Task ${Number(task_id)} not found.`;
|
|
1056
|
+
}
|
|
1057
|
+
// P1:同 task_complete,删除 messageBus.send;仅写入 task manager 状态。
|
|
1058
|
+
const text = String(reason ?? "");
|
|
1059
|
+
const updated = await taskManager.update(task.id, "failed", undefined, undefined, undefined, text);
|
|
1060
|
+
return updated;
|
|
1061
|
+
},
|
|
1062
|
+
task_list: async () => await taskManager.list(),
|
|
1063
|
+
task_get: async ({ task_id }) => await taskManager.get(Number(task_id)),
|
|
1064
|
+
load_skill: ({ name, args }) => skillLoader.renderSkill(String(name), toOptionalString(args)),
|
|
1065
|
+
// P1:teammate_list handler 异步化。formatTeamStatus 在 T6 改成 async。
|
|
1066
|
+
teammate_list: async () => await teammateManager.formatTeamStatus(),
|
|
1067
|
+
web_fetch: ({ url }, control) => runWebFetch(String(url), control?.signal),
|
|
1068
|
+
};
|