Module 2: Tool Use Fundamentals

3. bind_tools and the tool calling flow

Capsule overview

In the previous capsule you created tools with @tool and learned that the docstring, the type hints and the Pydantic schemas define what the model "sees" about each tool. But creating tools isn't enough — you need to connect them to the model. That connection happens with bind_tools(), the method that attaches the tool definitions to an LLM instance. Without bind_tools(), the model doesn't know the tools exist.

What bind_tools() does is subtle but critical: it takes your tools' schemas (name, description, parameters) and sends them as part of every request to the model. The model doesn't receive your Python code — it receives a JSON Schema describing which tools it has available. With that information, the model can decide whether it needs to call a tool, which one, and with which arguments. But there's a point you need to understand right now: the model never executes the tool. It only returns an instruction ("I want to call get_weather with city='Madrid'"). You execute it.

This capsule covers four fundamental concepts: bind_tools() and what happens internally when you connect tools, tool_choice to control when the model uses tools, the structure of tool_calls in the AIMessage so you can inspect the model's instructions, and the model↔tool contract that defines who does what. Mastering these four prepares you for capsule 04, where you'll implement the complete execution loop.


bind_tools: connecting tools to the model

The bind_tools() method

bind_tools() is a method available on every LangChain chat model that supports function calling (OpenAI, Anthropic, Google, etc.). It takes a list of tools and returns a new instance of the model that includes those tool definitions in every call.

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    climates = {
        "Madrid": "22°C, sunny",
        "Barcelona": "20°C, cloudy",
        "París": "15°C, rainy"
    }
    return f"Weather in {city}: {climates.get(city, '18°C, partly cloudy')}"

@tool
def calculator(expression: str) -> str:
    """Calculate a safe mathematical expression. Example: '15 * 23'"""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Error: {e}"

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator]

model_with_tools = model.bind_tools(tools)

After bind_tools(), you have two instances:

  • model — the base model, with no tools
  • model_with_tools — the model with tools attached

They're different instances. model will keep answering without tools. model_with_tools will include the schemas in every request.

What happens internally

When you call model.bind_tools(tools), LangChain:

  1. Extracts the schema of each tool (name, description, JSON Schema parameters)
  2. Serializes those schemas into the format the provider expects (OpenAI, Anthropic, etc.)
  3. Creates a new instance of the model with those schemas attached
  4. On every invoke(), the schemas travel as part of the HTTP request to the provider
# What the model receives internally (simplified OpenAI format):
# {
#   "tools": [
#     {
#       "type": "function",
#       "function": {
#         "name": "get_weather",
#         "description": "Get the current weather for a city.",
#         "parameters": {
#           "properties": { "city": {"type": "string"} },
#           "required": ["city"]
#         }
#       }
#     },
#     { ... calculator with its schema ... }
#   ]
# }

The model receives JSON Schema, not Python code. That's why the quality of your docstrings and type hints matters so much: they're the only information the model has to decide when and how to use each tool.

bind_tools doesn't modify the original model

An important detail: bind_tools() returns a new instance. It doesn't modify the original model.

model = init_chat_model("openai:gpt-4.1-mini")
model_v1 = model.bind_tools([get_weather])
model_v2 = model.bind_tools([get_weather, calculator])

# model    → no tools
# model_v1 → get_weather only
# model_v2 → get_weather + calculator

This lets you have multiple configurations of the same base model: one for search, another for calculations, another for combined tasks. Each one "sees" different tools.


tool_choice: controlling when the model uses tools

By default, the model decides whether to use tools or not. But sometimes you need more control: force it to always use some tool (extraction pipelines), force a specific one (testing), or let it decide (conversational agents). That's tool_choice.

The three modes

1. tool_choice="auto" (default)

The model freely decides whether to use tools or answer directly. It's the default behavior when you don't specify tool_choice.

model_auto = model.bind_tools(tools, tool_choice="auto")

# Equivalent to:
model_auto = model.bind_tools(tools)  # auto is the default

With auto, the model analyzes the user's message and decides:

  • If it needs external data → it calls one or more tools
  • If it can answer directly → it generates text with no tool_calls
from langchain_core.messages import HumanMessage

# The model DECIDES to call a tool
response = model_auto.invoke([HumanMessage(content="What's the weather in Madrid?")])
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': 'Madrid'}, 'id': 'call_abc123'}]

# The model DECIDES not to call a tool
response = model_auto.invoke([HumanMessage(content="What is Python?")])
print(response.tool_calls)
# []  ← empty, it answers directly
print(response.content)
# "Python is a programming language..."

2. tool_choice="any" (force at least one tool)

Forces the model to call at least one tool, no matter the message. The model picks which one, but it can't answer directly.

model_forced = model.bind_tools(tools, tool_choice="any")

# Even with a generic question, it MUST call some tool
response = model_forced.invoke([HumanMessage(content="Hi, how are you?")])
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]
# or [{'name': 'calculator', 'args': {'expression': '...'}, 'id': '...'}]

Useful for extraction pipelines (always extract data), testing (verifying tool_calls), and flows where the tool is mandatory.

3. tool_choice="tool_name" (force a specific tool)

Forces the model to call exactly that tool. It can't choose another one or answer directly.

model_weather_only = model.bind_tools(tools, tool_choice="get_weather")

# It will always call get_weather, no matter what the user asks
response = model_weather_only.invoke(
    [HumanMessage(content="What's 2+2?")]
)
print(response.tool_calls)
# [{'name': 'get_weather', 'args': {'city': '...'}, 'id': '...'}]

The model will try to fill in the arguments as best it can, even if the question has nothing to do with the tool.

Provider-specific format

The simplified syntax (tool_choice="get_weather") works in LangChain as an abstraction. Internally, each provider has its own native format, but LangChain translates it for you:

# LangChain syntax (recommended — works with every provider)
model.bind_tools(tools, tool_choice="get_weather")

# Native OpenAI format (also works, but less portable)
model.bind_tools(tools, tool_choice={
    "type": "function",
    "function": {"name": "get_weather"}
})

tool_choice="none"

Some providers support tool_choice="none", which disables tools even if they're bound:

model_no_tools = model.bind_tools(tools, tool_choice="none")
response = model_no_tools.invoke([HumanMessage(content="What's the weather in Madrid?")])
print(response.tool_calls)  # []  ← empty, even though it has tools bound

Inspecting tool_calls in an AIMessage

The structure of response.tool_calls

When the model decides to use a tool, the AIMessage contains a tool_calls list. Each element is a dictionary with three fields:

response = model_with_tools.invoke([HumanMessage(content="What's the weather in Madrid?")])

for tc in response.tool_calls:
    print(f"name: {tc['name']}")       # Name of the tool to call
    print(f"args: {tc['args']}")       # Arguments as a dict
    print(f"id:   {tc['id']}")         # Unique ID for this call

# Output:
# name: get_weather
# args: {'city': 'Madrid'}
# id:   call_abc123def456
FieldTypeDescription
namestrTool name (matches your function's name)
argsdictArguments parsed as a Python dictionary
idstrUnique ID generated by the provider. Required for the response ToolMessage

tool_call_id: why it matters

Each tool_call's id is a unique identifier that links the call to its result. When you run the tool and create a ToolMessage, you must include the tool_call_id so the model knows which result belongs to which call.

from langchain_core.messages import ToolMessage

# Run the tool
result = get_weather.invoke({"city": "Madrid"})

# Create the ToolMessage with the correct id
tool_msg = ToolMessage(
    content=result,
    tool_call_id=tc["id"]  # ← MUST match the tool_call's id
)

Without tool_call_id, the model can't associate the result with the original call. This is especially critical when there are multiple tool_calls in a single response.

Multiple tool_calls in one response

Modern models (GPT-4.1, Claude, Gemini) can generate multiple tool_calls in a single response. This is called parallel function calling:

response = model_with_tools.invoke(
    [HumanMessage(content="What's the weather in Madrid and what's 15*23?")]
)

print(len(response.tool_calls))  # 2

for tc in response.tool_calls:
    print(f"{tc['name']}({tc['args']}) → id: {tc['id']}")

# Output:
# get_weather({'city': 'Madrid'}) → id: call_abc123
# calculator({'expression': '15*23'}) → id: call_def456

Each tool_call has its own id. When you execute them, you create one ToolMessage per call:

tools_by_name = {t.name: t for t in tools}
messages = [HumanMessage(content="What's the weather in Madrid and what's 15*23?")]
messages.append(response)

for tc in response.tool_calls:
    tool_fn = tools_by_name[tc["name"]]
    result = tool_fn.invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

# messages now holds:
# [HumanMessage, AIMessage(2 tool_calls), ToolMessage(weather), ToolMessage(calc)]

When there are no tool_calls

If the model decides to answer directly (without calling tools), response.tool_calls is an empty list and response.content holds the text:

response = model_with_tools.invoke(
    [HumanMessage(content="What is an API?")]
)

print(response.tool_calls)  # []
print(response.content)     # "An API is a programming interface..."

This is the most natural stop condition: if tool_calls is empty, the agent decided it has enough information to answer.

content and tool_calls: can they coexist?

Yes, but it depends on the provider. OpenAI generally leaves content empty when there are tool_calls. Anthropic and Google can generate explanatory text and tool_calls in the same response. For your agent logic, the rule is simple: check tool_calls first. If there are tool_calls, process them. If not, use content.


The model ↔ tool contract

The fundamental principle

This is the most important concept in the capsule:

The model PROPOSES. The system EXECUTES. The model never runs code. It only generates instructions.

When the model returns tool_calls, it's saying: "I think I need to call this tool with these arguments." But it doesn't execute it. It has no access to your code, your network, your APIs. It can only generate structured text (JSON) that says what it wants to do.

You — the system, the runtime, the agent — are the one who:

  1. Receives the instruction (tool_calls)
  2. Verifies the tool exists
  3. Runs the tool with the arguments
  4. Packages the result as a ToolMessage
  5. Sends it back to the model so it can continue

The contract, drawn

┌──────────────────────────────────────────────────────────┐
│                  YOUR SYSTEM (Runtime)                   │
│                                                          │
│  ┌────────────┐    tool_calls     ┌──────────────────┐   │
│  │   TOOLS    │ ←──────────────── │      MODEL       │   │
│  │ (your code)│ ────────────────→ │  (external API)  │   │
│  └────────────┘    ToolMessage    └──────────────────┘   │
│                                                          │
│  The model has NO direct access to your tools.           │
│  It only generates instructions. You execute them.       │
└──────────────────────────────────────────────────────────┘

Why this contract exists

This separation isn't arbitrary. It has four reasons:

  • Security: If the model executed code directly, any prompt injection could run malicious code. By separating "deciding" from "executing," you can add validations and permissions between the two steps.
  • Control: You can intercept the model's instructions before running them. It wants to delete a file? You can ask for confirmation.
  • Observability: You can log every step: what the model decided, what ran, what result it got.
  • Testing: You can mock tool execution without needing the real model.

The complete flow, step by step

from langchain_core.messages import HumanMessage, ToolMessage

messages = [HumanMessage(content="What's the weather in Madrid?")]

# Step 1: The model REASONS and PROPOSES
response = model_with_tools.invoke(messages)
messages.append(response)

# Step 2: Inspect what the model is proposing
if response.tool_calls:
    for tc in response.tool_calls:
        print(f"Model proposes: {tc['name']}({tc['args']})")
        
        # Step 3: YOU EXECUTE (you can validate, log, reject)
        tool_fn = tools_by_name[tc["name"]]
        result = tool_fn.invoke(tc["args"])
        
        # Step 4: You send the result back to the model
        messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

# Step 5: The model REASONS again, now with the results
final_response = model_with_tools.invoke(messages)
print(final_response.content)
# "The weather in Madrid is 22°C and sunny."

Validation between proposal and execution

One of the benefits of the contract is that you can insert logic between the model's proposal and the execution:

ALLOWED_TOOLS = {"get_weather", "calculator"}

for tc in response.tool_calls:
    if tc["name"] not in ALLOWED_TOOLS:
        messages.append(ToolMessage(
            content=f"Error: tool '{tc['name']}' is not allowed.",
            tool_call_id=tc["id"]
        ))
        continue

    if tc["name"] == "calculator" and "import" in tc["args"].get("expression", ""):
        messages.append(ToolMessage(
            content="Error: expressions containing 'import' are not allowed.",
            tool_call_id=tc["id"]
        ))
        continue
    
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

This validation layer is invisible to the model — it only sees results or errors. But it gives you total control over what runs in your system.


Comparison: tool_choice auto vs any vs specific

Aspectautoany"tool_name"
The model can answer without toolsYesNoNo
The model chooses which tool to useYesYesNo (forced)
It can call no tool at allYesNoNo
Parallel tool callsYesYesOnly the forced tool
Typical use caseConversational agentsExtraction pipelinesTesting, deterministic flows
RiskIt may not use tools when it shouldIt may force an irrelevant toolForced, meaningless arguments
ConfigurationDefault, no need to specifytool_choice="any"tool_choice="get_weather"

The same prompt, compared

msg = [HumanMessage(content="Hi, how are you?")]

r_auto = model.bind_tools(tools, tool_choice="auto").invoke(msg)
print(f"auto → calls: {len(r_auto.tool_calls)}")  # 0 — answers directly

r_any = model.bind_tools(tools, tool_choice="any").invoke(msg)
print(f"any  → calls: {len(r_any.tool_calls)}, tool: {r_any.tool_calls[0]['name']}")
# 1, tool: get_weather (forced, meaningless)

r_spec = model.bind_tools(tools, tool_choice="calculator").invoke(msg)
print(f"spec → calls: {len(r_spec.tool_calls)}, tool: {r_spec.tool_calls[0]['name']}")
# 1, tool: calculator (forced, invented arguments)

Connection with the project

In this module's project (capsule 08)

This module's project is to build an agent with 5 real external tools. What you learned here applies directly:

  • You'll use bind_tools() to connect the 5 tools to the model
  • You'll use tool_choice="auto" so the model decides which tool is relevant for each question
  • You'll inspect tool_calls to log which tools the model is picking
  • You'll apply the model↔tool contract: the model proposes, your system executes

In capsule 04 (Tool execution loop)

The next capsule puts all of this inside a loop: invoke → tool_calls → execute → ToolMessage → invoke → final answer. This capsule is the content of each step; capsule 04 covers the structure of the loop.

In the evolving project (Modules 4-10)

ModuleHow this capsule's content is used
4bind_tools() inside StateGraph nodes
5tool_choice to force tools during planning stages
7bind_tools() with dynamic tools loaded from MCP servers
8Different agents with different bind_tools() (each one sees different tools)

Troubleshooting

Problem 1: tool_calls is empty when you expected a call

Cause: The model decided it can answer without tools. The prompt or the question wasn't clear enough for the model to consider the tool necessary.

Fix:

# Option 1: Force it with tool_choice
model_forced = model.bind_tools(tools, tool_choice="any")

# Option 2: Improve the tool's description
@tool
def get_weather(city: str) -> str:
    """Get the CURRENT, real-time weather for a city.
    Use this tool for any question about weather, temperature or forecast."""
    return f"Weather in {city}: 22°C"

# Option 3: Be more specific in the user's prompt
response = model_with_tools.invoke(
    [HumanMessage(content="I need the current weather in Madrid. Check the weather tool.")]
)

Problem 2: A ToolMessage without tool_call_id raises an error

Cause: You created a ToolMessage without the tool_call_id, or with the wrong id. The model (and the provider's API) need this id to associate the result with the call.

Fix:

# WRONG — tool_call_id is missing
msg = ToolMessage(content="22°C")  # Error: tool_call_id is required

# WRONG — made-up id
msg = ToolMessage(content="22°C", tool_call_id="fake_id")  # API error

# RIGHT — use the original tool_call's id
for tc in response.tool_calls:
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    msg = ToolMessage(content=result, tool_call_id=tc["id"])  # ✓

Problem 3: content is empty when there are tool_calls

Cause: This is normal with OpenAI. When the model generates tool_calls, it generally doesn't generate textual content. The content can be "" or None.

Fix: It's not an error. Your logic should check tool_calls first:

if response.tool_calls:
    # Process the tool calls (ignore the empty content)
    pass
elif response.content:
    # Direct answer from the model
    print(response.content)
else:
    print("Empty response — possible error")

Problem 4: The model calls a tool that isn't in tools_by_name

Cause: Rare but possible. The model can hallucinate tool names, especially with smaller models or confusing prompts.

Fix:

for tc in response.tool_calls:
    if tc["name"] not in tools_by_name:
        messages.append(ToolMessage(
            content=(
                f"Error: the tool '{tc['name']}' does not exist. "
                f"Available tools: {list(tools_by_name.keys())}"
            ),
            tool_call_id=tc["id"]
        ))
        continue
    
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

By returning the error as a ToolMessage, the model gets the feedback and can correct itself on the next iteration.

Problem 5: tool_choice with a tool name that isn't in the list

Cause: You specified tool_choice="search" but there's no tool with that name among the bound tools.

Fix:

tools = [get_weather, calculator]
tool_names = [t.name for t in tools]

desired = "search"
if desired not in tool_names:
    print(f"Error: '{desired}' is not in {tool_names}")
else:
    model.bind_tools(tools, tool_choice=desired)

Exercises

Exercise 1: bind_tools with multiple tools and check the tool_calls (Easy)

Create three tools: get_weather(city: str), calculator(expression: str), and get_time(timezone: str). Bind them to the model, invoke with "What time is it in Tokyo and what's 100/7?", and print every tool_call.

View solution
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°C"

@tool
def calculator(expression: str) -> str:
    """Calculate a mathematical expression. Example: '100/7'"""
    try:
        return str(round(eval(expression), 2))
    except Exception as e:
        return f"Error: {e}"

@tool
def get_time(timezone: str) -> str:
    """Get the current time in a timezone. Example: 'Asia/Tokyo'"""
    return f"Time in {timezone}: 14:30 JST"

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator, get_time]
model_with_tools = model.bind_tools(tools)

response = model_with_tools.invoke(
    [HumanMessage(content="What time is it in Tokyo and what's 100/7?")]
)

print(f"Total tool_calls: {len(response.tool_calls)}")
for tc in response.tool_calls:
    print(f"  → {tc['name']}({tc['args']}) [id: {tc['id'][:12]}...]")

# Expected output:
# Total tool_calls: 2
#   → get_time({'timezone': 'Asia/Tokyo'}) [id: call_abc123...]
#   → calculator({'expression': '100/7'}) [id: call_def456...]

Explanation: The model detects two independent sub-tasks and generates two tool_calls in parallel. It doesn't call get_weather because the question doesn't mention weather. This shows that with tool_choice="auto" the model selects only the relevant tools.

Exercise 2: Compare auto vs any vs specific (Easy)

Use the message "Hi, how are you?" with the three tool_choice options. Print how many tool_calls each one generates and the tool's name (if any).

View solution
msg = [HumanMessage(content="Hi, how are you?")]
tools = [get_weather, calculator]

for choice in ["auto", "any", "get_weather"]:
    r = model.bind_tools(tools, tool_choice=choice).invoke(msg)
    n = len(r.tool_calls)
    tool_name = r.tool_calls[0]["name"] if n > 0 else "none"
    print(f"tool_choice={choice:12s} → calls: {n}, tool: {tool_name}")

# Expected output:
# tool_choice=auto         → calls: 0, tool: none
# tool_choice=any          → calls: 1, tool: get_weather
# tool_choice=get_weather  → calls: 1, tool: get_weather

Explanation: With auto, the model doesn't need tools for a greeting. With any, it's forced to call some tool (it picks one arbitrarily). With "get_weather", that specific tool is forced. Notice how any and the specific choice generate meaningless tool_calls for a greeting — that's why auto is the right default for conversational agents.

Exercise 3: Implement validation between proposal and execution (Medium)

Write a function safe_execute(response, tools_by_name, allowed_tools) that runs the response's tool_calls but only if the tool name is in allowed_tools. For tools that aren't allowed, create a ToolMessage with an error message.

View solution
from langchain_core.messages import ToolMessage

def safe_execute(
    response,
    tools_by_name: dict,
    allowed_tools: set[str]
) -> list[ToolMessage]:
    """Run the tool_calls with permission checks."""
    results = []
    
    for tc in response.tool_calls:
        if tc["name"] not in allowed_tools:
            results.append(ToolMessage(
                content=f"Error: '{tc['name']}' is not allowed. "
                        f"Allowed tools: {sorted(allowed_tools)}",
                tool_call_id=tc["id"]
            ))
            continue
        
        if tc["name"] not in tools_by_name:
            results.append(ToolMessage(
                content=f"Error: '{tc['name']}' does not exist.",
                tool_call_id=tc["id"]
            ))
            continue
        
        try:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            results.append(ToolMessage(content=result, tool_call_id=tc["id"]))
        except Exception as e:
            results.append(ToolMessage(
                content=f"Error running '{tc['name']}': {str(e)}",
                tool_call_id=tc["id"]
            ))
    
    return results

# Usage
allowed = {"get_weather"}  # Allow weather only, not calculator
tool_messages = safe_execute(response, tools_by_name, allowed)

Explanation: This function implements the model↔tool contract with a security layer. The model can propose any tool, but your system decides which ones it allows to run. This is fundamental in production: never blindly execute what the model asks for.

Exercise 4: Detect parallel vs sequential tool_calls (Medium)

Write code that invokes the model with two different questions and determines whether the model generated tool_calls in parallel (multiple tool_calls in one response) or sequentially (one tool_call per response).

View solution
from langchain_core.messages import HumanMessage

def analyze_pattern(model_with_tools, prompt: str) -> str:
    """Analyze whether the model uses parallel or sequential tool calling."""
    response = model_with_tools.invoke([HumanMessage(content=prompt)])
    n = len(response.tool_calls)
    if n == 0:
        return "No tool calls — direct answer"
    elif n == 1:
        return f"Sequential (1 call): {response.tool_calls[0]['name']}"
    else:
        names = [tc['name'] for tc in response.tool_calls]
        return f"Parallel ({n} calls): {names}"

model_with_tools = model.bind_tools([get_weather, calculator, get_time])

for p in ["What's the weather in Madrid?", "What's the weather and what's 15*23?", "What is an API?"]:
    print(f"'{p}' → {analyze_pattern(model_with_tools, p)}")

# Expected output:
# 'What's the weather in Madrid?' → Sequential (1 call): get_weather
# 'What's the weather and what's 15*23?' → Parallel (2 calls): ['get_weather', 'calculator']
# 'What is an API?' → No tool calls — direct answer

Explanation: Modern models are good at spotting independent sub-tasks. When the sub-tasks don't depend on each other (weather and a calculation are independent), the model generates parallel tool_calls. That reduces loop iterations and improves latency. In capsule 04 you'll see how the loop executes these parallel calls.

Exercise 5: Build a mini-router with dynamic tool_choice (Hard)

Implement a function smart_invoke(user_input, model, tools) that analyzes the user's input and dynamically picks the most appropriate tool_choice: if the input contains "weather" or "temperature", force get_weather; if it contains "calculate" or "math", force calculator; otherwise, use "auto".

View solution
from langchain_core.messages import HumanMessage

def smart_invoke(user_input: str, model, tools: list) -> dict:
    """Invoke the model with a dynamic tool_choice based on the input."""
    input_lower = user_input.lower()
    tool_names = {t.name for t in tools}
    
    if any(kw in input_lower for kw in ["weather", "forecast", "temperature"]):
        choice = "get_weather" if "get_weather" in tool_names else "auto"
    elif any(kw in input_lower for kw in ["calculate", "math", "what's"]):
        choice = "calculator" if "calculator" in tool_names else "auto"
    else:
        choice = "auto"
    
    model_configured = model.bind_tools(tools, tool_choice=choice)
    response = model_configured.invoke([HumanMessage(content=user_input)])
    
    return {
        "tool_choice_used": choice,
        "tool_calls": response.tool_calls,
        "content": response.content,
    }

# Try it
tools = [get_weather, calculator]
for prompt in ["Weather in Barcelona?", "Calculate 256*3.14", "What is ML?", "Weather and calculate 10+5"]:
    r = smart_invoke(prompt, model, tools)
    tc_info = [f"{tc['name']}({tc['args']})" for tc in r["tool_calls"]]
    print(f"'{prompt[:35]}' → choice={r['tool_choice_used']}, calls={tc_info or 'direct'}")

Explanation: This exercise combines bind_tools() with a dynamic tool_choice. In a real system, the "routing" would use a more sophisticated classifier (another LLM, regex patterns, or embeddings), but the mechanics are the same: you analyze the input, decide the tool_choice, and configure the model before invoking. This pattern expands in Module 3 (Function Calling Patterns) with advanced routing.


Summary

In this capsule you learned:

  • bind_tools() connects tools to the model by creating a new instance that includes the JSON schemas in every request. It doesn't modify the original model.
  • tool_choice controls when the model uses tools: "auto" (it decides), "any" (force at least one), or "tool_name" (force a specific one).
  • response.tool_calls is a list of dictionaries with name, args, and id. The id is mandatory for the response ToolMessage.
  • The model↔tool contract is clear: the model proposes instructions (tool_calls), the system executes. The model never runs code directly.
  • Modern models support parallel function calling: multiple tool_calls in a single response.
  • You can insert validation and security between the model's proposal and the actual execution.

Next capsule: The complete tool execution loop — you'll implement the full loop that uses everything you learned here: invoke → inspect tool_calls → execute → ToolMessage → invoke → final answer. With stop conditions, max_iterations, and logging.


Additional resources

  1. LangChain Tool Calling Guide — LangChain's official guide to tool calling with bind_tools and tool_choice
  2. OpenAI Function Calling — OpenAI's documentation on function calling and tool_choice
  3. Anthropic Tool Use — How Claude implements tool calling and tool_choice
  4. LangChain ChatModel.bind_tools API — API reference for bind_tools
  5. Google Gemini Function Calling — Tool calling implementation in Gemini
  6. LangChain How to Return Tool Results — How to pass ToolMessages back to the model