Skip to content

Commit c6f7d17

Browse files
aditik0303claude
andcommitted
fix(llamaindex): address governance review — rebind, detach, trace_id
Review findings (Viswa) for PR #360: - Re-install now rebinds the single process-global handler to the new run's evaluator / session instead of silently no-oping. The dispatcher is process-global, so a reused process serving a new runtime previously kept governing under the *first* run's evaluator; last install now wins. - Added uninstall_governance() (public detach) and wired it into the factory's dispose, so the global dispatcher does not retain the evaluator (and its resources) after the runtime is gone. Tests use it instead of mutating dispatcher.event_handlers directly. - Documented the process-global model explicitly: single active governance handler per process; two *concurrently* executing runtimes in one process would share the latest-installed evaluator — a property of LlamaIndex's global instrumentation, matching the one-workflow-per-process runtime model. - Documented that handle() is a deliberately synchronous gate: a BEFORE_MODEL / TOOL_CALL decision must complete (and be able to BLOCK) before the underlying call proceeds; an async out-of-band check could not gate it. - Drop the per-callbacks uuid trace_id (identical for every call); trace correlation is owned by the layer below, matching LangChain. Requires uipath-core >= 0.5.20 (removed trace_id from EvaluatorProtocol) — bumped. - Count llm/tool calls only after governance passes (no inflation on block). Tests: reinstall-rebinds-single-handler, uninstall-removes-handler, and made the swallow-on-error caplog assertion propagation-independent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9f4a4f1 commit c6f7d17

5 files changed

Lines changed: 187 additions & 45 deletions

File tree

packages/uipath-llamaindex/src/uipath_llamaindex/governance/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,14 @@
1212

1313
from __future__ import annotations
1414

15-
from .event_handler import GovernanceEventHandler, install_governance
15+
from .event_handler import (
16+
GovernanceEventHandler,
17+
install_governance,
18+
uninstall_governance,
19+
)
1620

1721
__all__ = [
1822
"GovernanceEventHandler",
1923
"install_governance",
20-
]
24+
"uninstall_governance",
25+
]

packages/uipath-llamaindex/src/uipath_llamaindex/governance/event_handler.py

Lines changed: 91 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,18 @@
1515
The dispatcher is process-global, so registration is process-wide — which fits
1616
the coded-agent model (one workflow per process). :func:`install_governance`
1717
therefore returns the ``agent`` unchanged (nothing is mutated on it); the wiring
18-
lives on the dispatcher.
18+
lives on the dispatcher. A second install (a reused process serving a new
19+
runtime) **rebinds** that one handler to the new run's evaluator / session
20+
rather than silently ignoring it — the most-recent install governs.
21+
:func:`uninstall_governance` removes the handler so the global dispatcher does
22+
not retain the evaluator after the runtime is gone; the factory calls it on
23+
dispose.
24+
25+
Because the dispatcher is process-global and LlamaIndex events do not carry a
26+
stable per-run identity, this adapter does not isolate two *concurrently*
27+
executing runtimes in the same process — they would share the latest-installed
28+
evaluator. That is a property of LlamaIndex's global instrumentation and matches
29+
the one-workflow-per-process runtime model.
1930
2031
LlamaIndex does **not** emit a tool-*end* instrumentation event, so AFTER_TOOL
2132
is not wired here; a tool's result is governed at the next ``LLMChatStartEvent``
@@ -42,7 +53,6 @@
4253
import json
4354
import logging
4455
from typing import Any, Dict, List
45-
from uuid import uuid4
4656

4757
from llama_index.core.instrumentation import ( # type: ignore[attr-defined]
4858
get_dispatcher,
@@ -76,15 +86,22 @@ def install_governance(
7686
"""Register the governance event handler on the root dispatcher.
7787
7888
Returns the ``agent`` unchanged — LlamaIndex governance is wired on the
79-
process-global instrumentation dispatcher, not on the agent object.
80-
Idempotent: a second call is a no-op while a handler is already registered.
89+
process-global instrumentation dispatcher, not on the agent object. If a
90+
governance handler is already registered (a reused process serving a new
91+
runtime), it is **rebound** to this run's evaluator / session instead of
92+
being left pointing at the previous run.
8193
8294
Called by :class:`UiPathLlamaIndexRuntimeFactory` when an ``evaluator``
8395
is supplied to ``new_runtime``.
8496
"""
8597
dispatcher = get_dispatcher()
86-
if any(isinstance(h, GovernanceEventHandler) for h in dispatcher.event_handlers):
87-
return agent # idempotent — already governed
98+
for handler in dispatcher.event_handlers:
99+
if isinstance(handler, GovernanceEventHandler):
100+
handler.rebind(
101+
evaluator=evaluator, agent_name=agent_name, session_id=session_id
102+
)
103+
logger.debug("Rebound existing governance handler to the new runtime")
104+
return agent
88105
callbacks = GovernanceCallbacks(
89106
evaluator=evaluator, agent_name=agent_name, session_id=session_id
90107
)
@@ -93,6 +110,25 @@ def install_governance(
93110
return agent
94111

95112

113+
def uninstall_governance(agent: Any = None) -> Any:
114+
"""Remove the governance handler(s) from the root dispatcher.
115+
116+
The instrumentation dispatcher is process-global, so a registered handler
117+
(and the evaluator it holds) would otherwise outlive the runtime. The
118+
factory calls this on ``dispose`` to release it. Returns ``agent`` unchanged.
119+
Safe to call when nothing is registered.
120+
"""
121+
dispatcher = get_dispatcher()
122+
handlers = dispatcher.event_handlers
123+
remaining = [h for h in handlers if not isinstance(h, GovernanceEventHandler)]
124+
if len(remaining) != len(handlers):
125+
# event_handlers is a plain list; mutate in place to avoid a pydantic
126+
# attribute re-assignment on the Dispatcher model.
127+
handlers[:] = remaining
128+
logger.debug("Removed governance event handler from LlamaIndex dispatcher")
129+
return agent
130+
131+
96132
class GovernanceEventHandler(BaseEventHandler):
97133
"""Routes LlamaIndex instrumentation events to a governance evaluator.
98134
@@ -112,7 +148,23 @@ def __init__(self, callbacks: "GovernanceCallbacks", **data: Any) -> None:
112148
def class_name(cls) -> str:
113149
return "GovernanceEventHandler"
114150

151+
def rebind(
152+
self,
153+
evaluator: EvaluatorProtocol,
154+
agent_name: str,
155+
session_id: str,
156+
) -> None:
157+
"""Re-point the single process-global handler at a new runtime."""
158+
self._callbacks.rebind(
159+
evaluator=evaluator, agent_name=agent_name, session_id=session_id
160+
)
161+
115162
def handle(self, event: Any, **kwargs: Any) -> Any:
163+
# The dispatcher calls ``handle`` synchronously and inline with the
164+
# instrumented call. That is deliberate: a BEFORE_MODEL / TOOL_CALL
165+
# governance decision must complete (and be able to BLOCK) *before* the
166+
# underlying LLM / tool call proceeds — an async, out-of-band check
167+
# could not gate it. The evaluator is expected to be fast.
116168
if isinstance(event, LLMChatStartEvent):
117169
self._callbacks.before_model(event.messages)
118170
elif isinstance(event, LLMChatEndEvent):
@@ -139,20 +191,41 @@ def __init__(
139191
self._evaluator = evaluator
140192
self._agent_name = agent_name
141193
self._session_id = session_id
142-
self._trace_id = str(uuid4())
194+
# ``trace_id`` is intentionally NOT held here. A single uuid minted at
195+
# install time would be identical for every call. Trace correlation is
196+
# owned by the layer below (OTel span / HTTP resolve at call time),
197+
# matching the LangChain adapter.
143198
self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0}
144199

200+
def rebind(
201+
self,
202+
evaluator: EvaluatorProtocol,
203+
agent_name: str,
204+
session_id: str,
205+
) -> None:
206+
"""Re-point this callback set at a new run.
207+
208+
Called when the process-global handler is reused for a fresh runtime —
209+
updates the evaluator and identifiers and resets the per-run counters so
210+
state does not bleed across runtimes.
211+
"""
212+
self._evaluator = evaluator
213+
self._agent_name = agent_name
214+
self._session_id = session_id
215+
self._session_state = {"tool_calls": 0, "llm_calls": 0}
216+
145217
def before_model(self, messages: Any) -> None:
146218
"""Evaluate BEFORE_MODEL on the latest input message (see ADK rationale)."""
147219
try:
148-
self._session_state["llm_calls"] = (
149-
self._session_state.get("llm_calls", 0) + 1
150-
)
151220
self._evaluator.evaluate_before_model(
152221
model_input=_latest_message_text(messages),
153222
agent_name=self._agent_name,
154223
runtime_id=self._session_id,
155-
trace_id=self._trace_id,
224+
)
225+
# Count only calls that passed governance — a DENY raises above, so
226+
# a blocked call must not inflate the counter.
227+
self._session_state["llm_calls"] = (
228+
self._session_state.get("llm_calls", 0) + 1
156229
)
157230
except GovernanceBlockException:
158231
raise
@@ -166,7 +239,6 @@ def after_model(self, response: Any) -> None:
166239
model_output=_response_text(response),
167240
agent_name=self._agent_name,
168241
runtime_id=self._session_id,
169-
trace_id=self._trace_id,
170242
)
171243
except GovernanceBlockException:
172244
raise
@@ -176,17 +248,18 @@ def after_model(self, response: Any) -> None:
176248
def tool_call(self, tool: Any, arguments: Any) -> None:
177249
"""Evaluate TOOL_CALL with the tool name + arguments."""
178250
try:
179-
self._session_state["tool_calls"] = (
180-
self._session_state.get("tool_calls", 0) + 1
181-
)
182251
self._evaluator.evaluate_tool_call(
183252
tool_name=getattr(tool, "name", None) or "unknown",
184253
tool_args=_coerce_args(arguments),
185254
agent_name=self._agent_name,
186255
runtime_id=self._session_id,
187-
trace_id=self._trace_id,
188256
session_state=self._session_state,
189257
)
258+
# Count only calls that passed governance; the evaluator saw the
259+
# count of prior tool calls, and a DENY raises before this bump.
260+
self._session_state["tool_calls"] = (
261+
self._session_state.get("tool_calls", 0) + 1
262+
)
190263
except GovernanceBlockException:
191264
raise
192265
except Exception as e: # noqa: BLE001
@@ -256,4 +329,5 @@ def _coerce_args(arguments: Any) -> Dict[str, Any]:
256329
"GovernanceCallbacks",
257330
"GovernanceEventHandler",
258331
"install_governance",
259-
]
332+
"uninstall_governance",
333+
]

packages/uipath-llamaindex/src/uipath_llamaindex/runtime/factory.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from uipath.runtime.errors import UiPathErrorCategory
2121
from workflows import Workflow
2222

23-
from uipath_llamaindex.governance import install_governance
23+
from uipath_llamaindex.governance import install_governance, uninstall_governance
2424
from uipath_llamaindex.runtime._telemetry import (
2525
ToolCallAttributeNormalizer,
2626
)
@@ -304,6 +304,11 @@ async def new_runtime(
304304

305305
async def dispose(self) -> None:
306306
"""Cleanup factory resources."""
307+
# The governance handler lives on the process-global instrumentation
308+
# dispatcher; remove it so the evaluator (and its resources) are not
309+
# retained after the runtime is gone.
310+
uninstall_governance()
311+
307312
for loader in self._workflow_loaders.values():
308313
await loader.cleanup()
309314

0 commit comments

Comments
 (0)