@gajae-code/agent-core 0.15.6 → 0.16.1

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,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.16.1] - 2026-09-03
6
+
7
+ ### Changed
8
+
9
+ - `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).
10
+ - `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.
11
+ - 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"`.
12
+
13
+ ## [0.16.0] - 2026-09-02
14
+
5
15
  ## [0.15.6] - 2026-08-30
6
16
 
7
17
  ## [0.15.5] - 2026-08-29
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.15.6",
4
+ "version": "0.16.1",
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.15.6",
36
- "@gajae-code/natives": "0.15.6",
37
- "@gajae-code/utils": "0.15.6",
35
+ "@gajae-code/ai": "0.16.1",
36
+ "@gajae-code/natives": "0.16.1",
37
+ "@gajae-code/utils": "0.16.1",
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
@@ -117,6 +117,27 @@ function sanitizeAgentFailure(error: unknown, runtimeClassifiedCode?: string): {
117
117
  return { code, message: "Agent run failed." };
118
118
  }
119
119
 
120
+ /** Only runtime-authenticated built-in constructors may contribute a name. */
121
+ const TRUSTED_ERROR_CONSTRUCTORS = new Map<Function, string>([
122
+ [Error, "Error"],
123
+ [TypeError, "TypeError"],
124
+ [RangeError, "RangeError"],
125
+ [SyntaxError, "SyntaxError"],
126
+ [ReferenceError, "ReferenceError"],
127
+ [URIError, "URIError"],
128
+ [EvalError, "EvalError"],
129
+ [AggregateError, "AggregateError"],
130
+ ]);
131
+
132
+ function safeErrorName(error: unknown): string | undefined {
133
+ try {
134
+ if (!(error instanceof Error)) return undefined;
135
+ return TRUSTED_ERROR_CONSTRUCTORS.get(error.constructor);
136
+ } catch {
137
+ return undefined;
138
+ }
139
+ }
140
+
120
141
  /** Guarded HTTP-status extraction for untrusted provider errors: a throwing
121
142
  * getter must never escape the failure handler and suppress terminalization
122
143
  * (exact-head review P1). */
@@ -246,11 +267,15 @@ export interface AgentOptions {
246
267
  followUpMode?: "all" | "one-at-a-time";
247
268
 
248
269
  /**
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
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.
252
277
  */
253
- interruptMode?: "immediate" | "wait";
278
+ toolInterruptPolicy?: "abort_tools" | "finish_tools";
254
279
  /** Cooperative pause checkpoint passed through to AgentLoopConfig.shouldPause. */
255
280
  shouldPause?: AgentLoopConfig["shouldPause"];
256
281
 
@@ -412,6 +437,13 @@ export interface AgentPromptOptions {
412
437
  fallbackManaged?: boolean;
413
438
  /** Continue a cooperative maintenance checkpoint under its existing logical run and cancellation domain. */
414
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;
415
447
  /** Called synchronously after this invocation claims the agent run, before asynchronous provider work. */
416
448
  onRunAccepted?: (handle: AttemptRunHandle, acceptance: { consumedQueuedMessages: readonly AgentMessage[] }) => void;
417
449
  /** Called once immediately before every managed upstream request. */
@@ -433,6 +465,13 @@ export type AgentQueueSnapshot = {
433
465
  followUp: AgentMessage[];
434
466
  };
435
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
+
436
475
  export class Agent {
437
476
  #state: AgentState = {
438
477
  systemPrompt: [],
@@ -461,9 +500,11 @@ export class Agent {
461
500
  #steeringWaiters = new Set<() => void>();
462
501
  #followUpQueue: AgentMessage[] = [];
463
502
  #followUpForceOneAtATime = new WeakSet<AgentMessage>();
503
+ #followUpBatches = new WeakMap<AgentMessage, readonly AgentMessage[]>();
504
+ #steeringForceOneAtATime = new WeakSet<AgentMessage>();
464
505
  #steeringMode: "all" | "one-at-a-time";
465
506
  #followUpMode: "all" | "one-at-a-time";
466
- #interruptMode: "immediate" | "wait";
507
+ #toolInterruptPolicy: "abort_tools" | "finish_tools";
467
508
  #sessionId?: string;
468
509
  #providerSessionId?: string;
469
510
  #metadata?: Record<string, unknown>;
@@ -506,7 +547,7 @@ export class Agent {
506
547
  #onHarmonyLeak?: (event: HarmonyAuditEvent) => void | Promise<void>;
507
548
  #onBeforeYield?: () => Promise<void> | void;
508
549
  #shouldPause?: AgentLoopConfig["shouldPause"];
509
- /** 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). */
510
551
  #steeringAdmissionFence?: () => boolean;
511
552
  #maintainContext?: AgentLoopConfig["maintainContext"];
512
553
  #telemetry?: AgentLoopConfig["telemetry"];
@@ -572,7 +613,7 @@ export class Agent {
572
613
  this.#transformContext = opts.transformContext;
573
614
  this.#steeringMode = opts.steeringMode || "one-at-a-time";
574
615
  this.#followUpMode = opts.followUpMode || "one-at-a-time";
575
- this.#interruptMode = opts.interruptMode || "immediate";
616
+ this.#toolInterruptPolicy = opts.toolInterruptPolicy || "abort_tools";
576
617
  this.streamFn = opts.streamFn || streamSimple;
577
618
  this.#sessionId = opts.sessionId;
578
619
  this.#providerSessionId = opts.providerSessionId;
@@ -912,7 +953,9 @@ export class Agent {
912
953
  * immediate-interrupt path), so a cooperative stop alone cannot prevent one
913
954
  * more old-turn model call once a steering message has already been dequeued.
914
955
  * While the fence returns true the poll yields no messages AND does not
915
- * 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.
916
959
  */
917
960
  setSteeringAdmissionFence(fn: (() => boolean) | undefined): void {
918
961
  this.#steeringAdmissionFence = fn;
@@ -1109,12 +1152,12 @@ export class Agent {
1109
1152
  return this.#followUpMode;
1110
1153
  }
1111
1154
 
1112
- setInterruptMode(mode: "immediate" | "wait") {
1113
- this.#interruptMode = mode;
1155
+ setToolInterruptPolicy(policy: "abort_tools" | "finish_tools") {
1156
+ this.#toolInterruptPolicy = policy;
1114
1157
  }
1115
1158
 
1116
- getInterruptMode(): "immediate" | "wait" {
1117
- return this.#interruptMode;
1159
+ getToolInterruptPolicy(): "abort_tools" | "finish_tools" {
1160
+ return this.#toolInterruptPolicy;
1118
1161
  }
1119
1162
 
1120
1163
  setTools(t: AgentTool<any>[]) {
@@ -1166,21 +1209,31 @@ export class Agent {
1166
1209
  /**
1167
1210
  * Queue a steering message to interrupt the agent mid-run.
1168
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.
1169
1216
  */
1170
- steer(m: AgentMessage) {
1217
+ steer(m: AgentMessage, options?: { forceOneAtATime?: boolean }): SteerAdmission {
1171
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);
1172
1223
  this.#steeringQueue.push(m);
1173
1224
  for (const notify of [...this.#steeringWaiters]) notify();
1225
+ return { admitted: true, runId };
1174
1226
  }
1175
1227
 
1176
1228
  /**
1177
- * Resolves when a steering message is queued (or is already queued), or when
1178
- * `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
1179
1232
  * to end their wait early so a busy user message is handled at the next tool
1180
1233
  * boundary instead of after the full wait window.
1181
1234
  */
1182
1235
  waitForSteeringArrival(signal: AbortSignal): Promise<void> {
1183
- if (this.#steeringQueue.length > 0 || signal.aborted) return Promise.resolve();
1236
+ if (signal.aborted) return Promise.resolve();
1184
1237
  const { promise, resolve } = Promise.withResolvers<void>();
1185
1238
  let settled = false;
1186
1239
  const settle = () => {
@@ -1192,7 +1245,6 @@ export class Agent {
1192
1245
  };
1193
1246
  this.#steeringWaiters.add(settle);
1194
1247
  signal.addEventListener("abort", settle, { once: true });
1195
- if (this.#steeringQueue.length > 0 || signal.aborted) settle();
1196
1248
  return promise;
1197
1249
  }
1198
1250
 
@@ -1212,6 +1264,21 @@ export class Agent {
1212
1264
  this.#followUpQueue.push(m);
1213
1265
  }
1214
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
+
1215
1282
  clearSteeringQueue() {
1216
1283
  this.#steeringQueue = [];
1217
1284
  }
@@ -1284,23 +1351,37 @@ export class Agent {
1284
1351
  }
1285
1352
  return [];
1286
1353
  }
1287
- const steering = this.#steeringQueue.slice();
1288
- 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);
1289
1366
  return steering;
1290
1367
  }
1291
1368
 
1292
1369
  #dequeueFollowUpMessages(): AgentMessage[] {
1293
- if (this.#followUpMode === "one-at-a-time") {
1294
- if (this.#followUpQueue.length > 0) {
1295
- const first = this.#followUpQueue[0];
1296
- this.#followUpQueue = this.#followUpQueue.slice(1);
1297
- 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];
1298
1378
  }
1299
- return [];
1379
+ }
1380
+ if (this.#followUpMode === "one-at-a-time") {
1381
+ this.#followUpQueue = this.#followUpQueue.slice(1);
1382
+ return [first];
1300
1383
  }
1301
1384
 
1302
- const first = this.#followUpQueue[0];
1303
- if (!first) return [];
1304
1385
  if (this.#followUpForceOneAtATime.has(first)) {
1305
1386
  this.#followUpQueue = this.#followUpQueue.slice(1);
1306
1387
  return [first];
@@ -1357,22 +1438,9 @@ export class Agent {
1357
1438
  return true;
1358
1439
  }
1359
1440
 
1360
- /**
1361
- * Remove ALL queued STEERING messages without touching the follow-up queue.
1362
- * Used by the terminal-abort path to purge steering queued for the aborted
1363
- * turn (the loop may exit on the abort signal without polling it); the
1364
- * follow-up queue is preserved because it may carry owned-completion
1365
- * resumes that must still deliver.
1366
- */
1367
- clearSteeringMessages(): void {
1368
- this.#steeringQueue = [];
1369
- }
1370
-
1371
1441
  /**
1372
1442
  * Remove queued steering/follow-up messages matching `predicate`, preserving
1373
- * order of the rest. `scope` restricts the removal to one queue — the
1374
- * terminal-abort steering purge must not wipe the follow-up queue, which
1375
- * the owned-completion resume policy preserves.
1443
+ * order of the rest. `scope` restricts the removal to one queue.
1376
1444
  */
1377
1445
  removeQueuedMessages(
1378
1446
  predicate: (message: AgentMessage) => boolean,
@@ -1834,7 +1902,7 @@ export class Agent {
1834
1902
  repetitionPenalty: this.#repetitionPenalty,
1835
1903
  serviceTier: this.#serviceTier,
1836
1904
  hideThinkingSummary: this.#hideThinkingSummary,
1837
- interruptMode: this.#interruptMode,
1905
+ toolInterruptPolicy: this.#toolInterruptPolicy,
1838
1906
  sessionId: this.#sessionId,
1839
1907
  providerSessionId: this.#providerSessionId,
1840
1908
  metadata: this.#metadataResolver ? undefined : this.#metadata,
@@ -1939,11 +2007,18 @@ export class Agent {
1939
2007
  return [];
1940
2008
  }
1941
2009
  // Fenced: yield nothing and dequeue nothing, so a steer submitted while a
1942
- // fold is being claimed is neither consumed by the run being wound down
1943
- // 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.
1944
2012
  if (this.#steeringAdmissionFence?.() === true) {
1945
2013
  return [];
1946
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
+ }
1947
2022
  const queued = this.#dequeueSteeringMessages();
1948
2023
  if (this.#activeRunId !== runId) {
1949
2024
  this.#steeringQueue = [...queued, ...this.#steeringQueue];
@@ -2128,6 +2203,8 @@ export class Agent {
2128
2203
  const runtimeFailureCode = abortController.signal.aborted
2129
2204
  ? "aborted"
2130
2205
  : (managedLocalErrorDiagnostic(err)?.errorKind ?? providerCode);
2206
+ const sanitized = sanitizeAgentFailure(err, runtimeFailureCode);
2207
+ const errorName = safeErrorName(err);
2131
2208
 
2132
2209
  const errorMsg: AgentMessage = {
2133
2210
  role: "assistant",
@@ -2144,7 +2221,9 @@ export class Agent {
2144
2221
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
2145
2222
  },
2146
2223
  stopReason: abortController.signal.aborted ? "aborted" : "error",
2147
- errorMessage: sanitizeAgentFailure(err).message,
2224
+ errorMessage: sanitized.message,
2225
+ errorCode: sanitized.code,
2226
+ ...(errorName ? { errorName } : {}),
2148
2227
  errorStatus: safeErrorStatus(err),
2149
2228
  // Local-diagnostic authority (`errorKind` + structured
2150
2229
  // `bufferOverflow`) comes from ONE identity check: a foreign error
@@ -2302,6 +2381,15 @@ export class Agent {
2302
2381
  scope: handle?.scope,
2303
2382
  };
2304
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
+ }
2305
2393
  if (domain) {
2306
2394
  setAgentTerminalOwnerContext(terminalEvent, {
2307
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;