@springbrand/agent-runtime 0.2.0-alpha.18 → 0.2.0-alpha.20

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.18",
3
+ "version": "0.2.0-alpha.20",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -412,6 +412,20 @@ function timedOut(error: unknown): boolean {
412
412
  return message.includes("timeout") || message.includes("timed out");
413
413
  }
414
414
 
415
+ function processAlreadyStopped(error: unknown): boolean {
416
+ if (
417
+ typeof error !== "object" ||
418
+ error === null ||
419
+ !("code" in error)
420
+ ) {
421
+ return false;
422
+ }
423
+ return (
424
+ error.code === "COMMAND_NOT_FOUND" ||
425
+ error.code === "PROCESS_NOT_FOUND"
426
+ );
427
+ }
428
+
415
429
  function mappedError(error: unknown): SandboxPortError {
416
430
  if (error instanceof SandboxPortError) return error;
417
431
  const message = errorMessage(error).toLowerCase();
@@ -664,11 +678,11 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
664
678
  id,
665
679
  PROCESS_SESSION_ID,
666
680
  );
667
- if (!process) {
668
- throw new SandboxPortError(
669
- "not_found",
670
- "background process was not found",
671
- );
681
+ if (
682
+ !process ||
683
+ (process.status !== "starting" && process.status !== "running")
684
+ ) {
685
+ return { stopped: false };
672
686
  }
673
687
  if (this.processExpired(id)) {
674
688
  await this.client
@@ -683,6 +697,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
683
697
  this.emit("sandbox.process.stopped", { success: true });
684
698
  return { stopped: true };
685
699
  } catch (error) {
700
+ if (processAlreadyStopped(error)) return { stopped: false };
686
701
  throw mappedError(error);
687
702
  }
688
703
  });
@@ -4,7 +4,6 @@ import type {
4
4
  RunAgentToolOptions,
5
5
  } from "agents";
6
6
  import type {
7
- RuntimeBrowserPort,
8
7
  RuntimeMemoryPort,
9
8
  RuntimePlatformPort,
10
9
  RuntimeSubagentPort,
@@ -15,7 +14,11 @@ import type { RuntimeDegradation } from "../../../kernel/degradation";
15
14
  import type { RuntimeMemoryProfile } from "../../../kernel/profile";
16
15
  import { AGENT_TYPES } from "../../../layers/orchestration/subagents/agent-types/registry";
17
16
  import type { RuntimeAgentConfigContext } from "../../../runtime-agent-context";
18
- import { createWorkspaceCodeExecutionFactory } from "../../../pi/tool/core-host";
17
+ import {
18
+ createBrowserExecutionFactory,
19
+ createWorkspaceCodeExecutionFactory,
20
+ type RuntimeBrowserBinding,
21
+ } from "../../../pi/tool/core-host";
19
22
  import {
20
23
  createCloudflareSandboxAdapter,
21
24
  type SandboxAdmission,
@@ -33,7 +36,8 @@ export type PlatformLoader = () => Promise<RuntimePlatformPort>;
33
36
 
34
37
  export interface CloudflarePlatformBindings {
35
38
  LOADER: WorkerLoader;
36
- BROWSER?: RuntimeBrowserPort;
39
+ /** Browser Rendering 绑定(可选):CDP Code Mode 的 `browser_execute` 由它开出浏览器会话。 */
40
+ BROWSER?: RuntimeBrowserBinding;
37
41
  }
38
42
 
39
43
  export interface WorkspaceRequirement {
@@ -97,7 +101,18 @@ export function createWorkspaceLoader(
97
101
 
98
102
  export function createPlatformLoader<
99
103
  Env extends Cloudflare.Env & CloudflarePlatformBindings,
100
- >(context: RuntimeAgentConfigContext<Env>): PlatformLoader {
104
+ >(
105
+ context: RuntimeAgentConfigContext<Env>,
106
+ options: {
107
+ /**
108
+ * 本 Session 的 Workspace preview URL(可以带短期签名 query)。
109
+ *
110
+ * 只有 Host 知道公开 origin、这个 Session 的身份和签名方式,所以只能由 Host 传进来。
111
+ * 调用方拿不到就别传 —— 浏览器工具会因此不声称自己能打开产物。
112
+ */
113
+ previewBaseUrl?: string;
114
+ } = {},
115
+ ): PlatformLoader {
101
116
  let cached: ReturnType<PlatformLoader> | undefined;
102
117
  return () => cached ??= Promise.resolve().then(() => {
103
118
  const exports = (context.ctx as unknown as {
@@ -107,7 +122,19 @@ export function createPlatformLoader<
107
122
  }).exports;
108
123
  return {
109
124
  loader: context.env.LOADER,
110
- ...(context.env.BROWSER ? { browser: context.env.BROWSER } : {}),
125
+ // 绑定缺失时整条浏览器能力不进 Platform Port,Tool Surface 因此注册空集。
126
+ ...(context.env.BROWSER
127
+ ? {
128
+ browser: createBrowserExecutionFactory({
129
+ ctx: context.ctx,
130
+ loader: context.env.LOADER,
131
+ browser: context.env.BROWSER,
132
+ ...(options.previewBaseUrl
133
+ ? { previewBaseUrl: options.previewBaseUrl }
134
+ : {}),
135
+ }),
136
+ }
137
+ : {}),
111
138
  outbound: () => exports.HttpGateway({}),
112
139
  };
113
140
  });
package/src/db/schema.ts CHANGED
@@ -46,6 +46,16 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
46
46
  if (!submissionColumns.has("admission_retryable")) {
47
47
  sql`ALTER TABLE pi_submissions ADD COLUMN admission_retryable INTEGER`;
48
48
  }
49
+ // 跨执行片累计的模型回合数。让步把一个 Turn 切成多片后,每片的回合计数都从 0 开始,
50
+ // 只有把总数落到持久层,全局预算才拦得住「一直让步、永不收敛」的工具循环。
51
+ if (!submissionColumns.has("model_turns")) {
52
+ sql`ALTER TABLE pi_submissions
53
+ ADD COLUMN model_turns INTEGER NOT NULL DEFAULT 0`;
54
+ }
55
+ // 当前让步片的标识。用来丢弃重复到期的续跑 alarm,并让看门狗判断这一轮有没有推进。
56
+ if (!submissionColumns.has("continuation_id")) {
57
+ sql`ALTER TABLE pi_submissions ADD COLUMN continuation_id TEXT`;
58
+ }
49
59
  sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_run_id
50
60
  ON pi_submissions(run_id) WHERE run_id IS NOT NULL`;
51
61
  if (!submissionColumns.has("queued_input_json")) {
@@ -53,6 +53,10 @@ export interface StoredSubmission {
53
53
  rateVersion: number | null;
54
54
  slotIdentity: string | null;
55
55
  retryable?: boolean;
56
+ /** 跨执行片累计的模型回合数,全局回合预算的唯一事实来源。 */
57
+ modelTurns: number;
58
+ /** 当前让步执行片的标识;没有未完成让步时为 null。 */
59
+ continuationId: string | null;
56
60
  }
57
61
 
58
62
  export interface NewSubmission {
@@ -97,6 +101,8 @@ type SubmissionRow = {
97
101
  rate_version: number | null;
98
102
  slot_identity: string | null;
99
103
  admission_retryable: number | null;
104
+ model_turns: number;
105
+ continuation_id: string | null;
100
106
  };
101
107
 
102
108
  // #endregion
@@ -131,6 +137,8 @@ function mapRow(row: SubmissionRow): StoredSubmission {
131
137
  retryable: row.admission_retryable === null
132
138
  ? undefined
133
139
  : row.admission_retryable === 1,
140
+ modelTurns: row.model_turns ?? 0,
141
+ continuationId: row.continuation_id,
134
142
  };
135
143
  }
136
144
 
@@ -153,7 +161,8 @@ export class SubmissionRepository {
153
161
  queued_input_json, queued_ui_message_json, user_message_id,
154
162
  regenerate_message_id, recovery_error_count, recovery_reason,
155
163
  run_id, account_id, rate_version, slot_identity,
156
- admission_retryable
164
+ admission_retryable,
165
+ model_turns, continuation_id
157
166
  FROM pi_submissions
158
167
  WHERE submission_id = ${id}
159
168
  `[0];
@@ -413,6 +422,40 @@ export class SubmissionRepository {
413
422
  `[0]?.recovery_error_count ?? 0;
414
423
  }
415
424
 
425
+ /**
426
+ * 记录一个让步执行片的累计模型回合数和续跑标识。
427
+ *
428
+ * @remarks
429
+ * Runtime 在一片主动让出后、排出续跑调度之前调用。
430
+ *
431
+ * 两个值必须一起写:全局回合预算靠 `model_turns` 拦住不收敛的工具循环,
432
+ * 续跑去重和看门狗靠 `continuation_id` 判断这一轮到底有没有推进。
433
+ * 只写非终态记录,避免一次迟到的写入复活已经结束的 Submission。
434
+ */
435
+ recordYieldedSlice(
436
+ id: string,
437
+ modelTurns: number,
438
+ continuationId: string,
439
+ ): void {
440
+ this.sql`
441
+ UPDATE pi_submissions
442
+ SET model_turns = ${modelTurns},
443
+ continuation_id = ${continuationId}
444
+ WHERE submission_id = ${id}
445
+ AND status IN ('pending', 'running')
446
+ `;
447
+ }
448
+
449
+ /** 记录累计模型回合数,不改动续跑标识。终态提交前的最后一次结算用它。 */
450
+ recordModelTurns(id: string, modelTurns: number): void {
451
+ this.sql`
452
+ UPDATE pi_submissions
453
+ SET model_turns = ${modelTurns}
454
+ WHERE submission_id = ${id}
455
+ AND status IN ('pending', 'running')
456
+ `;
457
+ }
458
+
416
459
  clearRecoveryReason(id: string): void {
417
460
  this.sql`
418
461
  UPDATE pi_submissions
package/src/index.ts CHANGED
@@ -135,10 +135,12 @@ export {
135
135
  export { skillPiToolCandidates } from "./pi/tool";
136
136
  export type { PiSkillBinding } from "./pi/tool";
137
137
  export {
138
- browserQuickActionPiToolCandidates,
138
+ BROWSER_EXECUTE_TOOL_NAME,
139
+ browserExecutionPiToolCandidate,
139
140
  codeExecutionPiToolCandidate,
140
141
  } from "./pi/tool";
141
142
  export {
143
+ createBrowserExecutionFactory,
142
144
  createWorkspaceCodeExecutionFactory,
143
145
  } from "./pi/tool";
144
146
  export {
@@ -2,7 +2,7 @@ import type { SkillSource } from "agents/skills";
2
2
  import type { ScheduleSpec } from "./receipts";
3
3
  import type { RuntimeActivityProjection } from "./state";
4
4
  import type { ExecutionLevel } from "../lib/execution-level";
5
- import type { RuntimeCodeExecutionFactory } from "../tool-registry";
5
+ import type { ToolRegistry } from "./tool-surface";
6
6
  import type {
7
7
  RuntimeEventConfirmation,
8
8
  RuntimeLifecycleFact,
@@ -856,26 +856,23 @@ export interface RuntimeTurnEventsPort {
856
856
  }
857
857
 
858
858
  /**
859
- * 向 Runtime 浏览器工具提供 Browser Run Quick Action 调用。
859
+ * 向 Runtime 提供一个真实无头浏览器的 CDP Code Mode 执行能力。
860
860
  *
861
861
  * @remarks
862
- * Platform Plugin 在 Browser 绑定存在时生成快捷操作工具,工具在读取、提取或抓取页面时调用。
862
+ * Platform Plugin 在 Browser 绑定存在时准备它,Tool Surface 决定注册 `browser_execute` 时才调用 `create()`。
863
863
  *
864
- * 该 Port 保留 Browser Run 的通用 `action + options` 协议,具体工具仍由 `agents/browser` 辅助函数组装。
864
+ * 该 Port 只承载「能不能开出一个浏览器执行端口」,浏览器会话生命周期与 CDP 协议细节都留在 `agents/browser`,
865
+ * 避免本仓复制一份会随上游漂移的协议。旧的 Quick Action `action + options` 单方法协议已于 2026-08-13 退役:
866
+ * 复测确认本地绑定至今不实现 `quickAction()`,该面在本仓从未执行过。
865
867
  */
866
868
  export interface RuntimeBrowserPort {
867
869
  /**
868
- * 执行一个 Browser Run Quick Action 并返回原始响应。
870
+ * 创建本次装配的浏览器代码执行端口。
869
871
  *
870
872
  * @remarks
871
- * `agents/browser` 的快捷操作辅助函数在对应 Pi 工具执行期调用。
872
- *
873
- * Runtime 不复制 Browser Run 的选项联合类型,避免两份协议随上游漂移。
873
+ * Tool Surface 在浏览器工具确实可见时调用一次,延迟到此刻是为了让被 deny 的装配不白建连接器。
874
874
  */
875
- quickAction(
876
- action: string,
877
- options: unknown,
878
- ): Promise<Response>;
875
+ create(): RuntimeCodeExecutionPort;
879
876
  }
880
877
 
881
878
  // #endregion
@@ -942,7 +939,7 @@ export interface RuntimeProviderPort {
942
939
  export interface RuntimePlatformPort {
943
940
  /** Workspace Codemode 在创建 Dynamic Worker 执行器时使用的 Worker Loader。 */
944
941
  loader: WorkerLoader;
945
- /** Platform Plugin 存在 Browser Run 绑定时用它生成浏览器工具。 */
942
+ /** Platform Plugin 存在 Browser Run 绑定时用它生成 `browser_execute`;缺失即整条浏览器 Tool 面不注册。 */
946
943
  browser?: RuntimeBrowserPort;
947
944
  /**
948
945
  * 为一次 Dynamic Worker 组装取得已限定的网络出口。
@@ -1021,6 +1018,18 @@ export interface RuntimeSkillSourceBinding {
1021
1018
  *
1022
1019
  * 它不携带业务 ID、Repository、数据库 Key、任意能力注册表或凭据配置;术语见 `../index.ts`。
1023
1020
  */
1021
+ /**
1022
+ * 延迟到最终 Tool Surface 完成后再创建 Code Mode 执行能力。
1023
+ *
1024
+ * @remarks
1025
+ * 定义在这里而不是 `tool-registry.ts`:它的返回类型是本文件的
1026
+ * `RuntimeCodeExecutionPort`,而 `RuntimeBindings` 又要引用这个工厂 —— 放在
1027
+ * tool-registry 会让两个模块互相 import。`tool-registry.ts` 继续对外导出它。
1028
+ */
1029
+ export interface RuntimeCodeExecutionFactory {
1030
+ create(tools: ToolRegistry): RuntimeCodeExecutionPort;
1031
+ }
1032
+
1024
1033
  export interface RuntimeBindings {
1025
1034
  provider: RuntimeProviderPort;
1026
1035
  platform: RuntimePlatformPort;
@@ -353,10 +353,41 @@ export abstract class RecoverableChatAgent<
353
353
  * 清理延后执行,为断线重连的客户端保留终态流片段。
354
354
  */
355
355
  protected completeRecoverableStream(streamId: string): void {
356
- const completedRequestId = this.resumableStream.activeRequestId;
357
- this.resumableStream.complete(streamId);
356
+ this.closeRecoverableStream(streamId, (id) =>
357
+ this.resumableStream.complete(id),
358
+ );
359
+ }
360
+
361
+ /**
362
+ * 密封一个只跑完当前执行切片的可续传流,把这一轮交给下一个切片继续。
363
+ *
364
+ * @remarks
365
+ * 子类在一次计划让步(Turn 未结束、但本次执行已让出)后调用;调用方随后必须
366
+ * 排出续跑,且**不能**同时发 `done` 终态帧 —— 这一轮还没有权威结果。
367
+ *
368
+ * 与 `completeRecoverableStream` 的差别只在语义:那个断言「业务终态已提交」,
369
+ * 这个断言「本切片不再产出,另一个切片接手」。底层动作相同是有意的 ——
370
+ * 让步不密封的话,`ResumableStream.start()` 会直接覆盖 `activeStreamId`,
371
+ * 旧行永远停在 `streaming`,缓冲区要等 abandoned 保留期才回收。
372
+ */
373
+ protected sealRecoverableStreamSlice(streamId: string): void {
374
+ this.closeRecoverableStream(streamId, (id) =>
375
+ this.resumableStream.complete(id),
376
+ );
377
+ }
378
+
379
+ // 作用:收口一条可续传流的终止写入与内存续传状态。
380
+ // 调用:完成、失败和切片密封三条路径共用。
381
+ // 原因:清理顺序(先读 activeRequestId、再写状态、最后比对续传归属)三处必须一致,
382
+ // 分开维护过一次就会漏掉 pendingResumeConnections 或 continuation 归属其中之一。
383
+ private closeRecoverableStream(
384
+ streamId: string,
385
+ close: (streamId: string) => void,
386
+ ): void {
387
+ const closedRequestId = this.resumableStream.activeRequestId;
388
+ close(streamId);
358
389
  this.pendingResumeConnections.clear();
359
- if (completedRequestId === this.continuation.activeRequestId) {
390
+ if (closedRequestId === this.continuation.activeRequestId) {
360
391
  this.continuation.activeRequestId = null;
361
392
  this.continuation.activeConnectionId = null;
362
393
  }
@@ -372,14 +403,9 @@ export abstract class RecoverableChatAgent<
372
403
  * 流错误标志与业务失败分开,因为续传协议和提交生命周期的责任不同。
373
404
  */
374
405
  protected failRecoverableStream(streamId: string): void {
375
- const erroredRequestId = this.resumableStream.activeRequestId;
376
- this.resumableStream.markError(streamId);
377
- this.pendingResumeConnections.clear();
378
- if (erroredRequestId === this.continuation.activeRequestId) {
379
- this.continuation.activeRequestId = null;
380
- this.continuation.activeConnectionId = null;
381
- }
382
- void this.ensureStreamCleanupScheduled();
406
+ this.closeRecoverableStream(streamId, (id) =>
407
+ this.resumableStream.markError(id),
408
+ );
383
409
  }
384
410
 
385
411
  /**
@@ -131,6 +131,25 @@ export class SubmissionQueueFullError extends Error {
131
131
  */
132
132
  export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
133
133
 
134
+ /**
135
+ * 说明一次执行是怎么被启动的。
136
+ *
137
+ * @remarks
138
+ * `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
139
+ *
140
+ * `recovery` 和 `continuation` 必须分开:前者的内存状态已经丢了、transcript 可能停在半路,
141
+ * 要走完整的里程碑重放;后者是本轮主动让出执行片,transcript 完好、什么都不用重建。
142
+ * 合成一个布尔量意味着最常跑的那条路径每次都去趟最脆弱的恢复代码,
143
+ * 而且重放成本会随让步次数增长。
144
+ */
145
+ export type SubmissionExecutionMode =
146
+ /** 首次执行一条新接收的提交。 */
147
+ | "fresh"
148
+ /** 中断后按持久事实重建并继续。 */
149
+ | "recovery"
150
+ /** 上一执行片主动让出后,接着跑同一条提交。 */
151
+ | "continuation";
152
+
134
153
  /**
135
154
  * 描述接收一次提交所需的标识和延迟创建步骤。
136
155
  *
@@ -183,10 +202,13 @@ interface SubmissionLifecycleOptions<
183
202
  // 调用:`admit` 只在新提交成功写入后调用。
184
203
  // 原因:重复提交只加入旧工作,不应清除现有终态。
185
204
  clearTerminal(): Promise<void>;
186
- // 作用:执行或恢复一条已持久化的提交。
205
+ // 作用:执行、恢复或续跑一条已持久化的提交。
187
206
  // 调用:`start` 在 `TurnQueue` 轮到该提交时调用。
188
- // 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime 负责。
189
- execute(submissionId: string, recovery: boolean): Promise<TSubmission>;
207
+ // 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime 负责;模式决定 Runtime 的前置动作。
208
+ execute(
209
+ submissionId: string,
210
+ mode: SubmissionExecutionMode,
211
+ ): Promise<TSubmission>;
190
212
  // 作用:记录可恢复的取消意图。
191
213
  // 调用:`cancel` 在同一存储事务内与取消原因一起写入。
192
214
  // 原因:持久化里程碑可以让恢复路径看到已经发生的取消。
@@ -300,22 +322,30 @@ export class SubmissionLifecycle<
300
322
  * 它复用 `start` 的实例内去重和串行队列,避免重复唤醒产生两个执行器。
301
323
  */
302
324
  recover(submissionId: string): Promise<TSubmission> {
303
- return this.start(submissionId, true);
325
+ return this.start(submissionId, "recovery");
304
326
  }
305
327
 
306
- /** 等当前切片退出后恢复同一条非终态 Submission,避免 planned wake 被实例内去重吞掉。 */
307
- async recoverAfterCurrent(submissionId: string): Promise<TSubmission> {
328
+ /**
329
+ * 等当前执行片退出后,接着跑同一条非终态 Submission。
330
+ *
331
+ * @remarks
332
+ * `_piPlannedContinuation` 在计划让步的唤醒里调用。先 await 当前执行片,
333
+ * 是为了避开 `executions` 的实例内去重 —— 否则这次唤醒会被合流到正在退场的那一片上。
334
+ *
335
+ * 走 `continuation` 而不是 `recovery`:这一轮什么都没丢,不需要重放里程碑。
336
+ */
337
+ async continueAfterCurrent(submissionId: string): Promise<TSubmission> {
308
338
  const current = this.executions.get(submissionId);
309
339
  if (current) {
310
340
  const outcome = await current;
311
341
  if (isTerminalSubmissionStatus(outcome.status)) return outcome;
312
342
  }
313
- return this.start(submissionId, true);
343
+ return this.start(submissionId, "continuation");
314
344
  }
315
345
 
316
346
  recoverHead(): Promise<TSubmission | null> {
317
347
  const running = this.options.store.findRunning();
318
- if (running) return this.start(running.submissionId, true);
348
+ if (running) return this.start(running.submissionId, "recovery");
319
349
  const pending = this.options.store.findNextPending();
320
350
  return pending
321
351
  ? this.start(pending.submissionId)
@@ -410,14 +440,14 @@ export class SubmissionLifecycle<
410
440
  // 原因:`TurnQueue` 防止不同 Pi Turn 重叠,`executions` 则防止同一标识重复入队。
411
441
  private start(
412
442
  submissionId: string,
413
- recovery = false,
443
+ mode: SubmissionExecutionMode = "fresh",
414
444
  ): Promise<TSubmission> {
415
445
  const existing = this.executions.get(submissionId);
416
446
  if (existing) return existing;
417
447
  let shouldPump = false;
418
448
  const started = this.queue
419
449
  .enqueue(submissionId, () =>
420
- this.options.execute(submissionId, recovery),
450
+ this.options.execute(submissionId, mode),
421
451
  )
422
452
  .then((outcome) => {
423
453
  // TODO(待确认): 当前类没有调用 `queue.reset()`,按现有调用链不会产生 `stale` 结果。
@@ -583,6 +613,19 @@ export class SubmissionLifecycle<
583
613
  await this.executions.get(submissionId)?.catch(() => undefined);
584
614
  }
585
615
 
616
+ /**
617
+ * 判断某条提交此刻是否有执行器在跑。
618
+ *
619
+ * @remarks
620
+ * 续跑看门狗在决定要不要接手前调用。
621
+ *
622
+ * 只反映本实例的内存状态 —— 这正是它要问的:跨实例的丢失由持久事实兜底,
623
+ * 而「有执行器在跑」只有本实例知道,误判会派发出重复的执行片。
624
+ */
625
+ isActive(submissionId: string): boolean {
626
+ return this.activeBySubmission.has(submissionId);
627
+ }
628
+
586
629
  /**
587
630
  * 返回当前唯一活动的执行器。
588
631
  *
@@ -0,0 +1,41 @@
1
+ import type { ExecutionLevel } from "../lib/execution-level";
2
+ import type { PiToolCandidate } from "../pi/tool/compiler";
3
+
4
+ /**
5
+ * Tool Surface 的形状类型。
6
+ *
7
+ * @remarks
8
+ * `kernel/bindings.ts` 和 `tool-registry.ts` 都要用它们,所以它们不能住在其中任何
9
+ * 一边:`RuntimeBindings.codeExecution` 的工厂签名需要 `ToolRegistry`,而
10
+ * `tool-registry.ts` 又需要 bindings 里的各个 Port —— 两边互相 import 就形成了
11
+ * depcruise 的 no-circular 违规(`bindings → tool-registry → bindings`)。
12
+ *
13
+ * 这里只放形状,不放行为:注册表的合并、筛选和构造仍然留在 `tool-registry.ts`。
14
+ *
15
+ * 术语见 `../index.ts`。
16
+ */
17
+
18
+ /** 模型调用 Tool 时传给 `execute` 的上下文。 */
19
+ export interface ToolContext {
20
+ readonly toolCallId: string;
21
+ readonly signal: AbortSignal;
22
+ }
23
+
24
+ /** Definition 作者声明的一个 Tool。 */
25
+ export interface ToolSpec extends Partial<
26
+ Omit<PiToolCandidate, "tool" | "requiredExecutionLevel">
27
+ > {
28
+ readonly label: string;
29
+ readonly description: string;
30
+ readonly parameters: unknown;
31
+ readonly requiredExecutionLevel: ExecutionLevel;
32
+ /** Require a fresh human decision for every call, regardless of execution level. */
33
+ readonly alwaysRequiresApproval?: boolean;
34
+ readonly execute: (
35
+ input: unknown,
36
+ ctx: ToolContext,
37
+ ) => Promise<unknown>;
38
+ }
39
+
40
+ /** 一次装配声明的全部 Tool,键即模型可见的工具名。 */
41
+ export type ToolRegistry = Record<string, ToolSpec>;
@@ -15,14 +15,48 @@ import {
15
15
  * must be given an explicit protected path here.
16
16
  */
17
17
 
18
- /** Maximum string leaf retained in durable tool output. */
19
- const STORAGE_LEAF_MAX_CHARS = 32 * 1024;
18
+ /**
19
+ * 预算常量的排序不变量(改任何一个都必须同时复核其余三个):
20
+ *
21
+ * MODEL_LEAF_MAX_CHARS < MODEL_REPLAY_THRESHOLD < SPILL_THRESHOLD
22
+ * ≤ STORAGE_LEAF_MAX_CHARS
23
+ * 4 KB 16 KB 32 KB 64 KB
24
+ *
25
+ * 这四个数回答四个不同问题。曾经其中两个共用一个常量,结果“放宽外置”和“收紧
26
+ * 历史”这两个互相冲突的诉求被绑在同一个旋钮上,只能二选一。
27
+ */
20
28
 
21
- /** Serialized outputs above this threshold get a narrower model view. */
22
- const MODEL_VIEW_THRESHOLD = 8 * 1024;
29
+ /**
30
+ * 持久化单个字符串叶子的上限。
31
+ *
32
+ * MUST ≥ 单页 read 的上限(见 `workspace-sandbox.ts` 的 `READ_PAGE_MAX_CHARS`),
33
+ * 否则持久记录会比模型当轮看到的内容还少,会话恢复后出现凭空缺页。
34
+ */
35
+ export const STORAGE_LEAF_MAX_CHARS = 64 * 1024;
23
36
 
24
- /** Maximum string leaf exposed in a structure-preserving model view. */
25
- const MODEL_LEAF_MAX_CHARS = 500;
37
+ /**
38
+ * 一次新的 Tool 结果多大才值得外置成 Workspace 文件。
39
+ *
40
+ * 只影响当轮新产生的结果,不影响历史重放。
41
+ */
42
+ export const SPILL_THRESHOLD = 32 * 1024;
43
+
44
+ /**
45
+ * 历史里一条旧 Tool 结果重放多少。
46
+ *
47
+ * 这条最紧,因为它对每个 Turn 的**全部**历史消息生效(见
48
+ * `pi/runtime-adapter/execution.ts` 的 `projectToolResultsForModel`):
49
+ * 放宽 1 KB 就是 N 条历史 × 1 KB。
50
+ */
51
+ export const MODEL_REPLAY_THRESHOLD = 16 * 1024;
52
+
53
+ /**
54
+ * 重放时单个字符串叶子的上限。
55
+ *
56
+ * 曾经是 500,小到让 Code Mode 这类返回长字符串的结果在**第二轮**就塌成碎片,
57
+ * 模型只能反复换姿势(`Object.values()`、`slice()`)去捞已经不存在的内容。
58
+ */
59
+ export const MODEL_LEAF_MAX_CHARS = 4 * 1024;
26
60
 
27
61
  const ELISION_RESERVE = 40;
28
62
  const PREVIEW_CHARS = 600;
@@ -118,7 +152,7 @@ export async function spillDurableToolOutput(
118
152
  },
119
153
  ): Promise<ArtifactRef | null> {
120
154
  const { text, ext } = serializeOutput(value);
121
- if (text.length <= MODEL_VIEW_THRESHOLD || !options.workspace) return null;
155
+ if (text.length <= SPILL_THRESHOLD || !options.workspace) return null;
122
156
  try {
123
157
  const hash = await sha256Hex(text);
124
158
  const path = `${DEFAULT_SPILL_DIR}/${hash.slice(0, 24)}.${ext}`;
@@ -133,9 +167,19 @@ export async function spillDurableToolOutput(
133
167
  bytes: new TextEncoder().encode(text).byteLength,
134
168
  hash: hash.slice(0, 16),
135
169
  preview: text.slice(0, PREVIEW_CHARS),
170
+ // 出口指令必须是可执行的:只说“去 read 这个路径”会诱导模型整读,而整读一个
171
+ // 刚刚因为过大被外置的文件毫无意义。这里明确要求分页,并告知 read 会返回
172
+ // nextOffset / eof 以便继续。
173
+ // 出口指令必须是可执行的,而且必须只承诺模型真的拿得到的东西:Provider 只把
174
+ // Tool 结果的 content 发给模型,所以这里不能引用只存在于 details 的字段。
175
+ // 溢出产物是 JSON,一个大字符串叶子会整块挤在一行上,而按行分页追不回被行宽
176
+ // 截断的内容 —— 那种情况要走 grep / bash,不能让模型以为 read 一定够用。
136
177
  note:
137
178
  "Output was large and has been saved to the Workspace file above. " +
138
- "Use the read tool with that path to retrieve the full content when needed.",
179
+ "Read it in pages with read(path, offset, limit) do not read it whole; " +
180
+ "each page ends with a footer telling you the line range and the next offset. " +
181
+ "This file is JSON, so a single large value can sit on one very long line: " +
182
+ "if a page reports that lines were cut short, use grep or bash on the path instead.",
139
183
  };
140
184
  } catch {
141
185
  return null;
@@ -145,7 +189,9 @@ export async function spillDurableToolOutput(
145
189
  // Bounds leaves first, then removes oldest array entries until the view fits.
146
190
  function structureView(output: unknown): unknown {
147
191
  const capped = truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
148
- if (serializeOutput(capped).text.length <= MODEL_VIEW_THRESHOLD) return capped;
192
+ if (serializeOutput(capped).text.length <= MODEL_REPLAY_THRESHOLD) {
193
+ return capped;
194
+ }
149
195
  if (typeof capped !== "object" || capped === null) return capped;
150
196
 
151
197
  const record = { ...(capped as Record<string, unknown>) };
@@ -159,7 +205,7 @@ function structureView(output: unknown): unknown {
159
205
  while (
160
206
  items.length > 1 &&
161
207
  serializeOutput({ ...record, [arrayKey]: items }).text.length >
162
- MODEL_VIEW_THRESHOLD
208
+ MODEL_REPLAY_THRESHOLD
163
209
  ) {
164
210
  items.shift();
165
211
  dropped += 1;
@@ -178,7 +224,7 @@ function structureView(output: unknown): unknown {
178
224
  /** Creates a non-destructive, smaller Tool-result projection for the model. */
179
225
  export function projectToolOutputForModel(output: unknown): unknown {
180
226
  try {
181
- return serializeOutput(output).text.length <= MODEL_VIEW_THRESHOLD
227
+ return serializeOutput(output).text.length <= MODEL_REPLAY_THRESHOLD
182
228
  ? output
183
229
  : structureView(output);
184
230
  } catch {