@sayknow-cli/agent-core 0.3.16 → 0.4.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 "@sayknow-cli/ai";
23
+ import { extractHttpStatusFromError } from "@sayknow-cli/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 */
@@ -284,6 +308,11 @@ interface CursorToolResultEntry {
284
308
  textLengthAtCall: number;
285
309
  }
286
310
 
311
+ export type AgentQueueSnapshot = {
312
+ steering: AgentMessage[];
313
+ followUp: AgentMessage[];
314
+ };
315
+
287
316
  export class Agent {
288
317
  #state: AgentState = {
289
318
  systemPrompt: [],
@@ -332,6 +361,8 @@ export class Agent {
332
361
  #resolveRunningPrompt?: () => void;
333
362
  #runSequence = 0;
334
363
  #activeRunId?: number;
364
+ #continuationGeneration = 0;
365
+ #activeFallbackManaged = false;
335
366
  #kimiApiFormat?: "openai" | "anthropic";
336
367
  #preferWebsockets?: boolean;
337
368
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
@@ -345,6 +376,7 @@ export class Agent {
345
376
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
346
377
  #onBeforeYield?: () => Promise<void> | void;
347
378
  #shouldPause?: AgentLoopConfig["shouldPause"];
379
+ #maintainContext?: AgentLoopConfig["maintainContext"];
348
380
  #telemetry?: AgentLoopConfig["telemetry"];
349
381
  #appendOnlyContext?: AppendOnlyContextManager;
350
382
 
@@ -354,6 +386,8 @@ export class Agent {
354
386
 
355
387
  /** Buffered Cursor tool results with text length at time of call (for correct ordering) */
356
388
  #cursorToolResultBuffer: CursorToolResultEntry[] = [];
389
+ #terminalizedLogicalRunIds = new Set<ManagedLogicalRunId>();
390
+ #managedLogicalRunOwner?: ManagedLogicalRunId;
357
391
 
358
392
  streamFn: StreamFn;
359
393
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -680,6 +714,10 @@ export class Agent {
680
714
  this.#shouldPause = fn;
681
715
  }
682
716
 
717
+ setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void {
718
+ this.#maintainContext = fn;
719
+ }
720
+
683
721
  emitExternalEvent(event: AgentEvent) {
684
722
  switch (event.type) {
685
723
  case "message_start":
@@ -830,7 +868,7 @@ export class Agent {
830
868
  this.#contextRevision++;
831
869
  }
832
870
 
833
- setModel(m: Model) {
871
+ setModel(m: Model | undefined) {
834
872
  this.#state.model = m;
835
873
  this.#contextRevision++;
836
874
  }
@@ -976,6 +1014,20 @@ export class Agent {
976
1014
  this.#followUpQueue = [...messages, ...this.#followUpQueue];
977
1015
  }
978
1016
 
1017
+ /** Snapshot both executable queues as one atomic session-level view. */
1018
+ snapshotQueues(): AgentQueueSnapshot {
1019
+ return {
1020
+ steering: this.#steeringQueue.slice(),
1021
+ followUp: this.#followUpQueue.slice(),
1022
+ };
1023
+ }
1024
+
1025
+ /** Replace both executable queues with a prior snapshot. */
1026
+ restoreQueues(snapshot: AgentQueueSnapshot): void {
1027
+ this.#steeringQueue = snapshot.steering.slice();
1028
+ this.#followUpQueue = snapshot.followUp.slice();
1029
+ }
1030
+
979
1031
  #dequeueSteeringMessages(): AgentMessage[] {
980
1032
  if (this.#steeringMode === "one-at-a-time") {
981
1033
  if (this.#steeringQueue.length > 0) {
@@ -1088,23 +1140,30 @@ export class Agent {
1088
1140
  * #runLoop guards every state mutation with a run id.
1089
1141
  */
1090
1142
  forceAbort(reason = "Force aborted"): boolean {
1091
- const hadActiveRun = this.#runningPrompt !== undefined || this.#state.isStreaming;
1143
+ const runId = this.#activeRunId;
1144
+ const managedLogicalRunId = this.#managedLogicalRunOwner;
1145
+ const hadActiveRun = runId !== undefined && (this.#runningPrompt !== undefined || this.#state.isStreaming);
1092
1146
  if (!hadActiveRun) return false;
1093
1147
 
1094
1148
  this.#abortController?.abort(reason);
1095
- this.#activeRunId = undefined;
1149
+ this.#continuationGeneration++;
1096
1150
  this.#state.isStreaming = false;
1097
1151
  this.#state.streamMessage = null;
1098
1152
  this.#state.pendingToolCalls = new Set<string>();
1099
1153
  this.#abortController = undefined;
1100
1154
  this.#cursorToolResultBuffer = [];
1155
+ this.#managedLogicalRunOwner = undefined;
1101
1156
 
1102
1157
  const resolve = this.#resolveRunningPrompt;
1103
1158
  this.#runningPrompt = undefined;
1104
1159
  this.#resolveRunningPrompt = undefined;
1160
+ this.#activeRunId = undefined;
1105
1161
  resolve?.();
1106
-
1107
- this.#emit({ type: "agent_end", messages: [] });
1162
+ if (this.#activeFallbackManaged) {
1163
+ this.requestRunTerminal(managedLogicalRunId ?? runId, { stopReason: "cancelled" });
1164
+ } else {
1165
+ this.#finalizeRun(runId, { type: "agent_end", messages: [] });
1166
+ }
1108
1167
  return true;
1109
1168
  }
1110
1169
 
@@ -1112,12 +1171,56 @@ export class Agent {
1112
1171
  return this.#runningPrompt ?? Promise.resolve();
1113
1172
  }
1114
1173
 
1174
+ /** The active per-attempt run identifier. */
1175
+ get activeRunId(): number | undefined {
1176
+ return this.#activeRunId;
1177
+ }
1178
+
1179
+ /**
1180
+ * Stable identifier for the active managed logical run, shared by every retry
1181
+ * attempt. Pass this value to requestRunTerminal(); never retain activeRunId
1182
+ * for managed terminal completion.
1183
+ */
1184
+ get currentManagedLogicalRunId(): ManagedLogicalRunId | undefined {
1185
+ return this.#managedLogicalRunOwner;
1186
+ }
1187
+
1188
+ /**
1189
+ * Request terminal completion through the single logical-run keyed finalizer.
1190
+ *
1191
+ * For managed runs, logicalRunId must be currentManagedLogicalRunId from any
1192
+ * attempt in the retry chain. Non-managed runs use their activeRunId. Terminal
1193
+ * requests with messages emit a committed message_start/message_end lifecycle
1194
+ * for each diagnostic before agent_end. Requests without messages (such as
1195
+ * cancellation) emit only agent_end.
1196
+ */
1197
+ requestRunTerminal(logicalRunId: ManagedLogicalRunId, request: RunTerminalRequest): boolean {
1198
+ if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return false;
1199
+ this.#finalizeRun(
1200
+ logicalRunId,
1201
+ {
1202
+ type: "agent_end",
1203
+ messages: request.messages ?? [],
1204
+ ...(request.stopReason === "cancelled" ? { stopReason: "cancelled" as const } : {}),
1205
+ },
1206
+ () => {
1207
+ for (const message of request.messages ?? []) {
1208
+ this.#emit({ type: "message_start", message });
1209
+ this.appendMessage(message);
1210
+ this.#emit({ type: "message_end", message });
1211
+ }
1212
+ },
1213
+ );
1214
+ return true;
1215
+ }
1216
+
1115
1217
  reset() {
1116
1218
  this.#state.messages = [];
1117
1219
  this.#contextRevision++;
1118
1220
  this.#state.isStreaming = false;
1119
1221
  this.#state.streamMessage = null;
1120
1222
  this.#state.pendingToolCalls = new Set<string>();
1223
+ this.#managedLogicalRunOwner = undefined;
1121
1224
  this.#state.error = undefined;
1122
1225
  this.#steeringQueue = [];
1123
1226
  this.#followUpQueue = [];
@@ -1170,6 +1273,10 @@ export class Agent {
1170
1273
  }
1171
1274
 
1172
1275
  assertUserImagePlaceholdersHavePayload(msgs);
1276
+ if (this.#managedLogicalRunOwner !== undefined) {
1277
+ this.requestRunTerminal(this.#managedLogicalRunOwner, { stopReason: "cancelled" });
1278
+ this.#managedLogicalRunOwner = undefined;
1279
+ }
1173
1280
 
1174
1281
  await this.#runLoop(msgs, promptOptions);
1175
1282
  }
@@ -1177,7 +1284,7 @@ export class Agent {
1177
1284
  /**
1178
1285
  * Continue from current context (used for retries and resuming queued messages).
1179
1286
  */
1180
- async continue() {
1287
+ async continue(options?: AgentPromptOptions) {
1181
1288
  if (this.#state.isStreaming) {
1182
1289
  throw new AgentBusyError();
1183
1290
  }
@@ -1189,13 +1296,13 @@ export class Agent {
1189
1296
  if (messages[messages.length - 1].role === "assistant") {
1190
1297
  const queuedSteering = this.#dequeueSteeringMessages();
1191
1298
  if (queuedSteering.length > 0) {
1192
- await this.#runLoop(queuedSteering, { skipInitialSteeringPoll: true });
1299
+ await this.#runLoop(queuedSteering, { ...options, skipInitialSteeringPoll: true });
1193
1300
  return;
1194
1301
  }
1195
1302
 
1196
1303
  const queuedFollowUp = this.#dequeueFollowUpMessages();
1197
1304
  if (queuedFollowUp.length > 0) {
1198
- await this.#runLoop(queuedFollowUp);
1305
+ await this.#runLoop(queuedFollowUp, options);
1199
1306
  return;
1200
1307
  }
1201
1308
 
@@ -1206,7 +1313,7 @@ export class Agent {
1206
1313
  throw new Error("No messages to continue from");
1207
1314
  }
1208
1315
 
1209
- await this.#runLoop(undefined);
1316
+ await this.#runLoop(undefined, options);
1210
1317
  }
1211
1318
 
1212
1319
  /**
@@ -1225,18 +1332,41 @@ export class Agent {
1225
1332
  this.#resolveRunningPrompt = resolve;
1226
1333
 
1227
1334
  const runId = ++this.#runSequence;
1335
+ const continuationGeneration = ++this.#continuationGeneration;
1228
1336
  this.#activeRunId = runId;
1229
1337
  const abortController = new AbortController();
1230
1338
  this.#abortController = abortController;
1231
1339
  this.#state.isStreaming = true;
1232
1340
  this.#state.streamMessage = null;
1233
1341
  this.#state.error = undefined;
1234
-
1235
- // Clear Cursor tool result buffer at start of each run
1342
+ options?.onRunAccepted?.();
1343
+
1344
+ const fallbackManaged = options?.fallbackManaged === true;
1345
+ const managedLogicalRunOwner = fallbackManaged ? (this.#managedLogicalRunOwner ?? runId) : undefined;
1346
+ const startsManagedLogicalRun = fallbackManaged && this.#managedLogicalRunOwner === undefined;
1347
+ if (startsManagedLogicalRun) {
1348
+ this.#managedLogicalRunOwner = managedLogicalRunOwner;
1349
+ this.#emit({ type: "agent_start" });
1350
+ }
1351
+ if (fallbackManaged && this.#cursorToolResultBuffer.length > 0) {
1352
+ const error = new ManagedCursorInvariantError(
1353
+ "Managed Cursor attempt started with buffered provider-side tool results",
1354
+ );
1355
+ this.#state.isStreaming = false;
1356
+ this.#abortController = undefined;
1357
+ this.#activeRunId = undefined;
1358
+ this.#runningPrompt = undefined;
1359
+ this.#resolveRunningPrompt = undefined;
1360
+ resolve();
1361
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1362
+ this.#managedLogicalRunOwner = undefined;
1363
+ throw error;
1364
+ }
1365
+ // Each run gets a fresh buffer only after managed stale-state validation.
1236
1366
  this.#cursorToolResultBuffer = [];
1367
+ this.#activeFallbackManaged = fallbackManaged;
1237
1368
 
1238
1369
  const reasoning = this.#state.thinkingLevel;
1239
-
1240
1370
  const context: AgentContext = {
1241
1371
  systemPrompt: this.#state.systemPrompt,
1242
1372
  messages: this.#state.messages.slice(),
@@ -1244,7 +1374,7 @@ export class Agent {
1244
1374
  };
1245
1375
 
1246
1376
  const cursorOnToolResult =
1247
- this.#cursorExecHandlers || this.#cursorOnToolResult
1377
+ !fallbackManaged && (this.#cursorExecHandlers || this.#cursorOnToolResult)
1248
1378
  ? async (message: ToolResultMessage) => {
1249
1379
  let finalMessage = message;
1250
1380
  if (this.#activeRunId !== runId) {
@@ -1261,7 +1391,6 @@ export class Agent {
1261
1391
  }
1262
1392
  } catch {}
1263
1393
  }
1264
- // Buffer tool result with current text length for correct ordering later.
1265
1394
  // Cursor executes tools server-side during streaming, so the assistant message
1266
1395
  // already incorporates results. We buffer here and emit in correct order
1267
1396
  // when the assistant message ends.
@@ -1273,7 +1402,10 @@ export class Agent {
1273
1402
 
1274
1403
  const getToolChoice = () =>
1275
1404
  this.#getToolChoice?.() ?? refreshToolChoiceForActiveTools(options?.toolChoice, this.#state.tools);
1276
- const cursorExecHandlers = this.#cursorExecHandlersForRun(runId);
1405
+ const cursorExecHandlers = fallbackManaged ? undefined : this.#cursorExecHandlersForRun(runId);
1406
+ let managedDecision: ManagedAttemptDecision | undefined;
1407
+ let managedOutcome: ManagedAttemptOutcome | undefined;
1408
+ let maintenanceInterrupted = false;
1277
1409
 
1278
1410
  const config: AgentLoopConfig = {
1279
1411
  model,
@@ -1296,6 +1428,21 @@ export class Agent {
1296
1428
  maxRetryDelayMs: this.#maxRetryDelayMs,
1297
1429
  requestMaxRetries: this.#requestMaxRetries,
1298
1430
  streamMaxRetries: this.#streamMaxRetries,
1431
+ ...(fallbackManaged
1432
+ ? {
1433
+ fallbackManaged: true,
1434
+ nextFallbackAttempt: options?.nextFallbackAttempt,
1435
+ onManagedAttemptAccepted: options?.onManagedAttemptAccepted,
1436
+ onManagedAttemptOutcome: async outcome => {
1437
+ managedOutcome = outcome;
1438
+ managedDecision = (await options?.onManagedAttemptOutcome?.(outcome)) ?? {
1439
+ type: "terminal",
1440
+ terminal: { stopReason: outcome.type === "run_terminal" ? outcome.reason : "error" },
1441
+ };
1442
+ return managedDecision;
1443
+ },
1444
+ }
1445
+ : {}),
1299
1446
  kimiApiFormat: this.#kimiApiFormat,
1300
1447
  preferWebsockets: this.#preferWebsockets,
1301
1448
  convertToLlm: this.#convertToLlm,
@@ -1314,8 +1461,8 @@ export class Agent {
1314
1461
  context.systemPrompt = this.#state.systemPrompt;
1315
1462
  context.tools = this.#state.tools;
1316
1463
  },
1317
- cursorExecHandlers,
1318
- cursorOnToolResult,
1464
+ ...(cursorExecHandlers ? { cursorExecHandlers } : {}),
1465
+ ...(cursorOnToolResult ? { cursorOnToolResult } : {}),
1319
1466
  transformToolCallArguments: this.#transformToolCallArguments,
1320
1467
  intentTracing: this.#intentTracing,
1321
1468
  appendOnlyContext: this.#appendOnlyContext,
@@ -1384,6 +1531,12 @@ export class Agent {
1384
1531
  if (this.#activeRunId !== runId) return false;
1385
1532
  return this.#shouldPause?.() === true;
1386
1533
  },
1534
+ maintainContext: this.#maintainContext
1535
+ ? async (context, lifecycle) => {
1536
+ if (this.#activeRunId !== runId) return "not-needed";
1537
+ return (await this.#maintainContext?.(context, lifecycle)) ?? "not-needed";
1538
+ }
1539
+ : undefined,
1387
1540
  telemetry: this.#telemetry,
1388
1541
  };
1389
1542
 
@@ -1391,8 +1544,8 @@ export class Agent {
1391
1544
 
1392
1545
  try {
1393
1546
  const stream = messages
1394
- ? agentLoop(messages, context, config, abortController.signal, this.streamFn)
1395
- : agentLoopContinue(context, config, abortController.signal, this.streamFn);
1547
+ ? agentLoop(messages, context, config, abortController.signal, this.streamFn, !fallbackManaged)
1548
+ : agentLoopContinue(context, config, abortController.signal, this.streamFn, !fallbackManaged);
1396
1549
 
1397
1550
  for await (const event of stream) {
1398
1551
  if (this.#activeRunId !== runId) {
@@ -1412,6 +1565,9 @@ export class Agent {
1412
1565
  break;
1413
1566
 
1414
1567
  case "message_end":
1568
+ if (fallbackManaged && this.#cursorToolResultBuffer.length > 0) {
1569
+ throw new ManagedCursorInvariantError();
1570
+ }
1415
1571
  partial = null;
1416
1572
  // Check if this is an assistant message with buffered Cursor tool results.
1417
1573
  // If so, split the message to emit tool results at the correct position.
@@ -1444,9 +1600,18 @@ export class Agent {
1444
1600
  break;
1445
1601
 
1446
1602
  case "agent_end":
1603
+ if (fallbackManaged && managedOutcome) {
1604
+ continue;
1605
+ }
1447
1606
  this.#state.isStreaming = false;
1448
1607
  this.#state.streamMessage = null;
1449
- break;
1608
+ if (event.stopReason === "maintenance") {
1609
+ maintenanceInterrupted = true;
1610
+ this.#emit(event);
1611
+ continue;
1612
+ }
1613
+ this.#finalizeRun(managedLogicalRunOwner ?? runId, event);
1614
+ continue;
1450
1615
  }
1451
1616
 
1452
1617
  // Emit to listeners
@@ -1456,6 +1621,15 @@ export class Agent {
1456
1621
  if (this.#activeRunId !== runId) {
1457
1622
  return;
1458
1623
  }
1624
+ if (managedOutcome) {
1625
+ if (managedDecision?.type === "terminal") {
1626
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, managedDecision.terminal);
1627
+ } else if (managedOutcome.type === "run_terminal") {
1628
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: managedOutcome.reason });
1629
+ } else if (managedDecision?.type !== "retry" && managedDecision?.type !== "maintenance") {
1630
+ this.#finalizeRun(managedLogicalRunOwner ?? runId);
1631
+ }
1632
+ }
1459
1633
 
1460
1634
  // Handle any remaining partial message
1461
1635
  if (partial && partial.role === "assistant" && Array.isArray(partial.content) && partial.content.length > 0) {
@@ -1494,23 +1668,73 @@ export class Agent {
1494
1668
  },
1495
1669
  stopReason: abortController.signal.aborted ? "aborted" : "error",
1496
1670
  errorMessage: err?.message || String(err),
1671
+ errorStatus: extractHttpStatusFromError({ status: err?.errorStatus }) ?? extractHttpStatusFromError(err),
1497
1672
  timestamp: Date.now(),
1498
1673
  } as AgentMessage;
1499
1674
 
1500
- this.appendMessage(errorMsg);
1501
1675
  this.#state.error = err?.message || String(err);
1502
- this.#emit({ type: "agent_end", messages: [errorMsg] });
1676
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, {
1677
+ stopReason: abortController.signal.aborted ? "cancelled" : "error",
1678
+ messages: [errorMsg],
1679
+ });
1503
1680
  } finally {
1681
+ let continuation: ManagedAttemptContinuation | undefined;
1682
+ if (
1683
+ managedOutcome?.type !== "run_terminal" &&
1684
+ (managedDecision?.type === "retry" || managedDecision?.type === "maintenance")
1685
+ ) {
1686
+ continuation = managedDecision.continuation;
1687
+ }
1688
+ const ownership: ManagedAttemptContinuationOwnership = {
1689
+ runId,
1690
+ logicalRunId: managedLogicalRunOwner ?? runId,
1691
+ generation: continuationGeneration,
1692
+ isCurrent: () => this.#continuationGeneration === continuationGeneration && this.#activeRunId === undefined,
1693
+ };
1504
1694
  if (this.#activeRunId === runId) {
1505
1695
  this.#state.isStreaming = false;
1506
1696
  this.#state.streamMessage = null;
1507
1697
  this.#state.pendingToolCalls = new Set<string>();
1508
1698
  this.#abortController = undefined;
1509
1699
  this.#activeRunId = undefined;
1700
+ this.#activeFallbackManaged = false;
1510
1701
  this.#resolveRunningPrompt?.();
1511
1702
  this.#runningPrompt = undefined;
1512
1703
  this.#resolveRunningPrompt = undefined;
1513
1704
  }
1705
+ if (
1706
+ fallbackManaged &&
1707
+ !continuation &&
1708
+ !maintenanceInterrupted &&
1709
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1710
+ ) {
1711
+ this.#managedLogicalRunOwner = undefined;
1712
+ }
1713
+ if (continuation && ownership.isCurrent()) {
1714
+ try {
1715
+ await continuation(ownership);
1716
+ if (
1717
+ managedDecision?.type === "maintenance" &&
1718
+ this.#terminalizedLogicalRunIds.has(managedLogicalRunOwner ?? runId) &&
1719
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1720
+ ) {
1721
+ this.#managedLogicalRunOwner = undefined;
1722
+ }
1723
+ if (
1724
+ managedDecision?.type !== "maintenance" &&
1725
+ this.#activeRunId === undefined &&
1726
+ this.#managedLogicalRunOwner === managedLogicalRunOwner
1727
+ ) {
1728
+ this.#managedLogicalRunOwner = undefined;
1729
+ }
1730
+ } catch (err) {
1731
+ if (ownership.isCurrent()) {
1732
+ this.#state.error = err instanceof Error ? err.message : String(err);
1733
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1734
+ if (this.#managedLogicalRunOwner === managedLogicalRunOwner) this.#managedLogicalRunOwner = undefined;
1735
+ }
1736
+ }
1737
+ }
1514
1738
  }
1515
1739
  }
1516
1740
 
@@ -1521,6 +1745,20 @@ export class Agent {
1521
1745
  }
1522
1746
 
1523
1747
  /** Calculate total text length from an assistant message's content blocks */
1748
+ #finalizeRun(
1749
+ logicalRunId: ManagedLogicalRunId,
1750
+ event?: Extract<AgentEvent, { type: "agent_end" }>,
1751
+ beforeEvent?: () => void,
1752
+ ): void {
1753
+ if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return;
1754
+ this.#terminalizedLogicalRunIds.add(logicalRunId);
1755
+ if (this.#terminalizedLogicalRunIds.size > 256) {
1756
+ this.#terminalizedLogicalRunIds.delete(this.#terminalizedLogicalRunIds.values().next().value!);
1757
+ }
1758
+ beforeEvent?.();
1759
+ if (event) this.#emit(event);
1760
+ }
1761
+
1524
1762
  #getAssistantTextLength(message: AgentMessage | null): number {
1525
1763
  if (message?.role !== "assistant" || !Array.isArray(message.content)) {
1526
1764
  return 0;