@springbrand/agent-runtime 0.1.0

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 (75) hide show
  1. package/package.json +28 -0
  2. package/src/db/approval.repo.ts +291 -0
  3. package/src/db/ext-context.repo.ts +34 -0
  4. package/src/db/index.ts +83 -0
  5. package/src/db/message-ui.repo.ts +39 -0
  6. package/src/db/milestone.repo.ts +96 -0
  7. package/src/db/runtime-event-outbox.repo.ts +89 -0
  8. package/src/db/schema.ts +164 -0
  9. package/src/db/settlement.repo.ts +104 -0
  10. package/src/db/steer.repo.ts +73 -0
  11. package/src/db/submission.repo.ts +323 -0
  12. package/src/index.ts +133 -0
  13. package/src/kernel/approval-lifecycle.ts +552 -0
  14. package/src/kernel/bindings.ts +898 -0
  15. package/src/kernel/degradation.ts +15 -0
  16. package/src/kernel/extensions.ts +108 -0
  17. package/src/kernel/profile.ts +116 -0
  18. package/src/kernel/public-contracts.ts +17 -0
  19. package/src/kernel/receipts.ts +124 -0
  20. package/src/kernel/recoverable-chat-agent.ts +899 -0
  21. package/src/kernel/state.ts +76 -0
  22. package/src/kernel/submission-lifecycle.ts +600 -0
  23. package/src/layers/context/budget/gate.ts +88 -0
  24. package/src/layers/orchestration/subagents/agent-types/contract.ts +78 -0
  25. package/src/layers/orchestration/subagents/agent-types/extract/index.ts +47 -0
  26. package/src/layers/orchestration/subagents/agent-types/fanout/index.ts +53 -0
  27. package/src/layers/orchestration/subagents/agent-types/registry.ts +16 -0
  28. package/src/layers/orchestration/temporary-agent/core.ts +152 -0
  29. package/src/layers/orchestration/temporary-agent/runner.ts +133 -0
  30. package/src/layers/orchestration/temporary-agent/workspace.ts +154 -0
  31. package/src/lib/artifacts.ts +54 -0
  32. package/src/lib/egress.ts +44 -0
  33. package/src/lib/execution-level.ts +27 -0
  34. package/src/lib/extension-name.ts +18 -0
  35. package/src/lib/host-actions.ts +57 -0
  36. package/src/lib/mcp.ts +86 -0
  37. package/src/lib/model-catalog.ts +7 -0
  38. package/src/lib/prompt.ts +139 -0
  39. package/src/lib/telemetry-dev.ts +44 -0
  40. package/src/pi/assembly/context.ts +510 -0
  41. package/src/pi/assembly/extensions.ts +661 -0
  42. package/src/pi/assembly/index.ts +19 -0
  43. package/src/pi/assembly/snapshot.ts +200 -0
  44. package/src/pi/message/contract.ts +8 -0
  45. package/src/pi/message/conversion.ts +73 -0
  46. package/src/pi/message/index.ts +3 -0
  47. package/src/pi/message/projection.ts +604 -0
  48. package/src/pi/runtime-adapter/assembly.ts +552 -0
  49. package/src/pi/runtime-adapter/execution.ts +683 -0
  50. package/src/pi/runtime-adapter/index.ts +232 -0
  51. package/src/pi/runtime-adapter/models.ts +243 -0
  52. package/src/pi/runtime-adapter/recovery.ts +805 -0
  53. package/src/pi/runtime-adapter/transcript.ts +825 -0
  54. package/src/pi/session/index.ts +24 -0
  55. package/src/pi/session/storage.ts +353 -0
  56. package/src/pi/tool/ai-adapter.ts +100 -0
  57. package/src/pi/tool/base.ts +110 -0
  58. package/src/pi/tool/compiler.ts +444 -0
  59. package/src/pi/tool/core-host.ts +48 -0
  60. package/src/pi/tool/core.ts +251 -0
  61. package/src/pi/tool/index.ts +32 -0
  62. package/src/pi/tool/mcp.ts +319 -0
  63. package/src/pi/tool/schedule.ts +198 -0
  64. package/src/pi/tool/skill.ts +455 -0
  65. package/src/pi/tool/subagent.ts +148 -0
  66. package/src/pi/tool/web-search/api.ts +1292 -0
  67. package/src/pi/tool/web-search/index.ts +2 -0
  68. package/src/pi/tool/web-search/web-search.ts +127 -0
  69. package/src/pi/tool/workspace-sandbox.ts +664 -0
  70. package/src/pi/turn/approval.ts +181 -0
  71. package/src/pi/turn/index.ts +62 -0
  72. package/src/pi/turn/tool-recovery.ts +792 -0
  73. package/src/plugins.ts +1024 -0
  74. package/src/runtime-agent.ts +654 -0
  75. package/src/runtime.ts +2880 -0
@@ -0,0 +1,661 @@
1
+ import {
2
+ ExtensionManager,
3
+ type ExtensionManifest,
4
+ type ExtensionPermissions,
5
+ } from "@cloudflare/think/extensions";
6
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
7
+ import { asSchema } from "ai";
8
+ import type {
9
+ LoadedRuntimeExtension,
10
+ RuntimeExtensionConfig,
11
+ RuntimeExtensionPermissions,
12
+ } from "../../kernel/extensions";
13
+ import type { RuntimeDegradation } from "../../kernel/degradation";
14
+ import { sanitizeExtensionName } from "../../lib/extension-name";
15
+ import type { PiToolCandidate } from "../tool/compiler";
16
+
17
+ export interface PiExtensionContextContribution {
18
+ readonly extensionName: string;
19
+ readonly label: string;
20
+ readonly description?: string;
21
+ readonly maxTokens?: number;
22
+ }
23
+
24
+ export interface PiExtensionToolCandidate extends PiToolCandidate {
25
+ readonly requiredExecutionLevel: "low" | "high";
26
+ }
27
+
28
+ export interface PiExtensionAssembly {
29
+ readonly loaded: readonly PiLoadedExtension[];
30
+ readonly context: readonly PiExtensionContextContribution[];
31
+ readonly candidates: readonly PiExtensionToolCandidate[];
32
+ readonly degradations: readonly RuntimeDegradation[];
33
+ }
34
+
35
+ export interface PiLoadedExtension {
36
+ readonly name: string;
37
+ readonly version: string;
38
+ readonly description?: string;
39
+ readonly tools: readonly string[];
40
+ readonly permissions?: RuntimeExtensionPermissions;
41
+ }
42
+
43
+ export interface AssemblePiExtensionsOptions {
44
+ readonly extensions: readonly RuntimeExtensionConfig[];
45
+ readonly published: ReadonlySet<string>;
46
+ readonly enabled: ReadonlySet<string>;
47
+ readonly authorized: ReadonlySet<string>;
48
+ readonly load: (
49
+ extension: RuntimeExtensionConfig,
50
+ ) => Promise<LoadedRuntimeExtension>;
51
+ }
52
+
53
+ export interface LoadPiExtensionOptions {
54
+ readonly loader: WorkerLoader;
55
+ readonly createHostBinding?: (
56
+ permissions: RuntimeExtensionPermissions,
57
+ ownContextLabels: readonly string[],
58
+ ) => Fetcher;
59
+ }
60
+
61
+ const PI_TOOL_NAME = /^[a-zA-Z0-9_-]+$/;
62
+ const PI_TOOL_NAME_MAX_LENGTH = 64;
63
+
64
+ // #region Permission and descriptor validation
65
+
66
+ // 作用:复制并冻结 Extension 权限,防止装配后被外部改写。
67
+ // 调用:Extension 装配成功后,在返回已加载描述前调用。
68
+ // 原因:权限同时影响 Host 能力和 Tool 风险,不能让调用方持有的数组在提交后改变它。
69
+ function freezePermissions(
70
+ permissions: RuntimeExtensionPermissions,
71
+ ): RuntimeExtensionPermissions {
72
+ const context = permissions.context;
73
+ return Object.freeze({
74
+ ...(permissions.network
75
+ ? { network: Object.freeze([...permissions.network]) }
76
+ : {}),
77
+ ...(permissions.workspace
78
+ ? { workspace: permissions.workspace }
79
+ : {}),
80
+ ...(context
81
+ ? {
82
+ context: Object.freeze({
83
+ ...(context.read
84
+ ? {
85
+ read: context.read === "all"
86
+ ? "all" as const
87
+ : Object.freeze([...context.read]),
88
+ }
89
+ : {}),
90
+ ...(context.write
91
+ ? {
92
+ write: context.write === "own"
93
+ ? "own" as const
94
+ : Object.freeze([...context.write]),
95
+ }
96
+ : {}),
97
+ }),
98
+ }
99
+ : {}),
100
+ ...(permissions.messages
101
+ ? { messages: permissions.messages }
102
+ : {}),
103
+ ...(permissions.session
104
+ ? { session: Object.freeze({ ...permissions.session }) }
105
+ : {}),
106
+ });
107
+ }
108
+
109
+ // 作用:判断一组 Extension 权限是否需要 Runtime Host 绑定。
110
+ // 调用:加载 Extension 前决定是否必须提供 `createHostBinding`。
111
+ // 原因:工作区、上下文、消息和 Session 权限都通过 Host 访问,漏判会让 Extension 带着缺失能力启动。
112
+ function requiresHostBinding(
113
+ permissions: RuntimeExtensionPermissions,
114
+ ): boolean {
115
+ return (
116
+ (permissions.workspace ?? "none") !== "none" ||
117
+ permissions.context?.read !== undefined ||
118
+ permissions.context?.write !== undefined ||
119
+ (permissions.messages ?? "none") !== "none" ||
120
+ permissions.session?.sendMessage === true ||
121
+ permissions.session?.metadata === true
122
+ );
123
+ }
124
+
125
+ // 作用:把 ExtensionManager 的 ToolSet 校验并转成 Runtime 使用的 Tool 描述。
126
+ // 调用:无权限的 discovery 加载完成后,在任何 Tool 进入候选集前调用。
127
+ // 原因:上游结果不保证本 Runtime 要求的名称、描述和 object schema,而且名称已带前缀,必须在这个信任边界统一校验和去前缀。
128
+ function parseToolDescriptors(
129
+ extensionName: string,
130
+ extensionPrefix: string,
131
+ tools: Readonly<Record<string, unknown>>,
132
+ ): LoadedRuntimeExtension["tools"] {
133
+ if (typeof tools !== "object" || tools === null) {
134
+ throw new Error(
135
+ `Extension "${extensionName}" returned invalid Tool descriptors`,
136
+ );
137
+ }
138
+ return Object.freeze(
139
+ Object.entries(tools).map(([finalName, candidate]) => {
140
+ if (typeof candidate !== "object" || candidate === null) {
141
+ throw new Error(
142
+ `Extension "${extensionName}" returned an invalid Tool descriptor`,
143
+ );
144
+ }
145
+ if (
146
+ !PI_TOOL_NAME.test(finalName) ||
147
+ finalName.length > PI_TOOL_NAME_MAX_LENGTH
148
+ ) {
149
+ throw new Error(
150
+ `Extension "${extensionName}" returned an invalid Tool name`,
151
+ );
152
+ }
153
+ const marker = `${extensionPrefix}_`;
154
+ if (!finalName.startsWith(marker) || finalName === marker) {
155
+ throw new Error(
156
+ `Extension "${extensionName}" returned an invalid Tool name`,
157
+ );
158
+ }
159
+ const name = finalName.slice(marker.length);
160
+
161
+ const tool = candidate as {
162
+ description?: unknown;
163
+ inputSchema?: unknown;
164
+ };
165
+ // The manager prefixes descriptions with `[extName] `; the assembly layer
166
+ // adds that prefix itself, so it is stripped back off here.
167
+ const rawDescription = typeof tool.description === "string"
168
+ ? tool.description
169
+ : "";
170
+ const descriptionPrefix = `[${extensionName}] `;
171
+ const description = rawDescription.startsWith(descriptionPrefix)
172
+ ? rawDescription.slice(descriptionPrefix.length)
173
+ : rawDescription;
174
+ if (description.trim().length === 0) {
175
+ throw new Error(
176
+ `Extension "${extensionName}" returned an invalid Tool descriptor`,
177
+ );
178
+ }
179
+
180
+ const schema = asSchema(
181
+ tool.inputSchema as Parameters<typeof asSchema>[0],
182
+ ).jsonSchema as Record<string, unknown> | undefined;
183
+ if (
184
+ !schema ||
185
+ schema.type !== "object" ||
186
+ typeof schema.properties !== "object" ||
187
+ schema.properties === null ||
188
+ Array.isArray(schema.properties) ||
189
+ (
190
+ schema.required !== undefined &&
191
+ (
192
+ !Array.isArray(schema.required) ||
193
+ schema.required.some((entry) => typeof entry !== "string")
194
+ )
195
+ )
196
+ ) {
197
+ throw new Error(
198
+ `Extension "${extensionName}" returned an invalid Tool descriptor`,
199
+ );
200
+ }
201
+
202
+ return Object.freeze({
203
+ name,
204
+ description,
205
+ inputSchema: Object.freeze({
206
+ type: "object" as const,
207
+ properties: Object.freeze({
208
+ ...(schema.properties as Record<string, unknown>),
209
+ }),
210
+ ...(schema.required
211
+ ? {
212
+ required: Object.freeze([
213
+ ...(schema.required as string[]),
214
+ ]),
215
+ }
216
+ : {}),
217
+ }),
218
+ });
219
+ }),
220
+ );
221
+ }
222
+
223
+ // #endregion
224
+
225
+ // #region Extension loading
226
+
227
+ /** Hooks the upstream manager can dispatch; all are rejected here. */
228
+ const EXTENSION_HOOK_NAMES = [
229
+ "beforeTurn",
230
+ "beforeToolCall",
231
+ "afterToolCall",
232
+ "onStepFinish",
233
+ "onChunk",
234
+ ] as const;
235
+
236
+ /**
237
+ * 通过 Cloudflare ExtensionManager 加载一个 Extension,并只暴露 Runtime 需要的描述和执行能力。
238
+ *
239
+ * @remarks
240
+ * Runtime 准备阶段会对已授权 Extension 调用,资源发布的 smoke test 也会用它校验候选源码;调用方必须提供 WorkerLoader,并在权限需要时提供 Host 绑定工厂。
241
+ *
242
+ * discovery 使用剔除权限的 manifest,有外部访问的执行 isolate 则延迟到首次 Tool 执行时创建。这是为了防止 `describe()` 在授权和审批前获得网络或 Host 能力,不能随意合并两条加载路径。
243
+ *
244
+ * ExtensionManager 负责 isolate、loader RPC 和结果解析;这层另外保证 Tool 描述严格校验并拒绝本 Runtime 不调度的生命周期 hook。
245
+ *
246
+ * 核心术语见包入口 `index.ts`。
247
+ */
248
+ export async function loadPiExtension(
249
+ extension: RuntimeExtensionConfig,
250
+ options: LoadPiExtensionOptions,
251
+ ): Promise<LoadedRuntimeExtension> {
252
+ const name = extension.manifest.name.trim();
253
+ const permissions = extension.manifest.permissions ?? {};
254
+ const prefix = sanitizeExtensionName(name);
255
+ const needsHost = requiresHostBinding(permissions);
256
+ if (needsHost && !options.createHostBinding) {
257
+ throw new Error(`Extension "${name}" requires a Host binding`);
258
+ }
259
+
260
+ // Descriptor discovery runs in a manager whose manifest declares no
261
+ // permissions, so the isolate that executes the Extension's top-level code and
262
+ // describe() gets `globalOutbound: null` and no Host binding. The upstream
263
+ // manager would otherwise build a single fully-privileged isolate and call
264
+ // describe() on it, letting discovery reach the network before any Tool is
265
+ // approved.
266
+ const discovery = new ExtensionManager({ loader: options.loader });
267
+ const { permissions: _ignored, ...manifestWithoutPermissions } =
268
+ extension.manifest;
269
+ try {
270
+ await discovery.load(
271
+ manifestWithoutPermissions as ExtensionManifest,
272
+ extension.source,
273
+ );
274
+ } catch (cause) {
275
+ // Upstream assumes describe() yields an array and throws an opaque TypeError
276
+ // otherwise; a malformed Extension must not surface as an internal error.
277
+ if (cause instanceof TypeError) {
278
+ throw new Error(
279
+ `Extension "${name}" returned invalid Tool descriptors`,
280
+ { cause },
281
+ );
282
+ }
283
+ throw cause;
284
+ }
285
+
286
+ // Upstream supports lifecycle hooks; this Runtime does not dispatch them, so
287
+ // an Extension declaring one must fail loudly rather than load with a handler
288
+ // that never fires.
289
+ const declaredHooks = EXTENSION_HOOK_NAMES.filter(
290
+ (hook) => discovery.getHookSubscribers(hook).length > 0,
291
+ );
292
+ if (declaredHooks.length > 0) {
293
+ throw new Error(
294
+ `lifecycle hooks are not supported: ${declaredHooks.join(", ")}`,
295
+ );
296
+ }
297
+
298
+ const tools = parseToolDescriptors(name, prefix, discovery.getTools());
299
+
300
+ // Validate that each discovered tool actually defines an execute handler.
301
+ // The discovery isolate has no network or Host access (permission-stripped
302
+ // manifest, globalOutbound: null), so probing is safe at authoring time.
303
+ // A short race is used: if the call does not resolve within a turn it is
304
+ // executing real work (execute exists); if it throws "Unknown tool" quickly
305
+ // the handler is absent and the extension is invalid.
306
+ const discoveryToolSet = discovery.getTools();
307
+ await Promise.all(
308
+ tools.map(async (tool) => {
309
+ const aiTool = discoveryToolSet[`${prefix}_${tool.name}`];
310
+ const toolExecute = (aiTool as { execute?: unknown } | undefined)
311
+ ?.execute;
312
+ if (typeof toolExecute !== "function") {
313
+ throw new Error(
314
+ `Tool "${tool.name}" must define an execute handler`,
315
+ );
316
+ }
317
+ const PROBE_TIMEOUT_MS = 200;
318
+ const PENDING = Symbol("pending");
319
+ const outcome = await Promise.race<unknown | typeof PENDING>([
320
+ (toolExecute as (
321
+ input: unknown,
322
+ ctx: { toolCallId: string; messages: never[]; context: undefined },
323
+ ) => Promise<unknown>)(
324
+ {},
325
+ { toolCallId: "", messages: [], context: undefined },
326
+ ).then(() => "ok" as const, (err: unknown) => err),
327
+ new Promise<typeof PENDING>((resolve) => {
328
+ setTimeout(() => resolve(PENDING), PROBE_TIMEOUT_MS);
329
+ }),
330
+ ]);
331
+ if (outcome === PENDING) return; // Timed out → handler is present and doing work
332
+ if (
333
+ outcome instanceof Error &&
334
+ outcome.message.startsWith(`Unknown tool: ${tool.name}`)
335
+ ) {
336
+ throw new Error(
337
+ `Tool "${tool.name}" must define an execute handler`,
338
+ );
339
+ }
340
+ }),
341
+ );
342
+
343
+ // The privileged isolate is built lazily and only when the Extension actually
344
+ // declares external access, matching the previous adapter's isolate count.
345
+ const hasExternalAccess =
346
+ needsHost || (permissions.network?.length ?? 0) > 0;
347
+ let executionTools: Readonly<Record<string, unknown>> | undefined;
348
+
349
+ // 作用:返回可执行 ToolSet,并在需要外部能力时延迟创建有权限的 manager。
350
+ // 调用:已加载 Extension 的 `execute` 在每次 Tool 调用前调用。
351
+ // 原因:无外部访问时复用 discovery ToolSet,有外部访问时只创建并缓存一个有权限 isolate。
352
+ const executionToolSet = async (): Promise<
353
+ Readonly<Record<string, unknown>>
354
+ > => {
355
+ if (!hasExternalAccess) return discovery.getTools();
356
+ if (!executionTools) {
357
+ const execution = new ExtensionManager({
358
+ loader: options.loader,
359
+ ...(options.createHostBinding
360
+ ? {
361
+ createHostBinding: (
362
+ managedPermissions: ExtensionPermissions,
363
+ ownContextLabels: string[],
364
+ ) =>
365
+ options.createHostBinding!(
366
+ managedPermissions as RuntimeExtensionPermissions,
367
+ ownContextLabels,
368
+ ),
369
+ }
370
+ : {}),
371
+ });
372
+ await execution.load(
373
+ extension.manifest as ExtensionManifest,
374
+ extension.source,
375
+ );
376
+ executionTools = execution.getTools();
377
+ }
378
+ return executionTools;
379
+ };
380
+
381
+ return Object.freeze({
382
+ tools,
383
+ // 作用:在 Extension isolate 中执行一个已发现的 Tool。
384
+ // 调用:共享 Tool 执行路径在授权和必要审批完成后,传入去前缀的 Tool 名和参数调用。
385
+ // 原因:进入 loader RPC 前可以响应取消,但 RPC 发出后没有取消原语,必须等待真实结果才能正确记录副作用。
386
+ async execute(
387
+ toolName: string,
388
+ args: Readonly<Record<string, unknown>>,
389
+ signal?: AbortSignal,
390
+ ) {
391
+ signal?.throwIfAborted();
392
+ const aiTool = (await executionToolSet())[`${prefix}_${toolName}`];
393
+ const run = (aiTool as { execute?: unknown } | undefined)?.execute;
394
+ if (typeof run !== "function") {
395
+ throw new Error(`Unknown tool: ${toolName}`);
396
+ }
397
+ // WorkerLoader RPC has no cancellation primitive. Once admitted, await
398
+ // its authoritative result so the shared Tool path can settle the real
399
+ // side-effect outcome instead of recording a false cancellation.
400
+ // Executing through the manager's ToolSet also keeps its unloaded-Extension
401
+ // guard on the call path.
402
+ return await (run as (
403
+ input: unknown,
404
+ context: {
405
+ toolCallId: string;
406
+ messages: never[];
407
+ abortSignal?: AbortSignal;
408
+ context: undefined;
409
+ },
410
+ ) => Promise<unknown>)(args, {
411
+ toolCallId: `${prefix}_${toolName}`,
412
+ messages: [],
413
+ abortSignal: signal,
414
+ context: undefined,
415
+ });
416
+ },
417
+ });
418
+ }
419
+
420
+ // #endregion
421
+
422
+ // #region Extension assembly
423
+
424
+ /**
425
+ * 根据 Extension 权限判断其 Tool 是否需要高风险审批。
426
+ *
427
+ * @remarks
428
+ * Extension 装配在为每个已加载 Tool 生成候选项前调用,调用方应传入 manifest 的原始权限集。
429
+ *
430
+ * 网络、写工作区、写上下文和发送 Session 消息都能产生外部副作用,所以统一归为 `high`;放宽这些条件会改变审批边界。
431
+ *
432
+ * 核心术语见包入口 `index.ts`。
433
+ */
434
+ function knownRecord(
435
+ value: unknown,
436
+ keys: readonly string[],
437
+ ): value is Record<string, unknown> {
438
+ return typeof value === "object" && value !== null && !Array.isArray(value) &&
439
+ Object.keys(value).every((key) => keys.includes(key));
440
+ }
441
+
442
+ function stringList(value: unknown): value is readonly string[] {
443
+ return Array.isArray(value) &&
444
+ value.every((entry) => typeof entry === "string");
445
+ }
446
+
447
+ export function extensionToolRequiredExecutionLevel(
448
+ permissions: RuntimeExtensionPermissions = {},
449
+ ): "low" | "high" {
450
+ if (
451
+ !knownRecord(permissions, [
452
+ "network",
453
+ "workspace",
454
+ "context",
455
+ "messages",
456
+ "session",
457
+ ])
458
+ ) return "high";
459
+
460
+ const { network, workspace, context, messages, session } = permissions;
461
+ if (network !== undefined && !stringList(network)) return "high";
462
+ if (
463
+ workspace !== undefined && workspace !== "none" && workspace !== "read" &&
464
+ workspace !== "read-write"
465
+ ) return "high";
466
+ if (messages !== undefined && messages !== "none" && messages !== "read") {
467
+ return "high";
468
+ }
469
+ if (context !== undefined && !knownRecord(context, ["read", "write"])) {
470
+ return "high";
471
+ }
472
+ if (
473
+ session !== undefined &&
474
+ !knownRecord(session, ["sendMessage", "metadata"])
475
+ ) {
476
+ return "high";
477
+ }
478
+
479
+ const contextRead = context?.read;
480
+ const contextWrite = context?.write;
481
+ if (
482
+ contextRead !== undefined && contextRead !== "all" &&
483
+ !stringList(contextRead)
484
+ ) return "high";
485
+ if (
486
+ contextWrite !== undefined && contextWrite !== "own" &&
487
+ !stringList(contextWrite)
488
+ ) return "high";
489
+ if (
490
+ session?.sendMessage !== undefined &&
491
+ typeof session.sendMessage !== "boolean"
492
+ ) return "high";
493
+ if (session?.metadata !== undefined && typeof session.metadata !== "boolean") {
494
+ return "high";
495
+ }
496
+
497
+ if (
498
+ (network?.length ?? 0) > 0 ||
499
+ workspace === "read-write" ||
500
+ contextWrite === "own" ||
501
+ (Array.isArray(contextWrite) && contextWrite.length > 0) ||
502
+ session?.sendMessage === true
503
+ ) {
504
+ return "high";
505
+ }
506
+ return "low";
507
+ }
508
+
509
+ /**
510
+ * 把当前已发布、已启用且已授权的 Extension 装配成 Runtime 能力。
511
+ *
512
+ * @remarks
513
+ * Runtime 准备阶段和资源 smoke test 调用;调用方传入三个选择集合及一个已经确定加载策略的 `load`。
514
+ *
515
+ * 只有同时通过三道选择的 Extension 才会被加载,单个加载失败记为 degradation 而不影响其他项;不要绕过这个交集直接注册 Tool。
516
+ *
517
+ * 核心术语见包入口 `index.ts`。
518
+ */
519
+ export async function assemblePiExtensions({
520
+ extensions,
521
+ published,
522
+ enabled,
523
+ authorized,
524
+ load,
525
+ }: AssemblePiExtensionsOptions): Promise<PiExtensionAssembly> {
526
+ const loadedExtensions: PiLoadedExtension[] = [];
527
+ const context: PiExtensionContextContribution[] = [];
528
+ const candidates: PiExtensionToolCandidate[] = [];
529
+ const degradations: RuntimeDegradation[] = [];
530
+
531
+ for (const extension of extensions) {
532
+ const name = extension.manifest.name.trim();
533
+ if (
534
+ !published.has(name) ||
535
+ !enabled.has(name) ||
536
+ !authorized.has(name)
537
+ ) {
538
+ continue;
539
+ }
540
+
541
+ let loaded: LoadedRuntimeExtension;
542
+ try {
543
+ loaded = await load(extension);
544
+ } catch (error) {
545
+ degradations.push({
546
+ capability: "extension",
547
+ reason: "unavailable",
548
+ detail:
549
+ `${name}@${extension.manifest.version}: ${errorText(error)}`,
550
+ });
551
+ continue;
552
+ }
553
+
554
+ const prefix = sanitizeExtensionName(name);
555
+ for (const block of extension.manifest.context ?? []) {
556
+ context.push(Object.freeze({
557
+ extensionName: name,
558
+ label: `${prefix}_${block.label}`,
559
+ ...(block.description
560
+ ? { description: block.description }
561
+ : {}),
562
+ ...(block.maxTokens !== undefined
563
+ ? { maxTokens: block.maxTokens }
564
+ : {}),
565
+ }));
566
+ }
567
+
568
+ const requiredExecutionLevel = extensionToolRequiredExecutionLevel(
569
+ extension.manifest.permissions,
570
+ );
571
+ const toolNames: string[] = [];
572
+ for (const descriptor of loaded.tools) {
573
+ const toolName = `${prefix}_${descriptor.name}`;
574
+ toolNames.push(toolName);
575
+ const description = `[${name}] ${descriptor.description}`;
576
+ // 作用:把 Extension 的原始执行结果包装成 Pi Agent Core 的 Tool 结果。
577
+ // 调用:编译后的共享 Tool 调度器在模型发起该 Extension Tool 时调用。
578
+ // 原因:所有 Extension 结果在这个边界统一变成 text content,同时在 details 保留未损失的原值。
579
+ const execute: AgentTool<any, {
580
+ extension: string;
581
+ result: unknown;
582
+ }>["execute"] = async (_toolCallId, params, signal) => {
583
+ const result = await loaded.execute(
584
+ descriptor.name,
585
+ params as Readonly<Record<string, unknown>>,
586
+ signal,
587
+ );
588
+ return {
589
+ content: [{
590
+ type: "text" as const,
591
+ text: resultText(result),
592
+ }],
593
+ details: {
594
+ extension: name,
595
+ result,
596
+ },
597
+ };
598
+ };
599
+ const tool: AgentTool<any, {
600
+ extension: string;
601
+ result: unknown;
602
+ }> = Object.freeze({
603
+ name: toolName,
604
+ label: `${name}: ${descriptor.name}`,
605
+ description,
606
+ parameters: descriptor.inputSchema as AgentTool["parameters"],
607
+ execute,
608
+ });
609
+ candidates.push(Object.freeze({
610
+ owner: `extension:${name}@${extension.manifest.version}`,
611
+ authorized: true,
612
+ requiredExecutionLevel,
613
+ summary: description,
614
+ tool,
615
+ }));
616
+ }
617
+ loadedExtensions.push(Object.freeze({
618
+ name,
619
+ version: extension.manifest.version,
620
+ ...(extension.manifest.description
621
+ ? { description: extension.manifest.description }
622
+ : {}),
623
+ tools: Object.freeze(toolNames),
624
+ ...(extension.manifest.permissions
625
+ ? {
626
+ permissions: freezePermissions(
627
+ extension.manifest.permissions,
628
+ ),
629
+ }
630
+ : {}),
631
+ }));
632
+ }
633
+
634
+ return Object.freeze({
635
+ loaded: Object.freeze(loadedExtensions),
636
+ context: Object.freeze(context),
637
+ candidates: Object.freeze(candidates),
638
+ degradations: Object.freeze(
639
+ degradations.map((degradation) =>
640
+ Object.freeze(degradation),
641
+ ),
642
+ ),
643
+ });
644
+ }
645
+
646
+ // 作用:把 Extension Tool 结果转成 Pi text content 需要的字符串。
647
+ // 调用:Extension Tool 包装器在构造成功结果时调用。
648
+ // 原因:字符串原样保留,其他值用 JSON 表达,以与 Tool 的文本输出契约保持一致。
649
+ function resultText(result: unknown): string {
650
+ if (typeof result === "string") return result;
651
+ return JSON.stringify(result) ?? String(result);
652
+ }
653
+
654
+ // 作用:把未知异常统一变成 degradation 可显示的文本。
655
+ // 调用:单个 Extension 加载失败时调用。
656
+ // 原因:保留 `Error.message` 而不暴露整个异常对象,非 Error 值则使用标准字符串转换。
657
+ function errorText(error: unknown): string {
658
+ return error instanceof Error ? error.message : String(error);
659
+ }
660
+
661
+ // #endregion
@@ -0,0 +1,19 @@
1
+ import {
2
+ assemblePiSystemContext,
3
+ compactPiContext,
4
+ } from "./context";
5
+ import {
6
+ assemblePiExtensions,
7
+ loadPiExtension,
8
+ } from "./extensions";
9
+
10
+ export const piAssembly = Object.freeze({
11
+ assembleContext: assemblePiSystemContext,
12
+ compactContext: compactPiContext,
13
+ assembleExtensions: assemblePiExtensions,
14
+ loadExtension: loadPiExtension,
15
+ });
16
+
17
+ export * from "./context";
18
+ export * from "./extensions";
19
+ export * from "./snapshot";