@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.
@@ -2,8 +2,10 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import {
3
3
  createModels,
4
4
  createProvider,
5
+ isRetryableAssistantError,
5
6
  lazyStream,
6
7
  type Api,
8
+ type AssistantMessage,
7
9
  type AssistantMessageEvent,
8
10
  type Model,
9
11
  type MutableModels,
@@ -35,6 +37,16 @@ import {
35
37
  import {
36
38
  openaiCodexProvider,
37
39
  } from "@earendil-works/pi-ai/providers/openai-codex";
40
+ import {
41
+ cloudflareStreams,
42
+ } from "@earendil-works/pi-ai/providers/cloudflare-stream";
43
+ import {
44
+ ChatStreamStalledError,
45
+ } from "agents/chat";
46
+ import {
47
+ genericObservability,
48
+ type ObservabilityEvent,
49
+ } from "agents/observability";
38
50
  import type {
39
51
  RuntimeModelEndpoint,
40
52
  RuntimeModelProtocol,
@@ -50,57 +62,375 @@ const CATALOGS = {
50
62
  } satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
51
63
 
52
64
  const PROVIDER_MAX_RETRIES = 2;
53
- const MODEL_STREAM_STALL_TIMEOUT_MS = 120_000;
65
+ // 看门狗要抓的是「连接死了」,不是「模型想得慢」——这两件事在流上分不开:
66
+ // 推理模型在中转后面是「闷头想完再吐」,静默期一个字节都没有,也没有 keepalive。
67
+ //
68
+ // 2026-08-06 实测(gpt-5.6-sol @ api.sharkmelon.tech,一道需要真推理的题):
69
+ // 响应头 3.86s → 单段静默 **34.4s** → 1190 个 chunk 在 12s 内吐完,首个可见内容 38.3s。
70
+ // 而真实回合(长 transcript + 工具)比这道题重得多。原值 60s 卡在这条曲线的正中间:
71
+ // 简单回合(3~9s)不触发,一旦模型真开始想就必然超时 → abort → 从 transcript 整轮重跑
72
+ // → 同一个提示词又想同样久 → 再超时。**重试的对象正是那个「本来就要更久」的东西,
73
+ // 结构上不可能收敛**,表现为前端 think 转到恢复预算耗尽为止。
74
+ //
75
+ // 调到 240s:比实测静默期留约 7 倍余量。代价是连接真死时单次要等更久,
76
+ // 所以 runtime.ts 同时把 stall 的恢复次数单独收窄(见 CHAT_STALL_MAX_ATTEMPTS)。
77
+ export const MODEL_STREAM_STALL_TIMEOUT_MS = 240_000;
78
+ export const MODEL_STREAM_STALL_MESSAGE =
79
+ `Chat stream stalled: no activity for ${MODEL_STREAM_STALL_TIMEOUT_MS}ms; the turn was aborted by the stall watchdog.`;
80
+ const MODEL_STREAM_STALL_DETAILS_PREFIX = `${MODEL_STREAM_STALL_MESSAGE}\n`;
81
+
82
+ export interface ModelStreamStallDetails {
83
+ lastMeaningfulActivityAt: number;
84
+ lastMeaningfulActivityType:
85
+ | AssistantMessageEvent["type"]
86
+ | "model_stream_started";
87
+ idleMs: number;
88
+ }
89
+
90
+ export function isModelStreamStallMessage(message?: string): message is string {
91
+ return message === MODEL_STREAM_STALL_MESSAGE ||
92
+ message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
93
+ }
94
+
95
+ export function readModelStreamStallDetails(
96
+ message?: string,
97
+ ): ModelStreamStallDetails | undefined {
98
+ if (!message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX)) return;
99
+ try {
100
+ const value = JSON.parse(
101
+ message.slice(MODEL_STREAM_STALL_DETAILS_PREFIX.length),
102
+ ) as Partial<ModelStreamStallDetails>;
103
+ if (
104
+ typeof value.lastMeaningfulActivityAt !== "number" ||
105
+ typeof value.lastMeaningfulActivityType !== "string" ||
106
+ typeof value.idleMs !== "number"
107
+ ) return;
108
+ return value as ModelStreamStallDetails;
109
+ } catch {
110
+ return;
111
+ }
112
+ }
113
+
114
+ export function isRecoverableAssistantError(
115
+ message: AssistantMessage,
116
+ ): boolean {
117
+ if (
118
+ message.stopReason === "error" &&
119
+ /thought[_ ]signature/i.test(message.errorMessage ?? "")
120
+ ) return false;
121
+ return isRetryableAssistantError(message) ||
122
+ (message.stopReason === "error" &&
123
+ /upstream http\/2 stream failed/i.test(message.errorMessage ?? ""));
124
+ }
125
+
126
+ function pdfPayload(payload: unknown, api: Api): unknown {
127
+ if (Array.isArray(payload)) {
128
+ return payload.map((value) => pdfPayload(value, api));
129
+ }
130
+ if (typeof payload !== "object" || payload === null) return payload;
131
+
132
+ const value = payload as Record<string, unknown>;
133
+ if (api === "anthropic-messages" && value.type === "image") {
134
+ const source = value.source as Record<string, unknown> | undefined;
135
+ if (source?.media_type === "application/pdf") {
136
+ return { ...value, type: "document" };
137
+ }
138
+ }
139
+ if (api === "openai-completions" && value.type === "image_url") {
140
+ const image = value.image_url as Record<string, unknown> | undefined;
141
+ if (
142
+ typeof image?.url === "string" &&
143
+ image.url.startsWith("data:application/pdf;base64,")
144
+ ) {
145
+ return {
146
+ type: "file",
147
+ file: { filename: "workspace.pdf", file_data: image.url },
148
+ };
149
+ }
150
+ }
151
+ if (api === "openai-codex-responses" && value.type === "input_image") {
152
+ if (
153
+ typeof value.image_url === "string" &&
154
+ value.image_url.startsWith("data:application/pdf;base64,")
155
+ ) {
156
+ return {
157
+ type: "input_file",
158
+ filename: "workspace.pdf",
159
+ file_data: value.image_url,
160
+ };
161
+ }
162
+ }
163
+
164
+ return Object.fromEntries(
165
+ Object.entries(value).map(([key, child]) => [
166
+ key,
167
+ pdfPayload(child, api),
168
+ ]),
169
+ );
170
+ }
171
+
172
+ function meaningfulModelProgress(
173
+ event: AssistantMessageEvent,
174
+ streamedContent: Set<string>,
175
+ ): AssistantMessageEvent["type"] | undefined {
176
+ switch (event.type) {
177
+ case "text_delta":
178
+ case "thinking_delta": {
179
+ if (!event.delta.trim()) return undefined;
180
+ streamedContent.add(`${event.type}:${event.contentIndex}`);
181
+ return event.type;
182
+ }
183
+ case "text_end":
184
+ if (streamedContent.has(`text_delta:${event.contentIndex}`)) return;
185
+ return event.content.trim() ? event.type : undefined;
186
+ case "thinking_end":
187
+ if (streamedContent.has(`thinking_delta:${event.contentIndex}`)) return;
188
+ return event.content.trim() ? event.type : undefined;
189
+ case "toolcall_end":
190
+ return event.type;
191
+ default:
192
+ return undefined;
193
+ }
194
+ }
54
195
 
55
196
  async function* stopStalledModelStream(
56
197
  source: AsyncIterable<AssistantMessageEvent>,
57
198
  watchdog: AbortController,
199
+ probe: (phase: string, details?: Record<string, unknown>) => void,
58
200
  ): AsyncGenerator<AssistantMessageEvent> {
59
201
  const iterator = source[Symbol.asyncIterator]();
60
- while (true) {
61
- let timer: ReturnType<typeof setTimeout> | undefined;
62
- const next = await Promise.race([
63
- iterator.next(),
64
- new Promise<never>((_, reject) => {
65
- timer = setTimeout(() => {
66
- const error = new Error(
67
- `Model stream stalled for ${MODEL_STREAM_STALL_TIMEOUT_MS} ms`,
68
- );
69
- watchdog.abort(error);
70
- reject(error);
71
- }, MODEL_STREAM_STALL_TIMEOUT_MS);
72
- }),
73
- ]).finally(() => {
74
- if (timer !== undefined) clearTimeout(timer);
202
+ let timer: ReturnType<typeof setTimeout> | undefined;
203
+ let stalled = false;
204
+ let stallError: ChatStreamStalledError | undefined;
205
+ let idleWaitMs = 0;
206
+ let rawEventCount = 0;
207
+ let meaningfulEventCount = 0;
208
+ let lastRawEventAt: number | undefined;
209
+ let lastRawEventType: AssistantMessageEvent["type"] | undefined;
210
+ const streamedContent = new Set<string>();
211
+ let lastMeaningfulActivityAt = Date.now();
212
+ let lastMeaningfulActivityType:
213
+ ModelStreamStallDetails["lastMeaningfulActivityType"] =
214
+ "model_stream_started";
215
+ const stop = (idleMs: number) => {
216
+ stalled = true;
217
+ const now = Date.now();
218
+ probe("stall", {
219
+ idleMs,
220
+ rawEventCount,
221
+ meaningfulEventCount,
222
+ lastRawEventType: lastRawEventType ?? "none",
223
+ sinceLastRawEventMs: lastRawEventAt === undefined
224
+ ? null
225
+ : now - lastRawEventAt,
226
+ lastMeaningfulActivityType,
75
227
  });
76
- if (next.done) {
77
- throw new Error("Model stream ended without a terminal event");
228
+ stallError = new ChatStreamStalledError(
229
+ MODEL_STREAM_STALL_DETAILS_PREFIX + JSON.stringify({
230
+ lastMeaningfulActivityAt,
231
+ lastMeaningfulActivityType,
232
+ idleMs,
233
+ } satisfies ModelStreamStallDetails),
234
+ );
235
+ watchdog.abort(stallError);
236
+ return stallError;
237
+ };
238
+ const arm = (
239
+ delayMs: number,
240
+ waitStartedAt: number,
241
+ ) => new Promise<never>((_, reject) => {
242
+ timer = setTimeout(() => {
243
+ reject(stop(idleWaitMs + Date.now() - waitStartedAt));
244
+ }, delayMs);
245
+ });
246
+ try {
247
+ while (true) {
248
+ const waitStartedAt = Date.now();
249
+ const nextPromise = iterator.next();
250
+ nextPromise.catch(() => {});
251
+ let next: IteratorResult<AssistantMessageEvent>;
252
+ try {
253
+ next = await Promise.race([
254
+ nextPromise,
255
+ arm(
256
+ Math.max(0, MODEL_STREAM_STALL_TIMEOUT_MS - idleWaitMs),
257
+ waitStartedAt,
258
+ ),
259
+ ]);
260
+ } catch (error) {
261
+ if (stalled) throw stallError;
262
+ probe("iterator_error", {
263
+ errorName: error instanceof Error ? error.name : typeof error,
264
+ rawEventCount,
265
+ meaningfulEventCount,
266
+ });
267
+ throw error;
268
+ } finally {
269
+ clearTimeout(timer);
270
+ timer = undefined;
271
+ }
272
+ if (stalled) throw stallError;
273
+ if (next.done) break;
274
+ const event = next.value;
275
+ rawEventCount += 1;
276
+ lastRawEventAt = Date.now();
277
+ lastRawEventType = event.type;
278
+ if (rawEventCount === 1) {
279
+ probe("first_raw_event", { eventType: event.type });
280
+ }
281
+ if (event.type === "done" || event.type === "error") {
282
+ probe(event.type, {
283
+ reason: event.reason,
284
+ stopReason: event.type === "done"
285
+ ? event.message.stopReason
286
+ : event.error.stopReason,
287
+ rawEventCount,
288
+ meaningfulEventCount,
289
+ });
290
+ yield event;
291
+ return;
292
+ }
293
+ idleWaitMs += Date.now() - waitStartedAt;
294
+ const progress = meaningfulModelProgress(event, streamedContent);
295
+ if (progress) {
296
+ meaningfulEventCount += 1;
297
+ if (meaningfulEventCount === 1) {
298
+ probe("first_meaningful_event", { eventType: progress });
299
+ }
300
+ lastMeaningfulActivityAt = Date.now();
301
+ lastMeaningfulActivityType = progress;
302
+ idleWaitMs = 0;
303
+ }
304
+ yield event;
78
305
  }
79
- yield next.value;
80
- if (next.value.type === "done" || next.value.type === "error") return;
306
+ } finally {
307
+ clearTimeout(timer);
308
+ if (!stalled) await iterator.return?.().catch(() => {});
309
+ }
310
+ probe("ended_without_terminal", {
311
+ rawEventCount,
312
+ meaningfulEventCount,
313
+ lastRawEventType: lastRawEventType ?? "none",
314
+ });
315
+ throw new Error("Model stream ended without a terminal event");
316
+ }
317
+
318
+ function trimTrailingSlash(value: string): string {
319
+ return value.endsWith("/") ? value.slice(0, -1) : value;
320
+ }
321
+
322
+ /**
323
+ * 算出一个已解析模型真正会被请求的 URL。
324
+ *
325
+ * {@link withProviderRetry} 在每次派发模型请求前调用它写观测日志;诊断"这个 Agent 到底调了哪个 LLM"时也可以直接复用。
326
+ *
327
+ * 各协议的路径由底层 SDK 决定,这里必须与之逐条对齐:OpenAI 兼容 SDK 用 `baseURL + /chat/completions`,
328
+ * Anthropic SDK 用 `baseURL + /v1/messages`(纯字符串拼接,不会去重 `/v1`),Google 的 baseUrl 已含版本段,
329
+ * Codex 走 `resolveCodexUrl`。不要在这里"顺手规范化"路径,否则日志会与真实请求脱节,反而掩盖配置错误。
330
+ */
331
+ export function modelRequestUrl(model: Model<Api>): string {
332
+ const base = trimTrailingSlash(model.baseUrl ?? "");
333
+ switch (model.api) {
334
+ case "openai-completions":
335
+ return `${base}/chat/completions`;
336
+ case "anthropic-messages":
337
+ return `${base}/v1/messages`;
338
+ case "google-generative-ai":
339
+ return `${base}/models/${model.id}:streamGenerateContent`;
340
+ case "openai-codex-responses":
341
+ return base.endsWith("/codex/responses") || base.endsWith("/responses")
342
+ ? base
343
+ : `${base}/codex/responses`;
344
+ default:
345
+ return base;
81
346
  }
82
347
  }
83
348
 
84
349
  /**
85
- * 给模型请求补上可中断的 provider 瞬时错误重试和空闲终止边界。
350
+ * 给模型请求补上可中断的空闲终止边界。
86
351
  *
87
352
  * Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
88
353
  *
89
- * Pi 0.83 默认 `maxRetries` 0,且流无事件时会无限等待。这里补 2 次默认重试,
90
- * 并在连续 120 秒没有模型事件时中止 provider、返回明确失败终态。
354
+ * 调用方可以覆盖默认的 2 Provider 重试;主 Turn 传 0,由 Submission
355
+ * 统一持有恢复预算。连续 {@link MODEL_STREAM_STALL_TIMEOUT_MS} 毫秒没有可展示进展时中止 provider
356
+ *
357
+ * 每个相位发一条 `ua:model` 观测事件(dispatch / response_headers / first_raw_event /
358
+ * first_meaningful_event / stall / done…)。**只发事件、不直接 console.log**:
359
+ * 这样它和 `ua:tool`、`chat:*` 共用同一个消费面,被 `TELEMETRY_CONSOLE` 一个开关统一管,
360
+ * 关掉即零订阅 no-op;将来若接上 `tail_consumers`,这条也自动跟着走。
361
+ *
362
+ * 每条都带 `url`(而不是只在 dispatch 带一次):模型路由只存在于 secret 里,
363
+ * 线上排查时最需要回答的就是"这次打到哪个 URL",让每行自解释比省几十字节值。
364
+ * payload 只含路由与时序,不含 key 和消息内容。
365
+ *
366
+ * 注意这里用的是模块级 `genericObservability`,不是 Agent 实例的 `_emit` ——
367
+ * 纯模块拿不到实例,代价是事件不带 `agent` / `name` 字段;turn 的身份由
368
+ * payload 里的 `requestId` / `sessionId` 承担。
91
369
  */
92
- export function withProviderRetry(streamFn: StreamFn): StreamFn {
370
+ export function withProviderRetry(
371
+ streamFn: StreamFn,
372
+ defaultMaxRetries = PROVIDER_MAX_RETRIES,
373
+ defaultSessionId?: string,
374
+ ): StreamFn {
93
375
  return (model, context, options) =>
94
376
  lazyStream(model, async () => {
377
+ const requestId = crypto.randomUUID();
378
+ const startedAt = Date.now();
379
+ const sessionId = options?.sessionId ?? defaultSessionId;
380
+ const url = modelRequestUrl(model);
381
+ const probe = (phase: string, details: Record<string, unknown> = {}) =>
382
+ // `ua:*` 是本仓自有的事件命名,不在上游的 ObservabilityEvent 联合里,
383
+ // 故整体断言一次(runtime.ts 的 `ua:tool` 是同一处上游类型缺口)。
384
+ // 不能只把 type 断言成 never——那会把联合窄成 never,连 payload 一起报错。
385
+ genericObservability.emit({
386
+ type: "ua:model",
387
+ timestamp: Date.now(),
388
+ payload: {
389
+ requestId,
390
+ sessionId,
391
+ phase,
392
+ elapsedMs: Date.now() - startedAt,
393
+ url,
394
+ api: model.api,
395
+ provider: model.provider,
396
+ model: model.id,
397
+ ...details,
398
+ },
399
+ } as unknown as ObservabilityEvent);
400
+ probe("dispatch");
95
401
  const watchdog = new AbortController();
96
- const source = await streamFn(model, context, {
97
- ...options,
98
- signal: options?.signal
99
- ? AbortSignal.any([options.signal, watchdog.signal])
100
- : watchdog.signal,
101
- maxRetries: options?.maxRetries ?? PROVIDER_MAX_RETRIES,
102
- });
103
- return stopStalledModelStream(source, watchdog);
402
+ let responseCount = 0;
403
+ try {
404
+ const source = await streamFn(model, context, {
405
+ ...options,
406
+ signal: options?.signal
407
+ ? AbortSignal.any([options.signal, watchdog.signal])
408
+ : watchdog.signal,
409
+ maxRetries: options?.maxRetries ?? defaultMaxRetries,
410
+ sessionId,
411
+ onPayload: async (payload, activeModel) =>
412
+ pdfPayload(
413
+ await options?.onPayload?.(payload, activeModel) ?? payload,
414
+ activeModel.api,
415
+ ),
416
+ onResponse: async (response, activeModel) => {
417
+ const upstreamRequestId = response.headers["x-request-id"] ??
418
+ response.headers["request-id"] ?? response.headers["cf-ray"];
419
+ probe("response_headers", {
420
+ responseCount: ++responseCount,
421
+ status: response.status,
422
+ ...(upstreamRequestId ? { upstreamRequestId } : {}),
423
+ });
424
+ await options?.onResponse?.(response, activeModel);
425
+ },
426
+ });
427
+ return stopStalledModelStream(source, watchdog, probe);
428
+ } catch (error) {
429
+ probe("dispatch_error", {
430
+ errorName: error instanceof Error ? error.name : typeof error,
431
+ });
432
+ throw error;
433
+ }
104
434
  });
105
435
  }
106
436
 
@@ -188,8 +518,14 @@ function configuredModel(
188
518
  api: apiFor(endpoint.protocol),
189
519
  provider: providerId(endpoint, index),
190
520
  baseUrl: endpoint.baseURL,
191
- ...(endpoint.protocol === "openrouter-chat" && compat
192
- ? { compat }
521
+ ...(endpoint.protocol === "openrouter-chat"
522
+ ? {
523
+ compat: {
524
+ ...compat,
525
+ sendSessionAffinityHeaders: true,
526
+ sessionAffinityFormat: "openrouter" as const,
527
+ },
528
+ }
193
529
  : {}),
194
530
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
195
531
  };
@@ -265,7 +601,9 @@ export function configurePiModels(
265
601
  models: endpoint.models.map((modelId) =>
266
602
  configuredModel(endpoint, index, modelId),
267
603
  ),
268
- api: piApiFor(endpoint.protocol),
604
+ // cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
605
+ // 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
606
+ api: cloudflareStreams(piApiFor(endpoint.protocol)),
269
607
  })
270
608
  );
271
609
  models.clearProviders();
@@ -29,6 +29,10 @@ import {
29
29
  import type { PiCanonicalMessageCommit } from "./execution";
30
30
  import type { PiRecoveredToolSettlement } from "./recovery";
31
31
  import type { RuntimeModelUsageEvent } from "../../kernel/bindings";
32
+ import {
33
+ isModelStreamStallMessage,
34
+ isRecoverableAssistantError,
35
+ } from "./models";
32
36
 
33
37
  /**
34
38
  * 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
@@ -196,6 +200,15 @@ function hostText(message: UserMessage | AssistantMessage): string {
196
200
  export class PiRuntimeTranscript {
197
201
  private readonly store: PiTranscriptStore;
198
202
 
203
+ private modelBranch() {
204
+ return this.store.branch().filter((entry) =>
205
+ entry.type !== "message" ||
206
+ entry.message.role !== "assistant" ||
207
+ (!isModelStreamStallMessage(entry.message.errorMessage) &&
208
+ !isRecoverableAssistantError(entry.message))
209
+ );
210
+ }
211
+
199
212
  /**
200
213
  * 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
201
214
  *
@@ -383,7 +396,7 @@ export class PiRuntimeTranscript {
383
396
  * 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
384
397
  */
385
398
  async canonicalMessages(): Promise<AgentMessage[]> {
386
- return this.store.branch().flatMap((entry) =>
399
+ return this.modelBranch().flatMap((entry) =>
387
400
  entry.type === "message" ? [entry.message] : []
388
401
  );
389
402
  }
@@ -449,7 +462,7 @@ export class PiRuntimeTranscript {
449
462
  onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
450
463
  },
451
464
  ): Promise<AgentMessage[]> {
452
- const branch = this.store.branch();
465
+ const branch = this.modelBranch();
453
466
  const current = branch.flatMap((entry) =>
454
467
  entry.type === "message" ? [entry.message] : []
455
468
  );
@@ -616,10 +629,47 @@ export class PiRuntimeTranscript {
616
629
  async browserMessages(): Promise<UIMessage[]> {
617
630
  const result: UIMessage[] = [];
618
631
  const seenAssistantSubmissions = new Set<string>();
632
+ const entries = await this.storedMessages();
633
+ const assistantSubmissions = new Set(entries.flatMap((entry) =>
634
+ entry.submissionId && entry.message.role === "assistant"
635
+ ? [entry.submissionId]
636
+ : []
637
+ ));
638
+ let pendingFailedSubmissionId: string | undefined;
639
+ const flushFailedSubmission = () => {
640
+ if (!pendingFailedSubmissionId) return;
641
+ const submissionId = pendingFailedSubmissionId;
642
+ pendingFailedSubmissionId = undefined;
643
+ const submission = this.durability.findSubmissionProjection(submissionId);
644
+ if (submission?.status !== "error") return;
645
+ const completedAt = submission.completedAt ?? submission.createdAt;
646
+ result.push({
647
+ id: submission.assistantMessageId,
648
+ role: "assistant",
649
+ parts: [],
650
+ metadata: {
651
+ createdAt: submission.createdAt,
652
+ completedAt,
653
+ turnDurationMs: Math.max(
654
+ 0,
655
+ completedAt - submission.createdAt,
656
+ ),
657
+ turnStatus: "error",
658
+ ...(submission.error ? { error: submission.error } : {}),
659
+ },
660
+ });
661
+ seenAssistantSubmissions.add(submissionId);
662
+ };
619
663
  let activeAssistant:
620
664
  | { submissionId: string; index: number }
621
665
  | undefined;
622
- for (const entry of await this.storedMessages()) {
666
+ for (const entry of entries) {
667
+ if (
668
+ pendingFailedSubmissionId &&
669
+ entry.submissionId !== pendingFailedSubmissionId
670
+ ) {
671
+ flushFailedSubmission();
672
+ }
623
673
  if (entry.message.role === "user") {
624
674
  const body = this.durability.readUserSidecar(entry.id);
625
675
  result.push(userUIMessage(entry.message, {
@@ -629,6 +679,13 @@ export class PiRuntimeTranscript {
629
679
  ? JSON.parse(body) as UIUserSidecar
630
680
  : undefined,
631
681
  }));
682
+ if (
683
+ entry.submissionId &&
684
+ !assistantSubmissions.has(entry.submissionId) &&
685
+ !seenAssistantSubmissions.has(entry.submissionId)
686
+ ) {
687
+ pendingFailedSubmissionId = entry.submissionId;
688
+ }
632
689
  activeAssistant = undefined;
633
690
  continue;
634
691
  }
@@ -744,6 +801,7 @@ export class PiRuntimeTranscript {
744
801
  }
745
802
  }
746
803
  }
804
+ flushFailedSubmission();
747
805
  return result;
748
806
  }
749
807
 
@@ -38,6 +38,52 @@ function resolveDescription(tool: AiTool): string {
38
38
  return typeof raw === "string" ? raw : "";
39
39
  }
40
40
 
41
+ function modelContent(
42
+ output: unknown,
43
+ ): AgentToolResult<unknown>["content"] | null {
44
+ if (typeof output !== "object" || output === null) return null;
45
+ const modelOutput = output as { type?: unknown; value?: unknown };
46
+ if (
47
+ (modelOutput.type === "text" || modelOutput.type === "error-text") &&
48
+ typeof modelOutput.value === "string"
49
+ ) {
50
+ return [{ type: "text", text: modelOutput.value }];
51
+ }
52
+ if (modelOutput.type === "json") {
53
+ return [{ type: "text", text: serializeOutput(modelOutput.value).text }];
54
+ }
55
+ if (modelOutput.type !== "content" || !Array.isArray(modelOutput.value)) {
56
+ return null;
57
+ }
58
+
59
+ const content: AgentToolResult<unknown>["content"] = [];
60
+ for (const block of modelOutput.value) {
61
+ if (typeof block !== "object" || block === null) continue;
62
+ const item = block as {
63
+ type?: unknown;
64
+ text?: unknown;
65
+ data?: unknown;
66
+ mediaType?: unknown;
67
+ };
68
+ if (item.type === "text" && typeof item.text === "string") {
69
+ content.push({ type: "text", text: item.text });
70
+ continue;
71
+ }
72
+ if (
73
+ (item.type === "file-data" || item.type === "image-data") &&
74
+ typeof item.data === "string" &&
75
+ typeof item.mediaType === "string"
76
+ ) {
77
+ content.push({
78
+ type: "image",
79
+ data: item.data,
80
+ mimeType: item.mediaType,
81
+ });
82
+ }
83
+ }
84
+ return content.length > 0 ? content : null;
85
+ }
86
+
41
87
  /**
42
88
  * 把一个可执行的 ai-sdk Tool 转成 Pi AgentTool。
43
89
  *
@@ -91,8 +137,19 @@ export function aiToolToPi(
91
137
  context: undefined,
92
138
  });
93
139
  const details = options.details ? options.details(raw) : raw;
140
+ const toModelOutput = (tool as { toModelOutput?: unknown })
141
+ .toModelOutput;
142
+ const content = typeof toModelOutput === "function"
143
+ ? modelContent(await (toModelOutput as (input: {
144
+ input: unknown;
145
+ output: unknown;
146
+ }) => unknown)({ input: params, output: raw }))
147
+ : null;
94
148
  return {
95
- content: [{ type: "text", text: serializeOutput(details).text }],
149
+ content: content ?? [{
150
+ type: "text",
151
+ text: serializeOutput(details).text,
152
+ }],
96
153
  details,
97
154
  };
98
155
  },