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

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.
@@ -0,0 +1,256 @@
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
+ const fallback = models.find((m) => m.id === config.defaultModel);
78
+ if (fallback)
79
+ return { model: fallback, endpoint: findEndpoint(fallback.endpointId) };
80
+ return { endpoint: null };
81
+ }
82
+ /** 端点是否声明了 Files API 能力 */
83
+ export function supportsFilesApi(endpoint) {
84
+ return endpoint?.capabilities?.filesApi === true;
85
+ }
86
+ /**
87
+ * 上传缓存:`端点id + 绝对路径 + mtime + size` → `file_id`。
88
+ *
89
+ * file_id 是**端点维度**的资源,所以 key 必须带端点 id —— 同一张图传到两个端点
90
+ * 会得到两个不同的 id,混用会让另一端返回 not found。
91
+ */
92
+ const uploadCache = new Map();
93
+ /** 清空上传缓存(测试用;进程内长期运行无需清理,条目有上限) */
94
+ export function clearUploadCache() {
95
+ uploadCache.clear();
96
+ }
97
+ const UPLOAD_CACHE_LIMIT = 200;
98
+ /** 内容哈希(sha256),用于给「内存里的字节」做上传缓存键 */
99
+ export function contentHash(bytes) {
100
+ return createHash("sha256").update(bytes).digest("hex");
101
+ }
102
+ function cacheKey(endpoint, absPath, stat) {
103
+ return `${endpoint.id}\u0000${absPath}\u0000${stat.mtimeMs}\u0000${stat.size}`;
104
+ }
105
+ /** 内存来源的缓存键:没有路径与 mtime,改用内容哈希(同一张图不会重复上传) */
106
+ function contentCacheKey(endpoint, hash) {
107
+ return `${endpoint.id}\u0000sha256:${hash}`;
108
+ }
109
+ function rememberUpload(key, fileId) {
110
+ uploadCache.set(key, fileId);
111
+ while (uploadCache.size > UPLOAD_CACHE_LIMIT) {
112
+ const oldest = uploadCache.keys().next();
113
+ if (oldest.done)
114
+ break;
115
+ uploadCache.delete(oldest.value);
116
+ }
117
+ }
118
+ /** 默认上传实现:OpenAI 兼容的 `POST {baseUrl}/files`(`purpose=user_data`) */
119
+ const defaultUpload = async ({ endpoint, data, filename, signal }) => {
120
+ const client = new OpenAI({ apiKey: endpoint.apiKey, baseURL: endpoint.baseUrl });
121
+ const file = await toFile(Buffer.from(data), filename);
122
+ const uploaded = await client.files.create({ file, purpose: "user_data" }, { signal });
123
+ return uploaded.id;
124
+ };
125
+ function isRemoteUrl(value) {
126
+ return /^https?:\/\//i.test(value);
127
+ }
128
+ function textSection(text) {
129
+ return { type: "text", text };
130
+ }
131
+ /**
132
+ * 把文件转成内容块。
133
+ *
134
+ * 抛错场景(均由调用方决定如何呈现给模型):
135
+ * - `source.url` 不是 http(s)(本地 URL 服务商访问不到)
136
+ * - 本地文件不存在 / 不是文件 / 扩展名不支持
137
+ * - 需要内联但体积超过上限,且端点不支持上传
138
+ */
139
+ export async function fileToContentSection(input) {
140
+ const { source, endpoint, model, signal } = input;
141
+ const maxInline = input.maxInlineBytes ?? DEFAULT_MAX_INLINE_BYTES;
142
+ // ---- 1. 网络 URL:直接引用,不经上传/内联 ----
143
+ if ("url" in source) {
144
+ if (!isRemoteUrl(source.url)) {
145
+ throw new Error(`不支持本地 URL(${source.url}):API 服务商无法访问。本地文件请传文件路径,而不是本地链接。`);
146
+ }
147
+ return {
148
+ section: { type: "image_url", image_url: { url: source.url } },
149
+ transport: "url",
150
+ bytes: 0,
151
+ };
152
+ }
153
+ // ---- 2. 取得字节与 MIME ----
154
+ // 已经是 base64 的来源(MCP):先不解码,内联时原样透传,只有真要上传/算哈希时才解码
155
+ let base64 = null;
156
+ let bytes = null;
157
+ let byteLength;
158
+ let mime;
159
+ let filename;
160
+ let absPath = null;
161
+ let stat = null;
162
+ if ("path" in source) {
163
+ absPath = path.isAbsolute(source.path) ? source.path : path.resolve(source.path);
164
+ const st = await fs.stat(absPath);
165
+ if (!st.isFile())
166
+ throw new Error(`"${absPath}" 不是文件。`);
167
+ const inferred = mimeOf(absPath);
168
+ if (!inferred) {
169
+ throw new Error(`不支持的文件类型:"${absPath}"(已知类型:${Object.keys(MIME_BY_EXT).join(" ")})`);
170
+ }
171
+ mime = inferred;
172
+ stat = { mtimeMs: st.mtimeMs, size: st.size };
173
+ bytes = new Uint8Array(await fs.readFile(absPath));
174
+ byteLength = bytes.byteLength;
175
+ filename = path.basename(absPath);
176
+ }
177
+ else if ("base64" in source) {
178
+ mime = source.mime;
179
+ base64 = source.base64;
180
+ byteLength = base64ByteLength(base64);
181
+ filename = source.filename ?? "file";
182
+ }
183
+ else {
184
+ mime = source.mime;
185
+ bytes = source.data;
186
+ byteLength = bytes.byteLength;
187
+ filename = source.filename ?? "file";
188
+ }
189
+ /** 需要真实字节时才解码(上传、算内容哈希) */
190
+ const ensureBytes = () => {
191
+ if (!bytes)
192
+ bytes = new Uint8Array(Buffer.from(base64 ?? "", "base64"));
193
+ return bytes;
194
+ };
195
+ const isImage = mime.startsWith("image/");
196
+ // ---- 3. 模型看不见图:退化为文本提示 ----
197
+ // 判定口径与 `ViewImageTool.isAvailable` 一致:只有 `vision === true` 才算视觉模型,
198
+ // 缺省视为不支持(模型未提供 model 时不做判断,交给调用方)。
199
+ // 对任何来源都生效(本地路径给路径,内存来源给文件名):非视觉模型收到 image_url
200
+ // 可能直接 4xx,宁可退化成一行提示。
201
+ if (isImage && model && model.vision !== true) {
202
+ const label = absPath ?? filename;
203
+ return {
204
+ section: textSection(`[图片] ${label}(当前模型不支持图片输入,如需查看请改用视觉模型)`),
205
+ transport: "text",
206
+ bytes: byteLength,
207
+ warning: "当前模型不支持图片输入,已退化为文本提示",
208
+ };
209
+ }
210
+ // ---- 4. 端点支持 Files API:上传(带缓存),失败降级内联 ----
211
+ let warning;
212
+ if (supportsFilesApi(endpoint) && endpoint?.apiKey) {
213
+ const key = absPath && stat
214
+ ? cacheKey(endpoint, absPath, stat)
215
+ : contentCacheKey(endpoint, contentHash(ensureBytes()));
216
+ const cached = uploadCache.get(key);
217
+ if (cached) {
218
+ return {
219
+ section: { type: "file", file_id: cached },
220
+ transport: "files-api",
221
+ bytes: byteLength,
222
+ };
223
+ }
224
+ try {
225
+ const upload = input.deps?.upload ?? defaultUpload;
226
+ const fileId = await upload({ endpoint, data: ensureBytes(), filename, signal });
227
+ rememberUpload(key, fileId);
228
+ return {
229
+ section: { type: "file", file_id: fileId },
230
+ transport: "files-api",
231
+ bytes: byteLength,
232
+ };
233
+ }
234
+ catch (error) {
235
+ if (signal?.aborted)
236
+ throw error;
237
+ warning = `上传 Files API 失败,已改为 base64 内联:${error?.message ?? error}`;
238
+ }
239
+ }
240
+ // ---- 5. 内联(base64 data URL)----
241
+ if (byteLength > maxInline) {
242
+ throw new Error(`文件体积 ${(byteLength / 1024 / 1024).toFixed(1)} MiB 超过内联上限 ` +
243
+ `${(maxInline / 1024 / 1024).toFixed(1)} MiB,且端点未声明 Files API 能力` +
244
+ `(capabilities.filesApi),无法发送。`);
245
+ }
246
+ // base64 来源直接透传,不重新编码(保证字节级一致,也省一次编解码)
247
+ const dataUrl = toDataUrl(base64 ?? Buffer.from(ensureBytes()).toString("base64"), mime);
248
+ return {
249
+ section: isImage
250
+ ? { type: "image_url", image_url: { url: dataUrl } }
251
+ : { type: "file", file_data: dataUrl, filename },
252
+ transport: "inline",
253
+ bytes: byteLength,
254
+ warning,
255
+ };
256
+ }
@@ -8,17 +8,13 @@ export declare class SdkError extends Error {
8
8
  }
9
9
  /**
10
10
  * 上下文超限错误。由 ContextGuardPlugin 在请求前抛出,用于中断对话。
11
- * 触发条件通常是上下文 token 用量远超阈值(如读入超大文件),
11
+ * 触发条件通常是上下文 token 用量超过硬线(如读入超大文件),
12
12
  * 继续对话已无意义且会持续放大费用/延迟,故由上层捕获后向用户显式报错。
13
13
  */
14
14
  export declare class ContextOverflowError extends Error {
15
15
  /** 触发中断时的刚用量(usage.prompt_tokens) */
16
16
  readonly promptTokens: number;
17
- /** 配置的告警阈值(maxTokens) */
18
- readonly maxTokens: number;
19
- /** 实际生效的硬上限(maxTokens × ratio,越界即中断) */
20
- readonly threshold: number;
21
- /** 超出比例(如 1.2 表示 20%) */
22
- readonly ratio: number;
23
- constructor(promptTokens: number, maxTokens: number, ratio: number);
17
+ /** 被越过的强行阻断阈值(`tokenThresholds.guard`,绝对值) */
18
+ readonly guardTokens: number;
19
+ constructor(promptTokens: number, guardTokens: number);
24
20
  }
@@ -15,26 +15,20 @@ export class SdkError extends Error {
15
15
  }
16
16
  /**
17
17
  * 上下文超限错误。由 ContextGuardPlugin 在请求前抛出,用于中断对话。
18
- * 触发条件通常是上下文 token 用量远超阈值(如读入超大文件),
18
+ * 触发条件通常是上下文 token 用量超过硬线(如读入超大文件),
19
19
  * 继续对话已无意义且会持续放大费用/延迟,故由上层捕获后向用户显式报错。
20
20
  */
21
21
  export class ContextOverflowError extends Error {
22
22
  /** 触发中断时的刚用量(usage.prompt_tokens) */
23
23
  promptTokens;
24
- /** 配置的告警阈值(maxTokens) */
25
- maxTokens;
26
- /** 实际生效的硬上限(maxTokens × ratio,越界即中断) */
27
- threshold;
28
- /** 超出比例(如 1.2 表示 20%) */
29
- ratio;
30
- constructor(promptTokens, maxTokens, ratio) {
31
- const threshold = maxTokens * ratio;
32
- super(`🛑 上下文严重超限:已用 ${promptTokens} token,超过硬上限 ${threshold}(${Math.round((ratio - 1) * 100)}%,配置上限 ${maxTokens})。` +
33
- `当前对话可能因读入超大文件而失控,已中断,请检查并重新开始。`);
24
+ /** 被越过的强行阻断阈值(`tokenThresholds.guard`,绝对值) */
25
+ guardTokens;
26
+ constructor(promptTokens, guardTokens) {
27
+ super(`🛑 上下文严重超限:已用 ${promptTokens} token,超过阻断阈值 ${guardTokens}。` +
28
+ `当前对话可能因读入超大文件而失控,已中断,请检查并重新开始。` +
29
+ `(如确需更长上下文,请调高 config.json 中该模型的 tokenThresholds.guard。)`);
34
30
  this.name = "ContextOverflowError";
35
31
  this.promptTokens = promptTokens;
36
- this.maxTokens = maxTokens;
37
- this.threshold = threshold;
38
- this.ratio = ratio;
32
+ this.guardTokens = guardTokens;
39
33
  }
40
34
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 思考强度(reasoning effort)—— 能力声明、可用档位与参数写入的唯一实现
3
+ *
4
+ * 为什么要有这一层:档位不是全局统一枚举,各端点接受的值域**互不相同**,而且
5
+ * 同一家不同型号也不同(智谱文档原文):
6
+ *
7
+ * | 端点 | 官方依据 | 可调档位 | 能否关闭 |
8
+ * |------|---------|---------|---------|
9
+ * | DeepSeek | `/guides/thinking_mode` | `low / high / max` | 能,`thinking.type = "disabled"` |
10
+ * | 智谱 GLM-5.3 系 | `/cn/guide/capabilities/thinking` | `low / high / max`(**其余报错**) | **不能**,传 disabled 直接报错 |
11
+ * | 智谱 GLM-5.2 | 同上 | `none / minimal / low / medium / high / xhigh / max` | 能(`none` 或 `disabled`) |
12
+ * | OpenAI | `openai-node` 的 `Shared.ReasoningEffort` | `minimal / low / medium / high`(枚举含 none/xhigh/max) | 能,`reasoning_effort = "none"` |
13
+ *
14
+ * 所以「能力声明在模型上,Agent 只能收窄」,写入时按声明选择表达方式,
15
+ * 绝不把端点不认的值发出去。
16
+ */
17
+ import type { AgentDefinition, Model, ReasoningCapability, ReasoningEffort } from "../types/index.js";
18
+ /** 是否为合法档位值 */
19
+ export declare function isReasoningEffort(value: unknown): value is ReasoningEffort;
20
+ /** 模型的能力声明(缺省 = 不可调档) */
21
+ export declare function capabilityOf(model?: Model | null): ReasoningCapability | undefined;
22
+ /**
23
+ * 模型可调的档位(不含 `off`)。
24
+ *
25
+ * 未声明能力的模型返回空数组 —— 不猜、不给默认值,避免把非法参数发给服务端。
26
+ */
27
+ export declare function modelEfforts(model?: Model | null): ReasoningEffort[];
28
+ /**
29
+ * 模型允许的全部取值(含 `off`)。
30
+ *
31
+ * `off` 只在声明了 `disable` 方式时出现:没有合法表达式时,「关闭思考」这个选项
32
+ * 本身就不该出现在菜单里。
33
+ */
34
+ export declare function modelAllowedEfforts(model?: Model | null): ReasoningEffort[];
35
+ /**
36
+ * 从 `defaultParams` 读当前档位。
37
+ *
38
+ * 返回 `undefined` 表示「没有显式档位」:可能是模型默认开思考但没写 effort
39
+ * (DeepSeek 出厂配置就是 `{thinking:{type:"enabled"}}`),也可能压根没有思考参数。
40
+ * 展示层要区分这两种情况,请配合 `capabilityOf()` 判断。
41
+ */
42
+ export declare function readReasoningEffort(params?: Record<string, unknown> | null): ReasoningEffort | undefined;
43
+ /**
44
+ * 按档位生成新的 `defaultParams`(不改入参)。
45
+ *
46
+ * 三种写入形态,取决于模型声明:
47
+ * - `disable: "thinking"` → 同时维护 `thinking.type` 与 `reasoning_effort`
48
+ * - `disable: "effort-none"` → 只写 `reasoning_effort`(OpenAI 不认 `thinking` 键,
49
+ * 对它写 `thinking` 会被当作未知参数拒绝)
50
+ * - 未声明 `disable`(如 GLM-5.3 系)→ 只写 `reasoning_effort`,传 `off` 直接抛错
51
+ *
52
+ * @throws 当档位为 `off` 而模型不支持关闭思考,或档位不在模型能力范围内
53
+ */
54
+ export declare function applyReasoningEffort(params: Record<string, unknown> | undefined | null, model: Model | null | undefined, effort: ReasoningEffort): Record<string, unknown>;
55
+ /**
56
+ * 解析某 Agent 的实际档位:Agent 声明优先,否则看模型默认参数。
57
+ */
58
+ export declare function resolveReasoningEffort(model?: Model | null, definition?: Pick<AgentDefinition, "reasoningEffort"> | null): ReasoningEffort | undefined;
59
+ /**
60
+ * 把 Agent 的档位覆盖应用到模型参数上(`createModel` 用)。
61
+ *
62
+ * 未声明 `reasoningEffort` 时原样返回模型参数,保证既有行为不变。
63
+ */
64
+ export declare function resolveModelParams(model?: Model | null, definition?: Pick<AgentDefinition, "reasoningEffort"> | null): Record<string, unknown> | undefined;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * 思考强度(reasoning effort)—— 能力声明、可用档位与参数写入的唯一实现
3
+ *
4
+ * 为什么要有这一层:档位不是全局统一枚举,各端点接受的值域**互不相同**,而且
5
+ * 同一家不同型号也不同(智谱文档原文):
6
+ *
7
+ * | 端点 | 官方依据 | 可调档位 | 能否关闭 |
8
+ * |------|---------|---------|---------|
9
+ * | DeepSeek | `/guides/thinking_mode` | `low / high / max` | 能,`thinking.type = "disabled"` |
10
+ * | 智谱 GLM-5.3 系 | `/cn/guide/capabilities/thinking` | `low / high / max`(**其余报错**) | **不能**,传 disabled 直接报错 |
11
+ * | 智谱 GLM-5.2 | 同上 | `none / minimal / low / medium / high / xhigh / max` | 能(`none` 或 `disabled`) |
12
+ * | OpenAI | `openai-node` 的 `Shared.ReasoningEffort` | `minimal / low / medium / high`(枚举含 none/xhigh/max) | 能,`reasoning_effort = "none"` |
13
+ *
14
+ * 所以「能力声明在模型上,Agent 只能收窄」,写入时按声明选择表达方式,
15
+ * 绝不把端点不认的值发出去。
16
+ */
17
+ /** `defaultParams` 里承载开关的键 */
18
+ const THINKING_KEY = "thinking";
19
+ /** `defaultParams` 里承载档位的键 */
20
+ const EFFORT_KEY = "reasoning_effort";
21
+ const KNOWN_EFFORTS = [
22
+ "off",
23
+ "none",
24
+ "minimal",
25
+ "low",
26
+ "medium",
27
+ "high",
28
+ "xhigh",
29
+ "max",
30
+ ];
31
+ /** 是否为合法档位值 */
32
+ export function isReasoningEffort(value) {
33
+ return typeof value === "string" && KNOWN_EFFORTS.includes(value);
34
+ }
35
+ /** 模型的能力声明(缺省 = 不可调档) */
36
+ export function capabilityOf(model) {
37
+ return model?.reasoning;
38
+ }
39
+ /**
40
+ * 模型可调的档位(不含 `off`)。
41
+ *
42
+ * 未声明能力的模型返回空数组 —— 不猜、不给默认值,避免把非法参数发给服务端。
43
+ */
44
+ export function modelEfforts(model) {
45
+ return [...(capabilityOf(model)?.efforts ?? [])];
46
+ }
47
+ /**
48
+ * 模型允许的全部取值(含 `off`)。
49
+ *
50
+ * `off` 只在声明了 `disable` 方式时出现:没有合法表达式时,「关闭思考」这个选项
51
+ * 本身就不该出现在菜单里。
52
+ */
53
+ export function modelAllowedEfforts(model) {
54
+ const capability = capabilityOf(model);
55
+ if (!capability)
56
+ return [];
57
+ return capability.disable ? ["off", ...capability.efforts] : [...capability.efforts];
58
+ }
59
+ function thinkingType(params) {
60
+ const thinking = params?.[THINKING_KEY];
61
+ if (typeof thinking !== "object" || thinking === null)
62
+ return undefined;
63
+ const type = thinking.type;
64
+ return typeof type === "string" ? type : undefined;
65
+ }
66
+ /**
67
+ * 从 `defaultParams` 读当前档位。
68
+ *
69
+ * 返回 `undefined` 表示「没有显式档位」:可能是模型默认开思考但没写 effort
70
+ * (DeepSeek 出厂配置就是 `{thinking:{type:"enabled"}}`),也可能压根没有思考参数。
71
+ * 展示层要区分这两种情况,请配合 `capabilityOf()` 判断。
72
+ */
73
+ export function readReasoningEffort(params) {
74
+ if (thinkingType(params) === "disabled")
75
+ return "off";
76
+ const raw = params?.[EFFORT_KEY];
77
+ if (typeof raw !== "string")
78
+ return undefined;
79
+ // `none` 与 `off` 语义一致(都表示不思考),统一归一为 off,避免菜单里出现两个「关」
80
+ if (raw === "none")
81
+ return "off";
82
+ return isReasoningEffort(raw) ? raw : undefined;
83
+ }
84
+ /**
85
+ * 按档位生成新的 `defaultParams`(不改入参)。
86
+ *
87
+ * 三种写入形态,取决于模型声明:
88
+ * - `disable: "thinking"` → 同时维护 `thinking.type` 与 `reasoning_effort`
89
+ * - `disable: "effort-none"` → 只写 `reasoning_effort`(OpenAI 不认 `thinking` 键,
90
+ * 对它写 `thinking` 会被当作未知参数拒绝)
91
+ * - 未声明 `disable`(如 GLM-5.3 系)→ 只写 `reasoning_effort`,传 `off` 直接抛错
92
+ *
93
+ * @throws 当档位为 `off` 而模型不支持关闭思考,或档位不在模型能力范围内
94
+ */
95
+ export function applyReasoningEffort(params, model, effort) {
96
+ const capability = capabilityOf(model);
97
+ const next = { ...(params ?? {}) };
98
+ if (effort === "off") {
99
+ if (capability?.disable === "thinking") {
100
+ next[THINKING_KEY] = { type: "disabled" };
101
+ delete next[EFFORT_KEY];
102
+ return next;
103
+ }
104
+ if (capability?.disable === "effort-none") {
105
+ next[EFFORT_KEY] = "none";
106
+ // 不在 OpenAI 的请求里塞 thinking:那是未知参数
107
+ delete next[THINKING_KEY];
108
+ return next;
109
+ }
110
+ throw new Error(`模型 "${model?.id ?? "(未知)"}" 不支持关闭思考`);
111
+ }
112
+ if (!capability) {
113
+ throw new Error(`模型 "${model?.id ?? "(未知)"}" 未声明思考能力,无法设置档位`);
114
+ }
115
+ if (!capability.efforts.includes(effort)) {
116
+ throw new Error(`模型 "${model?.id ?? "(未知)"}" 不支持档位 "${effort}"(可选:${capability.efforts.join(" / ")})`);
117
+ }
118
+ if (capability.disable === "thinking") {
119
+ next[THINKING_KEY] = { type: "enabled" };
120
+ }
121
+ next[EFFORT_KEY] = effort;
122
+ return next;
123
+ }
124
+ /**
125
+ * 解析某 Agent 的实际档位:Agent 声明优先,否则看模型默认参数。
126
+ */
127
+ export function resolveReasoningEffort(model, definition) {
128
+ const declared = definition?.reasoningEffort;
129
+ if (declared) {
130
+ // 声明了但模型不支持 → 退化为模型默认,避免把非法值发给服务端
131
+ const allowed = modelAllowedEfforts(model);
132
+ if (allowed.length === 0 || allowed.includes(declared))
133
+ return declared;
134
+ }
135
+ return readReasoningEffort(model?.defaultParams);
136
+ }
137
+ /**
138
+ * 把 Agent 的档位覆盖应用到模型参数上(`createModel` 用)。
139
+ *
140
+ * 未声明 `reasoningEffort` 时原样返回模型参数,保证既有行为不变。
141
+ */
142
+ export function resolveModelParams(model, definition) {
143
+ const declared = definition?.reasoningEffort;
144
+ if (!declared)
145
+ return model?.defaultParams;
146
+ const allowed = modelAllowedEfforts(model);
147
+ // 模型不支持声明值:宁可保持模型默认,也不发非法参数
148
+ if (allowed.length === 0 || !allowed.includes(declared))
149
+ return model?.defaultParams;
150
+ return applyReasoningEffort(model?.defaultParams, model, declared);
151
+ }
@@ -12,6 +12,48 @@ export interface AgentPermissions {
12
12
  mcps?: PermissionPolicy;
13
13
  subagents?: PermissionPolicy;
14
14
  }
15
+ /**
16
+ * 端点能力声明 —— 决定「本地文件 → 内容块」时走哪条传输路径。
17
+ *
18
+ * 之前这段判断硬编码在 ViewImageTool 里(`endpoint.id === "deepseek"`),
19
+ * 用户改一下端点 id 就失效;改为显式能力位后,新端点只要声明能力即可享受上传。
20
+ */
21
+ export interface EndpointCapabilities {
22
+ /**
23
+ * 是否支持 OpenAI 兼容的 Files API(`POST {baseUrl}/files`,`purpose=user_data`)。
24
+ * 为 true 时本地文件优先上传并以 `file` 块(file_id)发送;否则走 base64 内联。
25
+ */
26
+ filesApi?: boolean;
27
+ }
28
+ /**
29
+ * 思考强度档位 —— 各端点官方枚举的**并集**,不含各家不认的值。
30
+ *
31
+ * OpenAI(`openai-node` 的 `Shared.ReasoningEffort`):`none | minimal | low | medium | high | xhigh | max`
32
+ * DeepSeek(`/guides/thinking_mode`):`low | high | max`(另接受 minimal/medium/xhigh/ultra 并做映射)
33
+ * 智谱 GLM-5.3 系(`/cn/guide/capabilities/thinking`):`low | high | max`,**其余值直接报错**
34
+ * 智谱 GLM-5.2:`none | minimal | low | medium | high | xhigh | max`
35
+ *
36
+ * `off` 是本 SDK 的语义值(「关闭思考」),对接时按 `ReasoningCapability.disable` 选择表达方式。
37
+ * 它与 `none` 不重复:`none` 是端点自己认的档位值,`off` 是「没有档位、彻底不思考」。
38
+ */
39
+ export type ReasoningEffort = "off" | "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
40
+ /**
41
+ * 模型/端点的思考能力声明(**物理上限**,来自厂商官方文档)。
42
+ *
43
+ * 刻意不推断、不猜:未声明的模型视为「不可调档」,`/think` 不会列任何选项——
44
+ * 发一个端点不认的值会被直接报错(智谱 5.3 系原文「其余输入将报错」)。
45
+ */
46
+ export interface ReasoningCapability {
47
+ /** 型号真实接受的档位(不含 `off`);空数组 = 支持思考但不可调档 */
48
+ efforts: ReasoningEffort[];
49
+ /**
50
+ * 关闭思考的表达方式:
51
+ * - `thinking` → `thinking: { type: "disabled" }`(DeepSeek / GLM-5.2)
52
+ * - `effort-none` → `reasoning_effort: "none"`(OpenAI / GLM-5.2)
53
+ * - 缺省 → 不支持关闭(如 GLM-5.3 系,传 disabled 会报错)
54
+ */
55
+ disable?: "thinking" | "effort-none";
56
+ }
15
57
  /** API 端点 */
16
58
  export interface Endpoint {
17
59
  id: string;
@@ -19,6 +61,25 @@ export interface Endpoint {
19
61
  baseUrl: string;
20
62
  apiKey: string;
21
63
  description?: string;
64
+ capabilities?: EndpointCapabilities;
65
+ }
66
+ /**
67
+ * 上下文用量阈值 —— 与模型的真实窗口无关,只描述「用量到了这条线该怎么办」。
68
+ *
69
+ * 两个阈值语义不同,消费方也不同:
70
+ * - `handoff`:软线。`AutoMigratePlugin` 在响应返回后拿 `usage.prompt_tokens` 比它,
71
+ * 超过即触发任务交接(交接本身还要再发 1~2 次请求,所以这条线必须留足余量)。
72
+ * - `guard`:硬线。`ContextGuardPlugin` 在**每次请求前**比它,超过即抛
73
+ * `ContextOverflowError` 中断本轮——这是最后一道保护,宁可报错也不要发出必然失败的请求。
74
+ *
75
+ * 建议 `guard ≈ handoff × 1.5`,给交接留出缓冲;两者都是绝对值,配置里写多少就是多少,
76
+ * 不做任何倍数换算(阈值就该是能直接读懂的数字)。
77
+ */
78
+ export interface TokenThresholds {
79
+ /** 交接阈值(软线):prompt_tokens 超过它 → 触发任务交接 */
80
+ handoff: number;
81
+ /** 强行阻断阈值(硬线):请求前用量超过它 → 中断本轮并报错 */
82
+ guard: number;
22
83
  }
23
84
  /** 模型配置,绑定一个 Endpoint */
24
85
  export interface Model {
@@ -27,15 +88,20 @@ export interface Model {
27
88
  endpointId: string;
28
89
  modelName?: string;
29
90
  /**
30
- * 任务迁移(交接)触发阈值 —— **不是**模型的真实上下文窗口。
91
+ * 上下文用量阈值(交接软线 + 阻断硬线)。**不是**模型的真实上下文窗口。
31
92
  *
32
- * AutoMigratePlugin 不估算、不学习:只在 API 响应返回后,用 `usage.prompt_tokens`
33
- * 与该值比较,超过即触发交接迁移。建议取模型真实窗口的约 25%,为 response 留出空间。
93
+ * 历史字段 `maxContextTokens` / `maxContextChars` 已由 `config/migrate.ts`
94
+ * 迁移成这个结构体并删除。
34
95
  */
35
- maxContextTokens: number;
36
- /** 旧版字符数阈值(兼容迁移) */
37
- maxContextChars?: number;
96
+ tokenThresholds: TokenThresholds;
38
97
  defaultParams?: Record<string, unknown>;
98
+ /**
99
+ * 思考强度能力(按厂商官方文档声明)。缺省视为不可调档。
100
+ *
101
+ * Agent 用 `AgentDefinition.reasoningEffort` 在这个范围内选一档;
102
+ * 超出范围的值不会发给服务端(`resolveModelParams()` 会退回模型默认)。
103
+ */
104
+ reasoning?: ReasoningCapability;
39
105
  /** 是否支持图片输入(视觉模型)。看图工具(view_image)仅对视觉模型启用;generate_image 是否返回图片内容块也据此判断 */
40
106
  vision?: boolean;
41
107
  description?: string;
@@ -55,6 +121,13 @@ export interface AgentDefinition {
55
121
  description?: string;
56
122
  messages: AgentNS.Message[];
57
123
  modelId?: string;
124
+ /**
125
+ * 该 Agent 的思考档位(可选),覆盖模型 `defaultParams` 里的对应参数。
126
+ *
127
+ * 有了它,同一个模型可以给不同 Agent 配不同强度(编程 Agent `max`、闲聊 Agent `low`),
128
+ * 而模型本身的默认参数保持不变。未声明时沿用模型默认。
129
+ */
130
+ reasoningEffort?: ReasoningEffort;
58
131
  permissions?: AgentPermissions;
59
132
  function?: AgentNS.FunctionDefine;
60
133
  createdAt: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-zen/agents-sdk",
3
- "version": "1.0.0-alpha.11",
3
+ "version": "1.0.0-alpha.13",
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",