From a981a9ea232ba852981e64cd12343552395421b1 Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Mon, 21 Sep 2026 16:58:13 +0300 Subject: [PATCH 1/2] feat(advanced): download tool-produced attachments into the workspace An advanced agent only had a file on disk when its ticket arrived through the input schema or the chat window. A ticket a tool returned mid-run, such as a process tool's output file, a batch transform result or a child agent's output, stayed as JSON in the tool message, so the agent had no path to open. The standard agent's job-attachment wrapper covers this but runs only in UiPathToolNode; on this path tools run through deepagents' tool node. ToolAttachmentsMiddleware wraps every tool call, finds JobAttachment-shaped objects in the result, downloads them to /_, the layout input attachments use, and writes a FilePath onto each ticket. It is installed for a FilesystemBackend on the main agent and on every subagent, which share the workspace. Content-block results and Command results are handled; error results and create_output_file results pass through, and a failed download leaves the ticket without a path rather than failing the call. Tracks PC-5029. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QQVAR7BZ5CZbRMMZcb9RGf --- pyproject.toml | 2 +- src/uipath_langchain/agent/advanced/agent.py | 16 +- .../agent/advanced/tool_attachments.py | 242 ++++++++++++ src/uipath_langchain/agent/advanced/utils.py | 2 +- tests/agent/advanced/test_tool_attachments.py | 357 ++++++++++++++++++ uv.lock | 4 +- 6 files changed, 615 insertions(+), 8 deletions(-) create mode 100644 src/uipath_langchain/agent/advanced/tool_attachments.py create mode 100644 tests/agent/advanced/test_tool_attachments.py diff --git a/pyproject.toml b/pyproject.toml index d2ac71462..e74b80dfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "uipath-langchain" -version = "0.18.12" +version = "0.18.13" 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..a9e29b1db 100644 --- a/src/uipath_langchain/agent/advanced/agent.py +++ b/src/uipath_langchain/agent/advanced/agent.py @@ -47,6 +47,7 @@ from uipath_langchain.chat.handlers import get_payload_handler from uipath_langchain.runtime.messages import UiPathChatMessagesMapper +from .tool_attachments import ToolAttachmentsMiddleware from .types import ( AdvancedAgentGraphState, ConversationalAdvancedAgentGraphState, @@ -379,20 +380,26 @@ def create_advanced_agent( ``None`` or empty disables it (mirroring ``_create_deep_agent``'s contract). Tools named in :data:`MAIN_AGENT_ONLY_TOOLS` are withheld from every subagent. + + With a ``FilesystemBackend``, every job attachment a tool returns is downloaded + into the workspace and given a ``FilePath``, for the main agent and for every + subagent, which share that workspace. """ shared_tools, _ = _partition_main_agent_tools(tools) - payload_handler = _PayloadHandlerMiddleware() + shared_middleware: list[AgentMiddleware[Any, Any]] = [_PayloadHandlerMiddleware()] + if isinstance(backend, FilesystemBackend): + shared_middleware.append(ToolAttachmentsMiddleware(backend)) 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, shared_middleware ), backend=backend, response_format=response_format, memory=list(memory) or None, - middleware=[*middleware, payload_handler], + middleware=[*middleware, *shared_middleware], skills=list(skills) if skills else None, ) @@ -414,7 +421,8 @@ def create_advanced_agent_graph( """Wrap the advanced agent in a parent graph that maps typed I/O to/from messages. With a ``FilesystemBackend``, attachment-shaped inputs are downloaded into the - workspace and given a ``FilePath`` before the user message is built. A + workspace and given a ``FilePath`` before the user message is built, and so is + every attachment a tool returns during the run. A ``FilesystemBackend`` also enables workspace memory: deepagents' ``MemoryMiddleware`` reads ``/memory/MEMORY.md`` from the backend each turn. Memory stays disabled for non-filesystem backends, which carry no workspace. diff --git a/src/uipath_langchain/agent/advanced/tool_attachments.py b/src/uipath_langchain/agent/advanced/tool_attachments.py new file mode 100644 index 000000000..f88fa244a --- /dev/null +++ b/src/uipath_langchain/agent/advanced/tool_attachments.py @@ -0,0 +1,242 @@ +"""Download the attachments a tool returns into the advanced agent's workspace. + +An advanced agent reads files with its filesystem tools, so a job attachment is +useful to it only once it is on disk. Input attachments and chat attachments are +downloaded before the loop starts. A ticket that a tool returns mid-run, such as +a process tool's output file, a batch transform result or a child agent's output, +was not: on this path tools run through deepagents' tool node, so the +job-attachment wrapper of the standard agent never sees the result either. This +middleware closes that gap at the tool boundary. Every ticket found in a tool +result is streamed into the workspace under the layout input attachments use and +gains a ``FilePath`` the agent can open, in the main agent and in every subagent +alike. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import logging +import uuid +from collections.abc import Awaitable, Callable, Iterator +from pathlib import Path +from typing import Any, NamedTuple, cast + +from deepagents.backends import FilesystemBackend +from langchain.agents.middleware import AgentMiddleware, AgentState, ToolCallRequest +from langchain_core.messages import ToolMessage +from langgraph.types import Command + +from ..attachments.constants import OUTPUT_FILE_TOOL_NAME +from .utils import _download_missing, _workspace_file_name + +logger = logging.getLogger(__name__) + +# The agent wrote and named this file itself; the ticket points back at a copy of +# what is already in the workspace. +_TOOLS_WITHOUT_DOWNLOAD: frozenset[str] = frozenset({OUTPUT_FILE_TOOL_NAME}) + +ToolCallHandler = Callable[[ToolCallRequest], ToolMessage | Command[Any]] +AsyncToolCallHandler = Callable[ + [ToolCallRequest], Awaitable[ToolMessage | Command[Any]] +] + + +class _TicketRef(NamedTuple): + attachment_id: uuid.UUID + full_name: str + + +def _as_ticket(value: Any) -> _TicketRef | None: + """The attachment a JobAttachment-shaped object refers to, else None.""" + if not isinstance(value, dict): + return None + full_name = value.get("FullName") + if not isinstance(full_name, str) or not full_name: + return None + try: + attachment_id = uuid.UUID(str(value["ID"])) + except (KeyError, ValueError, AttributeError, TypeError): + return None + return _TicketRef(attachment_id, full_name) + + +def _iter_tickets(payload: Any) -> Iterator[tuple[dict[str, Any], _TicketRef]]: + if isinstance(payload, dict): + ref = _as_ticket(payload) + if ref is not None: + yield payload, ref + return + for value in payload.values(): + yield from _iter_tickets(value) + elif isinstance(payload, list): + for value in payload: + yield from _iter_tickets(value) + + +def find_tickets(payload: Any) -> list[dict[str, Any]]: + """Every JobAttachment-shaped object in a parsed tool result, in document order. + + Detection is structural rather than schema-driven: an MCP tool, the code + interpreter or a child agent's free-form output can all carry a ticket that + no output schema declares. A ticket's own fields, such as ``Metadata``, are + not searched. + """ + return [ticket for ticket, _ in _iter_tickets(payload)] + + +class _Slot(NamedTuple): + """One JSON document inside a tool message's content.""" + + block_index: int | None + """``None`` for string content; the block index for a text content block.""" + + payload: Any + + +def _parse_json(text: str) -> Any: + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return None + + +def _slots(content: str | list[Any]) -> list[_Slot]: + if isinstance(content, str): + payload = _parse_json(content) + return [_Slot(None, payload)] if isinstance(payload, (dict, list)) else [] + slots: list[_Slot] = [] + for block_index, block in enumerate(content): + if not isinstance(block, dict) or not isinstance(block.get("text"), str): + continue + payload = _parse_json(block["text"]) + if isinstance(payload, (dict, list)): + slots.append(_Slot(block_index, payload)) + return slots + + +def _dump(payload: Any) -> str: + # the serialization the tool node applied to the original output + return json.dumps(payload, ensure_ascii=False) + + +def _content_with(content: str | list[Any], slots: list[_Slot]) -> str | list[Any]: + if isinstance(content, str): + return _dump(slots[0].payload) + rewritten = list(content) + for slot in slots: + if slot.block_index is not None: + rewritten[slot.block_index] = { + **rewritten[slot.block_index], + "text": _dump(slot.payload), + } + return rewritten + + +def _tool_messages(result: ToolMessage | Command[Any]) -> list[ToolMessage]: + if isinstance(result, ToolMessage): + return [result] + if isinstance(result, Command) and isinstance(result.update, dict): + messages = result.update.get("messages") + if isinstance(messages, list): + return [m for m in messages if isinstance(m, ToolMessage)] + return [] + + +def _with_messages( + result: ToolMessage | Command[Any], rewritten: dict[int, ToolMessage] +) -> ToolMessage | Command[Any]: + if isinstance(result, ToolMessage): + return rewritten.get(id(result), result) + update = cast(dict[str, Any], result.update) + messages = [rewritten.get(id(m), m) for m in update["messages"]] + return dataclasses.replace(result, update={**update, "messages": messages}) + + +class ToolAttachmentsMiddleware(AgentMiddleware[AgentState[Any], Any]): + """Give every attachment a tool returns a ``FilePath`` in the workspace. + + Each ticket in a tool result is downloaded to ``/_``, + the layout input attachments already use, and the ticket in the tool message + gains ``FilePath``. A file already in the workspace is not fetched again. A + ticket whose download fails is left without a path rather than failing the + tool call; the agent still holds a reference it can hand to other tools. + Error results and results of :data:`_TOOLS_WITHOUT_DOWNLOAD` pass through + untouched. + """ + + def __init__(self, backend: FilesystemBackend) -> None: + self.backend = backend + + def wrap_tool_call( + self, request: ToolCallRequest, handler: ToolCallHandler + ) -> ToolMessage | Command[Any]: + result = handler(request) + if self._skips(request): + return result + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(self.resolve(result)) + logger.warning( + "Tool attachments stay unopenable: the tool ran synchronously inside a " + "running event loop, where they cannot be downloaded" + ) + return result + + async def awrap_tool_call( + self, request: ToolCallRequest, handler: AsyncToolCallHandler + ) -> ToolMessage | Command[Any]: + result = await handler(request) + if self._skips(request): + return result + return await self.resolve(result) + + @staticmethod + def _skips(request: ToolCallRequest) -> bool: + return request.tool_call["name"] in _TOOLS_WITHOUT_DOWNLOAD + + async def resolve( + self, result: ToolMessage | Command[Any] + ) -> ToolMessage | Command[Any]: + """Return ``result`` with a path on every ticket whose file is in the workspace. + + The result comes back unchanged, same object, when it carries no ticket. + """ + parsed = [ + (message, slots) + for message in _tool_messages(result) + if message.status != "error" + for slots in (_slots(message.content),) + if slots + ] + tickets = [ + found + for _, slots in parsed + for slot in slots + for found in _iter_tickets(slot.payload) + ] + if not tickets: + return result + + paths: dict[uuid.UUID, Path] = { + ref.attachment_id: self.backend.cwd + / _workspace_file_name(ref.attachment_id, ref.full_name) + for _, ref in tickets + } + downloaded = await _download_missing(paths, self.backend.cwd) + for ticket, ref in tickets: + path = downloaded.get(ref.attachment_id) + if path is None: + ticket.pop("FilePath", None) + else: + ticket["FilePath"] = f"/{path.name}" + + rewritten = { + id(message): message.model_copy( + update={"content": _content_with(message.content, slots)} + ) + for message, slots in parsed + } + return _with_messages(result, rewritten) diff --git a/src/uipath_langchain/agent/advanced/utils.py b/src/uipath_langchain/agent/advanced/utils.py index e79ea612b..a8c2310a7 100644 --- a/src/uipath_langchain/agent/advanced/utils.py +++ b/src/uipath_langchain/agent/advanced/utils.py @@ -168,7 +168,7 @@ async def _download_missing( if not missing: return paths - logger.info("Downloading %d message attachment(s) into %s", len(missing), workspace) + logger.info("Downloading %d attachment(s) into %s", len(missing), workspace) client = UiPath() outcomes = await asyncio.gather( *( diff --git a/tests/agent/advanced/test_tool_attachments.py b/tests/agent/advanced/test_tool_attachments.py new file mode 100644 index 000000000..79b4293e0 --- /dev/null +++ b/tests/agent/advanced/test_tool_attachments.py @@ -0,0 +1,357 @@ +"""Tests for the middleware that downloads tool-produced attachments.""" + +import asyncio +import json +import uuid +from pathlib import Path +from typing import Any, Sequence +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from deepagents.backends import FilesystemBackend +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage, BaseMessage, ToolMessage +from langchain_core.tools import StructuredTool +from langgraph.types import Command + +from uipath_langchain.agent.advanced.agent import create_advanced_agent +from uipath_langchain.agent.advanced.tool_attachments import ( + ToolAttachmentsMiddleware, + find_tickets, +) +from uipath_langchain.agent.attachments.constants import OUTPUT_FILE_TOOL_NAME + +_UIPATH = "uipath_langchain.agent.advanced.utils.UiPath" + + +def _ticket(attachment_id: uuid.UUID, name: str = "report.csv") -> dict[str, Any]: + return {"ID": str(attachment_id), "FullName": name, "MimeType": "text/csv"} + + +def _request(tool_name: str = "produce_file") -> MagicMock: + request = MagicMock() + request.tool_call = {"name": tool_name, "args": {}, "id": "c1"} + return request + + +def _client(*, failing: set[uuid.UUID] = frozenset()) -> MagicMock: + """A UiPath client whose download writes the destination file.""" + + async def download(*, key: uuid.UUID, destination_path: str, **_: Any) -> str: + if key in failing: + Path(destination_path).write_bytes(b"partial") + raise RuntimeError("boom") + Path(destination_path).write_text("content") + return destination_path + + client = MagicMock() + client.attachments.download_async = AsyncMock(side_effect=download) + return client + + +async def _passthrough(result: ToolMessage | Command[Any]) -> Any: + async def handler(_request: Any) -> ToolMessage | Command[Any]: + return result + + return handler + + +class TestFindTickets: + def test_finds_nested_tickets_in_document_order(self) -> None: + first, second = uuid.uuid4(), uuid.uuid4() + payload = { + "result": _ticket(first, "a.csv"), + "items": [{"nested": {"file": _ticket(second, "b.csv")}}], + } + + assert [t["ID"] for t in find_tickets(payload)] == [str(first), str(second)] + + def test_skips_objects_that_are_not_tickets(self) -> None: + payload = { + "not_uuid": {"ID": "123", "FullName": "a.csv"}, + "no_name": {"ID": str(uuid.uuid4())}, + "empty_name": {"ID": str(uuid.uuid4()), "FullName": ""}, + "plain": "text", + } + + assert find_tickets(payload) == [] + + def test_does_not_search_inside_a_ticket(self) -> None: + """A ticket's own Metadata is not a place another ticket can hide.""" + inner = uuid.uuid4() + outer = {**_ticket(uuid.uuid4()), "Metadata": {"file": _ticket(inner)}} + + assert find_tickets({"file": outer}) == [outer] + + +class TestResolve: + @pytest.mark.asyncio + async def test_downloads_and_adds_a_file_path(self, tmp_path: Path) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + attachment_id = uuid.uuid4() + message = ToolMessage( + content=json.dumps({"file": _ticket(attachment_id)}), tool_call_id="c1" + ) + client = _client() + + with patch(_UIPATH, return_value=client): + result = await ToolAttachmentsMiddleware(backend).awrap_tool_call( + _request(), await _passthrough(message) + ) + + expected_name = f"{attachment_id}_report.csv" + call_kwargs = client.attachments.download_async.call_args.kwargs + assert call_kwargs["key"] == attachment_id + assert call_kwargs["destination_path"] == str(tmp_path / expected_name) + assert isinstance(result, ToolMessage) + assert json.loads(str(result.content)) == { + "file": {**_ticket(attachment_id), "FilePath": f"/{expected_name}"} + } + assert result.tool_call_id == "c1" + + @pytest.mark.asyncio + async def test_rewrites_json_inside_text_content_blocks( + self, tmp_path: Path + ) -> None: + """An MCP tool answers with content blocks; the ticket lives in a block's text.""" + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + attachment_id = uuid.uuid4() + message = ToolMessage( + content=[ + {"type": "text", "text": "prose"}, + {"type": "text", "text": json.dumps(_ticket(attachment_id))}, + ], + tool_call_id="c1", + ) + + with patch(_UIPATH, return_value=_client()): + result = await ToolAttachmentsMiddleware(backend).resolve(message) + + assert isinstance(result, ToolMessage) + blocks = list(result.content) + assert blocks[0] == {"type": "text", "text": "prose"} + assert json.loads(blocks[1]["text"])["FilePath"] == ( + f"/{attachment_id}_report.csv" + ) + + @pytest.mark.asyncio + async def test_rewrites_the_messages_of_a_command(self, tmp_path: Path) -> None: + """A subagent's ``task`` answer is a Command; its other updates survive.""" + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + attachment_id = uuid.uuid4() + command = Command( + update={ + "messages": [ + ToolMessage( + content=json.dumps(_ticket(attachment_id)), tool_call_id="c1" + ) + ], + "todos": ["keep me"], + }, + goto="somewhere", + ) + + with patch(_UIPATH, return_value=_client()): + result = await ToolAttachmentsMiddleware(backend).resolve(command) + + assert isinstance(result, Command) + assert result.goto == "somewhere" + assert result.update["todos"] == ["keep me"] + content = json.loads(str(result.update["messages"][0].content)) + assert content["FilePath"] == f"/{attachment_id}_report.csv" + + @pytest.mark.asyncio + async def test_returns_the_same_object_when_nothing_to_do( + self, tmp_path: Path + ) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + client = _client() + messages = [ + ToolMessage(content="plain prose", tool_call_id="c1"), + ToolMessage(content=json.dumps({"rows": 3}), tool_call_id="c2"), + ToolMessage( + content=json.dumps(_ticket(uuid.uuid4())), + tool_call_id="c3", + status="error", + ), + ] + + with patch(_UIPATH, return_value=client): + for message in messages: + assert ( + await ToolAttachmentsMiddleware(backend).resolve(message) is message + ) + + client.attachments.download_async.assert_not_awaited() + + @pytest.mark.asyncio + async def test_skips_the_output_file_tool(self, tmp_path: Path) -> None: + """The agent wrote that file itself; fetching a copy back is waste.""" + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + message = ToolMessage( + content=json.dumps({"file": _ticket(uuid.uuid4())}), tool_call_id="c1" + ) + client = _client() + + with patch(_UIPATH, return_value=client): + result = await ToolAttachmentsMiddleware(backend).awrap_tool_call( + _request(OUTPUT_FILE_TOOL_NAME), await _passthrough(message) + ) + + assert result is message + client.attachments.download_async.assert_not_awaited() + + @pytest.mark.asyncio + async def test_does_not_fetch_a_file_already_in_the_workspace( + self, tmp_path: Path + ) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + attachment_id = uuid.uuid4() + (tmp_path / f"{attachment_id}_report.csv").write_text("already here") + message = ToolMessage( + content=json.dumps(_ticket(attachment_id)), tool_call_id="c1" + ) + client = _client() + + with patch(_UIPATH, return_value=client): + result = await ToolAttachmentsMiddleware(backend).resolve(message) + + client.attachments.download_async.assert_not_awaited() + assert isinstance(result, ToolMessage) + assert json.loads(str(result.content))["FilePath"] == ( + f"/{attachment_id}_report.csv" + ) + + @pytest.mark.asyncio + async def test_a_failed_download_leaves_that_ticket_without_a_path( + self, tmp_path: Path + ) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + good, bad = uuid.uuid4(), uuid.uuid4() + message = ToolMessage( + content=json.dumps( + { + "good": _ticket(good, "good.csv"), + "bad": {**_ticket(bad, "bad.csv"), "FilePath": "/stale"}, + } + ), + tool_call_id="c1", + ) + + with patch(_UIPATH, return_value=_client(failing={bad})): + result = await ToolAttachmentsMiddleware(backend).resolve(message) + + assert isinstance(result, ToolMessage) + content = json.loads(str(result.content)) + assert content["good"]["FilePath"] == f"/{good}_good.csv" + assert "FilePath" not in content["bad"] + assert not (tmp_path / f"{bad}_bad.csv").exists() + + def test_sync_tool_calls_download_too(self, tmp_path: Path) -> None: + backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True) + attachment_id = uuid.uuid4() + message = ToolMessage( + content=json.dumps(_ticket(attachment_id)), tool_call_id="c1" + ) + + with patch(_UIPATH, return_value=_client()): + result = ToolAttachmentsMiddleware(backend).wrap_tool_call( + _request(), lambda _request: message + ) + + assert isinstance(result, ToolMessage) + assert json.loads(str(result.content))["FilePath"] == ( + f"/{attachment_id}_report.csv" + ) + + +# --- End to end on a real deep agent --- + +_ATTACHMENT_ID = uuid.uuid4() +_MODEL_INPUTS: list[list[BaseMessage]] = [] + + +class _RecordingModel(GenericFakeChatModel): + """Records the messages of every model call and accepts any tool binding.""" + + model_name: str = "test-model-tool-attachments" + + def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> Any: + return {"ls_provider": "openai", "ls_model_name": self.model_name} + + def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> "_RecordingModel": + return self + + def _generate(self, messages: list[BaseMessage], *args: Any, **kwargs: Any) -> Any: + _MODEL_INPUTS.append(list(messages)) + return super()._generate(messages, *args, **kwargs) + + +def _produce_file_tool() -> StructuredTool: + return StructuredTool.from_function( + func=lambda name="report.csv": {"file": _ticket(_ATTACHMENT_ID, name)}, + name="produce_file", + description="produce a file", + ) + + +def _tool_call(name: str, args: dict[str, Any], call_id: str) -> AIMessage: + return AIMessage( + content="", tool_calls=[{"name": name, "args": args, "id": call_id}] + ) + + +def _file_path_tool_messages() -> list[ToolMessage]: + """Every ToolMessage any agent was shown that carries the downloaded path.""" + expected = f"/{_ATTACHMENT_ID}_report.csv" + return [ + message + for messages in _MODEL_INPUTS + for message in messages + if isinstance(message, ToolMessage) + and message.name == "produce_file" + and expected in str(message.content) + ] + + +def _run(tmp_path: Path, scripted: list[AIMessage]) -> dict[str, Any]: + _MODEL_INPUTS.clear() + model = _RecordingModel( + messages=iter([*scripted, *[AIMessage(content="done")] * 20]) + ) + graph = create_advanced_agent( + model=model, + tools=[_produce_file_tool()], + backend=FilesystemBackend(root_dir=tmp_path, virtual_mode=True), + ) + with patch(_UIPATH, return_value=_client()): + return asyncio.run( + graph.ainvoke({"messages": [{"role": "user", "content": "hi"}]}) + ) + + +def test_the_main_agent_sees_the_path_of_a_tool_produced_file(tmp_path: Path) -> None: + result = _run(tmp_path, [_tool_call("produce_file", {"name": "report.csv"}, "c1")]) + + tool_messages = [m for m in result["messages"] if isinstance(m, ToolMessage)] + assert len(tool_messages) == 1 + assert json.loads(str(tool_messages[0].content))["file"]["FilePath"] == ( + f"/{_ATTACHMENT_ID}_report.csv" + ) + assert (tmp_path / f"{_ATTACHMENT_ID}_report.csv").read_text() == "content" + + +def test_a_subagent_sees_the_path_of_a_tool_produced_file(tmp_path: Path) -> None: + """The subagent shares the workspace, so its own tool results get a path too.""" + _run( + tmp_path, + [ + _tool_call( + "task", {"description": "go", "subagent_type": "general-purpose"}, "c1" + ), + _tool_call("produce_file", {"name": "report.csv"}, "c2"), + ], + ) + + assert _file_path_tool_messages(), "no agent was shown the downloaded path" + assert (tmp_path / f"{_ATTACHMENT_ID}_report.csv").read_text() == "content" diff --git a/uv.lock b/uv.lock index b6cf4ec82..98742b275 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-09-19T13:56:34.8761628Z" exclude-newer-span = "P2D" [options.exclude-newer-package] @@ -4828,7 +4828,7 @@ wheels = [ [[package]] name = "uipath-langchain" -version = "0.18.12" +version = "0.18.13" source = { editable = "." } dependencies = [ { name = "a2a-sdk" }, From 0eccde22b271dd7e2e92f5ee66fe75c87a7ff412 Mon Sep 17 00:00:00 2001 From: Robert Ursu Date: Mon, 21 Sep 2026 17:02:00 +0300 Subject: [PATCH 2/2] test(advanced): satisfy mypy in the tool attachment tests CI type-checks the tests too. Narrow the content block and the Command update before indexing, and type the failing-download set as a frozenset. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QQVAR7BZ5CZbRMMZcb9RGf --- tests/agent/advanced/test_tool_attachments.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/agent/advanced/test_tool_attachments.py b/tests/agent/advanced/test_tool_attachments.py index 79b4293e0..3cf42f5bc 100644 --- a/tests/agent/advanced/test_tool_attachments.py +++ b/tests/agent/advanced/test_tool_attachments.py @@ -34,7 +34,7 @@ def _request(tool_name: str = "produce_file") -> MagicMock: return request -def _client(*, failing: set[uuid.UUID] = frozenset()) -> MagicMock: +def _client(*, failing: frozenset[uuid.UUID] = frozenset()) -> MagicMock: """A UiPath client whose download writes the destination file.""" async def download(*, key: uuid.UUID, destination_path: str, **_: Any) -> str: @@ -130,6 +130,7 @@ async def test_rewrites_json_inside_text_content_blocks( assert isinstance(result, ToolMessage) blocks = list(result.content) assert blocks[0] == {"type": "text", "text": "prose"} + assert isinstance(blocks[1], dict) assert json.loads(blocks[1]["text"])["FilePath"] == ( f"/{attachment_id}_report.csv" ) @@ -156,6 +157,7 @@ async def test_rewrites_the_messages_of_a_command(self, tmp_path: Path) -> None: assert isinstance(result, Command) assert result.goto == "somewhere" + assert isinstance(result.update, dict) assert result.update["todos"] == ["keep me"] content = json.loads(str(result.update["messages"][0].content)) assert content["FilePath"] == f"/{attachment_id}_report.csv" @@ -238,7 +240,7 @@ async def test_a_failed_download_leaves_that_ticket_without_a_path( tool_call_id="c1", ) - with patch(_UIPATH, return_value=_client(failing={bad})): + with patch(_UIPATH, return_value=_client(failing=frozenset({bad}))): result = await ToolAttachmentsMiddleware(backend).resolve(message) assert isinstance(result, ToolMessage)