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