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.
Quick path
Section titled “Quick path”In a hurry? These three steps are the whole challenge. Everything below is the why and the how.
- Run
make p5and count the[tool fired]lines, where only two fire (lookup_traveler,get_weather) so any flight price is invented. - Edit
start/agent.py: do the TODO (write theget_flightstool withorigin/destination, a docstring, and aFLIGHTSlookup, then register it with@agent.tool_plain). - Done when three
[tool fired]lines run in order, the real £142 price appears, andrequestsis 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.
Mental model
Section titled “Mental model”You write no if/else; the model decides which tool to call and in what order.
The mechanic, in another domain
Section titled “The mechanic, in another domain”Forget travel. Say a billing agent has three tools and one request, “look into my last charge”:
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.
The setup
Section titled “The setup”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.
Run it
Section titled “Run it”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.
Build it
Section titled “Build it”-
Run it and count the tool calls. Run
make p5and watch for the[tool fired]lines. There are two:lookup_travelerandget_weather. Read the pitch and notice any flight detail has nothing behind it. -
Write the
get_flightstool (TODO). Build it from the two tools above, don’t copy one from here.get_weatheris your closest template: a@agent.tool_plainwith 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_flightsis the same shape over theFLIGHTStable: give itorigin: stranddestination: str, build the route key the table uses, and return the matching flight. The TODO comment instart/agent.pynames each piece; write the tool there against the readyFLIGHTSdata, and register it with@agent.tool_plain.Run again, and now you get three
[tool fired]lines in dependency order:lookup_traveler, thenget_weather(Lisbon), thenget_flights(London -> Lisbon). The £142 price comes from your tool, andrequestsclimbs to 4: three tool calls plus the compose. -
Cap the budget and watch it break (poke). Uncomment
usage_limits=UsageLimits(request_limit=2)on theagent.runcall, predict what you will get, then run: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_limitis a safety ceiling for runaway loops, not a target. -
Check you’ve got it. You should see three
[tool fired]lines in order, a real £142 price, andrequests: 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 wroteget_flightsbut 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
FLIGHTStable only has London to Lisbon. Keep the prompt on Lisbon, or add rows to the table.
A couple of things worth knowing
Section titled “A couple of things worth knowing”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.