@ai-zen/agents-sdk 1.0.0-alpha.2 → 1.0.0-alpha.21
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/CHANGELOG.md +368 -0
- package/README.md +15 -14
- package/README.zh.md +15 -14
- package/dist/agent-plugins/AutoMigratePlugin.d.ts +35 -9
- package/dist/agent-plugins/AutoMigratePlugin.js +23 -10
- package/dist/agent-plugins/ContextGuardPlugin.d.ts +14 -16
- package/dist/agent-plugins/ContextGuardPlugin.js +13 -17
- package/dist/config/ConfigManager.d.ts +57 -8
- package/dist/config/ConfigManager.js +166 -25
- package/dist/config/constants.d.ts +7 -0
- package/dist/config/constants.js +38 -13
- package/dist/config/factory-skills.d.ts +33 -0
- package/dist/config/factory-skills.js +126 -0
- package/dist/config/migrate.d.ts +67 -0
- package/dist/config/migrate.js +127 -0
- package/dist/config/reasoning-presets.d.ts +37 -0
- package/dist/config/reasoning-presets.js +64 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.js +10 -2
- package/dist/runtime/HandoffGuardPlugin.d.ts +35 -0
- package/dist/runtime/HandoffGuardPlugin.js +65 -0
- package/dist/runtime/HandoffService.d.ts +245 -0
- package/dist/runtime/HandoffService.js +472 -0
- package/dist/runtime/SdkAgent.d.ts +7 -1
- package/dist/runtime/SdkAgent.js +3 -0
- package/dist/runtime/createAgent.d.ts +16 -1
- package/dist/runtime/createAgent.js +10 -5
- package/dist/runtime/createModel.d.ts +8 -4
- package/dist/runtime/createModel.js +19 -2
- package/dist/scope/plugins/builtin/BatchEditTool.js +5 -2
- package/dist/scope/plugins/builtin/DownloadFileTool.js +1 -1
- package/dist/scope/plugins/builtin/EditTool.js +6 -1
- package/dist/scope/plugins/builtin/ExecTool.js +2 -2
- package/dist/scope/plugins/builtin/FindTextTool.js +6 -6
- package/dist/scope/plugins/builtin/GenerateImageTool.d.ts +1 -1
- package/dist/scope/plugins/builtin/GenerateImageTool.js +7 -7
- package/dist/scope/plugins/builtin/GlobTool.js +1 -1
- package/dist/scope/plugins/builtin/InspectFileTool.d.ts +2 -2
- package/dist/scope/plugins/builtin/InspectFileTool.js +5 -5
- package/dist/scope/plugins/builtin/LsTool.js +1 -1
- package/dist/scope/plugins/builtin/{ReadFileTool.d.ts → ReadTool.d.ts} +1 -1
- package/dist/scope/plugins/builtin/{ReadFileTool.js → ReadTool.js} +3 -3
- package/dist/scope/plugins/builtin/{ExecAsyncTool.d.ts → SpawnTool.d.ts} +1 -1
- package/dist/scope/plugins/builtin/{ExecAsyncTool.js → SpawnTool.js} +2 -2
- package/dist/scope/plugins/builtin/ViewImageTool.d.ts +6 -4
- package/dist/scope/plugins/builtin/ViewImageTool.js +43 -59
- package/dist/scope/plugins/builtin/{WriteFileTool.d.ts → WriteTool.d.ts} +1 -1
- package/dist/scope/plugins/builtin/{WriteFileTool.js → WriteTool.js} +2 -2
- package/dist/scope/plugins/builtin/index.d.ts +4 -4
- package/dist/scope/plugins/builtin/index.js +7 -7
- package/dist/scope/plugins/builtin/outputGuard.d.ts +2 -2
- package/dist/scope/plugins/builtin/outputGuard.js +1 -1
- package/dist/scope/plugins/mcp/McpScopePlugin.d.ts +1 -1
- package/dist/scope/plugins/mcp/McpScopePlugin.js +4 -3
- package/dist/scope/plugins/mcp/mcpTools.d.ts +9 -3
- package/dist/scope/plugins/mcp/mcpTools.js +196 -18
- package/dist/scope/plugins/skills/discover.js +1 -2
- package/dist/scope/plugins/skills/skillTools.js +0 -2
- package/dist/scope/plugins/subagents/subAgentTools.js +2 -1
- package/dist/shared/content-blocks.d.ts +108 -0
- package/dist/shared/content-blocks.js +253 -0
- package/dist/shared/errors.d.ts +4 -8
- package/dist/shared/errors.js +8 -14
- package/dist/types/index.d.ts +103 -10
- package/package.json +3 -3
- package/dist/runtime/TaskMigrationService.d.ts +0 -128
- package/dist/runtime/TaskMigrationService.js +0 -211
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CallbackTool } from "@ai-zen/agents-core";
|
|
2
|
+
import { fileToContentSection, resolveModelAndEndpoint } from "../../../shared/content-blocks.js";
|
|
2
3
|
import { createDisclosureParam } from "../../disclosure.js";
|
|
4
|
+
import { DEFAULT_MAX_TOOL_OUTPUT, guardOutput, headTailPreview } from "../builtin/outputGuard.js";
|
|
3
5
|
import { getLogger } from "../../../shared/logger.js";
|
|
4
6
|
const EMPTY_HINT = "(当前没有可用的 MCP 服务器,请联系用户添加)";
|
|
5
|
-
const log = getLogger();
|
|
6
7
|
/**
|
|
7
8
|
* 创建 load_mcp 工具。
|
|
8
9
|
* server 枚举由 filteredMcps (完整 McpServerConfig[])自动推导,
|
|
@@ -68,7 +69,7 @@ export function createLoadMcpTool(mcpManager, filteredMcps) {
|
|
|
68
69
|
}
|
|
69
70
|
// 静默模式:只回吐摘要(清单仍留在管理器内,供 call_mcp_tool 使用)
|
|
70
71
|
if (!includeManifest) {
|
|
71
|
-
|
|
72
|
+
getLogger().info(`[load_mcp] ${serverName}: 已连接(未披露清单:工具 ${manifest.tools.length} 个,资源 ${manifest.resources.length} 个)`);
|
|
72
73
|
return JSON.stringify({
|
|
73
74
|
server: serverName,
|
|
74
75
|
connected: true,
|
|
@@ -76,20 +77,177 @@ export function createLoadMcpTool(mcpManager, filteredMcps) {
|
|
|
76
77
|
resources: manifest.resources.length,
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
+
getLogger().info(`[load_mcp] ${serverName}:\n${JSON.stringify(manifest, null, 2)}`);
|
|
80
81
|
return JSON.stringify({ tools: manifest.tools, resources: manifest.resources });
|
|
81
82
|
},
|
|
82
83
|
});
|
|
83
84
|
}
|
|
85
|
+
/** MIME → 文件名后缀(仅用于上传时给文件一个好名字) */
|
|
86
|
+
const EXT_BY_MIME = {
|
|
87
|
+
"image/png": "png",
|
|
88
|
+
"image/jpeg": "jpg",
|
|
89
|
+
"image/gif": "gif",
|
|
90
|
+
"image/webp": "webp",
|
|
91
|
+
"audio/wav": "wav",
|
|
92
|
+
"audio/mpeg": "mp3",
|
|
93
|
+
"application/pdf": "pdf",
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* MCP 媒体(base64 字符串)→ SDK 内容块。
|
|
97
|
+
*
|
|
98
|
+
* 统一交给 `fileToContentSection()` 决定传输方式:端点声明 Files API 能力就上传
|
|
99
|
+
* (`file` 块 + `file_id`,请求体小、不随历史反复膨胀),否则保持 base64 内联。
|
|
100
|
+
* 转换失败**不丢块**——退化为一条文本说明,避免整个工具调用因媒体问题失败。
|
|
101
|
+
*/
|
|
102
|
+
async function mediaSection(data, mime, media) {
|
|
103
|
+
const ext = EXT_BY_MIME[mime] ?? "bin";
|
|
104
|
+
try {
|
|
105
|
+
const { section } = await fileToContentSection({
|
|
106
|
+
source: {
|
|
107
|
+
base64: data,
|
|
108
|
+
mime,
|
|
109
|
+
filename: `mcp-media-${Date.now()}.${ext}`,
|
|
110
|
+
},
|
|
111
|
+
endpoint: media.endpoint,
|
|
112
|
+
model: media.model,
|
|
113
|
+
signal: media.signal,
|
|
114
|
+
});
|
|
115
|
+
return section;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return {
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `[媒体] 无法转换(${mime}):${error?.message ?? error}`,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* `read_mcp_resource` 的 resource contents → SDK 内容块。
|
|
126
|
+
*
|
|
127
|
+
* 注意形状差异:`callTool` 的 content 块带 `type` 判别字段,而 `readResource` 的
|
|
128
|
+
* contents 是 `{ uri, mimeType?, text }` 或 `{ uri, mimeType?, blob }`——**没有 type**。
|
|
129
|
+
* 若直接复用 `toMessageSection`,文本资源会掉进 default 分支被 JSON 化(实测踩过)。
|
|
130
|
+
*/
|
|
131
|
+
async function resourceToMessageSection(item, media) {
|
|
132
|
+
if (typeof item?.text === "string")
|
|
133
|
+
return { type: "text", text: item.text };
|
|
134
|
+
if (typeof item?.blob === "string") {
|
|
135
|
+
return mediaSection(item.blob, item.mimeType ?? "application/octet-stream", media);
|
|
136
|
+
}
|
|
137
|
+
return { type: "text", text: JSON.stringify(item) };
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* MCP content 块 → SDK 内容块。
|
|
141
|
+
*
|
|
142
|
+
* 文本类(text / resource.text / resource_link / 未知类型)归一为 `text` 块,
|
|
143
|
+
* 由调用方拼接成正文后进入输出保护;
|
|
144
|
+
* 媒体类(image / audio / resource.blob)经 `mediaSection()` → `fileToContentSection()`:
|
|
145
|
+
* 端点支持 Files API 时上传为 `file` 块,否则 base64 内联——图片不进字符闸门,也不丢弃。
|
|
146
|
+
*/
|
|
147
|
+
async function toMessageSection(block, media) {
|
|
148
|
+
switch (block?.type) {
|
|
149
|
+
case "image":
|
|
150
|
+
return await mediaSection(block.data, block.mimeType ?? "image/png", media);
|
|
151
|
+
case "audio":
|
|
152
|
+
// AgentNS 无独立音频块,以 `file` 块承载(支持上传的端点 → file_id,否则 file_data 内联)
|
|
153
|
+
return await mediaSection(block.data, block.mimeType ?? "audio/wav", media);
|
|
154
|
+
case "resource": {
|
|
155
|
+
const r = block.resource ?? {};
|
|
156
|
+
// 内嵌文本资源:本质是纯文本,并入正文(同样受输出保护)
|
|
157
|
+
if (typeof r.text === "string")
|
|
158
|
+
return { type: "text", text: r.text };
|
|
159
|
+
// 内嵌二进制资源:按 mimeType 选块类型,不丢弃
|
|
160
|
+
if (typeof r.blob === "string") {
|
|
161
|
+
const mime = r.mimeType ?? "application/octet-stream";
|
|
162
|
+
return await mediaSection(r.blob, mime, media);
|
|
163
|
+
}
|
|
164
|
+
return { type: "text", text: `[资源] ${r.uri ?? "(未知 URI)"}` };
|
|
165
|
+
}
|
|
166
|
+
case "resource_link": {
|
|
167
|
+
const parts = [block.name, block.uri, block.mimeType].filter(Boolean);
|
|
168
|
+
return { type: "text", text: `[资源链接] ${parts.join(" · ")}` };
|
|
169
|
+
}
|
|
170
|
+
case "text":
|
|
171
|
+
return { type: "text", text: block.text ?? "" };
|
|
172
|
+
default:
|
|
173
|
+
// 未知类型(如 tool_use)退化为 JSON 文本,仍受输出保护
|
|
174
|
+
return { type: "text", text: JSON.stringify(block) };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 文本输出保护(保序):
|
|
179
|
+
*
|
|
180
|
+
* - **未超限**:原样返回映射后的块数组,不重排、不合并、不丢块;
|
|
181
|
+
* 纯文本时退化为单个字符串(历史行为)。
|
|
182
|
+
* - **超限**:全部文本落盘为 result.txt,只保留**一个**警告文本块占据
|
|
183
|
+
* 原首个文本块的位置,其余文本块丢弃(内容已落盘),媒体块原位不动。
|
|
184
|
+
* 这样即使文本夹在中间,媒体与位置的对应关系也不会被打乱。
|
|
185
|
+
*
|
|
186
|
+
* 判定用全部文本的字符总数——不能逐块判,否则 N 段各 3 万字符都不超限、合起来却能灌爆。
|
|
187
|
+
* 落盘只写文本正文,不写原始结果——后者可能含 image 的 base64,
|
|
188
|
+
* 落盘等于把几 MB 二进制再抄一遍磁盘。
|
|
189
|
+
*/
|
|
190
|
+
async function protectCallOutput(output, env) {
|
|
191
|
+
const limit = env.config.maxToolOutput ?? DEFAULT_MAX_TOOL_OUTPUT;
|
|
192
|
+
const { sections, fullText, chars, isError } = output;
|
|
193
|
+
const guarded = await guardOutput({
|
|
194
|
+
tool: "call_mcp_tool",
|
|
195
|
+
content: output,
|
|
196
|
+
isOverLimit: (o) => o.chars > limit,
|
|
197
|
+
dump: (_dir, o) => [{ name: "result.txt", content: o.fullText }],
|
|
198
|
+
buildWarning: (ctx) => {
|
|
199
|
+
const preview = headTailPreview(fullText);
|
|
200
|
+
return JSON.stringify({
|
|
201
|
+
outputTooLarge: true,
|
|
202
|
+
isError: isError || undefined,
|
|
203
|
+
warning: `文本输出过大:共 ${chars} 字符,超过上限 ${limit} 字符。` +
|
|
204
|
+
`完整文本已落盘(result.txt),可用 read 分批读取。`,
|
|
205
|
+
files: ctx.files,
|
|
206
|
+
chars,
|
|
207
|
+
...preview,
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
// 超限:警告占据首个文本块的位置,其余文本块合并到它身上(内容已落盘),媒体原位
|
|
212
|
+
if (typeof guarded === "string") {
|
|
213
|
+
const collapsed = [];
|
|
214
|
+
let placed = false;
|
|
215
|
+
for (const section of sections) {
|
|
216
|
+
if (section.type === "text") {
|
|
217
|
+
if (!placed) {
|
|
218
|
+
collapsed.push({ type: "text", text: guarded });
|
|
219
|
+
placed = true;
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
collapsed.push(section);
|
|
224
|
+
}
|
|
225
|
+
// 仅一个文本块(无媒体):退化为字符串,与历史行为一致
|
|
226
|
+
return collapsed.length === 1 ? collapsed[0].text : collapsed;
|
|
227
|
+
}
|
|
228
|
+
// 未超限:无媒体 → 纯字符串;有媒体 → 原序块数组
|
|
229
|
+
const hasMedia = sections.some((s) => s.type !== "text");
|
|
230
|
+
if (!hasMedia)
|
|
231
|
+
return isError ? `❌ 工具执行出错:\n${fullText}` : fullText;
|
|
232
|
+
return isError
|
|
233
|
+
? [{ type: "text", text: "❌ 工具执行出错:" }, ...sections]
|
|
234
|
+
: sections;
|
|
235
|
+
}
|
|
84
236
|
/**
|
|
85
237
|
* 创建 call_mcp_tool 工具。
|
|
86
238
|
* 通过官方 Client.callTool() API 调用 MCP 服务器上的工具。
|
|
239
|
+
*
|
|
240
|
+
* 返回形态:
|
|
241
|
+
* - 仅文本 → 纯字符串(与历史行为一致);
|
|
242
|
+
* - 含媒体 → 内容块数组:首块为受保护的文本,其后原样透传 image / file 块。
|
|
243
|
+
*
|
|
244
|
+
* env 仅用于读取 maxToolOutput(文本输出保护上限),与内置工具口径一致。
|
|
87
245
|
*/
|
|
88
|
-
export function createCallMcpTool(mcpManager) {
|
|
246
|
+
export function createCallMcpTool(mcpManager, env) {
|
|
89
247
|
return new CallbackTool({
|
|
90
248
|
function: {
|
|
91
249
|
name: "call_mcp_tool",
|
|
92
|
-
description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp
|
|
250
|
+
description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。文本输出过大时会落盘并只返回头尾预览;图片、音频等媒体会保留:端点支持 Files API 时上传后以 file_id 引用,否则 base64 内联。",
|
|
93
251
|
parameters: {
|
|
94
252
|
type: "object",
|
|
95
253
|
properties: {
|
|
@@ -117,15 +275,23 @@ export function createCallMcpTool(mcpManager) {
|
|
|
117
275
|
name: input.tool,
|
|
118
276
|
arguments: input.arguments,
|
|
119
277
|
}, undefined, { signal: ctx?.signal });
|
|
278
|
+
// 按 MCP content 原始顺序映射,文本与媒体的相对位置完整保留
|
|
120
279
|
const contents = result.content ?? [];
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return `❌ 工具执行出错:\n${text || JSON.stringify(result)}`;
|
|
280
|
+
const isError = Boolean(result.isError);
|
|
281
|
+
// 没有 content 时退回原始 JSON(既有兜底)
|
|
282
|
+
if (contents.length === 0) {
|
|
283
|
+
const json = JSON.stringify(result);
|
|
284
|
+
return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError }, env);
|
|
127
285
|
}
|
|
128
|
-
|
|
286
|
+
// 按当前模型定位端点:决定媒体走 Files API 上传还是 base64 内联
|
|
287
|
+
const { model, endpoint } = resolveModelAndEndpoint(env.config, ctx?.agent?.model);
|
|
288
|
+
const mediaContext = { endpoint, model, signal: ctx?.signal };
|
|
289
|
+
// Promise.all 保序:文本与媒体的相对位置不变
|
|
290
|
+
const sections = await Promise.all(contents.map((block) => toMessageSection(block, mediaContext)));
|
|
291
|
+
const textSections = sections.filter((s) => s.type === "text");
|
|
292
|
+
const fullText = textSections.map((s) => s.text).join("\n");
|
|
293
|
+
const chars = textSections.reduce((n, s) => n + s.text.length, 0);
|
|
294
|
+
return await protectCallOutput({ sections, fullText, chars, isError }, env);
|
|
129
295
|
}
|
|
130
296
|
catch (error) {
|
|
131
297
|
return `❌ 调用 "${input.tool}" 失败: ${error?.message ?? error}`;
|
|
@@ -136,11 +302,11 @@ export function createCallMcpTool(mcpManager) {
|
|
|
136
302
|
/**
|
|
137
303
|
* 创建 read_mcp_resource 工具。
|
|
138
304
|
*/
|
|
139
|
-
export function createReadMcpResourceTool(mcpManager) {
|
|
305
|
+
export function createReadMcpResourceTool(mcpManager, env) {
|
|
140
306
|
return new CallbackTool({
|
|
141
307
|
function: {
|
|
142
308
|
name: "read_mcp_resource",
|
|
143
|
-
description: "读取已连接 MCP
|
|
309
|
+
description: "读取已连接 MCP 服务器上的指定资源(文档、数据、图片等)。文本原样返回;图片等媒体按端点能力上传(file_id)或 base64 内联,不会被丢弃。",
|
|
144
310
|
parameters: {
|
|
145
311
|
type: "object",
|
|
146
312
|
properties: {
|
|
@@ -165,10 +331,22 @@ export function createReadMcpResourceTool(mcpManager) {
|
|
|
165
331
|
mcpManager.touch(serverName);
|
|
166
332
|
const result = await client.readResource({ uri: input.uri }, { signal: ctx?.signal });
|
|
167
333
|
const contents = result?.contents ?? [];
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
334
|
+
const { model, endpoint } = resolveModelAndEndpoint(env.config, ctx?.agent?.model);
|
|
335
|
+
const mediaContext = { endpoint, model, signal: ctx?.signal };
|
|
336
|
+
// 与 call_mcp_tool 同一套映射:文本归一为 text 块,媒体按端点能力上传或内联,
|
|
337
|
+
// 不再把二进制资源(图片 / PDF)静默丢掉。
|
|
338
|
+
const sections = await Promise.all(contents.map((content) => resourceToMessageSection(content, mediaContext)));
|
|
339
|
+
// 没有 contents 时退回原始 JSON(与 call_mcp_tool 同一兜底)
|
|
340
|
+
if (sections.length === 0) {
|
|
341
|
+
const json = JSON.stringify(result);
|
|
342
|
+
return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError: false }, env);
|
|
343
|
+
}
|
|
344
|
+
// 与 call_mcp_tool 共用同一套输出保护:文本超限落盘 result.txt,
|
|
345
|
+
// 媒体块原位保留;未超限时纯文本退化为字符串、含媒体返回块数组。
|
|
346
|
+
const textSections = sections.filter((section) => section.type === "text");
|
|
347
|
+
const fullText = textSections.map((section) => section.text).join("\n");
|
|
348
|
+
const chars = textSections.reduce((n, section) => n + section.text.length, 0);
|
|
349
|
+
return await protectCallOutput({ sections, fullText, chars, isError: false }, env);
|
|
172
350
|
}
|
|
173
351
|
catch (error) {
|
|
174
352
|
return `❌ 读取资源 "${input.uri}" 失败: ${error?.message ?? error}`;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { promises as fs } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getLogger } from "../../../shared/logger.js";
|
|
4
|
-
const log = getLogger();
|
|
5
4
|
// ---- 发现(返回完整 SkillInfo,不再丢失 subAgent 等信息)----
|
|
6
5
|
/**
|
|
7
6
|
* 扫描多个目录中发现的所有 Skill(含 SKILL.md 的子目录)。
|
|
@@ -78,7 +77,7 @@ async function readSkillFromPath(skillId, skillMdPath, silent) {
|
|
|
78
77
|
if (!silent) {
|
|
79
78
|
const errors = validateSkill(skillId, fm);
|
|
80
79
|
for (const err of errors) {
|
|
81
|
-
|
|
80
|
+
getLogger().warn(`[skills] ${err.skillId}: ${err.field} — ${err.message}`);
|
|
82
81
|
}
|
|
83
82
|
}
|
|
84
83
|
return {
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
import { CallbackTool, Agent, AgentToolLazy, Message } from "@ai-zen/agents-core";
|
|
2
2
|
import { createDisclosureParam } from "../../disclosure.js";
|
|
3
3
|
import { readSkill } from "./discover.js";
|
|
4
|
-
import { getLogger } from "../../../shared/logger.js";
|
|
5
4
|
import { promises as fs } from "node:fs";
|
|
6
|
-
const log = getLogger();
|
|
7
5
|
const EMPTY_HINT = "(当前没有可用的 Skill,请联系用户添加)";
|
|
8
6
|
/**
|
|
9
7
|
* 创建 load_skill 工具。
|
|
@@ -24,7 +24,8 @@ export function createSubAgentTool(def, scope) {
|
|
|
24
24
|
let model;
|
|
25
25
|
let modelConfig;
|
|
26
26
|
if (def.modelId) {
|
|
27
|
-
|
|
27
|
+
// SubAgent 可声明自己的模型与参数覆盖(与主 Agent 同一套规则:直接合并)
|
|
28
|
+
const m = createModel(scope, def.modelId, def.modelParams);
|
|
28
29
|
client = m.client;
|
|
29
30
|
model = m.model;
|
|
30
31
|
modelConfig = m.modelConfig;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地文件 → 消息内容块的唯一转换入口。
|
|
3
|
+
*
|
|
4
|
+
* 背景:这段逻辑原先散落三处,且各自为政 ——
|
|
5
|
+
* 1. `ViewImageTool` 自己判断 `endpoint.id === "deepseek"` 再上传(端点 id 一变就失效);
|
|
6
|
+
* 2. `mcp/mcpTools.ts` 自己把 MCP 的 base64 归一成 `image_url` / `file` 块;
|
|
7
|
+
* 3. CLI 侧又实现了一遍「上传 or base64」。
|
|
8
|
+
*
|
|
9
|
+
* 现在统一到这里,调用方只提供「文件 + 当前端点/模型」,传输方式由本模块决定:
|
|
10
|
+
*
|
|
11
|
+
* | 条件 | 结果 |
|
|
12
|
+
* |------|------|
|
|
13
|
+
* | http(s) URL | 直接 `image_url`(不上传、不内联) |
|
|
14
|
+
* | 端点声明 `capabilities.filesApi` | 上传 Files API → `file` 块(`file_id`),失败降级为内联 |
|
|
15
|
+
* | 其余端点 + 图片 | `image_url` + base64 data URL |
|
|
16
|
+
* | 其余端点 + 非图片 | `file` 块 + `file_data`(base64 data URL) |
|
|
17
|
+
* | 模型明确不支持视觉(`vision === false`)且是图片 | 退化为 `text`,只给路径提示 |
|
|
18
|
+
* | 内联体积超过上限且无法上传 | 抛错(不静默塞进请求体) |
|
|
19
|
+
*/
|
|
20
|
+
import type { AgentNS } from "@ai-zen/agents-core";
|
|
21
|
+
import type { AppConfig, Endpoint, Model } from "../types/index.js";
|
|
22
|
+
/** 扩展名 → MIME。图片走视觉通道,其余走 `file` 块 */
|
|
23
|
+
export declare const MIME_BY_EXT: Record<string, string>;
|
|
24
|
+
/** 按扩展名推断 MIME;未知扩展名返回 undefined */
|
|
25
|
+
export declare function mimeOf(filePath: string): string | undefined;
|
|
26
|
+
/** base64 二进制转 data URL(`image_url` 与 `file.file_data` 均接受) */
|
|
27
|
+
export declare function toDataUrl(base64: string, mime: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* base64 字符串对应的字节数(不含空白)。
|
|
30
|
+
*
|
|
31
|
+
* 用于「只看体积、不想解码」的场景:内联上限检查与日志统计。
|
|
32
|
+
*/
|
|
33
|
+
export declare function base64ByteLength(base64: string): number;
|
|
34
|
+
/** 内联(base64)体积上限,默认 8 MiB;超过且端点不支持上传时直接报错 */
|
|
35
|
+
export declare const DEFAULT_MAX_INLINE_BYTES: number;
|
|
36
|
+
/** 实际采用的传输方式(供调用方组织提示文案 / 日志) */
|
|
37
|
+
export type ContentTransport = "url" | "files-api" | "inline" | "text";
|
|
38
|
+
/** 文件来源:本地路径 / 网络 URL / 已在内存里的字节 */
|
|
39
|
+
export type FileSource = {
|
|
40
|
+
path: string;
|
|
41
|
+
} | {
|
|
42
|
+
url: string;
|
|
43
|
+
} | {
|
|
44
|
+
data: Uint8Array;
|
|
45
|
+
mime: string;
|
|
46
|
+
filename?: string;
|
|
47
|
+
}
|
|
48
|
+
/** 已经是 base64 的字节(MCP 等协议给的就是 base64):内联时原样透传,不做解码再编码 */
|
|
49
|
+
| {
|
|
50
|
+
base64: string;
|
|
51
|
+
mime: string;
|
|
52
|
+
filename?: string;
|
|
53
|
+
};
|
|
54
|
+
/** Files API 上传函数签名(可注入,便于测试与替换实现) */
|
|
55
|
+
export type UploadFn = (input: {
|
|
56
|
+
endpoint: Endpoint;
|
|
57
|
+
data: Uint8Array;
|
|
58
|
+
filename: string;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}) => Promise<string>;
|
|
61
|
+
export interface FileToSectionInput {
|
|
62
|
+
source: FileSource;
|
|
63
|
+
/** 当前模型所用端点:决定能否走 Files API */
|
|
64
|
+
endpoint?: Endpoint | null;
|
|
65
|
+
/** 当前模型:`vision === false` 时图片退化为文本提示 */
|
|
66
|
+
model?: Model | null;
|
|
67
|
+
signal?: AbortSignal;
|
|
68
|
+
/** 覆盖内联体积上限 */
|
|
69
|
+
maxInlineBytes?: number;
|
|
70
|
+
/** 依赖注入(测试用),默认走 OpenAI SDK 的 Files API */
|
|
71
|
+
deps?: {
|
|
72
|
+
upload?: UploadFn;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export interface FileToSectionResult {
|
|
76
|
+
section: AgentNS.MessageContentSection;
|
|
77
|
+
transport: ContentTransport;
|
|
78
|
+
/** 实际处理的字节数(URL 来源为 0) */
|
|
79
|
+
bytes: number;
|
|
80
|
+
/** 降级 / 退化时的人类可读说明,供上层拼提示 */
|
|
81
|
+
warning?: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 由「发给 API 的模型名」反查它绑定的模型定义与端点。
|
|
85
|
+
*
|
|
86
|
+
* 工具在 `call()` 阶段拿不到 Agent 定义,但 `ctx.agent.model` 就是发给 API 的
|
|
87
|
+
* 模型名(`Model.modelName ?? Model.id`)。必须按**当前实际使用的模型**定位端点:
|
|
88
|
+
* `file_id` 是端点维度资源,传错端点会得到 not found。
|
|
89
|
+
*/
|
|
90
|
+
export declare function resolveModelAndEndpoint(config: AppConfig, apiModelName?: string): {
|
|
91
|
+
model?: Model;
|
|
92
|
+
endpoint?: Endpoint | null;
|
|
93
|
+
};
|
|
94
|
+
/** 端点是否声明了 Files API 能力 */
|
|
95
|
+
export declare function supportsFilesApi(endpoint?: Endpoint | null): boolean;
|
|
96
|
+
/** 清空上传缓存(测试用;进程内长期运行无需清理,条目有上限) */
|
|
97
|
+
export declare function clearUploadCache(): void;
|
|
98
|
+
/** 内容哈希(sha256),用于给「内存里的字节」做上传缓存键 */
|
|
99
|
+
export declare function contentHash(bytes: Uint8Array): string;
|
|
100
|
+
/**
|
|
101
|
+
* 把文件转成内容块。
|
|
102
|
+
*
|
|
103
|
+
* 抛错场景(均由调用方决定如何呈现给模型):
|
|
104
|
+
* - `source.url` 不是 http(s)(本地 URL 服务商访问不到)
|
|
105
|
+
* - 本地文件不存在 / 不是文件 / 扩展名不支持
|
|
106
|
+
* - 需要内联但体积超过上限,且端点不支持上传
|
|
107
|
+
*/
|
|
108
|
+
export declare function fileToContentSection(input: FileToSectionInput): Promise<FileToSectionResult>;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地文件 → 消息内容块的唯一转换入口。
|
|
3
|
+
*
|
|
4
|
+
* 背景:这段逻辑原先散落三处,且各自为政 ——
|
|
5
|
+
* 1. `ViewImageTool` 自己判断 `endpoint.id === "deepseek"` 再上传(端点 id 一变就失效);
|
|
6
|
+
* 2. `mcp/mcpTools.ts` 自己把 MCP 的 base64 归一成 `image_url` / `file` 块;
|
|
7
|
+
* 3. CLI 侧又实现了一遍「上传 or base64」。
|
|
8
|
+
*
|
|
9
|
+
* 现在统一到这里,调用方只提供「文件 + 当前端点/模型」,传输方式由本模块决定:
|
|
10
|
+
*
|
|
11
|
+
* | 条件 | 结果 |
|
|
12
|
+
* |------|------|
|
|
13
|
+
* | http(s) URL | 直接 `image_url`(不上传、不内联) |
|
|
14
|
+
* | 端点声明 `capabilities.filesApi` | 上传 Files API → `file` 块(`file_id`),失败降级为内联 |
|
|
15
|
+
* | 其余端点 + 图片 | `image_url` + base64 data URL |
|
|
16
|
+
* | 其余端点 + 非图片 | `file` 块 + `file_data`(base64 data URL) |
|
|
17
|
+
* | 模型明确不支持视觉(`vision === false`)且是图片 | 退化为 `text`,只给路径提示 |
|
|
18
|
+
* | 内联体积超过上限且无法上传 | 抛错(不静默塞进请求体) |
|
|
19
|
+
*/
|
|
20
|
+
import OpenAI, { toFile } from "openai";
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
/** 扩展名 → MIME。图片走视觉通道,其余走 `file` 块 */
|
|
25
|
+
export const MIME_BY_EXT = {
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".jpeg": "image/jpeg",
|
|
29
|
+
".gif": "image/gif",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".pdf": "application/pdf",
|
|
32
|
+
".txt": "text/plain",
|
|
33
|
+
".md": "text/markdown",
|
|
34
|
+
".json": "application/json",
|
|
35
|
+
};
|
|
36
|
+
/** 按扩展名推断 MIME;未知扩展名返回 undefined */
|
|
37
|
+
export function mimeOf(filePath) {
|
|
38
|
+
const dot = filePath.lastIndexOf(".");
|
|
39
|
+
if (dot < 0)
|
|
40
|
+
return undefined;
|
|
41
|
+
return MIME_BY_EXT[filePath.slice(dot).toLowerCase()];
|
|
42
|
+
}
|
|
43
|
+
/** base64 二进制转 data URL(`image_url` 与 `file.file_data` 均接受) */
|
|
44
|
+
export function toDataUrl(base64, mime) {
|
|
45
|
+
return `data:${mime};base64,${base64}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* base64 字符串对应的字节数(不含空白)。
|
|
49
|
+
*
|
|
50
|
+
* 用于「只看体积、不想解码」的场景:内联上限检查与日志统计。
|
|
51
|
+
*/
|
|
52
|
+
export function base64ByteLength(base64) {
|
|
53
|
+
const len = base64.replace(/\s/g, "").length;
|
|
54
|
+
if (len === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
const pad = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
|
57
|
+
return Math.max(0, Math.floor((len * 3) / 4) - pad);
|
|
58
|
+
}
|
|
59
|
+
/** 内联(base64)体积上限,默认 8 MiB;超过且端点不支持上传时直接报错 */
|
|
60
|
+
export const DEFAULT_MAX_INLINE_BYTES = 8 * 1024 * 1024;
|
|
61
|
+
/**
|
|
62
|
+
* 由「发给 API 的模型名」反查它绑定的模型定义与端点。
|
|
63
|
+
*
|
|
64
|
+
* 工具在 `call()` 阶段拿不到 Agent 定义,但 `ctx.agent.model` 就是发给 API 的
|
|
65
|
+
* 模型名(`Model.modelName ?? Model.id`)。必须按**当前实际使用的模型**定位端点:
|
|
66
|
+
* `file_id` 是端点维度资源,传错端点会得到 not found。
|
|
67
|
+
*/
|
|
68
|
+
export function resolveModelAndEndpoint(config, apiModelName) {
|
|
69
|
+
const models = config.models ?? [];
|
|
70
|
+
const endpoints = config.endpoints ?? [];
|
|
71
|
+
const findEndpoint = (id) => (id ? endpoints.find((e) => e.id === id) ?? null : null);
|
|
72
|
+
const byApiName = apiModelName
|
|
73
|
+
? models.find((m) => (m.modelName ?? m.id) === apiModelName)
|
|
74
|
+
: undefined;
|
|
75
|
+
if (byApiName)
|
|
76
|
+
return { model: byApiName, endpoint: findEndpoint(byApiName.endpointId) };
|
|
77
|
+
return { endpoint: null };
|
|
78
|
+
}
|
|
79
|
+
/** 端点是否声明了 Files API 能力 */
|
|
80
|
+
export function supportsFilesApi(endpoint) {
|
|
81
|
+
return endpoint?.capabilities?.filesApi === true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* 上传缓存:`端点id + 绝对路径 + mtime + size` → `file_id`。
|
|
85
|
+
*
|
|
86
|
+
* file_id 是**端点维度**的资源,所以 key 必须带端点 id —— 同一张图传到两个端点
|
|
87
|
+
* 会得到两个不同的 id,混用会让另一端返回 not found。
|
|
88
|
+
*/
|
|
89
|
+
const uploadCache = new Map();
|
|
90
|
+
/** 清空上传缓存(测试用;进程内长期运行无需清理,条目有上限) */
|
|
91
|
+
export function clearUploadCache() {
|
|
92
|
+
uploadCache.clear();
|
|
93
|
+
}
|
|
94
|
+
const UPLOAD_CACHE_LIMIT = 200;
|
|
95
|
+
/** 内容哈希(sha256),用于给「内存里的字节」做上传缓存键 */
|
|
96
|
+
export function contentHash(bytes) {
|
|
97
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
98
|
+
}
|
|
99
|
+
function cacheKey(endpoint, absPath, stat) {
|
|
100
|
+
return `${endpoint.id}\u0000${absPath}\u0000${stat.mtimeMs}\u0000${stat.size}`;
|
|
101
|
+
}
|
|
102
|
+
/** 内存来源的缓存键:没有路径与 mtime,改用内容哈希(同一张图不会重复上传) */
|
|
103
|
+
function contentCacheKey(endpoint, hash) {
|
|
104
|
+
return `${endpoint.id}\u0000sha256:${hash}`;
|
|
105
|
+
}
|
|
106
|
+
function rememberUpload(key, fileId) {
|
|
107
|
+
uploadCache.set(key, fileId);
|
|
108
|
+
while (uploadCache.size > UPLOAD_CACHE_LIMIT) {
|
|
109
|
+
const oldest = uploadCache.keys().next();
|
|
110
|
+
if (oldest.done)
|
|
111
|
+
break;
|
|
112
|
+
uploadCache.delete(oldest.value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** 默认上传实现:OpenAI 兼容的 `POST {baseUrl}/files`(`purpose=user_data`) */
|
|
116
|
+
const defaultUpload = async ({ endpoint, data, filename, signal }) => {
|
|
117
|
+
const client = new OpenAI({ apiKey: endpoint.apiKey, baseURL: endpoint.baseUrl });
|
|
118
|
+
const file = await toFile(Buffer.from(data), filename);
|
|
119
|
+
const uploaded = await client.files.create({ file, purpose: "user_data" }, { signal });
|
|
120
|
+
return uploaded.id;
|
|
121
|
+
};
|
|
122
|
+
function isRemoteUrl(value) {
|
|
123
|
+
return /^https?:\/\//i.test(value);
|
|
124
|
+
}
|
|
125
|
+
function textSection(text) {
|
|
126
|
+
return { type: "text", text };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* 把文件转成内容块。
|
|
130
|
+
*
|
|
131
|
+
* 抛错场景(均由调用方决定如何呈现给模型):
|
|
132
|
+
* - `source.url` 不是 http(s)(本地 URL 服务商访问不到)
|
|
133
|
+
* - 本地文件不存在 / 不是文件 / 扩展名不支持
|
|
134
|
+
* - 需要内联但体积超过上限,且端点不支持上传
|
|
135
|
+
*/
|
|
136
|
+
export async function fileToContentSection(input) {
|
|
137
|
+
const { source, endpoint, model, signal } = input;
|
|
138
|
+
const maxInline = input.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
|
|
139
|
+
// ---- 1. 网络 URL:直接引用,不经上传/内联 ----
|
|
140
|
+
if ("url" in source) {
|
|
141
|
+
if (!isRemoteUrl(source.url)) {
|
|
142
|
+
throw new Error(`不支持本地 URL(${source.url}):API 服务商无法访问。本地文件请传文件路径,而不是本地链接。`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
section: { type: "image_url", image_url: { url: source.url } },
|
|
146
|
+
transport: "url",
|
|
147
|
+
bytes: 0,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// ---- 2. 取得字节与 MIME ----
|
|
151
|
+
// 已经是 base64 的来源(MCP):先不解码,内联时原样透传,只有真要上传/算哈希时才解码
|
|
152
|
+
let base64 = null;
|
|
153
|
+
let bytes = null;
|
|
154
|
+
let byteLength;
|
|
155
|
+
let mime;
|
|
156
|
+
let filename;
|
|
157
|
+
let absPath = null;
|
|
158
|
+
let stat = null;
|
|
159
|
+
if ("path" in source) {
|
|
160
|
+
absPath = path.isAbsolute(source.path) ? source.path : path.resolve(source.path);
|
|
161
|
+
const st = await fs.stat(absPath);
|
|
162
|
+
if (!st.isFile())
|
|
163
|
+
throw new Error(`"${absPath}" 不是文件。`);
|
|
164
|
+
const inferred = mimeOf(absPath);
|
|
165
|
+
if (!inferred) {
|
|
166
|
+
throw new Error(`不支持的文件类型:"${absPath}"(已知类型:${Object.keys(MIME_BY_EXT).join(" ")})`);
|
|
167
|
+
}
|
|
168
|
+
mime = inferred;
|
|
169
|
+
stat = { mtimeMs: st.mtimeMs, size: st.size };
|
|
170
|
+
bytes = new Uint8Array(await fs.readFile(absPath));
|
|
171
|
+
byteLength = bytes.byteLength;
|
|
172
|
+
filename = path.basename(absPath);
|
|
173
|
+
}
|
|
174
|
+
else if ("base64" in source) {
|
|
175
|
+
mime = source.mime;
|
|
176
|
+
base64 = source.base64;
|
|
177
|
+
byteLength = base64ByteLength(base64);
|
|
178
|
+
filename = source.filename ?? "file";
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
mime = source.mime;
|
|
182
|
+
bytes = source.data;
|
|
183
|
+
byteLength = bytes.byteLength;
|
|
184
|
+
filename = source.filename ?? "file";
|
|
185
|
+
}
|
|
186
|
+
/** 需要真实字节时才解码(上传、算内容哈希) */
|
|
187
|
+
const ensureBytes = () => {
|
|
188
|
+
if (!bytes)
|
|
189
|
+
bytes = new Uint8Array(Buffer.from(base64 ?? "", "base64"));
|
|
190
|
+
return bytes;
|
|
191
|
+
};
|
|
192
|
+
const isImage = mime.startsWith("image/");
|
|
193
|
+
// ---- 3. 模型看不见图:退化为文本提示 ----
|
|
194
|
+
// 判定口径与 `ViewImageTool.isAvailable` 一致:只有 `vision === true` 才算视觉模型,
|
|
195
|
+
// 缺省视为不支持(模型未提供 model 时不做判断,交给调用方)。
|
|
196
|
+
// 对任何来源都生效(本地路径给路径,内存来源给文件名):非视觉模型收到 image_url
|
|
197
|
+
// 可能直接 4xx,宁可退化成一行提示。
|
|
198
|
+
if (isImage && model && model.vision !== true) {
|
|
199
|
+
const label = absPath ?? filename;
|
|
200
|
+
return {
|
|
201
|
+
section: textSection(`[图片] ${label}(当前模型不支持图片输入,如需查看请改用视觉模型)`),
|
|
202
|
+
transport: "text",
|
|
203
|
+
bytes: byteLength,
|
|
204
|
+
warning: "当前模型不支持图片输入,已退化为文本提示",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
// ---- 4. 端点支持 Files API:上传(带缓存),失败降级内联 ----
|
|
208
|
+
let warning;
|
|
209
|
+
if (supportsFilesApi(endpoint) && endpoint?.apiKey) {
|
|
210
|
+
const key = absPath && stat
|
|
211
|
+
? cacheKey(endpoint, absPath, stat)
|
|
212
|
+
: contentCacheKey(endpoint, contentHash(ensureBytes()));
|
|
213
|
+
const cached = uploadCache.get(key);
|
|
214
|
+
if (cached) {
|
|
215
|
+
return {
|
|
216
|
+
section: { type: "file", file_id: cached },
|
|
217
|
+
transport: "files-api",
|
|
218
|
+
bytes: byteLength,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const upload = input.deps?.upload ?? defaultUpload;
|
|
223
|
+
const fileId = await upload({ endpoint, data: ensureBytes(), filename, signal });
|
|
224
|
+
rememberUpload(key, fileId);
|
|
225
|
+
return {
|
|
226
|
+
section: { type: "file", file_id: fileId },
|
|
227
|
+
transport: "files-api",
|
|
228
|
+
bytes: byteLength,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
if (signal?.aborted)
|
|
233
|
+
throw error;
|
|
234
|
+
warning = `上传 Files API 失败,已改为 base64 内联:${error?.message ?? error}`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// ---- 5. 内联(base64 data URL)----
|
|
238
|
+
if (byteLength > maxInline) {
|
|
239
|
+
throw new Error(`文件体积 ${(byteLength / 1024 / 1024).toFixed(1)} MiB 超过内联上限 ` +
|
|
240
|
+
`${(maxInline / 1024 / 1024).toFixed(1)} MiB,且端点未声明 Files API 能力` +
|
|
241
|
+
`(capabilities.filesApi),无法发送。`);
|
|
242
|
+
}
|
|
243
|
+
// base64 来源直接透传,不重新编码(保证字节级一致,也省一次编解码)
|
|
244
|
+
const dataUrl = toDataUrl(base64 ?? Buffer.from(ensureBytes()).toString("base64"), mime);
|
|
245
|
+
return {
|
|
246
|
+
section: isImage
|
|
247
|
+
? { type: "image_url", image_url: { url: dataUrl } }
|
|
248
|
+
: { type: "file", file_data: dataUrl, filename },
|
|
249
|
+
transport: "inline",
|
|
250
|
+
bytes: byteLength,
|
|
251
|
+
warning,
|
|
252
|
+
};
|
|
253
|
+
}
|