@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.3

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,15 @@ import {
91
96
 
92
97
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
93
98
  const TURN_EVENT_RETRY_SECONDS = 10;
99
+ export const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
100
+ // stall 单独收窄。理由不是「stall 更不值得救」,而是它的重试**期望值和别的错不一样**:
101
+ // 瞬时错(5xx / 断流)重跑一次往往就好了;stall 的重跑是拿同一份 transcript 让模型
102
+ // 重新想同样久,如果它本来就超预算,再跑几次也一样超。把看门狗放宽到 240s 之后,
103
+ // 真该救的那一类已经在第一次就跑完了,剩下还在 stall 的基本是连接真死——
104
+ // 那种情况下 5 次 × 240s ≈ 20 分钟的空转纯属折磨用户。3 次约 12 分钟封顶。
105
+ export const CHAT_STALL_MAX_ATTEMPTS = 3;
106
+ const CHAT_RECOVERY_TERMINAL_MESSAGE =
107
+ "多次恢复仍未成功,本次生成已停止,当前进度已保留。请发送新消息继续。";
94
108
 
95
109
  type RuntimeEventOutboxPayload =
96
110
  | { readonly type: "model-usage"; readonly event: RuntimeModelUsageEvent }
@@ -110,6 +124,8 @@ interface StoredSubmission extends SubmissionReceipt {
110
124
  queuedUiMessageJson?: string | null;
111
125
  userMessageId?: string | null;
112
126
  regenerateMessageId?: string | null;
127
+ recoveryErrorCount: number;
128
+ recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
113
129
  }
114
130
 
115
131
  interface SubmitMessageOptions {
@@ -210,6 +226,11 @@ interface PendingTemporaryAgentApproval {
210
226
  export abstract class AgentRuntimeKernel<
211
227
  Env extends Cloudflare.Env = Cloudflare.Env,
212
228
  > extends RecoverableChatAgent<Env, RuntimeState, PiChatRecoveryData> {
229
+ override chatRecovery: ChatRecoveryConfig = {
230
+ maxAttempts: CHAT_RECOVERY_MAX_ATTEMPTS,
231
+ terminalMessage: CHAT_RECOVERY_TERMINAL_MESSAGE,
232
+ };
233
+
213
234
  initialState: RuntimeState = {
214
235
  runtimeLoad: { status: "idle", available: false },
215
236
  };
@@ -352,15 +373,25 @@ export abstract class AgentRuntimeKernel<
352
373
  };
353
374
  },
354
375
  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
- };
376
+ try {
377
+ const receipt = await this.submissions.recover(submissionId);
378
+ return receipt.status === "completed"
379
+ ? { status: "completed" as const }
380
+ : {
381
+ status: "failed" as const,
382
+ error:
383
+ receipt.error ??
384
+ `Pi recovery ended with status ${receipt.status}`,
385
+ };
386
+ } catch (error) {
387
+ if (
388
+ error instanceof ChatStreamStalledError ||
389
+ error instanceof RetryableModelError
390
+ ) {
391
+ return { status: "scheduled" as const };
392
+ }
393
+ throw error;
394
+ }
364
395
  },
365
396
  failTurn: async (submissionId, message) => {
366
397
  const submission = this.readSubmission(submissionId);
@@ -450,7 +481,6 @@ export abstract class AgentRuntimeKernel<
450
481
  const startedAt = output.startedAt ?? Date.now();
451
482
  return this.pi.createTurn({
452
483
  prepared: this.preparedPi(),
453
- baseRevision: this.baseRevision(),
454
484
  pinnedDescriptor: submission.assemblyDescriptor,
455
485
  submission: {
456
486
  id: submission.submissionId,
@@ -833,16 +863,6 @@ export abstract class AgentRuntimeKernel<
833
863
  .join("");
834
864
  }
835
865
 
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
866
  // 作用:返回当前已准备好的 Pi Runtime。
847
867
  // 调用:创建 Turn 适配器或准入固定装配时调用。
848
868
  // 原因:明确区分“已有 Snapshot”和“Pi 已完成准备”,防止半初始化状态进入 Turn。
@@ -922,7 +942,7 @@ export abstract class AgentRuntimeKernel<
922
942
  await this.hash(submission.assemblyDescriptor)
923
943
  ) {
924
944
  throw new Error(
925
- "Pinned Runtime revision is unavailable for Pi recovery",
945
+ "Pinned Runtime assembly descriptor is invalid",
926
946
  );
927
947
  }
928
948
  }
@@ -1436,6 +1456,19 @@ export abstract class AgentRuntimeKernel<
1436
1456
  return terminal;
1437
1457
  });
1438
1458
  await this.drainRuntimeEvents();
1459
+ try {
1460
+ await this.settleChatRecovery(
1461
+ latest.requestId,
1462
+ effectiveOutcome === "succeeded"
1463
+ ? "completed"
1464
+ : effectiveOutcome === "aborted"
1465
+ ? "skipped"
1466
+ : "failed",
1467
+ effectiveMessage,
1468
+ );
1469
+ } catch (error) {
1470
+ console.error("[chat-recovery] terminal settlement failed", error);
1471
+ }
1439
1472
  let inactiveActivity: RuntimeActivity = "idle";
1440
1473
  if (terminal.status === "completed") {
1441
1474
  try {
@@ -1479,6 +1512,16 @@ export abstract class AgentRuntimeKernel<
1479
1512
  ): Promise<boolean> {
1480
1513
  let decision = await this.materializeRecoveredToolResults(submission);
1481
1514
  for (let step = 0; step < 32; step += 1) {
1515
+ const latest = this.readSubmission(submission.submissionId);
1516
+ if (!latest || isTerminalSubmissionStatus(latest.status)) return false;
1517
+ if (latest.abortReason) {
1518
+ await this.submissions.finish(
1519
+ latest,
1520
+ "aborted",
1521
+ latest.abortReason,
1522
+ );
1523
+ return false;
1524
+ }
1482
1525
  this.db.transaction(() => {
1483
1526
  this.applyPiRecoveryMutations(
1484
1527
  submission,
@@ -1656,13 +1699,24 @@ export abstract class AgentRuntimeKernel<
1656
1699
  submission.abortReason,
1657
1700
  );
1658
1701
  }
1659
- if (recovery) await this.ensureRuntimeReady();
1702
+ if (recovery) {
1703
+ this.db.submissions.clearRecoveryReason(submissionId);
1704
+ await this.broadcastApprovals();
1705
+ await this.ensureRuntimeReady();
1706
+ }
1660
1707
  try {
1661
1708
  return await this.executeNonTerminalSubmission(
1662
1709
  submission,
1663
1710
  recovery,
1664
1711
  );
1665
1712
  } catch (error) {
1713
+ if (
1714
+ recovery &&
1715
+ (error instanceof ChatStreamStalledError ||
1716
+ error instanceof RetryableModelError)
1717
+ ) {
1718
+ throw error;
1719
+ }
1666
1720
  const failed = await this.submissions.finish(
1667
1721
  submission,
1668
1722
  "failed",
@@ -1712,11 +1766,33 @@ export abstract class AgentRuntimeKernel<
1712
1766
  }
1713
1767
  const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
1714
1768
  if (recovery) {
1715
- const ready = await this.prepareRecoveredTurn(
1716
- submission,
1717
- recoveryAdapter,
1718
- );
1719
- if (!ready) return this.readSubmission(submissionId)!;
1769
+ const deactivatePreparation = this.submissions.activate(submission, {
1770
+ submissionId,
1771
+ requestId: submission.requestId,
1772
+ messageId: submission.assistantMessageId,
1773
+ startedAt: submission.createdAt,
1774
+ continuation: true,
1775
+ agent: recoveryAdapter,
1776
+ });
1777
+ let ready: boolean;
1778
+ try {
1779
+ ready = await this.prepareRecoveredTurn(
1780
+ submission,
1781
+ recoveryAdapter,
1782
+ );
1783
+ } finally {
1784
+ deactivatePreparation();
1785
+ }
1786
+ if (!ready) {
1787
+ const latest = this.readSubmission(submissionId)!;
1788
+ return latest.abortReason
1789
+ ? this.submissions.finish(
1790
+ latest,
1791
+ "aborted",
1792
+ latest.abortReason,
1793
+ )
1794
+ : latest;
1795
+ }
1720
1796
  } else {
1721
1797
  await this.materializeRecoveredToolResults(submission);
1722
1798
  }
@@ -1822,13 +1898,78 @@ export abstract class AgentRuntimeKernel<
1822
1898
  await this.projectTerminal(turn, terminal);
1823
1899
  return terminal;
1824
1900
  } catch (error) {
1901
+ const stalled = error instanceof ChatStreamStalledError;
1902
+ const abortReason = this.readSubmission(submissionId)?.abortReason;
1903
+ if (!abortReason && (stalled || error instanceof RetryableModelError)) {
1904
+ const recoveryErrorCount = this.db.submissions
1905
+ .incrementRecoveryErrorCount(
1906
+ submissionId,
1907
+ stalled
1908
+ ? "no_meaningful_model_progress"
1909
+ : "transient_model_error",
1910
+ );
1911
+ if (stalled) {
1912
+ const stallDetails = readModelStreamStallDetails(errorText(error)) ?? {
1913
+ lastMeaningfulActivityAt:
1914
+ Date.now() - MODEL_STREAM_STALL_TIMEOUT_MS,
1915
+ lastMeaningfulActivityType: "model_stream_started",
1916
+ idleMs: MODEL_STREAM_STALL_TIMEOUT_MS,
1917
+ };
1918
+ this._emit("chat:stream:stalled", {
1919
+ requestId: submission.requestId,
1920
+ submissionId,
1921
+ attempt: recoveryErrorCount,
1922
+ timeoutMs: MODEL_STREAM_STALL_TIMEOUT_MS,
1923
+ ...stallDetails,
1924
+ reason: "no_meaningful_model_progress",
1925
+ });
1926
+ }
1927
+ await this.broadcastApprovals();
1928
+ const stoppedDuringRecovery = this.readSubmission(submissionId)
1929
+ ?.abortReason;
1930
+ if (
1931
+ !stoppedDuringRecovery &&
1932
+ recoveryErrorCount <
1933
+ (stalled ? CHAT_STALL_MAX_ATTEMPTS : CHAT_RECOVERY_MAX_ATTEMPTS)
1934
+ ) {
1935
+ const recoveryOutcome = await this.scheduleChatRecoveryRetry(
1936
+ {
1937
+ submissionId,
1938
+ requestId: submission.requestId,
1939
+ },
1940
+ async () => {
1941
+ this.broadcast(json({
1942
+ type: MessageType.CF_AGENT_CHAT_MESSAGES,
1943
+ messages: await this.getMessages(),
1944
+ }));
1945
+ if (streamId) this.completeRecoverableStream(streamId);
1946
+ this.sendChatResponse(submission.requestId, "", true);
1947
+ },
1948
+ );
1949
+ if (
1950
+ recoveryOutcome !== "disabled" &&
1951
+ !this.readSubmission(submissionId)?.abortReason
1952
+ ) {
1953
+ if (recovery && recoveryOutcome === "scheduled") throw error;
1954
+ return this.readSubmission(submissionId)!;
1955
+ }
1956
+ } else if (!stoppedDuringRecovery) {
1957
+ terminalIntent = {
1958
+ outcome: "failed",
1959
+ message: CHAT_RECOVERY_TERMINAL_MESSAGE,
1960
+ };
1961
+ }
1962
+ }
1825
1963
  const latest = this.readSubmission(submissionId);
1826
- const intent = terminalIntent ?? {
1827
- outcome: latest?.abortReason
1828
- ? ("aborted" as const)
1829
- : ("failed" as const),
1830
- message: errorText(error),
1831
- };
1964
+ const intent = terminalIntent ?? (latest?.abortReason
1965
+ ? {
1966
+ outcome: "aborted" as const,
1967
+ message: latest.abortReason,
1968
+ }
1969
+ : {
1970
+ outcome: "failed" as const,
1971
+ message: errorText(error),
1972
+ });
1832
1973
  const failed = await this.submissions.finish(
1833
1974
  submission,
1834
1975
  intent.outcome,
@@ -1857,19 +1998,21 @@ export abstract class AgentRuntimeKernel<
1857
1998
  }
1858
1999
  }
1859
2000
 
1860
- // 作用:先持久化一条 Pi 流记录,再广播给在线客户端。
2001
+ // 作用:持久化一条 Pi 流记录并广播给在线客户端。
1861
2002
  // 调用:活跃 Turn 适配器每产生一条 stream record 时调用。
1862
- // 原因:耐久写入早于 WebSocket 发送,断线客户端才能从同一序列续传。
2003
+ // 原因:先发 WebSocket 再等持久化完成,让客户端零延迟收到 token,同时
2004
+ // await 保证 Pi 的事件循环不越过尚未落盘的 chunk,续传完整性不受影响。
1863
2005
  private async persistAndBroadcastRecord(
1864
2006
  turn: ActiveTurn,
1865
2007
  chunk: UIMessageChunk,
1866
2008
  ): Promise<void> {
1867
2009
  const streamId = this.streamBySubmission.get(turn.submissionId);
1868
2010
  const body = json(chunk);
1869
- if (streamId) {
1870
- await this.appendRecoverableChunk(streamId, body);
1871
- }
2011
+ const persist = streamId
2012
+ ? this.appendRecoverableChunk(streamId, body)
2013
+ : Promise.resolve();
1872
2014
  this.sendChatResponse(turn.requestId, body, false);
2015
+ await persist;
1873
2016
  }
1874
2017
 
1875
2018
  private sendChatTerminal(requestId: string, error?: string): void {
@@ -2081,7 +2224,7 @@ export abstract class AgentRuntimeKernel<
2081
2224
  if (submission) {
2082
2225
  await this.cancelSubmissionById(
2083
2226
  submission.submissionId,
2084
- "Client cancelled",
2227
+ USER_STOP_REASON,
2085
2228
  );
2086
2229
  }
2087
2230
  return;
@@ -2822,7 +2965,25 @@ export abstract class AgentRuntimeKernel<
2822
2965
  );
2823
2966
  const turn: RuntimeTurnState = {
2824
2967
  ...(current
2825
- ? { activeSubmissionId: current.submissionId }
2968
+ ? {
2969
+ activeSubmissionId: current.submissionId,
2970
+ ...(current.requestId
2971
+ ? { activeRequestId: current.requestId }
2972
+ : {}),
2973
+ ...(current.recoveryErrorCount === undefined
2974
+ ? {}
2975
+ : {
2976
+ recoveryAttempt: current.recoveryErrorCount,
2977
+ // 上限按当前恢复原因取,否则 stall 会显示 "2/5" 却在第 3 次就终止。
2978
+ recoveryMax:
2979
+ current.recoveryReason === "no_meaningful_model_progress"
2980
+ ? CHAT_STALL_MAX_ATTEMPTS
2981
+ : CHAT_RECOVERY_MAX_ATTEMPTS,
2982
+ ...(current.recoveryReason
2983
+ ? { recoveryReason: current.recoveryReason }
2984
+ : {}),
2985
+ }),
2986
+ }
2826
2987
  : {}),
2827
2988
  steerable: Boolean(
2828
2989
  current &&