Defining Tools & Dynamic Pydantic Schemas
Bridging Reasoning with the Outside World
Without tools, Large Language Models are isolated mathematical reasoning engines trapped in their training snapshot. They cannot check live database records, call third-party payment APIs, query Kubernetes clusters, or execute calculations with certainty. Tools provide LLMs with the agency to interact with external systems.
In modern LangChain (v0.3+), tool definition is built on strict data contracts. A tool is not simply a Python function: it is a typed schema specification, an executable callable, and a documentation contract that tells the model exactly when and how to invoke the action.
The Mechanics of Tool Calling
When you attach tools to a chat model using model.bind_tools([...]), the following sequence occurs:
- LangChain inspects each tool's Pydantic schema or type hints and converts them into standardized JSON Schema specifications.
- The model provider (such as OpenAI or Anthropic) accepts these schemas during the API request.
- If the user query requires external data, the model halts normal text generation and outputs an
AIMessagecontaining atool_callsarray with function names and validated JSON arguments. - Your runtime executes the corresponding code and returns a
ToolMessagecontaining the output string.
+-----------------------------------------------------------------------+
| TOOL CALLING EXECUTION LOOP |
| |
| +-----------------+ |
| | User Request | |
| +--------+--------+ |
| | |
| +------------v------------+ |
| | ChatModel.bind_tools() | |
| +------------+------------+ |
| | |
| [Decides to call tool?] |
| / \ |
| No / \ Yes |
| v v |
| +---------+ +---------------------+ |
| | Text | | AIMessage with | |
| | Answer | | tool_calls payload | |
| +---------+ +----------+----------+ |
| | |
| +----------v----------+ |
| | Execute Local Code | |
| +----------+----------+ |
| | |
| +----------v----------+ |
| | Return ToolMessage | |
| | with output content | |
| +----------+----------+ |
| | |
| +----------v----------+ |
| | Model Synthesizes | |
| | Final Response | |
| +---------------------+ |
+-----------------------------------------------------------------------+
Defining Tools: @tool vs StructuredTool
LangChain provides two primary ways to declare tools:
- The @tool Decorator: The most ergonomic approach for standard functions. It derives tool names, descriptions, and argument schemas directly from Python function names, type hints, and docstrings.
- StructuredTool.from_function: Provides granular control. Use this when you want to supply a custom Pydantic
args_schema, attach asynchronous runners (coroutine), or inject dynamic runtime parameters that the LLM should not see.
Dynamic Parameter Injection with InjectedToolArg
In secure enterprise environments, the LLM should never be trusted to decide critical security parameters (such as the authenticated user_id or tenant_id). If an LLM is asked to fetch user records, allowing it to supply the user_id invites lateral privilege escalation attacks. Modern LangChain solves this with InjectedToolArg: the parameter is hidden from the JSON schema passed to the model, and injected automatically by the runtime execution engine.
Production Implementation: Modern Python (LangChain v0.3+)
Below is a production implementation defining tools with strict Pydantic argument schemas, docstring descriptions, and dynamic runtime parameter injection:
# requirements: langchain-core langchain-openai pydantic
import asyncio
from typing import Optional, Literal
from typing_extensions import Annotated
from pydantic import BaseModel, Field
from langchain_core.tools import tool, InjectedToolArg, StructuredTool
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
# 1. Define strict Pydantic argument schema
class DatabaseQueryInput(BaseModel):
table_name: Literal["orders", "subscriptions", "invoices"] = Field(
description="Target database table to inspect"
)
filter_field: str = Field(description="Column name to filter against, e.g. status or order_id")
filter_value: str = Field(description="Exact value to filter on")
limit: Optional[int] = Field(default=5, ge=1, le=50, description="Maximum rows to retrieve (1-50)")
# 2. Tool using InjectedToolArg to prevent LLM tenant manipulation
@tool("query_tenant_records", args_schema=DatabaseQueryInput)
def query_tenant_records(
table_name: str,
filter_field: str,
filter_value: str,
limit: int = 5,
# Hidden from LLM schema: injected directly from authenticated session
authenticated_tenant_id: Annotated[str, InjectedToolArg] = "CORP-DEFAULT",
) -> str:
"""Query isolated tenant business records from the relational datastore."""
# Simulated secure multi-tenant SQL execution
sql = f"SELECT * FROM {table_name} WHERE tenant_id = '{authenticated_tenant_id}' AND {filter_field} = '{filter_value}' LIMIT {limit};"
return f"Simulated Query Result for Tenant [{authenticated_tenant_id}]: Found 2 matching rows in '{table_name}' using SQL: {sql}"
# 3. Arithmetic tool with clear docstring formatting
@tool
def calculate_runway_months(current_cash_usd: float, monthly_burn_usd: float) -> str:
"""Calculate company runway in months given current cash reserves and net monthly burn rate."""
if monthly_burn_usd <= 0:
return "Error: Monthly burn must be greater than zero to calculate runway."
months = current_cash_usd / monthly_burn_usd
return f"Calculated Runway: {months:.1f} months before zero-cash date."
# 4. Attach tools to model
model = ChatOpenAI(model="gpt-4o", temperature=0.0)
tools = [query_tenant_records, calculate_runway_months]
model_with_tools = model.bind_tools(tools)
# 5. Execute invocation and inspect tool call payload
async def main():
print("--- TESTING MODEL TOOL CALL SELECTION ---")
query = "Our current treasury balance is $4,800,000 and we burn $320,000 every month. What is our runway?"
response = await model_with_tools.ainvoke([HumanMessage(content=query)])
print("Model Content:", response.content or "(Empty text - Tool Call Requested)")
print("Tool Calls Emitted:")
for call in response.tool_calls:
print(f" Tool Name: {call['name']}")
print(f" Arguments: {call['args']}")
print(f" Call ID: {call['id']}")
if __name__ == "__main__":
asyncio.run(main())
TypeScript Equivalent (LangChain.js v0.3+)
Defining typed tools with Zod in LangChain.js:
import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { ChatOpenAI } from "@langchain/openai";
// 1. Define tool with Zod schema
const runwayTool = tool(
async ({ cash, burn }) => {
const months = cash / burn;
return "Calculated Runway: " + months.toFixed(1) + " months remaining.";
},
{
name: "calculate_runway",
description: "Calculates remaining operational runway in months.",
schema: z.object({
cash: z.number().describe("Current cash balance in USD"),
burn: z.number().describe("Net monthly burn rate in USD"),
}),
}
);
// 2. Bind tool to model
const model = new ChatOpenAI({ model: "gpt-4o", temperature: 0 }).bindTools([runwayTool]);
async function run() {
const response = await model.invoke("We have 1.2M in the bank and spend 150k monthly. How long will we last?");
console.log("TypeScript Tool Calls:", response.tool_calls);
}
run();
Security & Design Rules for Production Tools
- Detailed Docstrings Are Mandatory: The model chooses which tool to invoke solely based on your description. If your docstrings are vague, the model will either fail to invoke the tool or invoke it with hallucinated parameters.
- Validate All Inputs Server-Side: Never assume the arguments passed by the model are safe. Always run them through strict Pydantic schemas or database parameter bindings to eliminate SQL injection or command injection risks.
- Keep Return Payloads Concise: Do not return raw 10MB JSON responses from API tools. Summarize or truncate payloads before returning them in the
ToolMessageto protect model context budgets.
Security Directive
Never allow an LLM to supply authorization headers or tenant keys. Inject sensitive tenant scope variables server-side using InjectedToolArg or session context wrappers.