Skip to content
Merged
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.0"
version = "0.18.1"
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
28 changes: 28 additions & 0 deletions src/uipath_langchain/_utils/_attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Shared rendering of the attachment block handed to the model."""

import json
from typing import Any

ATTACHMENTS_BLOCK_PREFIX = "<uip:attachments>"
ATTACHMENTS_BLOCK_SUFFIX = "</uip:attachments>"

# the model copies these straight into tool arguments, which are validated
# against JOB_ATTACHMENT_DEFINITION
_JOB_ATTACHMENT_KEYS = {
"id": "ID",
"full_name": "FullName",
"mime_type": "MimeType",
"file_path": "FilePath",
}


def render_attachments_block(attachments: list[dict[str, Any]]) -> str:
"""Render attachment references as the text block the model reads."""
renamed = [
{_JOB_ATTACHMENT_KEYS.get(key, key): value for key, value in attachment.items()}
for attachment in attachments
]
# an attachment name is caller-controlled and would otherwise be able to
# close this block early. In JSON output "<" only occurs inside a string
payload = json.dumps(renamed).replace("<", "\\u003c").replace(">", "\\u003e")
return f"{ATTACHMENTS_BLOCK_PREFIX}{payload}{ATTACHMENTS_BLOCK_SUFFIX}"
6 changes: 5 additions & 1 deletion src/uipath_langchain/agent/advanced/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
MEMORY_INDEX_VIRTUAL_PATH,
create_state_with_input,
resolve_input_attachments,
resolve_message_attachments,
)


Expand Down Expand Up @@ -569,9 +570,12 @@ def declared_input(state: BaseModel) -> dict[str, Any]:
}
).model_dump(by_alias=True, exclude_unset=True)

def capture_exchange_start(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)
if hydrated_messages:
update["messages"] = hydrated_messages
if runtime_prompt.build_prompt is not None:
update.update(runtime_prompt.resolve(declared_input(state)))
return update
Expand Down
127 changes: 124 additions & 3 deletions src/uipath_langchain/agent/advanced/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@
import copy
import logging
import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any, NamedTuple, cast

from deepagents.backends import BackendProtocol, FilesystemBackend
from jsonpath_ng import parse as jsonpath_parse # type: ignore[import-untyped]
from langchain_core.messages import AnyMessage
from pydantic import BaseModel, ConfigDict
from uipath.platform import UiPath
from uipath.platform.attachments import Attachment

from ..._utils._attachments import (
ATTACHMENTS_BLOCK_PREFIX,
render_attachments_block,
)
from .types import AdvancedAgentGraphState

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -48,6 +54,12 @@ def create_state_with_input(
return CompleteState


def _workspace_file_name(attachment_id: uuid.UUID, full_name: str) -> str:
# basename only: full_name is caller-controlled, keep the download inside
# the workspace (no path traversal)
return f"{attachment_id}_{Path(full_name).name}"


class _AttachmentDownload(NamedTuple):
"""One input attachment to download and patch back into the args."""

Expand Down Expand Up @@ -87,9 +99,7 @@ async def resolve_input_attachments(
_AttachmentDownload(
location=match.full_path,
attachment_id=att.id,
# basename only: full_name is caller-controlled, keep the
# download inside the workspace (no path traversal)
file_name=f"{att.id}_{Path(att.full_name).name}",
file_name=_workspace_file_name(att.id, att.full_name),
ticket=ticket,
)
)
Expand All @@ -110,3 +120,114 @@ async def resolve_input_attachments(
for item in worklist:
item.location.update(result, {**item.ticket, "FilePath": f"/{item.file_name}"})
return result


def _with_attachments_block(
message: AnyMessage, attachments: list[dict[str, Any]]
) -> AnyMessage:
rendered = render_attachments_block(attachments)
content = [
{**block, "text": rendered}
if isinstance(block, dict)
and isinstance(block.get("text"), str)
and block["text"].startswith(ATTACHMENTS_BLOCK_PREFIX)
else block
for block in message.content
]
return message.model_copy(
update={
"content": content,
"additional_kwargs": {
**message.additional_kwargs,
"attachments": attachments,
},
}
)


def _with_file_paths(
attachments: list[dict[str, Any]], paths: dict[uuid.UUID, Path]
) -> list[dict[str, Any]]:
resolved: list[dict[str, Any]] = []
for attachment in attachments:
path = paths.get(uuid.UUID(str(attachment["id"])))
if path is None:
resolved.append(
{key: value for key, value in attachment.items() if key != "file_path"}
)
else:
resolved.append({**attachment, "file_path": f"/{path.name}"})
return resolved


async def _download_missing(
paths: dict[uuid.UUID, Path], workspace: Path
) -> dict[uuid.UUID, Path]:
"""Fetch the attachments not already in the workspace, dropping those that fail."""
missing = {key: path for key, path in paths.items() if not path.exists()}
if not missing:
return paths

logger.info("Downloading %d message attachment(s) into %s", len(missing), workspace)
client = UiPath()
outcomes = await asyncio.gather(
*(
client.attachments.download_async(key=key, destination_path=str(path))
for key, path in missing.items()
),
return_exceptions=True,
)
downloaded = dict(paths)
for key, outcome in zip(missing, outcomes, strict=True):
if isinstance(outcome, BaseException):
logger.warning("Attachment %s could not be downloaded: %s", key, outcome)
# a failed download leaves a truncated file behind, which would then
# pass for a complete one on the next exchange
missing[key].unlink(missing_ok=True)
del downloaded[key]
return downloaded


async def resolve_message_attachments(
backend: BackendProtocol | None,
messages: Sequence[AnyMessage],
) -> list[AnyMessage]:
"""Download attachments referenced by messages and add their ``file_path``.

Each attachment is streamed to ``<backend.cwd>/<id>_<name>``, the layout
input attachments already use, and its entry in the message's attachment
block gains the path the agent's file tools can open. Files already in the
workspace are left alone, so replaying a conversation history downloads
nothing. An attachment that cannot be downloaded is left without a path
rather than failing the exchange. Returns only the messages that changed.
"""
candidates = [
message
for message in messages
if message.additional_kwargs.get("attachments")
and isinstance(message.content, list)
]
if not candidates:
return []
if not isinstance(backend, FilesystemBackend):
logger.warning(
"Message attachments stay unopenable: %s has no workspace to download into",
type(backend).__name__,
)
return []

paths: dict[uuid.UUID, Path] = {}
for message in candidates:
for attachment in message.additional_kwargs["attachments"]:
attachment_id = uuid.UUID(str(attachment["id"]))
paths[attachment_id] = backend.cwd / _workspace_file_name(
attachment_id, attachment["full_name"]
)

paths = await _download_missing(paths, backend.cwd)
return [
_with_attachments_block(
message, _with_file_paths(message.additional_kwargs["attachments"], paths)
)
for message in candidates
]
5 changes: 2 additions & 3 deletions src/uipath_langchain/runtime/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
)
from uipath.runtime import UiPathRuntimeStorageProtocol

from uipath_langchain._utils._attachments import render_attachments_block
from uipath_langchain.agent.contracts.client_side_tools import ClientSideToolInfo
from uipath_langchain.chat.hitl import IS_CONVERSATIONAL_CLIENT_SIDE_TOOL

Expand Down Expand Up @@ -208,9 +209,7 @@ def _map_messages_internal(
# Add attachment references as a text block for LLM visibility
if attachments:
content_blocks.append(
create_text_block(
f"<uip:attachments>{json.dumps(attachments)}</uip:attachments>"
)
create_text_block(render_attachments_block(attachments))
)

# Metadata for the user/assistant message
Expand Down
98 changes: 97 additions & 1 deletion tests/agent/advanced/test_conversational_advanced_agent_graph.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"""Tests for the conversational advanced agent wrapper builder."""

import uuid
from collections.abc import Sequence
from pathlib import Path
from typing import Any, cast
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from deepagents.backends import FilesystemBackend
from langchain.agents.middleware import ModelRequest, ModelResponse
from langchain_core.language_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableLambda
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field

from uipath_langchain._utils._attachments import render_attachments_block
from uipath_langchain.agent.advanced.agent import (
_RuntimeSystemPromptMiddleware,
create_conversational_advanced_agent_graph,
Expand Down Expand Up @@ -506,3 +510,95 @@ async def extract(messages: Any) -> dict[str, Any]:
return args

return extract


def _recording_inner_agent(seen: list[Any]) -> Any:
"""A stand-in deepagent that records the messages the wrapper handed it."""

def respond(state: ConversationalAdvancedAgentGraphState) -> dict[str, Any]:
seen.extend(state.messages)
return {"messages": [AIMessage(content="here is my plan", id="ai-1")]}

builder: StateGraph[Any, Any, Any, Any] = StateGraph(
ConversationalAdvancedAgentGraphState
)
builder.add_node("respond", respond)
builder.add_edge(START, "respond")
builder.add_edge("respond", END)
return builder.compile()


def _attachment_message(attachment_id: uuid.UUID) -> HumanMessage:
attachments = [
{
"id": str(attachment_id),
"full_name": "uipath_company_report.md",
"mime_type": "text/markdown",
}
]
return HumanMessage(
id="u1",
content_blocks=[
{"type": "text", "text": "can you read this file?"},
{"type": "text", "text": render_attachments_block(attachments)},
],
additional_kwargs={"attachments": attachments},
)


@pytest.mark.asyncio
async def test_chat_attachments_are_downloaded_and_pathed(tmp_path: Path) -> None:
"""A file attached in the chat reaches the workspace and the model sees its path."""
backend = FilesystemBackend(root_dir=tmp_path, virtual_mode=True)
attachment_id = uuid.uuid4()
seen: list[Any] = []

mock_client = MagicMock()
mock_client.attachments.download_async = AsyncMock()
with (
patch(
"uipath_langchain.agent.advanced.agent.create_advanced_agent",
return_value=_recording_inner_agent(seen),
),
patch(
"uipath_langchain.agent.advanced.utils.UiPath",
return_value=mock_client,
),
):
graph = create_conversational_advanced_agent_graph(
model=_mock_model(), tools=[], system_prompt="sys", backend=backend
).compile()
result = await graph.ainvoke({"messages": [_attachment_message(attachment_id)]})

expected_name = f"{attachment_id}_uipath_company_report.md"
assert mock_client.attachments.download_async.call_args.kwargs[
"destination_path"
] == str(backend.cwd / expected_name)

hydrated = next(message for message in seen if message.id == "u1")
assert hydrated.additional_kwargs["attachments"][0]["file_path"] == (
f"/{expected_name}"
)
assert f"/{expected_name}" in hydrated.content[1]["text"]
assert len(result["uipath__agent_response_messages"]) == 1


@pytest.mark.asyncio
async def test_chat_attachments_need_a_filesystem_backend() -> None:
"""Without a workspace the attachment block is passed through unchanged."""
attachment_id = uuid.uuid4()
message = _attachment_message(attachment_id)
seen: list[Any] = []

with patch(
"uipath_langchain.agent.advanced.agent.create_advanced_agent",
return_value=_recording_inner_agent(seen),
):
graph = create_conversational_advanced_agent_graph(
model=_mock_model(), tools=[], system_prompt="sys", backend=None
).compile()
await graph.ainvoke({"messages": [message]})

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]
Loading
Loading