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 // Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) { while (true) {
let hasMoreToolCalls = true; let hasMoreToolCalls = true;
let steeringAfterTools: AgentMessage[] | null = null;
// Inner loop: process tool calls and steering messages // Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) { while (hasMoreToolCalls || pendingMessages.length > 0) {
@@ -204,9 +203,7 @@ async function runLoop(
const toolResults: ToolResultMessage[] = []; const toolResults: ToolResultMessage[] = [];
if (hasMoreToolCalls) { if (hasMoreToolCalls) {
const toolExecution = await executeToolCalls(currentContext, message, config, signal, emit); toolResults.push(...(await executeToolCalls(currentContext, message, config, signal, emit)));
toolResults.push(...toolExecution.toolResults);
steeringAfterTools = toolExecution.steeringMessages ?? null;
for (const result of toolResults) { for (const result of toolResults) {
currentContext.messages.push(result); currentContext.messages.push(result);
@@ -216,13 +213,7 @@ async function runLoop(
await emit({ type: "turn_end", message, toolResults }); await emit({ type: "turn_end", message, toolResults });
// Get steering messages after turn completes pendingMessages = (await config.getSteeringMessages?.()) || [];
if (steeringAfterTools && steeringAfterTools.length > 0) {
pendingMessages = steeringAfterTools;
steeringAfterTools = null;
} else {
pendingMessages = (await config.getSteeringMessages?.()) || [];
}
} }
// Agent would stop here. Check for follow-up messages. // Agent would stop here. Check for follow-up messages.
@@ -348,7 +339,7 @@ async function executeToolCalls(
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
emit: AgentEventSink, emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { ): Promise<ToolResultMessage[]> {
const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall");
if (config.toolExecution === "sequential") { if (config.toolExecution === "sequential") {
return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit); return executeToolCallsSequential(currentContext, assistantMessage, toolCalls, config, signal, emit);
@@ -363,12 +354,10 @@ async function executeToolCallsSequential(
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
emit: AgentEventSink, emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { ): Promise<ToolResultMessage[]> {
const results: ToolResultMessage[] = []; const results: ToolResultMessage[] = [];
let steeringMessages: AgentMessage[] | undefined;
for (let index = 0; index < toolCalls.length; index++) { for (const toolCall of toolCalls) {
const toolCall = toolCalls[index];
await emit({ await emit({
type: "tool_execution_start", type: "tool_execution_start",
toolCallId: toolCall.id, 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( async function executeToolCallsParallel(
@@ -417,13 +394,11 @@ async function executeToolCallsParallel(
config: AgentLoopConfig, config: AgentLoopConfig,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
emit: AgentEventSink, emit: AgentEventSink,
): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { ): Promise<ToolResultMessage[]> {
const results: ToolResultMessage[] = []; const results: ToolResultMessage[] = [];
const runnableCalls: PreparedToolCall[] = []; const runnableCalls: PreparedToolCall[] = [];
let steeringMessages: AgentMessage[] | undefined;
for (let index = 0; index < toolCalls.length; index++) { for (const toolCall of toolCalls) {
const toolCall = toolCalls[index];
await emit({ await emit({
type: "tool_execution_start", type: "tool_execution_start",
toolCallId: toolCall.id, toolCallId: toolCall.id,
@@ -437,21 +412,6 @@ async function executeToolCallsParallel(
} else { } else {
runnableCalls.push(preparation); 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) => ({ const runningCalls = runnableCalls.map((prepared) => ({
@@ -474,14 +434,7 @@ async function executeToolCallsParallel(
); );
} }
if (!steeringMessages && config.getSteeringMessages) { return results;
const steering = await config.getSteeringMessages();
if (steering.length > 0) {
steeringMessages = steering;
}
}
return { toolResults: results, steeringMessages };
} }
type PreparedToolCall = { type PreparedToolCall = {
@@ -661,22 +614,3 @@ async function emitToolCallOutcome(
await emit({ type: "message_end", message: toolResultMessage }); await emit({ type: "message_end", message: toolResultMessage });
return 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"]); 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 toolSchema = Type.Object({ value: Type.String() });
const executed: string[] = []; const executed: string[] = [];
const tool: AgentTool<typeof toolSchema, { value: string }> = { const tool: AgentTool<typeof toolSchema, { value: string }> = {
@@ -423,8 +423,8 @@ describe("agentLoop with AgentMessage", () => {
convertToLlm: identityConverter, convertToLlm: identityConverter,
toolExecution: "sequential", toolExecution: "sequential",
getSteeringMessages: async () => { getSteeringMessages: async () => {
// Return steering message after first tool executes // Return steering message after tool execution has started.
if (executed.length === 1 && !queuedDelivered) { if (executed.length >= 1 && !queuedDelivered) {
queuedDelivered = true; queuedDelivered = true;
return [queuedUserMessage]; return [queuedUserMessage];
} }
@@ -467,29 +467,28 @@ describe("agentLoop with AgentMessage", () => {
events.push(event); events.push(event);
} }
// Only first tool should have executed // Both tools should execute before steering is injected
expect(executed).toEqual(["first"]); expect(executed).toEqual(["first", "second"]);
// Second tool should be skipped
const toolEnds = events.filter( const toolEnds = events.filter(
(e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end", (e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
); );
expect(toolEnds.length).toBe(2); expect(toolEnds.length).toBe(2);
expect(toolEnds[0].isError).toBe(false); expect(toolEnds[0].isError).toBe(false);
expect(toolEnds[1].isError).toBe(true); expect(toolEnds[1].isError).toBe(false);
if (toolEnds[1].result.content[0]?.type === "text") {
expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message");
}
// Queued message should appear in events // Queued message should appear in events after both tool result messages
const queuedMessageEvent = events.find( const eventSequence = events.flatMap((event) => {
(e) => if (event.type !== "message_start") return [];
e.type === "message_start" && if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
e.message.role === "user" && if (event.message.role === "user" && typeof event.message.content === "string") {
typeof e.message.content === "string" && return [event.message.content];
e.message.content === "interrupt", }
); return [];
expect(queuedMessageEvent).toBeDefined(); });
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 // Interrupt message should be in context when second LLM call is made
expect(sawInterruptInContext).toBe(true); expect(sawInterruptInContext).toBe(true);