fix(realtime): forward sanitized function_call_output on guardrail block

Providers that pair every toolCall with a toolResponse (e.g. Gemini and
Vertex Live) stay in the awaiting-tool-call state until a toolResponse
arrives. Dropping a blocked function_call_output outright left those
providers stalled — the subsequent guardrail clientContent and
response.create were ignored because the prior toolCall had no matching
toolResponse.

When the client-supplied tool output fails the realtime guardrail check,
forward a sanitized placeholder function_call_output (same call_id,
generic policy marker as output) instead of dropping the message
entirely. The placeholder carries no blocked content, so the model never
sees it, while still completing the provider's tool-call cycle so the
session can recover and the violation message reaches the user.
This commit is contained in:
mateo-berri 2026-05-23 01:55:05 +00:00
parent d3490859a4
commit 70e1169989
No known key found for this signature in database
2 changed files with 33 additions and 2 deletions

View file

@ -899,6 +899,30 @@ class RealTimeStreaming:
output_text
)
if blocked:
# Forward a sanitized function_call_output so
# providers that pair every toolCall with a
# toolResponse (e.g. Gemini/Vertex Live) exit
# their pending-tool-call state. Dropping the
# blocked item outright would leave such
# providers waiting indefinitely while the
# subsequent guardrail clientContent is
# ignored. The sanitized payload carries no
# blocked content — only a generic policy
# marker.
sanitized_msg = json.dumps(
{
**msg_obj,
"item": {
**item,
"output": json.dumps(
{
"error": "Tool output blocked by content policy",
}
),
},
}
)
await self._send_to_backend(sanitized_msg)
self._pending_guardrail_message = output_text
continue

View file

@ -951,10 +951,17 @@ async def test_realtime_function_call_output_guardrail_blocks_and_returns_error(
and json.loads(m).get("type") == "conversation.item.create"
and json.loads(m).get("item", {}).get("type") == "function_call_output"
]
assert len(forwarded_tool_outputs) == 0, (
f"Blocked function_call_output should not be forwarded, got: "
# A sanitized placeholder must reach the backend so providers that pair
# every toolCall with a toolResponse (Gemini/Vertex Live) exit their
# pending-tool-call state instead of stalling. The placeholder must NOT
# contain any of the blocked content.
assert len(forwarded_tool_outputs) == 1, (
f"Sanitized function_call_output should be forwarded, got: "
f"{forwarded_tool_outputs}"
)
sanitized_item = forwarded_tool_outputs[0]["item"]
assert sanitized_item["call_id"] == "call_123"
assert "test@example.com" not in sanitized_item["output"]
litellm.callbacks = [] # cleanup