From 1642ce3860e4caa3562e557f589d84d951ab3d5b Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:47:03 -0700 Subject: [PATCH 01/34] feat: map conversationalService.syntheticUserId to runtime context Add synthetic_user_id to UiPathRuntimeContext plus the fps_mappings entry, so the conversational bridge can present the conversation owner id to CAS on the websocket handshake. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uipath/runtime/context.py | 4 ++++ tests/test_context.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 2fe81a8a..f2261770 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -37,6 +37,9 @@ class UiPathRuntimeContext(BaseModel): ) exchange_id: str | None = Field(None, description="Exchange identifier for CAS") message_id: str | None = Field(None, description="Message identifier for CAS") + synthetic_user_id: str | None = Field( + None, description="Conversation owner id (synthetic user) for CAS" + ) voice_mode: Literal["session"] | None = Field( None, description="Voice job type for CAS" ) @@ -364,6 +367,7 @@ def from_config( "conversationalService.conversationId": "conversation_id", "conversationalService.exchangeId": "exchange_id", "conversationalService.messageId": "message_id", + "conversationalService.syntheticUserId": "synthetic_user_id", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", "voice.mode": "voice_mode", diff --git a/tests/test_context.py b/tests/test_context.py index 9231f65a..41312c04 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -183,6 +183,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> "conversationalService.conversationId": "conv-123", "conversationalService.exchangeId": "ex-456", "conversationalService.messageId": "msg-789", + "conversationalService.syntheticUserId": "owner-guid", "mcpServer.id": "server-id-123", "mcpServer.slug": "my-mcp-server", } @@ -195,6 +196,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> assert ctx.conversation_id == "conv-123" assert ctx.exchange_id == "ex-456" assert ctx.message_id == "msg-789" + assert ctx.synthetic_user_id == "owner-guid" assert ctx.mcp_server_id == "server-id-123" assert ctx.mcp_server_slug == "my-mcp-server" From f94398cd46358a86d41b9f3ee406f02c156f323a Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Mon, 15 Jun 2026 14:53:54 -0700 Subject: [PATCH 02/34] feat: allow conversational agents to optionally end exchange --- pyproject.toml | 2 +- src/uipath/runtime/chat/runtime.py | 7 +++- src/uipath/runtime/context.py | 5 +++ tests/test_chat.py | 64 ++++++++++++++++++++++++++++++ tests/test_context.py | 32 +++++++++++++++ uv.lock | 4 +- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1762d366..76db1894 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.0" +version = "0.12.0" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 040040be..b2a0c713 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -35,16 +35,20 @@ def __init__( self, delegate: UiPathRuntimeProtocol, chat_bridge: UiPathChatProtocol, + end_exchange: bool = True, ): """Initialize the UiPathChatRuntime. Args: delegate: The underlying runtime to wrap chat_bridge: Bridge for chat event communication + end_exchange: Whether to emit the exchange end event. When False, the exchange is left open so a + downstream consumer can continue it and end it later. """ super().__init__() self.delegate = delegate self.chat_bridge = chat_bridge + self.end_exchange = end_exchange async def execute( self, @@ -167,7 +171,8 @@ async def stream( else: yield event execution_completed = True - await self.chat_bridge.emit_exchange_end_event() + if self.end_exchange: + await self.chat_bridge.emit_exchange_end_event() else: yield event diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 2fe81a8a..567c6382 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -37,6 +37,10 @@ class UiPathRuntimeContext(BaseModel): ) exchange_id: str | None = Field(None, description="Exchange identifier for CAS") message_id: str | None = Field(None, description="Message identifier for CAS") + end_exchange: bool = Field( + True, + description="Whether to emit the exchange end event for CAS", + ) voice_mode: Literal["session"] | None = Field( None, description="Voice job type for CAS" ) @@ -364,6 +368,7 @@ def from_config( "conversationalService.conversationId": "conversation_id", "conversationalService.exchangeId": "exchange_id", "conversationalService.messageId": "message_id", + "conversationalService.endExchange": "end_exchange", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", "voice.mode": "voice_mode", diff --git a/tests/test_chat.py b/tests/test_chat.py index c3be06b3..c87b7cbe 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -257,6 +257,70 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 +@pytest.mark.asyncio +async def test_chat_runtime_emits_exchange_end_by_default(): + """Without end_exchange specified, exchange end is emitted.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): + """end_exchange=True emits the exchange end event.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + end_exchange=True, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() + + +@pytest.mark.asyncio +async def test_chat_runtime_skips_exchange_end_when_end_exchange_false(): + """end_exchange=False suppresses the exchange end event but completes normally.""" + + runtime_impl = StreamingMockRuntime(messages=["Hello", "World"]) + bridge = make_chat_bridge_mock() + + chat_runtime = UiPathChatRuntime( + delegate=runtime_impl, + chat_bridge=bridge, + end_exchange=False, + ) + + result = await chat_runtime.execute({}) + + await chat_runtime.dispose() + + # Execution completes normally; only the exchange end emission is skipped + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 + cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited() + + @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" diff --git a/tests/test_context.py b/tests/test_context.py index 9231f65a..79069541 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -241,6 +241,38 @@ def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None: assert ctx.mcp_server_slug == "test-server-slug" +def test_from_config_maps_end_exchange_fps_property(tmp_path: Path) -> None: + """conversationalService.endExchange should map onto end_exchange.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + "conversationalService.exchangeId": "ex-456", + "conversationalService.endExchange": False, + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is False + + +def test_end_exchange_defaults_true_when_fps_property_absent(tmp_path: Path) -> None: + """end_exchange defaults to True (legacy behavior) when the fps key is missing.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.end_exchange is True + + def test_result_file_written_on_faulted_trigger_error(tmp_path: Path) -> None: runtime_dir = tmp_path / "runtime" ctx = UiPathRuntimeContext( diff --git a/uv.lock b/uv.lock index 8a9846e8..fca7407b 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-27T14:28:49.412753Z" +exclude-newer = "2026-06-10T16:51:23.374942Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.0" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From 3062bd0845e8a4add1cb672b60cf3bc6f4684990 Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Mon, 15 Jun 2026 16:39:41 -0700 Subject: [PATCH 03/34] feat: rename syntheticUserId to conversationalUserId [JAR-9965] Match the CAS-side rename: the forwarded conversation owner id may be a real cloud user id or a synthetic user id, so 'synthetic' was misleading. Renames the context field (conversational_user_id) and the FpsProperties key (conversationalService.conversationalUserId). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uipath/runtime/context.py | 7 ++++--- tests/test_context.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index f2261770..f76e8e9a 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -37,8 +37,9 @@ class UiPathRuntimeContext(BaseModel): ) exchange_id: str | None = Field(None, description="Exchange identifier for CAS") message_id: str | None = Field(None, description="Message identifier for CAS") - synthetic_user_id: str | None = Field( - None, description="Conversation owner id (synthetic user) for CAS" + conversational_user_id: str | None = Field( + None, + description="Conversation owner id for CAS (a real cloud user id or a synthetic user id)", ) voice_mode: Literal["session"] | None = Field( None, description="Voice job type for CAS" @@ -367,7 +368,7 @@ def from_config( "conversationalService.conversationId": "conversation_id", "conversationalService.exchangeId": "exchange_id", "conversationalService.messageId": "message_id", - "conversationalService.syntheticUserId": "synthetic_user_id", + "conversationalService.conversationalUserId": "conversational_user_id", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", "voice.mode": "voice_mode", diff --git a/tests/test_context.py b/tests/test_context.py index 41312c04..af5d8de0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -183,7 +183,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> "conversationalService.conversationId": "conv-123", "conversationalService.exchangeId": "ex-456", "conversationalService.messageId": "msg-789", - "conversationalService.syntheticUserId": "owner-guid", + "conversationalService.conversationalUserId": "owner-guid", "mcpServer.id": "server-id-123", "mcpServer.slug": "my-mcp-server", } @@ -196,7 +196,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> assert ctx.conversation_id == "conv-123" assert ctx.exchange_id == "ex-456" assert ctx.message_id == "msg-789" - assert ctx.synthetic_user_id == "owner-guid" + assert ctx.conversational_user_id == "owner-guid" assert ctx.mcp_server_id == "server-id-123" assert ctx.mcp_server_slug == "my-mcp-server" From fb37dc9c2eaf5608d03719b84a33ed54a427a958 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Wed, 17 Jun 2026 10:33:41 -0700 Subject: [PATCH 04/34] fix: move emit end exchange check to bridge --- src/uipath/runtime/chat/runtime.py | 7 +---- tests/test_chat.py | 50 +++--------------------------- 2 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index b2a0c713..040040be 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -35,20 +35,16 @@ def __init__( self, delegate: UiPathRuntimeProtocol, chat_bridge: UiPathChatProtocol, - end_exchange: bool = True, ): """Initialize the UiPathChatRuntime. Args: delegate: The underlying runtime to wrap chat_bridge: Bridge for chat event communication - end_exchange: Whether to emit the exchange end event. When False, the exchange is left open so a - downstream consumer can continue it and end it later. """ super().__init__() self.delegate = delegate self.chat_bridge = chat_bridge - self.end_exchange = end_exchange async def execute( self, @@ -171,8 +167,7 @@ async def stream( else: yield event execution_completed = True - if self.end_exchange: - await self.chat_bridge.emit_exchange_end_event() + await self.chat_bridge.emit_exchange_end_event() else: yield event diff --git a/tests/test_chat.py b/tests/test_chat.py index c87b7cbe..f252ebfe 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -258,28 +258,12 @@ async def test_chat_runtime_stream_yields_all_events(): @pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_by_default(): - """Without end_exchange specified, exchange end is emitted.""" +async def test_chat_runtime_emits_exchange_end_on_success(): + """The runtime always emits the exchange end event on successful completion. - runtime_impl = StreamingMockRuntime(messages=["Hello"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() - - -@pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): - """end_exchange=True emits the exchange end event.""" + Whether that event is honored (e.g. suppressed to keep the exchange open) is a + decision for the bridge implementation, not this low-level runtime. + """ runtime_impl = StreamingMockRuntime(messages=["Hello"]) bridge = make_chat_bridge_mock() @@ -287,7 +271,6 @@ async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): chat_runtime = UiPathChatRuntime( delegate=runtime_impl, chat_bridge=bridge, - end_exchange=True, ) result = await chat_runtime.execute({}) @@ -298,29 +281,6 @@ async def test_chat_runtime_emits_exchange_end_when_end_exchange_true(): cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() -@pytest.mark.asyncio -async def test_chat_runtime_skips_exchange_end_when_end_exchange_false(): - """end_exchange=False suppresses the exchange end event but completes normally.""" - - runtime_impl = StreamingMockRuntime(messages=["Hello", "World"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - end_exchange=False, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - # Execution completes normally; only the exchange end emission is skipped - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 - cast(AsyncMock, bridge.emit_exchange_end_event).assert_not_awaited() - - @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" From a9e4c3547aef1648c7868241c108005913a9a523 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Wed, 17 Jun 2026 10:35:58 -0700 Subject: [PATCH 05/34] fix: remove test --- tests/test_chat.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/tests/test_chat.py b/tests/test_chat.py index f252ebfe..c3be06b3 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -257,30 +257,6 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 -@pytest.mark.asyncio -async def test_chat_runtime_emits_exchange_end_on_success(): - """The runtime always emits the exchange end event on successful completion. - - Whether that event is honored (e.g. suppressed to keep the exchange open) is a - decision for the bridge implementation, not this low-level runtime. - """ - - runtime_impl = StreamingMockRuntime(messages=["Hello"]) - bridge = make_chat_bridge_mock() - - chat_runtime = UiPathChatRuntime( - delegate=runtime_impl, - chat_bridge=bridge, - ) - - result = await chat_runtime.execute({}) - - await chat_runtime.dispose() - - assert result.status == UiPathRuntimeStatus.SUCCESSFUL - cast(AsyncMock, bridge.emit_exchange_end_event).assert_awaited_once() - - @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" From efcb6f96dca9b3bb6cc907064e34191d8f32ac2b Mon Sep 17 00:00:00 2001 From: Christin <164907691+scottcmg@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:40:42 -0700 Subject: [PATCH 06/34] chore: bump version to 0.11.1 Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1762d366..3a7cb2ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.0" +version = "0.11.1" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 8a9846e8..4651d344 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-05-27T14:28:49.412753Z" +exclude-newer = "2026-06-16T20:40:40.185404Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.0" +version = "0.11.1" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From 5af927e7a4bcf936cab2b382a1409e588740fcb2 Mon Sep 17 00:00:00 2001 From: andrewwan-uipath Date: Fri, 19 Jun 2026 12:33:40 -0700 Subject: [PATCH 07/34] chore: patch increase to 0.11.1 --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 76db1894..3a7cb2ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.0" +version = "0.11.1" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index fca7407b..2f586a06 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-10T16:51:23.374942Z" +exclude-newer = "2026-06-17T19:32:25.812022Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.0" +version = "0.11.1" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From e853dbf77a54e3eb9a55e0b99ecf27c40002a608 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 23 Jun 2026 19:26:44 +0300 Subject: [PATCH 08/34] feat: add workspace hydration primitives --- pyproject.toml | 4 +- src/uipath/runtime/__init__.py | 14 + src/uipath/runtime/workspace/__init__.py | 21 + src/uipath/runtime/workspace/hydration.py | 120 +++++ src/uipath/runtime/workspace/hydrator.py | 190 +++++++ .../runtime/workspace/registry_store.py | 49 ++ src/uipath/runtime/workspace/workspace.py | 47 ++ tests/workspace/test_workspace_hydration.py | 483 ++++++++++++++++++ uv.lock | 12 +- 9 files changed, 932 insertions(+), 8 deletions(-) create mode 100644 src/uipath/runtime/workspace/__init__.py create mode 100644 src/uipath/runtime/workspace/hydration.py create mode 100644 src/uipath/runtime/workspace/hydrator.py create mode 100644 src/uipath/runtime/workspace/registry_store.py create mode 100644 src/uipath/runtime/workspace/workspace.py create mode 100644 tests/workspace/test_workspace_hydration.py diff --git a/pyproject.toml b/pyproject.toml index a7b626c7..c1427ea4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-runtime" -version = "0.11.2" +version = "0.11.3" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.17, <0.6.0" + "uipath-core>=0.5.22,<0.6.0", ] classifiers = [ "Intended Audience :: Developers", diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index 1eed011f..afc27198 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -43,6 +43,14 @@ ) from uipath.runtime.schema import UiPathRuntimeSchema from uipath.runtime.storage import UiPathRuntimeStorageProtocol +from uipath.runtime.workspace import ( + AttachmentRegistryEntry, + HydrationPolicy, + HydrationRuntime, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, +) __all__ = [ "UiPathExecuteOptions", @@ -73,4 +81,10 @@ "UiPathResumeTriggerName", "UiPathChatProtocol", "UiPathChatRuntime", + "AttachmentRegistryEntry", + "HydrationPolicy", + "HydrationRuntime", + "Workspace", + "WorkspaceHydrator", + "WorkspaceRegistryStore", ] diff --git a/src/uipath/runtime/workspace/__init__.py b/src/uipath/runtime/workspace/__init__.py new file mode 100644 index 00000000..c8dc23bc --- /dev/null +++ b/src/uipath/runtime/workspace/__init__.py @@ -0,0 +1,21 @@ +"""Workspace persistence primitives for runtime implementations.""" + +from uipath.runtime.workspace.hydration import ( + HydrationPolicy, + HydrationRuntime, +) +from uipath.runtime.workspace.hydrator import ( + AttachmentRegistryEntry, + WorkspaceHydrator, +) +from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore +from uipath.runtime.workspace.workspace import Workspace + +__all__ = [ + "AttachmentRegistryEntry", + "HydrationPolicy", + "HydrationRuntime", + "Workspace", + "WorkspaceHydrator", + "WorkspaceRegistryStore", +] diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py new file mode 100644 index 00000000..2b3d8e1c --- /dev/null +++ b/src/uipath/runtime/workspace/hydration.py @@ -0,0 +1,120 @@ +"""Runtime wrapper for workspace hydration.""" + +from enum import Enum +from typing import Any, AsyncGenerator + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.hydrator import WorkspaceHydrator +from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore +from uipath.runtime.workspace.workspace import Workspace + + +class HydrationPolicy(str, Enum): + """Controls when workspace changes are persisted.""" + + SUSPEND_ONLY = "suspend_only" + SUSPEND_OR_SUCCESS = "suspend_or_success" + ALWAYS = "always" + + +class HydrationRuntime: + """Wraps a runtime with hydrate-before and dehydrate-after behavior.""" + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + *, + workspace: Workspace, + hydrator: WorkspaceHydrator, + registry_store: WorkspaceRegistryStore, + policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY, + ): + """Initialize the hydration wrapper.""" + self.delegate = delegate + self.workspace = workspace + self.hydrator = hydrator + self.registry_store = registry_store + self.policy = policy + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Hydrate, execute, then persist files according to policy.""" + await self._hydrate() + try: + result = await self.delegate.execute(input, options=options) + except Exception: + if self.policy == HydrationPolicy.ALWAYS: + await self._persist() + raise + await self._dehydrate(result) + return result + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Hydrate, stream delegate events, then persist files according to policy.""" + await self._hydrate() + final_result: UiPathRuntimeResult | None = None + + try: + async for event in self.delegate.stream(input, options=options): + if isinstance(event, UiPathRuntimeResult): + final_result = event + else: + yield event + except Exception: + if self.policy == HydrationPolicy.ALWAYS: + await self._persist() + raise + + if final_result is not None: + await self._dehydrate(final_result) + yield final_result + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema from delegate runtime.""" + return await self.delegate.get_schema() + + async def dispose(self) -> None: + """Dispose delegate and workspace.""" + try: + await self.delegate.dispose() + finally: + await self.workspace.dispose() + + async def _hydrate(self) -> None: + registry = await self.registry_store.load() + hydrated = await self.hydrator.hydrate(registry) + if hydrated != registry: + await self.registry_store.save(hydrated) + + async def _dehydrate(self, result: UiPathRuntimeResult) -> None: + if self._should_dehydrate(result): + await self._persist() + + async def _persist(self) -> None: + registry = await self.registry_store.load() + dehydrated = await self.hydrator.dehydrate(registry) + await self.registry_store.save(dehydrated) + + def _should_dehydrate(self, result: UiPathRuntimeResult) -> bool: + if self.policy == HydrationPolicy.ALWAYS: + return True + if result.status == UiPathRuntimeStatus.SUSPENDED: + return True + return ( + self.policy == HydrationPolicy.SUSPEND_OR_SUCCESS + and result.status == UiPathRuntimeStatus.SUCCESSFUL + ) diff --git a/src/uipath/runtime/workspace/hydrator.py b/src/uipath/runtime/workspace/hydrator.py new file mode 100644 index 00000000..fba298ee --- /dev/null +++ b/src/uipath/runtime/workspace/hydrator.py @@ -0,0 +1,190 @@ +"""Attachment-backed workspace hydration.""" + +import hashlib +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from uuid import UUID + +from pydantic import BaseModel +from uipath.core.workspace import AttachmentsProtocol, JobsProtocol + + +class AttachmentRegistryEntry(BaseModel): + """Registry entry for one workspace file attachment.""" + + attachment_key: str + sha256: str + size: int + uploaded_at: str + attachment_name: str | None = None + + +class WorkspaceHydrator: + """Hydrates/dehydrates a workspace directory through job attachments.""" + + DEFAULT_ATTACHMENT_PREFIX = ".uipath-workspace/" + + def __init__( + self, + *, + workspace_path: str | Path, + attachments: AttachmentsProtocol, + jobs: JobsProtocol | None = None, + current_job_key: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + attachment_prefix: str = DEFAULT_ATTACHMENT_PREFIX, + ): + """Initialize the hydrator.""" + self.workspace_path = Path(workspace_path) + self.attachments = attachments + self.jobs = jobs + self.current_job_key = current_job_key + self.folder_key = folder_key + self.folder_path = folder_path + self.attachment_prefix = attachment_prefix.strip("/") + + async def hydrate( + self, + registry: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Download registry files into the workspace. + + Files with matching SHA-256 are left untouched. + """ + normalized = self._normalize_registry(registry) + for virtual_path, entry in normalized.items(): + target = self._resolve_workspace_path(virtual_path) + if target.exists() and self._sha256(target) == entry.sha256: + continue + target.parent.mkdir(parents=True, exist_ok=True) + await self.attachments.download_async( + key=UUID(entry.attachment_key), + destination_path=str(target), + folder_key=self.folder_key, + folder_path=self.folder_path, + ) + return self._dump_registry(normalized) + + async def dehydrate( + self, + registry: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Upload new/changed workspace files and return the merged registry. + + The result is rebuilt from the files currently present, so entries for + files deleted locally are dropped and not restored on the next hydrate. + """ + previous = self._normalize_registry(registry) + current: dict[str, AttachmentRegistryEntry] = {} + for file_path in self._iter_files(): + virtual_path = self._virtual_path(file_path) + digest = self._sha256(file_path) + size = file_path.stat().st_size + existing = previous.get(virtual_path) + + if existing and existing.sha256 == digest and existing.size == size: + current[virtual_path] = existing + if self.current_job_key: + await self.link_attachment(existing.attachment_key) + continue + + attachment_name = self._attachment_name_for_virtual_path(virtual_path) + attachment_key = await self.attachments.upload_async( + name=attachment_name, + source_path=str(file_path), + folder_key=self.folder_key, + folder_path=self.folder_path, + ) + entry = AttachmentRegistryEntry( + attachment_key=str(attachment_key), + sha256=digest, + size=size, + uploaded_at=datetime.now(timezone.utc).isoformat(), + attachment_name=attachment_name, + ) + current[virtual_path] = entry + + if self.current_job_key: + await self.link_attachment(entry.attachment_key) + + return self._dump_registry(current) + + async def link_attachment(self, attachment_key: str) -> None: + """Link an already uploaded attachment to the current job.""" + if self.jobs is None or not self.current_job_key: + return + + await self.jobs.link_attachment_async( + job_key=UUID(self.current_job_key), + attachment_key=UUID(attachment_key), + folder_key=self.folder_key, + folder_path=self.folder_path, + ) + + def _iter_files(self) -> list[Path]: + if not self.workspace_path.exists(): + return [] + + files: list[Path] = [] + for root, _, names in os.walk(self.workspace_path): + for name in names: + path = Path(root) / name + if path.is_file(): + files.append(path) + return sorted(files) + + def _resolve_workspace_path(self, virtual_path: str) -> Path: + path = (self.workspace_path / virtual_path).resolve() + workspace = self.workspace_path.resolve() + if workspace != path and workspace not in path.parents: + raise ValueError(f"Workspace path escapes root: {virtual_path}") + return path + + def _virtual_path(self, path: Path) -> str: + return path.relative_to(self.workspace_path).as_posix() + + @staticmethod + def _escape(value: str) -> str: + # json-pointer escaping; escape ~ before / so they can't collide + return value.replace("~", "~0").replace("/", "~1") + + @staticmethod + def _unescape(value: str) -> str: + return value.replace("~1", "/").replace("~0", "~") + + def _attachment_name_for_virtual_path(self, virtual_path: str) -> str: + # the platform mishandles "/" in attachment names, so keep it slash-free + return self._escape(f"{self.attachment_prefix}/{virtual_path}") + + def _virtual_path_from_attachment_name(self, name: str) -> str | None: + decoded = self._unescape(name) + prefix = f"{self.attachment_prefix}/" + if not decoded.startswith(prefix): + return None + virtual_path = decoded[len(prefix) :] + self._resolve_workspace_path(virtual_path) + return virtual_path + + def _normalize_registry( + self, registry: dict[str, dict[str, Any]] + ) -> dict[str, AttachmentRegistryEntry]: + normalized: dict[str, AttachmentRegistryEntry] = {} + for virtual_path, entry in registry.items(): + self._resolve_workspace_path(virtual_path) + normalized[virtual_path] = AttachmentRegistryEntry.model_validate(entry) + return normalized + + def _dump_registry( + self, registry: dict[str, AttachmentRegistryEntry] + ) -> dict[str, dict[str, Any]]: + return {path: entry.model_dump() for path, entry in registry.items()} + + def _sha256(self, path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as file: + for chunk in iter(lambda: file.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/uipath/runtime/workspace/registry_store.py b/src/uipath/runtime/workspace/registry_store.py new file mode 100644 index 00000000..d6e1eb49 --- /dev/null +++ b/src/uipath/runtime/workspace/registry_store.py @@ -0,0 +1,49 @@ +"""Storage-backed workspace attachment registry.""" + +from typing import Any + +from uipath.runtime.storage import UiPathRuntimeStorageProtocol + + +class WorkspaceRegistryStore: + """Stores workspace file attachment metadata in runtime storage.""" + + DEFAULT_NAMESPACE = "workspace" + DEFAULT_KEY = "attachments" + + def __init__( + self, + storage: UiPathRuntimeStorageProtocol, + runtime_id: str, + *, + namespace: str = DEFAULT_NAMESPACE, + key: str = DEFAULT_KEY, + ): + """Initialize the registry store.""" + self.storage = storage + self.runtime_id = runtime_id + self.namespace = namespace + self.key = key + + async def load(self) -> dict[str, dict[str, Any]]: + """Load registry entries keyed by workspace-relative path.""" + value = await self.storage.get_value(self.runtime_id, self.namespace, self.key) + if value is None: + return {} + if not isinstance(value, dict): + raise TypeError("Workspace registry payload must be a dictionary.") + + registry: dict[str, dict[str, Any]] = {} + for path, entry in value.items(): + if isinstance(path, str) and isinstance(entry, dict): + registry[path] = entry + return registry + + async def save(self, registry: dict[str, dict[str, Any]]) -> None: + """Persist registry entries.""" + await self.storage.set_value( + self.runtime_id, + self.namespace, + self.key, + registry, + ) diff --git a/src/uipath/runtime/workspace/workspace.py b/src/uipath/runtime/workspace/workspace.py new file mode 100644 index 00000000..6a1fb97f --- /dev/null +++ b/src/uipath/runtime/workspace/workspace.py @@ -0,0 +1,47 @@ +"""Disk workspace lifecycle helpers.""" + +import shutil +import tempfile +from pathlib import Path + + +class Workspace: + """Owns a directory used by a runtime to persist files between executions.""" + + def __init__(self, path: Path, *, cleanup: bool = True): + """Initialize the workspace. + + Args: + path: Workspace directory. + cleanup: Whether dispose should remove the directory. + """ + self.path = path + self.cleanup = cleanup + + @classmethod + def create( + cls, + path: str | Path | None = None, + *, + prefix: str = "uipath-workspace-", + cleanup: bool | None = None, + ) -> "Workspace": + """Create a workspace directory. + + ``cleanup`` defaults to True for an owned temp dir and False for a + supplied path; pass ``cleanup=True`` for a temp path you want disposed. + """ + if path is None: + workspace_path = Path(tempfile.mkdtemp(prefix=prefix)) + should_cleanup = True if cleanup is None else cleanup + else: + workspace_path = Path(path) + workspace_path.mkdir(parents=True, exist_ok=True) + should_cleanup = False if cleanup is None else cleanup + + return cls(workspace_path, cleanup=should_cleanup) + + async def dispose(self) -> None: + """Remove the workspace directory when configured to do so.""" + if self.cleanup and self.path.exists(): + shutil.rmtree(self.path) diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py new file mode 100644 index 00000000..c6a2b297 --- /dev/null +++ b/tests/workspace/test_workspace_hydration.py @@ -0,0 +1,483 @@ +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path +from typing import Any, AsyncGenerator + +import pytest + +from uipath.runtime import ( + HydrationPolicy, + HydrationRuntime, + UiPathExecuteOptions, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, +) +from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent +from uipath.runtime.schema import UiPathRuntimeSchema + + +class MemoryStorage: + def __init__(self) -> None: + self.values: dict[tuple[str, str, str], Any] = {} + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: Any + ) -> None: + self.values[(runtime_id, namespace, key)] = value + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + return self.values.get((runtime_id, namespace, key)) + + +class FakeAttachments: + def __init__(self) -> None: + self.files: dict[uuid.UUID, tuple[str, bytes]] = {} + self.uploads = 0 + self.downloads = 0 + + async def upload_async( + self, + *, + name: str, + content: str | bytes | None = None, + source_path: str | None = None, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> uuid.UUID: + assert source_path is not None + key = uuid.uuid4() + self.files[key] = (name, Path(source_path).read_bytes()) + self.uploads += 1 + return key + + async def download_async( + self, + *, + key: uuid.UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + name, content = self.files[key] + Path(destination_path).write_bytes(content) + self.downloads += 1 + return name + + +class FakeJobs: + def __init__(self) -> None: + self.attachments: dict[str, list[str]] = {} + self.links: list[tuple[uuid.UUID, uuid.UUID]] = [] + + async def list_attachments_async( + self, + *, + job_key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> list[str]: + return self.attachments.get(str(job_key), []) + + async def link_attachment_async( + self, + *, + job_key: uuid.UUID, + attachment_key: uuid.UUID, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> None: + self.links.append((job_key, attachment_key)) + + +class WritingRuntime: + def __init__(self, workspace_path: Path, status: UiPathRuntimeStatus) -> None: + self.workspace_path = workspace_path + self.status = status + self.disposed = False + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + (self.workspace_path / "notes.txt").write_text("hello", encoding="utf-8") + return UiPathRuntimeResult(status=self.status, output={"ok": True}) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + yield UiPathRuntimeStateEvent(payload={"started": True}) + result = await self.execute(input, options) + yield result + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError + + async def dispose(self) -> None: + self.disposed = True + + +@pytest.mark.asyncio +async def test_dehydrate_uploads_changed_files_and_saves_registry( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + jobs = FakeJobs() + current_job = uuid.uuid4() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + jobs=jobs, + current_job_key=str(current_job), + ) + storage = MemoryStorage() + store = WorkspaceRegistryStore(storage, "runtime-1") + runtime = HydrationRuntime( + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED), + workspace=workspace, + hydrator=hydrator, + registry_store=store, + ) + + result = await runtime.execute({}) + + registry = await store.load() + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert list(registry) == ["notes.txt"] + assert registry["notes.txt"]["attachment_name"] == ".uipath-workspace~1notes.txt" + assert attachments.uploads == 1 + assert len(jobs.links) == 1 + + +@pytest.mark.asyncio +async def test_successful_completion_persists_when_policy_allows( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + runtime = HydrationRuntime( + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + policy=HydrationPolicy.SUSPEND_OR_SUCCESS, + ) + + await runtime.execute({}) + + assert attachments.uploads == 1 + assert "notes.txt" in await runtime.registry_store.load() + + +@pytest.mark.asyncio +async def test_hydrate_downloads_missing_registry_files(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + key = uuid.uuid4() + attachments.files[key] = (".uipath-workspace/notes.txt", b"from attachment") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + registry = { + "notes.txt": { + "attachment_key": str(key), + "sha256": "different", + "size": 15, + "uploaded_at": "2026-01-01T00:00:00+00:00", + "attachment_name": ".uipath-workspace/notes.txt", + } + } + + await hydrator.hydrate(registry) + + assert (workspace.path / "notes.txt").read_text( + encoding="utf-8" + ) == "from attachment" + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +async def test_stream_persists_on_suspend(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + runtime = HydrationRuntime( + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + ) + + events = [event async for event in runtime.stream({})] + + assert isinstance(events[-1], UiPathRuntimeResult) + assert attachments.uploads == 1 + assert "notes.txt" in await runtime.registry_store.load() + + +@pytest.mark.asyncio +async def test_hydrate_skips_unchanged_local_file(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + (workspace.path / "notes.txt").write_text("same", encoding="utf-8") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + digest = hydrator._sha256(workspace.path / "notes.txt") + registry = { + "notes.txt": { + "attachment_key": str(uuid.uuid4()), + "sha256": digest, + "size": 4, + "uploaded_at": "", + "attachment_name": ".uipath-workspace~1notes.txt", + } + } + + await hydrator.hydrate(registry) + + assert attachments.downloads == 0 + + +@pytest.mark.asyncio +async def test_workspace_dispose_removes_temp_dir(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace", cleanup=True) + (workspace.path / "file.txt").write_text("x", encoding="utf-8") + + await workspace.dispose() + + assert not workspace.path.exists() + + +@pytest.mark.asyncio +async def test_workspace_dispose_keeps_owned_path_by_default(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + (workspace.path / "file.txt").write_text("x", encoding="utf-8") + + await workspace.dispose() + + assert workspace.path.exists() + shutil.rmtree(workspace.path) + + +@pytest.mark.asyncio +async def test_create_temp_workspace_is_cleaned_up_by_default() -> None: + workspace = Workspace.create() + + assert workspace.path.exists() + + await workspace.dispose() + + assert not workspace.path.exists() + + +@pytest.mark.asyncio +async def test_dehydrate_relinks_unchanged_file_without_reupload( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + jobs = FakeJobs() + current_job = uuid.uuid4() + key = uuid.uuid4() + (workspace.path / "notes.txt").write_text("same", encoding="utf-8") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + jobs=jobs, + current_job_key=str(current_job), + ) + prior = { + "notes.txt": { + "attachment_key": str(key), + "sha256": hydrator._sha256(workspace.path / "notes.txt"), + "size": 4, + "uploaded_at": "", + "attachment_name": ".uipath-workspace~1notes.txt", + } + } + + result = await hydrator.dehydrate(prior) + + assert attachments.uploads == 0 + assert result["notes.txt"]["attachment_key"] == str(key) + assert jobs.links == [(current_job, key)] + + +@pytest.mark.asyncio +async def test_attachment_names_are_single_segment_for_nested_files( + tmp_path: Path, +) -> None: + """Attachment names must stay slash-free (a "/" breaks the blob round-trip).""" + workspace = Workspace.create(tmp_path / "workspace") + (workspace.path / "plan").mkdir(parents=True, exist_ok=True) + (workspace.path / "plan" / "todo.md").write_text("step 1", encoding="utf-8") + attachments = FakeAttachments() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + + registry = await hydrator.dehydrate({}) + + assert "plan/todo.md" in registry + attachment_name = registry["plan/todo.md"]["attachment_name"] + assert "/" not in attachment_name + assert all("/" not in name for name, _ in attachments.files.values()) + assert ( + hydrator._virtual_path_from_attachment_name(attachment_name) == "plan/todo.md" + ) + + +@pytest.mark.asyncio +async def test_attachment_name_round_trips_special_characters(tmp_path: Path) -> None: + """The encoding must be reversible even for paths containing the escape char.""" + workspace = Workspace.create(tmp_path / "workspace") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ) + + for virtual_path in ["a~~b.txt", "plan/todo.md", "we~ird/a~~b/file.txt"]: + name = hydrator._attachment_name_for_virtual_path(virtual_path) + assert "/" not in name + assert hydrator._virtual_path_from_attachment_name(name) == virtual_path + + +@pytest.mark.asyncio +async def test_dehydrate_drops_files_deleted_locally(tmp_path: Path) -> None: + """A registry entry whose file no longer exists is not carried forward.""" + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + (workspace.path / "present.txt").write_text("hi", encoding="utf-8") + prior = { + "gone.txt": { + "attachment_key": str(uuid.uuid4()), + "sha256": "stale", + "size": 1, + "uploaded_at": "2026-01-01T00:00:00+00:00", + "attachment_name": ".uipath-workspace~1gone.txt", + } + } + + result = await hydrator.dehydrate(prior) + + assert "present.txt" in result + assert "gone.txt" not in result + + +class _WriteThenRaiseRuntime: + def __init__(self, workspace_path: Path) -> None: + self.workspace_path = workspace_path + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + (self.workspace_path / "partial.txt").write_text("wip", encoding="utf-8") + raise RuntimeError("boom") + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + await self.execute(input, options) + yield UiPathRuntimeStateEvent(payload={}) + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError + + async def dispose(self) -> None: + pass + + +@pytest.mark.asyncio +async def test_always_policy_persists_on_failure(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + runtime = HydrationRuntime( + _WriteThenRaiseRuntime(workspace.path), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + policy=HydrationPolicy.ALWAYS, + ) + + with pytest.raises(RuntimeError): + await runtime.execute({}) + + assert attachments.uploads == 1 + assert "partial.txt" in await runtime.registry_store.load() + + +@pytest.mark.asyncio +async def test_suspend_only_policy_skips_persist_on_failure(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + runtime = HydrationRuntime( + _WriteThenRaiseRuntime(workspace.path), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + policy=HydrationPolicy.SUSPEND_ONLY, + ) + + with pytest.raises(RuntimeError): + await runtime.execute({}) + + assert attachments.uploads == 0 + + +@pytest.mark.asyncio +async def test_always_policy_persists_on_stream_failure(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + runtime = HydrationRuntime( + _WriteThenRaiseRuntime(workspace.path), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + policy=HydrationPolicy.ALWAYS, + ) + + with pytest.raises(RuntimeError): + async for _ in runtime.stream({}): + pass + + assert attachments.uploads == 1 diff --git a/uv.lock b/uv.lock index f05b1f20..6b5443c2 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.11" [options] -exclude-newer = "2026-06-17T19:38:11.87942Z" +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -998,21 +998,21 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.17" +version = "0.5.22" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e3/80/a626eb3136a6765e0af06c9d5080ac0843c2a72f17b7a2170f1f45da40dd/uipath_core-0.5.17.tar.gz", hash = "sha256:13565e1eba9f059a8221494dfb3239257ddf7f265fc7057199ffe03ed066300a", size = 119023, upload-time = "2026-05-28T21:34:10.903Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/e0/1cdf0537ae1db831b066604e0e83132a2dd559371ac6e5d56e96b9039163/uipath_core-0.5.22.tar.gz", hash = "sha256:01ae7c3770369469acf5cef31908e8b878a5b1123f2d930f8537ea2d97d7d621", size = 136212, upload-time = "2026-06-23T16:18:43.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/cf/f4b481970621e2a9aec869302773fa2c7d346aef294a553429626369633f/uipath_core-0.5.17-py3-none-any.whl", hash = "sha256:6e088eec5130bc492ac176ab85d4924d7d4cb07ee290ed7e6a46984e9de8c12b", size = 44957, upload-time = "2026-05-28T21:34:09.534Z" }, + { url = "https://files.pythonhosted.org/packages/fb/97/2258d51969ec71b1056d67f39d612eac2d7c6e9458d3b3c9a0b10f42e730/uipath_core-0.5.22-py3-none-any.whl", hash = "sha256:60df655b207e02a6d3bfae8c61e1fc9bc0bf11576f7ead07b8b38f23d13fc4d6", size = 58222, upload-time = "2026-06-23T16:18:41.536Z" }, ] [[package]] name = "uipath-runtime" -version = "0.11.2" +version = "0.11.3" source = { editable = "." } dependencies = [ { name = "uipath-core" }, @@ -1034,7 +1034,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "uipath-core", specifier = ">=0.5.17,<0.6.0" }] +requires-dist = [{ name = "uipath-core", specifier = ">=0.5.22,<0.6.0" }] [package.metadata.requires-dev] dev = [ From 9b2abc64fdb0e02f2bac16dd183e3c308010a108 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 24 Jun 2026 16:37:22 +0300 Subject: [PATCH 09/34] feat(context): add execution_source derived from command Add UiPathRuntimeContext.execution_source, derived from the executing command (run -> runtime, debug/dev -> playground, eval -> eval) in with_defaults. This lets execution metadata flow through the existing context boundary instead of an out-of-band env var, so downstream platform calls (e.g. guardrails) can identify the run context and it stays correctly scoped in concurrent runs. Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 2 +- src/uipath/runtime/context.py | 21 ++++++++++++++++++ tests/test_context.py | 42 +++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1427ea4..ec055aa3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.3" +version = "0.11.4" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index c655a762..524d7e53 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -23,6 +23,15 @@ logger = logging.getLogger(__name__) +# Maps the executing command to an execution source. Commands that do not run +# an agent are absent, leaving execution_source unset. +_EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { + "run": "runtime", + "debug": "playground", + "dev": "playground", + "eval": "eval", +} + class UiPathRuntimeContext(BaseModel): """Context information passed throughout the runtime execution.""" @@ -31,6 +40,14 @@ class UiPathRuntimeContext(BaseModel): input: str | None = None resume: bool = False command: str | None = None + execution_source: str | None = Field( + None, + description=( + "Execution source derived from the command " + "(runtime/playground/eval). Propagated to platform clients so " + "downstream calls (e.g. guardrails) can identify the run context." + ), + ) job_id: str | None = None conversation_id: str | None = Field( None, description="Conversation identifier for CAS" @@ -345,6 +362,10 @@ def with_defaults( for k, v in kwargs.items(): setattr(base, k, v) + # Derive the execution source from the command unless explicitly set. + if base.execution_source is None and base.command is not None: + base.execution_source = _EXECUTION_SOURCE_BY_COMMAND.get(base.command) + return base @classmethod diff --git a/tests/test_context.py b/tests/test_context.py index d4868c7a..92482a76 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -318,3 +318,45 @@ def test_string_output_wrapped_in_dict() -> None: assert result_dict["output"] == {"output": "primitive str"} assert result_dict["status"] == UiPathRuntimeStatus.SUCCESSFUL + + +@pytest.mark.parametrize( + "command,expected", + [ + ("run", "runtime"), + ("debug", "playground"), + ("dev", "playground"), + ("eval", "eval"), + ], +) +def test_with_defaults_derives_execution_source( + command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """execution_source is derived from the command when not provided.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command=command) + + assert ctx.execution_source == expected + + +def test_with_defaults_execution_source_none_for_unmapped_command( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Commands that do not run an agent leave execution_source unset.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command="pack") + + assert ctx.execution_source is None + + +def test_with_defaults_explicit_execution_source_not_overwritten( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """An explicitly provided execution_source takes precedence over the command.""" + monkeypatch.chdir(tmp_path) + + ctx = UiPathRuntimeContext.with_defaults(command="run", execution_source="custom") + + assert ctx.execution_source == "custom" diff --git a/uv.lock b/uv.lock index 6b5443c2..0a07a221 100644 --- a/uv.lock +++ b/uv.lock @@ -1012,7 +1012,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.3" +version = "0.11.4" source = { editable = "." } dependencies = [ { name = "uipath-core" }, From 711246aa9e9cfc7c469cd419fd7b0e153093a076 Mon Sep 17 00:00:00 2001 From: Valentina Bojan Date: Wed, 24 Jun 2026 16:44:57 +0300 Subject: [PATCH 10/34] refactor(context): derive execution_source via model_validator Move derivation out of with_defaults into a model_validator so it fires on the plain-constructor path too (e.g. dev/init), and never assign None for unmapped commands so the field stays absent under model_dump(exclude_unset=True). with_defaults re-applies it since it mutates command via setattr after construction. Addresses Copilot review feedback on PR #132. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/context.py | 38 ++++++++++++++++++++++++----------- tests/test_context.py | 37 ++++++++++++++++++++++------------ 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 524d7e53..924548ca 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from uipath.core.errors import UiPathFaultedTriggerError from uipath.core.tracing import UiPathTraceManager @@ -23,8 +23,6 @@ logger = logging.getLogger(__name__) -# Maps the executing command to an execution source. Commands that do not run -# an agent are absent, leaving execution_source unset. _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", "debug": "playground", @@ -41,12 +39,7 @@ class UiPathRuntimeContext(BaseModel): resume: bool = False command: str | None = None execution_source: str | None = Field( - None, - description=( - "Execution source derived from the command " - "(runtime/playground/eval). Propagated to platform clients so " - "downstream calls (e.g. guardrails) can identify the run context." - ), + None, description="Execution source derived from the command." ) job_id: str | None = None conversation_id: str | None = Field( @@ -113,6 +106,28 @@ class UiPathRuntimeContext(BaseModel): model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") + def _apply_execution_source(self) -> None: + """Derive execution_source from the command, if not already set. + + Only assigns a mapped value, so the field stays unset (absent under + ``model_dump(exclude_unset=True)``) for unmapped commands, and an + explicitly-provided value is never overwritten. + """ + if self.execution_source is None and self.command is not None: + source = _EXECUTION_SOURCE_BY_COMMAND.get(self.command) + if source is not None: + self.execution_source = source + + @model_validator(mode="after") + def _derive_execution_source(self) -> "UiPathRuntimeContext": + """Derive execution_source on the constructor path (e.g. dev/init). + + ``with_defaults`` mutates ``command`` via ``setattr`` after construction, + so it re-applies the derivation itself. + """ + self._apply_execution_source() + return self + def get_input(self) -> dict[str, Any] | None: """Get parsed input data. @@ -362,9 +377,8 @@ def with_defaults( for k, v in kwargs.items(): setattr(base, k, v) - # Derive the execution source from the command unless explicitly set. - if base.execution_source is None and base.command is not None: - base.execution_source = _EXECUTION_SOURCE_BY_COMMAND.get(base.command) + # setattr does not re-run the validator, so derive explicitly. + base._apply_execution_source() return base diff --git a/tests/test_context.py b/tests/test_context.py index 92482a76..d3ed4aed 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -329,10 +329,24 @@ def test_string_output_wrapped_in_dict() -> None: ("eval", "eval"), ], ) +def test_constructor_derives_execution_source(command: str, expected: str) -> None: + """execution_source is derived from the command on the plain constructor path.""" + ctx = UiPathRuntimeContext(command=command) + + assert ctx.execution_source == expected + + +@pytest.mark.parametrize( + "command,expected", + [ + ("run", "runtime"), + ("eval", "eval"), + ], +) def test_with_defaults_derives_execution_source( command: str, expected: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """execution_source is derived from the command when not provided.""" + """execution_source is also derived via with_defaults.""" monkeypatch.chdir(tmp_path) ctx = UiPathRuntimeContext.with_defaults(command=command) @@ -340,23 +354,20 @@ def test_with_defaults_derives_execution_source( assert ctx.execution_source == expected -def test_with_defaults_execution_source_none_for_unmapped_command( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Commands that do not run an agent leave execution_source unset.""" - monkeypatch.chdir(tmp_path) +def test_execution_source_unset_for_unmapped_command() -> None: + """Commands that do not run an agent leave execution_source unset. - ctx = UiPathRuntimeContext.with_defaults(command="pack") + The field must remain unset (not explicitly None) so it is absent from + model_dump(exclude_unset=True). + """ + ctx = UiPathRuntimeContext(command="pack") assert ctx.execution_source is None + assert "execution_source" not in ctx.model_dump(exclude_unset=True) -def test_with_defaults_explicit_execution_source_not_overwritten( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: +def test_explicit_execution_source_not_overwritten() -> None: """An explicitly provided execution_source takes precedence over the command.""" - monkeypatch.chdir(tmp_path) - - ctx = UiPathRuntimeContext.with_defaults(command="run", execution_source="custom") + ctx = UiPathRuntimeContext(command="run", execution_source="custom") assert ctx.execution_source == "custom" From d68d3ccca0768bc1bdd1c8a593e7ae41fa866cd4 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Sat, 27 Jun 2026 23:08:25 +0300 Subject: [PATCH 11/34] fix: treat timer triggers as external resume triggers --- pyproject.toml | 4 +- src/uipath/runtime/debug/runtime.py | 7 +- src/uipath/runtime/resumable/runtime.py | 11 +++- tests/test_debugger.py | 88 +++++++++++++++++++++++++ tests/test_resumable.py | 41 ++++++++++++ uv.lock | 10 +-- 6 files changed, 148 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ec055aa3..2bf085cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-runtime" -version = "0.11.4" +version = "0.11.5" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.22,<0.6.0", + "uipath-core>=0.5.26,<0.6.0", ] classifiers = [ "Intended Audience :: Developers", diff --git a/src/uipath/runtime/debug/runtime.py b/src/uipath/runtime/debug/runtime.py index 6bd56e2c..a64606a4 100644 --- a/src/uipath/runtime/debug/runtime.py +++ b/src/uipath/runtime/debug/runtime.py @@ -194,9 +194,10 @@ async def _stream_and_debug( resume_data: dict[str, Any] | None = None try: trigger_data: dict[str, Any] | None = None - if ( - final_result.trigger.trigger_type - == UiPathResumeTriggerType.API + if final_result.trigger.trigger_type in ( + UiPathResumeTriggerType.API, + UiPathResumeTriggerType.INBOX, + UiPathResumeTriggerType.TIMER, ): trigger_data = ( await self.debug_bridge.wait_for_resume() diff --git a/src/uipath/runtime/resumable/runtime.py b/src/uipath/runtime/resumable/runtime.py index 93bc2e51..8cf2ed57 100644 --- a/src/uipath/runtime/resumable/runtime.py +++ b/src/uipath/runtime/resumable/runtime.py @@ -147,8 +147,9 @@ async def stream( async def _get_fired_triggers(self) -> dict[str, Any] | None: """Check stored triggers for any that have already fired. - Skips async-external triggers (API, Inbox) whose payloads only arrive - asynchronously and cannot be polled at suspend time. + Skips external triggers (API, Inbox, Timer) whose payloads only arrive + asynchronously or through Orchestrator resume and cannot be polled at + suspend time. Returns: A resume map of {interrupt_id: resume_data} for fired triggers, or None. @@ -161,7 +162,11 @@ async def _get_fired_triggers(self) -> dict[str, Any] | None: t for t in triggers if t.trigger_type - not in (UiPathResumeTriggerType.API, UiPathResumeTriggerType.INBOX) + not in ( + UiPathResumeTriggerType.API, + UiPathResumeTriggerType.INBOX, + UiPathResumeTriggerType.TIMER, + ) ] return await self._build_resume_map(pollable_triggers) diff --git a/tests/test_debugger.py b/tests/test_debugger.py index 739ccbd7..cd82d2e7 100644 --- a/tests/test_debugger.py +++ b/tests/test_debugger.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, Mock import pytest +from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType from uipath.runtime import ( UiPathBreakpointResult, @@ -131,6 +132,48 @@ async def get_schema(self) -> UiPathRuntimeSchema: raise NotImplementedError() +class SuspendedThenSuccessfulRuntime: + """Mock runtime that suspends once and completes after resume.""" + + def __init__(self, trigger: UiPathResumeTrigger) -> None: + self.trigger = trigger + self.inputs: list[dict[str, Any] | None] = [] + self.options: list[UiPathStreamOptions | None] = [] + + async def dispose(self) -> None: + pass + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + raise NotImplementedError() + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + self.inputs.append(input) + self.options.append(options) + + if options and options.resume: + yield UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"resumed_with": input}, + ) + return + + yield UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + trigger=self.trigger, + ) + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError() + + @pytest.mark.asyncio async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): """UiPathDebugRuntime should stream events, handle breakpoints and state updates.""" @@ -169,6 +212,51 @@ async def test_debug_runtime_streams_and_handles_breakpoints_and_state(): ) # initial + after breakpoint +@pytest.mark.asyncio +async def test_debug_runtime_waits_for_timer_resume_without_polling(): + """Timer triggers should wait for external resume in debug mode.""" + trigger = UiPathResumeTrigger( + interrupt_id="timer-interrupt", + trigger_type=UiPathResumeTriggerType.TIMER, + payload={"kind": "timeout"}, + ) + runtime_impl = SuspendedThenSuccessfulRuntime(trigger) + bridge = make_debug_bridge_mock() + cast(AsyncMock, bridge.wait_for_resume).side_effect = [ + None, + {"__uipath": {"kind": "timeout"}}, + ] + cast(Mock, bridge.get_breakpoints).return_value = [] + + trigger_manager = Mock() + trigger_manager.read_trigger = AsyncMock( + side_effect=AssertionError("Timer triggers must not be polled") + ) + + debug_runtime = UiPathDebugRuntime( + delegate=runtime_impl, + debug_bridge=bridge, + ) + debug_runtime.get_resumable_runtime = Mock( # type: ignore[method-assign] + return_value=Mock(trigger_manager=trigger_manager) + ) + + result = await debug_runtime.execute({}) + + assert result.status == UiPathRuntimeStatus.SUCCESSFUL + assert result.output == { + "resumed_with": { + "timer-interrupt": {"__uipath": {"kind": "timeout"}}, + }, + } + assert cast(AsyncMock, bridge.wait_for_resume).await_count == 2 + cast(AsyncMock, bridge.emit_execution_suspended).assert_awaited_once() + cast(AsyncMock, bridge.emit_execution_resumed).assert_awaited_once_with( + {"timer-interrupt": {"__uipath": {"kind": "timeout"}}} + ) + trigger_manager.read_trigger.assert_not_awaited() + + @pytest.mark.asyncio async def test_debug_runtime_falls_back_when_stream_not_supported(): """If runtime raises UiPathStreamNotSupportedError, we fall back to execute().""" diff --git a/tests/test_resumable.py b/tests/test_resumable.py index 59655f39..16d0c413 100644 --- a/tests/test_resumable.py +++ b/tests/test_resumable.py @@ -548,6 +548,47 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger: trigger_manager.read_trigger.assert_not_called() assert runtime_impl.execution_count == 1 + @pytest.mark.asyncio + async def test_resumable_skips_timer_triggers_on_auto_resume_check(self) -> None: + """Timer triggers should be skipped when checking for auto-resume.""" + + runtime_impl = MultiTriggerMockRuntime() + storage = StatefulStorageMock() + trigger_manager = make_trigger_manager_mock() + + def create_timer_trigger(data: dict[str, Any]) -> UiPathResumeTrigger: + return UiPathResumeTrigger( + interrupt_id="", # Will be set by resumable runtime + trigger_type=UiPathResumeTriggerType.TIMER, + payload=data, + ) + + trigger_manager.create_trigger = AsyncMock(side_effect=create_timer_trigger) # type: ignore[method-assign] + read_trigger_guard = AsyncMock( + side_effect=AssertionError( + "read_trigger must not be called for Timer triggers pre-resume" + ) + ) + trigger_manager.read_trigger = read_trigger_guard # type: ignore[method-assign] + + resumable = UiPathResumableRuntime( + delegate=runtime_impl, + storage=storage, + trigger_manager=trigger_manager, + runtime_id="runtime-1", + ) + + result = await resumable.execute({}) + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert result.triggers is not None + assert len(result.triggers) == 2 + assert all( + t.trigger_type == UiPathResumeTriggerType.TIMER for t in result.triggers + ) + trigger_manager.read_trigger.assert_not_called() + assert runtime_impl.execution_count == 1 + @pytest.mark.asyncio async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers( self, diff --git a/uv.lock b/uv.lock index 0a07a221..2aaded66 100644 --- a/uv.lock +++ b/uv.lock @@ -998,21 +998,21 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.22" +version = "0.5.26" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/e0/1cdf0537ae1db831b066604e0e83132a2dd559371ac6e5d56e96b9039163/uipath_core-0.5.22.tar.gz", hash = "sha256:01ae7c3770369469acf5cef31908e8b878a5b1123f2d930f8537ea2d97d7d621", size = 136212, upload-time = "2026-06-23T16:18:43.081Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/9d/f2fd705cbe404e53150ebee4a7208df158ea0e307ade455dc2a3ee16fd12/uipath_core-0.5.26.tar.gz", hash = "sha256:c34f1d7bc823e4a45b8e21ae590d74b6ae9e2caab839c855e376c5f38ffa3e29", size = 130421, upload-time = "2026-06-29T15:43:31.882Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/97/2258d51969ec71b1056d67f39d612eac2d7c6e9458d3b3c9a0b10f42e730/uipath_core-0.5.22-py3-none-any.whl", hash = "sha256:60df655b207e02a6d3bfae8c61e1fc9bc0bf11576f7ead07b8b38f23d13fc4d6", size = 58222, upload-time = "2026-06-23T16:18:41.536Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/b0947b61fe5b45a8642774a02bffa23371c38556fe877b37c8313bbea5f7/uipath_core-0.5.26-py3-none-any.whl", hash = "sha256:ad81607910ccf567721e31c2de557e100a46228f7e1277cfe37d71f6d472b06a", size = 54787, upload-time = "2026-06-29T15:43:30.642Z" }, ] [[package]] name = "uipath-runtime" -version = "0.11.4" +version = "0.11.5" source = { editable = "." } dependencies = [ { name = "uipath-core" }, @@ -1034,7 +1034,7 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "uipath-core", specifier = ">=0.5.22,<0.6.0" }] +requires-dist = [{ name = "uipath-core", specifier = ">=0.5.26,<0.6.0" }] [package.metadata.requires-dev] dev = [ From 58b2cbae8f29765e0bdbb3daf23c3d7085be1edf Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:55:40 -0400 Subject: [PATCH 12/34] fix: allow maestro flow voice mode --- src/uipath/runtime/context.py | 2 +- tests/test_context.py | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 924548ca..4a45850c 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -55,7 +55,7 @@ class UiPathRuntimeContext(BaseModel): None, description="Conversation owner id for CAS (a real cloud user id or a synthetic user id)", ) - voice_mode: Literal["session"] | None = Field( + voice_mode: Literal["session", "maestro_flow"] | None = Field( None, description="Voice job type for CAS" ) mcp_server_id: str | None = None diff --git a/tests/test_context.py b/tests/test_context.py index d3ed4aed..d9c0d8c0 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from pydantic import ValidationError from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError from uipath.runtime.context import UiPathRuntimeContext @@ -371,3 +372,29 @@ def test_explicit_execution_source_not_overwritten() -> None: ctx = UiPathRuntimeContext(command="run", execution_source="custom") assert ctx.execution_source == "custom" + + +@pytest.mark.parametrize("voice_mode", ["session", "maestro_flow"]) +def test_constructor_accepts_supported_voice_modes(voice_mode: str) -> None: + ctx = UiPathRuntimeContext(voice_mode=voice_mode) + + assert ctx.voice_mode == voice_mode + + +@pytest.mark.parametrize("voice_mode", ["session", "maestro_flow"]) +def test_from_config_accepts_supported_voice_modes( + voice_mode: str, tmp_path: Path +) -> None: + config_path = tmp_path / "uipath.json" + config_path.write_text( + json.dumps({"fpsProperties": {"voice.mode": voice_mode}}) + ) + + ctx = UiPathRuntimeContext.from_config(str(config_path)) + + assert ctx.voice_mode == voice_mode + + +def test_constructor_rejects_unknown_voice_mode() -> None: + with pytest.raises(ValidationError): + UiPathRuntimeContext(voice_mode="chat") From 0a3e28f9562feeac8014e4bbc4124d4311e2ebf1 Mon Sep 17 00:00:00 2001 From: Aditi Kumari Date: Fri, 12 Jun 2026 16:27:47 +0530 Subject: [PATCH 13/34] feat(governance): support governance enforcement-mode config, policy models, and dependencies Co-Authored-By: Claude Opus 4.8 --- pyproject.toml | 6 +- .../runtime/governance/_audit/__init__.py | 12 + src/uipath/runtime/governance/_audit/base.py | 639 +++++++++ .../runtime/governance/_audit/factory.py | 33 + .../runtime/governance/_audit/metadata.py | 69 + .../runtime/governance/_audit/traces.py | 287 ++++ .../runtime/governance/_audit/track_events.py | 329 +++++ .../runtime/governance/native/__init__.py | 40 + .../runtime/governance/native/evaluator.py | 1188 +++++++++++++++++ .../native/guardrail_compensation.py | 183 +++ .../runtime/governance/native/models.py | 162 +++ src/uipath/runtime/governance/runtime.py | 301 +++++ tests/conftest.py | 6 + tests/test_audit_factory.py | 50 + tests/test_audit_manager_lifecycle.py | 207 +++ .../test_audit_manager_track_event_wiring.py | 178 +++ tests/test_audit_register_sink.py | 108 ++ tests/test_commitment_concern.py | 200 +++ tests/test_evaluator.py | 407 ++++++ tests/test_evaluator_operators.py | 688 ++++++++++ tests/test_evaluator_telemetry.py | 289 ++++ tests/test_governance_metadata.py | 81 ++ tests/test_governance_runtime.py | 630 +++++++++ tests/test_guardrail_compensation.py | 298 +++++ tests/test_traces_severity.py | 472 +++++++ tests/test_track_events_sink.py | 416 ++++++ uv.lock | 178 ++- 27 files changed, 7450 insertions(+), 7 deletions(-) create mode 100644 src/uipath/runtime/governance/_audit/__init__.py create mode 100644 src/uipath/runtime/governance/_audit/base.py create mode 100644 src/uipath/runtime/governance/_audit/factory.py create mode 100644 src/uipath/runtime/governance/_audit/metadata.py create mode 100644 src/uipath/runtime/governance/_audit/traces.py create mode 100644 src/uipath/runtime/governance/_audit/track_events.py create mode 100644 src/uipath/runtime/governance/native/__init__.py create mode 100644 src/uipath/runtime/governance/native/evaluator.py create mode 100644 src/uipath/runtime/governance/native/guardrail_compensation.py create mode 100644 src/uipath/runtime/governance/native/models.py create mode 100644 src/uipath/runtime/governance/runtime.py create mode 100644 tests/test_audit_factory.py create mode 100644 tests/test_audit_manager_lifecycle.py create mode 100644 tests/test_audit_manager_track_event_wiring.py create mode 100644 tests/test_audit_register_sink.py create mode 100644 tests/test_commitment_concern.py create mode 100644 tests/test_evaluator.py create mode 100644 tests/test_evaluator_operators.py create mode 100644 tests/test_evaluator_telemetry.py create mode 100644 tests/test_governance_metadata.py create mode 100644 tests/test_governance_runtime.py create mode 100644 tests/test_guardrail_compensation.py create mode 100644 tests/test_traces_severity.py create mode 100644 tests/test_track_events_sink.py diff --git a/pyproject.toml b/pyproject.toml index 2bf085cb..5f35dece 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,13 @@ [project] name = "uipath-runtime" -version = "0.11.5" +version = "0.11.6" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.26,<0.6.0", + "uipath-core>=0.5.28, <0.6.0", + "vaderSentiment>=3.3.2, <4.0", + "chardet>=5.2.0, <8.0", ] classifiers = [ "Intended Audience :: Developers", diff --git a/src/uipath/runtime/governance/_audit/__init__.py b/src/uipath/runtime/governance/_audit/__init__.py new file mode 100644 index 00000000..ce109ef8 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/__init__.py @@ -0,0 +1,12 @@ +"""Audit sink framework for governance events. + +Internal module. Provides a pluggable audit system that emits +governance events to one or more sinks. The built-in +:class:`TracesAuditSink` emits OpenTelemetry spans and is always +registered by every :class:`AuditManager` — it carries the governance +audit trail and cannot be disabled by application code. + +Callers import from the submodules directly (``_audit.base``, +``_audit.traces``, ``_audit.factory``). This package exposes no +aggregated symbols. +""" diff --git a/src/uipath/runtime/governance/_audit/base.py b/src/uipath/runtime/governance/_audit/base.py new file mode 100644 index 00000000..7f002712 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/base.py @@ -0,0 +1,639 @@ +"""Base classes and models for the audit sink framework. + +This module provides the core abstractions for the governance audit system: +- AuditEvent: The data model for audit events +- EventType: Constants for common event types +- AuditSink: Abstract base class for sink implementations +- AuditManager: Central hub for routing events to sinks + +Sink dispatch is synchronous on the caller's thread. Sinks that need +async export (HTTP, batched I/O) own that concern internally — the +OTel traces sink rides on opentelemetry-sdk's BatchSpanProcessor, +which handles export off the caller's thread. +""" + +from __future__ import annotations + +import json +import logging +import threading +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any, Callable + +from uipath.core.governance import EnforcementMode + +from .metadata import GovernanceRuntimeMetadata + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Audit Event Model +# ============================================================================= + + +@dataclass +class AuditEvent: + """Generic audit event that can be sent to any sink. + + Trace correlation is intentionally absent from this dataclass. + Sinks that need a trace id resolve one at their own boundary: + OTel-backed sinks read the live span from the caller's + ``contextvars`` directly (sink dispatch runs synchronously on the + caller's thread, so ``trace.get_current_span()`` resolves to the + agent's live span), and HTTP sinks defer to their injected + provider, which resolves at HTTP-call time. + + Attributes: + event_type: Type of event (e.g., "rule_evaluation", "hook_summary") + timestamp: When the event occurred (auto-set if not provided) + agent_name: Name of the agent being governed + hook: Lifecycle hook where event occurred (optional) + data: Event-specific data dictionary + metadata: Additional metadata for filtering/routing + """ + + event_type: str + agent_name: str = "unknown" + hook: str = "" + data: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for serialization.""" + result = asdict(self) + result["timestamp"] = self.timestamp.isoformat() + return result + + def to_json(self) -> str: + """Convert to JSON string.""" + return json.dumps(self.to_dict()) + + +class EventType: + """Constants for common event types.""" + + RULE_EVALUATION = "rule_evaluation" + HOOK_START = "hook_start" + HOOK_END = "hook_end" + SESSION_START = "session_start" + SESSION_END = "session_end" + POLICY_VIOLATION = "policy_violation" + POLICY_ALLOW = "policy_allow" + PACKS_LOADED = "packs_loaded" + + +# ============================================================================= +# Audit Sink Base Class +# ============================================================================= + + +class AuditSink(ABC): + """Abstract base class for audit output destinations. + + Subclass this to create custom audit sinks. Each sink receives + all audit events and decides how to handle them. + + Sinks that perform network I/O should batch internally — :meth:`emit` + runs on the caller's thread (typically an agent hook), so a slow + synchronous sink blocks the agent. The standard pattern is the one + opentelemetry-sdk uses for its trace exporter: enqueue in-process, + drain on a sink-owned background thread. + + Example: + A Slack sink that posts on rule denials. ``emit`` enqueues onto + an in-process queue; a daemon thread the sink owns drains the + queue and runs the HTTP POST off the caller's thread. + + class SlackAuditSink(AuditSink): + def __init__(self, webhook_url: str): + self.webhook_url = webhook_url + self._name = "slack" + self._queue: queue.Queue[AuditEvent | None] = queue.Queue() + self._worker = threading.Thread( + target=self._drain, name="slack-audit", daemon=True + ) + self._worker.start() + + @property + def name(self) -> str: + return self._name + + def accepts(self, event: AuditEvent) -> bool: + # Only ship denials — drops irrelevant events at the + # boundary instead of forwarding them to the queue. + return ( + event.data.get("matched") + and event.data.get("action") == "deny" + ) + + def emit(self, event: AuditEvent) -> None: + # Non-blocking — runs on the caller's hook thread. + self._queue.put_nowait(event) + + def _drain(self) -> None: + while True: + event = self._queue.get() + if event is None: + return # close() sentinel + try: + requests.post(self.webhook_url, json=event.to_dict()) + except Exception: + pass # log/retry per sink's own policy + finally: + self._queue.task_done() + + def flush(self) -> None: + self._queue.join() + + def close(self) -> None: + self._queue.put_nowait(None) + self._worker.join(timeout=2.0) + """ + + @property + @abstractmethod + def name(self) -> str: + """Unique name for this sink.""" + pass + + @abstractmethod + def emit(self, event: AuditEvent) -> None: + """Emit an audit event to this sink. + + Args: + event: The audit event to emit + + Note: + Implementations should handle errors gracefully and not + raise exceptions that would disrupt governance evaluation. + """ + pass + + def flush(self) -> None: # noqa: B027 — optional hook; subclasses override + """Flush any buffered events. + + Override if sink buffers events before writing. + """ + + def close(self) -> None: # noqa: B027 — optional hook; subclasses override + """Clean up resources. + + Override if sink holds resources that need cleanup. + """ + + def accepts(self, event: AuditEvent) -> bool: + """Check if this sink should receive the event. + + Override to filter events. Default accepts all events. + + Args: + event: The audit event to check + + Returns: + True if sink should receive event, False to skip + """ + return True + + +# ============================================================================= +# Audit Manager +# ============================================================================= + + +class AuditManager: + """Manages multiple audit sinks and routes events to them. + + Instance-scoped: each :class:`GovernanceRuntime` owns its own + manager. Parallel runtimes (``uipath eval``) don't share sinks or + per-sink failure state. + + Constructor automatically registers the always-on ``traces`` sink, + which carries the governance audit trail and cannot be disabled by + application code. Additional sinks can be added via + :meth:`register_sink`. + + Thread Safety: + :meth:`emit` dispatches synchronously on the caller's thread. + Sinks that need to avoid blocking the caller (HTTP exporters) + own their own batching — the OTel traces sink, for example, + rides on opentelemetry-sdk's BatchSpanProcessor. + """ + + # Trip a sink after this many consecutive emit failures (circuit-breaker). + _SINK_FAILURE_THRESHOLD = 10 + + def __init__( + self, + register_default_sinks: bool = True, + *, + track_event: Callable[..., None] | None = None, + runtime_metadata: GovernanceRuntimeMetadata | None = None, + ) -> None: + """Initialize the audit manager. + + Args: + register_default_sinks: If True (default), register the + always-on ``traces`` and platform-mandated + ``track_events`` sinks. Tests that want a bare manager + can pass ``False`` and register sinks explicitly. + track_event: Platform-supplied telemetry callable wired by + the host. When ``None`` (or any sink-construction + error), the ``track_events`` sink is skipped and a + warning is logged — the runtime continues so a wiring + bug never breaks the agent run. + runtime_metadata: Constants stamped on every telemetry + event (execution engine, agent type, agent framework, + runtime version). Defaults to + :class:`GovernanceRuntimeMetadata` () — auto-resolved + version + ``unknown`` agent type / framework. The host + overrides with concrete values. + """ + self._sinks: list[AuditSink] = [] + # Guards _sinks, _sink_failures, _tripped_sinks — all read + + # mutated by emit() across threads when concurrent agent hooks + # share one manager. + self._sinks_lock = threading.Lock() + # Per-sink consecutive-failure counter, keyed by sink name. + self._sink_failures: dict[str, int] = {} + self._tripped_sinks: set[str] = set() + + if register_default_sinks: + self._register_traces_sink() + self._register_track_event_sink(track_event, runtime_metadata) + + def _register_traces_sink(self) -> None: + """Register the always-on ``traces`` sink. + + Registered for every manager and cannot be disabled by + application code — it carries the governance audit trail. The + factory import is deferred to avoid a module-load cycle + (``factory`` imports back into this module). + """ + from .factory import create_sink + + sink = create_sink("traces") + if sink is not None: + self.register_sink(sink) + logger.info("Governance audit sink registered: traces") + + def _register_track_event_sink( + self, + track_event: Callable[..., None] | None, + runtime_metadata: GovernanceRuntimeMetadata | None, + ) -> None: + """Register the platform-mandated ``track_events`` sink. + + Mirrors :meth:`_register_traces_sink`: deferred import, + construct, register, log. The sink is expected to be wired by + the host's platform layer. + + Wrapped in a broad ``except`` so a misconfigured wiring layer + (missing callable, sink construction error) never crashes the + agent — the runtime logs and proceeds without the sink. The + sink-level circuit breaker handles per-emit failures + separately. + """ + try: + from .track_events import TrackEventAuditSink + + if track_event is None: + raise ValueError( + "Platform-mandated track_event callable was not supplied; " + "the host wiring layer must pass it to AuditManager(...)." + ) + meta = ( + runtime_metadata + if runtime_metadata is not None + else GovernanceRuntimeMetadata() + ) + sink = TrackEventAuditSink(track_event, meta) + self.register_sink(sink) + logger.info("Governance audit sink registered: track_events") + except Exception as exc: # noqa: BLE001 - registration must not crash the agent + # ``str(exc)`` instead of passing ``exc`` directly: the + # logging LogRecord retains its ``args`` tuple until the + # handler formats the record, and a raw exception there + # carries its ``__traceback__`` → frame chain → ``self``, + # which would keep the AuditManager alive in any + # log-capturing test. Stringifying breaks that ref. + logger.warning("Failed to register track_events sink: %s", str(exc)) + + def register_sink(self, sink: AuditSink) -> None: + """Register an audit sink. + + Args: + sink: The sink to register + + Note: + Duplicate sinks (same name) are ignored. + The circuit-breaker failure counter is cleared so a freshly + registered sink doesn't inherit a previous instance's tripped + state. ``unregister_sink`` already clears these, but the + defensive reset here guards against external manipulation + of the internal counters (tests, future callers). + """ + with self._sinks_lock: + if any(s.name == sink.name for s in self._sinks): + logger.debug("Sink '%s' already registered, skipping", sink.name) + return + self._sinks.append(sink) + self._sink_failures.pop(sink.name, None) + self._tripped_sinks.discard(sink.name) + logger.info("Registered audit sink: %s", sink.name) + + def unregister_sink(self, name: str) -> bool: + """Unregister an audit sink by name. + + Args: + name: Name of the sink to remove + + Returns: + True if sink was removed, False if not found + """ + sink_to_close: AuditSink | None = None + with self._sinks_lock: + for i, sink in enumerate(self._sinks): + if sink.name == name: + sink_to_close = sink + del self._sinks[i] + self._sink_failures.pop(name, None) + self._tripped_sinks.discard(name) + break + if sink_to_close is not None: + try: + sink_to_close.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", name, e) + logger.info("Unregistered audit sink: %s", name) + return True + return False + + def get_sink(self, name: str) -> AuditSink | None: + """Get a registered sink by name.""" + with self._sinks_lock: + for sink in self._sinks: + if sink.name == name: + return sink + return None + + def list_sinks(self) -> list[str]: + """Get names of all registered sinks.""" + with self._sinks_lock: + return [s.name for s in self._sinks] + + def emit(self, event: AuditEvent) -> None: + """Dispatch ``event`` synchronously to every live sink. + + Per-sink errors are caught and folded into the circuit breaker + — a sink that fails too many times in a row is skipped for the + rest of the manager's lifetime. The caller never sees a sink + exception. + + Args: + event: The audit event to emit + """ + with self._sinks_lock: + sinks = list(self._sinks) + tripped = set(self._tripped_sinks) + for sink in sinks: + if sink.name in tripped: + continue + try: + if sink.accepts(event): + sink.emit(event) + # Success — reset failure counter for this sink. + with self._sinks_lock: + if self._sink_failures.get(sink.name): + self._sink_failures[sink.name] = 0 + except Exception as exc: # noqa: BLE001 — swallow to guard the agent + self._record_sink_failure(sink.name, exc) + + def _record_sink_failure(self, sink_name: str, exc: Exception) -> None: + """Bump the circuit-breaker counter for ``sink_name`` and log. + + Extracted from :meth:`emit` to keep that method's cognitive + complexity below the linter's threshold. The lock-guarded + counter mutation and the branch between "still under threshold" + vs "just tripped" logging live here as a single responsibility. + """ + with self._sinks_lock: + fails = self._sink_failures.get(sink_name, 0) + 1 + self._sink_failures[sink_name] = fails + tripped_now = fails >= self._SINK_FAILURE_THRESHOLD + if tripped_now: + self._tripped_sinks.add(sink_name) + if tripped_now: + # ``logger.exception`` captures the traceback via + # ``exc_info`` — no need to interpolate ``exc`` into + # the message (Sonar S8572). + logger.exception( + "Audit sink '%s' tripped after %d consecutive failures; " + "will be skipped for the rest of this process.", + sink_name, + fails, + ) + else: + logger.warning( + "Audit sink '%s' failed to emit event (%d/%d): %s", + sink_name, + fails, + self._SINK_FAILURE_THRESHOLD, + exc, + ) + + def emit_rule_evaluation( + self, + policy_id: str, + rule_name: str, + pack_name: str, + hook: str, + matched: bool, + action: str, + enforcement_mode: EnforcementMode, + detail: str = "", + agent_name: str = "agent", + description: str = "", + duration_ms: float = 0.0, + mapped_to_uipath: bool = False, + ) -> None: + """Convenience method to emit a rule evaluation event. + + ``enforcement_mode`` travels on the event so sinks don't have to + read a process-global. Each emitter (instance-scoped) supplies + its own mode — parallel runtimes can run in different modes + simultaneously, and a process-global wouldn't be authoritative + for any of them. + + ``duration_ms`` and ``mapped_to_uipath`` are stamped on the + event's data dict for telemetry sinks (e.g. + :class:`TrackEventAuditSink`); the OTel traces sink ignores them. + """ + self.emit( + AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name=agent_name, + hook=hook, + data={ + "policy_id": policy_id, + "rule_name": rule_name, + "pack_name": pack_name, + "matched": matched, + "action": action, + "enforcement_mode": enforcement_mode, + "detail": detail, + "description": description, + "status": "MATCHED" if matched else "PASS", + "duration_ms": duration_ms, + "mapped_to_uipath": mapped_to_uipath, + }, + ) + ) + + def emit_hook_summary( + self, + hook: str, + agent_name: str, + total_rules: int, + matched_rules: int, + final_action: str, + enforcement_mode: EnforcementMode, + duration_ms: float = 0.0, + skipped_policy_names: list[str] | None = None, + guardrail_dispatched_count: int = 0, + denied_count: int | None = None, + ) -> None: + """Convenience method to emit a hook summary event. + + ``matched_rules`` keeps its historical meaning — any rule whose + checks matched, regardless of the configured action — for + backward compatibility with existing sinks. The newer + ``denied_count`` captures only the rules the evaluator actually + wanted to act on (matched **and** configured action ≠ + ``allow``). A matched rule whose action is ``allow`` is a + positive informational match and is folded into + ``passed_count``, not ``denied_count``. + + Args: + duration_ms: Total wall time spent evaluating this hook. + skipped_policy_names: Rules in the pack that were not + evaluated (currently disabled). The summary carries + their ids so operators can spot which policies a tenant + turned off. + guardrail_dispatched_count: How many UiPath-mapped + guardrail-fallback rules fired for this hook and were + handed to the compensating path. Lets dashboards + compute the native-vs-dispatched ratio. + denied_count: Rules that matched **and** would have acted + (action ∈ {``deny``, ``escalate``, ``audit``}). When + ``None`` (legacy callers), falls back to + ``matched_rules`` so the old "matched == denial" + semantic is preserved. + """ + skipped = list(skipped_policy_names or []) + actual_denied = denied_count if denied_count is not None else matched_rules + passed_count = max(total_rules - actual_denied, 0) + self.emit( + AuditEvent( + event_type=EventType.HOOK_END, + agent_name=agent_name, + hook=hook, + data={ + "total_rules": total_rules, + "matched_rules": matched_rules, + "final_action": final_action, + "enforcement_mode": enforcement_mode, + "duration_ms": duration_ms, + "passed_count": passed_count, + "denied_count": actual_denied, + "skipped_count": len(skipped), + "skipped_policy_names": skipped, + "guardrail_dispatched_count": guardrail_dispatched_count, + }, + ) + ) + + def emit_session_start( + self, + session_id: str, + agent_name: str, + packs: list[str], + enforcement_mode: EnforcementMode, + ) -> None: + """Convenience method to emit a session start event. + + Same ``enforcement_mode: EnforcementMode`` contract as + :meth:`emit_rule_evaluation` and :meth:`emit_hook_summary` + — every governance event carries the emitter's per-instance + mode so sinks don't depend on a process-global. + """ + self.emit( + AuditEvent( + event_type=EventType.SESSION_START, + agent_name=agent_name, + data={ + "session_id": session_id, + "packs": packs, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def emit_session_end( + self, + session_id: str, + agent_name: str, + total_evaluations: int, + rules_matched: int, + rules_denied: int, + enforcement_mode: EnforcementMode, + ) -> None: + """Convenience method to emit a session end event.""" + self.emit( + AuditEvent( + event_type=EventType.SESSION_END, + agent_name=agent_name, + data={ + "session_id": session_id, + "total_evaluations": total_evaluations, + "rules_matched": rules_matched, + "rules_denied": rules_denied, + "enforcement_mode": enforcement_mode, + }, + ) + ) + + def flush(self) -> None: + """Flush every registered sink. + + Per-sink — a sink that maintains its own buffer (OTel batched + export, HTTP batcher, etc.) gets a chance to drain. The + manager itself holds no queue. + """ + with self._sinks_lock: + sinks = list(self._sinks) + for sink in sinks: + try: + sink.flush() + except Exception as e: + logger.warning("Audit sink '%s' failed to flush: %s", sink.name, e) + + def close(self) -> None: + """Close all sinks and release resources. + + Idempotent — a manager that has already been closed has an + empty sink list, so a repeat call is a no-op. + """ + with self._sinks_lock: + sinks = list(self._sinks) + self._sinks.clear() + self._sink_failures.clear() + self._tripped_sinks.clear() + for sink in sinks: + try: + sink.close() + except Exception as e: + logger.warning("Audit sink '%s' failed to close: %s", sink.name, e) diff --git a/src/uipath/runtime/governance/_audit/factory.py b/src/uipath/runtime/governance/_audit/factory.py new file mode 100644 index 00000000..334f8678 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/factory.py @@ -0,0 +1,33 @@ +"""Factory function for creating audit sinks by name. + +Used by :class:`AuditManager` to construct the always-on ``traces`` +sink at initialization. +""" + +from __future__ import annotations + +import logging + +from .base import AuditSink + +logger = logging.getLogger(__name__) + + +def create_sink(name: str) -> AuditSink | None: + """Create an audit sink by name. + + Args: + name: Name of the sink to create (currently only ``traces``). + + Returns: + The created sink, or ``None`` if the name is unknown. + """ + name = name.lower() + + if name == "traces": + from .traces import TracesAuditSink + + return TracesAuditSink() + + logger.warning("Unknown audit sink: %s", name) + return None diff --git a/src/uipath/runtime/governance/_audit/metadata.py b/src/uipath/runtime/governance/_audit/metadata.py new file mode 100644 index 00000000..068a8a67 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/metadata.py @@ -0,0 +1,69 @@ +"""Per-runtime metadata stamped on every governance telemetry event. + +The host constructs a :class:`GovernanceRuntimeMetadata` once per +agent run and passes it to the telemetry sink. Every event produced +by :class:`TrackEventAuditSink` carries these fields so downstream +consumers can pivot on engine / agent type / framework / runtime +version. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from importlib.metadata import PackageNotFoundError, version + +NATIVE_EXECUTION_ENGINE = "uipath_native_governance_checker" + + +def _resolve_runtime_version() -> str: + """Read the ``uipath-runtime`` package version, or ``"unknown"``. + + ``importlib.metadata.version`` fails when the package is imported + from a source checkout that was never installed (CI fixtures, + editable installs with stripped metadata). Telemetry must keep + flowing in those cases, so the fallback is a sentinel rather than + a raise. + """ + try: + return version("uipath-runtime") + except PackageNotFoundError: + return "unknown" + + +@dataclass(frozen=True) +class GovernanceRuntimeMetadata: + """Constants stamped on every governance telemetry event. + + Attributes: + execution_engine: Implementation behind the evaluator. Default + ``"uipath_native_governance_checker"``. When a future engine + (e.g. AGT) replaces the native checker, the host supplies + its own identifier here so the emitted event records which + engine produced the verdict. + agent_type: Category of agent under governance — e.g. + ``"uipath_coded"``, ``"uipath_lowcode"``, ``"servicenow"``, + or any other identifier the host wants to attach. External + agents (ServiceNow, etc.) join this taxonomy when they + land. ``"unknown"`` keeps telemetry flowing if the host + forgets to set it. + agent_framework: Framework that drives the agent — e.g. + ``"langchain"``, ``"openai_agents"``, ``"llamaindex"``, + ``"google_adk"``, ``"agent_framework"``, ``"mcp"``. + runtime_version: ``uipath-runtime`` package version. Resolved + from installed package metadata at construction; falls + back to ``"unknown"`` for source checkouts. + """ + + execution_engine: str = NATIVE_EXECUTION_ENGINE + agent_type: str = "unknown" + agent_framework: str = "unknown" + runtime_version: str = field(default_factory=_resolve_runtime_version) + + def as_payload(self) -> dict[str, str]: + """Return the metadata as a dict ready to merge into an event payload.""" + return { + "execution_engine": self.execution_engine, + "agent_type": self.agent_type, + "agent_framework": self.agent_framework, + "runtime_version": self.runtime_version, + } diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py new file mode 100644 index 00000000..1c76e437 --- /dev/null +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -0,0 +1,287 @@ +"""OpenTelemetry traces audit sink for governance events. + +Emits an OpenTelemetry span per rule evaluation and per hook summary. +This sink emits spans only — it does not resolve or stamp +job-execution metadata (organization, tenant, folder, job, trace id) +onto them. That resolution is owned by the platform-side OTel +exporter that ships spans downstream, so the runtime governance +contract stays scoped to span emission. +""" + +from __future__ import annotations + +import importlib.metadata +import logging +from typing import Any + +from uipath.core.governance import EnforcementMode + +from .base import AuditEvent, AuditSink, EventType + +logger = logging.getLogger(__name__) + + +def _package_version() -> str: + """Return the installed ``uipath-runtime`` version (``unknown`` if absent).""" + try: + return importlib.metadata.version("uipath-runtime") + except importlib.metadata.PackageNotFoundError: + return "unknown" + + +# Stamped on every governance span as ``uipath_governance.version`` so +# consumers can correlate the trace payload shape with the runtime +# release that produced it. Resolved once at import time — the installed +# package version doesn't change for the life of the process. +SCHEMA_VERSION = _package_version() + +# Value of the ``type`` / ``span_type`` span attributes on every +# governance span. Local to the runtime trace contract — kept as a +# string literal (not a cross-package import) so the runtime stays +# self-contained. +SPAN_TYPE_AGENT_RUN = "agentRun" + +# Set as the ``source`` attribute on every governance span. Lets +# consumers identify which producer emitted a given span when more +# than one governance producer feeds the same trace backend. +GOVERNANCE_SOURCE = "governance-checker-python" + +# Shared attribute namespace for every governance span attribute. +# Concatenated into each ``span.set_attribute`` call so the prefix +# appears in one place and a future rename is a one-line change. +NS = "uipath_governance" + +# Governance verdict / action vocabulary (UPPER_SNAKE). +EVALUATOR_ALLOW = "ALLOW" +EVALUATOR_DENY = "DENY" +EVALUATOR_HITL = "HITL" + +ACTION_ALLOW = "ALLOW" +ACTION_DENY = "DENY" +ACTION_HITL = "HITL" +ACTION_AUDIT = "AUDIT" +ACTION_NONE = "NONE" + + +def _resolve_mode(event: AuditEvent) -> EnforcementMode: + """Read the enforcement mode the evaluator stamped on the event. + + Mode travels with the event (set by the emitter when it calls + :meth:`AuditManager.emit_rule_evaluation` / + :meth:`emit_hook_summary` and passes its own per-instance mode) so + the sink doesn't read a process-global that wouldn't be + authoritative in a parallel-runtime setup. + + Falls back to ``AUDIT`` only when the field is missing — that's a + contract violation by the emitter (every governance event must carry + the mode), but defaulting to the safe option avoids a sink crash. + """ + mode = event.data.get("enforcement_mode") + if isinstance(mode, EnforcementMode): + return mode + if isinstance(mode, str): + try: + return EnforcementMode(mode.lower()) + except ValueError: + pass + return EnforcementMode.AUDIT + + +def _derive_results( + matched: bool, configured_action: str, mode: EnforcementMode +) -> tuple[str, str]: + """Return ``(evaluator_result, action_applied)`` in spec vocabulary. + + ``evaluator_result`` is mode-independent — what the rule decided. The + rule's configured ``audit`` action collapses into a DENY decision + here; whether that DENY is actually applied is reflected in + ``action_applied``. + + ``action_applied`` is mode-driven. Currently only AUDIT mode is wired + in the runtime, so every non-allow result lands on ``AUDIT``; the + ENFORCE branch is kept so the contract is already correct when + ENFORCE arrives in a later phase. + + The configured ``audit`` rule-level action acts as a per-rule audit + override: even when global mode is ENFORCE, such a rule only ever + produces ``action_applied = AUDIT``. This preserves today's "audit + never blocks" behavior. + """ + action = configured_action.lower() + + if not matched or action == "allow": + return EVALUATOR_ALLOW, ACTION_NONE + + if action == "escalate": + evaluator = EVALUATOR_HITL + else: + evaluator = EVALUATOR_DENY + + # Per-rule audit override: emit AUDIT regardless of global mode. + if action == "audit": + return evaluator, ACTION_AUDIT + + if mode == EnforcementMode.ENFORCE: + return evaluator, ACTION_DENY if evaluator == EVALUATOR_DENY else ACTION_HITL + return evaluator, ACTION_AUDIT + + +class TracesAuditSink(AuditSink): + """Audit sink that emits an OpenTelemetry span per governance event.""" + + def __init__(self) -> None: + """Initialize the sink with a deferred tracer and zero span count.""" + self._tracer: Any = None # Can be None, Tracer, or False + self._spans_created = 0 + + @property + def name(self) -> str: + """Constant sink identifier.""" + return "traces" + + def _get_tracer(self) -> Any: + """Get or create the OpenTelemetry tracer.""" + if self._tracer is None: + try: + from opentelemetry import trace + + self._tracer = trace.get_tracer("uipath.governance") + logger.info("OpenTelemetry tracer initialized for governance traces") + except ImportError: + logger.warning( + "OpenTelemetry not available — governance traces disabled." + ) + self._tracer = False + return self._tracer if self._tracer else None + + def emit(self, event: AuditEvent) -> None: + """Create a span for RULE_EVALUATION or HOOK_END events; drop others.""" + if event.event_type == EventType.RULE_EVALUATION: + self._emit_rule_span(event) + elif event.event_type == EventType.HOOK_END: + self._emit_hook_span(event) + + def _emit_hook_span(self, event: AuditEvent) -> None: + """Create a span for a hook summary (always emitted for each governance check).""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + data = event.data + hook = event.hook or "unknown" + span_name = f"governance.{hook.lower()}" + + # Sink dispatch runs on the caller's thread (see + # :meth:`AuditManager.emit`), so the current OTel context + # is the agent's live span — the governance span attaches + # as its child without any cross-thread plumbing. + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("uipath.custom_instrumentation", True) + span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) + + # Mode travels on the event so parallel runtimes running + # different per-instance modes don't cross-contaminate. + mode = _resolve_mode(event) + final_action = data.get("final_action", "allow") + _, action_applied = _derive_results( + matched=final_action.lower() != "allow", + configured_action=final_action, + mode=mode, + ) + span.set_attribute(f"{NS}.hook", hook) + span.set_attribute(f"{NS}.action_applied", action_applied) + span.set_attribute(f"{NS}.mode", mode.value.upper()) + + # Hook spans are summary containers — severity lives on + # the per-rule spans. Marking the hook ERROR would paint + # the whole lifecycle phase as failed when only one rule + # fired beneath it. + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance hook span: %s", e) + + def _emit_rule_span(self, event: AuditEvent) -> None: + """Create a span for a rule evaluation.""" + tracer = self._get_tracer() + if tracer is None: + return + + try: + data = event.data + policy_id = data.get("policy_id", "unknown") + span_name = f"{NS}.rule.{policy_id}" + + # See _emit_hook_span: sync dispatch on the caller's + # thread means the current OTel context is the agent's + # live span, so this rule span attaches as its child. + with tracer.start_as_current_span(span_name) as span: + span.set_attribute("type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("uipath.custom_instrumentation", True) + span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) + + # Single source of truth for the emitted attributes + # below AND the verbosityLevel / Status decision further + # down. Mode comes from the event (per-instance) so + # parallel runtimes don't cross-contaminate. + mode = _resolve_mode(event) + configured_action = data.get("action", "allow") + matched = bool(data.get("matched", False)) + evaluator_result, action_applied = _derive_results( + matched=matched, + configured_action=configured_action, + mode=mode, + ) + + span.set_attribute(f"{NS}.policy_id", policy_id) + span.set_attribute(f"{NS}.rule_name", data.get("rule_name", "")) + span.set_attribute(f"{NS}.pack_name", data.get("pack_name", "")) + span.set_attribute(f"{NS}.hook", event.hook) + span.set_attribute(f"{NS}.evaluator_result", evaluator_result) + span.set_attribute(f"{NS}.action_applied", action_applied) + span.set_attribute(f"{NS}.mode", mode.value.upper()) + span.set_attribute(f"{NS}.version", SCHEMA_VERSION) + + detail = data.get("detail", "") + if detail: + span.set_attribute(f"{NS}.evidence", detail[:500]) + + # Severity is driven off the derived ``action_applied``: + # - DENY — runtime blocked → verbosityLevel=4 + + # Status.ERROR (agent span genuinely failed). + # - AUDIT / HITL — advisory, runtime did not block → + # verbosityLevel=3, Status stays UNSET. Marking the + # agent span failed for an advisory rule would mislead. + # - ALLOW / NONE — no verbosityLevel attribute set. + if action_applied == ACTION_DENY: + span.set_attribute("verbosityLevel", 4) + try: + from opentelemetry.trace import Status, StatusCode + + span.set_status( + Status( + StatusCode.ERROR, + f"Policy violation: " + f"{data.get('rule_name', policy_id)} " + f"(action={configured_action.lower()})", + ) + ) + except ImportError: + pass + elif action_applied in (ACTION_AUDIT, ACTION_HITL): + span.set_attribute("verbosityLevel", 3) + + self._spans_created += 1 + + except Exception as e: + logger.warning("Failed to create governance span: %s", e) + + @property + def spans_created(self) -> int: + """Number of spans created.""" + return self._spans_created diff --git a/src/uipath/runtime/governance/_audit/track_events.py b/src/uipath/runtime/governance/_audit/track_events.py new file mode 100644 index 00000000..8a2b745a --- /dev/null +++ b/src/uipath/runtime/governance/_audit/track_events.py @@ -0,0 +1,329 @@ +"""Telemetry sink for governance evaluation events. + +Submits custom telemetry events through a caller-supplied **non-blocking** +``track_event`` callable. The runtime layer does not own dispatch — the +host is responsible for supplying a callable that returns immediately +(typically a thread-pool-backed adapter around the real telemetry +client). Tests pass a mock that captures payloads. + +Event-rate policy (volume control): + +- DENIED rules (``matched=True``) → one ``governance.rule.denied`` event + per matched rule. +- PASSED + SKIPPED rules → one ``governance.hook.summary`` event per + hook with counts + the names of skipped rules + the count of + UiPath-mapped guardrails dispatched through the compensating path. + +Why: a 50-rule pack evaluated on every hook of every agent step would +multiply per-step telemetry calls by 50. Bundling the "nothing +happened" cases into a single hook summary cuts that to 1. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable + +from uipath.core.governance import EnforcementMode + +from .base import AuditEvent, AuditSink, EventType +from .metadata import GovernanceRuntimeMetadata + +logger = logging.getLogger(__name__) + + +TrackEventCallable = Callable[..., None] +"""Host-supplied telemetry callable. + +Expected kwargs: ``event_name: str``, ``data: dict | None``, +``operation_id: str | None``. The sink calls this synchronously from +the caller's hook thread, so the callable **MUST** be non-blocking — +the agent run shares that thread, and any wait propagates straight +into latency the agent observes. Hosts that need to do network I/O +are expected to wrap the underlying call in a thread-pool-backed +adapter that submits the work and returns immediately. +""" + + +EVENT_RULE_DENIED = "governance.rule.denied" +EVENT_HOOK_SUMMARY = "governance.hook.summary" + + +def _resolve_operation_id() -> str | None: + """Read the current OTel span's trace id for downstream correlation. + + Sink dispatch runs on the caller's thread (see + :meth:`AuditManager.emit`), so the live agent span is visible via + ``trace.get_current_span()`` directly. Rendered as a 32-char + lowercase hex string for use as a correlation id by the consumer. + + Returns ``None`` when: + + - OpenTelemetry isn't installed (sink no-ops gracefully). + - No span is recording (no trace context to correlate against). + + The live span is the single source of truth for trace correlation; + the sink does not consult environment variables. + """ + try: + from opentelemetry import trace + except ImportError: + return None + + span = trace.get_current_span() + span_ctx = span.get_span_context() + if not span_ctx.is_valid: + return None + return f"{span_ctx.trace_id:032x}" + + +def _mode_str(mode: Any) -> str: + """Coerce an enforcement-mode field to its canonical uppercase string.""" + if isinstance(mode, EnforcementMode): + return mode.value.upper() + if isinstance(mode, str): + return mode.upper() + return "AUDIT" + + +def _evaluator_result(action: str) -> str: + """Map a rule's configured action to the spec-vocabulary verdict. + + Mirrors :func:`uipath.runtime.governance._audit.traces._derive_results` + so both sinks agree on the (matched, action) → verdict mapping. + """ + action_lc = action.lower() + if action_lc == "deny": + return "DENY" + if action_lc == "escalate": + return "HITL" + if action_lc == "audit": + # The rule wanted to deny but was tagged audit-only at the + # check level — the evaluator's intent is still "deny". + return "DENY" + return "ALLOW" + + +def _action_applied(evaluator_result: str, configured_action: str, mode: str) -> str: + """Mode-adjusted action: AUDIT mode collapses DENY/HITL into AUDIT.""" + if mode == "AUDIT": + if evaluator_result in ("DENY", "HITL"): + return "AUDIT" + return "NONE" + # ENFORCE mode: per-rule ``audit`` override stays AUDIT. + if configured_action.lower() == "audit": + return "AUDIT" + return evaluator_result if evaluator_result != "ALLOW" else "NONE" + + +class TrackEventAuditSink(AuditSink): + """Sink that emits governance telemetry via the injected ``track_event``. + + Volume-controlled per the module docstring: individual events only + for denials, one aggregated event per hook for passed / skipped / + dispatched. Other event types are dropped at :meth:`accepts`. + + The ``track_event`` callable is invoked synchronously on the + caller's hook thread, so the callable must be non-blocking — see + the :class:`TrackEventCallable` type alias for the contract the + host is expected to satisfy. + """ + + SINK_NAME = "track_events" + + def __init__( + self, + track_event: TrackEventCallable, + runtime_metadata: GovernanceRuntimeMetadata, + *, + name: str = SINK_NAME, + ) -> None: + """Initialize the sink. + + Args: + track_event: Host-supplied **non-blocking** telemetry + callable. Receives ``event_name``, ``data``, + ``operation_id`` kwargs. The sink invokes it + synchronously from the agent's hook thread, so any + network or otherwise blocking work in the underlying + implementation must be offloaded by the host (e.g., + via a thread-pool-backed adapter). Tests pass a + capture callable. **Required** — ``None`` is a wiring + bug and is rejected here so the manager's registration + try/except surfaces it as a clear warning rather than + letting a ``TypeError`` fire on the first event. + runtime_metadata: Per-run constants stamped on every event + (execution engine, agent type, agent framework, runtime + version). + name: Sink name used for circuit-breaker accounting in the + manager. Override only if more than one telemetry sink + of this kind is registered against the same manager. + """ + if track_event is None: + raise ValueError( + "TrackEventAuditSink requires a non-None track_event callable. " + "The host is expected to supply a non-blocking adapter — " + "wiring a blocking client (e.g., raw HTTP) on the agent's " + "hook thread will block the agent on every governance event." + ) + self._track_event = track_event + self._meta = runtime_metadata + self._name = name + + @property + def name(self) -> str: + return self._name + + def accepts(self, event: AuditEvent) -> bool: + """Filter the audit stream down to denials + non-empty hook summaries. + + Drops: + + - **Any event whose mode is** :attr:`EnforcementMode.DISABLED` + — governance is off; emit nothing. The evaluator short-circuits + before ``_emit_audit`` in this case, so in practice the sink + shouldn't see these, but the guard here is belt-and-suspenders + against any future emitter that bypasses the short-circuit. + - Rules that didn't match (``matched=False``) — rolled into the + hook summary's ``passed_count``. + - Rules that matched but whose configured ``action`` is + ``allow`` — a positive informational match isn't a denial; + rolled into the hook summary too. + - **Empty hook summaries** — ``total_rules=0`` AND + ``skipped_count=0`` means nothing was evaluated and nothing + was deliberately skipped. Zero operator value; suppressed. + (A summary with ``skipped_count>0`` still fires because the + ``skipped_policy_names`` payload is operator-relevant — it + shows which policies a tenant turned off.) + - Other event types (``hook_start``, ``session_*``, generic + violations) — out of scope for this sink's telemetry surface. + + The result is bounded volume: at most one ``rule.denied`` event + per matched-and-restrictive rule + at most one + ``hook.summary`` per hook end (and only when it carries data). + """ + if _mode_str(event.data.get("enforcement_mode")) == "DISABLED": + return False + + if event.event_type == EventType.RULE_EVALUATION: + if not event.data.get("matched"): + return False + action = str(event.data.get("action") or "allow").lower() + return action != "allow" + if event.event_type == EventType.HOOK_END: + data = event.data + total = int(data.get("total_rules") or 0) + skipped = int(data.get("skipped_count") or 0) + return not (total == 0 and skipped == 0) + return False + + def emit(self, event: AuditEvent) -> None: + """Render the event and dispatch via the injected callable. + + Re-checks ``matched`` for rule evaluations as a defense-in-depth + guard. :meth:`accepts` already drops passed rules at the + :class:`AuditManager` dispatch boundary, but a direct + ``sink.emit(event)`` call (tests, future alternate dispatch + paths) must not route a passed rule into the + ``governance.rule.denied`` telemetry stream. + + Errors from ``track_event`` propagate to the audit manager, + which has its own circuit breaker (10 consecutive failures → + sink tripped for the rest of the process). The sink itself + doesn't catch — letting the manager track failure rate is the + whole point of the sink-failure protocol. + """ + # DISABLED governance = no telemetry, full stop. Same + # defense-in-depth rationale as the matched/allow check below: + # ``accepts`` drops these at the manager boundary, but a + # direct ``sink.emit`` call must not leak a DISABLED-mode + # event downstream either. + if _mode_str(event.data.get("enforcement_mode")) == "DISABLED": + return + + if event.event_type == EventType.RULE_EVALUATION: + if not event.data.get("matched"): + return + action = str(event.data.get("action") or "allow").lower() + if action == "allow": + # Positive informational match — not a denial. Same + # defense-in-depth rationale as the ``matched`` check + # above: ``accepts`` drops these at the manager + # boundary, but a direct ``sink.emit`` call must not + # leak them into the ``rule.denied`` stream. + return + self._emit_rule_denied(event) + elif event.event_type == EventType.HOOK_END: + data = event.data + total = int(data.get("total_rules") or 0) + skipped = int(data.get("skipped_count") or 0) + if total == 0 and skipped == 0: + # Empty hook — same defense-in-depth as the disabled + # mode and matched-allow filters. + return + self._emit_hook_summary(event) + # Other event types are filtered out at accepts(); reaching + # here for anything else would be a contract violation by the + # manager, not the sink. + + def _common_payload(self, event: AuditEvent) -> dict[str, Any]: + """Per-runtime constants + event identifiers stamped on every payload.""" + payload: dict[str, Any] = dict(self._meta.as_payload()) + payload["agent_name"] = event.agent_name + payload["hook"] = event.hook + payload["timestamp"] = event.timestamp.isoformat() + return payload + + def _emit_rule_denied(self, event: AuditEvent) -> None: + """Emit one ``governance.rule.denied`` per matched rule.""" + data = event.data + mode = _mode_str(data.get("enforcement_mode")) + configured_action = str(data.get("action") or "allow") + evaluator_result = _evaluator_result(configured_action) + action_applied = _action_applied(evaluator_result, configured_action, mode) + + payload = self._common_payload(event) + payload.update( + { + "pack": str(data.get("pack_name") or ""), + "clause": str(data.get("policy_id") or ""), + "rule_name": str(data.get("rule_name") or ""), + "mode": mode, + "evaluator_result": evaluator_result, + "action_applied": action_applied, + "duration_ms": float(data.get("duration_ms") or 0.0), + "mapped_to_uipath": bool(data.get("mapped_to_uipath", False)), + "detail": str(data.get("detail") or ""), + } + ) + self._track_event( + event_name=EVENT_RULE_DENIED, + data=payload, + operation_id=_resolve_operation_id(), + ) + + def _emit_hook_summary(self, event: AuditEvent) -> None: + """Emit one ``governance.hook.summary`` per hook end.""" + data = event.data + mode = _mode_str(data.get("enforcement_mode")) + + payload = self._common_payload(event) + payload.update( + { + "mode": mode, + "passed_count": int(data.get("passed_count") or 0), + "skipped_count": int(data.get("skipped_count") or 0), + "skipped_policy_names": list(data.get("skipped_policy_names") or []), + "denied_count": int(data.get("denied_count") or 0), + "guardrail_dispatched_count": int( + data.get("guardrail_dispatched_count") or 0 + ), + "duration_ms": float(data.get("duration_ms") or 0.0), + "final_action": str(data.get("final_action") or "allow").upper(), + } + ) + self._track_event( + event_name=EVENT_HOOK_SUMMARY, + data=payload, + operation_id=_resolve_operation_id(), + ) diff --git a/src/uipath/runtime/governance/native/__init__.py b/src/uipath/runtime/governance/native/__init__.py new file mode 100644 index 00000000..e3bca078 --- /dev/null +++ b/src/uipath/runtime/governance/native/__init__.py @@ -0,0 +1,40 @@ +"""Native UiPath governance policy evaluator. + +Rules evaluated in-process at each agent lifecycle hook. The caller +fetches the policy pack and compiles it into a :class:`PolicyIndex` +outside this package — the runtime never sees the wire format. Any +provider, any code path, any format is fair game; this package only +consumes the compiled index. + +This subpackage owns: + +- :class:`GovernanceEvaluator` – the evaluator implementation. +- The native policy model: :class:`Rule`, :class:`Check`, + :class:`Condition`, :class:`PolicyIndex`, :class:`PolicyPack`. + +Shared output types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance`. +""" + +from .evaluator import GovernanceEvaluator +from .models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, + Severity, +) + +__all__ = [ + "GovernanceEvaluator", + # Native policy model + "Check", + "CheckContext", + "Condition", + "PolicyIndex", + "PolicyPack", + "Rule", + "Severity", +] diff --git a/src/uipath/runtime/governance/native/evaluator.py b/src/uipath/runtime/governance/native/evaluator.py new file mode 100644 index 00000000..a9c60a60 --- /dev/null +++ b/src/uipath/runtime/governance/native/evaluator.py @@ -0,0 +1,1188 @@ +"""Governance rule evaluator. + +Instance-scoped — every :class:`GovernanceRuntime` constructs its own +evaluator with explicit dependencies (audit manager, compensator, +enforcement mode). The evaluator does not reach across the runtime +layer through process-globals; the wiring layer composes the runtime +graph and the evaluator consumes what it's given. +""" + +from __future__ import annotations + +import logging +import math +import re +import time +from collections import Counter +from datetime import datetime, timezone +from functools import cache, lru_cache +from typing import Any + +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import ( + Action, + AuditRecord, + LifecycleHook, + RuleEvaluation, +) + +from uipath.runtime.governance._audit.base import AuditManager +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, + disabled_guardrails, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + Rule, +) + +logger = logging.getLogger(__name__) + + +def _compensation_data_for_hook(context: CheckContext) -> dict[str, Any]: + """Build the ``data`` payload for the compensating-governance call. + + Forwards whichever :class:`CheckContext` field is populated for the + active hook so the compensating call evaluates the same content + the evaluator was looking at. Fields not relevant to the hook are + omitted to keep the payload tight. + """ + if context.hook in (LifecycleHook.BEFORE_AGENT,): + return {"content": context.agent_input} + if context.hook in (LifecycleHook.AFTER_AGENT,): + return {"content": context.agent_output} + if context.hook in (LifecycleHook.BEFORE_MODEL,): + payload: dict[str, Any] = {"content": context.model_input} + if context.messages: + payload["messages"] = context.messages + return payload + if context.hook in (LifecycleHook.AFTER_MODEL,): + return {"content": context.model_output} + if context.hook in (LifecycleHook.TOOL_CALL,): + return {"tool_name": context.tool_name, "tool_args": context.tool_args} + if context.hook in (LifecycleHook.AFTER_TOOL,): + return {"tool_name": context.tool_name, "tool_result": context.tool_result} + # Memory-write and unknown hooks: pass an empty content so the + # server still receives a structurally-valid payload. + return {"content": ""} + + +@lru_cache(maxsize=256) +def _compile_regex(pattern: str) -> re.Pattern[str] | None: + """Compile and cache a regex pattern. + + Args: + pattern: The regex pattern string + + Returns: + Compiled pattern or None if invalid + """ + try: + return re.compile(pattern) + except re.error as e: + logger.warning("Invalid regex pattern '%s': %s", pattern, e) + return None + + +# --- vaderSentiment: lazy-imported singleton --- +# Hard dependency, but lazy-loaded to keep import-time cost off the +# critical path. The except branch is defence against a corrupted +# install (file present in METADATA but module unimportable) — the +# operator no-ops rather than crashing the agent. +@cache +def _get_vader_analyzer() -> Any: + """Return a cached SentimentIntensityAnalyzer, or None if unavailable.""" + try: + from vaderSentiment.vaderSentiment import ( # type: ignore[import-untyped] + SentimentIntensityAnalyzer, + ) + + return SentimentIntensityAnalyzer() + except ImportError: + logger.error( + "vaderSentiment failed to import despite being a hard dependency; " + "sentiment_concern checks will not fire." + ) + return None + + +# --- chardet: lazy-imported module for encoding integrity (A.7.4) --- +# Hard dependency, lazy-loaded for symmetry with the other library +# wrappers. The except branch covers corrupted installs only. +@cache +def _get_chardet() -> Any: + """Return the chardet module, or None if unavailable.""" + try: + import chardet + + return chardet + except ImportError: + logger.error( + "chardet failed to import despite being a hard dependency; " + "encoding_concern confidence check will not fire (stdlib " + "signals still apply)." + ) + return None + + +# --- Static patterns for encoding_concern (A.7.4) --- +# Latin-1-as-UTF-8 mojibake bigrams — the visible artefacts when +# UTF-8-encoded text is re-decoded as Latin-1 / Windows-1252. +_MOJIBAKE_BIGRAMS: tuple[str, ...] = ( + "é", + "è", + "â", + "à ", + "ù", + "î", + "ô", + "ç", # accented vowels + "Ä", + "Ö", + "Ü", + "ß", # German umlauts / eszett + "’", + "“", + "â€\x9d", + "–", + "—", + "•", # smart quotes / dashes + "£", + "°", + "§", + "¶", + "©", + "®", # NBSP-leading symbols + "ï¿", + "¿½", # mojibake'd U+FFFD (0xEF 0xBF 0xBD as Latin-1) + "ï»", + "»¿", # mojibake'd BOM (0xEF 0xBB 0xBF as Latin-1) +) + +# Literal hex escape sequences ("\x80" as 4 source chars) indicate raw +# bytes leaked through a string layer rather than being decoded. +_HEX_ESCAPE_PATTERN = re.compile(r"\\x[0-9a-fA-F]{2}") + + +# --- Static patterns for incident_concern (A.8.4) --- +# Stdlib-only categorical taxonomy. Mirrors sentry-sdk's incident shape +# (categorical types over stack/status), but for string payloads from +# model output / tool result rather than exception objects. +_INCIDENT_PATTERNS: dict[str, list[re.Pattern[str]]] = { + "safety_refusal": [ + re.compile( + r"(?i)\b(i\s+(?:cannot|can'?t|am\s+unable\s+to|won'?t\s+be\s+able\s+to)" + r"\s+(?:help|assist|provide|answer|do\s+that))\b" + ), + re.compile(r"(?i)\b(i'?m\s+sorry,?\s+but\s+i\s+(?:cannot|can'?t))\b"), + re.compile(r"(?i)\b(against\s+my\s+(?:guidelines|policies|programming))\b"), + ], + "tool_failure": [ + re.compile( + r"\b(5\d{2})\b\s*(?:internal\s+server\s+error|service\s+unavailable)" + ), + re.compile(r"(?i)\b(ERR_[A-Z_]+|connection\s+refused|ECONNREFUSED)\b"), + re.compile(r"(?i)\b(timed?\s*out)\b"), + ], + "auth_failure": [ + re.compile(r"\b(401|403)\b\s*(?:unauthori[sz]ed|forbidden)"), + re.compile( + r"(?i)\b(authentication\s+failed|invalid\s+(?:token|credentials))\b" + ), + ], + "quota_exceeded": [ + re.compile(r"\b(429)\b"), + re.compile( + r"(?i)\b(rate\s+limit\s+exceeded|quota\s+exceeded|too\s+many\s+requests)\b" + ), + ], + "hallucination": [ + re.compile(r"(?i)\b(i\s+(?:made\s+(?:that|this)\s+up|am\s+just\s+guessing))\b"), + re.compile(r"(?i)\b(i\s+don'?t\s+actually\s+know|i\s+fabricat(?:ed|ing))\b"), + ], +} + +# --- Static patterns for commitment_concern (A.10.4) --- +# Commitment-language signals. The verb pattern covers both first-person +# promise verbs ("we will refund") and formal-business commitment markers +# common in proposal / SOW outputs ("Cost: $X", "fixed scope", +# "Deliverables", "Timeline: N days", "I propose"). Verb, amount, and +# deadline signals combine via OR semantics — see +# :meth:`_check_commitment_concern`. +_COMMITMENT_VERB_PATTERN = re.compile( + r"(?i)(" + # First-person promise / liability verbs + r"\brefund\b|\breimburse\b|" + r"\bwarranty\b|\bwarrant(?:y|ed|ies)\b|\bguarante[ed]+\b|" + r"\bsla\b|" + r"\bwaive[d]?\b|" + r"\b(?:we|i)\s+(?:will|shall|promise|commit|guarantee)\b|" + r"\b(?:we|i|i'?ll)\s+(?:deliver|provide|complete|finish|" + r"handover|hand\s+over|ship)\b|" + # Proposal / SOW commitment markers + r"\bfixed\s+(?:price|cost|fee|scope|bid|rate)\b|" + r"\bcost\s*:\s*\$?\d|" + r"\bquote\s*:\s*\$?\d|" + r"\bdeliverables?\b|" + r"\btimeline\s*:\s*\d+\s*(?:second|minute|hour|day|week|month|year)s?\b|" + r"\bI\s+propose\b" + r")" +) +# Currency-anchored amount detection. Requires a currency marker adjacent +# to the number so URL fragments (e.g. ``/667851``) don't false-positive. +# Covers symbol-then-number ($780) and number-then-code (780 USD). +# +# Bare percentages (``75%``, ``99.9%``) are deliberately NOT matched +# here — they fire on benign status / progress text ("75% complete", +# "99.9% uptime") under OR semantics. Real percentage-bearing +# commitments ("we'll give you a 20% discount", "refund 100%") still +# fire via the verb pattern. +_COMMITMENT_AMOUNT_FALLBACK = re.compile( + r"(?:\$|€|£|¥|₹|USD|EUR|GBP|JPY|INR)\s*\d[\d,]*(?:\.\d+)?" + r"|\b\d[\d,]*(?:\.\d+)?\s*(?:USD|EUR|GBP|JPY|INR|" + r"dollars?|euros?|pounds?|yen|rupees?)\b" +) +_COMMITMENT_DEADLINE_PATTERN = re.compile( + r"(?i)\bwithin\s+\d+\s*(?:second|minute|hour|day|week|month|year)s?\b" + r"|\bby\s+(?:tomorrow|next\s+\w+|\d+/\d+(?:/\d+)?)\b" +) + + +class GovernanceEvaluator: + """Evaluates governance rules against check contexts. + + Supports two enforcement modes: + + - ``AUDIT``: log all violations but never block (DENY collapses to + AUDIT in the final action). + - ``ENFORCE``: actually block on DENY rules — raises + :class:`GovernanceBlockException` and the agent stops. + + All dependencies (mode, audit manager, compensator) are injected + via the constructor. The evaluator does not consult any + process-global state — parallel runtimes (``uipath eval``) get + their own evaluator with their own audit + compensation pipelines. + """ + + def __init__( + self, + policy_index: PolicyIndex, + *, + enforcement_mode: EnforcementMode = EnforcementMode.AUDIT, + audit_manager: AuditManager | None = None, + compensator: GuardrailCompensator | None = None, + ) -> None: + """Initialize with a compiled policy index and runtime-scoped deps. + + Args: + policy_index: The compiled :class:`PolicyIndex` to evaluate. + Typically read from :attr:`GovernanceRuntime.policy_index` + — built by the caller from whatever policy source they + own; the evaluator only consumes the compiled index. + enforcement_mode: Mode the evaluator applies. Defaults to + ``AUDIT`` — the safe default for callers that don't + explicitly opt in to ENFORCE. The wiring layer should + pass ``runtime.enforcement_mode`` here so the evaluator + and the wrapping :class:`GovernanceRuntime` agree on a + single source of truth. + audit_manager: Per-runtime :class:`AuditManager`. When + ``None`` the evaluator runs silently (no audit events + emitted). Tests that don't care about emission can + leave this out. + compensator: Per-runtime :class:`GuardrailCompensator` + used to dispatch compensating-governance calls for + guardrail-fallback rules. When ``None`` such dispatch + is skipped — the evaluator still records the matched + rules in the :class:`AuditRecord`. + """ + self._policy_index = policy_index + self._enforcement_mode = enforcement_mode + self._audit_manager = audit_manager + self._compensator = compensator + + @property + def policy_index(self) -> PolicyIndex: + """Return the compiled policy index this evaluator runs against.""" + return self._policy_index + + @property + def mode(self) -> EnforcementMode: + """The enforcement mode this evaluator applies.""" + return self._enforcement_mode + + def is_audit_mode(self) -> bool: + """Check if running in audit-only mode.""" + return self._enforcement_mode == EnforcementMode.AUDIT + + def evaluate(self, context: CheckContext) -> AuditRecord: + """Evaluate rules registered for ``context.hook`` against the context. + + Only rules whose ``hook`` field matches the current lifecycle hook + are evaluated — a ``tool_call`` rule does not fire on + ``before_model``, and vice versa. This avoids running checks + against fields the context cannot provide and keeps the audit + stream scoped to the active phase. + + The final action depends on the enforcement mode: + - DISABLED mode: Short-circuit; no rules evaluated, no audit emitted. + - AUDIT mode: Even DENY rules result in AUDIT action (log only, don't block) + - ENFORCE mode: DENY rules result in DENY action AND a + :class:`GovernanceBlockException` is raised. + + Audit events (per-rule + hook summary) are emitted via the + :class:`AuditManager` injected at construction (skipped when + none was supplied). + + Args: + context: The check context with hook and content + + Returns: + AuditRecord with all evaluations and final action. + + Raises: + GovernanceBlockException: In ENFORCE mode when a DENY rule matches. + """ + mode = self._enforcement_mode + if mode == EnforcementMode.DISABLED: + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + hook=context.hook, + evaluations=[], + final_action=Action.ALLOW, + metadata={**context.metadata, "enforcement_mode": mode.value}, + ) + + rules = self._policy_index.get_rules_for_hook(context.hook) + + evaluations: list[RuleEvaluation] = [] + # Per-rule wall time (ms), keyed by rule_id. Forwarded to the + # telemetry sink via emit_rule_evaluation(duration_ms=...) so + # operators can see which rules are slow. + rule_durations: dict[str, float] = {} + # Disabled rules — never evaluated, surfaced in the hook + # summary's skipped_policy_names so dashboards can spot which + # policies a tenant turned off. + skipped_policy_ids: list[str] = [] + raw_action = Action.ALLOW # The action before mode adjustment + deny_would_fire = False # Track if DENY would have fired + + hook_start = time.monotonic() + + for rule in rules: + if not rule.enabled: + skipped_policy_ids.append(rule.rule_id) + continue + + rule_start = time.monotonic() + evaluation = self._evaluate_rule(rule, context) + rule_durations[rule.rule_id] = (time.monotonic() - rule_start) * 1000.0 + evaluations.append(evaluation) + + if evaluation.matched: + # Take the most restrictive action. Use evaluation.action + # (which already folds in per-check overrides), not + # rule.action, so check-level overrides are honored here too. + eval_action = evaluation.action + if eval_action == Action.DENY: + raw_action = Action.DENY + deny_would_fire = True + elif eval_action == Action.ESCALATE and raw_action != Action.DENY: + raw_action = Action.ESCALATE + elif eval_action == Action.AUDIT and raw_action == Action.ALLOW: + raw_action = Action.AUDIT + + hook_duration_ms = (time.monotonic() - hook_start) * 1000.0 + + # Apply enforcement mode + final_action = self._apply_enforcement_mode(raw_action) + + # Build metadata with mode info + record_metadata = dict(context.metadata) + record_metadata["enforcement_mode"] = mode.value + if deny_would_fire and self.is_audit_mode(): + record_metadata["audit_mode_would_deny"] = True + + audit = AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=context.agent_name, + runtime_id=context.runtime_id, + hook=context.hook, + evaluations=evaluations, + final_action=final_action, + metadata=record_metadata, + ) + + self._emit_audit( + audit, + mode, + rule_durations=rule_durations, + skipped_policy_ids=skipped_policy_ids, + hook_duration_ms=hook_duration_ms, + ) + + # For any guardrail mapped to UiPath but currently disabled, + # dispatch a compensating-governance call via the injected + # compensator. The compensating call (provider-owned) runs the + # real guardrail check and writes its own audit trace — the + # evaluator does NOT emit a Python-side trace for these rules, + # to avoid double-writing. The provider is expected to be + # non-blocking (batched / async / fire-and-forget internally); + # the runtime layer no longer owns that scheduling. + self._dispatch_compensation(audit, context) + + if final_action == Action.DENY: + raise GovernanceBlockException.from_audit_record(audit) + + return audit + + def _dispatch_compensation(self, audit: AuditRecord, context: CheckContext) -> None: + """Dispatch compensating governance for any matched fallback rules. + + Delegates to the injected :class:`GuardrailCompensator`, which + builds the wire payload and hands it to the provider. This + method picks the fallback rules out of the audit, logs the + summary, and submits. + + No-op when no compensator was supplied at construction (e.g. + unit tests that don't care about the dispatch path). + """ + if self._compensator is None: + return + + try: + disabled = disabled_guardrails(audit, self._policy_index) + if not disabled: + return + + # Distinct validator names for the operator-facing log line. + validators = [rule.validator for rule in disabled] + + # Surface the disabled-guardrail fire-up: how many rules + # triggered the compensating call, and which validators + # they map to (e.g. pii_detection / prompt_injection / + # harmful_content). One line per dispatch so an operator + # can see the volume + breakdown at a glance. + logger.info( + "Compensating governance triggered: hook=%s, count=%d, validators=[%s]", + audit.hook.value, + len(disabled), + ", ".join(validators), + ) + + self._compensator.submit( + rules=disabled, + data=_compensation_data_for_hook(context), + hook=audit.hook.value, + src_timestamp=audit.timestamp.isoformat(), + agent_name=audit.agent_name, + runtime_id=audit.runtime_id, + ) + except Exception as exc: # noqa: BLE001 - fail-open + logger.warning("Failed to dispatch compensating governance call: %s", exc) + + def _emit_audit( + self, + audit: AuditRecord, + mode: EnforcementMode, + *, + rule_durations: dict[str, float] | None = None, + skipped_policy_ids: list[str] | None = None, + hook_duration_ms: float = 0.0, + ) -> None: + """Emit per-rule and hook-summary events to the injected audit manager. + + No-op when no audit manager was supplied at construction. The + per-runtime :class:`AuditManager` handles sink-level circuit + breaking; emission errors stay there and never break evaluation. + + Args: + audit: Resolved :class:`AuditRecord` produced by + :meth:`evaluate`. + mode: Active :class:`EnforcementMode`. + rule_durations: Per-rule wall time in ms, keyed by + ``rule_id``. Looked up when emitting individual + rule-evaluation events; unknown ids default to 0. + skipped_policy_ids: Ids of rules that were disabled and + therefore not evaluated. Folded into the hook + summary's ``skipped_policy_names``. + hook_duration_ms: Total wall time spent evaluating this + hook. Folded into the hook summary's ``duration_ms``. + """ + manager = self._audit_manager + if manager is None: + return + + hook_name = audit.hook.name + durations = rule_durations or {} + + # ``guardrail_fallback`` rules are traced by the compensating + # path (see :meth:`_dispatch_compensation`), which carries the + # actual validator verdict. Emitting a Python-side + # ``rule_evaluation`` event here would produce a duplicate + # trace carrying no verdict, so filter these rules out of every + # per-rule event the evaluator emits AND out of the hook + # summary's passed/denied counts. The summary keeps a separate + # ``guardrail_dispatched_count`` so the mapped-vs-unmapped + # dimension is still queryable. + emittable = [ + ev + for ev in audit.evaluations + if not self._is_guardrail_fallback_rule(ev.rule_id) + ] + guardrail_dispatched_count = sum( + 1 + for ev in audit.evaluations + if ev.matched and self._is_guardrail_fallback_rule(ev.rule_id) + ) + + # No emittable rules means every match this hook was a + # guardrail-fallback already traced by the compensation path. + # Skip the hook summary entirely — emitting it with + # total_rules=0 + the audit's overall final_action would + # double-count the compensation-owned verdict. + if not emittable: + return + + for evaluation in emittable: + manager.emit_rule_evaluation( + policy_id=evaluation.rule_id, + rule_name=evaluation.rule_name, + pack_name=evaluation.pack_name, + hook=hook_name, + matched=evaluation.matched, + action=evaluation.action.value if evaluation.matched else "allow", + enforcement_mode=mode, + detail=evaluation.detail, + agent_name=audit.agent_name, + description=evaluation.description, + duration_ms=durations.get(evaluation.rule_id, 0.0), + # Per-rule events the evaluator emits are never for + # UiPath-mapped guardrails (those are filtered out of + # ``emittable`` above and traced by the compensating + # path). Stamping ``False`` keeps the schema stable + # for sinks. + mapped_to_uipath=False, + ) + + # All summary metrics derive from ``emittable`` only — + # ``audit.final_action`` folded in fallback rules whose + # verdict travels on the compensation path, so using it here + # would mix the two trace paths' verdicts. + # + # ``matched_rules`` keeps the historical "any check matched" + # semantic (used by the legacy traces sink). ``denied_count`` + # is the precise spec verdict — only rules whose configured + # action would actually act (not ``allow``). A matched rule + # configured as ``allow`` is an explicit positive match and + # rolls into ``passed_count`` via the manager's arithmetic. + matched_rules = sum(1 for ev in emittable if ev.matched) + denied_count = sum( + 1 for ev in emittable if ev.matched and ev.action != Action.ALLOW + ) + summary_final_action = self._apply_enforcement_mode( + self._most_restrictive_matched_action(emittable) + ) + + manager.emit_hook_summary( + hook=hook_name, + agent_name=audit.agent_name, + total_rules=len(emittable), + matched_rules=matched_rules, + final_action=summary_final_action.value, + enforcement_mode=mode, + duration_ms=hook_duration_ms, + skipped_policy_names=list(skipped_policy_ids or []), + guardrail_dispatched_count=guardrail_dispatched_count, + denied_count=denied_count, + ) + + @staticmethod + def _most_restrictive_matched_action( + evals: list[RuleEvaluation], + ) -> Action: + """Return the most-restrictive matched action across ``evals``. + + Mirrors the cross-rule aggregation in :meth:`evaluate`: + DENY > ESCALATE > AUDIT > ALLOW. Unmatched rules contribute + nothing. + """ + result = Action.ALLOW + for ev in evals: + if not ev.matched: + continue + a = ev.action + if a == Action.DENY: + return Action.DENY + if a == Action.ESCALATE and result != Action.DENY: + result = Action.ESCALATE + elif a == Action.AUDIT and result == Action.ALLOW: + result = Action.AUDIT + return result + + def _is_guardrail_fallback_rule(self, rule_id: str) -> bool: + """Return True if the rule carries a ``guardrail_fallback`` condition. + + Such rules are traced by the compensating path, so the + evaluator must not emit a duplicate Python-side trace for them. + """ + rule = self._policy_index.get_rule(rule_id) + if rule is None: + return False + for check in rule.checks: + for cond in check.conditions: + if cond.operator == "guardrail_fallback": + return True + return False + + def _apply_enforcement_mode(self, raw_action: Action) -> Action: + """Apply enforcement mode to the raw action. + + In AUDIT mode: + - DENY becomes AUDIT (log but don't block) + - ESCALATE becomes AUDIT (log but don't escalate) + - AUDIT stays AUDIT + - ALLOW stays ALLOW + + In ENFORCE mode: + - All actions pass through unchanged + """ + if self._enforcement_mode == EnforcementMode.AUDIT: + if raw_action in (Action.DENY, Action.ESCALATE): + return Action.AUDIT + return raw_action + + def evaluate_before_agent( + self, + agent_input: str, + agent_name: str, + runtime_id: str, + model_name: str = "", + **kwargs: Any, + ) -> AuditRecord: + """Evaluate BEFORE_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + agent_input=agent_input, + model_name=model_name, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_agent( + self, + agent_output: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_AGENT rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_AGENT, + agent_name=agent_name, + runtime_id=runtime_id, + agent_output=agent_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_before_model( + self, + model_input: str, + agent_name: str, + runtime_id: str, + messages: list[dict[str, Any]] | None = None, + model_name: str = "", + **kwargs: Any, + ) -> AuditRecord: + """Evaluate BEFORE_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.BEFORE_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + model_input=model_input, + model_name=model_name, + messages=messages or [], + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_model( + self, + model_output: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_MODEL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_MODEL, + agent_name=agent_name, + runtime_id=runtime_id, + model_output=model_output, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_tool_call( + self, + tool_name: str, + tool_args: dict[str, Any], + agent_name: str, + runtime_id: str, + session_state: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate TOOL_CALL rules.""" + context = CheckContext( + hook=LifecycleHook.TOOL_CALL, + agent_name=agent_name, + runtime_id=runtime_id, + tool_name=tool_name, + tool_args=tool_args, + session_state=session_state or {}, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def evaluate_after_tool( + self, + tool_name: str, + tool_result: str, + agent_name: str, + runtime_id: str, + **kwargs: Any, + ) -> AuditRecord: + """Evaluate AFTER_TOOL rules.""" + context = CheckContext( + hook=LifecycleHook.AFTER_TOOL, + agent_name=agent_name, + runtime_id=runtime_id, + tool_name=tool_name, + tool_result=tool_result, + metadata=kwargs.get("metadata", {}), + ) + return self.evaluate(context) + + def _evaluate_rule(self, rule: Rule, context: CheckContext) -> RuleEvaluation: + """Evaluate a single rule against the context.""" + if not rule.checks: + # No checks = always matches (for audit-only rules) + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=True, + detail="Rule has no conditions (always matches)", + pack_name=rule.pack_name, + action=rule.action, + description=rule.description, + ) + + check_results: list[dict[str, Any]] = [] + any_check_matched = False + # Resolve the rule's action from the MATCHED checks so per-check + # `action` overrides take effect. ``Check.action`` defaults to the + # rule's action (see _yaml_to_index), so for rules without an + # override this equals ``rule.action`` exactly. Take the most + # restrictive matched action (DENY > ESCALATE > AUDIT > ALLOW), + # mirroring evaluate()'s cross-rule aggregation. + matched_action = Action.ALLOW + + for check in rule.checks: + matched, detail = self._evaluate_check(check, context) + check_results.append( + { + "matched": matched, + "detail": detail, + "action": check.action.value, + } + ) + if matched: + any_check_matched = True + if check.action == Action.DENY: + matched_action = Action.DENY + elif check.action == Action.ESCALATE and matched_action != Action.DENY: + matched_action = Action.ESCALATE + elif check.action == Action.AUDIT and matched_action == Action.ALLOW: + matched_action = Action.AUDIT + + # Surface the FIRST matched check's message; falls back to the + # first check's detail (empty string when none matched) for + # backward compatibility with rules that have a single check. + first_matched_detail = next( + (cr["detail"] for cr in check_results if cr["matched"]), + check_results[0]["detail"] if check_results else "", + ) + + return RuleEvaluation( + rule_id=rule.rule_id, + rule_name=rule.name, + matched=any_check_matched, + detail=first_matched_detail, + pack_name=rule.pack_name, + action=matched_action if any_check_matched else Action.ALLOW, + description=rule.description, + check_results=check_results, + ) + + def _evaluate_check(self, check: Check, context: CheckContext) -> tuple[bool, str]: + """Evaluate a single check against the context.""" + if not check.conditions: + return True, "No conditions (always matches)" + + results = [] + for condition in check.conditions: + matched = self._evaluate_condition(condition, context) + results.append(matched) + + if check.logic == "any": + final_match = any(results) + else: # "all" is default + final_match = all(results) + + detail = check.message if final_match else "" + return final_match, detail + + def _evaluate_condition(self, condition: Condition, context: CheckContext) -> bool: + """Evaluate a single condition against the context.""" + field_value = self._get_field_value(condition.field, context) + result = self._apply_operator(condition.operator, field_value, condition.value) + + if condition.negate: + result = not result + + return result + + def _get_field_value(self, field: str, context: CheckContext) -> Any: + """Get a field value from the context.""" + parts = field.split(".") + + # Start with context + value: Any = context + + for part in parts: + if hasattr(value, part): + value = getattr(value, part) + elif isinstance(value, dict) and part in value: + value = value[part] + else: + return None + + return value + + def _apply_operator( + self, operator: str, field_value: Any, check_value: Any + ) -> bool: + """Apply an operator to compare field value against check value.""" + # Handle existence checks before the None check + if operator == "exists": + return field_value is not None + if operator == "not_exists": + return field_value is None + + # guardrail_fallback fires only when the guardrail is mapped to + # UiPath but its policy is disabled. Config travels in + # ``check_value``; the rule's ``field`` is unused (so + # ``field_value`` is ``None`` here, which is expected — we must + # special-case this before the generic ``None`` short-circuit + # below). + if operator == "guardrail_fallback": + cfg = check_value if isinstance(check_value, dict) else {} + return bool(cfg.get("mapped_to_uipath", False)) and not bool( + cfg.get("policy_enabled", True) + ) + + if field_value is None: + return False + + # Numeric operators don't need stringification — short-circuit + # before `str(field_value)` (expensive for dict / large payloads). + if operator in ("gt", "gte", "lt", "lte"): + try: + lhs = float(field_value) + rhs = float(check_value) + except (ValueError, TypeError): + return False + if operator == "gt": + return lhs > rhs + if operator == "gte": + return lhs >= rhs + if operator == "lt": + return lhs < rhs + return lhs <= rhs + + field_str = str(field_value) + + match operator: + case "equals" | "eq": + return field_str == str(check_value) + + case "not_equals" | "ne": + return field_str != str(check_value) + + case "contains": + return str(check_value).lower() in field_str.lower() + + case "not_contains": + return str(check_value).lower() not in field_str.lower() + + case "regex" | "matches": + compiled = _compile_regex(str(check_value)) + if compiled is None: + return False + return bool(compiled.search(field_str)) + + case "in_list": + if isinstance(check_value, list): + return field_str in check_value + return False + + case "not_in_list": + if isinstance(check_value, list): + return field_str not in check_value + return True + + case "vader_concern": + # VADER compound score <= threshold. + # check_value: dict like {"threshold": -0.3} (default -0.3) + return self._check_vader_concern(field_str, check_value) + + case "encoding_concern": + # chardet-backed encoding integrity check (A.7.4). + # check_value: dict with optional `min_confidence` (default 0.5) + # and `max_replacement_ratio` (default 0.05). + return self._check_encoding_concern(field_str, check_value) + + case "entropy_concern": + # Shannon entropy outside expected range (A.7.4). + # check_value: dict with optional `min` (default 1.5) and + # `max` (default 7.5) bits/byte. Stdlib only. + return self._check_entropy_concern(field_str, check_value) + + case "incident_concern": + # Categorical incident detection (A.8.4). + # check_value: dict with optional `categories` list + # (subset of safety_refusal/tool_failure/auth_failure/ + # quota_exceeded/hallucination). Default: all categories. + return self._check_incident_concern(field_str, check_value) + + case "commitment_concern": + # Customer commitment language detection (A.10.4). + # check_value: dict with optional `require_amount` (default + # True) and `require_deadline` (default False). Fires when + # a commitment verb co-occurs with the configured signals. + return self._check_commitment_concern(field_str, check_value) + + case _: + logger.debug("Unknown operator: %s", operator) + return False + + @staticmethod + def _check_vader_concern(text: str, params: Any) -> bool: + """Return True if VADER compound score on `text` is <= threshold. + + Args: + text: Text to analyse. + params: Either a dict with `threshold` key, or a numeric threshold + directly. Default threshold is -0.3 (clearly-negative). + + Returns: + True iff vaderSentiment is available AND compound score <= threshold. + Returns False on empty input or if the library is not installed — + sentiment checks no-op rather than crash. + """ + if not text or not text.strip(): + return False + + analyzer = _get_vader_analyzer() + if analyzer is None: + return False + + if isinstance(params, dict): + threshold = float(params.get("threshold", -0.3)) + else: + try: + threshold = float(params) + except (TypeError, ValueError): + threshold = -0.3 + + try: + compound = float(analyzer.polarity_scores(text)["compound"]) + except Exception as exc: # pragma: no cover - defensive + logger.debug("VADER analysis failed: %s", exc) + return False + + return compound <= threshold + + @staticmethod + def _check_encoding_concern(text: str, params: Any) -> bool: + r"""Return True if `text` shows encoding integrity issues. + + Sums multiple deterministic corruption signals against text length: + - U+FFFD replacement characters (already-decoded lossy text) + - Literal ``�`` escape sequences carried through a JSON + / repr layer rather than being decoded + - Literal ``\xHH`` hex escapes (raw bytes leaked into a string) + - Latin-1-as-UTF-8 mojibake bigrams (e.g. ``é``, ``’``) + If the corruption ratio exceeds ``max_replacement_ratio`` the + check fires. chardet (when installed) is consulted as a + secondary low-confidence signal. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + min_confidence = float(params.get("min_confidence", 0.5)) + max_replacement_ratio = float(params.get("max_replacement_ratio", 0.05)) + min_corruption_events = int(params.get("min_corruption_events", 2)) + + length = max(len(text), 1) + + replacement_chars = text.count("�") + literal_ufffd_escapes = text.count("\\ufffd") + hex_escapes = len(_HEX_ESCAPE_PATTERN.findall(text)) + mojibake_bigrams = sum(text.count(bigram) for bigram in _MOJIBAKE_BIGRAMS) + + # Absolute count of distinct corruption *events* (one per + # U+FFFD, one per literal escape sequence, one per mojibake + # bigram). Even diluted by a lot of clean text, a few of these + # in production output is a strong signal. + corruption_events = ( + replacement_chars + literal_ufffd_escapes + hex_escapes + mojibake_bigrams + ) + if corruption_events >= min_corruption_events: + return True + + # Ratio-based fallback for cases below the absolute floor: still + # catches very short payloads where a single corruption char is + # disproportionate. + # Weight each event by its source-char span so denser corruption + # in shorter text trips the ratio sooner: + # U+FFFD = 1 char, "�" = 6 chars, "\xHH" = 4 chars, + # mojibake bigram = 2 chars. + corruption_chars = ( + replacement_chars + + 6 * literal_ufffd_escapes + + 4 * hex_escapes + + 2 * mojibake_bigrams + ) + if corruption_chars / length > max_replacement_ratio: + return True + + # Secondary: chardet on the encoded bytes. For pure str input + # this almost always reports high UTF-8/ASCII confidence (the + # branch is intentionally permissive), but it does catch bytes + # routed through `repr()` or `__str__` of a `bytes` object that + # chardet recognises as a non-UTF8 encoding with low confidence. + chardet = _get_chardet() + if chardet is None: + return False + try: + detection = chardet.detect(text.encode("utf-8", errors="replace")) + confidence = float(detection.get("confidence") or 0.0) + except Exception as exc: # pragma: no cover - defensive + logger.debug("chardet detection failed: %s", exc) + return False + + return confidence < min_confidence + + @staticmethod + def _check_entropy_concern(text: str, params: Any) -> bool: + """Return True if Shannon entropy of `text` is outside an expected range. + + Stdlib-only. Entropy is computed in bits per symbol over byte + frequencies. English prose typically lands ~3.5–4.5 bits/byte; + binary noise approaches 8 bits/byte; constant/repetitive text + approaches 0. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + lo = float(params.get("min", 1.5)) + hi = float(params.get("max", 7.5)) + + data = text.encode("utf-8", errors="replace") + total = len(data) + if total == 0: + return False + + counts = Counter(data) + entropy = 0.0 + for c in counts.values(): + p = c / total + entropy -= p * math.log2(p) + + return entropy < lo or entropy > hi + + @staticmethod + def _check_incident_concern(text: str, params: Any) -> bool: + """Return True if `text` matches any configured incident pattern (A.8.4). + + Categories: safety_refusal, tool_failure, auth_failure, + quota_exceeded, hallucination. Pass ``{"categories": [...]}`` to + restrict; default scans all categories. + """ + if not text or not text.strip(): + return False + + if isinstance(params, dict): + requested = params.get("categories") + else: + requested = None + + if not requested: + categories = list(_INCIDENT_PATTERNS.keys()) + else: + categories = [c for c in requested if c in _INCIDENT_PATTERNS] + + for category in categories: + for pattern in _INCIDENT_PATTERNS[category]: + if pattern.search(text): + return True + return False + + @staticmethod + def _check_commitment_concern(text: str, params: Any) -> bool: + """Return True if `text` carries customer-commitment language (A.10.4). + + OR semantics: a commitment-verb match always fires; when + ``require_amount`` is true, a currency-anchored amount alone also + fires; when ``require_deadline`` is true, a deadline phrase alone + also fires. With both flags false the rule matches on verb only + (verb-only mode). + + The verb pattern covers first-person promise verbs *and* proposal + / SOW commitment markers ("Cost: $X", "fixed scope", + "Deliverables", "Timeline: N days", "I propose"). The amount + pattern requires a currency marker adjacent to the number so URL + fragments don't false-positive. + """ + if not text or not text.strip(): + return False + + if not isinstance(params, dict): + params = {} + require_amount = bool(params.get("require_amount", True)) + require_deadline = bool(params.get("require_deadline", False)) + + verb_match = bool(_COMMITMENT_VERB_PATTERN.search(text)) + + # Verb-only mode: neither supporting signal is enabled. + if not require_amount and not require_deadline: + return verb_match + + amount_match = require_amount and bool(_COMMITMENT_AMOUNT_FALLBACK.search(text)) + deadline_match = require_deadline and bool( + _COMMITMENT_DEADLINE_PATTERN.search(text) + ) + return verb_match or amount_match or deadline_match diff --git a/src/uipath/runtime/governance/native/guardrail_compensation.py b/src/uipath/runtime/governance/native/guardrail_compensation.py new file mode 100644 index 00000000..53fd688e --- /dev/null +++ b/src/uipath/runtime/governance/native/guardrail_compensation.py @@ -0,0 +1,183 @@ +"""Compensating governance for disabled centralized guardrails. + +When a ``guardrail_fallback`` rule fires, this module builds the +:class:`GovernRequest` wire payload and calls the injected +:class:`uipath.core.governance.GovernanceCompensationProvider`. The +provider runs the actual guardrail check and writes its own audit +trace; the runtime layer here only assembles the request. + +Synchronous dispatch on the caller's thread. The provider owns +non-blocking semantics (internal batching, async fire-and-forget, or +whatever scheduling the host wires) — the runtime layer no longer +holds a worker pool, semaphore, or process-exit cleanup for this +path. Same architectural shape as :class:`AuditManager`: runtime is +synchronous and pure; async export is the sink/provider's concern. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from uipath.core.governance import ( + FiredRule, + GovernanceCompensationProvider, + GovernRequest, +) + +from .._audit.metadata import GovernanceRuntimeMetadata + +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------------- +# Stateless helpers +# ---------------------------------------------------------------------------- + + +def disabled_guardrails(audit: Any, policy_index: Any) -> list[FiredRule]: + """Return per-rule metadata for each fired guardrail-fallback rule. + + A guardrail rule fires only when it is mapped to UiPath + (``mapped_to_uipath`` true) but disabled (``policy_enabled`` false) — + see the ``guardrail_fallback`` operator. The validator name (e.g. + ``pii_detection``) is read from the rule's ``guardrail_fallback`` + check config and used as the validator on the compensating call. + + One :class:`FiredRule` entry is emitted per matching + ``guardrail_fallback`` condition. Rules in this codebase declare a + single fallback condition each, so the returned list has one entry + per fired rule in practice; multi-condition rules would emit more + than one entry sharing the same ``rule_id``. + """ + out: list[FiredRule] = [] + for ev in audit.evaluations: + if not ev.matched: + continue + rule = policy_index.get_rule(ev.rule_id) + if rule is None: + continue + for check in rule.checks: + for cond in check.conditions: + if cond.operator != "guardrail_fallback": + continue + if not isinstance(cond.value, dict): + continue + # The ``guardrail_fallback`` operator at evaluation time + # only matches when ``mapped_to_uipath=True`` AND + # ``policy_enabled=False``. We re-check here defensively + # so a future code path that bypasses the evaluator (or + # a multi-condition rule that fired on a sibling check) + # can't trigger a compensation call for a guardrail + # that isn't actually disabled. + if not bool(cond.value.get("mapped_to_uipath", False)): + continue + if bool(cond.value.get("policy_enabled", True)): + continue + validator = str(cond.value.get("validator", "")) + if validator: + out.append( + FiredRule( + rule_id=ev.rule_id, + rule_name=ev.rule_name, + pack_name=getattr(rule, "pack_name", "") or "", + validator=validator, + ) + ) + return out + + +def _validators(rules: list[FiredRule]) -> list[str]: + """Distinct validator names from the fired rules, preserving order.""" + return list(dict.fromkeys(r.validator for r in rules if r.validator)) + + +# ---------------------------------------------------------------------------- +# GuardrailCompensator +# ---------------------------------------------------------------------------- + + +class GuardrailCompensator: + """Synchronous dispatcher for compensating-governance calls. + + Builds the :class:`GovernRequest` payload and invokes the injected + provider's ``compensate`` method on the caller's thread. The + provider is expected to be non-blocking (batched internally, async + fire-and-forget, or otherwise scheduled off the agent's hook + thread) — the runtime layer owns no worker pool, semaphore, or + process-exit cleanup for this path. + + Per-call exceptions are caught and logged so a provider failure + never breaks the agent hook. + """ + + def __init__( + self, + provider: GovernanceCompensationProvider, + *, + runtime_metadata: GovernanceRuntimeMetadata | None = None, + ) -> None: + """Construct a compensator bound to one provider. + + Args: + provider: Host-supplied + :class:`GovernanceCompensationProvider`. ``compensate`` + is invoked synchronously on the agent's hook thread. + runtime_metadata: Per-run identity (agent framework / type / + runtime version) stamped onto the ``/runtime/govern`` call + so the server can attach it to the rule-denied telemetry it + emits. ``None`` leaves the fields unset (server defaults + them to ``unknown``). + """ + self._provider = provider + self._runtime_metadata = runtime_metadata + + def submit( + self, + rules: list[FiredRule], + data: dict[str, Any], + hook: str, + src_timestamp: str, + agent_name: str, + runtime_id: str, + ) -> None: + """Build the wire payload and hand it to the provider. + + Short-circuits on empty rules / empty validators. Per-call + provider exceptions are caught and logged. + """ + if not rules: + return + validators = _validators(rules) + if not validators: + return + + meta = self._runtime_metadata + request = GovernRequest( + validators=validators, + rules=rules, + data=data, + hook=hook, + src_timestamp=src_timestamp, + agent_name=agent_name, + runtime_id=runtime_id, + agent_framework=meta.agent_framework if meta else None, + agent_type=meta.agent_type if meta else None, + runtime_version=meta.runtime_version if meta else None, + ) + + try: + self._provider.compensate(request) + except Exception as exc: # noqa: BLE001 - fail-open by contract + logger.warning( + "Compensation provider call failed (validators=[%s]): %s", + ", ".join(validators), + exc, + ) + + def close(self) -> None: + """No-op — the compensator holds no resources. + + Kept on the API so callers that wire ``close()`` (e.g. shared + teardown patterns) don't need a branch for this class. + """ diff --git a/src/uipath/runtime/governance/native/models.py b/src/uipath/runtime/governance/native/models.py new file mode 100644 index 00000000..eb874a76 --- /dev/null +++ b/src/uipath/runtime/governance/native/models.py @@ -0,0 +1,162 @@ +"""Native policy model. + +Rules, checks, conditions and pack indexes consumed by the native +governance evaluator. + +These are the inputs of the native evaluator. The evaluator-agnostic +*output* types (``Action``, ``AuditRecord``, …) live in +:mod:`uipath.core.governance.models`. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any + +from uipath.core.governance.models import Action, LifecycleHook + + +class Severity(Enum): + """Rule severity levels.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" + + +class Logic(str, Enum): + """How a check combines its conditions.""" + + ALL = "all" # AND — every condition must hold. + ANY = "any" # OR — any matching condition is a hit. + + +@dataclass +class Condition: + """A single condition within a rule check.""" + + operator: str + field: str + value: Any + negate: bool = False + + +@dataclass +class Check: + """A check within a rule - contains conditions and action.""" + + conditions: list[Condition] + action: Action = Action.DENY + message: str = "" + logic: Logic = Logic.ALL + + +@dataclass +class Rule: + """A compliance rule with checks evaluated at a specific lifecycle hook.""" + + rule_id: str + name: str + clause: str + hook: LifecycleHook + action: Action + severity: Severity = Severity.HIGH + checks: list[Check] = field(default_factory=list) + enabled: bool = True + description: str = "" + pack_name: str = "" + + # Approval configuration (for ESCALATE action) + approval_config: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CheckContext: + """Context passed to rule evaluation. + + Scoped to evaluator input data only. Trace correlation is + intentionally not carried here — that concern is owned by the + provider / platform layer, not by the evaluator input model. + """ + + hook: LifecycleHook + agent_name: str + runtime_id: str + + # Content fields (populated based on hook) + agent_input: str = "" + agent_output: str = "" + model_input: str = "" + model_output: str = "" + model_name: str = "" + tool_name: str = "" + tool_args: dict[str, Any] = field(default_factory=dict) + tool_result: str = "" + messages: list[dict[str, Any]] = field(default_factory=list) + + # Session state + session_state: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + # Ring level (privilege level: 0=system, 1=admin, 2=user, 3=untrusted) + ring: int = 2 + + +@dataclass +class PolicyPack: + """A collection of rules for a compliance standard.""" + + name: str + version: str + description: str + rules: list[Rule] + enabled: bool = True + + +@dataclass +class PolicyIndex: + """Index of all loaded policy packs and rules.""" + + packs: dict[str, PolicyPack] = field(default_factory=dict) + _rules_by_id: dict[str, Rule] = field(default_factory=dict) + _rules_by_hook: dict[LifecycleHook, list[Rule]] = field(default_factory=dict) + + def add_pack(self, pack: PolicyPack) -> None: + """Add a policy pack to the index.""" + self.packs[pack.name] = pack + for rule in pack.rules: + rule.pack_name = pack.name + self._rules_by_id[rule.rule_id] = rule + if rule.hook not in self._rules_by_hook: + self._rules_by_hook[rule.hook] = [] + self._rules_by_hook[rule.hook].append(rule) + + def get_rule(self, rule_id: str) -> Rule | None: + """Get a rule by ID.""" + return self._rules_by_id.get(rule_id) + + def get_rules_for_hook(self, hook: LifecycleHook) -> list[Rule]: + """Get all rules for a lifecycle hook.""" + return self._rules_by_hook.get(hook, []) + + def get_rules_for_pack(self, pack_name: str) -> list[Rule]: + """Get all rules for a pack.""" + pack = self.packs.get(pack_name) + return pack.rules if pack else [] + + @property + def pack_names(self) -> list[str]: + """Get all pack names.""" + return list(self.packs.keys()) + + @property + def total_rules(self) -> int: + """Get total number of rules.""" + return len(self._rules_by_id) + + @property + def all_rules(self) -> list[Rule]: + """Get all rules.""" + return list(self._rules_by_id.values()) diff --git a/src/uipath/runtime/governance/runtime.py b/src/uipath/runtime/governance/runtime.py new file mode 100644 index 00000000..c9cf6996 --- /dev/null +++ b/src/uipath/runtime/governance/runtime.py @@ -0,0 +1,301 @@ +"""Governance runtime wrapper. + +Wraps a :class:`UiPathRuntimeProtocol` delegate and carries a resolved +policy snapshot — a :class:`PolicyIndex` and :class:`EnforcementMode` +supplied by the caller. The wrapper performs no I/O at construction, +holds no background thread, retains no policy provider, and reads no +host environment variables. + +The caller is expected to: + +- resolve a policy pack for the current run — any provider, any code + path, any wire format, +- produce a :class:`~uipath.runtime.governance.native.PolicyIndex` + from that policy; the runtime is format-agnostic and only accepts a + compiled index, +- skip wrapping entirely when the resolved enforcement mode is + :attr:`EnforcementMode.DISABLED`, +- pass the resolved ``PolicyIndex`` and ``EnforcementMode`` into the + constructor. + +The wrapper owns the BEFORE_AGENT / AFTER_AGENT lifecycle boundary +when an evaluator is supplied at construction. Framework adapters +intentionally skip chain-level events so nested chain runs don't fire +duplicate boundary evaluations; the runtime layer is the unambiguous +"one invocation = one boundary" point, so it owns those hooks. Per-step +hooks (BEFORE_MODEL, AFTER_MODEL, TOOL_CALL, AFTER_TOOL) are fired by +adapters that observe per-step events. + +Trace correlation: :meth:`UiPathGovernedRuntime.execute` and +:meth:`UiPathGovernedRuntime.stream` open a single OTel span that +wraps BEFORE_AGENT, the delegate's run (including any framework +adapter spans), and AFTER_AGENT. The span inherits the host's +ambient context when one is set, or opens a new root trace +otherwise. Every governance event emitted under that span shares +its ``trace_id``, so runtime-layer telemetry and framework-layer +OTel spans correlate end-to-end. Downstream consumers map that +``trace_id`` to whatever correlation field they use. + +The governance compensator dispatches synchronously on the caller's +thread; the injected compensation provider resolves the canonical +trace id at call time. The runtime layer remains env-free for that +path. +""" + +from __future__ import annotations + +import json +import logging +from contextlib import contextmanager +from typing import Any, AsyncGenerator, Iterator + +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.serialization import serialize_object + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import UiPathRuntimeEvent +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.result import UiPathRuntimeResult +from uipath.runtime.schema import UiPathRuntimeSchema + +logger = logging.getLogger(__name__) + + +@contextmanager +def _governance_root_span(agent_name: str, runtime_id: str) -> Iterator[None]: + """Open the governance run's root OTel span if OTel is available. + + Every governance event and every framework OTel span emitted + inside this block inherits the span's ``trace_id``. The result + is one trace per agent run that correlates BEFORE_AGENT, + BEFORE_MODEL / AFTER_MODEL, AFTER_AGENT, and any per-rule + governance events. + + Behavior matrix: + + - **OTel installed + host opened a parent span**: this becomes a + child of the host's span and inherits its ``trace_id`` — the + host's outer correlation context is preserved end-to-end. + - **OTel installed + no parent span**: this becomes the root + span of a fresh trace; everything below it shares the new + ``trace_id``. + - **OTel not installed**: no-op context manager — the wrapper + degrades gracefully (no spans, no correlation, but the + runtime still functions). + + Lazy import mirrors + :func:`uipath.runtime.governance._audit.track_events._resolve_operation_id`: + OTel is a transitive runtime dependency, but the wrapper must + not crash if a downstream host strips it. + """ + try: + from opentelemetry import trace + except ImportError: + yield + return + + tracer = trace.get_tracer("uipath.runtime.governance") + # No explicit ``context=`` → OTel picks up the ambient context. + # If the host wrapped this call in its own span, we become its + # child (same trace_id). Otherwise we open a root span (new + # trace_id). + with tracer.start_as_current_span("uipath.governance.run") as span: + # Span attributes for downstream consumers. ``agent_name`` + # and ``runtime_id`` are the primary keys an operator + # filters on; ``source`` identifies which producer emitted + # the span (mirrors the constant used by + # :class:`TracesAuditSink` so consumers stay consistent). + if agent_name: + span.set_attribute("uipath_governance.agent_name", agent_name) + if runtime_id: + span.set_attribute("uipath_governance.runtime_id", runtime_id) + span.set_attribute("uipath_governance.source", "governance-runtime-python") + yield + + +def _serialize_payload(payload: Any) -> str: + """Serialize an agent input / output to a string for evaluator checks. + + The native evaluator's BEFORE_AGENT / AFTER_AGENT checks scan a + flat string. ``None`` becomes ``""``, ``str`` passes through (so + regex / sentiment checks don't see JSON quotes around the bare + text), and everything else is normalized via + :func:`uipath.core.serialization.serialize_object` (handles + Pydantic / dataclass / datetime / nested structures) and then + JSON-encoded. + """ + if payload is None: + return "" + if isinstance(payload, str): + return payload + try: + return json.dumps(serialize_object(payload)) + except Exception: # noqa: BLE001 — last-resort string fallback + return str(payload) + + +class UiPathGovernedRuntime: + """Governance wrapper over a :class:`UiPathRuntimeProtocol` delegate. + + Holds a caller-resolved :class:`PolicyIndex` and + :class:`EnforcementMode` for the lifetime of the instance. + ``execute`` / ``stream`` / ``get_schema`` forward to the delegate. + ``dispose`` is a no-op — the caller owns the delegate (and every + other host-managed resource) and is responsible for tearing them + down. The method is kept only to satisfy + :class:`UiPathDisposableProtocol` so the wrapper stays + structurally substitutable for any :class:`UiPathRuntimeProtocol`. + + When ``evaluator`` is supplied, :meth:`execute` and :meth:`stream` + fire ``BEFORE_AGENT`` before delegating and ``AFTER_AGENT`` after a + successful return. Without an evaluator the wrapper is a pure + pass-through. + """ + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + policy_index: PolicyIndex, + enforcement_mode: EnforcementMode, + *, + evaluator: GovernanceEvaluator | None = None, + agent_name: str = "", + runtime_id: str = "", + ): + """Initialize the governance runtime with a resolved policy snapshot. + + Args: + delegate: The wrapped runtime to forward execution to. + policy_index: Resolved :class:`PolicyIndex` built from the + provider's :class:`PolicyResponse`. Pass an empty + ``PolicyIndex()`` to attach the wrapper without any + rules (useful when the wrapper exists for audit + emission only). + enforcement_mode: Resolved :class:`EnforcementMode` from + the provider's :class:`PolicyResponse`. The caller is + expected to skip wrapping entirely when the response + mode is :attr:`EnforcementMode.DISABLED`; this + constructor does not check. + evaluator: Optional :class:`GovernanceEvaluator` that + drives BEFORE_AGENT / AFTER_AGENT inside + :meth:`execute` / :meth:`stream`. When ``None`` the + wrapper is a pure passthrough — the caller is expected + to fire those evaluations itself. + agent_name: Name of the agent (the runtime's entrypoint). + Passed through to the evaluator's hook methods. + runtime_id: Runtime-instance id (conversation id, job id, + or a synthetic per-run id). Passed through so + per-runtime state routes cleanly. + """ + self._delegate = delegate + self._policy_index = policy_index + self._enforcement_mode = enforcement_mode + self._evaluator = evaluator + self._agent_name = agent_name + self._runtime_id = runtime_id + + def _fire_before_agent(self, input: Any) -> None: + """Fire BEFORE_AGENT when an evaluator is wired; otherwise no-op. + + ``GovernanceBlockException`` propagates — that's how + ENFORCE-mode DENY rules halt a run. Anything else is logged + and swallowed so a governance bug never breaks the agent. + """ + if self._evaluator is None: + return + try: + self._evaluator.evaluate_before_agent( + agent_input=_serialize_payload(input), + agent_name=self._agent_name, + runtime_id=self._runtime_id, + ) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 — never break a run on audit failure + logger.warning("BEFORE_AGENT governance evaluation failed: %s", exc) + + def _fire_after_agent(self, result: UiPathRuntimeResult) -> None: + """Fire AFTER_AGENT against ``result.output``. + + Same exception policy as :meth:`_fire_before_agent`. + """ + if self._evaluator is None: + return + try: + self._evaluator.evaluate_after_agent( + agent_output=_serialize_payload(result.output), + agent_name=self._agent_name, + runtime_id=self._runtime_id, + ) + except GovernanceBlockException: + raise + except Exception as exc: # noqa: BLE001 + logger.warning("AFTER_AGENT governance evaluation failed: %s", exc) + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute the delegate, firing BEFORE_AGENT / AFTER_AGENT around it. + + Wraps the entire invocation — BEFORE_AGENT, delegate execution + (which may itself open framework spans), and AFTER_AGENT — in + a single OTel span. The shared ``trace_id`` unifies the + runtime-side governance events with any framework-side OTel + spans the delegate emits. + + AFTER_AGENT fires only on successful return — if the delegate + raises, there's no output to evaluate. + """ + with _governance_root_span(self._agent_name, self._runtime_id): + self._fire_before_agent(input) + result = await self._delegate.execute(input, options=options) + self._fire_after_agent(result) + return result + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Stream events from the delegate, firing BEFORE_AGENT first. + + Same root-span wrap as :meth:`execute`. Holding the span + across ``async for`` is safe — Python asyncio propagates + contextvars (including the OTel current-span pointer) across + ``await`` suspensions. + + AFTER_AGENT fires once a :class:`UiPathRuntimeResult` event is + observed in the stream — that's the runtime's contract for + signalling a completed invocation. Intermediate state events + pass through untouched. + """ + with _governance_root_span(self._agent_name, self._runtime_id): + self._fire_before_agent(input) + async for event in self._delegate.stream(input, options=options): + if isinstance(event, UiPathRuntimeResult): + self._fire_after_agent(event) + yield event + + async def get_schema(self) -> UiPathRuntimeSchema: + """Forward schema lookup to the delegate.""" + return await self._delegate.get_schema() + + async def dispose(self) -> None: + """No-op — the caller owns delegate + host-resource lifecycle. + + The wrapper deliberately does not forward to + ``self._delegate.dispose()`` (the caller instantiated the + delegate and is responsible for disposing it) and does not + invoke any host-supplied cleanup hook (the host runs its own + teardown on the same code path). Kept as a method purely to + satisfy :class:`UiPathDisposableProtocol` — the wrapper stays + structurally substitutable for any ``UiPathRuntimeProtocol``. + """ diff --git a/tests/conftest.py b/tests/conftest.py index 2556e753..a6c5cd57 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,3 +17,9 @@ def temp_dir() -> Generator[str, None, None]: """Provide a temporary directory for test files.""" with tempfile.TemporaryDirectory() as tmp_dir: yield tmp_dir + + +# Governance state is held inline on the :class:`UiPathGovernedRuntime` +# instance — the host passes a resolved :class:`PolicyIndex` + +# :class:`EnforcementMode` into the constructor, no module-level +# state, no cross-test reset needed. diff --git a/tests/test_audit_factory.py b/tests/test_audit_factory.py new file mode 100644 index 00000000..3dc44e17 --- /dev/null +++ b/tests/test_audit_factory.py @@ -0,0 +1,50 @@ +"""Tests for :func:`uipath.runtime.governance._audit.factory.create_sink`. + +The factory is the entry point :class:`AuditManager` uses to construct +its always-on ``traces`` sink at init. It's tiny (one name → one sink) +but the unknown-name path deserves a regression guard so a future +refactor can't silently drop a sink registration. +""" + +from __future__ import annotations + +import logging + +import pytest + +from uipath.runtime.governance._audit.factory import create_sink +from uipath.runtime.governance._audit.traces import TracesAuditSink + + +def test_create_sink_traces_returns_traces_audit_sink() -> None: + """The single supported name resolves to a real ``TracesAuditSink``.""" + sink = create_sink("traces") + assert isinstance(sink, TracesAuditSink) + + +def test_create_sink_is_name_case_insensitive() -> None: + """Callers can pass any casing — the factory normalizes internally. + + Regression guard: dropping the ``.lower()`` would silently break + every consumer that hand-typed a name with different casing. + """ + assert isinstance(create_sink("TRACES"), TracesAuditSink) + assert isinstance(create_sink("Traces"), TracesAuditSink) + + +def test_create_sink_unknown_name_returns_none_and_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """An unknown name returns ``None`` (no crash) and logs a warning. + + Contract: registration failures must not raise — the audit manager's + ``_register_traces_sink`` handles ``None`` by skipping the sink, + which is safer than crashing at construction on a bad config. + """ + with caplog.at_level( + logging.WARNING, logger="uipath.runtime.governance._audit.factory" + ): + result = create_sink("unknown-sink") + + assert result is None + assert any("Unknown audit sink" in r.message for r in caplog.records) diff --git a/tests/test_audit_manager_lifecycle.py b/tests/test_audit_manager_lifecycle.py new file mode 100644 index 00000000..25094f9b --- /dev/null +++ b/tests/test_audit_manager_lifecycle.py @@ -0,0 +1,207 @@ +"""Lifecycle tests for :class:`AuditManager`. + +Pins the production-readiness invariants of the audit manager: + +- Construction is side-effect-free: no background thread, no atexit + registration, no global state mutation. +- :meth:`close` is idempotent. +- :meth:`emit` dispatches on the caller's thread, so an OTel-backed + sink sees the caller's live span without any cross-thread plumbing. +""" + +from __future__ import annotations + +import threading +from typing import Any + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) + +# --------------------------------------------------------------------------- +# Construction is side-effect-free +# --------------------------------------------------------------------------- + + +def test_construction_starts_no_background_thread() -> None: + """``AuditManager()`` must not spawn a worker thread. + + Regression guard for the design pivot: the audit pipeline used to + construct a daemon worker thread eagerly. Construction now only + builds in-memory state; any async export lives inside the sink. + """ + before = {t.name for t in threading.enumerate()} + m = AuditManager(register_default_sinks=False) + after = {t.name for t in threading.enumerate()} + try: + assert after == before, ( + f"AuditManager() spawned a thread; new threads: {after - before}" + ) + finally: + m.close() + + +def test_default_sink_registered_on_construction() -> None: + """With defaults, the traces sink is auto-registered.""" + m = AuditManager() + try: + assert "traces" in m.list_sinks() + finally: + m.close() + + +def test_bare_construction_skips_default_sink() -> None: + """``register_default_sinks=False`` produces an empty manager.""" + m = AuditManager(register_default_sinks=False) + try: + assert m.list_sinks() == [] + finally: + m.close() + + +# --------------------------------------------------------------------------- +# close() is idempotent and clears sinks +# --------------------------------------------------------------------------- + + +def test_close_clears_sinks_and_failure_state() -> None: + """``close()`` empties sinks, failure counters, and tripped set.""" + + class _Sink(AuditSink): + def __init__(self, name: str) -> None: + self._name = name + self.closed = False + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + pass + + def close(self) -> None: + self.closed = True + + m = AuditManager(register_default_sinks=False) + s = _Sink("test") + m.register_sink(s) + m._sink_failures["test"] = 3 + m._tripped_sinks.add("test") + + m.close() + + assert m.list_sinks() == [] + assert m._sink_failures == {} + assert m._tripped_sinks == set() + assert s.closed + + +def test_close_is_idempotent() -> None: + """Calling ``close()`` twice must not raise.""" + m = AuditManager(register_default_sinks=False) + m.close() + m.close() # must not raise + + +# --------------------------------------------------------------------------- +# flush() delegates to every sink +# --------------------------------------------------------------------------- + + +def test_flush_calls_flush_on_each_sink() -> None: + """The manager holds no buffer; ``flush()`` is a fan-out to sinks.""" + + class _Sink(AuditSink): + def __init__(self, name: str) -> None: + self._name = name + self.flush_count = 0 + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + pass + + def flush(self) -> None: + self.flush_count += 1 + + m = AuditManager(register_default_sinks=False) + a, b = _Sink("a"), _Sink("b") + m.register_sink(a) + m.register_sink(b) + try: + m.flush() + assert a.flush_count == 1 + assert b.flush_count == 1 + finally: + m.close() + + +# --------------------------------------------------------------------------- +# emit() runs on the caller's thread — OTel context visible directly +# --------------------------------------------------------------------------- + + +def test_emit_runs_on_caller_thread() -> None: + """``emit()`` invokes sinks synchronously on the calling thread. + + Asserts the design contract that lets OTel-backed sinks see the + agent's live span via ``trace.get_current_span()`` without any + cross-thread context propagation. + """ + captured: dict[str, Any] = {} + + class _Probe(AuditSink): + @property + def name(self) -> str: + return "probe" + + def emit(self, event: AuditEvent) -> None: + captured["thread"] = threading.current_thread() + + m = AuditManager(register_default_sinks=False) + m.register_sink(_Probe()) + try: + m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION)) + assert captured["thread"] is threading.current_thread() + finally: + m.close() + + +def test_emit_propagates_otel_span_via_current_context() -> None: + """An OTel-backed sink sees the caller's live span directly. + + With sync dispatch there's no contextvars snapshot/restore — the + sink just calls ``trace.get_current_span()`` on the same thread the + caller is on, and that's the span the caller has active. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + captured: dict[str, Any] = {} + + class _Probe(AuditSink): + @property + def name(self) -> str: + return "probe" + + def emit(self, event: AuditEvent) -> None: + sc = trace.get_current_span().get_span_context() + captured["trace_id"] = sc.trace_id if sc.is_valid else None + captured["span_id"] = sc.span_id if sc.is_valid else None + + tracer = TracerProvider().get_tracer("test") + m = AuditManager(register_default_sinks=False) + m.register_sink(_Probe()) + try: + with tracer.start_as_current_span("agent-run") as span: + expected = span.get_span_context() + m.emit(AuditEvent(event_type=EventType.RULE_EVALUATION)) + assert captured["trace_id"] == expected.trace_id + assert captured["span_id"] == expected.span_id + finally: + m.close() diff --git a/tests/test_audit_manager_track_event_wiring.py b/tests/test_audit_manager_track_event_wiring.py new file mode 100644 index 00000000..31c62e9c --- /dev/null +++ b/tests/test_audit_manager_track_event_wiring.py @@ -0,0 +1,178 @@ +"""Tests for ``AuditManager`` auto-registration of the ``track_events`` sink. + +The sink is platform-mandated, like ``traces``. The host wires the +``track_event`` callable + ``GovernanceRuntimeMetadata`` at +construction. ``_register_track_event_sink`` mirrors the simple shape +of ``_register_traces_sink`` — deferred import, construct, register, +log — wrapped in a broad ``except`` so a misconfigured wiring layer +(missing callable, sink-construction error) is surfaced as a warning +instead of crashing the agent. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from uipath.runtime.governance._audit.base import AuditManager +from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata +from uipath.runtime.governance._audit.track_events import TrackEventAuditSink + + +class _Capture: + """Stand-in for ``provider.track_event`` — records calls.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def __call__(self, **kwargs: Any) -> None: + self.calls.append(kwargs) + + +# --------------------------------------------------------------------------- +# Happy path — host wires the callable +# --------------------------------------------------------------------------- + + +def test_track_event_sink_registered_when_callable_supplied() -> None: + mgr = AuditManager( + track_event=_Capture(), + runtime_metadata=GovernanceRuntimeMetadata(agent_type="uipath_coded"), + ) + try: + sinks = mgr.list_sinks() + assert TrackEventAuditSink.SINK_NAME in sinks + sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME) + assert isinstance(sink, TrackEventAuditSink) + finally: + mgr.close() + + +def test_traces_sink_is_still_registered_alongside() -> None: + """track_events doesn't replace traces — both are mandatory.""" + mgr = AuditManager( + track_event=_Capture(), + runtime_metadata=GovernanceRuntimeMetadata(), + ) + try: + sinks = mgr.list_sinks() + assert "traces" in sinks + assert TrackEventAuditSink.SINK_NAME in sinks + finally: + mgr.close() + + +def test_supplied_runtime_metadata_reaches_sink() -> None: + mgr = AuditManager( + track_event=_Capture(), + runtime_metadata=GovernanceRuntimeMetadata( + agent_type="uipath_coded", agent_framework="langchain" + ), + ) + try: + sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME) + assert isinstance(sink, TrackEventAuditSink) + # Internal: confirm the sink carries the host-supplied metadata. + assert sink._meta.agent_type == "uipath_coded" + assert sink._meta.agent_framework == "langchain" + finally: + mgr.close() + + +def test_metadata_defaults_when_not_supplied() -> None: + """Helper falls back to ``GovernanceRuntimeMetadata()`` defaults.""" + mgr = AuditManager(track_event=_Capture()) + try: + sink = mgr.get_sink(TrackEventAuditSink.SINK_NAME) + assert isinstance(sink, TrackEventAuditSink) + assert sink._meta.agent_type == "unknown" + assert sink._meta.agent_framework == "unknown" + finally: + mgr.close() + + +# --------------------------------------------------------------------------- +# Missing-callable path — caught by the helper's try/except, logged +# --------------------------------------------------------------------------- + + +def test_missing_callable_logs_warning_and_skips_sink( + caplog: pytest.LogCaptureFixture, +) -> None: + """``track_event=None`` → sink __init__ raises → helper catches + warns.""" + caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base") + mgr = AuditManager() # no track_event supplied + try: + # Traces sink still wired; track_events skipped because sink + # construction raised ValueError caught by the helper. + assert "traces" in mgr.list_sinks() + assert TrackEventAuditSink.SINK_NAME not in mgr.list_sinks() + + warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING] + assert any("Failed to register track_events sink" in m for m in warnings), ( + f"expected registration-failure warning, got: {warnings}" + ) + finally: + mgr.close() + + +def test_sink_construction_error_does_not_crash_manager( + caplog: pytest.LogCaptureFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """An exception inside ``TrackEventAuditSink.__init__`` is swallowed. + + Forces a non-``ValueError`` exception (a raise inside ``__init__`` + triggered via the runtime_metadata path) to confirm the helper's + ``except Exception`` covers the broad case the user asked for, not + just the validation error. + """ + caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base") + + def _boom(*args: Any, **kwargs: Any) -> Any: + raise RuntimeError("synthetic registration failure") + + from uipath.runtime.governance._audit import track_events as te + + monkeypatch.setattr(te, "TrackEventAuditSink", _boom) + + mgr = AuditManager(track_event=_Capture()) + try: + # Helper swallowed the RuntimeError; manager kept the traces sink + # and reached a constructed state without crashing. + assert "traces" in mgr.list_sinks() + assert TrackEventAuditSink.SINK_NAME not in mgr.list_sinks() + warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING] + assert any( + "Failed to register track_events sink" in m + and "synthetic registration failure" in m + for m in warnings + ), f"expected wrapped registration failure, got: {warnings}" + finally: + mgr.close() + + +# --------------------------------------------------------------------------- +# Opt-out path — register_default_sinks=False +# --------------------------------------------------------------------------- + + +def test_opt_out_skips_both_sinks() -> None: + """register_default_sinks=False keeps the audit pipeline empty for tests.""" + mgr = AuditManager(register_default_sinks=False) + try: + assert mgr.list_sinks() == [] + finally: + mgr.close() + + +def test_opt_out_does_not_warn(caplog: pytest.LogCaptureFixture) -> None: + """Opting out is an explicit signal — no missing-callable warning.""" + caplog.set_level(logging.WARNING, logger="uipath.runtime.governance._audit.base") + mgr = AuditManager(register_default_sinks=False) + try: + warnings = [r.message for r in caplog.records if r.levelno == logging.WARNING] + assert not any("track_events" in m for m in warnings) + finally: + mgr.close() diff --git a/tests/test_audit_register_sink.py b/tests/test_audit_register_sink.py new file mode 100644 index 00000000..d0e3590b --- /dev/null +++ b/tests/test_audit_register_sink.py @@ -0,0 +1,108 @@ +"""Tests for ``AuditManager.register_sink`` failure-counter semantics. + +A re-registered same-name sink must NOT inherit the previous instance's +tripped circuit-breaker state. ``unregister_sink`` already clears these +counters, but ``register_sink`` also clears them on a successful add as +defense-in-depth (covers tests / external callers that touch the +internal counter dicts directly). +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) + + +class _NoopSink(AuditSink): + """Sink that records emit calls and never raises.""" + + def __init__(self, name: str = "test-sink") -> None: + self._name = name + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return self._name + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _event() -> AuditEvent: + return AuditEvent(event_type=EventType.RULE_EVALUATION, agent_name="a") + + +@pytest.fixture +def manager() -> Any: + """Build a fresh AuditManager with no default sinks. + + ``register_default_sinks=False`` keeps the traces sink out of the + test, so assertions about registered sinks see only what the test + puts there. + """ + return AuditManager(register_default_sinks=False) + + +def test_register_clears_stale_failure_counter(manager: AuditManager) -> None: + """A new sink with a name that previously tripped starts fresh.""" + # Simulate prior instance having tripped the circuit-breaker without + # going through unregister (e.g. test code or external code that + # mutated the counters directly). + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + + # Counter and tripped-set must be cleared. + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + # And the new sink actually receives events (would be skipped if + # still considered tripped). + manager.emit(_event()) + assert len(new_sink.events) == 1 + + +def test_register_does_not_clear_for_duplicate(manager: AuditManager) -> None: + """Re-registering an already-present sink is a no-op (no counter reset).""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + + # Simulate the existing sink having accumulated some failures. + manager._sink_failures["test-sink"] = 3 + + # A second register call with the same name should NOT clear those + # failures — the duplicate-check fires before the reset. + duplicate = _NoopSink(name="test-sink") + manager.register_sink(duplicate) + + assert manager._sink_failures["test-sink"] == 3 + + +def test_unregister_then_register_starts_fresh(manager: AuditManager) -> None: + """The full lifecycle: register → trip → unregister → register again.""" + sink = _NoopSink(name="test-sink") + manager.register_sink(sink) + manager._sink_failures["test-sink"] = manager._SINK_FAILURE_THRESHOLD + manager._tripped_sinks.add("test-sink") + + manager.unregister_sink("test-sink") + # Unregister already clears. + assert "test-sink" not in manager._tripped_sinks + + new_sink = _NoopSink(name="test-sink") + manager.register_sink(new_sink) + assert manager._sink_failures.get("test-sink", 0) == 0 + assert "test-sink" not in manager._tripped_sinks + + manager.emit(_event()) + assert len(new_sink.events) == 1 diff --git a/tests/test_commitment_concern.py b/tests/test_commitment_concern.py new file mode 100644 index 00000000..378bb604 --- /dev/null +++ b/tests/test_commitment_concern.py @@ -0,0 +1,200 @@ +"""Tests for the commitment_concern check (A.10.4). + +The check now uses OR semantics: a verb match, an amount match, or a +deadline match is each sufficient when its enabling flag is on. With +both flags false the rule matches verb-only. + +The verb pattern also covers proposal / SOW style commitment markers +("Cost: $X", "fixed scope", "Deliverables", "Timeline", "I propose") +so formal-business commitments without first-person verbs still fire. + +Amount detection requires a currency marker adjacent to the number to +prevent URL fragments (forum-post IDs, image dimensions, etc.) from +false-positiving. +""" + +from __future__ import annotations + +import pytest + +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator + +# --------------------------------------------------------------------------- +# The proposal-style sample that originally slipped through the rule. +# Contains: "Cost: $780 (fixed for the above scope)", "Deliverables", +# "Timeline: 4 days total", "I propose", a forum URL with a 6-digit ID. +# Triple-quoted so we keep the line breaks the model produced. +# --------------------------------------------------------------------------- +SAMPLE_PROPOSAL = """To address your concerns, I reviewed the official UiPath site you referenced and relevant resources on uipath.com to inform a fast stabilization plan. Notable findings include: a community CI/CD sample for UiPath projects (https://forum.uipath.com/t/announcement-ci-cd-pipeline-sample-implementation-s-for-uipath-projects-alpha/667851). + +Here's how I propose we turn your software around quickly: + +Plan +- Triage (logs + reproduce) +- Quick stabilization + +Deliverables +- Defect triage report + +Timeline: 4 days total +- Day 1: Triage + reproduction + +Cost: $780 (fixed for the above scope) +""" + + +@pytest.mark.parametrize( + "text", + [ + "Cost: $780 (fixed for the above scope)", + "Deliverables: a, b, c", + "Timeline: 4 days total for the whole engagement", + "I propose we turn this around in a week", + "We will refund the difference", + "I'll deliver the report by Friday", + "the warranty covers parts only", + "fixed price of one hundred dollars", + ], +) +def test_verb_match_alone_fires(text: str) -> None: + """Each verb-style commitment marker fires on its own (verb-only mode).""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": False, "require_deadline": False} + ) + is True + ) + + +def test_full_proposal_sample_fires() -> None: + """The originally-missed proposal output now fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + SAMPLE_PROPOSAL, + {"require_amount": False, "require_deadline": False}, + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "$780", + "We charge USD 1,200 per seat", + "The fee is 500 EUR", + ], +) +def test_amount_alone_fires_when_require_amount_true(text: str) -> None: + """Currency-anchored amount alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is True + ) + + +@pytest.mark.parametrize( + "text", + [ + "Task is 75% complete.", + "We maintain 99.9% uptime.", + "Battery at 50%.", + "Score: 12%.", + ], +) +def test_bare_percentage_does_not_fire(text: str) -> None: + """Status-only percentages must not trigger commitment_concern. + + Regression for the prior ``\\d{1,3}\\s*%`` branch in the amount + regex, which fired on benign status / progress text. Real + percentage-bearing commitments ("we'll give a 20% discount") + still fire via the verb pattern. + """ + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": False} + ) + is False + ) + + +def test_percentage_with_verb_still_fires() -> None: + """A commitment verb co-occurring with a percentage still fires.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "We will refund 100% of the purchase price.", + {"require_amount": True, "require_deadline": False}, + ) + is True + ) + + +def test_amount_alone_does_not_fire_when_require_amount_false() -> None: + """Amount-only text is silent when require_amount=False and no verb.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "The list price is $780.", + {"require_amount": False, "require_deadline": False}, + ) + is False + ) + + +def test_deadline_alone_fires_when_require_deadline_true() -> None: + """Deadline phrase alone fires under OR semantics.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + "Will be done within 5 days.", + {"require_amount": False, "require_deadline": True}, + ) + is True + ) + + +def test_url_fragment_digits_do_not_false_positive() -> None: + """A long URL with embedded digits is not a 'commitment'. + + Catches the prior price-parser misbehaviour where Price.fromstring() + picked up forum-post IDs (e.g. ``667851``) and conflated them with + unrelated currency symbols elsewhere in the text. + """ + text = ( + "See https://forum.example.com/t/topic/667851 for details — " + "no commitment language here." + ) + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +@pytest.mark.parametrize( + "text", + [ + "", + " ", + "Just chatting about the weather today.", + "The product is durable and well-made.", + ], +) +def test_no_signal_does_not_fire(text: str) -> None: + """Text without any commitment signal stays silent regardless of flags.""" + assert ( + GovernanceEvaluator._check_commitment_concern( + text, {"require_amount": True, "require_deadline": True} + ) + is False + ) + + +def test_non_dict_params_treated_as_defaults() -> None: + """``params`` of the wrong type degrades to defaults rather than crashing.""" + assert GovernanceEvaluator._check_commitment_concern("we will refund", None) is True + assert ( + GovernanceEvaluator._check_commitment_concern("no verbs here", "garbage") + is False + ) diff --git a/tests/test_evaluator.py b/tests/test_evaluator.py new file mode 100644 index 00000000..96750a29 --- /dev/null +++ b/tests/test_evaluator.py @@ -0,0 +1,407 @@ +"""Tests for the audit + enforcement behavior of GovernanceEvaluator. + +The evaluator's three load-bearing responsibilities: + +1. DISABLED enforcement mode short-circuits — no rules evaluated, no + audit events emitted, no exceptions raised. +2. AUDIT mode evaluates rules and emits audit events, but transforms + matched DENY actions into AUDIT so execution continues. +3. ENFORCE mode evaluates, emits audit, and raises + :class:`GovernanceBlockException` when a DENY rule matches. + +Plus a fail-safe contract: a misbehaving audit sink must not stop +evaluation from completing or propagate as an exception. The +evaluator is constructed with explicit dependencies (audit manager, +enforcement mode); no process-globals are involved. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from uipath.core.governance import EnforcementMode +from uipath.core.governance.exceptions import GovernanceBlockException +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Test helpers +# --------------------------------------------------------------------------- + + +class _CapturingSink(AuditSink): + """Audit sink that records every event for assertions.""" + + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return "capturing" + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _deny_rule_on_input_contains(needle: str) -> Rule: + """Build a rule that DENIES when agent_input contains ``needle``.""" + return Rule( + rule_id="TEST-01", + name="Test deny on input", + clause="A.1.1", + hook=LifecycleHook.BEFORE_AGENT, + action=Action.DENY, + checks=[ + Check( + conditions=[ + Condition( + operator="contains", + field="agent_input", + value=needle, + ) + ], + action=Action.DENY, + message=f"Input must not contain {needle!r}", + ) + ], + ) + + +def _build_index_with(rule: Rule) -> PolicyIndex: + """Wrap a single rule in a one-pack PolicyIndex.""" + idx = PolicyIndex() + idx.add_pack( + PolicyPack( + name="test_pack", + version="1.0", + description="test", + rules=[rule], + ) + ) + return idx + + +def _ctx(agent_input: str) -> CheckContext: + return CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name="test-agent", + runtime_id="run-1", + agent_input=agent_input, + ) + + +def _build_evaluator( + rule: Rule, + mode: EnforcementMode, + audit_manager: AuditManager | None = None, +) -> GovernanceEvaluator: + """Construct an evaluator with explicit deps — no process-globals involved.""" + return GovernanceEvaluator( + _build_index_with(rule), + enforcement_mode=mode, + audit_manager=audit_manager, + ) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def audit_setup() -> Any: + """Per-test :class:`AuditManager` + capturing sink — no default sinks. + + Returns ``(manager, sink)`` so a test can build evaluators with the + manager and inspect emitted events through the sink. + """ + manager = AuditManager(register_default_sinks=False) + sink = _CapturingSink() + manager.register_sink(sink) + yield manager, sink + manager.close() + + +# --------------------------------------------------------------------------- +# DISABLED mode +# --------------------------------------------------------------------------- + + +def test_disabled_mode_short_circuits_with_empty_record(audit_setup: Any) -> None: + """DISABLED returns an empty AuditRecord and emits nothing.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.DISABLED, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("definitely contains secret")) + + assert audit.evaluations == [] + assert audit.final_action == Action.ALLOW + assert audit.metadata["enforcement_mode"] == "disabled" + assert sink.events == [] + + +def test_disabled_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None: + """Even when a DENY rule WOULD match, DISABLED never raises.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.DISABLED, + audit_manager=manager, + ) + + # Must not raise. + evaluator.evaluate(_ctx("this is blocked")) + + +# --------------------------------------------------------------------------- +# AUDIT mode +# --------------------------------------------------------------------------- + + +def test_audit_mode_transforms_deny_to_audit(audit_setup: Any) -> None: + """AUDIT mode evaluates rules but never returns a DENY final_action.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("contains secret data")) + + assert len(audit.evaluations) == 1 + assert audit.evaluations[0].matched is True + assert audit.evaluations[0].action == Action.DENY # raw rule action preserved + assert audit.final_action == Action.AUDIT # mode-adjusted + assert audit.metadata["audit_mode_would_deny"] is True + + +def test_audit_mode_does_not_raise_on_deny_match(audit_setup: Any) -> None: + """AUDIT mode never raises GovernanceBlockException, even on a DENY hit.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("this is blocked")) # must not raise + + +def test_audit_mode_emits_per_rule_and_summary_events(audit_setup: Any) -> None: + """One rule_evaluation event per rule + one hook_summary per evaluate().""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("contains secret")) + + rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION] + summary_events = [e for e in sink.events if e.event_type == EventType.HOOK_END] + assert len(rule_events) == 1 + assert rule_events[0].hook == "BEFORE_AGENT" + assert rule_events[0].data["policy_id"] == "TEST-01" + assert rule_events[0].data["matched"] is True + assert rule_events[0].data["action"] == "deny" + # Mode travels on every event (PR #122 contract). + assert rule_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT + + assert len(summary_events) == 1 + assert summary_events[0].data["matched_rules"] == 1 + assert summary_events[0].data["final_action"] == "audit" + assert summary_events[0].data["enforcement_mode"] == EnforcementMode.AUDIT + + +def test_audit_mode_unmatched_rule_logged_as_allow(audit_setup: Any) -> None: + """Unmatched rules still emit a rule_evaluation event with action='allow'.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + evaluator.evaluate(_ctx("benign user query")) + + rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION] + assert len(rule_events) == 1 + assert rule_events[0].data["matched"] is False + assert rule_events[0].data["action"] == "allow" + + +# --------------------------------------------------------------------------- +# ENFORCE mode +# --------------------------------------------------------------------------- + + +def test_enforce_mode_raises_on_deny_match(audit_setup: Any) -> None: + """ENFORCE mode raises GovernanceBlockException when a DENY rule matches.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + with pytest.raises(GovernanceBlockException) as exc_info: + evaluator.evaluate(_ctx("input is blocked")) + + exc = exc_info.value + assert exc.rule_id == "TEST-01" + assert exc.rule_name == "Test deny on input" + assert exc.audit_record is not None + assert exc.audit_record.final_action == Action.DENY + + +def test_enforce_mode_emits_audit_before_raising(audit_setup: Any) -> None: + """The audit trail must be emitted even when the call raises.""" + manager, sink = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + with pytest.raises(GovernanceBlockException): + evaluator.evaluate(_ctx("contains blocked")) + + rule_events = [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION] + summary_events = [e for e in sink.events if e.event_type == EventType.HOOK_END] + assert len(rule_events) == 1 + assert summary_events[0].data["final_action"] == "deny" + assert summary_events[0].data["enforcement_mode"] == EnforcementMode.ENFORCE + + +def test_enforce_mode_returns_record_when_no_rule_matches(audit_setup: Any) -> None: + """No DENY hit → no raise; the AuditRecord is returned normally.""" + manager, _ = audit_setup + evaluator = _build_evaluator( + _deny_rule_on_input_contains("blocked"), + EnforcementMode.ENFORCE, + audit_manager=manager, + ) + + audit = evaluator.evaluate(_ctx("benign query")) + + assert audit.final_action == Action.ALLOW + assert audit.evaluations[0].matched is False + + +# --------------------------------------------------------------------------- +# Sink-failure isolation + no-audit-manager case +# --------------------------------------------------------------------------- + + +def test_sink_failure_does_not_propagate_or_block_evaluation( + audit_setup: Any, +) -> None: + """A broken sink must not make evaluate() raise or lose its return value. + + Contract: AuditManager wraps each sink's emit() in try/except with a + per-sink failure counter (circuit-breaker), so a sink exception + never propagates back to the evaluator. + """ + manager, capturing_sink = audit_setup + + class _BrokenSink(AuditSink): + @property + def name(self) -> str: + return "broken" + + def emit(self, event: AuditEvent) -> None: + raise RuntimeError("sink broke") + + manager.register_sink(_BrokenSink()) + + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=manager, + ) + + # Must complete without raising even with a broken sink registered. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + # The non-broken capturing sink still got its events. + assert any(e.event_type == EventType.RULE_EVALUATION for e in capturing_sink.events) + + +def test_no_audit_manager_short_circuits_emission() -> None: + """``audit_manager=None`` is a no-op — evaluation still completes. + + Replaces the previous test that mocked ``get_audit_manager`` to + raise. With explicit injection, the equivalent "no manager + available" state is simply ``audit_manager=None`` at construction. + """ + evaluator = _build_evaluator( + _deny_rule_on_input_contains("secret"), + EnforcementMode.AUDIT, + audit_manager=None, + ) + + # Must complete, return record, and not raise. + audit = evaluator.evaluate(_ctx("contains secret")) + + assert audit.final_action == Action.AUDIT + assert audit.evaluations[0].matched is True + + +# --------------------------------------------------------------------------- +# Protocol conformance smoke test +# --------------------------------------------------------------------------- + + +def test_governance_evaluator_satisfies_evaluator_protocol() -> None: + """GovernanceEvaluator must be usable wherever EvaluatorProtocol is expected. + + Mirrors the pattern from test_detached_bridge_satisfies_debug_protocol — + an explicit assignment to the protocol-typed variable documents the + structural contract. + """ + from uipath.core.adapters import EvaluatorProtocol + + evaluator: EvaluatorProtocol = GovernanceEvaluator(PolicyIndex()) + assert isinstance(evaluator, EvaluatorProtocol) + + +def test_evaluator_protocol_methods_resolvable_on_concrete() -> None: + """Every method the protocol declares must be callable on the concrete impl.""" + from uipath.core.adapters import EvaluatorProtocol + + evaluator: Any = GovernanceEvaluator(PolicyIndex()) + for method_name in ( + "evaluate_before_agent", + "evaluate_after_agent", + "evaluate_before_model", + "evaluate_after_model", + "evaluate_tool_call", + "evaluate_after_tool", + ): + assert callable(getattr(evaluator, method_name)) + # The variable annotation also asserts type compatibility at runtime + # because EvaluatorProtocol is @runtime_checkable. + assert isinstance(evaluator, EvaluatorProtocol) diff --git a/tests/test_evaluator_operators.py b/tests/test_evaluator_operators.py new file mode 100644 index 00000000..9f050557 --- /dev/null +++ b/tests/test_evaluator_operators.py @@ -0,0 +1,688 @@ +"""Tests for ``GovernanceEvaluator`` operators and field resolution. + +Covers each operator implemented in :meth:`_apply_operator` plus the +``_check_*`` helper functions (vader, encoding, entropy, incident, +commitment) and the ``evaluate_*`` dispatchers. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from uipath.core.governance import EnforcementMode +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance.native.evaluator import ( + _INCIDENT_PATTERNS, + GovernanceEvaluator, +) +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + Logic, + PolicyIndex, + PolicyPack, + Rule, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _evaluator() -> GovernanceEvaluator: + """Build a GovernanceEvaluator with an empty PolicyIndex (operators only). + + AUDIT is the default mode; operator tests don't care about + enforcement and we don't need an audit manager for purely + operator-level assertions. + """ + return GovernanceEvaluator(policy_index=PolicyIndex()) + + +def _ctx(**fields: Any) -> CheckContext: + """Construct a CheckContext with sensible defaults plus overrides.""" + defaults: dict[str, Any] = dict( + hook=LifecycleHook.AFTER_MODEL, + agent_name="agent", + runtime_id="rt-1", + ) + defaults.update(fields) + return CheckContext(**defaults) + + +def _rule_with_condition( + operator: str, field: str, value, *, negate: bool = False +) -> Rule: + return Rule( + rule_id="r1", + name="r1", + clause="", + hook=LifecycleHook.AFTER_MODEL, + action=Action.AUDIT, + checks=[ + Check( + conditions=[ + Condition( + operator=operator, field=field, value=value, negate=negate + ) + ], + ) + ], + ) + + +# Mode is per-instance now — tests construct evaluators with the mode +# they need via the ``enforcement_mode`` kwarg. No process-globals to +# reset. + + +# --------------------------------------------------------------------------- +# Field resolution — _get_field_value +# --------------------------------------------------------------------------- + + +def test_get_field_value_top_level_attr() -> None: + ev = _evaluator() + ctx = _ctx(model_output="hello") + assert ev._get_field_value("model_output", ctx) == "hello" + + +def test_get_field_value_dotted_path_into_dict() -> None: + ev = _evaluator() + ctx = _ctx(session_state={"tool_calls": 7}) + assert ev._get_field_value("session_state.tool_calls", ctx) == 7 + + +def test_get_field_value_missing_segment_returns_none() -> None: + ev = _evaluator() + ctx = _ctx() + assert ev._get_field_value("nonexistent", ctx) is None + assert ev._get_field_value("session_state.absent", ctx) is None + + +# --------------------------------------------------------------------------- +# Existence / guardrail_fallback (special-cased before the None check) +# --------------------------------------------------------------------------- + + +def test_exists_true_when_value_present() -> None: + ev = _evaluator() + ctx = _ctx(model_output="x") + assert ( + ev._apply_operator("exists", ev._get_field_value("model_output", ctx), None) + is True + ) + + +def test_exists_false_when_missing() -> None: + ev = _evaluator() + assert ev._apply_operator("exists", None, None) is False + + +def test_not_exists_inverse() -> None: + ev = _evaluator() + assert ev._apply_operator("not_exists", None, None) is True + assert ev._apply_operator("not_exists", "x", None) is False + + +def test_guardrail_fallback_mapped_and_disabled_fires() -> None: + ev = _evaluator() + result = ev._apply_operator( + "guardrail_fallback", + None, + {"mapped_to_uipath": True, "policy_enabled": False, "validator": "pii"}, + ) + assert result is True + + +@pytest.mark.parametrize( + "cfg", + [ + {"mapped_to_uipath": False, "policy_enabled": False}, + {"mapped_to_uipath": True, "policy_enabled": True}, + {"mapped_to_uipath": False, "policy_enabled": True}, + ], +) +def test_guardrail_fallback_silent_when_not_mapped_or_enabled( + cfg: dict[str, Any], +) -> None: + ev = _evaluator() + assert ev._apply_operator("guardrail_fallback", None, cfg) is False + + +def test_guardrail_fallback_non_dict_value_silent() -> None: + ev = _evaluator() + assert ev._apply_operator("guardrail_fallback", None, "string") is False + + +# --------------------------------------------------------------------------- +# None-field short-circuit (everything except exists / guardrail_fallback) +# --------------------------------------------------------------------------- + + +def test_other_operators_short_circuit_when_field_is_none() -> None: + ev = _evaluator() + for op in ("contains", "regex", "in_list", "gt"): + assert ev._apply_operator(op, None, "anything") is False, op + + +# --------------------------------------------------------------------------- +# Numeric operators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op,lhs,rhs,expected", + [ + ("gt", 5, 3, True), + ("gt", 3, 5, False), + ("gt", 3, 3, False), + ("gte", 3, 3, True), + ("gte", 2, 3, False), + ("lt", 1, 3, True), + ("lt", 3, 3, False), + ("lte", 3, 3, True), + ("lte", 4, 3, False), + ], +) +def test_numeric_operators(op: str, lhs: float, rhs: float, expected: bool) -> None: + assert _evaluator()._apply_operator(op, lhs, rhs) is expected + + +def test_numeric_operators_handle_string_coercion() -> None: + ev = _evaluator() + assert ev._apply_operator("gt", "5", "3") is True + + +def test_numeric_operators_return_false_on_uncoercible() -> None: + ev = _evaluator() + assert ev._apply_operator("gt", "not-a-number", 3) is False + assert ev._apply_operator("gt", 3, "not-a-number") is False + + +# --------------------------------------------------------------------------- +# String operators +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "op,lhs,rhs,expected", + [ + ("equals", "abc", "abc", True), + ("equals", "abc", "ABC", False), # equals is case-sensitive + ("eq", "x", "x", True), + ("not_equals", "abc", "xyz", True), + ("ne", "x", "x", False), + ("contains", "Hello World", "world", True), # case-insensitive + ("contains", "Hello", "xyz", False), + ("not_contains", "Hello", "xyz", True), + ("not_contains", "Hello", "hello", False), + ], +) +def test_string_operators(op: str, lhs: str, rhs: str, expected: bool) -> None: + assert _evaluator()._apply_operator(op, lhs, rhs) is expected + + +def test_regex_matches_pattern() -> None: + ev = _evaluator() + assert ev._apply_operator("regex", "Cost: $1,200", r"\$\d+") is True + + +def test_regex_matches_alias() -> None: + """``matches`` is documented as a synonym for ``regex``.""" + ev = _evaluator() + assert ev._apply_operator("matches", "abc-123", r"\d+") is True + + +def test_regex_invalid_pattern_returns_false() -> None: + """Malformed regex is logged and silently returns False.""" + ev = _evaluator() + assert ev._apply_operator("regex", "anything", "(unclosed") is False + + +# --------------------------------------------------------------------------- +# List operators +# --------------------------------------------------------------------------- + + +def test_in_list_membership() -> None: + ev = _evaluator() + assert ( + ev._apply_operator("in_list", "delete_file", ["shell", "delete_file"]) is True + ) + assert ev._apply_operator("in_list", "ls", ["shell", "delete_file"]) is False + + +def test_in_list_non_list_value_returns_false() -> None: + ev = _evaluator() + assert ev._apply_operator("in_list", "x", "not a list") is False + + +def test_not_in_list_inverse() -> None: + ev = _evaluator() + assert ev._apply_operator("not_in_list", "ls", ["shell"]) is True + assert ev._apply_operator("not_in_list", "shell", ["shell"]) is False + + +def test_not_in_list_non_list_value_returns_true() -> None: + """``not_in_list`` against a non-list value safely returns True + (nothing is in a non-list).""" + ev = _evaluator() + assert ev._apply_operator("not_in_list", "x", "not a list") is True + + +# --------------------------------------------------------------------------- +# Unknown operator +# --------------------------------------------------------------------------- + + +def test_unknown_operator_returns_false() -> None: + """Unknown operator strings log a debug message and return False.""" + ev = _evaluator() + assert ev._apply_operator("never_heard_of_this", "x", "y") is False + + +# --------------------------------------------------------------------------- +# Negate flag — flips the result +# --------------------------------------------------------------------------- + + +def test_condition_negate_flips_result() -> None: + ev = _evaluator() + ctx = _ctx(model_output="hello") + # contains "hello" → matches; negate inverts to False. + cond = Condition( + operator="contains", + field="model_output", + value="hello", + negate=True, + ) + assert ev._evaluate_condition(cond, ctx) is False + cond2 = Condition( + operator="contains", + field="model_output", + value="world", + negate=True, + ) + assert ev._evaluate_condition(cond2, ctx) is True + + +# --------------------------------------------------------------------------- +# Check-level logic: "all" (AND) vs "any" (OR), and empty-conditions +# --------------------------------------------------------------------------- + + +def test_empty_check_conditions_always_match() -> None: + """A check with no conditions trivially matches — surfaces rule shape bugs.""" + ev = _evaluator() + check = Check(conditions=[], logic=Logic.ALL) + matched, _ = ev._evaluate_check(check, _ctx()) + assert matched is True + + +def test_check_logic_all_requires_every_condition() -> None: + ev = _evaluator() + check = Check( + conditions=[ + Condition(operator="contains", field="model_output", value="a"), + Condition(operator="contains", field="model_output", value="missing"), + ], + logic=Logic.ALL, + ) + matched, _ = ev._evaluate_check(check, _ctx(model_output="a only")) + assert matched is False + + +def test_check_logic_any_requires_one_condition() -> None: + ev = _evaluator() + check = Check( + conditions=[ + Condition(operator="contains", field="model_output", value="present"), + Condition(operator="contains", field="model_output", value="absent"), + ], + logic=Logic.ANY, + ) + matched, detail = ev._evaluate_check(check, _ctx(model_output="present text")) + assert matched is True + # detail is the check's message on match; empty by default in our builder. + assert detail == "" + + +# --------------------------------------------------------------------------- +# VADER sentiment +# --------------------------------------------------------------------------- + + +def test_vader_concern_negative_text_fires() -> None: + """A clearly-negative sentence trips the default threshold of -0.3.""" + assert ( + GovernanceEvaluator._check_vader_concern( + "I absolutely hate this terrible, awful product.", {"threshold": -0.3} + ) + is True + ) + + +def test_vader_concern_positive_text_does_not_fire() -> None: + assert ( + GovernanceEvaluator._check_vader_concern( + "This is wonderful and I love it!", {"threshold": -0.3} + ) + is False + ) + + +def test_vader_concern_empty_text_silent() -> None: + assert GovernanceEvaluator._check_vader_concern("", {}) is False + assert GovernanceEvaluator._check_vader_concern(" ", {}) is False + + +def test_vader_concern_threshold_as_scalar() -> None: + """``params`` may be a bare number; the operator coerces.""" + assert GovernanceEvaluator._check_vader_concern("I hate everything", -0.3) is True + + +def test_vader_concern_invalid_threshold_falls_back() -> None: + """Non-numeric scalar params fall back to the documented default.""" + # "garbage" -> default -0.3 → should still classify clear negative + assert ( + GovernanceEvaluator._check_vader_concern( + "I hate this awful, terrible thing", "garbage" + ) + is True + ) + + +# --------------------------------------------------------------------------- +# Encoding integrity +# --------------------------------------------------------------------------- + + +def test_encoding_concern_clean_text_silent() -> None: + assert ( + GovernanceEvaluator._check_encoding_concern( + "Just a normal English sentence with no corruption.", {} + ) + is False + ) + + +def test_encoding_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_encoding_concern("", {}) is False + + +def test_encoding_concern_replacement_chars_fire() -> None: + """U+FFFD replacement chars are a strong corruption signal.""" + text = "Hello � � world" + assert ( + GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2}) + is True + ) + + +def test_encoding_concern_mojibake_bigrams_fire() -> None: + """Latin-1-as-UTF-8 mojibake patterns are a known corruption shape.""" + text = "é é hello é" + assert ( + GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2}) + is True + ) + + +def test_encoding_concern_hex_escape_literals_fire() -> None: + """Literal ``\\xHH`` sequences mean raw bytes leaked into a string.""" + text = r"Hello \x80 \x81 \x82 world" + assert ( + GovernanceEvaluator._check_encoding_concern(text, {"min_corruption_events": 2}) + is True + ) + + +# --------------------------------------------------------------------------- +# Entropy (stdlib only — deterministic) +# --------------------------------------------------------------------------- + + +def test_entropy_concern_normal_english_does_not_fire() -> None: + """English prose entropy lands ~3.5–4.5 bits/byte — inside default range.""" + text = "The quick brown fox jumps over the lazy dog." * 5 + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5}) + is False + ) + + +def test_entropy_concern_low_entropy_fires() -> None: + """Highly repetitive text approaches 0 bits/byte.""" + text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 7.5}) + is True + ) + + +def test_entropy_concern_high_entropy_fires() -> None: + """Random-ish bytes approach 8 bits/byte.""" + # Build text with many distinct chars to push entropy high. + text = "".join(chr(c) for c in range(32, 127)) * 5 + assert ( + GovernanceEvaluator._check_entropy_concern(text, {"min": 1.5, "max": 6.0}) + is True + ) + + +def test_entropy_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_entropy_concern("", {}) is False + + +def test_entropy_concern_non_dict_params_uses_defaults() -> None: + """Non-dict params don't crash; defaults apply.""" + # Normal English prose still won't trip the default min=1.5, max=7.5 range. + text = "The quick brown fox jumps over the lazy dog." + assert GovernanceEvaluator._check_entropy_concern(text, "garbage") is False + + +# --------------------------------------------------------------------------- +# Incident taxonomy (regex-based, deterministic) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text,expected_category", + [ + ("I cannot help with that.", "safety_refusal"), + ("I'm sorry, but I cannot answer.", "safety_refusal"), + ("500 internal server error", "tool_failure"), + ("Connection refused", "tool_failure"), + ("timed out", "tool_failure"), + ("401 unauthorized", "auth_failure"), + ("authentication failed", "auth_failure"), + ("429", "quota_exceeded"), + ("rate limit exceeded", "quota_exceeded"), + ("I made that up", "hallucination"), + ("I don't actually know", "hallucination"), + ], +) +def test_incident_concern_categorical_matches( + text: str, expected_category: str +) -> None: + """Each category in ``_INCIDENT_PATTERNS`` has at least one matching exemplar.""" + assert expected_category in _INCIDENT_PATTERNS + assert GovernanceEvaluator._check_incident_concern(text, {}) is True + + +def test_incident_concern_unmatched_silent() -> None: + assert ( + GovernanceEvaluator._check_incident_concern( + "All systems operating normally.", {} + ) + is False + ) + + +def test_incident_concern_empty_silent() -> None: + assert GovernanceEvaluator._check_incident_concern("", {}) is False + + +def test_incident_concern_category_filter() -> None: + """Limit scanning to a subset of categories via ``categories`` param.""" + # "401 unauthorized" hits auth_failure; with only quota_exceeded enabled, + # the scanner should miss it. + assert ( + GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["quota_exceeded"]} + ) + is False + ) + # With auth_failure enabled, it fires. + assert ( + GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["auth_failure"]} + ) + is True + ) + + +def test_incident_concern_unknown_category_silently_dropped() -> None: + """Categories the system doesn't know about are silently ignored.""" + # Only the unknown category is requested — falls back to no categories, + # so even matching text doesn't fire. + result = GovernanceEvaluator._check_incident_concern( + "401 unauthorized", {"categories": ["unknown_cat_xyz"]} + ) + assert result is False + + +# --------------------------------------------------------------------------- +# evaluate_* dispatchers — verify they build the right CheckContext +# --------------------------------------------------------------------------- + + +def _record_context_evaluator() -> tuple[GovernanceEvaluator, dict[str, Any]]: + """Patch evaluate() to capture the context it receives instead of running rules.""" + captured: dict[str, Any] = {} + ev = _evaluator() + + def _fake_evaluate(ctx: CheckContext) -> Any: + captured["ctx"] = ctx + from datetime import datetime, timezone + + from uipath.core.governance.models import AuditRecord + + return AuditRecord( + timestamp=datetime.now(timezone.utc), + agent_name=ctx.agent_name, + runtime_id=ctx.runtime_id, + hook=ctx.hook, + evaluations=[], + final_action=Action.ALLOW, + ) + + ev.evaluate = _fake_evaluate # type: ignore[assignment] + return ev, captured + + +def test_evaluate_before_agent_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_before_agent( + agent_input="user-text", + agent_name="a", + runtime_id="r", + model_name="gpt-5", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.BEFORE_AGENT + assert ctx.agent_input == "user-text" + assert ctx.model_name == "gpt-5" + + +def test_evaluate_after_agent_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_agent( + agent_output="reply", + agent_name="a", + runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_AGENT + assert ctx.agent_output == "reply" + + +def test_evaluate_before_model_carries_messages() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_before_model( + model_input="prompt", + agent_name="a", + runtime_id="r", + messages=[{"role": "user", "content": "hi"}], + model_name="gpt-5", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.BEFORE_MODEL + assert ctx.model_input == "prompt" + assert ctx.messages == [{"role": "user", "content": "hi"}] + + +def test_evaluate_after_model_builds_context() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_model( + model_output="resp", + agent_name="a", + runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_MODEL + assert ctx.model_output == "resp" + + +def test_evaluate_tool_call_carries_args() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_tool_call( + tool_name="search", + tool_args={"q": "x"}, + agent_name="a", + runtime_id="r", + session_state={"tool_calls": 1}, + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.TOOL_CALL + assert ctx.tool_name == "search" + assert ctx.tool_args == {"q": "x"} + assert ctx.session_state == {"tool_calls": 1} + + +def test_evaluate_after_tool_carries_result() -> None: + ev, captured = _record_context_evaluator() + ev.evaluate_after_tool( + tool_name="search", + tool_result="some-data", + agent_name="a", + runtime_id="r", + ) + ctx = captured["ctx"] + assert ctx.hook == LifecycleHook.AFTER_TOOL + assert ctx.tool_name == "search" + assert ctx.tool_result == "some-data" + + +# --------------------------------------------------------------------------- +# DISABLED mode — evaluate() short-circuits without emitting audit +# --------------------------------------------------------------------------- + + +def test_disabled_mode_returns_empty_audit_record() -> None: + """DISABLED mode short-circuits the rule loop and audit emission.""" + rule = _rule_with_condition("contains", "model_output", "anything") + pack = PolicyPack(name="p", version="1", description="", rules=[rule]) + idx = PolicyIndex() + idx.add_pack(pack) + ev = GovernanceEvaluator( + policy_index=idx, enforcement_mode=EnforcementMode.DISABLED + ) + + audit = ev.evaluate(_ctx(model_output="contains anything")) + assert audit.final_action == Action.ALLOW + assert audit.evaluations == [] diff --git a/tests/test_evaluator_telemetry.py b/tests/test_evaluator_telemetry.py new file mode 100644 index 00000000..55b61cd9 --- /dev/null +++ b/tests/test_evaluator_telemetry.py @@ -0,0 +1,289 @@ +"""Tests that the evaluator passes the new telemetry fields downstream. + +The evaluator must: + +- Measure per-rule and per-hook wall-clock duration. +- Collect disabled rule ids into ``skipped_policy_names``. +- Count matched UiPath-mapped guardrails (``guardrail_dispatched_count``). +- Hand all of the above to ``AuditManager.emit_rule_evaluation`` and + ``emit_hook_summary`` so the ``TrackEventAuditSink`` payload carries + them. + +We capture events via a manager registered with no default sinks + +one ``_CapturingSink`` so the assertions see exactly the per-rule and +hook-summary events the evaluator emits. +""" + +from __future__ import annotations + +import pytest +from uipath.core.governance import EnforcementMode +from uipath.core.governance.models import Action, LifecycleHook + +from uipath.runtime.governance._audit.base import ( + AuditEvent, + AuditManager, + AuditSink, + EventType, +) +from uipath.runtime.governance.native.evaluator import GovernanceEvaluator +from uipath.runtime.governance.native.models import ( + Check, + CheckContext, + Condition, + PolicyIndex, + PolicyPack, + Rule, +) + + +class _CapturingSink(AuditSink): + def __init__(self) -> None: + self.events: list[AuditEvent] = [] + + @property + def name(self) -> str: + return "capturing" + + def emit(self, event: AuditEvent) -> None: + self.events.append(event) + + +def _rule( + rule_id: str, + *, + enabled: bool = True, + matches: bool = True, + action: Action = Action.DENY, +) -> Rule: + """A rule whose ``contains`` check always (or never) matches the input.""" + return Rule( + rule_id=rule_id, + name=f"rule-{rule_id}", + clause=rule_id, + hook=LifecycleHook.BEFORE_AGENT, + action=action, + enabled=enabled, + checks=[ + Check( + conditions=[ + Condition( + operator="contains", + field="agent_input", + value="needle" if matches else "absent-needle", + ) + ], + action=action, + message=f"matched {rule_id}", + ) + ], + ) + + +def _pack(*rules: Rule) -> PolicyIndex: + idx = PolicyIndex() + idx.add_pack( + PolicyPack(name="test_pack", version="1.0", description="t", rules=list(rules)) + ) + return idx + + +def _ctx() -> CheckContext: + return CheckContext( + hook=LifecycleHook.BEFORE_AGENT, + agent_name="agent-x", + runtime_id="run-1", + agent_input="needle", + ) + + +@pytest.fixture +def sink_and_manager() -> tuple[_CapturingSink, AuditManager]: + """Sink-capturing manager with no default sinks (no traces, no track_events).""" + sink = _CapturingSink() + mgr = AuditManager(register_default_sinks=False) + mgr.register_sink(sink) + return sink, mgr + + +def _rule_events(sink: _CapturingSink) -> list[AuditEvent]: + return [e for e in sink.events if e.event_type == EventType.RULE_EVALUATION] + + +def _hook_summary(sink: _CapturingSink) -> AuditEvent: + summaries = [e for e in sink.events if e.event_type == EventType.HOOK_END] + assert len(summaries) == 1, f"expected 1 hook summary, got {len(summaries)}" + return summaries[0] + + +# --------------------------------------------------------------------------- +# Per-rule timing +# --------------------------------------------------------------------------- + + +def test_rule_evaluation_carries_duration_ms( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack(_rule("A.1.1")), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + rule_evs = _rule_events(sink) + assert len(rule_evs) == 1 + duration = rule_evs[0].data["duration_ms"] + assert isinstance(duration, float) + assert duration >= 0.0 + + +def test_rule_evaluation_mapped_to_uipath_is_false_for_native( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + """Native rules (no guardrail_fallback) always emit ``mapped_to_uipath=False``.""" + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack(_rule("A.1.1")), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + rule_evs = _rule_events(sink) + assert rule_evs[0].data["mapped_to_uipath"] is False + + +# --------------------------------------------------------------------------- +# Hook-summary aggregates +# --------------------------------------------------------------------------- + + +def test_hook_summary_carries_total_duration( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack(_rule("A.1.1"), _rule("A.1.2", matches=False)), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + summary = _hook_summary(sink) + duration = summary.data["duration_ms"] + assert isinstance(duration, float) + assert duration >= 0.0 + + +def test_hook_summary_tracks_skipped_disabled_rules( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + """Disabled rules appear in ``skipped_policy_names`` (and skipped_count).""" + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack( + _rule("A.1.1"), # enabled, matches + _rule("A.1.2", enabled=False), # DISABLED + _rule("A.1.3", enabled=False), # DISABLED + ), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + summary = _hook_summary(sink) + assert summary.data["skipped_count"] == 2 + assert set(summary.data["skipped_policy_names"]) == {"A.1.2", "A.1.3"} + + +def test_hook_summary_passed_and_denied_counts( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + """One match + two non-matches → denied=1, passed=2, matched_rules=1.""" + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack( + _rule("A.1.1"), # matches (DENY) + _rule("A.1.2", matches=False), # passes + _rule("A.1.3", matches=False), # passes + ), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + summary = _hook_summary(sink) + assert summary.data["denied_count"] == 1 + assert summary.data["passed_count"] == 2 + # ``matched_rules`` keeps its historical "any check matched" sense. + assert summary.data["matched_rules"] == 1 + + +def test_hook_summary_matched_allow_rule_counts_as_passed( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + """A matched rule with action=ALLOW is a positive informational match. + + It should NOT contribute to ``denied_count`` — it rolls into + ``passed_count`` instead. ``matched_rules`` (raw count) still + includes it for backward compatibility with the legacy traces sink. + """ + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack( + _rule("A.1.1"), # matches (DENY) + _rule("A.1.2", action=Action.ALLOW), # matches (ALLOW) — positive + _rule("A.1.3", matches=False), # passes (unmatched) + ), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + summary = _hook_summary(sink) + # 3 total rules; only the explicit DENY counts as a denial. + assert summary.data["denied_count"] == 1 + # ``passed_count`` includes the unmatched rule AND the matched-allow rule. + assert summary.data["passed_count"] == 2 + # Raw ``matched_rules`` still reflects "any check matched" — both + # the DENY and the ALLOW match contribute. + assert summary.data["matched_rules"] == 2 + + +def test_hook_summary_guardrail_dispatched_count_for_native_rules_is_zero( + sink_and_manager: tuple[_CapturingSink, AuditManager], +) -> None: + """Without guardrail_fallback conditions, dispatched count is 0.""" + sink, mgr = sink_and_manager + try: + evaluator = GovernanceEvaluator( + _pack(_rule("A.1.1")), + enforcement_mode=EnforcementMode.AUDIT, + audit_manager=mgr, + ) + evaluator.evaluate(_ctx()) + finally: + mgr.close() + + summary = _hook_summary(sink) + assert summary.data["guardrail_dispatched_count"] == 0 diff --git a/tests/test_governance_metadata.py b/tests/test_governance_metadata.py new file mode 100644 index 00000000..0feb2640 --- /dev/null +++ b/tests/test_governance_metadata.py @@ -0,0 +1,81 @@ +"""Tests for :class:`GovernanceRuntimeMetadata`. + +The dataclass carries the per-runtime constants every governance +telemetry event stamps. Defaults must keep telemetry flowing when the +host hasn't populated agent type / framework yet, and the runtime +version must resolve from installed package metadata when available. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from uipath.runtime.governance._audit.metadata import ( + NATIVE_EXECUTION_ENGINE, + GovernanceRuntimeMetadata, + _resolve_runtime_version, +) + + +def test_defaults() -> None: + """Default-constructed metadata uses native engine + ``unknown`` slots.""" + meta = GovernanceRuntimeMetadata() + assert meta.execution_engine == NATIVE_EXECUTION_ENGINE + assert meta.agent_type == "unknown" + assert meta.agent_framework == "unknown" + # runtime_version is resolved at construction; either real or "unknown" + assert isinstance(meta.runtime_version, str) + assert meta.runtime_version != "" + + +def test_explicit_overrides_persist() -> None: + """Host-supplied values override the defaults verbatim.""" + meta = GovernanceRuntimeMetadata( + execution_engine="agt", + agent_type="uipath_coded", + agent_framework="langchain", + runtime_version="1.2.3", + ) + assert meta.execution_engine == "agt" + assert meta.agent_type == "uipath_coded" + assert meta.agent_framework == "langchain" + assert meta.runtime_version == "1.2.3" + + +def test_frozen() -> None: + """Dataclass is frozen — host can't mutate per-run constants mid-run.""" + meta = GovernanceRuntimeMetadata() + try: + meta.agent_type = "other" # type: ignore[misc] + except Exception as exc: + assert "frozen" in str(exc).lower() or "cannot assign" in str(exc).lower() + else: + raise AssertionError("frozen dataclass must reject attribute writes") + + +def test_as_payload_contains_all_four_fields() -> None: + """``as_payload`` is the canonical merge-into-event-data dict shape.""" + meta = GovernanceRuntimeMetadata( + execution_engine="agt", + agent_type="uipath_coded", + agent_framework="langchain", + runtime_version="1.2.3", + ) + payload = meta.as_payload() + assert payload == { + "execution_engine": "agt", + "agent_type": "uipath_coded", + "agent_framework": "langchain", + "runtime_version": "1.2.3", + } + + +def test_runtime_version_fallback_on_missing_package() -> None: + """A source checkout with no installed metadata falls back to ``unknown``.""" + from importlib.metadata import PackageNotFoundError + + with patch( + "uipath.runtime.governance._audit.metadata.version", + side_effect=PackageNotFoundError("uipath-runtime"), + ): + assert _resolve_runtime_version() == "unknown" diff --git a/tests/test_governance_runtime.py b/tests/test_governance_runtime.py new file mode 100644 index 00000000..b2ca3e3d --- /dev/null +++ b/tests/test_governance_runtime.py @@ -0,0 +1,630 @@ +"""Tests for :class:`UiPathGovernedRuntime` — pure resolved-policy wrapper. + +The runtime takes an already-resolved :class:`PolicyIndex` + +:class:`EnforcementMode` at construction. The caller resolves and +compiles the policy on its own side; the runtime is format-agnostic +and never sees a wire representation. Tests here confirm the wrapper +holds the snapshot and passes execution straight through to the +delegate. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from uipath.core.governance import EnforcementMode + +from uipath.runtime.governance.native.models import PolicyIndex +from uipath.runtime.governance.runtime import UiPathGovernedRuntime + +# --------------------------------------------------------------------------- +# UiPathGovernedRuntime — passthroughs +# --------------------------------------------------------------------------- + + +class _StubDelegate: + """Captures delegate calls so the passthroughs can be asserted.""" + + def __init__(self) -> None: + self.execute_calls: list[tuple[Any, Any]] = [] + self.stream_calls: list[tuple[Any, Any]] = [] + self.disposed = False + self.schema_called = False + + async def execute(self, input: Any = None, options: Any = None) -> Any: + self.execute_calls.append((input, options)) + return "result" + + async def stream(self, input: Any = None, options: Any = None) -> Any: + self.stream_calls.append((input, options)) + for event in ("a", "b"): + yield event + + async def get_schema(self) -> Any: + self.schema_called = True + return "schema" + + async def dispose(self) -> None: + self.disposed = True + + +def _make_runtime( + delegate: _StubDelegate | None = None, + *, + policy_index: PolicyIndex | None = None, + enforcement_mode: EnforcementMode = EnforcementMode.AUDIT, +) -> UiPathGovernedRuntime: + """Build a runtime with sensible test defaults.""" + return UiPathGovernedRuntime( + delegate or _StubDelegate(), + policy_index if policy_index is not None else PolicyIndex(), + enforcement_mode, + ) + + +# --------------------------------------------------------------------------- +# Snapshot stored internally — not exposed as a public property +# --------------------------------------------------------------------------- + + +def test_resolved_policy_index_is_held_for_evaluator_use() -> None: + """The wrapper stores the resolved snapshot; the evaluator reads it. + + The wrapper is oblivious to how the index was built (any wire + format, any compilation path, or a hand-constructed test fixture) + — it just holds the instance. A plain ``PolicyIndex()`` suffices + to prove the reference is preserved. + """ + index = PolicyIndex() + runtime = _make_runtime(policy_index=index) + # Internal attribute — verify the wrapper kept the exact instance. + assert runtime._policy_index is index + + +def test_enforcement_mode_is_held_for_evaluator_use() -> None: + """The wrapper stores the mode supplied at construction.""" + runtime = _make_runtime(enforcement_mode=EnforcementMode.ENFORCE) + assert runtime._enforcement_mode is EnforcementMode.ENFORCE + + +def test_empty_policy_index_is_a_valid_construction() -> None: + """``PolicyIndex()`` with no packs is acceptable — wrapper attaches without rules.""" + runtime = _make_runtime(policy_index=PolicyIndex()) + assert runtime._policy_index.total_rules == 0 + + +# --------------------------------------------------------------------------- +# Passthrough behavior +# --------------------------------------------------------------------------- + + +async def test_governance_runtime_execute_delegates() -> None: + delegate = _StubDelegate() + runtime = _make_runtime(delegate) + + result = await runtime.execute({"x": 1}) + + assert result == "result" + assert delegate.execute_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_stream_delegates() -> None: + delegate = _StubDelegate() + runtime = _make_runtime(delegate) + + events = [e async for e in runtime.stream({"x": 1})] + + assert events == ["a", "b"] + assert delegate.stream_calls == [({"x": 1}, None)] + + +async def test_governance_runtime_schema_delegates() -> None: + """``get_schema`` still forwards — only ``dispose`` was cut to a no-op.""" + delegate = _StubDelegate() + runtime = _make_runtime(delegate) + + assert await runtime.get_schema() == "schema" + assert delegate.schema_called + + +# --------------------------------------------------------------------------- +# dispose — no-op by contract; caller owns delegate + host cleanup +# --------------------------------------------------------------------------- + + +async def test_dispose_does_not_touch_the_delegate() -> None: + """The wrapper deliberately does not forward ``dispose`` to the delegate. + + Caller-owned lifecycle: whichever code built the delegate is + responsible for disposing it, along with any adjacent resources + the caller wired up (telemetry dispatchers, session pools, etc.). + The wrapper's ``dispose`` exists only to satisfy + :class:`UiPathDisposableProtocol` so the wrapper stays structurally + substitutable for any runtime. + """ + delegate = _StubDelegate() + runtime = _make_runtime(delegate) + + await runtime.dispose() + + # The delegate was never touched — caller must dispose it themselves. + assert delegate.disposed is False + + +async def test_dispose_does_not_raise_when_delegate_would_have_raised() -> None: + """Because the wrapper's ``dispose`` never invokes the delegate, + a delegate whose ``dispose`` raises has no path to surface through + the wrapper. + """ + + class _RaisingDelegate(_StubDelegate): + async def dispose(self) -> None: + raise RuntimeError("delegate would boom — but wrapper never calls this") + + runtime = _make_runtime(_RaisingDelegate()) + + # Must not raise — the wrapper's dispose is a plain no-op. + await runtime.dispose() + + +async def test_dispose_is_idempotent() -> None: + """Two calls in a row must not raise — teardown paths sometimes wire + ``close``/``dispose`` from multiple layers. + """ + runtime = _make_runtime() + + await runtime.dispose() + await runtime.dispose() # second call: no crash, no state to corrupt + + +# --------------------------------------------------------------------------- +# Root-span trace correlation — one trace_id per agent run +# --------------------------------------------------------------------------- + + +async def test_execute_opens_a_root_span_that_covers_the_whole_invocation() -> None: + """A single OTel span wraps BEFORE_AGENT, delegate, and AFTER_AGENT. + + Verifies the unified-trace contract: every code path under + ``execute`` — including any framework adapter span the delegate + opens — sees the same ``trace_id`` and the same current span. The + ``track_events`` sink reads ``trace.get_current_span()`` at emit + time, so this guarantee is what makes its ``operation_id`` + correlate across BEFORE_AGENT, BEFORE_MODEL, AFTER_MODEL, and + AFTER_AGENT events. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + # Replace the global tracer provider for this test so our span + # actually records (the default NoOp provider returns invalid + # contexts). + trace.set_tracer_provider(TracerProvider()) + + seen_trace_ids: list[int] = [] + + class _SpanProbeDelegate: + async def execute(self, input: Any = None, options: Any = None) -> Any: + seen_trace_ids.append(trace.get_current_span().get_span_context().trace_id) + return "result" + + async def stream(self, input: Any = None, options: Any = None) -> Any: + if False: + yield # pragma: no cover + + async def get_schema(self) -> Any: + return "schema" + + async def dispose(self) -> None: + return None + + runtime = UiPathGovernedRuntime( + _SpanProbeDelegate(), + PolicyIndex(), + EnforcementMode.AUDIT, + agent_name="agent-x", + runtime_id="run-1", + ) + + # Outside execute(): no governance span is active. + pre_call_ctx = trace.get_current_span().get_span_context() + assert not pre_call_ctx.is_valid, ( + "no span should be active before execute() opens the wrapper" + ) + + await runtime.execute({"x": 1}) + + # Inside the delegate: a valid span context was current. + assert len(seen_trace_ids) == 1 + assert seen_trace_ids[0] != 0, ( + "delegate must see a valid OTel trace_id from the wrapping span" + ) + + # After execute(): the span has closed. + post_call_ctx = trace.get_current_span().get_span_context() + assert not post_call_ctx.is_valid, "wrapper span must close on return" + + +async def test_execute_span_inherits_host_trace_id_when_one_is_active() -> None: + """When the host opens a span first, our wrapper becomes a child. + + Same ``trace_id`` flows through everything — governance events + emitted by runtime hooks correlate with the host's outer + operation. The host's OTel context is preserved end-to-end. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + trace.set_tracer_provider(TracerProvider()) + tracer = trace.get_tracer("test-host") + + inner_trace_id: list[int] = [] + + class _ProbeDelegate: + async def execute(self, input: Any = None, options: Any = None) -> Any: + inner_trace_id.append(trace.get_current_span().get_span_context().trace_id) + return "result" + + async def stream(self, input: Any = None, options: Any = None) -> Any: + if False: + yield # pragma: no cover + + async def get_schema(self) -> Any: + return "schema" + + async def dispose(self) -> None: + return None + + runtime = UiPathGovernedRuntime( + _ProbeDelegate(), PolicyIndex(), EnforcementMode.AUDIT + ) + + with tracer.start_as_current_span("host-outer") as host_span: + host_trace_id = host_span.get_span_context().trace_id + await runtime.execute({"x": 1}) + + # The trace_id seen inside the delegate equals the host's + # trace_id — the wrapper became a child of the host's span. + assert inner_trace_id == [host_trace_id] + + +async def test_stream_opens_a_root_span_that_covers_iteration() -> None: + """``stream``'s span stays open across the entire ``async for``. + + Holding the span over ``await`` boundaries is safe — Python's + asyncio propagates contextvars (including OTel's current-span + pointer) across suspensions. The delegate (which yields events) + must see the same wrapper span on every iteration. + """ + from opentelemetry import trace + from opentelemetry.sdk.trace import TracerProvider + + trace.set_tracer_provider(TracerProvider()) + + per_event_trace_ids: list[int] = [] + + class _StreamProbeDelegate: + async def execute(self, input: Any = None, options: Any = None) -> Any: + return "result" + + async def stream(self, input: Any = None, options: Any = None) -> Any: + for label in ("a", "b", "c"): + per_event_trace_ids.append( + trace.get_current_span().get_span_context().trace_id + ) + yield label + + async def get_schema(self) -> Any: + return "schema" + + async def dispose(self) -> None: + return None + + runtime = UiPathGovernedRuntime( + _StreamProbeDelegate(), PolicyIndex(), EnforcementMode.AUDIT + ) + + events = [e async for e in runtime.stream({"x": 1})] + + assert events == ["a", "b", "c"] + # Same valid trace_id on every yield — one span covers the whole + # stream. + assert len(set(per_event_trace_ids)) == 1 + assert per_event_trace_ids[0] != 0 + + +# --------------------------------------------------------------------------- +# _serialize_payload — evaluator input/output flattening +# --------------------------------------------------------------------------- + + +def test_serialize_payload_none_returns_empty_string() -> None: + """``None`` becomes ``""`` — the evaluator's regex/sentiment checks + scan a string and would break on a raw ``None``. + """ + from uipath.runtime.governance.runtime import _serialize_payload + + assert _serialize_payload(None) == "" + + +def test_serialize_payload_string_passes_through_unchanged() -> None: + """A bare string must not be JSON-quoted — regex/sentiment expect the + text as-is, without surrounding double quotes. + """ + from uipath.runtime.governance.runtime import _serialize_payload + + assert _serialize_payload("hello world") == "hello world" + + +def test_serialize_payload_dict_is_json_encoded() -> None: + """Structured payloads route through ``serialize_object`` then + ``json.dumps`` — the evaluator sees a flat JSON string it can scan. + """ + from uipath.runtime.governance.runtime import _serialize_payload + + result = _serialize_payload({"a": 1, "b": ["x", "y"]}) + # Order-agnostic content check — json.dumps default ordering can vary + # by dict insertion order across Python versions. + assert '"a": 1' in result + assert '"b": ["x", "y"]' in result + + +def test_serialize_payload_unserializable_object_falls_back_to_str() -> None: + """When ``serialize_object`` + ``json.dumps`` can't handle the value + (e.g., raw file handles, sockets, generators), the wrapper falls back + to ``str(payload)`` so the evaluator still gets *something* rather + than crashing the agent hook. + """ + from uipath.runtime.governance.runtime import _serialize_payload + + class _Unpicklable: + def __repr__(self) -> str: + return "" + + # A recursive dict that can't JSON-encode without hitting cycles or + # non-serializable values. + obj = _Unpicklable() + result = _serialize_payload(obj) + assert result == "" + + +# --------------------------------------------------------------------------- +# _fire_before_agent / _fire_after_agent — evaluator-wired paths +# --------------------------------------------------------------------------- + + +class _CapturingEvaluator: + """Minimal stub for :class:`GovernanceEvaluator` — records calls. + + The runtime only invokes ``evaluate_before_agent`` / + ``evaluate_after_agent`` on the evaluator, so those two are the only + methods this stub needs to expose. + """ + + def __init__(self) -> None: + self.before_calls: list[dict[str, Any]] = [] + self.after_calls: list[dict[str, Any]] = [] + self.before_raises: Exception | None = None + self.after_raises: Exception | None = None + + def evaluate_before_agent(self, **kwargs: Any) -> None: + self.before_calls.append(kwargs) + if self.before_raises is not None: + raise self.before_raises + + def evaluate_after_agent(self, **kwargs: Any) -> None: + self.after_calls.append(kwargs) + if self.after_raises is not None: + raise self.after_raises + + +class _ResultDelegate: + """Delegate that returns a real ``UiPathRuntimeResult`` so + :meth:`_fire_after_agent` can read ``result.output`` without + tripping over the ``_StubDelegate``'s bare-string return. + """ + + def __init__(self) -> None: + self.execute_calls: list[tuple[Any, Any]] = [] + + async def execute(self, input: Any = None, options: Any = None) -> Any: + from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus + + self.execute_calls.append((input, options)) + return UiPathRuntimeResult( + output="agent-output", status=UiPathRuntimeStatus.SUCCESSFUL + ) + + async def stream(self, input: Any = None, options: Any = None) -> Any: + if False: + yield # pragma: no cover + + async def get_schema(self) -> Any: + return "schema" + + async def dispose(self) -> None: + return None + + +async def test_execute_fires_before_and_after_agent_when_evaluator_wired() -> None: + """The evaluator's hooks fire on happy-path execute — this is the + core reason ``UiPathGovernedRuntime`` exists. + + Regression guard: without wiring the evaluator's calls, the wrapper + silently degrades to a pure passthrough (the ``self._evaluator is + None`` early-returns in ``_fire_*`` would kick in), and no + governance would run. + """ + delegate = _ResultDelegate() + evaluator = _CapturingEvaluator() + runtime = UiPathGovernedRuntime( + delegate, + PolicyIndex(), + EnforcementMode.AUDIT, + evaluator=evaluator, # type: ignore[arg-type] + agent_name="agent-x", + runtime_id="run-1", + ) + + result = await runtime.execute({"prompt": "leak?"}) + + assert result.output == "agent-output" + assert len(evaluator.before_calls) == 1 + assert len(evaluator.after_calls) == 1 + # ``_serialize_payload`` turned the dict input into a JSON string. + assert "leak" in evaluator.before_calls[0]["agent_input"] + assert evaluator.before_calls[0]["agent_name"] == "agent-x" + assert evaluator.before_calls[0]["runtime_id"] == "run-1" + # And AFTER_AGENT saw the delegate's output. + assert evaluator.after_calls[0]["agent_output"] == "agent-output" + + +async def test_execute_propagates_governance_block_exception() -> None: + """A DENY in ``ENFORCE`` mode must halt the run — the evaluator + raises :class:`GovernanceBlockException` and the wrapper propagates + it rather than swallowing. + """ + from uipath.core.governance.exceptions import GovernanceBlockException + + evaluator = _CapturingEvaluator() + evaluator.before_raises = GovernanceBlockException("policy denied") + + runtime = UiPathGovernedRuntime( + _StubDelegate(), + PolicyIndex(), + EnforcementMode.ENFORCE, + evaluator=evaluator, # type: ignore[arg-type] + ) + + with pytest.raises(GovernanceBlockException): + await runtime.execute({"x": 1}) + + +async def test_after_agent_block_exception_also_propagates() -> None: + """The re-raise contract holds for AFTER_AGENT too, not just + BEFORE_AGENT. + + Even though DENY on output is a rarer configuration (most policies + fire pre-model / on tool call), the wrapper must treat both hooks + symmetrically — a governance block from either boundary halts the + run, not just the input side. + """ + from uipath.core.governance.exceptions import GovernanceBlockException + + evaluator = _CapturingEvaluator() + evaluator.after_raises = GovernanceBlockException("output policy denied") + + runtime = UiPathGovernedRuntime( + _ResultDelegate(), + PolicyIndex(), + EnforcementMode.ENFORCE, + evaluator=evaluator, # type: ignore[arg-type] + ) + + with pytest.raises(GovernanceBlockException): + await runtime.execute({"x": 1}) + + +async def test_execute_swallows_unexpected_evaluator_errors() -> None: + """A generic ``Exception`` from the evaluator must NOT break the + agent run — governance bugs are logged and the delegate still runs. + """ + delegate = _StubDelegate() + evaluator = _CapturingEvaluator() + evaluator.before_raises = RuntimeError("evaluator internal error") + + runtime = UiPathGovernedRuntime( + delegate, + PolicyIndex(), + EnforcementMode.AUDIT, + evaluator=evaluator, # type: ignore[arg-type] + ) + + # Must not raise — the wrapper swallows non-block exceptions so a + # governance bug never breaks the agent. + result = await runtime.execute({"x": 1}) + assert result == "result" + # The delegate still ran (the evaluator failure didn't short-circuit). + assert delegate.execute_calls == [({"x": 1}, None)] + + +async def test_stream_fires_after_agent_only_on_result_event() -> None: + """AFTER_AGENT fires when a :class:`UiPathRuntimeResult` event is + yielded — intermediate events pass through untouched. + + Uses a delegate that yields a mix of plain events + a + ``UiPathRuntimeResult`` sentinel so the branch is exercised. + """ + from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus + + class _MixedStreamDelegate: + async def execute(self, input: Any = None, options: Any = None) -> Any: + return None + + async def stream(self, input: Any = None, options: Any = None) -> Any: + yield "intermediate-1" + yield UiPathRuntimeResult( + output="final-output", status=UiPathRuntimeStatus.SUCCESSFUL + ) + yield "intermediate-2" + + async def get_schema(self) -> Any: + return "schema" + + async def dispose(self) -> None: + return None + + evaluator = _CapturingEvaluator() + runtime = UiPathGovernedRuntime( + _MixedStreamDelegate(), + PolicyIndex(), + EnforcementMode.AUDIT, + evaluator=evaluator, # type: ignore[arg-type] + ) + + events = [e async for e in runtime.stream({"x": 1})] + + # All three events came through in order. + assert len(events) == 3 + assert isinstance(events[1], UiPathRuntimeResult) + # AFTER_AGENT fires exactly once — on the UiPathRuntimeResult. + assert len(evaluator.after_calls) == 1 + # BEFORE_AGENT fires once at stream start. + assert len(evaluator.before_calls) == 1 + + +# --------------------------------------------------------------------------- +# OTel-not-installed fallback for _governance_root_span +# --------------------------------------------------------------------------- + + +async def test_execute_still_runs_when_opentelemetry_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The runtime must not depend on OTel being importable. + + Simulate a stripped host by making ``from opentelemetry import trace`` + inside ``_governance_root_span`` raise ``ImportError``. The wrapper + should yield without opening a span and the agent still runs. + """ + import builtins + + real_import = builtins.__import__ + + def _blocked_import(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "opentelemetry" or name.startswith("opentelemetry."): + raise ImportError(f"simulated missing: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocked_import) + + delegate = _StubDelegate() + runtime = UiPathGovernedRuntime(delegate, PolicyIndex(), EnforcementMode.AUDIT) + + # Must not raise. The context manager takes the ImportError branch, + # yields with no span, and execute completes normally. + result = await runtime.execute({"x": 1}) + + assert result == "result" + assert delegate.execute_calls == [({"x": 1}, None)] diff --git a/tests/test_guardrail_compensation.py b/tests/test_guardrail_compensation.py new file mode 100644 index 00000000..22b238e1 --- /dev/null +++ b/tests/test_guardrail_compensation.py @@ -0,0 +1,298 @@ +"""Tests for the synchronous GuardrailCompensator. + +The runtime layer builds the wire payload and hands it to the +injected provider. The provider owns batching / async / fire-and- +forget. HTTP/auth/URL/header concerns — including ``trace_id`` +resolution — live behind the +:class:`uipath.core.governance.GovernanceCompensationProvider` +protocol and are exercised in the concrete provider's own tests. + +These tests cover: + +- ``disabled_guardrails`` — distilling fired ``guardrail_fallback`` rules + into per-rule wire metadata. +- ``GuardrailCompensator.submit`` — short-circuits on empty input, + wire-model assembly, provider invocation, and fail-open behavior + when the provider raises. +- ``close`` is a no-op (kept for API symmetry). +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from uipath.core.governance import ( + FiredRule, + GovernanceCompensationProvider, + GovernRequest, +) + +from uipath.runtime.governance.native.guardrail_compensation import ( + GuardrailCompensator, + disabled_guardrails, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _provider() -> MagicMock: + """Mock satisfying the GovernanceCompensationProvider protocol.""" + return MagicMock(spec=GovernanceCompensationProvider) + + +def _rules( + *validators: str, + rule_id: str = "R1", + rule_name: str = "n", + pack: str = "p", +) -> list[FiredRule]: + """Build a list of FiredRule wire models — one per validator.""" + return [ + FiredRule( + rule_id=rule_id, + rule_name=rule_name, + pack_name=pack, + validator=v, + ) + for v in validators + ] + + +# --------------------------------------------------------------------------- +# disabled_guardrails +# --------------------------------------------------------------------------- + + +def test_disabled_guardrails_returns_fired_rule_for_matched_disabled_guardrail() -> ( + None +): + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": False, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[ + SimpleNamespace(matched=True, rule_id="R1", rule_name="PII guardrail") + ] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule if rid == "R1" else None) + + out = disabled_guardrails(audit, policy_index) + + assert len(out) == 1 + fr = out[0] + assert isinstance(fr, FiredRule) + assert fr.rule_id == "R1" + assert fr.rule_name == "PII guardrail" + assert fr.pack_name == "" + assert fr.validator == "pii_detection" + + +def test_disabled_guardrails_skips_unmatched_evaluations() -> None: + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=False, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: None) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_non_guardrail_conditions() -> None: + cond = SimpleNamespace(operator="regex", value="some-pattern") + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])]) + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_enabled_guardrails() -> None: + """Mapped to UiPath AND enabled → no compensation needed.""" + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": True, + "policy_enabled": True, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +def test_disabled_guardrails_skips_unmapped_guardrails() -> None: + """Not mapped to UiPath → server can't fall back; skip.""" + cond = SimpleNamespace( + operator="guardrail_fallback", + value={ + "validator": "pii_detection", + "mapped_to_uipath": False, + "policy_enabled": False, + }, + ) + rule = SimpleNamespace(checks=[SimpleNamespace(conditions=[cond])], pack_name="") + audit = SimpleNamespace( + evaluations=[SimpleNamespace(matched=True, rule_id="R1", rule_name="x")] + ) + policy_index = SimpleNamespace(get_rule=lambda rid: rule) + assert disabled_guardrails(audit, policy_index) == [] + + +# --------------------------------------------------------------------------- +# GuardrailCompensator.submit — short-circuits +# --------------------------------------------------------------------------- + + +def test_submit_empty_rules_short_circuits() -> None: + """No rules → no provider call.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + compensator.submit([], {}, "before_model", "ts", "a", "r") + provider.compensate.assert_not_called() + + +def test_submit_no_validators_short_circuits() -> None: + """Rules with empty validator strings → no call (nothing to dispatch).""" + provider = _provider() + compensator = GuardrailCompensator(provider) + rules = [FiredRule(rule_id="R", rule_name="n", pack_name="p", validator="")] + compensator.submit(rules, {}, "before_model", "ts", "a", "r") + provider.compensate.assert_not_called() + + +# --------------------------------------------------------------------------- +# GuardrailCompensator.submit — wire-model assembly + provider invocation +# --------------------------------------------------------------------------- + + +def test_submit_invokes_provider_with_govern_request() -> None: + """The provider receives a GovernRequest carrying every wire field. + + ``trace_id`` is left empty on the wire — the injected provider + resolves it at HTTP-call time. + """ + provider = _provider() + compensator = GuardrailCompensator(provider) + rules = _rules("pii_detection", "harmful_content") + + compensator.submit( + rules, + {"content": "x"}, + "before_model", + "2026-06-06T00:00:00Z", + "langchain", + "patch-langchain", + ) + + provider.compensate.assert_called_once() + (request,) = provider.compensate.call_args.args + assert isinstance(request, GovernRequest) + # distinct validators drive the guardrail API call + assert request.validators == ["pii_detection", "harmful_content"] + assert request.rules == rules + assert request.data == {"content": "x"} + assert request.hook == "before_model" + # ``trace_id`` is not carried on the wire — the provider resolves at HTTP time. + assert request.trace_id in (None, "") + assert request.src_timestamp == "2026-06-06T00:00:00Z" + assert request.agent_name == "langchain" + assert request.runtime_id == "patch-langchain" + # Job-context fields are left for the provider to auto-fill from env. + assert request.folder_key is None + assert request.job_key is None + assert request.process_key is None + assert request.reference_id is None + assert request.agent_version is None + # Runtime-identity fields are unset without metadata (server defaults them). + assert request.agent_framework is None + assert request.agent_type is None + assert request.runtime_version is None + + +def test_submit_stamps_runtime_metadata() -> None: + """When runtime_metadata is supplied, its identity fields ride the request.""" + from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata + + meta = GovernanceRuntimeMetadata( + agent_type="uipath_coded", + agent_framework="langchain", + runtime_version="0.11.4", + ) + provider = _provider() + compensator = GuardrailCompensator(provider, runtime_metadata=meta) + + compensator.submit( + _rules("pii_detection"), + {"content": "x"}, + "before_model", + "2026-06-06T00:00:00Z", + "langchain", + "patch-langchain", + ) + + (request,) = provider.compensate.call_args.args + assert request.agent_framework == "langchain" + assert request.agent_type == "uipath_coded" + assert request.runtime_version == "0.11.4" + + +def test_submit_dedupes_validators() -> None: + """Multiple rules with the same validator collapse on the wire.""" + provider = _provider() + compensator = GuardrailCompensator(provider) + rules = _rules("pii_detection") + _rules("pii_detection", rule_id="R2") + + compensator.submit(rules, {}, "before_model", "ts", "a", "r") + + (request,) = provider.compensate.call_args.args + assert request.validators == ["pii_detection"] + # Per-rule metadata is preserved (one record per rule even with shared validator). + assert len(request.rules) == 2 + + +def test_submit_swallows_provider_errors() -> None: + """A provider exception must never propagate to the caller / agent.""" + provider = _provider() + provider.compensate.side_effect = RuntimeError("network down") + compensator = GuardrailCompensator(provider) + + # Must not raise. + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + + provider.compensate.assert_called_once() + + +def test_submit_recovers_after_provider_error() -> None: + """A failed call doesn't poison the compensator — the next call still fires.""" + provider = _provider() + provider.compensate.side_effect = [RuntimeError("transient"), None] + compensator = GuardrailCompensator(provider) + + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + compensator.submit(_rules("x"), {}, "before_model", "ts", "a", "r") + + assert provider.compensate.call_count == 2 + + +# --------------------------------------------------------------------------- +# close — no-op for API symmetry +# --------------------------------------------------------------------------- + + +def test_close_is_a_noop() -> None: + """``close()`` holds no resources to release; calling it twice is safe.""" + c = GuardrailCompensator(_provider()) + c.close() + c.close() # must not raise diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py new file mode 100644 index 00000000..f93eab8e --- /dev/null +++ b/tests/test_traces_severity.py @@ -0,0 +1,472 @@ +"""Tests for trace-span verbosity / status semantics. + +``TracesAuditSink`` emits an OpenTelemetry span for every governance +hook end and every rule evaluation. The verdict is split into +``evaluator_result`` (what the rule decided, mode-independent) and +``action_applied`` (what actually happened, derived from +evaluator_result + mode). + +Mode travels with the event (set by the emitter from its +per-instance ``EnforcementMode``) so parallel runtimes running +different modes don't cross-contaminate the sink's view. + +- ``verbosityLevel = 4`` (Error) and ``StatusCode.ERROR`` fire **only** + when ``action_applied = DENY`` — i.e. the runtime actually blocked + the agent (ENFORCE mode + configured action ``deny``). +- ``verbosityLevel = 3`` (Warning) and ``Status.UNSET`` for advisory + outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own + bucket — escalation pauses for human review, it doesn't fail the + run, so it stays Warning even in ENFORCE mode. +- Hook spans never set Status, regardless of mode or final_action. + They're summary containers; severity belongs on the per-rule span. +- ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers + apply their default) and never call set_status. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from uipath.core.governance import EnforcementMode + +from uipath.runtime.governance._audit.base import AuditEvent, EventType +from uipath.runtime.governance._audit.traces import TracesAuditSink + + +@pytest.fixture +def captured_span(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + """Wire ``TracesAuditSink`` to a mock tracer and return the span mock.""" + span = MagicMock(name="span") + tracer = MagicMock(name="tracer") + tracer.start_as_current_span.return_value.__enter__.return_value = span + tracer.start_as_current_span.return_value.__exit__.return_value = False + monkeypatch.setattr(TracesAuditSink, "_get_tracer", lambda self: tracer) + return span + + +def _hook_event(final_action: str, mode: EnforcementMode) -> AuditEvent: + return AuditEvent( + event_type=EventType.HOOK_END, + agent_name="agent", + hook="after_model", + data={ + "total_rules": 1, + "matched_rules": 1 if final_action != "allow" else 0, + "final_action": final_action, + "enforcement_mode": mode, + }, + ) + + +def _rule_event( + matched: bool, action: str, mode: EnforcementMode = EnforcementMode.AUDIT +) -> AuditEvent: + return AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent", + hook="after_model", + data={ + "policy_id": "A.10.4", + "rule_name": "commitment-language", + "pack_name": "iso42001", + "matched": matched, + "action": action, + "enforcement_mode": mode, + "status": "MATCHED" if matched else "PASS", + "detail": "Customer-binding commitment detected.", + }, + ) + + +def _span_attrs(span: MagicMock) -> dict[str, object]: + """Return a mapping of attribute name → value for set_attribute calls.""" + attrs: dict[str, object] = {} + for call in span.set_attribute.call_args_list: + key, value = call.args + attrs[key] = value + return attrs + + +# --------------------------------------------------------------------------- +# Hook span — never marked ERROR +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "final_action,mode", + [ + ("deny", EnforcementMode.ENFORCE), + ("deny", EnforcementMode.AUDIT), + ("audit", EnforcementMode.AUDIT), + ("escalate", EnforcementMode.AUDIT), + ("allow", EnforcementMode.AUDIT), + ], +) +def test_hook_span_never_sets_error( + captured_span: MagicMock, final_action: str, mode: EnforcementMode +) -> None: + """Hook spans are summary containers — they never carry an ERROR Status.""" + sink = TracesAuditSink() + sink.emit(_hook_event(final_action=final_action, mode=mode)) + assert not captured_span.set_status.called, ( + f"Hook span should never set_status; called with " + f"final_action={final_action!r}, mode={mode!r}" + ) + + +# --------------------------------------------------------------------------- +# Rule span — enforce-mode DENY is the only Status.ERROR case +# --------------------------------------------------------------------------- + + +def test_enforce_mode_deny_is_error(captured_span: MagicMock) -> None: + """Enforce mode + action=deny = real block → verbosityLevel=4 + Status.ERROR.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 4 + assert attrs.get("uipath_governance.evaluator_result") == "DENY" + assert attrs.get("uipath_governance.action_applied") == "DENY" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + + assert captured_span.set_status.called, ( + "Status.ERROR must fire for enforce-mode deny violation" + ) + (status_arg,) = captured_span.set_status.call_args.args + from opentelemetry.trace import Status, StatusCode + + assert isinstance(status_arg, Status) + assert status_arg.status_code is StatusCode.ERROR + description = status_arg.description or "" + assert "commitment-language" in description + assert "deny" in description + + +def test_enforce_mode_escalate_is_hitl_warning(captured_span: MagicMock) -> None: + """Enforce mode + action=escalate = HITL pause, not a block. + + HITL is its own spec bucket distinct from DENY — escalation pauses + for human review, the run isn't failed. So verbosityLevel stays at + Warning and Status is not marked ERROR. + """ + sink = TracesAuditSink() + sink.emit( + _rule_event(matched=True, action="escalate", mode=EnforcementMode.ENFORCE) + ) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == "HITL" + assert attrs.get("uipath_governance.action_applied") == "HITL" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Rule span — advisory violations (audit mode, or audit-action rules) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "action,expected_evaluator", + [("deny", "DENY"), ("audit", "DENY"), ("escalate", "HITL")], +) +def test_audit_mode_violation_is_warning( + captured_span: MagicMock, action: str, expected_evaluator: str +) -> None: + """Audit mode never blocks → action_applied=AUDIT, verbosityLevel=3. + + Surfacing Status.ERROR for an audit-mode violation would falsely + mark the agent's run as failed when the runtime intentionally + let it through. evaluator_result still records the rule's actual + decision (DENY/HITL), independent of mode. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action=action, mode=EnforcementMode.AUDIT)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == expected_evaluator + assert attrs.get("uipath_governance.action_applied") == "AUDIT" + assert attrs.get("uipath_governance.mode") == "AUDIT" + + assert not captured_span.set_status.called, ( + f"Audit-mode {action} violation must NOT set Status.ERROR" + ) + + +def test_enforce_mode_audit_action_is_warning(captured_span: MagicMock) -> None: + """Enforce mode + action=audit is a per-rule audit override. + + The rule's configured ``audit`` action means "log this match but + don't block" even when the global mode is ENFORCE. evaluator_result + is DENY (the rule decided to deny), but action_applied is AUDIT + (the per-rule override kicks in), so verbosity stays Warning. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="audit", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("verbosityLevel") == 3 + assert attrs.get("uipath_governance.evaluator_result") == "DENY" + assert attrs.get("uipath_governance.action_applied") == "AUDIT" + assert attrs.get("uipath_governance.mode") == "ENFORCE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Rule span — no violation, no verbosityLevel attribute (Orchestrator default = 2) +# --------------------------------------------------------------------------- + + +def test_unmatched_rule_no_verbosity_no_error(captured_span: MagicMock) -> None: + """Unmatched evaluations → evaluator_result=ALLOW, action_applied=NONE, quiet.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=False, action="deny", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert "verbosityLevel" not in attrs + assert attrs.get("uipath_governance.evaluator_result") == "ALLOW" + assert attrs.get("uipath_governance.action_applied") == "NONE" + assert not captured_span.set_status.called + + +def test_matched_allow_action_no_verbosity(captured_span: MagicMock) -> None: + """A rule whose action is 'allow' is an explicit non-violation.""" + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="allow", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert "verbosityLevel" not in attrs + assert attrs.get("uipath_governance.evaluator_result") == "ALLOW" + assert attrs.get("uipath_governance.action_applied") == "NONE" + assert not captured_span.set_status.called + + +# --------------------------------------------------------------------------- +# Cross-runtime isolation — the architectural motivation for the refactor +# --------------------------------------------------------------------------- + + +def test_two_events_carry_independent_modes(captured_span: MagicMock) -> None: + """Parallel runtimes (different modes) cannot cross-contaminate the sink. + + Mode travels on each event (set by the emitter from its own + per-instance ``EnforcementMode``), so two consecutive emits with + different modes each render their own correct + ``uipath_governance.mode`` value — no shared state in the sink + that one runtime could clobber for another. + """ + sink = TracesAuditSink() + + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.AUDIT)) + + # Collect every set_attribute call ordered by emit. + calls = [c.args for c in captured_span.set_attribute.call_args_list] + modes = [v for k, v in calls if k == "uipath_governance.mode"] + actions_applied = [v for k, v in calls if k == "uipath_governance.action_applied"] + assert modes == ["ENFORCE", "AUDIT"] + assert actions_applied == ["DENY", "AUDIT"] + + +# --------------------------------------------------------------------------- +# _get_tracer — deferred init + ImportError fallback +# --------------------------------------------------------------------------- + + +def test_get_tracer_initializes_lazily_and_caches() -> None: + """``_get_tracer`` should populate ``self._tracer`` on first call and + return the same tracer on subsequent calls. This exercises the + real (non-monkeypatched) path — most other tests stub the method. + """ + sink = TracesAuditSink() + # Before first call — deferred, no tracer yet. + assert sink._tracer is None + + tracer_1 = sink._get_tracer() + assert tracer_1 is not None # OTel is installed; a tracer is returned + + tracer_2 = sink._get_tracer() + # Second call returns the cached tracer — no re-init log flood. + assert tracer_2 is tracer_1 + + +def test_get_tracer_returns_none_when_opentelemetry_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """If a downstream host strips OpenTelemetry, ``_get_tracer`` logs a + warning once and caches ``False`` so subsequent calls short-circuit + instead of retrying the import every event. + """ + import builtins + + real_import = builtins.__import__ + + def _blocked_import(name: str, *args, **kwargs): + if name == "opentelemetry" or name.startswith("opentelemetry."): + raise ImportError(f"simulated missing: {name}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocked_import) + + sink = TracesAuditSink() + + assert sink._get_tracer() is None + # Cached — no retry. + assert sink._tracer is False + assert sink._get_tracer() is None + + +def test_emit_is_a_noop_when_tracer_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When ``_get_tracer`` returns None, ``emit`` short-circuits inside + both ``_emit_rule_span`` and ``_emit_hook_span`` without touching + the tracer. Regression guard for the early-return branch. + """ + sink = TracesAuditSink() + monkeypatch.setattr(sink, "_get_tracer", lambda: None) + + # Both event types — must not raise, must not create spans. + sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + assert sink.spans_created == 0 + + +# --------------------------------------------------------------------------- +# spans_created — internal counter +# --------------------------------------------------------------------------- + + +def test_spans_created_increments_per_successful_emit( + captured_span: MagicMock, +) -> None: + """The ``spans_created`` counter must reflect every successful span + creation. Consumers use this for smoke-test assertions in + integration tests. + """ + sink = TracesAuditSink() + + assert sink.spans_created == 0 + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + assert sink.spans_created == 1 + sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) + assert sink.spans_created == 2 + + +# --------------------------------------------------------------------------- +# _resolve_mode — enforcement-mode field parsing +# --------------------------------------------------------------------------- + + +def test_resolve_mode_reads_string_field(captured_span: MagicMock) -> None: + """Emitters that forward the raw enum value as a lowercase string + (rather than the ``EnforcementMode`` object) must still resolve to + the correct mode. This exercises the ``isinstance(mode, str)`` + branch of ``_resolve_mode``. + """ + sink = TracesAuditSink() + + ev = AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent", + hook="after_model", + data={ + "policy_id": "A.1.1", + "matched": True, + "action": "deny", + "enforcement_mode": "enforce", # string, not EnforcementMode + }, + ) + sink.emit(ev) + + modes = [ + c.args[1] + for c in captured_span.set_attribute.call_args_list + if c.args[0] == "uipath_governance.mode" + ] + assert modes == ["ENFORCE"] + + +def test_resolve_mode_falls_back_to_audit_on_unknown_string( + captured_span: MagicMock, +) -> None: + """An unparseable mode string (contract violation) must not crash + the sink — it falls back to AUDIT so a bad emitter can't kill + governance telemetry. + """ + sink = TracesAuditSink() + + ev = AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent", + hook="after_model", + data={ + "policy_id": "A.1.1", + "matched": True, + "action": "deny", + "enforcement_mode": "not-a-real-mode", + }, + ) + sink.emit(ev) + + modes = [ + c.args[1] + for c in captured_span.set_attribute.call_args_list + if c.args[0] == "uipath_governance.mode" + ] + assert modes == ["AUDIT"] # safe default + + +# --------------------------------------------------------------------------- +# _package_version — install-metadata lookup + fallback +# --------------------------------------------------------------------------- + + +def test_package_version_returns_unknown_when_package_not_installed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The version helper must degrade gracefully when the + ``uipath-runtime`` package metadata isn't findable (e.g., when + running from a source checkout without an install). + """ + import importlib.metadata + + from uipath.runtime.governance._audit import traces + + def _raise_not_found(name: str) -> str: + raise importlib.metadata.PackageNotFoundError(name) + + monkeypatch.setattr(importlib.metadata, "version", _raise_not_found) + + assert traces._package_version() == "unknown" + + +# --------------------------------------------------------------------------- +# Exception isolation — sink swallows internal errors, per contract +# --------------------------------------------------------------------------- + + +def test_rule_span_exception_is_swallowed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A sink emit must never propagate exceptions to the caller — the + audit manager's circuit breaker tracks failures, but the sink + itself catches so a broken tracer can't crash the agent hook. + """ + sink = TracesAuditSink() + + # Tracer whose start_as_current_span raises inside the with-block. + tracer = MagicMock() + tracer.start_as_current_span.side_effect = RuntimeError("tracer boom") + monkeypatch.setattr(sink, "_get_tracer", lambda: tracer) + + # Must not raise. + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) + + # And no span was successfully created. + assert sink.spans_created == 0 diff --git a/tests/test_track_events_sink.py b/tests/test_track_events_sink.py new file mode 100644 index 00000000..ee38f311 --- /dev/null +++ b/tests/test_track_events_sink.py @@ -0,0 +1,416 @@ +"""Tests for :class:`TrackEventAuditSink`. + +Verifies the volume-control filter (``accepts``), event-name vocabulary, +payload shape (runtime metadata + per-event fields), and OTel-span → +``operation_id`` correlation (sink resolves the correlation id from +the live OTel context at emit time, not from the event itself). Uses +a capture-callable in place of the host-supplied ``track_event`` so +no I/O fires. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import pytest +from uipath.core.governance import EnforcementMode + +from uipath.runtime.governance._audit.base import AuditEvent, EventType +from uipath.runtime.governance._audit.metadata import GovernanceRuntimeMetadata +from uipath.runtime.governance._audit.track_events import ( + EVENT_HOOK_SUMMARY, + EVENT_RULE_DENIED, + TrackEventAuditSink, +) + + +class _Capture: + """Stand-in for ``provider.track_event`` — records every call.""" + + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def __call__( + self, + *, + event_name: str, + data: dict[str, Any] | None = None, + operation_id: str | None = None, + ) -> None: + self.calls.append( + {"event_name": event_name, "data": data, "operation_id": operation_id} + ) + + +@pytest.fixture +def capture() -> _Capture: + return _Capture() + + +@pytest.fixture +def metadata() -> GovernanceRuntimeMetadata: + return GovernanceRuntimeMetadata( + execution_engine="uipath_native_governance_checker", + agent_type="uipath_coded", + agent_framework="langchain", + runtime_version="9.9.9-test", + ) + + +@pytest.fixture +def sink(capture: _Capture, metadata: GovernanceRuntimeMetadata) -> TrackEventAuditSink: + return TrackEventAuditSink(capture, metadata) + + +def _rule_event( + *, + matched: bool, + action: str = "deny", + mode: EnforcementMode = EnforcementMode.AUDIT, + duration_ms: float = 12.5, + mapped_to_uipath: bool = False, +) -> AuditEvent: + return AuditEvent( + event_type=EventType.RULE_EVALUATION, + agent_name="agent-x", + hook="after_model", + data={ + "policy_id": "A.6.1.4", + "rule_name": "commitment-language", + "pack_name": "iso42001", + "matched": matched, + "action": action, + "enforcement_mode": mode, + "detail": "Detected a commitment phrase.", + "duration_ms": duration_ms, + "mapped_to_uipath": mapped_to_uipath, + }, + timestamp=datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc), + ) + + +def _hook_event( + *, + mode: EnforcementMode = EnforcementMode.AUDIT, + duration_ms: float = 45.0, + passed_count: int = 4, + denied_count: int = 1, + skipped_policy_names: list[str] | None = None, + guardrail_dispatched_count: int = 0, +) -> AuditEvent: + return AuditEvent( + event_type=EventType.HOOK_END, + agent_name="agent-x", + hook="after_model", + data={ + "total_rules": passed_count + denied_count, + "matched_rules": denied_count, + "final_action": "audit", + "enforcement_mode": mode, + "duration_ms": duration_ms, + "passed_count": passed_count, + "denied_count": denied_count, + "skipped_count": len(skipped_policy_names or []), + "skipped_policy_names": list(skipped_policy_names or []), + "guardrail_dispatched_count": guardrail_dispatched_count, + }, + timestamp=datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc), + ) + + +# --------------------------------------------------------------------------- +# accepts() — volume-control filter +# --------------------------------------------------------------------------- + + +def test_accepts_matched_rule_evaluation(sink: TrackEventAuditSink) -> None: + assert sink.accepts(_rule_event(matched=True)) is True + + +def test_rejects_unmatched_rule_evaluation(sink: TrackEventAuditSink) -> None: + assert sink.accepts(_rule_event(matched=False)) is False + + +def test_rejects_matched_allow_rule(sink: TrackEventAuditSink) -> None: + """A matched rule with action=allow is a positive informational + match — it should NOT trigger the ``rule.denied`` stream. It rolls + into the hook summary's ``passed_count`` instead. + """ + assert sink.accepts(_rule_event(matched=True, action="allow")) is False + + +def test_direct_emit_of_unmatched_rule_is_noop( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Defense-in-depth: even if a caller bypasses ``accepts``, passed + rules must not be routed to ``governance.rule.denied``. + + The AuditManager dispatch path always consults ``accepts`` first, + but a direct ``sink.emit(...)`` call (tests, future alternate + dispatch) must still produce zero ``track_event`` calls for a + ``matched=False`` rule. + """ + sink.emit(_rule_event(matched=False)) + assert capture.calls == [] + + +def test_direct_emit_of_matched_allow_rule_is_noop( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Defense-in-depth equivalent of the matched-but-allow filter.""" + sink.emit(_rule_event(matched=True, action="allow")) + assert capture.calls == [] + + +# --------------------------------------------------------------------------- +# DISABLED mode — zero telemetry across every event type +# --------------------------------------------------------------------------- + + +def test_rejects_disabled_mode_rule_evaluation(sink: TrackEventAuditSink) -> None: + """Governance off → ``accepts`` returns False for rule events.""" + assert ( + sink.accepts(_rule_event(matched=True, mode=EnforcementMode.DISABLED)) is False + ) + + +def test_rejects_disabled_mode_hook_summary(sink: TrackEventAuditSink) -> None: + """Governance off → ``accepts`` returns False for hook summaries.""" + assert sink.accepts(_hook_event(mode=EnforcementMode.DISABLED)) is False + + +def test_direct_emit_of_disabled_rule_is_noop( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Defense-in-depth: ``emit`` drops DISABLED-mode events too.""" + sink.emit(_rule_event(matched=True, mode=EnforcementMode.DISABLED)) + assert capture.calls == [] + + +def test_direct_emit_of_disabled_hook_is_noop( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Defense-in-depth for hook summary in DISABLED mode.""" + sink.emit(_hook_event(mode=EnforcementMode.DISABLED)) + assert capture.calls == [] + + +# --------------------------------------------------------------------------- +# Empty hook summary suppression +# --------------------------------------------------------------------------- + + +def test_rejects_empty_hook_summary(sink: TrackEventAuditSink) -> None: + """``total_rules=0`` AND ``skipped_count=0`` → nothing to report.""" + ev = _hook_event(passed_count=0, denied_count=0, skipped_policy_names=[]) + assert sink.accepts(ev) is False + + +def test_accepts_hook_summary_with_only_skipped(sink: TrackEventAuditSink) -> None: + """Hook with ``total=0`` but ``skipped_count>0`` is still operator-useful.""" + ev = _hook_event( + passed_count=0, + denied_count=0, + skipped_policy_names=["A.1.1", "A.1.2"], + ) + assert sink.accepts(ev) is True + + +def test_direct_emit_of_empty_hook_is_noop( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Defense-in-depth equivalent of the empty-hook filter.""" + sink.emit(_hook_event(passed_count=0, denied_count=0, skipped_policy_names=[])) + assert capture.calls == [] + + +def test_accepts_hook_end(sink: TrackEventAuditSink) -> None: + assert sink.accepts(_hook_event()) is True + + +def test_rejects_other_event_types(sink: TrackEventAuditSink) -> None: + for et in ( + EventType.HOOK_START, + EventType.SESSION_START, + EventType.SESSION_END, + EventType.POLICY_VIOLATION, + EventType.POLICY_ALLOW, + EventType.PACKS_LOADED, + ): + ev = AuditEvent(event_type=et, agent_name="agent-x") + assert sink.accepts(ev) is False, f"sink must drop {et}" + + +# --------------------------------------------------------------------------- +# emit() — rule-denied event shape +# --------------------------------------------------------------------------- + + +def test_rule_denied_event_name_and_operation_id( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """``operation_id`` is the live OTel span's trace id rendered as 32-hex. + + The sink reads from :func:`opentelemetry.trace.get_current_span` at + emit time — the audit manager dispatches synchronously on the + caller's thread, so any span open on the calling thread is visible + to the sink directly. + """ + from opentelemetry.sdk.trace import TracerProvider + + tracer = TracerProvider().get_tracer("test") + with tracer.start_as_current_span("agent-run") as span: + expected = f"{span.get_span_context().trace_id:032x}" + sink.emit(_rule_event(matched=True)) + + assert len(capture.calls) == 1 + call = capture.calls[0] + assert call["event_name"] == EVENT_RULE_DENIED + assert call["operation_id"] == expected + + +def test_rule_denied_payload_carries_metadata( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + sink.emit(_rule_event(matched=True)) + data = capture.calls[0]["data"] + assert data["execution_engine"] == "uipath_native_governance_checker" + assert data["agent_type"] == "uipath_coded" + assert data["agent_framework"] == "langchain" + assert data["runtime_version"] == "9.9.9-test" + assert data["agent_name"] == "agent-x" + assert data["hook"] == "after_model" + + +def test_rule_denied_payload_splits_pack_and_clause( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """``pack`` and ``clause`` are separate fields so consumers can aggregate.""" + sink.emit(_rule_event(matched=True)) + data = capture.calls[0]["data"] + assert data["pack"] == "iso42001" + assert data["clause"] == "A.6.1.4" + assert data["rule_name"] == "commitment-language" + + +def test_rule_denied_audit_mode_collapses_to_audit( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """AUDIT mode never escalates DENY past AUDIT, but evaluator_result is true.""" + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.AUDIT)) + data = capture.calls[0]["data"] + assert data["mode"] == "AUDIT" + assert data["evaluator_result"] == "DENY" + assert data["action_applied"] == "AUDIT" + + +def test_rule_denied_enforce_mode_deny( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + data = capture.calls[0]["data"] + assert data["mode"] == "ENFORCE" + assert data["evaluator_result"] == "DENY" + assert data["action_applied"] == "DENY" + + +def test_rule_denied_enforce_mode_escalate_is_hitl( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + sink.emit( + _rule_event(matched=True, action="escalate", mode=EnforcementMode.ENFORCE) + ) + data = capture.calls[0]["data"] + assert data["evaluator_result"] == "HITL" + assert data["action_applied"] == "HITL" + + +def test_rule_denied_carries_duration( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + sink.emit(_rule_event(matched=True, duration_ms=42.5)) + assert capture.calls[0]["data"]["duration_ms"] == 42.5 + + +def test_rule_denied_mapped_to_uipath_passes_through( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Field defaults False for native events but the schema is stable.""" + sink.emit(_rule_event(matched=True, mapped_to_uipath=False)) + assert capture.calls[0]["data"]["mapped_to_uipath"] is False + + +# --------------------------------------------------------------------------- +# emit() — hook-summary event shape +# --------------------------------------------------------------------------- + + +def test_hook_summary_event_name_and_operation_id( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Same OTel-derived operation_id contract as the rule-denied event.""" + from opentelemetry.sdk.trace import TracerProvider + + tracer = TracerProvider().get_tracer("test") + with tracer.start_as_current_span("agent-run") as span: + expected = f"{span.get_span_context().trace_id:032x}" + sink.emit(_hook_event()) + + assert len(capture.calls) == 1 + assert capture.calls[0]["event_name"] == EVENT_HOOK_SUMMARY + assert capture.calls[0]["operation_id"] == expected + + +def test_hook_summary_carries_counts( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + sink.emit( + _hook_event( + passed_count=10, + denied_count=2, + skipped_policy_names=["A.1.2", "A.3.4"], + guardrail_dispatched_count=3, + ) + ) + data = capture.calls[0]["data"] + assert data["passed_count"] == 10 + assert data["denied_count"] == 2 + assert data["skipped_count"] == 2 + assert data["skipped_policy_names"] == ["A.1.2", "A.3.4"] + assert data["guardrail_dispatched_count"] == 3 + + +def test_hook_summary_duration_ms(sink: TrackEventAuditSink, capture: _Capture) -> None: + sink.emit(_hook_event(duration_ms=123.4)) + assert capture.calls[0]["data"]["duration_ms"] == 123.4 + + +def test_hook_summary_carries_metadata( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """Same per-runtime metadata stamping as rule-denied events.""" + sink.emit(_hook_event()) + data = capture.calls[0]["data"] + assert data["execution_engine"] == "uipath_native_governance_checker" + assert data["agent_type"] == "uipath_coded" + assert data["agent_framework"] == "langchain" + assert data["runtime_version"] == "9.9.9-test" + + +def test_hook_summary_mode_string(sink: TrackEventAuditSink, capture: _Capture) -> None: + sink.emit(_hook_event(mode=EnforcementMode.ENFORCE)) + assert capture.calls[0]["data"]["mode"] == "ENFORCE" + + +# --------------------------------------------------------------------------- +# operation_id fallback when no OTel span is active +# --------------------------------------------------------------------------- + + +def test_operation_id_none_when_no_active_span( + sink: TrackEventAuditSink, capture: _Capture +) -> None: + """No live OTel span → ``operation_id=None`` (consumer fills in its own).""" + sink.emit(_rule_event(matched=True)) + assert capture.calls[0]["operation_id"] is None diff --git a/uv.lock b/uv.lock index 2aaded66..24e8901d 100644 --- a/uv.lock +++ b/uv.lock @@ -99,6 +99,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "chardet" +version = "7.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/b6/9df434a8eeba2e6628c465a1dfa31034228ef79b26f76f46278f4ef7e49d/chardet-7.4.3.tar.gz", hash = "sha256:cc1d4eb92a4ec1c2df3b490836ffa46922e599d34ce0bb75cf41fd2bf6303d56", size = 784800, upload-time = "2026-04-13T21:33:39.803Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/52/505c207f334d51e937cbaa27ff95776e16e2d120e13cbe491cd7b3a70b50/chardet-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:25a862cddc6a9ac07023e808aedd297115345fbaabc2690479481ddc0f980e09", size = 870747, upload-time = "2026-04-13T21:32:56.916Z" }, + { url = "https://files.pythonhosted.org/packages/14/4b/d3c79495dee4831b8bebca2790e72cb90f0c5849c940570a7c7e5b70b952/chardet-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7005c88da26fd95d8abb8acbe6281d833e9a9181b03cf49b4546c4555389bd97", size = 853210, upload-time = "2026-04-13T21:32:58.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/99/f6a822ad1bde25a4c38dc3e770485e78e0893dfd871cd6e18ed3ea3a795e/chardet-7.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc50f28bad067393cce0af9091052c3b8df7a23115afd8ba7b2e0947f0cef1f8", size = 873625, upload-time = "2026-04-13T21:32:59.606Z" }, + { url = "https://files.pythonhosted.org/packages/b1/10/31932775c94a86814f76b41c4a772b52abfb0e6125324f32c6da1196c297/chardet-7.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3da294de1a681097848ab58bd3f2771a674f8039d2d87a5538b28856b815e9", size = 883436, upload-time = "2026-04-13T21:33:01.351Z" }, + { url = "https://files.pythonhosted.org/packages/6c/63/0f43e3acf2c436fdb32a0f904aeb03a2904d2126eed34a042a194d235926/chardet-7.4.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c45e116dd51b66226a53ade3f9f635e870de5399b90e00ce45dcc311093bf4", size = 876589, upload-time = "2026-04-13T21:33:02.636Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a6/e9b8f8a3e99602792b01fa7d0a731737615ab56d8bfd0b52935a0ef88b85/chardet-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:ccc1f83ab4bcfb901cf39e0c4ba6bc6e726fc6264735f10e24ceb5cb47387578", size = 941866, upload-time = "2026-04-13T21:33:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/61/33/29de185079e6675c3f375546e30a559b7ddc75ce972f18d6e566cd9ea4eb/chardet-7.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:75d3c65cc16bddf40b8da1fd25ba84fca5f8070f2b14e86083653c1c85aee971", size = 874870, upload-time = "2026-04-13T21:33:05.977Z" }, + { url = "https://files.pythonhosted.org/packages/9c/2f/4c5af01fd1a7506a1d5375403d68925eac70289229492db5aa68b58103d8/chardet-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:29af5999f654e8729d251f1724a62b538b1262d9292cccaefddf8a02aae1ef6a", size = 854859, upload-time = "2026-04-13T21:33:07.381Z" }, + { url = "https://files.pythonhosted.org/packages/36/21/edb36ad5dfa48d7f8eed97ab43931ecdaa8c15166c21b1d614967e49d681/chardet-7.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:626f00299ad62dfe937058a09572beed442ccc7b58f87aa667949b20fd3db235", size = 875032, upload-time = "2026-04-13T21:33:08.741Z" }, + { url = "https://files.pythonhosted.org/packages/e5/59/a32a241d861cf180853a11c8e5a67641cb1b2af13c3a5ccce83ec07e2c9f/chardet-7.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a4904dd5f071b7a7d7f50b4a67a86db3c902d243bf31708f1d5cde2f68239cb", size = 888283, upload-time = "2026-04-13T21:33:10.213Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/e1ee6a77abf3782c00e05b89c4d4328c8353bf9500661c4348df1dd68614/chardet-7.4.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d2879598bc220689e8ce509fe9c3f37ad2fca53a36be9c9bd91abdd91dd364f", size = 879974, upload-time = "2026-04-13T21:33:11.448Z" }, + { url = "https://files.pythonhosted.org/packages/32/60/fca69c534602a7ced04280c952a246ad1edde2a6ca3a164f65d32ac41fe7/chardet-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:4b2799bd58e7245cfa8d4ab2e8ad1d76a5c3a5b1f32318eb6acca4c69a3e7101", size = 943973, upload-time = "2026-04-13T21:33:12.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/43/79ac9b4db5bc87020c9dbc419125371d80882d1d197e9c4765ba8682b605/chardet-7.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a9e4486df251b8962e86ea9f139ca235aa6e0542a00f7844c9a04160afb99aa9", size = 873769, upload-time = "2026-04-13T21:33:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/55/5f/25bdec773905bff0ff6cf35ca73b17bd05593b4f87bd8c5fa43705f7167d/chardet-7.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fbff1907925b0c5a1064cffb5e040cd5e338585c9c552625f30de6bc2f3107a", size = 853991, upload-time = "2026-04-13T21:33:15.564Z" }, + { url = "https://files.pythonhosted.org/packages/b4/07/a29380ee0b215d23d77733b5ad60c5c0c7969650e080c667acdf9462040d/chardet-7.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:365135eaf37ba65a828f8e668eb0a8c38c479dcbec724dc25f4dfd781049c357", size = 874024, upload-time = "2026-04-13T21:33:16.915Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b1/3338e121cbd4c8a126b8ccb1061170c2ce51a53f678c502793ea49c6fd6d/chardet-7.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfc134b70c846c21ead8e43ada3ae1a805fff732f6922f8abcf2ff27b8f6493d", size = 887410, upload-time = "2026-04-13T21:33:18.368Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/44a9a9e0c59c185a5d307ceaeee8768afa1558f0a24f7a4b5fa11b67586b/chardet-7.4.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9acd9988a93e09390f3cd231201ea7166c415eb8da1b735928990ffc05cb9fbb", size = 879269, upload-time = "2026-04-13T21:33:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b3/5d0e77ea774bd3224321c248880ea0c0379000ac5c2bb6d77609549de247/chardet-7.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:e1b98790c284ff813f18f7cf7de5f05ea2435a080030c7f1a8318f3a4f80b131", size = 944155, upload-time = "2026-04-13T21:33:21.694Z" }, + { url = "https://files.pythonhosted.org/packages/70/a8/bf0811d859e13801279a2ae64f37a408027b282f2047bc0001c75dd356ad/chardet-7.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d892d3dcd652fdef53e3d6327d39b17c0df40a899dfc919abaeb64c974497531", size = 872887, upload-time = "2026-04-13T21:33:23.328Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/b9d68ebddfe1b02c77af5bf81120e12b036b4432dc6af7a303d90e2bc38b/chardet-7.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:acc46d1b8b7d5783216afe15db56d1c179b9a40e5a1558bc13164c4fd20674c4", size = 853964, upload-time = "2026-04-13T21:33:24.724Z" }, + { url = "https://files.pythonhosted.org/packages/2a/81/17fa103ea9caf5d325a5e4051ab2ba65996fd66baa60b81ee41af1f54e10/chardet-7.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ac3bf11c645734a1701a3804e43eabd98851838192267d08c353a834ab79fea", size = 876006, upload-time = "2026-04-13T21:33:26.098Z" }, + { url = "https://files.pythonhosted.org/packages/c2/20/193faab46a68ea550587331a698c3dca8099f8901d10937c4443135c7ed9/chardet-7.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e3bd9f936e04bae89c254262af08d9e5b98f805175ba1e29d454e6cba3107b7", size = 887680, upload-time = "2026-04-13T21:33:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/94a3c673327392652ee8bdea9a45bc8a5f5365197a7387d68f0eed007115/chardet-7.4.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27cc23da03630cdecc9aa81a895aa86629c211f995cd57651f0fbc280717bf93", size = 879865, upload-time = "2026-04-13T21:33:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2c/cad8b5e3623a987f3c930b68e2bdd06cfc388cd91cd42ed05f1227701b73/chardet-7.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:b95c934b9ad59e2ba8abb9be49df70d3ad1b0d95d864b9fdb7588d4fa8bd921c", size = 939594, upload-time = "2026-04-13T21:33:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/33/e0/d06e42fd6f02a58e5e227e5106587751cb38adcff0aaf949add744b78b6e/chardet-7.4.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c77867f0c1cb8bd819502249fcdc500364aedb07881e11b743726fa2148e7b6e", size = 889714, upload-time = "2026-04-13T21:33:32.772Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ed/40d091954d48abea037baae6be8fb79905e5f78d34d12ea955132c7d8011/chardet-7.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf1efeaf65a6ef2f5b9cc3a1df6f08ba2831b369ccaa4c7018eaf90aa757bb11", size = 872319, upload-time = "2026-04-13T21:33:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/bb/77/82a46821dbfbdfe062710d2bf2ede13426304e3567a23c57d919c0c31630/chardet-7.4.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f3504c139a2ad544077dd2d9e412cd08b01786843d76997cd43bb6de311723c", size = 892021, upload-time = "2026-04-13T21:33:35.766Z" }, + { url = "https://files.pythonhosted.org/packages/49/57/42d30c562bda5b4a839766c1aad8d5856b798ad2a1c3247b72a679afec94/chardet-7.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457f619882ba66327d4d8d14c6c342269bdb1e4e1c38e8117df941d14d351b04", size = 902509, upload-time = "2026-04-13T21:33:37.096Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/0a40afdb50a0fe041ab95553b835a8160b6cf0e81edf2ae2fe9f5224cbf9/chardet-7.4.3-py3-none-any.whl", hash = "sha256:1173b74051570cf08099d7429d92e4882d375ad4217f92a6e5240ccfb26f231e", size = 626562, upload-time = "2026-04-13T21:33:38.559Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -821,6 +947,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.2.0" @@ -998,24 +1139,26 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.26" +version = "0.5.28" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/9d/f2fd705cbe404e53150ebee4a7208df158ea0e307ade455dc2a3ee16fd12/uipath_core-0.5.26.tar.gz", hash = "sha256:c34f1d7bc823e4a45b8e21ae590d74b6ae9e2caab839c855e376c5f38ffa3e29", size = 130421, upload-time = "2026-06-29T15:43:31.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/f9/8d2f1d98cbebbcf059cf4561f38f34ad4cd58423e4f15cad22bd297a2563/uipath_core-0.5.28.tar.gz", hash = "sha256:942987f6b612c64f93d612ad7b242276ed75f129fdd8f25bc71c24ec8887e388", size = 130578, upload-time = "2026-06-30T14:04:48.841Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/71/b0947b61fe5b45a8642774a02bffa23371c38556fe877b37c8313bbea5f7/uipath_core-0.5.26-py3-none-any.whl", hash = "sha256:ad81607910ccf567721e31c2de557e100a46228f7e1277cfe37d71f6d472b06a", size = 54787, upload-time = "2026-06-29T15:43:30.642Z" }, + { url = "https://files.pythonhosted.org/packages/e8/1e/385bb166232a57ebe938cc57ad2717f350bc922bb5d2ce31af84306b7569/uipath_core-0.5.28-py3-none-any.whl", hash = "sha256:b952a46a21710073cbc16d6d5684e9aa645c107f57a636b778cfb94aa81a1e48", size = 54980, upload-time = "2026-06-30T14:04:47.374Z" }, ] [[package]] name = "uipath-runtime" -version = "0.11.5" +version = "0.11.6" source = { editable = "." } dependencies = [ + { name = "chardet" }, { name = "uipath-core" }, + { name = "vadersentiment" }, ] [package.dev-dependencies] @@ -1034,7 +1177,11 @@ dev = [ ] [package.metadata] -requires-dist = [{ name = "uipath-core", specifier = ">=0.5.26,<0.6.0" }] +requires-dist = [ + { name = "chardet", specifier = ">=5.2.0,<8.0" }, + { name = "uipath-core", specifier = ">=0.5.28,<0.6.0" }, + { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, +] [package.metadata.requires-dev] dev = [ @@ -1051,6 +1198,27 @@ dev = [ { name = "rust-just", specifier = ">=1.39.0" }, ] +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "vadersentiment" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/8c/4a48c10a50f750ae565e341e697d74a38075a3e43ff0df6f1ab72e186902/vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9", size = 2466783, upload-time = "2020-05-22T15:06:32.81Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/fc/310e16254683c1ed35eeb97386986d6c00bc29df17ce280aed64d55537e9/vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311", size = 125950, upload-time = "2020-05-22T15:07:00.052Z" }, +] + [[package]] name = "virtualenv" version = "20.35.4" From cf3638ab0771d09f639fd26de3436c3b303202b0 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 2 Jul 2026 16:22:28 +0530 Subject: [PATCH 14/34] feat(runtime): expose framework name via factory settings --- pyproject.toml | 2 +- src/uipath/runtime/factory.py | 4 ++++ uv.lock | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5f35dece..7fb95311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.6" +version = "0.11.7" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/factory.py b/src/uipath/runtime/factory.py index c9481bd3..ee88e126 100644 --- a/src/uipath/runtime/factory.py +++ b/src/uipath/runtime/factory.py @@ -19,6 +19,10 @@ class UiPathRuntimeFactorySettings(BaseModel): trace_settings: UiPathTraceSettings | None = None + agent_type: str | None = None + + agent_framework: str | None = None + class UiPathRuntimeFactoryProtocol( UiPathDisposableProtocol, diff --git a/uv.lock b/uv.lock index 24e8901d..03ad15a7 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.6" +version = "0.11.7" source = { editable = "." } dependencies = [ { name = "chardet" }, From 927f15b48fb5d5ad570f5f49139083fd959330d0 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Thu, 2 Jul 2026 17:42:52 +0530 Subject: [PATCH 15/34] style(runtime): drop trailing whitespace in UiPathRuntimeFactorySettings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's ``ruff format --check .`` flagged trailing whitespace on the blank line between the ``agent_type`` and ``agent_framework`` fields. Autoformat trim only — no behavioral change. Co-Authored-By: Claude Opus 4.8 --- src/uipath/runtime/factory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath/runtime/factory.py b/src/uipath/runtime/factory.py index ee88e126..166d2abe 100644 --- a/src/uipath/runtime/factory.py +++ b/src/uipath/runtime/factory.py @@ -20,7 +20,7 @@ class UiPathRuntimeFactorySettings(BaseModel): trace_settings: UiPathTraceSettings | None = None agent_type: str | None = None - + agent_framework: str | None = None From 327387b83461142bf77761b263c22517202eb01a Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:02:16 -0400 Subject: [PATCH 16/34] feat: enable conversational-agent outputs from fpsproperties --- src/uipath/runtime/context.py | 5 +++++ tests/test_context.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 924548ca..989cfccd 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -51,6 +51,10 @@ class UiPathRuntimeContext(BaseModel): True, description="Whether to emit the exchange end event for CAS", ) + conversational_outputs_enabled: bool = Field( + False, + description=("Whether conversational agent should generate structured outputs on its turn."), + ) conversational_user_id: str | None = Field( None, description="Conversation owner id for CAS (a real cloud user id or a synthetic user id)", @@ -408,6 +412,7 @@ def from_config( "conversationalService.exchangeId": "exchange_id", "conversationalService.messageId": "message_id", "conversationalService.endExchange": "end_exchange", + "conversationalService.enableOutputs": "conversational_outputs_enabled", "conversationalService.conversationalUserId": "conversational_user_id", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", diff --git a/tests/test_context.py b/tests/test_context.py index d3ed4aed..30cef65a 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -183,6 +183,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> "conversationalService.conversationId": "conv-123", "conversationalService.exchangeId": "ex-456", "conversationalService.messageId": "msg-789", + "conversationalService.enableOutputs": True, "conversationalService.conversationalUserId": "owner-guid", "mcpServer.id": "server-id-123", "mcpServer.slug": "my-mcp-server", @@ -196,11 +197,30 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> assert ctx.conversation_id == "conv-123" assert ctx.exchange_id == "ex-456" assert ctx.message_id == "msg-789" + assert ctx.conversational_outputs_enabled is True assert ctx.conversational_user_id == "owner-guid" assert ctx.mcp_server_id == "server-id-123" assert ctx.mcp_server_slug == "my-mcp-server" +def test_from_config_conversational_outputs_enabled_defaults_false( + tmp_path: Path, +) -> None: + """When enableOutputs isn't in fpsProperties, the field defaults to False — + legacy safe behavior for pre-migration conversational agents.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-legacy", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.conversational_outputs_enabled is False + + def test_from_config_loads_runtime_and_fps_properties(tmp_path: Path) -> None: """runtime.* keys and fpsProperties.* keys should both be applied.""" cfg = { From 6d2712bb70050b5c57b4e5bd0a35b0a6335a7a87 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:22:27 -0400 Subject: [PATCH 17/34] fix: comment --- src/uipath/runtime/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 989cfccd..4174b204 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -53,7 +53,7 @@ class UiPathRuntimeContext(BaseModel): ) conversational_outputs_enabled: bool = Field( False, - description=("Whether conversational agent should generate structured outputs on its turn."), + description="Whether conversational agent output-generation is enabled." ) conversational_user_id: str | None = Field( None, From adacf6f446ef8f2ce1ad8866ca844ac939744f8a Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:30:27 -0400 Subject: [PATCH 18/34] chore: formatting --- src/uipath/runtime/context.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 4174b204..820bb189 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -52,8 +52,7 @@ class UiPathRuntimeContext(BaseModel): description="Whether to emit the exchange end event for CAS", ) conversational_outputs_enabled: bool = Field( - False, - description="Whether conversational agent output-generation is enabled." + False, description="Whether conversational agent output-generation is enabled." ) conversational_user_id: str | None = Field( None, From 250f14b90a7b0bf48a9b6ed3a0f44fa0d1fcdd7a Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:26:30 -0400 Subject: [PATCH 19/34] chore: bump uipath-runtime version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7fb95311..b0315236 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.7" +version = "0.11.8" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 03ad15a7..cd8797c1 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.7" +version = "0.11.8" source = { editable = "." } dependencies = [ { name = "chardet" }, From 6c173c4f4967f27e8407f4b7b1566b3be437730e Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:30:44 -0400 Subject: [PATCH 20/34] test: simplify maestro flow voice mode coverage --- tests/test_context.py | 22 +++------------------- 1 file changed, 3 insertions(+), 19 deletions(-) diff --git a/tests/test_context.py b/tests/test_context.py index d9c0d8c0..2b3de899 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -3,7 +3,6 @@ from typing import Any import pytest -from pydantic import ValidationError from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError from uipath.runtime.context import UiPathRuntimeContext @@ -374,27 +373,12 @@ def test_explicit_execution_source_not_overwritten() -> None: assert ctx.execution_source == "custom" -@pytest.mark.parametrize("voice_mode", ["session", "maestro_flow"]) -def test_constructor_accepts_supported_voice_modes(voice_mode: str) -> None: - ctx = UiPathRuntimeContext(voice_mode=voice_mode) - - assert ctx.voice_mode == voice_mode - - -@pytest.mark.parametrize("voice_mode", ["session", "maestro_flow"]) -def test_from_config_accepts_supported_voice_modes( - voice_mode: str, tmp_path: Path -) -> None: +def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None: config_path = tmp_path / "uipath.json" config_path.write_text( - json.dumps({"fpsProperties": {"voice.mode": voice_mode}}) + json.dumps({"fpsProperties": {"voice.mode": "maestro_flow"}}) ) ctx = UiPathRuntimeContext.from_config(str(config_path)) - assert ctx.voice_mode == voice_mode - - -def test_constructor_rejects_unknown_voice_mode() -> None: - with pytest.raises(ValidationError): - UiPathRuntimeContext(voice_mode="chat") + assert ctx.voice_mode == "maestro_flow" From 00ffc108bea216832e92f9c9e600c5cffe2ecdc5 Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:47:46 -0400 Subject: [PATCH 21/34] test: validate maestro flow voice mode construction --- tests/test_context.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_context.py b/tests/test_context.py index 23fb212a..9b99b7e9 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -393,6 +393,12 @@ def test_explicit_execution_source_not_overwritten() -> None: assert ctx.execution_source == "custom" +def test_constructor_accepts_maestro_flow_voice_mode() -> None: + ctx = UiPathRuntimeContext(voice_mode="maestro_flow") + + assert ctx.voice_mode == "maestro_flow" + + def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None: config_path = tmp_path / "uipath.json" config_path.write_text( From 752ec708a79d6875ee771b1bfba351fd4ad818ed Mon Sep 17 00:00:00 2001 From: Josh Park <50765702+JoshParkSJ@users.noreply.github.com> Date: Mon, 6 Jul 2026 00:22:37 -0400 Subject: [PATCH 22/34] chore: bump uipath-runtime version to 0.11.9 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b0315236..fae52044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.8" +version = "0.11.9" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index cd8797c1..aaf4df8a 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.8" +version = "0.11.9" source = { editable = "." } dependencies = [ { name = "chardet" }, From 97ac96605319f23709e24f5466a9139f1f66e1b6 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Tue, 30 Jun 2026 12:42:21 +0300 Subject: [PATCH 23/34] feat: support multiple resume triggers per interrupt --- .github/scripts/force-runtime-override.py | 91 +++++ .github/workflows/test-uipath-langchain.yml | 14 +- .github/workflows/test-uipath.yml | 14 +- pyproject.toml | 2 +- src/uipath/runtime/resumable/protocols.py | 47 ++- src/uipath/runtime/resumable/runtime.py | 91 ++++- tests/test_resumable.py | 398 +++++++++++++++++++- uv.lock | 2 +- 8 files changed, 614 insertions(+), 45 deletions(-) create mode 100644 .github/scripts/force-runtime-override.py diff --git a/.github/scripts/force-runtime-override.py b/.github/scripts/force-runtime-override.py new file mode 100644 index 00000000..5abbacdc --- /dev/null +++ b/.github/scripts/force-runtime-override.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Force cross-repo uv resolves to use the runtime wheel under test.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def _quoted(value: str) -> str: + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def _add_override(pyproject_path: Path, override: str) -> None: + text = pyproject_path.read_text() + quoted_override = _quoted(override) + + if override in text: + return + + tool_uv_match = re.search( + r"(?ms)^\[tool\.uv\]\n(?P.*?)(?=^\[|\Z)", + text, + ) + if not tool_uv_match: + text = ( + text.rstrip() + + f"\n\n[tool.uv]\noverride-dependencies = [{quoted_override}]\n" + ) + pyproject_path.write_text(text) + return + + body = tool_uv_match.group("body") + override_match = re.search( + r"(?ms)^override-dependencies\s*=\s*\[(?P.*?)\]\s*", + body, + ) + if override_match: + items = override_match.group("items").strip() + if quoted_override in items: + return + + if items: + replacement = f"override-dependencies = [{items}, {quoted_override}]\n" + else: + replacement = f"override-dependencies = [{quoted_override}]\n" + + body = ( + body[: override_match.start()] + replacement + body[override_match.end() :] + ) + else: + body = f"override-dependencies = [{quoted_override}]\n" + body + + text = ( + text[: tool_uv_match.start("body")] + body + text[tool_uv_match.end("body") :] + ) + pyproject_path.write_text(text) + + +def main() -> None: + """Add a local runtime wheel override to a pyproject and print the wheel path.""" + if len(sys.argv) != 3: + print( + "usage: force-runtime-override.py ", + file=sys.stderr, + ) + raise SystemExit(2) + + pyproject_path = Path(sys.argv[1]) + wheel_dir = Path(sys.argv[2]) + wheels = sorted(wheel_dir.glob("uipath_runtime-*.whl")) + if not wheels: + print(f"no uipath-runtime wheel found in {wheel_dir}", file=sys.stderr) + raise SystemExit(1) + if len(wheels) > 1: + wheel_names = ", ".join(str(wheel) for wheel in wheels) + print( + f"expected one uipath-runtime wheel in {wheel_dir}, found: {wheel_names}", + file=sys.stderr, + ) + raise SystemExit(1) + + wheel = wheels[0].resolve() + + _add_override(pyproject_path, f"uipath-runtime @ {wheel.as_uri()}") + print(wheel) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index d30d20e8..dc1145dc 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -45,7 +45,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-langchain-python - run: uv add ../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Run uipath-langchain tests working-directory: uipath-langchain-python @@ -89,7 +91,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-langchain-python - run: uv add ../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Run typecheck working-directory: uipath-langchain-python @@ -163,7 +167,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-langchain-python - run: uv add ../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Install dependencies working-directory: uipath-langchain-python @@ -182,8 +188,8 @@ jobs: echo "Environment: ${{ matrix.environment }}" echo "LLM: ${{ matrix.use_azure_chat && 'UiPathAzureChatOpenAI' || 'UiPathChat' }}" echo "USE_AZURE_CHAT: ${{ matrix.use_azure_chat }}" + python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist # Execute the testcase run script directly bash run.sh bash ../common/validate_output.sh - diff --git a/.github/workflows/test-uipath.yml b/.github/workflows/test-uipath.yml index ddcc5e1d..347504f0 100644 --- a/.github/workflows/test-uipath.yml +++ b/.github/workflows/test-uipath.yml @@ -45,7 +45,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-python/packages/uipath - run: uv add ../../../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Run uipath tests working-directory: uipath-python/packages/uipath @@ -89,7 +91,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-python/packages/uipath - run: uv add ../../../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Run typecheck working-directory: uipath-python/packages/uipath @@ -162,7 +166,9 @@ jobs: - name: Update uipath-runtime version shell: bash working-directory: uipath-python/packages/uipath - run: uv add ../../../uipath-runtime-python/dist/*.whl --dev + run: | + runtime_wheel="$(python ../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../uipath-runtime-python/dist)" + uv add "$runtime_wheel" --dev - name: Install dependencies working-directory: uipath-python/packages/uipath @@ -179,8 +185,8 @@ jobs: run: | echo "Running testcase: ${{ matrix.testcase }}" echo "Environment: ${{ matrix.environment }}" + python ../../../../../uipath-runtime-python/.github/scripts/force-runtime-override.py pyproject.toml ../../../../../uipath-runtime-python/dist # Execute the testcase run script directly bash run.sh bash ../common/validate_output.sh - diff --git a/pyproject.toml b/pyproject.toml index b0315236..9caf79b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.11.8" +version = "0.12.0" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/resumable/protocols.py b/src/uipath/runtime/resumable/protocols.py index 7ce77c80..53855c67 100644 --- a/src/uipath/runtime/resumable/protocols.py +++ b/src/uipath/runtime/resumable/protocols.py @@ -4,6 +4,8 @@ from uipath.core.triggers import UiPathResumeTrigger +from uipath.runtime.errors import UiPathRuntimeError +from uipath.runtime.errors.codes import UiPathErrorCode from uipath.runtime.storage import UiPathRuntimeStorageProtocol @@ -34,6 +36,20 @@ async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger] | Non """ ... + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + """Delete resume triggers from storage. + + Args: + runtime_id: The runtime ID + triggers: The resume triggers to delete + + Raises: + Exception: If deletion operation fails + """ + ... + async def delete_trigger( self, runtime_id: str, trigger: UiPathResumeTrigger ) -> None: @@ -46,7 +62,8 @@ async def delete_trigger( Raises: Exception: If deletion operation fails """ - ... + # TODO: Remove this compatibility alias in the next minor version. + await self.delete_triggers(runtime_id, [trigger]) class UiPathResumeTriggerCreatorProtocol(Protocol): @@ -63,6 +80,34 @@ async def create_trigger(self, suspend_value: Any) -> UiPathResumeTrigger: Returns: UiPathResumeTrigger ready to be persisted + Raises: + UiPathRuntimeError: If trigger creation fails + """ + # TODO: Remove this compatibility alias in the next minor version. + triggers = await self.create_triggers(suspend_value) + if len(triggers) != 1: + raise UiPathRuntimeError( + code=UiPathErrorCode.CREATE_RESUME_TRIGGER_ERROR, + title="Unexpected resume trigger count", + detail=( + "create_trigger() cannot return multiple resume triggers. " + "Use create_triggers() instead." + ), + ) + return triggers[0] + + async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]: + """Create resume triggers from a suspend value. + + Most suspend values produce one trigger. Composite values may produce + multiple sibling triggers for the same interrupt. + + Args: + suspend_value: The value that caused the suspension. + + Returns: + UiPathResumeTrigger objects ready to be persisted. + Raises: UiPathRuntimeError: If trigger creation fails """ diff --git a/src/uipath/runtime/resumable/runtime.py b/src/uipath/runtime/resumable/runtime.py index 8cf2ed57..6bdb6f86 100644 --- a/src/uipath/runtime/resumable/runtime.py +++ b/src/uipath/runtime/resumable/runtime.py @@ -1,10 +1,16 @@ """Resumable runtime protocol and implementation.""" import logging +from collections import Counter +from enum import Enum from typing import Any, AsyncGenerator from uipath.core.errors import UiPathPendingTriggerError -from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType +from uipath.core.triggers import ( + UIPATH_METADATA_KEY, + UiPathResumeTrigger, + UiPathResumeTriggerType, +) from uipath.runtime.base import ( UiPathExecuteOptions, @@ -168,7 +174,7 @@ async def _get_fired_triggers(self) -> dict[str, Any] | None: UiPathResumeTriggerType.TIMER, ) ] - return await self._build_resume_map(pollable_triggers) + return await self._build_resume_map(pollable_triggers, all_triggers=triggers) async def _restore_resume_input( self, @@ -190,15 +196,17 @@ async def _restore_resume_input( if triggers: if len(triggers) == 1: # Single trigger - just delete it - await self.storage.delete_trigger(self.runtime_id, triggers[0]) + await self.storage.delete_triggers(self.runtime_id, triggers) else: # Multiple triggers - match by interrupt_id - found = False - for trigger in triggers: - if trigger.interrupt_id in input: - await self.storage.delete_trigger(self.runtime_id, trigger) - found = True - if not found: + matched_triggers = [ + trigger for trigger in triggers if trigger.interrupt_id in input + ] + if matched_triggers: + await self.storage.delete_triggers( + self.runtime_id, matched_triggers + ) + else: logger.warning( f"Multiple triggers detected but none match the provided input. " f"Please specify which trigger to resume by {{interrupt_id: value}}. " @@ -213,31 +221,75 @@ async def _restore_resume_input( async def _build_resume_map( self, - triggers: list[UiPathResumeTrigger], + pollable_triggers: list[UiPathResumeTrigger], + all_triggers: list[UiPathResumeTrigger] | None = None, ) -> dict[str, Any]: """Build resume map from triggers: {interrupt_id: resume_data}. Args: - triggers: List of triggers to read and map + pollable_triggers: List of triggers to read and map. + all_triggers: Optional full trigger set for the same suspension. + When provided, it is used to detect and delete sibling triggers + that are not pollable. Returns: A dict mapping interrupt_id to the trigger's resume data. """ resume_map: dict[str, Any] = {} - for trigger in triggers: + triggers_to_match = ( + all_triggers if all_triggers is not None else pollable_triggers + ) + trigger_count_by_interrupt_id = Counter( + trigger.interrupt_id for trigger in triggers_to_match + ) + for trigger in pollable_triggers: + assert trigger.interrupt_id is not None, ( + "Trigger interrupt_id cannot be None" + ) + # one fired sibling is enough to resume an interrupt; skip the rest. + if trigger.interrupt_id in resume_map: + continue + try: data = await self.trigger_manager.read_trigger(trigger) - assert trigger.interrupt_id is not None, ( - "Trigger interrupt_id cannot be None" - ) + if trigger_count_by_interrupt_id[trigger.interrupt_id] > 1: + data = self._with_trigger_metadata(trigger, data) resume_map[trigger.interrupt_id] = data - await self.storage.delete_trigger(self.runtime_id, trigger) + sibling_triggers = [ + sibling + for sibling in triggers_to_match + if sibling.interrupt_id == trigger.interrupt_id + ] + await self.storage.delete_triggers(self.runtime_id, sibling_triggers) except UiPathPendingTriggerError: # Trigger still pending, skip it pass return resume_map + def _with_trigger_metadata( + self, trigger: UiPathResumeTrigger, data: Any + ) -> dict[str, Any]: + def normalize_enum_values(value: Any) -> Any: + if isinstance(value, Enum): + return value.value + + return value + + metadata = { + "triggerType": normalize_enum_values(trigger.trigger_type), + "triggerName": normalize_enum_values(trigger.trigger_name), + } + + if isinstance(data, dict): + existing_metadata = data.get(UIPATH_METADATA_KEY) + if isinstance(existing_metadata, dict): + metadata = {**existing_metadata, **metadata} + + return {**data, UIPATH_METADATA_KEY: metadata} + + return {UIPATH_METADATA_KEY: metadata, "value": data} + async def _handle_suspension( self, result: UiPathRuntimeResult ) -> UiPathRuntimeResult: @@ -273,11 +325,12 @@ async def _handle_suspension( # Create triggers only for new interrupts for interrupt_id in new_ids: - trigger = await self.trigger_manager.create_trigger( + triggers = await self.trigger_manager.create_triggers( current_interrupts[interrupt_id] ) - trigger.interrupt_id = interrupt_id - suspended_result.triggers.append(trigger) + for trigger in triggers: + trigger.interrupt_id = interrupt_id + suspended_result.triggers.append(trigger) if suspended_result.triggers: await self.storage.save_triggers(self.runtime_id, suspended_result.triggers) diff --git a/tests/test_resumable.py b/tests/test_resumable.py index 16d0c413..8564b575 100644 --- a/tests/test_resumable.py +++ b/tests/test_resumable.py @@ -7,7 +7,11 @@ import pytest from uipath.core.errors import ErrorCategory, UiPathPendingTriggerError -from uipath.core.triggers import UiPathResumeTrigger, UiPathResumeTriggerType +from uipath.core.triggers import ( + UiPathResumeTrigger, + UiPathResumeTriggerName, + UiPathResumeTriggerType, +) from uipath.runtime import ( UiPathExecuteOptions, @@ -15,15 +19,17 @@ UiPathRuntimeStatus, UiPathStreamOptions, ) +from uipath.runtime.errors import UiPathRuntimeError from uipath.runtime.events import UiPathRuntimeEvent from uipath.runtime.resumable.protocols import ( + UiPathResumableStorageProtocol, UiPathResumeTriggerProtocol, ) from uipath.runtime.resumable.runtime import UiPathResumableRuntime from uipath.runtime.schema import UiPathRuntimeSchema -class MultiTriggerMockRuntime: +class SiblingTriggerMockRuntime: """Mock runtime that simulates parallel branching with multiple interrupts.""" def __init__(self) -> None: @@ -89,6 +95,56 @@ async def get_schema(self) -> UiPathRuntimeSchema: raise NotImplementedError() +class MultiTriggerMockRuntime: + """Mock runtime that suspends on a multi-trigger interrupt, then suspends again.""" + + def __init__(self) -> None: + self.execution_count = 0 + + async def dispose(self) -> None: + pass + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + self.execution_count += 1 + is_resume = options and options.resume + + if self.execution_count == 1: + return UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={"child-1": {"process": "first-child"}}, + ) + + assert is_resume + assert input == { + "child-1": { + "completed": True, + "__uipath": { + "triggerType": UiPathResumeTriggerType.JOB.value, + "triggerName": "Unknown", + }, + } + } + return UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={"child-2": {"process": "second-child"}}, + ) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + result = await self.execute(input, options) + yield result + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError() + + class StatefulStorageMock: """Stateful storage mock that tracks triggers.""" @@ -106,8 +162,14 @@ async def save_triggers( async def delete_trigger( self, runtime_id: str, trigger: UiPathResumeTrigger ) -> None: + await self.delete_triggers(runtime_id, [trigger]) + + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + interrupt_ids = {trigger.interrupt_id for trigger in triggers} self.triggers = [ - t for t in self.triggers if t.interrupt_id != trigger.interrupt_id + t for t in self.triggers if t.interrupt_id not in interrupt_ids ] async def set_value( @@ -119,6 +181,56 @@ async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: return None +class DeprecatedDeleteAliasStorage(UiPathResumableStorageProtocol): + """Storage that relies on the protocol's deprecated singular delete alias.""" + + def __init__(self) -> None: + self.deleted_triggers: list[UiPathResumeTrigger] = [] + + async def get_triggers(self, runtime_id: str) -> list[UiPathResumeTrigger]: + return [] + + async def save_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + pass + + async def delete_triggers( + self, runtime_id: str, triggers: list[UiPathResumeTrigger] + ) -> None: + self.deleted_triggers.extend(triggers) + + async def set_value( + self, runtime_id: str, namespace: str, key: str, value: Any + ) -> None: + pass + + async def get_value(self, runtime_id: str, namespace: str, key: str) -> Any: + return None + + +class DeprecatedCreateAliasManager(UiPathResumeTriggerProtocol): + """Trigger manager that relies on the protocol's singular create alias.""" + + def __init__(self) -> None: + self.created_values: list[Any] = [] + self.trigger_count = 1 + + async def create_triggers(self, suspend_value: Any) -> list[UiPathResumeTrigger]: + self.created_values.append(suspend_value) + return [ + UiPathResumeTrigger( + interrupt_id="", + trigger_type=UiPathResumeTriggerType.TASK, + payload=suspend_value, + ) + for _ in range(self.trigger_count) + ] + + async def read_trigger(self, trigger: UiPathResumeTrigger) -> Any | None: + return None + + def make_trigger_manager_mock() -> UiPathResumeTriggerProtocol: """Create trigger manager mock.""" manager = Mock(spec=UiPathResumeTriggerProtocol) @@ -134,19 +246,211 @@ async def read_trigger_default(trigger: UiPathResumeTrigger) -> dict[str, Any]: raise UiPathPendingTriggerError(ErrorCategory.USER, "Trigger not fired yet") manager.create_trigger = AsyncMock(side_effect=create_trigger_impl) + + async def create_triggers_impl(data: dict[str, Any]) -> list[UiPathResumeTrigger]: + return [await manager.create_trigger(data)] + + manager.create_triggers = AsyncMock(side_effect=create_triggers_impl) manager.read_trigger = AsyncMock(side_effect=read_trigger_default) return cast(UiPathResumeTriggerProtocol, manager) class TestResumableRuntime: + @pytest.mark.asyncio + async def test_delete_trigger_alias_delegates_to_delete_triggers(self) -> None: + """Deprecated singular delete API delegates to the plural API.""" + + storage = DeprecatedDeleteAliasStorage() + trigger = UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.TASK, + ) + + await storage.delete_trigger("runtime-1", trigger) + + assert storage.deleted_triggers == [trigger] + + @pytest.mark.asyncio + async def test_create_trigger_alias_delegates_to_create_triggers(self) -> None: + """Deprecated singular create API delegates to the plural API.""" + + manager = DeprecatedCreateAliasManager() + + trigger = await manager.create_trigger({"action": "approve"}) + + assert trigger.payload == {"action": "approve"} + assert manager.created_values == [{"action": "approve"}] + + @pytest.mark.asyncio + async def test_create_trigger_alias_rejects_multiple_triggers(self) -> None: + """Deprecated singular create API fails if plural API returns siblings.""" + + manager = DeprecatedCreateAliasManager() + manager.trigger_count = 2 + + with pytest.raises(UiPathRuntimeError, match="Use create_triggers"): + await manager.create_trigger({"action": "approve"}) + + def test_with_trigger_metadata_merges_existing_uipath_metadata(self) -> None: + """Existing UiPath metadata is preserved when trigger metadata is added.""" + + storage = StatefulStorageMock() + trigger_manager = make_trigger_manager_mock() + resumable = UiPathResumableRuntime( + delegate=SiblingTriggerMockRuntime(), + storage=storage, + trigger_manager=trigger_manager, + runtime_id="runtime-1", + ) + trigger = UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.TIMER, + trigger_name=UiPathResumeTriggerName.TIMER, + ) + + result = resumable._with_trigger_metadata( + trigger, + {"__uipath": {"kind": "timeout", "timeout": 10}, "value": "done"}, + ) + + assert result == { + "__uipath": { + "kind": "timeout", + "timeout": 10, + "triggerType": UiPathResumeTriggerType.TIMER.value, + "triggerName": UiPathResumeTriggerName.TIMER.value, + }, + "value": "done", + } + + def test_with_trigger_metadata_wraps_non_mapping_data(self) -> None: + """Non-mapping resume values are wrapped with trigger metadata.""" + + storage = StatefulStorageMock() + trigger_manager = make_trigger_manager_mock() + resumable = UiPathResumableRuntime( + delegate=SiblingTriggerMockRuntime(), + storage=storage, + trigger_manager=trigger_manager, + runtime_id="runtime-1", + ) + trigger = Mock( + interrupt_id="int-1", + trigger_type="CustomType", + trigger_name="CustomName", + ) + + result = resumable._with_trigger_metadata( + cast(UiPathResumeTrigger, trigger), "approved" + ) + + assert result == { + "__uipath": { + "triggerType": "CustomType", + "triggerName": "CustomName", + }, + "value": "approved", + } + + @pytest.mark.asyncio + async def test_restore_resume_input_deletes_single_trigger_for_explicit_input( + self, + ) -> None: + """Explicit resume input clears the only stored trigger.""" + + storage = StatefulStorageMock() + storage.triggers = [ + UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.TASK, + ) + ] + resumable = UiPathResumableRuntime( + delegate=SiblingTriggerMockRuntime(), + storage=storage, + trigger_manager=make_trigger_manager_mock(), + runtime_id="runtime-1", + ) + + result = await resumable._restore_resume_input({"int-1": {"approved": True}}) + + assert result == {"int-1": {"approved": True}} + assert storage.triggers == [] + + @pytest.mark.asyncio + async def test_restore_resume_input_deletes_matching_trigger_for_explicit_input( + self, + ) -> None: + """Explicit resume input clears only matching stored triggers.""" + + trigger_1 = UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.TASK, + ) + trigger_2 = UiPathResumeTrigger( + interrupt_id="int-2", + trigger_type=UiPathResumeTriggerType.TASK, + ) + storage = StatefulStorageMock() + storage.triggers = [trigger_1, trigger_2] + resumable = UiPathResumableRuntime( + delegate=SiblingTriggerMockRuntime(), + storage=storage, + trigger_manager=make_trigger_manager_mock(), + runtime_id="runtime-1", + ) + + result = await resumable._restore_resume_input({"int-2": {"approved": True}}) + + assert result == {"int-2": {"approved": True}} + assert storage.triggers == [trigger_1] + + @pytest.mark.asyncio + async def test_build_resume_map_skips_duplicate_pollable_trigger(self) -> None: + """Duplicate pollable triggers for one interrupt are read once.""" + + trigger_1 = UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.TASK, + ) + trigger_2 = UiPathResumeTrigger( + interrupt_id="int-1", + trigger_type=UiPathResumeTriggerType.JOB, + ) + storage = StatefulStorageMock() + storage.triggers = [trigger_1, trigger_2] + trigger_manager = make_trigger_manager_mock() + read_trigger_mock = AsyncMock(return_value="approved") + cast(Any, trigger_manager).read_trigger = read_trigger_mock + resumable = UiPathResumableRuntime( + delegate=SiblingTriggerMockRuntime(), + storage=storage, + trigger_manager=trigger_manager, + runtime_id="runtime-1", + ) + + result = await resumable._build_resume_map([trigger_1, trigger_2]) + + assert result == { + "int-1": { + "__uipath": { + "triggerType": UiPathResumeTriggerType.TASK.value, + "triggerName": "Unknown", + }, + "value": "approved", + } + } + read_trigger_mock.assert_awaited_once_with(trigger_1) + assert storage.triggers == [] + @pytest.mark.asyncio async def test_resumable_creates_multiple_triggers_on_first_suspension( self, ) -> None: """First suspension with parallel branches should create multiple triggers.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -179,7 +483,7 @@ async def test_resumable_creates_multiple_triggers_on_first_suspension( async def test_resumable_adds_only_new_triggers_on_partial_resume(self) -> None: """Partial resume should keep pending trigger and add only new ones.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -222,7 +526,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_completes_after_all_triggers_resolved(self) -> None: """After all triggers resolved, execution should complete successfully.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -269,7 +573,7 @@ async def read_trigger_impl_3(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_auto_resumes_when_triggers_already_fired(self) -> None: """When triggers are already fired during suspension, runtime should auto-resume.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -308,7 +612,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_auto_resumes_partial_fired_triggers(self) -> None: """When only some triggers are fired during suspension, auto-resume with those.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -344,7 +648,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_auto_resumes_multiple_times(self) -> None: """When triggers keep being fired immediately, keep auto-resuming until complete.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -389,7 +693,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_stream_auto_resumes_when_triggers_fired(self) -> None: """Stream should auto-resume when triggers are already fired.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -426,7 +730,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_no_auto_resume_when_all_triggers_pending(self) -> None: """When all triggers are pending, should NOT auto-resume.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -457,7 +761,7 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: async def test_resumable_skips_api_triggers_on_auto_resume_check(self) -> None: """API triggers should be skipped when checking for auto-resume after suspension.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -508,7 +812,7 @@ async def test_resumable_skips_inbox_triggers_on_auto_resume_check(self) -> None 404 and fault the run. They should behave like API triggers here. """ - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -552,7 +856,7 @@ def create_inbox_trigger(data: dict[str, Any]) -> UiPathResumeTrigger: async def test_resumable_skips_timer_triggers_on_auto_resume_check(self) -> None: """Timer triggers should be skipped when checking for auto-resume.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -595,7 +899,7 @@ async def test_resumable_auto_resumes_task_triggers_but_not_api_triggers( ) -> None: """Mixed triggers: TASK triggers should trigger auto-resume, API triggers should not.""" - runtime_impl = MultiTriggerMockRuntime() + runtime_impl = SiblingTriggerMockRuntime() storage = StatefulStorageMock() trigger_manager = make_trigger_manager_mock() @@ -640,3 +944,67 @@ async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: # Delegate should have been executed twice (initial + auto-resume for TASK trigger) assert runtime_impl.execution_count == 2 + + @pytest.mark.asyncio + async def test_resumable_removes_sibling_triggers_when_one_trigger_fires( + self, + ) -> None: + """When one trigger fires, sibling triggers must not leak forward.""" + + runtime_impl = MultiTriggerMockRuntime() + storage = StatefulStorageMock() + trigger_manager = make_trigger_manager_mock() + + async def create_sibling_triggers( + data: dict[str, Any], + ) -> list[UiPathResumeTrigger]: + return [ + UiPathResumeTrigger( + interrupt_id="", # Will be set by resumable runtime + trigger_type=UiPathResumeTriggerType.JOB, + payload={"source": data["process"]}, + ), + UiPathResumeTrigger( + interrupt_id="", # Will be set by resumable runtime + trigger_type=UiPathResumeTriggerType.TIMER, + payload={"source": data["process"]}, + ), + ] + + create_triggers_mock = AsyncMock(side_effect=create_sibling_triggers) + cast(Any, trigger_manager).create_triggers = create_triggers_mock + + async def read_trigger_impl(trigger: UiPathResumeTrigger) -> dict[str, Any]: + if ( + trigger.interrupt_id == "child-1" + and trigger.trigger_type == UiPathResumeTriggerType.JOB + ): + return {"completed": True} + raise UiPathPendingTriggerError(ErrorCategory.USER, "still pending") + + read_trigger_mock = AsyncMock(side_effect=read_trigger_impl) + cast(Any, trigger_manager).read_trigger = read_trigger_mock + + resumable = UiPathResumableRuntime( + delegate=runtime_impl, + storage=storage, + trigger_manager=trigger_manager, + runtime_id="runtime-1", + ) + + result = await resumable.execute({}) + + assert result.status == UiPathRuntimeStatus.SUSPENDED + assert result.triggers is not None + assert {t.interrupt_id for t in result.triggers} == {"child-2"} + assert len(result.triggers) == 2 + assert {t.trigger_type for t in result.triggers} == { + UiPathResumeTriggerType.JOB, + UiPathResumeTriggerType.TIMER, + } + assert runtime_impl.execution_count == 2 + assert create_triggers_mock.await_count == 2 + assert read_trigger_mock.await_count == 2 + + saved_triggers = await storage.get_triggers("runtime-1") + assert {t.interrupt_id for t in saved_triggers} == {"child-2"} diff --git a/uv.lock b/uv.lock index cd8797c1..4098b66d 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.11.8" +version = "0.12.0" source = { editable = "." } dependencies = [ { name = "chardet" }, From f366ecbec1f763507265a5a425c344d4a3adbd72 Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 7 Jul 2026 13:41:09 +0300 Subject: [PATCH 24/34] feat: add isTransactionRoot to UiPathRuntimeSchema Declares is_transaction_root (alias isTransactionRoot) as an optional field on the runtime schema so 'uipath init' can stamp vertical-solution entrypoints in entry-points.json with a typed assignment instead of an extra-field workaround. Unset, the field is omitted from serialization, matching the nullable consumer contract. Co-Authored-By: Claude Fable 5 --- src/uipath/runtime/schema.py | 6 ++++++ tests/test_schema.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/uipath/runtime/schema.py b/src/uipath/runtime/schema.py index f9a25a37..cf4b3118 100644 --- a/src/uipath/runtime/schema.py +++ b/src/uipath/runtime/schema.py @@ -59,6 +59,12 @@ class UiPathRuntimeSchema(BaseModel): type: str = Field(..., alias="type") input: dict[str, Any] = Field(..., alias="input") output: dict[str, Any] = Field(..., alias="output") + is_transaction_root: bool | None = Field( + None, + alias="isTransactionRoot", + description="Whether this entrypoint is a transaction root " + "(set for vertical-solution projects)", + ) graph: UiPathRuntimeGraph | None = Field( None, description="Runtime graph structure for debugging" ) diff --git a/tests/test_schema.py b/tests/test_schema.py index 3dd737d0..29440be6 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from uipath.runtime.schema import ( + UiPathRuntimeSchema, transform_attachments, transform_nullable_types, transform_references, @@ -295,3 +296,38 @@ class TestModel(BaseModel): } assert result == expected + + +class TestUiPathRuntimeSchema: + def _make_schema(self, **kwargs: Any) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath="main.py", + uniqueId="00000000-0000-0000-0000-000000000001", + type="function", + input={}, + output={}, + **kwargs, + ) + + def test_is_transaction_root_omitted_when_unset(self): + """Unset flag must not appear in the serialized entrypoint (bool? on consumers).""" + schema = self._make_schema() + + dumped = schema.model_dump(by_alias=True, exclude_unset=True) + + assert "isTransactionRoot" not in dumped + + def test_is_transaction_root_serializes_by_alias(self): + """The flag set via the python name serializes under its camelCase alias.""" + schema = self._make_schema() + schema.is_transaction_root = True + + dumped = schema.model_dump(by_alias=True, exclude_unset=True) + + assert dumped["isTransactionRoot"] is True + + def test_is_transaction_root_validates_from_alias(self): + """The flag round-trips when read back from entry-points.json content.""" + schema = self._make_schema(isTransactionRoot=True) + + assert schema.is_transaction_root is True From 0a7dbd0612af78b020b196ecb608354ec4e08d9c Mon Sep 17 00:00:00 2001 From: Vlad Cimpeanu Date: Tue, 7 Jul 2026 13:41:10 +0300 Subject: [PATCH 25/34] chore: bump uipath-runtime version to 0.12.2 Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fd25b531..b0b413a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.1" +version = "0.12.2" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index e123b899..afe9408e 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.1" +version = "0.12.2" source = { editable = "." } dependencies = [ { name = "chardet" }, From 8f675e08e7e70f9adbbdcb1d0f3afc53a5d2c596 Mon Sep 17 00:00:00 2001 From: Maxwell Du <60411452+maxduu@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:02:42 -0400 Subject: [PATCH 26/34] feat: add conversational_run_as_me to runtime context Exposes fpsProperties.conversationalService.runAsMe on UiPathRuntimeContext.conversational_run_as_me so downstream layers (e.g. langchain tool factory) can mirror the parent conversational agent job's RunAsMe flag onto child process/API-workflow tool invocations, replacing the brittle token sub_type inspection that was misclassifying user tokens. Co-Authored-By: Claude Opus 4.7 (1M context) --- pyproject.toml | 2 +- src/uipath/runtime/context.py | 5 +++++ tests/test_context.py | 19 +++++++++++++++++++ uv.lock | 2 +- 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b0b413a6..c1415a6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.2" +version = "0.12.3" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index d59c6a0c..05219b45 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -58,6 +58,10 @@ class UiPathRuntimeContext(BaseModel): None, description="Conversation owner id for CAS (a real cloud user id or a synthetic user id)", ) + conversational_run_as_me: bool = Field( + False, + description="Whether the conversational agent job is running with RunAsMe: true.", + ) voice_mode: Literal["session", "maestro_flow"] | None = Field( None, description="Voice job type for CAS" ) @@ -413,6 +417,7 @@ def from_config( "conversationalService.endExchange": "end_exchange", "conversationalService.enableOutputs": "conversational_outputs_enabled", "conversationalService.conversationalUserId": "conversational_user_id", + "conversationalService.runAsMe": "conversational_run_as_me", "mcpServer.id": "mcp_server_id", "mcpServer.slug": "mcp_server_slug", "voice.mode": "voice_mode", diff --git a/tests/test_context.py b/tests/test_context.py index 9b99b7e9..39bc4289 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -185,6 +185,7 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> "conversationalService.messageId": "msg-789", "conversationalService.enableOutputs": True, "conversationalService.conversationalUserId": "owner-guid", + "conversationalService.runAsMe": True, "mcpServer.id": "server-id-123", "mcpServer.slug": "my-mcp-server", } @@ -199,10 +200,28 @@ def test_from_config_extracts_fps_properties_without_runtime(tmp_path: Path) -> assert ctx.message_id == "msg-789" assert ctx.conversational_outputs_enabled is True assert ctx.conversational_user_id == "owner-guid" + assert ctx.conversational_run_as_me is True assert ctx.mcp_server_id == "server-id-123" assert ctx.mcp_server_slug == "my-mcp-server" +def test_conversational_run_as_me_defaults_false_when_fps_property_absent( + tmp_path: Path, +) -> None: + """conversational_run_as_me defaults to False when the fps key is missing.""" + cfg = { + "fpsProperties": { + "conversationalService.conversationId": "conv-123", + } + } + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.conversational_run_as_me is False + + def test_from_config_conversational_outputs_enabled_defaults_false( tmp_path: Path, ) -> None: diff --git a/uv.lock b/uv.lock index afe9408e..1aa86de4 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.2" +version = "0.12.3" source = { editable = "." } dependencies = [ { name = "chardet" }, From 58cc1e35e14f50388f48a36ed382ceda7265b105 Mon Sep 17 00:00:00 2001 From: Andrei Tava Date: Fri, 17 Jul 2026 12:01:16 +0300 Subject: [PATCH 27/34] feat: support lazy workspace hydrator creation (#154) --- pyproject.toml | 2 +- src/uipath/runtime/workspace/hydration.py | 44 ++++++++- tests/workspace/test_workspace_hydration.py | 102 ++++++++++++++++++++ uv.lock | 2 +- 4 files changed, 144 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c1415a6e..d02f960f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.3" +version = "0.12.4" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py index 2b3d8e1c..e8e979a6 100644 --- a/src/uipath/runtime/workspace/hydration.py +++ b/src/uipath/runtime/workspace/hydration.py @@ -1,7 +1,7 @@ """Runtime wrapper for workspace hydration.""" from enum import Enum -from typing import Any, AsyncGenerator +from typing import Any, AsyncGenerator, Callable, overload from uipath.runtime.base import ( UiPathExecuteOptions, @@ -27,6 +27,7 @@ class HydrationPolicy(str, Enum): class HydrationRuntime: """Wraps a runtime with hydrate-before and dehydrate-after behavior.""" + @overload def __init__( self, delegate: UiPathRuntimeProtocol, @@ -34,15 +35,50 @@ def __init__( workspace: Workspace, hydrator: WorkspaceHydrator, registry_store: WorkspaceRegistryStore, + hydrator_factory: None = None, policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY, - ): + ) -> None: ... + + @overload + def __init__( + self, + delegate: UiPathRuntimeProtocol, + *, + workspace: Workspace, + hydrator: None = None, + registry_store: WorkspaceRegistryStore, + hydrator_factory: Callable[[], WorkspaceHydrator], + policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY, + ) -> None: ... + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + *, + workspace: Workspace, + hydrator: WorkspaceHydrator | None = None, + registry_store: WorkspaceRegistryStore, + hydrator_factory: Callable[[], WorkspaceHydrator] | None = None, + policy: HydrationPolicy = HydrationPolicy.SUSPEND_ONLY, + ) -> None: """Initialize the hydration wrapper.""" + if (hydrator is None) == (hydrator_factory is None): + raise ValueError("Provide exactly one of hydrator or hydrator_factory") + self.delegate = delegate self.workspace = workspace self.hydrator = hydrator + self._hydrator_factory = hydrator_factory self.registry_store = registry_store self.policy = policy + def _get_hydrator(self) -> WorkspaceHydrator: + if self.hydrator is None: + if self._hydrator_factory is None: # pragma: no cover - constructor guard + raise RuntimeError("Hydrator is not configured") + self.hydrator = self._hydrator_factory() + return self.hydrator + async def execute( self, input: dict[str, Any] | None = None, @@ -96,7 +132,7 @@ async def dispose(self) -> None: async def _hydrate(self) -> None: registry = await self.registry_store.load() - hydrated = await self.hydrator.hydrate(registry) + hydrated = await self._get_hydrator().hydrate(registry) if hydrated != registry: await self.registry_store.save(hydrated) @@ -106,7 +142,7 @@ async def _dehydrate(self, result: UiPathRuntimeResult) -> None: async def _persist(self) -> None: registry = await self.registry_store.load() - dehydrated = await self.hydrator.dehydrate(registry) + dehydrated = await self._get_hydrator().dehydrate(registry) await self.registry_store.save(dehydrated) def _should_dehydrate(self, result: UiPathRuntimeResult) -> bool: diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py index c6a2b297..b37e1817 100644 --- a/tests/workspace/test_workspace_hydration.py +++ b/tests/workspace/test_workspace_hydration.py @@ -125,6 +125,20 @@ async def dispose(self) -> None: self.disposed = True +class SchemaRuntime(WritingRuntime): + def __init__(self, workspace_path: Path) -> None: + super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL) + + async def get_schema(self) -> UiPathRuntimeSchema: + return UiPathRuntimeSchema( + filePath="agent.py", + uniqueId="agent", + type="agent", + input={}, + output={}, + ) + + @pytest.mark.asyncio async def test_dehydrate_uploads_changed_files_and_saves_registry( tmp_path: Path, @@ -158,6 +172,94 @@ async def test_dehydrate_uploads_changed_files_and_saves_registry( assert len(jobs.links) == 1 +@pytest.mark.asyncio +async def test_hydrator_factory_is_deferred_and_cached(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + storage = MemoryStorage() + factory_calls = 0 + + def create_hydrator() -> WorkspaceHydrator: + nonlocal factory_calls + factory_calls += 1 + return WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + + runtime = HydrationRuntime( + SchemaRuntime(workspace.path), + workspace=workspace, + hydrator_factory=create_hydrator, + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + ) + + await runtime.get_schema() + assert factory_calls == 0 + assert runtime.hydrator is None + + await runtime.execute({}) + assert [event async for event in runtime.stream({})] + + assert factory_calls == 1 + assert runtime.hydrator is not None + assert runtime.hydrator.attachments is attachments + + +@pytest.mark.asyncio +async def test_dispose_does_not_create_hydrator(tmp_path: Path) -> None: + workspace = Workspace.create(tmp_path / "workspace") + factory_calls = 0 + + def create_hydrator() -> WorkspaceHydrator: + nonlocal factory_calls + factory_calls += 1 + return WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ) + + delegate = SchemaRuntime(workspace.path) + runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator_factory=create_hydrator, + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + await runtime.dispose() + + assert delegate.disposed + assert factory_calls == 0 + + +def test_hydration_runtime_requires_exactly_one_hydrator_source( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ) + registry_store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + + with pytest.raises(ValueError, match="exactly one"): + HydrationRuntime( # type: ignore[call-overload] + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + registry_store=registry_store, + ) + + with pytest.raises(ValueError, match="exactly one"): + HydrationRuntime( # type: ignore[call-overload] + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=hydrator, + hydrator_factory=lambda: hydrator, + registry_store=registry_store, + ) + + @pytest.mark.asyncio async def test_successful_completion_persists_when_policy_allows( tmp_path: Path, diff --git a/uv.lock b/uv.lock index 1aa86de4..685e071a 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.3" +version = "0.12.4" source = { editable = "." } dependencies = [ { name = "chardet" }, From fff0bc67008cf95e9e445da949e5302bce629704 Mon Sep 17 00:00:00 2001 From: Viswanath Lekshmanan Date: Mon, 20 Jul 2026 22:29:42 +0530 Subject: [PATCH 28/34] feat: align governance trace spans with OTEL contract Governance spans previously identified as agentRun spans (type/span_type) and defaulted the root Source to CodedAgents, which broke the BYO OpenTelemetry contract: consumers grouping by span_type=agentRun saw governance spans that carry none of the agentRun attribute shape. - Set uipath.source="Governance" so the platform exporter maps the root Source field to SpanSource.GOVERNANCE instead of CodedAgents. - Change type/span_type from "agentRun" to a dedicated "governance" value so governance spans no longer impersonate agent-run spans. - Remove the hook-summary span from TracesAuditSink; HOOK_END is now dropped by this sink (still emitted and consumed by track_events). - Add tests for the dropped HOOK_END span and the new span-type/source contract (asserts the tracer is never asked to start a HOOK_END span and that rule spans carry governance type/source). - Bump uipath-runtime version to 0.12.5. Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- .../runtime/governance/_audit/traces.py | 67 ++++------------- tests/test_traces_severity.py | 72 ++++++++++++++----- uv.lock | 2 +- 4 files changed, 68 insertions(+), 75 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index d02f960f..a7b90c5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.4" +version = "0.12.5" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/governance/_audit/traces.py b/src/uipath/runtime/governance/_audit/traces.py index 1c76e437..5cb14603 100644 --- a/src/uipath/runtime/governance/_audit/traces.py +++ b/src/uipath/runtime/governance/_audit/traces.py @@ -1,6 +1,6 @@ """OpenTelemetry traces audit sink for governance events. -Emits an OpenTelemetry span per rule evaluation and per hook summary. +Emits an OpenTelemetry span per rule evaluation. This sink emits spans only — it does not resolve or stamp job-execution metadata (organization, tenant, folder, job, trace id) onto them. That resolution is owned by the platform-side OTel @@ -39,13 +39,16 @@ def _package_version() -> str: # governance span. Local to the runtime trace contract — kept as a # string literal (not a cross-package import) so the runtime stays # self-contained. -SPAN_TYPE_AGENT_RUN = "agentRun" +SPAN_TYPE_GOVERNANCE = "governance" # Set as the ``source`` attribute on every governance span. Lets # consumers identify which producer emitted a given span when more # than one governance producer feeds the same trace backend. GOVERNANCE_SOURCE = "governance-checker-python" +# Value of the root ``uipath.source`` span attribute. +UIPATH_SOURCE = "Governance" + # Shared attribute namespace for every governance span attribute. # Concatenated into each ``span.set_attribute`` call so the prefix # appears in one place and a future rename is a one-line change. @@ -155,55 +158,9 @@ def _get_tracer(self) -> Any: return self._tracer if self._tracer else None def emit(self, event: AuditEvent) -> None: - """Create a span for RULE_EVALUATION or HOOK_END events; drop others.""" + """Create a span for RULE_EVALUATION events; drop others.""" if event.event_type == EventType.RULE_EVALUATION: self._emit_rule_span(event) - elif event.event_type == EventType.HOOK_END: - self._emit_hook_span(event) - - def _emit_hook_span(self, event: AuditEvent) -> None: - """Create a span for a hook summary (always emitted for each governance check).""" - tracer = self._get_tracer() - if tracer is None: - return - - try: - data = event.data - hook = event.hook or "unknown" - span_name = f"governance.{hook.lower()}" - - # Sink dispatch runs on the caller's thread (see - # :meth:`AuditManager.emit`), so the current OTel context - # is the agent's live span — the governance span attaches - # as its child without any cross-thread plumbing. - with tracer.start_as_current_span(span_name) as span: - span.set_attribute("type", SPAN_TYPE_AGENT_RUN) - span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) - span.set_attribute("uipath.custom_instrumentation", True) - span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) - - # Mode travels on the event so parallel runtimes running - # different per-instance modes don't cross-contaminate. - mode = _resolve_mode(event) - final_action = data.get("final_action", "allow") - _, action_applied = _derive_results( - matched=final_action.lower() != "allow", - configured_action=final_action, - mode=mode, - ) - span.set_attribute(f"{NS}.hook", hook) - span.set_attribute(f"{NS}.action_applied", action_applied) - span.set_attribute(f"{NS}.mode", mode.value.upper()) - - # Hook spans are summary containers — severity lives on - # the per-rule spans. Marking the hook ERROR would paint - # the whole lifecycle phase as failed when only one rule - # fired beneath it. - - self._spans_created += 1 - - except Exception as e: - logger.warning("Failed to create governance hook span: %s", e) def _emit_rule_span(self, event: AuditEvent) -> None: """Create a span for a rule evaluation.""" @@ -216,13 +173,15 @@ def _emit_rule_span(self, event: AuditEvent) -> None: policy_id = data.get("policy_id", "unknown") span_name = f"{NS}.rule.{policy_id}" - # See _emit_hook_span: sync dispatch on the caller's - # thread means the current OTel context is the agent's - # live span, so this rule span attaches as its child. + # Sink dispatch runs on the caller's thread (see + # :meth:`AuditManager.emit`), so the current OTel context + # is the agent's live span — this rule span attaches as + # its child without any cross-thread plumbing. with tracer.start_as_current_span(span_name) as span: - span.set_attribute("type", SPAN_TYPE_AGENT_RUN) - span.set_attribute("span_type", SPAN_TYPE_AGENT_RUN) + span.set_attribute("type", SPAN_TYPE_GOVERNANCE) + span.set_attribute("span_type", SPAN_TYPE_GOVERNANCE) span.set_attribute("uipath.custom_instrumentation", True) + span.set_attribute("uipath.source", UIPATH_SOURCE) span.set_attribute(f"{NS}.source", GOVERNANCE_SOURCE) # Single source of truth for the emitted attributes diff --git a/tests/test_traces_severity.py b/tests/test_traces_severity.py index f93eab8e..5b7e11ab 100644 --- a/tests/test_traces_severity.py +++ b/tests/test_traces_severity.py @@ -1,10 +1,10 @@ """Tests for trace-span verbosity / status semantics. -``TracesAuditSink`` emits an OpenTelemetry span for every governance -hook end and every rule evaluation. The verdict is split into -``evaluator_result`` (what the rule decided, mode-independent) and -``action_applied`` (what actually happened, derived from -evaluator_result + mode). +``TracesAuditSink`` emits an OpenTelemetry span for every rule +evaluation. HOOK_END events are dropped — no hook-summary span. The +verdict is split into ``evaluator_result`` (what the rule decided, +mode-independent) and ``action_applied`` (what actually happened, +derived from evaluator_result + mode). Mode travels with the event (set by the emitter from its per-instance ``EnforcementMode``) so parallel runtimes running @@ -17,8 +17,6 @@ outcomes (``action_applied`` in ``{AUDIT, HITL}``). HITL is its own bucket — escalation pauses for human review, it doesn't fail the run, so it stays Warning even in ENFORCE mode. -- Hook spans never set Status, regardless of mode or final_action. - They're summary containers; severity belongs on the per-rule span. - ``ALLOW`` / ``NONE`` results leave verbosityLevel unset (consumers apply their default) and never call set_status. """ @@ -31,7 +29,11 @@ from uipath.core.governance import EnforcementMode from uipath.runtime.governance._audit.base import AuditEvent, EventType -from uipath.runtime.governance._audit.traces import TracesAuditSink +from uipath.runtime.governance._audit.traces import ( + SPAN_TYPE_GOVERNANCE, + UIPATH_SOURCE, + TracesAuditSink, +) @pytest.fixture @@ -89,7 +91,7 @@ def _span_attrs(span: MagicMock) -> dict[str, object]: # --------------------------------------------------------------------------- -# Hook span — never marked ERROR +# Hook span — dropped; no span is emitted for HOOK_END # --------------------------------------------------------------------------- @@ -103,16 +105,46 @@ def _span_attrs(span: MagicMock) -> dict[str, object]: ("allow", EnforcementMode.AUDIT), ], ) -def test_hook_span_never_sets_error( +def test_hook_end_emits_no_span( captured_span: MagicMock, final_action: str, mode: EnforcementMode ) -> None: - """Hook spans are summary containers — they never carry an ERROR Status.""" + """HOOK_END events are dropped — the sink emits spans per rule only. + + No span is started and the counter stays at zero regardless of the + hook's final_action or mode. + """ sink = TracesAuditSink() sink.emit(_hook_event(final_action=final_action, mode=mode)) - assert not captured_span.set_status.called, ( - f"Hook span should never set_status; called with " - f"final_action={final_action!r}, mode={mode!r}" - ) + + # The tracer is never asked to start a span for HOOK_END — the drop + # happens in ``emit`` before any span work. + tracer = sink._get_tracer() + tracer.start_as_current_span.assert_not_called() + assert not captured_span.set_attribute.called + assert not captured_span.set_status.called + assert sink.spans_created == 0 + + +# --------------------------------------------------------------------------- +# Rule span — OTEL-contract attributes (span type + source) +# --------------------------------------------------------------------------- + + +def test_rule_span_sets_governance_type_and_source(captured_span: MagicMock) -> None: + """Governance rule spans carry the governance span type and source. + + This is the OTEL-contract change the PR exists to make: spans must no + longer look like agent runs (``agentRun`` / CodedAgents). Guards + against a regression that recreates the span but mis-types/mis-sources + it. + """ + sink = TracesAuditSink() + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + + attrs = _span_attrs(captured_span) + assert attrs.get("type") == SPAN_TYPE_GOVERNANCE + assert attrs.get("span_type") == SPAN_TYPE_GOVERNANCE + assert attrs.get("uipath.source") == UIPATH_SOURCE # --------------------------------------------------------------------------- @@ -324,13 +356,13 @@ def test_emit_is_a_noop_when_tracer_unavailable( monkeypatch: pytest.MonkeyPatch, ) -> None: """When ``_get_tracer`` returns None, ``emit`` short-circuits inside - both ``_emit_rule_span`` and ``_emit_hook_span`` without touching - the tracer. Regression guard for the early-return branch. + ``_emit_rule_span`` without touching the tracer. Regression guard + for the early-return branch. """ sink = TracesAuditSink() monkeypatch.setattr(sink, "_get_tracer", lambda: None) - # Both event types — must not raise, must not create spans. + # Rule events must not raise, must not create spans; HOOK_END is dropped. sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) assert sink.spans_created == 0 @@ -353,6 +385,9 @@ def test_spans_created_increments_per_successful_emit( assert sink.spans_created == 0 sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) assert sink.spans_created == 1 + # A second rule increments; HOOK_END events are dropped and never do. + sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) + assert sink.spans_created == 2 sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) assert sink.spans_created == 2 @@ -466,7 +501,6 @@ def test_rule_span_exception_is_swallowed( # Must not raise. sink.emit(_rule_event(matched=True, action="deny", mode=EnforcementMode.ENFORCE)) - sink.emit(_hook_event(final_action="deny", mode=EnforcementMode.ENFORCE)) # And no span was successfully created. assert sink.spans_created == 0 diff --git a/uv.lock b/uv.lock index 685e071a..eccd2fe1 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.4" +version = "0.12.5" source = { editable = "." } dependencies = [ { name = "chardet" }, From 59508bb57abc16a8bcecb0b55fbf901ff17845cc Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 23 Jul 2026 15:35:31 +0300 Subject: [PATCH 29/34] feat: expose managed workspace during runtime execution --- pyproject.toml | 2 +- src/uipath/runtime/__init__.py | 2 + src/uipath/runtime/errors/codes.py | 1 + src/uipath/runtime/factory.py | 3 + src/uipath/runtime/workspace/__init__.py | 2 + src/uipath/runtime/workspace/context.py | 52 +++++++++++++++ src/uipath/runtime/workspace/hydration.py | 55 ++++++++-------- src/uipath/runtime/workspace/workspace.py | 3 +- tests/workspace/test_workspace_hydration.py | 72 +++++++++++++++++++++ uv.lock | 2 +- 10 files changed, 165 insertions(+), 29 deletions(-) create mode 100644 src/uipath/runtime/workspace/context.py diff --git a/pyproject.toml b/pyproject.toml index a7b90c5f..2158ad6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.5" +version = "0.12.6" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index afc27198..0dedd231 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -50,6 +50,7 @@ Workspace, WorkspaceHydrator, WorkspaceRegistryStore, + get_workspace_path, ) __all__ = [ @@ -84,6 +85,7 @@ "AttachmentRegistryEntry", "HydrationPolicy", "HydrationRuntime", + "get_workspace_path", "Workspace", "WorkspaceHydrator", "WorkspaceRegistryStore", diff --git a/src/uipath/runtime/errors/codes.py b/src/uipath/runtime/errors/codes.py index f198fdc1..460d0fa1 100644 --- a/src/uipath/runtime/errors/codes.py +++ b/src/uipath/runtime/errors/codes.py @@ -16,6 +16,7 @@ class UiPathErrorCode(str, Enum): MODULE_EXECUTION_ERROR = "MODULE_EXECUTION_ERROR" FUNCTION_EXECUTION_ERROR = "FUNCTION_EXECUTION_ERROR" EXECUTION_ERROR = "EXECUTION_ERROR" + MANAGED_WORKSPACE_UNAVAILABLE = "MANAGED_WORKSPACE_UNAVAILABLE" # Input validation errors INVALID_INPUT_FILE_EXTENSION = "INVALID_INPUT_FILE_EXTENSION" diff --git a/src/uipath/runtime/factory.py b/src/uipath/runtime/factory.py index 166d2abe..cdfbf128 100644 --- a/src/uipath/runtime/factory.py +++ b/src/uipath/runtime/factory.py @@ -23,6 +23,9 @@ class UiPathRuntimeFactorySettings(BaseModel): agent_framework: str | None = None + managed_workspace: bool = False + """Whether this runtime can use host-managed persistent workspace storage.""" + class UiPathRuntimeFactoryProtocol( UiPathDisposableProtocol, diff --git a/src/uipath/runtime/workspace/__init__.py b/src/uipath/runtime/workspace/__init__.py index c8dc23bc..cdf46206 100644 --- a/src/uipath/runtime/workspace/__init__.py +++ b/src/uipath/runtime/workspace/__init__.py @@ -1,5 +1,6 @@ """Workspace persistence primitives for runtime implementations.""" +from uipath.runtime.workspace.context import get_workspace_path from uipath.runtime.workspace.hydration import ( HydrationPolicy, HydrationRuntime, @@ -15,6 +16,7 @@ "AttachmentRegistryEntry", "HydrationPolicy", "HydrationRuntime", + "get_workspace_path", "Workspace", "WorkspaceHydrator", "WorkspaceRegistryStore", diff --git a/src/uipath/runtime/workspace/context.py b/src/uipath/runtime/workspace/context.py new file mode 100644 index 00000000..59abb014 --- /dev/null +++ b/src/uipath/runtime/workspace/context.py @@ -0,0 +1,52 @@ +"""Execution-scoped access to a managed workspace.""" + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from pathlib import Path + +from uipath.runtime.errors import ( + UiPathErrorCategory, + UiPathErrorCode, + UiPathRuntimeError, +) + +_workspace_path: ContextVar[Path | None] = ContextVar( + "uipath_workspace_path", default=None +) + + +def get_workspace_path() -> Path: + """Return the workspace available to the current runtime execution. + + A workspace is available only while a runtime managed by + :class:`HydrationRuntime` is executing. Files created beneath this path are + restored before a resumed execution and persisted when the runtime + suspends, according to its hydration policy. + + Raises: + UiPathRuntimeError: If called outside a managed runtime execution. + """ + workspace_path = _workspace_path.get() + if workspace_path is None: + raise UiPathRuntimeError( + code=UiPathErrorCode.MANAGED_WORKSPACE_UNAVAILABLE, + title="Managed Workspace Unavailable", + detail=( + "No managed workspace is available outside a runtime execution. " + "Call get_workspace_path() only from a graph node or tool." + ), + category=UiPathErrorCategory.USER, + include_traceback=False, + ) + return workspace_path + + +@contextmanager +def _workspace_execution(path: Path) -> Iterator[None]: + """Make ``path`` available for one managed runtime execution.""" + token = _workspace_path.set(path) + try: + yield + finally: + _workspace_path.reset(token) diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py index e8e979a6..ce2884db 100644 --- a/src/uipath/runtime/workspace/hydration.py +++ b/src/uipath/runtime/workspace/hydration.py @@ -11,6 +11,7 @@ from uipath.runtime.events import UiPathRuntimeEvent from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.context import _workspace_execution from uipath.runtime.workspace.hydrator import WorkspaceHydrator from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore from uipath.runtime.workspace.workspace import Workspace @@ -85,15 +86,16 @@ async def execute( options: UiPathExecuteOptions | None = None, ) -> UiPathRuntimeResult: """Hydrate, execute, then persist files according to policy.""" - await self._hydrate() - try: - result = await self.delegate.execute(input, options=options) - except Exception: - if self.policy == HydrationPolicy.ALWAYS: - await self._persist() - raise - await self._dehydrate(result) - return result + with _workspace_execution(self.workspace.path): + await self._hydrate() + try: + result = await self.delegate.execute(input, options=options) + except Exception: + if self.policy == HydrationPolicy.ALWAYS: + await self._persist() + raise + await self._dehydrate(result) + return result async def stream( self, @@ -101,23 +103,24 @@ async def stream( options: UiPathStreamOptions | None = None, ) -> AsyncGenerator[UiPathRuntimeEvent, None]: """Hydrate, stream delegate events, then persist files according to policy.""" - await self._hydrate() - final_result: UiPathRuntimeResult | None = None - - try: - async for event in self.delegate.stream(input, options=options): - if isinstance(event, UiPathRuntimeResult): - final_result = event - else: - yield event - except Exception: - if self.policy == HydrationPolicy.ALWAYS: - await self._persist() - raise - - if final_result is not None: - await self._dehydrate(final_result) - yield final_result + with _workspace_execution(self.workspace.path): + await self._hydrate() + final_result: UiPathRuntimeResult | None = None + + try: + async for event in self.delegate.stream(input, options=options): + if isinstance(event, UiPathRuntimeResult): + final_result = event + else: + yield event + except Exception: + if self.policy == HydrationPolicy.ALWAYS: + await self._persist() + raise + + if final_result is not None: + await self._dehydrate(final_result) + yield final_result async def get_schema(self) -> UiPathRuntimeSchema: """Passthrough schema from delegate runtime.""" diff --git a/src/uipath/runtime/workspace/workspace.py b/src/uipath/runtime/workspace/workspace.py index 6a1fb97f..6647b059 100644 --- a/src/uipath/runtime/workspace/workspace.py +++ b/src/uipath/runtime/workspace/workspace.py @@ -1,5 +1,6 @@ """Disk workspace lifecycle helpers.""" +import asyncio import shutil import tempfile from pathlib import Path @@ -44,4 +45,4 @@ def create( async def dispose(self) -> None: """Remove the workspace directory when configured to do so.""" if self.cleanup and self.path.exists(): - shutil.rmtree(self.path) + await asyncio.to_thread(shutil.rmtree, self.path) diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py index b37e1817..c4b0e9e0 100644 --- a/tests/workspace/test_workspace_hydration.py +++ b/tests/workspace/test_workspace_hydration.py @@ -17,7 +17,9 @@ Workspace, WorkspaceHydrator, WorkspaceRegistryStore, + get_workspace_path, ) +from uipath.runtime.errors import UiPathErrorCategory, UiPathRuntimeError from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent from uipath.runtime.schema import UiPathRuntimeSchema @@ -139,6 +141,25 @@ async def get_schema(self) -> UiPathRuntimeSchema: ) +class ContextAwareRuntime(WritingRuntime): + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + assert get_workspace_path() == self.workspace_path + return await super().execute(input, options) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + assert get_workspace_path() == self.workspace_path + async for event in super().stream(input, options): + yield event + + @pytest.mark.asyncio async def test_dehydrate_uploads_changed_files_and_saves_registry( tmp_path: Path, @@ -172,6 +193,57 @@ async def test_dehydrate_uploads_changed_files_and_saves_registry( assert len(jobs.links) == 1 +def test_get_workspace_path_requires_a_managed_execution() -> None: + with pytest.raises(UiPathRuntimeError, match="No managed workspace") as error: + get_workspace_path() + + assert error.value.error_info.code == "Python.MANAGED_WORKSPACE_UNAVAILABLE" + assert error.value.error_info.category == UiPathErrorCategory.USER + + +@pytest.mark.asyncio +async def test_workspace_path_is_available_only_during_execution( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + await runtime.execute({}) + + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + get_workspace_path() + + +@pytest.mark.asyncio +async def test_workspace_path_is_available_during_streaming( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + events = [event async for event in runtime.stream({})] + + assert isinstance(events[-1], UiPathRuntimeResult) + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + get_workspace_path() + + @pytest.mark.asyncio async def test_hydrator_factory_is_deferred_and_cached(tmp_path: Path) -> None: workspace = Workspace.create(tmp_path / "workspace") diff --git a/uv.lock b/uv.lock index eccd2fe1..276fad66 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.5" +version = "0.12.6" source = { editable = "." } dependencies = [ { name = "chardet" }, From 92d0795ec1de9bb275a681ea94960e1827e5c17f Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Thu, 23 Jul 2026 19:29:21 +0300 Subject: [PATCH 30/34] fix: harden managed workspace lifecycle --- CODEOWNERS | 2 +- pyproject.toml | 2 +- src/uipath/runtime/workspace/context.py | 55 +++- src/uipath/runtime/workspace/hydration.py | 57 ++-- src/uipath/runtime/workspace/workspace.py | 6 +- tests/workspace/test_workspace_hydration.py | 291 ++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 383 insertions(+), 32 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 03c07761..8410903c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @UiPath/team-coded-agents +* @UiPath/agents @cristipufu diff --git a/pyproject.toml b/pyproject.toml index 2158ad6e..a0eb682c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.12.6" +version = "0.12.7" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/workspace/context.py b/src/uipath/runtime/workspace/context.py index 59abb014..6e554095 100644 --- a/src/uipath/runtime/workspace/context.py +++ b/src/uipath/runtime/workspace/context.py @@ -3,6 +3,7 @@ from collections.abc import Iterator from contextlib import contextmanager from contextvars import ContextVar +from dataclasses import dataclass from pathlib import Path from uipath.runtime.errors import ( @@ -11,8 +12,17 @@ UiPathRuntimeError, ) -_workspace_path: ContextVar[Path | None] = ContextVar( - "uipath_workspace_path", default=None + +@dataclass +class _WorkspaceExecution: + """Revocable workspace state shared with tasks created during execution.""" + + path: Path + active: bool = True + + +_workspace_execution_state: ContextVar[_WorkspaceExecution | None] = ContextVar( + "uipath_workspace_execution", default=None ) @@ -27,26 +37,49 @@ def get_workspace_path() -> Path: Raises: UiPathRuntimeError: If called outside a managed runtime execution. """ - workspace_path = _workspace_path.get() - if workspace_path is None: + execution = _workspace_execution_state.get() + if execution is None or not execution.active: raise UiPathRuntimeError( code=UiPathErrorCode.MANAGED_WORKSPACE_UNAVAILABLE, title="Managed Workspace Unavailable", detail=( - "No managed workspace is available outside a runtime execution. " - "Call get_workspace_path() only from a graph node or tool." + "No managed workspace is available in the current code path. " + "Call get_workspace_path() only from code invoked by a managed runtime, " + "not from module-level initialization or from the caller that invokes " + "or consumes the execution." ), category=UiPathErrorCategory.USER, include_traceback=False, ) - return workspace_path + return execution.path @contextmanager -def _workspace_execution(path: Path) -> Iterator[None]: - """Make ``path`` available for one managed runtime execution.""" - token = _workspace_path.set(path) +def _bind_workspace_execution(execution: _WorkspaceExecution) -> Iterator[None]: + """Make an active execution available in the current context.""" + token = _workspace_execution_state.set(execution) try: yield finally: - _workspace_path.reset(token) + _workspace_execution_state.reset(token) + + +def _create_workspace_execution(path: Path) -> _WorkspaceExecution: + """Create state for a workspace execution.""" + return _WorkspaceExecution(path) + + +def _revoke_workspace_execution(execution: _WorkspaceExecution) -> None: + """Prevent all contexts that inherited ``execution`` from accessing it.""" + execution.active = False + + +@contextmanager +def _workspace_execution(path: Path) -> Iterator[None]: + """Make ``path`` available for one non-streaming execution.""" + execution = _create_workspace_execution(path) + try: + with _bind_workspace_execution(execution): + yield + finally: + _revoke_workspace_execution(execution) diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py index ce2884db..b3fa1298 100644 --- a/src/uipath/runtime/workspace/hydration.py +++ b/src/uipath/runtime/workspace/hydration.py @@ -11,7 +11,12 @@ from uipath.runtime.events import UiPathRuntimeEvent from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus from uipath.runtime.schema import UiPathRuntimeSchema -from uipath.runtime.workspace.context import _workspace_execution +from uipath.runtime.workspace.context import ( + _bind_workspace_execution, + _create_workspace_execution, + _revoke_workspace_execution, + _workspace_execution, +) from uipath.runtime.workspace.hydrator import WorkspaceHydrator from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore from uipath.runtime.workspace.workspace import Workspace @@ -103,24 +108,44 @@ async def stream( options: UiPathStreamOptions | None = None, ) -> AsyncGenerator[UiPathRuntimeEvent, None]: """Hydrate, stream delegate events, then persist files according to policy.""" - with _workspace_execution(self.workspace.path): - await self._hydrate() - final_result: UiPathRuntimeResult | None = None + execution = _create_workspace_execution(self.workspace.path) + delegate_stream: AsyncGenerator[UiPathRuntimeEvent, None] | None = None + final_result: UiPathRuntimeResult | None = None + hydrated = False - try: - async for event in self.delegate.stream(input, options=options): - if isinstance(event, UiPathRuntimeResult): - final_result = event - else: - yield event - except Exception: - if self.policy == HydrationPolicy.ALWAYS: + try: + with _bind_workspace_execution(execution): + await self._hydrate() + hydrated = True + delegate_stream = self.delegate.stream(input, options=options) + + event = await anext(delegate_stream, None) + while event is not None: + if isinstance(event, UiPathRuntimeResult): + final_result = event + else: + yield event + with _bind_workspace_execution(execution): + event = await anext(delegate_stream, None) + except Exception: + if self.policy == HydrationPolicy.ALWAYS and hydrated: + with _bind_workspace_execution(execution): await self._persist() - raise - + raise + else: if final_result is not None: - await self._dehydrate(final_result) - yield final_result + with _bind_workspace_execution(execution): + await self._dehydrate(final_result) + finally: + try: + if delegate_stream is not None: + with _bind_workspace_execution(execution): + await delegate_stream.aclose() + finally: + _revoke_workspace_execution(execution) + + if final_result is not None: + yield final_result async def get_schema(self) -> UiPathRuntimeSchema: """Passthrough schema from delegate runtime.""" diff --git a/src/uipath/runtime/workspace/workspace.py b/src/uipath/runtime/workspace/workspace.py index 6647b059..ffd12a3e 100644 --- a/src/uipath/runtime/workspace/workspace.py +++ b/src/uipath/runtime/workspace/workspace.py @@ -44,5 +44,7 @@ def create( async def dispose(self) -> None: """Remove the workspace directory when configured to do so.""" - if self.cleanup and self.path.exists(): - await asyncio.to_thread(shutil.rmtree, self.path) + if not self.cleanup: + return + + await asyncio.to_thread(shutil.rmtree, self.path) diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py index c4b0e9e0..82567e2b 100644 --- a/tests/workspace/test_workspace_hydration.py +++ b/tests/workspace/test_workspace_hydration.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import shutil import uuid from pathlib import Path @@ -72,6 +73,18 @@ async def download_async( return name +class FailingDownloadAttachments(FakeAttachments): + async def download_async( + self, + *, + key: uuid.UUID, + destination_path: str, + folder_key: str | None = None, + folder_path: str | None = None, + ) -> str: + raise RuntimeError("download failed") + + class FakeJobs: def __init__(self) -> None: self.attachments: dict[str, list[str]] = {} @@ -160,6 +173,68 @@ async def stream( yield event +class ChildTaskRuntime(WritingRuntime): + def __init__(self, workspace_path: Path, release: asyncio.Event) -> None: + super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL) + self.release = release + self.task: asyncio.Task[Path] | None = None + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + async def access_workspace_after_execution() -> Path: + await self.release.wait() + return get_workspace_path() + + self.task = asyncio.create_task(access_workspace_after_execution()) + return await super().execute(input, options) + + +class StreamingChildTaskRuntime(WritingRuntime): + def __init__(self, workspace_path: Path, release: asyncio.Event) -> None: + super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL) + self.release = release + self.task: asyncio.Task[Path] | None = None + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + async def access_workspace_during_stream() -> Path: + await self.release.wait() + return get_workspace_path() + + self.task = asyncio.create_task(access_workspace_during_stream()) + yield UiPathRuntimeStateEvent(payload={"started": True}) + await self.release.wait() + assert self.task is not None + assert await self.task == self.workspace_path + yield UiPathRuntimeResult(status=self.status, output={"ok": True}) + + +class FinalChildTaskRuntime(WritingRuntime): + def __init__(self, workspace_path: Path, release: asyncio.Event) -> None: + super().__init__(workspace_path, UiPathRuntimeStatus.SUCCESSFUL) + self.release = release + self.task: asyncio.Task[Path] | None = None + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + async def access_workspace_after_final_result() -> Path: + await self.release.wait() + return get_workspace_path() + + self.task = asyncio.create_task(access_workspace_after_final_result()) + yield UiPathRuntimeStateEvent(payload={"started": True}) + yield UiPathRuntimeResult(status=self.status, output={"ok": True}) + + @pytest.mark.asyncio async def test_dehydrate_uploads_changed_files_and_saves_registry( tmp_path: Path, @@ -198,6 +273,7 @@ def test_get_workspace_path_requires_a_managed_execution() -> None: get_workspace_path() assert error.value.error_info.code == "Python.MANAGED_WORKSPACE_UNAVAILABLE" + assert "not from module-level initialization" in str(error.value) assert error.value.error_info.category == UiPathErrorCategory.USER @@ -244,6 +320,221 @@ async def test_workspace_path_is_available_during_streaming( get_workspace_path() +@pytest.mark.asyncio +async def test_stream_does_not_expose_workspace_to_the_consumer( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + stream = runtime.stream({}) + assert isinstance(await anext(stream), UiPathRuntimeStateEvent) + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + get_workspace_path() + + await stream.aclose() + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + get_workspace_path() + + +@pytest.mark.asyncio +async def test_breaking_after_the_final_stream_result_resets_workspace_access( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + stream = runtime.stream({}) + async for event in stream: + if isinstance(event, UiPathRuntimeResult): + break + + await stream.aclose() + + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + get_workspace_path() + + +@pytest.mark.asyncio +async def test_stream_supports_task_wrapped_iterator_resumes( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + ContextAwareRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + stream = runtime.stream({}) + first = await asyncio.wait_for(anext(stream), timeout=1) + result = await asyncio.wait_for(anext(stream), timeout=1) + + assert isinstance(first, UiPathRuntimeStateEvent) + assert isinstance(result, UiPathRuntimeResult) + await stream.aclose() + + +@pytest.mark.asyncio +async def test_stream_child_tasks_keep_workspace_access_between_events( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + release = asyncio.Event() + delegate = StreamingChildTaskRuntime(workspace.path, release) + runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + stream = runtime.stream({}) + assert isinstance(await anext(stream), UiPathRuntimeStateEvent) + release.set() + assert isinstance(await anext(stream), UiPathRuntimeResult) + await stream.aclose() + + +@pytest.mark.asyncio +async def test_stream_does_not_retry_failed_final_persistence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + runtime = HydrationRuntime( + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + policy=HydrationPolicy.ALWAYS, + ) + persist_calls = 0 + + async def fail_persist() -> None: + nonlocal persist_calls + persist_calls += 1 + raise RuntimeError("persist failed") + + monkeypatch.setattr(runtime, "_persist", fail_persist) + + with pytest.raises(RuntimeError, match="persist failed"): + [event async for event in runtime.stream({})] + + assert persist_calls == 1 + + +@pytest.mark.asyncio +async def test_stream_hydration_failure_preserves_the_workspace_registry( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + storage = MemoryStorage() + store = WorkspaceRegistryStore(storage, "runtime-1") + registry = { + "notes.txt": { + "attachment_key": str(uuid.uuid4()), + "sha256": "0" * 64, + "size": 1, + "uploaded_at": "2026-01-01T00:00:00+00:00", + } + } + await store.save(registry) + runtime = HydrationRuntime( + WritingRuntime(workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FailingDownloadAttachments(), + ), + registry_store=store, + policy=HydrationPolicy.ALWAYS, + ) + + with pytest.raises(RuntimeError, match="download failed"): + [event async for event in runtime.stream({})] + + assert await store.load() == registry + + +@pytest.mark.asyncio +async def test_stream_revokes_workspace_before_yielding_final_result( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + release = asyncio.Event() + delegate = FinalChildTaskRuntime(workspace.path, release) + runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + stream = runtime.stream({}) + assert isinstance(await anext(stream), UiPathRuntimeStateEvent) + assert isinstance(await anext(stream), UiPathRuntimeResult) + release.set() + + assert delegate.task is not None + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + await delegate.task + + +@pytest.mark.asyncio +async def test_child_tasks_cannot_access_workspace_after_execution( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + release = asyncio.Event() + delegate = ChildTaskRuntime(workspace.path, release) + runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + registry_store=WorkspaceRegistryStore(MemoryStorage(), "runtime-1"), + ) + + await runtime.execute({}) + release.set() + + assert delegate.task is not None + with pytest.raises(UiPathRuntimeError, match="No managed workspace"): + await delegate.task + + @pytest.mark.asyncio async def test_hydrator_factory_is_deferred_and_cached(tmp_path: Path) -> None: workspace = Workspace.create(tmp_path / "workspace") diff --git a/uv.lock b/uv.lock index 276fad66..ec486156 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.12.6" +version = "0.12.7" source = { editable = "." } dependencies = [ { name = "chardet" }, From 4036755303e0094c8948feea2d560827a07bfc91 Mon Sep 17 00:00:00 2001 From: radu-mocanu Date: Wed, 15 Jul 2026 12:46:03 +0300 Subject: [PATCH 31/34] feat: persist conversational workspace files --- pyproject.toml | 4 +- src/uipath/runtime/__init__.py | 5 +- src/uipath/runtime/chat/__init__.py | 4 +- src/uipath/runtime/chat/protocol.py | 11 +- src/uipath/runtime/chat/runtime.py | 10 +- src/uipath/runtime/events/__init__.py | 2 + src/uipath/runtime/events/base.py | 1 + src/uipath/runtime/events/state.py | 15 +- src/uipath/runtime/workspace/__init__.py | 2 + .../runtime/workspace/conversational.py | 211 +++++++ src/uipath/runtime/workspace/hydration.py | 2 +- src/uipath/runtime/workspace/hydrator.py | 47 +- .../runtime/workspace/registry_store.py | 11 +- tests/test_chat.py | 53 +- .../test_conversational_workspace.py | 555 ++++++++++++++++++ tests/workspace/test_workspace_hydration.py | 153 ++++- uv.lock | 10 +- 17 files changed, 1071 insertions(+), 25 deletions(-) create mode 100644 src/uipath/runtime/workspace/conversational.py create mode 100644 tests/workspace/test_conversational_workspace.py diff --git a/pyproject.toml b/pyproject.toml index a0eb682c..8ade0630 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,11 @@ [project] name = "uipath-runtime" -version = "0.12.7" +version = "0.13.0" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" dependencies = [ - "uipath-core>=0.5.28, <0.6.0", + "uipath-core>=0.5.31, <0.6.0", "vaderSentiment>=3.3.2, <4.0", "chardet>=5.2.0, <8.0", ] diff --git a/src/uipath/runtime/__init__.py b/src/uipath/runtime/__init__.py index 0dedd231..8807ae09 100644 --- a/src/uipath/runtime/__init__.py +++ b/src/uipath/runtime/__init__.py @@ -15,7 +15,7 @@ UiPathStreamNotSupportedError, UiPathStreamOptions, ) -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.chat.runtime import UiPathChatRuntime from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.debug.breakpoint import UiPathBreakpointResult @@ -45,6 +45,7 @@ from uipath.runtime.storage import UiPathRuntimeStorageProtocol from uipath.runtime.workspace import ( AttachmentRegistryEntry, + ConversationalWorkspaceRuntime, HydrationPolicy, HydrationRuntime, Workspace, @@ -80,9 +81,11 @@ "UiPathBreakpointResult", "UiPathStreamNotSupportedError", "UiPathResumeTriggerName", + "UiPathChatMetaEventProtocol", "UiPathChatProtocol", "UiPathChatRuntime", "AttachmentRegistryEntry", + "ConversationalWorkspaceRuntime", "HydrationPolicy", "HydrationRuntime", "get_workspace_path", diff --git a/src/uipath/runtime/chat/__init__.py b/src/uipath/runtime/chat/__init__.py index 8261465b..429fbf26 100644 --- a/src/uipath/runtime/chat/__init__.py +++ b/src/uipath/runtime/chat/__init__.py @@ -1,6 +1,6 @@ """Chat bridge protocol and runtime for conversational agents.""" -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.chat.runtime import UiPathChatRuntime -__all__ = ["UiPathChatProtocol", "UiPathChatRuntime"] +__all__ = ["UiPathChatMetaEventProtocol", "UiPathChatProtocol", "UiPathChatRuntime"] diff --git a/src/uipath/runtime/chat/protocol.py b/src/uipath/runtime/chat/protocol.py index aa7930c5..343e03e1 100644 --- a/src/uipath/runtime/chat/protocol.py +++ b/src/uipath/runtime/chat/protocol.py @@ -1,6 +1,6 @@ """Abstract conversation bridge interface.""" -from typing import Any, Protocol +from typing import Any, Protocol, runtime_checkable from uipath.core.chat import ( UiPathConversationMessageEvent, @@ -70,3 +70,12 @@ async def emit_exchange_error_event(self, error: Exception) -> None: async def wait_for_resume(self) -> dict[str, Any]: """Wait for the interrupt_end event to be received.""" ... + + +@runtime_checkable +class UiPathChatMetaEventProtocol(Protocol): + """Optional chat-bridge capability for conversation metadata events.""" + + async def emit_meta_event(self, meta_event: dict[str, Any]) -> None: + """Send an exchange-scoped conversation metadata event.""" + ... diff --git a/src/uipath/runtime/chat/runtime.py b/src/uipath/runtime/chat/runtime.py index 040040be..9eb31a7f 100644 --- a/src/uipath/runtime/chat/runtime.py +++ b/src/uipath/runtime/chat/runtime.py @@ -12,10 +12,11 @@ UiPathRuntimeProtocol, UiPathStreamOptions, ) -from uipath.runtime.chat.protocol import UiPathChatProtocol +from uipath.runtime.chat.protocol import UiPathChatMetaEventProtocol, UiPathChatProtocol from uipath.runtime.errors import UiPathBaseRuntimeError from uipath.runtime.errors.contract import UiPathErrorCategory from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, UiPathRuntimeEvent, UiPathRuntimeMessageEvent, ) @@ -86,6 +87,13 @@ async def stream( if isinstance(event, UiPathRuntimeMessageEvent): if event.payload: await self.chat_bridge.emit_message_event(event.payload) + elif isinstance(event, UiPathRuntimeConversationMetaEvent): + if isinstance(self.chat_bridge, UiPathChatMetaEventProtocol): + await self.chat_bridge.emit_meta_event(event.payload) + else: + logger.warning( + "Chat bridge does not support conversation metadata events" + ) if isinstance(event, UiPathRuntimeResult): runtime_result = event diff --git a/src/uipath/runtime/events/__init__.py b/src/uipath/runtime/events/__init__.py index a7aace53..22d13f98 100644 --- a/src/uipath/runtime/events/__init__.py +++ b/src/uipath/runtime/events/__init__.py @@ -14,6 +14,7 @@ UiPathRuntimeStatePhase, ) from uipath.runtime.events.state import ( + UiPathRuntimeConversationMetaEvent, UiPathRuntimeMessageEvent, UiPathRuntimeStateEvent, ) @@ -26,4 +27,5 @@ # Runtime events "UiPathRuntimeStateEvent", "UiPathRuntimeMessageEvent", + "UiPathRuntimeConversationMetaEvent", ] diff --git a/src/uipath/runtime/events/base.py b/src/uipath/runtime/events/base.py index d305e1eb..08176ba2 100644 --- a/src/uipath/runtime/events/base.py +++ b/src/uipath/runtime/events/base.py @@ -10,6 +10,7 @@ class UiPathRuntimeEventType(str, Enum): """Types of events that can be emitted during execution.""" RUNTIME_MESSAGE = "runtime_message" + CONVERSATION_META = "conversation_meta" RUNTIME_STATE = "runtime_state" RUNTIME_ERROR = "runtime_error" RUNTIME_RESULT = "runtime_result" diff --git a/src/uipath/runtime/events/state.py b/src/uipath/runtime/events/state.py index 735fbb6e..33f2c4fd 100644 --- a/src/uipath/runtime/events/state.py +++ b/src/uipath/runtime/events/state.py @@ -40,6 +40,15 @@ class UiPathRuntimeMessageEvent(UiPathRuntimeEvent): ) +class UiPathRuntimeConversationMetaEvent(UiPathRuntimeEvent): + """Conversation metadata emitted by a runtime.""" + + payload: dict[str, Any] + event_type: UiPathRuntimeEventType = Field( + default=UiPathRuntimeEventType.CONVERSATION_META, frozen=True + ) + + class UiPathRuntimeStateEvent(UiPathRuntimeEvent): """Event emitted when agent state is updated. @@ -82,4 +91,8 @@ class UiPathRuntimeStateEvent(UiPathRuntimeEvent): ) -__all__ = ["UiPathRuntimeMessageEvent", "UiPathRuntimeStateEvent"] +__all__ = [ + "UiPathRuntimeConversationMetaEvent", + "UiPathRuntimeMessageEvent", + "UiPathRuntimeStateEvent", +] diff --git a/src/uipath/runtime/workspace/__init__.py b/src/uipath/runtime/workspace/__init__.py index cdf46206..03e8e97e 100644 --- a/src/uipath/runtime/workspace/__init__.py +++ b/src/uipath/runtime/workspace/__init__.py @@ -1,6 +1,7 @@ """Workspace persistence primitives for runtime implementations.""" from uipath.runtime.workspace.context import get_workspace_path +from uipath.runtime.workspace.conversational import ConversationalWorkspaceRuntime from uipath.runtime.workspace.hydration import ( HydrationPolicy, HydrationRuntime, @@ -14,6 +15,7 @@ __all__ = [ "AttachmentRegistryEntry", + "ConversationalWorkspaceRuntime", "HydrationPolicy", "HydrationRuntime", "get_workspace_path", diff --git a/src/uipath/runtime/workspace/conversational.py b/src/uipath/runtime/workspace/conversational.py new file mode 100644 index 00000000..635b95c2 --- /dev/null +++ b/src/uipath/runtime/workspace/conversational.py @@ -0,0 +1,211 @@ +"""Attachment-backed workspace persistence for conversational agents.""" + +import logging +from collections.abc import Mapping +from typing import Any, AsyncGenerator + +from uipath.runtime.base import ( + UiPathExecuteOptions, + UiPathRuntimeProtocol, + UiPathStreamOptions, +) +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, +) +from uipath.runtime.result import UiPathRuntimeResult, UiPathRuntimeStatus +from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.hydrator import WorkspaceHydrator +from uipath.runtime.workspace.registry_store import WorkspaceRegistryStore + +logger = logging.getLogger(__name__) + +CONVERSATION_META_EVENTS_INPUT_KEY = "uipath__conversation_meta_events" +WORKSPACE_FILES_META_KEY = "workspaceFiles" +WORKSPACE_FILE_PATH_KEY = "path" +WORKSPACE_FILE_ATTACHMENT_KEY = "attachmentKey" + + +class _InvalidWorkspaceSnapshot(ValueError): + pass + + +def _meta_event_payload( + event: Mapping[object, object], +) -> Mapping[object, object] | None: + exchange = event.get("exchange") + if isinstance(exchange, Mapping): + exchange_meta_event = exchange.get("metaEvent") + if isinstance(exchange_meta_event, Mapping): + return exchange_meta_event + + meta_event = event.get("metaEvent") + return meta_event if isinstance(meta_event, Mapping) else None + + +def _attachment_keys_from_meta_events( + input: Mapping[str, object] | None, +) -> dict[str, str] | None: + events = (input or {}).get(CONVERSATION_META_EVENTS_INPUT_KEY) + if not isinstance(events, list): + return None + + for event in reversed(events): + if not isinstance(event, Mapping): + continue + meta_event = _meta_event_payload(event) + if meta_event is None or WORKSPACE_FILES_META_KEY not in meta_event: + continue + + workspace_files = meta_event[WORKSPACE_FILES_META_KEY] + if not isinstance(workspace_files, list): + raise _InvalidWorkspaceSnapshot + + attachment_keys_by_path: dict[str, str] = {} + for workspace_file in workspace_files: + if not isinstance(workspace_file, Mapping): + raise _InvalidWorkspaceSnapshot + path = workspace_file.get(WORKSPACE_FILE_PATH_KEY) + attachment_key = workspace_file.get(WORKSPACE_FILE_ATTACHMENT_KEY) + if not isinstance(path, str) or not isinstance(attachment_key, str): + raise _InvalidWorkspaceSnapshot + attachment_keys_by_path[path] = attachment_key + return attachment_keys_by_path + + return None + + +def _without_conversation_meta_events( + input: dict[str, Any] | None, +) -> dict[str, Any] | None: + if input is None or CONVERSATION_META_EVENTS_INPUT_KEY not in input: + return input + return { + key: value + for key, value in input.items() + if key != CONVERSATION_META_EVENTS_INPUT_KEY + } + + +class ConversationalWorkspaceRuntime: + """Persists workspace attachments between conversational jobs.""" + + def __init__( + self, + delegate: UiPathRuntimeProtocol, + *, + hydrator: WorkspaceHydrator, + registry_store: WorkspaceRegistryStore | None = None, + ): + """Initialize the wrapper with its delegate and hydrator.""" + self.delegate = delegate + self.hydrator = hydrator + self.registry_store = registry_store + self._registry: dict[str, dict[str, Any]] = {} + self._hydrated = False + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + """Execute by draining the stream.""" + result: UiPathRuntimeResult | None = None + stream_options = ( + UiPathStreamOptions.model_validate(options.model_dump()) + if options is not None + else None + ) + async for event in self.stream(input, options=stream_options): + if isinstance(event, UiPathRuntimeResult): + result = event + if result is None: + raise RuntimeError("Delegate stream completed without a runtime result") + return result + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + """Hydrate, stream the delegate, then emit the workspace snapshot.""" + await self._hydrate(input) + delegate_input = _without_conversation_meta_events(input) + final_result: UiPathRuntimeResult | None = None + + async for event in self.delegate.stream(delegate_input, options=options): + if isinstance(event, UiPathRuntimeResult): + final_result = event + else: + yield event + + if final_result is None: + return + if final_result.status == UiPathRuntimeStatus.SUCCESSFUL: + yield await self._dehydrate() + yield final_result + + async def get_schema(self) -> UiPathRuntimeSchema: + """Passthrough schema from delegate runtime.""" + return await self.delegate.get_schema() + + async def dispose(self) -> None: + """Release resources owned by this wrapper.""" + + async def _hydrate(self, input: Mapping[str, object] | None) -> None: + if self._hydrated: + return + + persisted_registry = ( + await self.registry_store.try_load() if self.registry_store else None + ) + if persisted_registry is not None: + self._registry = persisted_registry + source = "suspended job state" + else: + try: + attachment_keys_by_path = _attachment_keys_from_meta_events(input) + except _InvalidWorkspaceSnapshot: + logger.warning("Ignoring malformed conversational workspace snapshot") + source = "existing workspace" + else: + if attachment_keys_by_path is None: + source = "existing workspace" + else: + source = "conversation metadata" + self._registry = await self.hydrator.hydrate_from_attachments( + attachment_keys_by_path + ) + + logger.info( + "Conversational workspace initialized: %d file(s) from %s", + len(self._registry), + source, + ) + self._hydrated = True + + async def _dehydrate(self) -> UiPathRuntimeConversationMetaEvent: + registry = self._registry + if self.registry_store is not None: + persisted_registry = await self.registry_store.try_load() + if persisted_registry is not None: + registry = persisted_registry + + self._registry = await self.hydrator.dehydrate(registry) + if self.registry_store is not None: + await self.registry_store.save(self._registry) + + workspace_files = [ + { + WORKSPACE_FILE_PATH_KEY: virtual_path, + WORKSPACE_FILE_ATTACHMENT_KEY: entry["attachment_key"], + } + for virtual_path, entry in sorted(self._registry.items()) + ] + logger.info( + "Conversational workspace dehydrate: emitting %d file(s)", + len(workspace_files), + ) + return UiPathRuntimeConversationMetaEvent( + payload={WORKSPACE_FILES_META_KEY: workspace_files} + ) diff --git a/src/uipath/runtime/workspace/hydration.py b/src/uipath/runtime/workspace/hydration.py index b3fa1298..d1c04809 100644 --- a/src/uipath/runtime/workspace/hydration.py +++ b/src/uipath/runtime/workspace/hydration.py @@ -160,7 +160,7 @@ async def dispose(self) -> None: async def _hydrate(self) -> None: registry = await self.registry_store.load() - hydrated = await self._get_hydrator().hydrate(registry) + hydrated = await self._get_hydrator().hydrate_from_registry(registry) if hydrated != registry: await self.registry_store.save(hydrated) diff --git a/src/uipath/runtime/workspace/hydrator.py b/src/uipath/runtime/workspace/hydrator.py index fba298ee..26111be8 100644 --- a/src/uipath/runtime/workspace/hydrator.py +++ b/src/uipath/runtime/workspace/hydrator.py @@ -50,7 +50,14 @@ async def hydrate( self, registry: dict[str, dict[str, Any]], ) -> dict[str, dict[str, Any]]: - """Download registry files into the workspace. + """Restore a registry using the original public API.""" + return await self.hydrate_from_registry(registry) + + async def hydrate_from_registry( + self, + registry: dict[str, dict[str, Any]], + ) -> dict[str, dict[str, Any]]: + """Restore registry attachments into the workspace. Files with matching SHA-256 are left untouched. """ @@ -68,6 +75,38 @@ async def hydrate( ) return self._dump_registry(normalized) + async def hydrate_from_attachments( + self, + attachment_keys_by_path: dict[str, str], + ) -> dict[str, dict[str, Any]]: + """Replace the workspace with an attachment snapshot.""" + attachments_by_path: dict[str, UUID] = {} + for virtual_path, raw_attachment_key in attachment_keys_by_path.items(): + normalized_path = self._virtual_path( + self._resolve_workspace_path(virtual_path) + ) + attachments_by_path[normalized_path] = UUID(raw_attachment_key) + + registry: dict[str, AttachmentRegistryEntry] = {} + for virtual_path, parsed_attachment_key in attachments_by_path.items(): + target = self._resolve_workspace_path(virtual_path) + target.parent.mkdir(parents=True, exist_ok=True) + await self.attachments.download_async( + key=parsed_attachment_key, + destination_path=str(target), + folder_key=self.folder_key, + folder_path=self.folder_path, + ) + registry[virtual_path] = AttachmentRegistryEntry( + attachment_key=str(parsed_attachment_key), + sha256=self._sha256(target), + size=target.stat().st_size, + uploaded_at=datetime.now(timezone.utc).isoformat(), + attachment_name=self._attachment_name_for_virtual_path(virtual_path), + ) + self._remove_files_not_in(set(registry)) + return self._dump_registry(registry) + async def dehydrate( self, registry: dict[str, dict[str, Any]], @@ -146,6 +185,11 @@ def _resolve_workspace_path(self, virtual_path: str) -> Path: def _virtual_path(self, path: Path) -> str: return path.relative_to(self.workspace_path).as_posix() + def _remove_files_not_in(self, virtual_paths: set[str]) -> None: + for path in self._iter_files(): + if self._virtual_path(path) not in virtual_paths: + path.unlink() + @staticmethod def _escape(value: str) -> str: # json-pointer escaping; escape ~ before / so they can't collide @@ -173,7 +217,6 @@ def _normalize_registry( ) -> dict[str, AttachmentRegistryEntry]: normalized: dict[str, AttachmentRegistryEntry] = {} for virtual_path, entry in registry.items(): - self._resolve_workspace_path(virtual_path) normalized[virtual_path] = AttachmentRegistryEntry.model_validate(entry) return normalized diff --git a/src/uipath/runtime/workspace/registry_store.py b/src/uipath/runtime/workspace/registry_store.py index d6e1eb49..a494dba2 100644 --- a/src/uipath/runtime/workspace/registry_store.py +++ b/src/uipath/runtime/workspace/registry_store.py @@ -25,11 +25,11 @@ def __init__( self.namespace = namespace self.key = key - async def load(self) -> dict[str, dict[str, Any]]: - """Load registry entries keyed by workspace-relative path.""" + async def try_load(self) -> dict[str, dict[str, Any]] | None: + """Load the registry, or return ``None`` when it has not been saved.""" value = await self.storage.get_value(self.runtime_id, self.namespace, self.key) if value is None: - return {} + return None if not isinstance(value, dict): raise TypeError("Workspace registry payload must be a dictionary.") @@ -39,6 +39,11 @@ async def load(self) -> dict[str, dict[str, Any]]: registry[path] = entry return registry + async def load(self) -> dict[str, dict[str, Any]]: + """Load registry entries, defaulting to an empty registry.""" + registry = await self.try_load() + return registry if registry is not None else {} + async def save(self, registry: dict[str, dict[str, Any]]) -> None: """Persist registry entries.""" await self.storage.set_value( diff --git a/tests/test_chat.py b/tests/test_chat.py index c3be06b3..f17bad53 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -23,23 +23,31 @@ UiPathStreamOptions, ) from uipath.runtime.chat import ( + UiPathChatMetaEventProtocol, UiPathChatProtocol, UiPathChatRuntime, ) from uipath.runtime.chat.runtime import _parse_confirmation -from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeMessageEvent +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, +) from uipath.runtime.schema import UiPathRuntimeSchema -def make_chat_bridge_mock() -> UiPathChatProtocol: +def make_chat_bridge_mock(*, supports_meta_events: bool = True) -> UiPathChatProtocol: """Create a chat bridge mock with all methods that UiPathChatRuntime uses.""" bridge_mock: Mock = Mock(spec=UiPathChatProtocol) bridge_mock.connect = AsyncMock() bridge_mock.disconnect = AsyncMock() bridge_mock.emit_message_event = AsyncMock() + if supports_meta_events: + bridge_mock.emit_meta_event = AsyncMock() bridge_mock.emit_interrupt_event = AsyncMock() bridge_mock.emit_executing_tool_call_event = AsyncMock() + bridge_mock.emit_exchange_end_event = AsyncMock() bridge_mock.wait_for_resume = AsyncMock() return cast(UiPathChatProtocol, bridge_mock) @@ -52,10 +60,12 @@ def __init__( self, messages: Sequence[str], *, + meta_events: Sequence[dict[str, Any]] = (), error_in_stream: bool = False, ) -> None: super().__init__() self.messages: list[str] = list(messages) + self.meta_events = list(meta_events) self.error_in_stream: bool = error_in_stream self.execute_called: bool = False @@ -93,6 +103,9 @@ async def stream( ) yield UiPathRuntimeMessageEvent(payload=message_event) + for meta_event in self.meta_events: + yield UiPathRuntimeConversationMetaEvent(payload=meta_event) + # Final result at the end of streaming yield UiPathRuntimeResult( status=UiPathRuntimeStatus.SUCCESSFUL, @@ -257,6 +270,42 @@ async def test_chat_runtime_stream_yields_all_events(): assert cast(AsyncMock, bridge.emit_message_event).await_count == 2 +@pytest.mark.asyncio +async def test_chat_runtime_emits_conversation_meta_events(): + runtime_impl = StreamingMockRuntime( + messages=[], + meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}], + ) + bridge = make_chat_bridge_mock() + chat_runtime = UiPathChatRuntime(runtime_impl, bridge) + + events = [event async for event in chat_runtime.stream({})] + + meta_event_bridge = cast(UiPathChatMetaEventProtocol, bridge) + cast(AsyncMock, meta_event_bridge.emit_meta_event).assert_awaited_once_with( + {"workspaceFiles": [{"path": "plan.md"}]} + ) + assert isinstance(events[0], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[1], UiPathRuntimeResult) + + +@pytest.mark.asyncio +async def test_chat_runtime_skips_meta_events_for_legacy_bridge(caplog): + runtime_impl = StreamingMockRuntime( + messages=[], + meta_events=[{"workspaceFiles": [{"path": "plan.md"}]}], + ) + bridge = make_chat_bridge_mock(supports_meta_events=False) + chat_runtime = UiPathChatRuntime(runtime_impl, bridge) + + with caplog.at_level("WARNING"): + events = [event async for event in chat_runtime.stream({})] + + assert "does not support conversation metadata events" in caplog.text + assert isinstance(events[0], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[1], UiPathRuntimeResult) + + @pytest.mark.asyncio async def test_chat_runtime_handles_errors(): """On unexpected errors, UiPathChatRuntime should propagate them.""" diff --git a/tests/workspace/test_conversational_workspace.py b/tests/workspace/test_conversational_workspace.py new file mode 100644 index 00000000..46cafd1f --- /dev/null +++ b/tests/workspace/test_conversational_workspace.py @@ -0,0 +1,555 @@ +from __future__ import annotations + +import uuid +from pathlib import Path +from typing import Any, AsyncGenerator +from unittest.mock import AsyncMock + +import pytest +from uipath.core.chat import UiPathConversationMessageEvent + +from tests.workspace.test_workspace_hydration import FakeAttachments, MemoryStorage +from uipath.runtime import ( + ConversationalWorkspaceRuntime, + HydrationRuntime, + UiPathExecuteOptions, + UiPathRuntimeResult, + UiPathRuntimeStatus, + UiPathStreamOptions, + Workspace, + WorkspaceHydrator, + WorkspaceRegistryStore, +) +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, +) +from uipath.runtime.schema import UiPathRuntimeSchema +from uipath.runtime.workspace.conversational import ( + CONVERSATION_META_EVENTS_INPUT_KEY, + WORKSPACE_FILES_META_KEY, +) + + +class ScriptedRuntime: + def __init__( + self, + events: list[UiPathRuntimeEvent], + result: UiPathRuntimeResult, + workspace_path: Path | None = None, + files: dict[str, str] | None = None, + ) -> None: + self.events = events + self.result = result + self.workspace_path = workspace_path + self.files = files or {} + self.disposed = False + self.received_input: dict[str, Any] | None = None + self.received_options: UiPathStreamOptions | None = None + + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + raise NotImplementedError + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + self.received_input = input + self.received_options = options + if self.workspace_path is not None: + for virtual_path, content in self.files.items(): + target = self.workspace_path / virtual_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + for event in self.events: + yield event + yield self.result + + async def get_schema(self) -> UiPathRuntimeSchema: + raise NotImplementedError + + async def dispose(self) -> None: + self.disposed = True + + +def make_runtime( + tmp_path: Path, + delegate: ScriptedRuntime, + attachments: FakeAttachments, + registry_store: WorkspaceRegistryStore | None = None, +) -> tuple[ConversationalWorkspaceRuntime, Workspace]: + workspace = Workspace.create(tmp_path / "workspace") + return ( + ConversationalWorkspaceRuntime( + delegate, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ), + registry_store=registry_store, + ), + workspace, + ) + + +def workspace_meta_event( + files: list[tuple[str, uuid.UUID]], + *, + top_level: bool = False, +) -> dict[str, Any]: + meta_event = { + WORKSPACE_FILES_META_KEY: [ + {"path": path, "attachmentKey": str(attachment_key)} + for path, attachment_key in files + ] + } + event: dict[str, Any] = {"conversationId": "conversation-1"} + if top_level: + event["metaEvent"] = meta_event + else: + event["exchange"] = { + "exchangeId": "exchange-1", + "metaEvent": meta_event, + } + return event + + +def meta_events( + events: list[UiPathRuntimeEvent], +) -> list[UiPathRuntimeConversationMetaEvent]: + return [ + event + for event in events + if isinstance(event, UiPathRuntimeConversationMetaEvent) + ] + + +@pytest.mark.asyncio +async def test_success_emits_sorted_workspace_snapshot_before_result( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + delegate_message = UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent(message_id="message-1") + ) + delegate = ScriptedRuntime( + events=[delegate_message], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + workspace_path=tmp_path / "workspace", + files={"z.txt": "z", "nested/a.txt": "a"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + events = [event async for event in runtime.stream({"messages": []})] + + assert events[0] is delegate_message + assert isinstance(events[-2], UiPathRuntimeConversationMetaEvent) + assert isinstance(events[-1], UiPathRuntimeResult) + assert [file["path"] for file in events[-2].payload[WORKSPACE_FILES_META_KEY]] == [ + "nested/a.txt", + "z.txt", + ] + assert attachments.uploads == 2 + + +@pytest.mark.asyncio +async def test_empty_workspace_emits_authoritative_snapshot(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + + events = [event async for event in runtime.stream({"messages": []})] + + assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []} + + +@pytest.mark.asyncio +async def test_empty_snapshot_removes_stale_workspace_files(tmp_path: Path) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + stale_file = workspace.path / "stale.txt" + stale_file.write_text("stale", encoding="utf-8") + + events = [ + event + async for event in runtime.stream( + { + CONVERSATION_META_EVENTS_INPUT_KEY: [workspace_meta_event([])], + } + ) + ] + + assert not stale_file.exists() + assert meta_events(events)[0].payload == {WORKSPACE_FILES_META_KEY: []} + assert attachments.uploads == 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "workspace_files", + [ + "invalid", + [{"path": "restored.txt"}], + [ + {"path": "restored.txt", "attachmentKey": str(uuid.uuid4())}, + {"attachmentKey": str(uuid.uuid4())}, + ], + ], +) +async def test_malformed_snapshot_does_not_remove_workspace_files( + tmp_path: Path, + workspace_files: object, +) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + existing_file = workspace.path / "existing.txt" + existing_file.write_text("keep", encoding="utf-8") + + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + {"exchange": {"metaEvent": {WORKSPACE_FILES_META_KEY: workspace_files}}} + ] + } + async for _ in runtime.stream(input): + pass + + assert existing_file.read_text(encoding="utf-8") == "keep" + assert attachments.downloads == 0 + assert attachments.uploads == 0 + + +@pytest.mark.asyncio +async def test_reserved_meta_events_are_not_forwarded_to_delegate( + tmp_path: Path, +) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + input = { + "messages": [], + "custom": "value", + CONVERSATION_META_EVENTS_INPUT_KEY: [], + } + + async for _ in runtime.stream(input): + pass + + assert delegate.received_input == {"messages": [], "custom": "value"} + assert input[CONVERSATION_META_EVENTS_INPUT_KEY] == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("top_level", [False, True]) +async def test_hydrates_single_workspace_snapshot( + tmp_path: Path, + top_level: bool, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("latest.txt", b"latest") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event( + [("nested/latest.txt", attachment_key)], top_level=top_level + ) + ] + } + + async for _ in runtime.stream(input): + pass + + assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest" + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("top_level", [False, True]) +async def test_hydrates_latest_workspace_snapshot( + tmp_path: Path, + top_level: bool, +) -> None: + attachments = FakeAttachments() + old_key = uuid.uuid4() + latest_key = uuid.uuid4() + attachments.files[old_key] = ("old.txt", b"old") + attachments.files[latest_key] = ("latest.txt", b"latest") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + "messages": [], + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", old_key)]), + workspace_meta_event( + [("nested/latest.txt", latest_key)], top_level=top_level + ), + ], + } + + async for _ in runtime.stream(input): + pass + + assert not (workspace.path / "old.txt").exists() + assert (workspace.path / "nested/latest.txt").read_bytes() == b"latest" + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +async def test_latest_empty_snapshot_does_not_restore_older_files( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + old_key = uuid.uuid4() + attachments.files[old_key] = ("old.txt", b"old") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", old_key)]), + workspace_meta_event([]), + ] + } + + async for _ in runtime.stream(input): + pass + + assert not (workspace.path / "old.txt").exists() + assert attachments.downloads == 0 + + +@pytest.mark.asyncio +async def test_persisted_empty_registry_wins_over_conversation_metadata( + tmp_path: Path, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("old.txt", b"old") + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + await store.save({}) + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments, store) + + async for _ in runtime.stream( + { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("old.txt", attachment_key)]) + ] + } + ): + pass + + assert not (workspace.path / "old.txt").exists() + assert attachments.downloads == 0 + + +@pytest.mark.asyncio +async def test_hydration_runtime_owns_persisted_registry_hydration( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + await store.save({}) + workspace = Workspace.create(tmp_path / "workspace") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + hydrate_from_registry = AsyncMock(wraps=hydrator.hydrate_from_registry) + monkeypatch.setattr( + hydrator, + "hydrate_from_registry", + hydrate_from_registry, + ) + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + hydration_runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=hydrator, + registry_store=store, + ) + runtime = ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + registry_store=store, + ) + + async for _ in runtime.stream({}): + pass + + hydrate_from_registry.assert_awaited_once_with({}) + + +@pytest.mark.asyncio +async def test_faulted_result_does_not_emit_or_upload_snapshot(tmp_path: Path) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.FAULTED), + workspace_path=tmp_path / "workspace", + files={"notes.txt": "hello"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + events = [event async for event in runtime.stream({})] + + assert meta_events(events) == [] + assert attachments.uploads == 0 + assert isinstance(events[-1], UiPathRuntimeResult) + + +@pytest.mark.asyncio +async def test_execute_preserves_options(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, FakeAttachments()) + options = UiPathExecuteOptions(resume=True, breakpoints=["node-1"]) + + await runtime.execute({}, options=options) + + assert delegate.received_options is not None + assert delegate.received_options.resume is True + assert delegate.received_options.breakpoints == ["node-1"] + + +@pytest.mark.asyncio +async def test_hydration_only_runs_once_across_resume_passes(tmp_path: Path) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("todo.md", b"step 1") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("todo.md", attachment_key)]) + ] + } + + async for _ in runtime.stream(input): + pass + async for _ in runtime.stream({"interrupt-1": {"approved": True}}): + pass + + assert attachments.downloads == 1 + + +@pytest.mark.asyncio +async def test_failed_hydration_can_be_retried( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + attachment_key = uuid.uuid4() + attachments.files[attachment_key] = ("todo.md", b"step 1") + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + runtime, workspace = make_runtime(tmp_path, delegate, attachments) + input = { + CONVERSATION_META_EVENTS_INPUT_KEY: [ + workspace_meta_event([("todo.md", attachment_key)]) + ] + } + download = attachments.download_async + + async def fail_download(**_: Any) -> str: + raise RuntimeError("download failed") + + monkeypatch.setattr(attachments, "download_async", fail_download) + with pytest.raises(RuntimeError, match="download failed"): + async for _ in runtime.stream(input): + pass + + monkeypatch.setattr(attachments, "download_async", download) + async for _ in runtime.stream(input): + pass + + assert (workspace.path / "todo.md").read_bytes() == b"step 1" + + +@pytest.mark.asyncio +async def test_attachment_upload_failure_propagates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + attachments = FakeAttachments() + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + workspace_path=tmp_path / "workspace", + files={"plan.md": "plan"}, + ) + runtime, _ = make_runtime(tmp_path, delegate, attachments) + + async def fail_upload(**_: Any) -> uuid.UUID: + raise RuntimeError("upload failed") + + monkeypatch.setattr(attachments, "upload_async", fail_upload) + + with pytest.raises(RuntimeError, match="upload failed"): + async for _ in runtime.stream({}): + pass + + +@pytest.mark.asyncio +async def test_dispose_does_not_dispose_injected_dependencies(tmp_path: Path) -> None: + delegate = ScriptedRuntime( + events=[], + result=UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL), + ) + workspace = Workspace.create(tmp_path / "workspace", cleanup=True) + runtime = ConversationalWorkspaceRuntime( + delegate, + hydrator=WorkspaceHydrator( + workspace_path=workspace.path, + attachments=FakeAttachments(), + ), + ) + + await runtime.dispose() + + assert not delegate.disposed + assert workspace.path.exists() + + await delegate.dispose() + await workspace.dispose() diff --git a/tests/workspace/test_workspace_hydration.py b/tests/workspace/test_workspace_hydration.py index 82567e2b..74e52bbc 100644 --- a/tests/workspace/test_workspace_hydration.py +++ b/tests/workspace/test_workspace_hydration.py @@ -7,8 +7,13 @@ from typing import Any, AsyncGenerator import pytest +from uipath.core.chat import ( + UiPathConversationMessageEndEvent, + UiPathConversationMessageEvent, +) from uipath.runtime import ( + ConversationalWorkspaceRuntime, HydrationPolicy, HydrationRuntime, UiPathExecuteOptions, @@ -21,7 +26,12 @@ get_workspace_path, ) from uipath.runtime.errors import UiPathErrorCategory, UiPathRuntimeError -from uipath.runtime.events import UiPathRuntimeEvent, UiPathRuntimeStateEvent +from uipath.runtime.events import ( + UiPathRuntimeConversationMetaEvent, + UiPathRuntimeEvent, + UiPathRuntimeMessageEvent, + UiPathRuntimeStateEvent, +) from uipath.runtime.schema import UiPathRuntimeSchema @@ -110,6 +120,18 @@ async def link_attachment_async( self.links.append((job_key, attachment_key)) +@pytest.mark.asyncio +async def test_registry_store_distinguishes_missing_from_saved_empty() -> None: + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + + assert await store.try_load() is None + + await store.save({}) + + assert await store.try_load() == {} + assert await store.load() == {} + + class WritingRuntime: def __init__(self, workspace_path: Path, status: UiPathRuntimeStatus) -> None: self.workspace_path = workspace_path @@ -235,6 +257,35 @@ async def access_workspace_after_final_result() -> Path: yield UiPathRuntimeResult(status=self.status, output={"ok": True}) +class ReadingRuntime(WritingRuntime): + async def execute( + self, + input: dict[str, Any] | None = None, + options: UiPathExecuteOptions | None = None, + ) -> UiPathRuntimeResult: + assert (self.workspace_path / "notes.txt").read_text( + encoding="utf-8" + ) == "hello" + (self.workspace_path / "notes.txt").write_text( + "hello after resume", encoding="utf-8" + ) + return UiPathRuntimeResult(status=self.status, output={"ok": True}) + + async def stream( + self, + input: dict[str, Any] | None = None, + options: UiPathStreamOptions | None = None, + ) -> AsyncGenerator[UiPathRuntimeEvent, None]: + message_id = "assistant-1" + yield UiPathRuntimeMessageEvent( + payload=UiPathConversationMessageEvent( + message_id=message_id, + end=UiPathConversationMessageEndEvent(), + ) + ) + yield await self.execute(input, options) + + @pytest.mark.asyncio async def test_dehydrate_uploads_changed_files_and_saves_registry( tmp_path: Path, @@ -623,6 +674,37 @@ def test_hydration_runtime_requires_exactly_one_hydrator_source( ) +@pytest.mark.asyncio +async def test_conversational_resume_reuses_registry_persisted_on_suspend( + tmp_path: Path, +) -> None: + workspace = Workspace.create(tmp_path / "workspace") + attachments = FakeAttachments() + store = WorkspaceRegistryStore(MemoryStorage(), "runtime-1") + hydrator = WorkspaceHydrator( + workspace_path=workspace.path, + attachments=attachments, + ) + delegate = WritingRuntime(workspace.path, UiPathRuntimeStatus.SUSPENDED) + hydration_runtime = HydrationRuntime( + delegate, + workspace=workspace, + hydrator=hydrator, + registry_store=store, + ) + runtime = ConversationalWorkspaceRuntime( + hydration_runtime, + hydrator=hydrator, + registry_store=store, + ) + + await runtime.execute({}) + delegate.status = UiPathRuntimeStatus.SUCCESSFUL + await runtime.execute({}, options=UiPathExecuteOptions(resume=True)) + + assert attachments.uploads == 1 + + @pytest.mark.asyncio async def test_successful_completion_persists_when_policy_allows( tmp_path: Path, @@ -648,7 +730,9 @@ async def test_successful_completion_persists_when_policy_allows( @pytest.mark.asyncio -async def test_hydrate_downloads_missing_registry_files(tmp_path: Path) -> None: +async def test_hydrate_compatibility_api_downloads_registry_files( + tmp_path: Path, +) -> None: workspace = Workspace.create(tmp_path / "workspace") attachments = FakeAttachments() key = uuid.uuid4() @@ -698,7 +782,68 @@ async def test_stream_persists_on_suspend(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_hydrate_skips_unchanged_local_file(tmp_path: Path) -> None: +async def test_conversational_suspend_restores_in_new_workspace(tmp_path: Path) -> None: + attachments = FakeAttachments() + storage = MemoryStorage() + first_workspace = Workspace.create(tmp_path / "first", cleanup=False) + first_hydrator = WorkspaceHydrator( + workspace_path=first_workspace.path, + attachments=attachments, + ) + suspended_runtime = HydrationRuntime( + WritingRuntime(first_workspace.path, UiPathRuntimeStatus.SUSPENDED), + workspace=first_workspace, + hydrator=first_hydrator, + registry_store=WorkspaceRegistryStore(storage, "runtime-1"), + ) + conversation_runtime = ConversationalWorkspaceRuntime( + suspended_runtime, + hydrator=first_hydrator, + ) + + events = [event async for event in conversation_runtime.stream({"messages": []})] + + assert isinstance(events[-1], UiPathRuntimeResult) + assert attachments.uploads == 1 + + second_workspace = Workspace.create(tmp_path / "second", cleanup=False) + second_hydrator = WorkspaceHydrator( + workspace_path=second_workspace.path, + attachments=attachments, + ) + second_registry_store = WorkspaceRegistryStore(storage, "runtime-1") + resumed_runtime = HydrationRuntime( + ReadingRuntime(second_workspace.path, UiPathRuntimeStatus.SUCCESSFUL), + workspace=second_workspace, + hydrator=second_hydrator, + registry_store=second_registry_store, + ) + resumed_conversation_runtime = ConversationalWorkspaceRuntime( + resumed_runtime, + hydrator=second_hydrator, + registry_store=second_registry_store, + ) + + resumed_events = [ + event async for event in resumed_conversation_runtime.stream({"messages": []}) + ] + + assert isinstance(resumed_events[-1], UiPathRuntimeResult) + assert attachments.downloads == 1 + assert attachments.uploads == 2 + workspace_snapshots = [ + event.payload + for event in resumed_events + if isinstance(event, UiPathRuntimeConversationMetaEvent) + ] + assert len(workspace_snapshots) == 1 + assert workspace_snapshots[0]["workspaceFiles"][0]["path"] == "notes.txt" + + +@pytest.mark.asyncio +async def test_hydrate_from_registry_skips_unchanged_local_file( + tmp_path: Path, +) -> None: workspace = Workspace.create(tmp_path / "workspace") attachments = FakeAttachments() (workspace.path / "notes.txt").write_text("same", encoding="utf-8") @@ -717,7 +862,7 @@ async def test_hydrate_skips_unchanged_local_file(tmp_path: Path) -> None: } } - await hydrator.hydrate(registry) + await hydrator.hydrate_from_registry(registry) assert attachments.downloads == 0 diff --git a/uv.lock b/uv.lock index ec486156..43a3b2eb 100644 --- a/uv.lock +++ b/uv.lock @@ -1139,21 +1139,21 @@ wheels = [ [[package]] name = "uipath-core" -version = "0.5.28" +version = "0.5.31" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4c/f9/8d2f1d98cbebbcf059cf4561f38f34ad4cd58423e4f15cad22bd297a2563/uipath_core-0.5.28.tar.gz", hash = "sha256:942987f6b612c64f93d612ad7b242276ed75f129fdd8f25bc71c24ec8887e388", size = 130578, upload-time = "2026-06-30T14:04:48.841Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/518fcb7d466a39c675194c492e8accbffdc7fd6b280d89b21ed11700a7bd/uipath_core-0.5.31.tar.gz", hash = "sha256:6abb443582d9a8ab6a504791296d49f524fb430f66d8dfaf6d6d1849958700bb", size = 130979, upload-time = "2026-07-15T06:56:03.867Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1e/385bb166232a57ebe938cc57ad2717f350bc922bb5d2ce31af84306b7569/uipath_core-0.5.28-py3-none-any.whl", hash = "sha256:b952a46a21710073cbc16d6d5684e9aa645c107f57a636b778cfb94aa81a1e48", size = 54980, upload-time = "2026-06-30T14:04:47.374Z" }, + { url = "https://files.pythonhosted.org/packages/1a/37/47a4e12bc7bdcfab4bd4e8e1f2a5c083bd59fca42fd431026a3f14c642a3/uipath_core-0.5.31-py3-none-any.whl", hash = "sha256:3dff5ecb236bf46af683c34d79bb2cf458a942ec041e1cbf7b4825ae246ff00c", size = 55040, upload-time = "2026-07-15T06:56:02.446Z" }, ] [[package]] name = "uipath-runtime" -version = "0.12.7" +version = "0.13.0" source = { editable = "." } dependencies = [ { name = "chardet" }, @@ -1179,7 +1179,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "chardet", specifier = ">=5.2.0,<8.0" }, - { name = "uipath-core", specifier = ">=0.5.28,<0.6.0" }, + { name = "uipath-core", specifier = ">=0.5.31,<0.6.0" }, { name = "vadersentiment", specifier = ">=3.3.2,<4.0" }, ] From 5d263af6d6ca7d220ff1cae3727b1c3436a5afb6 Mon Sep 17 00:00:00 2001 From: tibrnui <159773373+tibrnui@users.noreply.github.com> Date: Thu, 6 Aug 2026 13:52:14 +0300 Subject: [PATCH 32/34] chore: change to centralized managed GitHub pool 8 workflow file(s) modified, 44 action(s) pinned Runners migrated: ubuntu-latest ubuntu-24.04 ubuntu-22.04 ubuntu-24.04-arm ubuntu-22.04-arm ubuntu-slim ubuntu-18.04 ubuntu-20.04 windows-latest --- .github/workflows/auto-label.yml | 4 +-- .github/workflows/cd.yml | 16 ++++++------ .github/workflows/commitlint.yml | 6 ++--- .github/workflows/lint.yml | 8 +++--- .github/workflows/publish-dev.yml | 8 +++--- .github/workflows/test-uipath-langchain.yml | 28 ++++++++++----------- .github/workflows/test-uipath.yml | 28 ++++++++++----------- .github/workflows/test.yml | 20 +++++++-------- 8 files changed, 59 insertions(+), 59 deletions(-) diff --git a/.github/workflows/auto-label.yml b/.github/workflows/auto-label.yml index a5f67bcc..4bfccae4 100644 --- a/.github/workflows/auto-label.yml +++ b/.github/workflows/auto-label.yml @@ -10,10 +10,10 @@ permissions: jobs: label: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest steps: - name: Label PR - uses: actions/labeler@v5 + uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 with: configuration-path: '.github/labeler.yml' sync-labels: true diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index c3b34bb2..d531023b 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -19,7 +19,7 @@ jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest needs: - lint @@ -32,15 +32,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: ".python-version" @@ -51,14 +51,14 @@ jobs: run: uv build - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-dists path: dist/ pypi-publish: name: Upload release to PyPI - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest environment: pypi needs: @@ -69,10 +69,10 @@ jobs: steps: - name: Retrieve release distributions - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: release-dists path: dist/ - name: Publish package distributions to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1 diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 8562e455..d88c4910 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -6,18 +6,18 @@ on: jobs: commitlint: name: Commit Lint - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v3 + uses: actions/setup-node@3235b876344d2a9aa001b8d1453c930bba69e610 # v3.9.1 with: node-version: 22 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d6813d86..ce8ecd63 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,21 +6,21 @@ on: jobs: lint: name: Lint - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: ".python-version" diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index ae0907d8..4d712df3 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -6,7 +6,7 @@ on: jobs: publish-dev: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read pull-requests: write @@ -16,15 +16,15 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: enable-cache: true - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version-file: ".python-version" diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index dc1145dc..b1897479 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -37,7 +37,7 @@ jobs: run: uv build - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' @@ -56,7 +56,7 @@ jobs: uv run pytest typecheck-uipath-langchain: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read @@ -66,15 +66,15 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' @@ -83,7 +83,7 @@ jobs: run: uv build - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' @@ -102,7 +102,7 @@ jobs: uv run mypy --config-file pyproject.toml . discover-testcases: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read needs: [test-uipath-langchain] @@ -110,7 +110,7 @@ jobs: testcases: ${{ steps.discover.outputs.testcases }} steps: - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' @@ -130,7 +130,7 @@ jobs: echo "testcases=$testcases_json" >> $GITHUB_OUTPUT run-uipath-langchain-integration-tests: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest needs: [discover-testcases] container: image: ghcr.io/astral-sh/uv:python3.12-bookworm @@ -150,7 +150,7 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' @@ -159,7 +159,7 @@ jobs: run: uv build - name: Checkout uipath-langchain-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-langchain-python' path: 'uipath-langchain-python' diff --git a/.github/workflows/test-uipath.yml b/.github/workflows/test-uipath.yml index 347504f0..86637742 100644 --- a/.github/workflows/test-uipath.yml +++ b/.github/workflows/test-uipath.yml @@ -20,15 +20,15 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -37,7 +37,7 @@ jobs: run: uv build - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-python' path: 'uipath-python' @@ -56,7 +56,7 @@ jobs: uv run pytest typecheck-uipath: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read @@ -66,15 +66,15 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' @@ -83,7 +83,7 @@ jobs: run: uv build - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-python' path: 'uipath-python' @@ -102,7 +102,7 @@ jobs: uv run mypy --config-file pyproject.toml . discover-testcases: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest permissions: contents: read needs: [test-uipath] @@ -110,7 +110,7 @@ jobs: testcases: ${{ steps.discover.outputs.testcases }} steps: - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-python' path: 'uipath-python' @@ -130,7 +130,7 @@ jobs: echo "testcases=$testcases_json" >> $GITHUB_OUTPUT run-uipath-integration-tests: - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest needs: [discover-testcases] container: image: ghcr.io/astral-sh/uv:python3.12-bookworm @@ -149,7 +149,7 @@ jobs: steps: - name: Checkout uipath-runtime-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: path: 'uipath-runtime-python' @@ -158,7 +158,7 @@ jobs: run: uv build - name: Checkout uipath-python - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: repository: 'UiPath/uipath-python' path: 'uipath-python' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a126aae..30570162 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,20 +13,20 @@ jobs: strategy: matrix: python-version: ["3.11", "3.12", "3.13"] - os: [ubuntu-latest, windows-latest] + os: [uipath-ubuntu-latest, uipath-windows-latest] permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: ${{ matrix.python-version }} @@ -43,7 +43,7 @@ jobs: - name: Upload coverage HTML report if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-html-report path: htmlcov/ @@ -51,7 +51,7 @@ jobs: - name: Upload coverage XML report if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-xml-report path: coverage.xml @@ -62,23 +62,23 @@ jobs: sonarcloud: name: SonarCloud needs: test - runs-on: ubuntu-latest + runs-on: uipath-ubuntu-latest if: always() && needs.test.result != 'failure' permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 - name: Download coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 continue-on-error: true with: name: coverage-xml-report - name: SonarCloud Scan - uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 + uses: SonarSource/sonarqube-scan-action@2f77a1ec69fb1d595b06f35ab27e97605bdef703 # v5 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} From ab78debb3ffd848e5d76a8183de38365325a1235 Mon Sep 17 00:00:00 2001 From: Tiberiu Baron Date: Mon, 10 Aug 2026 13:45:58 +0300 Subject: [PATCH 33/34] fix(ci): retarget matrix.os conditions and migrate the remaining matrices The matrix moved to uipath- runners but the step conditions still compared matrix.os against 'ubuntu-latest', so the coverage and SonarCloud steps never ran. test-uipath.yml and test-uipath-langchain.yml were still on stock runners. --- .github/workflows/test-uipath-langchain.yml | 2 +- .github/workflows/test-uipath.yml | 2 +- .github/workflows/test.yml | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-uipath-langchain.yml b/.github/workflows/test-uipath-langchain.yml index b1897479..057ca7e6 100644 --- a/.github/workflows/test-uipath-langchain.yml +++ b/.github/workflows/test-uipath-langchain.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: python-version: [ "3.11", "3.12", "3.13" ] - os: [ ubuntu-latest, windows-latest ] + os: [ uipath-ubuntu-latest, uipath-windows-latest ] permissions: contents: read diff --git a/.github/workflows/test-uipath.yml b/.github/workflows/test-uipath.yml index 86637742..1673e1bb 100644 --- a/.github/workflows/test-uipath.yml +++ b/.github/workflows/test-uipath.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: python-version: [ "3.11", "3.12", "3.13" ] - os: [ ubuntu-latest, windows-latest ] + os: [ uipath-ubuntu-latest, uipath-windows-latest ] permissions: contents: read diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 30570162..cb2d06e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,15 +34,15 @@ jobs: run: uv sync --all-extras - name: Run tests - if: "!(matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13')" + if: "!(matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13')" run: uv run pytest - name: Run tests with coverage - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' + if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13' run: uv run pytest --cov-report=xml --cov-report=html --tb=short - name: Upload coverage HTML report - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13' && always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-html-report @@ -50,7 +50,7 @@ jobs: retention-days: 30 - name: Upload coverage XML report - if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.13' && always() + if: matrix.os == 'uipath-ubuntu-latest' && matrix.python-version == '3.13' && always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-xml-report From 78808b7e0f9b73b468b6be7fc1fd1ff08beecf3d Mon Sep 17 00:00:00 2001 From: Eduard Dumitru Date: Wed, 12 Aug 2026 14:41:52 +0200 Subject: [PATCH 34/34] Merge pull request #159 from UiPath/feat/output-args-memory-optimization feat: optionally split output arguments into their own file [ROBO-5900] --- .github/scripts/force-runtime-override.py | 3 +- pyproject.toml | 2 +- src/uipath/runtime/context.py | 42 +++- tests/test_context.py | 273 ++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 318 insertions(+), 4 deletions(-) diff --git a/.github/scripts/force-runtime-override.py b/.github/scripts/force-runtime-override.py index 5abbacdc..49f88205 100644 --- a/.github/scripts/force-runtime-override.py +++ b/.github/scripts/force-runtime-override.py @@ -37,7 +37,8 @@ def _add_override(pyproject_path: Path, override: str) -> None: body, ) if override_match: - items = override_match.group("items").strip() + # A trailing comma must not survive the join below, or it yields `[..,, ..]` + items = override_match.group("items").strip().rstrip(",").strip() if quoted_override in items: return diff --git a/pyproject.toml b/pyproject.toml index 8ade0630..84af1782 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-runtime" -version = "0.13.0" +version = "0.13.1" description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath/runtime/context.py b/src/uipath/runtime/context.py index 05219b45..575db03a 100644 --- a/src/uipath/runtime/context.py +++ b/src/uipath/runtime/context.py @@ -23,6 +23,8 @@ logger = logging.getLogger(__name__) +OUTPUT_ARGUMENTS_SUFFIX = ".args" + _EXECUTION_SOURCE_BY_COMMAND: dict[str, str] = { "run": "runtime", "debug": "playground", @@ -87,6 +89,14 @@ class UiPathRuntimeContext(BaseModel): "If not specified, path is constructed from runtime_dir and result_file." ), ) + split_output_arguments: bool = Field( + False, + description=( + "Write the output arguments to their own file alongside the result file, " + "and carry an 'outputArgumentsFilePath' pointer in the result file " + "instead of the inline 'output' value." + ), + ) state_file: str = Field("state.db", description="Filename for the state database") state_file_path: str | None = Field( None, @@ -275,6 +285,21 @@ def __exit__(self, exc_type, exc_val, exc_tb): content = self.result.to_dict() + # Read, not popped: output_file needs the arguments even when the split + # does not run, and popping to re-insert would move "output" after + # "status" in the envelope + output_payload = content.get("output", {}) + + # Gated on job_id like the envelope write below: the pointer only has a + # reader when there is a job, so without one there is nothing to point at it + if self.split_output_arguments and self.job_id: + output_arguments_path = self.resolved_output_arguments_file_path + os.makedirs(os.path.dirname(output_arguments_path), exist_ok=True) + with open(output_arguments_path, "w") as f: + json.dump(output_payload, f, default=str) + content.pop("output", None) + content["outputArgumentsFilePath"] = output_arguments_path + # Always write output file at runtime, except for inner runtimes # Inner runtimes have execution_id if self.job_id: @@ -283,7 +308,6 @@ def __exit__(self, exc_type, exc_val, exc_tb): # Write the execution output to file if requested if self.output_file: - output_payload = content.get("output", {}) with open(self.output_file, "w") as f: json.dump(output_payload, f, default=str) @@ -343,6 +367,21 @@ def resolved_result_file_path(self) -> str: return os.path.join(self.runtime_dir, self.result_file) return os.path.join("__uipath", "output.json") + @cached_property + def resolved_output_arguments_file_path(self) -> str: + """Get the full path to the output arguments file. + + Derived from the result file, not configured: the host cannot put the two + in different places, and inserting the suffix before the extension keeps + them distinct whatever the result file is called. + """ + result_path = Path(os.path.abspath(self.resolved_result_file_path)) + return str( + result_path.with_name( + f"{result_path.stem}{OUTPUT_ARGUMENTS_SUFFIX}{result_path.suffix}" + ) + ) + @cached_property def resolved_state_file_path(self) -> str: """Get the full path to the state file.""" @@ -406,6 +445,7 @@ def from_config( mapping = { "dir": "runtime_dir", "outputFile": "result_file", # we need this to maintain back-compat with serverless runtime + "splitOutputArguments": "split_output_arguments", "stateFile": "state_file", "logsFile": "logs_file", } diff --git a/tests/test_context.py b/tests/test_context.py index 39bc4289..d04de55e 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,9 +1,11 @@ import json +import os from pathlib import Path from typing import Any import pytest from uipath.core.errors import ErrorCategory, UiPathFaultedTriggerError +from uipath.core.triggers import UiPathResumeTrigger from uipath.runtime.context import UiPathRuntimeContext from uipath.runtime.errors import ( @@ -427,3 +429,274 @@ def test_from_config_accepts_maestro_flow_voice_mode(tmp_path: Path) -> None: ctx = UiPathRuntimeContext.from_config(str(config_path)) assert ctx.voice_mode == "maestro_flow" + + +def test_from_config_maps_split_output_arguments(tmp_path: Path) -> None: + """runtime.splitOutputArguments should map onto the knob.""" + cfg = {"runtime": {"splitOutputArguments": True}} + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.split_output_arguments is True + + +def test_split_output_arguments_defaults_off_when_config_key_absent( + tmp_path: Path, +) -> None: + """The split stays off when the config omits the key.""" + cfg = {"runtime": {"outputFile": "my_output.json"}} + config_path = tmp_path / "uipath.json" + config_path.write_text(json.dumps(cfg)) + + ctx = UiPathRuntimeContext.from_config(config_path=str(config_path)) + + assert ctx.split_output_arguments is False + + +def test_output_arguments_file_is_a_sibling_of_the_result_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The arguments file lands next to the result file, never in the process CWD. + + The host names the directory once, through runtime_dir, and both files follow + it. The filename is not configurable, so the knob has exactly one encoding and + the two files cannot be pointed at different directories. + """ + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.chdir(cwd) + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-sibling", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + arguments_path = Path(ctx.resolved_output_arguments_file_path) + assert arguments_path.parent == Path(ctx.resolved_result_file_path).parent + assert arguments_path.parent == runtime_dir + assert arguments_path.name == "result.args.json" + assert arguments_path.is_absolute() + assert cwd not in arguments_path.parents + + +def test_output_arguments_file_cannot_collide_with_the_result_file( + tmp_path: Path, +) -> None: + """The suffix goes before the extension, so the two names can never converge. + + Naming the result file after the arguments file used to produce one path for + both: the envelope overwrote the arguments and then pointed at itself. + """ + ctx = UiPathRuntimeContext( + job_id="job-collide", + runtime_dir=str(tmp_path / "runtime"), + result_file="output.args.json", + split_output_arguments=True, + ) + + assert Path(ctx.resolved_output_arguments_file_path).name == "output.args.args.json" + assert ctx.resolved_output_arguments_file_path != os.path.abspath( + ctx.resolved_result_file_path + ) + + +def test_output_arguments_file_not_written_without_a_job(tmp_path: Path) -> None: + """No job means no envelope, so the pointer would have no reader and no file. + + A local `uipath run` has no UIPATH_JOB_KEY; writing the payload there would + leave a full copy on disk that nothing references. + """ + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert not Path(ctx.resolved_output_arguments_file_path).exists() + assert not Path(ctx.resolved_result_file_path).exists() + + +def test_result_file_keeps_output_inline_when_split_disabled( + tmp_path: Path, +) -> None: + """Without the knob, the result file is byte-identical to the legacy envelope.""" + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-inline", + runtime_dir=str(runtime_dir), + result_file="result.json", + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + result_path = Path(ctx.resolved_result_file_path) + # The envelope is written in text mode, so json's newline reaches disk as os.linesep + expected = json.dumps( + {"output": {"foo": "bar"}, "status": "successful"}, indent=2 + ).replace("\n", os.linesep) + assert result_path.read_bytes() == expected.encode() + + content = json.loads(result_path.read_bytes()) + assert "outputArgumentsFilePath" not in content + assert not Path(ctx.resolved_output_arguments_file_path).exists() + + +def test_output_arguments_written_to_separate_file(tmp_path: Path) -> None: + """With the knob, the arguments move out and the envelope carries the path.""" + runtime_dir = tmp_path / "nested" / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + arguments_path = Path(ctx.resolved_output_arguments_file_path) + # Parent directory is created on demand + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert "output" not in content + assert content["status"] == UiPathRuntimeStatus.SUCCESSFUL.value + assert content["outputArgumentsFilePath"] == str(arguments_path) + assert Path(content["outputArgumentsFilePath"]).is_absolute() + + +def test_output_file_receives_bare_arguments_when_split_enabled( + tmp_path: Path, +) -> None: + """--output-file keeps receiving the bare arguments when both are set.""" + runtime_dir = tmp_path / "runtime" + output_path = tmp_path / "output.json" + ctx = UiPathRuntimeContext( + job_id="job-both", + runtime_dir=str(runtime_dir), + result_file="result.json", + output_file=str(output_path), + split_output_arguments=True, + ) + + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert json.loads(output_path.read_text()) == {"foo": "bar"} + arguments_path = Path(ctx.resolved_output_arguments_file_path) + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + +def test_faulted_run_keeps_status_and_error_inline_when_split_enabled( + tmp_path: Path, +) -> None: + """status and error stay in the envelope when the arguments are split out.""" + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-faulted-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with pytest.raises(RuntimeError, match="Stream blew up"): + with ctx: + raise RuntimeError("Stream blew up") + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.FAULTED.value + assert content["error"]["code"] == "ERROR_RuntimeError" + assert "Stream blew up" in content["error"]["detail"] + assert "output" not in content + + # The pointer must never advertise a file that was not actually written + arguments_path = Path(content["outputArgumentsFilePath"]) + assert arguments_path.exists() + assert json.loads(arguments_path.read_text()) == {} + + +def test_resume_triggers_stay_inline_when_split_enabled(tmp_path: Path) -> None: + """resume and resumeTriggers must never be moved out of the envelope. + + They are what makes a suspended job resumable, so a split that swept them + into the arguments file would strand the job. + """ + runtime_dir = tmp_path / "runtime" + ctx = UiPathRuntimeContext( + job_id="job-suspended-split", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + trigger = UiPathResumeTrigger(item_key="k") + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUSPENDED, + output={"foo": "bar"}, + trigger=trigger, + triggers=[trigger], + ) + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.SUSPENDED.value + assert content["resume"]["itemKey"] == "k" + assert len(content["resumeTriggers"]) == 1 + assert content["resumeTriggers"][0]["itemKey"] == "k" + # Only the output moved out + assert "output" not in content + arguments_path = Path(content["outputArgumentsFilePath"]) + assert json.loads(arguments_path.read_text()) == {"foo": "bar"} + + +def test_failed_arguments_write_faults_the_run(tmp_path: Path) -> None: + """A failing arguments write faults the run, like every other write in __exit__. + + Falling back to the inline value would write the same bytes to the same volume, + so it cannot rescue the failure that actually matters, and it would hand the + consumer the payload the split exists to keep out of its heap. + """ + runtime_dir = tmp_path / "runtime" + runtime_dir.mkdir() + # A directory cannot be opened for writing, so the split write fails + (runtime_dir / "result.args.json").mkdir() + ctx = UiPathRuntimeContext( + job_id="job-failed-write", + runtime_dir=str(runtime_dir), + result_file="result.json", + split_output_arguments=True, + ) + + with pytest.raises(RuntimeError) as excinfo: + with ctx: + ctx.result = UiPathRuntimeResult( + status=UiPathRuntimeStatus.SUCCESSFUL, + output={"foo": "bar"}, + ) + + assert "RUNTIME_SHUTDOWN_ERROR" in str(excinfo.value) + + content = json.loads(Path(ctx.resolved_result_file_path).read_text()) + assert content["status"] == UiPathRuntimeStatus.FAULTED.value + assert content["error"]["code"] == "RUNTIME_SHUTDOWN_ERROR" diff --git a/uv.lock b/uv.lock index 43a3b2eb..1350d745 100644 --- a/uv.lock +++ b/uv.lock @@ -1153,7 +1153,7 @@ wheels = [ [[package]] name = "uipath-runtime" -version = "0.13.0" +version = "0.13.1" source = { editable = "." } dependencies = [ { name = "chardet" },