The agent from chapter 4 runs tools and returns results. Real deployments keep asking for more: pause for approval before a risky tool, shrink a huge search result before it floods the context, strip a secret from an output.
None of that belongs in the core loop. Callbacks are how you add it without editing the loop at all.
The extension points
Walk one step of the agent and mark the places worth intervening. Before the model call, you might inject context or check a cache. After it, you might validate the response. Before a tool runs, you might ask for approval or return a cached result. After a tool runs, you might compress the output or mask sensitive fields. After the whole run, you might save the conversation.
Each of those is a callback: a function you supply that the agent calls at a defined point. The agent's core stays fixed, and your function does the extra work.
Callbacks fire at fixed points around each step: before and after the model, before and after a tool.
The two tool callbacks carry this chapter's work: approval before a tool, compression after. This post uses those two, and the others follow the same pattern.
The one rule that makes callbacks powerful
Two callbacks, with a single convention that does a lot of work.
before_tool_callback(context, tool_call)runs before a tool executes. Return a value and the tool is skipped, with that value used as the result. ReturnNoneand the tool runs normally.after_tool_callback(context, tool_result)runs after a tool executes. Return a value and it replaces the result. ReturnNoneand the original result stands.
The convention is the return value. None means "carry on"; anything else means "I am handling this".
When several callbacks are registered, the agent stops at the first one that returns non-None. That
is the early-exit pattern, and it is what lets a callback quietly veto or rewrite a tool call.
Adding it to the agent
The change to act is small. Before running a tool, offer it to the before-callbacks; if one returns a
value, use that and skip execution. After running, offer the result to the after-callbacks; if one
returns a new result, use it.
async def act(self, context, tool_calls):
results = []
for call in tool_calls:
# before: a callback may veto or short-circuit the tool
result = None
for cb in self.before_tool_callbacks:
result = await _maybe_await(cb(context, call))
if result is not None:
break # early exit: skip the tool
# run the tool only if no callback handled it
if result is None:
result = await self._run_tool(context, call)
# after: a callback may replace the result
for cb in self.after_tool_callbacks:
replaced = await _maybe_await(cb(context, result))
if replaced is not None:
result = replaced
break
results.append(result)
return results
The core loop still just runs tools. Everything conditional lives in callbacks the caller supplies, so two very different features share one mechanism.
Example one: approve a dangerous tool
Some tools should not run unattended. A before-callback can pause and ask.
DANGEROUS = {"delete_file", "send_email", "execute_sql"}
def approval_callback(context, tool_call):
if tool_call.name not in DANGEROUS:
return None # safe tool: carry on
print(f"About to run {tool_call.name}({tool_call.arguments})")
if input("Approve? (y/n) ") == "y":
return None # approved: let it run
return "Skipped: the operator declined this action." # denied: skip, tell the model
Read the return values against the rule. A safe tool returns None, so it runs. An approved tool
returns None, so it runs. A denied tool returns a string, which becomes the tool result, so the tool
never executes and the model reads the refusal and can try another way.
Human-in-the-loop is the name for this, and it is a headline safety pattern rather than a detail. The security series treats it as one: which tools belong in that set, and how a gate that fires too often quietly stops being a gate.
Example two: compress a huge result
A search tool can return far more than the answer needs, and every token of it then rides in the context for the rest of the run. An after-callback shrinks it.
def compress_results(context, tool_result):
if tool_result.name != "search" or len(str(tool_result.content)) < 2000:
return None # small enough: leave it
summary = summarise(tool_result.content) # a cheap model call, or a heuristic
return replace(tool_result, content=[summary])
The core loop appended a full result as before. The callback caught the big ones and replaced them with a summary, so the context stays lean, and the loop never knew compression happened.
What to take from this
- Real deployments need behaviour the core loop should not carry: approval, compression, masking. Callbacks add it at defined points without editing the loop.
- One convention drives it. A callback returns
Noneto carry on, or a value to handle the call itself, and the agent stops at the first that does. - The same mechanism powers very different features: a before-callback that gates a dangerous tool, an after-callback that compresses a bloated result.
The agent's knowledge-base chapter is complete: it retrieves by meaning, explores by structure, and extends by callback. One of those extension points, the approval gate, is where safety lives, and the security series takes it from here.