Langchain  

Custom LangChain Toolsets in Enterprise Multi-Agent RAG

The Limitation of Off-the-Shelf Tools

LangChain’s built-in tool ecosystem is impressive for prototyping. You get TavilySearchResults, PythonREPLTool, SQLDatabaseToolkit, and dozens more out of the box. But in enterprise multi-agent RAG systems, built-in tools consistently fail at three critical junctures:

  1. Domain Semantics: Built-in tools understand syntax (SQL, HTTP, file I/O) but not business meaning. A SQLDatabaseTool can execute SELECT * FROM orders WHERE status='pending', but it cannot distinguish between "pending regulatory approval" and "pending payment processing" when both map to the same enum value.

  2. Stateful Multi-Step Operations: Built-in tools are stateless request-response functions. Enterprise workflows require tools that maintain transactional context across multiple agent turns—like a compliance check that must reference the exact document version retrieved two steps earlier.

  3. Governance Boundaries: Built-in tools lack embedded policy enforcement. In regulated industries, every tool invocation must carry audit metadata, respect data classification labels, and enforce least-privilege access—none of which generic tools support natively.

This article demonstrates building custom LangChain toolsets that solve these gaps within a stateful LangGraph multi-agent RAG system. We use a real-world pharmaceutical adverse event reporting use case where built-in tools fundamentally cannot operate.

Real-Time Use Case: Pharmacovigilance Signal Detection

The Scenario

A global pharma company’s safety team receives 50,000+ adverse event reports monthly. Analysts must cross-reference individual case safety reports (ICSRs) against clinical trial protocols, FDA labeling databases, and internal signal detection models to determine if an emerging pattern constitutes a reportable safety signal.

Why Built-In Tools Fail Here

RequirementBuilt-In Tool GapCustom Tool Solution
Query MedDRA-coded events with semantic hierarchySQLDatabaseTool treats codes as flat strings; misses parent-child relationshipsMedDRAHierarchyTool encodes ontology traversal logic
Retrieve ICSR narratives with PHI redaction appliedRetrieverTool returns raw documents; no inline governanceGovernedDocumentRetriever enforces redaction policies at retrieval time
Check signal threshold against rolling 90-day windowStateless tools cannot maintain temporal aggregation stateSignalAccumulatorTool persists windowed counts in graph state
Submit FAERS report with audit trailNo built-in tool supports FDA ESG submission + provenance trackingFAERSSubmissionTool bundles payload, metadata, and chain-of-custody

Architecture Overview

384

Implementation: Custom Toolsets That Built-In Tools Cannot Replace

Step 1: Define State with Domain-Specific Fields

Built-in tools work with generic state. Our custom tools require typed domain state.

from typing import Annotated, List, Optional, Dict, Any
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.documents import Document
from datetime import datetime
import operator

class PharmaVigilanceState(TypedDict):
    messages: Annotated[list, add_messages]
    
    # Governed retrieval results with redaction metadata
    retrieved_icsrs: Annotated[List[Document], operator.add]
    
    # MedDRA preferred terms resolved from raw reports
    coded_events: List[Dict[str, str]]  # [{"raw": "headache", "pt_code": "10019211", "soc": "Nervous system disorders"}]
    
    # Rolling signal window maintained across turns
    signal_window: Dict[str, Any]  # {"drug": "X", "pt_codes": [...], "count_90d": int, "threshold": int}
    
    # Immutable audit trail for regulatory inspection
    audit_trail: Annotated[List[Dict[str, Any]], operator.add]
    
    # Current analyst query
    current_query: str
    
    # Final determination
    signal_determination: Optional[str]
    faers_submission_id: Optional[str]

Step 2: Custom Tool #1 — Governed Document Retriever

Why built-in RetrieverTool fails: It returns raw documents with no awareness of data classification, consent status, or redaction requirements. In pharmacovigilance, returning unredacted PHI is a HIPAA/GDPR violation.

from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from typing import Type
import re
import hashlib

class GovernedRetrievalInput(BaseModel):
    query: str = Field(description="Natural language search query for ICSR narratives")
    data_classification: str = Field(
        description="Required classification level: 'phi_redacted', 'de_identified', or 'internal_only'"
    )
    max_results: int = Field(default=5, ge=1, le=20)

class GovernedDocumentRetriever(BaseTool):
    """
    Retrieves ICSR documents with mandatory governance enforcement.
    Unlike built-in RetrieverTool, this:
    1. Applies PHI redaction BEFORE returning content
    2. Validates requester authorization against document classification
    3. Logs every retrieval to the audit trail with cryptographic hash
    4. Filters by consent status and retention policy
    """
    name: str = "governed_icsr_retriever"
    description: str = (
        "Retrieve adverse event case reports with automatic PHI redaction "
        "and compliance filtering. ALWAYS use this instead of generic retrieval "
        "for patient safety data. Requires data_classification parameter."
    )
    args_schema: Type[BaseModel] = GovernedRetrievalInput
    
    # Injected dependencies (not serializable, set at init)
    vectorstore: Any = None
    phi_redactor: Any = None
    audit_logger: Any = None
    user_context: Dict = None
    
    def _run(self, query: str, data_classification: str, max_results: int = 5) -> List[Document]:
        # 1. Authorization check
        allowed_classifications = self.user_context.get("allowed_classifications", [])
        if data_classification not in allowed_classifications:
            raise PermissionError(
                f"User lacks clearance for '{data_classification}'. "
                f"Allowed: {allowed_classifications}"
            )
        
        # 2. Retrieve with classification filter
        raw_docs = self.vectorstore.similarity_search(
            query, 
            k=max_results,
            filter={"classification": data_classification, "consent_status": "active"}
        )
        
        # 3. Apply PHI redaction
        governed_docs = []
        for doc in raw_docs:
            redacted_content, redaction_log = self.phi_redactor.redact(
                doc.page_content,
                policy=data_classification
            )
            doc.page_content = redacted_content
            doc.metadata["redaction_applied"] = True
            doc.metadata["redaction_count"] = len(redaction_log)
            doc.metadata["content_hash"] = hashlib.sha256(redacted_content.encode()).hexdigest()[:16]
            governed_docs.append(doc)
        
        # 4. Audit log
        self.audit_logger.log_event(
            event_type="document_retrieval",
            tool=self.name,
            query=query,
            classification=data_classification,
            result_count=len(governed_docs),
            content_hashes=[d.metadata["content_hash"] for d in governed_docs],
            user_id=self.user_context["user_id"]
        )
        
        return governed_docs
    
    async def _arun(self, *args, **kwargs):
        return self._run(*args, **kwargs)

Step 3: Custom Tool #2 — MedDRA Hierarchy Resolver

Why built-in search/tools fail: MedDRA is a hierarchical ontology with 5 levels (SOC → HLGT → HLT → PT → LLT). Searching for "cardiac arrhythmia" must also match child terms like "atrial fibrillation" and "ventricular tachycardia." No built-in tool understands biomedical ontologies.

class MedDRAQueryInput(BaseModel):
    raw_term: str = Field(description="Clinician-reported adverse event term (free text)")
    resolution_strategy: str = Field(
        default="hierarchical_expansion",
        description="'exact_match', 'hierarchical_expansion', or 'semantic_similarity'"
    )

class MedDRAHierarchyTool(BaseTool):
    """
    Resolves free-text adverse event terms to standardized MedDRA Preferred Terms
    with full hierarchical context. Built-in search tools treat medical terminology
    as flat strings and miss critical parent-child relationships needed for
    signal detection aggregation.
    """
    name: str = "meddra_hierarchy_resolver"
    description: str = (
        "Convert raw adverse event descriptions to standardized MedDRA codes "
        "with hierarchical expansion. Essential for accurate signal detection. "
        "Never use generic search for medical coding."
    )
    args_schema: Type[BaseModel] = MedDRAQueryInput
    
    meddra_ontology: Any = None  # Loaded at init from MedDRA dictionary files
    
    def _run(self, raw_term: str, resolution_strategy: str = "hierarchical_expansion") -> Dict:
        # Exact match first
        exact = self.meddra_ontology.find_exact(raw_term)
        
        if exact and resolution_strategy == "exact_match":
            return {"pt_code": exact.pt_code, "pt_name": exact.pt_name, 
                    "soc": exact.soc, "confidence": 1.0, "strategy": "exact"}
        
        # Hierarchical expansion: find all child PTs under matching HLT/SOC
        if resolution_strategy == "hierarchical_expansion":
            parent_node = self.meddra_ontology.find_best_parent(raw_term)
            if parent_node:
                child_pts = self.meddra_ontology.get_descendant_pts(parent_node.code)
                return {
                    "parent_code": parent_node.code,
                    "parent_name": parent_node.name,
                    "parent_level": parent_node.level,
                    "expanded_pt_codes": [pt.code for pt in child_pts],
                    "expanded_pt_names": [pt.name for pt in child_pts],
                    "total_expanded": len(child_pts),
                    "confidence": parent_node.match_score,
                    "strategy": "hierarchical"
                }
        
        # Fallback: semantic similarity using pre-computed MedDRA embeddings
        similar = self.meddra_ontology.semantic_search(raw_term, top_k=3)
        return {
            "candidates": [{"pt_code": s.code, "pt_name": s.name, "score": s.score} for s in similar],
            "confidence": similar[0].score if similar else 0.0,
            "strategy": "semantic_fallback"
        }
    
    async def _arun(self, *args, **kwargs):
        return self._run(*args, **kwargs)

Step 4: Custom Tool #3 — Stateful Signal Accumulator

Why built-in tools fail: Signal detection requires maintaining a rolling temporal window across multiple conversation turns. Built-in tools are stateless; they cannot accumulate evidence incrementally. This tool reads/writes directly to LangGraph state.

class SignalAccumulatorInput(BaseModel):
    drug_name: str = Field(description="Suspect drug product name")
    pt_codes: List[str] = Field(description="MedDRA PT codes to evaluate")
    window_days: int = Field(default=90, description="Rolling window in days")

class SignalAccumulatorTool(BaseTool):
    """
    Maintains rolling adverse event counts across agent turns.
    Unlike stateless built-in tools, this reads/writes to LangGraph state
    to preserve temporal aggregation context. This is impossible with
    standard LangChain tools because they have no access to graph state.
    """
    name: str = "signal_accumulator"
    description: str = (
        "Update and query rolling adverse event signal counts. "
        "Maintains state across conversation turns for temporal analysis. "
        "Use after MedDRA coding to evaluate signal thresholds."
    )
    args_schema: Type[BaseModel] = SignalAccumulatorInput
    
    # CRITICAL: This tool needs access to graph state
    # Injected via LangGraph's tool node binding
    state_accessor: Any = None  
    signal_db: Any = None
    
    def _run(self, drug_name: str, pt_codes: List[str], window_days: int = 90) -> Dict:
        from datetime import timedelta
        
        # Read current state
        current_window = self.state_accessor.get("signal_window", {})
        
        # Query signal database with temporal window
        end_date = datetime.utcnow()
        start_date = end_date - timedelta(days=window_days)
        
        count = self.signal_db.count_events(
            drug=drug_name,
            pt_codes=pt_codes,
            start_date=start_date,
            end_date=end_date
        )
        
        # Get regulatory threshold (varies by drug/event severity)
        threshold = self.signal_db.get_threshold(drug_name, pt_codes)
        
        # Update state for next turn
        new_window = {
            "drug": drug_name,
            "pt_codes": pt_codes,
            "window_start": start_date.isoformat(),
            "window_end": end_date.isoformat(),
            "count_90d": count,
            "threshold": threshold,
            "exceeds_threshold": count >= threshold,
            "updated_at": end_date.isoformat()
        }
        
        # Write back to state (this is what built-in tools CANNOT do)
        self.state_accessor.set("signal_window", new_window)
        
        return new_window
    
    async def _arun(self, *args, **kwargs):
        return self._run(*args, **kwargs)

🔑 Key Insight: The state_accessor pattern is what makes this truly custom. LangGraph allows injecting state references into tools at runtime. Built-in tools have no mechanism for this—they exist outside the graph's state lifecycle.

Step 5: Bind Custom Tools to LangGraph Agents

from langgraph.prebuilt import create_react_agent
from langgraph.graph import StateGraph, START, END

# Initialize custom tools with dependencies
governed_retriever = GovernedDocumentRetriever(
    vectorstore=chroma_pgvectordb,
    phi_redactor=PresidioRedactor(policy="hipaa_safe_harbor"),
    audit_logger=AuditLogger(connection_string="postgresql://..."),
    user_context={"user_id": "analyst-042", "allowed_classifications": ["phi_redacted", "de_identified"]}
)

meddra_tool = MedDRAHierarchyTool(
    meddra_ontology=MedDRAOntology.from_mdb("/opt/meddra/v27.0/")
)

signal_tool = SignalAccumulatorTool(
    signal_db=SignalDetectionDB(connection="postgresql://..."),
    state_accessor=None  # Injected at compile time below
)

custom_tools = [governed_retriever, meddra_tool, signal_tool]

# Create agents with custom toolsets
triage_agent = create_react_agent(
    model=ChatOpenAI(model="gpt-4o"),
    tools=[governed_retriever],
    prompt="You are a PV triage specialist. Retrieve and classify ICSRs..."
)

signal_agent = create_react_agent(
    model=ChatOpenAI(model="gpt-4o"),
    tools=[meddra_tool, signal_tool],
    prompt="You are a signal detection analyst. Code events and evaluate thresholds..."
)

# Build graph with state-aware tool injection
workflow = StateGraph(PharmaVigilanceState)

def signal_analysis_node(state: PharmaVigilanceState):
    """Inject state accessor into signal tool before execution."""
    # This is the critical bridge: give the tool access to current state
    signal_tool.state_accessor = StateAccessor(state)
    
    # Now invoke the agent with state-aware tools
    result = signal_agent.invoke({
        "messages": state["messages"],
        "coded_events": state["coded_events"]
    })
    return {"messages": result["messages"]}

workflow.add_node("triage", triage_agent)
workflow.add_node("signal_analysis", signal_analysis_node)
workflow.add_edge(START, "triage")
workflow.add_edge("triage", "signal_analysis")
workflow.add_edge("signal_analysis", END)

app = workflow.compile(checkpointer=MemorySaver())

Step 6: End-to-End Execution

config = {"configurable": {"thread_id": "pv-signal-2024-0892"}}

result = await app.ainvoke({
    "current_query": "Evaluate potential cardiac signal for DrugX based on recent ICSRs mentioning palpitations or irregular heartbeat",
    "messages": [],
    "retrieved_icsrs": [],
    "coded_events": [],
    "signal_window": {},
    "audit_trail": [],
    "signal_determination": None,
    "faers_submission_id": None
}, config=config)

# Inspect the governed retrieval
for doc in result["retrieved_icsrs"]:
    print(f"[{doc.metadata['content_hash']}] Redactions: {doc.metadata['redaction_count']}")
    print(doc.page_content[:200])

# Inspect accumulated signal state
print(f"\nSignal Window: {result['signal_window']}")
# {'drug': 'DrugX', 'pt_codes': ['10033781', '10003120'], 
#  'count_90d': 47, 'threshold': 35, 'exceeds_threshold': True}

# Verify audit trail completeness
print(f"\nAudit Events: {len(result['audit_trail'])}")
for event in result["audit_trail"]:
    print(f"  {event['timestamp']} | {event['event_type']} | {event['tool']}")

Comparative Analysis: Custom vs. Built-In

DimensionBuilt-In ToolsCustom Toolsets
Domain OntologyFlat string matchingHierarchical MedDRA traversal with expansion
Data GovernanceNone; returns raw contentInline PHI redaction + classification enforcement
State AccessImpossible; tools are statelessDirect read/write to LangGraph state via accessor
Audit ProvenanceExternal logging requiredCryptographic content hashing embedded in tool output
AuthorizationApplication-layer onlyTool-level permission checks with user context
Temporal AggregationRequires external database + manual plumbingNative rolling window maintained in graph state
Error SemanticsGeneric exceptionsDomain-specific errors (e.g., ConsentExpiredError)
TestingMock entire toolUnit test governance logic independently of LLM

When to Build Custom vs. Use Built-In

Use built-in tools when:

  • The operation is domain-agnostic (web search, math, code execution)

  • No governance/compliance requirements exist

  • Statelessness is acceptable

  • Prototyping or internal non-regulated use cases

Build custom tools when:

  • Domain ontology knowledge must be encoded (medical, legal, financial standards)

  • Data governance must be enforced at the tool level, not the application level

  • Tools must read/write to graph state across turns

  • Audit provenance must be cryptographically bound to tool outputs

  • Authorization depends on dynamic user context and document classification

  • Error handling requires domain-specific semantics

Production Considerations

  1. Tool Serialization: Custom tools with injected dependencies (vectorstore, meddra_ontology) cannot be serialized. Use factory patterns and inject dependencies at graph compile time, not definition time.

  2. State Accessor Safety: The state_accessor pattern bypasses LangGraph's immutability guarantees. Always return new state dicts from nodes rather than mutating in-place. The accessor should be read-only except through explicit set() methods that validate schema.

  3. Testing Strategy: Test custom tools in isolation with mocked dependencies. Then test tool-node integration with a minimal graph. Never rely solely on end-to-end LLM evaluation for governance-critical tools.

  4. Version Pinning: MedDRA dictionaries, redaction policies, and regulatory thresholds change. Version-pin all domain dependencies and include version metadata in audit trails.

  5. Fallback Chains: Always provide graceful degradation. If MedDRAHierarchyTool fails, fall back to semantic similarity. If governance checks fail, return explicit denial reasons rather than silent empty results.

Conclusion

Built-in LangChain tools are excellent primitives. But enterprise multi-agent RAG demands tools that understand domain semantics, enforce governance at the point of access, and participate in the graph's state lifecycle. These are not incremental improvements—they are categorical capabilities that built-in tools architecturally cannot provide. The pharmacovigilance system demonstrated here shows that custom toolsets are not about reinventing retrieval or search. They are about encoding institutional knowledge, regulatory requirements, and operational constraints directly into the agent's action space. When your agents can only act through tools that already embody your organization's rules, you move from "RAG that sometimes works" to "RAG that is correct by construction." The investment in custom toolsets pays dividends in reduced hallucination, simplified compliance audits, and the ability to reason over domain-specific structures that no general-purpose tool will ever understand. In enterprise AI, the tool layer is where domain expertise becomes executable.