@springbrand/agent-runtime 0.1.0
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 +28 -0
- package/src/db/approval.repo.ts +291 -0
- package/src/db/ext-context.repo.ts +34 -0
- package/src/db/index.ts +83 -0
- package/src/db/message-ui.repo.ts +39 -0
- package/src/db/milestone.repo.ts +96 -0
- package/src/db/runtime-event-outbox.repo.ts +89 -0
- package/src/db/schema.ts +164 -0
- package/src/db/settlement.repo.ts +104 -0
- package/src/db/steer.repo.ts +73 -0
- package/src/db/submission.repo.ts +323 -0
- package/src/index.ts +133 -0
- package/src/kernel/approval-lifecycle.ts +552 -0
- package/src/kernel/bindings.ts +898 -0
- package/src/kernel/degradation.ts +15 -0
- package/src/kernel/extensions.ts +108 -0
- package/src/kernel/profile.ts +116 -0
- package/src/kernel/public-contracts.ts +17 -0
- package/src/kernel/receipts.ts +124 -0
- package/src/kernel/recoverable-chat-agent.ts +899 -0
- package/src/kernel/state.ts +76 -0
- package/src/kernel/submission-lifecycle.ts +600 -0
- package/src/layers/context/budget/gate.ts +88 -0
- package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
- package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
- package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
- package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
- package/src/layers/orchestration/temporary-agent/core.ts +152 -0
- package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
- package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
- package/src/lib/artifacts.ts +54 -0
- package/src/lib/egress.ts +44 -0
- package/src/lib/execution-level.ts +27 -0
- package/src/lib/extension-name.ts +18 -0
- package/src/lib/host-actions.ts +57 -0
- package/src/lib/mcp.ts +86 -0
- package/src/lib/model-catalog.ts +7 -0
- package/src/lib/prompt.ts +139 -0
- package/src/lib/telemetry-dev.ts +44 -0
- package/src/pi/assembly/context.ts +510 -0
- package/src/pi/assembly/extensions.ts +661 -0
- package/src/pi/assembly/index.ts +19 -0
- package/src/pi/assembly/snapshot.ts +200 -0
- package/src/pi/message/contract.ts +8 -0
- package/src/pi/message/conversion.ts +73 -0
- package/src/pi/message/index.ts +3 -0
- package/src/pi/message/projection.ts +604 -0
- package/src/pi/runtime-adapter/assembly.ts +552 -0
- package/src/pi/runtime-adapter/execution.ts +683 -0
- package/src/pi/runtime-adapter/index.ts +232 -0
- package/src/pi/runtime-adapter/models.ts +243 -0
- package/src/pi/runtime-adapter/recovery.ts +805 -0
- package/src/pi/runtime-adapter/transcript.ts +825 -0
- package/src/pi/session/index.ts +24 -0
- package/src/pi/session/storage.ts +353 -0
- package/src/pi/tool/ai-adapter.ts +100 -0
- package/src/pi/tool/base.ts +110 -0
- package/src/pi/tool/compiler.ts +444 -0
- package/src/pi/tool/core-host.ts +48 -0
- package/src/pi/tool/core.ts +251 -0
- package/src/pi/tool/index.ts +32 -0
- package/src/pi/tool/mcp.ts +319 -0
- package/src/pi/tool/schedule.ts +198 -0
- package/src/pi/tool/skill.ts +455 -0
- package/src/pi/tool/subagent.ts +148 -0
- package/src/pi/tool/web-search/api.ts +1292 -0
- package/src/pi/tool/web-search/index.ts +2 -0
- package/src/pi/tool/web-search/web-search.ts +127 -0
- package/src/pi/tool/workspace-sandbox.ts +664 -0
- package/src/pi/turn/approval.ts +181 -0
- package/src/pi/turn/index.ts +62 -0
- package/src/pi/turn/tool-recovery.ts +792 -0
- package/src/plugins.ts +1024 -0
- package/src/runtime-agent.ts +654 -0
- package/src/runtime.ts +2880 -0
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createWorkspaceStateBackend,
|
|
3
|
+
type FileSystemStateBackend,
|
|
4
|
+
type WorkspaceFsLike,
|
|
5
|
+
} from "@cloudflare/shell";
|
|
6
|
+
import {
|
|
7
|
+
createDeleteTool,
|
|
8
|
+
createFindTool,
|
|
9
|
+
createListTool,
|
|
10
|
+
} from "@cloudflare/think/tools/workspace";
|
|
11
|
+
import type {
|
|
12
|
+
AgentTool,
|
|
13
|
+
AgentToolResult,
|
|
14
|
+
AgentToolUpdateCallback,
|
|
15
|
+
} from "@earendil-works/pi-agent-core";
|
|
16
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
17
|
+
import type {
|
|
18
|
+
RuntimeSandboxPort,
|
|
19
|
+
WorkspacePort,
|
|
20
|
+
} from "../../kernel/bindings";
|
|
21
|
+
import { serializeOutput } from "../../lib/artifacts";
|
|
22
|
+
import { aiToolToPi } from "./ai-adapter";
|
|
23
|
+
import type { PiToolCandidate } from "./compiler";
|
|
24
|
+
|
|
25
|
+
// #region Shared Pi result helpers
|
|
26
|
+
|
|
27
|
+
// 作用:把 Workspace 或 Sandbox 的结构化详情包装成 Pi 工具结果。
|
|
28
|
+
// 调用:本文件所有自定义 execute 方法在成功后调用。
|
|
29
|
+
// 原因:序列化文本供模型阅读,
|
|
30
|
+
// 原始 details 供事件投影和统一输出预算处理,
|
|
31
|
+
// 避免各工具自造格式。
|
|
32
|
+
function result(details: unknown): AgentToolResult<unknown> {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: "text", text: serializeOutput(details).text }],
|
|
35
|
+
details,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 作用:向 Pi 报告一次 Workspace 或 Sandbox 操作已经开始。
|
|
40
|
+
// 调用:可能访问存储或容器的 execute 方法在进入 Port 前调用。
|
|
41
|
+
// 原因:复用统一的 running 结果形状,
|
|
42
|
+
// 让长操作有进度反馈而不复制回调协议。
|
|
43
|
+
function running(
|
|
44
|
+
onUpdate: AgentToolUpdateCallback<unknown> | undefined,
|
|
45
|
+
text: string,
|
|
46
|
+
): void {
|
|
47
|
+
onUpdate?.({
|
|
48
|
+
content: [{ type: "text", text }],
|
|
49
|
+
details: { status: "running" },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// #endregion
|
|
54
|
+
|
|
55
|
+
// #region Workspace tools
|
|
56
|
+
|
|
57
|
+
const workspaceWriteParameters = Type.Object({
|
|
58
|
+
path: Type.String({
|
|
59
|
+
minLength: 1,
|
|
60
|
+
maxLength: 4_096,
|
|
61
|
+
description: "Absolute Workspace path",
|
|
62
|
+
}),
|
|
63
|
+
content: Type.String({ description: "Content to write" }),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const workspaceReadParameters = Type.Object({
|
|
67
|
+
path: Type.String({
|
|
68
|
+
minLength: 1,
|
|
69
|
+
maxLength: 4_096,
|
|
70
|
+
description: "Absolute Workspace path",
|
|
71
|
+
}),
|
|
72
|
+
offset: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
73
|
+
limit: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
74
|
+
});
|
|
75
|
+
const workspaceEditParameters = Type.Object({
|
|
76
|
+
path: Type.String({ minLength: 1, maxLength: 4_096 }),
|
|
77
|
+
old_string: Type.String(),
|
|
78
|
+
new_string: Type.String(),
|
|
79
|
+
});
|
|
80
|
+
const workspaceGrepParameters = Type.Object({
|
|
81
|
+
query: Type.String(),
|
|
82
|
+
include: Type.Optional(Type.String({ minLength: 1, maxLength: 4_096 })),
|
|
83
|
+
fixedString: Type.Optional(Type.Boolean()),
|
|
84
|
+
caseSensitive: Type.Optional(Type.Boolean()),
|
|
85
|
+
contextLines: Type.Optional(Type.Integer({ minimum: 0, maximum: 10 })),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const MAX_READ_LINES = 2_000;
|
|
89
|
+
const MAX_READ_LINE_CHARS = 2_000;
|
|
90
|
+
const MAX_GREP_MATCHES = 200;
|
|
91
|
+
|
|
92
|
+
// 作用:计算一个非空字符串在文本中出现了多少次。
|
|
93
|
+
// 调用:edit 在精确替换前调用,空 old_string 已由创建文件分支提前处理。
|
|
94
|
+
// 原因:只允许唯一命中可避免模型给出短片段时误改多个位置。
|
|
95
|
+
function countOccurrences(text: string, search: string): number {
|
|
96
|
+
let count = 0;
|
|
97
|
+
let position = 0;
|
|
98
|
+
while (true) {
|
|
99
|
+
const index = text.indexOf(search, position);
|
|
100
|
+
if (index === -1) return count;
|
|
101
|
+
count += 1;
|
|
102
|
+
position = index + 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 把模型给的路径收敛成稳定的闸键。
|
|
108
|
+
*
|
|
109
|
+
* 归一化的写法与 Host 侧 ScopedWorkspace 的路径归一化保持一致(去空白、反斜杠转正斜杠、
|
|
110
|
+
* 折叠重复分隔符、补前导斜杠):同一个文件的不同拼法(`/a.md`、`a.md`、`//a.md`)必须
|
|
111
|
+
* 落到同一个键,否则闸等于没上。
|
|
112
|
+
*
|
|
113
|
+
* 这里刻意不解析 `.` / `..`:带 traversal 的路径会被 WorkspacePort 直接拒掉,永远走不到
|
|
114
|
+
* 写入,替它编一个等价键反而是在宣称一个不会发生的等价关系。
|
|
115
|
+
*/
|
|
116
|
+
function mutationKey(path: string): string {
|
|
117
|
+
const parts = path
|
|
118
|
+
.trim()
|
|
119
|
+
.replaceAll("\\", "/")
|
|
120
|
+
.split("/")
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
return `/${parts.join("/")}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 同一路径上的文件变更串行闸。
|
|
127
|
+
*
|
|
128
|
+
* pi 的 `toolExecution` 默认就是 "parallel"(pi-agent-core 的 Agent 构造里
|
|
129
|
+
* `options.toolExecution ?? "parallel"`),同一批 tool_call 是真并发跑的。而 edit 是
|
|
130
|
+
* 「读 → 比对 → 写」三步:两个打同一文件的 edit 并发时会各自读到同一份旧内容,后落盘的
|
|
131
|
+
* 那个把前一个的改动整段盖掉 —— 两次调用都回成功,丢写完全静默,用户看不见。
|
|
132
|
+
*
|
|
133
|
+
* 所以闸必须包住整个读-改-写窗口(只包最后那次 writeFile 等于没包)。write 也要进同一条
|
|
134
|
+
* 队列:它本身没有读窗口,但它可以落在别人的窗口中间,把那次 edit 的基准内容换掉。
|
|
135
|
+
*
|
|
136
|
+
* 队列挂在 WorkspacePort 实例上,而不是某一次工具装配上 —— 同一个 Workspace 可能被装配成
|
|
137
|
+
* 多份候选集(每轮 prepare 各一份),它们指向同一批文件,必须共用同一条队列。WeakMap 让
|
|
138
|
+
* Workspace 一旦被回收,队列跟着消失。
|
|
139
|
+
*/
|
|
140
|
+
const mutationQueues = new WeakMap<
|
|
141
|
+
WorkspacePort,
|
|
142
|
+
Map<string, Promise<void>>
|
|
143
|
+
>();
|
|
144
|
+
|
|
145
|
+
function serializeByPath<T>(
|
|
146
|
+
workspace: WorkspacePort,
|
|
147
|
+
path: string,
|
|
148
|
+
run: () => Promise<T>,
|
|
149
|
+
): Promise<T> {
|
|
150
|
+
const queue =
|
|
151
|
+
mutationQueues.get(workspace) ?? new Map<string, Promise<void>>();
|
|
152
|
+
mutationQueues.set(workspace, queue);
|
|
153
|
+
|
|
154
|
+
const key = mutationKey(path);
|
|
155
|
+
// 队尾已经吞掉了失败,只用来保序:前一个 edit 报错不该把后面排队的一起拖死。
|
|
156
|
+
const tail = queue.get(key) ?? Promise.resolve();
|
|
157
|
+
const result = tail.then(run);
|
|
158
|
+
const next = result.then(
|
|
159
|
+
() => {},
|
|
160
|
+
() => {},
|
|
161
|
+
);
|
|
162
|
+
queue.set(key, next);
|
|
163
|
+
// 自己仍是队尾时摘掉键,避免长会话里 Map 无界增长。
|
|
164
|
+
void next.then(() => {
|
|
165
|
+
if (queue.get(key) === next) queue.delete(key);
|
|
166
|
+
});
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 为一个已经限定作用域的 Workspace 创建 Pi 文件工具。
|
|
172
|
+
*
|
|
173
|
+
* Worker 的 Workspace Plugin 在工作区加载完成后调用它;SubAgent 工具装配也会
|
|
174
|
+
* 复用同一工厂并筛选所需能力。调用方必须传入当前会话的 WorkspacePort,不要
|
|
175
|
+
* 直接传底层存储,因为路径隔离、符号链接、配额和共享文件写保护都由 Port 负责。
|
|
176
|
+
*
|
|
177
|
+
* 本文件中 “Workspace” 指会话作用域的持久文件视图,“Port” 指 Runtime 只依赖
|
|
178
|
+
* 的窄文件接口,“候选工具”指进入 Pi 编译和统一治理前的描述。read、write、
|
|
179
|
+
* edit、grep 在这里保留现有结果契约;list、find、delete 复用 @cloudflare/think
|
|
180
|
+
* 的上游工厂,避免维护第二套同义文件操作。
|
|
181
|
+
*
|
|
182
|
+
* `Pi`、`Port` 与“候选工具”等项目核心术语见 `../../index.ts`。
|
|
183
|
+
*/
|
|
184
|
+
export function workspacePiToolCandidates(
|
|
185
|
+
workspace: WorkspacePort,
|
|
186
|
+
): PiToolCandidate[] {
|
|
187
|
+
let backend: FileSystemStateBackend | undefined;
|
|
188
|
+
// 作用:按需创建并复用 @cloudflare/shell 的 Workspace 搜索后端。
|
|
189
|
+
// 调用:grep execute 第一次搜索及后续搜索时调用。
|
|
190
|
+
// 原因:只有 grep 需要该适配器,
|
|
191
|
+
// 延迟创建可避免普通读写 Turn 支付无用初始化成本。
|
|
192
|
+
const searchBackend = (): FileSystemStateBackend => {
|
|
193
|
+
backend ??= createWorkspaceStateBackend(
|
|
194
|
+
workspace as unknown as WorkspaceFsLike,
|
|
195
|
+
);
|
|
196
|
+
return backend;
|
|
197
|
+
};
|
|
198
|
+
const read: AgentTool<typeof workspaceReadParameters> = {
|
|
199
|
+
name: "read",
|
|
200
|
+
label: "Read file",
|
|
201
|
+
description:
|
|
202
|
+
"Read a Workspace text file with line numbers. Use offset and limit for large files.",
|
|
203
|
+
parameters: workspaceReadParameters,
|
|
204
|
+
// 作用:分段读取一个 Workspace 文本文件,并返回带行号的内容。
|
|
205
|
+
// 调用:Pi 需要查看文件或为后续精确编辑取上下文时调用。
|
|
206
|
+
// 原因:先核对文件类型,再限制行数和单行长度,
|
|
207
|
+
// 避免一次结果挤满模型上下文。
|
|
208
|
+
async execute(_toolCallId, { path, offset, limit }, signal, onUpdate) {
|
|
209
|
+
signal?.throwIfAborted();
|
|
210
|
+
running(onUpdate, "Reading the Workspace file.");
|
|
211
|
+
const info = await workspace.stat(path);
|
|
212
|
+
if (!info) throw new Error(`File not found: ${path}`);
|
|
213
|
+
if (info.type !== "file") {
|
|
214
|
+
throw new Error(`${path} is not a file`);
|
|
215
|
+
}
|
|
216
|
+
const content = await workspace.readFile(path);
|
|
217
|
+
if (content === null) {
|
|
218
|
+
throw new Error(`Could not read file: ${path}`);
|
|
219
|
+
}
|
|
220
|
+
const lines = content.split("\n");
|
|
221
|
+
const start = (offset ?? 1) - 1;
|
|
222
|
+
const requestedEnd = limit === undefined ? lines.length : start + limit;
|
|
223
|
+
const end = Math.min(requestedEnd, start + MAX_READ_LINES);
|
|
224
|
+
const numbered = lines.slice(start, end).map((line, index) => {
|
|
225
|
+
const visible =
|
|
226
|
+
line.length > MAX_READ_LINE_CHARS
|
|
227
|
+
? `${line.slice(0, MAX_READ_LINE_CHARS)}... (truncated)`
|
|
228
|
+
: line;
|
|
229
|
+
return `${start + index + 1}\t${visible}`;
|
|
230
|
+
});
|
|
231
|
+
return result({
|
|
232
|
+
path,
|
|
233
|
+
content: numbered.join("\n"),
|
|
234
|
+
totalLines: lines.length,
|
|
235
|
+
fromLine: start + 1,
|
|
236
|
+
toLine: Math.min(end, lines.length),
|
|
237
|
+
...(requestedEnd > end ? { truncated: true } : {}),
|
|
238
|
+
});
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const write: AgentTool<typeof workspaceWriteParameters> = {
|
|
243
|
+
name: "write",
|
|
244
|
+
label: "Write file",
|
|
245
|
+
description:
|
|
246
|
+
"Write content to a file in this Chat's scoped Workspace. Parent directories are created automatically.",
|
|
247
|
+
parameters: workspaceWriteParameters,
|
|
248
|
+
// 作用:把完整文本写入 Workspace 文件,并按需创建父目录。
|
|
249
|
+
// 调用:Pi 明确要创建或覆盖文件时调用。
|
|
250
|
+
// 原因:目录创建和写入都经同一个 WorkspacePort,
|
|
251
|
+
// 不能绕过会话路径与配额策略。
|
|
252
|
+
async execute(_toolCallId, { path, content }, signal, onUpdate) {
|
|
253
|
+
signal?.throwIfAborted();
|
|
254
|
+
running(onUpdate, "Writing the Workspace file.");
|
|
255
|
+
|
|
256
|
+
return serializeByPath(workspace, path, async () => {
|
|
257
|
+
// 排队期间可能已经被取消,拿到闸之后必须重新确认,否则会写一个已放弃的结果。
|
|
258
|
+
signal?.throwIfAborted();
|
|
259
|
+
const separator = path.lastIndexOf("/");
|
|
260
|
+
const parent = separator > 0 ? path.slice(0, separator) : "";
|
|
261
|
+
if (parent && parent !== "/") {
|
|
262
|
+
await workspace.mkdir(parent, { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
await workspace.writeFile(path, content);
|
|
265
|
+
|
|
266
|
+
return result({
|
|
267
|
+
path,
|
|
268
|
+
bytesWritten: new TextEncoder().encode(content).byteLength,
|
|
269
|
+
lines: content.split("\n").length,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const edit: AgentTool<typeof workspaceEditParameters> = {
|
|
276
|
+
name: "edit",
|
|
277
|
+
label: "Edit file",
|
|
278
|
+
description:
|
|
279
|
+
"Replace one exact string in a Workspace file. An empty old_string creates a new file.",
|
|
280
|
+
parameters: workspaceEditParameters,
|
|
281
|
+
// 作用:唯一命中时精确替换文本,或用空 old_string 创建新文件。
|
|
282
|
+
// 调用:Pi 已读取文件并能提供足够上下文时调用。
|
|
283
|
+
// 原因:零命中和多命中都拒绝写入,
|
|
284
|
+
// 防止过短片段静默改错位置。
|
|
285
|
+
async execute(
|
|
286
|
+
_toolCallId,
|
|
287
|
+
{ path, old_string, new_string },
|
|
288
|
+
signal,
|
|
289
|
+
onUpdate,
|
|
290
|
+
) {
|
|
291
|
+
signal?.throwIfAborted();
|
|
292
|
+
running(onUpdate, "Editing the Workspace file.");
|
|
293
|
+
// 读-比对-写整段都在闸内:闸只包住最后那次 writeFile 的话,基准内容仍然可能在
|
|
294
|
+
// 比对之后被别的调用换掉,丢写照旧发生。
|
|
295
|
+
return serializeByPath(workspace, path, async () => {
|
|
296
|
+
signal?.throwIfAborted();
|
|
297
|
+
const content = await workspace.readFile(path);
|
|
298
|
+
if (old_string === "") {
|
|
299
|
+
if (content !== null) {
|
|
300
|
+
throw new Error(
|
|
301
|
+
"File already exists. Provide old_string to edit, or use write to overwrite.",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
await workspace.writeFile(path, new_string);
|
|
305
|
+
return result({
|
|
306
|
+
path,
|
|
307
|
+
created: true,
|
|
308
|
+
lines: new_string.split("\n").length,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
if (content === null) {
|
|
312
|
+
throw new Error(`File not found: ${path}`);
|
|
313
|
+
}
|
|
314
|
+
const occurrences = countOccurrences(content, old_string);
|
|
315
|
+
if (occurrences === 0) {
|
|
316
|
+
throw new Error(
|
|
317
|
+
"old_string not found in file. Read the file first and match whitespace exactly.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (occurrences > 1) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`old_string appears ${occurrences} times. Include more surrounding context.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
const next = content.replace(old_string, new_string);
|
|
326
|
+
await workspace.writeFile(path, next);
|
|
327
|
+
return result({
|
|
328
|
+
path,
|
|
329
|
+
replaced: true,
|
|
330
|
+
lines: next.split("\n").length,
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// list / find / delete come from the upstream think factories: WorkspacePort
|
|
337
|
+
// already satisfies their ops interfaces structurally, and their output shapes
|
|
338
|
+
// match what this file used to build by hand.
|
|
339
|
+
const list = aiToolToPi("list", createListTool({ ops: workspace }), {
|
|
340
|
+
label: "List files",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const find = aiToolToPi("find", createFindTool({ ops: workspace }), {
|
|
344
|
+
label: "Find files",
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const grep: AgentTool<typeof workspaceGrepParameters> = {
|
|
348
|
+
name: "grep",
|
|
349
|
+
label: "Search files",
|
|
350
|
+
description:
|
|
351
|
+
"Search bounded Workspace file contents with a regex or fixed string.",
|
|
352
|
+
parameters: workspaceGrepParameters,
|
|
353
|
+
// 作用:在匹配 glob 的 Workspace 文件中做有上限的文本搜索。
|
|
354
|
+
// 调用:Pi 需要定位定义、调用方或内容片段时调用。
|
|
355
|
+
// 原因:搜索交给 @cloudflare/shell 后端并限制命中数;
|
|
356
|
+
// 正则先在本地检查以保留既有错误契约。
|
|
357
|
+
async execute(
|
|
358
|
+
_toolCallId,
|
|
359
|
+
{
|
|
360
|
+
query,
|
|
361
|
+
include = "**/*",
|
|
362
|
+
fixedString = false,
|
|
363
|
+
caseSensitive = false,
|
|
364
|
+
contextLines = 0,
|
|
365
|
+
},
|
|
366
|
+
signal,
|
|
367
|
+
onUpdate,
|
|
368
|
+
) {
|
|
369
|
+
signal?.throwIfAborted();
|
|
370
|
+
running(onUpdate, "Searching Workspace files.");
|
|
371
|
+
// Reject bad patterns before reaching the backend so the error text stays
|
|
372
|
+
// identical to the previous in-isolate implementation.
|
|
373
|
+
if (!fixedString) {
|
|
374
|
+
try {
|
|
375
|
+
new RegExp(query);
|
|
376
|
+
} catch {
|
|
377
|
+
throw new Error(`Invalid regex: ${query}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// TODO(待确认):@cloudflare/shell@0.4.3 的 searchFiles 会先 glob,再逐个
|
|
382
|
+
// 调 readFile;WorkspacePort 跨 DO 时,内容可能仍会经 RPC 回到当前 isolate。
|
|
383
|
+
const hits = await searchBackend().searchFiles(include, query, {
|
|
384
|
+
regex: !fixedString,
|
|
385
|
+
caseSensitive,
|
|
386
|
+
maxMatches: MAX_GREP_MATCHES,
|
|
387
|
+
...(contextLines > 0
|
|
388
|
+
? { contextBefore: contextLines, contextAfter: contextLines }
|
|
389
|
+
: {}),
|
|
390
|
+
});
|
|
391
|
+
signal?.throwIfAborted();
|
|
392
|
+
|
|
393
|
+
const matches: Array<string | {
|
|
394
|
+
file: string;
|
|
395
|
+
line: number;
|
|
396
|
+
context: string;
|
|
397
|
+
}> = [];
|
|
398
|
+
let filesWithMatches = 0;
|
|
399
|
+
|
|
400
|
+
for (const hit of hits) {
|
|
401
|
+
if (matches.length >= MAX_GREP_MATCHES) break;
|
|
402
|
+
if (hit.matches.length > 0) filesWithMatches += 1;
|
|
403
|
+
for (const match of hit.matches) {
|
|
404
|
+
if (matches.length >= MAX_GREP_MATCHES) break;
|
|
405
|
+
if (contextLines === 0) {
|
|
406
|
+
matches.push(`${hit.path}:${match.line}: ${match.lineText}`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
const before = match.beforeLines ?? [];
|
|
410
|
+
const after = match.afterLines ?? [];
|
|
411
|
+
const firstLine = match.line - before.length;
|
|
412
|
+
const context = [...before, match.lineText, ...after]
|
|
413
|
+
.map(
|
|
414
|
+
(line, offset) =>
|
|
415
|
+
`${firstLine + offset === match.line ? ">" : " "} ${
|
|
416
|
+
firstLine + offset
|
|
417
|
+
}\t${line}`,
|
|
418
|
+
)
|
|
419
|
+
.join("\n");
|
|
420
|
+
matches.push({ file: hit.path, line: match.line, context });
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return result({
|
|
425
|
+
query,
|
|
426
|
+
filesSearched: hits.length,
|
|
427
|
+
filesWithMatches,
|
|
428
|
+
totalMatches: matches.length,
|
|
429
|
+
matches,
|
|
430
|
+
...(matches.length >= MAX_GREP_MATCHES
|
|
431
|
+
? { truncated: true }
|
|
432
|
+
: {}),
|
|
433
|
+
});
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// delete 来自上游 think 工厂,只能在外面包一层闸。它自己没有读-改-写窗口,但必须与
|
|
438
|
+
// edit 的窗口互斥:否则 edit 读到内容 → delete 删掉文件 → edit 落盘,已删的文件会被
|
|
439
|
+
// 静默复活。上闸后两种顺序都有明确回执(先删则 edit 报 File not found)。
|
|
440
|
+
// 残留缺口:recursive 删目录影响的是一整棵子树,单路径键覆盖不到子树内的文件。
|
|
441
|
+
const removeTool = aiToolToPi(
|
|
442
|
+
"delete",
|
|
443
|
+
createDeleteTool({ ops: workspace }),
|
|
444
|
+
{ label: "Delete file" },
|
|
445
|
+
);
|
|
446
|
+
const remove: AgentTool<any, unknown> = {
|
|
447
|
+
...removeTool,
|
|
448
|
+
async execute(toolCallId, params, signal, onUpdate) {
|
|
449
|
+
const path = (params as { path?: unknown }).path;
|
|
450
|
+
if (typeof path !== "string") {
|
|
451
|
+
return removeTool.execute(toolCallId, params, signal, onUpdate);
|
|
452
|
+
}
|
|
453
|
+
return serializeByPath(workspace, path, () =>
|
|
454
|
+
removeTool.execute(toolCallId, params, signal, onUpdate),
|
|
455
|
+
);
|
|
456
|
+
},
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
return [read, write, edit, list, find, grep, remove].map((tool) => ({
|
|
460
|
+
owner: "workspace",
|
|
461
|
+
authorized: true,
|
|
462
|
+
requiredExecutionLevel: "safe" as const,
|
|
463
|
+
tool,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// #endregion
|
|
468
|
+
|
|
469
|
+
// #region Sandbox tools
|
|
470
|
+
|
|
471
|
+
const sandboxCommandParameters = {
|
|
472
|
+
command: Type.String({ minLength: 1, maxLength: 32_768 }),
|
|
473
|
+
cwd: Type.Optional(Type.String({ maxLength: 4_096 })),
|
|
474
|
+
stdin: Type.Optional(Type.String({ maxLength: 65_536 })),
|
|
475
|
+
};
|
|
476
|
+
const sandboxExecParameters = Type.Object({
|
|
477
|
+
...sandboxCommandParameters,
|
|
478
|
+
timeoutMs: Type.Optional(
|
|
479
|
+
Type.Integer({ minimum: 1, maximum: 60_000 }),
|
|
480
|
+
),
|
|
481
|
+
});
|
|
482
|
+
const sandboxStartParameters = Type.Object(sandboxCommandParameters);
|
|
483
|
+
const sandboxProcessParameters = Type.Object({
|
|
484
|
+
id: Type.String({ minLength: 1, maxLength: 128 }),
|
|
485
|
+
});
|
|
486
|
+
const sandboxPublishParameters = Type.Object({
|
|
487
|
+
paths: Type.Array(Type.String({ minLength: 1, maxLength: 4_096 }), {
|
|
488
|
+
minItems: 1,
|
|
489
|
+
maxItems: 100,
|
|
490
|
+
}),
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* 为现有 RuntimeSandboxPort 创建 Pi 命令、进程和文件发布工具。
|
|
495
|
+
*
|
|
496
|
+
* Worker 的 Sandbox Plugin 在容器能力和持久 Workspace 都可用时调用它。调用方
|
|
497
|
+
* 应传入已绑定当前会话的 Port;前台命令适合一次性执行,后台进程适合需要持续
|
|
498
|
+
* 运行的服务,二者不能互换结果语义。
|
|
499
|
+
*
|
|
500
|
+
* 这里的 “Sandbox” 指 Cloudflare Sandbox SDK 提供的隔离 Linux 容器;本层只
|
|
501
|
+
* 面向项目自己的 Port。命令限制、cwd 校验、工作区装载、超时、取消、清理和
|
|
502
|
+
* 冲突安全发布仍在 Port 内。容器文件不会自动写回 Workspace,只有 publish
|
|
503
|
+
* 工具显式选中的文件才进入持久视图;执行、启动、停止和发布继续走高风险审批。
|
|
504
|
+
*
|
|
505
|
+
* `Pi`、`Port` 与“候选工具”等项目核心术语见 `../../index.ts`。
|
|
506
|
+
*/
|
|
507
|
+
export function sandboxPiToolCandidates(
|
|
508
|
+
sandbox: RuntimeSandboxPort,
|
|
509
|
+
): PiToolCandidate[] {
|
|
510
|
+
const exec: AgentTool<typeof sandboxExecParameters> = {
|
|
511
|
+
name: "sandbox_exec",
|
|
512
|
+
label: "Run command",
|
|
513
|
+
description:
|
|
514
|
+
"Run a foreground command in this Chat's isolated Linux Sandbox. Files stay temporary until sandbox_publish_files is called.",
|
|
515
|
+
parameters: sandboxExecParameters,
|
|
516
|
+
// 作用:在 Sandbox 中运行一次前台命令并等待完整退出结果。
|
|
517
|
+
// 调用:Pi 执行构建、测试或其他会结束的命令时调用。
|
|
518
|
+
// 原因:输入和 AbortSignal 原样交给 Port,返回值只投影公开字段,
|
|
519
|
+
// 避免容器内部句柄进入对话。
|
|
520
|
+
async execute(_toolCallId, input, signal, onUpdate) {
|
|
521
|
+
signal?.throwIfAborted();
|
|
522
|
+
running(onUpdate, "Running the Sandbox command.");
|
|
523
|
+
const output = await sandbox.exec(input, signal);
|
|
524
|
+
return result({
|
|
525
|
+
success: output.success,
|
|
526
|
+
stdout: output.stdout,
|
|
527
|
+
stderr: output.stderr,
|
|
528
|
+
exitCode: output.exitCode,
|
|
529
|
+
stdoutTruncated: output.stdoutTruncated,
|
|
530
|
+
stderrTruncated: output.stderrTruncated,
|
|
531
|
+
workspacePersistence: "explicit_publish_required",
|
|
532
|
+
});
|
|
533
|
+
},
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const startProcess: AgentTool<typeof sandboxStartParameters> = {
|
|
537
|
+
name: "sandbox_start_process",
|
|
538
|
+
label: "Start process",
|
|
539
|
+
description:
|
|
540
|
+
"Start a long-running background process in this Chat's isolated Linux Sandbox.",
|
|
541
|
+
parameters: sandboxStartParameters,
|
|
542
|
+
// 作用:在 Sandbox 中启动一个长期运行的后台进程并返回进程标识。
|
|
543
|
+
// 调用:Pi 启动开发服务器、监听器或其他不能等待退出的程序时调用。
|
|
544
|
+
// 原因:Cloudflare 官方接口把长期进程与 exec 分开;
|
|
545
|
+
// 这里只公开后续查日志和停止所需字段。
|
|
546
|
+
async execute(_toolCallId, input, signal, onUpdate) {
|
|
547
|
+
signal?.throwIfAborted();
|
|
548
|
+
running(onUpdate, "Starting the Sandbox process.");
|
|
549
|
+
const process = await sandbox.startProcess(input);
|
|
550
|
+
return result({
|
|
551
|
+
id: process.id,
|
|
552
|
+
command: process.command,
|
|
553
|
+
status: process.status,
|
|
554
|
+
...(process.exitCode === undefined
|
|
555
|
+
? {}
|
|
556
|
+
: { exitCode: process.exitCode }),
|
|
557
|
+
workspacePersistence: "explicit_publish_required",
|
|
558
|
+
});
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const processLogs: AgentTool<typeof sandboxProcessParameters> = {
|
|
563
|
+
name: "sandbox_process_logs",
|
|
564
|
+
label: "Read process logs",
|
|
565
|
+
description:
|
|
566
|
+
"Read bounded stdout and stderr logs for one background Sandbox process.",
|
|
567
|
+
parameters: sandboxProcessParameters,
|
|
568
|
+
// 作用:读取一个 Sandbox 后台进程当前的有界日志。
|
|
569
|
+
// 调用:Pi 拿到 startProcess 返回的 id 后检查启动或运行状态时调用。
|
|
570
|
+
// 原因:日志截断由 Port 统一处理并显式回传标记,
|
|
571
|
+
// 避免适配层再次缓存不断增长的输出。
|
|
572
|
+
async execute(_toolCallId, { id }, signal, onUpdate) {
|
|
573
|
+
signal?.throwIfAborted();
|
|
574
|
+
running(onUpdate, "Reading the Sandbox process logs.");
|
|
575
|
+
const logs = await sandbox.getProcessLogs(id);
|
|
576
|
+
return result({
|
|
577
|
+
id: logs.id,
|
|
578
|
+
stdout: logs.stdout,
|
|
579
|
+
stderr: logs.stderr,
|
|
580
|
+
stdoutTruncated: logs.stdoutTruncated,
|
|
581
|
+
stderrTruncated: logs.stderrTruncated,
|
|
582
|
+
});
|
|
583
|
+
},
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const stopProcess: AgentTool<typeof sandboxProcessParameters> = {
|
|
587
|
+
name: "sandbox_stop_process",
|
|
588
|
+
label: "Stop process",
|
|
589
|
+
description: "Stop one background Sandbox process.",
|
|
590
|
+
parameters: sandboxProcessParameters,
|
|
591
|
+
// 作用:停止一个由当前 Sandbox 管理的后台进程。
|
|
592
|
+
// 调用:Pi 不再需要该进程或需要清理运行环境时调用。
|
|
593
|
+
// 原因:只把进程 id 交给 Port,
|
|
594
|
+
// 进程归属检查和实际清理由 Sandbox 边界统一负责。
|
|
595
|
+
async execute(_toolCallId, { id }, signal, onUpdate) {
|
|
596
|
+
signal?.throwIfAborted();
|
|
597
|
+
running(onUpdate, "Stopping the Sandbox process.");
|
|
598
|
+
const output = await sandbox.stopProcess(id);
|
|
599
|
+
return result({ stopped: output.stopped });
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
const publishFiles: AgentTool<typeof sandboxPublishParameters> = {
|
|
604
|
+
name: "sandbox_publish_files",
|
|
605
|
+
label: "Publish files",
|
|
606
|
+
description:
|
|
607
|
+
"Publish named files from the temporary Sandbox into the scoped Workspace. Shared files remain read-only.",
|
|
608
|
+
parameters: sandboxPublishParameters,
|
|
609
|
+
// 作用:把明确列出的 Sandbox 文件发布到持久 Workspace。
|
|
610
|
+
// 调用:Pi 确认命令产物需要保留到会话文件区时调用。
|
|
611
|
+
// 原因:显式发布避免临时容器状态被误当成已持久化;
|
|
612
|
+
// 冲突、共享写保护和失败码继续由 Port 决定。
|
|
613
|
+
async execute(_toolCallId, { paths }, signal, onUpdate) {
|
|
614
|
+
signal?.throwIfAborted();
|
|
615
|
+
running(onUpdate, "Publishing Sandbox files to the Workspace.");
|
|
616
|
+
const output = await sandbox.publishFiles(paths);
|
|
617
|
+
return result({
|
|
618
|
+
files: output.files,
|
|
619
|
+
skipped: output.skipped,
|
|
620
|
+
failed: output.failed.map(({ path, errorCode }) => ({
|
|
621
|
+
path,
|
|
622
|
+
errorCode,
|
|
623
|
+
})),
|
|
624
|
+
bytes: output.bytes,
|
|
625
|
+
workspacePersistence: "published",
|
|
626
|
+
});
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
return [
|
|
631
|
+
{
|
|
632
|
+
owner: "sandbox",
|
|
633
|
+
authorized: true,
|
|
634
|
+
requiredExecutionLevel: "high",
|
|
635
|
+
tool: exec,
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
owner: "sandbox",
|
|
639
|
+
authorized: true,
|
|
640
|
+
requiredExecutionLevel: "high",
|
|
641
|
+
tool: startProcess,
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
owner: "sandbox",
|
|
645
|
+
authorized: true,
|
|
646
|
+
requiredExecutionLevel: "safe",
|
|
647
|
+
tool: processLogs,
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
owner: "sandbox",
|
|
651
|
+
authorized: true,
|
|
652
|
+
requiredExecutionLevel: "high",
|
|
653
|
+
tool: stopProcess,
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
owner: "sandbox",
|
|
657
|
+
authorized: true,
|
|
658
|
+
requiredExecutionLevel: "high",
|
|
659
|
+
tool: publishFiles,
|
|
660
|
+
},
|
|
661
|
+
];
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// #endregion
|