@springbrand/agent-runtime 0.2.0-alpha.42 → 0.2.0-alpha.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,13 @@
1
1
  import {
2
2
  createWorkspaceStateBackend,
3
+ type StateBackend,
3
4
  type WorkspaceFsLike,
4
5
  } from "@cloudflare/shell";
6
+ import {
7
+ truncateResponse,
8
+ truncateResult,
9
+ type ProxyToolOutput,
10
+ } from "@cloudflare/codemode";
5
11
  import { createBrowserTools } from "@cloudflare/think/tools/browser";
6
12
  import { createExecuteRuntime } from "@cloudflare/think/tools/execute";
7
13
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
@@ -20,41 +26,77 @@ import { piCandidatesToAiTools } from "./nested-tools";
20
26
  // 本文件沿用 `../../index.ts` 入口定义的 Workspace、Port 和 Tool Candidate 术语。
21
27
 
22
28
  const CODEMODE_SANDBOX_TIMEOUT_MS = 55_000;
29
+ export type WorkspaceAccessMode = "none" | "read" | "write";
30
+ const WRITE_STATE_METHODS = new Set([
31
+ "writeFile",
32
+ "writeFileBytes",
33
+ "appendFile",
34
+ "writeJson",
35
+ "updateJson",
36
+ "mkdir",
37
+ "replaceInFile",
38
+ "replaceInFiles",
39
+ "rm",
40
+ "cp",
41
+ "mv",
42
+ "symlink",
43
+ "createArchive",
44
+ "extractArchive",
45
+ "compressFile",
46
+ "decompressFile",
47
+ "removeTree",
48
+ "copyTree",
49
+ "moveTree",
50
+ "planEdits",
51
+ "applyEditPlan",
52
+ "applyEdits",
53
+ ]);
23
54
 
24
- function directoryEntry(name: string, label: string): string {
25
- const singleLineLabel = label.replaceAll(/\s+/g, " ").trim().replaceAll("`", "'");
26
- return `- \`${name}\` — ${singleLineLabel}`;
55
+ export function workspaceStateBackend(
56
+ workspace: WorkspacePort,
57
+ mode: WorkspaceAccessMode,
58
+ ): StateBackend | undefined {
59
+ if (mode === "none") return undefined;
60
+ const backend = createWorkspaceStateBackend(
61
+ workspace as unknown as WorkspaceFsLike,
62
+ );
63
+ return new Proxy(backend, {
64
+ get(target, property, receiver) {
65
+ if (property === "writeFileBytes") return undefined;
66
+ if (
67
+ mode === "read" &&
68
+ typeof property === "string" &&
69
+ WRITE_STATE_METHODS.has(property)
70
+ ) {
71
+ return async () => {
72
+ throw new Error("Workspace state is read-only for this execution");
73
+ };
74
+ }
75
+ const value = Reflect.get(target, property, receiver);
76
+ return typeof value === "function" ? value.bind(target) : value;
77
+ },
78
+ }) as StateBackend;
27
79
  }
28
80
 
29
- function codeExecutionDescription(
30
- candidates: readonly PiToolCandidate[],
31
- ): string {
32
- const list = (
33
- entries: readonly { readonly name: string; readonly label: string }[],
34
- ) => entries.length > 0
35
- ? entries.map(({ name, label }) => directoryEntry(name, label)).join("\n")
36
- : "- None.";
81
+ const SPRINGBRAND_CODE_MODE_POLICY = `## SpringBrand Workspace and Asset Policy
37
82
 
38
- return [
39
- "Execute plain JavaScript in a sandbox using the exact Tool directory below.",
40
- "",
41
- "## `tools.*` Available",
42
- list(candidates.map(({ tool }) => ({
43
- name: tool.name,
44
- label: tool.label ?? tool.name,
45
- }))),
46
- "",
47
- "Call only the methods listed above through `tools.*`; never guess or construct a method name.",
48
- "Use `codemode.describe(\"tools.method\")` when you need the exact input type for a listed method.",
49
- "`codemode.search` cannot add methods to `tools.*` or load top-level Tools; it searches only connector methods and snippets already installed in this Code Mode Runtime.",
50
- "Use `state.*` for the Workspace filesystem. Every method takes one object argument, for example `state.readFile({ path })` and `state.writeFile({ path, content })`.",
51
- "Wrap raw fetch, random values, time, and other nondeterministic work in `codemode.step(name, fn)` so replay runs them once.",
52
- "Some connector methods pause for approval and resume automatically. Do not re-issue paused code.",
53
- "Keep all code outside connector calls and `codemode.step` deterministic.",
54
- "Raw `fetch` is available inside `codemode.step(...)`; prefer connector SDKs when one owns the target.",
55
- "There is no Node.js `require`, `process`, package manager, or Python runtime.",
56
- ].join("\n");
57
- }
83
+ Use state.* for every Workspace filesystem operation. Workspace file methods do not exist under tools.*.
84
+
85
+ state.writeFileBytes is not available. Use state.writeFile or state.appendFile for sandbox-created text.
86
+
87
+ Bundled Skill resources are not Workspace files. Read text with tools.read_skill_resource, run bundled scripts with tools.run_skill_script, and copy templates or assets with tools.materialize_skill_resource.
88
+
89
+ User Attachments are not Workspace files until copied with tools.materialize_attachment.
90
+
91
+ Never create or write data URLs, base64 image strings, or embedded binary payloads in Code Mode.
92
+
93
+ Copy a remote asset into Workspace with tools.materialize_url({ url, destination }). Raw fetch remains available. Do not carry large responses, binary payloads, data URLs, or base64 assets through Code Mode.
94
+
95
+ After materialization, reference the Workspace file by a relative path. Website HTML and CSS must reference relative Workspace assets, never temporary external URLs or data URLs.
96
+
97
+ Keep large content out of the execute result. Write it to Workspace and return only path, mediaType, bytes, summary, and warnings.
98
+
99
+ This execute tool cannot ask the user, inspect a rendered page, capture a screenshot, save or publish a Creation, or authorize an external action. Use the corresponding top-level Tool for those operations.`;
58
100
 
59
101
  /**
60
102
  * 宿主的 Browser Rendering 绑定,只取本仓真正用到的那一面。
@@ -130,6 +172,42 @@ function codeExecutionResult(
130
172
  };
131
173
  }
132
174
 
175
+ function projectCodeModeResult(details: unknown): unknown {
176
+ if (typeof details !== "object" || details === null) {
177
+ return { status: "error", executionId: "unknown", error: truncateResponse(String(details)) };
178
+ }
179
+ const output = details as ProxyToolOutput;
180
+ const calls = Array.isArray(output.calls) ? output.calls : [];
181
+ const callSummary = calls.length > 0
182
+ ? {
183
+ total: calls.length,
184
+ failed: calls.filter(({ state }) => state === "error").length,
185
+ methods: [...new Set(calls.map(({ method }) => method))]
186
+ .map((method) => truncateResponse(method, { maxChars: 256 }))
187
+ .slice(0, 100),
188
+ }
189
+ : undefined;
190
+ const common = {
191
+ status: output.status,
192
+ executionId: output.executionId,
193
+ ...(callSummary ? { callSummary } : {}),
194
+ };
195
+ if (output.status === "completed") {
196
+ return { ...common, result: truncateResult(output.result) };
197
+ }
198
+ if (output.status === "paused") {
199
+ return {
200
+ ...common,
201
+ pending: output.pending.slice(0, 100).map(({ connector, method, seq }) => ({
202
+ connector: truncateResponse(connector, { maxChars: 256 }),
203
+ method: truncateResponse(method, { maxChars: 256 }),
204
+ seq,
205
+ })),
206
+ };
207
+ }
208
+ return { ...common, error: truncateResponse(output.error) };
209
+ }
210
+
133
211
  // 上游把 Code Mode 类工具交付为 AI SDK tool;本仓只取 description 和 execute 两件,
134
212
  // 并在这里就地校验,使装配期缺件立刻失败,而不是等模型调用时才炸。
135
213
  function toCodeExecutionPort(
@@ -246,35 +324,42 @@ export function createWorkspaceCodeExecutionFactory(options: {
246
324
  readonly loader: WorkerLoader;
247
325
  readonly outbound: Fetcher;
248
326
  readonly workspace: WorkspacePort;
327
+ readonly workspaceAccessMode: WorkspaceAccessMode;
249
328
  }): RuntimeCodeExecutionFactory {
250
329
  return {
251
330
  create(candidates) {
252
- const description = codeExecutionDescription(candidates);
331
+ const state = workspaceStateBackend(
332
+ options.workspace,
333
+ options.workspaceAccessMode,
334
+ );
335
+ const createPort = (innerResults: AgentToolResult<unknown>[]) => {
336
+ let nestedCallOrdinal = 0;
337
+ const { tool } = createExecuteRuntime({
338
+ ctx: options.ctx,
339
+ loader: options.loader,
340
+ globalOutbound: options.outbound,
341
+ tools: piCandidatesToAiTools(candidates, {
342
+ fallbackToolCallId: (name) =>
343
+ `codemode:${++nestedCallOrdinal}:${name}`,
344
+ onResult: (value) => innerResults.push(value),
345
+ }),
346
+ timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
347
+ ...(state ? { state } : {}),
348
+ name: "execute",
349
+ });
350
+ return toCodeExecutionPort(
351
+ tool,
352
+ "execute",
353
+ (details) => codeExecutionResult(
354
+ projectCodeModeResult(details),
355
+ innerResults,
356
+ ),
357
+ );
358
+ };
359
+ const description = createPort([]).description;
253
360
  return {
254
- description,
255
- async execute(input) {
256
- const innerResults: AgentToolResult<unknown>[] = [];
257
- const { tool } = createExecuteRuntime({
258
- ctx: options.ctx,
259
- loader: options.loader,
260
- globalOutbound: options.outbound,
261
- tools: piCandidatesToAiTools(candidates, {
262
- onResult: (value) => innerResults.push(value),
263
- }),
264
- description,
265
- // 先于外层 60s 截止结束,给 Runtime RPC 结算和 Worker 释放留出时间。
266
- timeout: CODEMODE_SANDBOX_TIMEOUT_MS,
267
- state: createWorkspaceStateBackend(
268
- options.workspace as unknown as WorkspaceFsLike,
269
- ),
270
- name: "execute",
271
- });
272
- return toCodeExecutionPort(
273
- tool,
274
- "execute",
275
- (details) => codeExecutionResult(details, innerResults),
276
- ).execute(input);
277
- },
361
+ description: `${description}\n\n${SPRINGBRAND_CODE_MODE_POLICY}`,
362
+ execute: (input) => createPort([]).execute(input),
278
363
  };
279
364
  },
280
365
  };
@@ -165,7 +165,7 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
165
165
 
166
166
  // #region Extension discovery
167
167
 
168
- const noParameters = Type.Object({});
168
+ const noParameters = Type.Object({}, { additionalProperties: false });
169
169
 
170
170
  /**
171
171
  * 为当前已加载 Extension 列表创建一个 Pi 工具候选项。
@@ -207,10 +207,11 @@ export function listExtensionsPiToolCandidate(
207
207
  const executeParameters = Type.Object({
208
208
  code: Type.String({
209
209
  minLength: 1,
210
+ maxLength: 131_072,
210
211
  description:
211
212
  "Plain JavaScript async function. TypeScript annotations are not supported.",
212
213
  }),
213
- });
214
+ }, { additionalProperties: false });
214
215
  const CODEMODE_EXECUTE_TIMEOUT_MS = 60_000;
215
216
 
216
217
  // 把模型提供的代码交给 Codemode Runtime 执行,并在外层再压一道截止。
@@ -49,7 +49,7 @@ export interface PiDeclaredToolPolicy<Reply = unknown> {
49
49
  readonly modelName: string;
50
50
  readonly requiredExecutionLevel: PiToolCandidate["requiredExecutionLevel"];
51
51
  readonly requiredExecutionLevelForInput?: PiToolCandidate["requiredExecutionLevelForInput"];
52
- readonly direct?: true;
52
+ readonly exposureMode?: PiToolCandidate["exposureMode"];
53
53
  readonly retry?: PiToolCandidate["retry"];
54
54
  readonly source?: PiToolCandidate["source"];
55
55
  readonly summary?: string;
@@ -105,6 +105,7 @@ export function createPiDeclaredToolCandidate<Reply = unknown>(
105
105
  parameters: structuredClone(
106
106
  declared.inputSchema ?? { type: "object", additionalProperties: true },
107
107
  ) as AgentTool["parameters"],
108
+ constrainedSampling: false,
108
109
  execute: async (toolCallId, input, signal) =>
109
110
  project(await call(input as Record<string, unknown>, { toolCallId, signal })),
110
111
  };
@@ -116,7 +117,7 @@ export function createPiDeclaredToolCandidate<Reply = unknown>(
116
117
  ...(policy.requiredExecutionLevelForInput
117
118
  ? { requiredExecutionLevelForInput: policy.requiredExecutionLevelForInput }
118
119
  : {}),
119
- ...(policy.direct ? { direct: policy.direct } : {}),
120
+ ...(policy.exposureMode ? { exposureMode: policy.exposureMode } : {}),
120
121
  ...(policy.retry ? { retry: policy.retry } : {}),
121
122
  ...(policy.source ? { source: policy.source } : {}),
122
123
  };
@@ -35,7 +35,11 @@ export function piCandidatesToAiTools(
35
35
  call?.abortSignal ?? new AbortController().signal,
36
36
  );
37
37
  options.onResult?.(result);
38
- return result;
38
+ if (result.details !== undefined) return result.details;
39
+ const [only] = result.content;
40
+ return result.content.length === 1 && only?.type === "text"
41
+ ? only.text
42
+ : result.content;
39
43
  },
40
44
  }),
41
45
  ]));
@@ -18,28 +18,28 @@ const scheduleTriggerParameters = Type.Union([
18
18
  minimum: 1,
19
19
  description: "Fire once, this many seconds from now.",
20
20
  }),
21
- }),
21
+ }, { additionalProperties: false }),
22
22
  Type.Object({
23
23
  kind: Type.Literal("once-at"),
24
24
  at: Type.String({
25
25
  description:
26
26
  "Fire once at this absolute UTC ISO time, e.g. 2026-07-20T09:00:00Z.",
27
27
  }),
28
- }),
28
+ }, { additionalProperties: false }),
29
29
  Type.Object({
30
30
  kind: Type.Literal("cron"),
31
31
  cron: Type.String({
32
32
  description:
33
33
  "Repeat on a standard UTC cron, e.g. '0 9 * * *' = daily 09:00 UTC.",
34
34
  }),
35
- }),
35
+ }, { additionalProperties: false }),
36
36
  Type.Object({
37
37
  kind: Type.Literal("interval"),
38
38
  seconds: Type.Integer({
39
39
  minimum: 1,
40
40
  description: "Repeat every this many seconds.",
41
41
  }),
42
- }),
42
+ }, { additionalProperties: false }),
43
43
  ], { description: "When to fire." });
44
44
 
45
45
  const scheduleParameters = Type.Object({
@@ -56,21 +56,21 @@ const scheduleParameters = Type.Object({
56
56
  description:
57
57
  "IANA timezone the clock times in `trigger` should be interpreted in, e.g. 'Asia/Shanghai'. Pass the user's own timezone whenever they state a wall-clock time. Omit only for delays and intervals. Defaults to UTC.",
58
58
  })),
59
- });
59
+ }, { additionalProperties: false });
60
60
 
61
- const noParameters = Type.Object({});
61
+ const noParameters = Type.Object({}, { additionalProperties: false });
62
62
  const scheduleIdParameters = Type.Object({
63
63
  id: Type.String({
64
64
  description: "The schedule id, as returned by `list_schedules`.",
65
65
  }),
66
- });
66
+ }, { additionalProperties: false });
67
67
  const updateScheduleParameters = Type.Object({
68
68
  id: Type.String({ description: "The exact id returned by `list_schedules`." }),
69
69
  prompt: Type.Optional(Type.String()),
70
70
  trigger: Type.Optional(scheduleTriggerParameters),
71
71
  label: Type.Optional(Type.String()),
72
72
  timezone: Type.Optional(Type.String()),
73
- });
73
+ }, { additionalProperties: false });
74
74
  const changeScheduleAgentParameters = Type.Object({
75
75
  id: Type.String({
76
76
  description: "The exact schedule id returned by `list_schedules`.",
@@ -78,7 +78,7 @@ const changeScheduleAgentParameters = Type.Object({
78
78
  userAgentId: Type.String({
79
79
  description: "The exact User Agent id selected by the user.",
80
80
  }),
81
- });
81
+ }, { additionalProperties: false });
82
82
 
83
83
  function result<T>(details: T): AgentToolResult<T> {
84
84
  return {
@@ -92,19 +92,19 @@ function candidate<T extends TSchema>(
92
92
  options: Partial<
93
93
  Pick<
94
94
  PiToolCandidate,
95
- "alwaysRequiresApproval" | "direct" | "owner" | "requiredExecutionLevel" | "summary"
95
+ "alwaysRequiresApproval" | "exposureMode" | "owner" | "requiredExecutionLevel" | "summary"
96
96
  >
97
97
  > = {},
98
98
  ): PiToolCandidate {
99
99
  return {
100
100
  owner: options.owner ?? "runtime-base",
101
+ exposureMode: options.exposureMode ?? "direct",
101
102
  requiredExecutionLevel: options.requiredExecutionLevel ?? "safe",
102
103
  source: "action",
103
104
  tool,
104
105
  ...(options.alwaysRequiresApproval
105
106
  ? { alwaysRequiresApproval: true }
106
107
  : {}),
107
- ...(options.direct ? { direct: true } : {}),
108
108
  ...(options.summary ? { summary: options.summary } : {}),
109
109
  };
110
110
  }
@@ -134,7 +134,7 @@ export function schedulePiToolCandidates(
134
134
  },
135
135
  {
136
136
  alwaysRequiresApproval: true,
137
- direct: true,
137
+ exposureMode: "direct",
138
138
  summary: "Create a scheduled task",
139
139
  },
140
140
  ),
@@ -6,12 +6,15 @@ import {
6
6
  type SkillScriptRunner,
7
7
  type SkillSource,
8
8
  } from "agents/skills";
9
+ import { truncateResponse, truncateResult } from "@cloudflare/codemode";
9
10
  import { tool } from "ai";
10
11
  import { z } from "zod";
11
12
  import type {
12
13
  RuntimeSkillScriptPolicy,
13
14
  WorkspacePort,
14
15
  } from "../../kernel/bindings";
16
+ import { STORAGE_LEAF_MAX_CHARS } from "../../layers/context/budget/gate";
17
+ import { serializeOutput } from "../../lib/artifacts";
15
18
  import { aiToolToPi } from "./ai-adapter";
16
19
  import type { PiToolCandidate } from "./compiler";
17
20
  import { piCandidatesToAiTools } from "./nested-tools";
@@ -48,9 +51,10 @@ type LoadedSkill = NonNullable<Awaited<ReturnType<SkillSource["load"]>>>;
48
51
 
49
52
  const SKILL_RESOURCE_READ_GUIDANCE =
50
53
  "Bundled Skill resources are not Workspace files. " +
51
- "Use read_skill_resource for text instead of read, bash, or find on /skills.";
54
+ "Inside execute, use tools.read_skill_resource for text instead of state.* on /skills.";
52
55
  const SKILL_RESOURCE_MATERIALIZE_GUIDANCE =
53
- "Use materialize_skill_resource for Workspace assets when that Tool is available.";
56
+ "Inside execute, use tools.materialize_skill_resource for Workspace assets when available.";
57
+ const SKILL_SCRIPT_OUTPUT_ROOT = "/scratch/skill-output";
54
58
 
55
59
  /**
56
60
  * 按体积裁掉超预算的 Skill 资源,并把裁掉的事实写回 Skill 正文。
@@ -227,6 +231,127 @@ function normalizeRunSkillScriptArguments(input: unknown): unknown {
227
231
  }
228
232
  }
229
233
 
234
+ function result(details: unknown) {
235
+ return {
236
+ content: [{ type: "text" as const, text: serializeOutput(details).text }],
237
+ details,
238
+ };
239
+ }
240
+
241
+ function resourceTarget(
242
+ bindings: readonly PiSkillBinding[],
243
+ input: unknown,
244
+ ): { binding: PiSkillBinding; path: string } | null {
245
+ if (input === null || typeof input !== "object") return null;
246
+ const target = input as { name?: unknown; path?: unknown };
247
+ if (typeof target.path !== "string") return null;
248
+ if (typeof target.name === "string") {
249
+ const binding = bindings.find(({ name }) => name === target.name);
250
+ return binding ? { binding, path: target.path } : null;
251
+ }
252
+ const [name, ...rest] = target.path.split("/");
253
+ const binding = bindings.find((candidate) => candidate.name === name);
254
+ return binding && rest.length > 0
255
+ ? { binding, path: rest.join("/") }
256
+ : null;
257
+ }
258
+
259
+ function materializationRequired(name: string, path: string, bytes?: number) {
260
+ return result({
261
+ status: "materialization_required",
262
+ name,
263
+ path,
264
+ ...(bytes === undefined ? {} : { bytes }),
265
+ next: "tools.materialize_skill_resource",
266
+ });
267
+ }
268
+
269
+ type ScriptOutputFile = {
270
+ path?: unknown;
271
+ content?: unknown;
272
+ encoding?: unknown;
273
+ mimeType?: unknown;
274
+ };
275
+
276
+ function scratchSegment(value: string): string {
277
+ const segment = value.replace(/[^a-zA-Z0-9._-]+/g, "_");
278
+ return !segment || segment === "." || segment === ".." ? "_" : segment;
279
+ }
280
+
281
+ function outputRelativePath(value: unknown): string {
282
+ const path = String(value ?? "output.txt").replace(/^\/?output\//, "");
283
+ const parts = path.split("/");
284
+ if (parts.some((part) => !part || part === "." || part === "..")) {
285
+ throw new Error(`Invalid Skill output path: ${path}`);
286
+ }
287
+ return parts.join("/");
288
+ }
289
+
290
+ async function projectSkillScriptResult(
291
+ details: unknown,
292
+ input: unknown,
293
+ toolCallId: string,
294
+ workspace: WorkspacePort | undefined,
295
+ signal: AbortSignal | undefined,
296
+ ): Promise<unknown> {
297
+ const record = details !== null && typeof details === "object"
298
+ ? details as Record<string, unknown>
299
+ : null;
300
+ const outputFiles = Array.isArray(record?.outputFiles)
301
+ ? record.outputFiles as ScriptOutputFile[]
302
+ : [];
303
+ const target = input as { name?: unknown };
304
+ const outputs: Array<{ path: string; bytes: number; mediaType?: string }> = [];
305
+ if (outputFiles.length > 0 && !workspace) {
306
+ throw new Error("Skill output files require Workspace access");
307
+ }
308
+ for (const file of outputFiles) {
309
+ signal?.throwIfAborted();
310
+ const relative = outputRelativePath(file.path);
311
+ const destination = `${SKILL_SCRIPT_OUTPUT_ROOT}/${scratchSegment(String(target.name ?? "skill"))}/${scratchSegment(toolCallId)}/${relative}`;
312
+ const content = String(file.content ?? "");
313
+ const binary = file.encoding === "base64";
314
+ const mediaType = typeof file.mimeType === "string"
315
+ ? file.mimeType
316
+ : binary ? "application/octet-stream" : "text/plain";
317
+ let bytes = new Blob([content]).size;
318
+ await serializeWorkspaceMutation(workspace!, destination, async () => {
319
+ const parent = destination.replace(/\/[^/]+$/, "");
320
+ await workspace!.mkdir(parent, { recursive: true });
321
+ if (!binary) {
322
+ await workspace!.writeFile(destination, content, mediaType);
323
+ return;
324
+ }
325
+ const decoded = resourceBytes({
326
+ path: relative,
327
+ kind: "file",
328
+ encoding: "base64",
329
+ content,
330
+ });
331
+ bytes = decoded.byteLength;
332
+ await workspace!.writeFileBytes(destination, decoded, mediaType);
333
+ });
334
+ outputs.push({ path: destination, bytes, mediaType });
335
+ }
336
+ const scriptResult = record && "result" in record
337
+ ? record.result
338
+ : record && ("outputFiles" in record || "logs" in record)
339
+ ? Object.fromEntries(Object.entries(record).filter(([key]) =>
340
+ key !== "outputFiles" && key !== "logs"
341
+ ))
342
+ : details;
343
+ const logs = Array.isArray(record?.logs)
344
+ ? record.logs.map(String)
345
+ : [];
346
+ return {
347
+ result: truncateResult(scriptResult),
348
+ ...(logs.length > 0
349
+ ? { logs: [truncateResponse(logs.join("\n"))] }
350
+ : {}),
351
+ ...(outputs.length > 0 ? { outputs } : {}),
352
+ };
353
+ }
354
+
230
355
  function resourceBytes(resource: SkillResource): Uint8Array {
231
356
  if ((resource.encoding ?? "text") === "text") {
232
357
  return new TextEncoder().encode(resource.content);
@@ -249,7 +374,7 @@ function materializeSkillResourceTool(
249
374
  description:
250
375
  "Copy a complete bundled Skill resource directly into the Workspace without returning its contents to the model. " +
251
376
  "Use this instead of read_skill_resource when a template, script, image, font, or other asset must become a Workspace file. " +
252
- "When multiple resources are needed, copy them in one execute with a loop or Promise.all; never start one execute per resource. " +
377
+ "When multiple resources are needed, copy them sequentially in one execute; never start one execute per resource. " +
253
378
  "The destination is created or overwritten, including parent directories.",
254
379
  inputSchema: z.object({
255
380
  name: z.enum(names).describe("Activated Skill name"),
@@ -332,7 +457,7 @@ export async function skillPiToolCandidates(
332
457
  }
333
458
  if (name === "read_skill_resource") {
334
459
  const execute = adapted.execute;
335
- adapted.execute = (toolCallId, input, signal) => {
460
+ adapted.execute = async (toolCallId, input, signal) => {
336
461
  const target = input as { name?: unknown; path?: unknown };
337
462
  const parts = typeof target.path === "string"
338
463
  ? target.path.split("/")
@@ -343,32 +468,72 @@ export async function skillPiToolCandidates(
343
468
  parts.length === 2 &&
344
469
  parts[1] === "SKILL.md")
345
470
  ) {
346
- return Promise.resolve({
471
+ return {
347
472
  content: [{ type: "text", text: SKILL_ENTRY_READ_GUIDANCE }],
348
473
  details: SKILL_ENTRY_READ_GUIDANCE,
349
- });
474
+ };
350
475
  }
351
- return execute(toolCallId, input, signal);
476
+ const resolved = resourceTarget(bindings, input);
477
+ if (resolved) {
478
+ const skill = await resolved.binding.source
479
+ .load(resolved.binding.name)
480
+ .catch(() => null);
481
+ const descriptor = skill?.resources?.find(({ path }) =>
482
+ path === resolved.path
483
+ );
484
+ if (
485
+ typeof descriptor?.size === "number" &&
486
+ descriptor.size > STORAGE_LEAF_MAX_CHARS
487
+ ) {
488
+ return materializationRequired(
489
+ resolved.binding.name,
490
+ resolved.path,
491
+ descriptor.size,
492
+ );
493
+ }
494
+ }
495
+ const output = await execute(toolCallId, input, signal);
496
+ if (
497
+ resolved &&
498
+ typeof output.details === "string" &&
499
+ output.details.length > STORAGE_LEAF_MAX_CHARS
500
+ ) {
501
+ return materializationRequired(
502
+ resolved.binding.name,
503
+ resolved.path,
504
+ );
505
+ }
506
+ return output;
352
507
  };
353
508
  }
354
509
  if (name === "run_skill_script") {
510
+ adapted.description +=
511
+ " Large artifacts must be written directly to Workspace, not returned or logged; scratch outputs are materialized to Workspace and returned only as file references.";
355
512
  adapted.prepareArguments = normalizeRunSkillScriptArguments;
513
+ const execute = adapted.execute;
514
+ adapted.execute = async (toolCallId, input, signal) => {
515
+ const output = await execute(toolCallId, input, signal);
516
+ return result(await projectSkillScriptResult(
517
+ output.details,
518
+ input,
519
+ toolCallId,
520
+ options.workspace,
521
+ signal,
522
+ ));
523
+ };
356
524
  }
357
525
  return {
358
526
  owner: "runtime-skill",
359
- requiredExecutionLevel:
360
- name === "run_skill_script" || name === "materialize_skill_resource"
361
- ? "high"
362
- : "safe",
527
+ requiredExecutionLevel: "safe",
363
528
  // activate_skill 返回的是 Skill 指令本身,是模型接下来所有动作的依据。把它外置
364
529
  // 成文件,模型手里就只剩一个路径,必须再取一次才能知道该做什么 —— 而典型
365
530
  // SKILL.md 正好落在会触发外置的区间。指令必须当场到手。
366
531
  ...(name === "activate_skill"
367
532
  ? { outputBudget: { kind: "structure" as const } }
368
533
  : {}),
369
- ...(name === "materialize_skill_resource"
370
- ? { codeExecutionOnly: true as const }
371
- : {}),
534
+ ...(name === "activate_skill"
535
+ ? { exposureMode: "direct" as const }
536
+ : { exposureMode: "codemode" as const }),
372
537
  tool: adapted,
373
538
  };
374
539
  });
@@ -180,6 +180,7 @@ export function subagentPiToolCandidates(
180
180
  };
181
181
  return {
182
182
  owner: `subagent:${type.name}`,
183
+ exposureMode: "direct",
183
184
  requiredExecutionLevel: "safe",
184
185
  tool,
185
186
  };
@@ -262,6 +263,7 @@ export function subagentPiToolCandidates(
262
263
  };
263
264
  candidates.push({
264
265
  owner: "subagent:background",
266
+ exposureMode: "direct",
265
267
  requiredExecutionLevel: "low",
266
268
  summary: "Dispatch a background sub-agent",
267
269
  source: "action",
@@ -4,7 +4,7 @@ import type { PiToolCandidate } from "./compiler";
4
4
 
5
5
  export const GET_TIME_TOOL_NAME = "get_time";
6
6
 
7
- const parameters = Type.Object({});
7
+ const parameters = Type.Object({}, { additionalProperties: false });
8
8
 
9
9
  /** Product-neutral current-time Tool for Host registries. */
10
10
  export function getTimePiToolCandidate(owner = "utilities"): PiToolCandidate {