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