EQTY LabDocs

Plain LangChain

Route LangChain code that isn't a graph, such as a chain or your own loop: describe the agent, bind each chat model, and what binding records and refuses.

View as Markdown

For LangChain code that isn’t a graph, such as a chain or your own loop, describe the agent with guardian.agent(), which takes LangChain chat models and tools as they are, then bind() each chat model it uses. A graph is better described by describe_graph. It needs the langchain extra (Choose an integration).

python
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI

import guardian_agent as ga

INSTRUCTIONS = "You research questions using the internal docs."


@tool
def search_docs(query: str) -> list[str]:
    """Search the internal documentation."""
    return []


# Describe the agent with the chat model and tools themselves, so they match
llm = ChatOpenAI(model="gpt-4.1", temperature=0)
researcher = ga.Guardian().agent(
    "researcher", model=llm, instructions=INSTRUCTIONS, tools=[search_docs]
)

# Bind first, then add tools: every call the model makes is signed
model = researcher.bind(llm).bind_tools([search_docs])

messages = [SystemMessage(INSTRUCTIONS), HumanMessage("Summarise the release notes.")]
reply = model.invoke(messages)
while reply.tool_calls:
    messages.append(reply)
    for call in reply.tool_calls:
        result = search_docs.invoke(call["args"])
        messages.append(ToolMessage(str(result), tool_call_id=call["id"]))
    reply = model.invoke(messages)
print(reply.content)

bind() takes only a model the agent’s definition describes exactly: the same provider, model and settings, or for a sub-agent’s route, one of the sub-agent’s models. Its requests are signed under that definition, so a model it doesn’t describe would be signed as a configuration it isn’t. The simplest way to match is to describe the agent with the model you bind, as above.

As with any model client you call yourself, sending the instructions and tools the definition records is your code’s job: the example sends both (Send what the definition says). A bound model checks its settings on each request, but not its prompts or tools: one bound with a tool the definition doesn’t list still sends it.

What binding routes

  • ChatOpenAI and ChatAnthropic, each returned as a copy; your model is left as it was.
  • Every call the copy makes: sync or async, streamed or not, the Responses API, and through bind_tools() and with_structured_output(). Bind the model before those, not after.
  • Settings passed per call are checked as each request is built, so a value such as invoke(..., temperature=0.9), model.bind(temperature=...) or a configurable field that sends settings the definition doesn’t describe is refused before it’s sent. Retries and timeouts set on the model aren’t sent, and are free. A timeout passed per call goes into the request, and is refused.

Bind every chat model the agent calls, including any you give to middleware (SummarizationMiddleware(model=...), say): a model that isn’t bound sends its requests unsigned (Calls the SDK can’t see).

How a model is described

  • By its provider and model, and the settings that change its answers, read from the request LangChain builds for it: temperature, top p, top k, max tokens, stop sequences, seed, frequency and presence penalties, reasoning effort and summary, Anthropic’s thinking, effort and betas, and OpenAI’s service tier. A request may carry fewer of the recorded betas, since ChatAnthropic adds some only with the tools that need them, but no others.
  • Left out: settings that don’t change answers (store, logprobs, user and the like), and never its key, other headers, clients, or the retries and timeouts set on the model.
  • A model that sends anything else is refused, naming the setting: one a definition can’t record (verbosity, n, logit_bias, truncation, a beta header in default_headers, any default_query). For Anthropic’s beta header, give the model betas=[...] instead. model_kwargs and extra_body are read, so a setting given through them is recorded like any other, and one a definition can’t record is refused, naming model_kwargs or extra_body rather than the key. A call can’t give betas in extra_body: Anthropic’s client sends them as a header, so the body wouldn’t show what’s sent. Its requests would otherwise be signed under a definition that doesn’t describe them.
  • ChatAnthropic always sends max_tokens, its own default if you set none, so it’s recorded. A hand-written ga.Model("anthropic", "claude-sonnet-5") without max_tokens doesn’t match it, and bind() refuses it: describe the agent with the model itself.

A LangChain tool is described as the model sees it: its name, description and input schema, with a fingerprint of the code it runs. A decorator’s wrapper around the tool’s function or coroutine runs too, so it’s fingerprinted with the function it wraps, each layer in order, when the decorator records what it wraps (as functools.wraps does). A tool class is fingerprinted with the classes of yours it inherits from. Code from LangChain, LangGraph and Deep Agents, such as an MCP adapter’s or a wrapper of theirs, isn’t fingerprinted, and neither is Python’s own or LangSmith’s (its tracing, such as @traceable): a function is judged by the module it’s defined in, not the one functools.wraps copies. Other libraries’ code is, so the fingerprint changes with their versions.

What binding refuses

With what to do instead:

  • A model the definition doesn’t describe exactly, one that sends a setting no definition can record, or one aimed at the proxy’s address with the wrong path: ValueError, naming the settings, never their values.
  • A call that adds or removes tools in its system content, as Anthropic’s tool_addition and tool_removal blocks do: ValueError, before it’s sent, whatever betas the model records. Give the agent its tools when it’s described.
  • A model aimed somewhere other than its provider’s API or the proxy, such as a custom base_url, OPENAI_BASE_URL or Azure: ga.ModelNotRoutedThroughProxy.
  • A model already wrapped by bind_tools(), with_structured_output(), with_retry(), with_fallbacks() or with_config(), or made with init_chat_model(..., configurable_fields=...), which builds a new model on every call: TypeError. Bind the model, then wrap it.
  • A ChatOpenAI given its own client or async_client, which bind() can’t route: TypeError. Give it http_client or http_async_client instead.
  • A model name, or a chat model other than ChatOpenAI and ChatAnthropic: TypeError. A name such as "openai:gpt-4.1" is resolved inside LangChain, where bind() can’t reach it, so build the model yourself (init_chat_model("openai:gpt-4.1")) and bind that.

A chat model that takes an httpx client can be given agent.httpx_client() or agent.httpx_async_client() instead (Any httpx client). A bound model can’t be pickled, since it holds the agent’s connection to the proxy: pickle the model, and bind it after loading.