@gajae-code/agent-core 0.10.2 → 0.11.1
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 +34 -2
- package/dist/types/compaction/compaction.d.ts +15 -0
- package/dist/types/compaction/entries.d.ts +11 -1
- package/dist/types/types.d.ts +92 -2
- package/package.json +4 -4
- package/src/agent-loop.ts +359 -17
- package/src/agent.ts +226 -21
- package/src/compaction/compaction.ts +34 -3
- package/src/compaction/entries.ts +12 -0
- package/src/compaction/openai.ts +8 -25
- package/src/types.ts +102 -2
package/src/agent-loop.ts
CHANGED
|
@@ -6,15 +6,18 @@ 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";
|
|
20
|
+
import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
|
|
18
21
|
import { sanitizeText } from "@gajae-code/utils";
|
|
19
22
|
import {
|
|
20
23
|
createHarmonyAuditEvent,
|
|
@@ -51,10 +54,30 @@ import type {
|
|
|
51
54
|
AgentMessage,
|
|
52
55
|
AgentTool,
|
|
53
56
|
AgentToolResult,
|
|
57
|
+
ManagedAttemptOutcome,
|
|
54
58
|
StreamFn,
|
|
55
59
|
} from "./types";
|
|
56
60
|
|
|
57
61
|
/** Sentinel returned by the abort race in `streamAssistantResponse`. */
|
|
62
|
+
/**
|
|
63
|
+
* Defensive caps for a provisional managed attempt. These are intentionally
|
|
64
|
+
* well above ordinary streamed responses; they only bound memory when an
|
|
65
|
+
* upstream emits an unbounded event stream before the attempt can commit.
|
|
66
|
+
*/
|
|
67
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_EVENTS = 10_000;
|
|
68
|
+
export const MANAGED_ATTEMPT_MAX_STAGED_BYTES = 16 * 1024 * 1024;
|
|
69
|
+
|
|
70
|
+
class ManagedAttemptBufferOverflowError extends Error {
|
|
71
|
+
readonly status = 503;
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
super("Managed fallback attempt exceeded the provisional event buffer limit");
|
|
75
|
+
this.name = "ManagedAttemptBufferOverflowError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const managedAttemptTextEncoder = new TextEncoder();
|
|
80
|
+
|
|
58
81
|
const ABORTED: unique symbol = Symbol("agent-loop-aborted");
|
|
59
82
|
/**
|
|
60
83
|
* Detect empty "successful" responses that indicate a proxy-level context
|
|
@@ -66,6 +89,99 @@ function isEmptyResponseOverflow(message: AssistantMessage): boolean {
|
|
|
66
89
|
return isContextOverflow(message);
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
/** Managed fallback owns retry policy; only typed transport facts may discard an attempt. */
|
|
93
|
+
function managedTransportFailure(failure: unknown) {
|
|
94
|
+
if (failure && typeof failure === "object" && "transportFailure" in failure) {
|
|
95
|
+
const facts = (failure as { transportFailure?: unknown }).transportFailure;
|
|
96
|
+
if (facts && typeof facts === "object") return transportFailureFacts(facts);
|
|
97
|
+
}
|
|
98
|
+
return transportFailureFacts(failure);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function managedRetryableFailure(failure: unknown): boolean {
|
|
102
|
+
const facts = managedTransportFailure(failure);
|
|
103
|
+
if (!facts) return false;
|
|
104
|
+
const trigger = classifyFallbackTrigger(facts);
|
|
105
|
+
return (
|
|
106
|
+
trigger.class === "rate_limit" ||
|
|
107
|
+
trigger.class === "quota" ||
|
|
108
|
+
trigger.class === "auth" ||
|
|
109
|
+
trigger.class === "server"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Neutralize leaked reserved control tokens in-place across the outgoing
|
|
115
|
+
* history so a re-send no longer carries the poison that triggered
|
|
116
|
+
* `Request blocked (code=invalid_prompt)`. Only string text fields are
|
|
117
|
+
* rewritten; no history item is ever dropped or reordered. Returns whether any
|
|
118
|
+
* byte actually changed — the circuit breaker uses this to decide between a
|
|
119
|
+
* single repaired resend (changed) and immediate fail-fast (unchanged).
|
|
120
|
+
*/
|
|
121
|
+
function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
|
|
122
|
+
let changed = false;
|
|
123
|
+
const repairString = (value: string): string => {
|
|
124
|
+
const next = neutralizeReservedControlTokens(value);
|
|
125
|
+
if (next !== value) changed = true;
|
|
126
|
+
return next;
|
|
127
|
+
};
|
|
128
|
+
for (const message of messages) {
|
|
129
|
+
const content = (message as { content?: unknown }).content;
|
|
130
|
+
if (typeof content === "string") {
|
|
131
|
+
(message as { content: string }).content = repairString(content);
|
|
132
|
+
} else if (Array.isArray(content)) {
|
|
133
|
+
for (const block of content) {
|
|
134
|
+
if (!block || typeof block !== "object") continue;
|
|
135
|
+
const record = block as Record<string, unknown>;
|
|
136
|
+
for (const key of ["text", "thinking"]) {
|
|
137
|
+
const value = record[key];
|
|
138
|
+
if (typeof value === "string") record[key] = repairString(value);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return changed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
|
|
147
|
+
return {
|
|
148
|
+
type: "retryable_discarded",
|
|
149
|
+
failure: { message, transportFailure: managedTransportFailure(message) },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
|
|
154
|
+
const details = error as { message?: unknown; errorStatus?: unknown; status?: unknown };
|
|
155
|
+
const status =
|
|
156
|
+
typeof details.errorStatus === "number"
|
|
157
|
+
? details.errorStatus
|
|
158
|
+
: typeof details.status === "number"
|
|
159
|
+
? details.status
|
|
160
|
+
: undefined;
|
|
161
|
+
const transportFailure =
|
|
162
|
+
managedTransportFailure(error) ?? (status === undefined ? undefined : { kind: "transport" as const, status });
|
|
163
|
+
return {
|
|
164
|
+
role: "assistant",
|
|
165
|
+
content: [],
|
|
166
|
+
api: config.model.api,
|
|
167
|
+
provider: config.model.provider,
|
|
168
|
+
model: config.model.id,
|
|
169
|
+
usage: {
|
|
170
|
+
input: 0,
|
|
171
|
+
output: 0,
|
|
172
|
+
cacheRead: 0,
|
|
173
|
+
cacheWrite: 0,
|
|
174
|
+
totalTokens: 0,
|
|
175
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
176
|
+
},
|
|
177
|
+
stopReason: "error",
|
|
178
|
+
errorMessage: typeof details.message === "string" ? details.message : String(error),
|
|
179
|
+
errorStatus: status,
|
|
180
|
+
...(transportFailure ? { transportFailure } : {}),
|
|
181
|
+
timestamp: Date.now(),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
69
185
|
class HarmonyLeakInterruption extends Error {
|
|
70
186
|
constructor(
|
|
71
187
|
readonly detection: HarmonyDetection,
|
|
@@ -132,6 +248,7 @@ export function agentLoop(
|
|
|
132
248
|
config: AgentLoopConfig,
|
|
133
249
|
signal?: AbortSignal,
|
|
134
250
|
streamFn?: StreamFn,
|
|
251
|
+
emitManagedAgentStart = true,
|
|
135
252
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
136
253
|
const stream = createAgentStream();
|
|
137
254
|
|
|
@@ -141,16 +258,19 @@ export function agentLoop(
|
|
|
141
258
|
...context,
|
|
142
259
|
messages: [...context.messages, ...prompts],
|
|
143
260
|
};
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
261
|
+
const transaction = config.fallbackManaged
|
|
262
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
263
|
+
: undefined;
|
|
264
|
+
const attemptStream = transaction ?? stream;
|
|
265
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
266
|
+
attemptStream.push({ type: "turn_start" });
|
|
147
267
|
for (const prompt of prompts) {
|
|
148
268
|
stream.push({ type: "message_start", message: prompt });
|
|
149
269
|
stream.push({ type: "message_end", message: prompt });
|
|
150
270
|
}
|
|
151
271
|
|
|
152
272
|
try {
|
|
153
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
273
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
154
274
|
} catch (err) {
|
|
155
275
|
stream.fail(err);
|
|
156
276
|
}
|
|
@@ -172,6 +292,7 @@ export function agentLoopContinue(
|
|
|
172
292
|
config: AgentLoopConfig,
|
|
173
293
|
signal?: AbortSignal,
|
|
174
294
|
streamFn?: StreamFn,
|
|
295
|
+
emitManagedAgentStart = true,
|
|
175
296
|
): EventStream<AgentEvent, AgentMessage[]> {
|
|
176
297
|
if (context.messages.length === 0) {
|
|
177
298
|
throw new Error("Cannot continue: no messages in context");
|
|
@@ -186,12 +307,15 @@ export function agentLoopContinue(
|
|
|
186
307
|
(async () => {
|
|
187
308
|
const newMessages: AgentMessage[] = [];
|
|
188
309
|
const currentContext: AgentContext = { ...context };
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
310
|
+
const transaction = config.fallbackManaged
|
|
311
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
312
|
+
: undefined;
|
|
313
|
+
const attemptStream = transaction ?? stream;
|
|
314
|
+
if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
|
|
315
|
+
attemptStream.push({ type: "turn_start" });
|
|
192
316
|
|
|
193
317
|
try {
|
|
194
|
-
await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
|
|
318
|
+
await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
|
|
195
319
|
} catch (err) {
|
|
196
320
|
stream.fail(err);
|
|
197
321
|
}
|
|
@@ -207,6 +331,94 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
|
|
|
207
331
|
);
|
|
208
332
|
}
|
|
209
333
|
|
|
334
|
+
/** Capture an event-time value because providers commonly mutate partial messages in place. */
|
|
335
|
+
function managedAttemptSnapshot<T>(value: T): T {
|
|
336
|
+
return structuredClone(value);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Holds managed-attempt assistant output above the public event stream. A
|
|
341
|
+
* cancelled provider attempt is therefore unobservable to sessions and their
|
|
342
|
+
* side-effect consumers. Non-managed streams bypass this object entirely.
|
|
343
|
+
*/
|
|
344
|
+
class ManagedAttemptTransaction {
|
|
345
|
+
#batch: Array<
|
|
346
|
+
| { type: "event"; event: AgentEvent }
|
|
347
|
+
| { type: "assistant_event"; message: AssistantMessage; event: AssistantMessageEvent }
|
|
348
|
+
> = [];
|
|
349
|
+
#stagedEventCount = 0;
|
|
350
|
+
#stagedBytes = 0;
|
|
351
|
+
#discarded = false;
|
|
352
|
+
#committed = false;
|
|
353
|
+
|
|
354
|
+
constructor(
|
|
355
|
+
private readonly stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
356
|
+
private readonly onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void,
|
|
357
|
+
) {}
|
|
358
|
+
|
|
359
|
+
push(event: AgentEvent): void {
|
|
360
|
+
if (this.#committed) {
|
|
361
|
+
this.stream.push(event);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.#stage(event);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
end(messages: AgentMessage[]): void {
|
|
368
|
+
this.stream.end(messages);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
stageAssistantMessageEvent(message: AssistantMessage, event: AssistantMessageEvent): void {
|
|
372
|
+
this.#batch.push({
|
|
373
|
+
type: "assistant_event",
|
|
374
|
+
message: managedAttemptSnapshot(message),
|
|
375
|
+
event: managedAttemptSnapshot(event),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
flush(): void {
|
|
380
|
+
if (this.#discarded || this.#committed) return;
|
|
381
|
+
for (const item of this.#batch) {
|
|
382
|
+
if (item.type === "assistant_event") {
|
|
383
|
+
this.onAssistantMessageEvent?.(item.message, item.event);
|
|
384
|
+
} else {
|
|
385
|
+
this.stream.push(item.event);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
this.#batch = [];
|
|
389
|
+
this.#stagedBytes = 0;
|
|
390
|
+
this.#stagedEventCount = 0;
|
|
391
|
+
this.#committed = true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
discard(): void {
|
|
395
|
+
this.#batch = [];
|
|
396
|
+
this.#stagedBytes = 0;
|
|
397
|
+
this.#stagedEventCount = 0;
|
|
398
|
+
this.#discarded = true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#stage(event: AgentEvent): void {
|
|
402
|
+
let bytes: number;
|
|
403
|
+
try {
|
|
404
|
+
bytes = managedAttemptTextEncoder.encode(JSON.stringify(event)).byteLength;
|
|
405
|
+
} catch {
|
|
406
|
+
bytes = MANAGED_ATTEMPT_MAX_STAGED_BYTES + 1;
|
|
407
|
+
}
|
|
408
|
+
if (
|
|
409
|
+
this.#stagedEventCount + 1 > MANAGED_ATTEMPT_MAX_STAGED_EVENTS ||
|
|
410
|
+
this.#stagedBytes + bytes > MANAGED_ATTEMPT_MAX_STAGED_BYTES
|
|
411
|
+
) {
|
|
412
|
+
this.discard();
|
|
413
|
+
throw new ManagedAttemptBufferOverflowError();
|
|
414
|
+
}
|
|
415
|
+
this.#batch.push({ type: "event", event: managedAttemptSnapshot(event) });
|
|
416
|
+
this.#stagedEventCount += 1;
|
|
417
|
+
|
|
418
|
+
this.#stagedBytes += bytes;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
210
422
|
/**
|
|
211
423
|
* Build the `agent_end` event payload. When telemetry is enabled, snapshots
|
|
212
424
|
* the run collector so consumers receive {@link AgentRunSummary} +
|
|
@@ -549,7 +761,10 @@ async function runLoop(
|
|
|
549
761
|
signal: AbortSignal | undefined,
|
|
550
762
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
551
763
|
streamFn?: StreamFn,
|
|
764
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
552
765
|
): Promise<void> {
|
|
766
|
+
const loopSignal = signal ?? new AbortController().signal;
|
|
767
|
+
|
|
553
768
|
const telemetry = resolveTelemetry(config.telemetry, config.sessionId);
|
|
554
769
|
const invokeAgentSpan = startInvokeAgentSpan(telemetry, config.model);
|
|
555
770
|
const stepCounter = { count: 0 };
|
|
@@ -560,12 +775,14 @@ async function runLoop(
|
|
|
560
775
|
currentContext,
|
|
561
776
|
newMessages,
|
|
562
777
|
config,
|
|
563
|
-
|
|
778
|
+
loopSignal,
|
|
779
|
+
|
|
564
780
|
stream,
|
|
565
781
|
telemetry,
|
|
566
782
|
invokeAgentSpan,
|
|
567
783
|
stepCounter,
|
|
568
784
|
streamFn,
|
|
785
|
+
initialTransaction,
|
|
569
786
|
),
|
|
570
787
|
);
|
|
571
788
|
} catch (err) {
|
|
@@ -587,18 +804,28 @@ async function runLoopBody(
|
|
|
587
804
|
currentContext: AgentContext,
|
|
588
805
|
newMessages: AgentMessage[],
|
|
589
806
|
config: AgentLoopConfig,
|
|
590
|
-
|
|
807
|
+
loopSignal: AbortSignal,
|
|
808
|
+
|
|
591
809
|
stream: EventStream<AgentEvent, AgentMessage[]>,
|
|
592
810
|
telemetry: AgentTelemetry | undefined,
|
|
593
811
|
invokeAgentSpan: Span | undefined,
|
|
594
812
|
stepCounter: StepCounter,
|
|
595
813
|
streamFn?: StreamFn,
|
|
814
|
+
initialTransaction?: ManagedAttemptTransaction,
|
|
596
815
|
): Promise<void> {
|
|
597
816
|
let firstTurn = true;
|
|
598
817
|
// Check for steering messages at start (user may have typed while waiting)
|
|
599
818
|
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
|
|
600
819
|
let harmonyRetryAttempt = 0;
|
|
820
|
+
// Whether at least one assistant response has been produced in THIS run. The
|
|
821
|
+
// mid-run maintenance checkpoint only fires between tool iterations (after a
|
|
822
|
+
// model response); pre-turn maintenance is the pre-prompt check's job, so the
|
|
823
|
+
// first iteration is skipped to avoid duplicating/racing it.
|
|
824
|
+
let modelHasResponded = false;
|
|
601
825
|
let harmonyTruncateResumeCount = 0;
|
|
826
|
+
// Fires at most one repaired resend per run for the poisoned-history
|
|
827
|
+
// `invalid_prompt` circuit breaker below.
|
|
828
|
+
let invalidPromptRepairAttempted = false;
|
|
602
829
|
|
|
603
830
|
// Outer loop: continues when queued follow-up messages arrive after agent would stop
|
|
604
831
|
while (true) {
|
|
@@ -606,13 +833,21 @@ async function runLoopBody(
|
|
|
606
833
|
|
|
607
834
|
// Inner loop: process tool calls and steering messages
|
|
608
835
|
while (hasMoreToolCalls || pendingMessages.length > 0) {
|
|
836
|
+
const transaction =
|
|
837
|
+
initialTransaction ??
|
|
838
|
+
(config.fallbackManaged
|
|
839
|
+
? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent)
|
|
840
|
+
: undefined);
|
|
841
|
+
initialTransaction = undefined;
|
|
842
|
+
const attemptStream = transaction ?? stream;
|
|
609
843
|
if (!firstTurn) {
|
|
610
|
-
|
|
844
|
+
attemptStream.push({ type: "turn_start" });
|
|
611
845
|
} else {
|
|
612
846
|
firstTurn = false;
|
|
613
847
|
}
|
|
614
848
|
|
|
615
|
-
//
|
|
849
|
+
// Commit queued user input outside the provisional assistant transaction so a
|
|
850
|
+
// discarded managed attempt cannot lose it before its retry continuation.
|
|
616
851
|
if (pendingMessages.length > 0) {
|
|
617
852
|
for (const message of pendingMessages) {
|
|
618
853
|
stream.push({ type: "message_start", message });
|
|
@@ -623,20 +858,63 @@ async function runLoopBody(
|
|
|
623
858
|
pendingMessages = [];
|
|
624
859
|
}
|
|
625
860
|
|
|
861
|
+
// Cooperative mid-run context maintenance. Runs after pending
|
|
862
|
+
// tool/steering messages are materialized into durable context and
|
|
863
|
+
// before syncContextBeforeModelCall / the model call — the only
|
|
864
|
+
// boundary where the full unsent context is already durable. A
|
|
865
|
+
// non-"not-needed" outcome means context was (or was attempted to be)
|
|
866
|
+
// rewritten, so end the run WITHOUT the lossy agent_end finalization;
|
|
867
|
+
// the maintenance owner resumes the run on the rewritten context.
|
|
868
|
+
// "not-needed" falls through to the model call.
|
|
869
|
+
if (config.maintainContext && modelHasResponded && !loopSignal.aborted) {
|
|
870
|
+
const lifecycle = {
|
|
871
|
+
signal: loopSignal,
|
|
872
|
+
awaitEventDrain: (invocationSignal: AbortSignal) =>
|
|
873
|
+
stream.waitForConsumerDrain(AbortSignal.any([loopSignal, invocationSignal])),
|
|
874
|
+
};
|
|
875
|
+
const maintenanceOutcome = await config.maintainContext(currentContext, lifecycle);
|
|
876
|
+
// A callback can settle after its loop has been cancelled. Never let a
|
|
877
|
+
// stale "not-needed" fall through to streamAssistantResponse, which
|
|
878
|
+
// invokes the provider before it observes the aborted signal.
|
|
879
|
+
const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
|
|
880
|
+
|
|
881
|
+
if (outcome !== "not-needed") {
|
|
882
|
+
stream.push({
|
|
883
|
+
type: "agent_end",
|
|
884
|
+
messages: newMessages,
|
|
885
|
+
stopReason: "maintenance",
|
|
886
|
+
maintenanceOutcome: outcome,
|
|
887
|
+
});
|
|
888
|
+
stream.end(newMessages);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
626
893
|
// Refresh prompt/tool context from live state before each model call
|
|
627
894
|
if (config.syncContextBeforeModelCall) {
|
|
628
895
|
await config.syncContextBeforeModelCall(currentContext);
|
|
629
896
|
}
|
|
630
897
|
|
|
898
|
+
const contextMessageCount = currentContext.messages.length;
|
|
899
|
+
const newMessageCount = newMessages.length;
|
|
900
|
+
|
|
631
901
|
// Stream assistant response
|
|
632
902
|
let recovered: HarmonyRecoveredToolCall | undefined;
|
|
633
903
|
let message: AssistantMessage;
|
|
904
|
+
const attemptTransaction = transaction;
|
|
634
905
|
try {
|
|
906
|
+
const attemptConfig = attemptTransaction
|
|
907
|
+
? {
|
|
908
|
+
...config,
|
|
909
|
+
onAssistantMessageEvent: (partial: AssistantMessage, event: AssistantMessageEvent) =>
|
|
910
|
+
attemptTransaction.stageAssistantMessageEvent(partial, event),
|
|
911
|
+
}
|
|
912
|
+
: config;
|
|
635
913
|
message = await streamAssistantResponse(
|
|
636
914
|
currentContext,
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
stream,
|
|
915
|
+
attemptConfig,
|
|
916
|
+
loopSignal,
|
|
917
|
+
attemptTransaction ? (attemptTransaction as unknown as EventStream<AgentEvent, AgentMessage[]>) : stream,
|
|
640
918
|
telemetry,
|
|
641
919
|
invokeAgentSpan,
|
|
642
920
|
stepCounter,
|
|
@@ -652,7 +930,21 @@ async function runLoopBody(
|
|
|
652
930
|
harmonyRetryAttempt = 0;
|
|
653
931
|
harmonyTruncateResumeCount = 0;
|
|
654
932
|
} catch (err) {
|
|
655
|
-
if (!(err instanceof HarmonyLeakInterruption))
|
|
933
|
+
if (!(err instanceof HarmonyLeakInterruption)) {
|
|
934
|
+
if (config.fallbackManaged && transaction && managedRetryableFailure(err)) {
|
|
935
|
+
transaction.discard();
|
|
936
|
+
currentContext.messages.splice(contextMessageCount);
|
|
937
|
+
newMessages.splice(newMessageCount);
|
|
938
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(managedFailureMessage(err, config)));
|
|
939
|
+
stream.end(newMessages);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
throw err;
|
|
943
|
+
}
|
|
944
|
+
if (config.fallbackManaged) {
|
|
945
|
+
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
946
|
+
throw err;
|
|
947
|
+
}
|
|
656
948
|
if (err.recovered) {
|
|
657
949
|
if (harmonyTruncateResumeCount >= 2) {
|
|
658
950
|
await emitHarmonyAudit(config, err, "escalated", harmonyRetryAttempt);
|
|
@@ -694,7 +986,31 @@ async function runLoopBody(
|
|
|
694
986
|
continue;
|
|
695
987
|
}
|
|
696
988
|
}
|
|
989
|
+
// Session-level invalid_prompt circuit breaker (bounded, neutralize-only).
|
|
990
|
+
// A poisoned-history rejection (`Request blocked (code=invalid_prompt)`) is
|
|
991
|
+
// a deterministic content fault: re-sending the same history re-triggers it,
|
|
992
|
+
// so naive session auto-retry would burn its whole budget re-poisoning the
|
|
993
|
+
// model. On the first invalid_prompt of this run, neutralize leaked control
|
|
994
|
+
// tokens in history IN PLACE (never dropping items). If that changed the
|
|
995
|
+
// outgoing bytes, resend exactly once with the repaired history; if
|
|
996
|
+
// neutralization cannot change anything (nothing left to repair), fall
|
|
997
|
+
// through to terminal handling and fail fast. Budget = one repaired resend.
|
|
998
|
+
// Runs before the response is committed so the resend is a clean retry;
|
|
999
|
+
// managed fallback owns its own retry policy, so this is scoped to the
|
|
1000
|
+
// non-managed session path where uncontrolled auto-retry would recur.
|
|
1001
|
+
if (
|
|
1002
|
+
!config.fallbackManaged &&
|
|
1003
|
+
message.stopReason === "error" &&
|
|
1004
|
+
!invalidPromptRepairAttempted &&
|
|
1005
|
+
isInvalidPromptError(message)
|
|
1006
|
+
) {
|
|
1007
|
+
invalidPromptRepairAttempted = true;
|
|
1008
|
+
if (repairInvalidPromptHistory(currentContext.messages)) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
697
1012
|
newMessages.push(message);
|
|
1013
|
+
modelHasResponded = true;
|
|
698
1014
|
let steeringMessagesFromExecution: AgentMessage[] | undefined;
|
|
699
1015
|
|
|
700
1016
|
// Detect empty "successful" responses (stopReason "stop" + empty content).
|
|
@@ -710,6 +1026,30 @@ async function runLoopBody(
|
|
|
710
1026
|
: "Provider returned an empty response with anomalously low token usage (possible context overflow via proxy)";
|
|
711
1027
|
}
|
|
712
1028
|
|
|
1029
|
+
if (config.fallbackManaged && message.stopReason === "error" && managedRetryableFailure(message)) {
|
|
1030
|
+
transaction?.discard();
|
|
1031
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1032
|
+
newMessages.splice(newMessageCount);
|
|
1033
|
+
await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
|
|
1034
|
+
stream.end(newMessages);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (config.fallbackManaged && message.stopReason === "aborted") {
|
|
1039
|
+
transaction?.discard();
|
|
1040
|
+
currentContext.messages.splice(contextMessageCount);
|
|
1041
|
+
newMessages.splice(newMessageCount);
|
|
1042
|
+
await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
|
|
1043
|
+
stream.end(newMessages);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// One provider invocation is committed before any tool can run.
|
|
1048
|
+
transaction?.flush();
|
|
1049
|
+
if (config.fallbackManaged && message.stopReason !== "error" && message.stopReason !== "aborted") {
|
|
1050
|
+
await config.onManagedAttemptAccepted?.();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
713
1053
|
if (message.stopReason === "error" || message.stopReason === "aborted") {
|
|
714
1054
|
// Create placeholder tool results for any tool calls in the aborted message
|
|
715
1055
|
// This maintains the tool_use/tool_result pairing that the API requires
|
|
@@ -747,7 +1087,7 @@ async function runLoopBody(
|
|
|
747
1087
|
const executionResult = await executeToolCalls(
|
|
748
1088
|
currentContext,
|
|
749
1089
|
message,
|
|
750
|
-
|
|
1090
|
+
loopSignal,
|
|
751
1091
|
stream,
|
|
752
1092
|
config,
|
|
753
1093
|
telemetry,
|
|
@@ -925,8 +1265,10 @@ async function streamAssistantResponse(
|
|
|
925
1265
|
|
|
926
1266
|
try {
|
|
927
1267
|
return await runInActiveSpan(chatSpan, async () => {
|
|
1268
|
+
const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
|
|
928
1269
|
const response = await streamFunction(config.model, llmContext, {
|
|
929
1270
|
...config,
|
|
1271
|
+
fallbackAttempt,
|
|
930
1272
|
apiKey: resolvedApiKey,
|
|
931
1273
|
authCredentialType,
|
|
932
1274
|
metadata: resolvedMetadata,
|