@gajae-code/agent-core 0.16.0 → 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 +8 -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 +107 -44
- package/src/types.ts +16 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
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
|
+
|
|
5
13
|
## [0.16.0] - 2026-09-02
|
|
6
14
|
|
|
7
15
|
## [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.
|
|
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.16.
|
|
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.16.
|
|
36
|
-
"@gajae-code/natives": "0.16.
|
|
37
|
-
"@gajae-code/utils": "0.16.
|
|
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
|
@@ -267,11 +267,15 @@ export interface AgentOptions {
|
|
|
267
267
|
followUpMode?: "all" | "one-at-a-time";
|
|
268
268
|
|
|
269
269
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* - "
|
|
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
|
-
|
|
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
|
-
#
|
|
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,
|
|
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.#
|
|
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
|
|
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
|
-
|
|
1134
|
-
this.#
|
|
1155
|
+
setToolInterruptPolicy(policy: "abort_tools" | "finish_tools") {
|
|
1156
|
+
this.#toolInterruptPolicy = policy;
|
|
1135
1157
|
}
|
|
1136
1158
|
|
|
1137
|
-
|
|
1138
|
-
return this.#
|
|
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
|
|
1199
|
-
* `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
|
|
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 (
|
|
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
|
-
|
|
1309
|
-
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1964
|
-
//
|
|
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
|
-
*
|
|
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;
|