@linxiraos/pi-agent-core 1.0.0

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +1263 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +137 -0
  4. package/dist/types/agent.d.ts +465 -0
  5. package/dist/types/append-only-context.d.ts +131 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +85 -0
  8. package/dist/types/compaction/compaction.d.ts +307 -0
  9. package/dist/types/compaction/entries.d.ts +111 -0
  10. package/dist/types/compaction/errors.d.ts +38 -0
  11. package/dist/types/compaction/index.d.ts +13 -0
  12. package/dist/types/compaction/message-cache.d.ts +23 -0
  13. package/dist/types/compaction/messages.d.ts +79 -0
  14. package/dist/types/compaction/openai.d.ts +117 -0
  15. package/dist/types/compaction/pruning.d.ts +105 -0
  16. package/dist/types/compaction/shake.d.ts +97 -0
  17. package/dist/types/compaction/tool-protection.d.ts +19 -0
  18. package/dist/types/compaction/utils.d.ts +61 -0
  19. package/dist/types/compaction.d.ts +1 -0
  20. package/dist/types/index.d.ts +13 -0
  21. package/dist/types/pause.d.ts +46 -0
  22. package/dist/types/proxy.d.ts +91 -0
  23. package/dist/types/replay-policy.d.ts +5 -0
  24. package/dist/types/run-collector.d.ts +196 -0
  25. package/dist/types/telemetry.d.ts +608 -0
  26. package/dist/types/thinking.d.ts +18 -0
  27. package/dist/types/tokenizer.d.ts +2 -0
  28. package/dist/types/types.d.ts +781 -0
  29. package/dist/types/utils/yield.d.ts +71 -0
  30. package/package.json +79 -0
  31. package/src/agent-loop.ts +2926 -0
  32. package/src/agent.ts +1748 -0
  33. package/src/append-only-context.ts +348 -0
  34. package/src/compaction/branch-summarization.ts +369 -0
  35. package/src/compaction/compaction-v2-streaming.ts +846 -0
  36. package/src/compaction/compaction.ts +1733 -0
  37. package/src/compaction/entries.ts +141 -0
  38. package/src/compaction/errors.ts +47 -0
  39. package/src/compaction/index.ts +14 -0
  40. package/src/compaction/message-cache.ts +92 -0
  41. package/src/compaction/messages.ts +241 -0
  42. package/src/compaction/openai.ts +992 -0
  43. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  44. package/src/compaction/prompts/branch-summary-context.md +5 -0
  45. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  46. package/src/compaction/prompts/branch-summary.md +30 -0
  47. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  48. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  49. package/src/compaction/prompts/compaction-summary.md +38 -0
  50. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  51. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  52. package/src/compaction/prompts/context-window-truncated-output.md +1 -0
  53. package/src/compaction/prompts/file-operations.md +5 -0
  54. package/src/compaction/prompts/handoff-document.md +49 -0
  55. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  56. package/src/compaction/prompts/summarization-system.md +3 -0
  57. package/src/compaction/pruning.ts +427 -0
  58. package/src/compaction/shake.ts +446 -0
  59. package/src/compaction/tool-protection.ts +65 -0
  60. package/src/compaction/utils.ts +343 -0
  61. package/src/compaction.ts +1 -0
  62. package/src/index.ts +26 -0
  63. package/src/pause.ts +107 -0
  64. package/src/proxy.ts +391 -0
  65. package/src/replay-policy.ts +13 -0
  66. package/src/run-collector.ts +631 -0
  67. package/src/telemetry.ts +2078 -0
  68. package/src/thinking.ts +20 -0
  69. package/src/tokenizer.ts +27 -0
  70. package/src/types.ts +875 -0
  71. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,2926 @@
1
+ /**
2
+ * Agent loop that works with AgentMessage throughout.
3
+ * Transforms to Message[] only at the LLM call boundary.
4
+ */
5
+
6
+ import {
7
+ type AssistantMessage,
8
+ type AssistantMessageEvent,
9
+ type ComputerAction,
10
+ type ComputerSafetyCheck,
11
+ type Context,
12
+ EventStream,
13
+ isApiKeyResolver,
14
+ type Model,
15
+ resolveApiKeyOnce,
16
+ seedApiKeyResolver,
17
+ streamSimple,
18
+ stripSchemaDescriptions,
19
+ type ToolCallProviderMetadata,
20
+ type ToolChoice,
21
+ type ToolResultMessage,
22
+ type ToolResultProviderMetadata,
23
+ type TSchema,
24
+ toolWireSchema,
25
+ validateToolArguments,
26
+ } from "@linxiraos/pi-ai";
27
+ import {
28
+ type Dialect,
29
+ encodeInbandToolHistory,
30
+ renderInbandToolPrompt,
31
+ renderToolExamples,
32
+ wrapInbandToolStream,
33
+ } from "@linxiraos/pi-ai/dialect";
34
+ import * as AIError from "@linxiraos/pi-ai/error";
35
+ import { type CursorExecResolvedCarrier, kCursorExecResolved } from "@linxiraos/pi-ai/utils/block-symbols";
36
+ import {
37
+ createHarmonyAuditEvent,
38
+ detectHarmonyLeakInAssistantMessage,
39
+ extractHarmonyRemoved,
40
+ type HarmonyDetection,
41
+ type HarmonyRecoveredToolCall,
42
+ isHarmonyLeakMitigationTarget,
43
+ recoverHarmonyToolCall,
44
+ signalListLabel,
45
+ } from "@linxiraos/pi-ai/utils/harmony-leak";
46
+ import { logger, sanitizeText, structuredCloneJSON } from "@linxiraos/pi-utils";
47
+ import { INTENT_FIELD } from "@linxiraos/pi-wire";
48
+ import { agentPauseGate } from "./pause";
49
+ import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
50
+ import {
51
+ type AgentTelemetry,
52
+ failChatSpan,
53
+ finishChatSpan,
54
+ finishExecuteToolSpan,
55
+ finishInvokeAgentSpan,
56
+ fireOnRunEnd,
57
+ PiGenAIAttr,
58
+ recordSkippedTool,
59
+ resolveTelemetry,
60
+ runInActiveSpan,
61
+ type Span,
62
+ startChatSpan,
63
+ startExecuteToolSpan,
64
+ startInvokeAgentSpan,
65
+ } from "./telemetry";
66
+ import type {
67
+ AgentContext,
68
+ AgentEvent,
69
+ AgentLoopConfig,
70
+ AgentMessage,
71
+ AgentPreModelCallResult,
72
+ AgentTool,
73
+ AgentToolCall,
74
+ AgentToolResult,
75
+ AgentTurnEndContext,
76
+ AsideMessage,
77
+ BeforeToolCallResult,
78
+ CommittableAsideMessage,
79
+ SoftToolRequirement,
80
+ SteeringInterruptSource,
81
+ SteeringQueueState,
82
+ StreamFn,
83
+ } from "./types";
84
+ import { ASIDE_MESSAGE_COMMIT, ASIDE_MESSAGE_DISCARD, isSoftToolRequirement } from "./types";
85
+ import { yieldIfDue } from "./utils/yield";
86
+
87
+ /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
88
+ export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
89
+
90
+ /** Sentinel returned by the abort race in `streamAssistantResponse`. */
91
+ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
92
+
93
+ /**
94
+ * Cap on consecutive re-samples triggered by a non-terminal stop
95
+ * (`stopDetails.type === "pause_turn"`) without an intervening tool call. Each
96
+ * continuation is a full model request, so a backend that never stops pausing
97
+ * must not spin the loop forever. Resets whenever a turn carries tool calls.
98
+ */
99
+ const MAX_PAUSED_TURN_CONTINUATIONS = 8;
100
+
101
+ /**
102
+ * Cap on consecutive forced escalations for a single soft tool requirement.
103
+ * A forced `toolChoice` guarantees the call, so this is purely defensive: if a
104
+ * model somehow never satisfies the requirement, give up forcing rather than
105
+ * spin the loop. Reset whenever the requirement id changes or clears.
106
+ */
107
+ const MAX_SOFT_TOOL_ESCALATIONS = 3;
108
+
109
+ /**
110
+ * Whether a hard `toolChoice` for a turn conflicts with a pending soft tool
111
+ * requirement — i.e. forbids tools (`"none"`) or forces a *different* specific
112
+ * tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model
113
+ * satisfy the requirement, so they do not conflict and the soft gate stays active.
114
+ */
115
+ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean {
116
+ if (choice === undefined) return false;
117
+ if (typeof choice === "string") return choice === "none";
118
+ if (choice.type === "computer") return requiredTool !== "computer";
119
+ const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
120
+ return name !== requiredTool;
121
+ }
122
+
123
+ /**
124
+ * Cadence (ms) for polling queued steering while an `interruptible` tool is in
125
+ * flight, so a steer cuts the wait short instead of sitting idle until the
126
+ * tool's own window elapses. A cheap synchronous queue check; latency-bounded
127
+ * at one tick.
128
+ */
129
+ /**
130
+ * Abort reason for a turn-wide interruption where only some tool calls caused
131
+ * the abort and sibling placeholders need neutral messages.
132
+ */
133
+ export interface ToolScopedAbortReason {
134
+ readonly kind: "tool-scoped-abort";
135
+ readonly message: string;
136
+ readonly toolCallMessages: Record<string, string>;
137
+ readonly defaultToolCallMessage: string;
138
+ }
139
+
140
+ /** Creates an abort reason that labels matching tool calls separately from siblings. */
141
+ export function createToolScopedAbortReason(
142
+ message: string,
143
+ toolCallMessages: Record<string, string>,
144
+ defaultToolCallMessage: string,
145
+ ): ToolScopedAbortReason {
146
+ return { kind: "tool-scoped-abort", message, toolCallMessages, defaultToolCallMessage };
147
+ }
148
+
149
+ /**
150
+ * Marks an abort raised by a completed post-tool hook as terminal for the
151
+ * current run. External/user aborts still synthesize an aborted assistant
152
+ * boundary; this reason stops after persisting the completed tool batch.
153
+ */
154
+ export const TERMINAL_TOOL_RESULT_ABORT_REASON = Symbol.for("pi-agent-core.terminal-tool-result");
155
+
156
+ const STEERING_INTERRUPT_POLL_MS = 250;
157
+
158
+ class HarmonyLeakInterruption extends Error {
159
+ constructor(
160
+ readonly detection: HarmonyDetection,
161
+ readonly removed: string,
162
+ readonly recovered?: HarmonyRecoveredToolCall,
163
+ ) {
164
+ super(`Detected GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`);
165
+ this.name = "HarmonyLeakInterruption";
166
+ }
167
+ }
168
+ export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
169
+ switch (value) {
170
+ case "1":
171
+ case "true":
172
+ return "glm";
173
+ case "glm":
174
+ case "hermes":
175
+ case "kimi":
176
+ case "xml":
177
+ case "anthropic":
178
+ case "deepseek":
179
+ case "harmony":
180
+ case "qwen3":
181
+ case "gemini":
182
+ case "gemma":
183
+ case "minimax":
184
+ return value;
185
+ default:
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ type AssistantContentBlock = AssistantMessage["content"][number];
191
+ type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
192
+
193
+ function snapshotComputerSafetyChecks(value: unknown): ComputerSafetyCheck[] | undefined {
194
+ if (!Array.isArray(value)) return undefined;
195
+ const checks: ComputerSafetyCheck[] = [];
196
+ for (const raw of value) {
197
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
198
+ const check = raw as Record<string, unknown>;
199
+ if (typeof check.id !== "string" || check.id.length === 0) return undefined;
200
+ if (check.code !== undefined && check.code !== null && typeof check.code !== "string") return undefined;
201
+ if (check.message !== undefined && check.message !== null && typeof check.message !== "string") return undefined;
202
+ checks.push({
203
+ id: check.id,
204
+ ...(check.code !== undefined ? { code: check.code as string | null } : {}),
205
+ ...(check.message !== undefined ? { message: check.message as string | null } : {}),
206
+ });
207
+ }
208
+ return checks;
209
+ }
210
+
211
+ function isFiniteCoordinate(value: unknown): value is number {
212
+ return typeof value === "number" && Number.isFinite(value);
213
+ }
214
+
215
+ function hasValidComputerKeys(value: unknown, optional: boolean): boolean {
216
+ return (
217
+ (optional && value === undefined) ||
218
+ value === null ||
219
+ (Array.isArray(value) && value.every(key => typeof key === "string"))
220
+ );
221
+ }
222
+
223
+ function snapshotComputerAction(value: unknown): ComputerAction | undefined {
224
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
225
+ const action = value as Record<string, unknown>;
226
+ switch (action.type) {
227
+ case "click":
228
+ if (
229
+ !(["left", "right", "wheel", "back", "forward"] as unknown[]).includes(action.button) ||
230
+ !isFiniteCoordinate(action.x) ||
231
+ !isFiniteCoordinate(action.y) ||
232
+ !hasValidComputerKeys(action.keys, true)
233
+ )
234
+ return undefined;
235
+ break;
236
+ case "double_click":
237
+ if (
238
+ !isFiniteCoordinate(action.x) ||
239
+ !isFiniteCoordinate(action.y) ||
240
+ !hasValidComputerKeys(action.keys, false)
241
+ )
242
+ return undefined;
243
+ break;
244
+ case "drag":
245
+ if (
246
+ !Array.isArray(action.path) ||
247
+ !action.path.every(
248
+ point =>
249
+ point &&
250
+ typeof point === "object" &&
251
+ isFiniteCoordinate((point as Record<string, unknown>).x) &&
252
+ isFiniteCoordinate((point as Record<string, unknown>).y),
253
+ ) ||
254
+ !hasValidComputerKeys(action.keys, true)
255
+ )
256
+ return undefined;
257
+ break;
258
+ case "keypress":
259
+ if (!Array.isArray(action.keys) || !action.keys.every(key => typeof key === "string")) return undefined;
260
+ break;
261
+ case "move":
262
+ if (!isFiniteCoordinate(action.x) || !isFiniteCoordinate(action.y) || !hasValidComputerKeys(action.keys, true))
263
+ return undefined;
264
+ break;
265
+ case "screenshot":
266
+ case "wait":
267
+ break;
268
+ case "scroll":
269
+ if (
270
+ !isFiniteCoordinate(action.x) ||
271
+ !isFiniteCoordinate(action.y) ||
272
+ !isFiniteCoordinate(action.scroll_x) ||
273
+ !isFiniteCoordinate(action.scroll_y) ||
274
+ !hasValidComputerKeys(action.keys, true)
275
+ )
276
+ return undefined;
277
+ break;
278
+ case "type":
279
+ if (typeof action.text !== "string") return undefined;
280
+ break;
281
+ default:
282
+ return undefined;
283
+ }
284
+ return structuredCloneJSON(action) as ComputerAction;
285
+ }
286
+
287
+ function snapshotToolCallProviderMetadata(value: unknown): ToolCallProviderMetadata | undefined {
288
+ if (value === undefined) return undefined;
289
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
290
+ const metadata = value as Record<string, unknown>;
291
+ if (
292
+ metadata.type !== "computer" ||
293
+ typeof metadata.providerItemId !== "string" ||
294
+ metadata.providerItemId.length === 0
295
+ )
296
+ return undefined;
297
+ if (!Array.isArray(metadata.actions) || metadata.actions.length === 0) return undefined;
298
+ const actions = metadata.actions.map(snapshotComputerAction);
299
+ if (actions.some(action => action === undefined)) return undefined;
300
+ const pendingSafetyChecks = snapshotComputerSafetyChecks(metadata.pendingSafetyChecks);
301
+ if (!pendingSafetyChecks) return undefined;
302
+ return {
303
+ type: "computer",
304
+ providerItemId: metadata.providerItemId,
305
+ actions: actions as ComputerAction[],
306
+ pendingSafetyChecks,
307
+ };
308
+ }
309
+
310
+ function snapshotToolResultProviderMetadata(value: unknown): {
311
+ metadata?: ToolResultProviderMetadata;
312
+ malformed: boolean;
313
+ } {
314
+ if (value === undefined) return { malformed: false };
315
+ if (!value || typeof value !== "object" || Array.isArray(value)) return { malformed: true };
316
+ const metadata = value as Record<string, unknown>;
317
+ if (
318
+ metadata.type !== "computer" ||
319
+ !metadata.screenshot ||
320
+ typeof metadata.screenshot !== "object" ||
321
+ Array.isArray(metadata.screenshot)
322
+ ) {
323
+ return { malformed: true };
324
+ }
325
+ const screenshot = metadata.screenshot as Record<string, unknown>;
326
+ const hasImageUrl = Object.hasOwn(screenshot, "image_url");
327
+ const hasFileId = Object.hasOwn(screenshot, "file_id");
328
+ if (screenshot.type !== "computer_screenshot" || hasImageUrl === hasFileId) return { malformed: true };
329
+ if (hasImageUrl && (typeof screenshot.image_url !== "string" || screenshot.image_url.length === 0))
330
+ return { malformed: true };
331
+ if (hasFileId && (typeof screenshot.file_id !== "string" || screenshot.file_id.length === 0))
332
+ return { malformed: true };
333
+ const acknowledgedSafetyChecks = snapshotComputerSafetyChecks(metadata.acknowledgedSafetyChecks);
334
+ if (!acknowledgedSafetyChecks) return { malformed: true };
335
+ return {
336
+ malformed: false,
337
+ metadata: {
338
+ type: "computer",
339
+ screenshot: hasImageUrl
340
+ ? { type: "computer_screenshot", image_url: screenshot.image_url as string }
341
+ : { type: "computer_screenshot", file_id: screenshot.file_id as string },
342
+ acknowledgedSafetyChecks,
343
+ },
344
+ };
345
+ }
346
+
347
+ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
348
+ switch (block.type) {
349
+ case "text":
350
+ case "image":
351
+ return { ...block };
352
+ case "thinking":
353
+ return { ...block };
354
+ case "redactedThinking":
355
+ return { ...block };
356
+ case "anthropicServerTool":
357
+ return { ...block, block: structuredCloneJSON(block.block) };
358
+ case "fallback":
359
+ return { ...block, from: { ...block.from }, to: { ...block.to } };
360
+ case "toolCall":
361
+ return {
362
+ ...block,
363
+ arguments: structuredCloneJSON(block.arguments),
364
+ providerMetadata: snapshotToolCallProviderMetadata(block.providerMetadata),
365
+ };
366
+ }
367
+ }
368
+
369
+ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
370
+ return {
371
+ ...message,
372
+ content: message.content.map(snapshotAssistantContentBlock),
373
+ usage: {
374
+ ...message.usage,
375
+ cost: { ...message.usage.cost },
376
+ },
377
+ disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
378
+ toolCallAbortMessages: message.toolCallAbortMessages ? { ...message.toolCallAbortMessages } : undefined,
379
+ };
380
+ }
381
+
382
+ /**
383
+ * Deep-clone an assistant streaming event so subscribers get an immutable view.
384
+ * Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
385
+ * (the `message_update` push sites alias it as the event's `message`) so the
386
+ * identical partial is not deep-cloned twice per streaming delta.
387
+ */
388
+ function snapshotAssistantMessageEvent(
389
+ event: AssistantMessageEvent,
390
+ partialSnapshot?: AssistantMessage,
391
+ ): AssistantMessageEvent {
392
+ switch (event.type) {
393
+ case "start":
394
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
395
+ case "text_start":
396
+ case "text_delta":
397
+ case "text_end":
398
+ case "image_end":
399
+ case "thinking_start":
400
+ case "thinking_delta":
401
+ case "thinking_end":
402
+ case "toolcall_start":
403
+ case "toolcall_delta":
404
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
405
+ case "toolcall_end":
406
+ return {
407
+ ...event,
408
+ toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
409
+ partial: partialSnapshot ?? snapshotAssistantMessage(event.partial),
410
+ };
411
+ case "done":
412
+ return { ...event, message: snapshotAssistantMessage(event.message) };
413
+ case "error":
414
+ return { ...event, error: snapshotAssistantMessage(event.error) };
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
420
+ * into a structurally valid {@link AgentToolResult}.
421
+ *
422
+ * The tool interface is typed, but third-party tools (MCP, extensions, user-authored AgentTools)
423
+ * can violate the contract at runtime. Persisting a malformed result corrupts the session file
424
+ * (missing `content` array → crash on reload). We coerce at the single boundary where untyped
425
+ * results enter the agent loop, so every downstream consumer can rely on the type.
426
+ */
427
+ const EMPTY_ERROR_TOOL_RESULT_TEXT = "Tool failed with no output.";
428
+
429
+ function hasSubstantiveToolResultContent(content: AgentToolResult["content"]): boolean {
430
+ for (const block of content) {
431
+ if (block.type === "image") return true;
432
+ if (block.type === "text" && block.text.trim().length > 0) return true;
433
+ }
434
+ return false;
435
+ }
436
+
437
+ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
438
+ const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
439
+ const rawContent = rawObj?.content;
440
+ const details = rawObj && "details" in rawObj ? rawObj.details : {};
441
+ const providerMetadataResult = snapshotToolResultProviderMetadata(
442
+ rawObj && "providerMetadata" in rawObj ? rawObj.providerMetadata : undefined,
443
+ );
444
+ const providerMetadata = providerMetadataResult.metadata;
445
+ // Tools may flag a non-throwing failure on the result itself (e.g. an
446
+ // aggregator that catches per-entry errors and synthesizes a combined
447
+ // result). Preserve the flag so agent-loop can surface it on the wire.
448
+ const explicitError = Boolean(rawObj && "isError" in rawObj && rawObj.isError);
449
+ // Tools may flag the result contextually useless (zero matches, elapsed
450
+ // wait) so compaction can elide it once consumed. Errors are never useless.
451
+ const useless = Boolean(rawObj && "useless" in rawObj && rawObj.useless);
452
+
453
+ if (!Array.isArray(rawContent)) {
454
+ return {
455
+ result: {
456
+ content: [{ type: "text", text: "Tool returned an invalid result: missing content array." }],
457
+ details,
458
+ isError: true,
459
+ },
460
+ malformed: true,
461
+ };
462
+ }
463
+
464
+ const content: AgentToolResult["content"] = [];
465
+ let invalidBlocks = 0;
466
+ for (const block of rawContent) {
467
+ if (!block || typeof block !== "object" || !("type" in block)) {
468
+ invalidBlocks++;
469
+ continue;
470
+ }
471
+ if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
472
+ content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
473
+ } else if (
474
+ block.type === "image" &&
475
+ typeof (block as { data?: unknown }).data === "string" &&
476
+ typeof (block as { mimeType?: unknown }).mimeType === "string"
477
+ ) {
478
+ content.push(block as { type: "image"; data: string; mimeType: string });
479
+ } else {
480
+ invalidBlocks++;
481
+ }
482
+ }
483
+ if (invalidBlocks > 0) {
484
+ content.push({
485
+ type: "text",
486
+ text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
487
+ });
488
+ }
489
+ if (providerMetadataResult.malformed) {
490
+ content.push({
491
+ type: "text",
492
+ text: "Tool returned an invalid result: computer providerMetadata had an unsupported shape.",
493
+ });
494
+ }
495
+ const isError = explicitError || invalidBlocks > 0 || providerMetadataResult.malformed;
496
+ // Anthropic rejects tool_result blocks with is_error: true and empty content.
497
+ if (isError && !hasSubstantiveToolResultContent(content)) {
498
+ content.length = 0;
499
+ content.push({ type: "text", text: EMPTY_ERROR_TOOL_RESULT_TEXT });
500
+ }
501
+ return {
502
+ result: {
503
+ content,
504
+ details,
505
+ providerMetadata,
506
+ ...(isError ? { isError: true } : {}),
507
+ ...(useless && !isError ? { useless: true } : {}),
508
+ },
509
+ malformed: invalidBlocks > 0 || providerMetadataResult.malformed,
510
+ };
511
+ }
512
+
513
+ /**
514
+ * Start an agent loop with a new prompt message.
515
+ * The prompt is added to the context and events are emitted for it.
516
+ */
517
+ export function agentLoop(
518
+ prompts: AgentMessage[],
519
+ context: AgentContext,
520
+ config: AgentLoopConfig,
521
+ signal?: AbortSignal,
522
+ streamFn?: StreamFn,
523
+ ): EventStream<AgentEvent, AgentMessage[]> {
524
+ const stream = createAgentStream();
525
+
526
+ (async () => {
527
+ const newMessages: AgentMessage[] = [...prompts];
528
+ const currentContext: AgentContext = {
529
+ ...context,
530
+ messages: [...context.messages, ...prompts],
531
+ };
532
+ for (const prompt of prompts) {
533
+ (prompt as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
534
+ }
535
+
536
+ stream.push({ type: "agent_start" });
537
+
538
+ try {
539
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, prompts);
540
+ } catch (err) {
541
+ stream.fail(err);
542
+ }
543
+ })();
544
+
545
+ return stream;
546
+ }
547
+
548
+ /**
549
+ * Continue an agent loop from the current context without adding a new message.
550
+ * Used for retries - context already has user message or tool results.
551
+ *
552
+ * **Important:** The last message in context must convert to a `user` or `toolResult` message
553
+ * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
554
+ * This cannot be validated here since `convertToLlm` is only called once per turn.
555
+ */
556
+ export function agentLoopContinue(
557
+ context: AgentContext,
558
+ config: AgentLoopConfig,
559
+ signal?: AbortSignal,
560
+ streamFn?: StreamFn,
561
+ ): EventStream<AgentEvent, AgentMessage[]> {
562
+ if (context.messages.length === 0) {
563
+ throw new Error("Cannot continue: no messages in context");
564
+ }
565
+
566
+ if (context.messages[context.messages.length - 1].role === "assistant") {
567
+ throw new Error("Cannot continue from message role: assistant");
568
+ }
569
+
570
+ const stream = createAgentStream();
571
+
572
+ (async () => {
573
+ const newMessages: AgentMessage[] = [];
574
+ const currentContext: AgentContext = { ...context, messages: [...context.messages] };
575
+
576
+ stream.push({ type: "agent_start" });
577
+
578
+ try {
579
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
580
+ } catch (err) {
581
+ stream.fail(err);
582
+ }
583
+ })();
584
+
585
+ return stream;
586
+ }
587
+
588
+ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
589
+ return new EventStream<AgentEvent, AgentMessage[]>(
590
+ (event: AgentEvent) => event.type === "agent_end",
591
+ (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
592
+ );
593
+ }
594
+
595
+ /**
596
+ * Build the `agent_end` event payload. When telemetry is enabled, snapshots
597
+ * the run collector so consumers receive {@link AgentRunSummary} +
598
+ * {@link AgentRunCoverage} alongside the messages without parsing OTEL spans.
599
+ * When telemetry is unset, returns the bare event for backwards compatibility.
600
+ */
601
+ function buildAgentEndEvent(
602
+ messages: AgentMessage[],
603
+ telemetry: AgentTelemetry | undefined,
604
+ stepCount: number,
605
+ ): Extract<AgentEvent, { type: "agent_end" }> {
606
+ if (!telemetry) return { type: "agent_end", messages };
607
+ const snapshot = telemetry.collector.snapshot({ stepCount });
608
+ if (telemetry.collector.markRunEnded()) {
609
+ fireOnRunEnd(telemetry, snapshot.summary, snapshot.coverage);
610
+ }
611
+ return { type: "agent_end", messages, telemetry: snapshot.summary, coverage: snapshot.coverage };
612
+ }
613
+ /**
614
+ * Push a `turn_end` event and run the awaited per-turn hook when the run is
615
+ * still healthy. The hook is skipped for externally aborted or errored turns so
616
+ * a user interrupt does not hang on a background backlog wait.
617
+ */
618
+ async function emitTurnEnd(
619
+ stream: EventStream<AgentEvent, AgentMessage[]>,
620
+ currentContext: AgentContext,
621
+ message: AgentMessage,
622
+ toolResults: ToolResultMessage[],
623
+ config: AgentLoopConfig,
624
+ signal?: AbortSignal,
625
+ context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
626
+ runHookOnAbortedMessage = false,
627
+ ): Promise<void> {
628
+ stream.push({ type: "turn_end", message, toolResults });
629
+ const isAbortedOrError =
630
+ message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
631
+ if (signal?.aborted || (isAbortedOrError && !runHookOnAbortedMessage)) return;
632
+ await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
633
+ }
634
+
635
+ function createGateStopMessage(model: Model, reason: string | undefined): AssistantMessage {
636
+ return {
637
+ role: "assistant",
638
+ content: [{ type: "text", text: "" }],
639
+ api: model.api,
640
+ provider: model.provider,
641
+ model: model.id,
642
+ usage: {
643
+ input: 0,
644
+ output: 0,
645
+ cacheRead: 0,
646
+ cacheWrite: 0,
647
+ totalTokens: 0,
648
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
649
+ },
650
+ stopReason: "aborted",
651
+ errorMessage: reason ?? "Stopped before model call",
652
+ timestamp: Date.now(),
653
+ };
654
+ }
655
+
656
+ /**
657
+ * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
658
+ * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
659
+ * payload without changing the resolved type of `stream.result()`.
660
+ */
661
+ export interface AgentLoopDetailedResult {
662
+ readonly messages: AgentMessage[];
663
+ readonly telemetry: AgentRunSummary | undefined;
664
+ readonly coverage: AgentRunCoverage | undefined;
665
+ }
666
+
667
+ /**
668
+ * Convenience wrapper over {@link agentLoop} that exposes the run-level
669
+ * summary + coverage alongside the messages. The returned `stream` is the
670
+ * same `EventStream` callers already consume; `detailed()` awaits the
671
+ * stream's `agent_end` event and returns the additive fields.
672
+ *
673
+ * Existing `stream.result()` semantics are preserved — it still resolves to
674
+ * `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
675
+ * use {@link agentLoop} when you do not.
676
+ */
677
+ export function agentLoopDetailed(
678
+ prompts: AgentMessage[],
679
+ context: AgentContext,
680
+ config: AgentLoopConfig,
681
+ signal?: AbortSignal,
682
+ streamFn?: StreamFn,
683
+ ): {
684
+ readonly stream: EventStream<AgentEvent, AgentMessage[]>;
685
+ readonly detailed: () => Promise<AgentLoopDetailedResult>;
686
+ } {
687
+ const capture = createDetailedCapture(config);
688
+ const stream = agentLoop(prompts, context, capture.config, signal, streamFn);
689
+ return { stream, detailed: () => capture.detailed(stream) };
690
+ }
691
+
692
+ /**
693
+ * Like {@link agentLoopDetailed} but built on top of
694
+ * {@link agentLoopContinue}.
695
+ */
696
+ export function agentLoopContinueDetailed(
697
+ context: AgentContext,
698
+ config: AgentLoopConfig,
699
+ signal?: AbortSignal,
700
+ streamFn?: StreamFn,
701
+ ): {
702
+ readonly stream: EventStream<AgentEvent, AgentMessage[]>;
703
+ readonly detailed: () => Promise<AgentLoopDetailedResult>;
704
+ } {
705
+ const capture = createDetailedCapture(config);
706
+ const stream = agentLoopContinue(context, capture.config, signal, streamFn);
707
+ return { stream, detailed: () => capture.detailed(stream) };
708
+ }
709
+
710
+ /**
711
+ * Wire an `onRunEnd` telemetry hook onto `config` so the detailed helper can
712
+ * capture the run summary without consuming the event stream. Preserves any
713
+ * existing `onRunEnd` the caller had set.
714
+ */
715
+ function createDetailedCapture(config: AgentLoopConfig): {
716
+ readonly config: AgentLoopConfig;
717
+ readonly detailed: (stream: EventStream<AgentEvent, AgentMessage[]>) => Promise<AgentLoopDetailedResult>;
718
+ } {
719
+ let captured: { summary: AgentRunSummary; coverage: AgentRunCoverage } | undefined;
720
+ const userHook = config.telemetry?.onRunEnd;
721
+ const wired: AgentLoopConfig = {
722
+ ...config,
723
+ telemetry: {
724
+ ...(config.telemetry ?? {}),
725
+ onRunEnd: (summary, coverage) => {
726
+ captured = { summary, coverage };
727
+ userHook?.(summary, coverage);
728
+ },
729
+ },
730
+ };
731
+ return {
732
+ config: wired,
733
+ detailed: async stream => {
734
+ const messages = await stream.result();
735
+ return {
736
+ messages,
737
+ telemetry: captured?.summary,
738
+ coverage: captured?.coverage,
739
+ };
740
+ },
741
+ };
742
+ }
743
+
744
+ export function normalizeMessagesForProvider(
745
+ messages: Context["messages"],
746
+ model: AgentLoopConfig["model"],
747
+ ): Context["messages"] {
748
+ if (model.provider !== "cerebras") {
749
+ return messages;
750
+ }
751
+
752
+ let hasThinking = false;
753
+ for (const message of messages) {
754
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
755
+ for (const block of message.content) {
756
+ if (block.type === "thinking") {
757
+ hasThinking = true;
758
+ break;
759
+ }
760
+ }
761
+ if (hasThinking) break;
762
+ }
763
+ if (!hasThinking) return messages;
764
+
765
+ return messages.map(message => {
766
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
767
+ return message;
768
+ }
769
+ const filtered = message.content.filter(block => block.type !== "thinking");
770
+ return filtered.length === message.content.length ? message : { ...message, content: filtered };
771
+ });
772
+ }
773
+
774
+ const INTENT_FIELD_DESCRIPTION = "concise intent";
775
+ const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
776
+
777
+ function injectIntentIntoSchema(
778
+ schema: unknown,
779
+ mode: "require" | "optional" = "require",
780
+ describeIntent = true,
781
+ ): unknown {
782
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
783
+ const schemaRecord = schema as Record<string, unknown>;
784
+ const propertiesValue = schemaRecord.properties;
785
+ const hasOwnProperties =
786
+ propertiesValue !== null && typeof propertiesValue === "object" && !Array.isArray(propertiesValue);
787
+
788
+ // Pure union root (anyOf/oneOf with no own properties): push `i` into each
789
+ // alternative branch so each closed shape keeps `additionalProperties: false`
790
+ // honest with intent tracing. Adding a sibling root `properties: { i }` /
791
+ // `required: [i]` would force every input to satisfy both root *and* a
792
+ // branch, leaving no satisfiable shape because each branch's
793
+ // `additionalProperties: false` rejects every other field — and OpenAI
794
+ // strict sanitization later promotes that sibling to a closed root
795
+ // `type: "object"` that rejects every non-`i` key outright. allOf is not
796
+ // alternation (its members are sub-constraints), so we don't recurse into it.
797
+ if (!hasOwnProperties) {
798
+ for (const key of INTENT_SCHEMA_UNION_KEYS) {
799
+ const variants = schemaRecord[key];
800
+ if (!Array.isArray(variants)) continue;
801
+ return {
802
+ ...schemaRecord,
803
+ [key]: variants.map(variant => injectIntentIntoSchema(variant, mode, describeIntent)),
804
+ };
805
+ }
806
+ }
807
+
808
+ const properties = hasOwnProperties ? (propertiesValue as Record<string, unknown>) : {};
809
+ const requiredValue = schemaRecord.required;
810
+ const required = Array.isArray(requiredValue)
811
+ ? requiredValue.filter((item): item is string => typeof item === "string")
812
+ : [];
813
+ if (INTENT_FIELD in properties) {
814
+ const { [INTENT_FIELD]: intentProp, ...rest } = properties;
815
+ const needsReorder = Object.keys(properties)[0] !== INTENT_FIELD;
816
+ const needsRequired = mode === "require" && !required.includes(INTENT_FIELD);
817
+ if (!needsReorder && !needsRequired) return schema;
818
+ return {
819
+ ...schemaRecord,
820
+ ...(needsReorder ? { properties: { [INTENT_FIELD]: intentProp, ...rest } } : {}),
821
+ ...(needsRequired ? { required: [...required, INTENT_FIELD] } : {}),
822
+ };
823
+ }
824
+ return {
825
+ ...schemaRecord,
826
+ properties: {
827
+ [INTENT_FIELD]: describeIntent
828
+ ? { type: "string", description: INTENT_FIELD_DESCRIPTION }
829
+ : { type: "string" },
830
+ ...properties,
831
+ },
832
+ ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
833
+ };
834
+ }
835
+
836
+ export interface NormalizeToolsOptions {
837
+ /** Inject the `i` intent field into tool schemas (subject to `PI_NO_INTENT`). */
838
+ injectIntent: boolean;
839
+ /** Strip descriptions from the wire specs when the catalog rides in the system prompt. */
840
+ pruneDescriptions?: boolean;
841
+ }
842
+
843
+ export function normalizeTools(tools: AgentContext["tools"], options: NormalizeToolsOptions): Context["tools"] {
844
+ const pruneDescriptions = options.pruneDescriptions === true;
845
+ const injectIntent = options.injectIntent && Bun.env.PI_NO_INTENT !== "1";
846
+ return tools?.map(t => {
847
+ const intentMode = resolveIntentMode(t.intent);
848
+ const doInjectIntent = injectIntent && intentMode !== "omit";
849
+ // When the full catalog is rendered into the system prompt, ship the tool
850
+ // specs without their descriptions (top-level + nested schema annotations)
851
+ // so they are not duplicated on the wire. Strip the STABLE wire schema (the
852
+ // memoized `stripSchemaDescriptions` result is reused across requests), then
853
+ // re-inject `i` (without its hint, which `describeIntent: false` omits) so
854
+ // intent tracing keeps the field while no descriptions ride the wire.
855
+ if (pruneDescriptions) {
856
+ let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
857
+ if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
858
+ return { ...t, parameters, description: "" };
859
+ }
860
+ let parameters = toolWireSchema(t) as TSchema;
861
+ if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
862
+ const description = t.description ?? "";
863
+ const examplesBlock = renderToolExamples({ ...t, parameters }, doInjectIntent ? INTENT_FIELD : undefined);
864
+ const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
865
+ return { ...t, parameters, description: finalDescription };
866
+ });
867
+ }
868
+
869
+ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional" | "omit" {
870
+ if (typeof intent === "function") return "omit";
871
+ if (intent === "optional" || intent === "omit") return intent;
872
+ return "require";
873
+ }
874
+
875
+ function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
876
+ const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
877
+ if (typeof intent !== "string") {
878
+ return { strippedArgs };
879
+ }
880
+ const trimmed = intent.trim();
881
+ return { intent: trimmed.length > 0 ? trimmed : undefined, strippedArgs };
882
+ }
883
+
884
+ /**
885
+ * Main loop logic shared by agentLoop and agentLoopContinue.
886
+ */
887
+ async function runLoop(
888
+ currentContext: AgentContext,
889
+ newMessages: AgentMessage[],
890
+ config: AgentLoopConfig,
891
+ signal: AbortSignal | undefined,
892
+ stream: EventStream<AgentEvent, AgentMessage[]>,
893
+ streamFn?: StreamFn,
894
+ initialMessages: AgentMessage[] = [],
895
+ ): Promise<void> {
896
+ const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
897
+ const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
898
+ const stepCounter = { count: 0 };
899
+ let caughtError: unknown;
900
+ try {
901
+ await runInActiveSpan(invokeAgentSpan, () =>
902
+ runLoopBody(
903
+ currentContext,
904
+ newMessages,
905
+ config,
906
+ signal,
907
+ stream,
908
+ telemetry,
909
+ invokeAgentSpan,
910
+ stepCounter,
911
+ initialMessages,
912
+ streamFn,
913
+ ),
914
+ );
915
+ } catch (err) {
916
+ caughtError = err;
917
+ throw err;
918
+ } finally {
919
+ finishInvokeAgentSpan(telemetry, invokeAgentSpan, {
920
+ stepCount: stepCounter.count,
921
+ errorObject: caughtError,
922
+ });
923
+ }
924
+ }
925
+
926
+ interface StepCounter {
927
+ count: number;
928
+ }
929
+
930
+ function isDeadlineExceeded(deadline: number | undefined): boolean {
931
+ return deadline !== undefined && Date.now() >= deadline;
932
+ }
933
+
934
+ function endAgentStream(
935
+ stream: EventStream<AgentEvent, AgentMessage[]>,
936
+ newMessages: AgentMessage[],
937
+ telemetry: AgentTelemetry | undefined,
938
+ stepCount: number,
939
+ ): void {
940
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
941
+ stream.end(newMessages);
942
+ }
943
+ function emitInputMessages(stream: EventStream<AgentEvent, AgentMessage[]>, messages: readonly AgentMessage[]): void {
944
+ for (const message of messages) {
945
+ stream.push({ type: "message_start", message });
946
+ stream.push({ type: "message_end", message });
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Resolve aside entries at the moment the loop is about to inject them. Each entry
952
+ * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
953
+ * producer can make the final inject-or-drop decision (return null) against
954
+ * up-to-the-injection state — e.g. dropping late diagnostics a newer edit
955
+ * superseded. Kept sync so it can never stall the loop.
956
+ */
957
+ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
958
+ if (!entries || entries.length === 0) return [];
959
+ const out: AgentMessage[] = [];
960
+ try {
961
+ for (const entry of entries) {
962
+ const message = typeof entry === "function" ? entry() : entry;
963
+ if (message) out.push(message);
964
+ }
965
+ } catch (error) {
966
+ discardAsides(out, error instanceof Error ? error : new Error(String(error)));
967
+ throw error;
968
+ }
969
+ return out;
970
+ }
971
+
972
+ function discardAsides(messages: readonly AgentMessage[], error: Error): void {
973
+ for (const message of messages) {
974
+ (message as CommittableAsideMessage)[ASIDE_MESSAGE_DISCARD]?.(error);
975
+ }
976
+ }
977
+
978
+ async function runLoopBody(
979
+ currentContext: AgentContext,
980
+ newMessages: AgentMessage[],
981
+ config: AgentLoopConfig,
982
+ signal: AbortSignal | undefined,
983
+ stream: EventStream<AgentEvent, AgentMessage[]>,
984
+ telemetry: AgentTelemetry | undefined,
985
+ invokeAgentSpan: Span | undefined,
986
+ stepCounter: StepCounter,
987
+ initialMessages: AgentMessage[],
988
+ streamFn?: StreamFn,
989
+ ): Promise<void> {
990
+ let deadlineTimer: Timer | undefined;
991
+ if (config.deadline !== undefined) {
992
+ const deadlineAbortController = new AbortController();
993
+ const deadlineReason = new DOMException("Deadline exceeded", "TimeoutError");
994
+ const delay = config.deadline - Date.now();
995
+ if (delay <= 0) {
996
+ deadlineAbortController.abort(deadlineReason);
997
+ } else {
998
+ deadlineTimer = setTimeout(() => {
999
+ deadlineAbortController.abort(deadlineReason);
1000
+ }, delay);
1001
+ }
1002
+ signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
1003
+ }
1004
+
1005
+ const softRequirementState = config.softToolRequirementState ?? { escalations: 0 };
1006
+ let preserveSoftRequirementState = false;
1007
+
1008
+ let pendingMessages: AgentMessage[] = [];
1009
+ try {
1010
+ let messagesToEmit = [...initialMessages];
1011
+ if (isDeadlineExceeded(config.deadline)) {
1012
+ emitInputMessages(stream, messagesToEmit);
1013
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1014
+ return;
1015
+ }
1016
+ // Check for steering messages at start (user may have typed while waiting).
1017
+ // Skip when the run is already externally aborted — dequeuing would strand
1018
+ // the messages in a run that is about to die.
1019
+ try {
1020
+ pendingMessages = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1021
+ } catch (error) {
1022
+ stream.push({ type: "turn_start" });
1023
+ emitInputMessages(stream, messagesToEmit);
1024
+ throw error;
1025
+ }
1026
+ let harmonyRetryAttempt = 0;
1027
+ let harmonyTruncateResumeCount = 0;
1028
+ let pausedTurnContinuations = 0;
1029
+
1030
+ // Soft tool requirement lifecycle (reminder then escalation; see SoftToolRequirement).
1031
+ // The host-owned state survives only a gate stop between Agent.prompt calls.
1032
+ // Resolved once per logical turn at the fetch site below and reused across
1033
+ // Harmony-leak re-samples (which re-enter the same turn) so the consuming
1034
+ // getToolChoice is never advanced twice; the flag resets at the message boundary.
1035
+ let hostToolChoice: ToolChoice | undefined;
1036
+ let softRequiredTool: string | undefined;
1037
+ let softSatisfies: SoftToolRequirement["satisfies"];
1038
+ let directiveResolvedForTurn = false;
1039
+ let turnOpen = false;
1040
+
1041
+ // Outer loop: continues when queued follow-up messages arrive after agent would stop
1042
+ while (true) {
1043
+ let hasMoreToolCalls = true;
1044
+
1045
+ // Inner loop: process tool calls and steering messages
1046
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
1047
+ if (isDeadlineExceeded(config.deadline)) {
1048
+ emitInputMessages(stream, messagesToEmit);
1049
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1050
+ return;
1051
+ }
1052
+ // Yield at the top of each iteration to prevent busy-wait when
1053
+ // the agent loop is executing tool calls back-to-back.
1054
+ await yieldIfDue();
1055
+ // Park at the turn boundary while the process-wide pause gate is
1056
+ // engaged (host /pause). An external abort releases the park so a
1057
+ // cancelled run still unwinds while everything else stays frozen.
1058
+ if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(signal);
1059
+
1060
+ // Build the provider-bound context before opening the turn. Queue
1061
+ // messages are added now but their events remain deferred until
1062
+ // provider preparation either succeeds or opens an error turn.
1063
+ const turnMessages = messagesToEmit;
1064
+ messagesToEmit = [];
1065
+ if (pendingMessages.length > 0) {
1066
+ for (const message of pendingMessages) {
1067
+ currentContext.messages.push(message);
1068
+ newMessages.push(message);
1069
+ turnMessages.push(message);
1070
+ (message as CommittableAsideMessage)[ASIDE_MESSAGE_COMMIT]?.();
1071
+ }
1072
+ pendingMessages = [];
1073
+ }
1074
+
1075
+ let preparedProviderCall: PreparedProviderCall;
1076
+ let gateResult: AgentPreModelCallResult;
1077
+ try {
1078
+ if (config.syncContextBeforeModelCall) {
1079
+ await config.syncContextBeforeModelCall(currentContext, signal);
1080
+ }
1081
+
1082
+ if (!directiveResolvedForTurn) {
1083
+ const directive = signal?.aborted ? undefined : config.getToolChoice?.();
1084
+ const softReq = isSoftToolRequirement(directive) ? directive : undefined;
1085
+ hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
1086
+ softRequiredTool = softReq?.toolName;
1087
+ softSatisfies = softReq?.satisfies;
1088
+ const softRequirementId = softRequirementState.id;
1089
+ if (softReq !== undefined) {
1090
+ if (softReq.id !== softRequirementId) {
1091
+ softRequirementState.id = softReq.id;
1092
+ softRequirementState.forcedToolChoice = undefined;
1093
+ softRequirementState.escalations = 0;
1094
+ for (const reminder of softReq.reminder) {
1095
+ currentContext.messages.push(reminder);
1096
+ newMessages.push(reminder);
1097
+ turnMessages.push(reminder);
1098
+ }
1099
+ }
1100
+ } else {
1101
+ softRequirementState.id = undefined;
1102
+ softRequirementState.forcedToolChoice = undefined;
1103
+ softRequirementState.escalations = 0;
1104
+ }
1105
+ directiveResolvedForTurn = true;
1106
+ }
1107
+
1108
+ preparedProviderCall = await prepareProviderCall(currentContext, config, signal);
1109
+ gateResult = (await config.beforeModelCall?.(preparedProviderCall.context, signal)) || undefined;
1110
+ } catch (error) {
1111
+ if (!turnOpen) {
1112
+ stream.push({ type: "turn_start" });
1113
+ emitInputMessages(stream, turnMessages);
1114
+ turnOpen = true;
1115
+ }
1116
+ throw error;
1117
+ }
1118
+ if (config.beforeModelCall && signal?.aborted) {
1119
+ gateResult = { stop: true };
1120
+ }
1121
+ if (gateResult?.stop) {
1122
+ if (gateResult.reason) {
1123
+ logger.debug("Agent loop stopped before the model call", { reason: gateResult.reason });
1124
+ }
1125
+ if (!turnOpen && !signal?.aborted) {
1126
+ try {
1127
+ config.onToolChoiceRejected?.();
1128
+ } catch (error) {
1129
+ stream.push({ type: "turn_start" });
1130
+ emitInputMessages(stream, turnMessages);
1131
+ turnOpen = true;
1132
+ throw error;
1133
+ }
1134
+ }
1135
+ emitInputMessages(stream, turnMessages);
1136
+ if (turnOpen) {
1137
+ const stopMessage = createGateStopMessage(preparedProviderCall.model, gateResult.reason);
1138
+ currentContext.messages.push(stopMessage);
1139
+ newMessages.push(stopMessage);
1140
+ stream.push({ type: "message_start", message: stopMessage });
1141
+ stream.push({ type: "message_end", message: stopMessage });
1142
+ await emitTurnEnd(
1143
+ stream,
1144
+ currentContext,
1145
+ stopMessage,
1146
+ [],
1147
+ config,
1148
+ signal,
1149
+ { willContinue: false },
1150
+ true,
1151
+ );
1152
+ turnOpen = false;
1153
+ }
1154
+ preserveSoftRequirementState = !signal?.aborted;
1155
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1156
+ return;
1157
+ }
1158
+
1159
+ if (!turnOpen) {
1160
+ stream.push({ type: "turn_start" });
1161
+ emitInputMessages(stream, turnMessages);
1162
+ turnOpen = true;
1163
+ }
1164
+
1165
+ // Stream assistant response
1166
+ let recovered: HarmonyRecoveredToolCall | undefined;
1167
+ let message: AssistantMessage;
1168
+ try {
1169
+ message = await streamAssistantResponse(
1170
+ currentContext,
1171
+ config,
1172
+ signal,
1173
+ stream,
1174
+ telemetry,
1175
+ invokeAgentSpan,
1176
+ stepCounter,
1177
+ streamFn,
1178
+ harmonyRetryAttempt,
1179
+ hostToolChoice,
1180
+ softRequirementState.forcedToolChoice,
1181
+ preparedProviderCall,
1182
+ );
1183
+ harmonyRetryAttempt = 0;
1184
+ harmonyTruncateResumeCount = 0;
1185
+ } catch (err) {
1186
+ if (!(err instanceof HarmonyLeakInterruption)) throw err;
1187
+ if (err.recovered) {
1188
+ if (harmonyTruncateResumeCount >= 2) {
1189
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
1190
+ throw new Error(
1191
+ `GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
1192
+ );
1193
+ }
1194
+ harmonyTruncateResumeCount++;
1195
+ recovered = err.recovered;
1196
+ message = recovered.message;
1197
+ await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
1198
+ // A recovered message completes the turn, so the abort-retry counter
1199
+ // resets like the normal success path (the truncate-resume counter
1200
+ // keeps accumulating for its cross-turn cap).
1201
+ harmonyRetryAttempt = 0;
1202
+ } else {
1203
+ if (harmonyRetryAttempt >= 2) {
1204
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
1205
+ throw new Error(
1206
+ `GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
1207
+ );
1208
+ }
1209
+ await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
1210
+ harmonyRetryAttempt++;
1211
+ continue;
1212
+ }
1213
+ }
1214
+ if (recovered) {
1215
+ message = snapshotAssistantMessage(message);
1216
+ currentContext.messages.push(message);
1217
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
1218
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
1219
+ }
1220
+ newMessages.push(message);
1221
+
1222
+ // The escalation choice (if any) applied to the call above; clear it so
1223
+ // only the single escalation turn carries the forced choice.
1224
+ softRequirementState.forcedToolChoice = undefined;
1225
+
1226
+ // A fresh logical turn re-resolves the directive next iteration; a Harmony
1227
+ // retry `continue`s before this line and keeps the cached value.
1228
+ directiveResolvedForTurn = false;
1229
+
1230
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
1231
+ // Create placeholder tool results for any tool calls in the aborted message
1232
+ // This maintains the tool_use/tool_result pairing that the API requires
1233
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
1234
+ // Cursor exec-resolved blocks already have their toolResult buffered
1235
+ // for out-of-band emission; a placeholder aborted result here would
1236
+ // pair a duplicate to the same toolCallId (issue #4348 codex review).
1237
+ const toolCalls = message.content.filter(
1238
+ (c): c is ToolCallContent =>
1239
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
1240
+ );
1241
+ // Provider-built aborted messages (stream error events) carry no
1242
+ // per-tool labels; derive them from a tool-scoped abort signal so
1243
+ // only the matching call is blamed and siblings stay neutral.
1244
+ const scopedAbort = toolScopedAbortReason(signal);
1245
+ const toolCallAbortMessages =
1246
+ message.toolCallAbortMessages ??
1247
+ (scopedAbort ? buildToolCallAbortMessages(message, scopedAbort) : undefined);
1248
+ const toolResults: ToolResultMessage[] = [];
1249
+ for (const toolCall of toolCalls) {
1250
+ const errorMessage = toolCallAbortMessages?.[toolCall.id] ?? message.errorMessage;
1251
+ const result = createAbortedToolResult(toolCall, stream, message.stopReason, errorMessage);
1252
+ currentContext.messages.push(result);
1253
+ newMessages.push(result);
1254
+ toolResults.push(result);
1255
+ // The placeholder result above keeps the API's tool_use/tool_result
1256
+ // pairing intact, but no execute_tool span is started for these
1257
+ // calls. Mirror the run-collector entry directly so the run
1258
+ // summary's tool counters and `coverage.toolsInvoked` reflect
1259
+ // what the user actually saw on the wire.
1260
+ recordSkippedTool(telemetry, {
1261
+ toolCallId: toolCall.id,
1262
+ toolName: toolCall.name,
1263
+ status: message.stopReason === "aborted" ? "aborted" : "error",
1264
+ });
1265
+ }
1266
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
1267
+ turnOpen = false;
1268
+
1269
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1270
+ stream.end(newMessages);
1271
+ return;
1272
+ }
1273
+
1274
+ // Run tools whenever the turn carries tool_use blocks AND was not truncated.
1275
+ // `stop_reason` is provider metadata that never goes back on the wire, so it
1276
+ // does not gate continuation validity: replaying a tool_use turn with the
1277
+ // tool_results appended is accepted whether the turn ended on `tool_use` or
1278
+ // `end_turn` (adaptive/interleaved-thinking Opus routinely emits tool calls
1279
+ // under `end_turn`; verified against the live Anthropic API). The only
1280
+ // continuation hazard is a thinking block carrying a stale/invalid signature,
1281
+ // which `transformMessages` already neutralizes — it strips the signature on
1282
+ // non-`toolUse` turns and the encoder downgrades the unsigned block to text,
1283
+ // which the API accepts. So treat `stop` (end_turn/pause_turn) the same as
1284
+ // `toolUse`. `length` (max_tokens) is the one reason we must NOT run: the
1285
+ // trailing tool_use may be truncated with incomplete arguments — those calls
1286
+ // are abandoned below. (`error`/`aborted` already returned above.)
1287
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
1288
+ // A Cursor exec-channel synthesized `toolCall` block carries
1289
+ // `kCursorExecResolved` because Cursor already executed the tool
1290
+ // server-side (via the bridge) and buffered the result for
1291
+ // out-of-band emission — running it here again would duplicate the
1292
+ // same side-effecting call (issue #4348 review by @chatgpt-codex-connector).
1293
+ const toolCalls = message.content.filter(
1294
+ (c): c is ToolCallContent =>
1295
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
1296
+ );
1297
+ const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
1298
+ hasMoreToolCalls = runnableStop && toolCalls.length > 0;
1299
+
1300
+ const deadlinePassed = isDeadlineExceeded(config.deadline);
1301
+ if (hasMoreToolCalls && deadlinePassed) {
1302
+ hasMoreToolCalls = false;
1303
+ }
1304
+
1305
+ // A turn is compliant ONLY when it calls the required tool and nothing
1306
+ // else — mirroring the forced-tool_choice turn, which can emit only that
1307
+ // tool. A required+detour batch is treated as non-compliant so detour
1308
+ // tools never run side effects while the requirement is still pending.
1309
+ const calledOnlyRequiredTool =
1310
+ softRequiredTool !== undefined &&
1311
+ toolCalls.length > 0 &&
1312
+ toolCalls.every(toolCall => softSatisfies?.(toolCall) ?? toolCall.name === softRequiredTool);
1313
+ const softGateActive =
1314
+ softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
1315
+ const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
1316
+
1317
+ const toolResults: ToolResultMessage[] = [];
1318
+ if (softNonCompliant && softRequiredTool !== undefined) {
1319
+ if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
1320
+ throw new Error(
1321
+ `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
1322
+ );
1323
+ }
1324
+ // A soft-required tool is pending but the model called something else
1325
+ // (or yielded). Do NOT execute the detour — pair each call with a
1326
+ // skipped result and force the required tool next turn. This is the
1327
+ // only turn that changes toolChoice; a model that complies with the
1328
+ // reminder pays no message-cache invalidation. Re-engage so the loop
1329
+ // never yields while the requirement is unmet.
1330
+ for (const toolCall of toolCalls) {
1331
+ const result = createAbortedToolResult(
1332
+ toolCall,
1333
+ stream,
1334
+ "skipped",
1335
+ `Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`,
1336
+ );
1337
+ currentContext.messages.push(result);
1338
+ newMessages.push(result);
1339
+ toolResults.push(result);
1340
+ recordSkippedTool(telemetry, {
1341
+ toolCallId: toolCall.id,
1342
+ toolName: toolCall.name,
1343
+ status: "skipped",
1344
+ });
1345
+ }
1346
+ softRequirementState.forcedToolChoice = { type: "tool", name: softRequiredTool };
1347
+ softRequirementState.escalations++;
1348
+ hasMoreToolCalls = true;
1349
+ } else if (hasMoreToolCalls) {
1350
+ const executionResult = await executeToolCalls(
1351
+ currentContext,
1352
+ message,
1353
+ signal,
1354
+ stream,
1355
+ config,
1356
+ telemetry,
1357
+ invokeAgentSpan,
1358
+ );
1359
+
1360
+ toolResults.push(...executionResult.toolResults);
1361
+
1362
+ for (const result of toolResults) {
1363
+ currentContext.messages.push(result);
1364
+ newMessages.push(result);
1365
+ }
1366
+ } else if (toolCalls.length > 0) {
1367
+ // Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
1368
+ // but left toolCall blocks behind. pair each with a placeholder result.
1369
+ const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
1370
+ const skipErrMsg = deadlinePassed ? "Deadline exceeded" : undefined;
1371
+ for (const toolCall of toolCalls) {
1372
+ const result = createAbortedToolResult(toolCall, stream, skipReason, skipErrMsg);
1373
+ currentContext.messages.push(result);
1374
+ newMessages.push(result);
1375
+ toolResults.push(result);
1376
+ recordSkippedTool(telemetry, {
1377
+ toolCallId: toolCall.id,
1378
+ toolName: toolCall.name,
1379
+ status: deadlinePassed ? "aborted" : "skipped",
1380
+ });
1381
+ }
1382
+ if (message.stopReason === "length" && toolResults.length > 0 && !deadlinePassed) {
1383
+ hasMoreToolCalls = true;
1384
+ }
1385
+ }
1386
+
1387
+ // A tool hook may mark its completed result as terminal (e.g. subagent yield).
1388
+ // Stop before the next provider call without changing external/user abort semantics.
1389
+ if (signal?.reason === TERMINAL_TOOL_RESULT_ABORT_REASON) {
1390
+ hasMoreToolCalls = false;
1391
+ }
1392
+
1393
+ if (toolCalls.length > 0) {
1394
+ pausedTurnContinuations = 0;
1395
+ } else if (
1396
+ !hasMoreToolCalls &&
1397
+ message.stopReason === "stop" &&
1398
+ message.stopDetails?.type === "pause_turn" &&
1399
+ pausedTurnContinuations < MAX_PAUSED_TURN_CONTINUATIONS
1400
+ ) {
1401
+ // Non-terminal stop: the provider ended the response but not the turn
1402
+ // (e.g. Codex `end_turn: false` on a commentary-only progress update).
1403
+ // Re-sample with the assistant message replayed so the model keeps
1404
+ // working; the next round folds steering/asides in like any other
1405
+ // mid-work turn.
1406
+ pausedTurnContinuations++;
1407
+ hasMoreToolCalls = true;
1408
+ }
1409
+
1410
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1411
+ willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1412
+ });
1413
+ turnOpen = false;
1414
+
1415
+ if (isDeadlineExceeded(config.deadline)) {
1416
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1417
+ return;
1418
+ }
1419
+ // On external abort (user interrupt), leave the steering queue intact: the
1420
+ // session aborts then continues, delivering the queue into a fresh run.
1421
+ // Draining it here would inject the messages right before a model call that
1422
+ // instantly aborts — message lands in history, agent never responds. The
1423
+ // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1424
+ // still owns every message until this dequeue.
1425
+ const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1426
+ if (hasMoreToolCalls) {
1427
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1428
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1429
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1430
+ } else {
1431
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1432
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1433
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1434
+ pendingMessages = steering;
1435
+ }
1436
+ }
1437
+
1438
+ if (isDeadlineExceeded(config.deadline)) {
1439
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1440
+ return;
1441
+ }
1442
+
1443
+ // Agent would stop here. Drain non-interrupting asides + follow-up messages.
1444
+ await config.onBeforeYield?.();
1445
+
1446
+ if (isDeadlineExceeded(config.deadline)) {
1447
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1448
+ return;
1449
+ }
1450
+ // Skip queue drains when externally aborted (same stranding hazard as above).
1451
+ // Re-poll steering too: a steer can land between the stop-boundary dequeue
1452
+ // above and this yield point (e.g. queued while onBeforeYield ran). Without
1453
+ // this poll it would strand in the queue until the next manual prompt.
1454
+ const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.(signal)) || [];
1455
+ const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1456
+ const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.(signal)) || [];
1457
+ if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
1458
+ // Set as pending so the inner loop processes them before stopping.
1459
+ pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages];
1460
+ continue;
1461
+ }
1462
+
1463
+ // No more messages, exit
1464
+ break;
1465
+ }
1466
+
1467
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1468
+ } finally {
1469
+ discardAsides(pendingMessages, new Error("Aside message was not committed before the agent loop ended"));
1470
+ if (!preserveSoftRequirementState) {
1471
+ softRequirementState.id = undefined;
1472
+ softRequirementState.forcedToolChoice = undefined;
1473
+ softRequirementState.escalations = 0;
1474
+ }
1475
+ if (deadlineTimer) {
1476
+ clearTimeout(deadlineTimer);
1477
+ }
1478
+ }
1479
+ }
1480
+
1481
+ async function emitHarmonyAudit(
1482
+ config: AgentLoopConfig,
1483
+ interruption: HarmonyLeakInterruption,
1484
+ action: "truncate_resume" | "abort_retry" | "escalated",
1485
+ retryN: number,
1486
+ ): Promise<void> {
1487
+ await config.onHarmonyLeak?.(
1488
+ createHarmonyAuditEvent({
1489
+ action,
1490
+ detection: interruption.detection,
1491
+ model: config.getModel?.() ?? config.model,
1492
+ retryN,
1493
+ removed: interruption.removed,
1494
+ }),
1495
+ );
1496
+ }
1497
+
1498
+ interface PreparedProviderCall {
1499
+ model: Model;
1500
+ context: Context;
1501
+ promptToolWireTools: Context["tools"];
1502
+ ownedDialect: Dialect | undefined;
1503
+ }
1504
+
1505
+ async function prepareProviderCall(
1506
+ context: AgentContext,
1507
+ config: AgentLoopConfig,
1508
+ signal: AbortSignal | undefined,
1509
+ ): Promise<PreparedProviderCall> {
1510
+ const model = config.getModel?.() ?? config.model;
1511
+ let messages = context.messages;
1512
+ if (config.transformContext) {
1513
+ messages = await config.transformContext(messages, signal);
1514
+ }
1515
+
1516
+ const llmMessages = await config.convertToLlm(messages);
1517
+ const normalizedMessages = normalizeMessagesForProvider(llmMessages, model);
1518
+ const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1519
+ const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1520
+ let llmContext: Context;
1521
+ if (config.appendOnlyContext) {
1522
+ config.appendOnlyContext.syncMessages(normalizedMessages);
1523
+ llmContext = config.appendOnlyContext.build(context, {
1524
+ intentTracing: !!config.intentTracing,
1525
+ pruneToolDescriptions,
1526
+ });
1527
+ } else {
1528
+ llmContext = {
1529
+ systemPrompt: context.systemPrompt,
1530
+ messages: normalizedMessages,
1531
+ tools: normalizeTools(context.tools, {
1532
+ injectIntent: !!config.intentTracing,
1533
+ pruneDescriptions: pruneToolDescriptions,
1534
+ }),
1535
+ };
1536
+ }
1537
+ if (config.transformProviderContext) {
1538
+ llmContext = await config.transformProviderContext(llmContext, model);
1539
+ }
1540
+
1541
+ let promptToolWireTools: Context["tools"];
1542
+ if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
1543
+ promptToolWireTools = llmContext.tools;
1544
+ llmContext = {
1545
+ ...llmContext,
1546
+ systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedDialect)],
1547
+ messages: encodeInbandToolHistory(llmContext.messages, ownedDialect, promptToolWireTools),
1548
+ tools: undefined,
1549
+ };
1550
+ }
1551
+ return { model, context: llmContext, promptToolWireTools, ownedDialect };
1552
+ }
1553
+
1554
+ /**
1555
+ * Stream an assistant response from the LLM.
1556
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1557
+ */
1558
+ async function streamAssistantResponse(
1559
+ context: AgentContext,
1560
+ config: AgentLoopConfig,
1561
+ signal: AbortSignal | undefined,
1562
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1563
+ telemetry: AgentTelemetry | undefined,
1564
+ invokeAgentSpan: Span | undefined,
1565
+ stepCounter: StepCounter,
1566
+ streamFn?: StreamFn,
1567
+ harmonyRetryAttempt = 0,
1568
+ hostToolChoice?: ToolChoice,
1569
+ forcedToolChoice?: ToolChoice,
1570
+ prepared?: PreparedProviderCall,
1571
+ ): Promise<AssistantMessage> {
1572
+ const providerCall = prepared ?? (await prepareProviderCall(context, config, signal));
1573
+ const { model, context: llmContext, promptToolWireTools, ownedDialect } = providerCall;
1574
+
1575
+ const streamFunction = streamFn || streamSimple;
1576
+
1577
+ const dynamicReasoning = config.getReasoning?.();
1578
+ const dynamicDisableReasoning = config.getDisableReasoning?.();
1579
+ // `getServiceTier` is authoritative when present (replaces the static tier
1580
+ // for both the wire request and telemetry), so callers can scope priority
1581
+ // per model without touching the shared session `serviceTier`.
1582
+ const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(model) : config.serviceTier;
1583
+ const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(model);
1584
+ const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1585
+ const requestSignal = harmonyAbortController
1586
+ ? signal
1587
+ ? AbortSignal.any([signal, harmonyAbortController.signal])
1588
+ : harmonyAbortController.signal
1589
+ : signal;
1590
+ // Owned tool calling: aborted by the stream wrapper when the model starts
1591
+ // fabricating a `<tool_response>`, so the provider stops generating the rest of
1592
+ // the hallucinated turn. Merged into the provider signal ONLY (not
1593
+ // `requestSignal`), so it cancels the request without tripping the loop's
1594
+ // external-abort handling (`abortRacePromise` / `requestSignal.aborted`).
1595
+ const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
1596
+ const providerAbortSignals: AbortSignal[] = [];
1597
+ if (requestSignal) providerAbortSignals.push(requestSignal);
1598
+ if (promptToolAbortController) providerAbortSignals.push(promptToolAbortController.signal);
1599
+ const finalRequestSignal =
1600
+ providerAbortSignals.length === 0
1601
+ ? undefined
1602
+ : providerAbortSignals.length === 1
1603
+ ? providerAbortSignals[0]!
1604
+ : AbortSignal.any(providerAbortSignals);
1605
+ const requestApiKey = (config.getApiKey ? await config.getApiKey(model) : undefined) ?? config.apiKey;
1606
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1607
+ const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1608
+
1609
+ // Re-resolve metadata after credential selection so the per-request value
1610
+ // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
1611
+ const resolvedMetadata = config.metadataResolver ? config.metadataResolver(model.provider) : config.metadata;
1612
+ const effectiveTemperature =
1613
+ harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1614
+ // Owned tool calling sends no native tools, so any tool_choice would error.
1615
+ const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1616
+ const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1617
+ const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1618
+ // `getCwd` is read once per LLM call so a mid-run session move (`/move`) reaches
1619
+ // workspace-scoped provider discovery; falls back to the static `cwd` when unset.
1620
+ const effectiveCwd = config.getCwd?.() ?? config.cwd;
1621
+
1622
+ const chatStepNumber = stepCounter.count;
1623
+ stepCounter.count += 1;
1624
+ const chatSpan = startChatSpan(telemetry, model, {
1625
+ parent: invokeAgentSpan,
1626
+ stepNumber: chatStepNumber,
1627
+ request: {
1628
+ maxTokens: config.maxTokens,
1629
+ temperature: effectiveTemperature,
1630
+ topP: config.topP,
1631
+ topK: config.topK,
1632
+ presencePenalty: config.presencePenalty,
1633
+ serviceTier: effectiveServiceTier,
1634
+ reasoningEffort: typeof effectiveReasoning === "string" ? effectiveReasoning : undefined,
1635
+ toolChoice: effectiveToolChoice,
1636
+ tools: llmContext.tools,
1637
+ systemPrompt: llmContext.systemPrompt,
1638
+ messages: llmContext.messages,
1639
+ },
1640
+ });
1641
+
1642
+ // Wrap the user-supplied onResponse so we always observe response headers
1643
+ // for telemetry (`ChatUsageEvent.headers`, gateway auto-detection) without
1644
+ // stealing them from the configured hook.
1645
+ let capturedHeaders: Readonly<Record<string, string>> | undefined;
1646
+ const userOnResponse = config.onResponse;
1647
+ const captureOnResponse: AgentLoopConfig["onResponse"] = (response, modelInfo) => {
1648
+ capturedHeaders = response.headers;
1649
+ return userOnResponse?.(response, modelInfo);
1650
+ };
1651
+
1652
+ const finishChat = async (message: AssistantMessage): Promise<void> => {
1653
+ await finishChatSpan(telemetry, chatSpan, message, {
1654
+ stepNumber: chatStepNumber,
1655
+ serviceTier: effectiveServiceTier,
1656
+ responseHeaders: capturedHeaders,
1657
+ baseUrl: model.baseUrl,
1658
+ });
1659
+ };
1660
+
1661
+ try {
1662
+ return await runInActiveSpan(chatSpan, async () => {
1663
+ let response = await streamFunction(model, llmContext, {
1664
+ ...config,
1665
+ apiKey,
1666
+ metadata: resolvedMetadata,
1667
+ toolChoice: effectiveToolChoice,
1668
+ reasoning: effectiveReasoning,
1669
+ disableReasoning: effectiveDisableReasoning,
1670
+ temperature: effectiveTemperature,
1671
+ serviceTier: effectiveServiceTier,
1672
+ cwd: effectiveCwd,
1673
+ signal: finalRequestSignal,
1674
+ onResponse: captureOnResponse,
1675
+ });
1676
+ if (promptToolWireTools && ownedDialect) {
1677
+ // Re-materialize in-band tool-call text as native toolCall content blocks
1678
+ // so the rest of the loop executes them unchanged. When the model starts
1679
+ // fabricating tool results, the abort callback cancels the provider — unless
1680
+ // `abortOnFabricatedToolResult` is false, in which case the stream drains and
1681
+ // the fabricated continuation is discarded without aborting.
1682
+ response = wrapInbandToolStream(
1683
+ response,
1684
+ promptToolWireTools,
1685
+ ownedDialect,
1686
+ () => promptToolAbortController?.abort(),
1687
+ config.abortOnFabricatedToolResult ?? true,
1688
+ );
1689
+ }
1690
+
1691
+ let partialMessage: AssistantMessage | null = null;
1692
+ let addedPartial = false;
1693
+ const completedToolCallIds = new Set<string>();
1694
+
1695
+ const responseIterator = response[Symbol.asyncIterator]();
1696
+ const finishAbortedStream = async (): Promise<AssistantMessage> => {
1697
+ try {
1698
+ const cleanup = responseIterator.return?.();
1699
+ if (cleanup) void cleanup.catch(() => {});
1700
+ } catch {
1701
+ // Provider cancellation failures cannot change the committed aborted message.
1702
+ }
1703
+ const aborted = emitAbortedAssistantMessage(
1704
+ partialMessage,
1705
+ addedPartial,
1706
+ completedToolCallIds,
1707
+ context,
1708
+ config,
1709
+ stream,
1710
+ requestSignal,
1711
+ );
1712
+ await finishChat(aborted);
1713
+ return aborted;
1714
+ };
1715
+
1716
+ // Set up a single abort race: register the abort listener once for the whole
1717
+ // stream and reuse the same race promise for every iterator.next() instead of
1718
+ // allocating Promise.withResolvers and add/removeEventListener per event.
1719
+ let abortRacePromise: Promise<typeof ABORTED> | undefined;
1720
+ let detachAbortListener: (() => void) | undefined;
1721
+ if (requestSignal) {
1722
+ if (requestSignal.aborted) {
1723
+ return await finishAbortedStream();
1724
+ }
1725
+ const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
1726
+ const onAbort = () => resolve(ABORTED);
1727
+ requestSignal.addEventListener("abort", onAbort, { once: true });
1728
+ abortRacePromise = promise;
1729
+ detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
1730
+ }
1731
+
1732
+ try {
1733
+ while (true) {
1734
+ let next: IteratorResult<AssistantMessageEvent>;
1735
+ if (abortRacePromise) {
1736
+ const result = await Promise.race([responseIterator.next(), abortRacePromise]);
1737
+ if (result === ABORTED) {
1738
+ return await finishAbortedStream();
1739
+ }
1740
+ next = result;
1741
+ } else {
1742
+ next = await responseIterator.next();
1743
+ }
1744
+ if (next.done) break;
1745
+
1746
+ const event = next.value;
1747
+ if (event.type === "done" || event.type === "error") {
1748
+ let finalMessage = recoverTransientErrorToolTurn(
1749
+ retainCompletedToolCalls(await response.result(), completedToolCallIds),
1750
+ context.tools ?? [],
1751
+ );
1752
+ if (harmonyMitigationEnabled) {
1753
+ const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
1754
+ if (detection) {
1755
+ const recovered = recoverHarmonyToolCall(finalMessage, detection);
1756
+ const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
1757
+ if (addedPartial) {
1758
+ emitDiscardedHarmonyPartial(
1759
+ partialMessage,
1760
+ stream,
1761
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
1762
+ );
1763
+ context.messages.pop();
1764
+ addedPartial = false;
1765
+ }
1766
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
1767
+ }
1768
+ }
1769
+ finalMessage = snapshotAssistantMessage(finalMessage);
1770
+ // Expand inline macros (and any other registered rewrite) on the
1771
+ // finalized message before it reaches the context, the UI, or tool
1772
+ // dispatch — so a single mutation is the source of truth for all three.
1773
+ if (config.transformAssistantMessage) {
1774
+ await config.transformAssistantMessage(finalMessage, requestSignal);
1775
+ }
1776
+ // Prepare tool dispatch (validation + the `beforeToolCall` hook)
1777
+ // BEFORE the message is snapshotted for consumers: a hook args
1778
+ // revision is written back into this message's toolCall blocks,
1779
+ // so history, the UI, persistence, provider replay, scheduling,
1780
+ // and execution all carry the revised arguments.
1781
+ if (finalMessage.content.some(c => c.type === "toolCall")) {
1782
+ preparedDispatchByMessage.set(
1783
+ finalMessage,
1784
+ await prepareToolCallDispatch(finalMessage, context, config, requestSignal),
1785
+ );
1786
+ }
1787
+ if (addedPartial) {
1788
+ context.messages[context.messages.length - 1] = finalMessage;
1789
+ } else {
1790
+ context.messages.push(finalMessage);
1791
+ }
1792
+ if (!addedPartial) {
1793
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
1794
+ }
1795
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
1796
+ await finishChat(finalMessage);
1797
+ return finalMessage;
1798
+ }
1799
+ if (requestSignal?.aborted) {
1800
+ return await finishAbortedStream();
1801
+ }
1802
+
1803
+ // Yield to the event loop periodically to prevent busy-wait
1804
+ // when the LLM is streaming chunks faster than the loop can rest.
1805
+ await yieldIfDue();
1806
+
1807
+ switch (event.type) {
1808
+ case "start":
1809
+ partialMessage = event.partial;
1810
+ if (addedPartial) {
1811
+ context.messages[context.messages.length - 1] = partialMessage;
1812
+ completedToolCallIds.clear();
1813
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1814
+ // immutable snapshot of the streaming partial: every message_update
1815
+ // consumer treats both as read-only, so cloning the identical partial
1816
+ // twice per delta was pure waste.
1817
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1818
+ stream.push({
1819
+ type: "message_update",
1820
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1821
+ message: messageSnapshot,
1822
+ });
1823
+ } else {
1824
+ context.messages.push(partialMessage);
1825
+ addedPartial = true;
1826
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
1827
+ }
1828
+ break;
1829
+
1830
+ case "text_start":
1831
+ case "text_delta":
1832
+ case "text_end":
1833
+ case "image_end":
1834
+ case "thinking_start":
1835
+ case "thinking_delta":
1836
+ case "thinking_end":
1837
+ case "toolcall_start":
1838
+ case "toolcall_delta":
1839
+ case "toolcall_end":
1840
+ if (partialMessage) {
1841
+ if (event.type === "toolcall_end") {
1842
+ completedToolCallIds.add(event.toolCall.id);
1843
+ }
1844
+ partialMessage = event.partial;
1845
+ context.messages[context.messages.length - 1] = partialMessage;
1846
+ config.onAssistantMessageEvent?.(partialMessage, event);
1847
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1848
+ // immutable snapshot of the streaming partial: every message_update
1849
+ // consumer treats both as read-only, so cloning the identical partial
1850
+ // twice per delta was pure waste.
1851
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1852
+ stream.push({
1853
+ type: "message_update",
1854
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1855
+ message: messageSnapshot,
1856
+ });
1857
+ }
1858
+ break;
1859
+ }
1860
+ }
1861
+ } finally {
1862
+ detachAbortListener?.();
1863
+ }
1864
+
1865
+ let trailing = await response.result();
1866
+ if (harmonyMitigationEnabled) {
1867
+ const detection = detectHarmonyLeakInAssistantMessage(trailing);
1868
+ if (detection) {
1869
+ const recovered = recoverHarmonyToolCall(trailing, detection);
1870
+ const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
1871
+ if (addedPartial) {
1872
+ emitDiscardedHarmonyPartial(
1873
+ partialMessage,
1874
+ stream,
1875
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
1876
+ );
1877
+ context.messages.pop();
1878
+ addedPartial = false;
1879
+ }
1880
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
1881
+ }
1882
+ }
1883
+ trailing = snapshotAssistantMessage(trailing);
1884
+ if (addedPartial) {
1885
+ context.messages[context.messages.length - 1] = trailing;
1886
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
1887
+ }
1888
+ await finishChat(trailing);
1889
+ return trailing;
1890
+ });
1891
+ } catch (err) {
1892
+ failChatSpan(telemetry, chatSpan, {
1893
+ errorObject: err,
1894
+ responseHeaders: capturedHeaders,
1895
+ baseUrl: model.baseUrl,
1896
+ });
1897
+ throw err;
1898
+ }
1899
+ }
1900
+
1901
+ function retainCompletedToolCalls(
1902
+ message: AssistantMessage,
1903
+ completedToolCallIds: ReadonlySet<string>,
1904
+ ): AssistantMessage {
1905
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
1906
+ let droppedIncompleteToolCall = false;
1907
+ const content = message.content.filter(block => {
1908
+ if (block.type !== "toolCall") return true;
1909
+ const keep = completedToolCallIds.has(block.id);
1910
+ if (!keep) droppedIncompleteToolCall = true;
1911
+ return keep;
1912
+ });
1913
+ if (!droppedIncompleteToolCall) return message;
1914
+ return {
1915
+ ...message,
1916
+ content,
1917
+ stopDetails:
1918
+ message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL
1919
+ ? message.stopDetails
1920
+ : {
1921
+ type: STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
1922
+ category: message.stopDetails?.type ?? null,
1923
+ explanation: message.stopDetails?.explanation ?? message.errorMessage ?? null,
1924
+ },
1925
+ };
1926
+ }
1927
+
1928
+ function recoverTransientErrorToolTurn(
1929
+ message: AssistantMessage,
1930
+ availableTools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">>,
1931
+ ): AssistantMessage {
1932
+ if (message.stopReason !== "error") return message;
1933
+ const toolCalls = message.content.filter(block => block.type === "toolCall");
1934
+ if (toolCalls.length === 0) return message;
1935
+ const stopDetailType = message.stopDetails?.type;
1936
+ const stopDetailCategory = message.stopDetails?.category;
1937
+ if (
1938
+ stopDetailType === "refusal" ||
1939
+ stopDetailType === "sensitive" ||
1940
+ stopDetailCategory === "refusal" ||
1941
+ stopDetailCategory === "sensitive"
1942
+ )
1943
+ return message;
1944
+ const availableToolNames = new Set<string>();
1945
+ for (const tool of availableTools) {
1946
+ availableToolNames.add(tool.name);
1947
+ if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
1948
+ }
1949
+ if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
1950
+ const errorText = `${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`;
1951
+ if (
1952
+ !AIError.isStreamReadErrorText(errorText) &&
1953
+ !AIError.isStreamEnvelopeErrorText(errorText) &&
1954
+ !AIError.isTransientStreamParseError(message.errorMessage) &&
1955
+ !AIError.isTransientStreamParseError(message.stopDetails?.explanation)
1956
+ )
1957
+ return message;
1958
+ return {
1959
+ ...message,
1960
+ stopReason: "toolUse",
1961
+ stopDetails:
1962
+ message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL
1963
+ ? message.stopDetails
1964
+ : {
1965
+ type: STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
1966
+ category: message.stopDetails?.type ?? null,
1967
+ explanation: message.stopDetails?.explanation ?? message.errorMessage ?? null,
1968
+ },
1969
+ errorMessage: undefined,
1970
+ errorId: undefined,
1971
+ errorStatus: undefined,
1972
+ };
1973
+ }
1974
+
1975
+ function emitDiscardedHarmonyPartial(
1976
+ partialMessage: AssistantMessage | null,
1977
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1978
+ errorMessage: string,
1979
+ ): void {
1980
+ if (!partialMessage) return;
1981
+ stream.push({
1982
+ type: "message_end",
1983
+ message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
1984
+ });
1985
+ }
1986
+
1987
+ function isStringRecord(value: unknown): value is Record<string, string> {
1988
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1989
+ return Object.values(value).every(child => typeof child === "string");
1990
+ }
1991
+
1992
+ function toolScopedAbortReason(signal: AbortSignal | undefined): ToolScopedAbortReason | undefined {
1993
+ const reason = signal?.reason;
1994
+ if (!reason || typeof reason !== "object") return undefined;
1995
+ if (Reflect.get(reason, "kind") !== "tool-scoped-abort") return undefined;
1996
+ if (typeof Reflect.get(reason, "message") !== "string") return undefined;
1997
+ if (typeof Reflect.get(reason, "defaultToolCallMessage") !== "string") return undefined;
1998
+ return isStringRecord(Reflect.get(reason, "toolCallMessages")) ? reason : undefined;
1999
+ }
2000
+
2001
+ function buildToolCallAbortMessages(
2002
+ message: AssistantMessage,
2003
+ reason: ToolScopedAbortReason,
2004
+ ): Record<string, string> | undefined {
2005
+ let hasToolCall = false;
2006
+ const messages: Record<string, string> = {};
2007
+ for (const block of message.content) {
2008
+ if (block.type !== "toolCall") continue;
2009
+ hasToolCall = true;
2010
+ messages[block.id] = reason.toolCallMessages[block.id] ?? reason.defaultToolCallMessage;
2011
+ }
2012
+ return hasToolCall ? messages : undefined;
2013
+ }
2014
+
2015
+ /** Resolve the human-readable reason an abort carried. A caller that aborts via
2016
+ * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
2017
+ * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
2018
+ * synthesized assistant message's `errorMessage`; a bare `abort()` (whose
2019
+ * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
2020
+ * generic sentinel that downstream renderers treat as "no specific reason". */
2021
+ export function abortReasonText(signal: AbortSignal | undefined): string {
2022
+ const scopedReason = toolScopedAbortReason(signal);
2023
+ if (scopedReason) return scopedReason.message;
2024
+ const reason = signal?.reason;
2025
+ if (typeof reason === "string" && reason.trim().length > 0) return reason;
2026
+ if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) {
2027
+ return reason.message;
2028
+ }
2029
+ return "Request was aborted";
2030
+ }
2031
+
2032
+ function emitAbortedAssistantMessage(
2033
+ partialMessage: AssistantMessage | null,
2034
+ addedPartial: boolean,
2035
+ completedToolCallIds: ReadonlySet<string>,
2036
+ context: AgentContext,
2037
+ config: AgentLoopConfig,
2038
+ stream: EventStream<AgentEvent, AgentMessage[]>,
2039
+ requestSignal: AbortSignal | undefined,
2040
+ ): AssistantMessage {
2041
+ const model = config.getModel?.() ?? config.model;
2042
+ const errorMessage = abortReasonText(requestSignal);
2043
+ const errorId =
2044
+ errorMessage === "Request was aborted"
2045
+ ? AIError.create(AIError.Flag.Abort)
2046
+ : AIError.classify(requestSignal?.reason) || undefined;
2047
+ const base: AssistantMessage = partialMessage
2048
+ ? { ...partialMessage, stopReason: "aborted", errorMessage, errorId }
2049
+ : {
2050
+ role: "assistant",
2051
+ content: [],
2052
+ api: model.api,
2053
+ provider: model.provider,
2054
+ model: model.id,
2055
+ usage: {
2056
+ input: 0,
2057
+ output: 0,
2058
+ cacheRead: 0,
2059
+ cacheWrite: 0,
2060
+ totalTokens: 0,
2061
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2062
+ },
2063
+ stopReason: "aborted",
2064
+ errorMessage,
2065
+ errorId,
2066
+ timestamp: Date.now(),
2067
+ };
2068
+ // Only tool calls that reached `toolcall_end` survive abort/error replay. A
2069
+ // labeled user interrupt still surfaces through `errorMessage`, but partial
2070
+ // tool arguments are unsafe to keep and can carry incomplete provider IDs.
2071
+ const retained = retainCompletedToolCalls(base, completedToolCallIds);
2072
+ const scopedAbort = toolScopedAbortReason(requestSignal);
2073
+ const toolCallAbortMessages = scopedAbort ? buildToolCallAbortMessages(retained, scopedAbort) : undefined;
2074
+ if (toolCallAbortMessages) {
2075
+ retained.toolCallAbortMessages = toolCallAbortMessages;
2076
+ }
2077
+ const abortedMessage = snapshotAssistantMessage(retained);
2078
+ if (addedPartial) {
2079
+ context.messages[context.messages.length - 1] = abortedMessage;
2080
+ } else {
2081
+ context.messages.push(abortedMessage);
2082
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
2083
+ }
2084
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
2085
+ return abortedMessage;
2086
+ }
2087
+
2088
+ /** Per-call outcome of the pre-dispatch prepare phase (validation + `beforeToolCall`). */
2089
+ interface PreparedToolCall {
2090
+ tool: AgentTool<any> | undefined;
2091
+ /** Validated (possibly hook-revised) execution args; raw args when validation failed. */
2092
+ args: Record<string, unknown>;
2093
+ validationErrorMessage?: string;
2094
+ blocked?: boolean;
2095
+ blockReason?: string;
2096
+ prepareError?: unknown;
2097
+ }
2098
+
2099
+ /**
2100
+ * Prepare results computed in the stream-done branch (before `message_start`/
2101
+ * `message_end`) so a `beforeToolCall` args revision is baked into the message
2102
+ * every consumer snapshots. `executeToolCalls` consumes them; a message that
2103
+ * bypassed the streamed path (e.g. Harmony-recovered) is prepared at dispatch
2104
+ * time instead.
2105
+ */
2106
+ const preparedDispatchByMessage = new WeakMap<AssistantMessage, Map<string, PreparedToolCall>>();
2107
+
2108
+ function resolveToolForCall(
2109
+ tools: AgentTool<any>[] | undefined,
2110
+ toolCall: AgentToolCall,
2111
+ resolveFallbackTool: AgentLoopConfig["resolveFallbackTool"],
2112
+ ): AgentTool<any> | undefined {
2113
+ // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
2114
+ // come back under their wire-level name, which may differ from the
2115
+ // harness-internal `name`. Match on either, preferring `name` for
2116
+ // determinism if both somehow collide.
2117
+ return (
2118
+ tools?.find(t => t.name === toolCall.name) ??
2119
+ tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name) ??
2120
+ // Not in the advertised set: let the host route side-transport tools
2121
+ // (e.g. xd:// device mounts) called by their top-level name.
2122
+ resolveFallbackTool?.(toolCall.name)
2123
+ );
2124
+ }
2125
+
2126
+ /**
2127
+ * Pre-dispatch phase for every pending tool call on `assistantMessage`, run in
2128
+ * call order: intent extraction, argument validation, and the `beforeToolCall`
2129
+ * hook. A hook `args` revision is revalidated against the tool schema and
2130
+ * written back to `toolCall.arguments`; run before `message_start`/`message_end`
2131
+ * (the streamed path) that makes the revision the single source of truth —
2132
+ * history, execution events, persistence, provider replay, concurrency
2133
+ * scheduling, and `tool.execute` all agree. Failures are recorded per call and
2134
+ * surfaced by `executeToolCalls` at the record's scheduled slot.
2135
+ */
2136
+ async function prepareToolCallDispatch(
2137
+ assistantMessage: AssistantMessage,
2138
+ context: AgentContext,
2139
+ config: AgentLoopConfig,
2140
+ signal: AbortSignal | undefined,
2141
+ ): Promise<Map<string, PreparedToolCall>> {
2142
+ const { resolveFallbackTool, intentTracing, beforeToolCall } = config;
2143
+ const prepared = new Map<string, PreparedToolCall>();
2144
+ for (const toolCall of assistantMessage.content) {
2145
+ if (toolCall.type !== "toolCall") continue;
2146
+ if ((toolCall as CursorExecResolvedCarrier)[kCursorExecResolved] === true) continue;
2147
+ const tool = resolveToolForCall(context.tools, toolCall, resolveFallbackTool);
2148
+ const entry: PreparedToolCall = { tool, args: toolCall.arguments as Record<string, unknown> };
2149
+ prepared.set(toolCall.id, entry);
2150
+ let argsForExecution = toolCall.arguments as Record<string, unknown>;
2151
+ if (intentTracing) {
2152
+ const { intent, strippedArgs } = extractIntent(toolCall.arguments);
2153
+ argsForExecution = strippedArgs;
2154
+ if (intent) {
2155
+ toolCall.intent = intent;
2156
+ } else if (typeof tool?.intent === "function") {
2157
+ try {
2158
+ const derived = tool.intent(strippedArgs as never)?.trim();
2159
+ if (derived) {
2160
+ toolCall.intent = derived;
2161
+ }
2162
+ } catch {
2163
+ // intent function must never break tool execution
2164
+ }
2165
+ }
2166
+ }
2167
+ const validate = (args: Record<string, unknown>): Record<string, unknown> | undefined => {
2168
+ try {
2169
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
2170
+ return validateToolArguments(tool, { ...toolCall, arguments: args });
2171
+ } catch (validationError) {
2172
+ if (tool?.lenientArgValidation) {
2173
+ const fallback = { ...args };
2174
+ delete fallback.__parseError;
2175
+ delete fallback.__rawJson;
2176
+ return fallback;
2177
+ }
2178
+ entry.args = "__parseError" in args ? { __parseError: args.__parseError } : args;
2179
+ entry.validationErrorMessage =
2180
+ validationError instanceof Error ? validationError.message : String(validationError);
2181
+ return undefined;
2182
+ }
2183
+ };
2184
+ const effectiveArgs = validate(argsForExecution);
2185
+ if (effectiveArgs === undefined) continue;
2186
+ entry.args = effectiveArgs;
2187
+ if (!beforeToolCall || !tool) continue;
2188
+ let beforeResult: BeforeToolCallResult | undefined;
2189
+ try {
2190
+ beforeResult = await beforeToolCall(
2191
+ { assistantMessage, toolCall, tool, args: effectiveArgs, context },
2192
+ signal,
2193
+ );
2194
+ } catch (e) {
2195
+ // Contract: a throwing hook surfaces as a tool-error result without
2196
+ // aborting the batch — rethrown inside the execution span in runTool.
2197
+ entry.prepareError = e;
2198
+ continue;
2199
+ }
2200
+ if (beforeResult?.block) {
2201
+ entry.blocked = true;
2202
+ entry.blockReason = beforeResult.reason;
2203
+ continue;
2204
+ }
2205
+ if (beforeResult?.args !== undefined) {
2206
+ // Revalidate: a hook revision is untrusted input to the tool schema.
2207
+ const revised = validate(beforeResult.args);
2208
+ if (revised === undefined) continue;
2209
+ // Bake the revision into the message itself. On the streamed path this
2210
+ // precedes every consumer snapshot, so there is exactly one version of
2211
+ // the call anywhere downstream.
2212
+ toolCall.arguments = beforeResult.args;
2213
+ entry.args = revised;
2214
+ }
2215
+ }
2216
+ return prepared;
2217
+ }
2218
+ /**
2219
+ * Execute tool calls from an assistant message.
2220
+ */
2221
+ async function executeToolCalls(
2222
+ currentContext: AgentContext,
2223
+ assistantMessage: AssistantMessage,
2224
+ signal: AbortSignal | undefined,
2225
+ stream: EventStream<AgentEvent, AgentMessage[]>,
2226
+ config: AgentLoopConfig,
2227
+ telemetry: AgentTelemetry | undefined,
2228
+ invokeAgentSpan: Span | undefined,
2229
+ ): Promise<{ toolResults: ToolResultMessage[] }> {
2230
+ const tools = currentContext.tools;
2231
+ const {
2232
+ hasSteeringMessages,
2233
+ hasIrcInterrupts,
2234
+ interruptMode = "immediate",
2235
+ getToolContext,
2236
+ transformToolCallArguments,
2237
+ resolveFallbackTool,
2238
+ afterToolCall,
2239
+ } = config;
2240
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
2241
+ // Defensive: the outer loop already filters exec-resolved blocks before
2242
+ // deciding to invoke `executeToolCalls`, but skip them here too so the
2243
+ // guarantee lives with the code that would re-run the tool.
2244
+ const toolCalls = assistantMessage.content.filter(
2245
+ (c): c is ToolCallContent =>
2246
+ c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
2247
+ );
2248
+ const emittedToolResults: ToolResultMessage[] = [];
2249
+ const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
2250
+ const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
2251
+ const shouldInterruptImmediately = interruptMode !== "wait";
2252
+ const steeringAbortController = new AbortController();
2253
+ const ircAbortController = new AbortController();
2254
+ // Cooperative channel: aborted when queued steering (or an interrupting
2255
+ // peer IRC) is detected mid-batch. Tools receive it via tool context
2256
+ // (`ctx.steeringSignal`) and MAY react — e.g. an auto-backgroundable bash
2257
+ // backgrounds itself so the message injects promptly — but it never kills
2258
+ // anything; ignoring it is always safe.
2259
+ const steeringSoftController = new AbortController();
2260
+ // Interruptible tools (pure waits: hub wait, vibe) observe steering +
2261
+ // external + IRC aborts. Every other tool sees ONLY the external signal:
2262
+ // neither queued steering nor a peer IRC ever hard-kills a partially
2263
+ // side-effecting foreground tool (e.g. `bash`) — those get the cooperative
2264
+ // `steeringSignal` above, and the message injects at the next boundary.
2265
+ const nonInterruptibleSignal: AbortSignal = signal ?? new AbortController().signal;
2266
+ const interruptibleSignal: AbortSignal = signal
2267
+ ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
2268
+ : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
2269
+ const interruptState: { triggered: boolean; source?: SteeringInterruptSource | "irc" } = { triggered: false };
2270
+
2271
+ // Streamed messages were prepared (validation + `beforeToolCall`) before
2272
+ // `message_end`, so hook revisions are already part of the message; anything
2273
+ // that bypassed the streamed path is prepared here instead.
2274
+ const preparedDispatch =
2275
+ preparedDispatchByMessage.get(assistantMessage) ??
2276
+ (await prepareToolCallDispatch(assistantMessage, currentContext, config, signal));
2277
+
2278
+ const records = toolCalls.map(toolCall => {
2279
+ const prepared = preparedDispatch.get(toolCall.id) ?? {
2280
+ tool: resolveToolForCall(tools, toolCall, resolveFallbackTool),
2281
+ args: toolCall.arguments as Record<string, unknown>,
2282
+ };
2283
+ const { tool, args } = prepared;
2284
+ const interruptibleMode = tool?.interruptible;
2285
+ let interruptible = false;
2286
+ if (typeof interruptibleMode === "function") {
2287
+ try {
2288
+ // Resolved from the prepared (possibly hook-revised) args so an
2289
+ // argument-dependent policy governs the call that actually runs.
2290
+ interruptible = interruptibleMode(args);
2291
+ } catch {
2292
+ // Resolver failures default to preserving the tool's outcome.
2293
+ interruptible = false;
2294
+ }
2295
+ } else {
2296
+ interruptible = interruptibleMode === true;
2297
+ }
2298
+ return {
2299
+ toolCall,
2300
+ tool,
2301
+ args,
2302
+ interruptible,
2303
+ signal: interruptible ? interruptibleSignal : nonInterruptibleSignal,
2304
+ started: false,
2305
+ result: undefined as AgentToolResult<any> | undefined,
2306
+ isError: false,
2307
+ skipped: false,
2308
+ toolResultMessage: undefined as ToolResultMessage | undefined,
2309
+ resultEmitted: false,
2310
+ validationErrorMessage: prepared.validationErrorMessage,
2311
+ blocked: prepared.blocked === true,
2312
+ blockReason: prepared.blockReason,
2313
+ prepareError: prepared.prepareError,
2314
+ };
2315
+ });
2316
+
2317
+ const checkIrcInterrupts = async (): Promise<void> => {
2318
+ // IRC only fires once: a peer interrupt already recorded on interruptState
2319
+ // must not re-abort, and (unlike steering) never re-consumes a queue.
2320
+ if (!shouldInterruptImmediately || signal?.aborted || interruptState.triggered) return;
2321
+ if (hasIrcInterrupts && (await hasIrcInterrupts())) {
2322
+ // Peer IRC hard-aborts interruptible waits only; foreground tools keep
2323
+ // running (no partial side effects) but get the cooperative soft
2324
+ // signal so backgroundable work can step aside for the peer message.
2325
+ interruptState.triggered = true;
2326
+ interruptState.source = "irc";
2327
+ ircAbortController.abort();
2328
+ steeringSoftController.abort();
2329
+ }
2330
+ };
2331
+
2332
+ const checkSteering = async (): Promise<void> => {
2333
+ // `signal` (external/user abort) is checked separately from the internal
2334
+ // abort controllers: once the run is externally aborted it is unwinding
2335
+ // and the interrupt would be redundant.
2336
+ if (!shouldInterruptImmediately || signal?.aborted) {
2337
+ return;
2338
+ }
2339
+ // Mid-batch steering detection must be non-consuming. If a direct
2340
+ // integration only provides getSteeringMessages(), the queue drains at the
2341
+ // injection boundary below; polling it here would strand or drop messages.
2342
+ let steeringQueued = false;
2343
+ let steeringSource: SteeringInterruptSource | undefined;
2344
+ if (hasSteeringMessages) {
2345
+ const queuedState = await hasSteeringMessages();
2346
+ if (typeof queuedState === "boolean") {
2347
+ steeringQueued = queuedState;
2348
+ steeringSource = queuedState ? "user" : undefined;
2349
+ } else {
2350
+ const state: SteeringQueueState = queuedState;
2351
+ steeringQueued = state.queued;
2352
+ steeringSource = state.source ?? (state.queued ? "unknown" : undefined);
2353
+ }
2354
+ }
2355
+ if (steeringQueued) {
2356
+ // Queued steering hard-aborts only interruptible waits and raises the
2357
+ // cooperative soft signal for everything else: the boundary dequeue
2358
+ // below injects the message as soon as running tools finish (or
2359
+ // background themselves), and not-yet-started tools are skipped.
2360
+ // Idempotent — a second steer poll after the abort is a no-op.
2361
+ if (!steeringAbortController.signal.aborted) {
2362
+ interruptState.triggered = true;
2363
+ interruptState.source = steeringSource ?? "unknown";
2364
+ steeringAbortController.abort();
2365
+ steeringSoftController.abort();
2366
+ }
2367
+ return;
2368
+ }
2369
+ await checkIrcInterrupts();
2370
+ };
2371
+
2372
+ const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
2373
+ if (record.resultEmitted) return;
2374
+ const { toolCall } = record;
2375
+ if (!record.started) {
2376
+ stream.push({
2377
+ type: "tool_execution_start",
2378
+ toolCallId: toolCall.id,
2379
+ toolName: toolCall.name,
2380
+ args: record.args,
2381
+ intent: toolCall.intent,
2382
+ });
2383
+ }
2384
+ stream.push({
2385
+ type: "tool_execution_end",
2386
+ toolCallId: toolCall.id,
2387
+ toolName: toolCall.name,
2388
+ result,
2389
+ isError,
2390
+ });
2391
+
2392
+ const toolResultMessage: ToolResultMessage = {
2393
+ role: "toolResult",
2394
+ toolCallId: toolCall.id,
2395
+ toolName: toolCall.name,
2396
+ content: result.content,
2397
+ details: result.details,
2398
+ providerMetadata: result.providerMetadata,
2399
+ isError,
2400
+ ...(result.useless && !isError ? { useless: true } : {}),
2401
+ timestamp: Date.now(),
2402
+ };
2403
+ record.result = result;
2404
+ record.isError = isError;
2405
+ record.toolResultMessage = toolResultMessage;
2406
+ record.resultEmitted = true;
2407
+ emittedToolResults.push(toolResultMessage);
2408
+
2409
+ stream.push({ type: "message_start", message: toolResultMessage });
2410
+ stream.push({ type: "message_end", message: toolResultMessage });
2411
+ };
2412
+
2413
+ const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
2414
+ // A pending interrupt preempts not-yet-started tools so the message
2415
+ // injects promptly. A peer-IRC interrupt is the exception: it aborts
2416
+ // interruptible waits only and leaves non-interruptible foreground work
2417
+ // untouched (see the emit branch below and the `does not abort a
2418
+ // non-interruptible foreground tool` case). That guarantee must hold for
2419
+ // work still queued behind the aborted wait too — otherwise a batched
2420
+ // `todo`/`write` gets dropped as "Skipped due to pending peer interrupt"
2421
+ // purely for being ordered after the wait (#7493). User/system steering
2422
+ // still preempts everything queued.
2423
+ if (interruptState.triggered && (record.interruptible || interruptState.source !== "irc")) {
2424
+ // Skip both span emission and the collector orphan record here. The
2425
+ // tail sweep below (after `Promise.allSettled`) is the single path
2426
+ // that handles "no result message was produced" — it calls
2427
+ // `recordSkippedTool` and `emitToolResult` once per record, so any
2428
+ // work we did here would double-count.
2429
+ record.skipped = true;
2430
+ return;
2431
+ }
2432
+ // Park before starting this tool while the process-wide pause gate is
2433
+ // engaged. Tools already executing are unaffected (pausing never aborts);
2434
+ // a batch interrupted mid-pause unwinds via the signal checks below.
2435
+ if (agentPauseGate.paused) await agentPauseGate.waitUntilResumed(record.signal);
2436
+
2437
+ const { toolCall, tool } = record;
2438
+ // Validation (and the beforeToolCall hook) ran in the prepare phase; a
2439
+ // failure recorded there surfaces here at the record's scheduled slot so
2440
+ // result emission keeps batch order.
2441
+ if (record.validationErrorMessage !== undefined) {
2442
+ emitToolResult(
2443
+ record,
2444
+ {
2445
+ content: [{ type: "text" as const, text: record.validationErrorMessage }],
2446
+ details: { isError: true, error: record.validationErrorMessage },
2447
+ },
2448
+ true,
2449
+ );
2450
+ return;
2451
+ }
2452
+ const effectiveArgs = record.args;
2453
+ if (record.signal.aborted) {
2454
+ record.skipped = true;
2455
+ recordSkippedTool(telemetry, {
2456
+ toolCallId: toolCall.id,
2457
+ toolName: toolCall.name,
2458
+ status: "aborted",
2459
+ });
2460
+ emitToolResult(record, createToolSignalAbortedResult(record.signal), true);
2461
+ return;
2462
+ }
2463
+ record.started = true;
2464
+ stream.push({
2465
+ type: "tool_execution_start",
2466
+ toolCallId: toolCall.id,
2467
+ toolName: toolCall.name,
2468
+ args: effectiveArgs,
2469
+ intent: toolCall.intent,
2470
+ });
2471
+
2472
+ const toolSpan = startExecuteToolSpan(telemetry, {
2473
+ tool,
2474
+ toolName: toolCall.name,
2475
+ toolCallId: toolCall.id,
2476
+ args: effectiveArgs,
2477
+ parent: invokeAgentSpan,
2478
+ });
2479
+ if (toolSpan && toolCall.intent) {
2480
+ toolSpan.setAttribute(PiGenAIAttr.ToolCallIntent, toolCall.intent);
2481
+ }
2482
+
2483
+ let result: AgentToolResult<any> = { content: [], details: {} };
2484
+ let isError = false;
2485
+ let caughtError: unknown;
2486
+ let completedToolExecution = false;
2487
+ let executionStarted = false;
2488
+
2489
+ await runInActiveSpan(toolSpan, async () => {
2490
+ try {
2491
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
2492
+ if (record.signal.aborted) {
2493
+ result = createToolSignalAbortedResult(record.signal);
2494
+ isError = true;
2495
+ return;
2496
+ }
2497
+
2498
+ if (record.prepareError !== undefined) throw record.prepareError;
2499
+ if (record.blocked) {
2500
+ throw new ToolCallBlockedError(record.blockReason);
2501
+ }
2502
+ const executionArgs = transformToolCallArguments
2503
+ ? transformToolCallArguments(effectiveArgs, toolCall.name)
2504
+ : effectiveArgs;
2505
+ record.args = executionArgs;
2506
+
2507
+ // The cooperative steering signal rides the loop-owned
2508
+ // ToolCallContext (surfacing as `ctx.toolCall.steeringSignal`):
2509
+ // AgentToolContext itself is app-built via declaration merging, so
2510
+ // the loop cannot construct or extend one structurally.
2511
+ const toolContext = getToolContext
2512
+ ? getToolContext({
2513
+ batchId,
2514
+ index,
2515
+ total: toolCalls.length,
2516
+ toolCalls: toolCallInfos,
2517
+ steeringSignal: steeringSoftController.signal,
2518
+ providerMetadata: toolCall.providerMetadata,
2519
+ })
2520
+ : undefined;
2521
+ executionStarted = true;
2522
+ const rawResult = await tool.execute(
2523
+ toolCall.id,
2524
+ executionArgs,
2525
+ record.signal,
2526
+ partialResult => {
2527
+ stream.push({
2528
+ type: "tool_execution_update",
2529
+ toolCallId: toolCall.id,
2530
+ toolName: toolCall.name,
2531
+ args: executionArgs,
2532
+ partialResult: coerceToolResult(partialResult).result,
2533
+ });
2534
+ },
2535
+ toolContext,
2536
+ );
2537
+ completedToolExecution = true;
2538
+ const coerced = coerceToolResult(rawResult);
2539
+ result = coerced.result;
2540
+ if (coerced.malformed || result.isError) isError = true;
2541
+ } catch (e) {
2542
+ caughtError = e;
2543
+ result = {
2544
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2545
+ details: {},
2546
+ };
2547
+ isError = true;
2548
+ }
2549
+
2550
+ if (afterToolCall && (!record.signal.aborted || completedToolExecution)) {
2551
+ try {
2552
+ const after = await afterToolCall(
2553
+ {
2554
+ assistantMessage,
2555
+ toolCall,
2556
+ args: record.args,
2557
+ result,
2558
+ isError,
2559
+ context: currentContext,
2560
+ },
2561
+ record.signal,
2562
+ );
2563
+ if (after) {
2564
+ // Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
2565
+ // code and may return malformed `content` (non-array / invalid blocks), which
2566
+ // would otherwise be persisted verbatim and corrupt the session — the same
2567
+ // hazard `coerceToolResult` guards on the execute path.
2568
+ const coerced = coerceToolResult({
2569
+ content: after.content ?? result.content,
2570
+ details: after.details ?? result.details,
2571
+ isError: after.isError ?? result.isError,
2572
+ providerMetadata: after.providerMetadata ?? result.providerMetadata,
2573
+ useless: after.useless ?? result.useless,
2574
+ });
2575
+ result = coerced.result;
2576
+ isError = coerced.malformed || (after.isError ?? isError);
2577
+ }
2578
+ } catch (e) {
2579
+ caughtError = e;
2580
+ result = {
2581
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2582
+ details: {},
2583
+ };
2584
+ isError = true;
2585
+ }
2586
+ }
2587
+ });
2588
+
2589
+ const interrupted = interruptState.triggered;
2590
+ const perToolAborted = record.signal.aborted;
2591
+ const abortedDuringExecution = perToolAborted && isError && !completedToolExecution;
2592
+ if (interrupted && abortedDuringExecution) {
2593
+ // This tool's own signal fired AND it failed to produce a result. The
2594
+ // execution may already have performed partial work before throwing on
2595
+ // abort, so preserve that distinction in the placeholder metadata.
2596
+ record.skipped = true;
2597
+ emitToolResult(record, createSkippedToolResult(interruptState.source, executionStarted), true);
2598
+ } else {
2599
+ // No interrupt on this signal, or the tool finished before the interrupt landed
2600
+ // (`completedToolExecution`) — even if the signal aborted around completion. Keep
2601
+ // its real result: a completed tool already ran its side effects, so the model must
2602
+ // see what actually happened (a genuine non-zero exit / error result) rather than a
2603
+ // false "skipped" that discards work the tool performed (#4752). A peer-IRC interrupt
2604
+ // on the batch leaves non-interruptible tools' signals untouched — their genuine
2605
+ // errors survive here too.
2606
+ emitToolResult(record, result, isError);
2607
+ }
2608
+
2609
+ const firstTextBlock = result.content?.[0];
2610
+ const errorMessageForSpan =
2611
+ caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
2612
+ const status = abortedDuringExecution
2613
+ ? "aborted"
2614
+ : caughtError instanceof ToolCallBlockedError
2615
+ ? "blocked"
2616
+ : isError
2617
+ ? "error"
2618
+ : "ok";
2619
+ finishExecuteToolSpan(telemetry, toolSpan, {
2620
+ result,
2621
+ isError,
2622
+ status,
2623
+ errorMessage: errorMessageForSpan,
2624
+ errorObject: caughtError,
2625
+ toolCallId: toolCall.id,
2626
+ toolName: toolCall.name,
2627
+ });
2628
+
2629
+ await checkSteering();
2630
+ };
2631
+
2632
+ let lastExclusive: Promise<void> = Promise.resolve();
2633
+ let sharedTasks: Promise<void>[] = [];
2634
+ const tasks: Promise<void>[] = [];
2635
+
2636
+ // While tool calls are in flight, queued steering or interrupting IRC would
2637
+ // otherwise wait out the tools' own window. Poll only non-consuming queues:
2638
+ // detection hard-aborts interruptible waits, soft-signals cooperative tools
2639
+ // (auto-background bash), and skips not-yet-started tools, so the boundary
2640
+ // dequeue below injects the message promptly. Gated on immediate-interrupt
2641
+ // mode; checkSteering is idempotent (no-op once triggered).
2642
+ const watchSteeringWhileRunning =
2643
+ shouldInterruptImmediately && (hasSteeringMessages !== undefined || hasIrcInterrupts !== undefined);
2644
+ const eventDrivenSteeringWatch =
2645
+ watchSteeringWhileRunning && config.waitForSteeringMessages !== undefined && hasSteeringMessages !== undefined;
2646
+ const steeringWatchAbortController = new AbortController();
2647
+ const steeringWatchSignal = signal
2648
+ ? AbortSignal.any([signal, steeringWatchAbortController.signal])
2649
+ : steeringWatchAbortController.signal;
2650
+ // Race every wait against one local abort promise. The callback contract does
2651
+ // not require an implementation to observe the signal, and one that resolves
2652
+ // only on the next queue event would otherwise never settle once the batch
2653
+ // finishes, so awaiting it during teardown would hang a batch with no steer.
2654
+ const { promise: watchAborted, resolve: resolveWatchAbort } = Promise.withResolvers<void>();
2655
+ if (steeringWatchSignal.aborted) {
2656
+ resolveWatchAbort();
2657
+ } else {
2658
+ steeringWatchSignal.addEventListener("abort", () => resolveWatchAbort(), { once: true });
2659
+ }
2660
+ const watchAbortedFalse = watchAborted.then(() => false);
2661
+ const steeringWatchPromise = eventDrivenSteeringWatch
2662
+ ? (async (): Promise<void> => {
2663
+ while (!steeringWatchSignal.aborted) {
2664
+ // Subscribe before checking queue state. This closes the edge
2665
+ // race where a steer arrives after a check but before listener
2666
+ // registration: the subsequent check observes queued state,
2667
+ // while later arrivals resolve this already-installed wait.
2668
+ const steeringQueued = config.waitForSteeringMessages?.(steeringWatchSignal).then(
2669
+ () => true,
2670
+ () => false,
2671
+ );
2672
+ const steeringChecked = checkSteering().then(
2673
+ () => true,
2674
+ () => false,
2675
+ );
2676
+ if (!(await Promise.race([steeringChecked, watchAbortedFalse]))) return;
2677
+ if (steeringWatchSignal.aborted || interruptState.triggered) return;
2678
+ if (!(await Promise.race([steeringQueued, watchAbortedFalse]))) return;
2679
+ }
2680
+ })()
2681
+ : undefined;
2682
+ // IRC interrupt records have a separate session-owned queue and no wake
2683
+ // callback. Keep its established timer fallback when that queue is present;
2684
+ // system steering uses the event-driven path above and does not poll.
2685
+ const steeringWatchTimer =
2686
+ watchSteeringWhileRunning && (!eventDrivenSteeringWatch || hasIrcInterrupts !== undefined)
2687
+ ? setInterval(
2688
+ () => void (eventDrivenSteeringWatch ? checkIrcInterrupts() : checkSteering()),
2689
+ STEERING_INTERRUPT_POLL_MS,
2690
+ )
2691
+ : undefined;
2692
+ for (let index = 0; index < records.length; index++) {
2693
+ const record = records[index];
2694
+ const concurrencyMode = record.tool?.concurrency;
2695
+ let concurrency: "shared" | "exclusive";
2696
+ if (typeof concurrencyMode === "function") {
2697
+ // Resolved from the prepared (possibly hook-revised) args — raw args
2698
+ // only when validation failed, and those records error out before
2699
+ // executing. A throwing resolver must not take down the whole batch,
2700
+ // so fall back to the safe (serial) mode.
2701
+ try {
2702
+ concurrency = concurrencyMode(record.args);
2703
+ } catch {
2704
+ concurrency = "exclusive";
2705
+ }
2706
+ } else {
2707
+ concurrency = concurrencyMode ?? "shared";
2708
+ }
2709
+ const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2710
+ const task = start.then(() => runTool(record, index));
2711
+ tasks.push(task);
2712
+ if (concurrency === "exclusive") {
2713
+ lastExclusive = task;
2714
+ sharedTasks = [];
2715
+ } else {
2716
+ sharedTasks.push(task);
2717
+ }
2718
+ }
2719
+ try {
2720
+ await Promise.allSettled(tasks);
2721
+ } finally {
2722
+ steeringWatchAbortController.abort();
2723
+ await steeringWatchPromise?.catch(() => undefined);
2724
+ clearInterval(steeringWatchTimer);
2725
+ }
2726
+ // Yield after batch tool execution to let GC and I/O catch up,
2727
+ // especially when tool results are large (e.g. bash output).
2728
+ await yieldIfDue();
2729
+
2730
+ for (const record of records) {
2731
+ if (!record.toolResultMessage) {
2732
+ record.skipped = true;
2733
+ recordSkippedTool(telemetry, {
2734
+ toolCallId: record.toolCall.id,
2735
+ toolName: record.toolCall.name,
2736
+ status: "skipped",
2737
+ });
2738
+ emitToolResult(record, createSkippedToolResult(interruptState.source, false), true);
2739
+ }
2740
+ }
2741
+
2742
+ return { toolResults: emittedToolResults };
2743
+ }
2744
+
2745
+ /**
2746
+ * Discriminator embedded in {@link AgentToolResult.details} and
2747
+ * {@link ToolResultMessage.details} for tool calls that were emitted by the
2748
+ * assistant but never actually invoked locally.
2749
+ *
2750
+ * The synthetic result exists only to preserve the tool_use / tool_result
2751
+ * pairing the provider API requires; no `tool.execute()` ran. UI, telemetry,
2752
+ * and history consumers can key on `__synthetic === true` to render or
2753
+ * classify these as "call emitted, not executed" instead of a real local
2754
+ * tool failure — the mislabeling this discriminator was introduced to fix
2755
+ * (#4321): a provider-side stream error after tool-call emission (e.g. Codex
2756
+ * websocket close) was surfaced by the CLI as if the local tool had failed.
2757
+ *
2758
+ * `source` names the state that prevented execution — either an assistant-side
2759
+ * turn termination (`assistant_stop_*`) or a mid-batch interrupt that skipped a
2760
+ * still-pending call to service queued steering/peer input (`interrupt_skipped`).
2761
+ * `upstreamError` is the provider-reported message when the turn ended with
2762
+ * `stopReason === "error"`.
2763
+ */
2764
+ export interface SyntheticToolResultDetails {
2765
+ __synthetic: true;
2766
+ source:
2767
+ | "assistant_stop_aborted"
2768
+ | "assistant_stop_error"
2769
+ | "assistant_stop_skipped"
2770
+ | "assistant_stop_length"
2771
+ | "interrupt_skipped";
2772
+ executed: false;
2773
+ upstreamError?: string;
2774
+ }
2775
+
2776
+ /**
2777
+ * Metadata for an interrupt-aborted call that entered `tool.execute()` but
2778
+ * threw before returning a usable result. It may have performed partial work.
2779
+ */
2780
+ interface InterruptedToolResultDetails {
2781
+ __interrupted: true;
2782
+ source: "interrupt_skipped";
2783
+ execution: "started";
2784
+ }
2785
+
2786
+ /**
2787
+ * Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} —
2788
+ * a tool_result emitted for a tool call the assistant never invoked (see
2789
+ * {@link SyntheticToolResultDetails}). Consumers use this to look past the
2790
+ * placeholder pairing back to the assistant turn that produced it, e.g.
2791
+ * `AgentSession.retry()` walking back over the synthetic results a
2792
+ * stalled/aborted mid-tool-call turn leaves behind.
2793
+ */
2794
+ export function isSyntheticToolResultMessage(
2795
+ message: AgentMessage | undefined,
2796
+ ): message is ToolResultMessage<SyntheticToolResultDetails> {
2797
+ return (
2798
+ message?.role === "toolResult" &&
2799
+ (message.details as SyntheticToolResultDetails | undefined)?.__synthetic === true
2800
+ );
2801
+ }
2802
+
2803
+ function syntheticDetailsFor(
2804
+ reason: "aborted" | "error" | "skipped" | "length",
2805
+ errorMessage: string | undefined,
2806
+ ): SyntheticToolResultDetails {
2807
+ const source: SyntheticToolResultDetails["source"] =
2808
+ reason === "aborted"
2809
+ ? "assistant_stop_aborted"
2810
+ : reason === "error"
2811
+ ? "assistant_stop_error"
2812
+ : reason === "length"
2813
+ ? "assistant_stop_length"
2814
+ : "assistant_stop_skipped";
2815
+ return {
2816
+ __synthetic: true,
2817
+ source,
2818
+ executed: false,
2819
+ ...(reason === "error" && errorMessage ? { upstreamError: errorMessage } : {}),
2820
+ };
2821
+ }
2822
+
2823
+ /**
2824
+ * Create the persisted synthetic result for a tool call that was emitted by
2825
+ * the assistant but never invoked locally.
2826
+ */
2827
+ export function createSyntheticToolResultMessage(
2828
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
2829
+ reason: "aborted" | "error" | "skipped" | "length",
2830
+ errorMessage?: string,
2831
+ ): ToolResultMessage<SyntheticToolResultDetails> {
2832
+ const message =
2833
+ reason === "aborted"
2834
+ ? "Tool execution was aborted"
2835
+ : reason === "length"
2836
+ ? "Tool call was not executed because the assistant hit its output token limit (stop_reason: length) before the arguments could complete; the recorded arguments are truncated and unsafe to run. Do NOT retry by re-emitting the same large payload — split the work into several smaller tool calls (e.g. for `write`/`edit`, write the first chunk then append the rest with subsequent `edit` insert ops, or break the file into multiple `write` targets)"
2837
+ : reason === "skipped"
2838
+ ? "Tool call was not executed because the assistant ended its turn"
2839
+ : "Tool call was not executed because the provider stream ended with an error before the tool could run";
2840
+ const details = syntheticDetailsFor(reason, errorMessage);
2841
+ return {
2842
+ role: "toolResult",
2843
+ toolCallId: toolCall.id,
2844
+ toolName: toolCall.name,
2845
+ content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
2846
+ details,
2847
+ isError: true,
2848
+ timestamp: Date.now(),
2849
+ };
2850
+ }
2851
+
2852
+ /**
2853
+ * Create and emit a tool result for a tool call that was emitted by the
2854
+ * assistant but never invoked locally.
2855
+ */
2856
+ function createAbortedToolResult(
2857
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
2858
+ stream: EventStream<AgentEvent, AgentMessage[]>,
2859
+ reason: "aborted" | "error" | "skipped" | "length",
2860
+ errorMessage?: string,
2861
+ ): ToolResultMessage {
2862
+ const toolResultMessage = createSyntheticToolResultMessage(toolCall, reason, errorMessage);
2863
+ const result: AgentToolResult<SyntheticToolResultDetails> = {
2864
+ content: toolResultMessage.content,
2865
+ details: toolResultMessage.details,
2866
+ };
2867
+
2868
+ stream.push({
2869
+ type: "tool_execution_start",
2870
+ toolCallId: toolCall.id,
2871
+ toolName: toolCall.name,
2872
+ args: toolCall.arguments,
2873
+ intent: toolCall.intent,
2874
+ });
2875
+ stream.push({
2876
+ type: "tool_execution_end",
2877
+ toolCallId: toolCall.id,
2878
+ toolName: toolCall.name,
2879
+ result,
2880
+ isError: true,
2881
+ });
2882
+ stream.push({ type: "message_start", message: toolResultMessage });
2883
+ stream.push({ type: "message_end", message: toolResultMessage });
2884
+
2885
+ return toolResultMessage;
2886
+ }
2887
+
2888
+ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
2889
+ const reason = abortReasonText(signal);
2890
+ return {
2891
+ content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
2892
+ details: {},
2893
+ };
2894
+ }
2895
+
2896
+ function createSkippedToolResult(
2897
+ source: SteeringInterruptSource | "irc" | undefined,
2898
+ executionStarted: boolean,
2899
+ ): AgentToolResult<SyntheticToolResultDetails | InterruptedToolResultDetails> {
2900
+ let reason = "pending steering message";
2901
+ let blocker = "queued message";
2902
+ if (source === "user") {
2903
+ reason = "queued user message";
2904
+ blocker = "queued message";
2905
+ } else if (source === "agent") {
2906
+ reason = "pending parent steering message";
2907
+ blocker = "steering message";
2908
+ } else if (source === "system") {
2909
+ reason = "pending system advisory";
2910
+ blocker = "advisory";
2911
+ } else if (source === "irc") {
2912
+ reason = "pending peer interrupt";
2913
+ blocker = "interrupt";
2914
+ }
2915
+ return {
2916
+ content: [
2917
+ {
2918
+ type: "text",
2919
+ text: `Skipped due to ${reason}. Do not count this skipped result as completed work or verification. After the ${blocker} is handled on the next step, retry the skipped tool if it is still needed.`,
2920
+ },
2921
+ ],
2922
+ details: executionStarted
2923
+ ? { __interrupted: true, source: "interrupt_skipped", execution: "started" }
2924
+ : { __synthetic: true, source: "interrupt_skipped", executed: false },
2925
+ };
2926
+ }