Solutions — Extending the Hand-Rolled Agent
Lesson 02 · Worked solutions · model code + the common mistake for each task
Below are the reference implementations for the three homework tasks, each shown as the
minimum diff against the Lab 02 agent.py. Read them after you have
written your own version — the value is in comparing your choices to these, not in
copy-pasting. Each solution closes with a Common mistake callout that flags
the trap most people fall into the first time.
Solution to Task 1 — Add the search_notes tool
Three additions: the in-process notes dict, the tool function itself, and one new entry in
both the dispatch table (TOOL_FUNCS) and the schema list
(TOOLS) the API actually sees.
NOTES = {
"kyiv-trip": "Pack thermals, the forecast was rainy.",
"groceries": "milk, bread, oats",
}
def search_notes(query: str) -> str:
q = query.lower()
hits = [f"{k}: {v}" for k, v in NOTES.items() if q in k.lower() or q in v.lower()]
return "\n".join(hits) if hits else "no notes match"
TOOL_FUNCS["search_notes"] = search_notes
TOOLS.append({
"name": "search_notes",
"description": "Search the user's notes by substring; returns matching notes.",
"input_schema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
})
Common mistake
Forgetting to mention search_notes in the system prompt. Some models tool-call
fine off the schema alone; others need the prompt to nudge them. If your agent never picks
the new tool even on a perfectly-matching question, add a sentence like “When the
user asks about their notes, call search_notes” to the system prompt
and try again.
Solution to Task 2 — Append a JSON line per turn
Two changes inside run_agent: truncate the log file once at the top, then append
one JSON object per line as the very last thing in each loop iteration.
import json
# at the top of run_agent, before the loop:
log_path = "agent.log"
with open(log_path, "w"): # truncate
pass
# inside the loop, just after computing `cost`:
log_entry = {
"turn": turn,
"stop_reason": resp.stop_reason,
"tool_calls": [b.name for b in resp.content if b.type == "tool_use"],
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
"cost_so_far_usd": round(cost, 6),
}
with open(log_path, "a") as f:
f.write(json.dumps(log_entry) + "\n")
Common mistake
Writing one big JSON list at the end. JSONL (one object per line) lets you
tail -f agent.log while the agent is running — much more useful for
debugging. A single closing-bracketed list is unreadable until the script exits, and broken
forever if the script crashes mid-run.
Solution to Task 3 — Haiku by default, Sonnet as one-shot fallback
Replace the single MODEL constant with a primary/fallback pair and a pricing
table keyed by model. Track current_model as loop state. After appending tool
results, check whether any of them is an error — if so, upgrade to Sonnet and stay there.
MODEL_PRIMARY = "claude-3-5-haiku-latest"
MODEL_FALLBACK = "claude-3-5-sonnet-latest"
PRICING = {
MODEL_PRIMARY: (0.80, 4.0),
MODEL_FALLBACK: (3.0, 15.0),
}
def run_agent(user_question: str) -> tuple[str, float]:
client = anthropic.Anthropic()
messages = [{"role": "user", "content": user_question}]
total_in = total_out = 0.0
current_model = MODEL_PRIMARY
for turn in range(MAX_TURNS):
resp = client.messages.create(
model=current_model,
max_tokens=1024,
system=SYSTEM,
tools=TOOLS,
messages=messages,
)
in_rate, out_rate = PRICING[current_model]
total_in += resp.usage.input_tokens
total_out += resp.usage.output_tokens
# cost tracking unchanged ...
# ... existing branch on stop_reason ...
# after appending tool_results, check for tool errors:
if any(tr.get("is_error") for tr in tool_results):
current_model = MODEL_FALLBACK
messages.append({"role": "user", "content": tool_results})
raise RuntimeError(f"turn cap ({MAX_TURNS}) reached without end_turn")
Common mistake
Switching models per turn rather than upgrading once and staying upgraded. Bouncing between models on every turn is wasteful — once you’ve decided the task is harder than Haiku can handle, just stay on Sonnet for the rest of the conversation. The fallback is a one-way ratchet, not a thermostat.
What you have built
Together, these three tasks cover the three axes every production agent grows along:
- Capability — one new function plus one schema entry expands what the agent can do, with zero changes to the loop.
- Observability — per-turn JSONL is the smallest thing that’s actually useful for cost dashboards, replays, and regression tests.
- Cost — routing the cheap model by default and upgrading only on proven failure is the canonical production pattern, in miniature.
Coming up — Module 2: Claude Agent SDK
You’ve now built every load-bearing piece of an agent from scratch. Module 2 introduces Claude Agent SDK, which gives you most of these 80 lines for free — the loop, the tool dispatch, the stop-condition handling, the cost accounting. Because you wrote them yourself, you’ll know exactly what the SDK is taking off your plate, and exactly where to reach back in when the SDK doesn’t do what you want.