@oh-my-pi/pi-agent-core 15.10.3 → 15.10.5
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 +18 -0
- package/dist/types/agent.d.ts +0 -7
- package/dist/types/telemetry.d.ts +2 -1
- package/dist/types/types.d.ts +0 -7
- package/package.json +4 -4
- package/src/agent-loop.ts +321 -181
- package/src/agent.ts +0 -17
- package/src/telemetry.ts +2 -1
- package/src/types.ts +0 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.5] - 2026-06-08
|
|
6
|
+
### Removed
|
|
7
|
+
|
|
8
|
+
- Removed the `maxToolCallsPerTurn` option from `AgentOptions` and `AgentLoopConfig`, so assistant turns are no longer capped after a configured number of completed tool calls
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Fixed stalled aborted assistant responses so the run now stops without waiting for provider iterator cleanup and returns the aborted message promptly
|
|
13
|
+
- Fixed `afterToolCall` handling so it now runs for completed tool executions even after a run is aborted so tool post-processing still applies
|
|
14
|
+
- Fixed `agentLoopDetailed().detailed()` so run telemetry and coverage are captured before `stream.result()` resolves.
|
|
15
|
+
- Fixed agent-loop stream invariants so `agentLoopContinue` no longer mutates the caller's message array, emitted assistant events snapshot mutable provider content, terminal provider events win over late abort signals, transformed tool arguments are reflected consistently in hooks/events, and successful run-end telemetry fires from the same finalization path as failures.
|
|
16
|
+
- Fixed tool result parsing to mark assistant tool outputs with unsupported content block shapes as errors and include a diagnostic text block
|
|
17
|
+
- Fixed GPT-5 Harmony leakage handling by recovering valid leaked tool calls when possible and discarding leaked partial assistant output before retrying
|
|
18
|
+
- Fixed tool-call cancellation handling so aborted tools are marked aborted with an explicit reason and do not report generic errors
|
|
19
|
+
- Fixed tool-call completion so assistant messages on abort keep only completed tool-call blocks and continue processing tool calls when a length stop still included results
|
|
20
|
+
- Fixed deliberate aborts (TTSR rule matches, user-interrupt labels) so a mid-stream tool-call block that never reached `toolcall_end` is retained on the aborted assistant message and paired with a placeholder result labeled by the abort reason, instead of being dropped; anonymous aborts (bare `abort()`) still drop incomplete tool calls whose partial arguments are unsafe to replay
|
|
21
|
+
- Fixed runs that stopped with reason `length` after returning tool results so execution continues to handle additional tool calls
|
|
22
|
+
|
|
5
23
|
## [15.10.3] - 2026-06-08
|
|
6
24
|
|
|
7
25
|
### Added
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -31,11 +31,6 @@ export interface AgentOptions {
|
|
|
31
31
|
* - "wait": defer steering until the current turn completes
|
|
32
32
|
*/
|
|
33
33
|
interruptMode?: "immediate" | "wait";
|
|
34
|
-
/**
|
|
35
|
-
* Maximum completed tool calls to accept from one streamed assistant turn before
|
|
36
|
-
* executing the batch. Undefined disables batching.
|
|
37
|
-
*/
|
|
38
|
-
maxToolCallsPerTurn?: number;
|
|
39
34
|
/**
|
|
40
35
|
* API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
|
|
41
36
|
*/
|
|
@@ -281,8 +276,6 @@ export declare class Agent {
|
|
|
281
276
|
* Set to 0 to disable the cap.
|
|
282
277
|
*/
|
|
283
278
|
set maxRetryDelayMs(value: number | undefined);
|
|
284
|
-
get maxToolCallsPerTurn(): number | undefined;
|
|
285
|
-
set maxToolCallsPerTurn(value: number | undefined);
|
|
286
279
|
get state(): AgentState;
|
|
287
280
|
get appendOnlyContext(): AppendOnlyContextManager | undefined;
|
|
288
281
|
setAppendOnlyContext(manager?: AppendOnlyContextManager): void;
|
|
@@ -527,7 +527,8 @@ export declare function finishInvokeAgentSpan(telemetry: AgentTelemetry | undefi
|
|
|
527
527
|
} | undefined;
|
|
528
528
|
/**
|
|
529
529
|
* Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
|
|
530
|
-
are caught and
|
|
530
|
+
* are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
|
|
531
|
+
* when no hook is set) — telemetry callbacks NEVER turn a
|
|
531
532
|
* successful agent run into a failed one. Idempotent at the call site via
|
|
532
533
|
* {@link AgentRunCollector.markRunEnded}; callers must check that before
|
|
533
534
|
* calling this helper.
|
package/dist/types/types.d.ts
CHANGED
|
@@ -23,13 +23,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
23
23
|
* - "wait" = defer steering until the current turn completes
|
|
24
24
|
*/
|
|
25
25
|
interruptMode?: "immediate" | "wait";
|
|
26
|
-
/**
|
|
27
|
-
* Maximum completed tool calls to accept from one streamed assistant turn before
|
|
28
|
-
* cutting the provider stream and executing that batch. The cap is enforced on
|
|
29
|
-
* `toolcall_end` so every executed call has complete arguments. Undefined disables
|
|
30
|
-
* batching.
|
|
31
|
-
*/
|
|
32
|
-
maxToolCallsPerTurn?: number;
|
|
33
26
|
/**
|
|
34
27
|
* Optional session identifier forwarded to LLM providers.
|
|
35
28
|
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "15.10.
|
|
4
|
+
"version": "15.10.5",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "15.10.
|
|
39
|
-
"@oh-my-pi/pi-natives": "15.10.
|
|
40
|
-
"@oh-my-pi/pi-utils": "15.10.
|
|
38
|
+
"@oh-my-pi/pi-ai": "15.10.5",
|
|
39
|
+
"@oh-my-pi/pi-natives": "15.10.5",
|
|
40
|
+
"@oh-my-pi/pi-utils": "15.10.5",
|
|
41
41
|
"@opentelemetry/api": "^1.9.1"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type HarmonyDetection,
|
|
24
24
|
type HarmonyRecoveredToolCall,
|
|
25
25
|
isHarmonyLeakMitigationTarget,
|
|
26
|
+
recoverHarmonyToolCall,
|
|
26
27
|
signalListLabel,
|
|
27
28
|
} from "./harmony-leak";
|
|
28
29
|
import { type AgentRunCoverage, type AgentRunSummary, ToolCallBlockedError } from "./run-collector";
|
|
@@ -68,6 +69,76 @@ class HarmonyLeakInterruption extends Error {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
type AssistantContentBlock = AssistantMessage["content"][number];
|
|
73
|
+
type AssistantToolCallBlock = Extract<AssistantContentBlock, { type: "toolCall" }>;
|
|
74
|
+
type CloneableRecord = Record<string, unknown>;
|
|
75
|
+
|
|
76
|
+
function cloneUnknown(value: unknown): unknown {
|
|
77
|
+
if (Array.isArray(value)) return value.map(cloneUnknown);
|
|
78
|
+
if (!value || typeof value !== "object") return value;
|
|
79
|
+
const source = value as CloneableRecord;
|
|
80
|
+
const out: CloneableRecord = {};
|
|
81
|
+
for (const [key, child] of Object.entries(source)) {
|
|
82
|
+
out[key] = cloneUnknown(child);
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function cloneToolArguments(args: AssistantToolCallBlock["arguments"]): AssistantToolCallBlock["arguments"] {
|
|
88
|
+
return cloneUnknown(args) as AssistantToolCallBlock["arguments"];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function snapshotAssistantContentBlock(block: AssistantContentBlock): AssistantContentBlock {
|
|
92
|
+
switch (block.type) {
|
|
93
|
+
case "text":
|
|
94
|
+
return { ...block };
|
|
95
|
+
case "thinking":
|
|
96
|
+
return { ...block };
|
|
97
|
+
case "redactedThinking":
|
|
98
|
+
return { ...block };
|
|
99
|
+
case "toolCall":
|
|
100
|
+
return { ...block, arguments: cloneToolArguments(block.arguments) };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
|
|
105
|
+
return {
|
|
106
|
+
...message,
|
|
107
|
+
content: message.content.map(snapshotAssistantContentBlock),
|
|
108
|
+
usage: {
|
|
109
|
+
...message.usage,
|
|
110
|
+
cost: { ...message.usage.cost },
|
|
111
|
+
},
|
|
112
|
+
disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function snapshotAssistantMessageEvent(event: AssistantMessageEvent): AssistantMessageEvent {
|
|
117
|
+
switch (event.type) {
|
|
118
|
+
case "start":
|
|
119
|
+
return { ...event, partial: snapshotAssistantMessage(event.partial) };
|
|
120
|
+
case "text_start":
|
|
121
|
+
case "text_delta":
|
|
122
|
+
case "text_end":
|
|
123
|
+
case "thinking_start":
|
|
124
|
+
case "thinking_delta":
|
|
125
|
+
case "thinking_end":
|
|
126
|
+
case "toolcall_start":
|
|
127
|
+
case "toolcall_delta":
|
|
128
|
+
return { ...event, partial: snapshotAssistantMessage(event.partial) };
|
|
129
|
+
case "toolcall_end":
|
|
130
|
+
return {
|
|
131
|
+
...event,
|
|
132
|
+
toolCall: snapshotAssistantContentBlock(event.toolCall) as AssistantToolCallBlock,
|
|
133
|
+
partial: snapshotAssistantMessage(event.partial),
|
|
134
|
+
};
|
|
135
|
+
case "done":
|
|
136
|
+
return { ...event, message: snapshotAssistantMessage(event.message) };
|
|
137
|
+
case "error":
|
|
138
|
+
return { ...event, error: snapshotAssistantMessage(event.error) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
71
142
|
/**
|
|
72
143
|
* Normalize a value coming back from `tool.execute()` (or its streaming partial-update callback)
|
|
73
144
|
* into a structurally valid {@link AgentToolResult}.
|
|
@@ -77,7 +148,7 @@ class HarmonyLeakInterruption extends Error {
|
|
|
77
148
|
* (missing `content` array → crash on reload). We coerce at the single boundary where untyped
|
|
78
149
|
* results enter the agent loop, so every downstream consumer can rely on the type.
|
|
79
150
|
*/
|
|
80
|
-
function coerceToolResult(raw: unknown): { result: AgentToolResult<
|
|
151
|
+
function coerceToolResult(raw: unknown): { result: AgentToolResult<unknown>; malformed: boolean } {
|
|
81
152
|
const rawObj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
82
153
|
const rawContent = rawObj?.content;
|
|
83
154
|
const details = rawObj && "details" in rawObj ? rawObj.details : {};
|
|
@@ -98,8 +169,12 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
|
|
|
98
169
|
}
|
|
99
170
|
|
|
100
171
|
const content: AgentToolResult["content"] = [];
|
|
172
|
+
let invalidBlocks = 0;
|
|
101
173
|
for (const block of rawContent) {
|
|
102
|
-
if (!block || typeof block !== "object" || !("type" in block))
|
|
174
|
+
if (!block || typeof block !== "object" || !("type" in block)) {
|
|
175
|
+
invalidBlocks++;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
103
178
|
if (block.type === "text" && typeof (block as { text?: unknown }).text === "string") {
|
|
104
179
|
content.push({ type: "text", text: sanitizeText((block as { text: string }).text) });
|
|
105
180
|
} else if (
|
|
@@ -108,9 +183,20 @@ function coerceToolResult(raw: unknown): { result: AgentToolResult<any>; malform
|
|
|
108
183
|
typeof (block as { mimeType?: unknown }).mimeType === "string"
|
|
109
184
|
) {
|
|
110
185
|
content.push(block as { type: "image"; data: string; mimeType: string });
|
|
186
|
+
} else {
|
|
187
|
+
invalidBlocks++;
|
|
111
188
|
}
|
|
112
189
|
}
|
|
113
|
-
|
|
190
|
+
if (invalidBlocks > 0) {
|
|
191
|
+
content.push({
|
|
192
|
+
type: "text",
|
|
193
|
+
text: `Tool returned an invalid result: ${invalidBlocks} content block${invalidBlocks === 1 ? "" : "s"} had an unsupported shape.`,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
result: { content, details, ...(explicitError || invalidBlocks > 0 ? { isError: true } : {}) },
|
|
198
|
+
malformed: invalidBlocks > 0,
|
|
199
|
+
};
|
|
114
200
|
}
|
|
115
201
|
|
|
116
202
|
/**
|
|
@@ -176,7 +262,7 @@ export function agentLoopContinue(
|
|
|
176
262
|
|
|
177
263
|
(async () => {
|
|
178
264
|
const newMessages: AgentMessage[] = [];
|
|
179
|
-
const currentContext: AgentContext = { ...context };
|
|
265
|
+
const currentContext: AgentContext = { ...context, messages: [...context.messages] };
|
|
180
266
|
|
|
181
267
|
stream.push({ type: "agent_start" });
|
|
182
268
|
stream.push({ type: "turn_start" });
|
|
@@ -313,22 +399,26 @@ function normalizeMessagesForProvider(
|
|
|
313
399
|
return messages;
|
|
314
400
|
}
|
|
315
401
|
|
|
316
|
-
let
|
|
317
|
-
const
|
|
318
|
-
if (message.role !== "assistant" || !Array.isArray(message.content))
|
|
319
|
-
|
|
402
|
+
let hasThinking = false;
|
|
403
|
+
for (const message of messages) {
|
|
404
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
405
|
+
for (const block of message.content) {
|
|
406
|
+
if (block.type === "thinking") {
|
|
407
|
+
hasThinking = true;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
320
410
|
}
|
|
411
|
+
if (hasThinking) break;
|
|
412
|
+
}
|
|
413
|
+
if (!hasThinking) return messages;
|
|
321
414
|
|
|
322
|
-
|
|
323
|
-
if (
|
|
415
|
+
return messages.map(message => {
|
|
416
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
324
417
|
return message;
|
|
325
418
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
return { ...message, content: filtered };
|
|
419
|
+
const filtered = message.content.filter(block => block.type !== "thinking");
|
|
420
|
+
return filtered.length === message.content.length ? message : { ...message, content: filtered };
|
|
329
421
|
});
|
|
330
|
-
|
|
331
|
-
return changed ? normalized : messages;
|
|
332
422
|
}
|
|
333
423
|
|
|
334
424
|
export const INTENT_FIELD = "_i";
|
|
@@ -445,27 +535,6 @@ interface StepCounter {
|
|
|
445
535
|
count: number;
|
|
446
536
|
}
|
|
447
537
|
|
|
448
|
-
function normalizeMaxToolCallsPerTurn(value: number | undefined): number | undefined {
|
|
449
|
-
if (value === undefined || !Number.isFinite(value)) return undefined;
|
|
450
|
-
const normalized = Math.trunc(value);
|
|
451
|
-
return normalized > 0 ? normalized : undefined;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function cloneAssistantMessageForToolCallCap(message: AssistantMessage): AssistantMessage {
|
|
455
|
-
return {
|
|
456
|
-
...message,
|
|
457
|
-
content: message.content.map(block => {
|
|
458
|
-
if (block.type === "toolCall") {
|
|
459
|
-
return { ...block, arguments: structuredClone(block.arguments) };
|
|
460
|
-
}
|
|
461
|
-
return { ...block };
|
|
462
|
-
}),
|
|
463
|
-
stopReason: "toolUse",
|
|
464
|
-
errorMessage: undefined,
|
|
465
|
-
errorStatus: undefined,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
|
|
469
538
|
/**
|
|
470
539
|
* Resolve aside entries at the moment the loop is about to inject them. Each entry
|
|
471
540
|
* is either a ready {@link AgentMessage} or a sync thunk evaluated here so the
|
|
@@ -573,6 +642,12 @@ async function runLoopBody(
|
|
|
573
642
|
continue;
|
|
574
643
|
}
|
|
575
644
|
}
|
|
645
|
+
if (recovered) {
|
|
646
|
+
message = snapshotAssistantMessage(message);
|
|
647
|
+
currentContext.messages.push(message);
|
|
648
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(message) });
|
|
649
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(message) });
|
|
650
|
+
}
|
|
576
651
|
newMessages.push(message);
|
|
577
652
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
578
653
|
|
|
@@ -661,13 +736,24 @@ async function runLoopBody(
|
|
|
661
736
|
status: "skipped",
|
|
662
737
|
});
|
|
663
738
|
}
|
|
739
|
+
if (message.stopReason === "length" && toolResults.length > 0) {
|
|
740
|
+
hasMoreToolCalls = true;
|
|
741
|
+
}
|
|
664
742
|
}
|
|
665
743
|
|
|
666
744
|
stream.push({ type: "turn_end", message, toolResults });
|
|
667
745
|
|
|
668
746
|
const steering = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []);
|
|
669
|
-
|
|
670
|
-
|
|
747
|
+
if (hasMoreToolCalls) {
|
|
748
|
+
// Mid-work: fold any non-interrupting asides into the next turn alongside steering.
|
|
749
|
+
const asides = resolveAsides(await config.getAsideMessages?.());
|
|
750
|
+
pendingMessages = asides.length > 0 ? [...steering, ...asides] : steering;
|
|
751
|
+
} else {
|
|
752
|
+
// Stop boundary: only steering (live user input) forces another turn here. Leave
|
|
753
|
+
// asides for the outer drain below so a passive aside can't trigger an extra model
|
|
754
|
+
// turn ahead of a queued follow-up — the outer drain batches asides + follow-ups together.
|
|
755
|
+
pendingMessages = steering;
|
|
756
|
+
}
|
|
671
757
|
}
|
|
672
758
|
|
|
673
759
|
// Agent would stop here. Drain non-interrupting asides + follow-up messages.
|
|
@@ -761,18 +847,11 @@ async function streamAssistantResponse(
|
|
|
761
847
|
const dynamicReasoning = config.getReasoning?.();
|
|
762
848
|
const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
|
|
763
849
|
const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (toolCallCapAbortController) requestSignals.push(toolCallCapAbortController.signal);
|
|
770
|
-
const requestSignal =
|
|
771
|
-
requestSignals.length === 0
|
|
772
|
-
? undefined
|
|
773
|
-
: requestSignals.length === 1
|
|
774
|
-
? requestSignals[0]
|
|
775
|
-
: AbortSignal.any(requestSignals);
|
|
850
|
+
const requestSignal = harmonyAbortController
|
|
851
|
+
? signal
|
|
852
|
+
? AbortSignal.any([signal, harmonyAbortController.signal])
|
|
853
|
+
: harmonyAbortController.signal
|
|
854
|
+
: signal;
|
|
776
855
|
const effectiveTemperature =
|
|
777
856
|
harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
|
|
778
857
|
const effectiveToolChoice = dynamicToolChoice ?? config.toolChoice;
|
|
@@ -844,27 +923,27 @@ async function streamAssistantResponse(
|
|
|
844
923
|
|
|
845
924
|
let partialMessage: AssistantMessage | null = null;
|
|
846
925
|
let addedPartial = false;
|
|
926
|
+
const completedToolCallIds = new Set<string>();
|
|
847
927
|
|
|
848
928
|
const responseIterator = response[Symbol.asyncIterator]();
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
responseIterator.return?.()?.catch(() => {});
|
|
856
|
-
if (!capFinalized) {
|
|
857
|
-
if (addedPartial) {
|
|
858
|
-
context.messages[context.messages.length - 1] = cappedMessage;
|
|
859
|
-
} else {
|
|
860
|
-
context.messages.push(cappedMessage);
|
|
861
|
-
stream.push({ type: "message_start", message: { ...cappedMessage } });
|
|
862
|
-
}
|
|
863
|
-
stream.push({ type: "message_end", message: cappedMessage });
|
|
864
|
-
await finishChat(cappedMessage);
|
|
865
|
-
capFinalized = true;
|
|
929
|
+
const finishAbortedStream = async (): Promise<AssistantMessage> => {
|
|
930
|
+
try {
|
|
931
|
+
const cleanup = responseIterator.return?.();
|
|
932
|
+
if (cleanup) void cleanup.catch(() => {});
|
|
933
|
+
} catch {
|
|
934
|
+
// Provider cancellation failures cannot change the committed aborted message.
|
|
866
935
|
}
|
|
867
|
-
|
|
936
|
+
const aborted = emitAbortedAssistantMessage(
|
|
937
|
+
partialMessage,
|
|
938
|
+
addedPartial,
|
|
939
|
+
completedToolCallIds,
|
|
940
|
+
context,
|
|
941
|
+
config,
|
|
942
|
+
stream,
|
|
943
|
+
requestSignal,
|
|
944
|
+
);
|
|
945
|
+
await finishChat(aborted);
|
|
946
|
+
return aborted;
|
|
868
947
|
};
|
|
869
948
|
|
|
870
949
|
// Set up a single abort race: register the abort listener once for the whole
|
|
@@ -874,16 +953,7 @@ async function streamAssistantResponse(
|
|
|
874
953
|
let detachAbortListener: (() => void) | undefined;
|
|
875
954
|
if (requestSignal) {
|
|
876
955
|
if (requestSignal.aborted) {
|
|
877
|
-
|
|
878
|
-
partialMessage,
|
|
879
|
-
addedPartial,
|
|
880
|
-
context,
|
|
881
|
-
config,
|
|
882
|
-
stream,
|
|
883
|
-
requestSignal,
|
|
884
|
-
);
|
|
885
|
-
await finishChat(aborted);
|
|
886
|
-
return aborted;
|
|
956
|
+
return await finishAbortedStream();
|
|
887
957
|
}
|
|
888
958
|
const { promise, resolve } = Promise.withResolvers<typeof ABORTED>();
|
|
889
959
|
const onAbort = () => resolve(ABORTED);
|
|
@@ -898,45 +968,51 @@ async function streamAssistantResponse(
|
|
|
898
968
|
if (abortRacePromise) {
|
|
899
969
|
const result = await Promise.race([responseIterator.next(), abortRacePromise]);
|
|
900
970
|
if (result === ABORTED) {
|
|
901
|
-
|
|
902
|
-
const capped = await finishCappedAssistantMessage();
|
|
903
|
-
if (capped) return capped;
|
|
904
|
-
}
|
|
905
|
-
responseIterator.return?.()?.catch(() => {});
|
|
906
|
-
const aborted = emitAbortedAssistantMessage(
|
|
907
|
-
partialMessage,
|
|
908
|
-
addedPartial,
|
|
909
|
-
context,
|
|
910
|
-
config,
|
|
911
|
-
stream,
|
|
912
|
-
requestSignal,
|
|
913
|
-
);
|
|
914
|
-
await finishChat(aborted);
|
|
915
|
-
return aborted;
|
|
971
|
+
return await finishAbortedStream();
|
|
916
972
|
}
|
|
917
973
|
next = result;
|
|
918
974
|
} else {
|
|
919
975
|
next = await responseIterator.next();
|
|
920
976
|
}
|
|
921
|
-
if (requestSignal?.aborted) {
|
|
922
|
-
if (toolCallCapAbortController?.signal.aborted) {
|
|
923
|
-
const capped = await finishCappedAssistantMessage();
|
|
924
|
-
if (capped) return capped;
|
|
925
|
-
}
|
|
926
|
-
const aborted = emitAbortedAssistantMessage(
|
|
927
|
-
partialMessage,
|
|
928
|
-
addedPartial,
|
|
929
|
-
context,
|
|
930
|
-
config,
|
|
931
|
-
stream,
|
|
932
|
-
requestSignal,
|
|
933
|
-
);
|
|
934
|
-
await finishChat(aborted);
|
|
935
|
-
return aborted;
|
|
936
|
-
}
|
|
937
977
|
if (next.done) break;
|
|
938
978
|
|
|
939
979
|
const event = next.value;
|
|
980
|
+
if (event.type === "done" || event.type === "error") {
|
|
981
|
+
let finalMessage = retainCompletedToolCalls(await response.result(), completedToolCallIds);
|
|
982
|
+
if (harmonyMitigationEnabled) {
|
|
983
|
+
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
|
|
984
|
+
if (detection) {
|
|
985
|
+
const recovered = recoverHarmonyToolCall(finalMessage, detection);
|
|
986
|
+
const removed = recovered?.removed ?? extractHarmonyRemoved(finalMessage, detection);
|
|
987
|
+
if (addedPartial) {
|
|
988
|
+
emitDiscardedHarmonyPartial(
|
|
989
|
+
partialMessage,
|
|
990
|
+
stream,
|
|
991
|
+
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
|
992
|
+
);
|
|
993
|
+
context.messages.pop();
|
|
994
|
+
addedPartial = false;
|
|
995
|
+
}
|
|
996
|
+
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
finalMessage = snapshotAssistantMessage(finalMessage);
|
|
1000
|
+
if (addedPartial) {
|
|
1001
|
+
context.messages[context.messages.length - 1] = finalMessage;
|
|
1002
|
+
} else {
|
|
1003
|
+
context.messages.push(finalMessage);
|
|
1004
|
+
}
|
|
1005
|
+
if (!addedPartial) {
|
|
1006
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(finalMessage) });
|
|
1007
|
+
}
|
|
1008
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(finalMessage) });
|
|
1009
|
+
await finishChat(finalMessage);
|
|
1010
|
+
return finalMessage;
|
|
1011
|
+
}
|
|
1012
|
+
if (requestSignal?.aborted) {
|
|
1013
|
+
return await finishAbortedStream();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
940
1016
|
// Yield to the event loop periodically to prevent busy-wait
|
|
941
1017
|
// when the LLM is streaming chunks faster than the loop can rest.
|
|
942
1018
|
await yieldIfDue();
|
|
@@ -946,7 +1022,7 @@ async function streamAssistantResponse(
|
|
|
946
1022
|
partialMessage = event.partial;
|
|
947
1023
|
context.messages.push(partialMessage);
|
|
948
1024
|
addedPartial = true;
|
|
949
|
-
stream.push({ type: "message_start", message:
|
|
1025
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(partialMessage) });
|
|
950
1026
|
break;
|
|
951
1027
|
|
|
952
1028
|
case "text_start":
|
|
@@ -959,72 +1035,48 @@ async function streamAssistantResponse(
|
|
|
959
1035
|
case "toolcall_delta":
|
|
960
1036
|
case "toolcall_end":
|
|
961
1037
|
if (partialMessage) {
|
|
1038
|
+
if (event.type === "toolcall_end") {
|
|
1039
|
+
completedToolCallIds.add(event.toolCall.id);
|
|
1040
|
+
}
|
|
962
1041
|
partialMessage = event.partial;
|
|
963
1042
|
context.messages[context.messages.length - 1] = partialMessage;
|
|
964
1043
|
config.onAssistantMessageEvent?.(partialMessage, event);
|
|
965
|
-
if (signal?.aborted) {
|
|
966
|
-
continue;
|
|
967
|
-
}
|
|
968
1044
|
stream.push({
|
|
969
1045
|
type: "message_update",
|
|
970
|
-
assistantMessageEvent: event,
|
|
971
|
-
message:
|
|
1046
|
+
assistantMessageEvent: snapshotAssistantMessageEvent(event),
|
|
1047
|
+
message: snapshotAssistantMessage(partialMessage),
|
|
972
1048
|
});
|
|
973
|
-
if (event.type === "toolcall_end" && maxToolCallsPerTurn !== undefined) {
|
|
974
|
-
completedToolCalls++;
|
|
975
|
-
if (completedToolCalls >= maxToolCallsPerTurn) {
|
|
976
|
-
cappedMessage = cloneAssistantMessageForToolCallCap(partialMessage);
|
|
977
|
-
toolCallCapAbortController?.abort();
|
|
978
|
-
const capped = await finishCappedAssistantMessage();
|
|
979
|
-
if (capped) return capped;
|
|
980
|
-
}
|
|
981
|
-
}
|
|
982
1049
|
}
|
|
983
1050
|
break;
|
|
984
|
-
|
|
985
|
-
case "done":
|
|
986
|
-
case "error": {
|
|
987
|
-
const finalMessage = await response.result();
|
|
988
|
-
if (harmonyMitigationEnabled) {
|
|
989
|
-
const detection = detectHarmonyLeakInAssistantMessage(finalMessage);
|
|
990
|
-
if (detection) {
|
|
991
|
-
const removed = extractHarmonyRemoved(finalMessage, detection);
|
|
992
|
-
if (addedPartial) {
|
|
993
|
-
context.messages.pop();
|
|
994
|
-
addedPartial = false;
|
|
995
|
-
}
|
|
996
|
-
throw new HarmonyLeakInterruption(detection, removed);
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
if (addedPartial) {
|
|
1000
|
-
context.messages[context.messages.length - 1] = finalMessage;
|
|
1001
|
-
} else {
|
|
1002
|
-
context.messages.push(finalMessage);
|
|
1003
|
-
}
|
|
1004
|
-
if (!addedPartial) {
|
|
1005
|
-
stream.push({ type: "message_start", message: { ...finalMessage } });
|
|
1006
|
-
}
|
|
1007
|
-
stream.push({ type: "message_end", message: finalMessage });
|
|
1008
|
-
await finishChat(finalMessage);
|
|
1009
|
-
return finalMessage;
|
|
1010
|
-
}
|
|
1011
1051
|
}
|
|
1012
1052
|
}
|
|
1013
1053
|
} finally {
|
|
1014
1054
|
detachAbortListener?.();
|
|
1015
1055
|
}
|
|
1016
1056
|
|
|
1017
|
-
|
|
1057
|
+
let trailing = await response.result();
|
|
1018
1058
|
if (harmonyMitigationEnabled) {
|
|
1019
1059
|
const detection = detectHarmonyLeakInAssistantMessage(trailing);
|
|
1020
1060
|
if (detection) {
|
|
1061
|
+
const recovered = recoverHarmonyToolCall(trailing, detection);
|
|
1062
|
+
const removed = recovered?.removed ?? extractHarmonyRemoved(trailing, detection);
|
|
1021
1063
|
if (addedPartial) {
|
|
1064
|
+
emitDiscardedHarmonyPartial(
|
|
1065
|
+
partialMessage,
|
|
1066
|
+
stream,
|
|
1067
|
+
`Discarded after GPT-5 Harmony protocol leakage (${signalListLabel(detection.signals)})`,
|
|
1068
|
+
);
|
|
1022
1069
|
context.messages.pop();
|
|
1023
1070
|
addedPartial = false;
|
|
1024
1071
|
}
|
|
1025
|
-
throw new HarmonyLeakInterruption(detection,
|
|
1072
|
+
throw new HarmonyLeakInterruption(detection, removed, recovered);
|
|
1026
1073
|
}
|
|
1027
1074
|
}
|
|
1075
|
+
trailing = snapshotAssistantMessage(trailing);
|
|
1076
|
+
if (addedPartial) {
|
|
1077
|
+
context.messages[context.messages.length - 1] = trailing;
|
|
1078
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(trailing) });
|
|
1079
|
+
}
|
|
1028
1080
|
await finishChat(trailing);
|
|
1029
1081
|
return trailing;
|
|
1030
1082
|
});
|
|
@@ -1038,6 +1090,33 @@ async function streamAssistantResponse(
|
|
|
1038
1090
|
}
|
|
1039
1091
|
}
|
|
1040
1092
|
|
|
1093
|
+
function retainCompletedToolCalls(
|
|
1094
|
+
message: AssistantMessage,
|
|
1095
|
+
completedToolCallIds: ReadonlySet<string>,
|
|
1096
|
+
): AssistantMessage {
|
|
1097
|
+
if (message.stopReason !== "error" && message.stopReason !== "aborted") return message;
|
|
1098
|
+
let changed = false;
|
|
1099
|
+
const content = message.content.filter(block => {
|
|
1100
|
+
if (block.type !== "toolCall") return true;
|
|
1101
|
+
const keep = completedToolCallIds.has(block.id);
|
|
1102
|
+
if (!keep) changed = true;
|
|
1103
|
+
return keep;
|
|
1104
|
+
});
|
|
1105
|
+
return changed ? { ...message, content } : message;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
function emitDiscardedHarmonyPartial(
|
|
1109
|
+
partialMessage: AssistantMessage | null,
|
|
1110
|
+
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1111
|
+
errorMessage: string,
|
|
1112
|
+
): void {
|
|
1113
|
+
if (!partialMessage) return;
|
|
1114
|
+
stream.push({
|
|
1115
|
+
type: "message_end",
|
|
1116
|
+
message: snapshotAssistantMessage({ ...partialMessage, stopReason: "error", errorMessage }),
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1041
1120
|
/** Resolve the human-readable reason an abort carried. A caller that aborts via
|
|
1042
1121
|
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
|
|
1043
1122
|
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
|
|
@@ -1053,16 +1132,31 @@ export function abortReasonText(signal: AbortSignal | undefined): string {
|
|
|
1053
1132
|
return "Request was aborted";
|
|
1054
1133
|
}
|
|
1055
1134
|
|
|
1135
|
+
/** True when an abort carried a *deliberate*, human-meaningful reason — a string
|
|
1136
|
+
* reason or a non-`AbortError` `Error` (TTSR rule match, user-interrupt label).
|
|
1137
|
+
* A bare `abort()` (default `AbortError` `DOMException`) is anonymous and returns
|
|
1138
|
+
* false. Used to decide whether a mid-stream tool call survives the abort: a
|
|
1139
|
+
* deliberate interruption is a conscious decision made after the (partial) call
|
|
1140
|
+
* was observed, so the block is retained and paired with a labeled placeholder;
|
|
1141
|
+
* an anonymous abort drops incomplete calls whose args may be unsafe to replay. */
|
|
1142
|
+
function isExplicitAbortReason(signal: AbortSignal | undefined): boolean {
|
|
1143
|
+
const reason = signal?.reason;
|
|
1144
|
+
if (typeof reason === "string") return reason.trim().length > 0;
|
|
1145
|
+
if (reason instanceof Error) return reason.name !== "AbortError" && reason.message.trim().length > 0;
|
|
1146
|
+
return false;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1056
1149
|
function emitAbortedAssistantMessage(
|
|
1057
1150
|
partialMessage: AssistantMessage | null,
|
|
1058
1151
|
addedPartial: boolean,
|
|
1152
|
+
completedToolCallIds: ReadonlySet<string>,
|
|
1059
1153
|
context: AgentContext,
|
|
1060
1154
|
config: AgentLoopConfig,
|
|
1061
1155
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
1062
1156
|
requestSignal: AbortSignal | undefined,
|
|
1063
1157
|
): AssistantMessage {
|
|
1064
1158
|
const errorMessage = abortReasonText(requestSignal);
|
|
1065
|
-
const
|
|
1159
|
+
const base: AssistantMessage = partialMessage
|
|
1066
1160
|
? { ...partialMessage, stopReason: "aborted", errorMessage }
|
|
1067
1161
|
: {
|
|
1068
1162
|
role: "assistant",
|
|
@@ -1082,13 +1176,19 @@ function emitAbortedAssistantMessage(
|
|
|
1082
1176
|
errorMessage,
|
|
1083
1177
|
timestamp: Date.now(),
|
|
1084
1178
|
};
|
|
1179
|
+
// A deliberate, labeled abort (TTSR rule match, user interrupt) keeps every
|
|
1180
|
+
// committed tool-call block so the loop pairs it with a placeholder labeled by
|
|
1181
|
+
// `errorMessage`; an anonymous abort still drops calls that never completed
|
|
1182
|
+
// (no `toolcall_end`), whose partial args are unsafe to replay.
|
|
1183
|
+
const retained = isExplicitAbortReason(requestSignal) ? base : retainCompletedToolCalls(base, completedToolCallIds);
|
|
1184
|
+
const abortedMessage = snapshotAssistantMessage(retained);
|
|
1085
1185
|
if (addedPartial) {
|
|
1086
1186
|
context.messages[context.messages.length - 1] = abortedMessage;
|
|
1087
1187
|
} else {
|
|
1088
1188
|
context.messages.push(abortedMessage);
|
|
1089
|
-
stream.push({ type: "message_start", message:
|
|
1189
|
+
stream.push({ type: "message_start", message: snapshotAssistantMessage(abortedMessage) });
|
|
1090
1190
|
}
|
|
1091
|
-
stream.push({ type: "message_end", message: abortedMessage });
|
|
1191
|
+
stream.push({ type: "message_end", message: snapshotAssistantMessage(abortedMessage) });
|
|
1092
1192
|
return abortedMessage;
|
|
1093
1193
|
}
|
|
1094
1194
|
|
|
@@ -1126,7 +1226,7 @@ async function executeToolCalls(
|
|
|
1126
1226
|
: steeringAbortController.signal;
|
|
1127
1227
|
const interruptState = { triggered: false };
|
|
1128
1228
|
let steeringMessages: AgentMessage[] | undefined;
|
|
1129
|
-
let
|
|
1229
|
+
let steeringCheckTail: Promise<void> = Promise.resolve();
|
|
1130
1230
|
|
|
1131
1231
|
const records = toolCalls.map(toolCall => ({
|
|
1132
1232
|
toolCall,
|
|
@@ -1150,21 +1250,17 @@ async function executeToolCalls(
|
|
|
1150
1250
|
if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) {
|
|
1151
1251
|
return;
|
|
1152
1252
|
}
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return;
|
|
1156
|
-
}
|
|
1157
|
-
steeringCheck = (async () => {
|
|
1253
|
+
const check = steeringCheckTail.then(async () => {
|
|
1254
|
+
if (interruptState.triggered) return;
|
|
1158
1255
|
const steering = await getSteeringMessages();
|
|
1159
1256
|
if (steering.length > 0) {
|
|
1160
1257
|
steeringMessages = steering;
|
|
1161
1258
|
interruptState.triggered = true;
|
|
1162
1259
|
steeringAbortController.abort();
|
|
1163
1260
|
}
|
|
1164
|
-
})().finally(() => {
|
|
1165
|
-
steeringCheck = null;
|
|
1166
1261
|
});
|
|
1167
|
-
|
|
1262
|
+
steeringCheckTail = check.catch(() => {});
|
|
1263
|
+
await check;
|
|
1168
1264
|
};
|
|
1169
1265
|
|
|
1170
1266
|
const emitToolResult = (record: (typeof records)[number], result: AgentToolResult<any>, isError: boolean): void => {
|
|
@@ -1236,6 +1332,16 @@ async function executeToolCalls(
|
|
|
1236
1332
|
}
|
|
1237
1333
|
}
|
|
1238
1334
|
record.args = argsForExecution;
|
|
1335
|
+
if (toolSignal.aborted) {
|
|
1336
|
+
record.skipped = true;
|
|
1337
|
+
recordSkippedTool(telemetry, {
|
|
1338
|
+
toolCallId: toolCall.id,
|
|
1339
|
+
toolName: toolCall.name,
|
|
1340
|
+
status: "aborted",
|
|
1341
|
+
});
|
|
1342
|
+
emitToolResult(record, createToolSignalAbortedResult(toolSignal), true);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1239
1345
|
record.started = true;
|
|
1240
1346
|
stream.push({
|
|
1241
1347
|
type: "tool_execution_start",
|
|
@@ -1259,10 +1365,16 @@ async function executeToolCalls(
|
|
|
1259
1365
|
let result: AgentToolResult<any> = { content: [], details: {} };
|
|
1260
1366
|
let isError = false;
|
|
1261
1367
|
let caughtError: unknown;
|
|
1368
|
+
let completedToolExecution = false;
|
|
1262
1369
|
|
|
1263
1370
|
await runInActiveSpan(toolSpan, async () => {
|
|
1264
1371
|
try {
|
|
1265
1372
|
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
|
|
1373
|
+
if (toolSignal.aborted) {
|
|
1374
|
+
result = createToolSignalAbortedResult(toolSignal);
|
|
1375
|
+
isError = true;
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1266
1378
|
|
|
1267
1379
|
let effectiveArgs: Record<string, unknown>;
|
|
1268
1380
|
try {
|
|
@@ -1289,8 +1401,15 @@ async function executeToolCalls(
|
|
|
1289
1401
|
throw new ToolCallBlockedError(beforeResult.reason);
|
|
1290
1402
|
}
|
|
1291
1403
|
}
|
|
1292
|
-
|
|
1293
|
-
|
|
1404
|
+
if (toolSignal.aborted) {
|
|
1405
|
+
result = createToolSignalAbortedResult(toolSignal);
|
|
1406
|
+
isError = true;
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const executionArgs = transformToolCallArguments
|
|
1410
|
+
? transformToolCallArguments(effectiveArgs, toolCall.name)
|
|
1411
|
+
: effectiveArgs;
|
|
1412
|
+
record.args = executionArgs;
|
|
1294
1413
|
|
|
1295
1414
|
const toolContext = getToolContext
|
|
1296
1415
|
? getToolContext({
|
|
@@ -1302,19 +1421,20 @@ async function executeToolCalls(
|
|
|
1302
1421
|
: undefined;
|
|
1303
1422
|
const rawResult = await tool.execute(
|
|
1304
1423
|
toolCall.id,
|
|
1305
|
-
|
|
1424
|
+
executionArgs,
|
|
1306
1425
|
toolSignal,
|
|
1307
1426
|
partialResult => {
|
|
1308
1427
|
stream.push({
|
|
1309
1428
|
type: "tool_execution_update",
|
|
1310
1429
|
toolCallId: toolCall.id,
|
|
1311
1430
|
toolName: toolCall.name,
|
|
1312
|
-
args:
|
|
1431
|
+
args: executionArgs,
|
|
1313
1432
|
partialResult: coerceToolResult(partialResult).result,
|
|
1314
1433
|
});
|
|
1315
1434
|
},
|
|
1316
1435
|
toolContext,
|
|
1317
1436
|
);
|
|
1437
|
+
completedToolExecution = true;
|
|
1318
1438
|
const coerced = coerceToolResult(rawResult);
|
|
1319
1439
|
result = coerced.result;
|
|
1320
1440
|
if (coerced.malformed || result.isError) isError = true;
|
|
@@ -1327,7 +1447,7 @@ async function executeToolCalls(
|
|
|
1327
1447
|
isError = true;
|
|
1328
1448
|
}
|
|
1329
1449
|
|
|
1330
|
-
if (afterToolCall) {
|
|
1450
|
+
if (afterToolCall && (!toolSignal.aborted || completedToolExecution)) {
|
|
1331
1451
|
try {
|
|
1332
1452
|
const after = await afterToolCall(
|
|
1333
1453
|
{
|
|
@@ -1341,12 +1461,17 @@ async function executeToolCalls(
|
|
|
1341
1461
|
toolSignal,
|
|
1342
1462
|
);
|
|
1343
1463
|
if (after) {
|
|
1344
|
-
result
|
|
1464
|
+
// Re-normalize the post-hook result: `afterToolCall` is untyped user/extension
|
|
1465
|
+
// code and may return malformed `content` (non-array / invalid blocks), which
|
|
1466
|
+
// would otherwise be persisted verbatim and corrupt the session — the same
|
|
1467
|
+
// hazard `coerceToolResult` guards on the execute path.
|
|
1468
|
+
const coerced = coerceToolResult({
|
|
1345
1469
|
content: after.content ?? result.content,
|
|
1346
1470
|
details: after.details ?? result.details,
|
|
1347
1471
|
isError: after.isError ?? result.isError,
|
|
1348
|
-
};
|
|
1349
|
-
|
|
1472
|
+
});
|
|
1473
|
+
result = coerced.result;
|
|
1474
|
+
isError = coerced.malformed || (after.isError ?? isError);
|
|
1350
1475
|
}
|
|
1351
1476
|
} catch (e) {
|
|
1352
1477
|
caughtError = e;
|
|
@@ -1360,23 +1485,30 @@ async function executeToolCalls(
|
|
|
1360
1485
|
});
|
|
1361
1486
|
|
|
1362
1487
|
const interrupted = interruptState.triggered;
|
|
1363
|
-
|
|
1488
|
+
const abortedDuringExecution = toolSignal.aborted && isError;
|
|
1489
|
+
if (interrupted && isError) {
|
|
1490
|
+
// Steering/abort fired AND this tool failed — it was cut off before producing a
|
|
1491
|
+
// usable result, so report it as skipped.
|
|
1364
1492
|
record.skipped = true;
|
|
1365
1493
|
emitToolResult(record, createSkippedToolResult(), true);
|
|
1366
1494
|
} else {
|
|
1495
|
+
// No interrupt, or the tool finished (successfully or with a genuine error) before
|
|
1496
|
+
// the interrupt landed. Keep its real result: a completed tool already ran its side
|
|
1497
|
+
// effects, so the model must see what actually happened rather than a false "skipped".
|
|
1367
1498
|
emitToolResult(record, result, isError);
|
|
1368
1499
|
}
|
|
1369
1500
|
|
|
1370
1501
|
const firstTextBlock = result.content?.[0];
|
|
1371
1502
|
const errorMessageForSpan =
|
|
1372
1503
|
caughtError === undefined && isError && firstTextBlock?.type === "text" ? firstTextBlock.text : undefined;
|
|
1373
|
-
const status =
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1504
|
+
const status =
|
|
1505
|
+
(interrupted && isError) || abortedDuringExecution
|
|
1506
|
+
? "aborted"
|
|
1507
|
+
: caughtError instanceof ToolCallBlockedError
|
|
1508
|
+
? "blocked"
|
|
1509
|
+
: isError
|
|
1510
|
+
? "error"
|
|
1511
|
+
: "ok";
|
|
1380
1512
|
finishExecuteToolSpan(telemetry, toolSpan, {
|
|
1381
1513
|
result,
|
|
1382
1514
|
isError,
|
|
@@ -1482,6 +1614,14 @@ function createAbortedToolResult(
|
|
|
1482
1614
|
return toolResultMessage;
|
|
1483
1615
|
}
|
|
1484
1616
|
|
|
1617
|
+
function createToolSignalAbortedResult(signal: AbortSignal): AgentToolResult<unknown> {
|
|
1618
|
+
const reason = abortReasonText(signal);
|
|
1619
|
+
return {
|
|
1620
|
+
content: [{ type: "text", text: `Tool was not executed because the run was aborted: ${reason}.` }],
|
|
1621
|
+
details: {},
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1485
1625
|
function createSkippedToolResult(): AgentToolResult<any> {
|
|
1486
1626
|
return {
|
|
1487
1627
|
content: [{ type: "text", text: "Skipped due to queued user message." }],
|
package/src/agent.ts
CHANGED
|
@@ -110,12 +110,6 @@ export interface AgentOptions {
|
|
|
110
110
|
*/
|
|
111
111
|
interruptMode?: "immediate" | "wait";
|
|
112
112
|
|
|
113
|
-
/**
|
|
114
|
-
* Maximum completed tool calls to accept from one streamed assistant turn before
|
|
115
|
-
* executing the batch. Undefined disables batching.
|
|
116
|
-
*/
|
|
117
|
-
maxToolCallsPerTurn?: number;
|
|
118
|
-
|
|
119
113
|
/**
|
|
120
114
|
* API format for Kimi Code provider: "openai" or "anthropic" (default: "anthropic")
|
|
121
115
|
*/
|
|
@@ -288,7 +282,6 @@ export class Agent {
|
|
|
288
282
|
#steeringMode: "all" | "one-at-a-time";
|
|
289
283
|
#followUpMode: "all" | "one-at-a-time";
|
|
290
284
|
#interruptMode: "immediate" | "wait";
|
|
291
|
-
#maxToolCallsPerTurn?: number;
|
|
292
285
|
#sessionId?: string;
|
|
293
286
|
#promptCacheKey?: string;
|
|
294
287
|
#metadata?: Record<string, unknown>;
|
|
@@ -350,7 +343,6 @@ export class Agent {
|
|
|
350
343
|
this.#steeringMode = opts.steeringMode || "one-at-a-time";
|
|
351
344
|
this.#followUpMode = opts.followUpMode || "one-at-a-time";
|
|
352
345
|
this.#interruptMode = opts.interruptMode || "immediate";
|
|
353
|
-
this.#maxToolCallsPerTurn = opts.maxToolCallsPerTurn;
|
|
354
346
|
this.streamFn = opts.streamFn || streamSimple;
|
|
355
347
|
this.#sessionId = opts.sessionId;
|
|
356
348
|
this.#promptCacheKey = opts.promptCacheKey;
|
|
@@ -588,14 +580,6 @@ export class Agent {
|
|
|
588
580
|
this.#maxRetryDelayMs = value;
|
|
589
581
|
}
|
|
590
582
|
|
|
591
|
-
get maxToolCallsPerTurn(): number | undefined {
|
|
592
|
-
return this.#maxToolCallsPerTurn;
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
set maxToolCallsPerTurn(value: number | undefined) {
|
|
596
|
-
this.#maxToolCallsPerTurn = value;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
583
|
get state(): AgentState {
|
|
600
584
|
return this.#state;
|
|
601
585
|
}
|
|
@@ -967,7 +951,6 @@ export class Agent {
|
|
|
967
951
|
serviceTier: this.#serviceTier,
|
|
968
952
|
hideThinkingSummary: this.#hideThinkingSummary,
|
|
969
953
|
interruptMode: this.#interruptMode,
|
|
970
|
-
maxToolCallsPerTurn: this.#maxToolCallsPerTurn,
|
|
971
954
|
sessionId: this.#sessionId,
|
|
972
955
|
promptCacheKey: this.#promptCacheKey,
|
|
973
956
|
metadata: this.#metadataResolver ? undefined : this.#metadata,
|
package/src/telemetry.ts
CHANGED
|
@@ -1869,7 +1869,8 @@ export function finishInvokeAgentSpan(
|
|
|
1869
1869
|
|
|
1870
1870
|
/**
|
|
1871
1871
|
* Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
|
|
1872
|
-
are caught and
|
|
1872
|
+
* are caught and surfaced via the `onTelemetryWarning` hook (falling back to `console.warn`
|
|
1873
|
+
* when no hook is set) — telemetry callbacks NEVER turn a
|
|
1873
1874
|
* successful agent run into a failed one. Idempotent at the call site via
|
|
1874
1875
|
* {@link AgentRunCollector.markRunEnded}; callers must check that before
|
|
1875
1876
|
* calling this helper.
|
package/src/types.ts
CHANGED
|
@@ -47,14 +47,6 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
|
|
|
47
47
|
*/
|
|
48
48
|
interruptMode?: "immediate" | "wait";
|
|
49
49
|
|
|
50
|
-
/**
|
|
51
|
-
* Maximum completed tool calls to accept from one streamed assistant turn before
|
|
52
|
-
* cutting the provider stream and executing that batch. The cap is enforced on
|
|
53
|
-
* `toolcall_end` so every executed call has complete arguments. Undefined disables
|
|
54
|
-
* batching.
|
|
55
|
-
*/
|
|
56
|
-
maxToolCallsPerTurn?: number;
|
|
57
|
-
|
|
58
50
|
/**
|
|
59
51
|
* Optional session identifier forwarded to LLM providers.
|
|
60
52
|
* Used by providers that support session-based caching (e.g., OpenAI Codex).
|