@springbrand/agent-runtime 0.2.0-alpha.43 → 0.2.0-alpha.45

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.2.0-alpha.43",
3
+ "version": "0.2.0-alpha.45",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -19,7 +19,6 @@ import type {
19
19
  import { PiChunkEncoder } from "../../../pi/message";
20
20
  import {
21
21
  createPiModels,
22
- resolvePiApiKey,
23
22
  resolvePiModel,
24
23
  withProviderRetry,
25
24
  } from "../../../pi/runtime-adapter/models";
@@ -398,8 +397,6 @@ export abstract class CloudflareSubAgent<
398
397
  input,
399
398
  model,
400
399
  streamFn: withProviderRetry(models.streamSimple.bind(models)),
401
- getApiKey: () =>
402
- resolvePiApiKey(provider, provider.defaultModel),
403
400
  tools,
404
401
  signal,
405
402
  onEvent: (event) => {
@@ -12,6 +12,7 @@ import type {
12
12
  import { z } from "zod";
13
13
  import { assembleSubagentPrompt } from "../../../lib/prompt";
14
14
  import type { AgentType } from "../../../layers/orchestration/subagents/agent-types/contract";
15
+ import { structuredOutputStream } from "../../../pi/runtime-adapter/models";
15
16
 
16
17
  const RECURSIVE_TOOLS = new Set([
17
18
  "run_agent",
@@ -28,7 +29,6 @@ export interface CloudflareSubAgentRun {
28
29
  model: Model<Api>;
29
30
  streamFn: StreamFn;
30
31
  tools: AgentTool[];
31
- getApiKey?: (provider: string) => string | undefined;
32
32
  signal?: AbortSignal;
33
33
  timeoutMs?: number;
34
34
  onEvent?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;
@@ -67,11 +67,16 @@ function toolErrorText(result: unknown): string {
67
67
  return "tool execution failed";
68
68
  }
69
69
 
70
- function taskPrompt(type: AgentType, input: unknown): string {
70
+ function taskPrompt(
71
+ input: unknown,
72
+ outputSchema?: Record<string, unknown>,
73
+ ): string {
71
74
  return [
72
75
  "Complete this bounded task.",
73
76
  `Input:\n${JSON.stringify(input)}`,
74
- `Return only one JSON object matching this schema:\n${JSON.stringify(z.toJSONSchema(type.outputSchema))}`,
77
+ outputSchema
78
+ ? `Return only one JSON object matching this schema:\n${JSON.stringify(outputSchema)}`
79
+ : "Return only one JSON object matching the configured output schema.",
75
80
  ].join("\n\n");
76
81
  }
77
82
 
@@ -90,13 +95,18 @@ export async function runCloudflareSubAgent(
90
95
  }
91
96
  }
92
97
  if (run.signal?.aborted) throw new Error("SubAgent run aborted");
98
+ const { $schema: _, ...outputSchema } = z.toJSONSchema(run.type.outputSchema);
99
+ const constrainedStream = structuredOutputStream(
100
+ run.streamFn,
101
+ run.model,
102
+ outputSchema,
103
+ );
93
104
 
94
105
  // 这里没有审批闸,也装不了:到这一步工具已经被 compilePiTools 编译成 AgentTool,
95
106
  // requiredExecutionLevel 只挂在编译前的 PiToolCandidate 上,这里读不到。「子 agent 不许挂需审批的工具」
96
107
  // 这条不变量由装配处 createCloudflareSubAgentTools 的启动期断言强制执行。
97
108
  const agent = new Agent({
98
- streamFn: run.streamFn,
99
- getApiKey: run.getApiKey,
109
+ streamFn: constrainedStream ?? run.streamFn,
100
110
  initialState: {
101
111
  model: run.model,
102
112
  systemPrompt: assembleSubagentPrompt(run.type.persona),
@@ -125,7 +135,10 @@ export async function runCloudflareSubAgent(
125
135
  }, run.timeoutMs ?? DEFAULT_TIMEOUT_MS);
126
136
 
127
137
  try {
128
- await agent.prompt(taskPrompt(run.type, input.data));
138
+ await agent.prompt(taskPrompt(
139
+ input.data,
140
+ constrainedStream ? undefined : outputSchema,
141
+ ));
129
142
  } finally {
130
143
  clearTimeout(timeout);
131
144
  run.signal?.removeEventListener("abort", abort);
package/src/db/schema.ts CHANGED
@@ -25,7 +25,8 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
25
25
  assistant_message_id TEXT NOT NULL,
26
26
  abort_reason TEXT,
27
27
  recovery_error_count INTEGER NOT NULL DEFAULT 0,
28
- recovery_reason TEXT
28
+ recovery_reason TEXT,
29
+ context_overflow_retried INTEGER NOT NULL DEFAULT 0
29
30
  )`;
30
31
  const submissionColumns = new Set(
31
32
  sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
@@ -77,6 +78,10 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
77
78
  if (!submissionColumns.has("recovery_reason")) {
78
79
  sql`ALTER TABLE pi_submissions ADD COLUMN recovery_reason TEXT`;
79
80
  }
81
+ if (!submissionColumns.has("context_overflow_retried")) {
82
+ sql`ALTER TABLE pi_submissions
83
+ ADD COLUMN context_overflow_retried INTEGER NOT NULL DEFAULT 0`;
84
+ }
80
85
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
81
86
  ON pi_submissions(status)
82
87
  WHERE status = 'running'`;
@@ -11,7 +11,8 @@ export type SubmissionStatus =
11
11
  | "error";
12
12
  export type SubmissionRecoveryReason =
13
13
  | "no_meaningful_model_progress"
14
- | "transient_model_error";
14
+ | "transient_model_error"
15
+ | "context_overflow";
15
16
 
16
17
  const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
17
18
  "completed",
@@ -422,6 +423,17 @@ export class SubmissionRepository {
422
423
  `[0]?.recovery_error_count ?? 0;
423
424
  }
424
425
 
426
+ claimContextOverflowRecovery(id: string): boolean {
427
+ return Boolean(this.sql<{ claimed: number }>`
428
+ UPDATE pi_submissions
429
+ SET context_overflow_retried = 1
430
+ WHERE submission_id = ${id}
431
+ AND status IN ('pending', 'running')
432
+ AND context_overflow_retried = 0
433
+ RETURNING 1 AS claimed
434
+ `[0]?.claimed);
435
+ }
436
+
425
437
  /**
426
438
  * 记录一个让步执行片的累计模型回合数和续跑标识。
427
439
  *
package/src/index.ts CHANGED
@@ -144,6 +144,5 @@ export {
144
144
  export {
145
145
  createPiModels,
146
146
  modelRequestUrl,
147
- resolvePiApiKey,
148
147
  resolvePiModel,
149
148
  } from "./pi/runtime-adapter/models";
@@ -92,14 +92,7 @@ export interface SubmissionStore<TSubmission extends SubmissionRecord> {
92
92
  countPending(): number;
93
93
  findRunning(): TSubmission | null;
94
94
  findNextPending(): TSubmission | null;
95
- /**
96
- * 列出全部等待或运行中的提交标识。
97
- *
98
- * @remarks
99
- * `stop` 没有指定请求时调用,并逐条走统一取消路径。
100
- *
101
- * 只返回标识可以避免停止入口复制完整记录或状态判断。
102
- */
95
+ listPending(): TSubmission[];
103
96
  /**
104
97
  * 持久化一条取消原因。
105
98
  *
@@ -694,5 +687,29 @@ export class SubmissionLifecycle<
694
687
  return { ok: submissionIds.length > 0 };
695
688
  }
696
689
 
690
+ /** Stops every running or queued Submission without pumping between them. */
691
+ async stopAll(reason = "Stopped"): Promise<{ ok: boolean }> {
692
+ const running = this.options.store.findRunning();
693
+ const submissions = [
694
+ ...(running ? [running] : []),
695
+ ...this.options.store.listPending(),
696
+ ];
697
+ if (submissions.length === 0) return { ok: false };
698
+
699
+ this.options.store.transaction(() => {
700
+ for (const submission of submissions) {
701
+ this.options.store.updateAbortReason(submission.submissionId, reason);
702
+ this.options.appendAbortIntent(submission, reason);
703
+ }
704
+ });
705
+ for (const submission of submissions) {
706
+ const active = this.activeBySubmission.get(submission.submissionId);
707
+ if (active) this.options.abortActive(active);
708
+ await this.finish(submission, "aborted", reason);
709
+ }
710
+ this.pump();
711
+ return { ok: true };
712
+ }
713
+
697
714
  // #endregion
698
715
  }
@@ -3,6 +3,7 @@ import {
3
3
  compact as compactPiTranscript,
4
4
  DEFAULT_COMPACTION_SETTINGS,
5
5
  estimateContextTokens,
6
+ estimateTokens,
6
7
  prepareCompaction,
7
8
  type AgentMessage,
8
9
  type CompactionEntry,
@@ -86,9 +87,9 @@ function sumUsage(responses: readonly AssistantMessage[]): Usage | undefined {
86
87
  export async function compactPiContext(input: Readonly<{
87
88
  branch: readonly SessionTreeEntry[];
88
89
  compactAfterTokens: number;
90
+ force?: boolean;
89
91
  models: Models;
90
92
  model: Model<any>;
91
- apiKey?: string;
92
93
  signal?: AbortSignal;
93
94
  }>): Promise<{
94
95
  messages: AgentMessage[];
@@ -116,8 +117,8 @@ export async function compactPiContext(input: Readonly<{
116
117
  const settings = DEFAULT_COMPACTION_SETTINGS;
117
118
  if (
118
119
  !settings.enabled ||
119
- estimateContextTokens(currentMessages).tokens <
120
- input.compactAfterTokens
120
+ (!input.force && estimateContextTokens(currentMessages).tokens <
121
+ input.compactAfterTokens)
121
122
  ) {
122
123
  return {
123
124
  messages: currentMessages,
@@ -149,10 +150,7 @@ export async function compactPiContext(input: Readonly<{
149
150
  context: Parameters<Models["completeSimple"]>[1],
150
151
  options?: Parameters<Models["completeSimple"]>[2],
151
152
  ) => {
152
- const response = await input.models.completeSimple(model, context, {
153
- ...options,
154
- ...(input.apiKey ? { apiKey: input.apiKey } : {}),
155
- });
153
+ const response = await input.models.completeSimple(model, context, options);
156
154
  responses.push(response);
157
155
  return response;
158
156
  },
@@ -199,10 +197,25 @@ export async function compactPiContext(input: Readonly<{
199
197
  ? firstResponse.stopReason
200
198
  : undefined;
201
199
  const usage = sumUsage(responses);
200
+ const messages = buildSessionContext(
201
+ [...branch, previewEntry],
202
+ ).messages;
203
+ if (
204
+ input.force &&
205
+ messages.reduce((total, message) => total + estimateTokens(message), 0) >=
206
+ currentMessages.reduce(
207
+ (total, message) => total + estimateTokens(message),
208
+ 0,
209
+ )
210
+ ) {
211
+ return {
212
+ messages: currentMessages,
213
+ didCompact: false,
214
+ degraded: true,
215
+ };
216
+ }
202
217
  return {
203
- messages: buildSessionContext(
204
- [...branch, previewEntry],
205
- ).messages,
218
+ messages,
206
219
  compactionEntry,
207
220
  ...(firstResponse && usage
208
221
  ? {
@@ -2,8 +2,11 @@ import type {
2
2
  AgentMessage,
3
3
  ThinkingLevel,
4
4
  } from "@earendil-works/pi-agent-core";
5
- import type { Api, Model } from "@earendil-works/pi-ai";
6
- import { clampReasoning } from "@earendil-works/pi-ai/api/simple-options";
5
+ import {
6
+ clampThinkingLevel,
7
+ type Api,
8
+ type Model,
9
+ } from "@earendil-works/pi-ai";
7
10
  import type {
8
11
  RuntimeMcpServer,
9
12
  RuntimeProfile,
@@ -84,15 +87,14 @@ function freezeModel(model: Model<Api>): Model<Api> {
84
87
  });
85
88
  }
86
89
 
87
- // 作用:把 Runtime 的思考强度名称转成 Pi Agent Core 接受的名称。
88
- // 调用:创建 Runtime 装配快照时对 profile 中的 `thinking` 调用。
89
- // 原因:`none` 映射成 `off`;xhigh/max 用 clampReasoning 夹到 API 真正支持的最高档,
90
- // 避免超出范围的等级在 provider 侧静默失败。
90
+ // 作用:把 Runtime 的思考强度夹到当前模型真正支持的 Pi 档位。
91
+ // 调用:创建 Runtime 装配快照时对已解析模型和 profile `thinking` 调用。
91
92
  function toPiThinkingLevel(
93
+ model: Model<Api>,
92
94
  thinking: ThinkingEffort,
93
95
  ): ThinkingLevel {
94
96
  if (thinking === "none") return "off";
95
- return clampReasoning(thinking) ?? "off";
97
+ return clampThinkingLevel(model, thinking);
96
98
  }
97
99
 
98
100
  // 作用:复制并冻结一个 Extension 配置的 manifest 和权限集合。
@@ -197,10 +199,14 @@ export function createPiRuntimeAssembly(
197
199
  Object.freeze(messages);
198
200
  Object.freeze(mcpServers);
199
201
  Object.freeze(extensions);
202
+ const model = freezeModel(
203
+ resolvePiModel(input.provider, input.profile.model),
204
+ );
200
205
 
201
206
  return Object.freeze({
202
- model: freezeModel(resolvePiModel(input.provider, input.profile.model)),
207
+ model,
203
208
  thinkingLevel: toPiThinkingLevel(
209
+ model,
204
210
  input.profile.thinking,
205
211
  ),
206
212
  systemPrompt: assembleSystemPrompt(
@@ -123,17 +123,40 @@ function toolCallAt(
123
123
  return part?.type === "toolCall" ? part : undefined;
124
124
  }
125
125
 
126
- function finishReason(reason: string): FinishReason {
127
- switch (reason) {
126
+ /** The one interpretation of a non-Tool Pi assistant terminal. */
127
+ export function classifyPiTerminalStopReason(
128
+ stopReason: string | undefined,
129
+ ): {
130
+ outcome: "succeeded" | "failed" | "aborted";
131
+ turnStatus: "completed" | "error" | "aborted";
132
+ finishReason?: FinishReason;
133
+ message?: string;
134
+ } {
135
+ switch (stopReason) {
128
136
  case "stop":
129
137
  case "length":
130
- return reason;
131
- case "toolUse":
132
- return "tool-calls";
138
+ return {
139
+ outcome: "succeeded",
140
+ turnStatus: "completed",
141
+ finishReason: stopReason,
142
+ };
143
+ case "aborted":
144
+ return { outcome: "aborted", turnStatus: "aborted" };
133
145
  case "error":
134
- return "error";
146
+ return {
147
+ outcome: "failed",
148
+ turnStatus: "error",
149
+ finishReason: "error",
150
+ };
135
151
  default:
136
- return "other";
152
+ return {
153
+ outcome: "failed",
154
+ turnStatus: "error",
155
+ finishReason: "error",
156
+ message: `SpringBrand ended the turn with an unrecognized stop reason: ${
157
+ stopReason ?? "(none)"
158
+ }`,
159
+ };
137
160
  }
138
161
  }
139
162
 
@@ -345,8 +368,15 @@ export class PiChunkEncoder {
345
368
  update.error.errorMessage ?? update.reason,
346
369
  ) ?? "SpringBrand turn failed",
347
370
  }];
348
- default:
371
+ case "start":
372
+ case "done":
349
373
  return [];
374
+ default:
375
+ throw new Error(
376
+ `Unsupported SpringBrand AssistantMessageEvent: ${
377
+ String((update as { type?: unknown }).type)
378
+ }`,
379
+ );
350
380
  }
351
381
  }
352
382
 
@@ -448,43 +478,39 @@ export class PiChunkEncoder {
448
478
  }
449
479
  this.finished = true;
450
480
  const publicError = publicAssistantError(message.errorMessage);
481
+ const terminal = classifyPiTerminalStopReason(message.stopReason);
451
482
  if (this.startedAt !== undefined) {
452
- const turnStatus = message.stopReason === "aborted"
453
- ? "aborted"
454
- : message.stopReason === "error"
455
- ? "error"
456
- : "completed";
457
483
  chunks.push({
458
484
  type: "message-metadata",
459
485
  messageMetadata: {
460
486
  createdAt: this.startedAt,
461
487
  completedAt: message.timestamp,
462
488
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
463
- turnStatus,
489
+ turnStatus: terminal.turnStatus,
464
490
  ...(this.turnId ? { turnId: this.turnId } : {}),
465
- ...(publicError &&
466
- (message.stopReason !== "aborted" ||
491
+ ...((publicError ?? terminal.message) &&
492
+ (terminal.outcome !== "aborted" ||
467
493
  message.errorMessage !== USER_STOP_REASON)
468
- ? { error: publicError }
494
+ ? { error: publicError ?? terminal.message }
469
495
  : {}),
470
496
  },
471
497
  });
472
498
  }
473
- if (message.stopReason === "aborted") {
499
+ if (terminal.outcome === "aborted") {
474
500
  chunks.push({ type: "abort", reason: message.errorMessage });
475
501
  return chunks;
476
502
  }
477
- if (message.stopReason === "error") {
503
+ if (terminal.outcome === "failed") {
478
504
  chunks.push({
479
505
  type: "error",
480
- errorText: publicError ?? "SpringBrand turn failed",
506
+ errorText: publicError ?? terminal.message ?? "SpringBrand turn failed",
481
507
  });
482
508
  return chunks;
483
509
  }
484
510
  chunks.push({ type: "finish-step" });
485
511
  chunks.push({
486
512
  type: "finish",
487
- finishReason: finishReason(message.stopReason),
513
+ finishReason: terminal.finishReason ?? "other",
488
514
  });
489
515
  return chunks;
490
516
  }
@@ -200,46 +200,18 @@ export interface PreparedPiRuntimeState {
200
200
 
201
201
  // #region 版本描述与准备句柄
202
202
 
203
- const IDEMPOTENT_TOOL_NAMES = new Set([
204
- "activate_skill",
205
- "browser_extract",
206
- "browser_links",
207
- "browser_markdown",
208
- "browser_scrape",
209
- "bind_resource",
210
- "delete",
211
- "edit",
212
- "execute",
213
- "find",
214
- "get_time",
215
- "grep",
216
- "list",
217
- "list_resources",
218
- "list_extensions",
219
- "list_schedules",
220
- "read",
221
- "read_skill_resource",
222
- "sandbox_process_logs",
223
- "web_search",
224
- "write",
225
- "unbind_resource",
226
- ]);
227
-
228
203
  /**
229
204
  * 判断工具在执行结果不确定时能否安全重试。
230
205
  *
231
206
  * @remarks
232
207
  * Turn 执行在记录第一次尝试前调用它,恢复流程随后使用这条持久化结论。
233
208
  *
234
- * 白名单故意保持保守,未知工具和新工具都会默认按不可重复执行处理。
209
+ * 重试语义由 Tool Candidate 自己声明;未声明的工具失败关闭为不可重复执行。
235
210
  */
236
211
  export function piToolRetryPolicy(
237
212
  candidate: PiToolCandidate,
238
213
  ): "idempotent" | "non-idempotent" {
239
- return candidate.retry ?? (IDEMPOTENT_TOOL_NAMES.has(candidate.tool.name) ||
240
- candidate.owner.startsWith("subagent:")
241
- ? "idempotent"
242
- : "non-idempotent");
214
+ return candidate.retry ?? "non-idempotent";
243
215
  }
244
216
 
245
217
  // 把工具候选整理成版本描述里要保存的稳定字段。
@@ -15,13 +15,17 @@ import type {
15
15
  ToolResultMessage,
16
16
  UserMessage,
17
17
  } from "@earendil-works/pi-ai";
18
+ import { isContextOverflow } from "@earendil-works/pi-ai";
18
19
  import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
19
20
  import {
20
21
  parkPiToolApproval,
21
22
  parkPiToolInteraction,
22
23
  requiresPiToolApproval,
23
24
  } from "../turn";
24
- import { PiChunkEncoder } from "../message";
25
+ import {
26
+ classifyPiTerminalStopReason,
27
+ PiChunkEncoder,
28
+ } from "../message";
25
29
  import type { UIMessageChunk } from "ai";
26
30
  import { ChatStreamStalledError } from "agents/chat";
27
31
  import {
@@ -42,7 +46,6 @@ import {
42
46
  import {
43
47
  isRecoverableAssistantError,
44
48
  isModelStreamStallMessage,
45
- resolvePiApiKey,
46
49
  withProviderRetry,
47
50
  type PiGenerationLifecycleObserver,
48
51
  } from "./models";
@@ -79,6 +82,8 @@ const MAX_WRAP_UP_TURNS = 2;
79
82
 
80
83
  export class RetryableModelError extends Error {}
81
84
 
85
+ export class ContextOverflowError extends RetryableModelError {}
86
+
82
87
  function normalizePlanToolCalls(message: AgentMessage): void {
83
88
  if (message.role !== "assistant") return;
84
89
  for (const part of message.content) {
@@ -88,42 +93,6 @@ function normalizePlanToolCalls(message: AgentMessage): void {
88
93
  }
89
94
  }
90
95
 
91
- /**
92
- * 把 Pi 的终止原因翻译成本仓的回合结局。
93
- *
94
- * 调用:终态提交(`onTerminal`)与流式记录的 `turnStatus` 共用这一处,
95
- * 保证服务端只有一份判据 —— 两处各写一遍三元链正是它们悄悄分叉的原因。
96
- *
97
- * 为什么未知取值判失败而不是成功:`stopReason` 是上游会扩的联合体
98
- * (0.83 就加了 `"pending"`)。把认不出的结局宣称成「成功」,等于让一次
99
- * 异常结束伪装成正常完成 —— 这是本仓明确禁止的假完成,
100
- * 也是 Pi 自己在 0.83 的选择(认不出的终止原因直接抛错而非压平成 stop)。
101
- * 前端对未知取值取 `"running"`(未知即未定),最终由服务端这份权威判据校正。
102
- */
103
- function classifyPiStopReason(stopReason: string | undefined): {
104
- outcome: PiTurnTerminalIntent["outcome"];
105
- turnStatus: "completed" | "error" | "aborted";
106
- message?: string;
107
- } {
108
- switch (stopReason) {
109
- case "stop":
110
- case "length":
111
- return { outcome: "succeeded", turnStatus: "completed" };
112
- case "aborted":
113
- return { outcome: "aborted", turnStatus: "aborted" };
114
- case "error":
115
- return { outcome: "failed", turnStatus: "error" };
116
- default:
117
- return {
118
- outcome: "failed",
119
- turnStatus: "error",
120
- message: `SpringBrand ended the turn with an unrecognized stop reason: ${
121
- stopReason ?? "(none)"
122
- }`,
123
- };
124
- }
125
- }
126
-
127
96
  function visibleAssistantContent(message: AssistantMessage) {
128
97
  return message.content.filter((part) =>
129
98
  part.type === "text"
@@ -140,7 +109,6 @@ interface PiTurnAdapterOptions {
140
109
  readonly thinkingLevel?: ThinkingLevel;
141
110
  };
142
111
  readonly models: Models;
143
- readonly apiKey: string;
144
112
  readonly modelSessionId: string;
145
113
  // 读取这次执行真正要交给 Pi 的 canonical transcript。
146
114
  // PiTurnAdapter.run 在创建 PiCore 前调用它,宿主应返回当时最新的 transcript。
@@ -331,7 +299,6 @@ class PiTurnAdapter {
331
299
  this.opts.modelSessionId,
332
300
  this.opts.onGeneration,
333
301
  ),
334
- getApiKey: () => this.opts.apiKey,
335
302
  // transformMessages 在宿主上下文变换之后运行,确保跨 provider 的 tool call ID 格式兼容。
336
303
  // OpenAI Responses API 生成含 `|` 的 450+ 字符 ID,Anthropic 只接受 ^[a-zA-Z0-9_-]+$(64 字符上限);
337
304
  // 切换 provider 或消息跨 provider 回放时若不规范化,provider 会静默拒绝。
@@ -564,6 +531,21 @@ export interface CreatePreparedPiTurnOptions {
564
531
  error?: unknown;
565
532
  occurredAt: number;
566
533
  }>) => void;
534
+ /** Reports model-requested Tools absent from the pinned Runtime surface. */
535
+ readonly onUnknownToolStarted?: (input: Readonly<{
536
+ toolCallId: string;
537
+ toolName: string;
538
+ input: unknown;
539
+ occurredAt: number;
540
+ }>) => void;
541
+ /** Reports the immediate Pi failure for a Tool absent from the pinned Runtime surface. */
542
+ readonly onUnknownToolFinished?: (input: Readonly<{
543
+ toolCallId: string;
544
+ toolName: string;
545
+ outcome: "failed";
546
+ output: import("@earendil-works/pi-agent-core").AgentToolResult<unknown>;
547
+ occurredAt: number;
548
+ }>) => void;
567
549
  /** Per-Submission executors for tools whose metadata is fixed at assembly time. */
568
550
  readonly toolExecutors?: Readonly<Record<
569
551
  string,
@@ -659,6 +641,7 @@ export class PreparedPiTurnAdapter {
659
641
  private readonly encoder: PiChunkEncoder;
660
642
  private readonly steerMessageIds: string[] = [];
661
643
  private terminalIntent?: PiTurnTerminalIntent;
644
+ private readonly contextWindow: number;
662
645
 
663
646
  /**
664
647
  * 把 Prepared Runtime 和 Submission 持久化端口绑定成一个可执行 Turn。
@@ -679,6 +662,7 @@ export class PreparedPiTurnAdapter {
679
662
  dependencies.owner,
680
663
  options.pinnedDescriptor,
681
664
  );
665
+ this.contextWindow = state.snapshot.pi.model.contextWindow;
682
666
  this.assistantOrdinal = options.submission.assistantOrdinal;
683
667
  this.encoder = new PiChunkEncoder({
684
668
  messageId: options.submission.messageId,
@@ -903,10 +887,6 @@ export class PreparedPiTurnAdapter {
903
887
  thinkingLevel: state.snapshot.pi.thinkingLevel,
904
888
  },
905
889
  models: dependencies.models,
906
- apiKey: resolvePiApiKey(
907
- state.snapshot.bindings.provider,
908
- state.snapshot.pi.model.id,
909
- ),
910
890
  modelSessionId: options.modelSessionId,
911
891
  ...(options.onGeneration ? { onGeneration: options.onGeneration } : {}),
912
892
  canonicalMessages: options.canonicalMessages,
@@ -1038,6 +1018,28 @@ export class PreparedPiTurnAdapter {
1038
1018
  // PiTurnAdapter 会对每个 AgentEvent 调用它,PiCore 会等待该 Promise 后再越过订阅事件屏障。
1039
1019
  // canonical message 先于流投影提交,恢复才不会依赖仅供浏览器消费的记录;调整顺序必须复核 recovery effect。
1040
1020
  private async handleEvent(event: AgentEvent): Promise<void> {
1021
+ if (
1022
+ event.type === "tool_execution_start" &&
1023
+ !this.candidates.some(({ tool }) => tool.name === event.toolName)
1024
+ ) {
1025
+ this.options.onUnknownToolStarted?.({
1026
+ toolCallId: event.toolCallId,
1027
+ toolName: event.toolName,
1028
+ input: event.args,
1029
+ occurredAt: Date.now(),
1030
+ });
1031
+ } else if (
1032
+ event.type === "tool_execution_end" &&
1033
+ !this.candidates.some(({ tool }) => tool.name === event.toolName)
1034
+ ) {
1035
+ this.options.onUnknownToolFinished?.({
1036
+ toolCallId: event.toolCallId,
1037
+ toolName: event.toolName,
1038
+ outcome: "failed",
1039
+ output: event.result,
1040
+ occurredAt: Date.now(),
1041
+ });
1042
+ }
1041
1043
  if (
1042
1044
  event.type === "message_start" ||
1043
1045
  event.type === "message_update" ||
@@ -1048,7 +1050,10 @@ export class PreparedPiTurnAdapter {
1048
1050
  if (
1049
1051
  event.type === "message_update" &&
1050
1052
  event.assistantMessageEvent.type === "error" &&
1051
- (isModelStreamStallMessage(
1053
+ (isContextOverflow(
1054
+ event.assistantMessageEvent.error,
1055
+ this.contextWindow,
1056
+ ) || isModelStreamStallMessage(
1052
1057
  event.assistantMessageEvent.error.errorMessage,
1053
1058
  ) ||
1054
1059
  isRecoverableAssistantError(event.assistantMessageEvent.error))
@@ -1059,6 +1064,11 @@ export class PreparedPiTurnAdapter {
1059
1064
  if (event.type === "message_end") {
1060
1065
  const message = event.message;
1061
1066
  if (message.role === "assistant") {
1067
+ if (isContextOverflow(message, this.contextWindow)) {
1068
+ throw new ContextOverflowError(
1069
+ message.errorMessage ?? "Model context window overflow",
1070
+ );
1071
+ }
1062
1072
  const stalled = message.stopReason === "error" &&
1063
1073
  isModelStreamStallMessage(message.errorMessage);
1064
1074
  const retryable = isRecoverableAssistantError(message);
@@ -1119,7 +1129,7 @@ export class PreparedPiTurnAdapter {
1119
1129
  authoritativeMessage.role === "assistant" &&
1120
1130
  authoritativeMessage.stopReason !== "toolUse"
1121
1131
  ) {
1122
- const terminal = classifyPiStopReason(
1132
+ const terminal = classifyPiTerminalStopReason(
1123
1133
  authoritativeMessage.stopReason,
1124
1134
  );
1125
1135
  this.terminalIntent = {