Billing and refunds agent: sub agents, state and a guard
Everything in this course, in one program, on the agent lesson 0 promised.
It is built in four pieces: two tools, a guard, a specialist, and the agent the customer talks to. Each one is something you wrote alone in an earlier lesson.
The two tools
def lookup_order(order_id: str, tool_context: ToolContext) -> dict:
"""Look up the delivery status of an existing order by its id."""
tool_context.state["last_order"] = order_id
return {"status": "success", "order_id": order_id, "state": "shipped", "date": "3 March"}The lookup writes the order id into state, so nothing later has to ask the customer again, and no model has to remember it.
def refund(amount: int, tool_context: ToolContext) -> dict:
"""Refund an amount in rupees for the order this customer is asking about."""
return {"status": "success", "refunded": amount,
"order_id": tool_context.state.get("last_order")}The refund reads that id back out of state rather than taking it as a parameter. This applies lesson 10's rule to money: the model chooses the amount, never the account.
The guard
def needs_a_person(tool, args, tool_context):
"""Refuse anything expensive until a human has approved it."""
if tool.name == "refund" and args.get("amount", 0) > 100:
if not tool_context.state.get("approved_by_human"):
return {"status": "refused", "reason": "refunds over 100 need a person"}Four lines, from lesson 14. It returns a refusal for a big refund and nothing at all otherwise, which lets all other calls through untouched.
Two agents
billing = LlmAgent(
name="billing",
description="Handles refunds and charges for a customer's order.",
model=PretendModel(replies=[call("refund", amount=500),
say("That refund needs a person to approve it.")]),
instruction="Handle billing for {customer?}. Use the refund tool.",
tools=[refund],
before_tool_callback=needs_a_person,
)The specialist owns the refund tool and the guard. No other part of the desk can issue money.
support = LlmAgent(
name="support",
description="Answers questions about orders.",
model=PretendModel(replies=[call("lookup_order", order_id="A17"),
say("Order A17 shipped on 3 March."),
call("transfer_to_agent", agent_name="billing")]),
instruction="Help {customer?} with orders. Send billing questions to the billing agent.",
tools=[lookup_order],
sub_agents=[billing],
)The agent the customer talks to. It answers order questions itself and hands billing over, which is lesson 16.
Running two turns
runner = InMemoryRunner(agent=support, app_name="desk")
session = await runner.session_service.create_session(
app_name="desk", user_id="u1", state={"customer": "Asha"})A session seeded with the customer's name, which both instructions read through their templates.
async def turn(text):
message = types.Content(role="user", parts=[types.Part(text=text)])
async for event in runner.run_async(user_id="u1", session_id=session.id,
new_message=message,
run_config=RunConfig(max_llm_calls=20)):
for part in (event.content.parts if event.content else []):
if part.text:
print(f"{event.author:<8} {part.text.strip()}")
elif part.function_response:
print(f"{event.author:<8} tool -> {part.function_response.response}")Every turn carries the call limit from lesson 15, so a stuck run stops rather than spending.
print("--- turn 1")
await turn("Where is order A17?")
print("--- turn 2")
await turn("I want my money back, 500 rupees")
done = await runner.session_service.get_session(
app_name="desk", user_id="u1", session_id=session.id)
print("--- state")
print(dict(done.state))--- turn 1
support tool -> {'status': 'success', 'order_id': 'A17', 'state': 'shipped', 'date': '3 March'}
support Order A17 shipped on 3 March.
--- turn 2
support tool -> {'result': None}
billing tool -> {'status': 'refused', 'reason': 'refunds over 100 need a person'}
billing That refund needs a person to approve it.
--- state
{'customer': 'Asha', 'last_order': 'A17'}The first turn is one agent and one tool. The second is the interesting one: support handed over, billing asked for the refund, and the guard refused it before the tool ran. The customer got an explanation rather than an error, and no money moved.
Pick one to watch it run, step by step.
The lessons behind the desk
| Piece | Lesson |
|---|---|
| Tools with docstrings the model reads | 2, 3, 7 |
ToolContext, so the refund reads the order from state | 10 |
An instruction template, {customer?} | 12 |
A before_tool_callback that can refuse | 14 |
max_llm_calls, so a stuck run stops | 15 |
| A sub agent and a transfer | 16 |
| Session state, seeded and written to | 21 |
What to change first
- Set
approved_by_humanin state and run turn two again. The refund goes through, and the rest of the run is unchanged. - Give billing its own
disallow_transfer_to_parentand see what happens when it tries to hand back. - Swap one model for a real one and read the events. The scripted answers become decisions.
- Add the monthly summary as a workflow, with the counting done in a function node.
More of ADK: deployment and beyond
| Left out | What it is |
|---|---|
| Deployment | Cloud Run, GKE and the managed agent runtime, plus adk deploy |
| Evaluation | adk eval, eval sets, criteria and simulated users |
| Live and voice agents | The bidirectional streaming API, audio and video |
| A2A | The agent to agent protocol, for agents from different vendors |
| Artifacts | Files an agent produces or is given, saved through the artifact service |
| Plugins and callbacks in depth | Reusable cross-cutting behaviour across a whole app |
| The integrations catalogue | More than a hundred connectors, from BigQuery to Slack |
| Other languages | The same ADK in TypeScript, Go, Java and Kotlin |
None of those change what you learned. They are the same agents with more around them, and the documentation reads like a manual once the loop makes sense.
What you can build now
You can explain what happens between a question and an answer in ADK, write tools a model can choose between, shape the reply into something your code can use, split work across agents and workflows, keep what matters in state, and stop the things that should not happen without a person. Those are the core parts of ADK; the documentation covers the rest when you need it.
- Run the capstone, then set
approved_by_humanand run it again. - Put your own project's first agent together this week, with one tool and one guard.
You understood something today that you didn't yesterday.