Skip to content

Commit ef4518b

Browse files
aditik0303claude
andcommitted
fix(google-adk): address governance review — trace_id, rebind, AgentTool walk
Review findings (Viswa) for PR #362: - 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, which removed trace_id from EvaluatorProtocol — bumped in the lock. - Count llm/tool calls only after governance passes: a DENY raised before the bump, inflating the counter on blocked calls. - Follow AgentTool-wrapped agents: an agent exposed as a tool lives in ``tools`` (on ``tool.agent``), not ``sub_agents``, so the walk missed it. - Refresh governance metadata on cached-agent reuse: the factory caches agents by entrypoint, so a second new_runtime with a new session_id would otherwise keep the first run's session_id (install was a no-op). Added GovernanceCallbacks.rebind() + _find_governance_callbacks(). - Log (not silently drop) when the tree walk hits the node cap. - Cap _stringify output so an oversized tool result / args / response can't hand a multi-megabyte string to the evaluator. - Trailing newline (W292). Tests: added AgentTool-walk, cached-agent rebind, and no-inflation-on-block coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 39948ab commit ef4518b

3 files changed

Lines changed: 164 additions & 31 deletions

File tree

packages/uipath-google-adk/src/uipath_google_adk/governance/callbacks.py

Lines changed: 106 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
import json
4444
import logging
4545
from typing import Any, Dict, List
46-
from uuid import uuid4
4746

4847
from uipath.core.adapters import EvaluatorProtocol
4948
from uipath.core.governance.exceptions import GovernanceBlockException
@@ -58,6 +57,10 @@
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+
7393
def _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

98118
def _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]

packages/uipath-google-adk/tests/governance/test_callbacks.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,27 @@ def evaluate_after_tool(self, **kwargs: Any) -> None:
6161
class FakeLlmAgent:
6262
"""Minimal stand-in for ``google.adk.agents.LlmAgent``."""
6363

64-
def __init__(self, name: str = "agent", sub_agents: List[Any] | None = None):
64+
def __init__(
65+
self,
66+
name: str = "agent",
67+
sub_agents: List[Any] | None = None,
68+
tools: List[Any] | None = None,
69+
):
6570
self.name = name
6671
self.before_model_callback: Any = None
6772
self.after_model_callback: Any = None
6873
self.before_tool_callback: Any = None
6974
self.after_tool_callback: Any = None
7075
self.sub_agents = sub_agents or []
76+
self.tools = tools or []
77+
78+
79+
class FakeAgentTool:
80+
"""Stand-in for ``google.adk.tools.agent_tool.AgentTool`` — wraps an agent."""
81+
82+
def __init__(self, agent: Any):
83+
self.agent = agent
84+
self.name = getattr(agent, "name", "agent_tool")
7185

7286

7387
class FakeContainerAgent:
@@ -154,6 +168,33 @@ def test_install_governance_warns_when_no_llm_agent(caplog):
154168
assert any("no LlmAgent" in r.message for r in caplog.records)
155169

156170

171+
def test_install_governance_follows_agent_tool_wrapped_agents():
172+
"""An agent exposed to another agent via AgentTool lives in ``tools``, not
173+
``sub_agents`` — it must still be governed."""
174+
wrapped = FakeLlmAgent("researcher")
175+
root = FakeLlmAgent("root", tools=[FakeAgentTool(wrapped)])
176+
install_governance(root, FakeEvaluator(), agent_name="x", session_id="s")
177+
assert isinstance(wrapped.before_model_callback, list)
178+
assert len(wrapped.before_model_callback) == 1
179+
180+
181+
def test_install_governance_rebinds_session_on_cached_agent_reuse():
182+
"""The factory caches agents by entrypoint; a second new_runtime reuses the
183+
same agent, so governance metadata must refresh to the new session."""
184+
agent = FakeLlmAgent()
185+
install_governance(agent, FakeEvaluator(), agent_name="a", session_id="session-1")
186+
gov = agent.before_model_callback[0].__self__
187+
assert gov._session_id == "session-1"
188+
189+
ev2 = FakeEvaluator()
190+
install_governance(agent, ev2, agent_name="a", session_id="session-2")
191+
# same callback object, not re-stacked, but re-pointed at the new run
192+
assert len(agent.before_model_callback) == 1
193+
assert agent.before_model_callback[0].__self__ is gov
194+
assert gov._session_id == "session-2"
195+
assert gov._evaluator is ev2
196+
197+
157198
# --------------------------------------------------------------------------
158199
# Factory wiring — the evaluator kwarg drives install_governance
159200
# --------------------------------------------------------------------------
@@ -191,7 +232,9 @@ async def _session_service(self):
191232
return _FakeSessionService()
192233

193234
monkeypatch.setattr(
194-
factory_mod.UiPathGoogleADKRuntimeFactory, "_get_session_service", _session_service
235+
factory_mod.UiPathGoogleADKRuntimeFactory,
236+
"_get_session_service",
237+
_session_service,
195238
)
196239

197240

@@ -322,6 +365,16 @@ def test_after_tool_none_response():
322365
assert ev.calls[-1][1]["tool_result"] == ""
323366

324367

368+
def test_blocked_tool_call_does_not_increment_counter():
369+
"""A DENY raises before the counter bump, so the count is not inflated."""
370+
ev = FakeEvaluator(block_on="tool_call")
371+
cb = _make_callbacks(ev)
372+
with pytest.raises(GovernanceBlockException):
373+
cb.before_tool(FakeTool("t"), {}, tool_context=None)
374+
assert ev.calls[-1][1]["session_state"]["tool_calls"] == 0
375+
assert cb._session_state["tool_calls"] == 0
376+
377+
325378
# --------------------------------------------------------------------------
326379
# enforcement semantics
327380
# --------------------------------------------------------------------------

packages/uipath-google-adk/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)