@ai-zen/agents-sdk 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +153 -0
- package/README.md +15 -14
- package/README.zh.md +15 -14
- package/dist/agent-plugins/AutoMigratePlugin.d.ts +33 -7
- package/dist/agent-plugins/AutoMigratePlugin.js +20 -6
- package/dist/agent-plugins/ContextGuardPlugin.d.ts +13 -3
- package/dist/agent-plugins/ContextGuardPlugin.js +9 -6
- package/dist/config/ConfigManager.d.ts +29 -1
- package/dist/config/ConfigManager.js +84 -2
- package/dist/config/constants.d.ts +3 -1
- package/dist/config/constants.js +77 -39
- package/dist/config/factory-skills.d.ts +33 -0
- package/dist/config/factory-skills.js +126 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +5 -2
- package/dist/runtime/HandoffGuardPlugin.d.ts +35 -0
- package/dist/runtime/HandoffGuardPlugin.js +65 -0
- package/dist/runtime/HandoffService.d.ts +199 -0
- package/dist/runtime/HandoffService.js +372 -0
- package/dist/scope/plugins/builtin/BatchEditTool.js +1 -1
- package/dist/scope/plugins/builtin/DownloadFileTool.js +1 -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.js +2 -2
- 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/types/index.d.ts +24 -3
- package/package.json +3 -3
- package/dist/runtime/TaskMigrationService.d.ts +0 -128
- package/dist/runtime/TaskMigrationService.js +0 -211
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { AgentNS } from "@ai-zen/agents-core";
|
|
2
|
+
import type OpenAI from "openai";
|
|
3
|
+
import { type Logger } from "../shared/logger.js";
|
|
4
|
+
import type { SdkAgent } from "./SdkAgent.js";
|
|
5
|
+
/**
|
|
6
|
+
* 交接文档的执行者。
|
|
7
|
+
*
|
|
8
|
+
* - `self`:**原 agent 在自己的会话里**停工后写(默认)。上下文完整、可用工具核实真实状态,
|
|
9
|
+
* 产出质量最好;代价是多 1~2 次全量输入。
|
|
10
|
+
* - `summary`:一次独立的模型调用,把序列化后的历史摘要成交接文档(无工具、无原会话上下文)。
|
|
11
|
+
* 便宜、确定,但保真度低——它是**逃生模式**,也是 `self` 失败时的单向降级目标。
|
|
12
|
+
*/
|
|
13
|
+
export type HandoffMode = "self" | "summary";
|
|
14
|
+
/**
|
|
15
|
+
* 迁移后的历史处理策略。
|
|
16
|
+
*
|
|
17
|
+
* - `omit`(默认):历史消息标记 `omit: true`,仍留在 `agent.messages` 可审计/回放,但不再发给模型;
|
|
18
|
+
* - `prune`:物理剔除历史,只保留 `definition.messages`(系统提示等模板)+ 交接断点。
|
|
19
|
+
*/
|
|
20
|
+
export type MigrationStrategy = "omit" | "prune";
|
|
21
|
+
/**
|
|
22
|
+
* 一次交接的完整上下文 —— 贯穿「交接前 → 写文档 → 迁移后」,供钩子与调用方使用。
|
|
23
|
+
*/
|
|
24
|
+
export interface HandoffContext {
|
|
25
|
+
/** 当前对话 Agent(引用不变,仅替换消息列表) */
|
|
26
|
+
agent: SdkAgent;
|
|
27
|
+
/** **实际**使用的执行者(`self` 失败降级后会是 `summary`) */
|
|
28
|
+
mode: HandoffMode;
|
|
29
|
+
/** 调用方请求的执行者 */
|
|
30
|
+
requestedMode: HandoffMode;
|
|
31
|
+
/** 交接文档绝对路径 */
|
|
32
|
+
handoffPath: string;
|
|
33
|
+
/** 交接文档正文:仅 `summary` 模式由模型返回;`self` 模式不读盘,故为 undefined */
|
|
34
|
+
handoffDoc?: string;
|
|
35
|
+
/** 生成文档所用模型名 */
|
|
36
|
+
model: string;
|
|
37
|
+
/** 当前 prompt token 用量(自动迁移时由插件提供;手动触发可省略) */
|
|
38
|
+
promptTokens?: number;
|
|
39
|
+
/** 上下文阈值(自动迁移时由插件提供;手动触发可省略) */
|
|
40
|
+
maxTokens?: number;
|
|
41
|
+
/** 本次是否会在交接后开新会话(`migrate()` 为 true,`handoff()` 为 false)。钩子据此区分两种语义 */
|
|
42
|
+
migrating: boolean;
|
|
43
|
+
/** 交接前消息总数 */
|
|
44
|
+
messageCountBefore: number;
|
|
45
|
+
/** 序列化后的历史(仅 `summary` 模式填充;`self` 模式为空串) */
|
|
46
|
+
historyText: string;
|
|
47
|
+
}
|
|
48
|
+
/** 交接服务构造参数 */
|
|
49
|
+
export interface HandoffServiceOptions {
|
|
50
|
+
/**
|
|
51
|
+
* 交接文档目录(绝对路径推荐)。
|
|
52
|
+
* 默认按进程 cwd 推导为 `<cwd>/.ai-zen/handoffs`;多工作区场景请显式传入,勿依赖默认值。
|
|
53
|
+
*/
|
|
54
|
+
handoffDir?: string;
|
|
55
|
+
/** 默认执行者,默认 `self`(可被 `handoff()/migrate()` 的参数覆盖) */
|
|
56
|
+
mode?: HandoffMode;
|
|
57
|
+
/** 迁移后历史处理策略,默认 `omit`(可被 `migrate()` 的参数覆盖) */
|
|
58
|
+
strategy?: MigrationStrategy;
|
|
59
|
+
/** 查找 `handoff` Skill 的目录列表(优先级从高到低);为空则直接用兜底模板 */
|
|
60
|
+
skillDirs?: string[];
|
|
61
|
+
/** `summary` 模式所用客户端(可选;未传则回退到 agent.client) */
|
|
62
|
+
client?: OpenAI;
|
|
63
|
+
/** `summary` 模式所用模型名(可选;未传则回退到 agent.model) */
|
|
64
|
+
model?: string;
|
|
65
|
+
/** `summary` 模式所用模型参数(可选;未传则回退到 agent.modelConfig) */
|
|
66
|
+
modelConfig?: Record<string, unknown>;
|
|
67
|
+
/** 交接文档有效性阈值(字节),默认 400。低于此值视为「没写出来」 */
|
|
68
|
+
minHandoffBytes?: number;
|
|
69
|
+
/** 交接回合开始前触发(此时 `agent.messages` 仍是完整旧历史) */
|
|
70
|
+
onBeforeHandoff?: (ctx: HandoffContext) => void | Promise<void>;
|
|
71
|
+
/** 交接文档写好后、迁移(清历史)之前触发 */
|
|
72
|
+
onHandoff?: (ctx: HandoffContext) => void | Promise<void>;
|
|
73
|
+
/** 迁移完成后触发(此时交接断点已注入 `agent.messages`) */
|
|
74
|
+
onMigrated?: (ctx: HandoffContext) => void | Promise<void>;
|
|
75
|
+
/** 可注入日志(可选,默认使用全局单例) */
|
|
76
|
+
logger?: Logger;
|
|
77
|
+
}
|
|
78
|
+
/** `handoff()` 参数 */
|
|
79
|
+
export interface HandoffParams {
|
|
80
|
+
agent: SdkAgent;
|
|
81
|
+
/** 本次执行者(覆盖构造时的默认值) */
|
|
82
|
+
mode?: HandoffMode;
|
|
83
|
+
/** 当前 prompt token 用量(可选,仅透传给钩子) */
|
|
84
|
+
promptTokens?: number;
|
|
85
|
+
/** 上下文阈值(可选,仅透传给钩子) */
|
|
86
|
+
maxTokens?: number;
|
|
87
|
+
/** 会话 id(可选,用于交接文件名,便于回溯) */
|
|
88
|
+
conversationId?: string;
|
|
89
|
+
}
|
|
90
|
+
/** `migrate()` 参数 */
|
|
91
|
+
export interface MigrateParams extends HandoffParams {
|
|
92
|
+
/** 本次迁移的历史处理策略(覆盖构造时的默认值) */
|
|
93
|
+
strategy?: MigrationStrategy;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 任务交接服务。
|
|
97
|
+
*
|
|
98
|
+
* 职责分层(这是本模块唯一的设计主线):
|
|
99
|
+
* - `handoff()`:**只产出交接文档**(停工 → 写文档 → 校验 → 失败降级),不动会话;
|
|
100
|
+
* - `migrate()`:`handoff()` 之后清历史并开新会话(原「任务迁移」语义)。
|
|
101
|
+
*
|
|
102
|
+
* 模板的唯一真相源是 `handoff` Skill(`SKILL.md`);代码只负责
|
|
103
|
+
* 「读正文 → 注入 → 校验文件 → 读不到时用兜底模板」,不持有第二份章节清单。
|
|
104
|
+
*
|
|
105
|
+
* 重入:`handoff()` 与 `migrate()` 共用 `busy` 标志。交接回合本身也是一次 `send()`,
|
|
106
|
+
* 若不在 busy 期间让 `AutoMigratePlugin` / `ContextGuardPlugin` 让行,就会「交接触发迁移、
|
|
107
|
+
* 迁移再触发交接」无限递归,或在最满的时刻被护栏直接击杀。
|
|
108
|
+
*/
|
|
109
|
+
export declare class HandoffService {
|
|
110
|
+
/** 交接文档兜底模板(仅在读不到 Skill 时使用) */
|
|
111
|
+
static readonly FALLBACK_TEMPLATE: string;
|
|
112
|
+
private readonly handoffDirPath;
|
|
113
|
+
private readonly defaultMode;
|
|
114
|
+
private readonly defaultStrategy;
|
|
115
|
+
private readonly skillDirs;
|
|
116
|
+
private readonly client?;
|
|
117
|
+
private readonly model?;
|
|
118
|
+
private readonly modelConfig?;
|
|
119
|
+
private readonly minHandoffBytes;
|
|
120
|
+
private readonly onBeforeHandoff?;
|
|
121
|
+
private readonly onHandoff?;
|
|
122
|
+
private readonly onMigrated?;
|
|
123
|
+
private readonly logger;
|
|
124
|
+
/** 交接/迁移进行中标志(插件据此让行) */
|
|
125
|
+
private _busy;
|
|
126
|
+
/** 交接回合的工具守卫(同一实例复用 + arm/disarm,避免插件数组无限增长) */
|
|
127
|
+
private guard?;
|
|
128
|
+
/** 已注册过守卫的 agent(每个 agent 只 use 一次) */
|
|
129
|
+
private readonly guardedAgents;
|
|
130
|
+
constructor(options?: HandoffServiceOptions);
|
|
131
|
+
/** 是否正在交接/迁移(`AutoMigratePlugin` / `ContextGuardPlugin` 据此让行) */
|
|
132
|
+
get busy(): boolean;
|
|
133
|
+
/** 交接文档目录 */
|
|
134
|
+
get handoffDir(): string;
|
|
135
|
+
/**
|
|
136
|
+
* 交接文件名:`<YYYYMMDD-HHmmss>-<会话id前8位>.md`(本地时间,便于人眼归档)。
|
|
137
|
+
*/
|
|
138
|
+
static createHandoffFileName(date?: Date, conversationId?: string): string;
|
|
139
|
+
/**
|
|
140
|
+
* 拼装「本轮交接」指令:`[Skill 正文] + [本次交接参数]`。
|
|
141
|
+
*
|
|
142
|
+
* 不在 Skill 正文里发明占位符(本体系没有模板变量引擎),参数一律由代码拼在后面,
|
|
143
|
+
* 这样 Skill 只讲「怎么写」、代码只讲「写到哪、有什么能力」。
|
|
144
|
+
*/
|
|
145
|
+
static createHandoffInstruction(params: {
|
|
146
|
+
skillContent: string;
|
|
147
|
+
handoffPath: string;
|
|
148
|
+
/** 执行者是否具备工具能力(`summary` 模式为 false) */
|
|
149
|
+
toolsAvailable: boolean;
|
|
150
|
+
}): string;
|
|
151
|
+
/**
|
|
152
|
+
* 生成迁移后追加的「交接断点」消息(role=user):只给路径,让接手方自己 read。
|
|
153
|
+
*
|
|
154
|
+
* 为什么不内联全文:接手方第一次真正需要的是「去哪里取交接」,读一次文件的输入成本
|
|
155
|
+
* 与内联全文相当,但内联会让每条请求都携带全文;路径方案把成本推迟到真正需要时。
|
|
156
|
+
*/
|
|
157
|
+
static createPostMessages(handoffPath: string): AgentNS.Message[];
|
|
158
|
+
/** 序列化消息为 `[role]: content` 文本(仅 `summary` 模式使用) */
|
|
159
|
+
static serializeMessages(messages: AgentNS.Message[]): string;
|
|
160
|
+
/**
|
|
161
|
+
* 只产出交接文档,**不改动会话**(`/handoff`)。
|
|
162
|
+
*
|
|
163
|
+
* 不清理历史:交接回合(指令 + 工具调用 + 回执)会留在 `agent.messages` 里,
|
|
164
|
+
* 用户可继续让 agent 打磨这份文档(重写同一路径即可)。
|
|
165
|
+
*/
|
|
166
|
+
handoff(params: HandoffParams): Promise<HandoffContext>;
|
|
167
|
+
/**
|
|
168
|
+
* 交接 + 开新会话(`/migrate`、自动迁移)。
|
|
169
|
+
*
|
|
170
|
+
* 策略(`strategy`):
|
|
171
|
+
* - `omit`(默认):历史标记 `omit: true`(保留可审计),末尾追加交接断点;
|
|
172
|
+
* - `prune`:物理剔除历史,只留 `definition.messages` + 交接断点。
|
|
173
|
+
*/
|
|
174
|
+
migrate(params: MigrateParams): Promise<HandoffContext>;
|
|
175
|
+
/** 串行化 + 防重入:交接与迁移共用同一把锁 */
|
|
176
|
+
private withBusy;
|
|
177
|
+
private runHandoff;
|
|
178
|
+
/** 自交接是否可用:需要 `write` 工具 + `send` 能力 */
|
|
179
|
+
private supportsSelfHandoff;
|
|
180
|
+
/**
|
|
181
|
+
* 自交接回合:让原 agent 停工、核实、写文档,并校验文件确实落盘。
|
|
182
|
+
*
|
|
183
|
+
* 失败判定(任一成立即视为失败,交由上层单向降级 summary):
|
|
184
|
+
* - 文件不存在 / 小于阈值;
|
|
185
|
+
* - 模型/工具链抛错(例如没有权限、被护栏中断)。
|
|
186
|
+
*/
|
|
187
|
+
private runSelfHandoff;
|
|
188
|
+
/** 独立摘要:一次模型调用,用同一份 Skill 正文作为规范 */
|
|
189
|
+
private generateSummary;
|
|
190
|
+
/** 落盘(仅 `summary` 模式需要;`self` 模式由 agent 的 write 工具完成) */
|
|
191
|
+
private writeHandoffFile;
|
|
192
|
+
/** 交接文档是否可用:文件存在且不小于阈值(不读内容,避免二次读取) */ private isHandoffFileReady;
|
|
193
|
+
/** 读取 `handoff` Skill 正文;读不到时回落到兜底模板 */
|
|
194
|
+
private readSkillContent;
|
|
195
|
+
/** 守卫按 agent 注册一次(`Agent.use()` 无移除 API,故复用实例 + 开关) */
|
|
196
|
+
private ensureGuard;
|
|
197
|
+
/** 钩子容错:抛错只记录,不中断交接(与既有迁移钩子语义一致) */
|
|
198
|
+
private safeHook;
|
|
199
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
* 任务交接服务。
|
|
10
|
+
*
|
|
11
|
+
* 职责分层(这是本模块唯一的设计主线):
|
|
12
|
+
* - `handoff()`:**只产出交接文档**(停工 → 写文档 → 校验 → 失败降级),不动会话;
|
|
13
|
+
* - `migrate()`:`handoff()` 之后清历史并开新会话(原「任务迁移」语义)。
|
|
14
|
+
*
|
|
15
|
+
* 模板的唯一真相源是 `handoff` Skill(`SKILL.md`);代码只负责
|
|
16
|
+
* 「读正文 → 注入 → 校验文件 → 读不到时用兜底模板」,不持有第二份章节清单。
|
|
17
|
+
*
|
|
18
|
+
* 重入:`handoff()` 与 `migrate()` 共用 `busy` 标志。交接回合本身也是一次 `send()`,
|
|
19
|
+
* 若不在 busy 期间让 `AutoMigratePlugin` / `ContextGuardPlugin` 让行,就会「交接触发迁移、
|
|
20
|
+
* 迁移再触发交接」无限递归,或在最满的时刻被护栏直接击杀。
|
|
21
|
+
*/
|
|
22
|
+
export class HandoffService {
|
|
23
|
+
/** 交接文档兜底模板(仅在读不到 Skill 时使用) */
|
|
24
|
+
static FALLBACK_TEMPLATE = FALLBACK_HANDOFF_TEMPLATE;
|
|
25
|
+
handoffDirPath;
|
|
26
|
+
defaultMode;
|
|
27
|
+
defaultStrategy;
|
|
28
|
+
skillDirs;
|
|
29
|
+
client;
|
|
30
|
+
model;
|
|
31
|
+
modelConfig;
|
|
32
|
+
minHandoffBytes;
|
|
33
|
+
onBeforeHandoff;
|
|
34
|
+
onHandoff;
|
|
35
|
+
onMigrated;
|
|
36
|
+
logger;
|
|
37
|
+
/** 交接/迁移进行中标志(插件据此让行) */
|
|
38
|
+
_busy = false;
|
|
39
|
+
/** 交接回合的工具守卫(同一实例复用 + arm/disarm,避免插件数组无限增长) */
|
|
40
|
+
guard;
|
|
41
|
+
/** 已注册过守卫的 agent(每个 agent 只 use 一次) */
|
|
42
|
+
guardedAgents = new WeakSet();
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
const { handoffDir, mode, strategy, skillDirs, client, model, modelConfig, minHandoffBytes, onBeforeHandoff, onHandoff, onMigrated, logger, } = options;
|
|
45
|
+
this.handoffDirPath = resolvePath(handoffDir ?? join(process.cwd(), ".ai-zen", "handoffs"));
|
|
46
|
+
this.defaultMode = mode ?? "self";
|
|
47
|
+
this.defaultStrategy = strategy ?? "omit";
|
|
48
|
+
this.skillDirs = skillDirs ?? [];
|
|
49
|
+
this.client = client;
|
|
50
|
+
this.model = model;
|
|
51
|
+
this.modelConfig = modelConfig;
|
|
52
|
+
this.minHandoffBytes = minHandoffBytes ?? 400;
|
|
53
|
+
this.onBeforeHandoff = onBeforeHandoff;
|
|
54
|
+
this.onHandoff = onHandoff;
|
|
55
|
+
this.onMigrated = onMigrated;
|
|
56
|
+
this.logger = logger ?? getLogger();
|
|
57
|
+
}
|
|
58
|
+
/** 是否正在交接/迁移(`AutoMigratePlugin` / `ContextGuardPlugin` 据此让行) */
|
|
59
|
+
get busy() {
|
|
60
|
+
return this._busy;
|
|
61
|
+
}
|
|
62
|
+
/** 交接文档目录 */
|
|
63
|
+
get handoffDir() {
|
|
64
|
+
return this.handoffDirPath;
|
|
65
|
+
}
|
|
66
|
+
// ==================================================================
|
|
67
|
+
// 静态纯工具(供上层复用 / 单测)
|
|
68
|
+
// ==================================================================
|
|
69
|
+
/**
|
|
70
|
+
* 交接文件名:`<YYYYMMDD-HHmmss>-<会话id前8位>.md`(本地时间,便于人眼归档)。
|
|
71
|
+
*/
|
|
72
|
+
static createHandoffFileName(date = new Date(), conversationId) {
|
|
73
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
74
|
+
const stamp = `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}` +
|
|
75
|
+
`-${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
76
|
+
const suffix = conversationId ? `-${conversationId.slice(0, 8)}` : "";
|
|
77
|
+
return `${stamp}${suffix}.md`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 拼装「本轮交接」指令:`[Skill 正文] + [本次交接参数]`。
|
|
81
|
+
*
|
|
82
|
+
* 不在 Skill 正文里发明占位符(本体系没有模板变量引擎),参数一律由代码拼在后面,
|
|
83
|
+
* 这样 Skill 只讲「怎么写」、代码只讲「写到哪、有什么能力」。
|
|
84
|
+
*/
|
|
85
|
+
static createHandoffInstruction(params) {
|
|
86
|
+
const { skillContent, handoffPath, toolsAvailable } = params;
|
|
87
|
+
const tail = toolsAvailable
|
|
88
|
+
? [
|
|
89
|
+
"请立即开始撰写,并用 write 工具把完整文档写入上面给出的路径(路径原样使用,不要改名、不要换目录)。",
|
|
90
|
+
"写完后用 read 回读一次确认完整,然后只回一句不超过 40 字的确认,**不要复述文档内容**。",
|
|
91
|
+
]
|
|
92
|
+
: [
|
|
93
|
+
"请直接输出完整文档正文:不要任何解释、不要用代码块包裹,调用方会自行落盘。",
|
|
94
|
+
];
|
|
95
|
+
return [
|
|
96
|
+
"你现在要为本轮对话编写一份任务交接文档。以下是必须遵守的交接规范。",
|
|
97
|
+
"",
|
|
98
|
+
"---",
|
|
99
|
+
"",
|
|
100
|
+
skillContent.trim(),
|
|
101
|
+
"",
|
|
102
|
+
"---",
|
|
103
|
+
"",
|
|
104
|
+
"## 本次交接参数",
|
|
105
|
+
`- 交接文档绝对路径:${handoffPath}`,
|
|
106
|
+
`- 工具能力:${toolsAvailable
|
|
107
|
+
? "可用(read / exec 等,请据此核实真实状态)"
|
|
108
|
+
: "不可用(你无法核实磁盘,状态类信息一律写「⚠️ 未核实」,禁止推测)"}`,
|
|
109
|
+
"",
|
|
110
|
+
...tail,
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* 生成迁移后追加的「交接断点」消息(role=user):只给路径,让接手方自己 read。
|
|
115
|
+
*
|
|
116
|
+
* 为什么不内联全文:接手方第一次真正需要的是「去哪里取交接」,读一次文件的输入成本
|
|
117
|
+
* 与内联全文相当,但内联会让每条请求都携带全文;路径方案把成本推迟到真正需要时。
|
|
118
|
+
*/
|
|
119
|
+
static createPostMessages(handoffPath) {
|
|
120
|
+
const content = [
|
|
121
|
+
"上一轮对话已交接,交接文档保存在:",
|
|
122
|
+
handoffPath,
|
|
123
|
+
"",
|
|
124
|
+
"请先用 read 工具读取该文件,理解上下文与未完成事项后,再继续协助用户完成任务。",
|
|
125
|
+
].join("\n");
|
|
126
|
+
return [Message.User(content)];
|
|
127
|
+
}
|
|
128
|
+
/** 序列化消息为 `[role]: content` 文本(仅 `summary` 模式使用) */
|
|
129
|
+
static serializeMessages(messages) {
|
|
130
|
+
return messages
|
|
131
|
+
.map((m) => {
|
|
132
|
+
const role = m.role ?? "unknown";
|
|
133
|
+
const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content);
|
|
134
|
+
return `[${role}]: ${content}`;
|
|
135
|
+
})
|
|
136
|
+
.join("\n\n");
|
|
137
|
+
}
|
|
138
|
+
// ==================================================================
|
|
139
|
+
// 公开流程
|
|
140
|
+
// ==================================================================
|
|
141
|
+
/**
|
|
142
|
+
* 只产出交接文档,**不改动会话**(`/handoff`)。
|
|
143
|
+
*
|
|
144
|
+
* 不清理历史:交接回合(指令 + 工具调用 + 回执)会留在 `agent.messages` 里,
|
|
145
|
+
* 用户可继续让 agent 打磨这份文档(重写同一路径即可)。
|
|
146
|
+
*/
|
|
147
|
+
async handoff(params) {
|
|
148
|
+
return this.withBusy(() => this.runHandoff(params, false));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* 交接 + 开新会话(`/migrate`、自动迁移)。
|
|
152
|
+
*
|
|
153
|
+
* 策略(`strategy`):
|
|
154
|
+
* - `omit`(默认):历史标记 `omit: true`(保留可审计),末尾追加交接断点;
|
|
155
|
+
* - `prune`:物理剔除历史,只留 `definition.messages` + 交接断点。
|
|
156
|
+
*/
|
|
157
|
+
async migrate(params) {
|
|
158
|
+
return this.withBusy(async () => {
|
|
159
|
+
const ctx = await this.runHandoff(params, true);
|
|
160
|
+
const { agent } = ctx;
|
|
161
|
+
const strategy = params.strategy ?? this.defaultStrategy;
|
|
162
|
+
const breakpointMessages = HandoffService.createPostMessages(ctx.handoffPath);
|
|
163
|
+
if (strategy === "prune") {
|
|
164
|
+
// 浅拷贝模板消息,避免污染 definition.messages 引用的对象
|
|
165
|
+
agent.messages = [...agent.definition.messages, ...breakpointMessages];
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
const preserveCount = Math.min(agent.definition.messages.length, agent.messages.length);
|
|
169
|
+
agent.messages = [
|
|
170
|
+
...agent.messages.map((m, i) => ({
|
|
171
|
+
...m,
|
|
172
|
+
omit: i >= preserveCount ? true : m.omit,
|
|
173
|
+
})),
|
|
174
|
+
...breakpointMessages,
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
await this.safeHook(this.onMigrated, ctx);
|
|
178
|
+
return ctx;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
// ==================================================================
|
|
182
|
+
// 内部
|
|
183
|
+
// ==================================================================
|
|
184
|
+
/** 串行化 + 防重入:交接与迁移共用同一把锁 */
|
|
185
|
+
async withBusy(fn) {
|
|
186
|
+
if (this._busy)
|
|
187
|
+
throw new Error("交接流程已在进行中,请等待当前交接完成");
|
|
188
|
+
this._busy = true;
|
|
189
|
+
try {
|
|
190
|
+
return await fn();
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
this._busy = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async runHandoff(params, migrating) {
|
|
197
|
+
const { agent, promptTokens, maxTokens, conversationId } = params;
|
|
198
|
+
const requestedMode = params.mode ?? this.defaultMode;
|
|
199
|
+
const handoffPath = join(this.handoffDirPath, HandoffService.createHandoffFileName(new Date(), conversationId));
|
|
200
|
+
const model = this.model ?? agent.model;
|
|
201
|
+
const messageCountBefore = agent.messages.length;
|
|
202
|
+
// 迁移前上下文(不含文档,语义固定)
|
|
203
|
+
const beforeCtx = {
|
|
204
|
+
agent,
|
|
205
|
+
mode: requestedMode,
|
|
206
|
+
requestedMode,
|
|
207
|
+
handoffPath,
|
|
208
|
+
model,
|
|
209
|
+
promptTokens,
|
|
210
|
+
maxTokens,
|
|
211
|
+
migrating,
|
|
212
|
+
messageCountBefore,
|
|
213
|
+
historyText: "",
|
|
214
|
+
};
|
|
215
|
+
await this.safeHook(this.onBeforeHandoff, beforeCtx);
|
|
216
|
+
// ---- 执行者:self 优先,失败/不可用则单向降级 summary ----
|
|
217
|
+
let mode = requestedMode;
|
|
218
|
+
let handoffDoc;
|
|
219
|
+
let historyText = "";
|
|
220
|
+
if (mode === "self" && !this.supportsSelfHandoff(agent)) {
|
|
221
|
+
this.logger.warn("[handoff] agent 不具备 write 工具或 send 能力,降级为独立摘要模式");
|
|
222
|
+
mode = "summary";
|
|
223
|
+
}
|
|
224
|
+
if (mode === "self") {
|
|
225
|
+
const ok = await this.runSelfHandoff(agent, handoffPath);
|
|
226
|
+
if (!ok) {
|
|
227
|
+
this.logger.warn("[handoff] 自交接失败,降级为独立摘要模式");
|
|
228
|
+
mode = "summary";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (mode === "summary") {
|
|
232
|
+
historyText = HandoffService.serializeMessages(agent.messages.filter((m) => !m.omit));
|
|
233
|
+
handoffDoc = await this.generateSummary(agent, historyText, handoffPath);
|
|
234
|
+
await this.writeHandoffFile(handoffPath, handoffDoc);
|
|
235
|
+
}
|
|
236
|
+
const ctx = {
|
|
237
|
+
agent,
|
|
238
|
+
mode,
|
|
239
|
+
requestedMode,
|
|
240
|
+
handoffPath,
|
|
241
|
+
handoffDoc,
|
|
242
|
+
model,
|
|
243
|
+
promptTokens,
|
|
244
|
+
maxTokens,
|
|
245
|
+
migrating,
|
|
246
|
+
messageCountBefore,
|
|
247
|
+
historyText,
|
|
248
|
+
};
|
|
249
|
+
await this.safeHook(this.onHandoff, ctx);
|
|
250
|
+
return ctx;
|
|
251
|
+
}
|
|
252
|
+
/** 自交接是否可用:需要 `write` 工具 + `send` 能力 */
|
|
253
|
+
supportsSelfHandoff(agent) {
|
|
254
|
+
if (typeof agent.send !== "function")
|
|
255
|
+
return false;
|
|
256
|
+
return Boolean(agent.tools?.some((tool) => tool.function?.name === "write"));
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 自交接回合:让原 agent 停工、核实、写文档,并校验文件确实落盘。
|
|
260
|
+
*
|
|
261
|
+
* 失败判定(任一成立即视为失败,交由上层单向降级 summary):
|
|
262
|
+
* - 文件不存在 / 小于阈值;
|
|
263
|
+
* - 模型/工具链抛错(例如没有权限、被护栏中断)。
|
|
264
|
+
*/
|
|
265
|
+
async runSelfHandoff(agent, handoffPath) {
|
|
266
|
+
const guard = this.ensureGuard(agent);
|
|
267
|
+
const skillContent = await this.readSkillContent();
|
|
268
|
+
const cwd = agent.scope?.cwd ?? process.cwd();
|
|
269
|
+
/*
|
|
270
|
+
* 清空 lastUsage 再开跑:
|
|
271
|
+
* - 护栏(ContextGuardPlugin)在每轮请求前用 lastUsage 判定严重超限,
|
|
272
|
+
* 而交接恰好发生在上下文最满的时刻;
|
|
273
|
+
* - AutoMigratePlugin 也会读它。busy 已能让两者让行,这里再清一次是纵深防御。
|
|
274
|
+
* 交接回合结束后 lastUsage 由本轮真实响应用量回填,无需恢复旧值。
|
|
275
|
+
*/
|
|
276
|
+
agent.lastUsage = undefined;
|
|
277
|
+
guard.arm(handoffPath, cwd);
|
|
278
|
+
try {
|
|
279
|
+
await agent.send(HandoffService.createHandoffInstruction({
|
|
280
|
+
skillContent,
|
|
281
|
+
handoffPath,
|
|
282
|
+
toolsAvailable: true,
|
|
283
|
+
}));
|
|
284
|
+
if (await this.isHandoffFileReady(handoffPath))
|
|
285
|
+
return true;
|
|
286
|
+
this.logger.warn(`[handoff] 交接文档缺失或过小,重试一次:${handoffPath}`);
|
|
287
|
+
await agent.send([
|
|
288
|
+
`交接文档不存在或内容过少,无法交付:${handoffPath}`,
|
|
289
|
+
"请立即用 write 工具把**完整**交接文档写入该路径(路径原样使用),不要做其他任何事。",
|
|
290
|
+
].join("\n"));
|
|
291
|
+
return await this.isHandoffFileReady(handoffPath);
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
this.logger.error(`[handoff] 自交接回合失败:${err?.message ?? err}`);
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
guard.disarm();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** 独立摘要:一次模型调用,用同一份 Skill 正文作为规范 */
|
|
302
|
+
async generateSummary(agent, historyText, handoffPath) {
|
|
303
|
+
const client = this.client ?? agent.client;
|
|
304
|
+
const model = this.model ?? agent.model;
|
|
305
|
+
const modelConfig = this.modelConfig ?? agent.modelConfig;
|
|
306
|
+
const skillContent = await this.readSkillContent();
|
|
307
|
+
const response = await client.chat.completions.create({
|
|
308
|
+
model,
|
|
309
|
+
...(modelConfig ?? {}),
|
|
310
|
+
messages: [
|
|
311
|
+
{
|
|
312
|
+
role: "system",
|
|
313
|
+
content: HandoffService.createHandoffInstruction({
|
|
314
|
+
skillContent,
|
|
315
|
+
handoffPath,
|
|
316
|
+
toolsAvailable: false,
|
|
317
|
+
}),
|
|
318
|
+
},
|
|
319
|
+
{ role: "user", content: historyText },
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
const handoffDoc = response.choices[0]?.message?.content;
|
|
323
|
+
if (!handoffDoc || !handoffDoc.trim()) {
|
|
324
|
+
throw new Error("未能生成任务交接文档");
|
|
325
|
+
}
|
|
326
|
+
return handoffDoc;
|
|
327
|
+
}
|
|
328
|
+
/** 落盘(仅 `summary` 模式需要;`self` 模式由 agent 的 write 工具完成) */
|
|
329
|
+
async writeHandoffFile(handoffPath, content) {
|
|
330
|
+
await fs.mkdir(dirname(handoffPath), { recursive: true });
|
|
331
|
+
await fs.writeFile(handoffPath, content, "utf-8");
|
|
332
|
+
}
|
|
333
|
+
/** 交接文档是否可用:文件存在且不小于阈值(不读内容,避免二次读取) */ async isHandoffFileReady(handoffPath) {
|
|
334
|
+
try {
|
|
335
|
+
const stat = await fs.stat(handoffPath);
|
|
336
|
+
return stat.isFile() && stat.size >= this.minHandoffBytes;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** 读取 `handoff` Skill 正文;读不到时回落到兜底模板 */
|
|
343
|
+
async readSkillContent() {
|
|
344
|
+
if (this.skillDirs.length > 0) {
|
|
345
|
+
const skill = await readSkill(this.skillDirs, HANDOFF_SKILL_ID, { silent: true });
|
|
346
|
+
if (skill?.content?.trim())
|
|
347
|
+
return skill.content;
|
|
348
|
+
}
|
|
349
|
+
this.logger.warn(`[handoff] 未找到 ${HANDOFF_SKILL_ID} Skill,使用内置兜底模板(路径:${this.skillDirs.join(", ") || "未配置"})`);
|
|
350
|
+
return FALLBACK_HANDOFF_TEMPLATE;
|
|
351
|
+
}
|
|
352
|
+
/** 守卫按 agent 注册一次(`Agent.use()` 无移除 API,故复用实例 + 开关) */
|
|
353
|
+
ensureGuard(agent) {
|
|
354
|
+
const guard = (this.guard ??= new HandoffGuardPlugin());
|
|
355
|
+
if (!this.guardedAgents.has(agent)) {
|
|
356
|
+
agent.use(guard);
|
|
357
|
+
this.guardedAgents.add(agent);
|
|
358
|
+
}
|
|
359
|
+
return guard;
|
|
360
|
+
}
|
|
361
|
+
/** 钩子容错:抛错只记录,不中断交接(与既有迁移钩子语义一致) */
|
|
362
|
+
async safeHook(hook, ctx) {
|
|
363
|
+
if (!hook)
|
|
364
|
+
return;
|
|
365
|
+
try {
|
|
366
|
+
await hook(ctx);
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
this.logger.error(`[handoff] 钩子执行失败: ${err?.message ?? err}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
@@ -2,7 +2,7 @@ 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: "
|
|
5
|
+
name: "batch_edit",
|
|
6
6
|
description: "批量编辑文件文本,可以优先使用这个工具对文件进行编辑",
|
|
7
7
|
parameters: {
|
|
8
8
|
type: "object",
|
|
@@ -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",
|
|
@@ -14,7 +14,7 @@ export class ExecTool extends SdkCallbackTool {
|
|
|
14
14
|
},
|
|
15
15
|
timeout: {
|
|
16
16
|
type: "number",
|
|
17
|
-
description: "超时时间(毫秒),必填。超时后会终止进程;超过该时长仍未完成即视为超时并终止。建议长时间运行的命令使用
|
|
17
|
+
description: "超时时间(毫秒),必填。超时后会终止进程;超过该时长仍未完成即视为超时并终止。建议长时间运行的命令使用 spawn 异步执行。",
|
|
18
18
|
},
|
|
19
19
|
},
|
|
20
20
|
required: ["command", "timeout"],
|
|
@@ -98,7 +98,7 @@ export class ExecTool extends SdkCallbackTool {
|
|
|
98
98
|
return JSON.stringify({
|
|
99
99
|
outputTooLarge: true,
|
|
100
100
|
warning: `输出过大:stdout + stderr 合计 ${result.stdout.length + result.stderr.length} 字符,` +
|
|
101
|
-
`超过上限 ${limit} 字符。完整内容已分文件落盘(stdout.log / stderr.log),可用
|
|
101
|
+
`超过上限 ${limit} 字符。完整内容已分文件落盘(stdout.log / stderr.log),可用 read 分批读取。`,
|
|
102
102
|
files: ctx.files,
|
|
103
103
|
stdout: { chars: result.stdout.length, lines: countLines(result.stdout), ...stdout },
|
|
104
104
|
stderr: { chars: result.stderr.length, lines: countLines(result.stderr), ...stderr },
|
|
@@ -4,13 +4,13 @@ import { SdkCallbackTool } from "./SdkCallbackTool.js";
|
|
|
4
4
|
import { DEFAULT_MAX_TOOL_OUTPUT, guardOutput, headPreview } from "./outputGuard.js";
|
|
5
5
|
/** 默认总匹配数上限 */
|
|
6
6
|
const DEFAULT_MAX_MATCHES = 200;
|
|
7
|
-
/** 默认文件大小上限(字节,与
|
|
7
|
+
/** 默认文件大小上限(字节,与 ReadTool 的 300KB 口径一致) */
|
|
8
8
|
const DEFAULT_MAX_FILE_SIZE = 300 * 1024;
|
|
9
9
|
/** 默认行内容/匹配子串截断长度 */
|
|
10
10
|
const DEFAULT_MAX_LINE_LENGTH = 200;
|
|
11
11
|
export class FindTextTool extends SdkCallbackTool {
|
|
12
12
|
function = {
|
|
13
|
-
name: "
|
|
13
|
+
name: "find_text",
|
|
14
14
|
description: "查找文本出现的位置,支持普通文本或正则匹配,返回文件名及具体行号、行内容。为避免结果撑爆上下文,默认限制总匹配数、单文件大小与行内容长度,超限会截断并标记 truncated。",
|
|
15
15
|
parameters: {
|
|
16
16
|
type: "object",
|
|
@@ -89,12 +89,12 @@ export class FindTextTool extends SdkCallbackTool {
|
|
|
89
89
|
break; // 中断:stat 后再次检查
|
|
90
90
|
if (!stats.isFile())
|
|
91
91
|
continue;
|
|
92
|
-
// 文件过大则跳过(避免撑爆上下文;与
|
|
92
|
+
// 文件过大则跳过(避免撑爆上下文;与 ReadTool 300KB 口径一致,但此处为跳过而非报错)
|
|
93
93
|
if (stats.size > maxFileSize)
|
|
94
94
|
continue;
|
|
95
95
|
const content = await fsp.readFile(fullPath, "utf-8");
|
|
96
96
|
if (signal?.aborted)
|
|
97
|
-
break; // 中断:
|
|
97
|
+
break; // 中断:read 后再次检查
|
|
98
98
|
const lines = content.split("\n");
|
|
99
99
|
const matches = [];
|
|
100
100
|
for (let index = 0; index < lines.length; index++) {
|
|
@@ -145,7 +145,7 @@ export class FindTextTool extends SdkCallbackTool {
|
|
|
145
145
|
async protect(json) {
|
|
146
146
|
const limit = this.env.config.maxToolOutput ?? DEFAULT_MAX_TOOL_OUTPUT;
|
|
147
147
|
return await guardOutput({
|
|
148
|
-
tool: "
|
|
148
|
+
tool: "find_text",
|
|
149
149
|
content: json,
|
|
150
150
|
isOverLimit: (text) => text.length > limit,
|
|
151
151
|
dump: (_dir, text) => [{ name: "result.json", content: text }],
|
|
@@ -154,7 +154,7 @@ export class FindTextTool extends SdkCallbackTool {
|
|
|
154
154
|
return JSON.stringify({
|
|
155
155
|
outputTooLarge: true,
|
|
156
156
|
warning: `输出过大:结果共 ${json.length} 字符,超过上限 ${limit} 字符。` +
|
|
157
|
-
`完整结果已落盘(result.json),可用
|
|
157
|
+
`完整结果已落盘(result.json),可用 read 分批读取。`,
|
|
158
158
|
files: ctx.files,
|
|
159
159
|
chars: json.length,
|
|
160
160
|
...preview,
|
|
@@ -4,7 +4,7 @@ import type { AgentDefinition, AppConfig, ToolEnv } from "../../../types/index.j
|
|
|
4
4
|
/**
|
|
5
5
|
* 根据文字描述生成图片。**统一返回字符串**(JSON,含图片 URL 列表):
|
|
6
6
|
* 生成结果如何处理(查看/下载/转发)由模型与上层自由决定——
|
|
7
|
-
* 需要让模型看图时,模型可主动调用
|
|
7
|
+
* 需要让模型看图时,模型可主动调用 view_image(URL);需要保存时用 download_file。
|
|
8
8
|
*
|
|
9
9
|
* 依赖 AppConfig 中的 imageModels 和 defaultImageModel 配置(isAvailable 声明),
|
|
10
10
|
* 未配置时由 buildTools 过滤,不暴露给 Agent。
|