diff --git a/packages/uipath-agent-framework/pyproject.toml b/packages/uipath-agent-framework/pyproject.toml index cecd9918..2d0eb510 100644 --- a/packages/uipath-agent-framework/pyproject.toml +++ b/packages/uipath-agent-framework/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "aiosqlite>=0.20.0", "openinference-instrumentation-agent-framework>=0.1.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 = [ @@ -32,6 +33,9 @@ register = "uipath_agent_framework.middlewares:register_middleware" [project.entry-points."uipath.runtime.factories"] agent-framework = "uipath_agent_framework.runtime:register_runtime_factory" +[project.entry-points."uipath.governance.adapters"] +agent-framework = "uipath_agent_framework.governance:register_governance_adapter" + [project.urls] Homepage = "https://uipath.com" Repository = "https://github.com/UiPath/uipath-integrations-python" diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/governance/__init__.py b/packages/uipath-agent-framework/src/uipath_agent_framework/governance/__init__.py new file mode 100644 index 00000000..89492faa --- /dev/null +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/governance/__init__.py @@ -0,0 +1,58 @@ +"""Governance integration for ``uipath-agent-framework``. + +Registers :class:`AgentFrameworkAdapter` with the adapter registry in +``uipath.core.adapters`` so the governance host can attach the +Agent-Framework-specific inner hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, +AFTER_TOOL) when it sees an ``agent_framework`` 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. +""" + +from __future__ import annotations + +import logging + +from uipath.core.adapters import get_adapter_registry + +from .adapter import ( + AgentFrameworkAdapter, + GovernanceCallbacks, + GovernanceChatMiddleware, + GovernanceFunctionMiddleware, +) + +logger = logging.getLogger(__name__) + +_registered: bool = False + + +def register_governance_adapter() -> None: + """Register :class:`AgentFrameworkAdapter` with the global registry. + + Idempotent — safe to call multiple times. + """ + global _registered + if _registered: + return + registry = get_adapter_registry() + if any(a.name == "AgentFramework" for a in registry.get_all()): + _registered = True + return + registry.register(AgentFrameworkAdapter()) + _registered = True + logger.debug("Registered uipath-agent-framework governance adapter") + + +__all__ = [ + "AgentFrameworkAdapter", + "GovernanceCallbacks", + "GovernanceChatMiddleware", + "GovernanceFunctionMiddleware", + "register_governance_adapter", +] \ No newline at end of file diff --git a/packages/uipath-agent-framework/src/uipath_agent_framework/governance/adapter.py b/packages/uipath-agent-framework/src/uipath_agent_framework/governance/adapter.py new file mode 100644 index 00000000..f4da2a45 --- /dev/null +++ b/packages/uipath-agent-framework/src/uipath_agent_framework/governance/adapter.py @@ -0,0 +1,357 @@ +"""Microsoft Agent Framework adapter for UiPath governance. + +Provides governance for ``agent_framework`` agents (``Agent`` and +``WorkflowAgent`` graphs). The framework runs agents through a middleware +pipeline that it rebuilds from ``agent.middleware`` on **every** ``run`` call +("Re-categorize self.middleware at runtime to support dynamic changes"). So, +like the Google ADK and OpenAI Agents adapters — and unlike the LangChain +adapter, which wraps the ``Runnable`` — this adapter installs governance by +appending middleware to each agent's ``middleware`` list in place: + +- :class:`GovernanceChatMiddleware` (a ``ChatMiddleware``) brackets the LLM + call → BEFORE_MODEL before ``call_next`` / AFTER_MODEL after it. +- :class:`GovernanceFunctionMiddleware` (a ``FunctionMiddleware``) brackets a + tool call → TOOL_CALL before ``call_next`` / AFTER_TOOL after it. + +Both subclass the framework's middleware base classes because the framework's +``categorize_middleware`` sorts middleware into chat/function/agent pipelines +by ``isinstance`` — a duck-typed object would be silently dropped. + +Because the mutation is in place, :meth:`AgentFrameworkAdapter.attach` returns +the **original agent**. For a ``WorkflowAgent`` the inner agents reachable via +``workflow.executors[*]._agent`` are governed too, so a multi-agent app is +covered end to end. + +Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are owned by the +governance host, so they are not fired here. The framework's +``AgentMiddleware`` slot is therefore left untouched. + +Contracts and the evaluator protocol come from ``uipath-core``; this package +contributes only the Agent-Framework-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. Each middleware only extracts the relevant +payload and calls the matching ``evaluate_*`` method; +:class:`GovernanceBlockException` is allowed to propagate (it aborts the run), +anything else is logged and swallowed so a governance bug never breaks a run. +""" + +from __future__ import annotations + +import json +import logging +from collections.abc import Mapping +from typing import Any, Awaitable, Callable, Dict, List +from uuid import uuid4 + +from agent_framework._middleware import ( + ChatContext, + ChatMiddleware, + FunctionInvocationContext, + FunctionMiddleware, +) +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 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 message, not the full +# prompt — see :meth:`GovernanceCallbacks._latest_message_text`. +_BEFORE_MODEL_TEXT_CAP = 64000 + + +class AgentFrameworkAdapter(BaseAdapter): + """Adapter for the Microsoft Agent Framework. + + Detects ``agent_framework`` agents and appends governance middleware to + every agent reachable through a ``WorkflowAgent``'s executors (or the + single agent itself). + """ + + @property + def name(self) -> str: + return "AgentFramework" + + def can_handle(self, agent: Any) -> bool: + """Return True only for an ``agent_framework`` ``BaseAgent``.""" + try: + from agent_framework import BaseAgent + except ImportError: + return False + return isinstance(agent, BaseAgent) + + def attach( + self, + agent: Any, + agent_id: str, + session_id: str, + evaluator: EvaluatorProtocol, + ) -> Any: + """Append governance middleware to the agent graph (mutated in place). + + Returns the original ``agent`` — the framework rebuilds the middleware + pipeline from ``agent.middleware`` on each ``run``, so the in-place + append is what wires governance into execution. + """ + callbacks = GovernanceCallbacks( + evaluator=evaluator, agent_name=agent_id, session_id=session_id + ) + targets = _iter_agents(agent) + installed = 0 + for node in targets: + existing = list(getattr(node, "middleware", None) or []) + if any(isinstance(m, _GOVERNANCE_MIDDLEWARE) for m in existing): + continue # idempotent — already governed + # Governance runs first so it can BLOCK before user middleware. + node.middleware = [ + GovernanceChatMiddleware(callbacks), + GovernanceFunctionMiddleware(callbacks), + *existing, + ] + installed += 1 + if not targets: + logger.warning( + "AgentFrameworkAdapter found no agent in %s — hooks will not fire", + type(agent).__name__, + ) + else: + logger.debug("Installed governance middleware on %d agent(s)", installed) + return agent + + def detach(self, governed: Any) -> Any: + """Strip governance middleware from each agent and return the graph.""" + for node in _iter_agents(governed): + existing = getattr(node, "middleware", None) + if not existing: + continue + kept = [m for m in existing if not isinstance(m, _GOVERNANCE_MIDDLEWARE)] + node.middleware = kept or None + return governed + + +def _iter_agents(root: Any) -> List[Any]: + """Return every agent node carrying a ``middleware`` slot. + + A plain ``Agent`` is itself the target. A ``WorkflowAgent`` exposes its + inner agents through ``workflow.executors[*]._agent`` (the same traversal + the breakpoint middleware uses), so a multi-agent app is governed end to + end. Cycles / pathological size are bounded by an id-visited set and a cap. + """ + found: List[Any] = [] + seen: set[int] = set() + + def _add(node: Any) -> None: + if node is None or id(node) in seen: + return + seen.add(id(node)) + if hasattr(node, "middleware"): + found.append(node) + + _add(root) + workflow = getattr(root, "workflow", None) + executors = getattr(workflow, "executors", None) + if isinstance(executors, Mapping): + for executor in list(executors.values()): + inner = getattr(executor, "_agent", None) + if inner is not None and len(seen) < 1000: + _add(inner) + return found + + +class GovernanceCallbacks: + """Holds the governance evaluator + per-attach state shared by the two + middleware classes. + + Each method extracts the relevant payload and calls the matching + ``evaluate_*`` method. :class:`GovernanceBlockException` is re-raised (it + aborts the run); anything else is logged and swallowed so a governance bug + never breaks an agent run. + """ + + def __init__( + self, + evaluator: EvaluatorProtocol, + agent_name: str, + session_id: str, + ) -> None: + self._evaluator = evaluator + self._agent_name = agent_name + self._session_id = session_id + self._trace_id = str(uuid4()) + self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0} + + # ----- Model -------------------------------------------------------- + + def before_model(self, messages: Any) -> None: + """Evaluate BEFORE_MODEL on the latest message only (see ADK rationale).""" + try: + self._session_state["llm_calls"] = ( + self._session_state.get("llm_calls", 0) + 1 + ) + self._evaluator.evaluate_before_model( + model_input=self._latest_message_text(messages), + 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("before_model governance check failed (continuing): %s", e) + + def after_model(self, result: Any) -> None: + """Evaluate AFTER_MODEL on the chat response text.""" + try: + self._evaluator.evaluate_after_model( + model_output=self._response_text(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("after_model governance check failed (continuing): %s", e) + + # ----- Tools -------------------------------------------------------- + + def before_tool(self, function: Any, arguments: Any) -> None: + """Evaluate TOOL_CALL with the tool name + arguments.""" + try: + self._session_state["tool_calls"] = ( + self._session_state.get("tool_calls", 0) + 1 + ) + self._evaluator.evaluate_tool_call( + tool_name=getattr(function, "name", None) or "unknown", + tool_args=_coerce_args(arguments), + 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("before_tool governance check failed (continuing): %s", e) + + def after_tool(self, function: Any, result: Any) -> None: + """Evaluate AFTER_TOOL with the tool result.""" + try: + self._evaluator.evaluate_after_tool( + tool_name=getattr(function, "name", None) or "unknown", + tool_result="" if result is None else _stringify(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("after_tool governance check failed (continuing): %s", e) + + # ----- Text extraction --------------------------------------------- + + @classmethod + def _latest_message_text(cls, messages: Any) -> str: + """Text of the most-recent message in a chat request.""" + if not messages: + return "" + if isinstance(messages, (list, tuple)): + return cls._message_text(messages[-1]) + return cls._message_text(messages) + + @classmethod + def _message_text(cls, message: Any) -> str: + """Pull text from a ``Message`` (``.text``) or a bare string.""" + if message is None: + return "" + if isinstance(message, str): + return message[:_BEFORE_MODEL_TEXT_CAP] + text = getattr(message, "text", None) + if isinstance(text, str): + return text[:_BEFORE_MODEL_TEXT_CAP] + return _stringify(message)[:_BEFORE_MODEL_TEXT_CAP] + + @classmethod + def _response_text(cls, result: Any) -> str: + """Pull text from a ``ChatResponse`` (``.text``) or fallbacks.""" + if result is None: + return "" + text = getattr(result, "text", None) + if isinstance(text, str) and text: + return text[:_BEFORE_MODEL_TEXT_CAP] + messages = getattr(result, "messages", None) + if isinstance(messages, (list, tuple)) and messages: + return cls._message_text(messages[-1]) + return _stringify(result)[:_BEFORE_MODEL_TEXT_CAP] + + +class GovernanceChatMiddleware(ChatMiddleware): + """Brackets each LLM call: BEFORE_MODEL, then ``call_next``, then AFTER_MODEL.""" + + def __init__(self, callbacks: GovernanceCallbacks) -> None: + self._cb = callbacks + + async def process( + self, context: ChatContext, call_next: Callable[[], Awaitable[None]] + ) -> None: + self._cb.before_model(getattr(context, "messages", None)) + await call_next() + self._cb.after_model(getattr(context, "result", None)) + + +class GovernanceFunctionMiddleware(FunctionMiddleware): + """Brackets each tool call: TOOL_CALL, then ``call_next``, then AFTER_TOOL.""" + + def __init__(self, callbacks: GovernanceCallbacks) -> None: + self._cb = callbacks + + async def process( + self, + context: FunctionInvocationContext, + call_next: Callable[[], Awaitable[None]], + ) -> None: + function = getattr(context, "function", None) + self._cb.before_tool(function, getattr(context, "arguments", None)) + await call_next() + self._cb.after_tool(function, getattr(context, "result", None)) + + +# Tuple used for isinstance idempotency / detach checks. +_GOVERNANCE_MIDDLEWARE = (GovernanceChatMiddleware, GovernanceFunctionMiddleware) + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _coerce_args(arguments: Any) -> Dict[str, Any]: + """Normalise tool arguments (Mapping / pydantic model / None) to a dict.""" + if arguments is None: + return {} + if isinstance(arguments, Mapping): + return dict(arguments) + model_dump = getattr(arguments, "model_dump", None) + if callable(model_dump): + try: + dumped = model_dump() + if isinstance(dumped, dict): + return dumped + except Exception: # noqa: BLE001 - fall through to empty + pass + return {} + + +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-agent-framework/tests/governance/__init__.py b/packages/uipath-agent-framework/tests/governance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/uipath-agent-framework/tests/governance/test_adapter.py b/packages/uipath-agent-framework/tests/governance/test_adapter.py new file mode 100644 index 00000000..1164fcda --- /dev/null +++ b/packages/uipath-agent-framework/tests/governance/test_adapter.py @@ -0,0 +1,300 @@ +"""Unit tests for the Microsoft Agent Framework governance adapter. + +The middleware classes subclass ``agent_framework`` base classes (the +framework routes middleware by ``isinstance``), so importing the adapter +requires ``agent-framework-core`` — but the messages / responses / tools / +contexts under test are lightweight duck-typed fakes. + +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_agent_framework.governance.adapter import ( + _BEFORE_MODEL_TEXT_CAP, + AgentFrameworkAdapter, + GovernanceCallbacks, + GovernanceChatMiddleware, + GovernanceFunctionMiddleware, +) + +# -------------------------------------------------------------------------- +# 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 an ``agent_framework`` Agent (duck-typed).""" + + def __init__(self, name: str = "agent"): + self.name = name + self.middleware: Any = None + + async def run(self, *_a: Any, **_k: Any) -> None: # marks it as an agent + return None + + +class FakeWorkflowAgent: + """Stand-in for ``WorkflowAgent`` exposing inner agents via executors.""" + + def __init__(self, inner_agents: List[Any]): + self.middleware: Any = None + executors = { + f"e{i}": SimpleNamespace(_agent=a) for i, a in enumerate(inner_agents) + } + self.workflow = SimpleNamespace(executors=executors) + + +class FakeTool: + def __init__(self, name: str): + self.name = name + + +def _msg(text: str) -> SimpleNamespace: + return SimpleNamespace(text=text) + + +def _make_callbacks(ev: FakeEvaluator) -> GovernanceCallbacks: + return GovernanceCallbacks(evaluator=ev, agent_name="agent-1", session_id="sess-1") + + +async def _noop_next() -> None: + return None + + +# -------------------------------------------------------------------------- +# can_handle +# -------------------------------------------------------------------------- + + +def test_can_handle_real_agent(): + from agent_framework import BaseAgent + + assert AgentFrameworkAdapter().can_handle(BaseAgent(name="t")) is True + + +def test_can_handle_rejects_non_agent(): + # Duck-typed look-alikes (middleware + run/workflow) must NOT be claimed — + # only a real agent_framework BaseAgent is. + assert AgentFrameworkAdapter().can_handle(FakeAgent()) is False + assert AgentFrameworkAdapter().can_handle(FakeWorkflowAgent([])) is False + assert AgentFrameworkAdapter().can_handle(object()) is False + + +# -------------------------------------------------------------------------- +# attach / detach +# -------------------------------------------------------------------------- + + +def test_attach_appends_both_middleware(): + agent = FakeAgent() + returned = AgentFrameworkAdapter().attach( + agent, agent_id="x", session_id="s", evaluator=FakeEvaluator() + ) + assert returned is agent + kinds = [type(m) for m in agent.middleware] + assert GovernanceChatMiddleware in kinds + assert GovernanceFunctionMiddleware in kinds + + +def test_attach_installs_on_workflow_inner_agents(): + a, b = FakeAgent("a"), FakeAgent("b") + root = FakeWorkflowAgent([a, b]) + AgentFrameworkAdapter().attach(root, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + for node in (a, b): + assert any(isinstance(m, GovernanceChatMiddleware) for m in node.middleware) + + +def test_attach_is_idempotent(): + agent = FakeAgent() + adapter = AgentFrameworkAdapter() + ev = FakeEvaluator() + adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + adapter.attach(agent, agent_id="x", session_id="s", evaluator=ev) + assert sum(isinstance(m, GovernanceChatMiddleware) for m in agent.middleware) == 1 + + +def test_attach_preserves_existing_middleware_and_runs_governance_first(): + user_mw = object() + agent = FakeAgent() + agent.middleware = [user_mw] + AgentFrameworkAdapter().attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + # governance prepended → runs first; user middleware preserved at the end + assert isinstance(agent.middleware[0], GovernanceChatMiddleware) + assert agent.middleware[-1] is user_mw + + +def test_detach_removes_governance_middleware(): + user_mw = object() + agent = FakeAgent() + agent.middleware = [user_mw] + adapter = AgentFrameworkAdapter() + adapter.attach(agent, agent_id="x", session_id="s", evaluator=FakeEvaluator()) + adapter.detach(agent) + assert agent.middleware == [user_mw] + + +def test_attach_warns_when_no_agent(caplog): + with caplog.at_level(logging.WARNING): + AgentFrameworkAdapter().attach( + object(), agent_id="x", session_id="s", evaluator=FakeEvaluator() + ) + assert any("no agent" in r.message for r in caplog.records) + + +# -------------------------------------------------------------------------- +# ChatMiddleware → BEFORE_MODEL / AFTER_MODEL +# -------------------------------------------------------------------------- + + +async def test_chat_middleware_brackets_call_with_before_and_after(): + ev = FakeEvaluator() + mw = GovernanceChatMiddleware(_make_callbacks(ev)) + order: List[str] = [] + + async def call_next() -> None: + order.append("model_call") + + context = SimpleNamespace( + messages=[_msg("old"), _msg("the question")], + result=SimpleNamespace(text="the answer"), + ) + await mw.process(context, call_next) + + hooks = [h for h, _ in ev.calls] + assert hooks == ["before_model", "after_model"] + assert order == ["model_call"] + assert ev.calls[0][1]["model_input"] == "the question" # latest only + assert ev.calls[1][1]["model_output"] == "the answer" + + +async def test_chat_middleware_caps_text(): + ev = FakeEvaluator() + mw = GovernanceChatMiddleware(_make_callbacks(ev)) + huge = "x" * (_BEFORE_MODEL_TEXT_CAP + 5000) + context = SimpleNamespace(messages=[_msg(huge)], result=SimpleNamespace(text="")) + await mw.process(context, _noop_next) + assert len(ev.calls[0][1]["model_input"]) <= _BEFORE_MODEL_TEXT_CAP + + +# -------------------------------------------------------------------------- +# FunctionMiddleware → TOOL_CALL / AFTER_TOOL +# -------------------------------------------------------------------------- + + +async def test_function_middleware_passes_name_args_and_result(): + ev = FakeEvaluator() + mw = GovernanceFunctionMiddleware(_make_callbacks(ev)) + order: List[str] = [] + + async def call_next() -> None: + order.append("tool_call") + + context = SimpleNamespace( + function=FakeTool("transfer"), + arguments={"amount": 50}, + result={"status": "ok"}, + ) + await mw.process(context, call_next) + + hooks = [h for h, _ in ev.calls] + assert hooks == ["tool_call", "after_tool"] + assert order == ["tool_call"] + assert ev.calls[0][1]["tool_name"] == "transfer" + assert ev.calls[0][1]["tool_args"] == {"amount": 50} + assert ev.calls[0][1]["session_state"]["tool_calls"] == 1 + assert "ok" in ev.calls[1][1]["tool_result"] + + +async def test_function_middleware_coerces_pydantic_args(): + ev = FakeEvaluator() + mw = GovernanceFunctionMiddleware(_make_callbacks(ev)) + args = SimpleNamespace(model_dump=lambda: {"x": 1}) + context = SimpleNamespace(function=FakeTool("t"), arguments=args, result=None) + await mw.process(context, _noop_next) + assert ev.calls[0][1]["tool_args"] == {"x": 1} + assert ev.calls[1][1]["tool_result"] == "" # None result → "" + + +# -------------------------------------------------------------------------- +# enforcement semantics +# -------------------------------------------------------------------------- + + +async def test_block_in_before_model_aborts_before_call_next(): + ev = FakeEvaluator(block_on="before_model") + mw = GovernanceChatMiddleware(_make_callbacks(ev)) + called = {"next": False} + + async def call_next() -> None: + called["next"] = True + + context = SimpleNamespace(messages=[_msg("hi")], result=None) + with pytest.raises(GovernanceBlockException): + await mw.process(context, call_next) + assert called["next"] is False # tool/model never ran + + +async def test_block_in_before_tool_aborts_before_call_next(): + ev = FakeEvaluator(block_on="tool_call") + mw = GovernanceFunctionMiddleware(_make_callbacks(ev)) + called = {"next": False} + + async def call_next() -> None: + called["next"] = True + + context = SimpleNamespace(function=FakeTool("t"), arguments={}, result=None) + with pytest.raises(GovernanceBlockException): + await mw.process(context, call_next) + assert called["next"] is False + + +async def test_non_block_exception_is_swallowed(caplog): + class Boom: + def evaluate_before_model(self, **_: Any) -> None: + raise RuntimeError("evaluator bug") + + cb = GovernanceCallbacks(evaluator=Boom(), agent_name="a", session_id="s") # type: ignore[arg-type] + mw = GovernanceChatMiddleware(cb) + with caplog.at_level(logging.WARNING): + await mw.process(SimpleNamespace(messages=[_msg("x")], result=None), _noop_next) + assert any("governance check failed" in r.message for r in caplog.records) \ No newline at end of file diff --git a/packages/uipath-agent-framework/uv.lock b/packages/uipath-agent-framework/uv.lock index e551beac..f35c9a06 100644 --- a/packages/uipath-agent-framework/uv.lock +++ b/packages/uipath-agent-framework/uv.lock @@ -2486,6 +2486,7 @@ dependencies = [ { name = "aiosqlite" }, { name = "openinference-instrumentation-agent-framework" }, { name = "uipath" }, + { name = "uipath-core" }, { name = "uipath-runtime" }, ] @@ -2515,6 +2516,7 @@ requires-dist = [ { name = "anthropic", marker = "extra == 'anthropic'", specifier = ">=0.43.0" }, { name = "openinference-instrumentation-agent-framework", specifier = ">=0.1.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" }, ] provides-extras = ["anthropic"]