Skip to content

Commit fdda17c

Browse files
princemittalrcopybara-github
authored andcommitted
feat: add after_tool_callback to screen tool output
Input and model output results that report `MATCH_FOUND` are now blocked even when `invocation_result` is not `SUCCESS` and `block_on_screening_failure` is `False`. Merge #6969 Fixes #6966 PiperOrigin-RevId: 994696801
1 parent f5b6b08 commit fdda17c

4 files changed

Lines changed: 522 additions & 35 deletions

File tree

‎docs/guides/integrations/model_armor/index.md‎

Lines changed: 40 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
# Model Armor
22

3-
`ModelArmorPlugin` screens user input and model output against [Google Cloud Model Armor](https://cloud.google.com/security-command-center/docs/model-armor-overview) templates. When a filter matches, or when screening cannot complete, the offending content is replaced with a safe message before it reaches the model or the user.
3+
`ModelArmorPlugin` screens user input, model output, and tool output against [Google Cloud Model Armor](https://cloud.google.com/security-command-center/docs/model-armor-overview) templates. When a filter matches, or when screening cannot complete, the offending content is replaced with a safe message before it reaches the model or the user.
44

55
## Introduction
66

77
Model Armor is a Google Cloud service that inspects text for prompt injection and jailbreak attempts, harmful content, and sensitive data. You define what to look for in a *template* — a named, server-side policy — and the service returns a verdict for each piece of text you send it.
88

9-
The integration is two public types: `ModelArmorPlugin`, a `BasePlugin` subclass driven by `PluginManager`, and `ModelArmorConfig`, which says which templates to screen against and what to do about a match. The plugin reads text off the `LlmRequest` and `LlmResponse`, calls Model Armor, and returns a replacement `LlmResponse` when content should be blocked.
9+
The integration is two public types: `ModelArmorPlugin`, a `BasePlugin` subclass driven by `PluginManager`, and `ModelArmorConfig`, which says which templates to screen against and what to do about a match. The plugin reads text off the `LlmRequest`, `LlmResponse`, and tool results, calls Model Armor, and returns a replacement when content should be blocked.
1010

1111
Key features:
1212

13-
- **Input and output screening**, each governed by its own template, and each optional.
13+
- **Input, output, and tool output screening**, each governed by its own template, and each optional.
1414
- **Block screening failures by default**: by default screening failures are blocked rather than delivered.
15-
- **Blocked responses are marked** with `custom_metadata['model_armor_blocked']` so your application can detect them.
15+
- **Blocked input and model output responses are marked** with `custom_metadata['model_armor_blocked']` so your application can detect them (blocked tool outputs return a plain `{'error': ...}` dict to the model and do not carry this marker).
1616

1717
## Get started
1818

@@ -76,22 +76,34 @@ Credentials come from Application Default Credentials.
7676
3. The text is sent to Model Armor's `SanitizeModelResponse` method.
7777
4. It acts on the result (below).
7878

79+
### `after_tool_callback` - screening tool output
80+
81+
`after_tool_callback` runs after each tool call:
82+
83+
1. If `tool_output_template_name` is unset, it returns immediately and nothing is screened.
84+
2. It extracts and serializes text from the tool result (skipping raw bytes and non-text media parts), splitting oversized text into overlapping 65,536-character chunks.
85+
3. Each chunk is sent to Model Armor's `SanitizeUserPrompt` method.
86+
4. It acts on each result (below).
87+
7988
### Acting on a result
8089

81-
| `invocation_result` | Meaning | Plugin behavior |
82-
| :--- | :--- | :--- |
83-
| `SUCCESS` | Every filter ran. | Check `filter_match_state`. |
84-
| Anything else | Some or all filters were skipped, failed, or the field was unset. | Screening failure. |
90+
| `filter_match_state` | `invocation_result` | Meaning | Plugin behavior |
91+
| :--- | :--- | :--- | :--- |
92+
| `MATCH_FOUND` | Any | At least one filter tripped. | Block content. |
93+
| Anything else | `SUCCESS` | Every filter ran with no match. | Pass through untouched. |
94+
| Anything else | Anything else | Some or all filters were skipped, failed, or the field was unset. | Screening failure. |
8595

86-
When screening completes successfully, a `filter_match_state` of `MATCH_FOUND` means at least one filter tripped, and the content is blocked. Anything else passes through untouched.
96+
`filter_match_state` is checked first, so a partial result that still reports `MATCH_FOUND` is always blocked.
8797

8898
A screening failure is routed through `block_on_screening_failure` and blocked by default.
8999

90100
### The blocked response
91101

92-
Blocking returns an `LlmResponse` carrying the message for the direction that
93-
was screened: `input_blocked_message` for user input, `output_blocked_message`
94-
for model output.
102+
Blocking user input or model output returns an `LlmResponse` carrying the
103+
message for the direction that was screened: `input_blocked_message` for user
104+
input, `output_blocked_message` for model output. Blocking tool output returns
105+
`{'error': tool_output_blocked_message}` to the model as the tool result instead
106+
of an `LlmResponse`.
95107

96108
### Template paths and regional endpoints
97109

@@ -124,27 +136,30 @@ Options introduced by `ModelArmorPlugin` (those inherited from `BasePlugin` are
124136
| :--- | :--- | :--- | :--- |
125137
| `prompt_template_name` | `str \| None` | `None` | Template used to screen user input. Unset means input is not screened. |
126138
| `response_template_name` | `str \| None` | `None` | Template used to screen model output. Unset means output is not screened. |
139+
| `tool_output_template_name` | `str \| None` | `None` | Template used to screen tool output. Unset means tool output is not screened. |
127140
| `input_blocked_message` | `str` | `"I'm sorry, but I can't help with that request."` | Replacement text shown when user input is blocked. |
128141
| `output_blocked_message` | `str` | `"I'm sorry, but I can't help with that request."` | Replacement text shown when model output is blocked. |
142+
| `tool_output_blocked_message` | `str` | `"Tool output was blocked by Model Armor."` | Replacement error message returned to the model when tool output is blocked. |
129143
| `block_on_screening_failure` | `bool` | `True` | Whether to block content that could not be screened. |
130144

131-
At least one of the two template names must be set.
145+
At least one of `prompt_template_name`, `response_template_name`, or `tool_output_template_name` must be set.
132146

133-
#### `prompt_template_name` and `response_template_name`
147+
#### `prompt_template_name`, `response_template_name`, and `tool_output_template_name`
134148

135-
Both fields require fully-qualified resource paths formatted as:
149+
All template fields require fully-qualified resource paths formatted as:
136150
`projects/{project}/locations/{location}/templates/{template}`
137151

138-
You can configure either or both:
152+
You can configure any combination:
139153

140154
- `prompt_template_name`: Screens user input prompts before forwarding to the model.
141155
- `response_template_name`: Screens model responses before delivering to the user.
156+
- `tool_output_template_name`: Screens tool outputs before returning to the model.
142157

143-
If both are set they must reside in the same GCP location — see [Template paths and regional endpoints](#template-paths-and-regional-endpoints).
158+
If multiple templates are set they must reside in the same GCP location — see [Template paths and regional endpoints](#template-paths-and-regional-endpoints).
144159

145-
#### `input_blocked_message` and `output_blocked_message`
160+
#### `input_blocked_message`, `output_blocked_message`, and `tool_output_blocked_message`
146161

147-
Defines the replacement text returned to the user when a prompt or response is blocked. Screening failures reuse the message for the direction that failed.
162+
Defines the replacement text returned to the user when a prompt or response is blocked, or returned to the model in `{'error': tool_output_blocked_message}` when tool output is blocked. Screening failures reuse the message for the direction that failed.
148163

149164
#### `block_on_screening_failure`
150165

@@ -174,7 +189,7 @@ config = ModelArmorConfig(
174189

175190
### Detecting blocks in your application
176191

177-
Blocked responses carry a marker, so a UI can render them differently from a real answer:
192+
Blocked input and model output responses carry a `custom_metadata['model_armor_blocked']` marker, so a UI can render them differently from a real answer (blocked tool outputs return a plain `{'error': ...}` dict to the model and do not carry this marker):
178193

179194
```python
180195
async for event in runner.run_async(...):
@@ -184,7 +199,11 @@ async for event in runner.run_async(...):
184199

185200
## Limitations
186201

187-
- **Tool output is not screened.** Only the most recent `user` content with text parts is sent for screening. Tool results are added to the request as `user` content whose only part is a `function_response` and doesn't reach Model Armor.
202+
- **Live streaming and long-running tools are not screened.** Live streaming tools send yielded chunks directly to the live request queue and long-running tools deliver their final `function_response` on a later turn, so their outputs skip `after_tool_callback`.
203+
204+
- **Media in tool results is not screened.** Raw `bytes` values and non-text `Part` objects (such as `inline_data` or `file_data` media) in tool results are skipped during text extraction and are not sent to Model Armor.
205+
206+
- **Plugin registration order matters.** `PluginManager` stops at the first plugin that returns a non-`None` value from a callback. Register `ModelArmorPlugin` ahead of plugins that return a value from `after_tool_callback` (such as `MultimodalToolResultsPlugin`) so tool output screening is not bypassed.
188207

189208
- **Enforcement mode is limited.** The Model Armor plugin is currently limited to logging detection results and blocking content. Future extensions could include replacing or redacting text.
190209

‎src/google/adk/integrations/model_armor/_config.py‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from pydantic import model_validator
2222

2323
_DEFAULT_BLOCKED_MESSAGE = "I'm sorry, but I can't help with that request."
24+
_DEFAULT_TOOL_OUTPUT_BLOCKED_MESSAGE = 'Tool output was blocked by Model Armor.'
2425

2526

2627
class ModelArmorConfig(BaseModel):
@@ -50,15 +51,30 @@ class ModelArmorConfig(BaseModel):
5051
output_blocked_message: str = _DEFAULT_BLOCKED_MESSAGE
5152
"""The safe replacement text returned to the user when model output is blocked."""
5253

54+
tool_output_template_name: Optional[str] = None
55+
"""The Model Armor template used to screen tool output.
56+
57+
Should use the fully-qualified resource name:
58+
``projects/{project}/locations/{location}/templates/{template}``.
59+
If unset, tool output screening is skipped.
60+
"""
61+
62+
tool_output_blocked_message: str = _DEFAULT_TOOL_OUTPUT_BLOCKED_MESSAGE
63+
"""The safe replacement text returned when tool output is blocked."""
64+
5365
block_on_screening_failure: bool = True
5466
"""Whether to block when Model Armor screening fails."""
5567

5668
@model_validator(mode='after')
5769
def _validate_templates(self) -> ModelArmorConfig:
5870
"""Ensure at least one template is configured."""
59-
if not self.prompt_template_name and not self.response_template_name:
71+
if (
72+
not self.prompt_template_name
73+
and not self.response_template_name
74+
and not self.tool_output_template_name
75+
):
6076
raise ValueError(
61-
'At least one of prompt_template_name or response_template_name'
62-
' must be set for ModelArmorConfig.'
77+
'At least one of prompt_template_name, response_template_name, or'
78+
' tool_output_template_name must be set for ModelArmorConfig.'
6379
)
6480
return self

‎src/google/adk/integrations/model_armor/_plugin.py‎

Lines changed: 118 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,22 @@
1414

1515
"""Model Armor guardrail plugin.
1616
17-
Screens user input and model output with Google Cloud Model Armor in both
18-
unary (``run_async``) and live (``run_live``) modes, through the ordinary
19-
``before_model_callback`` and ``after_model_callback`` seams.
17+
Screens user input, model output, and tool output with Google Cloud Model Armor
18+
in both unary (``run_async``) and live (``run_live``) modes, through the
19+
ordinary ``before_model_callback``, ``after_model_callback``, and
20+
``after_tool_callback`` seams.
2021
2122
- input reaches ``before_model_callback`` as request content.
2223
- output reaches ``after_model_callback`` as content parts in unary mode, and
2324
as an output transcription in live mode.
25+
- tool output reaches ``after_tool_callback`` as the tool result.
2426
"""
2527

2628
from __future__ import annotations
2729

30+
import json
2831
import logging
32+
from typing import Any
2933
from typing import Optional
3034

3135
from google.api_core.client_options import ClientOptions
@@ -38,6 +42,8 @@
3842
from ...models.llm_request import LlmRequest
3943
from ...models.llm_response import LlmResponse
4044
from ...plugins.base_plugin import BasePlugin
45+
from ...tools.base_tool import BaseTool
46+
from ...tools.tool_context import ToolContext
4147
from ._config import ModelArmorConfig
4248

4349
try:
@@ -57,9 +63,13 @@
5763

5864
USER_AGENT = f'adk-model-armor-plugin google-adk/{version.__version__}'
5965

66+
# Model Armor skips filters past 65,536 tokens (~1 token/char worst case).
67+
_MAX_TOOL_OUTPUT_CHARS = 65_536
68+
_TOOL_OUTPUT_CHUNK_OVERLAP_CHARS = 4_096
69+
6070

6171
class ModelArmorPlugin(BasePlugin):
62-
"""A plugin that screens input and output with Google Cloud Model Armor."""
72+
"""A plugin that screens input, output, and tool output with Model Armor."""
6373

6474
def __init__(
6575
self,
@@ -87,7 +97,9 @@ def __init__(
8797
self._credentials = credentials
8898

8999
self._location = _shared_template_location(
90-
config.prompt_template_name, config.response_template_name
100+
config.prompt_template_name,
101+
config.response_template_name,
102+
config.tool_output_template_name,
91103
)
92104

93105
async def before_model_callback(
@@ -186,6 +198,42 @@ async def _sanitize_model_response(
186198
)
187199
return response.sanitization_result
188200

201+
async def after_tool_callback(
202+
self,
203+
*,
204+
tool: BaseTool,
205+
tool_args: dict[str, Any],
206+
tool_context: ToolContext,
207+
result: dict[str, Any],
208+
) -> Optional[dict[str, Any]]:
209+
"""Screens tool output text against the configured tool output template."""
210+
if not self._config.tool_output_template_name:
211+
return None
212+
213+
text = _extract_tool_result_text(result)
214+
if not text:
215+
return None
216+
217+
step = _MAX_TOOL_OUTPUT_CHARS - _TOOL_OUTPUT_CHUNK_OVERLAP_CHARS
218+
for start in range(0, len(text), step):
219+
chunk = text[start : start + _MAX_TOOL_OUTPUT_CHARS]
220+
try:
221+
sanitization_result = await self._sanitize_user_prompt(
222+
chunk, self._config.tool_output_template_name
223+
)
224+
except Exception: # pylint: disable=broad-except
225+
logger.exception('Model Armor tool output screening call failed.')
226+
if self._config.block_on_screening_failure:
227+
return {'error': self._config.tool_output_blocked_message}
228+
else:
229+
if self._should_block(sanitization_result, direction='tool output'):
230+
return {'error': self._config.tool_output_blocked_message}
231+
232+
if start + _MAX_TOOL_OUTPUT_CHARS >= len(text):
233+
break
234+
235+
return None
236+
189237
async def close(self) -> None:
190238
"""Closes the underlying client."""
191239
if self._client:
@@ -199,19 +247,30 @@ def _handle_sanitization_result(
199247
blocked_message: str,
200248
) -> Optional[LlmResponse]:
201249
"""Handles a Model Armor sanitization result."""
250+
if self._should_block(result, direction=direction):
251+
return self._blocked_response(blocked_message)
252+
return None
253+
254+
def _should_block(
255+
self,
256+
result: modelarmor_v1.SanitizationResult,
257+
*,
258+
direction: str,
259+
) -> bool:
260+
"""Returns whether a sanitization result should be blocked."""
261+
if result.filter_match_state == modelarmor_v1.FilterMatchState.MATCH_FOUND:
262+
logger.warning('Model Armor %s sanitization match found.', direction)
263+
return True
264+
202265
if result.invocation_result != modelarmor_v1.InvocationResult.SUCCESS:
203266
logger.error(
204267
'Model Armor %s sanitization did not succeed: invocation_result=%r',
205268
direction,
206269
result.invocation_result,
207270
)
208-
return self._handle_screening_failure(blocked_message)
209-
210-
if result.filter_match_state == modelarmor_v1.FilterMatchState.MATCH_FOUND:
211-
logger.warning('Model Armor %s sanitization match found.', direction)
212-
return self._blocked_response(blocked_message)
271+
return self._config.block_on_screening_failure
213272

214-
return None
273+
return False
215274

216275
def _handle_screening_failure(
217276
self, blocked_message: str
@@ -272,6 +331,54 @@ def _content_text(content: Optional[types.Content]) -> Optional[str]:
272331
return '\n'.join(texts)
273332

274333

334+
_SKIP = object()
335+
336+
337+
def _filter_screenable_value(value: object) -> object:
338+
"""Filters bytes and non-text media parts out of a tool result value."""
339+
if isinstance(
340+
value, (bytes, bytearray, memoryview, types.Blob, types.FileData)
341+
):
342+
return _SKIP
343+
if isinstance(value, types.Part):
344+
if value.text and not value.thought and value.text.strip():
345+
return value.text
346+
return _SKIP
347+
if isinstance(value, dict):
348+
cleaned = {
349+
k: filtered
350+
for k, v in value.items()
351+
if not isinstance(k, (bytes, bytearray, memoryview))
352+
and (filtered := _filter_screenable_value(v)) is not _SKIP
353+
}
354+
return cleaned if cleaned or not value else _SKIP
355+
if isinstance(value, (list, tuple)):
356+
cleaned_list = [
357+
filtered
358+
for item in value
359+
if (filtered := _filter_screenable_value(item)) is not _SKIP
360+
]
361+
return cleaned_list if cleaned_list or not value else _SKIP
362+
return value
363+
364+
365+
def _extract_tool_result_text(result: Any) -> Optional[str]:
366+
"""Extracts screenable text from a tool result."""
367+
if result is None:
368+
return None
369+
filtered = _filter_screenable_value(result)
370+
if filtered is _SKIP:
371+
return None
372+
if isinstance(filtered, str):
373+
return filtered if filtered.strip() else None
374+
try:
375+
serialized = json.dumps(filtered, default=str, ensure_ascii=False)
376+
return serialized if serialized not in ('{}', 'null', '[]', '""') else None
377+
except Exception: # pylint: disable=broad-except
378+
text = str(filtered)
379+
return text if text.strip() else None
380+
381+
275382
def _regional_endpoint(location: str) -> str:
276383
"""Builds the Model Armor regional endpoint for a location."""
277384
return f'modelarmor.{location}.rep.googleapis.com'

0 commit comments

Comments
 (0)