@gajae-code/agent-core 0.13.2 → 0.14.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
@@ -301,6 +301,8 @@ export interface AgentOptions {
301
301
  * message are emitted. See {@link AgentLoopConfig.afterToolCall} for full semantics.
302
302
  */
303
303
  afterToolCall?: AgentLoopConfig["afterToolCall"];
304
+ /** Invoked with the follow-up messages dequeued for the next turn (reassignable). */
305
+ onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"];
304
306
 
305
307
  /**
306
308
  * Opt-in OpenTelemetry instrumentation. Passing `{}` enables the loop's
@@ -322,8 +324,7 @@ export interface AgentPromptOptions {
322
324
  /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
323
325
  maintenanceContinuation?: boolean;
324
326
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
325
- /** Receives the immutable run handle as the first callback argument. */
326
- onRunAccepted?: (...args: any[]) => void;
327
+ onRunAccepted?: (handle: AttemptRunHandle, acceptance: { consumedQueuedMessages: readonly AgentMessage[] }) => void;
327
328
  /** Called once immediately before every managed upstream request. */
328
329
  nextFallbackAttempt?: AgentLoopConfig["nextFallbackAttempt"];
329
330
  /** Called after a managed upstream request is accepted and committed. */
@@ -410,6 +411,7 @@ export class Agent {
410
411
  #onResponse?: SimpleStreamOptions["onResponse"];
411
412
  #onSseEvent?: SimpleStreamOptions["onSseEvent"];
412
413
  #onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
414
+ #onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
413
415
  #onToolChoiceIncapability?: AgentLoopConfig["onToolChoiceIncapability"];
414
416
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
415
417
  #onBeforeYield?: () => Promise<void> | void;
@@ -467,6 +469,8 @@ export class Agent {
467
469
  * message emission. Reassign at any time to swap the implementation.
468
470
  */
469
471
  afterToolCall?: AgentLoopConfig["afterToolCall"];
472
+ /** Invoked with the follow-up messages dequeued for the next turn. Reassign at any time. */
473
+ onFollowUpConsumed?: AgentLoopConfig["onFollowUpConsumed"];
470
474
 
471
475
  constructor(opts: AgentOptions = {}) {
472
476
  this.#state = { ...this.#state, ...opts.initialState };
@@ -510,6 +514,7 @@ export class Agent {
510
514
  this.#onHarmonyLeak = opts.onHarmonyLeak;
511
515
  this.#shouldPause = opts.shouldPause;
512
516
  this.beforeToolCall = opts.beforeToolCall;
517
+ this.onFollowUpConsumed = opts.onFollowUpConsumed;
513
518
  this.afterToolCall = opts.afterToolCall;
514
519
  this.#telemetry = opts.telemetry;
515
520
  this.#appendOnlyContext = opts.appendOnlyContext;
@@ -780,6 +785,12 @@ export class Agent {
780
785
  this.#onAssistantMessageEvent = fn;
781
786
  }
782
787
 
788
+ setProvisionalAssistantMessageEventInterceptor(
789
+ fn: ((message: AssistantMessage, event: AssistantMessageEvent) => void) | undefined,
790
+ ): void {
791
+ this.#onProvisionalAssistantMessageEvent = fn;
792
+ }
793
+
783
794
  setOnBeforeYield(fn: (() => Promise<void> | void) | undefined): void {
784
795
  this.#onBeforeYield = fn;
785
796
  }
@@ -792,6 +803,18 @@ export class Agent {
792
803
  this.#maintainContext = fn;
793
804
  }
794
805
 
806
+ /**
807
+ * Publish an event produced OUTSIDE the agent loop (a provider that executed the tool
808
+ * itself, a host bridge, a replay).
809
+ *
810
+ * Identity is the PRODUCER's to prove: whoever dispatched the call binds the tool object
811
+ * it actually ran (see `bindDispatchedToolIdentity`) before handing the event here, and
812
+ * that binding is never touched from this side. Re-resolving `event.toolName` against the
813
+ * mutable current tool list would let a mid-run `setTools`, MCP reload, or plain name
814
+ * collision overwrite a proven object with one that never ran — and would invent an
815
+ * identity for replays and host bridges that never executed an AgentTool at all. An
816
+ * unbound external event stays unbound; unproven provenance is `custom`.
817
+ */
795
818
  emitExternalEvent(event: AgentEvent) {
796
819
  switch (event.type) {
797
820
  case "message_start":
@@ -1191,16 +1214,39 @@ export class Agent {
1191
1214
  return true;
1192
1215
  }
1193
1216
 
1194
- /** Remove queued steering+follow-up messages matching `predicate`, preserving order of the rest. */
1195
- removeQueuedMessages(predicate: (message: AgentMessage) => boolean): {
1217
+ /**
1218
+ * Remove ALL queued STEERING messages without touching the follow-up queue.
1219
+ * Used by the terminal-abort path to purge steering queued for the aborted
1220
+ * turn (the loop may exit on the abort signal without polling it); the
1221
+ * follow-up queue is preserved because it may carry owned-completion
1222
+ * resumes that must still deliver.
1223
+ */
1224
+ clearSteeringMessages(): void {
1225
+ this.#steeringQueue = [];
1226
+ }
1227
+
1228
+ /**
1229
+ * Remove queued steering/follow-up messages matching `predicate`, preserving
1230
+ * order of the rest. `scope` restricts the removal to one queue — the
1231
+ * terminal-abort steering purge must not wipe the follow-up queue, which
1232
+ * the owned-completion resume policy preserves.
1233
+ */
1234
+ removeQueuedMessages(
1235
+ predicate: (message: AgentMessage) => boolean,
1236
+ scope: "both" | "steering" | "followUp" = "both",
1237
+ ): {
1196
1238
  steering: number;
1197
1239
  followUp: number;
1198
1240
  total: number;
1199
1241
  } {
1200
1242
  const beforeSteering = this.#steeringQueue.length;
1201
1243
  const beforeFollowUp = this.#followUpQueue.length;
1202
- this.#steeringQueue = this.#steeringQueue.filter(m => !predicate(m));
1203
- this.#followUpQueue = this.#followUpQueue.filter(m => !predicate(m));
1244
+ if (scope !== "followUp") {
1245
+ this.#steeringQueue = this.#steeringQueue.filter(m => !predicate(m));
1246
+ }
1247
+ if (scope !== "steering") {
1248
+ this.#followUpQueue = this.#followUpQueue.filter(m => !predicate(m));
1249
+ }
1204
1250
  const steering = beforeSteering - this.#steeringQueue.length;
1205
1251
  const followUp = beforeFollowUp - this.#followUpQueue.length;
1206
1252
  return { steering, followUp, total: steering + followUp };
@@ -1409,13 +1455,28 @@ export class Agent {
1409
1455
  if (messages[messages.length - 1].role === "assistant") {
1410
1456
  const queuedSteering = this.#dequeueSteeringMessages();
1411
1457
  if (queuedSteering.length > 0) {
1412
- await this.#runLoop(queuedSteering, { ...options, skipInitialSteeringPoll: true });
1458
+ await this.#runLoop(queuedSteering, {
1459
+ ...options,
1460
+ skipInitialSteeringPoll: true,
1461
+ consumedQueuedMessages: queuedSteering,
1462
+ });
1413
1463
  return;
1414
1464
  }
1415
1465
 
1416
1466
  const queuedFollowUp = this.#dequeueFollowUpMessages();
1417
1467
  if (queuedFollowUp.length > 0) {
1418
- await this.#runLoop(queuedFollowUp, options);
1468
+ // Route the DIRECT-dequeue batch through the same consumption hook
1469
+ // the in-loop getFollowUpMessages path uses: denied owned-completion
1470
+ // envelopes are filtered before they reach the loop, and delivered
1471
+ // envelopes settle their registrations — the direct path otherwise
1472
+ // bypasses onFollowUpConsumed entirely (review threads P1/P2).
1473
+ await this.onFollowUpConsumed?.(queuedFollowUp);
1474
+ // The hook can filter the WHOLE batch (every entry denied by a
1475
+ // scope:"owned" abort): starting an empty provider run would
1476
+ // violate the zero-final-call guarantee, so return without
1477
+ // running the loop (review thread P1).
1478
+ if (queuedFollowUp.length === 0) return;
1479
+ await this.#runLoop(queuedFollowUp, { ...options, consumedQueuedMessages: queuedFollowUp });
1419
1480
  return;
1420
1481
  }
1421
1482
 
@@ -1438,12 +1499,31 @@ export class Agent {
1438
1499
  }
1439
1500
  const queuedSteering = this.#dequeueSteeringMessages();
1440
1501
  if (queuedSteering.length > 0) {
1441
- await this.#runLoop(queuedSteering, { ...options, skipInitialSteeringPoll: true });
1502
+ await this.#runLoop(queuedSteering, {
1503
+ ...options,
1504
+ skipInitialSteeringPoll: true,
1505
+ consumedQueuedMessages: queuedSteering,
1506
+ });
1442
1507
  return;
1443
1508
  }
1444
1509
  const queuedFollowUp = this.#dequeueFollowUpMessages();
1445
1510
  if (queuedFollowUp.length > 0) {
1446
- await this.#runLoop(queuedFollowUp, options);
1511
+ // Route the queued-tail batch through the same consumption hook as the
1512
+ // in-loop getFollowUpMessages path and the assistant-tail continue()
1513
+ // path: denied owned-completion envelopes are filtered before they
1514
+ // reach the loop, and delivered envelopes settle their registrations.
1515
+ // A terminal abort that leaves a tool/result tail and rearms an
1516
+ // authorized owned-completion follow-up reaches this branch, so
1517
+ // bypassing the hook would leak every such job's ownership tuple and
1518
+ // eventually exhaust the bounded ownership registries (review thread
1519
+ // P2).
1520
+ await this.onFollowUpConsumed?.(queuedFollowUp);
1521
+ // The hook can filter the WHOLE batch (every entry denied by a
1522
+ // scope:"owned" abort): starting an empty provider run would violate
1523
+ // the zero-final-call guarantee, so return without running the loop
1524
+ // (review thread P2).
1525
+ if (queuedFollowUp.length === 0) return;
1526
+ await this.#runLoop(queuedFollowUp, { ...options, consumedQueuedMessages: queuedFollowUp });
1447
1527
  return;
1448
1528
  }
1449
1529
  throw new Error("No queued messages to continue");
@@ -1454,7 +1534,13 @@ export class Agent {
1454
1534
  * If messages are provided, starts a new conversation turn with those messages.
1455
1535
  * Otherwise, continues from existing context.
1456
1536
  */
1457
- async #runLoop(messages?: AgentMessage[], options?: AgentPromptOptions & { skipInitialSteeringPoll?: boolean }) {
1537
+ async #runLoop(
1538
+ messages?: AgentMessage[],
1539
+ options?: AgentPromptOptions & {
1540
+ skipInitialSteeringPoll?: boolean;
1541
+ consumedQueuedMessages?: readonly AgentMessage[];
1542
+ },
1543
+ ) {
1458
1544
  const model = this.#state.model;
1459
1545
  if (!model) throw new Error("No model configured");
1460
1546
 
@@ -1503,7 +1589,9 @@ export class Agent {
1503
1589
  this.#observeMainAttemptScope(scope);
1504
1590
  const handle: AttemptRunHandle = { logicalRunId, scope };
1505
1591
  this.#runHandles.set(logicalRunId, handle);
1506
- options?.onRunAccepted?.(handle);
1592
+ options?.onRunAccepted?.(handle, {
1593
+ consumedQueuedMessages: options.consumedQueuedMessages ?? [],
1594
+ });
1507
1595
  if (startsManagedLogicalRun) {
1508
1596
  this.#managedLogicalRunOwner = logicalRunId;
1509
1597
  this.#emit({ type: "agent_start", scope });
@@ -1671,12 +1759,16 @@ export class Agent {
1671
1759
  return result;
1672
1760
  }
1673
1761
  : undefined,
1674
- onAssistantMessageEvent: this.#onAssistantMessageEvent
1675
- ? (message, event) => {
1676
- if (this.#activeRunId !== runId) return;
1677
- this.#onAssistantMessageEvent?.(message, event);
1678
- }
1679
- : undefined,
1762
+ onAssistantMessageEvent: (message, event) => {
1763
+ if (this.#activeRunId !== runId) return;
1764
+ this.#onAssistantMessageEvent?.(message, event);
1765
+ },
1766
+ onProvisionalAssistantMessageEvent: (message, event) => {
1767
+ if (this.#activeRunId !== runId) return;
1768
+ this.#state.streamMessage = message;
1769
+ this.#onProvisionalAssistantMessageEvent?.(message, event);
1770
+ },
1771
+ hasProvisionalAssistantMessageEventConsumer: this.#onProvisionalAssistantMessageEvent !== undefined,
1680
1772
  onToolChoiceIncapability: this.#onToolChoiceIncapability
1681
1773
  ? event => {
1682
1774
  if (this.#activeRunId !== runId) return;
@@ -1710,6 +1802,9 @@ export class Agent {
1710
1802
  this.#followUpQueue = [...queued, ...this.#followUpQueue];
1711
1803
  return [];
1712
1804
  }
1805
+ if (queued.length > 0) {
1806
+ await this.onFollowUpConsumed?.(queued);
1807
+ }
1713
1808
  return queued;
1714
1809
  },
1715
1810
  getSyntheticRecoveryMessage: async () => {
@@ -1881,6 +1976,9 @@ export class Agent {
1881
1976
  stopReason: abortController.signal.aborted ? "aborted" : "error",
1882
1977
  errorMessage: err?.message || String(err),
1883
1978
  errorStatus: extractHttpStatusFromError({ status: err?.errorStatus }) ?? extractHttpStatusFromError(err),
1979
+ ...(err?.errorKind === "local_snapshot_failure" || err?.errorKind === "local_buffer_overflow"
1980
+ ? { errorKind: err.errorKind as "local_snapshot_failure" | "local_buffer_overflow" }
1981
+ : {}),
1884
1982
  timestamp: Date.now(),
1885
1983
  } as AgentMessage;
1886
1984
 
@@ -253,6 +253,7 @@ export type CompactionTriggerReason =
253
253
  | "token"
254
254
  | "heap"
255
255
  | "retainedMemory"
256
+ | "transcriptFile"
256
257
  | "providerBytes"
257
258
  | "messageCount"
258
259
  | "imageBytes";
@@ -275,6 +276,8 @@ export interface EmergencyCompactionSample {
275
276
  tuiChatChildren?: number;
276
277
  /** Bytes retained by TUI render caches. */
277
278
  tuiCachedRenderBytes?: number;
279
+ /** On-disk JSONL transcript file size in bytes; 0/undefined when unknown. */
280
+ transcriptFileBytes?: number;
278
281
  }
279
282
 
280
283
  export interface EmergencyCompactionLimits {
@@ -286,6 +289,7 @@ export interface EmergencyCompactionLimits {
286
289
  retainedMemoryDiagnosticBytes?: number;
287
290
  tuiChatChildren?: number;
288
291
  tuiChatChildrenDiagnostic?: number;
292
+ transcriptFileBytes?: number;
289
293
  }
290
294
 
291
295
  const MAX_EMERGENCY_HEAP_FLOOR_BYTES = 1_536 * 1024 * 1024; // 1.5 GiB resident heap
@@ -293,6 +297,7 @@ const EMERGENCY_RETAINED_MEMORY_BYTES = 128 * 1024 * 1024;
293
297
  const DIAGNOSTIC_RETAINED_MEMORY_BYTES = 64 * 1024 * 1024;
294
298
  const EMERGENCY_TUI_CHAT_CHILDREN = 1000;
295
299
  const DIAGNOSTIC_TUI_CHAT_CHILDREN = 700;
300
+ const EMERGENCY_TRANSCRIPT_FILE_BYTES = 48 * 1024 * 1024; // 48 MiB (75% of the 64 MiB managed cap)
296
301
  let retainedMemoryDiagnosticActive = false;
297
302
  let tuiChatChildrenDiagnosticActive = false;
298
303
 
@@ -315,6 +320,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
315
320
  retainedMemoryDiagnosticBytes: DIAGNOSTIC_RETAINED_MEMORY_BYTES,
316
321
  tuiChatChildren: EMERGENCY_TUI_CHAT_CHILDREN,
317
322
  tuiChatChildrenDiagnostic: DIAGNOSTIC_TUI_CHAT_CHILDREN,
323
+ transcriptFileBytes: EMERGENCY_TRANSCRIPT_FILE_BYTES,
318
324
  };
319
325
  }
320
326
 
@@ -326,7 +332,7 @@ export function resolveEmergencyCompactionLimits(totalMemoryBytes: number = os.t
326
332
  export const DEFAULT_EMERGENCY_COMPACTION_LIMITS: EmergencyCompactionLimits = resolveEmergencyCompactionLimits();
327
333
 
328
334
  /**
329
- * Returns the first emergency limit exceeded (heap > retainedMemory > providerBytes > imageBytes > messageCount),
335
+ * Returns the first emergency limit exceeded (heap > retainedMemory > transcriptFile > providerBytes > imageBytes > messageCount),
330
336
  * or null when none is. Pure apart from retained-memory diagnostics; the caller routes the result through the
331
337
  * normal pair-safe `compact()` cut logic so a tool_use/tool_result pair is never split.
332
338
  */
@@ -360,6 +366,11 @@ export function emergencyCompactionReason(
360
366
  tuiChatChildren >= (limits.tuiChatChildren ?? EMERGENCY_TUI_CHAT_CHILDREN)
361
367
  )
362
368
  return "retainedMemory";
369
+ if (
370
+ sample.transcriptFileBytes &&
371
+ sample.transcriptFileBytes > (limits.transcriptFileBytes ?? EMERGENCY_TRANSCRIPT_FILE_BYTES)
372
+ )
373
+ return "transcriptFile";
363
374
  if (sample.providerBytes > limits.providerBytes) return "providerBytes";
364
375
  if (sample.imageBytes > limits.imageBytes) return "imageBytes";
365
376
  if (sample.messageCount > limits.messageCount) return "messageCount";
package/src/index.ts CHANGED
@@ -17,5 +17,7 @@ export * from "./run-resource-ledger";
17
17
  export * from "./telemetry";
18
18
  // Thinking selectors
19
19
  export * from "./thinking";
20
+ // Dispatch-bound tool identity (non-serializable side channel keyed by event object)
21
+ export * from "./tool-dispatch-identity";
20
22
  // Types
21
23
  export * from "./types";
package/src/proxy.ts CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  type ToolCall,
14
14
  } from "@gajae-code/ai";
15
15
  import { calculateCost } from "@gajae-code/ai/models";
16
- import { parseStreamingJson } from "@gajae-code/ai/utils/json-parse";
16
+ import { findUnnecessaryUnicodeEscape, parseStreamingJson } from "@gajae-code/ai/utils/json-parse";
17
17
  import { readSseJson } from "@gajae-code/utils";
18
18
 
19
19
  // Create stream class matching ProxyMessageEventStream
@@ -379,6 +379,8 @@ function processProxyEvent(
379
379
  case "toolcall_end": {
380
380
  const content = partial.content[proxyEvent.contentIndex];
381
381
  if (content?.type === "toolCall") {
382
+ const raw = (content as { partialJson?: string }).partialJson;
383
+ if (raw && findUnnecessaryUnicodeEscape(raw)) content.escapedNonAsciiArguments = true;
382
384
  delete (content as any).partialJson;
383
385
  return {
384
386
  type: "toolcall_end",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Identity of the tool object a `tool_execution_start` event was produced for.
3
+ *
4
+ * The producer already knows exactly which tool object it dispatched to: the agent loop
5
+ * resolves it once, from the run's immutable tool snapshot, before it emits anything. Any
6
+ * consumer that instead re-resolves `event.toolName` later reads a MUTABLE registry — a
7
+ * mid-run `setTools`, MCP reload, or tool refresh can hand the same wire name to a
8
+ * completely different object — and would then attribute the call to a tool that never
9
+ * ran.
10
+ *
11
+ * The binding is deliberately a side channel rather than an event field:
12
+ *
13
+ * - a tool object exposes `execute`, closures over session state, and its own
14
+ * description/parameters; putting it on `AgentEvent` would make it enumerable, walkable,
15
+ * and reachable by every serializer, wire envelope, and log sink that copies events, and
16
+ * `JSON.stringify(event)` would start emitting tool metadata;
17
+ * - the map is weak on the event object, so the association disappears with the event it
18
+ * describes and pins nothing alive;
19
+ * - it carries no arguments, no results, no call ids, and no timing — only object identity,
20
+ * which is exactly what provenance checks (`WeakSet.has`) need and nothing more.
21
+ */
22
+ const dispatchedToolByEvent = new WeakMap<object, object>();
23
+
24
+ /**
25
+ * Record, at the producer boundary, the tool object this event was emitted for.
26
+ *
27
+ * A call with no resolved tool (an unknown name the loop is about to reject, or a call
28
+ * aborted before dispatch) binds nothing: there is no object, so there is nothing to prove.
29
+ */
30
+ export function bindDispatchedToolIdentity(event: object, tool: object | undefined): void {
31
+ if (!tool) return;
32
+ dispatchedToolByEvent.set(event, tool);
33
+ }
34
+
35
+ /** The tool object this event was actually dispatched to, if its producer bound one. */
36
+ export function dispatchedToolIdentity(event: object): object | undefined {
37
+ return dispatchedToolByEvent.get(event);
38
+ }
39
+
40
+ /**
41
+ * Tool events emitted only to keep the stream's start/end PAIRING intact, for calls that
42
+ * were never dispatched.
43
+ *
44
+ * A skipped or aborted call still has to produce a result, and every consumer downstream
45
+ * is built around results arriving in pairs, so the loop synthesizes the missing start.
46
+ * That is a stream-shape obligation and nothing more: no `execute` ran, no work began, and
47
+ * no time was spent in a tool.
48
+ *
49
+ * Consumers that merely relay or record events are unaffected and must stay unaffected.
50
+ * Consumers that publish a claim about what is RUNNING are not: between the synthetic
51
+ * start and its end, such a consumer would report a tool as active that was never entered.
52
+ *
53
+ * A side channel rather than an `AgentEvent` field, for the same reasons the dispatched
54
+ * identity is one — no wire/schema surface to serialize, copy, or persist — and a `WeakSet`
55
+ * because the mark is meaningful only for the exact event object it was applied to.
56
+ */
57
+ const nonDispatchedToolEvents = new WeakSet<object>();
58
+
59
+ /** Mark an event as pairing-only: emitted for a call the loop never dispatched. */
60
+ export function markNonDispatchedToolEvent(event: object): void {
61
+ nonDispatchedToolEvents.add(event);
62
+ }
63
+
64
+ /** Whether this exact event was synthesized for a call that never ran. */
65
+ export function isNonDispatchedToolEvent(event: object): boolean {
66
+ return nonDispatchedToolEvents.has(event);
67
+ }
68
+
69
+ /**
70
+ * Active tool a call name dispatches to. Tools emitted via OpenAI's custom-tool path
71
+ * (e.g. `apply_patch` on GPT-5) come back under their wire-level name, which may differ
72
+ * from the harness-internal `name`. Match on either, preferring `name` for determinism if
73
+ * both somehow collide.
74
+ *
75
+ * This is the single dispatch-matching rule: execution, external-event identity binding,
76
+ * and every "is this tool callable" check must agree, or a label can describe a tool the
77
+ * call would never have reached.
78
+ */
79
+ export function activeToolForCallName<T extends { name: string; customWireName?: string }>(
80
+ tools: ReadonlyArray<T> | undefined,
81
+ callName: string,
82
+ ): T | undefined {
83
+ return (
84
+ tools?.find(tool => tool.name === callName) ??
85
+ tools?.find(tool => tool.customWireName !== undefined && tool.customWireName === callName)
86
+ );
87
+ }
package/src/types.ts CHANGED
@@ -182,6 +182,12 @@ export type ManagedAttemptOutcome =
182
182
  };
183
183
  scope?: AttemptScope;
184
184
  }
185
+ | {
186
+ type: "escaped_arguments_discarded";
187
+ /** The defective assistant turn; already removed from usable history by the loop. */
188
+ message: AssistantMessage;
189
+ scope?: AttemptScope;
190
+ }
185
191
  | { type: "context_overflow_discarded"; message: AssistantMessage; scope?: AttemptScope }
186
192
  | { type: "run_terminal"; reason: "cancelled" | "error" | "exhausted"; scope?: AttemptScope };
187
193
 
@@ -325,6 +331,13 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
325
331
  * continues with another turn.
326
332
  */
327
333
  getFollowUpMessages?: () => Promise<AgentMessage[]>;
334
+ /**
335
+ * Invoked with the follow-up messages the loop dequeues for the next turn
336
+ * (right after {@link getFollowUpMessages}). The consumer may use this to
337
+ * attach per-turn state (e.g. a fresh owned-completion lineage) at actual
338
+ * resume admission rather than when the message was merely queued.
339
+ */
340
+ onFollowUpConsumed?: (messages: AgentMessage[]) => void;
328
341
  /**
329
342
  * Supplies one bounded synthetic recovery instruction before the loop would
330
343
  * otherwise yield. Unlike a follow-up, it is sent only to the provider and
@@ -417,6 +430,10 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
417
430
  * Callers may abort synchronously to stop consuming buffered provider events.
418
431
  */
419
432
  onAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
433
+ /** Observe unmanaged provisional assistant deltas before public publication. */
434
+ onProvisionalAssistantMessageEvent?: (message: AssistantMessage, event: AssistantMessageEvent) => void;
435
+ /** True when the host consumes provisional assistant events for live safety checks. */
436
+ hasProvisionalAssistantMessageEventConsumer?: boolean;
420
437
 
421
438
  /** Called for non-content tool-choice incapability stream events. */
422
439
  onToolChoiceIncapability?: (event: Extract<AssistantMessageEvent, { type: "toolChoiceIncapability" }>) => void;