4040import json
4141import logging
4242from 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
4645from pydantic_ai import Agent
4746from 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
146162class 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+
297362def _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