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

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.
@@ -46,6 +46,12 @@ const SKILL_RESOURCE_BUDGET_BYTES = 8 * 1024 * 1024;
46
46
 
47
47
  type LoadedSkill = NonNullable<Awaited<ReturnType<SkillSource["load"]>>>;
48
48
 
49
+ const SKILL_RESOURCE_READ_GUIDANCE =
50
+ "Bundled Skill resources are not Workspace files. " +
51
+ "Use read_skill_resource for text instead of read, bash, or find on /skills.";
52
+ const SKILL_RESOURCE_MATERIALIZE_GUIDANCE =
53
+ "Use materialize_skill_resource for Workspace assets when that Tool is available.";
54
+
49
55
  /**
50
56
  * 按体积裁掉超预算的 Skill 资源,并把裁掉的事实写回 Skill 正文。
51
57
  *
@@ -93,7 +99,7 @@ function budgetSkillResources(skill: LoadedSkill): LoadedSkill {
93
99
  if (portable.length > 0) {
94
100
  lines.push(
95
101
  `> If any of these resources are needed, copy all required ones into the Workspace ` +
96
- `in one materialize_skill_resource call using its resources array: ` +
102
+ `in one execute with tools.materialize_skill_resource: ` +
97
103
  `${portable.map((entry) => entry.path).join(", ")}.`,
98
104
  );
99
105
  }
@@ -121,7 +127,21 @@ function catalogSkillSource(binding: PiSkillBinding): SkillSource {
121
127
  }],
122
128
  load: async (name) => {
123
129
  const skill = await source.load(name);
124
- return skill && budgetSkillResources(skill);
130
+ if (!skill) return null;
131
+ const loaded = budgetSkillResources(skill);
132
+ const hasOversizedResource = skill.resources?.some((resource) =>
133
+ typeof resource.size === "number" &&
134
+ resource.size > SKILL_RESOURCE_BUDGET_BYTES
135
+ );
136
+ const guidance = hasOversizedResource
137
+ ? SKILL_RESOURCE_READ_GUIDANCE
138
+ : `${SKILL_RESOURCE_READ_GUIDANCE} ${SKILL_RESOURCE_MATERIALIZE_GUIDANCE}`;
139
+ return loaded.resources?.length
140
+ ? {
141
+ ...loaded,
142
+ body: `${loaded.body}\n\n${guidance}`,
143
+ }
144
+ : loaded;
125
145
  },
126
146
  ...(source.readResource
127
147
  ? { readResource: (name: string, path: string) =>
@@ -193,6 +213,20 @@ const SKILL_TOOL_PARAMETER_DESCRIPTIONS: Readonly<Record<string, Readonly<Record
193
213
  const SKILL_ENTRY_READ_GUIDANCE =
194
214
  "SKILL.md contains the Skill instructions; use activate_skill instead.";
195
215
 
216
+ function normalizeRunSkillScriptArguments(input: unknown): unknown {
217
+ if (input === null || typeof input !== "object") return input;
218
+ const value = input as Record<string, unknown>;
219
+ if (typeof value.input !== "string") return input;
220
+ try {
221
+ const decoded = JSON.parse(value.input);
222
+ return decoded !== null && typeof decoded === "object"
223
+ ? { ...value, input: decoded }
224
+ : input;
225
+ } catch {
226
+ return input;
227
+ }
228
+ }
229
+
196
230
  function resourceBytes(resource: SkillResource): Uint8Array {
197
231
  if ((resource.encoding ?? "text") === "text") {
198
232
  return new TextEncoder().encode(resource.content);
@@ -211,95 +245,44 @@ function materializeSkillResourceTool(
211
245
  ) {
212
246
  const names = bindings.map(({ name }) => name) as [string, ...string[]];
213
247
  const byName = new Map(bindings.map((binding) => [binding.name, binding]));
214
- const resourceInput = z.object({
215
- name: z.enum(names).describe("Activated Skill name"),
216
- path: z.string().min(1).describe("Bundled resource path listed by activate_skill"),
217
- destination: z.string().min(1).startsWith("/").describe(
218
- "Absolute destination path in the Workspace",
219
- ),
220
- });
221
248
  return tool({
222
249
  description:
223
- "Copy complete bundled Skill resources directly into the Workspace without returning their contents to the model. " +
250
+ "Copy a complete bundled Skill resource directly into the Workspace without returning its contents to the model. " +
224
251
  "Use this instead of read_skill_resource when a template, script, image, font, or other asset must become a Workspace file. " +
225
- "Pass every required item in the resources array; items are copied in order and failures are reported per item. " +
252
+ "When multiple resources are needed, copy them in one execute with a loop or Promise.all; never start one execute per resource. " +
226
253
  "The destination is created or overwritten, including parent directories.",
227
254
  inputSchema: z.object({
228
- resources: z.array(resourceInput).min(1).max(100),
255
+ name: z.enum(names).describe("Activated Skill name"),
256
+ path: z.string().min(1).describe("Bundled resource path listed by activate_skill"),
257
+ destination: z.string().min(1).describe("Absolute destination path in the Workspace"),
229
258
  }),
230
- execute: async ({ resources }, { abortSignal }) => {
259
+ execute: async ({ name, path, destination }, { abortSignal }) => {
231
260
  abortSignal?.throwIfAborted();
232
- const destinations = new Set<string>();
233
- for (const { destination } of resources) {
234
- if (destinations.has(destination)) {
235
- throw new Error(`Duplicate materialize destination: ${destination}`);
236
- }
237
- destinations.add(destination);
261
+ const source = byName.get(name)?.source;
262
+ if (!source?.readResource) {
263
+ throw new Error(`Skill \"${name}\" has no readable resources.`);
238
264
  }
265
+ const resource = await source.readResource(name, path);
266
+ if (!resource) throw new Error(`Resource not found: ${name}/${path}`);
267
+ abortSignal?.throwIfAborted();
268
+ const bytes = resourceBytes(resource);
239
269
 
240
- const results: Array<Record<string, unknown>> = [];
241
- for (const { name, path, destination } of resources) {
270
+ await serializeWorkspaceMutation(workspace, destination, async () => {
242
271
  abortSignal?.throwIfAborted();
243
- const source = byName.get(name)?.source;
244
- if (!source?.readResource) {
245
- results.push({
246
- status: "error",
247
- name,
248
- path,
249
- destination,
250
- error: `Skill \"${name}\" has no readable resources.`,
251
- });
252
- continue;
272
+ const parent = destination.replace(/\/[^/]+$/, "");
273
+ if (parent && parent !== "/") {
274
+ await workspace.mkdir(parent, { recursive: true });
253
275
  }
254
- try {
255
- const resource = await source.readResource(name, path);
256
- if (!resource) {
257
- results.push({
258
- status: "error",
259
- name,
260
- path,
261
- destination,
262
- error: `Resource not found: ${name}/${path}`,
263
- });
264
- continue;
265
- }
266
- abortSignal?.throwIfAborted();
267
- const bytes = resourceBytes(resource);
268
-
269
- await serializeWorkspaceMutation(workspace, destination, async () => {
270
- abortSignal?.throwIfAborted();
271
- const parent = destination.replace(/\/[^/]+$/, "");
272
- if (parent && parent !== "/") {
273
- await workspace.mkdir(parent, { recursive: true });
274
- }
275
- await workspace.writeFileBytes(destination, bytes, resource.mimeType);
276
- });
277
-
278
- results.push({
279
- status: "written",
280
- name,
281
- path: resource.path,
282
- destination,
283
- bytesWritten: bytes.byteLength,
284
- encoding: resource.encoding ?? "text",
285
- ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
286
- });
287
- } catch (error) {
288
- abortSignal?.throwIfAborted();
289
- results.push({
290
- status: "error",
291
- name,
292
- path,
293
- destination,
294
- error: "Resource materialization failed",
295
- });
296
- }
297
- }
276
+ await workspace.writeFileBytes(destination, bytes, resource.mimeType);
277
+ });
298
278
 
299
279
  return {
300
- written: results.filter(({ status }) => status === "written").length,
301
- failed: results.filter(({ status }) => status === "error").length,
302
- results,
280
+ name,
281
+ path: resource.path,
282
+ destination,
283
+ bytesWritten: bytes.byteLength,
284
+ encoding: resource.encoding ?? "text",
285
+ ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
303
286
  };
304
287
  },
305
288
  });
@@ -368,6 +351,9 @@ export async function skillPiToolCandidates(
368
351
  return execute(toolCallId, input, signal);
369
352
  };
370
353
  }
354
+ if (name === "run_skill_script") {
355
+ adapted.prepareArguments = normalizeRunSkillScriptArguments;
356
+ }
371
357
  return {
372
358
  owner: "runtime-skill",
373
359
  requiredExecutionLevel:
@@ -380,6 +366,9 @@ export async function skillPiToolCandidates(
380
366
  ...(name === "activate_skill"
381
367
  ? { outputBudget: { kind: "structure" as const } }
382
368
  : {}),
369
+ ...(name === "materialize_skill_resource"
370
+ ? { codeExecutionOnly: true as const }
371
+ : {}),
383
372
  tool: adapted,
384
373
  };
385
374
  });
@@ -672,6 +672,9 @@ export function defineRuntimeAgent<
672
672
  ...(assembly.bindings?.workspace
673
673
  ? { workspace: assembly.bindings.workspace }
674
674
  : {}),
675
+ ...(assembly.bindings?.codeExecution
676
+ ? { codeExecution: assembly.bindings.codeExecution }
677
+ : {}),
675
678
  },
676
679
  memoryProfile: {
677
680
  enabled: false,
@@ -30,6 +30,7 @@ import {
30
30
  } from "./lib/execution-level";
31
31
  import {
32
32
  createPiRuntimeAssembly,
33
+ type FinalizedPiToolSurface,
33
34
  type PiToolSurface,
34
35
  type PiRuntimeAssembly,
35
36
  } from "./pi/assembly/snapshot";
@@ -43,6 +44,7 @@ import type {
43
44
  RuntimeSkillContribution,
44
45
  RuntimeToolSurfacePolicy,
45
46
  } from "./runtime-definition";
47
+ import type { RuntimeCodeExecutionFactory } from "./kernel/bindings";
46
48
  import {
47
49
  basePiToolCandidates,
48
50
  memoryPiToolCandidate,
@@ -50,6 +52,7 @@ import {
50
52
  import {
51
53
  BROWSER_EXECUTE_TOOL_NAME,
52
54
  browserExecutionPiToolCandidate,
55
+ codeExecutionPiToolCandidate,
53
56
  } from "./pi/tool/core";
54
57
  import { skillPiToolCandidates } from "./pi/tool/skill";
55
58
  import { createWebSearch } from "./pi/tool/web-search";
@@ -80,6 +83,7 @@ export interface RuntimeAssemblyInput {
80
83
  readonly provider: RuntimeProviderPort;
81
84
  readonly platform: RuntimePlatformPort;
82
85
  readonly workspace?: WorkspacePort;
86
+ readonly codeExecution?: RuntimeCodeExecutionFactory;
83
87
  readonly sandbox?: RuntimeSandboxPort;
84
88
  readonly memory?: RuntimeMemoryPort;
85
89
  readonly hostTools: readonly PiToolCandidate[];
@@ -153,6 +157,7 @@ function assertMemoryProfile(profile: RuntimeMemoryProfile): void {
153
157
  }
154
158
 
155
159
  interface ToolSurfaceInput {
160
+ readonly codeExecution?: RuntimeCodeExecutionFactory;
156
161
  readonly platform: RuntimePlatformPort;
157
162
  readonly workspace?: WorkspacePort;
158
163
  readonly memory?: {
@@ -198,10 +203,15 @@ async function createToolSurface(
198
203
  });
199
204
  // 平台没有浏览器能力时注册空集:宁可没有这个 Tool,也不注册一个必然失败的 Tool 误导模型。
200
205
  // `create()` 推迟到确认这个名字真的可见之后,被 deny 的装配不白建一个浏览器连接器。
206
+ // `direct` 使它只走 Direct 调用,不再被并进 `execute` 的工具集——两个 Code Mode 类工具互相嵌套
207
+ // 会让「哪次执行被记录、被重放」变得无法解释,而它们的分工本就由 System Prompt 划清。
201
208
  const browserVisible = !deny.has(BROWSER_EXECUTE_TOOL_NAME) &&
202
209
  allowsTool?.(BROWSER_EXECUTE_TOOL_NAME) !== false;
203
210
  const browserCandidates = input.platform.browser && browserVisible
204
- ? [browserExecutionPiToolCandidate(input.platform.browser.create())]
211
+ ? [{
212
+ ...browserExecutionPiToolCandidate(input.platform.browser.create()),
213
+ direct: true as const,
214
+ }]
205
215
  : [];
206
216
  const baseCandidates = basePiToolCandidates(input.webSearch);
207
217
  const memoryCandidates = input.memory
@@ -238,16 +248,87 @@ async function createToolSurface(
238
248
  if (!EXECUTION_LEVELS.includes(candidate.requiredExecutionLevel)) {
239
249
  throw new Error(`SpringBrand Tool ${name} execution level is invalid`);
240
250
  }
241
- if (name === "execute") {
242
- throw new Error("SpringBrand reserved Runtime Tool name: execute");
243
- }
244
251
  if (tools.has(name)) {
245
252
  throw new Error(`Duplicate Runtime SpringBrand Tool: ${name}`);
246
253
  }
247
- tools.set(name, classify(candidate));
254
+ tools.set(name, Object.freeze({ ...candidate }));
248
255
  }
249
256
 
250
- return Object.freeze([...tools.values()]);
257
+ const finalized = [...tools.values()];
258
+ const directVisible = finalized.filter(
259
+ (candidate) => !candidate.codeExecutionOnly,
260
+ );
261
+ if (tools.has("execute")) {
262
+ throw new Error("SpringBrand reserved Runtime Tool name: execute");
263
+ }
264
+ if (
265
+ !input.codeExecution ||
266
+ deny.has("execute") ||
267
+ allowsTool?.("execute") === false
268
+ ) {
269
+ return Object.freeze({
270
+ candidates: Object.freeze(directVisible.map(classify)),
271
+ codeExecutionCandidates: Object.freeze([]),
272
+ }) satisfies FinalizedPiToolSurface;
273
+ }
274
+ const mergeable = finalized.filter(
275
+ (candidate) =>
276
+ !candidate.direct &&
277
+ !candidate.interaction &&
278
+ typeof candidate.tool.execute === "function",
279
+ );
280
+ const codeExecutionCandidates = Object.freeze([...mergeable]);
281
+ const codeExecutionToolNames = new Set(
282
+ codeExecutionCandidates.map(({ tool }) => tool.name),
283
+ );
284
+ const codeExecution = input.codeExecution.create(
285
+ codeExecutionCandidates,
286
+ );
287
+ const topLevelOnly = directVisible.filter(
288
+ ({ tool }) => !codeExecutionToolNames.has(tool.name),
289
+ );
290
+ const routeList = (deferred: boolean) => {
291
+ const matches = topLevelOnly.filter((candidate) =>
292
+ (defersTool?.(candidate.tool.name) === true) === deferred
293
+ );
294
+ return matches.length > 0
295
+ ? matches.map(({ tool }) =>
296
+ `- \`${tool.name}\` — ${(tool.label ?? tool.name)
297
+ .replaceAll(/\s+/g, " ").trim().replaceAll("`", "'")}`
298
+ ).join("\n")
299
+ : "- None.";
300
+ };
301
+ const routeGuidance = [
302
+ "## Tool routing",
303
+ "Top-level deferred Tools are not callable through `tools.*`. Use Provider Tool Search with their exact name, then call them directly.",
304
+ "Top-level immediate Tools are already available outside execute; call them directly.",
305
+ "",
306
+ "## Top-level deferred Tools",
307
+ routeList(true),
308
+ "",
309
+ "## Top-level immediate Tools",
310
+ routeList(false),
311
+ "",
312
+ ].join("\n");
313
+ const materializeGuidance = codeExecutionCandidates.some(
314
+ ({ tool }) => tool.name === "materialize_skill_resource",
315
+ )
316
+ ? "Skill resource copying is available only inside execute through " +
317
+ "`tools.materialize_skill_resource({ name, path, destination })`; " +
318
+ "no Direct Tool is registered for it. " +
319
+ "Use a loop or Promise.all when copying multiple resources.\n\n"
320
+ : "";
321
+ return Object.freeze({
322
+ candidates: Object.freeze([
323
+ classify(codeExecutionPiToolCandidate({
324
+ ...codeExecution,
325
+ description:
326
+ routeGuidance + "\n" + materializeGuidance + codeExecution.description,
327
+ })),
328
+ ...directVisible.map(classify),
329
+ ]),
330
+ codeExecutionCandidates,
331
+ }) satisfies FinalizedPiToolSurface;
251
332
  },
252
333
  }),
253
334
  extensions: input.extensions.filter(
@@ -275,6 +356,7 @@ class RuntimeBuilder {
275
356
  private platform?: RuntimePlatformPort;
276
357
  private gateway?: RuntimeGatewayPort;
277
358
  private workspace?: WorkspacePort;
359
+ private codeExecution?: RuntimeCodeExecutionFactory;
278
360
  private sandbox?: RuntimeSandboxPort;
279
361
  private schedule?: RuntimeSchedulePort;
280
362
  private memoryProfile: RuntimeMemoryProfile = DISABLED_MEMORY;
@@ -310,6 +392,7 @@ class RuntimeBuilder {
310
392
  this.provider = input.provider;
311
393
  this.platform = input.platform;
312
394
  this.workspace = input.workspace;
395
+ this.codeExecution = input.codeExecution;
313
396
  this.sandbox = input.sandbox;
314
397
  this.memoryProfile = input.memoryProfile;
315
398
  this.memory = input.memory;
@@ -449,6 +532,9 @@ class RuntimeBuilder {
449
532
  if (!this.platform.loader) {
450
533
  throw new Error("Runtime platform loader is required");
451
534
  }
535
+ if (typeof this.platform.outbound !== "function") {
536
+ throw new Error("Runtime platform outbound port is required");
537
+ }
452
538
 
453
539
  assertMemoryProfile(this.memoryProfile);
454
540
  if (this.memoryProfile.enabled && (!this.workspace || !this.memory)) {
@@ -498,6 +584,7 @@ class RuntimeBuilder {
498
584
  });
499
585
  const skillSources = [...this.skillSources.values()];
500
586
  const toolSurface = await createToolSurface({
587
+ ...(this.codeExecution ? { codeExecution: this.codeExecution } : {}),
501
588
  platform: this.platform,
502
589
  ...(this.workspace ? { workspace: this.workspace } : {}),
503
590
  ...(this.memoryProfile.enabled && this.memory
@@ -522,6 +609,7 @@ class RuntimeBuilder {
522
609
  const bindings: RuntimeBindings = Object.freeze({
523
610
  provider,
524
611
  platform: Object.freeze({ ...this.platform }),
612
+ ...(this.codeExecution ? { codeExecution: this.codeExecution } : {}),
525
613
  ...(this.gateway ? { gateway: this.gateway } : {}),
526
614
  ...(this.workspace ? { workspace: this.workspace } : {}),
527
615
  ...(this.memoryProfile.enabled && this.memory
@@ -666,6 +754,9 @@ export async function assembleRuntimeSnapshot<
666
754
  ...(toolAssembly.bindings?.workspace
667
755
  ? { workspace: toolAssembly.bindings.workspace }
668
756
  : {}),
757
+ ...(toolAssembly.bindings?.codeExecution
758
+ ? { codeExecution: toolAssembly.bindings.codeExecution }
759
+ : {}),
669
760
  ...(memoryProfile.enabled && toolAssembly.bindings?.memory
670
761
  ? { memory: toolAssembly.bindings.memory }
671
762
  : {}),
@@ -33,6 +33,7 @@ export interface ToolSurfaceSelectionPolicy {
33
33
  /** Kernel 绑定仍需要的执行后端;不出现在 Definition 公开类型里。 */
34
34
  export interface RuntimeToolBindings {
35
35
  readonly workspace?: import("./kernel/bindings").WorkspacePort;
36
+ readonly codeExecution?: import("./kernel/bindings").RuntimeCodeExecutionFactory;
36
37
  readonly memory?: import("./kernel/bindings").RuntimeMemoryPort;
37
38
  readonly subagents?: import("./kernel/bindings").RuntimeSubagentPort;
38
39
  }
package/src/runtime.ts CHANGED
@@ -975,6 +975,28 @@ export abstract class AgentRuntimeKernel<
975
975
  occurredAt: event.timestamp,
976
976
  });
977
977
  },
978
+ onNestedToolStarted: (event) => {
979
+ this.telemetry.capture("toolStarted", {
980
+ submissionId: submission.submissionId,
981
+ ...event,
982
+ });
983
+ },
984
+ onNestedToolFinished: (event) => {
985
+ this.telemetry.capture("toolFinished", {
986
+ submissionId: submission.submissionId,
987
+ parentToolCallId: event.parentToolCallId,
988
+ toolCallId: event.toolCallId,
989
+ toolName: event.toolName,
990
+ outcome: event.outcome,
991
+ durationMs: event.durationMs,
992
+ ...(event.output ? {
993
+ output: event.output,
994
+ outputBytes: json(event.output).length,
995
+ } : {}),
996
+ ...(event.error ? { error: errorText(event.error) } : {}),
997
+ occurredAt: event.occurredAt,
998
+ });
999
+ },
978
1000
  ...(toolExecutors && Object.keys(toolExecutors).length > 0
979
1001
  ? { toolExecutors }
980
1002
  : {}),