Summary
On /v1/chat/completionsstreaming, the output guardrail buffers only delta.content for the end-of-stream check_output — delta.tool_calls is never accumulated. So a blocked literal in a streamed tool-call's function.arguments bypasses the output guardrail.
This is asymmetric: chat non-streaming scans tool-call output (ChatResponse::guardrail_output_text, the #448 fix), and /v1/messagesstreaming scans them too — only chat streaming leaks.
Fix
Accumulate tool-call name + arguments from chunk.delta.tool_calls across the stream and include them in the end-of-stream synthesized ChatResponse passed to check_output (bounded to the hold-back cap; concatenated without a separator so a literal split across deltas reassembles). Under the default BufferFull policy the tool-call chunks are already held in pending, so a block holds them back (never on the wire).
Surfaced by the independent pre-fix audit of the #719 follow-up batch. Refs #719, #546, #448.
Summary
On
/v1/chat/completionsstreaming, the output guardrail buffers onlydelta.contentfor the end-of-streamcheck_output—delta.tool_callsis never accumulated. So a blocked literal in a streamed tool-call'sfunction.argumentsbypasses the output guardrail.This is asymmetric: chat non-streaming scans tool-call output (
ChatResponse::guardrail_output_text, the #448 fix), and/v1/messagesstreaming scans them too — only chat streaming leaks.Fix
Accumulate tool-call name + arguments from
chunk.delta.tool_callsacross the stream and include them in the end-of-stream synthesizedChatResponsepassed tocheck_output(bounded to the hold-back cap; concatenated without a separator so a literal split across deltas reassembles). Under the default BufferFull policy the tool-call chunks are already held inpending, so a block holds them back (never on the wire).Surfaced by the independent pre-fix audit of the #719 follow-up batch. Refs #719, #546, #448.