Skip to content

Commit f3a7cbc

Browse files
aditik0303claude
andcommitted
fix(pydantic-ai): address governance review — streaming, ModelRequest, tool_call_id
Review findings (Viswa) for PR #359: - Streaming AFTER_MODEL now runs in a finally *inside* the request_stream async-with, so it fires even if the consumer's `async for` raises partway through, and while the stream context is still open. Documented that streaming governance is inherently post-hoc (tokens are already emitted by the time the response is complete; a DENY aborts the run but cannot un-send them). - _latest_request now scans from the end for the last ModelRequest instead of blindly taking messages[-1] — a history ending in a ModelResponse (mid tool round-trip) would otherwise scan the wrong side of the exchange. - Pass tool_call_id through to evaluate_tool_call / evaluate_after_tool so the evaluator can correlate a call with its result. - Bounded joins (_join_within_cap) for request/response/content text and a capped _stringify, so an oversized tool-return can't build a multi-megabyte string before the final slice. - Drop the per-callbacks uuid trace_id (identical for every call); trace correlation is owned by the layer below, matching LangChain. Requires uipath-core >= 0.5.20 (removed trace_id from EvaluatorProtocol) — bumped. - Count llm/tool calls only after governance passes (no inflation on block). - Documented that agent.model is a property whose setter the agent re-reads per run (no stale ref), and that a per-run model override bypasses the wrap. Tests: added streaming happy-path (finalized response governed), streaming finally-on-consumer-error, ModelRequest-filter, and tool_call_id passthrough. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d0ff1f1 commit f3a7cbc

3 files changed

Lines changed: 247 additions & 68 deletions

File tree

packages/uipath-pydantic-ai/src/uipath_pydantic_ai/governance/model.py

Lines changed: 130 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@
4040
import json
4141
import logging
4242
from contextlib import asynccontextmanager
43-
from typing import Any, AsyncIterator, Dict, List
44-
from uuid import uuid4
43+
from typing import Any, AsyncIterator, Dict, Iterable, List
4544

4645
from pydantic_ai import Agent
4746
from pydantic_ai.messages import (
4847
BuiltinToolCallPart,
48+
ModelRequest,
4949
TextPart,
5050
ToolCallPart,
5151
ToolReturnPart,
@@ -93,6 +93,11 @@ def install_governance(
9393
callbacks = GovernanceCallbacks(
9494
evaluator=evaluator, agent_name=agent_name, session_id=session_id
9595
)
96+
# ``agent.model`` is a property whose setter stores ``_model``; the agent
97+
# re-reads ``self.model`` on every run (``_get_model``), so this in-place
98+
# wrap takes effect for all subsequent runs with no stale reference. A model
99+
# supplied per-run (``agent.run(model=...)``) bypasses this wrap — that path
100+
# is governed by whatever model the caller passes, not by us.
96101
agent.model = GovernanceModel(model, callbacks)
97102
logger.debug("Wrapped Pydantic AI agent model with governance")
98103
return agent
@@ -130,17 +135,28 @@ async def request_stream(
130135
async with super().request_stream(
131136
messages, model_settings, model_request_parameters, run_context
132137
) as stream:
133-
yield stream
134-
# After the caller has consumed the stream, the final response is
135-
# assembled — govern it the same as the non-streaming path. A DENY
136-
# decision must still abort the run, so the block exception propagates;
137-
# any other governance error is logged and swallowed.
138-
try:
139-
self._callbacks.on_response(stream.get())
140-
except GovernanceBlockException:
141-
raise
142-
except Exception as e: # noqa: BLE001 - a governance bug must not break the run
143-
logger.warning("after-stream governance check failed (continuing): %s", e)
138+
try:
139+
yield stream
140+
finally:
141+
# Once the caller has consumed the stream the final response is
142+
# assembled — govern it the same as the non-streaming path. This
143+
# runs in a ``finally`` so AFTER_MODEL / TOOL_CALL still fire
144+
# even if the consumer's ``async for`` raised partway through.
145+
#
146+
# Streaming governance is inherently post-hoc: the tokens have
147+
# already been streamed to the caller by the time the response
148+
# is complete, so a DENY here aborts the run but cannot un-send
149+
# what was already emitted. The block exception still
150+
# propagates; any other governance error is logged and swallowed
151+
# so a governance bug can't break the run.
152+
try:
153+
self._callbacks.on_response(stream.get())
154+
except GovernanceBlockException:
155+
raise
156+
except Exception as e: # noqa: BLE001
157+
logger.warning(
158+
"after-stream governance check failed (continuing): %s", e
159+
)
144160

145161

146162
class GovernanceCallbacks:
@@ -160,7 +176,10 @@ def __init__(
160176
self._evaluator = evaluator
161177
self._agent_name = agent_name
162178
self._session_id = session_id
163-
self._trace_id = str(uuid4())
179+
# ``trace_id`` is intentionally NOT held here. A single uuid minted at
180+
# install time would be identical for every call. Trace correlation is
181+
# owned by the layer below (OTel span / HTTP resolve at call time),
182+
# matching the LangChain adapter.
164183
self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0}
165184

166185
# ----- before the model call --------------------------------------
@@ -180,7 +199,11 @@ def on_request(self, messages: Any) -> None:
180199
self._before_model(self._parts_input_text(parts))
181200
for part in parts:
182201
if isinstance(part, ToolReturnPart):
183-
self._after_tool(part.tool_name or "unknown", part.content)
202+
self._after_tool(
203+
part.tool_name or "unknown",
204+
part.content,
205+
tool_call_id=getattr(part, "tool_call_id", None),
206+
)
184207

185208
# ----- after the model call ---------------------------------------
186209

@@ -190,20 +213,25 @@ def on_response(self, response: Any) -> None:
190213
self._after_model(self._response_text(parts))
191214
for part in parts:
192215
if isinstance(part, (ToolCallPart, BuiltinToolCallPart)):
193-
self._tool_call(part.tool_name or "unknown", part.args)
216+
self._tool_call(
217+
part.tool_name or "unknown",
218+
part.args,
219+
tool_call_id=getattr(part, "tool_call_id", None),
220+
)
194221

195222
# ----- individual evaluate_* wrappers (block-propagate, else swallow) --
196223

197224
def _before_model(self, text: str) -> None:
198225
try:
199-
self._session_state["llm_calls"] = (
200-
self._session_state.get("llm_calls", 0) + 1
201-
)
202226
self._evaluator.evaluate_before_model(
203227
model_input=text,
204228
agent_name=self._agent_name,
205229
runtime_id=self._session_id,
206-
trace_id=self._trace_id,
230+
)
231+
# Count only calls that passed governance — a DENY raises above, so
232+
# a blocked call must not inflate the counter.
233+
self._session_state["llm_calls"] = (
234+
self._session_state.get("llm_calls", 0) + 1
207235
)
208236
except GovernanceBlockException:
209237
raise
@@ -216,39 +244,44 @@ def _after_model(self, text: str) -> None:
216244
model_output=text,
217245
agent_name=self._agent_name,
218246
runtime_id=self._session_id,
219-
trace_id=self._trace_id,
220247
)
221248
except GovernanceBlockException:
222249
raise
223250
except Exception as e: # noqa: BLE001
224251
logger.warning("after_model governance check failed (continuing): %s", e)
225252

226-
def _tool_call(self, tool_name: str, args: Any) -> None:
253+
def _tool_call(
254+
self, tool_name: str, args: Any, tool_call_id: str | None = None
255+
) -> None:
227256
try:
228-
self._session_state["tool_calls"] = (
229-
self._session_state.get("tool_calls", 0) + 1
230-
)
231257
self._evaluator.evaluate_tool_call(
232258
tool_name=tool_name,
233259
tool_args=_coerce_args(args),
234260
agent_name=self._agent_name,
235261
runtime_id=self._session_id,
236-
trace_id=self._trace_id,
237262
session_state=self._session_state,
263+
tool_call_id=tool_call_id,
264+
)
265+
# Count only calls that passed governance; the evaluator saw the
266+
# count of prior tool calls, and a DENY raises before this bump.
267+
self._session_state["tool_calls"] = (
268+
self._session_state.get("tool_calls", 0) + 1
238269
)
239270
except GovernanceBlockException:
240271
raise
241272
except Exception as e: # noqa: BLE001
242273
logger.warning("tool_call governance check failed (continuing): %s", e)
243274

244-
def _after_tool(self, tool_name: str, content: Any) -> None:
275+
def _after_tool(
276+
self, tool_name: str, content: Any, tool_call_id: str | None = None
277+
) -> None:
245278
try:
246279
self._evaluator.evaluate_after_tool(
247280
tool_name=tool_name,
248281
tool_result="" if content is None else _stringify(content),
249282
agent_name=self._agent_name,
250283
runtime_id=self._session_id,
251-
trace_id=self._trace_id,
284+
tool_call_id=tool_call_id,
252285
)
253286
except GovernanceBlockException:
254287
raise
@@ -259,57 +292,91 @@ def _after_tool(self, tool_name: str, content: Any) -> None:
259292

260293
@staticmethod
261294
def _latest_request(messages: Any) -> Any:
262-
"""Return the most recent message (a ``ModelRequest``) or ``None``."""
295+
"""Return the most recent ``ModelRequest`` message, or ``None``.
296+
297+
Scans from the end for the last ``ModelRequest`` rather than blindly
298+
taking ``messages[-1]``: the history can end with a ``ModelResponse``
299+
(e.g. mid tool-call round-trips), and treating that as request input
300+
would scan the wrong side of the exchange.
301+
"""
263302
if not messages or not isinstance(messages, (list, tuple)):
264303
return None
265-
return messages[-1]
304+
for message in reversed(messages):
305+
if isinstance(message, ModelRequest):
306+
return message
307+
return None
266308

267309
@classmethod
268310
def _parts_input_text(cls, parts: Any) -> str:
269311
"""Join governance-relevant input text from a request message's parts.
270312
271313
Covers user prompts and tool-return content (the model's input on a
272-
follow-up turn). Capped at :data:`_BEFORE_MODEL_TEXT_CAP`.
314+
follow-up turn). Joined with a running cap so an oversized tool-return
315+
can't build a multi-megabyte string before the final slice.
273316
"""
274-
collected: List[str] = []
275-
for part in parts:
276-
if isinstance(part, UserPromptPart):
277-
collected.append(_content_text(part.content))
278-
elif isinstance(part, ToolReturnPart):
279-
collected.append(_stringify(part.content))
280-
return "\n".join(p for p in collected if p)[:_BEFORE_MODEL_TEXT_CAP]
317+
318+
def _pieces() -> Iterable[str]:
319+
for part in parts:
320+
if isinstance(part, UserPromptPart):
321+
yield _content_text(part.content)
322+
elif isinstance(part, ToolReturnPart):
323+
yield _stringify(part.content)
324+
325+
return _join_within_cap(_pieces(), _BEFORE_MODEL_TEXT_CAP)
281326

282327
@classmethod
283328
def _response_text(cls, parts: Any) -> str:
284-
"""Join ``TextPart`` content from a model response's parts."""
285-
collected: List[str] = []
286-
for part in parts:
287-
if isinstance(part, TextPart) and part.content:
288-
collected.append(part.content)
289-
return "\n".join(collected)[:_BEFORE_MODEL_TEXT_CAP]
329+
"""Join ``TextPart`` content from a model response's parts (running cap)."""
330+
331+
def _pieces() -> Iterable[str]:
332+
for part in parts:
333+
if isinstance(part, TextPart) and part.content:
334+
yield part.content
335+
336+
return _join_within_cap(_pieces(), _BEFORE_MODEL_TEXT_CAP)
290337

291338

292339
# --------------------------------------------------------------------------
293340
# Helpers
294341
# --------------------------------------------------------------------------
295342

296343

344+
def _join_within_cap(pieces: Iterable[str], cap: int) -> str:
345+
"""Join non-empty ``pieces`` with newlines, stopping once ``cap`` is hit.
346+
347+
Bounds the work (and the allocation) to ``cap`` characters instead of
348+
building the full string and slicing afterwards.
349+
"""
350+
collected: List[str] = []
351+
remaining = cap
352+
for piece in pieces:
353+
if remaining <= 0:
354+
break
355+
if not piece:
356+
continue
357+
collected.append(piece[:remaining])
358+
remaining -= len(piece) + 1
359+
return "\n".join(collected)[:cap]
360+
361+
297362
def _content_text(content: Any) -> str:
298363
"""Render a ``UserPromptPart.content`` (str or list of items) as text."""
299364
if content is None:
300365
return ""
301366
if isinstance(content, str):
302-
return content
367+
return content[:_BEFORE_MODEL_TEXT_CAP]
303368
if isinstance(content, (list, tuple)):
304-
out: List[str] = []
305-
for item in content:
306-
if isinstance(item, str):
307-
out.append(item)
308-
else:
309-
text = getattr(item, "text", None)
310-
if isinstance(text, str):
311-
out.append(text)
312-
return "\n".join(out)
369+
370+
def _pieces() -> Iterable[str]:
371+
for item in content:
372+
if isinstance(item, str):
373+
yield item
374+
else:
375+
text = getattr(item, "text", None)
376+
if isinstance(text, str):
377+
yield text
378+
379+
return _join_within_cap(_pieces(), _BEFORE_MODEL_TEXT_CAP)
313380
return _stringify(content)
314381

315382

@@ -328,11 +395,15 @@ def _coerce_args(args: Any) -> Dict[str, Any]:
328395
return {}
329396

330397

331-
def _stringify(value: Any) -> str:
332-
"""Render a dict / object payload as compact, scannable text."""
398+
def _stringify(value: Any, cap: int = _BEFORE_MODEL_TEXT_CAP) -> str:
399+
"""Render a dict / object payload as compact, scannable text, capped.
400+
401+
Bounded by ``cap`` so an oversized tool result / return content can't hand
402+
a multi-megabyte string to the evaluator.
403+
"""
333404
if isinstance(value, str):
334-
return value
405+
return value[:cap]
335406
try:
336-
return json.dumps(value, default=str, ensure_ascii=False)
407+
return json.dumps(value, default=str, ensure_ascii=False)[:cap]
337408
except (TypeError, ValueError):
338-
return str(value)
409+
return str(value)[:cap]

0 commit comments

Comments
 (0)