From 531dceeafd44f2fc91b2d25298ac5821eb1d5016 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Mon, 22 Jun 2026 23:09:00 +0530 Subject: [PATCH 01/12] feat(governance): add OpenAI Agents governance adapter Installs governance on each agent's AgentHooks (on_llm_start/end -> BEFORE/AFTER_MODEL, on_tool_start/end -> TOOL_CALL/AFTER_TOOL), chaining any existing hooks and walking the handoffs graph. Self-registers via the uipath.governance.adapters entry point; unit-tested and verified firing through the framework's real execution path. BEFORE/AFTER_AGENT remain owned by the uipath-runtime wrapper. Co-Authored-By: Claude Opus 4.8 --- packages/uipath-openai-agents/pyproject.toml | 4 + .../governance/__init__.py | 57 +++ .../governance/adapter.py | 462 ++++++++++++++++++ .../tests/governance/__init__.py | 0 .../tests/governance/test_adapter.py | 374 ++++++++++++++ packages/uipath-openai-agents/uv.lock | 2 + 6 files changed, 899 insertions(+) create mode 100644 packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py create mode 100644 packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py create mode 100644 packages/uipath-openai-agents/tests/governance/__init__.py create mode 100644 packages/uipath-openai-agents/tests/governance/test_adapter.py diff --git a/packages/uipath-openai-agents/pyproject.toml b/packages/uipath-openai-agents/pyproject.toml index 329eb4c8..69d64b1c 100644 --- a/packages/uipath-openai-agents/pyproject.toml +++ b/packages/uipath-openai-agents/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "openai-agents>=0.6.5", "openinference-instrumentation-openai-agents>=1.4.0", "uipath>=2.10.0, <2.11.0", + "uipath-core>=0.5.18, <0.7.0", "uipath-runtime>=0.11.0, <0.12.0", ] classifiers = [ @@ -30,6 +31,9 @@ register = "uipath_openai_agents.middlewares:register_middleware" [project.entry-points."uipath.runtime.factories"] openai-agents = "uipath_openai_agents.runtime:register_runtime_factory" +[project.entry-points."uipath.governance.adapters"] +openai-agents = "uipath_openai_agents.governance:register_governance_adapter" + [project.urls] Homepage = "https://uipath.com" Repository = "https://github.com/UiPath/uipath-integrations-python" diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py new file mode 100644 index 00000000..66088991 --- /dev/null +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py @@ -0,0 +1,57 @@ +"""Governance integration for ``uipath-openai-agents``. + +Registers :class:`OpenAIAgentsAdapter` with the global adapter registry in +``uipath.core.adapters`` so ``uipath.runtime.governance.GovernanceRuntime`` +can attach the OpenAI-Agents-specific inner hooks (BEFORE_MODEL, AFTER_MODEL, +TOOL_CALL, AFTER_TOOL) when it sees an OpenAI Agents agent. + +Registration is **idempotent**: calling :func:`register_governance_adapter` +twice is a no-op on the second call. + +Wiring: + 1. Importing this module triggers registration as a side-effect, so any + caller that does ``import uipath_openai_agents.governance`` is opted in. + 2. The package also exposes :func:`register_governance_adapter` as an entry + point under ``uipath.governance.adapters`` so the registry's entry-point + discovery can plug us in without an explicit import. +""" + +from __future__ import annotations + +import logging + +from uipath.core.adapters import get_adapter_registry + +from .adapter import GovernanceAgentHooks, OpenAIAgentsAdapter + +logger = logging.getLogger(__name__) + +_registered: bool = False + + +def register_governance_adapter() -> None: + """Register :class:`OpenAIAgentsAdapter` with the global registry. + + Idempotent — safe to call multiple times. + """ + global _registered + if _registered: + return + registry = get_adapter_registry() + if any(a.name == "OpenAIAgents" for a in registry.get_all()): + _registered = True + return + registry.register(OpenAIAgentsAdapter()) + _registered = True + logger.debug("Registered uipath-openai-agents governance adapter") + + +# Side-effect registration on module import. +register_governance_adapter() + + +__all__ = [ + "GovernanceAgentHooks", + "OpenAIAgentsAdapter", + "register_governance_adapter", +] \ No newline at end of file diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py new file mode 100644 index 00000000..c0cd3774 --- /dev/null +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py @@ -0,0 +1,462 @@ +"""OpenAI Agents adapter for UiPath governance. + +Provides governance for OpenAI Agents SDK agents (``agents.Agent`` and any +graph of agents reachable via ``handoffs``). Like the Google ADK adapter — +and unlike the LangChain adapter, which wraps a ``Runnable`` and intercepts +``invoke`` / ``ainvoke`` — OpenAI Agents are executed by ``Runner.run`` / +``Runner.run_streamed``, which hold their **own** reference to the agent +object. Replacing ``runtime.agent`` with a proxy would never reach the +``Runner``. So this adapter installs governance directly onto each agent's +native ``hooks`` attribute (an :class:`agents.AgentHooks`), mutating it in +place: + +- ``on_llm_start`` → BEFORE_MODEL +- ``on_llm_end`` → AFTER_MODEL +- ``on_tool_start`` → TOOL_CALL +- ``on_tool_end`` → AFTER_TOOL + +Because the mutation is in place, :meth:`OpenAIAgentsAdapter.attach` returns +the **original agent** (hooks installed) rather than a wrapping proxy. +``agents.Agent`` validates that ``hooks`` is an ``AgentHooks`` instance, so +:class:`GovernanceAgentHooks` subclasses it (the ADK adapter could duck-type +its callbacks; here the SDK type-checks the slot). + +``agent.hooks`` holds a **single** ``AgentHooks`` (not a list, as in ADK), so +when an agent already carries user hooks we *chain*: governance runs first, +then the previously-installed hooks. ``detach`` restores the original. + +Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are intentionally *not* +fired from here — they are owned by the runtime wrapper layer in +``uipath-runtime`` (``GovernanceRuntime.execute`` / ``.stream``). Firing them +here too would duplicate every boundary evaluation. (The SDK's per-agent +``on_start`` / ``on_end`` are pass-through-only here for that reason.) + +Contracts and the evaluator protocol come from ``uipath-core``; this package +contributes only the OpenAI-Agents-specific implementation and self-registers +it with the global adapter registry when +``uipath_openai_agents.governance`` is imported. + +Audit emission and enforcement (raising :class:`GovernanceBlockException` on +DENY) are owned by the evaluator itself. Each hook only extracts the relevant +payload and calls the matching ``evaluate_*`` method; +:class:`GovernanceBlockException` is allowed to propagate (it aborts the +``Runner`` run), anything else is logged and swallowed so a governance bug +never breaks an agent run. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List +from uuid import uuid4 + +from agents import Agent, AgentHooks +from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.governance.exceptions import GovernanceBlockException + +logger = logging.getLogger(__name__) + +# Cap on the text blob passed to BEFORE_MODEL / AFTER_MODEL governance +# evaluation. Sized to match the runtime side (``_GOVERNANCE_TEXT_CAP`` in +# ``uipath.runtime.governance.wrapper``) and the other adapters so scan-time +# budgets are consistent across hooks. A long conversation history is governed +# at the LLM layer by scanning only the latest request content, not the full +# prompt — see :func:`_latest_input_text`. +_BEFORE_MODEL_TEXT_CAP = 64000 + +# Marks an agent we have already governed so a double ``attach`` is a no-op and +# ``detach`` can restore the hooks slot to whatever was there before. +_PREV_HOOKS_ATTR = "_uipath_governance_prev_hooks" + + +class OpenAIAgentsAdapter(BaseAdapter): + """Adapter for the OpenAI Agents SDK. + + Detects ``agents.Agent`` instances and installs governance hooks on every + agent reachable through the ``handoffs`` graph. + """ + + @property + def name(self) -> str: + return "OpenAIAgents" + + def can_handle(self, agent: Any) -> bool: + """Return True if this adapter knows how to hook into the agent.""" + try: + if isinstance(agent, Agent): + return True + except Exception: # noqa: BLE001 - defensive; isinstance shouldn't raise + pass + + # Duck-typed fallback: an OpenAI agent exposes a name, a hooks slot, + # and either a tools or handoffs collection. + if ( + hasattr(agent, "name") + and hasattr(agent, "hooks") + and (hasattr(agent, "tools") or hasattr(agent, "handoffs")) + ): + return True + return False + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + """Install governance hooks on the agent graph (mutated in place). + + Returns the original ``agent`` — the ``Runner`` already holds this + reference, so in-place mutation is what actually wires governance into + execution. A wrapping proxy would not reach the ``Runner`` and would + break the SDK's ``isinstance(agent, Agent)`` checks. + """ + agents = _iter_agents(agent) + installed = 0 + for node in agents: + if isinstance(getattr(node, "hooks", None), GovernanceAgentHooks): + continue # idempotent — already governed + prev = getattr(node, "hooks", None) + hooks = GovernanceAgentHooks( + evaluator=evaluator, + agent_name=agent_id, + session_id=session_id, + inner=prev, + ) + # Remember what was there so detach can restore it. + setattr(node, _PREV_HOOKS_ATTR, prev) + node.hooks = hooks + installed += 1 + if not agents: + logger.warning( + "OpenAIAgentsAdapter found no Agent in %s — deep hooks will not fire", + type(agent).__name__, + ) + else: + logger.debug("Installed governance hooks on %d OpenAI agent(s)", installed) + return agent + + def detach(self, governed: Any) -> Any: + """Restore each agent's original ``hooks`` slot and return the graph.""" + for node in _iter_agents(governed): + if isinstance(getattr(node, "hooks", None), GovernanceAgentHooks): + node.hooks = getattr(node, _PREV_HOOKS_ATTR, None) + if hasattr(node, _PREV_HOOKS_ATTR): + delattr(node, _PREV_HOOKS_ATTR) + return governed + + +def _iter_agents(root: Any) -> List[Any]: + """Return every agent node reachable through the ``handoffs`` graph. + + A node qualifies if it exposes the ``hooks`` slot (duck-typed so we don't + hard-require ``Agent`` to be importable in every path). Handoff targets may + be ``Agent`` instances or ``Handoff`` objects that carry the target on + ``.agent``; both are followed so a multi-agent app is governed end to end. + Cycles and pathological depth are bounded by an id-visited set and a hard + cap. + """ + found: List[Any] = [] + seen: set[int] = set() + stack: List[Any] = [root] + while stack and len(seen) < 1000: + node = stack.pop() + if node is None or id(node) in seen: + continue + seen.add(id(node)) + if hasattr(node, "hooks"): + found.append(node) + handoffs = getattr(node, "handoffs", None) + if isinstance(handoffs, (list, tuple)): + for h in handoffs: + # A Handoff wraps its target agent on ``.agent``; a bare Agent + # is itself the target. + stack.append(getattr(h, "agent", h)) + return found + + +class GovernanceAgentHooks(AgentHooks): # type: ignore[type-arg] + """Per-agent ``AgentHooks`` bound to one governance evaluator. + + The evaluator owns audit emission and DENY-raising. Each hook extracts the + relevant payload, calls the matching ``evaluate_*`` method, and returns + ``None``. :class:`GovernanceBlockException` is allowed to propagate — it + aborts the ``Runner`` run — anything else is logged and swallowed. + + When the agent already carried an ``AgentHooks`` (``inner``), governance + runs first and then delegates to it, so user hooks keep working. + """ + + def __init__( + self, + evaluator: EvaluatorProtocol, + agent_name: str, + session_id: str, + inner: Any = None, + ) -> None: + self._evaluator = evaluator + self._agent_name = agent_name + self._session_id = session_id + self._inner = inner + self._trace_id = str(uuid4()) + self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0} + + # ----- Model hooks ----------------------------------------------------- + + async def on_llm_start( + self, + context: Any, + agent: Any, + system_prompt: Any, + input_items: Any, + ) -> None: + """Evaluate BEFORE_MODEL rules immediately before the LLM call. + + Scans only the **latest input item** — not the full history. The model + still receives the entire history (this hook does not mutate the + request); the evaluator focuses on the new content the agent is about + to respond to. Without this scoping, a violation in an earlier turn + would re-fire on every subsequent model call because that text stays in + the prompt for context. + """ + try: + self._session_state["llm_calls"] = ( + self._session_state.get("llm_calls", 0) + 1 + ) + model_input = _latest_input_text(input_items) + self._evaluator.evaluate_before_model( + model_input=model_input, + agent_name=self._agent_name, + runtime_id=self._session_id, + trace_id=self._trace_id, + ) + except GovernanceBlockException: + raise + except Exception as e: # noqa: BLE001 - governance must not break the run + logger.warning("on_llm_start governance check failed (continuing): %s", e) + await _delegate(self._inner, "on_llm_start", context, agent, system_prompt, input_items) + + async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None: + """Evaluate AFTER_MODEL rules immediately after the LLM response.""" + try: + model_output = _model_response_text(response) + self._evaluator.evaluate_after_model( + model_output=model_output, + agent_name=self._agent_name, + runtime_id=self._session_id, + trace_id=self._trace_id, + ) + except GovernanceBlockException: + raise + except Exception as e: # noqa: BLE001 + logger.warning("on_llm_end governance check failed (continuing): %s", e) + await _delegate(self._inner, "on_llm_end", context, agent, response) + + # ----- Tool hooks ------------------------------------------------------ + + async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None: + """Evaluate TOOL_CALL rules immediately before a tool is invoked. + + The OpenAI Agents SDK does not surface tool *arguments* on + ``on_tool_start`` (only the tool itself), so ``tool_args`` is empty + here — argument-shaped rules evaluate at AFTER_TOOL via the result, or + at the model layer where the call's arguments are visible in the output. + """ + try: + self._session_state["tool_calls"] = ( + self._session_state.get("tool_calls", 0) + 1 + ) + tool_name = getattr(tool, "name", None) or "unknown" + self._evaluator.evaluate_tool_call( + tool_name=tool_name, + tool_args={}, + agent_name=self._agent_name, + runtime_id=self._session_id, + trace_id=self._trace_id, + session_state=self._session_state, + ) + except GovernanceBlockException: + raise + except Exception as e: # noqa: BLE001 + logger.warning("on_tool_start governance check failed (continuing): %s", e) + await _delegate(self._inner, "on_tool_start", context, agent, tool) + + async def on_tool_end( + self, context: Any, agent: Any, tool: Any, result: Any + ) -> None: + """Evaluate AFTER_TOOL rules immediately after a tool is invoked.""" + try: + tool_name = getattr(tool, "name", None) or "unknown" + tool_result = "" if result is None else _stringify(result) + self._evaluator.evaluate_after_tool( + tool_name=tool_name, + tool_result=tool_result, + agent_name=self._agent_name, + runtime_id=self._session_id, + trace_id=self._trace_id, + ) + except GovernanceBlockException: + raise + except Exception as e: # noqa: BLE001 + logger.warning("on_tool_end governance check failed (continuing): %s", e) + await _delegate(self._inner, "on_tool_end", context, agent, tool, result) + + # ----- Pass-through boundaries ---------------------------------------- + # BEFORE_AGENT / AFTER_AGENT are owned by the runtime wrapper; here we only + # forward to any wrapped user hooks so their behaviour is preserved. + + async def on_start(self, context: Any, agent: Any) -> None: + await _delegate(self._inner, "on_start", context, agent) + + async def on_end(self, context: Any, agent: Any, output: Any) -> None: + await _delegate(self._inner, "on_end", context, agent, output) + + async def on_handoff(self, context: Any, agent: Any, source: Any) -> None: + await _delegate(self._inner, "on_handoff", context, agent, source) + + +# -------------------------------------------------------------------------- +# Delegation + text extraction (module-level, sync, duck-typed) +# -------------------------------------------------------------------------- + + +async def _delegate(inner: Any, method: str, *args: Any) -> None: + """Call ``inner.(*args)`` if a wrapped hooks object provides it. + + User hooks are best-effort: a failure in a chained hook is logged and + swallowed (it must not abort the run on governance's behalf), except a + :class:`GovernanceBlockException`, which always propagates. + """ + if inner is None: + return + fn = getattr(inner, method, None) + if fn is None: + return + try: + await fn(*args) + except GovernanceBlockException: + raise + except Exception as e: # noqa: BLE001 + logger.warning("chained user hook %s failed (continuing): %s", method, e) + + +def _latest_input_text(input_items: Any) -> str: + """Extract text from the most-recent item in an LLM-call input list. + + ``input_items`` is the full ``list`` of response input items sent to the + model. We take the last entry — the new user message, or the tool + ``function_call_output`` being fed back — and pull its text via + :func:`_item_text`. Returns ``""`` when there is nothing extractable. + """ + if not input_items: + return "" + if isinstance(input_items, (list, tuple)): + return _item_text(input_items[-1]) + return _item_text(input_items) + + +def _item_text(item: Any) -> str: + """Return governance-relevant text from one response input/output item. + + Tolerant of both dict-shaped items (``{"role": ..., "content": ...}``, + ``{"type": "function_call", "name": ..., "arguments": ...}``) and + object-shaped items (``.content`` / ``.text`` / ``.name`` / ``.arguments``). + Content may itself be a string or a list of parts (each a dict with + ``text`` / ``input_text`` / ``output_text`` or an object with ``.text``). + Capped at :data:`_BEFORE_MODEL_TEXT_CAP`. + """ + if item is None: + return "" + if isinstance(item, str): + return item[:_BEFORE_MODEL_TEXT_CAP] + + pieces: List[str] = [] + + # A function/tool call carries its intent in name + arguments. + name = _get(item, "name") + arguments = _get(item, "arguments") + if name and (_get(item, "type") in (None, "function_call") or arguments is not None): + if isinstance(name, str): + pieces.append(name) + if arguments is not None: + pieces.append(_stringify(arguments)) + + content = _get(item, "content") + if content is not None: + pieces.append(_content_text(content)) + + # Tool result fed back to the model. + output = _get(item, "output") + if output is not None and not pieces: + pieces.append(_stringify(output)) + + text = "\n".join(p for p in pieces if p) + return text[:_BEFORE_MODEL_TEXT_CAP] + + +def _content_text(content: Any) -> str: + """Return text from a message ``content`` (string or list of parts).""" + if isinstance(content, str): + return content + if isinstance(content, (list, tuple)): + out: List[str] = [] + for part in content: + if isinstance(part, str): + out.append(part) + continue + t = ( + _get(part, "text") + or _get(part, "input_text") + or _get(part, "output_text") + ) + if isinstance(t, str) and t: + out.append(t) + return "\n".join(out) + t = _get(content, "text") + return t if isinstance(t, str) else "" + + +def _model_response_text(response: Any) -> str: + """Extract assistant text + tool-call intent from a ``ModelResponse``. + + ``response.output`` is the ``list`` of output items the model produced + (assistant messages and function/tool calls). Each is run through + :func:`_item_text` so both visible replies and tool-call arguments are + governed. Capped at :data:`_BEFORE_MODEL_TEXT_CAP`. + """ + if response is None: + return "" + output = _get(response, "output") + if output is None: + # Some shapes hand back text directly. + return _item_text(response) + items = output if isinstance(output, (list, tuple)) else [output] + collected: List[str] = [] + remaining = _BEFORE_MODEL_TEXT_CAP + for item in items: + if remaining <= 0: + break + piece = _item_text(item) + if piece: + collected.append(piece) + remaining -= len(piece) + 1 + return "\n".join(collected)[:_BEFORE_MODEL_TEXT_CAP] + + +def _get(obj: Any, attr: str) -> Any: + """Read ``attr`` from a dict key or object attribute, else ``None``.""" + if isinstance(obj, dict): + return obj.get(attr) + return getattr(obj, attr, None) + + +def _stringify(value: Any) -> str: + """Render a dict / object payload as compact, scannable text.""" + if isinstance(value, str): + return value + try: + return json.dumps(value, default=str, ensure_ascii=False) + except (TypeError, ValueError): + return str(value) \ No newline at end of file diff --git a/packages/uipath-openai-agents/tests/governance/__init__.py b/packages/uipath-openai-agents/tests/governance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-openai-agents/tests/governance/test_adapter.py b/packages/uipath-openai-agents/tests/governance/test_adapter.py new file mode 100644 index 00000000..eed5e01b --- /dev/null +++ b/packages/uipath-openai-agents/tests/governance/test_adapter.py @@ -0,0 +1,374 @@ +"""Unit tests for the OpenAI Agents governance adapter. + +These tests duck-type the OpenAI Agents payloads (response input/output +items, tools) with lightweight fakes so the real code paths are exercised +without a live LLM. ``GovernanceAgentHooks`` subclasses ``agents.AgentHooks`` +(the SDK type-checks ``agent.hooks``), so importing the adapter does require +``openai-agents`` — but the agents under test are simple stand-ins. + +The package is configured with ``asyncio_mode = "auto"``, so ``async def`` +tests run without an explicit marker. +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace +from typing import Any, List + +import pytest +from uipath.core.governance.exceptions import GovernanceBlockException + +from uipath_openai_agents.governance.adapter import ( + _BEFORE_MODEL_TEXT_CAP, + GovernanceAgentHooks, + OpenAIAgentsAdapter, +) + +# -------------------------------------------------------------------------- +# Fakes +# -------------------------------------------------------------------------- + + +class FakeEvaluator: + """Records evaluate_* calls; optionally BLOCKs on a named hook.""" + + def __init__(self, block_on: str | None = None) -> None: + self.block_on = block_on + self.calls: List[tuple[str, dict]] = [] + + def _record(self, hook: str, **kwargs: Any) -> None: + self.calls.append((hook, kwargs)) + if self.block_on == hook: + raise GovernanceBlockException("blocked") # type: ignore[call-arg] + + def evaluate_before_agent(self, **kwargs: Any) -> None: + self._record("before_agent", **kwargs) + + def evaluate_after_agent(self, **kwargs: Any) -> None: + self._record("after_agent", **kwargs) + + def evaluate_before_model(self, **kwargs: Any) -> None: + self._record("before_model", **kwargs) + + def evaluate_after_model(self, **kwargs: Any) -> None: + self._record("after_model", **kwargs) + + def evaluate_tool_call(self, **kwargs: Any) -> None: + self._record("tool_call", **kwargs) + + def evaluate_after_tool(self, **kwargs: Any) -> None: + self._record("after_tool", **kwargs) + + +class FakeAgent: + """Minimal stand-in for ``agents.Agent`` (duck-typed by the adapter).""" + + def __init__(self, name: str = "agent", handoffs: List[Any] | None = None): + self.name = name + self.hooks: Any = None + self.tools: List[Any] = [] + self.handoffs = handoffs or [] + + +class FakeTool: + def __init__(self, name: str): + self.name = name + + +class RecordingHooks: + """A user-supplied AgentHooks-like object that records delegated calls.""" + + def __init__(self) -> None: + self.seen: List[str] = [] + + async def on_llm_start(self, *_a: Any) -> None: + self.seen.append("on_llm_start") + + async def on_llm_end(self, *_a: Any) -> None: + self.seen.append("on_llm_end") + + async def on_tool_start(self, *_a: Any) -> None: + self.seen.append("on_tool_start") + + async def on_tool_end(self, *_a: Any) -> None: + self.seen.append("on_tool_end") + + +def _msg(text: str, role: str = "user") -> dict: + """A response input item carrying plain string content.""" + return {"role": role, "content": text} + + +def _msg_parts(*texts: str, role: str = "user") -> dict: + """A response input item carrying a list of text parts.""" + return {"role": role, "content": [{"type": "input_text", "text": t} for t in texts]} + + +def _function_call(name: str, arguments: str) -> dict: + return {"type": "function_call", "name": name, "arguments": arguments} + + +def _output_message(*texts: str) -> SimpleNamespace: + """A ModelResponse output message item with text parts.""" + parts = [SimpleNamespace(text=t) for t in texts] + return SimpleNamespace(role="assistant", content=parts) + + +def _make_hooks(evaluator: FakeEvaluator, inner: Any = None) -> GovernanceAgentHooks: + return GovernanceAgentHooks( + evaluator=evaluator, agent_name="agent-1", session_id="sess-1", inner=inner + ) + + +# -------------------------------------------------------------------------- +# can_handle +# -------------------------------------------------------------------------- + + +def test_can_handle_agent(): + assert OpenAIAgentsAdapter().can_handle(FakeAgent()) is True + + +def test_can_handle_rejects_plain_object(): + assert OpenAIAgentsAdapter().can_handle(object()) is False + + +# -------------------------------------------------------------------------- +# attach / detach +# -------------------------------------------------------------------------- + + +def test_attach_installs_on_all_agents_in_handoff_graph(): + leaf_a = FakeAgent("a") + leaf_b = FakeAgent("b") + root = FakeAgent("root", handoffs=[leaf_a, leaf_b]) + + returned = OpenAIAgentsAdapter().attach( + root, agent_id="x", session_id="s", evaluator=FakeEvaluator() + ) + + assert returned is root # original returned, not a proxy + for node in (root, leaf_a, leaf_b): + assert isinstance(node.hooks, GovernanceAgentHooks) + + +def test_attach_follows_handoff_wrapper_objects(): + target = FakeAgent("target") + handoff = SimpleNamespace(agent=target) # Handoff-shaped wrapper + root = FakeAgent("root", handoffs=[handoff]) + OpenAIAgentsAdapter().attach(root, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + assert isinstance(target.hooks, GovernanceAgentHooks) + + +def test_attach_is_idempotent(): + agent = FakeAgent() + adapter = OpenAIAgentsAdapter() + ev = FakeEvaluator() + adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + first = agent.hooks + adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + assert agent.hooks is first # not re-wrapped + + +def test_attach_chains_existing_hooks(): + agent = FakeAgent() + user_hooks = RecordingHooks() + agent.hooks = user_hooks + OpenAIAgentsAdapter().attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + assert isinstance(agent.hooks, GovernanceAgentHooks) + assert agent.hooks._inner is user_hooks + + +def test_detach_restores_previous_hooks(): + agent = FakeAgent() + user_hooks = RecordingHooks() + agent.hooks = user_hooks + adapter = OpenAIAgentsAdapter() + adapter.attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + adapter.detach(agent) + assert agent.hooks is user_hooks + + +def test_detach_restores_none_when_no_prior_hooks(): + agent = FakeAgent() + adapter = OpenAIAgentsAdapter() + adapter.attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + adapter.detach(agent) + assert agent.hooks is None + + +def test_attach_warns_when_no_agent(caplog): + with caplog.at_level(logging.WARNING): + OpenAIAgentsAdapter().attach( + object(), agent_id="x", session_id="s", evaluator=FakeEvaluator() + ) + assert any("no Agent" in r.message for r in caplog.records) + + +# -------------------------------------------------------------------------- +# on_llm_start (BEFORE_MODEL) +# -------------------------------------------------------------------------- + + +async def test_on_llm_start_scopes_to_latest_item(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + items = [_msg("OLD turn — secret leak here"), _msg("the new question")] + await cb.on_llm_start(None, FakeAgent(), "system", items) + hook, kwargs = ev.calls[-1] + assert hook == "before_model" + assert kwargs["model_input"] == "the new question" + assert "OLD turn" not in kwargs["model_input"] + + +async def test_on_llm_start_extracts_list_parts(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_llm_start(None, FakeAgent(), None, [_msg_parts("part one", "part two")]) + out = ev.calls[-1][1]["model_input"] + assert "part one" in out and "part two" in out + + +async def test_on_llm_start_extracts_function_call_when_latest(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + items = [_function_call("lookup", '{"balance": "1000"}')] + await cb.on_llm_start(None, FakeAgent(), None, items) + out = ev.calls[-1][1]["model_input"] + assert "lookup" in out and "1000" in out + + +async def test_on_llm_start_caps_text(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + huge = "x" * (_BEFORE_MODEL_TEXT_CAP + 5000) + await cb.on_llm_start(None, FakeAgent(), None, [_msg(huge)]) + assert len(ev.calls[-1][1]["model_input"]) <= _BEFORE_MODEL_TEXT_CAP + + +async def test_on_llm_start_empty_input(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_llm_start(None, FakeAgent(), None, []) + assert ev.calls[-1][1]["model_input"] == "" + + +# -------------------------------------------------------------------------- +# on_llm_end (AFTER_MODEL) +# -------------------------------------------------------------------------- + + +async def test_on_llm_end_extracts_text_and_function_call(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + response = SimpleNamespace( + output=[ + _output_message("thinking"), + SimpleNamespace( + type="function_call", + name="submit_answer", + arguments='{"content": "final reply"}', + ), + ] + ) + await cb.on_llm_end(None, FakeAgent(), response) + out = ev.calls[-1][1]["model_output"] + assert "thinking" in out and "submit_answer" in out and "final reply" in out + + +async def test_on_llm_end_empty_response(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) + assert ev.calls[-1][1]["model_output"] == "" + + +# -------------------------------------------------------------------------- +# tools +# -------------------------------------------------------------------------- + + +async def test_on_tool_start_passes_name_and_session_state(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_tool_start(None, FakeAgent(), FakeTool("transfer")) + hook, kwargs = ev.calls[-1] + assert hook == "tool_call" + assert kwargs["tool_name"] == "transfer" + assert kwargs["tool_args"] == {} # OpenAI SDK does not surface args here + assert kwargs["session_state"]["tool_calls"] == 1 + + +async def test_on_tool_end_stringifies_dict_result(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_tool_end(None, FakeAgent(), FakeTool("lookup"), {"x": 1}) + out = ev.calls[-1][1]["tool_result"] + assert "x" in out and "1" in out + + +async def test_on_tool_end_none_result(): + ev = FakeEvaluator() + cb = _make_hooks(ev) + await cb.on_tool_end(None, FakeAgent(), FakeTool("noop"), None) + assert ev.calls[-1][1]["tool_result"] == "" + + +# -------------------------------------------------------------------------- +# chaining to user hooks +# -------------------------------------------------------------------------- + + +async def test_governance_delegates_to_inner_hooks(): + inner = RecordingHooks() + cb = _make_hooks(FakeEvaluator(), inner=inner) + await cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")]) + await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) + await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) + await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) + assert inner.seen == ["on_llm_start", "on_llm_end", "on_tool_start", "on_tool_end"] + + +# -------------------------------------------------------------------------- +# enforcement semantics +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "hook,invoke", + [ + ("before_model", lambda cb: cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")])), + ("after_model", lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[]))), + ("tool_call", lambda cb: cb.on_tool_start(None, FakeAgent(), FakeTool("t"))), + ("after_tool", lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1})), + ], +) +async def test_block_exception_propagates(hook, invoke): + cb = _make_hooks(FakeEvaluator(block_on=hook)) + with pytest.raises(GovernanceBlockException): + await invoke(cb) + + +async def test_non_block_exception_is_swallowed(caplog): + class Boom: + def evaluate_before_model(self, **_: Any) -> None: + raise RuntimeError("evaluator bug") + + cb = GovernanceAgentHooks( + evaluator=Boom(), # type: ignore[arg-type] + agent_name="a", + session_id="s", + ) + with caplog.at_level(logging.WARNING): + # must NOT raise — a governance bug can't break the agent run + await cb.on_llm_start(None, FakeAgent(), None, [_msg("x")]) + assert any("governance check failed" in r.message for r in caplog.records) + + +async def test_hooks_return_none(): + cb = _make_hooks(FakeEvaluator()) + assert await cb.on_llm_start(None, FakeAgent(), None, []) is None + assert await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) is None + assert await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) is None + assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None \ No newline at end of file diff --git a/packages/uipath-openai-agents/uv.lock b/packages/uipath-openai-agents/uv.lock index ff2970af..d34369c2 100644 --- a/packages/uipath-openai-agents/uv.lock +++ b/packages/uipath-openai-agents/uv.lock @@ -2356,6 +2356,7 @@ dependencies = [ { name = "openai-agents" }, { name = "openinference-instrumentation-openai-agents" }, { name = "uipath" }, + { name = "uipath-core" }, { name = "uipath-runtime" }, ] @@ -2377,6 +2378,7 @@ requires-dist = [ { name = "openai-agents", specifier = ">=0.6.5" }, { name = "openinference-instrumentation-openai-agents", specifier = ">=1.4.0" }, { name = "uipath", specifier = ">=2.10.0,<2.11.0" }, + { name = "uipath-core", specifier = ">=0.5.18,<0.7.0" }, { name = "uipath-runtime", specifier = ">=0.11.0,<0.12.0" }, ] From acfce0976cc3f1ccf08a8cdaac66b88fb6ef9f45 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 24 Jun 2026 17:18:33 +0530 Subject: [PATCH 02/12] chore(governance): apply review feedback (no import-time registration, framework-only can_handle) Mirror radu's LangChain-adapter review across the OpenAI Agents adapter: - __init__: drop the import-time register_governance_adapter() side-effect; registration happens only via the uipath.governance.adapters entry-point discovery path. - can_handle: claim only a real agents.Agent; remove the broad duck-typed (name/hooks/tools) fallback. - docstring/comments: refer to the generic 'governance host', not uipath-runtime internals. - tests: can_handle uses a real Agent; a duck-typed look-alike is now rejected. Co-Authored-By: Claude Opus 4.8 --- .../governance/__init__.py | 22 +++++--------- .../governance/adapter.py | 29 +++++-------------- .../tests/governance/test_adapter.py | 10 +++++-- 3 files changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py index 66088991..344f275e 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py @@ -1,19 +1,17 @@ """Governance integration for ``uipath-openai-agents``. -Registers :class:`OpenAIAgentsAdapter` with the global adapter registry in -``uipath.core.adapters`` so ``uipath.runtime.governance.GovernanceRuntime`` -can attach the OpenAI-Agents-specific inner hooks (BEFORE_MODEL, AFTER_MODEL, -TOOL_CALL, AFTER_TOOL) when it sees an OpenAI Agents agent. +Registers :class:`OpenAIAgentsAdapter` with the adapter registry in +``uipath.core.adapters`` so the governance host can attach the +OpenAI-Agents-specific inner hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, +AFTER_TOOL) when it sees an OpenAI Agents agent. Registration is **idempotent**: calling :func:`register_governance_adapter` twice is a no-op on the second call. -Wiring: - 1. Importing this module triggers registration as a side-effect, so any - caller that does ``import uipath_openai_agents.governance`` is opted in. - 2. The package also exposes :func:`register_governance_adapter` as an entry - point under ``uipath.governance.adapters`` so the registry's entry-point - discovery can plug us in without an explicit import. +Wiring: the package exposes :func:`register_governance_adapter` as an entry +point under ``uipath.governance.adapters``. The governance adapter discovery +path calls it to register the adapter. Importing this module does not, by +itself, mutate the global registry. """ from __future__ import annotations @@ -46,10 +44,6 @@ def register_governance_adapter() -> None: logger.debug("Registered uipath-openai-agents governance adapter") -# Side-effect registration on module import. -register_governance_adapter() - - __all__ = [ "GovernanceAgentHooks", "OpenAIAgentsAdapter", diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py index c0cd3774..a6335a67 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py @@ -25,11 +25,10 @@ when an agent already carries user hooks we *chain*: governance runs first, then the previously-installed hooks. ``detach`` restores the original. -Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are intentionally *not* -fired from here — they are owned by the runtime wrapper layer in -``uipath-runtime`` (``GovernanceRuntime.execute`` / ``.stream``). Firing them -here too would duplicate every boundary evaluation. (The SDK's per-agent -``on_start`` / ``on_end`` are pass-through-only here for that reason.) +Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are owned by the +governance host, so they are not fired here — that would duplicate every +boundary evaluation. (The SDK's per-agent ``on_start`` / ``on_end`` are +pass-through-only here for that reason.) Contracts and the evaluator protocol come from ``uipath-core``; this package contributes only the OpenAI-Agents-specific implementation and self-registers @@ -82,22 +81,8 @@ def name(self) -> str: return "OpenAIAgents" def can_handle(self, agent: Any) -> bool: - """Return True if this adapter knows how to hook into the agent.""" - try: - if isinstance(agent, Agent): - return True - except Exception: # noqa: BLE001 - defensive; isinstance shouldn't raise - pass - - # Duck-typed fallback: an OpenAI agent exposes a name, a hooks slot, - # and either a tools or handoffs collection. - if ( - hasattr(agent, "name") - and hasattr(agent, "hooks") - and (hasattr(agent, "tools") or hasattr(agent, "handoffs")) - ): - return True - return False + """Return True only for an OpenAI Agents ``Agent``.""" + return isinstance(agent, Agent) def attach( self, @@ -304,7 +289,7 @@ async def on_tool_end( await _delegate(self._inner, "on_tool_end", context, agent, tool, result) # ----- Pass-through boundaries ---------------------------------------- - # BEFORE_AGENT / AFTER_AGENT are owned by the runtime wrapper; here we only + # BEFORE_AGENT / AFTER_AGENT are owned by the governance host; here we only # forward to any wrapped user hooks so their behaviour is preserved. async def on_start(self, context: Any, agent: Any) -> None: diff --git a/packages/uipath-openai-agents/tests/governance/test_adapter.py b/packages/uipath-openai-agents/tests/governance/test_adapter.py index eed5e01b..6bab0318 100644 --- a/packages/uipath-openai-agents/tests/governance/test_adapter.py +++ b/packages/uipath-openai-agents/tests/governance/test_adapter.py @@ -126,11 +126,15 @@ def _make_hooks(evaluator: FakeEvaluator, inner: Any = None) -> GovernanceAgentH # -------------------------------------------------------------------------- -def test_can_handle_agent(): - assert OpenAIAgentsAdapter().can_handle(FakeAgent()) is True +def test_can_handle_real_agent(): + from agents import Agent + assert OpenAIAgentsAdapter().can_handle(Agent(name="t")) is True -def test_can_handle_rejects_plain_object(): + +def test_can_handle_rejects_non_agent(): + # A duck-typed look-alike must NOT be claimed — only a real Agent is. + assert OpenAIAgentsAdapter().can_handle(FakeAgent()) is False assert OpenAIAgentsAdapter().can_handle(object()) is False From 6b58406f10133a54379add590f58ce4ad302dd85 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 24 Jun 2026 18:22:00 +0530 Subject: [PATCH 03/12] docs(governance): address Copilot review on the OpenAI adapter - Module docstring: registers via the uipath.governance.adapters entry point, not at import time. - Text-cap comment: refer to the governance host, not the uipath-runtime wrapper constant. - _iter_agents docstring: drop the stale 'duck-typed so Agent need not be importable' claim (the module imports agents.Agent). - Test docstring: note can_handle uses a real agents.Agent; only payload shapes are faked. Co-Authored-By: Claude Opus 4.8 --- .../governance/adapter.py | 19 ++++++++----------- .../tests/governance/test_adapter.py | 11 ++++++----- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py index a6335a67..f0850b20 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py @@ -31,9 +31,8 @@ pass-through-only here for that reason.) Contracts and the evaluator protocol come from ``uipath-core``; this package -contributes only the OpenAI-Agents-specific implementation and self-registers -it with the global adapter registry when -``uipath_openai_agents.governance`` is imported. +contributes only the OpenAI-Agents-specific implementation and registers it +with the adapter registry via the ``uipath.governance.adapters`` entry point. Audit emission and enforcement (raising :class:`GovernanceBlockException` on DENY) are owned by the evaluator itself. Each hook only extracts the relevant @@ -57,11 +56,10 @@ logger = logging.getLogger(__name__) # Cap on the text blob passed to BEFORE_MODEL / AFTER_MODEL governance -# evaluation. Sized to match the runtime side (``_GOVERNANCE_TEXT_CAP`` in -# ``uipath.runtime.governance.wrapper``) and the other adapters so scan-time -# budgets are consistent across hooks. A long conversation history is governed -# at the LLM layer by scanning only the latest request content, not the full -# prompt — see :func:`_latest_input_text`. +# evaluation. Sized to match the governance host and the other adapters so +# scan-time budgets are consistent across hooks. A long conversation history is +# governed at the LLM layer by scanning only the latest request content, not the +# full prompt — see :func:`_latest_input_text`. _BEFORE_MODEL_TEXT_CAP = 64000 # Marks an agent we have already governed so a double ``attach`` is a no-op and @@ -136,9 +134,8 @@ def detach(self, governed: Any) -> Any: def _iter_agents(root: Any) -> List[Any]: """Return every agent node reachable through the ``handoffs`` graph. - A node qualifies if it exposes the ``hooks`` slot (duck-typed so we don't - hard-require ``Agent`` to be importable in every path). Handoff targets may - be ``Agent`` instances or ``Handoff`` objects that carry the target on + A node qualifies if it exposes the ``hooks`` slot. Handoff targets may be + ``Agent`` instances or ``Handoff`` objects that carry the target on ``.agent``; both are followed so a multi-agent app is governed end to end. Cycles and pathological depth are bounded by an id-visited set and a hard cap. diff --git a/packages/uipath-openai-agents/tests/governance/test_adapter.py b/packages/uipath-openai-agents/tests/governance/test_adapter.py index 6bab0318..e0225cc0 100644 --- a/packages/uipath-openai-agents/tests/governance/test_adapter.py +++ b/packages/uipath-openai-agents/tests/governance/test_adapter.py @@ -1,10 +1,11 @@ """Unit tests for the OpenAI Agents governance adapter. -These tests duck-type the OpenAI Agents payloads (response input/output -items, tools) with lightweight fakes so the real code paths are exercised -without a live LLM. ``GovernanceAgentHooks`` subclasses ``agents.AgentHooks`` -(the SDK type-checks ``agent.hooks``), so importing the adapter does require -``openai-agents`` — but the agents under test are simple stand-ins. +``can_handle`` is tested against a real ``agents.Agent``; everything else +duck-types the OpenAI Agents payloads (response input/output items, tools) +with lightweight fakes so the real code paths are exercised without a live +LLM. ``GovernanceAgentHooks`` subclasses ``agents.AgentHooks`` (the SDK +type-checks ``agent.hooks``), so importing the adapter requires +``openai-agents`` either way. The package is configured with ``asyncio_mode = "auto"``, so ``async def`` tests run without an explicit marker. From 0fcfc823afbe96071919c1370dcce8efb59433c4 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 24 Jun 2026 18:40:46 +0530 Subject: [PATCH 04/12] chore(governance): remove unrelated files bundled from a dirty tree These files were swept into the branch by a broad add; they are unrelated to the governance adapter. Reverting/removing them so the PR contains only governance changes. Co-Authored-By: Claude Opus 4.8 --- SETUP.MD | 141 ------------------ .../docs/llms_and_embeddings.md | 56 +++---- 2 files changed, 19 insertions(+), 178 deletions(-) delete mode 100644 SETUP.MD diff --git a/SETUP.MD b/SETUP.MD deleted file mode 100644 index d7b750a8..00000000 --- a/SETUP.MD +++ /dev/null @@ -1,141 +0,0 @@ -# SETUP.MD - -This file documents how to provision a clean development environment for the five packages in this repo (`uipath-agent-framework`, `uipath-google-adk`, `uipath-llamaindex`, `uipath-openai-agents`, `uipath-pydantic-ai`), run the build, execute the tests, and validate a sample code change end-to-end. It is intended both as a quick reference for human contributors and as a structured guide for automated environment-setup tooling. - -## Prerequisites - -- Python 3.11+ -- [uv](https://docs.astral.sh/uv/) 0.5+ - -### Supported platforms - -`uv` is shell- and OS-agnostic, so the commands below run unchanged on every supported platform: - -- [x] Linux -- [x] Windows -- [x] macOS - -## Environment Variables - -None required for environment setup, build, or unit tests. The suites under the `Test` section run fully offline and require no external authentication. - -> **All commands below must be run from the repository root.** The `uv --directory packages/` invocations resolve each subpackage relative to the current working directory. The first line of `## Setup` enforces this by `cd`-ing to the git root. - -## Setup - -```bash -cd "$(git rev-parse --show-toplevel)" -python3 -m pip install --upgrade uv - -# Sync all five packages (each is independent) -uv --directory packages/uipath-agent-framework sync --all-extras -uv --directory packages/uipath-google-adk sync --all-extras -uv --directory packages/uipath-llamaindex sync --all-extras -uv --directory packages/uipath-openai-agents sync --all-extras -uv --directory packages/uipath-pydantic-ai sync --all-extras -``` - -## Verify Setup - -```bash -uv --version -uv --directory packages/uipath-pydantic-ai run python --version -uv --directory packages/uipath-agent-framework run python -c "import uipath_agent_framework; print('uipath-agent-framework ok')" -uv --directory packages/uipath-google-adk run python -c "import uipath_google_adk; print('uipath-google-adk ok')" -uv --directory packages/uipath-llamaindex run python -c "import uipath_llamaindex; print('uipath-llamaindex ok')" -uv --directory packages/uipath-openai-agents run python -c "import uipath_openai_agents; print('uipath-openai-agents ok')" -uv --directory packages/uipath-pydantic-ai run python -c "import uipath_pydantic_ai; print('uipath-pydantic-ai ok')" -``` - -## Build - -N/A - -## Test - -```bash -uv --directory packages/uipath-agent-framework run pytest -uv --directory packages/uipath-google-adk run pytest -uv --directory packages/uipath-llamaindex run pytest -uv --directory packages/uipath-openai-agents run pytest -uv --directory packages/uipath-pydantic-ai run pytest -``` - -## Sample Code Change - -### The change - -Add a new `agent_count` property to `PydanticAiConfig` in `packages/uipath-pydantic-ai/src/uipath_pydantic_ai/runtime/config.py`, immediately after the existing `entrypoint` property: - -```python -@property -def agent_count(self) -> int: - """Number of agents defined in the configuration.""" - return len(self.agents) -``` - -Then create `packages/uipath-pydantic-ai/tests/test_config_agent_count.py` with two pytest tests: - -```python -"""Tests for PydanticAiConfig.agent_count.""" - -import json -from pathlib import Path - -from uipath_pydantic_ai.runtime.config import PydanticAiConfig - - -def test_agent_count_single(tmp_path: Path) -> None: - config_path = tmp_path / "pydantic_ai.json" - config_path.write_text(json.dumps({"agents": {"main": "main:agent"}})) - cfg = PydanticAiConfig(str(config_path)) - assert cfg.agent_count == 1 - - -def test_agent_count_multiple(tmp_path: Path) -> None: - config_path = tmp_path / "pydantic_ai.json" - config_path.write_text( - json.dumps( - { - "agents": { - "alpha": "alpha:agent", - "beta": "beta:agent", - "gamma": "gamma:agent", - } - } - ) - ) - cfg = PydanticAiConfig(str(config_path)) - assert cfg.agent_count == 3 -``` - -### Verification - -```bash -uv --directory packages/uipath-pydantic-ai run pytest tests/test_config_agent_count.py -v -``` - -## Test with a real UiPath Coded Agent - -The unit tests above are necessary but not sufficient — they don't exercise the package end-to-end through a real agent. The flow below validates changes against a live runtime: - -1. Apply the code changes locally. -2. Run the unit tests (see the `Sample Code Change` section above). -3. Scaffold a coded UiPath agent (PydanticAI / OpenAI / Google ADK / LlamaIndex / Agent Framework, matching the package you changed) that exercises the changed code path. -4. In the downstream project's `pyproject.toml`, add this local library as an editable dependency (substitute the package you changed): - - ```toml - [tool.uv.sources] - uipath-pydantic-ai = { path = "../path/to/uipath-integrations-python/packages/uipath-pydantic-ai", editable = true } - ``` - -5. Exercise the new behavior end-to-end: - - ```bash - uv run uipath run --input '{...}' - ``` - -6. (Optional) Open a PR and apply the `build:dev` label — this publishes the development version to Test PyPI. -7. The PR description is updated automatically with instructions for pointing the downstream agent at the Test PyPI dev version. -8. Push the dev version to UiPath with [`uipath push`](https://uipath.github.io/uipath-python/cli/#push), then deploy it to Orchestrator or Studio Web with [`uipath deploy`](https://uipath.github.io/uipath-python/cli/#deploy), and run it in cloud to confirm the changes behave correctly against the real platform. -9. Once validation is done, close the dev PR — these PRs are not meant to be merged; their only purpose was to publish a Test PyPI build for end-to-end validation. diff --git a/packages/uipath-llamaindex/docs/llms_and_embeddings.md b/packages/uipath-llamaindex/docs/llms_and_embeddings.md index 01416be8..15e25eb4 100644 --- a/packages/uipath-llamaindex/docs/llms_and_embeddings.md +++ b/packages/uipath-llamaindex/docs/llms_and_embeddings.md @@ -1,39 +1,7 @@ # LLMs and Embeddings -UiPath provides pre-configured LLM and embedding classes for several providers (OpenAI via `UiPathOpenAI`, Anthropic on AWS Bedrock via `UiPathChatBedrockConverse`, Google Vertex AI via `UiPathVertex`, and more), plus embeddings via `UiPathOpenAIEmbedding`. These handle authentication, routing, and configuration automatically, allowing you to focus on building your agents. You do not need to add API keys from OpenAI, AWS, or Google, usage of these models will consume `Agent Units` on your account. - -## Available models - -LLM models are served through the UiPath LLM Gateway and are subject to [AI Trust Layer](https://docs.uipath.com/automation-cloud/automation-cloud/latest/admin-guide/about-ai-trust-layer) policies, so the exact set of models available to you depends on your tenant configuration. List the models you can use with the `uipath` CLI: - -```console -$ uipath list-models - Available LLM Models -┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ -┃ AwsBedrock ┃ OpenAi ┃ VertexAi ┃ -┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ -│ anthropic.claude-haiku-4-5-20251001-v1:0 │ gpt-4.1-2025-04-14 │ gemini-2.5-flash │ -│ anthropic.claude-opus-4-7 │ gpt-4.1-mini-2025-04-14 │ gemini-2.5-pro │ -│ ... │ ... │ ... │ -└──────────────────────────────────────────┴─────────────────────────┴──────────────────┘ -``` - -Pick a model id from the relevant provider column and pass it (or the matching enum member) to the matching class: - -```python -from uipath_llamaindex.llms import UiPathOpenAI -from uipath_llamaindex.llms.bedrock import UiPathChatBedrockConverse -from uipath_llamaindex.llms.vertex import UiPathVertex - -# OpenAI models -llm = UiPathOpenAI(model="gpt-4.1-mini-2025-04-14") - -# AWS Bedrock (Anthropic) models -llm = UiPathChatBedrockConverse(model="anthropic.claude-sonnet-4-5-20250929-v1:0") - -# Google Vertex AI (Gemini) models -llm = UiPathVertex(model="gemini-2.5-flash") -``` +UiPath provides pre-configured LLM and embedding classes that handle authentication, routing, and configuration automatically, allowing you to focus on building your agents. +You do not need to add API keys from OpenAI, AWS, or Google, usage of these models will consume `Agent Units` on your account. ## UiPathOpenAI @@ -41,7 +9,17 @@ The `UiPathOpenAI` class is a pre-configured Azure OpenAI client that routes req ### Available Models -The OpenAI models from the `OpenAi` column of [`uipath list-models`](#available-models) can be used here, either as a model string or via the `OpenAIModel` enum. +The following OpenAI models are available through the `OpenAIModel` enum: + +- `GPT_4_1_2025_04_14` +- `GPT_4_1_MINI_2025_04_14` +- `GPT_4_1_NANO_2025_04_14` +- `GPT_4O_2024_05_13` +- `GPT_4O_2024_08_06` +- `GPT_4O_2024_11_20` +- `GPT_4O_MINI_2024_07_18` (default) +- `O3_MINI_2025_01_31` +- `TEXT_DAVINCI_003` ### Basic Usage @@ -165,7 +143,9 @@ from uipath_llamaindex.llms import BedrockModel llm = UiPathChatBedrock(model=BedrockModel.anthropic_claude_sonnet_4) ``` -The available models are the ones in the `AwsBedrock` column of [`uipath list-models`](#available-models). +Currently, the following models can be used (this list can be updated in the future): + +- `anthropic.claude-3-7-sonnet-20250219-v1:0`, `anthropic.claude-sonnet-4-20250514-v1:0`, `anthropic.claude-sonnet-4-5-20250929-v1:0`, `anthropic.claude-haiku-4-5-20251001-v1:0` ## UiPathVertex @@ -204,7 +184,9 @@ response = llm.chat(messages) print(response) ``` -The available models are the ones in the `VertexAi` column of [`uipath list-models`](#available-models). +Currently, the following models can be used (this list can be updated in the future): + +- `gemini-2.0-flash-001`, `gemini-2.5-flash`, `gemini-2.5-pro` ## Integration with LlamaIndex From 4ecb14ee13fee0b96d9058cdb47b68f9cf695be9 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 24 Jun 2026 19:01:02 +0530 Subject: [PATCH 05/12] chore(governance): restore SETUP.MD and llms doc that belong to main An earlier cleanup commit compared against a stale local main and wrongly removed SETUP.MD and reverted the LlamaIndex docs change. Both files come from main (PRs #352/#356), not this branch. Restore them to the main version so this PR is governance-only with no spurious deletions. Co-Authored-By: Claude Opus 4.8 --- SETUP.MD | 141 ++++++++++++++++++ .../docs/llms_and_embeddings.md | 56 ++++--- 2 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 SETUP.MD diff --git a/SETUP.MD b/SETUP.MD new file mode 100644 index 00000000..d7b750a8 --- /dev/null +++ b/SETUP.MD @@ -0,0 +1,141 @@ +# SETUP.MD + +This file documents how to provision a clean development environment for the five packages in this repo (`uipath-agent-framework`, `uipath-google-adk`, `uipath-llamaindex`, `uipath-openai-agents`, `uipath-pydantic-ai`), run the build, execute the tests, and validate a sample code change end-to-end. It is intended both as a quick reference for human contributors and as a structured guide for automated environment-setup tooling. + +## Prerequisites + +- Python 3.11+ +- [uv](https://docs.astral.sh/uv/) 0.5+ + +### Supported platforms + +`uv` is shell- and OS-agnostic, so the commands below run unchanged on every supported platform: + +- [x] Linux +- [x] Windows +- [x] macOS + +## Environment Variables + +None required for environment setup, build, or unit tests. The suites under the `Test` section run fully offline and require no external authentication. + +> **All commands below must be run from the repository root.** The `uv --directory packages/` invocations resolve each subpackage relative to the current working directory. The first line of `## Setup` enforces this by `cd`-ing to the git root. + +## Setup + +```bash +cd "$(git rev-parse --show-toplevel)" +python3 -m pip install --upgrade uv + +# Sync all five packages (each is independent) +uv --directory packages/uipath-agent-framework sync --all-extras +uv --directory packages/uipath-google-adk sync --all-extras +uv --directory packages/uipath-llamaindex sync --all-extras +uv --directory packages/uipath-openai-agents sync --all-extras +uv --directory packages/uipath-pydantic-ai sync --all-extras +``` + +## Verify Setup + +```bash +uv --version +uv --directory packages/uipath-pydantic-ai run python --version +uv --directory packages/uipath-agent-framework run python -c "import uipath_agent_framework; print('uipath-agent-framework ok')" +uv --directory packages/uipath-google-adk run python -c "import uipath_google_adk; print('uipath-google-adk ok')" +uv --directory packages/uipath-llamaindex run python -c "import uipath_llamaindex; print('uipath-llamaindex ok')" +uv --directory packages/uipath-openai-agents run python -c "import uipath_openai_agents; print('uipath-openai-agents ok')" +uv --directory packages/uipath-pydantic-ai run python -c "import uipath_pydantic_ai; print('uipath-pydantic-ai ok')" +``` + +## Build + +N/A + +## Test + +```bash +uv --directory packages/uipath-agent-framework run pytest +uv --directory packages/uipath-google-adk run pytest +uv --directory packages/uipath-llamaindex run pytest +uv --directory packages/uipath-openai-agents run pytest +uv --directory packages/uipath-pydantic-ai run pytest +``` + +## Sample Code Change + +### The change + +Add a new `agent_count` property to `PydanticAiConfig` in `packages/uipath-pydantic-ai/src/uipath_pydantic_ai/runtime/config.py`, immediately after the existing `entrypoint` property: + +```python +@property +def agent_count(self) -> int: + """Number of agents defined in the configuration.""" + return len(self.agents) +``` + +Then create `packages/uipath-pydantic-ai/tests/test_config_agent_count.py` with two pytest tests: + +```python +"""Tests for PydanticAiConfig.agent_count.""" + +import json +from pathlib import Path + +from uipath_pydantic_ai.runtime.config import PydanticAiConfig + + +def test_agent_count_single(tmp_path: Path) -> None: + config_path = tmp_path / "pydantic_ai.json" + config_path.write_text(json.dumps({"agents": {"main": "main:agent"}})) + cfg = PydanticAiConfig(str(config_path)) + assert cfg.agent_count == 1 + + +def test_agent_count_multiple(tmp_path: Path) -> None: + config_path = tmp_path / "pydantic_ai.json" + config_path.write_text( + json.dumps( + { + "agents": { + "alpha": "alpha:agent", + "beta": "beta:agent", + "gamma": "gamma:agent", + } + } + ) + ) + cfg = PydanticAiConfig(str(config_path)) + assert cfg.agent_count == 3 +``` + +### Verification + +```bash +uv --directory packages/uipath-pydantic-ai run pytest tests/test_config_agent_count.py -v +``` + +## Test with a real UiPath Coded Agent + +The unit tests above are necessary but not sufficient — they don't exercise the package end-to-end through a real agent. The flow below validates changes against a live runtime: + +1. Apply the code changes locally. +2. Run the unit tests (see the `Sample Code Change` section above). +3. Scaffold a coded UiPath agent (PydanticAI / OpenAI / Google ADK / LlamaIndex / Agent Framework, matching the package you changed) that exercises the changed code path. +4. In the downstream project's `pyproject.toml`, add this local library as an editable dependency (substitute the package you changed): + + ```toml + [tool.uv.sources] + uipath-pydantic-ai = { path = "../path/to/uipath-integrations-python/packages/uipath-pydantic-ai", editable = true } + ``` + +5. Exercise the new behavior end-to-end: + + ```bash + uv run uipath run --input '{...}' + ``` + +6. (Optional) Open a PR and apply the `build:dev` label — this publishes the development version to Test PyPI. +7. The PR description is updated automatically with instructions for pointing the downstream agent at the Test PyPI dev version. +8. Push the dev version to UiPath with [`uipath push`](https://uipath.github.io/uipath-python/cli/#push), then deploy it to Orchestrator or Studio Web with [`uipath deploy`](https://uipath.github.io/uipath-python/cli/#deploy), and run it in cloud to confirm the changes behave correctly against the real platform. +9. Once validation is done, close the dev PR — these PRs are not meant to be merged; their only purpose was to publish a Test PyPI build for end-to-end validation. diff --git a/packages/uipath-llamaindex/docs/llms_and_embeddings.md b/packages/uipath-llamaindex/docs/llms_and_embeddings.md index 15e25eb4..01416be8 100644 --- a/packages/uipath-llamaindex/docs/llms_and_embeddings.md +++ b/packages/uipath-llamaindex/docs/llms_and_embeddings.md @@ -1,7 +1,39 @@ # LLMs and Embeddings -UiPath provides pre-configured LLM and embedding classes that handle authentication, routing, and configuration automatically, allowing you to focus on building your agents. -You do not need to add API keys from OpenAI, AWS, or Google, usage of these models will consume `Agent Units` on your account. +UiPath provides pre-configured LLM and embedding classes for several providers (OpenAI via `UiPathOpenAI`, Anthropic on AWS Bedrock via `UiPathChatBedrockConverse`, Google Vertex AI via `UiPathVertex`, and more), plus embeddings via `UiPathOpenAIEmbedding`. These handle authentication, routing, and configuration automatically, allowing you to focus on building your agents. You do not need to add API keys from OpenAI, AWS, or Google, usage of these models will consume `Agent Units` on your account. + +## Available models + +LLM models are served through the UiPath LLM Gateway and are subject to [AI Trust Layer](https://docs.uipath.com/automation-cloud/automation-cloud/latest/admin-guide/about-ai-trust-layer) policies, so the exact set of models available to you depends on your tenant configuration. List the models you can use with the `uipath` CLI: + +```console +$ uipath list-models + Available LLM Models +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓ +┃ AwsBedrock ┃ OpenAi ┃ VertexAi ┃ +┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩ +│ anthropic.claude-haiku-4-5-20251001-v1:0 │ gpt-4.1-2025-04-14 │ gemini-2.5-flash │ +│ anthropic.claude-opus-4-7 │ gpt-4.1-mini-2025-04-14 │ gemini-2.5-pro │ +│ ... │ ... │ ... │ +└──────────────────────────────────────────┴─────────────────────────┴──────────────────┘ +``` + +Pick a model id from the relevant provider column and pass it (or the matching enum member) to the matching class: + +```python +from uipath_llamaindex.llms import UiPathOpenAI +from uipath_llamaindex.llms.bedrock import UiPathChatBedrockConverse +from uipath_llamaindex.llms.vertex import UiPathVertex + +# OpenAI models +llm = UiPathOpenAI(model="gpt-4.1-mini-2025-04-14") + +# AWS Bedrock (Anthropic) models +llm = UiPathChatBedrockConverse(model="anthropic.claude-sonnet-4-5-20250929-v1:0") + +# Google Vertex AI (Gemini) models +llm = UiPathVertex(model="gemini-2.5-flash") +``` ## UiPathOpenAI @@ -9,17 +41,7 @@ The `UiPathOpenAI` class is a pre-configured Azure OpenAI client that routes req ### Available Models -The following OpenAI models are available through the `OpenAIModel` enum: - -- `GPT_4_1_2025_04_14` -- `GPT_4_1_MINI_2025_04_14` -- `GPT_4_1_NANO_2025_04_14` -- `GPT_4O_2024_05_13` -- `GPT_4O_2024_08_06` -- `GPT_4O_2024_11_20` -- `GPT_4O_MINI_2024_07_18` (default) -- `O3_MINI_2025_01_31` -- `TEXT_DAVINCI_003` +The OpenAI models from the `OpenAi` column of [`uipath list-models`](#available-models) can be used here, either as a model string or via the `OpenAIModel` enum. ### Basic Usage @@ -143,9 +165,7 @@ from uipath_llamaindex.llms import BedrockModel llm = UiPathChatBedrock(model=BedrockModel.anthropic_claude_sonnet_4) ``` -Currently, the following models can be used (this list can be updated in the future): - -- `anthropic.claude-3-7-sonnet-20250219-v1:0`, `anthropic.claude-sonnet-4-20250514-v1:0`, `anthropic.claude-sonnet-4-5-20250929-v1:0`, `anthropic.claude-haiku-4-5-20251001-v1:0` +The available models are the ones in the `AwsBedrock` column of [`uipath list-models`](#available-models). ## UiPathVertex @@ -184,9 +204,7 @@ response = llm.chat(messages) print(response) ``` -Currently, the following models can be used (this list can be updated in the future): - -- `gemini-2.0-flash-001`, `gemini-2.5-flash`, `gemini-2.5-pro` +The available models are the ones in the `VertexAi` column of [`uipath list-models`](#available-models). ## Integration with LlamaIndex From fd72ec54f704871803be688a19f7cc9c23466c99 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 30 Jun 2026 16:55:56 +0530 Subject: [PATCH 06/12] docs(governance): clarify OpenAI Agents tool-name + duck-typing rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AFTER_TOOL reads the tool name directly from the SDK-provided tool at both on_tool_start and on_tool_end, so no start→end correlation is needed (addresses the #899-style tool-identity review note). Also record that the run-item/response text extraction is duck-typed intentionally, since the SDK shapes are not stable public models. Co-Authored-By: Claude Opus 4.8 --- .../src/uipath_openai_agents/governance/adapter.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py index f0850b20..32f9fe41 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py @@ -268,7 +268,12 @@ async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None: async def on_tool_end( self, context: Any, agent: Any, tool: Any, result: Any ) -> None: - """Evaluate AFTER_TOOL rules immediately after a tool is invoked.""" + """Evaluate AFTER_TOOL rules immediately after a tool is invoked. + + The SDK passes ``tool`` to both ``on_tool_start`` and ``on_tool_end``, + so the name is read directly here — no start→end correlation is needed + (unlike callback frameworks whose end hook omits the tool). + """ try: tool_name = getattr(tool, "name", None) or "unknown" tool_result = "" if result is None else _stringify(result) @@ -301,6 +306,10 @@ async def on_handoff(self, context: Any, agent: Any, source: Any) -> None: # -------------------------------------------------------------------------- # Delegation + text extraction (module-level, sync, duck-typed) +# +# Extraction is duck-typed on purpose: the OpenAI Agents SDK's run-item / +# response shapes are not stable public models, so we read attributes +# defensively rather than isinstance-checking SDK types that may move. # -------------------------------------------------------------------------- From e0c7d8600563863085aab6c84a1f5a843ac90c80 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 30 Jun 2026 19:01:37 +0530 Subject: [PATCH 07/12] refactor(governance): migrate OpenAI Agents adapter to factory-evaluator Core PR #1761 dropped BaseAdapter + AdapterRegistry from uipath-core (only EvaluatorProtocol remains), so the entry-point/adapter model no longer compiles against current core. Migrate to the factory-evaluator pattern used by the LangChain adapter (#899): - governance/adapter.py: replace the BaseAdapter subclass (name/ can_handle/attach/detach) with a module-level install_governance(); keep GovernanceAgentHooks + the handoff-graph walk + chaining. - runtime/factory.py: new_runtime reads `evaluator` from kwargs and calls install_governance on the resolved agent. - governance/__init__.py: drop register_governance_adapter + the registry import; expose install_governance. No import-time side effects. - pyproject.toml: remove the uipath.governance.adapters entry point. - tests: drop can_handle/attach/detach tests; cover install_governance + factory wiring (evaluator present installs, absent skips). ruff + mypy clean; 24 governance tests pass. Co-Authored-By: Claude Opus 4.8 --- packages/uipath-openai-agents/pyproject.toml | 3 - .../governance/__init__.py | 51 ++------ .../governance/adapter.py | 110 +++++++----------- .../uipath_openai_agents/runtime/factory.py | 18 ++- .../tests/governance/test_adapter.py | 103 ++++++++-------- 5 files changed, 120 insertions(+), 165 deletions(-) diff --git a/packages/uipath-openai-agents/pyproject.toml b/packages/uipath-openai-agents/pyproject.toml index 69d64b1c..7a88aeae 100644 --- a/packages/uipath-openai-agents/pyproject.toml +++ b/packages/uipath-openai-agents/pyproject.toml @@ -31,9 +31,6 @@ register = "uipath_openai_agents.middlewares:register_middleware" [project.entry-points."uipath.runtime.factories"] openai-agents = "uipath_openai_agents.runtime:register_runtime_factory" -[project.entry-points."uipath.governance.adapters"] -openai-agents = "uipath_openai_agents.governance:register_governance_adapter" - [project.urls] Homepage = "https://uipath.com" Repository = "https://github.com/UiPath/uipath-integrations-python" diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py index 344f275e..9d5b2395 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py @@ -1,51 +1,20 @@ """Governance integration for ``uipath-openai-agents``. -Registers :class:`OpenAIAgentsAdapter` with the adapter registry in -``uipath.core.adapters`` so the governance host can attach the -OpenAI-Agents-specific inner hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, -AFTER_TOOL) when it sees an OpenAI Agents agent. - -Registration is **idempotent**: calling :func:`register_governance_adapter` -twice is a no-op on the second call. - -Wiring: the package exposes :func:`register_governance_adapter` as an entry -point under ``uipath.governance.adapters``. The governance adapter discovery -path calls it to register the adapter. Importing this module does not, by -itself, mutate the global registry. +Exposes :func:`install_governance` — installs the OpenAI-Agents-specific inner +hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, AFTER_TOOL) onto an agent's native +``hooks`` slot. Wired into a run by passing an ``evaluator`` to +:class:`UiPathOpenAIAgentRuntimeFactory`; the factory calls +:func:`install_governance` on the resolved agent. + +Importing this module has no side effects: no adapter is registered, no global +state is mutated. """ from __future__ import annotations -import logging - -from uipath.core.adapters import get_adapter_registry - -from .adapter import GovernanceAgentHooks, OpenAIAgentsAdapter - -logger = logging.getLogger(__name__) - -_registered: bool = False - - -def register_governance_adapter() -> None: - """Register :class:`OpenAIAgentsAdapter` with the global registry. - - Idempotent — safe to call multiple times. - """ - global _registered - if _registered: - return - registry = get_adapter_registry() - if any(a.name == "OpenAIAgents" for a in registry.get_all()): - _registered = True - return - registry.register(OpenAIAgentsAdapter()) - _registered = True - logger.debug("Registered uipath-openai-agents governance adapter") - +from .adapter import GovernanceAgentHooks, install_governance __all__ = [ "GovernanceAgentHooks", - "OpenAIAgentsAdapter", - "register_governance_adapter", + "install_governance", ] \ No newline at end of file diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py index 32f9fe41..52675b8c 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py @@ -30,9 +30,12 @@ boundary evaluation. (The SDK's per-agent ``on_start`` / ``on_end`` are pass-through-only here for that reason.) -Contracts and the evaluator protocol come from ``uipath-core``; this package -contributes only the OpenAI-Agents-specific implementation and registers it -with the adapter registry via the ``uipath.governance.adapters`` entry point. +The evaluator protocol comes from ``uipath-core``; this package contributes +only the OpenAI-Agents-specific wiring. Governance is installed by the runtime +factory: passing an ``evaluator`` to +:class:`UiPathOpenAIAgentRuntimeFactory.new_runtime` calls +:func:`install_governance` on the resolved agent. No adapter registry, no +entry point, no import-time side effects. Audit emission and enforcement (raising :class:`GovernanceBlockException` on DENY) are owned by the evaluator itself. Each hook only extracts the relevant @@ -50,7 +53,7 @@ from uuid import uuid4 from agents import Agent, AgentHooks -from uipath.core.adapters import BaseAdapter, EvaluatorProtocol +from uipath.core.adapters import EvaluatorProtocol from uipath.core.governance.exceptions import GovernanceBlockException logger = logging.getLogger(__name__) @@ -62,73 +65,46 @@ # full prompt — see :func:`_latest_input_text`. _BEFORE_MODEL_TEXT_CAP = 64000 -# Marks an agent we have already governed so a double ``attach`` is a no-op and -# ``detach`` can restore the hooks slot to whatever was there before. -_PREV_HOOKS_ATTR = "_uipath_governance_prev_hooks" +def install_governance( + agent: Agent, + evaluator: EvaluatorProtocol, + *, + agent_name: str, + session_id: str, +) -> Agent: + """Install governance hooks on the agent graph (mutated in place). -class OpenAIAgentsAdapter(BaseAdapter): - """Adapter for the OpenAI Agents SDK. + Walks every agent reachable through ``handoffs`` and installs a + :class:`GovernanceAgentHooks` on each one's ``hooks`` slot, chaining to any + pre-existing hooks. Returns the original ``agent`` — the ``Runner`` already + holds this reference, so in-place mutation is what wires governance into + execution. Idempotent: an already-governed agent is left untouched. - Detects ``agents.Agent`` instances and installs governance hooks on every - agent reachable through the ``handoffs`` graph. + Called by :class:`UiPathOpenAIAgentRuntimeFactory` when an ``evaluator`` + is supplied to ``new_runtime``. """ - - @property - def name(self) -> str: - return "OpenAIAgents" - - def can_handle(self, agent: Any) -> bool: - """Return True only for an OpenAI Agents ``Agent``.""" - return isinstance(agent, Agent) - - def attach( - self, - agent: Any, - agent_id: str, - session_id: str, - evaluator: EvaluatorProtocol, - ) -> Any: - """Install governance hooks on the agent graph (mutated in place). - - Returns the original ``agent`` — the ``Runner`` already holds this - reference, so in-place mutation is what actually wires governance into - execution. A wrapping proxy would not reach the ``Runner`` and would - break the SDK's ``isinstance(agent, Agent)`` checks. - """ - agents = _iter_agents(agent) - installed = 0 - for node in agents: - if isinstance(getattr(node, "hooks", None), GovernanceAgentHooks): - continue # idempotent — already governed - prev = getattr(node, "hooks", None) - hooks = GovernanceAgentHooks( - evaluator=evaluator, - agent_name=agent_id, - session_id=session_id, - inner=prev, - ) - # Remember what was there so detach can restore it. - setattr(node, _PREV_HOOKS_ATTR, prev) - node.hooks = hooks - installed += 1 - if not agents: - logger.warning( - "OpenAIAgentsAdapter found no Agent in %s — deep hooks will not fire", - type(agent).__name__, - ) - else: - logger.debug("Installed governance hooks on %d OpenAI agent(s)", installed) - return agent - - def detach(self, governed: Any) -> Any: - """Restore each agent's original ``hooks`` slot and return the graph.""" - for node in _iter_agents(governed): - if isinstance(getattr(node, "hooks", None), GovernanceAgentHooks): - node.hooks = getattr(node, _PREV_HOOKS_ATTR, None) - if hasattr(node, _PREV_HOOKS_ATTR): - delattr(node, _PREV_HOOKS_ATTR) - return governed + agents = _iter_agents(agent) + installed = 0 + for node in agents: + if isinstance(getattr(node, "hooks", None), GovernanceAgentHooks): + continue # idempotent — already governed + prev = getattr(node, "hooks", None) + node.hooks = GovernanceAgentHooks( + evaluator=evaluator, + agent_name=agent_name, + session_id=session_id, + inner=prev, + ) + installed += 1 + if not agents: + logger.warning( + "install_governance found no Agent in %s — deep hooks will not fire", + type(agent).__name__, + ) + else: + logger.debug("Installed governance hooks on %d OpenAI agent(s)", installed) + return agent def _iter_agents(root: Any) -> List[Any]: diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/runtime/factory.py b/packages/uipath-openai-agents/src/uipath_openai_agents/runtime/factory.py index 226df03d..bbe62473 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/runtime/factory.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/runtime/factory.py @@ -5,6 +5,7 @@ from agents import Agent from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor +from uipath.core.adapters import EvaluatorProtocol from uipath.runtime import ( UiPathRuntimeContext, UiPathRuntimeFactorySettings, @@ -13,6 +14,7 @@ ) from uipath.runtime.errors import UiPathErrorCategory +from uipath_openai_agents.governance import install_governance from uipath_openai_agents.runtime.agent import OpenAiAgentLoader from uipath_openai_agents.runtime.config import OpenAiAgentsConfig from uipath_openai_agents.runtime.errors import ( @@ -201,6 +203,7 @@ async def _create_runtime_instance( agent: Agent, runtime_id: str, entrypoint: str, + evaluator: EvaluatorProtocol | None = None, ) -> UiPathRuntimeProtocol: """ Create a runtime instance from an agent. @@ -209,10 +212,20 @@ async def _create_runtime_instance( agent: The OpenAI Agent runtime_id: Unique identifier for the runtime instance entrypoint: Agent entrypoint name + evaluator: When supplied, governance hooks are installed on the + agent graph in place via :func:`install_governance`. Returns: Configured runtime instance """ + if evaluator is not None: + install_governance( + agent, + evaluator, + agent_name=entrypoint, + session_id=runtime_id, + ) + return UiPathOpenAIAgentRuntime( agent=agent, runtime_id=runtime_id, @@ -228,7 +241,9 @@ async def new_runtime( Args: entrypoint: Agent name from openai_agents.json runtime_id: Unique identifier for the runtime instance - **kwargs: Additional keyword arguments (unused) + **kwargs: Forwarded factory kwargs. Recognized: ``evaluator`` + (``EvaluatorProtocol``) — when present, governance hooks are + installed on the agent via :func:`install_governance`. Returns: Configured runtime instance with agent @@ -239,6 +254,7 @@ async def new_runtime( agent=agent, runtime_id=runtime_id, entrypoint=entrypoint, + evaluator=kwargs.get("evaluator"), ) async def dispose(self) -> None: diff --git a/packages/uipath-openai-agents/tests/governance/test_adapter.py b/packages/uipath-openai-agents/tests/governance/test_adapter.py index e0225cc0..25187a96 100644 --- a/packages/uipath-openai-agents/tests/governance/test_adapter.py +++ b/packages/uipath-openai-agents/tests/governance/test_adapter.py @@ -23,7 +23,7 @@ from uipath_openai_agents.governance.adapter import ( _BEFORE_MODEL_TEXT_CAP, GovernanceAgentHooks, - OpenAIAgentsAdapter, + install_governance, ) # -------------------------------------------------------------------------- @@ -123,34 +123,17 @@ def _make_hooks(evaluator: FakeEvaluator, inner: Any = None) -> GovernanceAgentH # -------------------------------------------------------------------------- -# can_handle +# install_governance # -------------------------------------------------------------------------- -def test_can_handle_real_agent(): - from agents import Agent - - assert OpenAIAgentsAdapter().can_handle(Agent(name="t")) is True - - -def test_can_handle_rejects_non_agent(): - # A duck-typed look-alike must NOT be claimed — only a real Agent is. - assert OpenAIAgentsAdapter().can_handle(FakeAgent()) is False - assert OpenAIAgentsAdapter().can_handle(object()) is False - - -# -------------------------------------------------------------------------- -# attach / detach -# -------------------------------------------------------------------------- - - -def test_attach_installs_on_all_agents_in_handoff_graph(): +def test_install_governance_installs_on_all_agents_in_handoff_graph(): leaf_a = FakeAgent("a") leaf_b = FakeAgent("b") root = FakeAgent("root", handoffs=[leaf_a, leaf_b]) - returned = OpenAIAgentsAdapter().attach( - root, agent_id="x", session_id="s", evaluator=FakeEvaluator() + returned = install_governance( + root, FakeEvaluator(), agent_name="x", session_id="s" ) assert returned is root # original returned, not a proxy @@ -158,56 +141,35 @@ def test_attach_installs_on_all_agents_in_handoff_graph(): assert isinstance(node.hooks, GovernanceAgentHooks) -def test_attach_follows_handoff_wrapper_objects(): +def test_install_governance_follows_handoff_wrapper_objects(): target = FakeAgent("target") handoff = SimpleNamespace(agent=target) # Handoff-shaped wrapper root = FakeAgent("root", handoffs=[handoff]) - OpenAIAgentsAdapter().attach(root, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + install_governance(root, FakeEvaluator(), agent_name="x", session_id="s") assert isinstance(target.hooks, GovernanceAgentHooks) -def test_attach_is_idempotent(): +def test_install_governance_is_idempotent(): agent = FakeAgent() - adapter = OpenAIAgentsAdapter() ev = FakeEvaluator() - adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + install_governance(agent, ev, agent_name="x", session_id="s") first = agent.hooks - adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + install_governance(agent, ev, agent_name="x", session_id="s") assert agent.hooks is first # not re-wrapped -def test_attach_chains_existing_hooks(): +def test_install_governance_chains_existing_hooks(): agent = FakeAgent() user_hooks = RecordingHooks() agent.hooks = user_hooks - OpenAIAgentsAdapter().attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + install_governance(agent, FakeEvaluator(), agent_name="x", session_id="s") assert isinstance(agent.hooks, GovernanceAgentHooks) assert agent.hooks._inner is user_hooks -def test_detach_restores_previous_hooks(): - agent = FakeAgent() - user_hooks = RecordingHooks() - agent.hooks = user_hooks - adapter = OpenAIAgentsAdapter() - adapter.attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) - adapter.detach(agent) - assert agent.hooks is user_hooks - - -def test_detach_restores_none_when_no_prior_hooks(): - agent = FakeAgent() - adapter = OpenAIAgentsAdapter() - adapter.attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) - adapter.detach(agent) - assert agent.hooks is None - - -def test_attach_warns_when_no_agent(caplog): +def test_install_governance_warns_when_no_agent(caplog): with caplog.at_level(logging.WARNING): - OpenAIAgentsAdapter().attach( - object(), agent_id="x", session_id="s", evaluator=FakeEvaluator() - ) + install_governance(object(), FakeEvaluator(), agent_name="x", session_id="s") # type: ignore[arg-type] assert any("no Agent" in r.message for r in caplog.records) @@ -376,4 +338,39 @@ async def test_hooks_return_none(): assert await cb.on_llm_start(None, FakeAgent(), None, []) is None assert await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) is None assert await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) is None - assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None \ No newline at end of file + assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None + + +# -------------------------------------------------------------------------- +# Factory wiring — the evaluator kwarg drives install_governance +# -------------------------------------------------------------------------- + + +def _factory_without_init(): + """A factory instance that skips __init__ (avoids SDK instrumentation).""" + from uipath_openai_agents.runtime.factory import UiPathOpenAIAgentRuntimeFactory + + return UiPathOpenAIAgentRuntimeFactory.__new__(UiPathOpenAIAgentRuntimeFactory) + + +async def test_factory_installs_governance_when_evaluator_supplied(monkeypatch): + from uipath_openai_agents.runtime import factory as factory_mod + + # Stub the runtime so we don't introspect a real Agent. + monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)) + agent = FakeAgent() + await _factory_without_init()._create_runtime_instance( + agent=agent, runtime_id="r", entrypoint="e", evaluator=FakeEvaluator() + ) + assert isinstance(agent.hooks, GovernanceAgentHooks) + + +async def test_factory_skips_governance_without_evaluator(monkeypatch): + from uipath_openai_agents.runtime import factory as factory_mod + + monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)) + agent = FakeAgent() + await _factory_without_init()._create_runtime_instance( + agent=agent, runtime_id="r", entrypoint="e" + ) + assert agent.hooks is None \ No newline at end of file From db7b42a310b6c7e3094a581c3c5794d1dc7b915f Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Tue, 30 Jun 2026 19:26:53 +0530 Subject: [PATCH 08/12] refactor(governance): rename OpenAI adapter.py -> hooks.py for #899 consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module is no longer an adapter (no BaseAdapter). Name the file after its native seam — agent hooks — matching how the LangChain integration (#899) uses callbacks.py. Renames adapter.py->hooks.py and test_adapter.py->test_hooks.py; updates imports + docstrings. Co-Authored-By: Claude Opus 4.8 --- .../governance/__init__.py | 2 +- .../governance/{adapter.py => hooks.py} | 22 +++++++++---------- .../{test_adapter.py => test_hooks.py} | 4 ++-- 3 files changed, 14 insertions(+), 14 deletions(-) rename packages/uipath-openai-agents/src/uipath_openai_agents/governance/{adapter.py => hooks.py} (96%) rename packages/uipath-openai-agents/tests/governance/{test_adapter.py => test_hooks.py} (99%) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py index 9d5b2395..f7133a2e 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py @@ -12,7 +12,7 @@ from __future__ import annotations -from .adapter import GovernanceAgentHooks, install_governance +from .hooks import GovernanceAgentHooks, install_governance __all__ = [ "GovernanceAgentHooks", diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py similarity index 96% rename from packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py rename to packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py index 52675b8c..0e99099f 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/adapter.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py @@ -1,29 +1,29 @@ -"""OpenAI Agents adapter for UiPath governance. +"""OpenAI Agents governance hooks for UiPath. Provides governance for OpenAI Agents SDK agents (``agents.Agent`` and any -graph of agents reachable via ``handoffs``). Like the Google ADK adapter — -and unlike the LangChain adapter, which wraps a ``Runnable`` and intercepts +graph of agents reachable via ``handoffs``). Like the Google ADK integration — +and unlike the LangChain one, which wraps a ``Runnable`` and intercepts ``invoke`` / ``ainvoke`` — OpenAI Agents are executed by ``Runner.run`` / ``Runner.run_streamed``, which hold their **own** reference to the agent object. Replacing ``runtime.agent`` with a proxy would never reach the -``Runner``. So this adapter installs governance directly onto each agent's -native ``hooks`` attribute (an :class:`agents.AgentHooks`), mutating it in -place: +``Runner``. So :func:`install_governance` installs governance directly onto +each agent's native ``hooks`` attribute (an :class:`agents.AgentHooks`), +mutating it in place: - ``on_llm_start`` → BEFORE_MODEL - ``on_llm_end`` → AFTER_MODEL - ``on_tool_start`` → TOOL_CALL - ``on_tool_end`` → AFTER_TOOL -Because the mutation is in place, :meth:`OpenAIAgentsAdapter.attach` returns -the **original agent** (hooks installed) rather than a wrapping proxy. +Because the mutation is in place, :func:`install_governance` returns the +**original agent** (hooks installed) rather than a wrapping proxy. ``agents.Agent`` validates that ``hooks`` is an ``AgentHooks`` instance, so -:class:`GovernanceAgentHooks` subclasses it (the ADK adapter could duck-type -its callbacks; here the SDK type-checks the slot). +:class:`GovernanceAgentHooks` subclasses it (the ADK integration could +duck-type its callbacks; here the SDK type-checks the slot). ``agent.hooks`` holds a **single** ``AgentHooks`` (not a list, as in ADK), so when an agent already carries user hooks we *chain*: governance runs first, -then the previously-installed hooks. ``detach`` restores the original. +then the previously-installed hooks. Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are owned by the governance host, so they are not fired here — that would duplicate every diff --git a/packages/uipath-openai-agents/tests/governance/test_adapter.py b/packages/uipath-openai-agents/tests/governance/test_hooks.py similarity index 99% rename from packages/uipath-openai-agents/tests/governance/test_adapter.py rename to packages/uipath-openai-agents/tests/governance/test_hooks.py index 25187a96..e20c594a 100644 --- a/packages/uipath-openai-agents/tests/governance/test_adapter.py +++ b/packages/uipath-openai-agents/tests/governance/test_hooks.py @@ -1,4 +1,4 @@ -"""Unit tests for the OpenAI Agents governance adapter. +"""Unit tests for the OpenAI Agents governance hooks. ``can_handle`` is tested against a real ``agents.Agent``; everything else duck-types the OpenAI Agents payloads (response input/output items, tools) @@ -20,7 +20,7 @@ import pytest from uipath.core.governance.exceptions import GovernanceBlockException -from uipath_openai_agents.governance.adapter import ( +from uipath_openai_agents.governance.hooks import ( _BEFORE_MODEL_TEXT_CAP, GovernanceAgentHooks, install_governance, From 0917c4a79da9a850c0a6fc5bd4fe2daba25821fc Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 1 Jul 2026 15:31:27 +0530 Subject: [PATCH 09/12] =?UTF-8?q?fix(openai-agents):=20address=20governanc?= =?UTF-8?q?e=20review=20=E2=80=94=20trace=5Fid,=20counters,=20graph=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../uipath_openai_agents/governance/hooks.py | 110 ++++++++++++------ .../tests/governance/test_hooks.py | 91 ++++++++++++--- packages/uipath-openai-agents/uv.lock | 6 +- 3 files changed, 153 insertions(+), 54 deletions(-) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py index 0e99099f..3b8ba3ca 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/hooks.py @@ -50,7 +50,6 @@ import json import logging from typing import Any, Dict, List -from uuid import uuid4 from agents import Agent, AgentHooks from uipath.core.adapters import EvaluatorProtocol @@ -65,6 +64,10 @@ # full prompt — see :func:`_latest_input_text`. _BEFORE_MODEL_TEXT_CAP = 64000 +# Hard cap on how many nodes the handoff-graph walk visits, guarding against +# cyclic or pathologically deep agent graphs. Hitting it is logged, not silent. +_MAX_GRAPH_NODES = 1000 + def install_governance( agent: Agent, @@ -108,23 +111,34 @@ def install_governance( def _iter_agents(root: Any) -> List[Any]: - """Return every agent node reachable through the ``handoffs`` graph. - - A node qualifies if it exposes the ``hooks`` slot. Handoff targets may be - ``Agent`` instances or ``Handoff`` objects that carry the target on - ``.agent``; both are followed so a multi-agent app is governed end to end. - Cycles and pathological depth are bounded by an id-visited set and a hard - cap. + """Return every ``Agent`` reachable through the ``handoffs`` graph. + + A node qualifies only if it is a real :class:`agents.Agent`. The SDK + type-checks the ``hooks`` slot, so duck-typing on ``hasattr(node, "hooks")`` + could let non-Agent objects through — we isinstance-check instead. Handoff + targets may be ``Agent`` instances or ``Handoff`` objects that carry the + target on ``.agent``; both are followed so a multi-agent app is governed end + to end. Cycles and pathological depth are bounded by an id-visited set and a + hard cap (``_MAX_GRAPH_NODES``), which logs rather than silently truncating. + + Not walked: agents reachable only as tools (``agent.as_tool()``) or embedded + in input/output guardrail functions — the SDK closes over those behind + opaque callables, so they are governed by their own runtime rather than this + graph walk. """ found: List[Any] = [] seen: set[int] = set() stack: List[Any] = [root] - while stack and len(seen) < 1000: + capped = False + while stack: + if len(seen) >= _MAX_GRAPH_NODES: + capped = True + break node = stack.pop() if node is None or id(node) in seen: continue seen.add(id(node)) - if hasattr(node, "hooks"): + if isinstance(node, Agent): found.append(node) handoffs = getattr(node, "handoffs", None) if isinstance(handoffs, (list, tuple)): @@ -132,6 +146,12 @@ def _iter_agents(root: Any) -> List[Any]: # A Handoff wraps its target agent on ``.agent``; a bare Agent # is itself the target. stack.append(getattr(h, "agent", h)) + if capped: + logger.warning( + "install_governance stopped walking the agent graph at the %d-node " + "cap; agents beyond it will not be governed", + _MAX_GRAPH_NODES, + ) return found @@ -158,9 +178,24 @@ def __init__( self._agent_name = agent_name self._session_id = session_id self._inner = inner - self._trace_id = str(uuid4()) + # ``trace_id`` is intentionally NOT held here. A single uuid minted at + # install time would be identical for every model/tool call and would + # diverge across handoff nodes (each carries its own hooks). Trace + # correlation is owned by the layer below: OTel-backed sinks read the + # live span on the caller's thread, HTTP consumers resolve the canonical + # id at call time. This matches the LangChain adapter. self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0} + def _resolve_agent_name(self, agent: Any) -> str: + """Prefer the live executing agent's name over the install-time name. + + After a handoff the running node may differ from the graph entrypoint + the factory named us with; reporting the actual agent gives governance + accurate attribution. Falls back to the install-time name. + """ + name = getattr(agent, "name", None) + return name if isinstance(name, str) and name else self._agent_name + # ----- Model hooks ----------------------------------------------------- async def on_llm_start( @@ -180,21 +215,24 @@ async def on_llm_start( the prompt for context. """ try: - self._session_state["llm_calls"] = ( - self._session_state.get("llm_calls", 0) + 1 - ) model_input = _latest_input_text(input_items) self._evaluator.evaluate_before_model( model_input=model_input, - agent_name=self._agent_name, + agent_name=self._resolve_agent_name(agent), runtime_id=self._session_id, - trace_id=self._trace_id, + ) + # Count only calls that passed governance — a DENY raises above, so + # a blocked call must not inflate the counter. + self._session_state["llm_calls"] = ( + self._session_state.get("llm_calls", 0) + 1 ) except GovernanceBlockException: raise except Exception as e: # noqa: BLE001 - governance must not break the run logger.warning("on_llm_start governance check failed (continuing): %s", e) - await _delegate(self._inner, "on_llm_start", context, agent, system_prompt, input_items) + await _delegate( + self._inner, "on_llm_start", context, agent, system_prompt, input_items + ) async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None: """Evaluate AFTER_MODEL rules immediately after the LLM response.""" @@ -202,9 +240,8 @@ async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None: model_output = _model_response_text(response) self._evaluator.evaluate_after_model( model_output=model_output, - agent_name=self._agent_name, + agent_name=self._resolve_agent_name(agent), runtime_id=self._session_id, - trace_id=self._trace_id, ) except GovernanceBlockException: raise @@ -223,18 +260,19 @@ async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None: at the model layer where the call's arguments are visible in the output. """ try: - self._session_state["tool_calls"] = ( - self._session_state.get("tool_calls", 0) + 1 - ) tool_name = getattr(tool, "name", None) or "unknown" self._evaluator.evaluate_tool_call( tool_name=tool_name, tool_args={}, - agent_name=self._agent_name, + agent_name=self._resolve_agent_name(agent), runtime_id=self._session_id, - trace_id=self._trace_id, session_state=self._session_state, ) + # Count only calls that passed governance; the evaluator saw the + # count of prior tool calls, and a DENY raises before this bump. + self._session_state["tool_calls"] = ( + self._session_state.get("tool_calls", 0) + 1 + ) except GovernanceBlockException: raise except Exception as e: # noqa: BLE001 @@ -256,9 +294,8 @@ async def on_tool_end( self._evaluator.evaluate_after_tool( tool_name=tool_name, tool_result=tool_result, - agent_name=self._agent_name, + agent_name=self._resolve_agent_name(agent), runtime_id=self._session_id, - trace_id=self._trace_id, ) except GovernanceBlockException: raise @@ -341,10 +378,13 @@ def _item_text(item: Any) -> str: pieces: List[str] = [] - # A function/tool call carries its intent in name + arguments. + # A function/tool call carries its intent in name + arguments. Treat an + # item as a call only when it is explicitly typed ``function_call`` or it + # actually carries arguments — a bare ``name`` on some other item type (a + # named message part) is not a tool call. name = _get(item, "name") arguments = _get(item, "arguments") - if name and (_get(item, "type") in (None, "function_call") or arguments is not None): + if name and (_get(item, "type") == "function_call" or arguments is not None): if isinstance(name, str): pieces.append(name) if arguments is not None: @@ -419,11 +459,15 @@ def _get(obj: Any, attr: str) -> Any: return getattr(obj, attr, None) -def _stringify(value: Any) -> str: - """Render a dict / object payload as compact, scannable text.""" +def _stringify(value: Any, cap: int = _BEFORE_MODEL_TEXT_CAP) -> str: + """Render a dict / object payload as compact, scannable text, capped. + + The result is bounded by ``cap`` so an oversized tool result or argument + blob can't hand a multi-megabyte string to the evaluator. + """ if isinstance(value, str): - return value + return value[:cap] try: - return json.dumps(value, default=str, ensure_ascii=False) + return json.dumps(value, default=str, ensure_ascii=False)[:cap] except (TypeError, ValueError): - return str(value) \ No newline at end of file + return str(value)[:cap] diff --git a/packages/uipath-openai-agents/tests/governance/test_hooks.py b/packages/uipath-openai-agents/tests/governance/test_hooks.py index e20c594a..68630287 100644 --- a/packages/uipath-openai-agents/tests/governance/test_hooks.py +++ b/packages/uipath-openai-agents/tests/governance/test_hooks.py @@ -14,10 +14,12 @@ from __future__ import annotations import logging +from contextlib import contextmanager from types import SimpleNamespace -from typing import Any, List +from typing import Any, Iterator, List import pytest +from agents import Agent from uipath.core.governance.exceptions import GovernanceBlockException from uipath_openai_agents.governance.hooks import ( @@ -62,14 +64,14 @@ def evaluate_after_tool(self, **kwargs: Any) -> None: self._record("after_tool", **kwargs) -class FakeAgent: - """Minimal stand-in for ``agents.Agent`` (duck-typed by the adapter).""" +class FakeAgent(Agent): # type: ignore[type-arg] + """A real ``agents.Agent`` — the graph walk isinstance-checks ``Agent``, so + a bare duck-typed stand-in would be (correctly) skipped by + ``install_governance``. Subclassing keeps the construction lightweight while + remaining a genuine ``Agent`` instance.""" def __init__(self, name: str = "agent", handoffs: List[Any] | None = None): - self.name = name - self.hooks: Any = None - self.tools: List[Any] = [] - self.handoffs = handoffs or [] + super().__init__(name=name, handoffs=handoffs or []) class FakeTool: @@ -132,9 +134,7 @@ def test_install_governance_installs_on_all_agents_in_handoff_graph(): leaf_b = FakeAgent("b") root = FakeAgent("root", handoffs=[leaf_a, leaf_b]) - returned = install_governance( - root, FakeEvaluator(), agent_name="x", session_id="s" - ) + returned = install_governance(root, FakeEvaluator(), agent_name="x", session_id="s") assert returned is root # original returned, not a proxy for node in (root, leaf_a, leaf_b): @@ -167,8 +167,30 @@ def test_install_governance_chains_existing_hooks(): assert agent.hooks._inner is user_hooks +_HOOKS_LOGGER = "uipath_openai_agents.governance.hooks" + + +@contextmanager +def _capture_hooks_logs(caplog: Any) -> Iterator[None]: + """Attach caplog's handler straight to the hooks logger. + + Some sibling suites configure an ancestor ``uipath*`` logger with + ``propagate=False``, which silently breaks caplog's default root-handler + capture. Attaching directly to the target logger is propagation-independent. + """ + logger = logging.getLogger(_HOOKS_LOGGER) + logger.addHandler(caplog.handler) + prev = logger.level + logger.setLevel(logging.WARNING) + try: + yield + finally: + logger.removeHandler(caplog.handler) + logger.setLevel(prev) + + def test_install_governance_warns_when_no_agent(caplog): - with caplog.at_level(logging.WARNING): + with _capture_hooks_logs(caplog): install_governance(object(), FakeEvaluator(), agent_name="x", session_id="s") # type: ignore[arg-type] assert any("no Agent" in r.message for r in caplog.records) @@ -282,6 +304,26 @@ async def test_on_tool_end_none_result(): assert ev.calls[-1][1]["tool_result"] == "" +async def test_reports_live_agent_name_not_install_time_name(): + """After a handoff the executing agent differs from the graph entrypoint + the factory named us with; governance should attribute the live agent.""" + ev = FakeEvaluator() + cb = _make_hooks(ev) # install-time name is "agent-1" + await cb.on_llm_start(None, FakeAgent("billing_specialist"), None, [_msg("hi")]) + assert ev.calls[-1][1]["agent_name"] == "billing_specialist" + + +async def test_blocked_call_does_not_increment_counter(): + """A DENY raises before the counter bump, so the count is not inflated.""" + ev = FakeEvaluator(block_on="tool_call") + cb = _make_hooks(ev) + with pytest.raises(GovernanceBlockException): + await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) + # evaluator saw the pre-call count (0) and the block prevented the bump + assert ev.calls[-1][1]["session_state"]["tool_calls"] == 0 + assert cb._session_state["tool_calls"] == 0 + + # -------------------------------------------------------------------------- # chaining to user hooks # -------------------------------------------------------------------------- @@ -305,10 +347,19 @@ async def test_governance_delegates_to_inner_hooks(): @pytest.mark.parametrize( "hook,invoke", [ - ("before_model", lambda cb: cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")])), - ("after_model", lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[]))), + ( + "before_model", + lambda cb: cb.on_llm_start(None, FakeAgent(), None, [_msg("hi")]), + ), + ( + "after_model", + lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])), + ), ("tool_call", lambda cb: cb.on_tool_start(None, FakeAgent(), FakeTool("t"))), - ("after_tool", lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1})), + ( + "after_tool", + lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1}), + ), ], ) async def test_block_exception_propagates(hook, invoke): @@ -327,7 +378,7 @@ def evaluate_before_model(self, **_: Any) -> None: agent_name="a", session_id="s", ) - with caplog.at_level(logging.WARNING): + with _capture_hooks_logs(caplog): # must NOT raise — a governance bug can't break the agent run await cb.on_llm_start(None, FakeAgent(), None, [_msg("x")]) 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): from uipath_openai_agents.runtime import factory as factory_mod # Stub the runtime so we don't introspect a real Agent. - monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)) + monkeypatch.setattr( + factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw) + ) agent = FakeAgent() await _factory_without_init()._create_runtime_instance( agent=agent, runtime_id="r", entrypoint="e", evaluator=FakeEvaluator() @@ -368,9 +421,11 @@ async def test_factory_installs_governance_when_evaluator_supplied(monkeypatch): async def test_factory_skips_governance_without_evaluator(monkeypatch): from uipath_openai_agents.runtime import factory as factory_mod - monkeypatch.setattr(factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw)) + monkeypatch.setattr( + factory_mod, "UiPathOpenAIAgentRuntime", lambda **kw: SimpleNamespace(**kw) + ) agent = FakeAgent() await _factory_without_init()._create_runtime_instance( agent=agent, runtime_id="r", entrypoint="e" ) - assert agent.hooks is None \ No newline at end of file + assert agent.hooks is None diff --git a/packages/uipath-openai-agents/uv.lock b/packages/uipath-openai-agents/uv.lock index d34369c2..192c3081 100644 --- a/packages/uipath-openai-agents/uv.lock +++ b/packages/uipath-openai-agents/uv.lock @@ -2334,16 +2334,16 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.18" +version = "0.5.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/14/b1/d4e555a1a2ccf298195a5f2968e538b0cea8592b3e03f43fc12b178d6c69/uipath_core-0.5.18.tar.gz", hash = "sha256:63ebe8bdb818ca30a4bc9ab0ea8171315680691429931282939359ce039401ab", size = 131988, upload-time = "2026-06-08T14:04:49.688Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/f9/8d2f1d98cbebbcf059cf4561f38f34ad4cd58423e4f15cad22bd297a2563/uipath_core-0.5.28.tar.gz", hash = "sha256:942987f6b612c64f93d612ad7b242276ed75f129fdd8f25bc71c24ec8887e388", size = 130578, upload-time = "2026-06-30T14:04:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/57/de/1a820b33f7bff4565d7649772bc54c88480ac7e70f707097f7da37d05157/uipath_core-0.5.18-py3-none-any.whl", hash = "sha256:351d6faeecfc6a0acea93182e01526f39c04a77e09fa0444be5f4fb580463f5a", size = 54572, upload-time = "2026-06-08T14:04:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1e/385bb166232a57ebe938cc57ad2717f350bc922bb5d2ce31af84306b7569/uipath_core-0.5.28-py3-none-any.whl", hash = "sha256:b952a46a21710073cbc16d6d5684e9aa645c107f57a636b778cfb94aa81a1e48", size = 54980, upload-time = "2026-06-30T14:04:47.374Z" }, ] [[package]] From 697f32138923d4eb3bb6c322879dce7da9af9584 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 1 Jul 2026 17:28:50 +0530 Subject: [PATCH 10/12] fix(openai-agents): trailing newline on governance/__init__.py (W292) The review's W292 nit named __init__.py:39 too; the earlier fix only covered hooks.py and the test file. Co-Authored-By: Claude Opus 4.8 --- .../src/uipath_openai_agents/governance/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py index f7133a2e..45725a16 100644 --- a/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py +++ b/packages/uipath-openai-agents/src/uipath_openai_agents/governance/__init__.py @@ -17,4 +17,4 @@ __all__ = [ "GovernanceAgentHooks", "install_governance", -] \ No newline at end of file +] From 99fcc15a556b565565db73f435b2c8cf703d0c83 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 1 Jul 2026 19:10:18 +0530 Subject: [PATCH 11/12] test(openai-agents): make test_hooks mypy-clean (fix CI lint) CI runs mypy on the whole package incl. tests; the migration-era test file wasn't type-clean, so 'lint / Lint uipath-openai-agents' was red (pre-existing). - FakeEvaluator evaluate_* now (self, *args, **kwargs) -> Any so it structurally satisfies EvaluatorProtocol (was **kwargs-only + -> None, which mypy rejected on both signature and AuditRecord return). - bare dict -> dict[str, Any]. - type: ignore[assignment] on the RecordingHooks test double assigned to the real Agent.hooks slot; type: ignore[func-returns-value] on the return-None pass-through asserts. mypy . clean (34 files); ruff + 60 tests green. Co-Authored-By: Claude Opus 4.8 --- .../tests/governance/test_hooks.py | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/uipath-openai-agents/tests/governance/test_hooks.py b/packages/uipath-openai-agents/tests/governance/test_hooks.py index 68630287..359b8008 100644 --- a/packages/uipath-openai-agents/tests/governance/test_hooks.py +++ b/packages/uipath-openai-agents/tests/governance/test_hooks.py @@ -38,29 +38,29 @@ class FakeEvaluator: def __init__(self, block_on: str | None = None) -> None: self.block_on = block_on - self.calls: List[tuple[str, dict]] = [] + self.calls: List[tuple[str, dict[str, Any]]] = [] def _record(self, hook: str, **kwargs: Any) -> None: self.calls.append((hook, kwargs)) if self.block_on == hook: raise GovernanceBlockException("blocked") # type: ignore[call-arg] - def evaluate_before_agent(self, **kwargs: Any) -> None: + def evaluate_before_agent(self, *args: Any, **kwargs: Any) -> Any: self._record("before_agent", **kwargs) - def evaluate_after_agent(self, **kwargs: Any) -> None: + def evaluate_after_agent(self, *args: Any, **kwargs: Any) -> Any: self._record("after_agent", **kwargs) - def evaluate_before_model(self, **kwargs: Any) -> None: + def evaluate_before_model(self, *args: Any, **kwargs: Any) -> Any: self._record("before_model", **kwargs) - def evaluate_after_model(self, **kwargs: Any) -> None: + def evaluate_after_model(self, *args: Any, **kwargs: Any) -> Any: self._record("after_model", **kwargs) - def evaluate_tool_call(self, **kwargs: Any) -> None: + def evaluate_tool_call(self, *args: Any, **kwargs: Any) -> Any: self._record("tool_call", **kwargs) - def evaluate_after_tool(self, **kwargs: Any) -> None: + def evaluate_after_tool(self, *args: Any, **kwargs: Any) -> Any: self._record("after_tool", **kwargs) @@ -98,17 +98,17 @@ async def on_tool_end(self, *_a: Any) -> None: self.seen.append("on_tool_end") -def _msg(text: str, role: str = "user") -> dict: +def _msg(text: str, role: str = "user") -> dict[str, Any]: """A response input item carrying plain string content.""" return {"role": role, "content": text} -def _msg_parts(*texts: str, role: str = "user") -> dict: +def _msg_parts(*texts: str, role: str = "user") -> dict[str, Any]: """A response input item carrying a list of text parts.""" return {"role": role, "content": [{"type": "input_text", "text": t} for t in texts]} -def _function_call(name: str, arguments: str) -> dict: +def _function_call(name: str, arguments: str) -> dict[str, Any]: return {"type": "function_call", "name": name, "arguments": arguments} @@ -161,7 +161,7 @@ def test_install_governance_is_idempotent(): def test_install_governance_chains_existing_hooks(): agent = FakeAgent() user_hooks = RecordingHooks() - agent.hooks = user_hooks + agent.hooks = user_hooks # type: ignore[assignment] # test double, not a real AgentHooks install_governance(agent, FakeEvaluator(), agent_name="x", session_id="s") assert isinstance(agent.hooks, GovernanceAgentHooks) assert agent.hooks._inner is user_hooks @@ -385,11 +385,14 @@ def evaluate_before_model(self, **_: Any) -> None: async def test_hooks_return_none(): + # hooks are pass-through (return None) — they never short-circuit the run. + # (the inline type: ignores below silence mypy's func-returns-value on the + # None-returning hooks; the runtime assert documents the contract.) cb = _make_hooks(FakeEvaluator()) - assert await cb.on_llm_start(None, FakeAgent(), None, []) is None - assert await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) is None - assert await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) is None - assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None + assert await cb.on_llm_start(None, FakeAgent(), None, []) is None # type: ignore[func-returns-value] + assert await cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])) is None # type: ignore[func-returns-value] + assert await cb.on_tool_start(None, FakeAgent(), FakeTool("t")) is None # type: ignore[func-returns-value] + assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None # type: ignore[func-returns-value] # -------------------------------------------------------------------------- From 74ebf458282cd9cd337e0fa73d511ca62a75dc9e Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Wed, 1 Jul 2026 19:36:09 +0530 Subject: [PATCH 12/12] test(openai-agents): cover swallow/delegate/extraction branches (Sonar coverage) New-code coverage was ~89% (just under Sonar's 90% gate). Added tests for the uncovered defensive branches: non-block-error swallow on every hook, the pass-through boundary hooks (on_start/on_end/on_handoff) + _delegate error path, and the text-extraction edges (_stringify circular fallback, output-only item, object .text content, no-output response, single input). governance/hooks.py: 83% -> 94.5%. Co-Authored-By: Claude Opus 4.8 --- .../tests/governance/test_hooks.py | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/uipath-openai-agents/tests/governance/test_hooks.py b/packages/uipath-openai-agents/tests/governance/test_hooks.py index 359b8008..5be3d051 100644 --- a/packages/uipath-openai-agents/tests/governance/test_hooks.py +++ b/packages/uipath-openai-agents/tests/governance/test_hooks.py @@ -25,6 +25,11 @@ from uipath_openai_agents.governance.hooks import ( _BEFORE_MODEL_TEXT_CAP, GovernanceAgentHooks, + _content_text, + _item_text, + _latest_input_text, + _model_response_text, + _stringify, install_governance, ) @@ -395,6 +400,87 @@ async def test_hooks_return_none(): assert await cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {}) is None # type: ignore[func-returns-value] +# -------------------------------------------------------------------------- +# coverage: swallow paths on every hook, boundary delegation, extraction edges +# -------------------------------------------------------------------------- + + +class _Boom: + """Evaluator whose every evaluate_* raises a non-block error.""" + + def __getattr__(self, _name: str) -> Any: + def _raise(*_a: Any, **_k: Any) -> None: + raise RuntimeError("evaluator bug") + + return _raise + + +@pytest.mark.parametrize( + "invoke", + [ + lambda cb: cb.on_llm_end(None, FakeAgent(), SimpleNamespace(output=[])), + lambda cb: cb.on_tool_start(None, FakeAgent(), FakeTool("t")), + lambda cb: cb.on_tool_end(None, FakeAgent(), FakeTool("t"), {"r": 1}), + ], +) +async def test_model_and_tool_hooks_swallow_non_block_errors(invoke, caplog): + cb = GovernanceAgentHooks(evaluator=_Boom(), agent_name="a", session_id="s") # type: ignore[arg-type] + with _capture_hooks_logs(caplog): + await invoke(cb) # must NOT raise — a governance bug can't break the run + assert any("governance check failed" in r.message for r in caplog.records) + + +class _InnerBoundary: + def __init__(self) -> None: + self.seen: List[str] = [] + + async def on_start(self, *_a: Any) -> None: + self.seen.append("on_start") + + async def on_end(self, *_a: Any) -> None: + self.seen.append("on_end") + + async def on_handoff(self, *_a: Any) -> None: + self.seen.append("on_handoff") + + +async def test_boundary_hooks_delegate_to_inner(): + inner = _InnerBoundary() + cb = _make_hooks(FakeEvaluator(), inner=inner) + await cb.on_start(None, FakeAgent()) + await cb.on_end(None, FakeAgent(), "out") + await cb.on_handoff(None, FakeAgent(), FakeAgent()) + assert inner.seen == ["on_start", "on_end", "on_handoff"] + + +async def test_delegate_swallows_inner_hook_error(caplog): + class _BadInner: + async def on_llm_start(self, *_a: Any) -> None: + raise RuntimeError("inner boom") + + cb = _make_hooks(FakeEvaluator(), inner=_BadInner()) + with _capture_hooks_logs(caplog): + await cb.on_llm_start(None, FakeAgent(), None, [_msg("x")]) # must not raise + assert any("chained user hook" in r.message for r in caplog.records) + + +def test_extraction_edges(): + # _stringify: str passthrough; circular ref → str() fallback (not a crash) + assert _stringify("hi") == "hi" + circular: dict[str, Any] = {} + circular["self"] = circular + assert isinstance(_stringify(circular), str) + # _item_text: tool-result output-only item + assert "42" in _item_text({"output": {"balance": 42}}) + # _content_text: object exposing .text, and a bare-string part in a list + assert _content_text(SimpleNamespace(text="hello")) == "hello" + assert "raw" in _content_text(["raw", {"text": "block"}]) + # _model_response_text: response with no .output → falls back to item text + assert _model_response_text(SimpleNamespace(content="direct")) == "direct" + # _latest_input_text: single (non-list) item + assert _latest_input_text(_msg("solo")) == "solo" + + # -------------------------------------------------------------------------- # Factory wiring — the evaluator kwarg drives install_governance # --------------------------------------------------------------------------