jeopi-agent-core 16.2.13

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 (66) hide show
  1. package/CHANGELOG.md +1016 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +66 -0
  4. package/dist/types/agent.d.ts +427 -0
  5. package/dist/types/append-only-context.d.ts +133 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +101 -0
  7. package/dist/types/compaction/compaction-v2-streaming.d.ts +82 -0
  8. package/dist/types/compaction/compaction.d.ts +283 -0
  9. package/dist/types/compaction/entries.d.ts +110 -0
  10. package/dist/types/compaction/errors.d.ts +26 -0
  11. package/dist/types/compaction/index.d.ts +12 -0
  12. package/dist/types/compaction/messages.d.ts +77 -0
  13. package/dist/types/compaction/openai.d.ts +77 -0
  14. package/dist/types/compaction/pruning.d.ts +105 -0
  15. package/dist/types/compaction/shake.d.ts +92 -0
  16. package/dist/types/compaction/tool-protection.d.ts +17 -0
  17. package/dist/types/compaction/utils.d.ts +58 -0
  18. package/dist/types/compaction.d.ts +1 -0
  19. package/dist/types/index.d.ts +12 -0
  20. package/dist/types/proxy.d.ts +85 -0
  21. package/dist/types/replay-policy.d.ts +5 -0
  22. package/dist/types/run-collector.d.ts +196 -0
  23. package/dist/types/telemetry.d.ts +590 -0
  24. package/dist/types/thinking.d.ts +17 -0
  25. package/dist/types/tokenizer.d.ts +1 -0
  26. package/dist/types/types.d.ts +640 -0
  27. package/dist/types/utils/yield.d.ts +71 -0
  28. package/package.json +78 -0
  29. package/src/agent-loop.ts +2188 -0
  30. package/src/agent.ts +1457 -0
  31. package/src/append-only-context.ts +348 -0
  32. package/src/compaction/branch-summarization.ts +370 -0
  33. package/src/compaction/compaction-v2-streaming.ts +719 -0
  34. package/src/compaction/compaction.ts +1553 -0
  35. package/src/compaction/entries.ts +142 -0
  36. package/src/compaction/errors.ts +31 -0
  37. package/src/compaction/index.ts +13 -0
  38. package/src/compaction/messages.ts +237 -0
  39. package/src/compaction/openai.ts +581 -0
  40. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  41. package/src/compaction/prompts/branch-summary-context.md +5 -0
  42. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  43. package/src/compaction/prompts/branch-summary.md +30 -0
  44. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  45. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  46. package/src/compaction/prompts/compaction-summary.md +38 -0
  47. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  48. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  49. package/src/compaction/prompts/file-operations.md +5 -0
  50. package/src/compaction/prompts/handoff-document.md +49 -0
  51. package/src/compaction/prompts/snapcompact-archive-context.md +3 -0
  52. package/src/compaction/prompts/summarization-system.md +3 -0
  53. package/src/compaction/pruning.ts +424 -0
  54. package/src/compaction/shake.ts +429 -0
  55. package/src/compaction/tool-protection.ts +55 -0
  56. package/src/compaction/utils.ts +323 -0
  57. package/src/compaction.ts +1 -0
  58. package/src/index.ts +24 -0
  59. package/src/proxy.ts +376 -0
  60. package/src/replay-policy.ts +13 -0
  61. package/src/run-collector.ts +631 -0
  62. package/src/telemetry.ts +2034 -0
  63. package/src/thinking.ts +19 -0
  64. package/src/tokenizer.ts +17 -0
  65. package/src/types.ts +718 -0
  66. package/src/utils/yield.ts +183 -0
@@ -0,0 +1,2188 @@
1
+ /**
2
+ * Agent loop that works with AgentMessage throughout.
3
+ * Transforms to Message[] only at the LLM call boundary.
4
+ */
5
+ import {
6
+ type AssistantMessage,
7
+ type AssistantMessageEvent,
8
+ type Context,
9
+ EventStream,
10
+ isApiKeyResolver,
11
+ resolveApiKeyOnce,
12
+ seedApiKeyResolver,
13
+ streamSimple,
14
+ stripSchemaDescriptions,
15
+ type ToolChoice,
16
+ type ToolResultMessage,
17
+ type TSchema,
18
+ toolWireSchema,
19
+ validateToolArguments,
20
+ } from "jeopi-ai";
21
+ import {
22
+ type Dialect,
23
+ encodeInbandToolHistory,
24
+ renderInbandToolPrompt,
25
+ renderToolExamples,
26
+ wrapInbandToolStream,
27
+ } from "jeopi-ai/dialect";
28
+ import * as AIError from "jeopi-ai/error";
29
+ import {
30
+ createHarmonyAuditEvent,
31
+ detectHarmonyLeakInAssistantMessage,
32
+ extractHarmonyRemoved,
33
+ type HarmonyDetection,
34
+ type HarmonyRecoveredToolCall,
35
+ isHarmonyLeakMitigationTarget,
36
+ recoverHarmonyToolCall,
37
+ signalListLabel,
38
+ } from "jeopi-ai/utils/harmony-leak";
39
+ import { preferredDialect } from "jeopi-catalog/identity";
40
+ import { sanitizeText, structuredCloneJSON } from "jeopi-utils";
41
+ import { INTENT_FIELD } from "jeopi-wire";
42
+ import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
43
+ import {
44
+ type AgentTelemetry,
45
+ failChatSpan,
46
+ finishChatSpan,
47
+ finishExecuteToolSpan,
48
+ finishInvokeAgentSpan,
49
+ fireOnRunEnd,
50
+ PiGenAIAttr,
51
+ recordSkippedTool,
52
+ resolveTelemetry,
53
+ runInActiveSpan,
54
+ type Span,
55
+ startChatSpan,
56
+ startExecuteToolSpan,
57
+ startInvokeAgentSpan,
58
+ } from "./telemetry";
59
+ import type {
60
+ AgentContext,
61
+ AgentEvent,
62
+ AgentLoopConfig,
63
+ AgentMessage,
64
+ AgentTool,
65
+ AgentToolResult,
66
+ AgentTurnEndContext,
67
+ AsideMessage,
68
+ StreamFn,
69
+ } from "./types";
70
+ import { isSoftToolRequirement } from "./types";
71
+ import { yieldIfDue } from "./utils/yield";
72
+
73
+ /** Stop-details marker for a provider error after assistant content/tool args already streamed. */
74
+ export const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
75
+
76
+ /** Sentinel returned by the abort race in `streamAssistantResponse`. */
77
+ const ABORTED: unique symbol = Symbol("agent-loop-aborted");
78
+
79
+ /**
80
+ * Cap on consecutive re-samples triggered by a non-terminal stop
81
+ * (`stopDetails.type === "pause_turn"`) without an intervening tool call. Each
82
+ * continuation is a full model request, so a backend that never stops pausing
83
+ * must not spin the loop forever. Resets whenever a turn carries tool calls.
84
+ */
85
+ const MAX_PAUSED_TURN_CONTINUATIONS = 8;
86
+
87
+ /**
88
+ * Cap on consecutive forced escalations for a single soft tool requirement.
89
+ * A forced `toolChoice` guarantees the call, so this is purely defensive: if a
90
+ * model somehow never satisfies the requirement, give up forcing rather than
91
+ * spin the loop. Reset whenever the requirement id changes or clears.
92
+ */
93
+ const MAX_SOFT_TOOL_ESCALATIONS = 3;
94
+
95
+ /**
96
+ * Whether a hard `toolChoice` for a turn conflicts with a pending soft tool
97
+ * requirement — i.e. forbids tools (`"none"`) or forces a *different* specific
98
+ * tool. `"auto"`/`"required"`/`"any"` and a same-tool force still let the model
99
+ * satisfy the requirement, so they do not conflict and the soft gate stays active.
100
+ */
101
+ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: string): boolean {
102
+ if (choice === undefined) return false;
103
+ if (typeof choice === "string") return choice === "none";
104
+ const name = choice.type === "tool" ? choice.name : "function" in choice ? choice.function.name : choice.name;
105
+ return name !== requiredTool;
106
+ }
107
+
108
+ /**
109
+ * Cadence (ms) for polling queued steering while an `interruptible` tool is in
110
+ * flight, so a steer cuts the wait short instead of sitting idle until the
111
+ * tool's own window elapses. A cheap synchronous queue check; latency-bounded
112
+ * at one tick.
113
+ */
114
+ const STEERING_INTERRUPT_POLL_MS = 250;
115
+
116
+ class HarmonyLeakInterruption extends Error {
117
+ constructor(
118
+ readonly detection: HarmonyDetection,
119
+ readonly removed: string,
120
+ readonly recovered?: HarmonyRecoveredToolCall,
121
+ ) {
122
+ super(`Detected GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`);
123
+ this.name = "HarmonyLeakInterruption";
124
+ }
125
+ }
126
+ export function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined {
127
+ switch (value) {
128
+ case "1":
129
+ case "true":
130
+ return "glm";
131
+ case "glm":
132
+ case "hermes":
133
+ case "kimi":
134
+ case "xml":
135
+ case "anthropic":
136
+ case "deepseek":
137
+ case "harmony":
138
+ case "qwen3":
139
+ case "gemini":
140
+ case "gemma":
141
+ case "minimax":
142
+ return value;
143
+ default:
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ type AssistantContentBlock = AssistantMessage["content"][number];
149
+ type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
150
+
151
+ function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
152
+ switch (block.type) {
153
+ case "text":
154
+ return { ...block };
155
+ case "thinking":
156
+ return { ...block };
157
+ case "redactedThinking":
158
+ return { ...block };
159
+ case "fallback":
160
+ return { ...block, from: { ...block.from }, to: { ...block.to } };
161
+ case "toolCall":
162
+ return { ...block, arguments: structuredCloneJSON(block.arguments) };
163
+ }
164
+ }
165
+
166
+ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
167
+ return {
168
+ ...message,
169
+ content: message.content.map(snapshotAssistantContentBlock),
170
+ usage: {
171
+ ...message.usage,
172
+ cost: { ...message.usage.cost },
173
+ },
174
+ disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Deep-clone an assistant streaming event so subscribers get an immutable view.
180
+ * Pass `partialSnapshot` when the caller has already snapshotted `event.partial`
181
+ * (the `message_update` push sites alias it as the event's `message`) so the
182
+ * identical partial is not deep-cloned twice per streaming delta.
183
+ */
184
+ function snapshotAssistantMessageEvent(
185
+ event: AssistantMessageEvent,
186
+ partialSnapshot?: AssistantMessage,
187
+ ): AssistantMessageEvent {
188
+ switch (event.type) {
189
+ case "start":
190
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
191
+ case "text_start":
192
+ case "text_delta":
193
+ case "text_end":
194
+ case "thinking_start":
195
+ case "thinking_delta":
196
+ case "thinking_end":
197
+ case "toolcall_start":
198
+ case "toolcall_delta":
199
+ return { ...event, partial: partialSnapshot ?? snapshotAssistantMessage(event.partial) };
200
+ case "toolcall_end":
201
+ return {
202
+ ...event,
203
+ toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
204
+ partial: partialSnapshot ?? snapshotAssistantMessage(event.partial),
205
+ };
206
+ case "done":
207
+ return { ...event, message: snapshotAssistantMessage(event.message) };
208
+ case "error":
209
+ return { ...event, error: snapshotAssistantMessage(event.error) };
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
215
+ * into a structurally valid {@link AgentToolResult}.
216
+ *
217
+ * The tool interface is typed, but third-party tools (MCP, extensions, user-authored AgentTools)
218
+ * can violate the contract at runtime. Persisting a malformed result corrupts the session file
219
+ * (missing `content` array → crash on reload). We coerce at the single boundary where untyped
220
+ * results enter the agent loop, so every downstream consumer can rely on the type.
221
+ */
222
+ const EMPTY_ERROR_TOOL_RESULT_TEXT = "Tool failed with no output.";
223
+
224
+ function hasSubstantiveToolResultContent(content: AgentToolResult["content"]): boolean {
225
+ for (const block of content) {
226
+ if (block.type === "image") return true;
227
+ if (block.type === "text" && block.text.trim().length > 0) return true;
228
+ }
229
+ return false;
230
+ }
231
+
232
+ function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
233
+ const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
234
+ const rawContent = rawObj?.content;
235
+ const details = rawObj && "details" in rawObj ? rawObj.details : {};
236
+ // Tools may flag a non-throwing failure on the result itself (e.g. an
237
+ // aggregator that catches per-entry errors and synthesizes a combined
238
+ // result). Preserve the flag so agent-loop can surface it on the wire.
239
+ const explicitError = Boolean(rawObj && "isError" in rawObj && rawObj.isError);
240
+ // Tools may flag the result contextually useless (zero matches, elapsed
241
+ // wait) so compaction can elide it once consumed. Errors are never useless.
242
+ const useless = Boolean(rawObj && "useless" in rawObj && rawObj.useless);
243
+
244
+ if (!Array.isArray(rawContent)) {
245
+ return {
246
+ result: {
247
+ content: [{ type: "text", text: "Tool returned an invalid result: missing content array." }],
248
+ details,
249
+ isError: true,
250
+ },
251
+ malformed: true,
252
+ };
253
+ }
254
+
255
+ const content: AgentToolResult["content"] = [];
256
+ let invalidBlocks = 0;
257
+ for (const block of rawContent) {
258
+ if (!block || typeof block !== "object" || !("type" in block)) {
259
+ invalidBlocks++;
260
+ continue;
261
+ }
262
+ if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
263
+ content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
264
+ } else if (
265
+ block.type === "image" &&
266
+ typeof (block as { data?: unknown }).data === "string" &&
267
+ typeof (block as { mimeType?: unknown }).mimeType === "string"
268
+ ) {
269
+ content.push(block as { type: "image"; data: string; mimeType: string });
270
+ } else {
271
+ invalidBlocks++;
272
+ }
273
+ }
274
+ if (invalidBlocks > 0) {
275
+ content.push({
276
+ type: "text",
277
+ text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
278
+ });
279
+ }
280
+ const isError = explicitError || invalidBlocks > 0;
281
+ // Anthropic rejects tool_result blocks with is_error: true and empty content.
282
+ if (isError && !hasSubstantiveToolResultContent(content)) {
283
+ content.length = 0;
284
+ content.push({ type: "text", text: EMPTY_ERROR_TOOL_RESULT_TEXT });
285
+ }
286
+ return {
287
+ result: {
288
+ content,
289
+ details,
290
+ ...(isError ? { isError: true } : {}),
291
+ ...(useless && !isError ? { useless: true } : {}),
292
+ },
293
+ malformed: invalidBlocks > 0,
294
+ };
295
+ }
296
+
297
+ /**
298
+ * Start an agent loop with a new prompt message.
299
+ * The prompt is added to the context and events are emitted for it.
300
+ */
301
+ export function agentLoop(
302
+ prompts: AgentMessage[],
303
+ context: AgentContext,
304
+ config: AgentLoopConfig,
305
+ signal?: AbortSignal,
306
+ streamFn?: StreamFn,
307
+ ): EventStream<AgentEvent, AgentMessage[]> {
308
+ const stream = createAgentStream();
309
+
310
+ (async () => {
311
+ const newMessages: AgentMessage[] = [...prompts];
312
+ const currentContext: AgentContext = {
313
+ ...context,
314
+ messages: [...context.messages, ...prompts],
315
+ };
316
+
317
+ stream.push({ type: "agent_start" });
318
+ stream.push({ type: "turn_start" });
319
+ for (const prompt of prompts) {
320
+ stream.push({ type: "message_start", message: prompt });
321
+ stream.push({ type: "message_end", message: prompt });
322
+ }
323
+
324
+ try {
325
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
326
+ } catch (err) {
327
+ stream.fail(err);
328
+ }
329
+ })();
330
+
331
+ return stream;
332
+ }
333
+
334
+ /**
335
+ * Continue an agent loop from the current context without adding a new message.
336
+ * Used for retries - context already has user message or tool results.
337
+ *
338
+ * **Important:** The last message in context must convert to a `user` or `toolResult` message
339
+ * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
340
+ * This cannot be validated here since `convertToLlm` is only called once per turn.
341
+ */
342
+ export function agentLoopContinue(
343
+ context: AgentContext,
344
+ config: AgentLoopConfig,
345
+ signal?: AbortSignal,
346
+ streamFn?: StreamFn,
347
+ ): EventStream<AgentEvent, AgentMessage[]> {
348
+ if (context.messages.length === 0) {
349
+ throw new Error("Cannot continue: no messages in context");
350
+ }
351
+
352
+ if (context.messages[context.messages.length - 1].role === "assistant") {
353
+ throw new Error("Cannot continue from message role: assistant");
354
+ }
355
+
356
+ const stream = createAgentStream();
357
+
358
+ (async () => {
359
+ const newMessages: AgentMessage[] = [];
360
+ const currentContext: AgentContext = { ...context, messages: [...context.messages] };
361
+
362
+ stream.push({ type: "agent_start" });
363
+ stream.push({ type: "turn_start" });
364
+
365
+ try {
366
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
367
+ } catch (err) {
368
+ stream.fail(err);
369
+ }
370
+ })();
371
+
372
+ return stream;
373
+ }
374
+
375
+ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
376
+ return new EventStream<AgentEvent, AgentMessage[]>(
377
+ (event: AgentEvent) => event.type === "agent_end",
378
+ (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
379
+ );
380
+ }
381
+
382
+ /**
383
+ * Build the `agent_end` event payload. When telemetry is enabled, snapshots
384
+ * the run collector so consumers receive {@link AgentRunSummary} +
385
+ * {@link AgentRunCoverage} alongside the messages without parsing OTEL spans.
386
+ * When telemetry is unset, returns the bare event for backwards compatibility.
387
+ */
388
+ function buildAgentEndEvent(
389
+ messages: AgentMessage[],
390
+ telemetry: AgentTelemetry | undefined,
391
+ stepCount: number,
392
+ ): Extract<AgentEvent, { type: "agent_end" }> {
393
+ if (!telemetry) return { type: "agent_end", messages };
394
+ const snapshot = telemetry.collector.snapshot({ stepCount });
395
+ if (telemetry.collector.markRunEnded()) {
396
+ fireOnRunEnd(telemetry, snapshot.summary, snapshot.coverage);
397
+ }
398
+ return { type: "agent_end", messages, telemetry: snapshot.summary, coverage: snapshot.coverage };
399
+ }
400
+ /**
401
+ * Push a `turn_end` event and run the awaited per-turn hook when the run is
402
+ * still healthy. The hook is skipped for externally aborted or errored turns so
403
+ * a user interrupt does not hang on a background backlog wait.
404
+ */
405
+ async function emitTurnEnd(
406
+ stream: EventStream<AgentEvent, AgentMessage[]>,
407
+ currentContext: AgentContext,
408
+ message: AgentMessage,
409
+ toolResults: ToolResultMessage[],
410
+ config: AgentLoopConfig,
411
+ signal?: AbortSignal,
412
+ context?: Omit<AgentTurnEndContext, "message" | "toolResults">,
413
+ ): Promise<void> {
414
+ stream.push({ type: "turn_end", message, toolResults });
415
+ const isAbortedOrError =
416
+ message.role === "assistant" && (message.stopReason === "aborted" || message.stopReason === "error");
417
+ if (signal?.aborted || isAbortedOrError) return;
418
+ await config.onTurnEnd?.(currentContext.messages, signal, { message, toolResults, willContinue: false, ...context });
419
+ }
420
+
421
+ /**
422
+ * Detailed-result handle returned by {@link agentLoopDetailed}. Adds the
423
+ * run-level telemetry/coverage rollup to the existing `AgentMessage[]`
424
+ * payload without changing the resolved type of `stream.result()`.
425
+ */
426
+ export interface AgentLoopDetailedResult {
427
+ readonly messages: AgentMessage[];
428
+ readonly telemetry: AgentRunSummary | undefined;
429
+ readonly coverage: AgentRunCoverage | undefined;
430
+ }
431
+
432
+ /**
433
+ * Convenience wrapper over {@link agentLoop} that exposes the run-level
434
+ * summary + coverage alongside the messages. The returned `stream` is the
435
+ * same `EventStream` callers already consume; `detailed()` awaits the
436
+ * stream's `agent_end` event and returns the additive fields.
437
+ *
438
+ * Existing `stream.result()` semantics are preserved — it still resolves to
439
+ * `AgentMessage[]`. Use {@link agentLoopDetailed} when you need the rollup;
440
+ * use {@link agentLoop} when you do not.
441
+ */
442
+ export function agentLoopDetailed(
443
+ prompts: AgentMessage[],
444
+ context: AgentContext,
445
+ config: AgentLoopConfig,
446
+ signal?: AbortSignal,
447
+ streamFn?: StreamFn,
448
+ ): {
449
+ readonly stream: EventStream<AgentEvent, AgentMessage[]>;
450
+ readonly detailed: () => Promise<AgentLoopDetailedResult>;
451
+ } {
452
+ const capture = createDetailedCapture(config);
453
+ const stream = agentLoop(prompts, context, capture.config, signal, streamFn);
454
+ return { stream, detailed: () => capture.detailed(stream) };
455
+ }
456
+
457
+ /**
458
+ * Like {@link agentLoopDetailed} but built on top of
459
+ * {@link agentLoopContinue}.
460
+ */
461
+ export function agentLoopContinueDetailed(
462
+ context: AgentContext,
463
+ config: AgentLoopConfig,
464
+ signal?: AbortSignal,
465
+ streamFn?: StreamFn,
466
+ ): {
467
+ readonly stream: EventStream<AgentEvent, AgentMessage[]>;
468
+ readonly detailed: () => Promise<AgentLoopDetailedResult>;
469
+ } {
470
+ const capture = createDetailedCapture(config);
471
+ const stream = agentLoopContinue(context, capture.config, signal, streamFn);
472
+ return { stream, detailed: () => capture.detailed(stream) };
473
+ }
474
+
475
+ /**
476
+ * Wire an `onRunEnd` telemetry hook onto `config` so the detailed helper can
477
+ * capture the run summary without consuming the event stream. Preserves any
478
+ * existing `onRunEnd` the caller had set.
479
+ */
480
+ function createDetailedCapture(config: AgentLoopConfig): {
481
+ readonly config: AgentLoopConfig;
482
+ readonly detailed: (stream: EventStream<AgentEvent, AgentMessage[]>) => Promise<AgentLoopDetailedResult>;
483
+ } {
484
+ let captured: { summary: AgentRunSummary; coverage: AgentRunCoverage } | undefined;
485
+ const userHook = config.telemetry?.onRunEnd;
486
+ const wired: AgentLoopConfig = {
487
+ ...config,
488
+ telemetry: {
489
+ ...(config.telemetry ?? {}),
490
+ onRunEnd: (summary, coverage) => {
491
+ captured = { summary, coverage };
492
+ userHook?.(summary, coverage);
493
+ },
494
+ },
495
+ };
496
+ return {
497
+ config: wired,
498
+ detailed: async stream => {
499
+ const messages = await stream.result();
500
+ return {
501
+ messages,
502
+ telemetry: captured?.summary,
503
+ coverage: captured?.coverage,
504
+ };
505
+ },
506
+ };
507
+ }
508
+
509
+ export function normalizeMessagesForProvider(
510
+ messages: Context["messages"],
511
+ model: AgentLoopConfig["model"],
512
+ ): Context["messages"] {
513
+ if (model.provider !== "cerebras") {
514
+ return messages;
515
+ }
516
+
517
+ let hasThinking = false;
518
+ for (const message of messages) {
519
+ if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
520
+ for (const block of message.content) {
521
+ if (block.type === "thinking") {
522
+ hasThinking = true;
523
+ break;
524
+ }
525
+ }
526
+ if (hasThinking) break;
527
+ }
528
+ if (!hasThinking) return messages;
529
+
530
+ return messages.map(message => {
531
+ if (message.role !== "assistant" || !Array.isArray(message.content)) {
532
+ return message;
533
+ }
534
+ const filtered = message.content.filter(block => block.type !== "thinking");
535
+ return filtered.length === message.content.length ? message : { ...message, content: filtered };
536
+ });
537
+ }
538
+
539
+ const INTENT_FIELD_DESCRIPTION = "concise intent";
540
+ const INTENT_SCHEMA_UNION_KEYS = ["anyOf", "oneOf"] as const;
541
+
542
+ function injectIntentIntoSchema(
543
+ schema: unknown,
544
+ mode: "require" | "optional" = "require",
545
+ describeIntent = true,
546
+ ): unknown {
547
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
548
+ const schemaRecord = schema as Record<string, unknown>;
549
+ const propertiesValue = schemaRecord.properties;
550
+ const hasOwnProperties =
551
+ propertiesValue !== null && typeof propertiesValue === "object" && !Array.isArray(propertiesValue);
552
+
553
+ // Pure union root (anyOf/oneOf with no own properties): push `i` into each
554
+ // alternative branch so each closed shape keeps `additionalProperties: false`
555
+ // honest with intent tracing. Adding a sibling root `properties: { i }` /
556
+ // `required: [i]` would force every input to satisfy both root *and* a
557
+ // branch, leaving no satisfiable shape because each branch's
558
+ // `additionalProperties: false` rejects every other field — and OpenAI
559
+ // strict sanitization later promotes that sibling to a closed root
560
+ // `type: "object"` that rejects every non-`i` key outright. allOf is not
561
+ // alternation (its members are sub-constraints), so we don't recurse into it.
562
+ if (!hasOwnProperties) {
563
+ for (const key of INTENT_SCHEMA_UNION_KEYS) {
564
+ const variants = schemaRecord[key];
565
+ if (!Array.isArray(variants)) continue;
566
+ return {
567
+ ...schemaRecord,
568
+ [key]: variants.map(variant => injectIntentIntoSchema(variant, mode, describeIntent)),
569
+ };
570
+ }
571
+ }
572
+
573
+ const properties = hasOwnProperties ? (propertiesValue as Record<string, unknown>) : {};
574
+ const requiredValue = schemaRecord.required;
575
+ const required = Array.isArray(requiredValue)
576
+ ? requiredValue.filter((item): item is string => typeof item === "string")
577
+ : [];
578
+ if (INTENT_FIELD in properties) {
579
+ const { [INTENT_FIELD]: intentProp, ...rest } = properties;
580
+ const needsReorder = Object.keys(properties)[0] !== INTENT_FIELD;
581
+ const needsRequired = mode === "require" && !required.includes(INTENT_FIELD);
582
+ if (!needsReorder && !needsRequired) return schema;
583
+ return {
584
+ ...schemaRecord,
585
+ ...(needsReorder ? { properties: { [INTENT_FIELD]: intentProp, ...rest } } : {}),
586
+ ...(needsRequired ? { required: [...required, INTENT_FIELD] } : {}),
587
+ };
588
+ }
589
+ return {
590
+ ...schemaRecord,
591
+ properties: {
592
+ [INTENT_FIELD]: describeIntent
593
+ ? { type: "string", description: INTENT_FIELD_DESCRIPTION }
594
+ : { type: "string" },
595
+ ...properties,
596
+ },
597
+ ...(mode === "require" ? { required: [...required, INTENT_FIELD] } : {}),
598
+ };
599
+ }
600
+
601
+ export function normalizeTools(
602
+ tools: AgentContext["tools"],
603
+ injectIntent: boolean,
604
+ exampleDialect?: Dialect,
605
+ pruneDescriptions = false,
606
+ ): Context["tools"] {
607
+ injectIntent = injectIntent && Bun.env.PI_NO_INTENT !== "1";
608
+ return tools?.map(t => {
609
+ const intentMode = resolveIntentMode(t.intent);
610
+ const doInjectIntent = injectIntent && intentMode !== "omit";
611
+ // When the full catalog is rendered into the system prompt, ship the tool
612
+ // specs without their descriptions (top-level + nested schema annotations)
613
+ // so they are not duplicated on the wire. Strip the STABLE wire schema (the
614
+ // memoized `stripSchemaDescriptions` result is reused across requests), then
615
+ // re-inject `i` (without its hint, which `describeIntent: false` omits) so
616
+ // intent tracing keeps the field while no descriptions ride the wire.
617
+ if (pruneDescriptions) {
618
+ let parameters = stripSchemaDescriptions(toolWireSchema(t)) as TSchema;
619
+ if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode, false) as TSchema;
620
+ return { ...t, parameters, description: "" };
621
+ }
622
+ let parameters = toolWireSchema(t) as TSchema;
623
+ if (doInjectIntent) parameters = injectIntentIntoSchema(parameters, intentMode) as TSchema;
624
+ const description = t.description ?? "";
625
+ const examplesBlock = exampleDialect
626
+ ? renderToolExamples({ ...t, parameters }, exampleDialect, doInjectIntent ? INTENT_FIELD : undefined)
627
+ : "";
628
+ const finalDescription = examplesBlock ? `${description}\n\n${examplesBlock}` : description;
629
+ return { ...t, parameters, description: finalDescription };
630
+ });
631
+ }
632
+
633
+ function resolveIntentMode(intent: AgentTool["intent"]): "require" | "optional" | "omit" {
634
+ if (typeof intent === "function") return "omit";
635
+ if (intent === "optional" || intent === "omit") return intent;
636
+ return "require";
637
+ }
638
+
639
+ function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
640
+ const { [INTENT_FIELD]: intent, ...strippedArgs } = args;
641
+ if (typeof intent !== "string") {
642
+ return { strippedArgs };
643
+ }
644
+ const trimmed = intent.trim();
645
+ return { intent: trimmed.length > 0 ? trimmed : undefined, strippedArgs };
646
+ }
647
+
648
+ /**
649
+ * Main loop logic shared by agentLoop and agentLoopContinue.
650
+ */
651
+ async function runLoop(
652
+ currentContext: AgentContext,
653
+ newMessages: AgentMessage[],
654
+ config: AgentLoopConfig,
655
+ signal: AbortSignal | undefined,
656
+ stream: EventStream<AgentEvent, AgentMessage[]>,
657
+ streamFn?: StreamFn,
658
+ ): Promise<void> {
659
+ const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
660
+ const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
661
+ const stepCounter = { count: 0 };
662
+ let caughtError: unknown;
663
+ try {
664
+ await runInActiveSpan(invokeAgentSpan, () =>
665
+ runLoopBody(
666
+ currentContext,
667
+ newMessages,
668
+ config,
669
+ signal,
670
+ stream,
671
+ telemetry,
672
+ invokeAgentSpan,
673
+ stepCounter,
674
+ streamFn,
675
+ ),
676
+ );
677
+ } catch (err) {
678
+ caughtError = err;
679
+ throw err;
680
+ } finally {
681
+ finishInvokeAgentSpan(telemetry, invokeAgentSpan, {
682
+ stepCount: stepCounter.count,
683
+ errorObject: caughtError,
684
+ });
685
+ }
686
+ }
687
+
688
+ interface StepCounter {
689
+ count: number;
690
+ }
691
+
692
+ function isDeadlineExceeded(deadline: number | undefined): boolean {
693
+ return deadline !== undefined && Date.now() >= deadline;
694
+ }
695
+
696
+ function endAgentStream(
697
+ stream: EventStream<AgentEvent, AgentMessage[]>,
698
+ newMessages: AgentMessage[],
699
+ telemetry: AgentTelemetry | undefined,
700
+ stepCount: number,
701
+ ): void {
702
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCount));
703
+ stream.end(newMessages);
704
+ }
705
+
706
+ /**
707
+ * Resolve aside entries at the moment the loop is about to inject them. Each entry
708
+ * is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
709
+ * producer can make the final inject-or-drop decision (return null) against
710
+ * up-to-the-injection state — e.g. dropping late diagnostics a newer edit
711
+ * superseded. Kept sync so it can never stall the loop.
712
+ */
713
+ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
714
+ if (!entries || entries.length === 0) return [];
715
+ const out: AgentMessage[] = [];
716
+ for (const entry of entries) {
717
+ const message = typeof entry === "function" ? entry() : entry;
718
+ if (message) out.push(message);
719
+ }
720
+ return out;
721
+ }
722
+
723
+ async function runLoopBody(
724
+ currentContext: AgentContext,
725
+ newMessages: AgentMessage[],
726
+ config: AgentLoopConfig,
727
+ signal: AbortSignal | undefined,
728
+ stream: EventStream<AgentEvent, AgentMessage[]>,
729
+ telemetry: AgentTelemetry | undefined,
730
+ invokeAgentSpan: Span | undefined,
731
+ stepCounter: StepCounter,
732
+ streamFn?: StreamFn,
733
+ ): Promise<void> {
734
+ let deadlineTimer: Timer | undefined;
735
+ if (config.deadline !== undefined) {
736
+ const deadlineAbortController = new AbortController();
737
+ const delay = config.deadline - Date.now();
738
+ if (delay <= 0) {
739
+ deadlineAbortController.abort("Deadline exceeded");
740
+ } else {
741
+ deadlineTimer = setTimeout(() => {
742
+ deadlineAbortController.abort("Deadline exceeded");
743
+ }, delay);
744
+ }
745
+ signal = signal ? AbortSignal.any([signal, deadlineAbortController.signal]) : deadlineAbortController.signal;
746
+ }
747
+
748
+ try {
749
+ let firstTurn = true;
750
+ if (isDeadlineExceeded(config.deadline)) {
751
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
752
+ return;
753
+ }
754
+ // Check for steering messages at start (user may have typed while waiting).
755
+ // Skip when the run is already externally aborted — dequeuing would strand
756
+ // the messages in a run that is about to die.
757
+ let pendingMessages: AgentMessage[] = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
758
+ let harmonyRetryAttempt = 0;
759
+ let harmonyTruncateResumeCount = 0;
760
+ let pausedTurnContinuations = 0;
761
+
762
+ // Soft tool requirement lifecycle (reminder → escalate; see SoftToolRequirement).
763
+ // `forcedToolChoice` carries a one-turn escalation into the next model call. It
764
+ // overrides the static toolChoice but NEVER the host's hard getToolChoice().
765
+ let softRequirementId: string | undefined;
766
+ let forcedToolChoice: ToolChoice | undefined;
767
+ let softEscalations = 0;
768
+ // Resolved once per logical turn at the fetch site below and reused across
769
+ // Harmony-leak re-samples (which re-enter the same turn) so the consuming
770
+ // getToolChoice is never advanced twice; the flag resets at the message boundary.
771
+ let hostToolChoice: ToolChoice | undefined;
772
+ let softRequiredTool: string | undefined;
773
+ let directiveResolvedForTurn = false;
774
+
775
+ // Outer loop: continues when queued follow-up messages arrive after agent would stop
776
+ while (true) {
777
+ let hasMoreToolCalls = true;
778
+
779
+ // Inner loop: process tool calls and steering messages
780
+ while (hasMoreToolCalls || pendingMessages.length > 0) {
781
+ if (isDeadlineExceeded(config.deadline)) {
782
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
783
+ return;
784
+ }
785
+ // Yield at the top of each iteration to prevent busy-wait when
786
+ // the agent loop is executing tool calls back-to-back.
787
+ await yieldIfDue();
788
+ if (!firstTurn) {
789
+ stream.push({ type: "turn_start" });
790
+ } else {
791
+ firstTurn = false;
792
+ }
793
+
794
+ // Process pending messages (inject before next assistant response)
795
+ if (pendingMessages.length > 0) {
796
+ for (const message of pendingMessages) {
797
+ stream.push({ type: "message_start", message });
798
+ stream.push({ type: "message_end", message });
799
+ currentContext.messages.push(message);
800
+ newMessages.push(message);
801
+ }
802
+ pendingMessages = [];
803
+ }
804
+
805
+ // Refresh prompt/tool context from live state before each model call
806
+ if (config.syncContextBeforeModelCall) {
807
+ await config.syncContextBeforeModelCall(currentContext);
808
+ }
809
+
810
+ // Resolve the per-turn tool-choice directive ONCE per logical turn. The
811
+ // host hard-choice path (getToolChoice → nextToolChoice) is CONSUMING — it
812
+ // advances a generator on every call — so Harmony-leak retries, which
813
+ // re-sample the same turn via `continue` without a turn_end, must reuse the
814
+ // values fetched on the first attempt rather than double-advancing it.
815
+ // Fetched here (after pending-message flush + context sync, immediately
816
+ // before the call) so a throw in between cannot wedge an in-flight
817
+ // directive. A hard ToolChoice is applied verbatim; a SoftToolRequirement
818
+ // triggers the remind-then-escalate lifecycle: inject its reminder inline
819
+ // once per new id (toolChoice stays auto), and the gate below escalates to
820
+ // a forced choice only if the model declines. The host wrapper already
821
+ // dropped a soft requirement whose tool is inactive.
822
+ if (!directiveResolvedForTurn) {
823
+ const directive = signal?.aborted ? undefined : config.getToolChoice?.();
824
+ const softReq = isSoftToolRequirement(directive) ? directive : undefined;
825
+ hostToolChoice = directive === undefined || isSoftToolRequirement(directive) ? undefined : directive;
826
+ softRequiredTool = softReq?.toolName;
827
+ if (softReq !== undefined) {
828
+ if (softReq.id !== softRequirementId) {
829
+ softRequirementId = softReq.id;
830
+ softEscalations = 0;
831
+ for (const reminder of softReq.reminder) {
832
+ stream.push({ type: "message_start", message: reminder });
833
+ stream.push({ type: "message_end", message: reminder });
834
+ currentContext.messages.push(reminder);
835
+ newMessages.push(reminder);
836
+ }
837
+ }
838
+ } else {
839
+ softRequirementId = undefined;
840
+ softEscalations = 0;
841
+ }
842
+ directiveResolvedForTurn = true;
843
+ }
844
+
845
+ // Stream assistant response
846
+ let recovered: HarmonyRecoveredToolCall | undefined;
847
+ let message: AssistantMessage;
848
+ try {
849
+ message = await streamAssistantResponse(
850
+ currentContext,
851
+ config,
852
+ signal,
853
+ stream,
854
+ telemetry,
855
+ invokeAgentSpan,
856
+ stepCounter,
857
+ streamFn,
858
+ harmonyRetryAttempt,
859
+ hostToolChoice,
860
+ forcedToolChoice,
861
+ );
862
+ harmonyRetryAttempt = 0;
863
+ harmonyTruncateResumeCount = 0;
864
+ } catch (err) {
865
+ if (!(err instanceof HarmonyLeakInterruption)) throw err;
866
+ if (err.recovered) {
867
+ if (harmonyTruncateResumeCount >= 2) {
868
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
869
+ throw new Error(
870
+ `GPT-5 Harmony leak recurred after truncate-and-resume recovery (${signalListLabel(err.detection.signals)}).`,
871
+ );
872
+ }
873
+ harmonyTruncateResumeCount++;
874
+ recovered = err.recovered;
875
+ message = recovered.message;
876
+ await emitHarmonyAudit(config, err, "truncate_resume", harmonyRetryAttempt);
877
+ // A recovered message completes the turn, so the abort-retry counter
878
+ // resets like the normal success path (the truncate-resume counter
879
+ // keeps accumulating for its cross-turn cap).
880
+ harmonyRetryAttempt = 0;
881
+ } else {
882
+ if (harmonyRetryAttempt >= 2) {
883
+ await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
884
+ throw new Error(
885
+ `GPT-5 Harmony leak persisted after ${harmonyRetryAttempt} retries (${signalListLabel(err.detection.signals)}).`,
886
+ );
887
+ }
888
+ await emitHarmonyAudit(config, err, "abort_retry", harmonyRetryAttempt);
889
+ harmonyRetryAttempt++;
890
+ continue;
891
+ }
892
+ }
893
+ if (recovered) {
894
+ message = snapshotAssistantMessage(message);
895
+ currentContext.messages.push(message);
896
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
897
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
898
+ }
899
+ newMessages.push(message);
900
+
901
+ // The escalation choice (if any) applied to the call above; clear it so
902
+ // only the single escalation turn carries the forced choice.
903
+ forcedToolChoice = undefined;
904
+
905
+ // A fresh logical turn re-resolves the directive next iteration; a Harmony
906
+ // retry `continue`s before this line and keeps the cached value.
907
+ directiveResolvedForTurn = false;
908
+
909
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
910
+ // Create placeholder tool results for any tool calls in the aborted message
911
+ // This maintains the tool_use/tool_result pairing that the API requires
912
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
913
+ const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
914
+ const toolResults: ToolResultMessage[] = [];
915
+ for (const toolCall of toolCalls) {
916
+ const result = createAbortedToolResult(toolCall, stream, message.stopReason, message.errorMessage);
917
+ currentContext.messages.push(result);
918
+ newMessages.push(result);
919
+ toolResults.push(result);
920
+ // The placeholder result above keeps the API's tool_use/tool_result
921
+ // pairing intact, but no execute_tool span is started for these
922
+ // calls. Mirror the run-collector entry directly so the run
923
+ // summary's tool counters and `coverage.toolsInvoked` reflect
924
+ // what the user actually saw on the wire.
925
+ recordSkippedTool(telemetry, {
926
+ toolCallId: toolCall.id,
927
+ toolName: toolCall.name,
928
+ status: message.stopReason === "aborted" ? "aborted" : "error",
929
+ });
930
+ }
931
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, { willContinue: false });
932
+
933
+ stream.push(buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
934
+ stream.end(newMessages);
935
+ return;
936
+ }
937
+
938
+ // Run tools whenever the turn carries tool_use blocks AND was not truncated.
939
+ // `stop_reason` is provider metadata that never goes back on the wire, so it
940
+ // does not gate continuation validity: replaying a tool_use turn with the
941
+ // tool_results appended is accepted whether the turn ended on `tool_use` or
942
+ // `end_turn` (adaptive/interleaved-thinking Opus routinely emits tool calls
943
+ // under `end_turn`; verified against the live Anthropic API). The only
944
+ // continuation hazard is a thinking block carrying a stale/invalid signature,
945
+ // which `transformMessages` already neutralizes — it strips the signature on
946
+ // non-`toolUse` turns and the encoder downgrades the unsigned block to text,
947
+ // which the API accepts. So treat `stop` (end_turn/pause_turn) the same as
948
+ // `toolUse`. `length` (max_tokens) is the one reason we must NOT run: the
949
+ // trailing tool_use may be truncated with incomplete arguments — those calls
950
+ // are abandoned below. (`error`/`aborted` already returned above.)
951
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
952
+ const toolCalls = message.content.filter((c): c is ToolCallContent => c.type === "toolCall");
953
+ const runnableStop = message.stopReason === "toolUse" || message.stopReason === "stop";
954
+ hasMoreToolCalls = runnableStop && toolCalls.length > 0;
955
+
956
+ const deadlinePassed = isDeadlineExceeded(config.deadline);
957
+ if (hasMoreToolCalls && deadlinePassed) {
958
+ hasMoreToolCalls = false;
959
+ }
960
+
961
+ // A turn is compliant ONLY when it calls the required tool and nothing
962
+ // else — mirroring the forced-tool_choice turn, which can emit only that
963
+ // tool. A required+detour batch is treated as non-compliant so detour
964
+ // tools never run side effects while the requirement is still pending.
965
+ const calledOnlyRequiredTool =
966
+ softRequiredTool !== undefined &&
967
+ toolCalls.length > 0 &&
968
+ toolCalls.every(toolCall => toolCall.name === softRequiredTool);
969
+ const softGateActive =
970
+ softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
971
+ const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
972
+
973
+ const toolResults: ToolResultMessage[] = [];
974
+ if (softNonCompliant && softRequiredTool !== undefined) {
975
+ if (softEscalations >= MAX_SOFT_TOOL_ESCALATIONS) {
976
+ throw new Error(
977
+ `Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
978
+ );
979
+ }
980
+ // A soft-required tool is pending but the model called something else
981
+ // (or yielded). Do NOT execute the detour — pair each call with a
982
+ // skipped result and force the required tool next turn. This is the
983
+ // only turn that changes toolChoice; a model that complies with the
984
+ // reminder pays no message-cache invalidation. Re-engage so the loop
985
+ // never yields while the requirement is unmet.
986
+ for (const toolCall of toolCalls) {
987
+ const result = createAbortedToolResult(
988
+ toolCall,
989
+ stream,
990
+ "skipped",
991
+ `Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`,
992
+ );
993
+ currentContext.messages.push(result);
994
+ newMessages.push(result);
995
+ toolResults.push(result);
996
+ recordSkippedTool(telemetry, {
997
+ toolCallId: toolCall.id,
998
+ toolName: toolCall.name,
999
+ status: "skipped",
1000
+ });
1001
+ }
1002
+ forcedToolChoice = { type: "tool", name: softRequiredTool };
1003
+ softEscalations++;
1004
+ hasMoreToolCalls = true;
1005
+ } else if (hasMoreToolCalls) {
1006
+ const executionResult = await executeToolCalls(
1007
+ currentContext,
1008
+ message,
1009
+ signal,
1010
+ stream,
1011
+ config,
1012
+ telemetry,
1013
+ invokeAgentSpan,
1014
+ );
1015
+
1016
+ toolResults.push(...executionResult.toolResults);
1017
+
1018
+ for (const result of toolResults) {
1019
+ currentContext.messages.push(result);
1020
+ newMessages.push(result);
1021
+ }
1022
+ } else if (toolCalls.length > 0) {
1023
+ // Turn ended on a non-runnable reason (`length` truncation) or deadline was exceeded
1024
+ // but left toolCall blocks behind. pair each with a placeholder result.
1025
+ const skipReason = deadlinePassed ? "aborted" : message.stopReason === "length" ? "length" : "skipped";
1026
+ const skipErrMsg = deadlinePassed ? "Deadline exceeded" : undefined;
1027
+ for (const toolCall of toolCalls) {
1028
+ const result = createAbortedToolResult(toolCall, stream, skipReason, skipErrMsg);
1029
+ currentContext.messages.push(result);
1030
+ newMessages.push(result);
1031
+ toolResults.push(result);
1032
+ recordSkippedTool(telemetry, {
1033
+ toolCallId: toolCall.id,
1034
+ toolName: toolCall.name,
1035
+ status: deadlinePassed ? "aborted" : "skipped",
1036
+ });
1037
+ }
1038
+ if (message.stopReason === "length" && toolResults.length > 0 && !deadlinePassed) {
1039
+ hasMoreToolCalls = true;
1040
+ }
1041
+ }
1042
+
1043
+ if (toolCalls.length > 0) {
1044
+ pausedTurnContinuations = 0;
1045
+ } else if (
1046
+ !hasMoreToolCalls &&
1047
+ message.stopReason === "stop" &&
1048
+ message.stopDetails?.type === "pause_turn" &&
1049
+ pausedTurnContinuations < MAX_PAUSED_TURN_CONTINUATIONS
1050
+ ) {
1051
+ // Non-terminal stop: the provider ended the response but not the turn
1052
+ // (e.g. Codex `end_turn: false` on a commentary-only progress update).
1053
+ // Re-sample with the assistant message replayed so the model keeps
1054
+ // working; the next round folds steering/asides in like any other
1055
+ // mid-work turn.
1056
+ pausedTurnContinuations++;
1057
+ hasMoreToolCalls = true;
1058
+ }
1059
+
1060
+ await emitTurnEnd(stream, currentContext, message, toolResults, config, signal, {
1061
+ willContinue: hasMoreToolCalls && !isDeadlineExceeded(config.deadline),
1062
+ });
1063
+
1064
+ if (isDeadlineExceeded(config.deadline)) {
1065
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1066
+ return;
1067
+ }
1068
+ // On external abort (user interrupt), leave the steering queue intact: the
1069
+ // session aborts then continues, delivering the queue into a fresh run.
1070
+ // Draining it here would inject the messages right before a model call that
1071
+ // instantly aborts — message lands in history, agent never responds. The
1072
+ // mid-batch interrupt poll only peeks (hasSteeringMessages), so the queue
1073
+ // still owns every message until this dequeue.
1074
+ const steering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1075
+ if (hasMoreToolCalls) {
1076
+ // Mid-work: fold any non-interrupting asides into the next turn alongside steering.
1077
+ const asides = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1078
+ pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
1079
+ } else {
1080
+ // Stop boundary: only steering (live user input) forces another turn here. Leave
1081
+ // asides for the outer drain below so a passive aside can't trigger an extra model
1082
+ // turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
1083
+ pendingMessages = steering;
1084
+ }
1085
+ }
1086
+
1087
+ if (isDeadlineExceeded(config.deadline)) {
1088
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1089
+ return;
1090
+ }
1091
+
1092
+ // Agent would stop here. Drain non-interrupting asides + follow-up messages.
1093
+ await config.onBeforeYield?.();
1094
+
1095
+ if (isDeadlineExceeded(config.deadline)) {
1096
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1097
+ return;
1098
+ }
1099
+ // Skip queue drains when externally aborted (same stranding hazard as above).
1100
+ // Re-poll steering too: a steer can land between the stop-boundary dequeue
1101
+ // above and this yield point (e.g. queued while onBeforeYield ran). Without
1102
+ // this poll it would strand in the queue until the next manual prompt.
1103
+ const lateSteering = signal?.aborted ? [] : (await config.getSteeringMessages?.()) || [];
1104
+ const asideMessages = signal?.aborted ? [] : resolveAsides(await config.getAsideMessages?.());
1105
+ const followUpMessages = signal?.aborted ? [] : (await config.getFollowUpMessages?.()) || [];
1106
+ if (lateSteering.length > 0 || asideMessages.length > 0 || followUpMessages.length > 0) {
1107
+ // Set as pending so the inner loop processes them before stopping.
1108
+ pendingMessages = [...lateSteering, ...asideMessages, ...followUpMessages];
1109
+ continue;
1110
+ }
1111
+
1112
+ // No more messages, exit
1113
+ break;
1114
+ }
1115
+
1116
+ endAgentStream(stream, newMessages, telemetry, stepCounter.count);
1117
+ } finally {
1118
+ if (deadlineTimer) {
1119
+ clearTimeout(deadlineTimer);
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ async function emitHarmonyAudit(
1125
+ config: AgentLoopConfig,
1126
+ interruption: HarmonyLeakInterruption,
1127
+ action: "truncate_resume" | "abort_retry" | "escalated",
1128
+ retryN: number,
1129
+ ): Promise<void> {
1130
+ await config.onHarmonyLeak?.(
1131
+ createHarmonyAuditEvent({
1132
+ action,
1133
+ detection: interruption.detection,
1134
+ model: config.model,
1135
+ retryN,
1136
+ removed: interruption.removed,
1137
+ }),
1138
+ );
1139
+ }
1140
+
1141
+ /**
1142
+ * Stream an assistant response from the LLM.
1143
+ * This is where AgentMessage[] gets transformed to Message[] for the LLM.
1144
+ */
1145
+ async function streamAssistantResponse(
1146
+ context: AgentContext,
1147
+ config: AgentLoopConfig,
1148
+ signal: AbortSignal | undefined,
1149
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1150
+ telemetry: AgentTelemetry | undefined,
1151
+ invokeAgentSpan: Span | undefined,
1152
+ stepCounter: StepCounter,
1153
+ streamFn?: StreamFn,
1154
+ harmonyRetryAttempt = 0,
1155
+ hostToolChoice?: ToolChoice,
1156
+ forcedToolChoice?: ToolChoice,
1157
+ ): Promise<AssistantMessage> {
1158
+ // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1159
+ let messages = context.messages;
1160
+ if (config.transformContext) {
1161
+ messages = await config.transformContext(messages, signal);
1162
+ }
1163
+
1164
+ // Convert to LLM-compatible messages (AgentMessage[] → Message[])
1165
+ const llmMessages = await config.convertToLlm(messages);
1166
+ const normalizedMessages = normalizeMessagesForProvider(llmMessages, config.model);
1167
+
1168
+ const ownedDialect: Dialect | undefined = config.dialect ?? resolveOwnedDialectFromEnv(Bun.env.PI_DIALECT);
1169
+ const exampleDialect = ownedDialect ?? preferredDialect(config.model.id);
1170
+ // Owned/in-band dialects carry the catalog in the prompt as text and send no
1171
+ // native `tools`, so description pruning only applies to native tool calling.
1172
+ const pruneToolDescriptions = !!config.pruneToolDescriptions && !ownedDialect;
1173
+ // Build LLM context — append-only mode caches system prompt + tools
1174
+ // AND keeps an append-only message log so prior-turn bytes are stable.
1175
+ let llmContext: Context;
1176
+ if (config.appendOnlyContext) {
1177
+ config.appendOnlyContext.syncMessages(normalizedMessages);
1178
+ llmContext = config.appendOnlyContext.build(context, {
1179
+ intentTracing: !!config.intentTracing,
1180
+ exampleDialect,
1181
+ pruneToolDescriptions,
1182
+ });
1183
+ } else {
1184
+ llmContext = {
1185
+ systemPrompt: context.systemPrompt,
1186
+ messages: normalizedMessages,
1187
+ tools: normalizeTools(context.tools, !!config.intentTracing, exampleDialect, pruneToolDescriptions),
1188
+ };
1189
+ }
1190
+ if (config.transformProviderContext) {
1191
+ llmContext = await config.transformProviderContext(llmContext, config.model);
1192
+ }
1193
+
1194
+ // Owned tool calling: take tool calls away from the provider and run them
1195
+ // through the selected in-band prompt dialect. `PI_DIALECT=1` still
1196
+ // force-enables GLM; `PI_DIALECT=<dialect>` force-enables that dialect.
1197
+ let promptToolWireTools: Context["tools"];
1198
+ if (ownedDialect && llmContext.tools && llmContext.tools.length > 0) {
1199
+ promptToolWireTools = llmContext.tools;
1200
+ llmContext = {
1201
+ ...llmContext,
1202
+ systemPrompt: [...(llmContext.systemPrompt ?? []), renderInbandToolPrompt(promptToolWireTools, ownedDialect)],
1203
+ messages: encodeInbandToolHistory(llmContext.messages, ownedDialect, promptToolWireTools),
1204
+ tools: undefined,
1205
+ };
1206
+ }
1207
+
1208
+ const streamFunction = streamFn || streamSimple;
1209
+
1210
+ const dynamicReasoning = config.getReasoning?.();
1211
+ const dynamicDisableReasoning = config.getDisableReasoning?.();
1212
+ // `getServiceTier` is authoritative when present (replaces the static tier
1213
+ // for both the wire request and telemetry), so callers can scope priority
1214
+ // per model without touching the shared session `serviceTier`.
1215
+ const effectiveServiceTier = config.getServiceTier ? config.getServiceTier(config.model) : config.serviceTier;
1216
+ const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1217
+ const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1218
+ const requestSignal = harmonyAbortController
1219
+ ? signal
1220
+ ? AbortSignal.any([signal, harmonyAbortController.signal])
1221
+ : harmonyAbortController.signal
1222
+ : signal;
1223
+ // Owned tool calling: aborted by the stream wrapper when the model starts
1224
+ // fabricating a `<tool_response>`, so the provider stops generating the rest of
1225
+ // the hallucinated turn. Merged into the provider signal ONLY (not
1226
+ // `requestSignal`), so it cancels the request without tripping the loop's
1227
+ // external-abort handling (`abortRacePromise` / `requestSignal.aborted`).
1228
+ const promptToolAbortController = ownedDialect ? new AbortController() : undefined;
1229
+ const providerAbortSignals: AbortSignal[] = [];
1230
+ if (requestSignal) providerAbortSignals.push(requestSignal);
1231
+ if (promptToolAbortController) providerAbortSignals.push(promptToolAbortController.signal);
1232
+ const finalRequestSignal =
1233
+ providerAbortSignals.length === 0
1234
+ ? undefined
1235
+ : providerAbortSignals.length === 1
1236
+ ? providerAbortSignals[0]!
1237
+ : AbortSignal.any(providerAbortSignals);
1238
+ const requestApiKey = (config.getApiKey ? await config.getApiKey(config.model) : undefined) ?? config.apiKey;
1239
+ const resolvedApiKey = await resolveApiKeyOnce(requestApiKey, finalRequestSignal);
1240
+ const apiKey = isApiKeyResolver(requestApiKey) ? seedApiKeyResolver(resolvedApiKey, requestApiKey) : requestApiKey;
1241
+
1242
+ // Re-resolve metadata after credential selection so the per-request value
1243
+ // reflects the credential actually used, not the snapshot from AgentLoopConfig construction.
1244
+ const resolvedMetadata = config.metadataResolver ? config.metadataResolver(config.model.provider) : config.metadata;
1245
+ const effectiveTemperature =
1246
+ harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1247
+ // Owned tool calling sends no native tools, so any tool_choice would error.
1248
+ const effectiveToolChoice = ownedDialect ? undefined : (hostToolChoice ?? forcedToolChoice ?? config.toolChoice);
1249
+ const effectiveReasoning = dynamicReasoning ?? config.reasoning;
1250
+ const effectiveDisableReasoning = dynamicDisableReasoning ?? config.disableReasoning;
1251
+ // `getCwd` is read once per LLM call so a mid-run session move (`/move`) reaches
1252
+ // workspace-scoped provider discovery; falls back to the static `cwd` when unset.
1253
+ const effectiveCwd = config.getCwd?.() ?? config.cwd;
1254
+
1255
+ const chatStepNumber = stepCounter.count;
1256
+ stepCounter.count += 1;
1257
+ const chatSpan = startChatSpan(telemetry, config.model, {
1258
+ parent: invokeAgentSpan,
1259
+ stepNumber: chatStepNumber,
1260
+ request: {
1261
+ maxTokens: config.maxTokens,
1262
+ temperature: effectiveTemperature,
1263
+ topP: config.topP,
1264
+ topK: config.topK,
1265
+ presencePenalty: config.presencePenalty,
1266
+ serviceTier: effectiveServiceTier,
1267
+ reasoningEffort: typeof effectiveReasoning === "string" ? effectiveReasoning : undefined,
1268
+ toolChoice: effectiveToolChoice,
1269
+ tools: llmContext.tools,
1270
+ systemPrompt: llmContext.systemPrompt,
1271
+ messages: llmContext.messages,
1272
+ },
1273
+ });
1274
+
1275
+ // Wrap the user-supplied onResponse so we always observe response headers
1276
+ // for telemetry (`ChatUsageEvent.headers`, gateway auto-detection) without
1277
+ // stealing them from the configured hook.
1278
+ let capturedHeaders: Readonly<Record<string, string>> | undefined;
1279
+ const userOnResponse = config.onResponse;
1280
+ const captureOnResponse: AgentLoopConfig["onResponse"] = (response, modelInfo) => {
1281
+ capturedHeaders = response.headers;
1282
+ return userOnResponse?.(response, modelInfo);
1283
+ };
1284
+
1285
+ const finishChat = async (message: AssistantMessage): Promise<void> => {
1286
+ await finishChatSpan(telemetry, chatSpan, message, {
1287
+ stepNumber: chatStepNumber,
1288
+ serviceTier: effectiveServiceTier,
1289
+ responseHeaders: capturedHeaders,
1290
+ baseUrl: config.model.baseUrl,
1291
+ });
1292
+ };
1293
+
1294
+ try {
1295
+ return await runInActiveSpan(chatSpan, async () => {
1296
+ let response = await streamFunction(config.model, llmContext, {
1297
+ ...config,
1298
+ apiKey,
1299
+ metadata: resolvedMetadata,
1300
+ toolChoice: effectiveToolChoice,
1301
+ reasoning: effectiveReasoning,
1302
+ disableReasoning: effectiveDisableReasoning,
1303
+ temperature: effectiveTemperature,
1304
+ serviceTier: effectiveServiceTier,
1305
+ cwd: effectiveCwd,
1306
+ signal: finalRequestSignal,
1307
+ onResponse: captureOnResponse,
1308
+ });
1309
+ if (promptToolWireTools && ownedDialect) {
1310
+ // Re-materialize in-band tool-call text as native toolCall content blocks
1311
+ // so the rest of the loop executes them unchanged. When the model starts
1312
+ // fabricating tool results, the abort callback cancels the provider — unless
1313
+ // `abortOnFabricatedToolResult` is false, in which case the stream drains and
1314
+ // the fabricated continuation is discarded without aborting.
1315
+ response = wrapInbandToolStream(
1316
+ response,
1317
+ promptToolWireTools,
1318
+ ownedDialect,
1319
+ () => promptToolAbortController?.abort(),
1320
+ config.abortOnFabricatedToolResult ?? true,
1321
+ );
1322
+ }
1323
+
1324
+ let partialMessage: AssistantMessage | null = null;
1325
+ let addedPartial = false;
1326
+ const completedToolCallIds = new Set<string>();
1327
+
1328
+ const responseIterator = response[Symbol.asyncIterator]();
1329
+ const finishAbortedStream = async (): Promise<AssistantMessage> => {
1330
+ try {
1331
+ const cleanup = responseIterator.return?.();
1332
+ if (cleanup) void cleanup.catch(() => {});
1333
+ } catch {
1334
+ // Provider cancellation failures cannot change the committed aborted message.
1335
+ }
1336
+ const aborted = emitAbortedAssistantMessage(
1337
+ partialMessage,
1338
+ addedPartial,
1339
+ completedToolCallIds,
1340
+ context,
1341
+ config,
1342
+ stream,
1343
+ requestSignal,
1344
+ );
1345
+ await finishChat(aborted);
1346
+ return aborted;
1347
+ };
1348
+
1349
+ // Set up a single abort race: register the abort listener once for the whole
1350
+ // stream and reuse the same race promise for every iterator.next() instead of
1351
+ // allocating Promise.withResolvers and add/removeEventListener per event.
1352
+ let abortRacePromise: Promise<typeof ABORTED> | undefined;
1353
+ let detachAbortListener: (() => void) | undefined;
1354
+ if (requestSignal) {
1355
+ if (requestSignal.aborted) {
1356
+ return await finishAbortedStream();
1357
+ }
1358
+ const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
1359
+ const onAbort = () => resolve(ABORTED);
1360
+ requestSignal.addEventListener("abort", onAbort, { once: true });
1361
+ abortRacePromise = promise;
1362
+ detachAbortListener = () => requestSignal.removeEventListener("abort", onAbort);
1363
+ }
1364
+
1365
+ try {
1366
+ while (true) {
1367
+ let next: IteratorResult<AssistantMessageEvent>;
1368
+ if (abortRacePromise) {
1369
+ const result = await Promise.race([responseIterator.next(), abortRacePromise]);
1370
+ if (result === ABORTED) {
1371
+ return await finishAbortedStream();
1372
+ }
1373
+ next = result;
1374
+ } else {
1375
+ next = await responseIterator.next();
1376
+ }
1377
+ if (next.done) break;
1378
+
1379
+ const event = next.value;
1380
+ if (event.type === "done" || event.type === "error") {
1381
+ let finalMessage = recoverTransientErrorToolTurn(
1382
+ retainCompletedToolCalls(await response.result(), completedToolCallIds),
1383
+ context.tools ?? [],
1384
+ );
1385
+ if (harmonyMitigationEnabled) {
1386
+ const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
1387
+ if (detection) {
1388
+ const recovered = recoverHarmonyToolCall(finalMessage, detection);
1389
+ const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
1390
+ if (addedPartial) {
1391
+ emitDiscardedHarmonyPartial(
1392
+ partialMessage,
1393
+ stream,
1394
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
1395
+ );
1396
+ context.messages.pop();
1397
+ addedPartial = false;
1398
+ }
1399
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
1400
+ }
1401
+ }
1402
+ finalMessage = snapshotAssistantMessage(finalMessage);
1403
+ // Expand inline macros (and any other registered rewrite) on the
1404
+ // finalized message before it reaches the context, the UI, or tool
1405
+ // dispatch — so a single mutation is the source of truth for all three.
1406
+ if (config.transformAssistantMessage) {
1407
+ await config.transformAssistantMessage(finalMessage, requestSignal);
1408
+ }
1409
+ if (addedPartial) {
1410
+ context.messages[context.messages.length - 1] = finalMessage;
1411
+ } else {
1412
+ context.messages.push(finalMessage);
1413
+ }
1414
+ if (!addedPartial) {
1415
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
1416
+ }
1417
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
1418
+ await finishChat(finalMessage);
1419
+ return finalMessage;
1420
+ }
1421
+ if (requestSignal?.aborted) {
1422
+ return await finishAbortedStream();
1423
+ }
1424
+
1425
+ // Yield to the event loop periodically to prevent busy-wait
1426
+ // when the LLM is streaming chunks faster than the loop can rest.
1427
+ await yieldIfDue();
1428
+
1429
+ switch (event.type) {
1430
+ case "start":
1431
+ partialMessage = event.partial;
1432
+ if (addedPartial) {
1433
+ context.messages[context.messages.length - 1] = partialMessage;
1434
+ completedToolCallIds.clear();
1435
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1436
+ // immutable snapshot of the streaming partial: every message_update
1437
+ // consumer treats both as read-only, so cloning the identical partial
1438
+ // twice per delta was pure waste.
1439
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1440
+ stream.push({
1441
+ type: "message_update",
1442
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1443
+ message: messageSnapshot,
1444
+ });
1445
+ } else {
1446
+ context.messages.push(partialMessage);
1447
+ addedPartial = true;
1448
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
1449
+ }
1450
+ break;
1451
+
1452
+ case "text_start":
1453
+ case "text_delta":
1454
+ case "text_end":
1455
+ case "thinking_start":
1456
+ case "thinking_delta":
1457
+ case "thinking_end":
1458
+ case "toolcall_start":
1459
+ case "toolcall_delta":
1460
+ case "toolcall_end":
1461
+ if (partialMessage) {
1462
+ if (event.type === "toolcall_end") {
1463
+ completedToolCallIds.add(event.toolCall.id);
1464
+ }
1465
+ partialMessage = event.partial;
1466
+ context.messages[context.messages.length - 1] = partialMessage;
1467
+ config.onAssistantMessageEvent?.(partialMessage, event);
1468
+ // `message` and `assistantMessageEvent.partial` intentionally share one
1469
+ // immutable snapshot of the streaming partial: every message_update
1470
+ // consumer treats both as read-only, so cloning the identical partial
1471
+ // twice per delta was pure waste.
1472
+ const messageSnapshot = snapshotAssistantMessage(partialMessage);
1473
+ stream.push({
1474
+ type: "message_update",
1475
+ assistantMessageEvent: snapshotAssistantMessageEvent(event, messageSnapshot),
1476
+ message: messageSnapshot,
1477
+ });
1478
+ }
1479
+ break;
1480
+ }
1481
+ }
1482
+ } finally {
1483
+ detachAbortListener?.();
1484
+ }
1485
+
1486
+ let trailing = await response.result();
1487
+ if (harmonyMitigationEnabled) {
1488
+ const detection = detectHarmonyLeakInAssistantMessage(trailing);
1489
+ if (detection) {
1490
+ const recovered = recoverHarmonyToolCall(trailing, detection);
1491
+ const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
1492
+ if (addedPartial) {
1493
+ emitDiscardedHarmonyPartial(
1494
+ partialMessage,
1495
+ stream,
1496
+ `Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
1497
+ );
1498
+ context.messages.pop();
1499
+ addedPartial = false;
1500
+ }
1501
+ throw new HarmonyLeakInterruption(detection, removed, recovered);
1502
+ }
1503
+ }
1504
+ trailing = snapshotAssistantMessage(trailing);
1505
+ if (addedPartial) {
1506
+ context.messages[context.messages.length - 1] = trailing;
1507
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
1508
+ }
1509
+ await finishChat(trailing);
1510
+ return trailing;
1511
+ });
1512
+ } catch (err) {
1513
+ failChatSpan(telemetry, chatSpan, {
1514
+ errorObject: err,
1515
+ responseHeaders: capturedHeaders,
1516
+ baseUrl: config.model.baseUrl,
1517
+ });
1518
+ throw err;
1519
+ }
1520
+ }
1521
+
1522
+ function retainCompletedToolCalls(
1523
+ message: AssistantMessage,
1524
+ completedToolCallIds: ReadonlySet<string>,
1525
+ ): AssistantMessage {
1526
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
1527
+ let droppedIncompleteToolCall = false;
1528
+ const content = message.content.filter(block => {
1529
+ if (block.type !== "toolCall") return true;
1530
+ const keep = completedToolCallIds.has(block.id);
1531
+ if (!keep) droppedIncompleteToolCall = true;
1532
+ return keep;
1533
+ });
1534
+ if (!droppedIncompleteToolCall) return message;
1535
+ return {
1536
+ ...message,
1537
+ content,
1538
+ stopDetails:
1539
+ message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL
1540
+ ? message.stopDetails
1541
+ : {
1542
+ type: STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
1543
+ category: message.stopDetails?.type ?? null,
1544
+ explanation: message.stopDetails?.explanation ?? message.errorMessage ?? null,
1545
+ },
1546
+ };
1547
+ }
1548
+
1549
+ function recoverTransientErrorToolTurn(
1550
+ message: AssistantMessage,
1551
+ availableTools: ReadonlyArray<Pick<AgentTool, "name" | "customWireName">>,
1552
+ ): AssistantMessage {
1553
+ if (message.stopReason !== "error") return message;
1554
+ const toolCalls = message.content.filter(block => block.type === "toolCall");
1555
+ if (toolCalls.length === 0) return message;
1556
+ const availableToolNames = new Set<string>();
1557
+ for (const tool of availableTools) {
1558
+ availableToolNames.add(tool.name);
1559
+ if (tool.customWireName !== undefined) availableToolNames.add(tool.customWireName);
1560
+ }
1561
+ if (!toolCalls.every(toolCall => availableToolNames.has(toolCall.name))) return message;
1562
+ if (!AIError.isStreamReadErrorText(`${message.errorMessage ?? ""}\n${message.stopDetails?.explanation ?? ""}`))
1563
+ return message;
1564
+ return {
1565
+ ...message,
1566
+ stopReason: "toolUse",
1567
+ stopDetails:
1568
+ message.stopDetails?.type === STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL
1569
+ ? message.stopDetails
1570
+ : {
1571
+ type: STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL,
1572
+ category: message.stopDetails?.type ?? null,
1573
+ explanation: message.stopDetails?.explanation ?? message.errorMessage ?? null,
1574
+ },
1575
+ errorMessage: undefined,
1576
+ errorId: undefined,
1577
+ errorStatus: undefined,
1578
+ };
1579
+ }
1580
+
1581
+ function emitDiscardedHarmonyPartial(
1582
+ partialMessage: AssistantMessage | null,
1583
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1584
+ errorMessage: string,
1585
+ ): void {
1586
+ if (!partialMessage) return;
1587
+ stream.push({
1588
+ type: "message_end",
1589
+ message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
1590
+ });
1591
+ }
1592
+
1593
+ /** Resolve the human-readable reason an abort carried. A caller that aborts via
1594
+ * `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
1595
+ * (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
1596
+ * synthesized assistant message's `errorMessage`; a bare `abort()` (whose
1597
+ * `signal.reason` is the default `AbortError` `DOMException`) falls back to the
1598
+ * generic sentinel that downstream renderers treat as "no specific reason". */
1599
+ export function abortReasonText(signal: AbortSignal | undefined): string {
1600
+ const reason = signal?.reason;
1601
+ if (typeof reason === "string" && reason.trim().length > 0) return reason;
1602
+ if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) {
1603
+ return reason.message;
1604
+ }
1605
+ return "Request was aborted";
1606
+ }
1607
+
1608
+ function emitAbortedAssistantMessage(
1609
+ partialMessage: AssistantMessage | null,
1610
+ addedPartial: boolean,
1611
+ completedToolCallIds: ReadonlySet<string>,
1612
+ context: AgentContext,
1613
+ config: AgentLoopConfig,
1614
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1615
+ requestSignal: AbortSignal | undefined,
1616
+ ): AssistantMessage {
1617
+ const errorMessage = abortReasonText(requestSignal);
1618
+ const errorId =
1619
+ errorMessage === "Request was aborted"
1620
+ ? AIError.create(AIError.Flag.Abort)
1621
+ : AIError.classify(requestSignal?.reason) || undefined;
1622
+ const base: AssistantMessage = partialMessage
1623
+ ? { ...partialMessage, stopReason: "aborted", errorMessage, errorId }
1624
+ : {
1625
+ role: "assistant",
1626
+ content: [],
1627
+ api: config.model.api,
1628
+ provider: config.model.provider,
1629
+ model: config.model.id,
1630
+ usage: {
1631
+ input: 0,
1632
+ output: 0,
1633
+ cacheRead: 0,
1634
+ cacheWrite: 0,
1635
+ totalTokens: 0,
1636
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
1637
+ },
1638
+ stopReason: "aborted",
1639
+ errorMessage,
1640
+ errorId,
1641
+ timestamp: Date.now(),
1642
+ };
1643
+ // Only tool calls that reached `toolcall_end` survive abort/error replay. A
1644
+ // labeled user interrupt still surfaces through `errorMessage`, but partial
1645
+ // tool arguments are unsafe to keep and can carry incomplete provider IDs.
1646
+ const retained = retainCompletedToolCalls(base, completedToolCallIds);
1647
+ const abortedMessage = snapshotAssistantMessage(retained);
1648
+ if (addedPartial) {
1649
+ context.messages[context.messages.length - 1] = abortedMessage;
1650
+ } else {
1651
+ context.messages.push(abortedMessage);
1652
+ stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
1653
+ }
1654
+ stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
1655
+ return abortedMessage;
1656
+ }
1657
+
1658
+ /**
1659
+ * Execute tool calls from an assistant message.
1660
+ */
1661
+ async function executeToolCalls(
1662
+ currentContext: AgentContext,
1663
+ assistantMessage: AssistantMessage,
1664
+ signal: AbortSignal | undefined,
1665
+ stream: EventStream<AgentEvent, AgentMessage[]>,
1666
+ config: AgentLoopConfig,
1667
+ telemetry: AgentTelemetry | undefined,
1668
+ invokeAgentSpan: Span | undefined,
1669
+ ): Promise<{ toolResults: ToolResultMessage[] }> {
1670
+ const tools = currentContext.tools;
1671
+ const {
1672
+ hasSteeringMessages,
1673
+ hasIrcInterrupts,
1674
+ getSteeringMessages,
1675
+ interruptMode = "immediate",
1676
+ getToolContext,
1677
+ transformToolCallArguments,
1678
+ intentTracing,
1679
+ beforeToolCall,
1680
+ afterToolCall,
1681
+ } = config;
1682
+ type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
1683
+ const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall");
1684
+ const emittedToolResults: ToolResultMessage[] = [];
1685
+ const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
1686
+ const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
1687
+ const shouldInterruptImmediately = interruptMode !== "wait";
1688
+ const steeringAbortController = new AbortController();
1689
+ const ircAbortController = new AbortController();
1690
+ // Interruptible tools observe steering + external + IRC aborts; every other
1691
+ // tool only sees steering + external, so an IRC-only interrupt never kills a
1692
+ // partially side-effecting foreground tool (e.g. `bash`) running alongside a
1693
+ // pure wait (e.g. `job` poll).
1694
+ const nonInterruptibleSignal: AbortSignal = signal
1695
+ ? AbortSignal.any([signal, steeringAbortController.signal])
1696
+ : steeringAbortController.signal;
1697
+ const interruptibleSignal: AbortSignal = signal
1698
+ ? AbortSignal.any([signal, steeringAbortController.signal, ircAbortController.signal])
1699
+ : AbortSignal.any([steeringAbortController.signal, ircAbortController.signal]);
1700
+ const interruptState = { triggered: false };
1701
+
1702
+ const records = toolCalls.map(toolCall => {
1703
+ // Tools emitted via OpenAI's custom-tool path (e.g. `apply_patch` on GPT-5)
1704
+ // come back under their wire-level name, which may differ from the
1705
+ // harness-internal `name`. Match on either, preferring `name` for
1706
+ // determinism if both somehow collide.
1707
+ const tool =
1708
+ tools?.find(t => t.name === toolCall.name) ??
1709
+ tools?.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
1710
+ return {
1711
+ toolCall,
1712
+ tool,
1713
+ args: toolCall.arguments as Record<string, unknown>,
1714
+ signal: tool?.interruptible ? interruptibleSignal : nonInterruptibleSignal,
1715
+ started: false,
1716
+ result: undefined as AgentToolResult<any> | undefined,
1717
+ isError: false,
1718
+ skipped: false,
1719
+ toolResultMessage: undefined as ToolResultMessage | undefined,
1720
+ resultEmitted: false,
1721
+ };
1722
+ });
1723
+
1724
+ const checkSteering = async (): Promise<void> => {
1725
+ // `signal` (external/user abort) is checked separately from the internal
1726
+ // abort controllers: once the run is externally aborted it is unwinding
1727
+ // and the interrupt would be redundant.
1728
+ if (!shouldInterruptImmediately || signal?.aborted) {
1729
+ return;
1730
+ }
1731
+ // Prefer non-consuming peeks so queues still own their messages until
1732
+ // the injection boundary. Fall back to consuming steering only for older
1733
+ // integrations that never supplied a peek.
1734
+ let steeringQueued = false;
1735
+ if (hasSteeringMessages) {
1736
+ steeringQueued = await hasSteeringMessages();
1737
+ } else if (getSteeringMessages) {
1738
+ const msgs = await getSteeringMessages();
1739
+ steeringQueued = (msgs?.length ?? 0) > 0;
1740
+ }
1741
+ if (steeringQueued) {
1742
+ // User steering upgrades an in-flight IRC interrupt: it aborts the
1743
+ // shared signal so foreground tools stop as they do for a user Esc.
1744
+ if (!steeringAbortController.signal.aborted) {
1745
+ interruptState.triggered = true;
1746
+ steeringAbortController.abort();
1747
+ }
1748
+ return;
1749
+ }
1750
+ if (interruptState.triggered) return;
1751
+ if (hasIrcInterrupts && (await hasIrcInterrupts())) {
1752
+ // Peer IRC only aborts interruptible waits: a foreground bash / write
1753
+ // mid-execution keeps running so we never leave partial side effects.
1754
+ interruptState.triggered = true;
1755
+ ircAbortController.abort();
1756
+ }
1757
+ };
1758
+
1759
+ const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
1760
+ if (record.resultEmitted) return;
1761
+ const { toolCall } = record;
1762
+ if (!record.started) {
1763
+ stream.push({
1764
+ type: "tool_execution_start",
1765
+ toolCallId: toolCall.id,
1766
+ toolName: toolCall.name,
1767
+ args: record.args,
1768
+ intent: toolCall.intent,
1769
+ });
1770
+ }
1771
+ stream.push({
1772
+ type: "tool_execution_end",
1773
+ toolCallId: toolCall.id,
1774
+ toolName: toolCall.name,
1775
+ result,
1776
+ isError,
1777
+ });
1778
+
1779
+ const toolResultMessage: ToolResultMessage = {
1780
+ role: "toolResult",
1781
+ toolCallId: toolCall.id,
1782
+ toolName: toolCall.name,
1783
+ content: result.content,
1784
+ details: result.details,
1785
+ isError,
1786
+ ...(result.useless && !isError ? { useless: true } : {}),
1787
+ timestamp: Date.now(),
1788
+ };
1789
+ record.result = result;
1790
+ record.isError = isError;
1791
+ record.toolResultMessage = toolResultMessage;
1792
+ record.resultEmitted = true;
1793
+ emittedToolResults.push(toolResultMessage);
1794
+
1795
+ stream.push({ type: "message_start", message: toolResultMessage });
1796
+ stream.push({ type: "message_end", message: toolResultMessage });
1797
+ };
1798
+
1799
+ const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
1800
+ if (interruptState.triggered) {
1801
+ // Skip both span emission and the collector orphan record here. The
1802
+ // tail sweep below (after `Promise.allSettled`) is the single path
1803
+ // that handles "no result message was produced" — it calls
1804
+ // `recordSkippedTool` and `emitToolResult` once per record, so any
1805
+ // work we did here would double-count.
1806
+ record.skipped = true;
1807
+ return;
1808
+ }
1809
+
1810
+ const { toolCall, tool } = record;
1811
+ let argsForExecution = toolCall.arguments as Record<string, unknown>;
1812
+ if (intentTracing) {
1813
+ const { intent, strippedArgs } = extractIntent(toolCall.arguments);
1814
+ argsForExecution = strippedArgs;
1815
+ if (intent) {
1816
+ toolCall.intent = intent;
1817
+ } else if (typeof tool?.intent === "function") {
1818
+ try {
1819
+ const derived = tool.intent(strippedArgs as never)?.trim();
1820
+ if (derived) {
1821
+ toolCall.intent = derived;
1822
+ }
1823
+ } catch {
1824
+ // intent function must never break tool execution
1825
+ }
1826
+ }
1827
+ }
1828
+ let effectiveArgs: Record<string, unknown>;
1829
+ try {
1830
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1831
+ effectiveArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
1832
+ } catch (validationError) {
1833
+ if (tool?.lenientArgValidation) {
1834
+ effectiveArgs = { ...argsForExecution };
1835
+ delete effectiveArgs.__parseError;
1836
+ delete effectiveArgs.__rawJson;
1837
+ } else {
1838
+ if ("__parseError" in argsForExecution) {
1839
+ record.args = {
1840
+ __parseError: argsForExecution.__parseError,
1841
+ };
1842
+ } else {
1843
+ record.args = argsForExecution;
1844
+ }
1845
+ emitToolResult(
1846
+ record,
1847
+ {
1848
+ content: [
1849
+ {
1850
+ type: "text" as const,
1851
+ text: validationError instanceof Error ? validationError.message : String(validationError),
1852
+ },
1853
+ ],
1854
+ details: {
1855
+ isError: true,
1856
+ error: validationError instanceof Error ? validationError.message : String(validationError),
1857
+ },
1858
+ },
1859
+ true,
1860
+ );
1861
+ return;
1862
+ }
1863
+ }
1864
+
1865
+ record.args = effectiveArgs;
1866
+ if (record.signal.aborted) {
1867
+ record.skipped = true;
1868
+ recordSkippedTool(telemetry, {
1869
+ toolCallId: toolCall.id,
1870
+ toolName: toolCall.name,
1871
+ status: "aborted",
1872
+ });
1873
+ emitToolResult(record, createToolSignalAbortedResult(record.signal), true);
1874
+ return;
1875
+ }
1876
+ record.started = true;
1877
+ stream.push({
1878
+ type: "tool_execution_start",
1879
+ toolCallId: toolCall.id,
1880
+ toolName: toolCall.name,
1881
+ args: effectiveArgs,
1882
+ intent: toolCall.intent,
1883
+ });
1884
+
1885
+ const toolSpan = startExecuteToolSpan(telemetry, {
1886
+ tool,
1887
+ toolName: toolCall.name,
1888
+ toolCallId: toolCall.id,
1889
+ args: effectiveArgs,
1890
+ parent: invokeAgentSpan,
1891
+ });
1892
+ if (toolSpan && toolCall.intent) {
1893
+ toolSpan.setAttribute(PiGenAIAttr.ToolCallIntent, toolCall.intent);
1894
+ }
1895
+
1896
+ let result: AgentToolResult<any> = { content: [], details: {} };
1897
+ let isError = false;
1898
+ let caughtError: unknown;
1899
+ let completedToolExecution = false;
1900
+
1901
+ await runInActiveSpan(toolSpan, async () => {
1902
+ try {
1903
+ if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
1904
+ if (record.signal.aborted) {
1905
+ result = createToolSignalAbortedResult(record.signal);
1906
+ isError = true;
1907
+ return;
1908
+ }
1909
+
1910
+ if (beforeToolCall) {
1911
+ const beforeResult = await beforeToolCall(
1912
+ {
1913
+ assistantMessage,
1914
+ toolCall,
1915
+ args: effectiveArgs,
1916
+ context: currentContext,
1917
+ },
1918
+ record.signal,
1919
+ );
1920
+ if (beforeResult?.block) {
1921
+ throw new ToolCallBlockedError(beforeResult.reason);
1922
+ }
1923
+ }
1924
+ if (record.signal.aborted) {
1925
+ result = createToolSignalAbortedResult(record.signal);
1926
+ isError = true;
1927
+ return;
1928
+ }
1929
+ const executionArgs = transformToolCallArguments
1930
+ ? transformToolCallArguments(effectiveArgs, toolCall.name)
1931
+ : effectiveArgs;
1932
+ record.args = executionArgs;
1933
+
1934
+ const toolContext = getToolContext
1935
+ ? getToolContext({
1936
+ batchId,
1937
+ index,
1938
+ total: toolCalls.length,
1939
+ toolCalls: toolCallInfos,
1940
+ })
1941
+ : undefined;
1942
+ const rawResult = await tool.execute(
1943
+ toolCall.id,
1944
+ executionArgs,
1945
+ record.signal,
1946
+ partialResult => {
1947
+ stream.push({
1948
+ type: "tool_execution_update",
1949
+ toolCallId: toolCall.id,
1950
+ toolName: toolCall.name,
1951
+ args: executionArgs,
1952
+ partialResult: coerceToolResult(partialResult).result,
1953
+ });
1954
+ },
1955
+ toolContext,
1956
+ );
1957
+ completedToolExecution = true;
1958
+ const coerced = coerceToolResult(rawResult);
1959
+ result = coerced.result;
1960
+ if (coerced.malformed || result.isError) isError = true;
1961
+ } catch (e) {
1962
+ caughtError = e;
1963
+ result = {
1964
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
1965
+ details: {},
1966
+ };
1967
+ isError = true;
1968
+ }
1969
+
1970
+ if (afterToolCall && (!record.signal.aborted || completedToolExecution)) {
1971
+ try {
1972
+ const after = await afterToolCall(
1973
+ {
1974
+ assistantMessage,
1975
+ toolCall,
1976
+ args: record.args,
1977
+ result,
1978
+ isError,
1979
+ context: currentContext,
1980
+ },
1981
+ record.signal,
1982
+ );
1983
+ if (after) {
1984
+ // Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
1985
+ // code and may return malformed `content` (non-array / invalid blocks), which
1986
+ // would otherwise be persisted verbatim and corrupt the session — the same
1987
+ // hazard `coerceToolResult` guards on the execute path.
1988
+ const coerced = coerceToolResult({
1989
+ content: after.content ?? result.content,
1990
+ details: after.details ?? result.details,
1991
+ isError: after.isError ?? result.isError,
1992
+ useless: after.useless ?? result.useless,
1993
+ });
1994
+ result = coerced.result;
1995
+ isError = coerced.malformed || (after.isError ?? isError);
1996
+ }
1997
+ } catch (e) {
1998
+ caughtError = e;
1999
+ result = {
2000
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2001
+ details: {},
2002
+ };
2003
+ isError = true;
2004
+ }
2005
+ }
2006
+ });
2007
+
2008
+ const interrupted = interruptState.triggered;
2009
+ const perToolAborted = record.signal.aborted;
2010
+ const abortedDuringExecution = perToolAborted && isError;
2011
+ if (interrupted && perToolAborted && isError) {
2012
+ // This tool's own signal fired AND it failed — it was cut off before producing
2013
+ // a usable result, so report it as skipped.
2014
+ record.skipped = true;
2015
+ emitToolResult(record, createSkippedToolResult(), true);
2016
+ } else {
2017
+ // No interrupt on this signal, or the tool finished (successfully or with a
2018
+ // genuine error) before the interrupt landed. Keep its real result: a completed
2019
+ // tool already ran its side effects, so the model must see what actually
2020
+ // happened rather than a false "skipped". A peer-IRC interrupt on the batch
2021
+ // leaves non-interruptible tools' signals untouched — their genuine errors
2022
+ // survive here instead of being clobbered into "skipped".
2023
+ emitToolResult(record, result, isError);
2024
+ }
2025
+
2026
+ const firstTextBlock = result.content?.[0];
2027
+ const errorMessageForSpan =
2028
+ caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
2029
+ const status = abortedDuringExecution
2030
+ ? "aborted"
2031
+ : caughtError instanceof ToolCallBlockedError
2032
+ ? "blocked"
2033
+ : isError
2034
+ ? "error"
2035
+ : "ok";
2036
+ finishExecuteToolSpan(telemetry, toolSpan, {
2037
+ result,
2038
+ isError,
2039
+ status,
2040
+ errorMessage: errorMessageForSpan,
2041
+ errorObject: caughtError,
2042
+ toolCallId: toolCall.id,
2043
+ toolName: toolCall.name,
2044
+ });
2045
+
2046
+ await checkSteering();
2047
+ };
2048
+
2049
+ let lastExclusive: Promise<void> = Promise.resolve();
2050
+ let sharedTasks: Promise<void>[] = [];
2051
+ const tasks: Promise<void>[] = [];
2052
+
2053
+ for (let index = 0; index < records.length; index++) {
2054
+ const record = records[index];
2055
+ const concurrencyMode = record.tool?.concurrency;
2056
+ let concurrency: "shared" | "exclusive";
2057
+ if (typeof concurrencyMode === "function") {
2058
+ // Resolved from raw pre-validation args; a throwing resolver must not
2059
+ // take down the whole batch, so fall back to the safe (serial) mode.
2060
+ try {
2061
+ concurrency = concurrencyMode(record.args);
2062
+ } catch {
2063
+ concurrency = "exclusive";
2064
+ }
2065
+ } else {
2066
+ concurrency = concurrencyMode ?? "shared";
2067
+ }
2068
+ const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2069
+ const task = start.then(() => runTool(record, index));
2070
+ tasks.push(task);
2071
+ if (concurrency === "exclusive") {
2072
+ lastExclusive = task;
2073
+ sharedTasks = [];
2074
+ } else {
2075
+ sharedTasks.push(task);
2076
+ }
2077
+ }
2078
+
2079
+ // While an interruptible tool is in flight (e.g. a `job`/`irc` wait
2080
+ // blocking on external work), queued steering or interrupting IRC would
2081
+ // otherwise wait out the tool's own window. Poll the non-consuming queues
2082
+ // and abort the shared tool signal so the boundary dequeue below injects
2083
+ // the message promptly. Gated on immediate-interrupt mode + an
2084
+ // interruptible tool; checkSteering is idempotent (no-op once triggered).
2085
+ const watchSteeringWhileRunning =
2086
+ shouldInterruptImmediately &&
2087
+ (hasSteeringMessages !== undefined || getSteeringMessages !== undefined || hasIrcInterrupts !== undefined) &&
2088
+ records.some(r => r.tool?.interruptible === true);
2089
+ const steeringWatchTimer = watchSteeringWhileRunning
2090
+ ? setInterval(() => void checkSteering(), STEERING_INTERRUPT_POLL_MS)
2091
+ : undefined;
2092
+ try {
2093
+ await Promise.allSettled(tasks);
2094
+ } finally {
2095
+ if (steeringWatchTimer !== undefined) clearInterval(steeringWatchTimer);
2096
+ }
2097
+ // Yield after batch tool execution to let GC and I/O catch up,
2098
+ // especially when tool results are large (e.g. bash output).
2099
+ await yieldIfDue();
2100
+
2101
+ for (const record of records) {
2102
+ if (!record.toolResultMessage) {
2103
+ record.skipped = true;
2104
+ recordSkippedTool(telemetry, {
2105
+ toolCallId: record.toolCall.id,
2106
+ toolName: record.toolCall.name,
2107
+ status: "skipped",
2108
+ });
2109
+ emitToolResult(record, createSkippedToolResult(), true);
2110
+ }
2111
+ }
2112
+
2113
+ return { toolResults: emittedToolResults };
2114
+ }
2115
+
2116
+ /**
2117
+ * Create a tool result for a tool call that was aborted or errored before execution.
2118
+ * Maintains the tool_use/tool_result pairing required by the API.
2119
+ */
2120
+ function createAbortedToolResult(
2121
+ toolCall: Extract<AssistantMessage["content"][number], { type: "toolCall" }>,
2122
+ stream: EventStream<AgentEvent, AgentMessage[]>,
2123
+ reason: "aborted" | "error" | "skipped" | "length",
2124
+ errorMessage?: string,
2125
+ ): ToolResultMessage {
2126
+ const message =
2127
+ reason === "aborted"
2128
+ ? "Tool execution was aborted"
2129
+ : reason === "length"
2130
+ ? "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)"
2131
+ : reason === "skipped"
2132
+ ? "Tool call was not executed because the assistant ended its turn"
2133
+ : "Tool execution failed due to an error";
2134
+ const result: AgentToolResult<any> = {
2135
+ content: [{ type: "text", text: errorMessage ? `${message}: ${errorMessage}` : `${message}.` }],
2136
+ details: {},
2137
+ };
2138
+
2139
+ stream.push({
2140
+ type: "tool_execution_start",
2141
+ toolCallId: toolCall.id,
2142
+ toolName: toolCall.name,
2143
+ args: toolCall.arguments,
2144
+ intent: toolCall.intent,
2145
+ });
2146
+ stream.push({
2147
+ type: "tool_execution_end",
2148
+ toolCallId: toolCall.id,
2149
+ toolName: toolCall.name,
2150
+ result,
2151
+ isError: true,
2152
+ });
2153
+
2154
+ const toolResultMessage: ToolResultMessage = {
2155
+ role: "toolResult",
2156
+ toolCallId: toolCall.id,
2157
+ toolName: toolCall.name,
2158
+ content: result.content,
2159
+ details: {},
2160
+ isError: true,
2161
+ timestamp: Date.now(),
2162
+ };
2163
+
2164
+ stream.push({ type: "message_start", message: toolResultMessage });
2165
+ stream.push({ type: "message_end", message: toolResultMessage });
2166
+
2167
+ return toolResultMessage;
2168
+ }
2169
+
2170
+ function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
2171
+ const reason = abortReasonText(signal);
2172
+ return {
2173
+ content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
2174
+ details: {},
2175
+ };
2176
+ }
2177
+
2178
+ function createSkippedToolResult(): AgentToolResult<any> {
2179
+ return {
2180
+ content: [
2181
+ {
2182
+ type: "text",
2183
+ text: "Skipped due to queued user message. Do not count this skipped result as completed work or verification. After the queued message is handled on the next step, retry the skipped tool if it is still needed.",
2184
+ },
2185
+ ],
2186
+ details: {},
2187
+ };
2188
+ }