EQTY LabDocs

LangChain agents

Describe and route an agent built with LangChain's create_agent: what its definition records, its middleware's models, structured output, and what each call is checked for.

View as Markdown

guardian_agent.langchain.create_agent takes LangChain’s create_agent arguments, with name required: the agent’s name as enrolled with viper agent add. It describes the agent from those arguments, routes its models through the proxy, and returns LangChain’s compiled agent, built with its models routed and each call checked. It needs the langchain extra, with langchain 1.2.11 or later (Choose an integration).

python
from langchain_openai import ChatOpenAI

from guardian_agent.langchain import agent_of, create_agent


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


# LangChain's create_agent, with one import changed and the agent's name added
researcher = create_agent(
    ChatOpenAI(model="gpt-4.1", temperature=0),
    tools=[search_docs],
    system_prompt="You research questions using the internal docs.",
    name="researcher",
)

# Optional: register at start-up, so a misconfigured proxy fails here
print(f"researcher is registered as {agent_of(researcher).register()}")

result = researcher.invoke(
    {"messages": [{"role": "user", "content": "Summarise the release notes."}]}
)
print(result["messages"][-1].content)
  • The model is a LangChain chat model, described as guardian.agent() describes one (Plain LangChain), or a model name such as "openai:gpt-4.1", which is built with init_chat_model and routed.
  • agent_of(agent) returns the SDK’s agent, also on LangGraph’s copies of it, such as researcher.with_config(...). Calling its register() at start-up, as above, makes a misconfigured proxy fail there rather than on the first request.
  • guardian= connects to another proxy than the one at VIPER_PROXY_URL. description=, version= and exclude= are as for ga.Definition (Definition fields). These four are the SDK’s own arguments.

What the definition records

From the arguments you pass, and what LangChain builds from them:

  • Every model the agent calls, each once, in models: its own first, then those LangChain’s middleware calls (below).
  • The system prompt.
  • Every tool the model can call: the ones you pass and the ones middleware adds (write_todos, say), each with a fingerprint of its code: a hash of its source, so editing the code gives a new definition (What is captured). A tool given as a dict is recorded with what else it sends, such as OpenAI’s strict. A provider’s own tool, one whose type names its kind, such as OpenAI’s {"type": "web_search"} or Anthropic’s server tools ({"type": "web_search_20260209", "name": "web_search"}, say), is recorded whole under extensions.langchain.builtinTools, as is one that middleware sends in the provider’s own form (Claude’s text editor). Each call may offer it only as recorded, so the model’s client must send it as given. One it wouldn’t is refused with TypeError when the agent is made: a provider tool given to another provider’s model, which the client would send as a function (Anthropic’s web search to a ChatOpenAI, say).
  • The beta features the model’s requests may carry, as its betas setting: its own (ChatAnthropic(betas=[...])), and those ChatAnthropic adds for the tools the agent offers it, such as context-management-2025-06-27 for the memory tool of Claude’s memory middleware, or web-fetch-2025-09-10 for an older web fetch. A call that doesn’t offer such a tool, such as a middleware’s own call, carries fewer, and that’s allowed; one carrying a beta the definition doesn’t record is refused.
  • Middleware, in the order it runs, with a fingerprint of your code, your base classes included, and the settings it was made with (below).
  • A structured response_format: its strategy, its JSON Schema and its settings (below).
  • The graph’s nodes and edges, and the versions of langchain, langchain-core, langgraph and your provider’s package, under the framework langgraph. interrupt_before and interrupt_after are recorded too.

LangChain’s other arguments (state_schema, context_schema, checkpointer, store, debug, cache, and from langchain 1.4 transformers) are passed on, but aren’t recorded. The same arguments always give the same definition, and changing anything it records, such as a prompt, a tool’s code, a setting or a package version, gives a new one.

Middleware and its models

Some of LangChain’s middleware calls a model of its own: ModelFallbackMiddleware’s fallbacks, SummarizationMiddleware (its model, and a chat-model summarizer), LLMToolSelectorMiddleware and LLMToolEmulator. Those models are routed through the proxy too, signed as the agent, and recorded in models after the agent’s own, each once:

python
from langchain.agents.middleware import (
    HumanInTheLoopMiddleware,
    SummarizationMiddleware,
)
from langchain_openai import ChatOpenAI

from guardian_agent.langchain import agent_of, create_agent


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


def publish(summary: str) -> str:
    """Publish a summary to the team's channel."""
    return "published"


researcher = create_agent(
    ChatOpenAI(model="gpt-4.1"),
    tools=[search_docs, publish],
    system_prompt="You research questions and publish what you find.",
    middleware=[
        # Its model is routed through the proxy too, signed as the researcher
        SummarizationMiddleware(
            model=ChatOpenAI(model="gpt-4.1-mini"), trigger=("tokens", 4000)
        ),
        HumanInTheLoopMiddleware(interrupt_on={"publish": True}),
    ],
    name="researcher",
)

document = agent_of(researcher).definition.document("researcher")
for model in document["models"]:
    print("model:", model["provider"], model["model"])
for middleware in document["middleware"]:
    print("middleware:", middleware["name"])

It prints the two models, gpt-4.1 then gpt-4.1-mini, and the two middleware, in order. A middleware’s settings refer to a model by its place in models, so the summarization middleware’s model is recorded as {"@model": 1}.

Your middleware is left as it was: the agent runs a copy of any middleware that holds a model, with its models routed wherever it keeps them. These are refused when the agent is made, with TypeError, since their calls would go out unsigned:

  • Other middleware that holds a model of its own, wherever it holds it (attributes, its class or a class of yours it holds, containers, closures, weak references), whatever exclude leaves out. So is middleware whose code reads one from a module global, in its hooks, a function it’s given (a token_counter, say) or the methods of an object it holds, followed as a tool’s code is (How far the search goes). That includes the agent’s own model, or one you give LangChain’s middleware, read from a global: the agent routes a copy, and the global still holds the original, so use a model bound to an agent there. So is a model your subclass of LangChain’s middleware reads from a global in its constructor (super().__init__(model=SUMMARIZER)): a constructor can run again as the agent runs, and the copy would then hold the original. Give it the model as an argument instead (MySummary(model=SUMMARIZER)). A refusal for a model, client or MCP tool reached through a global names that global, or, for one your code imports as it runs, where it’s imported from (models.llm). Through a global, a model bound to an agent, an agent this SDK made (guardian.agent(), or one create_agent, create_deep_agent or describe_graph made) and the agent’s own MCP tools are allowed: a bound model signs as the agent it’s bound to, and an agent as itself. A call your middleware makes to an MCP tool itself isn’t one the agent makes, so the per-call checks don’t see it, as for one it holds in its tools.
  • One of LangChain’s that keeps its model where it can’t be routed.
  • Middleware that calls OpenAI’s moderation endpoint or Decisions API: OpenAIModerationMiddleware, OpenAIAutoModeMiddleware and OpenAIModelRouterMiddleware. Their calls would be the agent’s own, which the integration describes and checks, and it can’t describe or check these yet, so they’re refused whatever client they’re given. So is OpenAI’s OpenAIDecisions, wherever it’s held.
  • Middleware of yours holding a provider’s client that isn’t routed, or whose code reads one from a module global: an OpenAI or Anthropic client of any kind, or something holding one, such as OpenAIEmbeddings. A client bound to an agent is routed, but it’s recorded as one of your middleware’s settings, which a definition can’t hold, so leave that setting out with exclude.

A model or client middleware builds or looks up as it runs can’t be seen, as for a tool (How far the search goes, Calls the SDK can’t see). Neither can a call middleware makes to a model directly, outside the agent’s model call.

A middleware model from another provider than the agent’s own, such as an Anthropic fallback for an OpenAI agent, is refused with TypeError too, since its calls would reach a proxy that fronts the other provider (One provider per agent). That includes a model LangChain picks for middleware given none: LLMToolEmulator() uses an Anthropic model by default, so give it the agent’s own provider’s model.

Middleware settings

Each middleware is recorded with the settings it was made with, such as which tools need approval in HumanInTheLoopMiddleware, a call limit, a retry policy or a shell’s startup commands. Everything the middleware holds is read, private attributes and what your classes hold included.

  • Code in a setting is fingerprinted, or named when it’s LangChain’s or Python’s, along with the values your code captured or was given by name, written as [name, value] pairs.
  • A captured value named like a credential (api_key, say) that holds text, a whole number or more is refused with ga.DefinitionContainsSecret.
  • A module is named, a tool is named, and a model is written as its place in models, {"@model": 1}.
  • Left out: secrets (SecretStr). Locks and executors are recorded by type only, and loggers by type and name, and so is an object that holds nothing yet and is all Python source, yours or a library’s. One built on compiled code is refused, since it may keep state where it can’t be read.
  • A setting that can’t be written as JSON, such as a client, is refused by name, with ValueError. If it doesn’t change what the agent does, leave it out with exclude, as in exclude=["/middleware/0/config/client"].

Settings are read when the agent is made, so keep values that change from run to run, such as a counter or a start time, out of your middleware’s attributes, or exclude them. Otherwise each start gives a new definition.

Structured output

A structured response_format shapes every answer, so it’s recorded as sent: its strategy (ToolStrategy, ProviderStrategy or LangChain’s choice), its JSON Schema and its settings, under extensions.langchain.responseFormat:

python
from langchain.agents.structured_output import ToolStrategy
from langchain_openai import ChatOpenAI
from pydantic import BaseModel

from guardian_agent.langchain import agent_of, create_agent


class Answer(BaseModel):
    """An answer, with the pages it came from."""

    summary: str
    sources: list[str]


researcher = create_agent(
    ChatOpenAI(model="gpt-4.1"),
    system_prompt="You research questions and cite your sources.",
    response_format=ToolStrategy(Answer),
    name="researcher",
)

# The response format's strategy and schema are recorded, with the settings it
# was made with, since they shape every answer
recorded = agent_of(researcher).definition.document("researcher")["extensions"]
print(recorded["langchain"]["responseFormat"]["strategy"])  # "tool"
print(recorded["langchain"]["responseFormat"]["schema"]["required"])

What each call is checked for

Middleware can change a call as the agent runs, so the agent checks each call before it’s sent. It’s refused if middleware changed it to use:

  • a model the agent wasn’t bound to: ga.ModelNotRoutedThroughProxy;
  • a model, or model settings (model_settings), the definition doesn’t describe, or a beta feature it doesn’t record: ValueError;
  • tools added or removed in the system content, as Anthropic’s tool_addition and tool_removal blocks do, in the system prompt or a system message mid-conversation: ValueError, whatever betas the model records;
  • a tool the definition doesn’t describe as sent, one it doesn’t list, or one whose description, arguments, settings or MCP server changed: ValueError;
  • another response format: ValueError.

Which tools the model must or may call (tool_choice, parallel_tool_calls) is the agent’s choice for each call, and isn’t checked. Neither is the prompt, so middleware that changes it as the agent runs isn’t caught.

Tools that call a model

A tool that calls a model of its own is refused when the agent is made, with TypeError, since its calls would go out unsigned beside the agent’s. That covers a tool you pass and a tool your middleware offers, and a model the tool:

  • closes over, or defaults to;
  • holds on self, as a BaseTool subclass or the object a bound method belongs to;
  • holds as an attribute of a function or container it uses, or of the container’s class;
  • reads from a module global or a module’s attribute, in its own code or a function it calls, or from a module your code imports as it runs, once that module has been imported: for from models import llm, only llm is read. What a library’s code imports as it runs isn’t followed. Code that reads its own module by name (sys.modules[__name__].llm) has each name it reads looked up among its globals. Only names the code reads as globals count, so config.model doesn’t make a module-level model the tool’s. A module the code reads is followed through every name the code reads, though: with models.helper() in it too, models.model is found.

It also covers a tool LangChain builds around your objects (as_tool(), create_retriever_tool, a partial). The agent’s own model counts too, since the agent routes a copy of it and the tool would call the original. Two kinds of model are allowed, because each signs as an agent:

  • A model bound to an agent: guardian.agent("summarizer", model=llm).bind(llm) signs as summarizer, under its own definition.
  • An agent made with create_agent, called from the tool: it signs as itself (Multiple agents).

The same goes for a provider’s client the tool can reach: an OpenAI or Anthropic client of any kind (a resource such as client.chat.completions, and OpenAI’s module-level client, included), or something holding one, such as OpenAIEmbeddings or a retriever built on it. Its calls go through the proxy, signed as an agent, when the client is bound to that agent (guardian.agent("summarizer", model=...).bind(client)), or built on the agent’s httpx client (OpenAIEmbeddings(base_url=..., http_client=agent.httpx_client(), http_async_client=agent.httpx_async_client())); otherwise the tool is refused. A bound client is yours, not the integration’s: its requests are signed as the agent it’s bound to, and what they send is yours to keep to that agent’s definition, as for any client you call yourself (Run an agent). A client aimed somewhere other than OpenAI’s or Anthropic’s API, such as Azure’s, can’t be routed: bind() refuses it, and one built on an agent’s httpx client is refused at its first call.

An MCP tool isn’t searched, since its server is recorded instead. Neither are the tools LangChain’s and Deep Agents’ own middleware makes for itself (task, the filesystem tools), whose models are routed as sub-agents. A tool you add to their middleware is yours, and is searched.

How far the search goes

  • Your code is followed wherever it’s installed: the package a tool or middleware comes from counts as yours, in site-packages or not, found through what LangChain wraps it in (as_tool(), RunnableLambda, the class @before_model makes), and so does that of a function middleware is given, even as a partial or a bound method. A decorator from another package (tenacity’s @retry, say) doesn’t make that package yours: the code it wraps is followed, and the decorator’s own code one hop. LangSmith’s tracing code isn’t searched, since it holds no models; what it wraps is.
  • Another installed package’s code, such as a library, or a shared package of yours installed beside your app, is followed one hop. A function of it that your code calls has its module globals read, so a model it keeps there, or in a dict or list there, is refused. What that code calls in turn isn’t followed, which keeps libraries’ internals (httpx, pandas) out of the search, so a model reached only that way isn’t seen. What a library’s object holds is searched, so a client or connection pool a tool holds is fine.
  • What a value is comes from its own type, never from its __class__, which an object can set to anything: one posing as a string, a number, a module, a container or a function is searched as what it really is. Telling what a value is runs none of its code (a __class__ property) or a class’s (a __subclasshook__); reading what it holds can, as for any object. A function a wrapt decorator made is searched as the function it wraps, with everything its proxy holds: the decorator’s own code, its enabled check, arguments bound to it and attributes of its own.
  • Weak references are followed to what they refer to. A model or MCP tool behind a weakref.ref or weakref.proxy, in a WeakValueDictionary, WeakKeyDictionary or WeakSet, or run by a reference’s or proxy’s callback or a weakref.finalize the code holds, is found as if held directly, and a dead reference holds nothing. Reading what a proxy refers to runs none of that object’s code; on a Python other than CPython, or one running without the GIL, where it can’t be read that way, a proxy is refused. A weakref.ref, proxy, WeakSet, weak dictionary or weakref.finalize can’t be written into the definition, since what it refers to, or runs, can change or go without the definition showing it, so one in a middleware’s settings, or one a graph node or router closes over, is refused unless exclude leaves it out; the search still follows it.
  • Modules are followed however deep your code names them (acme.platform.ai.clients.openai.chat.llm), each module once, so modules that name each other (os and os.path) don’t loop.
  • A ContextVar and a defaultdict: a context variable’s default, and its value in the context the agent is made in, are searched, as is the factory a defaultdict makes missing values with. A value set in another context, or after the agent is made, can’t be seen.
  • A chat model’s callbacks, rate limiter and token counter (custom_get_token_ids) run code of yours on each of its calls, so they’re searched too. That covers the agent’s model and each middleware’s model, the models describe_graph is given, and a bound model a tool, a node or middleware’s code reaches. One that reaches a chat model, a provider’s client or an MCP tool that isn’t routed or offered is refused with TypeError (for the agent’s model and a middleware’s, the error names which of the three it was); a callback holding a model bound to an agent is fine. A CallbackManager’s handlers count as the model’s callbacks, and a Deep Agents sub-agent’s model may run an MCP tool its parent agent offers. A handler from another installed package, such as LangSmith’s or Langfuse’s tracer, has what it holds checked, and its lists and dicts opened, but an installed package’s objects in it (a tracer’s client, its sessions and connections) aren’t followed. Your code, held by the handler or in its lists and dicts, is searched in full: code that isn’t installed, and the packages your tools, middleware or describe_graph’s build come from, wherever they’re installed. Not searched: code such an object of a package’s runs for you (a LangSmith client’s hide_inputs, Langfuse’s mask), callbacks given as a call runs (config={"callbacks": [...]}), a model’s cache and the global one (set_llm_cache), a callable api_key, its HTTP client’s event hooks, and the code of your own subclass of a chat model.
  • The search stops at 100,000 values, or 16 levels deep. A tool that holds more, or an object that can’t be read, is refused, since a model could be inside it: a large lookup table a tool reads from a global, say. Build it inside the tool, or keep it in the agent’s state.
  • A model a tool or middleware builds or looks up as it runs, such as a global set on first use or after the agent is made, getattr on a module, globals()[...], or a module first imported as it runs, can’t be seen (Calls the SDK can’t see).

An error names a tool by its name or, if the name looks like a credential, by its place: in tools=, or in a middleware’s tools.