@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-loop.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "@gajae-code/ai";
23
23
  import { isInvalidPromptError, neutralizeReservedControlTokens } from "@gajae-code/ai/utils";
24
24
  import { sanitizeText } from "@gajae-code/utils";
25
+ import type { AttemptScope } from "./attempt-scope";
25
26
  import {
26
27
  createHarmonyAuditEvent,
27
28
  detectHarmonyLeakInAssistantMessage,
@@ -57,8 +58,10 @@ import type {
57
58
  AgentLoopConfig,
58
59
  AgentMessage,
59
60
  AgentTool,
61
+ AgentToolContext,
60
62
  AgentToolResult,
61
63
  ManagedAttemptOutcome,
64
+ StandaloneRunOwnership,
62
65
  StreamFn,
63
66
  } from "./types";
64
67
 
@@ -102,6 +105,13 @@ class ManagedAttemptSnapshotError extends Error {
102
105
  const managedAttemptTextEncoder = new TextEncoder();
103
106
 
104
107
  const ABORTED: unique symbol = Symbol("agent-loop-aborted");
108
+ interface StandaloneOwnershipState {
109
+ continuationAvailable: boolean;
110
+ continuationClaimed: boolean;
111
+ terminal: boolean;
112
+ }
113
+
114
+ const standaloneOwnershipStates = new WeakMap<StandaloneRunOwnership, StandaloneOwnershipState>();
105
115
 
106
116
  /**
107
117
  * Terminal bound for argument-validation loops: how many CONSECUTIVE turns may
@@ -181,15 +191,16 @@ function repairInvalidPromptHistory(messages: AgentMessage[]): boolean {
181
191
  return changed;
182
192
  }
183
193
 
184
- function managedFailureOutcome(message: AssistantMessage): ManagedAttemptOutcome {
194
+ function managedFailureOutcome(message: AssistantMessage, scope?: AttemptScope): ManagedAttemptOutcome {
185
195
  return {
186
196
  type: "retryable_discarded",
187
197
  failure: { message, transportFailure: managedTransportFailure(message) },
198
+ scope,
188
199
  };
189
200
  }
190
201
 
191
- function managedContextOverflowOutcome(message: AssistantMessage): ManagedAttemptOutcome {
192
- return { type: "context_overflow_discarded", message };
202
+ function managedContextOverflowOutcome(message: AssistantMessage, scope?: AttemptScope): ManagedAttemptOutcome {
203
+ return { type: "context_overflow_discarded", message, scope };
193
204
  }
194
205
 
195
206
  function managedFailureMessage(error: unknown, config: AgentLoopConfig): AssistantMessage {
@@ -291,7 +302,8 @@ export function agentLoop(
291
302
  config: AgentLoopConfig,
292
303
  signal?: AbortSignal,
293
304
  streamFn?: StreamFn,
294
- emitManagedAgentStart = true,
305
+ emitAgentStart = true,
306
+ initialScope?: AttemptScope,
295
307
  ): EventStream<AgentEvent, AgentMessage[]> {
296
308
  const stream = createAgentStream();
297
309
 
@@ -301,22 +313,24 @@ export function agentLoop(
301
313
  ...context,
302
314
  messages: [...context.messages, ...prompts],
303
315
  };
316
+ // Allocate before constructing the provisional transaction so every first turn
317
+ // has one stable scope for lifecycle events, transform hooks, and transport.
318
+ const scope = initialScope ?? config.initialScope ?? config.attemptMinter?.mint("main");
304
319
  const transaction = config.fallbackManaged
305
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
320
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
306
321
  : undefined;
307
322
  const attemptStream = transaction ?? stream;
308
- openResourceRun(config);
309
- if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
310
- attemptStream.push({ type: "turn_start" });
311
- for (const prompt of prompts) {
312
- stream.push({ type: "message_start", message: prompt });
313
- stream.push({ type: "message_end", message: prompt });
314
- }
315
-
316
323
  try {
317
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
324
+ prepareResourceOwnership(config, false);
325
+ if (emitAgentStart) stream.push({ type: "agent_start", ...(scope ? { scope } : {}) });
326
+ attemptStream.push({ type: "turn_start", ...(scope ? { scope } : {}) });
327
+ for (const prompt of prompts) {
328
+ stream.push({ type: "message_start", message: prompt, scope });
329
+ stream.push({ type: "message_end", message: prompt, scope });
330
+ }
331
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);
318
332
  } catch (err) {
319
- if (config.resourceLedger && config.resourceRunId) config.resourceLedger.seal(config.resourceRunId);
333
+ sealStandaloneOnError(config);
320
334
  stream.fail(err);
321
335
  }
322
336
  })();
@@ -337,7 +351,8 @@ export function agentLoopContinue(
337
351
  config: AgentLoopConfig,
338
352
  signal?: AbortSignal,
339
353
  streamFn?: StreamFn,
340
- emitManagedAgentStart = true,
354
+ emitAgentStart = true,
355
+ initialScope?: AttemptScope,
341
356
  ): EventStream<AgentEvent, AgentMessage[]> {
342
357
  if (context.messages.length === 0) {
343
358
  throw new Error("Cannot continue: no messages in context");
@@ -352,18 +367,20 @@ export function agentLoopContinue(
352
367
  (async () => {
353
368
  const newMessages: AgentMessage[] = [];
354
369
  const currentContext: AgentContext = { ...context };
370
+ // Allocate before constructing the provisional transaction so every first turn
371
+ // has one stable scope for lifecycle events, transform hooks, and transport.
372
+ const scope = initialScope ?? config.initialScope ?? config.attemptMinter?.mint("main");
355
373
  const transaction = config.fallbackManaged
356
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
374
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
357
375
  : undefined;
358
376
  const attemptStream = transaction ?? stream;
359
- openResourceRun(config);
360
- if (!config.fallbackManaged || emitManagedAgentStart) stream.push({ type: "agent_start" });
361
- attemptStream.push({ type: "turn_start" });
362
-
363
377
  try {
364
- await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction);
378
+ prepareResourceOwnership(config, true);
379
+ if (emitAgentStart) stream.push({ type: "agent_start", ...(scope ? { scope } : {}) });
380
+ attemptStream.push({ type: "turn_start", ...(scope ? { scope } : {}) });
381
+ await runLoop(currentContext, newMessages, config, signal, stream, streamFn, transaction, scope);
365
382
  } catch (err) {
366
- if (config.resourceLedger && config.resourceRunId) config.resourceLedger.seal(config.resourceRunId);
383
+ sealStandaloneOnError(config);
367
384
  stream.fail(err);
368
385
  }
369
386
  })();
@@ -378,17 +395,121 @@ function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
378
395
  );
379
396
  }
380
397
 
381
- function openResourceRun(config: AgentLoopConfig): void {
382
- if (config.resourceLedger && config.resourceRunId) config.resourceLedger.open(config.resourceRunId);
398
+ function prepareResourceOwnership(config: AgentLoopConfig, continuation: boolean): void {
399
+ if (!config.resourceLedger || !config.resourceRunId) return;
400
+ if (config.resourceSealOwner === "caller") {
401
+ const existing = config.resourceLedger.lookupDomain(config.resourceRunId);
402
+ if (config.resourceCancellationDomain && existing && config.resourceCancellationDomain !== existing) {
403
+ config.resourceLedger.quarantine(config.resourceRunId);
404
+ throw new Error("Prompt resource cancellation domain is unavailable");
405
+ }
406
+ const domain = config.resourceCancellationDomain ?? existing ?? config.resourceLedger.open(config.resourceRunId);
407
+ if (!domain) throw new Error("Prompt resource cancellation domain is unavailable");
408
+ config.resourceCancellationDomain = domain;
409
+ return;
410
+ }
411
+
412
+ const existing = config.resourceLedger.lookupDomain(config.resourceRunId);
413
+ const supplied = config.standaloneRunOwnership;
414
+ if (supplied) {
415
+ if (!continuation && existing) {
416
+ config.resourceLedger.quarantine(config.resourceRunId);
417
+ throw new Error("Standalone prompt continuation ownership is unavailable");
418
+ }
419
+ const state = standaloneOwnershipStates.get(supplied);
420
+ if (
421
+ !state ||
422
+ supplied.resourceRunId !== config.resourceRunId ||
423
+ supplied.domain !== existing ||
424
+ (config.resourceCancellationDomain !== undefined && config.resourceCancellationDomain !== existing)
425
+ ) {
426
+ if (existing) config.resourceLedger.quarantine(config.resourceRunId);
427
+ throw new Error("Standalone prompt ownership is unavailable");
428
+ }
429
+ if (continuation && (!state.continuationClaimed || state.terminal)) {
430
+ config.resourceLedger.quarantine(config.resourceRunId);
431
+ throw new Error("Standalone prompt continuation ownership is unavailable");
432
+ }
433
+ if (continuation) {
434
+ state.continuationClaimed = false;
435
+ state.continuationAvailable = false;
436
+ }
437
+ config.resourceCancellationDomain = existing;
438
+ return;
439
+ }
440
+ if (existing) {
441
+ config.resourceLedger.quarantine(config.resourceRunId);
442
+ throw new Error("Standalone prompt continuation ownership is unavailable");
443
+ }
444
+
445
+ const domain = config.resourceLedger.open(config.resourceRunId);
446
+ if (!domain) throw new Error("Prompt resource cancellation domain is unavailable");
447
+ config.resourceCancellationDomain = domain;
448
+ const state: StandaloneOwnershipState = {
449
+ continuationAvailable: false,
450
+ continuationClaimed: false,
451
+ terminal: false,
452
+ };
453
+ const ownership: StandaloneRunOwnership = {
454
+ resourceRunId: config.resourceRunId,
455
+ domain,
456
+ claimContinuation: () => {
457
+ if (domain.signal.aborted) {
458
+ state.terminal = true;
459
+ return { ok: false, reason: "quarantined" };
460
+ }
461
+ if (state.terminal) return { ok: false, reason: "terminal" };
462
+ if (!state.continuationAvailable || state.continuationClaimed) return { ok: false, reason: "already_claimed" };
463
+ state.continuationClaimed = true;
464
+ return { ok: true, ownership };
465
+ },
466
+ abandon: reason => {
467
+ if (state.terminal) return;
468
+ state.terminal = true;
469
+ config.resourceLedger?.quarantine(config.resourceRunId!);
470
+ void reason;
471
+ },
472
+ };
473
+ standaloneOwnershipStates.set(ownership, state);
474
+ config.standaloneRunOwnership = ownership;
475
+ }
476
+
477
+ function sealStandaloneOnError(config: AgentLoopConfig): void {
478
+ const standalone = config.standaloneRunOwnership
479
+ ? standaloneOwnershipStates.get(config.standaloneRunOwnership)
480
+ : undefined;
481
+ if (standalone) standalone.terminal = true;
482
+ if (config.resourceSealOwner !== "caller" && config.resourceLedger && config.resourceRunId) {
483
+ config.resourceLedger.seal(config.resourceRunId);
484
+ }
383
485
  }
384
486
 
385
487
  function publishAgentEnd(
386
488
  stream: EventStream<AgentEvent, AgentMessage[]>,
387
489
  config: AgentLoopConfig,
388
490
  event: Extract<AgentEvent, { type: "agent_end" }>,
491
+ scope?: AttemptScope,
389
492
  ): void {
390
- stream.push(event);
391
- if (event.stopReason !== "maintenance" && config.resourceLedger && config.resourceRunId) {
493
+ // Aborted maintenance yields no continuation, so it is terminal for standalone
494
+ // ownership and resource sealing. The event itself keeps its `maintenance`
495
+ // stopReason so AgentSession can still report the aborted maintenance
496
+ // settlement to its consumers.
497
+ const publishedEvent = scope ? { ...event, scope } : event;
498
+ const maintenanceContinues =
499
+ publishedEvent.stopReason === "maintenance" && publishedEvent.maintenanceOutcome !== "aborted";
500
+ stream.push(publishedEvent);
501
+ const standalone = config.standaloneRunOwnership
502
+ ? standaloneOwnershipStates.get(config.standaloneRunOwnership)
503
+ : undefined;
504
+ if (maintenanceContinues) {
505
+ if (standalone) {
506
+ standalone.continuationAvailable = true;
507
+ standalone.continuationClaimed = false;
508
+ }
509
+ return;
510
+ }
511
+ if (standalone) standalone.terminal = true;
512
+ if (config.resourceSealOwner !== "caller" && config.resourceLedger && config.resourceRunId) {
392
513
  config.resourceLedger.seal(config.resourceRunId);
393
514
  }
394
515
  }
@@ -741,6 +862,7 @@ class ManagedAttemptTransaction {
741
862
  | ((message: AssistantMessage, event: AssistantMessageEvent) => void)
742
863
  | undefined,
743
864
  private readonly model: AgentLoopConfig["model"],
865
+ readonly scope?: AttemptScope,
744
866
  ) {}
745
867
 
746
868
  push(event: AgentEvent): void {
@@ -883,8 +1005,9 @@ function buildAgentEndEvent(
883
1005
  telemetry: AgentTelemetry | undefined,
884
1006
  stepCount: number,
885
1007
  stopReason: "completed" | "paused" = "completed",
1008
+ scope?: AttemptScope,
886
1009
  ): Extract<AgentEvent, { type: "agent_end" }> {
887
- const base = { type: "agent_end" as const, messages, stopReason };
1010
+ const base = { type: "agent_end" as const, messages, stopReason, ...(scope ? { scope } : {}) };
888
1011
  if (!telemetry) return base;
889
1012
  const snapshot = telemetry.collector.snapshot({ stepCount });
890
1013
  if (telemetry.collector.markRunEnded()) {
@@ -1215,6 +1338,7 @@ async function runLoop(
1215
1338
  stream: EventStream<AgentEvent, AgentMessage[]>,
1216
1339
  streamFn?: StreamFn,
1217
1340
  initialTransaction?: ManagedAttemptTransaction,
1341
+ initialScope?: AttemptScope,
1218
1342
  ): Promise<void> {
1219
1343
  const loopSignal = signal ?? new AbortController().signal;
1220
1344
 
@@ -1236,6 +1360,7 @@ async function runLoop(
1236
1360
  stepCounter,
1237
1361
  streamFn,
1238
1362
  initialTransaction,
1363
+ initialScope,
1239
1364
  ),
1240
1365
  );
1241
1366
  } catch (err) {
@@ -1265,8 +1390,10 @@ async function runLoopBody(
1265
1390
  stepCounter: StepCounter,
1266
1391
  streamFn?: StreamFn,
1267
1392
  initialTransaction?: ManagedAttemptTransaction,
1393
+ initialScope?: AttemptScope,
1268
1394
  ): Promise<void> {
1269
1395
  let firstTurn = true;
1396
+ let lastAttemptScope: AttemptScope | undefined;
1270
1397
  // Check for steering messages at start (user may have typed while waiting)
1271
1398
  let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
1272
1399
  let harmonyRetryAttempt = 0;
@@ -1301,15 +1428,20 @@ async function runLoopBody(
1301
1428
 
1302
1429
  // Inner loop: process tool calls and steering messages
1303
1430
  while (hasMoreToolCalls || pendingMessages.length > 0) {
1431
+ const scope =
1432
+ initialScope ?? (firstTurn ? config.initialScope : undefined) ?? config.attemptMinter?.mint("main");
1433
+ initialScope = undefined;
1304
1434
  const transaction =
1305
1435
  initialTransaction ??
1306
1436
  (config.fallbackManaged
1307
- ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model)
1437
+ ? new ManagedAttemptTransaction(stream, config.onAssistantMessageEvent, config.model, scope)
1308
1438
  : undefined);
1309
1439
  initialTransaction = undefined;
1440
+ const attemptScope = transaction?.scope ?? scope;
1441
+ lastAttemptScope = attemptScope;
1310
1442
  const attemptStream = transaction ?? stream;
1311
1443
  if (!firstTurn) {
1312
- attemptStream.push({ type: "turn_start" });
1444
+ attemptStream.push({ type: "turn_start", ...(attemptScope ? { scope: attemptScope } : {}) });
1313
1445
  } else {
1314
1446
  firstTurn = false;
1315
1447
  }
@@ -1318,8 +1450,8 @@ async function runLoopBody(
1318
1450
  // discarded managed attempt cannot lose it before its retry continuation.
1319
1451
  if (pendingMessages.length > 0) {
1320
1452
  for (const message of pendingMessages) {
1321
- stream.push({ type: "message_start", message });
1322
- stream.push({ type: "message_end", message });
1453
+ stream.push({ type: "message_start", message, scope: attemptScope });
1454
+ stream.push({ type: "message_end", message, scope: attemptScope });
1323
1455
  currentContext.messages.push(message);
1324
1456
  newMessages.push(message);
1325
1457
  }
@@ -1347,12 +1479,17 @@ async function runLoopBody(
1347
1479
  const outcome = loopSignal.aborted ? "aborted" : maintenanceOutcome;
1348
1480
 
1349
1481
  if (outcome !== "not-needed") {
1350
- stream.push({
1351
- type: "agent_end",
1352
- messages: newMessages,
1353
- stopReason: "maintenance",
1354
- maintenanceOutcome: outcome,
1355
- });
1482
+ publishAgentEnd(
1483
+ stream,
1484
+ config,
1485
+ {
1486
+ type: "agent_end",
1487
+ messages: newMessages,
1488
+ stopReason: "maintenance",
1489
+ maintenanceOutcome: outcome,
1490
+ },
1491
+ attemptScope,
1492
+ );
1356
1493
  stream.end(newMessages);
1357
1494
  return;
1358
1495
  }
@@ -1395,6 +1532,7 @@ async function runLoopBody(
1395
1532
  telemetry,
1396
1533
  invokeAgentSpan,
1397
1534
  stepCounter,
1535
+ attemptScope,
1398
1536
  streamFn,
1399
1537
  harmonyRetryAttempt,
1400
1538
  recoveryState.pending && recoveryState.syntheticMessage
@@ -1416,7 +1554,9 @@ async function runLoopBody(
1416
1554
  transaction.discard();
1417
1555
  currentContext.messages.splice(contextMessageCount);
1418
1556
  newMessages.splice(newMessageCount);
1419
- await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(failureMessage));
1557
+ await config.onManagedAttemptOutcome?.(
1558
+ managedContextOverflowOutcome(failureMessage, transaction.scope),
1559
+ );
1420
1560
  stream.end(newMessages);
1421
1561
  return;
1422
1562
  }
@@ -1424,7 +1564,7 @@ async function runLoopBody(
1424
1564
  transaction.discard();
1425
1565
  currentContext.messages.splice(contextMessageCount);
1426
1566
  newMessages.splice(newMessageCount);
1427
- await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage));
1567
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(failureMessage, transaction.scope));
1428
1568
  stream.end(newMessages);
1429
1569
  return;
1430
1570
  }
@@ -1516,7 +1656,7 @@ async function runLoopBody(
1516
1656
  transaction?.discard();
1517
1657
  currentContext.messages.splice(contextMessageCount);
1518
1658
  newMessages.splice(newMessageCount);
1519
- await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(message));
1659
+ await config.onManagedAttemptOutcome?.(managedContextOverflowOutcome(message, transaction?.scope));
1520
1660
  stream.end(newMessages);
1521
1661
  return;
1522
1662
  }
@@ -1537,7 +1677,7 @@ async function runLoopBody(
1537
1677
  transaction?.discard();
1538
1678
  currentContext.messages.splice(contextMessageCount);
1539
1679
  newMessages.splice(newMessageCount);
1540
- await config.onManagedAttemptOutcome?.(managedFailureOutcome(message));
1680
+ await config.onManagedAttemptOutcome?.(managedFailureOutcome(message, transaction?.scope));
1541
1681
  stream.end(newMessages);
1542
1682
  return;
1543
1683
  }
@@ -1546,7 +1686,11 @@ async function runLoopBody(
1546
1686
  transaction?.discard();
1547
1687
  currentContext.messages.splice(contextMessageCount);
1548
1688
  newMessages.splice(newMessageCount);
1549
- await config.onManagedAttemptOutcome?.({ type: "run_terminal", reason: "cancelled" });
1689
+ await config.onManagedAttemptOutcome?.({
1690
+ type: "run_terminal",
1691
+ reason: "cancelled",
1692
+ scope: transaction?.scope,
1693
+ });
1550
1694
  stream.end(newMessages);
1551
1695
  return;
1552
1696
  }
@@ -1588,8 +1732,13 @@ async function runLoopBody(
1588
1732
  status: message.stopReason === "aborted" ? "aborted" : "error",
1589
1733
  });
1590
1734
  }
1591
- stream.push({ type: "turn_end", message, toolResults });
1592
- publishAgentEnd(stream, config, buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1735
+ stream.push({ type: "turn_end", message, toolResults, scope: attemptScope });
1736
+ publishAgentEnd(
1737
+ stream,
1738
+ config,
1739
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope),
1740
+ attemptScope,
1741
+ );
1593
1742
  stream.end(newMessages);
1594
1743
  return;
1595
1744
  }
@@ -1627,6 +1776,7 @@ async function runLoopBody(
1627
1776
  config,
1628
1777
  telemetry,
1629
1778
  invokeAgentSpan,
1779
+ attemptScope,
1630
1780
  );
1631
1781
 
1632
1782
  toolResults.push(...executionResult.toolResults);
@@ -1660,7 +1810,7 @@ async function runLoopBody(
1660
1810
  recoveryState.syntheticMessage = undefined;
1661
1811
  }
1662
1812
 
1663
- stream.push({ type: "turn_end", message, toolResults });
1813
+ stream.push({ type: "turn_end", message, toolResults, scope: attemptScope });
1664
1814
 
1665
1815
  if (steeringMessagesFromExecution && steeringMessagesFromExecution.length > 0) {
1666
1816
  pendingMessages = steeringMessagesFromExecution;
@@ -1669,7 +1819,12 @@ async function runLoopBody(
1669
1819
  pendingMessages = (await config.getSteeringMessages?.()) || [];
1670
1820
  if (pendingMessages.length > 0) continue;
1671
1821
  if (config.shouldPause?.()) {
1672
- publishAgentEnd(stream, config, buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "paused"));
1822
+ publishAgentEnd(
1823
+ stream,
1824
+ config,
1825
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "paused", attemptScope),
1826
+ attemptScope,
1827
+ );
1673
1828
  stream.end(newMessages);
1674
1829
  return;
1675
1830
  }
@@ -1690,7 +1845,12 @@ async function runLoopBody(
1690
1845
  message.errorMessage = message.errorMessage
1691
1846
  ? `${message.errorMessage} | ${breakerMessage}`
1692
1847
  : breakerMessage;
1693
- publishAgentEnd(stream, config, buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1848
+ publishAgentEnd(
1849
+ stream,
1850
+ config,
1851
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", attemptScope),
1852
+ attemptScope,
1853
+ );
1694
1854
  stream.end(newMessages);
1695
1855
  return;
1696
1856
  }
@@ -1699,7 +1859,12 @@ async function runLoopBody(
1699
1859
  // Agent would stop here. Check for follow-up messages.
1700
1860
  await config.onBeforeYield?.();
1701
1861
  if (config.shouldPause?.()) {
1702
- publishAgentEnd(stream, config, buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "paused"));
1862
+ publishAgentEnd(
1863
+ stream,
1864
+ config,
1865
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "paused", lastAttemptScope),
1866
+ lastAttemptScope,
1867
+ );
1703
1868
  stream.end(newMessages);
1704
1869
  return;
1705
1870
  }
@@ -1714,7 +1879,12 @@ async function runLoopBody(
1714
1879
  break;
1715
1880
  }
1716
1881
 
1717
- publishAgentEnd(stream, config, buildAgentEndEvent(newMessages, telemetry, stepCounter.count));
1882
+ publishAgentEnd(
1883
+ stream,
1884
+ config,
1885
+ buildAgentEndEvent(newMessages, telemetry, stepCounter.count, "completed", lastAttemptScope),
1886
+ lastAttemptScope,
1887
+ );
1718
1888
  stream.end(newMessages);
1719
1889
  }
1720
1890
 
@@ -1747,6 +1917,7 @@ async function streamAssistantResponse(
1747
1917
  telemetry: AgentTelemetry | undefined,
1748
1918
  invokeAgentSpan: Span | undefined,
1749
1919
  stepCounter: StepCounter,
1920
+ scope?: AttemptScope,
1750
1921
  streamFn?: StreamFn,
1751
1922
  harmonyRetryAttempt = 0,
1752
1923
  recoveryMode?: { syntheticMessage: UserMessage },
@@ -1754,7 +1925,7 @@ async function streamAssistantResponse(
1754
1925
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
1755
1926
  let messages = context.messages;
1756
1927
  if (config.transformContext) {
1757
- messages = await config.transformContext(messages, signal);
1928
+ messages = await config.transformContext(messages, signal, scope);
1758
1929
  }
1759
1930
 
1760
1931
  // Convert to LLM-compatible messages (AgentMessage[] → Message[]) and normalize at the LLM boundary.
@@ -1810,11 +1981,17 @@ async function streamAssistantResponse(
1810
1981
  const dynamicReasoning = config.getReasoning?.();
1811
1982
  const harmonyMitigationEnabled = isHarmonyLeakMitigationTarget(config.model);
1812
1983
  const harmonyAbortController = harmonyMitigationEnabled ? new AbortController() : undefined;
1813
- const requestSignal = harmonyAbortController
1814
- ? signal
1815
- ? AbortSignal.any([signal, harmonyAbortController.signal])
1816
- : harmonyAbortController.signal
1817
- : signal;
1984
+ const requestSignals = [
1985
+ ...(signal ? [signal] : []),
1986
+ ...(config.resourceCancellationDomain ? [config.resourceCancellationDomain.signal] : []),
1987
+ ...(harmonyAbortController ? [harmonyAbortController.signal] : []),
1988
+ ];
1989
+ const requestSignal =
1990
+ requestSignals.length === 0
1991
+ ? undefined
1992
+ : requestSignals.length === 1
1993
+ ? requestSignals[0]
1994
+ : AbortSignal.any(requestSignals);
1818
1995
  const effectiveTemperature =
1819
1996
  harmonyRetryAttempt > 0 && config.temperature !== undefined ? config.temperature + 0.05 : config.temperature;
1820
1997
  const effectiveToolChoice = recoveryMode ? "none" : (dynamicToolChoice ?? config.toolChoice);
@@ -1845,9 +2022,9 @@ async function streamAssistantResponse(
1845
2022
  // stealing them from the configured hook.
1846
2023
  let capturedHeaders: Readonly<Record<string, string>> | undefined;
1847
2024
  const userOnResponse = config.onResponse;
1848
- const captureOnResponse: AgentLoopConfig["onResponse"] = (response, modelInfo) => {
2025
+ const captureOnResponse: AgentLoopConfig["onResponse"] = (response, modelInfo, scope) => {
1849
2026
  capturedHeaders = response.headers;
1850
- return userOnResponse?.(response, modelInfo);
2027
+ return userOnResponse?.(response, modelInfo, scope);
1851
2028
  };
1852
2029
 
1853
2030
  const finishChat = async (message: AssistantMessage): Promise<void> => {
@@ -1862,21 +2039,45 @@ async function streamAssistantResponse(
1862
2039
  try {
1863
2040
  return await runInActiveSpan(chatSpan, async () => {
1864
2041
  const fallbackAttempt = config.fallbackManaged ? config.nextFallbackAttempt?.(config.model) : undefined;
1865
- const responsePromise = Promise.resolve().then(() =>
1866
- streamFunction(config.model, llmContext, {
1867
- ...config,
1868
- fallbackAttempt,
1869
- apiKey: resolvedApiKey,
1870
- authCredentialType,
1871
- metadata: resolvedMetadata,
1872
- sessionId: config.providerSessionId ?? config.sessionId,
1873
- toolChoice: effectiveToolChoice,
1874
- reasoning: effectiveReasoning,
1875
- temperature: effectiveTemperature,
1876
- signal: requestSignal,
1877
- onResponse: captureOnResponse,
1878
- }),
1879
- );
2042
+ const providerReservation =
2043
+ config.resourceLedger && config.resourceRunId
2044
+ ? config.resourceLedger.reserveProducer(
2045
+ config.resourceRunId,
2046
+ config.resourceCancellationDomain,
2047
+ "provider_factory",
2048
+ `${config.model.provider}/${config.model.id}`,
2049
+ )
2050
+ : undefined;
2051
+ if (providerReservation && !providerReservation.ok)
2052
+ throw new Error("Prompt resource ownership is unavailable");
2053
+ if (requestSignal?.aborted) {
2054
+ providerReservation?.ok && providerReservation.lease.closeDiscovery();
2055
+ const aborted = emitAbortedAssistantMessage(null, false, context, config, stream, scope);
2056
+ await finishChat(aborted);
2057
+ return aborted;
2058
+ }
2059
+ let responsePromise: Promise<Awaited<ReturnType<StreamFn>>>;
2060
+ try {
2061
+ responsePromise = Promise.resolve(
2062
+ streamFunction(config.model, llmContext, {
2063
+ ...config,
2064
+ attemptScope: scope,
2065
+ fallbackAttempt,
2066
+ apiKey: resolvedApiKey,
2067
+ authCredentialType,
2068
+ metadata: resolvedMetadata,
2069
+ sessionId: config.providerSessionId ?? config.sessionId,
2070
+ toolChoice: effectiveToolChoice,
2071
+ reasoning: effectiveReasoning,
2072
+ temperature: effectiveTemperature,
2073
+ signal: requestSignal,
2074
+ onResponse: captureOnResponse,
2075
+ }),
2076
+ );
2077
+ } catch (error) {
2078
+ providerReservation?.ok && providerReservation.lease.closeDiscovery();
2079
+ throw error;
2080
+ }
1880
2081
  const { promise: iteratorSettled, resolve: settleIterator } = Promise.withResolvers<void>();
1881
2082
  let responseResultPromise: Promise<AssistantMessage> | undefined;
1882
2083
  let responseForResult: { result(): Promise<AssistantMessage> } | undefined;
@@ -1887,16 +2088,55 @@ async function streamAssistantResponse(
1887
2088
  await iteratorSettled;
1888
2089
  await Promise.allSettled([getResponseResult()]);
1889
2090
  });
1890
- if (config.resourceLedger && config.resourceRunId) {
1891
- // One ownership spans factory creation, iterator close, and trailing result.
1892
- config.resourceLedger.track(
1893
- config.resourceRunId,
1894
- "provider_factory",
1895
- `${config.model.provider}/${config.model.id}`,
1896
- providerLifecycle,
2091
+ const closeLateFactoryResponse = (): void => {
2092
+ void responsePromise.then(
2093
+ response => {
2094
+ responseForResult = response;
2095
+ try {
2096
+ const iterator = response[Symbol.asyncIterator]();
2097
+ try {
2098
+ const returned = iterator.return?.();
2099
+ void Promise.resolve(returned).then(
2100
+ () => settleIterator(),
2101
+ () => settleIterator(),
2102
+ );
2103
+ } catch {
2104
+ settleIterator();
2105
+ }
2106
+ } catch {
2107
+ settleIterator();
2108
+ }
2109
+ },
2110
+ () => settleIterator(),
2111
+ );
2112
+ };
2113
+ if (providerReservation?.ok) {
2114
+ providerReservation.lease.track("provider_iterator", "provider-lifecycle", providerLifecycle);
2115
+ void providerLifecycle.then(
2116
+ () => providerReservation.lease.closeDiscovery(),
2117
+ () => providerReservation.lease.closeDiscovery(),
1897
2118
  );
1898
2119
  }
1899
- const response = await responsePromise;
2120
+ let response: Awaited<typeof responsePromise>;
2121
+ if (requestSignal) {
2122
+ const { promise: factoryAbort, resolve: resolveFactoryAbort } = Promise.withResolvers<typeof ABORTED>();
2123
+ const onFactoryAbort = () => resolveFactoryAbort(ABORTED);
2124
+ requestSignal.addEventListener("abort", onFactoryAbort, { once: true });
2125
+ try {
2126
+ const responseOrAbort = await Promise.race([responsePromise, factoryAbort]);
2127
+ if (responseOrAbort === ABORTED) {
2128
+ const aborted = emitAbortedAssistantMessage(null, false, context, config, stream, scope);
2129
+ await finishChat(aborted);
2130
+ closeLateFactoryResponse();
2131
+ return aborted;
2132
+ }
2133
+ response = responseOrAbort;
2134
+ } finally {
2135
+ requestSignal.removeEventListener("abort", onFactoryAbort);
2136
+ }
2137
+ } else {
2138
+ response = await responsePromise;
2139
+ }
1900
2140
  responseForResult = response;
1901
2141
 
1902
2142
  let partialMessage: AssistantMessage | null = null;
@@ -1929,7 +2169,14 @@ async function streamAssistantResponse(
1929
2169
  if (requestSignal) {
1930
2170
  if (requestSignal.aborted) {
1931
2171
  closeIterator();
1932
- const aborted = emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
2172
+ const aborted = emitAbortedAssistantMessage(
2173
+ partialMessage,
2174
+ addedPartial,
2175
+ context,
2176
+ config,
2177
+ stream,
2178
+ scope,
2179
+ );
1933
2180
  await finishChat(aborted);
1934
2181
  return aborted;
1935
2182
  }
@@ -1947,7 +2194,14 @@ async function streamAssistantResponse(
1947
2194
  const result = await Promise.race([responseIterator.next(), abortRacePromise]);
1948
2195
  if (result === ABORTED) {
1949
2196
  closeIterator();
1950
- const aborted = emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
2197
+ const aborted = emitAbortedAssistantMessage(
2198
+ partialMessage,
2199
+ addedPartial,
2200
+ context,
2201
+ config,
2202
+ stream,
2203
+ scope,
2204
+ );
1951
2205
  await finishChat(aborted);
1952
2206
  return aborted;
1953
2207
  }
@@ -1956,7 +2210,14 @@ async function streamAssistantResponse(
1956
2210
  next = await responseIterator.next();
1957
2211
  }
1958
2212
  if (requestSignal?.aborted) {
1959
- const aborted = emitAbortedAssistantMessage(partialMessage, addedPartial, context, config, stream);
2213
+ const aborted = emitAbortedAssistantMessage(
2214
+ partialMessage,
2215
+ addedPartial,
2216
+ context,
2217
+ config,
2218
+ stream,
2219
+ scope,
2220
+ );
1960
2221
  await finishChat(aborted);
1961
2222
  return aborted;
1962
2223
  }
@@ -1975,7 +2236,7 @@ async function streamAssistantResponse(
1975
2236
  : event.partial;
1976
2237
  context.messages.push(partialMessage);
1977
2238
  addedPartial = true;
1978
- stream.push({ type: "message_start", message: { ...partialMessage } });
2239
+ stream.push({ type: "message_start", message: { ...partialMessage }, scope });
1979
2240
  break;
1980
2241
 
1981
2242
  case "toolChoiceIncapability":
@@ -2006,6 +2267,7 @@ async function streamAssistantResponse(
2006
2267
  type: "message_update",
2007
2268
  assistantMessageEvent: partialEvent,
2008
2269
  message: { ...partialMessage },
2270
+ scope,
2009
2271
  });
2010
2272
  }
2011
2273
  break;
@@ -2021,9 +2283,9 @@ async function streamAssistantResponse(
2021
2283
  context.messages.push(finalMessage);
2022
2284
  }
2023
2285
  if (!addedPartial) {
2024
- stream.push({ type: "message_start", message: { ...finalMessage } });
2286
+ stream.push({ type: "message_start", message: { ...finalMessage }, scope });
2025
2287
  }
2026
- stream.push({ type: "message_end", message: finalMessage });
2288
+ stream.push({ type: "message_end", message: finalMessage, scope });
2027
2289
  await finishChat(finalMessage);
2028
2290
  return finalMessage;
2029
2291
  }
@@ -2056,6 +2318,7 @@ function emitAbortedAssistantMessage(
2056
2318
  context: AgentContext,
2057
2319
  config: AgentLoopConfig,
2058
2320
  stream: EventStream<AgentEvent, AgentMessage[]>,
2321
+ scope?: AttemptScope,
2059
2322
  ): AssistantMessage {
2060
2323
  const errorMessage = "Request was aborted";
2061
2324
  const now = Date.now();
@@ -2080,9 +2343,9 @@ function emitAbortedAssistantMessage(
2080
2343
  if (addedPartial) {
2081
2344
  context.messages.pop();
2082
2345
  } else {
2083
- stream.push({ type: "message_start", message: { ...abortedMessage } });
2346
+ stream.push({ type: "message_start", message: { ...abortedMessage }, scope });
2084
2347
  }
2085
- stream.push({ type: "message_end", message: abortedMessage });
2348
+ stream.push({ type: "message_end", message: abortedMessage, scope });
2086
2349
  return abortedMessage;
2087
2350
  }
2088
2351
 
@@ -2108,6 +2371,7 @@ async function executeToolCalls(
2108
2371
  config: AgentLoopConfig,
2109
2372
  telemetry: AgentTelemetry | undefined,
2110
2373
  invokeAgentSpan: Span | undefined,
2374
+ scope?: AttemptScope,
2111
2375
  ): Promise<{
2112
2376
  toolResults: ToolResultMessage[];
2113
2377
  steeringMessages?: AgentMessage[];
@@ -2130,9 +2394,12 @@ async function executeToolCalls(
2130
2394
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
2131
2395
  const shouldInterruptImmediately = interruptMode !== "wait";
2132
2396
  const steeringAbortController = new AbortController();
2133
- const toolSignal = signal
2134
- ? AbortSignal.any([signal, steeringAbortController.signal])
2135
- : steeringAbortController.signal;
2397
+ const toolSignals = [
2398
+ ...(signal ? [signal] : []),
2399
+ ...(config.resourceCancellationDomain ? [config.resourceCancellationDomain.signal] : []),
2400
+ steeringAbortController.signal,
2401
+ ];
2402
+ const toolSignal = toolSignals.length === 1 ? toolSignals[0] : AbortSignal.any(toolSignals);
2136
2403
  const interruptState = { triggered: false };
2137
2404
  let steeringMessages: AgentMessage[] | undefined;
2138
2405
  let steeringCheck: Promise<void> | null = null;
@@ -2187,6 +2454,7 @@ async function executeToolCalls(
2187
2454
  toolName: toolCall.name,
2188
2455
  args: record.args,
2189
2456
  intent: toolCall.intent,
2457
+ scope,
2190
2458
  });
2191
2459
  }
2192
2460
  stream.push({
@@ -2195,6 +2463,7 @@ async function executeToolCalls(
2195
2463
  toolName: toolCall.name,
2196
2464
  result,
2197
2465
  isError,
2466
+ scope,
2198
2467
  });
2199
2468
 
2200
2469
  const toolResultMessage: ToolResultMessage = {
@@ -2212,8 +2481,8 @@ async function executeToolCalls(
2212
2481
  record.resultEmitted = true;
2213
2482
  emittedToolResults.push(toolResultMessage);
2214
2483
 
2215
- stream.push({ type: "message_start", message: toolResultMessage });
2216
- stream.push({ type: "message_end", message: toolResultMessage });
2484
+ stream.push({ type: "message_start", message: toolResultMessage, scope });
2485
+ stream.push({ type: "message_end", message: toolResultMessage, scope });
2217
2486
  };
2218
2487
 
2219
2488
  const runTool = async (record: (typeof records)[number], index: number): Promise<void> => {
@@ -2251,6 +2520,7 @@ async function executeToolCalls(
2251
2520
  toolName: toolCall.name,
2252
2521
  args: argsForExecution,
2253
2522
  intent: toolCall.intent,
2523
+ scope,
2254
2524
  });
2255
2525
 
2256
2526
  const toolSpan = startExecuteToolSpan(telemetry, {
@@ -2328,7 +2598,7 @@ async function executeToolCalls(
2328
2598
  // Reflect post-hook args so emitted tool results / afterToolCall see what actually executed.
2329
2599
  record.args = effectiveArgs;
2330
2600
 
2331
- const toolContext = getToolContext
2601
+ const baseToolContext = getToolContext
2332
2602
  ? getToolContext({
2333
2603
  batchId,
2334
2604
  index,
@@ -2336,6 +2606,9 @@ async function executeToolCalls(
2336
2606
  toolCalls: toolCallInfos,
2337
2607
  })
2338
2608
  : undefined;
2609
+ const toolContext = scope
2610
+ ? (Object.assign(baseToolContext ?? {}, { attemptScope: scope }) as AgentToolContext)
2611
+ : baseToolContext;
2339
2612
  const execution = tool.execute(
2340
2613
  toolCall.id,
2341
2614
  transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs,
@@ -2347,6 +2620,7 @@ async function executeToolCalls(
2347
2620
  toolName: toolCall.name,
2348
2621
  args: effectiveArgs,
2349
2622
  partialResult: coerceToolResult(partialResult).result,
2623
+ scope,
2350
2624
  });
2351
2625
  },
2352
2626
  toolContext,
@@ -2435,11 +2709,28 @@ async function executeToolCalls(
2435
2709
  const record = records[index];
2436
2710
  const concurrency = record.tool?.concurrency ?? "shared";
2437
2711
  const start = concurrency === "exclusive" ? Promise.all([lastExclusive, ...sharedTasks]) : lastExclusive;
2712
+ const reservation =
2713
+ config.resourceLedger && config.resourceRunId
2714
+ ? config.resourceLedger.reserveProducer(
2715
+ config.resourceRunId,
2716
+ config.resourceCancellationDomain,
2717
+ "tool",
2718
+ `${record.toolCall.name}:${record.toolCall.id}`,
2719
+ )
2720
+ : undefined;
2721
+ if (reservation && !reservation.ok) {
2722
+ record.skipped = true;
2723
+ recordSkippedTool(telemetry, {
2724
+ toolCallId: record.toolCall.id,
2725
+ toolName: record.toolCall.name,
2726
+ status: "skipped",
2727
+ });
2728
+ emitToolResult(record, createSkippedToolResult(), true);
2729
+ continue;
2730
+ }
2438
2731
  const task = start
2439
2732
  .then(() => runTool(record, index))
2440
2733
  .finally(() => {
2441
- // Scheduler ownership includes dependency waits and the fallback skip
2442
- // emission, not only tool.execute().
2443
2734
  if (!record.toolResultMessage) {
2444
2735
  record.skipped = true;
2445
2736
  recordSkippedTool(telemetry, {
@@ -2450,15 +2741,14 @@ async function executeToolCalls(
2450
2741
  emitToolResult(record, createSkippedToolResult(), true);
2451
2742
  }
2452
2743
  });
2453
- tasks.push(task);
2454
- if (config.resourceLedger && config.resourceRunId) {
2455
- config.resourceLedger.track(
2456
- config.resourceRunId,
2457
- "tool",
2458
- `${record.toolCall.name}:${record.toolCall.id}`,
2459
- task,
2744
+ if (reservation?.ok) {
2745
+ reservation.lease.track("tool", `${record.toolCall.name}:${record.toolCall.id}`, task);
2746
+ void task.then(
2747
+ () => reservation.lease.closeDiscovery(),
2748
+ () => reservation.lease.closeDiscovery(),
2460
2749
  );
2461
2750
  }
2751
+ tasks.push(task);
2462
2752
  if (concurrency === "exclusive") {
2463
2753
  lastExclusive = task;
2464
2754
  sharedTasks = [];