11"""Granite Guardian safety capability for input/output guardrail moderation."""
22
33import asyncio
4+ from collections .abc import AsyncIterable
45from dataclasses import dataclass , field
56from typing import ClassVar , Literal , Optional
67from uuid import uuid4
1415from pydantic_ai .direct import model_request
1516from pydantic_ai .exceptions import UnexpectedModelBehavior
1617from pydantic_ai .messages import (
18+ AgentStreamEvent ,
1719 ModelRequest ,
1820 ModelResponse ,
21+ PartEndEvent ,
22+ PartStartEvent ,
1923 TextPart ,
2024)
2125from pydantic_ai .models import Model
3337from models .config import GraniteGuardianConfig , RiskDefinition
3438from pydantic_ai_lightspeed .capabilities .base import AbstractSafetyCapability
3539from pydantic_ai_lightspeed .capabilities .granite_guardian .utils import (
40+ aclose_if_supported ,
3641 build_guardian_block ,
42+ event_text ,
3743 is_safe ,
3844)
3945from pydantic_ai_lightspeed .capabilities .utils import (
4046 extract_conversation_id ,
4147 message_to_str ,
4248)
43- from utils .conversations import append_turn_to_conversation
49+ from utils .conversations import (
50+ append_turn_to_conversation ,
51+ replace_last_assistant_message ,
52+ )
53+ from utils .token_estimator import estimate_tokens
4454
4555type Guardrail = tuple [str , str , float , str ]
4656
4757logger = get_logger (__name__ )
4858
4959
60+ class _OutputGuardrailViolation (Exception ):
61+ """Raised when streamed output violates an OUTPUT-point guardrail."""
62+
63+
64+ async def _drain_remaining (stream : AsyncIterable [AgentStreamEvent ]) -> None :
65+ """Exhaust an agent stream without yielding or acting on its events.
66+
67+ Called after an OUTPUT-point violation is detected, before the
68+ violation is raised to the caller, so the underlying provider call
69+ still runs to natural completion instead of being torn down
70+ mid-generation.
71+
72+ Cutting the stream immediately on violation (the previous behavior)
73+ meant the provider's final usage totals were never received (most
74+ providers only report usage in the terminating chunk), and OGX's own
75+ conversation-persistence side effect for the turn -- tied to the
76+ response completing -- could be left half-done. Draining lets both
77+ finish normally; only the *content* released to the end user is
78+ withheld.
79+
80+ Parameters:
81+ stream: The stream to exhaust.
82+ """
83+ try :
84+ async for _ in stream :
85+ pass
86+ except Exception : # pylint: disable=broad-except
87+ # Best-effort: a failure while draining (e.g. the connection dropping)
88+ # must not mask the guardrail violation that's about to be raised.
89+ logger .debug (
90+ "Ignoring error while draining stream after guardrail violation" ,
91+ exc_info = True ,
92+ )
93+
94+
5095async def _package_risk_check_task (
5196 prompt : str , guardrail : Guardrail , model : Model
5297) -> tuple [ModelResponse , float , str ]:
@@ -174,10 +219,26 @@ def _get_batch_size(parallel: StrictBool | int, num_guardrail: int) -> int:
174219class GraniteGuardian (AbstractSafetyCapability ):
175220 """Safety capability using Granite Guardian for risk-based moderation.
176221
177- Uses Granite Guardian's logprob-based scoring to evaluate user input
178- against configured risk categories. When used as a pydantic-ai capability,
179- ``wrap_run`` applies input guardrails. The ``run`` method provides a
180- standalone shield interface for use outside the agent lifecycle.
222+ Uses Granite Guardian's logprob-based scoring to evaluate text against
223+ configured risk categories. When used as a pydantic-ai capability:
224+
225+ - ``wrap_run`` applies INPUT-point risks to the user prompt before the
226+ real run starts.
227+ - ``wrap_run_event_stream`` applies OUTPUT-point risks to the generated
228+ response, checking each newly generated chunk roughly every
229+ ``config.streaming_output_check_interval_tokens`` tokens (plus a final
230+ check over any remainder), so a violation partway through generation
231+ stops the response before any more of it is released to the caller.
232+ Each check screens only the chunk generated since the previous check
233+ (not the whole response so far), so Guardian's workload stays linear
234+ in the response length instead of growing quadratically. None of the
235+ currently configured risk categories require cross-chunk context; if
236+ one ever does, that risk should carry its own overlap/context handling
237+ rather than reintroducing full-history re-checks for every risk.
238+
239+ Either check short-circuits the run with the same kind of rejection
240+ result. The ``run`` method provides a standalone shield interface for
241+ use outside the agent lifecycle (see ``run_moderation_guardrail_point``).
181242
182243 Attributes:
183244 config: Granite Guardian configuration with risks and connection details.
@@ -232,7 +293,10 @@ async def wrap_run(
232293
233294 Evaluates the user prompt against all INPUT-point risks. If any risk
234295 is violated, the run is short-circuited with a rejection message.
235- Otherwise, the handler is called to proceed with the real run.
296+ Otherwise, the handler is called to proceed with the real run, which
297+ applies OUTPUT-point risks incrementally via ``wrap_run_event_stream``.
298+ A violation surfaced there is caught here and turned into the same
299+ kind of rejection.
236300
237301 Parameters:
238302 ctx: The run context containing the user prompt and usage tracker.
@@ -252,34 +316,202 @@ async def wrap_run(
252316 )
253317
254318 if violation_message is not None :
255- state = GraphAgentState (
256- usage = ctx .usage ,
257- message_history = [
258- ModelRequest .user_text_prompt (user_prompt ),
259- ModelResponse (
260- [TextPart (violation_message )],
261- finish_reason = "stop" ,
262- ),
263- ],
264- )
319+ return await self ._reject (ctx , violation_message , real_turn_persisted = False )
320+
321+ try :
322+ return await handler () # proceed with the real run
323+ except _OutputGuardrailViolation as exc :
324+ return await self ._reject (ctx , str (exc ), real_turn_persisted = True )
325+
326+ async def _reject (
327+ self ,
328+ ctx : RunContext ,
329+ violation_message : str ,
330+ * ,
331+ real_turn_persisted : bool ,
332+ ) -> AgentRunResult :
333+ """Short-circuit the run with a rejection message and persist it.
334+
335+ Shared by the input-guardrail check in ``wrap_run`` and the
336+ output-guardrail check in ``wrap_run_event_stream`` (surfaced via
337+ ``_OutputGuardrailViolation``): both replace the turn with a single
338+ rejection message rather than exposing any content flagged as unsafe.
265339
266- conversation_id = extract_conversation_id (ctx .model )
267- if conversation_id is not None :
340+ Parameters:
341+ ctx: The run context containing the user prompt and usage tracker.
342+ violation_message: The message describing the violated risk.
343+ real_turn_persisted: Whether OGX already persisted a real
344+ assistant turn for this request before the violation was
345+ caught. This is the case for OUTPUT-point violations, since
346+ the real model call has already completed (and been recorded
347+ by OGX) by the time streamed text fails a guardrail check.
348+ INPUT-point violations short-circuit before any model call is
349+ made, so no turn exists yet.
350+
351+ Returns:
352+ An ``AgentRunResult`` whose output is the violation message.
353+ """
354+ user_prompt = message_to_str (ctx .prompt )
355+ state = GraphAgentState (
356+ usage = ctx .usage ,
357+ message_history = [
358+ ModelRequest .user_text_prompt (user_prompt ),
359+ ModelResponse (
360+ [TextPart (violation_message )],
361+ finish_reason = "stop" ,
362+ ),
363+ ],
364+ )
365+
366+ conversation_id = extract_conversation_id (ctx .model )
367+ if conversation_id is not None :
368+ client = AsyncOgxClientHolder ().get_client ()
369+ if real_turn_persisted :
370+ # OGX already recorded the real (possibly unsafe) assistant
371+ # turn; replace it instead of appending a second turn. The
372+ # stream is always drained to natural completion before a
373+ # violation is raised (see _drain_remaining), so that turn
374+ # is reliably persisted by the time we get here.
375+ await replace_last_assistant_message (
376+ client , conversation_id , violation_message
377+ )
378+ else :
268379 await append_turn_to_conversation (
269- AsyncOgxClientHolder (). get_client () ,
380+ client ,
270381 conversation_id ,
271382 user_prompt ,
272383 violation_message ,
273384 )
274- else :
275- logger .warning (
276- "Unable to determine conversation ID from model settings; "
277- "skipping v1/conversation persistence for rejected question."
278- )
385+ else :
386+ logger .warning (
387+ "Unable to determine conversation ID from model settings; "
388+ "skipping v1/conversation persistence for rejected question."
389+ )
279390
280- return AgentRunResult (output = violation_message , _state = state )
391+ return AgentRunResult (output = violation_message , _state = state )
392+
393+ async def wrap_run_event_stream (
394+ self ,
395+ ctx : RunContext ,
396+ * ,
397+ stream : AsyncIterable [AgentStreamEvent ],
398+ ) -> AsyncIterable [AgentStreamEvent ]:
399+ """Screen streamed output against OUTPUT-point risks as it's generated.
400+
401+ Text is buffered until ``config.streaming_output_check_interval_tokens``
402+ tokens accumulate, then that chunk (not the whole response so far) is
403+ checked against every OUTPUT-point risk. Events are released only
404+ after their text clears a check. A final check covers any remainder
405+ when the stream ends. When no OUTPUT-point risks are configured,
406+ events pass through unchanged. An event that contributes no text
407+ (e.g. a tool-call event) is also released immediately as long as no
408+ text is currently buffered; once text is pending, later non-text
409+ events are held with it and released together once that text clears.
410+
411+ Checking only the newly generated chunk on each interval (instead of
412+ re-sending everything checked so far) keeps Guardian's workload
413+ linear in the response length. It does mean risky content split
414+ exactly across a check boundary could, in principle, evade detection;
415+ none of the currently configured risk categories require that kind
416+ of cross-chunk context. Token counts are accumulated per fragment
417+ (not by re-tokenizing the whole pending chunk on every event), so
418+ checking-interval bookkeeping also stays linear.
419+
420+ On a mid-stream violation, the remainder of ``stream`` is drained
421+ (see ``_drain_remaining``) before the violation is raised, so the
422+ underlying provider call reaches natural completion -- giving
423+ accurate usage totals and letting OGX's own turn-persistence
424+ complete -- rather than being cut off mid-generation. Only the
425+ *content* is withheld from the caller.
281426
282- return await handler () # proceed with the real run
427+ Parameters:
428+ ctx: The run context for the current agent run (unused).
429+ stream: The underlying agent stream to guard.
430+
431+ Yields:
432+ Events from ``stream`` once their text has cleared guardrails. A
433+ synthetic ``PartEndEvent`` carrying the violation message is
434+ yielded immediately before raising, so callers that build up a
435+ response from stream events (e.g. conversation persistence) see
436+ the rejection text instead of an empty response.
437+
438+ Raises:
439+ _OutputGuardrailViolation: When an OUTPUT-point risk is violated.
440+ """
441+ _ = ctx
442+ output_guardrails = _filter_guardrails (self .config .risks , "output" )
443+ batch_size = (
444+ _get_batch_size (self .config .parallel , len (output_guardrails ))
445+ if output_guardrails
446+ else 1
447+ )
448+ interval = self .config .streaming_output_check_interval_tokens
449+
450+ pending_events : list [AgentStreamEvent ] = []
451+ pending_fragments : list [str ] = []
452+ pending_tokens = 0
453+ # Tracks the index of the currently open text part, so a synthetic
454+ # PartEndEvent reports a violation against the part it actually
455+ # belongs to instead of always assuming index 0.
456+ part_index = 0
457+
458+ try :
459+ async for event in stream :
460+ if isinstance (event , PartStartEvent ):
461+ part_index = event .index
462+
463+ if not output_guardrails :
464+ yield event
465+ continue
466+
467+ text = event_text (event )
468+ if not text and not pending_fragments :
469+ # Nothing buffered and this event carries no text of its
470+ # own (e.g. a tool-call event): no reason to withhold it.
471+ yield event
472+ continue
473+
474+ pending_events .append (event )
475+ if text :
476+ pending_fragments .append (text )
477+ pending_tokens += estimate_tokens (text )
478+
479+ if pending_tokens >= interval :
480+ pending_text = "" .join (pending_fragments )
481+ violation_message , _ = await _run_risk_check (
482+ pending_text , self ._model , output_guardrails , batch_size
483+ )
484+ if violation_message is not None :
485+ yield PartEndEvent (
486+ index = part_index , part = TextPart (violation_message )
487+ )
488+ # Let the underlying provider call finish instead of
489+ # tearing it down mid-generation (see
490+ # _drain_remaining).
491+ await _drain_remaining (stream )
492+ raise _OutputGuardrailViolation (violation_message )
493+
494+ pending_fragments = []
495+ pending_tokens = 0
496+ for pending_event in pending_events :
497+ yield pending_event
498+ pending_events = []
499+
500+ if output_guardrails and pending_fragments :
501+ pending_text = "" .join (pending_fragments )
502+ violation_message , _ = await _run_risk_check (
503+ pending_text , self ._model , output_guardrails , batch_size
504+ )
505+ if violation_message is not None :
506+ yield PartEndEvent (
507+ index = part_index , part = TextPart (violation_message )
508+ )
509+ raise _OutputGuardrailViolation (violation_message )
510+
511+ for pending_event in pending_events :
512+ yield pending_event
513+ finally :
514+ await aclose_if_supported (stream )
283515
284516 async def run (self , input_text : str ) -> ShieldModerationResult :
285517 """Run standalone shield moderation on the given text.
0 commit comments