Callbacks: code around the loop
A callback is your function, run at a fixed moment in the loop, with the power to look at what is happening and sometimes to stop it.
There are eight, in three families: around the agent, around the model, and around the tools.
| Callback | What it receives |
|---|---|
before_agent_callback, after_agent_callback | The callback context |
before_model_callback | The context, and the request about to be sent |
after_model_callback | The context, and the response that came back |
before_tool_callback | The tool, its arguments, and the tool context |
after_tool_callback | The same, plus the tool's result |
on_model_error_callback, on_tool_error_callback | The same, plus the error |
Only the two agent callbacks exist on every agent. The six model and tool ones are fields on LlmAgent, because they are about a loop with a model in it.
Two functions that watch
def before_tool(tool, args, tool_context):
print("about to run:", tool.name, args)
def after_tool(tool, args, tool_context, tool_response):
print("came back: ", tool_response)Ordinary functions. The parameter names are what ADK passes in, and printing is the whole body for now.
def lookup_order(order_id: str) -> dict:
"""Look up the status of an order by its id."""
return {"status": "success", "state": "shipped"}
agent = LlmAgent(
name="support",
model=PretendModel(replies=[call("lookup_order", order_id="A17"), say("It shipped.")]),
instruction="Help with orders.",
tools=[lookup_order],
before_tool_callback=before_tool,
after_tool_callback=after_tool,
)print("answer: ", await ask(agent, "Where is order A17?"))Every tool call in this agent is now visible without touching the tools themselves. This is the cheapest observability in ADK and it is worth adding on day one of a real project.
Stopping something
A callback that returns a value replaces what would have happened. Return a tool response from before_tool_callback and the tool never runs.
def refund(amount: int) -> dict:
"""Refund an amount in rupees."""
return {"status": "success", "refunded": amount}
def guard(tool, args, tool_context):
if tool.name == "refund" and args.get("amount", 0) > 100:
return {"status": "refused", "reason": "refunds over 100 need a person"}Four lines of rule. Under a hundred, it returns nothing and the tool runs normally. Over, it returns a refusal and the tool is skipped.
guarded = LlmAgent(
name="support",
model=PretendModel(replies=[call("refund", amount=500),
say("I cannot refund that much without a person.")]),
instruction="Help with orders.",
tools=[refund],
before_tool_callback=guard,
)runner = InMemoryRunner(agent=guarded, app_name="demo")
session = await runner.session_service.create_session(app_name="demo", user_id="u1")
message = types.Content(role="user", parts=[types.Part(text="refund 500")])
async for event in runner.run_async(user_id="u1", session_id=session.id, new_message=message):
for part in (event.content.parts if event.content else []):
if part.function_response:
print("tool result:", part.function_response.response)
elif part.text:
print("answer: ", part.text.strip())The refund never ran. The model asked, the callback answered instead, and the model read that answer and explained it. Nothing about the tool changed, and nothing about the instruction had to be trusted.
- Lower the limit to 100 and watch the same refund go through.
- Add an
after_model_callbackthat prints what the model returned.
This is what real progress feels like.