2626
2727T = 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
3149class 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
199209def _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 )
0 commit comments