Skip to content

Commit 387e982

Browse files
aditik0303claude
andcommitted
fix(agent-framework): address governance review — streaming, try/finally
Review findings (Viswa) for PR #361: - AFTER_MODEL / AFTER_TOOL now run inside a try/finally around call_next, so they still fire (audit + rules observe the turn) when the model or tool call raises. GovernanceBlockException from before_* still aborts before call_next; the underlying error still propagates after the finally. - Streaming AFTER_MODEL: on a streaming ChatContext, context.result is a ResponseStream after call_next (reading it yielded empty text). We now register a stream_result_hook so AFTER_MODEL runs on the finalized ChatResponse the framework assembles once the stream is consumed. - 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: a DENY raised before the bump, inflating the counter on blocked calls. - Cap _stringify output so an oversized tool result / message can't hand a multi-megabyte string to the evaluator. - Trailing newline (W292). Note: agents are not cached by the factory (each runtime gets a fresh instance to avoid concurrent-Workflow reuse), so the cached-agent re-attach concern does not apply here — the idempotency guard is only a double-install safety net. The PR description's entry-point claim is stale (factory-evaluator uses no governance entry-point) and will be corrected. Tests: added finally-on-error (model + tool), streaming result-hook, and no-inflation-on-block coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3ef8d9d commit 387e982

3 files changed

Lines changed: 144 additions & 30 deletions

File tree

packages/uipath-agent-framework/src/uipath_agent_framework/governance/middleware.py

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
import logging
4646
from collections.abc import Mapping
4747
from typing import Any, Awaitable, Callable, Dict, List
48-
from uuid import uuid4
4948

5049
from agent_framework._middleware import (
5150
ChatContext,
@@ -158,22 +157,26 @@ def __init__(
158157
self._evaluator = evaluator
159158
self._agent_name = agent_name
160159
self._session_id = session_id
161-
self._trace_id = str(uuid4())
160+
# ``trace_id`` is intentionally NOT held here. A single uuid minted at
161+
# install time would be identical for every call. Trace correlation is
162+
# owned by the layer below (OTel span / HTTP resolve at call time),
163+
# matching the LangChain adapter.
162164
self._session_state: Dict[str, Any] = {"tool_calls": 0, "llm_calls": 0}
163165

164166
# ----- Model --------------------------------------------------------
165167

166168
def before_model(self, messages: Any) -> None:
167169
"""Evaluate BEFORE_MODEL on the latest message only (see ADK rationale)."""
168170
try:
169-
self._session_state["llm_calls"] = (
170-
self._session_state.get("llm_calls", 0) + 1
171-
)
172171
self._evaluator.evaluate_before_model(
173172
model_input=self._latest_message_text(messages),
174173
agent_name=self._agent_name,
175174
runtime_id=self._session_id,
176-
trace_id=self._trace_id,
175+
)
176+
# Count only calls that passed governance — a DENY raises above, so
177+
# a blocked call must not inflate the counter.
178+
self._session_state["llm_calls"] = (
179+
self._session_state.get("llm_calls", 0) + 1
177180
)
178181
except GovernanceBlockException:
179182
raise
@@ -187,7 +190,6 @@ def after_model(self, result: Any) -> None:
187190
model_output=self._response_text(result),
188191
agent_name=self._agent_name,
189192
runtime_id=self._session_id,
190-
trace_id=self._trace_id,
191193
)
192194
except GovernanceBlockException:
193195
raise
@@ -199,17 +201,18 @@ def after_model(self, result: Any) -> None:
199201
def before_tool(self, function: Any, arguments: Any) -> None:
200202
"""Evaluate TOOL_CALL with the tool name + arguments."""
201203
try:
202-
self._session_state["tool_calls"] = (
203-
self._session_state.get("tool_calls", 0) + 1
204-
)
205204
self._evaluator.evaluate_tool_call(
206205
tool_name=getattr(function, "name", None) or "unknown",
207206
tool_args=_coerce_args(arguments),
208207
agent_name=self._agent_name,
209208
runtime_id=self._session_id,
210-
trace_id=self._trace_id,
211209
session_state=self._session_state,
212210
)
211+
# Count only calls that passed governance; the evaluator saw the
212+
# count of prior tool calls, and a DENY raises before this bump.
213+
self._session_state["tool_calls"] = (
214+
self._session_state.get("tool_calls", 0) + 1
215+
)
213216
except GovernanceBlockException:
214217
raise
215218
except Exception as e: # noqa: BLE001
@@ -223,7 +226,6 @@ def after_tool(self, function: Any, result: Any) -> None:
223226
tool_result="" if result is None else _stringify(result),
224227
agent_name=self._agent_name,
225228
runtime_id=self._session_id,
226-
trace_id=self._trace_id,
227229
)
228230
except GovernanceBlockException:
229231
raise
@@ -281,8 +283,39 @@ async def process(
281283
self, context: ChatContext, call_next: Callable[[], Awaitable[None]]
282284
) -> None:
283285
self._cb.before_model(getattr(context, "messages", None))
284-
await call_next()
285-
self._cb.after_model(getattr(context, "result", None))
286+
287+
if getattr(context, "stream", False):
288+
# Streaming: after ``call_next`` ``context.result`` is a
289+
# ResponseStream, not finalized text — reading it for AFTER_MODEL
290+
# yields nothing. Register a result hook so AFTER_MODEL runs on the
291+
# finalized ``ChatResponse`` the framework assembles once the stream
292+
# is consumed.
293+
hooks = getattr(context, "stream_result_hooks", None)
294+
if isinstance(hooks, list):
295+
hooks.append(self._govern_streamed_result)
296+
else: # pragma: no cover - defensive: framework always provides it
297+
logger.debug(
298+
"ChatContext has no stream_result_hooks; AFTER_MODEL will "
299+
"not run for this streamed response"
300+
)
301+
await call_next()
302+
return
303+
304+
try:
305+
await call_next()
306+
finally:
307+
# AFTER_MODEL must run even if the model call raised, so audit and
308+
# rules still observe whatever result is present.
309+
self._cb.after_model(getattr(context, "result", None))
310+
311+
def _govern_streamed_result(self, response: Any) -> Any:
312+
"""``stream_result_hook``: govern the finalized streamed ``ChatResponse``.
313+
314+
Returns the response unchanged (governance observes, it does not
315+
rewrite). A DENY raised here still propagates to abort the run.
316+
"""
317+
self._cb.after_model(response)
318+
return response
286319

287320

288321
class GovernanceFunctionMiddleware(FunctionMiddleware):
@@ -298,8 +331,11 @@ async def process(
298331
) -> None:
299332
function = getattr(context, "function", None)
300333
self._cb.before_tool(function, getattr(context, "arguments", None))
301-
await call_next()
302-
self._cb.after_tool(function, getattr(context, "result", None))
334+
try:
335+
await call_next()
336+
finally:
337+
# AFTER_TOOL must run even if the tool call raised.
338+
self._cb.after_tool(function, getattr(context, "result", None))
303339

304340

305341
# Tuple used for isinstance idempotency / detach checks.
@@ -328,11 +364,17 @@ def _coerce_args(arguments: Any) -> Dict[str, Any]:
328364
return {}
329365

330366

331-
def _stringify(value: Any) -> str:
332-
"""Render a dict / object payload as compact, scannable text."""
367+
def _stringify(value: Any, cap: int = _BEFORE_MODEL_TEXT_CAP) -> str:
368+
"""Render a dict / object payload as compact, scannable text, capped.
369+
370+
Bounded by ``cap`` so an oversized tool result or message payload can't
371+
hand a multi-megabyte string to the evaluator. Callers that slice the
372+
result again (the ``_message_text`` / ``_response_text`` fallbacks) are
373+
unaffected.
374+
"""
333375
if isinstance(value, str):
334-
return value
376+
return value[:cap]
335377
try:
336-
return json.dumps(value, default=str, ensure_ascii=False)
378+
return json.dumps(value, default=str, ensure_ascii=False)[:cap]
337379
except (TypeError, ValueError):
338-
return str(value)
380+
return str(value)[:cap]

packages/uipath-agent-framework/tests/governance/test_middleware.py

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ async def _noop_next() -> None:
108108

109109
def test_install_governance_appends_both_middleware():
110110
agent = FakeAgent()
111-
returned = install_governance(agent, FakeEvaluator(), agent_name="x", session_id="s")
111+
returned = install_governance(
112+
agent, FakeEvaluator(), agent_name="x", session_id="s"
113+
)
112114
assert returned is agent
113115
kinds = [type(m) for m in agent.middleware]
114116
assert GovernanceChatMiddleware in kinds
@@ -158,20 +160,28 @@ def _factory_without_init():
158160
UiPathAgentFrameworkRuntimeFactory,
159161
)
160162

161-
return UiPathAgentFrameworkRuntimeFactory.__new__(UiPathAgentFrameworkRuntimeFactory)
163+
return UiPathAgentFrameworkRuntimeFactory.__new__(
164+
UiPathAgentFrameworkRuntimeFactory
165+
)
162166

163167

164168
def _stub_factory_runtime(monkeypatch, factory_mod):
165169
"""Stub storage + runtime constructions so only the governance branch runs."""
166170
monkeypatch.setattr(factory_mod, "ScopedCheckpointStorage", lambda *a, **k: None)
167-
monkeypatch.setattr(factory_mod, "UiPathAgentFrameworkRuntime", lambda **kw: SimpleNamespace(**kw))
168-
monkeypatch.setattr(factory_mod, "UiPathResumableRuntime", lambda **kw: SimpleNamespace(**kw))
171+
monkeypatch.setattr(
172+
factory_mod, "UiPathAgentFrameworkRuntime", lambda **kw: SimpleNamespace(**kw)
173+
)
174+
monkeypatch.setattr(
175+
factory_mod, "UiPathResumableRuntime", lambda **kw: SimpleNamespace(**kw)
176+
)
169177
monkeypatch.setattr(factory_mod, "UiPathResumeTriggerHandler", lambda *a, **k: None)
170178

171179
async def _storage(self):
172180
return SimpleNamespace(checkpoint_storage=object())
173181

174-
monkeypatch.setattr(factory_mod.UiPathAgentFrameworkRuntimeFactory, "_get_storage", _storage)
182+
monkeypatch.setattr(
183+
factory_mod.UiPathAgentFrameworkRuntimeFactory, "_get_storage", _storage
184+
)
175185

176186

177187
async def test_factory_installs_governance_when_evaluator_supplied(monkeypatch):
@@ -231,6 +241,45 @@ async def test_chat_middleware_caps_text():
231241
assert len(ev.calls[0][1]["model_input"]) <= _BEFORE_MODEL_TEXT_CAP
232242

233243

244+
async def test_after_model_runs_even_when_model_call_raises():
245+
"""AFTER_MODEL must fire from the finally so audit/rules still observe the
246+
turn, and the underlying error must still propagate."""
247+
ev = FakeEvaluator()
248+
mw = GovernanceChatMiddleware(_make_callbacks(ev))
249+
250+
async def boom_next() -> None:
251+
raise RuntimeError("model exploded")
252+
253+
context = SimpleNamespace(messages=[_msg("hi")], result=SimpleNamespace(text=""))
254+
with pytest.raises(RuntimeError, match="model exploded"):
255+
await mw.process(context, boom_next)
256+
assert [h for h, _ in ev.calls] == ["before_model", "after_model"]
257+
258+
259+
async def test_streaming_governs_finalized_response_via_result_hook():
260+
"""Streaming: context.result is a ResponseStream after call_next, so
261+
AFTER_MODEL runs from a stream_result_hook on the finalized ChatResponse."""
262+
ev = FakeEvaluator()
263+
mw = GovernanceChatMiddleware(_make_callbacks(ev))
264+
context = SimpleNamespace(
265+
messages=[_msg("the question")],
266+
stream=True,
267+
stream_result_hooks=[],
268+
result=None,
269+
)
270+
await mw.process(context, _noop_next)
271+
272+
# BEFORE_MODEL fired; AFTER_MODEL deferred to the registered hook.
273+
assert [h for h, _ in ev.calls] == ["before_model"]
274+
assert len(context.stream_result_hooks) == 1
275+
276+
finalized = SimpleNamespace(text="the streamed answer")
277+
returned = context.stream_result_hooks[0](finalized)
278+
assert returned is finalized # hook returns the response unchanged
279+
assert [h for h, _ in ev.calls] == ["before_model", "after_model"]
280+
assert ev.calls[-1][1]["model_output"] == "the streamed answer"
281+
282+
234283
# --------------------------------------------------------------------------
235284
# FunctionMiddleware → TOOL_CALL / AFTER_TOOL
236285
# --------------------------------------------------------------------------
@@ -270,6 +319,29 @@ async def test_function_middleware_coerces_pydantic_args():
270319
assert ev.calls[1][1]["tool_result"] == "" # None result → ""
271320

272321

322+
async def test_after_tool_runs_even_when_tool_call_raises():
323+
ev = FakeEvaluator()
324+
mw = GovernanceFunctionMiddleware(_make_callbacks(ev))
325+
326+
async def boom_next() -> None:
327+
raise RuntimeError("tool exploded")
328+
329+
context = SimpleNamespace(function=FakeTool("t"), arguments={}, result=None)
330+
with pytest.raises(RuntimeError, match="tool exploded"):
331+
await mw.process(context, boom_next)
332+
assert [h for h, _ in ev.calls] == ["tool_call", "after_tool"]
333+
334+
335+
def test_blocked_before_tool_does_not_increment_counter():
336+
"""A DENY raises before the counter bump, so the count is not inflated."""
337+
ev = FakeEvaluator(block_on="tool_call")
338+
cb = _make_callbacks(ev)
339+
with pytest.raises(GovernanceBlockException):
340+
cb.before_tool(FakeTool("t"), {})
341+
assert ev.calls[-1][1]["session_state"]["tool_calls"] == 0
342+
assert cb._session_state["tool_calls"] == 0
343+
344+
273345
# --------------------------------------------------------------------------
274346
# enforcement semantics
275347
# --------------------------------------------------------------------------
@@ -312,4 +384,4 @@ def evaluate_before_model(self, **_: Any) -> None:
312384
mw = GovernanceChatMiddleware(cb)
313385
with caplog.at_level(logging.WARNING):
314386
await mw.process(SimpleNamespace(messages=[_msg("x")], result=None), _noop_next)
315-
assert any("governance check failed" in r.message for r in caplog.records)
387+
assert any("governance check failed" in r.message for r in caplog.records)

packages/uipath-agent-framework/uv.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)