@springbrand/agent-runtime 0.1.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/package.json +28 -0
- package/src/db/approval.repo.ts +291 -0
- package/src/db/ext-context.repo.ts +34 -0
- package/src/db/index.ts +83 -0
- package/src/db/message-ui.repo.ts +39 -0
- package/src/db/milestone.repo.ts +96 -0
- package/src/db/runtime-event-outbox.repo.ts +89 -0
- package/src/db/schema.ts +164 -0
- package/src/db/settlement.repo.ts +104 -0
- package/src/db/steer.repo.ts +73 -0
- package/src/db/submission.repo.ts +323 -0
- package/src/index.ts +133 -0
- package/src/kernel/approval-lifecycle.ts +552 -0
- package/src/kernel/bindings.ts +898 -0
- package/src/kernel/degradation.ts +15 -0
- package/src/kernel/extensions.ts +108 -0
- package/src/kernel/profile.ts +116 -0
- package/src/kernel/public-contracts.ts +17 -0
- package/src/kernel/receipts.ts +124 -0
- package/src/kernel/recoverable-chat-agent.ts +899 -0
- package/src/kernel/state.ts +76 -0
- package/src/kernel/submission-lifecycle.ts +600 -0
- package/src/layers/context/budget/gate.ts +88 -0
- package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
- package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
- package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
- package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
- package/src/layers/orchestration/temporary-agent/core.ts +152 -0
- package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
- package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
- package/src/lib/artifacts.ts +54 -0
- package/src/lib/egress.ts +44 -0
- package/src/lib/execution-level.ts +27 -0
- package/src/lib/extension-name.ts +18 -0
- package/src/lib/host-actions.ts +57 -0
- package/src/lib/mcp.ts +86 -0
- package/src/lib/model-catalog.ts +7 -0
- package/src/lib/prompt.ts +139 -0
- package/src/lib/telemetry-dev.ts +44 -0
- package/src/pi/assembly/context.ts +510 -0
- package/src/pi/assembly/extensions.ts +661 -0
- package/src/pi/assembly/index.ts +19 -0
- package/src/pi/assembly/snapshot.ts +200 -0
- package/src/pi/message/contract.ts +8 -0
- package/src/pi/message/conversion.ts +73 -0
- package/src/pi/message/index.ts +3 -0
- package/src/pi/message/projection.ts +604 -0
- package/src/pi/runtime-adapter/assembly.ts +552 -0
- package/src/pi/runtime-adapter/execution.ts +683 -0
- package/src/pi/runtime-adapter/index.ts +232 -0
- package/src/pi/runtime-adapter/models.ts +243 -0
- package/src/pi/runtime-adapter/recovery.ts +805 -0
- package/src/pi/runtime-adapter/transcript.ts +825 -0
- package/src/pi/session/index.ts +24 -0
- package/src/pi/session/storage.ts +353 -0
- package/src/pi/tool/ai-adapter.ts +100 -0
- package/src/pi/tool/base.ts +110 -0
- package/src/pi/tool/compiler.ts +444 -0
- package/src/pi/tool/core-host.ts +48 -0
- package/src/pi/tool/core.ts +251 -0
- package/src/pi/tool/index.ts +32 -0
- package/src/pi/tool/mcp.ts +319 -0
- package/src/pi/tool/schedule.ts +198 -0
- package/src/pi/tool/skill.ts +455 -0
- package/src/pi/tool/subagent.ts +148 -0
- package/src/pi/tool/web-search/api.ts +1292 -0
- package/src/pi/tool/web-search/index.ts +2 -0
- package/src/pi/tool/web-search/web-search.ts +127 -0
- package/src/pi/tool/workspace-sandbox.ts +664 -0
- package/src/pi/turn/approval.ts +181 -0
- package/src/pi/turn/index.ts +62 -0
- package/src/pi/turn/tool-recovery.ts +792 -0
- package/src/plugins.ts +1024 -0
- package/src/runtime-agent.ts +654 -0
- package/src/runtime.ts +2880 -0
|
@@ -0,0 +1,899 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Agent,
|
|
3
|
+
type Connection,
|
|
4
|
+
type FiberContext,
|
|
5
|
+
type FiberRecoveryContext,
|
|
6
|
+
} from "agents";
|
|
7
|
+
import {
|
|
8
|
+
ChatRecoveryEngine,
|
|
9
|
+
ContinuationState,
|
|
10
|
+
MessageType,
|
|
11
|
+
PreStreamTurns,
|
|
12
|
+
ResumableStream,
|
|
13
|
+
ResumeHandshake,
|
|
14
|
+
STREAM_CLEANUP_DELAY_SECONDS,
|
|
15
|
+
StreamProgressCreditThrottle,
|
|
16
|
+
buildChatRecoveringFrame,
|
|
17
|
+
bumpChatRecoveryProgress,
|
|
18
|
+
cleanupStreamBuffers,
|
|
19
|
+
createChatFiberSnapshot,
|
|
20
|
+
pendingChatTerminal,
|
|
21
|
+
readChatRecoveryProgress,
|
|
22
|
+
resolveChatRecoveryConfig,
|
|
23
|
+
runChatRecoveryExhaustion,
|
|
24
|
+
sendIfOpen,
|
|
25
|
+
setChatRecovering,
|
|
26
|
+
sweepStaleChatRecoveryIncidents,
|
|
27
|
+
unwrapChatFiberSnapshot,
|
|
28
|
+
wrapChatFiberSnapshot,
|
|
29
|
+
type ChatFiberWakeHooks,
|
|
30
|
+
type ChatRecoveryAdapter,
|
|
31
|
+
type ChatRecoveryConfig,
|
|
32
|
+
type ChatRecoveryIncident,
|
|
33
|
+
type ChatRecoveryIncidentEvent,
|
|
34
|
+
type ChatRecoveryScheduleCallback,
|
|
35
|
+
type ChatRecoveryScheduleReason,
|
|
36
|
+
type DispatchRecoveredTurnInput,
|
|
37
|
+
type RecoveryPartial,
|
|
38
|
+
type ResolvedRecoveryStream,
|
|
39
|
+
} from "agents/chat";
|
|
40
|
+
|
|
41
|
+
const RECOVERY_MESSAGE_TYPE = MessageType.CF_AGENT_CHAT_RECOVERING;
|
|
42
|
+
|
|
43
|
+
// #region 本地工具与恢复协议类型
|
|
44
|
+
|
|
45
|
+
// 把未知异常转成可持久化的简短文本;恢复回调记录失败原因时调用,且保留 Error.message 而不持久化不稳定的对象结构。
|
|
46
|
+
function errorText(error: unknown): string {
|
|
47
|
+
return error instanceof Error ? error.message : String(error);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 把协议帧编码为 WebSocket 文本;发送和广播路径在输出前调用,以保持本文件只有一种编码方式。
|
|
51
|
+
function json(value: unknown): string {
|
|
52
|
+
return JSON.stringify(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 说明 Runtime 遇到一次中断对话时要采取的下一步。
|
|
57
|
+
*
|
|
58
|
+
* @remarks
|
|
59
|
+
* 恢复端口的 `classify` 在 Fiber 唤醒或孤立流重连时返回此类型。
|
|
60
|
+
*
|
|
61
|
+
* 调用方应仅在 `retry` 分支提供可重放的业务数据;其余分支只描述如何结束或记录本次恢复。
|
|
62
|
+
*
|
|
63
|
+
* 该联合类型让恢复策略留在具体 Runtime,而通用引擎只处理调度、事故状态和终态化。`Runtime` 与 `Port` 沿用 `../index.ts` 的统一术语。
|
|
64
|
+
*/
|
|
65
|
+
export type RuntimeRecoveryDecision<Data> =
|
|
66
|
+
| { readonly kind: "retry"; readonly data: Data }
|
|
67
|
+
| {
|
|
68
|
+
readonly kind: "park";
|
|
69
|
+
readonly reason: string;
|
|
70
|
+
readonly incidentStatus?: "skipped" | "failed";
|
|
71
|
+
}
|
|
72
|
+
| { readonly kind: "fail"; readonly reason: string }
|
|
73
|
+
| { readonly kind: "ignore"; readonly reason: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 记录一次已执行恢复的最终结果。
|
|
77
|
+
*
|
|
78
|
+
* @remarks
|
|
79
|
+
* `_chatRecoveryRetry` 在调用端口的 `retry` 后读取该结果,并用它关闭对应的恢复事故。
|
|
80
|
+
*
|
|
81
|
+
* 只有失败时才需要 `error`;结果不承载流内容,避免在调度数据与流缓冲区之间复制状态。
|
|
82
|
+
*/
|
|
83
|
+
export interface RuntimeRecoveryResult {
|
|
84
|
+
readonly status: "completed" | "failed";
|
|
85
|
+
readonly error?: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 让通用对话恢复层调用具体 Runtime 的最小业务能力。
|
|
90
|
+
*
|
|
91
|
+
* @remarks
|
|
92
|
+
* 子类在 `createRecoveryPort` 中提供实现,本类在分类、重试、部分内容读取和耗尽处理时调用它。
|
|
93
|
+
*
|
|
94
|
+
* 实现应使 `requestId` 稳定地指向同一业务提交,并保持 `retry` 可被持久调度回调调用。这是 `../index.ts` 所述 `Port` 边界,不应把通用流恢复状态携回子类。
|
|
95
|
+
*/
|
|
96
|
+
export interface RuntimeRecoveryPort<Data> {
|
|
97
|
+
/**
|
|
98
|
+
* 判断一次中断对话应重试、停放、失败还是忽略。
|
|
99
|
+
*
|
|
100
|
+
* @remarks Fiber 恢复和孤立流处理会调用它;实现应只读取当前持久状态,把后续副作用交给返回分支的执行路径。
|
|
101
|
+
*/
|
|
102
|
+
classify(requestId: string): Promise<RuntimeRecoveryDecision<Data>>;
|
|
103
|
+
/**
|
|
104
|
+
* 使用已分类的业务数据重放一次对话。
|
|
105
|
+
*
|
|
106
|
+
* @remarks 持久调度回调会调用它;实现应返回权威终态,而不是仅表示重试已启动。
|
|
107
|
+
*/
|
|
108
|
+
retry(data: Data): Promise<RuntimeRecoveryResult>;
|
|
109
|
+
/**
|
|
110
|
+
* 把无法继续的对话写成用户可见的失败终态。
|
|
111
|
+
*
|
|
112
|
+
* @remarks 分类为 `fail` 或恢复次数耗尽时调用;实现必须更新业务持久状态,不能只发送临时消息。
|
|
113
|
+
*/
|
|
114
|
+
terminalize(requestId: string, message: string): Promise<void>;
|
|
115
|
+
/**
|
|
116
|
+
* 从已持久的流块还原可保留的部分回答。
|
|
117
|
+
*
|
|
118
|
+
* @remarks 孤立流恢复和耗尽处理在读取缓冲区后调用;实现需要容忍不完整的最后一块。
|
|
119
|
+
*/
|
|
120
|
+
readPartial(input: {
|
|
121
|
+
readonly requestId: string;
|
|
122
|
+
readonly chunks: readonly string[];
|
|
123
|
+
}): RecoveryPartial;
|
|
124
|
+
/**
|
|
125
|
+
* 判断新流块是否足以证明恢复进度向前推进。
|
|
126
|
+
*
|
|
127
|
+
* @remarks 每次追加流块时调用;实现应通过传入的 throttle 限制持久化频率,避免每个 token 都写 Durable Object 存储。
|
|
128
|
+
*/
|
|
129
|
+
shouldCreditProgress(input: {
|
|
130
|
+
readonly body: string;
|
|
131
|
+
readonly throttle: StreamProgressCreditThrottle;
|
|
132
|
+
readonly now: number;
|
|
133
|
+
}): boolean;
|
|
134
|
+
/**
|
|
135
|
+
* 把运行时特有的部分段落收敛为耗尽处理能保存的文本段落。
|
|
136
|
+
*
|
|
137
|
+
* @remarks 恢复引擎准备失败终态时调用;返回值只允许 text 与 reasoning,防止未结算的工具状态越过边界。
|
|
138
|
+
*/
|
|
139
|
+
toExhaustionParts(parts: readonly unknown[]): Array<{
|
|
140
|
+
readonly type: "text" | "reasoning";
|
|
141
|
+
readonly text: string;
|
|
142
|
+
}>;
|
|
143
|
+
/**
|
|
144
|
+
* 判断 Runtime 是否正在等待必须由用户完成的交互。
|
|
145
|
+
*
|
|
146
|
+
* @remarks 恢复引擎决定是否继续自动恢复时调用;实现应只反映权威业务状态,避免将等待审批误当成停滞。
|
|
147
|
+
*/
|
|
148
|
+
isAwaitingInteraction(): boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface RecoverableChatAgentOptions {
|
|
152
|
+
readonly fiberPrefix: string;
|
|
153
|
+
readonly snapshotKey: string;
|
|
154
|
+
readonly snapshotKind: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
interface RecoverableFiberInput<Data> {
|
|
158
|
+
readonly requestId: string;
|
|
159
|
+
readonly messageId?: string;
|
|
160
|
+
readonly continuation: boolean;
|
|
161
|
+
readonly messages: ReadonlyArray<{ id?: string; role: string }>;
|
|
162
|
+
readonly recoveryData: Data;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
type RecoveryScheduleData<Data> = Data & {
|
|
166
|
+
readonly incidentId?: string;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// #endregion
|
|
170
|
+
|
|
171
|
+
// #region 装配与子类契约
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 为具体 Runtime 提供与 Pi 无关的对话传输与恢复底座。
|
|
175
|
+
*
|
|
176
|
+
* @remarks
|
|
177
|
+
* `AgentRuntimeKernel` 在 Durable Object 实例构造时继承本类,并通过 `createRecoveryPort` 连接具体业务状态。
|
|
178
|
+
*
|
|
179
|
+
* 子类应通过受保护方法启动 Fiber、写入流和处理客户端续传,不应绕过这些入口自行维护并行恢复状态。
|
|
180
|
+
*
|
|
181
|
+
* 实现将流缓冲、续传握手、恢复事故、Fiber 快照、重试调度与清理收口到一处,防止具体 Runtime 各自保存一套会分叉的状态。`Runtime` 和 `Port` 的含义以 `../index.ts` 为准。
|
|
182
|
+
*
|
|
183
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/durable-execution/
|
|
184
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/
|
|
185
|
+
*/
|
|
186
|
+
export abstract class RecoverableChatAgent<
|
|
187
|
+
Env extends Cloudflare.Env,
|
|
188
|
+
State,
|
|
189
|
+
RecoveryData extends { requestId: string },
|
|
190
|
+
> extends Agent<Env, State> {
|
|
191
|
+
/**
|
|
192
|
+
* 控制对话中断后是否启用自动恢复以及使用哪些限制。
|
|
193
|
+
*
|
|
194
|
+
* @remarks
|
|
195
|
+
* 恢复引擎在处理孤立 Fiber 和事故时读取该值;子类可在实例启动前覆盖它。
|
|
196
|
+
*
|
|
197
|
+
* 保留 Agents SDK 的 `boolean | config` 形状,避免本层创建第二套配置语义。
|
|
198
|
+
*/
|
|
199
|
+
chatRecovery: ChatRecoveryConfig = true;
|
|
200
|
+
|
|
201
|
+
private readonly recoveryOptions: RecoverableChatAgentOptions;
|
|
202
|
+
private readonly resumableStream: ResumableStream;
|
|
203
|
+
private readonly progressThrottles = new Map<
|
|
204
|
+
string,
|
|
205
|
+
StreamProgressCreditThrottle
|
|
206
|
+
>();
|
|
207
|
+
private readonly continuation = new ContinuationState<Connection>();
|
|
208
|
+
private readonly preStream = new PreStreamTurns<Connection>();
|
|
209
|
+
private readonly pendingResumeConnections = new Set<string>();
|
|
210
|
+
private readonly resumeHandshake: ResumeHandshake;
|
|
211
|
+
private activeRecoveryRootRequestId: string | undefined;
|
|
212
|
+
private recoveryEngine?: ChatRecoveryEngine;
|
|
213
|
+
private runtimeRecoveryPort?: RuntimeRecoveryPort<RecoveryData>;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 为一个 Cloudflare Agent 实例建立可续传流与恢复握手状态。
|
|
217
|
+
*
|
|
218
|
+
* @remarks
|
|
219
|
+
* 只由具体 Runtime 构造器通过 `super` 调用;`options` 中的前缀、快照键和种类必须在该 Runtime 的所有版本中保持可识别。
|
|
220
|
+
*
|
|
221
|
+
* 构造期只连接 Agent 提供的 SQL、存储和连接查询,不执行恢复副作用;这保证对象唤醒时由正式回调驱动后续工作。
|
|
222
|
+
*/
|
|
223
|
+
constructor(
|
|
224
|
+
ctx: DurableObjectState,
|
|
225
|
+
env: Env,
|
|
226
|
+
options: RecoverableChatAgentOptions,
|
|
227
|
+
) {
|
|
228
|
+
super(ctx, env);
|
|
229
|
+
this.recoveryOptions = options;
|
|
230
|
+
this.resumableStream = new ResumableStream(this.sql.bind(this));
|
|
231
|
+
this.resumeHandshake = new ResumeHandshake({
|
|
232
|
+
responseMessageType: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
|
|
233
|
+
resumableStream: this.resumableStream,
|
|
234
|
+
continuation: this.continuation,
|
|
235
|
+
preStream: this.preStream,
|
|
236
|
+
pendingResumeConnections: this.pendingResumeConnections,
|
|
237
|
+
pendingChatTerminal: () => pendingChatTerminal(this.ctx.storage),
|
|
238
|
+
persistOrphanedStream: (streamId) =>
|
|
239
|
+
this.recoverOrphanedStream(streamId),
|
|
240
|
+
isConnectionPresent: (connectionId) =>
|
|
241
|
+
this.getConnection(connectionId) !== undefined,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 创建连接通用恢复机制与具体 Runtime 状态的端口。
|
|
247
|
+
*
|
|
248
|
+
* @remarks
|
|
249
|
+
* 本类首次需要分类、重试或解析部分流时惰性调用;子类只需返回一个稳定实现。
|
|
250
|
+
*
|
|
251
|
+
* 实现由 `recoveryPort` 缓存,因此不能依赖每次调用都重新构建的短命状态。
|
|
252
|
+
*/
|
|
253
|
+
protected abstract createRecoveryPort(): RuntimeRecoveryPort<RecoveryData>;
|
|
254
|
+
|
|
255
|
+
// 作用:惰性取得并缓存子类提供的恢复端口。
|
|
256
|
+
// 调用:Fiber、续传和调度恢复路径在访问恢复 I/O 时调用。
|
|
257
|
+
// 原因:同一 Agent 必须复用单例,避免策略状态分叉。
|
|
258
|
+
private recoveryPort(): RuntimeRecoveryPort<RecoveryData> {
|
|
259
|
+
return (this.runtimeRecoveryPort ??= this.createRecoveryPort());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// #endregion
|
|
263
|
+
|
|
264
|
+
// #region 可恢复轮次与流生命周期
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 在可持久恢复的 Fiber 和可续传流中执行一次对话。
|
|
268
|
+
*
|
|
269
|
+
* @remarks
|
|
270
|
+
* 子类在业务提交进入模型或工具循环前调用,并在 `run` 中使用传入的 `streamId` 写入增量记录。
|
|
271
|
+
*
|
|
272
|
+
* 该方法先固定快照和恢复根请求,再启动流;这个顺序使 Agent 被中断后能把 Fiber、流和业务提交重新关联。不要在子类中另外创建同一轮次的恢复快照。
|
|
273
|
+
*
|
|
274
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/durable-execution/
|
|
275
|
+
*/
|
|
276
|
+
protected async runRecoverableChatFiber(
|
|
277
|
+
input: RecoverableFiberInput<RecoveryData>,
|
|
278
|
+
run: (fiber: FiberContext, streamId: string) => Promise<void>,
|
|
279
|
+
): Promise<void> {
|
|
280
|
+
const snapshot = createChatFiberSnapshot({
|
|
281
|
+
kind: this.recoveryOptions.snapshotKind,
|
|
282
|
+
requestId: input.requestId,
|
|
283
|
+
recoveryRootRequestId:
|
|
284
|
+
this.activeRecoveryRootRequestId ?? input.requestId,
|
|
285
|
+
continuation: input.continuation,
|
|
286
|
+
messages: input.messages,
|
|
287
|
+
});
|
|
288
|
+
await this._runFiberWithStashWrapper(
|
|
289
|
+
this.recoveryOptions.fiberPrefix + input.requestId,
|
|
290
|
+
async (fiber) => {
|
|
291
|
+
const streamId = this.startRecoverableStream(input.requestId, {
|
|
292
|
+
...(input.messageId === undefined
|
|
293
|
+
? {}
|
|
294
|
+
: { messageId: input.messageId }),
|
|
295
|
+
continuation: input.continuation,
|
|
296
|
+
});
|
|
297
|
+
try {
|
|
298
|
+
await run(fiber, streamId);
|
|
299
|
+
} finally {
|
|
300
|
+
this.progressThrottles.delete(streamId);
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
initialSnapshot: wrapChatFiberSnapshot(
|
|
305
|
+
this.recoveryOptions.snapshotKey,
|
|
306
|
+
snapshot,
|
|
307
|
+
input.recoveryData,
|
|
308
|
+
),
|
|
309
|
+
wrapStash: (data) =>
|
|
310
|
+
wrapChatFiberSnapshot(
|
|
311
|
+
this.recoveryOptions.snapshotKey,
|
|
312
|
+
snapshot,
|
|
313
|
+
data,
|
|
314
|
+
),
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 把一块对话输出追加到可续传流,并在值得时记录恢复进度。
|
|
321
|
+
*
|
|
322
|
+
* @remarks
|
|
323
|
+
* 子类在向客户端广播每个已结构化的流记录前调用;`streamId` 必须来自 `runRecoverableChatFiber` 的当前回调。
|
|
324
|
+
*
|
|
325
|
+
* 流块每次都先进缓冲区,但持久化进度由端口节流;这避免逐 token 写存储,同时让停滞检测看到有意义的前进。
|
|
326
|
+
*/
|
|
327
|
+
protected async appendRecoverableChunk(
|
|
328
|
+
streamId: string,
|
|
329
|
+
body: string,
|
|
330
|
+
): Promise<void> {
|
|
331
|
+
this.resumableStream.storeChunk(streamId, body);
|
|
332
|
+
let throttle = this.progressThrottles.get(streamId);
|
|
333
|
+
if (!throttle) {
|
|
334
|
+
throttle = new StreamProgressCreditThrottle();
|
|
335
|
+
this.progressThrottles.set(streamId, throttle);
|
|
336
|
+
}
|
|
337
|
+
if (this.recoveryPort().shouldCreditProgress({
|
|
338
|
+
body,
|
|
339
|
+
throttle,
|
|
340
|
+
now: Date.now(),
|
|
341
|
+
})) {
|
|
342
|
+
this.resumableStream.flushBuffer();
|
|
343
|
+
await bumpChatRecoveryProgress(this.ctx.storage);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 把可续传流标记为成功完成并安排延后清理。
|
|
349
|
+
*
|
|
350
|
+
* @remarks
|
|
351
|
+
* 子类只在对应业务提交已提交权威成功终态后调用;不要用它表示“暂无更多块”。
|
|
352
|
+
*
|
|
353
|
+
* 清理延后执行,为断线重连的客户端保留终态流片段。
|
|
354
|
+
*/
|
|
355
|
+
protected completeRecoverableStream(streamId: string): void {
|
|
356
|
+
this.resumableStream.complete(streamId);
|
|
357
|
+
void this.ensureStreamCleanupScheduled();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* 把可续传流标记为失败并安排延后清理。
|
|
362
|
+
*
|
|
363
|
+
* @remarks
|
|
364
|
+
* 子类在轮次失败、取消或未形成权威助手终态时调用;调用后仍应由业务层写入失败终态。
|
|
365
|
+
*
|
|
366
|
+
* 流错误标志与业务失败分开,因为续传协议和提交生命周期的责任不同。
|
|
367
|
+
*/
|
|
368
|
+
protected failRecoverableStream(streamId: string): void {
|
|
369
|
+
this.resumableStream.markError(streamId);
|
|
370
|
+
void this.ensureStreamCleanupScheduled();
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* 结算一个尚未建立流的对话请求。
|
|
375
|
+
*
|
|
376
|
+
* @remarks
|
|
377
|
+
* 子类在提交执行结束的 `finally` 路径调用,以便无论流是否真正开始都能释放等待续传的连接。
|
|
378
|
+
*
|
|
379
|
+
* 只在没有活动流时批量释放等待者,避免把尚可续传的轮次误判为结束。
|
|
380
|
+
*/
|
|
381
|
+
protected settlePreStream(requestId: string): void {
|
|
382
|
+
if (
|
|
383
|
+
this.preStream.settle(requestId) &&
|
|
384
|
+
!this.resumableStream.hasActiveStream()
|
|
385
|
+
) {
|
|
386
|
+
this.preStream.releaseAwaiting();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 记录一个已接收但尚未建立可续传流的请求。
|
|
392
|
+
*
|
|
393
|
+
* @remarks
|
|
394
|
+
* 子类在接受客户端提交后立即调用,且必须使用与后续 Fiber 相同的 `requestId`。
|
|
395
|
+
*
|
|
396
|
+
* 单独记录这个窗口,是为了让刚提交就断线的客户端也能等到流启动,而不是过早放弃续传。
|
|
397
|
+
*/
|
|
398
|
+
protected beginPreStream(requestId: string): void {
|
|
399
|
+
this.preStream.begin(requestId);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 删除当前 Agent 保存的所有可续传流数据。
|
|
404
|
+
*
|
|
405
|
+
* @remarks
|
|
406
|
+
* 子类只在用户明确清空且业务数据同处一事务边界时调用;不应将它用作单个轮次的常规清理。
|
|
407
|
+
*
|
|
408
|
+
* 使用 `ResumableStream.clearAll` 与业务清空收口,防止聊天记录已删除但旧流仍可重放。
|
|
409
|
+
*/
|
|
410
|
+
protected clearRecoverableStreamStorage(): void {
|
|
411
|
+
this.resumableStream.clearAll();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 重置当前内存中的续传连接、继续状态和流前请求。
|
|
416
|
+
*
|
|
417
|
+
* @remarks
|
|
418
|
+
* 子类在持久聊天数据已成功清空后调用;它不删除流表,因此通常与 `clearRecoverableStreamStorage` 成对使用。
|
|
419
|
+
*
|
|
420
|
+
* 独立清理内存状态能避免已失效连接在清空后继续收到旧轮次的续传信号。
|
|
421
|
+
*/
|
|
422
|
+
protected clearRecoverableChatState(): void {
|
|
423
|
+
this.pendingResumeConnections.clear();
|
|
424
|
+
this.continuation.clearAll();
|
|
425
|
+
this.preStream.reset();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// #endregion
|
|
429
|
+
|
|
430
|
+
// #region 客户端连接与续传协议
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* 处理客户端发起的流续传探测。
|
|
434
|
+
*
|
|
435
|
+
* @remarks
|
|
436
|
+
* 子类在收到 `stream-resume-request` 协议消息时调用,并原样传入当前连接与可选 probe id。
|
|
437
|
+
*
|
|
438
|
+
* 所有握手状态由 `ResumeHandshake` 集中管理,避免子类自行判断流、流前请求和终态之间的优先级。
|
|
439
|
+
*/
|
|
440
|
+
protected handleResumeRequest(
|
|
441
|
+
connection: Connection,
|
|
442
|
+
probeId?: string,
|
|
443
|
+
): Promise<void> {
|
|
444
|
+
return this.resumeHandshake.handleResumeRequest(connection, probeId);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 处理客户端对某次续传的确认。
|
|
449
|
+
*
|
|
450
|
+
* @remarks
|
|
451
|
+
* 子类在收到 `stream-resume-ack` 协议消息时调用,并使用该消息的请求 id 结算握手。
|
|
452
|
+
*
|
|
453
|
+
* 确认由 `ResumeHandshake` 统一释放暂存的连接状态,不能与普通聊天响应的 `done` 信号混用。
|
|
454
|
+
*/
|
|
455
|
+
protected handleResumeAck(
|
|
456
|
+
connection: Connection,
|
|
457
|
+
requestId: string,
|
|
458
|
+
): Promise<void> {
|
|
459
|
+
return this.resumeHandshake.handleResumeAck(connection, requestId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 向当前可用的聊天连接广播一帧轮次响应。
|
|
464
|
+
*
|
|
465
|
+
* @remarks
|
|
466
|
+
* 子类在发送增量记录、成功终态或失败终态时调用;`done` 表示轮次终态,`error` 只在终态失败时设置。
|
|
467
|
+
*
|
|
468
|
+
* 广播显式排除正在续传握手的连接,避免它们在历史流重放完成前同时收到实时帧。
|
|
469
|
+
*/
|
|
470
|
+
protected sendChatResponse(
|
|
471
|
+
requestId: string,
|
|
472
|
+
body: string,
|
|
473
|
+
done: boolean,
|
|
474
|
+
error = false,
|
|
475
|
+
): void {
|
|
476
|
+
this.broadcast(
|
|
477
|
+
json({
|
|
478
|
+
type: MessageType.CF_AGENT_USE_CHAT_RESPONSE,
|
|
479
|
+
id: requestId,
|
|
480
|
+
body,
|
|
481
|
+
done,
|
|
482
|
+
...(error ? { error: true } : {}),
|
|
483
|
+
}),
|
|
484
|
+
[...this.pendingResumeConnections],
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 在新连接建立时同步当前恢复状态并启动续传通知。
|
|
490
|
+
*
|
|
491
|
+
* @remarks
|
|
492
|
+
* Agents SDK 在 WebSocket 连接建立后调用;子类覆盖时应调用 `super.onConnect`,否则新客户端无法得知已在进行的恢复。
|
|
493
|
+
*
|
|
494
|
+
* 先发送持久化的 recovering 帧,再通知流重放,使客户端先建立正确的界面状态。
|
|
495
|
+
*/
|
|
496
|
+
async onConnect(connection: Connection): Promise<void> {
|
|
497
|
+
const recovering = await buildChatRecoveringFrame(
|
|
498
|
+
this.ctx.storage,
|
|
499
|
+
RECOVERY_MESSAGE_TYPE,
|
|
500
|
+
Date.now(),
|
|
501
|
+
);
|
|
502
|
+
if (recovering) sendIfOpen(connection, json(recovering));
|
|
503
|
+
this.resumeHandshake.notifyStreamResuming(connection);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 在连接关闭时释放该连接持有的续传和流前等待状态。
|
|
508
|
+
*
|
|
509
|
+
* @remarks
|
|
510
|
+
* Agents SDK 在 WebSocket 断开时调用;子类覆盖时应调用 `super.onClose`。
|
|
511
|
+
*
|
|
512
|
+
* 此处只清理按 connection id 索引的内存状态,不删除持久流,因为断线正是续传需要保留数据的场景。
|
|
513
|
+
*/
|
|
514
|
+
onClose(
|
|
515
|
+
connection: Connection,
|
|
516
|
+
_code: number,
|
|
517
|
+
_reason: string,
|
|
518
|
+
_wasClean: boolean,
|
|
519
|
+
): void {
|
|
520
|
+
this.pendingResumeConnections.delete(connection.id);
|
|
521
|
+
this.continuation.releaseConnection(connection.id);
|
|
522
|
+
this.preStream.release(connection.id);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// #endregion
|
|
526
|
+
|
|
527
|
+
// #region Fiber 恢复分类与重试调度
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 执行一次已持久调度的对话重试,并结算对应恢复事故。
|
|
531
|
+
*
|
|
532
|
+
* @remarks
|
|
533
|
+
* Agents SDK 的调度器在 `dispatchRecoveredTurn` 创建的延时任务到期时按方法名调用;它是框架回调,不是供子类直接触发的重试入口。
|
|
534
|
+
*
|
|
535
|
+
* 执行期间暂存恢复根请求,使重试内新建的 Fiber 仍归属原始事故;`finally` 必须恢复旧值,否则后续正常轮次会被错误关联。
|
|
536
|
+
*
|
|
537
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/
|
|
538
|
+
*/
|
|
539
|
+
async _chatRecoveryRetry(
|
|
540
|
+
data?: RecoveryScheduleData<RecoveryData>,
|
|
541
|
+
): Promise<void> {
|
|
542
|
+
if (!data) return;
|
|
543
|
+
const previous = this.activeRecoveryRootRequestId;
|
|
544
|
+
this.activeRecoveryRootRequestId = data.requestId;
|
|
545
|
+
try {
|
|
546
|
+
const result = await this.recoveryPort().retry(data);
|
|
547
|
+
await this.engine().updateIncident(
|
|
548
|
+
data.incidentId,
|
|
549
|
+
result.status === "completed" ? "completed" : "failed",
|
|
550
|
+
result.status === "completed"
|
|
551
|
+
? undefined
|
|
552
|
+
: result.error ?? "Chat recovery failed",
|
|
553
|
+
);
|
|
554
|
+
} catch (error) {
|
|
555
|
+
await this.engine().updateIncident(
|
|
556
|
+
data.incidentId,
|
|
557
|
+
"failed",
|
|
558
|
+
errorText(error),
|
|
559
|
+
);
|
|
560
|
+
throw error;
|
|
561
|
+
} finally {
|
|
562
|
+
this.activeRecoveryRootRequestId = previous;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 把 Agents SDK 发现的孤立 Fiber 交给聊天恢复引擎。
|
|
568
|
+
*
|
|
569
|
+
* @remarks
|
|
570
|
+
* Agent 在 Durable Object 重新激活并扫描到未完成 Fiber 时调用;返回值表示该 Fiber 是否由聊天恢复路径接管。
|
|
571
|
+
*
|
|
572
|
+
* 所有快照解包、分类与重试派发都经过同一引擎,避免 SDK 唤醒与客户端续传各用一套恢复规则。
|
|
573
|
+
*
|
|
574
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/durable-execution/
|
|
575
|
+
*/
|
|
576
|
+
protected override async _handleInternalFiberRecovery(
|
|
577
|
+
context: FiberRecoveryContext,
|
|
578
|
+
): Promise<boolean> {
|
|
579
|
+
return this.engine().handleChatFiberRecovery<
|
|
580
|
+
RuntimeRecoveryDecision<RecoveryData>
|
|
581
|
+
>(context, this.wakeHooks());
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// 作用:组装 Fiber 唤醒需要的回调。
|
|
585
|
+
// 调用:内部恢复入口每次处理恢复上下文时调用。
|
|
586
|
+
// 原因:单一适配器可以统一快照解包、分类和派发边界。
|
|
587
|
+
private wakeHooks(): ChatFiberWakeHooks<
|
|
588
|
+
RuntimeRecoveryDecision<RecoveryData>
|
|
589
|
+
> {
|
|
590
|
+
return {
|
|
591
|
+
chatFiberPrefix: () => this.recoveryOptions.fiberPrefix,
|
|
592
|
+
unwrapRecoverySnapshot: (context) => {
|
|
593
|
+
const { snapshot, user } = unwrapChatFiberSnapshot(
|
|
594
|
+
this.recoveryOptions.snapshotKey,
|
|
595
|
+
context.snapshot,
|
|
596
|
+
this.recoveryOptions.snapshotKind,
|
|
597
|
+
);
|
|
598
|
+
return { snapshot, recoveryData: user };
|
|
599
|
+
},
|
|
600
|
+
classifyRecoveredTurn: async (input) => ({
|
|
601
|
+
recoveryKind: "retry",
|
|
602
|
+
detail: await this.recoveryPort().classify(
|
|
603
|
+
input.snapshot?.requestId ?? input.requestId,
|
|
604
|
+
),
|
|
605
|
+
}),
|
|
606
|
+
shouldPersistOrphanedPartial: (input) => input.streamStillActive,
|
|
607
|
+
persistOrphanedStream: async () => {},
|
|
608
|
+
completeRecoveredStream: (streamId) => {
|
|
609
|
+
this.completeRecoverableStream(streamId);
|
|
610
|
+
},
|
|
611
|
+
dispatchRecoveredTurn: (input) =>
|
|
612
|
+
this.dispatchRecoveredTurn(input),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// 作用:把已分类的恢复决策落到事故终态或持久调度。
|
|
617
|
+
// 调用:唤醒回调在恢复分类完成后调用。
|
|
618
|
+
// 原因:单一分支点可以防止各入口对 retry、park、fail 和 ignore 的解释分叉。
|
|
619
|
+
private async dispatchRecoveredTurn(
|
|
620
|
+
input: DispatchRecoveredTurnInput<
|
|
621
|
+
RuntimeRecoveryDecision<RecoveryData>
|
|
622
|
+
>,
|
|
623
|
+
): Promise<void> {
|
|
624
|
+
const decision = input.detail;
|
|
625
|
+
if (decision.kind === "park") {
|
|
626
|
+
await this.engine().updateIncident(
|
|
627
|
+
input.incident.incidentId,
|
|
628
|
+
decision.incidentStatus ?? "skipped",
|
|
629
|
+
decision.reason,
|
|
630
|
+
);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (decision.kind === "ignore") {
|
|
634
|
+
await this.engine().updateIncident(
|
|
635
|
+
input.incident.incidentId,
|
|
636
|
+
"completed",
|
|
637
|
+
decision.reason,
|
|
638
|
+
);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (decision.kind === "fail") {
|
|
642
|
+
await this.recoveryPort().terminalize(
|
|
643
|
+
input.recoveryRootRequestId,
|
|
644
|
+
decision.reason,
|
|
645
|
+
);
|
|
646
|
+
await this.engine().updateIncident(
|
|
647
|
+
input.incident.incidentId,
|
|
648
|
+
"failed",
|
|
649
|
+
decision.reason,
|
|
650
|
+
);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
await this.engine().scheduleRecovery({
|
|
654
|
+
incident: input.incident,
|
|
655
|
+
recoveryKind: "retry",
|
|
656
|
+
callback: "_chatRecoveryRetry",
|
|
657
|
+
data: {
|
|
658
|
+
...decision.data,
|
|
659
|
+
incidentId: input.incident.incidentId,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// #endregion
|
|
665
|
+
|
|
666
|
+
// #region 恢复引擎适配与事故记账
|
|
667
|
+
|
|
668
|
+
// 作用:惰性取得并缓存聊天恢复引擎。
|
|
669
|
+
// 调用:Fiber、续传和调度路径在处理恢复事故时调用。
|
|
670
|
+
// 原因:共用单例可以保持事故处理只有一个统一入口。
|
|
671
|
+
private engine(): ChatRecoveryEngine {
|
|
672
|
+
return (this.recoveryEngine ??= new ChatRecoveryEngine(
|
|
673
|
+
this.recoveryAdapter(),
|
|
674
|
+
));
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// 作用:把 Agent 的存储、调度、事件与流能力映射给恢复引擎。
|
|
678
|
+
// 调用:恢复引擎在首次惰性初始化时调用。
|
|
679
|
+
// 原因:适配边界可以避免恢复策略直接依赖具体 Durable Object 类。
|
|
680
|
+
private recoveryAdapter(): ChatRecoveryAdapter {
|
|
681
|
+
return {
|
|
682
|
+
resolveConfig: () => resolveChatRecoveryConfig(this.chatRecovery),
|
|
683
|
+
now: () => Date.now(),
|
|
684
|
+
sweepStaleIncidents: (now) =>
|
|
685
|
+
sweepStaleChatRecoveryIncidents(this.ctx.storage, now),
|
|
686
|
+
getIncident: (key) =>
|
|
687
|
+
this.ctx.storage
|
|
688
|
+
.get<ChatRecoveryIncident>(key)
|
|
689
|
+
.then((value) => value ?? null),
|
|
690
|
+
readProgress: () => readChatRecoveryProgress(this.ctx.storage),
|
|
691
|
+
isAwaitingClientInteraction: () =>
|
|
692
|
+
this.recoveryPort().isAwaitingInteraction(),
|
|
693
|
+
putIncident: (key, incident) =>
|
|
694
|
+
this.ctx.storage.put(key, incident),
|
|
695
|
+
deleteIncident: (key) =>
|
|
696
|
+
this.ctx.storage.delete(key).then(() => {}),
|
|
697
|
+
emitRecoveryEvent: (event: ChatRecoveryIncidentEvent) =>
|
|
698
|
+
this._emit(event.type, { ...event }),
|
|
699
|
+
scheduleRecovery: async (
|
|
700
|
+
callback: ChatRecoveryScheduleCallback,
|
|
701
|
+
data: Record<string, unknown>,
|
|
702
|
+
reason: ChatRecoveryScheduleReason,
|
|
703
|
+
delaySeconds: number,
|
|
704
|
+
) => {
|
|
705
|
+
await this.schedule(
|
|
706
|
+
delaySeconds,
|
|
707
|
+
callback as keyof this,
|
|
708
|
+
data,
|
|
709
|
+
{ idempotent: reason === "initial" },
|
|
710
|
+
);
|
|
711
|
+
},
|
|
712
|
+
setRecovering: (active, requestId) =>
|
|
713
|
+
this.setRecovering(active, requestId),
|
|
714
|
+
onShouldKeepRecoveringError: (error) =>
|
|
715
|
+
console.error("[chat-recovery] shouldKeepRecovering threw", error),
|
|
716
|
+
exhaustChatRecovery: (
|
|
717
|
+
incident,
|
|
718
|
+
config,
|
|
719
|
+
partial,
|
|
720
|
+
streamId,
|
|
721
|
+
createdAt,
|
|
722
|
+
) =>
|
|
723
|
+
runChatRecoveryExhaustion(
|
|
724
|
+
{
|
|
725
|
+
incident,
|
|
726
|
+
config,
|
|
727
|
+
partialText: partial.text,
|
|
728
|
+
partialParts: this.recoveryPort().toExhaustionParts(
|
|
729
|
+
partial.parts,
|
|
730
|
+
),
|
|
731
|
+
streamId,
|
|
732
|
+
createdAt,
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
emit: (context) =>
|
|
736
|
+
this._emit("chat:recovery:exhausted", { ...context }),
|
|
737
|
+
onError: (error) =>
|
|
738
|
+
console.error("[chat-recovery] onExhausted threw", error),
|
|
739
|
+
terminalize: async (context) => {
|
|
740
|
+
await this.recoveryPort().terminalize(
|
|
741
|
+
context.recoveryRootRequestId ?? context.requestId,
|
|
742
|
+
context.terminalMessage,
|
|
743
|
+
);
|
|
744
|
+
await this.setRecovering(false, context.requestId);
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
),
|
|
748
|
+
resolveRecoveryStream: (requestId): ResolvedRecoveryStream => {
|
|
749
|
+
const streamId = this.resolveStreamId(requestId);
|
|
750
|
+
return {
|
|
751
|
+
streamId,
|
|
752
|
+
streamStillActive:
|
|
753
|
+
streamId !== "" &&
|
|
754
|
+
streamId === this.resumableStream.activeStreamId,
|
|
755
|
+
};
|
|
756
|
+
},
|
|
757
|
+
getPartialStreamText: (streamId): RecoveryPartial => {
|
|
758
|
+
const metadata = this.resumableStream.getStreamMetadata(streamId);
|
|
759
|
+
return this.recoveryPort().readPartial({
|
|
760
|
+
requestId: metadata?.request_id ?? "",
|
|
761
|
+
chunks: this.resumableStream
|
|
762
|
+
.getStreamChunks(streamId)
|
|
763
|
+
.map((chunk) => chunk.body),
|
|
764
|
+
});
|
|
765
|
+
},
|
|
766
|
+
activeChatRecoveryRootRequestId: () =>
|
|
767
|
+
this.activeRecoveryRootRequestId,
|
|
768
|
+
onGiveUpBookkeepingError: (phase, error) =>
|
|
769
|
+
console.error(`[chat-recovery] give-up ${phase} error`, error),
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// 作用:查找请求当前或最新的可恢复流。
|
|
774
|
+
// 调用:恢复适配器在关联事故与流时调用。
|
|
775
|
+
// 原因:先取内存活动流、再回退到持久元数据,才能覆盖 Durable Object 重启。
|
|
776
|
+
private resolveStreamId(requestId: string): string {
|
|
777
|
+
if (
|
|
778
|
+
this.resumableStream.activeRequestId === requestId &&
|
|
779
|
+
this.resumableStream.activeStreamId
|
|
780
|
+
) {
|
|
781
|
+
return this.resumableStream.activeStreamId;
|
|
782
|
+
}
|
|
783
|
+
const metadata = this.resumableStream
|
|
784
|
+
.getAllStreamMetadata()
|
|
785
|
+
.filter((row) => row.request_id === requestId)
|
|
786
|
+
.sort((left, right) => right.created_at - left.created_at)[0];
|
|
787
|
+
return metadata?.id ?? "";
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// 作用:持久化恢复标志并通知所有客户端。
|
|
791
|
+
// 调用:恢复引擎与孤立流处理在状态切换时调用。
|
|
792
|
+
// 原因:共享方法能保证持久状态和客户端协议帧同步变化。
|
|
793
|
+
private setRecovering(
|
|
794
|
+
active: boolean,
|
|
795
|
+
requestId?: string,
|
|
796
|
+
): Promise<void> {
|
|
797
|
+
return setChatRecovering(active, requestId, {
|
|
798
|
+
storage: this.ctx.storage,
|
|
799
|
+
messageType: RECOVERY_MESSAGE_TYPE,
|
|
800
|
+
broadcast: (frame) => this.broadcast(json(frame)),
|
|
801
|
+
now: Date.now(),
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 作用:恢复已有部分输出、但没有活动执行者的孤立流。
|
|
806
|
+
// 调用:续传握手层在发现孤立流时调用。
|
|
807
|
+
// 原因:只对可重试的非空部分流开启后台重放,避免无进度请求被误恢复。
|
|
808
|
+
private async recoverOrphanedStream(streamId: string): Promise<void> {
|
|
809
|
+
const metadata = this.resumableStream.getStreamMetadata(streamId);
|
|
810
|
+
if (!metadata) return;
|
|
811
|
+
const partial = this.recoveryPort().readPartial({
|
|
812
|
+
requestId: metadata.request_id,
|
|
813
|
+
chunks: this.resumableStream
|
|
814
|
+
.getStreamChunks(streamId)
|
|
815
|
+
.map((chunk) => chunk.body),
|
|
816
|
+
});
|
|
817
|
+
if (
|
|
818
|
+
partial.text.length === 0 &&
|
|
819
|
+
partial.parts.length === 0 &&
|
|
820
|
+
!partial.hasSettledToolResults
|
|
821
|
+
) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const decision = await this.recoveryPort().classify(
|
|
825
|
+
metadata.request_id,
|
|
826
|
+
);
|
|
827
|
+
if (decision.kind === "fail") {
|
|
828
|
+
await this.recoveryPort().terminalize(
|
|
829
|
+
metadata.request_id,
|
|
830
|
+
decision.reason,
|
|
831
|
+
);
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
if (decision.kind !== "retry") return;
|
|
835
|
+
await this.setRecovering(true, metadata.request_id);
|
|
836
|
+
// TODO(待确认): Cloudflare 文档说 DurableObjectState.waitUntil 不延长对象生命期;需确认此处是否只为保持调用形式一致。
|
|
837
|
+
this.ctx.waitUntil(
|
|
838
|
+
this.recoveryPort()
|
|
839
|
+
.retry(decision.data)
|
|
840
|
+
.finally(() => this.setRecovering(false, metadata.request_id)),
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// #endregion
|
|
845
|
+
|
|
846
|
+
// #region 流启动与延后清理
|
|
847
|
+
|
|
848
|
+
// 作用:为请求建立可续传流并释放流前等待者。
|
|
849
|
+
// 调用:可恢复 Fiber 真正开始执行时调用。
|
|
850
|
+
// 原因:握手切换和清理调度必须从同一入口启动。
|
|
851
|
+
private startRecoverableStream(
|
|
852
|
+
requestId: string,
|
|
853
|
+
options: {
|
|
854
|
+
readonly messageId?: string;
|
|
855
|
+
readonly continuation?: boolean;
|
|
856
|
+
} = {},
|
|
857
|
+
): string {
|
|
858
|
+
const streamId = this.resumableStream.start(requestId, options);
|
|
859
|
+
this.preStream.settle(requestId);
|
|
860
|
+
this.preStream.flushOnStreamStart((connection) =>
|
|
861
|
+
this.resumeHandshake.notifyStreamResuming(connection),
|
|
862
|
+
);
|
|
863
|
+
void this.ensureStreamCleanupScheduled();
|
|
864
|
+
return streamId;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// 作用:确保存在一个未来执行的流缓冲清理任务。
|
|
868
|
+
// 调用:流开始、完成、失败或清理回调需要续排时调用。
|
|
869
|
+
// 原因:首次幂等去重可防止重复调度,而回调续排必须明确创建下一个任务。
|
|
870
|
+
private async ensureStreamCleanupScheduled(
|
|
871
|
+
{ idempotent = true }: { idempotent?: boolean } = {},
|
|
872
|
+
): Promise<void> {
|
|
873
|
+
await this.schedule(
|
|
874
|
+
STREAM_CLEANUP_DELAY_SECONDS,
|
|
875
|
+
"_cleanupStreamBuffers",
|
|
876
|
+
undefined,
|
|
877
|
+
{ idempotent },
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 删除已过期的流缓冲区,并在仍有数据需保留时续排下一次清理。
|
|
883
|
+
*
|
|
884
|
+
* @remarks
|
|
885
|
+
* Agents SDK 的调度器在 `ensureStreamCleanupScheduled` 创建的延时任务到期时按方法名调用;它是公开的框架回调,不是业务层手动清空入口。
|
|
886
|
+
*
|
|
887
|
+
* 实际保留策略由 `cleanupStreamBuffers` 和 SDK 常量决定;本层只负责把持久调度回接到清理算法,不应另外引入第二个保留时钟。
|
|
888
|
+
*
|
|
889
|
+
* @see https://developers.cloudflare.com/agents/runtime/execution/schedule-tasks/
|
|
890
|
+
*/
|
|
891
|
+
async _cleanupStreamBuffers(): Promise<void> {
|
|
892
|
+
await cleanupStreamBuffers(
|
|
893
|
+
this.resumableStream,
|
|
894
|
+
() => this.ensureStreamCleanupScheduled({ idempotent: false }),
|
|
895
|
+
);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// #endregion
|
|
899
|
+
}
|