@gajae-code/agent-core 0.16.0 → 0.16.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.3] - 2026-09-04
6
+
7
+ ## [0.16.2] - 2026-09-04
8
+
9
+ ## [0.16.1] - 2026-09-03
10
+
11
+ ### Changed
12
+
13
+ - `Agent.steer()` now performs enqueue-time admission and returns a `SteerAdmission` result. A steer is pushed onto the steering queue only while a run is live and its signal is not aborted; otherwise it returns `{ admitted: false, reason: "idle" | "aborting" }` and queues nothing. A steer submitted after a turn ended, or during an abort, can no longer sit orphaned in the queue and get consumed by whichever unrelated later prompt polls it first. Callers that need delivery when no run is live route the message themselves (the coding-agent session queues it as a sequential follow-up owned by the next turn).
14
+ - `AgentOptions.interruptMode` (`"immediate" | "wait"`) is renamed to `toolInterruptPolicy` (`"abort_tools" | "finish_tools"`), with `setToolInterruptPolicy()` / `getToolInterruptPolicy()` replacing the old accessors and `AgentLoopConfig.toolInterruptPolicy` replacing `interruptMode`. The behaviour is unchanged: it only decides whether a steer aborts the tools still running in the current batch; a steer is always consumed at the next tool/turn boundary regardless. `Agent.steer()` accepts `{ forceOneAtATime: true }` so a message is delivered on its own even under `steeringMode: "all"` (the follow-up queue already had this override). `waitForSteeringArrival()` now resolves only on a steer admitted after the wait started (or abort), so a message already queued cannot interrupt a later observation window.
15
+ - Every run exit now disowns steering it admitted but never consumed: `Agent` clears its steering queue when the run's `agent_end` is finalized (completed, aborted, cancelled, error, or `forceAbort`) and reports the messages on the event's new `disownedSteering` field, so the owner decides once what happens to them and no later, unrelated run can consume them. The admission fence (a fold winding the turn down) only stops the run from POLLING the queue; it no longer keeps the queue past the terminal, so ownership always transfers to the owner. Added `Agent.markFollowUpSequential()` for callers that restore a message ahead of the follow-up queue but still want prompt-by-prompt delivery under `followUpMode: "all"`.
16
+
5
17
  ## [0.16.0] - 2026-09-02
6
18
 
7
19
  ## [0.15.6] - 2026-08-30
package/README.md CHANGED
@@ -250,14 +250,19 @@ Queue messages to inject during tool execution (steering) or after the agent wou
250
250
 
251
251
  ```typescript
252
252
  agent.setSteeringMode("one-at-a-time");
253
- agent.setInterruptMode("immediate");
253
+ agent.setToolInterruptPolicy("abort_tools");
254
254
 
255
- // While agent is running tools
256
- agent.steer({
255
+ // A steer is admitted ONLY into a live, non-aborted run.
256
+ const admission = agent.steer({
257
257
  role: "user",
258
258
  content: "Stop! Do this instead.",
259
259
  timestamp: Date.now(),
260
260
  });
261
+ if (!admission.admitted) {
262
+ // admission.reason is "idle" (no run to steer) or "aborting" (the run is
263
+ // winding down). Nothing was queued: the caller owns routing the message,
264
+ // e.g. as a fresh prompt or a follow-up of the next turn.
265
+ }
261
266
 
262
267
  // Queue a follow-up to run after the current turn completes
263
268
  agent.followUp({
@@ -267,8 +272,13 @@ agent.followUp({
267
272
  });
268
273
  ```
269
274
 
270
- Steering messages are checked after each tool call by default. Set `interruptMode` to `"wait"` to defer
271
- steering until the current turn completes.
275
+ Steering messages are checked after each tool call by default. Set `toolInterruptPolicy` to
276
+ `"finish_tools"` to let the running tool batch finish before the steering turn opens; the steer is
277
+ still consumed at the next tool/turn boundary either way.
278
+
279
+ A run that ends (completed, aborted, or error) never keeps steering it did not consume: the Agent
280
+ clears its queue and reports the messages on `agent_end.disownedSteering`, so the owner decides once
281
+ whether to re-route or drop them.
272
282
 
273
283
  ## Custom Message Types
274
284
 
@@ -39,11 +39,15 @@ export interface AgentOptions {
39
39
  */
40
40
  followUpMode?: "all" | "one-at-a-time";
41
41
  /**
42
- * When to interrupt tool execution for steering messages.
43
- * - "immediate": check after each tool call (default)
44
- * - "wait": defer steering until the current turn completes
42
+ * Whether a steering message aborts the tool calls still running in the
43
+ * current batch.
44
+ * - "abort_tools": abort the remaining tools and open the steering turn (default)
45
+ * - "finish_tools": let the batch finish, then open the steering turn
46
+ *
47
+ * This never changes WHEN a steer is consumed: the loop picks it up at the
48
+ * next tool/turn boundary either way.
45
49
  */
46
- interruptMode?: "immediate" | "wait";
50
+ toolInterruptPolicy?: "abort_tools" | "finish_tools";
47
51
  /** Cooperative pause checkpoint passed through to AgentLoopConfig.shouldPause. */
48
52
  shouldPause?: AgentLoopConfig["shouldPause"];
49
53
  /**
@@ -186,6 +190,13 @@ export interface AgentPromptOptions {
186
190
  fallbackManaged?: boolean;
187
191
  /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
188
192
  maintenanceContinuation?: boolean;
193
+ /**
194
+ * Skip the loop's INITIAL steering poll for this run. Used when the caller
195
+ * seeds the steering queue at run acceptance but the run's first model call
196
+ * must answer its own prompt first; the steering is then consumed at the
197
+ * first turn boundary instead of being merged into the opening call.
198
+ */
199
+ skipInitialSteeringPoll?: boolean;
189
200
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
190
201
  onRunAccepted?: (handle: AttemptRunHandle, acceptance: {
191
202
  consumedQueuedMessages: readonly AgentMessage[];
@@ -201,6 +212,18 @@ export type AgentQueueSnapshot = {
201
212
  steering: AgentMessage[];
202
213
  followUp: AgentMessage[];
203
214
  };
215
+ /**
216
+ * Result of `Agent.steer()`. A steer is admitted only into a live, non-aborted
217
+ * run; otherwise the message is NOT queued and the caller (the session) owns
218
+ * routing it — as a fresh prompt when idle, or after the unwind when aborting.
219
+ */
220
+ export type SteerAdmission = {
221
+ admitted: true;
222
+ runId: number;
223
+ } | {
224
+ admitted: false;
225
+ reason: "idle" | "aborting";
226
+ };
204
227
  export declare class Agent {
205
228
  #private;
206
229
  get intentTracing(): boolean;
@@ -367,7 +390,9 @@ export declare class Agent {
367
390
  * immediate-interrupt path), so a cooperative stop alone cannot prevent one
368
391
  * more old-turn model call once a steering message has already been dequeued.
369
392
  * While the fence returns true the poll yields no messages AND does not
370
- * dequeue, so the queue survives intact for the next turn.
393
+ * dequeue, so the winding-down run cannot consume it. The message is not
394
+ * retained past the run's terminal either: `agent_end.disownedSteering`
395
+ * hands it to the owner, which re-routes it onto the next turn.
371
396
  */
372
397
  setSteeringAdmissionFence(fn: (() => boolean) | undefined): void;
373
398
  setMaintainContext(fn: AgentLoopConfig["maintainContext"] | undefined): void;
@@ -392,8 +417,8 @@ export declare class Agent {
392
417
  getSteeringMode(): "all" | "one-at-a-time";
393
418
  setFollowUpMode(mode: "all" | "one-at-a-time"): void;
394
419
  getFollowUpMode(): "all" | "one-at-a-time";
395
- setInterruptMode(mode: "immediate" | "wait"): void;
396
- getInterruptMode(): "immediate" | "wait";
420
+ setToolInterruptPolicy(policy: "abort_tools" | "finish_tools"): void;
421
+ getToolInterruptPolicy(): "abort_tools" | "finish_tools";
397
422
  setTools(t: AgentTool<any>[]): void;
398
423
  replaceMessages(ms: AgentMessage[], options?: {
399
424
  historyRewrite?: {
@@ -411,11 +436,18 @@ export declare class Agent {
411
436
  /**
412
437
  * Queue a steering message to interrupt the agent mid-run.
413
438
  * Delivered after current tool execution, skips remaining tools.
439
+ *
440
+ * Enqueue-time admission: the message is pushed only when a run is live and
441
+ * its signal is not aborted, so a steer can never be orphaned in the queue
442
+ * waiting for whichever unrelated run polls next.
414
443
  */
415
- steer(m: AgentMessage): void;
444
+ steer(m: AgentMessage, options?: {
445
+ forceOneAtATime?: boolean;
446
+ }): SteerAdmission;
416
447
  /**
417
- * Resolves when a steering message is queued (or is already queued), or when
418
- * `signal` aborts. The queue is not consumed. Long observation tools use this
448
+ * Resolves when a steering message is admitted AFTER this wait started, or
449
+ * when `signal` aborts. The queue is not consumed and a message already
450
+ * queued before the wait does not resolve it. Long observation tools use this
419
451
  * to end their wait early so a busy user message is handled at the next tool
420
452
  * boundary instead of after the full wait window.
421
453
  */
@@ -431,6 +463,14 @@ export declare class Agent {
431
463
  followUp(m: AgentMessage, options?: {
432
464
  forceOneAtATime?: boolean;
433
465
  }): void;
466
+ /**
467
+ * Mark a follow-up message for prompt-by-prompt delivery under `all` mode
468
+ * without queueing it. Used when a message is restored ahead of the queue
469
+ * (e.g. steering disowned by an ended run and re-routed as a follow-up).
470
+ */
471
+ markFollowUpSequential(m: AgentMessage): void;
472
+ /** Preserve one atomic delivery cohort when messages are re-routed into the follow-up queue. */
473
+ markFollowUpBatch(messages: readonly AgentMessage[]): void;
434
474
  clearSteeringQueue(): void;
435
475
  clearFollowUpQueue(): void;
436
476
  clearAllQueues(): void;
@@ -468,19 +508,9 @@ export declare class Agent {
468
508
  popLastFollowUp(): AgentMessage | undefined;
469
509
  removeFollowUpAt(index: number): AgentMessage | undefined;
470
510
  moveFollowUp(fromIndex: number, toIndex: number): boolean;
471
- /**
472
- * Remove ALL queued STEERING messages without touching the follow-up queue.
473
- * Used by the terminal-abort path to purge steering queued for the aborted
474
- * turn (the loop may exit on the abort signal without polling it); the
475
- * follow-up queue is preserved because it may carry owned-completion
476
- * resumes that must still deliver.
477
- */
478
- clearSteeringMessages(): void;
479
511
  /**
480
512
  * Remove queued steering/follow-up messages matching `predicate`, preserving
481
- * order of the rest. `scope` restricts the removal to one queue — the
482
- * terminal-abort steering purge must not wipe the follow-up queue, which
483
- * the owned-completion resume policy preserves.
513
+ * order of the rest. `scope` restricts the removal to one queue.
484
514
  */
485
515
  removeQueuedMessages(predicate: (message: AgentMessage) => boolean, scope?: "both" | "steering" | "followUp"): {
486
516
  steering: number;
@@ -223,11 +223,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
223
223
  /** Scope allocated by the owning Agent for the first attempt in this loop. */
224
224
  initialScope?: AttemptScope;
225
225
  /**
226
- * When to interrupt tool execution for steering messages.
227
- * - "immediate" = check after each tool call (default)
228
- * - "wait" = defer steering until the current turn completes
226
+ * Whether a steering message aborts the tool calls still running in the
227
+ * current batch.
228
+ * - "abort_tools": abort the remaining tools and open the steering turn (default)
229
+ * - "finish_tools": let the batch finish, then open the steering turn
230
+ *
231
+ * This never changes WHEN a steer is consumed: the loop picks it up at the
232
+ * next tool/turn boundary either way.
229
233
  */
230
- interruptMode?: "immediate" | "wait";
234
+ toolInterruptPolicy?: "abort_tools" | "finish_tools";
231
235
  /**
232
236
  * Optional session identifier forwarded to LLM providers.
233
237
  * Used by providers that support session-based caching (e.g., OpenAI code provider).
@@ -302,7 +306,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
302
306
  /**
303
307
  * Returns steering messages to inject into the conversation mid-run.
304
308
  *
305
- * Called after each tool execution to check for user interruptions unless interruptMode is "wait".
309
+ * Called after each tool execution to check for user interruptions unless toolInterruptPolicy is "finish_tools".
306
310
  * If messages are returned, remaining tool calls are skipped and
307
311
  * these messages are added to the context before the next LLM call.
308
312
  */
@@ -719,6 +723,13 @@ export type AgentEvent = {
719
723
  stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
720
724
  /** Present iff `stopReason === "maintenance"`; the maintenance outcome. */
721
725
  maintenanceOutcome?: MidRunMaintenanceOutcome;
726
+ /**
727
+ * Steering that was admitted into this run but never consumed before it
728
+ * ended (the loop exited on abort, error, pause, or completion without a
729
+ * further poll). The Agent clears its queue on every run exit; the owner
730
+ * decides once here whether to re-route or drop these messages.
731
+ */
732
+ disownedSteering?: AgentMessage[];
722
733
  /** Present iff `AgentTelemetryConfig` was supplied on this run. */
723
734
  telemetry?: AgentRunSummary;
724
735
  coverage?: AgentRunCoverage;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/agent-core",
4
- "version": "0.16.0",
4
+ "version": "0.16.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -32,9 +32,9 @@
32
32
  "fmt": "biome format --write ."
33
33
  },
34
34
  "dependencies": {
35
- "@gajae-code/ai": "0.16.0",
36
- "@gajae-code/natives": "0.16.0",
37
- "@gajae-code/utils": "0.16.0",
35
+ "@gajae-code/ai": "0.16.3",
36
+ "@gajae-code/natives": "0.16.3",
37
+ "@gajae-code/utils": "0.16.3",
38
38
  "@opentelemetry/api": "^1.9.0"
39
39
  },
40
40
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -4316,6 +4316,11 @@ async function runLoopBody(
4316
4316
  config.requeueSteeringMessages?.(pendingMessages);
4317
4317
  break;
4318
4318
  }
4319
+ // An aborted run must not open another turn: the provider rejects it before
4320
+ // the first token and the attempt only appends an aborted assistant message.
4321
+ // The run's steering (which the poll above deliberately leaves queued once
4322
+ // the signal is aborted) is disowned by the terminal instead.
4323
+ if (loopSignal.aborted) break;
4319
4324
  if (config.shouldPause?.()) {
4320
4325
  publishAgentEnd(
4321
4326
  stream,
@@ -5006,7 +5011,7 @@ async function executeToolCalls(
5006
5011
  const tools = currentContext.tools;
5007
5012
  const {
5008
5013
  getSteeringMessages,
5009
- interruptMode = "immediate",
5014
+ toolInterruptPolicy = "abort_tools",
5010
5015
  getToolContext,
5011
5016
  transformToolCallArguments,
5012
5017
  intentTracing,
@@ -5020,7 +5025,7 @@ async function executeToolCalls(
5020
5025
  const emittedToolResults: ToolResultMessage[] = [];
5021
5026
  const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name }));
5022
5027
  const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`;
5023
- const shouldInterruptImmediately = interruptMode !== "wait";
5028
+ const shouldInterruptImmediately = toolInterruptPolicy !== "finish_tools";
5024
5029
  const steeringAbortController = new AbortController();
5025
5030
  const toolSignals = [
5026
5031
  ...(signal ? [signal] : []),
package/src/agent.ts CHANGED
@@ -267,11 +267,15 @@ export interface AgentOptions {
267
267
  followUpMode?: "all" | "one-at-a-time";
268
268
 
269
269
  /**
270
- * When to interrupt tool execution for steering messages.
271
- * - "immediate": check after each tool call (default)
272
- * - "wait": defer steering until the current turn completes
270
+ * Whether a steering message aborts the tool calls still running in the
271
+ * current batch.
272
+ * - "abort_tools": abort the remaining tools and open the steering turn (default)
273
+ * - "finish_tools": let the batch finish, then open the steering turn
274
+ *
275
+ * This never changes WHEN a steer is consumed: the loop picks it up at the
276
+ * next tool/turn boundary either way.
273
277
  */
274
- interruptMode?: "immediate" | "wait";
278
+ toolInterruptPolicy?: "abort_tools" | "finish_tools";
275
279
  /** Cooperative pause checkpoint passed through to AgentLoopConfig.shouldPause. */
276
280
  shouldPause?: AgentLoopConfig["shouldPause"];
277
281
 
@@ -433,6 +437,13 @@ export interface AgentPromptOptions {
433
437
  fallbackManaged?: boolean;
434
438
  /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
435
439
  maintenanceContinuation?: boolean;
440
+ /**
441
+ * Skip the loop's INITIAL steering poll for this run. Used when the caller
442
+ * seeds the steering queue at run acceptance but the run's first model call
443
+ * must answer its own prompt first; the steering is then consumed at the
444
+ * first turn boundary instead of being merged into the opening call.
445
+ */
446
+ skipInitialSteeringPoll?: boolean;
436
447
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
437
448
  onRunAccepted?: (handle: AttemptRunHandle, acceptance: { consumedQueuedMessages: readonly AgentMessage[] }) => void;
438
449
  /** Called once immediately before every managed upstream request. */
@@ -454,6 +465,13 @@ export type AgentQueueSnapshot = {
454
465
  followUp: AgentMessage[];
455
466
  };
456
467
 
468
+ /**
469
+ * Result of `Agent.steer()`. A steer is admitted only into a live, non-aborted
470
+ * run; otherwise the message is NOT queued and the caller (the session) owns
471
+ * routing it — as a fresh prompt when idle, or after the unwind when aborting.
472
+ */
473
+ export type SteerAdmission = { admitted: true; runId: number } | { admitted: false; reason: "idle" | "aborting" };
474
+
457
475
  export class Agent {
458
476
  #state: AgentState = {
459
477
  systemPrompt: [],
@@ -482,9 +500,11 @@ export class Agent {
482
500
  #steeringWaiters = new Set<() => void>();
483
501
  #followUpQueue: AgentMessage[] = [];
484
502
  #followUpForceOneAtATime = new WeakSet<AgentMessage>();
503
+ #followUpBatches = new WeakMap<AgentMessage, readonly AgentMessage[]>();
504
+ #steeringForceOneAtATime = new WeakSet<AgentMessage>();
485
505
  #steeringMode: "all" | "one-at-a-time";
486
506
  #followUpMode: "all" | "one-at-a-time";
487
- #interruptMode: "immediate" | "wait";
507
+ #toolInterruptPolicy: "abort_tools" | "finish_tools";
488
508
  #sessionId?: string;
489
509
  #providerSessionId?: string;
490
510
  #metadata?: Record<string, unknown>;
@@ -527,7 +547,7 @@ export class Agent {
527
547
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
528
548
  #onBeforeYield?: () => Promise<void> | void;
529
549
  #shouldPause?: AgentLoopConfig["shouldPause"];
530
- /** While set and returning true, steering is neither admitted nor dequeued. */
550
+ /** While set and returning true, the run does not DEQUEUE steering (admission is unaffected). */
531
551
  #steeringAdmissionFence?: () => boolean;
532
552
  #maintainContext?: AgentLoopConfig["maintainContext"];
533
553
  #telemetry?: AgentLoopConfig["telemetry"];
@@ -593,7 +613,7 @@ export class Agent {
593
613
  this.#transformContext = opts.transformContext;
594
614
  this.#steeringMode = opts.steeringMode || "one-at-a-time";
595
615
  this.#followUpMode = opts.followUpMode || "one-at-a-time";
596
- this.#interruptMode = opts.interruptMode || "immediate";
616
+ this.#toolInterruptPolicy = opts.toolInterruptPolicy || "abort_tools";
597
617
  this.streamFn = opts.streamFn || streamSimple;
598
618
  this.#sessionId = opts.sessionId;
599
619
  this.#providerSessionId = opts.providerSessionId;
@@ -933,7 +953,9 @@ export class Agent {
933
953
  * immediate-interrupt path), so a cooperative stop alone cannot prevent one
934
954
  * more old-turn model call once a steering message has already been dequeued.
935
955
  * While the fence returns true the poll yields no messages AND does not
936
- * dequeue, so the queue survives intact for the next turn.
956
+ * dequeue, so the winding-down run cannot consume it. The message is not
957
+ * retained past the run's terminal either: `agent_end.disownedSteering`
958
+ * hands it to the owner, which re-routes it onto the next turn.
937
959
  */
938
960
  setSteeringAdmissionFence(fn: (() => boolean) | undefined): void {
939
961
  this.#steeringAdmissionFence = fn;
@@ -1130,12 +1152,12 @@ export class Agent {
1130
1152
  return this.#followUpMode;
1131
1153
  }
1132
1154
 
1133
- setInterruptMode(mode: "immediate" | "wait") {
1134
- this.#interruptMode = mode;
1155
+ setToolInterruptPolicy(policy: "abort_tools" | "finish_tools") {
1156
+ this.#toolInterruptPolicy = policy;
1135
1157
  }
1136
1158
 
1137
- getInterruptMode(): "immediate" | "wait" {
1138
- return this.#interruptMode;
1159
+ getToolInterruptPolicy(): "abort_tools" | "finish_tools" {
1160
+ return this.#toolInterruptPolicy;
1139
1161
  }
1140
1162
 
1141
1163
  setTools(t: AgentTool<any>[]) {
@@ -1187,21 +1209,31 @@ export class Agent {
1187
1209
  /**
1188
1210
  * Queue a steering message to interrupt the agent mid-run.
1189
1211
  * Delivered after current tool execution, skips remaining tools.
1212
+ *
1213
+ * Enqueue-time admission: the message is pushed only when a run is live and
1214
+ * its signal is not aborted, so a steer can never be orphaned in the queue
1215
+ * waiting for whichever unrelated run polls next.
1190
1216
  */
1191
- steer(m: AgentMessage) {
1217
+ steer(m: AgentMessage, options?: { forceOneAtATime?: boolean }): SteerAdmission {
1192
1218
  assertUserImagePlaceholdersHavePayload([m]);
1219
+ const runId = this.#activeRunId;
1220
+ if (runId === undefined || !this.#state.isStreaming) return { admitted: false, reason: "idle" };
1221
+ if (this.#abortController?.signal.aborted) return { admitted: false, reason: "aborting" };
1222
+ if (options?.forceOneAtATime) this.#steeringForceOneAtATime.add(m);
1193
1223
  this.#steeringQueue.push(m);
1194
1224
  for (const notify of [...this.#steeringWaiters]) notify();
1225
+ return { admitted: true, runId };
1195
1226
  }
1196
1227
 
1197
1228
  /**
1198
- * Resolves when a steering message is queued (or is already queued), or when
1199
- * `signal` aborts. The queue is not consumed. Long observation tools use this
1229
+ * Resolves when a steering message is admitted AFTER this wait started, or
1230
+ * when `signal` aborts. The queue is not consumed and a message already
1231
+ * queued before the wait does not resolve it. Long observation tools use this
1200
1232
  * to end their wait early so a busy user message is handled at the next tool
1201
1233
  * boundary instead of after the full wait window.
1202
1234
  */
1203
1235
  waitForSteeringArrival(signal: AbortSignal): Promise<void> {
1204
- if (this.#steeringQueue.length > 0 || signal.aborted) return Promise.resolve();
1236
+ if (signal.aborted) return Promise.resolve();
1205
1237
  const { promise, resolve } = Promise.withResolvers<void>();
1206
1238
  let settled = false;
1207
1239
  const settle = () => {
@@ -1213,7 +1245,6 @@ export class Agent {
1213
1245
  };
1214
1246
  this.#steeringWaiters.add(settle);
1215
1247
  signal.addEventListener("abort", settle, { once: true });
1216
- if (this.#steeringQueue.length > 0 || signal.aborted) settle();
1217
1248
  return promise;
1218
1249
  }
1219
1250
 
@@ -1233,6 +1264,21 @@ export class Agent {
1233
1264
  this.#followUpQueue.push(m);
1234
1265
  }
1235
1266
 
1267
+ /**
1268
+ * Mark a follow-up message for prompt-by-prompt delivery under `all` mode
1269
+ * without queueing it. Used when a message is restored ahead of the queue
1270
+ * (e.g. steering disowned by an ended run and re-routed as a follow-up).
1271
+ */
1272
+ markFollowUpSequential(m: AgentMessage): void {
1273
+ this.#followUpForceOneAtATime.add(m);
1274
+ }
1275
+
1276
+ /** Preserve one atomic delivery cohort when messages are re-routed into the follow-up queue. */
1277
+ markFollowUpBatch(messages: readonly AgentMessage[]): void {
1278
+ const first = messages[0];
1279
+ if (first && messages.length > 1) this.#followUpBatches.set(first, messages.slice());
1280
+ }
1281
+
1236
1282
  clearSteeringQueue() {
1237
1283
  this.#steeringQueue = [];
1238
1284
  }
@@ -1305,23 +1351,37 @@ export class Agent {
1305
1351
  }
1306
1352
  return [];
1307
1353
  }
1308
- const steering = this.#steeringQueue.slice();
1309
- this.#steeringQueue = [];
1354
+ // "all" batches within ONE poll only; a per-message sequential mark still
1355
+ // delivers that message on its own, mirroring the follow-up override.
1356
+ const first = this.#steeringQueue[0];
1357
+ if (!first) return [];
1358
+ if (this.#steeringForceOneAtATime.has(first)) {
1359
+ this.#steeringQueue = this.#steeringQueue.slice(1);
1360
+ return [first];
1361
+ }
1362
+ const forcedIndex = this.#steeringQueue.findIndex(message => this.#steeringForceOneAtATime.has(message));
1363
+ const takeCount = forcedIndex === -1 ? this.#steeringQueue.length : forcedIndex;
1364
+ const steering = this.#steeringQueue.slice(0, takeCount);
1365
+ this.#steeringQueue = this.#steeringQueue.slice(takeCount);
1310
1366
  return steering;
1311
1367
  }
1312
1368
 
1313
1369
  #dequeueFollowUpMessages(): AgentMessage[] {
1314
- if (this.#followUpMode === "one-at-a-time") {
1315
- if (this.#followUpQueue.length > 0) {
1316
- const first = this.#followUpQueue[0];
1317
- this.#followUpQueue = this.#followUpQueue.slice(1);
1318
- return [first];
1370
+ const first = this.#followUpQueue[0];
1371
+ if (!first) return [];
1372
+ const batch = this.#followUpBatches.get(first);
1373
+ if (batch) {
1374
+ this.#followUpBatches.delete(first);
1375
+ if (batch.every((message, index) => this.#followUpQueue[index] === message)) {
1376
+ this.#followUpQueue = this.#followUpQueue.slice(batch.length);
1377
+ return [...batch];
1319
1378
  }
1320
- return [];
1379
+ }
1380
+ if (this.#followUpMode === "one-at-a-time") {
1381
+ this.#followUpQueue = this.#followUpQueue.slice(1);
1382
+ return [first];
1321
1383
  }
1322
1384
 
1323
- const first = this.#followUpQueue[0];
1324
- if (!first) return [];
1325
1385
  if (this.#followUpForceOneAtATime.has(first)) {
1326
1386
  this.#followUpQueue = this.#followUpQueue.slice(1);
1327
1387
  return [first];
@@ -1378,22 +1438,9 @@ export class Agent {
1378
1438
  return true;
1379
1439
  }
1380
1440
 
1381
- /**
1382
- * Remove ALL queued STEERING messages without touching the follow-up queue.
1383
- * Used by the terminal-abort path to purge steering queued for the aborted
1384
- * turn (the loop may exit on the abort signal without polling it); the
1385
- * follow-up queue is preserved because it may carry owned-completion
1386
- * resumes that must still deliver.
1387
- */
1388
- clearSteeringMessages(): void {
1389
- this.#steeringQueue = [];
1390
- }
1391
-
1392
1441
  /**
1393
1442
  * Remove queued steering/follow-up messages matching `predicate`, preserving
1394
- * order of the rest. `scope` restricts the removal to one queue — the
1395
- * terminal-abort steering purge must not wipe the follow-up queue, which
1396
- * the owned-completion resume policy preserves.
1443
+ * order of the rest. `scope` restricts the removal to one queue.
1397
1444
  */
1398
1445
  removeQueuedMessages(
1399
1446
  predicate: (message: AgentMessage) => boolean,
@@ -1855,7 +1902,7 @@ export class Agent {
1855
1902
  repetitionPenalty: this.#repetitionPenalty,
1856
1903
  serviceTier: this.#serviceTier,
1857
1904
  hideThinkingSummary: this.#hideThinkingSummary,
1858
- interruptMode: this.#interruptMode,
1905
+ toolInterruptPolicy: this.#toolInterruptPolicy,
1859
1906
  sessionId: this.#sessionId,
1860
1907
  providerSessionId: this.#providerSessionId,
1861
1908
  metadata: this.#metadataResolver ? undefined : this.#metadata,
@@ -1960,11 +2007,18 @@ export class Agent {
1960
2007
  return [];
1961
2008
  }
1962
2009
  // Fenced: yield nothing and dequeue nothing, so a steer submitted while a
1963
- // fold is being claimed is neither consumed by the run being wound down
1964
- // nor lost.
2010
+ // fold is being claimed is not consumed by the run being wound down. It
2011
+ // is not lost either: the terminal disowns it to the owner.
1965
2012
  if (this.#steeringAdmissionFence?.() === true) {
1966
2013
  return [];
1967
2014
  }
2015
+ // An aborted run cannot deliver steering: the loop hands drained
2016
+ // messages back and ends. Dequeuing here would fire the in-run
2017
+ // consumption hook for a message the run never delivers, so its SDK
2018
+ // submission would settle as consumed instead of removed at disown.
2019
+ if (abortController.signal.aborted) {
2020
+ return [];
2021
+ }
1968
2022
  const queued = this.#dequeueSteeringMessages();
1969
2023
  if (this.#activeRunId !== runId) {
1970
2024
  this.#steeringQueue = [...queued, ...this.#steeringQueue];
@@ -2327,6 +2381,15 @@ export class Agent {
2327
2381
  scope: handle?.scope,
2328
2382
  };
2329
2383
  if (handle) terminalEvent.scope = handle.scope;
2384
+ // The run is over: nothing will poll the steering queue again. Disown
2385
+ // whatever it still holds — unconditionally, so no ownership exception can
2386
+ // leave an ended run's steering behind for an unrelated run to consume —
2387
+ // and hand it to the owner on the terminal event to re-route, hold, or
2388
+ // drop exactly once.
2389
+ if (this.#steeringQueue.length > 0) {
2390
+ terminalEvent.disownedSteering = this.#steeringQueue;
2391
+ this.#steeringQueue = [];
2392
+ }
2330
2393
  if (domain) {
2331
2394
  setAgentTerminalOwnerContext(terminalEvent, {
2332
2395
  resourceRunId,
package/src/types.ts CHANGED
@@ -246,11 +246,15 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
246
246
  initialScope?: AttemptScope;
247
247
 
248
248
  /**
249
- * When to interrupt tool execution for steering messages.
250
- * - "immediate" = check after each tool call (default)
251
- * - "wait" = defer steering until the current turn completes
249
+ * Whether a steering message aborts the tool calls still running in the
250
+ * current batch.
251
+ * - "abort_tools": abort the remaining tools and open the steering turn (default)
252
+ * - "finish_tools": let the batch finish, then open the steering turn
253
+ *
254
+ * This never changes WHEN a steer is consumed: the loop picks it up at the
255
+ * next tool/turn boundary either way.
252
256
  */
253
- interruptMode?: "immediate" | "wait";
257
+ toolInterruptPolicy?: "abort_tools" | "finish_tools";
254
258
 
255
259
  /**
256
260
  * Optional session identifier forwarded to LLM providers.
@@ -332,7 +336,7 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
332
336
  /**
333
337
  * Returns steering messages to inject into the conversation mid-run.
334
338
  *
335
- * Called after each tool execution to check for user interruptions unless interruptMode is "wait".
339
+ * Called after each tool execution to check for user interruptions unless toolInterruptPolicy is "finish_tools".
336
340
  * If messages are returned, remaining tool calls are skipped and
337
341
  * these messages are added to the context before the next LLM call.
338
342
  */
@@ -803,6 +807,13 @@ export type AgentEvent =
803
807
  stopReason?: "completed" | "paused" | "cancelled" | "maintenance";
804
808
  /** Present iff `stopReason === "maintenance"`; the maintenance outcome. */
805
809
  maintenanceOutcome?: MidRunMaintenanceOutcome;
810
+ /**
811
+ * Steering that was admitted into this run but never consumed before it
812
+ * ended (the loop exited on abort, error, pause, or completion without a
813
+ * further poll). The Agent clears its queue on every run exit; the owner
814
+ * decides once here whether to re-route or drop these messages.
815
+ */
816
+ disownedSteering?: AgentMessage[];
806
817
  /** Present iff `AgentTelemetryConfig` was supplied on this run. */
807
818
  telemetry?: AgentRunSummary;
808
819
  coverage?: AgentRunCoverage;