Skip to content

Commit ada115c

Browse files
authored
Merge pull request #2761 from jrobertboos/lcore-3391
LCORE-3391: added output granite guardian
2 parents 966acb4 + 1f4319c commit ada115c

10 files changed

Lines changed: 1091 additions & 30 deletions

File tree

‎docs/devel_doc/openapi.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14728,6 +14728,13 @@
1472814728
"type": "array",
1472914729
"title": "Defined risks",
1473014730
"description": "Risks to be considered while applying this guardrail"
14731+
},
14732+
"streaming_output_check_interval_tokens": {
14733+
"type": "integer",
14734+
"exclusiveMinimum": 0.0,
14735+
"title": "Streaming output check interval (tokens)",
14736+
"description": "For risks with `output` in `points`, re-check the response against those risks approximately every N generated output tokens while streaming, plus once more over any remaining text once generation completes. Ignored when no configured risk targets the `output` point.",
14737+
"default": 50
1473114738
}
1473214739
},
1473314740
"additionalProperties": false,

‎src/models/config.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3271,6 +3271,18 @@ class GraniteGuardianConfig(ConfigurationBase):
32713271
description="Risks to be considered while applying this guardrail",
32723272
)
32733273

3274+
streaming_output_check_interval_tokens: PositiveInt = Field(
3275+
default=50,
3276+
title="Streaming output check interval (tokens)",
3277+
description=(
3278+
"For risks with `output` in `points`, re-check the response "
3279+
"against those risks approximately every N generated output "
3280+
"tokens while streaming, plus once more over any remaining "
3281+
"text once generation completes. Ignored when no configured "
3282+
"risk targets the `output` point."
3283+
),
3284+
)
3285+
32743286
@model_validator(mode="after")
32753287
def validate_api_key_requires_https(self) -> Self:
32763288
"""Require HTTPS when an API key is configured.

‎src/pydantic_ai_lightspeed/capabilities/granite_guardian/_capability.py‎

Lines changed: 258 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Granite Guardian safety capability for input/output guardrail moderation."""
22

33
import asyncio
4+
from collections.abc import AsyncIterable
45
from dataclasses import dataclass, field
56
from typing import ClassVar, Literal, Optional
67
from uuid import uuid4
@@ -14,8 +15,11 @@
1415
from pydantic_ai.direct import model_request
1516
from pydantic_ai.exceptions import UnexpectedModelBehavior
1617
from pydantic_ai.messages import (
18+
AgentStreamEvent,
1719
ModelRequest,
1820
ModelResponse,
21+
PartEndEvent,
22+
PartStartEvent,
1923
TextPart,
2024
)
2125
from pydantic_ai.models import Model
@@ -33,20 +37,61 @@
3337
from models.config import GraniteGuardianConfig, RiskDefinition
3438
from pydantic_ai_lightspeed.capabilities.base import AbstractSafetyCapability
3539
from pydantic_ai_lightspeed.capabilities.granite_guardian.utils import (
40+
aclose_if_supported,
3641
build_guardian_block,
42+
event_text,
3743
is_safe,
3844
)
3945
from 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

4555
type Guardrail = tuple[str, str, float, str]
4656

4757
logger = 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+
5095
async 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:
174219
class 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

Comments
 (0)