@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/assistant-stream.ts
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
1
|
import { calculateCost, type AssistantMessage, type Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { appendIntegrityEntry, safeNotify } from "./bridge-state.js";
|
|
4
|
+
import { connectorResultByteSize, recordConnectorCallResult } from "./connector-audit.js";
|
|
5
|
+
import { isChildExecutedTool } from "./connectors.js";
|
|
6
|
+
import { debug, diagDump } from "./debug.js";
|
|
7
|
+
import { ctx, failStrandedToolCall, type QueryContext } from "./query-state.js";
|
|
5
8
|
import { mapToolArgs, mapToolName } from "./tool-mapping.js";
|
|
6
9
|
|
|
7
10
|
// --- Usage helpers ---
|
|
8
11
|
|
|
9
|
-
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function updateUsage(output: AssistantMessage, usage: Record<string, number | undefined>, model: Model<any>, c: QueryContext): void {
|
|
13
|
+
// Anthropic reports per-message counters and RE-reports them as the message
|
|
14
|
+
// grows, so the in-flight message's figures are replaced, not added. What is
|
|
15
|
+
// added is every child message already finished in this Pi turn — see
|
|
16
|
+
// `turnUsageCarry` in query-state.ts for why a turn can span several.
|
|
17
|
+
const current = c.currentMessageUsage;
|
|
18
|
+
const carry = c.turnUsageCarry;
|
|
19
|
+
if (usage.input_tokens != null) current.input = usage.input_tokens;
|
|
20
|
+
if (usage.output_tokens != null) current.output = usage.output_tokens;
|
|
21
|
+
if (usage.cache_read_input_tokens != null) current.cacheRead = usage.cache_read_input_tokens;
|
|
22
|
+
if (usage.cache_creation_input_tokens != null) current.cacheWrite = usage.cache_creation_input_tokens;
|
|
23
|
+
output.usage.input = carry.input + current.input;
|
|
24
|
+
output.usage.output = carry.output + current.output;
|
|
25
|
+
output.usage.cacheRead = carry.cacheRead + current.cacheRead;
|
|
26
|
+
output.usage.cacheWrite = carry.cacheWrite + current.cacheWrite;
|
|
14
27
|
output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
15
28
|
calculateCost(model, output.usage);
|
|
16
29
|
const promptTokens = output.usage.input + output.usage.cacheRead + output.usage.cacheWrite;
|
|
@@ -33,39 +46,265 @@ export function parsePartialJson(input: string, fallback: Record<string, unknown
|
|
|
33
46
|
try { return JSON.parse(input); } catch { return fallback; }
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
49
|
+
// Both take the query context explicitly (defaulting to the live one) so the
|
|
50
|
+
// completion/teardown closures in index.ts can finalize the stream of the query
|
|
51
|
+
// they were created for — under reentrancy the live ctx() is the subagent's.
|
|
52
|
+
export function ensureTurnStarted(c: QueryContext = ctx()): void {
|
|
53
|
+
if (!c.turnStarted && c.currentPiStream && c.turnOutput) {
|
|
54
|
+
c.currentPiStream.push({ type: "start", partial: c.turnOutput });
|
|
55
|
+
c.turnStarted = true;
|
|
40
56
|
}
|
|
41
57
|
}
|
|
42
58
|
|
|
43
|
-
export function finalizeCurrentStream(stopReason?: string): void {
|
|
44
|
-
if (!
|
|
45
|
-
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason:
|
|
46
|
-
if (!
|
|
59
|
+
export function finalizeCurrentStream(stopReason?: string, c: QueryContext = ctx()): void {
|
|
60
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
61
|
+
debug(`provider: finalizeCurrentStream called, stopReason=${stopReason}, turnOutput=${JSON.stringify({stopReason: c.turnOutput.stopReason, error: c.turnOutput.errorMessage})}`);
|
|
62
|
+
if (!c.turnStarted) ensureTurnStarted(c);
|
|
47
63
|
const reason = stopReason === "length" ? "length" : "stop";
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
c.currentPiStream.push({ type: "done", reason, message: c.turnOutput });
|
|
65
|
+
c.currentPiStream.end();
|
|
66
|
+
c.currentPiStream = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- Tool-use turn end: deferred to the stream's terminal events ---
|
|
70
|
+
//
|
|
71
|
+
// The Claude Code CLI dispatches MCP tool calls (and the SDK yields the
|
|
72
|
+
// completed assistant message) BEFORE the stream's message_delta arrives — and
|
|
73
|
+
// message_delta is what carries the message's REAL output-token count (measured:
|
|
74
|
+
// handler invoked ~45ms before message_delta on every tool-use turn). Ending
|
|
75
|
+
// the pi stream at either of those early signals therefore froze usage at the
|
|
76
|
+
// message_start placeholder values, which is why pi sessions recorded 1–7
|
|
77
|
+
// output tokens per tool-use turn while the final text turn recorded hundreds
|
|
78
|
+
// (2026-07-28 token test, both bridge panes).
|
|
79
|
+
//
|
|
80
|
+
// So the turn now ends at message_stop, exactly like the streamed-text case,
|
|
81
|
+
// and the early signals only ARM a grace timer. The timer is the deadlock
|
|
82
|
+
// backstop for the one observed case where the terminal events never arrive
|
|
83
|
+
// (pi 0.80 steer draining): pi cannot execute tools before the stream ends, and
|
|
84
|
+
// the MCP handler cannot resolve before pi executes, so a stream that has gone
|
|
85
|
+
// silent must be ended by force — just 1.5s later instead of immediately.
|
|
86
|
+
|
|
87
|
+
const TOOL_USE_END_GRACE_MS = 1500;
|
|
88
|
+
|
|
89
|
+
/** End the current pi stream as a tool_use turn boundary. Safe to call when the
|
|
90
|
+
* turn already ended (no-op). Every end path funnels here, so this is where
|
|
91
|
+
* two invariants are enforced by construction (vstack#1469): a block still
|
|
92
|
+
* carrying partialJson never ships — Pi executes the done message's content,
|
|
93
|
+
* and truncated arguments must never execute — and every call that DOES ship
|
|
94
|
+
* is stamped forwarded so no lagging replay can dispatch it again. */
|
|
95
|
+
export function endToolUseTurn(c: QueryContext): void {
|
|
96
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
97
|
+
cancelScheduledToolUseEnd(c);
|
|
98
|
+
const partial = (c.turnOutput.content as Array<any>).filter((b) => b?.type === "toolCall" && "partialJson" in b);
|
|
99
|
+
if (partial.length > 0) {
|
|
100
|
+
const calls = partial.map((b) => ({ id: b.id, name: b.name }));
|
|
101
|
+
debug(`endToolUseTurn: pruning ${partial.length} still-partial tool call(s) — truncated arguments never execute:`, calls.map((entry) => `${entry.name} [${entry.id}]`).join(", "));
|
|
102
|
+
diagDump("partial_tool_calls_pruned", { count: partial.length, calls });
|
|
103
|
+
appendIntegrityEntry("partial_tool_calls_pruned", { count: partial.length, calls });
|
|
104
|
+
c.turnOutput.content = (c.turnOutput.content as Array<any>).filter((b) => !(b?.type === "toolCall" && "partialJson" in b));
|
|
105
|
+
}
|
|
106
|
+
// Every tool call Pi is about to execute from this turn is owed a result and
|
|
107
|
+
// must never be dispatched again: a lagging stream replays the same tool_use
|
|
108
|
+
// into the NEXT turn, whose per-message dedup cannot see it (vstack#1469).
|
|
109
|
+
for (const block of c.turnOutput.content as Array<{ type?: string; id?: unknown }>) {
|
|
110
|
+
if (block?.type === "toolCall" && typeof block.id === "string") c.forwardedToolCallIds.add(block.id);
|
|
111
|
+
}
|
|
112
|
+
c.turnOutput.stopReason = "toolUse";
|
|
113
|
+
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
114
|
+
c.currentPiStream.end();
|
|
115
|
+
c.currentPiStream = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function cancelScheduledToolUseEnd(c: QueryContext): void {
|
|
119
|
+
if (!c.scheduledToolUseEnd) return;
|
|
120
|
+
clearTimeout(c.scheduledToolUseEnd.timer);
|
|
121
|
+
c.scheduledToolUseEnd = null;
|
|
51
122
|
}
|
|
52
123
|
|
|
53
|
-
|
|
54
|
-
|
|
124
|
+
/**
|
|
125
|
+
* Arm the grace timer that force-ends the current tool_use turn if the stream's
|
|
126
|
+
* terminal events never arrive. First arming per stream wins; message_stop (or
|
|
127
|
+
* resetTurnState) disarms it. `action` runs only if the SAME stream is still
|
|
128
|
+
* current when the grace elapses — a turn that ended normally makes it a no-op.
|
|
129
|
+
*/
|
|
130
|
+
export function scheduleToolUseTurnEnd(c: QueryContext, action: () => void, source: string): void {
|
|
131
|
+
if (!c.currentPiStream || !c.turnOutput) return;
|
|
132
|
+
if (c.scheduledToolUseEnd?.stream === c.currentPiStream) return;
|
|
133
|
+
cancelScheduledToolUseEnd(c);
|
|
134
|
+
const stream = c.currentPiStream;
|
|
135
|
+
const timer = setTimeout(() => {
|
|
136
|
+
if (c.currentPiStream !== stream) return;
|
|
137
|
+
debug(`scheduleToolUseTurnEnd: no terminal stream event within ${TOOL_USE_END_GRACE_MS}ms (${source}) — force-ending tool_use turn`);
|
|
138
|
+
c.scheduledToolUseEnd = null;
|
|
139
|
+
action();
|
|
140
|
+
}, TOOL_USE_END_GRACE_MS);
|
|
141
|
+
timer.unref?.();
|
|
142
|
+
c.scheduledToolUseEnd = { stream, timer };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Park queued tool results whose handler has not fired by a child message
|
|
147
|
+
* boundary, and say so everywhere it matters. The boundary is where stale
|
|
148
|
+
* entries would start poisoning mismatch reports — but it does NOT prove the
|
|
149
|
+
* handler gave up: the SDK staggers handler invocations, and in the 2026-08-17
|
|
150
|
+
* deadlock session three of five parallel handlers fired after this point
|
|
151
|
+
* (vstack#1469). Parked results stay consumable through
|
|
152
|
+
* takeQueuedOrParkedResult; one that is never consumed belongs to a call the
|
|
153
|
+
* SDK abandoned client-side (permission denial), which is exactly what the
|
|
154
|
+
* notice describes.
|
|
155
|
+
*/
|
|
156
|
+
export function reapStaleQueuedResults(c: QueryContext): void {
|
|
157
|
+
const stale = c.takeStaleQueuedResults();
|
|
158
|
+
if (stale.length === 0) return;
|
|
159
|
+
const names = stale.map((entry) => entry.toolName);
|
|
160
|
+
debug(`reapStaleQueuedResults: parked ${stale.length} early tool result(s) awaiting a late handler:`, names.join(", "));
|
|
161
|
+
diagDump("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
162
|
+
appendIntegrityEntry("stale_queued_tool_results_parked", { count: stale.length, stale });
|
|
163
|
+
safeNotify(
|
|
164
|
+
`Claude bridge: parked ${stale.length} early tool result(s) whose handler has not arrived (${names.slice(0, 6).join(", ")}${names.length > 6 ? ", …" : ""}). ` +
|
|
165
|
+
`A late handler can still consume them.`,
|
|
166
|
+
"warning",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function updateTurnOutputModel(modelId: unknown, c: QueryContext = ctx()): void {
|
|
55
171
|
if (typeof modelId !== "string" || !modelId || !c.turnOutput) return;
|
|
56
172
|
if (c.turnOutput.model === modelId) return;
|
|
57
173
|
debug(`provider: active Claude model changed ${c.turnOutput.model} -> ${modelId}`);
|
|
58
174
|
c.turnOutput.model = modelId;
|
|
59
175
|
}
|
|
60
176
|
|
|
177
|
+
export const FINALIZE_MAX_REARMS = 3;
|
|
178
|
+
|
|
179
|
+
/** Force-finalizes the current pi turn as a tool_use boundary when its terminal
|
|
180
|
+
* stream events never arrived (the grace-timer action armed by an MCP handler
|
|
181
|
+
* invocation — see scheduleToolUseTurnEnd).
|
|
182
|
+
*
|
|
183
|
+
* Observed with Claude Code under pi 0.80's steer draining (tool result and
|
|
184
|
+
* drained steer arrive in one provider call): the NEXT tool turn's tool_use
|
|
185
|
+
* streams in, the SDK invokes the MCP handler — and neither terminal event
|
|
186
|
+
* ever arrives. The invocation itself proves the assistant turn is committed,
|
|
187
|
+
* so end the pi stream like the `message_stop` path — with this handler's
|
|
188
|
+
* schema-validated arguments, never a partial parse — after settling every
|
|
189
|
+
* sibling whose handler has fired and giving a merely-lagging stream up to
|
|
190
|
+
* FINALIZE_MAX_REARMS extra grace periods for the rest (vstack#1469).
|
|
191
|
+
*
|
|
192
|
+
* The dead-stream guard is a backstop: the grace timer's own stream-identity
|
|
193
|
+
* check means this normally never runs after the turn ended. The primary
|
|
194
|
+
* recovery for a handler whose call missed its turn is the generation-guarded
|
|
195
|
+
* drainStrandedToolCalls at the next provider callback. */
|
|
196
|
+
export function finalizeToolUseTurnFromMcpInvocation(
|
|
197
|
+
queryCtx: QueryContext,
|
|
198
|
+
toolCallId: string,
|
|
199
|
+
toolName: string,
|
|
200
|
+
mappedArgs: Record<string, unknown>,
|
|
201
|
+
rearmCount = 0,
|
|
202
|
+
): void {
|
|
203
|
+
if (!queryCtx.currentPiStream || !queryCtx.turnOutput) {
|
|
204
|
+
// The turn ended without this call. An unforwarded handler here can never
|
|
205
|
+
// be answered — the yield that could have replayed its call was consumed
|
|
206
|
+
// against a null stream, and the dead-mark below suppresses any replay
|
|
207
|
+
// that has not happened yet. Failing it now is what turns the observed
|
|
208
|
+
// multi-hour session deadlock into one retryable error (vstack#1469).
|
|
209
|
+
if (failStrandedToolCall(queryCtx, toolCallId)) {
|
|
210
|
+
debug(`mcp handler: ${toolName} [${toolCallId}] stranded — turn ended before its call reached Pi; resolved with error`);
|
|
211
|
+
diagDump("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
|
|
212
|
+
appendIntegrityEntry("tool_handler_stranded", { toolCallId, toolName, site: "finalize-no-stream" });
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
let idx = queryCtx.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === toolCallId);
|
|
217
|
+
if (idx >= 0) {
|
|
218
|
+
const block = queryCtx.turnBlocks[idx] as any;
|
|
219
|
+
if ("partialJson" in block) {
|
|
220
|
+
// Stream ended before content_block_stop. The SDK invoked this handler
|
|
221
|
+
// with the COMPLETE schema-validated input, so the handler's copy is
|
|
222
|
+
// authoritative — the streamed partial JSON is by definition behind it.
|
|
223
|
+
// Settling from the partial is what forwarded `{}`-argument calls that
|
|
224
|
+
// Pi then executed and errored (vstack#1469), exactly the divergence
|
|
225
|
+
// the synthesize branch below never had.
|
|
226
|
+
block.arguments = mappedArgs;
|
|
227
|
+
queryCtx.updateToolCallArgs(block.id, block.arguments);
|
|
228
|
+
delete block.partialJson;
|
|
229
|
+
delete block.index;
|
|
230
|
+
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
231
|
+
}
|
|
232
|
+
} else if (queryCtx.forwardedToolCallIds.has(toolCallId) || queryCtx.deadToolCallIds.has(toolCallId)) {
|
|
233
|
+
// Pi already executed this call in an earlier turn (its result arrives or
|
|
234
|
+
// sits parked), or the handler was already failed — either way a second
|
|
235
|
+
// dispatch is the one outcome worse than waiting. Do NOT return: this
|
|
236
|
+
// firing consumed the stream's only grace timer, so the current turn's own
|
|
237
|
+
// blocks must still be settled and the turn still ended below, or a turn
|
|
238
|
+
// that loses its terminal events afterwards has no backstop left and Pi
|
|
239
|
+
// sits busy until manual abort.
|
|
240
|
+
debug(`mcp handler: ${toolName} [${toolCallId}] already ${queryCtx.forwardedToolCallIds.has(toolCallId) ? "forwarded" : "dead"} — not re-emitting`);
|
|
241
|
+
} else {
|
|
242
|
+
// The invocation can arrive before the tool_use is streamed at all
|
|
243
|
+
// (observed after a tool-result+steer provider call reset the turn):
|
|
244
|
+
// synthesize the toolCall from the claim — the MCP call carries the
|
|
245
|
+
// authoritative id, name, and arguments.
|
|
246
|
+
queryCtx.turnBlocks.push({ type: "toolCall", id: toolCallId, name: toolName, arguments: mappedArgs });
|
|
247
|
+
idx = queryCtx.turnBlocks.length - 1;
|
|
248
|
+
const block = queryCtx.turnBlocks[idx] as any;
|
|
249
|
+
queryCtx.currentPiStream.push({ type: "toolcall_start", contentIndex: idx, partial: queryCtx.turnOutput });
|
|
250
|
+
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: idx, toolCall: block, partial: queryCtx.turnOutput });
|
|
251
|
+
}
|
|
252
|
+
// Settle every OTHER still-partial block whose handler has fired: each
|
|
253
|
+
// waiting handler carries the authoritative args for its own call.
|
|
254
|
+
for (let i = 0; i < queryCtx.turnBlocks.length; i++) {
|
|
255
|
+
const sibling = queryCtx.turnBlocks[i] as any;
|
|
256
|
+
if (sibling.type !== "toolCall" || !("partialJson" in sibling)) continue;
|
|
257
|
+
const waiting = queryCtx.pendingToolCalls.get(sibling.id);
|
|
258
|
+
if (!waiting) continue;
|
|
259
|
+
sibling.arguments = waiting.args;
|
|
260
|
+
queryCtx.updateToolCallArgs(sibling.id, sibling.arguments);
|
|
261
|
+
delete sibling.partialJson;
|
|
262
|
+
delete sibling.index;
|
|
263
|
+
queryCtx.currentPiStream.push({ type: "toolcall_end", contentIndex: i, toolCall: sibling, partial: queryCtx.turnOutput });
|
|
264
|
+
}
|
|
265
|
+
// A block still partial here has NO fired handler: its complete arguments
|
|
266
|
+
// exist nowhere on this side of the boundary yet. Ending the turn now would
|
|
267
|
+
// hand Pi truncated arguments to execute — a truncated bash command is not a
|
|
268
|
+
// hypothetical hazard — so give the lagging stream more grace first.
|
|
269
|
+
const unsettled = queryCtx.turnBlocks.filter((b: any) => b.type === "toolCall" && "partialJson" in b);
|
|
270
|
+
if (unsettled.length > 0 && rearmCount < FINALIZE_MAX_REARMS) {
|
|
271
|
+
debug(`mcp handler: ${unsettled.length} sibling tool call(s) still streaming — re-arming grace (${rearmCount + 1}/${FINALIZE_MAX_REARMS})`);
|
|
272
|
+
scheduleToolUseTurnEnd(
|
|
273
|
+
queryCtx,
|
|
274
|
+
() => finalizeToolUseTurnFromMcpInvocation(queryCtx, toolCallId, toolName, mappedArgs, rearmCount + 1),
|
|
275
|
+
`finalize-rearm:${toolName}`,
|
|
276
|
+
);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// Grace exhausted with blocks still partial: endToolUseTurn prunes them —
|
|
280
|
+
// truncated arguments never execute; each pruned call replays complete on
|
|
281
|
+
// the SDK's assistant yield in a later turn, or its handler eventually
|
|
282
|
+
// fires and the stranded drain fails it with a retryable error. When the
|
|
283
|
+
// turn holds NOTHING executable (this call suppressed as forwarded/dead and
|
|
284
|
+
// no settled siblings), leave the stream to its own terminal events — an
|
|
285
|
+
// empty tool_use turn would make Pi execute nothing and record an empty
|
|
286
|
+
// assistant message for it.
|
|
287
|
+
const executable = queryCtx.turnBlocks.some((b: any) => b.type === "toolCall" && !("partialJson" in b));
|
|
288
|
+
if (!executable) {
|
|
289
|
+
debug(`mcp handler: nothing executable in this turn after suppression — leaving the stream to its own terminal events (${toolName} [${toolCallId}])`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
queryCtx.turnSawToolCall = true;
|
|
293
|
+
debug(`mcp handler: finalizing tool_use turn from MCP invocation [${toolCallId}] (${toolName}) — terminal stream events never arrived`);
|
|
294
|
+
endToolUseTurn(queryCtx);
|
|
295
|
+
}
|
|
296
|
+
|
|
61
297
|
/** Maps Anthropic stream events to pi stream events (text, thinking, toolcall).
|
|
62
298
|
* On message_stop with tool_use: ends currentPiStream so pi can execute the tool. */
|
|
63
299
|
export function processStreamEvent(
|
|
64
300
|
message: SDKMessage,
|
|
65
301
|
customToolNameToPi: Map<string, string>,
|
|
66
302
|
model: Model<any>,
|
|
303
|
+
// The consuming query's CAPTURED context, never the live ctx() (see the C4
|
|
304
|
+
// note in consumeQuery): a reentrant subagent can be pushed while the parent
|
|
305
|
+
// iterator is suspended, and live-state reads would hit the wrong query.
|
|
306
|
+
c: QueryContext = ctx(),
|
|
67
307
|
): void {
|
|
68
|
-
const c = ctx();
|
|
69
308
|
if (!c.currentPiStream || !c.turnOutput) return;
|
|
70
309
|
const event = (message as SDKMessage & { event: any }).event;
|
|
71
310
|
if (event?.type === "ping") return;
|
|
@@ -75,15 +314,36 @@ export function processStreamEvent(
|
|
|
75
314
|
}
|
|
76
315
|
|
|
77
316
|
if (event?.type === "message_start") {
|
|
317
|
+
// The child moving to a new message proves every result for the previous
|
|
318
|
+
// one reached it; anything still queued can never be consumed.
|
|
319
|
+
reapStaleQueuedResults(c);
|
|
78
320
|
c.resetToolTracking();
|
|
79
|
-
|
|
80
|
-
|
|
321
|
+
// A new child message begins: bank what the previous one billed before its
|
|
322
|
+
// counters are replaced. No-op on the turn's first, and no-op if this same
|
|
323
|
+
// message was already declared (see beginChildMessage).
|
|
324
|
+
c.beginChildMessage(event.message?.id);
|
|
325
|
+
updateTurnOutputModel(event.message?.model, c);
|
|
326
|
+
if (event.message?.usage) updateUsage(c.turnOutput, event.message.usage, model, c);
|
|
81
327
|
return;
|
|
82
328
|
}
|
|
83
329
|
|
|
84
330
|
if (event?.type === "content_block_start") {
|
|
85
331
|
c.turnSawStreamEvent = true;
|
|
86
|
-
ensureTurnStarted();
|
|
332
|
+
ensureTurnStarted(c);
|
|
333
|
+
// A new block owns this index from here on, so release any child-executed
|
|
334
|
+
// or suppressed claim on it. Belt-and-braces against a missed
|
|
335
|
+
// message_start: without this a stale index could silently swallow a later
|
|
336
|
+
// text block's deltas.
|
|
337
|
+
c.childExecutedStreamIndexes.delete(event.index);
|
|
338
|
+
c.suppressedStreamIndexes.delete(event.index);
|
|
339
|
+
if (event.content_block?.type === "tool_use" && isChildExecutedTool(event.content_block.name)) {
|
|
340
|
+
// The child runs this one itself — mirroring it into the Pi stream would
|
|
341
|
+
// make Pi's agent loop dispatch a tool it does not have. See
|
|
342
|
+
// isChildExecutedTool.
|
|
343
|
+
c.noteChildExecutedToolCall(event.content_block.id, event.content_block.name, event.index);
|
|
344
|
+
debug(`processStreamEvent: child-executed tool ${event.content_block.name} [${event.content_block.id}] — not mirrored as a Pi tool call`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
87
347
|
if (event.content_block?.type === "text") {
|
|
88
348
|
c.turnBlocks.push({ type: "text", text: "", index: event.index });
|
|
89
349
|
c.currentPiStream!.push({ type: "text_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
@@ -91,6 +351,24 @@ export function processStreamEvent(
|
|
|
91
351
|
c.turnBlocks.push({ type: "thinking", thinking: "", thinkingSignature: "", index: event.index });
|
|
92
352
|
c.currentPiStream!.push({ type: "thinking_start", contentIndex: c.turnBlocks.length - 1, partial: c.turnOutput });
|
|
93
353
|
} else if (event.content_block?.type === "tool_use") {
|
|
354
|
+
const streamedId: unknown = event.content_block.id;
|
|
355
|
+
if (typeof streamedId === "string" && (c.forwardedToolCallIds.has(streamedId) || c.deadToolCallIds.has(streamedId))) {
|
|
356
|
+
// A lagging stream replaying a call Pi already executed — or one whose
|
|
357
|
+
// handler was already failed as stranded — into a later turn. Mirroring
|
|
358
|
+
// it would make Pi dispatch it a second time (vstack#1469).
|
|
359
|
+
c.suppressedStreamIndexes.add(event.index);
|
|
360
|
+
debug(`processStreamEvent: tool_use ${streamedId} already ${c.forwardedToolCallIds.has(streamedId) ? "forwarded" : "dead"} — suppressing duplicate stream block`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (typeof streamedId === "string" && c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === streamedId)) {
|
|
364
|
+
// Same turn, same id: the completed-message yield beat the stream (its
|
|
365
|
+
// block is already recorded with complete arguments). A second
|
|
366
|
+
// partialJson copy would ship the id twice in one done message — and
|
|
367
|
+
// if its stop never arrives, ship it truncated (vstack#1469).
|
|
368
|
+
c.suppressedStreamIndexes.add(event.index);
|
|
369
|
+
debug(`processStreamEvent: tool_use ${streamedId} already recorded in this turn — suppressing duplicate stream block`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
94
372
|
c.turnSawToolCall = true;
|
|
95
373
|
const mappedName = mapToolName(event.content_block.name, customToolNameToPi);
|
|
96
374
|
c.recordToolCall(event.content_block.id, mappedName, {});
|
|
@@ -108,6 +386,15 @@ export function processStreamEvent(
|
|
|
108
386
|
}
|
|
109
387
|
|
|
110
388
|
if (event?.type === "content_block_delta") {
|
|
389
|
+
// A child-executed tool's argument deltas have no Pi block to land in. Skip
|
|
390
|
+
// them here rather than letting the lookup below miss, so the "unmatched"
|
|
391
|
+
// warning keeps meaning "something is wrong". Unlike that stale-event case
|
|
392
|
+
// this IS a live event for the current message, so it still counts as one.
|
|
393
|
+
// Suppressed duplicate/dead blocks skip identically.
|
|
394
|
+
if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
|
|
395
|
+
c.turnSawStreamEvent = true;
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
111
398
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
112
399
|
const block = c.turnBlocks[index];
|
|
113
400
|
if (!block) {
|
|
@@ -134,6 +421,12 @@ export function processStreamEvent(
|
|
|
134
421
|
}
|
|
135
422
|
|
|
136
423
|
if (event?.type === "content_block_stop") {
|
|
424
|
+
// Same as the delta case: the block was never mirrored, so there is nothing
|
|
425
|
+
// to seal and nothing unmatched about it.
|
|
426
|
+
if (c.childExecutedStreamIndexes.has(event.index) || c.suppressedStreamIndexes.has(event.index)) {
|
|
427
|
+
c.turnSawStreamEvent = true;
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
137
430
|
const index = c.turnBlocks.findIndex((b: any) => b.index === event.index);
|
|
138
431
|
const block = c.turnBlocks[index];
|
|
139
432
|
if (!block) {
|
|
@@ -160,19 +453,18 @@ export function processStreamEvent(
|
|
|
160
453
|
|
|
161
454
|
if (event?.type === "message_delta") {
|
|
162
455
|
c.turnOutput.stopReason = mapStopReason(event.delta?.stop_reason);
|
|
163
|
-
if (event.usage) updateUsage(c.turnOutput, event.usage, model);
|
|
456
|
+
if (event.usage) updateUsage(c.turnOutput, event.usage, model, c);
|
|
164
457
|
return;
|
|
165
458
|
}
|
|
166
459
|
|
|
167
460
|
if (event?.type === "message_stop" && c.turnSawToolCall) {
|
|
168
|
-
// Tool call complete — end this pi stream
|
|
169
|
-
// assistant
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
c
|
|
175
|
-
c.currentPiStream = null;
|
|
461
|
+
// Tool call complete — end this pi stream, disarming any grace timer the
|
|
462
|
+
// MCP-invocation or assistant-boundary path armed. This is the NORMAL end
|
|
463
|
+
// for a tool-use turn: message_delta already delivered the message's real
|
|
464
|
+
// usage just above, so the done event carries correct output tokens. The
|
|
465
|
+
// MCP handler blocks the generator until pi delivers the tool result via
|
|
466
|
+
// the next streamSimple call.
|
|
467
|
+
endToolUseTurn(c);
|
|
176
468
|
|
|
177
469
|
// Cursor is updated by the next streamSimple call (tool result delivery path)
|
|
178
470
|
// which sets cursor = context.messages.length with the post-tool-result context.
|
|
@@ -194,17 +486,40 @@ function appendMissingToolUsesFromAssistant(
|
|
|
194
486
|
assistantMsg: { content?: Array<any>; usage?: Record<string, number | undefined> },
|
|
195
487
|
model: Model<any>,
|
|
196
488
|
customToolNameToPi: Map<string, string>,
|
|
489
|
+
c: QueryContext,
|
|
197
490
|
): boolean {
|
|
198
|
-
const c = ctx();
|
|
199
491
|
if (!assistantMsg?.content) return false;
|
|
492
|
+
// With a dead stream, ids are still RECORDED (claims and result matching
|
|
493
|
+
// need them) but the content is never touched: turnBlocks IS the content
|
|
494
|
+
// array of a turnOutput that endToolUseTurn already handed Pi BY REFERENCE
|
|
495
|
+
// in its done event, so a push here appends calls into a delivered message
|
|
496
|
+
// behind Pi's back — whether Pi's dispatch enumerates before or after the
|
|
497
|
+
// push is a microtask race (vstack#1469).
|
|
498
|
+
const streamLive = Boolean(c.currentPiStream && c.turnOutput);
|
|
200
499
|
let sawToolUse = false;
|
|
201
500
|
for (const block of assistantMsg.content) {
|
|
202
501
|
if (block.type !== "tool_use") continue;
|
|
203
|
-
|
|
502
|
+
if (isChildExecutedTool(block.name)) {
|
|
503
|
+
// Not a Pi tool call, so it is NOT a turn boundary either: `sawToolUse`
|
|
504
|
+
// stays false for it and the caller keeps streaming this Pi message. The
|
|
505
|
+
// child neither blocks on Pi nor needs a result from it.
|
|
506
|
+
c.noteChildExecutedToolCall(block.id, block.name);
|
|
507
|
+
debug(`assistant message: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
204
510
|
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
511
|
+
if (existingIdx < 0 && (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
512
|
+
// Completed-message replay of a call Pi already executed in an earlier
|
|
513
|
+
// turn (or one already failed as stranded). Not a live Pi turn boundary:
|
|
514
|
+
// sawToolUse stays false for it, and no block is emitted (vstack#1469).
|
|
515
|
+
debug(`assistant message: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
sawToolUse = true;
|
|
205
519
|
const name = mapToolName(block.name, customToolNameToPi);
|
|
206
520
|
const mappedArgs = mapToolArgs(name, block.input);
|
|
207
521
|
c.recordToolCall(block.id, name, mappedArgs);
|
|
522
|
+
if (!streamLive) continue;
|
|
208
523
|
if (existingIdx >= 0) {
|
|
209
524
|
const existing = c.turnBlocks[existingIdx] as any;
|
|
210
525
|
existing.name = name;
|
|
@@ -218,7 +533,7 @@ function appendMissingToolUsesFromAssistant(
|
|
|
218
533
|
continue;
|
|
219
534
|
}
|
|
220
535
|
|
|
221
|
-
ensureTurnStarted();
|
|
536
|
+
ensureTurnStarted(c);
|
|
222
537
|
c.turnBlocks.push({
|
|
223
538
|
type: "toolCall", id: block.id,
|
|
224
539
|
name,
|
|
@@ -229,57 +544,148 @@ function appendMissingToolUsesFromAssistant(
|
|
|
229
544
|
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
230
545
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
231
546
|
}
|
|
232
|
-
|
|
547
|
+
// Only while the stream is still live: the SDK's assistant yields carry the
|
|
548
|
+
// message_start placeholder usage (output ≈ 1–7), and once the done event has
|
|
549
|
+
// delivered turnOutput to pi, overwriting its usage with those placeholders
|
|
550
|
+
// would corrupt the very figure message_delta got right.
|
|
551
|
+
if (assistantMsg.usage && c.turnOutput && c.currentPiStream) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
|
|
233
552
|
return sawToolUse;
|
|
234
553
|
}
|
|
235
554
|
|
|
236
|
-
|
|
237
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Record that a child-executed tool call came back, from the SDK's `user` message
|
|
557
|
+
* carrying the child's own `tool_result` blocks.
|
|
558
|
+
*
|
|
559
|
+
* This is the only place the bridge ever OBSERVES one of these results, and it is
|
|
560
|
+
* deliberately observation-only: the result already reached the model inside the
|
|
561
|
+
* child, which is the conversation of record for a bridge turn, so re-delivering
|
|
562
|
+
* it would double it. What the bridge could not do before this existed was say
|
|
563
|
+
* anything true about these calls at all — the Pi transcript claimed they failed
|
|
564
|
+
* and nothing anywhere claimed otherwise.
|
|
565
|
+
*
|
|
566
|
+
* The payload is NEVER logged or recorded, only its shape: a connector result is
|
|
567
|
+
* live account data (mail, messages, documents) and the bridge's debug log sits
|
|
568
|
+
* outside a host app's redaction boundary.
|
|
569
|
+
*
|
|
570
|
+
* Observing it is also what makes the call auditable: each one appends a session
|
|
571
|
+
* `CustomEntry` (connector-audit.ts) so the Pi session records that the call
|
|
572
|
+
* happened, without a content block Pi's agent loop could try to dispatch.
|
|
573
|
+
*/
|
|
574
|
+
export function noteChildExecutedToolResults(message: SDKMessage, c: QueryContext = ctx()): void {
|
|
575
|
+
if (c.childExecutedToolCalls.size === 0) return;
|
|
576
|
+
const content = (message as SDKMessage & { message?: { content?: unknown } }).message?.content;
|
|
577
|
+
if (!Array.isArray(content)) return;
|
|
578
|
+
for (const block of content) {
|
|
579
|
+
if (block?.type !== "tool_result") continue;
|
|
580
|
+
const name = c.childExecutedToolCalls.get(block.tool_use_id);
|
|
581
|
+
if (!name) continue;
|
|
582
|
+
const isError = block.is_error === true;
|
|
583
|
+
const byteSize = connectorResultByteSize(block.content);
|
|
584
|
+
const audited = recordConnectorCallResult(c, block.tool_use_id, name, isError, byteSize);
|
|
585
|
+
debug(`child-executed tool result: ${name} [${block.tool_use_id}] isError=${isError} byteSize=${byteSize ?? "unknown"} audited=${audited}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function processAssistantMessage(message: SDKMessage, model: Model<any>, customToolNameToPi: Map<string, string>, c: QueryContext = ctx()): void {
|
|
238
590
|
const assistantMsg = (message as any).message;
|
|
239
591
|
if (!assistantMsg?.content) return;
|
|
240
|
-
updateTurnOutputModel(assistantMsg.model);
|
|
592
|
+
updateTurnOutputModel(assistantMsg.model, c);
|
|
241
593
|
if (c.turnSawStreamEvent) {
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
594
|
+
// The SDK yields the completed assistant message BEFORE the stream's
|
|
595
|
+
// message_delta/message_stop on every tool-use turn (measured — this is
|
|
596
|
+
// the norm, not a fallback). Record any tool_use blocks the stream hasn't
|
|
597
|
+
// delivered yet, but do NOT end the pi stream here: message_delta, which
|
|
598
|
+
// arrives tens of ms later, carries the message's real output-token count,
|
|
599
|
+
// and message_stop is the normal turn end. Ending here froze usage at the
|
|
600
|
+
// message_start placeholders (1–7 output tokens per tool turn). The grace
|
|
601
|
+
// timer force-ends the turn if the terminal events never arrive, so pi
|
|
602
|
+
// still gets to execute the tools and unblock the MCP handlers.
|
|
603
|
+
if (appendMissingToolUsesFromAssistant(assistantMsg, model, customToolNameToPi, c)) {
|
|
249
604
|
c.turnSawToolCall = true;
|
|
250
|
-
|
|
251
|
-
c.turnOutput.stopReason = "toolUse";
|
|
252
|
-
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
253
|
-
c.currentPiStream.end();
|
|
254
|
-
c.currentPiStream = null;
|
|
255
|
-
debug("processAssistantMessage boundary: ended streamed tool_use turn from assistant message");
|
|
256
|
-
}
|
|
605
|
+
scheduleToolUseTurnEnd(c, () => endToolUseTurn(c), "assistant-boundary");
|
|
257
606
|
}
|
|
258
607
|
return;
|
|
259
608
|
}
|
|
260
|
-
|
|
261
|
-
|
|
609
|
+
// The SDK yields the SAME assistant message more than once (per-block
|
|
610
|
+
// partial copies and the completed message share one id). With stream
|
|
611
|
+
// events, the streamed path already renders content and the duplicates are
|
|
612
|
+
// naturally ignored; on this no-stream-events path each yield used to be
|
|
613
|
+
// re-rendered wholesale — a rate-limited turn printed "You've hit your
|
|
614
|
+
// weekly limit" twice. Same-message yields keep the turn's tracking (a
|
|
615
|
+
// reset mid-message would wipe live tool-claim state) and render only
|
|
616
|
+
// blocks not already rendered.
|
|
617
|
+
const sameMessage = typeof assistantMsg.id === "string" && assistantMsg.id.length > 0 && assistantMsg.id === c.currentMessageId;
|
|
618
|
+
if (!sameMessage) {
|
|
619
|
+
reapStaleQueuedResults(c);
|
|
620
|
+
c.resetToolTracking();
|
|
621
|
+
}
|
|
622
|
+
// The no-stream-events path also sees a message boundary. It is keyed on the
|
|
623
|
+
// message ID rather than trusted blindly, because this branch is ALSO reached
|
|
624
|
+
// for a message whose `message_start` already streamed — any message that
|
|
625
|
+
// produced no content blocks, since `turnSawStreamEvent` only tracks those.
|
|
626
|
+
c.beginChildMessage(assistantMsg.id);
|
|
627
|
+
debug(`processAssistantMessage fallback: ${assistantMsg.content.length} blocks, types=${assistantMsg.content.map((b: any) => b.type).join(",")}${sameMessage ? " (same message re-yield)" : ""}`);
|
|
628
|
+
// Deduped against the WHOLE current turn, not just same-id re-yields: a
|
|
629
|
+
// rejected turn's synthesized error message ("You've hit your weekly limit")
|
|
630
|
+
// arrives as multiple assistant yields whose ids DIFFER or are absent
|
|
631
|
+
// (measured 2026-07-28: one pi message, two byte-identical text blocks), so
|
|
632
|
+
// an id-keyed guard alone still rendered it twice. A model legitimately
|
|
633
|
+
// producing two byte-identical full blocks in one turn is vanishingly rare;
|
|
634
|
+
// rendering such a duplicate once is the better failure mode.
|
|
635
|
+
const alreadyRendered = (type: string, content: string): boolean =>
|
|
636
|
+
c.turnBlocks.some((b: any) => b.type === type && (type === "text" ? b.text : b.thinking) === content);
|
|
262
637
|
for (const block of assistantMsg.content) {
|
|
263
638
|
if (block.type === "text" && block.text) {
|
|
264
|
-
|
|
639
|
+
if (alreadyRendered("text", block.text)) continue;
|
|
640
|
+
ensureTurnStarted(c);
|
|
265
641
|
c.turnBlocks.push({ type: "text", text: block.text });
|
|
266
642
|
const idx = c.turnBlocks.length - 1;
|
|
267
643
|
c.currentPiStream?.push({ type: "text_start", contentIndex: idx, partial: c.turnOutput });
|
|
268
644
|
c.currentPiStream?.push({ type: "text_delta", contentIndex: idx, delta: block.text, partial: c.turnOutput });
|
|
269
645
|
c.currentPiStream?.push({ type: "text_end", contentIndex: idx, content: block.text, partial: c.turnOutput });
|
|
270
646
|
} else if (block.type === "thinking") {
|
|
271
|
-
|
|
647
|
+
if (alreadyRendered("thinking", block.thinking ?? "")) continue;
|
|
648
|
+
ensureTurnStarted(c);
|
|
272
649
|
c.turnBlocks.push({ type: "thinking", thinking: block.thinking ?? "", thinkingSignature: block.signature ?? "" });
|
|
273
650
|
const idx = c.turnBlocks.length - 1;
|
|
274
651
|
c.currentPiStream?.push({ type: "thinking_start", contentIndex: idx, partial: c.turnOutput });
|
|
275
652
|
if (block.thinking) c.currentPiStream?.push({ type: "thinking_delta", contentIndex: idx, delta: block.thinking, partial: c.turnOutput });
|
|
276
653
|
c.currentPiStream?.push({ type: "thinking_end", contentIndex: idx, content: block.thinking ?? "", partial: c.turnOutput });
|
|
277
654
|
} else if (block.type === "tool_use") {
|
|
278
|
-
|
|
655
|
+
if (isChildExecutedTool(block.name)) {
|
|
656
|
+
// Same as the streamed path: the child owns this call, so it never
|
|
657
|
+
// becomes a Pi tool call and never ends the turn.
|
|
658
|
+
c.noteChildExecutedToolCall(block.id, block.name);
|
|
659
|
+
debug(`processAssistantMessage fallback: child-executed tool ${block.name} [${block.id}] — not mirrored as a Pi tool call`);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
if (!c.turnBlocks.some((b: any) => b.type === "toolCall" && b.id === block.id)
|
|
663
|
+
&& (c.forwardedToolCallIds.has(block.id) || c.deadToolCallIds.has(block.id))) {
|
|
664
|
+
// A cross-turn replay of a call Pi already executed (or one whose
|
|
665
|
+
// handler was already failed as stranded). This exact path produced
|
|
666
|
+
// the observed duplicate dispatches: the completed-message yield lands
|
|
667
|
+
// in the callback AFTER a grace finalize already ended the call's
|
|
668
|
+
// turn, and per-message dedup cannot see across turns (vstack#1469).
|
|
669
|
+
// Not recorded either — a forwarded call must not be claimable again.
|
|
670
|
+
debug(`processAssistantMessage fallback: tool_use ${block.id} already ${c.forwardedToolCallIds.has(block.id) ? "forwarded" : "dead"} — skipping duplicate`);
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
ensureTurnStarted(c);
|
|
279
674
|
c.turnSawToolCall = true;
|
|
280
675
|
const mappedName = mapToolName(block.name, customToolNameToPi);
|
|
281
676
|
const mappedArgs = mapToolArgs(mappedName, block.input);
|
|
282
677
|
c.recordToolCall(block.id, mappedName, mappedArgs);
|
|
678
|
+
// A same-message re-yield of an already-mirrored call refreshes its
|
|
679
|
+
// arguments in place — a second toolCall block would make pi dispatch
|
|
680
|
+
// the tool twice.
|
|
681
|
+
const existingIdx = c.turnBlocks.findIndex((b: any) => b.type === "toolCall" && b.id === block.id);
|
|
682
|
+
if (existingIdx >= 0) {
|
|
683
|
+
const existing = c.turnBlocks[existingIdx] as any;
|
|
684
|
+
existing.name = mappedName;
|
|
685
|
+
existing.arguments = mappedArgs;
|
|
686
|
+
c.updateToolCallArgs(block.id, mappedArgs);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
283
689
|
c.turnBlocks.push({
|
|
284
690
|
type: "toolCall", id: block.id,
|
|
285
691
|
name: mappedName,
|
|
@@ -290,18 +696,18 @@ export function processAssistantMessage(message: SDKMessage, model: Model<any>,
|
|
|
290
696
|
c.currentPiStream?.push({ type: "toolcall_start", contentIndex: idx, partial: c.turnOutput });
|
|
291
697
|
c.currentPiStream?.push({ type: "toolcall_end", contentIndex: idx, toolCall: toolBlock as any, partial: c.turnOutput });
|
|
292
698
|
} else if (block.type === "fallback") {
|
|
293
|
-
updateTurnOutputModel(block.to?.model);
|
|
699
|
+
updateTurnOutputModel(block.to?.model, c);
|
|
294
700
|
} else {
|
|
295
701
|
debug("processAssistantMessage: unhandled block type", block.type);
|
|
296
702
|
}
|
|
297
703
|
}
|
|
298
|
-
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model);
|
|
704
|
+
if (assistantMsg.usage && c.turnOutput) updateUsage(c.turnOutput, assistantMsg.usage, model, c);
|
|
299
705
|
|
|
300
|
-
// End the stream on tool_use
|
|
706
|
+
// End the stream on tool_use. Immediate (no grace deferral) ON PURPOSE: this
|
|
707
|
+
// branch only runs when NO content blocks streamed for the message, so there
|
|
708
|
+
// is no reason to expect terminal stream events either, and the completed
|
|
709
|
+
// message's own usage — applied just above — is the best figure available.
|
|
301
710
|
if (c.turnSawToolCall && c.currentPiStream && c.turnOutput) {
|
|
302
|
-
c
|
|
303
|
-
c.currentPiStream.push({ type: "done", reason: "toolUse", message: c.turnOutput });
|
|
304
|
-
c.currentPiStream.end();
|
|
305
|
-
c.currentPiStream = null;
|
|
711
|
+
endToolUseTurn(c);
|
|
306
712
|
}
|
|
307
713
|
}
|