@springbrand/agent-runtime 0.2.0-alpha.15 → 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 (36) 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 +2 -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/assembly.ts +1 -0
  17. package/src/pi/runtime-adapter/execution.ts +63 -27
  18. package/src/pi/runtime-adapter/models.ts +144 -44
  19. package/src/pi/tool/ai-adapter.ts +2 -2
  20. package/src/pi/tool/base.ts +31 -25
  21. package/src/pi/tool/compiler.ts +6 -102
  22. package/src/pi/turn/tool-recovery.ts +11 -1
  23. package/src/runtime-agent.ts +24 -0
  24. package/src/runtime-assembler.ts +38 -19
  25. package/src/runtime-definition.ts +2 -0
  26. package/src/runtime.ts +362 -20
  27. package/src/telemetry/contract.ts +389 -0
  28. package/src/telemetry/coordinator.ts +143 -0
  29. package/src/telemetry/delivery.ts +138 -0
  30. package/src/telemetry/ids.ts +60 -0
  31. package/src/telemetry/index.ts +7 -0
  32. package/src/telemetry/recorder.ts +61 -0
  33. package/src/telemetry/runtime-telemetry.ts +484 -0
  34. package/src/telemetry/sanitize.ts +97 -0
  35. package/src/tool-registry.ts +18 -11
  36. package/src/lib/telemetry-dev.ts +0 -47
@@ -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,
@@ -276,31 +276,37 @@ export function basePiToolCandidates(
276
276
  },
277
277
  },
278
278
  },
279
- candidate({
280
- name: "suggest_followups",
281
- label: "Suggest follow-ups",
282
- description:
283
- "Offer the user 2-4 optional follow-up directions after completing a substantive task (a report, an analysis, a multi-step job). Call this at most once, and it MUST be the very last thing you do: finish all of your prose FIRST, then call this tool and END the turn immediately — do NOT write any text after calling it. Do NOT call it for small talk, quick answers, or while a task is still in progress.",
284
- parameters: suggestFollowupsParameters,
285
- async execute(_toolCallId, input) {
286
- return result({ noted: true, count: input.items.length });
287
- },
288
- }),
289
- candidate({
290
- name: "update_plan",
291
- label: "Update plan",
292
- description:
293
- "Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
294
- parameters: updatePlanParameters,
295
- prepareArguments: normalizeUpdatePlanArguments,
296
- async execute(_toolCallId, input) {
297
- return result({
298
- ok: true,
299
- total: input.steps.length,
300
- done: input.steps.filter((step) => step.status === "done").length,
301
- });
302
- },
303
- }),
279
+ {
280
+ ...candidate({
281
+ name: "suggest_followups",
282
+ label: "Suggest follow-ups",
283
+ description:
284
+ "Offer the user 2-4 optional follow-up directions after completing a substantive task (a report, an analysis, a multi-step job). Call this at most once, and it MUST be the very last thing you do: finish all of your prose FIRST, then call this tool and END the turn immediately — do NOT write any text after calling it. Do NOT call it for small talk, quick answers, or while a task is still in progress.",
285
+ parameters: suggestFollowupsParameters,
286
+ async execute(_toolCallId, input) {
287
+ return result({ noted: true, count: input.items.length });
288
+ },
289
+ }),
290
+ direct: true,
291
+ },
292
+ {
293
+ ...candidate({
294
+ name: "update_plan",
295
+ label: "Update plan",
296
+ description:
297
+ "Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
298
+ parameters: updatePlanParameters,
299
+ prepareArguments: normalizeUpdatePlanArguments,
300
+ async execute(_toolCallId, input) {
301
+ return result({
302
+ ok: true,
303
+ total: input.steps.length,
304
+ done: input.steps.filter((step) => step.status === "done").length,
305
+ });
306
+ },
307
+ }),
308
+ direct: true,
309
+ },
304
310
  ...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
305
311
  ];
306
312
  }
@@ -46,6 +46,10 @@ export interface PiToolInteractionSpec {
46
46
  export interface PiToolCandidate {
47
47
  readonly owner: string;
48
48
  readonly tool: AgentTool<any, any>;
49
+ /** Keep this Tool Direct-only instead of also offering it through Code Mode. */
50
+ readonly direct?: true;
51
+ /** @internal Tools also callable through this Code Mode candidate. */
52
+ readonly codeExecutionTools?: readonly PiToolCandidate[];
49
53
  /** Conservative maximum used in the stable Runtime descriptor. */
50
54
  readonly requiredExecutionLevel: ExecutionLevel;
51
55
  /** Trusted parameter-level policy, evaluated before approval or dispatch. */
@@ -76,15 +80,6 @@ export interface CompilePiToolsOptions {
76
80
  ) => void | Promise<void>;
77
81
  }
78
82
 
79
- /** 记录一次 Pi 工具调用的耗时、结果大小和溢出状态。 */
80
- export interface PiToolTelemetry {
81
- readonly tool: string;
82
- readonly ms: number;
83
- readonly ok: boolean;
84
- readonly bytes: number;
85
- readonly spill: boolean;
86
- }
87
-
88
83
  const governanceState = Symbol("PiToolGovernanceState");
89
84
 
90
85
  /** 向 Pi Agent 提供工具后置钩子,并为编译器保留同一 Turn 的治理状态。 */
@@ -114,7 +109,6 @@ interface PiToolGovernanceState {
114
109
  readonly terminalCalls: Set<string>;
115
110
  totalFailures: number;
116
111
  aborted: boolean;
117
- readonly telemetry?: (event: PiToolTelemetry) => void;
118
112
  }
119
113
 
120
114
  // #endregion
@@ -161,57 +155,6 @@ function callKey(toolName: string, args: unknown): string {
161
155
  return `${toolName}\0${(hash >>> 0).toString(36)}`;
162
156
  }
163
157
 
164
- // 估算工具结果给 Durable Object 带来的字节压力。
165
- // `emitToolTelemetry()` 在每次成功、失败或阻断后调用它。
166
- // artifact_ref 已把大结果外溢,因此必须使用 details 中的原始字节数而不是小引用自身的大小。
167
- function resultPressure(result: AgentToolResult<unknown>): {
168
- bytes: number;
169
- spill: boolean;
170
- } {
171
- const details = result.details as {
172
- kind?: string;
173
- bytes?: number;
174
- } | undefined;
175
- if (
176
- details?.kind === "artifact_ref" &&
177
- typeof details.bytes === "number"
178
- ) {
179
- return { bytes: details.bytes, spill: true };
180
- }
181
- try {
182
- return {
183
- bytes: JSON.stringify(result).length,
184
- spill: false,
185
- };
186
- } catch {
187
- return { bytes: 0, spill: false };
188
- }
189
- }
190
-
191
- // 尽力把一次工具调用的计量事件交给可选遥测端口。
192
- // 受治理 execute 方法在每条终止路径上调用。
193
- // 遥测是观测而非业务逻辑,所以端口抛错必须被吞掉,不能改变工具结果。
194
- function emitToolTelemetry(
195
- state: PiToolGovernanceState | undefined,
196
- tool: string,
197
- startedAt: number,
198
- ok: boolean,
199
- result: AgentToolResult<unknown>,
200
- ): void {
201
- if (!state?.telemetry) return;
202
- const pressure = resultPressure(result);
203
- try {
204
- state.telemetry({
205
- tool,
206
- ms: Math.max(0, Math.round(performance.now() - startedAt)),
207
- ok,
208
- ...pressure,
209
- });
210
- } catch {
211
- // Observability must not change Tool execution.
212
- }
213
- }
214
-
215
158
  // Most Pi adapters keep the original Tool value in details and its serialized
216
159
  // model form in one text block. Spill that original once instead of duplicating
217
160
  // it inside an AgentToolResult JSON envelope.
@@ -311,15 +254,12 @@ function blockReason(
311
254
  *
312
255
  * 状态用 symbol 绑定到钩子对象,让编译后的工具和 Pi 后置钩子共享一份 Turn 计数而不暴露公开可变 API。
313
256
  */
314
- export function createPiToolGovernance(
315
- telemetry?: (event: PiToolTelemetry) => void,
316
- ): PiToolGovernance {
257
+ export function createPiToolGovernance(): PiToolGovernance {
317
258
  const state: PiToolGovernanceState = {
318
259
  failures: new Map(),
319
260
  terminalCalls: new Set(),
320
261
  totalFailures: 0,
321
262
  aborted: false,
322
- telemetry,
323
263
  };
324
264
  const governance: PiToolGovernance = {
325
265
  [governanceState]: state,
@@ -336,7 +276,7 @@ export function createPiToolGovernance(
336
276
  return governance;
337
277
  }
338
278
 
339
- // 为一个候选工具包上输出限额、持久化结算、重试治理和遥测。
279
+ // 为一个候选工具包上输出限额、持久化结算和重试治理。
340
280
  // `compilePiTools()` 对最终 Tool Surface 中的每个候选项调用。
341
281
  // 一个共享包装边界可确保所有工具无论成功还是失败都经过 settle,不能由各工具自行选择是否持久化。
342
282
  function governedTool(
@@ -353,7 +293,6 @@ function governedTool(
353
293
  // Pi 工具循环选中编译后的工具时调用,调用方应传入稳定 toolCall id 供结算去重。
354
294
  // 输出限额必须发生在 settle 之前,否则 Durable Object 会持久化一份与模型最终所见不同的过大结果。
355
295
  async execute(toolCallId, args, signal, onUpdate) {
356
- const startedAt = performance.now();
357
296
  const blocked = blockReason(
358
297
  state,
359
298
  toolCallId,
@@ -378,22 +317,8 @@ function governedTool(
378
317
  source: candidate.source,
379
318
  });
380
319
  } catch (settlementFailure) {
381
- emitToolTelemetry(
382
- state,
383
- candidate.tool.name,
384
- startedAt,
385
- false,
386
- result,
387
- );
388
320
  throw settlementFailure;
389
321
  }
390
- emitToolTelemetry(
391
- state,
392
- candidate.tool.name,
393
- startedAt,
394
- false,
395
- result,
396
- );
397
322
  throw cause;
398
323
  }
399
324
 
@@ -432,13 +357,6 @@ function governedTool(
432
357
  candidate.tool.name,
433
358
  args,
434
359
  );
435
- emitToolTelemetry(
436
- state,
437
- candidate.tool.name,
438
- startedAt,
439
- false,
440
- result,
441
- );
442
360
  const boundedMessage = (result.content[0] as { text: string }).text;
443
361
  throw cause instanceof Error &&
444
362
  failure === cause &&
@@ -468,25 +386,11 @@ function governedTool(
468
386
  candidate.tool.name,
469
387
  args,
470
388
  );
471
- emitToolTelemetry(
472
- state,
473
- candidate.tool.name,
474
- startedAt,
475
- false,
476
- result,
477
- );
478
389
  throw cause;
479
390
  }
480
391
  state?.failures.delete(
481
392
  callKey(candidate.tool.name, args),
482
393
  );
483
- emitToolTelemetry(
484
- state,
485
- candidate.tool.name,
486
- startedAt,
487
- true,
488
- result,
489
- );
490
394
  return result;
491
395
  },
492
396
  };
@@ -732,7 +732,17 @@ export function planPiToolRecovery(
732
732
  }
733
733
 
734
734
  // 已结算 Tool 先补齐续跑;未结算 Tool 只有声明幂等时才自动重试。
735
- for (const tool of Object.values(state.toolCalls)) {
735
+ const tools = Object.values(state.toolCalls);
736
+ const codeModeParents = tools
737
+ .filter((tool) => tool.toolName === "execute")
738
+ .map((tool) => `${tool.toolCallId}:`);
739
+ for (const tool of tools) {
740
+ // 旧版把 Code Mode 内层调用写成独立 Pi attempt,但只有父
741
+ // execute 会产生 settlement。这些带父调用前缀的记录不是可
742
+ // 独立恢复的 Tool;父 execute 才是唯一恢复边界。
743
+ if (codeModeParents.some((prefix) => tool.toolCallId.startsWith(prefix))) {
744
+ continue;
745
+ }
736
746
  if (tool.result) {
737
747
  const key = `tool:${tool.toolCallId}:settled`;
738
748
  const plan = tool.needsContinuation
@@ -56,6 +56,7 @@ import type {
56
56
  RuntimeAgentRole,
57
57
  RuntimeAgentToolResult,
58
58
  } from "./runtime-agent-context";
59
+ import type { AgentTelemetryBinding } from "./telemetry/contract";
59
60
 
60
61
  /**
61
62
  * 本文件把应用提供的 Config Definition 与 Planner 接到 Cloudflare Agent 生命周期。
@@ -111,6 +112,11 @@ interface RuntimeAgentDefinitionBase<
111
112
  | RuntimeAgentHooks<Env, Config, Command, Change>
112
113
  | ((context: RuntimeAgentPlanningContext<Env, Command, Change>) =>
113
114
  RuntimeAgentHooks<Env, Config, Command, Change>);
115
+
116
+ /** Prepare the optional telemetry consumer for this concrete Agent facet. */
117
+ readonly telemetry?: (
118
+ context: RuntimeAgentPlanningContext<Env, Command, Change>,
119
+ ) => AgentTelemetryBinding | undefined;
114
120
  }
115
121
 
116
122
  export interface ReadonlyRuntimeAgentDefinition<
@@ -415,11 +421,23 @@ export function defineRuntimeAgent<
415
421
  : hooks as RuntimeAgentHooks<Env, Config, Command, Change>;
416
422
  };
417
423
 
424
+ const resolveDefinitionTelemetry = (
425
+ context: RuntimeAgentPlanningContext<Env, Command, Change>,
426
+ ): AgentTelemetryBinding | undefined => {
427
+ const telemetry = definition.telemetry;
428
+ if (!telemetry) return undefined;
429
+ return (telemetry as (
430
+ context: RuntimeAgentPlanningContext<Env, Command, Change>,
431
+ ) => AgentTelemetryBinding | undefined)(context);
432
+ };
433
+
418
434
  const GeneratedRuntimeAgent = {
419
435
  [definition.name]: class extends AgentToolRuntimeKernel<Env> {
420
436
  private hasLoadedRuntime = false;
421
437
  private loadedRuntimeKey?: string;
422
438
  private loading?: Promise<void>;
439
+ private telemetryResolved = false;
440
+ private resolvedTelemetry?: AgentTelemetryBinding;
423
441
  private temporaryLaunch?: TemporaryAgentLaunch<Config>;
424
442
  private temporaryDispose?: () => Promise<void>;
425
443
  private temporaryCleanup?: Promise<void>;
@@ -609,6 +627,11 @@ export function defineRuntimeAgent<
609
627
  { timeoutMs: RUNTIME_LOAD_TIMEOUT_MS },
610
628
  ));
611
629
  let hooks = resolveDefinitionHooks(context);
630
+ if (!this.telemetryResolved) {
631
+ this.resolvedTelemetry = resolveDefinitionTelemetry(context);
632
+ this.telemetryResolved = true;
633
+ }
634
+ const telemetry = this.resolvedTelemetry;
612
635
  if (this.role === "temporary") {
613
636
  this.temporaryDispose = tools.dispose;
614
637
  tools = this.applyTemporaryToolPolicy(tools);
@@ -620,6 +643,7 @@ export function defineRuntimeAgent<
620
643
  tools,
621
644
  resources: loaded.resources,
622
645
  hooks,
646
+ ...(telemetry ? { telemetry } : {}),
623
647
  });
624
648
 
625
649
  await this.initCandidate(candidate);