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.18.14"
version = "0.18.15"
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
25 changes: 19 additions & 6 deletions src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from dataclasses import dataclass
from typing import Any, Literal, NotRequired, cast

from deepagents import CompiledSubAgent, SubAgent
from deepagents import CompiledSubAgent, FilesystemPermission, SubAgent
from deepagents import create_deep_agent as _create_deep_agent
from deepagents.backends import BackendProtocol, FilesystemBackend
from deepagents.backends import BackendProtocol, CompositeBackend, FilesystemBackend
from deepagents.middleware.subagents import GENERAL_PURPOSE_SUBAGENT
from langchain.agents.middleware import (
AgentMiddleware,
Expand Down Expand Up @@ -368,6 +368,7 @@ def create_advanced_agent(
memory: Sequence[str] = (),
middleware: Sequence[AgentMiddleware[Any, Any]] = (),
skills: Sequence[str] | None = None,
permissions: Sequence[FilesystemPermission] | None = None,
) -> CompiledStateGraph[Any, Any, Any, Any]:
"""Create a deepagents agent with planning, filesystem, and sub-agent tools.

Expand All @@ -378,6 +379,9 @@ 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).

``permissions`` are deepagents ``FilesystemPermission`` rules, enforced by its
filesystem tools for the main agent and inherited by every subagent.

Tools named in :data:`MAIN_AGENT_ONLY_TOOLS` are withheld from every subagent.
"""
shared_tools, _ = _partition_main_agent_tools(tools)
Expand All @@ -394,6 +398,7 @@ def create_advanced_agent(
memory=list(memory) or None,
middleware=[*middleware, payload_handler],
skills=list(skills) if skills else None,
permissions=list(permissions) if permissions else None,
)


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

Expand All @@ -427,8 +433,10 @@ def create_advanced_agent_graph(
``max_iterations`` caps the model calls the agent loop may make; ``None``
leaves it uncapped.
"""
# A CompositeBackend's default is the workspace; routed mounts are not.
workspace = backend.default if isinstance(backend, CompositeBackend) else backend
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(workspace, FilesystemBackend) else []
)
runtime_prompt = _resolve_runtime_system_prompt(
system_prompt, AdvancedAgentGraphState, input_schema
Expand All @@ -450,6 +458,7 @@ def create_advanced_agent_graph(
*middleware,
],
skills=skills,
permissions=permissions,
)

output_file_retries_key = get_unique_model_field_name(
Expand Down Expand Up @@ -481,7 +490,7 @@ async def transform_input_async(state: BaseModel) -> dict[str, Any]:
)
if attachment_paths:
input_args = await resolve_input_attachments(
backend, attachment_paths, input_args
workspace, attachment_paths, input_args
)
user_text = build_user_message(input_args)
update: dict[str, Any] = {
Expand Down Expand Up @@ -554,6 +563,7 @@ def create_conversational_advanced_agent_graph(
output_schema: type[BaseModel] | None = None,
max_iterations: int | None = None,
middleware: Sequence[AgentMiddleware[Any, Any]] = (),
permissions: Sequence[FilesystemPermission] | None = None,
) -> StateGraph[Any, Any, Any, Any]:
"""Wrap the advanced agent in a parent graph that speaks the conversational contract.

Expand All @@ -571,8 +581,10 @@ def create_conversational_advanced_agent_graph(
``max_iterations`` caps the model calls the agent loop may make per exchange;
``None`` leaves it uncapped.
"""
# A CompositeBackend's default is the workspace; routed mounts are not.
workspace = backend.default if isinstance(backend, CompositeBackend) else backend
memory_sources = (
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(backend, FilesystemBackend) else []
[MEMORY_INDEX_VIRTUAL_PATH] if isinstance(workspace, FilesystemBackend) else []
)
runtime_prompt = _resolve_runtime_system_prompt(
system_prompt, _ConversationalAdvancedAgentGraphInput, input_schema
Expand All @@ -595,6 +607,7 @@ def create_conversational_advanced_agent_graph(
*middleware,
],
skills=skills,
permissions=permissions,
)

class ConversationalAdvancedAgentOutput(BaseModel):
Expand Down Expand Up @@ -671,7 +684,7 @@ def declared_input(state: BaseModel) -> dict[str, Any]:
async def capture_exchange_start(state: BaseModel) -> dict[str, Any]:
messages = cast(ConversationalAdvancedAgentGraphState, state).messages
update: dict[str, Any] = {initial_message_count_key: len(messages)}
hydrated_messages = await resolve_message_attachments(backend, messages)
hydrated_messages = await resolve_message_attachments(workspace, messages)
if hydrated_messages:
update["messages"] = hydrated_messages
if runtime_prompt.build_prompt is not None:
Expand Down
32 changes: 31 additions & 1 deletion tests/agent/advanced/test_conversational_advanced_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from deepagents.backends import FilesystemBackend
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langchain.agents.middleware import ModelRequest, ModelResponse
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
Expand Down Expand Up @@ -602,3 +602,33 @@ async def test_chat_attachments_need_a_filesystem_backend() -> None:
unchanged = next(seen_message for seen_message in seen if seen_message.id == "u1")
assert unchanged.content == message.content
assert "file_path" not in unchanged.additional_kwargs["attachments"][0]


@pytest.mark.asyncio
async def test_message_attachments_resolve_into_a_composite_default(
tmp_path: Path,
) -> None:
"""A CompositeBackend's default is the workspace, so attachments land there."""
workspace = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
backend = CompositeBackend(default=workspace, routes={"/skills/": StateBackend()})
with patch(
"uipath_langchain.agent.advanced.agent.resolve_message_attachments",
new_callable=AsyncMock,
return_value=[],
) as mock_resolve:
graph = create_conversational_advanced_agent_graph(
model=_mock_model(),
tools=[],
system_prompt="sys",
backend=backend,
input_schema=_Input,
)
state = graph.state_schema(
messages=[HumanMessage(content="hi")],
tenant="finance",
uipath__user_settings={"name": "Ada"},
)
await cast(Any, graph.nodes["capture_exchange_start"].runnable).ainvoke(state)

assert mock_resolve.await_args is not None
assert mock_resolve.await_args.args[0] is workspace
31 changes: 31 additions & 0 deletions tests/agent/advanced/test_create_advanced_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from deepagents.backends import CompositeBackend, FilesystemBackend, StateBackend
from langchain.agents.middleware import ModelRequest, ModelResponse
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage, SystemMessage
Expand Down Expand Up @@ -458,3 +459,33 @@ async def test_verification_state_is_not_forwarded_as_agent_input(self) -> None:

assert "messages" in update
assert "uipath__output_file_retries" not in update


@pytest.mark.asyncio
async def test_transform_input_resolves_attachments_into_a_composite_default(
tmp_path: Any,
) -> None:
"""A CompositeBackend's default is the workspace, so attachments land there."""
workspace = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
backend = CompositeBackend(default=workspace, routes={"/skills/": StateBackend()})
with (
patch(
"uipath_langchain.agent.advanced.agent.get_job_attachment_paths",
return_value=["$.book"],
),
patch(
"uipath_langchain.agent.advanced.agent.resolve_input_attachments",
new_callable=AsyncMock,
) as mock_resolve,
):
mock_resolve.return_value = {"book": {"FilePath": "/x"}, "question": "q"}
graph = _build(
backend=backend,
input_schema=_Input,
build_user_message=lambda args: f"msg:{args['question']}",
)
state = create_state_with_input(_Input)(book={"ID": "1"}, question="q")
await graph.nodes["transform_input"].runnable.ainvoke(state)

assert mock_resolve.await_args is not None
assert mock_resolve.await_args.args[0] is workspace
39 changes: 39 additions & 0 deletions tests/agent/advanced/test_memory_injection.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@
from unittest.mock import MagicMock, patch

import pytest
from deepagents.backends import CompositeBackend, StateBackend
from deepagents.backends.filesystem import FilesystemBackend
from langchain_core.language_models import BaseChatModel
from pydantic import BaseModel

from uipath_langchain.agent.advanced.agent import (
create_advanced_agent_graph,
create_conversational_advanced_agent_graph,
)
from uipath_langchain.agent.advanced.utils import (
MEMORY_INDEX_VIRTUAL_PATH,
Expand Down Expand Up @@ -74,6 +76,43 @@ def test_disables_memory_for_non_filesystem_backend(self) -> None:
assert _memory_kwarg(None) is None


class TestCompositeBackendMemoryWiring:
"""A CompositeBackend's default decides memory, as a plain backend would."""

def test_enables_memory_for_a_filesystem_default(self, tmp_path: Any) -> None:
backend = CompositeBackend(
default=FilesystemBackend(root_dir=tmp_path, virtual_mode=True),
routes={"/skills/": StateBackend()},
)

assert _memory_kwarg(backend) == [MEMORY_INDEX_VIRTUAL_PATH]

def test_disables_memory_for_a_state_default(self) -> None:
backend = CompositeBackend(default=StateBackend(), routes={})

assert _memory_kwarg(backend) is None

def test_conversational_enables_memory_for_a_filesystem_default(
self, tmp_path: Any
) -> None:
backend = CompositeBackend(
default=FilesystemBackend(root_dir=tmp_path, virtual_mode=True),
routes={"/skills/": StateBackend()},
)
with patch(
"uipath_langchain.agent.advanced.agent._create_deep_agent",
return_value=MagicMock(),
) as mock_create:
create_conversational_advanced_agent_graph(
model=MagicMock(spec=BaseChatModel),
tools=[],
system_prompt="",
backend=backend,
)

assert mock_create.call_args.kwargs["memory"] == [MEMORY_INDEX_VIRTUAL_PATH]


@pytest.mark.asyncio
class TestWrapperInputUnchanged:
"""transform_input no longer hand-rolls a memory SystemMessage."""
Expand Down
Loading
Loading