Exercises — Week 3 — Agents & Tools¶
Do these after reading Week 3 — Agents & Tools. The loop in the lesson is the assignment. create_react_agent is optional library spelling — you may mention it; you do not have to use it.
Predict before you run¶
If the loop picks a missing tool name, do you spin forever or stop at MAX_STEPS? Does a useless docstring change the trace, or only your feelings?
Starter / TODO¶
Write ordinary functions (get_account_balance, get_billing_date). Then the lesson's thought/action/observation loop by hand — no agent class. Leave issue_refund unhooked.
Runnable command¶
Each task has three hints, closed by default. Open only as far as you need.
1. Two tools, one question¶
Write get_account_balance and get_billing_date as ordinary functions (dict lookups are fine). Then write the loop from the lesson by hand — no agent class — that answers: “What’s my balance and when do you bill me?” for user_0001. Print the trace (thought / action / observation).
Hint 1 — a nudge
With no model, something still has to decide the next action each step. What's the smallest stand-in for that decision?
Hint 2 — the approach
Split the loop from the decider. The loop owns MAX_STEPS, the tool registry, and the trace; the decider is a function (question, trace) -> (thought, action, args). A scripted decider — a list it walks through — is enough for this exercise, and a model slots into the same signature later.
Hint 3 — most of the code
MAX_STEPS = 5
BALANCES = {"user_0001": 128.40}
def get_account_balance(user_id: str) -> str:
"""Current balance in USD for a CloudWave user_id."""
return f"{BALANCES[user_id]:.2f}" if user_id in BALANCES else f"error: unknown user_id {user_id}"
def get_billing_date(user_id: str) -> str:
"""Next invoice date (ISO) for a CloudWave user_id."""
return "2026-09-01"
TOOLS = {"get_account_balance": get_account_balance, "get_billing_date": get_billing_date}
def run(question: str, decide) -> tuple[str, list[dict]]:
trace: list[dict] = []
for _ in range(MAX_STEPS):
thought, action, args = decide(question, trace)
if action == "finish":
return args["answer"], trace
tool = TOOLS.get(action)
observation = tool(**args) if tool else f"error: no tool named {action!r}"
trace.append({"thought": thought, "action": action, "observation": observation})
raise RuntimeError(f"agent hit MAX_STEPS={MAX_STEPS} without finishing")
def scripted(question: str, trace: list[dict]):
plan = [
("need the balance", "get_account_balance", {"user_id": "user_0001"}),
("need the bill date", "get_billing_date", {"user_id": "user_0001"}),
]
if len(trace) < len(plan):
return plan[len(trace)]
return ("have both", "finish", {"answer": f"Balance {trace[0]['observation']}, billed {trace[1]['observation']}."})
answer, trace = run("What's my balance and when do you bill me?", scripted)
for step in trace:
print(step)
print(answer)
2. A tool you would refuse¶
Add a third tool, issue_refund(user_id, amount). Do not hook it to the loop. Write five lines explaining why this tool needs a human confirmation node (LangGraph week 4) instead of an agent.
Hint 1 — a nudge
The two tools above only read. What changes when a tool moves money and the thing choosing it is a model?
Hint 2 — the approach
Write the function, keep it out of TOOLS, and prove the loop can't reach it. Your five lines should cover: irreversibility, who is accountable, what a prompt injection could do, what "retry" means for a write, and where the approval lives.
Hint 3 — a skeleton
1. issue_refund is a write that <reversible? who notices?>.
2. A model picking tools can be steered by <what>.
3. A retry of this call could <what goes wrong>.
4. The approval belongs in <where>, not in the prompt, because <why>.
5. So: not in TOOLS; reachable only through <the LangGraph week-4 pattern>.
3. The loop cap¶
Force the loop to pick a missing tool name. Confirm it stops at MAX_STEPS with a clear error, not an infinite spin.
Hint 1 — a nudge
A missing tool is an observation, not a crash — a real model gets to see "no such tool" and try again. So what, in your loop, stops a model that keeps trying?
Hint 2 — the approach
Write a decider that always returns "get_invoice_pdf". Run it inside try / except RuntimeError, and print how many trace rows were recorded before the stop.
Hint 3 — most of the code
4. Docstring as API¶
Give get_account_balance a useless docstring ("gets stuff"). Call the same question. Then write the docstring you actually want. What changed in the trace?
Hint 1 — a nudge
In your loop, who reads the docstring?
Hint 2 — the approach
Your scripted decider never looks at docstrings, so the trace can't change. A real model does: the tool description is the only thing it sees when choosing. Build the "menu" a model would receive from the docstrings, and compare the two menus instead of the two traces.
Hint 3 — most of the code
The docstring you actually want is yours.Expected observation¶
Open after you run
A printed trace for user_0001 with a balance and a billing date. A missing tool name stops at MAX_STEPS with a clear error.
Self-check¶
issue_refund is not reachable from the loop. A docstring of "gets stuff" is an API bug, not a prompt-engineering flex.