Skip to content

p5: Agentic: the model orchestrates

The first half of this track was workflows you wrote. You fixed the steps, you wrote the if/else, and the model filled in the words. Now the model takes the wheel. TripMate becomes a real agent here.

In a hurry? These three steps are the whole challenge. Everything below is the why and the how.

  1. Run make p5 and count the [tool fired] lines, where only two fire (lookup_traveler, get_weather) so any flight price is invented.
  2. Edit start/agent.py: do the TODO (write the get_flights tool with origin/destination, a docstring, and a FLIGHTS lookup, then register it with @agent.tool_plain).
  3. Done when three [tool fired] lines run in order, the real £142 price appears, and requests is 4.

You hand it three tools and one request. It looks up who you are, checks the weather where you are going, prices the flight from your home city, then writes the pitch. That order is a real dependency chain: it cannot check the weather before it has a destination, or price a flight before it knows your home city. The model reads the tool docstrings and works the order out itself, with no orchestration code from you.

lookup_traveler also arrives here, and it shows the Pydantic idiom this challenge is built around: deps. In f1 to f5 the agent knew nothing about you. This tool reads your profile from ctx.deps, the typed object you pass into agent.run(..., deps=...), so the agent can greet you by name instead of guessing.

"plan my trip"  ->  TripMate sequences the tools itself:
     lookup_traveler  ->  get_weather  ->  get_flights  ->  compose  ->  answer

You write no if/else; the model decides which tool to call and in what order.

Forget travel. Say a billing agent has three tools and one request, “look into my last charge”:

agent = Agent(
    model,
    instructions=(
        "You handle billing questions. Call lookup_account for the customer id, "
        "get_latest_invoice for that customer, then check_refund for that invoice. "
        "Never invent amounts; only state what a tool returned."
    ),
)
# lookup_account, get_latest_invoice, check_refund are each an @agent.tool_plain.

The instructions describe the job, not the order. There is no “first do this, then that” in your code: get_latest_invoice needs a customer id and check_refund needs an invoice id, so the model has to call them in that order. It reads each tool’s docstring, sees what each one needs, and derives the sequence itself. Below you give TripMate its third tool and watch the same thing happen.

Open start/agent.py. The whole world is in that one file: two inlined data tables, with lookup_traveler and get_weather already wired into the agent, whose instructions describe the job and not the order. lookup_traveler is @agent.tool rather than @agent.tool_plain because it reads your profile from ctx.deps, the typed Traveler you pass at agent.run(..., deps=...); the plain tools do not need that run context. What is missing is get_flights: that tool is yours to write (TODO), using get_weather as the template.

make p5

The starter wires lookup_traveler and get_weather, but not get_flights yet. TripMate greets you as Jag and describes Lisbon’s weather. You will see two [tool fired] lines, so no flight price is grounded. If it states a flight price anyway, that price is invented; if it hedges or refuses, that is useful signal too. Nothing is pricing the flight yet, and you are about to write the tool that does.

  1. Run it and count the tool calls. Run make p5 and watch for the [tool fired] lines. There are two: lookup_traveler and get_weather. Read the pitch and notice any flight detail has nothing behind it.

  2. Write the get_flights tool (TODO). Build it from the two tools above, don’t copy one from here. get_weather is your closest template: a @agent.tool_plain with a docstring the model routes on, typed parameters, and a body that looks a value up in a table and returns it (or an {"error": ...} dict when there is no match). get_flights is the same shape over the FLIGHTS table: give it origin: str and destination: str, build the route key the table uses, and return the matching flight. The TODO comment in start/agent.py names each piece; write the tool there against the ready FLIGHTS data, and register it with @agent.tool_plain.

    Run again, and now you get three [tool fired] lines in dependency order: lookup_traveler, then get_weather(Lisbon), then get_flights(London -> Lisbon). The £142 price comes from your tool, and requests climbs to 4: three tool calls plus the compose.

  3. Cap the budget and watch it break (poke). Uncomment usage_limits=UsageLimits(request_limit=2) on the agent.run call, predict what you will get, then run:

    usage_limits=UsageLimits(request_limit=2),

    The chain needs four round-trips and you allowed two, so it raises UsageLimitExceeded: a dependency chain needs room to run. Remove the limit, or raise it, and it completes. request_limit is a safety ceiling for runaway loops, not a target.

  4. Check you’ve got it. You should see three [tool fired] lines in order, a real £142 price, and requests: 4. Scroll up to the console trace and find the same chain drawn as a span tree. You should be able to say why the model called the tools in that order, where the traveller’s profile came from, and why the destination is pinned to Lisbon in the prompt.

Stuck? finish/agent.py is the canonical version. Read it after you’ve had a real go.

  • Only two [tool fired] lines after the TODO. You wrote get_flights but didn’t add @agent.tool_plain. An undecorated function is not registered, so the agent can’t call it.
  • The chain skips a tool. Small local models drift under load. The instructions say to call all the tools and the prompt asks for the flight price by name to keep granite on track; re-run if it still skips one.
  • The pitch names a different city than the tool calls. The destination is pinned to Lisbon in the prompt to keep the chain crisp; a stronger model can both pick the destination and orchestrate.
  • A flight call returns an error. The inlined FLIGHTS table only has London to Lisbon. Keep the prompt on Lisbon, or add rows to the table.
Why not just write the order myself?

You could: call lookup, then weather, then flights, in your own code. That works until the questions vary. “Is it warm enough to hike in Lisbon?” needs weather but not flights. “How much to get to Barcelona?” needs flights but not weather. Writing the branching for every shape of question is the routing logic you are trying to avoid.

The agent reads each request and calls only the tools that request needs, in the order their results require. You maintain tools, not a decision tree.

Why deps instead of a global or a closure?

lookup_traveler needs the current user. You could close over a module global, but then every run shares it and you cannot pass a different user per call. deps is per-run and typed: Agent(deps_type=Traveler) makes ctx.deps a Traveler inside every @agent.tool, and you pass the actual value at agent.run(..., deps=...). Tests pass a fake; production passes the real session.

The TypeScript path has no first-class deps, so it injects context through a closure instead. Same outcome, different idiom; this is the more Pythonic one.

Next up is p6, where this agent’s tools become other agents and it orchestrates them.