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.5.78"
version = "0.5.79"
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
2 changes: 1 addition & 1 deletion samples/chat-hitl-agent/graph.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from langchain_anthropic import ChatAnthropic
from langchain_tavily import TavilySearch
from langchain.agents import create_agent
from uipath_langchain.chat import requires_approval
from uipath_langchain.chat.tools import requires_approval

tavily_tool = TavilySearch(max_results=5)

Expand Down
3 changes: 3 additions & 0 deletions samples/file-attachments-chat-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
UIPATH_ACCESS_TOKEN=YOUR TOKEN HERE
UIPATH_URL=https://alpha.uipath.com/<organization>/<tenant>
OPENAI_API_KEY=your_openai_api_key
42 changes: 42 additions & 0 deletions samples/file-attachments-chat-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# File Attachments Chat Agent

An AI assistant that reads and analyzes file attachments shared in the conversation.

## Requirements

- Python 3.11+
- OpenAI API key

## Installation

```bash
uv venv -p 3.11 .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
uv sync
```

Set your API key as an environment variable in .env

```bash
OPENAI_API_KEY=your_openai_api_key
```

## Usage

**1.** Upload the file to Orchestrator using the [attachments API](https://uipath.github.io/uipath-python/core/attachments/).

**2.** Run the agent, passing the attachment ID and file metadata returned by the upload:

```bash
uipath run agent '{
"messages": [
{
"type": "human",
"content": [
{ "type": "text", "text": "Summarize this document." },
{ "type": "text", "text": "<uip:attachments>[{\"id\": \"{orchestrator_attachment_id}\", \"full_name\": \"{file_name}\", \"mime_type\": \"{file_mime_type}\"}]</uip:attachments>" }
]
}
]
}'
```
9 changes: 9 additions & 0 deletions samples/file-attachments-chat-agent/agent.mermaid
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
flowchart TB
__start__(__start__)
model(model)
tools(tools)
__end__(__end__)
__start__ --> model
model --> __end__
model --> tools
tools --> model
5 changes: 5 additions & 0 deletions samples/file-attachments-chat-agent/langgraph.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"graphs": {
"agent": "./main.py:graph"
}
}
32 changes: 32 additions & 0 deletions samples/file-attachments-chat-agent/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI

from uipath_langchain.chat.tools import AnalyzeAttachmentsTool

system_prompt = """
You are an AI assistant specialized in analyzing user-provided files using the available file analysis tool.
Always use the provided tool to read and analyze any uploaded or referenced file. Never guess or fabricate file contents. If a file is missing or inaccessible, ask the user to upload it again.

When a file is received:
1.Identify the file type.
2.Provide a clear, concise summary.
3.Extract key information relevant to the user’s request.
4.Highlight important patterns, issues, or insights when applicable.
5.If the user’s request is unclear, ask a focused clarification question before proceeding.

For follow-up questions:
1.Base all answers strictly on the file contents.
2.Maintain context across the conversation.
3.Perform deeper analysis, comparisons, transformations, or extractions as requested.
4.Clearly distinguish between observed facts and inferred insights. If something cannot be determined from the file, state that explicitly.

Keep responses structured, concise, and professional. Treat all file data as sensitive and do not retain or reuse it outside the current conversation.
"""

llm = ChatOpenAI(model="gpt-4.1")

graph = create_agent(
llm,
tools=[AnalyzeAttachmentsTool(llm=llm)],
system_prompt=system_prompt,
)
10 changes: 10 additions & 0 deletions samples/file-attachments-chat-agent/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[project]
name = "file-attachments-chat-agent"
version = "0.0.1"
description = "file-attachments-chat-agent"
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
dependencies = [
"langchain-openai>=1.1.9",
"uipath-langchain",
]
requires-python = ">=3.11"
14 changes: 14 additions & 0 deletions samples/file-attachments-chat-agent/uipath.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://cloud.uipath.com/draft/2024-12/uipath",
"runtimeOptions": {
"isConversational": true
},
"packOptions": {
"fileExtensionsIncluded": [],
"filesIncluded": [],
"filesExcluded": [],
"directoriesExcluded": [],
"includeUvLock": true
},
"functions": {}
}
12 changes: 11 additions & 1 deletion src/uipath_langchain/chat/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,17 @@ def __getattr__(name):

return UiPathChatOpenAI
if name == "requires_approval":
from .hitl import requires_approval
from .tools.hitl import requires_approval

return requires_approval
if name == "resolve_attachments":
from .tools.attachments import resolve_attachments

return resolve_attachments
if name == "AnalyzeAttachmentsTool":
from .tools.attachments import AnalyzeAttachmentsTool

return AnalyzeAttachmentsTool
if name in ("OpenAIModels", "BedrockModels", "GeminiModels"):
from . import supported_models

Expand All @@ -51,4 +59,6 @@ def __getattr__(name):
"LLMProvider",
"APIFlavor",
"requires_approval",
"resolve_attachments",
"AnalyzeAttachmentsTool",
]
21 changes: 21 additions & 0 deletions src/uipath_langchain/chat/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
def __getattr__(name):
if name == "AnalyzeAttachmentsTool":
from .attachments import AnalyzeAttachmentsTool

return AnalyzeAttachmentsTool
if name == "requires_approval":
from .hitl import requires_approval

return requires_approval
if name == "resolve_attachments":
from .attachments import resolve_attachments

return resolve_attachments
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


__all__ = [
"AnalyzeAttachmentsTool",
"requires_approval",
"resolve_attachments",
]
211 changes: 211 additions & 0 deletions src/uipath_langchain/chat/tools/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"""Attachment resolution for conversational agents."""

import asyncio
import json
import re
import uuid
from typing import Any, Type, cast

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import (
AnyMessage,
ContentBlock,
HumanMessage,
SystemMessage,
)
from langchain_core.runnables.config import var_child_runnable_config
from langchain_core.tools import BaseTool
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
from uipath.platform import UiPath
from uipath.platform.attachments import Attachment

from uipath_langchain.agent.multimodal import FileInfo, build_file_content_block
from uipath_langchain.chat.helpers import (
append_content_blocks_to_message,
extract_text_content,
)

_ATTACHMENTS_PATTERN = re.compile(
r"<uip:attachments>(.*?)</uip:attachments>", re.DOTALL
)

_READ_ATTACHMENTS_SYSTEM_MESSAGE = (
"Process the provided files to complete the given task. "
"Analyze the files contents thoroughly to deliver an accurate response "
"based on the extracted information."
)


async def resolve_attachments(messages: list[AnyMessage]) -> list[AnyMessage]:
"""Resolve file attachments embedded in conversational messages.

UiPath's runtime injects attachment metadata as a ``<uip:attachments>``
text block into HumanMessages. This function resolves those references to
actual file content blocks so the LLM can read the files.

Messages that contain no ``<uip:attachments>`` tag are returned unchanged,
so it is safe to call this on the full message history on every turn.

To persist resolved content to the checkpointer (avoiding re-downloads on
subsequent turns), include the resolved messages in the node's return value
alongside the LLM response. LangGraph's ``add_messages`` reducer replaces
messages with matching IDs rather than appending them::

async def call_model(state: MessagesState):
resolved = await resolve_attachments(state["messages"])
response = await llm.ainvoke([SystemMessage(...)] + resolved)
return {"messages": resolved + [response]}

Args:
messages: List of LangChain messages from the agent state.

Returns:
New list where HumanMessages with attachments have their
``<uip:attachments>`` text replaced with DataContentBlock items
containing the actual file content.
"""
return list(await asyncio.gather(*[_resolve_message(m) for m in messages]))


async def _resolve_message(message: AnyMessage) -> AnyMessage:
if not isinstance(message, HumanMessage):
return message

content = message.content
if not isinstance(content, list):
return message

clean_blocks: list[Any] = []
file_infos: list[FileInfo] = []

for block in content:
if not isinstance(block, dict) or block.get("type") != "text":
clean_blocks.append(block)
continue

text = block.get("text", "")
match = _ATTACHMENTS_PATTERN.search(text)
if not match:
clean_blocks.append(block)
continue

# Parse attachment metadata
attachments: list[dict[str, Any]] = json.loads(match.group(1))
file_infos.extend(await _resolve_file_infos(attachments))

# Preserve any text outside the tag
remaining = _ATTACHMENTS_PATTERN.sub("", text).strip()
if remaining:
clean_blocks.append({"type": "text", "text": remaining})

if not file_infos:
return message

file_blocks = list(
await asyncio.gather(*[build_file_content_block(fi) for fi in file_infos])
)

return HumanMessage(
id=message.id,
content=clean_blocks + file_blocks,
additional_kwargs=message.additional_kwargs,
)


async def _resolve_file_infos(
attachments: list[dict[str, Any]],
) -> list[FileInfo]:
client = UiPath()
file_infos: list[FileInfo] = []

for att in attachments:
att_id = att.get("id")
if not att_id:
continue

blob_info = await client.attachments.get_blob_file_access_uri_async(
key=uuid.UUID(att_id)
)
file_infos.append(
FileInfo(
url=blob_info.uri,
name=blob_info.name,
mime_type=att.get("mime_type", ""),
)
)

return file_infos


class AnalyzeAttachmentsInput(BaseModel):
"""Input schema for the AnalyzeAttachmentsTool."""

model_config = ConfigDict(arbitrary_types_allowed=True)

query: str = Field(description="What you want to know about or do with the files.")
attachments: list[Attachment] = Field(
description="The attachment objects from inside the <uip:attachments> tag."
)


class AnalyzeAttachmentsTool(BaseTool):
"""Tool that reads and interprets file attachments using the provided LLM.

The tool downloads each attachment, passes the file content to a non-streaming
copy of the provided LLM for interpretation, and returns the result as text.
This keeps multimodal content out of the agent's message state — the original
``<uip:attachments>`` metadata in HumanMessages is never modified.

Example::

from langchain_openai import ChatOpenAI
from uipath_langchain.chat import AnalyzeAttachmentsTool

llm = ChatOpenAI(model="gpt-4.1")
tool = AnalyzeAttachmentsTool(llm=llm)
"""

name: str = "analyze_attachments"
description: str = (
"Read and interpret the content of file attachments provided by the user. "
"Call this when you see a <uip:attachments> tag in a user message, passing "
"the attachment objects from inside the tag and a query describing what you "
"want to know about or do with the files."
)
args_schema: Type[BaseModel] = AnalyzeAttachmentsInput

llm: BaseChatModel

_non_streaming_llm: BaseChatModel = PrivateAttr()

def model_post_init(self, __context: Any) -> None:
self._non_streaming_llm = self.llm.model_copy(
update={"disable_streaming": True}
)

def _run(self, query: str, attachments: list[Attachment]) -> dict[str, str]:
raise NotImplementedError("Use async version via arun()")

async def _arun(self, query: str, attachments: list[Attachment]) -> dict[str, str]:
file_infos = await _resolve_file_infos(
[a.model_dump(mode="json") for a in attachments]
)
if not file_infos:
return {"analysisResult": "No attachments provided to analyze."}

file_blocks = list(
await asyncio.gather(*[build_file_content_block(fi) for fi in file_infos])
)

human_message_with_files = append_content_blocks_to_message(
HumanMessage(content=query), cast(list[ContentBlock], file_blocks)
)

messages = [
SystemMessage(content=_READ_ATTACHMENTS_SYSTEM_MESSAGE),
human_message_with_files,
]

config = var_child_runnable_config.get(None)
result = await self._non_streaming_llm.ainvoke(messages, config=config)
return {"analysisResult": extract_text_content(result)}
Loading