@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/src/agent.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type ToolChoice,
21
21
  type ToolResultMessage,
22
22
  } from "@gajae-code/ai";
23
+ import { extractHttpStatusFromError } from "@gajae-code/utils";
23
24
  import { agentLoop, agentLoopContinue } from "./agent-loop";
24
25
  import type { AppendOnlyContextManager } from "./append-only-context";
25
26
  import type { HarmonyAuditEvent } from "./harmony-leak";
@@ -32,6 +33,12 @@ import type {
32
33
  AgentState,
33
34
  AgentTool,
34
35
  AgentToolContext,
36
+ ManagedAttemptContinuation,
37
+ ManagedAttemptContinuationOwnership,
38
+ ManagedAttemptDecision,
39
+ ManagedAttemptOutcome,
40
+ ManagedLogicalRunId,
41
+ RunTerminalRequest,
35
42
  StreamFn,
36
43
  ToolCallContext,
37
44
  } from "./types";
@@ -88,6 +95,13 @@ function refreshToolChoiceForActiveTools(
88
95
  return tools.some(tool => tool.name === toolName) ? toolChoice : undefined;
89
96
  }
90
97
 
98
+ export class ManagedCursorInvariantError extends Error {
99
+ constructor(message: string = "Managed Cursor attempt received a provider-side tool result") {
100
+ super(message);
101
+ this.name = "ManagedCursorInvariantError";
102
+ }
103
+ }
104
+
91
105
  export class AgentBusyError extends Error {
92
106
  constructor(
93
107
  message: string = "Agent is already processing. Use steer() or followUp() to queue messages, or wait for completion.",
@@ -276,6 +290,16 @@ export interface AgentOptions {
276
290
 
277
291
  export interface AgentPromptOptions {
278
292
  toolChoice?: ToolChoice;
293
+ /** Disable transport replay; fallback accounting is owned by the caller. */
294
+ fallbackManaged?: boolean;
295
+ /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
296
+ onRunAccepted?: () => void;
297
+ /** Called once immediately before every managed upstream request. */
298
+ nextFallbackAttempt?: AgentLoopConfig["nextFallbackAttempt"];
299
+ /** Called after a managed upstream request is accepted and committed. */
300
+ onManagedAttemptAccepted?: AgentLoopConfig["onManagedAttemptAccepted"];
301
+ /** Receives a discarded managed attempt without exposing assistant lifecycle events. */
302
+ onManagedAttemptOutcome?: AgentLoopConfig["onManagedAttemptOutcome"];
279
303
  }
280
304
 
281
305
  /** Buffered Cursor tool result with text position at time of call */
@@ -332,6 +356,8 @@ export class Agent {
332
356
  #resolveRunningPrompt?: () => void;
333
357
  #runSequence = 0;
334
358
  #activeRunId?: number;
359
+ #continuationGeneration = 0;
360
+ #activeFallbackManaged = false;
335
361
  #kimiApiFormat?: "openai" | "anthropic";
336
362
  #preferWebsockets?: boolean;
337
363
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
@@ -345,6 +371,7 @@ export class Agent {
345
371
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
346
372
  #onBeforeYield?: () => Promise<void> | void;
347
373
  #shouldPause?: AgentLoopConfig["shouldPause"];
374
+ #maintainContext?: AgentLoopConfig["maintainContext"];
348
375
  #telemetry?: AgentLoopConfig["telemetry"];
349
376
  #appendOnlyContext?: AppendOnlyContextManager;
350
377
 
@@ -354,6 +381,8 @@ export class Agent {
354
381
 
355
382
  /** Buffered Cursor tool results with text length at time of call (for correct ordering) */
356
383
  #cursorToolResultBuffer: CursorToolResultEntry[] = [];
384
+ #terminalizedLogicalRunIds = new Set<ManagedLogicalRunId>();
385
+ #managedLogicalRunOwner?: ManagedLogicalRunId;
357
386
 
358
387
  streamFn: StreamFn;
359
388
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -680,6 +709,10 @@ export class Agent {
680
709
  this.#shouldPause = fn;
681
710
  }
682
711
 
712
+ setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void {
713
+ this.#maintainContext = fn;
714
+ }
715
+
683
716
  emitExternalEvent(event: AgentEvent) {
684
717
  switch (event.type) {
685
718
  case "message_start":
@@ -1088,23 +1121,30 @@ export class Agent {
1088
1121
  * #runLoop guards every state mutation with a run id.
1089
1122
  */
1090
1123
  forceAbort(reason = "Force aborted"): boolean {
1091
- const hadActiveRun = this.#runningPrompt !== undefined || this.#state.isStreaming;
1124
+ const runId = this.#activeRunId;
1125
+ const managedLogicalRunId = this.#managedLogicalRunOwner;
1126
+ const hadActiveRun = runId !== undefined && (this.#runningPrompt !== undefined || this.#state.isStreaming);
1092
1127
  if (!hadActiveRun) return false;
1093
1128
 
1094
1129
  this.#abortController?.abort(reason);
1095
- this.#activeRunId = undefined;
1130
+ this.#continuationGeneration++;
1096
1131
  this.#state.isStreaming = false;
1097
1132
  this.#state.streamMessage = null;
1098
1133
  this.#state.pendingToolCalls = new Set<string>();
1099
1134
  this.#abortController = undefined;
1100
1135
  this.#cursorToolResultBuffer = [];
1136
+ this.#managedLogicalRunOwner = undefined;
1101
1137
 
1102
1138
  const resolve = this.#resolveRunningPrompt;
1103
1139
  this.#runningPrompt = undefined;
1104
1140
  this.#resolveRunningPrompt = undefined;
1141
+ this.#activeRunId = undefined;
1105
1142
  resolve?.();
1106
-
1107
- this.#emit({ type: "agent_end", messages: [] });
1143
+ if (this.#activeFallbackManaged) {
1144
+ this.requestRunTerminal(managedLogicalRunId ?? runId, { stopReason: "cancelled" });
1145
+ } else {
1146
+ this.#finalizeRun(runId, { type: "agent_end", messages: [] });
1147
+ }
1108
1148
  return true;
1109
1149
  }
1110
1150
 
@@ -1112,12 +1152,56 @@ export class Agent {
1112
1152
  return this.#runningPrompt ?? Promise.resolve();
1113
1153
  }
1114
1154
 
1155
+ /** The active per-attempt run identifier. */
1156
+ get activeRunId(): number | undefined {
1157
+ return this.#activeRunId;
1158
+ }
1159
+
1160
+ /**
1161
+ * Stable identifier for the active managed logical run, shared by every retry
1162
+ * attempt. Pass this value to requestRunTerminal(); never retain activeRunId
1163
+ * for managed terminal completion.
1164
+ */
1165
+ get currentManagedLogicalRunId(): ManagedLogicalRunId | undefined {
1166
+ return this.#managedLogicalRunOwner;
1167
+ }
1168
+
1169
+ /**
1170
+ * Request terminal completion through the single logical-run keyed finalizer.
1171
+ *
1172
+ * For managed runs, logicalRunId must be currentManagedLogicalRunId from any
1173
+ * attempt in the retry chain. Non-managed runs use their activeRunId. Terminal
1174
+ * requests with messages emit a committed message_start/message_end lifecycle
1175
+ * for each diagnostic before agent_end. Requests without messages (such as
1176
+ * cancellation) emit only agent_end.
1177
+ */
1178
+ requestRunTerminal(logicalRunId: ManagedLogicalRunId, request: RunTerminalRequest): boolean {
1179
+ if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return false;
1180
+ this.#finalizeRun(
1181
+ logicalRunId,
1182
+ {
1183
+ type: "agent_end",
1184
+ messages: request.messages ?? [],
1185
+ ...(request.stopReason === "cancelled" ? { stopReason: "cancelled" as const } : {}),
1186
+ },
1187
+ () => {
1188
+ for (const message of request.messages ?? []) {
1189
+ this.#emit({ type: "message_start", message });
1190
+ this.appendMessage(message);
1191
+ this.#emit({ type: "message_end", message });
1192
+ }
1193
+ },
1194
+ );
1195
+ return true;
1196
+ }
1197
+
1115
1198
  reset() {
1116
1199
  this.#state.messages = [];
1117
1200
  this.#contextRevision++;
1118
1201
  this.#state.isStreaming = false;
1119
1202
  this.#state.streamMessage = null;
1120
1203
  this.#state.pendingToolCalls = new Set<string>();
1204
+ this.#managedLogicalRunOwner = undefined;
1121
1205
  this.#state.error = undefined;
1122
1206
  this.#steeringQueue = [];
1123
1207
  this.#followUpQueue = [];
@@ -1170,6 +1254,10 @@ export class Agent {
1170
1254
  }
1171
1255
 
1172
1256
  assertUserImagePlaceholdersHavePayload(msgs);
1257
+ if (this.#managedLogicalRunOwner !== undefined) {
1258
+ this.requestRunTerminal(this.#managedLogicalRunOwner, { stopReason: "cancelled" });
1259
+ this.#managedLogicalRunOwner = undefined;
1260
+ }
1173
1261
 
1174
1262
  await this.#runLoop(msgs, promptOptions);
1175
1263
  }
@@ -1177,7 +1265,7 @@ export class Agent {
1177
1265
  /**
1178
1266
  * Continue from current context (used for retries and resuming queued messages).
1179
1267
  */
1180
- async continue() {
1268
+ async continue(options?: AgentPromptOptions) {
1181
1269
  if (this.#state.isStreaming) {
1182
1270
  throw new AgentBusyError();
1183
1271
  }
@@ -1189,13 +1277,13 @@ export class Agent {
1189
1277
  if (messages[messages.length - 1].role === "assistant") {
1190
1278
  const queuedSteering = this.#dequeueSteeringMessages();
1191
1279
  if (queuedSteering.length > 0) {
1192
- await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true });
1280
+ await this.#runLoop(queuedSteering, { ...options, skipInitialSteeringPoll: true });
1193
1281
  return;
1194
1282
  }
1195
1283
 
1196
1284
  const queuedFollowUp = this.#dequeueFollowUpMessages();
1197
1285
  if (queuedFollowUp.length > 0) {
1198
- await this.#runLoop(queuedFollowUp);
1286
+ await this.#runLoop(queuedFollowUp, options);
1199
1287
  return;
1200
1288
  }
1201
1289
 
@@ -1206,7 +1294,7 @@ export class Agent {
1206
1294
  throw new Error("No messages to continue from");
1207
1295
  }
1208
1296
 
1209
- await this.#runLoop(undefined);
1297
+ await this.#runLoop(undefined, options);
1210
1298
  }
1211
1299
 
1212
1300
  /**
@@ -1225,18 +1313,41 @@ export class Agent {
1225
1313
  this.#resolveRunningPrompt = resolve;
1226
1314
 
1227
1315
  const runId = ++this.#runSequence;
1316
+ const continuationGeneration = ++this.#continuationGeneration;
1228
1317
  this.#activeRunId = runId;
1229
1318
  const abortController = new AbortController();
1230
1319
  this.#abortController = abortController;
1231
1320
  this.#state.isStreaming = true;
1232
1321
  this.#state.streamMessage = null;
1233
1322
  this.#state.error = undefined;
1234
-
1235
- // Clear Cursor tool result buffer at start of each run
1323
+ options?.onRunAccepted?.();
1324
+
1325
+ const fallbackManaged = options?.fallbackManaged === true;
1326
+ const managedLogicalRunOwner = fallbackManaged ? (this.#managedLogicalRunOwner ?? runId) : undefined;
1327
+ const startsManagedLogicalRun = fallbackManaged && this.#managedLogicalRunOwner === undefined;
1328
+ if (startsManagedLogicalRun) {
1329
+ this.#managedLogicalRunOwner = managedLogicalRunOwner;
1330
+ this.#emit({ type: "agent_start" });
1331
+ }
1332
+ if (fallbackManaged && this.#cursorToolResultBuffer.length > 0) {
1333
+ const error = new ManagedCursorInvariantError(
1334
+ "Managed Cursor attempt started with buffered provider-side tool results",
1335
+ );
1336
+ this.#state.isStreaming = false;
1337
+ this.#abortController = undefined;
1338
+ this.#activeRunId = undefined;
1339
+ this.#runningPrompt = undefined;
1340
+ this.#resolveRunningPrompt = undefined;
1341
+ resolve();
1342
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1343
+ this.#managedLogicalRunOwner = undefined;
1344
+ throw error;
1345
+ }
1346
+ // Each run gets a fresh buffer only after managed stale-state validation.
1236
1347
  this.#cursorToolResultBuffer = [];
1348
+ this.#activeFallbackManaged = fallbackManaged;
1237
1349
 
1238
1350
  const reasoning = this.#state.thinkingLevel;
1239
-
1240
1351
  const context: AgentContext = {
1241
1352
  systemPrompt: this.#state.systemPrompt,
1242
1353
  messages: this.#state.messages.slice(),
@@ -1244,7 +1355,7 @@ export class Agent {
1244
1355
  };
1245
1356
 
1246
1357
  const cursorOnToolResult =
1247
- this.#cursorExecHandlers || this.#cursorOnToolResult
1358
+ !fallbackManaged && (this.#cursorExecHandlers || this.#cursorOnToolResult)
1248
1359
  ? async (message: ToolResultMessage) => {
1249
1360
  let finalMessage = message;
1250
1361
  if (this.#activeRunId !== runId) {
@@ -1261,7 +1372,6 @@ export class Agent {
1261
1372
  }
1262
1373
  } catch {}
1263
1374
  }
1264
- // Buffer tool result with current text length for correct ordering later.
1265
1375
  // Cursor executes tools server-side during streaming, so the assistant message
1266
1376
  // already incorporates results. We buffer here and emit in correct order
1267
1377
  // when the assistant message ends.
@@ -1273,7 +1383,10 @@ export class Agent {
1273
1383
 
1274
1384
  const getToolChoice = () =>
1275
1385
  this.#getToolChoice?.() ?? refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
1276
- const cursorExecHandlers = this.#cursorExecHandlersForRun(runId);
1386
+ const cursorExecHandlers = fallbackManaged ? undefined : this.#cursorExecHandlersForRun(runId);
1387
+ let managedDecision: ManagedAttemptDecision | undefined;
1388
+ let managedOutcome: ManagedAttemptOutcome | undefined;
1389
+ let maintenanceInterrupted = false;
1277
1390
 
1278
1391
  const config: AgentLoopConfig = {
1279
1392
  model,
@@ -1296,6 +1409,21 @@ export class Agent {
1296
1409
  maxRetryDelayMs: this.#maxRetryDelayMs,
1297
1410
  requestMaxRetries: this.#requestMaxRetries,
1298
1411
  streamMaxRetries: this.#streamMaxRetries,
1412
+ ...(fallbackManaged
1413
+ ? {
1414
+ fallbackManaged: true,
1415
+ nextFallbackAttempt: options?.nextFallbackAttempt,
1416
+ onManagedAttemptAccepted: options?.onManagedAttemptAccepted,
1417
+ onManagedAttemptOutcome: async outcome => {
1418
+ managedOutcome = outcome;
1419
+ managedDecision = (await options?.onManagedAttemptOutcome?.(outcome)) ?? {
1420
+ type: "terminal",
1421
+ terminal: { stopReason: outcome.type === "run_terminal" ? outcome.reason : "error" },
1422
+ };
1423
+ return managedDecision;
1424
+ },
1425
+ }
1426
+ : {}),
1299
1427
  kimiApiFormat: this.#kimiApiFormat,
1300
1428
  preferWebsockets: this.#preferWebsockets,
1301
1429
  convertToLlm: this.#convertToLlm,
@@ -1314,8 +1442,8 @@ export class Agent {
1314
1442
  context.systemPrompt = this.#state.systemPrompt;
1315
1443
  context.tools = this.#state.tools;
1316
1444
  },
1317
- cursorExecHandlers,
1318
- cursorOnToolResult,
1445
+ ...(cursorExecHandlers ? { cursorExecHandlers } : {}),
1446
+ ...(cursorOnToolResult ? { cursorOnToolResult } : {}),
1319
1447
  transformToolCallArguments: this.#transformToolCallArguments,
1320
1448
  intentTracing: this.#intentTracing,
1321
1449
  appendOnlyContext: this.#appendOnlyContext,
@@ -1384,6 +1512,12 @@ export class Agent {
1384
1512
  if (this.#activeRunId !== runId) return false;
1385
1513
  return this.#shouldPause?.() === true;
1386
1514
  },
1515
+ maintainContext: this.#maintainContext
1516
+ ? async (context, lifecycle) => {
1517
+ if (this.#activeRunId !== runId) return "not-needed";
1518
+ return (await this.#maintainContext?.(context, lifecycle)) ?? "not-needed";
1519
+ }
1520
+ : undefined,
1387
1521
  telemetry: this.#telemetry,
1388
1522
  };
1389
1523
 
@@ -1391,8 +1525,8 @@ export class Agent {
1391
1525
 
1392
1526
  try {
1393
1527
  const stream = messages
1394
- ? agentLoop(messages, context, config, abortController.signal, this.streamFn)
1395
- : agentLoopContinue(context, config, abortController.signal, this.streamFn);
1528
+ ? agentLoop(messages, context, config, abortController.signal, this.streamFn, !fallbackManaged)
1529
+ : agentLoopContinue(context, config, abortController.signal, this.streamFn, !fallbackManaged);
1396
1530
 
1397
1531
  for await (const event of stream) {
1398
1532
  if (this.#activeRunId !== runId) {
@@ -1412,6 +1546,9 @@ export class Agent {
1412
1546
  break;
1413
1547
 
1414
1548
  case "message_end":
1549
+ if (fallbackManaged && this.#cursorToolResultBuffer.length > 0) {
1550
+ throw new ManagedCursorInvariantError();
1551
+ }
1415
1552
  partial = null;
1416
1553
  // Check if this is an assistant message with buffered Cursor tool results.
1417
1554
  // If so, split the message to emit tool results at the correct position.
@@ -1444,9 +1581,18 @@ export class Agent {
1444
1581
  break;
1445
1582
 
1446
1583
  case "agent_end":
1584
+ if (fallbackManaged && managedOutcome) {
1585
+ continue;
1586
+ }
1447
1587
  this.#state.isStreaming = false;
1448
1588
  this.#state.streamMessage = null;
1449
- break;
1589
+ if (event.stopReason === "maintenance") {
1590
+ maintenanceInterrupted = true;
1591
+ this.#emit(event);
1592
+ continue;
1593
+ }
1594
+ this.#finalizeRun(managedLogicalRunOwner ?? runId, event);
1595
+ continue;
1450
1596
  }
1451
1597
 
1452
1598
  // Emit to listeners
@@ -1456,6 +1602,15 @@ export class Agent {
1456
1602
  if (this.#activeRunId !== runId) {
1457
1603
  return;
1458
1604
  }
1605
+ if (managedOutcome) {
1606
+ if (managedDecision?.type === "terminal") {
1607
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, managedDecision.terminal);
1608
+ } else if (managedOutcome.type === "run_terminal") {
1609
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: managedOutcome.reason });
1610
+ } else if (managedDecision?.type !== "retry") {
1611
+ this.#finalizeRun(managedLogicalRunOwner ?? runId);
1612
+ }
1613
+ }
1459
1614
 
1460
1615
  // Handle any remaining partial message
1461
1616
  if (partial && partial.role === "assistant" && Array.isArray(partial.content) && partial.content.length > 0) {
@@ -1494,23 +1649,59 @@ export class Agent {
1494
1649
  },
1495
1650
  stopReason: abortController.signal.aborted ? "aborted" : "error",
1496
1651
  errorMessage: err?.message || String(err),
1652
+ errorStatus: extractHttpStatusFromError({ status: err?.errorStatus }) ?? extractHttpStatusFromError(err),
1497
1653
  timestamp: Date.now(),
1498
1654
  } as AgentMessage;
1499
1655
 
1500
- this.appendMessage(errorMsg);
1501
1656
  this.#state.error = err?.message || String(err);
1502
- this.#emit({ type: "agent_end", messages: [errorMsg] });
1657
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, {
1658
+ stopReason: abortController.signal.aborted ? "cancelled" : "error",
1659
+ messages: [errorMsg],
1660
+ });
1503
1661
  } finally {
1662
+ let continuation: ManagedAttemptContinuation | undefined;
1663
+ if (managedOutcome?.type === "retryable_discarded" && managedDecision?.type === "retry") {
1664
+ continuation = managedDecision.continuation;
1665
+ }
1666
+ const ownership: ManagedAttemptContinuationOwnership = {
1667
+ runId,
1668
+ logicalRunId: managedLogicalRunOwner ?? runId,
1669
+ generation: continuationGeneration,
1670
+ isCurrent: () => this.#continuationGeneration === continuationGeneration && this.#activeRunId === undefined,
1671
+ };
1504
1672
  if (this.#activeRunId === runId) {
1505
1673
  this.#state.isStreaming = false;
1506
1674
  this.#state.streamMessage = null;
1507
1675
  this.#state.pendingToolCalls = new Set<string>();
1508
1676
  this.#abortController = undefined;
1509
1677
  this.#activeRunId = undefined;
1678
+ this.#activeFallbackManaged = false;
1510
1679
  this.#resolveRunningPrompt?.();
1511
1680
  this.#runningPrompt = undefined;
1512
1681
  this.#resolveRunningPrompt = undefined;
1513
1682
  }
1683
+ if (
1684
+ fallbackManaged &&
1685
+ !continuation &&
1686
+ !maintenanceInterrupted &&
1687
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1688
+ ) {
1689
+ this.#managedLogicalRunOwner = undefined;
1690
+ }
1691
+ if (continuation && ownership.isCurrent()) {
1692
+ try {
1693
+ await continuation(ownership);
1694
+ if (this.#activeRunId === undefined && this.#managedLogicalRunOwner === managedLogicalRunOwner) {
1695
+ this.#managedLogicalRunOwner = undefined;
1696
+ }
1697
+ } catch (err) {
1698
+ if (ownership.isCurrent()) {
1699
+ this.#state.error = err instanceof Error ? err.message : String(err);
1700
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1701
+ if (this.#managedLogicalRunOwner === managedLogicalRunOwner) this.#managedLogicalRunOwner = undefined;
1702
+ }
1703
+ }
1704
+ }
1514
1705
  }
1515
1706
  }
1516
1707
 
@@ -1521,6 +1712,20 @@ export class Agent {
1521
1712
  }
1522
1713
 
1523
1714
  /** Calculate total text length from an assistant message's content blocks */
1715
+ #finalizeRun(
1716
+ logicalRunId: ManagedLogicalRunId,
1717
+ event?: Extract<AgentEvent, { type: "agent_end" }>,
1718
+ beforeEvent?: () => void,
1719
+ ): void {
1720
+ if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return;
1721
+ this.#terminalizedLogicalRunIds.add(logicalRunId);
1722
+ if (this.#terminalizedLogicalRunIds.size > 256) {
1723
+ this.#terminalizedLogicalRunIds.delete(this.#terminalizedLogicalRunIds.values().next().value!);
1724
+ }
1725
+ beforeEvent?.();
1726
+ if (event) this.#emit(event);
1727
+ }
1728
+
1524
1729
  #getAssistantTextLength(message: AgentMessage | null): number {
1525
1730
  if (message?.role !== "assistant" || !Array.isArray(message.content)) {
1526
1731
  return 0;
@@ -144,6 +144,18 @@ export interface CompactionSettings {
144
144
  remoteEndpoint?: string;
145
145
  }
146
146
 
147
+ export type RemoteCompactionFallbackHealthEvent =
148
+ | { kind: "success"; model: string; provider: string }
149
+ | { kind: "fallback"; model: string; provider: string; error: string };
150
+
151
+ export interface RemoteCompactionFallbackHealthHooks {
152
+ recordRemoteCompactionFallback(event: RemoteCompactionFallbackHealthEvent): void;
153
+ }
154
+
155
+ function isAbortError(error: unknown): boolean {
156
+ return error instanceof Error && error.name === "AbortError";
157
+ }
158
+
147
159
  export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
148
160
  enabled: true,
149
161
  strategy: "context-full",
@@ -799,6 +811,8 @@ export interface SummaryOptions {
799
811
  providerSessionState?: Map<string, ProviderSessionState>;
800
812
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
801
813
  preferWebsockets?: boolean;
814
+ /** Session-owned health sink for remote-compaction fallback transition logging. */
815
+ remoteCompactionFallbackHealth?: RemoteCompactionFallbackHealthHooks;
802
816
  }
803
817
 
804
818
  /**
@@ -1305,6 +1319,7 @@ export async function compact(
1305
1319
  sessionId: options?.sessionId,
1306
1320
  providerSessionState: options?.providerSessionState,
1307
1321
  preferWebsockets: options?.preferWebsockets,
1322
+ remoteCompactionFallbackHealth: options?.remoteCompactionFallbackHealth,
1308
1323
  };
1309
1324
 
1310
1325
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -1331,12 +1346,28 @@ export async function compact(
1331
1346
  { authCredentialType: options?.authCredentialType },
1332
1347
  );
1333
1348
  preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1334
- } catch (err) {
1335
- logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1336
- error: err instanceof Error ? err.message : String(err),
1349
+ summaryOptions.remoteCompactionFallbackHealth?.recordRemoteCompactionFallback({
1350
+ kind: "success",
1337
1351
  model: model.id,
1338
1352
  provider: model.provider,
1339
1353
  });
1354
+ } catch (err) {
1355
+ if (signal?.aborted || isAbortError(err)) throw err;
1356
+ const error = err instanceof Error ? err.message : String(err);
1357
+ if (summaryOptions.remoteCompactionFallbackHealth) {
1358
+ summaryOptions.remoteCompactionFallbackHealth.recordRemoteCompactionFallback({
1359
+ kind: "fallback",
1360
+ error,
1361
+ model: model.id,
1362
+ provider: model.provider,
1363
+ });
1364
+ } else {
1365
+ logger.warn("OpenAI remote compaction failed, falling back to local summarization", {
1366
+ error,
1367
+ model: model.id,
1368
+ provider: model.provider,
1369
+ });
1370
+ }
1340
1371
  }
1341
1372
  }
1342
1373
  }
@@ -115,6 +115,17 @@ export interface ModeChangeEntry extends SessionEntryBase {
115
115
  data?: Record<string, unknown>;
116
116
  }
117
117
 
118
+ export interface ConfiguredModelChainEntry extends SessionEntryBase {
119
+ type: "configured_model_chain";
120
+ role: string;
121
+ entries: readonly string[];
122
+ origin: string;
123
+ identity?: string;
124
+ explicitHead: boolean;
125
+ /** Whether this entry removes the configured chain for its role. */
126
+ cleared?: boolean;
127
+ }
128
+
118
129
  export interface CustomCompactionSessionEntries {}
119
130
 
120
131
  export type SessionEntry =
@@ -131,6 +142,7 @@ export type SessionEntry =
131
142
  | MCPToolSelectionEntry
132
143
  | SessionInitEntry
133
144
  | ModeChangeEntry
145
+ | ConfiguredModelChainEntry
134
146
  | CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries];
135
147
 
136
148
  export interface ReadonlySessionManager {
@@ -514,18 +514,14 @@ export async function requestOpenAiRemoteCompaction(
514
514
  });
515
515
 
516
516
  if (!response.ok) {
517
- const errorText = await response.text().catch(() => "");
518
- logger.warn("OpenAI remote compaction failed", {
519
- endpoint,
520
- status: response.status,
521
- statusText: response.statusText,
522
- errorText,
523
- });
524
517
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
525
518
  }
526
519
 
527
- const data = (await response.json()) as { output?: unknown[] } | undefined;
528
- const rawOutput = data?.output ?? [];
520
+ const data = (await response.json()) as { output?: unknown } | undefined;
521
+ if (!Array.isArray(data?.output)) {
522
+ throw new Error(`Remote compaction response malformed output (outputType=${typeof data?.output})`);
523
+ }
524
+ const rawOutput = data.output;
529
525
  const replacementHistory = rawOutput.filter(
530
526
  (item): item is Record<string, unknown> =>
531
527
  !!item && typeof item === "object" && shouldKeepOpenAiCompactOutputItem(item as Record<string, unknown>),
@@ -539,15 +535,9 @@ export async function requestOpenAiRemoteCompaction(
539
535
  const outputTypes = rawOutput.map(item =>
540
536
  typeof item === "object" && item !== null ? (item as Record<string, unknown>).type : typeof item,
541
537
  );
542
- logger.warn("Remote compaction response missing compaction item", {
543
- endpoint,
544
- model: model.id,
545
- provider: model.provider,
546
- rawOutputLength: rawOutput.length,
547
- outputTypes,
548
- replacementHistoryLength: replacementHistory.length,
549
- });
550
- throw new Error("Remote compaction response missing compaction item");
538
+ throw new Error(
539
+ `Remote compaction response missing compaction item (rawOutputLength=${rawOutput.length}, outputTypes=${outputTypes.join(",")}, replacementHistoryLength=${replacementHistory.length})`,
540
+ );
551
541
  }
552
542
  return { provider: model.provider, replacementHistory, compactionItem };
553
543
  }
@@ -572,13 +562,6 @@ export async function requestRemoteCompaction(
572
562
  });
573
563
 
574
564
  if (!response.ok) {
575
- const errorText = await response.text().catch(() => "");
576
- logger.warn("Remote compaction failed", {
577
- endpoint,
578
- status: response.status,
579
- statusText: response.statusText,
580
- errorText,
581
- });
582
565
  throw new Error(`Remote compaction failed (${response.status} ${response.statusText})`);
583
566
  }
584
567