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
3 changes: 3 additions & 0 deletions src/uipath_langchain/agent/advanced/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
subagent_dispatch_is_replay_safe,
warm_code_interpreter,
)
from .static_args import StaticArgsMiddleware, build_static_args_middleware
from .types import AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState
from .utils import (
MEMORY_DIR_NAME,
Expand All @@ -31,12 +32,14 @@
"PTC_FILESYSTEM_TOOLS",
"PersistenceMode",
"AdvancedAgentGraphState",
"StaticArgsMiddleware",
"BackendProtocol",
"CompiledSubAgent",
"ConversationalAdvancedAgentGraphState",
"FilesystemBackend",
"SubAgent",
"build_code_interpreter_middleware",
"build_static_args_middleware",
"create_advanced_agent",
"create_advanced_agent_graph",
"create_conversational_advanced_agent_graph",
Expand Down
36 changes: 29 additions & 7 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
from uipath_langchain.chat.handlers import get_payload_handler
from uipath_langchain.runtime.messages import UiPathChatMessagesMapper

from .static_args import build_static_args_middleware
from .types import (
AdvancedAgentGraphState,
ConversationalAdvancedAgentGraphState,
Expand Down Expand Up @@ -312,11 +313,13 @@ def _subagents_without_main_agent_tools(
skills: Sequence[str] | None,
middleware: Sequence[AgentMiddleware[Any, Any]] = (),
) -> list[SubAgent | CompiledSubAgent]:
"""Give every subagent the shared tool list instead of the parent's.
"""Give every subagent the shared tool list instead of the parent's, and ``middleware``.

deepagents hands a subagent the parent's ``tools`` unless its spec declares its
own (``graph.py``: ``spec.get("tools") if "tools" in spec else tools``), so
pinning ``tools`` on each spec is what actually withholds a main-agent-only tool.
A spec that declares its own tools keeps them and still receives ``middleware``;
a ``CompiledSubAgent`` brings its own graph and is passed through unchanged.

The auto-added ``general-purpose`` subagent is replaced with an explicit spec,
since it would otherwise inherit the parent list too. Supplying a spec under
Expand All @@ -332,14 +335,16 @@ def _subagents_without_main_agent_tools(
"""
resolved: list[SubAgent | CompiledSubAgent] = []
for spec in subagents:
# A CompiledSubAgent brings its own graph and tools; nothing to filter.
if "runnable" in spec or "tools" in spec:
if "runnable" in spec:
resolved.append(spec)
continue
declared_tools = cast("dict[str, Any]", spec).get("tools")
resolved.append(
{
**spec,
"tools": list(shared_tools),
"tools": list(declared_tools)
if declared_tools is not None
else list(shared_tools),
"middleware": [*spec.get("middleware", []), *middleware],
}
)
Expand Down Expand Up @@ -368,6 +373,7 @@ def create_advanced_agent(
memory: Sequence[str] = (),
middleware: Sequence[AgentMiddleware[Any, Any]] = (),
skills: Sequence[str] | None = None,
shared_middleware: Sequence[AgentMiddleware[Any, Any]] = (),
) -> CompiledStateGraph[Any, Any, Any, Any]:
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.

Expand All @@ -378,21 +384,27 @@ def create_advanced_agent(
``skills`` is a list of skill source paths for deepagents' ``SkillsMiddleware``;
``None`` or empty disables it (mirroring ``_create_deep_agent``'s contract).

``middleware`` reaches the main agent only, the way ``create_deep_agent``
treats it. ``shared_middleware`` reaches the main agent, after ``middleware``,
and every subagent deepagents builds from a spec, for behavior a subagent's
tool calls need as much as the main agent's do. A precompiled subagent
(``runnable``) brings its own graph and is left unchanged.

Tools named in :data:`MAIN_AGENT_ONLY_TOOLS` are withheld from every subagent.
"""
shared_tools, _ = _partition_main_agent_tools(tools)
payload_handler = _PayloadHandlerMiddleware()
every_agent_middleware = [*shared_middleware, _PayloadHandlerMiddleware()]
return _create_deep_agent(
model=model,
system_prompt=system_prompt,
tools=list(tools),
subagents=_subagents_without_main_agent_tools(
subagents, shared_tools, skills, [payload_handler]
subagents, shared_tools, skills, every_agent_middleware
),
backend=backend,
response_format=response_format,
memory=list(memory) or None,
middleware=[*middleware, payload_handler],
middleware=[*middleware, *every_agent_middleware],
skills=list(skills) if skills else None,
)

Expand Down Expand Up @@ -426,6 +438,11 @@ def create_advanced_agent_graph(

``max_iterations`` caps the model calls the agent loop may make; ``None``
leaves it uncapped.

A tool carrying argument bindings (``argument_properties``) gets
:class:`~uipath_langchain.agent.advanced.static_args.StaticArgsMiddleware`
on the main agent and every subagent, after ``middleware``, so the bound
values are pinned for the model and written into its tool calls.
"""
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
Expand All @@ -450,6 +467,7 @@ def create_advanced_agent_graph(
*middleware,
],
skills=skills,
shared_middleware=build_static_args_middleware(tools, input_schema),
)

output_file_retries_key = get_unique_model_field_name(
Expand Down Expand Up @@ -570,6 +588,9 @@ def create_conversational_advanced_agent_graph(

``max_iterations`` caps the model calls the agent loop may make per exchange;
``None`` leaves it uncapped.

A tool carrying argument bindings gets the static-args middleware on the
main agent and every subagent, as in :func:`create_advanced_agent_graph`.
"""
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
Expand All @@ -595,6 +616,7 @@ def create_conversational_advanced_agent_graph(
*middleware,
],
skills=skills,
shared_middleware=build_static_args_middleware(tools, input_schema),
)

class ConversationalAdvancedAgentOutput(BaseModel):
Expand Down
15 changes: 14 additions & 1 deletion src/uipath_langchain/agent/advanced/code_interpreter.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from langchain_core.tools import BaseTool

from uipath_langchain._utils.durable_interrupt import suspends_run
from uipath_langchain.agent.tools.static_args import has_argument_bindings

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -83,14 +84,20 @@
def ptc_tool_names(tools: Sequence[BaseTool]) -> list[str]:
"""Names of the agent tools that may be called from inside the REPL.

Three exclusions, each for a different reason:
Four exclusions, each for a different reason:

- **Tools that suspend the run.** One raising ``GraphInterrupt`` never returns
a value into the JS ``await``. Worse, the node is replayed from its
checkpoint on resume, so the ``eval`` re-runs from the top and every bridged
call made before the interrupt fires a second time. Upstream also documents
that PTC bridges bypass ``interrupt_on`` approval hooks, so an escalation
reached this way would skip its own approval.
- **Tools with configured argument bindings.** A static, argument or
text-builder binding is applied at the model-call boundary by
:class:`~uipath_langchain.agent.advanced.static_args.StaticArgsMiddleware`,
which rewrites the tool calls the model returns. The REPL bridge invokes
the tool object directly with whatever the script passes, so a bound
value would not be enforced on that path.
- **Names that cannot be JavaScript identifiers.** A tool name is caller
supplied and may hold spaces, dots or non-ASCII characters. Upstream raises
``ValueError`` for those from inside ``wrap_model_call``, faulting the run
Expand All @@ -116,6 +123,12 @@ def ptc_tool_names(tools: Sequence[BaseTool]) -> list[str]:
if suspends_run(tool):
logger.debug("Tool %r withheld from PTC: it suspends the run", tool.name)
continue
if has_argument_bindings(tool):
logger.debug(
"Tool %r withheld from PTC: it has configured argument bindings",
tool.name,
)
continue
if not is_valid(tool.name):
logger.info(
"Tool %r withheld from PTC: %r is not a valid JavaScript identifier",
Expand Down
169 changes: 169 additions & 0 deletions src/uipath_langchain/agent/advanced/static_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Configured tool argument bindings for advanced agents.

A low-code tool can bind an argument to a static value, an agent input, or a
text or array built from inputs (``argument_properties`` on the tool). The
standard ReAct llm node applies those bindings around every model call with
``StaticArgsHandler``: it binds the model to schemas that pin the bound fields
and writes the resolved values into the returned tool calls. ``create_deep_agent``
binds tools as given, so on the advanced path the model would be free to fill a
bound field with anything and nothing would overwrite it. This middleware runs
the same handler at the deep agent's model-call boundary, on the main agent and
on every subagent.
"""

from __future__ import annotations

import logging
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, NotRequired, cast

from deepagents.middleware.async_subagents import AsyncSubAgentState
from deepagents.middleware.filesystem import FilesystemState
from deepagents.middleware.memory import MemoryState
from deepagents.middleware.rubric import RubricState
from deepagents.middleware.skills import SkillsState
from deepagents.middleware.summarization import SummarizationState
from langchain.agents.middleware import (
AgentMiddleware,
AgentState,
ModelRequest,
ModelResponse,
)
from langchain.agents.middleware.todo import PlanningState
from langchain_core.messages import AIMessage
from langchain_core.tools import BaseTool
from pydantic import BaseModel

from uipath_langchain.agent.tools.static_args import (
StaticArgsHandler,
agent_input_from_state,
has_argument_bindings,
)

logger = logging.getLogger(__name__)

# Channels declared by the deep agent's own middleware. Declaring an agent input
# under one of these names would replace the channel (and its reducer) rather
# than add a key.
_RESERVED_STATE_KEYS: frozenset[str] = frozenset(
{
*AgentState.__annotations__,
*PlanningState.__annotations__,
*FilesystemState.__annotations__,
*SkillsState.__annotations__,
*SummarizationState.__annotations__,
*MemoryState.__annotations__,
*AsyncSubAgentState.__annotations__,
*RubricState.__annotations__,
}
)


def _is_reserved(name: str) -> bool:
# deepagents keeps its private channels underscored (summarization, rubric,
# forked context), so the prefix is reserved wholesale.
return name in _RESERVED_STATE_KEYS or name.startswith("_")


def build_static_args_middleware(
tools: Sequence[BaseTool],
input_schema: type[BaseModel] | None,
) -> list[AgentMiddleware[Any, Any]]:
"""The static-args middleware for ``tools``, ready to splice into ``middleware``.

Empty when no tool carries bindings, so an agent that has none keeps the
deep agent's state untouched.
"""
if not any(has_argument_bindings(tool) for tool in tools):
return []
return [StaticArgsMiddleware(input_schema)]


class StaticArgsMiddleware(AgentMiddleware[AgentState[Any], Any]):
"""Apply configured tool argument bindings around every deep-agent model call.

Bindings to agent inputs need the invocation's input, which lives on the
wrapper graph's state. Declaring the input fields on ``state_schema`` is what
carries them into the deep agent's state, where ``request.state`` exposes
them; deepagents copies that state into each subagent it dispatches, so a
subagent carrying this middleware resolves the same bindings. The input is
read from the state on every model call and the bindings are re-resolved
whenever it changes, so a compiled graph invoked again with other input, or
resumed from a checkpoint, pins the values of that invocation.
"""

def __init__(self, input_schema: type[BaseModel] | None) -> None:
self._handler = StaticArgsHandler()

declared = list((input_schema or BaseModel).model_fields)
self._input_fields = [name for name in declared if not _is_reserved(name)]
reserved = sorted(set(declared) - set(self._input_fields))
if reserved:
logger.warning(
"Agent inputs %s share a name with deep-agent state and cannot be "
"bound to tool arguments in Advanced Mode.",
reserved,
)
self.state_schema = cast(
type[AgentState[Any]],
type(
"StaticArgsState",
(AgentState,),
{
"__annotations__": {
name: NotRequired[Any] for name in self._input_fields
}
},
),
)

def _prepare_request(self, request: ModelRequest[Any]) -> ModelRequest[Any]:
bound_tools = [tool for tool in request.tools if isinstance(tool, BaseTool)]
if not any(has_argument_bindings(tool) for tool in bound_tools):
return request

agent_input = agent_input_from_state(
cast(Mapping[str, Any], request.state), self._input_fields
)
pinned_by_name = {
original.name: pinned
for original, pinned in zip(
bound_tools,
self._handler.resolve(bound_tools, agent_input),
strict=True,
)
if pinned is not original
}
if not pinned_by_name:
return request
return request.override(
tools=[
pinned_by_name.get(tool.name, tool)
if isinstance(tool, BaseTool)
else tool
for tool in request.tools
]
)

def _apply_to_response(self, response: ModelResponse[Any]) -> None:
for message in response.result:
if isinstance(message, AIMessage) and message.tool_calls:
self._handler.apply_to_response(message.tool_calls)

def wrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], ModelResponse[Any]],
) -> ModelResponse[Any]:
response = handler(self._prepare_request(request))
self._apply_to_response(response)
return response

async def awrap_model_call(
self,
request: ModelRequest[Any],
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
) -> ModelResponse[Any]:
response = await handler(self._prepare_request(request))
self._apply_to_response(response)
return response
Loading
Loading