All posts
Fundamentals

Assembling the agent: run, step, think, act

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.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.

MethodJobUses
runManage the loop, enforce the step limit, return the resultExecutionContext (post 19)
stepOne package-think-act cyclethe request seam (post 21)
thinkAsk the modelLlmClient (post 21)
actExecute the requested toolsBaseTool (post 20)

Every earlier component slots into one of the four methods.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: run manages the loop, step does one cycle, think asks the model, act runs the tools.
  • Each earlier component slots into one method, which is why each method stays short.
  • run returns 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.