@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 +10 -0
- package/README.md +15 -5
- package/dist/types/agent.d.ts +51 -21
- package/dist/types/types.d.ts +16 -5
- package/package.json +4 -4
- package/src/agent-loop.ts +7 -2
- package/src/agent.ts +133 -45
- package/src/types.ts +16 -5
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.
|
|
253
|
+
agent.setToolInterruptPolicy("abort_tools");
|
|
254
254
|
|
|
255
|
-
//
|
|
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 `
|
|
271
|
-
|
|
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
|
|
package/dist/types/agent.d.ts
CHANGED
|
@@ -39,11 +39,15 @@ export interface AgentOptions {
|
|
|
39
39
|
*/
|
|
40
40
|
followUpMode?: "all" | "one-at-a-time";
|
|
41
41
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* - "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
396
|
-
|
|
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
|
|
444
|
+
steer(m: AgentMessage, options?: {
|
|
445
|
+
forceOneAtATime?: boolean;
|
|
446
|
+
}): SteerAdmission;
|
|
416
447
|
/**
|
|
417
|
-
* Resolves when a steering message is
|
|
418
|
-
* `signal` aborts. The queue is not consumed
|
|
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
|
|
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;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* - "
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
36
|
-
"@gajae-code/natives": "0.
|
|
37
|
-
"@gajae-code/utils": "0.
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* - "
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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.#
|
|
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
|
|
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
|
-
|
|
1113
|
-
this.#
|
|
1155
|
+
setToolInterruptPolicy(policy: "abort_tools" | "finish_tools") {
|
|
1156
|
+
this.#toolInterruptPolicy = policy;
|
|
1114
1157
|
}
|
|
1115
1158
|
|
|
1116
|
-
|
|
1117
|
-
return this.#
|
|
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
|
|
1178
|
-
* `signal` aborts. The queue is not consumed
|
|
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 (
|
|
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
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1943
|
-
//
|
|
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:
|
|
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
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* - "
|
|
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
|
-
|
|
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
|
|
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;
|