@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.
@@ -0,0 +1,173 @@
1
+ /// <reference lib="esnext.disposable" />
2
+ import {
3
+ DynamicWorkerExecutor,
4
+ type ConnectorBinding,
5
+ type DynamicWorkerExecutorOptions,
6
+ type ExecuteResult,
7
+ type Executor,
8
+ type ResolvedProvider,
9
+ } from "@cloudflare/codemode";
10
+ import { RpcTarget } from "cloudflare:workers";
11
+
12
+ const CONTROL = "__springbrand_execution_control";
13
+ const SETUP = "__springbrand_execution_setup";
14
+ const CANCELLED = "Code execution cancelled";
15
+
16
+ interface AbortHandle {
17
+ abort(): Promise<void>;
18
+ dup(): AbortHandle;
19
+ [Symbol.dispose](): void;
20
+ }
21
+
22
+ function isAbortHandle(value: unknown): value is AbortHandle {
23
+ return (typeof value === "object" && value !== null || typeof value === "function") &&
24
+ typeof Reflect.get(value, "abort") === "function" &&
25
+ typeof Reflect.get(value, "dup") === "function" &&
26
+ typeof Reflect.get(value, Symbol.dispose) === "function";
27
+ }
28
+
29
+ class ExecutionControl extends RpcTarget {
30
+ #handle?: AbortHandle;
31
+ #registered = false;
32
+ #requested = false;
33
+ #aborting?: Promise<void>;
34
+ failure?: unknown;
35
+
36
+ constructor(readonly abortMessage: string) {
37
+ super();
38
+ }
39
+
40
+ async callTool(method: string, value: unknown): Promise<void> {
41
+ if (method !== "register" || this.#registered || !isAbortHandle(value)) {
42
+ throw new Error("Invalid Code Mode cancellation registration");
43
+ }
44
+ this.#registered = true;
45
+ this.#handle = value.dup();
46
+ if (this.#requested) {
47
+ this.abort();
48
+ await this.#aborting;
49
+ }
50
+ }
51
+
52
+ abort(): void {
53
+ this.#requested = true;
54
+ const handle = this.#handle;
55
+ if (!handle || this.#aborting) return;
56
+ this.#aborting = Promise.resolve().then(() => handle.abort()).then(
57
+ () => undefined,
58
+ (error: unknown) => {
59
+ if (!(error instanceof Error && error.message === this.abortMessage)) {
60
+ this.failure = error;
61
+ }
62
+ },
63
+ );
64
+ }
65
+
66
+ async close(): Promise<void> {
67
+ this.abort();
68
+ await this.#aborting;
69
+ try {
70
+ this.#handle?.[Symbol.dispose]();
71
+ } catch (error) {
72
+ this.failure = error;
73
+ } finally {
74
+ this.#handle = undefined;
75
+ }
76
+ }
77
+ }
78
+
79
+ class GuardedConnector extends RpcTarget {
80
+ constructor(
81
+ readonly binding: ConnectorBinding["binding"],
82
+ readonly check: () => void,
83
+ ) {
84
+ super();
85
+ }
86
+
87
+ async callTool(method: string, args: unknown): Promise<unknown> {
88
+ this.check();
89
+ return await this.binding.callTool(method, args);
90
+ }
91
+ }
92
+
93
+ function setup(abortMessage: string): string {
94
+ return `await (async () => {
95
+ const { RpcTarget } = await import("cloudflare:workers");
96
+ const context = this.ctx;
97
+ if (typeof context.abort !== "function") throw new Error("Native Code Mode cancellation is unavailable");
98
+ const abort = context.abort.bind(context);
99
+ const reason = new Error(${JSON.stringify(abortMessage)});
100
+ class Cancellation extends RpcTarget {
101
+ abort() { abort(reason); }
102
+ }
103
+ await ${CONTROL}.register(new Cancellation());
104
+ })();`;
105
+ }
106
+
107
+ /** Cancels a native sandbox pass, not its Host effects or the loaded Worker isolate. */
108
+ export function createCodeExecutionExecutor(
109
+ options: DynamicWorkerExecutorOptions,
110
+ signal?: AbortSignal,
111
+ ): Executor {
112
+ const executor = new DynamicWorkerExecutor(options);
113
+ return {
114
+ async execute(code, providersOrFns, executeOptions): Promise<ExecuteResult> {
115
+ if (signal?.aborted) return { result: undefined, error: CANCELLED };
116
+ const providers: ResolvedProvider[] = Array.isArray(providersOrFns)
117
+ ? providersOrFns
118
+ : [{ name: "codemode", fns: providersOrFns }];
119
+ const connectors = executeOptions?.connectors ?? [];
120
+ if ([...providers, ...connectors].some(({ name }) => name === CONTROL || name === SETUP)) {
121
+ return { result: undefined, error: "Code execution uses a reserved namespace" };
122
+ }
123
+ const control = new ExecutionControl(`Code Mode pass aborted (${crypto.randomUUID()})`);
124
+ let ended = false;
125
+ const check = () => {
126
+ if (signal?.aborted || ended) throw new Error(CANCELLED);
127
+ };
128
+ const onAbort = () => control.abort();
129
+ signal?.addEventListener("abort", onAbort, { once: true });
130
+ let result: ExecuteResult;
131
+ try {
132
+ if (signal?.aborted) return { result: undefined, error: CANCELLED };
133
+ result = await executor.execute(code, [{
134
+ name: SETUP,
135
+ fns: {},
136
+ prelude: setup(control.abortMessage),
137
+ }, ...providers.map((provider) => ({
138
+ ...provider,
139
+ fns: Object.fromEntries(Object.entries(provider.fns).map(([name, fn]) => [
140
+ name,
141
+ async (...args: unknown[]) => {
142
+ check();
143
+ return await fn(...args);
144
+ },
145
+ ])),
146
+ }))], {
147
+ ...executeOptions,
148
+ connectors: [{
149
+ name: CONTROL,
150
+ binding: control,
151
+ }, ...connectors.map((connector) => ({
152
+ ...connector,
153
+ binding: new GuardedConnector(connector.binding, check),
154
+ }))],
155
+ });
156
+ } catch (error) {
157
+ result = {
158
+ result: undefined,
159
+ error: error instanceof Error && error.message === control.abortMessage
160
+ ? CANCELLED
161
+ : error instanceof Error ? error.message : String(error),
162
+ };
163
+ } finally {
164
+ ended = true;
165
+ signal?.removeEventListener("abort", onAbort);
166
+ // A returned SDK timeout is only a soft race; stop the native pass too.
167
+ await control.close();
168
+ }
169
+ if (control.failure) return { result: undefined, error: "Failed to stop the Code Mode pass" };
170
+ return signal?.aborted ? { result: undefined, error: CANCELLED } : result;
171
+ },
172
+ };
173
+ }
@@ -1,3 +1,4 @@
1
+ import { truncateResult } from "@cloudflare/codemode";
1
2
  import type { AgentTool, AgentToolResult } from "@earendil-works/pi-agent-core";
2
3
  import { Type } from "@earendil-works/pi-ai";
3
4
  import type { RuntimeCodeExecutionPort } from "../../config/ports";
@@ -86,17 +87,18 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
86
87
  }
87
88
 
88
89
  const image = record(inner?.image);
90
+ const nativeImage = inner?.type === "browser_screenshot" ? inner : undefined;
89
91
  const screenshotArgs = record(screenshotCall?.args);
90
92
  const screenshotResult = record(screenshotCall?.result);
91
93
  const format = record(screenshotArgs?.params)?.format;
92
94
  const data = typeof image?.data === "string"
93
95
  ? image.data
94
- : screenshotResult?.data;
96
+ : nativeImage ? nativeImage.data : screenshotResult?.data;
95
97
  const mimeType = typeof image?.mimeType === "string"
96
98
  ? image.mimeType
97
- : screenshotMimeType(format);
98
- if (!(inner && "image" in inner) && typeof data !== "string") {
99
- return result(details);
99
+ : nativeImage ? nativeImage.mediaType : screenshotMimeType(format);
100
+ if (!(inner && "image" in inner) && !nativeImage && typeof data !== "string") {
101
+ return result("result" in outer ? { ...outer, result: truncateResult(rawResult) } : details);
100
102
  }
101
103
  const projectedImage = browserImageMetadata(data, mimeType);
102
104
 
@@ -127,10 +129,9 @@ function browserResult(details: unknown): AgentToolResult<unknown> {
127
129
  const projectedDetails = {
128
130
  ...outer,
129
131
  result: inner
130
- ? {
131
- ...inner,
132
- image: projectedImage,
133
- }
132
+ ? nativeImage
133
+ ? { ...inner, data: projectedImage }
134
+ : { ...inner, image: projectedImage }
134
135
  : {
135
136
  image: projectedImage,
136
137
  note: "Screenshot recovered from the Page.captureScreenshot result.",
@@ -172,30 +173,18 @@ function runCodemode(
172
173
  ): AgentTool<typeof executeParameters>["execute"] {
173
174
  return async (_toolCallId, input, signal) => {
174
175
  signal?.throwIfAborted();
175
- // ponytail: 外层截止只保证 Agent 继续;Codemode 支持 AbortSignal 或宿主 dispose 后再终止底层执行。
176
- let timeout: ReturnType<typeof setTimeout> | undefined;
177
- const deadline = new Promise<{
178
- status: "error";
179
- code: "timeout";
180
- error: string;
181
- retryable: false;
182
- outcome: "unknown";
183
- }>((resolve) => {
184
- timeout = setTimeout(() => resolve({
185
- status: "error",
186
- code: "timeout",
187
- error:
188
- `${label} exceeded ${CODEMODE_EXECUTE_TIMEOUT_MS} ms; ` +
189
- "use existing results and state the missing evidence.",
190
- retryable: false,
191
- outcome: "unknown",
192
- }), CODEMODE_EXECUTE_TIMEOUT_MS);
193
- });
176
+ const execution = new AbortController();
177
+ const cancel = () => execution.abort(signal?.reason);
178
+ signal?.addEventListener("abort", cancel, { once: true });
179
+ // The first abort reason distinguishes a parent cancellation from our deadline.
180
+ const timeout = setTimeout(() => execution.abort(new Error(
181
+ `${label} exceeded ${CODEMODE_EXECUTE_TIMEOUT_MS} ms; ` +
182
+ "use existing results and state the missing evidence.",
183
+ )), CODEMODE_EXECUTE_TIMEOUT_MS);
194
184
  try {
195
- const output = await Promise.race([
196
- runtime.execute(input),
197
- deadline.then(result),
198
- ]);
185
+ // The Port settles only after the executor and connector cleanup finish.
186
+ const output = await runtime.execute(input, { signal: execution.signal });
187
+ execution.signal.throwIfAborted();
199
188
  const details = output.details as { status?: unknown; error?: unknown } | null;
200
189
  if (details?.status === "error") {
201
190
  throw new Error(typeof details.error === "string"
@@ -203,8 +192,12 @@ function runCodemode(
203
192
  : `${label} failed`);
204
193
  }
205
194
  return project(output);
195
+ } catch (error) {
196
+ execution.signal.throwIfAborted();
197
+ throw error;
206
198
  } finally {
207
- if (timeout !== undefined) clearTimeout(timeout);
199
+ clearTimeout(timeout);
200
+ signal?.removeEventListener("abort", cancel);
208
201
  }
209
202
  };
210
203
  }
@@ -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
  }