@gajae-code/agent-core 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/dist/types/agent-loop.d.ts +10 -2
- package/dist/types/agent.d.ts +35 -3
- package/dist/types/compaction/entries.d.ts +11 -1
- package/dist/types/types.d.ts +93 -3
- package/package.json +4 -4
- package/src/agent-loop.ts +299 -17
- package/src/agent.ts +227 -22
- package/src/compaction/entries.ts +12 -0
- package/src/compaction/openai.ts +16 -5
- package/src/types.ts +103 -3
package/src/agent-loop.ts
CHANGED
|
@@ -6,12 +6,14 @@ import {
|
|
|
6
6
|
type AssistantMessage,
|
|
7
7
|
type AssistantMessageEvent,
|
|
8
8
|
type Context,
|
|
9
|
+
classifyFallbackTrigger,
|
|
9
10
|
EventStream,
|
|
10
11
|
isContextOverflow,
|
|
11
12
|
isZodSchema,
|
|
12
13
|
streamSimple,
|
|
13
14
|
type ToolResultMessage,
|
|
14
15
|
type TSchema,
|
|
16
|
+
transportFailureFacts,
|
|
15
17
|
validateToolArguments,
|
|
16
18
|
zodToWireSchema,
|
|
17
19
|
} from "@gajae-code/ai";
|
|
@@ -51,10 +53,30 @@ import type {
|
|
|
51
53
|
AgentMessage,
|
|
52
54
|
AgentTool,
|
|
53
55
|
AgentToolResult,
|
|
56
|
+
ManagedAttemptOutcome,
|
|
54
57
|
StreamFn,
|
|
55
58
|
} from "./types";
|
|
56
59
|
|
|
57
60
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
61
|
+
/**
|
|
62
|
+
* Defensive caps for a provisional managed attempt. These are intentionally
|
|
63
|
+
* well above ordinary streamed responses; they only bound memory when an
|
|
64
|
+
* upstream emits an unbounded event stream before the attempt can commit.
|
|
65
|
+
*/
|
|
66
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
|
|
67
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
|
|
68
|
+
|
|
69
|
+
class ManagedAttemptBufferOverflowError extends Error {
|
|
70
|
+
readonly status = 503;
|
|
71
|
+
|
|
72
|
+
constructor() {
|
|
73
|
+
super("Managed fallback attempt exceeded the provisional event buffer limit");
|
|
74
|
+
this.name = "ManagedAttemptBufferOverflowError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const managedAttemptTextEncoder = new TextEncoder();
|
|
79
|
+
|
|
58
80
|
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
59
81
|
/**
|
|
60
82
|
* Detect empty "successful" responses that indicate a proxy-level context
|
|
@@ -66,6 +88,66 @@ function isEmptyResponseOverflow(message: AssistantMessage): boolean {
|
|
|
66
88
|
return isContextOverflow(message);
|
|
67
89
|
}
|
|
68
90
|
|
|
91
|
+
/** Managed fallback owns retry policy; only typed transport facts may discard an attempt. */
|
|
92
|
+
function managedTransportFailure(failure: unknown) {
|
|
93
|
+
if (failure && typeof failure === "object" && "transportFailure" in failure) {
|
|
94
|
+
const facts = (failure as { transportFailure?: unknown }).transportFailure;
|
|
95
|
+
if (facts && typeof facts === "object") return transportFailureFacts(facts);
|
|
96
|
+
}
|
|
97
|
+
return transportFailureFacts(failure);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function managedRetryableFailure(failure: unknown): boolean {
|
|
101
|
+
const facts = managedTransportFailure(failure);
|
|
102
|
+
if (!facts) return false;
|
|
103
|
+
const trigger = classifyFallbackTrigger(facts);
|
|
104
|
+
return (
|
|
105
|
+
trigger.class === "rate_limit" ||
|
|
106
|
+
trigger.class === "quota" ||
|
|
107
|
+
trigger.class === "auth" ||
|
|
108
|
+
trigger.class === "server"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
|
|
113
|
+
return {
|
|
114
|
+
type: "retryable_discarded",
|
|
115
|
+
failure: { message, transportFailure: managedTransportFailure(message) },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
|
|
120
|
+
const details = error as { message?: unknown; errorStatus?: unknown; status?: unknown };
|
|
121
|
+
const status =
|
|
122
|
+
typeof details.errorStatus === "number"
|
|
123
|
+
? details.errorStatus
|
|
124
|
+
: typeof details.status === "number"
|
|
125
|
+
? details.status
|
|
126
|
+
: undefined;
|
|
127
|
+
const transportFailure =
|
|
128
|
+
managedTransportFailure(error) ?? (status === undefined ? undefined : { kind: "transport" as const, status });
|
|
129
|
+
return {
|
|
130
|
+
role: "assistant",
|
|
131
|
+
content: [],
|
|
132
|
+
api: config.model.api,
|
|
133
|
+
provider: config.model.provider,
|
|
134
|
+
model: config.model.id,
|
|
135
|
+
usage: {
|
|
136
|
+
input: 0,
|
|
137
|
+
output: 0,
|
|
138
|
+
cacheRead: 0,
|
|
139
|
+
cacheWrite: 0,
|
|
140
|
+
totalTokens: 0,
|
|
141
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
142
|
+
},
|
|
143
|
+
stopReason: "error",
|
|
144
|
+
errorMessage: typeof details.message === "string" ? details.message : String(error),
|
|
145
|
+
errorStatus: status,
|
|
146
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
69
151
|
class HarmonyLeakInterruption extends Error {
|
|
70
152
|
constructor(
|
|
71
153
|
readonly detection: HarmonyDetection,
|
|
@@ -132,6 +214,7 @@ export function agentLoop(
|
|
|
132
214
|
config: AgentLoopConfig,
|
|
133
215
|
signal?: AbortSignal,
|
|
134
216
|
streamFn?: StreamFn,
|
|
217
|
+
emitManagedAgentStart = true,
|
|
135
218
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
136
219
|
const stream = createAgentStream();
|
|
137
220
|
|
|
@@ -141,16 +224,19 @@ export function agentLoop(
|
|
|
141
224
|
...context,
|
|
142
225
|
messages: [...context.messages, ...prompts],
|
|
143
226
|
};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
227
|
+
const transaction = config.fallbackManaged
|
|
228
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
229
|
+
: undefined;
|
|
230
|
+
const attemptStream = transaction ?? stream;
|
|
231
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
232
|
+
attemptStream.push({ type: "turn_start" });
|
|
147
233
|
for (const prompt of prompts) {
|
|
148
234
|
stream.push({ type: "message_start", message: prompt });
|
|
149
235
|
stream.push({ type: "message_end", message: prompt });
|
|
150
236
|
}
|
|
151
237
|
|
|
152
238
|
try {
|
|
153
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
239
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
154
240
|
} catch (err) {
|
|
155
241
|
stream.fail(err);
|
|
156
242
|
}
|
|
@@ -172,6 +258,7 @@ export function agentLoopContinue(
|
|
|
172
258
|
config: AgentLoopConfig,
|
|
173
259
|
signal?: AbortSignal,
|
|
174
260
|
streamFn?: StreamFn,
|
|
261
|
+
emitManagedAgentStart = true,
|
|
175
262
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
176
263
|
if (context.messages.length === 0) {
|
|
177
264
|
throw new Error("Cannot continue: no messages in context");
|
|
@@ -186,12 +273,15 @@ export function agentLoopContinue(
|
|
|
186
273
|
(async () => {
|
|
187
274
|
const newMessages: AgentMessage[] = [];
|
|
188
275
|
const currentContext: AgentContext = { ...context };
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
276
|
+
const transaction = config.fallbackManaged
|
|
277
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
278
|
+
: undefined;
|
|
279
|
+
const attemptStream = transaction ?? stream;
|
|
280
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
281
|
+
attemptStream.push({ type: "turn_start" });
|
|
192
282
|
|
|
193
283
|
try {
|
|
194
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
284
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
195
285
|
} catch (err) {
|
|
196
286
|
stream.fail(err);
|
|
197
287
|
}
|
|
@@ -207,6 +297,94 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
|
|
207
297
|
);
|
|
208
298
|
}
|
|
209
299
|
|
|
300
|
+
/** Capture an event-time value because providers commonly mutate partial messages in place. */
|
|
301
|
+
function managedAttemptSnapshot<T>(value: T): T {
|
|
302
|
+
return structuredClone(value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Holds managed-attempt assistant output above the public event stream. A
|
|
307
|
+
* cancelled provider attempt is therefore unobservable to sessions and their
|
|
308
|
+
* side-effect consumers. Non-managed streams bypass this object entirely.
|
|
309
|
+
*/
|
|
310
|
+
class ManagedAttemptTransaction {
|
|
311
|
+
#batch: Array<
|
|
312
|
+
| { type: "event"; event: AgentEvent }
|
|
313
|
+
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
|
|
314
|
+
> = [];
|
|
315
|
+
#stagedEventCount = 0;
|
|
316
|
+
#stagedBytes = 0;
|
|
317
|
+
#discarded = false;
|
|
318
|
+
#committed = false;
|
|
319
|
+
|
|
320
|
+
constructor(
|
|
321
|
+
private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
322
|
+
private readonly onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void,
|
|
323
|
+
) {}
|
|
324
|
+
|
|
325
|
+
push(event: AgentEvent): void {
|
|
326
|
+
if (this.#committed) {
|
|
327
|
+
this.stream.push(event);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.#stage(event);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
end(messages: AgentMessage[]): void {
|
|
334
|
+
this.stream.end(messages);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
338
|
+
this.#batch.push({
|
|
339
|
+
type: "assistant_event",
|
|
340
|
+
message: managedAttemptSnapshot(message),
|
|
341
|
+
event: managedAttemptSnapshot(event),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
flush(): void {
|
|
346
|
+
if (this.#discarded || this.#committed) return;
|
|
347
|
+
for (const item of this.#batch) {
|
|
348
|
+
if (item.type === "assistant_event") {
|
|
349
|
+
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
350
|
+
} else {
|
|
351
|
+
this.stream.push(item.event);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
this.#batch = [];
|
|
355
|
+
this.#stagedBytes = 0;
|
|
356
|
+
this.#stagedEventCount = 0;
|
|
357
|
+
this.#committed = true;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
discard(): void {
|
|
361
|
+
this.#batch = [];
|
|
362
|
+
this.#stagedBytes = 0;
|
|
363
|
+
this.#stagedEventCount = 0;
|
|
364
|
+
this.#discarded = true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
#stage(event: AgentEvent): void {
|
|
368
|
+
let bytes: number;
|
|
369
|
+
try {
|
|
370
|
+
bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
|
|
371
|
+
} catch {
|
|
372
|
+
bytes = MANAGED_ATTEMPT_MAX_STAGED_BYTES + 1;
|
|
373
|
+
}
|
|
374
|
+
if (
|
|
375
|
+
this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
|
|
376
|
+
this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
|
|
377
|
+
) {
|
|
378
|
+
this.discard();
|
|
379
|
+
throw new ManagedAttemptBufferOverflowError();
|
|
380
|
+
}
|
|
381
|
+
this.#batch.push({ type: "event", event: managedAttemptSnapshot(event) });
|
|
382
|
+
this.#stagedEventCount += 1;
|
|
383
|
+
|
|
384
|
+
this.#stagedBytes += bytes;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
210
388
|
/**
|
|
211
389
|
* Build the `agent_end` event payload. When telemetry is enabled, snapshots
|
|
212
390
|
* the run collector so consumers receive {@link AgentRunSummary} +
|
|
@@ -549,7 +727,10 @@ async function runLoop(
|
|
|
549
727
|
signal: AbortSignal | undefined,
|
|
550
728
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
551
729
|
streamFn?: StreamFn,
|
|
730
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
552
731
|
): Promise<void> {
|
|
732
|
+
const loopSignal = signal ?? new AbortController().signal;
|
|
733
|
+
|
|
553
734
|
const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
|
|
554
735
|
const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
|
|
555
736
|
const stepCounter = { count: 0 };
|
|
@@ -560,12 +741,14 @@ async function runLoop(
|
|
|
560
741
|
currentContext,
|
|
561
742
|
newMessages,
|
|
562
743
|
config,
|
|
563
|
-
|
|
744
|
+
loopSignal,
|
|
745
|
+
|
|
564
746
|
stream,
|
|
565
747
|
telemetry,
|
|
566
748
|
invokeAgentSpan,
|
|
567
749
|
stepCounter,
|
|
568
750
|
streamFn,
|
|
751
|
+
initialTransaction,
|
|
569
752
|
),
|
|
570
753
|
);
|
|
571
754
|
} catch (err) {
|
|
@@ -587,17 +770,24 @@ async function runLoopBody(
|
|
|
587
770
|
currentContext: AgentContext,
|
|
588
771
|
newMessages: AgentMessage[],
|
|
589
772
|
config: AgentLoopConfig,
|
|
590
|
-
|
|
773
|
+
loopSignal: AbortSignal,
|
|
774
|
+
|
|
591
775
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
592
776
|
telemetry: AgentTelemetry | undefined,
|
|
593
777
|
invokeAgentSpan: Span | undefined,
|
|
594
778
|
stepCounter: StepCounter,
|
|
595
779
|
streamFn?: StreamFn,
|
|
780
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
596
781
|
): Promise<void> {
|
|
597
782
|
let firstTurn = true;
|
|
598
783
|
// Check for steering messages at start (user may have typed while waiting)
|
|
599
784
|
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
|
600
785
|
let harmonyRetryAttempt = 0;
|
|
786
|
+
// Whether at least one assistant response has been produced in THIS run. The
|
|
787
|
+
// mid-run maintenance checkpoint only fires between tool iterations (after a
|
|
788
|
+
// model response); pre-turn maintenance is the pre-prompt check's job, so the
|
|
789
|
+
// first iteration is skipped to avoid duplicating/racing it.
|
|
790
|
+
let modelHasResponded = false;
|
|
601
791
|
let harmonyTruncateResumeCount = 0;
|
|
602
792
|
|
|
603
793
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
@@ -606,13 +796,21 @@ async function runLoopBody(
|
|
|
606
796
|
|
|
607
797
|
// Inner loop: process tool calls and steering messages
|
|
608
798
|
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
799
|
+
const transaction =
|
|
800
|
+
initialTransaction ??
|
|
801
|
+
(config.fallbackManaged
|
|
802
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
803
|
+
: undefined);
|
|
804
|
+
initialTransaction = undefined;
|
|
805
|
+
const attemptStream = transaction ?? stream;
|
|
609
806
|
if (!firstTurn) {
|
|
610
|
-
|
|
807
|
+
attemptStream.push({ type: "turn_start" });
|
|
611
808
|
} else {
|
|
612
809
|
firstTurn = false;
|
|
613
810
|
}
|
|
614
811
|
|
|
615
|
-
//
|
|
812
|
+
// Commit queued user input outside the provisional assistant transaction so a
|
|
813
|
+
// discarded managed attempt cannot lose it before its retry continuation.
|
|
616
814
|
if (pendingMessages.length > 0) {
|
|
617
815
|
for (const message of pendingMessages) {
|
|
618
816
|
stream.push({ type: "message_start", message });
|
|
@@ -623,20 +821,63 @@ async function runLoopBody(
|
|
|
623
821
|
pendingMessages = [];
|
|
624
822
|
}
|
|
625
823
|
|
|
824
|
+
// Cooperative mid-run context maintenance. Runs after pending
|
|
825
|
+
// tool/steering messages are materialized into durable context and
|
|
826
|
+
// before syncContextBeforeModelCall / the model call — the only
|
|
827
|
+
// boundary where the full unsent context is already durable. A
|
|
828
|
+
// non-"not-needed" outcome means context was (or was attempted to be)
|
|
829
|
+
// rewritten, so end the run WITHOUT the lossy agent_end finalization;
|
|
830
|
+
// the maintenance owner resumes the run on the rewritten context.
|
|
831
|
+
// "not-needed" falls through to the model call.
|
|
832
|
+
if (config.maintainContext && modelHasResponded && !loopSignal.aborted) {
|
|
833
|
+
const lifecycle = {
|
|
834
|
+
signal: loopSignal,
|
|
835
|
+
awaitEventDrain: (invocationSignal: AbortSignal) =>
|
|
836
|
+
stream.waitForConsumerDrain(AbortSignal.any([loopSignal, invocationSignal])),
|
|
837
|
+
};
|
|
838
|
+
const maintenanceOutcome = await config.maintainContext(currentContext, lifecycle);
|
|
839
|
+
// A callback can settle after its loop has been cancelled. Never let a
|
|
840
|
+
// stale "not-needed" fall through to streamAssistantResponse, which
|
|
841
|
+
// invokes the provider before it observes the aborted signal.
|
|
842
|
+
const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
|
|
843
|
+
|
|
844
|
+
if (outcome !== "not-needed") {
|
|
845
|
+
stream.push({
|
|
846
|
+
type: "agent_end",
|
|
847
|
+
messages: newMessages,
|
|
848
|
+
stopReason: "maintenance",
|
|
849
|
+
maintenanceOutcome: outcome,
|
|
850
|
+
});
|
|
851
|
+
stream.end(newMessages);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
626
856
|
// Refresh prompt/tool context from live state before each model call
|
|
627
857
|
if (config.syncContextBeforeModelCall) {
|
|
628
858
|
await config.syncContextBeforeModelCall(currentContext);
|
|
629
859
|
}
|
|
630
860
|
|
|
861
|
+
const contextMessageCount = currentContext.messages.length;
|
|
862
|
+
const newMessageCount = newMessages.length;
|
|
863
|
+
|
|
631
864
|
// Stream assistant response
|
|
632
865
|
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
633
866
|
let message: AssistantMessage;
|
|
867
|
+
const attemptTransaction = transaction;
|
|
634
868
|
try {
|
|
869
|
+
const attemptConfig = attemptTransaction
|
|
870
|
+
? {
|
|
871
|
+
...config,
|
|
872
|
+
onAssistantMessageEvent: (partial: AssistantMessage, event: AssistantMessageEvent) =>
|
|
873
|
+
attemptTransaction.stageAssistantMessageEvent(partial, event),
|
|
874
|
+
}
|
|
875
|
+
: config;
|
|
635
876
|
message = await streamAssistantResponse(
|
|
636
877
|
currentContext,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
stream,
|
|
878
|
+
attemptConfig,
|
|
879
|
+
loopSignal,
|
|
880
|
+
attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
|
|
640
881
|
telemetry,
|
|
641
882
|
invokeAgentSpan,
|
|
642
883
|
stepCounter,
|
|
@@ -652,7 +893,21 @@ async function runLoopBody(
|
|
|
652
893
|
harmonyRetryAttempt = 0;
|
|
653
894
|
harmonyTruncateResumeCount = 0;
|
|
654
895
|
} catch (err) {
|
|
655
|
-
if (!(err instanceof HarmonyLeakInterruption))
|
|
896
|
+
if (!(err instanceof HarmonyLeakInterruption)) {
|
|
897
|
+
if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
|
|
898
|
+
transaction.discard();
|
|
899
|
+
currentContext.messages.splice(contextMessageCount);
|
|
900
|
+
newMessages.splice(newMessageCount);
|
|
901
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(managedFailureMessage(err, config)));
|
|
902
|
+
stream.end(newMessages);
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
throw err;
|
|
906
|
+
}
|
|
907
|
+
if (config.fallbackManaged) {
|
|
908
|
+
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
909
|
+
throw err;
|
|
910
|
+
}
|
|
656
911
|
if (err.recovered) {
|
|
657
912
|
if (harmonyTruncateResumeCount >= 2) {
|
|
658
913
|
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
@@ -695,6 +950,7 @@ async function runLoopBody(
|
|
|
695
950
|
}
|
|
696
951
|
}
|
|
697
952
|
newMessages.push(message);
|
|
953
|
+
modelHasResponded = true;
|
|
698
954
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
699
955
|
|
|
700
956
|
// Detect empty "successful" responses (stopReason "stop" + empty content).
|
|
@@ -710,6 +966,30 @@ async function runLoopBody(
|
|
|
710
966
|
: "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
|
|
711
967
|
}
|
|
712
968
|
|
|
969
|
+
if (config.fallbackManaged && message.stopReason === "error" && managedRetryableFailure(message)) {
|
|
970
|
+
transaction?.discard();
|
|
971
|
+
currentContext.messages.splice(contextMessageCount);
|
|
972
|
+
newMessages.splice(newMessageCount);
|
|
973
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
|
|
974
|
+
stream.end(newMessages);
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (config.fallbackManaged && message.stopReason === "aborted") {
|
|
979
|
+
transaction?.discard();
|
|
980
|
+
currentContext.messages.splice(contextMessageCount);
|
|
981
|
+
newMessages.splice(newMessageCount);
|
|
982
|
+
await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
|
|
983
|
+
stream.end(newMessages);
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// One provider invocation is committed before any tool can run.
|
|
988
|
+
transaction?.flush();
|
|
989
|
+
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
|
|
990
|
+
await config.onManagedAttemptAccepted?.();
|
|
991
|
+
}
|
|
992
|
+
|
|
713
993
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
714
994
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
715
995
|
// This maintains the tool_use/tool_result pairing that the API requires
|
|
@@ -747,7 +1027,7 @@ async function runLoopBody(
|
|
|
747
1027
|
const executionResult = await executeToolCalls(
|
|
748
1028
|
currentContext,
|
|
749
1029
|
message,
|
|
750
|
-
|
|
1030
|
+
loopSignal,
|
|
751
1031
|
stream,
|
|
752
1032
|
config,
|
|
753
1033
|
telemetry,
|
|
@@ -925,8 +1205,10 @@ async function streamAssistantResponse(
|
|
|
925
1205
|
|
|
926
1206
|
try {
|
|
927
1207
|
return await runInActiveSpan(chatSpan, async () => {
|
|
1208
|
+
const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
|
|
928
1209
|
const response = await streamFunction(config.model, llmContext, {
|
|
929
1210
|
...config,
|
|
1211
|
+
fallbackAttempt,
|
|
930
1212
|
apiKey: resolvedApiKey,
|
|
931
1213
|
authCredentialType,
|
|
932
1214
|
metadata: resolvedMetadata,
|