|
| 1 | +# Live model callbacks |
| 2 | + |
| 3 | +`before_model_callback` and `after_model_callback` run during `run_live`, giving |
| 4 | +you a place to inspect or block content on a live bidirectional session. |
| 5 | +Returning an `LlmResponse` from either callback substitutes your content and |
| 6 | +ends the current turn. |
| 7 | + |
| 8 | +## Introduction |
| 9 | + |
| 10 | +A live agent maintains an open, bidirectional connection to the model. While the |
| 11 | +connection is active, the client streams audio, text, or video, and the model |
| 12 | +streams back audio and text. |
| 13 | + |
| 14 | +Model callbacks intercept interactions on this connection for tasks such as |
| 15 | +guardrails, redaction, audit logging, and content filtering. `BaseLlmFlow` |
| 16 | +invokes both agent-level callbacks (`LlmAgent.before_model_callback`) and |
| 17 | +plugin callbacks (`BasePlugin.before_model_callback`). |
| 18 | + |
| 19 | +## Get started |
| 20 | + |
| 21 | +Callbacks that inspect user input and model output for blocked terms: |
| 22 | + |
| 23 | +```python |
| 24 | +from typing import Optional |
| 25 | + |
| 26 | +from google.adk.agents import Agent |
| 27 | +from google.adk.agents.callback_context import CallbackContext |
| 28 | +from google.adk.models.llm_request import LlmRequest |
| 29 | +from google.adk.models.llm_response import LlmResponse |
| 30 | +from google.genai import types |
| 31 | + |
| 32 | + |
| 33 | +def block_input_keyword( |
| 34 | + callback_context: CallbackContext, |
| 35 | + llm_request: LlmRequest, |
| 36 | +) -> Optional[LlmResponse]: |
| 37 | + """Blocks user input containing a forbidden keyword.""" |
| 38 | + text = None |
| 39 | + if llm_request.contents and llm_request.contents[-1].parts: |
| 40 | + text = ''.join( |
| 41 | + part.text for part in llm_request.contents[-1].parts if part.text |
| 42 | + ) |
| 43 | + |
| 44 | + if not text or 'forbidden' not in text.lower(): |
| 45 | + return None # Send the request to the model. |
| 46 | + |
| 47 | + return LlmResponse( |
| 48 | + content=types.Content( |
| 49 | + role='model', |
| 50 | + parts=[types.Part(text='That input is not allowed.')], |
| 51 | + ) |
| 52 | + ) |
| 53 | + |
| 54 | + |
| 55 | +def block_output_keyword( |
| 56 | + callback_context: CallbackContext, |
| 57 | + llm_response: LlmResponse, |
| 58 | +) -> Optional[LlmResponse]: |
| 59 | + """Ends the turn when the model's output so far mentions a blocked term.""" |
| 60 | + text = None |
| 61 | + if llm_response.output_transcription: |
| 62 | + text = llm_response.output_transcription.text |
| 63 | + |
| 64 | + if not text or 'forbidden' not in text.lower(): |
| 65 | + return None # Deliver the response unchanged. |
| 66 | + |
| 67 | + return LlmResponse( |
| 68 | + content=types.Content( |
| 69 | + role='model', |
| 70 | + parts=[types.Part(text="I can't help with that.")], |
| 71 | + ) |
| 72 | + ) |
| 73 | + |
| 74 | + |
| 75 | +root_agent = Agent( |
| 76 | + name='guarded_agent', |
| 77 | + instruction='Answer the user.', |
| 78 | + before_model_callback=block_input_keyword, |
| 79 | + after_model_callback=block_output_keyword, |
| 80 | +) |
| 81 | +``` |
| 82 | + |
| 83 | +Run this agent with `run_live`. When the model output matches the keyword, the |
| 84 | +client receives the replacement text on an event with `turn_complete=True`, the |
| 85 | +connection resets, and the conversation continues on the next user turn. |
| 86 | + |
| 87 | +## How it works |
| 88 | + |
| 89 | +### `before_model_callback` |
| 90 | + |
| 91 | +`before_model_callback` inspects user input before the model acts on it. In live |
| 92 | +sessions, it handles two forms of user input: |
| 93 | + |
| 94 | +- **Typed text**: Evaluated before the text is sent to the model. |
| 95 | +- **Spoken audio**: Evaluated once the user finishes speaking and the model |
| 96 | + transcribes the speech. |
| 97 | + |
| 98 | +#### What it receives |
| 99 | + |
| 100 | +The callback receives an `LlmRequest` whose `contents` list contains the user |
| 101 | +message or transcription currently being evaluated. Because live mode streams |
| 102 | +data continuously, this request object is a read-only snapshot. |
| 103 | + |
| 104 | +#### What a returned response does |
| 105 | + |
| 106 | +- **Returning `None`**: The user input is sent to the model (or generation |
| 107 | + proceeds normally). |
| 108 | +- **Returning an `LlmResponse`**: The user input is blocked. The framework emits |
| 109 | + your replacement response to the client with `turn_complete=True` and records |
| 110 | + it in the session. |
| 111 | + - For **typed text**, the original message is withheld from the model entirely. |
| 112 | + - For **spoken audio**, the active connection automatically resets so the |
| 113 | + model drops any in-flight response it began generating while listening. |
| 114 | + |
| 115 | +### `after_model_callback` |
| 116 | + |
| 117 | +`after_model_callback` inspects the model's response as it is generated, before |
| 118 | +it is delivered to the user. |
| 119 | + |
| 120 | +- **Streaming evaluation**: The callback runs as model output arrives, evaluating |
| 121 | + accumulated audio transcriptions. |
| 122 | + |
| 123 | +#### What it receives |
| 124 | + |
| 125 | +The callback receives an `LlmResponse` whose `output_transcription` contains the |
| 126 | +accumulated text generated by the model so far in the current turn. |
| 127 | + |
| 128 | +#### What a returned response does |
| 129 | + |
| 130 | +- **Returning `None`**: Output continues streaming to the client unchanged. |
| 131 | +- **Returning an `LlmResponse`**: Generation halts immediately. The framework |
| 132 | + delivers your replacement response to the client with `turn_complete=True`, |
| 133 | + and resets the active live connection so the model does not retain the blocked generation. |
| 134 | + |
| 135 | +### Reconnecting after a refusal |
| 136 | + |
| 137 | +When `before_model_callback` blocks spoken audio or `after_model_callback` blocks |
| 138 | +model output, the model has already processed part of the exchange. To ensure the |
| 139 | +model does not remember refused content in subsequent turns: |
| 140 | + |
| 141 | +1. The framework closes the active live connection. |
| 142 | +2. It opens a fresh live session with session resumption cleared. |
| 143 | +3. The new connection's history is populated from session events, which contain |
| 144 | + the replacement response rather than the blocked content. |
| 145 | + |
| 146 | +## Configuration options |
| 147 | + |
| 148 | +Live model callbacks use the standard ADK model callback interfaces: |
| 149 | + |
| 150 | +| Callback | Return `None` | Return `LlmResponse` | |
| 151 | +| :--- | :--- | :--- | |
| 152 | +| `before_model_callback` | Send the user's content to the model. | Withhold it and emit the replacement instead. Reconnects when the input was spoken. | |
| 153 | +| `after_model_callback` | Deliver the model's response unchanged. | Emit the replacement, end the turn, and reconnect. | |
| 154 | + |
| 155 | +Both are available as agent callbacks on `LlmAgent` and as plugin callbacks on |
| 156 | +`BasePlugin`. Plugin callbacks run first; if a plugin returns a response, the |
| 157 | +agent callbacks are skipped. |
| 158 | + |
| 159 | +## Limitations |
| 160 | + |
| 161 | +**Audio blobs are not screened.** The model callbacks run on live bidi input |
| 162 | +text as well as *transcriptions* of spoken and model audio. Input transcriptions |
| 163 | +are expected to arrive before the main model response. Output transcriptions are |
| 164 | +expected to arrive interleaved with the audio. |
| 165 | + |
| 166 | +**Output text modality is not screened.** A text-only live agent emits no |
| 167 | +output transcription, so it doesn't fire `after_model_callback` and its output |
| 168 | +is unscreened. This is a current limitation and will be fixed to include text |
| 169 | +output screening in the future. |
| 170 | + |
| 171 | +**Turn-level semantics**: Output callbacks receive the accumulated text of the |
| 172 | +turn, and returning an `LlmResponse` ends the turn with your replacement. |
| 173 | +Modifying individual streaming chunks while leaving generation active is |
| 174 | +currently not supported. |
| 175 | + |
| 176 | +**Mutating LLM requests and responses.** Callbacks in live may receive copies of |
| 177 | +`LlmRequest` and `LlmResponse` which are meant to be read-only. An `LlmResponse` |
| 178 | +can be returned to replace the turn. `on_event_callback` can be used to annotate |
| 179 | +events. |
| 180 | + |
| 181 | +**Callback latency.** The callback is awaited inside the receive loop, so |
| 182 | +blocking operations or network calls may add latency to the streaming session. |
| 183 | + |
| 184 | +### Live vs. non-live comparison |
| 185 | + |
| 186 | +While live and non-live (unary) flows share the same `before_model_callback` and `after_model_callback` interfaces, their behavior reflects the differences between batch generation and bidirectional streaming: |
| 187 | + |
| 188 | +| Behavior | Non-live (Unary) | Live (Bidirectional Streaming) | |
| 189 | +| :--- | :--- | :--- | |
| 190 | +| **`before_model_callback` timing** | Once per LLM call before generation. | Evaluated for typed text before sending, but for spoken audio only after transcription. | |
| 191 | +| **`before_model_callback` payload** | Full conversation history in `llm_request.contents`. | A single-item `contents` list containing the specific user message or transcription being evaluated. | |
| 192 | +| **`after_model_callback` timing** | Once per completed model response. | Continuously as output arrives, evaluating accumulated audio transcriptions. | |
| 193 | +| **`after_model_callback` payload** | Full generated `Content` in `llm_response.content`. | Accumulated text in `llm_response.output_transcription`. | |
| 194 | +| **Blocking refusal behavior** | Directly replaces the response or halts execution. | Replaces the turn and resets the connection to clear refused content from model memory. | |
| 195 | +| **Request & response mutability** | Standard mutable objects. | Read-only snapshots. | |
0 commit comments