All posts
Fundamentals

Extending an agent without touching its core

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.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. Return None and the tool runs normally.
  • after_tool_callback(context, tool_result) runs after a tool executes. Return a value and it replaces the result. Return None and 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 None to 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.