@springbrand/agent-runtime 0.1.3-alpha.0 → 0.1.3-alpha.2

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/src/runtime.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { Connection } from "agents";
2
2
  import {
3
+ ChatStreamStalledError,
3
4
  MessageType,
4
5
  clearChatTerminal,
5
6
  parseProtocolMessage,
6
7
  recordChatTerminal,
7
8
  sendIfOpen,
9
+ type ChatRecoveryConfig,
8
10
  } from "agents/chat";
9
11
  import { RecoverableChatAgent } from "./kernel/recoverable-chat-agent";
10
12
  import {
@@ -46,6 +48,9 @@ import { connectConfiguredMcpServers } from "./lib/mcp";
46
48
  import { installConsoleSink } from "./lib/telemetry-dev";
47
49
  import {
48
50
  PiRuntimeAdapter,
51
+ MODEL_STREAM_STALL_TIMEOUT_MS,
52
+ readModelStreamStallDetails,
53
+ RetryableModelError,
49
54
  type PiCanonicalUserInput,
50
55
  type PiCanonicalTranscriptSnapshot,
51
56
  type PiChatRecoveryData,
@@ -91,6 +96,9 @@ import {
91
96
 
92
97
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
93
98
  const TURN_EVENT_RETRY_SECONDS = 10;
99
+ const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
100
+ const CHAT_RECOVERY_TERMINAL_MESSAGE =
101
+ "多次恢复仍未成功,本次生成已停止,当前进度已保留。请发送新消息继续。";
94
102
 
95
103
  type RuntimeEventOutboxPayload =
96
104
  | { readonly type: "model-usage"; readonly event: RuntimeModelUsageEvent }
@@ -110,6 +118,8 @@ interface StoredSubmission extends SubmissionReceipt {
110
118
  queuedUiMessageJson?: string | null;
111
119
  userMessageId?: string | null;
112
120
  regenerateMessageId?: string | null;
121
+ recoveryErrorCount: number;
122
+ recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
113
123
  }
114
124
 
115
125
  interface SubmitMessageOptions {
@@ -210,6 +220,11 @@ interface PendingTemporaryAgentApproval {
210
220
  export abstract class AgentRuntimeKernel<
211
221
  Env extends Cloudflare.Env = Cloudflare.Env,
212
222
  > extends RecoverableChatAgent<Env, RuntimeState, PiChatRecoveryData> {
223
+ override chatRecovery: ChatRecoveryConfig = {
224
+ maxAttempts: CHAT_RECOVERY_MAX_ATTEMPTS,
225
+ terminalMessage: CHAT_RECOVERY_TERMINAL_MESSAGE,
226
+ };
227
+
213
228
  initialState: RuntimeState = {
214
229
  runtimeLoad: { status: "idle", available: false },
215
230
  };
@@ -352,15 +367,25 @@ export abstract class AgentRuntimeKernel<
352
367
  };
353
368
  },
354
369
  retryTurn: async (submissionId) => {
355
- const receipt = await this.submissions.recover(submissionId);
356
- return receipt.status === "completed"
357
- ? { status: "completed" as const }
358
- : {
359
- status: "failed" as const,
360
- error:
361
- receipt.error ??
362
- `Pi recovery ended with status ${receipt.status}`,
363
- };
370
+ try {
371
+ const receipt = await this.submissions.recover(submissionId);
372
+ return receipt.status === "completed"
373
+ ? { status: "completed" as const }
374
+ : {
375
+ status: "failed" as const,
376
+ error:
377
+ receipt.error ??
378
+ `Pi recovery ended with status ${receipt.status}`,
379
+ };
380
+ } catch (error) {
381
+ if (
382
+ error instanceof ChatStreamStalledError ||
383
+ error instanceof RetryableModelError
384
+ ) {
385
+ return { status: "scheduled" as const };
386
+ }
387
+ throw error;
388
+ }
364
389
  },
365
390
  failTurn: async (submissionId, message) => {
366
391
  const submission = this.readSubmission(submissionId);
@@ -450,7 +475,6 @@ export abstract class AgentRuntimeKernel<
450
475
  const startedAt = output.startedAt ?? Date.now();
451
476
  return this.pi.createTurn({
452
477
  prepared: this.preparedPi(),
453
- baseRevision: this.baseRevision(),
454
478
  pinnedDescriptor: submission.assemblyDescriptor,
455
479
  submission: {
456
480
  id: submission.submissionId,
@@ -833,16 +857,6 @@ export abstract class AgentRuntimeKernel<
833
857
  .join("");
834
858
  }
835
859
 
836
- // 作用:返回当前已激活配置的 revision。
837
- // 调用:创建 Pi Turn 适配器时调用。
838
- // 原因:把初始化不变式收口在一处,避免执行层带着空 revision 继续。
839
- private baseRevision(): string {
840
- if (!this.runtimeRevision) {
841
- throw new Error("Runtime revision is not initialized");
842
- }
843
- return this.runtimeRevision;
844
- }
845
-
846
860
  // 作用:返回当前已准备好的 Pi Runtime。
847
861
  // 调用:创建 Turn 适配器或准入固定装配时调用。
848
862
  // 原因:明确区分“已有 Snapshot”和“Pi 已完成准备”,防止半初始化状态进入 Turn。
@@ -922,23 +936,24 @@ export abstract class AgentRuntimeKernel<
922
936
  await this.hash(submission.assemblyDescriptor)
923
937
  ) {
924
938
  throw new Error(
925
- "Pinned Runtime revision is unavailable for Pi recovery",
939
+ "Pinned Runtime assembly descriptor is invalid",
926
940
  );
927
941
  }
928
942
  }
929
943
 
930
- // 作用:按当前内存预算和模型压缩 Pi 上下文。
944
+ // 作用:按当前模型输入预算压缩 Pi 上下文。
931
945
  // 调用:Pi Turn 适配器在发模型请求前通过 `transformContext` 回调。
932
- // 原因:压缩必须使用本 Snapshot 的模型和密钥,不能脱离已固定的 Runtime 配置。
946
+ // 原因:模型窗口必须预留最大输出,压缩也必须使用本 Snapshot 的模型和密钥。
933
947
  private async transformPiContext(
934
948
  submissionId: string,
935
949
  messages: Parameters<PiRuntimeTranscript["compactContext"]>[0],
936
950
  signal?: AbortSignal,
937
951
  ): ReturnType<PiRuntimeTranscript["compactContext"]> {
938
952
  const snapshot = this.assembly();
953
+ const inputBudget = snapshot.pi.model.contextWindow -
954
+ snapshot.pi.model.maxTokens;
939
955
  const compacted = await this.transcript.compactContext(messages, {
940
- compactAfterTokens:
941
- snapshot.profile.memory.compactAfterTokens,
956
+ compactAfterTokens: inputBudget * 0.85,
942
957
  model: snapshot.pi.model,
943
958
  apiKey: this.pi.resolveApiKey(
944
959
  snapshot.bindings.provider,
@@ -1435,6 +1450,19 @@ export abstract class AgentRuntimeKernel<
1435
1450
  return terminal;
1436
1451
  });
1437
1452
  await this.drainRuntimeEvents();
1453
+ try {
1454
+ await this.settleChatRecovery(
1455
+ latest.requestId,
1456
+ effectiveOutcome === "succeeded"
1457
+ ? "completed"
1458
+ : effectiveOutcome === "aborted"
1459
+ ? "skipped"
1460
+ : "failed",
1461
+ effectiveMessage,
1462
+ );
1463
+ } catch (error) {
1464
+ console.error("[chat-recovery] terminal settlement failed", error);
1465
+ }
1438
1466
  let inactiveActivity: RuntimeActivity = "idle";
1439
1467
  if (terminal.status === "completed") {
1440
1468
  try {
@@ -1478,6 +1506,16 @@ export abstract class AgentRuntimeKernel<
1478
1506
  ): Promise<boolean> {
1479
1507
  let decision = await this.materializeRecoveredToolResults(submission);
1480
1508
  for (let step = 0; step < 32; step += 1) {
1509
+ const latest = this.readSubmission(submission.submissionId);
1510
+ if (!latest || isTerminalSubmissionStatus(latest.status)) return false;
1511
+ if (latest.abortReason) {
1512
+ await this.submissions.finish(
1513
+ latest,
1514
+ "aborted",
1515
+ latest.abortReason,
1516
+ );
1517
+ return false;
1518
+ }
1481
1519
  this.db.transaction(() => {
1482
1520
  this.applyPiRecoveryMutations(
1483
1521
  submission,
@@ -1655,13 +1693,24 @@ export abstract class AgentRuntimeKernel<
1655
1693
  submission.abortReason,
1656
1694
  );
1657
1695
  }
1658
- if (recovery) await this.ensureRuntimeReady();
1696
+ if (recovery) {
1697
+ this.db.submissions.clearRecoveryReason(submissionId);
1698
+ await this.broadcastApprovals();
1699
+ await this.ensureRuntimeReady();
1700
+ }
1659
1701
  try {
1660
1702
  return await this.executeNonTerminalSubmission(
1661
1703
  submission,
1662
1704
  recovery,
1663
1705
  );
1664
1706
  } catch (error) {
1707
+ if (
1708
+ recovery &&
1709
+ (error instanceof ChatStreamStalledError ||
1710
+ error instanceof RetryableModelError)
1711
+ ) {
1712
+ throw error;
1713
+ }
1665
1714
  const failed = await this.submissions.finish(
1666
1715
  submission,
1667
1716
  "failed",
@@ -1711,11 +1760,33 @@ export abstract class AgentRuntimeKernel<
1711
1760
  }
1712
1761
  const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
1713
1762
  if (recovery) {
1714
- const ready = await this.prepareRecoveredTurn(
1715
- submission,
1716
- recoveryAdapter,
1717
- );
1718
- if (!ready) return this.readSubmission(submissionId)!;
1763
+ const deactivatePreparation = this.submissions.activate(submission, {
1764
+ submissionId,
1765
+ requestId: submission.requestId,
1766
+ messageId: submission.assistantMessageId,
1767
+ startedAt: submission.createdAt,
1768
+ continuation: true,
1769
+ agent: recoveryAdapter,
1770
+ });
1771
+ let ready: boolean;
1772
+ try {
1773
+ ready = await this.prepareRecoveredTurn(
1774
+ submission,
1775
+ recoveryAdapter,
1776
+ );
1777
+ } finally {
1778
+ deactivatePreparation();
1779
+ }
1780
+ if (!ready) {
1781
+ const latest = this.readSubmission(submissionId)!;
1782
+ return latest.abortReason
1783
+ ? this.submissions.finish(
1784
+ latest,
1785
+ "aborted",
1786
+ latest.abortReason,
1787
+ )
1788
+ : latest;
1789
+ }
1719
1790
  } else {
1720
1791
  await this.materializeRecoveredToolResults(submission);
1721
1792
  }
@@ -1821,13 +1892,77 @@ export abstract class AgentRuntimeKernel<
1821
1892
  await this.projectTerminal(turn, terminal);
1822
1893
  return terminal;
1823
1894
  } catch (error) {
1895
+ const stalled = error instanceof ChatStreamStalledError;
1896
+ const abortReason = this.readSubmission(submissionId)?.abortReason;
1897
+ if (!abortReason && (stalled || error instanceof RetryableModelError)) {
1898
+ const recoveryErrorCount = this.db.submissions
1899
+ .incrementRecoveryErrorCount(
1900
+ submissionId,
1901
+ stalled
1902
+ ? "no_meaningful_model_progress"
1903
+ : "transient_model_error",
1904
+ );
1905
+ if (stalled) {
1906
+ const stallDetails = readModelStreamStallDetails(errorText(error)) ?? {
1907
+ lastMeaningfulActivityAt:
1908
+ Date.now() - MODEL_STREAM_STALL_TIMEOUT_MS,
1909
+ lastMeaningfulActivityType: "model_stream_started",
1910
+ idleMs: MODEL_STREAM_STALL_TIMEOUT_MS,
1911
+ };
1912
+ this._emit("chat:stream:stalled", {
1913
+ requestId: submission.requestId,
1914
+ submissionId,
1915
+ attempt: recoveryErrorCount,
1916
+ timeoutMs: MODEL_STREAM_STALL_TIMEOUT_MS,
1917
+ ...stallDetails,
1918
+ reason: "no_meaningful_model_progress",
1919
+ });
1920
+ }
1921
+ await this.broadcastApprovals();
1922
+ const stoppedDuringRecovery = this.readSubmission(submissionId)
1923
+ ?.abortReason;
1924
+ if (
1925
+ !stoppedDuringRecovery &&
1926
+ recoveryErrorCount < CHAT_RECOVERY_MAX_ATTEMPTS
1927
+ ) {
1928
+ const recoveryOutcome = await this.scheduleChatRecoveryRetry(
1929
+ {
1930
+ submissionId,
1931
+ requestId: submission.requestId,
1932
+ },
1933
+ async () => {
1934
+ this.broadcast(json({
1935
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1936
+ messages: await this.getMessages(),
1937
+ }));
1938
+ if (streamId) this.completeRecoverableStream(streamId);
1939
+ this.sendChatResponse(submission.requestId, "", true);
1940
+ },
1941
+ );
1942
+ if (
1943
+ recoveryOutcome !== "disabled" &&
1944
+ !this.readSubmission(submissionId)?.abortReason
1945
+ ) {
1946
+ if (recovery && recoveryOutcome === "scheduled") throw error;
1947
+ return this.readSubmission(submissionId)!;
1948
+ }
1949
+ } else if (!stoppedDuringRecovery) {
1950
+ terminalIntent = {
1951
+ outcome: "failed",
1952
+ message: CHAT_RECOVERY_TERMINAL_MESSAGE,
1953
+ };
1954
+ }
1955
+ }
1824
1956
  const latest = this.readSubmission(submissionId);
1825
- const intent = terminalIntent ?? {
1826
- outcome: latest?.abortReason
1827
- ? ("aborted" as const)
1828
- : ("failed" as const),
1829
- message: errorText(error),
1830
- };
1957
+ const intent = terminalIntent ?? (latest?.abortReason
1958
+ ? {
1959
+ outcome: "aborted" as const,
1960
+ message: latest.abortReason,
1961
+ }
1962
+ : {
1963
+ outcome: "failed" as const,
1964
+ message: errorText(error),
1965
+ });
1831
1966
  const failed = await this.submissions.finish(
1832
1967
  submission,
1833
1968
  intent.outcome,
@@ -1856,19 +1991,21 @@ export abstract class AgentRuntimeKernel<
1856
1991
  }
1857
1992
  }
1858
1993
 
1859
- // 作用:先持久化一条 Pi 流记录,再广播给在线客户端。
1994
+ // 作用:持久化一条 Pi 流记录并广播给在线客户端。
1860
1995
  // 调用:活跃 Turn 适配器每产生一条 stream record 时调用。
1861
- // 原因:耐久写入早于 WebSocket 发送,断线客户端才能从同一序列续传。
1996
+ // 原因:先发 WebSocket 再等持久化完成,让客户端零延迟收到 token,同时
1997
+ // await 保证 Pi 的事件循环不越过尚未落盘的 chunk,续传完整性不受影响。
1862
1998
  private async persistAndBroadcastRecord(
1863
1999
  turn: ActiveTurn,
1864
2000
  chunk: UIMessageChunk,
1865
2001
  ): Promise<void> {
1866
2002
  const streamId = this.streamBySubmission.get(turn.submissionId);
1867
2003
  const body = json(chunk);
1868
- if (streamId) {
1869
- await this.appendRecoverableChunk(streamId, body);
1870
- }
2004
+ const persist = streamId
2005
+ ? this.appendRecoverableChunk(streamId, body)
2006
+ : Promise.resolve();
1871
2007
  this.sendChatResponse(turn.requestId, body, false);
2008
+ await persist;
1872
2009
  }
1873
2010
 
1874
2011
  private sendChatTerminal(requestId: string, error?: string): void {
@@ -2080,7 +2217,7 @@ export abstract class AgentRuntimeKernel<
2080
2217
  if (submission) {
2081
2218
  await this.cancelSubmissionById(
2082
2219
  submission.submissionId,
2083
- "Client cancelled",
2220
+ USER_STOP_REASON,
2084
2221
  );
2085
2222
  }
2086
2223
  return;
@@ -2821,7 +2958,21 @@ export abstract class AgentRuntimeKernel<
2821
2958
  );
2822
2959
  const turn: RuntimeTurnState = {
2823
2960
  ...(current
2824
- ? { activeSubmissionId: current.submissionId }
2961
+ ? {
2962
+ activeSubmissionId: current.submissionId,
2963
+ ...(current.requestId
2964
+ ? { activeRequestId: current.requestId }
2965
+ : {}),
2966
+ ...(current.recoveryErrorCount === undefined
2967
+ ? {}
2968
+ : {
2969
+ recoveryAttempt: current.recoveryErrorCount,
2970
+ recoveryMax: CHAT_RECOVERY_MAX_ATTEMPTS,
2971
+ ...(current.recoveryReason
2972
+ ? { recoveryReason: current.recoveryReason }
2973
+ : {}),
2974
+ }),
2975
+ }
2825
2976
  : {}),
2826
2977
  steerable: Boolean(
2827
2978
  current &&