@springbrand/agent-runtime 0.3.0-alpha.26 → 0.3.0-alpha.28
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.
- package/package.json +2 -2
- package/src/adapter/cloudflare/agent.ts +53 -4
- package/src/adapter/cloudflare/database.ts +2 -2
- package/src/adapter/cloudflare/define-agent.ts +10 -6
- package/src/adapter/cloudflare/schema.ts +22 -0
- package/src/config/assembler.ts +4 -0
- package/src/config/harness-runtime.ts +6 -0
- package/src/config/prepared-runtime.ts +1 -0
- package/src/config/public-contracts.ts +7 -0
- package/src/conversation/state.ts +11 -2
- package/src/index.ts +8 -0
- package/src/tools/adapters/pi-to-ai.ts +3 -1
- package/src/tools/runtime/compiler.ts +8 -0
- package/src/tools/runtime/contracts.ts +56 -0
- package/src/tools/runtime/decision-gate.ts +56 -1
- package/src/tools/runtime/human-decisions.ts +68 -2
- package/src/tools/runtime/interaction/repository.ts +97 -8
- package/src/tools/runtime/prerequisite-contracts.ts +31 -0
- package/src/tools/runtime/prerequisite-gate.ts +150 -0
- package/src/tools/runtime/prerequisites.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/agent-runtime",
|
|
3
|
-
"version": "0.3.0-alpha.
|
|
3
|
+
"version": "0.3.0-alpha.28",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"zod": "^4.4.3",
|
|
37
37
|
"@earendil-works/pi-durable": "1.1.0",
|
|
38
38
|
"@earendil-works/chord": "1.1.0",
|
|
39
|
-
"@springbrand/model-client": "0.1.0-alpha.
|
|
39
|
+
"@springbrand/model-client": "0.1.0-alpha.3"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
42
|
"agents": ">=0.28.1 <0.29.0",
|
|
@@ -73,6 +73,8 @@ import type {
|
|
|
73
73
|
} from "@earendil-works/pi-ai";
|
|
74
74
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
75
75
|
import { openCloudflareSession } from "../../session";
|
|
76
|
+
import type { ToolPrerequisiteResponse } from "../../tools/runtime/prerequisite-contracts";
|
|
77
|
+
import { matchesPrerequisite, parsePrerequisiteResponse, projectPrerequisites } from "../../tools/runtime/prerequisites";
|
|
76
78
|
import { ModelRecovery, openSessionHarness, prepareSubmissionOperation } from "../../harness";
|
|
77
79
|
import type { SessionHarness } from "../../harness";
|
|
78
80
|
import {
|
|
@@ -158,6 +160,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
158
160
|
|
|
159
161
|
initialState: RuntimeState = {
|
|
160
162
|
runtimeLoad: { status: "idle", available: false },
|
|
163
|
+
prerequisites: [],
|
|
161
164
|
};
|
|
162
165
|
|
|
163
166
|
protected abstract ensureRuntimeReady(): Promise<void>;
|
|
@@ -763,8 +766,25 @@ export abstract class AgentRuntimeKernel<
|
|
|
763
766
|
}
|
|
764
767
|
}
|
|
765
768
|
|
|
769
|
+
/**
|
|
770
|
+
* Run or recover the Submission while an alarm heartbeat keeps the DO resident.
|
|
771
|
+
*
|
|
772
|
+
* A Turn makes only outbound model and Tool calls, which do not count as DO
|
|
773
|
+
* activity. Once every client disconnects, an unheld DO is evicted after
|
|
774
|
+
* 70-140s, taking the in-memory Turn and its stall watchdog with it while the
|
|
775
|
+
* row stays `running`. If the DO is still evicted (deploy, runtime restart),
|
|
776
|
+
* the pending heartbeat alarm wakes it and `onStart` recovers the Submission.
|
|
777
|
+
* A Turn parked for approval or input returns `released`, which drops the hold.
|
|
778
|
+
*/
|
|
779
|
+
private executeHarnessSubmission(
|
|
780
|
+
submission: StoredSubmission,
|
|
781
|
+
mode: SubmissionExecutionMode,
|
|
782
|
+
): Promise<StoredSubmission> {
|
|
783
|
+
return this.keepAliveWhile(() => this.runHarnessSubmission(submission, mode));
|
|
784
|
+
}
|
|
785
|
+
|
|
766
786
|
/** Run or recover the Submission through Pi's single durable AgentHarness. */
|
|
767
|
-
private async
|
|
787
|
+
private async runHarnessSubmission(
|
|
768
788
|
submission: StoredSubmission,
|
|
769
789
|
mode: SubmissionExecutionMode,
|
|
770
790
|
): Promise<StoredSubmission> {
|
|
@@ -826,6 +846,10 @@ export abstract class AgentRuntimeKernel<
|
|
|
826
846
|
interactions: this.db.interactions,
|
|
827
847
|
transaction: (run) => this.db.transaction(run),
|
|
828
848
|
release: (reason) => runtime.requestHumanRelease(reason),
|
|
849
|
+
isSubmissionActive: () => {
|
|
850
|
+
const current = this.db.submissions.find(submissionId);
|
|
851
|
+
return Boolean(current && current.status === "running" && !current.abortReason && !current.unattendedFailure);
|
|
852
|
+
},
|
|
829
853
|
onChanged: async (kind, id) => {
|
|
830
854
|
await this.broadcastApprovals();
|
|
831
855
|
if (kind === "approval") {
|
|
@@ -1513,11 +1537,12 @@ export abstract class AgentRuntimeKernel<
|
|
|
1513
1537
|
};
|
|
1514
1538
|
}
|
|
1515
1539
|
|
|
1516
|
-
/**
|
|
1540
|
+
/** Answers settle a Tool result; full prerequisite decisions control pre-effect waits. */
|
|
1517
1541
|
async respondToolInteraction(
|
|
1518
|
-
toolCallId: string,
|
|
1519
|
-
response
|
|
1542
|
+
toolCallId: string | ToolPrerequisiteResponse,
|
|
1543
|
+
response?: unknown,
|
|
1520
1544
|
): Promise<{ ok: boolean }> {
|
|
1545
|
+
if (typeof toolCallId !== "string") return this.respondPrerequisite(toolCallId);
|
|
1521
1546
|
const pending = this.db.interactions.findPendingByToolCallId(toolCallId);
|
|
1522
1547
|
if (!pending) return { ok: false };
|
|
1523
1548
|
const submission = this.db.submissions.find(pending.submissionId);
|
|
@@ -1552,6 +1577,29 @@ export abstract class AgentRuntimeKernel<
|
|
|
1552
1577
|
return { ok: true };
|
|
1553
1578
|
}
|
|
1554
1579
|
|
|
1580
|
+
private async respondPrerequisite(response: ToolPrerequisiteResponse): Promise<{ ok: boolean }> {
|
|
1581
|
+
const identity = parsePrerequisiteResponse(response);
|
|
1582
|
+
if (!identity) return { ok: false };
|
|
1583
|
+
const pending = this.db.interactions.find(identity.id);
|
|
1584
|
+
if (!matchesPrerequisite(pending, identity) || pending.status !== "pending") return { ok: false };
|
|
1585
|
+
const submission = this.db.submissions.findRunning();
|
|
1586
|
+
if (!submission || submission.submissionId !== pending.submissionId || submission.abortReason) return { ok: false };
|
|
1587
|
+
const active = this.submissions.currentActive();
|
|
1588
|
+
if (active && active.submissionId !== submission.submissionId) return { ok: false };
|
|
1589
|
+
// Decline is Config-independent, like an ordinary ask_user answer. It can win
|
|
1590
|
+
// while OAuth verification or the previous slice's parallel effects drain.
|
|
1591
|
+
if (identity.decision === "recheck" && !active) await this.ensureRuntimeVersion(submission.runtimeKey, true);
|
|
1592
|
+
const spec = identity.decision === "recheck"
|
|
1593
|
+
? this.pi.prerequisite(this.requireRuntime().pi, pending.toolName) : undefined;
|
|
1594
|
+
const result = await this.humanDecisions.respondPrerequisite(pending, spec, identity);
|
|
1595
|
+
if (!result.ok) return { ok: false };
|
|
1596
|
+
await this.broadcastApprovals();
|
|
1597
|
+
if (result.resume) this.ctx.waitUntil(
|
|
1598
|
+
this.submissions.continueAfterCurrent(submission.submissionId).then(() => undefined),
|
|
1599
|
+
);
|
|
1600
|
+
return { ok: true };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1555
1603
|
/** Apply one decision; the Host must persist allow_level before resuming. */
|
|
1556
1604
|
async decideApproval(
|
|
1557
1605
|
executionId: string,
|
|
@@ -1619,6 +1667,7 @@ export abstract class AgentRuntimeKernel<
|
|
|
1619
1667
|
try {
|
|
1620
1668
|
const { state, changed } = projectRuntimeState({
|
|
1621
1669
|
approvals: this.db.approvals.listPendingActive(),
|
|
1670
|
+
prerequisites: projectPrerequisites(this.db.interactions.listPendingActive()),
|
|
1622
1671
|
temporaryApprovals: this.temporaryAgentApprovals.list(),
|
|
1623
1672
|
running: this.db.submissions.findRunning(),
|
|
1624
1673
|
pending: this.db.submissions.listPending(),
|
|
@@ -21,8 +21,8 @@ import {
|
|
|
21
21
|
// 数据库术语以这里为准。
|
|
22
22
|
// Submission 是一次可持久化的用户提交,从排队到终态都用同一个 submissionId 跟踪。
|
|
23
23
|
// Approval 是某次工具调用的持久化审批记录,executionId 是对外决策时的主键。
|
|
24
|
-
// Tool Interaction
|
|
25
|
-
//
|
|
24
|
+
// Tool Interaction 保存客户端答案或原工具的前置条件等待,interactionId 是主键;
|
|
25
|
+
// 它保存人的决定和有界核验事实,Pi 保存工具执行位置和最终 ToolResult。
|
|
26
26
|
// Extension Context 是扩展按 label 保存的文本,不随清空聊天记录删除。
|
|
27
27
|
// Message UI 是会话消息的用户界面补充数据,它由 Transcript 而不是 Turn 生命周期管理。
|
|
28
28
|
// Agent Tool Run 是 Agents SDK 派发的子运行,唯一一张不归 Runtime 所有的表:SDK 建它、写它、清它,
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
TemporaryAgentApprovalDecision,
|
|
21
21
|
TemporaryAgentApprovalRequest,
|
|
22
22
|
TemporaryAgentLaunchSelection,
|
|
23
|
+
ToolPrerequisiteResponse,
|
|
23
24
|
} from "../../tools";
|
|
24
25
|
import { TEMPORARY_AGENT_LAUNCH_KEY } from "../../tools";
|
|
25
26
|
import {
|
|
@@ -215,14 +216,15 @@ export interface RuntimeAgentControls {
|
|
|
215
216
|
decision: ApprovalDecision,
|
|
216
217
|
): Promise<{ ok: boolean }>;
|
|
217
218
|
/**
|
|
218
|
-
*
|
|
219
|
+
* Settle an answer Tool, or control a prerequisite using its full wait identity.
|
|
219
220
|
*
|
|
220
221
|
* @remarks
|
|
221
|
-
*
|
|
222
|
+
* Legacy answers use (toolCallId, answer). Prerequisites use one response object;
|
|
223
|
+
* only the backend verifies readiness. Missing/stale/invalid decisions return false.
|
|
222
224
|
*/
|
|
223
225
|
respondToolInteraction(
|
|
224
|
-
toolCallId: string,
|
|
225
|
-
response
|
|
226
|
+
toolCallId: string | ToolPrerequisiteResponse,
|
|
227
|
+
response?: unknown,
|
|
226
228
|
): Promise<{ ok: boolean }>;
|
|
227
229
|
/**
|
|
228
230
|
* 登记一条受管临时 Agent 发起的审批请求。
|
|
@@ -327,6 +329,8 @@ export interface RuntimeAgentConfigControls<Command, Change> {
|
|
|
327
329
|
|
|
328
330
|
/** Type-only Host extension seam implemented by AgentRuntimeKernel. */
|
|
329
331
|
declare class RuntimeAgentHostSeam {
|
|
332
|
+
protected runConfigControl<T>(run: () => Promise<T>): Promise<T>;
|
|
333
|
+
protected ensureRuntimeVersion(runtimeKey: string, force?: boolean): Promise<void>;
|
|
330
334
|
protected prepareToolInteractionResponse(
|
|
331
335
|
input: import("./agent").ToolInteractionResponsePreparation,
|
|
332
336
|
): Promise<unknown | null>;
|
|
@@ -626,8 +630,8 @@ export function defineRuntimeAgent<
|
|
|
626
630
|
}
|
|
627
631
|
|
|
628
632
|
respondToolInteraction(
|
|
629
|
-
toolCallId: string,
|
|
630
|
-
response
|
|
633
|
+
toolCallId: string | ToolPrerequisiteResponse,
|
|
634
|
+
response?: unknown,
|
|
631
635
|
): Promise<{ ok: boolean }> {
|
|
632
636
|
return super.respondToolInteraction(toolCallId, response);
|
|
633
637
|
}
|
|
@@ -209,6 +209,28 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
209
209
|
responded_at INTEGER,
|
|
210
210
|
UNIQUE (submission_id, tool_call_id)
|
|
211
211
|
)`;
|
|
212
|
+
const interactionColumns = new Set(
|
|
213
|
+
sql<{ name: string }>`PRAGMA table_info(pi_tool_interactions)`.map((column) => column.name),
|
|
214
|
+
);
|
|
215
|
+
// Old client-settled answers keep their original meaning on a rolling upgrade.
|
|
216
|
+
if (!interactionColumns.has("purpose")) {
|
|
217
|
+
sql`ALTER TABLE pi_tool_interactions ADD COLUMN purpose TEXT NOT NULL DEFAULT 'answer'`;
|
|
218
|
+
}
|
|
219
|
+
if (!interactionColumns.has("scope_key")) {
|
|
220
|
+
sql`ALTER TABLE pi_tool_interactions ADD COLUMN scope_key TEXT`;
|
|
221
|
+
}
|
|
222
|
+
if (!interactionColumns.has("version")) {
|
|
223
|
+
sql`ALTER TABLE pi_tool_interactions ADD COLUMN version INTEGER NOT NULL DEFAULT 1`;
|
|
224
|
+
}
|
|
225
|
+
if (!interactionColumns.has("presentation_json")) {
|
|
226
|
+
sql`ALTER TABLE pi_tool_interactions ADD COLUMN presentation_json TEXT`;
|
|
227
|
+
}
|
|
228
|
+
if (!interactionColumns.has("verified_until")) {
|
|
229
|
+
sql`ALTER TABLE pi_tool_interactions ADD COLUMN verified_until INTEGER`;
|
|
230
|
+
}
|
|
231
|
+
sql`CREATE INDEX IF NOT EXISTS pi_tool_interactions_scope
|
|
232
|
+
ON pi_tool_interactions(submission_id, scope_key, status)
|
|
233
|
+
WHERE purpose = 'prerequisite'`;
|
|
212
234
|
sql`CREATE TABLE IF NOT EXISTS pi_extension_context (
|
|
213
235
|
label TEXT PRIMARY KEY,
|
|
214
236
|
content TEXT NOT NULL,
|
package/src/config/assembler.ts
CHANGED
|
@@ -256,6 +256,9 @@ async function createToolSurface(
|
|
|
256
256
|
if (!EXECUTION_LEVELS.includes(candidate.requiredExecutionLevel)) {
|
|
257
257
|
throw new Error(`SpringBrand Tool ${name} execution level is invalid`);
|
|
258
258
|
}
|
|
259
|
+
if (candidate.prerequisite && (candidate.interaction || exposureOf(candidate) === "codemode")) {
|
|
260
|
+
throw new Error(`SpringBrand Tool ${name} prerequisites require a direct, non-answer Tool`);
|
|
261
|
+
}
|
|
259
262
|
if (candidate.unattendedExecution) {
|
|
260
263
|
requiredName(
|
|
261
264
|
candidate.unattendedExecution.capability,
|
|
@@ -293,6 +296,7 @@ async function createToolSurface(
|
|
|
293
296
|
(candidate) =>
|
|
294
297
|
exposureOf(candidate) !== "direct" &&
|
|
295
298
|
!candidate.interaction &&
|
|
299
|
+
!candidate.prerequisite &&
|
|
296
300
|
typeof candidate.tool.execute === "function",
|
|
297
301
|
);
|
|
298
302
|
const codeExecutionCandidates = Object.freeze([...mergeable]);
|
|
@@ -88,6 +88,12 @@ export class HarnessRuntimeConfig {
|
|
|
88
88
|
)?.interaction;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
prerequisite(prepared: PreparedPiRuntime, name: string) {
|
|
92
|
+
return readPreparedPiRuntime(prepared, this.owner).candidates.find(
|
|
93
|
+
(candidate) => candidate.tool.name === name,
|
|
94
|
+
)?.prerequisite;
|
|
95
|
+
}
|
|
96
|
+
|
|
91
97
|
/** Read all executable inputs from the current Prepared Runtime. */
|
|
92
98
|
async harnessInputs(
|
|
93
99
|
prepared: PreparedPiRuntime,
|
|
@@ -197,6 +197,7 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
|
|
|
197
197
|
parameters: candidate.tool.parameters,
|
|
198
198
|
requiredExecutionLevel: candidate.requiredExecutionLevel,
|
|
199
199
|
...(candidate.confirmation ? { confirmation: true } : {}),
|
|
200
|
+
...(candidate.prerequisite ? { prerequisite: true } : {}),
|
|
200
201
|
...(candidate.alwaysRequiresApproval
|
|
201
202
|
? { alwaysRequiresApproval: true }
|
|
202
203
|
: {}),
|
|
@@ -18,6 +18,13 @@ export * from "../conversation/canonical-context";
|
|
|
18
18
|
export * from "../submission/contracts";
|
|
19
19
|
export * from "../conversation/state";
|
|
20
20
|
export * from "../lib/execution-level";
|
|
21
|
+
export type {
|
|
22
|
+
ToolPrerequisiteDecision,
|
|
23
|
+
ToolPrerequisiteIdentity,
|
|
24
|
+
ToolPrerequisitePresentation,
|
|
25
|
+
ToolPrerequisiteRequest,
|
|
26
|
+
ToolPrerequisiteResponse,
|
|
27
|
+
} from "../tools/runtime/prerequisite-contracts";
|
|
21
28
|
export type { WorkspaceRevision, WorkspaceRevisionManifest } from "../tools/workspace/versioning";
|
|
22
29
|
export type {
|
|
23
30
|
RuntimeEventConfirmation,
|
|
@@ -2,6 +2,7 @@ import type { ApprovalReceipt } from "../submission/contracts";
|
|
|
2
2
|
import type { StoredSubmission } from "../submission/repository";
|
|
3
3
|
import type { PendingApprovalSummary } from "../tools/runtime/approval/repository";
|
|
4
4
|
import type { PiCanonicalUserInput } from "./message/contract";
|
|
5
|
+
import type { ToolPrerequisiteRequest } from "../tools/runtime/prerequisite-contracts";
|
|
5
6
|
|
|
6
7
|
export type RuntimeLoadPhase = "config" | "assembly" | "mcp" | "pi";
|
|
7
8
|
|
|
@@ -95,6 +96,8 @@ export interface RuntimeState {
|
|
|
95
96
|
/** 供 Host 列表持久化的粗粒度活动状态;Submission 与 Approval 仍是执行真相。 */
|
|
96
97
|
activity?: RuntimeActivityProjection;
|
|
97
98
|
approvals?: ApprovalReceipt[];
|
|
99
|
+
/** Durable pre-effect waits, separate from canonical messages and answer tools. */
|
|
100
|
+
prerequisites?: ToolPrerequisiteRequest[];
|
|
98
101
|
turn?: RuntimeTurnState;
|
|
99
102
|
/** 有值时普通输入被锁定,直到锁定的 Submission 进入终态。 */
|
|
100
103
|
inputLock?: RuntimeInputLock;
|
|
@@ -109,6 +112,7 @@ type SubmissionProjection = Pick<
|
|
|
109
112
|
/** Rebuild client-visible activity from current facts without retaining lifecycle state. */
|
|
110
113
|
export function projectRuntimeState(input: {
|
|
111
114
|
readonly approvals: readonly PendingApprovalSummary[];
|
|
115
|
+
readonly prerequisites?: readonly ToolPrerequisiteRequest[];
|
|
112
116
|
readonly temporaryApprovals: readonly ApprovalReceipt[];
|
|
113
117
|
readonly running: SubmissionProjection | null | undefined;
|
|
114
118
|
readonly pending: readonly SubmissionProjection[];
|
|
@@ -134,6 +138,9 @@ export function projectRuntimeState(input: {
|
|
|
134
138
|
...input.temporaryApprovals,
|
|
135
139
|
];
|
|
136
140
|
const current = input.running ?? input.pending[0];
|
|
141
|
+
const prerequisites = (input.prerequisites ?? []).filter(
|
|
142
|
+
(request) => request.submissionId === current?.submissionId,
|
|
143
|
+
);
|
|
137
144
|
const queued = (input.running ? input.pending : input.pending.slice(1)).map(
|
|
138
145
|
(submission, index): RuntimeQueuedSubmission => {
|
|
139
146
|
let preview = "Queued message";
|
|
@@ -166,7 +173,7 @@ export function projectRuntimeState(input: {
|
|
|
166
173
|
hasPendingSteer: Boolean(current && input.hasPendingSteer(current.submissionId)),
|
|
167
174
|
queued,
|
|
168
175
|
};
|
|
169
|
-
const activity: RuntimeActivity = approvals.length > 0 ||
|
|
176
|
+
const activity: RuntimeActivity = approvals.length > 0 || prerequisites.length > 0 ||
|
|
170
177
|
(current && input.hasPendingInteraction(current.submissionId))
|
|
171
178
|
? "needs-input"
|
|
172
179
|
: current || queued.length > 0 ? "working" : "idle";
|
|
@@ -183,13 +190,15 @@ export function projectRuntimeState(input: {
|
|
|
183
190
|
? { reason: "scheduled-run" }
|
|
184
191
|
: undefined;
|
|
185
192
|
const changed = input.previousState.approvals === undefined ||
|
|
193
|
+
input.previousState.prerequisites === undefined ||
|
|
186
194
|
JSON.stringify(input.previousState.approvals) !== JSON.stringify(approvals) ||
|
|
195
|
+
JSON.stringify(input.previousState.prerequisites) !== JSON.stringify(prerequisites) ||
|
|
187
196
|
JSON.stringify(input.previousState.turn) !== JSON.stringify(turn) ||
|
|
188
197
|
JSON.stringify(input.previousState.inputLock) !== JSON.stringify(inputLock) ||
|
|
189
198
|
nextActivity !== previousActivity;
|
|
190
199
|
return {
|
|
191
200
|
state: changed
|
|
192
|
-
? { ...input.previousState, activity: nextActivity, approvals, turn, inputLock }
|
|
201
|
+
? { ...input.previousState, activity: nextActivity, approvals, prerequisites, turn, inputLock }
|
|
193
202
|
: input.previousState,
|
|
194
203
|
changed,
|
|
195
204
|
};
|
package/src/index.ts
CHANGED
|
@@ -91,6 +91,14 @@ export type {
|
|
|
91
91
|
} from "./tools/subagent/types/contract";
|
|
92
92
|
export type {
|
|
93
93
|
PiToolCandidate,
|
|
94
|
+
PiToolPrerequisiteCheck,
|
|
95
|
+
PiToolPrerequisiteContext,
|
|
96
|
+
PiToolPrerequisiteSpec,
|
|
97
|
+
ToolPrerequisiteDecision,
|
|
98
|
+
ToolPrerequisiteIdentity,
|
|
99
|
+
ToolPrerequisitePresentation,
|
|
100
|
+
ToolPrerequisiteRequest,
|
|
101
|
+
ToolPrerequisiteResponse,
|
|
94
102
|
ToolExposureMode,
|
|
95
103
|
} from "./tools";
|
|
96
104
|
export { basePiToolCandidates } from "./tools";
|
|
@@ -16,7 +16,9 @@ export function piCandidatesToAiTools(
|
|
|
16
16
|
candidates: readonly PiToolCandidate[],
|
|
17
17
|
options: NestedToolOptions,
|
|
18
18
|
): ToolSet {
|
|
19
|
-
|
|
19
|
+
// Nested sandboxes have no pre-intent durable human wait. Never bypass a
|
|
20
|
+
// prerequisite by exposing its raw execute closure in Code Mode or Skill scripts.
|
|
21
|
+
return Object.fromEntries(candidates.filter((candidate) => !candidate.prerequisite).map((candidate) => [
|
|
20
22
|
candidate.tool.name,
|
|
21
23
|
aiTool({
|
|
22
24
|
description: candidate.tool.description,
|
|
@@ -146,6 +146,9 @@ export function compileHarnessTools(
|
|
|
146
146
|
options: CompileHarnessToolsOptions = {},
|
|
147
147
|
): ToolRegistration<any, any>[] {
|
|
148
148
|
return candidates.map((candidate) => {
|
|
149
|
+
if (candidate.interaction && candidate.prerequisite) {
|
|
150
|
+
throw new Error(`Tool ${candidate.tool.name} cannot declare both interaction and prerequisite`);
|
|
151
|
+
}
|
|
149
152
|
const source = candidate.tool;
|
|
150
153
|
const tool = source.constrainedSampling === false
|
|
151
154
|
? source
|
|
@@ -182,6 +185,11 @@ export function compileHarnessTools(
|
|
|
182
185
|
const signal = context.abortSignal ?? new AbortController().signal;
|
|
183
186
|
let result: AgentToolResult<unknown>;
|
|
184
187
|
try {
|
|
188
|
+
// A declined prerequisite must not recheck account authorization or dispatch an effect.
|
|
189
|
+
if (candidate.prerequisite) {
|
|
190
|
+
const decided = await options.resolveReplay?.(candidate, toolCallId, args);
|
|
191
|
+
if (decided) return durableResult(decided, candidate, options.workspace);
|
|
192
|
+
}
|
|
185
193
|
await options.authorize?.(candidate, args, {
|
|
186
194
|
toolCallId,
|
|
187
195
|
signal,
|
|
@@ -4,6 +4,15 @@ import type { ApprovalReceipt } from "../../submission/contracts";
|
|
|
4
4
|
import { type BudgetPolicy } from "../../context/memory/budget/gate";
|
|
5
5
|
import { type SpillWorkspace } from "../../lib/artifacts";
|
|
6
6
|
import { type ExecutionLevel } from "../../lib/execution-level";
|
|
7
|
+
import type { ToolPrerequisitePresentation } from "./prerequisite-contracts";
|
|
8
|
+
|
|
9
|
+
export type {
|
|
10
|
+
ToolPrerequisiteDecision,
|
|
11
|
+
ToolPrerequisiteIdentity,
|
|
12
|
+
ToolPrerequisitePresentation,
|
|
13
|
+
ToolPrerequisiteRequest,
|
|
14
|
+
ToolPrerequisiteResponse,
|
|
15
|
+
} from "./prerequisite-contracts";
|
|
7
16
|
|
|
8
17
|
// 本文件沿用 `src/index.ts` 入口定义的 Tool Candidate 和 Tool Settlement 术语。
|
|
9
18
|
|
|
@@ -28,6 +37,51 @@ export interface PiToolInteractionSpec {
|
|
|
28
37
|
) => Omit<AgentToolResult<unknown>, "terminate">;
|
|
29
38
|
}
|
|
30
39
|
|
|
40
|
+
export interface PiToolPrerequisiteContext {
|
|
41
|
+
readonly submissionId: string;
|
|
42
|
+
readonly requestId: string;
|
|
43
|
+
readonly toolCallId: string;
|
|
44
|
+
readonly toolName: string;
|
|
45
|
+
readonly signal: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PiToolPrerequisiteCheck =
|
|
49
|
+
| {
|
|
50
|
+
readonly status: "ready";
|
|
51
|
+
/** Optional bounded verification receipt, in epoch milliseconds (at most 60s). */
|
|
52
|
+
readonly validUntil?: number;
|
|
53
|
+
}
|
|
54
|
+
| {
|
|
55
|
+
readonly status: "needs-input";
|
|
56
|
+
readonly presentation: ToolPrerequisitePresentation;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A separate, pre-effect condition, NOT a client-settled answer.
|
|
61
|
+
* Callbacks close over trusted Host dependencies. They never start authorization
|
|
62
|
+
* or the business effect, and receive independent copies of validated input.
|
|
63
|
+
* Host checkers own cache/in-flight reuse.
|
|
64
|
+
*/
|
|
65
|
+
export interface PiToolPrerequisiteSpec {
|
|
66
|
+
/** Derive before live checking. Null means this input has no prerequisite. */
|
|
67
|
+
readonly scope: (
|
|
68
|
+
input: unknown,
|
|
69
|
+
context: PiToolPrerequisiteContext,
|
|
70
|
+
) => string | null | Promise<string | null>;
|
|
71
|
+
readonly check: (
|
|
72
|
+
input: unknown,
|
|
73
|
+
context: PiToolPrerequisiteContext & {
|
|
74
|
+
readonly scopeKey: string;
|
|
75
|
+
readonly reason: "initial" | "recheck" | "continuation";
|
|
76
|
+
},
|
|
77
|
+
) => PiToolPrerequisiteCheck | Promise<PiToolPrerequisiteCheck>;
|
|
78
|
+
/** Optional domain-specific English non-execution result, from trusted metadata. */
|
|
79
|
+
readonly declinedResult?: (
|
|
80
|
+
input: unknown,
|
|
81
|
+
presentation: ToolPrerequisitePresentation,
|
|
82
|
+
) => Omit<AgentToolResult<unknown>, "terminate">;
|
|
83
|
+
}
|
|
84
|
+
|
|
31
85
|
/** Trusted Tool metadata used to evaluate a scheduled task grant. */
|
|
32
86
|
export interface PiToolUnattendedExecutionSpec {
|
|
33
87
|
readonly capability: string;
|
|
@@ -89,6 +143,8 @@ export interface PiToolCandidate<
|
|
|
89
143
|
readonly summary?: string;
|
|
90
144
|
readonly source?: ApprovalReceipt["source"];
|
|
91
145
|
readonly interaction?: PiToolInteractionSpec;
|
|
146
|
+
/** Conditions resume the original execute; cannot coexist with interaction. */
|
|
147
|
+
readonly prerequisite?: PiToolPrerequisiteSpec;
|
|
92
148
|
/** Large payloads spill by default; protocol-shaped results opt into structure. */
|
|
93
149
|
readonly outputBudget?: BudgetPolicy;
|
|
94
150
|
/**
|
|
@@ -10,6 +10,8 @@ import type { ApprovalRepository } from "./approval";
|
|
|
10
10
|
import type { PiToolCandidate } from "./contracts";
|
|
11
11
|
import type { ToolInteractionRepository } from "./interaction";
|
|
12
12
|
import { UnattendedExecutionError } from "./unattended-error";
|
|
13
|
+
import { HarnessPrerequisiteGate } from "./prerequisite-gate";
|
|
14
|
+
import { declinedPrerequisiteResult } from "./prerequisites";
|
|
13
15
|
|
|
14
16
|
export interface HarnessDecisionSubmission {
|
|
15
17
|
readonly submissionId: string;
|
|
@@ -28,6 +30,7 @@ export interface HarnessDecisionGateOptions {
|
|
|
28
30
|
readonly approvals: ApprovalRepository;
|
|
29
31
|
readonly interactions: ToolInteractionRepository;
|
|
30
32
|
readonly transaction: <T>(run: () => T) => T;
|
|
33
|
+
readonly isSubmissionActive?: () => boolean;
|
|
31
34
|
readonly release: (reason: "approval" | "interaction") => void;
|
|
32
35
|
readonly telemetry?: Pick<RuntimeTelemetryCoordinator, "capture">;
|
|
33
36
|
readonly onChanged?: (
|
|
@@ -79,11 +82,20 @@ function interactionResult(
|
|
|
79
82
|
/** Durable policy facts around Pi's before_tool hook; Pi owns execution state. */
|
|
80
83
|
export class HarnessDecisionGate {
|
|
81
84
|
private readonly candidates: ReadonlyMap<string, PiToolCandidate>;
|
|
85
|
+
private readonly prerequisites: HarnessPrerequisiteGate;
|
|
82
86
|
|
|
83
87
|
constructor(private readonly options: HarnessDecisionGateOptions) {
|
|
84
88
|
this.candidates = new Map(
|
|
85
89
|
options.candidates.map((candidate) => [candidate.tool.name, candidate]),
|
|
86
90
|
);
|
|
91
|
+
this.prerequisites = new HarnessPrerequisiteGate({
|
|
92
|
+
submission: options.submission, interactions: options.interactions,
|
|
93
|
+
transaction: options.transaction, isSubmissionActive: options.isSubmissionActive,
|
|
94
|
+
onChanged: (id) => options.onChanged?.("interaction", id),
|
|
95
|
+
...(options.submission.origin === "scheduled" ? {
|
|
96
|
+
onNeedsInput: (name: string) => this.denyUnattended(`SCHEDULED_EXECUTION_REQUIRES_INTERACTION: ${name}`),
|
|
97
|
+
} : {}),
|
|
98
|
+
});
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
readonly beforeTool = async (event: { toolName: string; toolCallId: string; args: unknown }, context: import("@earendil-works/chord").Context): Promise<{ block: string } | undefined> => {
|
|
@@ -118,6 +130,27 @@ export class HarnessDecisionGate {
|
|
|
118
130
|
if (existing?.status === "rejected") {
|
|
119
131
|
return { block: existing.reason ?? "Tool execution denied by user" };
|
|
120
132
|
}
|
|
133
|
+
if (candidate.prerequisite || this.options.interactions.find(executionId)?.purpose === "prerequisite") {
|
|
134
|
+
try {
|
|
135
|
+
const condition = await this.prerequisites.beforeTool(candidate, event.args, {
|
|
136
|
+
toolCallId: event.toolCallId, signal: operationSignal(context.abortSignal),
|
|
137
|
+
});
|
|
138
|
+
if (condition === "pending") {
|
|
139
|
+
if (this.options.submission.origin === "scheduled") {
|
|
140
|
+
await this.denyUnattended(`SCHEDULED_EXECUTION_REQUIRES_INTERACTION: ${candidate.tool.name}`);
|
|
141
|
+
}
|
|
142
|
+
this.options.release("interaction");
|
|
143
|
+
return waitForRelease(operationSignal(context.abortSignal));
|
|
144
|
+
}
|
|
145
|
+
// A refusal is a non-execution Tool result, not approval of the business effect.
|
|
146
|
+
if (condition === "decided") return undefined;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (error instanceof UnattendedExecutionError) return { block: error.message };
|
|
149
|
+
if (this.options.submission.origin !== "scheduled") throw error;
|
|
150
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
151
|
+
await this.denyUnattended(`SCHEDULED_EXECUTION_PREREQUISITE_CHECK_FAILED: ${candidate.tool.name}: ${reason}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
121
154
|
if (needsApproval && !unattendedAuthorized && existing?.status !== "approved") {
|
|
122
155
|
if (!existing) {
|
|
123
156
|
const prepared = candidate.confirmation
|
|
@@ -221,6 +254,16 @@ export class HarnessDecisionGate {
|
|
|
221
254
|
);
|
|
222
255
|
}
|
|
223
256
|
this.assertUnattendedActive();
|
|
257
|
+
if (candidate.prerequisite) {
|
|
258
|
+
try {
|
|
259
|
+
await this.prerequisites.authorize(candidate, args, invocation);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
if (this.options.submission.origin !== "scheduled") throw error;
|
|
262
|
+
await this.denyUnattended(`SCHEDULED_EXECUTION_PREREQUISITE_CHECK_FAILED: ${candidate.tool.name}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
invocation.signal.throwIfAborted();
|
|
266
|
+
if (this.options.isSubmissionActive?.() === false) throw new Error("Tool Submission is no longer active");
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
private assertUnattendedActive(): void {
|
|
@@ -308,10 +351,22 @@ export class HarnessDecisionGate {
|
|
|
308
351
|
toolCallId: string,
|
|
309
352
|
args: unknown,
|
|
310
353
|
): AgentToolResult<unknown> | undefined {
|
|
311
|
-
if (!candidate.interaction) return undefined;
|
|
312
354
|
const interaction = this.options.interactions.find(
|
|
313
355
|
`${this.options.submission.submissionId}:${toolCallId}`,
|
|
314
356
|
);
|
|
357
|
+
if (interaction?.purpose === "prerequisite") {
|
|
358
|
+
if (interaction.requestId !== this.options.submission.requestId ||
|
|
359
|
+
interaction.toolName !== candidate.tool.name || interaction.inputJson !== stableInput(args)) {
|
|
360
|
+
throw new Error(`Conflicting durable Tool prerequisite: ${toolCallId}`);
|
|
361
|
+
}
|
|
362
|
+
if (interaction.status === "declined") return declinedPrerequisiteResult(interaction, args, candidate.prerequisite);
|
|
363
|
+
if (interaction.status === "cancelled") return {
|
|
364
|
+
content: [{ type: "text", text: "The prerequisite wait was cancelled. This operation was not executed." }],
|
|
365
|
+
details: { cancelled: true, executed: false },
|
|
366
|
+
};
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
if (!candidate.interaction) return undefined;
|
|
315
370
|
if (!interaction || interaction.status === "pending") return undefined;
|
|
316
371
|
if (interaction.inputJson !== stableInput(args)) {
|
|
317
372
|
throw new Error(`Conflicting durable Tool interaction: ${toolCallId}`);
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ApprovalDecision } from "../../submission/contracts";
|
|
2
2
|
import type { RuntimeTelemetryCoordinator } from "../../telemetry/coordinator";
|
|
3
3
|
import type { ApprovalRepository } from "./approval/repository";
|
|
4
|
-
import type { PiToolInteractionSpec } from "./contracts";
|
|
4
|
+
import type { PiToolInteractionSpec, PiToolPrerequisiteSpec, ToolPrerequisiteResponse } from "./contracts";
|
|
5
5
|
import type { StoredToolInteraction, ToolInteractionRepository } from "./interaction/repository";
|
|
6
|
+
import { matchesPrerequisite, normalizePrerequisiteCheck, parsePrerequisiteResponse } from "./prerequisites";
|
|
6
7
|
|
|
7
8
|
/** Human decisions own validation and durable facts; the caller resumes Pi. */
|
|
8
9
|
export class HarnessHumanDecisions {
|
|
@@ -33,7 +34,7 @@ export class HarnessHumanDecisions {
|
|
|
33
34
|
response: unknown;
|
|
34
35
|
}) => Promise<unknown | null>,
|
|
35
36
|
): Promise<boolean> {
|
|
36
|
-
if (!this.options.isSubmissionActive(pending.submissionId) ||
|
|
37
|
+
if (pending.purpose !== "answer" || !this.options.isSubmissionActive(pending.submissionId) ||
|
|
37
38
|
this.options.interactions.find(pending.interactionId)?.status !== "pending") return false;
|
|
38
39
|
let input: unknown;
|
|
39
40
|
try { input = JSON.parse(pending.inputJson); } catch { return false; }
|
|
@@ -63,6 +64,70 @@ export class HarnessHumanDecisions {
|
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/** Control decisions never pass through the answer/attachment preparation hook. */
|
|
68
|
+
async respondPrerequisite(
|
|
69
|
+
pending: StoredToolInteraction,
|
|
70
|
+
spec: PiToolPrerequisiteSpec | undefined,
|
|
71
|
+
response: ToolPrerequisiteResponse,
|
|
72
|
+
signal: AbortSignal = new AbortController().signal,
|
|
73
|
+
): Promise<{ ok: boolean; resume: boolean }> {
|
|
74
|
+
const denied = { ok: false, resume: false };
|
|
75
|
+
const identity = parsePrerequisiteResponse(response);
|
|
76
|
+
const current = identity ? this.options.interactions.find(identity.id) : null;
|
|
77
|
+
if (!identity || !matchesPrerequisite(pending, identity) || !matchesPrerequisite(current, identity) ||
|
|
78
|
+
current.status !== "pending" || !this.options.isSubmissionActive(current.submissionId)) return denied;
|
|
79
|
+
let scopeKey = identity.scopeKey;
|
|
80
|
+
let check: ReturnType<typeof normalizePrerequisiteCheck> | undefined;
|
|
81
|
+
let declined = identity.decision === "declined";
|
|
82
|
+
if (!declined) {
|
|
83
|
+
if (!spec) return denied;
|
|
84
|
+
const input: unknown = JSON.parse(current.inputJson);
|
|
85
|
+
const context = {
|
|
86
|
+
submissionId: current.submissionId, requestId: current.requestId,
|
|
87
|
+
toolCallId: current.toolCallId, toolName: current.toolName, signal,
|
|
88
|
+
};
|
|
89
|
+
const derived = await spec.scope(structuredClone(input), context);
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
const stillPending = this.options.interactions.find(identity.id);
|
|
92
|
+
if (!matchesPrerequisite(stillPending, identity) || stillPending.status !== "pending" ||
|
|
93
|
+
!this.options.isSubmissionActive(stillPending.submissionId)) return denied;
|
|
94
|
+
if (derived !== null && (typeof derived !== "string" || !derived.trim())) {
|
|
95
|
+
throw new Error("Invalid trusted Tool prerequisite scope");
|
|
96
|
+
}
|
|
97
|
+
scopeKey = derived ?? identity.scopeKey;
|
|
98
|
+
declined = Boolean(this.options.interactions.findDeclinedScope(current.submissionId, scopeKey));
|
|
99
|
+
if (!declined) check = normalizePrerequisiteCheck(derived === null ? { status: "ready" } :
|
|
100
|
+
await spec.check(structuredClone(input), { ...context, scopeKey, reason: "recheck" }));
|
|
101
|
+
signal.throwIfAborted();
|
|
102
|
+
}
|
|
103
|
+
return this.options.transaction(() => {
|
|
104
|
+
const latest = this.options.interactions.find(identity.id);
|
|
105
|
+
if (!matchesPrerequisite(latest, identity) || latest.status !== "pending" ||
|
|
106
|
+
!this.options.isSubmissionActive(latest.submissionId) || signal.aborted) return denied;
|
|
107
|
+
const refused = this.options.interactions.findDeclinedScope(latest.submissionId, scopeKey);
|
|
108
|
+
const status = declined || refused ? "declined" : check!.status;
|
|
109
|
+
const sameScope = scopeKey === identity.scopeKey;
|
|
110
|
+
// Include same-scope calls that parked while the Host verification was in flight.
|
|
111
|
+
const group = sameScope
|
|
112
|
+
? this.options.interactions.listPendingForScope(latest.submissionId, scopeKey) : [latest];
|
|
113
|
+
for (const sibling of group) {
|
|
114
|
+
const row = this.options.interactions.find(sibling.interactionId);
|
|
115
|
+
if (row?.status !== "pending" || row.version !== sibling.version || row.scopeKey !== sibling.scopeKey) continue;
|
|
116
|
+
this.options.interactions.updatePrerequisite(row.interactionId, { status: row.status, version: row.version }, {
|
|
117
|
+
status, scopeKey,
|
|
118
|
+
presentationJson: refused?.presentationJson ?? check?.presentationJson ?? row.presentationJson,
|
|
119
|
+
verifiedUntil: status === "ready" ? check!.verifiedUntil : null,
|
|
120
|
+
renew: !sameScope || status === "pending",
|
|
121
|
+
});
|
|
122
|
+
if (status !== "pending") this.options.telemetry.capture("interactionResponded", {
|
|
123
|
+
submissionId: row.submissionId, toolCallId: row.toolCallId,
|
|
124
|
+
interactionId: row.interactionId, response: { decision: status }, occurredAt: Date.now(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, resume: status !== "pending" };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
66
131
|
approve(executionId: string, decision: ApprovalDecision): { ok: boolean; submissionId?: string } {
|
|
67
132
|
if (decision.decision === "allow_level") {
|
|
68
133
|
throw new Error("allow_level must be applied by the User Agent Host before resuming the Tool");
|
|
@@ -122,6 +187,7 @@ export class HarnessHumanDecisions {
|
|
|
122
187
|
});
|
|
123
188
|
}
|
|
124
189
|
this.cancelInteractions(submissionId, reason);
|
|
190
|
+
this.options.interactions.cancelReadyPrerequisites(submissionId);
|
|
125
191
|
});
|
|
126
192
|
}
|
|
127
193
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { SqlTaggedTemplate } from "agents/chat";
|
|
2
2
|
|
|
3
3
|
// Tool Interaction 的统一定义见 ./index.ts。
|
|
4
|
-
export type ToolInteractionStatus = "pending" | "responded" | "cancelled";
|
|
4
|
+
export type ToolInteractionStatus = "pending" | "responded" | "cancelled" | "ready" | "declined";
|
|
5
|
+
export type ToolInteractionPurpose = "answer" | "prerequisite";
|
|
5
6
|
|
|
6
7
|
export interface StoredToolInteraction {
|
|
7
8
|
interactionId: string;
|
|
@@ -14,6 +15,11 @@ export interface StoredToolInteraction {
|
|
|
14
15
|
responseJson: string | null;
|
|
15
16
|
createdAt: number;
|
|
16
17
|
respondedAt: number | null;
|
|
18
|
+
purpose: ToolInteractionPurpose;
|
|
19
|
+
scopeKey: string | null;
|
|
20
|
+
version: number;
|
|
21
|
+
presentationJson: string | null;
|
|
22
|
+
verifiedUntil: number | null;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export interface NewToolInteraction {
|
|
@@ -25,6 +31,11 @@ export interface NewToolInteraction {
|
|
|
25
31
|
inputJson: string;
|
|
26
32
|
status: ToolInteractionStatus;
|
|
27
33
|
createdAt: number;
|
|
34
|
+
purpose?: ToolInteractionPurpose;
|
|
35
|
+
scopeKey?: string | null;
|
|
36
|
+
version?: number;
|
|
37
|
+
presentationJson?: string | null;
|
|
38
|
+
verifiedUntil?: number | null;
|
|
28
39
|
}
|
|
29
40
|
|
|
30
41
|
type ToolInteractionRow = {
|
|
@@ -38,6 +49,11 @@ type ToolInteractionRow = {
|
|
|
38
49
|
response_json: string | null;
|
|
39
50
|
created_at: number;
|
|
40
51
|
responded_at: number | null;
|
|
52
|
+
purpose: string;
|
|
53
|
+
scope_key: string | null;
|
|
54
|
+
version: number;
|
|
55
|
+
presentation_json: string | null;
|
|
56
|
+
verified_until: number | null;
|
|
41
57
|
};
|
|
42
58
|
|
|
43
59
|
// 把 SQLite 的 Interaction 行转成 Runtime 对象。
|
|
@@ -55,6 +71,11 @@ function mapRow(row: ToolInteractionRow): StoredToolInteraction {
|
|
|
55
71
|
responseJson: row.response_json,
|
|
56
72
|
createdAt: row.created_at,
|
|
57
73
|
respondedAt: row.responded_at,
|
|
74
|
+
purpose: row.purpose as ToolInteractionPurpose,
|
|
75
|
+
scopeKey: row.scope_key,
|
|
76
|
+
version: row.version,
|
|
77
|
+
presentationJson: row.presentation_json,
|
|
78
|
+
verifiedUntil: row.verified_until,
|
|
58
79
|
};
|
|
59
80
|
}
|
|
60
81
|
|
|
@@ -71,7 +92,8 @@ export class ToolInteractionRepository {
|
|
|
71
92
|
const row = this.sql<ToolInteractionRow>`
|
|
72
93
|
SELECT interaction_id, submission_id, request_id,
|
|
73
94
|
tool_call_id, tool_name, input_json,
|
|
74
|
-
status, response_json, created_at, responded_at
|
|
95
|
+
status, response_json, created_at, responded_at,
|
|
96
|
+
purpose, scope_key, version, presentation_json, verified_until
|
|
75
97
|
FROM pi_tool_interactions
|
|
76
98
|
WHERE interaction_id = ${interactionId}
|
|
77
99
|
LIMIT 1
|
|
@@ -87,7 +109,7 @@ export class ToolInteractionRepository {
|
|
|
87
109
|
INSERT INTO pi_tool_interactions (
|
|
88
110
|
interaction_id, submission_id, request_id,
|
|
89
111
|
tool_call_id, tool_name, input_json,
|
|
90
|
-
status, created_at
|
|
112
|
+
status, created_at, purpose, scope_key, version, presentation_json, verified_until
|
|
91
113
|
) VALUES (
|
|
92
114
|
${interaction.interactionId},
|
|
93
115
|
${interaction.submissionId},
|
|
@@ -96,7 +118,12 @@ export class ToolInteractionRepository {
|
|
|
96
118
|
${interaction.toolName},
|
|
97
119
|
${interaction.inputJson},
|
|
98
120
|
${interaction.status},
|
|
99
|
-
${interaction.createdAt}
|
|
121
|
+
${interaction.createdAt},
|
|
122
|
+
${interaction.purpose ?? "answer"},
|
|
123
|
+
${interaction.scopeKey ?? null},
|
|
124
|
+
${interaction.version ?? 1},
|
|
125
|
+
${interaction.presentationJson ?? null},
|
|
126
|
+
${interaction.verifiedUntil ?? null}
|
|
100
127
|
)
|
|
101
128
|
`;
|
|
102
129
|
}
|
|
@@ -106,7 +133,7 @@ export class ToolInteractionRepository {
|
|
|
106
133
|
// WHERE status='pending' 实现首次响应胜出,不能去掉这个条件;调用方在事务后重读来确认是否真的生效。
|
|
107
134
|
settle(
|
|
108
135
|
interactionId: string,
|
|
109
|
-
status:
|
|
136
|
+
status: "responded" | "cancelled",
|
|
110
137
|
respondedAt: number,
|
|
111
138
|
responseJson?: string,
|
|
112
139
|
): void {
|
|
@@ -143,12 +170,15 @@ export class ToolInteractionRepository {
|
|
|
143
170
|
interaction.request_id, interaction.tool_call_id,
|
|
144
171
|
interaction.tool_name, interaction.input_json,
|
|
145
172
|
interaction.status, interaction.response_json,
|
|
146
|
-
interaction.created_at, interaction.responded_at
|
|
173
|
+
interaction.created_at, interaction.responded_at,
|
|
174
|
+
interaction.purpose, interaction.scope_key, interaction.version,
|
|
175
|
+
interaction.presentation_json, interaction.verified_until
|
|
147
176
|
FROM pi_tool_interactions interaction
|
|
148
177
|
JOIN pi_submissions submission
|
|
149
178
|
ON submission.submission_id = interaction.submission_id
|
|
150
179
|
WHERE interaction.tool_call_id = ${toolCallId}
|
|
151
180
|
AND interaction.status = 'pending'
|
|
181
|
+
AND interaction.purpose = 'answer'
|
|
152
182
|
AND submission.status NOT IN (
|
|
153
183
|
'completed', 'aborted', 'skipped', 'error'
|
|
154
184
|
)
|
|
@@ -167,13 +197,72 @@ export class ToolInteractionRepository {
|
|
|
167
197
|
interaction.request_id, interaction.tool_call_id,
|
|
168
198
|
interaction.tool_name, interaction.input_json,
|
|
169
199
|
interaction.status, interaction.response_json,
|
|
170
|
-
interaction.created_at, interaction.responded_at
|
|
200
|
+
interaction.created_at, interaction.responded_at,
|
|
201
|
+
interaction.purpose, interaction.scope_key, interaction.version,
|
|
202
|
+
interaction.presentation_json, interaction.verified_until
|
|
171
203
|
FROM pi_tool_interactions interaction
|
|
172
204
|
JOIN pi_submissions submission
|
|
173
205
|
ON submission.submission_id = interaction.submission_id
|
|
174
206
|
WHERE interaction.status = 'pending'
|
|
175
207
|
AND submission.status IN ('pending', 'running')
|
|
176
|
-
|
|
208
|
+
AND submission.abort_reason IS NULL
|
|
209
|
+
ORDER BY interaction.created_at ASC, interaction.interaction_id ASC
|
|
177
210
|
`.map(mapRow);
|
|
178
211
|
}
|
|
212
|
+
|
|
213
|
+
/** Submission-local scope facts, never a cross-Chat account cache. */
|
|
214
|
+
findDeclinedScope(submissionId: string, scopeKey: string): StoredToolInteraction | null {
|
|
215
|
+
const row = this.sql<ToolInteractionRow>`
|
|
216
|
+
SELECT * FROM pi_tool_interactions
|
|
217
|
+
WHERE submission_id = ${submissionId} AND scope_key = ${scopeKey}
|
|
218
|
+
AND purpose = 'prerequisite' AND status = 'declined'
|
|
219
|
+
ORDER BY created_at ASC LIMIT 1
|
|
220
|
+
`[0];
|
|
221
|
+
return row ? mapRow(row) : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
listPendingForScope(submissionId: string, scopeKey: string): StoredToolInteraction[] {
|
|
225
|
+
return this.sql<ToolInteractionRow>`
|
|
226
|
+
SELECT * FROM pi_tool_interactions
|
|
227
|
+
WHERE submission_id = ${submissionId} AND scope_key = ${scopeKey}
|
|
228
|
+
AND purpose = 'prerequisite' AND status = 'pending'
|
|
229
|
+
ORDER BY created_at ASC, interaction_id ASC
|
|
230
|
+
`.map(mapRow);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** CAS against both state and version: a late verifier cannot overwrite decline. */
|
|
234
|
+
updatePrerequisite(
|
|
235
|
+
interactionId: string,
|
|
236
|
+
expected: { status: "pending" | "ready"; version: number },
|
|
237
|
+
update: {
|
|
238
|
+
status: "pending" | "ready" | "declined";
|
|
239
|
+
scopeKey: string;
|
|
240
|
+
presentationJson: string | null;
|
|
241
|
+
verifiedUntil?: number | null;
|
|
242
|
+
renew?: boolean;
|
|
243
|
+
},
|
|
244
|
+
): void {
|
|
245
|
+
this.sql`
|
|
246
|
+
UPDATE pi_tool_interactions
|
|
247
|
+
SET status = ${update.status},
|
|
248
|
+
scope_key = ${update.scopeKey},
|
|
249
|
+
presentation_json = ${update.presentationJson},
|
|
250
|
+
verified_until = ${update.verifiedUntil ?? null},
|
|
251
|
+
version = ${expected.version + (update.renew ? 1 : 0)},
|
|
252
|
+
responded_at = ${update.status === "pending" ? null : Date.now()}
|
|
253
|
+
WHERE interaction_id = ${interactionId}
|
|
254
|
+
AND purpose = 'prerequisite'
|
|
255
|
+
AND status = ${expected.status} AND version = ${expected.version}
|
|
256
|
+
`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Terminal cleanup also invalidates verified receipts, not just pending UI. */
|
|
260
|
+
cancelReadyPrerequisites(submissionId: string): void {
|
|
261
|
+
this.sql`
|
|
262
|
+
UPDATE pi_tool_interactions SET status = 'cancelled', verified_until = NULL,
|
|
263
|
+
responded_at = ${Date.now()}
|
|
264
|
+
WHERE submission_id = ${submissionId} AND purpose = 'prerequisite'
|
|
265
|
+
AND status = 'ready'
|
|
266
|
+
`;
|
|
267
|
+
}
|
|
179
268
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Safe, Host-owned presentation; never contains credentials or model input. */
|
|
2
|
+
export interface ToolPrerequisitePresentation {
|
|
3
|
+
readonly kind: string;
|
|
4
|
+
readonly title: string;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
readonly data?: unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** A Tool Call ID alone is not a sufficient prerequisite decision identity. */
|
|
10
|
+
export interface ToolPrerequisiteIdentity {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly submissionId: string;
|
|
13
|
+
readonly toolCallId: string;
|
|
14
|
+
readonly toolName: string;
|
|
15
|
+
readonly scopeKey: string;
|
|
16
|
+
readonly version: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Each call retains its identity; clients may group equal Submission/scope pairs. */
|
|
20
|
+
export interface ToolPrerequisiteRequest extends ToolPrerequisiteIdentity {
|
|
21
|
+
readonly createdAt: number;
|
|
22
|
+
readonly presentation: ToolPrerequisitePresentation;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Only the backend may decide readiness. Recheck is not a Tool result. */
|
|
26
|
+
export type ToolPrerequisiteDecision = "recheck" | "declined";
|
|
27
|
+
|
|
28
|
+
/** The one-argument form of respondToolInteraction; legacy (callId, answer) remains valid. */
|
|
29
|
+
export interface ToolPrerequisiteResponse extends ToolPrerequisiteIdentity {
|
|
30
|
+
readonly decision: ToolPrerequisiteDecision;
|
|
31
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { PiToolCandidate, PiToolPrerequisiteContext } from "./contracts";
|
|
2
|
+
import type { StoredToolInteraction, ToolInteractionRepository } from "./interaction";
|
|
3
|
+
import { normalizePrerequisiteCheck } from "./prerequisites";
|
|
4
|
+
|
|
5
|
+
type Call = { readonly toolCallId: string; readonly signal: AbortSignal };
|
|
6
|
+
|
|
7
|
+
/** Pre-intent facts only. Pi remains the sole owner of Tool execution/replay. */
|
|
8
|
+
export class HarnessPrerequisiteGate {
|
|
9
|
+
private readonly checked = new Map<string, { version: number; expiresAt: number }>();
|
|
10
|
+
|
|
11
|
+
constructor(private readonly options: {
|
|
12
|
+
submission: { readonly submissionId: string; readonly requestId: string };
|
|
13
|
+
interactions: ToolInteractionRepository;
|
|
14
|
+
transaction: <T>(run: () => T) => T;
|
|
15
|
+
isSubmissionActive?: () => boolean;
|
|
16
|
+
onChanged?: (id: string) => void | Promise<void>;
|
|
17
|
+
/** Unattended calls fail here before creating any human wait. */
|
|
18
|
+
onNeedsInput?: (toolName: string) => void | Promise<void>;
|
|
19
|
+
}) {}
|
|
20
|
+
|
|
21
|
+
private context(candidate: PiToolCandidate, call: Call): PiToolPrerequisiteContext {
|
|
22
|
+
return { ...this.options.submission, ...call, toolName: candidate.tool.name };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private assertActive(signal: AbortSignal): void {
|
|
26
|
+
signal.throwIfAborted();
|
|
27
|
+
if (this.options.isSubmissionActive?.() === false) throw new Error("Tool Submission is no longer active");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private read(candidate: PiToolCandidate, input: unknown, call: Call): StoredToolInteraction | null {
|
|
31
|
+
const row = this.options.interactions.find(`${this.options.submission.submissionId}:${call.toolCallId}`);
|
|
32
|
+
if (row && (row.requestId !== this.options.submission.requestId ||
|
|
33
|
+
row.toolName !== candidate.tool.name || row.inputJson !== JSON.stringify(input) ||
|
|
34
|
+
row.purpose !== "prerequisite")) {
|
|
35
|
+
throw new Error(`Conflicting durable Tool prerequisite: ${call.toolCallId}`);
|
|
36
|
+
}
|
|
37
|
+
return row;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async beforeTool(candidate: PiToolCandidate, input: unknown, call: Call): Promise<"ready" | "pending" | "decided"> {
|
|
41
|
+
this.assertActive(call.signal);
|
|
42
|
+
const existing = this.read(candidate, input, call);
|
|
43
|
+
if (existing?.status === "declined" || existing?.status === "cancelled") return "decided";
|
|
44
|
+
const spec = candidate.prerequisite;
|
|
45
|
+
if (!spec) throw new Error("The original Tool prerequisite declaration is no longer available");
|
|
46
|
+
const context = this.context(candidate, call);
|
|
47
|
+
const scopeKey = await spec.scope(structuredClone(input), context);
|
|
48
|
+
this.assertActive(call.signal);
|
|
49
|
+
if (scopeKey !== null && (typeof scopeKey !== "string" || !scopeKey.trim())) {
|
|
50
|
+
throw new Error("Invalid trusted Tool prerequisite scope");
|
|
51
|
+
}
|
|
52
|
+
if (scopeKey === null && !existing) return "ready";
|
|
53
|
+
const scope = scopeKey ?? existing!.scopeKey!;
|
|
54
|
+
// Consult refusal before any live check, even for another Tool in this Submission.
|
|
55
|
+
const declined = this.options.interactions.findDeclinedScope(context.submissionId, scope);
|
|
56
|
+
if (declined) {
|
|
57
|
+
this.options.transaction(() => {
|
|
58
|
+
this.assertActive(call.signal);
|
|
59
|
+
if (!existing) this.options.interactions.insert({
|
|
60
|
+
interactionId: `${context.submissionId}:${call.toolCallId}`,
|
|
61
|
+
...this.options.submission, toolCallId: call.toolCallId, toolName: candidate.tool.name,
|
|
62
|
+
inputJson: JSON.stringify(input), purpose: "prerequisite", scopeKey: scope,
|
|
63
|
+
presentationJson: declined.presentationJson, status: "declined", createdAt: Date.now(),
|
|
64
|
+
});
|
|
65
|
+
else if (existing.status === "pending" || existing.status === "ready") {
|
|
66
|
+
this.options.interactions.updatePrerequisite(existing.interactionId, { status: existing.status, version: existing.version }, {
|
|
67
|
+
status: "declined", scopeKey: scope, presentationJson: declined.presentationJson,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return "decided";
|
|
72
|
+
}
|
|
73
|
+
if (existing?.scopeKey === scope && scopeKey !== null) {
|
|
74
|
+
if (existing.status === "pending") {
|
|
75
|
+
await this.options.onNeedsInput?.(candidate.tool.name);
|
|
76
|
+
return "pending";
|
|
77
|
+
}
|
|
78
|
+
if (existing.status === "ready" && (existing.verifiedUntil ?? 0) > Date.now()) return "ready";
|
|
79
|
+
}
|
|
80
|
+
const check = normalizePrerequisiteCheck(scopeKey === null ? { status: "ready" } : await spec.check(structuredClone(input), {
|
|
81
|
+
...context, scopeKey, reason: existing ? "continuation" : "initial",
|
|
82
|
+
}));
|
|
83
|
+
this.assertActive(call.signal);
|
|
84
|
+
if (check.status === "pending") await this.options.onNeedsInput?.(candidate.tool.name);
|
|
85
|
+
const row = this.options.transaction(() => {
|
|
86
|
+
this.assertActive(call.signal);
|
|
87
|
+
const current = this.read(candidate, input, call);
|
|
88
|
+
// A scope refusal or stop may have won while the Host checked.
|
|
89
|
+
const refused = this.options.interactions.findDeclinedScope(context.submissionId, scope);
|
|
90
|
+
if (current?.status === "declined" || current?.status === "cancelled") return current;
|
|
91
|
+
if (current && (current.version !== existing?.version || current.status !== existing?.status)) return current;
|
|
92
|
+
const status = refused ? "declined" : check.status;
|
|
93
|
+
const presentationJson = refused?.presentationJson ?? check.presentationJson ?? current?.presentationJson ?? null;
|
|
94
|
+
if (!current) {
|
|
95
|
+
this.options.interactions.insert({
|
|
96
|
+
interactionId: `${context.submissionId}:${call.toolCallId}`, ...this.options.submission,
|
|
97
|
+
toolCallId: call.toolCallId, toolName: candidate.tool.name, inputJson: JSON.stringify(input),
|
|
98
|
+
purpose: "prerequisite", scopeKey: scope, status, presentationJson,
|
|
99
|
+
verifiedUntil: refused ? null : check.verifiedUntil, createdAt: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
} else if (current.status === "pending" || current.status === "ready") {
|
|
102
|
+
this.options.interactions.updatePrerequisite(current.interactionId, { status: current.status, version: current.version }, {
|
|
103
|
+
status, scopeKey: scope, presentationJson, verifiedUntil: refused ? null : check.verifiedUntil,
|
|
104
|
+
renew: current.scopeKey !== scope || check.status === "pending",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return this.read(candidate, input, call)!;
|
|
108
|
+
});
|
|
109
|
+
if (row.status === "ready") {
|
|
110
|
+
this.checked.set(call.toolCallId, { version: row.version, expiresAt: check.verifiedUntil ?? Date.now() + 60_000 });
|
|
111
|
+
if (existing?.status === "pending") await this.options.onChanged?.(row.interactionId);
|
|
112
|
+
return "ready";
|
|
113
|
+
}
|
|
114
|
+
await this.options.onChanged?.(row.interactionId);
|
|
115
|
+
return row.status === "pending" ? "pending" : "decided";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Safe replay skips beforeTool. Reverify if necessary, but never park after intent. */
|
|
119
|
+
async authorize(candidate: PiToolCandidate, input: unknown, call: Call): Promise<void> {
|
|
120
|
+
this.assertActive(call.signal);
|
|
121
|
+
const row = this.read(candidate, input, call);
|
|
122
|
+
if (row?.status === "declined" || row?.status === "cancelled") return;
|
|
123
|
+
const checked = this.checked.get(call.toolCallId);
|
|
124
|
+
if (row?.status === "ready" && (
|
|
125
|
+
(row.verifiedUntil ?? 0) > Date.now() ||
|
|
126
|
+
(checked?.version === row.version && checked.expiresAt > Date.now())
|
|
127
|
+
)) return;
|
|
128
|
+
if (row?.status === "pending") throw new Error("Tool prerequisite is still waiting for input");
|
|
129
|
+
const context = this.context(candidate, call);
|
|
130
|
+
const scopeKey = await candidate.prerequisite!.scope(structuredClone(input), context);
|
|
131
|
+
this.assertActive(call.signal);
|
|
132
|
+
if (scopeKey === null) return;
|
|
133
|
+
if (typeof scopeKey !== "string" || !scopeKey.trim()) throw new Error("Invalid trusted Tool prerequisite scope");
|
|
134
|
+
if (this.options.interactions.findDeclinedScope(context.submissionId, scopeKey)) {
|
|
135
|
+
throw new Error("The user declined this prerequisite. This operation was not executed.");
|
|
136
|
+
}
|
|
137
|
+
const check = normalizePrerequisiteCheck(await candidate.prerequisite!.check(structuredClone(input), {
|
|
138
|
+
...context, scopeKey, reason: "continuation",
|
|
139
|
+
}));
|
|
140
|
+
this.assertActive(call.signal);
|
|
141
|
+
const latest = this.read(candidate, input, call);
|
|
142
|
+
if (latest?.status === "declined" || latest?.status === "cancelled") return;
|
|
143
|
+
if (row && (!latest || latest.version !== row.version || latest.status !== "ready" ||
|
|
144
|
+
latest.scopeKey !== scopeKey)) throw new Error("The Tool prerequisite wait changed before execution");
|
|
145
|
+
if (this.options.interactions.findDeclinedScope(context.submissionId, scopeKey)) {
|
|
146
|
+
throw new Error("The user declined this prerequisite. This operation was not executed.");
|
|
147
|
+
}
|
|
148
|
+
if (check.status !== "ready") throw new Error("Tool prerequisite no longer satisfied. This operation was not executed.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { PiToolPrerequisiteCheck, PiToolPrerequisiteSpec } from "./contracts";
|
|
3
|
+
import type { StoredToolInteraction } from "./interaction";
|
|
4
|
+
import type {
|
|
5
|
+
ToolPrerequisiteIdentity, ToolPrerequisitePresentation, ToolPrerequisiteRequest,
|
|
6
|
+
ToolPrerequisiteResponse,
|
|
7
|
+
} from "./prerequisite-contracts";
|
|
8
|
+
|
|
9
|
+
const MAX_RECEIPT_MS = 60_000;
|
|
10
|
+
const MAX_PRESENTATION_BYTES = 64 * 1024;
|
|
11
|
+
|
|
12
|
+
export function parsePrerequisiteResponse(value: unknown): ToolPrerequisiteResponse | null {
|
|
13
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
14
|
+
const input = value as Record<string, unknown>;
|
|
15
|
+
const strings = ["id", "submissionId", "toolCallId", "toolName", "scopeKey"];
|
|
16
|
+
if (!strings.every((key) => typeof input[key] === "string" && (input[key] as string).length > 0) ||
|
|
17
|
+
!Number.isSafeInteger(input.version) || (input.version as number) < 1 ||
|
|
18
|
+
(input.decision !== "recheck" && input.decision !== "declined")) return null;
|
|
19
|
+
// Callers may spread the displayed request; display fields never become authority.
|
|
20
|
+
return {
|
|
21
|
+
id: input.id as string, submissionId: input.submissionId as string,
|
|
22
|
+
toolCallId: input.toolCallId as string, toolName: input.toolName as string,
|
|
23
|
+
scopeKey: input.scopeKey as string, version: input.version as number,
|
|
24
|
+
decision: input.decision,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function matchesPrerequisite(row: StoredToolInteraction | null, identity: ToolPrerequisiteIdentity): row is StoredToolInteraction {
|
|
29
|
+
return Boolean(row && row.purpose === "prerequisite" &&
|
|
30
|
+
row.interactionId === identity.id && row.submissionId === identity.submissionId &&
|
|
31
|
+
row.toolCallId === identity.toolCallId && row.toolName === identity.toolName &&
|
|
32
|
+
row.scopeKey === identity.scopeKey && row.version === identity.version);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safePresentation(value: unknown): ToolPrerequisitePresentation {
|
|
36
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
37
|
+
throw new Error("Invalid Tool prerequisite presentation");
|
|
38
|
+
}
|
|
39
|
+
const input = value as ToolPrerequisitePresentation;
|
|
40
|
+
if (typeof input.kind !== "string" || !input.kind.trim() ||
|
|
41
|
+
typeof input.title !== "string" || !input.title.trim() ||
|
|
42
|
+
(input.description !== undefined && typeof input.description !== "string")) {
|
|
43
|
+
throw new Error("Invalid Tool prerequisite presentation");
|
|
44
|
+
}
|
|
45
|
+
const json = JSON.stringify({
|
|
46
|
+
kind: input.kind, title: input.title,
|
|
47
|
+
...(input.description !== undefined ? { description: input.description } : {}),
|
|
48
|
+
...(input.data !== undefined ? { data: input.data } : {}),
|
|
49
|
+
});
|
|
50
|
+
if (new TextEncoder().encode(json).byteLength > MAX_PRESENTATION_BYTES) {
|
|
51
|
+
throw new Error("Tool prerequisite presentation exceeds 64 KiB");
|
|
52
|
+
}
|
|
53
|
+
return JSON.parse(json) as ToolPrerequisitePresentation;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A receipt is only a short-lived check result, never an authorization grant. */
|
|
57
|
+
export function normalizePrerequisiteCheck(value: PiToolPrerequisiteCheck): {
|
|
58
|
+
status: "ready" | "pending";
|
|
59
|
+
presentationJson: string | null;
|
|
60
|
+
verifiedUntil: number | null;
|
|
61
|
+
} {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
if (value?.status === "ready") {
|
|
64
|
+
if (value.validUntil !== undefined && (!Number.isFinite(value.validUntil) || value.validUntil <= now)) {
|
|
65
|
+
throw new Error("Invalid Tool prerequisite verification expiry");
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
status: "ready", presentationJson: null,
|
|
69
|
+
verifiedUntil: value.validUntil !== undefined && value.validUntil > now
|
|
70
|
+
? Math.min(value.validUntil, now + MAX_RECEIPT_MS) : null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (value?.status === "needs-input") {
|
|
74
|
+
return { status: "pending", presentationJson: JSON.stringify(safePresentation(value.presentation)), verifiedUntil: null };
|
|
75
|
+
}
|
|
76
|
+
throw new Error("Invalid Tool prerequisite check result");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function prerequisitePresentation(row: StoredToolInteraction): ToolPrerequisitePresentation {
|
|
80
|
+
return safePresentation(JSON.parse(row.presentationJson ?? "null"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function projectPrerequisites(rows: readonly StoredToolInteraction[]): ToolPrerequisiteRequest[] {
|
|
84
|
+
return rows.filter((row) => row.purpose === "prerequisite" && row.status === "pending")
|
|
85
|
+
.map((row) => ({
|
|
86
|
+
id: row.interactionId, submissionId: row.submissionId, toolCallId: row.toolCallId,
|
|
87
|
+
toolName: row.toolName, scopeKey: row.scopeKey!, version: row.version,
|
|
88
|
+
createdAt: row.createdAt, presentation: prerequisitePresentation(row),
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function declinedPrerequisiteResult(
|
|
93
|
+
row: StoredToolInteraction,
|
|
94
|
+
input: unknown,
|
|
95
|
+
spec?: PiToolPrerequisiteSpec,
|
|
96
|
+
): AgentToolResult<unknown> {
|
|
97
|
+
const presentation = prerequisitePresentation(row);
|
|
98
|
+
const custom = spec?.declinedResult?.(input, presentation);
|
|
99
|
+
return custom ? { ...custom, terminate: false } : {
|
|
100
|
+
content: [{
|
|
101
|
+
type: "text",
|
|
102
|
+
text: `The user declined the prerequisite for ${presentation.title}. This operation was not executed.\n\n` +
|
|
103
|
+
"Try an alternative that does not require this prerequisite. Do not ask again for the current request. " +
|
|
104
|
+
"If no suitable alternative is available, explain the limitation without claiming the task was completed.",
|
|
105
|
+
}],
|
|
106
|
+
details: { declined: true, executed: false },
|
|
107
|
+
};
|
|
108
|
+
}
|