@springbrand/agent-runtime 0.3.0-alpha.25 → 0.3.0-alpha.27
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 +35 -3
- 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/ports.ts +10 -1
- 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/README.md +24 -0
- package/src/tools/adapters/pi-to-ai.ts +16 -2
- package/src/tools/codemode/adapter.ts +61 -31
- package/src/tools/codemode/browser-connector.ts +28 -0
- package/src/tools/codemode/executor.ts +173 -0
- package/src/tools/codemode/index.ts +25 -32
- 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
|
@@ -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
|
+
}
|