Put Limits Around a LangChain Agent
Botie keeps looking for a manual that does not exist. Use an agent runtime with explicit tool access and limits, then inspect the messages that explain its decisions.
15 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
When Every Application Was Rebuilding the Same Loop
BOTIE
Our maintenance assistant makes the reason concrete. A missing manual should produce a bounded unsuccessful search. We will not infer a hard operational limit from a sentence asking the model to stop; we will enforce it in the program that executes calls.
Sources for this history
How Many Times Should Botie Ask the Same Question?
The manual reader works for pump P7. A technician then asks about P8, whose manual has not been uploaded. Botie tries another lookup, receives the same missing result, and tries again. The loop needs a stopping rule that does not depend on the model deciding it has done enough.
LangChain provides an agent runtime that coordinates messages and tools. We will keep the task small, give it a read-only tool, and put a call limit around execution. The workshop lets you build the permission and budget decision yourself.
This is a deterministic execution policy, not a simulation of a model deciding which tool to request.
Step 1 of 2
Separate the request from permission to execute it
The model proposes read_manual. The server knows which tools are permitted and how many have executed.
| Item | Supplied by | Purpose |
|---|---|---|
| Tool name | Model proposal | Names the desired operation |
| Allowed tools | Application | Restricts available authority |
| Executed calls and limit | Runtime | Bounds additional work |
Tool name
- Supplied by
- Model proposal
- Purpose
- Names the desired operation
Allowed tools
- Supplied by
- Application
- Purpose
- Restricts available authority
Executed calls and limit
- Supplied by
- Runtime
- Purpose
- Bounds additional work
Step 2 of 2
The same request can reach a different route
Assume read_manual is allowed and the execution limit is two calls.
| Calls already executed | Requested tool | Route |
|---|---|---|
| 1 | read_manual | Execute |
| 2 | read_manual | Budget exhausted |
| 0 | create_job | Denied |
1
- Requested tool
- read_manual
- Route
- Execute
2
- Requested tool
- read_manual
- Route
- Budget exhausted
0
- Requested tool
- create_job
- Route
- Denied
A runtime needs checks at the execution boundary
The execution boundary checks permission and a runtime count, regardless of what the model claims in its request.
May this requested lookup run?
Combine the application’s permitted tool list with the remaining call budget. The output names the reason a request stops.
BOTIE
Tool execution route
execute read_manual
Follow the operations
1. Requested tool name
read_manual
2. Application tool allowlist
- 0read_manual
3. Check the tool permission
{ "name": "read_manual", "allowed": true }4. Calls already executed
1
5. Maximum executed calls
2
6. Apply the call budget
execute read_manual
7. Result
execute read_manual
This is a sequential policy gate. A production runtime must enforce counters atomically and handle provider errors, cancellation and side effects.
1. Know what create_agent is coordinating
A message describes what the technician asked. The model may return answer text or structured requests to call tools. The runtime executes permitted tools and adds their results to the conversation so the model can continue.
create_agent constructs this repeated model-and-tool process. Its interface is useful when that loop fits the task. If every step is predetermined, a small ordinary function may be clearer. Reaching for an agent is a design choice, not a requirement for using a language model.
We are using the same maintenance question as before so the runtime change does not hide a change in the task.
2. Keep messages paired with their tool results
A model response can contain a tool-call ID as well as a tool name and arguments. The result must refer to the matching call. That pairing lets the next model request distinguish one lookup from another, including when several tools were requested together.
Do not reduce the whole conversation to the last sentence while debugging. Inspect which arguments were requested and which result came back. An empty lookup and a network failure need different handling even if both prevented a useful answer.
LangChain returns agent state containing messages. The final message alone can hide the reason a run became expensive.
Find the result belonging to a lookup
These ordinary Python dictionaries represent an invented trace, not LangChain message objects. The list retains arrival order; the call ID establishes which request a result belongs to.
requested = {"id": "call-7", "name": "read_manual", "args": {"asset": "P7"}}
results = [
{"call_id": "call-8", "found": False},
{"call_id": "call-7", "found": True},
]
matched = next(item for item in results if item["call_id"] == requested["id"])
print(requested["args"]["asset"])
print(matched["found"])P7
TrueNested dictionary access reads the requested asset. The matching result can appear later in the list, so using the first result would attach the wrong outcome to this lookup.
3. Make the tool small enough to describe precisely
The @tool decorator uses a Python function as a tool definition. Type annotations describe the arguments, while the docstring tells the model what the operation does. Our example accepts an asset ID and returns either an excerpt or an explicit missing result.
For a real service, scope the lookup to the authenticated technician’s permitted equipment before reading data. The model-supplied asset string is a request, not evidence that the user owns that asset. A service layer can enforce that check regardless of which framework invokes it.
The example’s one-entry dictionary is deliberately invented. It demonstrates lookup semantics without giving the agent access to real maintenance records.
4. Use middleware where the operation happens
Middleware is code that runs at defined points around the agent loop. Hooks can inspect model requests or wrap tool execution. This gives the application a place to enforce limits and record what happened without burying every check in prompt text.
A limit on model calls and a limit on tool calls measure different things. One model response may request several tools. Choose the quantity that bounds the work you care about, and make the stopping behaviour visible to the caller.
The workshop checks an individual tool request against a count of executed calls. It is a small policy exercise, not a reproduction of every framework middleware counter.
Check yourself
Questions on Parts 1 to 4
Question 1 of 2
Why might a model-call limit be insufficient to bound tool work?
Use the information below.
A model response requests three tools. Only one model request produced that response.5. Build the bounded reader
This server recipe uses create_agent with the read_manual tool and separate model and tool limits. The selected error behaviour raises when a limit is exceeded. In a web service, catch that failure at the application boundary and return an unsuccessful result the interface can explain.
The tool only reads from a fixture. There is no create_job tool to call. Keep that narrow surface while learning how the messages and limits behave, then add capabilities with their own tests.
Provider responses vary. Compare the actual call sequence and final evidence with the task requirements instead of copying a displayed answer.
Run in your own environment
A reader with separate model and tool limits
This reference is for your own Python service environment. The browser exercise uses no external provider.
Install a compatible LangChain 1.x release and your model integration, such as langchain-openai for an OpenAI-backed model. Record resolved versions in a lockfile.
Set PERCEPTRON_LAB_AGENT_MODEL to a provider-qualified identifier your integration accepts, and configure its credential on the server. Provider requests may incur charges.
Run the script as a local Python program with invented data. Add application exception handling before exposing it as a service.
import os
from langchain.agents import create_agent
from langchain.tools import tool
from langchain.agents.middleware import (
ModelCallLimitMiddleware, ToolCallLimitMiddleware,
)
@tool
def read_manual(asset: str) -> dict:
"""Read the invented inspection excerpt for a pump asset."""
manuals = {"P7": {"id": "manual-7", "text": "Inspect the mounting."}}
return manuals.get(asset, {"error": "No excerpt for this asset"})
agent = create_agent(
model=os.environ["PERCEPTRON_LAB_AGENT_MODEL"],
tools=[read_manual],
system_prompt="Answer from the manual and cite its ID. "
"If no excerpt exists, say so. Do not schedule work.",
middleware=[
ModelCallLimitMiddleware(run_limit=4, exit_behavior="error"),
ToolCallLimitMiddleware(run_limit=2, exit_behavior="error"),
],
)
state = agent.invoke({"messages": [
{"role": "user", "content": "What should I inspect on P7?"}
]})
print(state["messages"][-1].content)The two middleware limits bound different operations. This example uses per-run limits; a limit across a thread additionally needs persistent thread state.
Official API documentation. Documentation checked .
6. A retry needs a reason and a budget
A temporary connection failure may justify another attempt. An asset absent from the catalogue generally needs different evidence, not immediate repetition of the same lookup. Classify these outcomes before choosing retries.
When an operation changes external state, a timeout can mean the effect happened but the reply was lost. Retrying create_job blindly can create a duplicate. Keep write operations separate from our read example and give them an idempotency key that the receiving service enforces.
A bounded retry can still repeat a mistake. Investigate whether another attempt can supply information the previous attempt lacked.
7. Keep policy checks outside model-written arguments
Our offline gate receives the allowed tool names and used-call count from trusted application state. It checks permission first, then whether another execution fits. These inputs must not come from the model’s claim that it has permission or from its own reported count.
For concurrent requests, reserving budget needs an atomic operation in shared state. A pair of workers can both read one remaining slot unless the runtime coordinates them. The worksheet is sequential so that you can inspect the underlying comparison before adding concurrency.
Allowed = ["read_manual"]
Used calls = 2
Limit = 2
Requested = "read_manual"
Next route = "budget exhausted"The limit is reached when used calls equals the limit. There is no spare call at equality.
8. Decide whether a framework is helping this particular job
Keep a small set of maintenance fixtures while trying implementations. Does each version preserve citation IDs, reject disallowed operations and stop when evidence is missing? Can you inspect a failure without reading framework internals?
Pydantic AI and LangChain both support more than the features introduced here. Compare the behaviour your service needs and the code your team can maintain. Moving to a larger abstraction does not remove the need for the contracts we have already built.
The next lesson takes explicit control of state transitions when a single model-tool loop no longer describes the work clearly.
Check yourself
Questions on Parts 1 to 8
Question 1 of 2
What should a retry design address?
Use the information below.
A create_job request times out. The server might already have created the job.Try it with Botie
Build with Botie
Write and run Python