The Problem: Why Tool Calling Breaks in Production
In demos, LLM tool calling looks magical. In enterprise production, it is a liability surface. Models hallucinate parameter names, pass strings where integers are expected, omit required fields, or select the wrong tool entirely when user intent is ambiguous. Without validation, these failures cascade through multi-agent pipelines, corrupt RAG retrieval contexts, and pollute shared state. This article demonstrates a defense-in-depth architecture for robust tool calling within an enterprise LangGraph multi-agent RAG system. We will build a Financial Research Assistant that handles malformed inputs gracefully, maintains conversation memory, and coordinates specialized agents—all with validated tool boundaries.
Architecture Overview

Key Design Principles:
Validate at every boundary, not just at input
Tools are typed contracts, not free-form functions
State is explicit and versioned, not implicit globals
Failures are structured events, not unhandled exceptions
Memory is scoped, preventing cross-session contamination
Step 1: Define Typed Tool Contracts with Validation
The foundation of robustness is making tool schemas enforceable. We use Pydantic V2 models as single sources of truth for both documentation and runtime validation.
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Literal, Optional
from enum import Enum
class ReportType(str, Enum):
QUARTERLY = "quarterly"
ANNUAL = "annual"
ESG = "esg"
class FinancialQuery(BaseModel):
"""Schema-validated financial data retrieval request."""
model_config = ConfigDict(strict=True) # Reject type coercion
ticker: str = Field(
..., min_length=1, max_length=5,
pattern=r'^[A-Z]{1,5}$',
description="Stock ticker symbol (uppercase, 1-5 chars)"
)
report_type: ReportType = Field(
..., description="Type of financial report to retrieve"
)
fiscal_year: int = Field(
..., ge=2000, le=2026,
description="Fiscal year of the report"
)
section: Optional[str] = Field(
default=None, max_length=100,
description="Specific section to extract (e.g., 'risk_factors')"
)
@field_validator("ticker")
@classmethod
def normalize_ticker(cls, v: str) -> str:
"""Auto-correct common formatting issues instead of rejecting."""
cleaned = v.strip().upper().replace(".", "")
if len(cleaned) > 5:
raise ValueError(f"Ticker '{v}' exceeds 5 characters after normalization")
return cleaned
class CalculationRequest(BaseModel):
"""Validated financial metric computation."""
model_config = ConfigDict(strict=True)
metric: Literal["pe_ratio", "roe", "debt_to_equity", "free_cash_flow"] = Field(
..., description="Financial metric to compute"
)
ticker: str = Field(..., pattern=r'^[A-Z]{1,5}$')
fiscal_year: int = Field(..., ge=2000, le=2026)
compare_prior_year: bool = Field(
default=False, description="Include year-over-year comparison"
)
Why This Matters
| Failure Mode | Without Validation | With Pydantic Contract |
|---|---|---|
ticker: "aapl" (lowercase) | Silent wrong lookup or API error | Auto-normalized to "AAPL" |
fiscal_year: "2024" (string) | Type error deep in tool execution | Rejected at boundary OR coerced safely via strict=False |
Missing report_type | KeyError in downstream code | Clear ValidationError before LLM output is trusted |
metric: "profit_margin" (not in enum) | Wrong calculation or crash | Immediate rejection with valid options listed |
| Ambiguous natural language | Wrong tool selected silently | Supervisor re-routes based on schema mismatch feedback |
Step 2: Build Validated Tool Wrappers
Never expose raw functions to the LLM. Wrap every tool in a validation layer that catches errors before they reach business logic and returns structured error messages the LLM can self-correct from.
import json
from langchain_core.tools import tool
from pydantic import ValidationError
def validated_tool(schema: type[BaseModel]):
"""Decorator that enforces schema validation on tool calls."""
def decorator(func):
@tool(args_schema=schema)
def wrapper(**kwargs):
try:
# Validate AND normalize inputs
validated = schema.model_validate(kwargs)
# Pass clean, typed object to business logic
return func(validated)
except ValidationError as e:
# Return STRUCTURED error the LLM can reason about
errors = []
for err in e.errors():
loc = ".".join(str(l) for l in err["loc"])
errors.append(f"{loc}: {err['msg']}")
return {
"status": "validation_error",
"message": "Tool call failed validation. Please fix and retry.",
"errors": errors,
"expected_schema": schema.model_json_schema()
}
except Exception as e:
# Never leak stack traces; return actionable error
return {
"status": "execution_error",
"message": f"Tool execution failed: {type(e).__name__}",
"retry_suggestion": "Verify parameters and try again"
}
# Preserve original function metadata for LangChain
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
return decorator
@validated_tool(FinancialQuery)
def retrieve_financial_report(query: FinancialQuery) -> dict:
"""Retrieve a specific financial report section from the document store."""
# Business logic receives GUARANTEED valid input
# No defensive checks needed here
results = vector_store.similarity_search(
query=f"{query.ticker} {query.report_type.value} {query.fiscal_year} {query.section or ''}",
k=5,
filter={
"ticker": query.ticker,
"report_type": query.report_type.value,
"fiscal_year": query.fiscal_year
}
)
return {
"status": "success",
"ticker": query.ticker,
"chunks_retrieved": len(results),
"content": [doc.page_content for doc in results],
"metadata": [doc.metadata for doc in results]
}
@validated_tool(CalculationRequest)
def compute_financial_metric(request: CalculationRequest) -> dict:
"""Compute a financial metric with optional YoY comparison."""
# Guaranteed valid request object
data = financial_db.get_metrics(request.ticker, request.fiscal_year)
calculations = {
"pe_ratio": lambda d: d["price"] / d["eps"],
"roe": lambda d: d["net_income"] / d["shareholders_equity"],
"debt_to_equity": lambda d: d["total_debt"] / d["shareholders_equity"],
"free_cash_flow": lambda d: d["operating_cf"] - d["capex"],
}
result = {"metric": request.metric, "value": calculations[request.metric](data)}
if request.compare_prior_year:
prior = financial_db.get_metrics(request.ticker, request.fiscal_year - 1)
prior_value = calculations[request.metric](prior)
result["prior_year_value"] = prior_value
result["yoy_change_pct"] = round(
((result["value"] - prior_value) / prior_value) * 100, 2
)
return {"status": "success", **result}Critical Design Decision: Structured Error Returns
When validation fails, we return a dict with status, not raise an exception. This is intentional:
Exceptions break the LangGraph execution flow and require try/catch at every node
Structured errors become part of the agent's observable state
The LLM can read the error, understand what went wrong, and self-correct in the next iteration
Errors are logged, traced, and auditable as first-class events

Join the conversation! Your thoughts help the community grow.