4343import json
4444import logging
4545from typing import Any , Dict , List
46- from uuid import uuid4
4746
4847from uipath .core .adapters import EvaluatorProtocol
4948from uipath .core .governance .exceptions import GovernanceBlockException
5857# :meth:`GovernanceCallbacks._latest_request_text`.
5958_BEFORE_MODEL_TEXT_CAP = 64000
6059
60+ # Hard cap on how many nodes the agent-tree walk visits, guarding against
61+ # cyclic or pathologically deep trees. Hitting it is logged, not silent.
62+ _MAX_GRAPH_NODES = 1000
63+
6164# Native LlmAgent callback attribute names this adapter manages.
6265_MODEL_BEFORE = "before_model_callback"
6366_MODEL_AFTER = "after_model_callback"
@@ -70,6 +73,23 @@ def _is_governance_callable(fn: Any) -> bool:
7073 return isinstance (getattr (fn , "__self__" , None ), GovernanceCallbacks )
7174
7275
76+ def _find_governance_callbacks (agent : Any ) -> "GovernanceCallbacks | None" :
77+ """Return the :class:`GovernanceCallbacks` already installed on ``agent``.
78+
79+ Scans the four callback slots for a governance-owned callable and returns
80+ the instance backing it, else ``None``. Used to detect a cached agent that
81+ was governed by a previous ``new_runtime`` so its metadata can be refreshed
82+ rather than left stale.
83+ """
84+ for attr in (_MODEL_BEFORE , _MODEL_AFTER , _TOOL_BEFORE , _TOOL_AFTER ):
85+ existing = getattr (agent , attr , None )
86+ handlers = existing if isinstance (existing , list ) else [existing ]
87+ for h in handlers :
88+ if _is_governance_callable (h ):
89+ return h .__self__ # type: ignore[no-any-return]
90+ return None
91+
92+
7393def _install_callback (agent : Any , attr : str , fn : Any ) -> None :
7494 """Prepend ``fn`` to an ADK callback slot, preserving existing handlers.
7595
@@ -96,19 +116,28 @@ def _install_callback(agent: Any, attr: str, fn: Any) -> None:
96116
97117
98118def _iter_llm_agents (root : Any ) -> List [Any ]:
99- """Return every ``LlmAgent``-shaped node in the ``sub_agents`` tree.
119+ """Return every ``LlmAgent``-shaped node in the agent tree.
100120
101121 A node qualifies if it exposes the model-callback surface (duck-typed
102122 via :data:`_MODEL_BEFORE` so we don't hard-require ``LlmAgent`` to be
103123 importable). Container agents (``Sequential`` / ``Parallel`` / ``Loop``)
104124 have no model callbacks themselves but their ``sub_agents`` are walked
105- so a multi-agent app is governed end to end. Cycles and pathological
106- depth are bounded by an id-visited set and a hard cap.
125+ so a multi-agent app is governed end to end.
126+
127+ ``AgentTool``-wrapped agents are also followed: an agent exposed to another
128+ agent as a tool carries its target on ``tool.agent`` and lives in ``tools``
129+ (not ``sub_agents``), so it would otherwise be missed. Cycles and
130+ pathological depth are bounded by an id-visited set and a hard cap
131+ (``_MAX_GRAPH_NODES``), which logs rather than silently truncating.
107132 """
108133 found : List [Any ] = []
109134 seen : set [int ] = set ()
110135 stack : List [Any ] = [root ]
111- while stack and len (seen ) < 1000 :
136+ capped = False
137+ while stack :
138+ if len (seen ) >= _MAX_GRAPH_NODES :
139+ capped = True
140+ break
112141 node = stack .pop ()
113142 if node is None or id (node ) in seen :
114143 continue
@@ -118,6 +147,20 @@ def _iter_llm_agents(root: Any) -> List[Any]:
118147 sub_agents = getattr (node , "sub_agents" , None )
119148 if isinstance (sub_agents , (list , tuple )):
120149 stack .extend (sub_agents )
150+ # AgentTool wraps its target agent on ``.agent``; follow tools so an
151+ # agent-as-tool is governed too.
152+ tools = getattr (node , "tools" , None )
153+ if isinstance (tools , (list , tuple )):
154+ for tool in tools :
155+ wrapped = getattr (tool , "agent" , None )
156+ if wrapped is not None :
157+ stack .append (wrapped )
158+ if capped :
159+ logger .warning (
160+ "install_governance stopped walking the agent tree at the %d-node "
161+ "cap; agents beyond it will not be governed" ,
162+ _MAX_GRAPH_NODES ,
163+ )
121164 return found
122165
123166
@@ -139,13 +182,25 @@ def install_governance(
139182 Called by :class:`UiPathGoogleADKRuntimeFactory` when an ``evaluator``
140183 is supplied to ``new_runtime``.
141184 """
142- callbacks = GovernanceCallbacks (
143- evaluator = evaluator ,
144- agent_name = agent_name ,
145- session_id = session_id ,
146- )
147185 llm_agents = _iter_llm_agents (agent )
186+ callbacks : GovernanceCallbacks | None = None
148187 for node in llm_agents :
188+ already = _find_governance_callbacks (node )
189+ if already is not None :
190+ # Cached agent reused for a new runtime: refresh the evaluator and
191+ # session/agent so governance attributes to *this* run rather than
192+ # the first one that installed it (the factory caches agents by
193+ # entrypoint across runtime_ids).
194+ already .rebind (
195+ evaluator = evaluator , agent_name = agent_name , session_id = session_id
196+ )
197+ continue
198+ if callbacks is None :
199+ callbacks = GovernanceCallbacks (
200+ evaluator = evaluator ,
201+ agent_name = agent_name ,
202+ session_id = session_id ,
203+ )
149204 _install_callback (node , _MODEL_BEFORE , callbacks .before_model )
150205 _install_callback (node , _MODEL_AFTER , callbacks .after_model )
151206 _install_callback (node , _TOOL_BEFORE , callbacks .before_tool )
@@ -183,9 +238,29 @@ def __init__(
183238 self ._evaluator = evaluator
184239 self ._agent_name = agent_name
185240 self ._session_id = session_id
186- self ._trace_id = str (uuid4 ())
241+ # ``trace_id`` is intentionally NOT held here. A single uuid minted at
242+ # install time would be identical for every call. Trace correlation is
243+ # owned by the layer below (OTel span / HTTP resolve at call time),
244+ # matching the LangChain adapter.
187245 self ._session_state : Dict [str , Any ] = {"tool_calls" : 0 , "llm_calls" : 0 }
188246
247+ def rebind (
248+ self ,
249+ evaluator : EvaluatorProtocol ,
250+ agent_name : str ,
251+ session_id : str ,
252+ ) -> None :
253+ """Re-point this callback set at a new run.
254+
255+ Called when a cached agent (already carrying these callbacks) is reused
256+ for a fresh ``new_runtime`` — updates the evaluator and identifiers and
257+ resets the per-run counters so state does not bleed across runtimes.
258+ """
259+ self ._evaluator = evaluator
260+ self ._agent_name = agent_name
261+ self ._session_id = session_id
262+ self ._session_state = {"tool_calls" : 0 , "llm_calls" : 0 }
263+
189264 # ----- Model callbacks -------------------------------------------------
190265
191266 def before_model (self , callback_context : Any , llm_request : Any ) -> None :
@@ -201,15 +276,16 @@ def before_model(self, callback_context: Any, llm_request: Any) -> None:
201276 Returns ``None`` so ADK proceeds with the model call.
202277 """
203278 try :
204- self ._session_state ["llm_calls" ] = (
205- self ._session_state .get ("llm_calls" , 0 ) + 1
206- )
207279 model_input = self ._latest_request_text (llm_request )
208280 self ._evaluator .evaluate_before_model (
209281 model_input = model_input ,
210282 agent_name = self ._agent_name ,
211283 runtime_id = self ._session_id ,
212- trace_id = self ._trace_id ,
284+ )
285+ # Count only calls that passed governance — a DENY raises above, so
286+ # a blocked call must not inflate the counter.
287+ self ._session_state ["llm_calls" ] = (
288+ self ._session_state .get ("llm_calls" , 0 ) + 1
213289 )
214290 except GovernanceBlockException :
215291 raise
@@ -236,7 +312,6 @@ def after_model(self, callback_context: Any, llm_response: Any) -> None:
236312 model_output = model_output ,
237313 agent_name = self ._agent_name ,
238314 runtime_id = self ._session_id ,
239- trace_id = self ._trace_id ,
240315 )
241316 except GovernanceBlockException :
242317 raise
@@ -253,18 +328,19 @@ def before_tool(self, tool: Any, args: Dict[str, Any], tool_context: Any) -> Non
253328 return would short-circuit it with a substitute result).
254329 """
255330 try :
256- self ._session_state ["tool_calls" ] = (
257- self ._session_state .get ("tool_calls" , 0 ) + 1
258- )
259331 tool_name = getattr (tool , "name" , None ) or "unknown"
260332 self ._evaluator .evaluate_tool_call (
261333 tool_name = tool_name ,
262334 tool_args = args or {},
263335 agent_name = self ._agent_name ,
264336 runtime_id = self ._session_id ,
265- trace_id = self ._trace_id ,
266337 session_state = self ._session_state ,
267338 )
339+ # Count only calls that passed governance; the evaluator saw the
340+ # count of prior tool calls, and a DENY raises before this bump.
341+ self ._session_state ["tool_calls" ] = (
342+ self ._session_state .get ("tool_calls" , 0 ) + 1
343+ )
268344 except GovernanceBlockException :
269345 raise
270346 except Exception as e :
@@ -292,7 +368,6 @@ def after_tool(
292368 tool_result = tool_result ,
293369 agent_name = self ._agent_name ,
294370 runtime_id = self ._session_id ,
295- trace_id = self ._trace_id ,
296371 )
297372 except GovernanceBlockException :
298373 raise
@@ -384,11 +459,16 @@ def _part_text(cls, part: Any) -> str:
384459 return "\n " .join (p for p in pieces if p )
385460
386461 @staticmethod
387- def _stringify (value : Any ) -> str :
388- """Render a dict / object payload as compact, scannable text."""
462+ def _stringify (value : Any , cap : int = _BEFORE_MODEL_TEXT_CAP ) -> str :
463+ """Render a dict / object payload as compact, scannable text, capped.
464+
465+ Bounded by ``cap`` so an oversized tool result, function-call args
466+ blob, or function-response can't hand a multi-megabyte string to the
467+ evaluator.
468+ """
389469 if isinstance (value , str ):
390- return value
470+ return value [: cap ]
391471 try :
392- return json .dumps (value , default = str , ensure_ascii = False )
472+ return json .dumps (value , default = str , ensure_ascii = False )[: cap ]
393473 except (TypeError , ValueError ):
394- return str (value )
474+ return str (value )[: cap ]
0 commit comments