@linxiraos/pi-agent-core 1.1.13 → 1.1.15
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/CHANGELOG.md +2 -47
- package/dist/types/agent.d.ts +13 -3
- package/dist/types/compaction/anthropic.d.ts +108 -0
- package/dist/types/compaction/compaction-v2-streaming.d.ts +2 -2
- package/dist/types/compaction/compaction.d.ts +7 -1
- package/dist/types/compaction/index.d.ts +1 -0
- package/dist/types/compaction/openai.d.ts +7 -7
- package/dist/types/index.d.ts +1 -0
- package/dist/types/speculative-execution.d.ts +63 -0
- package/dist/types/types.d.ts +203 -2
- package/package.json +8 -8
- package/src/agent-loop.ts +455 -76
- package/src/agent.ts +41 -21
- package/src/compaction/anthropic.ts +310 -0
- package/src/compaction/compaction-v2-streaming.ts +2 -2
- package/src/compaction/compaction.ts +204 -68
- package/src/compaction/index.ts +1 -0
- package/src/compaction/openai.ts +7 -7
- package/src/compaction/prompts/anthropic-compaction-instructions.md +17 -0
- package/src/index.ts +2 -0
- package/src/proxy.ts +1 -5
- package/src/speculative-execution.ts +953 -0
- package/src/tokenizer.ts +10 -2
- package/src/types.ts +218 -3
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { type AssistantMessage, validateToolArguments } from "@linxiraos/pi-ai";
|
|
3
|
+
import type {
|
|
4
|
+
AgentContext,
|
|
5
|
+
AgentLoopConfig,
|
|
6
|
+
AgentTool,
|
|
7
|
+
AgentToolCall,
|
|
8
|
+
AgentToolResult,
|
|
9
|
+
SpeculativeAuthorization,
|
|
10
|
+
SpeculativeChildDefinition,
|
|
11
|
+
SpeculativeChildHandle,
|
|
12
|
+
SpeculativeCommitContext,
|
|
13
|
+
SpeculativeOperationContext,
|
|
14
|
+
SpeculativePhysicalOutcome,
|
|
15
|
+
SpeculativeResourceAccess,
|
|
16
|
+
SpeculativeToolExecutionConfig,
|
|
17
|
+
SpeculativeToolTelemetry,
|
|
18
|
+
ToolSpeculationAssessment,
|
|
19
|
+
ToolSpeculationEffect,
|
|
20
|
+
ToolSpeculationExecutionContext,
|
|
21
|
+
ToolSpeculationPolicy,
|
|
22
|
+
ToolSpeculationStreamSession,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
export type SpeculativeRawOutcome = {
|
|
26
|
+
result: AgentToolResult<unknown>;
|
|
27
|
+
isError: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type CoerceToolResult = (raw: unknown) => { result: AgentToolResult<unknown>; malformed: boolean };
|
|
31
|
+
|
|
32
|
+
type FinalizedPolicy = NonNullable<ToolSpeculationPolicy["finalized"]>;
|
|
33
|
+
|
|
34
|
+
type CandidateState = "queued" | "running" | "completed" | "failed" | "discarded";
|
|
35
|
+
|
|
36
|
+
type SpeculativeToolCandidate = {
|
|
37
|
+
candidateId: string;
|
|
38
|
+
parentToolCallId?: string;
|
|
39
|
+
source: "direct" | "eval_shadow";
|
|
40
|
+
dependencies: readonly string[];
|
|
41
|
+
dependents: Set<string>;
|
|
42
|
+
toolCall: AgentToolCall;
|
|
43
|
+
tool: AgentTool;
|
|
44
|
+
policy: FinalizedPolicy;
|
|
45
|
+
executionArgs: Record<string, unknown>;
|
|
46
|
+
// Immutable snapshot of the raw admitted arguments. `toolCall` aliases the
|
|
47
|
+
// finalized assistant message block, which `prepareToolCallDispatch()`
|
|
48
|
+
// mutates in place when `beforeToolCall` returns replacement arguments —
|
|
49
|
+
// comparing against `toolCall.arguments` would compare new-vs-new and
|
|
50
|
+
// reuse stale admission-time `executionArgs` for the replaced call.
|
|
51
|
+
admittedArgumentsJson: string | undefined;
|
|
52
|
+
effect: ToolSpeculationEffect;
|
|
53
|
+
fingerprint: string;
|
|
54
|
+
deferBeforeToolCall: boolean;
|
|
55
|
+
virtualDurationMs?: number;
|
|
56
|
+
virtualStartedAt?: number;
|
|
57
|
+
virtualFinishedAt?: number;
|
|
58
|
+
startedAt?: number;
|
|
59
|
+
finishedAt?: number;
|
|
60
|
+
dispatchReachedAt?: number;
|
|
61
|
+
controller: AbortController;
|
|
62
|
+
outcome: Promise<SpeculativePhysicalOutcome>;
|
|
63
|
+
resolveOutcome: (outcome: SpeculativePhysicalOutcome) => void;
|
|
64
|
+
rejectOutcome: (error: unknown) => void;
|
|
65
|
+
state: CandidateState;
|
|
66
|
+
claimed: boolean;
|
|
67
|
+
// Set once the speculative result survives host validation and the
|
|
68
|
+
// policy/host commit. Dependents start only after every dependency
|
|
69
|
+
// reaches this state: physical completion alone is not enough, since a
|
|
70
|
+
// vetoed parent (e.g. a read whose file changed after execution) must
|
|
71
|
+
// discard its children before they consume the stale result.
|
|
72
|
+
committed: boolean;
|
|
73
|
+
reported: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type CoordinatorEnvironment = {
|
|
77
|
+
context: AgentContext;
|
|
78
|
+
loopConfig: AgentLoopConfig;
|
|
79
|
+
signal?: AbortSignal;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const coordinatorByMessage = new WeakMap<AssistantMessage, SpeculativeOperationCoordinator>();
|
|
83
|
+
const DEFAULT_MAX_IN_FLIGHT = 2;
|
|
84
|
+
const emptyDependencies: readonly string[] = Object.freeze([]);
|
|
85
|
+
|
|
86
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
87
|
+
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
|
91
|
+
const actual = Object.keys(value);
|
|
92
|
+
return actual.length === keys.length && actual.every(key => keys.includes(key));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeResources(value: unknown): readonly SpeculativeResourceAccess[] | undefined {
|
|
96
|
+
if (!Array.isArray(value) || value.length === 0) return undefined;
|
|
97
|
+
const resources: SpeculativeResourceAccess[] = [];
|
|
98
|
+
const accesses = new Set<string>();
|
|
99
|
+
for (const item of value) {
|
|
100
|
+
if (!isPlainRecord(item) || !hasExactKeys(item, ["scheme", "path", "access"])) return undefined;
|
|
101
|
+
if (item.scheme !== "file" || typeof item.path !== "string" || !path.isAbsolute(item.path)) return undefined;
|
|
102
|
+
if (item.access !== "read" || accesses.has(item.path)) return undefined;
|
|
103
|
+
accesses.add(item.path);
|
|
104
|
+
resources.push(Object.freeze({ scheme: "file", path: item.path, access: "read" }));
|
|
105
|
+
}
|
|
106
|
+
return Object.freeze(resources);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** JSON canonicalization shared by assessment and final reconciliation. */
|
|
110
|
+
export function canonicalJson(value: unknown, ancestors = new Set<object>()): string {
|
|
111
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
112
|
+
if (typeof value === "number") {
|
|
113
|
+
if (!Number.isFinite(value)) throw new Error("Non-finite numbers are not JSON values");
|
|
114
|
+
return JSON.stringify(value);
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
if (ancestors.has(value)) throw new Error("Circular values are not JSON values");
|
|
118
|
+
ancestors.add(value);
|
|
119
|
+
try {
|
|
120
|
+
const entries: string[] = [];
|
|
121
|
+
for (let index = 0; index < value.length; index++) {
|
|
122
|
+
if (!(index in value)) throw new Error("Sparse arrays are not JSON values");
|
|
123
|
+
entries.push(canonicalJson(value[index], ancestors));
|
|
124
|
+
}
|
|
125
|
+
return `[${entries.join(",")}]`;
|
|
126
|
+
} finally {
|
|
127
|
+
ancestors.delete(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (!isPlainRecord(value)) throw new Error("Non-JSON value");
|
|
131
|
+
if (ancestors.has(value)) throw new Error("Circular values are not JSON values");
|
|
132
|
+
ancestors.add(value);
|
|
133
|
+
try {
|
|
134
|
+
return `{${Object.keys(value)
|
|
135
|
+
.sort()
|
|
136
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key], ancestors)}`)
|
|
137
|
+
.join(",")}}`;
|
|
138
|
+
} finally {
|
|
139
|
+
ancestors.delete(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createExecutionFingerprint(
|
|
144
|
+
toolCall: AgentToolCall,
|
|
145
|
+
executionArgs: Readonly<Record<string, unknown>>,
|
|
146
|
+
effect: ToolSpeculationEffect,
|
|
147
|
+
): string {
|
|
148
|
+
return canonicalJson({ name: toolCall.name, args: executionArgs, effect });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function candidateMatchesFingerprint(
|
|
152
|
+
candidate: SpeculativeToolCandidate,
|
|
153
|
+
toolCall: AgentToolCall,
|
|
154
|
+
args: Readonly<Record<string, unknown>>,
|
|
155
|
+
): Promise<boolean> {
|
|
156
|
+
let assessment: ToolSpeculationAssessment;
|
|
157
|
+
try {
|
|
158
|
+
assessment = await candidate.policy.assess({ toolCall, args });
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
const effect = assessment.eligible ? normalizeSpeculationEffect(assessment.effect) : undefined;
|
|
163
|
+
if (!effect) return false;
|
|
164
|
+
try {
|
|
165
|
+
return createExecutionFingerprint(toolCall, args, effect) === candidate.fingerprint;
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Rejects malformed or ambiguous effects and returns a frozen logical identity. */
|
|
172
|
+
export function normalizeSpeculationEffect(effect: unknown): ToolSpeculationEffect | undefined {
|
|
173
|
+
if (!isPlainRecord(effect) || typeof effect.kind !== "string") return undefined;
|
|
174
|
+
if (effect.kind === "pure" && hasExactKeys(effect, ["kind"])) return Object.freeze({ kind: "pure" });
|
|
175
|
+
if (effect.kind === "local_read" && hasExactKeys(effect, ["kind", "resources"])) {
|
|
176
|
+
const resources = normalizeResources(effect.resources);
|
|
177
|
+
return resources ? Object.freeze({ kind: "local_read", resources }) : undefined;
|
|
178
|
+
}
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function emitTelemetry(config: SpeculativeToolExecutionConfig, event: SpeculativeToolTelemetry): void {
|
|
183
|
+
try {
|
|
184
|
+
config.onTelemetry?.(event);
|
|
185
|
+
} catch {
|
|
186
|
+
// Observability must not alter execution.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resourceCount(effect: ToolSpeculationEffect): number {
|
|
191
|
+
return effect.kind === "local_read" ? effect.resources.length : 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
class TrackedStreamSession implements ToolSpeculationStreamSession {
|
|
195
|
+
#closed = false;
|
|
196
|
+
#finalized = false;
|
|
197
|
+
|
|
198
|
+
constructor(readonly inner: ToolSpeculationStreamSession) {}
|
|
199
|
+
|
|
200
|
+
get contextIndependent(): boolean | undefined {
|
|
201
|
+
return this.inner.contextIndependent;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
update(toolCall: AgentToolCall, partialJson?: string): void | Promise<void> {
|
|
205
|
+
if (!this.#closed) return this.inner.update(toolCall, partialJson);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async finalize(context: Parameters<ToolSpeculationStreamSession["finalize"]>[0]): Promise<void> {
|
|
209
|
+
if (this.#closed || this.#finalized) return;
|
|
210
|
+
await this.inner.finalize(context);
|
|
211
|
+
this.#finalized = true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async commit(): Promise<void> {
|
|
215
|
+
if (this.#closed) return;
|
|
216
|
+
this.#closed = true;
|
|
217
|
+
await this.inner.commit();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async discard(reason: string): Promise<void> {
|
|
221
|
+
if (this.#closed) return;
|
|
222
|
+
this.#closed = true;
|
|
223
|
+
await this.inner.discard(reason);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
matchesFinalArgs(args: Readonly<Record<string, unknown>>): boolean {
|
|
227
|
+
return this.inner.matchesFinalArgs?.(args) ?? true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One streamed assistant response's invisible speculative-operation lifecycle. */
|
|
232
|
+
export class SpeculativeOperationCoordinator {
|
|
233
|
+
#candidates = new Map<string, SpeculativeToolCandidate>();
|
|
234
|
+
#streamSessions = new Map<string, TrackedStreamSession>();
|
|
235
|
+
#admission: Promise<void> = Promise.resolve();
|
|
236
|
+
#directBarrier = false;
|
|
237
|
+
#closed = false;
|
|
238
|
+
#admissionsFinalized = false;
|
|
239
|
+
#running = 0;
|
|
240
|
+
|
|
241
|
+
constructor(
|
|
242
|
+
readonly config: SpeculativeToolExecutionConfig,
|
|
243
|
+
readonly environment?: CoordinatorEnvironment,
|
|
244
|
+
) {}
|
|
245
|
+
|
|
246
|
+
get maxInFlight(): number {
|
|
247
|
+
const configured = this.config.maxInFlight;
|
|
248
|
+
return configured !== undefined && Number.isFinite(configured)
|
|
249
|
+
? Math.max(1, Math.floor(configured))
|
|
250
|
+
: DEFAULT_MAX_IN_FLIGHT;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
get size(): number {
|
|
254
|
+
return this.#candidates.size;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
register(_contentIndex: number): void {}
|
|
258
|
+
registerStreamSession(toolCallId: string, session: ToolSpeculationStreamSession): boolean {
|
|
259
|
+
if (this.#closed || this.#streamSessions.has(toolCallId)) return false;
|
|
260
|
+
this.#streamSessions.set(toolCallId, new TrackedStreamSession(session));
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
streamSession(toolCallId: string): ToolSpeculationStreamSession | undefined {
|
|
265
|
+
return this.#streamSessions.get(toolCallId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
takeStreamSession(toolCallId: string): ToolSpeculationStreamSession | undefined {
|
|
269
|
+
const session = this.#streamSessions.get(toolCallId);
|
|
270
|
+
if (session) this.#streamSessions.delete(toolCallId);
|
|
271
|
+
return session;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async discardStreamSession(toolCallId: string, reason: string): Promise<void> {
|
|
275
|
+
const session = this.#streamSessions.get(toolCallId);
|
|
276
|
+
if (!session) return;
|
|
277
|
+
this.#streamSessions.delete(toolCallId);
|
|
278
|
+
try {
|
|
279
|
+
await session.discard(reason);
|
|
280
|
+
} catch {
|
|
281
|
+
// Stream-session cleanup cannot alter ordinary dispatch.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async finalizeAdmissions(): Promise<void> {
|
|
286
|
+
await this.#admission;
|
|
287
|
+
this.#admissionsFinalized = true;
|
|
288
|
+
await this.#discardInvalidGraph();
|
|
289
|
+
this.#drain();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
attach(message: AssistantMessage): void {
|
|
293
|
+
if (this.#closed) return;
|
|
294
|
+
if (this.#streamSessions.size > 0) {
|
|
295
|
+
coordinatorByMessage.set(message, this);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
for (const candidate of this.#candidates.values()) {
|
|
299
|
+
if (candidate.state === "discarded") continue;
|
|
300
|
+
coordinatorByMessage.set(message, this);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
static take(message: AssistantMessage): SpeculativeOperationCoordinator | undefined {
|
|
306
|
+
const coordinator = coordinatorByMessage.get(message);
|
|
307
|
+
coordinatorByMessage.delete(message);
|
|
308
|
+
return coordinator;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
static discardForMessage(
|
|
312
|
+
message: AssistantMessage,
|
|
313
|
+
reason: string,
|
|
314
|
+
outcome: "discarded" | "aborted" = "discarded",
|
|
315
|
+
): void {
|
|
316
|
+
const coordinator = SpeculativeOperationCoordinator.take(message);
|
|
317
|
+
void coordinator?.close(reason, outcome);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#createContext(candidate: SpeculativeToolCandidate): SpeculativeOperationContext {
|
|
321
|
+
return {
|
|
322
|
+
candidateId: candidate.candidateId,
|
|
323
|
+
source: candidate.source,
|
|
324
|
+
dependencies: candidate.dependencies,
|
|
325
|
+
tool: candidate.tool,
|
|
326
|
+
toolCall: candidate.toolCall,
|
|
327
|
+
args: candidate.executionArgs,
|
|
328
|
+
effect: candidate.effect,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
#report(
|
|
333
|
+
candidate: SpeculativeToolCandidate,
|
|
334
|
+
outcome: Exclude<SpeculativeToolTelemetry["outcome"], "ineligible">,
|
|
335
|
+
reason?: string,
|
|
336
|
+
): void {
|
|
337
|
+
if (candidate.reported) return;
|
|
338
|
+
candidate.reported = true;
|
|
339
|
+
const executionDurationMs =
|
|
340
|
+
candidate.finishedAt === undefined || candidate.startedAt === undefined
|
|
341
|
+
? undefined
|
|
342
|
+
: candidate.finishedAt - candidate.startedAt;
|
|
343
|
+
emitTelemetry(this.config, {
|
|
344
|
+
source: candidate.source,
|
|
345
|
+
candidateId: candidate.candidateId,
|
|
346
|
+
parentToolCallId: candidate.parentToolCallId,
|
|
347
|
+
toolName: candidate.tool.name,
|
|
348
|
+
effectKind: candidate.effect.kind,
|
|
349
|
+
candidateStartedAt: candidate.startedAt,
|
|
350
|
+
candidateFinishedAt: candidate.finishedAt,
|
|
351
|
+
dispatchReachedAt: candidate.dispatchReachedAt,
|
|
352
|
+
dependencyCount: candidate.dependencies.length,
|
|
353
|
+
outcome,
|
|
354
|
+
reason,
|
|
355
|
+
executionDurationMs,
|
|
356
|
+
overlapMs:
|
|
357
|
+
outcome === "committed" &&
|
|
358
|
+
executionDurationMs !== undefined &&
|
|
359
|
+
candidate.dispatchReachedAt !== undefined &&
|
|
360
|
+
candidate.startedAt !== undefined
|
|
361
|
+
? Math.min(executionDurationMs, Math.max(0, candidate.dispatchReachedAt - candidate.startedAt))
|
|
362
|
+
: undefined,
|
|
363
|
+
resourceCount: resourceCount(candidate.effect),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
ineligible(
|
|
368
|
+
toolCall: AgentToolCall,
|
|
369
|
+
reason: string,
|
|
370
|
+
source: "direct" | "eval_shadow" = "direct",
|
|
371
|
+
parentToolCallId?: string,
|
|
372
|
+
): false {
|
|
373
|
+
emitTelemetry(this.config, {
|
|
374
|
+
source,
|
|
375
|
+
candidateId: toolCall.id,
|
|
376
|
+
parentToolCallId,
|
|
377
|
+
toolName: toolCall.name,
|
|
378
|
+
dependencyCount: 0,
|
|
379
|
+
outcome: "ineligible",
|
|
380
|
+
reason,
|
|
381
|
+
resourceCount: 0,
|
|
382
|
+
});
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async #discardCandidate(
|
|
387
|
+
candidate: SpeculativeToolCandidate,
|
|
388
|
+
outcome: "discarded" | "fingerprint_mismatch" | "aborted",
|
|
389
|
+
reason: string,
|
|
390
|
+
descendants = true,
|
|
391
|
+
): Promise<void> {
|
|
392
|
+
if (candidate.state === "discarded" || candidate.claimed) return;
|
|
393
|
+
candidate.state = "discarded";
|
|
394
|
+
candidate.controller.abort();
|
|
395
|
+
candidate.rejectOutcome(new Error(reason));
|
|
396
|
+
const context = this.#createContext(candidate);
|
|
397
|
+
try {
|
|
398
|
+
await candidate.policy.discard?.({ ...context, reason });
|
|
399
|
+
} catch {
|
|
400
|
+
// A policy discard hook cannot change the architectural call.
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
await this.config.host?.discard?.({ ...context, reason });
|
|
404
|
+
} catch {
|
|
405
|
+
// A host discard hook cannot change the architectural call.
|
|
406
|
+
}
|
|
407
|
+
this.#report(candidate, outcome, reason);
|
|
408
|
+
if (descendants) {
|
|
409
|
+
await Promise.all(
|
|
410
|
+
[...candidate.dependents]
|
|
411
|
+
.map(id => this.#candidates.get(id))
|
|
412
|
+
.filter((value): value is SpeculativeToolCandidate => value !== undefined)
|
|
413
|
+
.map(value => this.#discardCandidate(value, outcome, `dependency ${candidate.candidateId}: ${reason}`)),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
this.#candidates.delete(candidate.candidateId);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async close(reason: string, outcome: "discarded" | "aborted" = "discarded"): Promise<void> {
|
|
420
|
+
if (this.#closed) return;
|
|
421
|
+
this.#closed = true;
|
|
422
|
+
await this.#admission;
|
|
423
|
+
await Promise.all([
|
|
424
|
+
...[...this.#streamSessions.values()].map(async session => {
|
|
425
|
+
try {
|
|
426
|
+
await session.discard(reason);
|
|
427
|
+
} catch {
|
|
428
|
+
// Stream-session cleanup cannot alter ordinary dispatch.
|
|
429
|
+
}
|
|
430
|
+
}),
|
|
431
|
+
...[...this.#candidates.values()].map(candidate => this.#discardCandidate(candidate, outcome, reason)),
|
|
432
|
+
]);
|
|
433
|
+
this.#streamSessions.clear();
|
|
434
|
+
this.#candidates.clear();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async discardAll(reason: string, outcome: "discarded" | "aborted" = "discarded"): Promise<void> {
|
|
438
|
+
await this.close(reason, outcome);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Settles queued admissions without releasing host-deferred work. Lets
|
|
443
|
+
* final reconciliation reuse an admission-time transform result instead of
|
|
444
|
+
* running the (possibly stateful) transform a second time. Unlike
|
|
445
|
+
* `finalizeAdmissions` this never starts deferred candidates, so the
|
|
446
|
+
* `beforeToolCall` gate keeps its release semantics.
|
|
447
|
+
*/
|
|
448
|
+
async settleAdmissions(): Promise<void> {
|
|
449
|
+
await this.#admission;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Admission-time transformed args for a direct candidate whose raw call is
|
|
454
|
+
* unchanged. Returns undefined when there is no usable candidate, in which
|
|
455
|
+
* case the caller recomputes at most once via the transform. Gating on the
|
|
456
|
+
* raw call keeps `beforeToolCall` policy intact: any hook revision changes
|
|
457
|
+
* the raw args and forces a fresh transform plus re-reconciliation.
|
|
458
|
+
*/
|
|
459
|
+
directExecutionArgsFor(
|
|
460
|
+
toolCallId: string,
|
|
461
|
+
rawArgs: Readonly<Record<string, unknown>>,
|
|
462
|
+
): Record<string, unknown> | undefined {
|
|
463
|
+
const candidate = this.#candidates.get(toolCallId);
|
|
464
|
+
if (!candidate || candidate.source !== "direct" || candidate.claimed || candidate.state === "discarded") {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
// Compare against the immutable admission snapshot: `candidate.toolCall`
|
|
469
|
+
// aliases the finalized message block, which `prepareToolCallDispatch()`
|
|
470
|
+
// rewrites in place on a `beforeToolCall` args revision.
|
|
471
|
+
if (candidate.admittedArgumentsJson === undefined) return undefined;
|
|
472
|
+
if (canonicalJson(rawArgs) !== candidate.admittedArgumentsJson) return undefined;
|
|
473
|
+
} catch {
|
|
474
|
+
// Non-canonical calls cannot safely reuse admission arguments.
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
return candidate.executionArgs;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
async reconcileFinalCalls(calls: ReadonlyMap<string, AgentToolCall>): Promise<void> {
|
|
481
|
+
await this.#admission;
|
|
482
|
+
for (const candidate of this.#candidates.values()) {
|
|
483
|
+
if (candidate.source !== "direct") continue;
|
|
484
|
+
const finalCall = calls.get(candidate.candidateId);
|
|
485
|
+
let reuseAdmissionArgs = false;
|
|
486
|
+
try {
|
|
487
|
+
const finalArgs = canonicalJson(finalCall?.arguments);
|
|
488
|
+
// Raw comparison uses the immutable admission snapshot, not the
|
|
489
|
+
// aliased `toolCall.arguments` rewritten by `prepareToolCallDispatch()`.
|
|
490
|
+
reuseAdmissionArgs =
|
|
491
|
+
(candidate.admittedArgumentsJson !== undefined && finalArgs === candidate.admittedArgumentsJson) ||
|
|
492
|
+
finalArgs === canonicalJson(candidate.executionArgs);
|
|
493
|
+
} catch {
|
|
494
|
+
// Non-canonical calls cannot safely reuse admission arguments.
|
|
495
|
+
}
|
|
496
|
+
if (
|
|
497
|
+
!finalCall ||
|
|
498
|
+
finalCall.name !== candidate.toolCall.name ||
|
|
499
|
+
!reuseAdmissionArgs ||
|
|
500
|
+
!(await candidateMatchesFingerprint(candidate, finalCall, candidate.executionArgs))
|
|
501
|
+
) {
|
|
502
|
+
await this.#discardCandidate(candidate, "fingerprint_mismatch", "final tool call changed");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// oxlint-disable-next-line unicorn/no-useless-spread -- discardStreamSession mutates the map during iteration
|
|
506
|
+
for (const toolCallId of [...this.#streamSessions.keys()]) {
|
|
507
|
+
const finalCall = calls.get(toolCallId);
|
|
508
|
+
if (!finalCall) {
|
|
509
|
+
await this.discardStreamSession(toolCallId, "final outer tool call changed");
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
// A hook or argument transform may replace arguments while keeping the
|
|
513
|
+
// ID: the session planned from the original code, so releasing its
|
|
514
|
+
// deferred work would execute a stale plan.
|
|
515
|
+
const session = this.#streamSessions.get(toolCallId);
|
|
516
|
+
const args = finalCall.arguments;
|
|
517
|
+
if (session && args !== undefined && !session.matchesFinalArgs(args as Record<string, unknown>)) {
|
|
518
|
+
await this.discardStreamSession(toolCallId, "final outer tool call arguments changed");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async discardChildren(parentToolCallId: string, reason: string): Promise<void> {
|
|
524
|
+
await this.#admission;
|
|
525
|
+
await Promise.all(
|
|
526
|
+
[...this.#candidates.values()]
|
|
527
|
+
.filter(candidate => candidate.parentToolCallId === parentToolCallId)
|
|
528
|
+
.map(candidate => this.#discardCandidate(candidate, "discarded", reason)),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
async #commitCandidate(
|
|
532
|
+
candidate: SpeculativeToolCandidate,
|
|
533
|
+
tool: AgentTool | undefined,
|
|
534
|
+
toolCall: AgentToolCall,
|
|
535
|
+
args: Readonly<Record<string, unknown>>,
|
|
536
|
+
): Promise<SpeculativeRawOutcome | undefined> {
|
|
537
|
+
if (candidate.claimed || candidate.state === "discarded") return undefined;
|
|
538
|
+
candidate.dispatchReachedAt = Date.now();
|
|
539
|
+
|
|
540
|
+
const fingerprintMatches = await candidateMatchesFingerprint(candidate, toolCall, args);
|
|
541
|
+
if (candidate.tool !== tool || !fingerprintMatches) {
|
|
542
|
+
await this.#discardCandidate(candidate, "fingerprint_mismatch", "final speculation fingerprint changed");
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
candidate.claimed = true;
|
|
546
|
+
let physicalOutcome: SpeculativePhysicalOutcome;
|
|
547
|
+
try {
|
|
548
|
+
physicalOutcome = await candidate.outcome;
|
|
549
|
+
} catch {
|
|
550
|
+
candidate.claimed = false;
|
|
551
|
+
await this.#discardCandidate(candidate, "discarded", "speculative execution failed");
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
if (physicalOutcome.isError) {
|
|
555
|
+
candidate.claimed = false;
|
|
556
|
+
await this.#discardCandidate(candidate, "discarded", "speculative execution returned an error");
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
const context: SpeculativeCommitContext = { ...this.#createContext(candidate), physicalOutcome };
|
|
560
|
+
try {
|
|
561
|
+
if (this.config.host?.validate && !(await this.config.host.validate(context))) {
|
|
562
|
+
candidate.claimed = false;
|
|
563
|
+
await this.#discardCandidate(candidate, "discarded", "host validation vetoed speculative result");
|
|
564
|
+
return undefined;
|
|
565
|
+
}
|
|
566
|
+
const commitDefault = async (): Promise<AgentToolResult<unknown>> =>
|
|
567
|
+
candidate.policy.commit ? candidate.policy.commit(context, physicalOutcome) : physicalOutcome.result;
|
|
568
|
+
const decision = this.config.host?.commit
|
|
569
|
+
? await this.config.host.commit(context, commitDefault)
|
|
570
|
+
: { kind: "committed" as const, result: await commitDefault() };
|
|
571
|
+
if (decision.kind === "fallback") {
|
|
572
|
+
candidate.claimed = false;
|
|
573
|
+
await this.#discardCandidate(candidate, "discarded", decision.reason);
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
if (decision.kind === "failed") throw decision.error;
|
|
577
|
+
candidate.committed = true;
|
|
578
|
+
this.#report(candidate, "committed");
|
|
579
|
+
// Release dependents gated on this commit. Queued children hold no
|
|
580
|
+
// promises of their own, so a commit that arrives while they wait
|
|
581
|
+
// simply starts them here; a veto/failure discards them instead
|
|
582
|
+
// via the cascade below, settling their outcome promises.
|
|
583
|
+
this.#drain();
|
|
584
|
+
return {
|
|
585
|
+
result: decision.result,
|
|
586
|
+
isError: decision.result.isError === true || physicalOutcome.isError,
|
|
587
|
+
};
|
|
588
|
+
} catch (error) {
|
|
589
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
590
|
+
candidate.claimed = false;
|
|
591
|
+
this.#report(candidate, "commit_conflict", reason);
|
|
592
|
+
await this.#discardCandidate(candidate, "discarded", "speculative commit failed");
|
|
593
|
+
throw error;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async claim(
|
|
598
|
+
tool: AgentTool | undefined,
|
|
599
|
+
toolCall: AgentToolCall,
|
|
600
|
+
args: Record<string, unknown>,
|
|
601
|
+
): Promise<SpeculativeRawOutcome | undefined> {
|
|
602
|
+
const candidate = this.#candidates.get(toolCall.id);
|
|
603
|
+
if (candidate?.source !== "direct") return undefined;
|
|
604
|
+
return this.#commitCandidate(candidate, tool, toolCall, args);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
admitFinalized(
|
|
608
|
+
context: AgentContext,
|
|
609
|
+
toolCall: AgentToolCall,
|
|
610
|
+
loopConfig: AgentLoopConfig,
|
|
611
|
+
signal: AbortSignal | undefined,
|
|
612
|
+
): void {
|
|
613
|
+
this.#admission = this.#admission.then(async () => {
|
|
614
|
+
if (this.#directBarrier) {
|
|
615
|
+
this.ineligible(toolCall, "preceding speculation barrier");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const tool = context.tools?.find(value => value.name === toolCall.name);
|
|
619
|
+
if (!tool) {
|
|
620
|
+
this.#directBarrier = true;
|
|
621
|
+
this.ineligible(toolCall, "tool is not speculation-safe");
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
const candidate = await this.#prepareCandidate(
|
|
625
|
+
{
|
|
626
|
+
candidateId: toolCall.id,
|
|
627
|
+
dependencies: emptyDependencies,
|
|
628
|
+
toolCall,
|
|
629
|
+
tool,
|
|
630
|
+
source: "direct",
|
|
631
|
+
},
|
|
632
|
+
{ context, loopConfig, signal },
|
|
633
|
+
);
|
|
634
|
+
if (!candidate) {
|
|
635
|
+
this.#directBarrier = true;
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
this.#insertCandidate(candidate);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async admit(definition: SpeculativeChildDefinition): Promise<SpeculativeChildHandle | undefined> {
|
|
643
|
+
const environment = this.environment;
|
|
644
|
+
if (!environment) {
|
|
645
|
+
this.ineligible(
|
|
646
|
+
definition.toolCall,
|
|
647
|
+
"speculation coordinator has no admission environment",
|
|
648
|
+
definition.source,
|
|
649
|
+
definition.parentToolCallId,
|
|
650
|
+
);
|
|
651
|
+
return undefined;
|
|
652
|
+
}
|
|
653
|
+
let handle: SpeculativeChildHandle | undefined;
|
|
654
|
+
this.#admission = this.#admission.then(async () => {
|
|
655
|
+
const candidate = await this.#prepareCandidate(definition, environment);
|
|
656
|
+
if (!candidate) return;
|
|
657
|
+
this.#insertCandidate(candidate);
|
|
658
|
+
handle = {
|
|
659
|
+
candidateId: candidate.candidateId,
|
|
660
|
+
fingerprint: candidate.fingerprint,
|
|
661
|
+
effect: candidate.effect,
|
|
662
|
+
outcome: candidate.outcome,
|
|
663
|
+
commit: async actualArgs =>
|
|
664
|
+
(await this.#commitCandidate(candidate, candidate.tool, candidate.toolCall, actualArgs))?.result,
|
|
665
|
+
discard: reason => this.#discardCandidate(candidate, "discarded", reason),
|
|
666
|
+
};
|
|
667
|
+
});
|
|
668
|
+
await this.#admission;
|
|
669
|
+
return handle;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async #prepareCandidate(
|
|
673
|
+
definition: Omit<SpeculativeChildDefinition, "parentToolCallId" | "source"> & {
|
|
674
|
+
parentToolCallId?: string;
|
|
675
|
+
source: "direct" | "eval_shadow";
|
|
676
|
+
},
|
|
677
|
+
environment: CoordinatorEnvironment,
|
|
678
|
+
): Promise<SpeculativeToolCandidate | undefined> {
|
|
679
|
+
const { toolCall, source, parentToolCallId } = definition;
|
|
680
|
+
if (this.#closed) {
|
|
681
|
+
this.ineligible(toolCall, "speculation coordinator is closed", source, parentToolCallId);
|
|
682
|
+
return undefined;
|
|
683
|
+
}
|
|
684
|
+
if (this.#candidates.has(definition.candidateId)) {
|
|
685
|
+
this.ineligible(toolCall, "duplicate speculation candidate ID", source, parentToolCallId);
|
|
686
|
+
return undefined;
|
|
687
|
+
}
|
|
688
|
+
if (
|
|
689
|
+
definition.dependencies.includes(definition.candidateId) ||
|
|
690
|
+
new Set(definition.dependencies).size !== definition.dependencies.length
|
|
691
|
+
) {
|
|
692
|
+
this.ineligible(toolCall, "invalid speculation dependencies", source, parentToolCallId);
|
|
693
|
+
return undefined;
|
|
694
|
+
}
|
|
695
|
+
const tool = definition.tool;
|
|
696
|
+
const policy = tool.speculation?.finalized;
|
|
697
|
+
if (!policy || typeof tool.concurrency === "function") {
|
|
698
|
+
this.ineligible(toolCall, "tool is not speculation-safe", source, parentToolCallId);
|
|
699
|
+
return undefined;
|
|
700
|
+
}
|
|
701
|
+
let validatedArgs: Record<string, unknown>;
|
|
702
|
+
try {
|
|
703
|
+
validatedArgs = validateToolArguments(tool, toolCall);
|
|
704
|
+
} catch {
|
|
705
|
+
if (!tool.lenientArgValidation) {
|
|
706
|
+
this.ineligible(toolCall, "tool arguments are not valid", source, parentToolCallId);
|
|
707
|
+
return undefined;
|
|
708
|
+
}
|
|
709
|
+
validatedArgs = { ...(toolCall.arguments as Record<string, unknown>) };
|
|
710
|
+
delete validatedArgs.__parseError;
|
|
711
|
+
delete validatedArgs.__rawJson;
|
|
712
|
+
}
|
|
713
|
+
let executionArgs: Record<string, unknown>;
|
|
714
|
+
try {
|
|
715
|
+
executionArgs =
|
|
716
|
+
source === "direct" && environment.loopConfig.transformToolCallArguments
|
|
717
|
+
? environment.loopConfig.transformToolCallArguments(validatedArgs, toolCall.name)
|
|
718
|
+
: validatedArgs;
|
|
719
|
+
} catch {
|
|
720
|
+
this.ineligible(toolCall, "tool argument transform failed", source, parentToolCallId);
|
|
721
|
+
return undefined;
|
|
722
|
+
}
|
|
723
|
+
let assessment: ToolSpeculationAssessment;
|
|
724
|
+
try {
|
|
725
|
+
assessment = await policy.assess({ toolCall, args: executionArgs });
|
|
726
|
+
} catch {
|
|
727
|
+
this.ineligible(toolCall, "speculation assessment failed", source, parentToolCallId);
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
if (!assessment.eligible) {
|
|
731
|
+
this.ineligible(toolCall, assessment.reason, source, parentToolCallId);
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
const effect = normalizeSpeculationEffect(assessment.effect);
|
|
735
|
+
if (!effect) {
|
|
736
|
+
this.ineligible(toolCall, "invalid speculation effect", source, parentToolCallId);
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
if (tool.concurrency === "exclusive") {
|
|
740
|
+
this.ineligible(toolCall, "exclusive tool is not speculation-safe", source, parentToolCallId);
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
if (effect.kind !== "pure" && !this.config.host) {
|
|
744
|
+
this.ineligible(toolCall, "effect requires host authorization", source, parentToolCallId);
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
let fingerprint: string;
|
|
748
|
+
try {
|
|
749
|
+
fingerprint = createExecutionFingerprint(toolCall, executionArgs, effect);
|
|
750
|
+
} catch {
|
|
751
|
+
this.ineligible(toolCall, "speculation arguments are not canonical", source, parentToolCallId);
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
754
|
+
// Freeze the raw admission arguments before any downstream
|
|
755
|
+
// `prepareToolCallDispatch()` mutation of the aliased message block.
|
|
756
|
+
let admittedArgumentsJson: string | undefined;
|
|
757
|
+
try {
|
|
758
|
+
admittedArgumentsJson = canonicalJson(toolCall.arguments);
|
|
759
|
+
} catch {
|
|
760
|
+
admittedArgumentsJson = undefined;
|
|
761
|
+
}
|
|
762
|
+
const controller = new AbortController();
|
|
763
|
+
const { promise, resolve, reject } = Promise.withResolvers<SpeculativePhysicalOutcome>();
|
|
764
|
+
void promise.catch(() => undefined);
|
|
765
|
+
const candidate: SpeculativeToolCandidate = {
|
|
766
|
+
candidateId: definition.candidateId,
|
|
767
|
+
parentToolCallId,
|
|
768
|
+
source,
|
|
769
|
+
dependencies: Object.freeze([...definition.dependencies]),
|
|
770
|
+
dependents: new Set(),
|
|
771
|
+
toolCall,
|
|
772
|
+
tool,
|
|
773
|
+
policy,
|
|
774
|
+
executionArgs,
|
|
775
|
+
admittedArgumentsJson,
|
|
776
|
+
effect,
|
|
777
|
+
fingerprint,
|
|
778
|
+
deferBeforeToolCall: false,
|
|
779
|
+
virtualDurationMs: definition.virtualDurationMs,
|
|
780
|
+
controller,
|
|
781
|
+
outcome: promise,
|
|
782
|
+
resolveOutcome: resolve,
|
|
783
|
+
rejectOutcome: reject,
|
|
784
|
+
state: "queued",
|
|
785
|
+
claimed: false,
|
|
786
|
+
committed: false,
|
|
787
|
+
reported: false,
|
|
788
|
+
};
|
|
789
|
+
const operationContext = this.#createContext(candidate);
|
|
790
|
+
let authorization: SpeculativeAuthorization = { allowed: true };
|
|
791
|
+
if (this.config.host) {
|
|
792
|
+
try {
|
|
793
|
+
authorization = await this.config.host.authorize(operationContext);
|
|
794
|
+
} catch {
|
|
795
|
+
this.ineligible(toolCall, "host speculation authorization failed", source, parentToolCallId);
|
|
796
|
+
return undefined;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!authorization.allowed) {
|
|
800
|
+
this.ineligible(toolCall, authorization.reason, source, parentToolCallId);
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
803
|
+
candidate.deferBeforeToolCall =
|
|
804
|
+
authorization.deferBeforeToolCall === true && environment.loopConfig.beforeToolCall !== undefined;
|
|
805
|
+
return candidate;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
#insertCandidate(candidate: SpeculativeToolCandidate): void {
|
|
809
|
+
this.#candidates.set(candidate.candidateId, candidate);
|
|
810
|
+
for (const dependency of candidate.dependencies) {
|
|
811
|
+
this.#candidates.get(dependency)?.dependents.add(candidate.candidateId);
|
|
812
|
+
}
|
|
813
|
+
for (const existing of this.#candidates.values()) {
|
|
814
|
+
if (existing.dependencies.includes(candidate.candidateId)) candidate.dependents.add(existing.candidateId);
|
|
815
|
+
}
|
|
816
|
+
this.#drain();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async #discardInvalidGraph(): Promise<void> {
|
|
820
|
+
const invalid = new Set<string>();
|
|
821
|
+
for (const candidate of this.#candidates.values()) {
|
|
822
|
+
if (candidate.dependencies.some(dependency => !this.#candidates.has(dependency)))
|
|
823
|
+
invalid.add(candidate.candidateId);
|
|
824
|
+
}
|
|
825
|
+
const visiting = new Set<string>();
|
|
826
|
+
const visited = new Set<string>();
|
|
827
|
+
const visit = (id: string): boolean => {
|
|
828
|
+
if (visiting.has(id)) return true;
|
|
829
|
+
if (visited.has(id)) return false;
|
|
830
|
+
visiting.add(id);
|
|
831
|
+
const candidate = this.#candidates.get(id);
|
|
832
|
+
const cyclic = candidate?.dependencies.some(visit) ?? false;
|
|
833
|
+
visiting.delete(id);
|
|
834
|
+
visited.add(id);
|
|
835
|
+
if (cyclic) invalid.add(id);
|
|
836
|
+
return cyclic;
|
|
837
|
+
};
|
|
838
|
+
for (const id of this.#candidates.keys()) visit(id);
|
|
839
|
+
await Promise.all(
|
|
840
|
+
[...invalid]
|
|
841
|
+
.map(id => this.#candidates.get(id))
|
|
842
|
+
.filter((value): value is SpeculativeToolCandidate => value !== undefined)
|
|
843
|
+
.map(candidate => this.#discardCandidate(candidate, "discarded", "invalid speculation dependency graph")),
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
#drain(): void {
|
|
848
|
+
if (this.#closed) return;
|
|
849
|
+
for (const candidate of this.#candidates.values()) {
|
|
850
|
+
if (this.#running >= this.maxInFlight) return;
|
|
851
|
+
if (candidate.state !== "queued") continue;
|
|
852
|
+
// A host may defer a local read until the finalized call survives
|
|
853
|
+
// validation and the consumer's beforeToolCall hook.
|
|
854
|
+
if (candidate.deferBeforeToolCall && !this.#admissionsFinalized) continue;
|
|
855
|
+
const dependencies = candidate.dependencies.map(id => this.#candidates.get(id));
|
|
856
|
+
if (dependencies.some(value => value === undefined)) {
|
|
857
|
+
if (this.#admissionsFinalized) {
|
|
858
|
+
void this.#discardCandidate(candidate, "discarded", "unknown speculation dependency");
|
|
859
|
+
}
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
const failedDependency = dependencies.find(value => value?.state === "failed" || value?.state === "discarded");
|
|
863
|
+
if (failedDependency) {
|
|
864
|
+
void this.#discardCandidate(
|
|
865
|
+
candidate,
|
|
866
|
+
"discarded",
|
|
867
|
+
`dependency ${failedDependency.candidateId} did not settle successfully`,
|
|
868
|
+
);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
// Dependents start only after every dependency commits, not merely
|
|
872
|
+
// completes: a completed result may still be vetoed at commit time
|
|
873
|
+
// (host validation, policy/host fallback), and a veto discards the
|
|
874
|
+
// whole subtree via #discardCandidate before any child consumes the
|
|
875
|
+
// stale outcome. A vetoed dependency vanishes from the map, so the
|
|
876
|
+
// unknown-dependency branch above discards any waiter the cascade
|
|
877
|
+
// has not already reached; close/discardAll reject every queued
|
|
878
|
+
// outcome, so no waiter parks forever.
|
|
879
|
+
if (!dependencies.every(value => value?.state === "completed" && value.committed)) continue;
|
|
880
|
+
this.#startCandidate(candidate, dependencies as SpeculativeToolCandidate[]);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
#startCandidate(candidate: SpeculativeToolCandidate, dependencies: SpeculativeToolCandidate[]): void {
|
|
885
|
+
candidate.state = "running";
|
|
886
|
+
candidate.startedAt = Date.now();
|
|
887
|
+
candidate.virtualStartedAt = dependencies.reduce(
|
|
888
|
+
(maximum, dependency) => Math.max(maximum, dependency.virtualFinishedAt ?? 0),
|
|
889
|
+
0,
|
|
890
|
+
);
|
|
891
|
+
this.#running++;
|
|
892
|
+
const environmentSignal = this.environment?.signal;
|
|
893
|
+
const signal = environmentSignal
|
|
894
|
+
? AbortSignal.any([environmentSignal, candidate.controller.signal])
|
|
895
|
+
: candidate.controller.signal;
|
|
896
|
+
const executionContext: ToolSpeculationExecutionContext = {
|
|
897
|
+
toolCall: candidate.toolCall,
|
|
898
|
+
args: candidate.executionArgs,
|
|
899
|
+
effect: candidate.effect,
|
|
900
|
+
};
|
|
901
|
+
void (async () => {
|
|
902
|
+
try {
|
|
903
|
+
// Content evidence is captured here — immediately before execution —
|
|
904
|
+
// rather than at admission: for hook-deferred candidates this runs
|
|
905
|
+
// after `beforeToolCall`, so a blocked call never triggers content
|
|
906
|
+
// I/O. Hosts without the hook keep capturing during `authorize`.
|
|
907
|
+
if (this.config.host?.captureEvidence) {
|
|
908
|
+
const ready = await this.config.host.captureEvidence(this.#createContext(candidate));
|
|
909
|
+
if (!ready || candidate.state === "discarded") {
|
|
910
|
+
await this.#discardCandidate(candidate, "discarded", "host declined speculative evidence capture");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
const outcome = await candidate.policy.execute(executionContext, signal);
|
|
915
|
+
if (candidate.state === "discarded") return;
|
|
916
|
+
candidate.finishedAt = Date.now();
|
|
917
|
+
const duration =
|
|
918
|
+
candidate.virtualDurationMs ??
|
|
919
|
+
Math.max(0, candidate.finishedAt - (candidate.startedAt ?? candidate.finishedAt));
|
|
920
|
+
candidate.virtualFinishedAt = (candidate.virtualStartedAt ?? 0) + duration;
|
|
921
|
+
candidate.state = outcome.kind === "result" && outcome.isError ? "failed" : "completed";
|
|
922
|
+
candidate.resolveOutcome(outcome);
|
|
923
|
+
if (candidate.state === "failed") {
|
|
924
|
+
await Promise.all(
|
|
925
|
+
[...candidate.dependents]
|
|
926
|
+
.map(id => this.#candidates.get(id))
|
|
927
|
+
.filter((value): value is SpeculativeToolCandidate => value !== undefined)
|
|
928
|
+
.map(value =>
|
|
929
|
+
this.#discardCandidate(value, "discarded", `dependency ${candidate.candidateId} failed`),
|
|
930
|
+
),
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
} catch (error) {
|
|
934
|
+
if (candidate.state !== "discarded") {
|
|
935
|
+
candidate.finishedAt = Date.now();
|
|
936
|
+
candidate.state = "failed";
|
|
937
|
+
candidate.rejectOutcome(error);
|
|
938
|
+
await Promise.all(
|
|
939
|
+
[...candidate.dependents]
|
|
940
|
+
.map(id => this.#candidates.get(id))
|
|
941
|
+
.filter((value): value is SpeculativeToolCandidate => value !== undefined)
|
|
942
|
+
.map(value =>
|
|
943
|
+
this.#discardCandidate(value, "discarded", `dependency ${candidate.candidateId} failed`),
|
|
944
|
+
),
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
} finally {
|
|
948
|
+
this.#running--;
|
|
949
|
+
this.#drain();
|
|
950
|
+
}
|
|
951
|
+
})();
|
|
952
|
+
}
|
|
953
|
+
}
|