@springbrand/agent-runtime 0.2.0-alpha.16 → 0.2.0-alpha.17

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/src/adapter/cloudflare/sandbox/adapter.ts +61 -36
  3. package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -2
  4. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +23 -18
  5. package/src/db/index.ts +5 -0
  6. package/src/db/schema.ts +15 -0
  7. package/src/db/telemetry-outbox.repo.ts +151 -0
  8. package/src/index.ts +1 -0
  9. package/src/kernel/approval-lifecycle.ts +35 -3
  10. package/src/kernel/bindings.ts +0 -1
  11. package/src/kernel/interaction-lifecycle.ts +35 -6
  12. package/src/layers/orchestration/temporary-agent/workspace.ts +4 -4
  13. package/src/lib/prompt.ts +22 -15
  14. package/src/pi/assembly/context.ts +2 -2
  15. package/src/pi/message/conversion.ts +13 -1
  16. package/src/pi/runtime-adapter/execution.ts +26 -26
  17. package/src/pi/runtime-adapter/models.ts +144 -44
  18. package/src/pi/tool/ai-adapter.ts +2 -2
  19. package/src/pi/tool/base.ts +31 -25
  20. package/src/pi/tool/compiler.ts +5 -103
  21. package/src/pi/turn/tool-recovery.ts +11 -1
  22. package/src/runtime-agent.ts +24 -0
  23. package/src/runtime-assembler.ts +29 -15
  24. package/src/runtime-definition.ts +2 -0
  25. package/src/runtime.ts +362 -20
  26. package/src/telemetry/contract.ts +389 -0
  27. package/src/telemetry/coordinator.ts +143 -0
  28. package/src/telemetry/delivery.ts +138 -0
  29. package/src/telemetry/ids.ts +60 -0
  30. package/src/telemetry/index.ts +7 -0
  31. package/src/telemetry/recorder.ts +61 -0
  32. package/src/telemetry/runtime-telemetry.ts +484 -0
  33. package/src/telemetry/sanitize.ts +97 -0
  34. package/src/tool-registry.ts +11 -11
  35. package/src/lib/telemetry-dev.ts +0 -47
@@ -71,6 +71,8 @@ interface InteractionLifecycleOptions<
71
71
  * 没有这个回调,park 的那一刻不会有人去重算 —— 侧栏要等下一次广播才翻牌。
72
72
  */
73
73
  readonly onInteractionsChanged?: () => Promise<void>;
74
+ readonly onTelemetryRequested?: (interaction: InteractionRecord) => void;
75
+ readonly onTelemetrySettled?: (interaction: InteractionRecord) => void;
74
76
  }
75
77
 
76
78
  // #endregion
@@ -131,9 +133,17 @@ export class InteractionLifecycle<
131
133
  interaction: PiToolInteraction,
132
134
  signal?: AbortSignal,
133
135
  ): Promise<void> {
134
- const pending = this.options.db.transaction(() =>
135
- this.ensurePending(submission, interaction)
136
- );
136
+ const pending = this.options.db.transaction(() => {
137
+ const value = this.ensurePending(submission, interaction);
138
+ if (value.created) {
139
+ try {
140
+ this.options.onTelemetryRequested?.(value.interaction);
141
+ } catch {
142
+ // Observability must not change interaction durability.
143
+ }
144
+ }
145
+ return value.interaction;
146
+ });
137
147
  // 必须在 wait 之前通知:wait 会一直挂到客户端投递,之后再通知就晚了一个回合。
138
148
  await this.options.onInteractionsChanged?.();
139
149
  return this.wait(pending, signal);
@@ -218,6 +228,14 @@ export class InteractionLifecycle<
218
228
  settlement: { kind: "cancel", reason },
219
229
  });
220
230
  this.options.applyRecoveryMutations(submission, decision.mutations);
231
+ const settled = this.read(interactionId);
232
+ if (settled) {
233
+ try {
234
+ this.options.onTelemetrySettled?.(settled);
235
+ } catch {
236
+ // Observability must not change terminal cleanup.
237
+ }
238
+ }
221
239
  }
222
240
  return true;
223
241
  }
@@ -267,6 +285,14 @@ export class InteractionLifecycle<
267
285
  if (!decision.applied) return { ok: false };
268
286
  this.options.db.transaction(() => {
269
287
  this.options.applyRecoveryMutations(submission, decision.mutations);
288
+ const settled = this.read(interactionId);
289
+ if (settled && settled.status !== "pending") {
290
+ try {
291
+ this.options.onTelemetrySettled?.(settled);
292
+ } catch {
293
+ // Observability must not change interaction settlement.
294
+ }
295
+ }
270
296
  });
271
297
  const persisted = this.read(interactionId);
272
298
  if (!persisted || persisted.status === "pending") {
@@ -320,7 +346,7 @@ export class InteractionLifecycle<
320
346
  private ensurePending(
321
347
  submission: TSubmission,
322
348
  interaction: PiToolInteraction,
323
- ): InteractionRecord {
349
+ ): { interaction: InteractionRecord; created: boolean } {
324
350
  const existing = this.read(interaction.interactionId);
325
351
  if (existing) {
326
352
  if (
@@ -333,7 +359,7 @@ export class InteractionLifecycle<
333
359
  `Conflicting durable Tool interaction: ${interaction.toolCallId}`,
334
360
  );
335
361
  }
336
- return existing;
362
+ return { interaction: existing, created: false };
337
363
  }
338
364
  // 表行和恢复里程碑都由 record-interaction 这一条命令产出,
339
365
  // 不在这里直接 insert —— 两份状态分叉正是恢复最难查的一类 bug。
@@ -342,7 +368,10 @@ export class InteractionLifecycle<
342
368
  interaction,
343
369
  });
344
370
  this.options.applyRecoveryMutations(submission, decision.mutations);
345
- return { ...interaction, submissionId: submission.submissionId };
371
+ return {
372
+ interaction: { ...interaction, submissionId: submission.submissionId },
373
+ created: true,
374
+ };
346
375
  }
347
376
 
348
377
  // 作用:等待当前进程里的客户端响应。
@@ -3,7 +3,7 @@ import type {
3
3
  WorkspacePort,
4
4
  } from "../../../kernel/bindings";
5
5
 
6
- const MEMORY_ROOT = "/shared/memories";
6
+ const MEMORY_ROOT = "/userspace/memories";
7
7
 
8
8
  // 作用:把 Workspace 路径统一成以 `/` 开头的正斜杠形式。
9
9
  // 调用:所有临时 Agent 路径在检查 Memory 边界前都经过这里。
@@ -21,7 +21,7 @@ function normalize(path: string): string {
21
21
 
22
22
  // 作用:判断一条路径是否指向共享 Memory 根目录或其子项。
23
23
  // 调用:`allowed` 拦截直接访问,`visible` 过滤目录和 glob 结果。
24
- // 原因:同时比较根路径和带 `/` 的子路径前缀,避免误伤 `/shared/memories-old`。
24
+ // 原因:同时比较根路径和带 `/` 的子路径前缀,避免误伤 `/userspace/memories-old`。
25
25
  function isMemoryPath(path: string): boolean {
26
26
  const normalized = normalize(path);
27
27
  return (
@@ -32,12 +32,12 @@ function isMemoryPath(path: string): boolean {
32
32
 
33
33
  // 作用:返回可交给底层 Workspace 的归一化路径。
34
34
  // 调用:临时 Agent Workspace 外观的每个路径参数都必须先经过它。
35
- // 原因:在一个共享边界拒绝 `/shared/memories`,比在每个上层 Tool 里分别检查更不容易漏掉新调用方。
35
+ // 原因:在一个共享边界拒绝 `/userspace/memories`,比在每个上层 Tool 里分别检查更不容易漏掉新调用方。
36
36
  function allowed(path: string): string {
37
37
  const normalized = normalize(path);
38
38
  if (isMemoryPath(normalized)) {
39
39
  throw new Error(
40
- "/shared/memories is not accessible to temporary agents",
40
+ "/userspace/memories is not accessible to temporary agents",
41
41
  );
42
42
  }
43
43
  return normalized;
package/src/lib/prompt.ts CHANGED
@@ -24,9 +24,10 @@ export const SYSTEM_PROMPT =
24
24
 
25
25
  // Explains how to reach capabilities instead of listing unsupported substitutes.
26
26
  export const RUNTIME =
27
- "Runtime: this agent runs on Cloudflare Workers. The execute Code Mode Dynamic Worker is your " +
28
- "instrument — you write code there and it runs with outbound network access (fetch), your " +
29
- "workspace filesystem (state.*), and your other tools (tools.*). Use it for raw or customized HTTP " +
27
+ "Runtime: this agent runs on Cloudflare Workers. When execute is present, its Code Mode Dynamic Worker is your " +
28
+ "instrument — code there runs with outbound network access (fetch), your " +
29
+ "workspace filesystem (state.*), and your other tools (tools.* when that connector is available). Put repeated or multi-step work " +
30
+ "in one execute instead of making consecutive top-level Tool calls. Use it for raw or customized HTTP " +
30
31
  "requests, parsing a payload, hitting several known endpoints, or computing over a file. Write plain " +
31
32
  "JavaScript — the sandbox evaluates " +
32
33
  "it directly, so TypeScript type annotations (`: number`, `as Type`) are a syntax error, and there " +
@@ -52,28 +53,34 @@ export const PLANNING =
52
53
 
53
54
  // Tool-selection guidance mirrors the actual approval and network boundaries.
54
55
  export const TOOLS =
55
- "Tools: Prefer the dedicated workspace file tools (read/write/edit/list/find/grep) for file work " +
56
- "they run without approval. For web tasks, use web_search for web discovery, current facts, cited research, " +
57
- "and public URL analysis. Use execute Code Mode only for raw or customized network requests, structured " +
56
+ "Tools: When execute is present, call a top-level Tool directly only for a single standalone Tool call or when that Tool is unavailable " +
57
+ "inside execute. Any operation that needs the same Tool more than once, two or more related file, Skill, Extension, MCP, or Host Tool calls, " +
58
+ "or branching or repetition MUST use one execute. Do not make repeated top-level calls for that work. Inside execute, use state.* for " +
59
+ "workspace file operations, tools.* for other host capabilities, a loop for repeated calls, and Promise.all for independent non-CDP calls. " +
60
+ "Tools available only at the top level must stay Direct. " +
61
+ "For web tasks, use web_search for web discovery, current facts, cited research, " +
62
+ "and public URL analysis. For web access specifically, use execute Code Mode only for raw or customized network requests, structured " +
58
63
  "API calls, or when web_search cannot retrieve the required content; do not use execute for ordinary web " +
59
- "searches. When sandbox_* tools are present, use that isolated Linux environment for Python/Node, " +
64
+ "searches. When execute is absent, use the actually exposed Direct Tools. When sandbox_* tools are present, use that isolated Linux environment for Python/Node, " +
60
65
  "package managers, system commands, builds, tests and background processes; its filesystem is temporary, " +
61
- "persistent inputs are copied in automatically on first use, /shared is read-only, and only explicitly " +
66
+ "persistent inputs are copied in automatically on first use, /userspace is read-only, and only explicitly " +
62
67
  "published /workspace outputs survive. Use Code Mode for computation, multi-step data work, and the " +
63
68
  "raw or customized network cases described above; every response and failure it sees is visible to you. " +
64
69
  "bash is a shell over the workspace filesystem only " +
65
70
  "(no network, no system utilities) and is approval-gated — don't reach for it to read files or fetch. " +
66
- "For more than three related changes in one file, use one write or bash operation instead of serial edits; " +
67
- "do not re-plan or explain between consecutive tool calls. When a run is within the last five model turns, stop expanding scope and " +
71
+ "When execute is present, make related file changes in one execute with state.*; when execute is absent, use one write or bash operation instead of serial edits. " +
72
+ "Do not re-plan or explain between consecutive tool calls. When a run is within the last five model turns, stop expanding scope and " +
68
73
  "prioritize verification, saving durable results, and the final response. " +
69
- "When tool calls are independent, issue them in one turn so they run in parallel. When you reference " +
74
+ "When related Tool calls can run independently and execute is present, run them inside that execute rather than as parallel top-level calls; " +
75
+ "only independent top-level-only Direct Tools should be issued together in one turn. When you reference " +
70
76
  "code, cite it as file_path:line_number.";
71
77
 
72
78
  // Uploaded-file routing prevents lossy markdown conversions from becoming data sources.
73
79
  export const FILES =
74
80
  "Uploaded files live under /uploads/ in your workspace; convertible formats have a companion " +
75
- "'<file>.md' (structured markdown). Policy: to summarize/quote/search, read or grep the .md; " +
76
- "to compute/aggregate/transform (especially csv/xlsx), write code in execute Code Mode or the Linux Sandbox that reads " +
81
+ "'<file>.md' (structured markdown). Policy: to summarize/quote/search one file, read or grep the .md; when execute is present, " +
82
+ "read or search multiple files through state.* in one execute instead of repeated top-level file Tool calls. " +
83
+ "To compute/aggregate/transform (especially csv/xlsx), write code in execute Code Mode or the Linux Sandbox that reads " +
77
84
  "the ORIGINAL file — converted markdown tables are not for computation, and large spreadsheets may " +
78
85
  "have no .md at all. For formats without a companion .md (e.g. pptx: unzip and read ppt/slides/*.xml; " +
79
86
  "zip archives; unknown types), parse the original in the sandbox with JS.";
@@ -93,9 +100,9 @@ export const INTERACTION =
93
100
  export const MEMORY =
94
101
  "Memory conventions: memory/preferences context blocks are writable working memory " +
95
102
  "for this Session only. Cold memory is managed explicitly by the user and is shared " +
96
- "across Sessions under /shared/memories/. Runtime access to that directory is read-only: " +
103
+ "across Sessions under /userspace/memories/. Runtime access to that directory is read-only: " +
97
104
  "never create, edit, move, or delete its files. To recall long-tail information, check " +
98
- "the memory_index block first, then read the listed entry under /shared/memories/ when relevant.";
105
+ "the memory_index block first, then read the listed entry under /userspace/memories/ when relevant.";
99
106
 
100
107
  /**
101
108
  * 把 Agent 个性和 Runtime 的固定行为说明组成主 Agent 系统提示词。
@@ -41,7 +41,7 @@ const COMPACTION_RETRY: RetryPolicy = {
41
41
  baseDelayMs: 500,
42
42
  };
43
43
 
44
- const COLD_MEMORY_INDEX = "/shared/memories/MEMORY.md";
44
+ const COLD_MEMORY_INDEX = "/userspace/memories/MEMORY.md";
45
45
  const TRUNCATION_MARKER = "\n[truncated to memory budget]";
46
46
 
47
47
  function sumUsage(responses: readonly AssistantMessage[]): Usage | undefined {
@@ -350,7 +350,7 @@ async function readMemory(
350
350
  blocks.push(
351
351
  renderBlock(
352
352
  "MEMORY_INDEX",
353
- "Index of user-managed cold memory under /shared/memories; entries are read on demand",
353
+ "Index of user-managed cold memory under /userspace/memories; entries are read on demand",
354
354
  index,
355
355
  ),
356
356
  );
@@ -21,7 +21,19 @@ function attachmentText(
21
21
  part: Extract<UIMessage["parts"][number], { type: "file" }>,
22
22
  ): string {
23
23
  const name = part.filename?.trim() || "attachment";
24
- return `[Attachment: ${name} (${part.mediaType}) ${part.url}]`;
24
+ let ref: string | null = null;
25
+ try {
26
+ const path = part.url.startsWith("/")
27
+ ? new URL(part.url, "https://attachment.invalid").pathname
28
+ : new URL(part.url).pathname;
29
+ const leaf = path.split("/").filter(Boolean).at(-1);
30
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(leaf ?? "")) {
31
+ ref = leaf!;
32
+ }
33
+ } catch {
34
+ // Preserve the existing canonical form for non-platform file URLs.
35
+ }
36
+ return `[Attachment: ${name} (${part.mediaType}) ${ref ? `ref=${ref}` : part.url}]`;
25
37
  }
26
38
 
27
39
  function userContent(
@@ -32,7 +32,6 @@ import {
32
32
  type PiToolCandidate,
33
33
  type PiToolInteractionSpec,
34
34
  type PiToolGovernance,
35
- type PiToolTelemetry,
36
35
  type SettledPiToolCall,
37
36
  } from "../tool";
38
37
  import {
@@ -45,6 +44,7 @@ import {
45
44
  isModelStreamStallMessage,
46
45
  resolvePiApiKey,
47
46
  withProviderRetry,
47
+ type PiGenerationLifecycleObserver,
48
48
  } from "./models";
49
49
  import { EXECUTION_LEVELS } from "../../lib/execution-level";
50
50
  import { projectToolOutputForModel } from "../../layers/context/budget/gate";
@@ -130,10 +130,7 @@ interface PiTurnAdapterOptions {
130
130
  readonly settle: (
131
131
  call: SettledPiToolCall,
132
132
  ) => void | Promise<void>;
133
- // 上报工具执行耗时、结果大小和成败。
134
- // 工具治理层在每次执行结束时调用它,宿主可选择不提供。
135
- // 该回调只负责观测,工具治理层会吞掉它的异常以免改变执行结果。
136
- readonly onToolTelemetry?: (event: PiToolTelemetry) => void;
133
+ readonly onGeneration?: PiGenerationLifecycleObserver;
137
134
  // 在模型调用前调整 Pi 的消息上下文。
138
135
  // PiCore 通过 AgentOptions.transformContext 调用它,Runtime 用它接入现有上下文处理。
139
136
  // 直接复用 Pi 的回调类型可避免这里维护另一套上下文转换约定。
@@ -173,7 +170,7 @@ class PiTurnAdapter {
173
170
  // PreparedPiTurnAdapter 只构造一次,并把运行、重试、转向和中止都交给它。
174
171
  // 工具治理对象必须在这些路径间复用,因为它保存本 Turn 的重试次数和熔断状态。
175
172
  constructor(private readonly opts: PiTurnAdapterOptions) {
176
- this.governance = createPiToolGovernance(opts.onToolTelemetry);
173
+ this.governance = createPiToolGovernance();
177
174
  }
178
175
 
179
176
  // 把 Tool candidate 变成 Pi 可以执行的受治理 Tool。
@@ -242,6 +239,7 @@ class PiTurnAdapter {
242
239
  this.opts.models.streamSimple.bind(this.opts.models),
243
240
  0,
244
241
  this.opts.modelSessionId,
242
+ this.opts.onGeneration,
245
243
  ),
246
244
  getApiKey: () => this.opts.apiKey,
247
245
  // transformMessages 在宿主上下文变换之后运行,确保跨 provider 的 tool call ID 格式兼容。
@@ -404,18 +402,13 @@ export interface CreatePreparedPiTurnOptions {
404
402
  */
405
403
  readonly canonicalMessages: () => Promise<readonly AgentMessage[]>;
406
404
  readonly durability: PiTurnDurability;
405
+ /** Reports paired model-generation lifecycle facts to the Runtime owner. */
406
+ readonly onGeneration?: PiGenerationLifecycleObserver;
407
407
  /** Per-Submission executors for tools whose metadata is fixed at assembly time. */
408
408
  readonly toolExecutors?: Readonly<Record<
409
409
  string,
410
410
  NonNullable<PiToolCandidate["tool"]["execute"]>
411
411
  >>;
412
- /**
413
- * 上报受治理工具的耗时、结果大小和成败。
414
- *
415
- * 工具治理层在执行结束时调用它,Runtime 可用它发出工具遥测事件。
416
- * 该回调只负责观测,工具治理层会隔离它的异常,避免遥测改变工具结果。
417
- */
418
- readonly onToolTelemetry?: (event: PiToolTelemetry) => void;
419
412
  /**
420
413
  * 调整 Pi 即将发送给模型的消息上下文。
421
414
  *
@@ -532,6 +525,7 @@ export class PreparedPiTurnAdapter {
532
525
  const bindCandidate = (
533
526
  candidate: PiToolCandidate,
534
527
  toolCallIdPrefix?: string,
528
+ recordRecoveryAttempt = true,
535
529
  ): PiToolCandidate => ({
536
530
  ...candidate,
537
531
  tool: {
@@ -653,17 +647,19 @@ export class PreparedPiTurnAdapter {
653
647
  }
654
648
  return responded.result;
655
649
  }
656
- const retry = piToolRetryPolicy(candidate);
657
- const firstAttempt = options.durability.appendToolInput({
658
- toolCallId,
659
- toolName: candidate.tool.name,
660
- input,
661
- retry,
662
- });
663
- if (!firstAttempt && retry === "non-idempotent") {
664
- throw new Error(
665
- `Non-idempotent Tool outcome is uncertain after recovery: ${candidate.tool.name}`,
666
- );
650
+ if (recordRecoveryAttempt) {
651
+ const retry = piToolRetryPolicy(candidate);
652
+ const firstAttempt = options.durability.appendToolInput({
653
+ toolCallId,
654
+ toolName: candidate.tool.name,
655
+ input,
656
+ retry,
657
+ });
658
+ if (!firstAttempt && retry === "non-idempotent") {
659
+ throw new Error(
660
+ `Non-idempotent Tool outcome is uncertain after recovery: ${candidate.tool.name}`,
661
+ );
662
+ }
667
663
  }
668
664
  const execute = options.toolExecutors?.[candidate.tool.name] ??
669
665
  candidate.tool.execute;
@@ -690,7 +686,11 @@ export class PreparedPiTurnAdapter {
690
686
  const runtimeCandidate = codeExecutionPiToolCandidate(
691
687
  factory.create(toolRegistryFromPiCandidates(
692
688
  candidate.codeExecutionTools!.map((inner) =>
693
- bindCandidate(inner, toolCallId)
689
+ // Code Mode owns replay of its connector calls. Pi persists
690
+ // only the parent execute attempt/result; recording an inner
691
+ // input without a Pi settlement creates an orphan recovery
692
+ // action that cannot be found on the direct Tool surface.
693
+ bindCandidate(inner, toolCallId, false)
694
694
  ),
695
695
  )),
696
696
  );
@@ -715,13 +715,13 @@ export class PreparedPiTurnAdapter {
715
715
  state.snapshot.pi.model.id,
716
716
  ),
717
717
  modelSessionId: options.submission.requestId,
718
+ ...(options.onGeneration ? { onGeneration: options.onGeneration } : {}),
718
719
  canonicalMessages: options.canonicalMessages,
719
720
  // 被中断的 Turn 不写结算。它退场路上还会吐出「工具被中止」,而工具其实
720
721
  // 没有产生任何结果 —— 结算是单调的,落了这条假结果,重建出来的续跑会
721
722
  // 把一个从没跑过的工具当成跑过并失败。取消不走这里:取消要的就是终态。
722
723
  settle: (call) =>
723
724
  this.interrupted ? undefined : options.durability.settleTool(call),
724
- onToolTelemetry: options.onToolTelemetry,
725
725
  transformContext: options.transformContext,
726
726
  workspace: state.snapshot.bindings.workspace,
727
727
  });
@@ -43,10 +43,6 @@ import {
43
43
  import {
44
44
  ChatStreamStalledError,
45
45
  } from "agents/chat";
46
- import {
47
- genericObservability,
48
- type ObservabilityEvent,
49
- } from "agents/observability";
50
46
  import type {
51
47
  RuntimeModelEndpoint,
52
48
  RuntimeModelProtocol,
@@ -87,6 +83,35 @@ export interface ModelStreamStallDetails {
87
83
  idleMs: number;
88
84
  }
89
85
 
86
+ export type PiGenerationLifecycleEvent =
87
+ | {
88
+ readonly type: "started";
89
+ readonly generationId: string;
90
+ readonly timestamp: number;
91
+ readonly api: string;
92
+ readonly provider: string;
93
+ readonly model: string;
94
+ readonly url: string;
95
+ readonly input?: unknown;
96
+ }
97
+ | {
98
+ readonly type: "finished";
99
+ readonly generationId: string;
100
+ readonly timestamp: number;
101
+ readonly durationMs: number;
102
+ readonly outcome: "success" | "error" | "cancelled";
103
+ readonly stopReason?: string;
104
+ readonly errorName?: string;
105
+ readonly upstreamRequestId?: string;
106
+ readonly responseStatus?: number;
107
+ readonly usage?: AssistantMessage["usage"];
108
+ readonly output?: AssistantMessage;
109
+ };
110
+
111
+ export type PiGenerationLifecycleObserver = (
112
+ event: PiGenerationLifecycleEvent,
113
+ ) => void;
114
+
90
115
  export function isModelStreamStallMessage(message?: string): message is string {
91
116
  return message === MODEL_STREAM_STALL_MESSAGE ||
92
117
  message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
@@ -194,11 +219,11 @@ function meaningfulModelProgress(
194
219
  }
195
220
 
196
221
  async function* stopStalledModelStream(
197
- source: AsyncIterable<AssistantMessageEvent>,
222
+ source: Promise<AsyncIterable<AssistantMessageEvent>>,
198
223
  watchdog: AbortController,
199
224
  probe: (phase: string, details?: Record<string, unknown>) => void,
200
225
  ): AsyncGenerator<AssistantMessageEvent> {
201
- const iterator = source[Symbol.asyncIterator]();
226
+ let iterator: AsyncIterator<AssistantMessageEvent> | undefined;
202
227
  let timer: ReturnType<typeof setTimeout> | undefined;
203
228
  let stalled = false;
204
229
  let stallError: ChatStreamStalledError | undefined;
@@ -246,7 +271,13 @@ async function* stopStalledModelStream(
246
271
  try {
247
272
  while (true) {
248
273
  const waitStartedAt = Date.now();
249
- const nextPromise = iterator.next();
274
+ const nextPromise = iterator
275
+ ? iterator.next()
276
+ : source.then((stream) => {
277
+ const resolved = stream[Symbol.asyncIterator]();
278
+ iterator = resolved;
279
+ return resolved.next();
280
+ });
250
281
  nextPromise.catch(() => {});
251
282
  let next: IteratorResult<AssistantMessageEvent>;
252
283
  try {
@@ -259,7 +290,7 @@ async function* stopStalledModelStream(
259
290
  ]);
260
291
  } catch (error) {
261
292
  if (stalled) throw stallError;
262
- probe("iterator_error", {
293
+ probe(iterator ? "iterator_error" : "dispatch_error", {
263
294
  errorName: error instanceof Error ? error.name : typeof error,
264
295
  rawEventCount,
265
296
  meaningfulEventCount,
@@ -284,6 +315,10 @@ async function* stopStalledModelStream(
284
315
  stopReason: event.type === "done"
285
316
  ? event.message.stopReason
286
317
  : event.error.stopReason,
318
+ usage: event.type === "done"
319
+ ? event.message.usage
320
+ : event.error.usage,
321
+ output: event.type === "done" ? event.message : event.error,
287
322
  rawEventCount,
288
323
  meaningfulEventCount,
289
324
  });
@@ -305,7 +340,7 @@ async function* stopStalledModelStream(
305
340
  }
306
341
  } finally {
307
342
  clearTimeout(timer);
308
- if (!stalled) await iterator.return?.().catch(() => {});
343
+ if (!stalled) await iterator?.return?.().catch(() => {});
309
344
  }
310
345
  probe("ended_without_terminal", {
311
346
  rawEventCount,
@@ -354,23 +389,15 @@ export function modelRequestUrl(model: Model<Api>): string {
354
389
  * 调用方可以覆盖默认的 2 次 Provider 重试;主 Turn 传 0,由 Submission
355
390
  * 统一持有恢复预算。连续 {@link MODEL_STREAM_STALL_TIMEOUT_MS} 毫秒没有可展示进展时中止 provider。
356
391
  *
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` 承担。
392
+ * 可选 observer 接收配对的 generation started/finished 事实及实际模型输入输出;响应头仅保留
393
+ * 白名单 request id,永不携带 key 或任意响应 header。正文由 telemetry 隐私模式统一脱敏。
394
+ * observer 的异常被隔离,不能改变模型执行结果。
369
395
  */
370
396
  export function withProviderRetry(
371
397
  streamFn: StreamFn,
372
398
  defaultMaxRetries = PROVIDER_MAX_RETRIES,
373
399
  defaultSessionId?: string,
400
+ onGeneration?: PiGenerationLifecycleObserver,
374
401
  ): StreamFn {
375
402
  return (model, context, options) =>
376
403
  lazyStream(model, async () => {
@@ -378,30 +405,102 @@ export function withProviderRetry(
378
405
  const startedAt = Date.now();
379
406
  const sessionId = options?.sessionId ?? defaultSessionId;
380
407
  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");
408
+ const notify = (event: PiGenerationLifecycleEvent) => {
409
+ try {
410
+ onGeneration?.(event);
411
+ } catch {
412
+ // Observability must not change model execution.
413
+ }
414
+ };
415
+ notify({
416
+ type: "started",
417
+ generationId: requestId,
418
+ timestamp: startedAt,
419
+ api: model.api,
420
+ provider: model.provider,
421
+ model: model.id,
422
+ url,
423
+ input: {
424
+ ...(context.systemPrompt ? { systemPrompt: context.systemPrompt } : {}),
425
+ messages: context.messages,
426
+ },
427
+ });
401
428
  const watchdog = new AbortController();
402
429
  let responseCount = 0;
430
+ let upstreamRequestId: string | undefined;
431
+ let responseStatus: number | undefined;
432
+ let finished = false;
433
+ const finish = (
434
+ outcome: "success" | "error" | "cancelled",
435
+ details: {
436
+ stopReason?: string;
437
+ errorName?: string;
438
+ usage?: AssistantMessage["usage"];
439
+ output?: AssistantMessage;
440
+ } = {},
441
+ ) => {
442
+ if (finished) return;
443
+ finished = true;
444
+ notify({
445
+ type: "finished",
446
+ generationId: requestId,
447
+ timestamp: Date.now(),
448
+ durationMs: Math.max(0, Date.now() - startedAt),
449
+ outcome,
450
+ ...details,
451
+ ...(upstreamRequestId ? { upstreamRequestId } : {}),
452
+ ...(responseStatus === undefined ? {} : { responseStatus }),
453
+ });
454
+ };
455
+ const probe = (phase: string, details: Record<string, unknown> = {}) => {
456
+ if (phase === "response_headers") {
457
+ if (typeof details.upstreamRequestId === "string") {
458
+ upstreamRequestId = details.upstreamRequestId;
459
+ }
460
+ if (typeof details.status === "number") {
461
+ responseStatus = details.status;
462
+ }
463
+ return;
464
+ }
465
+ if (phase === "done") {
466
+ finish("success", {
467
+ ...(typeof details.stopReason === "string"
468
+ ? { stopReason: details.stopReason }
469
+ : {}),
470
+ ...(details.usage
471
+ ? { usage: details.usage as AssistantMessage["usage"] }
472
+ : {}),
473
+ ...(details.output
474
+ ? { output: details.output as AssistantMessage }
475
+ : {}),
476
+ });
477
+ return;
478
+ }
479
+ if (
480
+ phase === "error" ||
481
+ phase === "stall" ||
482
+ phase === "iterator_error" ||
483
+ phase === "dispatch_error" ||
484
+ phase === "ended_without_terminal"
485
+ ) {
486
+ finish(options?.signal?.aborted ? "cancelled" : "error", {
487
+ ...(typeof details.stopReason === "string"
488
+ ? { stopReason: details.stopReason }
489
+ : {}),
490
+ ...(typeof details.errorName === "string"
491
+ ? { errorName: details.errorName }
492
+ : {}),
493
+ ...(details.usage
494
+ ? { usage: details.usage as AssistantMessage["usage"] }
495
+ : {}),
496
+ ...(details.output
497
+ ? { output: details.output as AssistantMessage }
498
+ : {}),
499
+ });
500
+ }
501
+ };
403
502
  try {
404
- const source = await streamFn(model, context, {
503
+ const sourcePromise = Promise.resolve(streamFn(model, context, {
405
504
  ...options,
406
505
  signal: options?.signal
407
506
  ? AbortSignal.any([options.signal, watchdog.signal])
@@ -423,8 +522,9 @@ export function withProviderRetry(
423
522
  });
424
523
  await options?.onResponse?.(response, activeModel);
425
524
  },
426
- });
427
- return stopStalledModelStream(source, watchdog, probe);
525
+ }));
526
+ sourcePromise.catch(() => {});
527
+ return stopStalledModelStream(sourcePromise, watchdog, probe);
428
528
  } catch (error) {
429
529
  probe("dispatch_error", {
430
530
  errorName: error instanceof Error ? error.name : typeof error,
@@ -89,9 +89,9 @@ function modelContent(
89
89
  *
90
90
  * Workspace、Skill 和其他 ai-sdk 工具工厂在把候选项交给 `compilePiTools()` 前调用。
91
91
  *
92
- * 实现必须在一处同时对齐 schema、execute 参数和结果形状,否则 Pi 的校验、取消信号和 artifact 计量会绕过统一工具边界。
92
+ * 实现必须在一处同时对齐 schema、execute 参数和结果形状,否则 Pi 的校验、取消信号和 artifact 处理会绕过统一工具边界。
93
93
  *
94
- * @remarks `inputSchema` 经 ai@7 `asSchema()` 转为 JSON Schema;原始返回值保留在 `details` 中,供 `resultPressure()` 识别 `artifact_ref`。
94
+ * @remarks `inputSchema` 经 ai@7 `asSchema()` 转为 JSON Schema;原始返回值保留在 `details` 中。
95
95
  */
96
96
  export function aiToolToPi(
97
97
  name: string,