@springbrand/agent-runtime 0.2.0-alpha.19 → 0.2.0-alpha.21
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 +1 -1
- package/src/adapter/cloudflare/universal-agent/preparation.ts +32 -5
- package/src/db/approval.repo.ts +4 -1
- package/src/index.ts +3 -1
- package/src/kernel/approval-lifecycle.ts +1 -0
- package/src/kernel/bindings.ts +22 -13
- package/src/kernel/receipts.ts +2 -0
- package/src/kernel/tool-surface.ts +41 -0
- package/src/layers/context/budget/gate.ts +57 -11
- package/src/lib/prompt.ts +32 -15
- package/src/pi/message/conversion.ts +1 -13
- package/src/pi/runtime-adapter/execution.ts +44 -5
- package/src/pi/tool/compiler.ts +9 -5
- package/src/pi/tool/core-host.ts +102 -13
- package/src/pi/tool/core.ts +209 -61
- package/src/pi/tool/skill.ts +88 -1
- package/src/pi/tool/workspace-sandbox.ts +136 -4
- package/src/runtime-assembler.ts +32 -8
- package/src/runtime.ts +12 -9
- package/src/skills/index.ts +14 -0
- package/src/skills/springbrand-worker-website/index.ts +73 -0
- package/src/tool-registry.ts +15 -32
package/src/pi/tool/core-host.ts
CHANGED
|
@@ -2,9 +2,12 @@ import {
|
|
|
2
2
|
createWorkspaceStateBackend,
|
|
3
3
|
type WorkspaceFsLike,
|
|
4
4
|
} from "@cloudflare/shell";
|
|
5
|
+
import { createBrowserTools } from "@cloudflare/think/tools/browser";
|
|
5
6
|
import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
|
|
6
7
|
import { jsonSchema, tool as aiTool, type ToolSet } from "ai";
|
|
7
8
|
import type {
|
|
9
|
+
RuntimeBrowserPort,
|
|
10
|
+
RuntimeCodeExecutionPort,
|
|
8
11
|
WorkspacePort,
|
|
9
12
|
} from "../../kernel/bindings";
|
|
10
13
|
import type {
|
|
@@ -16,6 +19,16 @@ import type {
|
|
|
16
19
|
|
|
17
20
|
const CODEMODE_SANDBOX_TIMEOUT_MS = 55_000;
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 宿主的 Browser Rendering 绑定,只取本仓真正用到的那一面。
|
|
24
|
+
*
|
|
25
|
+
* 结构化声明而不是 `Fetcher`:`wrangler types` 现在为这个绑定生成 `BrowserRun` 而不是 `Fetcher`,
|
|
26
|
+
* 两种声明都得能传进来(上游 `BrowserBinding` 出于同样理由也是结构化的)。
|
|
27
|
+
*/
|
|
28
|
+
export interface RuntimeBrowserBinding {
|
|
29
|
+
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
/**
|
|
20
33
|
* 把宿主的 Worker Loader、出站网络和 Workspace 组装成代码执行 Port。
|
|
21
34
|
*
|
|
@@ -37,6 +50,89 @@ function codeExecutionTools(tools: ToolRegistry): ToolSet {
|
|
|
37
50
|
]));
|
|
38
51
|
}
|
|
39
52
|
|
|
53
|
+
// 上游把 Code Mode 类工具交付为 AI SDK tool;本仓只取 description 和 execute 两件,
|
|
54
|
+
// 并在这里就地校验,使装配期缺件立刻失败,而不是等模型调用时才炸。
|
|
55
|
+
function toCodeExecutionPort(
|
|
56
|
+
tool: ToolSet[string],
|
|
57
|
+
name: string,
|
|
58
|
+
): RuntimeCodeExecutionPort {
|
|
59
|
+
const { description, execute } = tool;
|
|
60
|
+
if (typeof description !== "string" || description.length === 0) {
|
|
61
|
+
throw new Error(`Upstream ${name} runtime returned a tool without a description`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof execute !== "function") {
|
|
64
|
+
throw new Error(`Upstream ${name} runtime returned a non-executable tool`);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
description,
|
|
68
|
+
execute: (input) => Promise.resolve(execute(input, {
|
|
69
|
+
toolCallId: name,
|
|
70
|
+
messages: [],
|
|
71
|
+
context: undefined,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 只有这一条挂在 Tool description 上:preview URL 是 per-Session 的,
|
|
77
|
+
// 放进静态 system prompt 前缀会让它每轮都变,破坏 prompt cache。
|
|
78
|
+
// 怎么用 CDP 才不会静默读空,是与 Session 无关的固定动作,住在静态提示词里。
|
|
79
|
+
function previewNote(base: string): string {
|
|
80
|
+
return "Open the current Workspace app with this exact preview URL, unchanged: " +
|
|
81
|
+
`${base} Keep its path and query string intact; do not append a workspace file path. ` +
|
|
82
|
+
"It is the exact URL and Content-Security-Policy (sandbox allow-scripts) the user sees. " +
|
|
83
|
+
"file:// cannot reach the workspace, and there is no other preview host — do not guess one.";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 把宿主的 Durable Object state、Worker Loader 和 Browser Run 绑定组装成浏览器执行 Port。
|
|
88
|
+
*
|
|
89
|
+
* Worker 宿主在具备 DO state 与 Browser 绑定时调用,返回值经 Platform Port 交给 Runtime 工具组装。
|
|
90
|
+
*
|
|
91
|
+
* 形状与 `createWorkspaceCodeExecutionFactory` 同构:宿主提供 DO state 与平台绑定,Runtime 只拿到一个可选装配输入。
|
|
92
|
+
* `create()` 延迟到 Tool Surface 真的要注册时才调,被 deny 的装配不会白建连接器。
|
|
93
|
+
*/
|
|
94
|
+
export function createBrowserExecutionFactory(options: {
|
|
95
|
+
readonly ctx: DurableObjectState;
|
|
96
|
+
readonly loader: WorkerLoader;
|
|
97
|
+
readonly browser: RuntimeBrowserBinding;
|
|
98
|
+
/**
|
|
99
|
+
* 本 Session 的 Workspace preview URL(可以带短期签名 query)。
|
|
100
|
+
*
|
|
101
|
+
* 宿主能确定公开 origin 并签发当前 Session 地址时提供;只有它能给出浏览器真的打得开的地址。
|
|
102
|
+
* 拿不到就不提供 —— 宁可让模型知道自己没有这条信息,也不要给一个猜的、会 404 的地址。
|
|
103
|
+
*/
|
|
104
|
+
readonly previewBaseUrl?: string;
|
|
105
|
+
}): RuntimeBrowserPort {
|
|
106
|
+
return {
|
|
107
|
+
create() {
|
|
108
|
+
const tools = createBrowserTools({
|
|
109
|
+
ctx: options.ctx,
|
|
110
|
+
loader: options.loader,
|
|
111
|
+
browser: options.browser,
|
|
112
|
+
// 沙箱超时沿用 Code Mode 的同一个常量,先于 60s 外层截止结束。
|
|
113
|
+
timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
|
|
114
|
+
// 会话用上游默认的 one-shot:每次执行一个全新浏览器,两次自检互不污染。
|
|
115
|
+
// Quick Action 显式关闭:2026-08-13 复测本地绑定仍不实现 quickAction(),
|
|
116
|
+
// 让上游默认值把那四个工具带回来只会注册出本地必然失败的 Tool(knowledge F22)。
|
|
117
|
+
quickActions: false,
|
|
118
|
+
name: "browser",
|
|
119
|
+
});
|
|
120
|
+
const tool = tools.browser_execute;
|
|
121
|
+
if (!tool) {
|
|
122
|
+
throw new Error("Upstream createBrowserTools did not return browser_execute");
|
|
123
|
+
}
|
|
124
|
+
const port = toCodeExecutionPort(tool, "browser_execute");
|
|
125
|
+
if (!options.previewBaseUrl) return port;
|
|
126
|
+
// 前置而不是追加:上游的 codemode 描述很长,缀在末尾的一句会被读丢 ——
|
|
127
|
+
// 2026-08-13 手动冒烟里模型就没看见它,转而去猜 `file://` 和 `https://workspace.local/`。
|
|
128
|
+
return {
|
|
129
|
+
...port,
|
|
130
|
+
description: `${previewNote(options.previewBaseUrl)}\n\n${port.description}`,
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
40
136
|
export function createWorkspaceCodeExecutionFactory(options: {
|
|
41
137
|
readonly ctx: DurableObjectState;
|
|
42
138
|
readonly loader: WorkerLoader;
|
|
@@ -57,20 +153,13 @@ export function createWorkspaceCodeExecutionFactory(options: {
|
|
|
57
153
|
),
|
|
58
154
|
name: "execute",
|
|
59
155
|
});
|
|
60
|
-
const
|
|
61
|
-
if (typeof description !== "string" || description.length === 0) {
|
|
62
|
-
throw new Error("Think createExecuteRuntime returned a tool without a description");
|
|
63
|
-
}
|
|
64
|
-
if (typeof execute !== "function") {
|
|
65
|
-
throw new Error("Think createExecuteRuntime returned a non-executable tool");
|
|
66
|
-
}
|
|
156
|
+
const port = toCodeExecutionPort(tool, "execute");
|
|
67
157
|
return {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
})),
|
|
158
|
+
...port,
|
|
159
|
+
description: port.description.replace(
|
|
160
|
+
"- Do not use `fetch` — use connector SDKs.",
|
|
161
|
+
"- Raw `fetch` is available inside `codemode.step(...)`; prefer connector SDKs when one owns the target.",
|
|
162
|
+
),
|
|
74
163
|
};
|
|
75
164
|
},
|
|
76
165
|
};
|
package/src/pi/tool/core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/pi/tool/skill.ts
CHANGED
|
@@ -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) =>
|
|
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
|
});
|