Skip to content

Commit 90ea89b

Browse files
fix: resolve dangling tool_call_id when rejecting parallel tool calls
When two tools are called in parallel and one is interrupted via interruptOn, rejecting the interrupt would leave a dangling tool_call_id, causing the provider to throw a 400 error. The root cause was middleware ordering: createPatchToolCallsMiddleware ran before humanInTheLoopMiddleware, so the dangling tool call patch could be overwritten when HITL processed the reject decision. Fix by moving createPatchToolCallsMiddleware to run after humanInTheLoopMiddleware, ensuring it can properly patch any remaining dangling tool calls after HITL has processed resume decisions. Fixes #15
1 parent 0ef8362 commit 90ea89b

File tree

2 files changed

+105
-2
lines changed

2 files changed

+105
-2
lines changed

libs/deepagents/src/agent.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,6 @@ export function createDeepAgent<
194194
anthropicPromptCachingMiddleware({
195195
unsupportedModelBehavior: "ignore",
196196
}),
197-
// Patches tool calls to ensure compatibility across different model providers
198-
createPatchToolCallsMiddleware(),
199197
// Add memory middleware if memory sources provided
200198
...(memory != null && memory.length > 0
201199
? [
@@ -215,6 +213,14 @@ export function createDeepAgent<
215213
builtInMiddleware.push(humanInTheLoopMiddleware({ interruptOn }));
216214
}
217215

216+
// Patches tool calls to ensure compatibility across different model providers
217+
// IMPORTANT: This must run AFTER humanInTheLoopMiddleware so that when a tool call
218+
// is rejected, the dangling tool_call_id can be properly patched with a synthetic
219+
// ToolMessage. If patchToolCalls runs before HITL, the patch may be overwritten
220+
// when HITL processes the resume decision.
221+
// @ts-expect-error - builtInMiddleware is readonly
222+
builtInMiddleware.push(createPatchToolCallsMiddleware());
223+
218224
// Combine built-in middleware with custom middleware
219225
// The custom middleware is typed as TMiddleware to preserve type information
220226
const allMiddleware = [

libs/deepagents/src/middleware/hitl.int.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,103 @@ describe("Human-in-the-Loop (HITL) Integration Tests", () => {
131131
},
132132
);
133133

134+
it.concurrent(
135+
"should not leave dangling tool_call_id when rejecting an interrupted tool with parallel tool calls (issue #15)",
136+
{ timeout: 120000 },
137+
async () => {
138+
// Regression test for GitHub issue #15:
139+
// When two tools are called in parallel (one interrupted, one not),
140+
// rejecting the interrupted tool should not leave a dangling tool_call_id.
141+
// The provider would throw: "An assistant message with 'tool_calls' must be
142+
// followed by tool messages responding to each 'tool_call_id'."
143+
144+
const checkpointer = new MemorySaver();
145+
146+
// interrupted_tool requires approval, free_tool does not
147+
const interruptConfig: Record<string, boolean | InterruptOnConfig> = {
148+
sample_tool: true, // This one will be interrupted
149+
get_weather: false, // This one will run freely
150+
};
151+
152+
const agent = createDeepAgent({
153+
tools: [sampleTool, getWeather],
154+
interruptOn: interruptConfig,
155+
checkpointer,
156+
});
157+
158+
const config = { configurable: { thread_id: uuidv4() } };
159+
assertAllDeepAgentQualities(agent);
160+
161+
// First invocation - ask agent to call both tools in parallel
162+
// sample_tool will be interrupted, get_weather will run freely
163+
const result = await agent.invoke(
164+
{
165+
messages: [
166+
{
167+
role: "user",
168+
content:
169+
"Call both sample_tool AND get_weather for New York in parallel.",
170+
},
171+
],
172+
},
173+
config,
174+
);
175+
176+
// Check that both tools were called
177+
const agentMessages = result.messages.filter((msg: any) =>
178+
AIMessage.isInstance(msg),
179+
);
180+
const toolCalls = agentMessages.flatMap(
181+
(msg: any) => msg.tool_calls || [],
182+
);
183+
184+
expect(toolCalls.some((tc: any) => tc.name === "sample_tool")).toBe(true);
185+
expect(toolCalls.some((tc: any) => tc.name === "get_weather")).toBe(true);
186+
187+
// Check that we have an interrupt for sample_tool
188+
expect(result.__interrupt__).toBeDefined();
189+
expect(result.__interrupt__).toHaveLength(1);
190+
191+
const interrupts = result.__interrupt__?.[0].value as HITLRequest;
192+
expect(interrupts.actionRequests).toHaveLength(1);
193+
expect(interrupts.actionRequests[0].name).toBe("sample_tool");
194+
195+
// REJECT the interrupted tool call
196+
// This is the key scenario from issue #15 - rejecting should not leave
197+
// a dangling tool_call_id
198+
const result2 = await agent.invoke(
199+
new Command({
200+
resume: {
201+
decisions: [{ type: "reject" }],
202+
},
203+
}),
204+
config,
205+
);
206+
207+
// The agent should complete without errors (no dangling tool_call_id)
208+
expect(result2.__interrupt__).toBeUndefined();
209+
210+
// Check that we have tool results for get_weather (the non-interrupted tool)
211+
const toolResults = result2.messages.filter(
212+
(msg: any) => msg._getType() === "tool",
213+
);
214+
expect(toolResults.some((tr: any) => tr.name === "get_weather")).toBe(
215+
true,
216+
);
217+
218+
// The sample_tool should have a synthetic ToolMessage (cancelled/rejected)
219+
// or the tool call should be handled in some way that doesn't leave it dangling
220+
const sampleToolResult = toolResults.find(
221+
(tr: any) => tr.name === "sample_tool",
222+
);
223+
// Either there's a result for sample_tool (rejection message) or
224+
// the agent handled it properly without leaving dangling tool_call_id
225+
if (sampleToolResult) {
226+
expect(typeof sampleToolResult.content).toBe("string");
227+
}
228+
},
229+
);
230+
134231
it.concurrent(
135232
"should handle HITL with subagents",
136233
{ timeout: 120000 },

0 commit comments

Comments
 (0)