@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
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
3
|
+
import { Message } from "@ai-zen/agents-core";
|
|
4
|
+
import { getLogger } from "../shared/logger.js";
|
|
5
|
+
import { readSkill } from "../scope/plugins/skills/discover.js";
|
|
6
|
+
import { FALLBACK_HANDOFF_TEMPLATE, HANDOFF_SKILL_ID } from "../config/factory-skills.js";
|
|
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
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 任务交接服务。
|
|
39
|
+
*
|
|
40
|
+
* 职责分层(这是本模块唯一的设计主线):
|
|
41
|
+
* - `handoff()`:**只产出交接文档**(停工 → 写文档 → 校验 → 失败降级),不动会话;
|
|
42
|
+
* - `migrate()`:`handoff()` 之后清历史并开新会话(原「任务迁移」语义)。
|
|
43
|
+
*
|
|
44
|
+
* 模板的唯一真相源是 `handoff` Skill(`SKILL.md`);代码只负责
|
|
45
|
+
* 「读正文 → 注入 → 校验文件 → 读不到时用兜底模板」,不持有第二份章节清单。
|
|
46
|
+
*
|
|
47
|
+
* 重入:`handoff()` 与 `migrate()` 共用 `busy` 标志。交接回合本身也是一次 `send()`,
|
|
48
|
+
* 若不在 busy 期间让 `AutoMigratePlugin` / `ContextGuardPlugin` 让行,就会「交接触发迁移、
|
|
49
|
+
* 迁移再触发交接」无限递归,或在最满的时刻被护栏直接击杀。
|
|
50
|
+
*/
|
|
51
|
+
export class HandoffService {
|
|
52
|
+
/** 交接文档兜底模板(仅在读不到 Skill 时使用) */
|
|
53
|
+
static FALLBACK_TEMPLATE = FALLBACK_HANDOFF_TEMPLATE;
|
|
54
|
+
handoffDirPath;
|
|
55
|
+
defaultMode;
|
|
56
|
+
defaultStrategy;
|
|
57
|
+
skillDirs;
|
|
58
|
+
client;
|
|
59
|
+
model;
|
|
60
|
+
modelConfig;
|
|
61
|
+
minHandoffBytes;
|
|
62
|
+
onBeforeHandoff;
|
|
63
|
+
onHandoff;
|
|
64
|
+
onMigrated;
|
|
65
|
+
logger;
|
|
66
|
+
/** 交接/迁移进行中标志(插件据此让行) */
|
|
67
|
+
_busy = false;
|
|
68
|
+
/** 交接回合的工具守卫(同一实例复用 + arm/disarm,避免插件数组无限增长) */
|
|
69
|
+
guard;
|
|
70
|
+
/** 已注册过守卫的 agent(每个 agent 只 use 一次) */
|
|
71
|
+
guardedAgents = new WeakSet();
|
|
72
|
+
constructor(options = {}) {
|
|
73
|
+
const { handoffDir, mode, strategy, skillDirs, client, model, modelConfig, minHandoffBytes, onBeforeHandoff, onHandoff, onMigrated, logger, } = options;
|
|
74
|
+
this.handoffDirPath = resolvePath(handoffDir ?? join(process.cwd(), ".ai-zen", "handoffs"));
|
|
75
|
+
this.defaultMode = mode ?? "self";
|
|
76
|
+
this.defaultStrategy = strategy ?? "omit";
|
|
77
|
+
this.skillDirs = skillDirs ?? [];
|
|
78
|
+
this.client = client;
|
|
79
|
+
this.model = model;
|
|
80
|
+
this.modelConfig = modelConfig;
|
|
81
|
+
this.minHandoffBytes = minHandoffBytes ?? 400;
|
|
82
|
+
this.onBeforeHandoff = onBeforeHandoff;
|
|
83
|
+
this.onHandoff = onHandoff;
|
|
84
|
+
this.onMigrated = onMigrated;
|
|
85
|
+
this.logger = logger ?? getLogger();
|
|
86
|
+
}
|
|
87
|
+
/** 是否正在交接/迁移(`AutoMigratePlugin` / `ContextGuardPlugin` 据此让行) */
|
|
88
|
+
get busy() {
|
|
89
|
+
return this._busy;
|
|
90
|
+
}
|
|
91
|
+
/** 交接文档目录 */
|
|
92
|
+
get handoffDir() {
|
|
93
|
+
return this.handoffDirPath;
|
|
94
|
+
}
|
|
95
|
+
// ==================================================================
|
|
96
|
+
// 静态纯工具(供上层复用 / 单测)
|
|
97
|
+
// ==================================================================
|
|
98
|
+
/**
|
|
99
|
+
* 交接文件名:`<YYYYMMDD-HHmmss>-<会话id前8位>.md`(本地时间,便于人眼归档)。
|
|
100
|
+
*/
|
|
101
|
+
static createHandoffFileName(date = new Date(), conversationId) {
|
|
102
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
103
|
+
const stamp = `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
|
|
104
|
+
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
105
|
+
const suffix = conversationId ? `-${conversationId.slice(0, 8)}` : "";
|
|
106
|
+
return `${stamp}${suffix}.md`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* 拼装「本轮交接」指令:`[Skill 正文] + [本次交接参数]`。
|
|
110
|
+
*
|
|
111
|
+
* 不在 Skill 正文里发明占位符(本体系没有模板变量引擎),参数一律由代码拼在后面,
|
|
112
|
+
* 这样 Skill 只讲「怎么写」、代码只讲「写到哪、有什么能力」。
|
|
113
|
+
*/
|
|
114
|
+
static createHandoffInstruction(params) {
|
|
115
|
+
const { skillContent, handoffPath, toolsAvailable } = params;
|
|
116
|
+
const tail = toolsAvailable
|
|
117
|
+
? [
|
|
118
|
+
"请立即开始撰写,并用 write 工具把完整文档写入上面给出的路径(路径原样使用,不要改名、不要换目录)。",
|
|
119
|
+
"写完后用 read 回读一次确认完整,然后只回一句不超过 40 字的确认,**不要复述文档内容**。",
|
|
120
|
+
]
|
|
121
|
+
: [
|
|
122
|
+
"请直接输出完整文档正文:不要任何解释、不要用代码块包裹,调用方会自行落盘。",
|
|
123
|
+
];
|
|
124
|
+
return [
|
|
125
|
+
"你现在要为本轮对话编写一份任务交接文档。以下是必须遵守的交接规范。",
|
|
126
|
+
"",
|
|
127
|
+
"---",
|
|
128
|
+
"",
|
|
129
|
+
skillContent.trim(),
|
|
130
|
+
"",
|
|
131
|
+
"---",
|
|
132
|
+
"",
|
|
133
|
+
"## 本次交接参数",
|
|
134
|
+
`- 交接文档绝对路径:${handoffPath}`,
|
|
135
|
+
`- 工具能力:${toolsAvailable
|
|
136
|
+
? "可用(read / exec 等,请据此核实真实状态)"
|
|
137
|
+
: "不可用(你无法核实磁盘,状态类信息一律写「⚠️ 未核实」,禁止推测)"}`,
|
|
138
|
+
"",
|
|
139
|
+
...tail,
|
|
140
|
+
].join("\n");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* 生成迁移后追加的「交接断点」消息(role=user):只给路径,让接手方自己 read。
|
|
144
|
+
*
|
|
145
|
+
* 为什么不内联全文:接手方第一次真正需要的是「去哪里取交接」,读一次文件的输入成本
|
|
146
|
+
* 与内联全文相当,但内联会让每条请求都携带全文;路径方案把成本推迟到真正需要时。
|
|
147
|
+
*/
|
|
148
|
+
static createPostMessages(handoffPath) {
|
|
149
|
+
const content = [
|
|
150
|
+
"上一轮对话已交接,交接文档保存在:",
|
|
151
|
+
handoffPath,
|
|
152
|
+
"",
|
|
153
|
+
"请先用 read 工具读取该文件,理解上下文与未完成事项后,再继续协助用户完成任务。",
|
|
154
|
+
].join("\n");
|
|
155
|
+
return [Message.User(content)];
|
|
156
|
+
}
|
|
157
|
+
/** 序列化消息为 `[role]: content` 文本(仅 `summary` 模式使用) */
|
|
158
|
+
static serializeMessages(messages) {
|
|
159
|
+
return messages
|
|
160
|
+
.map((m) => {
|
|
161
|
+
const role = m.role ?? "unknown";
|
|
162
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
163
|
+
return `[${role}]: ${content}`;
|
|
164
|
+
})
|
|
165
|
+
.join("\n\n");
|
|
166
|
+
}
|
|
167
|
+
// ==================================================================
|
|
168
|
+
// 公开流程
|
|
169
|
+
// ==================================================================
|
|
170
|
+
/**
|
|
171
|
+
* 只产出交接文档,**不改动会话**(`/handoff`)。
|
|
172
|
+
*
|
|
173
|
+
* 不清理历史:交接回合(指令 + 工具调用 + 回执)会留在 `agent.messages` 里,
|
|
174
|
+
* 用户可继续让 agent 打磨这份文档(重写同一路径即可)。
|
|
175
|
+
*/
|
|
176
|
+
async handoff(params) {
|
|
177
|
+
return this.withBusy(() => this.runCatchingAbort(params, false));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* 交接 + 开新会话(`/migrate`、自动迁移)。
|
|
181
|
+
*
|
|
182
|
+
* 策略(`strategy`):
|
|
183
|
+
* - `omit`(默认):历史标记 `omit: true`(保留可审计),末尾追加交接断点;
|
|
184
|
+
* - `prune`:物理剔除历史,只留 `definition.messages` + 交接断点。
|
|
185
|
+
*/
|
|
186
|
+
async migrate(params) {
|
|
187
|
+
return this.withBusy(async () => {
|
|
188
|
+
const ctx = await this.runCatchingAbort(params, true);
|
|
189
|
+
const { agent } = ctx;
|
|
190
|
+
const strategy = params.strategy ?? this.defaultStrategy;
|
|
191
|
+
const breakpointMessages = HandoffService.createPostMessages(ctx.handoffPath);
|
|
192
|
+
if (strategy === "prune") {
|
|
193
|
+
// 浅拷贝模板消息,避免污染 definition.messages 引用的对象
|
|
194
|
+
agent.messages = [...agent.definition.messages, ...breakpointMessages];
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const preserveCount = Math.min(agent.definition.messages.length, agent.messages.length);
|
|
198
|
+
agent.messages = [
|
|
199
|
+
...agent.messages.map((m, i) => ({
|
|
200
|
+
...m,
|
|
201
|
+
omit: i >= preserveCount ? true : m.omit,
|
|
202
|
+
})),
|
|
203
|
+
...breakpointMessages,
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
await this.safeHook(this.onMigrated, ctx);
|
|
207
|
+
return ctx;
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
// ==================================================================
|
|
211
|
+
// 内部
|
|
212
|
+
// ==================================================================
|
|
213
|
+
/** 串行化 + 防重入:交接与迁移共用同一把锁 */
|
|
214
|
+
async withBusy(fn) {
|
|
215
|
+
if (this._busy)
|
|
216
|
+
throw new Error("交接流程已在进行中,请等待当前交接完成");
|
|
217
|
+
this._busy = true;
|
|
218
|
+
try {
|
|
219
|
+
return await fn();
|
|
220
|
+
}
|
|
221
|
+
finally {
|
|
222
|
+
this._busy = false;
|
|
223
|
+
}
|
|
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
|
+
}
|
|
275
|
+
async runHandoff(params, migrating) {
|
|
276
|
+
const { agent, promptTokens, handoffTokens, conversationId, signal } = params;
|
|
277
|
+
const requestedMode = params.mode ?? this.defaultMode;
|
|
278
|
+
const handoffPath = join(this.handoffDirPath, HandoffService.createHandoffFileName(new Date(), conversationId));
|
|
279
|
+
const model = this.model ?? agent.model;
|
|
280
|
+
const messageCountBefore = agent.messages.length;
|
|
281
|
+
// 迁移前上下文(不含文档,语义固定)
|
|
282
|
+
const beforeCtx = {
|
|
283
|
+
agent,
|
|
284
|
+
mode: requestedMode,
|
|
285
|
+
requestedMode,
|
|
286
|
+
handoffPath,
|
|
287
|
+
model,
|
|
288
|
+
promptTokens,
|
|
289
|
+
handoffTokens,
|
|
290
|
+
migrating,
|
|
291
|
+
messageCountBefore,
|
|
292
|
+
historyText: "",
|
|
293
|
+
};
|
|
294
|
+
await this.safeHook(this.onBeforeHandoff, beforeCtx);
|
|
295
|
+
this.throwIfAborted(signal);
|
|
296
|
+
// ---- 执行者:self 优先,失败/不可用则单向降级 summary ----
|
|
297
|
+
let mode = requestedMode;
|
|
298
|
+
let handoffDoc;
|
|
299
|
+
let historyText = "";
|
|
300
|
+
if (mode === "self" && !this.supportsSelfHandoff(agent)) {
|
|
301
|
+
this.logger.warn("[handoff] agent 不具备 write 工具或 send 能力,降级为独立摘要模式");
|
|
302
|
+
mode = "summary";
|
|
303
|
+
}
|
|
304
|
+
if (mode === "self") {
|
|
305
|
+
const ok = await this.runSelfHandoff(agent, handoffPath, signal);
|
|
306
|
+
if (!ok) {
|
|
307
|
+
this.logger.warn("[handoff] 自交接失败,降级为独立摘要模式");
|
|
308
|
+
mode = "summary";
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (mode === "summary") {
|
|
312
|
+
historyText = HandoffService.serializeMessages(agent.messages.filter((m) => !m.omit));
|
|
313
|
+
handoffDoc = await this.generateSummary(agent, historyText, handoffPath, signal);
|
|
314
|
+
await this.writeHandoffFile(handoffPath, handoffDoc);
|
|
315
|
+
}
|
|
316
|
+
// 最后一道取消点:即使两步都跑完,只要期间被取消就不能算成功
|
|
317
|
+
this.throwIfAborted(signal);
|
|
318
|
+
const ctx = {
|
|
319
|
+
agent,
|
|
320
|
+
mode,
|
|
321
|
+
requestedMode,
|
|
322
|
+
handoffPath,
|
|
323
|
+
handoffDoc,
|
|
324
|
+
model,
|
|
325
|
+
promptTokens,
|
|
326
|
+
handoffTokens,
|
|
327
|
+
migrating,
|
|
328
|
+
messageCountBefore,
|
|
329
|
+
historyText,
|
|
330
|
+
};
|
|
331
|
+
await this.safeHook(this.onHandoff, ctx);
|
|
332
|
+
return ctx;
|
|
333
|
+
}
|
|
334
|
+
/** 自交接是否可用:需要 `write` 工具 + `send` 能力 */
|
|
335
|
+
supportsSelfHandoff(agent) {
|
|
336
|
+
if (typeof agent.send !== "function")
|
|
337
|
+
return false;
|
|
338
|
+
return Boolean(agent.tools?.some((tool) => tool.function?.name === "write"));
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* 自交接回合:让原 agent 停工、核实、写文档,并校验文件确实落盘。
|
|
342
|
+
*
|
|
343
|
+
* 失败判定(任一成立即视为失败,交由上层单向降级 summary):
|
|
344
|
+
* - 文件不存在 / 小于阈值;
|
|
345
|
+
* - 模型/工具链抛错(例如没有权限、被护栏中断)。
|
|
346
|
+
*
|
|
347
|
+
* **取消不算失败**:`signal` 已触发时抛 {@link HandoffAbortedError},既不走
|
|
348
|
+
* 「文件没写好→重试一次」也不降级 summary。 这条分支必须显式写:
|
|
349
|
+
* `agent.abort()` 不抛错(core 把消息标记为 Aborted 后正常返回),否则「用户
|
|
350
|
+
* 按了 Ctrl+C」会被下面的文件校验误判成「模型没写出文档」,于是重试一轮、再降级
|
|
351
|
+
* 一轮 —— 用户以为取消了,实际多烧了两轮全量输入。
|
|
352
|
+
*/
|
|
353
|
+
async runSelfHandoff(agent, handoffPath, signal) {
|
|
354
|
+
const guard = this.ensureGuard(agent);
|
|
355
|
+
const skillContent = await this.readSkillContent();
|
|
356
|
+
const cwd = agent.scope?.cwd ?? process.cwd();
|
|
357
|
+
// 取消时只删本次新建的文件,不碰本已存在的同名文件
|
|
358
|
+
const existedBefore = await this.fileExists(handoffPath);
|
|
359
|
+
/*
|
|
360
|
+
* 清空 lastUsage 再开跑:
|
|
361
|
+
* - 护栏(ContextGuardPlugin)在每轮请求前用 lastUsage 判定严重超限,
|
|
362
|
+
* 而交接恰好发生在上下文最满的时刻;
|
|
363
|
+
* - AutoMigratePlugin 也会读它。busy 已能让两者让行,这里再清一次是纵深防御。
|
|
364
|
+
* 交接回合结束后 lastUsage 由本轮真实响应用量回填,无需恢复旧值。
|
|
365
|
+
*/
|
|
366
|
+
agent.lastUsage = undefined;
|
|
367
|
+
guard.arm(handoffPath, cwd);
|
|
368
|
+
try {
|
|
369
|
+
await agent.send(HandoffService.createHandoffInstruction({
|
|
370
|
+
skillContent,
|
|
371
|
+
handoffPath,
|
|
372
|
+
toolsAvailable: true,
|
|
373
|
+
}));
|
|
374
|
+
// `agent.abort()` 不抛错,必须主动检查(见方法注释)
|
|
375
|
+
this.throwIfAborted(signal);
|
|
376
|
+
if (await this.isHandoffFileReady(handoffPath))
|
|
377
|
+
return true;
|
|
378
|
+
this.logger.warn(`[handoff] 交接文档缺失或过小,重试一次:${handoffPath}`);
|
|
379
|
+
await agent.send([
|
|
380
|
+
`交接文档不存在或内容过少,无法交付:${handoffPath}`,
|
|
381
|
+
"请立即用 write 工具把**完整**交接文档写入该路径(路径原样使用),不要做其他任何事。",
|
|
382
|
+
].join("\n"));
|
|
383
|
+
this.throwIfAborted(signal);
|
|
384
|
+
return await this.isHandoffFileReady(handoffPath);
|
|
385
|
+
}
|
|
386
|
+
catch (err) {
|
|
387
|
+
if (isAbortError(err, signal)) {
|
|
388
|
+
await this.discardPartialFile(handoffPath, existedBefore);
|
|
389
|
+
throw new HandoffAbortedError();
|
|
390
|
+
}
|
|
391
|
+
this.logger.error(`[handoff] 自交接回合失败:${err?.message ?? err}`);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
guard.disarm();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/** 独立摘要:一次模型调用,用同一份 Skill 正文作为规范 */
|
|
399
|
+
async generateSummary(agent, historyText, handoffPath, signal) {
|
|
400
|
+
const client = this.client ?? agent.client;
|
|
401
|
+
const model = this.model ?? agent.model;
|
|
402
|
+
const modelConfig = this.modelConfig ?? agent.modelConfig;
|
|
403
|
+
const skillContent = await this.readSkillContent();
|
|
404
|
+
this.throwIfAborted(signal);
|
|
405
|
+
// `signal` 走 RequestOptions(第二参数),与 `core/Agent.ts` 调用模型时一致:
|
|
406
|
+
// 放在 body 里不会被 OpenAI SDK 当成取消信号。
|
|
407
|
+
const response = await client.chat.completions.create({
|
|
408
|
+
model,
|
|
409
|
+
...(modelConfig ?? {}),
|
|
410
|
+
messages: [
|
|
411
|
+
{
|
|
412
|
+
role: "system",
|
|
413
|
+
content: HandoffService.createHandoffInstruction({
|
|
414
|
+
skillContent,
|
|
415
|
+
handoffPath,
|
|
416
|
+
toolsAvailable: false,
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
{ role: "user", content: historyText },
|
|
420
|
+
],
|
|
421
|
+
}, signal ? { signal } : undefined);
|
|
422
|
+
const handoffDoc = response.choices[0]?.message?.content;
|
|
423
|
+
if (!handoffDoc || !handoffDoc.trim()) {
|
|
424
|
+
throw new Error("未能生成任务交接文档");
|
|
425
|
+
}
|
|
426
|
+
return handoffDoc;
|
|
427
|
+
}
|
|
428
|
+
/** 落盘(仅 `summary` 模式需要;`self` 模式由 agent 的 write 工具完成) */
|
|
429
|
+
async writeHandoffFile(handoffPath, content) {
|
|
430
|
+
await fs.mkdir(dirname(handoffPath), { recursive: true });
|
|
431
|
+
await fs.writeFile(handoffPath, content, "utf-8");
|
|
432
|
+
}
|
|
433
|
+
/** 交接文档是否可用:文件存在且不小于阈值(不读内容,避免二次读取) */ async isHandoffFileReady(handoffPath) {
|
|
434
|
+
try {
|
|
435
|
+
const stat = await fs.stat(handoffPath);
|
|
436
|
+
return stat.isFile() && stat.size >= this.minHandoffBytes;
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
/** 读取 `handoff` Skill 正文;读不到时回落到兜底模板 */
|
|
443
|
+
async readSkillContent() {
|
|
444
|
+
if (this.skillDirs.length > 0) {
|
|
445
|
+
const skill = await readSkill(this.skillDirs, HANDOFF_SKILL_ID, { silent: true });
|
|
446
|
+
if (skill?.content?.trim())
|
|
447
|
+
return skill.content;
|
|
448
|
+
}
|
|
449
|
+
this.logger.warn(`[handoff] 未找到 ${HANDOFF_SKILL_ID} Skill,使用内置兜底模板(路径:${this.skillDirs.join(", ") || "未配置"})`);
|
|
450
|
+
return FALLBACK_HANDOFF_TEMPLATE;
|
|
451
|
+
}
|
|
452
|
+
/** 守卫按 agent 注册一次(`Agent.use()` 无移除 API,故复用实例 + 开关) */
|
|
453
|
+
ensureGuard(agent) {
|
|
454
|
+
const guard = (this.guard ??= new HandoffGuardPlugin());
|
|
455
|
+
if (!this.guardedAgents.has(agent)) {
|
|
456
|
+
agent.use(guard);
|
|
457
|
+
this.guardedAgents.add(agent);
|
|
458
|
+
}
|
|
459
|
+
return guard;
|
|
460
|
+
}
|
|
461
|
+
/** 钩子容错:抛错只记录,不中断交接(与既有迁移钩子语义一致) */
|
|
462
|
+
async safeHook(hook, ctx) {
|
|
463
|
+
if (!hook)
|
|
464
|
+
return;
|
|
465
|
+
try {
|
|
466
|
+
await hook(ctx);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
this.logger.error(`[handoff] 钩子执行失败: ${err?.message ?? err}`);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type OpenAI from "openai";
|
|
2
2
|
import { Agent } from "@ai-zen/agents-core";
|
|
3
|
-
import type { AgentNS, Tool } from "@ai-zen/agents-core";
|
|
3
|
+
import type { AbortedMessageStrategy, AbortedToolRoundStrategy, AgentNS, Tool } from "@ai-zen/agents-core";
|
|
4
4
|
import type { AgentDefinition } from "../types/index.js";
|
|
5
5
|
import type { Scope } from "../scope/Scope.js";
|
|
6
6
|
export type { AgentPlugin, SendContext } from "@ai-zen/agents-core";
|
|
@@ -30,5 +30,11 @@ export declare class SdkAgent extends Agent {
|
|
|
30
30
|
messages?: AgentNS.Message[];
|
|
31
31
|
tools?: Tool[];
|
|
32
32
|
allowJsonParseError?: boolean;
|
|
33
|
+
/** 工具轮次被中断时的归一策略(core 默认 `fill`) */
|
|
34
|
+
abortedToolRoundStrategy?: AbortedToolRoundStrategy;
|
|
35
|
+
/** `fill` 策略下未完成工具结果的填充原因 */
|
|
36
|
+
abortedToolRoundReason?: string;
|
|
37
|
+
/** 被中断的 assistant(内容/思考)是否保留(core 默认 `drop`) */
|
|
38
|
+
abortedMessageStrategy?: AbortedMessageStrategy;
|
|
33
39
|
});
|
|
34
40
|
}
|
package/dist/runtime/SdkAgent.js
CHANGED
|
@@ -24,6 +24,9 @@ export class SdkAgent extends Agent {
|
|
|
24
24
|
messages: params.messages,
|
|
25
25
|
tools: params.tools,
|
|
26
26
|
allowJsonParseError: params.allowJsonParseError,
|
|
27
|
+
abortedToolRoundStrategy: params.abortedToolRoundStrategy,
|
|
28
|
+
abortedToolRoundReason: params.abortedToolRoundReason,
|
|
29
|
+
abortedMessageStrategy: params.abortedMessageStrategy,
|
|
27
30
|
});
|
|
28
31
|
this.scope = params.scope;
|
|
29
32
|
this.definition = params.definition;
|
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { SdkAgent } from "./SdkAgent.js";
|
|
2
2
|
import type { Scope } from "../scope/Scope.js";
|
|
3
|
+
import type { AbortedMessageStrategy, AbortedToolRoundStrategy } from "@ai-zen/agents-core";
|
|
4
|
+
/**
|
|
5
|
+
* `createAgent` 的可选行为开关(不传则全部沿用 core 默认值)。
|
|
6
|
+
*/
|
|
7
|
+
export interface CreateAgentOptions {
|
|
8
|
+
/** 工具轮次被中断(或结果不完整)时的归一策略(core 默认 `fill`) */
|
|
9
|
+
abortedToolRoundStrategy?: AbortedToolRoundStrategy;
|
|
10
|
+
/** `fill` 策略下,未完成工具结果的填充原因 */
|
|
11
|
+
abortedToolRoundReason?: string;
|
|
12
|
+
/**
|
|
13
|
+
* 被中断的 assistant 消息(已输出的内容 / 思考)如何处置(core 默认 `drop`)。
|
|
14
|
+
* 传 `keep` 可让模型在下一轮接着半截往下说,而不是忘掉自己说过什么。
|
|
15
|
+
*/
|
|
16
|
+
abortedMessageStrategy?: AbortedMessageStrategy;
|
|
17
|
+
}
|
|
3
18
|
/**
|
|
4
19
|
* 从磁盘创建 Agent。
|
|
5
20
|
*
|
|
@@ -10,4 +25,4 @@ import type { Scope } from "../scope/Scope.js";
|
|
|
10
25
|
* // agent 是 SdkAgent,可直接注册插件、发送消息
|
|
11
26
|
* ```
|
|
12
27
|
*/
|
|
13
|
-
export declare function createAgent(scope: Scope, agentId: string): Promise<SdkAgent>;
|
|
28
|
+
export declare function createAgent(scope: Scope, agentId: string, options?: CreateAgentOptions): Promise<SdkAgent>;
|
|
@@ -11,14 +11,15 @@ import { createModel } from "./createModel.js";
|
|
|
11
11
|
* // agent 是 SdkAgent,可直接注册插件、发送消息
|
|
12
12
|
* ```
|
|
13
13
|
*/
|
|
14
|
-
export async function createAgent(scope, agentId) {
|
|
14
|
+
export async function createAgent(scope, agentId, options = {}) {
|
|
15
15
|
const definition = await new AgentRepository(scope.agentsDir).read(agentId);
|
|
16
16
|
if (!definition)
|
|
17
17
|
throw new Error(`Agent "${agentId}" 不存在`);
|
|
18
|
-
const modelId = definition.modelId
|
|
19
|
-
if (!modelId)
|
|
20
|
-
throw new Error("
|
|
21
|
-
|
|
18
|
+
const modelId = definition.modelId;
|
|
19
|
+
if (!modelId) {
|
|
20
|
+
throw new Error(`Agent "${agentId}" 未绑定模型(缺少 modelId)。请在配置里给它指定一个模型。`);
|
|
21
|
+
}
|
|
22
|
+
const { client, model, modelConfig } = createModel(scope, modelId, definition.modelParams);
|
|
22
23
|
const tools = scope.buildTools(definition, {
|
|
23
24
|
exclude: {
|
|
24
25
|
subagents: definition.function?.name
|
|
@@ -39,6 +40,10 @@ export async function createAgent(scope, agentId) {
|
|
|
39
40
|
// 快照后,运行时改动只作用于会话副本,模板保持纯净。
|
|
40
41
|
messages: [...(definition.messages ?? [])],
|
|
41
42
|
tools,
|
|
43
|
+
// 中断相关策略:不传则由 core 取默认值(工具轮次 fill / 消息保留 drop)
|
|
44
|
+
abortedToolRoundStrategy: options.abortedToolRoundStrategy,
|
|
45
|
+
abortedToolRoundReason: options.abortedToolRoundReason,
|
|
46
|
+
abortedMessageStrategy: options.abortedMessageStrategy,
|
|
42
47
|
});
|
|
43
48
|
return agent;
|
|
44
49
|
}
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
2
|
import type { Scope } from "../scope/Scope.js";
|
|
3
|
+
import type { ModelParams } from "../types/index.js";
|
|
3
4
|
/**
|
|
4
5
|
* Agent 模型装配结果:openai SDK client + 模型名 + 模型参数。
|
|
5
6
|
* Agent 构造时直接消费此结构。
|
|
6
7
|
*/
|
|
7
8
|
export interface AgentModel {
|
|
8
|
-
/** openai
|
|
9
|
+
/** openai SDK 客户端 */
|
|
9
10
|
client: OpenAI;
|
|
10
11
|
/** 发送给 API 的模型名 */
|
|
11
12
|
model: string;
|
|
12
|
-
/**
|
|
13
|
-
modelConfig?:
|
|
13
|
+
/** 模型参数,透传给 API(可含厂商特有字段,如 DeepSeek thinking) */
|
|
14
|
+
modelConfig?: ModelParams;
|
|
14
15
|
}
|
|
15
16
|
/**
|
|
16
17
|
* 模型工厂 — 根据 modelId 和 Scope 装配 openai SDK client。
|
|
17
18
|
*
|
|
18
19
|
* 端点(baseUrl / apiKey)来自 config.endpoints,发送给 API 的模型名 = modelName || id。
|
|
19
20
|
* 任何 OpenAI 兼容端点(OpenAI / 智谱 BigModel / DeepSeek 等)均可通过 baseURL 接入。
|
|
21
|
+
*
|
|
22
|
+
* `overrides` 是 **Agent 级参数覆盖**(`AgentDefinition.modelParams`):
|
|
23
|
+
* 同一个模型可以给不同 Agent 配不同参数,而模型自身的 `defaultParams` 保持不变。
|
|
20
24
|
*/
|
|
21
|
-
export declare function createModel(scope: Scope, modelId: string): AgentModel;
|
|
25
|
+
export declare function createModel(scope: Scope, modelId: string, overrides?: ModelParams): AgentModel;
|
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import OpenAI from "openai";
|
|
2
|
+
/**
|
|
3
|
+
* 参数覆盖 —— 浅合并,一行。
|
|
4
|
+
*
|
|
5
|
+
* 这是 SDK 对「模型参数」的唯一处理逻辑:不解释键名、不做档位推导、不知道厂商差异。
|
|
6
|
+
* 需要多层次语义(如「思考档位」)的应用,应该在**写配置时**就把档位展开成具体参数,
|
|
7
|
+
* 存进 `Model.defaultParams` / `AgentDefinition.modelParams`,运行期不再查表。
|
|
8
|
+
*
|
|
9
|
+
* 没有覆盖时返回原对象(保持 `undefined` 语义,避免凭空塞一个空对象)。
|
|
10
|
+
*/
|
|
11
|
+
function mergeParams(base, overrides) {
|
|
12
|
+
if (!overrides || Object.keys(overrides).length === 0)
|
|
13
|
+
return base;
|
|
14
|
+
return { ...base, ...overrides };
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* 模型工厂 — 根据 modelId 和 Scope 装配 openai SDK client。
|
|
4
18
|
*
|
|
5
19
|
* 端点(baseUrl / apiKey)来自 config.endpoints,发送给 API 的模型名 = modelName || id。
|
|
6
20
|
* 任何 OpenAI 兼容端点(OpenAI / 智谱 BigModel / DeepSeek 等)均可通过 baseURL 接入。
|
|
21
|
+
*
|
|
22
|
+
* `overrides` 是 **Agent 级参数覆盖**(`AgentDefinition.modelParams`):
|
|
23
|
+
* 同一个模型可以给不同 Agent 配不同参数,而模型自身的 `defaultParams` 保持不变。
|
|
7
24
|
*/
|
|
8
|
-
export function createModel(scope, modelId) {
|
|
25
|
+
export function createModel(scope, modelId, overrides) {
|
|
9
26
|
const config = scope.config;
|
|
10
27
|
const modelConfig = config.models.find((m) => m.id === modelId);
|
|
11
28
|
if (!modelConfig)
|
|
@@ -25,6 +42,6 @@ export function createModel(scope, modelId) {
|
|
|
25
42
|
return {
|
|
26
43
|
client,
|
|
27
44
|
model: apiModelName,
|
|
28
|
-
modelConfig: modelConfig.defaultParams,
|
|
45
|
+
modelConfig: mergeParams(modelConfig.defaultParams, overrides),
|
|
29
46
|
};
|
|
30
47
|
}
|
|
@@ -2,8 +2,11 @@ import * as fsp from "fs/promises";
|
|
|
2
2
|
import { SdkCallbackTool } from "./SdkCallbackTool.js";
|
|
3
3
|
export class BatchEditTool extends SdkCallbackTool {
|
|
4
4
|
function = {
|
|
5
|
-
name: "
|
|
6
|
-
description:
|
|
5
|
+
name: "batch_edit",
|
|
6
|
+
description: [
|
|
7
|
+
"批量编辑文件文本,可以优先使用这个工具对文件进行编辑。",
|
|
8
|
+
"对同一个文件的多处修改应在一次调用中全部提交(replacements 数组),而不是并行多次调用 edit——并行编辑同一文件会互相覆盖。",
|
|
9
|
+
].join("\n"),
|
|
7
10
|
parameters: {
|
|
8
11
|
type: "object",
|
|
9
12
|
properties: {
|
|
@@ -3,7 +3,7 @@ import * as path from "path";
|
|
|
3
3
|
import { SdkCallbackTool } from "./SdkCallbackTool.js";
|
|
4
4
|
export class DownloadFileTool extends SdkCallbackTool {
|
|
5
5
|
function = {
|
|
6
|
-
name: "
|
|
6
|
+
name: "download_file",
|
|
7
7
|
description: "从 URL 下载文件(图片、PDF 等二进制文件)并保存到本地磁盘。自动从 URL 推断文件名,也可指定保存路径。",
|
|
8
8
|
parameters: {
|
|
9
9
|
type: "object",
|
|
@@ -15,7 +15,12 @@ function countOccurrences(content, text) {
|
|
|
15
15
|
export class EditTool extends SdkCallbackTool {
|
|
16
16
|
function = {
|
|
17
17
|
name: "edit",
|
|
18
|
-
description:
|
|
18
|
+
description: [
|
|
19
|
+
"编辑文件中的文本,支持精确替换或替换所有匹配项。",
|
|
20
|
+
"",
|
|
21
|
+
"并发限制(重要):同一个文件禁止并行编辑。不要在同一轮回复中对同一路径发起多个 edit 调用——这些调用会各自读取旧内容再写回,后写者覆盖先写者,导致修改丢失或文件被写坏。",
|
|
22
|
+
"如需对同一个文件施加多处修改,请改用 batch_edit,在一次调用里提交全部替换。只有编辑不同文件时才可以并行调用 edit。",
|
|
23
|
+
].join("\n"),
|
|
19
24
|
parameters: {
|
|
20
25
|
type: "object",
|
|
21
26
|
properties: {
|