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

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 (35) hide show
  1. package/package.json +1 -1
  2. package/src/db/agent-tool.repo.ts +27 -0
  3. package/src/db/index.ts +33 -0
  4. package/src/db/interaction.repo.ts +185 -0
  5. package/src/db/schema.ts +15 -0
  6. package/src/db/submission.repo.ts +29 -0
  7. package/src/index.ts +8 -17
  8. package/src/kernel/approval-lifecycle.ts +41 -6
  9. package/src/kernel/bindings.ts +37 -0
  10. package/src/kernel/interaction-lifecycle.ts +395 -0
  11. package/src/kernel/public-contracts.ts +2 -0
  12. package/src/kernel/recoverable-chat-agent.ts +10 -2
  13. package/src/kernel/runtime-assembly-view.ts +37 -0
  14. package/src/kernel/runtime-assembly.ts +41 -0
  15. package/src/kernel/runtime-config.ts +4 -0
  16. package/src/kernel/runtime-load.ts +102 -0
  17. package/src/kernel/state.ts +8 -1
  18. package/src/kernel/submission-lifecycle.ts +30 -0
  19. package/src/lib/telemetry-dev.ts +7 -4
  20. package/src/pi/runtime-adapter/assembly.ts +13 -1
  21. package/src/pi/runtime-adapter/execution.ts +109 -9
  22. package/src/pi/runtime-adapter/index.ts +10 -3
  23. package/src/pi/runtime-adapter/models.ts +162 -16
  24. package/src/pi/runtime-adapter/recovery.ts +188 -1
  25. package/src/pi/tool/base.ts +62 -9
  26. package/src/pi/tool/compiler.ts +34 -0
  27. package/src/pi/tool/gateway.ts +54 -0
  28. package/src/pi/tool/index.ts +1 -0
  29. package/src/pi/tool/mcp.ts +93 -64
  30. package/src/pi/turn/index.ts +20 -0
  31. package/src/pi/turn/interaction.ts +181 -0
  32. package/src/pi/turn/tool-recovery.ts +244 -1
  33. package/src/runtime-agent.ts +246 -113
  34. package/src/{plugins.ts → runtime-assembler.ts} +65 -282
  35. package/src/runtime.ts +454 -162
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  applyRecoveredPiApprovalDecision,
3
+ applyRecoveredPiToolInteractionSettlement,
3
4
  commitPiRecoveryContinuation,
4
5
  encodePiToolRecoveryMilestone,
5
6
  planPiToolRecovery,
6
7
  replayPiToolRecovery,
8
+ stagePiAssemblyRepin,
7
9
  stagePiRecoveryContinuation,
8
10
  type PiToolApproval,
11
+ type PiToolInteraction,
12
+ type PiToolInteractionCancelReason,
9
13
  type PiToolRecoveryMilestone,
10
14
  type PiToolRecoveryPlan,
11
15
  type PiToolRecoveryState,
@@ -55,6 +59,32 @@ export type PiRecoveryCommand =
55
59
  readonly kind: "record-approval";
56
60
  readonly approval: PiToolApproval;
57
61
  }
62
+ | {
63
+ readonly kind: "record-interaction";
64
+ readonly interaction: PiToolInteraction;
65
+ }
66
+ /** 停在人机等待期间换了装配:记下这次被承认的身份迁移。 */
67
+ | {
68
+ readonly kind: "repin-assembly";
69
+ readonly nextAssemblyRevision: string;
70
+ }
71
+ | {
72
+ readonly kind: "interaction-settlement";
73
+ readonly interactionId: string;
74
+ readonly settlement:
75
+ | {
76
+ readonly kind: "respond";
77
+ readonly response: unknown;
78
+ readonly result: {
79
+ readonly content: ToolResultMessage["content"];
80
+ readonly details: unknown;
81
+ };
82
+ }
83
+ | {
84
+ readonly kind: "cancel";
85
+ readonly reason: PiToolInteractionCancelReason;
86
+ };
87
+ }
58
88
  | {
59
89
  readonly kind: "record-tool-result";
60
90
  readonly toolCallId: string;
@@ -109,6 +139,17 @@ export type PiDurableMutation =
109
139
  readonly status: "approved" | "rejected";
110
140
  readonly decidedAt: number;
111
141
  readonly reason?: string;
142
+ }
143
+ | {
144
+ readonly kind: "record-interaction";
145
+ readonly interaction: PiToolInteraction;
146
+ }
147
+ | {
148
+ readonly kind: "settle-interaction";
149
+ readonly interactionId: string;
150
+ readonly status: "responded" | "cancelled";
151
+ readonly settledAt: number;
152
+ readonly responseJson?: string;
112
153
  };
113
154
 
114
155
  export type PiRecoveryEffect =
@@ -131,7 +172,11 @@ export type PiRecoveryEffect =
131
172
  }
132
173
  | {
133
174
  readonly kind: "wait";
134
- readonly reason?: "approval" | "uncertain-tool" | "complete";
175
+ readonly reason?:
176
+ | "approval"
177
+ | "interaction"
178
+ | "uncertain-tool"
179
+ | "complete";
135
180
  }
136
181
  | { readonly kind: "resume-turn" };
137
182
 
@@ -198,6 +243,31 @@ function approvalExecutionId(continuationKey: string): string | null {
198
243
  return match?.[1] ?? null;
199
244
  }
200
245
 
246
+ // 把 Tool 自己的 settle 映射产出的内容包成一条权威 ToolResult。
247
+ // interaction-settlement 命令处理 respond 分支时调用它。
248
+ // 记录必须已经存在 —— 拿不到 toolCallId/toolName 就无法把结果绑回原调用,这时失败关闭而不是编一个 id。
249
+ function interactionToolResult(
250
+ interaction: PiToolInteraction | undefined,
251
+ result: {
252
+ readonly content: ToolResultMessage["content"];
253
+ readonly details: unknown;
254
+ },
255
+ timestamp: number,
256
+ ): ToolResultMessage {
257
+ if (!interaction) {
258
+ throw new Error("Pi Tool interaction is missing for its response");
259
+ }
260
+ return {
261
+ role: "toolResult",
262
+ toolCallId: interaction.toolCallId,
263
+ toolName: interaction.toolName,
264
+ content: result.content,
265
+ details: result.details,
266
+ isError: false,
267
+ timestamp,
268
+ };
269
+ }
270
+
201
271
  // 把纯恢复计划翻译成持久化操作和 Runtime 下一步动作。
202
272
  // decidePiRecovery 在命令应用并重放最新状态后调用它。
203
273
  // pending 续跑必须先形成里程碑并重放,再允许 dispatch,避免外部动作早于可恢复状态落盘。
@@ -223,6 +293,11 @@ function fromPlan(
223
293
  mutations: [],
224
294
  effect: { kind: "wait", reason: "approval" },
225
295
  };
296
+ case "parked-interaction":
297
+ return {
298
+ mutations: [],
299
+ effect: { kind: "wait", reason: "interaction" },
300
+ };
226
301
  case "park-uncertain-tool":
227
302
  return {
228
303
  mutations: [],
@@ -425,6 +500,112 @@ export function decidePiRecovery(
425
500
  applied = true;
426
501
  }
427
502
  }
503
+ if (input.command.kind === "repin-assembly") {
504
+ const milestone = stagePiAssemblyRepin(
505
+ state,
506
+ input.command.nextAssemblyRevision,
507
+ input.now,
508
+ );
509
+ if (milestone) {
510
+ const mutation = milestoneMutation(
511
+ `assembly-repin:${input.command.nextAssemblyRevision}`,
512
+ milestone,
513
+ );
514
+ mutations.push(mutation);
515
+ bodies = [...bodies, mutation.body];
516
+ state = replayPiToolRecovery(bodies);
517
+ applied = true;
518
+ }
519
+ }
520
+ if (input.command.kind === "record-interaction") {
521
+ const interaction = input.command.interaction;
522
+ const existing = state.interactions[interaction.interactionId];
523
+ if (existing) {
524
+ // 同一个 interactionId 必须描述同一次 Tool 调用,否则重放会把响应投给错的调用。
525
+ // 口径与 record-approval 的冲突检查一致。
526
+ if (
527
+ existing.requestId !== interaction.requestId ||
528
+ existing.toolCallId !== interaction.toolCallId ||
529
+ existing.toolName !== interaction.toolName ||
530
+ existing.inputJson !== interaction.inputJson
531
+ ) {
532
+ throw new Error(
533
+ `Conflicting Pi Tool interaction: ${interaction.interactionId}`,
534
+ );
535
+ }
536
+ } else {
537
+ const mutation = milestoneMutation(
538
+ `interaction:${interaction.interactionId}:${interaction.status}`,
539
+ {
540
+ ...recoveryIdentity(input),
541
+ type: "interaction",
542
+ interaction,
543
+ },
544
+ );
545
+ mutations.push(
546
+ { kind: "record-interaction", interaction },
547
+ mutation,
548
+ );
549
+ bodies = [...bodies, mutation.body];
550
+ state = replayPiToolRecovery(bodies);
551
+ applied = true;
552
+ }
553
+ }
554
+ if (input.command.kind === "interaction-settlement") {
555
+ const command = input.command;
556
+ const settlement = applyRecoveredPiToolInteractionSettlement(
557
+ state,
558
+ command.settlement.kind === "respond"
559
+ ? {
560
+ interactionId: command.interactionId,
561
+ kind: "respond",
562
+ response: command.settlement.response,
563
+ toolResult: interactionToolResult(
564
+ state.interactions[command.interactionId],
565
+ command.settlement.result,
566
+ input.now,
567
+ ),
568
+ settledAt: input.now,
569
+ }
570
+ : {
571
+ interactionId: command.interactionId,
572
+ kind: "cancel",
573
+ reason: command.settlement.reason,
574
+ settledAt: input.now,
575
+ },
576
+ );
577
+ applied = settlement.outcome.kind !== "noop";
578
+ if (applied) {
579
+ const record = settlement.milestones.find(
580
+ (milestone) => milestone.type === "interaction",
581
+ );
582
+ if (!record || record.type !== "interaction") {
583
+ throw new Error("Pi interaction settlement milestone is missing");
584
+ }
585
+ mutations.push({
586
+ kind: "settle-interaction",
587
+ interactionId: record.interaction.interactionId,
588
+ status: record.interaction.status as "responded" | "cancelled",
589
+ settledAt: record.interaction.respondedAt ?? input.now,
590
+ ...(record.interaction.responseJson === undefined
591
+ ? {}
592
+ : { responseJson: record.interaction.responseJson }),
593
+ });
594
+ settlement.milestones.forEach((milestone, index) => {
595
+ mutations.push(milestoneMutation(
596
+ milestone.type === "interaction"
597
+ ? `interaction:${command.interactionId}:${record.interaction.status}`
598
+ : `interaction-result:${command.interactionId}:${index}`,
599
+ milestone,
600
+ ));
601
+ });
602
+ bodies = [
603
+ ...bodies,
604
+ ...settlement.milestones.map(encodePiToolRecoveryMilestone),
605
+ ];
606
+ state = replayPiToolRecovery(bodies);
607
+ }
608
+ }
428
609
  if (input.command.kind === "record-tool-result") {
429
610
  const command = input.command;
430
611
  const toolResult: ToolResultMessage = {
@@ -649,6 +830,12 @@ export class PiRuntimeRecoveryAdapter
649
830
  reason: "Pi Turn is waiting for Tool approval",
650
831
  } as const;
651
832
  }
833
+ if (decision.effect.reason === "interaction") {
834
+ return {
835
+ kind: "park",
836
+ reason: "Pi Turn is waiting for a client Tool interaction response",
837
+ } as const;
838
+ }
652
839
  if (decision.effect.reason === "uncertain-tool") {
653
840
  return {
654
841
  kind: "park",
@@ -26,6 +26,34 @@ const askUserParameters = Type.Object({
26
26
  })),
27
27
  });
28
28
 
29
+ /**
30
+ * `ask_user` 的客户端响应体。
31
+ *
32
+ * `selections` 是选中的选项原文(多选时多于一项),`text` 是可选的补充说明。
33
+ * 刻意用结构化数组而不是拼好的字符串 —— 选项本身可能含分隔符,拼了再拆是有损的。
34
+ */
35
+ const askUserResponse = Type.Object({
36
+ selections: Type.Array(Type.String()),
37
+ text: Type.Optional(Type.String()),
38
+ });
39
+
40
+ // 手写校验而不是拉 TypeBox 的 compiler:这里只需要判真假,
41
+ // 且校验必须在 Worker 冷启动路径上零成本。
42
+ function isAskUserResponse(value: unknown): boolean {
43
+ if (typeof value !== "object" || value === null) return false;
44
+ const record = value as Record<string, unknown>;
45
+ if (!Array.isArray(record.selections)) return false;
46
+ if (!record.selections.every((item) => typeof item === "string")) {
47
+ return false;
48
+ }
49
+ if (record.text !== undefined && typeof record.text !== "string") {
50
+ return false;
51
+ }
52
+ // 什么都没选、也没写字,等于没回答 —— 不该被当成一次有效结算。
53
+ return record.selections.length > 0 ||
54
+ (typeof record.text === "string" && record.text.trim().length > 0);
55
+ }
56
+
29
57
  const suggestFollowupsParameters = Type.Object({
30
58
  items: Type.Array(Type.String({
31
59
  description: "A follow-up the user could ask next, phrased as a request.",
@@ -124,16 +152,41 @@ export function basePiToolCandidates(
124
152
  webSearch?: WebSearch,
125
153
  ): PiToolCandidate[] {
126
154
  return [
127
- candidate({
128
- name: "ask_user",
129
- label: "Ask user",
130
- description:
131
- "Ask the user to choose among a small set of options. Call this tool whenever you need the user to make a choice from limited options — do NOT write plain text like 'please pick A / B / C'. After calling this tool, end your turn and wait for the user's reply.",
132
- parameters: askUserParameters,
133
- async execute() {
134
- return result({ asked: true });
155
+ // ask_user 是 client-settled tool:它没有 execute,结果由用户点选后经
156
+ // respondToolInteraction 投递回来。工具调用会一直 park 到那时候。
157
+ {
158
+ ...candidate({
159
+ name: "ask_user",
160
+ label: "Ask user",
161
+ description:
162
+ "Ask the user to choose among a small set of options. Call this tool whenever you need the user to make a choice from limited options — do NOT write plain text like 'please pick A / B / C'. The user's choice comes back to you as this tool's result, so just continue once you have it. Do NOT end your turn after calling this tool.",
163
+ parameters: askUserParameters,
164
+ // 永远不会被调用:执行链在 interaction 闸就 park 住了。留一个失败关闭的
165
+ // 实现,是为了万一哪次改动绕过了那道闸,能立刻炸出来而不是静默返回空答案。
166
+ async execute() {
167
+ throw new Error(
168
+ "ask_user is client-settled and must not execute on the server",
169
+ );
170
+ },
171
+ }),
172
+ interaction: {
173
+ validateResponse: isAskUserResponse,
174
+ settle: (_input, response) => {
175
+ const answer = response as Static<typeof askUserResponse>;
176
+ const parts = [
177
+ ...answer.selections,
178
+ ...(answer.text?.trim() ? [answer.text.trim()] : []),
179
+ ];
180
+ return {
181
+ content: [{
182
+ type: "text",
183
+ text: `The user answered: ${parts.join(" / ")}`,
184
+ }],
185
+ details: answer,
186
+ };
187
+ },
135
188
  },
136
- }),
189
+ },
137
190
  candidate({
138
191
  name: "suggest_followups",
139
192
  label: "Suggest follow-ups",
@@ -13,14 +13,48 @@ import {
13
13
 
14
14
  // #region Public contracts
15
15
 
16
+ /**
17
+ * 声明一个 Tool 的结果由客户端投递,而不是由 `execute()` 产出。
18
+ *
19
+ * 带这个字段的 Tool 会在执行链里 park 住,等宿主经 `respondToolInteraction` 把响应送进来,
20
+ * 再用 `settle` 把响应映射成这次 `toolCallId` 的权威 ToolResult。它的 `execute` 不会被调用。
21
+ *
22
+ * 这是 AI SDK 里「没有 execute 的 user-interaction tool」的等价物;差别是我们 park 住整个 Turn,
23
+ * 而不是让 tool part 悬空到下一次请求 —— 我们的 transcript 是服务端权威的,悬空的 tool_use
24
+ * 会让下一次模型调用非法。
25
+ */
26
+ export interface PiToolInteractionSpec {
27
+ /** 校验客户端响应体;不通过则拒绝投递,park 保持不变。 */
28
+ readonly validateResponse: (response: unknown) => boolean;
29
+ /** 把通过校验的响应映射成 ToolResult;缺省是原样回传。 */
30
+ readonly settle?: (
31
+ input: unknown,
32
+ response: unknown,
33
+ ) => AgentToolResult<unknown>;
34
+ }
35
+
16
36
  /** 描述一个尚未进入最终 Tool Surface 和结算包装的 Pi 工具。 */
17
37
  export interface PiToolCandidate {
18
38
  readonly owner: string;
19
39
  readonly authorized: boolean;
20
40
  readonly tool: AgentTool<any, any>;
41
+ /** Conservative maximum used in the stable Runtime descriptor. */
21
42
  readonly requiredExecutionLevel: ExecutionLevel;
43
+ /** Trusted parameter-level policy, evaluated before approval or dispatch. */
44
+ readonly requiredExecutionLevelForInput?: (
45
+ input: unknown,
46
+ ) => ExecutionLevel | Promise<ExecutionLevel>;
47
+ /**
48
+ * 无条件需要人来决定:不看执行档位矩阵,每次调用都 park 出审批。
49
+ *
50
+ * 档位差是「够不够格自己跑」,不是「该不该问人」。花用户钱、改变账户
51
+ * 状态这类工具,一旦 Agent 档位被 `allow_level` 永久拉高就会静默执行,
52
+ * 那是失效不是降级。`deny` 仍然优先,被 deny 的工具根本不会注入。
53
+ */
54
+ readonly alwaysRequiresApproval?: boolean;
22
55
  readonly summary?: string;
23
56
  readonly source?: ApprovalReceipt["source"];
57
+ readonly interaction?: PiToolInteractionSpec;
24
58
  }
25
59
 
26
60
  /** 控制候选 Pi 工具如何被编译为可执行工具集。 */
@@ -0,0 +1,54 @@
1
+ import type { RuntimeGatewaySession } from "../../kernel/bindings";
2
+ import type { PiToolCandidate } from "./compiler";
3
+ import { createPiMcpToolCandidate } from "./mcp";
4
+
5
+ const GATEWAY_TOOLS = [
6
+ "search_capabilities",
7
+ "execute_capability",
8
+ ] as const;
9
+
10
+ /** Builds the two platform Gateway tools from one authenticated logical MCP Session. */
11
+ export function createPiGatewayToolCandidates(
12
+ session: RuntimeGatewaySession,
13
+ ): PiToolCandidate[] {
14
+ const tools = new Map(session.tools.map((tool) => [tool.name, tool]));
15
+ if (
16
+ tools.size !== GATEWAY_TOOLS.length ||
17
+ GATEWAY_TOOLS.some((name) => !tools.has(name))
18
+ ) {
19
+ throw new Error("Connector Gateway returned an invalid MCP catalog");
20
+ }
21
+
22
+ return GATEWAY_TOOLS.map((name) => {
23
+ const tool = tools.get(name)!;
24
+ const execute = name === "execute_capability";
25
+ return createPiMcpToolCandidate(
26
+ { ...tool, serverId: "gateway" },
27
+ (input, signal) => session.callTool(name, input, signal),
28
+ {
29
+ owner: "gateway",
30
+ modelName: name,
31
+ requiredExecutionLevel: execute ? "high" : "safe",
32
+ ...(execute
33
+ ? {
34
+ requiredExecutionLevelForInput: async (input: unknown) => {
35
+ const reference = input && typeof input === "object" &&
36
+ !Array.isArray(input)
37
+ ? (input as Record<string, unknown>).name
38
+ : undefined;
39
+ if (typeof reference !== "string" || !reference) {
40
+ throw new Error(
41
+ "Gateway Capability Reference is not verified",
42
+ );
43
+ }
44
+ return (await session.resolveCapabilityRisk(reference)) ===
45
+ "none"
46
+ ? "safe" as const
47
+ : "high" as const;
48
+ },
49
+ }
50
+ : {}),
51
+ },
52
+ );
53
+ });
54
+ }
@@ -24,6 +24,7 @@ export * from "./base";
24
24
  export * from "./compiler";
25
25
  export * from "./core";
26
26
  export * from "./core-host";
27
+ export * from "./gateway";
27
28
  export * from "./mcp";
28
29
  export * from "./schedule";
29
30
  export * from "./skill";
@@ -9,6 +9,22 @@ interface McpCallResult {
9
9
  isError?: boolean;
10
10
  }
11
11
 
12
+ export interface PiDiscoveredMcpTool {
13
+ readonly serverId: string;
14
+ readonly name: string;
15
+ readonly title?: string;
16
+ readonly description?: string;
17
+ readonly inputSchema?: unknown;
18
+ readonly annotations?: { readonly title?: string } & Record<string, unknown>;
19
+ }
20
+
21
+ interface PiMcpCandidatePolicy {
22
+ readonly owner: string;
23
+ readonly modelName: string;
24
+ readonly requiredExecutionLevel: PiToolCandidate["requiredExecutionLevel"];
25
+ readonly requiredExecutionLevelForInput?: PiToolCandidate["requiredExecutionLevelForInput"];
26
+ }
27
+
12
28
  /**
13
29
  * Pi 的 MCP 适配器所需的最小 Host 能力。
14
30
  *
@@ -200,6 +216,67 @@ function errorMessage(result: McpCallResult): string {
200
216
  : "MCP tool call failed";
201
217
  }
202
218
 
219
+ /** 共享 MCP 结果投影;Gateway 与用户配置的 Remote MCP 均经过此边界。 */
220
+ export function createPiMcpToolCandidate(
221
+ mcpTool: PiDiscoveredMcpTool,
222
+ callTool: (
223
+ input: Readonly<Record<string, unknown>>,
224
+ signal?: AbortSignal,
225
+ ) => Promise<unknown>,
226
+ policy: PiMcpCandidatePolicy,
227
+ ): PiToolCandidate {
228
+ const label =
229
+ mcpTool.title ??
230
+ mcpTool.annotations?.title ??
231
+ mcpTool.name;
232
+ const tool: AgentTool<any, {
233
+ kind: "mcp";
234
+ toolName: string;
235
+ }> = {
236
+ name: policy.modelName,
237
+ label,
238
+ description: mcpTool.description ?? label,
239
+ parameters: structuredClone(
240
+ mcpTool.inputSchema ?? {
241
+ type: "object",
242
+ additionalProperties: true,
243
+ },
244
+ ) as AgentTool["parameters"],
245
+ execute: async (_toolCallId, args, signal) => {
246
+ const result = normalizeCallResult(
247
+ await callTool(args as Record<string, unknown>, signal),
248
+ );
249
+ if (result.isError) throw new Error(errorMessage(result));
250
+ const structuredContent = result.structuredContent === undefined
251
+ ? undefined
252
+ : publicMcpValue(result.structuredContent);
253
+ const content = resultContent({ ...result, structuredContent });
254
+ return {
255
+ content,
256
+ details: {
257
+ kind: "mcp",
258
+ toolName: mcpTool.name,
259
+ output: structuredContent ?? content,
260
+ },
261
+ };
262
+ },
263
+ };
264
+ return {
265
+ owner: policy.owner,
266
+ authorized: true,
267
+ tool,
268
+ summary: label,
269
+ requiredExecutionLevel: policy.requiredExecutionLevel,
270
+ ...(policy.requiredExecutionLevelForInput
271
+ ? {
272
+ requiredExecutionLevelForInput:
273
+ policy.requiredExecutionLevelForInput,
274
+ }
275
+ : {}),
276
+ source: "action",
277
+ };
278
+ }
279
+
203
280
  /**
204
281
  * 把当前已连接且获授权的 MCP 工具转换成 Pi 候选工具。
205
282
  *
@@ -249,71 +326,23 @@ export function createPiMcpToolCandidates(
249
326
  state: "ready",
250
327
  })
251
328
  .filter((tool) => selectedIds.has(tool.serverId))
252
- .map((mcpTool): PiToolCandidate => {
253
- const label =
254
- mcpTool.title ??
255
- mcpTool.annotations?.title ??
256
- mcpTool.name;
257
- const tool: AgentTool<any, {
258
- kind: "mcp";
259
- toolName: string;
260
- }> = {
261
- name: modelVisibleName(mcpTool.serverId, mcpTool.name),
262
- label,
263
- description: mcpTool.description ?? label,
264
- parameters: structuredClone(
265
- mcpTool.inputSchema ?? {
266
- type: "object",
267
- additionalProperties: true,
268
- },
269
- ) as AgentTool["parameters"],
270
- // 作用:执行模型选中的远端 MCP 工具并返回公开结果。
271
- // 调用:Pi 在参数校验和统一工具治理通过后调用。
272
- // 原因:调用交给 Agents SDK,并在边界处统一处理错误、
273
- // 私有元数据和非原生内容。
274
- execute: async (_toolCallId, args, signal) => {
275
- const result = normalizeCallResult(
276
- await host.mcp.callTool(
277
- {
278
- serverId: mcpTool.serverId,
279
- name: mcpTool.name,
280
- arguments: args as Record<string, unknown>,
281
- },
282
- undefined,
283
- { signal },
284
- ),
285
- );
286
- if (result.isError) {
287
- throw new Error(errorMessage(result));
288
- }
289
- const structuredContent =
290
- result.structuredContent === undefined
291
- ? undefined
292
- : publicMcpValue(result.structuredContent);
293
- const content = resultContent({
294
- ...result,
295
- structuredContent,
296
- });
297
- return {
298
- content,
299
- details: {
300
- kind: "mcp",
301
- toolName: mcpTool.name,
302
- output: structuredContent ?? content,
303
- },
304
- };
329
+ .map((mcpTool) => createPiMcpToolCandidate(
330
+ mcpTool,
331
+ (args, signal) => host.mcp.callTool(
332
+ {
333
+ serverId: mcpTool.serverId,
334
+ name: mcpTool.name,
335
+ arguments: args,
305
336
  },
306
- };
307
- return {
337
+ undefined,
338
+ { signal },
339
+ ),
340
+ {
308
341
  owner: `mcp:${mcpTool.serverId}`,
309
- authorized: true,
310
- tool,
311
- summary: label,
312
- // MCP annotations are supplied by the remote server and are not an
313
- // authorization boundary. Until the Host supplies trusted per-method
314
- // policy, every dynamic MCP method must take the approval path.
342
+ modelName: modelVisibleName(mcpTool.serverId, mcpTool.name),
343
+ // Remote MCP annotations are untrusted; all user-configured methods
344
+ // retain the existing high-risk approval policy.
315
345
  requiredExecutionLevel: "high",
316
- source: "action",
317
- };
318
- });
346
+ },
347
+ ));
319
348
  }
@@ -7,6 +7,8 @@
7
7
  * 本目录统一使用以下术语:
8
8
  *
9
9
  * - **审批(approval)**:Tool handler 运行前持久化的用户许可;`pending` 状态会暂停 Turn。
10
+ * - **交互(interaction)**:结果由客户端投递而非 `execute()` 产出的 Tool 调用;`pending` 状态同样暂停 Turn。
11
+ * 与审批的差别只在响应体:审批是三态枚举,交互是任意 JSON。
10
12
  * - **里程碑(milestone)**:属于同一个 `turnId` 和 `assemblyRevision` 的持久 JSON 记录。
11
13
  * - **结算结果(settled ToolResult)**:已持久化且恢复时必须复用、不能再次执行副作用的 Tool 结果。
12
14
  * - **续跑(continuation)**:审批或 Tool 结算后恢复原 Turn 的动作;先记 `pending`,再记 `committed`。
@@ -21,12 +23,16 @@ import {
21
23
  parkPiToolApproval,
22
24
  requiresPiToolApproval,
23
25
  } from "./approval";
26
+ import { parkPiToolInteraction } from "./interaction";
24
27
  import {
25
28
  applyRecoveredPiApprovalDecision,
29
+ applyRecoveredPiToolInteractionSettlement,
26
30
  commitPiRecoveryContinuation,
27
31
  encodePiToolRecoveryMilestone,
28
32
  planPiToolRecovery,
33
+ recordRecoveredPiToolInteraction,
29
34
  replayPiToolRecovery,
35
+ stagePiAssemblyRepin,
30
36
  stagePiRecoveryContinuation,
31
37
  } from "./tool-recovery";
32
38
 
@@ -51,12 +57,26 @@ export const piApproval = Object.freeze({
51
57
  */
52
58
  export const piRecovery = Object.freeze({
53
59
  applyApprovalDecision: applyRecoveredPiApprovalDecision,
60
+ applyInteractionSettlement: applyRecoveredPiToolInteractionSettlement,
54
61
  commitContinuation: commitPiRecoveryContinuation,
55
62
  encodeMilestone: encodePiToolRecoveryMilestone,
56
63
  plan: planPiToolRecovery,
64
+ recordInteraction: recordRecoveredPiToolInteraction,
57
65
  replay: replayPiToolRecovery,
66
+ stageAssemblyRepin: stagePiAssemblyRepin,
58
67
  stageContinuation: stagePiRecoveryContinuation,
59
68
  });
60
69
 
70
+ /**
71
+ * 提供 Pi Tool interaction(结果由客户端提供的 Tool)的 park 入口。
72
+ *
73
+ * 执行适配器发现 Tool 声明了 `interaction` 后调用 `park` 生成待响应状态;
74
+ * 响应与取消是有状态操作,走 `piRecovery.applyInteractionSettlement`,不放在这个门面里。
75
+ */
76
+ export const piInteraction = Object.freeze({
77
+ park: parkPiToolInteraction,
78
+ });
79
+
61
80
  export * from "./approval";
81
+ export * from "./interaction";
62
82
  export * from "./tool-recovery";