@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.
@@ -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: Exclude<ToolInteractionStatus, "pending">,
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
- ORDER BY interaction.created_at ASC
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
+ }