Skip to content

Commit 0917c4a

Browse files
aditik0303claude
andcommitted
fix(openai-agents): address governance review — trace_id, counters, graph walk
Review findings (Viswa) for PR #358: - Drop the per-hooks uuid trace_id: minting one at install time was identical across every call and diverged across handoff nodes. Trace correlation is owned by the layer below (OTel span / HTTP resolve), matching the LangChain adapter. 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. - Walk the handoff graph by isinstance(node, Agent) instead of duck-typing hasattr(node, "hooks") — the SDK type-checks the slot, so non-Agent objects could slip through. - Log (not silently drop) when the graph walk hits the node cap. - Report the live executing agent's name rather than the install-time entrypoint name, so attribution is correct after a handoff. - Treat an item as a function call only when explicitly typed function_call or it carries arguments (no bare-name misclassification). - Cap _stringify output so an oversized tool result / arg blob can't hand a multi-megabyte string to the evaluator. - Trailing newline (W292). Tests: FakeAgent now subclasses agents.Agent (graph walk isinstance-checks Agent); added live-agent-name and no-inflation-on-block coverage; made the caplog assertions propagation-independent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent db7b42a commit 0917c4a

3 files changed

Lines changed: 153 additions & 54 deletions

File tree

packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py

Lines changed: 77 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
import json
5151
import logging
5252
from typing import Any, Dict, List
53-
from uuid import uuid4
5453

5554
from agents import Agent, AgentHooks
5655
from uipath.core.adapters import EvaluatorProtocol
@@ -65,6 +64,10 @@
6564
# full prompt — see :func:`_latest_input_text`.
6665
_BEFORE_MODEL_TEXT_CAP = 64000
6766

67+
# Hard cap on how many nodes the handoff-graph walk visits, guarding against
68+
# cyclic or pathologically deep agent graphs. Hitting it is logged, not silent.
69+
_MAX_GRAPH_NODES = 1000
70+
6871

6972
def install_governance(
7073
agent: Agent,
@@ -108,30 +111,47 @@ def install_governance(
108111

109112

110113
def _iter_agents(root: Any) -> List[Any]:
111-
"""Return every agent node reachable through the ``handoffs`` graph.
112-
113-
A node qualifies if it exposes the ``hooks`` slot. Handoff targets may be
114-
``Agent`` instances or ``Handoff`` objects that carry the target on
115-
``.agent``; both are followed so a multi-agent app is governed end to end.
116-
Cycles and pathological depth are bounded by an id-visited set and a hard
117-
cap.
114+
"""Return every ``Agent`` reachable through the ``handoffs`` graph.
115+
116+
A node qualifies only if it is a real :class:`agents.Agent`. The SDK
117+
type-checks the ``hooks`` slot, so duck-typing on ``hasattr(node, "hooks")``
118+
could let non-Agent objects through — we isinstance-check instead. Handoff
119+
targets may be ``Agent`` instances or ``Handoff`` objects that carry the
120+
target on ``.agent``; both are followed so a multi-agent app is governed end
121+
to end. Cycles and pathological depth are bounded by an id-visited set and a
122+
hard cap (``_MAX_GRAPH_NODES``), which logs rather than silently truncating.
123+
124+
Not walked: agents reachable only as tools (``agent.as_tool()``) or embedded
125+
in input/output guardrail functions — the SDK closes over those behind
126+
opaque callables, so they are governed by their own runtime rather than this
127+
graph walk.
118128
"""
119129
found: List[Any] = []
120130
seen: set[int] = set()
121131
stack: List[Any] = [root]
122-
while stack and len(seen) < 1000:
132+
capped = False
133+
while stack:
134+
if len(seen) >= _MAX_GRAPH_NODES:
135+
capped = True
136+
break
123137
node = stack.pop()
124138
if node is None or id(node) in seen:
125139
continue
126140
seen.add(id(node))
127-
if hasattr(node, "hooks"):
141+
if isinstance(node, Agent):
128142
found.append(node)
129143
handoffs = getattr(node, "handoffs", None)
130144
if isinstance(handoffs, (list, tuple)):
131145
for h in handoffs:
132146
# A Handoff wraps its target agent on ``.agent``; a bare Agent
133147
# is itself the target.
134148
stack.append(getattr(h, "agent", h))
149+
if capped:
150+
logger.warning(
151+
"install_governance stopped walking the agent graph at the %d-node "
152+
"cap; agents beyond it will not be governed",
153+
_MAX_GRAPH_NODES,
154+
)
135155
return found
136156

137157

@@ -158,9 +178,24 @@ def __init__(
158178
self._agent_name = agent_name
159179
self._session_id = session_id
160180
self._inner = inner
161-
self._trace_id = str(uuid4())
181+
# ``trace_id`` is intentionally NOT held here. A single uuid minted at
182+
# install time would be identical for every model/tool call and would
183+
# diverge across handoff nodes (each carries its own hooks). Trace
184+
# correlation is owned by the layer below: OTel-backed sinks read the
185+
# live span on the caller's thread, HTTP consumers resolve the canonical
186+
# id at call time. This matches the LangChain adapter.
162187
self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0}
163188

189+
def _resolve_agent_name(self, agent: Any) -> str:
190+
"""Prefer the live executing agent's name over the install-time name.
191+
192+
After a handoff the running node may differ from the graph entrypoint
193+
the factory named us with; reporting the actual agent gives governance
194+
accurate attribution. Falls back to the install-time name.
195+
"""
196+
name = getattr(agent, "name", None)
197+
return name if isinstance(name, str) and name else self._agent_name
198+
164199
# ----- Model hooks -----------------------------------------------------
165200

166201
async def on_llm_start(
@@ -180,31 +215,33 @@ async def on_llm_start(
180215
the prompt for context.
181216
"""
182217
try:
183-
self._session_state["llm_calls"] = (
184-
self._session_state.get("llm_calls", 0) + 1
185-
)
186218
model_input = _latest_input_text(input_items)
187219
self._evaluator.evaluate_before_model(
188220
model_input=model_input,
189-
agent_name=self._agent_name,
221+
agent_name=self._resolve_agent_name(agent),
190222
runtime_id=self._session_id,
191-
trace_id=self._trace_id,
223+
)
224+
# Count only calls that passed governance — a DENY raises above, so
225+
# a blocked call must not inflate the counter.
226+
self._session_state["llm_calls"] = (
227+
self._session_state.get("llm_calls", 0) + 1
192228
)
193229
except GovernanceBlockException:
194230
raise
195231
except Exception as e: # noqa: BLE001 - governance must not break the run
196232
logger.warning("on_llm_start governance check failed (continuing): %s", e)
197-
await _delegate(self._inner, "on_llm_start", context, agent, system_prompt, input_items)
233+
await _delegate(
234+
self._inner, "on_llm_start", context, agent, system_prompt, input_items
235+
)
198236

199237
async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None:
200238
"""Evaluate AFTER_MODEL rules immediately after the LLM response."""
201239
try:
202240
model_output = _model_response_text(response)
203241
self._evaluator.evaluate_after_model(
204242
model_output=model_output,
205-
agent_name=self._agent_name,
243+
agent_name=self._resolve_agent_name(agent),
206244
runtime_id=self._session_id,
207-
trace_id=self._trace_id,
208245
)
209246
except GovernanceBlockException:
210247
raise
@@ -223,18 +260,19 @@ async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
223260
at the model layer where the call's arguments are visible in the output.
224261
"""
225262
try:
226-
self._session_state["tool_calls"] = (
227-
self._session_state.get("tool_calls", 0) + 1
228-
)
229263
tool_name = getattr(tool, "name", None) or "unknown"
230264
self._evaluator.evaluate_tool_call(
231265
tool_name=tool_name,
232266
tool_args={},
233-
agent_name=self._agent_name,
267+
agent_name=self._resolve_agent_name(agent),
234268
runtime_id=self._session_id,
235-
trace_id=self._trace_id,
236269
session_state=self._session_state,
237270
)
271+
# Count only calls that passed governance; the evaluator saw the
272+
# count of prior tool calls, and a DENY raises before this bump.
273+
self._session_state["tool_calls"] = (
274+
self._session_state.get("tool_calls", 0) + 1
275+
)
238276
except GovernanceBlockException:
239277
raise
240278
except Exception as e: # noqa: BLE001
@@ -256,9 +294,8 @@ async def on_tool_end(
256294
self._evaluator.evaluate_after_tool(
257295
tool_name=tool_name,
258296
tool_result=tool_result,
259-
agent_name=self._agent_name,
297+
agent_name=self._resolve_agent_name(agent),
260298
runtime_id=self._session_id,
261-
trace_id=self._trace_id,
262299
)
263300
except GovernanceBlockException:
264301
raise
@@ -341,10 +378,13 @@ def _item_text(item: Any) -> str:
341378

342379
pieces: List[str] = []
343380

344-
# A function/tool call carries its intent in name + arguments.
381+
# A function/tool call carries its intent in name + arguments. Treat an
382+
# item as a call only when it is explicitly typed ``function_call`` or it
383+
# actually carries arguments — a bare ``name`` on some other item type (a
384+
# named message part) is not a tool call.
345385
name = _get(item, "name")
346386
arguments = _get(item, "arguments")
347-
if name and (_get(item, "type") in (None, "function_call") or arguments is not None):
387+
if name and (_get(item, "type") == "function_call" or arguments is not None):
348388
if isinstance(name, str):
349389
pieces.append(name)
350390
if arguments is not None:
@@ -419,11 +459,15 @@ def _get(obj: Any, attr: str) -> Any:
419459
return getattr(obj, attr, None)
420460

421461

422-
def _stringify(value: Any) -> str:
423-
"""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+
The result is bounded by ``cap`` so an oversized tool result or argument
466+
blob can't hand a multi-megabyte string to the evaluator.
467+
"""
424468
if isinstance(value, str):
425-
return value
469+
return value[:cap]
426470
try:
427-
return json.dumps(value, default=str, ensure_ascii=False)
471+
return json.dumps(value, default=str, ensure_ascii=False)[:cap]
428472
except (TypeError, ValueError):
429-
return str(value)
473+
return str(value)[:cap]

packages/uipath-openai-agents/tests/governance/test_hooks.py

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@
1414
from __future__ import annotations
1515

1616
import logging
17+
from contextlib import contextmanager
1718
from types import SimpleNamespace
18-
from typing import Any, List
19+
from typing import Any, Iterator, List
1920

2021
import pytest
22+
from agents import Agent
2123
from uipath.core.governance.exceptions import GovernanceBlockException
2224

2325
from uipath_openai_agents.governance.hooks import (
@@ -62,14 +64,14 @@ def evaluate_after_tool(self, **kwargs: Any) -> None:
6264
self._record("after_tool", **kwargs)
6365

6466

65-
class FakeAgent:
66-
"""Minimal stand-in for ``agents.Agent`` (duck-typed by the adapter)."""
67+
class FakeAgent(Agent): # type: ignore[type-arg]
68+
"""A real ``agents.Agent`` — the graph walk isinstance-checks ``Agent``, so
69+
a bare duck-typed stand-in would be (correctly) skipped by
70+
``install_governance``. Subclassing keeps the construction lightweight while
71+
remaining a genuine ``Agent`` instance."""
6772

6873
def __init__(self, name: str = "agent", handoffs: List[Any] | None = None):
69-
self.name = name
70-
self.hooks: Any = None
71-
self.tools: List[Any] = []
72-
self.handoffs = handoffs or []
74+
super().__init__(name=name, handoffs=handoffs or [])
7375

7476

7577
class FakeTool:
@@ -132,9 +134,7 @@ def test_install_governance_installs_on_all_agents_in_handoff_graph():
132134
leaf_b = FakeAgent("b")
133135
root = FakeAgent("root", handoffs=[leaf_a, leaf_b])
134136

135-
returned = install_governance(
136-
root, FakeEvaluator(), agent_name="x", session_id="s"
137-
)
137+
returned = install_governance(root, FakeEvaluator(), agent_name="x", session_id="s")
138138

139139
assert returned is root # original returned, not a proxy
140140
for node in (root, leaf_a, leaf_b):
@@ -167,8 +167,30 @@ def test_install_governance_chains_existing_hooks():
167167
assert agent.hooks._inner is user_hooks
168168

169169

170+
_HOOKS_LOGGER = "uipath_openai_agents.governance.hooks"
171+
172+
173+
@contextmanager
174+
def _capture_hooks_logs(caplog: Any) -> Iterator[None]:
175+
"""Attach caplog's handler straight to the hooks logger.
176+
177+
Some sibling suites configure an ancestor ``uipath*`` logger with
178+
``propagate=False``, which silently breaks caplog's default root-handler
179+
capture. Attaching directly to the target logger is propagation-independent.
180+
"""
181+
logger = logging.getLogger(_HOOKS_LOGGER)
182+
logger.addHandler(caplog.handler)
183+
prev = logger.level
184+
logger.setLevel(logging.WARNING)
185+
try:
186+
yield
187+
finally:
188+
logger.removeHandler(caplog.handler)
189+
logger.setLevel(prev)
190+
191+
170192
def test_install_governance_warns_when_no_agent(caplog):
171-
with caplog.at_level(logging.WARNING):
193+
with _capture_hooks_logs(caplog):
172194
install_governance(object(), FakeEvaluator(), agent_name="x", session_id="s") # type: ignore[arg-type]
173195
assert any("no Agent" in r.message for r in caplog.records)
174196

@@ -282,6 +304,26 @@ async def test_on_tool_end_none_result():
282304
assert ev.calls[-1][1]["tool_result"] == ""
283305

284306

307+
async def test_reports_live_agent_name_not_install_time_name():
308+
"""After a handoff the executing agent differs from the graph entrypoint
309+
the factory named us with; governance should attribute the live agent."""
310+
ev = FakeEvaluator()
311+
cb = _make_hooks(ev) # install-time name is "agent-1"
312+
await cb.on_llm_start(None, FakeAgent("billing_specialist"), None, [_msg("hi")])
313+
assert ev.calls[-1][1]["agent_name"] == "billing_specialist"
314+
315+
316+
async def test_blocked_call_does_not_increment_counter():
317+
"""A DENY raises before the counter bump, so the count is not inflated."""
318+
ev = FakeEvaluator(block_on="tool_call")
319+
cb = _make_hooks(ev)
320+
with pytest.raises(GovernanceBlockException):
321+
await cb.on_tool_start(None, FakeAgent(), FakeTool("t"))
322+
# evaluator saw the pre-call count (0) and the block prevented the bump
323+
assert ev.calls[-1][1]["session_state"]["tool_calls"] == 0
324+
assert cb._session_state["tool_calls"] == 0
325+
326+
285327
# --------------------------------------------------------------------------
286328
# chaining to user hooks
287329
# --------------------------------------------------------------------------
@@ -305,10 +347,19 @@ async def test_governance_delegates_to_inner_hooks():
305347
@pytest.mark.parametrize(
306348
"hook,invoke",
307349
[
308-
("before_model", lambda cb: cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")])),
309-
("after_model", lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[]))),
350+
(
351+
"before_model",
352+
lambda cb: cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")]),
353+
),
354+
(
355+
"after_model",
356+
lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])),
357+
),
310358
("tool_call", lambda cb: cb.on_tool_start(None, FakeAgent(), FakeTool("t"))),
311-
("after_tool", lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1})),
359+
(
360+
"after_tool",
361+
lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1}),
362+
),
312363
],
313364
)
314365
async def test_block_exception_propagates(hook, invoke):
@@ -327,7 +378,7 @@ def evaluate_before_model(self, **_: Any) -> None:
327378
agent_name="a",
328379
session_id="s",
329380
)
330-
with caplog.at_level(logging.WARNING):
381+
with _capture_hooks_logs(caplog):
331382
# must NOT raise — a governance bug can't break the agent run
332383
await cb.on_llm_start(None, FakeAgent(), None, [_msg("x")])
333384
assert any("governance check failed" in r.message for r in caplog.records)
@@ -357,7 +408,9 @@ async def test_factory_installs_governance_when_evaluator_supplied(monkeypatch):
357408
from uipath_openai_agents.runtime import factory as factory_mod
358409

359410
# Stub the runtime so we don't introspect a real Agent.
360-
monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw))
411+
monkeypatch.setattr(
412+
factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)
413+
)
361414
agent = FakeAgent()
362415
await _factory_without_init()._create_runtime_instance(
363416
agent=agent, runtime_id="r", entrypoint="e", evaluator=FakeEvaluator()
@@ -368,9 +421,11 @@ async def test_factory_installs_governance_when_evaluator_supplied(monkeypatch):
368421
async def test_factory_skips_governance_without_evaluator(monkeypatch):
369422
from uipath_openai_agents.runtime import factory as factory_mod
370423

371-
monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw))
424+
monkeypatch.setattr(
425+
factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)
426+
)
372427
agent = FakeAgent()
373428
await _factory_without_init()._create_runtime_instance(
374429
agent=agent, runtime_id="r", entrypoint="e"
375430
)
376-
assert agent.hooks is None
431+
assert agent.hooks is None

0 commit comments

Comments
 (0)