@ai-zen/agents-host 0.0.0-stage → 0.1.0-alpha.0
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 +39 -0
- package/LICENSE +21 -0
- package/README.md +759 -2
- package/dist/app.d.ts +384 -0
- package/dist/app.js +1148 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +353 -0
- package/dist/config.d.ts +97 -0
- package/dist/config.js +171 -0
- package/dist/handlers/agentHandlers.d.ts +18 -0
- package/dist/handlers/agentHandlers.js +20 -0
- package/dist/handlers/assetHandlers.d.ts +29 -0
- package/dist/handlers/assetHandlers.js +35 -0
- package/dist/handlers/capabilities.d.ts +26 -0
- package/dist/handlers/capabilities.js +138 -0
- package/dist/handlers/completionHandlers.d.ts +18 -0
- package/dist/handlers/completionHandlers.js +13 -0
- package/dist/handlers/configHandlers.d.ts +31 -0
- package/dist/handlers/configHandlers.js +59 -0
- package/dist/handlers/conversationHandlers.d.ts +118 -0
- package/dist/handlers/conversationHandlers.js +314 -0
- package/dist/handlers/handoffHandlers.d.ts +17 -0
- package/dist/handlers/handoffHandlers.js +14 -0
- package/dist/handlers/hostHandlers.d.ts +39 -0
- package/dist/handlers/hostHandlers.js +56 -0
- package/dist/handlers/index.d.ts +128 -0
- package/dist/handlers/index.js +92 -0
- package/dist/handlers/messageHandlers.d.ts +11 -0
- package/dist/handlers/messageHandlers.js +10 -0
- package/dist/handlers/modelHandlers.d.ts +17 -0
- package/dist/handlers/modelHandlers.js +19 -0
- package/dist/handlers/noticeHandlers.d.ts +24 -0
- package/dist/handlers/noticeHandlers.js +24 -0
- package/dist/handlers/questionHandlers.d.ts +16 -0
- package/dist/handlers/questionHandlers.js +16 -0
- package/dist/handlers/searchHandlers.d.ts +12 -0
- package/dist/handlers/searchHandlers.js +12 -0
- package/dist/handlers/workspaceHandlers.d.ts +29 -0
- package/dist/handlers/workspaceHandlers.js +47 -0
- package/dist/i18n/en.d.ts +243 -0
- package/dist/i18n/en.js +270 -0
- package/dist/i18n/index.d.ts +43 -0
- package/dist/i18n/index.js +72 -0
- package/dist/i18n/zh.d.ts +240 -0
- package/dist/i18n/zh.js +257 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +15 -0
- package/dist/net/UploadReceiver.d.ts +186 -0
- package/dist/net/UploadReceiver.js +494 -0
- package/dist/net/WsHub.d.ts +231 -0
- package/dist/net/WsHub.js +689 -0
- package/dist/net/auth.d.ts +73 -0
- package/dist/net/auth.js +74 -0
- package/dist/net/discovery.d.ts +72 -0
- package/dist/net/discovery.js +171 -0
- package/dist/net/index.d.ts +14 -0
- package/dist/net/index.js +10 -0
- package/dist/runtime/ChatRunner.d.ts +374 -0
- package/dist/runtime/ChatRunner.js +1204 -0
- package/dist/runtime/ConversationDrafts.d.ts +43 -0
- package/dist/runtime/ConversationDrafts.js +47 -0
- package/dist/runtime/RunRegistry.d.ts +79 -0
- package/dist/runtime/RunRegistry.js +108 -0
- package/dist/runtime/ScopeRegistry.d.ts +153 -0
- package/dist/runtime/ScopeRegistry.js +259 -0
- package/dist/runtime/TransientRunner.d.ts +90 -0
- package/dist/runtime/TransientRunner.js +179 -0
- package/dist/runtime/index.d.ts +14 -0
- package/dist/runtime/index.js +10 -0
- package/dist/services/AgentService.d.ts +150 -0
- package/dist/services/AgentService.js +718 -0
- package/dist/services/AssetService.d.ts +338 -0
- package/dist/services/AssetService.js +739 -0
- package/dist/services/AutoNaming.d.ts +92 -0
- package/dist/services/AutoNaming.js +235 -0
- package/dist/services/CompletionService.d.ts +122 -0
- package/dist/services/CompletionService.js +442 -0
- package/dist/services/ConfigService.d.ts +171 -0
- package/dist/services/ConfigService.js +695 -0
- package/dist/services/ConversationService.d.ts +274 -0
- package/dist/services/ConversationService.js +902 -0
- package/dist/services/CredentialService.d.ts +33 -0
- package/dist/services/CredentialService.js +86 -0
- package/dist/services/DiscoveryService.d.ts +182 -0
- package/dist/services/DiscoveryService.js +998 -0
- package/dist/services/HandoffService.d.ts +189 -0
- package/dist/services/HandoffService.js +414 -0
- package/dist/services/Logger.d.ts +130 -0
- package/dist/services/Logger.js +256 -0
- package/dist/services/MessageService.d.ts +88 -0
- package/dist/services/MessageService.js +280 -0
- package/dist/services/ModelService.d.ts +89 -0
- package/dist/services/ModelService.js +489 -0
- package/dist/services/NoticeService.d.ts +93 -0
- package/dist/services/NoticeService.js +217 -0
- package/dist/services/QuestionService.d.ts +184 -0
- package/dist/services/QuestionService.js +457 -0
- package/dist/services/SearchService.d.ts +54 -0
- package/dist/services/SearchService.js +117 -0
- package/dist/services/WorkspaceService.d.ts +96 -0
- package/dist/services/WorkspaceService.js +213 -0
- package/dist/services/imageSize.d.ts +41 -0
- package/dist/services/imageSize.js +146 -0
- package/dist/services/index.d.ts +20 -0
- package/dist/services/index.js +15 -0
- package/dist/services/secretMask.d.ts +24 -0
- package/dist/services/secretMask.js +34 -0
- package/dist/storage/AssetRepository.d.ts +87 -0
- package/dist/storage/AssetRepository.js +180 -0
- package/dist/storage/ConversationRepository.d.ts +55 -0
- package/dist/storage/ConversationRepository.js +128 -0
- package/dist/storage/CredentialRepository.d.ts +28 -0
- package/dist/storage/CredentialRepository.js +47 -0
- package/dist/storage/Database.d.ts +95 -0
- package/dist/storage/Database.js +180 -0
- package/dist/storage/MessageRepository.d.ts +104 -0
- package/dist/storage/MessageRepository.js +323 -0
- package/dist/storage/MetaRepository.d.ts +21 -0
- package/dist/storage/MetaRepository.js +33 -0
- package/dist/storage/NoticeRepository.d.ts +31 -0
- package/dist/storage/NoticeRepository.js +89 -0
- package/dist/storage/QuestionRepository.d.ts +34 -0
- package/dist/storage/QuestionRepository.js +96 -0
- package/dist/storage/WorkspaceRepository.d.ts +25 -0
- package/dist/storage/WorkspaceRepository.js +44 -0
- package/dist/storage/dbWorker.d.ts +10 -0
- package/dist/storage/dbWorker.js +132 -0
- package/dist/storage/index.d.ts +20 -0
- package/dist/storage/index.js +16 -0
- package/dist/storage/migrations.d.ts +26 -0
- package/dist/storage/migrations.js +207 -0
- package/dist/storage/types.d.ts +298 -0
- package/dist/storage/types.js +14 -0
- package/dist/wire/errors.d.ts +105 -0
- package/dist/wire/errors.js +143 -0
- package/dist/wire/events.d.ts +102 -0
- package/dist/wire/events.js +13 -0
- package/dist/wire/frames.d.ts +106 -0
- package/dist/wire/frames.js +156 -0
- package/dist/wire/index.d.ts +31 -0
- package/dist/wire/index.js +31 -0
- package/dist/wire/methods.d.ts +1896 -0
- package/dist/wire/methods.js +99 -0
- package/dist/wire/states.d.ts +487 -0
- package/dist/wire/states.js +61 -0
- package/package.json +63 -3
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交接服务:把「上下文要满了」这件事办成一份写进项目的文档,以及(按选择)一段新对话。
|
|
3
|
+
*
|
|
4
|
+
* 对应能力编号:F-D2(三种选择)、F-D3 / D18(迁移=开新对话,旧的完整保留)、
|
|
5
|
+
* F-D4(只产文档不换会话,可反复打磨)、F-D5(一步完成)、F-D6 / D19(文档写进
|
|
6
|
+
* `<cwd>/.ai-zen/handoffs/`)、F-D7(降级要说明;取消=回到交接前,不重试不报错)、
|
|
7
|
+
* D22(交接在对话停下之后触发,不在流式中途)。
|
|
8
|
+
*
|
|
9
|
+
* 三处刻意的分工:
|
|
10
|
+
*
|
|
11
|
+
* 1. **写文档那一步交给 SDK**(`HandoffService`):模板来自磁盘上的 `handoff` Skill
|
|
12
|
+
* (读不到才用 `FALLBACK_HANDOFF_TEMPLATE`)、`self` 失败降级 `summary`、取消时回退
|
|
13
|
+
* 都已在那里实现。本文件通过一个窄接口 {@link HandoffExecutor} 调它,于是用例可以在
|
|
14
|
+
* 不连模型的前提下验证「降级要说明」「取消不报错」这些产品行为。
|
|
15
|
+
* 2. **换会话那一步在宿主**(D18):SDK 的 `migrate()` 换的是**同一个 agent 的内存历史**,
|
|
16
|
+
* 而我们的产品口径是「旧的完整保留 + 开一段新对话」。所以本文件只调 `handoff()` 产文档,
|
|
17
|
+
* 再自己开新对话、把交接摘要作为它的第一条消息写进库。
|
|
18
|
+
* 3. **降级与取消是两种结果**:降级是成功(文档在,只是执行者退了一档),要说明;取消是
|
|
19
|
+
* 「什么都没发生」,回 `handoff_declined`(不是失败),不重试、不留半个文档。
|
|
20
|
+
*/
|
|
21
|
+
import type { Logger, ModelParams } from "@ai-zen/agents-sdk";
|
|
22
|
+
import { completeOnce, HandoffService as SdkHandoffService } from "@ai-zen/agents-sdk";
|
|
23
|
+
import type { HandoffMode } from "@ai-zen/agents-sdk";
|
|
24
|
+
import type { Language } from "../i18n/index.js";
|
|
25
|
+
import type { AgentFactory, ChatAgent, ChatEventEmitter } from "../runtime/ChatRunner.js";
|
|
26
|
+
import type { RunsView } from "../runtime/RunRegistry.js";
|
|
27
|
+
import type { ScopeRegistry } from "../runtime/ScopeRegistry.js";
|
|
28
|
+
import type { MessageRepository } from "../storage/MessageRepository.js";
|
|
29
|
+
import type { WorkspaceRepository } from "../storage/WorkspaceRepository.js";
|
|
30
|
+
import type { ConversationRepository } from "../storage/ConversationRepository.js";
|
|
31
|
+
import type { ConversationCreateParams, ConversationCreateResult, ConversationGetParams, ConversationGetResult, HandoffListDocumentsParams, HandoffListDocumentsResult, HandoffStartParams, HandoffStartResult } from "../wire/index.js";
|
|
32
|
+
import type { NoticeService } from "./NoticeService.js";
|
|
33
|
+
/** 交接文档落点:`<cwd>/.ai-zen/handoffs`(PRD D19) */
|
|
34
|
+
export declare function handoffDirFor(cwd: string): string;
|
|
35
|
+
/** 交接问题答完 / 交接做完之后推进状态需要的窄接口(结构上 `QuestionRepository` 满足) */
|
|
36
|
+
export interface QuestionMarker {
|
|
37
|
+
get(id: string): Promise<unknown>;
|
|
38
|
+
update(id: string, patch: {
|
|
39
|
+
state?: string;
|
|
40
|
+
answer?: unknown;
|
|
41
|
+
answeredAt?: number | null;
|
|
42
|
+
updatedAt: number;
|
|
43
|
+
}): Promise<number>;
|
|
44
|
+
}
|
|
45
|
+
/** 借 / 还 Scope 的窄接口(结构上 `ScopeRegistry` 满足) */
|
|
46
|
+
export interface ScopeBorrower {
|
|
47
|
+
acquire(workspaceId: string): Promise<Awaited<ReturnType<ScopeRegistry["acquire"]>>>;
|
|
48
|
+
release(workspaceId: string): void;
|
|
49
|
+
}
|
|
50
|
+
/** 对话视图的窄接口(结构上 `ConversationService` 满足:开新对话、取旧对话的完整形状) */
|
|
51
|
+
export interface HandoffConversationPort {
|
|
52
|
+
create(params: ConversationCreateParams): Promise<ConversationCreateResult>;
|
|
53
|
+
get(params: ConversationGetParams): Promise<ConversationGetResult>;
|
|
54
|
+
/**
|
|
55
|
+
* 把刚 `create` 出来的对话落成真行(R56:首次发消息时才写 `conversations`)。
|
|
56
|
+
*
|
|
57
|
+
* 交接开的新对话第一条就是宿主写的摘要,于是它(与普通对话的首发一样)马上就有行。
|
|
58
|
+
*/
|
|
59
|
+
ensurePersisted(conversationId: string): Promise<unknown>;
|
|
60
|
+
}
|
|
61
|
+
/** 取消交接(用户按了取消)。与「交接失败」分开:前者回到交接前,后者要说明原因。 */
|
|
62
|
+
export declare class HandoffCancelledError extends Error {
|
|
63
|
+
constructor();
|
|
64
|
+
}
|
|
65
|
+
/** 一次交接写文档的那一步要什么 */
|
|
66
|
+
export interface HandoffExecutionInput {
|
|
67
|
+
/** 这一轮装配出来的 agent(历史已经装进去了) */
|
|
68
|
+
agent: ChatAgent;
|
|
69
|
+
/** 文档目录(绝对路径) */
|
|
70
|
+
handoffDir: string;
|
|
71
|
+
/** 请求的执行者 */
|
|
72
|
+
mode: HandoffMode;
|
|
73
|
+
promptTokens?: number;
|
|
74
|
+
handoffTokens?: number;
|
|
75
|
+
}
|
|
76
|
+
/** 写文档的结果:文件在哪、**实际**用了哪个执行者(可能被降级) */
|
|
77
|
+
export interface HandoffExecutionResult {
|
|
78
|
+
path: string;
|
|
79
|
+
usedMode: HandoffMode;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* 写文档那一步的抽象。
|
|
83
|
+
*
|
|
84
|
+
* 默认实现走 SDK({@link createSdkHandoffExecutor});用例注入替身即可验证「降级」
|
|
85
|
+
* 「取消」这些路径,而不必真连模型。
|
|
86
|
+
*/
|
|
87
|
+
export type HandoffExecutor = (input: HandoffExecutionInput) => Promise<HandoffExecutionResult>;
|
|
88
|
+
/** `completeOnce` 吃的那种客户端(类型直接从 SDK 的签名里取,不自己 import 第三方类型) */
|
|
89
|
+
export type SummaryModelClient = Parameters<typeof completeOnce>[0]["client"];
|
|
90
|
+
export interface SdkHandoffExecutorOptions {
|
|
91
|
+
/** `handoff` Skill 的查找目录(优先级从高到低);没给就用 SDK 的兜底模板 */
|
|
92
|
+
skillDirs?: string[];
|
|
93
|
+
/** `summary` 模式用的客户端 / 模型 / 参数(不给就用 agent 自己的) */
|
|
94
|
+
client?: SummaryModelClient;
|
|
95
|
+
model?: string;
|
|
96
|
+
modelConfig?: ModelParams;
|
|
97
|
+
/** SDK 那边的日志口(读不到 Skill、self 降级都会在那里留一条) */
|
|
98
|
+
logger?: Logger;
|
|
99
|
+
}
|
|
100
|
+
/** 按 cwd 造一个 SDK 的交接服务(接线层给 `createSdkAgentFactory` 的 `handoff` 注入点用) */
|
|
101
|
+
export declare function createSdkHandoffService(cwd: string, options?: SdkHandoffExecutorOptions): SdkHandoffService;
|
|
102
|
+
/**
|
|
103
|
+
* 默认执行者:把一次交接交给 SDK。
|
|
104
|
+
*
|
|
105
|
+
* 两件 SDK 事实在这里被翻译成本服务的词汇:
|
|
106
|
+
* - `HandoffAbortedError`(用户取消)→ {@link HandoffCancelledError}(取消不是失败);
|
|
107
|
+
* - 返回的 `mode` 是**实际**用的那一档,降级时与请求的不同(F-D7 要说明的就是它)。
|
|
108
|
+
*/
|
|
109
|
+
export declare function createSdkHandoffExecutor(options?: SdkHandoffExecutorOptions): HandoffExecutor;
|
|
110
|
+
export interface HandoffServiceDeps {
|
|
111
|
+
conversations: ConversationRepository;
|
|
112
|
+
workspaces: WorkspaceRepository;
|
|
113
|
+
messages: MessageRepository;
|
|
114
|
+
/** 开新对话 / 取旧对话的完整形状 */
|
|
115
|
+
conversationViews: HandoffConversationPort;
|
|
116
|
+
/** 借 Scope 装配 agent(与一轮对话共用同一份 Scope 计数) */
|
|
117
|
+
scopes: ScopeBorrower;
|
|
118
|
+
/** 装配 agent 的工厂(与 `ChatRunner` 用同一个;跑完即弃,D28) */
|
|
119
|
+
createAgent: AgentFactory;
|
|
120
|
+
/** 写文档那一步;缺省走 SDK */
|
|
121
|
+
execute?: HandoffExecutor;
|
|
122
|
+
/** 「这段对话正在跑一轮」的唯一真相(D22:交接在对话停下之后触发) */
|
|
123
|
+
runs?: RunsView;
|
|
124
|
+
/** 答完之后把那条问题标成已答(`handoff.start` 直接调用时用得上) */
|
|
125
|
+
questions?: QuestionMarker;
|
|
126
|
+
/** 通知通道(「文档写好了」这条提示,F-I8) */
|
|
127
|
+
notices?: NoticeService;
|
|
128
|
+
emit?: ChatEventEmitter;
|
|
129
|
+
/** 交接文档模板用的 Skill 目录(给缺省执行者用) */
|
|
130
|
+
skillDirs?: string[];
|
|
131
|
+
now?: () => number;
|
|
132
|
+
}
|
|
133
|
+
export declare class HandoffService {
|
|
134
|
+
private readonly conversations;
|
|
135
|
+
private readonly workspaces;
|
|
136
|
+
private readonly messages;
|
|
137
|
+
private readonly conversationViews;
|
|
138
|
+
private readonly scopes;
|
|
139
|
+
private readonly createAgent;
|
|
140
|
+
private readonly execute;
|
|
141
|
+
private readonly runs?;
|
|
142
|
+
private readonly questions?;
|
|
143
|
+
private readonly notices?;
|
|
144
|
+
private readonly emit?;
|
|
145
|
+
private readonly now;
|
|
146
|
+
constructor(deps: HandoffServiceDeps);
|
|
147
|
+
/**
|
|
148
|
+
* 开始一次交接(PRD F-D2 / F-D3 / F-D4 / F-D5)。
|
|
149
|
+
*
|
|
150
|
+
* 顺序:确认对话停下 → 借 Scope 装配 agent 并装入历史 → 写文档 →(按 `then`)开新对话。
|
|
151
|
+
* 「对话正在跑」直接 `busy`:交接回合本身就是一次模型调用,插在流式中途会把这一轮的
|
|
152
|
+
* 消息数组改到一半(D22 明确交接在停下之后触发)。
|
|
153
|
+
*/
|
|
154
|
+
start(params: HandoffStartParams, language?: Language): Promise<HandoffStartResult>;
|
|
155
|
+
/**
|
|
156
|
+
* 项目里已有的交接文档(PRD F-D6:写进项目,可反复打磨同一份)。
|
|
157
|
+
*
|
|
158
|
+
* 目录不存在就是「这个项目还没有交接过文档」—— 回空列表,不报错(第一次交接之前本来就
|
|
159
|
+
* 没有这个目录)。按修改时间倒序:最近交出来的那份在列表最上面。
|
|
160
|
+
*/
|
|
161
|
+
listDocuments(params: HandoffListDocumentsParams): Promise<HandoffListDocumentsResult>;
|
|
162
|
+
/** 装配 agent、把历史装进去、交给执行者写文档(Scope 用完就当轮归还,D28) */
|
|
163
|
+
private runHandoffRound;
|
|
164
|
+
/** 这一轮交给 agent 的历史:库里还在上下文里的那些(`omit` 的不发) */
|
|
165
|
+
private loadHistory;
|
|
166
|
+
/** 文件事实:大小与写入时刻都从磁盘取,不由执行者自己报(它可能是模型) */
|
|
167
|
+
private describeDocument;
|
|
168
|
+
/**
|
|
169
|
+
* 开一段新对话(PRD D18 / D19)。
|
|
170
|
+
*
|
|
171
|
+
* 身份与旧的那段完全一致(同 Workspace、同助手、同模型),旧对话一行都不动。第一条消息
|
|
172
|
+
* 是**宿主注入**的交接摘要(`hidden`:不是用户打的字,界面上可以折起来),系统提示仍由
|
|
173
|
+
* 助手定义提供 —— 接手的那一轮装配 agent 时会自然带上。
|
|
174
|
+
*/
|
|
175
|
+
private openFollowUpConversation;
|
|
176
|
+
/** 交接文档的摘要片段(读不出来就给空串:新对话仍然指向文档路径) */
|
|
177
|
+
private excerpt;
|
|
178
|
+
/**
|
|
179
|
+
* 把「用户刚答的那道题」标成已答。
|
|
180
|
+
*
|
|
181
|
+
* `question.answer` 那条路径上已经答过了,这里只对「客户端直接调 `handoff.start` 并带上
|
|
182
|
+
* questionId」的情形起作用;已经是终态的行不动(多端同时到达时不让状态来回跳)。
|
|
183
|
+
*/
|
|
184
|
+
private markQuestionAnswered;
|
|
185
|
+
/** 执行者 / SDK 抛出的东西收敛成宿主的错误:取消是「不交接」,其余是「没走通」 */
|
|
186
|
+
private toHostError;
|
|
187
|
+
private requireConversation;
|
|
188
|
+
private requireWorkspace;
|
|
189
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 交接服务:把「上下文要满了」这件事办成一份写进项目的文档,以及(按选择)一段新对话。
|
|
3
|
+
*
|
|
4
|
+
* 对应能力编号:F-D2(三种选择)、F-D3 / D18(迁移=开新对话,旧的完整保留)、
|
|
5
|
+
* F-D4(只产文档不换会话,可反复打磨)、F-D5(一步完成)、F-D6 / D19(文档写进
|
|
6
|
+
* `<cwd>/.ai-zen/handoffs/`)、F-D7(降级要说明;取消=回到交接前,不重试不报错)、
|
|
7
|
+
* D22(交接在对话停下之后触发,不在流式中途)。
|
|
8
|
+
*
|
|
9
|
+
* 三处刻意的分工:
|
|
10
|
+
*
|
|
11
|
+
* 1. **写文档那一步交给 SDK**(`HandoffService`):模板来自磁盘上的 `handoff` Skill
|
|
12
|
+
* (读不到才用 `FALLBACK_HANDOFF_TEMPLATE`)、`self` 失败降级 `summary`、取消时回退
|
|
13
|
+
* 都已在那里实现。本文件通过一个窄接口 {@link HandoffExecutor} 调它,于是用例可以在
|
|
14
|
+
* 不连模型的前提下验证「降级要说明」「取消不报错」这些产品行为。
|
|
15
|
+
* 2. **换会话那一步在宿主**(D18):SDK 的 `migrate()` 换的是**同一个 agent 的内存历史**,
|
|
16
|
+
* 而我们的产品口径是「旧的完整保留 + 开一段新对话」。所以本文件只调 `handoff()` 产文档,
|
|
17
|
+
* 再自己开新对话、把交接摘要作为它的第一条消息写进库。
|
|
18
|
+
* 3. **降级与取消是两种结果**:降级是成功(文档在,只是执行者退了一档),要说明;取消是
|
|
19
|
+
* 「什么都没发生」,回 `handoff_declined`(不是失败),不重试、不留半个文档。
|
|
20
|
+
*/
|
|
21
|
+
import { promises as fs } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { AgentNS, Message } from "@ai-zen/agents-core";
|
|
24
|
+
import { HandoffAbortedError, HandoffService as SdkHandoffService, } from "@ai-zen/agents-sdk";
|
|
25
|
+
import { DEFAULT_LANGUAGE, render } from "../i18n/index.js";
|
|
26
|
+
import { HostError } from "../wire/errors.js";
|
|
27
|
+
import { toAgentMessage } from "./MessageService.js";
|
|
28
|
+
/** 交接文档落点:`<cwd>/.ai-zen/handoffs`(PRD D19) */
|
|
29
|
+
export function handoffDirFor(cwd) {
|
|
30
|
+
return join(cwd, ".ai-zen", "handoffs");
|
|
31
|
+
}
|
|
32
|
+
/** 新对话开场摘要取多长(交接文档的前若干字符,够模型认出「这是接着上一段的」) */
|
|
33
|
+
const SUMMARY_EXCERPT_CHARS = 800;
|
|
34
|
+
/** 取消交接(用户按了取消)。与「交接失败」分开:前者回到交接前,后者要说明原因。 */
|
|
35
|
+
export class HandoffCancelledError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super("handoff cancelled");
|
|
38
|
+
this.name = "HandoffCancelledError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** 按 cwd 造一个 SDK 的交接服务(接线层给 `createSdkAgentFactory` 的 `handoff` 注入点用) */
|
|
42
|
+
export function createSdkHandoffService(cwd, options = {}) {
|
|
43
|
+
return new SdkHandoffService({
|
|
44
|
+
handoffDir: handoffDirFor(cwd),
|
|
45
|
+
skillDirs: options.skillDirs ?? [],
|
|
46
|
+
client: options.client,
|
|
47
|
+
model: options.model,
|
|
48
|
+
modelConfig: options.modelConfig,
|
|
49
|
+
logger: options.logger,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* 默认执行者:把一次交接交给 SDK。
|
|
54
|
+
*
|
|
55
|
+
* 两件 SDK 事实在这里被翻译成本服务的词汇:
|
|
56
|
+
* - `HandoffAbortedError`(用户取消)→ {@link HandoffCancelledError}(取消不是失败);
|
|
57
|
+
* - 返回的 `mode` 是**实际**用的那一档,降级时与请求的不同(F-D7 要说明的就是它)。
|
|
58
|
+
*/
|
|
59
|
+
export function createSdkHandoffExecutor(options = {}) {
|
|
60
|
+
return async (input) => {
|
|
61
|
+
const service = new SdkHandoffService({
|
|
62
|
+
handoffDir: input.handoffDir,
|
|
63
|
+
skillDirs: options.skillDirs ?? [],
|
|
64
|
+
client: options.client,
|
|
65
|
+
model: options.model,
|
|
66
|
+
modelConfig: options.modelConfig,
|
|
67
|
+
logger: options.logger,
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
const context = await service.handoff({
|
|
71
|
+
agent: input.agent,
|
|
72
|
+
mode: input.mode,
|
|
73
|
+
promptTokens: input.promptTokens,
|
|
74
|
+
handoffTokens: input.handoffTokens,
|
|
75
|
+
});
|
|
76
|
+
return { path: context.handoffPath, usedMode: context.mode };
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error instanceof HandoffAbortedError)
|
|
80
|
+
throw new HandoffCancelledError();
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function iso(ms) {
|
|
86
|
+
return new Date(ms).toISOString();
|
|
87
|
+
}
|
|
88
|
+
/** 请求的执行者是 SDK 认的那两档之一吗 */
|
|
89
|
+
function isHandoffMode(value) {
|
|
90
|
+
return value === "self" || value === "summary";
|
|
91
|
+
}
|
|
92
|
+
/** 交接之后做什么:只产文档 / 顺带开新对话(F-D4 / F-D5) */
|
|
93
|
+
function isHandoffThen(value) {
|
|
94
|
+
return value === "document_only" || value === "new_conversation";
|
|
95
|
+
}
|
|
96
|
+
export class HandoffService {
|
|
97
|
+
conversations;
|
|
98
|
+
workspaces;
|
|
99
|
+
messages;
|
|
100
|
+
conversationViews;
|
|
101
|
+
scopes;
|
|
102
|
+
createAgent;
|
|
103
|
+
execute;
|
|
104
|
+
runs;
|
|
105
|
+
questions;
|
|
106
|
+
notices;
|
|
107
|
+
emit;
|
|
108
|
+
now;
|
|
109
|
+
constructor(deps) {
|
|
110
|
+
this.conversations = deps.conversations;
|
|
111
|
+
this.workspaces = deps.workspaces;
|
|
112
|
+
this.messages = deps.messages;
|
|
113
|
+
this.conversationViews = deps.conversationViews;
|
|
114
|
+
this.scopes = deps.scopes;
|
|
115
|
+
this.createAgent = deps.createAgent;
|
|
116
|
+
this.execute = deps.execute ?? createSdkHandoffExecutor({ skillDirs: deps.skillDirs });
|
|
117
|
+
this.runs = deps.runs;
|
|
118
|
+
this.questions = deps.questions;
|
|
119
|
+
this.notices = deps.notices;
|
|
120
|
+
this.emit = deps.emit;
|
|
121
|
+
this.now = deps.now ?? Date.now;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 开始一次交接(PRD F-D2 / F-D3 / F-D4 / F-D5)。
|
|
125
|
+
*
|
|
126
|
+
* 顺序:确认对话停下 → 借 Scope 装配 agent 并装入历史 → 写文档 →(按 `then`)开新对话。
|
|
127
|
+
* 「对话正在跑」直接 `busy`:交接回合本身就是一次模型调用,插在流式中途会把这一轮的
|
|
128
|
+
* 消息数组改到一半(D22 明确交接在停下之后触发)。
|
|
129
|
+
*/
|
|
130
|
+
async start(params, language = DEFAULT_LANGUAGE) {
|
|
131
|
+
const conversation = await this.requireConversation(params.conversationId);
|
|
132
|
+
if (!isHandoffMode(params.mode)) {
|
|
133
|
+
throw new HostError("invalid_params", {
|
|
134
|
+
params: { field: "mode" },
|
|
135
|
+
detail: { field: "mode", value: params.mode, allowed: ["self", "summary"] },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (!isHandoffThen(params.then)) {
|
|
139
|
+
throw new HostError("invalid_params", {
|
|
140
|
+
params: { field: "then" },
|
|
141
|
+
detail: {
|
|
142
|
+
field: "then",
|
|
143
|
+
value: params.then,
|
|
144
|
+
allowed: ["document_only", "new_conversation"],
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
if (this.runs?.isRunning(conversation.id) === true) {
|
|
149
|
+
throw new HostError("busy", {
|
|
150
|
+
detail: { conversationId: conversation.id, action: "handoff" },
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const workspace = await this.requireWorkspace(conversation.workspaceId);
|
|
154
|
+
const handoffDir = handoffDirFor(workspace.cwd);
|
|
155
|
+
const executed = await this.runHandoffRound(conversation, handoffDir, params.mode, language);
|
|
156
|
+
const document = await this.describeDocument(executed.path, executed.usedMode);
|
|
157
|
+
const degraded = executed.usedMode !== params.mode;
|
|
158
|
+
const problem = degraded ? degradeFact(params.mode, executed.usedMode, language) : null;
|
|
159
|
+
let newConversationId = null;
|
|
160
|
+
if (params.then === "new_conversation") {
|
|
161
|
+
newConversationId = await this.openFollowUpConversation(conversation, document, language);
|
|
162
|
+
}
|
|
163
|
+
if (params.questionId !== undefined) {
|
|
164
|
+
await this.markQuestionAnswered(params.questionId, executed.usedMode);
|
|
165
|
+
}
|
|
166
|
+
if (this.notices) {
|
|
167
|
+
await this.notices.create({
|
|
168
|
+
level: "info",
|
|
169
|
+
code: "notice.handoffWritten",
|
|
170
|
+
params: { path: document.path },
|
|
171
|
+
duration: 8,
|
|
172
|
+
conversationId: conversation.id,
|
|
173
|
+
}, language);
|
|
174
|
+
}
|
|
175
|
+
const view = (await this.conversationViews.get({ conversationId: conversation.id })).conversation;
|
|
176
|
+
return {
|
|
177
|
+
document,
|
|
178
|
+
requestedMode: params.mode,
|
|
179
|
+
usedMode: executed.usedMode,
|
|
180
|
+
degraded,
|
|
181
|
+
problem,
|
|
182
|
+
conversation: view,
|
|
183
|
+
newConversationId,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* 项目里已有的交接文档(PRD F-D6:写进项目,可反复打磨同一份)。
|
|
188
|
+
*
|
|
189
|
+
* 目录不存在就是「这个项目还没有交接过文档」—— 回空列表,不报错(第一次交接之前本来就
|
|
190
|
+
* 没有这个目录)。按修改时间倒序:最近交出来的那份在列表最上面。
|
|
191
|
+
*/
|
|
192
|
+
async listDocuments(params) {
|
|
193
|
+
const workspace = await this.requireWorkspace(params.workspaceId);
|
|
194
|
+
const dir = handoffDirFor(workspace.cwd);
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = await fs.readdir(dir);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { documents: [] };
|
|
201
|
+
}
|
|
202
|
+
const documents = [];
|
|
203
|
+
for (const name of entries) {
|
|
204
|
+
if (!name.endsWith(".md"))
|
|
205
|
+
continue;
|
|
206
|
+
const path = join(dir, name);
|
|
207
|
+
try {
|
|
208
|
+
const stat = await fs.stat(path);
|
|
209
|
+
if (!stat.isFile())
|
|
210
|
+
continue;
|
|
211
|
+
documents.push({
|
|
212
|
+
path,
|
|
213
|
+
name,
|
|
214
|
+
bytes: stat.size,
|
|
215
|
+
modifiedAt: stat.mtime.toISOString(),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
// 读不到的条目跳过:列文档不该因为一个坏文件整页报错
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
documents.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt));
|
|
224
|
+
return { documents };
|
|
225
|
+
}
|
|
226
|
+
/** 装配 agent、把历史装进去、交给执行者写文档(Scope 用完就当轮归还,D28) */
|
|
227
|
+
async runHandoffRound(conversation, handoffDir, mode, language) {
|
|
228
|
+
const scope = await this.scopes.acquire(conversation.workspaceId);
|
|
229
|
+
try {
|
|
230
|
+
const history = await this.loadHistory(conversation.id);
|
|
231
|
+
const agent = await this.createAgent({
|
|
232
|
+
conversationId: conversation.id,
|
|
233
|
+
workspaceId: conversation.workspaceId,
|
|
234
|
+
agentId: conversation.agentId,
|
|
235
|
+
modelId: conversation.modelId,
|
|
236
|
+
// 交接回合用的是与普通一轮同一个 agent 口径(会话级模型与档位)
|
|
237
|
+
thinkingLevel: conversation.thinkingLevel,
|
|
238
|
+
scope,
|
|
239
|
+
history,
|
|
240
|
+
});
|
|
241
|
+
// 历史逐条追加:agent 里已经有系统提示(来自助手定义),这里补上这段对话说过的话
|
|
242
|
+
for (const message of history)
|
|
243
|
+
agent.append(message);
|
|
244
|
+
return await this.execute({ agent, handoffDir, mode });
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
throw this.toHostError(error, conversation.id, language);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
this.scopes.release(conversation.workspaceId);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** 这一轮交给 agent 的历史:库里还在上下文里的那些(`omit` 的不发) */
|
|
254
|
+
async loadHistory(conversationId) {
|
|
255
|
+
const rows = await this.messages.listByConversation(conversationId);
|
|
256
|
+
return rows.filter((row) => !row.omit).map((row) => toAgentMessage(row));
|
|
257
|
+
}
|
|
258
|
+
/** 文件事实:大小与写入时刻都从磁盘取,不由执行者自己报(它可能是模型) */
|
|
259
|
+
async describeDocument(path, mode) {
|
|
260
|
+
try {
|
|
261
|
+
const stat = await fs.stat(path);
|
|
262
|
+
return { path, bytes: stat.size, writtenAt: iso(stat.mtimeMs), mode };
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
throw new HostError("internal", {
|
|
266
|
+
messageKey: "error.handoffFailed",
|
|
267
|
+
params: { reason: "the handoff document is not on disk" },
|
|
268
|
+
detail: { path, reason: "document_missing" },
|
|
269
|
+
cause: error,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 开一段新对话(PRD D18 / D19)。
|
|
275
|
+
*
|
|
276
|
+
* 身份与旧的那段完全一致(同 Workspace、同助手、同模型),旧对话一行都不动。第一条消息
|
|
277
|
+
* 是**宿主注入**的交接摘要(`hidden`:不是用户打的字,界面上可以折起来),系统提示仍由
|
|
278
|
+
* 助手定义提供 —— 接手的那一轮装配 agent 时会自然带上。
|
|
279
|
+
*/
|
|
280
|
+
async openFollowUpConversation(conversation, document, language) {
|
|
281
|
+
const created = await this.conversationViews.create({
|
|
282
|
+
workspaceId: conversation.workspaceId,
|
|
283
|
+
agentId: conversation.agentId,
|
|
284
|
+
modelId: conversation.modelId,
|
|
285
|
+
});
|
|
286
|
+
if (!created.conversation) {
|
|
287
|
+
// 交接开新对话给的是已有项目(`workspaceId`),不会走到「目录待登记」那条路
|
|
288
|
+
throw new HostError("internal", {
|
|
289
|
+
detail: { reason: "conversation_not_created", workspaceId: conversation.workspaceId },
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
const newConversationId = created.conversation.id;
|
|
293
|
+
// 摘要写得进库的前提是这段对话已经有行了(R56:消息的外键指向 `conversations`)
|
|
294
|
+
await this.conversationViews.ensurePersisted(newConversationId);
|
|
295
|
+
const summary = await this.excerpt(document.path);
|
|
296
|
+
const content = render(language, "handoff.newConversationSummary", {
|
|
297
|
+
path: document.path,
|
|
298
|
+
summary,
|
|
299
|
+
});
|
|
300
|
+
const message = new Message({
|
|
301
|
+
role: AgentNS.Role.System,
|
|
302
|
+
content,
|
|
303
|
+
status: AgentNS.MessageStatus.Completed,
|
|
304
|
+
hidden: true,
|
|
305
|
+
});
|
|
306
|
+
const createdAt = this.now();
|
|
307
|
+
await this.messages.upsert({
|
|
308
|
+
id: message.id,
|
|
309
|
+
conversationId: newConversationId,
|
|
310
|
+
role: AgentNS.Role.System,
|
|
311
|
+
status: AgentNS.MessageStatus.Completed,
|
|
312
|
+
omit: false,
|
|
313
|
+
content,
|
|
314
|
+
data: { hidden: true },
|
|
315
|
+
createdAt,
|
|
316
|
+
});
|
|
317
|
+
this.emit?.("conversation.changed", {
|
|
318
|
+
conversationId: newConversationId,
|
|
319
|
+
workspaceId: conversation.workspaceId,
|
|
320
|
+
change: "created",
|
|
321
|
+
messageCount: 1,
|
|
322
|
+
at: iso(createdAt),
|
|
323
|
+
});
|
|
324
|
+
this.emit?.("conversation.changed", {
|
|
325
|
+
conversationId: conversation.id,
|
|
326
|
+
workspaceId: conversation.workspaceId,
|
|
327
|
+
change: "settings_changed",
|
|
328
|
+
messageCount: null,
|
|
329
|
+
at: iso(createdAt),
|
|
330
|
+
});
|
|
331
|
+
return newConversationId;
|
|
332
|
+
}
|
|
333
|
+
/** 交接文档的摘要片段(读不出来就给空串:新对话仍然指向文档路径) */
|
|
334
|
+
async excerpt(path) {
|
|
335
|
+
try {
|
|
336
|
+
const text = await fs.readFile(path, "utf-8");
|
|
337
|
+
return text.length > SUMMARY_EXCERPT_CHARS ? text.slice(0, SUMMARY_EXCERPT_CHARS) : text;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return "";
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* 把「用户刚答的那道题」标成已答。
|
|
345
|
+
*
|
|
346
|
+
* `question.answer` 那条路径上已经答过了,这里只对「客户端直接调 `handoff.start` 并带上
|
|
347
|
+
* questionId」的情形起作用;已经是终态的行不动(多端同时到达时不让状态来回跳)。
|
|
348
|
+
*/
|
|
349
|
+
async markQuestionAnswered(questionId, mode) {
|
|
350
|
+
if (!this.questions)
|
|
351
|
+
return;
|
|
352
|
+
const row = await this.questions.get(questionId);
|
|
353
|
+
if (!row || row.state !== "pending")
|
|
354
|
+
return;
|
|
355
|
+
const at = this.now();
|
|
356
|
+
await this.questions.update(questionId, {
|
|
357
|
+
state: "answered",
|
|
358
|
+
answer: { value: mode, answeredBy: null },
|
|
359
|
+
answeredAt: at,
|
|
360
|
+
updatedAt: at,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/** 执行者 / SDK 抛出的东西收敛成宿主的错误:取消是「不交接」,其余是「没走通」 */
|
|
364
|
+
toHostError(error, conversationId, language) {
|
|
365
|
+
if (HostError.is(error))
|
|
366
|
+
return error;
|
|
367
|
+
if (error instanceof HandoffCancelledError) {
|
|
368
|
+
return new HostError("handoff_declined", {
|
|
369
|
+
detail: { conversationId, reason: "cancelled" },
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
373
|
+
return new HostError("internal", {
|
|
374
|
+
messageKey: "error.handoffFailed",
|
|
375
|
+
params: { reason },
|
|
376
|
+
detail: { conversationId, reason, message: render(language, "error.handoffFailed", { reason }) },
|
|
377
|
+
cause: error,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
async requireConversation(conversationId) {
|
|
381
|
+
const id = typeof conversationId === "string" ? conversationId.trim() : "";
|
|
382
|
+
const row = id.length === 0 ? undefined : await this.conversations.get(id);
|
|
383
|
+
if (!row) {
|
|
384
|
+
throw new HostError("not_found", {
|
|
385
|
+
params: { entity: "conversation" },
|
|
386
|
+
detail: { conversationId },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
return row;
|
|
390
|
+
}
|
|
391
|
+
async requireWorkspace(workspaceId) {
|
|
392
|
+
const id = typeof workspaceId === "string" ? workspaceId.trim() : "";
|
|
393
|
+
const row = id.length === 0 ? undefined : await this.workspaces.get(id);
|
|
394
|
+
if (!row) {
|
|
395
|
+
throw new HostError("not_found", {
|
|
396
|
+
params: { entity: "workspace" },
|
|
397
|
+
detail: { workspaceId },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
return row;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/** 降级的说明(F-D7):退到了哪一档、为什么 —— 两样都要给,缺一样用户就只能猜 */
|
|
404
|
+
function degradeFact(requestedMode, usedMode, language) {
|
|
405
|
+
const reason = render(language, "handoff.degradeReason.fallback");
|
|
406
|
+
const params = { mode: usedMode, reason };
|
|
407
|
+
return {
|
|
408
|
+
code: "handoff.degraded",
|
|
409
|
+
messageKey: "handoff.degraded",
|
|
410
|
+
params,
|
|
411
|
+
message: render(language, "handoff.degraded", params),
|
|
412
|
+
detail: { requestedMode, usedMode },
|
|
413
|
+
};
|
|
414
|
+
}
|