Tools & Retrievers¶
Tools let a ReActAgent call your Python functions during its
reasoning loop. On each iteration the LLM decides whether to answer directly or
to invoke one of the tools you registered. When it chooses a tool, the framework
runs the function, captures the return value, and feeds it back to the LLM as an
observation — the ReAct pattern: call → observe → iterate.
Concretely, every loop iteration looks like this:
- The LLM streams a response. If it wants a tool, it emits a
ToolCall(the toolnameplus JSON-encodedarguments). - The framework looks the name up in the agent's tool registry and runs the function with the decoded arguments.
- The return value becomes a
ToolResult, persisted into history as aMessage(role="tool", tool_call_id=...)and shown back to the LLM. - The loop repeats until the LLM produces final text (no tool calls) or
max_iterationsis reached.
Async-only since 5.0.0
Agents are invoked via await agent.invoke(...), agent.stream(...), or
await agent.ask(...). Tools themselves must be async def (see
Sync vs async).
@tool on a function¶
Decorate an async def with @tool to expose it to the LLM. Two things are
required:
- A docstring. It becomes the tool's
description— the text the LLM reads to decide when to call it. A function with no docstring raisesValueErrorat decoration time. - Type-annotated parameters. Each parameter's annotation is mapped to a JSON
Schema type to build the tool's
parametersschema.
Python types map to JSON Schema as follows; any unrecognized annotation falls
back to "string":
| Python type | JSON Schema type |
|---|---|
str |
string |
int |
integer |
float |
number |
bool |
boolean |
list |
array |
dict |
object |
Parameters without a default are added to the schema's required list;
parameters with a default are optional. A self parameter is skipped (relevant
for method tools).
from latent.agents import ReActAgent, tool
@tool
async def get_weather(city: str) -> str:
"""Get the current weather for a city."""
return f"It is 22C and sunny in {city}."
@tool
async def convert_currency(amount: float, to_currency: str) -> str:
"""Convert a USD amount into another currency."""
rates = {"EUR": 0.92, "GBP": 0.79}
return f"{amount * rates[to_currency]:.2f} {to_currency}"
Pass the decorated functions to the agent via the tools=[...] constructor
argument:
agent = ReActAgent(
name="assistant",
model="gpt-4o",
system_prompt="You are a helpful assistant. Use tools when relevant.",
tools=[get_weather, convert_currency],
)
answer = await agent.ask("What's the weather in Paris, and convert 50 USD to EUR?")
The agent inspects each passed function for the _tool_meta stamped by @tool.
A function missing _tool_meta (i.e. not decorated with @tool or
@retriever) raises ValueError at construction.
You can inspect the resulting schemas via the read-only agent.tools property,
which returns the OpenAI-style function definitions sent to the model.
@tool methods on a subclass¶
Tools can also live as methods on a ReActAgent subclass. Any @tool-decorated
method is auto-discovered at construction — you do not need to list it in
tools=. The self parameter is ignored when building the schema.
from latent.agents import ReActAgent, tool
class SupportAgent(ReActAgent):
@tool
async def lookup_order(self, order_id: str) -> str:
"""Look up the status of a customer order by its ID."""
return await self._db.fetch_order(order_id)
@tool
async def issue_refund(self, order_id: str, amount: float) -> str:
"""Issue a refund for an order."""
return await self._db.refund(order_id, amount)
agent = SupportAgent(
name="support",
model="gpt-4o",
system_prompt="You help customers with orders and refunds.",
)
# lookup_order and issue_refund are already registered.
You can mix both styles. Tools passed via tools=[...] override an
auto-discovered method tool on a name clash (the explicitly-passed function
wins).
Sync vs async tools¶
Tools must be async def. Sync tools were dropped in 5.4.0 — the agent pipeline
is async end-to-end. A plain (non-coroutine, non-async-generator) function
raises ValueError when decorated:
Internally, @tool unifies two shapes so the loop can treat every tool the
same way:
- A coroutine tool (
async def f(...): return value) is wrapped to yield its return value once. - An async-generator tool (one that
yields — used when a tool forwards retriever events; see below) passes through unchanged.
The ReAct loop drains every tool with async for, awaiting/iterating it
automatically. You write ordinary async def tools and await whatever you
need inside them; the framework handles the rest.
The end_call tool / END_CALL_TOOL_NAME¶
END_CALL_TOOL_NAME is a constant ("end_call") naming the tool an agent calls
to signal that a conversation has reached its natural end. It is a convention,
not something automatically registered on every ReActAgent.
The shipped end_call tool lives in the human-simulation package and is a normal
@tool:
@tool
async def end_call(reason: str) -> str:
"""End the current conversation. Call this when the conversation has reached a natural conclusion."""
return f"Call ended: {reason}"
HumanAgent registers it (in goal-directed and free-form modes) so the simulated
human can choose to hang up. In conversation simulation, the helper
respond_from_events(agent, messages) runs the agent, collects its tool calls,
and sets should_end=True on the returned AgentResponse when any tool call
matches end_tool_name (defaulting to END_CALL_TOOL_NAME). To use this pattern
in your own agent, register a tool named "end_call" (or pass a custom
end_tool_name) and check AgentResponse.should_end to terminate the loop.
@retriever¶
@retriever decorates an async def retrieval function so it emits
RetrieverStart / RetrieverEnd events around the underlying call — turning an
opaque vector-store / BM25 lookup into observable activity in the event stream.
The contract:
- The function must be an
async def(not itself an async generator — that raisesTypeError). - It should take an argument named
query(the search input). Other names likeaction_queryorqueriesare auto-detected as the query parameter, and the first non-selfparameter is used as a fallback. This name only affects the event payload, not the tool schema. - It should return an iterable of objects with a
contentattribute (e.g. retrieved chunks) or items that arestr-convertible; a rawstrcounts as a single document.
from latent.agents import retriever
@retriever
async def search_kb(query: str, k: int = 4):
"""Search the knowledge base for relevant chunks."""
return await chroma.retrieve(query, k=k)
When called, the decorated function is an async generator: it yields a
RetrieverStart (carrying run_id, query, and name), then awaits the
underlying retrieval, then yields a RetrieverEnd (carrying the same run_id, a
name, and documents — a list[str] content preview), and finally yields the
actual retrieval result as the last non-event item. If the retrieval raises, a
RetrieverEnd with empty documents is emitted before the exception
propagates, so every RetrieverStart is paired.
By default the retriever name comes from fn.__name__; override the event
name with @retriever(name="my-retriever") (the tool name exposed to the LLM
still comes from fn.__name__).
@retriever implies @tool¶
A @retriever-decorated function is also stamped with _tool_meta, so you can
pass it straight to ReActAgent(tools=[...]) for the common case where the
retriever's return value is the tool output:
agent = ReActAgent(
name="rag",
model="gpt-4o",
system_prompt="Answer using the knowledge base.",
tools=[search_kb], # @retriever is also a tool — no separate @tool needed
)
Composing retrievers inside a tool¶
For the case where one tool fans out to multiple retrievers and post-processes
the results, write a separate @tool async generator that drains the
@retriever building blocks. Forward events with yield, and yield the final
tool output last:
from latent.agents import tool, retriever
from latent.events import AgentEvent
@retriever
async def search_kb(query: str, k: int = 4):
"""Search the knowledge base."""
return await chroma.retrieve(query, k=k)
@tool
async def answer_from_kb(query: str):
"""Retrieve supporting passages and format them for the model."""
chunks = None
async for item in search_kb(query):
if isinstance(item, AgentEvent):
yield item # forward RetrieverStart / RetrieverEnd
else:
chunks = item # last non-event yield = retriever result
yield format_chunks(chunks) # tool's final yield = the ToolResult output
An async-generator tool must yield exactly one non-event item as its result.
Yielding only events raises a RuntimeError ("produced no result yield").
How tool activity appears in the event stream¶
Tool and retriever activity surfaces as typed events while you stream() the
agent (see the Agent Protocol for the full event taxonomy):
| Event | Meaning |
|---|---|
ToolCall |
The LLM is invoking a tool. Has id, type="function", and a function block (FunctionCall) with function.name and function.arguments (a JSON-encoded string, not a dict). |
ToolResult |
The outcome. Carries tool_call_id (matching the ToolCall.id), output (Python-native), and error (str | None). |
RetrieverStart |
A retriever began a query: run_id, query, name. |
RetrieverEnd |
A retriever finished: matching run_id, documents (preview), name. |
Parse a tool call's arguments with json.loads (or use
ToolCall.from_arguments(...) to build one from a dict). When a step is
persisted into history, the assistant's calls become
Message(role="assistant", tool_calls=[ToolCall, ...]) and each result becomes a
separate Message(role="tool", tool_call_id=..., content=str(output)) row.
import json
from latent.protocol import ToolCall, ToolResult
async for event in agent.stream([Message(role="user", content="weather in Tokyo?")]):
if isinstance(event, ToolCall):
args = json.loads(event.function.arguments or "{}")
print("calling", event.function.name, "with", args)
elif isinstance(event, ToolResult):
if event.error:
print("tool failed:", event.error)
else:
print("tool returned:", event.output)
Tool failures don't crash the loop: if argument JSON is invalid, the tool name
isn't registered, or the tool raises, the framework emits a ToolResult with
error set and feeds an error message back to the LLM so it can recover or
re-plan.