Skip to content

Commit a5dfdb6

Browse files
fix(sdk): detect interrupt for Python agents (#2285)
1 parent 7176861 commit a5dfdb6

File tree

9 files changed

+202
-13
lines changed

9 files changed

+202
-13
lines changed

.changeset/flat-apricots-laugh.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@langchain/angular": patch
3+
"@langchain/react": patch
4+
"@langchain/svelte": patch
5+
"@langchain/vue": patch
6+
"@langchain/langgraph-sdk": patch
7+
---
8+
9+
fix(sdk): detect interrupt for Python agents

libs/sdk-angular/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,14 @@ export type {
598598
ToolCallFromTool,
599599
ToolCallsFromTools,
600600
} from "@langchain/langgraph-sdk";
601+
export type {
602+
HeadlessToolImplementation,
603+
AnyHeadlessToolImplementation,
604+
ToolEvent,
605+
HeadlessToolInterrupt,
606+
OnToolCallback,
607+
FlushPendingHeadlessToolInterruptsOptions,
608+
} from "@langchain/langgraph-sdk";
601609

602610
export {
603611
SubagentManager,
@@ -606,3 +614,13 @@ export {
606614
extractParentIdFromNamespace,
607615
isSubagentNamespace,
608616
} from "@langchain/langgraph-sdk/ui";
617+
export {
618+
isHeadlessToolInterrupt,
619+
parseHeadlessToolInterruptPayload,
620+
filterOutHeadlessToolInterrupts,
621+
findHeadlessTool,
622+
executeHeadlessTool,
623+
handleHeadlessToolInterrupt,
624+
headlessToolResumeCommand,
625+
flushPendingHeadlessToolInterrupts,
626+
} from "@langchain/langgraph-sdk";

libs/sdk-react/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export {
105105
} from "@langchain/langgraph-sdk/ui";
106106
export {
107107
isHeadlessToolInterrupt,
108+
parseHeadlessToolInterruptPayload,
108109
filterOutHeadlessToolInterrupts,
109110
findHeadlessTool,
110111
executeHeadlessTool,

libs/sdk-svelte/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,14 @@ export type {
456456
ToolCallFromTool,
457457
ToolCallsFromTools,
458458
} from "@langchain/langgraph-sdk";
459+
export type {
460+
HeadlessToolImplementation,
461+
AnyHeadlessToolImplementation,
462+
ToolEvent,
463+
HeadlessToolInterrupt,
464+
OnToolCallback,
465+
FlushPendingHeadlessToolInterruptsOptions,
466+
} from "@langchain/langgraph-sdk";
459467

460468
export {
461469
SubagentManager,
@@ -464,3 +472,13 @@ export {
464472
extractParentIdFromNamespace,
465473
isSubagentNamespace,
466474
} from "@langchain/langgraph-sdk/ui";
475+
export {
476+
isHeadlessToolInterrupt,
477+
parseHeadlessToolInterruptPayload,
478+
filterOutHeadlessToolInterrupts,
479+
findHeadlessTool,
480+
executeHeadlessTool,
481+
handleHeadlessToolInterrupt,
482+
headlessToolResumeCommand,
483+
flushPendingHeadlessToolInterrupts,
484+
} from "@langchain/langgraph-sdk";

libs/sdk-vue/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,14 @@ export type {
505505
ToolCallFromTool,
506506
ToolCallsFromTools,
507507
} from "@langchain/langgraph-sdk";
508+
export type {
509+
HeadlessToolImplementation,
510+
AnyHeadlessToolImplementation,
511+
ToolEvent,
512+
HeadlessToolInterrupt,
513+
OnToolCallback,
514+
FlushPendingHeadlessToolInterruptsOptions,
515+
} from "@langchain/langgraph-sdk";
508516

509517
export {
510518
SubagentManager,
@@ -513,3 +521,13 @@ export {
513521
extractParentIdFromNamespace,
514522
isSubagentNamespace,
515523
} from "@langchain/langgraph-sdk/ui";
524+
export {
525+
isHeadlessToolInterrupt,
526+
parseHeadlessToolInterruptPayload,
527+
filterOutHeadlessToolInterrupts,
528+
findHeadlessTool,
529+
executeHeadlessTool,
530+
handleHeadlessToolInterrupt,
531+
headlessToolResumeCommand,
532+
flushPendingHeadlessToolInterrupts,
533+
} from "@langchain/langgraph-sdk";

libs/sdk/src/headless-tools.ts

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type { Interrupt } from "./schema.js";
33
/**
44
* Represents a headless tool interrupt payload emitted by LangChain's
55
* schema-only `tool({ ... })` overload.
6+
*
7+
* Servers may serialize the nested tool call as `toolCall` (JS) or
8+
* `tool_call` (Python). Use {@link parseHeadlessToolInterruptPayload} to
9+
* normalize either shape before reading fields.
610
*/
711
export interface HeadlessToolInterrupt {
812
type: "tool";
@@ -13,6 +17,42 @@ export interface HeadlessToolInterrupt {
1317
};
1418
}
1519

20+
/**
21+
* Parses a headless-tool interrupt `value` from the graph. Accepts both
22+
* `toolCall` (LangChain JS) and `tool_call` (Python / JSON snake_case).
23+
*/
24+
export function parseHeadlessToolInterruptPayload(
25+
value: unknown
26+
): HeadlessToolInterrupt | null {
27+
if (typeof value !== "object" || value == null) {
28+
return null;
29+
}
30+
const v = value as Record<string, unknown>;
31+
if (v.type !== "tool") {
32+
return null;
33+
}
34+
35+
const rawTc = v.toolCall ?? v.tool_call;
36+
if (typeof rawTc !== "object" || rawTc == null) {
37+
return null;
38+
}
39+
const tc = rawTc as Record<string, unknown>;
40+
if (typeof tc.name !== "string") {
41+
return null;
42+
}
43+
44+
const id = typeof tc.id === "string" ? tc.id : undefined;
45+
46+
return {
47+
type: "tool",
48+
toolCall: {
49+
id,
50+
name: tc.name,
51+
args: tc.args,
52+
},
53+
};
54+
}
55+
1656
/**
1757
* Client-side implementation returned by `headlessTool.implement(...)`.
1858
*/
@@ -55,17 +95,7 @@ export function filterOutHeadlessToolInterrupts<T extends { value?: unknown }>(
5595
export function isHeadlessToolInterrupt(
5696
interrupt: unknown
5797
): interrupt is HeadlessToolInterrupt {
58-
if (typeof interrupt !== "object" || interrupt == null) {
59-
return false;
60-
}
61-
62-
const value = interrupt as Record<string, unknown>;
63-
return (
64-
value.type === "tool" &&
65-
typeof value.toolCall === "object" &&
66-
value.toolCall != null &&
67-
typeof (value.toolCall as Record<string, unknown>).name === "string"
68-
);
98+
return parseHeadlessToolInterruptPayload(interrupt) != null;
6999
}
70100

71101
export function findHeadlessTool<Args = unknown, Output = unknown>(
@@ -204,8 +234,10 @@ export function flushPendingHeadlessToolInterrupts(
204234
const defer = options.defer ?? ((run) => run());
205235

206236
for (const interrupt of interrupts as Interrupt[]) {
207-
if (!isHeadlessToolInterrupt(interrupt.value)) continue;
208-
const headlessInterrupt = interrupt.value;
237+
const headlessInterrupt = parseHeadlessToolInterruptPayload(
238+
interrupt.value
239+
);
240+
if (!headlessInterrupt) continue;
209241

210242
const interruptId = interrupt.id ?? headlessInterrupt.toolCall.id ?? "";
211243
if (handledIds.has(interruptId)) continue;

libs/sdk/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ export type {
7979
} from "./headless-tools.js";
8080
export {
8181
isHeadlessToolInterrupt,
82+
parseHeadlessToolInterruptPayload,
8283
filterOutHeadlessToolInterrupts,
8384
findHeadlessTool,
8485
executeHeadlessTool,

libs/sdk/src/react/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export {
8080
} from "../ui/subagents.js";
8181
export {
8282
isHeadlessToolInterrupt,
83+
parseHeadlessToolInterruptPayload,
8384
filterOutHeadlessToolInterrupts,
8485
findHeadlessTool,
8586
executeHeadlessTool,

libs/sdk/src/ui/hitl-interrupt-payload.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
filterOutHeadlessToolInterrupts,
77
flushPendingHeadlessToolInterrupts,
88
headlessToolResumeCommand,
9+
parseHeadlessToolInterruptPayload,
910
} from "../headless-tools.js";
1011

1112
async function flushMicrotasks(count = 4) {
@@ -142,6 +143,54 @@ describe("headless tool interrupt helpers", () => {
142143
]);
143144
});
144145

146+
it("treats Python snake_case tool_call as headless for filtering", () => {
147+
const interrupts = [
148+
{
149+
id: "tool-int",
150+
value: {
151+
type: "tool" as const,
152+
tool_call: {
153+
id: "call-1",
154+
name: "geolocation_get",
155+
args: { high_accuracy: null },
156+
},
157+
},
158+
},
159+
{
160+
id: "hitl-int",
161+
value: {
162+
action_requests: [
163+
{ action_name: "approve", args: {}, description: "" },
164+
],
165+
},
166+
},
167+
];
168+
169+
expect(filterOutHeadlessToolInterrupts(interrupts)).toEqual([
170+
interrupts[1],
171+
]);
172+
});
173+
174+
it("normalizes Python tool_call via parseHeadlessToolInterruptPayload", () => {
175+
expect(
176+
parseHeadlessToolInterruptPayload({
177+
type: "tool",
178+
tool_call: {
179+
id: "call_heTfkJwAH7gjuxHXMANzQKTJ",
180+
name: "geolocation_get",
181+
args: { high_accuracy: null },
182+
},
183+
})
184+
).toEqual({
185+
type: "tool",
186+
toolCall: {
187+
id: "call_heTfkJwAH7gjuxHXMANzQKTJ",
188+
name: "geolocation_get",
189+
args: { high_accuracy: null },
190+
},
191+
});
192+
});
193+
145194
it("builds a keyed resume command for tool call results", () => {
146195
expect(
147196
headlessToolResumeCommand({
@@ -237,4 +286,46 @@ describe("headless tool interrupt helpers", () => {
237286

238287
expect(resumeSubmit).not.toHaveBeenCalled();
239288
});
289+
290+
it("flushes headless tool interrupts serialized with Python tool_call", async () => {
291+
const handled = new Set<string>();
292+
const onTool = vi.fn();
293+
const resumeSubmit = vi.fn();
294+
295+
flushPendingHeadlessToolInterrupts(
296+
{
297+
__interrupt__: [
298+
{
299+
id: "py-headless",
300+
value: {
301+
type: "tool",
302+
tool_call: {
303+
id: "call-1",
304+
name: "get_location",
305+
args: { high_accuracy: false },
306+
},
307+
},
308+
},
309+
],
310+
},
311+
[
312+
{
313+
tool: { name: "get_location" },
314+
execute: async () => ({ latitude: 1, longitude: 2 }),
315+
},
316+
],
317+
handled,
318+
{ onTool, resumeSubmit }
319+
);
320+
321+
await flushMicrotasks();
322+
323+
expect(resumeSubmit).toHaveBeenCalledWith({
324+
resume: {
325+
"call-1": { latitude: 1, longitude: 2 },
326+
},
327+
});
328+
expect(handled.has("py-headless")).toBe(true);
329+
expect(onTool).toHaveBeenCalledTimes(2);
330+
});
240331
});

0 commit comments

Comments
 (0)