Skip to content

Commit 4070c0b

Browse files
authored
fix: improve graph schema detection (#576)
1 parent 23dd8a4 commit 4070c0b

4 files changed

Lines changed: 150 additions & 93 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
[project]
22
name = "uipath-langchain"
3-
version = "0.5.58"
3+
version = "0.5.59"
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.8.23,<2.9.0",
9-
"uipath-runtime>=0.8.0, <0.9.0",
8+
"uipath>=2.8.27, <2.9.0",
9+
"uipath-runtime>=0.8.2, <0.9.0",
1010
"langgraph>=1.0.0, <2.0.0",
1111
"langchain-core>=1.2.11, <2.0.0",
1212
"langgraph-checkpoint-sqlite>=3.0.3, <4.0.0",

src/uipath_langchain/runtime/runtime.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ async def stream(
134134
stream_mode=["messages", "updates"],
135135
subgraphs=True,
136136
):
137-
_, chunk_type, data = stream_chunk
137+
namespace, chunk_type, data = stream_chunk
138138

139139
# Emit UiPathRuntimeMessageEvent for messages
140140
if chunk_type == "messages":
@@ -172,6 +172,10 @@ async def stream(
172172
if isinstance(agent_data, dict)
173173
else {},
174174
node_name=node_name,
175+
qualified_node_name=self._build_node_name(
176+
namespace,
177+
node_name,
178+
),
175179
)
176180
yield state_event
177181

@@ -454,6 +458,47 @@ def _is_middleware_node(self, node_name: str) -> bool:
454458
"""Check if a node name represents a middleware node."""
455459
return node_name in self._middleware_node_names
456460

461+
def _build_node_name(self, namespace: Any, node_name: str) -> str:
462+
"""Build a fully qualified node name with subgraph prefix from the namespace.
463+
464+
When streaming with ``subgraphs=True``, LangGraph provides a namespace
465+
tuple that identifies the subgraph hierarchy a node belongs to. This
466+
method extracts the subgraph names and prepends them to the node name.
467+
468+
Args:
469+
namespace: A tuple representing the subgraph hierarchy.
470+
- () for the root graph.
471+
- ("subgraph_name:node_id",) for a single-level subgraph.
472+
- ("subgraph_name:node_id", "nested:node_id") for nested subgraphs.
473+
node_name: The name of the node within its graph.
474+
475+
Returns:
476+
The fully qualified node name. For example:
477+
- "agent" when called from the root graph.
478+
- "coder:generate" when called from the *coder* subgraph.
479+
- "coder:debugger:analyze" when called from *debugger* nested inside *coder*.
480+
"""
481+
if not namespace:
482+
return node_name
483+
if not isinstance(namespace, (tuple, list)):
484+
return node_name
485+
parts = []
486+
for ns in namespace:
487+
if not isinstance(ns, str):
488+
continue
489+
if not ns:
490+
continue
491+
# Extract subgraph name (part before ':'), fall back to full string
492+
part = ns.split(":")[0] if ":" in ns else ns
493+
if part:
494+
parts.append(part)
495+
496+
if not parts:
497+
return node_name
498+
499+
prefix = ":".join(parts)
500+
return f"{prefix}:{node_name}"
501+
457502
async def dispose(self) -> None:
458503
"""Cleanup runtime resources."""
459504
pass

src/uipath_langchain/runtime/schema.py

Lines changed: 92 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,24 @@
2626

2727
T = TypeVar("T")
2828

29+
# Known attribute names on LangChain/LangGraph runnables that typically
30+
# hold nested Runnable instances. Scanning only these avoids the cost of
31+
# iterating over *every* attribute returned by vars().
32+
_KNOWN_RUNNABLE_ATTRS = (
33+
"bound",
34+
"first",
35+
"middle",
36+
"last",
37+
"steps",
38+
"runnable",
39+
"default",
40+
"fallbacks",
41+
"branches",
42+
)
43+
44+
# Attribute names used to find the wrapped function inside runnables.
45+
_FUNC_ATTR_NAMES = ("func", "_func", "afunc", "_afunc")
46+
2947

3048
@dataclass
3149
class SchemaDetails:
@@ -66,7 +84,7 @@ def _unwrap_runnable_callable(
6684

6785
# 2) Generic LangChain function-wrapping runnables
6886
if func is None:
69-
for attr_name in ("func", "_func", "afunc", "_afunc"):
87+
for attr_name in _FUNC_ATTR_NAMES:
7088
maybe = getattr(runnable, attr_name, None)
7189
if callable(maybe):
7290
func = maybe
@@ -84,116 +102,108 @@ def _unwrap_runnable_callable(
84102
if found is not None:
85103
return found
86104

87-
# 4) Deep-scan attributes, including nested runnables / containers
88-
def _scan_value(value: Any) -> T | None:
89-
if isinstance(value, target_type):
90-
return value
91-
if isinstance(value, Runnable):
92-
return _unwrap_runnable_callable(value, target_type, _seen)
93-
if isinstance(value, dict):
94-
for v in value.values():
95-
found = _scan_value(v)
96-
if found is not None:
97-
return found
98-
# Handle lists, tuples, sets, etc. but avoid strings/bytes
99-
if isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
100-
for item in value:
101-
found = _scan_value(item)
102-
if found is not None:
103-
return found
104-
return None
105-
106-
try:
107-
attrs = vars(runnable)
108-
except TypeError:
109-
attrs = {}
110-
111-
for value in attrs.values():
112-
found = _scan_value(value)
105+
# 4) Scan only known runnable-holding attributes instead of all vars()
106+
for attr_name in _KNOWN_RUNNABLE_ATTRS:
107+
value = getattr(runnable, attr_name, None)
108+
if value is None:
109+
continue
110+
found = _scan_value(value, target_type, _seen)
113111
if found is not None:
114112
return found
115113

116114
return None
117115

118116

119-
def _get_node_type(node: Node) -> str:
120-
"""Determine the type of a LangGraph node using strongly-typed isinstance checks.
117+
def _scan_value(
118+
value: Any,
119+
target_type: type[T],
120+
_seen: set[int],
121+
) -> T | None:
122+
"""Recursively scan a value for an instance of target_type."""
123+
if isinstance(value, target_type):
124+
return value
125+
if isinstance(value, Runnable):
126+
return _unwrap_runnable_callable(value, target_type, _seen)
127+
if isinstance(value, dict):
128+
for v in value.values():
129+
found = _scan_value(v, target_type, _seen)
130+
if found is not None:
131+
return found
132+
# Handle lists, tuples, sets, etc. but avoid strings/bytes
133+
elif isinstance(value, Iterable) and not isinstance(value, (str, bytes)):
134+
for item in value:
135+
found = _scan_value(item, target_type, _seen)
136+
if found is not None:
137+
return found
138+
return None
139+
140+
141+
def _extract_model_metadata(model: BaseLanguageModel[Any]) -> dict[str, Any]:
142+
"""Extract metadata from a language model instance.
121143
122144
Args:
123-
node: A Node object from the graph
145+
model: A language model instance
124146
125147
Returns:
126-
String representing the node type
148+
Dictionary containing model metadata
127149
"""
128-
if node.id in ("__start__", "__end__"):
129-
return node.id
150+
metadata: dict[str, Any] = {}
130151

131-
if node.data is None:
132-
return "node"
152+
if hasattr(model, "model") and isinstance(model.model, str):
153+
metadata["model_name"] = model.model
154+
elif hasattr(model, "model_name") and model.model_name:
155+
metadata["model_name"] = model.model_name
133156

134-
if not isinstance(node.data, Runnable):
135-
return "node"
157+
if hasattr(model, "temperature") and model.temperature is not None:
158+
metadata["temperature"] = model.temperature
136159

137-
tool_node = _unwrap_runnable_callable(node.data, ToolNode)
138-
if tool_node is not None:
139-
return "tool"
140-
141-
chat_model = _unwrap_runnable_callable(node.data, BaseChatModel) # type: ignore[type-abstract]
142-
if chat_model is not None:
143-
return "model"
144-
145-
language_model = _unwrap_runnable_callable(node.data, BaseLanguageModel) # type: ignore[type-abstract]
146-
if language_model is not None:
147-
return "model"
160+
if hasattr(model, "max_tokens") and model.max_tokens is not None:
161+
metadata["max_tokens"] = model.max_tokens
162+
elif (
163+
hasattr(model, "max_completion_tokens")
164+
and model.max_completion_tokens is not None
165+
):
166+
metadata["max_tokens"] = model.max_completion_tokens
148167

149-
return "node"
168+
return metadata
150169

151170

152-
def _get_node_metadata(node: Node) -> dict[str, Any]:
153-
"""Extract metadata from a node in a type-safe manner.
171+
def _get_node_info(node: Node) -> tuple[str, dict[str, Any]]:
172+
"""Determine the type and metadata of a LangGraph node in a single pass.
154173
155174
Args:
156175
node: A Node object from the graph
157176
158177
Returns:
159-
Dictionary containing node metadata
178+
Tuple of (node_type string, metadata dict)
160179
"""
161-
if node.data is None:
162-
return {}
163-
164-
# Early return if data is not a Runnable
165-
if not isinstance(node.data, Runnable):
166-
return {}
180+
if node.id in ("__start__", "__end__"):
181+
return node.id, {}
167182

168-
metadata: dict[str, Any] = {}
183+
if node.data is None or not isinstance(node.data, Runnable):
184+
return "node", {}
169185

186+
# Check for ToolNode
170187
tool_node = _unwrap_runnable_callable(node.data, ToolNode)
171188
if tool_node is not None:
189+
metadata: dict[str, Any] = {}
172190
if hasattr(tool_node, "_tools_by_name"):
173191
tools_by_name = tool_node._tools_by_name
174192
metadata["tool_names"] = list(tools_by_name.keys())
175193
metadata["tool_count"] = len(tools_by_name)
176-
return metadata
194+
return "tool", metadata
177195

196+
# Check for ChatModel (subclass of BaseLanguageModel, check first)
178197
chat_model = _unwrap_runnable_callable(node.data, BaseChatModel) # type: ignore[type-abstract]
179198
if chat_model is not None:
180-
if hasattr(chat_model, "model") and isinstance(chat_model.model, str):
181-
metadata["model_name"] = chat_model.model
182-
elif hasattr(chat_model, "model_name") and chat_model.model_name:
183-
metadata["model_name"] = chat_model.model_name
184-
185-
if hasattr(chat_model, "temperature") and chat_model.temperature is not None:
186-
metadata["temperature"] = chat_model.temperature
187-
188-
if hasattr(chat_model, "max_tokens") and chat_model.max_tokens is not None:
189-
metadata["max_tokens"] = chat_model.max_tokens
190-
elif (
191-
hasattr(chat_model, "max_completion_tokens")
192-
and chat_model.max_completion_tokens is not None
193-
):
194-
metadata["max_tokens"] = chat_model.max_completion_tokens
199+
return "model", _extract_model_metadata(chat_model)
195200

196-
return metadata
201+
# Check for other language models
202+
language_model = _unwrap_runnable_callable(node.data, BaseLanguageModel) # type: ignore[type-abstract]
203+
if language_model is not None:
204+
return "model", _extract_model_metadata(language_model)
205+
206+
return "node", {}
197207

198208

199209
def _convert_graph_to_uipath(graph: Graph) -> UiPathRuntimeGraph:
@@ -207,12 +217,13 @@ def _convert_graph_to_uipath(graph: Graph) -> UiPathRuntimeGraph:
207217
"""
208218
nodes: list[UiPathRuntimeNode] = []
209219
for _, node in graph.nodes.items():
220+
node_type, metadata = _get_node_info(node)
210221
nodes.append(
211222
UiPathRuntimeNode(
212223
id=node.id,
213224
name=node.name or node.id,
214-
type=_get_node_type(node),
215-
metadata=_get_node_metadata(node),
225+
type=node_type,
226+
metadata=metadata,
216227
subgraph=None,
217228
)
218229
)
@@ -253,13 +264,14 @@ def get_graph_schema(
253264

254265
nodes: list[UiPathRuntimeNode] = []
255266
for node_id, node in graph.nodes.items():
267+
node_type, metadata = _get_node_info(node)
256268
subgraph: UiPathRuntimeGraph | None = subgraphs_dict.get(node_id)
257269
nodes.append(
258270
UiPathRuntimeNode(
259271
id=node.id,
260272
name=node.name or node.id,
261-
type=_get_node_type(node),
262-
metadata=_get_node_metadata(node),
273+
type=node_type,
274+
metadata=metadata,
263275
subgraph=subgraph,
264276
)
265277
)

uv.lock

Lines changed: 9 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)