@springbrand/agent-runtime 0.1.3-alpha.0 → 0.1.3-alpha.2

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,455 +1,147 @@
1
- import type {
2
- AgentTool,
3
- AgentToolResult,
4
- } from "@earendil-works/pi-agent-core";
5
- import { Type } from "@earendil-works/pi-ai";
6
1
  import {
7
2
  runner as createSkillScriptRunner,
8
- type SkillContent,
9
- type SkillResource,
3
+ SkillRegistry,
4
+ type SkillScriptRequest,
5
+ type SkillScriptRunner,
10
6
  type SkillSource,
11
7
  } from "agents/skills";
12
- import type { RuntimeSkillScriptPolicy } from "../../kernel/bindings";
8
+ import { jsonSchema, type ToolSet } from "ai";
9
+ import type {
10
+ RuntimeSkillScriptPolicy,
11
+ WorkspacePort,
12
+ } from "../../kernel/bindings";
13
+ import { aiToolToPi } from "./ai-adapter";
13
14
  import type { PiToolCandidate } from "./compiler";
14
15
 
15
- /**
16
- * 一个已经过 Host 授权、可以交给 Pi 使用的 Skill 绑定。
17
- *
18
- * Worker 的 Skill Plugin 负责创建它;调用方应让 `name`、`source` 和脚本权限
19
- * 来自同一份 Runtime 配置,避免工具目录与实际读取权限脱节。
20
- *
21
- * `Host`、`Pi` 与 `Runtime` 等核心术语见 `../../index.ts`。
22
- */
16
+ /** An authorized Skill source and its script capabilities. */
23
17
  export interface PiSkillBinding {
24
- /** Runtime 配置中启用的唯一 Skill 名称。 */
25
18
  readonly name: string;
26
- /** 按名称延迟加载说明和资源的 Agents SDK Skill 来源。 */
27
19
  readonly source: SkillSource;
28
- /**
29
- * Isolated execution policy for this Skill's bundled scripts. Without it the
30
- * binding contributes no `run_skill_script` capability.
31
- */
32
20
  readonly script?: RuntimeSkillScriptPolicy;
33
21
  }
34
22
 
35
- /** 创建 Pi Skill 工具时可选的 Host 能力。 */
23
+ /** Host capabilities available while creating Skill tools. */
36
24
  export interface SkillPiToolOptions {
37
- /**
38
- * Worker Loader used to sandbox Skill scripts. Omit it and
39
- * `run_skill_script` is not contributed at all, which keeps the read-only
40
- * Skill surface available to hosts that cannot run isolates.
41
- */
42
25
  readonly loader?: WorkerLoader;
26
+ readonly workspace?: WorkspacePort;
27
+ readonly tools?: readonly PiToolCandidate[];
43
28
  }
44
29
 
45
- const activateParameters = Type.Object({
46
- name: Type.String({
47
- minLength: 1,
48
- maxLength: 128,
49
- description: "Name from the AUTHORIZED SKILLS catalog.",
50
- }),
51
- });
52
-
53
- const readResourceParameters = Type.Object({
54
- name: Type.String({
55
- minLength: 1,
56
- maxLength: 128,
57
- description: "Name from the AUTHORIZED SKILLS catalog.",
58
- }),
59
- path: Type.String({
60
- minLength: 1,
61
- maxLength: 4_096,
62
- description:
63
- "Normalized relative resource path listed by activate_skill.",
64
- }),
65
- });
66
-
67
- const runScriptParameters = Type.Object({
68
- name: Type.String({
69
- minLength: 1,
70
- maxLength: 128,
71
- description: "Name from the AUTHORIZED SKILLS catalog.",
72
- }),
73
- path: Type.String({
74
- minLength: 1,
75
- maxLength: 4_096,
76
- description:
77
- 'Script resource path under "scripts/" as listed by activate_skill.',
78
- }),
79
- input: Type.Optional(
80
- Type.Unknown({ description: "JSON input passed to the script." }),
81
- ),
82
- });
83
-
84
- // 只接受已安装 Agents SDK runner 能识别的脚本扩展名。
85
- const SCRIPT_EXTENSIONS = new Set([
86
- ".js",
87
- ".mjs",
88
- ".ts",
89
- ".tsx",
90
- ".py",
91
- ".sh",
92
- ".bash",
93
- ]);
94
-
95
- // 作用:按模型给出的名称找到已启用的 Skill 绑定。
96
- // 调用:激活、读资源和运行脚本三个工具在访问 SkillSource 前调用。
97
- // 原因:先在本地授权列表中查找可以默认拒绝未知名称,
98
- // 不能让模型直接枚举或访问任意来源。
99
- function bindingFor(
100
- bindings: readonly PiSkillBinding[],
101
- name: string,
102
- ): PiSkillBinding {
103
- const binding = bindings.find((candidate) => candidate.name === name);
104
- if (!binding) throw new Error(`Skill is not enabled: ${name}`);
105
- return binding;
106
- }
107
-
108
- // 作用:确认 Skill 资源使用规范化的相对路径。
109
- // 调用:读取资源前后,以及脚本路径校验的第一步调用。
110
- // 原因:同时检查请求路径和 Source 回传路径,
111
- // 防止绝对路径、空段和父目录跳转越过 Skill 边界。
112
- function validateResourcePath(path: string): void {
113
- if (
114
- path.startsWith("/") ||
115
- path.includes("\0") ||
116
- path.split("/").some(
117
- (part) => part === "" || part === "." || part === "..",
118
- )
119
- ) {
120
- throw new Error(
121
- `Skill resource path must be a normalized relative path: ${path}`,
122
- );
123
- }
124
- }
125
-
126
- // 作用:确认资源路径确实指向 runner 支持的 scripts 文件。
127
- // 调用:运行脚本前分别校验模型请求和 Source 回传的路径。
128
- // 原因:Agents SDK 的脚本执行是显式开启的能力,
129
- // 只允许当前 runner 已支持的 JavaScript、TypeScript、Python 和 Bash 扩展名。
130
- function validateScriptPath(path: string): void {
131
- validateResourcePath(path);
132
- if (!path.startsWith("scripts/")) {
133
- throw new Error(
134
- `Skill script path must start with "scripts/": ${path}`,
135
- );
136
- }
137
- const dot = path.lastIndexOf(".");
138
- const extension = dot === -1 ? "" : path.slice(dot).toLowerCase();
139
- if (!SCRIPT_EXTENSIONS.has(extension)) {
140
- throw new Error(
141
- `Unsupported skill script extension "${
142
- extension || "(none)"
143
- }" for ${path}`,
144
- );
145
- }
146
- }
147
-
148
- // 作用:转义写入 Skill 结果标签属性的文本。
149
- // 调用:activationResult、resourceResult 和脚本结果包装名称与路径时调用。
150
- // 原因:这些值来自配置或资源描述,
151
- // 必须避免引号和尖括号破坏供模型扫读的标签边界。
152
- function attribute(value: string): string {
153
- return value
154
- .replaceAll("&", "&")
155
- .replaceAll('"', """)
156
- .replaceAll("<", "&lt;")
157
- .replaceAll(">", "&gt;");
158
- }
159
-
160
- // 作用:把 Skill 的资源清单整理成人能直接扫读的列表。
161
- // 调用:activationResult 返回 Skill 正文时调用。
162
- // 原因:激活阶段只公布描述信息,不提前读取每个资源,
163
- // 保持 Agents SDK 的按需加载模型。
164
- function resourceList(skill: SkillContent): string {
165
- if (!skill.resources?.length) return "No bundled resources.";
166
- return skill.resources.map((resource) => {
167
- const encoding = resource.encoding ?? "text";
168
- const size = resource.size === undefined
169
- ? ""
170
- : `, ${resource.size} bytes`;
171
- const mime = resource.mimeType ? `, ${resource.mimeType}` : "";
172
- return `- ${resource.path} (${resource.kind}, ${encoding}${mime}${size})`;
173
- }).join("\n");
30
+ function scriptTools(
31
+ candidates: readonly PiToolCandidate[],
32
+ names: readonly string[],
33
+ ): ToolSet {
34
+ const byName = new Map(
35
+ candidates.map((candidate) => [candidate.tool.name, candidate.tool]),
36
+ );
37
+ return Object.fromEntries(names.flatMap((name) => {
38
+ const candidate = byName.get(name);
39
+ if (!candidate) return [];
40
+ return [[name, {
41
+ description: candidate.description,
42
+ inputSchema: jsonSchema(candidate.parameters as never),
43
+ execute: async (input: unknown) =>
44
+ (await candidate.execute(`skill-script:${name}`, input)).details,
45
+ }]];
46
+ })) as ToolSet;
174
47
  }
175
48
 
176
- // 作用:把已加载的 Skill 说明和资源清单包装成 Pi 工具结果。
177
- // 调用:activate_skill 成功加载且核对名称后调用。
178
- // 原因:正文给模型阅读,结构化 details 留给事件投影;
179
- // 两者共用同一 Skill 快照以免版本错位。
180
- function activationResult(
181
- skill: SkillContent,
182
- ): AgentToolResult<unknown> {
183
- const version = skill.version
184
- ? ` version="${attribute(skill.version)}"`
185
- : "";
49
+ // A binding authorizes one configured name even if its backing source happens
50
+ // to contain more Skills. Source failures are also sanitized at this boundary.
51
+ function authorizedSource(binding: PiSkillBinding): SkillSource {
52
+ const { name, source } = binding;
186
53
  return {
187
- content: [{
188
- type: "text",
189
- text: [
190
- `<skill_content name="${attribute(skill.name)}"${version}>`,
191
- skill.body.trim(),
192
- "</skill_content>",
193
- "",
194
- "Bundled resources:",
195
- resourceList(skill),
196
- ].join("\n"),
197
- }],
198
- details: {
199
- name: skill.name,
200
- ...(skill.version ? { version: skill.version } : {}),
201
- resources: skill.resources ?? [],
54
+ id: `${source.id}:${name}`,
55
+ get fingerprint() {
56
+ return `${source.fingerprint}:${name}`;
202
57
  },
203
- };
204
- }
205
-
206
- // 作用:把一个已读取的 Skill 资源包装成 Pi 工具结果。
207
- // 调用:read_skill_resource 完成路径和来源核对后调用。
208
- // 原因:显式保留种类、编码和 MIME 等元数据,
209
- // 避免模型从正文猜测资源格式。
210
- function resourceResult(
211
- name: string,
212
- resource: SkillResource,
213
- ): AgentToolResult<unknown> {
214
- const encoding = resource.encoding ?? "text";
215
- const mime = resource.mimeType
216
- ? ` mimeType="${attribute(resource.mimeType)}"`
217
- : "";
218
- return {
219
- content: [{
220
- type: "text",
221
- text: [
222
- `<skill_resource name="${attribute(name)}" path="${
223
- attribute(resource.path)
224
- }" kind="${resource.kind}" encoding="${encoding}"${mime}>`,
225
- resource.content,
226
- "</skill_resource>",
227
- ].join("\n"),
228
- }],
229
- details: {
230
- name,
231
- path: resource.path,
232
- kind: resource.kind,
233
- encoding,
234
- ...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
235
- ...(resource.size === undefined
236
- ? {}
237
- : { size: resource.size }),
58
+ async list() {
59
+ return (await source.list()).filter((skill) => skill.name === name);
238
60
  },
239
- };
240
- }
241
-
242
- /**
243
- * 为已启用的 Skill 创建按需加载说明、资源和可选脚本的 Pi 候选工具。
244
- *
245
- * Worker 的 Skill Plugin 在贡献 Runtime 能力时调用它。调用方只传入已授权绑定;
246
- * 没有绑定时不公开工具,没有 Worker Loader 或脚本策略时不公开脚本执行工具。
247
- *
248
- * 这里的 “Skill” 是按需加载的任务说明包,“Source” 负责列出和读取包,“资源”
249
- * 是包内文件,“runner” 是 Agents SDK 借助 Worker Loader 创建的隔离脚本执行器。
250
- * 激活只读取说明和资源清单,资源正文继续按路径读取,避免把整个 Skill 库常驻
251
- * 系统上下文。脚本执行仍是显式、按绑定授权的高风险能力,不能降级成普通读取。
252
- * Agents SDK 目前仍把 Skill 脚本标为 experimental,升级依赖时要重新核对 runner
253
- * 的选项和请求结构,不能把当前形状当成稳定协议。
254
- *
255
- * `Host`、`Pi`、`Runtime Snapshot` 与“候选工具”见 `../../index.ts`。
256
- */
257
- export function skillPiToolCandidates(
258
- bindings: readonly PiSkillBinding[],
259
- options: SkillPiToolOptions = {},
260
- ): PiToolCandidate[] {
261
- if (bindings.length === 0) return [];
262
-
263
- const activate: AgentTool<typeof activateParameters> = {
264
- name: "activate_skill",
265
- label: "Activate Skill",
266
- description:
267
- "Load the instructions and bundled-resource inventory for an enabled Skill. Use this when the user's task matches an AUTHORIZED SKILLS entry.",
268
- parameters: activateParameters,
269
- // 作用:按名称加载一个已启用 Skill 的说明和资源清单。
270
- // 调用:Pi 在任务命中系统上下文里的 Skill 摘要后调用。
271
- // 原因:先经过本地绑定授权,再隐藏 Source 原始异常,
272
- // 防止私有存储细节进入模型错误消息。
273
- async execute(_toolCallId, { name }, signal) {
274
- signal?.throwIfAborted();
275
- const binding = bindingFor(bindings, name);
276
- let skill: SkillContent | null;
61
+ async load(requested) {
62
+ if (requested !== name) return null;
277
63
  try {
278
- skill = await binding.source.load(name);
64
+ const skill = await source.load(requested);
65
+ return skill?.name === name ? skill : null;
279
66
  } catch (cause) {
280
67
  throw new Error(`Skill unavailable: ${name}`, { cause });
281
68
  }
282
- if (!skill || skill.name !== name) {
283
- throw new Error(`Skill not found: ${name}`);
284
- }
285
- return activationResult(skill);
286
69
  },
70
+ ...(source.readResource
71
+ ? {
72
+ async readResource(requested: string, path: string) {
73
+ if (requested !== name) return null;
74
+ try {
75
+ const resource = await source.readResource!(requested, path);
76
+ return resource?.path === path ? resource : null;
77
+ } catch (cause) {
78
+ throw new Error(
79
+ `Skill resource unavailable: ${name}/${path}`,
80
+ { cause },
81
+ );
82
+ }
83
+ },
84
+ }
85
+ : {}),
86
+ ...(source.refresh ? { refresh: () => source.refresh!() } : {}),
287
87
  };
88
+ }
288
89
 
289
- const read: AgentTool<typeof readResourceParameters> = {
290
- name: "read_skill_resource",
291
- label: "Read Skill resource",
292
- description:
293
- "Read one bundled file from an enabled Skill by the relative path returned by activate_skill.",
294
- parameters: readResourceParameters,
295
- // 作用:读取 activate_skill 清单中指定的一个 Skill 资源。
296
- // 调用:Pi 需要模板、参考资料或其他包内文件时调用。
297
- // 原因:请求和返回路径都重新校验,并把来源异常收口,
298
- // 防止越界读取或泄漏底层凭据。
299
- async execute(_toolCallId, { name, path }, signal) {
300
- signal?.throwIfAborted();
301
- validateResourcePath(path);
302
- const binding = bindingFor(bindings, name);
303
- if (!binding.source.readResource) {
304
- throw new Error(`Skill has no readable resources: ${name}`);
305
- }
306
- let resource: SkillResource | null;
307
- try {
308
- resource = await binding.source.readResource(name, path);
309
- } catch (cause) {
310
- throw new Error(
311
- `Skill resource unavailable: ${name}/${path}`,
312
- { cause },
313
- );
314
- }
315
- if (!resource || resource.path !== path) {
316
- throw new Error(`Skill resource not found: ${name}/${path}`);
90
+ function scriptRunner(
91
+ bindings: readonly PiSkillBinding[],
92
+ options: SkillPiToolOptions,
93
+ ): SkillScriptRunner | null {
94
+ if (!options.loader || !bindings.some((binding) => binding.script)) {
95
+ return null;
96
+ }
97
+ const byName = new Map(bindings.map((binding) => [binding.name, binding]));
98
+ return {
99
+ async run(request: SkillScriptRequest) {
100
+ const policy = byName.get(request.skill.name)?.script;
101
+ if (!policy) {
102
+ throw new Error(`Skill cannot run scripts: ${request.skill.name}`);
317
103
  }
318
- validateResourcePath(resource.path);
319
- return resourceResult(name, resource);
104
+ const workspace = policy.workspace !== "none" && options.workspace
105
+ ? policy.workspace
106
+ : "none";
107
+ return createSkillScriptRunner({
108
+ loader: options.loader!,
109
+ network: policy.network === "full",
110
+ workspace,
111
+ ...(workspace !== "none" && options.workspace
112
+ ? { workspaceInstance: options.workspace }
113
+ : {}),
114
+ tools: scriptTools(options.tools ?? [], policy.tools),
115
+ }).run(request);
320
116
  },
321
117
  };
118
+ }
322
119
 
323
- const candidates: PiToolCandidate[] = [
324
- {
325
- owner: "runtime-skill",
326
- authorized: true,
327
- requiredExecutionLevel: "safe",
328
- tool: activate,
329
- },
330
- {
331
- owner: "runtime-skill",
332
- authorized: true,
333
- requiredExecutionLevel: "safe",
334
- tool: read,
335
- },
336
- ];
337
-
338
- // Script execution needs both an isolate loader and at least one binding that
339
- // declares a script policy. The upstream runner enforces the capability gates;
340
- // this wrapper owns argument validation and throws on failure so the shared
341
- // Tool governance keeps counting real errors.
342
- const loader = options.loader;
343
- const scriptable = bindings.some((binding) => binding.script);
344
- if (loader && scriptable) {
345
- const runScript: AgentTool<typeof runScriptParameters> = {
346
- name: "run_skill_script",
347
- label: "Run Skill script",
348
- description:
349
- "Run a bundled script from an enabled Skill. Use this only when the Skill's instructions tell you to run it.",
350
- parameters: runScriptParameters,
351
- // 作用:把一个已授权 Skill 自带的脚本交给 Agents SDK 隔离 runner 执行。
352
- // 调用:Pi 只在 Skill 说明明确要求执行脚本时调用。
353
- // 原因:加载器、绑定策略、资源描述和实际资源缺一不可,
354
- // 逐层复核可避免把普通文件或未授权能力交给 runner。
355
- async execute(_toolCallId, { name, path, input }, signal) {
356
- signal?.throwIfAborted();
357
- validateScriptPath(path);
358
- const binding = bindingFor(bindings, name);
359
- const policy = binding.script;
360
- if (!policy) {
361
- throw new Error(`Skill cannot run scripts: ${name}`);
362
- }
363
- if (!binding.source.readResource) {
364
- throw new Error(`Skill has no readable resources: ${name}`);
365
- }
366
-
367
- let skill: SkillContent | null;
368
- try {
369
- skill = await binding.source.load(name);
370
- } catch (cause) {
371
- throw new Error(`Skill unavailable: ${name}`, { cause });
372
- }
373
- if (!skill || skill.name !== name) {
374
- throw new Error(`Skill not found: ${name}`);
375
- }
376
-
377
- const descriptor = skill.resources?.find(
378
- (resource) => resource.path === path,
379
- );
380
- if (!descriptor) {
381
- throw new Error(`Skill script not found: ${name}/${path}`);
382
- }
383
- if (descriptor.kind !== "script") {
384
- throw new Error(
385
- `Skill resource is not a script: ${name}/${path}`,
386
- );
387
- }
388
-
389
- let resource: SkillResource | null;
390
- try {
391
- resource = await binding.source.readResource(name, path);
392
- } catch (cause) {
393
- throw new Error(
394
- `Skill script unavailable: ${name}/${path}`,
395
- { cause },
396
- );
397
- }
398
- if (!resource || resource.path !== path) {
399
- throw new Error(`Skill script not found: ${name}/${path}`);
400
- }
401
- validateScriptPath(resource.path);
402
- if ((resource.encoding ?? "text") !== "text") {
403
- throw new Error(
404
- `Skill script must be text, got ${resource.encoding}: ${name}/${path}`,
405
- );
406
- }
407
-
408
- signal?.throwIfAborted();
409
- // TODO(待确认):agents@0.19.0 runner 需要 request.resources 才能识别
410
- // precompiled 脚本并填充 ctx.files 或 /skill;这里只传了目标脚本资源。
411
- // TODO(待确认):策略允许 Workspace 时还需 workspaceInstance;当前只传
412
- // workspace 权限;按已安装 runner 与官方文档,
413
- // 读写能力可能仍不可用。
414
- // TODO(待确认):RuntimeSkillScriptPolicy.tools 已被保存,但这里没有把
415
- // 对应工具集传给 runner;按官方文档,ctx.tools 可能始终为空。
416
- const output = await createSkillScriptRunner({
417
- loader,
418
- network: policy.network === "full",
419
- workspace: policy.workspace,
420
- }).run({
421
- skill,
422
- path,
423
- source: resource.content,
424
- input: input ?? {},
425
- });
120
+ const SKILL_TOOL_LABELS: Readonly<Record<string, string>> = {
121
+ activate_skill: "Activate Skill",
122
+ read_skill_resource: "Read Skill resource",
123
+ run_skill_script: "Run Skill script",
124
+ };
426
125
 
427
- return {
428
- content: [{
429
- type: "text",
430
- text: [
431
- `<skill_script name="${attribute(name)}" path="${
432
- attribute(path)
433
- }">`,
434
- typeof output === "string"
435
- ? output
436
- : JSON.stringify(output, null, 2) ?? "",
437
- "</skill_script>",
438
- ].join("\n"),
439
- }],
440
- details: { name, path, output },
441
- };
442
- },
443
- };
444
- candidates.push({
445
- owner: "runtime-skill",
446
- authorized: true,
447
- // Scripts execute host-provided code in an isolate, so this is never a
448
- // safe read like activate/read_resource.
449
- requiredExecutionLevel: "high",
450
- tool: runScript,
451
- });
452
- }
126
+ /** Create Pi candidates from the official Agents SDK SkillRegistry tools. */
127
+ export async function skillPiToolCandidates(
128
+ bindings: readonly PiSkillBinding[],
129
+ options: SkillPiToolOptions = {},
130
+ ): Promise<PiToolCandidate[]> {
131
+ if (bindings.length === 0) return [];
453
132
 
454
- return candidates;
133
+ const registry = new SkillRegistry(
134
+ bindings.map(authorizedSource),
135
+ scriptRunner(bindings, options),
136
+ );
137
+ await registry.load();
138
+
139
+ return Object.entries(registry.tools()).map(([name, tool]) => ({
140
+ owner: "runtime-skill",
141
+ authorized: true,
142
+ requiredExecutionLevel: name === "run_skill_script" ? "high" : "safe",
143
+ tool: aiToolToPi(name, tool, {
144
+ label: SKILL_TOOL_LABELS[name] ?? name,
145
+ }),
146
+ }));
455
147
  }