skip to content

In Google ADK, what does returning a value from before_model_callback or before_tool_callback do?

level: middleimportance: must knowfreq 56%

answer

  1. None means proceed
  2. non-None replaces the step
  3. six hooks: agent, model, tool
  4. mutate the request in place, return None
  5. error dict beats raising

basics

~20 s

Returning a non-None value short-circuits the step. A before_model_callback that returns an LlmResponse skips the model call and uses that response; a before_tool_callback that returns a dict skips the tool and uses that dict as its result. Returning None lets the step proceed.

solid answer

~50 s

ADK's callbacks are interception points, and their return value is the control signal. `before_model_callback(callback_context, llm_request)` may return an `LlmResponse` — the LLM is then never called and that response is fed onward as if it came from the model. `before_tool_callback(tool, args, tool_context)` may return a dict, and the tool function is skipped with that dict standing in as the tool result. Return `None` from either and execution continues normally, which is what you do when you only wanted to *inspect or mutate* — for example editing `llm_request` in place to inject an instruction, or rewriting `args` before the tool runs. The `after_*` counterparts mirror this: return a replacement `LlmResponse` or dict to override what actually happened, or `None` to keep it. That single convention is how you build guardrails, canned refusals and caches without touching agent logic.

code

python · 17 lines
python
from google.adk.agents.callback_context import CallbackContext
from google.adk.models import LlmRequest, LlmResponse
from google.genai import types


def screen_input(
    callback_context: CallbackContext, llm_request: LlmRequest
) -> LlmResponse | None:
    last = llm_request.contents[-1].parts[0].text or ""
    if "BLOCKED" in last:
        return LlmResponse(
            content=types.Content(
                role="model",
                parts=[types.Part(text="I can't help with that request.")],
            )
        )
    return None  # proceed to the model

go deeper

for a junior

Know the six hook names and the one rule: return None and the step happens, return a value and it does not — your value stands in for the model or tool result.

for a middle

Explain both usages — mutate llm_request or args in place and return None to steer a call, versus returning an LlmResponse or dict to short-circuit it — and that hooks fire per call inside the agent loop.

for a senior

Show guardrail judgment: return an error dict so the model can recover instead of raising, redact in the after hooks, and keep per-call hooks cheap because they multiply by loop length.

for a principal

Frame callbacks as a policy layer that belongs outside agent logic, and be honest about their boundary — they control what your agent sends and sees, not what the underlying credential can do.

## The callback family An ADK `LlmAgent` accepts six optional hooks: `before_agent_callback` and `after_agent_callback` around the agent's whole run, `before_model_callback` and `after_model_callback` around each LLM call, and `before_tool_callback` and `after_tool_callback` around each tool invocation. Each field takes a single callable or a list of callables, and they may be sync or async. The hooks are per *call*, not per turn. An agent that loops — model, tool, model again — fires the model and tool callbacks once per iteration. That is the property that makes them useful for enforcement and dangerous for expensive work: anything slow inside a model callback multiplies by the loop length. ## The return convention Every hook shares one rule: **`None` means proceed, non-`None` means override.** - `before_agent_callback(callback_context)` returning `types.Content` skips the agent's run entirely; that content becomes the response. - `before_model_callback(callback_context, llm_request)` returning an `LlmResponse` skips the LLM call. Downstream code cannot tell the difference — this is how you implement a canned refusal or serve a cache hit without paying for a token. - `after_model_callback(callback_context, llm_response)` returning an `LlmResponse` replaces the model's actual output, e.g. redacting a value the model echoed back. - `before_tool_callback(tool, args, tool_context)` returning a dict skips the tool function and uses that dict as the tool result. This is the argument-validation gate: reject a disallowed argument by returning `{"error": "..."}` so the model sees a normal tool error and can recover, rather than raising and killing the run. - `after_tool_callback(tool, args, tool_context, tool_response)` returning a dict replaces the tool's result — filtering rows, trimming an oversized payload, stripping fields the model should not see. When a field holds a list of callbacks, they run in order and the first non-`None` return wins and short-circuits the rest. ## Mutate-and-continue is the other half Half of real callback usage never returns anything. Because `llm_request` and `args` are passed in as live objects, you can modify them and return `None`: - append a system instruction or a policy reminder to `llm_request` on every call, so it survives no matter what the agent's static instruction says; - normalize or clamp a tool argument — force a `limit` down to a maximum, canonicalize a country code — before the tool sees it; - read `callback_context.state` or `tool_context.state` to make the decision, and write to it to record that you did. State written through the context is captured as a state delta on the resulting event rather than silently mutating a dict, which is what makes the change visible in the dev UI's State and Events tabs. The distinction interviewers probe: *mutate and return None* to steer the call, *return a value* to prevent it. ## Guardrail patterns worth naming - **Input screening** — inspect the last user message in `before_model_callback`; on a match, return a refusal `LlmResponse` so no prompt containing the offending content ever reaches the provider. - **Tool authorization** — in `before_tool_callback`, check the tool's name plus the caller identity you stashed in state; return an error dict for anything unauthorized. The model then reasons about a denial instead of the run exploding. - **Output redaction** — in `after_model_callback` or `after_tool_callback`, rewrite the payload before it reaches the user or the next model call. - **Caching** — key on the request in `before_model_callback` and return a stored `LlmResponse` on a hit. ## Limits to state honestly Callbacks are in-process Python around your agent, which makes them a real control point for what your agent sends and receives, but they are not a substitute for authorization at the resource itself: a tool that hits an API with a broad service-account credential is still that credential's blast radius if any other code path can call it. They also add latency on every iteration, so keep them cheap or make them async. And a `before_tool_callback` that raises rather than returning ends the run — returning an error dict keeps the agent alive and lets the model apologize or try another route, which is almost always the better user experience.

  • You want a policy reminder appended to every LLM call. Which hook, and what do you return?
    `before_model_callback`. Mutate the incoming `llm_request` to add the instruction, then return `None` so the model call proceeds with your edit applied. Returning an `LlmResponse` there would skip the model entirely — the wrong tool for injection. Because the hook fires on every iteration of the agent loop, the reminder survives long multi-step runs where a single static instruction has drifted far up the context.
  • A before_tool_callback rejects an argument. Should it raise or return a dict?
    Return a dict, typically an error payload. The tool is then skipped and the model receives your dict as the tool result, so it can explain the refusal or try a different route. Raising propagates out and terminates the run, turning a policy decision into an outage from the user's point of view.
  • How do callbacks interact with session state?
    They receive a `CallbackContext` (or `ToolContext` for tool hooks) whose `state` you can read and write. Writes go through the context so they are recorded as state deltas on the resulting event rather than as an invisible dict mutation, which means they show up in the dev UI's State and Events views and persist through whatever session service is configured.
  • What is the cost of putting an expensive check in before_model_callback?
    It runs once per LLM call, not once per user turn. In an agent that loops through several model-tool iterations, a 200 ms check becomes near-a-second of added latency on a five-step turn. Keep these hooks cheap, make them async if they do I/O, or move the expensive check to the agent-level hook that fires once.

saying these in an interview costs you the question

  • Thinking a returned value is merged into the request rather than replacing the step
  • Raising from a tool callback to block a call, killing the run
  • Assuming callbacks fire once per user turn instead of per call
  • Believing callbacks replace authorization at the underlying API
  • Editing llm_request but also returning a response, skipping the model unintentionally

context