@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,85 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.0.0-alpha.13] - 2026-10-22
4
+
5
+ ### 💥 Breaking Changes
6
+
7
+ - **`Model.maxContextTokens` / `maxContextChars` → `Model.tokenThresholds: { handoff, guard }`** —— 旧名字一直在误导(它既不是上下文窗口,也不是单一概念)。现在拆成两条**绝对值**:
8
+ - `handoff`:软线,响应返回后 `prompt_tokens` 超过它触发任务交接(`AutoMigratePlugin`);
9
+ - `guard`:硬线,请求前用量超过它抛 `ContextOverflowError` 中断本轮(`ContextGuardPlugin`)。
10
+ - `maxContextChars` 直接淘汰(字符口径早于 token 口径,两者并存只会让「按哪个判」长期含糊)。
11
+ - 迁移由 `config/migrate.ts` 完成,`ConfigManager.read()` 读到即迁、`ensureDefaultConfig()` 检测差异才写盘:`maxContextTokens` → `handoff`,`maxContextChars` → `floor(/4)`,缺 `guard` 时按 1.5 倍折算一次(保持迁移前行为),旧键随即删除。
12
+ - **`ContextGuardPlugin` 的 `ratio` / `suspendedRatio` / `isSuspended` 全部删除** —— 它们只是「相对阈值」的产物。现在 `new ContextGuardPlugin({ guardTokens })` 一个绝对数就够:想调松就把数字改大。代价是**交接回合不再有 2.0 倍的额外放宽**,若需要该缓冲请把 `guard` 配得更大(出厂取 `handoff × 1.5`,并写成整数)。
13
+ - **`ContextOverflowError(promptTokens, guardTokens)`** —— 去掉 `maxTokens` / `ratio` / `threshold` 三个派生字段,改为直接暴露被越过的 `guardTokens`。
14
+ - **`AutoMigratePlugin` 的 `maxTokens` → `handoffTokens`**,`AutoMigrateTriggerInfo.maxTokens` → `handoffTokens`,`HandoffService` 的 `HandoffParams.maxTokens` / `HandoffContext.maxTokens` → `handoffTokens`(仅字段改名,语义不变:交接阈值)。
15
+
16
+ ### ✨ 新增
17
+
18
+ - **思考强度(reasoning effort)能力位** —— 档位此前完全没有建模,CLI 只能手改 `defaultParams`,而各家端点接受的值域并不相同(智谱 GLM-5.3 系对 `medium` 直接报错、对 `thinking.type = "disabled"` 也报错)。现在分三层:
19
+ - `Model.reasoning: { efforts: ReasoningEffort[], disable?: "thinking" | "effort-none" }` —— **物理能力上限**,按厂商官方文档声明(防 400);
20
+ - `AgentDefinition.reasoningEffort?: ReasoningEffort` —— 该 Agent 的实际档位,在 `createModel` 阶段覆盖模型 `defaultParams`。于是同一模型可以给不同 Agent 配不同强度(编程 Agent `max`、闲聊 Agent `low`、闲聊小号 `off`)。
21
+ - 刻意**没有**做 Agent 侧的档位白名单(`reasoningEfforts`):它解决的是伪需求(真正会 400 的是端点能力,那已由模型声明兜住),却引入「差一个 s」的命名陷阱与一层交集规则。
22
+ - **`shared/reasoning.ts`**(公共 API,均从包入口导出):`resolveReasoningEffort()`、`resolveModelParams()`、`applyReasoningEffort()`(按声明写入,非法档位抛错)、`modelEfforts()` / `modelAllowedEfforts()` / `readReasoningEffort()` / `isReasoningEffort()` / `capabilityOf()`,以及 `ReasoningEffort` / `ReasoningCapability` 类型。
23
+ - **写入形态由声明决定**:`disable: "thinking"` → 同时维护 `thinking.type` 与 `reasoning_effort`(DeepSeek);`disable: "effort-none"` → 只写 `reasoning_effort`(OpenAI 没有 `thinking` 参数,写它会被当未知参数);未声明 `disable` → 只写 `reasoning_effort`,`off` 直接抛错(智谱 5.3 系)。
24
+ - **出厂模型按官方文档声明能力**(依据已写入 `constants.ts` 注释):
25
+ - `deepseek-flash` → `low/high/max`,可关闭(`api-docs.deepseek.com/guides/thinking_mode`)
26
+ - `glm-5.3` / `glm-5.3-flash` / `glm-5.3-flashx` → `low/high/max`,**不可关闭**(`docs.bigmodel.cn/cn/guide/capabilities/thinking`)
27
+ - `glm-4.7-flash` → 不可调(强制思考,`reasoning_effort` 仅 5.2+ 支持)
28
+ - `gpt-6-astra` / `gpt-6.1-sol` / `gpt-6-luna` → `minimal/low/medium/high`,可用 `reasoning_effort: "none"` 关闭
29
+ - 未声明的模型一律视为不可调档,不推测
30
+
31
+ ### 🔧 变更
32
+
33
+ - **`createModel(scope, modelId)` → `createModel(scope, modelId, overrides?: Pick<AgentDefinition, "reasoningEffort">)`** —— 新增的可选第三参由 `createAgent` 传入 `definition.reasoningEffort`。不传时行为与以前完全一致(原样返回模型 `defaultParams`)。
34
+
35
+ ### ✅ 测试
36
+
37
+ - 新增 `shared/reasoning.test.ts` 22 例:能力声明与出厂清单一致性、`off` 只对有 `disable` 的模型开放、GLM-5.3 设 `medium` 与 `off` 均抛错、OpenAI 永不写 `thinking` 键、往返一致性、Agent 覆盖与回退。
38
+ - 新增 `config/migrate.test.ts` 13 例:旧字段迁移(tokens / chars / 两者 / 都无 / 脏值)、`guard` 折算、已是新形状时不迁移、不改入参、部分迁移时未变条目保持同一引用。
39
+ - 全量 **57 文件 / 599 用例** 通过(6 skipped),`tsc --noEmit` 与 `build` 零错误。
40
+
41
+ ### 📄 Docs
42
+
43
+ - `docs/{zh,en}/sdk.md` 新增「思考强度(reasoning effort)」小节(三层模型、出厂声明表、写入形态、公共 API),核心实体表补充 `Model.reasoning` 与 Agent 的两个档位字段。
44
+
45
+ ## [1.0.0-alpha.12] - 2026-10-22
46
+
47
+ ### 💥 Breaking Changes
48
+
49
+ - **`createReadMcpResourceTool(mcpManager)` → `createReadMcpResourceTool(mcpManager, env: ToolEnv)`** —— 与 alpha.11 的 `createCallMcpTool` 对齐:`env` 用于读取 `maxToolOutput` 并解析当前模型绑定的端点。直接调用该工厂的下游需补第二参。
50
+ - **`view_image` 不再依赖 `deepseek` 端点** —— 此前本地图片固定上传到 `id === "deepseek"` 的端点,未配置时直接报「未配置 deepseek 端点,无法上传本地图片」。现在按能力位决定:声明 `capabilities.filesApi` 的端点走上传,其余走 base64 内联,没有 deepseek 也能查看本地图片。返回文案随之变化(含传输方式与降级说明)。
51
+ - **`Endpoint` 新增可选字段 `capabilities`** —— 类型层面非破坏性(可选),但语义重要:缺省视为「无特殊能力」,即不做上传。
52
+
53
+ ### ✨ 新增
54
+
55
+ - **「本地文件 → 内容块」统一入口 `shared/content-blocks.ts`**。这段逻辑此前散落三处各自为政(`ViewImageTool` 硬编码端点 id、MCP 媒体块归一、CLI 侧再实现一遍),现在收敛为一处:
56
+ - `fileToContentSection({ source, endpoint, model, signal, maxInlineBytes, deps })` → `{ section, transport, bytes, warning? }`;`source` 支持 `path` / `url` / `data`(`Uint8Array`)/ `base64` 四种来源。
57
+ - 决策链:http(s) URL → `image_url`;端点有 Files API → 上传得 `file_id`(失败**自动降级**为内联);其余端点 + 图片 → `image_url` + base64 data URL;其余端点 + 非图片 → `file` 块 + `file_data`;非视觉模型 + 图片 → 文本提示;内联超限且无法上传 → 抛错(不静默塞进请求体)。
58
+ - 配套:`resolveModelAndEndpoint()`(按发给 API 的模型名反查模型与端点)、`supportsFilesApi()`、`mimeOf()` / `toDataUrl()` / `base64ByteLength()` / `contentHash()`、`clearUploadCache()`、`DEFAULT_MAX_INLINE_BYTES`(8 MiB)。
59
+ - **已从包入口 `src/index.ts` 导出为公共 API**(`fileToContentSection` / `resolveModelAndEndpoint` / `supportsFilesApi` / `mimeOf` / `toDataUrl` / `base64ByteLength` / `contentHash` / `clearUploadCache` / `MIME_BY_EXT` / `DEFAULT_MAX_INLINE_BYTES`,以及 `FileSource` / `FileToSectionInput` / `FileToSectionResult` / `ContentTransport` / `UploadFn` / `EndpointCapabilities` 类型)。上层应用构造媒体消息时直接复用,不要各自再实现一遍「上传 or 内联」。
60
+ - 上传缓存:路径来源用「端点 id + 绝对路径 + mtime + size」,内存来源用内容 sha256(同一张图不重复上传),上限 200 条。
61
+ - `deps.upload` 可注入,便于测试与替换上传实现。
62
+ - **`Endpoint.capabilities.filesApi` 能力位** —— 出厂仅 `deepseek` 标记为 `true`(description 同步说明)。判据从「端点 id 是不是 deepseek」改为能力位,用户自建端点只要声明即可享受上传。
63
+
64
+ ### 🔧 修复
65
+
66
+ - **MCP 媒体不再一律 base64 内联**:`call_mcp_tool` 的 `image` / `audio` / `resource.blob` 改走统一入口,端点有 Files API 能力时上传后以 `file_id` 发送,请求体不再随历史反复膨胀;转换失败**不丢块**,退化为一条文本说明,避免整个工具调用因媒体问题失败。
67
+ - **`read_mcp_resource` 的二进制资源被静默丢弃**:此前只拼 `contents[].text`,图片 / PDF 等 `blob` 内容直接消失。新增 `resourceToMessageSection()`——注意 `readResource` 的 contents 形如 `{ uri, mimeType?, text|blob }`,**没有 `type` 判别字段**,不能复用 `toMessageSection`(否则文本资源会被 JSON 化);`blob` 交给统一入口。
68
+ - **`read_mcp_resource` 接入输出保护**:与 `call_mcp_tool` 共用同一套口径——文本超限落盘 `result.txt` 并返回头尾预览,媒体块原位保留;返回形态同样是「仅文本 → 字符串 / 含媒体 → 内容块数组」。
69
+ - **非视觉模型退化**:`Model.vision !== true` 时图片退化为一行文本提示(本地路径来源与 MCP 内存 base64 来源均生效),避免 `image_url` 被服务端 4xx 拒掉。
70
+ - **`edit` 并发护栏**:`edit` / `batch_edit` 工具描述明确「同一文件禁止并行 `edit`,多处修改改用 `batch_edit`,只有不同文件才能并行」——描述层劝阻(不引入运行时锁)。
71
+
72
+ ### ✅ 测试
73
+
74
+ - 新增 `shared/content-blocks.test.ts` 16 例;`mcpTools.test.ts` 扩展覆盖媒体统一入口、非视觉退化、`read` 超限落盘与混排保序;`ViewImageTool` / `EditTool` / `BatchEditTool` 断言同步到新语义。
75
+ - 全量 **55 文件 / 569 用例**通过(6 skipped),`tsc --noEmit` 与 `build` 零错误。
76
+
77
+ ### 📄 Docs
78
+
79
+ - `docs/{zh,en}/sdk.md` 新增「本地文件 → 内容块(统一入口)」小节、`Endpoint` 能力位与 MCP 媒体 / 退化 / 输出保护说明。
80
+ - `docs/{zh,en}/mcp.md` 同步 `call_mcp_tool` / `read_mcp_resource` 的媒体传输方式与输出保护口径。
81
+
82
+
3
83
  ## [1.0.0-alpha.11] - 2026-10-20
4
84
 
5
85
  ### 💥 Breaking Changes
@@ -4,8 +4,8 @@ import type { HandoffMode, HandoffService } from "../runtime/HandoffService.js";
4
4
  export interface AutoMigrateTriggerInfo {
5
5
  /** 当前 prompt token 用量 */
6
6
  promptTokens: number;
7
- /** 上下文阈值(当前模型的 maxContextTokens) */
8
- maxTokens: number;
7
+ /** 交接阈值(当前模型的 tokenThresholds.handoff) */
8
+ handoffTokens: number;
9
9
  }
10
10
  /**
11
11
  * 迁移执行者解析器:只负责「用什么方式交接」,不负责「何时迁移」。
@@ -19,7 +19,7 @@ export interface AutoMigrateOptions {
19
19
  /** 交接服务实例(持有交接前后钩子;编排 handoff / migrate 两条链路) */
20
20
  service: HandoffService;
21
21
  /** 触发迁移的上下文阈值 */
22
- maxTokens: number;
22
+ handoffTokens: number;
23
23
  /**
24
24
  * 执行者解析(可选)。上层可在此弹确认框、读用户偏好,
25
25
  * 或直接返回 `null` 表达「本次不迁移」——**无需**再继承本插件。
@@ -29,14 +29,14 @@ export interface AutoMigrateOptions {
29
29
  /**
30
30
  * 自动迁移插件 —— 只负责「何时触发」,实际迁移统一委托给持有的 `HandoffService`。
31
31
  *
32
- * 当 `usage.prompt_tokens` 超过 maxTokens 时触发迁移:
32
+ * 当 `usage.prompt_tokens` 超过 handoffTokens 时触发迁移:
33
33
  *
34
34
  * ```ts
35
35
  * agent.use(new AutoMigratePlugin({
36
36
  * service: handoffService,
37
- * maxTokens: 250_000,
38
- * resolveMode: async ({ promptTokens, maxTokens }) =>
39
- * (await askUser(promptTokens, maxTokens)) ? "self" : null,
37
+ * handoffTokens: 250_000,
38
+ * resolveMode: async ({ promptTokens, handoffTokens }) =>
39
+ * (await askUser(promptTokens, handoffTokens)) ? "self" : null,
40
40
  * }));
41
41
  * ```
42
42
  *
@@ -3,14 +3,14 @@ const log = getLogger();
3
3
  /**
4
4
  * 自动迁移插件 —— 只负责「何时触发」,实际迁移统一委托给持有的 `HandoffService`。
5
5
  *
6
- * 当 `usage.prompt_tokens` 超过 maxTokens 时触发迁移:
6
+ * 当 `usage.prompt_tokens` 超过 handoffTokens 时触发迁移:
7
7
  *
8
8
  * ```ts
9
9
  * agent.use(new AutoMigratePlugin({
10
10
  * service: handoffService,
11
- * maxTokens: 250_000,
12
- * resolveMode: async ({ promptTokens, maxTokens }) =>
13
- * (await askUser(promptTokens, maxTokens)) ? "self" : null,
11
+ * handoffTokens: 250_000,
12
+ * resolveMode: async ({ promptTokens, handoffTokens }) =>
13
+ * (await askUser(promptTokens, handoffTokens)) ? "self" : null,
14
14
  * }));
15
15
  * ```
16
16
  *
@@ -26,22 +26,22 @@ export class AutoMigratePlugin {
26
26
  }
27
27
  async onAfterSend(ctx) {
28
28
  const agent = ctx.agent;
29
- const { service, maxTokens, resolveMode } = this.options;
29
+ const { service, handoffTokens, resolveMode } = this.options;
30
30
  // 交接/迁移进行中:让行(防递归)
31
31
  if (service.busy)
32
32
  return;
33
33
  const promptTokens = agent.lastUsage?.prompt_tokens;
34
34
  if (promptTokens == null)
35
35
  return;
36
- if (promptTokens <= maxTokens)
36
+ if (promptTokens <= handoffTokens)
37
37
  return;
38
38
  try {
39
39
  const mode = resolveMode
40
- ? await resolveMode({ promptTokens, maxTokens })
40
+ ? await resolveMode({ promptTokens, handoffTokens })
41
41
  : undefined;
42
42
  if (mode === null)
43
43
  return; // 用户选择跳过本次迁移
44
- await service.migrate({ agent, promptTokens, maxTokens, mode: mode ?? undefined });
44
+ await service.migrate({ agent, promptTokens, handoffTokens, mode: mode ?? undefined });
45
45
  }
46
46
  catch (err) {
47
47
  log.error(`[autoMigrate] 迁移失败: ${err?.message ?? err}`);
@@ -1,44 +1,32 @@
1
1
  import type { AgentPlugin, SendContext } from "@ai-zen/agents-core";
2
2
  export interface ContextGuardOptions {
3
- /** 上下文告警阈值(token)。用量超过 maxTokens × ratio 即中断。 */
4
- maxTokens: number;
5
3
  /**
6
- * 超出比例。用量 > maxTokens × ratio 时判定为「严重超限」(如读入超大文件),
7
- * 在请求前抛出 ContextOverflowError 中断对话。默认 1.5(即 +50%)。
8
- */
9
- ratio?: number;
10
- /**
11
- * 是否处于「需要放宽」的阶段(例如交接回合)。返回 true 时改用 `suspendedRatio`。
4
+ * 强行阻断阈值(tokens,绝对值)。
12
5
  *
13
- * 为什么需要:交接恰好发生在上下文最满的时刻,而交接回合本身还要再发 1~2 次请求。
14
- * 若仍按 1.5 倍判定,正常交接会被护栏误杀;完全让行又会失去熔断,故改为放宽而非放行。
6
+ * 语义只有一条:请求前上一轮用量超过它 → 中断本轮。没有倍数、没有放宽档——
7
+ * 想调松就把它调大,「安全边界写多少就是多少」比「隐式乘以某个系数」可预测得多。
15
8
  */
16
- isSuspended?: () => boolean;
17
- /** 放宽后的比例,默认 2.0(即 maxTokens × 2) */
18
- suspendedRatio?: number;
9
+ guardTokens: number;
19
10
  }
20
11
  /**
21
12
  * 上下文安全护栏插件。
22
13
  *
23
- * 在每次内循环**发请求前**检测上一轮用量(usage.prompt_tokens):
24
- * - 若用量超过 maxTokens × ratio,说明上下文已严重超限(通常因读入超大文件),
25
- * 在请求前抛出 ContextOverflowError,**中断对话**,交由上层向用户报错。
14
+ * 在每次内循环**发请求前**检测上一轮用量(usage.prompt_tokens):超过 `guardTokens`
15
+ * 即抛 `ContextOverflowError` 中断对话,交由上层向用户报错。
26
16
  *
27
17
  * 与迁移插件职责分离:
28
- * - ContextGuardPlugin 只做「严重超限即中断」的安全兜底,绝不继续对话/迁移;
29
- * - AutoMigratePlugin 在正常范围内(超 maxTokens 但未达硬上限)做交接迁移。
30
- * - 交接回合由 `isSuspended` 放宽阈值(而非完全让行),保证交接不被误杀、又保留熔断。
18
+ * - `ContextGuardPlugin`:硬线,只做「严重超限即中断」的安全兜底,绝不继续对话/迁移;
19
+ * - `AutoMigratePlugin`:软线(`tokenThresholds.handoff`),在正常范围内做交接迁移。
20
+ * 两条线都是绝对值,`guard` 应当明显大于 `handoff`,交接回合的额外请求才有余量。
31
21
  *
32
22
  * ```ts
33
- * agent.use(new ContextGuardPlugin({
34
- * maxTokens: 250_000,
35
- * isSuspended: () => handoffService.busy,
36
- * }));
37
- * agent.use(new AutoMigratePlugin({ service: handoffService, maxTokens: 250_000 }));
23
+ * const { handoff, guard } = config.models[i].tokenThresholds;
24
+ * agent.use(new ContextGuardPlugin({ guardTokens: guard }));
25
+ * agent.use(new AutoMigratePlugin({ service: handoffService, handoffTokens: handoff }));
38
26
  * ```
39
27
  */
40
28
  export declare class ContextGuardPlugin implements AgentPlugin {
41
- private options;
29
+ private readonly guardTokens;
42
30
  constructor(options: ContextGuardOptions);
43
31
  onInnerLoopStart(ctx: SendContext): Promise<void>;
44
32
  }
@@ -2,39 +2,32 @@ import { ContextOverflowError } from "../shared/errors.js";
2
2
  /**
3
3
  * 上下文安全护栏插件。
4
4
  *
5
- * 在每次内循环**发请求前**检测上一轮用量(usage.prompt_tokens):
6
- * - 若用量超过 maxTokens × ratio,说明上下文已严重超限(通常因读入超大文件),
7
- * 在请求前抛出 ContextOverflowError,**中断对话**,交由上层向用户报错。
5
+ * 在每次内循环**发请求前**检测上一轮用量(usage.prompt_tokens):超过 `guardTokens`
6
+ * 即抛 `ContextOverflowError` 中断对话,交由上层向用户报错。
8
7
  *
9
8
  * 与迁移插件职责分离:
10
- * - ContextGuardPlugin 只做「严重超限即中断」的安全兜底,绝不继续对话/迁移;
11
- * - AutoMigratePlugin 在正常范围内(超 maxTokens 但未达硬上限)做交接迁移。
12
- * - 交接回合由 `isSuspended` 放宽阈值(而非完全让行),保证交接不被误杀、又保留熔断。
9
+ * - `ContextGuardPlugin`:硬线,只做「严重超限即中断」的安全兜底,绝不继续对话/迁移;
10
+ * - `AutoMigratePlugin`:软线(`tokenThresholds.handoff`),在正常范围内做交接迁移。
11
+ * 两条线都是绝对值,`guard` 应当明显大于 `handoff`,交接回合的额外请求才有余量。
13
12
  *
14
13
  * ```ts
15
- * agent.use(new ContextGuardPlugin({
16
- * maxTokens: 250_000,
17
- * isSuspended: () => handoffService.busy,
18
- * }));
19
- * agent.use(new AutoMigratePlugin({ service: handoffService, maxTokens: 250_000 }));
14
+ * const { handoff, guard } = config.models[i].tokenThresholds;
15
+ * agent.use(new ContextGuardPlugin({ guardTokens: guard }));
16
+ * agent.use(new AutoMigratePlugin({ service: handoffService, handoffTokens: handoff }));
20
17
  * ```
21
18
  */
22
19
  export class ContextGuardPlugin {
23
- options;
20
+ guardTokens;
24
21
  constructor(options) {
25
- this.options = options;
22
+ this.guardTokens = options.guardTokens;
26
23
  }
27
24
  async onInnerLoopStart(ctx) {
28
- const { agent } = ctx;
29
- const { maxTokens, ratio = 1.5, isSuspended, suspendedRatio = 2 } = this.options;
25
+ const promptTokens = ctx.agent.lastUsage?.prompt_tokens;
30
26
  // 首轮请求前尚无用量数据,跳过(只能在发出首轮请求、拿到 usage 后才有据可查)
31
- const promptTokens = agent.lastUsage?.prompt_tokens;
32
27
  if (promptTokens == null)
33
28
  return;
34
- // 交接回合等需要放宽的阶段:放宽阈值但保留熔断
35
- const effectiveRatio = isSuspended?.() ? suspendedRatio : ratio;
36
- if (promptTokens > maxTokens * effectiveRatio) {
37
- throw new ContextOverflowError(promptTokens, maxTokens, effectiveRatio);
29
+ if (promptTokens > this.guardTokens) {
30
+ throw new ContextOverflowError(promptTokens, this.guardTokens);
38
31
  }
39
32
  }
40
33
  }
@@ -8,8 +8,14 @@ export declare class ConfigManager {
8
8
  constructor(configPath: string);
9
9
  /**
10
10
  * 读取配置。文件不存在时返回出厂默认配置。
11
+ *
12
+ * 读到即**迁移**(`config/migrate.ts`):上层拿到的永远是当前字段形状,
13
+ * 旧字段(`maxContextTokens` / `maxContextChars`)不会漏到调用方。
14
+ * 迁移结果不在这里落盘——落盘只在 `ensureDefaultConfig()` 做一次。
11
15
  */
12
16
  read(): Promise<AppConfig>;
17
+ /** 读磁盘原文(不做任何迁移/同步),供 `ensureDefaultConfig()` 对比写盘用 */
18
+ private readRaw;
13
19
  write(config: AppConfig): Promise<void>;
14
20
  /**
15
21
  * 确保基础目录结构存在。
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { DEFAULT_AGENT_ID, DEFAULT_AGENT_DEFINITION, DEFAULT_SUBAGENT_ID, DEFAULT_SUBAGENT_DEFINITION, DEFAULT_APP_CONFIG, DEFAULT_MCP_CONFIG, CONFIG_SUB_DIRS, } from "./constants.js";
4
4
  import { FACTORY_SKILLS, isCustomSkill } from "./factory-skills.js";
5
+ import { migrateConfig } from "./migrate.js";
5
6
  /**
6
7
  * 配置管理器 — 负责配置文件的读写、目录初始化、默认实体创建。
7
8
  */
@@ -17,13 +18,22 @@ export class ConfigManager {
17
18
  // -----------------------------------------------------------------------
18
19
  /**
19
20
  * 读取配置。文件不存在时返回出厂默认配置。
21
+ *
22
+ * 读到即**迁移**(`config/migrate.ts`):上层拿到的永远是当前字段形状,
23
+ * 旧字段(`maxContextTokens` / `maxContextChars`)不会漏到调用方。
24
+ * 迁移结果不在这里落盘——落盘只在 `ensureDefaultConfig()` 做一次。
20
25
  */
21
26
  async read() {
27
+ const raw = await this.readRaw();
28
+ return raw ? migrateConfig(raw).config : { ...DEFAULT_APP_CONFIG };
29
+ }
30
+ /** 读磁盘原文(不做任何迁移/同步),供 `ensureDefaultConfig()` 对比写盘用 */
31
+ async readRaw() {
22
32
  try {
23
33
  await fs.access(this.configPath);
24
34
  }
25
35
  catch {
26
- return { ...DEFAULT_APP_CONFIG };
36
+ return null;
27
37
  }
28
38
  const raw = await fs.readFile(this.configPath, "utf-8");
29
39
  return JSON.parse(raw);
@@ -62,18 +72,16 @@ export class ConfigManager {
62
72
  * 条目视为用户自有,原样保留。同步结果与磁盘内容一致时不写盘。
63
73
  */
64
74
  async ensureDefaultConfig() {
65
- let existing;
66
- try {
67
- await fs.access(this.configPath);
68
- existing = await this.read();
69
- }
70
- catch {
75
+ const onDisk = await this.readRaw();
76
+ if (!onDisk) {
71
77
  await this.ensureDirs();
72
78
  await this.write(DEFAULT_APP_CONFIG);
73
79
  return { ...DEFAULT_APP_CONFIG };
74
80
  }
75
- const synced = syncManagedModels(existing);
76
- if (JSON.stringify(synced) !== JSON.stringify(existing)) {
81
+ // 先迁移历史字段,再同步出厂模型清单;两者结果与磁盘原文不同才写盘
82
+ const migrated = migrateConfig(onDisk).config;
83
+ const synced = syncManagedModels(migrated);
84
+ if (JSON.stringify(synced) !== JSON.stringify(onDisk)) {
77
85
  await this.write(synced);
78
86
  }
79
87
  return synced;
@@ -82,7 +82,10 @@ export const DEFAULT_APP_CONFIG = {
82
82
  name: "DeepSeek",
83
83
  apiKey: "",
84
84
  baseUrl: "https://api.deepseek.com/v1",
85
- description: "DeepSeek API 端点",
85
+ description: "DeepSeek API 端点(支持 Files API:本地文件上传后以 file_id 引用)",
86
+ // 唯一声明了 Files API 能力的出厂端点;其余端点本地文件走 base64 内联。
87
+ // 判断依据是能力位而非端点 id,用户自建端点只要声明即可享受上传。
88
+ capabilities: { filesApi: true },
86
89
  },
87
90
  ],
88
91
  // 模型清单(2026-10-03 依据各厂商官方来源核对):
@@ -95,9 +98,20 @@ export const DEFAULT_APP_CONFIG = {
95
98
  // 标了 `custom: true` 的条目视为用户自有,原样保留。实现见
96
99
  // ConfigManager.syncManagedModels。
97
100
  //
98
- // maxContextTokens 是「任务迁移(交接)触发阈值」,不是模型的真实上下文窗口:
99
- // 迁移判断只看 API 响应返回的 usage.prompt_tokens。窗口 ≥1M 的模型统一取 250K
100
- // (约 25%),glm-4.7-flash 窗口仅 200K,取 100K。
101
+ // tokenThresholds 的两条线都与模型的真实上下文窗口无关:
102
+ // handoff(软线,交接触发)与 guard(硬线,请求前强行阻断)。
103
+ // 出厂取值:窗口 ≥1M 的模型统一 handoff=250K / guard=375K(约窗口的 25% / 37.5%),
104
+ // glm-4.7-flash 窗口仅 200K,取 handoff=100K / guard=150K。
105
+ //
106
+ // reasoning(思考强度能力)按厂商官方文档声明,**不要推测**:
107
+ // OpenAI → openai-node 的 Shared.ReasoningEffort(none/minimal/low/medium/high/xhigh/max);
108
+ // 关闭用 reasoning_effort:"none"(OpenAI 没有 thinking 参数,写它会被当未知参数)
109
+ // DeepSeek → api-docs.deepseek.com/guides/thinking_mode:可调 low/high/max;
110
+ // 关闭用 thinking.type:"disabled";默认开思考且默认 effort=high
111
+ // 智谱 → docs.bigmodel.cn/cn/guide/capabilities/thinking:GLM-5.3 系**只认**
112
+ // low/high/max(其余输入报错),且不再支持关闭思考(传 disabled 会报错);
113
+ // GLM-4.7 强制思考且无 reasoning_effort 参数(仅 5.2+ 支持)
114
+ // 未声明 reasoning 的模型 = 不可调档,`modelAllowedEfforts()` 返回空数组。
101
115
  models: [
102
116
  // ---------- OpenAI ----------
103
117
  {
@@ -105,9 +119,10 @@ export const DEFAULT_APP_CONFIG = {
105
119
  name: "GPT-6 Astra",
106
120
  endpointId: "openai",
107
121
  modelName: "gpt-6-astra",
108
- maxContextTokens: 250_000,
122
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
109
123
  defaultParams: {},
110
124
  vision: true,
125
+ reasoning: { efforts: ["minimal", "low", "medium", "high"], disable: "effort-none" },
111
126
  description: "OpenAI 最强旗舰(2026-09):高级分析、软件工程、深度研究与科学工作",
112
127
  },
113
128
  {
@@ -115,9 +130,10 @@ export const DEFAULT_APP_CONFIG = {
115
130
  name: "GPT-6.1 Sol",
116
131
  endpointId: "openai",
117
132
  modelName: "gpt-6.1-sol",
118
- maxContextTokens: 250_000,
133
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
119
134
  defaultParams: {},
120
135
  vision: true,
136
+ reasoning: { efforts: ["minimal", "low", "medium", "high"], disable: "effort-none" },
121
137
  description: "OpenAI 最新旗舰(2026-09-29):Agentic Coding、计算机操作与文档密集型专业任务",
122
138
  },
123
139
  {
@@ -125,9 +141,10 @@ export const DEFAULT_APP_CONFIG = {
125
141
  name: "GPT-6 Luna",
126
142
  endpointId: "openai",
127
143
  modelName: "gpt-6-luna",
128
- maxContextTokens: 250_000,
144
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
129
145
  defaultParams: {},
130
146
  vision: true,
147
+ reasoning: { efforts: ["minimal", "low", "medium", "high"], disable: "effort-none" },
131
148
  description: "OpenAI GPT-6 系列快速低成本模型:适合高吞吐、低延迟场景",
132
149
  },
133
150
  // ---------- 智谱 BigModel ----------
@@ -136,8 +153,9 @@ export const DEFAULT_APP_CONFIG = {
136
153
  name: "GLM-5.3",
137
154
  endpointId: "bigmodelcn",
138
155
  modelName: "glm-5.3",
139
- maxContextTokens: 250_000,
156
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
140
157
  defaultParams: { thinking: { type: "enabled" }, reasoning_effort: "max" },
158
+ reasoning: { efforts: ["low", "high", "max"] },
141
159
  description: "智谱最新旗舰:复杂软件工程与长程 Agent 任务(强制开启思考,1M 上下文)",
142
160
  },
143
161
  {
@@ -145,9 +163,10 @@ export const DEFAULT_APP_CONFIG = {
145
163
  name: "GLM-5.3-Flash",
146
164
  endpointId: "bigmodelcn",
147
165
  modelName: "glm-5.3-flash",
148
- maxContextTokens: 250_000,
166
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
149
167
  defaultParams: { thinking: { type: "enabled" }, reasoning_effort: "max" },
150
168
  vision: true,
169
+ reasoning: { efforts: ["low", "high", "max"] },
151
170
  description: "智谱原生多模态模型(图片/视频/文件):视觉 Coding,1M 上下文",
152
171
  },
153
172
  {
@@ -155,9 +174,10 @@ export const DEFAULT_APP_CONFIG = {
155
174
  name: "GLM-5.3-FlashX",
156
175
  endpointId: "bigmodelcn",
157
176
  modelName: "glm-5.3-flashx",
158
- maxContextTokens: 250_000,
177
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
159
178
  defaultParams: { thinking: { type: "enabled" }, reasoning_effort: "max" },
160
179
  vision: true,
180
+ reasoning: { efforts: ["low", "high", "max"] },
161
181
  description: "GLM-5.3-Flash 高速版(200 tokens/s),原生多模态",
162
182
  },
163
183
  {
@@ -166,8 +186,9 @@ export const DEFAULT_APP_CONFIG = {
166
186
  endpointId: "bigmodelcn",
167
187
  modelName: "glm-4.7-flash",
168
188
  // 该模型真实窗口仅 200K,阈值下调至 100K(约窗口的 50%),留足 response 空间
169
- maxContextTokens: 100_000,
189
+ tokenThresholds: { handoff: 100_000, guard: 150_000 },
170
190
  defaultParams: {},
191
+ reasoning: { efforts: [] },
171
192
  description: "智谱免费文本模型:200K 上下文,适合轻量任务",
172
193
  },
173
194
  // ---------- DeepSeek ----------
@@ -176,9 +197,10 @@ export const DEFAULT_APP_CONFIG = {
176
197
  name: "DeepSeek-V4.1-Flash",
177
198
  endpointId: "deepseek",
178
199
  modelName: "deepseek-flash",
179
- maxContextTokens: 250_000,
200
+ tokenThresholds: { handoff: 250_000, guard: 375_000 },
180
201
  defaultParams: { thinking: { type: "enabled" } },
181
202
  vision: true,
203
+ reasoning: { efforts: ["low", "high", "max"], disable: "thinking" },
182
204
  description: "DeepSeek 旗舰(2026-09-10):552B MoE 非对称架构,原生多模态,1M 上下文",
183
205
  },
184
206
  ],
@@ -0,0 +1,47 @@
1
+ /**
2
+ * 配置字段迁移 —— 历史形状升级到当前形状
3
+ *
4
+ * 三条遗留字段被淘汰:
5
+ *
6
+ * 1. `Model.maxContextTokens` / `Model.maxContextChars` → **`Model.tokenThresholds`**
7
+ * 旧名字一直在误导:它既不是「模型的真实上下文窗口」,也不是单一概念。现在拆成两条线:
8
+ * `handoff`(交接阈值,软线)与 `guard`(强行阻断阈值,硬线)。
9
+ * `maxContextChars` 是 token 口径之前的历史遗留,按 `floor(chars / 4)` 折成 tokens
10
+ * (与旧代码的回退算法一致)后并入 `handoff`。
11
+ *
12
+ * 迁移时机:`ConfigManager.read()` 读出即迁(内存里永远是当前形状);
13
+ * 落盘由 `ensureDefaultConfig()` 负责 —— 它比较迁移结果与磁盘原文,不同才写回,
14
+ * 因此迁移只发生一次,且不会在每次启动时无谓地改写文件。
15
+ */
16
+ import type { AppConfig, Model } from "../types/index.js";
17
+ /** 当前字段名 */
18
+ export declare const TOKEN_THRESHOLDS_KEY = "tokenThresholds";
19
+ /** 已淘汰的旧字段名(仅迁移代码认识它们) */
20
+ export declare const LEGACY_TOKEN_FIELD = "maxContextTokens";
21
+ export declare const LEGACY_CHARS_FIELD = "maxContextChars";
22
+ /**
23
+ * 旧数据缺失阈值时的交接阈值兜底。
24
+ *
25
+ * 与 CLI 新建模型的默认值一致;宁可给一个保守值,也不要让模型缺阈值——
26
+ * 缺阈值意味着既没有交接触发、也没有阻断保护。
27
+ */
28
+ export declare const DEFAULT_HANDOFF_THRESHOLD = 128000;
29
+ /**
30
+ * 迁移单个模型条目。
31
+ *
32
+ * 优先级:已有 `tokenThresholds` 的成员 > 旧 tokens 字段 > 旧 chars 字段折算 > 兜底默认。
33
+ * 旧字段一律删除(连同一份数据里残留的脏值),保证迁移后的形状唯一。
34
+ * 已有 `handoff` 但缺 `guard` 时按 1.5 倍补齐。
35
+ */
36
+ export declare function migrateModelThresholds(model: Model): {
37
+ model: Model;
38
+ changed: boolean;
39
+ };
40
+ /**
41
+ * 迁移整个配置。只处理对话模型(`models`)——
42
+ * 图片模型(`imageModels`)从来没有上下文阈值概念。
43
+ */
44
+ export declare function migrateConfig(config: AppConfig): {
45
+ config: AppConfig;
46
+ changed: boolean;
47
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 配置字段迁移 —— 历史形状升级到当前形状
3
+ *
4
+ * 三条遗留字段被淘汰:
5
+ *
6
+ * 1. `Model.maxContextTokens` / `Model.maxContextChars` → **`Model.tokenThresholds`**
7
+ * 旧名字一直在误导:它既不是「模型的真实上下文窗口」,也不是单一概念。现在拆成两条线:
8
+ * `handoff`(交接阈值,软线)与 `guard`(强行阻断阈值,硬线)。
9
+ * `maxContextChars` 是 token 口径之前的历史遗留,按 `floor(chars / 4)` 折成 tokens
10
+ * (与旧代码的回退算法一致)后并入 `handoff`。
11
+ *
12
+ * 迁移时机:`ConfigManager.read()` 读出即迁(内存里永远是当前形状);
13
+ * 落盘由 `ensureDefaultConfig()` 负责 —— 它比较迁移结果与磁盘原文,不同才写回,
14
+ * 因此迁移只发生一次,且不会在每次启动时无谓地改写文件。
15
+ */
16
+ /** 当前字段名 */
17
+ export const TOKEN_THRESHOLDS_KEY = "tokenThresholds";
18
+ /** 已淘汰的旧字段名(仅迁移代码认识它们) */
19
+ export const LEGACY_TOKEN_FIELD = "maxContextTokens";
20
+ export const LEGACY_CHARS_FIELD = "maxContextChars";
21
+ /**
22
+ * 旧数据缺失阈值时的交接阈值兜底。
23
+ *
24
+ * 与 CLI 新建模型的默认值一致;宁可给一个保守值,也不要让模型缺阈值——
25
+ * 缺阈值意味着既没有交接触发、也没有阻断保护。
26
+ */
27
+ export const DEFAULT_HANDOFF_THRESHOLD = 128_000;
28
+ function toPositiveInt(value) {
29
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
30
+ return undefined;
31
+ return Math.floor(value);
32
+ }
33
+ function readThresholds(value) {
34
+ if (typeof value !== "object" || value === null)
35
+ return {};
36
+ const raw = value;
37
+ return {
38
+ handoff: toPositiveInt(raw.handoff),
39
+ guard: toPositiveInt(raw.guard),
40
+ };
41
+ }
42
+ /**
43
+ * 迁移单个模型条目。
44
+ *
45
+ * 优先级:已有 `tokenThresholds` 的成员 > 旧 tokens 字段 > 旧 chars 字段折算 > 兜底默认。
46
+ * 旧字段一律删除(连同一份数据里残留的脏值),保证迁移后的形状唯一。
47
+ * 已有 `handoff` 但缺 `guard` 时按 1.5 倍补齐。
48
+ */
49
+ export function migrateModelThresholds(model) {
50
+ const source = model;
51
+ const current = readThresholds(source[TOKEN_THRESHOLDS_KEY]);
52
+ const legacyTokens = toPositiveInt(source[LEGACY_TOKEN_FIELD]);
53
+ const legacyChars = toPositiveInt(source[LEGACY_CHARS_FIELD]);
54
+ const legacySingle = toPositiveInt(source.tokenThreshold);
55
+ const hasLegacy = LEGACY_TOKEN_FIELD in source ||
56
+ LEGACY_CHARS_FIELD in source ||
57
+ "tokenThreshold" in source;
58
+ const complete = current.handoff !== undefined && current.guard !== undefined;
59
+ if (!hasLegacy && complete)
60
+ return { model, changed: false };
61
+ const fromChars = legacyChars !== undefined ? Math.max(1, Math.floor(legacyChars / 4)) : undefined;
62
+ const handoff = current.handoff ?? legacyTokens ?? fromChars ?? legacySingle ?? DEFAULT_HANDOFF_THRESHOLD;
63
+ // 旧数据里没有 guard 这一项:按重构前 ContextGuardPlugin 的默认 ratio(1.5)折算一次,
64
+ // 让迁移后的实际行为与迁移前一致。这是**一次性**换算,落盘后两个值都是整数,
65
+ // 运行时不再有任何倍数关系(想调硬线就直接改 guard 的数字)。
66
+ const guard = current.guard ?? Math.round(handoff * 1.5);
67
+ const next = { ...source };
68
+ delete next[LEGACY_TOKEN_FIELD];
69
+ delete next[LEGACY_CHARS_FIELD];
70
+ delete next.tokenThreshold;
71
+ next[TOKEN_THRESHOLDS_KEY] = { handoff, guard };
72
+ return { model: next, changed: true };
73
+ }
74
+ /**
75
+ * 迁移整个配置。只处理对话模型(`models`)——
76
+ * 图片模型(`imageModels`)从来没有上下文阈值概念。
77
+ */
78
+ export function migrateConfig(config) {
79
+ let changed = false;
80
+ const models = (config.models ?? []).map((model) => {
81
+ const result = migrateModelThresholds(model);
82
+ if (result.changed)
83
+ changed = true;
84
+ return result.model;
85
+ });
86
+ if (!changed)
87
+ return { config, changed };
88
+ return { config: { ...config, models }, changed };
89
+ }