We have state, a uniform tool interface, and an LLM layer. Each was built to serve one small loop, and this is the post where they meet.
The agent is four methods. Read them in order and the whole thing comes apart cleanly.
The target
Here is what we are building toward, the clean interface a user sees.
agent = Agent(
model=LlmClient(model="<your-model>"),
tools=[read_tickets, search_web, calculate],
instructions="You are a support analysis assistant.",
)
result = await agent.run("Did routing routine tickets to an agent pay for itself last quarter?")
print(result.output)
That one call hides the loop. Behind it, four methods do the work.
run(): manage the loop
run is the entry point. It creates the run's state, loops until there is an answer or a step limit is
hit, and returns the result.
async def run(self, question: str) -> AgentResult:
context = ExecutionContext()
context.add_event(Event(author="user",
content=[Message(role="user", content=question)]))
while not context.final_result and context.current_step < self.max_steps:
await self.step(context)
last = context.events[-1]
if self._is_final_response(last):
context.final_result = self._extract_final_result(last)
return AgentResult(output=context.final_result, context=context)
Two things in the loop condition carry weight.
context.final_result is the done flag from post 19. The loop runs until the agent has an answer.
context.current_step < self.max_steps is the seatbelt. A model can keep asking for tools forever, so
the loop refuses to run past a limit. Reaching that limit is a signal to read the transcript, not to
raise the number, and a later security post treats it as the safety control it is.
Notice that run returns the whole context, not just the answer. Every event is still there, so you
can inspect exactly what the agent did.
step(): one think-act cycle
step performs one turn of the loop: package the request, get the model's response, and if it asked
for tools, run them.
async def step(self, context: ExecutionContext):
request = self._prepare_llm_request(context) # select what the model sees
response = await self.think(request) # ask the model
context.add_event(Event(author=self.name, content=[response_as_content(response)]))
if response.tool_calls:
results = await self.act(context, response.tool_calls)
context.add_event(Event(author=self.name, content=results))
context.increment_step()
_prepare_llm_request is the one place that turns the run's events into an LlmRequest, the selection
seam from post 21. think and act are the two halves of the cycle, below. Every outcome is appended
to the events list, so the history grows by one full step each pass.
run manages the loop, step does one cycle, think asks the model, act runs the tools.
think(): ask the model
think is deliberately tiny. It hands the request to the LLM layer and returns the response. Nothing
else.
async def think(self, request: LlmRequest) -> LlmResponse:
return await self.model.generate(request)
All the provider handling lives in LlmClient from the last post, so think stays a single line. That
is the payoff of the boundary: the reasoning step of the agent is one call, because the messy part was
sealed away earlier.
act(): run the tools
act executes the tools the model asked for and returns the results.
async def act(self, context, tool_calls) -> list[ToolResult]:
results = []
for call in tool_calls:
tool = self.tool_map[call.name]
try:
output = await tool(context, **call.arguments) # uniform call, post 20
results.append(ToolResult(tool_call_id=call.id, name=call.name,
status="success", content=[output]))
except Exception as e:
results.append(ToolResult(tool_call_id=call.id, name=call.name,
status="error", content=[str(e)]))
return results
Every earlier component shows up here. tool_map looks up a BaseTool by the name the model sent.
await tool(context, ...) is the uniform call from post 20, so a local function and an MCP tool run
the same way, and each gets the context. A tool that fails becomes a ToolResult with status="error"
instead of crashing the run, so the model reads the failure and can adjust.
The whole thing
Step back and the four methods form one picture.
| Method | Job | Uses |
|---|---|---|
run | Manage the loop, enforce the step limit, return the result | ExecutionContext (post 19) |
step | One package-think-act cycle | the request seam (post 21) |
think | Ask the model | LlmClient (post 21) |
act | Execute the requested tools | BaseTool (post 20) |
Every earlier component slots into one of the four methods.
The loop from part 15 was forty lines that made a point. This is the same loop, structured so you can
extend it, debug it, and read what it did. It answers the ticket question by asking for read_tickets,
then search_web, then calculate, then stopping, exactly the ReAct cycle from post 18, now running
on parts you built.
What to take from this
- The agent is four methods:
runmanages the loop,stepdoes one cycle,thinkasks the model,actruns the tools. - Each earlier component slots into one method, which is why each method stays short.
runreturns the full context, so the agent's every step is inspectable after the fact.
The agent works, and it returns free text. Some jobs downstream need a guaranteed shape instead, and the next post gets one out of the agent using the tool machinery already built.