@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/src/lib/prompt.ts CHANGED
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Stable system-prompt sections shared by every Runtime profile.
3
3
  *
4
- * Only `PERSONA` is replaceable by `RuntimeProfile.systemPrompt`. The global
5
- * `SYSTEM_PROMPT`, environment, tool and safety facts remain because a custom
6
- * Agent persona must not redefine capabilities that the running kernel does
7
- * not actually provide.
4
+ * Only `PERSONA` is replaceable by `RuntimeProfile.systemPrompt`. Environment,
5
+ * tool and safety facts remain because a custom Agent persona must not redefine
6
+ * capabilities that the running kernel does not actually provide.
8
7
  * Dynamic skills, memory and context blocks are injected by their own modules
9
8
  * so this static prefix remains cacheable.
10
9
  */
@@ -15,13 +14,6 @@ export const PERSONA =
15
14
  "You can recall user-managed cold memory across sessions and keep working memory within the current Session, " +
16
15
  "manage files in your workspace, and use execute Code Mode for network requests and tool composition.";
17
16
 
18
- // Base constraint injected alongside every Agent-specific persona.
19
- export const SYSTEM_PROMPT =
20
- "Web development: when writing a website or web page, use browser-ready mode: emit browser-ready static files " +
21
- "that run directly in Workspace preview. Use ./ or ../ for every Workspace asset, never a root-relative path; " +
22
- "load CSS from HTML instead of importing it from JavaScript. Resolve third-party ESM with a full pinned HTTPS URL " +
23
- "or import map to a pinned CORS-enabled CDN. Do not require Vite, Node.js, bundling, or Sandbox at preview time.";
24
-
25
17
  // Explains how to reach capabilities instead of listing unsupported substitutes.
26
18
  export const RUNTIME =
27
19
  "Runtime: this agent runs on Cloudflare Workers. When execute is present, its Code Mode Dynamic Worker is your " +
@@ -56,7 +48,8 @@ export const TOOLS =
56
48
  "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
49
  "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
50
  "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. " +
51
+ "workspace file operations, tools.* for other host capabilities, a loop for repeated calls, and Promise.all for independent calls. " +
52
+ "For materialize_skill_resource, materialize every known set of Skill resources in one execute; never start one execute per resource. " +
60
53
  "Tools available only at the top level must stay Direct. " +
61
54
  "For web tasks, use web_search for web discovery, current facts, cited research, " +
62
55
  "and public URL analysis. For web access specifically, use execute Code Mode only for raw or customized network requests, structured " +
@@ -75,6 +68,30 @@ export const TOOLS =
75
68
  "only independent top-level-only Direct Tools should be issued together in one turn. When you reference " +
76
69
  "code, cite it as file_path:line_number.";
77
70
 
71
+ // The browser tool is the only way to observe what a page actually does; its
72
+ // failure mode is returning nothing and looking clean, so the shape is fixed here.
73
+ export const BROWSER =
74
+ "Browser: When browser_execute is present, it is for one thing execute cannot do — what a page actually " +
75
+ "does in a real browser: rendered result, console output, runtime exceptions, failed resource loads, CSP " +
76
+ "blocks, and state after an interaction. Everything else stays with execute: computation, files, and " +
77
+ "ordinary HTTP requests (fetching HTML is execute's job, not the browser's). " +
78
+ "After you write or change a web page in the workspace, open it once with browser_execute before you call " +
79
+ "the work done, and say in your reply what you checked and what you saw. " +
80
+ "The cdp connector is request/response only: it delivers no CDP events. Enabling Runtime/Log/Network and " +
81
+ "then reading cdp.getDebugLog() returns method names with no payload — a page full of errors reads as clean. " +
82
+ "So the fixed shape is: create a target and attach for a sessionId; BEFORE Page.navigate, install a page-side " +
83
+ "collector via Page.addScriptToEvaluateOnNewDocument that buffers console.*, a capture-phase window 'error' " +
84
+ "listener (it catches both uncaught exceptions and failed <script>/<img>/<link> loads), 'unhandledrejection' " +
85
+ "and 'securitypolicyviolation' into one page global; then navigate, poll Runtime.evaluate for " +
86
+ "document.readyState === 'complete' (you cannot await an event), then Runtime.evaluate that global back with " +
87
+ "returnByValue. When visual appearance matters, call Page.captureScreenshot and return " +
88
+ "`{ image: { mimeType: 'image/png', data: screenshot.data }, observations }`; the Runtime projects that image " +
89
+ "back to you, so evaluate the visible hierarchy, spacing, clipping and overlap against the request as well as " +
90
+ "the textual diagnostics. Issue CDP calls sequentially — never Promise.all — because call order is recorded for replay. " +
91
+ "Fix what you find and re-check; when the page comes back clean, stop — do not re-verify a page that is " +
92
+ "already clean. If browser_execute is absent, say plainly that you could not verify the page in a browser " +
93
+ "instead of implying you did.";
94
+
78
95
  // Uploaded-file routing prevents lossy markdown conversions from becoming data sources.
79
96
  export const FILES =
80
97
  "Uploaded files live under /uploads/ in your workspace; convertible formats have a companion " +
@@ -112,7 +129,7 @@ export const MEMORY =
112
129
  * @remarks
113
130
  * Pi 快照装配在生成每次 Runtime Snapshot 时调用;调用方只替换角色文本,不要重复拼接其他段落。
114
131
  *
115
- * 全局 SYSTEM_PROMPT 固定前置;Runtime、行为、规划、Tool、文件、交互和 Memory 按固定顺序追加,
132
+ * Runtime、行为、规划、Tool、浏览器、文件、交互和 Memory 按固定顺序追加,
116
133
  * 因为自定义角色不应重新定义实际 Kernel 没有的能力。
117
134
  *
118
135
  * 各段保持静态,动态 Skill、Context 和 Memory 由各自模块注入,避免这个可缓存前缀每回合改变。
@@ -121,12 +138,12 @@ export const MEMORY =
121
138
  */
122
139
  export function assembleSystemPrompt(base?: string | null): string {
123
140
  return [
124
- SYSTEM_PROMPT,
125
141
  base ?? PERSONA,
126
142
  RUNTIME,
127
143
  BEHAVIOR,
128
144
  PLANNING,
129
145
  TOOLS,
146
+ BROWSER,
130
147
  FILES,
131
148
  INTERACTION,
132
149
  MEMORY,
@@ -139,7 +156,7 @@ export function assembleSystemPrompt(base?: string | null): string {
139
156
  * @remarks
140
157
  * Pi SubAgent 在启动有界子任务前调用;调用方应传入该子代理类型自己的 persona。
141
158
  *
142
- * 子代理共享真实执行环境和基本行为,但不获得主 Agent 专属的规划、Memory 和用户交互指令,因为这些责任留在主循环。
159
+ * 子代理共享真实执行环境和基本行为,但不获得主 Agent 专属的 Skill、规划、Memory 和用户交互指令,因为这些责任留在主循环。
143
160
  *
144
161
  * 不要直接改成复用 `assembleSystemPrompt`,否则子代理会收到它没有的 Session 记忆和对话交互责任。
145
162
  *
@@ -15,6 +15,8 @@ import { USER_STOP_REASON } from "../../kernel/receipts";
15
15
 
16
16
  const MAX_OUTPUT_BYTES = 256 * 1024;
17
17
  const MAX_OUTPUT_PREVIEW = 16 * 1024;
18
+ const PROVIDER_CREDIT_ERROR =
19
+ "The AI service is temporarily unavailable. Please try again later.";
18
20
 
19
21
  export interface PiToolApprovalView {
20
22
  readonly id: string;
@@ -101,6 +103,16 @@ function errorText(result: unknown): string {
101
103
  }
102
104
  }
103
105
 
106
+ /** Keep provider account details out of browser-facing error messages. */
107
+ export function publicAssistantError(message?: string): string | undefined {
108
+ if (!message) return undefined;
109
+ return /"limit_source"\s*:\s*"openrouter_credits"/.test(message) ||
110
+ (message.includes("Insufficient credits") &&
111
+ message.includes("openrouter.ai/settings/credits"))
112
+ ? PROVIDER_CREDIT_ERROR
113
+ : message;
114
+ }
115
+
104
116
  function toolCallAt(
105
117
  update: AssistantMessageEvent,
106
118
  ): Partial<ToolCall> | undefined {
@@ -318,7 +330,12 @@ export class PiChunkEncoder {
318
330
  case "toolcall_end":
319
331
  return this.encodeToolInput(update);
320
332
  case "error":
321
- return [{ type: "error", errorText: update.error.errorMessage ?? update.reason }];
333
+ return [{
334
+ type: "error",
335
+ errorText: publicAssistantError(
336
+ update.error.errorMessage ?? update.reason,
337
+ ) ?? "SpringBrand turn failed",
338
+ }];
322
339
  default:
323
340
  return [];
324
341
  }
@@ -421,6 +438,7 @@ export class PiChunkEncoder {
421
438
  return chunks;
422
439
  }
423
440
  this.finished = true;
441
+ const publicError = publicAssistantError(message.errorMessage);
424
442
  if (this.startedAt !== undefined) {
425
443
  const turnStatus = message.stopReason === "aborted"
426
444
  ? "aborted"
@@ -434,10 +452,10 @@ export class PiChunkEncoder {
434
452
  completedAt: message.timestamp,
435
453
  turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
436
454
  turnStatus,
437
- ...(message.errorMessage &&
455
+ ...(publicError &&
438
456
  (message.stopReason !== "aborted" ||
439
457
  message.errorMessage !== USER_STOP_REASON)
440
- ? { error: message.errorMessage }
458
+ ? { error: publicError }
441
459
  : {}),
442
460
  },
443
461
  });
@@ -449,7 +467,7 @@ export class PiChunkEncoder {
449
467
  if (message.stopReason === "error") {
450
468
  chunks.push({
451
469
  type: "error",
452
- errorText: message.errorMessage ?? "SpringBrand turn failed",
470
+ errorText: publicError ?? "SpringBrand turn failed",
453
471
  });
454
472
  return chunks;
455
473
  }
@@ -53,8 +53,31 @@ import { toolRegistryFromPiCandidates } from "../../tool-registry";
53
53
 
54
54
  // #region Single-run Pi bridge
55
55
 
56
+ // 一片执行最多跑多少个模型回合。这是 CPU 预算边界,不是任务边界:DO 的
57
+ // CPU 上限按 invocation 计,一条长回合跑在一次 invocation 里会把预算吃满并被驱逐,
58
+ // 让步换来的是一次全新 invocation 和全新预算。
56
59
  const MAX_MODEL_TURNS_PER_SLICE = 30;
57
60
 
61
+ // 一条 Submission 跨所有执行片最多跑多少个模型回合。这是任务边界:
62
+ // 没有它,一个不收敛的工具循环会无限让步、无限唤醒、无限烧 token。
63
+ export const MAX_MODEL_TURNS_PER_SUBMISSION = 300;
64
+
65
+ // 预算耗尽时注入的收尾指令。跟着它一起把工具表清空,模型只能出文本,
66
+ // 于是这一轮以一条正常的终止助手消息结束 —— 而不是硬判失败或继续让步。
67
+ // 走 user 角色而非 assistant:多个 provider 拒绝以模型回合结尾的请求。
68
+ const WRAP_UP_MESSAGE = "You have reached this run's model-turn budget. Stop " +
69
+ "calling tools now. Reply with a final message that states what you " +
70
+ "completed, what you did not finish, and the exact next step you would " +
71
+ "take. Do not start new work.";
72
+
73
+ const TOOL_FAILURE_WRAP_UP_MESSAGE = "This turn reached its tool failure limit. " +
74
+ "Stop calling tools now. Reply with a final message that explains what " +
75
+ "failed and the exact next step the user should take. Do not start new work.";
76
+
77
+ // 收尾开始后最多再放行几个模型回合。工具已经清空,正常一轮就结束了;
78
+ // 留出余量是给 steering 消息,但不能因此把本片的 CPU 上限一起取消。
79
+ const MAX_WRAP_UP_TURNS = 2;
80
+
58
81
  export class RetryableModelError extends Error {}
59
82
 
60
83
  function normalizePlanToolCalls(message: AgentMessage): void {
@@ -136,14 +159,41 @@ interface PiTurnAdapterOptions {
136
159
  // 直接复用 Pi 的回调类型可避免这里维护另一套上下文转换约定。
137
160
  readonly transformContext: NonNullable<AgentOptions["transformContext"]>;
138
161
  readonly workspace?: SpillWorkspace;
162
+ // 之前的执行片已经用掉的模型回合数。首次执行传 0。
163
+ readonly modelTurnsConsumed: number;
164
+ }
165
+
166
+ /**
167
+ * 收集那些结果已经由工具自己收口的 Tool 名字。
168
+ *
169
+ * @remarks
170
+ * `run` 在建 PiCore 前调用,把结果交给 `projectToolResultsForModel`。
171
+ *
172
+ * `outputBudget: "structure"` 的语义就是“这个结果不需要 Runtime 再替它省”:read
173
+ * 按页返回并在页脚写明覆盖的行号,activate_skill 返回的是模型接下来要照做的指令,
174
+ * Code Mode 的结果由它自己收口。对这些再套一次叶子上限,模型看到的正文会和结果里
175
+ * 写明的范围对不上 —— 页脚说 `lines 1-307`,正文却被从中间挖空。
176
+ *
177
+ * 其余工具仍然照常压缩:这道闸按分类放行,不是整体放宽。
178
+ */
179
+ function selfBoundedToolNames(
180
+ candidates: readonly PiToolCandidate[],
181
+ ): ReadonlySet<string> {
182
+ return new Set(
183
+ candidates
184
+ .filter((candidate) => candidate.outputBudget?.kind === "structure")
185
+ .map((candidate) => candidate.tool.name),
186
+ );
139
187
  }
140
188
 
141
189
  function projectToolResultsForModel(
142
190
  messages: readonly AgentMessage[],
191
+ selfBounded: ReadonlySet<string>,
143
192
  ): AgentMessage[] {
144
193
  let changed = false;
145
194
  const projected = messages.map((message) => {
146
195
  if (message.role !== "toolResult") return message;
196
+ if (selfBounded.has(message.toolName)) return message;
147
197
  const payload = {
148
198
  content: message.content,
149
199
  details: message.details,
@@ -231,8 +281,14 @@ class PiTurnAdapter {
231
281
  signal?.throwIfAborted();
232
282
  const canonicalMessages = await this.opts.canonicalMessages();
233
283
  const tools = this.compile(opts.tools);
234
- let modelTurns = 0;
284
+ const selfBounded = selfBoundedToolNames(opts.tools);
285
+ // `modelTurns` 是跨执行片的累计数,`sliceTurns` 只数本片 —— 前者管任务预算,
286
+ // 后者管 CPU 预算,两个边界互不替代。
287
+ let modelTurns = this.opts.modelTurnsConsumed;
288
+ let sliceTurns = 0;
235
289
  let reachedModelTurnLimit = false;
290
+ let wrappingUp = false;
291
+ let wrapUpTurns = 0;
236
292
  const pi = new PiCore({
237
293
  convertToLlm,
238
294
  streamFn: withProviderRetry(
@@ -248,14 +304,59 @@ class PiTurnAdapter {
248
304
  transformContext: async (messages, signal) => {
249
305
  const ctx = await this.opts.transformContext(messages, signal);
250
306
  return transformMessages(
251
- projectToolResultsForModel(ctx) as Message[],
307
+ projectToolResultsForModel(ctx, selfBounded) as Message[],
252
308
  this.opts.pi.model,
253
309
  ) as AgentMessage[];
254
310
  },
255
311
  afterToolCall: this.governance.afterToolCall,
312
+ // Pi 在每个 turn_end 之后先调 prepareNextTurn、再调 shouldStopAfterTurn,
313
+ // 所以回合计数记在这里,停止判据只读不写。
314
+ // 用 `WithContext` 变体:只有它拿得到当前上下文,也就拿得到工具表和消息。
315
+ prepareNextTurnWithContext: ({ context }) => {
316
+ modelTurns += 1;
317
+ sliceTurns += 1;
318
+ if (wrappingUp) {
319
+ wrapUpTurns += 1;
320
+ return undefined;
321
+ }
322
+ const toolFailureLimitReached = this.governance.wrapUpRequested();
323
+ if (
324
+ !toolFailureLimitReached &&
325
+ modelTurns < MAX_MODEL_TURNS_PER_SUBMISSION - 1
326
+ ) return undefined;
327
+ // 预算见底或工具熔断:清空工具表并注入收尾指令,逼出一条真正的
328
+ // 终止助手消息。直接判失败的话用户拿不到任何交代。
329
+ // 这条指令不进 newMessages,因此不会写进 transcript —— 它是控制指令,不是历史。
330
+ wrappingUp = true;
331
+ return {
332
+ context: {
333
+ ...context,
334
+ tools: [],
335
+ messages: [
336
+ ...context.messages,
337
+ {
338
+ role: "user" as const,
339
+ content: [{
340
+ type: "text" as const,
341
+ text: toolFailureLimitReached
342
+ ? TOOL_FAILURE_WRAP_UP_MESSAGE
343
+ : WRAP_UP_MESSAGE,
344
+ }],
345
+ timestamp: Date.now(),
346
+ },
347
+ ],
348
+ },
349
+ };
350
+ },
256
351
  shouldStopAfterTurn: () => {
257
- reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS_PER_SLICE;
258
- return signal?.aborted === true || reachedModelTurnLimit;
352
+ if (signal?.aborted === true) return true;
353
+ // 收尾那一轮必须跑完,否则让步会把唯一的终态机会顶掉。但「必须跑完」
354
+ // 不等于「不再有上限」:Pi 在这之后还会取 steering 和 follow-up 消息,
355
+ // 一直有新消息进来就会一直发模型请求,而且全在同一个 invocation 里 ——
356
+ // 那正是 MAX_MODEL_TURNS_PER_SLICE 要挡的 CPU 驱逐。
357
+ if (wrappingUp) return wrapUpTurns >= MAX_WRAP_UP_TURNS;
358
+ reachedModelTurnLimit = sliceTurns >= MAX_MODEL_TURNS_PER_SLICE;
359
+ return reachedModelTurnLimit;
259
360
  },
260
361
  initialState: {
261
362
  model: this.opts.pi.model,
@@ -286,8 +387,8 @@ class PiTurnAdapter {
286
387
  }
287
388
  await running;
288
389
  return reachedModelTurnLimit
289
- ? { kind: "yielded" }
290
- : { kind: "terminal" };
390
+ ? { kind: "yielded", modelTurns }
391
+ : { kind: "terminal", modelTurns };
291
392
  } finally {
292
393
  signal?.removeEventListener("abort", abort);
293
394
  unsubscribe();
@@ -393,6 +494,8 @@ export interface CreatePreparedPiTurnOptions {
393
494
  readonly startedAt: number;
394
495
  readonly continuation: boolean;
395
496
  readonly assistantOrdinal: number;
497
+ /** 之前的执行片已用掉的模型回合数;省略按 0 处理。 */
498
+ readonly modelTurnsConsumed?: number;
396
499
  };
397
500
  /**
398
501
  * 在 Pi 启动前读取最新 canonical transcript。
@@ -474,8 +577,10 @@ export interface PiPreparedTurnRunOptions {
474
577
  }
475
578
 
476
579
  export type PiTurnRunResult =
477
- | { readonly kind: "terminal" }
478
- | { readonly kind: "yielded" };
580
+ /** Turn 已产生权威结果,或已经收尾。 */
581
+ | { readonly kind: "terminal"; readonly modelTurns: number }
582
+ /** 本片让出执行,Turn 未结束;调用方必须排出续跑。 */
583
+ | { readonly kind: "yielded"; readonly modelTurns: number };
479
584
 
480
585
  // #endregion
481
586
 
@@ -724,6 +829,7 @@ export class PreparedPiTurnAdapter {
724
829
  this.interrupted ? undefined : options.durability.settleTool(call),
725
830
  transformContext: options.transformContext,
726
831
  workspace: state.snapshot.bindings.workspace,
832
+ modelTurnsConsumed: options.submission.modelTurnsConsumed ?? 0,
727
833
  });
728
834
  this.systemPrompt = descriptor.systemPrompt;
729
835
  this.options = options;
@@ -835,7 +941,7 @@ export class PreparedPiTurnAdapter {
835
941
  );
836
942
  if (this.terminalIntent) {
837
943
  await this.options.onTerminal(this.terminalIntent);
838
- return { kind: "terminal" };
944
+ return { kind: "terminal", modelTurns: result.modelTurns };
839
945
  }
840
946
  return result;
841
947
  }
@@ -17,6 +17,7 @@ import {
17
17
  applyPiToolResult,
18
18
  captureUIUserSidecar,
19
19
  piAssistantToUIMessage,
20
+ publicAssistantError,
20
21
  type PiToolApprovalView,
21
22
  type UIUserSidecar,
22
23
  } from "../message";
@@ -655,7 +656,9 @@ export class PiRuntimeTranscript {
655
656
  completedAt - submission.createdAt,
656
657
  ),
657
658
  turnStatus: "error",
658
- ...(submission.error ? { error: submission.error } : {}),
659
+ ...(submission.error
660
+ ? { error: publicAssistantError(submission.error) }
661
+ : {}),
659
662
  },
660
663
  });
661
664
  seenAssistantSubmissions.add(submissionId);
@@ -761,7 +764,9 @@ export class PiRuntimeTranscript {
761
764
  ),
762
765
  }),
763
766
  turnStatus: submission?.status,
764
- ...(submission?.error ? { error: submission.error } : {}),
767
+ ...(submission?.error
768
+ ? { error: publicAssistantError(submission.error) }
769
+ : {}),
765
770
  },
766
771
  approvals: this.approvalViews(entry.submissionId),
767
772
  });
@@ -48,6 +48,8 @@ export interface PiToolCandidate {
48
48
  readonly tool: AgentTool<any, any>;
49
49
  /** Keep this Tool Direct-only instead of also offering it through Code Mode. */
50
50
  readonly direct?: true;
51
+ /** Offer this Tool only through Code Mode, never as a top-level Tool. */
52
+ readonly codeExecutionOnly?: true;
51
53
  /** @internal Tools also callable through this Code Mode candidate. */
52
54
  readonly codeExecutionTools?: readonly PiToolCandidate[];
53
55
  /** Conservative maximum used in the stable Runtime descriptor. */
@@ -85,6 +87,7 @@ const governanceState = Symbol("PiToolGovernanceState");
85
87
  /** 向 Pi Agent 提供工具后置钩子,并为编译器保留同一 Turn 的治理状态。 */
86
88
  export interface PiToolGovernance {
87
89
  readonly afterToolCall: NonNullable<AgentOptions["afterToolCall"]>;
90
+ readonly wrapUpRequested: () => boolean;
88
91
  readonly [governanceState]: PiToolGovernanceState;
89
92
  }
90
93
 
@@ -222,7 +225,7 @@ function recordFailure(
222
225
 
223
226
  // 在执行前判断这次工具调用是否已超过重试或 Turn 上限。
224
227
  // 受治理 execute 方法对每个调用首先调用它。
225
- // 被阻断的 call id 必须记入 terminalCalls,才能让 Pi 的 afterToolCall 在返回错误后终止 Turn。
228
+ // 被阻断的 call id 必须记入 terminalCalls,才能让 Pi 在返回错误后进入无工具收尾回合。
226
229
  function blockReason(
227
230
  state: PiToolGovernanceState | undefined,
228
231
  toolCallId: string,
@@ -263,15 +266,16 @@ export function createPiToolGovernance(): PiToolGovernance {
263
266
  };
264
267
  const governance: PiToolGovernance = {
265
268
  [governanceState]: state,
266
- // 在必须终止的工具调用后告诉 Pi 停止当前 Turn。
267
- // Pi Agent 在每次工具执行结束后调用,并传入实际 toolCall id
268
- // 只消费 terminalCalls 中的 id,可避免一次失败误终止后续无关工具调用。
269
+ // Pi Agent 在每次工具执行结束后调用,并传入实际 toolCall id。只消费
270
+ // terminalCalls 中的 id,可避免一次失败误触发收尾;不能在这里直接
271
+ // terminate,否则模型没有机会产出权威 assistant 消息。
269
272
  async afterToolCall(context) {
270
273
  if (!state.terminalCalls.delete(context.toolCall.id)) {
271
274
  return;
272
275
  }
273
- return { terminate: true };
276
+ state.aborted = true;
274
277
  },
278
+ wrapUpRequested: () => state.aborted,
275
279
  };
276
280
  return governance;
277
281
  }
@@ -2,9 +2,12 @@ import {
2
2
  createWorkspaceStateBackend,
3
3
  type WorkspaceFsLike,
4
4
  } from "@cloudflare/shell";
5
+ import { createBrowserTools } from "@cloudflare/think/tools/browser";
5
6
  import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
6
7
  import { jsonSchema, tool as aiTool, type ToolSet } from "ai";
7
8
  import type {
9
+ RuntimeBrowserPort,
10
+ RuntimeCodeExecutionPort,
8
11
  WorkspacePort,
9
12
  } from "../../kernel/bindings";
10
13
  import type {
@@ -16,6 +19,16 @@ import type {
16
19
 
17
20
  const CODEMODE_SANDBOX_TIMEOUT_MS = 55_000;
18
21
 
22
+ /**
23
+ * 宿主的 Browser Rendering 绑定,只取本仓真正用到的那一面。
24
+ *
25
+ * 结构化声明而不是 `Fetcher`:`wrangler types` 现在为这个绑定生成 `BrowserRun` 而不是 `Fetcher`,
26
+ * 两种声明都得能传进来(上游 `BrowserBinding` 出于同样理由也是结构化的)。
27
+ */
28
+ export interface RuntimeBrowserBinding {
29
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
30
+ }
31
+
19
32
  /**
20
33
  * 把宿主的 Worker Loader、出站网络和 Workspace 组装成代码执行 Port。
21
34
  *
@@ -37,6 +50,89 @@ function codeExecutionTools(tools: ToolRegistry): ToolSet {
37
50
  ]));
38
51
  }
39
52
 
53
+ // 上游把 Code Mode 类工具交付为 AI SDK tool;本仓只取 description 和 execute 两件,
54
+ // 并在这里就地校验,使装配期缺件立刻失败,而不是等模型调用时才炸。
55
+ function toCodeExecutionPort(
56
+ tool: ToolSet[string],
57
+ name: string,
58
+ ): RuntimeCodeExecutionPort {
59
+ const { description, execute } = tool;
60
+ if (typeof description !== "string" || description.length === 0) {
61
+ throw new Error(`Upstream ${name} runtime returned a tool without a description`);
62
+ }
63
+ if (typeof execute !== "function") {
64
+ throw new Error(`Upstream ${name} runtime returned a non-executable tool`);
65
+ }
66
+ return {
67
+ description,
68
+ execute: (input) => Promise.resolve(execute(input, {
69
+ toolCallId: name,
70
+ messages: [],
71
+ context: undefined,
72
+ })),
73
+ };
74
+ }
75
+
76
+ // 只有这一条挂在 Tool description 上:preview URL 是 per-Session 的,
77
+ // 放进静态 system prompt 前缀会让它每轮都变,破坏 prompt cache。
78
+ // 怎么用 CDP 才不会静默读空,是与 Session 无关的固定动作,住在静态提示词里。
79
+ function previewNote(base: string): string {
80
+ return "Open the current Workspace app with this exact preview URL, unchanged: " +
81
+ `${base} Keep its path and query string intact; do not append a workspace file path. ` +
82
+ "It is the exact URL and Content-Security-Policy (sandbox allow-scripts) the user sees. " +
83
+ "file:// cannot reach the workspace, and there is no other preview host — do not guess one.";
84
+ }
85
+
86
+ /**
87
+ * 把宿主的 Durable Object state、Worker Loader 和 Browser Run 绑定组装成浏览器执行 Port。
88
+ *
89
+ * Worker 宿主在具备 DO state 与 Browser 绑定时调用,返回值经 Platform Port 交给 Runtime 工具组装。
90
+ *
91
+ * 形状与 `createWorkspaceCodeExecutionFactory` 同构:宿主提供 DO state 与平台绑定,Runtime 只拿到一个可选装配输入。
92
+ * `create()` 延迟到 Tool Surface 真的要注册时才调,被 deny 的装配不会白建连接器。
93
+ */
94
+ export function createBrowserExecutionFactory(options: {
95
+ readonly ctx: DurableObjectState;
96
+ readonly loader: WorkerLoader;
97
+ readonly browser: RuntimeBrowserBinding;
98
+ /**
99
+ * 本 Session 的 Workspace preview URL(可以带短期签名 query)。
100
+ *
101
+ * 宿主能确定公开 origin 并签发当前 Session 地址时提供;只有它能给出浏览器真的打得开的地址。
102
+ * 拿不到就不提供 —— 宁可让模型知道自己没有这条信息,也不要给一个猜的、会 404 的地址。
103
+ */
104
+ readonly previewBaseUrl?: string;
105
+ }): RuntimeBrowserPort {
106
+ return {
107
+ create() {
108
+ const tools = createBrowserTools({
109
+ ctx: options.ctx,
110
+ loader: options.loader,
111
+ browser: options.browser,
112
+ // 沙箱超时沿用 Code Mode 的同一个常量,先于 60s 外层截止结束。
113
+ timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
114
+ // 会话用上游默认的 one-shot:每次执行一个全新浏览器,两次自检互不污染。
115
+ // Quick Action 显式关闭:2026-08-13 复测本地绑定仍不实现 quickAction(),
116
+ // 让上游默认值把那四个工具带回来只会注册出本地必然失败的 Tool(knowledge F22)。
117
+ quickActions: false,
118
+ name: "browser",
119
+ });
120
+ const tool = tools.browser_execute;
121
+ if (!tool) {
122
+ throw new Error("Upstream createBrowserTools did not return browser_execute");
123
+ }
124
+ const port = toCodeExecutionPort(tool, "browser_execute");
125
+ if (!options.previewBaseUrl) return port;
126
+ // 前置而不是追加:上游的 codemode 描述很长,缀在末尾的一句会被读丢 ——
127
+ // 2026-08-13 手动冒烟里模型就没看见它,转而去猜 `file://` 和 `https://workspace.local/`。
128
+ return {
129
+ ...port,
130
+ description: `${previewNote(options.previewBaseUrl)}\n\n${port.description}`,
131
+ };
132
+ },
133
+ };
134
+ }
135
+
40
136
  export function createWorkspaceCodeExecutionFactory(options: {
41
137
  readonly ctx: DurableObjectState;
42
138
  readonly loader: WorkerLoader;
@@ -57,20 +153,13 @@ export function createWorkspaceCodeExecutionFactory(options: {
57
153
  ),
58
154
  name: "execute",
59
155
  });
60
- const { description, execute } = tool;
61
- if (typeof description !== "string" || description.length === 0) {
62
- throw new Error("Think createExecuteRuntime returned a tool without a description");
63
- }
64
- if (typeof execute !== "function") {
65
- throw new Error("Think createExecuteRuntime returned a non-executable tool");
66
- }
156
+ const port = toCodeExecutionPort(tool, "execute");
67
157
  return {
68
- description,
69
- execute: (input) => Promise.resolve(execute(input, {
70
- toolCallId: "execute",
71
- messages: [],
72
- context: undefined,
73
- })),
158
+ ...port,
159
+ description: port.description.replace(
160
+ "- Do not use `fetch` — use connector SDKs.",
161
+ "- Raw `fetch` is available inside `codemode.step(...)`; prefer connector SDKs when one owns the target.",
162
+ ),
74
163
  };
75
164
  },
76
165
  };