Skip to content

Commit 9f5788e

Browse files
Merge pull request #388 from UiPath/feat/isolate-inner-state
feat: isolate inner schema to avoid name collisions
2 parents 3567f60 + 86e8867 commit 9f5788e

14 files changed

Lines changed: 327 additions & 57 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.3.1"
3+
version = "0.3.2"
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"

src/uipath_langchain/agent/exceptions/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from .exceptions import AgentNodeRoutingException, AgentTerminationException
1+
from .exceptions import (
2+
AgentNodeRoutingException,
3+
AgentTerminationException,
4+
)
25

36
__all__ = [
47
"AgentNodeRoutingException",

src/uipath_langchain/agent/react/agent.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,14 @@
3636

3737

3838
def create_state_with_input(input_schema: Type[InputT]):
39-
InnerAgentGraphState = type(
40-
"InnerAgentGraphState",
39+
CompleteAgentGraphState = type(
40+
"CompleteAgentGraphState",
4141
(AgentGraphState, input_schema),
4242
{},
4343
)
4444

45-
cast(type[BaseModel], InnerAgentGraphState).model_rebuild()
46-
return InnerAgentGraphState
45+
cast(type[BaseModel], CompleteAgentGraphState).model_rebuild()
46+
return CompleteAgentGraphState
4747

4848

4949
def create_agent(
@@ -85,12 +85,12 @@ def create_agent(
8585
)
8686
terminate_node = create_terminate_node(output_schema)
8787

88-
InnerAgentGraphState = create_state_with_input(
88+
CompleteAgentGraphState = create_state_with_input(
8989
input_schema if input_schema is not None else BaseModel
9090
)
9191

9292
builder: StateGraph[AgentGraphState, None, InputT, OutputT] = StateGraph(
93-
InnerAgentGraphState, input_schema=input_schema, output_schema=output_schema
93+
CompleteAgentGraphState, input_schema=input_schema, output_schema=output_schema
9494
)
9595
init_with_guardrails_subgraph = create_agent_init_guardrails_subgraph(
9696
(AgentGraphNode.GUARDED_INIT, init_node),

src/uipath_langchain/agent/react/init_node.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ def create_init_node(
1515
| Callable[[Any], Sequence[SystemMessage | HumanMessage]],
1616
input_schema: type[BaseModel] | None,
1717
):
18-
def graph_state_init(state: Any):
18+
def graph_state_init(state: Any) -> Any:
1919
if callable(messages):
2020
resolved_messages = messages(state)
2121
else:
@@ -29,7 +29,9 @@ def graph_state_init(state: Any):
2929

3030
return {
3131
"messages": list(resolved_messages),
32-
"job_attachments": job_attachments_dict,
32+
"inner_state": {
33+
"job_attachments": job_attachments_dict,
34+
},
3335
}
3436

3537
return graph_state_init
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Dict-like object reducers for merging state with field-specific reducers."""
2+
3+
from typing import Any
4+
5+
from pydantic import BaseModel
6+
from uipath.platform.attachments import Attachment
7+
8+
9+
def add_job_attachments(
10+
left: dict[str, Attachment], right: dict[str, Attachment]
11+
) -> dict[str, Attachment]:
12+
"""Merge attachment dictionaries, with right values taking precedence.
13+
14+
This reducer function merges two dictionaries of attachments by UUID string.
15+
If the same UUID exists in both dictionaries, the value from 'right' takes precedence.
16+
17+
Args:
18+
left: Existing dictionary of attachments keyed by UUID string
19+
right: New dictionary of attachments to merge
20+
21+
Returns:
22+
Merged dictionary with right values overriding left values for duplicate keys
23+
"""
24+
if not right:
25+
return left
26+
27+
if not left:
28+
return right
29+
30+
return {**left, **right}
31+
32+
33+
def merge_objects(left: Any, right: Any) -> Any:
34+
"""Merge a Pydantic model with another model or dict, with right values taking precedence.
35+
36+
Applies field-specific reducers from annotation metadata when merging values.
37+
38+
Args:
39+
left: Existing Pydantic BaseModel instance
40+
right: New Pydantic BaseModel instance or dict to merge
41+
42+
Returns:
43+
New Pydantic model instance with merged values
44+
45+
Raises:
46+
TypeError: If left is not a Pydantic BaseModel or right is not a BaseModel or dict
47+
"""
48+
if not right:
49+
return left
50+
51+
if not left:
52+
return right
53+
54+
# validate input types
55+
if not isinstance(left, BaseModel):
56+
raise TypeError("Left object must be a Pydantic BaseModel")
57+
58+
if not isinstance(right, (BaseModel, dict)):
59+
raise TypeError("Right object must be a Pydantic BaseModel or dict")
60+
61+
model_fields = type(left).model_fields
62+
merged_values = {}
63+
64+
for field_name in model_fields:
65+
merged_values[field_name] = getattr(left, field_name)
66+
67+
for field_name in model_fields:
68+
if isinstance(right, BaseModel):
69+
if hasattr(right, field_name):
70+
right_value = getattr(right, field_name)
71+
else:
72+
continue # field not present in right
73+
else:
74+
# right is dict
75+
if field_name not in right:
76+
continue # field not present in right
77+
right_value = right[field_name]
78+
79+
field_info = model_fields[field_name]
80+
left_value = merged_values[field_name]
81+
82+
# apply reducer if defined
83+
if field_info.metadata and callable(field_info.metadata[0]):
84+
reducer_func = field_info.metadata[0]
85+
merged_values[field_name] = reducer_func(left_value, right_value)
86+
else:
87+
merged_values[field_name] = right_value
88+
89+
# return new model instance with merged values
90+
return type(left)(**merged_values)

src/uipath_langchain/agent/react/terminate_node.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ def create_terminate_node(
5757
"""
5858

5959
def terminate_node(state: AgentGraphState):
60-
if state.termination:
61-
_handle_agent_termination(state.termination)
60+
if state.inner_state.termination:
61+
_handle_agent_termination(state.inner_state.termination)
6262

6363
last_message = state.messages[-1]
6464
if not isinstance(last_message, AIMessage):

src/uipath_langchain/agent/react/types.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pydantic import BaseModel, Field
77
from uipath.platform.attachments import Attachment
88

9-
from uipath_langchain.agent.react.utils import add_job_attachments
9+
from uipath_langchain.agent.react.reducers import add_job_attachments, merge_objects
1010

1111

1212
class AgentTerminationSource(StrEnum):
@@ -21,12 +21,18 @@ class AgentTermination(BaseModel):
2121
detail: str = ""
2222

2323

24+
class InnerAgentGraphState(BaseModel):
25+
job_attachments: Annotated[dict[str, Attachment], add_job_attachments] = {}
26+
termination: AgentTermination | None = None
27+
28+
2429
class AgentGraphState(BaseModel):
2530
"""Agent Graph state for standard loop execution."""
2631

2732
messages: Annotated[list[AnyMessage], add_messages] = []
28-
job_attachments: Annotated[dict[str, Attachment], add_job_attachments] = {}
29-
termination: AgentTermination | None = None
33+
inner_state: Annotated[InnerAgentGraphState, merge_objects] = Field(
34+
default_factory=InnerAgentGraphState
35+
)
3036

3137

3238
class AgentGuardrailsGraphState(AgentGraphState):

src/uipath_langchain/agent/react/utils.py

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from langchain_core.messages import AIMessage, BaseMessage
66
from pydantic import BaseModel
77
from uipath.agent.react import END_EXECUTION_TOOL
8-
from uipath.platform.attachments import Attachment
98

109
from uipath_langchain.agent.react.jsonschema_pydantic_converter import create_model
1110

@@ -49,27 +48,3 @@ def count_consecutive_thinking_messages(messages: Sequence[BaseMessage]) -> int:
4948
count += 1
5049

5150
return count
52-
53-
54-
def add_job_attachments(
55-
left: dict[str, Attachment], right: dict[str, Attachment]
56-
) -> dict[str, Attachment]:
57-
"""Merge attachment dictionaries, with right values taking precedence.
58-
59-
This reducer function merges two dictionaries of attachments by UUID string.
60-
If the same UUID exists in both dictionaries, the value from 'right' takes precedence.
61-
62-
Args:
63-
left: Existing dictionary of attachments keyed by UUID string
64-
right: New dictionary of attachments to merge
65-
66-
Returns:
67-
Merged dictionary with right values overriding left values for duplicate keys
68-
"""
69-
if not right:
70-
return left
71-
72-
if not left:
73-
return right
74-
75-
return {**left, **right}

src/uipath_langchain/agent/tools/escalation_tool.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,12 @@ async def escalation_wrapper(
114114
tool_call_id=call["id"],
115115
)
116116
],
117-
"termination": {
118-
"source": AgentTerminationSource.ESCALATION,
119-
"title": termination_title,
120-
"detail": output_detail,
117+
"inner_state": {
118+
"termination": {
119+
"source": AgentTerminationSource.ESCALATION,
120+
"title": termination_title,
121+
"detail": output_detail,
122+
}
121123
},
122124
},
123125
goto=AgentGraphNode.TERMINATE,

src/uipath_langchain/agent/wrappers/job_attachment_wrapper.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ async def job_attachment_wrapper(
5151
errors: list[str] = []
5252
paths = get_job_attachment_paths(tool.args_schema)
5353
modified_input_args = replace_job_attachment_ids(
54-
paths, input_args, state.job_attachments, errors
54+
paths, input_args, state.inner_state.job_attachments, errors
5555
)
5656

5757
if errors:

0 commit comments

Comments
 (0)