@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.
- package/package.json +1 -1
- package/src/adapter/cloudflare/sandbox/adapter.ts +61 -36
- package/src/adapter/cloudflare/universal-agent/preparation.ts +0 -2
- package/src/adapter/cloudflare/workspace/scoped-workspace.ts +23 -18
- package/src/db/index.ts +5 -0
- package/src/db/schema.ts +15 -0
- package/src/db/telemetry-outbox.repo.ts +151 -0
- package/src/index.ts +1 -0
- package/src/kernel/approval-lifecycle.ts +35 -3
- package/src/kernel/bindings.ts +0 -1
- package/src/kernel/interaction-lifecycle.ts +35 -6
- package/src/layers/orchestration/temporary-agent/workspace.ts +4 -4
- package/src/lib/prompt.ts +22 -15
- package/src/pi/assembly/context.ts +2 -2
- package/src/pi/message/conversion.ts +13 -1
- package/src/pi/runtime-adapter/execution.ts +26 -26
- package/src/pi/runtime-adapter/models.ts +144 -44
- package/src/pi/tool/ai-adapter.ts +2 -2
- package/src/pi/tool/base.ts +31 -25
- package/src/pi/tool/compiler.ts +5 -103
- package/src/pi/turn/tool-recovery.ts +11 -1
- package/src/runtime-agent.ts +24 -0
- package/src/runtime-assembler.ts +29 -15
- package/src/runtime-definition.ts +2 -0
- package/src/runtime.ts +362 -20
- package/src/telemetry/contract.ts +389 -0
- package/src/telemetry/coordinator.ts +143 -0
- package/src/telemetry/delivery.ts +138 -0
- package/src/telemetry/ids.ts +60 -0
- package/src/telemetry/index.ts +7 -0
- package/src/telemetry/recorder.ts +61 -0
- package/src/telemetry/runtime-telemetry.ts +484 -0
- package/src/telemetry/sanitize.ts +97 -0
- package/src/tool-registry.ts +11 -11
- 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 {
|
|
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 = "/
|
|
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
|
-
// 原因:同时比较根路径和带 `/` 的子路径前缀,避免误伤 `/
|
|
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
|
-
// 原因:在一个共享边界拒绝 `/
|
|
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
|
-
"/
|
|
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.
|
|
28
|
-
"instrument —
|
|
29
|
-
"workspace filesystem (state.*), and your other tools (tools.*).
|
|
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:
|
|
56
|
-
"
|
|
57
|
-
"
|
|
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, /
|
|
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
|
-
"
|
|
67
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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 /
|
|
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 /
|
|
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 = "/
|
|
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 /
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
|
|
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
|
|
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
|
-
|
|
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`
|
|
94
|
+
* @remarks `inputSchema` 经 ai@7 `asSchema()` 转为 JSON Schema;原始返回值保留在 `details` 中。
|
|
95
95
|
*/
|
|
96
96
|
export function aiToolToPi(
|
|
97
97
|
name: string,
|