@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
|
@@ -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) =>
|
package/src/kernel/bindings.ts
CHANGED
|
@@ -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 /
|
|
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
|
|
106
|
-
"
|
|
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.
|
package/src/pi/tool/core-host.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
-
|
|
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:
|
|
366
|
-
|
|
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
|
+
}
|