diff --git a/pyproject.toml b/pyproject.toml index f62355cd6..17619d74a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.7.5" +version = "0.7.6" description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.11" diff --git a/src/uipath_langchain/agent/tools/durable_interrupt/__init__.py b/src/uipath_langchain/agent/tools/durable_interrupt/__init__.py new file mode 100644 index 000000000..c814b0be3 --- /dev/null +++ b/src/uipath_langchain/agent/tools/durable_interrupt/__init__.py @@ -0,0 +1,10 @@ +"""Durable interrupt package for side-effect-safe interrupt/resume in LangGraph.""" + +from .decorator import _durable_state, durable_interrupt +from .skip_interrupt import SkipInterruptValue + +__all__ = [ + "durable_interrupt", + "SkipInterruptValue", + "_durable_state", +] diff --git a/src/uipath_langchain/agent/tools/durable_interrupt.py b/src/uipath_langchain/agent/tools/durable_interrupt/decorator.py similarity index 78% rename from src/uipath_langchain/agent/tools/durable_interrupt.py rename to src/uipath_langchain/agent/tools/durable_interrupt/decorator.py index 656336ca1..1d304f11f 100644 --- a/src/uipath_langchain/agent/tools/durable_interrupt.py +++ b/src/uipath_langchain/agent/tools/durable_interrupt/decorator.py @@ -39,6 +39,8 @@ async def start_job(): from langgraph.config import get_config from langgraph.types import interrupt +from .skip_interrupt import SkipInterruptValue + F = TypeVar("F", bound=Callable[..., Any]) # Tracks (scratchpad identity, call index) per node execution. @@ -77,6 +79,21 @@ def _is_resumed(scratchpad: Any, idx: int) -> bool: return scratchpad is not None and scratchpad.resume and idx < len(scratchpad.resume) +def _inject_resume(scratchpad: Any, value: Any) -> Any: + """Inject a value into the scratchpad resume list and return it via interrupt(None). + + This keeps LangGraph's interrupt_counter in sync (interrupt(None) increments it) + while avoiding a real suspend — interrupt(None) finds the injected value and + returns it immediately without raising GraphInterrupt. + """ + if scratchpad is not None: + if scratchpad.resume is None: + scratchpad.resume = [] + scratchpad.resume.append(value) + return interrupt(None) + return value + + def durable_interrupt(fn: F) -> F: """Decorator that executes a side-effecting function exactly once and interrupts. @@ -85,6 +102,10 @@ def durable_interrupt(fn: F) -> F: is skipped and ``interrupt(None)`` returns the resume value from the runtime. + If the body returns a ``SkipInterruptValue``, the resolved value is + injected into the scratchpad resume list and ``interrupt(None)`` returns + it immediately — no real suspend/resume cycle occurs. + Replaces the ``@task`` + ``interrupt()`` two-step pattern with a single decorator that enforces the pairing contract. Works correctly in both parent graphs and subgraphs. @@ -112,7 +133,10 @@ async def async_wrapper(*args: Any, **kwargs: Any) -> Any: scratchpad, idx = _next_durable_index() if _is_resumed(scratchpad, idx): return interrupt(None) - return interrupt(await fn(*args, **kwargs)) + result = await fn(*args, **kwargs) + if isinstance(result, SkipInterruptValue): + return _inject_resume(scratchpad, result.resume_value) + return interrupt(result) return async_wrapper # type: ignore[return-value] @@ -121,6 +145,9 @@ def sync_wrapper(*args: Any, **kwargs: Any) -> Any: scratchpad, idx = _next_durable_index() if _is_resumed(scratchpad, idx): return interrupt(None) - return interrupt(fn(*args, **kwargs)) + result = fn(*args, **kwargs) + if isinstance(result, SkipInterruptValue): + return _inject_resume(scratchpad, result.resume_value) + return interrupt(result) return sync_wrapper # type: ignore[return-value] diff --git a/src/uipath_langchain/agent/tools/durable_interrupt/skip_interrupt.py b/src/uipath_langchain/agent/tools/durable_interrupt/skip_interrupt.py new file mode 100644 index 000000000..11e4fc061 --- /dev/null +++ b/src/uipath_langchain/agent/tools/durable_interrupt/skip_interrupt.py @@ -0,0 +1,50 @@ +"""Skip-interrupt value types for @durable_interrupt. + +SkipInterruptValue — base class +================================ + +When a node has **multiple sequential @durable_interrupt calls**, the +decorator's internal counter must produce the same sequence of indices on +every execution (first run *and* all resume runs). A conditional interrupt +— one that only fires under certain conditions — breaks this assumption and +causes index drift on resume. + +``SkipInterruptValue`` solves this by letting a @durable_interrupt-decorated +function signal "the result is already available, skip the real interrupt" +while still keeping LangGraph's interrupt counter in sync. The decorator +injects the resolved value into ``scratchpad.resume`` and calls +``interrupt(None)``, which returns immediately without raising ``GraphInterrupt``. + +Usage example:: + + @durable_interrupt + async def create_index(): + index = await client.create_index_async(...) + if index.in_progress(): + return WaitIndex(index=index) # real interrupt + return ReadyIndex(index=index) # instant resume + + @durable_interrupt + async def start_processing(): + return StartProcessing(index_id=index.id) # real interrupt + + # Both @durable_interrupt calls always execute — the counter always + # increments by 2. When the index is ready, ReadyIndex (a + # SkipInterruptValue subclass) injects the result into the scratchpad + # so the graph continues without suspending. +""" + +from typing import Any + + +class SkipInterruptValue: + """Base class for values that skip the interrupt in @durable_interrupt. + + Subclasses must implement the ``resume_value`` property, returning the + value to inject into the scratchpad resume list. + """ + + @property + def resume_value(self) -> Any: + """The value to inject into the resume list and return to the caller.""" + raise NotImplementedError diff --git a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py index 93bd68608..6dcb55add 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/batch_transform_tool.py @@ -29,7 +29,10 @@ from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.durable_interrupt import durable_interrupt +from uipath_langchain.agent.tools.durable_interrupt import ( + SkipInterruptValue, + durable_interrupt, +) from uipath_langchain.agent.tools.internal_tools.schema_utils import ( BATCH_TRANSFORM_OUTPUT_SCHEMA, add_query_field_to_schema, @@ -42,6 +45,17 @@ from uipath_langchain.agent.tools.utils import sanitize_tool_name +class ReadyEphemeralIndex(SkipInterruptValue): + """An ephemeral index that is already ready (no wait needed).""" + + def __init__(self, index: ContextGroundingIndex): + self.index = index + + @property + def resume_value(self) -> Any: + return self.index.model_dump() + + def create_batch_transform_tool( resource: AgentInternalToolResourceConfig, llm: BaseChatModel ) -> StructuredTool: @@ -131,7 +145,7 @@ async def create_ephemeral_index(): ) if ephemeral_index.in_progress_ingestion(): return WaitEphemeralIndex(index=ephemeral_index) - return ephemeral_index + return ReadyEphemeralIndex(index=ephemeral_index) index_result = await create_ephemeral_index() if isinstance(index_result, dict): diff --git a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py index 785d610f4..9effde108 100644 --- a/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py +++ b/src/uipath_langchain/agent/tools/internal_tools/deeprag_tool.py @@ -25,7 +25,10 @@ from uipath_langchain.agent.exceptions import AgentStartupError, AgentStartupErrorCode from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model from uipath_langchain.agent.react.types import AgentGraphState -from uipath_langchain.agent.tools.durable_interrupt import durable_interrupt +from uipath_langchain.agent.tools.durable_interrupt import ( + SkipInterruptValue, + durable_interrupt, +) from uipath_langchain.agent.tools.internal_tools.schema_utils import ( add_query_field_to_schema, ) @@ -37,6 +40,17 @@ from uipath_langchain.agent.tools.utils import sanitize_tool_name +class ReadyEphemeralIndex(SkipInterruptValue): + """An ephemeral index that is already ready (no wait needed).""" + + def __init__(self, index: ContextGroundingIndex): + self.index = index + + @property + def resume_value(self) -> Any: + return self.index.model_dump() + + def create_deeprag_tool( resource: AgentInternalToolResourceConfig, llm: BaseChatModel ) -> StructuredTool: @@ -101,6 +115,7 @@ async def deeprag_tool_fn(**kwargs: Any) -> dict[str, Any]: example_calls=[], # Examples cannot be provided for internal tools ) async def invoke_deeprag(**_tool_kwargs: Any): + @durable_interrupt async def create_ephemeral_index(): uipath = UiPath() ephemeral_index = ( @@ -109,21 +124,9 @@ async def create_ephemeral_index(): attachments=[attachment_id], ) ) - - # TODO this will not resume on concurrent runs for the same attachment if ephemeral_index.in_progress_ingestion(): - - @durable_interrupt - async def wait_for_ephemeral_index(): - return WaitEphemeralIndex(index=ephemeral_index) - - index_result = await wait_for_ephemeral_index() - if isinstance(index_result, dict): - ephemeral_index = ContextGroundingIndex(**index_result) - else: - ephemeral_index = index_result - - return ephemeral_index + return WaitEphemeralIndex(index=ephemeral_index) + return ReadyEphemeralIndex(index=ephemeral_index) index_result = await create_ephemeral_index() if isinstance(index_result, dict): @@ -142,7 +145,9 @@ async def create_deeprag(): is_ephemeral_index=True, ) - return await create_deeprag() + result = await create_deeprag() + + return result return await invoke_deeprag(**kwargs) diff --git a/tests/agent/tools/internal_tools/test_batch_transform_tool.py b/tests/agent/tools/internal_tools/test_batch_transform_tool.py index 5833b17d4..5831913f0 100644 --- a/tests/agent/tools/internal_tools/test_batch_transform_tool.py +++ b/tests/agent/tools/internal_tools/test_batch_transform_tool.py @@ -150,7 +150,7 @@ def resource_config_dynamic(self, batch_transform_settings_dynamic_query): "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -180,9 +180,8 @@ async def test_create_batch_transform_tool_static_query_index_ready( return_value=mock_index ) - # durable_interrupt always calls interrupt(); first for index, second for transform + # Index is ready → ReadyEphemeralIndex skips interrupt(). Only create_batch_transform fires. mock_interrupt.side_effect = [ - mock_index, {"file_path": "/path/to/output.csv"}, ] @@ -226,8 +225,8 @@ async def test_create_batch_transform_tool_static_query_index_ready( assert call_kwargs["usage"] == "BatchRAG" assert mock_attachment.ID in call_kwargs["attachments"] - # Both durable_interrupts call interrupt() - assert mock_interrupt.call_count == 2 + # Only create_batch_transform calls interrupt(); index was instant-resumed + assert mock_interrupt.call_count == 1 # Verify attachment was uploaded mock_uipath.jobs.create_attachment_async.assert_called_once_with( @@ -243,7 +242,7 @@ async def test_create_batch_transform_tool_static_query_index_ready( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -324,7 +323,7 @@ async def test_create_batch_transform_tool_static_query_wait_for_ingestion( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -354,9 +353,8 @@ async def test_create_batch_transform_tool_dynamic_query( return_value=mock_index ) - # durable_interrupt always calls interrupt(); first for index, second for transform + # Index is ready → ReadyEphemeralIndex skips interrupt(). Only create_batch_transform fires. mock_interrupt.side_effect = [ - mock_index, {"output": "Transformation complete"}, ] @@ -397,7 +395,7 @@ async def test_create_batch_transform_tool_dynamic_query( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -427,9 +425,8 @@ async def test_create_batch_transform_tool_default_destination_path( return_value=mock_index ) - # durable_interrupt always calls interrupt(); first for index, second for transform + # Index is ready → ReadyEphemeralIndex skips interrupt(). Only create_batch_transform fires. mock_interrupt.side_effect = [ - mock_index, {"file_path": "output.csv"}, ] @@ -461,8 +458,8 @@ async def test_create_batch_transform_tool_default_destination_path( } } - # Both durable_interrupts call interrupt() - assert mock_interrupt.call_count == 2 + # Only create_batch_transform calls interrupt(); index was instant-resumed + assert mock_interrupt.call_count == 1 # Verify attachment was uploaded with default path mock_uipath.jobs.create_attachment_async.assert_called_once_with( @@ -478,7 +475,7 @@ async def test_create_batch_transform_tool_default_destination_path( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPathConfig" ) @patch("uipath_langchain.agent.tools.internal_tools.batch_transform_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.batch_transform_tool.mockable", lambda **kwargs: lambda f: f, @@ -508,9 +505,8 @@ async def test_create_batch_transform_tool_custom_destination_path( return_value=mock_index ) - # durable_interrupt always calls interrupt(); first for index, second for transform + # Index is ready → ReadyEphemeralIndex skips interrupt(). Only create_batch_transform fires. mock_interrupt.side_effect = [ - mock_index, {"file_path": "/custom/path/result.csv"}, ] diff --git a/tests/agent/tools/internal_tools/test_deeprag_tool.py b/tests/agent/tools/internal_tools/test_deeprag_tool.py index e18797f81..3934bb73e 100644 --- a/tests/agent/tools/internal_tools/test_deeprag_tool.py +++ b/tests/agent/tools/internal_tools/test_deeprag_tool.py @@ -122,7 +122,7 @@ def resource_config_dynamic(self, deeprag_settings_dynamic_query): "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, @@ -150,7 +150,8 @@ async def test_create_deeprag_tool_static_query_index_ready( return_value=mock_index ) - # Index is ready (200): only create_deeprag fires interrupt(), not wait_for_ephemeral_index + # Index is ready → ReadyEphemeralIndex skips interrupt() (no scratchpad in tests). + # Only create_deeprag calls interrupt(). mock_interrupt.side_effect = [ {"text": "Deep RAG analysis result"}, ] @@ -184,14 +185,14 @@ async def test_create_deeprag_tool_static_query_index_ready( assert call_kwargs["usage"] == "DeepRAG" assert mock_attachment.ID in call_kwargs["attachments"] - # Only create_deeprag calls interrupt() — wait_for_ephemeral_index is skipped + # Only create_deeprag calls interrupt(); index was instant-resumed assert mock_interrupt.call_count == 1 @patch( "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, @@ -256,7 +257,7 @@ async def test_create_deeprag_tool_static_query_wait_for_ingestion( "uipath_langchain.agent.wrappers.job_attachment_wrapper.get_job_attachment_wrapper" ) @patch("uipath_langchain.agent.tools.internal_tools.deeprag_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch( "uipath_langchain.agent.tools.internal_tools.deeprag_tool.mockable", lambda **kwargs: lambda f: f, @@ -284,7 +285,7 @@ async def test_create_deeprag_tool_dynamic_query( return_value=mock_index ) - # Index is ready (200): only create_deeprag fires interrupt(), not wait_for_ephemeral_index + # Index is ready → ReadyEphemeralIndex skips interrupt(). Only create_deeprag fires. mock_interrupt.side_effect = [ {"content": "Dynamic query result"}, ] diff --git a/tests/agent/tools/test_context_tool.py b/tests/agent/tools/test_context_tool.py index 34ca43cdd..a88c9c8b4 100644 --- a/tests/agent/tools/test_context_tool.py +++ b/tests/agent/tools/test_context_tool.py @@ -178,7 +178,7 @@ async def test_tool_with_different_citation_modes(self, base_resource_config): tool = handle_deep_rag("test_tool", resource) with patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} assert tool.coroutine is not None @@ -200,7 +200,7 @@ async def test_unique_task_names_on_multiple_invocations( task_names = [] with patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} @@ -261,7 +261,7 @@ async def test_dynamic_query_uses_provided_query(self, base_resource_config): tool = handle_deep_rag("test_tool", resource) with patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt: mock_interrupt.return_value = {"mocked": "response"} assert tool.coroutine is not None @@ -629,7 +629,7 @@ async def test_static_query_batch_transform_uses_predefined_query( mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-1") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -677,7 +677,7 @@ async def test_dynamic_query_batch_transform_uses_provided_query(self): mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-2") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -705,7 +705,7 @@ async def test_static_query_batch_transform_uses_default_destination_path( mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-3") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", @@ -754,7 +754,7 @@ async def test_dynamic_query_batch_transform_uses_default_destination_path(self) mock_uipath.jobs.create_attachment_async = AsyncMock(return_value="att-id-4") with ( patch( - "uipath_langchain.agent.tools.durable_interrupt.interrupt" + "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" ) as mock_interrupt, patch( "uipath_langchain.agent.tools.context_tool.UiPath", diff --git a/tests/agent/tools/test_durable_interrupt.py b/tests/agent/tools/test_durable_interrupt.py index 1aee6cf76..200c419d9 100644 --- a/tests/agent/tools/test_durable_interrupt.py +++ b/tests/agent/tools/test_durable_interrupt.py @@ -27,8 +27,8 @@ def _make_config(scratchpad: FakeScratchpad | None = None) -> dict[str, Any]: return {"configurable": {CONFIG_KEY_SCRATCHPAD: scratchpad}} -PATCH_GET_CONFIG = "uipath_langchain.agent.tools.durable_interrupt.get_config" -PATCH_INTERRUPT = "uipath_langchain.agent.tools.durable_interrupt.interrupt" +PATCH_GET_CONFIG = "uipath_langchain.agent.tools.durable_interrupt.decorator.get_config" +PATCH_INTERRUPT = "uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt" @pytest.fixture(autouse=True) diff --git a/tests/agent/tools/test_escalation_tool.py b/tests/agent/tools/test_escalation_tool.py index ad2ba8745..aacb24ac2 100644 --- a/tests/agent/tools/test_escalation_tool.py +++ b/tests/agent/tools/test_escalation_tool.py @@ -285,7 +285,7 @@ async def test_escalation_tool_metadata_has_channel_type(self, escalation_resour @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_has_recipient( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -313,7 +313,7 @@ async def test_escalation_tool_metadata_has_recipient( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_metadata_recipient_none_when_no_recipients( self, mock_interrupt, mock_uipath_class, escalation_resource_no_recipient ): @@ -337,7 +337,7 @@ async def test_escalation_tool_metadata_recipient_none_when_no_recipients( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_string_task_title( self, mock_interrupt, mock_uipath_class ): @@ -386,7 +386,7 @@ async def test_escalation_tool_with_string_task_title( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_text_builder_task_title( self, mock_interrupt, mock_uipath_class ): @@ -443,7 +443,7 @@ async def test_escalation_tool_with_text_builder_task_title( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_empty_task_title_defaults_to_escalation_task( self, mock_interrupt, mock_uipath_class ): @@ -540,7 +540,7 @@ async def test_escalation_tool_output_schema_has_action_field( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_result_validation( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -567,7 +567,7 @@ async def test_escalation_tool_result_validation( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_extracts_action_from_result( self, mock_interrupt, mock_uipath_class, escalation_resource ): @@ -590,7 +590,7 @@ async def test_escalation_tool_extracts_action_from_result( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_escalation_tool_with_outcome_mapping_end( self, mock_interrupt, mock_uipath_class ): @@ -770,7 +770,7 @@ def escalation_resource(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_creates_task_then_interrupts_with_wait_escalation( self, mock_interrupt, mock_uipath_class, escalation_resource ): diff --git a/tests/agent/tools/test_ixp_escalation_tool.py b/tests/agent/tools/test_ixp_escalation_tool.py index f7bc6b328..5e7dab19a 100644 --- a/tests/agent/tools/test_ixp_escalation_tool.py +++ b/tests/agent/tools/test_ixp_escalation_tool.py @@ -187,7 +187,7 @@ def mock_state_without_extraction(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_wrapper_retrieves_extraction_from_state( self, mock_interrupt, @@ -287,7 +287,7 @@ async def test_wrapper_looks_for_correct_ixp_tool_id( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_wrapper_raises_on_document_rejection( self, mock_interrupt, @@ -368,7 +368,7 @@ def mock_extraction_response(self): @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_tool_calls_interrupt_with_correct_params( self, mock_interrupt, @@ -409,7 +409,7 @@ async def test_tool_calls_interrupt_with_correct_params( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_action_title_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response ): @@ -462,7 +462,7 @@ async def test_tool_uses_default_action_title_when_not_provided( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_tool_uses_default_priority_when_not_provided( self, mock_interrupt, mock_uipath_cls, mock_extraction_response ): @@ -515,7 +515,7 @@ async def test_tool_uses_default_priority_when_not_provided( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_tool_returns_data_projection_as_dict( self, mock_interrupt, @@ -543,7 +543,7 @@ async def test_tool_returns_data_projection_as_dict( @pytest.mark.asyncio @patch("uipath_langchain.agent.tools.ixp_escalation_tool.UiPath") - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") async def test_tool_stores_validation_response_in_metadata( self, mock_interrupt, diff --git a/tests/agent/tools/test_process_tool.py b/tests/agent/tools/test_process_tool.py index 173ba1e67..63c2683bf 100644 --- a/tests/agent/tools/test_process_tool.py +++ b/tests/agent/tools/test_process_tool.py @@ -114,7 +114,7 @@ class TestProcessToolInvocation: """Test process tool invocation behavior: invoke then interrupt.""" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_calls_processes_invoke_async( self, mock_uipath_class, mock_interrupt, process_resource @@ -141,7 +141,7 @@ async def test_invoke_calls_processes_invoke_async( ) @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_interrupts_with_wait_job( self, mock_uipath_class, mock_interrupt, process_resource @@ -166,7 +166,7 @@ async def test_invoke_interrupts_with_wait_job( assert wait_job_arg.process_folder_key == "folder-key-456" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_passes_input_arguments( self, mock_uipath_class, mock_interrupt, process_resource_with_inputs @@ -190,7 +190,7 @@ async def test_invoke_passes_input_arguments( assert call_kwargs["folder_path"] == "/Shared/DataFolder" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_invoke_returns_interrupt_value( self, mock_uipath_class, mock_interrupt, process_resource @@ -215,7 +215,7 @@ class TestProcessToolSpanContext: """Test that _span_context is properly wired for tracing.""" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_parent_span_id_passed_to_invoke( self, mock_uipath_class, mock_interrupt, process_resource @@ -242,7 +242,7 @@ async def test_span_context_parent_span_id_passed_to_invoke( assert call_kwargs["parent_span_id"] == "span-abc-123" @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_consumed_after_invoke( self, mock_uipath_class, mock_interrupt, process_resource @@ -267,7 +267,7 @@ async def test_span_context_consumed_after_invoke( assert "parent_span_id" not in tool.metadata["_span_context"] @pytest.mark.asyncio - @patch("uipath_langchain.agent.tools.durable_interrupt.interrupt") + @patch("uipath_langchain.agent.tools.durable_interrupt.decorator.interrupt") @patch("uipath_langchain.agent.tools.process_tool.UiPath") async def test_span_context_defaults_to_none_when_empty( self, mock_uipath_class, mock_interrupt, process_resource diff --git a/uv.lock b/uv.lock index 05c3a065a..1198ca08f 100644 --- a/uv.lock +++ b/uv.lock @@ -3324,7 +3324,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.7.5" +version = "0.7.6" source = { editable = "." } dependencies = [ { name = "httpx" },