From 531dceeafd44f2fc91b2d25298ac5821eb1d5016 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Mon, 22 Jun 2026 23:09:00 +0530 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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