Projects/Weather Tool Agent/Part 1
Overview and Architecture
Define the weather agent as a loop: user goal, three tools, JSON actions, stop conditions, and a fake model you can later swap for an API.
A weather tool-agent is the smallest system that still looks like production: a user goal, a tiny action space, a JSON protocol, and a loop that can fail. You will not call a live meteorological API in this project. You will simulate geocoding and forecasts with dictionaries so the lesson is the control plane, not HTTP.
This is the right first project because chatbots hide the hard parts. The moment you add two tools, you must answer: who chooses the next call, how you parse the model's output, what happens when the city does not exist, and when you stop. Those questions are the industry. A weather bot is small enough to hold in your head and large enough to need retries, schemas, and tests.
The product
A user types a goal such as What is the weather in Paris?. The agent may not invent a temperature from training data. It must geocode the place to coordinates, fetch weather for those coordinates, then finish with a one-sentence report. If geocoding fails, it retries with a cleaner city name or it finishes with a refusal. That is more honest than hallucinating 22°C.
| Piece | In this project | Not in this project |
|---|---|---|
| Goal | A string the user would type | A chat UI |
| Environment | Dicts of cities and forecasts | Real HTTP, API keys, GPS |
| Tools | geocode, weather, finish | Browser, SQL, shell |
| Policy | A fake model (script / rules), later an LLM | Fine-tuning |
| Stop | finish, max steps, parse budget | Kubernetes |
The four boxes on the whiteboard
Draw this before you write a line of Python. If a teammate cannot point at each box, you are building a demo, not an agent.
- Goal buffer — the original user string. You never overwrite it. Later parts will parse a city name out of it, but the raw goal stays in the transcript.
- Tool registry — a dict from name to callable. The model is not allowed to invent a fourth tool. Unknown names are errors, not "creative autonomy".
- JSON action protocol — every model turn must be an object
{"tool": name, "args": {...}}. Free prose is a parse error. Parse errors consume a retry, not a tool call. - Loop — assemble transcript → ask policy → parse → execute or retry → append observation → check stop.
That is the same loop you saw in Getting Started. This project makes it testable.
Why JSON actions, not "just ask the model"
Natural language is a terrible wire format. "Call weather for Paris" might mean the city, the hotel, or the perfume. JSON with a schema is a contract: the tool name is a key in your registry; arguments are types you can validate. When the contract breaks, you retry or you stop. You do not eval a sentence.
In production the model still writes JSON (or a vendor's tool-call object, which is JSON underneath). Your loop does not care whether the bytes came from GPT, Claude, or a 20-line fake. That swap is the whole point of part 3.
Stop conditions (write them first)
An agent that cannot stop is a denial-of-service against your wallet. Before the loop, write predicates:
- Success:
finishwas called with a non-emptyanswerafter a successfulweatherobservation in the transcript. - Refusal:
finishwas called with an answer that starts withcannot:(unknown city, missing forecast). - Budget: more than
max_stepspolicy calls, or more thanmax_parse_retriesconsecutive JSON failures. - Protocol error: the model named a tool that does not exist after one retry.
You will encode these as functions in part 4. For now, treat them as product requirements.
Fake model vs real LLM
Until part 3, the "model" can be a function that inspects the transcript and returns a dict. That is not cheating. The agent is the loop. Intelligence is a plug. If you cannot ship the loop with a fake model, you cannot ship it with a real one — you will just spend money while you debug JSON.
Run to execute this in your browser. Nothing is sent to a server.
Data flow for one successful run
Walk this on paper. Paris is in the catalog. The fake model has not seen the forecast yet.
- User goal lands in the transcript.
- Policy emits
{"tool": "geocode", "args": {"city": "Paris"}}. - Tool returns
{"lat": 48.86, "lon": 2.35}. - Policy emits
{"tool": "weather", "args": {"lat": 48.86, "lon": 2.35}}. - Tool returns
{"temp_c": 18, "conditions": "cloudy"}. - Policy emits
{"tool": "finish", "args": {"answer": "Paris is 18°C and cloudy."}}. - Loop stops. The answer is the product.
If step 2 returns {"error": "unknown_city"}, step 6 should be a refusal, not a guessed forecast. That branch is as important as the happy path. You will test both.
Failure modes this architecture must survive
| Failure | What you will do |
|---|---|
| Model writes markdown around JSON | Retry with a "JSON only" reminder in the transcript |
| City not in the catalog | finish with cannot: unknown city |
| Weather dict missing that lat/lon | finish with cannot: no forecast |
Model calls weather before geocode | Tool can reject missing coords; policy should geocode first |
Model calls explode_server | Registry miss → error observation, then stop if repeated |
Loop never calls finish | max_steps returns a budget error to the user |
Tip:If you cannot name the failure, you cannot write the test. List failures before tools.
What you will build across the five parts
Part 2 registers simulated APIs as tools with stable error shapes. Part 3 implements the JSON loop and retries. Part 4 puts a tiny test runner around tools, parser, and goal predicates. Part 5 hardens: unknown tools, parse budgets, argument validation, and a rate-limit stub.
You will keep stdlib only. Cities are a dict. Forecasts are a dict keyed by rounded coordinates. Files are dicts. Search is not needed. That is enough to learn the shape used by every vendor SDK.
Exercise
On paper, write the transcript for the goal Weather in Lyon? assuming Lyon is in the catalog, and a second transcript assuming it is not. Each turn is one JSON action plus one observation. Bring both to part 2.
Check your understanding