Package not found. Please check the package name and try again.

@springbrand/agent-runtime 0.2.0-alpha.44 → 0.2.0-alpha.46

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.
@@ -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 = {
@@ -2,13 +2,13 @@ import {
2
2
  PreparedPiTurnAdapter,
3
3
  type CreatePreparedPiTurnOptions,
4
4
  } from "./execution";
5
- export { RetryableModelError } from "./execution";
5
+ export { ContextOverflowError, RetryableModelError } from "./execution";
6
6
  import {
7
7
  uiUserMessageToPi,
8
8
  } from "../message";
9
9
  import type { UIMessage } from "ai";
10
10
  import { createModels, type MutableModels } from "@earendil-works/pi-ai";
11
- import { configurePiModels, createPiModels, resolvePiApiKey } from "./models";
11
+ import { configurePiModels, createPiModels } from "./models";
12
12
  export {
13
13
  MODEL_STREAM_STALL_TIMEOUT_MS,
14
14
  modelRequestUrl,
@@ -120,12 +120,6 @@ export class PiRuntimeAdapter {
120
120
  return uiUserMessageToPi(input, privateContext);
121
121
  }
122
122
 
123
- resolveApiKey(
124
- ...args: Parameters<typeof resolvePiApiKey>
125
- ): ReturnType<typeof resolvePiApiKey> {
126
- return resolvePiApiKey(...args);
127
- }
128
-
129
123
  /**
130
124
  * 加载并预检一份可供后续准入使用的 Prepared Runtime。
131
125
  *
@@ -111,6 +111,38 @@ export type PiGenerationLifecycleObserver = (
111
111
  event: PiGenerationLifecycleEvent,
112
112
  ) => void;
113
113
 
114
+ export function structuredOutputStream(
115
+ streamFn: StreamFn,
116
+ model: Model<Api>,
117
+ schema: Record<string, unknown>,
118
+ ): StreamFn | undefined {
119
+ if (
120
+ model.api !== "anthropic-messages" ||
121
+ !model.provider.startsWith("model-api-openrouter-messages-")
122
+ ) return undefined;
123
+
124
+ return (activeModel, context, options) =>
125
+ streamFn(activeModel, context, {
126
+ ...options,
127
+ onPayload: async (payload, payloadModel) => {
128
+ const value = await options?.onPayload?.(payload, payloadModel) ?? payload;
129
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
130
+ throw new Error("Structured model payload must be an object");
131
+ }
132
+ const current = (value as Record<string, unknown>).output_config;
133
+ return {
134
+ ...value,
135
+ output_config: {
136
+ ...(current && typeof current === "object" && !Array.isArray(current)
137
+ ? current
138
+ : {}),
139
+ format: { type: "json_schema", schema },
140
+ },
141
+ };
142
+ },
143
+ });
144
+ }
145
+
114
146
  export function isModelStreamStallMessage(message?: string): message is string {
115
147
  return message === MODEL_STREAM_STALL_MESSAGE ||
116
148
  message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
@@ -534,8 +566,8 @@ export function withProviderRetry(
534
566
  }
535
567
 
536
568
  // 找出部署把这个模型放在哪个端点,并保留该端点在配置中的位置。
537
- // resolvePiModel resolvePiApiKey 在组装运行快照、发起模型请求前调用它,调用方只需传部署配置和 modelId。
538
- // 这里复用同一条查找路径,避免模型与密钥落到不同端点;同一 modelId 若配置多次会取第一个,是否应改为拒绝重复配置仍待确认。
569
+ // resolvePiModel 在组装运行快照前调用它,调用方只需传部署配置和 modelId。
570
+ // 同一 modelId 若配置多次会取第一个,是否应改为拒绝重复配置仍待确认。
539
571
  function endpointFor(
540
572
  provider: RuntimeProviderPort,
541
573
  modelId: string,
@@ -585,6 +617,20 @@ function piApiFor(protocol: RuntimeModelProtocol) {
585
617
  }
586
618
  }
587
619
 
620
+ function anthropicMessagesModelForOpenRouter(
621
+ modelId: string,
622
+ ): Model<"anthropic-messages"> | undefined {
623
+ if (!modelId.startsWith("anthropic/")) return;
624
+ const anthropicModelId = modelId
625
+ .slice("anthropic/".length)
626
+ .replace(/:batch$/, "")
627
+ .replace(/-fast$/, "")
628
+ .replaceAll(".", "-");
629
+ return CATALOGS["anthropic-messages"].find(
630
+ (model) => model.id === anthropicModelId,
631
+ );
632
+ }
633
+
588
634
  // 把部署声明的模型改造成可由对应自定义端点执行的 Pi 模型。
589
635
  // resolvePiModel 为单个运行快照调用它,configurePiModels 则在注册端点时为端点中的每个 modelId 调用它。
590
636
  // Pi 的内置 OpenAI、Anthropic provider 提供上下文窗口、费用和推理能力等元数据;这里保留这些元数据,但以部署协议、provider 标识和 baseURL 覆盖内置路由,未知模型会在初始化阶段直接报错,而不是拖到请求阶段。
@@ -602,10 +648,10 @@ function configuredModel(
602
648
  );
603
649
  }
604
650
  const {
605
- api: _api,
651
+ api: catalogApi,
606
652
  provider: _provider,
607
653
  baseUrl: _baseUrl,
608
- compat,
654
+ compat: catalogCompat,
609
655
  headers: catalogHeaders,
610
656
  ...metadata
611
657
  } = catalogModel;
@@ -614,17 +660,24 @@ function configuredModel(
614
660
  ...endpoint.headers,
615
661
  };
616
662
  const openRouterProviderPin = endpoint.openRouterProviderPins?.[modelId];
617
- const supportsStrictTools = modelId.startsWith("anthropic/claude-");
663
+ const configuredApi = apiFor(endpoint.protocol);
664
+ // OpenRouter uses dotted versions and optional transport suffixes while the
665
+ // Anthropic catalog uses dashed base model ids.
666
+ const anthropicMessagesCompat = endpoint.protocol === "openrouter-messages"
667
+ ? anthropicMessagesModelForOpenRouter(modelId)?.compat
668
+ : undefined;
618
669
  return {
619
670
  ...metadata,
620
- api: apiFor(endpoint.protocol),
671
+ api: configuredApi,
621
672
  provider: providerId(endpoint, index),
622
673
  baseUrl: endpoint.baseURL,
623
674
  ...(endpoint.protocol === "openrouter-messages"
624
675
  ? {
625
676
  compat: {
677
+ ...anthropicMessagesCompat,
626
678
  supportsEagerToolInputStreaming: false,
627
- supportsStrictTools,
679
+ supportsStrictTools:
680
+ anthropicMessagesCompat?.supportsStrictTools ?? false,
628
681
  supportsToolReferences: true,
629
682
  openRouterRouting: {
630
683
  require_parameters: true,
@@ -637,7 +690,9 @@ function configuredModel(
637
690
  },
638
691
  },
639
692
  }
640
- : {}),
693
+ : catalogApi === configuredApi && catalogCompat
694
+ ? { compat: catalogCompat }
695
+ : {}),
641
696
  ...(Object.keys(headers).length > 0 ? { headers } : {}),
642
697
  };
643
698
  }
@@ -661,24 +716,6 @@ export function resolvePiModel(
661
716
  return configuredModel(endpoint, index, modelId);
662
717
  }
663
718
 
664
- /**
665
- * 取出负责指定模型的部署端点密钥。
666
- *
667
- * Runtime 在创建一次模型执行或压缩上下文前调用它;辅助的标题生成器也在请求 Pi 时使用它。
668
- * 调用方应传入与 {@link resolvePiModel} 相同的 provider 和 modelId,并只把返回值用于当前请求。
669
- *
670
- * 它与模型解析共用 endpointFor,确保密钥和模型总是选择同一个端点,也让未配置的模型在密钥离开部署边界前失败。
671
- * 不要另写按协议选密钥的分支,否则同协议的多个端点会拿错凭据。
672
- *
673
- * @throws 当部署未配置该 modelId 时抛出错误。
674
- */
675
- export function resolvePiApiKey(
676
- provider: RuntimeProviderPort,
677
- modelId: string,
678
- ): string {
679
- return endpointFor(provider, modelId).endpoint.apiKey;
680
- }
681
-
682
719
  /**
683
720
  * 用当前部署端点完整重建一个已有的 Pi 模型集合。
684
721
  *
@@ -440,7 +440,7 @@ export class PiRuntimeTranscript {
440
440
  /**
441
441
  * 在 Pi 请求模型前按 Runtime 阈值压缩当前上下文,并把压缩 entry 写回 Session。
442
442
  *
443
- * `AgentRuntimeKernel.transformPiContext` 作为 Pi 的 `transformContext` 调用它,并提供本次固定的模型、密钥和取消信号。
443
+ * `AgentRuntimeKernel.transformPiContext` 作为 Pi 的 `transformContext` 调用它,并提供本次固定的模型和取消信号。
444
444
  *
445
445
  * 方法先逐条核对调用参数与耐久分支;不一致或压缩不可用时保留原消息,避免把漂移的内存上下文提交成新的规范分支。
446
446
  */
@@ -448,8 +448,8 @@ export class PiRuntimeTranscript {
448
448
  messages: AgentMessage[],
449
449
  options: {
450
450
  compactAfterTokens: number;
451
+ force?: boolean;
451
452
  model: Model<Api>;
452
- apiKey: string;
453
453
  signal?: AbortSignal;
454
454
  submissionId?: string;
455
455
  onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
@@ -474,11 +474,14 @@ export class PiRuntimeTranscript {
474
474
  const result = await compactPiContext({
475
475
  branch,
476
476
  compactAfterTokens: options.compactAfterTokens,
477
+ force: options.force,
477
478
  models: this.models,
478
479
  model: options.model,
479
- apiKey: options.apiKey,
480
480
  signal: options.signal,
481
481
  });
482
+ if (options.force && !result.didCompact) {
483
+ throw new Error("Forced context compaction did not reduce the model input");
484
+ }
482
485
  if (result.compactionEntry) {
483
486
  // 整对象写入:assembly 侧已经造好了完整的 compaction 条目,这里不再拆成位置
484
487
  // 参数。旧写法拆 5 个参数喂 pi 的 Session.appendCompaction,pi 0.81 把它加到
@@ -2,7 +2,12 @@ import type {
2
2
  AgentTool,
3
3
  AgentToolResult,
4
4
  } from "@earendil-works/pi-agent-core";
5
- import { Type, type Static, type TSchema } from "@earendil-works/pi-ai";
5
+ import {
6
+ StringEnum,
7
+ Type,
8
+ type Static,
9
+ type TSchema,
10
+ } from "@earendil-works/pi-ai";
6
11
  import { estimateStringTokens } from "agents/experimental/memory/utils";
7
12
  import type { RuntimeMemoryPort } from "../../kernel/bindings";
8
13
  import type { RuntimeMemoryProfile } from "../../kernel/profile";
@@ -145,11 +150,7 @@ const updatePlanParameters = Type.Object({
145
150
  text: Type.String({
146
151
  description: "The step, phrased as a short imperative.",
147
152
  }),
148
- status: Type.Union([
149
- Type.Literal("pending"),
150
- Type.Literal("in_progress"),
151
- Type.Literal("done"),
152
- ], {
153
+ status: StringEnum(["pending", "in_progress", "done"] as const, {
153
154
  description:
154
155
  'Current status. Use "done" for a finished step; never use "completed".',
155
156
  }),
@@ -214,18 +215,12 @@ export function normalizeUpdatePlanArguments(
214
215
  }
215
216
 
216
217
  const setContextParameters = Type.Object({
217
- label: Type.Union([
218
- Type.Literal("memory"),
219
- Type.Literal("preferences"),
220
- ], {
218
+ label: StringEnum(["memory", "preferences"] as const, {
221
219
  description:
222
220
  "Context block to update: memory for durable facts and active context, preferences for tone and workflow choices.",
223
221
  }),
224
222
  content: Type.String({ description: "Text to store in the selected context block." }),
225
- action: Type.Optional(Type.Union([
226
- Type.Literal("replace"),
227
- Type.Literal("append"),
228
- ], {
223
+ action: Type.Optional(StringEnum(["replace", "append"] as const, {
229
224
  description: 'Whether to replace the block or append to it. Defaults to "replace".',
230
225
  })),
231
226
  }, { additionalProperties: false });
@@ -318,7 +313,7 @@ export function basePiToolCandidates(
318
313
  name: "update_plan",
319
314
  label: "Update plan",
320
315
  description:
321
- "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.",
316
+ "Maintain the user-visible plan for the current task. Required input: steps. 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.",
322
317
  parameters: updatePlanParameters,
323
318
  prepareArguments: normalizeUpdatePlanArguments,
324
319
  async execute(_toolCallId, input) {
@@ -66,7 +66,7 @@ export interface PiToolCandidate {
66
66
  * 那是失效不是降级。`deny` 仍然优先,被 deny 的工具根本不会注入。
67
67
  */
68
68
  readonly alwaysRequiresApproval?: boolean;
69
- /** Recovery may replay this Tool Call only when the candidate guarantees convergence. */
69
+ /** Recovery may replay this Tool Call only when the candidate guarantees convergence. Omitted means non-idempotent. */
70
70
  readonly retry?: "idempotent" | "non-idempotent";
71
71
  readonly summary?: string;
72
72
  readonly source?: ApprovalReceipt["source"];
@@ -196,6 +196,7 @@ export function listExtensionsPiToolCandidate(
196
196
  return {
197
197
  owner: "core:extensions",
198
198
  requiredExecutionLevel: "low",
199
+ retry: "idempotent",
199
200
  tool,
200
201
  };
201
202
  }
@@ -278,6 +279,7 @@ export function codeExecutionPiToolCandidate(
278
279
  return {
279
280
  owner: "core:codemode",
280
281
  requiredExecutionLevel: "safe",
282
+ retry: "idempotent",
281
283
  outputBudget: { kind: "structure" },
282
284
  source: "codemode",
283
285
  summary: "Run JavaScript with network and configured connector access",
@@ -92,7 +92,7 @@ function candidate<T extends TSchema>(
92
92
  options: Partial<
93
93
  Pick<
94
94
  PiToolCandidate,
95
- "alwaysRequiresApproval" | "exposureMode" | "owner" | "requiredExecutionLevel" | "summary"
95
+ "alwaysRequiresApproval" | "exposureMode" | "owner" | "requiredExecutionLevel" | "retry" | "summary"
96
96
  >
97
97
  > = {},
98
98
  ): PiToolCandidate {
@@ -106,6 +106,7 @@ function candidate<T extends TSchema>(
106
106
  ? { alwaysRequiresApproval: true }
107
107
  : {}),
108
108
  ...(options.summary ? { summary: options.summary } : {}),
109
+ ...(options.retry ? { retry: options.retry } : {}),
109
110
  };
110
111
  }
111
112
 
@@ -138,17 +139,20 @@ export function schedulePiToolCandidates(
138
139
  summary: "Create a scheduled task",
139
140
  },
140
141
  ),
141
- candidate({
142
- name: "list_schedules",
143
- label: "List schedules",
144
- description:
145
- "List the user's existing scheduled tasks. Call this when the user asks what they have scheduled, or before cancelling one — you need the id, and the user will refer to a task by what it does, not by its id.",
146
- parameters: noParameters,
147
- async execute(_toolCallId, _input, signal) {
148
- signal?.throwIfAborted();
149
- return result({ schedules: await schedule.list() });
142
+ candidate(
143
+ {
144
+ name: "list_schedules",
145
+ label: "List schedules",
146
+ description:
147
+ "List the user's existing scheduled tasks. Call this when the user asks what they have scheduled, or before cancelling one — you need the id, and the user will refer to a task by what it does, not by its id.",
148
+ parameters: noParameters,
149
+ async execute(_toolCallId, _input, signal) {
150
+ signal?.throwIfAborted();
151
+ return result({ schedules: await schedule.list() });
152
+ },
150
153
  },
151
- }),
154
+ { retry: "idempotent" },
155
+ ),
152
156
  candidate({
153
157
  name: "update_schedule",
154
158
  label: "Update schedule",
@@ -534,6 +534,9 @@ export async function skillPiToolCandidates(
534
534
  ...(name === "activate_skill"
535
535
  ? { exposureMode: "direct" as const }
536
536
  : { exposureMode: "codemode" as const }),
537
+ ...(name === "activate_skill" || name === "read_skill_resource"
538
+ ? { retry: "idempotent" as const }
539
+ : {}),
537
540
  tool: adapted,
538
541
  };
539
542
  });
@@ -182,6 +182,7 @@ export function subagentPiToolCandidates(
182
182
  owner: `subagent:${type.name}`,
183
183
  exposureMode: "direct",
184
184
  requiredExecutionLevel: "safe",
185
+ retry: "idempotent",
185
186
  tool,
186
187
  };
187
188
  });
@@ -265,6 +266,7 @@ export function subagentPiToolCandidates(
265
266
  owner: "subagent:background",
266
267
  exposureMode: "direct",
267
268
  requiredExecutionLevel: "low",
269
+ retry: "idempotent",
268
270
  summary: "Dispatch a background sub-agent",
269
271
  source: "action",
270
272
  tool: background,
@@ -11,6 +11,7 @@ export function getTimePiToolCandidate(owner = "utilities"): PiToolCandidate {
11
11
  return {
12
12
  owner,
13
13
  requiredExecutionLevel: "safe",
14
+ retry: "idempotent",
14
15
  source: "action",
15
16
  summary: "Get the current server time as an ISO string.",
16
17
  tool: {
@@ -707,6 +707,12 @@ async function callOpenAIStream(
707
707
  tools: isOpenRouter
708
708
  ? [{ type: "openrouter:web_search", parameters: { max_uses: 10 } }]
709
709
  : [{ type: "web_search" }],
710
+ ...(isOpenRouter
711
+ ? {
712
+ max_output_tokens: Math.min(model.maxTokens, OPENROUTER_MAX_OUTPUT_TOKENS),
713
+ reasoning: { effort: "none" },
714
+ }
715
+ : {}),
710
716
  ...(!isOpenRouter
711
717
  ? {
712
718
  include: isCodex
@@ -1108,6 +1114,8 @@ export function applyCitations(text: string, groundingMetadata: any): { text: st
1108
1114
 
1109
1115
  const PROVIDER_ERROR_MAX_LENGTH = 2_048;
1110
1116
  const NATIVE_TIMEOUT_MS = 60_000;
1117
+ const OPENROUTER_MAX_OUTPUT_TOKENS = 4_096;
1118
+ export const WEB_SEARCH_MODEL = "deepseek/deepseek-v4-flash";
1111
1119
 
1112
1120
  export interface WebSearchOptions {
1113
1121
  endpoint: RuntimeModelEndpoint;
@@ -121,6 +121,7 @@ export function webSearchPiToolCandidate(
121
121
  owner: "runtime-base",
122
122
  exposureMode: "direct",
123
123
  requiredExecutionLevel: "safe",
124
+ retry: "idempotent",
124
125
  source: "action",
125
126
  tool,
126
127
  };
@@ -402,6 +402,7 @@ export function workspacePiToolCandidates(
402
402
  {
403
403
  owner: "workspace",
404
404
  requiredExecutionLevel: "safe" as const,
405
+ retry: "idempotent" as const,
405
406
  // read 是大输出外置协议的**出口**:溢出结果的提示语指向它。出口自己再外置,
406
407
  // 就会变成 read → 新 artifact → read 的死循环,模型只能靠猜逃出去。
407
408
  // 分页上限由 `pageReadResult` 自己保证,不需要外置也不会撑爆一轮。
@@ -411,6 +412,7 @@ export function workspacePiToolCandidates(
411
412
  ...[write, edit, list, find, grep, remove].map((tool) => ({
412
413
  owner: "workspace",
413
414
  requiredExecutionLevel: "safe" as const,
415
+ retry: "idempotent" as const,
414
416
  tool,
415
417
  })),
416
418
  ];
@@ -621,6 +623,7 @@ export function sandboxPiToolCandidates(
621
623
  {
622
624
  owner: "sandbox",
623
625
  requiredExecutionLevel: "safe",
626
+ retry: "idempotent",
624
627
  tool: processLogs,
625
628
  },
626
629
  {
@@ -301,6 +301,7 @@ export interface RuntimeAgentControls {
301
301
  requestId?: string,
302
302
  reason?: string,
303
303
  ): Promise<{ ok: boolean }>;
304
+ stopAllSubmissions(reason?: string): Promise<{ ok: boolean }>;
304
305
  }
305
306
 
306
307
  export interface RuntimeAgentConfigControls<Command, Change> {
@@ -525,6 +526,10 @@ export function defineRuntimeAgent<
525
526
  return super.stopTurn(requestId, reason);
526
527
  }
527
528
 
529
+ stopAllSubmissions(reason?: string): Promise<{ ok: boolean }> {
530
+ return super.stopAllSubmissions(reason);
531
+ }
532
+
528
533
  protected override async prepareAgentToolRun(
529
534
  input: unknown,
530
535
  ): Promise<string> {
@@ -922,6 +927,10 @@ export function defineRuntimeAgent<
922
927
  callableContext,
923
928
  );
924
929
  callable()(GeneratedRuntimeAgent.prototype.stopTurn, callableContext);
930
+ callable()(
931
+ GeneratedRuntimeAgent.prototype.stopAllSubmissions,
932
+ callableContext,
933
+ );
925
934
 
926
935
  return GeneratedRuntimeAgent as unknown as RuntimeAgentClass<
927
936
  Env,