@ai-zen/agents-sdk 1.0.0-alpha.20 → 1.0.0-alpha.22
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 +46 -0
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/dist/config/constants.js +5 -0
- package/dist/config/migrate.d.ts +10 -2
- package/dist/config/migrate.js +23 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/runtime/HandoffService.d.ts +46 -0
- package/dist/runtime/HandoffService.js +108 -8
- package/dist/runtime/createAgent.d.ts +7 -3
- package/dist/runtime/createAgent.js +5 -3
- package/dist/types/index.d.ts +22 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.0.0-alpha.22] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
> 中断策略(`abortedMessageStrategy` / `abortedToolRoundStrategy`)从「调用方各自传参」
|
|
6
|
+
> 升级为**配置项**:写进 `config.json`,`createAgent()` 自动读取。
|
|
7
|
+
|
|
8
|
+
### ✨ 新增
|
|
9
|
+
|
|
10
|
+
- **`AppConfig` 新增两个策略字段**(`types/index.ts`):
|
|
11
|
+
- `abortedMessageStrategy?: "keep" | "drop"` —— 被中断的 assistant 已输出的思考 / 正文是否保留。`keep` 让模型下一轮能接着半截往下说,不再「失忆重来」。
|
|
12
|
+
- `abortedToolRoundStrategy?: "fill" | "drop"` —— 被中断 / 结果不完整的工具轮次如何归一。`fill` 整轮保留并给未完成结果补原因,保证 `tool_calls` 与结果一一配对。
|
|
13
|
+
- **出厂默认写 `keep` + `fill`**(`DEFAULT_APP_CONFIG`):`keep` 是产品层结论(core 的库级默认仍是 `drop`),`fill` 与 core 默认一致、无行为变化。
|
|
14
|
+
- **`createAgent()` 从 `scope.config` 取策略**:优先级为「显式参数 > `scope.config` > core 默认值」。上游(CLI / Desktop)不必自己读配置再转一道参数,子 Agent 创建时也自然继承同一份策略。
|
|
15
|
+
- **配置迁移补齐策略字段**(`migrateConfig`):缺失或非法的 `abortedMessageStrategy` / `abortedToolRoundStrategy` 一律写回出厂默认(`changed` 置真,`ensureDefaultConfig()` 会落盘一次)。于是「配置文件里写着的就是运行时用的」,各端不再需要 `?? "keep"` 式兜底。
|
|
16
|
+
|
|
17
|
+
### ✅ 测试
|
|
18
|
+
|
|
19
|
+
- 全量 **57 文件 / 612 例通过**(6 skipped,alpha.21 时 608 例)。
|
|
20
|
+
- `migrate.test.ts` 20 → 22 例:补齐缺失策略、非法值改写、合法值原样保留且 `changed=false`;原「无需迁移返回原对象」用例补上策略字段。
|
|
21
|
+
- `createAgent.test.ts` 6 → 8 例:策略取自 `scope.config`、显式参数覆盖配置、config 未写时回落 core 默认(`drop` / `fill`)。
|
|
22
|
+
|
|
23
|
+
## [1.0.0-alpha.21] - 2026-10-28
|
|
24
|
+
|
|
25
|
+
### ✨ 新增
|
|
26
|
+
|
|
27
|
+
- **交接/迁移支持取消(`AbortSignal`)** —— 此前 `handoff()` / `migrate()` 一旦开始就没有任何中止手段,宿主按 Ctrl+C 也停不下来:
|
|
28
|
+
- `summary` 模式的独立模型请求根本没有任何取消点;
|
|
29
|
+
- `self` 模式能被 `agent.abort()` 打断,但中止后被误判成「模型没写出文档」,于是重试一轮、再降级 `summary` 再跑一轮——用户以为取消了,实际多烧两轮全量输入。
|
|
30
|
+
|
|
31
|
+
现在 `HandoffParams` 新增 `signal?: AbortSignal`,两个执行者都认它:
|
|
32
|
+
|
|
33
|
+
- `summary`:`signal` 作为 **RequestOptions(第二参数)** 传给 `chat.completions.create`,请求会真的被中断;
|
|
34
|
+
- `self`:每个 `agent.send()` 之后**主动检查** `aborted`(因为 `abort()` 不抛错),取消即抛 `HandoffAbortedError`,**不重试、不降级**;
|
|
35
|
+
- `runHandoff()` 在阶段之间加取消点;`migrate()` 被取消时**不改动会话历史**(不会留下「已迁移」的假象)。
|
|
36
|
+
|
|
37
|
+
- **导出 `HandoffAbortedError`** —— 取消单独成类型,调用方才能把「用户按了 Ctrl+C」与「交接真的失败」分开处理:前者静默收尾,后者才该报错。
|
|
38
|
+
|
|
39
|
+
### 🐛 修复
|
|
40
|
+
|
|
41
|
+
- **取消后清掉写了一半的交接文档** —— 只删本次新建的(同名旧文件不碰)。半成品比没有更糟:下一轮接手方会把它当成真实交接读进去。
|
|
42
|
+
|
|
43
|
+
### ✅ 测试
|
|
44
|
+
|
|
45
|
+
- 全量 **57 文件 / 608 例通过**(6 skipped)。
|
|
46
|
+
- `HandoffService.test.ts` **24 → 30 例**:signal 透传 RequestOptions、summary 取消不落盘、self 取消不重试不降级、半成品清理、migrate 取消不改历史、未取消时不误伤正常路径。
|
|
47
|
+
- 破坏性验证:删掉 `self` 的取消检查 → 「不重试、不降级」用例红。
|
|
48
|
+
|
|
3
49
|
## [1.0.0-alpha.20] - 2026-10-27
|
|
4
50
|
|
|
5
51
|
### ✨ 新增
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Desktop ──┘ │
|
|
|
21
21
|
types ← pure types, zero business dependencies (incl. ToolEnv)
|
|
22
22
|
config ← ConfigManager + constants: config.json read/write + directory setup + factory defaults
|
|
23
23
|
crud ← capability-entity CRUD (Agent definitions; conversations/drafts are persisted by each consumer)
|
|
24
|
-
runtime ← model factory + Agent assembly + task handoff/migration (createModel / createAgent / SdkAgent / HandoffService / HandoffGuardPlugin)
|
|
24
|
+
runtime ← model factory + Agent assembly + task handoff/migration (cancellable via `AbortSignal`; createModel / createAgent / SdkAgent / HandoffService / HandoffGuardPlugin)
|
|
25
25
|
scope ← Scope orchestration container + ScopePlugin contract + PermissionEvaluator / disclosure + plugins/ (5 self-contained capability sources: discovery + tools + plugin)
|
|
26
26
|
agent-plugins ← Agent plugins (autoMigrate, autoRefreshTools, contextGuard, unknownToolHint)
|
|
27
27
|
shared ← logging, errors
|
|
@@ -125,6 +125,8 @@ Conditionally injected based on the active model / config:
|
|
|
125
125
|
|
|
126
126
|
Tool output protection: `AppConfig.maxToolOutput` (in characters, default 32768) is the unified ceiling for tool output. Each tool handles overflow itself — `exec` dumps `stdout.log` / `stderr.log` separately (returning head/tail previews per stream), `find_text` / `glob` / `ls` dump `result.json` (returning a head preview), and `read` only warns and suggests batched reads via `range`. Dumps go to `<tmpdir>/ai-zen/tool-output/<tool>-<timestamp>-<random>/`, one directory per call, with no self-cleanup. See [`docs/sdk-design-v1.md` §12](docs/sdk-design-v1.md).
|
|
127
127
|
|
|
128
|
+
Interrupt strategies live in the config: `AppConfig.abortedMessageStrategy` (`keep` / `drop`, factory `keep` — the model can continue the half-sentence after an interrupt) and `AppConfig.abortedToolRoundStrategy` (`fill` / `drop`, factory `fill` — whole rounds go in or out, because `tool_calls` must pair with results). `createAgent()` reads them from `scope.config` when no explicit option is passed, keeping a single source of truth.
|
|
129
|
+
|
|
128
130
|
## Built-in Plugins
|
|
129
131
|
|
|
130
132
|
| Plugin | Description |
|
package/README.zh.md
CHANGED
|
@@ -20,7 +20,7 @@ Desktop ──┘ │
|
|
|
20
20
|
types ← 纯类型,零业务依赖(含 ToolEnv 工具环境)
|
|
21
21
|
config ← ConfigManager + constants:读写 config.json + 目录初始化 + 出厂默认
|
|
22
22
|
crud ← 能力实体 CRUD(Agent 定义等;会话/草稿已下放给各端自行持久化)
|
|
23
|
-
runtime ← 模型工厂 + Agent 组装 +
|
|
23
|
+
runtime ← 模型工厂 + Agent 组装 + 任务交接/迁移(可 `AbortSignal` 取消;createModel / createAgent / SdkAgent / HandoffService / HandoffGuardPlugin)
|
|
24
24
|
scope ← Scope 编排容器 + ScopePlugin 契约 + PermissionEvaluator / disclosure + plugins/(5 个自包含能力来源:发现 + 工具 + 插件)
|
|
25
25
|
agent-plugins ← Agent 插件(autoMigrate、autoRefreshTools、contextGuard、unknownToolHint)
|
|
26
26
|
shared ← 日志、错误
|
|
@@ -124,6 +124,8 @@ await scope.dispose(); // 断开 MCP、释放插件资源
|
|
|
124
124
|
|
|
125
125
|
工具输出保护:`AppConfig.maxToolOutput`(字符数,缺省 32768)是工具输出的统一上限,超限时由各工具自行处置——`exec` 分文件落盘 `stdout.log` / `stderr.log`(返回各流头尾预览),`find_text` / `glob` / `ls` 落盘 `result.json`(返回头部预览),`read` 仅警告并提示用 `range` 分批读取。落盘目录为 `<tmpdir>/ai-zen/tool-output/<工具名>-<时间戳>-<随机串>/`,每次调用独立、不自清理。详见 [`docs/sdk-design-v1.md` §12](docs/sdk-design-v1.md)。
|
|
126
126
|
|
|
127
|
+
中断策略进配置:`AppConfig.abortedMessageStrategy`(`keep` / `drop`,出厂 `keep`——中断后模型能接着半截往下说)与 `AppConfig.abortedToolRoundStrategy`(`fill` / `drop`,出厂 `fill`——整轮进出,`tool_calls` 必须与结果配对)。`createAgent()` 不传参数时直接取 `scope.config`,于是策略只有一个真相源。
|
|
128
|
+
|
|
127
129
|
## 内置插件
|
|
128
130
|
|
|
129
131
|
| 插件 | 说明 |
|
package/dist/config/constants.js
CHANGED
|
@@ -237,6 +237,11 @@ export const DEFAULT_APP_CONFIG = {
|
|
|
237
237
|
defaultMigrationModel: "deepseek-flash",
|
|
238
238
|
// 工具输出上限(字符数),与 DEFAULT_MAX_TOOL_OUTPUT 保持一致
|
|
239
239
|
maxToolOutput: 32_768,
|
|
240
|
+
// 中断策略:出厂取「保留已输出的内容」—— 中断后模型能接着半截往下说,
|
|
241
|
+
// 而不是「失忆重来」(core 的库级默认是 drop,产品层认为那是坑);
|
|
242
|
+
// 工具轮次保持整轮进出(fill),服务端要求 tool_calls 与结果一一配对。
|
|
243
|
+
abortedMessageStrategy: "keep",
|
|
244
|
+
abortedToolRoundStrategy: "fill",
|
|
240
245
|
};
|
|
241
246
|
// ---------------------------------------------------------------------------
|
|
242
247
|
// 默认 MCP 配置(socket-pty 终端 MCP + chrome-devtools 浏览器调试 MCP)
|
package/dist/config/migrate.d.ts
CHANGED
|
@@ -58,10 +58,18 @@ export declare function migrateModelReasoning(model: Model): {
|
|
|
58
58
|
* 迁移整个配置。只处理对话模型(`models`)——
|
|
59
59
|
* 图片模型(`imageModels`)既没有上下文阈值、也没有思考概念。
|
|
60
60
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* 同时做两件字段级收敛:
|
|
62
|
+
* - 清除已废弃的 `defaultModel`(模型真相源已改为 `AgentDefinition.modelId`);
|
|
63
|
+
* - 补齐中断策略(`abortedMessageStrategy` / `abortedToolRoundStrategy`):把缺失或
|
|
64
|
+
* 非法值写成出厂默认。这样「配置即事实」——文件里写着的就是运行时用的,不再需要
|
|
65
|
+
* 使用方各自 `?? "keep"` 地兵分两路兜底。
|
|
63
66
|
*/
|
|
64
67
|
export declare function migrateConfig(config: AppConfig): {
|
|
65
68
|
config: AppConfig;
|
|
66
69
|
changed: boolean;
|
|
67
70
|
};
|
|
71
|
+
/** 合法的中断策略取值(与 core 的枚举一致) */
|
|
72
|
+
export declare const ABORTED_MESSAGE_STRATEGIES: readonly ["keep", "drop"];
|
|
73
|
+
export declare const ABORTED_TOOL_ROUND_STRATEGIES: readonly ["fill", "drop"];
|
|
74
|
+
/** 缺失 / 非法 → 出厂默认;返回是否有改动(直接改写传入对象) */
|
|
75
|
+
export declare function normalizeAbortStrategies(config: AppConfig): boolean;
|
package/dist/config/migrate.js
CHANGED
|
@@ -103,8 +103,11 @@ export function migrateModelReasoning(model) {
|
|
|
103
103
|
* 迁移整个配置。只处理对话模型(`models`)——
|
|
104
104
|
* 图片模型(`imageModels`)既没有上下文阈值、也没有思考概念。
|
|
105
105
|
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
106
|
+
* 同时做两件字段级收敛:
|
|
107
|
+
* - 清除已废弃的 `defaultModel`(模型真相源已改为 `AgentDefinition.modelId`);
|
|
108
|
+
* - 补齐中断策略(`abortedMessageStrategy` / `abortedToolRoundStrategy`):把缺失或
|
|
109
|
+
* 非法值写成出厂默认。这样「配置即事实」——文件里写着的就是运行时用的,不再需要
|
|
110
|
+
* 使用方各自 `?? "keep"` 地兵分两路兜底。
|
|
108
111
|
*/
|
|
109
112
|
export function migrateConfig(config) {
|
|
110
113
|
let changed = false;
|
|
@@ -121,7 +124,25 @@ export function migrateConfig(config) {
|
|
|
121
124
|
delete next.defaultModel;
|
|
122
125
|
changed = true;
|
|
123
126
|
}
|
|
127
|
+
if (normalizeAbortStrategies(next))
|
|
128
|
+
changed = true;
|
|
124
129
|
if (!changed)
|
|
125
130
|
return { config, changed };
|
|
126
131
|
return { config: next, changed };
|
|
127
132
|
}
|
|
133
|
+
/** 合法的中断策略取值(与 core 的枚举一致) */
|
|
134
|
+
export const ABORTED_MESSAGE_STRATEGIES = ["keep", "drop"];
|
|
135
|
+
export const ABORTED_TOOL_ROUND_STRATEGIES = ["fill", "drop"];
|
|
136
|
+
/** 缺失 / 非法 → 出厂默认;返回是否有改动(直接改写传入对象) */
|
|
137
|
+
export function normalizeAbortStrategies(config) {
|
|
138
|
+
let changed = false;
|
|
139
|
+
if (!ABORTED_MESSAGE_STRATEGIES.includes(config.abortedMessageStrategy)) {
|
|
140
|
+
config.abortedMessageStrategy = DEFAULT_APP_CONFIG.abortedMessageStrategy;
|
|
141
|
+
changed = true;
|
|
142
|
+
}
|
|
143
|
+
if (!ABORTED_TOOL_ROUND_STRATEGIES.includes(config.abortedToolRoundStrategy)) {
|
|
144
|
+
config.abortedToolRoundStrategy = DEFAULT_APP_CONFIG.abortedToolRoundStrategy;
|
|
145
|
+
changed = true;
|
|
146
|
+
}
|
|
147
|
+
return changed;
|
|
148
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -35,7 +35,7 @@ export { McpConnectionManager } from "./scope/plugins/mcp/McpConnectionManager.j
|
|
|
35
35
|
export type { McpConnectOptions } from "./scope/plugins/mcp/McpConnectionManager.js";
|
|
36
36
|
export { SdkCallbackTool } from "./scope/plugins/builtin/SdkCallbackTool.js";
|
|
37
37
|
export type { SdkCallbackToolOptions } from "./scope/plugins/builtin/SdkCallbackTool.js";
|
|
38
|
-
export { HandoffService } from "./runtime/HandoffService.js";
|
|
38
|
+
export { HandoffAbortedError, HandoffService } from "./runtime/HandoffService.js";
|
|
39
39
|
export type { HandoffContext, HandoffMode, HandoffParams, HandoffServiceOptions, MigrateParams, MigrationStrategy, } from "./runtime/HandoffService.js";
|
|
40
40
|
export { HandoffGuardPlugin } from "./runtime/HandoffGuardPlugin.js";
|
|
41
41
|
export { AutoMigratePlugin } from "./agent-plugins/AutoMigratePlugin.js";
|
package/dist/index.js
CHANGED
|
@@ -33,7 +33,7 @@ export { SdkAgent } from "./runtime/SdkAgent.js";
|
|
|
33
33
|
export { createAgent } from "./runtime/createAgent.js";
|
|
34
34
|
export { McpConnectionManager } from "./scope/plugins/mcp/McpConnectionManager.js";
|
|
35
35
|
export { SdkCallbackTool } from "./scope/plugins/builtin/SdkCallbackTool.js";
|
|
36
|
-
export { HandoffService } from "./runtime/HandoffService.js";
|
|
36
|
+
export { HandoffAbortedError, HandoffService } from "./runtime/HandoffService.js";
|
|
37
37
|
export { HandoffGuardPlugin } from "./runtime/HandoffGuardPlugin.js";
|
|
38
38
|
// 插件
|
|
39
39
|
export { AutoMigratePlugin } from "./agent-plugins/AutoMigratePlugin.js";
|
|
@@ -18,6 +18,18 @@ export type HandoffMode = "self" | "summary";
|
|
|
18
18
|
* - `prune`:物理剔除历史,只保留 `definition.messages`(系统提示等模板)+ 交接断点。
|
|
19
19
|
*/
|
|
20
20
|
export type MigrationStrategy = "omit" | "prune";
|
|
21
|
+
/**
|
|
22
|
+
* 交接被调用方取消(`AbortSignal` 触发)。
|
|
23
|
+
*
|
|
24
|
+
* 单独成类型而不是复用 `Error`,原因有两个:
|
|
25
|
+
* - 调用方(如 TUI)要把「用户按了 Ctrl+C」与「交接真的失败了」区分开 ——
|
|
26
|
+
* 前者应静默收尾并提示「已取消」,后者才该报错;
|
|
27
|
+
* - 它**不可降级**:`self` 被取消后绝不能退到 `summary` 再跑一遍。
|
|
28
|
+
* 把「用户中止」当成「模型没写出文档」,会白白多烧一轮全量输入。
|
|
29
|
+
*/
|
|
30
|
+
export declare class HandoffAbortedError extends Error {
|
|
31
|
+
constructor();
|
|
32
|
+
}
|
|
21
33
|
/**
|
|
22
34
|
* 一次交接的完整上下文 —— 贯穿「交接前 → 写文档 → 迁移后」,供钩子与调用方使用。
|
|
23
35
|
*/
|
|
@@ -86,6 +98,15 @@ export interface HandoffParams {
|
|
|
86
98
|
handoffTokens?: number;
|
|
87
99
|
/** 会话 id(可选,用于交接文件名,便于回溯) */
|
|
88
100
|
conversationId?: string;
|
|
101
|
+
/**
|
|
102
|
+
* 取消信号(可选)。触发后交接**立即停止且不降级**,抛 {@link HandoffAbortedError}。
|
|
103
|
+
*
|
|
104
|
+
* 两种执行者都要认它:
|
|
105
|
+
* - `summary`:直接作为 `chat.completions.create` 的 RequestOptions 传下去,
|
|
106
|
+
* 请求会真的被中断(否则该请求根本无从取消);
|
|
107
|
+
* - `self`:`agent.abort()` 不抛错,所以每个 `send()` 之后要主动检查 `aborted`。
|
|
108
|
+
*/
|
|
109
|
+
signal?: AbortSignal;
|
|
89
110
|
}
|
|
90
111
|
/** `migrate()` 参数 */
|
|
91
112
|
export interface MigrateParams extends HandoffParams {
|
|
@@ -174,6 +195,25 @@ export declare class HandoffService {
|
|
|
174
195
|
migrate(params: MigrateParams): Promise<HandoffContext>;
|
|
175
196
|
/** 串行化 + 防重入:交接与迁移共用同一把锁 */
|
|
176
197
|
private withBusy;
|
|
198
|
+
/**
|
|
199
|
+
* 把底层各种「取消」形态统一成 {@link HandoffAbortedError}。
|
|
200
|
+
*
|
|
201
|
+
* 取消可能从三处冒上来:`self` 回合后的主动检查(已是 HandoffAbortedError)、
|
|
202
|
+
* `summary` 请求被 abort 时 OpenAI SDK 抛的 `APIUserAbortError`、以及
|
|
203
|
+
* `readSkillContent` 这类前置 await 期间被取消。调用方只需要认一个类型。
|
|
204
|
+
*/
|
|
205
|
+
private runCatchingAbort;
|
|
206
|
+
/** 取消点检查:每跨过一个阶段就确认一次,避免取消后继续往下一步冲 */
|
|
207
|
+
private throwIfAborted;
|
|
208
|
+
private fileExists;
|
|
209
|
+
/**
|
|
210
|
+
* 取消后清掉半成品文档。
|
|
211
|
+
*
|
|
212
|
+
* 只删「本次新建」的文件:交接文件名含时间戳,正常不会撞已有文件,但调用方也
|
|
213
|
+
* 可以把 `handoffDir` 指向人工目录;不管怎样都不该删掉别人的文件。
|
|
214
|
+
* 留着半成品更糟 —— 下一轮接手方会把不完整的内容当成真实交接读进去。
|
|
215
|
+
*/
|
|
216
|
+
private discardPartialFile;
|
|
177
217
|
private runHandoff;
|
|
178
218
|
/** 自交接是否可用:需要 `write` 工具 + `send` 能力 */
|
|
179
219
|
private supportsSelfHandoff;
|
|
@@ -183,6 +223,12 @@ export declare class HandoffService {
|
|
|
183
223
|
* 失败判定(任一成立即视为失败,交由上层单向降级 summary):
|
|
184
224
|
* - 文件不存在 / 小于阈值;
|
|
185
225
|
* - 模型/工具链抛错(例如没有权限、被护栏中断)。
|
|
226
|
+
*
|
|
227
|
+
* **取消不算失败**:`signal` 已触发时抛 {@link HandoffAbortedError},既不走
|
|
228
|
+
* 「文件没写好→重试一次」也不降级 summary。 这条分支必须显式写:
|
|
229
|
+
* `agent.abort()` 不抛错(core 把消息标记为 Aborted 后正常返回),否则「用户
|
|
230
|
+
* 按了 Ctrl+C」会被下面的文件校验误判成「模型没写出文档」,于是重试一轮、再降级
|
|
231
|
+
* 一轮 —— 用户以为取消了,实际多烧了两轮全量输入。
|
|
186
232
|
*/
|
|
187
233
|
private runSelfHandoff;
|
|
188
234
|
/** 独立摘要:一次模型调用,用同一份 Skill 正文作为规范 */
|
|
@@ -5,6 +5,35 @@ import { getLogger } from "../shared/logger.js";
|
|
|
5
5
|
import { readSkill } from "../scope/plugins/skills/discover.js";
|
|
6
6
|
import { FALLBACK_HANDOFF_TEMPLATE, HANDOFF_SKILL_ID } from "../config/factory-skills.js";
|
|
7
7
|
import { HandoffGuardPlugin } from "./HandoffGuardPlugin.js";
|
|
8
|
+
/**
|
|
9
|
+
* 交接被调用方取消(`AbortSignal` 触发)。
|
|
10
|
+
*
|
|
11
|
+
* 单独成类型而不是复用 `Error`,原因有两个:
|
|
12
|
+
* - 调用方(如 TUI)要把「用户按了 Ctrl+C」与「交接真的失败了」区分开 ——
|
|
13
|
+
* 前者应静默收尾并提示「已取消」,后者才该报错;
|
|
14
|
+
* - 它**不可降级**:`self` 被取消后绝不能退到 `summary` 再跑一遍。
|
|
15
|
+
* 把「用户中止」当成「模型没写出文档」,会白白多烧一轮全量输入。
|
|
16
|
+
*/
|
|
17
|
+
export class HandoffAbortedError extends Error {
|
|
18
|
+
constructor() {
|
|
19
|
+
super("交接已取消");
|
|
20
|
+
this.name = "HandoffAbortedError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 错误是否来自取消。
|
|
25
|
+
*
|
|
26
|
+
* 两条路径都要认:
|
|
27
|
+
* - 调用方传了 `signal` 且已 `aborted`(`agent.abort()` 就是这种情况 ——
|
|
28
|
+
* 它**不抛错**,core 层把消息标记成 Aborted 后正常返回);
|
|
29
|
+
* - OpenAI SDK 在请求被 abort 时抛的 `APIUserAbortError`(`name` 为 `AbortError`)。
|
|
30
|
+
*/
|
|
31
|
+
function isAbortError(err, signal) {
|
|
32
|
+
if (signal?.aborted)
|
|
33
|
+
return true;
|
|
34
|
+
const name = err?.name;
|
|
35
|
+
return name === "AbortError" || name === "APIUserAbortError";
|
|
36
|
+
}
|
|
8
37
|
/**
|
|
9
38
|
* 任务交接服务。
|
|
10
39
|
*
|
|
@@ -145,7 +174,7 @@ export class HandoffService {
|
|
|
145
174
|
* 用户可继续让 agent 打磨这份文档(重写同一路径即可)。
|
|
146
175
|
*/
|
|
147
176
|
async handoff(params) {
|
|
148
|
-
return this.withBusy(() => this.
|
|
177
|
+
return this.withBusy(() => this.runCatchingAbort(params, false));
|
|
149
178
|
}
|
|
150
179
|
/**
|
|
151
180
|
* 交接 + 开新会话(`/migrate`、自动迁移)。
|
|
@@ -156,7 +185,7 @@ export class HandoffService {
|
|
|
156
185
|
*/
|
|
157
186
|
async migrate(params) {
|
|
158
187
|
return this.withBusy(async () => {
|
|
159
|
-
const ctx = await this.
|
|
188
|
+
const ctx = await this.runCatchingAbort(params, true);
|
|
160
189
|
const { agent } = ctx;
|
|
161
190
|
const strategy = params.strategy ?? this.defaultStrategy;
|
|
162
191
|
const breakpointMessages = HandoffService.createPostMessages(ctx.handoffPath);
|
|
@@ -193,8 +222,58 @@ export class HandoffService {
|
|
|
193
222
|
this._busy = false;
|
|
194
223
|
}
|
|
195
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* 把底层各种「取消」形态统一成 {@link HandoffAbortedError}。
|
|
227
|
+
*
|
|
228
|
+
* 取消可能从三处冒上来:`self` 回合后的主动检查(已是 HandoffAbortedError)、
|
|
229
|
+
* `summary` 请求被 abort 时 OpenAI SDK 抛的 `APIUserAbortError`、以及
|
|
230
|
+
* `readSkillContent` 这类前置 await 期间被取消。调用方只需要认一个类型。
|
|
231
|
+
*/
|
|
232
|
+
async runCatchingAbort(params, migrating) {
|
|
233
|
+
try {
|
|
234
|
+
return await this.runHandoff(params, migrating);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
if (err instanceof HandoffAbortedError)
|
|
238
|
+
throw err;
|
|
239
|
+
if (isAbortError(err, params.signal))
|
|
240
|
+
throw new HandoffAbortedError();
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** 取消点检查:每跨过一个阶段就确认一次,避免取消后继续往下一步冲 */
|
|
245
|
+
throwIfAborted(signal) {
|
|
246
|
+
if (signal?.aborted)
|
|
247
|
+
throw new HandoffAbortedError();
|
|
248
|
+
}
|
|
249
|
+
async fileExists(path) {
|
|
250
|
+
try {
|
|
251
|
+
await fs.access(path);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 取消后清掉半成品文档。
|
|
260
|
+
*
|
|
261
|
+
* 只删「本次新建」的文件:交接文件名含时间戳,正常不会撞已有文件,但调用方也
|
|
262
|
+
* 可以把 `handoffDir` 指向人工目录;不管怎样都不该删掉别人的文件。
|
|
263
|
+
* 留着半成品更糟 —— 下一轮接手方会把不完整的内容当成真实交接读进去。
|
|
264
|
+
*/
|
|
265
|
+
async discardPartialFile(path, existedBefore) {
|
|
266
|
+
if (existedBefore)
|
|
267
|
+
return;
|
|
268
|
+
try {
|
|
269
|
+
await fs.unlink(path);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
/* 文件本就没写出来:忽略 */
|
|
273
|
+
}
|
|
274
|
+
}
|
|
196
275
|
async runHandoff(params, migrating) {
|
|
197
|
-
const { agent, promptTokens, handoffTokens, conversationId } = params;
|
|
276
|
+
const { agent, promptTokens, handoffTokens, conversationId, signal } = params;
|
|
198
277
|
const requestedMode = params.mode ?? this.defaultMode;
|
|
199
278
|
const handoffPath = join(this.handoffDirPath, HandoffService.createHandoffFileName(new Date(), conversationId));
|
|
200
279
|
const model = this.model ?? agent.model;
|
|
@@ -213,6 +292,7 @@ export class HandoffService {
|
|
|
213
292
|
historyText: "",
|
|
214
293
|
};
|
|
215
294
|
await this.safeHook(this.onBeforeHandoff, beforeCtx);
|
|
295
|
+
this.throwIfAborted(signal);
|
|
216
296
|
// ---- 执行者:self 优先,失败/不可用则单向降级 summary ----
|
|
217
297
|
let mode = requestedMode;
|
|
218
298
|
let handoffDoc;
|
|
@@ -222,7 +302,7 @@ export class HandoffService {
|
|
|
222
302
|
mode = "summary";
|
|
223
303
|
}
|
|
224
304
|
if (mode === "self") {
|
|
225
|
-
const ok = await this.runSelfHandoff(agent, handoffPath);
|
|
305
|
+
const ok = await this.runSelfHandoff(agent, handoffPath, signal);
|
|
226
306
|
if (!ok) {
|
|
227
307
|
this.logger.warn("[handoff] 自交接失败,降级为独立摘要模式");
|
|
228
308
|
mode = "summary";
|
|
@@ -230,9 +310,11 @@ export class HandoffService {
|
|
|
230
310
|
}
|
|
231
311
|
if (mode === "summary") {
|
|
232
312
|
historyText = HandoffService.serializeMessages(agent.messages.filter((m) => !m.omit));
|
|
233
|
-
handoffDoc = await this.generateSummary(agent, historyText, handoffPath);
|
|
313
|
+
handoffDoc = await this.generateSummary(agent, historyText, handoffPath, signal);
|
|
234
314
|
await this.writeHandoffFile(handoffPath, handoffDoc);
|
|
235
315
|
}
|
|
316
|
+
// 最后一道取消点:即使两步都跑完,只要期间被取消就不能算成功
|
|
317
|
+
this.throwIfAborted(signal);
|
|
236
318
|
const ctx = {
|
|
237
319
|
agent,
|
|
238
320
|
mode,
|
|
@@ -261,11 +343,19 @@ export class HandoffService {
|
|
|
261
343
|
* 失败判定(任一成立即视为失败,交由上层单向降级 summary):
|
|
262
344
|
* - 文件不存在 / 小于阈值;
|
|
263
345
|
* - 模型/工具链抛错(例如没有权限、被护栏中断)。
|
|
346
|
+
*
|
|
347
|
+
* **取消不算失败**:`signal` 已触发时抛 {@link HandoffAbortedError},既不走
|
|
348
|
+
* 「文件没写好→重试一次」也不降级 summary。 这条分支必须显式写:
|
|
349
|
+
* `agent.abort()` 不抛错(core 把消息标记为 Aborted 后正常返回),否则「用户
|
|
350
|
+
* 按了 Ctrl+C」会被下面的文件校验误判成「模型没写出文档」,于是重试一轮、再降级
|
|
351
|
+
* 一轮 —— 用户以为取消了,实际多烧了两轮全量输入。
|
|
264
352
|
*/
|
|
265
|
-
async runSelfHandoff(agent, handoffPath) {
|
|
353
|
+
async runSelfHandoff(agent, handoffPath, signal) {
|
|
266
354
|
const guard = this.ensureGuard(agent);
|
|
267
355
|
const skillContent = await this.readSkillContent();
|
|
268
356
|
const cwd = agent.scope?.cwd ?? process.cwd();
|
|
357
|
+
// 取消时只删本次新建的文件,不碰本已存在的同名文件
|
|
358
|
+
const existedBefore = await this.fileExists(handoffPath);
|
|
269
359
|
/*
|
|
270
360
|
* 清空 lastUsage 再开跑:
|
|
271
361
|
* - 护栏(ContextGuardPlugin)在每轮请求前用 lastUsage 判定严重超限,
|
|
@@ -281,6 +371,8 @@ export class HandoffService {
|
|
|
281
371
|
handoffPath,
|
|
282
372
|
toolsAvailable: true,
|
|
283
373
|
}));
|
|
374
|
+
// `agent.abort()` 不抛错,必须主动检查(见方法注释)
|
|
375
|
+
this.throwIfAborted(signal);
|
|
284
376
|
if (await this.isHandoffFileReady(handoffPath))
|
|
285
377
|
return true;
|
|
286
378
|
this.logger.warn(`[handoff] 交接文档缺失或过小,重试一次:${handoffPath}`);
|
|
@@ -288,9 +380,14 @@ export class HandoffService {
|
|
|
288
380
|
`交接文档不存在或内容过少,无法交付:${handoffPath}`,
|
|
289
381
|
"请立即用 write 工具把**完整**交接文档写入该路径(路径原样使用),不要做其他任何事。",
|
|
290
382
|
].join("\n"));
|
|
383
|
+
this.throwIfAborted(signal);
|
|
291
384
|
return await this.isHandoffFileReady(handoffPath);
|
|
292
385
|
}
|
|
293
386
|
catch (err) {
|
|
387
|
+
if (isAbortError(err, signal)) {
|
|
388
|
+
await this.discardPartialFile(handoffPath, existedBefore);
|
|
389
|
+
throw new HandoffAbortedError();
|
|
390
|
+
}
|
|
294
391
|
this.logger.error(`[handoff] 自交接回合失败:${err?.message ?? err}`);
|
|
295
392
|
return false;
|
|
296
393
|
}
|
|
@@ -299,11 +396,14 @@ export class HandoffService {
|
|
|
299
396
|
}
|
|
300
397
|
}
|
|
301
398
|
/** 独立摘要:一次模型调用,用同一份 Skill 正文作为规范 */
|
|
302
|
-
async generateSummary(agent, historyText, handoffPath) {
|
|
399
|
+
async generateSummary(agent, historyText, handoffPath, signal) {
|
|
303
400
|
const client = this.client ?? agent.client;
|
|
304
401
|
const model = this.model ?? agent.model;
|
|
305
402
|
const modelConfig = this.modelConfig ?? agent.modelConfig;
|
|
306
403
|
const skillContent = await this.readSkillContent();
|
|
404
|
+
this.throwIfAborted(signal);
|
|
405
|
+
// `signal` 走 RequestOptions(第二参数),与 `core/Agent.ts` 调用模型时一致:
|
|
406
|
+
// 放在 body 里不会被 OpenAI SDK 当成取消信号。
|
|
307
407
|
const response = await client.chat.completions.create({
|
|
308
408
|
model,
|
|
309
409
|
...(modelConfig ?? {}),
|
|
@@ -318,7 +418,7 @@ export class HandoffService {
|
|
|
318
418
|
},
|
|
319
419
|
{ role: "user", content: historyText },
|
|
320
420
|
],
|
|
321
|
-
});
|
|
421
|
+
}, signal ? { signal } : undefined);
|
|
322
422
|
const handoffDoc = response.choices[0]?.message?.content;
|
|
323
423
|
if (!handoffDoc || !handoffDoc.trim()) {
|
|
324
424
|
throw new Error("未能生成任务交接文档");
|
|
@@ -2,7 +2,10 @@ import { SdkAgent } from "./SdkAgent.js";
|
|
|
2
2
|
import type { Scope } from "../scope/Scope.js";
|
|
3
3
|
import type { AbortedMessageStrategy, AbortedToolRoundStrategy } from "@ai-zen/agents-core";
|
|
4
4
|
/**
|
|
5
|
-
* `createAgent`
|
|
5
|
+
* `createAgent` 的可选行为开关。
|
|
6
|
+
*
|
|
7
|
+
* 不传时先取 `scope.config`(`config.json`)里的同名配置,再往下才是 core 默认值:
|
|
8
|
+
* 中断策略属于**产品配置**,调用方不该自己读配置再转一道参数。
|
|
6
9
|
*/
|
|
7
10
|
export interface CreateAgentOptions {
|
|
8
11
|
/** 工具轮次被中断(或结果不完整)时的归一策略(core 默认 `fill`) */
|
|
@@ -10,8 +13,9 @@ export interface CreateAgentOptions {
|
|
|
10
13
|
/** `fill` 策略下,未完成工具结果的填充原因 */
|
|
11
14
|
abortedToolRoundReason?: string;
|
|
12
15
|
/**
|
|
13
|
-
* 被中断的 assistant 消息(已输出的内容 /
|
|
14
|
-
*
|
|
16
|
+
* 被中断的 assistant 消息(已输出的内容 / 思考)如何处置(未传时取 `scope.config`,
|
|
17
|
+
* 再缺则用 core 默认 `drop`)。
|
|
18
|
+
* `keep` 可让模型在下一轮接着半截往下说,而不是忘掉自己说过什么。
|
|
15
19
|
*/
|
|
16
20
|
abortedMessageStrategy?: AbortedMessageStrategy;
|
|
17
21
|
}
|
|
@@ -40,10 +40,12 @@ export async function createAgent(scope, agentId, options = {}) {
|
|
|
40
40
|
// 快照后,运行时改动只作用于会话副本,模板保持纯净。
|
|
41
41
|
messages: [...(definition.messages ?? [])],
|
|
42
42
|
tools,
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// 中断相关策略:**显式参数 > `scope.config` > core 默认值**。
|
|
44
|
+
// 中间那一层是「配置即事实」:策略写在 `config.json` 里,各端(CLI / Desktop)
|
|
45
|
+
// 不必自己读配置再转成参数,子 Agent 创建时也能自然继承同一份策略。
|
|
46
|
+
abortedToolRoundStrategy: options.abortedToolRoundStrategy ?? scope.config?.abortedToolRoundStrategy,
|
|
45
47
|
abortedToolRoundReason: options.abortedToolRoundReason,
|
|
46
|
-
abortedMessageStrategy: options.abortedMessageStrategy,
|
|
48
|
+
abortedMessageStrategy: options.abortedMessageStrategy ?? scope.config?.abortedMessageStrategy,
|
|
47
49
|
});
|
|
48
50
|
return agent;
|
|
49
51
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentNS } from "@ai-zen/agents-core";
|
|
1
|
+
import type { AgentNS, AbortedMessageStrategy, AbortedToolRoundStrategy } from "@ai-zen/agents-core";
|
|
2
2
|
/** 单维度权限策略:allow 或 deny 互斥 */
|
|
3
3
|
export type PermissionPolicy = {
|
|
4
4
|
allow: string[];
|
|
@@ -200,6 +200,27 @@ export interface AppConfig {
|
|
|
200
200
|
* (如 read/find_text 对 300KB 文件的读取上限)。
|
|
201
201
|
*/
|
|
202
202
|
maxToolOutput?: number;
|
|
203
|
+
/**
|
|
204
|
+
* 被中断的 assistant 消息(已输出的思考 / 正文)如何处置。
|
|
205
|
+
*
|
|
206
|
+
* - `keep`:保留已输出的部分(恢复为 `Completed`)—— 模型下一轮能接着半截往下说,
|
|
207
|
+
* 不会「失忆重来」。**出厂默认**。
|
|
208
|
+
* - `drop`:丢弃(保持 `Aborted`,不进下一轮上下文)。core 的库级默认。
|
|
209
|
+
*
|
|
210
|
+
* 只管**不带工具调用**的消息;工具轮次由 {@link abortedToolRoundStrategy} 决定。
|
|
211
|
+
* 缺省时由 `createAgent()` 回落 core 默认值(`drop`),出厂配置已写 `keep`。
|
|
212
|
+
*/
|
|
213
|
+
abortedMessageStrategy?: AbortedMessageStrategy;
|
|
214
|
+
/**
|
|
215
|
+
* 被中断 / 结果不完整的工具轮次如何归一。
|
|
216
|
+
*
|
|
217
|
+
* - `fill`:保留整轮(assistant 恢复为 `Completed`,未完成的结果补原因)——**出厂默认**,
|
|
218
|
+
* 服务端要求「N 个 tool_calls 对 N 条结果」,整轮进出才不会报错。
|
|
219
|
+
* - `drop`:整轮出列(assistant 与其结果都不发给模型)。
|
|
220
|
+
*
|
|
221
|
+
* 缺省时由 `createAgent()` 回落 core 默认值(`fill`)。
|
|
222
|
+
*/
|
|
223
|
+
abortedToolRoundStrategy?: AbortedToolRoundStrategy;
|
|
203
224
|
}
|
|
204
225
|
/**
|
|
205
226
|
* 工具环境 — Scope 的能力插件在实例化内置工具时注入的环境信息。
|