@ai-zen/agents-sdk 1.0.0-alpha.10 → 1.0.0-alpha.11

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-alpha.11] - 2026-10-20
4
+
5
+ ### 💥 Breaking Changes
6
+
7
+ - **`createCallMcpTool(mcpManager)` → `createCallMcpTool(mcpManager, env: ToolEnv)`** —— 新增的 `env` 只用于读取 `maxToolOutput`,由 `McpScopePlugin.instantiate` 传入 `scope.env`。直接调用该工厂的下游需补第二参。
8
+
9
+ ### ✨ 新增
10
+
11
+ - **`call_mcp_tool` 输出保护**:MCP 工具是最容易一次吐出上百 KB 甚至上 MB JSON 的通道,此前完全裸返回。现在文本部分超过 `maxToolOutput`(默认 32768 字符)时,完整文本落盘为 `result.txt`,只返回警告 + 头尾预览(`outputTooLarge` / `chars` / `head` / `tail` / `files`)。复用内置工具同一套 `outputGuard` 骨架与同一配置口径,不另立阈值。
12
+ - **媒体内容块原样透传**:`image` → `image_url`(`data:<mimeType>;base64,...`)、`audio` → `file`(`file_data` 内联 data URL)、`resource.blob` 按 mimeType 分流到 `image_url` / `file`。媒体**不参与字符计数、不落盘、不丢弃**——只对文本做保护。
13
+ - **返回形态**:仅文本 → 纯字符串(与历史一致);含媒体 → 内容块数组。
14
+
15
+ ### 🔧 修复
16
+
17
+ - **非文本内容块被静默丢弃**:旧实现只取 `type === "text"`,`image` / `audio` / `resource` / `resource_link` 全部被扔掉,模型只知道「有个块没给我」。现在媒体透传、`resource.text` 并入正文、`resource_link` 转为 `[资源链接] name · uri · mimeType` 线索、未知类型(如 `tool_use`)退化为 JSON 文本。
18
+ - **内容块顺序**:严格按 MCP `content[]` 原始顺序逐块映射,文本夹在图片中间也保持原位。超限时只保留一个警告块占据原**首个**文本块的位置,其余文本落盘、媒体原位不动——即使文本夹在两图之间,图注与图片的对应关系也不会被打乱。
19
+ - **判定口径**:用全部文本的字符**总数**判超限,不逐块判(否则 N 段各 3 万字符都不超限、合起来却能灌爆)。
20
+
21
+ ### ✅ 测试
22
+
23
+ - `mcpTools.test.ts` 38 例(新增内容块映射 8 例、输出保护 4 例、保序 4 例)。全量 **54 文件 / 542 用例**通过,`tsc --noEmit` 与 `build` 零错误。
24
+
25
+ ### 📄 Docs
26
+
27
+ - `docs/sdk-design-v1.md` §9 `call_mcp_tool` 补充内容块映射表、输出保护口径与超限保序折叠规则;`../docs/{zh,en}/mcp.md` 及网站镜像同步。
28
+
29
+
3
30
  ## [1.0.0-alpha.10] - 2026-10-20
4
31
 
5
32
  ### 💥 Breaking Changes
@@ -24,6 +24,6 @@ export declare class McpScopePlugin implements ScopePlugin {
24
24
  /** 是否配置了 MCP(供外部代码判定,如 UnknownToolHintPlugin) */
25
25
  hasConfig(): boolean;
26
26
  candidates(): ScopeCandidates;
27
- instantiate(_scope: Scope, filtered: FilterOutput): Tool[];
27
+ instantiate(scope: Scope, filtered: FilterOutput): Tool[];
28
28
  dispose(): Promise<void>;
29
29
  }
@@ -37,7 +37,7 @@ export class McpScopePlugin {
37
37
  tools: ["load_mcp", "call_mcp_tool", "read_mcp_resource"],
38
38
  };
39
39
  }
40
- instantiate(_scope, filtered) {
40
+ instantiate(scope, filtered) {
41
41
  const allow = new Set(filtered.tools);
42
42
  const allowedMcps = new Set(filtered.mcps);
43
43
  const filteredMcps = this.servers.filter((s) => allowedMcps.has(s.id));
@@ -47,7 +47,8 @@ export class McpScopePlugin {
47
47
  result.push(createLoadMcpTool(m, filteredMcps));
48
48
  }
49
49
  if (allow.has("call_mcp_tool") && m) {
50
- result.push(createCallMcpTool(m));
50
+ // env 供 call_mcp_tool 读取 maxToolOutput,与内置工具共用同一上限口径
51
+ result.push(createCallMcpTool(m, scope.env));
51
52
  }
52
53
  if (allow.has("read_mcp_resource") && m) {
53
54
  result.push(createReadMcpResourceTool(m));
@@ -1,6 +1,6 @@
1
1
  import { CallbackTool } from "@ai-zen/agents-core";
2
2
  import type { McpConnectionManager } from "./McpConnectionManager.js";
3
- import type { McpServerConfig } from "../../../types/index.js";
3
+ import type { McpServerConfig, ToolEnv } from "../../../types/index.js";
4
4
  /**
5
5
  * 创建 load_mcp 工具。
6
6
  * server 枚举由 filteredMcps (完整 McpServerConfig[])自动推导,
@@ -10,8 +10,14 @@ export declare function createLoadMcpTool(mcpManager: McpConnectionManager, filt
10
10
  /**
11
11
  * 创建 call_mcp_tool 工具。
12
12
  * 通过官方 Client.callTool() API 调用 MCP 服务器上的工具。
13
+ *
14
+ * 返回形态:
15
+ * - 仅文本 → 纯字符串(与历史行为一致);
16
+ * - 含媒体 → 内容块数组:首块为受保护的文本,其后原样透传 image / file 块。
17
+ *
18
+ * env 仅用于读取 maxToolOutput(文本输出保护上限),与内置工具口径一致。
13
19
  */
14
- export declare function createCallMcpTool(mcpManager: McpConnectionManager): CallbackTool;
20
+ export declare function createCallMcpTool(mcpManager: McpConnectionManager, env: ToolEnv): CallbackTool;
15
21
  /**
16
22
  * 创建 read_mcp_resource 工具。
17
23
  */
@@ -1,5 +1,6 @@
1
1
  import { CallbackTool } from "@ai-zen/agents-core";
2
2
  import { createDisclosureParam } from "../../disclosure.js";
3
+ import { DEFAULT_MAX_TOOL_OUTPUT, guardOutput, headTailPreview } from "../builtin/outputGuard.js";
3
4
  import { getLogger } from "../../../shared/logger.js";
4
5
  const EMPTY_HINT = "(当前没有可用的 MCP 服务器,请联系用户添加)";
5
6
  const log = getLogger();
@@ -81,15 +82,130 @@ export function createLoadMcpTool(mcpManager, filteredMcps) {
81
82
  },
82
83
  });
83
84
  }
85
+ /** base64 二进制转 data URL(image_url / file_data 均接受) */
86
+ function toDataUrl(base64, mimeType) {
87
+ return `data:${mimeType};base64,${base64}`;
88
+ }
89
+ /**
90
+ * MCP content 块 → SDK 内容块。
91
+ *
92
+ * 文本类(text / resource.text / resource_link / 未知类型)归一为 `text` 块,
93
+ * 由调用方拼接成正文后进入输出保护;
94
+ * 媒体类(image / audio / resource.blob)归一为 `image_url` / `file` 块,
95
+ * 原样透传给模型——图片不进字符闸门,也不丢弃。
96
+ */
97
+ function toMessageSection(block) {
98
+ switch (block?.type) {
99
+ case "image":
100
+ return {
101
+ type: "image_url",
102
+ image_url: { url: toDataUrl(block.data, block.mimeType ?? "image/png") },
103
+ };
104
+ case "audio":
105
+ // AgentNS 无独立音频块,以 Files API 的 file_data(base64 data URL)内联承载
106
+ return {
107
+ type: "file",
108
+ file_data: toDataUrl(block.data, block.mimeType ?? "audio/wav"),
109
+ };
110
+ case "resource": {
111
+ const r = block.resource ?? {};
112
+ // 内嵌文本资源:本质是纯文本,并入正文(同样受输出保护)
113
+ if (typeof r.text === "string")
114
+ return { type: "text", text: r.text };
115
+ // 内嵌二进制资源:按 mimeType 选块类型,不丢弃
116
+ if (typeof r.blob === "string") {
117
+ const mime = r.mimeType ?? "application/octet-stream";
118
+ return mime.startsWith("image/")
119
+ ? { type: "image_url", image_url: { url: toDataUrl(r.blob, mime) } }
120
+ : { type: "file", file_data: toDataUrl(r.blob, mime) };
121
+ }
122
+ return { type: "text", text: `[资源] ${r.uri ?? "(未知 URI)"}` };
123
+ }
124
+ case "resource_link": {
125
+ const parts = [block.name, block.uri, block.mimeType].filter(Boolean);
126
+ return { type: "text", text: `[资源链接] ${parts.join(" · ")}` };
127
+ }
128
+ case "text":
129
+ return { type: "text", text: block.text ?? "" };
130
+ default:
131
+ // 未知类型(如 tool_use)退化为 JSON 文本,仍受输出保护
132
+ return { type: "text", text: JSON.stringify(block) };
133
+ }
134
+ }
135
+ /**
136
+ * 文本输出保护(保序):
137
+ *
138
+ * - **未超限**:原样返回映射后的块数组,不重排、不合并、不丢块;
139
+ * 纯文本时退化为单个字符串(历史行为)。
140
+ * - **超限**:全部文本落盘为 result.txt,只保留**一个**警告文本块占据
141
+ * 原首个文本块的位置,其余文本块丢弃(内容已落盘),媒体块原位不动。
142
+ * 这样即使文本夹在中间,媒体与位置的对应关系也不会被打乱。
143
+ *
144
+ * 判定用全部文本的字符总数——不能逐块判,否则 N 段各 3 万字符都不超限、合起来却能灌爆。
145
+ * 落盘只写文本正文,不写原始结果——后者可能含 image 的 base64,
146
+ * 落盘等于把几 MB 二进制再抄一遍磁盘。
147
+ */
148
+ async function protectCallOutput(output, env) {
149
+ const limit = env.config.maxToolOutput ?? DEFAULT_MAX_TOOL_OUTPUT;
150
+ const { sections, fullText, chars, isError } = output;
151
+ const guarded = await guardOutput({
152
+ tool: "call_mcp_tool",
153
+ content: output,
154
+ isOverLimit: (o) => o.chars > limit,
155
+ dump: (_dir, o) => [{ name: "result.txt", content: o.fullText }],
156
+ buildWarning: (ctx) => {
157
+ const preview = headTailPreview(fullText);
158
+ return JSON.stringify({
159
+ outputTooLarge: true,
160
+ isError: isError || undefined,
161
+ warning: `文本输出过大:共 ${chars} 字符,超过上限 ${limit} 字符。` +
162
+ `完整文本已落盘(result.txt),可用 read 分批读取。`,
163
+ files: ctx.files,
164
+ chars,
165
+ ...preview,
166
+ });
167
+ },
168
+ });
169
+ // 超限:警告占据首个文本块的位置,其余文本块合并到它身上(内容已落盘),媒体原位
170
+ if (typeof guarded === "string") {
171
+ const collapsed = [];
172
+ let placed = false;
173
+ for (const section of sections) {
174
+ if (section.type === "text") {
175
+ if (!placed) {
176
+ collapsed.push({ type: "text", text: guarded });
177
+ placed = true;
178
+ }
179
+ continue;
180
+ }
181
+ collapsed.push(section);
182
+ }
183
+ // 仅一个文本块(无媒体):退化为字符串,与历史行为一致
184
+ return collapsed.length === 1 ? collapsed[0].text : collapsed;
185
+ }
186
+ // 未超限:无媒体 → 纯字符串;有媒体 → 原序块数组
187
+ const hasMedia = sections.some((s) => s.type !== "text");
188
+ if (!hasMedia)
189
+ return isError ? `❌ 工具执行出错:\n${fullText}` : fullText;
190
+ return isError
191
+ ? [{ type: "text", text: "❌ 工具执行出错:" }, ...sections]
192
+ : sections;
193
+ }
84
194
  /**
85
195
  * 创建 call_mcp_tool 工具。
86
196
  * 通过官方 Client.callTool() API 调用 MCP 服务器上的工具。
197
+ *
198
+ * 返回形态:
199
+ * - 仅文本 → 纯字符串(与历史行为一致);
200
+ * - 含媒体 → 内容块数组:首块为受保护的文本,其后原样透传 image / file 块。
201
+ *
202
+ * env 仅用于读取 maxToolOutput(文本输出保护上限),与内置工具口径一致。
87
203
  */
88
- export function createCallMcpTool(mcpManager) {
204
+ export function createCallMcpTool(mcpManager, env) {
89
205
  return new CallbackTool({
90
206
  function: {
91
207
  name: "call_mcp_tool",
92
- description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。",
208
+ description: "调用已连接 MCP 服务器上的指定工具。需先通过 load_mcp 连接服务器。文本输出过大时会落盘并只返回头尾预览;图片、音频等媒体原样返回。",
93
209
  parameters: {
94
210
  type: "object",
95
211
  properties: {
@@ -117,15 +233,19 @@ export function createCallMcpTool(mcpManager) {
117
233
  name: input.tool,
118
234
  arguments: input.arguments,
119
235
  }, undefined, { signal: ctx?.signal });
236
+ // 按 MCP content 原始顺序映射,文本与媒体的相对位置完整保留
120
237
  const contents = result.content ?? [];
121
- const textParts = contents
122
- .filter((c) => c.type === "text")
123
- .map((c) => c.text);
124
- const text = textParts.join("\n");
125
- if (result.isError) {
126
- return `❌ 工具执行出错:\n${text || JSON.stringify(result)}`;
238
+ const isError = Boolean(result.isError);
239
+ // 没有 content 时退回原始 JSON(既有兜底)
240
+ if (contents.length === 0) {
241
+ const json = JSON.stringify(result);
242
+ return await protectCallOutput({ sections: [{ type: "text", text: json }], fullText: json, chars: json.length, isError }, env);
127
243
  }
128
- return text || JSON.stringify(result);
244
+ const sections = contents.map(toMessageSection);
245
+ const textSections = sections.filter((s) => s.type === "text");
246
+ const fullText = textSections.map((s) => s.text).join("\n");
247
+ const chars = textSections.reduce((n, s) => n + s.text.length, 0);
248
+ return await protectCallOutput({ sections, fullText, chars, isError }, env);
129
249
  }
130
250
  catch (error) {
131
251
  return `❌ 调用 "${input.tool}" 失败: ${error?.message ?? error}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-zen/agents-sdk",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "AI-Zen Agents SDK — shared business logic for CLI and Desktop",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",