@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.
@@ -1,16 +1,13 @@
1
- import { createQuickActionTools } from "@cloudflare/think/tools/browser";
2
1
  import type {
3
2
  AgentTool,
4
3
  AgentToolResult,
5
4
  } from "@earendil-works/pi-agent-core";
6
5
  import { Type } from "@earendil-works/pi-ai";
7
6
  import type {
8
- RuntimeBrowserPort,
9
7
  RuntimeCodeExecutionPort,
10
8
  } from "../../kernel/bindings";
11
9
  import { serializeOutput } from "../../lib/artifacts";
12
10
  import type { PiLoadedExtension } from "../assembly/extensions";
13
- import { aiToolToPi } from "./ai-adapter";
14
11
  import type { PiToolCandidate } from "./compiler";
15
12
 
16
13
  // 本文件沿用 `../../index.ts` 入口定义的 Extension、Port 和 Tool Candidate 术语。
@@ -25,37 +22,146 @@ function result(details: unknown): AgentToolResult<unknown> {
25
22
  };
26
23
  }
27
24
 
28
- // #region Browser Quick Actions
25
+ const BROWSER_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
26
+ const BROWSER_IMAGE_MAX_BASE64_CHARS = Math.ceil(BROWSER_IMAGE_MAX_BYTES / 3) * 4;
27
+ const BROWSER_IMAGE_MIME_TYPES = new Set([
28
+ "image/jpeg",
29
+ "image/png",
30
+ "image/webp",
31
+ ]);
29
32
 
30
- const BROWSER_TOOL_LABELS: Readonly<Record<string, string>> = {
31
- browser_markdown: "Read web page",
32
- browser_extract: "Extract web data",
33
- browser_links: "List web links",
34
- browser_scrape: "Scrape web elements",
35
- };
33
+ function record(value: unknown): Record<string, unknown> | undefined {
34
+ return value !== null && typeof value === "object" && !Array.isArray(value)
35
+ ? value as Record<string, unknown>
36
+ : undefined;
37
+ }
36
38
 
37
- /**
38
- * 为 Cloudflare Browser Run 的四个一次性 Quick Action 创建 Pi 工具候选项。
39
- *
40
- * Runtime 在宿主提供 Browser port 时调用,模型分别用它读取 Markdown、抽取数据、列出链接或按选择器抓取。
41
- *
42
- * Cloudflare 官方将 Quick Actions 定位为只需 browser binding 的无状态单次操作,这里直接复用官方工厂。
43
- */
44
- export function browserQuickActionPiToolCandidates(
45
- browser: RuntimeBrowserPort,
46
- ): PiToolCandidate[] {
47
- return Object.entries(createQuickActionTools({ browser })).map(
48
- ([name, tool]) => ({
49
- owner: "core:browser",
50
- requiredExecutionLevel: "low",
51
- tool: aiToolToPi(name, tool, {
52
- label: BROWSER_TOOL_LABELS[name] ?? name,
53
- }),
54
- }),
55
- );
39
+ function base64Bytes(value: string): number | undefined {
40
+ if (
41
+ value.length === 0 ||
42
+ value.length % 4 !== 0 ||
43
+ !/^[A-Za-z0-9+/]+={0,2}$/u.test(value)
44
+ ) return undefined;
45
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
46
+ return value.length / 4 * 3 - padding;
56
47
  }
57
48
 
58
- // #endregion
49
+ function screenshotMimeType(format: unknown): string {
50
+ return format === "jpeg"
51
+ ? "image/jpeg"
52
+ : format === "webp"
53
+ ? "image/webp"
54
+ : "image/png";
55
+ }
56
+
57
+ function browserImageMetadata(
58
+ data: unknown,
59
+ mimeType: unknown,
60
+ ): { error: string } | { mimeType: string; bytes: number } {
61
+ if (typeof mimeType !== "string" || !BROWSER_IMAGE_MIME_TYPES.has(mimeType)) {
62
+ return { error: "unsupported_mime_type" };
63
+ }
64
+ if (typeof data !== "string") return { error: "invalid_base64" };
65
+ if (data.length > BROWSER_IMAGE_MAX_BASE64_CHARS) {
66
+ return { error: "image_too_large" };
67
+ }
68
+ const bytes = base64Bytes(data);
69
+ if (bytes === undefined) return { error: "invalid_base64" };
70
+ if (bytes > BROWSER_IMAGE_MAX_BYTES) return { error: "image_too_large" };
71
+ return { mimeType, bytes };
72
+ }
73
+
74
+ /** Project the explicit browser screenshot envelope into Pi's native image content. */
75
+ function browserResult(details: unknown): AgentToolResult<unknown> {
76
+ const outer = record(details);
77
+ if (!outer) return result(details);
78
+ const rawResult = outer.result;
79
+ let inner = record(rawResult);
80
+ if (!inner && typeof rawResult === "string") {
81
+ try {
82
+ inner = record(JSON.parse(rawResult));
83
+ } catch {
84
+ // The screenshot audit below can still recover truncated JSON.
85
+ }
86
+ }
87
+
88
+ const calls = Array.isArray(outer.calls) ? outer.calls : [];
89
+ let screenshotCall: Record<string, unknown> | undefined;
90
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
91
+ const call = record(calls[index]);
92
+ const args = record(call?.args);
93
+ if (
94
+ call?.connector === "cdp" &&
95
+ call.method === "send" &&
96
+ args?.method === "Page.captureScreenshot"
97
+ ) {
98
+ screenshotCall = call;
99
+ break;
100
+ }
101
+ }
102
+
103
+ const image = record(inner?.image);
104
+ const screenshotArgs = record(screenshotCall?.args);
105
+ const screenshotResult = record(screenshotCall?.result);
106
+ const format = record(screenshotArgs?.params)?.format;
107
+ const data = typeof image?.data === "string"
108
+ ? image.data
109
+ : screenshotResult?.data;
110
+ const mimeType = typeof image?.mimeType === "string"
111
+ ? image.mimeType
112
+ : screenshotMimeType(format);
113
+ if (!(inner && "image" in inner) && typeof data !== "string") {
114
+ return result(details);
115
+ }
116
+ const projectedImage = browserImageMetadata(data, mimeType);
117
+
118
+ const projectedCalls = Array.isArray(outer.calls)
119
+ ? 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
+ })
141
+ : outer.calls;
142
+ const projectedDetails = {
143
+ ...outer,
144
+ result: inner
145
+ ? {
146
+ ...inner,
147
+ image: projectedImage,
148
+ }
149
+ : {
150
+ image: projectedImage,
151
+ note: "Screenshot recovered from the Page.captureScreenshot result.",
152
+ },
153
+ ...("calls" in outer ? { calls: projectedCalls } : {}),
154
+ };
155
+ return {
156
+ content: [
157
+ { type: "text", text: serializeOutput(projectedDetails).text },
158
+ ...("error" in projectedImage
159
+ ? []
160
+ : [{ type: "image" as const, data: data as string, mimeType: projectedImage.mimeType }]),
161
+ ],
162
+ details: projectedDetails,
163
+ };
164
+ }
59
165
 
60
166
  // #region Extension discovery
61
167
 
@@ -107,6 +213,46 @@ const executeParameters = Type.Object({
107
213
  });
108
214
  const CODEMODE_EXECUTE_TIMEOUT_MS = 60_000;
109
215
 
216
+ // 把模型提供的代码交给 Codemode Runtime 执行,并在外层再压一道截止。
217
+ // 两个 Code Mode 类工具(`execute` 与 `browser_execute`)共用同一段时序,
218
+ // 避免两套心智模型;`label` 只用于超时文案,因为模型看到的名字由候选项决定。
219
+ function runCodemode(
220
+ runtime: RuntimeCodeExecutionPort,
221
+ label: string,
222
+ project: (details: unknown) => AgentToolResult<unknown> = result,
223
+ ): AgentTool<typeof executeParameters>["execute"] {
224
+ return async (_toolCallId, input, signal) => {
225
+ signal?.throwIfAborted();
226
+ // ponytail: 外层截止只保证 Agent 继续;Codemode 支持 AbortSignal 或宿主 dispose 后再终止底层执行。
227
+ let timeout: ReturnType<typeof setTimeout> | undefined;
228
+ const deadline = new Promise<{
229
+ status: "error";
230
+ code: "timeout";
231
+ error: string;
232
+ retryable: false;
233
+ outcome: "unknown";
234
+ }>((resolve) => {
235
+ timeout = setTimeout(() => resolve({
236
+ status: "error",
237
+ code: "timeout",
238
+ error:
239
+ `${label} exceeded ${CODEMODE_EXECUTE_TIMEOUT_MS} ms; ` +
240
+ "use existing results and state the missing evidence.",
241
+ retryable: false,
242
+ outcome: "unknown",
243
+ }), CODEMODE_EXECUTE_TIMEOUT_MS);
244
+ });
245
+ try {
246
+ return project(await Promise.race([
247
+ runtime.execute(input),
248
+ deadline,
249
+ ]));
250
+ } finally {
251
+ if (timeout !== undefined) clearTimeout(timeout);
252
+ }
253
+ };
254
+ }
255
+
110
256
  /**
111
257
  * 把 Cloudflare Codemode Runtime handle 包装为 Pi 代码执行工具候选项。
112
258
  *
@@ -123,39 +269,9 @@ export function codeExecutionPiToolCandidate(
123
269
  label: "Execute JavaScript",
124
270
  description: runtime.description,
125
271
  parameters: executeParameters,
126
- // 把模型提供的 JavaScript 交给 Codemode Runtime 执行。
127
272
  // Pi 工具循环在模型选择 `execute` 时调用,调用前允许 Turn 取消。
128
273
  // 必须通过 Runtime handle 而不是直接调用 executor,因为 Cloudflare Codemode 把重放、审批和执行日志放在持久化 Runtime 层。
129
- async execute(_toolCallId, input, signal) {
130
- signal?.throwIfAborted();
131
- // ponytail: 外层截止只保证 Agent 继续;Codemode 支持 AbortSignal 或宿主 dispose 后再终止底层执行。
132
- let timeout: ReturnType<typeof setTimeout> | undefined;
133
- const deadline = new Promise<{
134
- status: "error";
135
- code: "timeout";
136
- error: string;
137
- retryable: false;
138
- outcome: "unknown";
139
- }>((resolve) => {
140
- timeout = setTimeout(() => resolve({
141
- status: "error",
142
- code: "timeout",
143
- error:
144
- `Code Mode execute exceeded ${CODEMODE_EXECUTE_TIMEOUT_MS} ms; ` +
145
- "use existing results and state the missing evidence.",
146
- retryable: false,
147
- outcome: "unknown",
148
- }), CODEMODE_EXECUTE_TIMEOUT_MS);
149
- });
150
- try {
151
- return result(await Promise.race([
152
- runtime.execute(input),
153
- deadline,
154
- ]));
155
- } finally {
156
- if (timeout !== undefined) clearTimeout(timeout);
157
- }
158
- },
274
+ execute: runCodemode(runtime, "Code Mode execute"),
159
275
  };
160
276
  return {
161
277
  owner: "core:codemode",
@@ -167,4 +283,36 @@ export function codeExecutionPiToolCandidate(
167
283
  };
168
284
  }
169
285
 
286
+ /** 模型可见的浏览器工具名;属外部契约,改名是破坏性变更。 */
287
+ export const BROWSER_EXECUTE_TOOL_NAME = "browser_execute";
288
+
289
+ /**
290
+ * 把 Cloudflare Browser Run 的 CDP Code Mode handle 包装为 Pi 浏览器工具候选项。
291
+ *
292
+ * Tool Surface 在 Platform 提供浏览器能力时调用,模型通过 `browser_execute`
293
+ * 对着 `cdp` 连接器驱动一个真实无头浏览器。
294
+ *
295
+ * `browser_execute` 是模型可见的外部契约,改名属破坏性变更。
296
+ * 执行档位取 `low`:它能对任意外部地址发起真实浏览器导航,故不低于 Code Mode 的 `safe`。
297
+ */
298
+ export function browserExecutionPiToolCandidate(
299
+ runtime: RuntimeCodeExecutionPort,
300
+ ): PiToolCandidate {
301
+ const tool: AgentTool<typeof executeParameters> = {
302
+ name: BROWSER_EXECUTE_TOOL_NAME,
303
+ label: "Drive a browser",
304
+ description: runtime.description,
305
+ parameters: executeParameters,
306
+ execute: runCodemode(runtime, "Browser Code Mode execute", browserResult),
307
+ };
308
+ return {
309
+ owner: "core:browser",
310
+ requiredExecutionLevel: "low",
311
+ outputBudget: { kind: "structure" },
312
+ source: "codemode",
313
+ summary: "Drive a real headless browser over CDP",
314
+ tool,
315
+ };
316
+ }
317
+
170
318
  // #endregion
@@ -96,7 +96,7 @@ function candidate<T extends TSchema>(
96
96
  options: Partial<
97
97
  Pick<
98
98
  PiToolCandidate,
99
- "alwaysRequiresApproval" | "owner" | "requiredExecutionLevel" | "summary"
99
+ "alwaysRequiresApproval" | "direct" | "owner" | "requiredExecutionLevel" | "summary"
100
100
  >
101
101
  > = {},
102
102
  ): PiToolCandidate {
@@ -108,6 +108,7 @@ function candidate<T extends TSchema>(
108
108
  ...(options.alwaysRequiresApproval
109
109
  ? { alwaysRequiresApproval: true }
110
110
  : {}),
111
+ ...(options.direct ? { direct: true } : {}),
111
112
  ...(options.summary ? { summary: options.summary } : {}),
112
113
  };
113
114
  }
@@ -137,6 +138,7 @@ export function schedulePiToolCandidates(
137
138
  },
138
139
  {
139
140
  alwaysRequiresApproval: true,
141
+ direct: true,
140
142
  summary: "Create a scheduled task",
141
143
  },
142
144
  ),
@@ -50,6 +50,80 @@ function scriptTools(
50
50
  })) as ToolSet;
51
51
  }
52
52
 
53
+ /**
54
+ * `run_skill_script` 一次挂载的 Skill 资源总量上限。
55
+ *
56
+ * 上游 SkillRegistry 在启动脚本前会把 Skill 的**全部**资源读进内存
57
+ * (`readSkillResources`),运行器随后还要再复制几份:files map、base64 解码后的
58
+ * 字节、以及内联进生成源码的那份 JSON。同一个资源包因此会在 Worker isolate 里
59
+ * 同时存在三到四份,按四倍放大倒推 128 MB 的 DO 内存上限,安全线落在 8 MB。
60
+ *
61
+ * 这道闸只认体积不认 Skill 名字:以后再上一个大包不需要改代码。
62
+ */
63
+ const SKILL_RESOURCE_BUDGET_BYTES = 8 * 1024 * 1024;
64
+
65
+ type LoadedSkill = NonNullable<Awaited<ReturnType<SkillSource["load"]>>>;
66
+
67
+ /**
68
+ * 按体积裁掉超预算的 Skill 资源,并把裁掉的事实写回 Skill 正文。
69
+ *
70
+ * @remarks
71
+ * `catalogSkillSource` 在每次 `load` 后调用,必须在这一层拦截:上游拿到 descriptor
72
+ * 后会立刻把它们全读进内存,等运行器收到请求时资源已经在堆上,再判断就晚了。
73
+ *
74
+ * 裁掉的资源必须**说出来**。它们同时会从 `activate_skill` 的资源清单里消失,若不
75
+ * 在正文里点名,模型只会看到一个凭空少了文件的 Skill,然后去猜。
76
+ *
77
+ * 已知缺口:没有 `size` 的 descriptor 不计入预算。R2 来源的 size 来自对象列表因而
78
+ * 总是存在;manifest 来源可能没有,此时这道闸对那些资源不生效。宁可放过也不误杀,
79
+ * 因为误杀会让本来能跑的 Skill 直接坏掉。
80
+ */
81
+ function budgetSkillResources(skill: LoadedSkill): LoadedSkill {
82
+ const resources = skill.resources ?? [];
83
+ const kept: typeof resources = [];
84
+ const dropped: { path: string; size: number }[] = [];
85
+ let used = 0;
86
+
87
+ for (const resource of resources) {
88
+ if (typeof resource.size !== "number") {
89
+ kept.push(resource);
90
+ continue;
91
+ }
92
+ if (used + resource.size > SKILL_RESOURCE_BUDGET_BYTES) {
93
+ dropped.push({ path: resource.path, size: resource.size });
94
+ continue;
95
+ }
96
+ used += resource.size;
97
+ kept.push(resource);
98
+ }
99
+
100
+ if (dropped.length === 0) return skill;
101
+ const budgetMb = Math.round(SKILL_RESOURCE_BUDGET_BYTES / (1024 * 1024));
102
+ // materialize_skill_resource 也要把整份资源读进内存(base64 字符串 → atob 副本 →
103
+ // Uint8Array),放大倍数和挂载路径同量级。把一个单独就超预算的文件推荐给它,只是把
104
+ // OOM 从脚本挪到复制那一步 —— 那种资源这一轮就是拿不到,必须直说。
105
+ const portable = dropped.filter((entry) => entry.size <= SKILL_RESOURCE_BUDGET_BYTES);
106
+ const oversized = dropped.filter((entry) => entry.size > SKILL_RESOURCE_BUDGET_BYTES);
107
+ const lines = [
108
+ `> ${dropped.length} bundled resource(s) exceed the ${budgetMb} MB ` +
109
+ "script-mount budget and are NOT available to run_skill_script.",
110
+ ];
111
+ if (portable.length > 0) {
112
+ lines.push(
113
+ `> If any of these resources are needed, copy all required ones into the Workspace ` +
114
+ `in one execute with materialize_skill_resource: ` +
115
+ `${portable.map((entry) => entry.path).join(", ")}.`,
116
+ );
117
+ }
118
+ if (oversized.length > 0) {
119
+ lines.push(
120
+ `> These are individually larger than ${budgetMb} MB and cannot be loaded at ` +
121
+ `all in this session — do not try: ${oversized.map((entry) => entry.path).join(", ")}.`,
122
+ );
123
+ }
124
+ return { ...skill, resources: kept, body: `${skill.body}\n\n${lines.join("\n")}` };
125
+ }
126
+
53
127
  function catalogSkillSource(binding: PiSkillBinding): SkillSource {
54
128
  const source = binding.source;
55
129
  return {
@@ -63,7 +137,10 @@ function catalogSkillSource(binding: PiSkillBinding): SkillSource {
63
137
  description: binding.description,
64
138
  sourceId: source.id,
65
139
  }],
66
- load: (name) => source.load(name),
140
+ load: async (name) => {
141
+ const skill = await source.load(name);
142
+ return skill && budgetSkillResources(skill);
143
+ },
67
144
  ...(source.readResource
68
145
  ? { readResource: (name: string, path: string) =>
69
146
  source.readResource!(name, path) }
@@ -134,6 +211,7 @@ function materializeSkillResourceTool(
134
211
  description:
135
212
  "Copy a complete bundled Skill resource directly into the Workspace without returning its contents to the model. " +
136
213
  "Use this instead of read_skill_resource when a template, script, image, font, or other asset must become a Workspace file. " +
214
+ "When multiple resources are needed, copy them in one execute with a loop or Promise.all; never start one execute per resource. " +
137
215
  "The destination is created or overwritten, including parent directories.",
138
216
  inputSchema: z.object({
139
217
  name: z.enum(names).describe("Activated Skill name"),
@@ -230,6 +308,15 @@ export async function skillPiToolCandidates(
230
308
  name === "run_skill_script" || name === "materialize_skill_resource"
231
309
  ? "high"
232
310
  : "safe",
311
+ // activate_skill 返回的是 Skill 指令本身,是模型接下来所有动作的依据。把它外置
312
+ // 成文件,模型手里就只剩一个路径,必须再取一次才能知道该做什么 —— 而典型
313
+ // SKILL.md 正好落在会触发外置的区间。指令必须当场到手。
314
+ ...(name === "activate_skill"
315
+ ? { outputBudget: { kind: "structure" as const } }
316
+ : {}),
317
+ ...(name === "materialize_skill_resource"
318
+ ? { codeExecutionOnly: true as const }
319
+ : {}),
233
320
  tool: adapted,
234
321
  };
235
322
  });
@@ -64,14 +64,137 @@ const workspaceReadParameters = Type.Object({
64
64
  maxLength: 4_096,
65
65
  description: "Absolute Workspace path",
66
66
  }),
67
- offset: Type.Optional(Type.Integer({ minimum: 1 })),
68
- limit: Type.Optional(Type.Integer({ minimum: 1 })),
67
+ offset: Type.Optional(Type.Integer({
68
+ minimum: 1,
69
+ description: "1-indexed line to start from; pass the nextOffset of the previous page",
70
+ })),
71
+ limit: Type.Optional(Type.Integer({
72
+ minimum: 1,
73
+ description: "Maximum lines to return; a page is capped by size regardless",
74
+ })),
69
75
  });
70
76
  const workspaceEditParameters = Type.Object({
71
77
  path: Type.String({ minLength: 1, maxLength: 4_096 }),
72
78
  old_string: Type.String(),
73
79
  new_string: Type.String(),
74
80
  });
81
+
82
+ /**
83
+ * 单页 read 返回的字符上限。
84
+ *
85
+ * 上游 think 的 read 只卡行数(2000)和行宽(2000 字符),二者相乘意味着单次调用
86
+ * 理论上能返回 4 MB。这里补上缺失的总量闸,让一页的大小可预测。
87
+ *
88
+ * MUST ≤ `budget/gate.ts` 的 `STORAGE_LEAF_MAX_CHARS`,否则持久记录会比模型当轮
89
+ * 看到的内容还少;`gate.test.ts` 有断言守着这条关系。
90
+ */
91
+ export const READ_PAGE_MAX_CHARS = 64 * 1024;
92
+
93
+ /** think 给每一行加的 `${lineNo}\t` 前缀。 */
94
+ const NUMBERED_LINE = /^(\d+)\t/;
95
+
96
+ /** 页脚占用的字符预留量;页脚和正文同属一个字符串叶子,必须一起受预算约束。 */
97
+ const MARKER_RESERVE_CHARS = 256;
98
+
99
+ interface ReadPage {
100
+ readonly content?: unknown;
101
+ readonly totalLines?: unknown;
102
+ readonly fromLine?: unknown;
103
+ }
104
+
105
+ /**
106
+ * 把一次 read 收敛成一页,并明确给出下一页的位置。
107
+ *
108
+ * @remarks
109
+ * read 的包装层在拿到上游结果后调用;图片、PDF 和二进制结果原样透传,因为它们
110
+ * 没有行的概念。
111
+ *
112
+ * 行号从内容里回读而不是复用上游的 `toLine`:上游那个字段是按**请求区间**算的,
113
+ * 一旦它自己触发 2000 行截断就会偏大,照抄会让 `nextOffset` 跳过没读到的行。
114
+ *
115
+ * 返回值同时重建模型可见文本和 details,二者必须来自同一份分页结果,否则模型读到
116
+ * 的内容会比 details 记录的多。
117
+ */
118
+ function pageReadResult(
119
+ output: AgentToolResult<unknown>,
120
+ ): AgentToolResult<unknown> {
121
+ const details = output.details;
122
+ if (details === null || typeof details !== "object") return output;
123
+ const page = details as ReadPage;
124
+ if (typeof page.content !== "string") return output;
125
+
126
+ const totalLines = typeof page.totalLines === "number"
127
+ ? page.totalLines
128
+ : undefined;
129
+ const rows = page.content.split("\n").filter((row) => NUMBERED_LINE.test(row));
130
+ if (rows.length === 0) {
131
+ // offset 越过文件末尾时上游返回空内容。空白结果没有任何可操作信息,而模型现在
132
+ // 是被要求自己推进 offset 的,不说清楚它只会换个数再试一次。
133
+ const text = `[no lines at that offset` +
134
+ `${totalLines === undefined ? "" : `; the file has ${totalLines} lines`}]`;
135
+ return {
136
+ ...output,
137
+ content: [{ type: "text", text }],
138
+ details: { ...details, eof: true, ...(totalLines === undefined ? {} : { totalLines }) },
139
+ };
140
+ }
141
+
142
+ // 页脚也要算进预算:它和正文一起构成模型看到的那个字符串叶子,漏算就会正好顶穿
143
+ // STORAGE_LEAF_MAX_CHARS,让持久化再从中间挖掉几十个字符。
144
+ const budget = READ_PAGE_MAX_CHARS - MARKER_RESERVE_CHARS;
145
+ let used = 0;
146
+ let kept = 0;
147
+ for (const row of rows) {
148
+ const next = used + row.length + (kept === 0 ? 0 : 1);
149
+ // 至少留一行,否则单行超限的文件会返回空页,模型无从推进。
150
+ if (kept > 0 && next > budget) break;
151
+ used = next;
152
+ kept += 1;
153
+ }
154
+
155
+ const lastRow = rows[kept - 1] as string;
156
+ const toLine = Number(NUMBERED_LINE.exec(lastRow)?.[1]);
157
+ const eof = totalLines === undefined
158
+ ? kept === rows.length
159
+ : toLine >= totalLines;
160
+ // 上游按 2000 字符截断超长行并就地打标。JSON 溢出产物这类“整块内容挤在一行”的
161
+ // 文件会命中它,此时按行分页永远追不回被砍掉的部分 —— 必须说出来,否则模型会
162
+ // 拿着一个 eof 以为自己读全了。
163
+ const lossy = rows.slice(0, kept).some((row) => row.endsWith("... (truncated)"));
164
+
165
+ const content = rows.slice(0, kept).join("\n");
166
+ const fromLine = typeof page.fromLine === "number"
167
+ ? page.fromLine
168
+ : Number(NUMBERED_LINE.exec(rows[0] as string)?.[1]);
169
+
170
+ // 分页信息 MUST 进 content,不能只放 details:Provider 把 Tool 结果转成 tool_result
171
+ // 时只带 content,details 是给应用和 UI 的(见 pi-ai 的 `convertToolResult`)。
172
+ // 放错地方,模型就看不见下一页在哪,只能把半个文件当成整个文件用。
173
+ const span = `lines ${fromLine}-${toLine} of ${totalLines ?? toLine}`;
174
+ const lossyNote = lossy
175
+ ? " Some lines were longer than 2000 chars and are cut short; " +
176
+ "paging cannot recover them — use grep or bash on this path instead."
177
+ : "";
178
+ const marker = fromLine === 1 && eof && !lossy
179
+ ? ""
180
+ : eof
181
+ ? `\n\n[${span} — end of file.${lossyNote}]`
182
+ : `\n\n[${span} — more remains. ` +
183
+ `Continue with read(path, offset: ${toLine + 1}).${lossyNote}]`;
184
+
185
+ return {
186
+ ...output,
187
+ content: [{ type: "text", text: `${content}${marker}` }],
188
+ details: {
189
+ ...details,
190
+ content,
191
+ fromLine,
192
+ toLine,
193
+ eof,
194
+ ...(eof ? {} : { nextOffset: toLine + 1 }),
195
+ },
196
+ };
197
+ }
75
198
  /**
76
199
  * 把模型给的路径收敛成稳定的闸键。
77
200
  *
@@ -174,7 +297,7 @@ export function workspacePiToolCandidates(
174
297
  );
175
298
  const error = (output.details as { error?: unknown })?.error;
176
299
  if (typeof error === "string") throw new Error(error);
177
- return output;
300
+ return pageReadResult(output);
178
301
  },
179
302
  };
180
303
 
@@ -286,7 +409,16 @@ export function workspacePiToolCandidates(
286
409
  );
287
410
 
288
411
  return [
289
- ...[read, write, edit, list, find, grep, remove].map((tool) => ({
412
+ {
413
+ owner: "workspace",
414
+ requiredExecutionLevel: "safe" as const,
415
+ // read 是大输出外置协议的**出口**:溢出结果的提示语指向它。出口自己再外置,
416
+ // 就会变成 read → 新 artifact → read 的死循环,模型只能靠猜逃出去。
417
+ // 分页上限由 `pageReadResult` 自己保证,不需要外置也不会撑爆一轮。
418
+ outputBudget: { kind: "structure" } as const,
419
+ tool: read,
420
+ },
421
+ ...[write, edit, list, find, grep, remove].map((tool) => ({
290
422
  owner: "workspace",
291
423
  requiredExecutionLevel: "safe" as const,
292
424
  tool,