@springbrand/agent-runtime 0.1.3-alpha.1 → 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,7 +936,7 @@ 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
  }
@@ -1436,6 +1450,19 @@ export abstract class AgentRuntimeKernel<
1436
1450
  return terminal;
1437
1451
  });
1438
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
+ }
1439
1466
  let inactiveActivity: RuntimeActivity = "idle";
1440
1467
  if (terminal.status === "completed") {
1441
1468
  try {
@@ -1479,6 +1506,16 @@ export abstract class AgentRuntimeKernel<
1479
1506
  ): Promise<boolean> {
1480
1507
  let decision = await this.materializeRecoveredToolResults(submission);
1481
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
+ }
1482
1519
  this.db.transaction(() => {
1483
1520
  this.applyPiRecoveryMutations(
1484
1521
  submission,
@@ -1656,13 +1693,24 @@ export abstract class AgentRuntimeKernel<
1656
1693
  submission.abortReason,
1657
1694
  );
1658
1695
  }
1659
- if (recovery) await this.ensureRuntimeReady();
1696
+ if (recovery) {
1697
+ this.db.submissions.clearRecoveryReason(submissionId);
1698
+ await this.broadcastApprovals();
1699
+ await this.ensureRuntimeReady();
1700
+ }
1660
1701
  try {
1661
1702
  return await this.executeNonTerminalSubmission(
1662
1703
  submission,
1663
1704
  recovery,
1664
1705
  );
1665
1706
  } catch (error) {
1707
+ if (
1708
+ recovery &&
1709
+ (error instanceof ChatStreamStalledError ||
1710
+ error instanceof RetryableModelError)
1711
+ ) {
1712
+ throw error;
1713
+ }
1666
1714
  const failed = await this.submissions.finish(
1667
1715
  submission,
1668
1716
  "failed",
@@ -1712,11 +1760,33 @@ export abstract class AgentRuntimeKernel<
1712
1760
  }
1713
1761
  const recoveryAdapter = this.createSubmissionExecutionAdapter(submission);
1714
1762
  if (recovery) {
1715
- const ready = await this.prepareRecoveredTurn(
1716
- submission,
1717
- recoveryAdapter,
1718
- );
1719
- 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
+ }
1720
1790
  } else {
1721
1791
  await this.materializeRecoveredToolResults(submission);
1722
1792
  }
@@ -1822,13 +1892,77 @@ export abstract class AgentRuntimeKernel<
1822
1892
  await this.projectTerminal(turn, terminal);
1823
1893
  return terminal;
1824
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
+ }
1825
1956
  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
- };
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
+ });
1832
1966
  const failed = await this.submissions.finish(
1833
1967
  submission,
1834
1968
  intent.outcome,
@@ -1857,19 +1991,21 @@ export abstract class AgentRuntimeKernel<
1857
1991
  }
1858
1992
  }
1859
1993
 
1860
- // 作用:先持久化一条 Pi 流记录,再广播给在线客户端。
1994
+ // 作用:持久化一条 Pi 流记录并广播给在线客户端。
1861
1995
  // 调用:活跃 Turn 适配器每产生一条 stream record 时调用。
1862
- // 原因:耐久写入早于 WebSocket 发送,断线客户端才能从同一序列续传。
1996
+ // 原因:先发 WebSocket 再等持久化完成,让客户端零延迟收到 token,同时
1997
+ // await 保证 Pi 的事件循环不越过尚未落盘的 chunk,续传完整性不受影响。
1863
1998
  private async persistAndBroadcastRecord(
1864
1999
  turn: ActiveTurn,
1865
2000
  chunk: UIMessageChunk,
1866
2001
  ): Promise<void> {
1867
2002
  const streamId = this.streamBySubmission.get(turn.submissionId);
1868
2003
  const body = json(chunk);
1869
- if (streamId) {
1870
- await this.appendRecoverableChunk(streamId, body);
1871
- }
2004
+ const persist = streamId
2005
+ ? this.appendRecoverableChunk(streamId, body)
2006
+ : Promise.resolve();
1872
2007
  this.sendChatResponse(turn.requestId, body, false);
2008
+ await persist;
1873
2009
  }
1874
2010
 
1875
2011
  private sendChatTerminal(requestId: string, error?: string): void {
@@ -2081,7 +2217,7 @@ export abstract class AgentRuntimeKernel<
2081
2217
  if (submission) {
2082
2218
  await this.cancelSubmissionById(
2083
2219
  submission.submissionId,
2084
- "Client cancelled",
2220
+ USER_STOP_REASON,
2085
2221
  );
2086
2222
  }
2087
2223
  return;
@@ -2822,7 +2958,21 @@ export abstract class AgentRuntimeKernel<
2822
2958
  );
2823
2959
  const turn: RuntimeTurnState = {
2824
2960
  ...(current
2825
- ? { 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
+ }
2826
2976
  : {}),
2827
2977
  steerable: Boolean(
2828
2978
  current &&