@springbrand/agent-runtime 0.3.0-alpha.7 → 0.3.0-alpha.9

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 (93) hide show
  1. package/package.json +2 -2
  2. package/src/adapter/cloudflare/agent.ts +4 -7
  3. package/src/adapter/cloudflare/define-agent.ts +2 -0
  4. package/src/adapter/cloudflare/schema.ts +3 -0
  5. package/src/adapter/cloudflare/subagent/definition.ts +4 -4
  6. package/src/adapter/cloudflare/subagent/runner.ts +1 -1
  7. package/src/adapter/cloudflare/subagent/tools.ts +2 -2
  8. package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
  9. package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
  10. package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
  11. package/src/config/agent-context.ts +1 -1
  12. package/src/config/assembler.ts +17 -31
  13. package/src/config/assembly-snapshot.ts +1 -1
  14. package/src/config/definition.ts +1 -1
  15. package/src/config/model-guidance.ts +1 -1
  16. package/src/config/ports.ts +5 -15
  17. package/src/config/public-contracts.ts +2 -2
  18. package/src/context/extension/assembly.ts +2 -2
  19. package/src/context/memory/budget/gate.ts +1 -1
  20. package/src/conversation/state.ts +1 -1
  21. package/src/index.ts +7 -7
  22. package/src/lib/execution-level.ts +13 -0
  23. package/src/lib/prompt.ts +3 -5
  24. package/src/models/index.ts +1 -0
  25. package/src/session/cloudflare/migration.ts +29 -8
  26. package/src/skills/springbrand-worker-website/index.ts +1 -1
  27. package/src/submission/admission.ts +4 -0
  28. package/src/submission/delivery.ts +1 -1
  29. package/src/submission/finalization.ts +1 -1
  30. package/src/submission/repository.ts +8 -1
  31. package/src/telemetry/coordinator.ts +1 -1
  32. package/src/telemetry/subagent-lifecycle.ts +1 -1
  33. package/src/telemetry/turn-events.ts +1 -1
  34. package/src/tools/README.md +44 -0
  35. package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
  36. package/src/tools/{declared.ts → adapters/declared.ts} +2 -2
  37. package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +2 -2
  38. package/src/tools/assistant/ask-user.ts +317 -0
  39. package/src/tools/assistant/candidate.ts +15 -0
  40. package/src/tools/assistant/followups.ts +34 -0
  41. package/src/tools/assistant/index.ts +19 -0
  42. package/src/tools/assistant/memory.ts +73 -0
  43. package/src/tools/assistant/plan.ts +98 -0
  44. package/src/tools/{time.ts → assistant/time.ts} +1 -1
  45. package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -49
  46. package/src/tools/{core.ts → codemode/index.ts} +36 -92
  47. package/src/tools/codemode/workspace-connector.ts +198 -0
  48. package/src/tools/extension/index.ts +42 -0
  49. package/src/tools/index.ts +28 -26
  50. package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
  51. package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
  52. package/src/tools/{approval → runtime/approval}/repository.ts +3 -9
  53. package/src/tools/{compiler.ts → runtime/compiler.ts} +24 -155
  54. package/src/tools/runtime/contracts.ts +117 -0
  55. package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +36 -61
  56. package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
  57. package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
  58. package/src/tools/runtime/result.ts +22 -0
  59. package/src/tools/sandbox/index.ts +228 -0
  60. package/src/tools/{schedule.ts → schedule/index.ts} +50 -49
  61. package/src/tools/skill/contracts.ts +20 -0
  62. package/src/tools/skill/index.ts +99 -0
  63. package/src/tools/skill/resources.ts +322 -0
  64. package/src/tools/skill/scripts.ts +168 -0
  65. package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
  66. package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
  67. package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
  68. package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
  69. package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
  70. package/src/tools/{temporary-agent → subagent/temporary}/core.ts +1 -1
  71. package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
  72. package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
  73. package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
  74. package/src/tools/web/index.ts +2 -0
  75. package/src/tools/web/search/api.ts +1320 -0
  76. package/src/tools/web/search/index.ts +2 -0
  77. package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
  78. package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
  79. package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
  80. package/src/tools/base.ts +0 -542
  81. package/src/tools/skill.ts +0 -565
  82. package/src/tools/web-search/api.ts +0 -1318
  83. package/src/tools/web-search/index.ts +0 -2
  84. package/src/tools/workspace-state-connector.ts +0 -146
  85. package/src/tools/{approval → runtime/approval}/index.ts +0 -0
  86. package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
  87. package/src/tools/{unattended-error.ts → runtime/unattended-error.ts} +0 -0
  88. package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
  89. package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
  90. package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
  91. package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
  92. package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
  93. /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
@@ -0,0 +1,99 @@
1
+ import { SkillRegistry } from "agents/skills";
2
+ import { aiToolToPi } from "../adapters/ai-to-pi";
3
+ import type { PiToolCandidate } from "../runtime/contracts";
4
+ import type { PiSkillBinding, SkillPiToolOptions } from "./contracts";
5
+ import { catalogSkillSource, materializeSkillResourceTool, wrapSkillResourceRead } from "./resources";
6
+ import { scriptRunner, wrapSkillScript } from "./scripts";
7
+
8
+ export type { PiSkillBinding, SkillPiToolOptions } from "./contracts";
9
+ const SKILL_TOOL_LABELS: Readonly<Record<string, string>> = {
10
+ activate_skill: "Activate Skill",
11
+ read_skill_resource: "Read Skill resource",
12
+ run_skill_script: "Run Skill script",
13
+ materialize_skill_resource: "Materialize Skill resource",
14
+ };
15
+
16
+ const SKILL_TOOL_PARAMETER_DESCRIPTIONS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
17
+ activate_skill: {
18
+ name: "Exact name of the available Skill to activate.",
19
+ },
20
+ read_skill_resource: {
21
+ name: "Name of the activated Skill. Omit only when path starts with the Skill name.",
22
+ path: "Bundled resource path listed by activate_skill.",
23
+ },
24
+ run_skill_script: {
25
+ name: "Name of the activated Skill that supplies the script.",
26
+ path: "Bundled script path listed by activate_skill.",
27
+ input: "JSON input expected by the Skill script. Defaults to an empty object.",
28
+ },
29
+ };
30
+
31
+ /** Create Pi candidates from the official Agents SDK SkillRegistry tools. */
32
+ export async function skillPiToolCandidates(
33
+ bindings: readonly PiSkillBinding[],
34
+ options: SkillPiToolOptions = {},
35
+ ): Promise<PiToolCandidate[]> {
36
+ if (bindings.length === 0) return [];
37
+
38
+ const availableToolNames = (options.tools ?? []).map(({ tool }) => tool.name);
39
+
40
+ const registry = new SkillRegistry(
41
+ bindings.map((binding) => catalogSkillSource(
42
+ binding,
43
+ options.transformSkillBody,
44
+ availableToolNames,
45
+ )),
46
+ scriptRunner(bindings, options),
47
+ );
48
+ await registry.load();
49
+
50
+ const tools = registry.tools();
51
+ if (options.workspace) {
52
+ tools.materialize_skill_resource = materializeSkillResourceTool(
53
+ bindings,
54
+ options.workspace,
55
+ );
56
+ }
57
+
58
+ return Object.entries(tools).map(([name, tool]) => {
59
+ const adapted = aiToolToPi(name, tool, {
60
+ label: SKILL_TOOL_LABELS[name] ?? name,
61
+ ...(name === "read_skill_resource"
62
+ ? {
63
+ description:
64
+ "Read a bundled resource listed by activate_skill. Returns structured resource metadata and content. Do not pass SKILL.md; activate_skill returns the Skill instructions.",
65
+ }
66
+ : {}),
67
+ });
68
+ const properties = (adapted.parameters as { properties?: unknown }).properties;
69
+ if (properties && typeof properties === "object" && !Array.isArray(properties)) {
70
+ for (const [parameter, description] of Object.entries(
71
+ SKILL_TOOL_PARAMETER_DESCRIPTIONS[name] ?? {},
72
+ )) {
73
+ const schema = (properties as Record<string, unknown>)[parameter];
74
+ if (schema && typeof schema === "object" && !Array.isArray(schema)) {
75
+ (schema as Record<string, unknown>).description ??= description;
76
+ }
77
+ }
78
+ }
79
+ if (name === "read_skill_resource") wrapSkillResourceRead(adapted, bindings);
80
+ if (name === "run_skill_script") wrapSkillScript(adapted, options.workspace);
81
+ return {
82
+ owner: "runtime-skill",
83
+ requiredExecutionLevel: "safe",
84
+ // activate_skill 返回的是 Skill 指令本身,是模型接下来所有动作的依据。把它外置
85
+ // 成文件,模型手里就只剩一个路径,必须再取一次才能知道该做什么 —— 而典型
86
+ // SKILL.md 正好落在会触发外置的区间。指令必须当场到手。
87
+ ...(name === "activate_skill"
88
+ ? { outputBudget: { kind: "structure" as const } }
89
+ : {}),
90
+ ...(name === "activate_skill"
91
+ ? { exposureMode: "direct" as const }
92
+ : { exposureMode: "codemode" as const }),
93
+ ...(name === "activate_skill" || name === "read_skill_resource"
94
+ ? { retry: "idempotent" as const }
95
+ : {}),
96
+ tool: adapted,
97
+ };
98
+ });
99
+ }
@@ -0,0 +1,322 @@
1
+ import { type SkillResource, type SkillSource } from "agents/skills";
2
+ import { tool } from "ai";
3
+ import { z } from "zod";
4
+ import type { WorkspacePort } from "../../config/ports";
5
+ import type { RuntimeSkillBodyTransform } from "../../config/definition";
6
+ import { STORAGE_LEAF_MAX_CHARS } from "../../context/memory/budget/gate";
7
+ import { result } from "../runtime/result";
8
+ import { serializeWorkspaceMutation } from "../workspace";
9
+ import type { PiSkillBinding } from "./contracts";
10
+
11
+ /**
12
+ * `run_skill_script` 一次挂载的 Skill 资源总量上限。
13
+ *
14
+ * 上游 SkillRegistry 在启动脚本前会把 Skill 的**全部**资源读进内存
15
+ * (`readSkillResources`),运行器随后还要再复制几份:files map、base64 解码后的
16
+ * 字节、以及内联进生成源码的那份 JSON。同一个资源包因此会在 Worker isolate 里
17
+ * 同时存在三到四份,按四倍放大倒推 128 MB 的 DO 内存上限,安全线落在 8 MB。
18
+ *
19
+ * 这道闸只认体积不认 Skill 名字:以后再上一个大包不需要改代码。
20
+ */
21
+ const SKILL_RESOURCE_BUDGET_BYTES = 8 * 1024 * 1024;
22
+
23
+ type LoadedSkill = NonNullable<Awaited<ReturnType<SkillSource["load"]>>>;
24
+
25
+ const SKILL_RESOURCE_READ_GUIDANCE =
26
+ "Bundled Skill resources are not Workspace files. " +
27
+ "Inside execute, use tools.read_skill_resource for text instead of state.* on /skills.";
28
+
29
+ const SKILL_RESOURCE_MATERIALIZE_GUIDANCE =
30
+ "Inside execute, use tools.materialize_skill_resource for Workspace assets when available.";
31
+
32
+ /**
33
+ * 按体积裁掉超预算的 Skill 资源,并把裁掉的事实写回 Skill 正文。
34
+ *
35
+ * @remarks
36
+ * `catalogSkillSource` 在每次 `load` 后调用,必须在这一层拦截:上游拿到 descriptor
37
+ * 后会立刻把它们全读进内存,等运行器收到请求时资源已经在堆上,再判断就晚了。
38
+ *
39
+ * 裁掉的资源必须**说出来**。它们同时会从 `activate_skill` 的资源清单里消失,若不
40
+ * 在正文里点名,模型只会看到一个凭空少了文件的 Skill,然后去猜。
41
+ *
42
+ * 已知缺口:没有 `size` 的 descriptor 不计入预算。R2 来源的 size 来自对象列表因而
43
+ * 总是存在;manifest 来源可能没有,此时这道闸对那些资源不生效。宁可放过也不误杀,
44
+ * 因为误杀会让本来能跑的 Skill 直接坏掉。
45
+ */
46
+ function budgetSkillResources(skill: LoadedSkill): LoadedSkill {
47
+ const resources = skill.resources ?? [];
48
+ const kept: typeof resources = [];
49
+ const dropped: { path: string; size: number }[] = [];
50
+ let used = 0;
51
+
52
+ for (const resource of resources) {
53
+ if (typeof resource.size !== "number") {
54
+ kept.push(resource);
55
+ continue;
56
+ }
57
+ if (used + resource.size > SKILL_RESOURCE_BUDGET_BYTES) {
58
+ dropped.push({ path: resource.path, size: resource.size });
59
+ continue;
60
+ }
61
+ used += resource.size;
62
+ kept.push(resource);
63
+ }
64
+
65
+ if (dropped.length === 0) return skill;
66
+ const budgetMb = Math.round(SKILL_RESOURCE_BUDGET_BYTES / (1024 * 1024));
67
+ // materialize_skill_resource 也要把整份资源读进内存(base64 字符串 → atob 副本 →
68
+ // Uint8Array),放大倍数和挂载路径同量级。把一个单独就超预算的文件推荐给它,只是把
69
+ // OOM 从脚本挪到复制那一步 —— 那种资源这一轮就是拿不到,必须直说。
70
+ const portable = dropped.filter((entry) => entry.size <= SKILL_RESOURCE_BUDGET_BYTES);
71
+ const oversized = dropped.filter((entry) => entry.size > SKILL_RESOURCE_BUDGET_BYTES);
72
+ const lines = [
73
+ `> ${dropped.length} bundled resource(s) exceed the ${budgetMb} MB ` +
74
+ "script-mount budget and are NOT available to run_skill_script.",
75
+ ];
76
+ if (portable.length > 0) {
77
+ lines.push(
78
+ `> If any of these resources are needed, copy all required ones into the Workspace ` +
79
+ `in one execute with tools.materialize_skill_resource: ` +
80
+ `${portable.map((entry) => entry.path).join(", ")}.`,
81
+ );
82
+ }
83
+ if (oversized.length > 0) {
84
+ lines.push(
85
+ `> These are individually larger than ${budgetMb} MB and cannot be loaded at ` +
86
+ `all in this session — do not try: ${oversized.map((entry) => entry.path).join(", ")}.`,
87
+ );
88
+ }
89
+ return { ...skill, resources: kept, body: `${skill.body}\n\n${lines.join("\n")}` };
90
+ }
91
+
92
+ export function catalogSkillSource(
93
+ binding: PiSkillBinding,
94
+ transformSkillBody: RuntimeSkillBodyTransform | undefined,
95
+ availableToolNames: readonly string[],
96
+ ): SkillSource {
97
+ const source = binding.source;
98
+ return {
99
+ id: source.id,
100
+ get fingerprint() {
101
+ return source.fingerprint;
102
+ },
103
+ // Runtime configuration already resolved the catalogue; Skill content stays lazy.
104
+ list: async () => [{
105
+ name: binding.name,
106
+ description: binding.description,
107
+ sourceId: source.id,
108
+ }],
109
+ load: async (name) => {
110
+ const skill = await source.load(name);
111
+ if (!skill) return null;
112
+ const prepared = transformSkillBody
113
+ ? {
114
+ ...skill,
115
+ body: transformSkillBody({
116
+ name: skill.name,
117
+ body: skill.body,
118
+ availableToolNames,
119
+ }),
120
+ }
121
+ : skill;
122
+ const loaded = budgetSkillResources(prepared);
123
+ const hasOversizedResource = skill.resources?.some((resource) =>
124
+ typeof resource.size === "number" &&
125
+ resource.size > SKILL_RESOURCE_BUDGET_BYTES
126
+ );
127
+ const guidance = hasOversizedResource
128
+ ? SKILL_RESOURCE_READ_GUIDANCE
129
+ : `${SKILL_RESOURCE_READ_GUIDANCE} ${SKILL_RESOURCE_MATERIALIZE_GUIDANCE}`;
130
+ return loaded.resources?.length
131
+ ? {
132
+ ...loaded,
133
+ body: `${loaded.body}\n\n${guidance}`,
134
+ }
135
+ : loaded;
136
+ },
137
+ ...(source.readResource
138
+ ? {
139
+ readResource: (name: string, path: string) =>
140
+ source.readResource!(name, path)
141
+ }
142
+ : {}),
143
+ ...(source.refresh ? { refresh: () => source.refresh!() } : {}),
144
+ };
145
+ }
146
+
147
+ const SKILL_ENTRY_READ_GUIDANCE =
148
+ "SKILL.md contains the Skill instructions; use activate_skill instead.";
149
+
150
+ function resourceTarget(
151
+ bindings: readonly PiSkillBinding[],
152
+ input: unknown,
153
+ ): { binding: PiSkillBinding; path: string } | null {
154
+ if (input === null || typeof input !== "object") return null;
155
+ const target = input as { name?: unknown; path?: unknown };
156
+ if (typeof target.path !== "string") return null;
157
+ if (typeof target.name === "string") {
158
+ const binding = bindings.find(({ name }) => name === target.name);
159
+ return binding ? { binding, path: target.path } : null;
160
+ }
161
+ const [name, ...rest] = target.path.split("/");
162
+ const binding = bindings.find((candidate) => candidate.name === name);
163
+ return binding && rest.length > 0
164
+ ? { binding, path: rest.join("/") }
165
+ : null;
166
+ }
167
+
168
+ function materializationRequired(name: string, path: string, bytes?: number) {
169
+ return result({
170
+ status: "materialization_required",
171
+ name,
172
+ path,
173
+ ...(bytes === undefined ? {} : { bytes }),
174
+ next: "tools.materialize_skill_resource",
175
+ });
176
+ }
177
+
178
+ function structuredSkillResource(
179
+ details: string,
180
+ target: { binding: PiSkillBinding; path: string },
181
+ ): ReturnType<typeof result> | null {
182
+ const firstNewline = details.indexOf("\n");
183
+ const closing = "\n</skill_resource>";
184
+ if (
185
+ firstNewline < 0 ||
186
+ !details.startsWith("<skill_resource ") ||
187
+ !details.endsWith(closing)
188
+ ) return null;
189
+ const header = details.slice(0, firstNewline);
190
+ const attribute = (name: string) =>
191
+ new RegExp(`${name}="([^"]*)"`, "u").exec(header)?.[1];
192
+ const kind = attribute("kind");
193
+ const encoding = attribute("encoding");
194
+ if (!kind || !encoding) return null;
195
+ const mimeType = attribute("mimeType");
196
+ return result({
197
+ name: target.binding.name,
198
+ path: target.path,
199
+ kind,
200
+ encoding,
201
+ ...(mimeType ? { mimeType } : {}),
202
+ content: details.slice(firstNewline + 1, -closing.length),
203
+ });
204
+ }
205
+
206
+ export function resourceBytes(resource: SkillResource): Uint8Array {
207
+ if ((resource.encoding ?? "text") === "text") {
208
+ return new TextEncoder().encode(resource.content);
209
+ }
210
+ const binary = atob(resource.content);
211
+ const bytes = new Uint8Array(binary.length);
212
+ for (let index = 0;index < binary.length;index++) {
213
+ bytes[index] = binary.charCodeAt(index);
214
+ }
215
+ return bytes;
216
+ }
217
+
218
+ export function materializeSkillResourceTool(
219
+ bindings: readonly PiSkillBinding[],
220
+ workspace: WorkspacePort,
221
+ ) {
222
+ const names = bindings.map(({ name }) => name) as [string, ...string[]];
223
+ const byName = new Map(bindings.map((binding) => [binding.name, binding]));
224
+ return tool({
225
+ description:
226
+ "Copy a complete bundled Skill resource directly into the Workspace without returning its contents to the model. " +
227
+ "Use this instead of read_skill_resource when a template, script, image, font, or other asset must become a Workspace file. " +
228
+ "The destination and its parent directories are created automatically; do not call state.mkdir first. " +
229
+ "When multiple resources are needed, copy them sequentially in one execute; never start one execute per resource. " +
230
+ "The destination is created or overwritten, including parent directories.",
231
+ inputSchema: z.object({
232
+ name: z.enum(names).describe("Activated Skill name"),
233
+ path: z.string().min(1).describe("Bundled resource path listed by activate_skill"),
234
+ destination: z.string().min(1).describe("Absolute destination path in the Workspace"),
235
+ }),
236
+ execute: async ({ name, path, destination }, { abortSignal }) => {
237
+ abortSignal?.throwIfAborted();
238
+ const source = byName.get(name)?.source;
239
+ if (!source?.readResource) {
240
+ throw new Error(`Skill \"${name}\" has no readable resources.`);
241
+ }
242
+ const resource = await source.readResource(name, path);
243
+ if (!resource) throw new Error(`Resource not found: ${name}/${path}`);
244
+ abortSignal?.throwIfAborted();
245
+ const bytes = resourceBytes(resource);
246
+
247
+ await serializeWorkspaceMutation(workspace, destination, async () => {
248
+ abortSignal?.throwIfAborted();
249
+ const parent = destination.replace(/\/[^/]+$/, "");
250
+ if (parent && parent !== "/") {
251
+ await workspace.mkdir(parent, { recursive: true });
252
+ }
253
+ await workspace.writeFileBytes(destination, bytes, resource.mimeType);
254
+ });
255
+
256
+ return {
257
+ name,
258
+ path: resource.path,
259
+ destination,
260
+ bytesWritten: bytes.byteLength,
261
+ encoding: resource.encoding ?? "text",
262
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
263
+ };
264
+ },
265
+ });
266
+ }
267
+
268
+ export function wrapSkillResourceRead(adapted: import("@earendil-works/pi-agent-core").AgentTool<any, unknown>, bindings: readonly PiSkillBinding[]): void {
269
+ const execute = adapted.execute;
270
+ adapted.execute = async (toolCallId, input, signal) => {
271
+ const target = input as { name?: unknown; path?: unknown };
272
+ const parts = typeof target.path === "string"
273
+ ? target.path.split("/")
274
+ : [];
275
+ if (
276
+ target.path === "SKILL.md" ||
277
+ (target.name === undefined &&
278
+ parts.length === 2 &&
279
+ parts[1] === "SKILL.md")
280
+ ) {
281
+ return {
282
+ content: [{ type: "text", text: SKILL_ENTRY_READ_GUIDANCE }],
283
+ details: SKILL_ENTRY_READ_GUIDANCE,
284
+ };
285
+ }
286
+ const resolved = resourceTarget(bindings, input);
287
+ if (resolved) {
288
+ const skill = await resolved.binding.source
289
+ .load(resolved.binding.name)
290
+ .catch(() => null);
291
+ const descriptor = skill?.resources?.find(({ path }) =>
292
+ path === resolved.path
293
+ );
294
+ if (
295
+ typeof descriptor?.size === "number" &&
296
+ descriptor.size > STORAGE_LEAF_MAX_CHARS
297
+ ) {
298
+ return materializationRequired(
299
+ resolved.binding.name,
300
+ resolved.path,
301
+ descriptor.size,
302
+ );
303
+ }
304
+ }
305
+ const output = await execute(toolCallId, input, signal);
306
+ if (
307
+ resolved &&
308
+ typeof output.details === "string" &&
309
+ output.details.length > STORAGE_LEAF_MAX_CHARS
310
+ ) {
311
+ return materializationRequired(
312
+ resolved.binding.name,
313
+ resolved.path,
314
+ );
315
+ }
316
+ if (resolved && typeof output.details === "string") {
317
+ const structured = structuredSkillResource(output.details, resolved);
318
+ if (structured) return structured;
319
+ }
320
+ return output;
321
+ };
322
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ runner as createSkillScriptRunner,
3
+ type SkillScriptRequest,
4
+ type SkillScriptRunner,
5
+ } from "agents/skills";
6
+ import { truncateResponse, truncateResult } from "@cloudflare/codemode";
7
+ import type { WorkspacePort } from "../../config/ports";
8
+ import { piCandidatesToAiTools } from "../adapters/pi-to-ai";
9
+ import { result } from "../runtime/result";
10
+ import { serializeWorkspaceMutation } from "../workspace";
11
+ import type { PiSkillBinding, SkillPiToolOptions } from "./contracts";
12
+ import { resourceBytes } from "./resources";
13
+
14
+ const SKILL_SCRIPT_OUTPUT_ROOT = "/scratch/skill-output";
15
+
16
+ export function scriptRunner(
17
+ bindings: readonly PiSkillBinding[],
18
+ options: SkillPiToolOptions,
19
+ ): SkillScriptRunner | null {
20
+ if (!options.loader || !bindings.some((binding) => binding.script)) {
21
+ return null;
22
+ }
23
+ const byName = new Map(bindings.map((binding) => [binding.name, binding]));
24
+ return {
25
+ async run(request: SkillScriptRequest) {
26
+ const policy = byName.get(request.skill.name)?.script;
27
+ if (!policy) {
28
+ throw new Error(`Skill cannot run scripts: ${request.skill.name}`);
29
+ }
30
+ const workspace = policy.workspace !== "none" && options.workspace
31
+ ? policy.workspace
32
+ : "none";
33
+ let toolCallOrdinal = 0;
34
+ const selected = (options.tools ?? []).filter(({ tool }) =>
35
+ policy.tools.includes(tool.name)
36
+ );
37
+ return createSkillScriptRunner({
38
+ loader: options.loader!,
39
+ network: policy.network === "full",
40
+ workspace,
41
+ ...(workspace !== "none" && options.workspace
42
+ ? { workspaceInstance: options.workspace }
43
+ : {}),
44
+ tools: piCandidatesToAiTools(selected, {
45
+ fallbackToolCallId: (name) =>
46
+ `skill-script:${++toolCallOrdinal}:${name}`,
47
+ }),
48
+ }).run(request);
49
+ },
50
+ };
51
+ }
52
+
53
+ function normalizeRunSkillScriptArguments(input: unknown): unknown {
54
+ if (input === null || typeof input !== "object") return input;
55
+ const value = input as Record<string, unknown>;
56
+ if (typeof value.input !== "string") return input;
57
+ try {
58
+ const decoded = JSON.parse(value.input);
59
+ return decoded !== null && typeof decoded === "object"
60
+ ? { ...value, input: decoded }
61
+ : input;
62
+ } catch {
63
+ return input;
64
+ }
65
+ }
66
+
67
+ type ScriptOutputFile = {
68
+ path?: unknown;
69
+ content?: unknown;
70
+ encoding?: unknown;
71
+ mimeType?: unknown;
72
+ };
73
+
74
+ function scratchSegment(value: string): string {
75
+ const segment = value.replace(/[^a-zA-Z0-9._-]+/g, "_");
76
+ return !segment || segment === "." || segment === ".." ? "_" : segment;
77
+ }
78
+
79
+ function outputRelativePath(value: unknown): string {
80
+ const path = String(value ?? "output.txt").replace(/^\/?output\//, "");
81
+ const parts = path.split("/");
82
+ if (parts.some((part) => !part || part === "." || part === "..")) {
83
+ throw new Error(`Invalid Skill output path: ${path}`);
84
+ }
85
+ return parts.join("/");
86
+ }
87
+
88
+ async function projectSkillScriptResult(
89
+ details: unknown,
90
+ input: unknown,
91
+ toolCallId: string,
92
+ workspace: WorkspacePort | undefined,
93
+ signal: AbortSignal | undefined,
94
+ ): Promise<unknown> {
95
+ const record = details !== null && typeof details === "object"
96
+ ? details as Record<string, unknown>
97
+ : null;
98
+ const outputFiles = Array.isArray(record?.outputFiles)
99
+ ? record.outputFiles as ScriptOutputFile[]
100
+ : [];
101
+ const target = input as { name?: unknown };
102
+ const outputs: Array<{ path: string; bytes: number; mediaType?: string }> = [];
103
+ if (outputFiles.length > 0 && !workspace) {
104
+ throw new Error("Skill output files require Workspace access");
105
+ }
106
+ for (const file of outputFiles) {
107
+ signal?.throwIfAborted();
108
+ const relative = outputRelativePath(file.path);
109
+ const destination = `${SKILL_SCRIPT_OUTPUT_ROOT}/${scratchSegment(String(target.name ?? "skill"))}/${scratchSegment(toolCallId)}/${relative}`;
110
+ const content = String(file.content ?? "");
111
+ const binary = file.encoding === "base64";
112
+ const mediaType = typeof file.mimeType === "string"
113
+ ? file.mimeType
114
+ : binary ? "application/octet-stream" : "text/plain";
115
+ let bytes = new Blob([content]).size;
116
+ await serializeWorkspaceMutation(workspace!, destination, async () => {
117
+ const parent = destination.replace(/\/[^/]+$/, "");
118
+ await workspace!.mkdir(parent, { recursive: true });
119
+ if (!binary) {
120
+ await workspace!.writeFile(destination, content, mediaType);
121
+ return;
122
+ }
123
+ const decoded = resourceBytes({
124
+ path: relative,
125
+ kind: "file",
126
+ encoding: "base64",
127
+ content,
128
+ });
129
+ bytes = decoded.byteLength;
130
+ await workspace!.writeFileBytes(destination, decoded, mediaType);
131
+ });
132
+ outputs.push({ path: destination, bytes, mediaType });
133
+ }
134
+ const scriptResult = record && "result" in record
135
+ ? record.result
136
+ : record && ("outputFiles" in record || "logs" in record)
137
+ ? Object.fromEntries(Object.entries(record).filter(([key]) =>
138
+ key !== "outputFiles" && key !== "logs"
139
+ ))
140
+ : details;
141
+ const logs = Array.isArray(record?.logs)
142
+ ? record.logs.map(String)
143
+ : [];
144
+ return {
145
+ result: truncateResult(scriptResult),
146
+ ...(logs.length > 0
147
+ ? { logs: [truncateResponse(logs.join("\n"))] }
148
+ : {}),
149
+ ...(outputs.length > 0 ? { outputs } : {}),
150
+ };
151
+ }
152
+
153
+ export function wrapSkillScript(adapted: import("@earendil-works/pi-agent-core").AgentTool<any, unknown>, workspace: WorkspacePort | undefined): void {
154
+ adapted.description +=
155
+ " Large artifacts must be written directly to Workspace, not returned or logged; scratch outputs are materialized to Workspace and returned only as file references.";
156
+ adapted.prepareArguments = normalizeRunSkillScriptArguments;
157
+ const execute = adapted.execute;
158
+ adapted.execute = async (toolCallId, input, signal) => {
159
+ const output = await execute(toolCallId, input, signal);
160
+ return result(await projectSkillScriptResult(
161
+ output.details,
162
+ input,
163
+ toolCallId,
164
+ workspace,
165
+ signal,
166
+ ));
167
+ };
168
+ }
@@ -47,10 +47,10 @@ export interface RuntimeSubmissionAdmissionInput {
47
47
  export type RuntimeSubmissionAdmissionResult =
48
48
  | { readonly accepted: true }
49
49
  | {
50
- readonly accepted: false;
51
- readonly code: string;
52
- readonly retryable?: boolean;
53
- };
50
+ readonly accepted: false;
51
+ readonly code: string;
52
+ readonly retryable?: boolean;
53
+ };
54
54
 
55
55
  export type RuntimeSubmissionAdmissionHook = (
56
56
  input: RuntimeSubmissionAdmissionInput,
@@ -58,36 +58,36 @@ export type RuntimeSubmissionAdmissionHook = (
58
58
 
59
59
  export type RuntimeLifecycleFact =
60
60
  | {
61
- readonly kind: "submission-admitted";
62
- readonly eventId: string;
63
- readonly submissionId: string;
64
- readonly runId: string;
65
- }
61
+ readonly kind: "submission-admitted";
62
+ readonly eventId: string;
63
+ readonly submissionId: string;
64
+ readonly runId: string;
65
+ }
66
66
  | {
67
- readonly kind: "submission-terminal";
68
- readonly eventId: string;
69
- readonly submissionId: string;
70
- readonly runId: string;
71
- readonly status: "completed" | "aborted" | "skipped" | "error";
72
- readonly childRegistrationClosed: true;
73
- readonly zeroUsage: boolean;
74
- }
67
+ readonly kind: "submission-terminal";
68
+ readonly eventId: string;
69
+ readonly submissionId: string;
70
+ readonly runId: string;
71
+ readonly status: "completed" | "aborted" | "skipped" | "error";
72
+ readonly childRegistrationClosed: true;
73
+ readonly zeroUsage: boolean;
74
+ }
75
75
  | {
76
- readonly kind: "subagent-run-registered";
77
- readonly eventId: string;
78
- readonly submissionId: string;
79
- readonly subagentRunId: string;
80
- readonly parentRunId: string;
81
- }
76
+ readonly kind: "subagent-run-registered";
77
+ readonly eventId: string;
78
+ readonly submissionId: string;
79
+ readonly subagentRunId: string;
80
+ readonly parentRunId: string;
81
+ }
82
82
  | {
83
- readonly kind: "subagent-terminal";
84
- readonly eventId: string;
85
- readonly submissionId: string;
86
- readonly subagentRunId: string;
87
- readonly parentRunId: string;
88
- readonly status: "completed" | "failed" | "aborted" | "skipped";
89
- readonly zeroUsage: boolean;
90
- };
83
+ readonly kind: "subagent-terminal";
84
+ readonly eventId: string;
85
+ readonly submissionId: string;
86
+ readonly subagentRunId: string;
87
+ readonly parentRunId: string;
88
+ readonly status: "completed" | "failed" | "aborted" | "skipped";
89
+ readonly zeroUsage: boolean;
90
+ };
91
91
 
92
92
  export interface RuntimeEventConfirmation {
93
93
  readonly confirmed: true;