Skip to content

Commit 5644684

Browse files
author
Dmitrii Troitskii
committed
fix(langchain): deduplicate interrupt parts matching existing tool calls
When an AI message already contains tool_calls that match the interrupt's actionRequests (standard HITL pattern), skip adding duplicate dynamic-tool parts. Deduplication uses both toolCallId and toolName:input composite key.
1 parent 67f649f commit 5644684

File tree

2 files changed

+68
-1
lines changed

2 files changed

+68
-1
lines changed

packages/langchain/src/adapter.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2801,6 +2801,55 @@ describe('stateSnapshotToUIMessages', () => {
28012801
});
28022802
});
28032803

2804+
it('should deduplicate interrupt parts matching existing tool calls', () => {
2805+
const snapshot = {
2806+
values: {
2807+
messages: [
2808+
new HumanMessage({ content: 'Send email', id: 'h-1' }),
2809+
new AIMessage({
2810+
content: '',
2811+
id: 'ai-1',
2812+
tool_calls: [
2813+
{
2814+
id: 'call-email-1',
2815+
name: 'send_email',
2816+
args: { to: 'user@example.com' },
2817+
},
2818+
],
2819+
}),
2820+
],
2821+
},
2822+
tasks: [
2823+
{
2824+
id: 'task-1',
2825+
name: 'agent',
2826+
interrupts: [
2827+
{
2828+
value: {
2829+
action_requests: [
2830+
{
2831+
name: 'send_email',
2832+
arguments: { to: 'user@example.com' },
2833+
id: 'call-email-1',
2834+
},
2835+
],
2836+
},
2837+
},
2838+
],
2839+
},
2840+
],
2841+
};
2842+
const result = stateSnapshotToUIMessages(snapshot);
2843+
2844+
// Should have only one dynamic-tool part, not duplicated
2845+
const toolParts = result[1].parts.filter(p => p.type === 'dynamic-tool');
2846+
expect(toolParts).toHaveLength(1);
2847+
expect(toolParts[0]).toMatchObject({
2848+
toolCallId: 'call-email-1',
2849+
toolName: 'send_email',
2850+
});
2851+
});
2852+
28042853
it('should ignore tasks without interrupts', () => {
28052854
const snapshot = {
28062855
values: {

packages/langchain/src/adapter.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,25 @@ export function stateSnapshotToUIMessages(
957957
};
958958
uiMessages.push(lastAssistant);
959959
}
960-
lastAssistant.parts.push(...interruptParts);
960+
// Deduplicate: skip interrupt parts that already exist on the assistant
961+
// (e.g., when the AI message's tool_calls match the interrupt's actionRequests)
962+
const existingToolCallIds = new Set<string>();
963+
const existingToolParts = new Set<string>();
964+
for (const part of lastAssistant.parts) {
965+
if (part.type === 'dynamic-tool') {
966+
existingToolCallIds.add(part.toolCallId);
967+
existingToolParts.add(`${part.toolName}:${JSON.stringify(part.input)}`);
968+
}
969+
}
970+
for (const interruptPart of interruptParts) {
971+
const toolCallKey = `${interruptPart.toolName}:${JSON.stringify(interruptPart.input)}`;
972+
if (
973+
!existingToolCallIds.has(interruptPart.toolCallId) &&
974+
!existingToolParts.has(toolCallKey)
975+
) {
976+
lastAssistant.parts.push(interruptPart);
977+
}
978+
}
961979
}
962980

963981
return uiMessages;

0 commit comments

Comments
 (0)