How to dynamically bind a tool to the ToolNode

Hello. I am trying to write a test case of dynamic execution sequence like the following:
What I want is, the planner_node firstly create the execution sequence, then in execute_node, the LLM parses the single step and create a tool call message, the message is passed to a ToolNode and the tool is invoked. In the real program, there are many tools, I want to dynamically search them and bind them to ToolNode. How to do that ? Anyone has some idea ? Thanks.

from langchain_core.tools import tool
from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.prebuilt import ToolNode
from pydantic import BaseModel, Field 
from typing import List 
from typing_extensions import Annotated
import operator
from model import create_model 

@tool 
def funca(a:int) -> int:
    '''
    A simple test tool, raise ValueError if a negative int is received, return `a+1` otherwise.
    Args:
        a: int parameter. 
    '''
    if a < 0:
        raise ValueError("Input value must be non-negative.")
    else:
        return a+1

@tool 
def funcb(content:str) -> str:
    '''
    A simple test tool, just add `Hello ` ahead of the `content`.
    Args:
        content: input content.
    '''
    return "Hello " + content 


# Here create a chat model.
model = create_model()

class Sequence(BaseModel):
    steps: List[str] = Field(description="The descriptions of execution steps. For example ['call `funca` with argument `10`', 'call `funcb` with argument `'Test'`'] ", default_factory=list)

class MyState(BaseModel):
    user_prompt:str = ""
    step_i: int = 0
    steps: List[str]
    results: Annotated[List[str], operator.add]

def planner_node(state: MyState):
    system_prompt = ''' You are a planner agent to create the execution sequence. 
There are two available execution steps:

- funca(a:int): raise ValueError if `a` is negative, return `a+1` otherwise.
- funcb(content:str): just return `'Hello ' + content`.

You should extracted the sequence of steps from the user's prompt. An example:

User: "run funca with 10, run funcb with 'World', at last run funca with -10."
Returned sequence: ["call funca with argument `10`", "call funcb with argument `'World'`", "call funca with `-10`"]
    '''
    steps = model.with_structured_output(Sequence).invoke([SystemMessage(content=system_prompt), 
                                                   HumanMessage(content=state.user_prompt)])
    return {
        "steps": steps
    }

def execute_node(state:MyState):
    if state.step_i < len(state.steps):
        pass 


Hi! If I’ve got it right: you have a plan-and-execute graph. planner_node looks at the user’s request and the available tools and produces an ordered sequence; execute_node then runs that sequence and returns the final result (or an error if a step fails). The sticking point is how to get the right tool into the ToolNode when there are many of them.

The way I would think about this is to have the planner node return a list of structured tool calls. That way you can hand it directly to the ToolNode, which can look up each of the tools by name and run it.

Here’s what an example might look like:

import operator
from typing import Any, List
from typing_extensions import Annotated

from pydantic import BaseModel, Field
from langchain_core.tools import tool, render_text_description_and_args
from langchain_core.messages import SystemMessage, HumanMessage, ToolCall
from langgraph.graph import StateGraph, START, END
from langgraph.prebuilt import ToolNode

from model import create_model

# --- tools -------------------------------------------------------------
@tool
def funca(a: int) -> int:
    """Return a + 1; raise ValueError if a is negative."""
    if a < 0:
        raise ValueError("Input value must be non-negative.")
    return a + 1

@tool
def funcb(content: str) -> str:
    """Prepend 'Hello ' to content."""
    return "Hello " + content

TOOLS = [funca, funcb]                       # ...add the rest here
ALL_TOOLS = {t.name: t for t in TOOLS}       # registry, if you ever need lookup by name

# One ToolNode holding every tool. It only runs the tool each call names, so
# there's no per-step "binding" to do. handle_tool_errors=True turns a raising
# tool (e.g. funca(-10)) into an error ToolMessage instead of crashing the graph.
tool_node = ToolNode(TOOLS, handle_tool_errors=True)

model = create_model()

# --- schemas -----------------------------------------------------------
class ToolCallStep(BaseModel):
    name: str = Field(description="Exact name of the tool to call")
    args: dict[str, Any] = Field(
        description="Keyword arguments for the tool, matching its schema"
    )

class Sequence(BaseModel):
    steps: List[ToolCallStep] = Field(default_factory=list)

class MyState(BaseModel):
    user_prompt: str = ""
    steps: List[ToolCallStep] = Field(default_factory=list)
    results: Annotated[List[str], operator.add] = Field(default_factory=list)

# --- nodes -------------------------------------------------------------
def planner_node(state: MyState):
    # Give the planner the exact tool names, signatures, and arg schemas so it
    # can fill in valid `name`/`args`. render_text_description_and_args renders,
    # e.g.: "funca(a: int) - Return a + 1..., args: {'a': {'type': 'integer'}}"
    system_prompt = (
        "You are a planner. Produce an ordered list of tool calls that "
        "fulfills the user's request, using only these tools:\n\n"
        f"{render_text_description_and_args(TOOLS)}\n\n"
        "For each step return the tool's exact `name` and an `args` object "
        "whose keys match that tool's arguments."
    )
    plan = model.with_structured_output(Sequence).invoke([
        SystemMessage(content=system_prompt),
        HumanMessage(content=state.user_prompt),
    ])
    return {"steps": plan.steps}   # note: plan.steps, not the Sequence object

def execute_node(state: MyState):
    results: List[str] = []
    for i, step in enumerate(state.steps):
        # Build a proper tool call and hand it straight to the ToolNode.
        call: ToolCall = {
            "name": step.name,
            "args": step.args,
            "id": str(i),
            "type": "tool_call",
        }
        (msg,) = tool_node.invoke([call])    # list-of-calls in -> list of ToolMessages out
        results.append(msg.content)
        if msg.status == "error":            # stop and return the failure
            break
    return {"results": results}

# --- graph -------------------------------------------------------------
builder = StateGraph(MyState)
builder.add_node("planner", planner_node)
builder.add_node("execute", execute_node)
builder.add_edge(START, "planner")
builder.add_edge("planner", "execute")
builder.add_edge("execute", END)
graph = builder.compile()

graph.invoke({"user_prompt": "run funca with 10, then funcb with 'World'"})

A couple of notes:

  • render_text_description_and_args(TOOLS) is what lets the planner see each tool’s name, signature, and argument schema — that’s how it produces valid name/args. (For a huge tool set, retrieve a candidate subset first and render only those.)
  • A ToolCall is just {"name", "args", "id", "type": "tool_call"}. Passing a list of these to ToolNode bypasses message parsing entirely — no AIMessage, no model — and returns a list of ToolMessages, each with .content and .status.
  • If a step names a tool that isn’t registered, ToolNode returns an error ToolMessage rather than throwing — so bad names surface the same way as tool failures.

Docs for reference:

hi @MSJavaScript

You do not actually need to bind tools to a ToolNode dynamically. ToolNode is a name-indexed dispatcher, not a model binding. In its __init__ it builds a tools_by_name dict and at runtime it executes only the tools named in the incoming tool calls - every other registered tool is inert and costs nothing, because ToolNode never talks to the model. So the simplest correct approach is: register the full registry in one ToolNode and let dispatch by name do the selection.

The second piece is your plan format. Steps as free text like call funca with argument 10 force you to re-parse prose in execute_node. Have the planner emit structured tool calls instead, then feed them straight to the ToolNode using its direct tool call input format (documented in the ToolNode docstring as input format 3), which bypasses message parsing entirely:

TOOL_REGISTRY = {t.name: t for t in [funca, funcb]}
tool_node = ToolNode(list(TOOL_REGISTRY.values()), handle_tool_errors=True)

class ToolCallStep(BaseModel):
    name: str = Field(description="Exact tool name")
    args: dict = Field(default_factory=dict)

class Sequence(BaseModel):
    steps: List[ToolCallStep] = Field(default_factory=list)

def planner_node(state: MyState):
    catalog = render_text_description_and_args(list(TOOL_REGISTRY.values()))
    system_prompt = f"You are a planner. Available tools:\n{catalog}\nReturn the sequence of tool calls."
    seq = model.with_structured_output(Sequence).invoke(
        [SystemMessage(content=system_prompt), HumanMessage(content=state.user_prompt)]
    )
    return {"steps": seq.steps}   # note: seq.steps, not the Sequence object

def execute_node(state: MyState):
    step = state.steps[state.step_i]
    [tool_message] = tool_node.invoke([{
        "name": step.name,
        "args": step.args,
        "id": f"call_{state.step_i}",
        "type": "tool_call",
    }])
    return {
        "step_i": state.step_i + 1,
        "results": [f"{step.name} -> [{tool_message.status}] {tool_message.content}"],
    }

render_text_description_and_args (from langchain_core.tools.render) generates the tool catalog for the planner prompt, so it never drifts from the registry. Also validate step.name in TOOL_REGISTRY before executing to catch hallucinated names.

About the many-tools concern - split it into two layers:

  1. execution layer (ToolNode): no selection needed, dict lookup handles any number of tools. If tools are genuinely discovered at runtime (MCP server, database), ToolNode is a plain Runnable with cheap construction, so you can build one on the fly inside the node: ToolNode(search_tools(step.name), handle_tool_errors=True).invoke([...]). Nothing about ToolNode is compiled into the graph, only the node function is.

  2. model layer: this is where too many tools actually hurt (context overload, wrong-tool errors). Do retrieval over tool descriptions in planner_node and render only the shortlist into the prompt, while the single ToolNode keeps the full superset for execution. If you move to the high-level create_agent API, this is built in as middleware: wrap_model_call with request.override(tools=shortlist) for filtering pre-registered tools, plus wrap_tool_call for tools registered at runtime. See Tools - Docs by LangChain

Regarding your code:

  1. planner_node returns the Sequence object into a List field - with a Pydantic state schema this fails validation. Return steps.steps.
  2. your own example ends with funca(-10), which raises ValueError. The default handle_tool_errors only converts invocation errors (invalid args from the model) into a ToolMessage and re-raises real execution errors, so your graph would crash. Pass handle_tool_errors=True (or an exception type / callable) to get a ToolMessage with status=error instead, then decide in your routing whether an errored step aborts the plan.
  3. steps and results in MyState have no defaults, so the initial graph.invoke input must include them - give both default_factory=list.

@pawel-twardziak Thanks for your detailed answer. I combined your answer and dariel.datonn’s answer, here are my resulting code .

Because I have to determine the tool call at execution phase, I use a LLM call to extract the tool call information. But there is still a problem, **tool call id is empty**, do I have to assign an id manually ?

 from langchain_core.messages import SystemMessage, HumanMessage
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool, render_text_description_and_args
from langchain.chat_models import BaseChatModel
from langgraph.graph import StateGraph, START, END

from pydantic import BaseModel, Field 
from typing import List 
from typing_extensions import Annotated
import operator
from model import create_response_model 

@tool 
def funca(a:int) -> int:
    '''
    Return a + 1; raise ValueError if a is negative.
    '''
    if a < 0:
        raise ValueError("Input value must be non-negative.")
    else:
        return a+1

@tool 
def funcb(content:str) -> str:
    '''
    Prepend 'Hello ' to content.
    '''
    return "Hello " + content 

TOOLS = [funca, funcb]                       # ...add the rest here

tool_node = ToolNode(TOOLS, handle_tool_errors=True)

model:BaseChatModel = create_response_model()
# In the real program, each entry of `steps` will be the content of a subtask.
# For example, ["create a geometry file for COMSOL with script gemo.py", "submit the job."]
# It may be executed directly by a tool or some composite steps which need further 
# planning (in this case, execution flow will goto the planner again). 
class Sequence(BaseModel):
    steps: List[str] = Field(description="The descriptions of execution steps. For example ['call `funca` with argument `10`', 'call `funcb` with argument `'Test'`'] ", default_factory=list)

# As mentioned above, some steps need replanning. So I need a counter variable `step_i`
# to tell where the execution flow reaches.
class MyState(BaseModel):
    user_prompt:str = ""
    step_i: int = 0
    steps: List[str] = Field(default_factory=list)
    results: Annotated[List[str], operator.add] = Field(default_factory=list)

def planner_node(state: MyState):
    # In real program, agent won't know the information of all the tools.
    system_prompt = (
        "You are a planner agent to create the execution sequence. Only the following tools are available:\n\n"
        f"{render_text_description_and_args(TOOLS)}\n\n"
        "You should extracted the sequence of steps from the user's prompt. An example:\n\n"
        "User: \"run funca with 10, run funcb with 'World', at last run funca with -10.\"\n"
        "Returned sequence: [\"call funca with argument `10`\", \"call funcb with argument `'World'`\", \"call funca with `-10`\"]"
    )
    
    response = model.with_structured_output(Sequence).invoke([SystemMessage(content=system_prompt), 
                                                   HumanMessage(content=state.user_prompt)])
    return {
        "steps": response.steps
    }


def execute_node(state:MyState):
    if state.step_i >= len(state.steps): # do nothing
        return {"step_i": state.step_i} 
    
    # In the real program, each entry in `MyState.steps` may have a `need_further_planning` field
    # If it is `True`, the flow will be sent to planner_node again.
    content = state.steps[state.step_i]
    print(content)
    
    # content = "Generate geometry file for COMSOL by gemo.py"
    # tools = search_tools(content) # discovery tools dynamically
    # tool invoke format cannot be determined directly in planner node.
    # Here I generate a tool call message by LLM.

    system_prompt = "Call the tool according to the prompt."
    response = model.bind_tools(TOOLS).invoke([SystemMessage(system_prompt), HumanMessage(content)])
    tool_msg = tool_node.invoke(response.tool_calls)

    return {
        "step_i": state.step_i + 1,
        "results": [tool_msg['messages'][0].content]
    }
    

def should_continue(state:MyState):
    if state.step_i < len(state.steps):
        return "execute"
    else:
        return END

workflow = StateGraph(MyState)
workflow.add_node("planner", planner_node)
workflow.add_node("execute", execute_node)

workflow.add_edge(START, "planner")
workflow.add_edge("planner", "execute")
workflow.add_conditional_edges("execute", should_continue)

graph = workflow.compile()

response = graph.invoke({"user_prompt": "run funca with 100, funcb with 'Checker', funca with 123, run funca with -10, at last run funcb with 'Hello'. "})

print(response)


hi @MSJavaScript

what is your LLM provider?

The LLM provider is Qwen-3.5 plus. I run the code again, `id` is not empty in this run. Maybe I mistaken something. Anyway, thanks again.

Great. If you need any further help, please open a new thread :slight_smile: