Skip to content

Commit 316002d

Browse files
feat: add a new internal analyze_files tool with supporting infrastructure for file attachment handling (#368)
* feat: add jsonchema pydantic converter * fix: tool args * fix: refactor code * fix: update uipath and jsonschema-pydantic-converter versions * fix: refactored code * fix: linting issues * fix: linting issues * fix: linting issues * fix: ruff format issues * fix: address PR comments * fix: pr comments * fix: increment package version * feat: resolve job attachments and call llm with files * fix: add unit tests * fix: linting errors
1 parent 1540956 commit 316002d

24 files changed

+2349
-42
lines changed

pyproject.toml

Lines changed: 9 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.1.43"
3+
version = "0.1.44"
44
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"
77
dependencies = [
8-
"uipath>=2.2.41, <2.3.0",
8+
"uipath>=2.2.44, <2.3.0",
99
"langgraph>=1.0.0, <2.0.0",
1010
"langchain-core>=1.0.0, <2.0.0",
1111
"aiosqlite==0.21.0",
@@ -16,7 +16,7 @@ dependencies = [
1616
"python-dotenv>=1.0.1",
1717
"httpx>=0.27.0",
1818
"openinference-instrumentation-langchain>=0.1.56",
19-
"jsonschema-pydantic-converter>=0.1.5",
19+
"jsonschema-pydantic-converter>=0.1.6",
2020
"jsonpath-ng>=1.7.0",
2121
"mcp==1.24.0",
2222
"langchain-mcp-adapters==0.2.1",
@@ -31,18 +31,12 @@ classifiers = [
3131
]
3232
maintainers = [
3333
{ name = "Marius Cosareanu", email = "[email protected]" },
34-
{ name = "Cristian Pufu", email = "[email protected]" }
34+
{ name = "Cristian Pufu", email = "[email protected]" },
3535
]
3636

3737
[project.optional-dependencies]
38-
vertex = [
39-
"langchain-google-genai>=2.0.0",
40-
"google-generativeai>=0.8.0",
41-
]
42-
bedrock = [
43-
"langchain-aws>=0.2.35",
44-
"boto3-stubs>=1.41.4",
45-
]
38+
vertex = ["langchain-google-genai>=2.0.0", "google-generativeai>=0.8.0"]
39+
bedrock = ["langchain-aws>=0.2.35", "boto3-stubs>=1.41.4"]
4640

4741
[project.entry-points."uipath.middlewares"]
4842
register = "uipath_langchain.middlewares:register_middleware"
@@ -69,7 +63,7 @@ dev = [
6963
"pytest-asyncio>=1.0.0",
7064
"pre-commit>=4.1.0",
7165
"numpy>=1.24.0",
72-
"pytest_httpx>=0.35.0"
66+
"pytest_httpx>=0.35.0",
7367
]
7468

7569
[tool.hatch.build.targets.wheel]
@@ -95,13 +89,8 @@ skip-magic-trailing-comma = false
9589
line-ending = "auto"
9690

9791
[tool.mypy]
98-
plugins = [
99-
"pydantic.mypy"
100-
]
101-
exclude = [
102-
"samples/.*",
103-
"testcases/.*"
104-
]
92+
plugins = ["pydantic.mypy"]
93+
exclude = ["samples/.*", "testcases/.*"]
10594

10695
follow_imports = "silent"
10796
warn_redundant_casts = true

src/uipath_langchain/agent/react/agent.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def create_agent(
7676
flow_control_tools: list[BaseTool] = create_flow_control_tools(output_schema)
7777
llm_tools: list[BaseTool] = [*agent_tools, *flow_control_tools]
7878

79-
init_node = create_init_node(messages)
79+
init_node = create_init_node(messages, input_schema)
8080
tool_nodes = create_tool_node(agent_tools)
8181
tool_nodes_with_guardrails = create_tools_guardrails_subgraph(
8282
tool_nodes, guardrails

src/uipath_langchain/agent/react/init_node.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,33 @@
33
from typing import Any, Callable, Sequence
44

55
from langchain_core.messages import HumanMessage, SystemMessage
6+
from pydantic import BaseModel
7+
8+
from .job_attachments import (
9+
get_job_attachments,
10+
)
611

712

813
def create_init_node(
914
messages: Sequence[SystemMessage | HumanMessage]
1015
| Callable[[Any], Sequence[SystemMessage | HumanMessage]],
16+
input_schema: type[BaseModel] | None,
1117
):
1218
def graph_state_init(state: Any):
1319
if callable(messages):
1420
resolved_messages = messages(state)
1521
else:
1622
resolved_messages = messages
1723

18-
return {"messages": list(resolved_messages)}
24+
schema = input_schema if input_schema is not None else BaseModel
25+
job_attachments = get_job_attachments(schema, state)
26+
job_attachments_dict = {
27+
str(att.id): att for att in job_attachments if att.id is not None
28+
}
29+
30+
return {
31+
"messages": list(resolved_messages),
32+
"job_attachments": job_attachments_dict,
33+
}
1934

2035
return graph_state_init
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Job attachment utilities for ReAct Agent."""
2+
3+
import copy
4+
import uuid
5+
from typing import Any
6+
7+
from jsonpath_ng import parse # type: ignore[import-untyped]
8+
from pydantic import BaseModel
9+
from uipath.platform.attachments import Attachment
10+
11+
from .json_utils import extract_values_by_paths, get_json_paths_by_type
12+
13+
14+
def get_job_attachments(
15+
schema: type[BaseModel],
16+
data: dict[str, Any] | BaseModel,
17+
) -> list[Attachment]:
18+
"""Extract job attachments from data based on schema and convert to Attachment objects.
19+
20+
Args:
21+
schema: The Pydantic model class defining the data structure
22+
data: The data object (dict or Pydantic model) to extract attachments from
23+
24+
Returns:
25+
List of Attachment objects
26+
"""
27+
job_attachment_paths = get_job_attachment_paths(schema)
28+
job_attachments = extract_values_by_paths(data, job_attachment_paths)
29+
30+
result = []
31+
for attachment in job_attachments:
32+
result.append(Attachment.model_validate(attachment, from_attributes=True))
33+
34+
return result
35+
36+
37+
def get_job_attachment_paths(model: type[BaseModel]) -> list[str]:
38+
"""Get JSONPath expressions for all job attachment fields in a Pydantic model.
39+
40+
Args:
41+
model: The Pydantic model class to analyze
42+
43+
Returns:
44+
List of JSONPath expressions pointing to job attachment fields
45+
"""
46+
return get_json_paths_by_type(model, "Job_attachment")
47+
48+
49+
def replace_job_attachment_ids(
50+
json_paths: list[str],
51+
tool_args: dict[str, Any],
52+
state: dict[str, Attachment],
53+
errors: list[str],
54+
) -> dict[str, Any]:
55+
"""Replace job attachment IDs in tool_args with full attachment objects from state.
56+
57+
For each JSON path, this function finds matching objects in tool_args and
58+
replaces them with corresponding attachment objects from state. The matching
59+
is done by looking up the object's 'ID' field in the state dictionary.
60+
61+
If an ID is not a valid UUID or is not present in state, an error message
62+
is added to the errors list.
63+
64+
Args:
65+
json_paths: List of JSONPath expressions (e.g., ["$.attachment", "$.attachments[*]"])
66+
tool_args: The dictionary containing tool arguments to modify
67+
state: Dictionary mapping attachment UUID strings to Attachment objects
68+
errors: List to collect error messages for invalid or missing IDs
69+
70+
Returns:
71+
Modified copy of tool_args with attachment IDs replaced by full objects
72+
73+
Example:
74+
>>> state = {
75+
... "123e4567-e89b-12d3-a456-426614174000": Attachment(id="123e4567-e89b-12d3-a456-426614174000", name="file1.pdf"),
76+
... "223e4567-e89b-12d3-a456-426614174001": Attachment(id="223e4567-e89b-12d3-a456-426614174001", name="file2.pdf")
77+
... }
78+
>>> tool_args = {
79+
... "attachment": {"ID": "123"},
80+
... "other_field": "value"
81+
... }
82+
>>> paths = ['$.attachment']
83+
>>> errors = []
84+
>>> replace_job_attachment_ids(paths, tool_args, state, errors)
85+
{'attachment': {'ID': '123', 'name': 'file1.pdf', ...}, 'other_field': 'value'}
86+
"""
87+
result = copy.deepcopy(tool_args)
88+
89+
for json_path in json_paths:
90+
expr = parse(json_path)
91+
matches = expr.find(result)
92+
93+
for match in matches:
94+
current_value = match.value
95+
96+
if isinstance(current_value, dict) and "ID" in current_value:
97+
attachment_id_str = str(current_value["ID"])
98+
99+
try:
100+
uuid.UUID(attachment_id_str)
101+
except (ValueError, AttributeError):
102+
errors.append(
103+
_create_job_attachment_error_message(attachment_id_str)
104+
)
105+
continue
106+
107+
if attachment_id_str in state:
108+
replacement_value = state[attachment_id_str]
109+
match.full_path.update(
110+
result, replacement_value.model_dump(by_alias=True, mode="json")
111+
)
112+
else:
113+
errors.append(
114+
_create_job_attachment_error_message(attachment_id_str)
115+
)
116+
117+
return result
118+
119+
120+
def _create_job_attachment_error_message(attachment_id_str: str) -> str:
121+
return (
122+
f"Could not find JobAttachment with ID='{attachment_id_str}'. "
123+
f"Try invoking the tool again and please make sure that you pass "
124+
f"valid JobAttachment IDs associated with existing JobAttachments in the current context."
125+
)

0 commit comments

Comments
 (0)