@springbrand/agent-runtime 0.1.3-alpha.3 → 0.1.3-alpha.5

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.
Files changed (59) hide show
  1. package/package.json +3 -1
  2. package/src/adapter/cloudflare/index.ts +60 -0
  3. package/src/adapter/cloudflare/resources/runtime-resources.ts +86 -0
  4. package/src/adapter/cloudflare/sandbox/adapter.ts +1509 -0
  5. package/src/adapter/cloudflare/sandbox/id.ts +23 -0
  6. package/src/adapter/cloudflare/sandbox/policy.ts +15 -0
  7. package/src/adapter/cloudflare/subagent/definition.ts +574 -0
  8. package/src/adapter/cloudflare/subagent/runner.ts +175 -0
  9. package/src/adapter/cloudflare/subagent/tools.ts +256 -0
  10. package/src/adapter/cloudflare/universal-agent/definition.ts +71 -0
  11. package/src/adapter/cloudflare/universal-agent/hooks.ts +35 -0
  12. package/src/adapter/cloudflare/universal-agent/preparation.ts +273 -0
  13. package/src/adapter/cloudflare/universal-agent/tools.ts +74 -0
  14. package/src/adapter/cloudflare/workspace/publisher.ts +31 -0
  15. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +376 -0
  16. package/src/agent-tool-runtime.ts +152 -0
  17. package/src/db/agent-tool.repo.ts +27 -0
  18. package/src/db/index.ts +33 -0
  19. package/src/db/interaction.repo.ts +185 -0
  20. package/src/db/schema.ts +15 -0
  21. package/src/db/submission.repo.ts +29 -0
  22. package/src/index.ts +53 -21
  23. package/src/kernel/approval-lifecycle.ts +41 -6
  24. package/src/kernel/bindings.ts +37 -0
  25. package/src/kernel/interaction-lifecycle.ts +395 -0
  26. package/src/kernel/public-contracts.ts +2 -0
  27. package/src/kernel/recoverable-chat-agent.ts +10 -2
  28. package/src/kernel/runtime-assembly-view.ts +37 -0
  29. package/src/kernel/runtime-assembly.ts +41 -0
  30. package/src/kernel/runtime-config.ts +4 -0
  31. package/src/kernel/runtime-load.ts +102 -0
  32. package/src/kernel/state.ts +8 -1
  33. package/src/kernel/submission-lifecycle.ts +30 -0
  34. package/src/layers/orchestration/temporary-agent/core.ts +12 -1
  35. package/src/layers/orchestration/temporary-agent/runner.ts +1 -2
  36. package/src/pi/message/contract.ts +7 -0
  37. package/src/pi/message/conversion.ts +9 -1
  38. package/src/pi/runtime-adapter/assembly.ts +17 -3
  39. package/src/pi/runtime-adapter/execution.ts +109 -9
  40. package/src/pi/runtime-adapter/index.ts +15 -5
  41. package/src/pi/runtime-adapter/recovery.ts +188 -1
  42. package/src/pi/tool/base.ts +79 -9
  43. package/src/pi/tool/compiler.ts +34 -0
  44. package/src/pi/tool/core.ts +13 -0
  45. package/src/pi/tool/gateway.ts +54 -0
  46. package/src/pi/tool/index.ts +1 -0
  47. package/src/pi/tool/mcp.ts +93 -64
  48. package/src/pi/tool/schedule.ts +11 -0
  49. package/src/pi/tool/subagent.ts +14 -0
  50. package/src/pi/tool/workspace-sandbox.ts +15 -0
  51. package/src/pi/turn/index.ts +20 -0
  52. package/src/pi/turn/interaction.ts +181 -0
  53. package/src/pi/turn/tool-recovery.ts +244 -1
  54. package/src/runtime-agent-context.ts +112 -0
  55. package/src/runtime-agent.ts +569 -322
  56. package/src/{plugins.ts → runtime-assembler.ts} +312 -379
  57. package/src/runtime-definition.ts +173 -0
  58. package/src/runtime.ts +572 -164
  59. package/src/tool-registry.ts +143 -0
@@ -0,0 +1,376 @@
1
+ import type {
2
+ WorkspaceAdminPort,
3
+ WorkspaceFileInfo,
4
+ WorkspacePort,
5
+ WorkspaceQuota,
6
+ WorkspaceUsage,
7
+ } from "../../../kernel/bindings";
8
+ import type {
9
+ WorkspaceConditionalWriteResult,
10
+ WorkspaceFileVersion,
11
+ } from "./publisher";
12
+
13
+ const USAGE_PAGE_SIZE = 256;
14
+ const UNCONFIGURED_QUOTA: WorkspaceQuota = Object.freeze({
15
+ maxFiles: null,
16
+ maxTotalBytes: null,
17
+ maxFileBytes: null,
18
+ });
19
+
20
+ function normalizePath(path: string): string {
21
+ const value = path.trim().replaceAll("\\", "/");
22
+ const absolute = value.startsWith("/") ? value : `/${value}`;
23
+ const parts = absolute.split("/").filter(Boolean);
24
+ if (parts.some((part) => part === ".." || part === ".")) {
25
+ throw new Error("workspace path traversal is not allowed");
26
+ }
27
+ return `/${parts.join("/")}`;
28
+ }
29
+
30
+ function isReservedMemoryPath(path: string): boolean {
31
+ return path === "/shared/memories" || path.startsWith("/shared/memories/");
32
+ }
33
+
34
+ /**
35
+ * A Session sees the user's shared mount plus its own directory. All other
36
+ * paths are interpreted relative to the current Session directory; another
37
+ * Session can never be named through this port. The Inbox Host also uses this
38
+ * object to create and remove the Session tree, so callers never need to build
39
+ * the physical `/sessions/<id>` path themselves.
40
+ */
41
+ export class ScopedWorkspace implements WorkspacePort, WorkspaceAdminPort {
42
+ readonly sessionRoot: string;
43
+
44
+ constructor(
45
+ private readonly parent: WorkspacePort,
46
+ readonly sessionId: string,
47
+ private readonly quota: WorkspaceQuota = UNCONFIGURED_QUOTA,
48
+ ) {
49
+ if (!sessionId || sessionId.includes("/") || sessionId === "." || sessionId === "..") {
50
+ throw new Error("invalid session id");
51
+ }
52
+ this.sessionRoot = `/sessions/${sessionId}`;
53
+ }
54
+
55
+ async ensure(): Promise<void> {
56
+ await this.parent.mkdir("/shared", { recursive: true });
57
+ await this.parent.mkdir(this.sessionRoot, { recursive: true });
58
+ }
59
+
60
+ /**
61
+ * Count only this Session's private tree. User-level `/shared` belongs to the
62
+ * Inbox and must not be charged once per Chat.
63
+ */
64
+ async getUsage(): Promise<WorkspaceUsage> {
65
+ if (!(await this.parent.exists(this.sessionRoot))) {
66
+ return { fileCount: 0, directoryCount: 0, totalBytes: 0 };
67
+ }
68
+
69
+ let fileCount = 0;
70
+ let directoryCount = 0;
71
+ let totalBytes = 0;
72
+ const pending = [this.sessionRoot];
73
+
74
+ while (pending.length > 0) {
75
+ const directory = pending.pop()!;
76
+ let offset = 0;
77
+ while (true) {
78
+ const entries = await this.parent.readDir(directory, {
79
+ limit: USAGE_PAGE_SIZE,
80
+ offset,
81
+ });
82
+ for (const entry of entries) {
83
+ if (entry.type === "directory") {
84
+ directoryCount += 1;
85
+ pending.push(entry.path);
86
+ } else {
87
+ fileCount += 1;
88
+ totalBytes += entry.size;
89
+ }
90
+ }
91
+ if (entries.length < USAGE_PAGE_SIZE) break;
92
+ offset += entries.length;
93
+ }
94
+ }
95
+
96
+ return { fileCount, directoryCount, totalBytes };
97
+ }
98
+
99
+ async getQuota(): Promise<WorkspaceQuota> {
100
+ return { ...this.quota };
101
+ }
102
+
103
+ /** Host-only lifecycle operation. It never removes the user-level `/shared` tree. */
104
+ async removeAll(): Promise<void> {
105
+ await this.parent.rm(this.sessionRoot, {
106
+ recursive: true,
107
+ force: true,
108
+ });
109
+ }
110
+
111
+ private physical(path: string): string {
112
+ const normalized = normalizePath(path);
113
+ if (normalized === "/shared" || normalized.startsWith("/shared/")) {
114
+ return normalized;
115
+ }
116
+ if (normalized === this.sessionRoot || normalized.startsWith(`${this.sessionRoot}/`)) {
117
+ return normalized;
118
+ }
119
+ if (normalized === "/sessions" || normalized.startsWith("/sessions/")) {
120
+ throw new Error("another Session workspace is not accessible");
121
+ }
122
+ return normalized === "/"
123
+ ? this.sessionRoot
124
+ : `${this.sessionRoot}${normalized}`;
125
+ }
126
+
127
+ private writablePhysical(path: string): string {
128
+ const physical = this.physical(path);
129
+ if (isReservedMemoryPath(physical)) {
130
+ throw new Error("/shared/memories is read-only in Session workspaces");
131
+ }
132
+ return physical;
133
+ }
134
+
135
+ private async assertNoSymlinkComponents(path: string): Promise<void> {
136
+ let root: string;
137
+ if (path === "/shared" || path.startsWith("/shared/")) {
138
+ root = "/shared";
139
+ } else if (
140
+ path === this.sessionRoot ||
141
+ path.startsWith(`${this.sessionRoot}/`)
142
+ ) {
143
+ root = this.sessionRoot;
144
+ } else {
145
+ throw new Error("another Session workspace is not accessible");
146
+ }
147
+ const relative = path.slice(root.length);
148
+ const parts = relative.split("/").filter(Boolean);
149
+ let current = root;
150
+
151
+ for (const part of ["", ...parts]) {
152
+ if (part) current = `${current}/${part}`;
153
+ const info = await this.parent.lstat(current);
154
+ if (!info) return;
155
+ if (info.type === "symlink") {
156
+ throw new Error(
157
+ "symbolic links are not accessible in scoped workspaces",
158
+ );
159
+ }
160
+ }
161
+ }
162
+
163
+ private async guardedPhysical(
164
+ path: string,
165
+ writable = false,
166
+ ): Promise<string> {
167
+ const physical = writable
168
+ ? this.writablePhysical(path)
169
+ : this.physical(path);
170
+ await this.assertNoSymlinkComponents(physical);
171
+ return physical;
172
+ }
173
+
174
+ private virtual(path: string): string {
175
+ if (path === this.sessionRoot) return "/";
176
+ if (path.startsWith(`${this.sessionRoot}/`)) {
177
+ return path.slice(this.sessionRoot.length);
178
+ }
179
+ return path;
180
+ }
181
+
182
+ private project(info: WorkspaceFileInfo | null): WorkspaceFileInfo | null {
183
+ return info ? { ...info, path: this.virtual(info.path) } : null;
184
+ }
185
+
186
+ async readFile(path: string) {
187
+ return this.parent.readFile(await this.guardedPhysical(path));
188
+ }
189
+
190
+ async readFileBytes(path: string) {
191
+ return this.parent.readFileBytes(await this.guardedPhysical(path));
192
+ }
193
+
194
+ async writeFile(path: string, content: string, mimeType?: string) {
195
+ return this.parent.writeFile(
196
+ await this.guardedPhysical(path, true),
197
+ content,
198
+ mimeType,
199
+ );
200
+ }
201
+
202
+ async writeFileBytes(
203
+ path: string,
204
+ data: Uint8Array | ArrayBuffer,
205
+ mimeType?: string,
206
+ ) {
207
+ return this.parent.writeFileBytes(
208
+ await this.guardedPhysical(path, true),
209
+ data,
210
+ mimeType,
211
+ );
212
+ }
213
+
214
+ async writeFileBytesIfUnchanged(
215
+ path: string,
216
+ data: Uint8Array,
217
+ mimeType: string,
218
+ expected: WorkspaceFileVersion | null,
219
+ ): Promise<WorkspaceConditionalWriteResult> {
220
+ const physical = await this.guardedPhysical(path, true);
221
+ if (physical === "/shared" || physical.startsWith("/shared/")) {
222
+ throw new Error("/shared is read-only for Sandbox publishing");
223
+ }
224
+ const current = await this.parent.stat(physical);
225
+ const currentVersion =
226
+ current?.type === "file"
227
+ ? { updatedAt: current.updatedAt, size: current.size }
228
+ : null;
229
+ const matches =
230
+ expected === null
231
+ ? current === null
232
+ : current?.type === "file" &&
233
+ current.updatedAt === expected.updatedAt &&
234
+ current.size === expected.size;
235
+ if (!matches) {
236
+ return {
237
+ written: false,
238
+ reason: "conflict",
239
+ current: currentVersion,
240
+ };
241
+ }
242
+ await this.parent.writeFileBytes(physical, data, mimeType);
243
+ const written = await this.parent.stat(physical);
244
+ if (!written || written.type !== "file") {
245
+ throw new Error("published Workspace file could not be verified");
246
+ }
247
+ return {
248
+ written: true,
249
+ version: {
250
+ updatedAt: written.updatedAt,
251
+ size: written.size,
252
+ },
253
+ };
254
+ }
255
+
256
+ async appendFile(path: string, content: string, mimeType?: string) {
257
+ return this.parent.appendFile(
258
+ await this.guardedPhysical(path, true),
259
+ content,
260
+ mimeType,
261
+ );
262
+ }
263
+
264
+ async exists(path: string) {
265
+ return this.parent.exists(await this.guardedPhysical(path));
266
+ }
267
+
268
+ async stat(path: string) {
269
+ return this.project(
270
+ await this.parent.stat(await this.guardedPhysical(path)),
271
+ );
272
+ }
273
+
274
+ async lstat(path: string) {
275
+ return this.project(
276
+ await this.parent.lstat(await this.guardedPhysical(path)),
277
+ );
278
+ }
279
+
280
+ async mkdir(path: string, opts?: { recursive?: boolean }) {
281
+ return this.parent.mkdir(await this.guardedPhysical(path, true), opts);
282
+ }
283
+
284
+ async readDir(dir = "/", opts?: { limit?: number; offset?: number }) {
285
+ const files = await this.parent.readDir(
286
+ await this.guardedPhysical(dir),
287
+ opts,
288
+ );
289
+ return files.map((file) => this.project(file)!);
290
+ }
291
+
292
+ async rm(path: string, opts?: { recursive?: boolean; force?: boolean }) {
293
+ const physical = await this.guardedPhysical(path, true);
294
+ if (physical === "/shared" || physical === this.sessionRoot) {
295
+ throw new Error("workspace mount roots cannot be removed");
296
+ }
297
+ return this.parent.rm(physical, opts);
298
+ }
299
+
300
+ async cp(src: string, dest: string, opts?: { recursive?: boolean }) {
301
+ return this.parent.cp(
302
+ await this.guardedPhysical(src),
303
+ await this.guardedPhysical(dest, true),
304
+ opts,
305
+ );
306
+ }
307
+
308
+ async mv(src: string, dest: string, opts?: { recursive?: boolean }) {
309
+ return this.parent.mv(
310
+ await this.guardedPhysical(src, true),
311
+ await this.guardedPhysical(dest, true),
312
+ opts,
313
+ );
314
+ }
315
+
316
+ async symlink(target: string, linkPath: string) {
317
+ /*
318
+ * A symlink stored under `/shared` could target this Session's physical
319
+ * directory and then be followed by another Session. Path normalization
320
+ * cannot guard a later filesystem dereference, so scoped runtimes do not
321
+ * create symlinks at all.
322
+ */
323
+ void target;
324
+ void linkPath;
325
+ throw new Error("symbolic links are disabled in scoped workspaces");
326
+ }
327
+
328
+ async readlink(path: string) {
329
+ return this.virtual(
330
+ await this.parent.readlink(await this.guardedPhysical(path)),
331
+ );
332
+ }
333
+
334
+ async glob(pattern: string) {
335
+ const files = await this.parent.glob(this.physical(pattern));
336
+ for (const file of files) {
337
+ await this.assertNoSymlinkComponents(file.path);
338
+ }
339
+ return files.map((file) => this.project(file)!);
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Build a runtime capability object that contains data-plane methods only.
345
+ *
346
+ * A TypeScript annotation does not remove extra methods at runtime. Returning
347
+ * this explicit facade prevents Agent Runtime and Sandbox adapters from
348
+ * reaching ScopedWorkspace lifecycle/accounting methods through the same
349
+ * object reference.
350
+ */
351
+ export function createWorkspacePortFacade(
352
+ workspace: WorkspacePort,
353
+ ): WorkspacePort {
354
+ const facade: WorkspacePort = {
355
+ readFile: (path) => workspace.readFile(path),
356
+ readFileBytes: (path) => workspace.readFileBytes(path),
357
+ writeFile: (path, content, mimeType) =>
358
+ workspace.writeFile(path, content, mimeType),
359
+ writeFileBytes: (path, data, mimeType) =>
360
+ workspace.writeFileBytes(path, data, mimeType),
361
+ appendFile: (path, content, mimeType) =>
362
+ workspace.appendFile(path, content, mimeType),
363
+ exists: (path) => workspace.exists(path),
364
+ stat: (path) => workspace.stat(path),
365
+ lstat: (path) => workspace.lstat(path),
366
+ mkdir: (path, opts) => workspace.mkdir(path, opts),
367
+ readDir: (dir, opts) => workspace.readDir(dir, opts),
368
+ rm: (path, opts) => workspace.rm(path, opts),
369
+ cp: (src, dest, opts) => workspace.cp(src, dest, opts),
370
+ mv: (src, dest, opts) => workspace.mv(src, dest, opts),
371
+ symlink: (target, linkPath) => workspace.symlink(target, linkPath),
372
+ readlink: (path) => workspace.readlink(path),
373
+ glob: (pattern) => workspace.glob(pattern),
374
+ };
375
+ return Object.freeze(facade);
376
+ }
@@ -0,0 +1,152 @@
1
+ import type {
2
+ AgentToolChildAdapter,
3
+ AgentToolRunInspection,
4
+ AgentToolStoredChunk,
5
+ } from "agents";
6
+ import { AgentRuntimeKernel } from "./runtime";
7
+
8
+ const AGENT_TOOL_RUN_PREFIX = "universal-agent:temporary-agent-run:";
9
+
10
+ interface AgentToolRunRecord {
11
+ runId: string;
12
+ submissionId: string;
13
+ startedAt: number;
14
+ completedAt?: number;
15
+ summary?: string;
16
+ }
17
+
18
+ function agentToolRunKey(runId: string): string {
19
+ return `${AGENT_TOOL_RUN_PREFIX}${runId}`;
20
+ }
21
+
22
+ /**
23
+ * 把 Cloudflare Agent Tool 子运行协议适配到 Runtime Submission 生命周期。
24
+ *
25
+ * 具体 Runtime 只负责准备输入与清理资源;启动、查询、取消和结果读取统一留在这里。
26
+ */
27
+ export abstract class AgentToolRuntimeKernel<
28
+ Env extends Cloudflare.Env = Cloudflare.Env,
29
+ > extends AgentRuntimeKernel<Env>
30
+ implements AgentToolChildAdapter<unknown, string> {
31
+ protected abstract prepareAgentToolRun(input: unknown): Promise<string>;
32
+ protected abstract cleanupAgentToolRun(): Promise<void>;
33
+
34
+ async startAgentToolRun(
35
+ input: unknown,
36
+ options: { runId: string; signal?: AbortSignal },
37
+ ): Promise<AgentToolRunInspection<string>> {
38
+ options.signal?.throwIfAborted();
39
+ try {
40
+ const prompt = await this.prepareAgentToolRun(input);
41
+ const receipt = await this.submitPrompt(prompt, {
42
+ idempotencyKey: options.runId,
43
+ });
44
+ const record: AgentToolRunRecord = {
45
+ runId: options.runId,
46
+ submissionId: receipt.submissionId,
47
+ startedAt: receipt.createdAt,
48
+ };
49
+ await this.ctx.storage.put(agentToolRunKey(options.runId), record);
50
+ const cancel = () => {
51
+ void this.cancelAgentToolRun(options.runId, options.signal?.reason);
52
+ };
53
+ if (options.signal?.aborted) cancel();
54
+ else options.signal?.addEventListener("abort", cancel, { once: true });
55
+ return {
56
+ runId: options.runId,
57
+ status: "running",
58
+ startedAt: record.startedAt,
59
+ };
60
+ } catch (error) {
61
+ await this.cleanupAgentToolRun();
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ async inspectAgentToolRun(
67
+ runId: string,
68
+ ): Promise<AgentToolRunInspection<string> | null> {
69
+ const key = agentToolRunKey(runId);
70
+ const record = await this.ctx.storage.get<AgentToolRunRecord>(key);
71
+ if (!record) return null;
72
+ const receipt = await this.getSubmission(record.submissionId);
73
+ if (!receipt) return null;
74
+ if (receipt.status === "pending" || receipt.status === "running") {
75
+ return { runId, status: "running", startedAt: record.startedAt };
76
+ }
77
+
78
+ const completedAt = receipt.completedAt ?? Date.now();
79
+ if (receipt.status === "completed") {
80
+ const summary = record.summary ?? await this.latestAssistantText();
81
+ if (!summary) {
82
+ await this.cleanupAgentToolRun();
83
+ return {
84
+ runId,
85
+ status: "error",
86
+ error: "Temporary Agent completed without final text",
87
+ startedAt: record.startedAt,
88
+ completedAt,
89
+ };
90
+ }
91
+ await this.ctx.storage.put(key, {
92
+ ...record,
93
+ summary,
94
+ completedAt,
95
+ } satisfies AgentToolRunRecord);
96
+ await this.cleanupAgentToolRun();
97
+ return {
98
+ runId,
99
+ status: "completed",
100
+ output: summary,
101
+ summary,
102
+ startedAt: record.startedAt,
103
+ completedAt,
104
+ };
105
+ }
106
+
107
+ await this.cleanupAgentToolRun();
108
+ return receipt.status === "aborted"
109
+ ? {
110
+ runId,
111
+ status: "aborted",
112
+ error: receipt.error ?? "Temporary Agent was cancelled",
113
+ startedAt: record.startedAt,
114
+ completedAt,
115
+ }
116
+ : {
117
+ runId,
118
+ status: "error",
119
+ error: receipt.error ??
120
+ (receipt.status === "skipped"
121
+ ? "Temporary Agent submission was skipped"
122
+ : "Temporary Agent failed"),
123
+ startedAt: record.startedAt,
124
+ completedAt,
125
+ };
126
+ }
127
+
128
+ async getAgentToolChunks(
129
+ _runId: string,
130
+ _options?: { afterSequence?: number },
131
+ ): Promise<AgentToolStoredChunk[]> {
132
+ return [];
133
+ }
134
+
135
+ async cancelAgentToolRun(runId: string, reason?: unknown): Promise<void> {
136
+ const record = await this.ctx.storage.get<AgentToolRunRecord>(
137
+ agentToolRunKey(runId),
138
+ );
139
+ try {
140
+ if (record) {
141
+ await this.cancelSubmissionById(
142
+ record.submissionId,
143
+ reason instanceof Error
144
+ ? reason.message
145
+ : String(reason ?? "Temporary Agent cancelled"),
146
+ );
147
+ }
148
+ } finally {
149
+ await this.cleanupAgentToolRun();
150
+ }
151
+ }
152
+ }
@@ -0,0 +1,27 @@
1
+ import type { SqlTaggedTemplate } from "agents/chat";
2
+
3
+ // Agent Tool Run 的统一定义见 ./index.ts。
4
+ export class AgentToolRepository {
5
+ // 保存当前 Agent SQLite 的查询入口。
6
+ // RuntimeDatabase 构造时调用,活动投影随后通过这个 Repository 读取子运行。
7
+ // 把存储绑定留在构造阶段,避免每次读取都传入可能属于其他 Agent 的 sql。
8
+ constructor(private readonly sql: SqlTaggedTemplate) {}
9
+
10
+ // 是否仍有 Agent Tool 子运行在执行。
11
+ // 活动投影在重算 activity 时调用,判定只在这里做一次。
12
+ // `interrupted` 单独判 `child_still_running`:父级不再等待并不代表子级已经停下。
13
+ hasRunning(): boolean {
14
+ try {
15
+ return (
16
+ this.sql<{ count: number }>`
17
+ SELECT COUNT(*) AS count FROM cf_agent_tool_runs
18
+ WHERE status IN ('starting', 'running')
19
+ OR (status = 'interrupted' AND child_still_running = 1)
20
+ `[0]?.count ?? 0
21
+ ) > 0;
22
+ } catch {
23
+ // 一个子运行都没派发过时,Agents SDK 还没有把该表建出来。
24
+ return false;
25
+ }
26
+ }
27
+ }
package/src/db/index.ts CHANGED
@@ -3,41 +3,52 @@ import type { SqlTaggedTemplate } from "agents/chat";
3
3
  import { initializeSchema } from "./schema";
4
4
  import { SubmissionRepository } from "./submission.repo";
5
5
  import { ApprovalRepository } from "./approval.repo";
6
+ import { ToolInteractionRepository } from "./interaction.repo";
6
7
  import { ToolSettlementRepository } from "./settlement.repo";
7
8
  import { RecoveryMilestoneRepository } from "./milestone.repo";
8
9
  import { ExtContextRepository } from "./ext-context.repo";
9
10
  import { MessageUiRepository } from "./message-ui.repo";
10
11
  import { SteerRepository } from "./steer.repo";
11
12
  import { RuntimeEventOutboxRepository } from "./runtime-event-outbox.repo";
13
+ import { AgentToolRepository } from "./agent-tool.repo";
12
14
 
13
15
  // 数据库术语以这里为准。
14
16
  // Submission 是一次可持久化的用户提交,从排队到终态都用同一个 submissionId 跟踪。
15
17
  // Approval 是某次工具调用的持久化审批记录,executionId 是对外决策时的主键。
18
+ // Tool Interaction 是「结果由客户端提供」的工具调用的 park 记录,interactionId 是主键;
19
+ // 它只回答还等不等得到响应,答案的权威副本仍是 Settlement。
16
20
  // Settlement 是工具调用的最终结果,同一 Submission 内用 toolCallId 防止重复落盘。
17
21
  // Recovery Milestone 是按 seq 重放的恢复事实,milestoneKey 用来去重。
18
22
  // Extension Context 是扩展按 label 保存的文本,不随清空聊天记录删除。
19
23
  // Message UI 是会话消息的用户界面补充数据,它由 Transcript 而不是 Turn 生命周期管理。
24
+ // Agent Tool Run 是 Agents SDK 派发的子运行,唯一一张不归 Runtime 所有的表:SDK 建它、写它、清它,
25
+ // 这里只读。它没有 `pi_` 前缀,也不进 initializeSchema 和 clearAll。之所以直读 SDK 的表而不另建账本,
26
+ // 是因为 SDK 没有公开父级侧的「还有没有子运行在跑」读法,而复制一份必然产生第二个真相源。
20
27
  // Repository 只把 Runtime 语义翻译成参数化 SQL,所有表都位于当前 Agent 实例自己的 Durable Object SQLite 存储中。
21
28
  // Transaction 指 Cloudflare transactionSync 包住的同步操作,回调抛错时整体回滚。
22
29
  export * from "./schema";
23
30
  export * from "./submission.repo";
24
31
  export * from "./approval.repo";
32
+ export * from "./interaction.repo";
25
33
  export * from "./settlement.repo";
26
34
  export * from "./milestone.repo";
27
35
  export * from "./ext-context.repo";
28
36
  export * from "./message-ui.repo";
29
37
  export * from "./steer.repo";
30
38
  export * from "./runtime-event-outbox.repo";
39
+ export * from "./agent-tool.repo";
31
40
 
32
41
  export class RuntimeDatabase {
33
42
  readonly submissions: SubmissionRepository;
34
43
  readonly approvals: ApprovalRepository;
44
+ readonly interactions: ToolInteractionRepository;
35
45
  readonly settlements: ToolSettlementRepository;
36
46
  readonly milestones: RecoveryMilestoneRepository;
37
47
  readonly extContext: ExtContextRepository;
38
48
  readonly messageUi: MessageUiRepository;
39
49
  readonly steers: SteerRepository;
40
50
  readonly runtimeEvents: RuntimeEventOutboxRepository;
51
+ readonly agentTools: AgentToolRepository;
41
52
 
42
53
  // 给各个 Repository 分配同一个 Agent SQLite 入口和事务入口。
43
54
  // AgentRuntimeKernel 构造时只创建一次,业务代码随后通过对应属性访问仓储。
@@ -48,12 +59,33 @@ export class RuntimeDatabase {
48
59
  ) {
49
60
  this.submissions = new SubmissionRepository(sql);
50
61
  this.approvals = new ApprovalRepository(sql);
62
+ this.interactions = new ToolInteractionRepository(sql);
51
63
  this.settlements = new ToolSettlementRepository(sql);
52
64
  this.milestones = new RecoveryMilestoneRepository(sql);
53
65
  this.extContext = new ExtContextRepository(sql);
54
66
  this.messageUi = new MessageUiRepository(sql);
55
67
  this.steers = new SteerRepository(sql);
56
68
  this.runtimeEvents = new RuntimeEventOutboxRepository(sql);
69
+ this.agentTools = new AgentToolRepository(sql);
70
+ }
71
+
72
+ /**
73
+ * 有未完成 Submission,且每一条都停在人机等待上。
74
+ *
75
+ * 换装配的忙碌闸门调用它。「停在等人」有两种形态:待决审批,和等客户端
76
+ * 投递结果的 Tool Interaction —— 两者都意味着没有模型请求在飞、没有工具
77
+ * 在执行,因此换掉能力语义是安全的。
78
+ *
79
+ * 注意它不是「存在待处理的人机等待」:只要有一条 Submission 正在真的执行,
80
+ * 整个判断就必须为假,否则会把执行中的 Turn 切成两套能力语义。
81
+ */
82
+ everyUnfinishedSubmissionParked(): boolean {
83
+ const unfinished = this.submissions.listUnfinishedIds();
84
+ if (unfinished.length === 0) return false;
85
+ return unfinished.every((submissionId) =>
86
+ this.approvals.listPendingForSubmission(submissionId).length > 0 ||
87
+ this.interactions.listPendingForSubmission(submissionId).length > 0
88
+ );
57
89
  }
58
90
 
59
91
  // 在一个同步 SQLite 事务里运行一组读写。
@@ -77,6 +109,7 @@ export class RuntimeDatabase {
77
109
  this.sql`DELETE FROM pi_pending_steers`;
78
110
  this.sql`DELETE FROM pi_submissions`;
79
111
  this.sql`DELETE FROM pi_approvals`;
112
+ this.sql`DELETE FROM pi_tool_interactions`;
80
113
  this.sql`DELETE FROM pi_tool_settlements`;
81
114
  this.sql`DELETE FROM pi_recovery_milestones`;
82
115
  }