feat(supervisor): Supervisor with RemoteGraph assistants (#1604)

Co-authored-by: Tat Dat Duong <david@duong.cz>
This commit is contained in:
MartijnLeplae
2025-09-11 12:01:50 +02:00
committed by GitHub
parent f6bb40e5e0
commit 19b4f3d5fa
3 changed files with 143 additions and 14 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@langchain/langgraph-supervisor": patch
---
Add support for RemoteGraph agents in supervisor (#1461)
+51 -14
View File
@@ -1,6 +1,9 @@
import { LanguageModelLike } from "@langchain/core/language_models/base";
import { StructuredToolInterface, DynamicTool } from "@langchain/core/tools";
import { RunnableToolLike } from "@langchain/core/runnables";
import type {
RunnableConfig,
RunnableToolLike,
} from "@langchain/core/runnables";
import { InteropZodType } from "@langchain/core/utils/types";
import {
START,
@@ -20,6 +23,8 @@ import {
BaseChatModel,
BindToolsInput,
} from "@langchain/core/language_models/chat_models";
import type { RemoteGraph } from "@langchain/langgraph/remote";
import { v5 as uuidv5 } from "uuid";
import { createHandoffTool, createHandoffBackMessages } from "./handoff.js";
export type { AgentNameMode };
@@ -58,6 +63,14 @@ function isChatModelWithParallelToolCallsParam(
return llm.bindTools.length >= 2;
}
function isRemoteGraph(agent: unknown): agent is RemoteGraph {
if (agent == null || typeof agent !== "object") return false;
if (!("lc_id" in agent)) return false;
if (!Array.isArray(agent.lc_id)) return false;
return agent.lc_id.join(".") === "langgraph.pregel.RemoteGraph";
}
const makeCallAgent = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
agent: any, // TODO: agent should not be `any`
@@ -71,8 +84,23 @@ const makeCallAgent = (
);
}
return async (state: Record<string, unknown>) => {
const output = await agent.invoke(state);
return async (state: Record<string, unknown>, config?: RunnableConfig) => {
let conf = config;
if (isRemoteGraph(agent)) {
const threadId = config?.configurable?.thread_id;
const agentThreadId =
threadId && agent.name ? uuidv5(agent.name, threadId) : null;
conf = {
...(config ?? {}),
configurable: {
...(config?.configurable ?? {}),
...{ thread_id: agentThreadId },
},
};
}
const output = await agent.invoke(state, conf);
let { messages } = output;
if (outputMode === "last_message") {
@@ -95,13 +123,16 @@ export type CreateSupervisorParams<
/**
* List of agents to manage
*/
agents: CompiledStateGraph<
AnnotationRootT["State"],
AnnotationRootT["Update"],
string,
AnnotationRootT["spec"],
AnnotationRootT["spec"]
>[];
agents: (
| CompiledStateGraph<
AnnotationRootT["State"],
AnnotationRootT["Update"],
string,
AnnotationRootT["spec"],
AnnotationRootT["spec"]
>
| RemoteGraph
)[];
/**
* Language model to use for the supervisor
@@ -323,9 +354,15 @@ const createSupervisor = <
agentNames.add(agent.name);
}
const handoffTools = agents.map(({ name, description }) =>
createHandoffTool({ agentName: name!, agentDescription: description })
);
const handoffTools = agents.map((agent) => {
const agentName = agent.name!;
const agentDescription =
"description" in agent && typeof agent.description === "string"
? agent.description
: undefined;
return createHandoffTool({ agentName, agentDescription });
});
const allTools = [...(tools ?? []), ...handoffTools];
let supervisorLLM = llm;
@@ -389,7 +426,7 @@ const createSupervisor = <
builder = builder.addNode(
agent.name!,
makeCallAgent(agent, outputMode, addHandoffBackMessages, supervisorName),
{ subgraphs: [agent] }
{ subgraphs: isRemoteGraph(agent) ? undefined : [agent] }
);
builder = builder.addEdge(agent.name!, supervisorAgent.name!);
}
@@ -0,0 +1,87 @@
import { describe, it, expect } from "vitest";
import { HumanMessage, AIMessage } from "@langchain/core/messages";
import { v5 as uuidv5 } from "uuid";
import { RemoteGraph } from "@langchain/langgraph/remote";
import { createReactAgent, ToolNode } from "@langchain/langgraph/prebuilt";
import { createSupervisor } from "../supervisor.js";
import { FakeToolCallingChatModel } from "./utils.js";
class FakeRemoteGraph extends RemoteGraph {
public receivedThreadIds: Array<string | undefined> = [];
get lc_id(): string[] {
return ["langgraph", "pregel", "RemoteGraph"];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
override async invoke(_state: any, config?: any) {
this.receivedThreadIds.push(config?.configurable?.thread_id);
return { messages: [new AIMessage({ content: "remote result" })] };
}
}
describe("Supervisor with RemoteGraph agents", () => {
it("propagates per-agent thread_id and exposes handoff tool description", async () => {
const ROOT_THREAD_ID = "123e4567-e89b-12d3-a456-426614174000";
// Supervisor will transfer to the remote agent, then produce a final answer
const supervisorMessages = [
new AIMessage({
content: "",
tool_calls: [
{
name: "transfer_to_remote_expert",
args: {},
id: "call_remote_handoff",
type: "tool_call",
},
],
}),
new AIMessage({ content: "done" }),
];
const supervisorModel = new FakeToolCallingChatModel({
responses: supervisorMessages,
});
// Prepare RemoteGraph agent
const remote = new FakeRemoteGraph({ graphId: "dummy" });
(remote as unknown as { name?: string }).name = "remote_expert";
(remote as unknown as { description?: string }).description =
"Remote expert doing remote things.";
// Build supervisor workflow
const workflow = createSupervisor({
agents: [remote],
llm: supervisorModel,
prompt: "You are a supervisor managing a remote expert.",
});
// Assert handoff tool includes remote description
const toolNode = (
workflow.nodes.supervisor.runnable as ReturnType<typeof createReactAgent>
).nodes.tools.bound as ToolNode;
expect(toolNode.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: "transfer_to_remote_expert",
description: "Remote expert doing remote things.",
}),
])
);
// Compile and invoke with a root thread id
const app = workflow.compile();
const result = await app.invoke(
{ messages: [new HumanMessage({ content: "hi" })] },
{ configurable: { thread_id: ROOT_THREAD_ID } }
);
expect(result).toBeDefined();
// Verify per-agent thread id derivation is passed to the RemoteGraph
const expectedAgentThreadId = uuidv5("remote_expert", ROOT_THREAD_ID);
expect(remote.receivedThreadIds[0]).toBe(expectedAgentThreadId);
});
});