Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.19.5"
version = "0.19.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"
Expand All @@ -11,7 +11,7 @@ dependencies = [
"uipath-runtime>=0.13.5, <0.14.0",
"uipath-llm-client>=1.22.3, <1.23.0",
"langgraph>=1.2.11, <2.0.0",
"langchain-core>=1.6.1, <2.0.0",
"langchain-core>=1.7.5, <2.0.0",
"langgraph-checkpoint-sqlite>=3.1.1, <4.0.0",
"langchain>=1.3.18, <2.0.0",
"deepagents>=0.7.11, <0.8.0",
Expand Down Expand Up @@ -58,7 +58,7 @@ fireworks = [
"uipath-langchain-client[fireworks]>=1.22.3, <1.23.0",
]
code-interpreter = [
"langchain-quickjs>=0.3.5, <0.4.0",
"langchain-quickjs>=0.3.12, <0.4.0",
]
video = [
"deepagents[video]>=0.7.11, <0.8.0",
Expand Down
48 changes: 46 additions & 2 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@
ToolErrorMiddleware,
)
from langchain.agents.middleware.types import PrivateStateAttr
from langchain.agents.structured_output import ResponseFormat
from langchain.agents.structured_output import (
ResponseFormat,
StructuredOutputValidationError,
)
from langchain_core.exceptions import ModelRefusalError
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool
Expand Down Expand Up @@ -505,6 +509,41 @@ async def awrap_model_call(
return response


class _RefusalResponseMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Return a refused main-agent turn as a response instead of ``ModelRefusalError``.

Keeps refusals on the provider checks of ``_PayloadHandlerMiddleware``.
"""

def wrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
) -> ModelResponse[Any]:
try:
return handler(request)
except ModelRefusalError as error:
return _refused_response(error)

async def awrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
) -> ModelResponse[Any]:
try:
return await handler(request)
except ModelRefusalError as error:
return _refused_response(error)


def _refused_response(error: ModelRefusalError) -> ModelResponse[Any]:
if isinstance(error.__context__, StructuredOutputValidationError):
raise error.__context__
if not isinstance(error.raw_message, AIMessage):
raise error
return ModelResponse(result=[error.raw_message])


def _resolve_subagent_specs(
subagents: Sequence[SubAgent | CompiledSubAgent],
shared_tools: Sequence[BaseTool],
Expand Down Expand Up @@ -609,7 +648,12 @@ def create_advanced_agent(
backend=backend,
response_format=response_format,
memory=list(memory) or None,
middleware=[*main_budget, *middleware, *shared_middleware],
middleware=[
*main_budget,
*middleware,
*shared_middleware,
_RefusalResponseMiddleware(),
],
skills=list(skills) if skills else None,
)

Expand Down
20 changes: 0 additions & 20 deletions src/uipath_langchain/agent/advanced/code_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
uv add "uipath-langchain[code-interpreter]"
"""

import atexit
import functools
import logging
from collections.abc import Iterable, Sequence
Expand Down Expand Up @@ -232,7 +231,6 @@ def build_code_interpreter_middleware(
timeout=timeout,
)
_append_single_in_flight_note(middleware)
_close_at_exit(middleware)
return [middleware]


Expand Down Expand Up @@ -262,24 +260,6 @@ def warm_code_interpreter() -> None:
runtime.close()


def _close_at_exit(middleware: Any) -> None:
"""Close the REPL registry from ``atexit`` instead of leaving it to ``__del__``.

langchain-quickjs 0.3.7 closes its QuickJS contexts from ``__del__`` by posting
to the daemon worker thread and waiting on the result with no timeout. During
interpreter finalization that thread no longer runs, so the wait never returns
and the process cannot exit. ``atexit`` handlers run before finalization, while
the worker still serves requests.
"""
registry = getattr(middleware, "_registry", None)
if registry is None or not callable(getattr(registry, "close", None)):
logger.warning(
"Code interpreter: middleware exposes no REPL registry to close at exit"
)
return
atexit.register(registry.close)


def _append_single_in_flight_note(middleware: Any) -> None:
"""Tell the model the REPL takes one call at a time.

Expand Down
13 changes: 9 additions & 4 deletions src/uipath_langchain/agent/tools/base_uipath_structured_tool.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from inspect import signature
from collections.abc import Callable, Mapping
from inspect import Parameter, signature
from typing import Any

from langchain_core.callbacks import (
Expand All @@ -18,6 +19,10 @@
)


def _signature_parameters(func: Callable[..., Any]) -> Mapping[str, Parameter]:
return signature(func).parameters


class BaseUiPathStructuredTool(StructuredTool):
"""Base class for UiPath structured tools.

Expand Down Expand Up @@ -53,7 +58,7 @@ def _run(
The result of the tool execution
"""
if __obj_internal_self__.func:
if run_manager and signature(__obj_internal_self__.func).parameters.get(
if run_manager and _signature_parameters(__obj_internal_self__.func).get(
"callbacks"
):
kwargs["callbacks"] = run_manager.get_child()
Expand Down Expand Up @@ -82,9 +87,9 @@ async def _arun(
The result of the tool execution
"""
if __obj_internal_self__.coroutine:
if run_manager and signature(
if run_manager and _signature_parameters(
__obj_internal_self__.coroutine
).parameters.get("callbacks"):
).get("callbacks"):
kwargs["callbacks"] = run_manager.get_child()
if config_param := _get_runnable_config_param(
__obj_internal_self__.coroutine
Expand Down
18 changes: 0 additions & 18 deletions tests/agent/advanced/test_code_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,24 +276,6 @@ def test_factory_returns_one_middleware() -> None:
assert len(build_code_interpreter_middleware([_tool("read_invoice")])) == 1


def test_factory_closes_the_repl_registry_at_exit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The REPL registry is closed from ``atexit``, not left to ``__del__``.

Upstream closes QuickJS contexts from ``__del__`` by blocking on its daemon
worker thread. At interpreter finalization that thread is gone and the wait
never returns, so a process that built this middleware cannot exit. Asserted
on the ``atexit`` registration because the hang itself only shows at exit.
"""
registered: list[Any] = []
monkeypatch.setattr("atexit.register", registered.append)

middleware = build_code_interpreter_middleware([_tool("read_invoice")])[0]

assert registered == [cast(Any, middleware)._registry.close]


def test_factory_warms_the_interpreter_before_building_the_repl(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand Down
90 changes: 89 additions & 1 deletion tests/agent/advanced/test_payload_handler_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
ModelResponse,
ToolErrorMiddleware,
)
from langchain.agents.structured_output import ToolStrategy
from langchain.agents.structured_output import (
StructuredOutputValidationError,
ToolStrategy,
)
from langchain_anthropic import ChatAnthropic
from langchain_core.exceptions import ModelRefusalError
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.tools import BaseTool, tool
Expand All @@ -21,6 +26,7 @@

from uipath_langchain.agent.advanced.agent import (
_PayloadHandlerMiddleware,
_RefusalResponseMiddleware,
_resolve_subagent_specs,
create_advanced_agent,
)
Expand Down Expand Up @@ -267,6 +273,88 @@
await middleware.awrap_model_call(_request(_gemini()), handler)


def _refusal() -> AIMessage:
return AIMessage(
content=[{"type": "refusal", "refusal": "I can't help with that."}],
response_metadata={"stop_reason": "refusal"},
)


def _refusing_handler(request: ModelRequest[Any]) -> ModelResponse[Any]:
raise ModelRefusalError(raw_message=_refusal())


class _RefusingModel(GenericFakeChatModel):
def bind_tools(self, tools: Any, **kwargs: Any) -> "_RefusingModel":
return self


class TestRefusals:
def test_anthropic_refusal_raises_our_chat_model_error(self) -> None:
request = _request(ChatAnthropic(model="claude-sonnet-4-5", api_key="dummy"))

with pytest.raises(ChatModelError) as exc_info:

Check warning on line 296 in tests/agent/advanced/test_payload_handler_middleware.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AaG_7FOJ-MfkravR6sWT&open=AaG_7FOJ-MfkravR6sWT&pullRequest=1202
_PayloadHandlerMiddleware().wrap_model_call(
request,
lambda r: _RefusalResponseMiddleware().wrap_model_call(
r, _refusing_handler
),
)

assert "refused" in exc_info.value.error_info.title.lower()

async def test_async_refusal_is_returned_as_the_response(self) -> None:
async def handler(request: ModelRequest[Any]) -> ModelResponse[Any]:
return _refusing_handler(request)

response = await _RefusalResponseMiddleware().awrap_model_call(
_request(GenericFakeChatModel(messages=iter([]))), handler
)

assert response.result == [_refusal()]

def test_structured_output_error_is_raised_unchanged(self) -> None:
source = StructuredOutputValidationError("out", ValueError("bad"), _refusal())

def handler(request: ModelRequest[Any]) -> ModelResponse[Any]:
try:
raise source
except StructuredOutputValidationError:
raise ModelRefusalError(raw_message=_refusal()) from None

with pytest.raises(StructuredOutputValidationError) as exc_info:

Check warning on line 325 in tests/agent/advanced/test_payload_handler_middleware.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this exception test to have only one invocation possibly throwing an exception.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-langchain-python&issues=AaG_7FOJ-MfkravR6sWU&open=AaG_7FOJ-MfkravR6sWU&pullRequest=1202
_RefusalResponseMiddleware().wrap_model_call(
_request(GenericFakeChatModel(messages=iter([]))), handler
)

assert exc_info.value is source

async def test_main_agent_run_ends_on_the_refusal(self) -> None:
agent = create_advanced_agent(model=_RefusingModel(messages=iter([_refusal()])))

result = await agent.ainvoke({"messages": [HumanMessage(content="go")]})

assert result["messages"][-1].content_blocks[0]["type"] == "refusal"

def test_builder_keeps_upstream_refusals_on_subagents(self) -> None:
with patch(
"uipath_langchain.agent.advanced.agent._create_deep_agent",
return_value=MagicMock(),
) as mock_create:
create_advanced_agent(model=GenericFakeChatModel(messages=iter([])))

kwargs = mock_create.call_args.kwargs
refusal_middleware = [
m for m in kwargs["middleware"] if isinstance(m, _RefusalResponseMiddleware)
]

assert len(refusal_middleware) == 1
assert not any(
isinstance(m, _RefusalResponseMiddleware)
for m in kwargs["subagents"][0]["middleware"]
)


class TestMissedOutputToolRetry:
"""A structured-output turn with no tool call is retried once, then fails."""

Expand Down
4 changes: 2 additions & 2 deletions tests/agent/multimodal/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -867,7 +867,7 @@ async def test_unlabeled_file_keeps_its_extension_type_when_not_a_pdf(
async def test_text_document_reaches_anthropic_unchanged(self) -> None:
from langchain_anthropic.chat_models import _format_messages

_, formatted = _format_messages([await self._csv_message()])
_, formatted = _format_messages([await self._csv_message()], model=None)

assert formatted[0]["content"][1] == _CSV_DOCUMENT

Expand Down Expand Up @@ -955,7 +955,7 @@ async def test_tags_never_reach_the_anthropic_payload(self) -> None:
file, pdf_only_documents=True
)

_, formatted = _format_messages([HumanMessage(content=content)])
_, formatted = _format_messages([HumanMessage(content=content)], model=None)

payload = json.dumps(formatted)
assert "uipath_" not in payload
Expand Down
Loading
Loading