@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.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":
@@ -830,7 +863,7 @@ export class Agent {
830
863
  this.#contextRevision++;
831
864
  }
832
865
 
833
- setModel(m: Model) {
866
+ setModel(m: Model | undefined) {
834
867
  this.#state.model = m;
835
868
  this.#contextRevision++;
836
869
  }
@@ -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;
@@ -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 {
@@ -24,6 +24,8 @@ import type { AssistantMessage, Message, Model } from "@gajae-code/ai/types";
24
24
  import {
25
25
  getOpenAIResponsesHistoryItems,
26
26
  getOpenAIResponsesHistoryPayload,
27
+ neutralizeReservedControlTokens,
28
+ neutralizeResponsesInputControlTokens,
27
29
  normalizeResponsesToolCallId,
28
30
  } from "@gajae-code/ai/utils";
29
31
  import { $env, logger } from "@gajae-code/utils";
@@ -479,10 +481,12 @@ export async function requestOpenAiRemoteCompaction(
479
481
  const endpoint = resolveOpenAiCompactEndpoint(model, options?.authCredentialType);
480
482
  const request: OpenAiRemoteCompactionRequest = {
481
483
  model: model.id,
482
- input: trimOpenAiCompactInput(
483
- compactInput,
484
- resolveOpenAiCompactInputBudget(model.contextWindow, model.maxTokens),
485
- instructions,
484
+ input: neutralizeResponsesInputControlTokens(
485
+ trimOpenAiCompactInput(
486
+ compactInput,
487
+ resolveOpenAiCompactInputBudget(model.contextWindow, model.maxTokens),
488
+ instructions,
489
+ ),
486
490
  ),
487
491
  instructions,
488
492
  };
@@ -553,10 +557,17 @@ export async function requestRemoteCompaction(
553
557
  request: RemoteCompactionRequest,
554
558
  signal?: AbortSignal,
555
559
  ): Promise<RemoteCompactionResponse> {
560
+ // The prompt embeds the serialized transcript, which can carry leaked Harmony
561
+ // control-token markers (e.g. `<|channel|>analysis`) from model output; a
562
+ // gpt-5.6-backed summarization endpoint rejects those with `Request blocked`.
563
+ const sanitizedRequest: RemoteCompactionRequest = {
564
+ systemPrompt: neutralizeReservedControlTokens(request.systemPrompt),
565
+ prompt: neutralizeReservedControlTokens(request.prompt),
566
+ };
556
567
  const response = await fetch(endpoint, {
557
568
  method: "POST",
558
569
  headers: { "content-type": "application/json" },
559
- body: JSON.stringify(request),
570
+ body: JSON.stringify(sanitizedRequest),
560
571
  signal,
561
572
  });
562
573
 
package/src/types.ts CHANGED
@@ -25,11 +25,82 @@ export type StreamFn = (
25
25
  ...args: Parameters<typeof streamSimple>
26
26
  ) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
27
27
 
28
+ /** Stable identifier for a managed logical run, shared by all of its retry attempts. */
29
+ export type ManagedLogicalRunId = number;
30
+
31
+ /** Terminal completion requested for a logical run. */
32
+ export interface RunTerminalRequest {
33
+ stopReason: "cancelled" | "error" | "exhausted";
34
+ messages?: AgentMessage[];
35
+ }
36
+
37
+ /**
38
+ * Ownership token supplied when Agent invokes a retry continuation.
39
+ *
40
+ * A continuation MUST verify `isCurrent()` immediately before starting a
41
+ * follow-up invocation and abandon the retry when it returns false. The token
42
+ * becomes invalid when its originating run is force-aborted or superseded.
43
+ * Coding-agent retry continuations must accept this argument and must not call
44
+ * `agent.continue()` after ownership has been lost.
45
+ */
46
+ export interface ManagedAttemptContinuationOwnership {
47
+ /** Per-attempt run-loop id; use only for attempt-local ownership checks. */
48
+ readonly runId: number;
49
+ /** Stable managed logical-run id; use for all terminal completion requests. */
50
+ readonly logicalRunId: ManagedLogicalRunId;
51
+ readonly generation: number;
52
+ isCurrent(): boolean;
53
+ }
54
+
55
+ /** Runs after a discarded attempt is idle, only while its ownership token remains current. */
56
+ export type ManagedAttemptContinuation = (ownership: ManagedAttemptContinuationOwnership) => void | Promise<void>;
57
+
58
+ /** Decision returned by managed fallback policy for one provisional attempt. */
59
+ export type ManagedAttemptDecision =
60
+ | { type: "retry"; continuation: ManagedAttemptContinuation }
61
+ | { type: "terminal"; terminal: RunTerminalRequest };
62
+
63
+ /** Structured result for one managed upstream invocation. */
64
+ export type ManagedAttemptOutcome =
65
+ | {
66
+ type: "retryable_discarded";
67
+ failure: {
68
+ message: AssistantMessage;
69
+ /** Exact provider transport facts, including retry headers, for fallback policy. */
70
+ transportFailure?: import("@gajae-code/ai").TransportFailureFacts;
71
+ };
72
+ }
73
+ | { type: "run_terminal"; reason: "cancelled" | "error" | "exhausted" };
74
+
75
+ export type ManagedAttemptOutcomeHandler = (
76
+ outcome: ManagedAttemptOutcome,
77
+ ) => ManagedAttemptDecision | Promise<ManagedAttemptDecision>;
78
+
79
+ /**
80
+ * Outcome of a cooperative mid-run context-maintenance checkpoint (see
81
+ * {@link AgentLoopConfig.maintainContext}). Any value other than "not-needed"
82
+ * means the checkpoint mutated (or attempted to mutate) durable context, so the
83
+ * loop ends the current run without the lossy `agent_end` finalization and the
84
+ * maintenance owner resumes the run on the rewritten context.
85
+ */
86
+ export type MidRunMaintenanceOutcome = "not-needed" | "pruned" | "compacted" | "promoted" | "failed" | "aborted";
87
+
28
88
  /**
29
89
  * Configuration for the agent loop.
30
90
  */
31
91
  export interface AgentLoopConfig extends SimpleStreamOptions {
32
92
  model: Model;
93
+ /**
94
+ * Supplies a fresh opaque token at each concrete managed transport invocation.
95
+ * The callback runs at the stream boundary so controller accounting matches
96
+ * upstream request count, including multi-step tool turns.
97
+ */
98
+ nextFallbackAttempt?: (model: Model) => SimpleStreamOptions["fallbackAttempt"];
99
+ /** Called after a managed upstream request is accepted and committed. */
100
+ onManagedAttemptAccepted?: () => void | Promise<void>;
101
+
102
+ /** Receives a managed invocation outcome without publishing provisional lifecycle events. */
103
+ onManagedAttemptOutcome?: ManagedAttemptOutcomeHandler;
33
104
 
34
105
  /**
35
106
  * When to interrupt tool execution for steering messages.
@@ -161,6 +232,33 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
161
232
  */
162
233
  syncContextBeforeModelCall?: (context: AgentContext) => void | Promise<void>;
163
234
 
235
+ /**
236
+ * Cooperative mid-run context-maintenance checkpoint.
237
+ *
238
+ * Invoked at the top of every loop iteration AFTER pending tool-result /
239
+ * steering messages have been materialized into durable context and BEFORE
240
+ * {@link syncContextBeforeModelCall} and the model call. This is the only
241
+ * boundary where the full unsent context (tool results + dequeued steering)
242
+ * is already durable, so a long uninterrupted tool loop can be bounded here
243
+ * before it grows past the provider window.
244
+ *
245
+ * The callback owns the maintenance decision (prune / compact / promote) and
246
+ * receives the minimal cancellation-aware lifecycle: `signal` is the
247
+ * non-optional loop signal, and `awaitEventDrain(invocationSignal)` waits for
248
+ * prior event consumer bodies with loop and invocation cancellation composed.
249
+ * Any outcome other than "not-needed" ends the current run with
250
+ * `agent_end.stopReason === "maintenance"` (NOT the lossy pause / completed
251
+ * finalization); the callback's continuation owner resumes the run on the
252
+ * rewritten context.
253
+ */
254
+ maintainContext?: (
255
+ context: AgentContext,
256
+ lifecycle: {
257
+ signal: AbortSignal;
258
+ awaitEventDrain: (invocationSignal: AbortSignal) => Promise<void>;
259
+ },
260
+ ) => Promise<MidRunMaintenanceOutcome> | MidRunMaintenanceOutcome;
261
+
164
262
  /**
165
263
  * Optional transform applied to tool call arguments before execution.
166
264
  * Use for deobfuscating secrets or rewriting arguments.
@@ -357,7 +455,7 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag
357
455
  */
358
456
  export interface AgentState {
359
457
  systemPrompt: string[];
360
- model: Model;
458
+ model: Model | undefined;
361
459
  thinkingLevel?: Effort;
362
460
  tools: AgentTool<any>[];
363
461
  messages: AgentMessage[]; // Can include attachments + custom message types
@@ -470,8 +568,10 @@ export type AgentEvent =
470
568
  | {
471
569
  type: "agent_end";
472
570
  messages: AgentMessage[];
473
- /** Indicates whether the loop ended normally or suspended at a pause checkpoint. */
474
- stopReason?: "completed" | "paused";
571
+ /** Indicates whether the loop ended normally, suspended, cancelled, or entered maintenance. */
572
+ stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
573
+ /** Present iff `stopReason === "maintenance"`; the maintenance outcome. */
574
+ maintenanceOutcome?: MidRunMaintenanceOutcome;
475
575
  /** Present iff `AgentTelemetryConfig` was supplied on this run. */
476
576
  telemetry?: AgentRunSummary;
477
577
  coverage?: AgentRunCoverage;