1515The dispatcher is process-global, so registration is process-wide — which fits
1616the coded-agent model (one workflow per process). :func:`install_governance`
1717therefore 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
2031LlamaIndex does **not** emit a tool-*end* instrumentation event, so AFTER_TOOL
2132is not wired here; a tool's result is governed at the next ``LLMChatStartEvent``
4253import json
4354import logging
4455from typing import Any , Dict , List
45- from uuid import uuid4
4656
4757from 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+
96132class 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+ ]
0 commit comments