@springbrand/agent-runtime 0.3.0-alpha.25 → 0.3.0-alpha.27

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.3.0-alpha.25",
3
+ "version": "0.3.0-alpha.27",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -36,7 +36,7 @@
36
36
  "zod": "^4.4.3",
37
37
  "@earendil-works/pi-durable": "1.1.0",
38
38
  "@earendil-works/chord": "1.1.0",
39
- "@springbrand/model-client": "0.1.0-alpha.1"
39
+ "@springbrand/model-client": "0.1.0-alpha.3"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "agents": ">=0.28.1 <0.29.0",
@@ -73,6 +73,8 @@ import type {
73
73
  } from "@earendil-works/pi-ai";
74
74
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
75
75
  import { openCloudflareSession } from "../../session";
76
+ import type { ToolPrerequisiteResponse } from "../../tools/runtime/prerequisite-contracts";
77
+ import { matchesPrerequisite, parsePrerequisiteResponse, projectPrerequisites } from "../../tools/runtime/prerequisites";
76
78
  import { ModelRecovery, openSessionHarness, prepareSubmissionOperation } from "../../harness";
77
79
  import type { SessionHarness } from "../../harness";
78
80
  import {
@@ -158,6 +160,7 @@ export abstract class AgentRuntimeKernel<
158
160
 
159
161
  initialState: RuntimeState = {
160
162
  runtimeLoad: { status: "idle", available: false },
163
+ prerequisites: [],
161
164
  };
162
165
 
163
166
  protected abstract ensureRuntimeReady(): Promise<void>;
@@ -826,6 +829,10 @@ export abstract class AgentRuntimeKernel<
826
829
  interactions: this.db.interactions,
827
830
  transaction: (run) => this.db.transaction(run),
828
831
  release: (reason) => runtime.requestHumanRelease(reason),
832
+ isSubmissionActive: () => {
833
+ const current = this.db.submissions.find(submissionId);
834
+ return Boolean(current && current.status === "running" && !current.abortReason && !current.unattendedFailure);
835
+ },
829
836
  onChanged: async (kind, id) => {
830
837
  await this.broadcastApprovals();
831
838
  if (kind === "approval") {
@@ -1513,11 +1520,12 @@ export abstract class AgentRuntimeKernel<
1513
1520
  };
1514
1521
  }
1515
1522
 
1516
- /** Validate a client-provided Tool result and resume its durable Submission. */
1523
+ /** Answers settle a Tool result; full prerequisite decisions control pre-effect waits. */
1517
1524
  async respondToolInteraction(
1518
- toolCallId: string,
1519
- response: unknown,
1525
+ toolCallId: string | ToolPrerequisiteResponse,
1526
+ response?: unknown,
1520
1527
  ): Promise<{ ok: boolean }> {
1528
+ if (typeof toolCallId !== "string") return this.respondPrerequisite(toolCallId);
1521
1529
  const pending = this.db.interactions.findPendingByToolCallId(toolCallId);
1522
1530
  if (!pending) return { ok: false };
1523
1531
  const submission = this.db.submissions.find(pending.submissionId);
@@ -1552,6 +1560,29 @@ export abstract class AgentRuntimeKernel<
1552
1560
  return { ok: true };
1553
1561
  }
1554
1562
 
1563
+ private async respondPrerequisite(response: ToolPrerequisiteResponse): Promise<{ ok: boolean }> {
1564
+ const identity = parsePrerequisiteResponse(response);
1565
+ if (!identity) return { ok: false };
1566
+ const pending = this.db.interactions.find(identity.id);
1567
+ if (!matchesPrerequisite(pending, identity) || pending.status !== "pending") return { ok: false };
1568
+ const submission = this.db.submissions.findRunning();
1569
+ if (!submission || submission.submissionId !== pending.submissionId || submission.abortReason) return { ok: false };
1570
+ const active = this.submissions.currentActive();
1571
+ if (active && active.submissionId !== submission.submissionId) return { ok: false };
1572
+ // Decline is Config-independent, like an ordinary ask_user answer. It can win
1573
+ // while OAuth verification or the previous slice's parallel effects drain.
1574
+ if (identity.decision === "recheck" && !active) await this.ensureRuntimeVersion(submission.runtimeKey, true);
1575
+ const spec = identity.decision === "recheck"
1576
+ ? this.pi.prerequisite(this.requireRuntime().pi, pending.toolName) : undefined;
1577
+ const result = await this.humanDecisions.respondPrerequisite(pending, spec, identity);
1578
+ if (!result.ok) return { ok: false };
1579
+ await this.broadcastApprovals();
1580
+ if (result.resume) this.ctx.waitUntil(
1581
+ this.submissions.continueAfterCurrent(submission.submissionId).then(() => undefined),
1582
+ );
1583
+ return { ok: true };
1584
+ }
1585
+
1555
1586
  /** Apply one decision; the Host must persist allow_level before resuming. */
1556
1587
  async decideApproval(
1557
1588
  executionId: string,
@@ -1619,6 +1650,7 @@ export abstract class AgentRuntimeKernel<
1619
1650
  try {
1620
1651
  const { state, changed } = projectRuntimeState({
1621
1652
  approvals: this.db.approvals.listPendingActive(),
1653
+ prerequisites: projectPrerequisites(this.db.interactions.listPendingActive()),
1622
1654
  temporaryApprovals: this.temporaryAgentApprovals.list(),
1623
1655
  running: this.db.submissions.findRunning(),
1624
1656
  pending: this.db.submissions.listPending(),
@@ -21,8 +21,8 @@ import {
21
21
  // 数据库术语以这里为准。
22
22
  // Submission 是一次可持久化的用户提交,从排队到终态都用同一个 submissionId 跟踪。
23
23
  // Approval 是某次工具调用的持久化审批记录,executionId 是对外决策时的主键。
24
- // Tool Interaction 是「结果由客户端提供」的工具调用的 park 记录,interactionId 是主键;
25
- // 它保存人的回答,Harness Session 保存工具执行位置和最终 ToolResult。
24
+ // Tool Interaction 保存客户端答案或原工具的前置条件等待,interactionId 是主键;
25
+ // 它保存人的决定和有界核验事实,Pi 保存工具执行位置和最终 ToolResult。
26
26
  // Extension Context 是扩展按 label 保存的文本,不随清空聊天记录删除。
27
27
  // Message UI 是会话消息的用户界面补充数据,它由 Transcript 而不是 Turn 生命周期管理。
28
28
  // Agent Tool Run 是 Agents SDK 派发的子运行,唯一一张不归 Runtime 所有的表:SDK 建它、写它、清它,
@@ -20,6 +20,7 @@ import type {
20
20
  TemporaryAgentApprovalDecision,
21
21
  TemporaryAgentApprovalRequest,
22
22
  TemporaryAgentLaunchSelection,
23
+ ToolPrerequisiteResponse,
23
24
  } from "../../tools";
24
25
  import { TEMPORARY_AGENT_LAUNCH_KEY } from "../../tools";
25
26
  import {
@@ -215,14 +216,15 @@ export interface RuntimeAgentControls {
215
216
  decision: ApprovalDecision,
216
217
  ): Promise<{ ok: boolean }>;
217
218
  /**
218
- * 把客户端投递的响应作为某次 Tool 调用的结果并续跑原 Turn。
219
+ * Settle an answer Tool, or control a prerequisite using its full wait identity.
219
220
  *
220
221
  * @remarks
221
- * 前端经 WS RPC 调用,只带 `toolCallId`。查无、已结算或响应体不过校验都返回 `{ ok: false }`。
222
+ * Legacy answers use (toolCallId, answer). Prerequisites use one response object;
223
+ * only the backend verifies readiness. Missing/stale/invalid decisions return false.
222
224
  */
223
225
  respondToolInteraction(
224
- toolCallId: string,
225
- response: unknown,
226
+ toolCallId: string | ToolPrerequisiteResponse,
227
+ response?: unknown,
226
228
  ): Promise<{ ok: boolean }>;
227
229
  /**
228
230
  * 登记一条受管临时 Agent 发起的审批请求。
@@ -327,6 +329,8 @@ export interface RuntimeAgentConfigControls<Command, Change> {
327
329
 
328
330
  /** Type-only Host extension seam implemented by AgentRuntimeKernel. */
329
331
  declare class RuntimeAgentHostSeam {
332
+ protected runConfigControl<T>(run: () => Promise<T>): Promise<T>;
333
+ protected ensureRuntimeVersion(runtimeKey: string, force?: boolean): Promise<void>;
330
334
  protected prepareToolInteractionResponse(
331
335
  input: import("./agent").ToolInteractionResponsePreparation,
332
336
  ): Promise<unknown | null>;
@@ -626,8 +630,8 @@ export function defineRuntimeAgent<
626
630
  }
627
631
 
628
632
  respondToolInteraction(
629
- toolCallId: string,
630
- response: unknown,
633
+ toolCallId: string | ToolPrerequisiteResponse,
634
+ response?: unknown,
631
635
  ): Promise<{ ok: boolean }> {
632
636
  return super.respondToolInteraction(toolCallId, response);
633
637
  }
@@ -209,6 +209,28 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
209
209
  responded_at INTEGER,
210
210
  UNIQUE (submission_id, tool_call_id)
211
211
  )`;
212
+ const interactionColumns = new Set(
213
+ sql<{ name: string }>`PRAGMA table_info(pi_tool_interactions)`.map((column) => column.name),
214
+ );
215
+ // Old client-settled answers keep their original meaning on a rolling upgrade.
216
+ if (!interactionColumns.has("purpose")) {
217
+ sql`ALTER TABLE pi_tool_interactions ADD COLUMN purpose TEXT NOT NULL DEFAULT 'answer'`;
218
+ }
219
+ if (!interactionColumns.has("scope_key")) {
220
+ sql`ALTER TABLE pi_tool_interactions ADD COLUMN scope_key TEXT`;
221
+ }
222
+ if (!interactionColumns.has("version")) {
223
+ sql`ALTER TABLE pi_tool_interactions ADD COLUMN version INTEGER NOT NULL DEFAULT 1`;
224
+ }
225
+ if (!interactionColumns.has("presentation_json")) {
226
+ sql`ALTER TABLE pi_tool_interactions ADD COLUMN presentation_json TEXT`;
227
+ }
228
+ if (!interactionColumns.has("verified_until")) {
229
+ sql`ALTER TABLE pi_tool_interactions ADD COLUMN verified_until INTEGER`;
230
+ }
231
+ sql`CREATE INDEX IF NOT EXISTS pi_tool_interactions_scope
232
+ ON pi_tool_interactions(submission_id, scope_key, status)
233
+ WHERE purpose = 'prerequisite'`;
212
234
  sql`CREATE TABLE IF NOT EXISTS pi_extension_context (
213
235
  label TEXT PRIMARY KEY,
214
236
  content TEXT NOT NULL,
@@ -256,6 +256,9 @@ async function createToolSurface(
256
256
  if (!EXECUTION_LEVELS.includes(candidate.requiredExecutionLevel)) {
257
257
  throw new Error(`SpringBrand Tool ${name} execution level is invalid`);
258
258
  }
259
+ if (candidate.prerequisite && (candidate.interaction || exposureOf(candidate) === "codemode")) {
260
+ throw new Error(`SpringBrand Tool ${name} prerequisites require a direct, non-answer Tool`);
261
+ }
259
262
  if (candidate.unattendedExecution) {
260
263
  requiredName(
261
264
  candidate.unattendedExecution.capability,
@@ -293,6 +296,7 @@ async function createToolSurface(
293
296
  (candidate) =>
294
297
  exposureOf(candidate) !== "direct" &&
295
298
  !candidate.interaction &&
299
+ !candidate.prerequisite &&
296
300
  typeof candidate.tool.execute === "function",
297
301
  );
298
302
  const codeExecutionCandidates = Object.freeze([...mergeable]);
@@ -88,6 +88,12 @@ export class HarnessRuntimeConfig {
88
88
  )?.interaction;
89
89
  }
90
90
 
91
+ prerequisite(prepared: PreparedPiRuntime, name: string) {
92
+ return readPreparedPiRuntime(prepared, this.owner).candidates.find(
93
+ (candidate) => candidate.tool.name === name,
94
+ )?.prerequisite;
95
+ }
96
+
91
97
  /** Read all executable inputs from the current Prepared Runtime. */
92
98
  async harnessInputs(
93
99
  prepared: PreparedPiRuntime,
@@ -264,7 +264,16 @@ export interface WorkspaceStreamWriteOptions {
264
264
  */
265
265
  export interface RuntimeCodeExecutionPort {
266
266
  readonly description: string;
267
- execute(input: { code: string }): Promise<AgentToolResult<unknown>>;
267
+ /**
268
+ * Honour a provided signal before dispatch and throughout execution.
269
+ * On abort, stop suspended sandbox continuations and further Host dispatches,
270
+ * await execution finalization, and reject rather than accepting a late result.
271
+ * Already-started Host effects are not rolled back; CPU preemption is not guaranteed.
272
+ */
273
+ execute(
274
+ input: { code: string },
275
+ options?: { signal?: AbortSignal },
276
+ ): Promise<AgentToolResult<unknown>>;
268
277
  }
269
278
 
270
279
  /**
@@ -197,6 +197,7 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
197
197
  parameters: candidate.tool.parameters,
198
198
  requiredExecutionLevel: candidate.requiredExecutionLevel,
199
199
  ...(candidate.confirmation ? { confirmation: true } : {}),
200
+ ...(candidate.prerequisite ? { prerequisite: true } : {}),
200
201
  ...(candidate.alwaysRequiresApproval
201
202
  ? { alwaysRequiresApproval: true }
202
203
  : {}),
@@ -18,6 +18,13 @@ export * from "../conversation/canonical-context";
18
18
  export * from "../submission/contracts";
19
19
  export * from "../conversation/state";
20
20
  export * from "../lib/execution-level";
21
+ export type {
22
+ ToolPrerequisiteDecision,
23
+ ToolPrerequisiteIdentity,
24
+ ToolPrerequisitePresentation,
25
+ ToolPrerequisiteRequest,
26
+ ToolPrerequisiteResponse,
27
+ } from "../tools/runtime/prerequisite-contracts";
21
28
  export type { WorkspaceRevision, WorkspaceRevisionManifest } from "../tools/workspace/versioning";
22
29
  export type {
23
30
  RuntimeEventConfirmation,
@@ -2,6 +2,7 @@ import type { ApprovalReceipt } from "../submission/contracts";
2
2
  import type { StoredSubmission } from "../submission/repository";
3
3
  import type { PendingApprovalSummary } from "../tools/runtime/approval/repository";
4
4
  import type { PiCanonicalUserInput } from "./message/contract";
5
+ import type { ToolPrerequisiteRequest } from "../tools/runtime/prerequisite-contracts";
5
6
 
6
7
  export type RuntimeLoadPhase = "config" | "assembly" | "mcp" | "pi";
7
8
 
@@ -95,6 +96,8 @@ export interface RuntimeState {
95
96
  /** 供 Host 列表持久化的粗粒度活动状态;Submission 与 Approval 仍是执行真相。 */
96
97
  activity?: RuntimeActivityProjection;
97
98
  approvals?: ApprovalReceipt[];
99
+ /** Durable pre-effect waits, separate from canonical messages and answer tools. */
100
+ prerequisites?: ToolPrerequisiteRequest[];
98
101
  turn?: RuntimeTurnState;
99
102
  /** 有值时普通输入被锁定,直到锁定的 Submission 进入终态。 */
100
103
  inputLock?: RuntimeInputLock;
@@ -109,6 +112,7 @@ type SubmissionProjection = Pick<
109
112
  /** Rebuild client-visible activity from current facts without retaining lifecycle state. */
110
113
  export function projectRuntimeState(input: {
111
114
  readonly approvals: readonly PendingApprovalSummary[];
115
+ readonly prerequisites?: readonly ToolPrerequisiteRequest[];
112
116
  readonly temporaryApprovals: readonly ApprovalReceipt[];
113
117
  readonly running: SubmissionProjection | null | undefined;
114
118
  readonly pending: readonly SubmissionProjection[];
@@ -134,6 +138,9 @@ export function projectRuntimeState(input: {
134
138
  ...input.temporaryApprovals,
135
139
  ];
136
140
  const current = input.running ?? input.pending[0];
141
+ const prerequisites = (input.prerequisites ?? []).filter(
142
+ (request) => request.submissionId === current?.submissionId,
143
+ );
137
144
  const queued = (input.running ? input.pending : input.pending.slice(1)).map(
138
145
  (submission, index): RuntimeQueuedSubmission => {
139
146
  let preview = "Queued message";
@@ -166,7 +173,7 @@ export function projectRuntimeState(input: {
166
173
  hasPendingSteer: Boolean(current && input.hasPendingSteer(current.submissionId)),
167
174
  queued,
168
175
  };
169
- const activity: RuntimeActivity = approvals.length > 0 ||
176
+ const activity: RuntimeActivity = approvals.length > 0 || prerequisites.length > 0 ||
170
177
  (current && input.hasPendingInteraction(current.submissionId))
171
178
  ? "needs-input"
172
179
  : current || queued.length > 0 ? "working" : "idle";
@@ -183,13 +190,15 @@ export function projectRuntimeState(input: {
183
190
  ? { reason: "scheduled-run" }
184
191
  : undefined;
185
192
  const changed = input.previousState.approvals === undefined ||
193
+ input.previousState.prerequisites === undefined ||
186
194
  JSON.stringify(input.previousState.approvals) !== JSON.stringify(approvals) ||
195
+ JSON.stringify(input.previousState.prerequisites) !== JSON.stringify(prerequisites) ||
187
196
  JSON.stringify(input.previousState.turn) !== JSON.stringify(turn) ||
188
197
  JSON.stringify(input.previousState.inputLock) !== JSON.stringify(inputLock) ||
189
198
  nextActivity !== previousActivity;
190
199
  return {
191
200
  state: changed
192
- ? { ...input.previousState, activity: nextActivity, approvals, turn, inputLock }
201
+ ? { ...input.previousState, activity: nextActivity, approvals, prerequisites, turn, inputLock }
193
202
  : input.previousState,
194
203
  changed,
195
204
  };
package/src/index.ts CHANGED
@@ -91,6 +91,14 @@ export type {
91
91
  } from "./tools/subagent/types/contract";
92
92
  export type {
93
93
  PiToolCandidate,
94
+ PiToolPrerequisiteCheck,
95
+ PiToolPrerequisiteContext,
96
+ PiToolPrerequisiteSpec,
97
+ ToolPrerequisiteDecision,
98
+ ToolPrerequisiteIdentity,
99
+ ToolPrerequisitePresentation,
100
+ ToolPrerequisiteRequest,
101
+ ToolPrerequisiteResponse,
94
102
  ToolExposureMode,
95
103
  } from "./tools";
96
104
  export { basePiToolCandidates } from "./tools";
@@ -34,6 +34,30 @@ codemode/adapter.ts Cloudflare 执行器组装、描述与结果适
34
34
 
35
35
  `pi-to-ai.ts` 也被 Skill 脚本执行使用,因此放在共用 `adapters/` 中。工具声明仍以 Pi 为唯一事实源。
36
36
 
37
+ ### Cancellation And Settlement
38
+
39
+ `RuntimeCodeExecutionPort.execute(input, { signal })` carries cancellation
40
+ through the adapter to the invocation's native sandbox. Parent cancellation
41
+ and the Tool deadline share this path. The Port awaits sandbox finalization
42
+ and connector cleanup before rejecting; it does not abandon a running Promise.
43
+
44
+ `codemode/executor.ts` composes the public Cloudflare executor with a retained,
45
+ per-invocation RPC cancellation handle. It aborts the sandbox invocation, never
46
+ the parent Host context or a sibling invocation. This does not guarantee CPU
47
+ preemption, Worker destruction, quota release or rollback of Host effects.
48
+
49
+ Already-started Host work can finish after sandbox cancellation. Its returned
50
+ facts must still reach the completion callback and the official connector's
51
+ durable result history. That does not permit model continuation or accept a
52
+ cancelled Tool's termination control. Producer-owned usage events remain
53
+ authoritative; an aborted outer Tool must not be interpreted as zero usage.
54
+
55
+ Browser invocations each own an `InvocationBrowserConnector`. At pass end it
56
+ rejects new dispatches and drains its already-started browser calls before the
57
+ public SDK closes the socket and one-shot session. An allocation can complete
58
+ after sandbox abort; the Port waits for that late allocation and its cleanup
59
+ rather than reporting terminal cleanup early. Other Host tools are not drained.
60
+
37
61
  ## 修改位置
38
62
 
39
63
  - 修改某个工具:进入对应功能目录,测试与实现放在一起。
@@ -4,7 +4,10 @@ import { tool as aiTool, jsonSchema, type ToolSet } from "ai";
4
4
  import type { PiToolCandidate } from "../runtime/contracts";
5
5
 
6
6
  interface NestedToolOptions {
7
+ /** Bound by the execution when the upstream connector omits AI SDK call options. */
8
+ readonly signal?: AbortSignal;
7
9
  readonly fallbackToolCallId?: (name: string) => string;
10
+ /** Settled Host facts, even after cancellation; not permission for model control. */
8
11
  readonly onResult?: (result: AgentToolResult<unknown>) => void;
9
12
  }
10
13
 
@@ -13,12 +16,19 @@ export function piCandidatesToAiTools(
13
16
  candidates: readonly PiToolCandidate[],
14
17
  options: NestedToolOptions,
15
18
  ): ToolSet {
16
- return Object.fromEntries(candidates.map((candidate) => [
19
+ // Nested sandboxes have no pre-intent durable human wait. Never bypass a
20
+ // prerequisite by exposing its raw execute closure in Code Mode or Skill scripts.
21
+ return Object.fromEntries(candidates.filter((candidate) => !candidate.prerequisite).map((candidate) => [
17
22
  candidate.tool.name,
18
23
  aiTool({
19
24
  description: candidate.tool.description,
20
25
  inputSchema: jsonSchema(candidate.tool.parameters as never),
21
26
  execute: async (input, call) => {
27
+ const callSignal = call?.abortSignal;
28
+ const signal = options.signal && callSignal && options.signal !== callSignal
29
+ ? AbortSignal.any([options.signal, callSignal])
30
+ : options.signal ?? callSignal ?? new AbortController().signal;
31
+ signal.throwIfAborted();
22
32
  const toolCallId = call?.toolCallId ??
23
33
  options.fallbackToolCallId?.(candidate.tool.name) ??
24
34
  candidate.tool.name;
@@ -29,11 +39,15 @@ export function piCandidatesToAiTools(
29
39
  name: candidate.tool.name,
30
40
  arguments: prepared as JsonObject,
31
41
  });
42
+ signal.throwIfAborted();
32
43
  const result = await candidate.tool.execute(
33
44
  toolCallId,
34
45
  args,
35
- call?.abortSignal ?? new AbortController().signal,
46
+ signal,
36
47
  );
48
+ // A resolved Host operation is a fact even if cancellation happened
49
+ // while it ran. Let settlement and the connector's durable recordResult
50
+ // finish; the outer Code Mode boundary rejects model continuation.
37
51
  options.onResult?.(result);
38
52
  if (result.details !== undefined) return result.details;
39
53
  const [only] = result.content;
@@ -1,4 +1,4 @@
1
- import { truncateResponse, truncateResult, type ProxyToolOutput } from "@cloudflare/codemode";
1
+ import { createCodemodeRuntime, truncateResponse, truncateResult, type ProxyToolOutput } from "@cloudflare/codemode";
2
2
  import { generateTypes } from "@cloudflare/codemode/ai";
3
3
  import {
4
4
  STATE_TYPES,
@@ -6,10 +6,10 @@ import {
6
6
  type StateBackend,
7
7
  type WorkspaceFsLike,
8
8
  } from "@cloudflare/shell";
9
- import { createBrowserTools } from "@cloudflare/think/tools/browser";
10
9
  import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
11
10
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
12
11
  import type { Usage } from "@earendil-works/pi-ai";
12
+ import { DurableBrowserSessionStore } from "agents/browser";
13
13
  import type { ToolSet } from "ai";
14
14
  import type {
15
15
  RuntimeBrowserPort,
@@ -19,6 +19,8 @@ import type {
19
19
  } from "../../config/ports";
20
20
  import { serializeOutput } from "../../lib/artifacts";
21
21
  import { piCandidatesToAiTools } from "../adapters/pi-to-ai";
22
+ import { InvocationBrowserConnector } from "./browser-connector";
23
+ import { createCodeExecutionExecutor } from "./executor";
22
24
  import {
23
25
  WORKSPACE_PAGING_INSTRUCTIONS,
24
26
  WORKSPACE_STATE_TYPES,
@@ -226,11 +228,23 @@ function toCodeExecutionPort(
226
228
  }
227
229
  return {
228
230
  description,
229
- execute: async (input) => project(await execute(input, {
230
- toolCallId: name,
231
- messages: [],
232
- context: undefined,
233
- })),
231
+ execute: async (input, options) => {
232
+ const signal = options?.signal;
233
+ signal?.throwIfAborted();
234
+ try {
235
+ const output = await execute(input, {
236
+ toolCallId: name,
237
+ messages: [],
238
+ context: undefined,
239
+ abortSignal: signal,
240
+ });
241
+ signal?.throwIfAborted();
242
+ return project(output);
243
+ } catch (error) {
244
+ signal?.throwIfAborted();
245
+ throw error;
246
+ }
247
+ },
234
248
  };
235
249
  }
236
250
 
@@ -292,30 +306,38 @@ export function createBrowserExecutionFactory(options: {
292
306
  }): RuntimeBrowserPort {
293
307
  return {
294
308
  create() {
295
- const tools = createBrowserTools({
296
- ctx: options.ctx,
297
- loader: options.loader,
298
- browser: options.browser,
299
- // 沙箱超时沿用 Code Mode 的同一个常量,先于 60s 外层截止结束。
300
- timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
301
- // 会话用上游默认的 one-shot:每次执行一个全新浏览器,两次自检互不污染。
302
- // Quick Action 显式关闭:2026-08-13 复测本地绑定仍不实现 quickAction(),
303
- // 让上游默认值把那四个工具带回来只会注册出本地必然失败的 Tool(knowledge F22)。
304
- quickActions: false,
305
- name: "browser",
306
- });
307
- const tool = tools.browser_execute;
308
- if (!tool) {
309
- throw new Error("Upstream createBrowserTools did not return browser_execute");
310
- }
311
- const port = toCodeExecutionPort(tool, "browser_execute");
312
- const description = `${port.description}\n\n${browserToolDescription()}`;
313
- if (!options.previewBaseUrl) return { ...port, description };
314
- // 前置而不是追加:上游的 codemode 描述很长,缀在末尾的一句会被读丢 ——
315
- // 2026-08-13 手动冒烟里模型就没看见它,转而去猜 `file://` 和 `https://workspace.local/`。
309
+ // Compose public seams with per-invocation ownership. No Quick Actions.
310
+ const createPort = (signal?: AbortSignal) => {
311
+ const connector = new InvocationBrowserConnector(options.ctx, {
312
+ browser: options.browser,
313
+ store: new DurableBrowserSessionStore(options.ctx.storage),
314
+ timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
315
+ // Keep the public connector's default one-shot lifecycle.
316
+ });
317
+ const runtime = createCodemodeRuntime({
318
+ ctx: options.ctx,
319
+ executor: createCodeExecutionExecutor({
320
+ loader: options.loader,
321
+ timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
322
+ }, signal),
323
+ connectors: [connector],
324
+ name: "browser",
325
+ // Keep raw screenshots intact for browserResult's Pi image projection.
326
+ });
327
+ return toCodeExecutionPort(runtime.tool({
328
+ connectorHints: {
329
+ cdp: "Browser CDP. Return screenshots as { type: 'browser_screenshot', mediaType: 'image/png', data: screenshot.data }; the UI keeps the image while the model receives a compact summary.",
330
+ },
331
+ }), "browser_execute");
332
+ };
333
+ const description = `${createPort().description}\n\n${browserToolDescription()}`;
316
334
  return {
317
- ...port,
318
- description: `${previewNote(options.previewBaseUrl)}\n\n${description}`,
335
+ // Keep the Session's exact preview URL ahead of the long generated guidance.
336
+ description: options.previewBaseUrl
337
+ ? `${previewNote(options.previewBaseUrl)}\n\n${description}`
338
+ : description,
339
+ execute: (input, executionOptions) =>
340
+ createPort(executionOptions?.signal).execute(input, executionOptions),
319
341
  };
320
342
  },
321
343
  };
@@ -343,9 +365,11 @@ export function createWorkspaceCodeExecutionFactory(options: {
343
365
  : undefined;
344
366
  const createPort = (
345
367
  innerResults: AgentToolResult<unknown>[],
368
+ signal?: AbortSignal,
346
369
  ) => {
347
370
  let nestedCallOrdinal = 0;
348
371
  const tools = piCandidatesToAiTools(candidates, {
372
+ signal,
349
373
  fallbackToolCallId: (name) =>
350
374
  `codemode:${++nestedCallOrdinal}:${name}`,
351
375
  onResult: (value) => innerResults.push(value),
@@ -354,6 +378,11 @@ export function createWorkspaceCodeExecutionFactory(options: {
354
378
  ctx: options.ctx,
355
379
  loader: options.loader,
356
380
  globalOutbound: options.outbound,
381
+ executor: createCodeExecutionExecutor({
382
+ loader: options.loader,
383
+ globalOutbound: options.outbound,
384
+ timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
385
+ }, signal),
357
386
  tools,
358
387
  timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
359
388
  connectors: workspaceState ? [workspaceState] : [],
@@ -392,7 +421,8 @@ export function createWorkspaceCodeExecutionFactory(options: {
392
421
  ...(state && options.workspace.readFilePage ? [WORKSPACE_PAGING_INSTRUCTIONS] : []),
393
422
  SPRINGBRAND_CODE_MODE_POLICY,
394
423
  ].join("\n\n"),
395
- execute: (input) => createPort([]).execute(input),
424
+ execute: (input, executionOptions) =>
425
+ createPort([], executionOptions?.signal).execute(input, executionOptions),
396
426
  };
397
427
  },
398
428
  };
@@ -0,0 +1,28 @@
1
+ import { BrowserConnector } from "agents/browser";
2
+
3
+ /** Drains one invocation's browser calls before the upstream pass lifecycle ends. */
4
+ export class InvocationBrowserConnector extends BrowserConnector {
5
+ readonly #pending = new Set<Promise<unknown>>();
6
+ #ending = false;
7
+
8
+ override async executeTool(
9
+ ...args: Parameters<BrowserConnector["executeTool"]>
10
+ ): Promise<unknown> {
11
+ if (this.#ending) throw new Error("Browser execution has ended");
12
+ const pending = super.executeTool(...args);
13
+ this.#pending.add(pending);
14
+ try {
15
+ return await pending;
16
+ } finally {
17
+ this.#pending.delete(pending);
18
+ }
19
+ }
20
+
21
+ override async onPassEnd(
22
+ ...args: Parameters<BrowserConnector["onPassEnd"]>
23
+ ): Promise<void> {
24
+ this.#ending = true;
25
+ await Promise.allSettled(this.#pending);
26
+ await super.onPassEnd(...args);
27
+ }
28
+ }