Skip to content

Commit d0ff1f1

Browse files
aditik0303claude
andcommitted
refactor(governance): migrate Pydantic AI adapter to factory-evaluator
Core PR #1761 removed BaseAdapter from uipath-core. Migrate to the factory-evaluator pattern (matching #899): - governance/adapter.py -> model.py: replace the BaseAdapter subclass (name/can_handle/attach/detach) with module-level install_governance(); keep GovernanceModel + GovernanceCallbacks. File named for its seam (the model wrapper), like LangChain's callbacks.py. - runtime/factory.py: new_runtime reads `evaluator` from kwargs and calls install_governance on the resolved agent. - governance/__init__.py: drop register_governance_adapter + registry import; expose install_governance. No import-time side effects. - pyproject.toml: remove the uipath.governance.adapters entry point. - tests (test_adapter.py -> test_model.py): drop can_handle/attach/ detach; cover install_governance + factory wiring. ruff + mypy clean; 17 governance tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 09dcec6 commit d0ff1f1

5 files changed

Lines changed: 111 additions & 143 deletions

File tree

packages/uipath-pydantic-ai/pyproject.toml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ register = "uipath_pydantic_ai.middlewares:register_middleware"
2828
[project.entry-points."uipath.runtime.factories"]
2929
pydantic-ai = "uipath_pydantic_ai.runtime:register_runtime_factory"
3030

31-
[project.entry-points."uipath.governance.adapters"]
32-
pydantic-ai = "uipath_pydantic_ai.governance:register_governance_adapter"
33-
3431
[project.urls]
3532
Homepage = "https://uipath.com"
3633
Repository = "https://github.com/UiPath/uipath-integrations-python"
Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,21 @@
11
"""Governance integration for ``uipath-pydantic-ai``.
22
3-
Registers :class:`PydanticAIAdapter` with the adapter registry in
4-
``uipath.core.adapters`` so the governance host can attach the
5-
Pydantic-AI-specific governance (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL,
6-
AFTER_TOOL) when it sees a ``pydantic_ai.Agent``.
7-
8-
Registration is **idempotent**: calling :func:`register_governance_adapter`
9-
twice is a no-op on the second call.
10-
11-
Wiring: the package exposes :func:`register_governance_adapter` as an entry
12-
point under ``uipath.governance.adapters``. The governance adapter discovery
13-
path calls it to register the adapter. Importing this module does not, by
14-
itself, mutate the global registry.
3+
Exposes :func:`install_governance` — wraps a ``pydantic_ai.Agent``'s ``model``
4+
with a :class:`GovernanceModel` that brackets every model call with governance
5+
(BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, AFTER_TOOL). Wired into a run by passing
6+
an ``evaluator`` to :class:`UiPathPydanticAIRuntimeFactory`; the factory calls
7+
:func:`install_governance` on the resolved agent.
8+
9+
Importing this module has no side effects: no adapter is registered, no global
10+
state is mutated.
1511
"""
1612

1713
from __future__ import annotations
1814

19-
import logging
20-
21-
from uipath.core.adapters import get_adapter_registry
22-
23-
from .adapter import GovernanceCallbacks, GovernanceModel, PydanticAIAdapter
24-
25-
logger = logging.getLogger(__name__)
26-
27-
_registered: bool = False
28-
29-
30-
def register_governance_adapter() -> None:
31-
"""Register :class:`PydanticAIAdapter` with the global registry.
32-
33-
Idempotent — safe to call multiple times.
34-
"""
35-
global _registered
36-
if _registered:
37-
return
38-
registry = get_adapter_registry()
39-
if any(a.name == "PydanticAI" for a in registry.get_all()):
40-
_registered = True
41-
return
42-
registry.register(PydanticAIAdapter())
43-
_registered = True
44-
logger.debug("Registered uipath-pydantic-ai governance adapter")
45-
15+
from .model import GovernanceCallbacks, GovernanceModel, install_governance
4616

4717
__all__ = [
4818
"GovernanceCallbacks",
4919
"GovernanceModel",
50-
"PydanticAIAdapter",
51-
"register_governance_adapter",
20+
"install_governance",
5221
]

packages/uipath-pydantic-ai/src/uipath_pydantic_ai/governance/adapter.py renamed to packages/uipath-pydantic-ai/src/uipath_pydantic_ai/governance/model.py

Lines changed: 37 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Pydantic AI adapter for UiPath governance.
1+
"""Pydantic AI governance model wrapper for UiPath.
22
33
Pydantic AI has the thinnest hook surface of the supported frameworks — there
44
is no per-agent callback or middleware system. But *everything* an agent does
@@ -17,15 +17,17 @@
1717
Both the non-streaming ``request`` and the streaming ``request_stream`` paths
1818
are covered (the runtime uses ``agent.run`` and ``agent.iter`` respectively).
1919
20-
Because the wrap is installed on ``agent.model`` in place, :meth:`attach`
21-
returns the **original agent**; :meth:`detach` restores the original model.
20+
Because the wrap is installed on ``agent.model`` in place,
21+
:func:`install_governance` returns the **original agent**.
2222
2323
Chain-level boundaries (BEFORE_AGENT / AFTER_AGENT) are owned by the
2424
governance host and are intentionally not fired here.
2525
26-
Contracts and the evaluator protocol come from ``uipath-core``; this package
27-
contributes only the Pydantic-AI-specific implementation and registers it with
28-
the adapter registry via the ``uipath.governance.adapters`` entry point.
26+
The evaluator protocol comes from ``uipath-core``; this package contributes
27+
only the Pydantic-AI-specific wiring. Governance is installed by the runtime
28+
factory: passing an ``evaluator`` to ``new_runtime`` calls
29+
:func:`install_governance` on the resolved agent. No adapter registry, no
30+
entry point, no import-time side effects.
2931
3032
Audit emission and enforcement (raising :class:`GovernanceBlockException` on
3133
DENY) are owned by the evaluator. The wrapper only extracts payloads and calls
@@ -41,6 +43,7 @@
4143
from typing import Any, AsyncIterator, Dict, List
4244
from uuid import uuid4
4345

46+
from pydantic_ai import Agent
4447
from pydantic_ai.messages import (
4548
BuiltinToolCallPart,
4649
TextPart,
@@ -51,7 +54,7 @@
5154
from pydantic_ai.models import Model, ModelRequestParameters, StreamedResponse
5255
from pydantic_ai.models.wrapper import WrapperModel
5356
from pydantic_ai.settings import ModelSettings
54-
from uipath.core.adapters import BaseAdapter, EvaluatorProtocol
57+
from uipath.core.adapters import EvaluatorProtocol
5558
from uipath.core.governance.exceptions import GovernanceBlockException
5659

5760
logger = logging.getLogger(__name__)
@@ -60,69 +63,39 @@
6063
# evaluation. Sized to match the runtime side and the other adapters.
6164
_BEFORE_MODEL_TEXT_CAP = 64000
6265

63-
# Attribute used to stash the original (unwrapped) model so detach can restore it.
64-
_ORIGINAL_MODEL_ATTR = "_uipath_governance_original_model"
6566

67+
def install_governance(
68+
agent: Agent,
69+
evaluator: EvaluatorProtocol,
70+
*,
71+
agent_name: str,
72+
session_id: str,
73+
) -> Agent:
74+
"""Wrap ``agent.model`` with a :class:`GovernanceModel` (mutated in place).
6675
67-
class PydanticAIAdapter(BaseAdapter):
68-
"""Adapter for the Pydantic AI framework.
76+
Returns the original ``agent``. Idempotent: an already-wrapped model is
77+
left untouched. If the agent has no concrete ``Model`` bound (the model is
78+
supplied per-run), there is nothing to wrap and a warning is logged.
6979
70-
Detects ``pydantic_ai.Agent`` instances and wraps their ``model`` with a
71-
:class:`GovernanceModel`.
80+
Called by :class:`UiPathPydanticAIRuntimeFactory` when an ``evaluator``
81+
is supplied to ``new_runtime``.
7282
"""
73-
74-
@property
75-
def name(self) -> str:
76-
return "PydanticAI"
77-
78-
def can_handle(self, agent: Any) -> bool:
79-
"""Return True only for a ``pydantic_ai.Agent``."""
80-
try:
81-
from pydantic_ai import Agent
82-
except ImportError:
83-
return False
84-
return isinstance(agent, Agent)
85-
86-
def attach(
87-
self,
88-
agent: Any,
89-
agent_id: str,
90-
session_id: str,
91-
evaluator: EvaluatorProtocol,
92-
) -> Any:
93-
"""Wrap ``agent.model`` with governance (mutated in place).
94-
95-
Returns the original ``agent``. If the agent has no concrete ``Model``
96-
bound (the model is supplied per-run), there is nothing to wrap and a
97-
warning is logged.
98-
"""
99-
model = getattr(agent, "model", None)
100-
if isinstance(model, GovernanceModel):
101-
return agent # idempotent — already governed
102-
if not isinstance(model, Model):
103-
logger.warning(
104-
"PydanticAIAdapter: agent has no bound Model to wrap (got %s); "
105-
"model-layer governance will not fire",
106-
type(model).__name__,
107-
)
108-
return agent
109-
callbacks = GovernanceCallbacks(
110-
evaluator=evaluator, agent_name=agent_id, session_id=session_id
83+
model = getattr(agent, "model", None)
84+
if isinstance(model, GovernanceModel):
85+
return agent # idempotent — already governed
86+
if not isinstance(model, Model):
87+
logger.warning(
88+
"install_governance: agent has no bound Model to wrap (got %s); "
89+
"model-layer governance will not fire",
90+
type(model).__name__,
11191
)
112-
setattr(agent, _ORIGINAL_MODEL_ATTR, model)
113-
agent.model = GovernanceModel(model, callbacks)
114-
logger.debug("Wrapped Pydantic AI agent model with governance")
11592
return agent
116-
117-
def detach(self, governed: Any) -> Any:
118-
"""Restore the agent's original (unwrapped) model and return it."""
119-
if isinstance(getattr(governed, "model", None), GovernanceModel):
120-
original = getattr(governed, _ORIGINAL_MODEL_ATTR, None)
121-
if original is not None:
122-
governed.model = original
123-
if hasattr(governed, _ORIGINAL_MODEL_ATTR):
124-
delattr(governed, _ORIGINAL_MODEL_ATTR)
125-
return governed
93+
callbacks = GovernanceCallbacks(
94+
evaluator=evaluator, agent_name=agent_name, session_id=session_id
95+
)
96+
agent.model = GovernanceModel(model, callbacks)
97+
logger.debug("Wrapped Pydantic AI agent model with governance")
98+
return agent
12699

127100

128101
class GovernanceModel(WrapperModel):

packages/uipath-pydantic-ai/src/uipath_pydantic_ai/runtime/factory.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any
55

66
from pydantic_ai import Agent
7+
from uipath.core.adapters import EvaluatorProtocol
78
from uipath.runtime import (
89
UiPathRuntimeContext,
910
UiPathRuntimeFactorySettings,
@@ -12,6 +13,7 @@
1213
)
1314
from uipath.runtime.errors import UiPathErrorCategory
1415

16+
from uipath_pydantic_ai.governance import install_governance
1517
from uipath_pydantic_ai.runtime.config import PydanticAiConfig
1618
from uipath_pydantic_ai.runtime.errors import (
1719
UiPathPydanticAIErrorCode,
@@ -215,6 +217,7 @@ async def _create_runtime_instance(
215217
agent: Agent,
216218
runtime_id: str,
217219
entrypoint: str,
220+
evaluator: EvaluatorProtocol | None = None,
218221
) -> UiPathRuntimeProtocol:
219222
"""
220223
Create a runtime instance from an agent.
@@ -223,10 +226,20 @@ async def _create_runtime_instance(
223226
agent: The PydanticAI Agent
224227
runtime_id: Unique identifier for the runtime instance
225228
entrypoint: Agent entrypoint name
229+
evaluator: When supplied, governance is installed on the agent's
230+
model in place via :func:`install_governance`.
226231
227232
Returns:
228233
Configured runtime instance
229234
"""
235+
if evaluator is not None:
236+
install_governance(
237+
agent,
238+
evaluator,
239+
agent_name=entrypoint,
240+
session_id=runtime_id,
241+
)
242+
230243
return UiPathPydanticAIRuntime(
231244
agent=agent,
232245
runtime_id=runtime_id,
@@ -242,7 +255,9 @@ async def new_runtime(
242255
Args:
243256
entrypoint: Agent name from pydantic_ai.json
244257
runtime_id: Unique identifier for the runtime instance
245-
**kwargs: Additional keyword arguments (unused)
258+
**kwargs: Forwarded factory kwargs. Recognized: ``evaluator``
259+
(``EvaluatorProtocol``) — when present, governance is installed
260+
on the agent's model via :func:`install_governance`.
246261
247262
Returns:
248263
Configured runtime instance with agent
@@ -252,6 +267,7 @@ async def new_runtime(
252267
return await self._create_runtime_instance(
253268
agent=agent,
254269
runtime_id=runtime_id,
270+
evaluator=kwargs.get("evaluator"),
255271
entrypoint=entrypoint,
256272
)
257273

0 commit comments

Comments
 (0)