Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

### Fixed

- Skipped next-turn preparation when every tool result terminates the current batch and no queued message requires another provider turn, retained queued input when next-turn preparation fails, and stopped before provider continuation when next-turn preparation aborts.

### Removed

## [2026.7.13] - 2026-07-13
Expand Down
76 changes: 72 additions & 4 deletions packages/agent/src/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
AgentContext,
AgentEvent,
AgentLoopConfig,
AgentLoopTurnUpdate,
AgentMessage,
AgentTool,
AgentToolCall,
Expand Down Expand Up @@ -182,6 +183,17 @@ async function runLoop(
let firstTurn = true;
// Check for steering messages at start (user may have typed while waiting)
let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || [];
let drainedTerminatingQueue: "steering" | "followUp" | undefined;
const refreshTerminatingQueueDrain = async (): Promise<void> => {
if (!drainedTerminatingQueue || !config.restorePendingMessages) return;
await config.restorePendingMessages(drainedTerminatingQueue, pendingMessages);
pendingMessages = (await config.getSteeringMessages?.()) || [];
drainedTerminatingQueue = pendingMessages.length > 0 ? "steering" : undefined;
if (pendingMessages.length === 0) {
pendingMessages = (await config.getFollowUpMessages?.()) || [];
drainedTerminatingQueue = pendingMessages.length > 0 ? "followUp" : undefined;
}
};

// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
Expand All @@ -194,6 +206,14 @@ async function runLoop(
} else {
firstTurn = false;
}
if (drainedTerminatingQueue) {
await refreshTerminatingQueueDrain();
if (pendingMessages.length === 0) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
drainedTerminatingQueue = undefined;
}

// Process pending messages (inject before next assistant response)
if (pendingMessages.length > 0) {
Expand Down Expand Up @@ -221,6 +241,7 @@ async function runLoop(

const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
let toolBatchTerminated = false;
if (toolCalls.length > 0) {
// A "length" stop means the output was cut off by the token limit, so
// every tool call in the message may carry truncated arguments. Fail
Expand All @@ -230,6 +251,7 @@ async function runLoop(
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
toolBatchTerminated = executedToolBatch.terminate;
hasMoreToolCalls = !executedToolBatch.terminate;

for (const result of toolResults) {
Expand All @@ -250,7 +272,36 @@ async function runLoop(
context: currentContext,
newMessages,
};
const nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
if (toolBatchTerminated) {
if (await config.shouldStopAfterTurn?.(nextTurnContext)) {
await emit({ type: "agent_end", messages: newMessages });
return;
}

pendingMessages = (await config.getSteeringMessages?.()) || [];
if (pendingMessages.length > 0) {
drainedTerminatingQueue = "steering";
}
if (pendingMessages.length === 0) {
pendingMessages = (await config.getFollowUpMessages?.()) || [];
if (pendingMessages.length > 0) {
drainedTerminatingQueue = "followUp";
}
}
if (pendingMessages.length === 0) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
}
let nextTurnSnapshot: AgentLoopTurnUpdate | undefined;
try {
nextTurnSnapshot = await config.prepareNextTurn?.(nextTurnContext);
} catch (error) {
if (drainedTerminatingQueue) {
await config.restorePendingMessages?.(drainedTerminatingQueue, pendingMessages);
}
throw error;
}
if (nextTurnSnapshot) {
currentContext = nextTurnSnapshot.context ?? currentContext;
config = {
Expand All @@ -264,20 +315,37 @@ async function runLoop(
: nextTurnSnapshot.thinkingLevel,
};
}
if (signal?.aborted) {
if (drainedTerminatingQueue) {
await config.restorePendingMessages?.(drainedTerminatingQueue, pendingMessages);
}
await emit({ type: "agent_end", messages: newMessages });
return;
}
if (drainedTerminatingQueue) {
await refreshTerminatingQueueDrain();
if (pendingMessages.length === 0) {
await emit({ type: "agent_end", messages: newMessages });
return;
}
}

if (
await config.shouldStopAfterTurn?.({
!toolBatchTerminated &&
(await config.shouldStopAfterTurn?.({
message,
toolResults,
context: currentContext,
newMessages,
})
}))
) {
await emit({ type: "agent_end", messages: newMessages });
return;
}

pendingMessages = (await config.getSteeringMessages?.()) || [];
if (!toolBatchTerminated) {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
}

// Agent would stop here. Check for follow-up messages.
Expand Down
27 changes: 26 additions & 1 deletion packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export interface AgentOptions {

class PendingMessageQueue {
private messages: AgentMessage[] = [];
private clearGeneration = 0;
public mode: QueueMode;

constructor(mode: QueueMode) {
Expand All @@ -137,6 +138,10 @@ class PendingMessageQueue {
return this.messages.length > 0;
}

getClearGeneration(): number {
return this.clearGeneration;
}

drain(): AgentMessage[] {
if (this.mode === "all") {
const drained = this.messages.slice();
Expand All @@ -152,8 +157,13 @@ class PendingMessageQueue {
return [first];
}

prepend(messages: AgentMessage[]): void {
this.messages = [...messages, ...this.messages];
}

clear(): void {
this.messages = [];
this.clearGeneration++;
}
}

Expand Down Expand Up @@ -434,6 +444,8 @@ export class Agent {

private createLoopConfig(options: { skipInitialSteeringPoll?: boolean } = {}): AgentLoopConfig {
let skipInitialSteeringPoll = options.skipInitialSteeringPoll === true;
let steeringQueueGeneration = this.steeringQueue.getClearGeneration();
let followUpQueueGeneration = this.followUpQueue.getClearGeneration();
return {
model: this._state.model,
reasoning: this._state.thinkingLevel === "off" ? undefined : this._state.thinkingLevel,
Expand Down Expand Up @@ -464,9 +476,22 @@ export class Agent {
skipInitialSteeringPoll = false;
return [];
}
steeringQueueGeneration = this.steeringQueue.getClearGeneration();
return this.steeringQueue.drain();
},
getFollowUpMessages: async () => this.followUpQueue.drain(),
getFollowUpMessages: async () => {
followUpQueueGeneration = this.followUpQueue.getClearGeneration();
return this.followUpQueue.drain();
},
restorePendingMessages: (queue, messages) => {
if (queue === "steering") {
if (this.steeringQueue.getClearGeneration() !== steeringQueueGeneration) return;
this.steeringQueue.prepend(messages);
return;
}
if (this.followUpQueue.getClearGeneration() !== followUpQueueGeneration) return;
this.followUpQueue.prepend(messages);
},
};
}

Expand Down
25 changes: 23 additions & 2 deletions packages/agent/src/harness/agent-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,8 @@ export class AgentHarness<
setTurnState: (turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => void,
): AgentLoopConfig {
const turnState = getTurnState();
let steeringQueueAtDrain = this.steerQueue;
let followUpQueueAtDrain = this.followUpQueue;
return {
model: turnState.model,
reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel,
Expand Down Expand Up @@ -459,8 +461,27 @@ export class AgentHarness<
thinkingLevel: nextTurnState.thinkingLevel,
};
},
getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode),
getSteeringMessages: async () => {
steeringQueueAtDrain = this.steerQueue;
return this.drainQueuedMessages(steeringQueueAtDrain, this.steeringQueueMode);
},
getFollowUpMessages: async () => {
followUpQueueAtDrain = this.followUpQueue;
return this.drainQueuedMessages(followUpQueueAtDrain, this.followUpQueueMode);
},
restorePendingMessages: async (queue, messages) => {
const target = queue === "steering" ? this.steerQueue : this.followUpQueue;
const drainedQueue = queue === "steering" ? steeringQueueAtDrain : followUpQueueAtDrain;
if (target !== drainedQueue) return;
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message?.role !== "user") {
throw new AgentHarnessError("invalid_state", "Only user messages can be restored to input queues");
}
target.unshift(message);
}
await this.emitQueueUpdate();
},
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
*/
getFollowUpMessages?: () => Promise<AgentMessage[]>;

/**
* Restores messages previously returned by a queue callback when next-turn preparation cannot continue.
* Restored messages must be placed before messages queued after the drain.
*/
restorePendingMessages?: (queue: "steering" | "followUp", messages: AgentMessage[]) => Promise<void> | void;

/**
* Tool execution mode.
* - "sequential": execute tool calls one by one
Expand Down
Loading
Loading