@springbrand/agent-runtime 0.2.0-alpha.71 → 0.3.0-alpha.1

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 (141) hide show
  1. package/package.json +3 -3
  2. package/src/{agent-tool-runtime.ts → adapter/cloudflare/agent-tool.ts} +1 -1
  3. package/src/adapter/cloudflare/agent.ts +1539 -0
  4. package/src/{db/index.ts → adapter/cloudflare/database.ts} +18 -55
  5. package/src/{runtime-agent.ts → adapter/cloudflare/define-agent.ts} +25 -35
  6. package/src/adapter/cloudflare/dynamic-worker-observability.ts +1 -1
  7. package/src/adapter/cloudflare/resources/runtime-resources.ts +4 -4
  8. package/src/adapter/cloudflare/sandbox/adapter.ts +1 -1
  9. package/src/{db → adapter/cloudflare}/schema.ts +16 -83
  10. package/src/adapter/cloudflare/subagent/definition.ts +63 -33
  11. package/src/adapter/cloudflare/subagent/runner.ts +174 -120
  12. package/src/adapter/cloudflare/subagent/tools.ts +13 -14
  13. package/src/adapter/cloudflare/universal-agent/preparation.ts +7 -7
  14. package/src/adapter/cloudflare/universal-agent/tools.ts +10 -10
  15. package/src/adapter/cloudflare/workspace/git-fs.ts +1 -1
  16. package/src/adapter/cloudflare/workspace/scoped-workspace.ts +1 -1
  17. package/src/adapter/cloudflare/workspace/version-control.ts +2 -2
  18. package/src/config/activation.ts +140 -0
  19. package/src/{runtime-agent-context.ts → config/agent-context.ts} +3 -3
  20. package/src/{runtime-assembler.ts → config/assembler.ts} +20 -19
  21. package/src/{kernel/runtime-assembly.ts → config/assembly-projection.ts} +4 -4
  22. package/src/{pi/assembly/snapshot.ts → config/assembly-snapshot.ts} +6 -6
  23. package/src/{runtime-definition.ts → config/definition.ts} +13 -13
  24. package/src/config/harness-runtime.ts +182 -0
  25. package/src/config/index.ts +14 -0
  26. package/src/{pi/runtime-adapter/assembly.ts → config/pinning.ts} +38 -16
  27. package/src/{kernel/bindings.ts → config/ports.ts} +9 -13
  28. package/src/{kernel → config}/profile.ts +7 -0
  29. package/src/{kernel → config}/public-contracts.ts +6 -6
  30. package/src/{kernel → config}/runtime-load.ts +1 -1
  31. package/src/{pi/assembly/context.ts → context/assembly.ts} +4 -206
  32. package/src/{pi/assembly/extensions.ts → context/extension/assembly.ts} +5 -5
  33. package/src/context/extension/index.ts +3 -0
  34. package/src/context/index.ts +6 -0
  35. package/src/{layers/context → context/memory}/budget/gate.ts +2 -2
  36. package/src/conversation/harness-resume.ts +288 -0
  37. package/src/conversation/harness-stream.ts +98 -0
  38. package/src/{pi/runtime-adapter/transcript.ts → conversation/history.ts} +206 -233
  39. package/src/conversation/index.ts +6 -0
  40. package/src/{pi → conversation}/message/contract.ts +3 -0
  41. package/src/{pi → conversation}/message/conversion.ts +75 -0
  42. package/src/{pi → conversation}/message/index.ts +1 -0
  43. package/src/{pi → conversation}/message/model-input.ts +1 -1
  44. package/src/{pi → conversation}/message/projection.ts +76 -10
  45. package/src/{db/message-ui.repo.ts → conversation/repository.ts} +4 -0
  46. package/src/conversation/state.ts +175 -0
  47. package/src/harness/index.ts +7 -0
  48. package/src/harness/session-harness.ts +481 -0
  49. package/src/harness/submission-operation.ts +49 -0
  50. package/src/index.ts +38 -43
  51. package/src/lib/mcp.ts +1 -1
  52. package/src/{pi/runtime-adapter/models.ts → models/index.ts} +65 -17
  53. package/src/{pi/turn/tool-recovery.ts → session/cloudflare/legacy-tool-recovery.ts} +33 -525
  54. package/src/{pi/session → session/cloudflare}/migration.ts +334 -8
  55. package/src/{pi/session → session/cloudflare}/storage.ts +8 -36
  56. package/src/session/index.ts +39 -0
  57. package/src/submission/admission.ts +275 -0
  58. package/src/{kernel/receipts.ts → submission/contracts.ts} +14 -0
  59. package/src/submission/delivery.ts +212 -0
  60. package/src/submission/finalization.ts +111 -0
  61. package/src/submission/index.ts +9 -0
  62. package/src/{kernel/submission-lifecycle.ts → submission/lifecycle.ts} +41 -28
  63. package/src/{db/submission.repo.ts → submission/repository.ts} +61 -118
  64. package/src/submission/steer-delivery.ts +167 -0
  65. package/src/{db/steer.repo.ts → submission/steer-repository.ts} +27 -5
  66. package/src/telemetry/contract.ts +12 -0
  67. package/src/telemetry/coordinator.ts +65 -0
  68. package/src/telemetry/delivery.ts +2 -2
  69. package/src/telemetry/index.ts +5 -0
  70. package/src/{model-usage.ts → telemetry/model-usage.ts} +1 -1
  71. package/src/telemetry/recorder.ts +1 -1
  72. package/src/telemetry/runtime-telemetry.ts +12 -0
  73. package/src/{kernel/durable-lifecycle.ts → telemetry/subagent-lifecycle.ts} +1 -1
  74. package/src/telemetry/turn-events.ts +279 -0
  75. package/src/{pi/tool → tools}/ai-adapter.ts +2 -2
  76. package/src/tools/approval/index.ts +1 -0
  77. package/src/{db/approval.repo.ts → tools/approval/repository.ts} +10 -30
  78. package/src/{pi/tool → tools}/base.ts +14 -30
  79. package/src/tools/compiler.ts +327 -0
  80. package/src/{pi/tool → tools}/core-host.ts +3 -3
  81. package/src/{pi/tool → tools}/core.ts +3 -3
  82. package/src/tools/decision-gate.ts +227 -0
  83. package/src/{pi/tool → tools}/gateway.ts +1 -1
  84. package/src/tools/human-decisions.ts +127 -0
  85. package/src/tools/index.ts +30 -0
  86. package/src/tools/interaction/index.ts +1 -0
  87. package/src/{pi/tool → tools}/mcp.ts +1 -1
  88. package/src/{pi/tool → tools}/schedule.ts +3 -3
  89. package/src/{pi/tool → tools}/skill.ts +4 -4
  90. package/src/{kernel → tools}/subagent-runtime.ts +1 -1
  91. package/src/{pi/tool → tools}/subagent.ts +4 -4
  92. package/src/tools/temporary-agent/approvals.ts +126 -0
  93. package/src/{layers/orchestration/temporary-agent/core.ts → tools/temporary-agent/coordinator.ts} +41 -49
  94. package/src/tools/temporary-agent/core.ts +53 -0
  95. package/src/{layers/orchestration → tools}/temporary-agent/runner.ts +1 -1
  96. package/src/{layers/orchestration → tools}/temporary-agent/workspace.ts +1 -1
  97. package/src/{pi/tool → tools}/web-search/api.ts +1 -1
  98. package/src/{pi/tool → tools}/workspace-revision.ts +2 -2
  99. package/src/{pi/tool → tools}/workspace-sandbox.ts +2 -2
  100. package/src/{pi/tool → tools}/workspace-state-connector.ts +1 -1
  101. package/src/db/milestone.repo.ts +0 -104
  102. package/src/db/settlement.repo.ts +0 -104
  103. package/src/kernel/approval-lifecycle.ts +0 -638
  104. package/src/kernel/interaction-lifecycle.ts +0 -433
  105. package/src/kernel/recoverable-chat-agent.ts +0 -1020
  106. package/src/kernel/state.ts +0 -91
  107. package/src/pi/assembly/index.ts +0 -19
  108. package/src/pi/runtime-adapter/execution.ts +0 -1163
  109. package/src/pi/runtime-adapter/index.ts +0 -293
  110. package/src/pi/runtime-adapter/recovery.ts +0 -982
  111. package/src/pi/session/index.ts +0 -24
  112. package/src/pi/tool/compiler.ts +0 -483
  113. package/src/pi/tool/index.ts +0 -38
  114. package/src/pi/turn/approval.ts +0 -181
  115. package/src/pi/turn/index.ts +0 -82
  116. package/src/pi/turn/interaction.ts +0 -181
  117. package/src/runtime.ts +0 -4760
  118. /package/src/{kernel/runtime-assembly-view.ts → config/assembly-view.ts} +0 -0
  119. /package/src/{kernel → config}/degradation.ts +0 -0
  120. /package/src/{kernel → config}/runtime-config.ts +0 -0
  121. /package/src/{kernel/extensions.ts → context/extension/contracts.ts} +0 -0
  122. /package/src/{db/ext-context.repo.ts → context/extension/repository.ts} +0 -0
  123. /package/src/{pi/runtime-adapter/openrouter-messages.ts → models/openrouter/messages.ts} +0 -0
  124. /package/src/{pi/runtime-adapter → models/openrouter}/test-fixtures/openrouter-tool-search.sse +0 -0
  125. /package/src/{db/submission-admission.repo.ts → submission/admission-repository.ts} +0 -0
  126. /package/src/{db/telemetry-outbox.repo.ts → telemetry/repository.ts} +0 -0
  127. /package/src/{db/runtime-event-outbox.repo.ts → telemetry/runtime-event-repository.ts} +0 -0
  128. /package/src/{pi/tool → tools}/declared.ts +0 -0
  129. /package/src/{db/interaction.repo.ts → tools/interaction/repository.ts} +0 -0
  130. /package/src/{pi/tool → tools}/nested-tools.ts +0 -0
  131. /package/src/{kernel/submission-authority.ts → tools/subagent-authority.ts} +0 -0
  132. /package/src/{db/agent-tool.repo.ts → tools/subagent-run-repository.ts} +0 -0
  133. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/contract.ts +0 -0
  134. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/extract/index.ts +0 -0
  135. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/fanout/index.ts +0 -0
  136. /package/src/{layers/orchestration/subagents/agent-types → tools/subagent-types}/registry.ts +0 -0
  137. /package/src/{pi/tool → tools}/time.ts +0 -0
  138. /package/src/{pi/tool → tools}/web-fetch.ts +0 -0
  139. /package/src/{pi/tool → tools}/web-search/index.ts +0 -0
  140. /package/src/{pi/tool → tools}/web-search/web-search.ts +0 -0
  141. /package/src/{workspace-versioning.ts → tools/workspace-versioning.ts} +0 -0
@@ -9,10 +9,10 @@ import {
9
9
  type TSchema,
10
10
  } from "@earendil-works/pi-ai";
11
11
  import { estimateStringTokens } from "agents/experimental/memory/utils";
12
- import type { RuntimeMemoryPort } from "../../kernel/bindings";
13
- import type { RuntimeMemoryProfile } from "../../kernel/profile";
14
- import { serializeOutput } from "../../lib/artifacts";
15
- import { modelAttachmentText } from "../message/conversion";
12
+ import type { RuntimeMemoryPort } from "../config/ports";
13
+ import type { RuntimeMemoryProfile } from "../config/profile";
14
+ import { serializeOutput } from "../lib/artifacts";
15
+ import { modelAttachmentText } from "../conversation/message/conversion";
16
16
  import type { PiToolCandidate } from "./compiler";
17
17
  import { webSearchPiToolCandidate } from "./web-search";
18
18
  import type { WebSearch } from "./web-search/api";
@@ -88,10 +88,7 @@ function recordOf(value: unknown): Record<string, unknown> | null {
88
88
  : null;
89
89
  }
90
90
 
91
- function questionsOf(
92
- value: unknown,
93
- allowLegacy = false,
94
- ): AskUserInput["questions"] | null {
91
+ function questionsOf(value: unknown): AskUserInput["questions"] | null {
95
92
  const questions = recordOf(value)?.questions;
96
93
  if (!Array.isArray(questions) || questions.length < 1 || questions.length > 10) {
97
94
  return null;
@@ -103,6 +100,10 @@ function questionsOf(
103
100
  if (!question || typeof question.question !== "string" || !question.question) {
104
101
  return null;
105
102
  }
103
+ if (Object.keys(question).some((key) =>
104
+ key !== "question" && key !== "responseType" && key !== "options" &&
105
+ key !== "allowCustom" && key !== "required"
106
+ )) return null;
106
107
  if (
107
108
  question.required !== undefined && typeof question.required !== "boolean"
108
109
  ) return null;
@@ -114,18 +115,7 @@ function questionsOf(
114
115
  responseType as typeof askUserResponseTypes[number],
115
116
  )
116
117
  ) {
117
- if (!allowLegacy || question.responseType !== undefined) return null;
118
- if (question.kind === "attachment") {
119
- responseType = "attachment";
120
- } else if (question.kind === undefined) {
121
- responseType = Array.isArray(question.options)
122
- ? question.multiSelect === true
123
- ? "multi_select"
124
- : "single_select"
125
- : "text";
126
- } else {
127
- return null;
128
- }
118
+ return null;
129
119
  }
130
120
  const normalizedQuestion = {
131
121
  question: question.question,
@@ -137,8 +127,7 @@ function questionsOf(
137
127
  if (
138
128
  attachmentQuestions > 1 ||
139
129
  question.options !== undefined ||
140
- question.allowCustom !== undefined ||
141
- (!allowLegacy && question.multiSelect !== undefined)
130
+ question.allowCustom !== undefined
142
131
  ) return null;
143
132
  normalized.push(normalizedQuestion);
144
133
  continue;
@@ -146,8 +135,7 @@ function questionsOf(
146
135
  if (responseType === "text") {
147
136
  if (
148
137
  question.options !== undefined ||
149
- (!allowLegacy &&
150
- (question.allowCustom !== undefined || question.multiSelect !== undefined))
138
+ question.allowCustom !== undefined
151
139
  ) {
152
140
  return null;
153
141
  }
@@ -165,10 +153,6 @@ function questionsOf(
165
153
  (question.allowCustom !== undefined &&
166
154
  typeof question.allowCustom !== "boolean")
167
155
  ) return null;
168
- if (
169
- !allowLegacy &&
170
- (question.kind !== undefined || question.multiSelect !== undefined)
171
- ) return null;
172
156
  normalized.push({
173
157
  ...normalizedQuestion,
174
158
  options: question.options,
@@ -199,7 +183,7 @@ function prepareAskUserArguments(input: unknown): AskUserInput {
199
183
  // 手写校验而不是拉 TypeBox compiler:这里只需要判真假,
200
184
  // 且校验位于 Worker 响应信任边界,不应为每次回答编译 schema。
201
185
  function isAskUserResponse(input: unknown, value: unknown): boolean {
202
- const questions = questionsOf(input, true);
186
+ const questions = questionsOf(input);
203
187
  const answers = recordOf(value)?.answers;
204
188
  if (!questions || !Array.isArray(answers) || answers.length !== questions.length) {
205
189
  return false;
@@ -400,7 +384,7 @@ export function basePiToolCandidates(
400
384
  interaction: {
401
385
  validateResponse: isAskUserResponse,
402
386
  settle: (input, response) => {
403
- const questions = questionsOf(input, true)!;
387
+ const questions = questionsOf(input)!;
404
388
  const responseAnswers = (response as AskUserResponse).answers;
405
389
  const answers = questions.map((question, index) => {
406
390
  const answer = responseAnswers[index]!;
@@ -0,0 +1,327 @@
1
+ import type {
2
+ AgentHarnessTool,
3
+ AgentTool,
4
+ AgentToolResult,
5
+ } from "@earendil-works/pi-agent-core";
6
+ import type { ApprovalReceipt } from "../submission/contracts";
7
+ import {
8
+ boundDurableToolOutput,
9
+ type BudgetPolicy,
10
+ spillDurableToolOutput,
11
+ } from "../context/memory/budget/gate";
12
+ import {
13
+ type ArtifactRef,
14
+ serializeOutput,
15
+ type SpillWorkspace,
16
+ } from "../lib/artifacts";
17
+ import {
18
+ type ExecutionLevel,
19
+ } from "../lib/execution-level";
20
+
21
+ // 本文件沿用 `../../index.ts` 入口定义的 Tool Candidate 和 Tool Settlement 术语。
22
+
23
+ // #region Public contracts
24
+
25
+ /**
26
+ * 声明一个 Tool 的结果由客户端投递,而不是由 `execute()` 产出。
27
+ *
28
+ * 带这个字段的 Tool 会在执行链里 park 住,等宿主经 `respondToolInteraction` 把响应送进来,
29
+ * 再用 `settle` 把响应映射成这次 `toolCallId` 的权威 ToolResult。它的 `execute` 不会被调用。
30
+ *
31
+ * 这是 AI SDK 里「没有 execute 的 user-interaction tool」的等价物;差别是我们 park 住整个 Turn,
32
+ * 而不是让 tool part 悬空到下一次请求 —— 我们的 transcript 是服务端权威的,悬空的 tool_use
33
+ * 会让下一次模型调用非法。
34
+ */
35
+ export interface PiToolInteractionSpec {
36
+ /** 结合 Tool 输入校验客户端响应体;不通过则拒绝投递,park 保持不变。 */
37
+ readonly validateResponse: (input: unknown, response: unknown) => boolean;
38
+ /** 把通过校验的响应映射成 ToolResult;缺省是原样回传。 */
39
+ readonly settle?: (
40
+ input: unknown,
41
+ response: unknown,
42
+ ) => Omit<AgentToolResult<unknown>, "terminate">;
43
+ }
44
+
45
+ /** 描述一个尚未进入最终 Tool Surface 和结算包装的 Pi 工具。 */
46
+ export type ToolExposureMode = "direct" | "codemode" | "both";
47
+
48
+ export interface PiToolCandidate {
49
+ readonly owner: string;
50
+ readonly tool: AgentTool<any, any>;
51
+ /** @internal Send the complete schema but hide it until provider Tool Search finds it. */
52
+ readonly deferLoading?: true;
53
+ /** Omitted means visible both directly and through Code Mode. */
54
+ readonly exposureMode?: ToolExposureMode;
55
+ /** Conservative maximum used in the stable Runtime descriptor. */
56
+ readonly requiredExecutionLevel: ExecutionLevel;
57
+ /** Trusted parameter-level policy, evaluated before approval or dispatch. */
58
+ readonly requiredExecutionLevelForInput?: (
59
+ input: unknown,
60
+ ) => ExecutionLevel | Promise<ExecutionLevel>;
61
+ /**
62
+ * 无条件需要人来决定:不看执行档位矩阵,每次调用都 park 出审批。
63
+ *
64
+ * 档位差是「够不够格自己跑」,不是「该不该问人」。花用户钱、改变账户
65
+ * 状态这类工具,一旦 Agent 档位被 `allow_level` 永久拉高就会静默执行,
66
+ * 那是失效不是降级。`deny` 仍然优先,被 deny 的工具根本不会注入。
67
+ */
68
+ readonly alwaysRequiresApproval?: boolean;
69
+ /** Recovery may replay this Tool Call only when the candidate guarantees convergence. Omitted means non-idempotent. */
70
+ readonly retry?: "idempotent" | "non-idempotent";
71
+ readonly summary?: string;
72
+ readonly source?: ApprovalReceipt["source"];
73
+ readonly interaction?: PiToolInteractionSpec;
74
+ /** Large payloads spill by default; protocol-shaped results opt into structure. */
75
+ readonly outputBudget?: BudgetPolicy;
76
+ }
77
+
78
+ export interface CompileHarnessToolsOptions {
79
+ readonly workspace?: SpillWorkspace;
80
+ /** Recheck live authorization at the effect boundary, including safe replay. */
81
+ readonly authorize?: (
82
+ candidate: PiToolCandidate,
83
+ args: unknown,
84
+ invocation: { readonly toolCallId: string; readonly signal: AbortSignal },
85
+ ) => void | Promise<void>;
86
+ /** Return an already decided interaction result without invoking the handler. */
87
+ readonly resolveReplay?: (
88
+ candidate: PiToolCandidate,
89
+ toolCallId: string,
90
+ args: unknown,
91
+ ) => AgentToolResult<unknown> | undefined | Promise<AgentToolResult<unknown> | undefined>;
92
+ /** Optional business projection; Pi remains the authoritative Tool result. */
93
+ readonly onSettlement?: (
94
+ call: SettledPiToolCall,
95
+ ) => void | Promise<void>;
96
+ }
97
+
98
+ /** 描述一次已进入持久化结算边界的 Pi 工具调用。 */
99
+ export interface SettledPiToolCall {
100
+ readonly toolCallId: string;
101
+ readonly toolName: string;
102
+ readonly args: unknown;
103
+ readonly result: AgentToolResult<unknown>;
104
+ readonly isError: boolean;
105
+ /** Diagnostic-only original error; callers must not persist or expose it. */
106
+ readonly cause?: unknown;
107
+ readonly summary?: string;
108
+ readonly source?: ApprovalReceipt["source"];
109
+ }
110
+
111
+ // DO SQLite rows also contain arguments and transcript/recovery envelopes.
112
+ // Keep each serialized result below half the 2 MiB row limit.
113
+ const DURABLE_TOOL_RESULT_MAX_BYTES = 1024 * 1024;
114
+ const MEDIA_CAPTION_MAX_CHARS = 4 * 1024;
115
+
116
+ // #endregion
117
+
118
+ // #region Governance helpers
119
+
120
+ // 把任意失败值收敛为可安全显示的简短消息。
121
+ // 受治理工具在工具执行或结算抛错时调用它。
122
+ // 读取错误自身也可能抛错,因此最外层必须保留固定回退文本。
123
+ function errorMessage(error: unknown): string {
124
+ try {
125
+ if (error instanceof Error) {
126
+ return error.message || error.name;
127
+ }
128
+ return String(error);
129
+ } catch {
130
+ return "Tool execution failed";
131
+ }
132
+ }
133
+
134
+ // Most Pi adapters keep the original Tool value in details and its serialized
135
+ // model form in one text block. Spill that original once instead of duplicating
136
+ // it inside an AgentToolResult JSON envelope.
137
+ function spillPayload(result: AgentToolResult<unknown>): unknown {
138
+ const text = result.content.length === 1 && result.content[0]?.type === "text"
139
+ ? result.content[0].text
140
+ : undefined;
141
+ if (text !== undefined && serializeOutput(result.details).text === text) {
142
+ return result.details;
143
+ }
144
+ return result;
145
+ }
146
+
147
+ function artifactResult(
148
+ result: AgentToolResult<unknown>,
149
+ ref: ArtifactRef,
150
+ ): AgentToolResult<unknown> {
151
+ const presentation = result.details !== null && typeof result.details === "object"
152
+ ? (result.details as Record<string, unknown>).presentation
153
+ : undefined;
154
+ let captionRemaining = MEDIA_CAPTION_MAX_CHARS;
155
+ const mediaContent = result.content.some((part) => part.type !== "text")
156
+ ? result.content.flatMap((part): AgentToolResult<unknown>["content"] => {
157
+ if (part.type !== "text") return [part];
158
+ const text = part.text.slice(0, captionRemaining);
159
+ captionRemaining -= text.length;
160
+ return text ? [{ ...part, text }] : [];
161
+ })
162
+ : [];
163
+ return {
164
+ ...result,
165
+ content: [{
166
+ type: "text",
167
+ text: [
168
+ ref.note,
169
+ `Path: ${ref.path}`,
170
+ `Bytes: ${ref.bytes}`,
171
+ `Preview:\n${ref.preview}`,
172
+ ].join("\n"),
173
+ }, ...mediaContent],
174
+ details: presentation === undefined ? ref : { ...ref, presentation },
175
+ };
176
+ }
177
+
178
+ async function durableResult(
179
+ result: AgentToolResult<unknown>,
180
+ candidate: PiToolCandidate,
181
+ workspace: SpillWorkspace | undefined,
182
+ ): Promise<AgentToolResult<unknown>> {
183
+ const spill = (candidate.outputBudget ?? { kind: "spill" }).kind === "spill";
184
+ let ref = spill
185
+ ? await spillDurableToolOutput(spillPayload(result), { workspace })
186
+ : null;
187
+ const bounded = ref
188
+ ? artifactResult(result, ref)
189
+ : boundDurableToolOutput(result) as AgentToolResult<unknown>;
190
+ const bytes = new TextEncoder().encode(JSON.stringify(bounded)).byteLength;
191
+ if (bytes <= DURABLE_TOOL_RESULT_MAX_BYTES) return bounded;
192
+
193
+ // Binary blocks cannot be truncated, and this boundary has no image resizer.
194
+ // Preserve the original where possible and fail before any SQLite write.
195
+ if (!spill) ref = await spillDurableToolOutput(spillPayload(result), { workspace });
196
+ throw new Error(
197
+ `Tool output exceeds the 1 MiB durable result limit (${bytes} bytes). ` +
198
+ "Return a smaller result, such as lower-resolution images or fewer media items; the original cannot be included inline. " +
199
+ (ref ? `Original output saved to Workspace: ${ref.path}` : "The original output could not be saved to Workspace."),
200
+ );
201
+ }
202
+
203
+ async function untilAborted<T>(
204
+ signal: AbortSignal | undefined,
205
+ run: () => T | Promise<T>,
206
+ ): Promise<T> {
207
+ // ponytail: 这会立即释放 Turn,但无法杀死不配合的进程内副作用;需要物理终止的 Tool 必须放进可销毁的隔离执行器。
208
+ signal?.throwIfAborted();
209
+ if (!signal) return run();
210
+ let onAbort!: () => void;
211
+ const aborted = new Promise<never>((_resolve, reject) => {
212
+ onAbort = () => reject(
213
+ signal.reason ?? new DOMException("Aborted", "AbortError"),
214
+ );
215
+ signal.addEventListener("abort", onAbort, { once: true });
216
+ });
217
+ try {
218
+ return await Promise.race([run(), aborted]);
219
+ } finally {
220
+ signal.removeEventListener("abort", onAbort);
221
+ }
222
+ }
223
+
224
+ // #endregion
225
+
226
+ // #region Governance and compilation
227
+
228
+ /**
229
+ * Convert the configured candidate surface once at the AgentHarness boundary.
230
+ * Schema, argument preparation, progress, cancellation, replay classification,
231
+ * and output bounds stay attached to the original Tool declaration.
232
+ */
233
+ export function compileHarnessTools(
234
+ candidates: readonly PiToolCandidate[],
235
+ options: CompileHarnessToolsOptions = {},
236
+ ): AgentHarnessTool<object | undefined>[] {
237
+ return candidates.map((candidate) => {
238
+ const source = candidate.tool;
239
+ const tool = source.constrainedSampling === false
240
+ ? source
241
+ : {
242
+ ...source,
243
+ constrainedSampling: source.constrainedSampling ?? {
244
+ type: "json_schema" as const,
245
+ strict: "prefer" as const,
246
+ },
247
+ };
248
+ return {
249
+ ...tool,
250
+ replay: candidate.retry === "idempotent" &&
251
+ candidate.requiredExecutionLevel === "safe" &&
252
+ !candidate.alwaysRequiresApproval &&
253
+ !candidate.requiredExecutionLevelForInput &&
254
+ !candidate.interaction
255
+ ? "safe" as const
256
+ : "never" as const,
257
+ ...(candidate.deferLoading ? { deferLoading: true as const } : {}),
258
+ async execute(
259
+ toolCallId,
260
+ args,
261
+ onUpdate,
262
+ _toolContext,
263
+ _invocation,
264
+ context,
265
+ ) {
266
+ const signal = context.abortSignal ?? new AbortController().signal;
267
+ await options.authorize?.(candidate, args, {
268
+ toolCallId,
269
+ signal,
270
+ });
271
+ const replay = await options.resolveReplay?.(
272
+ candidate,
273
+ toolCallId,
274
+ args,
275
+ );
276
+ if (replay) return durableResult(replay, candidate, options.workspace);
277
+ let result: AgentToolResult<unknown>;
278
+ try {
279
+ result = await untilAborted(
280
+ signal,
281
+ async () => durableResult(
282
+ await source.execute(
283
+ toolCallId,
284
+ args,
285
+ signal,
286
+ (update) => onUpdate(update),
287
+ ),
288
+ candidate,
289
+ options.workspace,
290
+ ),
291
+ );
292
+ } catch (cause) {
293
+ const message = errorMessage(cause);
294
+ const failed = boundDurableToolOutput({
295
+ content: [{ type: "text", text: message }],
296
+ details: {},
297
+ }) as AgentToolResult<unknown>;
298
+ await options.onSettlement?.({
299
+ toolCallId,
300
+ toolName: source.name,
301
+ args,
302
+ result: failed,
303
+ isError: true,
304
+ cause,
305
+ summary: candidate.summary,
306
+ source: candidate.source,
307
+ });
308
+ throw cause instanceof Error && cause.message === message
309
+ ? cause
310
+ : new Error(message, { cause });
311
+ }
312
+ await options.onSettlement?.({
313
+ toolCallId,
314
+ toolName: source.name,
315
+ args,
316
+ result,
317
+ isError: false,
318
+ summary: candidate.summary,
319
+ source: candidate.source,
320
+ });
321
+ return result;
322
+ },
323
+ } satisfies AgentHarnessTool<object | undefined>;
324
+ });
325
+ }
326
+
327
+ // #endregion
@@ -21,14 +21,14 @@ import type {
21
21
  RuntimeBrowserPort,
22
22
  RuntimeCodeExecutionPort,
23
23
  WorkspacePort,
24
- } from "../../kernel/bindings";
24
+ } from "../config/ports";
25
25
  import {
26
26
  WorkspaceStateConnector,
27
27
  WORKSPACE_PAGING_INSTRUCTIONS,
28
28
  WORKSPACE_STATE_TYPES,
29
29
  } from "./workspace-state-connector";
30
- import type { RuntimeCodeExecutionFactory } from "../../kernel/bindings";
31
- import { serializeOutput } from "../../lib/artifacts";
30
+ import type { RuntimeCodeExecutionFactory } from "../config/ports";
31
+ import { serializeOutput } from "../lib/artifacts";
32
32
  import type { PiToolCandidate } from "./compiler";
33
33
  import { piCandidatesToAiTools } from "./nested-tools";
34
34
 
@@ -5,9 +5,9 @@ import type {
5
5
  import { Type } from "@earendil-works/pi-ai";
6
6
  import type {
7
7
  RuntimeCodeExecutionPort,
8
- } from "../../kernel/bindings";
9
- import { serializeOutput } from "../../lib/artifacts";
10
- import type { PiLoadedExtension } from "../assembly/extensions";
8
+ } from "../config/ports";
9
+ import { serializeOutput } from "../lib/artifacts";
10
+ import type { PiLoadedExtension } from "../context/extension/assembly";
11
11
  import type { PiToolCandidate } from "./compiler";
12
12
 
13
13
  // 本文件沿用 `../../index.ts` 入口定义的 Extension、Port 和 Tool Candidate 术语。
@@ -0,0 +1,227 @@
1
+ import type {
2
+ AgentToolResult,
3
+ HookHandler,
4
+ } from "@earendil-works/pi-agent-core";
5
+ import type { ExecutionLevel } from "../lib/execution-level";
6
+ import {
7
+ EXECUTION_LEVELS,
8
+ requiresExecutionApproval,
9
+ } from "../lib/execution-level";
10
+ import type { PiToolCandidate } from "./compiler";
11
+ import type { ApprovalRepository } from "./approval";
12
+ import type { ToolInteractionRepository } from "./interaction";
13
+ import type { RuntimeTelemetryCoordinator } from "../telemetry/coordinator";
14
+
15
+ export interface HarnessDecisionSubmission {
16
+ readonly submissionId: string;
17
+ readonly requestId: string;
18
+ readonly executionLevel: ExecutionLevel;
19
+ }
20
+
21
+ export interface HarnessDecisionGateOptions {
22
+ readonly submission: HarnessDecisionSubmission;
23
+ readonly candidates: readonly PiToolCandidate[];
24
+ readonly approvals: ApprovalRepository;
25
+ readonly interactions: ToolInteractionRepository;
26
+ readonly transaction: <T>(run: () => T) => T;
27
+ readonly release: (reason: "approval" | "interaction") => void;
28
+ readonly telemetry?: Pick<RuntimeTelemetryCoordinator, "capture">;
29
+ readonly onChanged?: (
30
+ kind: "approval" | "interaction",
31
+ id: string,
32
+ ) => void | Promise<void>;
33
+ readonly gateTool?: (input: {
34
+ readonly toolCallId: string;
35
+ readonly toolName: string;
36
+ readonly input: unknown;
37
+ readonly requiredExecutionLevel: ExecutionLevel;
38
+ readonly signal: AbortSignal;
39
+ }) => void | Promise<void>;
40
+ }
41
+
42
+ function waitForRelease(signal: AbortSignal): Promise<never> {
43
+ return new Promise((_resolve, reject) => {
44
+ const abort = () => {
45
+ signal.removeEventListener("abort", abort);
46
+ reject(signal.reason ?? new DOMException("Aborted", "AbortError"));
47
+ };
48
+ if (signal.aborted) return abort();
49
+ signal.addEventListener("abort", abort, { once: true });
50
+ });
51
+ }
52
+
53
+ function operationSignal(signal: AbortSignal | undefined): AbortSignal {
54
+ if (!signal) throw new Error("Harness Tool hook has no operation signal");
55
+ return signal;
56
+ }
57
+
58
+ function stableInput(input: unknown): string {
59
+ return JSON.stringify(input);
60
+ }
61
+
62
+ function interactionResult(
63
+ response: unknown,
64
+ ): AgentToolResult<unknown> {
65
+ return {
66
+ content: [{ type: "text", text: JSON.stringify(response) }],
67
+ details: response,
68
+ };
69
+ }
70
+
71
+ /** Durable policy facts around Pi's before_tool hook; Pi owns execution state. */
72
+ export class HarnessDecisionGate {
73
+ private readonly candidates: ReadonlyMap<string, PiToolCandidate>;
74
+
75
+ constructor(private readonly options: HarnessDecisionGateOptions) {
76
+ this.candidates = new Map(
77
+ options.candidates.map((candidate) => [candidate.tool.name, candidate]),
78
+ );
79
+ }
80
+
81
+ readonly beforeTool: HookHandler<"before_tool"> = async (event, context) => {
82
+ const candidate = this.candidates.get(event.toolName);
83
+ if (!candidate) return undefined;
84
+ const requiredExecutionLevel = candidate.requiredExecutionLevelForInput
85
+ ? await candidate.requiredExecutionLevelForInput(event.args)
86
+ : candidate.requiredExecutionLevel;
87
+ if (!EXECUTION_LEVELS.includes(requiredExecutionLevel)) {
88
+ throw new Error(
89
+ `SpringBrand Tool "${event.toolName}" resolved an invalid execution level`,
90
+ );
91
+ }
92
+ const executionId = `${this.options.submission.submissionId}:${event.toolCallId}`;
93
+ const existing = this.options.approvals.find(executionId);
94
+ if (existing?.status === "rejected") {
95
+ return { block: { reason: existing.reason ?? "Tool execution denied by user" } };
96
+ }
97
+ const needsApproval = candidate.alwaysRequiresApproval ||
98
+ requiresExecutionApproval(
99
+ this.options.submission.executionLevel,
100
+ requiredExecutionLevel,
101
+ );
102
+ if (needsApproval && existing?.status !== "approved") {
103
+ if (existing && (
104
+ existing.requestId !== this.options.submission.requestId ||
105
+ existing.toolName !== event.toolName ||
106
+ existing.inputJson !== stableInput(event.args)
107
+ )) {
108
+ throw new Error(`Conflicting durable Tool approval: ${event.toolCallId}`);
109
+ }
110
+ if (!existing) {
111
+ const createdAt = Date.now();
112
+ this.options.transaction(() => this.options.approvals.insert({
113
+ executionId,
114
+ submissionId: this.options.submission.submissionId,
115
+ requestId: this.options.submission.requestId,
116
+ source: candidate.source ?? "action",
117
+ toolCallId: event.toolCallId,
118
+ toolName: event.toolName,
119
+ summary: candidate.summary ?? candidate.tool.label ?? event.toolName,
120
+ executionLevel: this.options.submission.executionLevel,
121
+ requiredExecutionLevel,
122
+ inputJson: stableInput(event.args),
123
+ status: "pending",
124
+ createdAt,
125
+ }));
126
+ this.options.telemetry?.capture("approvalRequested", {
127
+ submissionId: this.options.submission.submissionId,
128
+ toolCallId: event.toolCallId,
129
+ executionId,
130
+ toolName: event.toolName,
131
+ summary: candidate.summary ?? candidate.tool.label ?? event.toolName,
132
+ input: event.args,
133
+ occurredAt: createdAt,
134
+ });
135
+ await this.options.onChanged?.("approval", executionId);
136
+ }
137
+ this.options.release("approval");
138
+ return waitForRelease(operationSignal(context.abortSignal));
139
+ }
140
+
141
+ if (!candidate.interaction) return undefined;
142
+ const interaction = this.options.interactions.find(executionId);
143
+ if (interaction?.status === "responded" || interaction?.status === "cancelled") {
144
+ return undefined;
145
+ }
146
+ if (interaction && (
147
+ interaction.requestId !== this.options.submission.requestId ||
148
+ interaction.toolName !== event.toolName ||
149
+ interaction.inputJson !== stableInput(event.args)
150
+ )) {
151
+ throw new Error(`Conflicting durable Tool interaction: ${event.toolCallId}`);
152
+ }
153
+ if (!interaction) {
154
+ const createdAt = Date.now();
155
+ this.options.transaction(() => this.options.interactions.insert({
156
+ interactionId: executionId,
157
+ submissionId: this.options.submission.submissionId,
158
+ requestId: this.options.submission.requestId,
159
+ toolCallId: event.toolCallId,
160
+ toolName: event.toolName,
161
+ inputJson: stableInput(event.args),
162
+ status: "pending",
163
+ createdAt,
164
+ }));
165
+ this.options.telemetry?.capture("interactionRequested", {
166
+ submissionId: this.options.submission.submissionId,
167
+ toolCallId: event.toolCallId,
168
+ interactionId: executionId,
169
+ toolName: event.toolName,
170
+ input: event.args,
171
+ occurredAt: createdAt,
172
+ });
173
+ await this.options.onChanged?.("interaction", executionId);
174
+ }
175
+ this.options.release("interaction");
176
+ return waitForRelease(operationSignal(context.abortSignal));
177
+ };
178
+
179
+ async authorize(
180
+ candidate: PiToolCandidate,
181
+ args: unknown,
182
+ invocation: { readonly toolCallId: string; readonly signal: AbortSignal },
183
+ ): Promise<void> {
184
+ const requiredExecutionLevel = candidate.requiredExecutionLevelForInput
185
+ ? await candidate.requiredExecutionLevelForInput(args)
186
+ : candidate.requiredExecutionLevel;
187
+ await this.options.gateTool?.({
188
+ toolCallId: invocation.toolCallId,
189
+ toolName: candidate.tool.name,
190
+ input: args,
191
+ requiredExecutionLevel,
192
+ signal: invocation.signal,
193
+ });
194
+ }
195
+
196
+ resolveReplay(
197
+ candidate: PiToolCandidate,
198
+ toolCallId: string,
199
+ args: unknown,
200
+ ): AgentToolResult<unknown> | undefined {
201
+ if (!candidate.interaction) return undefined;
202
+ const interaction = this.options.interactions.find(
203
+ `${this.options.submission.submissionId}:${toolCallId}`,
204
+ );
205
+ if (!interaction || interaction.status === "pending") return undefined;
206
+ if (interaction.inputJson !== stableInput(args)) {
207
+ throw new Error(`Conflicting durable Tool interaction: ${toolCallId}`);
208
+ }
209
+ if (interaction.status === "cancelled") {
210
+ return {
211
+ content: [{
212
+ type: "text",
213
+ text: "The interaction was cancelled before the user answered.",
214
+ }],
215
+ details: { cancelled: true },
216
+ };
217
+ }
218
+ const response = JSON.parse(interaction.responseJson ?? "null");
219
+ if (!candidate.interaction.validateResponse(args, response)) {
220
+ throw new Error(`Stored Tool interaction response is invalid: ${toolCallId}`);
221
+ }
222
+ const settled = candidate.interaction.settle?.(args, response);
223
+ return settled
224
+ ? { ...settled, terminate: false }
225
+ : interactionResult(response);
226
+ }
227
+ }