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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.19.9"
version = "0.19.10"
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 Down
22 changes: 19 additions & 3 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
JobAttachmentsMiddleware,
dump_attachments,
)
from .tool_choice import ToolChoiceMiddleware
from .types import (
AdvancedAgentGraphState,
ConversationalAdvancedAgentGraphState,
Expand Down Expand Up @@ -442,7 +443,8 @@ def _missing_output_tool_error() -> AgentRuntimeError:
detail=(
"The model ended its turn without calling the output tool, even "
"after being asked to. If you are using a BYOM configuration, "
"verify your model deployment honors a forced tool choice."
"verify your model deployment returns tool calls for the tools "
"it is given."
),
category=UiPathErrorCategory.SYSTEM,
)
Expand Down Expand Up @@ -568,6 +570,7 @@ def create_advanced_agent(
skills: Sequence[str] | None = None,
max_iterations: int | None = None,
initial_message_count_key: str | None = None,
unforced_tool_choice: bool = False,
) -> CompiledStateGraph[Any, Any, Any, Any]:
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.

Expand All @@ -582,6 +585,11 @@ def create_advanced_agent(
``None`` leaves it uncapped. ``initial_message_count_key`` names the state field
holding the message count at the start of the turn, for agents whose input
carries earlier turns.

``unforced_tool_choice`` adds ``ToolChoiceMiddleware``, which sends the main
agent's structured-output calls unforced on models that reject a forced tool
choice. It must stay after ``shared_middleware`` so that
``_PayloadHandlerMiddleware`` sees the original ``response_format``.
"""
tools = [
tool.with_workspace(backend)
Expand Down Expand Up @@ -609,7 +617,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,
*([ToolChoiceMiddleware()] if unforced_tool_choice else []),
],
skills=list(skills) if skills else None,
)

Expand All @@ -627,6 +640,7 @@ def create_advanced_agent_graph(
output_files_enabled: bool = False,
max_iterations: int | None = None,
middleware: Sequence[AgentMiddleware[Any, Any]] = (),
unforced_tool_choice: bool = False,
) -> StateGraph[Any, Any, Any, Any]:
"""Wrap the advanced agent in a parent graph that maps typed I/O to/from messages.

Expand All @@ -642,7 +656,8 @@ def create_advanced_agent_graph(
back for another turn instead of emitting an output it cannot honor.

``max_iterations`` caps the model calls the agent loop may make, subagent calls
included; ``None`` leaves it uncapped.
included; ``None`` leaves it uncapped. ``unforced_tool_choice`` is passed to
``create_advanced_agent``.
"""
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
Expand All @@ -664,6 +679,7 @@ def create_advanced_agent_graph(
middleware=[*runtime_prompt.middleware, *middleware],
skills=skills,
max_iterations=max_iterations,
unforced_tool_choice=unforced_tool_choice,
)

output_file_retries_key = get_unique_model_field_name(
Expand Down
130 changes: 130 additions & 0 deletions src/uipath_langchain/agent/advanced/tool_choice.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Structured output for the advanced main agent on models that reject forcing.

create_agent binds tool_choice="any" on every call that carries a ToolStrategy,
and some models (Opus 5.5, Sonnet 5.5) answer any forced tool choice with a 400.
For those, the call goes out with no response_format and the output tool bound as
a plain tool on "auto", and the structured response is built here the way
create_agent builds it for a ToolStrategy (langchain.agents.factory, the
ToolStrategy branch of the model node's output handling).

Only that path's default behavior is mirrored: JSON-schema dict schemas, which
parse without validation, and handle_errors=True for several output calls.
"""

from collections.abc import Awaitable, Callable
from typing import Any

from langchain.agents.factory import STRUCTURED_OUTPUT_ERROR_TEMPLATE
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
)
from langchain.agents.structured_output import (
MultipleStructuredOutputsError,
OutputToolBinding,
ToolStrategy,
)
from langchain_core.messages import AIMessage, ToolCall, ToolMessage
from langchain_core.utils.function_calling import convert_to_openai_tool

from uipath_langchain.agent.react.no_forced_tool_choice import (
should_skip_forced_tool_choice,
)


def _tool_messages(calls: list[ToolCall], content: str) -> list[ToolMessage]:
return [
ToolMessage(content=content, tool_call_id=call["id"], name=call["name"])
for call in calls
]


class ToolChoiceMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Send a structured-output call unforced when the model rejects forcing.

Sits inside ``_PayloadHandlerMiddleware``, so that middleware still sees the
ToolStrategy and retries a turn that ends without the output tool. Needs the
graph to be built with the ToolStrategy: create_agent's routing and its
structured_response state come from it, and this only rewrites each call.
"""

@staticmethod
def _bindings(
request: ModelRequest[Any],
) -> tuple[ToolStrategy[Any], dict[str, OutputToolBinding[Any]]] | None:
strategy = request.response_format
if not isinstance(strategy, ToolStrategy):
return None
if not should_skip_forced_tool_choice(request.model):
return None
return strategy, {
spec.name: OutputToolBinding.from_schema_spec(spec)
for spec in strategy.schema_specs
}

@staticmethod
def _unforced(
request: ModelRequest[Any], bindings: dict[str, OutputToolBinding[Any]]
) -> ModelRequest[Any]:
output_tools = [convert_to_openai_tool(b.tool) for b in bindings.values()]
return request.override(
response_format=None,
tools=[*output_tools, *request.tools],
tool_choice="auto",
)

@staticmethod
def _structured(
response: ModelResponse[Any],
strategy: ToolStrategy[Any],
bindings: dict[str, OutputToolBinding[Any]],
) -> ModelResponse[Any]:
output = next(
(m for m in reversed(response.result) if isinstance(m, AIMessage)), None
)
calls = (
[c for c in output.tool_calls if c["name"] in bindings] if output else []
)
if not output or not calls:
return response
if len(calls) > 1:
error = MultipleStructuredOutputsError([c["name"] for c in calls], output)
content = STRUCTURED_OUTPUT_ERROR_TEMPLATE.format(error=str(error))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as is. The only caller builds ToolStrategy(output_json_schema) with the default handle_errors=True (graph.py#L504), and for that default this sends the same message langchain does. The module docstring says only the default is mirrored (tool_choice.py#L10-L11). This is a stopgap until langchain lets ToolStrategy skip the forced tool choice; if a caller needs a custom handle_errors before then, I'll add it.

return ModelResponse(
result=[*response.result, *_tool_messages(calls, content)]
)
structured = bindings[calls[0]["name"]].parse(calls[0]["args"])
content = (
strategy.tool_message_content
or f"Returning structured response: {structured}"
)
return ModelResponse(
result=[*response.result, *_tool_messages(calls, content)],
structured_response=structured,
)

def wrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
) -> ModelResponse[Any]:
found = self._bindings(request)
if found is None:
return handler(request)
strategy, bindings = found
response = handler(self._unforced(request, bindings))
return self._structured(response, strategy, bindings)

async def awrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
) -> ModelResponse[Any]:
found = self._bindings(request)
if found is None:
return await handler(request)
strategy, bindings = found
response = await handler(self._unforced(request, bindings))
return self._structured(response, strategy, bindings)
8 changes: 2 additions & 6 deletions src/uipath_langchain/agent/react/llm_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
finish_without_forcing,
finishing_tool_call,
output_fields,
should_skip_forced_tool_choice,
)
from .types import FLOW_CONTROL_TOOLS, AgentGraphState
from .utils import count_consecutive_tool_less_turns
Expand Down Expand Up @@ -87,11 +88,6 @@ async def _ainvoke(
raise


def _should_skip_forced_tool_choice(model: BaseChatModel) -> bool:
model_details = getattr(model, "model_details", None) or {}
return bool(model_details.get("shouldSkipForcedToolChoice", False))


def _no_finishing_tool_call_error() -> AgentRuntimeError:
return AgentRuntimeError(
code=AgentRuntimeErrorCode.THINKING_LIMIT_EXCEEDED,
Expand Down Expand Up @@ -367,7 +363,7 @@ def create_llm_node(
tool_choice=tool_choice,
prompt_caching=prompt_caching,
)
if _should_skip_forced_tool_choice(model):
if should_skip_forced_tool_choice(model):
return AutoToolChoiceLLMNode(
model,
tools,
Expand Down
7 changes: 7 additions & 0 deletions src/uipath_langchain/agent/react/no_forced_tool_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from collections.abc import Sequence
from typing import Any

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, AnyMessage, HumanMessage, ToolCall
from langchain_core.tools import BaseTool
from uipath.agent.react import END_EXECUTION_TOOL, RAISE_ERROR_TOOL
Expand All @@ -22,6 +23,12 @@
)


def should_skip_forced_tool_choice(model: BaseChatModel) -> bool:
"""Whether discovery says the model rejects tool_choice "any" or a named tool."""
model_details = getattr(model, "model_details", None) or {}
return bool(model_details.get("shouldSkipForcedToolChoice", False))


def output_fields(tools: Sequence[BaseTool]) -> list[str] | None:
"""The agent's output fields (end_execution's arguments), or None."""
for tool in tools:
Expand Down
14 changes: 14 additions & 0 deletions tests/agent/advanced/test_create_advanced_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
_RuntimeSystemPromptMiddleware,
create_advanced_agent_graph,
)
from uipath_langchain.agent.advanced.tool_choice import ToolChoiceMiddleware
from uipath_langchain.agent.advanced.types import AdvancedAgentGraphState
from uipath_langchain.agent.advanced.utils import create_state_with_input

Expand Down Expand Up @@ -97,6 +98,19 @@ def test_callable_system_prompt_enables_runtime_middleware() -> None:
assert runtime_middleware.state_key == "uipath__system_prompt"


@pytest.mark.parametrize("enabled", [True, False])
def test_unforced_tool_choice_reaches_the_main_agent(enabled: bool) -> None:
"""The agents runtime sets this from a feature flag on the wrapper graph."""
with patch(
"uipath_langchain.agent.advanced.agent._create_deep_agent",
return_value=MagicMock(),
) as mock_create:
_build(unforced_tool_choice=enabled)

middleware = mock_create.call_args.kwargs["middleware"]
assert any(isinstance(m, ToolChoiceMiddleware) for m in middleware) is enabled


def test_static_system_prompt_skips_runtime_middleware() -> None:
"""A plain string prompt reaches the deep agent unchanged, with no middleware."""
with patch(
Expand Down
Loading
Loading