@springbrand/agent-runtime 0.2.0-alpha.66 → 0.2.0-alpha.68

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.66",
3
+ "version": "0.2.0-alpha.68",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -197,6 +197,14 @@ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
197
197
  return this.parent.readFileBytes(await this.guardedPhysical(path));
198
198
  }
199
199
 
200
+ async readFilePage(
201
+ path: string,
202
+ offset?: number,
203
+ ) {
204
+ if (!this.parent.readFilePage) return null;
205
+ return this.parent.readFilePage(await this.guardedPhysical(path), offset);
206
+ }
207
+
200
208
  async writeFile(path: string, content: string, mimeType?: string) {
201
209
  return this.parent.writeFile(
202
210
  await this.guardedPhysical(path, true),
@@ -373,6 +381,9 @@ export function createWorkspacePortFacade(
373
381
  const facade: WorkspacePort = {
374
382
  readFile: (path) => workspace.readFile(path),
375
383
  readFileBytes: (path) => workspace.readFileBytes(path),
384
+ ...(workspace.readFilePage
385
+ ? { readFilePage: (path, offset) => workspace.readFilePage!(path, offset) }
386
+ : {}),
376
387
  writeFile: (path, content, mimeType) =>
377
388
  workspace.writeFile(path, content, mimeType),
378
389
  writeFileBytes: (path, data, mimeType) =>
@@ -59,6 +59,19 @@ export type WorkspaceFileInspection = WorkspaceFileInfo & {
59
59
  evidence?: WorkspaceFileEvidence;
60
60
  };
61
61
 
62
+ type WorkspaceFilePageBase = {
63
+ size: number;
64
+ nextOffset: number | null;
65
+ };
66
+
67
+ export type WorkspaceFilePage =
68
+ | (WorkspaceFilePageBase & { kind: "text"; content: string })
69
+ | (WorkspaceFilePageBase & {
70
+ kind: "csv";
71
+ columns: string[] | null;
72
+ rows: string[][];
73
+ });
74
+
62
75
  /**
63
76
  * 向 Runtime 提供已限定作用域的文件操作。
64
77
  *
@@ -86,6 +99,14 @@ export interface WorkspacePort {
86
99
  * 它与 `readFile` 分开,避免 Runtime 猜测字符编码。
87
100
  */
88
101
  readFileBytes(path: string): Promise<Uint8Array | null>;
102
+ /**
103
+ * Reads one bounded page without buffering the complete file.
104
+ * CSV pages preserve complete records and return parsed columns and rows.
105
+ */
106
+ readFilePage?(
107
+ path: string,
108
+ offset?: number,
109
+ ): Promise<WorkspaceFilePage | null>;
89
110
  /**
90
111
  * 覆盖写入一个文本文件。
91
112
  *
package/src/lib/prompt.ts CHANGED
@@ -99,11 +99,13 @@ export const BROWSER =
99
99
 
100
100
  // Uploaded-file routing prevents lossy markdown conversions from becoming data sources.
101
101
  export const FILES =
102
- "Uploaded files live under /uploads/ in your workspace; convertible formats have a companion " +
102
+ "Uploaded files live under /inputs/ in your workspace; convertible formats have a companion " +
103
103
  "'<file>.md' (structured markdown). Policy: to summarize, quote, or search files, use state.* inside execute. " +
104
+ "For every CSV analysis in Code Mode, start with state.readFilePage({ path }); CSV pages expose columns and rows, never data. Rows are arrays aligned to columns, not objects. Never call state.readFile for a CSV. " +
104
105
  "To compute/aggregate/transform (especially csv/xlsx), write code in execute Code Mode or the Linux Sandbox that reads " +
105
- "the ORIGINAL file — converted markdown tables are not for computation, and large spreadsheets may " +
106
- "have no .md at all. For formats without a companion .md (e.g. pptx: unzip and read ppt/slides/*.xml; " +
106
+ "the ORIGINAL file — converted markdown tables are not for computation. " +
107
+ "For other large text files in Code Mode, use state.readFilePage({ path }) and continue until nextOffset is null. " +
108
+ "Large spreadsheets may have no .md at all. For formats without a companion .md (e.g. pptx: unzip and read ppt/slides/*.xml; " +
107
109
  "zip archives; unknown types), parse the original in the sandbox with JS.";
108
110
 
109
111
  // User-interaction actions are called only when their UI semantics are useful.
@@ -9,7 +9,10 @@ import {
9
9
  type ProxyToolOutput,
10
10
  } from "@cloudflare/codemode";
11
11
  import { createBrowserTools } from "@cloudflare/think/tools/browser";
12
- import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
12
+ import {
13
+ createExecuteRuntime,
14
+ type CreateExecuteToolOptions,
15
+ } from "@cloudflare/think/tools/execute";
13
16
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
14
17
  import type { Usage } from "@earendil-works/pi-ai";
15
18
  import type { ToolSet } from "ai";
@@ -18,6 +21,10 @@ import type {
18
21
  RuntimeCodeExecutionPort,
19
22
  WorkspacePort,
20
23
  } from "../../kernel/bindings";
24
+ import {
25
+ WorkspaceStateConnector,
26
+ WORKSPACE_PAGING_INSTRUCTIONS,
27
+ } from "./workspace-state-connector";
21
28
  import type { RuntimeCodeExecutionFactory } from "../../kernel/bindings";
22
29
  import { serializeOutput } from "../../lib/artifacts";
23
30
  import type { PiToolCandidate } from "./compiler";
@@ -88,7 +95,7 @@ state.writeFileBytes is not available. Use state.writeFile or state.appendFile f
88
95
 
89
96
  Bundled Skill resources are not Workspace files. Read text with tools.read_skill_resource, run bundled scripts with tools.run_skill_script, and copy templates or assets with tools.materialize_skill_resource.
90
97
 
91
- User Attachments are already Workspace files below /inputs. Inspect their exact path and immutable cdnUrl before using them.
98
+ User Attachments are already Workspace files below /inputs. Inspect their exact path before using them. Never raw-fetch an attachment already present below /inputs or materialize its cdnUrl; read it with state.*.
92
99
 
93
100
  When an asset already exists in Workspace, use state.cp to copy it into the current Website directory. Use actual task paths and reference the destination with a relative URL.
94
101
 
@@ -336,7 +343,19 @@ export function createWorkspaceCodeExecutionFactory(options: {
336
343
  options.workspace,
337
344
  options.workspaceAccessMode,
338
345
  );
339
- const createPort = (innerResults: AgentToolResult<unknown>[]) => {
346
+ const workspaceState = state && options.workspace.readFilePage
347
+ ? new WorkspaceStateConnector(
348
+ options.ctx,
349
+ state,
350
+ options.workspace as WorkspacePort & {
351
+ readFilePage: NonNullable<WorkspacePort["readFilePage"]>;
352
+ },
353
+ )
354
+ : undefined;
355
+ const createPort = (
356
+ innerResults: AgentToolResult<unknown>[],
357
+ stateOptions: Pick<CreateExecuteToolOptions, "state" | "connectors">,
358
+ ) => {
340
359
  let nestedCallOrdinal = 0;
341
360
  const { tool } = createExecuteRuntime({
342
361
  ctx: options.ctx,
@@ -348,7 +367,7 @@ export function createWorkspaceCodeExecutionFactory(options: {
348
367
  onResult: (value) => innerResults.push(value),
349
368
  }),
350
369
  timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
351
- ...(state ? { state } : {}),
370
+ ...stateOptions,
352
371
  name: "execute",
353
372
  });
354
373
  return toCodeExecutionPort(
@@ -360,10 +379,17 @@ export function createWorkspaceCodeExecutionFactory(options: {
360
379
  ),
361
380
  );
362
381
  };
363
- const description = createPort([]).description;
382
+ // Inherit the complete official state description; custom connectors otherwise lose its hints.
383
+ const description = createPort([], state ? { state } : {}).description;
364
384
  return {
365
- description: `${description}\n\n${SPRINGBRAND_CODE_MODE_POLICY}`,
366
- execute: (input) => createPort([]).execute(input),
385
+ description: [
386
+ description,
387
+ ...(workspaceState ? [WORKSPACE_PAGING_INSTRUCTIONS] : []),
388
+ SPRINGBRAND_CODE_MODE_POLICY,
389
+ ].join("\n\n"),
390
+ execute: (input) => createPort(
391
+ [], workspaceState ? { connectors: [workspaceState] } : state ? { state } : {},
392
+ ).execute(input),
367
393
  };
368
394
  },
369
395
  };
@@ -0,0 +1,145 @@
1
+ import type { StateBackend } from "@cloudflare/shell/workers";
2
+ import { StateConnector } from "@cloudflare/shell/workers";
3
+ import type { ConnectorTools } from "@cloudflare/codemode";
4
+ import type { WorkspacePort } from "../../kernel/bindings";
5
+
6
+ export const WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES = 1024 * 1024;
7
+
8
+ const READ_FILE_PAGE_TYPES = `
9
+ type WorkspaceFilePage =
10
+ | { kind: "text"; size: number; nextOffset: number | null; content: string }
11
+ | { kind: "csv"; size: number; nextOffset: number | null; columns: string[] | null; rows: string[][] };
12
+ `;
13
+
14
+ 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:
15
+
16
+ let page = await state.readFilePage({ path });
17
+ if (!page || page.kind !== "csv") throw new Error("CSV not found");
18
+ const columns = page.columns;
19
+ for (;;) {
20
+ // Aggregate page.rows here. Do not return raw rows.
21
+ if (page.nextOffset === null) break;
22
+ page = await state.readFilePage({ path, offset: page.nextOffset });
23
+ }
24
+ return summary;
25
+
26
+ 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.`;
27
+
28
+ type PagedWorkspacePort = WorkspacePort & {
29
+ readFilePage: NonNullable<WorkspacePort["readFilePage"]>;
30
+ };
31
+
32
+ export class WorkspaceStateConnector extends StateConnector {
33
+ constructor(
34
+ ctx: DurableObjectState | ExecutionContext,
35
+ backend: StateBackend,
36
+ private readonly workspace: PagedWorkspacePort,
37
+ ) {
38
+ super(ctx, backend);
39
+ }
40
+
41
+ protected override instructions(): string {
42
+ const base = super.instructions();
43
+ return `${base}
44
+
45
+ ${WORKSPACE_PAGING_INSTRUCTIONS}`;
46
+ }
47
+
48
+ protected override tools(): ConnectorTools {
49
+ const tools = super.tools();
50
+ const readFile = tools.readFile!;
51
+ return {
52
+ ...tools,
53
+ readFile: {
54
+ ...readFile,
55
+ description:
56
+ `${readFile.description} CSV and files over ${WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES} bytes must use readFilePage.`,
57
+ execute: async (args, context) => {
58
+ const path = requiredPath(args, "readFile");
59
+ const file = await this.workspace.stat(path);
60
+ if (file?.type === "file" && (
61
+ file.mimeType === "text/csv" ||
62
+ file.size > WORKSPACE_DIRECT_TEXT_READ_MAX_BYTES
63
+ )) {
64
+ throw new Error(
65
+ `${path} requires state.readFilePage({ path: ${JSON.stringify(path)} }); continue with nextOffset until it is null`,
66
+ );
67
+ }
68
+ return readFile.execute(args, context);
69
+ },
70
+ },
71
+ readFilePage: {
72
+ description:
73
+ "Read up to 512 KiB or 2,000 CSV rows from a Workspace file. " +
74
+ "A CSV page returns columns and rows and has no data field; rows are string arrays aligned to columns. " +
75
+ "A text page returns content. Omit offset on the first call, then pass page.nextOffset until it is null. " +
76
+ "Prefer this over readFile for CSV and large files.",
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {
80
+ path: { type: "string", minLength: 1, description: "Absolute Workspace file path." },
81
+ offset: {
82
+ type: "integer",
83
+ minimum: 0,
84
+ description: "Previous page.nextOffset. Omit on the first call.",
85
+ },
86
+ },
87
+ required: ["path"],
88
+ additionalProperties: false,
89
+ },
90
+ replay: "reexecute",
91
+ execute: (args) => {
92
+ const input = readFilePageInput(args);
93
+ return this.workspace.readFilePage(input.path, input.offset);
94
+ },
95
+ },
96
+ };
97
+ }
98
+
99
+ override async getTypeScriptTypes(): Promise<string> {
100
+ const types = await super.getTypeScriptTypes();
101
+ const addition = [
102
+ " /** CSV pages have columns and rows; never data. Omit offset first, then pass nextOffset until null. */",
103
+ " readFilePage(args: { path: string; offset?: number }): Promise<WorkspaceFilePage | null>;",
104
+ ].join("\n");
105
+ return `${READ_FILE_PAGE_TYPES}\n${types.replace(/\n\};$/u, `\n${addition}\n};`)}`;
106
+ }
107
+ }
108
+
109
+ function requiredPath(value: unknown, method: string): string {
110
+ if (!value || typeof value !== "object" || Array.isArray(value) ||
111
+ typeof (value as { path?: unknown }).path !== "string" ||
112
+ !(value as { path: string }).path) {
113
+ throw new Error(
114
+ `state.${method} takes one object argument: state.${method}({ path: "/path/to/file" })`,
115
+ );
116
+ }
117
+ return (value as { path: string }).path;
118
+ }
119
+
120
+ function readFilePageInput(value: unknown): {
121
+ path: string;
122
+ offset?: number;
123
+ } {
124
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
125
+ throw new Error("state.readFilePage requires an object argument");
126
+ }
127
+ const input = value as Record<string, unknown>;
128
+ const keys = Object.keys(input);
129
+ if (keys.some((key) => !["path", "offset"].includes(key))) {
130
+ throw new Error("state.readFilePage accepts only path and offset");
131
+ }
132
+ if (typeof input.path !== "string" || input.path.length === 0) {
133
+ throw new Error("state.readFilePage requires path");
134
+ }
135
+ const offset = input.offset;
136
+ if (offset !== undefined && (!Number.isInteger(offset) || (offset as number) < 0)) {
137
+ throw new Error(
138
+ "state.readFilePage offset must be a previous page.nextOffset; omit it on the first call",
139
+ );
140
+ }
141
+ return {
142
+ path: input.path,
143
+ ...(offset === undefined ? {} : { offset: offset as number }),
144
+ };
145
+ }