fix(agent): defer steering until after tool execution

This commit is contained in:
Mario Zechner
2026-03-16 20:23:28 +01:00
parent 83378aad7e
commit 208a2cc123
2 changed files with 27 additions and 94 deletions

View File

@@ -167,7 +167,6 @@ async function runLoop(
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
@@ -204,9 +203,7 @@ async function runLoop(
const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
toolResults.push(...(await executeToolCalls(currentContext, message, config, signal, emit)));
for (const result of toolResults) {
currentContext.messages.push(result);
@@ -216,13 +213,7 @@ async function runLoop(
await emit({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
// Agent would stop here. Check for follow-up messages.
@@ -348,7 +339,7 @@ async function executeToolCalls(
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
): Promise<ToolResultMessage[]> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
if (config.toolExecution === "sequential") {
return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
@@ -363,12 +354,10 @@ async function executeToolCallsSequential(
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
): Promise<ToolResultMessage[]> {
const results: ToolResultMessage[] = [];
let steeringMessages: AgentMessage[] | undefined;
for (let index = 0; index < toolCalls.length; index++) {
const toolCall = toolCalls[index];
for (const toolCall of toolCalls) {
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
@@ -393,21 +382,9 @@ async function executeToolCallsSequential(
),
);
}
if (config.getSteeringMessages) {
const steering = await config.getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
const remainingCalls = toolCalls.slice(index + 1);
for (const skipped of remainingCalls) {
results.push(await skipToolCall(skipped, emit));
}
break;
}
}
}
return { toolResults: results, steeringMessages };
return results;
}
async function executeToolCallsParallel(
@@ -417,13 +394,11 @@ async function executeToolCallsParallel(
config: AgentLoopConfig,
signal: AbortSignal | undefined,
emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> {
): Promise<ToolResultMessage[]> {
const results: ToolResultMessage[] = [];
const runnableCalls: PreparedToolCall[] = [];
let steeringMessages: AgentMessage[] | undefined;
for (let index = 0; index < toolCalls.length; index++) {
const toolCall = toolCalls[index];
for (const toolCall of toolCalls) {
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
@@ -437,21 +412,6 @@ async function executeToolCallsParallel(
} else {
runnableCalls.push(preparation);
}
if (config.getSteeringMessages) {
const steering = await config.getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
for (const runnable of runnableCalls) {
results.push(await skipToolCall(runnable.toolCall, emit, { emitStart: false }));
}
const remainingCalls = toolCalls.slice(index + 1);
for (const skipped of remainingCalls) {
results.push(await skipToolCall(skipped, emit));
}
return { toolResults: results, steeringMessages };
}
}
}
const runningCalls = runnableCalls.map((prepared) => ({
@@ -474,14 +434,7 @@ async function executeToolCallsParallel(
);
}
if (!steeringMessages && config.getSteeringMessages) {
const steering = await config.getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
}
}
return { toolResults: results, steeringMessages };
return results;
}
type PreparedToolCall = {
@@ -661,22 +614,3 @@ async function emitToolCallOutcome(
await emit({ type: "message_end", message: toolResultMessage });
return toolResultMessage;
}
async function skipToolCall(
toolCall: AgentToolCall,
emit: AgentEventSink,
options?: { emitStart?: boolean },
): Promise<ToolResultMessage> {
const result = createErrorToolResult("Skipped due to queued user message.");
if (options?.emitStart !== false) {
await emit({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
});
}
return await emitToolCallOutcome(toolCall, result, true, emit);
}

View File

@@ -388,7 +388,7 @@ describe("agentLoop with AgentMessage", () => {
expect(toolResultIds).toEqual(["tool-1", "tool-2"]);
});
it("should inject queued messages and skip remaining tool calls", async () => {
it("should inject queued messages after all tool calls complete", async () => {
const toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = {
@@ -423,8 +423,8 @@ describe("agentLoop with AgentMessage", () => {
convertToLlm: identityConverter,
toolExecution: "sequential",
getSteeringMessages: async () => {
// Return steering message after first tool executes
if (executed.length === 1 && !queuedDelivered) {
// Return steering message after tool execution has started.
if (executed.length >= 1 && !queuedDelivered) {
queuedDelivered = true;
return [queuedUserMessage];
}
@@ -467,29 +467,28 @@ describe("agentLoop with AgentMessage", () => {
events.push(event);
}
// Only first tool should have executed
expect(executed).toEqual(["first"]);
// Both tools should execute before steering is injected
expect(executed).toEqual(["first", "second"]);
// Second tool should be skipped
const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
);
expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true);
if (toolEnds[1].result.content[0]?.type === "text") {
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
}
expect(toolEnds[1].isError).toBe(false);
// Queued message should appear in events
const queuedMessageEvent = events.find(
(e) =>
e.type === "message_start" &&
e.message.role === "user" &&
typeof e.message.content === "string" &&
e.message.content === "interrupt",
);
expect(queuedMessageEvent).toBeDefined();
// Queued message should appear in events after both tool result messages
const eventSequence = events.flatMap((event) => {
if (event.type !== "message_start") return [];
if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
if (event.message.role === "user" && typeof event.message.content === "string") {
return [event.message.content];
}
return [];
});
expect(eventSequence).toContain("interrupt");
expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
// Interrupt message should be in context when second LLM call is made
expect(sawInterruptInContext).toBe(true);