@springbrand/agent-runtime 0.2.0-alpha.71 → 0.3.0-alpha.1
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 +3 -3
- package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
- package/src/adapter/cloudflare/agent.ts +1539 -0
- package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
- package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
- package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
- package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
- package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
- package/src/{db → adapter/cloudflare}/schema.ts +16 -83
- package/src/adapter/cloudflare/subagent/definition.ts +63 -33
- package/src/adapter/cloudflare/subagent/runner.ts +174 -120
- package/src/adapter/cloudflare/subagent/tools.ts +13 -14
- package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
- package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
- package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
- package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
- package/src/config/activation.ts +140 -0
- package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
- package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
- package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
- package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
- package/src/{runtime-definition.ts → config/definition.ts} +13 -13
- package/src/config/harness-runtime.ts +182 -0
- package/src/config/index.ts +14 -0
- package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
- package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
- package/src/{kernel → config}/profile.ts +7 -0
- package/src/{kernel → config}/public-contracts.ts +6 -6
- package/src/{kernel → config}/runtime-load.ts +1 -1
- package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
- package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
- package/src/context/extension/index.ts +3 -0
- package/src/context/index.ts +6 -0
- package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
- package/src/conversation/harness-resume.ts +288 -0
- package/src/conversation/harness-stream.ts +98 -0
- package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
- package/src/conversation/index.ts +6 -0
- package/src/{pi → conversation}/message/contract.ts +3 -0
- package/src/{pi → conversation}/message/conversion.ts +75 -0
- package/src/{pi → conversation}/message/index.ts +1 -0
- package/src/{pi → conversation}/message/model-input.ts +1 -1
- package/src/{pi → conversation}/message/projection.ts +76 -10
- package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
- package/src/conversation/state.ts +175 -0
- package/src/harness/index.ts +7 -0
- package/src/harness/session-harness.ts +481 -0
- package/src/harness/submission-operation.ts +49 -0
- package/src/index.ts +38 -43
- package/src/lib/mcp.ts +1 -1
- package/src/{pi/runtime-adapter/models.ts → models/index.ts} +65 -17
- package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
- package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
- package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
- package/src/session/index.ts +39 -0
- package/src/submission/admission.ts +275 -0
- package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
- package/src/submission/delivery.ts +212 -0
- package/src/submission/finalization.ts +111 -0
- package/src/submission/index.ts +9 -0
- package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
- package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
- package/src/submission/steer-delivery.ts +167 -0
- package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
- package/src/telemetry/contract.ts +12 -0
- package/src/telemetry/coordinator.ts +65 -0
- package/src/telemetry/delivery.ts +2 -2
- package/src/telemetry/index.ts +5 -0
- package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
- package/src/telemetry/recorder.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +12 -0
- package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
- package/src/telemetry/turn-events.ts +279 -0
- package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
- package/src/tools/approval/index.ts +1 -0
- package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
- package/src/{pi/tool → tools}/base.ts +14 -30
- package/src/tools/compiler.ts +327 -0
- package/src/{pi/tool → tools}/core-host.ts +3 -3
- package/src/{pi/tool → tools}/core.ts +3 -3
- package/src/tools/decision-gate.ts +227 -0
- package/src/{pi/tool → tools}/gateway.ts +1 -1
- package/src/tools/human-decisions.ts +127 -0
- package/src/tools/index.ts +30 -0
- package/src/tools/interaction/index.ts +1 -0
- package/src/{pi/tool → tools}/mcp.ts +1 -1
- package/src/{pi/tool → tools}/schedule.ts +3 -3
- package/src/{pi/tool → tools}/skill.ts +4 -4
- package/src/{kernel → tools}/subagent-runtime.ts +1 -1
- package/src/{pi/tool → tools}/subagent.ts +4 -4
- package/src/tools/temporary-agent/approvals.ts +126 -0
- package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
- package/src/tools/temporary-agent/core.ts +53 -0
- package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
- package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
- package/src/{pi/tool → tools}/web-search/api.ts +1 -1
- package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
- package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
- package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
- package/src/db/milestone.repo.ts +0 -104
- package/src/db/settlement.repo.ts +0 -104
- package/src/kernel/approval-lifecycle.ts +0 -638
- package/src/kernel/interaction-lifecycle.ts +0 -433
- package/src/kernel/recoverable-chat-agent.ts +0 -1020
- package/src/kernel/state.ts +0 -91
- package/src/pi/assembly/index.ts +0 -19
- package/src/pi/runtime-adapter/execution.ts +0 -1163
- package/src/pi/runtime-adapter/index.ts +0 -293
- package/src/pi/runtime-adapter/recovery.ts +0 -982
- package/src/pi/session/index.ts +0 -24
- package/src/pi/tool/compiler.ts +0 -483
- package/src/pi/tool/index.ts +0 -38
- package/src/pi/turn/approval.ts +0 -181
- package/src/pi/turn/index.ts +0 -82
- package/src/pi/turn/interaction.ts +0 -181
- package/src/runtime.ts +0 -4760
- /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
- /package/src/{kernel → config}/degradation.ts +0 -0
- /package/src/{kernel → config}/runtime-config.ts +0 -0
- /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
- /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
- /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
- /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
- /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
- /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
- /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
- /package/src/{pi/tool → tools}/declared.ts +0 -0
- /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
- /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
- /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
- /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
- /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
- /package/src/{pi/tool → tools}/time.ts +0 -0
- /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
- /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
- /package/src/{workspace-versioning.ts → tools/workspace-versioning.ts} +0 -0
package/src/runtime.ts
DELETED
|
@@ -1,4760 +0,0 @@
|
|
|
1
|
-
import { summarizeModelUsage } from "./model-usage";
|
|
2
|
-
import type {
|
|
3
|
-
AgentToolLifecycleResult,
|
|
4
|
-
AgentToolRunInfo,
|
|
5
|
-
Connection,
|
|
6
|
-
} from "agents";
|
|
7
|
-
import {
|
|
8
|
-
ChatStreamStalledError,
|
|
9
|
-
MessageType,
|
|
10
|
-
clearChatTerminal,
|
|
11
|
-
parseProtocolMessage,
|
|
12
|
-
recordChatTerminal,
|
|
13
|
-
sendIfOpen,
|
|
14
|
-
type ChatRecoveryConfig,
|
|
15
|
-
} from "agents/chat";
|
|
16
|
-
import { RecoverableChatAgent } from "./kernel/recoverable-chat-agent";
|
|
17
|
-
import {
|
|
18
|
-
ApprovalLifecycle,
|
|
19
|
-
type ApprovalContinuationData,
|
|
20
|
-
} from "./kernel/approval-lifecycle";
|
|
21
|
-
import { InteractionLifecycle } from "./kernel/interaction-lifecycle";
|
|
22
|
-
import {
|
|
23
|
-
SubmissionLifecycle,
|
|
24
|
-
MAX_PENDING_SUBMISSIONS,
|
|
25
|
-
SubmissionQueueFullError,
|
|
26
|
-
type SubmissionExecutionMode,
|
|
27
|
-
type SubmissionInput,
|
|
28
|
-
type SubmissionStore,
|
|
29
|
-
type SubmissionHandle,
|
|
30
|
-
} from "./kernel/submission-lifecycle";
|
|
31
|
-
import {
|
|
32
|
-
USER_STOP_REASON,
|
|
33
|
-
type ApprovalDecision,
|
|
34
|
-
type ApprovalReceipt,
|
|
35
|
-
type MessageDelivery,
|
|
36
|
-
type MessageDispatchReceipt,
|
|
37
|
-
type SubmissionReceipt,
|
|
38
|
-
} from "./kernel/receipts";
|
|
39
|
-
import type { ExecutionLevel } from "./lib/execution-level";
|
|
40
|
-
import type {
|
|
41
|
-
RuntimeGatewaySession,
|
|
42
|
-
RuntimeSubmissionAdmissionHook,
|
|
43
|
-
RuntimeTurnEventsPort,
|
|
44
|
-
} from "./kernel/bindings";
|
|
45
|
-
import type { RuntimeAssemblyView } from "./kernel/runtime-assembly-view";
|
|
46
|
-
import { projectRuntimeAssembly } from "./kernel/runtime-assembly";
|
|
47
|
-
import type {
|
|
48
|
-
RuntimeExtensionPermissions,
|
|
49
|
-
} from "./kernel/extensions";
|
|
50
|
-
import type {
|
|
51
|
-
RuntimeActivity,
|
|
52
|
-
RuntimeState,
|
|
53
|
-
RuntimeTurnState,
|
|
54
|
-
} from "./kernel/state";
|
|
55
|
-
import {
|
|
56
|
-
RuntimeLoadTracker,
|
|
57
|
-
withRuntimeLoadTimeout,
|
|
58
|
-
} from "./kernel/runtime-load";
|
|
59
|
-
import {
|
|
60
|
-
prepareRuntimeCandidate,
|
|
61
|
-
type RuntimeAssemblyInput,
|
|
62
|
-
type RuntimeCandidate,
|
|
63
|
-
type RuntimeSnapshot,
|
|
64
|
-
} from "./runtime-assembler";
|
|
65
|
-
import {
|
|
66
|
-
RuntimeTelemetryCoordinator,
|
|
67
|
-
} from "./telemetry";
|
|
68
|
-
import { connectConfiguredMcpServers } from "./lib/mcp";
|
|
69
|
-
import {
|
|
70
|
-
ContextOverflowError,
|
|
71
|
-
PiRuntimeAdapter,
|
|
72
|
-
MODEL_STREAM_STALL_TIMEOUT_MS,
|
|
73
|
-
readModelStreamStallDetails,
|
|
74
|
-
RetryableModelError,
|
|
75
|
-
RetryableModelSessionError,
|
|
76
|
-
type PiCanonicalUserInput,
|
|
77
|
-
type PiCanonicalTranscriptSnapshot,
|
|
78
|
-
type PiChatRecoveryData,
|
|
79
|
-
type UIChatRequestBody,
|
|
80
|
-
type RequestedCapability,
|
|
81
|
-
type PiDurableMutation,
|
|
82
|
-
type PiRecoveryCommand,
|
|
83
|
-
type PiRecoveryDecision,
|
|
84
|
-
type PiRuntimeTranscript,
|
|
85
|
-
type PiToolInputRecord,
|
|
86
|
-
type PiStoredToolSettlement,
|
|
87
|
-
type PiToolSettlement,
|
|
88
|
-
type PreparedPiRuntime,
|
|
89
|
-
type PreparedPiTurnAdapter,
|
|
90
|
-
} from "./pi/runtime-adapter";
|
|
91
|
-
import type { UIMessage, UIMessageChunk } from "ai";
|
|
92
|
-
import type {
|
|
93
|
-
AssistantMessage,
|
|
94
|
-
ToolResultMessage,
|
|
95
|
-
} from "@earendil-works/pi-ai";
|
|
96
|
-
import { serializeOutput } from "./lib/artifacts";
|
|
97
|
-
import type {
|
|
98
|
-
RuntimeModelUsageEvent,
|
|
99
|
-
RuntimeToolSettlementEvent,
|
|
100
|
-
RuntimeLifecycleFact,
|
|
101
|
-
RuntimeSubagentUsageEvent,
|
|
102
|
-
} from "./kernel/bindings";
|
|
103
|
-
import { RuntimeSubagentLifecycleRecorder } from "./kernel/durable-lifecycle";
|
|
104
|
-
import {
|
|
105
|
-
decodeSubagentCompletion,
|
|
106
|
-
isZeroSubagentUsage,
|
|
107
|
-
runtimeSubagentRunMetadata,
|
|
108
|
-
subagentTerminalStatus,
|
|
109
|
-
type RuntimeSubagentRunMetadata,
|
|
110
|
-
} from "./kernel/subagent-runtime";
|
|
111
|
-
import {
|
|
112
|
-
isTerminalSubmissionStatus,
|
|
113
|
-
RuntimeDatabase,
|
|
114
|
-
type StoredSubmissionAdmission,
|
|
115
|
-
type SubmissionRecoveryReason,
|
|
116
|
-
type SubmissionStatus,
|
|
117
|
-
} from "./db/index";
|
|
118
|
-
import {
|
|
119
|
-
TemporaryAgentCoordinator,
|
|
120
|
-
type TemporaryAgentApprovalDecision,
|
|
121
|
-
type TemporaryAgentApprovalRequest,
|
|
122
|
-
type TemporaryAgentExecutor,
|
|
123
|
-
type TemporaryAgentRequest,
|
|
124
|
-
type TemporaryAgentRunContext,
|
|
125
|
-
} from "./layers/orchestration/temporary-agent/core";
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* 本文件负责把 Cloudflare Agent 的通信与持久化能力接到 Pi Turn。
|
|
129
|
-
*
|
|
130
|
-
* @remarks
|
|
131
|
-
* 核心术语见包入口 `index.ts`。
|
|
132
|
-
*/
|
|
133
|
-
|
|
134
|
-
// #region Runtime 内部数据与基础工具
|
|
135
|
-
|
|
136
|
-
const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
|
|
137
|
-
const TURN_EVENT_RETRY_SECONDS = 10;
|
|
138
|
-
const RUNTIME_EVENT_MAX_ATTEMPTS = 5;
|
|
139
|
-
const MODEL_SESSION_ID_STORAGE_KEY = "universal-agent:model-session-id";
|
|
140
|
-
|
|
141
|
-
// 计划续跑丢失多久后由看门狗接手。必须显著长于一个正常执行片的时长,
|
|
142
|
-
// 否则会在健康的长执行片上误判并派发出重复的一片。
|
|
143
|
-
const CONTINUATION_WATCHDOG_SECONDS = 300;
|
|
144
|
-
// 一次 Host admission 调用最多被视为「在途」多久。停在 pending 的准入行永久保留
|
|
145
|
-
// —— 它固定了同一 identity 重投时必须复用的 runId,不能靠删行来让容量回收 ——
|
|
146
|
-
// 但一次远端失败或崩溃留下的行超过这个窗口后就不再占用 pending 容量,否则
|
|
147
|
-
// Host 侧的一次抖动会把 Session 永久卡在「队列已满」。
|
|
148
|
-
const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
|
|
149
|
-
export const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
|
|
150
|
-
const SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS = 2;
|
|
151
|
-
// stall 单独收窄。理由不是「stall 更不值得救」,而是它的重试**期望值和别的错不一样**:
|
|
152
|
-
// 瞬时错(5xx / 断流)重跑一次往往就好了;stall 的重跑是拿同一份 transcript 让模型
|
|
153
|
-
// 重新想同样久,如果它本来就超预算,再跑几次也一样超。把看门狗放宽到 240s 之后,
|
|
154
|
-
// 真该救的那一类已经在第一次就跑完了,剩下还在 stall 的基本是连接真死——
|
|
155
|
-
// 那种情况下 5 次 × 240s ≈ 20 分钟的空转纯属折磨用户。3 次约 12 分钟封顶。
|
|
156
|
-
export const CHAT_STALL_MAX_ATTEMPTS = 3;
|
|
157
|
-
const CHAT_RECOVERY_TERMINAL_MESSAGE =
|
|
158
|
-
"Recovery failed after multiple attempts. This generation has stopped and your progress has been saved. Send a new message to continue.";
|
|
159
|
-
const CONTEXT_OVERFLOW_TERMINAL_MESSAGE =
|
|
160
|
-
"The model context is still too large after forced compaction. Start a new conversation or reduce the attached context.";
|
|
161
|
-
|
|
162
|
-
type RuntimeEventOutboxPayload =
|
|
163
|
-
| { readonly type: "model-usage"; readonly event: RuntimeModelUsageEvent }
|
|
164
|
-
| {
|
|
165
|
-
readonly type: "tool-settlement";
|
|
166
|
-
readonly event: RuntimeToolSettlementEvent;
|
|
167
|
-
}
|
|
168
|
-
| { readonly type: "subagent-usage"; readonly event: RuntimeSubagentUsageEvent }
|
|
169
|
-
| { readonly type: "lifecycle"; readonly event: RuntimeLifecycleFact };
|
|
170
|
-
interface StoredSubmission extends SubmissionReceipt {
|
|
171
|
-
status: SubmissionStatus;
|
|
172
|
-
requestId: string;
|
|
173
|
-
idempotencyKey?: string;
|
|
174
|
-
assemblyRevision: string;
|
|
175
|
-
assemblyDescriptor: string;
|
|
176
|
-
assistantMessageId: string;
|
|
177
|
-
abortReason?: string;
|
|
178
|
-
queuedInputJson?: string | null;
|
|
179
|
-
queuedUiMessageJson?: string | null;
|
|
180
|
-
userMessageId?: string | null;
|
|
181
|
-
regenerateMessageId?: string | null;
|
|
182
|
-
recoveryErrorCount: number;
|
|
183
|
-
recoveryReason?: SubmissionRecoveryReason;
|
|
184
|
-
runId: string | null;
|
|
185
|
-
/** 跨执行片累计的模型回合数,全局回合预算据此判断。 */
|
|
186
|
-
modelTurns: number;
|
|
187
|
-
/** 当前让步执行片的标识;没有未完成让步时为 null。 */
|
|
188
|
-
continuationId: string | null;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
interface SubmitMessageOptions {
|
|
192
|
-
requestId: string;
|
|
193
|
-
idempotencyKey?: string;
|
|
194
|
-
userMessageId?: string;
|
|
195
|
-
userMessage?: UIMessage & { role: "user" };
|
|
196
|
-
regenerate?: boolean;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
interface ActiveTurn {
|
|
200
|
-
submissionId: string;
|
|
201
|
-
requestId: string;
|
|
202
|
-
messageId: string;
|
|
203
|
-
startedAt: number;
|
|
204
|
-
continuation: boolean;
|
|
205
|
-
/** Execution adapter — use abort()/steer() instead of touching Pi directly. */
|
|
206
|
-
agent: PreparedPiTurnAdapter;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
interface ActiveRuntime {
|
|
210
|
-
readonly snapshot: RuntimeSnapshot;
|
|
211
|
-
readonly revision: string;
|
|
212
|
-
readonly pi: PreparedPiRuntime;
|
|
213
|
-
readonly gatewaySession?: RuntimeGatewaySession;
|
|
214
|
-
readonly admission?: RuntimeSubmissionAdmissionHook;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
interface PlannedContinuationData {
|
|
218
|
-
readonly submissionId: string;
|
|
219
|
-
readonly requestId: string;
|
|
220
|
-
/** Identifies the yielded execution slice so retries dedupe without swallowing the next slice. */
|
|
221
|
-
readonly continuationId: string;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* 说明一次恢复准备的结局。
|
|
226
|
-
*
|
|
227
|
-
* @remarks
|
|
228
|
-
* `prepareRecoveredTurn` 返回它,`executeNonTerminalSubmission` 据此决定重起 Turn、
|
|
229
|
-
* 保持停靠还是落终态。
|
|
230
|
-
*
|
|
231
|
-
* `parked` 与 `unresumable` 必须分开:前者有人(审批、客户端交互)或有已排的调度会来解开它,
|
|
232
|
-
* 后者没有任何东西会再唤醒 —— 把两者混成一个「不继续」,非终态记录就会永久停在 running。
|
|
233
|
-
*/
|
|
234
|
-
type RecoveredTurnPreparation =
|
|
235
|
-
/** Transcript 完好,可以重起 Pi Turn。 */
|
|
236
|
-
| { readonly kind: "resume" }
|
|
237
|
-
/** 正在等人或等已排的续跑;保持非终态是正确的。 */
|
|
238
|
-
| { readonly kind: "parked" }
|
|
239
|
-
/** 准备过程自己已经写好了终态。 */
|
|
240
|
-
| { readonly kind: "settled" }
|
|
241
|
-
/** 续不下去,调用方必须落一条用户看得见的失败终态。 */
|
|
242
|
-
| { readonly kind: "unresumable"; readonly reason: string };
|
|
243
|
-
|
|
244
|
-
// 作用:为一个没人会来解开的 `wait` 生成用户能看懂的终态原因。
|
|
245
|
-
// 调用:`prepareRecoveredTurn` 判定 wait 不是 approval/interaction 时调用。
|
|
246
|
-
// 原因:终态文本会直接呈现给用户,必须说明发生了什么以及能不能重试。
|
|
247
|
-
function unresumableWaitMessage(reason: string | undefined): string {
|
|
248
|
-
return reason === "uncertain-tool"
|
|
249
|
-
? "SpringBrand stopped this turn: a non-idempotent Tool was interrupted and its result cannot be confirmed, so it was not retried. Check whether the external action already happened before sending another request."
|
|
250
|
-
: "SpringBrand could not resume this turn after an interruption.";
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// 作用:把任意异常整理成可以持久化或发给客户端的文字。
|
|
254
|
-
// 调用:Turn 执行、恢复和业务投影捕获 `unknown` 异常时调用。
|
|
255
|
-
// 原因:错误边界不能假定抛出值一定是 `Error`。
|
|
256
|
-
function errorText(error: unknown): string {
|
|
257
|
-
return error instanceof Error ? error.message : String(error);
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// 作用:用同一种 JSON 序列化方式生成持久化或传输文本。
|
|
261
|
-
// 调用:本文件写里程碑、WebSocket 帧和比较投影时调用。
|
|
262
|
-
// 原因:收口序列化入口,避免同一数据在不同路径产生不同文本。
|
|
263
|
-
function json(value: unknown): string {
|
|
264
|
-
return JSON.stringify(value);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function validateAdmissionResult(
|
|
268
|
-
result: import("./kernel/bindings").RuntimeSubmissionAdmissionResult,
|
|
269
|
-
): import("./kernel/bindings").RuntimeSubmissionAdmissionResult {
|
|
270
|
-
if (!result || typeof result !== "object" || typeof result.accepted !== "boolean") {
|
|
271
|
-
throw new Error("Runtime admission result is invalid");
|
|
272
|
-
}
|
|
273
|
-
if (!result.accepted) {
|
|
274
|
-
if (
|
|
275
|
-
typeof result.code !== "string" || !result.code.trim() ||
|
|
276
|
-
(result.retryable !== undefined && typeof result.retryable !== "boolean")
|
|
277
|
-
) {
|
|
278
|
-
throw new Error("Runtime admission rejection is invalid");
|
|
279
|
-
}
|
|
280
|
-
return result;
|
|
281
|
-
}
|
|
282
|
-
return result;
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
async function closeRuntimeGatewaySession(
|
|
286
|
-
session: RuntimeGatewaySession | undefined,
|
|
287
|
-
step: string,
|
|
288
|
-
): Promise<void> {
|
|
289
|
-
if (!session) return;
|
|
290
|
-
await withRuntimeLoadTimeout(step, () => session.close()).catch(
|
|
291
|
-
() => undefined,
|
|
292
|
-
);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function assistantUsageEvent(
|
|
296
|
-
eventId: string,
|
|
297
|
-
submissionId: string,
|
|
298
|
-
message: AssistantMessage,
|
|
299
|
-
requestedModel: RuntimeSnapshot["pi"]["model"],
|
|
300
|
-
): RuntimeModelUsageEvent {
|
|
301
|
-
return {
|
|
302
|
-
eventId,
|
|
303
|
-
submissionId,
|
|
304
|
-
runId: submissionId,
|
|
305
|
-
kind: "assistant",
|
|
306
|
-
...summarizeModelUsage(requestedModel, [message]),
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// 作用:把用户消息内容变成可比较的稳定形状。
|
|
311
|
-
// 调用:重新生成回答时,提交入口用它核对客户端与已存用户消息。
|
|
312
|
-
// 原因:字符串和分段内容必须先归一化,否则语义相同的消息会被误判为不同。
|
|
313
|
-
// 把 park 时存下的 Tool 输入还原成对象,交给该 Tool 自己的 settle 映射。
|
|
314
|
-
// respondToolInteraction 调用;解析失败按 undefined 处理,因为映射函数是否用得到它由 Tool 决定。
|
|
315
|
-
function safeParseJson(text: string): unknown {
|
|
316
|
-
try {
|
|
317
|
-
return JSON.parse(text);
|
|
318
|
-
} catch {
|
|
319
|
-
return undefined;
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Tool 没有提供 settle 时的缺省映射:响应原样成为 ToolResult 的 details。
|
|
324
|
-
// text 用 serializeOutput 而不是裸 JSON.stringify,与其他 Tool 结果的文本形态保持一致。
|
|
325
|
-
function defaultInteractionResult(
|
|
326
|
-
response: unknown,
|
|
327
|
-
): { content: ToolResultMessage["content"]; details: unknown } {
|
|
328
|
-
return {
|
|
329
|
-
content: [{ type: "text", text: serializeOutput(response).text }],
|
|
330
|
-
details: response,
|
|
331
|
-
};
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function userContentKey(message: PiCanonicalUserInput): string {
|
|
335
|
-
return json(
|
|
336
|
-
typeof message.content === "string"
|
|
337
|
-
? [{ type: "text", text: message.content }]
|
|
338
|
-
: message.content,
|
|
339
|
-
);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[] {
|
|
343
|
-
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
344
|
-
return [];
|
|
345
|
-
}
|
|
346
|
-
const record = metadata as Record<string, unknown>;
|
|
347
|
-
if (!("requestedCapabilities" in record)) return [];
|
|
348
|
-
if (!Array.isArray(record.requestedCapabilities)) {
|
|
349
|
-
throw new Error("requestedCapabilities must be an array");
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const result: RequestedCapability[] = [];
|
|
353
|
-
const seen = new Set<string>();
|
|
354
|
-
for (const value of record.requestedCapabilities) {
|
|
355
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
356
|
-
throw new Error("requestedCapabilities contains an invalid capability");
|
|
357
|
-
}
|
|
358
|
-
const capability = value as Record<string, unknown>;
|
|
359
|
-
if (
|
|
360
|
-
(capability.kind !== "skill" && capability.kind !== "tool" && capability.kind !== "plan") ||
|
|
361
|
-
typeof capability.name !== "string" ||
|
|
362
|
-
!capability.name.trim() ||
|
|
363
|
-
typeof capability.label !== "string" ||
|
|
364
|
-
!capability.label.trim()
|
|
365
|
-
) {
|
|
366
|
-
throw new Error("requestedCapabilities contains an invalid capability");
|
|
367
|
-
}
|
|
368
|
-
const key = `${capability.kind}:${capability.name}`;
|
|
369
|
-
if (seen.has(key)) continue;
|
|
370
|
-
seen.add(key);
|
|
371
|
-
result.push({
|
|
372
|
-
kind: capability.kind,
|
|
373
|
-
name: capability.name,
|
|
374
|
-
label: capability.label,
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
return result;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
interface PendingTemporaryAgentApproval {
|
|
381
|
-
receipt: ApprovalReceipt;
|
|
382
|
-
submissionId?: string;
|
|
383
|
-
toolCallId: string;
|
|
384
|
-
resolve(decision: TemporaryAgentApprovalDecision): void;
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
export interface ToolInteractionResponsePreparation {
|
|
388
|
-
readonly toolCallId: string;
|
|
389
|
-
readonly toolName: string;
|
|
390
|
-
readonly toolInput: unknown;
|
|
391
|
-
readonly response: unknown;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// #endregion
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* 这是 UniversalAgent 唯一的生产 Runtime Kernel。
|
|
398
|
-
*
|
|
399
|
-
* @remarks
|
|
400
|
-
* Cloudflare Agent 负责 Durable Object 生命周期、SQLite、facet、
|
|
401
|
-
* WebSocket、定时任务和 fiber。
|
|
402
|
-
*
|
|
403
|
-
* Pi 负责模型与 Tool 循环和 canonical transcript。
|
|
404
|
-
*
|
|
405
|
-
* 生产 Agent 由 `defineRuntimeAgent` 生成子类,应用不直接实例化本类。
|
|
406
|
-
*/
|
|
407
|
-
export abstract class AgentRuntimeKernel<
|
|
408
|
-
Env extends Cloudflare.Env = Cloudflare.Env,
|
|
409
|
-
> extends RecoverableChatAgent<Env, RuntimeState, PiChatRecoveryData> {
|
|
410
|
-
override chatRecovery: ChatRecoveryConfig = {
|
|
411
|
-
maxAttempts: CHAT_RECOVERY_MAX_ATTEMPTS,
|
|
412
|
-
terminalMessage: CHAT_RECOVERY_TERMINAL_MESSAGE,
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
initialState: RuntimeState = {
|
|
416
|
-
runtimeLoad: { status: "idle", available: false },
|
|
417
|
-
};
|
|
418
|
-
|
|
419
|
-
protected abstract ensureRuntimeReady(): Promise<void>;
|
|
420
|
-
/** Runs only before the first pin of a newly activated Submission. */
|
|
421
|
-
protected prepareRuntimeForSubmission(_submissionId: string): Promise<void> {
|
|
422
|
-
return this.ensureRuntimeReady();
|
|
423
|
-
}
|
|
424
|
-
protected get submissionContext() {
|
|
425
|
-
return {
|
|
426
|
-
id: this.submissions.currentActive()?.submissionId ?? this.db.submissions.findRunning()?.submissionId,
|
|
427
|
-
unfinishedIds: this.db.submissions.listUnfinishedIds(),
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Let the Host prepare a validated client response before Tool settlement. */
|
|
432
|
-
protected prepareToolInteractionResponse(
|
|
433
|
-
input: ToolInteractionResponsePreparation,
|
|
434
|
-
): Promise<unknown | null> {
|
|
435
|
-
return Promise.resolve(input.response);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
private activeRuntime?: ActiveRuntime;
|
|
439
|
-
private telemetryCoordinator?: RuntimeTelemetryCoordinator;
|
|
440
|
-
private piAdapter?: PiRuntimeAdapter;
|
|
441
|
-
private readonly transcript: PiRuntimeTranscript;
|
|
442
|
-
private readonly submissions: SubmissionLifecycle<
|
|
443
|
-
StoredSubmission,
|
|
444
|
-
ActiveTurn
|
|
445
|
-
>;
|
|
446
|
-
private readonly approvals: ApprovalLifecycle<StoredSubmission>;
|
|
447
|
-
private readonly interactions: InteractionLifecycle<StoredSubmission>;
|
|
448
|
-
private runtimeLoadTracker?: RuntimeLoadTracker;
|
|
449
|
-
private modelSessionId?: string;
|
|
450
|
-
private readonly modelSessionIdReady?: Promise<void>;
|
|
451
|
-
private runtimeEventDrainTail?: Promise<void>;
|
|
452
|
-
private readonly streamBySubmission = new Map<string, string>();
|
|
453
|
-
private migratedSubmissionIds?: Set<string>;
|
|
454
|
-
private db!: RuntimeDatabase;
|
|
455
|
-
private readonly temporaryAgents = new TemporaryAgentCoordinator();
|
|
456
|
-
private readonly admissionsInFlight = new Map<
|
|
457
|
-
string,
|
|
458
|
-
Promise<import("./kernel/bindings").RuntimeSubmissionAdmissionResult>
|
|
459
|
-
>();
|
|
460
|
-
private readonly temporaryAgentApprovals =
|
|
461
|
-
new Map<string, PendingTemporaryAgentApproval>();
|
|
462
|
-
|
|
463
|
-
// #region 构造、装配与启动
|
|
464
|
-
|
|
465
|
-
// 作用:返回当前 Kernel 唯一的 Pi 适配器。
|
|
466
|
-
// 调用:构造和后续所有 Pi 装配、Turn 与恢复路径都通过它访问。
|
|
467
|
-
// 原因:允许构造时注入适配器,同时为未注入场景保留惰性单例。
|
|
468
|
-
private get pi(): PiRuntimeAdapter {
|
|
469
|
-
return this.piAdapter ??= new PiRuntimeAdapter();
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/**
|
|
473
|
-
* Runtime 装载进度的状态机。
|
|
474
|
-
*
|
|
475
|
-
* @remarks
|
|
476
|
-
* 本类和 `defineRuntimeAgent` 生成的宿主共同驱动它:宿主管一轮尝试的起止,
|
|
477
|
-
* `initConfig` 管中途的阶段推进,因此它是 protected 而非 private。
|
|
478
|
-
*
|
|
479
|
-
* 写成惰性访问器而不是字段:本类有测试用 `Object.create(prototype)` 造替身,
|
|
480
|
-
* 那条路径既不跑构造函数也不跑字段初始化器。
|
|
481
|
-
*/
|
|
482
|
-
protected get runtimeLoad(): RuntimeLoadTracker {
|
|
483
|
-
return this.runtimeLoadTracker ??= new RuntimeLoadTracker({
|
|
484
|
-
read: () => this.state.runtimeLoad,
|
|
485
|
-
publish: (runtimeLoad) => this.setState({ ...this.state, runtimeLoad }),
|
|
486
|
-
isAvailable: () => Boolean(this.activeRuntime),
|
|
487
|
-
});
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
private get telemetry(): RuntimeTelemetryCoordinator {
|
|
491
|
-
return this.telemetryCoordinator ??= new RuntimeTelemetryCoordinator({
|
|
492
|
-
outbox: () => this.db?.telemetry,
|
|
493
|
-
waitUntil: (promise) => this.ctx.waitUntil(promise),
|
|
494
|
-
scheduleRetry: async (delaySeconds, idempotent) => {
|
|
495
|
-
await this.schedule(
|
|
496
|
-
delaySeconds,
|
|
497
|
-
"_drainTelemetry",
|
|
498
|
-
undefined,
|
|
499
|
-
{ idempotent },
|
|
500
|
-
);
|
|
501
|
-
},
|
|
502
|
-
onError: (error) => {
|
|
503
|
-
console.warn(
|
|
504
|
-
"[runtime-telemetry:degraded]",
|
|
505
|
-
json({ error: errorText(error) }),
|
|
506
|
-
);
|
|
507
|
-
},
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* 为一个 Cloudflare Durable Object 实例建立持久化与执行协作器。
|
|
513
|
-
*
|
|
514
|
-
* @remarks
|
|
515
|
-
* Cloudflare Runtime 创建由 `defineRuntimeAgent` 生成的类时,间接调用。
|
|
516
|
-
*
|
|
517
|
-
* 数据库 schema、Submission、Transcript 和 Approval 在同一构造路径绑定,
|
|
518
|
-
* 避免它们引用不同的事务边界或 Pi 适配器。
|
|
519
|
-
*/
|
|
520
|
-
constructor(
|
|
521
|
-
ctx: DurableObjectState,
|
|
522
|
-
env: Env,
|
|
523
|
-
pi: PiRuntimeAdapter = new PiRuntimeAdapter(),
|
|
524
|
-
) {
|
|
525
|
-
super(ctx, env, {
|
|
526
|
-
fiberPrefix: "__cf_internal_pi_turn:",
|
|
527
|
-
snapshotKey: "__cfPiFiberSnapshot",
|
|
528
|
-
snapshotKind: "pi-turn",
|
|
529
|
-
});
|
|
530
|
-
this.modelSessionIdReady = ctx.storage.get<string>(
|
|
531
|
-
MODEL_SESSION_ID_STORAGE_KEY,
|
|
532
|
-
).then((stored) => {
|
|
533
|
-
this.modelSessionId = stored;
|
|
534
|
-
});
|
|
535
|
-
this.piAdapter = pi;
|
|
536
|
-
this.db = new RuntimeDatabase(
|
|
537
|
-
this.sql.bind(this),
|
|
538
|
-
(fn) => this.ctx.storage.transactionSync(fn),
|
|
539
|
-
);
|
|
540
|
-
this.db.initializeSchema();
|
|
541
|
-
const submissionStore: SubmissionStore<StoredSubmission> = {
|
|
542
|
-
transaction: (run) => this.db.transaction(run),
|
|
543
|
-
find: (submissionId) => this.readSubmission(submissionId),
|
|
544
|
-
findByRequestId: (requestId) =>
|
|
545
|
-
this.findSubmissionByRequest(requestId),
|
|
546
|
-
findByIdempotencyKey: (key) => this.findSubmissionByKey(key),
|
|
547
|
-
countUnfinished: () =>
|
|
548
|
-
this.db.submissions.countUnfinished(),
|
|
549
|
-
countPending: () => this.db.submissions.countPending(),
|
|
550
|
-
findRunning: () =>
|
|
551
|
-
this.db.submissions.findRunning() as StoredSubmission | null,
|
|
552
|
-
findNextPending: () =>
|
|
553
|
-
this.db.submissions.findNextPending() as StoredSubmission | null,
|
|
554
|
-
listPending: () =>
|
|
555
|
-
this.db.submissions.listPending() as StoredSubmission[],
|
|
556
|
-
updateAbortReason: (submissionId, reason) =>
|
|
557
|
-
this.db.submissions.updateAbortReason(submissionId, reason),
|
|
558
|
-
};
|
|
559
|
-
this.submissions = new SubmissionLifecycle({
|
|
560
|
-
store: submissionStore,
|
|
561
|
-
clearTerminal: () => clearChatTerminal(this.ctx.storage),
|
|
562
|
-
execute: (submissionId, mode) =>
|
|
563
|
-
this.executeSubmission(submissionId, mode),
|
|
564
|
-
appendAbortIntent: (submission, reason) =>
|
|
565
|
-
this.appendTerminalIntent(submission, "aborted", reason),
|
|
566
|
-
commitTerminal: (submission, outcome, message) =>
|
|
567
|
-
this.commitTerminalOutcome(submission, outcome, message),
|
|
568
|
-
abortActive: (turn) => turn.agent.abort(),
|
|
569
|
-
interruptActive: (turn) => turn.agent.interrupt(),
|
|
570
|
-
});
|
|
571
|
-
this.transcript = this.pi.createTranscript({
|
|
572
|
-
storage: this.ctx.storage,
|
|
573
|
-
durability: {
|
|
574
|
-
transaction: (run) => this.db.transaction(run),
|
|
575
|
-
upsertUserSidecar: (id, body) =>
|
|
576
|
-
this.db.messageUi.upsert(id, body),
|
|
577
|
-
readUserSidecar: (id) => this.db.messageUi.get(id),
|
|
578
|
-
clearUserSidecars: () => this.db.messageUi.clear(),
|
|
579
|
-
listMessageSubmissionLinks: () =>
|
|
580
|
-
this.db.submissions.listMessageSubmissionLinks(),
|
|
581
|
-
findSubmissionProjection: (submissionId) =>
|
|
582
|
-
this.readSubmission(submissionId),
|
|
583
|
-
listApprovalViews: (submissionId) =>
|
|
584
|
-
this.db.approvals.listApprovalViews(submissionId),
|
|
585
|
-
},
|
|
586
|
-
hasActiveTurn: () => this.submissions.isBusy(),
|
|
587
|
-
});
|
|
588
|
-
this.approvals = new ApprovalLifecycle({
|
|
589
|
-
db: this.db,
|
|
590
|
-
pi: this.pi,
|
|
591
|
-
findSubmission: (submissionId) =>
|
|
592
|
-
this.readSubmission(submissionId),
|
|
593
|
-
isSubmissionActive: (submissionId) => this.submissions.isActive(submissionId),
|
|
594
|
-
applyRecoveryMutations: (submission, mutations) =>
|
|
595
|
-
this.applyPiRecoveryMutations(submission, mutations),
|
|
596
|
-
materializeRecoveredToolResults: (submission) =>
|
|
597
|
-
this.materializeRecoveredToolResults(submission),
|
|
598
|
-
scheduleContinuation: async (data) => {
|
|
599
|
-
await this.schedule(
|
|
600
|
-
0,
|
|
601
|
-
"_piApprovalContinuation",
|
|
602
|
-
data,
|
|
603
|
-
{ idempotent: true },
|
|
604
|
-
);
|
|
605
|
-
},
|
|
606
|
-
onApprovalsChanged: () => this.broadcastApprovals(),
|
|
607
|
-
onTelemetryRequested: (approval) => {
|
|
608
|
-
this.telemetry.capture("approvalRequested", {
|
|
609
|
-
submissionId: approval.submissionId,
|
|
610
|
-
toolCallId: approval.toolCallId,
|
|
611
|
-
executionId: approval.executionId,
|
|
612
|
-
toolName: approval.toolName,
|
|
613
|
-
summary: approval.summary,
|
|
614
|
-
input: safeParseJson(approval.inputJson),
|
|
615
|
-
occurredAt: approval.createdAt,
|
|
616
|
-
});
|
|
617
|
-
},
|
|
618
|
-
onTelemetryDecided: (approval, decision) => {
|
|
619
|
-
this.telemetry.capture("approvalDecided", {
|
|
620
|
-
submissionId: approval.submissionId,
|
|
621
|
-
toolCallId: approval.toolCallId,
|
|
622
|
-
executionId: approval.executionId,
|
|
623
|
-
decision,
|
|
624
|
-
...(approval.reason ? { reason: approval.reason } : {}),
|
|
625
|
-
occurredAt: approval.decidedAt ?? Date.now(),
|
|
626
|
-
});
|
|
627
|
-
},
|
|
628
|
-
});
|
|
629
|
-
this.interactions = new InteractionLifecycle({
|
|
630
|
-
db: this.db,
|
|
631
|
-
pi: this.pi,
|
|
632
|
-
findSubmission: (submissionId) =>
|
|
633
|
-
this.readSubmission(submissionId),
|
|
634
|
-
applyRecoveryMutations: (submission, mutations) =>
|
|
635
|
-
this.applyPiRecoveryMutations(submission, mutations),
|
|
636
|
-
materializeRecoveredToolResults: (submission) =>
|
|
637
|
-
this.materializeRecoveredToolResults(submission),
|
|
638
|
-
// 与审批不同,这里不经 `schedule` 绕一圈:RPC 已经把 DO 叫醒了,
|
|
639
|
-
// 而 interaction 没有自己的续跑键,续跑就是普通的「已结算 Tool 恢复原 Turn」。
|
|
640
|
-
resumeSubmission: async (submissionId) => {
|
|
641
|
-
await this.submissions.recover(submissionId);
|
|
642
|
-
},
|
|
643
|
-
onInteractionsChanged: async () => {
|
|
644
|
-
this.broadcast(json({
|
|
645
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
646
|
-
messages: await this.getMessages(),
|
|
647
|
-
}));
|
|
648
|
-
await this.broadcastApprovals();
|
|
649
|
-
},
|
|
650
|
-
onTelemetryRequested: (interaction) => {
|
|
651
|
-
this.telemetry.capture("interactionRequested", {
|
|
652
|
-
submissionId: interaction.submissionId,
|
|
653
|
-
toolCallId: interaction.toolCallId,
|
|
654
|
-
interactionId: interaction.interactionId,
|
|
655
|
-
toolName: interaction.toolName,
|
|
656
|
-
input: safeParseJson(interaction.inputJson),
|
|
657
|
-
occurredAt: interaction.createdAt,
|
|
658
|
-
});
|
|
659
|
-
},
|
|
660
|
-
onTelemetrySettled: (interaction) => {
|
|
661
|
-
if (interaction.status === "responded") {
|
|
662
|
-
this.telemetry.capture("interactionResponded", {
|
|
663
|
-
submissionId: interaction.submissionId,
|
|
664
|
-
toolCallId: interaction.toolCallId,
|
|
665
|
-
interactionId: interaction.interactionId,
|
|
666
|
-
...(interaction.responseJson
|
|
667
|
-
? { response: safeParseJson(interaction.responseJson) }
|
|
668
|
-
: {}),
|
|
669
|
-
occurredAt: interaction.respondedAt ?? Date.now(),
|
|
670
|
-
});
|
|
671
|
-
return;
|
|
672
|
-
}
|
|
673
|
-
if (interaction.status === "cancelled") {
|
|
674
|
-
this.telemetry.capture("interactionCancelled", {
|
|
675
|
-
submissionId: interaction.submissionId,
|
|
676
|
-
toolCallId: interaction.toolCallId,
|
|
677
|
-
interactionId: interaction.interactionId,
|
|
678
|
-
occurredAt: interaction.respondedAt ?? Date.now(),
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
},
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// 作用:把通用聊天恢复协议接到本 Runtime 的 Submission 和 Pi 里程碑。
|
|
686
|
-
// 调用:`RecoverableChatAgent` 第一次需要分类或重试中断 Turn 时惰性调用。
|
|
687
|
-
// 原因:传输层不应认识 Pi 数据库,所以用窄端口隔开通用恢复与领域决策。
|
|
688
|
-
protected createRecoveryPort() {
|
|
689
|
-
return this.pi.createRecovery({
|
|
690
|
-
readRecoveryState: (requestId) => {
|
|
691
|
-
const submission = this.findSubmissionByRequest(requestId);
|
|
692
|
-
if (!submission) return null;
|
|
693
|
-
return {
|
|
694
|
-
submissionId: submission.submissionId,
|
|
695
|
-
requestId: submission.requestId,
|
|
696
|
-
terminal: isTerminalSubmissionStatus(submission.status),
|
|
697
|
-
milestoneBodies: this.recoveryMilestoneBodies(
|
|
698
|
-
submission.submissionId,
|
|
699
|
-
),
|
|
700
|
-
settledToolCount: this.db.settlements.countForSubmission(
|
|
701
|
-
submission.submissionId,
|
|
702
|
-
),
|
|
703
|
-
};
|
|
704
|
-
},
|
|
705
|
-
retryTurn: async (submissionId) => {
|
|
706
|
-
try {
|
|
707
|
-
const receipt = await this.submissions.recover(submissionId);
|
|
708
|
-
return receipt.status === "completed"
|
|
709
|
-
? { status: "completed" as const }
|
|
710
|
-
: {
|
|
711
|
-
status: "failed" as const,
|
|
712
|
-
error:
|
|
713
|
-
receipt.error ??
|
|
714
|
-
`SpringBrand recovery ended with status ${receipt.status}`,
|
|
715
|
-
};
|
|
716
|
-
} catch (error) {
|
|
717
|
-
if (
|
|
718
|
-
error instanceof ChatStreamStalledError ||
|
|
719
|
-
error instanceof RetryableModelError
|
|
720
|
-
) {
|
|
721
|
-
return { status: "scheduled" as const };
|
|
722
|
-
}
|
|
723
|
-
throw error;
|
|
724
|
-
}
|
|
725
|
-
},
|
|
726
|
-
failTurn: async (submissionId, message) => {
|
|
727
|
-
const submission = this.readSubmission(submissionId);
|
|
728
|
-
if (submission) {
|
|
729
|
-
await this.submissions.finish(submission, "failed", message);
|
|
730
|
-
}
|
|
731
|
-
},
|
|
732
|
-
hasPendingInteraction: () => this.approvals.countPending() > 0,
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// 作用:准备并原子切换一份新的 Runtime 配置。
|
|
737
|
-
// 调用:生成 Agent 在首次启动或显式重载 Runtime key 时调用。
|
|
738
|
-
// 原因:先完整 prepare 再替换 Snapshot,且活跃 Turn 期间禁止换 revision,可避免半装配和恢复时能力漂移。
|
|
739
|
-
protected async initConfig(input: RuntimeAssemblyInput): Promise<void> {
|
|
740
|
-
const candidate = await prepareRuntimeCandidate(input);
|
|
741
|
-
await this.initCandidate(candidate);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
private turnEventsPort(): RuntimeTurnEventsPort | undefined {
|
|
745
|
-
return this.activeRuntime?.snapshot.bindings.turnEvents;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
protected async initCandidate(candidate: RuntimeCandidate): Promise<void> {
|
|
749
|
-
await this.modelSessionIdReady;
|
|
750
|
-
const previous = this.activeRuntime;
|
|
751
|
-
const candidateSnapshot = candidate.snapshot;
|
|
752
|
-
this.runtimeLoad.advance("assembly");
|
|
753
|
-
this.runtimeLoad.advance("mcp");
|
|
754
|
-
await connectConfiguredMcpServers(
|
|
755
|
-
this,
|
|
756
|
-
candidateSnapshot.profile.mcpServers.filter(server => !server.hostManaged),
|
|
757
|
-
);
|
|
758
|
-
const gatewayDegradations = [];
|
|
759
|
-
let gatewaySession: RuntimeGatewaySession | undefined;
|
|
760
|
-
if (candidateSnapshot.bindings.gateway) {
|
|
761
|
-
try {
|
|
762
|
-
gatewaySession = await withRuntimeLoadTimeout(
|
|
763
|
-
"gateway.open",
|
|
764
|
-
() => candidateSnapshot.bindings.gateway!.open(
|
|
765
|
-
`${this.ctx.id.toString()}:${crypto.randomUUID()}`,
|
|
766
|
-
),
|
|
767
|
-
{
|
|
768
|
-
onLateResult: (session) =>
|
|
769
|
-
closeRuntimeGatewaySession(session, "gateway.close.late"),
|
|
770
|
-
},
|
|
771
|
-
);
|
|
772
|
-
} catch {
|
|
773
|
-
gatewayDegradations.push({
|
|
774
|
-
capability: "connector" as const,
|
|
775
|
-
reason: "unavailable" as const,
|
|
776
|
-
detail: "Connector Gateway MCP",
|
|
777
|
-
});
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
this.runtimeLoad.advance("pi");
|
|
782
|
-
let prepared: PreparedPiRuntime;
|
|
783
|
-
try {
|
|
784
|
-
prepared = await this.pi.prepare({
|
|
785
|
-
snapshot: candidateSnapshot,
|
|
786
|
-
mcpHost: this,
|
|
787
|
-
gatewaySession,
|
|
788
|
-
additionalDegradations: gatewayDegradations,
|
|
789
|
-
createExtensionHostBinding: (permissions, ownContextLabels) =>
|
|
790
|
-
this.createExtensionHostBinding(
|
|
791
|
-
permissions,
|
|
792
|
-
ownContextLabels,
|
|
793
|
-
),
|
|
794
|
-
});
|
|
795
|
-
} catch (error) {
|
|
796
|
-
await closeRuntimeGatewaySession(gatewaySession, "gateway.close.failed");
|
|
797
|
-
throw error;
|
|
798
|
-
}
|
|
799
|
-
const revision = await this.hash(prepared.revisionDescriptor);
|
|
800
|
-
// 停在人机等待上的 Submission 是「未完成」,但没有任何模型请求在飞、
|
|
801
|
-
// 没有任何工具在执行 —— 换装配对它是安全的,而且这正是「决定时才装上
|
|
802
|
-
// 新能力,同一轮接着跑」所依赖的那一步。真正在执行的 Turn 仍然被挡住。
|
|
803
|
-
const contested = Boolean(previous) &&
|
|
804
|
-
revision !== previous?.revision &&
|
|
805
|
-
this.submissions.isBusy() &&
|
|
806
|
-
this.db.submissions.listUnfinishedIds().some(id => Boolean(this.readSubmission(id)?.assemblyRevision));
|
|
807
|
-
const parked = contested && this.db.everyUnfinishedSubmissionParked();
|
|
808
|
-
if (contested && !parked) {
|
|
809
|
-
await closeRuntimeGatewaySession(gatewaySession, "gateway.close.contested");
|
|
810
|
-
throw new Error(
|
|
811
|
-
"Cannot reload Runtime while a revision-pinned SpringBrand Turn is active",
|
|
812
|
-
);
|
|
813
|
-
}
|
|
814
|
-
const repin = parked
|
|
815
|
-
? await withRuntimeLoadTimeout(
|
|
816
|
-
"parked-submission.repin",
|
|
817
|
-
() => this.prepareParkedSubmissionRepin(prepared, revision),
|
|
818
|
-
)
|
|
819
|
-
: undefined;
|
|
820
|
-
const next = Object.freeze({
|
|
821
|
-
snapshot: candidateSnapshot,
|
|
822
|
-
revision,
|
|
823
|
-
pi: prepared,
|
|
824
|
-
...(gatewaySession ? { gatewaySession } : {}),
|
|
825
|
-
...(candidate.admission
|
|
826
|
-
? { admission: candidate.admission }
|
|
827
|
-
: {}),
|
|
828
|
-
}) satisfies ActiveRuntime;
|
|
829
|
-
let activated = false;
|
|
830
|
-
const previousGatewaySession = previous?.gatewaySession;
|
|
831
|
-
try {
|
|
832
|
-
for (const [index, guard] of candidate.commitGuards.entries()) {
|
|
833
|
-
await withRuntimeLoadTimeout(`commit-guard:${index}`, guard);
|
|
834
|
-
}
|
|
835
|
-
this.pi.activate(candidateSnapshot);
|
|
836
|
-
activated = true;
|
|
837
|
-
repin?.commit();
|
|
838
|
-
this.telemetry.configure(candidate.telemetry);
|
|
839
|
-
this.activeRuntime = next;
|
|
840
|
-
} catch (error) {
|
|
841
|
-
repin?.abort();
|
|
842
|
-
if (activated && previous) this.pi.activate(previous.snapshot);
|
|
843
|
-
await closeRuntimeGatewaySession(gatewaySession, "gateway.close.aborted");
|
|
844
|
-
throw error;
|
|
845
|
-
}
|
|
846
|
-
if (repin) {
|
|
847
|
-
await withRuntimeLoadTimeout(
|
|
848
|
-
"parked-submission.interrupt",
|
|
849
|
-
() => repin.interrupt(),
|
|
850
|
-
).catch(() => undefined);
|
|
851
|
-
}
|
|
852
|
-
await closeRuntimeGatewaySession(
|
|
853
|
-
previousGatewaySession,
|
|
854
|
-
"gateway.close.previous",
|
|
855
|
-
);
|
|
856
|
-
|
|
857
|
-
const degradations = prepared.degradations;
|
|
858
|
-
if (degradations.length > 0) {
|
|
859
|
-
console.warn(
|
|
860
|
-
"[runtime-load:degraded]",
|
|
861
|
-
json({ degradations }),
|
|
862
|
-
);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
async _drainTelemetry(): Promise<void> {
|
|
867
|
-
await this.ensureRuntimeReady();
|
|
868
|
-
await this.telemetry.drainScheduled();
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// 作用:为某个已准入 Submission 建立一次 Pi Turn 执行适配器。
|
|
872
|
-
// 调用:新 Turn、恢复 Turn 和审批后重试 Tool 都在开始 Pi 执行前调用。
|
|
873
|
-
// 原因:在这里一次性固定 revision、持久化回调与终态回调,防止各执行路径绕过同一耐久性规则。
|
|
874
|
-
private createSubmissionExecutionAdapter(
|
|
875
|
-
submission: StoredSubmission,
|
|
876
|
-
output: {
|
|
877
|
-
readonly startedAt?: number;
|
|
878
|
-
readonly continuation?: boolean;
|
|
879
|
-
readonly assistantOrdinal?: number;
|
|
880
|
-
readonly onRecord?: (
|
|
881
|
-
chunk: UIMessageChunk,
|
|
882
|
-
) => void | Promise<void>;
|
|
883
|
-
readonly onTerminal?: (
|
|
884
|
-
terminal: {
|
|
885
|
-
outcome: "succeeded" | "failed" | "aborted";
|
|
886
|
-
message?: string;
|
|
887
|
-
},
|
|
888
|
-
) => void | Promise<void>;
|
|
889
|
-
} = {},
|
|
890
|
-
): PreparedPiTurnAdapter {
|
|
891
|
-
const startedAt = output.startedAt ?? Date.now();
|
|
892
|
-
const snapshot = this.assembly();
|
|
893
|
-
const runMetadata: RuntimeSubagentRunMetadata = {
|
|
894
|
-
submissionId: submission.submissionId,
|
|
895
|
-
parentRunId: submission.runId ?? submission.submissionId,
|
|
896
|
-
};
|
|
897
|
-
const lifecycle = this.subagentLifecycle(runMetadata);
|
|
898
|
-
const toolExecutors = snapshot.bindings.subagents
|
|
899
|
-
? this.pi.subagentToolExecutors(
|
|
900
|
-
snapshot.bindings.subagents,
|
|
901
|
-
snapshot.profile.enabledSubagents,
|
|
902
|
-
{
|
|
903
|
-
...runMetadata,
|
|
904
|
-
onRegistered: async (runId) => {
|
|
905
|
-
await lifecycle.register(runId);
|
|
906
|
-
},
|
|
907
|
-
onUsage: async (event) => {
|
|
908
|
-
await lifecycle.usage(event);
|
|
909
|
-
},
|
|
910
|
-
onTerminal: async (terminal) => {
|
|
911
|
-
await lifecycle.terminal(terminal);
|
|
912
|
-
},
|
|
913
|
-
},
|
|
914
|
-
)
|
|
915
|
-
: undefined;
|
|
916
|
-
return this.pi.createTurn({
|
|
917
|
-
prepared: this.preparedPi(),
|
|
918
|
-
pinnedDescriptor: submission.assemblyDescriptor,
|
|
919
|
-
modelSessionId: this.modelSessionId ?? this.name,
|
|
920
|
-
submission: {
|
|
921
|
-
id: submission.submissionId,
|
|
922
|
-
requestId: submission.requestId,
|
|
923
|
-
messageId: submission.assistantMessageId,
|
|
924
|
-
startedAt,
|
|
925
|
-
continuation: output.continuation ?? false,
|
|
926
|
-
assistantOrdinal: output.assistantOrdinal ?? 0,
|
|
927
|
-
modelTurnsConsumed: submission.modelTurns,
|
|
928
|
-
},
|
|
929
|
-
canonicalMessages: () => this.transcript.canonicalMessages(),
|
|
930
|
-
durability: {
|
|
931
|
-
findToolSettlement: (toolCallId) =>
|
|
932
|
-
this.readToolSettlement(submission.submissionId, toolCallId),
|
|
933
|
-
requestToolApproval: (approval, signal, onCreated) =>
|
|
934
|
-
this.approvals.request(
|
|
935
|
-
submission,
|
|
936
|
-
approval,
|
|
937
|
-
signal,
|
|
938
|
-
async (created) => {
|
|
939
|
-
await onCreated?.();
|
|
940
|
-
try {
|
|
941
|
-
await this.turnEventsPort()?.onApproval?.({
|
|
942
|
-
submissionId: submission.submissionId,
|
|
943
|
-
approvalExecutionId: created.executionId,
|
|
944
|
-
});
|
|
945
|
-
} catch (error) {
|
|
946
|
-
console.warn(
|
|
947
|
-
"[runtime-approval-projection:degraded]",
|
|
948
|
-
json({ error: errorText(error) }),
|
|
949
|
-
);
|
|
950
|
-
}
|
|
951
|
-
},
|
|
952
|
-
),
|
|
953
|
-
// spec 在这里用不上:响应侧一律从重新装配出的 candidate 按 toolName 取回同一份,
|
|
954
|
-
// 好让「DO 一直醒着」和「park 期间睡过一觉」走完全相同的一条代码路径。
|
|
955
|
-
requestToolInteraction: (interaction, _spec, signal) =>
|
|
956
|
-
this.interactions.request(submission, interaction, signal),
|
|
957
|
-
appendToolInput: (input) =>
|
|
958
|
-
this.appendToolInput(submission, input),
|
|
959
|
-
settleTool: (call) =>
|
|
960
|
-
this.settleTool(submission.submissionId, call),
|
|
961
|
-
},
|
|
962
|
-
onGeneration: (event) => {
|
|
963
|
-
if (event.type === "started") {
|
|
964
|
-
this.telemetry.capture("generationStarted", {
|
|
965
|
-
submissionId: submission.submissionId,
|
|
966
|
-
generationId: event.generationId,
|
|
967
|
-
provider: event.provider,
|
|
968
|
-
model: event.model,
|
|
969
|
-
input: event.input,
|
|
970
|
-
occurredAt: event.timestamp,
|
|
971
|
-
});
|
|
972
|
-
return;
|
|
973
|
-
}
|
|
974
|
-
this.telemetry.capture("generationFinished", {
|
|
975
|
-
submissionId: submission.submissionId,
|
|
976
|
-
generationId: event.generationId,
|
|
977
|
-
outcome: event.outcome === "success"
|
|
978
|
-
? "completed"
|
|
979
|
-
: event.outcome === "cancelled"
|
|
980
|
-
? "cancelled"
|
|
981
|
-
: "failed",
|
|
982
|
-
durationMs: event.durationMs,
|
|
983
|
-
...(event.usage
|
|
984
|
-
? {
|
|
985
|
-
inputTokens: event.usage.input,
|
|
986
|
-
outputTokens: event.usage.output,
|
|
987
|
-
totalTokens: event.usage.totalTokens,
|
|
988
|
-
cost: event.usage.cost.total,
|
|
989
|
-
}
|
|
990
|
-
: {}),
|
|
991
|
-
...(event.errorName ? { error: event.errorName } : {}),
|
|
992
|
-
...(event.output ? { output: event.output } : {}),
|
|
993
|
-
occurredAt: event.timestamp,
|
|
994
|
-
});
|
|
995
|
-
},
|
|
996
|
-
onNestedToolStarted: (event) => {
|
|
997
|
-
this.telemetry.capture("toolStarted", {
|
|
998
|
-
submissionId: submission.submissionId,
|
|
999
|
-
...event,
|
|
1000
|
-
});
|
|
1001
|
-
},
|
|
1002
|
-
onNestedToolFinished: (event) => {
|
|
1003
|
-
this.telemetry.capture("toolFinished", {
|
|
1004
|
-
submissionId: submission.submissionId,
|
|
1005
|
-
parentToolCallId: event.parentToolCallId,
|
|
1006
|
-
toolCallId: event.toolCallId,
|
|
1007
|
-
toolName: event.toolName,
|
|
1008
|
-
outcome: event.outcome,
|
|
1009
|
-
durationMs: event.durationMs,
|
|
1010
|
-
...(event.output ? {
|
|
1011
|
-
output: event.output,
|
|
1012
|
-
outputBytes: json(event.output).length,
|
|
1013
|
-
} : {}),
|
|
1014
|
-
...(event.error ? { error: errorText(event.error) } : {}),
|
|
1015
|
-
occurredAt: event.occurredAt,
|
|
1016
|
-
});
|
|
1017
|
-
this.projectNestedToolSettlement(submission.submissionId, event);
|
|
1018
|
-
},
|
|
1019
|
-
onUnknownToolStarted: (event) => {
|
|
1020
|
-
this.telemetry.capture("toolStarted", {
|
|
1021
|
-
submissionId: submission.submissionId,
|
|
1022
|
-
toolCallId: event.toolCallId,
|
|
1023
|
-
toolName: event.toolName,
|
|
1024
|
-
input: event.input,
|
|
1025
|
-
occurredAt: event.occurredAt,
|
|
1026
|
-
});
|
|
1027
|
-
},
|
|
1028
|
-
onUnknownToolFinished: (event) => {
|
|
1029
|
-
this.telemetry.capture("toolFinished", {
|
|
1030
|
-
submissionId: submission.submissionId,
|
|
1031
|
-
toolCallId: event.toolCallId,
|
|
1032
|
-
toolName: event.toolName,
|
|
1033
|
-
outcome: event.outcome,
|
|
1034
|
-
output: event.output,
|
|
1035
|
-
outputBytes: json(event.output).length,
|
|
1036
|
-
occurredAt: event.occurredAt,
|
|
1037
|
-
});
|
|
1038
|
-
},
|
|
1039
|
-
...(toolExecutors && Object.keys(toolExecutors).length > 0
|
|
1040
|
-
? { toolExecutors }
|
|
1041
|
-
: {}),
|
|
1042
|
-
transformContext: (messages, signal) =>
|
|
1043
|
-
this.transformPiContext(submission.submissionId, messages, signal),
|
|
1044
|
-
abortReason: () =>
|
|
1045
|
-
this.readSubmission(submission.submissionId)?.abortReason,
|
|
1046
|
-
onRecord: output.onRecord ?? (() => undefined),
|
|
1047
|
-
onCanonicalMessage: async (commit) => {
|
|
1048
|
-
// 换装配把这条执行器中断掉了。它退场路上还会吐出「工具被中止」这样的
|
|
1049
|
-
// 消息 —— 那不是这一轮的事实,写进权威 transcript 会让重建出来的续跑
|
|
1050
|
-
// 看到一份自己从没产生过的工具结果。
|
|
1051
|
-
if (this.migratedSubmissions.has(submission.submissionId)) return;
|
|
1052
|
-
let consumedSteer = false;
|
|
1053
|
-
this.db.transaction(() => {
|
|
1054
|
-
if (commit.kind !== "append-user") {
|
|
1055
|
-
const persisted = this.transcript.commitAdapterMessage(
|
|
1056
|
-
submission.submissionId,
|
|
1057
|
-
commit,
|
|
1058
|
-
);
|
|
1059
|
-
if (persisted.inserted && commit.message.role === "assistant") {
|
|
1060
|
-
const event = assistantUsageEvent(
|
|
1061
|
-
persisted.id,
|
|
1062
|
-
submission.submissionId,
|
|
1063
|
-
commit.message,
|
|
1064
|
-
snapshot.pi.model,
|
|
1065
|
-
);
|
|
1066
|
-
this.db.runtimeEvents.insert({
|
|
1067
|
-
eventId: event.eventId,
|
|
1068
|
-
body: json({ type: "model-usage", event }),
|
|
1069
|
-
createdAt: commit.message.timestamp,
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
const pending = this.db.steers.findByMessageId(commit.id);
|
|
1075
|
-
if (!pending) {
|
|
1076
|
-
this.transcript.commitAdapterMessage(
|
|
1077
|
-
submission.submissionId,
|
|
1078
|
-
commit,
|
|
1079
|
-
);
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
if (pending.submissionId !== submission.submissionId) {
|
|
1083
|
-
throw new Error(
|
|
1084
|
-
`Steer ${pending.steerId} belongs to another submission`,
|
|
1085
|
-
);
|
|
1086
|
-
}
|
|
1087
|
-
const uiMessage = pending.uiMessageJson
|
|
1088
|
-
? JSON.parse(pending.uiMessageJson) as UIMessage & {
|
|
1089
|
-
role: "user";
|
|
1090
|
-
}
|
|
1091
|
-
: undefined;
|
|
1092
|
-
this.transcript.append(commit.id, commit.message, {
|
|
1093
|
-
submissionId: submission.submissionId,
|
|
1094
|
-
...(uiMessage ? { userMessage: uiMessage } : {}),
|
|
1095
|
-
});
|
|
1096
|
-
this.db.steers.deleteByMessageId(commit.id);
|
|
1097
|
-
consumedSteer = true;
|
|
1098
|
-
});
|
|
1099
|
-
await this.drainRuntimeEvents();
|
|
1100
|
-
if (consumedSteer) await this.broadcastApprovals();
|
|
1101
|
-
},
|
|
1102
|
-
onTerminal: output.onTerminal ?? (() => undefined),
|
|
1103
|
-
});
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
private subagentLifecycle(
|
|
1107
|
-
input: RuntimeSubagentRunMetadata,
|
|
1108
|
-
): RuntimeSubagentLifecycleRecorder {
|
|
1109
|
-
return new RuntimeSubagentLifecycleRecorder(input, {
|
|
1110
|
-
save: async (event) => {
|
|
1111
|
-
this.db.transaction(() => {
|
|
1112
|
-
this.db.runtimeEvents.insert({
|
|
1113
|
-
eventId: event.eventId,
|
|
1114
|
-
body: json({
|
|
1115
|
-
type: event.kind === "subagent" ? "subagent-usage" : "lifecycle",
|
|
1116
|
-
event,
|
|
1117
|
-
}),
|
|
1118
|
-
createdAt: Date.now(),
|
|
1119
|
-
});
|
|
1120
|
-
});
|
|
1121
|
-
await this.drainRuntimeEvents();
|
|
1122
|
-
},
|
|
1123
|
-
has: (eventId) => this.db.runtimeEvents.has(eventId),
|
|
1124
|
-
hasSubagentUsage: (submissionId, subagentRunId) =>
|
|
1125
|
-
this.db.runtimeEvents.hasSubagentUsage(submissionId, subagentRunId),
|
|
1126
|
-
});
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* 在 Agent 实例启动或从休眠唤醒后恢复 Session 本地后台状态。
|
|
1131
|
-
*
|
|
1132
|
-
* @remarks
|
|
1133
|
-
* Cloudflare Agents SDK 在任何 HTTP、WebSocket 或 RPC 调用前调用它。
|
|
1134
|
-
*
|
|
1135
|
-
* 先恢复审批续跑,再分类未完 Submission,
|
|
1136
|
-
* 可避免把正在等待人的 Turn 当成普通中断直接重试。
|
|
1137
|
-
*
|
|
1138
|
-
* 待确认:Cloudflare 当前文档说 `DurableObjectState.waitUntil`
|
|
1139
|
-
* 不会延长 Durable Object 生命;本文件仍在三处用它启动后台任务。
|
|
1140
|
-
*/
|
|
1141
|
-
async onStart(): Promise<void> {
|
|
1142
|
-
this.runtimeLoad.reset();
|
|
1143
|
-
this.telemetry.recover(() => this.ensureRuntimeReady());
|
|
1144
|
-
if (this.db.runtimeEvents.hasPending()) {
|
|
1145
|
-
this.ctx.waitUntil(
|
|
1146
|
-
this.ensureRuntimeReady().then(() => this.drainRuntimeEvents()),
|
|
1147
|
-
);
|
|
1148
|
-
}
|
|
1149
|
-
await this.broadcastApprovals();
|
|
1150
|
-
await this.approvals.dispatchPendingContinuations();
|
|
1151
|
-
const running = this.db.submissions.findRunning() as StoredSubmission | null;
|
|
1152
|
-
if (running) {
|
|
1153
|
-
const effect = this.decidePiRecovery(running).effect;
|
|
1154
|
-
if (effect.kind === "wait" && effect.reason === "approval") {
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
this.ctx.waitUntil(
|
|
1159
|
-
this.submissions.recoverHead().then(() => undefined),
|
|
1160
|
-
);
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
private drainRuntimeEvents(idempotentRetry = true): Promise<void> {
|
|
1164
|
-
const drain = (this.runtimeEventDrainTail ?? Promise.resolve())
|
|
1165
|
-
.catch(() => undefined)
|
|
1166
|
-
.then(() => this.deliverRuntimeEvents(idempotentRetry));
|
|
1167
|
-
this.runtimeEventDrainTail = drain;
|
|
1168
|
-
return drain.finally(() => {
|
|
1169
|
-
if (this.runtimeEventDrainTail === drain) {
|
|
1170
|
-
this.runtimeEventDrainTail = undefined;
|
|
1171
|
-
}
|
|
1172
|
-
});
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
private async deliverRuntimeEvents(idempotentRetry: boolean): Promise<void> {
|
|
1176
|
-
const turnEvents = this.turnEventsPort();
|
|
1177
|
-
if (!turnEvents) return;
|
|
1178
|
-
|
|
1179
|
-
let failed = false;
|
|
1180
|
-
for (const row of this.db.runtimeEvents.listPending()) {
|
|
1181
|
-
try {
|
|
1182
|
-
const payload = JSON.parse(row.body) as RuntimeEventOutboxPayload;
|
|
1183
|
-
if (payload.type === "model-usage") {
|
|
1184
|
-
await turnEvents.onModelUsage?.(payload.event);
|
|
1185
|
-
} else if (payload.type === "tool-settlement") {
|
|
1186
|
-
await turnEvents.onToolSettled?.(payload.event);
|
|
1187
|
-
} else if (payload.type === "subagent-usage") {
|
|
1188
|
-
if (turnEvents.onSubagentUsage) {
|
|
1189
|
-
const confirmation = await turnEvents.onSubagentUsage(payload.event);
|
|
1190
|
-
if (confirmation?.confirmed !== true) {
|
|
1191
|
-
throw new Error("Runtime event was not confirmed");
|
|
1192
|
-
}
|
|
1193
|
-
}
|
|
1194
|
-
} else {
|
|
1195
|
-
if (turnEvents.onLifecycleFact) {
|
|
1196
|
-
const confirmation = await turnEvents.onLifecycleFact(payload.event);
|
|
1197
|
-
if (confirmation?.confirmed !== true) {
|
|
1198
|
-
throw new Error("Runtime event was not confirmed");
|
|
1199
|
-
}
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
this.db.transaction(() =>
|
|
1203
|
-
this.db.runtimeEvents.markDelivered(row.eventId, Date.now())
|
|
1204
|
-
);
|
|
1205
|
-
} catch (error) {
|
|
1206
|
-
const failure = errorText(error);
|
|
1207
|
-
const attemptCount = this.db.transaction(() => {
|
|
1208
|
-
const nextAttemptCount = this.db.runtimeEvents.markFailed(
|
|
1209
|
-
row.eventId,
|
|
1210
|
-
failure,
|
|
1211
|
-
);
|
|
1212
|
-
if (nextAttemptCount >= RUNTIME_EVENT_MAX_ATTEMPTS) {
|
|
1213
|
-
this.db.runtimeEvents.quarantine(row.eventId, Date.now(), failure);
|
|
1214
|
-
}
|
|
1215
|
-
return nextAttemptCount;
|
|
1216
|
-
});
|
|
1217
|
-
if (attemptCount >= RUNTIME_EVENT_MAX_ATTEMPTS) {
|
|
1218
|
-
console.warn(
|
|
1219
|
-
"[runtime-turn-event:quarantined]",
|
|
1220
|
-
json({ eventId: row.eventId, attemptCount, error: failure }),
|
|
1221
|
-
);
|
|
1222
|
-
continue;
|
|
1223
|
-
}
|
|
1224
|
-
failed = true;
|
|
1225
|
-
console.warn(
|
|
1226
|
-
"[runtime-turn-event:degraded]",
|
|
1227
|
-
json({ eventId: row.eventId, attemptCount, error: failure }),
|
|
1228
|
-
);
|
|
1229
|
-
break;
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
if (!failed) return;
|
|
1233
|
-
|
|
1234
|
-
try {
|
|
1235
|
-
await this.schedule(
|
|
1236
|
-
TURN_EVENT_RETRY_SECONDS,
|
|
1237
|
-
"_drainRuntimeTurnEvents",
|
|
1238
|
-
undefined,
|
|
1239
|
-
{ idempotent: idempotentRetry },
|
|
1240
|
-
);
|
|
1241
|
-
} catch (error) {
|
|
1242
|
-
console.warn(
|
|
1243
|
-
"[runtime-turn-event-retry:degraded]",
|
|
1244
|
-
json({ error: errorText(error) }),
|
|
1245
|
-
);
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
async _drainRuntimeTurnEvents(): Promise<void> {
|
|
1250
|
-
await this.ensureRuntimeReady();
|
|
1251
|
-
await this.drainRuntimeEvents(false);
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
// 作用:重新读取当前 Snapshot 以刷新动态 Context 依赖。
|
|
1255
|
-
// 调用:生成 Agent 的 `refreshMemoryContext` 在确保配置已加载后调用。
|
|
1256
|
-
// 原因:统一经过 `assembly` 的已初始化检查,不再引入第二份配置状态。
|
|
1257
|
-
async refreshContext(): Promise<void> {
|
|
1258
|
-
this.assembly();
|
|
1259
|
-
}
|
|
1260
|
-
|
|
1261
|
-
// 作用:在当前 Session 内启动一个临时 Agent 执行。
|
|
1262
|
-
// 调用:临时 Agent Tool 通过 `RuntimeAgentConfigContext` 带着本次信号调用。
|
|
1263
|
-
// 原因:协调器统一跟踪运行和取消,不把临时子执行塞进持久 Submission 模型。
|
|
1264
|
-
protected runTemporaryAgent(
|
|
1265
|
-
request: TemporaryAgentRequest,
|
|
1266
|
-
context: TemporaryAgentRunContext,
|
|
1267
|
-
execute: TemporaryAgentExecutor,
|
|
1268
|
-
): Promise<string> {
|
|
1269
|
-
return this.temporaryAgents.run(
|
|
1270
|
-
request,
|
|
1271
|
-
async (normalized) => {
|
|
1272
|
-
const submission = this.findSubmissionByRequest(context.requestId);
|
|
1273
|
-
const subagentId = `temporary:${context.toolCallId}`;
|
|
1274
|
-
const startedAt = Date.now();
|
|
1275
|
-
if (submission) {
|
|
1276
|
-
this.telemetry.capture("subagentStarted", {
|
|
1277
|
-
submissionId: submission.submissionId,
|
|
1278
|
-
toolCallId: context.toolCallId,
|
|
1279
|
-
subagentId,
|
|
1280
|
-
agentType: normalized.subagentName,
|
|
1281
|
-
prompt: {
|
|
1282
|
-
instructions: normalized.instructions,
|
|
1283
|
-
task: normalized.task,
|
|
1284
|
-
},
|
|
1285
|
-
occurredAt: startedAt,
|
|
1286
|
-
});
|
|
1287
|
-
}
|
|
1288
|
-
try {
|
|
1289
|
-
const output = await execute(normalized);
|
|
1290
|
-
if (submission) {
|
|
1291
|
-
this.telemetry.capture("subagentFinished", {
|
|
1292
|
-
submissionId: submission.submissionId,
|
|
1293
|
-
toolCallId: context.toolCallId,
|
|
1294
|
-
subagentId,
|
|
1295
|
-
outcome: "completed",
|
|
1296
|
-
durationMs: Math.max(0, Date.now() - startedAt),
|
|
1297
|
-
output,
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
return output;
|
|
1301
|
-
} catch (error) {
|
|
1302
|
-
if (submission) {
|
|
1303
|
-
this.telemetry.capture("subagentFinished", {
|
|
1304
|
-
submissionId: submission.submissionId,
|
|
1305
|
-
toolCallId: context.toolCallId,
|
|
1306
|
-
subagentId,
|
|
1307
|
-
outcome: context.signal.aborted ? "cancelled" : "failed",
|
|
1308
|
-
durationMs: Math.max(0, Date.now() - startedAt),
|
|
1309
|
-
error: errorText(error),
|
|
1310
|
-
});
|
|
1311
|
-
}
|
|
1312
|
-
throw error;
|
|
1313
|
-
}
|
|
1314
|
-
},
|
|
1315
|
-
context.signal,
|
|
1316
|
-
);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
/**
|
|
1320
|
-
* 登记一条临时 Agent 的 Tool 审批并等待用户决定。
|
|
1321
|
-
*
|
|
1322
|
-
* @remarks
|
|
1323
|
-
* Temporary Agent Host 在子执行需要人工授权时调用。
|
|
1324
|
-
*
|
|
1325
|
-
* 它与持久 Pi Tool 审批共用同一客户端投影,但只保存本次执行的 Promise,
|
|
1326
|
-
* 因为临时 Agent 本身不参与 Session Turn 恢复。
|
|
1327
|
-
*/
|
|
1328
|
-
async requestTemporaryAgentApproval(
|
|
1329
|
-
request: TemporaryAgentApprovalRequest,
|
|
1330
|
-
): Promise<TemporaryAgentApprovalDecision> {
|
|
1331
|
-
if (this.temporaryAgentApprovals.has(request.executionId)) {
|
|
1332
|
-
throw new Error(
|
|
1333
|
-
`temporary agent approval already pending: ${request.executionId}`,
|
|
1334
|
-
);
|
|
1335
|
-
}
|
|
1336
|
-
const submission = this.findSubmissionByRequest(request.requestId);
|
|
1337
|
-
return new Promise((resolve, reject) => {
|
|
1338
|
-
this.temporaryAgentApprovals.set(request.executionId, {
|
|
1339
|
-
receipt: {
|
|
1340
|
-
executionId: request.executionId,
|
|
1341
|
-
source: "temporary-agent",
|
|
1342
|
-
action: request.toolName,
|
|
1343
|
-
summary: `${request.subagentName} requests ${request.toolName}`,
|
|
1344
|
-
executionLevel: request.executionLevel,
|
|
1345
|
-
requiredExecutionLevel: request.requiredExecutionLevel,
|
|
1346
|
-
inputJson: JSON.stringify(request.input ?? null),
|
|
1347
|
-
requestId: request.requestId,
|
|
1348
|
-
},
|
|
1349
|
-
...(submission ? { submissionId: submission.submissionId } : {}),
|
|
1350
|
-
toolCallId: request.toolCallId,
|
|
1351
|
-
resolve,
|
|
1352
|
-
});
|
|
1353
|
-
if (submission) {
|
|
1354
|
-
this.telemetry.capture("approvalRequested", {
|
|
1355
|
-
submissionId: submission.submissionId,
|
|
1356
|
-
toolCallId: request.toolCallId,
|
|
1357
|
-
executionId: request.executionId,
|
|
1358
|
-
toolName: request.toolName,
|
|
1359
|
-
summary: `${request.subagentName} requests ${request.toolName}`,
|
|
1360
|
-
input: request.input,
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
void this.broadcastApprovals().catch((error) => {
|
|
1364
|
-
this.temporaryAgentApprovals.delete(request.executionId);
|
|
1365
|
-
reject(error);
|
|
1366
|
-
});
|
|
1367
|
-
});
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* 取消一条仍在等待的临时 Agent 审批。
|
|
1372
|
-
*
|
|
1373
|
-
* @remarks
|
|
1374
|
-
* Temporary Agent Host 在子执行停止或超时时调用。
|
|
1375
|
-
*
|
|
1376
|
-
* 先从内存表删除再解决 Promise,避免并发的批准请求再次命中已取消项。
|
|
1377
|
-
*/
|
|
1378
|
-
async cancelTemporaryAgentApproval(
|
|
1379
|
-
executionId: string,
|
|
1380
|
-
reason = "temporary agent was cancelled",
|
|
1381
|
-
): Promise<{ ok: boolean }> {
|
|
1382
|
-
const pending = this.temporaryAgentApprovals.get(executionId);
|
|
1383
|
-
if (!pending) return { ok: false };
|
|
1384
|
-
this.temporaryAgentApprovals.delete(executionId);
|
|
1385
|
-
pending.resolve({ approved: false, reason });
|
|
1386
|
-
if (pending.submissionId) {
|
|
1387
|
-
this.telemetry.capture("approvalDecided", {
|
|
1388
|
-
submissionId: pending.submissionId!,
|
|
1389
|
-
toolCallId: pending.toolCallId,
|
|
1390
|
-
executionId,
|
|
1391
|
-
decision: "cancelled",
|
|
1392
|
-
reason,
|
|
1393
|
-
});
|
|
1394
|
-
}
|
|
1395
|
-
await this.broadcastApprovals();
|
|
1396
|
-
return { ok: true };
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// 作用:返回已生效的 Runtime Snapshot。
|
|
1400
|
-
// 调用:所有需要模型、工作区、扩展或投影绑定的运行时路径调用。
|
|
1401
|
-
// 原因:未初始化时立即失败,比在深层路径触发空值更容易定位。
|
|
1402
|
-
private assembly(): RuntimeSnapshot {
|
|
1403
|
-
if (!this.activeRuntime) {
|
|
1404
|
-
throw new Error(
|
|
1405
|
-
"Runtime must be assembled before use",
|
|
1406
|
-
);
|
|
1407
|
-
}
|
|
1408
|
-
return this.activeRuntime.snapshot;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
private async normalizeUIUserInput(
|
|
1412
|
-
message: UIMessage & { role: "user" },
|
|
1413
|
-
): Promise<PiCanonicalUserInput> {
|
|
1414
|
-
const capabilities = requestedCapabilitiesOf(message.metadata);
|
|
1415
|
-
if (capabilities.length === 0) return this.pi.normalizeUserInput(message);
|
|
1416
|
-
|
|
1417
|
-
await this.ensureRuntimeReady();
|
|
1418
|
-
const installedSkills = new Set(
|
|
1419
|
-
this.assembly().bindings.skills.sources.map(({ name }) => name),
|
|
1420
|
-
);
|
|
1421
|
-
const context: string[] = [];
|
|
1422
|
-
for (const capability of capabilities) {
|
|
1423
|
-
if (capability.kind === "skill") {
|
|
1424
|
-
if (!installedSkills.has(capability.name)) {
|
|
1425
|
-
throw new Error(
|
|
1426
|
-
`Requested Skill is not installed: ${capability.name}`,
|
|
1427
|
-
);
|
|
1428
|
-
}
|
|
1429
|
-
context.push(
|
|
1430
|
-
`requested capability: skill/${capability.name}`,
|
|
1431
|
-
`required action: call activate_skill for "${capability.name}" before handling the task`,
|
|
1432
|
-
);
|
|
1433
|
-
continue;
|
|
1434
|
-
}
|
|
1435
|
-
if (capability.kind === "tool") {
|
|
1436
|
-
if (!this.pi.hasTool(this.preparedPi(), capability.name)) {
|
|
1437
|
-
throw new Error(`Requested Tool is not installed: ${capability.name}`);
|
|
1438
|
-
}
|
|
1439
|
-
context.push(
|
|
1440
|
-
`requested capability: tool/${capability.name}`,
|
|
1441
|
-
`required action: call tool "${capability.name}" when handling the task`,
|
|
1442
|
-
);
|
|
1443
|
-
continue;
|
|
1444
|
-
}
|
|
1445
|
-
if (capability.name !== "plan") {
|
|
1446
|
-
throw new Error(`Unknown plan capability: ${capability.name}`);
|
|
1447
|
-
}
|
|
1448
|
-
context.push(
|
|
1449
|
-
"requested capability: plan/plan",
|
|
1450
|
-
"required action: call update_plan with the complete plan before other work, then wait for user confirmation before execution",
|
|
1451
|
-
);
|
|
1452
|
-
}
|
|
1453
|
-
return this.pi.normalizeUserInput(message, context.join("\n"));
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
// 作用:把当前已安装的 Runtime Snapshot 投影成可序列化的调试视图。
|
|
1457
|
-
// 调用:Session facet 的 `getRuntimeAssembly` RPC。
|
|
1458
|
-
// 原因:UI 需要读取真实装配结果,而不是上层配置声明。
|
|
1459
|
-
protected readRuntimeAssembly(): RuntimeAssemblyView {
|
|
1460
|
-
const runtime = this.activeRuntime;
|
|
1461
|
-
if (!runtime) {
|
|
1462
|
-
throw new Error("Runtime must be assembled before use");
|
|
1463
|
-
}
|
|
1464
|
-
return projectRuntimeAssembly(
|
|
1465
|
-
runtime.snapshot,
|
|
1466
|
-
runtime.revision,
|
|
1467
|
-
runtime.pi,
|
|
1468
|
-
);
|
|
1469
|
-
}
|
|
1470
|
-
|
|
1471
|
-
// 作用:为 Runtime Extension 创建只包含授权能力的 Worker 回环绑定。
|
|
1472
|
-
// 调用:Pi 准备扩展时,按每个扩展的权限和 Context label 调用。
|
|
1473
|
-
// 原因:`ctx.exports` 是 Cloudflare 提供的 Worker 内回环入口,在边界上裁剪权限可避免扩展接触整个 Agent。
|
|
1474
|
-
private createExtensionHostBinding(
|
|
1475
|
-
permissions: RuntimeExtensionPermissions,
|
|
1476
|
-
ownContextLabels: readonly string[],
|
|
1477
|
-
): Fetcher {
|
|
1478
|
-
const exports = this.ctx.exports as unknown as {
|
|
1479
|
-
HostBridgeLoopback(input: {
|
|
1480
|
-
props: {
|
|
1481
|
-
agentClassName: string;
|
|
1482
|
-
agentId: string;
|
|
1483
|
-
permissions: RuntimeExtensionPermissions;
|
|
1484
|
-
ownContextLabels: string[];
|
|
1485
|
-
};
|
|
1486
|
-
}): Fetcher;
|
|
1487
|
-
};
|
|
1488
|
-
return exports.HostBridgeLoopback({
|
|
1489
|
-
props: {
|
|
1490
|
-
agentClassName: this.constructor.name,
|
|
1491
|
-
agentId: this.ctx.id.toString(),
|
|
1492
|
-
permissions,
|
|
1493
|
-
ownContextLabels: [...ownContextLabels],
|
|
1494
|
-
},
|
|
1495
|
-
});
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
// 作用:计算一段描述文本的 SHA-256 十六进制指纹。
|
|
1499
|
-
// 调用:配置激活、Submission 准入和恢复校验在比较 revision 时调用。
|
|
1500
|
-
// 原因:只持久化稳定短指纹,即可验证完整描述没有在 Turn 中途改变。
|
|
1501
|
-
private async hash(value: string): Promise<string> {
|
|
1502
|
-
const digest = await crypto.subtle.digest(
|
|
1503
|
-
"SHA-256",
|
|
1504
|
-
new TextEncoder().encode(value),
|
|
1505
|
-
);
|
|
1506
|
-
return [...new Uint8Array(digest)]
|
|
1507
|
-
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
1508
|
-
.join("");
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
// 作用:返回当前已准备好的 Pi Runtime。
|
|
1512
|
-
// 调用:创建 Turn 适配器或准入固定装配时调用。
|
|
1513
|
-
// 原因:明确区分“已有 Snapshot”和“Pi 已完成准备”,防止半初始化状态进入 Turn。
|
|
1514
|
-
/**
|
|
1515
|
-
* 正在被换装配中断、等待持久续跑接手的 Submission。
|
|
1516
|
-
*
|
|
1517
|
-
* 中断和取消在执行路径上长得一模一样(都是 abort),但结局相反:取消要写终态,
|
|
1518
|
-
* 换装配中断必须让 Submission 停在 running,等决策把它交给续跑。这个集合是
|
|
1519
|
-
* 两者唯一的区分依据,只在一次 `repinParkedSubmissions` 的窗口内有成员。
|
|
1520
|
-
*
|
|
1521
|
-
* 写成惰性访问器而不是字段:本类有测试用 `Object.create(prototype)` 造替身,
|
|
1522
|
-
* 那条路径不跑字段初始化器。
|
|
1523
|
-
*/
|
|
1524
|
-
private get migratedSubmissions(): Set<string> {
|
|
1525
|
-
return this.migratedSubmissionIds ??= new Set<string>();
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
private preparedPi(): PreparedPiRuntime {
|
|
1529
|
-
if (!this.activeRuntime) {
|
|
1530
|
-
throw new Error(
|
|
1531
|
-
"SpringBrand Runtime must be prepared before starting a Turn",
|
|
1532
|
-
);
|
|
1533
|
-
}
|
|
1534
|
-
return this.activeRuntime.pi;
|
|
1535
|
-
}
|
|
1536
|
-
|
|
1537
|
-
// #endregion
|
|
1538
|
-
|
|
1539
|
-
// #region Submission 准入与配置固定
|
|
1540
|
-
|
|
1541
|
-
// 作用:为新 Submission 生成包含当前 Context 的不可变装配描述。
|
|
1542
|
-
// 调用:Submission 通过去重和忙碌检查后,在真正写入前调用。
|
|
1543
|
-
// 原因:把动态 Context 和执行档位固定在准入时,恢复时才能按原配置继续。
|
|
1544
|
-
private async admissionPin(fresh: boolean, submissionId: string): Promise<{
|
|
1545
|
-
revision: string;
|
|
1546
|
-
descriptor: string;
|
|
1547
|
-
}> {
|
|
1548
|
-
if (fresh) await this.prepareRuntimeForSubmission(submissionId);
|
|
1549
|
-
else await this.ensureRuntimeReady();
|
|
1550
|
-
return this.currentAssemblyPin();
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
// 作用:按当前已装配的 Runtime 生成一份不可变装配描述。
|
|
1554
|
-
// 调用:准入 pin 走 `admissionPin`;`initConfig` 内部的重新 pin 直接调用本方法。
|
|
1555
|
-
// 原因:`initConfig` 已经在装配串行入口里面,再走 `ensureRuntimeReady` 会等自己,
|
|
1556
|
-
// 所以「确保装配就绪」和「按当前装配取 pin」必须是两步。
|
|
1557
|
-
private async currentAssemblyPin(): Promise<{
|
|
1558
|
-
revision: string;
|
|
1559
|
-
descriptor: string;
|
|
1560
|
-
}> {
|
|
1561
|
-
await this.drainRuntimeEvents();
|
|
1562
|
-
const runtime = this.activeRuntime;
|
|
1563
|
-
if (!runtime) {
|
|
1564
|
-
throw new Error("Runtime revision is not initialized");
|
|
1565
|
-
}
|
|
1566
|
-
return this.pinAssembly(runtime.pi, runtime.revision);
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
private async pinAssembly(
|
|
1570
|
-
prepared: PreparedPiRuntime,
|
|
1571
|
-
baseRevision: string,
|
|
1572
|
-
): Promise<{ revision: string; descriptor: string }> {
|
|
1573
|
-
const pinned = await this.pi.pin({
|
|
1574
|
-
prepared,
|
|
1575
|
-
baseRevision,
|
|
1576
|
-
readExtensionContext: ({ label }) =>
|
|
1577
|
-
this._hostGetContext(label),
|
|
1578
|
-
});
|
|
1579
|
-
if (pinned.degradations.length > 0) {
|
|
1580
|
-
console.warn(
|
|
1581
|
-
"[runtime-context:degraded]",
|
|
1582
|
-
json({ degradations: pinned.degradations }),
|
|
1583
|
-
);
|
|
1584
|
-
}
|
|
1585
|
-
return {
|
|
1586
|
-
revision: await this.hash(pinned.descriptor),
|
|
1587
|
-
descriptor: pinned.descriptor,
|
|
1588
|
-
};
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
private async pinSubmissionAssembly(
|
|
1592
|
-
submission: StoredSubmission,
|
|
1593
|
-
mode: SubmissionExecutionMode,
|
|
1594
|
-
): Promise<StoredSubmission> {
|
|
1595
|
-
if (submission.assemblyRevision && submission.assemblyDescriptor) {
|
|
1596
|
-
return submission;
|
|
1597
|
-
}
|
|
1598
|
-
if (submission.assemblyRevision || submission.assemblyDescriptor) {
|
|
1599
|
-
throw new Error("Submission assembly pin is incomplete");
|
|
1600
|
-
}
|
|
1601
|
-
const { revision, descriptor } = await this.admissionPin(mode === 'fresh', submission.submissionId);
|
|
1602
|
-
this.db.submissions.pinAssembly(
|
|
1603
|
-
submission.submissionId,
|
|
1604
|
-
revision,
|
|
1605
|
-
descriptor,
|
|
1606
|
-
);
|
|
1607
|
-
const pinned = this.readSubmission(submission.submissionId);
|
|
1608
|
-
if (!pinned?.assemblyRevision || !pinned.assemblyDescriptor) {
|
|
1609
|
-
throw new Error("Submission ended before Runtime assembly was pinned");
|
|
1610
|
-
}
|
|
1611
|
-
return pinned;
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
/** 准备 parked Submission 的续跑切换,持久 pin 只在最终 commit 中修改。 */
|
|
1615
|
-
private async prepareParkedSubmissionRepin(
|
|
1616
|
-
prepared: PreparedPiRuntime,
|
|
1617
|
-
baseRevision: string,
|
|
1618
|
-
): Promise<{ commit(): void; interrupt(): Promise<void>; abort(): void }> {
|
|
1619
|
-
await this.drainRuntimeEvents();
|
|
1620
|
-
const { revision, descriptor } = await this.pinAssembly(
|
|
1621
|
-
prepared,
|
|
1622
|
-
baseRevision,
|
|
1623
|
-
);
|
|
1624
|
-
const plans: Array<{
|
|
1625
|
-
submission: StoredSubmission;
|
|
1626
|
-
decision: PiRecoveryDecision;
|
|
1627
|
-
}> = [];
|
|
1628
|
-
for (const submissionId of this.db.submissions.listUnfinishedIds()) {
|
|
1629
|
-
const submission = this.readSubmission(submissionId);
|
|
1630
|
-
if (
|
|
1631
|
-
!submission?.assemblyRevision ||
|
|
1632
|
-
!submission.assemblyDescriptor ||
|
|
1633
|
-
submission.assemblyRevision === revision
|
|
1634
|
-
) {
|
|
1635
|
-
continue;
|
|
1636
|
-
}
|
|
1637
|
-
const decision = this.decidePiRecovery(submission, {
|
|
1638
|
-
kind: "repin-assembly",
|
|
1639
|
-
nextAssemblyRevision: revision,
|
|
1640
|
-
});
|
|
1641
|
-
plans.push({ submission, decision });
|
|
1642
|
-
}
|
|
1643
|
-
let settled = false;
|
|
1644
|
-
let committed = false;
|
|
1645
|
-
const cleanup = () => {
|
|
1646
|
-
for (const { submission } of plans) {
|
|
1647
|
-
this.migratedSubmissions.delete(submission.submissionId);
|
|
1648
|
-
}
|
|
1649
|
-
};
|
|
1650
|
-
return {
|
|
1651
|
-
commit: () => {
|
|
1652
|
-
if (settled) throw new Error("Parked Submission repin already settled");
|
|
1653
|
-
for (const { submission } of plans) {
|
|
1654
|
-
this.migratedSubmissions.add(submission.submissionId);
|
|
1655
|
-
}
|
|
1656
|
-
try {
|
|
1657
|
-
this.db.transaction(() => {
|
|
1658
|
-
for (const { submission, decision } of plans) {
|
|
1659
|
-
this.applyPiRecoveryMutations(submission, decision.mutations);
|
|
1660
|
-
this.db.submissions.repinAssembly(
|
|
1661
|
-
submission.submissionId,
|
|
1662
|
-
revision,
|
|
1663
|
-
descriptor,
|
|
1664
|
-
);
|
|
1665
|
-
}
|
|
1666
|
-
});
|
|
1667
|
-
committed = true;
|
|
1668
|
-
} catch (error) {
|
|
1669
|
-
settled = true;
|
|
1670
|
-
cleanup();
|
|
1671
|
-
throw error;
|
|
1672
|
-
}
|
|
1673
|
-
},
|
|
1674
|
-
interrupt: async () => {
|
|
1675
|
-
if (!committed || settled) return;
|
|
1676
|
-
try {
|
|
1677
|
-
for (const { submission } of plans) {
|
|
1678
|
-
const submissionId = submission.submissionId;
|
|
1679
|
-
await this.submissions.interrupt(
|
|
1680
|
-
submissionId,
|
|
1681
|
-
() => this.approvals.discardWaiters(submissionId),
|
|
1682
|
-
);
|
|
1683
|
-
}
|
|
1684
|
-
} finally {
|
|
1685
|
-
settled = true;
|
|
1686
|
-
cleanup();
|
|
1687
|
-
}
|
|
1688
|
-
},
|
|
1689
|
-
abort: () => {
|
|
1690
|
-
if (settled) return;
|
|
1691
|
-
settled = true;
|
|
1692
|
-
cleanup();
|
|
1693
|
-
},
|
|
1694
|
-
};
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
// 作用:检查 Submission 保存的装配描述与 revision 仍然匹配。
|
|
1698
|
-
// 调用:普通恢复和审批续跑在重建 Pi Turn 前调用。
|
|
1699
|
-
// 原因:若持久描述已变,继续执行会把同一 Turn 切成两套能力语义。
|
|
1700
|
-
private async assertPinnedSubmission(
|
|
1701
|
-
submission: StoredSubmission,
|
|
1702
|
-
): Promise<void> {
|
|
1703
|
-
if (
|
|
1704
|
-
submission.assemblyRevision !==
|
|
1705
|
-
await this.hash(submission.assemblyDescriptor)
|
|
1706
|
-
) {
|
|
1707
|
-
throw new Error(
|
|
1708
|
-
"Pinned Runtime assembly descriptor is invalid",
|
|
1709
|
-
);
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
// 作用:按当前模型输入预算压缩 Pi 上下文。
|
|
1714
|
-
// 调用:Pi Turn 适配器在发模型请求前通过 `transformContext` 回调。
|
|
1715
|
-
// 原因:模型窗口必须预留最大输出,压缩也必须使用本 Snapshot 的模型。
|
|
1716
|
-
private async transformPiContext(
|
|
1717
|
-
submissionId: string,
|
|
1718
|
-
messages: Parameters<PiRuntimeTranscript["compactContext"]>[0],
|
|
1719
|
-
signal?: AbortSignal,
|
|
1720
|
-
): ReturnType<PiRuntimeTranscript["compactContext"]> {
|
|
1721
|
-
const snapshot = this.assembly();
|
|
1722
|
-
const inputBudget = snapshot.pi.model.contextWindow -
|
|
1723
|
-
snapshot.pi.model.maxTokens;
|
|
1724
|
-
const force = this.readSubmission(submissionId)?.recoveryReason ===
|
|
1725
|
-
"context_overflow";
|
|
1726
|
-
const compacted = await this.transcript.compactContext(messages, {
|
|
1727
|
-
compactAfterTokens: inputBudget * 0.85,
|
|
1728
|
-
force,
|
|
1729
|
-
model: snapshot.pi.model,
|
|
1730
|
-
signal,
|
|
1731
|
-
submissionId,
|
|
1732
|
-
onCompactionUsage: (event, didCompact) => {
|
|
1733
|
-
this.db.runtimeEvents.insert({
|
|
1734
|
-
eventId: event.eventId,
|
|
1735
|
-
body: json({
|
|
1736
|
-
type: "model-usage",
|
|
1737
|
-
event: {
|
|
1738
|
-
...event,
|
|
1739
|
-
runId: submissionId,
|
|
1740
|
-
},
|
|
1741
|
-
}),
|
|
1742
|
-
createdAt: Date.now(),
|
|
1743
|
-
});
|
|
1744
|
-
if (didCompact) this.telemetry.capture("contextCompacted", {
|
|
1745
|
-
submissionId,
|
|
1746
|
-
compactionId: event.eventId,
|
|
1747
|
-
inputTokens: event.usage.input,
|
|
1748
|
-
outputTokens: event.usage.output,
|
|
1749
|
-
});
|
|
1750
|
-
},
|
|
1751
|
-
});
|
|
1752
|
-
if (force) this.db.submissions.clearRecoveryReason(submissionId);
|
|
1753
|
-
await this.drainRuntimeEvents();
|
|
1754
|
-
return compacted;
|
|
1755
|
-
}
|
|
1756
|
-
|
|
1757
|
-
// 作用:按 Submission ID 读取一条 Runtime 提交记录。
|
|
1758
|
-
// 调用:执行、恢复、审批和终态路径需要当前持久状态时调用。
|
|
1759
|
-
// 原因:在 Kernel 边界统一收窄为 `StoredSubmission`,避免上层分散重复转型。
|
|
1760
|
-
private readSubmission(
|
|
1761
|
-
submissionId: string,
|
|
1762
|
-
): StoredSubmission | null {
|
|
1763
|
-
return this.db.submissions.find(submissionId) as StoredSubmission | null;
|
|
1764
|
-
}
|
|
1765
|
-
|
|
1766
|
-
// 作用:按客户端 request ID 找到对应 Submission。
|
|
1767
|
-
// 调用:聊天取消、恢复分类和提交去重时调用。
|
|
1768
|
-
// 原因:request ID 是传输层身份,在此集中完成它到领域 Submission 的映射。
|
|
1769
|
-
private findSubmissionByRequest(
|
|
1770
|
-
requestId: string,
|
|
1771
|
-
): StoredSubmission | null {
|
|
1772
|
-
return this.db.submissions.findByRequestId(requestId) as StoredSubmission | null;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
// 作用:按业务幂等键找到已接收的 Submission。
|
|
1776
|
-
// 调用:SubmissionLifecycle 在新提交准入前调用。
|
|
1777
|
-
// 原因:去重必须查持久层,只看内存无法覆盖 Durable Object 重启。
|
|
1778
|
-
private findSubmissionByKey(
|
|
1779
|
-
idempotencyKey: string,
|
|
1780
|
-
): StoredSubmission | null {
|
|
1781
|
-
return this.db.submissions.findByIdempotencyKey(idempotencyKey) as StoredSubmission | null;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
// 作用:组装一个可由 SubmissionLifecycle 原子准入的用户消息。
|
|
1785
|
-
// 调用:即时提交和等待稳定的定时提交共用。
|
|
1786
|
-
// 原因:延迟真正写入到事务内,可让去重、忙碌检查、revision 固定和 transcript 更新一起成功或失败。
|
|
1787
|
-
private submissionInput(
|
|
1788
|
-
userMessage: PiCanonicalUserInput,
|
|
1789
|
-
options: SubmitMessageOptions,
|
|
1790
|
-
): SubmissionInput<StoredSubmission> {
|
|
1791
|
-
return {
|
|
1792
|
-
requestId: options.requestId,
|
|
1793
|
-
...(options.idempotencyKey
|
|
1794
|
-
? { idempotencyKey: options.idempotencyKey }
|
|
1795
|
-
: {}),
|
|
1796
|
-
prepareAdmission: async () => {
|
|
1797
|
-
await this.ensureRuntimeReady();
|
|
1798
|
-
const regenerateEntry =
|
|
1799
|
-
options.regenerate && options.userMessageId
|
|
1800
|
-
? await this.transcript.findUserMessage(options.userMessageId)
|
|
1801
|
-
: undefined;
|
|
1802
|
-
if (
|
|
1803
|
-
options.regenerate &&
|
|
1804
|
-
(!regenerateEntry ||
|
|
1805
|
-
userContentKey(regenerateEntry) !== userContentKey(userMessage))
|
|
1806
|
-
) {
|
|
1807
|
-
throw new Error(
|
|
1808
|
-
"Regenerate request must match an existing user message",
|
|
1809
|
-
);
|
|
1810
|
-
}
|
|
1811
|
-
const hook = this.activeRuntime?.admission;
|
|
1812
|
-
let existing: StoredSubmissionAdmission | null = null;
|
|
1813
|
-
if (hook) {
|
|
1814
|
-
const attemptedAt = Date.now();
|
|
1815
|
-
const reservedRunId = crypto.randomUUID();
|
|
1816
|
-
this.db.transaction(() => {
|
|
1817
|
-
const current = this.db.submissionAdmissions.find(
|
|
1818
|
-
options.requestId,
|
|
1819
|
-
options.idempotencyKey,
|
|
1820
|
-
);
|
|
1821
|
-
if (current) {
|
|
1822
|
-
// 同一 identity 重投:沿用已固定的 runId,只把这次尝试的时间推到现在,
|
|
1823
|
-
// 使它重新占住一个在途名额。
|
|
1824
|
-
if (current.status === "pending") {
|
|
1825
|
-
this.db.submissionAdmissions.recordAttempt(
|
|
1826
|
-
current.requestId,
|
|
1827
|
-
attemptedAt,
|
|
1828
|
-
);
|
|
1829
|
-
}
|
|
1830
|
-
return;
|
|
1831
|
-
}
|
|
1832
|
-
if (
|
|
1833
|
-
this.db.submissions.countPending() +
|
|
1834
|
-
this.db.submissionAdmissions.countPending(
|
|
1835
|
-
attemptedAt - ADMISSION_ATTEMPT_WINDOW_MS,
|
|
1836
|
-
) >=
|
|
1837
|
-
MAX_PENDING_SUBMISSIONS
|
|
1838
|
-
) {
|
|
1839
|
-
throw new SubmissionQueueFullError();
|
|
1840
|
-
}
|
|
1841
|
-
this.db.submissionAdmissions.begin(
|
|
1842
|
-
options.requestId,
|
|
1843
|
-
options.idempotencyKey ?? null,
|
|
1844
|
-
reservedRunId,
|
|
1845
|
-
attemptedAt,
|
|
1846
|
-
);
|
|
1847
|
-
});
|
|
1848
|
-
existing = this.db.submissionAdmissions.find(
|
|
1849
|
-
options.requestId,
|
|
1850
|
-
options.idempotencyKey,
|
|
1851
|
-
);
|
|
1852
|
-
}
|
|
1853
|
-
const runId = existing?.runId ?? crypto.randomUUID();
|
|
1854
|
-
const admissionRequestId = existing?.requestId ?? options.requestId;
|
|
1855
|
-
const admissionIdempotencyKey = existing
|
|
1856
|
-
? existing.idempotencyKey ?? undefined
|
|
1857
|
-
: options.idempotencyKey;
|
|
1858
|
-
const admissionKey = admissionIdempotencyKey
|
|
1859
|
-
? `idempotency:${admissionIdempotencyKey}`
|
|
1860
|
-
: `request:${admissionRequestId}`;
|
|
1861
|
-
const pendingAdmission = hook && existing?.status === "pending"
|
|
1862
|
-
? this.admissionsInFlight.get(admissionKey)
|
|
1863
|
-
: undefined;
|
|
1864
|
-
const admissionPromise = pendingAdmission ?? (hook && (!existing || existing.status === "pending")
|
|
1865
|
-
? hook({
|
|
1866
|
-
requestId: admissionRequestId,
|
|
1867
|
-
...(admissionIdempotencyKey
|
|
1868
|
-
? { idempotencyKey: admissionIdempotencyKey }
|
|
1869
|
-
: {}),
|
|
1870
|
-
runId,
|
|
1871
|
-
}).then(validateAdmissionResult).catch((error) => {
|
|
1872
|
-
this.admissionsInFlight.delete(admissionKey);
|
|
1873
|
-
throw error;
|
|
1874
|
-
})
|
|
1875
|
-
: undefined);
|
|
1876
|
-
if (hook && admissionPromise && !pendingAdmission) {
|
|
1877
|
-
this.admissionsInFlight.set(
|
|
1878
|
-
admissionKey,
|
|
1879
|
-
admissionPromise,
|
|
1880
|
-
);
|
|
1881
|
-
}
|
|
1882
|
-
let admission = existing?.status === "accepted"
|
|
1883
|
-
? {
|
|
1884
|
-
accepted: true as const,
|
|
1885
|
-
}
|
|
1886
|
-
: existing?.status === "rejected"
|
|
1887
|
-
? {
|
|
1888
|
-
accepted: false as const,
|
|
1889
|
-
code: existing.code ?? "admission_rejected",
|
|
1890
|
-
retryable: existing.retryable ?? false,
|
|
1891
|
-
}
|
|
1892
|
-
: admissionPromise
|
|
1893
|
-
? await admissionPromise
|
|
1894
|
-
: undefined;
|
|
1895
|
-
return () => {
|
|
1896
|
-
if (admission && !admission.accepted) {
|
|
1897
|
-
this.db.submissionAdmissions.saveRejected(
|
|
1898
|
-
admissionRequestId,
|
|
1899
|
-
runId,
|
|
1900
|
-
admission.code,
|
|
1901
|
-
admission.retryable ?? false,
|
|
1902
|
-
);
|
|
1903
|
-
const rejected = this.insertSkippedSubmission({
|
|
1904
|
-
submissionId: runId,
|
|
1905
|
-
requestId: admissionRequestId,
|
|
1906
|
-
idempotencyKey: admissionIdempotencyKey ?? null,
|
|
1907
|
-
createdAt: userMessage.timestamp,
|
|
1908
|
-
code: admission.code,
|
|
1909
|
-
retryable: admission.retryable ?? false,
|
|
1910
|
-
});
|
|
1911
|
-
this.admissionsInFlight.delete(admissionKey);
|
|
1912
|
-
return {
|
|
1913
|
-
...rejected,
|
|
1914
|
-
admissionRejected: true,
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
const submissionId = runId;
|
|
1918
|
-
const assistantMessageId = crypto.randomUUID();
|
|
1919
|
-
const createdAt = userMessage.timestamp;
|
|
1920
|
-
const userMessageId =
|
|
1921
|
-
options.userMessageId ?? crypto.randomUUID();
|
|
1922
|
-
if (admission?.accepted) {
|
|
1923
|
-
this.db.submissionAdmissions.saveAccepted(
|
|
1924
|
-
admissionRequestId,
|
|
1925
|
-
runId,
|
|
1926
|
-
);
|
|
1927
|
-
}
|
|
1928
|
-
this.db.submissions.insert({
|
|
1929
|
-
submissionId,
|
|
1930
|
-
requestId: admissionRequestId,
|
|
1931
|
-
idempotencyKey: admissionIdempotencyKey ?? null,
|
|
1932
|
-
createdAt,
|
|
1933
|
-
assemblyRevision: "",
|
|
1934
|
-
assemblyDescriptor: "",
|
|
1935
|
-
assistantMessageId,
|
|
1936
|
-
queuedInputJson: json(userMessage),
|
|
1937
|
-
queuedUiMessageJson: options.userMessage
|
|
1938
|
-
? json(options.userMessage)
|
|
1939
|
-
: null,
|
|
1940
|
-
userMessageId,
|
|
1941
|
-
regenerateMessageId: options.regenerate
|
|
1942
|
-
? options.userMessageId ?? null
|
|
1943
|
-
: null,
|
|
1944
|
-
runId,
|
|
1945
|
-
});
|
|
1946
|
-
if (admission?.accepted) {
|
|
1947
|
-
const fact: RuntimeLifecycleFact = {
|
|
1948
|
-
kind: "submission-admitted",
|
|
1949
|
-
eventId: `${submissionId}:admitted`,
|
|
1950
|
-
submissionId,
|
|
1951
|
-
runId: submissionId,
|
|
1952
|
-
};
|
|
1953
|
-
this.db.runtimeEvents.insert({
|
|
1954
|
-
eventId: fact.eventId,
|
|
1955
|
-
body: json({ type: "lifecycle", event: fact }),
|
|
1956
|
-
createdAt,
|
|
1957
|
-
});
|
|
1958
|
-
}
|
|
1959
|
-
this.telemetry.capture("turnAdmitted", {
|
|
1960
|
-
submissionId,
|
|
1961
|
-
requestId: admissionRequestId,
|
|
1962
|
-
...(admissionIdempotencyKey
|
|
1963
|
-
? { idempotencyKey: admissionIdempotencyKey }
|
|
1964
|
-
: {}),
|
|
1965
|
-
occurredAt: createdAt,
|
|
1966
|
-
});
|
|
1967
|
-
if (hook) this.admissionsInFlight.delete(admissionKey);
|
|
1968
|
-
return this.readSubmission(submissionId)!;
|
|
1969
|
-
};
|
|
1970
|
-
},
|
|
1971
|
-
};
|
|
1972
|
-
}
|
|
1973
|
-
|
|
1974
|
-
// 作用:立即尝试准入一条用户消息并返回执行句柄。
|
|
1975
|
-
// 调用:WebSocket 聊天、RPC prompt 和其他同步入口在收到消息时调用。
|
|
1976
|
-
// 原因:所有即时入口收口到同一 SubmissionLifecycle,才会共享去重和单 Turn 约束。
|
|
1977
|
-
private submitMessage(
|
|
1978
|
-
userMessage: PiCanonicalUserInput,
|
|
1979
|
-
options: SubmitMessageOptions,
|
|
1980
|
-
): Promise<SubmissionHandle<StoredSubmission>> {
|
|
1981
|
-
return this.submitMessageAndBroadcast(userMessage, options);
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
private async submitMessageAndBroadcast(
|
|
1985
|
-
userMessage: PiCanonicalUserInput,
|
|
1986
|
-
options: SubmitMessageOptions,
|
|
1987
|
-
): Promise<SubmissionHandle<StoredSubmission>> {
|
|
1988
|
-
const submitted = await this.submissions.submit(
|
|
1989
|
-
this.submissionInput(userMessage, options),
|
|
1990
|
-
);
|
|
1991
|
-
this.ctx.waitUntil(this.drainRuntimeEvents());
|
|
1992
|
-
await this.broadcastApprovals();
|
|
1993
|
-
return submitted;
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
// 作用:等待 Runtime 稳定后再准入一条用户消息。
|
|
1997
|
-
// 调用:定时任务在不应打断正在运行的 Turn 时调用。
|
|
1998
|
-
// 原因:有界等待比无限排队更适合定时任务,超时可返回明确的 skipped 回执。
|
|
1999
|
-
private async submitMessageWhenStable(
|
|
2000
|
-
userMessage: PiCanonicalUserInput,
|
|
2001
|
-
options: SubmitMessageOptions,
|
|
2002
|
-
timeoutMs: number,
|
|
2003
|
-
): Promise<SubmissionHandle<StoredSubmission> | null> {
|
|
2004
|
-
const submitted = await this.submissions.submitWhenStable(
|
|
2005
|
-
this.submissionInput(userMessage, options),
|
|
2006
|
-
timeoutMs,
|
|
2007
|
-
);
|
|
2008
|
-
if (submitted) {
|
|
2009
|
-
await this.drainRuntimeEvents();
|
|
2010
|
-
await this.broadcastApprovals();
|
|
2011
|
-
}
|
|
2012
|
-
return submitted;
|
|
2013
|
-
}
|
|
2014
|
-
|
|
2015
|
-
// #endregion
|
|
2016
|
-
|
|
2017
|
-
// #region 持久化、恢复与终态提交
|
|
2018
|
-
|
|
2019
|
-
// 作用:更新 Submission 状态并返回数据库中的最新记录。
|
|
2020
|
-
// 调用:终态提交在决定 completed、aborted 或 error 后调用。
|
|
2021
|
-
// 原因:完成时间只能跟终态一起写入,然后必须回读以防止返回过期对象。
|
|
2022
|
-
private updateSubmission(
|
|
2023
|
-
submissionId: string,
|
|
2024
|
-
status: SubmissionStatus,
|
|
2025
|
-
error?: string,
|
|
2026
|
-
): StoredSubmission {
|
|
2027
|
-
const completedAt = isTerminalSubmissionStatus(status) ? Date.now() : null;
|
|
2028
|
-
this.db.submissions.updateTerminal(
|
|
2029
|
-
submissionId,
|
|
2030
|
-
status,
|
|
2031
|
-
error,
|
|
2032
|
-
completedAt ?? undefined,
|
|
2033
|
-
);
|
|
2034
|
-
const submission = this.readSubmission(submissionId);
|
|
2035
|
-
if (!submission) {
|
|
2036
|
-
throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
|
|
2037
|
-
}
|
|
2038
|
-
return submission;
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
private insertSubmissionTerminalFact(submission: StoredSubmission): void {
|
|
2042
|
-
const terminalFact: RuntimeLifecycleFact = {
|
|
2043
|
-
kind: "submission-terminal",
|
|
2044
|
-
eventId: `${submission.submissionId}:terminal`,
|
|
2045
|
-
submissionId: submission.submissionId,
|
|
2046
|
-
runId: submission.runId ?? submission.submissionId,
|
|
2047
|
-
status: submission.status as "completed" | "aborted" | "skipped" | "error",
|
|
2048
|
-
childRegistrationClosed: true,
|
|
2049
|
-
zeroUsage: !this.db.runtimeEvents.hasNonZeroModelUsage(
|
|
2050
|
-
submission.submissionId,
|
|
2051
|
-
),
|
|
2052
|
-
};
|
|
2053
|
-
this.db.runtimeEvents.insert({
|
|
2054
|
-
eventId: terminalFact.eventId,
|
|
2055
|
-
body: json({ type: "lifecycle", event: terminalFact }),
|
|
2056
|
-
createdAt: submission.completedAt ?? Date.now(),
|
|
2057
|
-
});
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
private insertSkippedSubmission(input: {
|
|
2061
|
-
submissionId: string;
|
|
2062
|
-
requestId: string;
|
|
2063
|
-
idempotencyKey: string | null;
|
|
2064
|
-
createdAt: number;
|
|
2065
|
-
code: string;
|
|
2066
|
-
retryable: boolean;
|
|
2067
|
-
}): StoredSubmission {
|
|
2068
|
-
this.db.submissions.insertRejected(input);
|
|
2069
|
-
const skipped = this.readSubmission(input.submissionId)!;
|
|
2070
|
-
this.insertSubmissionTerminalFact(skipped);
|
|
2071
|
-
return skipped;
|
|
2072
|
-
}
|
|
2073
|
-
|
|
2074
|
-
// 作用:读取某个 Submission 已持久的全部 Pi 恢复里程碑。
|
|
2075
|
-
// 调用:恢复决策器每次检查或应用命令前调用。
|
|
2076
|
-
// 原因:决策只依赖持久事实,重启后才会得到同样结果。
|
|
2077
|
-
private recoveryMilestoneBodies(submissionId: string): string[] {
|
|
2078
|
-
return this.db.milestones.listBodies(submissionId);
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
// 作用:让 Pi 根据已持久里程碑决定下一个恢复动作。
|
|
2082
|
-
// 调用:Tool 输入与结果、审批、终态和唤醒恢复路径调用。
|
|
2083
|
-
// 原因:决策集中在 Pi 状态机,Kernel 只提供身份和事实,避免两份恢复规则漂移。
|
|
2084
|
-
private decidePiRecovery(
|
|
2085
|
-
submission: StoredSubmission,
|
|
2086
|
-
command: PiRecoveryCommand = { kind: "inspect" },
|
|
2087
|
-
now = Date.now(),
|
|
2088
|
-
): PiRecoveryDecision {
|
|
2089
|
-
return this.pi.decideRecovery({
|
|
2090
|
-
milestoneBodies: this.recoveryMilestoneBodies(
|
|
2091
|
-
submission.submissionId,
|
|
2092
|
-
),
|
|
2093
|
-
identity: {
|
|
2094
|
-
turnId: submission.submissionId,
|
|
2095
|
-
assemblyRevision: submission.assemblyRevision,
|
|
2096
|
-
},
|
|
2097
|
-
command,
|
|
2098
|
-
now,
|
|
2099
|
-
});
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
private recordPiToolMilestone(
|
|
2103
|
-
submission: StoredSubmission,
|
|
2104
|
-
command: Extract<PiRecoveryCommand, {
|
|
2105
|
-
kind: "record-tool-input" | "record-tool-result";
|
|
2106
|
-
}>,
|
|
2107
|
-
): readonly PiDurableMutation[] {
|
|
2108
|
-
const key = command.kind === "record-tool-input"
|
|
2109
|
-
? `tool-input:${command.record.toolCallId}`
|
|
2110
|
-
: `tool-result:${command.toolCallId}`;
|
|
2111
|
-
const body = this.db.milestones.findBody(submission.submissionId, key);
|
|
2112
|
-
return this.pi.recordToolMilestone({
|
|
2113
|
-
milestoneBodies: body === null ? [] : [body],
|
|
2114
|
-
identity: {
|
|
2115
|
-
turnId: submission.submissionId,
|
|
2116
|
-
assemblyRevision: submission.assemblyRevision,
|
|
2117
|
-
},
|
|
2118
|
-
command,
|
|
2119
|
-
now: Date.now(),
|
|
2120
|
-
});
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
// 作用:把 Pi 恢复决策产生的变更写入 Runtime 数据库。
|
|
2124
|
-
// 调用:记录 Tool、终态、审批或恢复步骤时,通常在事务内调用。
|
|
2125
|
-
// 原因:upsert 保证重放幂等,而 terminal intent 允许后来的更强结果更新早期意图。
|
|
2126
|
-
private applyPiRecoveryMutations(
|
|
2127
|
-
submission: StoredSubmission,
|
|
2128
|
-
mutations: readonly PiDurableMutation[],
|
|
2129
|
-
): boolean {
|
|
2130
|
-
let appended = false;
|
|
2131
|
-
for (const mutation of mutations) {
|
|
2132
|
-
if (mutation.kind === "decide-approval") {
|
|
2133
|
-
this.db.approvals.decide(
|
|
2134
|
-
mutation.executionId,
|
|
2135
|
-
mutation.status as any,
|
|
2136
|
-
mutation.decidedAt,
|
|
2137
|
-
mutation.reason,
|
|
2138
|
-
);
|
|
2139
|
-
continue;
|
|
2140
|
-
}
|
|
2141
|
-
if (mutation.kind === "record-interaction") {
|
|
2142
|
-
this.db.interactions.insert({
|
|
2143
|
-
interactionId: mutation.interaction.interactionId,
|
|
2144
|
-
submissionId: submission.submissionId,
|
|
2145
|
-
requestId: mutation.interaction.requestId,
|
|
2146
|
-
toolCallId: mutation.interaction.toolCallId,
|
|
2147
|
-
toolName: mutation.interaction.toolName,
|
|
2148
|
-
inputJson: mutation.interaction.inputJson,
|
|
2149
|
-
status: "pending",
|
|
2150
|
-
createdAt: mutation.interaction.createdAt,
|
|
2151
|
-
});
|
|
2152
|
-
continue;
|
|
2153
|
-
}
|
|
2154
|
-
if (mutation.kind === "settle-interaction") {
|
|
2155
|
-
this.db.interactions.settle(
|
|
2156
|
-
mutation.interactionId,
|
|
2157
|
-
mutation.status,
|
|
2158
|
-
mutation.settledAt,
|
|
2159
|
-
mutation.responseJson,
|
|
2160
|
-
);
|
|
2161
|
-
continue;
|
|
2162
|
-
}
|
|
2163
|
-
const inserted = this.db.milestones.upsert(
|
|
2164
|
-
submission.submissionId,
|
|
2165
|
-
mutation.key,
|
|
2166
|
-
mutation.body,
|
|
2167
|
-
);
|
|
2168
|
-
appended ||= inserted;
|
|
2169
|
-
if (!inserted && mutation.key === "terminal:intent") {
|
|
2170
|
-
this.db.milestones.updateTerminalIntent(
|
|
2171
|
-
submission.submissionId,
|
|
2172
|
-
mutation.body,
|
|
2173
|
-
);
|
|
2174
|
-
}
|
|
2175
|
-
}
|
|
2176
|
-
return appended;
|
|
2177
|
-
}
|
|
2178
|
-
|
|
2179
|
-
// 作用:在 Tool 执行前持久记录它的名称和输入。
|
|
2180
|
-
// 调用:Pi Turn 适配器通过 durability 回调在每个 Tool 开始时调用。
|
|
2181
|
-
// 原因:先留下 durable intent,中断后才能区分“未执行”和“结果不确定”。
|
|
2182
|
-
private appendToolInput(
|
|
2183
|
-
submission: StoredSubmission,
|
|
2184
|
-
input: PiToolInputRecord,
|
|
2185
|
-
): boolean {
|
|
2186
|
-
const appended = this.db.transaction(() => {
|
|
2187
|
-
const mutations = this.recordPiToolMilestone(submission, {
|
|
2188
|
-
kind: "record-tool-input",
|
|
2189
|
-
record: input,
|
|
2190
|
-
});
|
|
2191
|
-
const inserted = this.applyPiRecoveryMutations(submission, mutations);
|
|
2192
|
-
if (inserted) {
|
|
2193
|
-
this.telemetry.capture("toolStarted", {
|
|
2194
|
-
submissionId: submission.submissionId,
|
|
2195
|
-
toolCallId: input.toolCallId,
|
|
2196
|
-
toolName: input.toolName,
|
|
2197
|
-
input: input.input,
|
|
2198
|
-
});
|
|
2199
|
-
}
|
|
2200
|
-
return inserted;
|
|
2201
|
-
});
|
|
2202
|
-
if (appended) this.announceToolStart(submission.submissionId, input);
|
|
2203
|
-
return appended;
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
// 作用:把「这个 Tool 开始跑了」连同参数投给 Host。
|
|
2207
|
-
// 调用:durable intent 第一次落库之后,Tool 真正执行之前。
|
|
2208
|
-
// 原因:人在等的是「正在写一个网页」,不是三十秒后的「write 完成了」。
|
|
2209
|
-
//
|
|
2210
|
-
// 只在第一次落库时发:恢复重放会再走一遍这条路径,那时 Tool 可能早就结束了,
|
|
2211
|
-
// 再宣布一次「开始」比不宣布更糟。也不进 durable outbox —— 这是活性信号,
|
|
2212
|
-
// 补投一条迟到的开始事件没有意义,投失败就算了,终态有 onToolSettled 兜底。
|
|
2213
|
-
private announceToolStart(
|
|
2214
|
-
submissionId: string,
|
|
2215
|
-
input: PiToolInputRecord,
|
|
2216
|
-
): void {
|
|
2217
|
-
const turnEvents = this.turnEventsPort();
|
|
2218
|
-
if (!turnEvents?.onToolStart) return;
|
|
2219
|
-
const delivery = turnEvents.onToolStart({
|
|
2220
|
-
submissionId,
|
|
2221
|
-
toolCallId: input.toolCallId,
|
|
2222
|
-
toolName: input.toolName,
|
|
2223
|
-
args: input.input,
|
|
2224
|
-
}).catch((error: unknown) => {
|
|
2225
|
-
console.warn(
|
|
2226
|
-
"[runtime-tool-start:degraded]",
|
|
2227
|
-
json({ toolCallId: input.toolCallId, error: errorText(error) }),
|
|
2228
|
-
);
|
|
2229
|
-
});
|
|
2230
|
-
this.ctx.waitUntil(delivery);
|
|
2231
|
-
}
|
|
2232
|
-
|
|
2233
|
-
// 作用:持久化一次 Tool 结果,并推进相应的恢复里程碑。
|
|
2234
|
-
// 调用:正常 Tool 执行和恢复 Tool 结果落地时调用。
|
|
2235
|
-
// 原因:同一 `toolCallId` 只允许内容完全相同的重放,冲突结果必须立即失败以保护恢复确定性。
|
|
2236
|
-
private settleTool(
|
|
2237
|
-
submissionId: string,
|
|
2238
|
-
call: PiToolSettlement & { readonly createdAt?: number },
|
|
2239
|
-
): Promise<void> {
|
|
2240
|
-
this.db.transaction(() => this.settleToolSync(submissionId, call));
|
|
2241
|
-
return this.drainRuntimeEvents();
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
private projectNestedToolSettlement(
|
|
2245
|
-
submissionId: string,
|
|
2246
|
-
event: Readonly<{
|
|
2247
|
-
toolCallId: string;
|
|
2248
|
-
toolName: string;
|
|
2249
|
-
outcome: "completed" | "failed" | "cancelled";
|
|
2250
|
-
occurredAt: number;
|
|
2251
|
-
}>,
|
|
2252
|
-
): void {
|
|
2253
|
-
const settlement: RuntimeToolSettlementEvent = {
|
|
2254
|
-
eventId: `${submissionId}:tool:${event.toolCallId}`,
|
|
2255
|
-
submissionId,
|
|
2256
|
-
toolCallId: event.toolCallId,
|
|
2257
|
-
toolName: event.toolName,
|
|
2258
|
-
status: event.outcome === "completed" ? "success" : "error",
|
|
2259
|
-
};
|
|
2260
|
-
this.db.transaction(() => {
|
|
2261
|
-
this.db.runtimeEvents.insert({
|
|
2262
|
-
eventId: settlement.eventId,
|
|
2263
|
-
body: json({ type: "tool-settlement", event: settlement }),
|
|
2264
|
-
createdAt: event.occurredAt,
|
|
2265
|
-
});
|
|
2266
|
-
});
|
|
2267
|
-
this.ctx.waitUntil(this.drainRuntimeEvents());
|
|
2268
|
-
}
|
|
2269
|
-
|
|
2270
|
-
// 终态事务用这个同步内核先补齐 ToolResult,再在同一事务末尾写 marker。
|
|
2271
|
-
private settleToolSync(
|
|
2272
|
-
submissionId: string,
|
|
2273
|
-
call: PiToolSettlement & { readonly createdAt?: number },
|
|
2274
|
-
): void {
|
|
2275
|
-
const submission = this.readSubmission(submissionId);
|
|
2276
|
-
if (!submission) {
|
|
2277
|
-
throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
|
|
2278
|
-
}
|
|
2279
|
-
const existing = this.db.settlements.find(submissionId, call.toolCallId);
|
|
2280
|
-
const args = json(call.args);
|
|
2281
|
-
const result = json(call.result);
|
|
2282
|
-
const createdAt = existing?.createdAt ?? call.createdAt ?? Date.now();
|
|
2283
|
-
if (existing) {
|
|
2284
|
-
if (
|
|
2285
|
-
existing.toolName !== call.toolName ||
|
|
2286
|
-
existing.args !== args ||
|
|
2287
|
-
existing.result !== result ||
|
|
2288
|
-
existing.isError !== call.isError
|
|
2289
|
-
) {
|
|
2290
|
-
throw new Error(
|
|
2291
|
-
`Conflicting durable Tool settlement: ${call.toolCallId}`,
|
|
2292
|
-
);
|
|
2293
|
-
}
|
|
2294
|
-
} else {
|
|
2295
|
-
this.db.settlements.insert({
|
|
2296
|
-
toolCallId: call.toolCallId,
|
|
2297
|
-
submissionId,
|
|
2298
|
-
toolName: call.toolName,
|
|
2299
|
-
args,
|
|
2300
|
-
result,
|
|
2301
|
-
isError: call.isError,
|
|
2302
|
-
createdAt,
|
|
2303
|
-
});
|
|
2304
|
-
const event: RuntimeToolSettlementEvent = {
|
|
2305
|
-
eventId: `${submissionId}:tool:${call.toolCallId}`,
|
|
2306
|
-
submissionId,
|
|
2307
|
-
toolCallId: call.toolCallId,
|
|
2308
|
-
toolName: call.toolName,
|
|
2309
|
-
status: call.isError ? "error" : "success",
|
|
2310
|
-
};
|
|
2311
|
-
this.db.runtimeEvents.insert({
|
|
2312
|
-
eventId: event.eventId,
|
|
2313
|
-
body: json({ type: "tool-settlement", event }),
|
|
2314
|
-
createdAt,
|
|
2315
|
-
});
|
|
2316
|
-
const details = call.result.details as {
|
|
2317
|
-
kind?: string;
|
|
2318
|
-
bytes?: number;
|
|
2319
|
-
} | undefined;
|
|
2320
|
-
this.telemetry.capture("toolFinished", {
|
|
2321
|
-
submissionId,
|
|
2322
|
-
toolCallId: call.toolCallId,
|
|
2323
|
-
toolName: call.toolName,
|
|
2324
|
-
outcome: call.isError ? "failed" : "completed",
|
|
2325
|
-
outputBytes: details?.kind === "artifact_ref" &&
|
|
2326
|
-
typeof details.bytes === "number"
|
|
2327
|
-
? details.bytes
|
|
2328
|
-
: result.length,
|
|
2329
|
-
spilled: details?.kind === "artifact_ref",
|
|
2330
|
-
output: call.result,
|
|
2331
|
-
...(call.isError && call.cause
|
|
2332
|
-
? { error: errorText(call.cause) }
|
|
2333
|
-
: {}),
|
|
2334
|
-
occurredAt: createdAt,
|
|
2335
|
-
});
|
|
2336
|
-
}
|
|
2337
|
-
const mutations = this.recordPiToolMilestone(submission, {
|
|
2338
|
-
kind: "record-tool-result",
|
|
2339
|
-
toolCallId: call.toolCallId,
|
|
2340
|
-
toolName: call.toolName,
|
|
2341
|
-
result: call.result,
|
|
2342
|
-
isError: call.isError,
|
|
2343
|
-
timestamp: createdAt,
|
|
2344
|
-
// The current execution is already continuing. After a crash, recovery
|
|
2345
|
-
// materializes this result before resuming; no per-Tool dispatch is needed.
|
|
2346
|
-
needsContinuation: false,
|
|
2347
|
-
});
|
|
2348
|
-
this.applyPiRecoveryMutations(submission, mutations);
|
|
2349
|
-
this.approvals.commitContinuation(submission, call.toolCallId);
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
// 作用:按 Submission 和 Tool Call ID 读取已持久结果。
|
|
2353
|
-
// 调用:Pi 执行去重、恢复物化和审批续跑在重做 Tool 前调用。
|
|
2354
|
-
// 原因:只在边界解析 JSON,数据库仍保留可精确比较的原始文本。
|
|
2355
|
-
private readToolSettlement(
|
|
2356
|
-
submissionId: string,
|
|
2357
|
-
toolCallId: string,
|
|
2358
|
-
): {
|
|
2359
|
-
toolName: string;
|
|
2360
|
-
args: unknown;
|
|
2361
|
-
result: PiStoredToolSettlement["result"];
|
|
2362
|
-
isError: boolean;
|
|
2363
|
-
createdAt: number;
|
|
2364
|
-
} | null {
|
|
2365
|
-
const row = this.db.settlements.find(submissionId, toolCallId);
|
|
2366
|
-
return row
|
|
2367
|
-
? {
|
|
2368
|
-
toolName: row.toolName,
|
|
2369
|
-
args: JSON.parse(row.args) as unknown,
|
|
2370
|
-
result: JSON.parse(
|
|
2371
|
-
row.result,
|
|
2372
|
-
) as PiStoredToolSettlement["result"],
|
|
2373
|
-
isError: row.isError,
|
|
2374
|
-
createdAt: row.createdAt,
|
|
2375
|
-
}
|
|
2376
|
-
: null;
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
// 作用:把恢复状态机已知的 Tool 结果补全到 settlement 和 transcript 投影。
|
|
2380
|
-
// 调用:Turn 恢复、审批决定和终态收尾在继续前调用。
|
|
2381
|
-
// 原因:恢复记录可能比浏览器 transcript 更新,先去重落库再按 canonical 顺序投影才不会丢失或重复 ToolResult。
|
|
2382
|
-
private materializeRecoveredToolResultsSync(
|
|
2383
|
-
submission: StoredSubmission,
|
|
2384
|
-
): PiRecoveryDecision {
|
|
2385
|
-
let decision = this.decidePiRecovery(submission);
|
|
2386
|
-
for (const settlement of decision.recoveredToolSettlements) {
|
|
2387
|
-
const message = settlement.message;
|
|
2388
|
-
if (
|
|
2389
|
-
this.readToolSettlement(
|
|
2390
|
-
submission.submissionId,
|
|
2391
|
-
message.toolCallId,
|
|
2392
|
-
)
|
|
2393
|
-
) {
|
|
2394
|
-
continue;
|
|
2395
|
-
}
|
|
2396
|
-
this.settleToolSync(submission.submissionId, {
|
|
2397
|
-
toolCallId: message.toolCallId,
|
|
2398
|
-
toolName: message.toolName,
|
|
2399
|
-
args: settlement.args,
|
|
2400
|
-
result: {
|
|
2401
|
-
content: message.content,
|
|
2402
|
-
details: message.details,
|
|
2403
|
-
...(message.usage ? { usage: message.usage } : {}),
|
|
2404
|
-
...(message.addedToolNames?.length
|
|
2405
|
-
? { addedToolNames: message.addedToolNames }
|
|
2406
|
-
: {}),
|
|
2407
|
-
},
|
|
2408
|
-
isError: message.isError,
|
|
2409
|
-
createdAt: message.timestamp,
|
|
2410
|
-
});
|
|
2411
|
-
decision = this.decidePiRecovery(submission);
|
|
2412
|
-
}
|
|
2413
|
-
const results = this.transcript.orderRecoveredToolSettlements(
|
|
2414
|
-
decision.recoveredToolSettlements,
|
|
2415
|
-
);
|
|
2416
|
-
for (const recovered of results) {
|
|
2417
|
-
this.transcript.appendRecoveredToolSettlement(
|
|
2418
|
-
submission.submissionId,
|
|
2419
|
-
recovered,
|
|
2420
|
-
);
|
|
2421
|
-
}
|
|
2422
|
-
return decision;
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
private async materializeRecoveredToolResults(
|
|
2426
|
-
submission: StoredSubmission,
|
|
2427
|
-
): Promise<PiRecoveryDecision> {
|
|
2428
|
-
const decision = this.db.transaction(() =>
|
|
2429
|
-
this.materializeRecoveredToolResultsSync(submission)
|
|
2430
|
-
);
|
|
2431
|
-
await this.drainRuntimeEvents();
|
|
2432
|
-
return decision;
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
// 作用:在改写 Submission 终态前持久化 Turn 的结束意图。
|
|
2436
|
-
// 调用:模型终止回调、取消和最终提交都会调用。
|
|
2437
|
-
// 原因:intent 早于状态行落地,即使两步之间中断,恢复也能重建权威结果。
|
|
2438
|
-
private appendTerminalIntent(
|
|
2439
|
-
submission: StoredSubmission,
|
|
2440
|
-
outcome: "succeeded" | "failed" | "aborted",
|
|
2441
|
-
message?: string,
|
|
2442
|
-
): void {
|
|
2443
|
-
const decision = this.decidePiRecovery(submission, {
|
|
2444
|
-
kind: "record-terminal",
|
|
2445
|
-
phase: "intent",
|
|
2446
|
-
outcome,
|
|
2447
|
-
...(message === undefined ? {} : { message }),
|
|
2448
|
-
});
|
|
2449
|
-
this.applyPiRecoveryMutations(submission, decision.mutations);
|
|
2450
|
-
if (outcome === "aborted") {
|
|
2451
|
-
this.telemetry.capture("turnCancelled", {
|
|
2452
|
-
submissionId: submission.submissionId,
|
|
2453
|
-
...(message ? { reason: message } : {}),
|
|
2454
|
-
});
|
|
2455
|
-
}
|
|
2456
|
-
}
|
|
2457
|
-
|
|
2458
|
-
// 作用:把 Turn 的权威结果一次提交到 Submission、恢复里程碑和聊天续传标记。
|
|
2459
|
-
// 调用:SubmissionLifecycle 完成成功、失败或取消时调用。
|
|
2460
|
-
// 原因:已持久的 abort 和 terminal intent 优先于调用方参数,可避免并发收尾覆盖更早的真实结果。
|
|
2461
|
-
private async commitTerminalOutcome(
|
|
2462
|
-
submission: StoredSubmission,
|
|
2463
|
-
outcome: "succeeded" | "failed" | "aborted",
|
|
2464
|
-
message?: string,
|
|
2465
|
-
): Promise<StoredSubmission> {
|
|
2466
|
-
const latest = this.readSubmission(submission.submissionId);
|
|
2467
|
-
if (!latest) {
|
|
2468
|
-
throw new Error(
|
|
2469
|
-
`Unknown SpringBrand submission: ${submission.submissionId}`,
|
|
2470
|
-
);
|
|
2471
|
-
}
|
|
2472
|
-
if (isTerminalSubmissionStatus(latest.status)) return latest;
|
|
2473
|
-
|
|
2474
|
-
for (const steer of this.db.steers.listForSubmission(latest.submissionId)) {
|
|
2475
|
-
const userMessage = JSON.parse(steer.canonicalJson) as PiCanonicalUserInput;
|
|
2476
|
-
const uiMessage = steer.uiMessageJson
|
|
2477
|
-
? JSON.parse(steer.uiMessageJson) as UIMessage & { role: "user" }
|
|
2478
|
-
: undefined;
|
|
2479
|
-
const submitted = await this.submitMessage(userMessage, {
|
|
2480
|
-
requestId: steer.messageId,
|
|
2481
|
-
idempotencyKey: steer.messageId,
|
|
2482
|
-
userMessageId: steer.messageId,
|
|
2483
|
-
...(uiMessage ? { userMessage: uiMessage } : {}),
|
|
2484
|
-
});
|
|
2485
|
-
this.ctx.waitUntil(submitted.completion);
|
|
2486
|
-
this.db.transaction(() =>
|
|
2487
|
-
this.db.steers.deleteByMessageId(steer.messageId)
|
|
2488
|
-
);
|
|
2489
|
-
}
|
|
2490
|
-
|
|
2491
|
-
const durableIntent = this.decidePiRecovery(latest).terminal;
|
|
2492
|
-
const effectiveOutcome = latest.abortReason
|
|
2493
|
-
? "aborted"
|
|
2494
|
-
: durableIntent?.outcome ?? outcome;
|
|
2495
|
-
const effectiveMessage =
|
|
2496
|
-
latest.abortReason ?? durableIntent?.message ?? message;
|
|
2497
|
-
const turnOutput = (await this.transcript.storedMessages())
|
|
2498
|
-
.filter((entry) =>
|
|
2499
|
-
entry.submissionId === latest.submissionId &&
|
|
2500
|
-
entry.message.role === "assistant"
|
|
2501
|
-
)
|
|
2502
|
-
.at(-1)?.message;
|
|
2503
|
-
const terminal = this.db.transaction(() => {
|
|
2504
|
-
this.appendTerminalIntent(
|
|
2505
|
-
latest,
|
|
2506
|
-
effectiveOutcome,
|
|
2507
|
-
effectiveMessage,
|
|
2508
|
-
);
|
|
2509
|
-
const closedApprovals = this.approvals.rejectPending(
|
|
2510
|
-
latest,
|
|
2511
|
-
effectiveMessage ?? "Turn ended before approval",
|
|
2512
|
-
);
|
|
2513
|
-
// 还挂着的 interaction 同样要收尾,否则 Turn 结束后 pending 行会变成孤儿,
|
|
2514
|
-
// 用户还能看见一张点了没反应的卡。
|
|
2515
|
-
const closedInteractions = this.interactions
|
|
2516
|
-
.cancelPendingForSubmissionSync(latest);
|
|
2517
|
-
if (closedApprovals || closedInteractions) {
|
|
2518
|
-
this.materializeRecoveredToolResultsSync(latest);
|
|
2519
|
-
}
|
|
2520
|
-
const status =
|
|
2521
|
-
effectiveOutcome === "succeeded"
|
|
2522
|
-
? "completed"
|
|
2523
|
-
: effectiveOutcome === "aborted"
|
|
2524
|
-
? "aborted"
|
|
2525
|
-
: "error";
|
|
2526
|
-
const terminal = this.updateSubmission(
|
|
2527
|
-
latest.submissionId,
|
|
2528
|
-
status,
|
|
2529
|
-
effectiveMessage,
|
|
2530
|
-
);
|
|
2531
|
-
this.insertSubmissionTerminalFact(terminal);
|
|
2532
|
-
if (
|
|
2533
|
-
effectiveOutcome === "aborted" &&
|
|
2534
|
-
effectiveMessage === USER_STOP_REASON
|
|
2535
|
-
) {
|
|
2536
|
-
if (terminal.completedAt == null) {
|
|
2537
|
-
throw new Error(
|
|
2538
|
-
`Aborted SpringBrand submission has no completion time: ${latest.submissionId}`,
|
|
2539
|
-
);
|
|
2540
|
-
}
|
|
2541
|
-
this.transcript.appendTurnAbortedMarker(
|
|
2542
|
-
latest.submissionId,
|
|
2543
|
-
terminal.completedAt,
|
|
2544
|
-
);
|
|
2545
|
-
}
|
|
2546
|
-
const committed = this.decidePiRecovery(latest, {
|
|
2547
|
-
kind: "record-terminal",
|
|
2548
|
-
phase: "committed",
|
|
2549
|
-
outcome: effectiveOutcome,
|
|
2550
|
-
...(effectiveMessage === undefined
|
|
2551
|
-
? {}
|
|
2552
|
-
: { message: effectiveMessage }),
|
|
2553
|
-
});
|
|
2554
|
-
this.applyPiRecoveryMutations(
|
|
2555
|
-
latest,
|
|
2556
|
-
committed.mutations,
|
|
2557
|
-
);
|
|
2558
|
-
this.telemetry.capture("turnFinished", {
|
|
2559
|
-
submissionId: latest.submissionId,
|
|
2560
|
-
outcome: effectiveOutcome === "succeeded"
|
|
2561
|
-
? "completed"
|
|
2562
|
-
: effectiveOutcome === "aborted"
|
|
2563
|
-
? "cancelled"
|
|
2564
|
-
: "failed",
|
|
2565
|
-
durationMs: Math.max(
|
|
2566
|
-
0,
|
|
2567
|
-
(terminal.completedAt ?? Date.now()) - latest.createdAt,
|
|
2568
|
-
),
|
|
2569
|
-
...(turnOutput === undefined ? {} : { output: turnOutput }),
|
|
2570
|
-
...(effectiveMessage ? { error: effectiveMessage } : {}),
|
|
2571
|
-
occurredAt: terminal.completedAt ?? Date.now(),
|
|
2572
|
-
});
|
|
2573
|
-
return terminal;
|
|
2574
|
-
});
|
|
2575
|
-
await this.drainRuntimeEvents();
|
|
2576
|
-
try {
|
|
2577
|
-
await this.settleChatRecovery(
|
|
2578
|
-
latest.requestId,
|
|
2579
|
-
effectiveOutcome === "succeeded"
|
|
2580
|
-
? "completed"
|
|
2581
|
-
: effectiveOutcome === "aborted"
|
|
2582
|
-
? "skipped"
|
|
2583
|
-
: "failed",
|
|
2584
|
-
effectiveMessage,
|
|
2585
|
-
);
|
|
2586
|
-
} catch (error) {
|
|
2587
|
-
console.error("[chat-recovery] terminal settlement failed", error);
|
|
2588
|
-
}
|
|
2589
|
-
await this.broadcastApprovals();
|
|
2590
|
-
try {
|
|
2591
|
-
if (
|
|
2592
|
-
effectiveOutcome === "failed" ||
|
|
2593
|
-
(effectiveOutcome === "aborted" &&
|
|
2594
|
-
effectiveMessage !== USER_STOP_REASON)
|
|
2595
|
-
) {
|
|
2596
|
-
await recordChatTerminal(
|
|
2597
|
-
this.ctx.storage,
|
|
2598
|
-
latest.requestId,
|
|
2599
|
-
effectiveMessage ?? "",
|
|
2600
|
-
);
|
|
2601
|
-
} else {
|
|
2602
|
-
await clearChatTerminal(this.ctx.storage);
|
|
2603
|
-
}
|
|
2604
|
-
} catch (error) {
|
|
2605
|
-
console.error("[pi-terminal] resume marker failed", error);
|
|
2606
|
-
}
|
|
2607
|
-
return terminal;
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
|
-
// 作用:按持久事实把一个中断 Turn 推进到可继续或已收尾状态。
|
|
2611
|
-
// 调用:`executeNonTerminalSubmission` 以 recovery 模式重起 Pi Turn 前调用。
|
|
2612
|
-
// 原因:用有界循环执行状态机效果,既允许 Tool 重试产生新事实,也防止错误规则无限自旋。
|
|
2613
|
-
//
|
|
2614
|
-
// 返回值必须区分「在等人」和「续不下去」:以前两者都压成 false,调用方一律原样返回
|
|
2615
|
-
// 非终态记录,于是「续不下去」这一类会让 Submission 永久停在 running。
|
|
2616
|
-
private async prepareRecoveredTurn(
|
|
2617
|
-
submission: StoredSubmission,
|
|
2618
|
-
adapter: PreparedPiTurnAdapter,
|
|
2619
|
-
): Promise<RecoveredTurnPreparation> {
|
|
2620
|
-
let decision = await this.materializeRecoveredToolResults(submission);
|
|
2621
|
-
for (let step = 0; step < 32; step += 1) {
|
|
2622
|
-
const latest = this.readSubmission(submission.submissionId);
|
|
2623
|
-
if (!latest || isTerminalSubmissionStatus(latest.status)) {
|
|
2624
|
-
return { kind: "settled" };
|
|
2625
|
-
}
|
|
2626
|
-
if (latest.abortReason) {
|
|
2627
|
-
await this.submissions.finish(
|
|
2628
|
-
latest,
|
|
2629
|
-
"aborted",
|
|
2630
|
-
latest.abortReason,
|
|
2631
|
-
);
|
|
2632
|
-
return { kind: "settled" };
|
|
2633
|
-
}
|
|
2634
|
-
this.db.transaction(() => {
|
|
2635
|
-
this.applyPiRecoveryMutations(
|
|
2636
|
-
submission,
|
|
2637
|
-
decision.mutations,
|
|
2638
|
-
);
|
|
2639
|
-
});
|
|
2640
|
-
switch (decision.effect.kind) {
|
|
2641
|
-
case "wait":
|
|
2642
|
-
// 这里必须逐个 reason 判,不能按「不是 approval 就算续不下去」一刀切:
|
|
2643
|
-
// - approval / interaction:等人,用户迟早会点。
|
|
2644
|
-
// - complete:终态里程碑已提交,行状态同事务写入,上层的终态检查会收掉。
|
|
2645
|
-
// - undefined:续跑里程碑已暂存待派发,`dispatchPendingContinuations`
|
|
2646
|
-
// 会来接手 —— 判它失败等于杀掉一轮本来有人管的 Turn。
|
|
2647
|
-
// - uncertain-tool:**没有任何人会来解开**。非幂等工具结果不确定,恢复
|
|
2648
|
-
// 拒绝重放;停在这里就是永久挂死。
|
|
2649
|
-
return decision.effect.reason === "uncertain-tool"
|
|
2650
|
-
? {
|
|
2651
|
-
kind: "unresumable",
|
|
2652
|
-
reason: unresumableWaitMessage(decision.effect.reason),
|
|
2653
|
-
}
|
|
2654
|
-
: { kind: "parked" };
|
|
2655
|
-
case "retry-tool": {
|
|
2656
|
-
try {
|
|
2657
|
-
if (
|
|
2658
|
-
!await adapter.retryTool({
|
|
2659
|
-
toolName: decision.effect.toolName,
|
|
2660
|
-
toolCallId: decision.effect.toolCallId,
|
|
2661
|
-
input: decision.effect.input,
|
|
2662
|
-
})
|
|
2663
|
-
) {
|
|
2664
|
-
return {
|
|
2665
|
-
kind: "unresumable",
|
|
2666
|
-
reason:
|
|
2667
|
-
`SpringBrand could not resume this turn: Tool "${decision.effect.toolName}" is no longer available.`,
|
|
2668
|
-
};
|
|
2669
|
-
}
|
|
2670
|
-
} catch {
|
|
2671
|
-
// The governed Tool persisted its bounded error ToolResult.
|
|
2672
|
-
}
|
|
2673
|
-
decision = await this.materializeRecoveredToolResults(
|
|
2674
|
-
submission,
|
|
2675
|
-
);
|
|
2676
|
-
continue;
|
|
2677
|
-
}
|
|
2678
|
-
case "schedule-continuation":
|
|
2679
|
-
await this.approvals.dispatchContinuation(
|
|
2680
|
-
submission,
|
|
2681
|
-
decision.effect.approvalExecutionId,
|
|
2682
|
-
);
|
|
2683
|
-
return { kind: "parked" };
|
|
2684
|
-
case "finish":
|
|
2685
|
-
await this.submissions.finish(
|
|
2686
|
-
submission,
|
|
2687
|
-
decision.effect.outcome,
|
|
2688
|
-
decision.effect.message,
|
|
2689
|
-
);
|
|
2690
|
-
return { kind: "settled" };
|
|
2691
|
-
case "resume-turn": {
|
|
2692
|
-
const last = (await this.transcript.canonicalMessages()).at(-1);
|
|
2693
|
-
if (last?.role === "assistant" && last.stopReason === "toolUse") {
|
|
2694
|
-
// Tool input 会在真正执行前持久化。恢复状态仍为 idle 证明这些
|
|
2695
|
-
// canonical Tool Call 还没越过 attempt 边界,包括非幂等 Tool 也可以安全首次执行。
|
|
2696
|
-
const unstartedCalls = last.content.filter((part) =>
|
|
2697
|
-
part.type === "toolCall"
|
|
2698
|
-
);
|
|
2699
|
-
if (unstartedCalls.length > 0) {
|
|
2700
|
-
for (const call of unstartedCalls) {
|
|
2701
|
-
try {
|
|
2702
|
-
if (
|
|
2703
|
-
!await adapter.retryTool({
|
|
2704
|
-
toolName: call.name,
|
|
2705
|
-
toolCallId: call.id,
|
|
2706
|
-
input: call.arguments,
|
|
2707
|
-
})
|
|
2708
|
-
) {
|
|
2709
|
-
return {
|
|
2710
|
-
kind: "unresumable",
|
|
2711
|
-
reason:
|
|
2712
|
-
`SpringBrand could not resume this turn: Tool "${call.name}" is no longer available.`,
|
|
2713
|
-
};
|
|
2714
|
-
}
|
|
2715
|
-
} catch {
|
|
2716
|
-
// The governed Tool persisted its bounded error ToolResult.
|
|
2717
|
-
}
|
|
2718
|
-
}
|
|
2719
|
-
decision = await this.materializeRecoveredToolResults(submission);
|
|
2720
|
-
continue;
|
|
2721
|
-
}
|
|
2722
|
-
}
|
|
2723
|
-
// Pi 的 agentLoopContinue 拒绝从 assistant 消息继续。悬空的尾部 assistant
|
|
2724
|
-
// 意味着这条 transcript 续不下去了 —— 判断出来就必须收尾,不能默默退场。
|
|
2725
|
-
return last?.role === "user" || last?.role === "toolResult"
|
|
2726
|
-
? { kind: "resume" }
|
|
2727
|
-
: {
|
|
2728
|
-
kind: "unresumable",
|
|
2729
|
-
reason:
|
|
2730
|
-
"SpringBrand could not resume this turn: the transcript ends on an assistant message.",
|
|
2731
|
-
};
|
|
2732
|
-
}
|
|
2733
|
-
}
|
|
2734
|
-
}
|
|
2735
|
-
throw new Error("SpringBrand recovery plan did not converge");
|
|
2736
|
-
}
|
|
2737
|
-
|
|
2738
|
-
// #endregion
|
|
2739
|
-
|
|
2740
|
-
// #region Turn 执行、流式记录与完成投影
|
|
2741
|
-
|
|
2742
|
-
private async activateQueuedSubmission(
|
|
2743
|
-
submission: StoredSubmission,
|
|
2744
|
-
): Promise<StoredSubmission> {
|
|
2745
|
-
if (!submission.queuedInputJson) {
|
|
2746
|
-
const started = this.db.submissions.transition(
|
|
2747
|
-
submission.submissionId,
|
|
2748
|
-
"running",
|
|
2749
|
-
["pending"],
|
|
2750
|
-
);
|
|
2751
|
-
const activated = this.readSubmission(submission.submissionId)!;
|
|
2752
|
-
if (started) {
|
|
2753
|
-
this.telemetry.capture("turnStarted", {
|
|
2754
|
-
submissionId: activated.submissionId,
|
|
2755
|
-
assemblyRevision: activated.assemblyRevision,
|
|
2756
|
-
});
|
|
2757
|
-
}
|
|
2758
|
-
await this.broadcastApprovals();
|
|
2759
|
-
return activated;
|
|
2760
|
-
}
|
|
2761
|
-
|
|
2762
|
-
const userMessage = JSON.parse(
|
|
2763
|
-
submission.queuedInputJson,
|
|
2764
|
-
) as PiCanonicalUserInput;
|
|
2765
|
-
if (
|
|
2766
|
-
userMessage?.role !== "user" ||
|
|
2767
|
-
typeof userMessage.timestamp !== "number" ||
|
|
2768
|
-
!submission.userMessageId
|
|
2769
|
-
) {
|
|
2770
|
-
throw new Error("Queued SpringBrand user message is invalid");
|
|
2771
|
-
}
|
|
2772
|
-
const uiMessage = submission.queuedUiMessageJson
|
|
2773
|
-
? JSON.parse(submission.queuedUiMessageJson) as UIMessage & {
|
|
2774
|
-
role: "user";
|
|
2775
|
-
}
|
|
2776
|
-
: undefined;
|
|
2777
|
-
if (uiMessage && uiMessage.role !== "user") {
|
|
2778
|
-
throw new Error("Queued UI user message is invalid");
|
|
2779
|
-
}
|
|
2780
|
-
if (submission.regenerateMessageId) {
|
|
2781
|
-
const regenerateEntry = await this.transcript.findUserMessage(
|
|
2782
|
-
submission.regenerateMessageId,
|
|
2783
|
-
);
|
|
2784
|
-
if (
|
|
2785
|
-
!regenerateEntry ||
|
|
2786
|
-
userContentKey(regenerateEntry) !== userContentKey(userMessage)
|
|
2787
|
-
) {
|
|
2788
|
-
throw new Error(
|
|
2789
|
-
"Regenerate request must match an existing user message",
|
|
2790
|
-
);
|
|
2791
|
-
}
|
|
2792
|
-
}
|
|
2793
|
-
|
|
2794
|
-
const activated = this.db.transaction(() => {
|
|
2795
|
-
const latest = this.readSubmission(submission.submissionId);
|
|
2796
|
-
if (!latest || latest.status !== "pending") {
|
|
2797
|
-
if (!latest) {
|
|
2798
|
-
throw new Error(
|
|
2799
|
-
`Unknown SpringBrand submission: ${submission.submissionId}`,
|
|
2800
|
-
);
|
|
2801
|
-
}
|
|
2802
|
-
return latest;
|
|
2803
|
-
}
|
|
2804
|
-
const running = this.db.submissions.findRunning();
|
|
2805
|
-
if (running) {
|
|
2806
|
-
throw new Error(
|
|
2807
|
-
`SpringBrand submission ${running.submissionId} is already running`,
|
|
2808
|
-
);
|
|
2809
|
-
}
|
|
2810
|
-
if (submission.regenerateMessageId) {
|
|
2811
|
-
this.transcript.moveLeaf(submission.regenerateMessageId);
|
|
2812
|
-
} else {
|
|
2813
|
-
const appended = this.transcript.append(
|
|
2814
|
-
submission.userMessageId!,
|
|
2815
|
-
userMessage,
|
|
2816
|
-
{
|
|
2817
|
-
submissionId: submission.submissionId,
|
|
2818
|
-
...(uiMessage ? { userMessage: uiMessage } : {}),
|
|
2819
|
-
},
|
|
2820
|
-
);
|
|
2821
|
-
if (!appended) {
|
|
2822
|
-
throw new Error(
|
|
2823
|
-
`SpringBrand Session message ${submission.userMessageId} already exists`,
|
|
2824
|
-
);
|
|
2825
|
-
}
|
|
2826
|
-
}
|
|
2827
|
-
if (!this.db.submissions.transition(
|
|
2828
|
-
submission.submissionId,
|
|
2829
|
-
"running",
|
|
2830
|
-
["pending"],
|
|
2831
|
-
)) {
|
|
2832
|
-
throw new Error(
|
|
2833
|
-
`SpringBrand submission ${submission.submissionId} could not activate`,
|
|
2834
|
-
);
|
|
2835
|
-
}
|
|
2836
|
-
this.db.submissions.clearQueuedPayload(
|
|
2837
|
-
submission.submissionId,
|
|
2838
|
-
);
|
|
2839
|
-
const started = this.readSubmission(submission.submissionId)!;
|
|
2840
|
-
this.telemetry.capture("turnStarted", {
|
|
2841
|
-
submissionId: started.submissionId,
|
|
2842
|
-
assemblyRevision: started.assemblyRevision,
|
|
2843
|
-
input: userMessage,
|
|
2844
|
-
});
|
|
2845
|
-
return started;
|
|
2846
|
-
});
|
|
2847
|
-
this.broadcast(json({
|
|
2848
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
2849
|
-
messages: await this.getMessages(),
|
|
2850
|
-
}));
|
|
2851
|
-
await this.broadcastApprovals();
|
|
2852
|
-
return activated;
|
|
2853
|
-
}
|
|
2854
|
-
|
|
2855
|
-
// 作用:执行一条 Submission,并保证任何异常都被收口为持久终态。
|
|
2856
|
-
// 调用:SubmissionLifecycle 启动新提交或恢复未完提交时调用。
|
|
2857
|
-
// 原因:把失败帧、可续传流和 pre-stream 清理放在最外层,避免内部分支遗留半开传输状态。
|
|
2858
|
-
private async executeSubmission(
|
|
2859
|
-
submissionId: string,
|
|
2860
|
-
mode: SubmissionExecutionMode = "fresh",
|
|
2861
|
-
): Promise<StoredSubmission> {
|
|
2862
|
-
const submission = this.readSubmission(submissionId);
|
|
2863
|
-
if (!submission) {
|
|
2864
|
-
throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
|
|
2865
|
-
}
|
|
2866
|
-
if (isTerminalSubmissionStatus(submission.status)) {
|
|
2867
|
-
return submission;
|
|
2868
|
-
}
|
|
2869
|
-
if (submission.abortReason) {
|
|
2870
|
-
return this.submissions.finish(
|
|
2871
|
-
submission,
|
|
2872
|
-
"aborted",
|
|
2873
|
-
submission.abortReason,
|
|
2874
|
-
);
|
|
2875
|
-
}
|
|
2876
|
-
if (mode === "recovery") {
|
|
2877
|
-
// 只有真中断才记恢复事实。计划让步也走这里的话,每个执行片都会伪造一条
|
|
2878
|
-
// `runtime_restart`,并且抹掉真实的 stall 原因 —— 真崩溃和主动让出在观测上就分不开了。
|
|
2879
|
-
this.telemetry.capture("turnRecovering", {
|
|
2880
|
-
submissionId,
|
|
2881
|
-
reason: submission.recoveryReason ?? "runtime_restart",
|
|
2882
|
-
attempt: Math.max(1, submission.recoveryErrorCount),
|
|
2883
|
-
});
|
|
2884
|
-
if (submission.recoveryReason !== "context_overflow") {
|
|
2885
|
-
this.db.submissions.clearRecoveryReason(submissionId);
|
|
2886
|
-
}
|
|
2887
|
-
await this.broadcastApprovals();
|
|
2888
|
-
}
|
|
2889
|
-
if (mode !== "fresh") await this.ensureRuntimeReady();
|
|
2890
|
-
try {
|
|
2891
|
-
return await this.executeNonTerminalSubmission(
|
|
2892
|
-
submission,
|
|
2893
|
-
mode,
|
|
2894
|
-
);
|
|
2895
|
-
} catch (error) {
|
|
2896
|
-
if (
|
|
2897
|
-
mode === "recovery" &&
|
|
2898
|
-
(error instanceof ChatStreamStalledError ||
|
|
2899
|
-
error instanceof RetryableModelError)
|
|
2900
|
-
) {
|
|
2901
|
-
throw error;
|
|
2902
|
-
}
|
|
2903
|
-
const failed = await this.submissions.finish(
|
|
2904
|
-
submission,
|
|
2905
|
-
"failed",
|
|
2906
|
-
errorText(error),
|
|
2907
|
-
);
|
|
2908
|
-
const streamId =
|
|
2909
|
-
this.streamBySubmission.get(submission.submissionId);
|
|
2910
|
-
if (streamId) {
|
|
2911
|
-
this.failRecoverableStream(streamId);
|
|
2912
|
-
}
|
|
2913
|
-
this.sendChatTerminal(
|
|
2914
|
-
submission.requestId,
|
|
2915
|
-
failed.status === "error"
|
|
2916
|
-
? failed.error ?? "SpringBrand turn failed"
|
|
2917
|
-
: undefined,
|
|
2918
|
-
mode !== "fresh",
|
|
2919
|
-
);
|
|
2920
|
-
return failed;
|
|
2921
|
-
} finally {
|
|
2922
|
-
const streamId =
|
|
2923
|
-
this.streamBySubmission.get(submission.submissionId);
|
|
2924
|
-
if (streamId) {
|
|
2925
|
-
this.streamBySubmission.delete(submission.submissionId);
|
|
2926
|
-
}
|
|
2927
|
-
this.settlePreStream(submission.requestId);
|
|
2928
|
-
}
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
// 作用:运行一条未结束 Submission 的完整 Pi Turn。
|
|
2932
|
-
// 调用:外层 `executeSubmission` 确认记录存在且未终态后调用。
|
|
2933
|
-
// 原因:恢复补全、状态迁移、fiber、流式记录与终态投影必须保持固定顺序,否则唤醒后会重复执行或丢失回答。
|
|
2934
|
-
private async executeNonTerminalSubmission(
|
|
2935
|
-
submission: StoredSubmission,
|
|
2936
|
-
mode: SubmissionExecutionMode,
|
|
2937
|
-
): Promise<StoredSubmission> {
|
|
2938
|
-
// 续跑和恢复一样要给客户端打 continuation 标记(否则前端会另起一个空累加器,
|
|
2939
|
-
// 把已经渲染出来的半条回答整条换掉),但只有恢复需要重建持久事实。
|
|
2940
|
-
const continuation = mode !== "fresh";
|
|
2941
|
-
const submissionId = submission.submissionId;
|
|
2942
|
-
if (submission.abortReason) {
|
|
2943
|
-
return this.submissions.finish(
|
|
2944
|
-
submission,
|
|
2945
|
-
"aborted",
|
|
2946
|
-
submission.abortReason,
|
|
2947
|
-
);
|
|
2948
|
-
}
|
|
2949
|
-
submission = await this.pinSubmissionAssembly(submission, mode);
|
|
2950
|
-
await this.assertPinnedSubmission(submission);
|
|
2951
|
-
if (submission.status === "pending" && mode === "fresh") {
|
|
2952
|
-
submission = await this.activateQueuedSubmission(submission);
|
|
2953
|
-
}
|
|
2954
|
-
const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
|
|
2955
|
-
let awaitingHumanInput = false;
|
|
2956
|
-
// 让步续跑跳过整套里程碑重放:这一片是主动让出的,transcript 完好、没有半路的
|
|
2957
|
-
// Tool 要重建。走恢复通道不只是白跑,重放成本还随让步次数增长 —— 第 N 片要
|
|
2958
|
-
// 重放前 N-1 片积累的全部里程碑。但调度回调可能跨过等人的停靠期,所以下面仍需检查
|
|
2959
|
-
// 恢复决策,不能把尾部 assistant Tool Use 直接交给 Pi 续跑。
|
|
2960
|
-
if (mode === "recovery") {
|
|
2961
|
-
const deactivatePreparation = this.submissions.activate(submission, {
|
|
2962
|
-
submissionId,
|
|
2963
|
-
requestId: submission.requestId,
|
|
2964
|
-
messageId: submission.assistantMessageId,
|
|
2965
|
-
startedAt: submission.createdAt,
|
|
2966
|
-
continuation: true,
|
|
2967
|
-
agent: recoveryAdapter,
|
|
2968
|
-
});
|
|
2969
|
-
let preparation: RecoveredTurnPreparation;
|
|
2970
|
-
try {
|
|
2971
|
-
preparation = await this.prepareRecoveredTurn(
|
|
2972
|
-
submission,
|
|
2973
|
-
recoveryAdapter,
|
|
2974
|
-
);
|
|
2975
|
-
} finally {
|
|
2976
|
-
deactivatePreparation();
|
|
2977
|
-
}
|
|
2978
|
-
if (preparation.kind !== "resume") {
|
|
2979
|
-
const latest = this.readSubmission(submissionId)!;
|
|
2980
|
-
if (latest.abortReason) {
|
|
2981
|
-
return this.submissions.finish(
|
|
2982
|
-
latest,
|
|
2983
|
-
"aborted",
|
|
2984
|
-
latest.abortReason,
|
|
2985
|
-
);
|
|
2986
|
-
}
|
|
2987
|
-
if (isTerminalSubmissionStatus(latest.status)) return latest;
|
|
2988
|
-
// 非终态还想退场,只有「在等人」这一种是合法的。其余一律收成失败终态:
|
|
2989
|
-
// 停在 running 会让 `pump()` 的 `findRunning()` 永远有值,这个 Session
|
|
2990
|
-
// 之后所有消息都排不进去,而且没有任何东西会再唤醒它。
|
|
2991
|
-
if (preparation.kind === "parked") return latest;
|
|
2992
|
-
// `settled` 走到这里说明它没真的写成终态 —— 契约破了也不能放它挂着。
|
|
2993
|
-
return this.submissions.finish(
|
|
2994
|
-
latest,
|
|
2995
|
-
"failed",
|
|
2996
|
-
preparation.kind === "unresumable"
|
|
2997
|
-
? preparation.reason
|
|
2998
|
-
: unresumableWaitMessage(undefined),
|
|
2999
|
-
);
|
|
3000
|
-
}
|
|
3001
|
-
} else {
|
|
3002
|
-
const decision = await this.materializeRecoveredToolResults(submission);
|
|
3003
|
-
awaitingHumanInput = mode === "continuation" &&
|
|
3004
|
-
decision.effect.kind === "wait" &&
|
|
3005
|
-
(decision.effect.reason === "approval" ||
|
|
3006
|
-
decision.effect.reason === "interaction");
|
|
3007
|
-
}
|
|
3008
|
-
|
|
3009
|
-
const ready = this.readSubmission(submissionId);
|
|
3010
|
-
if (!ready) {
|
|
3011
|
-
throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
|
|
3012
|
-
}
|
|
3013
|
-
if (isTerminalSubmissionStatus(ready.status)) return ready;
|
|
3014
|
-
if (ready.abortReason) {
|
|
3015
|
-
return this.submissions.finish(
|
|
3016
|
-
ready,
|
|
3017
|
-
"aborted",
|
|
3018
|
-
ready.abortReason,
|
|
3019
|
-
);
|
|
3020
|
-
}
|
|
3021
|
-
if (awaitingHumanInput) return ready;
|
|
3022
|
-
if (ready.status === "pending") {
|
|
3023
|
-
if (this.db.submissions.transition(submissionId, "running", ["pending"])) {
|
|
3024
|
-
this.telemetry.capture("turnStarted", {
|
|
3025
|
-
submissionId,
|
|
3026
|
-
assemblyRevision: ready.assemblyRevision,
|
|
3027
|
-
});
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
const storedMessages = await this.transcript.storedMessages();
|
|
3031
|
-
const assistantOrdinal = storedMessages.filter(
|
|
3032
|
-
(entry) =>
|
|
3033
|
-
entry.submissionId === submissionId &&
|
|
3034
|
-
entry.message.role === "assistant",
|
|
3035
|
-
).length;
|
|
3036
|
-
let terminalIntent:
|
|
3037
|
-
| {
|
|
3038
|
-
outcome: "succeeded" | "failed" | "aborted";
|
|
3039
|
-
message?: string;
|
|
3040
|
-
}
|
|
3041
|
-
| undefined;
|
|
3042
|
-
const startedAt = ready.createdAt;
|
|
3043
|
-
let turn!: ActiveTurn;
|
|
3044
|
-
const adapter = this.createSubmissionExecutionAdapter(submission, {
|
|
3045
|
-
startedAt,
|
|
3046
|
-
continuation,
|
|
3047
|
-
assistantOrdinal,
|
|
3048
|
-
onRecord: (record) =>
|
|
3049
|
-
this.migratedSubmissions.has(submissionId)
|
|
3050
|
-
? undefined
|
|
3051
|
-
: this.persistAndBroadcastRecord(turn, record),
|
|
3052
|
-
onTerminal: async (intent) => {
|
|
3053
|
-
terminalIntent = intent;
|
|
3054
|
-
this.appendTerminalIntent(
|
|
3055
|
-
submission,
|
|
3056
|
-
intent.outcome,
|
|
3057
|
-
intent.message,
|
|
3058
|
-
);
|
|
3059
|
-
},
|
|
3060
|
-
});
|
|
3061
|
-
for (const pending of this.db.steers.listForSubmission(submissionId)) {
|
|
3062
|
-
const message = JSON.parse(
|
|
3063
|
-
pending.canonicalJson,
|
|
3064
|
-
) as PiCanonicalUserInput;
|
|
3065
|
-
if (message?.role !== "user") {
|
|
3066
|
-
throw new Error(`Pending steer ${pending.steerId} is invalid`);
|
|
3067
|
-
}
|
|
3068
|
-
adapter.steer(message, pending.messageId);
|
|
3069
|
-
}
|
|
3070
|
-
turn = {
|
|
3071
|
-
submissionId,
|
|
3072
|
-
requestId: submission.requestId,
|
|
3073
|
-
messageId: submission.assistantMessageId,
|
|
3074
|
-
startedAt,
|
|
3075
|
-
continuation,
|
|
3076
|
-
agent: adapter,
|
|
3077
|
-
};
|
|
3078
|
-
let streamId: string | undefined;
|
|
3079
|
-
let runResult: import("./pi/runtime-adapter").PiTurnRunResult | undefined;
|
|
3080
|
-
|
|
3081
|
-
const deactivate = this.submissions.activate(submission, turn);
|
|
3082
|
-
await this.broadcastApprovals();
|
|
3083
|
-
try {
|
|
3084
|
-
await this.runRecoverableChatFiber(
|
|
3085
|
-
{
|
|
3086
|
-
requestId: submission.requestId,
|
|
3087
|
-
messageId: submission.assistantMessageId,
|
|
3088
|
-
continuation,
|
|
3089
|
-
messages: await this.transcript.snapshotMessages(),
|
|
3090
|
-
recoveryData: {
|
|
3091
|
-
submissionId,
|
|
3092
|
-
requestId: submission.requestId,
|
|
3093
|
-
},
|
|
3094
|
-
},
|
|
3095
|
-
async (_fiber, activeStreamId) => {
|
|
3096
|
-
streamId = activeStreamId;
|
|
3097
|
-
this.streamBySubmission.set(submissionId, streamId);
|
|
3098
|
-
try {
|
|
3099
|
-
runResult = await adapter.run({});
|
|
3100
|
-
} finally {
|
|
3101
|
-
this.streamBySubmission.delete(submissionId);
|
|
3102
|
-
}
|
|
3103
|
-
},
|
|
3104
|
-
);
|
|
3105
|
-
// 换装配把这条执行器中断掉了:Submission 没有结束,它在等持久续跑用新
|
|
3106
|
-
// 装配重建一条接着跑。这里写终态会把那一轮当场杀掉。
|
|
3107
|
-
if (this.migratedSubmissions.has(submissionId)) {
|
|
3108
|
-
if (streamId) this.failRecoverableStream(streamId);
|
|
3109
|
-
return this.readSubmission(submissionId)!;
|
|
3110
|
-
}
|
|
3111
|
-
if (runResult?.kind === "yielded") {
|
|
3112
|
-
const latest = this.readSubmission(submissionId)!;
|
|
3113
|
-
if (latest.abortReason) {
|
|
3114
|
-
return this.submissions.finish(
|
|
3115
|
-
latest,
|
|
3116
|
-
"aborted",
|
|
3117
|
-
latest.abortReason,
|
|
3118
|
-
);
|
|
3119
|
-
}
|
|
3120
|
-
if (!streamId) {
|
|
3121
|
-
throw new Error("Yielded SpringBrand Turn has no recoverable stream");
|
|
3122
|
-
}
|
|
3123
|
-
// 回合数和续跑标识必须先落库、再排调度:调度一旦到期就会读这两个值,
|
|
3124
|
-
// 反过来写会让续跑看到上一片的状态。
|
|
3125
|
-
this.db.transaction(() =>
|
|
3126
|
-
this.db.submissions.recordYieldedSlice(
|
|
3127
|
-
submissionId,
|
|
3128
|
-
runResult!.modelTurns,
|
|
3129
|
-
streamId!,
|
|
3130
|
-
)
|
|
3131
|
-
);
|
|
3132
|
-
// 让步必须密封本切片的流,但不能发 done —— 这一轮还没有权威结果。
|
|
3133
|
-
// 不密封的话下一个切片的 start() 会直接顶掉 activeStreamId,旧行永远
|
|
3134
|
-
// 停在 streaming,元数据和 chunk 要等 abandoned 保留期才回收。
|
|
3135
|
-
this.sealRecoverableStreamSlice(streamId);
|
|
3136
|
-
await this.schedule(
|
|
3137
|
-
0,
|
|
3138
|
-
"_piPlannedContinuation",
|
|
3139
|
-
{
|
|
3140
|
-
submissionId,
|
|
3141
|
-
requestId: submission.requestId,
|
|
3142
|
-
continuationId: streamId,
|
|
3143
|
-
},
|
|
3144
|
-
{ idempotent: true },
|
|
3145
|
-
);
|
|
3146
|
-
await this.scheduleContinuationWatchdog(
|
|
3147
|
-
submissionId,
|
|
3148
|
-
submission.requestId,
|
|
3149
|
-
streamId,
|
|
3150
|
-
);
|
|
3151
|
-
return latest;
|
|
3152
|
-
}
|
|
3153
|
-
if (runResult) {
|
|
3154
|
-
this.db.transaction(() =>
|
|
3155
|
-
this.db.submissions.recordModelTurns(
|
|
3156
|
-
submissionId,
|
|
3157
|
-
runResult!.modelTurns,
|
|
3158
|
-
)
|
|
3159
|
-
);
|
|
3160
|
-
}
|
|
3161
|
-
const intent = terminalIntent ?? {
|
|
3162
|
-
outcome: "failed" as const,
|
|
3163
|
-
message:
|
|
3164
|
-
"SpringBrand turn ended without an authoritative assistant message",
|
|
3165
|
-
};
|
|
3166
|
-
const terminal = await this.submissions.finish(
|
|
3167
|
-
submission,
|
|
3168
|
-
intent.outcome,
|
|
3169
|
-
intent.message,
|
|
3170
|
-
);
|
|
3171
|
-
if (streamId) this.settleRecoverableStream(streamId, terminal);
|
|
3172
|
-
await this.projectTerminal(turn, terminal);
|
|
3173
|
-
return terminal;
|
|
3174
|
-
} catch (error) {
|
|
3175
|
-
// 同上:中断出来的 abort 不是失败,也不是取消。
|
|
3176
|
-
if (this.migratedSubmissions.has(submissionId)) {
|
|
3177
|
-
if (streamId) this.failRecoverableStream(streamId);
|
|
3178
|
-
return this.readSubmission(submissionId)!;
|
|
3179
|
-
}
|
|
3180
|
-
const stalled = error instanceof ChatStreamStalledError;
|
|
3181
|
-
const contextOverflow = error instanceof ContextOverflowError;
|
|
3182
|
-
const resetModelSession = error instanceof RetryableModelSessionError;
|
|
3183
|
-
const abortReason = this.readSubmission(submissionId)?.abortReason;
|
|
3184
|
-
if (!abortReason && (stalled || error instanceof RetryableModelError)) {
|
|
3185
|
-
if (resetModelSession) {
|
|
3186
|
-
this.modelSessionId = crypto.randomUUID();
|
|
3187
|
-
await this.ctx.storage.put(
|
|
3188
|
-
MODEL_SESSION_ID_STORAGE_KEY,
|
|
3189
|
-
this.modelSessionId,
|
|
3190
|
-
);
|
|
3191
|
-
}
|
|
3192
|
-
const recoveryReason: SubmissionRecoveryReason = stalled
|
|
3193
|
-
? "no_meaningful_model_progress"
|
|
3194
|
-
: resetModelSession
|
|
3195
|
-
? "server_tool_timeout"
|
|
3196
|
-
: contextOverflow
|
|
3197
|
-
? "context_overflow"
|
|
3198
|
-
: "transient_model_error";
|
|
3199
|
-
let contextOverflowRecoveryClaimed = false;
|
|
3200
|
-
const recoveryErrorCount = this.db.transaction(() => {
|
|
3201
|
-
if (contextOverflow) {
|
|
3202
|
-
contextOverflowRecoveryClaimed = this.db.submissions
|
|
3203
|
-
.claimContextOverflowRecovery(submissionId);
|
|
3204
|
-
}
|
|
3205
|
-
return this.db.submissions.incrementRecoveryErrorCount(
|
|
3206
|
-
submissionId,
|
|
3207
|
-
recoveryReason,
|
|
3208
|
-
);
|
|
3209
|
-
});
|
|
3210
|
-
if (stalled) {
|
|
3211
|
-
const stallDetails = readModelStreamStallDetails(errorText(error)) ?? {
|
|
3212
|
-
lastMeaningfulActivityAt:
|
|
3213
|
-
Date.now() - MODEL_STREAM_STALL_TIMEOUT_MS,
|
|
3214
|
-
lastMeaningfulActivityType: "model_stream_started",
|
|
3215
|
-
idleMs: MODEL_STREAM_STALL_TIMEOUT_MS,
|
|
3216
|
-
};
|
|
3217
|
-
this._emit("chat:stream:stalled", {
|
|
3218
|
-
requestId: submission.requestId,
|
|
3219
|
-
submissionId,
|
|
3220
|
-
attempt: recoveryErrorCount,
|
|
3221
|
-
timeoutMs: MODEL_STREAM_STALL_TIMEOUT_MS,
|
|
3222
|
-
...stallDetails,
|
|
3223
|
-
reason: "no_meaningful_model_progress",
|
|
3224
|
-
});
|
|
3225
|
-
}
|
|
3226
|
-
await this.broadcastApprovals();
|
|
3227
|
-
const stoppedDuringRecovery = this.readSubmission(submissionId)
|
|
3228
|
-
?.abortReason;
|
|
3229
|
-
const retryAllowed = contextOverflow
|
|
3230
|
-
? contextOverflowRecoveryClaimed &&
|
|
3231
|
-
recoveryErrorCount < CHAT_RECOVERY_MAX_ATTEMPTS
|
|
3232
|
-
: recoveryErrorCount <
|
|
3233
|
-
(stalled
|
|
3234
|
-
? CHAT_STALL_MAX_ATTEMPTS
|
|
3235
|
-
: resetModelSession
|
|
3236
|
-
? SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS
|
|
3237
|
-
: CHAT_RECOVERY_MAX_ATTEMPTS);
|
|
3238
|
-
if (
|
|
3239
|
-
!stoppedDuringRecovery &&
|
|
3240
|
-
retryAllowed
|
|
3241
|
-
) {
|
|
3242
|
-
const recoveryOutcome = await this.scheduleChatRecoveryRetry(
|
|
3243
|
-
{
|
|
3244
|
-
submissionId,
|
|
3245
|
-
requestId: submission.requestId,
|
|
3246
|
-
},
|
|
3247
|
-
async () => {
|
|
3248
|
-
this.broadcast(json({
|
|
3249
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
3250
|
-
messages: await this.getMessages(),
|
|
3251
|
-
}));
|
|
3252
|
-
if (streamId) this.completeRecoverableStream(streamId);
|
|
3253
|
-
this.sendChatResponse(submission.requestId, "", true, {
|
|
3254
|
-
continuation: turn.continuation,
|
|
3255
|
-
});
|
|
3256
|
-
},
|
|
3257
|
-
);
|
|
3258
|
-
const stoppedAfterScheduling = this.readSubmission(submissionId)
|
|
3259
|
-
?.abortReason;
|
|
3260
|
-
if (stoppedAfterScheduling) {
|
|
3261
|
-
await this.settleChatRecovery(
|
|
3262
|
-
submission.requestId,
|
|
3263
|
-
"skipped",
|
|
3264
|
-
stoppedAfterScheduling,
|
|
3265
|
-
);
|
|
3266
|
-
}
|
|
3267
|
-
if (!stoppedAfterScheduling) {
|
|
3268
|
-
// 只有恢复通道的调用方(`_chatRecoveryRetry`)靠这次重抛判定 "scheduled"。
|
|
3269
|
-
// 让步续跑由 alarm 驱动,重抛只会把那次 alarm 变成一次失败。
|
|
3270
|
-
if (mode === "recovery" && recoveryOutcome === "scheduled") {
|
|
3271
|
-
throw error;
|
|
3272
|
-
}
|
|
3273
|
-
return this.readSubmission(submissionId)!;
|
|
3274
|
-
}
|
|
3275
|
-
} else if (!stoppedDuringRecovery) {
|
|
3276
|
-
terminalIntent = {
|
|
3277
|
-
outcome: "failed",
|
|
3278
|
-
message: contextOverflow
|
|
3279
|
-
? CONTEXT_OVERFLOW_TERMINAL_MESSAGE
|
|
3280
|
-
: CHAT_RECOVERY_TERMINAL_MESSAGE,
|
|
3281
|
-
};
|
|
3282
|
-
}
|
|
3283
|
-
}
|
|
3284
|
-
const latest = this.readSubmission(submissionId);
|
|
3285
|
-
const intent = terminalIntent ?? (latest?.abortReason
|
|
3286
|
-
? {
|
|
3287
|
-
outcome: "aborted" as const,
|
|
3288
|
-
message: latest.abortReason,
|
|
3289
|
-
}
|
|
3290
|
-
: {
|
|
3291
|
-
outcome: "failed" as const,
|
|
3292
|
-
message: errorText(error),
|
|
3293
|
-
});
|
|
3294
|
-
const failed = await this.submissions.finish(
|
|
3295
|
-
submission,
|
|
3296
|
-
intent.outcome,
|
|
3297
|
-
intent.message,
|
|
3298
|
-
);
|
|
3299
|
-
if (streamId) this.settleRecoverableStream(streamId, failed);
|
|
3300
|
-
await this.projectTerminal(turn, failed);
|
|
3301
|
-
return failed;
|
|
3302
|
-
} finally {
|
|
3303
|
-
deactivate();
|
|
3304
|
-
}
|
|
3305
|
-
}
|
|
3306
|
-
|
|
3307
|
-
private settleRecoverableStream(
|
|
3308
|
-
streamId: string,
|
|
3309
|
-
submission: StoredSubmission,
|
|
3310
|
-
): void {
|
|
3311
|
-
if (
|
|
3312
|
-
submission.status === "completed" ||
|
|
3313
|
-
(submission.status === "aborted" &&
|
|
3314
|
-
submission.error === USER_STOP_REASON)
|
|
3315
|
-
) {
|
|
3316
|
-
this.completeRecoverableStream(streamId);
|
|
3317
|
-
} else {
|
|
3318
|
-
this.failRecoverableStream(streamId);
|
|
3319
|
-
}
|
|
3320
|
-
}
|
|
3321
|
-
|
|
3322
|
-
// 作用:持久化一条 Pi 流记录并广播给在线客户端。
|
|
3323
|
-
// 调用:活跃 Turn 适配器每产生一条 stream record 时调用。
|
|
3324
|
-
// 原因:先发 WebSocket 再等持久化完成,让客户端零延迟收到 token,同时
|
|
3325
|
-
// await 保证 Pi 的事件循环不越过尚未落盘的 chunk,续传完整性不受影响。
|
|
3326
|
-
private async persistAndBroadcastRecord(
|
|
3327
|
-
turn: ActiveTurn,
|
|
3328
|
-
chunk: UIMessageChunk,
|
|
3329
|
-
): Promise<void> {
|
|
3330
|
-
const streamId = this.streamBySubmission.get(turn.submissionId);
|
|
3331
|
-
const body = json(chunk);
|
|
3332
|
-
const persist = streamId
|
|
3333
|
-
? this.appendRecoverableChunk(streamId, body)
|
|
3334
|
-
: Promise.resolve();
|
|
3335
|
-
this.sendChatResponse(turn.requestId, body, false, {
|
|
3336
|
-
continuation: turn.continuation,
|
|
3337
|
-
});
|
|
3338
|
-
await persist;
|
|
3339
|
-
}
|
|
3340
|
-
|
|
3341
|
-
private sendChatTerminal(
|
|
3342
|
-
requestId: string,
|
|
3343
|
-
error?: string,
|
|
3344
|
-
continuation = false,
|
|
3345
|
-
): void {
|
|
3346
|
-
this.sendChatResponse(
|
|
3347
|
-
requestId,
|
|
3348
|
-
error
|
|
3349
|
-
? json({ type: "error", errorText: error } satisfies UIMessageChunk)
|
|
3350
|
-
: "",
|
|
3351
|
-
true,
|
|
3352
|
-
{ continuation },
|
|
3353
|
-
);
|
|
3354
|
-
}
|
|
3355
|
-
|
|
3356
|
-
// 作用:把已提交的 Submission 终态投影到聊天协议、审批状态和可选业务事件。
|
|
3357
|
-
// 调用:非终态 Turn 成功收尾且可续传流已标记完成后调用。
|
|
3358
|
-
// 原因:Submission 是权威状态,其他都是后续投影;业务投影失败只降级记录,不能推翻已完成 Turn。
|
|
3359
|
-
private async projectTerminal(
|
|
3360
|
-
turn: ActiveTurn,
|
|
3361
|
-
submission: StoredSubmission,
|
|
3362
|
-
): Promise<void> {
|
|
3363
|
-
this.sendChatTerminal(
|
|
3364
|
-
turn.requestId,
|
|
3365
|
-
submission.status === "error"
|
|
3366
|
-
? submission.error ?? "SpringBrand turn failed"
|
|
3367
|
-
: undefined,
|
|
3368
|
-
turn.continuation,
|
|
3369
|
-
);
|
|
3370
|
-
const messages = await this.getMessages();
|
|
3371
|
-
await this.broadcastApprovals();
|
|
3372
|
-
this.broadcast(
|
|
3373
|
-
json({
|
|
3374
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
3375
|
-
messages,
|
|
3376
|
-
}),
|
|
3377
|
-
);
|
|
3378
|
-
const events = this.turnEventsPort();
|
|
3379
|
-
if (events) {
|
|
3380
|
-
try {
|
|
3381
|
-
await events.onResponse(
|
|
3382
|
-
messages.flatMap((message) =>
|
|
3383
|
-
message.role === "user" || message.role === "assistant"
|
|
3384
|
-
? [
|
|
3385
|
-
{
|
|
3386
|
-
role: message.role,
|
|
3387
|
-
text: message.parts
|
|
3388
|
-
.filter(
|
|
3389
|
-
(
|
|
3390
|
-
part,
|
|
3391
|
-
): part is Extract<UIMessage["parts"][number], { type: "text" }> =>
|
|
3392
|
-
part.type === "text",
|
|
3393
|
-
)
|
|
3394
|
-
.map((part) => part.text)
|
|
3395
|
-
.join(""),
|
|
3396
|
-
},
|
|
3397
|
-
]
|
|
3398
|
-
: [],
|
|
3399
|
-
),
|
|
3400
|
-
);
|
|
3401
|
-
} catch (error) {
|
|
3402
|
-
console.warn(
|
|
3403
|
-
"[runtime-turn-projection:degraded]",
|
|
3404
|
-
json({ error: errorText(error) }),
|
|
3405
|
-
);
|
|
3406
|
-
}
|
|
3407
|
-
try {
|
|
3408
|
-
if (
|
|
3409
|
-
submission.status === "completed" ||
|
|
3410
|
-
submission.status === "aborted" ||
|
|
3411
|
-
submission.status === "skipped" ||
|
|
3412
|
-
submission.status === "error"
|
|
3413
|
-
) {
|
|
3414
|
-
await events.onSubmissionTerminal?.({
|
|
3415
|
-
submissionId: submission.submissionId,
|
|
3416
|
-
status: submission.status,
|
|
3417
|
-
...(submission.error ? { error: submission.error } : {}),
|
|
3418
|
-
resultMessageId: submission.assistantMessageId,
|
|
3419
|
-
});
|
|
3420
|
-
}
|
|
3421
|
-
} catch (error) {
|
|
3422
|
-
console.warn(
|
|
3423
|
-
"[runtime-terminal-projection:degraded]",
|
|
3424
|
-
json({ error: errorText(error) }),
|
|
3425
|
-
);
|
|
3426
|
-
}
|
|
3427
|
-
}
|
|
3428
|
-
}
|
|
3429
|
-
|
|
3430
|
-
// #endregion
|
|
3431
|
-
|
|
3432
|
-
// #region Cloudflare Agent 通信生命周期
|
|
3433
|
-
|
|
3434
|
-
/**
|
|
3435
|
-
* 在新 WebSocket 建立后向该连接发送当前聊天快照。
|
|
3436
|
-
*
|
|
3437
|
-
* @remarks
|
|
3438
|
-
* Cloudflare Agents SDK 为每条新 WebSocket 连接调用。
|
|
3439
|
-
*
|
|
3440
|
-
* 先发送已完成 transcript,再让恢复基类独自重建 active assistant,
|
|
3441
|
-
* 使持久化历史和 resumable stream 只有一个生产者。
|
|
3442
|
-
*/
|
|
3443
|
-
async onConnect(connection: Connection): Promise<void> {
|
|
3444
|
-
await this.sendHydrationSnapshot(connection);
|
|
3445
|
-
await super.onConnect(connection);
|
|
3446
|
-
}
|
|
3447
|
-
|
|
3448
|
-
/**
|
|
3449
|
-
* 处理直接发给 Agent 实例的 HTTP 请求。
|
|
3450
|
-
*
|
|
3451
|
-
* @remarks
|
|
3452
|
-
* Cloudflare Agents SDK 在每个非 WebSocket HTTP 请求到达时调用。
|
|
3453
|
-
*
|
|
3454
|
-
* Kernel 只拦截 `get-messages` 读取口,其余路由基类处理,
|
|
3455
|
-
* 避免在这里复制 Agents SDK 的默认协议行为。
|
|
3456
|
-
*/
|
|
3457
|
-
async onRequest(request: Request): Promise<Response> {
|
|
3458
|
-
const url = new URL(request.url);
|
|
3459
|
-
if (url.pathname.endsWith("/get-messages")) {
|
|
3460
|
-
return Response.json(
|
|
3461
|
-
url.searchParams.get("view") === "hydration"
|
|
3462
|
-
? await this.hydrationMessages()
|
|
3463
|
-
: await this.getMessages(),
|
|
3464
|
-
);
|
|
3465
|
-
}
|
|
3466
|
-
return super.onRequest(request);
|
|
3467
|
-
}
|
|
3468
|
-
|
|
3469
|
-
/**
|
|
3470
|
-
* 验证并处理一条来自聊天 WebSocket 的协议消息。
|
|
3471
|
-
*
|
|
3472
|
-
* @remarks
|
|
3473
|
-
* Cloudflare Agents SDK 在已连接 WebSocket 收到消息时调用。
|
|
3474
|
-
*
|
|
3475
|
-
* 提交、取消、清空、续传和 Tool 审批都在这一信任边界先验证再分流,
|
|
3476
|
-
* 这样领域层不会接到半解析的客户端输入。
|
|
3477
|
-
*/
|
|
3478
|
-
async onMessage(
|
|
3479
|
-
connection: Connection,
|
|
3480
|
-
raw: string | ArrayBuffer,
|
|
3481
|
-
): Promise<void> {
|
|
3482
|
-
if (typeof raw !== "string") return;
|
|
3483
|
-
const event = parseProtocolMessage(raw);
|
|
3484
|
-
if (!event) return;
|
|
3485
|
-
|
|
3486
|
-
if (event.type === "chat-request") {
|
|
3487
|
-
let body: UIChatRequestBody;
|
|
3488
|
-
try {
|
|
3489
|
-
body = JSON.parse(
|
|
3490
|
-
String(event.init.body ?? "{}"),
|
|
3491
|
-
) as UIChatRequestBody;
|
|
3492
|
-
} catch {
|
|
3493
|
-
this.sendChatTerminal(event.id, "Chat request body is invalid JSON");
|
|
3494
|
-
return;
|
|
3495
|
-
}
|
|
3496
|
-
const latest = Array.isArray(body.messages)
|
|
3497
|
-
? [...body.messages].reverse().find(
|
|
3498
|
-
(message): message is UIMessage & { role: "user" } =>
|
|
3499
|
-
message?.role === "user",
|
|
3500
|
-
)
|
|
3501
|
-
: undefined;
|
|
3502
|
-
if (
|
|
3503
|
-
!latest ||
|
|
3504
|
-
latest.role !== "user" ||
|
|
3505
|
-
typeof latest.id !== "string" ||
|
|
3506
|
-
!Array.isArray(latest.parts) ||
|
|
3507
|
-
!latest.parts.every((part) => {
|
|
3508
|
-
if (part === null || typeof part !== "object") return false;
|
|
3509
|
-
return part.type === "text"
|
|
3510
|
-
? typeof part.text === "string"
|
|
3511
|
-
: part.type === "file" &&
|
|
3512
|
-
typeof part.mediaType === "string" &&
|
|
3513
|
-
typeof part.url === "string" &&
|
|
3514
|
-
(part.filename === undefined || typeof part.filename === "string");
|
|
3515
|
-
}) ||
|
|
3516
|
-
(body.trigger !== "submit-message" &&
|
|
3517
|
-
body.trigger !== "regenerate-message")
|
|
3518
|
-
) {
|
|
3519
|
-
this.sendChatTerminal(event.id, "Chat request user message is invalid");
|
|
3520
|
-
return;
|
|
3521
|
-
}
|
|
3522
|
-
let submitted: Awaited<
|
|
3523
|
-
ReturnType<AgentRuntimeKernel["submitMessage"]>
|
|
3524
|
-
>;
|
|
3525
|
-
try {
|
|
3526
|
-
const normalized = await this.normalizeUIUserInput(latest);
|
|
3527
|
-
submitted = await this.submitMessage(normalized, {
|
|
3528
|
-
requestId: event.id,
|
|
3529
|
-
idempotencyKey: event.id,
|
|
3530
|
-
userMessageId: latest.id,
|
|
3531
|
-
userMessage: latest,
|
|
3532
|
-
regenerate: body.trigger === "regenerate-message",
|
|
3533
|
-
});
|
|
3534
|
-
} catch (error) {
|
|
3535
|
-
this.sendChatTerminal(event.id, errorText(error));
|
|
3536
|
-
return;
|
|
3537
|
-
}
|
|
3538
|
-
if (submitted.receipt.accepted) {
|
|
3539
|
-
this.beginPreStream(event.id);
|
|
3540
|
-
}
|
|
3541
|
-
await submitted.completion;
|
|
3542
|
-
return;
|
|
3543
|
-
}
|
|
3544
|
-
|
|
3545
|
-
if (event.type === "cancel") {
|
|
3546
|
-
const submission = this.findSubmissionByRequest(event.id);
|
|
3547
|
-
if (submission) {
|
|
3548
|
-
await this.cancelSubmissionById(
|
|
3549
|
-
submission.submissionId,
|
|
3550
|
-
USER_STOP_REASON,
|
|
3551
|
-
);
|
|
3552
|
-
}
|
|
3553
|
-
return;
|
|
3554
|
-
}
|
|
3555
|
-
|
|
3556
|
-
if (event.type === "clear") {
|
|
3557
|
-
if (this.submissions.isBusy()) return;
|
|
3558
|
-
this.db.transaction(() => {
|
|
3559
|
-
this.transcript.clear();
|
|
3560
|
-
this.db.clearAll();
|
|
3561
|
-
this.clearRecoverableStreamStorage();
|
|
3562
|
-
});
|
|
3563
|
-
this.clearRecoverableChatState();
|
|
3564
|
-
await this.broadcastApprovals();
|
|
3565
|
-
this.broadcast(json({ type: MessageType.CF_AGENT_CHAT_CLEAR }));
|
|
3566
|
-
return;
|
|
3567
|
-
}
|
|
3568
|
-
|
|
3569
|
-
if (event.type === "stream-resume-request") {
|
|
3570
|
-
// The client sends this only after its listener mounts; onConnect's snapshot
|
|
3571
|
-
// may have arrived while initial history suspended. Active replies still
|
|
3572
|
-
// come exclusively from CF replay, never from this history snapshot.
|
|
3573
|
-
await this.sendHydrationSnapshot(connection);
|
|
3574
|
-
await this.handleResumeRequest(
|
|
3575
|
-
connection,
|
|
3576
|
-
event.probeId,
|
|
3577
|
-
);
|
|
3578
|
-
return;
|
|
3579
|
-
}
|
|
3580
|
-
|
|
3581
|
-
if (event.type === "stream-resume-ack") {
|
|
3582
|
-
await this.handleResumeAck(
|
|
3583
|
-
connection,
|
|
3584
|
-
event.id,
|
|
3585
|
-
);
|
|
3586
|
-
return;
|
|
3587
|
-
}
|
|
3588
|
-
|
|
3589
|
-
if (event.type === "tool-approval") {
|
|
3590
|
-
const executionId = this.approvals.findPendingExecution(
|
|
3591
|
-
event.toolCallId,
|
|
3592
|
-
);
|
|
3593
|
-
if (!executionId) return;
|
|
3594
|
-
await this.decideApproval(
|
|
3595
|
-
executionId,
|
|
3596
|
-
event.approved
|
|
3597
|
-
? { decision: "allow_once" }
|
|
3598
|
-
: { decision: "deny", reason: "User denied the Tool" },
|
|
3599
|
-
);
|
|
3600
|
-
}
|
|
3601
|
-
}
|
|
3602
|
-
|
|
3603
|
-
/**
|
|
3604
|
-
* 返回当前 canonical transcript 的浏览器投影。
|
|
3605
|
-
*
|
|
3606
|
-
* @remarks
|
|
3607
|
-
* 新连接、HTTP 读取、Turn 完成投影和 RPC 调用方需要消息快照时调用。
|
|
3608
|
-
*
|
|
3609
|
-
* 统一从 transcript 生成完整视图;HTTP fallback、fork 和 RPC 读取保留 active assistant。
|
|
3610
|
-
*/
|
|
3611
|
-
async getMessages(): Promise<UIMessage[]> {
|
|
3612
|
-
return this.transcript.browserMessages();
|
|
3613
|
-
}
|
|
3614
|
-
|
|
3615
|
-
private async sendHydrationSnapshot(connection: Connection): Promise<void> {
|
|
3616
|
-
sendIfOpen(connection, json({
|
|
3617
|
-
type: MessageType.CF_AGENT_CHAT_MESSAGES,
|
|
3618
|
-
messages: await this.hydrationMessages(),
|
|
3619
|
-
}));
|
|
3620
|
-
}
|
|
3621
|
-
|
|
3622
|
-
private async hydrationMessages(): Promise<UIMessage[]> {
|
|
3623
|
-
const messages = await this.getMessages();
|
|
3624
|
-
const running = this.db.submissions.findRunning();
|
|
3625
|
-
// After a restart there is no in-memory replay stream. In particular, a
|
|
3626
|
-
// parked approval cannot recreate one until the user can see and decide it.
|
|
3627
|
-
return running?.assistantMessageId && this.streamBySubmission.has(running.submissionId)
|
|
3628
|
-
? messages.filter((message) =>
|
|
3629
|
-
message.role !== "assistant" ||
|
|
3630
|
-
(message.id !== running.assistantMessageId &&
|
|
3631
|
-
(message.metadata as { turnId?: unknown } | undefined)?.turnId !==
|
|
3632
|
-
running.submissionId)
|
|
3633
|
-
)
|
|
3634
|
-
: messages;
|
|
3635
|
-
}
|
|
3636
|
-
|
|
3637
|
-
/** 读取当前 canonical transcript 中最后一条助手文本。 */
|
|
3638
|
-
protected async latestAssistantText(): Promise<string | undefined> {
|
|
3639
|
-
const message = [...await this.transcript.canonicalMessages()]
|
|
3640
|
-
.reverse()
|
|
3641
|
-
.find((entry): entry is AssistantMessage => entry.role === "assistant");
|
|
3642
|
-
const text = message?.content
|
|
3643
|
-
.flatMap((part) => part.type === "text" ? [part.text] : [])
|
|
3644
|
-
.join("")
|
|
3645
|
-
.trim();
|
|
3646
|
-
return text || undefined;
|
|
3647
|
-
}
|
|
3648
|
-
|
|
3649
|
-
// #endregion
|
|
3650
|
-
|
|
3651
|
-
// #region Runtime Extension Host 回环端口
|
|
3652
|
-
|
|
3653
|
-
// 作用:代表扩展从当前 Runtime Workspace 读取文件。
|
|
3654
|
-
// 调用:已获文件读权限的 HostBridgeLoopback 在处理扩展请求时调用。
|
|
3655
|
-
// 原因:扩展只经过已裁剪的 Host 端口访问工作区,不直接持有平台绑定。
|
|
3656
|
-
private async runtimeWorkspace() {
|
|
3657
|
-
await this.ensureRuntimeReady();
|
|
3658
|
-
const workspace = this.assembly().bindings.workspace;
|
|
3659
|
-
if (!workspace) throw new Error("Runtime Workspace is unavailable");
|
|
3660
|
-
return workspace;
|
|
3661
|
-
}
|
|
3662
|
-
|
|
3663
|
-
async _hostReadFile(path: string): Promise<string | null> {
|
|
3664
|
-
return (await this.runtimeWorkspace()).readFile(path);
|
|
3665
|
-
}
|
|
3666
|
-
|
|
3667
|
-
// 作用:代表扩展把文本写入当前 Runtime Workspace。
|
|
3668
|
-
// 调用:已获文件写权限的 HostBridgeLoopback 在处理扩展请求时调用。
|
|
3669
|
-
// 原因:统一在 Session 边界检查 Workspace 是否可用,避免扩展自行解析平台能力。
|
|
3670
|
-
async _hostWriteFile(
|
|
3671
|
-
path: string,
|
|
3672
|
-
content: string,
|
|
3673
|
-
): Promise<void> {
|
|
3674
|
-
await (await this.runtimeWorkspace()).writeFile(path, content);
|
|
3675
|
-
}
|
|
3676
|
-
|
|
3677
|
-
// 作用:代表扩展删除当前 Runtime Workspace 中的路径。
|
|
3678
|
-
// 调用:已获文件删除权限的 HostBridgeLoopback 在处理扩展请求时调用。
|
|
3679
|
-
// 原因:Host 契约用布尔值表示删除结果,所以 Workspace 抛错在这一边界被收窄为 `false`。
|
|
3680
|
-
async _hostDeleteFile(path: string): Promise<boolean> {
|
|
3681
|
-
const workspace = await this.runtimeWorkspace();
|
|
3682
|
-
try {
|
|
3683
|
-
await workspace.rm(path);
|
|
3684
|
-
return true;
|
|
3685
|
-
} catch {
|
|
3686
|
-
return false;
|
|
3687
|
-
}
|
|
3688
|
-
}
|
|
3689
|
-
|
|
3690
|
-
// 作用:代表扩展列出当前 Runtime Workspace 目录。
|
|
3691
|
-
// 调用:已获目录读权限的 HostBridgeLoopback 在处理扩展请求时调用。
|
|
3692
|
-
// 原因:只投影稳定的名称、类型、大小和路径,不把 Workspace 的内部目录对象泄漏给扩展。
|
|
3693
|
-
async _hostListFiles(
|
|
3694
|
-
dir: string,
|
|
3695
|
-
): Promise<
|
|
3696
|
-
Array<{ name: string; type: string; size: number; path: string }>
|
|
3697
|
-
> {
|
|
3698
|
-
const workspace = await this.runtimeWorkspace();
|
|
3699
|
-
return (await workspace.readDir(dir)).map((entry) => ({
|
|
3700
|
-
name: entry.name,
|
|
3701
|
-
type: entry.type,
|
|
3702
|
-
size: entry.size,
|
|
3703
|
-
path: entry.path,
|
|
3704
|
-
}));
|
|
3705
|
-
}
|
|
3706
|
-
|
|
3707
|
-
// 作用:读取一份按 label 保存的 Runtime Extension Context。
|
|
3708
|
-
// 调用:已获对应 label 读权限的 HostBridgeLoopback 在扩展运行期调用。
|
|
3709
|
-
// 原因:Context 与 Durable Object 共享 SQLite 持久性,唤醒后不需要从扩展重建。
|
|
3710
|
-
async _hostGetContext(label: string): Promise<string | null> {
|
|
3711
|
-
return this.db.extContext.get(label) ?? null;
|
|
3712
|
-
}
|
|
3713
|
-
|
|
3714
|
-
// 作用:按 label 写入一份 Runtime Extension Context。
|
|
3715
|
-
// 调用:已获对应 label 写权限的 HostBridgeLoopback 在扩展运行期调用。
|
|
3716
|
-
// 原因:由 Host 集中选择持久存储,扩展不必获得任意 SQL 访问权。
|
|
3717
|
-
async _hostSetContext(
|
|
3718
|
-
label: string,
|
|
3719
|
-
content: string,
|
|
3720
|
-
): Promise<void> {
|
|
3721
|
-
this.db.extContext.set(label, content);
|
|
3722
|
-
}
|
|
3723
|
-
|
|
3724
|
-
// 作用:为扩展返回精简的 Session 消息历史。
|
|
3725
|
-
// 调用:已获消息读权限的 HostBridgeLoopback 按需传入数量上限时调用。
|
|
3726
|
-
// 原因:使用 transcript 的 Host 投影,避免把 Pi 专用 part 和持久化细节暴露给扩展。
|
|
3727
|
-
async _hostGetMessages(
|
|
3728
|
-
limit?: number,
|
|
3729
|
-
): Promise<
|
|
3730
|
-
Array<{ id: string; role: string; content: string }>
|
|
3731
|
-
> {
|
|
3732
|
-
return this.transcript.hostMessages(limit);
|
|
3733
|
-
}
|
|
3734
|
-
|
|
3735
|
-
// 作用:把扩展生成的文本作为用户消息送入当前 Session。
|
|
3736
|
-
// 调用:已获消息写权限的 HostBridgeLoopback 在扩展需要追加或 steer 时调用。
|
|
3737
|
-
// 原因:有活跃 Turn 时交给它的 adapter steer,空闲时才直接写 transcript,从而保持单一执行流。
|
|
3738
|
-
async _hostSendMessage(content: string): Promise<void> {
|
|
3739
|
-
if (typeof content !== "string" || content.trim().length === 0) {
|
|
3740
|
-
throw new Error("Extension message content is required");
|
|
3741
|
-
}
|
|
3742
|
-
const message: PiCanonicalUserInput = {
|
|
3743
|
-
role: "user",
|
|
3744
|
-
content: [{ type: "text", text: content }],
|
|
3745
|
-
timestamp: Date.now(),
|
|
3746
|
-
};
|
|
3747
|
-
const active = this.submissions.currentActive();
|
|
3748
|
-
if (active) {
|
|
3749
|
-
const messageId = crypto.randomUUID();
|
|
3750
|
-
this.db.transaction(() => {
|
|
3751
|
-
this.db.steers.insert({
|
|
3752
|
-
steerId: crypto.randomUUID(),
|
|
3753
|
-
submissionId: active.submissionId,
|
|
3754
|
-
messageId,
|
|
3755
|
-
canonicalJson: json(message),
|
|
3756
|
-
uiMessageJson: null,
|
|
3757
|
-
createdAt: message.timestamp,
|
|
3758
|
-
});
|
|
3759
|
-
});
|
|
3760
|
-
active.agent.steer(message, messageId);
|
|
3761
|
-
await this.broadcastApprovals();
|
|
3762
|
-
return;
|
|
3763
|
-
}
|
|
3764
|
-
this.transcript.append(crypto.randomUUID(), message);
|
|
3765
|
-
}
|
|
3766
|
-
|
|
3767
|
-
// 作用:返回扩展可见的当前 Session 摘要。
|
|
3768
|
-
// 调用:HostBridgeLoopback 在扩展请求 Session 信息时调用。
|
|
3769
|
-
// 原因:先只提供真实需要的消息数,不暴露 Durable Object 身份或数据库结构。
|
|
3770
|
-
async _hostGetSessionInfo(): Promise<{ messageCount: number }> {
|
|
3771
|
-
return {
|
|
3772
|
-
messageCount: (await this._hostGetMessages()).length,
|
|
3773
|
-
};
|
|
3774
|
-
}
|
|
3775
|
-
|
|
3776
|
-
// #endregion
|
|
3777
|
-
|
|
3778
|
-
// #region Session 消息与 Submission 公开控制面
|
|
3779
|
-
|
|
3780
|
-
/**
|
|
3781
|
-
* 把一组已有消息导入当前 canonical transcript。
|
|
3782
|
-
*
|
|
3783
|
-
* @remarks
|
|
3784
|
-
* Inbox 只在真实 fork 或 import 会话历史时调用。
|
|
3785
|
-
*
|
|
3786
|
-
* 导入统一交给 transcript 并传入当前模型,
|
|
3787
|
-
* 避免外部调用方自行拼接 Pi 的持久化形式。
|
|
3788
|
-
*/
|
|
3789
|
-
async getCanonicalSnapshot(
|
|
3790
|
-
throughMessageId: string,
|
|
3791
|
-
): Promise<PiCanonicalTranscriptSnapshot> {
|
|
3792
|
-
return this.transcript.exportSnapshot(throughMessageId);
|
|
3793
|
-
}
|
|
3794
|
-
|
|
3795
|
-
async addCanonicalSnapshot(
|
|
3796
|
-
snapshot: PiCanonicalTranscriptSnapshot,
|
|
3797
|
-
): Promise<void> {
|
|
3798
|
-
this.transcript.importSnapshot(snapshot);
|
|
3799
|
-
}
|
|
3800
|
-
|
|
3801
|
-
// 作用:把一段纯文本包装成标准用户消息并提交。
|
|
3802
|
-
// 调用:公开 `submitPrompt` 为 RPC 调用创建 request ID 时调用。
|
|
3803
|
-
// 原因:RPC 只需提供文本,内部仍走与 WebSocket 相同的 canonical admission。
|
|
3804
|
-
private submitText(
|
|
3805
|
-
text: string,
|
|
3806
|
-
options?: { idempotencyKey?: string },
|
|
3807
|
-
): Promise<SubmissionHandle<StoredSubmission>> {
|
|
3808
|
-
const requestId = crypto.randomUUID();
|
|
3809
|
-
const userMessage: PiCanonicalUserInput = {
|
|
3810
|
-
role: "user",
|
|
3811
|
-
content: [{ type: "text", text }],
|
|
3812
|
-
timestamp: Date.now(),
|
|
3813
|
-
};
|
|
3814
|
-
return this.submitMessage(userMessage, {
|
|
3815
|
-
requestId,
|
|
3816
|
-
idempotencyKey: options?.idempotencyKey,
|
|
3817
|
-
});
|
|
3818
|
-
}
|
|
3819
|
-
|
|
3820
|
-
/**
|
|
3821
|
-
* 可靠提交一段用户文本,并立即返回可查询回执。
|
|
3822
|
-
*
|
|
3823
|
-
* @remarks
|
|
3824
|
-
* Inbox、子任务或其他 RPC 调用方需要启动新 Turn 时调用。
|
|
3825
|
-
*
|
|
3826
|
-
* 接口不等待模型完成,而是把同一 completion 留给 Durable Object 继续处理,
|
|
3827
|
-
* 调用方用 Submission ID 查询状态。
|
|
3828
|
-
*/
|
|
3829
|
-
async submitPrompt(
|
|
3830
|
-
text: string,
|
|
3831
|
-
options?: { idempotencyKey?: string },
|
|
3832
|
-
): Promise<SubmissionReceipt> {
|
|
3833
|
-
const submitted = await this.submitText(text, options);
|
|
3834
|
-
this.ctx.waitUntil(submitted.completion);
|
|
3835
|
-
return submitted.receipt;
|
|
3836
|
-
}
|
|
3837
|
-
|
|
3838
|
-
// 作用:找出当前停在「等客户端结算某个 Tool」上的 Submission。
|
|
3839
|
-
// 调用:dispatchMessage 判断要不要把 delivery 提升成 steer 时调用。
|
|
3840
|
-
// 原因:park 期间 DO 可能已经睡过一觉,内存里的 active turn 不可信,只能问数据库。
|
|
3841
|
-
private findInteractionParkedSubmission(): StoredSubmission | null {
|
|
3842
|
-
const running = this.db.submissions.findRunning() as
|
|
3843
|
-
| StoredSubmission
|
|
3844
|
-
| null;
|
|
3845
|
-
return running &&
|
|
3846
|
-
this.interactions.hasPendingForSubmission(running.submissionId)
|
|
3847
|
-
? running
|
|
3848
|
-
: null;
|
|
3849
|
-
}
|
|
3850
|
-
|
|
3851
|
-
async dispatchMessage(
|
|
3852
|
-
message: UIMessage,
|
|
3853
|
-
delivery: MessageDelivery,
|
|
3854
|
-
): Promise<MessageDispatchReceipt> {
|
|
3855
|
-
if (
|
|
3856
|
-
message.role !== "user" ||
|
|
3857
|
-
!message.id ||
|
|
3858
|
-
!Array.isArray(message.parts) ||
|
|
3859
|
-
(delivery !== "steer" && delivery !== "enqueue")
|
|
3860
|
-
) {
|
|
3861
|
-
return {
|
|
3862
|
-
kind: "rejected",
|
|
3863
|
-
code: "invalid_message",
|
|
3864
|
-
message: "A valid user message is required",
|
|
3865
|
-
};
|
|
3866
|
-
}
|
|
3867
|
-
|
|
3868
|
-
let userMessage: PiCanonicalUserInput;
|
|
3869
|
-
try {
|
|
3870
|
-
userMessage = await this.normalizeUIUserInput(
|
|
3871
|
-
message as UIMessage & { role: "user" },
|
|
3872
|
-
);
|
|
3873
|
-
} catch (error) {
|
|
3874
|
-
return {
|
|
3875
|
-
kind: "rejected",
|
|
3876
|
-
code: "invalid_message",
|
|
3877
|
-
message: errorText(error),
|
|
3878
|
-
};
|
|
3879
|
-
}
|
|
3880
|
-
// 停在 interaction park 上的 Turn 一律按 steer 处理,哪怕客户端发的是 enqueue:
|
|
3881
|
-
// enqueue 要等本 Turn 结束,而本 Turn 正在等一个永远不会来的答案 —— 死锁。
|
|
3882
|
-
const parked = this.findInteractionParkedSubmission();
|
|
3883
|
-
const effectiveDelivery = parked ? "steer" : delivery;
|
|
3884
|
-
if (effectiveDelivery === "steer") {
|
|
3885
|
-
const active = this.submissions.currentActive();
|
|
3886
|
-
const target = parked ??
|
|
3887
|
-
active ??
|
|
3888
|
-
this.db.submissions.findRunning() ??
|
|
3889
|
-
this.db.submissions.findNextPending();
|
|
3890
|
-
if (target) {
|
|
3891
|
-
if (
|
|
3892
|
-
this.db.approvals.listPendingForSubmission(
|
|
3893
|
-
target.submissionId,
|
|
3894
|
-
).length > 0
|
|
3895
|
-
) {
|
|
3896
|
-
return {
|
|
3897
|
-
kind: "rejected",
|
|
3898
|
-
code: "not_steerable",
|
|
3899
|
-
message: "The current Turn is waiting for approval",
|
|
3900
|
-
};
|
|
3901
|
-
}
|
|
3902
|
-
const steerId = crypto.randomUUID();
|
|
3903
|
-
const inserted = this.db.transaction(() => {
|
|
3904
|
-
if (this.db.steers.findByMessageId(message.id)) return false;
|
|
3905
|
-
this.db.steers.insert({
|
|
3906
|
-
steerId,
|
|
3907
|
-
submissionId: target.submissionId,
|
|
3908
|
-
messageId: message.id,
|
|
3909
|
-
canonicalJson: json(userMessage),
|
|
3910
|
-
uiMessageJson: json(message),
|
|
3911
|
-
createdAt: userMessage.timestamp,
|
|
3912
|
-
});
|
|
3913
|
-
this.telemetry.capture("turnSteered", {
|
|
3914
|
-
submissionId: target.submissionId,
|
|
3915
|
-
steerId,
|
|
3916
|
-
messageId: message.id,
|
|
3917
|
-
content: userMessage,
|
|
3918
|
-
occurredAt: userMessage.timestamp,
|
|
3919
|
-
});
|
|
3920
|
-
return true;
|
|
3921
|
-
});
|
|
3922
|
-
if (inserted) {
|
|
3923
|
-
if (active?.submissionId === target.submissionId) {
|
|
3924
|
-
active.agent.steer(userMessage, message.id);
|
|
3925
|
-
}
|
|
3926
|
-
await this.broadcastApprovals();
|
|
3927
|
-
}
|
|
3928
|
-
// 先落 steer 再取消:取消会唤醒 park 住的 Tool 让 Turn 继续跑,
|
|
3929
|
-
// 顺序反过来 Turn 可能在这条消息落盘前就跑完了。
|
|
3930
|
-
if (parked?.submissionId === target.submissionId) {
|
|
3931
|
-
await this.interactions.cancelPendingForSubmission(
|
|
3932
|
-
target.submissionId,
|
|
3933
|
-
"user_replied_freeform",
|
|
3934
|
-
);
|
|
3935
|
-
}
|
|
3936
|
-
return {
|
|
3937
|
-
kind: "accepted",
|
|
3938
|
-
submissionId: target.submissionId,
|
|
3939
|
-
messageId: message.id,
|
|
3940
|
-
};
|
|
3941
|
-
}
|
|
3942
|
-
}
|
|
3943
|
-
|
|
3944
|
-
try {
|
|
3945
|
-
const submitted = await this.submitMessage(userMessage, {
|
|
3946
|
-
requestId: message.id,
|
|
3947
|
-
idempotencyKey: message.id,
|
|
3948
|
-
userMessageId: message.id,
|
|
3949
|
-
userMessage: message as UIMessage & { role: "user" },
|
|
3950
|
-
});
|
|
3951
|
-
this.ctx.waitUntil(submitted.completion);
|
|
3952
|
-
const position = this.db.submissions.listPending()
|
|
3953
|
-
.findIndex((item) =>
|
|
3954
|
-
item.submissionId === submitted.receipt.submissionId
|
|
3955
|
-
) + 1;
|
|
3956
|
-
return position > 0
|
|
3957
|
-
? {
|
|
3958
|
-
kind: "queued",
|
|
3959
|
-
submission: submitted.receipt,
|
|
3960
|
-
position,
|
|
3961
|
-
}
|
|
3962
|
-
: {
|
|
3963
|
-
kind: "accepted",
|
|
3964
|
-
submissionId: submitted.receipt.submissionId,
|
|
3965
|
-
messageId: message.id,
|
|
3966
|
-
};
|
|
3967
|
-
} catch (error) {
|
|
3968
|
-
if (error instanceof SubmissionQueueFullError) {
|
|
3969
|
-
return {
|
|
3970
|
-
kind: "rejected",
|
|
3971
|
-
code: "queue_full",
|
|
3972
|
-
message: error.message,
|
|
3973
|
-
};
|
|
3974
|
-
}
|
|
3975
|
-
throw error;
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
|
|
3979
|
-
async steerQueuedSubmission(
|
|
3980
|
-
submissionId: string,
|
|
3981
|
-
): Promise<MessageDispatchReceipt> {
|
|
3982
|
-
const active = this.submissions.currentActive();
|
|
3983
|
-
const target = active ??
|
|
3984
|
-
this.db.submissions.findRunning() ??
|
|
3985
|
-
this.db.submissions.findNextPending();
|
|
3986
|
-
if (
|
|
3987
|
-
!target ||
|
|
3988
|
-
this.db.approvals.listPendingForSubmission(
|
|
3989
|
-
target.submissionId,
|
|
3990
|
-
).length > 0
|
|
3991
|
-
) {
|
|
3992
|
-
return {
|
|
3993
|
-
kind: "rejected",
|
|
3994
|
-
code: "not_steerable",
|
|
3995
|
-
message: "The current Turn is not accepting steering",
|
|
3996
|
-
};
|
|
3997
|
-
}
|
|
3998
|
-
|
|
3999
|
-
const queued = this.readSubmission(submissionId);
|
|
4000
|
-
if (
|
|
4001
|
-
!queued ||
|
|
4002
|
-
queued.submissionId === target.submissionId ||
|
|
4003
|
-
queued.status !== "pending" ||
|
|
4004
|
-
!queued.queuedInputJson ||
|
|
4005
|
-
!queued.userMessageId
|
|
4006
|
-
) {
|
|
4007
|
-
return {
|
|
4008
|
-
kind: "rejected",
|
|
4009
|
-
code: "invalid_message",
|
|
4010
|
-
message: "The queued message is no longer available",
|
|
4011
|
-
};
|
|
4012
|
-
}
|
|
4013
|
-
const message = JSON.parse(
|
|
4014
|
-
queued.queuedInputJson,
|
|
4015
|
-
) as PiCanonicalUserInput;
|
|
4016
|
-
if (message?.role !== "user") {
|
|
4017
|
-
return {
|
|
4018
|
-
kind: "rejected",
|
|
4019
|
-
code: "invalid_message",
|
|
4020
|
-
message: "The queued message is invalid",
|
|
4021
|
-
};
|
|
4022
|
-
}
|
|
4023
|
-
const uiMessage = queued.queuedUiMessageJson
|
|
4024
|
-
? JSON.parse(queued.queuedUiMessageJson) as UIMessage
|
|
4025
|
-
: undefined;
|
|
4026
|
-
if (
|
|
4027
|
-
uiMessage &&
|
|
4028
|
-
(uiMessage.role !== "user" || uiMessage.id !== queued.userMessageId)
|
|
4029
|
-
) {
|
|
4030
|
-
return {
|
|
4031
|
-
kind: "rejected",
|
|
4032
|
-
code: "invalid_message",
|
|
4033
|
-
message: "The queued message is invalid",
|
|
4034
|
-
};
|
|
4035
|
-
}
|
|
4036
|
-
|
|
4037
|
-
const steerId = crypto.randomUUID();
|
|
4038
|
-
const moved = this.db.transaction(() => {
|
|
4039
|
-
const latest = this.readSubmission(submissionId);
|
|
4040
|
-
const current = this.db.submissions.findRunning() ??
|
|
4041
|
-
this.db.submissions.findNextPending();
|
|
4042
|
-
if (
|
|
4043
|
-
!latest ||
|
|
4044
|
-
latest.status !== "pending" ||
|
|
4045
|
-
current?.submissionId !== target.submissionId ||
|
|
4046
|
-
this.db.steers.findByMessageId(queued.userMessageId!)
|
|
4047
|
-
) {
|
|
4048
|
-
return false;
|
|
4049
|
-
}
|
|
4050
|
-
this.db.steers.insert({
|
|
4051
|
-
steerId,
|
|
4052
|
-
submissionId: target.submissionId,
|
|
4053
|
-
messageId: queued.userMessageId!,
|
|
4054
|
-
canonicalJson: queued.queuedInputJson!,
|
|
4055
|
-
uiMessageJson: queued.queuedUiMessageJson ?? null,
|
|
4056
|
-
createdAt: queued.createdAt,
|
|
4057
|
-
});
|
|
4058
|
-
this.telemetry.capture("turnSteered", {
|
|
4059
|
-
submissionId: target.submissionId,
|
|
4060
|
-
steerId,
|
|
4061
|
-
messageId: queued.userMessageId!,
|
|
4062
|
-
content: message,
|
|
4063
|
-
occurredAt: queued.createdAt,
|
|
4064
|
-
});
|
|
4065
|
-
const reason = "Moved to active Turn";
|
|
4066
|
-
this.appendTerminalIntent(latest, "aborted", reason);
|
|
4067
|
-
this.db.submissions.updateAbortReason(submissionId, reason);
|
|
4068
|
-
this.insertSubmissionTerminalFact(
|
|
4069
|
-
this.updateSubmission(submissionId, "aborted", reason),
|
|
4070
|
-
);
|
|
4071
|
-
return true;
|
|
4072
|
-
});
|
|
4073
|
-
if (!moved) {
|
|
4074
|
-
return {
|
|
4075
|
-
kind: "rejected",
|
|
4076
|
-
code: "invalid_message",
|
|
4077
|
-
message: "The queued message is no longer available",
|
|
4078
|
-
};
|
|
4079
|
-
}
|
|
4080
|
-
|
|
4081
|
-
try {
|
|
4082
|
-
if (active?.submissionId === target.submissionId) {
|
|
4083
|
-
active.agent.steer(message, queued.userMessageId);
|
|
4084
|
-
}
|
|
4085
|
-
} finally {
|
|
4086
|
-
await this.drainRuntimeEvents();
|
|
4087
|
-
await this.broadcastApprovals();
|
|
4088
|
-
}
|
|
4089
|
-
return {
|
|
4090
|
-
kind: "steered",
|
|
4091
|
-
submissionId: target.submissionId,
|
|
4092
|
-
messageId: queued.userMessageId,
|
|
4093
|
-
...(uiMessage ? { message: uiMessage } : {}),
|
|
4094
|
-
};
|
|
4095
|
-
}
|
|
4096
|
-
|
|
4097
|
-
/**
|
|
4098
|
-
* 等待 Session 稳定后提交定时提示,并在准入后立即返回。
|
|
4099
|
-
*
|
|
4100
|
-
* @remarks
|
|
4101
|
-
* Agent 定时回调在不应与人工 Turn 并发时调用。
|
|
4102
|
-
*
|
|
4103
|
-
* 最多等待固定的稳定窗口;超时返回 skipped,
|
|
4104
|
-
* 避免定时任务无限占住调用链。
|
|
4105
|
-
*/
|
|
4106
|
-
async submitScheduledPrompt(
|
|
4107
|
-
prompt: string,
|
|
4108
|
-
options?: { idempotencyKey?: string },
|
|
4109
|
-
): Promise<SubmissionReceipt> {
|
|
4110
|
-
const requestId = crypto.randomUUID();
|
|
4111
|
-
const submitted = await this.submitMessageWhenStable(
|
|
4112
|
-
{
|
|
4113
|
-
role: "user",
|
|
4114
|
-
content: [{ type: "text", text: prompt }],
|
|
4115
|
-
timestamp: Date.now(),
|
|
4116
|
-
},
|
|
4117
|
-
{
|
|
4118
|
-
requestId,
|
|
4119
|
-
idempotencyKey: options?.idempotencyKey,
|
|
4120
|
-
},
|
|
4121
|
-
SCHEDULED_STABLE_TIMEOUT_MS,
|
|
4122
|
-
);
|
|
4123
|
-
if (!submitted) {
|
|
4124
|
-
const skipped = this.db.transaction(() => {
|
|
4125
|
-
const duplicate = options?.idempotencyKey
|
|
4126
|
-
? this.db.submissions.findByIdempotencyKey(options.idempotencyKey)
|
|
4127
|
-
: this.db.submissions.findByRequestId(requestId);
|
|
4128
|
-
return duplicate ?? this.insertSkippedSubmission({
|
|
4129
|
-
submissionId: requestId,
|
|
4130
|
-
requestId,
|
|
4131
|
-
idempotencyKey: options?.idempotencyKey ?? null,
|
|
4132
|
-
createdAt: Date.now(),
|
|
4133
|
-
code: "Runtime not stable within timeout",
|
|
4134
|
-
retryable: true,
|
|
4135
|
-
});
|
|
4136
|
-
}) as StoredSubmission;
|
|
4137
|
-
await this.drainRuntimeEvents();
|
|
4138
|
-
await this.broadcastApprovals();
|
|
4139
|
-
return skipped;
|
|
4140
|
-
}
|
|
4141
|
-
this.ctx.waitUntil(submitted.completion.then(() => undefined));
|
|
4142
|
-
return submitted.receipt;
|
|
4143
|
-
}
|
|
4144
|
-
|
|
4145
|
-
/**
|
|
4146
|
-
* 按 Submission ID 读取当前回执。
|
|
4147
|
-
*
|
|
4148
|
-
* @remarks
|
|
4149
|
-
* 提交后的 RPC 调用方用它轮询或核对终态。
|
|
4150
|
-
*
|
|
4151
|
-
* 直接读取 SubmissionLifecycle 的持久存储,不用内存执行状态猜测结果。
|
|
4152
|
-
*/
|
|
4153
|
-
async getSubmission(
|
|
4154
|
-
submissionId: string,
|
|
4155
|
-
): Promise<SubmissionReceipt | null> {
|
|
4156
|
-
return this.submissions.get(submissionId);
|
|
4157
|
-
}
|
|
4158
|
-
|
|
4159
|
-
/**
|
|
4160
|
-
* 取消一条指定 Submission。
|
|
4161
|
-
*
|
|
4162
|
-
* @remarks
|
|
4163
|
-
* Inbox 或调度清理路径在已知 Submission ID 时调用。
|
|
4164
|
-
*
|
|
4165
|
-
* 取消交给 SubmissionLifecycle 先持久化 abort intent 再停活跃 adapter,
|
|
4166
|
-
* 避免重启后丢失取消决定。
|
|
4167
|
-
*/
|
|
4168
|
-
async cancelSubmissionById(
|
|
4169
|
-
submissionId: string,
|
|
4170
|
-
reason?: string,
|
|
4171
|
-
): Promise<{ ok: boolean }> {
|
|
4172
|
-
const result = await this.submissions.cancel(
|
|
4173
|
-
submissionId,
|
|
4174
|
-
reason ?? "Cancelled",
|
|
4175
|
-
);
|
|
4176
|
-
await this.broadcastApprovals();
|
|
4177
|
-
return result;
|
|
4178
|
-
}
|
|
4179
|
-
|
|
4180
|
-
/**
|
|
4181
|
-
* 按 request ID 停止 Turn,未给 ID 时停止所有未完成提交。
|
|
4182
|
-
*
|
|
4183
|
-
* @remarks
|
|
4184
|
-
* Inbox 的停止控制面或 Agent 内部清理在传输请求结束前调用。
|
|
4185
|
-
*
|
|
4186
|
-
* 复用 SubmissionLifecycle 的批量选择与取消语义,
|
|
4187
|
-
* 避免再从活跃 Pi adapter 反向推断持久记录。
|
|
4188
|
-
*/
|
|
4189
|
-
async stopTurn(
|
|
4190
|
-
requestId?: string,
|
|
4191
|
-
reason?: string,
|
|
4192
|
-
): Promise<{ ok: boolean }> {
|
|
4193
|
-
const result = await this.submissions.stop(
|
|
4194
|
-
requestId,
|
|
4195
|
-
reason ?? "Stopped",
|
|
4196
|
-
);
|
|
4197
|
-
await this.broadcastApprovals();
|
|
4198
|
-
return result;
|
|
4199
|
-
}
|
|
4200
|
-
|
|
4201
|
-
/** Stops every running, queued, or steered user request in this Session. */
|
|
4202
|
-
async stopAllSubmissions(reason?: string): Promise<{ ok: boolean }> {
|
|
4203
|
-
const current = this.db.submissions.findRunning() ??
|
|
4204
|
-
this.db.submissions.findNextPending();
|
|
4205
|
-
if (current) {
|
|
4206
|
-
this.db.transaction(() => {
|
|
4207
|
-
for (const steer of this.db.steers.listForSubmission(
|
|
4208
|
-
current.submissionId,
|
|
4209
|
-
)) {
|
|
4210
|
-
const message = JSON.parse(
|
|
4211
|
-
steer.canonicalJson,
|
|
4212
|
-
) as PiCanonicalUserInput;
|
|
4213
|
-
const userMessage = steer.uiMessageJson
|
|
4214
|
-
? JSON.parse(steer.uiMessageJson) as UIMessage & { role: "user" }
|
|
4215
|
-
: undefined;
|
|
4216
|
-
this.transcript.append(steer.messageId, message, {
|
|
4217
|
-
submissionId: current.submissionId,
|
|
4218
|
-
...(userMessage ? { userMessage } : {}),
|
|
4219
|
-
});
|
|
4220
|
-
this.db.steers.deleteByMessageId(steer.messageId);
|
|
4221
|
-
}
|
|
4222
|
-
});
|
|
4223
|
-
}
|
|
4224
|
-
const result = await this.submissions.stopAll(reason ?? "Stopped");
|
|
4225
|
-
await this.broadcastApprovals();
|
|
4226
|
-
return result;
|
|
4227
|
-
}
|
|
4228
|
-
|
|
4229
|
-
/** 返回当前已装配 User Agent 的执行档位,不提供 Session 覆盖。 */
|
|
4230
|
-
executionLevel(): ExecutionLevel {
|
|
4231
|
-
return this.assembly().profile.executionLevel;
|
|
4232
|
-
}
|
|
4233
|
-
|
|
4234
|
-
/**
|
|
4235
|
-
* 读取一条仍待决定的 Tool 或临时 Agent 执行。
|
|
4236
|
-
*
|
|
4237
|
-
* @remarks
|
|
4238
|
-
* Host 在处理 `allow_level` 前读取可信的 Tool 所需档位,客户端不能提交目标档位。
|
|
4239
|
-
*/
|
|
4240
|
-
getPendingApproval(executionId: string): ApprovalReceipt | null {
|
|
4241
|
-
const temporary = this.temporaryAgentApprovals.get(executionId);
|
|
4242
|
-
if (temporary) return temporary.receipt;
|
|
4243
|
-
const approval = this.approvals.read(executionId);
|
|
4244
|
-
if (!approval || approval.status !== "pending") return null;
|
|
4245
|
-
return {
|
|
4246
|
-
executionId: approval.executionId,
|
|
4247
|
-
source: approval.source,
|
|
4248
|
-
action: approval.toolName,
|
|
4249
|
-
summary: approval.summary,
|
|
4250
|
-
executionLevel: approval.executionLevel,
|
|
4251
|
-
requiredExecutionLevel: approval.requiredExecutionLevel,
|
|
4252
|
-
inputJson: approval.inputJson,
|
|
4253
|
-
requestId: approval.requestId,
|
|
4254
|
-
};
|
|
4255
|
-
}
|
|
4256
|
-
|
|
4257
|
-
/**
|
|
4258
|
-
* 把客户端投递的响应作为某次 Tool 调用的结果,并续跑原 Turn。
|
|
4259
|
-
*
|
|
4260
|
-
* @remarks
|
|
4261
|
-
* 前端经 WS RPC 调用,只带 `toolCallId` —— 它不知道 submissionId,也不该知道。
|
|
4262
|
-
*
|
|
4263
|
-
* 这是「结果由客户端提供」的 Tool 的唯一入口。与审批不同,这里没有任何业务身份
|
|
4264
|
-
* (审批的 `allow_level` 要改 User Agent 授权档位,所以必须经 Host),因此留在 Runtime。
|
|
4265
|
-
*
|
|
4266
|
-
* 查无、已结算、Tool 未声明 `interaction`、响应体不过校验,一律返回 `{ ok: false }` 而不抛 ——
|
|
4267
|
-
* 口径对齐 `cancelSubmissionById`:客户端重复点击不该看到异常。
|
|
4268
|
-
*/
|
|
4269
|
-
async respondToolInteraction(
|
|
4270
|
-
toolCallId: string,
|
|
4271
|
-
response: unknown,
|
|
4272
|
-
): Promise<{ ok: boolean }> {
|
|
4273
|
-
const pending = this.interactions.findPending(toolCallId);
|
|
4274
|
-
if (!pending) return { ok: false };
|
|
4275
|
-
const submission = this.readSubmission(pending.submissionId);
|
|
4276
|
-
if (!submission) return { ok: false };
|
|
4277
|
-
await this.ensureRuntimeReady();
|
|
4278
|
-
|
|
4279
|
-
const adapter = this.createSubmissionExecutionAdapter(submission);
|
|
4280
|
-
const spec = adapter.interactionSpec(pending.toolName);
|
|
4281
|
-
if (!spec) return { ok: false };
|
|
4282
|
-
|
|
4283
|
-
const input = safeParseJson(pending.inputJson);
|
|
4284
|
-
if (!spec.validateResponse(input, response)) return { ok: false };
|
|
4285
|
-
const prepared = await this.prepareToolInteractionResponse({
|
|
4286
|
-
toolCallId,
|
|
4287
|
-
toolName: pending.toolName,
|
|
4288
|
-
toolInput: input,
|
|
4289
|
-
response,
|
|
4290
|
-
});
|
|
4291
|
-
if (prepared === null || !spec.validateResponse(input, prepared)) {
|
|
4292
|
-
return { ok: false };
|
|
4293
|
-
}
|
|
4294
|
-
const settled = spec.settle
|
|
4295
|
-
? spec.settle(input, prepared)
|
|
4296
|
-
: defaultInteractionResult(prepared);
|
|
4297
|
-
return this.interactions.respond(pending.interactionId, prepared, settled);
|
|
4298
|
-
}
|
|
4299
|
-
|
|
4300
|
-
/**
|
|
4301
|
-
* 对一条待处理执行应用一次允许或拒绝决定。
|
|
4302
|
-
*
|
|
4303
|
-
* @remarks
|
|
4304
|
-
* `allow_level` 必须由 Host 先更新 User Agent,再以 `allow_once` 续跑当前固定 Turn。
|
|
4305
|
-
*/
|
|
4306
|
-
async decideApproval(
|
|
4307
|
-
executionId: string,
|
|
4308
|
-
decision: ApprovalDecision,
|
|
4309
|
-
): Promise<{ ok: boolean }> {
|
|
4310
|
-
if (decision.decision === "allow_level") {
|
|
4311
|
-
throw new Error(
|
|
4312
|
-
"allow_level must be applied by the User Agent Host before resuming the Tool",
|
|
4313
|
-
);
|
|
4314
|
-
}
|
|
4315
|
-
const temporary = this.temporaryAgentApprovals.get(executionId);
|
|
4316
|
-
if (temporary) {
|
|
4317
|
-
this.temporaryAgentApprovals.delete(executionId);
|
|
4318
|
-
temporary.resolve(
|
|
4319
|
-
decision.decision === "allow_once"
|
|
4320
|
-
? { approved: true }
|
|
4321
|
-
: { approved: false, reason: decision.reason },
|
|
4322
|
-
);
|
|
4323
|
-
if (temporary.submissionId) {
|
|
4324
|
-
this.telemetry.capture("approvalDecided", {
|
|
4325
|
-
submissionId: temporary.submissionId!,
|
|
4326
|
-
toolCallId: temporary.toolCallId,
|
|
4327
|
-
executionId,
|
|
4328
|
-
decision: decision.decision === "allow_once"
|
|
4329
|
-
? "approved"
|
|
4330
|
-
: "rejected",
|
|
4331
|
-
...(decision.decision === "deny" && decision.reason
|
|
4332
|
-
? { reason: decision.reason }
|
|
4333
|
-
: {}),
|
|
4334
|
-
});
|
|
4335
|
-
}
|
|
4336
|
-
await this.broadcastApprovals();
|
|
4337
|
-
return { ok: true };
|
|
4338
|
-
}
|
|
4339
|
-
return this.approvals.decide(executionId, decision);
|
|
4340
|
-
}
|
|
4341
|
-
|
|
4342
|
-
// 作用:在用户做出持久 Tool 审批决定后续跑对应 Submission。
|
|
4343
|
-
// 调用:Cloudflare Agents SDK 按 `schedule` 保存的回调名唤醒 Agent 时调用。
|
|
4344
|
-
// 原因:审批等待可跨 Durable Object 休眠,因此续跑必须重读持久决定、去重 Tool 结果并重进恢复入口。
|
|
4345
|
-
async _piApprovalContinuation(
|
|
4346
|
-
data?: ApprovalContinuationData,
|
|
4347
|
-
): Promise<void> {
|
|
4348
|
-
if (!data?.submissionId || !data.approvalExecutionId) return;
|
|
4349
|
-
const submission = this.readSubmission(data.submissionId);
|
|
4350
|
-
if (!submission || isTerminalSubmissionStatus(submission.status)) {
|
|
4351
|
-
return;
|
|
4352
|
-
}
|
|
4353
|
-
const approval = this.approvals.read(data.approvalExecutionId);
|
|
4354
|
-
if (!approval) {
|
|
4355
|
-
await this.submissions.finish(
|
|
4356
|
-
submission,
|
|
4357
|
-
"failed",
|
|
4358
|
-
`Approval continuation is missing: ${data.approvalExecutionId}`,
|
|
4359
|
-
);
|
|
4360
|
-
return;
|
|
4361
|
-
}
|
|
4362
|
-
await this.ensureRuntimeReady();
|
|
4363
|
-
try {
|
|
4364
|
-
await this.assertPinnedSubmission(submission);
|
|
4365
|
-
let decision = await this.materializeRecoveredToolResults(
|
|
4366
|
-
submission,
|
|
4367
|
-
);
|
|
4368
|
-
if (approval.status === "approved") {
|
|
4369
|
-
const settled = this.readToolSettlement(
|
|
4370
|
-
submission.submissionId,
|
|
4371
|
-
approval.toolCallId,
|
|
4372
|
-
);
|
|
4373
|
-
if (!settled) {
|
|
4374
|
-
const adapter = this.createSubmissionExecutionAdapter(submission);
|
|
4375
|
-
let available = true;
|
|
4376
|
-
try {
|
|
4377
|
-
available = await adapter.retryTool({
|
|
4378
|
-
toolName: approval.toolName,
|
|
4379
|
-
toolCallId: approval.toolCallId,
|
|
4380
|
-
input: JSON.parse(approval.inputJson),
|
|
4381
|
-
});
|
|
4382
|
-
} catch {
|
|
4383
|
-
// The governed Tool persisted its bounded error ToolResult.
|
|
4384
|
-
}
|
|
4385
|
-
if (!available) {
|
|
4386
|
-
throw new Error(
|
|
4387
|
-
`Approved Tool is denied: ${approval.toolName}`,
|
|
4388
|
-
);
|
|
4389
|
-
}
|
|
4390
|
-
if (!this.readToolSettlement(
|
|
4391
|
-
submission.submissionId,
|
|
4392
|
-
approval.toolCallId,
|
|
4393
|
-
)) {
|
|
4394
|
-
throw new Error(
|
|
4395
|
-
`Approved Tool produced no durable settlement: ${approval.toolName}`,
|
|
4396
|
-
);
|
|
4397
|
-
}
|
|
4398
|
-
}
|
|
4399
|
-
decision = await this.materializeRecoveredToolResults(
|
|
4400
|
-
submission,
|
|
4401
|
-
);
|
|
4402
|
-
}
|
|
4403
|
-
if (approval.status === "rejected") {
|
|
4404
|
-
decision = await this.materializeRecoveredToolResults(
|
|
4405
|
-
submission,
|
|
4406
|
-
);
|
|
4407
|
-
}
|
|
4408
|
-
const result = decision.recoveredToolSettlements.find(
|
|
4409
|
-
(item) => item.message.toolCallId === approval.toolCallId,
|
|
4410
|
-
);
|
|
4411
|
-
if (!result) {
|
|
4412
|
-
throw new Error(
|
|
4413
|
-
`Approval continuation has no ToolResult: ${approval.toolCallId}`,
|
|
4414
|
-
);
|
|
4415
|
-
}
|
|
4416
|
-
await this.submissions.recover(submission.submissionId);
|
|
4417
|
-
} catch (error) {
|
|
4418
|
-
await this.submissions.finish(
|
|
4419
|
-
submission,
|
|
4420
|
-
"failed",
|
|
4421
|
-
errorText(error),
|
|
4422
|
-
);
|
|
4423
|
-
}
|
|
4424
|
-
}
|
|
4425
|
-
|
|
4426
|
-
// 作用:为一个让步执行片排一次兜底唤醒。
|
|
4427
|
-
// 调用:`executeNonTerminalSubmission` 排出计划续跑之后立即调用。
|
|
4428
|
-
// 原因:计划续跑只有一次机会 —— schedule 抛错、alarm 回调抛错、实例中途被换掉,
|
|
4429
|
-
// 这一轮就再也没人唤醒了。看门狗是唯一能兜住未知丢失路径的东西;
|
|
4430
|
-
// 它按续跑标识判断有没有推进,所以正常情况下醒来即空转返回。
|
|
4431
|
-
private async scheduleContinuationWatchdog(
|
|
4432
|
-
submissionId: string,
|
|
4433
|
-
requestId: string,
|
|
4434
|
-
continuationId: string,
|
|
4435
|
-
): Promise<void> {
|
|
4436
|
-
try {
|
|
4437
|
-
await this.schedule(
|
|
4438
|
-
CONTINUATION_WATCHDOG_SECONDS,
|
|
4439
|
-
"_piContinuationWatchdog",
|
|
4440
|
-
{ submissionId, requestId, continuationId },
|
|
4441
|
-
{ idempotent: true },
|
|
4442
|
-
);
|
|
4443
|
-
} catch (error) {
|
|
4444
|
-
// 看门狗排不上不该反过来杀掉这一轮:计划续跑本身已经排好了。
|
|
4445
|
-
console.warn(
|
|
4446
|
-
"[pi-continuation-watchdog:degraded]",
|
|
4447
|
-
json({ submissionId, error: errorText(error) }),
|
|
4448
|
-
);
|
|
4449
|
-
}
|
|
4450
|
-
}
|
|
4451
|
-
|
|
4452
|
-
// 判断一条让步提交是否仍停在 `continuationId` 这一片上。
|
|
4453
|
-
// 计划续跑和看门狗都用它决定该不该接手,避免和更新的执行片重复派发。
|
|
4454
|
-
//
|
|
4455
|
-
// `allowUnrecorded` 给看门狗用:让步的落库万一没生效(记录已不在
|
|
4456
|
-
// pending/running),`continuationId` 会是 null,此时严格比对会把续跑和看门狗
|
|
4457
|
-
// 一起丢掉 —— 那正是这次要根除的静默挂死。计划续跑不放宽,它的职责就是去重。
|
|
4458
|
-
private stalledOnContinuation(
|
|
4459
|
-
data: PlannedContinuationData,
|
|
4460
|
-
{ allowUnrecorded = false }: { allowUnrecorded?: boolean } = {},
|
|
4461
|
-
): StoredSubmission | null {
|
|
4462
|
-
const submission = this.readSubmission(data.submissionId);
|
|
4463
|
-
if (
|
|
4464
|
-
!submission ||
|
|
4465
|
-
submission.requestId !== data.requestId ||
|
|
4466
|
-
isTerminalSubmissionStatus(submission.status)
|
|
4467
|
-
) {
|
|
4468
|
-
return null;
|
|
4469
|
-
}
|
|
4470
|
-
const onThisSlice = submission.continuationId === data.continuationId ||
|
|
4471
|
-
(allowUnrecorded && submission.continuationId === null);
|
|
4472
|
-
return onThisSlice ? submission : null;
|
|
4473
|
-
}
|
|
4474
|
-
|
|
4475
|
-
async _piPlannedContinuation(
|
|
4476
|
-
data?: PlannedContinuationData,
|
|
4477
|
-
): Promise<void> {
|
|
4478
|
-
if (!data?.submissionId || !data.requestId || !data.continuationId) return;
|
|
4479
|
-
// 校验续跑标识:两条 alarm 同时到期时,晚到的那条对应的已经是上一片,
|
|
4480
|
-
// 放它进去会白跑一整个执行片。
|
|
4481
|
-
const submission = this.stalledOnContinuation(data);
|
|
4482
|
-
if (!submission) return;
|
|
4483
|
-
await this.submissions.continueAfterCurrent(submission.submissionId);
|
|
4484
|
-
}
|
|
4485
|
-
|
|
4486
|
-
/**
|
|
4487
|
-
* 在计划续跑丢失时接手一条让步提交。
|
|
4488
|
-
*
|
|
4489
|
-
* @remarks
|
|
4490
|
-
* Agents SDK 的调度器在 `scheduleContinuationWatchdog` 排的延时任务到期时按方法名调用。
|
|
4491
|
-
*
|
|
4492
|
-
* 正常情况下这一片早就推进了(`continuationId` 已换或已终态),此时直接返回。
|
|
4493
|
-
*
|
|
4494
|
-
* 走 `recover` 而不是 `continueAfterCurrent`,有两个都不能省的理由:
|
|
4495
|
-
* 一是续跑会丢通常就是实例中途没了,那属于崩溃,必须重放里程碑、补齐半路的
|
|
4496
|
-
* Tool,续跑模式恰恰跳过这些;二是这一轮可能正停在审批或客户端交互上等人
|
|
4497
|
-
* (被驱逐后内存里没有执行器,看起来就像「没人在跑」),只有恢复准备认得出
|
|
4498
|
-
* 这是合法停靠并原样放过 —— 续跑模式会直接在它上面重起一轮。
|
|
4499
|
-
*
|
|
4500
|
-
* `recover` 复用 `executions` 的实例内去重,因此和一片刚进序幕、还没登记执行器的
|
|
4501
|
-
* 竞态也一并合流,不会多跑一片。
|
|
4502
|
-
*/
|
|
4503
|
-
async _piContinuationWatchdog(
|
|
4504
|
-
data?: PlannedContinuationData,
|
|
4505
|
-
): Promise<void> {
|
|
4506
|
-
if (!data?.submissionId || !data.requestId || !data.continuationId) return;
|
|
4507
|
-
const submission = this.stalledOnContinuation(data, {
|
|
4508
|
-
allowUnrecorded: true,
|
|
4509
|
-
});
|
|
4510
|
-
if (!submission) return;
|
|
4511
|
-
try {
|
|
4512
|
-
await this.ensureRuntimeReady();
|
|
4513
|
-
await this.submissions.recover(submission.submissionId);
|
|
4514
|
-
} catch (error) {
|
|
4515
|
-
const latest = this.readSubmission(data.submissionId);
|
|
4516
|
-
if (latest && !isTerminalSubmissionStatus(latest.status)) {
|
|
4517
|
-
await this.submissions.finish(
|
|
4518
|
-
latest,
|
|
4519
|
-
"failed",
|
|
4520
|
-
`SpringBrand could not continue this turn: ${errorText(error)}`,
|
|
4521
|
-
);
|
|
4522
|
-
}
|
|
4523
|
-
}
|
|
4524
|
-
}
|
|
4525
|
-
|
|
4526
|
-
// 作用:Agent Tool 子运行开始后重算一次活动投影。
|
|
4527
|
-
// 调用:Agents SDK 在登记子运行后调用。
|
|
4528
|
-
// 原因:分离的子运行不进 Submission 表,不在这里重算,Host 的列表会显示成已经空闲。
|
|
4529
|
-
override async onAgentToolStart(run: AgentToolRunInfo): Promise<void> {
|
|
4530
|
-
await super.onAgentToolStart(run);
|
|
4531
|
-
const metadata = runtimeSubagentRunMetadata(run.inputPreview);
|
|
4532
|
-
if (metadata) {
|
|
4533
|
-
await this.subagentLifecycle(metadata).register(run.runId);
|
|
4534
|
-
this.telemetry.capture("subagentStarted", {
|
|
4535
|
-
submissionId: metadata.submissionId,
|
|
4536
|
-
...(run.parentToolCallId
|
|
4537
|
-
? { toolCallId: run.parentToolCallId }
|
|
4538
|
-
: {}),
|
|
4539
|
-
subagentId: run.runId,
|
|
4540
|
-
agentType: run.agentType,
|
|
4541
|
-
occurredAt: run.startedAt,
|
|
4542
|
-
});
|
|
4543
|
-
}
|
|
4544
|
-
await this.broadcastApprovals();
|
|
4545
|
-
}
|
|
4546
|
-
|
|
4547
|
-
// 作用:Agent Tool 子运行结束后重算一次活动投影。
|
|
4548
|
-
// 调用:Agents SDK 在子运行进入终态或被中断后调用。
|
|
4549
|
-
// 原因:中断且子进程仍在跑时活动不能落回空闲,判定只在 `hasRunningAgentTools` 一处。
|
|
4550
|
-
override async onAgentToolFinish(
|
|
4551
|
-
run: AgentToolRunInfo,
|
|
4552
|
-
result: AgentToolLifecycleResult,
|
|
4553
|
-
): Promise<void> {
|
|
4554
|
-
await super.onAgentToolFinish(run, result);
|
|
4555
|
-
const metadata = runtimeSubagentRunMetadata(run.inputPreview);
|
|
4556
|
-
if (metadata) {
|
|
4557
|
-
const lifecycle = this.subagentLifecycle(metadata);
|
|
4558
|
-
await lifecycle.register(run.runId);
|
|
4559
|
-
if (!(result.status === "interrupted" && result.childStillRunning)) {
|
|
4560
|
-
const completion = decodeSubagentCompletion(result.summary);
|
|
4561
|
-
const hasUsage = completion && !isZeroSubagentUsage(completion.usage);
|
|
4562
|
-
const status = subagentTerminalStatus(result.status);
|
|
4563
|
-
if (completion && hasUsage) {
|
|
4564
|
-
await lifecycle.usage({
|
|
4565
|
-
eventId: `${run.runId}:usage`,
|
|
4566
|
-
submissionId: metadata.submissionId,
|
|
4567
|
-
runId: run.runId,
|
|
4568
|
-
subagentRunId: run.runId,
|
|
4569
|
-
parentRunId: metadata.parentRunId,
|
|
4570
|
-
kind: "subagent",
|
|
4571
|
-
status,
|
|
4572
|
-
usage: completion.usage,
|
|
4573
|
-
});
|
|
4574
|
-
}
|
|
4575
|
-
await lifecycle.terminal({
|
|
4576
|
-
subagentRunId: run.runId,
|
|
4577
|
-
status,
|
|
4578
|
-
zeroUsage: !hasUsage,
|
|
4579
|
-
});
|
|
4580
|
-
this.telemetry.capture("subagentFinished", {
|
|
4581
|
-
submissionId: metadata.submissionId,
|
|
4582
|
-
...(run.parentToolCallId
|
|
4583
|
-
? { toolCallId: run.parentToolCallId }
|
|
4584
|
-
: {}),
|
|
4585
|
-
subagentId: run.runId,
|
|
4586
|
-
outcome: status === "completed"
|
|
4587
|
-
? "completed"
|
|
4588
|
-
: status === "aborted" || status === "skipped"
|
|
4589
|
-
? "cancelled"
|
|
4590
|
-
: "failed",
|
|
4591
|
-
durationMs: Math.max(
|
|
4592
|
-
0,
|
|
4593
|
-
(run.completedAt ?? Date.now()) - run.startedAt,
|
|
4594
|
-
),
|
|
4595
|
-
...(completion ? { output: completion.output } : {}),
|
|
4596
|
-
...(result.error ? { error: result.error } : {}),
|
|
4597
|
-
occurredAt: run.completedAt ?? Date.now(),
|
|
4598
|
-
});
|
|
4599
|
-
}
|
|
4600
|
-
}
|
|
4601
|
-
await this.broadcastApprovals();
|
|
4602
|
-
}
|
|
4603
|
-
|
|
4604
|
-
async _cfDetachedNotifyFinish(
|
|
4605
|
-
run: AgentToolRunInfo,
|
|
4606
|
-
result: AgentToolLifecycleResult,
|
|
4607
|
-
): Promise<void> {
|
|
4608
|
-
const completion = decodeSubagentCompletion(result.summary);
|
|
4609
|
-
const outcome = result.status === "completed"
|
|
4610
|
-
? completion
|
|
4611
|
-
? JSON.stringify(completion.output)
|
|
4612
|
-
: result.summary ?? "Completed without a result."
|
|
4613
|
-
: result.error ?? `Background run ${result.status}.`;
|
|
4614
|
-
await this.submitPrompt(
|
|
4615
|
-
[
|
|
4616
|
-
"A background sub-agent run has finished.",
|
|
4617
|
-
`runId: ${run.runId}`,
|
|
4618
|
-
`status: ${result.status}`,
|
|
4619
|
-
`result: ${outcome}`,
|
|
4620
|
-
].join("\n"),
|
|
4621
|
-
{ idempotencyKey: `detached-agent-tool:${run.runId}:${result.status}` },
|
|
4622
|
-
);
|
|
4623
|
-
}
|
|
4624
|
-
|
|
4625
|
-
// 作用:把审批、队列和 Session 活动投影到 Agent 可广播状态。
|
|
4626
|
-
// 调用:启动、准入、审批变化、interaction 变化、子运行变化和 Turn 完成时调用。
|
|
4627
|
-
// 原因:这些都是可重建的 UI 投影,相同内容不重复 `setState`,投影失败也不能阻断执行。
|
|
4628
|
-
protected async broadcastApprovals(): Promise<void> {
|
|
4629
|
-
try {
|
|
4630
|
-
const approvals = [
|
|
4631
|
-
...this.approvals.list(),
|
|
4632
|
-
...[...this.temporaryAgentApprovals.values()].map(
|
|
4633
|
-
({ receipt }) => receipt,
|
|
4634
|
-
),
|
|
4635
|
-
];
|
|
4636
|
-
const running = this.db.submissions.findRunning();
|
|
4637
|
-
const pending = this.db.submissions.listPending();
|
|
4638
|
-
const current = running ?? pending[0];
|
|
4639
|
-
const queued = (running ? pending : pending.slice(1)).map(
|
|
4640
|
-
(submission, index) => {
|
|
4641
|
-
let preview = "Queued message";
|
|
4642
|
-
if (submission.queuedInputJson) {
|
|
4643
|
-
try {
|
|
4644
|
-
const message = JSON.parse(
|
|
4645
|
-
submission.queuedInputJson,
|
|
4646
|
-
) as PiCanonicalUserInput;
|
|
4647
|
-
preview = (typeof message.content === "string"
|
|
4648
|
-
? message.content
|
|
4649
|
-
: message.content
|
|
4650
|
-
.flatMap((part) =>
|
|
4651
|
-
part.type === "text" ? [part.text] : []
|
|
4652
|
-
)
|
|
4653
|
-
.join(" ")
|
|
4654
|
-
).trim().slice(0, 160) || preview;
|
|
4655
|
-
} catch {
|
|
4656
|
-
// Corrupt payload is surfaced when the queue pump activates it.
|
|
4657
|
-
}
|
|
4658
|
-
}
|
|
4659
|
-
return {
|
|
4660
|
-
submissionId: submission.submissionId,
|
|
4661
|
-
messageId:
|
|
4662
|
-
submission.userMessageId ?? submission.submissionId,
|
|
4663
|
-
preview,
|
|
4664
|
-
position: index + 1,
|
|
4665
|
-
createdAt: submission.createdAt,
|
|
4666
|
-
};
|
|
4667
|
-
},
|
|
4668
|
-
);
|
|
4669
|
-
const turn: RuntimeTurnState = {
|
|
4670
|
-
...(current
|
|
4671
|
-
? {
|
|
4672
|
-
activeSubmissionId: current.submissionId,
|
|
4673
|
-
activeMessageId: current.assistantMessageId,
|
|
4674
|
-
...(current.requestId
|
|
4675
|
-
? { activeRequestId: current.requestId }
|
|
4676
|
-
: {}),
|
|
4677
|
-
...(current.recoveryErrorCount === undefined
|
|
4678
|
-
? {}
|
|
4679
|
-
: {
|
|
4680
|
-
recoveryAttempt:
|
|
4681
|
-
current.recoveryReason === "context_overflow"
|
|
4682
|
-
? 1
|
|
4683
|
-
: current.recoveryErrorCount,
|
|
4684
|
-
// 上限按当前恢复原因取,否则 stall 会显示 "2/5" 却在第 3 次就终止。
|
|
4685
|
-
recoveryMax:
|
|
4686
|
-
current.recoveryReason === "context_overflow"
|
|
4687
|
-
? 1
|
|
4688
|
-
: current.recoveryReason ===
|
|
4689
|
-
"no_meaningful_model_progress"
|
|
4690
|
-
? CHAT_STALL_MAX_ATTEMPTS
|
|
4691
|
-
: current.recoveryReason === "server_tool_timeout"
|
|
4692
|
-
? SERVER_TOOL_TIMEOUT_MAX_ATTEMPTS
|
|
4693
|
-
: CHAT_RECOVERY_MAX_ATTEMPTS,
|
|
4694
|
-
...(current.recoveryReason &&
|
|
4695
|
-
current.recoveryReason !== "context_overflow"
|
|
4696
|
-
? { recoveryReason: current.recoveryReason }
|
|
4697
|
-
: {}),
|
|
4698
|
-
}),
|
|
4699
|
-
}
|
|
4700
|
-
: {}),
|
|
4701
|
-
steerable: Boolean(
|
|
4702
|
-
current &&
|
|
4703
|
-
this.db.approvals.listPendingForSubmission(
|
|
4704
|
-
current.submissionId,
|
|
4705
|
-
).length === 0
|
|
4706
|
-
),
|
|
4707
|
-
hasPendingSteer: Boolean(
|
|
4708
|
-
current &&
|
|
4709
|
-
this.db.steers.listForSubmission(current.submissionId).length > 0
|
|
4710
|
-
),
|
|
4711
|
-
queued,
|
|
4712
|
-
};
|
|
4713
|
-
// activity 与这里其他字段一样,是当前 DB 的纯函数:不接受调用方的提示,
|
|
4714
|
-
// 也不粘住上一次的值。needs-input 只有一个意思 —— 此刻真挂着一件等人回应
|
|
4715
|
-
// 的事(审批,或结果由客户端结算的 Tool)。可重算因而能自愈:任何一次广播
|
|
4716
|
-
// 都会把陈旧状态冲掉,不需要谁记得来清。
|
|
4717
|
-
const activity: RuntimeActivity = approvals.length > 0 ||
|
|
4718
|
-
(current &&
|
|
4719
|
-
this.interactions.hasPendingForSubmission(current.submissionId))
|
|
4720
|
-
? "needs-input"
|
|
4721
|
-
: current || queued.length > 0
|
|
4722
|
-
? "working"
|
|
4723
|
-
: "idle";
|
|
4724
|
-
const backgroundWork = this.db.agentTools.hasRunning();
|
|
4725
|
-
const currentActivity = this.state.activity;
|
|
4726
|
-
const nextActivity = currentActivity?.activity === activity &&
|
|
4727
|
-
currentActivity.backgroundWork === backgroundWork
|
|
4728
|
-
? currentActivity
|
|
4729
|
-
: {
|
|
4730
|
-
activity,
|
|
4731
|
-
backgroundWork,
|
|
4732
|
-
revision: (currentActivity?.revision ?? 0) + 1,
|
|
4733
|
-
};
|
|
4734
|
-
if (
|
|
4735
|
-
this.state?.approvals === undefined ||
|
|
4736
|
-
json(this.state.approvals) !== json(approvals) ||
|
|
4737
|
-
json(this.state.turn) !== json(turn) ||
|
|
4738
|
-
nextActivity !== currentActivity
|
|
4739
|
-
) {
|
|
4740
|
-
this.setState({
|
|
4741
|
-
...this.state,
|
|
4742
|
-
activity: nextActivity,
|
|
4743
|
-
approvals,
|
|
4744
|
-
turn,
|
|
4745
|
-
});
|
|
4746
|
-
}
|
|
4747
|
-
const projection = this.turnEventsPort()?.onActivityChanged?.(nextActivity);
|
|
4748
|
-
if (projection) {
|
|
4749
|
-
this.ctx.waitUntil(
|
|
4750
|
-
projection.catch(() => undefined),
|
|
4751
|
-
);
|
|
4752
|
-
}
|
|
4753
|
-
} catch {
|
|
4754
|
-
// Approval projection is best effort and must not block execution.
|
|
4755
|
-
}
|
|
4756
|
-
}
|
|
4757
|
-
|
|
4758
|
-
// #endregion
|
|
4759
|
-
|
|
4760
|
-
}
|