From bcb668d5b9abf1419f07216dd05ef9c0fdbfd425 Mon Sep 17 00:00:00 2001 From: Maria Dhakal Date: Tue, 22 Sep 2026 15:04:22 -0700 Subject: [PATCH] feat(advanced): support a CompositeBackend workspace and filesystem permissions Memory and input and message attachments were enabled only for a FilesystemBackend, so a CompositeBackend silently lost memory and faulted on input attachments. Each graph builder now treats a CompositeBackend's default as the workspace, once, and uses it for memory and attachments. The deep agent still receives the full composite, so routed mounts stay visible to it. The graph builders also accept deepagents FilesystemPermission rules and forward them to create_deep_agent. Its filesystem tools enforce them for the main agent, its subagents, and calls bridged from the code interpreter. Co-Authored-By: Claude Opus 5.5 (1M context) --- pyproject.toml | 2 +- src/uipath_langchain/agent/advanced/agent.py | 25 +- ...est_conversational_advanced_agent_graph.py | 32 ++- .../test_create_advanced_agent_graph.py | 31 +++ tests/agent/advanced/test_memory_injection.py | 39 +++ tests/agent/advanced/test_permissions.py | 230 ++++++++++++++++++ uv.lock | 2 +- 7 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 tests/agent/advanced/test_permissions.py diff --git a/pyproject.toml b/pyproject.toml index 572ab6b09..01b693284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/uipath_langchain/agent/advanced/agent.py b/src/uipath_langchain/agent/advanced/agent.py index ca3619890..e33fcef38 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -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, @@ -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. @@ -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) @@ -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, ) @@ -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. @@ -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 @@ -450,6 +458,7 @@ def create_advanced_agent_graph( *middleware, ], skills=skills, + permissions=permissions, ) output_file_retries_key = get_unique_model_field_name( @@ -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] = { @@ -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. @@ -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 @@ -595,6 +607,7 @@ def create_conversational_advanced_agent_graph( *middleware, ], skills=skills, + permissions=permissions, ) class ConversationalAdvancedAgentOutput(BaseModel): @@ -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: diff --git a/tests/agent/advanced/test_conversational_advanced_agent_graph.py b/tests/agent/advanced/test_conversational_advanced_agent_graph.py index 3997230ce..4cd0780d5 100644 --- a/tests/agent/advanced/test_conversational_advanced_agent_graph.py +++ b/tests/agent/advanced/test_conversational_advanced_agent_graph.py @@ -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 @@ -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 diff --git a/tests/agent/advanced/test_create_advanced_agent_graph.py b/tests/agent/advanced/test_create_advanced_agent_graph.py index f01798e12..f99168f68 100644 --- a/tests/agent/advanced/test_create_advanced_agent_graph.py +++ b/tests/agent/advanced/test_create_advanced_agent_graph.py @@ -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 @@ -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 diff --git a/tests/agent/advanced/test_memory_injection.py b/tests/agent/advanced/test_memory_injection.py index 5a54e814e..f45c7f452 100644 --- a/tests/agent/advanced/test_memory_injection.py +++ b/tests/agent/advanced/test_memory_injection.py @@ -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, @@ -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.""" diff --git a/tests/agent/advanced/test_permissions.py b/tests/agent/advanced/test_permissions.py new file mode 100644 index 000000000..8685f15a7 --- /dev/null +++ b/tests/agent/advanced/test_permissions.py @@ -0,0 +1,230 @@ +"""deepagents filesystem permissions reach the deep agent and its subagents.""" + +from collections.abc import Sequence +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from deepagents import FilesystemPermission +from deepagents.backends import CompositeBackend, FilesystemBackend +from langchain_core.language_models import BaseChatModel +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from pydantic import BaseModel + +from uipath_langchain.agent.advanced.agent import ( + create_advanced_agent, + create_advanced_agent_graph, + create_conversational_advanced_agent_graph, +) + +READ_ONLY_SKILLS = [ + FilesystemPermission(operations=["write"], paths=["/skills/**"], mode="deny") +] + + +class _Input(BaseModel): + task: str = "" + + +class _Output(BaseModel): + result: str = "" + + +class _ScriptedModel(GenericFakeChatModel): + """Replays ``messages`` and records every prompt it is sent.""" + + seen: list[list[BaseMessage]] = [] + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "_ScriptedModel": + return self + + def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any: + self.seen.append(list(messages)) + return super()._generate(messages, *args, **kwargs) + + +def _deep_agent_kwargs(build: Any) -> dict[str, Any]: + with patch( + "uipath_langchain.agent.advanced.agent._create_deep_agent", + return_value=MagicMock(), + ) as mock_create: + build() + return dict(mock_create.call_args.kwargs) + + +# --- Forwarding --- + + +def test_autonomous_graph_forwards_permissions() -> None: + kwargs = _deep_agent_kwargs( + lambda: create_advanced_agent_graph( + model=MagicMock(spec=BaseChatModel), + tools=[], + system_prompt="", + backend=None, + response_format=None, + input_schema=_Input, + output_schema=_Output, + build_user_message=lambda args: "", + permissions=READ_ONLY_SKILLS, + ) + ) + + assert kwargs["permissions"] == READ_ONLY_SKILLS + + +def test_conversational_graph_forwards_permissions() -> None: + kwargs = _deep_agent_kwargs( + lambda: create_conversational_advanced_agent_graph( + model=MagicMock(spec=BaseChatModel), + tools=[], + system_prompt="", + backend=None, + permissions=READ_ONLY_SKILLS, + ) + ) + + assert kwargs["permissions"] == READ_ONLY_SKILLS + + +def test_no_permissions_by_default() -> None: + kwargs = _deep_agent_kwargs( + lambda: create_conversational_advanced_agent_graph( + model=MagicMock(spec=BaseChatModel), + tools=[], + system_prompt="", + backend=None, + ) + ) + + assert kwargs["permissions"] is None + + +# --- Enforcement on a real deep agent --- + + +def _mounted(tmp_path: Path) -> tuple[CompositeBackend, Path, Path]: + workspace = tmp_path / "workspace" + workspace.mkdir() + skills = tmp_path / "skills" + (skills / "alpha").mkdir(parents=True) + (skills / "alpha" / "SKILL.md").write_text("Step one.\n") + backend = CompositeBackend( + default=FilesystemBackend(root_dir=workspace, virtual_mode=True), + routes={"/skills/": FilesystemBackend(root_dir=skills, virtual_mode=True)}, + ) + return backend, workspace, skills + + +def _tool_results(model: _ScriptedModel) -> dict[str, str]: + return { + str(message.tool_call_id): str(message.content) + for message in model.seen[-1] + if isinstance(message, ToolMessage) + } + + +def test_deny_rule_makes_a_routed_path_read_only(tmp_path: Path) -> None: + backend, workspace, skills = _mounted(tmp_path) + model = _ScriptedModel( + seen=[], + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "read_file", + "args": {"file_path": "/skills/alpha/SKILL.md"}, + "id": "read", + }, + { + "name": "write_file", + "args": {"file_path": "/skills/alpha/x.md", "content": "x"}, + "id": "write", + }, + { + "name": "edit_file", + "args": { + "file_path": "/skills/alpha/SKILL.md", + "old_string": "Step one.", + "new_string": "Changed.", + }, + "id": "edit", + }, + { + "name": "write_file", + "args": {"file_path": "/notes.md", "content": "ok"}, + "id": "workspace", + }, + ], + ), + AIMessage(content="done"), + ] + ), + ) + + agent = create_advanced_agent( + model=model, backend=backend, permissions=READ_ONLY_SKILLS + ) + agent.invoke({"messages": [{"role": "user", "content": "go"}]}) + + results = _tool_results(model) + assert "Step one." in results["read"] + assert "permission denied" in results["write"] + assert "permission denied" in results["edit"] + assert (skills / "alpha" / "SKILL.md").read_text() == "Step one.\n" + assert not (skills / "alpha" / "x.md").exists() + assert (workspace / "notes.md").read_text() == "ok" + + +def test_subagents_inherit_the_deny_rule(tmp_path: Path) -> None: + backend, _, skills = _mounted(tmp_path) + # The model is shared, so one script plays the main agent and the subagent. + model = _ScriptedModel( + seen=[], + messages=iter( + [ + AIMessage( + content="", + tool_calls=[ + { + "name": "task", + "args": { + "description": "write a file", + "subagent_type": "general-purpose", + }, + "id": "dispatch", + } + ], + ), + AIMessage( + content="", + tool_calls=[ + { + "name": "write_file", + "args": {"file_path": "/skills/alpha/x.md", "content": "x"}, + "id": "sub-write", + } + ], + ), + AIMessage(content="subagent done"), + AIMessage(content="done"), + ] + ), + ) + + agent = create_advanced_agent( + model=model, backend=backend, permissions=READ_ONLY_SKILLS + ) + agent.invoke({"messages": [{"role": "user", "content": "go"}]}) + + subagent_results = { + str(message.tool_call_id): str(message.content) + for prompt in model.seen + for message in prompt + if isinstance(message, ToolMessage) + } + assert "permission denied" in subagent_results["sub-write"] + assert not (skills / "alpha" / "x.md").exists() diff --git a/uv.lock b/uv.lock index b41ef45ca..fdc6bc03c 100644 --- a/uv.lock +++ b/uv.lock @@ -4828,7 +4828,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.18.14" +version = "0.18.15" source = { editable = "." } dependencies = [ { name = "a2a-sdk" },