Skip to content

Commit ef2d680

Browse files
allen-stephenjazhang00
authored andcommitted
feat(live): run before and after model callbacks in live mode
Run agent and plugin model callbacks on the bidi streaming path. - typed input reaches before model callback before it is forwarded - spoken input reaches the before model callback once the model returns its transcription of the utterance - model output reaches after model callback as it streams A callback that returns a replacement response blocks the turn. Where the model has already been given the input, the block reconnects the live session so it does not continue the blocked turn. Co-authored-by: Jason Zhang <jasoncz@google.com> PiperOrigin-RevId: 970678524
1 parent 543f55f commit ef2d680

6 files changed

Lines changed: 1091 additions & 14 deletions

File tree

docs/guides/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ This directory contains specific developer guides for the ADK Python implementat
2727
* [Event and NodeInfo](events/event/index.md) - Understanding Event and NodeInfo in workflows.
2828
* [RequestInput](events/request_input/index.md) - How to use RequestInput for human-in-the-loop interactions.
2929

30+
### Flows
31+
* [Live model callbacks](flows/llm_flows/base_llm_flow/live_model_callbacks.md) - Inspecting or blocking content on a live bidirectional session.
32+
3033
### Memory
3134
* [BaseMemoryService](memory/memory_service/index.md) - Storing finished sessions and recalling them from later conversations.
3235

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)