@vanillagreen/pi-claude-bridge 1.9.0 → 3.2.2
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/README.md +43 -125
- package/bundle/connector-inventory.js +16 -3
- package/bundle/index.js +3743 -1810
- package/package.json +14 -23
- package/src/account-host.ts +112 -0
- package/src/account-router.ts +272 -0
- package/src/agents-md.ts +54 -10
- package/src/assistant-stream.ts +472 -66
- package/src/auth-presence.ts +6 -50
- package/src/bridge-commands.ts +86 -0
- package/src/bridge-state.ts +200 -15
- package/src/config.ts +170 -20
- package/src/connector-audit.ts +203 -0
- package/src/connector-cache.ts +148 -0
- package/src/connector-inventory.ts +66 -7
- package/src/connector-runtime.ts +158 -0
- package/src/connectors.ts +406 -19
- package/src/consume-query.ts +312 -0
- package/src/convert.ts +20 -10
- package/src/debug.ts +64 -6
- package/src/index.ts +901 -676
- package/src/models.ts +0 -7
- package/src/native-provider.ts +94 -0
- package/src/prompt-context.ts +5 -1
- package/src/query-options.ts +183 -0
- package/src/query-state.ts +490 -25
- package/src/query-teardown.ts +45 -0
- package/src/rate-limit.ts +48 -13
- package/src/request-lane.ts +36 -0
- package/src/sdk-query.ts +16 -0
- package/src/session-persistence.ts +370 -50
- package/src/tool-pairing-audit.ts +117 -0
- package/src/typebox-to-zod.ts +9 -3
package/src/query-state.ts
CHANGED
|
@@ -6,11 +6,47 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Extracted from index.ts so tests can import without activating the extension.
|
|
8
8
|
|
|
9
|
+
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
|
9
10
|
import type { AssistantMessage, AssistantMessageEventStream, Model } from "@earendil-works/pi-ai";
|
|
11
|
+
import { isConnectorTool } from "./connectors.js";
|
|
10
12
|
import type { McpResult } from "./extract-tool-results.js";
|
|
13
|
+
import { currentRequestLaneId } from "./request-lane.js";
|
|
14
|
+
|
|
15
|
+
/** A mid-query user run captured for replay after the active query ends.
|
|
16
|
+
* `text` is the joined text form (previews, and the replay prompt when no
|
|
17
|
+
* image blocks were captured). `blocks` is present when the run carried
|
|
18
|
+
* images — the replay must send the blocks or the images are silently lost
|
|
19
|
+
* (vstack#993). */
|
|
20
|
+
export interface DeferredUserMessage {
|
|
21
|
+
text: string;
|
|
22
|
+
blocks?: ContentBlockParam[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Diag payload for a deferred-message drop: counts, sites, and lengths only.
|
|
26
|
+
* The messages are user-authored prompt text and the diag log sits outside
|
|
27
|
+
* any host app's retention boundary, so no content — not even a preview —
|
|
28
|
+
* may appear in the entry (VST-15). */
|
|
29
|
+
export function summarizeDroppedUserMessages(site: string, dropped: DeferredUserMessage[]): Record<string, unknown> {
|
|
30
|
+
return {
|
|
31
|
+
site,
|
|
32
|
+
count: dropped.length,
|
|
33
|
+
textLengths: dropped.map((message) => message.text.length),
|
|
34
|
+
imageOnlyCount: dropped.filter((message) => !message.text && message.blocks?.length).length,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
11
37
|
|
|
12
38
|
export interface PendingToolCall {
|
|
13
39
|
toolName: string;
|
|
40
|
+
/** The MCP invocation's schema-validated arguments. The SDK hands the handler
|
|
41
|
+
* the COMPLETE input, so this is the authoritative copy — the grace-timer
|
|
42
|
+
* finalize settles a still-partial streamed block from here instead of from
|
|
43
|
+
* its truncated partial JSON (vstack#1469: a `{}` settle made Pi execute
|
|
44
|
+
* empty-argument calls). */
|
|
45
|
+
args: Record<string, unknown>;
|
|
46
|
+
/** `QueryContext.callbackGeneration` at registration. A handler from an older
|
|
47
|
+
* generation whose id was never forwarded to Pi can never be answered — see
|
|
48
|
+
* drainStrandedToolCalls. */
|
|
49
|
+
generation: number;
|
|
14
50
|
resolve: (result: McpResult) => void;
|
|
15
51
|
}
|
|
16
52
|
|
|
@@ -56,6 +92,86 @@ export function drainPendingToolCalls(queryCtx: QueryContext, cause: ToolCallDra
|
|
|
56
92
|
return drained;
|
|
57
93
|
}
|
|
58
94
|
|
|
95
|
+
/** The error a stranded handler resolves with: its call never reached Pi and
|
|
96
|
+
* the forward paths have marked it dead, so no result can ever arrive and the
|
|
97
|
+
* call is guaranteed not to have executed on the Pi side. */
|
|
98
|
+
export function strandedToolCallResult(): McpResult {
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text: "Claude bridge: this tool call was never forwarded to Pi before its turn ended, so it did not execute and no result can arrive. Re-run the tool." }],
|
|
101
|
+
isError: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Fail ONE waiting handler whose call never reached Pi. No-op when the id was
|
|
106
|
+
* forwarded (Pi owes it a result — steer-split deliveries arrive turns later)
|
|
107
|
+
* or nothing is waiting. Marks the id dead so a lagging stream replay can
|
|
108
|
+
* never forward it AFTER the model was told it failed — that late forward
|
|
109
|
+
* would execute the call a second time behind the model's back (vstack#1469).
|
|
110
|
+
* Returns true when a handler was failed. */
|
|
111
|
+
export function failStrandedToolCall(queryCtx: QueryContext, id: string): boolean {
|
|
112
|
+
if (queryCtx.forwardedToolCallIds.has(id)) return false;
|
|
113
|
+
const pending = queryCtx.pendingToolCalls.get(id);
|
|
114
|
+
if (!pending) return false;
|
|
115
|
+
queryCtx.pendingToolCalls.delete(id);
|
|
116
|
+
queryCtx.deadToolCallIds.add(id);
|
|
117
|
+
pending.resolve(strandedToolCallResult());
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fail every waiting handler that provably can never be answered: registered
|
|
122
|
+
* before the CURRENT provider callback (older `generation`) with an id Pi was
|
|
123
|
+
* never told about. Runs at the delivery site, where a fresh callback proves
|
|
124
|
+
* the previous turn is settled. Handlers whose id WAS forwarded stay waiting —
|
|
125
|
+
* Pi may deliver their result in a later callback (steer-split batches).
|
|
126
|
+
* Handlers from the current generation stay untouched: their turn is still
|
|
127
|
+
* streaming and the forward may simply not have happened yet. Failed ids are
|
|
128
|
+
* marked dead exactly like failStrandedToolCall. */
|
|
129
|
+
export function drainStrandedToolCalls(queryCtx: QueryContext): Array<{ id: string; toolName: string }> {
|
|
130
|
+
const stranded: Array<{ id: string; toolName: string }> = [];
|
|
131
|
+
for (const [id, pending] of queryCtx.pendingToolCalls) {
|
|
132
|
+
if (pending.generation >= queryCtx.callbackGeneration) continue;
|
|
133
|
+
if (queryCtx.forwardedToolCallIds.has(id)) continue;
|
|
134
|
+
stranded.push({ id, toolName: pending.toolName });
|
|
135
|
+
}
|
|
136
|
+
for (const { id } of stranded) {
|
|
137
|
+
const pending = queryCtx.pendingToolCalls.get(id)!;
|
|
138
|
+
queryCtx.pendingToolCalls.delete(id);
|
|
139
|
+
queryCtx.deadToolCallIds.add(id);
|
|
140
|
+
pending.resolve(strandedToolCallResult());
|
|
141
|
+
}
|
|
142
|
+
return stranded;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Consume a result waiting for `id`, checking the live queue first and the
|
|
146
|
+
* reap-parked store second. Late handlers land here: Pi delivers every result
|
|
147
|
+
* of a turn in one callback, while the SDK staggers handler invocations, so a
|
|
148
|
+
* handler can fire after a message boundary already parked its result. */
|
|
149
|
+
export function takeQueuedOrParkedResult(queryCtx: QueryContext, id: string): McpResult | undefined {
|
|
150
|
+
const queued = queryCtx.pendingResults.get(id);
|
|
151
|
+
if (queued !== undefined) {
|
|
152
|
+
queryCtx.pendingResults.delete(id);
|
|
153
|
+
return queued;
|
|
154
|
+
}
|
|
155
|
+
const parked = queryCtx.reapedResults.get(id);
|
|
156
|
+
if (parked !== undefined) {
|
|
157
|
+
queryCtx.reapedResults.delete(id);
|
|
158
|
+
return parked;
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One connector call's audit state for the life of a query. `recorded` means an
|
|
164
|
+
* entry for it has already been appended (or attempted), so neither a re-yielded
|
|
165
|
+
* result nor the teardown flush can record it twice. */
|
|
166
|
+
export interface ConnectorCallAuditState {
|
|
167
|
+
name: string;
|
|
168
|
+
/** The child session that issued it, captured when the call was seen — a
|
|
169
|
+
* continuation query gets a new one, and a call is audited against the session
|
|
170
|
+
* that actually made it. */
|
|
171
|
+
childSessionId?: string;
|
|
172
|
+
recorded: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
59
175
|
export interface TurnToolCallRecord {
|
|
60
176
|
id: string;
|
|
61
177
|
toolName: string;
|
|
@@ -67,6 +183,12 @@ export interface ClaimedToolCall {
|
|
|
67
183
|
match: "tool-args" | "tool-name" | "none";
|
|
68
184
|
ambiguous: boolean;
|
|
69
185
|
available: number;
|
|
186
|
+
/** True when the claim went through the sole-same-name fallback even though
|
|
187
|
+
* the recorded call had (different) arguments. Recorded args come from the
|
|
188
|
+
* raw streamed input while the handler receives the MCP server's
|
|
189
|
+
* schema-validated copy, so a benign divergence (stripped unknown key,
|
|
190
|
+
* applied default) must not strand the call — but it is worth a diagnostic. */
|
|
191
|
+
argsMismatch?: boolean;
|
|
70
192
|
}
|
|
71
193
|
|
|
72
194
|
export interface ToolResultProgress {
|
|
@@ -130,15 +252,150 @@ export class QueryContext {
|
|
|
130
252
|
latestCursor = 0;
|
|
131
253
|
pendingToolCalls = new Map<string, PendingToolCall>();
|
|
132
254
|
pendingResults = new Map<string, McpResult>();
|
|
255
|
+
/** Results a message-boundary reap moved OUT of pendingResults so they stop
|
|
256
|
+
* poisoning mismatch reports, kept CONSUMABLE for a handler that fires later.
|
|
257
|
+
* The 2026-08-17 deadlock session showed the reap's "no consumer will ever
|
|
258
|
+
* come" assumption failing routinely: Pi delivers a turn's results in one
|
|
259
|
+
* callback while the SDK staggers handler invocations past the next message
|
|
260
|
+
* boundary. Query-scoped, bounded by the query's tool-call count. */
|
|
261
|
+
reapedResults = new Map<string, McpResult>();
|
|
262
|
+
/** Every tool-call id this query has handed to Pi inside an ENDED turn — the
|
|
263
|
+
* set endToolUseTurn stamps from the turn's content. A forwarded id is one Pi
|
|
264
|
+
* will execute and answer; it must never be emitted again (a lagging stream
|
|
265
|
+
* replays the same tool_use into the NEXT turn, and per-message turnBlocks
|
|
266
|
+
* dedup cannot see across turns — vstack#1469's duplicate executions), and a
|
|
267
|
+
* handler waiting on it must be left waiting at the stranded-handler drains.
|
|
268
|
+
* Query-scoped, never reset per message. */
|
|
269
|
+
forwardedToolCallIds = new Set<string>();
|
|
270
|
+
/** Ids whose waiting handler was resolved with strandedToolCallResult. The
|
|
271
|
+
* model has been told these calls failed; forwarding one later would execute
|
|
272
|
+
* it behind the model's back, so every forward path skips them. */
|
|
273
|
+
deadToolCallIds = new Set<string>();
|
|
274
|
+
/** Streamed block indexes suppressed as duplicate or dead tool_use blocks —
|
|
275
|
+
* their deltas and stops must be ignored the same way child-executed indexes
|
|
276
|
+
* are. Per message; reset by resetToolTracking. */
|
|
277
|
+
suppressedStreamIndexes = new Set<number>();
|
|
278
|
+
/** Bumped at every provider callback for this query. Stamped onto handlers at
|
|
279
|
+
* registration so the stranded-handler drain can tell "registered before this
|
|
280
|
+
* callback, provably settled" from "racing this callback's own stream". */
|
|
281
|
+
callbackGeneration = 0;
|
|
133
282
|
turnToolCallIds: string[] = [];
|
|
134
283
|
turnToolCalls: TurnToolCallRecord[] = [];
|
|
284
|
+
/**
|
|
285
|
+
* id → Pi tool name for every tool call this QUERY recorded, across all child
|
|
286
|
+
* messages. Deliberately NOT cleared by resetToolTracking: per-message tracking
|
|
287
|
+
* resets at every message boundary, but `pendingResults` is query-scoped, so a
|
|
288
|
+
* result stranded there outlives the message that named it. Without this map a
|
|
289
|
+
* teardown report can only say "1 queued" with empty toolNames and 0/0
|
|
290
|
+
* counters — which is exactly the unactionable record the 2026-07-28 diag log
|
|
291
|
+
* showed. Bounded by the number of tool calls in one query.
|
|
292
|
+
*/
|
|
293
|
+
queryToolNames = new Map<string, string>();
|
|
294
|
+
/** id → last-known arguments, query-scoped like queryToolNames and for the
|
|
295
|
+
* same reason: a late handler firing after resetToolTracking wiped the
|
|
296
|
+
* per-message records must still be able to exact-match the parked/queued
|
|
297
|
+
* result of ITS OWN call — without stored args the only fallback is
|
|
298
|
+
* sole-same-name, which can hand it a LIVE sibling's id (vstack#1469). */
|
|
299
|
+
queryToolArgs = new Map<string, Record<string, unknown>>();
|
|
135
300
|
claimedToolCallIds = new Set<string>();
|
|
136
301
|
deliveredToolResultIds = new Set<string>();
|
|
137
302
|
resolvedToolResultIds = new Set<string>();
|
|
138
303
|
unmatchedToolResultIds = new Set<string>();
|
|
139
304
|
reportedToolResultMismatch = false;
|
|
140
|
-
deferredUserMessages:
|
|
305
|
+
deferredUserMessages: DeferredUserMessage[] = [];
|
|
141
306
|
handledTerminalError = false;
|
|
307
|
+
// Once visible text/thinking, a complete tool call, or a child-executed
|
|
308
|
+
// CONNECTOR dispatch reaches Pi, the request must never be replayed on
|
|
309
|
+
// another account (duplicate side effects). Query-scoped, not per-turn:
|
|
310
|
+
// resetTurnState must not clear it.
|
|
311
|
+
committedOutput = false;
|
|
312
|
+
/** True when this query holds NO claim on the module-level shared session
|
|
313
|
+
* record: a reentrant (subagent) query, or a foreign-conversation one-shot
|
|
314
|
+
* (vstack#1001). Every shared-record mutation reachable from this context —
|
|
315
|
+
* reportToolResultMismatch's needsRebuild/forceRotate mark, the cursor
|
|
316
|
+
* advances on the tool-result-delivery and orphaned-result paths — must
|
|
317
|
+
* no-op so the PARENT's record stays untouched. Assigned at fresh-query
|
|
318
|
+
* setup; deliberately NOT cleared at query end, so a late orphaned tool
|
|
319
|
+
* result arriving after this query settled is still attributed to it. */
|
|
320
|
+
detachedFromSharedSession = false;
|
|
321
|
+
/** Armed grace timer for ending a tool_use turn whose terminal stream events
|
|
322
|
+
* (message_delta/message_stop) never arrive. The normal path ends the turn at
|
|
323
|
+
* message_stop, AFTER message_delta delivered the real output-token count;
|
|
324
|
+
* this is the deadlock backstop for streams that go silent instead. Managed
|
|
325
|
+
* by schedule/cancelToolUseTurnEnd in assistant-stream.ts. */
|
|
326
|
+
scheduledToolUseEnd: { stream: unknown; timer: ReturnType<typeof setTimeout> } | null = null;
|
|
327
|
+
|
|
328
|
+
// Tool calls the CHILD executes itself (see isChildExecutedTool).
|
|
329
|
+
// Deliberately NOT in turnToolCalls/turnToolCallIds: those track calls Pi
|
|
330
|
+
// owes a result for, and Pi owes nothing here. CONNECTORS ONLY — kept so the
|
|
331
|
+
// child's real result can be recognized when it comes back on the SDK's
|
|
332
|
+
// `user` message and audited. A child-internal built-in (ToolSearch et al.)
|
|
333
|
+
// never enters this map: its result needs no recognition and no audit, only
|
|
334
|
+
// its streamed deltas need skipping (childExecutedStreamIndexes below).
|
|
335
|
+
/** tool_use id → raw SDK tool name. */
|
|
336
|
+
childExecutedToolCalls = new Map<string, string>();
|
|
337
|
+
/**
|
|
338
|
+
* The same calls, for the connector-call audit trail (see connector-audit.ts).
|
|
339
|
+
*
|
|
340
|
+
* Query-scoped and deliberately NOT cleared by resetToolTracking: that runs at
|
|
341
|
+
* every child message boundary, and a call issued in one child message is only
|
|
342
|
+
* reconciled after that message ends. Clearing it there would make an abandoned
|
|
343
|
+
* call unrecordable at teardown — which is the one case the trail exists for.
|
|
344
|
+
*/
|
|
345
|
+
connectorCallAudit = new Map<string, ConnectorCallAuditState>();
|
|
346
|
+
/** Claude Code session id for this query, from the SDK's `system` init message.
|
|
347
|
+
* Undefined until it arrives; the audit trail omits the field rather than
|
|
348
|
+
* guessing. */
|
|
349
|
+
childSessionId: string | undefined;
|
|
350
|
+
/** Anthropic content-block indexes of the current assistant message that carry
|
|
351
|
+
* a child-executed tool_use. Scoped to one message: cleared at message_start,
|
|
352
|
+
* and an index is released as soon as a new block starts there. */
|
|
353
|
+
childExecutedStreamIndexes = new Set<number>();
|
|
354
|
+
|
|
355
|
+
// Usage accounting for a Pi turn that spans SEVERAL child assistant messages.
|
|
356
|
+
//
|
|
357
|
+
// Every child message is a separate billed API call, and each reports its own
|
|
358
|
+
// counters — `message_start`/`message_delta` REPLACE rather than accumulate. A
|
|
359
|
+
// Pi turn used to end at the first tool call, so one Pi message meant one child
|
|
360
|
+
// message and replacing was right. A turn containing a child-executed connector
|
|
361
|
+
// call now keeps running across the child's follow-up messages, so replacing
|
|
362
|
+
// would silently drop everything the earlier ones billed (measured: 55,685
|
|
363
|
+
// cache-write tokens lost on a single connector turn).
|
|
364
|
+
//
|
|
365
|
+
// So: `turnUsageCarry` holds the totals of the child messages already COMPLETE
|
|
366
|
+
// in this Pi turn, `currentMessageUsage` holds the one in flight, and the Pi
|
|
367
|
+
// message reports their sum. Summing is the correct model for input and cache
|
|
368
|
+
// too — each call bills its own.
|
|
369
|
+
turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
370
|
+
currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
371
|
+
/** Anthropic id of the child message `currentMessageUsage` describes. */
|
|
372
|
+
currentMessageId: string | undefined;
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Declare which child message the following usage belongs to, banking the
|
|
376
|
+
* previous one's counters into the turn total.
|
|
377
|
+
*
|
|
378
|
+
* Keyed on the MESSAGE ID rather than on the call site, because both paths
|
|
379
|
+
* that see a message boundary can fire for the SAME message: `message_start`
|
|
380
|
+
* arrives on the stream, and the SDK then yields that message again in
|
|
381
|
+
* completed form. Banking per call site double-counted whenever the completed
|
|
382
|
+
* copy took the no-stream-events branch — which it does whenever a message
|
|
383
|
+
* produced no content blocks, since `turnSawStreamEvent` only tracks those.
|
|
384
|
+
*
|
|
385
|
+
* With no id on either side (older/streamless shapes) this degrades to
|
|
386
|
+
* banking on every call, which is what each caller means when it cannot
|
|
387
|
+
* prove otherwise.
|
|
388
|
+
*/
|
|
389
|
+
beginChildMessage(messageId?: unknown): void {
|
|
390
|
+
const id = typeof messageId === "string" && messageId.length > 0 ? messageId : undefined;
|
|
391
|
+
if (id !== undefined && id === this.currentMessageId) return; // same message
|
|
392
|
+
this.turnUsageCarry.input += this.currentMessageUsage.input;
|
|
393
|
+
this.turnUsageCarry.output += this.currentMessageUsage.output;
|
|
394
|
+
this.turnUsageCarry.cacheRead += this.currentMessageUsage.cacheRead;
|
|
395
|
+
this.turnUsageCarry.cacheWrite += this.currentMessageUsage.cacheWrite;
|
|
396
|
+
this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
397
|
+
this.currentMessageId = id;
|
|
398
|
+
}
|
|
142
399
|
|
|
143
400
|
// Per-turn (reset together)
|
|
144
401
|
turnOutput: AssistantMessage | null = null;
|
|
@@ -163,6 +420,17 @@ export class QueryContext {
|
|
|
163
420
|
this.turnSawStreamEvent = false;
|
|
164
421
|
this.turnSawToolCall = false;
|
|
165
422
|
this.handledTerminalError = false;
|
|
423
|
+
// A new pi message means the previous turn's stream is done with; an armed
|
|
424
|
+
// end-timer for it must not fire into the new turn's state.
|
|
425
|
+
if (this.scheduledToolUseEnd) {
|
|
426
|
+
clearTimeout(this.scheduledToolUseEnd.timer);
|
|
427
|
+
this.scheduledToolUseEnd = null;
|
|
428
|
+
}
|
|
429
|
+
// Usage accounting IS per-Pi-message, so it resets with the message it
|
|
430
|
+
// describes — unlike tool-call tracking below.
|
|
431
|
+
this.turnUsageCarry = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
432
|
+
this.currentMessageUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
433
|
+
this.currentMessageId = undefined;
|
|
166
434
|
// Tool-call tracking is NOT reset here — it persists across the
|
|
167
435
|
// tool-result delivery callback for the same assistant message. New
|
|
168
436
|
// assistant messages call resetToolTracking() explicitly.
|
|
@@ -176,10 +444,47 @@ export class QueryContext {
|
|
|
176
444
|
this.resolvedToolResultIds.clear();
|
|
177
445
|
this.unmatchedToolResultIds.clear();
|
|
178
446
|
this.reportedToolResultMismatch = false;
|
|
447
|
+
this.childExecutedToolCalls.clear();
|
|
448
|
+
this.childExecutedStreamIndexes.clear();
|
|
449
|
+
this.suppressedStreamIndexes.clear();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Note a tool_use the child runs itself. `streamIndex` is present only on the
|
|
453
|
+
* streamed path, where later deltas/stops for that block must be skipped —
|
|
454
|
+
* that skip applies to every child-executed call. Result recognition and the
|
|
455
|
+
* connector-call audit apply to CONNECTORS only: a child-internal built-in
|
|
456
|
+
* (ToolSearch et al.) is tool plumbing, not account-data access, so nothing
|
|
457
|
+
* about it belongs in the audit trail and no result needs matching. */
|
|
458
|
+
noteChildExecutedToolCall(id: string | undefined, rawName: string, streamIndex?: number): void {
|
|
459
|
+
if (isConnectorTool(rawName)) {
|
|
460
|
+
// A connector call is an account-visible side effect the child may run
|
|
461
|
+
// before any Pi-visible event; crossing it permanently forbids account
|
|
462
|
+
// replay even when the result or a later text delta never arrives.
|
|
463
|
+
// Child-internal built-ins (ToolSearch et al.) are pure plumbing and
|
|
464
|
+
// deliberately do NOT commit — an early ToolSearch must not make the
|
|
465
|
+
// whole turn non-rotatable.
|
|
466
|
+
this.markOutputCommitted();
|
|
467
|
+
}
|
|
468
|
+
if (id && isConnectorTool(rawName)) {
|
|
469
|
+
this.childExecutedToolCalls.set(id, rawName);
|
|
470
|
+
// Both emission paths can see the same call (streamed block, then the
|
|
471
|
+
// SDK's completed copy), so never overwrite an existing audit state —
|
|
472
|
+
// that would resurrect one already recorded.
|
|
473
|
+
if (!this.connectorCallAudit.has(id)) {
|
|
474
|
+
this.connectorCallAudit.set(id, {
|
|
475
|
+
name: rawName,
|
|
476
|
+
...(this.childSessionId ? { childSessionId: this.childSessionId } : {}),
|
|
477
|
+
recorded: false,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (typeof streamIndex === "number") this.childExecutedStreamIndexes.add(streamIndex);
|
|
179
482
|
}
|
|
180
483
|
|
|
181
484
|
recordToolCall(id: string | undefined, toolName: string, args: Record<string, unknown> = {}): void {
|
|
182
485
|
if (!id) return;
|
|
486
|
+
this.queryToolNames.set(id, toolName);
|
|
487
|
+
this.queryToolArgs.set(id, args);
|
|
183
488
|
if (!this.turnToolCallIds.includes(id)) this.turnToolCallIds.push(id);
|
|
184
489
|
const existing = this.turnToolCalls.find((call) => call.id === id);
|
|
185
490
|
if (existing) {
|
|
@@ -192,6 +497,7 @@ export class QueryContext {
|
|
|
192
497
|
|
|
193
498
|
updateToolCallArgs(id: string | undefined, args: Record<string, unknown>): void {
|
|
194
499
|
if (!id) return;
|
|
500
|
+
this.queryToolArgs.set(id, args);
|
|
195
501
|
const existing = this.turnToolCalls.find((call) => call.id === id);
|
|
196
502
|
if (existing) existing.arguments = args;
|
|
197
503
|
}
|
|
@@ -200,30 +506,99 @@ export class QueryContext {
|
|
|
200
506
|
return Boolean(id && (this.turnToolCallIds.includes(id) || this.turnToolCalls.some((call) => call.id === id)));
|
|
201
507
|
}
|
|
202
508
|
|
|
509
|
+
markOutputCommitted(): void {
|
|
510
|
+
this.committedOutput = true;
|
|
511
|
+
}
|
|
512
|
+
|
|
203
513
|
claimToolCall(toolName: string, args: Record<string, unknown> = {}): ClaimedToolCall {
|
|
204
514
|
const unclaimed = this.turnToolCalls.filter((call) => !this.claimedToolCallIds.has(call.id));
|
|
205
515
|
const byName = unclaimed.filter((call) => call.toolName === toolName);
|
|
206
516
|
const exact = byName.filter((call) => sameArgs(call.arguments, args));
|
|
517
|
+
// Ids whose RESULT already sits queued or parked. A handler can fire after
|
|
518
|
+
// the message boundary wiped the per-message records — by then Pi has
|
|
519
|
+
// executed its call and only these query-scoped stores still know it
|
|
520
|
+
// (vstack#1469: the boundary reap used to make such a handler error out
|
|
521
|
+
// and the model re-run an already-executed side-effectful call). An
|
|
522
|
+
// exact-args match here outranks the live sole-same-name fallback below,
|
|
523
|
+
// so a late handler can never steal a live sibling's id while its own
|
|
524
|
+
// result waits; without an exact match it is only a last resort.
|
|
525
|
+
const resultBacked = [...new Set([...this.pendingResults.keys(), ...this.reapedResults.keys()])]
|
|
526
|
+
.filter((id) => !this.claimedToolCallIds.has(id) && this.queryToolNames.get(id) === toolName);
|
|
527
|
+
const backedExact = resultBacked.filter((id) => sameArgs(this.queryToolArgs.get(id), args));
|
|
528
|
+
const claimBacked = (id: string, viaExact: boolean): ClaimedToolCall => {
|
|
529
|
+
this.claimedToolCallIds.add(id);
|
|
530
|
+
return {
|
|
531
|
+
toolCallId: id,
|
|
532
|
+
match: viaExact ? "tool-args" : "tool-name",
|
|
533
|
+
ambiguous: viaExact && backedExact.length > 1,
|
|
534
|
+
available: unclaimed.length,
|
|
535
|
+
...(!viaExact && hasRecordedArgs(this.queryToolArgs.get(id)) ? { argsMismatch: true } : {}),
|
|
536
|
+
};
|
|
537
|
+
};
|
|
207
538
|
let chosen: TurnToolCallRecord | undefined;
|
|
208
539
|
let match: ClaimedToolCall["match"] = "none";
|
|
209
540
|
let ambiguous = false;
|
|
210
541
|
|
|
542
|
+
let argsMismatch = false;
|
|
211
543
|
if (exact.length > 0) {
|
|
212
544
|
chosen = exact[0];
|
|
213
545
|
match = "tool-args";
|
|
214
546
|
ambiguous = exact.length > 1;
|
|
215
|
-
} else if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
//
|
|
547
|
+
} else if (backedExact.length > 0) {
|
|
548
|
+
return claimBacked(backedExact[0], true);
|
|
549
|
+
} else if (byName.length === 1) {
|
|
550
|
+
// A single unclaimed call of this tool type is the only call this
|
|
551
|
+
// handler can possibly belong to, so claim it even when the recorded
|
|
552
|
+
// arguments differ. Two known benign sources of divergence:
|
|
553
|
+
// - the SDK can invoke the handler after content_block_start but
|
|
554
|
+
// before input_json_delta/content_block_stop finalizes arguments,
|
|
555
|
+
// so the record still holds a partial parse;
|
|
556
|
+
// - the handler receives the MCP server's schema-VALIDATED copy of
|
|
557
|
+
// the input (zod may strip unknown keys or apply defaults) while
|
|
558
|
+
// the record holds the raw streamed input.
|
|
559
|
+
// Refusing here stranded the call outright: the handler errored into
|
|
560
|
+
// the child while pi's real result sat queued forever (diag log
|
|
561
|
+
// 2026-07-28, `edit` with argKeys [edits, path] on both sides). A
|
|
562
|
+
// same-type sole-candidate claim is strictly safer than that. With
|
|
563
|
+
// SEVERAL same-name candidates and no exact match we still refuse —
|
|
564
|
+
// cross-pairing two live calls is the one outcome worse than failing.
|
|
220
565
|
chosen = byName[0];
|
|
221
566
|
match = "tool-name";
|
|
567
|
+
argsMismatch = hasRecordedArgs(byName[0].arguments);
|
|
222
568
|
}
|
|
223
569
|
|
|
570
|
+
// Last resort: nothing live matched and no exact result-backed pairing —
|
|
571
|
+
// a sole result-backed same-name id is still this handler's only possible
|
|
572
|
+
// owner, same reasoning as the live sole-candidate fallback above.
|
|
573
|
+
if (!chosen && resultBacked.length === 1) return claimBacked(resultBacked[0], false);
|
|
574
|
+
|
|
224
575
|
if (!chosen) return { match: "none", ambiguous: false, available: unclaimed.length };
|
|
225
576
|
this.claimedToolCallIds.add(chosen.id);
|
|
226
|
-
return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length };
|
|
577
|
+
return { toolCallId: chosen.id, match, ambiguous, available: unclaimed.length, ...(argsMismatch ? { argsMismatch } : {}) };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Move results still queued in `pendingResults` into the parked store and
|
|
582
|
+
* report what moved.
|
|
583
|
+
*
|
|
584
|
+
* Called at a child MESSAGE boundary (message_start / the no-stream-events
|
|
585
|
+
* assistant fallback). Left in pendingResults, each entry poisons every later
|
|
586
|
+
* mismatch report for the whole query (queued>0 with 0/0 counters and no tool
|
|
587
|
+
* names) and forces a session rebuild per turn. But the boundary does NOT
|
|
588
|
+
* prove the handler gave up — the SDK staggers handler invocations, and the
|
|
589
|
+
* 2026-08-17 deadlock session (vstack#1469) had three of five parallel
|
|
590
|
+
* handlers fire after this reap destroyed their results. So the reap parks
|
|
591
|
+
* instead of dropping: reports stay clean, and a late handler still gets its
|
|
592
|
+
* real result through takeQueuedOrParkedResult.
|
|
593
|
+
*/
|
|
594
|
+
takeStaleQueuedResults(): Array<{ id: string; toolName: string }> {
|
|
595
|
+
if (this.pendingResults.size === 0) return [];
|
|
596
|
+
const stale = [...this.pendingResults.entries()].map(([id, result]) => {
|
|
597
|
+
this.reapedResults.set(id, result);
|
|
598
|
+
return { id, toolName: this.queryToolNames.get(id) ?? "unknown" };
|
|
599
|
+
});
|
|
600
|
+
this.pendingResults.clear();
|
|
601
|
+
return stale;
|
|
227
602
|
}
|
|
228
603
|
|
|
229
604
|
markToolResultDelivered(id: string | undefined): void {
|
|
@@ -252,9 +627,21 @@ export class QueryContext {
|
|
|
252
627
|
const unresolvedIds = expectedIds.filter((id) => !this.resolvedToolResultIds.has(id));
|
|
253
628
|
const affectedIds = new Set([...missingDeliveredIds, ...unresolvedIds, ...waitingIds, ...queuedIds, ...unmatchedResultIds]);
|
|
254
629
|
const counts = new Map<string, number>();
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
630
|
+
if (affectedIds.size > 0) {
|
|
631
|
+
// Name the affected ids from the query-scoped map, not just this
|
|
632
|
+
// message's records: a queued straggler from an earlier child message is
|
|
633
|
+
// exactly the case a mismatch report exists for, and this message's
|
|
634
|
+
// turnToolCalls no longer knows it.
|
|
635
|
+
for (const id of affectedIds) {
|
|
636
|
+
const name = this.queryToolNames.get(id)
|
|
637
|
+
?? this.turnToolCalls.find((call) => call.id === id)?.toolName
|
|
638
|
+
?? "unknown";
|
|
639
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
for (const call of this.turnToolCalls) {
|
|
643
|
+
counts.set(call.toolName, (counts.get(call.toolName) ?? 0) + 1);
|
|
644
|
+
}
|
|
258
645
|
}
|
|
259
646
|
return {
|
|
260
647
|
expectedIds,
|
|
@@ -278,29 +665,107 @@ export class QueryContext {
|
|
|
278
665
|
}
|
|
279
666
|
}
|
|
280
667
|
|
|
281
|
-
|
|
282
|
-
|
|
668
|
+
interface QueryLaneState {
|
|
669
|
+
current: QueryContext;
|
|
670
|
+
stack: QueryContext[];
|
|
671
|
+
}
|
|
283
672
|
|
|
284
|
-
|
|
673
|
+
interface QueryLaneStoreV1 {
|
|
674
|
+
defaultLane: QueryLaneState;
|
|
675
|
+
sessionLanes: Map<string, QueryLaneState>;
|
|
676
|
+
}
|
|
285
677
|
|
|
286
|
-
|
|
678
|
+
const QUERY_LANES_SYMBOL = Symbol.for("vstack.pi.claude-bridge.query-lanes.v1");
|
|
679
|
+
|
|
680
|
+
function queryLaneStore(): QueryLaneStoreV1 {
|
|
681
|
+
const host = globalThis as Record<symbol, unknown>;
|
|
682
|
+
let store = host[QUERY_LANES_SYMBOL] as QueryLaneStoreV1 | undefined;
|
|
683
|
+
if (!store) {
|
|
684
|
+
store = {
|
|
685
|
+
defaultLane: { current: new QueryContext(), stack: [] },
|
|
686
|
+
sessionLanes: new Map(),
|
|
687
|
+
};
|
|
688
|
+
host[QUERY_LANES_SYMBOL] = store;
|
|
689
|
+
}
|
|
690
|
+
return store;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function lane(): QueryLaneState {
|
|
694
|
+
const store = queryLaneStore();
|
|
695
|
+
const sessionId = currentRequestLaneId();
|
|
696
|
+
if (sessionId === undefined) return store.defaultLane;
|
|
697
|
+
let state = store.sessionLanes.get(sessionId);
|
|
698
|
+
if (!state) {
|
|
699
|
+
state = { current: new QueryContext(), stack: [] };
|
|
700
|
+
store.sessionLanes.set(sessionId, state);
|
|
701
|
+
}
|
|
702
|
+
return state;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function ctx(): QueryContext { return lane().current; }
|
|
706
|
+
|
|
707
|
+
export function stackDepth(): number { return lane().stack.length; }
|
|
287
708
|
|
|
288
709
|
export function pushContext(): void {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
710
|
+
const state = lane();
|
|
711
|
+
if (!state.current.activeQuery) throw new Error("pushContext() called with no active query");
|
|
712
|
+
state.stack.push(state.current);
|
|
713
|
+
state.current = new QueryContext();
|
|
292
714
|
}
|
|
293
715
|
|
|
294
716
|
export function popContext(): void {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
parent.
|
|
298
|
-
|
|
717
|
+
const state = lane();
|
|
718
|
+
if (state.stack.length === 0) throw new Error("popContext() called with empty stack");
|
|
719
|
+
const parent = state.stack[state.stack.length - 1];
|
|
720
|
+
parent.deferredUserMessages.push(...state.current.deferredUserMessages);
|
|
721
|
+
state.current = state.stack.pop()!;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Pop the context that belongs to ONE specific query, wherever it sits.
|
|
725
|
+
*
|
|
726
|
+
* The common case is `target === ctx()` and this is exactly popContext(). The
|
|
727
|
+
* reason this exists: a reentrant parent query can end ABNORMALLY (abort, child
|
|
728
|
+
* process death) while its own subagent's context is still pushed above it. A
|
|
729
|
+
* bare popContext() there would discard the live grandchild's context and
|
|
730
|
+
* merge the wrong deferred messages. Instead, splice `target` out of the stack
|
|
731
|
+
* and hand its deferred messages to its own parent (the element below it), so
|
|
732
|
+
* the still-live contexts above keep their positions and later pops restore
|
|
733
|
+
* the correct lineage. Returns false when `target` is nowhere in the state —
|
|
734
|
+
* already popped — so callers can treat that as "someone else tore this down". */
|
|
735
|
+
export function popContextFor(target: QueryContext): boolean {
|
|
736
|
+
const state = lane();
|
|
737
|
+
if (state.current === target) {
|
|
738
|
+
popContext();
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
const idx = state.stack.indexOf(target);
|
|
742
|
+
if (idx < 0) return false;
|
|
743
|
+
const parent = idx > 0 ? state.stack[idx - 1] : undefined;
|
|
744
|
+
parent?.deferredUserMessages.push(...target.deferredUserMessages);
|
|
745
|
+
state.stack.splice(idx, 1);
|
|
746
|
+
return true;
|
|
299
747
|
}
|
|
300
748
|
|
|
301
|
-
// Test-only: drop
|
|
302
|
-
// Not called from production.
|
|
749
|
+
// Test-only: drop every lane so test files can start clean.
|
|
303
750
|
export function resetStack(): void {
|
|
304
|
-
|
|
305
|
-
|
|
751
|
+
clearQueryLanes();
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function deleteQueryLane(sessionId: string | undefined): void {
|
|
755
|
+
const store = queryLaneStore();
|
|
756
|
+
if (sessionId === undefined) {
|
|
757
|
+
store.defaultLane.current = new QueryContext();
|
|
758
|
+
store.defaultLane.stack.length = 0;
|
|
759
|
+
} else store.sessionLanes.delete(sessionId);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
export function clearQueryLanes(): void {
|
|
763
|
+
const store = queryLaneStore();
|
|
764
|
+
store.sessionLanes.clear();
|
|
765
|
+
store.defaultLane.current = new QueryContext();
|
|
766
|
+
store.defaultLane.stack.length = 0;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export function __testQueryLaneCount(): number {
|
|
770
|
+
return queryLaneStore().sessionLanes.size;
|
|
306
771
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// End-of-query teardown, extracted from streamClaudeAgentSdk's .finally so it
|
|
2
|
+
// operates on the ONE context captured at query start — never the live ctx().
|
|
3
|
+
// The two only differ while a reentrant (subagent) context is pushed, which is
|
|
4
|
+
// exactly when a parent query ending abnormally (abort, child process death)
|
|
5
|
+
// used to run this against the subagent's state: the parent's drain, audit
|
|
6
|
+
// flush, and activeQuery clear were skipped, leaking its pending MCP handlers.
|
|
7
|
+
|
|
8
|
+
import { reportToolResultMismatch } from "./bridge-state.js";
|
|
9
|
+
import { flushConnectorCallAudit } from "./connector-audit.js";
|
|
10
|
+
import { debug } from "./debug.js";
|
|
11
|
+
import { drainPendingToolCalls, popContextFor, type QueryContext, type ToolCallDrainCause } from "./query-state.js";
|
|
12
|
+
|
|
13
|
+
/** Tear down `queryCtx` after its SDK query settled. No-ops when the query is
|
|
14
|
+
* no longer the context's active one (a continuation replaced it, or teardown
|
|
15
|
+
* already ran). Returns true when teardown actually ran. */
|
|
16
|
+
export function teardownQuery(
|
|
17
|
+
queryCtx: QueryContext,
|
|
18
|
+
sdkQuery: unknown,
|
|
19
|
+
cause: ToolCallDrainCause,
|
|
20
|
+
cwd: string,
|
|
21
|
+
isReentrant: boolean,
|
|
22
|
+
): boolean {
|
|
23
|
+
if (queryCtx.activeQuery !== sdkQuery) return false;
|
|
24
|
+
reportToolResultMismatch(queryCtx, "query teardown", cwd, { forceRotate: cause !== "query-end" });
|
|
25
|
+
// Drain pending handlers for this query as errors naming the cause —
|
|
26
|
+
// their results are never coming.
|
|
27
|
+
const drained = drainPendingToolCalls(queryCtx, cause);
|
|
28
|
+
if (drained > 0) debug(`provider: query teardown drained ${drained} waiting MCP handler(s) as errors (cause=${cause})`);
|
|
29
|
+
queryCtx.pendingResults.clear();
|
|
30
|
+
|
|
31
|
+
// Same idea for calls the CHILD owned: one whose result never came back
|
|
32
|
+
// is recorded as unobserved rather than left silent, so an answer in the
|
|
33
|
+
// transcript is never the only evidence a connector call was made.
|
|
34
|
+
const unobserved = flushConnectorCallAudit(queryCtx, cause);
|
|
35
|
+
if (unobserved > 0) debug(`provider: query teardown recorded ${unobserved} connector call(s) with no observed result (cause=${cause})`);
|
|
36
|
+
|
|
37
|
+
if (isReentrant) {
|
|
38
|
+
// Merges deferred messages and restores/repairs the stack. popContextFor
|
|
39
|
+
// (not popContext): a live subagent context may sit above this one.
|
|
40
|
+
if (!popContextFor(queryCtx)) debug("provider: query teardown found context already popped; skipping pop");
|
|
41
|
+
} else {
|
|
42
|
+
queryCtx.activeQuery = null;
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
}
|