@springbrand/agent-runtime 0.1.3-alpha.2 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.1.3-alpha.2",
3
+ "version": "0.1.3-alpha.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
package/src/index.ts CHANGED
@@ -126,6 +126,7 @@ export {
126
126
  } from "./layers/orchestration/temporary-agent/workspace";
127
127
  export {
128
128
  createPiModels,
129
+ modelRequestUrl,
129
130
  resolvePiApiKey,
130
131
  resolvePiModel,
131
132
  } from "./pi/runtime-adapter/models";
@@ -1,9 +1,12 @@
1
1
  /**
2
- * dev-only 观测降级:把 observability 事件打到 console,让 `wrangler dev` /
3
- * `wrangler tail` 里能实时看到埋点(否则 publish 到零订阅 channel = 静默 no-op)。
2
+ * 观测降级:把 observability 事件打到 console,让 `wrangler dev` / `wrangler tail` 里能实时看到埋点,
3
+ * 并且(生产上开了 `observability.logs` 时)进 Workers Logs 供事后查询。
4
+ * 不装它,事件就 publish 到零订阅 channel = 静默 no-op。
4
5
  *
5
- * 仅在 env `TELEMETRY_CONSOLE==="1"` 时由 onStart 装载(`.dev.vars` 里开,prod 不设 自动关)。
6
- * 生产不靠它:prod 所有 channel 事件自动转发 Tail Worker
6
+ * onStart 在 env `TELEMETRY_CONSOLE==="1"` 时装载 —— `.dev.vars` `wrangler.jsonc` vars 里都开着。
7
+ * 注意:上游注释宣称"生产上所有 channel 事件自动转发 Tail Worker",那条路径需要 `tail_consumers`,
8
+ * 本仓没有配,所以生产**不能**指望它;这个 console sink 就是生产的唯一消费面(2026-08-06 实测:
9
+ * 未开启前 Observability API 查 universal-agent 24h 事件 count=0)。
7
10
  *
8
11
  * 用 `agents/observability` 的类型化 `subscribe`(按 channel key 订阅),避免引 `node:diagnostics_channel`
9
12
  * 与 `@types/node`(tsconfig 只带 workers-types)。
@@ -11,6 +11,7 @@ import { createModels, type MutableModels } from "@earendil-works/pi-ai";
11
11
  import { configurePiModels, resolvePiApiKey } from "./models";
12
12
  export {
13
13
  MODEL_STREAM_STALL_TIMEOUT_MS,
14
+ modelRequestUrl,
14
15
  readModelStreamStallDetails,
15
16
  withProviderRetry,
16
17
  } from "./models";
@@ -43,6 +43,10 @@ import {
43
43
  import {
44
44
  ChatStreamStalledError,
45
45
  } from "agents/chat";
46
+ import {
47
+ genericObservability,
48
+ type ObservabilityEvent,
49
+ } from "agents/observability";
46
50
  import type {
47
51
  RuntimeModelEndpoint,
48
52
  RuntimeModelProtocol,
@@ -58,7 +62,19 @@ const CATALOGS = {
58
62
  } satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
59
63
 
60
64
  const PROVIDER_MAX_RETRIES = 2;
61
- export const MODEL_STREAM_STALL_TIMEOUT_MS = 60_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;
62
78
  export const MODEL_STREAM_STALL_MESSAGE =
63
79
  `Chat stream stalled: no activity for ${MODEL_STREAM_STALL_TIMEOUT_MS}ms; the turn was aborted by the stall watchdog.`;
64
80
  const MODEL_STREAM_STALL_DETAILS_PREFIX = `${MODEL_STREAM_STALL_MESSAGE}\n`;
@@ -180,12 +196,17 @@ function meaningfulModelProgress(
180
196
  async function* stopStalledModelStream(
181
197
  source: AsyncIterable<AssistantMessageEvent>,
182
198
  watchdog: AbortController,
199
+ probe: (phase: string, details?: Record<string, unknown>) => void,
183
200
  ): AsyncGenerator<AssistantMessageEvent> {
184
201
  const iterator = source[Symbol.asyncIterator]();
185
202
  let timer: ReturnType<typeof setTimeout> | undefined;
186
203
  let stalled = false;
187
204
  let stallError: ChatStreamStalledError | undefined;
188
205
  let idleWaitMs = 0;
206
+ let rawEventCount = 0;
207
+ let meaningfulEventCount = 0;
208
+ let lastRawEventAt: number | undefined;
209
+ let lastRawEventType: AssistantMessageEvent["type"] | undefined;
189
210
  const streamedContent = new Set<string>();
190
211
  let lastMeaningfulActivityAt = Date.now();
191
212
  let lastMeaningfulActivityType:
@@ -193,6 +214,17 @@ async function* stopStalledModelStream(
193
214
  "model_stream_started";
194
215
  const stop = (idleMs: number) => {
195
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,
227
+ });
196
228
  stallError = new ChatStreamStalledError(
197
229
  MODEL_STREAM_STALL_DETAILS_PREFIX + JSON.stringify({
198
230
  lastMeaningfulActivityAt,
@@ -227,6 +259,11 @@ async function* stopStalledModelStream(
227
259
  ]);
228
260
  } catch (error) {
229
261
  if (stalled) throw stallError;
262
+ probe("iterator_error", {
263
+ errorName: error instanceof Error ? error.name : typeof error,
264
+ rawEventCount,
265
+ meaningfulEventCount,
266
+ });
230
267
  throw error;
231
268
  } finally {
232
269
  clearTimeout(timer);
@@ -235,13 +272,31 @@ async function* stopStalledModelStream(
235
272
  if (stalled) throw stallError;
236
273
  if (next.done) break;
237
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
+ }
238
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
+ });
239
290
  yield event;
240
291
  return;
241
292
  }
242
293
  idleWaitMs += Date.now() - waitStartedAt;
243
294
  const progress = meaningfulModelProgress(event, streamedContent);
244
295
  if (progress) {
296
+ meaningfulEventCount += 1;
297
+ if (meaningfulEventCount === 1) {
298
+ probe("first_meaningful_event", { eventType: progress });
299
+ }
245
300
  lastMeaningfulActivityAt = Date.now();
246
301
  lastMeaningfulActivityType = progress;
247
302
  idleWaitMs = 0;
@@ -252,16 +307,65 @@ async function* stopStalledModelStream(
252
307
  clearTimeout(timer);
253
308
  if (!stalled) await iterator.return?.().catch(() => {});
254
309
  }
310
+ probe("ended_without_terminal", {
311
+ rawEventCount,
312
+ meaningfulEventCount,
313
+ lastRawEventType: lastRawEventType ?? "none",
314
+ });
255
315
  throw new Error("Model stream ended without a terminal event");
256
316
  }
257
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;
346
+ }
347
+ }
348
+
258
349
  /**
259
350
  * 给模型请求补上可中断的空闲终止边界。
260
351
  *
261
352
  * Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
262
353
  *
263
354
  * 调用方可以覆盖默认的 2 次 Provider 重试;主 Turn 传 0,由 Submission
264
- * 统一持有恢复预算。连续 60 秒没有可展示进展时中止 provider。
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` 承担。
265
369
  */
266
370
  export function withProviderRetry(
267
371
  streamFn: StreamFn,
@@ -270,21 +374,63 @@ export function withProviderRetry(
270
374
  ): StreamFn {
271
375
  return (model, context, options) =>
272
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");
273
401
  const watchdog = new AbortController();
274
- const source = await streamFn(model, context, {
275
- ...options,
276
- signal: options?.signal
277
- ? AbortSignal.any([options.signal, watchdog.signal])
278
- : watchdog.signal,
279
- maxRetries: options?.maxRetries ?? defaultMaxRetries,
280
- sessionId: options?.sessionId ?? defaultSessionId,
281
- onPayload: async (payload, activeModel) =>
282
- pdfPayload(
283
- await options?.onPayload?.(payload, activeModel) ?? payload,
284
- activeModel.api,
285
- ),
286
- });
287
- 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
+ }
288
434
  });
289
435
  }
290
436
 
package/src/runtime.ts CHANGED
@@ -96,7 +96,13 @@ import {
96
96
 
97
97
  const SCHEDULED_STABLE_TIMEOUT_MS = 30_000;
98
98
  const TURN_EVENT_RETRY_SECONDS = 10;
99
- const CHAT_RECOVERY_MAX_ATTEMPTS = 5;
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;
100
106
  const CHAT_RECOVERY_TERMINAL_MESSAGE =
101
107
  "多次恢复仍未成功,本次生成已停止,当前进度已保留。请发送新消息继续。";
102
108
 
@@ -1923,7 +1929,8 @@ export abstract class AgentRuntimeKernel<
1923
1929
  ?.abortReason;
1924
1930
  if (
1925
1931
  !stoppedDuringRecovery &&
1926
- recoveryErrorCount < CHAT_RECOVERY_MAX_ATTEMPTS
1932
+ recoveryErrorCount <
1933
+ (stalled ? CHAT_STALL_MAX_ATTEMPTS : CHAT_RECOVERY_MAX_ATTEMPTS)
1927
1934
  ) {
1928
1935
  const recoveryOutcome = await this.scheduleChatRecoveryRetry(
1929
1936
  {
@@ -2967,7 +2974,11 @@ export abstract class AgentRuntimeKernel<
2967
2974
  ? {}
2968
2975
  : {
2969
2976
  recoveryAttempt: current.recoveryErrorCount,
2970
- recoveryMax: CHAT_RECOVERY_MAX_ATTEMPTS,
2977
+ // 上限按当前恢复原因取,否则 stall 会显示 "2/5" 却在第 3 次就终止。
2978
+ recoveryMax:
2979
+ current.recoveryReason === "no_meaningful_model_progress"
2980
+ ? CHAT_STALL_MAX_ATTEMPTS
2981
+ : CHAT_RECOVERY_MAX_ATTEMPTS,
2971
2982
  ...(current.recoveryReason
2972
2983
  ? { recoveryReason: current.recoveryReason }
2973
2984
  : {}),