Skip to content

Commit 2bea397

Browse files
mjnoviceclaude
andauthored
fix(eval): apply resource overwrites and NoMemory override correctly (#1815)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7b41bc9 commit 2bea397

5 files changed

Lines changed: 285 additions & 33 deletions

File tree

packages/uipath/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath"
3-
version = "2.13.10"
3+
version = "2.13.11"
44
description = "Python SDK and CLI for UiPath Platform, enabling programmatic interaction with automation services, process management, and deployment tools."
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

packages/uipath/src/uipath/_cli/cli_eval.py

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import os
55
import uuid
6+
from contextlib import AsyncExitStack
67
from pathlib import Path
78
from typing import Any
89

@@ -138,6 +139,13 @@ def _resolve_agent_memory_settings_override(
138139
if not evaluation_set.agent_memory_enabled:
139140
return {"enabled": False}
140141

142+
# "NoMemory" is a sentinel id meaning "run without memory", matching the
143+
# Agents backend (ApplyAgentMemorySettingsOverride removes the memorySpace
144+
# feature for a null or "NoMemory" setting). Its entry's field values are
145+
# also "NoMemory" strings, so it must never be applied as real settings.
146+
if agent_memory_settings_id == "NoMemory":
147+
return {"enabled": False}
148+
141149
memory_settings = evaluation_set.agent_memory_settings
142150
target = None
143151
if agent_memory_settings_id:
@@ -154,6 +162,9 @@ def _resolve_agent_memory_settings_override(
154162
if target is None:
155163
target = memory_settings[0] if memory_settings else None
156164

165+
if target is not None and target.id == "NoMemory":
166+
return {"enabled": False}
167+
157168
if target is None:
158169
# Memory enabled but no settings configured: keep the agent's own configuration
159170
return {"enabled": True}
@@ -468,40 +479,46 @@ async def execute_eval():
468479
)
469480
)
470481

471-
runtime = await runtime_factory.new_runtime(
472-
entrypoint=eval_context.entrypoint or "",
473-
runtime_id=eval_context.execution_id,
474-
settings=settings_override,
475-
agent_memory_settings=agent_memory_settings_override,
476-
)
477-
478-
eval_context.runtime_schema = await runtime.get_schema()
479-
480-
eval_context.evaluators = await EvalHelpers.load_evaluators(
481-
resolved_eval_set_path,
482-
eval_context.evaluation_set,
483-
get_agent_model(eval_context.runtime_schema),
484-
)
485-
486-
# Runtime is not required anymore.
487-
await runtime.dispose()
482+
# Resource overwrites must be in scope before any runtime is
483+
# created: building the agent graph resolves folder-scoped
484+
# resources (e.g. escalation memory spaces) at tool-creation
485+
# time, and those lookups need the overwritten folder paths.
486+
async with AsyncExitStack() as stack:
487+
if project_id:
488+
studio_client = StudioClient(project_id)
489+
490+
await stack.enter_async_context(
491+
ResourceOverwritesContext(
492+
lambda: studio_client.get_resource_overwrites()
493+
)
494+
)
495+
else:
496+
logger.debug(
497+
"No UIPATH_PROJECT_ID configured, executing evaluation without resource overwrites"
498+
)
488499

489-
if project_id:
490-
studio_client = StudioClient(project_id)
500+
runtime = await runtime_factory.new_runtime(
501+
entrypoint=eval_context.entrypoint or "",
502+
runtime_id=eval_context.execution_id,
503+
settings=settings_override,
504+
agent_memory_settings=agent_memory_settings_override,
505+
)
491506

492-
async with ResourceOverwritesContext(
493-
lambda: studio_client.get_resource_overwrites()
494-
):
495-
ctx.result = await evaluate(
496-
runtime_factory,
497-
trace_manager,
498-
eval_context,
499-
event_bus,
507+
# The runtime is only needed for schema/evaluator
508+
# loading; dispose it before evaluation starts.
509+
try:
510+
eval_context.runtime_schema = await runtime.get_schema()
511+
512+
eval_context.evaluators = (
513+
await EvalHelpers.load_evaluators(
514+
resolved_eval_set_path,
515+
eval_context.evaluation_set,
516+
get_agent_model(eval_context.runtime_schema),
517+
)
500518
)
501-
else:
502-
logger.debug(
503-
"No UIPATH_PROJECT_ID configured, executing evaluation without resource overwrites"
504-
)
519+
finally:
520+
await runtime.dispose()
521+
505522
ctx.result = await evaluate(
506523
runtime_factory,
507524
trace_manager,

packages/uipath/tests/cli/eval/test_agent_memory_settings.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,3 +205,53 @@ def test_memory_enabled_without_settings_keeps_agent_configuration(self):
205205
override = _resolve_agent_memory_settings_override("default", eval_set)
206206

207207
assert override == {"enabled": True}
208+
209+
def test_no_memory_id_disables_memory(self):
210+
# Selecting "No memory" in the eval settings passes the "NoMemory"
211+
# sentinel id; its entry stores "NoMemory" field values that must not
212+
# be applied as real settings.
213+
eval_set = make_eval_set(
214+
agentMemoryEnabled=True,
215+
agentMemorySettings=[
216+
{"id": "s1", "searchMode": "hybrid"},
217+
{
218+
"id": "NoMemory",
219+
"resultCount": "NoMemory",
220+
"searchMode": "semantic",
221+
"threshold": "NoMemory",
222+
},
223+
],
224+
)
225+
226+
override = _resolve_agent_memory_settings_override("NoMemory", eval_set)
227+
228+
assert override == {"enabled": False}
229+
230+
def test_no_memory_id_disables_memory_without_matching_entry(self):
231+
# The sentinel disables memory even when the eval set has no
232+
# "NoMemory" entry; it must not fall back to the first setting.
233+
eval_set = make_eval_set(
234+
agentMemoryEnabled=True,
235+
agentMemorySettings=[{"id": "s1", "searchMode": "hybrid"}],
236+
)
237+
238+
override = _resolve_agent_memory_settings_override("NoMemory", eval_set)
239+
240+
assert override == {"enabled": False}
241+
242+
def test_fallback_to_no_memory_entry_disables_memory(self):
243+
eval_set = make_eval_set(
244+
agentMemoryEnabled=True,
245+
agentMemorySettings=[
246+
{
247+
"id": "NoMemory",
248+
"resultCount": "NoMemory",
249+
"searchMode": "semantic",
250+
"threshold": "NoMemory",
251+
}
252+
],
253+
)
254+
255+
override = _resolve_agent_memory_settings_override("default", eval_set)
256+
257+
assert override == {"enabled": False}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for resource overwrites context ordering in the eval CLI.
2+
3+
The overwrites context must be entered before the runtime is created:
4+
building the agent graph resolves folder-scoped resources (e.g. escalation
5+
memory spaces) at tool-creation time, and those lookups need the
6+
overwritten folder paths.
7+
"""
8+
9+
import json
10+
import os
11+
from contextlib import ExitStack
12+
from typing import Any
13+
from unittest.mock import AsyncMock, Mock, patch
14+
15+
import pytest
16+
from click.testing import CliRunner
17+
18+
from uipath._cli import cli
19+
from uipath._cli.middlewares import MiddlewareResult
20+
from uipath.platform.common._bindings import _resource_overwrites
21+
22+
23+
def _middleware_continue() -> MiddlewareResult:
24+
return MiddlewareResult(
25+
should_continue=True,
26+
error_message=None,
27+
should_include_stacktrace=False,
28+
)
29+
30+
31+
def _write_project_files() -> None:
32+
with open("uipath.json", "w") as f:
33+
json.dump({"functions": {"agent": "main.py:main"}}, f)
34+
35+
os.makedirs("evaluations/eval-sets", exist_ok=True)
36+
eval_set = {
37+
"version": "1.0",
38+
"id": "test-set",
39+
"name": "Test Set",
40+
"evaluatorRefs": [],
41+
"evaluations": [],
42+
}
43+
with open("evaluations/eval-sets/test-set.json", "w") as f:
44+
json.dump(eval_set, f)
45+
46+
47+
def _make_mock_runtime() -> Mock:
48+
mock_runtime = Mock()
49+
mock_runtime.get_schema = AsyncMock(
50+
return_value=Mock(metadata=None, input_schema=None, output_schema=None)
51+
)
52+
mock_runtime.dispose = AsyncMock()
53+
return mock_runtime
54+
55+
56+
def _make_mock_factory(mock_runtime: Mock) -> Mock:
57+
mock_factory = Mock()
58+
mock_factory.discover_entrypoints.return_value = ["agent"]
59+
mock_factory.get_settings = AsyncMock(return_value=None)
60+
mock_factory.dispose = AsyncMock()
61+
mock_factory.new_runtime = AsyncMock(return_value=mock_runtime)
62+
return mock_factory
63+
64+
65+
def _enter_base_patches(stack: ExitStack, mock_factory: Mock) -> None:
66+
stack.enter_context(
67+
patch(
68+
"uipath._cli.cli_eval.Middlewares.next",
69+
return_value=_middleware_continue(),
70+
)
71+
)
72+
stack.enter_context(
73+
patch(
74+
"uipath._cli.cli_eval.UiPathRuntimeFactoryRegistry.get",
75+
return_value=mock_factory,
76+
)
77+
)
78+
stack.enter_context(
79+
patch("uipath._cli.cli_eval.setup_reporting_prereq", return_value=False)
80+
)
81+
stack.enter_context(
82+
patch(
83+
"uipath._cli.cli_eval.EvalHelpers.load_evaluators",
84+
new=AsyncMock(return_value=[]),
85+
)
86+
)
87+
stack.enter_context(
88+
patch("uipath._cli.cli_eval.evaluate", new=AsyncMock(return_value=None))
89+
)
90+
91+
92+
class TestEvalResourceOverwritesOrdering:
93+
def test_overwrites_context_active_when_runtime_is_created(
94+
self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch
95+
) -> None:
96+
"""new_runtime must run inside the resource overwrites context."""
97+
monkeypatch.setenv("UIPATH_PROJECT_ID", "project-123")
98+
99+
with runner.isolated_filesystem(temp_dir=temp_dir):
100+
_write_project_files()
101+
102+
overwrite = Mock()
103+
overwrites = {"memorySpace.MemorySpace": overwrite}
104+
overwrites_seen_by_new_runtime: list[Any] = []
105+
106+
mock_runtime = _make_mock_runtime()
107+
108+
async def new_runtime(*args: Any, **kwargs: Any) -> Mock:
109+
overwrites_seen_by_new_runtime.append(_resource_overwrites.get())
110+
return mock_runtime
111+
112+
mock_factory = _make_mock_factory(mock_runtime)
113+
mock_factory.new_runtime = AsyncMock(side_effect=new_runtime)
114+
115+
mock_studio_client = Mock()
116+
mock_studio_client.get_resource_overwrites = AsyncMock(
117+
return_value=overwrites
118+
)
119+
120+
with ExitStack() as stack:
121+
_enter_base_patches(stack, mock_factory)
122+
stack.enter_context(
123+
patch(
124+
"uipath._cli.cli_eval.StudioClient",
125+
return_value=mock_studio_client,
126+
)
127+
)
128+
result = runner.invoke(cli, ["eval"])
129+
130+
assert result.exit_code == 0
131+
mock_studio_client.get_resource_overwrites.assert_awaited_once()
132+
assert overwrites_seen_by_new_runtime == [overwrites]
133+
mock_runtime.dispose.assert_awaited_once()
134+
135+
def test_no_project_id_runs_without_overwrites(
136+
self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch
137+
) -> None:
138+
monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False)
139+
140+
with runner.isolated_filesystem(temp_dir=temp_dir):
141+
_write_project_files()
142+
143+
overwrites_seen_by_new_runtime: list[Any] = []
144+
mock_runtime = _make_mock_runtime()
145+
146+
async def new_runtime(*args: Any, **kwargs: Any) -> Mock:
147+
overwrites_seen_by_new_runtime.append(_resource_overwrites.get())
148+
return mock_runtime
149+
150+
mock_factory = _make_mock_factory(mock_runtime)
151+
mock_factory.new_runtime = AsyncMock(side_effect=new_runtime)
152+
153+
with ExitStack() as stack:
154+
_enter_base_patches(stack, mock_factory)
155+
mock_studio_client_cls = stack.enter_context(
156+
patch("uipath._cli.cli_eval.StudioClient")
157+
)
158+
result = runner.invoke(cli, ["eval"])
159+
160+
assert result.exit_code == 0
161+
mock_studio_client_cls.assert_not_called()
162+
assert overwrites_seen_by_new_runtime == [None]
163+
mock_runtime.dispose.assert_awaited_once()
164+
165+
def test_runtime_disposed_when_schema_loading_fails(
166+
self, runner: CliRunner, temp_dir: str, monkeypatch: pytest.MonkeyPatch
167+
) -> None:
168+
monkeypatch.delenv("UIPATH_PROJECT_ID", raising=False)
169+
170+
with runner.isolated_filesystem(temp_dir=temp_dir):
171+
_write_project_files()
172+
173+
mock_runtime = _make_mock_runtime()
174+
mock_runtime.get_schema = AsyncMock(
175+
side_effect=RuntimeError("schema loading failed")
176+
)
177+
mock_factory = _make_mock_factory(mock_runtime)
178+
179+
with ExitStack() as stack:
180+
_enter_base_patches(stack, mock_factory)
181+
stack.enter_context(patch("uipath._cli.cli_eval.StudioClient"))
182+
result = runner.invoke(cli, ["eval"])
183+
184+
assert result.exit_code != 0
185+
mock_runtime.dispose.assert_awaited_once()

packages/uipath/uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)