@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/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
- stream.push({ type: "agent_start" });
146
- stream.push({ type: "turn_start" });
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
- stream.push({ type: "agent_start" });
191
- stream.push({ type: "turn_start" });
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
- signal,
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
- signal: AbortSignal | undefined,
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
- stream.push({ type: "turn_start" });
807
+ attemptStream.push({ type: "turn_start" });
611
808
  } else {
612
809
  firstTurn = false;
613
810
  }
614
811
 
615
- // Process pending messages (inject before next assistant response)
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
- config,
638
- signal,
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)) throw err;
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
- signal,
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,