@gajae-code/agent-core 0.12.5 → 0.12.7

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
@@ -23,6 +23,8 @@ import {
23
23
  import { extractHttpStatusFromError } from "@gajae-code/utils";
24
24
  import { agentLoop, agentLoopContinue } from "./agent-loop";
25
25
  import type { AppendOnlyContextManager } from "./append-only-context";
26
+ import type { AttemptRunHandle, AttemptScope } from "./attempt-scope";
27
+ import { createAttemptScopeAuthority } from "./attempt-scope";
26
28
  import type { HarmonyAuditEvent } from "./harmony-leak";
27
29
  import { assertImagePlaceholdersHavePayload } from "./image-placeholder-guard";
28
30
  import { createRunResourceLedger } from "./run-resource-ledger";
@@ -39,11 +41,14 @@ import type {
39
41
  ManagedAttemptDecision,
40
42
  ManagedAttemptOutcome,
41
43
  ManagedLogicalRunId,
44
+ RunCancellationDomain,
45
+ RunCancellationDomainBridge,
42
46
  RunResourceLedger,
43
47
  RunTerminalRequest,
44
48
  StreamFn,
45
49
  ToolCallContext,
46
50
  } from "./types";
51
+ import { setAgentTerminalOwnerContext } from "./types";
47
52
 
48
53
  function assertUserImagePlaceholdersHavePayload(messages: readonly AgentMessage[]): void {
49
54
  for (const message of messages) {
@@ -125,7 +130,7 @@ export interface AgentOptions {
125
130
  * Optional transform applied to context before convertToLlm.
126
131
  * Use for context pruning, injecting external context, etc.
127
132
  */
128
- transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]>;
133
+ transformContext?: (messages: AgentMessage[], signal?: AbortSignal, scope?: AttemptScope) => Promise<AgentMessage[]>;
129
134
 
130
135
  /**
131
136
  * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn
@@ -296,8 +301,11 @@ export interface AgentPromptOptions {
296
301
  toolChoice?: ToolChoice;
297
302
  /** Disable transport replay; fallback accounting is owned by the caller. */
298
303
  fallbackManaged?: boolean;
304
+ /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
305
+ maintenanceContinuation?: boolean;
299
306
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
300
- onRunAccepted?: () => void;
307
+ /** Receives the immutable run handle as the first callback argument. */
308
+ onRunAccepted?: (...args: any[]) => void;
301
309
  /** Called once immediately before every managed upstream request. */
302
310
  nextFallbackAttempt?: AgentLoopConfig["nextFallbackAttempt"];
303
311
  /** Called after a managed upstream request is accepted and committed. */
@@ -330,6 +338,8 @@ export class Agent {
330
338
  error: undefined,
331
339
  };
332
340
  #contextRevision = 0;
341
+ #attemptAuthority = createAttemptScopeAuthority();
342
+ #runHandles = new Map<number | ManagedLogicalRunId, AttemptRunHandle>();
333
343
 
334
344
  #listeners = new Set<(e: AgentEvent) => void>();
335
345
  #abortController?: AbortController;
@@ -367,8 +377,8 @@ export class Agent {
367
377
  #runSequence = 0;
368
378
  #activeRunId?: number;
369
379
  #activeResourceRunId?: string;
380
+ #activeResourceCancellationDomain?: RunCancellationDomain;
370
381
  #continuationGeneration = 0;
371
- #activeFallbackManaged = false;
372
382
  #kimiApiFormat?: "openai" | "anthropic";
373
383
  #preferWebsockets?: boolean;
374
384
  #transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
@@ -395,6 +405,20 @@ export class Agent {
395
405
  #terminalizedLogicalRunIds = new Set<ManagedLogicalRunId>();
396
406
  #managedLogicalRunOwner?: ManagedLogicalRunId;
397
407
  readonly resourceLedger: RunResourceLedger = createRunResourceLedger();
408
+ bindRunCancellationDomainBridge(bridge: RunCancellationDomainBridge, agentSessionClaimKey?: object): void {
409
+ this.resourceLedger.bindCancellationDomainBridge(bridge);
410
+ if (agentSessionClaimKey) this.resourceLedger.bindAgentSessionClaimKey(agentSessionClaimKey);
411
+ }
412
+
413
+ /** Mint a side-attempt scope and its authority unregister function. */
414
+ mintSideAttemptScope(): { scope: AttemptScope; dispose: () => void } {
415
+ return this.#attemptAuthority.mintSide();
416
+ }
417
+
418
+ /** Return the Agent-owned attempt scope authority for session record injection. */
419
+ getAttemptScopeAuthority() {
420
+ return this.#attemptAuthority;
421
+ }
398
422
 
399
423
  streamFn: StreamFn;
400
424
  getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined;
@@ -1155,20 +1179,32 @@ export class Agent {
1155
1179
  * did not drain. The abandoned provider/tool stream may still settle later, so
1156
1180
  * #runLoop guards every state mutation with a run id.
1157
1181
  */
1158
- forceAbort(reason = "Force aborted"): boolean {
1182
+ forceAbort(reason = "Force aborted", logicalRunId?: ManagedLogicalRunId | number): boolean {
1183
+ const targetLogicalRunId = logicalRunId ?? this.#managedLogicalRunOwner ?? this.#activeRunId;
1184
+ const handle = targetLogicalRunId !== undefined ? this.#runHandles.get(targetLogicalRunId) : undefined;
1159
1185
  const runId = this.#activeRunId;
1160
1186
  const managedLogicalRunId = this.#managedLogicalRunOwner;
1187
+ const activeLogicalRunId = managedLogicalRunId ?? runId;
1188
+ if (
1189
+ targetLogicalRunId !== undefined &&
1190
+ activeLogicalRunId !== undefined &&
1191
+ activeLogicalRunId !== targetLogicalRunId
1192
+ ) {
1193
+ throw new Error(`forceAbort: logicalRunId ${targetLogicalRunId} does not match the active run`);
1194
+ }
1195
+ const activeResourceDomain = this.#activeResourceCancellationDomain;
1196
+ const activeResourceRunId = this.#activeResourceRunId;
1161
1197
  const hadActiveRun = runId !== undefined && (this.#runningPrompt !== undefined || this.#state.isStreaming);
1162
1198
  if (!hadActiveRun) return false;
1163
1199
 
1164
1200
  this.#abortController?.abort(reason);
1165
1201
  this.#continuationGeneration++;
1202
+ this.#attemptAuthority.advanceMain();
1166
1203
  this.#state.isStreaming = false;
1167
1204
  this.#state.streamMessage = null;
1168
1205
  this.#state.pendingToolCalls = new Set<string>();
1169
1206
  this.#abortController = undefined;
1170
1207
  this.#cursorToolResultBuffer = [];
1171
- this.resourceLedger.quarantine(this.#activeResourceRunId ?? String(managedLogicalRunId ?? runId));
1172
1208
  this.#managedLogicalRunOwner = undefined;
1173
1209
 
1174
1210
  const resolve = this.#resolveRunningPrompt;
@@ -1176,12 +1212,20 @@ export class Agent {
1176
1212
  this.#resolveRunningPrompt = undefined;
1177
1213
  this.#activeRunId = undefined;
1178
1214
  this.#activeResourceRunId = undefined;
1215
+ this.#activeResourceCancellationDomain = undefined;
1179
1216
  resolve?.();
1180
- if (this.#activeFallbackManaged) {
1181
- this.requestRunTerminal(managedLogicalRunId ?? runId, { stopReason: "cancelled" });
1182
- } else {
1183
- this.#finalizeRun(runId, { type: "agent_end", messages: [] });
1184
- }
1217
+ this.#finalizeRun(
1218
+ activeLogicalRunId ?? runId!,
1219
+ {
1220
+ type: "agent_end",
1221
+ messages: [],
1222
+ stopReason: "cancelled",
1223
+ scope: handle?.scope,
1224
+ },
1225
+ undefined,
1226
+ activeResourceDomain,
1227
+ );
1228
+ if (activeResourceRunId) this.resourceLedger.quarantine(activeResourceRunId);
1185
1229
  return true;
1186
1230
  }
1187
1231
 
@@ -1218,6 +1262,8 @@ export class Agent {
1218
1262
  */
1219
1263
  requestRunTerminal(logicalRunId: ManagedLogicalRunId, request: RunTerminalRequest): boolean {
1220
1264
  if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return false;
1265
+ const handle = this.#runHandles.get(logicalRunId);
1266
+ if (!handle) throw new Error(`requestRunTerminal: unknown logicalRunId ${logicalRunId} (no attempt handle)`);
1221
1267
  if (this.#managedLogicalRunOwner === logicalRunId) {
1222
1268
  this.#managedLogicalRunOwner = undefined;
1223
1269
  }
@@ -1227,6 +1273,7 @@ export class Agent {
1227
1273
  type: "agent_end",
1228
1274
  messages: request.messages ?? [],
1229
1275
  ...(request.stopReason === "cancelled" ? { stopReason: "cancelled" as const } : {}),
1276
+ scope: handle.scope,
1230
1277
  },
1231
1278
  () => {
1232
1279
  for (const message of request.messages ?? []) {
@@ -1350,6 +1397,10 @@ export class Agent {
1350
1397
  const model = this.#state.model;
1351
1398
  if (!model) throw new Error("No model configured");
1352
1399
 
1400
+ const maintenanceContinuation = options?.maintenanceContinuation === true;
1401
+ if (maintenanceContinuation && this.#managedLogicalRunOwner === undefined) {
1402
+ throw new Error("Maintenance continuation ownership is unavailable");
1403
+ }
1353
1404
  let skipInitialSteeringPoll = options?.skipInitialSteeringPoll === true;
1354
1405
 
1355
1406
  const { promise, resolve } = Promise.withResolvers<void>();
@@ -1366,14 +1417,34 @@ export class Agent {
1366
1417
  this.#state.error = undefined;
1367
1418
 
1368
1419
  const fallbackManaged = options?.fallbackManaged === true;
1369
- const managedLogicalRunOwner = fallbackManaged ? (this.#managedLogicalRunOwner ?? runId) : undefined;
1420
+ const managedLogicalRunOwner = fallbackManaged
1421
+ ? (this.#managedLogicalRunOwner ?? runId)
1422
+ : maintenanceContinuation
1423
+ ? this.#managedLogicalRunOwner
1424
+ : undefined;
1425
+ const continuesLogicalRun = fallbackManaged || maintenanceContinuation;
1370
1426
  const startsManagedLogicalRun = fallbackManaged && this.#managedLogicalRunOwner === undefined;
1371
1427
  this.#activeResourceRunId = String(managedLogicalRunOwner ?? runId);
1372
- this.resourceLedger.open(this.#activeResourceRunId);
1373
- options?.onRunAccepted?.();
1428
+ this.#activeResourceCancellationDomain = this.resourceLedger.open(this.#activeResourceRunId);
1429
+ if (!this.#activeResourceCancellationDomain) {
1430
+ this.#state.isStreaming = false;
1431
+ this.#abortController = undefined;
1432
+ this.#activeRunId = undefined;
1433
+ this.#activeResourceRunId = undefined;
1434
+ this.#activeResourceCancellationDomain = undefined;
1435
+ this.#runningPrompt = undefined;
1436
+ this.#resolveRunningPrompt = undefined;
1437
+ resolve();
1438
+ throw new Error("Prompt resource cancellation domain is unavailable");
1439
+ }
1440
+ const logicalRunId = managedLogicalRunOwner ?? runId;
1441
+ const scope = this.#attemptAuthority.mintMain();
1442
+ const handle: AttemptRunHandle = { logicalRunId, scope };
1443
+ this.#runHandles.set(logicalRunId, handle);
1444
+ options?.onRunAccepted?.(handle);
1374
1445
  if (startsManagedLogicalRun) {
1375
- this.#managedLogicalRunOwner = managedLogicalRunOwner;
1376
- this.#emit({ type: "agent_start" });
1446
+ this.#managedLogicalRunOwner = logicalRunId;
1447
+ this.#emit({ type: "agent_start", scope });
1377
1448
  }
1378
1449
  if (fallbackManaged && this.#cursorToolResultBuffer.length > 0) {
1379
1450
  const error = new ManagedCursorInvariantError(
@@ -1383,6 +1454,7 @@ export class Agent {
1383
1454
  this.#abortController = undefined;
1384
1455
  this.#activeRunId = undefined;
1385
1456
  this.#activeResourceRunId = undefined;
1457
+ this.#activeResourceCancellationDomain = undefined;
1386
1458
  this.#runningPrompt = undefined;
1387
1459
  this.#resolveRunningPrompt = undefined;
1388
1460
  resolve();
@@ -1392,7 +1464,6 @@ export class Agent {
1392
1464
  }
1393
1465
  // Each run gets a fresh buffer only after managed stale-state validation.
1394
1466
  this.#cursorToolResultBuffer = [];
1395
- this.#activeFallbackManaged = fallbackManaged;
1396
1467
 
1397
1468
  const reasoning = this.#state.thinkingLevel;
1398
1469
  const context: AgentContext = {
@@ -1476,12 +1547,16 @@ export class Agent {
1476
1547
  preferWebsockets: this.#preferWebsockets,
1477
1548
  convertToLlm: this.#convertToLlm,
1478
1549
  transformContext: this.#transformContext,
1550
+ attemptMinter: { mint: () => this.#attemptAuthority.mintMain() },
1551
+ initialScope: scope,
1479
1552
  onPayload: this.#onPayload,
1480
1553
  onResponse: this.#onResponse,
1481
1554
  onSseEvent: this.#onSseEvent,
1482
1555
  signal: abortController.signal,
1483
1556
  resourceLedger: this.resourceLedger,
1484
1557
  resourceRunId: this.#activeResourceRunId,
1558
+ resourceCancellationDomain: this.#activeResourceCancellationDomain,
1559
+ resourceSealOwner: "caller",
1485
1560
  getApiKey: this.getApiKey,
1486
1561
  getAuthCredentialType: this.getAuthCredentialType,
1487
1562
  getToolContext: this.#getToolContext,
@@ -1575,8 +1650,8 @@ export class Agent {
1575
1650
 
1576
1651
  try {
1577
1652
  const stream = messages
1578
- ? agentLoop(messages, context, config, abortController.signal, this.streamFn, !fallbackManaged)
1579
- : agentLoopContinue(context, config, abortController.signal, this.streamFn, !fallbackManaged);
1653
+ ? agentLoop(messages, context, config, abortController.signal, this.streamFn, !continuesLogicalRun, scope)
1654
+ : agentLoopContinue(context, config, abortController.signal, this.streamFn, !continuesLogicalRun, scope);
1580
1655
 
1581
1656
  for await (const event of stream) {
1582
1657
  if (this.#activeRunId !== runId) {
@@ -1636,7 +1711,13 @@ export class Agent {
1636
1711
  }
1637
1712
  this.#state.isStreaming = false;
1638
1713
  this.#state.streamMessage = null;
1639
- if (event.stopReason === "maintenance") {
1714
+ // A maintenance checkpoint is only non-terminal while a continuation will
1715
+ // follow. An aborted maintenance yields none, and because the loop runs with
1716
+ // `resourceSealOwner: "caller"` it deliberately leaves sealing to us, so
1717
+ // treating it as a checkpoint here would leave the run open forever and make
1718
+ // every cancel report `run_not_sealed`.
1719
+ if (event.stopReason === "maintenance" && event.maintenanceOutcome !== "aborted") {
1720
+ this.#managedLogicalRunOwner ??= managedLogicalRunOwner ?? runId;
1640
1721
  maintenanceInterrupted = true;
1641
1722
  this.#emit(event);
1642
1723
  continue;
@@ -1716,12 +1797,32 @@ export class Agent {
1716
1797
  ) {
1717
1798
  continuation = managedDecision.continuation;
1718
1799
  }
1719
- const ownership: ManagedAttemptContinuationOwnership = {
1720
- runId,
1721
- logicalRunId: managedLogicalRunOwner ?? runId,
1722
- generation: continuationGeneration,
1723
- isCurrent: () => this.#continuationGeneration === continuationGeneration && this.#activeRunId === undefined,
1724
- };
1800
+ const domain = this.#activeResourceCancellationDomain;
1801
+ const continuationReservation =
1802
+ continuation && domain
1803
+ ? this.resourceLedger.reserveProducer(
1804
+ String(managedLogicalRunOwner ?? runId),
1805
+ domain,
1806
+ "post_prompt",
1807
+ "managed-continuation",
1808
+ )
1809
+ : undefined;
1810
+ if (continuation && !continuationReservation?.ok) {
1811
+ this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1812
+ continuation = undefined;
1813
+ }
1814
+ const ownership: ManagedAttemptContinuationOwnership | undefined = continuationReservation?.ok
1815
+ ? {
1816
+ runId,
1817
+ logicalRunId: managedLogicalRunOwner ?? runId,
1818
+ generation: continuationGeneration,
1819
+ domain: continuationReservation.lease.domain,
1820
+ lease: continuationReservation.lease,
1821
+ handle,
1822
+ isCurrent: () =>
1823
+ this.#continuationGeneration === continuationGeneration && this.#activeRunId === undefined,
1824
+ }
1825
+ : undefined;
1725
1826
  if (this.#activeRunId === runId) {
1726
1827
  this.#state.isStreaming = false;
1727
1828
  this.#state.streamMessage = null;
@@ -1729,20 +1830,20 @@ export class Agent {
1729
1830
  this.#abortController = undefined;
1730
1831
  this.#activeRunId = undefined;
1731
1832
  this.#activeResourceRunId = undefined;
1732
- this.#activeFallbackManaged = false;
1833
+ this.#activeResourceCancellationDomain = undefined;
1733
1834
  this.#resolveRunningPrompt?.();
1734
1835
  this.#runningPrompt = undefined;
1735
1836
  this.#resolveRunningPrompt = undefined;
1736
1837
  }
1737
1838
  if (
1738
- fallbackManaged &&
1839
+ continuesLogicalRun &&
1739
1840
  !continuation &&
1740
1841
  !maintenanceInterrupted &&
1741
1842
  this.#managedLogicalRunOwner === managedLogicalRunOwner
1742
1843
  ) {
1743
1844
  this.#managedLogicalRunOwner = undefined;
1744
1845
  }
1745
- if (continuation && ownership.isCurrent()) {
1846
+ if (continuation && ownership?.isCurrent()) {
1746
1847
  try {
1747
1848
  await continuation(ownership);
1748
1849
  if (
@@ -1765,6 +1866,8 @@ export class Agent {
1765
1866
  this.requestRunTerminal(managedLogicalRunOwner ?? runId, { stopReason: "error" });
1766
1867
  if (this.#managedLogicalRunOwner === managedLogicalRunOwner) this.#managedLogicalRunOwner = undefined;
1767
1868
  }
1869
+ } finally {
1870
+ ownership.lease.closeDiscovery();
1768
1871
  }
1769
1872
  }
1770
1873
  }
@@ -1781,18 +1884,48 @@ export class Agent {
1781
1884
  logicalRunId: ManagedLogicalRunId,
1782
1885
  event?: Extract<AgentEvent, { type: "agent_end" }>,
1783
1886
  beforeEvent?: () => void,
1887
+ knownDomain?: RunCancellationDomain,
1784
1888
  ): void {
1785
1889
  if (this.#terminalizedLogicalRunIds.has(logicalRunId)) return;
1890
+ const handle = this.#runHandles.get(logicalRunId);
1891
+ if (!handle && !event?.scope) {
1892
+ throw new Error(`finalizeRun: unknown logicalRunId ${logicalRunId} (no attempt handle)`);
1893
+ }
1894
+ const resourceRunId = String(logicalRunId);
1895
+ const boundDomain = this.resourceLedger.lookupDomain(resourceRunId);
1896
+ const domain = boundDomain ?? knownDomain;
1897
+ const terminalReservation = boundDomain
1898
+ ? this.resourceLedger.reserveProducer(resourceRunId, boundDomain, "post_prompt", "terminal-publication")
1899
+ : undefined;
1786
1900
  this.#terminalizedLogicalRunIds.add(logicalRunId);
1787
1901
  if (this.#terminalizedLogicalRunIds.size > 256) {
1788
1902
  this.#terminalizedLogicalRunIds.delete(this.#terminalizedLogicalRunIds.values().next().value!);
1789
1903
  }
1904
+ const terminalEvent: Extract<AgentEvent, { type: "agent_end" }> = event ?? {
1905
+ type: "agent_end",
1906
+ messages: [],
1907
+ scope: handle?.scope,
1908
+ };
1909
+ if (handle) terminalEvent.scope = handle.scope;
1910
+ if (domain) {
1911
+ setAgentTerminalOwnerContext(terminalEvent, {
1912
+ resourceRunId,
1913
+ domain,
1914
+ });
1915
+ }
1790
1916
  try {
1791
1917
  beforeEvent?.();
1792
- if (event) this.#emit(event);
1918
+ this.#emit(terminalEvent);
1793
1919
  } finally {
1794
- // Publish terminal lifecycle synchronously before sealing the stable handle.
1795
- this.resourceLedger.seal(String(logicalRunId));
1920
+ try {
1921
+ terminalReservation?.ok && terminalReservation.lease.closeDiscovery();
1922
+ } finally {
1923
+ try {
1924
+ this.resourceLedger.seal(resourceRunId);
1925
+ } finally {
1926
+ this.#runHandles.delete(logicalRunId);
1927
+ }
1928
+ }
1796
1929
  }
1797
1930
  }
1798
1931
 
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Per-attempt scope identity for request-scoped execution attribution.
3
+ *
4
+ * An AttemptScope is an immutable, frozen value allocated before every
5
+ * observable lifecycle emission for a single provider/agent attempt.
6
+ * It carries a stable `attemptId`, a monotonic `generation` (per-lineage),
7
+ * and a `lineage` discriminator that distinguishes the main attempt from
8
+ * concurrent side attempts (IRC background, ephemeral/btw turns).
9
+ *
10
+ * The `attemptId` + `generation` + `lineage` form the comparable identity.
11
+ * AttemptScope is structurally assignable to AttemptScopeRef in
12
+ * `packages/ai` so it can be carried through `SimpleStreamOptions` and
13
+ * provider hook signatures without a reverse dependency.
14
+ */
15
+ export type AttemptLineage = "main" | `side:${string}`;
16
+
17
+ export interface AttemptScope {
18
+ readonly attemptId: string;
19
+ readonly generation: number;
20
+ readonly lineage: AttemptLineage;
21
+ }
22
+
23
+ export function attemptScopesEqual(a: AttemptScope, b: AttemptScope): boolean {
24
+ return a.attemptId === b.attemptId && a.generation === b.generation && a.lineage === b.lineage;
25
+ }
26
+
27
+ /**
28
+ * Per-lineage currentness authority. Main and side attempts have separate
29
+ * instances so a side attempt never invalidates the main scope, and
30
+ * `forceAbort` advances only the main lineage.
31
+ */
32
+ export interface LineageCurrentness {
33
+ readonly lineage: AttemptLineage;
34
+ /** True iff no successor scope with a greater generation was allocated in this lineage. */
35
+ isCurrent(scope: AttemptScope): boolean;
36
+ /** Allocate the next generation for the given attempt identity in this lineage. */
37
+ advance(attemptId: string): number;
38
+ /** Allocate the next generation in this lineage. */
39
+ /** Current generation value for this lineage. */
40
+ readonly current: number;
41
+ }
42
+
43
+ export function createLineageCurrentness(lineage: AttemptLineage): LineageCurrentness {
44
+ let current = 0;
45
+ let currentAttemptId: string | undefined;
46
+ return {
47
+ lineage,
48
+ get current() {
49
+ return current;
50
+ },
51
+ isCurrent(scope: AttemptScope): boolean {
52
+ return scope.lineage === lineage && scope.generation === current && scope.attemptId === currentAttemptId;
53
+ },
54
+ advance(attemptId: string): number {
55
+ currentAttemptId = attemptId;
56
+ return ++current;
57
+ },
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Agent-owned authority over all attempt lineages. Owns the main lineage;
63
+ * side lineages are registered/removed with bounded lifecycle.
64
+ *
65
+ * This is the SINGLE source of currentness truth injected into
66
+ * AttemptRecordStore (packages/coding-agent). Every store operation
67
+ * calls `authority.isCurrent(scope)` and fails closed when the authority
68
+ * is missing or the scope is superseded.
69
+ */
70
+ export interface AttemptScopeAuthority {
71
+ /** Register a side-lineage authority. Returns an unregister function. */
72
+ registerSide(lineage: AttemptLineage, auth: LineageCurrentness): () => void;
73
+ /** True iff the scope's lineage is known and its generation is current. */
74
+ isCurrent(scope: AttemptScope): boolean;
75
+ /** Advance the main lineage (called by forceAbort). Returns the new generation. */
76
+ advanceMain(): number;
77
+ /** Mint the next main-lineage scope. */
78
+ mintMain(): AttemptScope;
79
+ /**
80
+ * Atomically register a fresh side lineage, mint a side scope, and return
81
+ * both the scope and a dispose function. The authority knows the lineage
82
+ * BEFORE the scope is returned, so `isCurrent` succeeds immediately.
83
+ */
84
+ mintSide(): { scope: AttemptScope; dispose: () => void };
85
+ }
86
+
87
+ export interface AttemptMinter {
88
+ mint(lineage: AttemptLineage): AttemptScope;
89
+ }
90
+
91
+ export function createAttemptMinter(): AttemptMinter {
92
+ const generations = new Map<AttemptLineage, number>();
93
+ return {
94
+ mint(lineage: AttemptLineage): AttemptScope {
95
+ const gen = (generations.get(lineage) ?? 0) + 1;
96
+ generations.set(lineage, gen);
97
+ return Object.freeze({
98
+ attemptId: crypto.randomUUID(),
99
+ generation: gen,
100
+ lineage,
101
+ });
102
+ },
103
+ };
104
+ }
105
+
106
+ const SIDE_LRU_CAP = 1024;
107
+
108
+ /**
109
+ * Create the Agent-owned authority. Owns the main lineage and a bounded
110
+ * (LRU-capped) map of side lineages. Only RETIRED side authorities are
111
+ * eligible for LRU eviction; a live side attempt is never silently
112
+ * invalidated by a newer side registration.
113
+ */
114
+ export function createAttemptScopeAuthority(): AttemptScopeAuthority {
115
+ const mainAuth = createLineageCurrentness("main");
116
+ const sideAuths = new Map<AttemptLineage, LineageCurrentness>();
117
+ const sideOrder: AttemptLineage[] = [];
118
+ const retiredSet = new Set<AttemptLineage>();
119
+
120
+ function evictRetiredIfNeeded(): void {
121
+ // Only evict RETIRED side authorities. A live side attempt is never
122
+ // evicted by a newer registration.
123
+ while (sideOrder.length > SIDE_LRU_CAP) {
124
+ const retiredIdx = sideOrder.findIndex(l => retiredSet.has(l));
125
+ if (retiredIdx < 0) break;
126
+ const [removed] = sideOrder.splice(retiredIdx, 1);
127
+ if (removed) {
128
+ sideAuths.delete(removed);
129
+ retiredSet.delete(removed);
130
+ }
131
+ }
132
+ }
133
+
134
+ function mintFor(lineage: AttemptLineage, auth: LineageCurrentness): AttemptScope {
135
+ const attemptId = crypto.randomUUID();
136
+ return Object.freeze({
137
+ attemptId,
138
+ generation: auth.advance(attemptId),
139
+ lineage,
140
+ });
141
+ }
142
+
143
+ return {
144
+ registerSide(lineage: AttemptLineage, auth: LineageCurrentness): () => void {
145
+ if (sideAuths.has(lineage)) {
146
+ const idx = sideOrder.indexOf(lineage);
147
+ if (idx >= 0) sideOrder.splice(idx, 1);
148
+ }
149
+ sideAuths.set(lineage, auth);
150
+ sideOrder.push(lineage);
151
+ evictRetiredIfNeeded();
152
+ return () => {
153
+ if (sideAuths.get(lineage) === auth) {
154
+ // Mark as retired but keep in maps until eviction.
155
+ // isCurrent returns false for retired lineages because
156
+ // the auth is still present but the scope is superseded
157
+ // by disposal (generation stays at its last value).
158
+ retiredSet.add(lineage);
159
+ evictRetiredIfNeeded();
160
+ }
161
+ };
162
+ },
163
+ isCurrent(scope: AttemptScope): boolean {
164
+ if (scope.lineage === "main") return mainAuth.isCurrent(scope);
165
+ if (retiredSet.has(scope.lineage)) return false;
166
+ const auth = sideAuths.get(scope.lineage);
167
+ return auth ? auth.isCurrent(scope) : false;
168
+ },
169
+ advanceMain(): number {
170
+ // Advance main lineage to a fresh attemptId so any previously-minted
171
+ // main scope becomes non-current. The next mintMain() will set the
172
+ // real attemptId for the new attempt.
173
+ return mainAuth.advance(crypto.randomUUID());
174
+ },
175
+ mintMain(): AttemptScope {
176
+ return mintFor("main", mainAuth);
177
+ },
178
+ mintSide(): { scope: AttemptScope; dispose: () => void } {
179
+ const lineage = `side:${crypto.randomUUID()}` as AttemptLineage;
180
+ const auth = createLineageCurrentness(lineage);
181
+ const unregister = this.registerSide(lineage, auth);
182
+ const scope = mintFor(lineage, auth);
183
+ return { scope, dispose: unregister };
184
+ },
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Immutable per-run attempt handle, carried through terminal/finalizer paths.
190
+ * Keyed by logicalRunId in the Agent's `#runHandles` map.
191
+ */
192
+ export interface AttemptRunHandle {
193
+ readonly logicalRunId: number | import("./types.js").ManagedLogicalRunId;
194
+ readonly scope: AttemptScope;
195
+ }
package/src/proxy.ts CHANGED
@@ -387,7 +387,7 @@ function processProxyEvent(
387
387
  partial,
388
388
  };
389
389
  }
390
- return undefined;
390
+ throw new Error("Received toolcall_end for non-toolCall content");
391
391
  }
392
392
 
393
393
  case "done":