@springbrand/agent-runtime 0.3.0-alpha.7 → 0.3.0-alpha.9

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.
Files changed (93) hide show
  1. package/package.json +2 -2
  2. package/src/adapter/cloudflare/agent.ts +4 -7
  3. package/src/adapter/cloudflare/define-agent.ts +2 -0
  4. package/src/adapter/cloudflare/schema.ts +3 -0
  5. package/src/adapter/cloudflare/subagent/definition.ts +4 -4
  6. package/src/adapter/cloudflare/subagent/runner.ts +1 -1
  7. package/src/adapter/cloudflare/subagent/tools.ts +2 -2
  8. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  9. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  10. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  11. package/src/config/agent-context.ts +1 -1
  12. package/src/config/assembler.ts +17 -31
  13. package/src/config/assembly-snapshot.ts +1 -1
  14. package/src/config/definition.ts +1 -1
  15. package/src/config/model-guidance.ts +1 -1
  16. package/src/config/ports.ts +5 -15
  17. package/src/config/public-contracts.ts +2 -2
  18. package/src/context/extension/assembly.ts +2 -2
  19. package/src/context/memory/budget/gate.ts +1 -1
  20. package/src/conversation/state.ts +1 -1
  21. package/src/index.ts +7 -7
  22. package/src/lib/execution-level.ts +13 -0
  23. package/src/lib/prompt.ts +3 -5
  24. package/src/models/index.ts +1 -0
  25. package/src/session/cloudflare/migration.ts +29 -8
  26. package/src/skills/springbrand-worker-website/index.ts +1 -1
  27. package/src/submission/admission.ts +4 -0
  28. package/src/submission/delivery.ts +1 -1
  29. package/src/submission/finalization.ts +1 -1
  30. package/src/submission/repository.ts +8 -1
  31. package/src/telemetry/coordinator.ts +1 -1
  32. package/src/telemetry/subagent-lifecycle.ts +1 -1
  33. package/src/telemetry/turn-events.ts +1 -1
  34. package/src/tools/README.md +44 -0
  35. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  36. package/src/tools/{declared.ts → adapters/declared.ts} +2 -2
  37. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +2 -2
  38. package/src/tools/assistant/ask-user.ts +317 -0
  39. package/src/tools/assistant/candidate.ts +15 -0
  40. package/src/tools/assistant/followups.ts +34 -0
  41. package/src/tools/assistant/index.ts +19 -0
  42. package/src/tools/assistant/memory.ts +73 -0
  43. package/src/tools/assistant/plan.ts +98 -0
  44. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  45. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -49
  46. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  47. package/src/tools/codemode/workspace-connector.ts +198 -0
  48. package/src/tools/extension/index.ts +42 -0
  49. package/src/tools/index.ts +28 -26
  50. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  51. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  52. package/src/tools/{approval → runtime/approval}/repository.ts +3 -9
  53. package/src/tools/{compiler.ts → runtime/compiler.ts} +24 -155
  54. package/src/tools/runtime/contracts.ts +117 -0
  55. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +36 -61
  56. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  57. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  58. package/src/tools/runtime/result.ts +22 -0
  59. package/src/tools/sandbox/index.ts +228 -0
  60. package/src/tools/{schedule.ts → schedule/index.ts} +50 -49
  61. package/src/tools/skill/contracts.ts +20 -0
  62. package/src/tools/skill/index.ts +99 -0
  63. package/src/tools/skill/resources.ts +322 -0
  64. package/src/tools/skill/scripts.ts +168 -0
  65. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  66. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  67. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  68. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  69. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  70. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +1 -1
  71. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  72. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  73. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  74. package/src/tools/web/index.ts +2 -0
  75. package/src/tools/web/search/api.ts +1320 -0
  76. package/src/tools/web/search/index.ts +2 -0
  77. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  78. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  79. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  80. package/src/tools/base.ts +0 -542
  81. package/src/tools/skill.ts +0 -565
  82. package/src/tools/web-search/api.ts +0 -1318
  83. package/src/tools/web-search/index.ts +0 -2
  84. package/src/tools/workspace-state-connector.ts +0 -146
  85. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  86. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  87. package/src/tools/{unattended-error.ts → runtime/unattended-error.ts} +0 -0
  88. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  89. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  90. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  91. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  92. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  93. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -0,0 +1,98 @@
1
+ import { StringEnum, Type, type Static } from "@earendil-works/pi-ai";
2
+ import type { PiToolCandidate } from "../runtime/contracts";
3
+ import { result } from "../runtime/result";
4
+ import { candidate } from "./candidate";
5
+
6
+ const updatePlanParameters = Type.Object({
7
+ steps: Type.Array(Type.Object({
8
+ text: Type.String({
9
+ description: "The step, phrased as a short imperative.",
10
+ }),
11
+ status: StringEnum(["pending", "in_progress", "done"] as const, {
12
+ description:
13
+ 'Current status. Use "done" for a finished step; never use "completed".',
14
+ }),
15
+ }, { additionalProperties: false }), {
16
+ description:
17
+ "The complete, ordered plan. Overwrites any previously reported plan.",
18
+ }),
19
+ }, { additionalProperties: false });
20
+
21
+ type UpdatePlanArguments = Static<typeof updatePlanParameters>;
22
+
23
+ function normalizeStepStatus(
24
+ status: unknown,
25
+ ): UpdatePlanArguments["steps"][number]["status"] {
26
+ switch (status) {
27
+ case "done":
28
+ case "completed": // Anthropic models sometimes output "completed"
29
+ return "done";
30
+ case "in_progress":
31
+ case "inProgress": // camelCase variant
32
+ return "in_progress";
33
+ default: // covers "pending", "todo" (Google), and any other unknown value
34
+ return "pending";
35
+ }
36
+ }
37
+
38
+ export function normalizeUpdatePlanArguments(
39
+ input: unknown,
40
+ ): UpdatePlanArguments {
41
+ if (input === null || typeof input !== "object") {
42
+ return input as UpdatePlanArguments;
43
+ }
44
+ const value = input as Record<string, unknown>;
45
+ let steps = value.steps;
46
+ if (typeof steps === "string") {
47
+ try {
48
+ steps = JSON.parse(steps);
49
+ } catch {
50
+ return input as UpdatePlanArguments;
51
+ }
52
+ }
53
+ if (!Array.isArray(steps)) return input as UpdatePlanArguments;
54
+ return {
55
+ ...value,
56
+ steps: steps.map((step) => {
57
+ if (step === null || typeof step !== "object") return step;
58
+ const s = step as Record<string, unknown>;
59
+ const { step: stepAlias, title: titleAlias, ...rest } = s;
60
+ const alias = typeof stepAlias === "string" ? stepAlias : titleAlias;
61
+ const text = typeof rest.text === "string"
62
+ ? rest.text
63
+ : typeof alias === "string"
64
+ ? alias
65
+ : undefined;
66
+ return {
67
+ ...rest,
68
+ ...(text === undefined ? {} : { text }),
69
+ status: normalizeStepStatus(s.status),
70
+ };
71
+ }),
72
+ } as UpdatePlanArguments;
73
+ }
74
+
75
+ export function updatePlanPiToolCandidate(): PiToolCandidate {
76
+ return {
77
+ ...candidate({
78
+ name: "update_plan",
79
+ label: "Update plan",
80
+ description:
81
+ "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.",
82
+ parameters: updatePlanParameters,
83
+ prepareArguments: normalizeUpdatePlanArguments,
84
+ async execute(_toolCallId, input) {
85
+ return result({
86
+ ok: true,
87
+ total: input.steps.length,
88
+ done: input.steps.filter((step) => step.status === "done").length,
89
+ });
90
+ },
91
+ }),
92
+ unattendedExecution: {
93
+ capability: "runtime-ui",
94
+ operation: "update",
95
+ },
96
+ exposureMode: "direct",
97
+ };
98
+ }
@@ -1,6 +1,6 @@
1
1
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
2
  import { Type } from "@earendil-works/pi-ai";
3
- import type { PiToolCandidate } from "./compiler";
3
+ import type { PiToolCandidate } from "../runtime/contracts";
4
4
 
5
5
  export const GET_TIME_TOOL_NAME = "get_time";
6
6
 
@@ -1,38 +1,31 @@
1
+ import { truncateResponse, truncateResult, type ProxyToolOutput } from "@cloudflare/codemode";
2
+ import { generateTypes } from "@cloudflare/codemode/ai";
1
3
  import {
2
- createWorkspaceStateBackend,
3
4
  STATE_TYPES,
5
+ createWorkspaceStateBackend,
4
6
  type StateBackend,
5
7
  type WorkspaceFsLike,
6
8
  } from "@cloudflare/shell";
7
- import {
8
- truncateResponse,
9
- truncateResult,
10
- type ProxyToolOutput,
11
- } from "@cloudflare/codemode";
12
9
  import { createBrowserTools } from "@cloudflare/think/tools/browser";
13
- import {
14
- createExecuteRuntime,
15
- type CreateExecuteToolOptions,
16
- } from "@cloudflare/think/tools/execute";
10
+ import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
17
11
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
18
12
  import type { Usage } from "@earendil-works/pi-ai";
19
13
  import type { ToolSet } from "ai";
20
14
  import type {
21
15
  RuntimeBrowserPort,
16
+ RuntimeCodeExecutionFactory,
22
17
  RuntimeCodeExecutionPort,
23
18
  WorkspacePort,
24
- } from "../config/ports";
19
+ } from "../../config/ports";
20
+ import { serializeOutput } from "../../lib/artifacts";
21
+ import { piCandidatesToAiTools } from "../adapters/pi-to-ai";
25
22
  import {
26
- WorkspaceStateConnector,
27
23
  WORKSPACE_PAGING_INSTRUCTIONS,
28
24
  WORKSPACE_STATE_TYPES,
29
- } from "./workspace-state-connector";
30
- import type { RuntimeCodeExecutionFactory } from "../config/ports";
31
- import { serializeOutput } from "../lib/artifacts";
32
- import type { PiToolCandidate } from "./compiler";
33
- import { piCandidatesToAiTools } from "./nested-tools";
25
+ WorkspaceStateConnector,
26
+ } from "./workspace-connector";
34
27
 
35
- // 本文件沿用 `../../index.ts` 入口定义的 Workspace、Port 和 Tool Candidate 术语。
28
+ // 将 Cloudflare 的 Code Mode 与浏览器执行能力适配为 Runtime Port;Pi Tool 定义位于同目录 index.ts。
36
29
 
37
30
  const CODEMODE_SANDBOX_TIMEOUT_MS = 55_000;
38
31
  export type WorkspaceAccessMode = "none" | "read" | "write";
@@ -103,7 +96,7 @@ When an asset already exists in Workspace, use state.cp to copy it into the curr
103
96
 
104
97
  Never create or write data URLs, base64 image strings, or embedded binary payloads in Code Mode.
105
98
 
106
- Copy a remote asset into Workspace with tools.materialize_url({ url, destination }). Raw fetch remains available. Do not carry large responses, binary payloads, data URLs, or base64 assets through Code Mode.
99
+ Copy a remote asset into Workspace with tools.materialize_url({ url, destination }). Do not carry large responses, binary payloads, data URLs, or base64 assets through Code Mode.
107
100
 
108
101
  After materialization, reference the Workspace file by a relative path. Website HTML and CSS must reference relative Workspace assets, never temporary external URLs or data URLs.
109
102
 
@@ -193,12 +186,12 @@ function projectCodeModeResult(details: unknown): unknown {
193
186
  const calls = Array.isArray(output.calls) ? output.calls : [];
194
187
  const callSummary = calls.length > 0
195
188
  ? {
196
- total: calls.length,
197
- failed: calls.filter(({ state }) => state === "error").length,
198
- methods: [...new Set(calls.map(({ method }) => method))]
199
- .map((method) => truncateResponse(method, { maxChars: 256 }))
200
- .slice(0, 100),
201
- }
189
+ total: calls.length,
190
+ failed: calls.filter(({ state }) => state === "error").length,
191
+ methods: [...new Set(calls.map(({ method }) => method))]
192
+ .map((method) => truncateResponse(method, { maxChars: 256 }))
193
+ .slice(0, 100),
194
+ }
202
195
  : undefined;
203
196
  const common = {
204
197
  status: output.status,
@@ -224,7 +217,7 @@ function projectCodeModeResult(details: unknown): unknown {
224
217
  // 上游把 Code Mode 类工具交付为 AI SDK tool;本仓只取 description 和 execute 两件,
225
218
  // 并在这里就地校验,使装配期缺件立刻失败,而不是等模型调用时才炸。
226
219
  function toCodeExecutionPort(
227
- tool: ToolSet[string],
220
+ tool: Pick<ToolSet[string], "description" | "execute">,
228
221
  name: string,
229
222
  project: (details: unknown) => AgentToolResult<unknown> = result,
230
223
  ): RuntimeCodeExecutionPort {
@@ -345,35 +338,40 @@ export function createWorkspaceCodeExecutionFactory(options: {
345
338
  options.workspace,
346
339
  options.workspaceAccessMode,
347
340
  );
348
- const workspaceState = state && options.workspace.readFilePage
341
+ const workspaceState = state
349
342
  ? new WorkspaceStateConnector(
350
- options.ctx,
351
- state,
352
- options.workspace as WorkspacePort & {
353
- readFilePage: NonNullable<WorkspacePort["readFilePage"]>;
354
- },
355
- )
343
+ options.ctx,
344
+ state,
345
+ options.workspace,
346
+ )
356
347
  : undefined;
357
348
  const createPort = (
358
349
  innerResults: AgentToolResult<unknown>[],
359
- stateOptions: Pick<CreateExecuteToolOptions, "state" | "connectors">,
360
350
  ) => {
361
351
  let nestedCallOrdinal = 0;
362
- const { tool } = createExecuteRuntime({
352
+ const tools = piCandidatesToAiTools(candidates, {
353
+ fallbackToolCallId: (name) =>
354
+ `codemode:${++nestedCallOrdinal}:${name}`,
355
+ onResult: (value) => innerResults.push(value),
356
+ });
357
+ const { tool, runtime } = createExecuteRuntime({
363
358
  ctx: options.ctx,
364
359
  loader: options.loader,
365
360
  globalOutbound: options.outbound,
366
- tools: piCandidatesToAiTools(candidates, {
367
- fallbackToolCallId: (name) =>
368
- `codemode:${++nestedCallOrdinal}:${name}`,
369
- onResult: (value) => innerResults.push(value),
370
- }),
361
+ tools,
371
362
  timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
372
- ...stateOptions,
363
+ connectors: workspaceState ? [workspaceState] : [],
373
364
  name: "execute",
374
365
  });
366
+ // Generate the official description from the same connectors used to execute.
367
+ const description = runtime.tool({
368
+ connectorHints: {
369
+ tools: "host tools as async functions; call tools.<method>({ ... }) inside execute. Discover additional methods with codemode.search and codemode.describe.",
370
+ state: "the workspace filesystem. Every method takes ONE object argument: state.readFile({ path }), state.writeFile({ path, content }), state.readdir({ path }).",
371
+ }
372
+ }).description;
375
373
  return toCodeExecutionPort(
376
- tool,
374
+ { ...tool, description },
377
375
  "execute",
378
376
  (details) => codeExecutionResult(
379
377
  projectCodeModeResult(details),
@@ -381,21 +379,24 @@ export function createWorkspaceCodeExecutionFactory(options: {
381
379
  ),
382
380
  );
383
381
  };
384
- // Inherit the complete official state description; custom connectors otherwise lose its hints.
385
- const description = createPort([], state ? { state } : {}).description;
382
+ // External declarations opt out of constrained sampling and remain discoverable on demand.
383
+ const inlineCandidates = candidates.filter(({ tool }) => tool.constrainedSampling !== false);
384
+ const toolTypes = inlineCandidates.length > 0
385
+ ? generateTypes(piCandidatesToAiTools(inlineCandidates, {}), "tools")
386
+ : undefined;
387
+ const description = createPort([]).description;
386
388
  return {
387
389
  description: [
388
390
  description,
391
+ ...(toolTypes ? ["## Code Mode Tool types\nUse these signatures inside execute. The Tool routing list defines which methods also have a top-level entry.\n\n```ts\n" + toolTypes + "\n```"] : []),
389
392
  ...(state ? [
390
393
  "## Workspace type reference\nOnly methods exposed by connector discovery are callable.\n\n```ts\n" +
391
- (workspaceState ? WORKSPACE_STATE_TYPES : STATE_TYPES) + "\n```",
394
+ (options.workspace.readFilePage ? WORKSPACE_STATE_TYPES : STATE_TYPES) + "\n```",
392
395
  ] : []),
393
- ...(workspaceState ? [WORKSPACE_PAGING_INSTRUCTIONS] : []),
396
+ ...(state && options.workspace.readFilePage ? [WORKSPACE_PAGING_INSTRUCTIONS] : []),
394
397
  SPRINGBRAND_CODE_MODE_POLICY,
395
398
  ].join("\n\n"),
396
- execute: (input) => createPort(
397
- [], workspaceState ? { connectors: [workspaceState] } : state ? { state } : {},
398
- ).execute(input),
399
+ execute: (input) => createPort([]).execute(input),
399
400
  };
400
401
  },
401
402
  };
@@ -1,29 +1,14 @@
1
- import type {
2
- AgentTool,
3
- AgentToolResult,
4
- } from "@earendil-works/pi-agent-core";
1
+ import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
5
2
  import { Type } from "@earendil-works/pi-ai";
6
- import type {
7
- RuntimeCodeExecutionPort,
8
- } from "../config/ports";
9
- import { serializeOutput } from "../lib/artifacts";
10
- import type { PiLoadedExtension } from "../context/extension/assembly";
11
- import type { PiToolCandidate } from "./compiler";
12
-
13
- // 本文件沿用 `../../index.ts` 入口定义的 Extension、Port 和 Tool Candidate 术语。
14
-
15
- // 把核心工具的结构化值投影为 Pi 同时需要的文本和 details。
16
- // 本文件中所有核心工具 execute 方法在返回宿主结果时调用它。
17
- // 统一使用 `serializeOutput()` 可以保持模型可见文本和持久化 details 来自同一个值。
18
- function result(details: unknown): AgentToolResult<unknown> {
19
- return {
20
- content: [{ type: "text", text: serializeOutput(details).text }],
21
- details,
22
- };
23
- }
3
+ import type { RuntimeCodeExecutionPort } from "../../config/ports";
4
+ import { serializeOutput } from "../../lib/artifacts";
5
+ import type { PiToolCandidate } from "../runtime/contracts";
6
+ import { result } from "../runtime/result";
24
7
 
25
8
  const BROWSER_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
9
+
26
10
  const BROWSER_IMAGE_MAX_BASE64_CHARS = Math.ceil(BROWSER_IMAGE_MAX_BYTES / 3) * 4;
11
+
27
12
  const BROWSER_IMAGE_MIME_TYPES = new Set([
28
13
  "image/jpeg",
29
14
  "image/png",
@@ -87,7 +72,7 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
87
72
 
88
73
  const calls = Array.isArray(outer.calls) ? outer.calls : [];
89
74
  let screenshotCall: Record<string, unknown> | undefined;
90
- for (let index = calls.length - 1; index >= 0; index -= 1) {
75
+ for (let index = calls.length - 1;index >= 0;index -= 1) {
91
76
  const call = record(calls[index]);
92
77
  const args = record(call?.args);
93
78
  if (
@@ -117,39 +102,39 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
117
102
 
118
103
  const projectedCalls = Array.isArray(outer.calls)
119
104
  ? outer.calls.map((call) => {
120
- const entry = record(call);
121
- const args = record(entry?.args);
122
- const callResult = record(entry?.result);
123
- if (
124
- entry?.connector !== "cdp" ||
125
- entry.method !== "send" ||
126
- args?.method !== "Page.captureScreenshot" ||
127
- typeof callResult?.data !== "string"
128
- ) return call;
129
- const callFormat = record(args.params)?.format;
130
- return {
131
- ...entry,
132
- result: {
133
- ...callResult,
134
- data: browserImageMetadata(
135
- callResult.data,
136
- screenshotMimeType(callFormat),
137
- ),
138
- },
139
- };
140
- })
105
+ const entry = record(call);
106
+ const args = record(entry?.args);
107
+ const callResult = record(entry?.result);
108
+ if (
109
+ entry?.connector !== "cdp" ||
110
+ entry.method !== "send" ||
111
+ args?.method !== "Page.captureScreenshot" ||
112
+ typeof callResult?.data !== "string"
113
+ ) return call;
114
+ const callFormat = record(args.params)?.format;
115
+ return {
116
+ ...entry,
117
+ result: {
118
+ ...callResult,
119
+ data: browserImageMetadata(
120
+ callResult.data,
121
+ screenshotMimeType(callFormat),
122
+ ),
123
+ },
124
+ };
125
+ })
141
126
  : outer.calls;
142
127
  const projectedDetails = {
143
128
  ...outer,
144
129
  result: inner
145
130
  ? {
146
- ...inner,
147
- image: projectedImage,
148
- }
131
+ ...inner,
132
+ image: projectedImage,
133
+ }
149
134
  : {
150
- image: projectedImage,
151
- note: "Screenshot recovered from the Page.captureScreenshot result.",
152
- },
135
+ image: projectedImage,
136
+ note: "Screenshot recovered from the Page.captureScreenshot result.",
137
+ },
153
138
  ...("calls" in outer ? { calls: projectedCalls } : {}),
154
139
  };
155
140
  return {
@@ -163,47 +148,7 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
163
148
  };
164
149
  }
165
150
 
166
- // #region Extension discovery
167
-
168
- const noParameters = Type.Object({}, { additionalProperties: false });
169
-
170
- /**
171
- * 为当前已加载 Extension 列表创建一个 Pi 工具候选项。
172
- *
173
- * Runtime 组装层在 Extension 装载完成后调用,模型需要发现当前可用扩展时使用该工具。
174
- *
175
- * 候选项捕获准备阶段的只读列表,这与 pinned Runtime 组装保持一致,不能在执行时重新扫描出另一份 Extension 集合。
176
- */
177
- export function listExtensionsPiToolCandidate(
178
- extensions: readonly PiLoadedExtension[],
179
- ): PiToolCandidate {
180
- const tool: AgentTool<typeof noParameters> = {
181
- name: "list_extensions",
182
- label: "List extensions",
183
- description: "List all currently loaded extensions and their tools.",
184
- parameters: noParameters,
185
- // 返回这个 pinned Runtime 已加载的 Extension 快照。
186
- // Pi 工具循环在模型选择 `list_extensions` 时调用,调用前允许 Turn 取消。
187
- // 同时返回 count 和原列表,使模型既能快速判空又不丢失 Extension 细节。
188
- async execute(_toolCallId, _input, signal) {
189
- signal?.throwIfAborted();
190
- return result({
191
- count: extensions.length,
192
- extensions,
193
- });
194
- },
195
- };
196
- return {
197
- owner: "core:extensions",
198
- requiredExecutionLevel: "low",
199
- retry: "idempotent",
200
- tool,
201
- };
202
- }
203
-
204
- // #endregion
205
151
 
206
- // #region Code Mode
207
152
 
208
153
  const executeParameters = Type.Object({
209
154
  code: Type.String({
@@ -213,6 +158,7 @@ const executeParameters = Type.Object({
213
158
  "Plain JavaScript async function. TypeScript annotations are not supported.",
214
159
  }),
215
160
  }, { additionalProperties: false });
161
+
216
162
  const CODEMODE_EXECUTE_TIMEOUT_MS = 60_000;
217
163
 
218
164
  // 把模型提供的代码交给 Codemode Runtime 执行,并在外层再压一道截止。
@@ -331,5 +277,3 @@ export function browserExecutionPiToolCandidate(
331
277
  tool,
332
278
  };
333
279
  }
334
-
335
- // #endregion
@@ -0,0 +1,198 @@
1
+ import type { ConnectorTools } from "@cloudflare/codemode";
2
+ import { STATE_TYPES } from "@cloudflare/shell";
3
+ import type { StateBackend } from "@cloudflare/shell/workers";
4
+ import { StateConnector } from "@cloudflare/shell/workers";
5
+ import type { WorkspacePort } from "../../config/ports";
6
+
7
+ export const WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES = 1024 * 1024;
8
+
9
+ const READ_FILE_PAGE_TYPES = `
10
+ type WorkspaceFilePage =
11
+ | { kind: "text"; size: number; nextOffset: number | null; content: string }
12
+ | { kind: "csv"; size: number; nextOffset: number | null; columns: string[] | null; rows: string[][] };
13
+ `;
14
+
15
+ export const WORKSPACE_STATE_TYPES = `${READ_FILE_PAGE_TYPES}\n${STATE_TYPES.replace(
16
+ /\n\};$/u,
17
+ '\n /** CSV pages have columns and rows; never data. Omit offset first, then pass nextOffset until null. */\n readFilePage(args: { path: string; offset?: number }): Promise<WorkspaceFilePage | null>;\n};',
18
+ )}`;
19
+
20
+ export const WORKSPACE_PAGING_INSTRUCTIONS = `For CSV files, do not make a separate probe read or return page contents. Inspect columns and aggregate rows in the same execution with this loop shape:
21
+
22
+ let page = await state.readFilePage({ path });
23
+ if (!page || page.kind !== "csv") throw new Error("CSV not found");
24
+ const columns = page.columns;
25
+ for (;;) {
26
+ // Aggregate page.rows here. Do not return raw rows.
27
+ if (page.nextOffset === null) break;
28
+ page = await state.readFilePage({ path, offset: page.nextOffset });
29
+ }
30
+ return summary;
31
+
32
+ CSV pages use page.columns and page.rows; never page.data or page.content. Only the first page has columns. Do not use console.log to inspect a page; explicitly return the final summary.`;
33
+
34
+ export class WorkspaceStateConnector extends StateConnector {
35
+ constructor(
36
+ ctx: DurableObjectState | ExecutionContext,
37
+ backend: StateBackend,
38
+ private readonly workspace: WorkspacePort,
39
+ ) {
40
+ super(ctx, backend);
41
+ }
42
+
43
+ protected override instructions(): string {
44
+ const base = super.instructions();
45
+ return this.workspace.readFilePage ? `${base}
46
+
47
+ ${WORKSPACE_PAGING_INSTRUCTIONS}` : base;
48
+ }
49
+
50
+ protected override tools(): ConnectorTools {
51
+ const tools = super.tools();
52
+ const readFile = tools.readFile!;
53
+ const mkdir = tools.mkdir!;
54
+ const readFilePage = this.workspace.readFilePage;
55
+ return {
56
+ ...tools,
57
+ mkdir: {
58
+ ...mkdir,
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ path: { type: "string", minLength: 1 },
63
+ options: {
64
+ type: "object",
65
+ properties: { recursive: { type: "boolean" } },
66
+ additionalProperties: false,
67
+ },
68
+ },
69
+ required: ["path"],
70
+ additionalProperties: false,
71
+ },
72
+ execute: (args, context) => mkdir.execute(mkdirInput(args), context),
73
+ },
74
+ ...(readFilePage
75
+ ? {
76
+ readFile: {
77
+ ...readFile,
78
+ description:
79
+ `${readFile.description} CSV and files over ${WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES} bytes must use readFilePage.`,
80
+ execute: async (args, context) => {
81
+ const path = requiredPath(args, "readFile");
82
+ const file = await this.workspace.stat(path);
83
+ if (file?.type === "file" && (
84
+ file.mimeType === "text/csv" ||
85
+ file.size > WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES
86
+ )) {
87
+ throw new Error(
88
+ `${path} requires state.readFilePage({ path: ${JSON.stringify(path)} }); continue with nextOffset until it is null`,
89
+ );
90
+ }
91
+ return readFile.execute(args, context);
92
+ },
93
+ },
94
+ readFilePage: {
95
+ description:
96
+ "Read up to 512 KiB or 2,000 CSV rows from a Workspace file. " +
97
+ "A CSV page returns columns and rows and has no data field; rows are string arrays aligned to columns. " +
98
+ "A text page returns content. Omit offset on the first call, then pass page.nextOffset until it is null. " +
99
+ "Prefer this over readFile for CSV and large files.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ path: { type: "string", minLength: 1, description: "Absolute Workspace file path." },
104
+ offset: {
105
+ type: "integer",
106
+ minimum: 0,
107
+ description: "Previous page.nextOffset. Omit on the first call.",
108
+ },
109
+ },
110
+ required: ["path"],
111
+ additionalProperties: false,
112
+ },
113
+ replay: "reexecute",
114
+ execute: (args: unknown) => {
115
+ const input = readFilePageInput(args);
116
+ return readFilePage.call(this.workspace, input.path, input.offset);
117
+ },
118
+ },
119
+ }
120
+ : {}),
121
+ };
122
+ }
123
+
124
+ override async getTypeScriptTypes(): Promise<string> {
125
+ return this.workspace.readFilePage ? WORKSPACE_STATE_TYPES : STATE_TYPES;
126
+ }
127
+ }
128
+
129
+ function mkdirInput(value: unknown): {
130
+ path: string;
131
+ options?: { recursive?: boolean };
132
+ } {
133
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
134
+ throw new Error("state.mkdir requires an object argument");
135
+ }
136
+ const input = value as Record<string, unknown>;
137
+ if (Object.keys(input).some((key) => !["path", "options"].includes(key))) {
138
+ throw new Error("state.mkdir accepts only path and options");
139
+ }
140
+ if (typeof input.path !== "string" || input.path.length === 0) {
141
+ throw new Error("state.mkdir requires path");
142
+ }
143
+ if (input.options === undefined) return { path: input.path };
144
+ if (!input.options || typeof input.options !== "object" || Array.isArray(input.options)) {
145
+ throw new Error("state.mkdir options must be an object");
146
+ }
147
+ const options = input.options as Record<string, unknown>;
148
+ if (Object.keys(options).some((key) => key !== "recursive")) {
149
+ throw new Error("state.mkdir options accepts only recursive");
150
+ }
151
+ if (options.recursive !== undefined && typeof options.recursive !== "boolean") {
152
+ throw new Error("state.mkdir options.recursive must be boolean");
153
+ }
154
+ return {
155
+ path: input.path,
156
+ options: options.recursive === undefined
157
+ ? {}
158
+ : { recursive: options.recursive },
159
+ };
160
+ }
161
+
162
+ function requiredPath(value: unknown, method: string): string {
163
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
164
+ typeof (value as { path?: unknown }).path !== "string" ||
165
+ !(value as { path: string }).path) {
166
+ throw new Error(
167
+ `state.${method} takes one object argument: state.${method}({ path: "/path/to/file" })`,
168
+ );
169
+ }
170
+ return (value as { path: string }).path;
171
+ }
172
+
173
+ function readFilePageInput(value: unknown): {
174
+ path: string;
175
+ offset?: number;
176
+ } {
177
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
178
+ throw new Error("state.readFilePage requires an object argument");
179
+ }
180
+ const input = value as Record<string, unknown>;
181
+ const keys = Object.keys(input);
182
+ if (keys.some((key) => !["path", "offset"].includes(key))) {
183
+ throw new Error("state.readFilePage accepts only path and offset");
184
+ }
185
+ if (typeof input.path !== "string" || input.path.length === 0) {
186
+ throw new Error("state.readFilePage requires path");
187
+ }
188
+ const offset = input.offset;
189
+ if (offset !== undefined && (!Number.isInteger(offset) || (offset as number) < 0)) {
190
+ throw new Error(
191
+ "state.readFilePage offset must be a previous page.nextOffset; omit it on the first call",
192
+ );
193
+ }
194
+ return {
195
+ path: input.path,
196
+ ...(offset === undefined ? {} : { offset: offset as number }),
197
+ };
198
+ }