uipath-langchain 0.0.140__py3-none-any.whl → 0.0.142__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of uipath-langchain might be problematic. Click here for more details.

@@ -1,24 +1,32 @@
1
1
  import logging
2
2
  import os
3
- from typing import Any, List, Optional, Sequence
3
+ from contextlib import asynccontextmanager
4
+ from typing import Any, AsyncGenerator, AsyncIterator, Optional, Sequence
4
5
 
5
- from langchain_core.callbacks.base import BaseCallbackHandler
6
- from langchain_core.messages import BaseMessage
7
6
  from langchain_core.runnables.config import RunnableConfig
8
7
  from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
9
8
  from langgraph.errors import EmptyInputError, GraphRecursionError, InvalidUpdateError
9
+ from langgraph.graph.state import CompiledStateGraph, StateGraph
10
+ from langgraph.types import Interrupt, StateSnapshot
10
11
  from uipath._cli._runtime._contracts import (
11
12
  UiPathBaseRuntime,
13
+ UiPathBreakpointResult,
12
14
  UiPathErrorCategory,
15
+ UiPathResumeTrigger,
13
16
  UiPathRuntimeResult,
17
+ UiPathRuntimeStatus,
18
+ )
19
+ from uipath._events._events import (
20
+ UiPathAgentMessageEvent,
21
+ UiPathAgentStateEvent,
22
+ UiPathRuntimeEvent,
14
23
  )
15
24
 
16
25
  from ._context import LangGraphRuntimeContext
17
- from ._conversation import map_message
18
26
  from ._exception import LangGraphRuntimeError
19
27
  from ._graph_resolver import AsyncResolver, LangGraphJsonResolver
20
- from ._input import LangGraphInputProcessor
21
- from ._output import LangGraphOutputProcessor
28
+ from ._input import get_graph_input
29
+ from ._output import create_and_save_resume_trigger, serialize_output
22
30
 
23
31
  logger = logging.getLogger(__name__)
24
32
 
@@ -33,171 +41,221 @@ class LangGraphRuntime(UiPathBaseRuntime):
33
41
  super().__init__(context)
34
42
  self.context: LangGraphRuntimeContext = context
35
43
  self.graph_resolver: AsyncResolver = graph_resolver
44
+ self.resume_triggers_table: str = "__uipath_resume_triggers"
36
45
 
37
- async def execute(self) -> Optional[UiPathRuntimeResult]:
46
+ @asynccontextmanager
47
+ async def _get_or_create_memory(self) -> AsyncIterator[AsyncSqliteSaver]:
38
48
  """
39
- Execute the graph with the provided input and configuration.
49
+ Get existing memory from context or create a new one.
40
50
 
41
- Returns:
42
- Dictionary with execution results
51
+ If memory is created, it will be automatically disposed at the end.
52
+ If memory already exists in context, it will be reused without disposal.
43
53
 
44
- Raises:
45
- LangGraphRuntimeError: If execution fails
54
+ Yields:
55
+ AsyncSqliteSaver instance
46
56
  """
57
+ # Check if memory already exists in context
58
+ if self.context.memory is not None:
59
+ # Use existing memory, don't dispose
60
+ yield self.context.memory
61
+ else:
62
+ # Create new memory and dispose at the end
63
+ async with AsyncSqliteSaver.from_conn_string(
64
+ self.state_file_path
65
+ ) as memory:
66
+ yield memory
67
+ # Memory is automatically disposed by the context manager
47
68
 
69
+ async def execute(self) -> Optional[UiPathRuntimeResult]:
70
+ """Execute the graph with the provided input and configuration."""
48
71
  graph = await self.graph_resolver()
49
72
  if not graph:
50
73
  return None
51
74
 
52
75
  try:
53
- async with AsyncSqliteSaver.from_conn_string(
54
- self.state_file_path
55
- ) as memory:
56
- self.context.memory = memory
76
+ async with self._get_or_create_memory() as memory:
77
+ compiled_graph = await self._setup_graph(memory, graph)
78
+ graph_input = await self._get_graph_input(memory)
79
+ graph_config = self._get_graph_config()
57
80
 
58
- # Compile the graph with the checkpointer
59
- compiled_graph = graph.compile(checkpointer=self.context.memory)
81
+ # Execute without streaming
82
+ graph_output = await compiled_graph.ainvoke(graph_input, graph_config)
60
83
 
61
- # Process input, handling resume if needed
62
- input_processor = LangGraphInputProcessor(context=self.context)
84
+ # Get final state and create result
85
+ self.context.result = await self._create_runtime_result(
86
+ compiled_graph, graph_config, memory, graph_output
87
+ )
63
88
 
64
- processed_input = await input_processor.process()
89
+ return self.context.result
65
90
 
66
- callbacks: List[BaseCallbackHandler] = []
91
+ except Exception as e:
92
+ raise self._create_runtime_error(e) from e
67
93
 
68
- graph_config: RunnableConfig = {
69
- "configurable": {
70
- "thread_id": (
71
- self.context.execution_id
72
- or self.context.job_id
73
- or "default"
74
- )
75
- },
76
- "callbacks": callbacks,
77
- }
94
+ async def stream(
95
+ self,
96
+ ) -> AsyncGenerator[UiPathRuntimeEvent | UiPathRuntimeResult, None]:
97
+ """
98
+ Stream graph execution events in real-time.
99
+
100
+ Yields UiPath UiPathRuntimeEvent instances (thin wrappers around framework data),
101
+ then yields the final UiPathRuntimeResult as the last item.
102
+ The result is also stored in self.context.result.
103
+
104
+ Yields:
105
+ - UiPathAgentMessageEvent: Wraps framework messages (BaseMessage, chunks, etc.)
106
+ - UiPathAgentStateEvent: Wraps framework state updates
107
+ - Final event: UiPathRuntimeResult or UiPathBreakpointResult
108
+
109
+ Example:
110
+ async for event in runtime.stream():
111
+ if isinstance(event, UiPathRuntimeResult):
112
+ # Last event is the result
113
+ print(f"Final result: {event}")
114
+ elif isinstance(event, UiPathAgentMessageEvent):
115
+ # Access framework-specific message
116
+ message = event.payload # BaseMessage or AIMessageChunk
117
+ print(f"Message: {message.content}")
118
+ elif isinstance(event, UiPathAgentStateEvent):
119
+ # Access framework-specific state
120
+ state = event.payload
121
+ print(f"Node {event.node_name} updated: {state}")
78
122
 
79
- recursion_limit = os.environ.get("LANGCHAIN_RECURSION_LIMIT", None)
80
- max_concurrency = os.environ.get("LANGCHAIN_MAX_CONCURRENCY", None)
81
-
82
- if recursion_limit is not None:
83
- graph_config["recursion_limit"] = int(recursion_limit)
84
- if max_concurrency is not None:
85
- graph_config["max_concurrency"] = int(max_concurrency)
86
-
87
- if self.context.chat_handler or self.is_debug_run():
88
- final_chunk: Optional[dict[Any, Any]] = None
89
- async for stream_chunk in compiled_graph.astream(
90
- processed_input,
91
- graph_config,
92
- stream_mode=["messages", "updates"],
93
- subgraphs=True,
94
- ):
95
- _, chunk_type, data = stream_chunk
96
- if chunk_type == "messages":
97
- if self.context.chat_handler:
98
- if isinstance(data, tuple):
99
- message, _ = data
100
- event = map_message(
101
- message=message,
102
- conversation_id=self.context.execution_id,
103
- exchange_id=self.context.execution_id,
123
+ Raises:
124
+ LangGraphRuntimeError: If execution fails
125
+ """
126
+ graph = await self.graph_resolver()
127
+ if not graph:
128
+ return
129
+
130
+ try:
131
+ async with self._get_or_create_memory() as memory:
132
+ compiled_graph = await self._setup_graph(memory, graph)
133
+ graph_input = await self._get_graph_input(memory)
134
+ graph_config = self._get_graph_config()
135
+
136
+ # Track final chunk for result creation
137
+ final_chunk: Optional[dict[Any, Any]] = None
138
+
139
+ # Stream events from graph
140
+ async for stream_chunk in compiled_graph.astream(
141
+ graph_input,
142
+ graph_config,
143
+ stream_mode=["messages", "updates"],
144
+ subgraphs=True,
145
+ ):
146
+ _, chunk_type, data = stream_chunk
147
+
148
+ # Emit UiPathAgentMessageEvent for messages
149
+ if chunk_type == "messages":
150
+ if isinstance(data, tuple):
151
+ message, _ = data
152
+ event = UiPathAgentMessageEvent(
153
+ payload=message,
154
+ execution_id=self.context.execution_id,
155
+ )
156
+ yield event
157
+
158
+ # Emit UiPathAgentStateEvent for state updates
159
+ elif chunk_type == "updates":
160
+ if isinstance(data, dict):
161
+ final_chunk = data
162
+
163
+ # Emit state update event for each node
164
+ for node_name, agent_data in data.items():
165
+ if isinstance(agent_data, dict):
166
+ state_event = UiPathAgentStateEvent(
167
+ payload=agent_data,
168
+ node_name=node_name,
169
+ execution_id=self.context.execution_id,
104
170
  )
105
- if event:
106
- self.context.chat_handler.on_event(event)
107
- elif chunk_type == "updates":
108
- if isinstance(data, dict):
109
- # data is a dict, e.g. {'agent': {'messages': [...]}}
110
- for agent_data in data.values():
111
- if isinstance(agent_data, dict):
112
- messages = agent_data.get("messages", [])
113
- if isinstance(messages, list):
114
- for message in messages:
115
- if isinstance(message, BaseMessage):
116
- message.pretty_print()
117
- final_chunk = data
118
-
119
- self.context.output = self._extract_graph_result(
120
- final_chunk, compiled_graph.output_channels
121
- )
122
- else:
123
- # Execute the graph normally at runtime or eval
124
- self.context.output = await compiled_graph.ainvoke(
125
- processed_input, graph_config
126
- )
127
-
128
- # Get the state if available
129
- try:
130
- self.context.state = await compiled_graph.aget_state(graph_config)
131
- except Exception:
132
- pass
133
-
134
- output_processor = await LangGraphOutputProcessor.create(self.context)
135
-
136
- self.context.result = await output_processor.process()
137
-
138
- return self.context.result
171
+ yield state_event
139
172
 
140
- except Exception as e:
141
- if isinstance(e, LangGraphRuntimeError):
142
- raise
143
-
144
- detail = f"Error: {str(e)}"
145
-
146
- if isinstance(e, GraphRecursionError):
147
- raise LangGraphRuntimeError(
148
- "GRAPH_RECURSION_ERROR",
149
- "Graph recursion limit exceeded",
150
- detail,
151
- UiPathErrorCategory.USER,
152
- ) from e
153
-
154
- if isinstance(e, InvalidUpdateError):
155
- raise LangGraphRuntimeError(
156
- "GRAPH_INVALID_UPDATE",
157
- str(e),
158
- detail,
159
- UiPathErrorCategory.USER,
160
- ) from e
161
-
162
- if isinstance(e, EmptyInputError):
163
- raise LangGraphRuntimeError(
164
- "GRAPH_EMPTY_INPUT",
165
- "The input data is empty",
166
- detail,
167
- UiPathErrorCategory.USER,
168
- ) from e
169
-
170
- raise LangGraphRuntimeError(
171
- "EXECUTION_ERROR",
172
- "Graph execution failed",
173
- detail,
174
- UiPathErrorCategory.USER,
175
- ) from e
176
- finally:
177
- pass
173
+ # Extract output from final chunk
174
+ graph_output = self._extract_graph_result(
175
+ final_chunk, compiled_graph.output_channels
176
+ )
178
177
 
179
- async def validate(self) -> None:
180
- pass
178
+ # Get final state and create result
179
+ self.context.result = await self._create_runtime_result(
180
+ compiled_graph, graph_config, memory, graph_output
181
+ )
181
182
 
182
- async def cleanup(self):
183
- pass
183
+ # Yield the final result as last event
184
+ yield self.context.result
184
185
 
185
- def _extract_graph_result(self, final_chunk, output_channels: str | Sequence[str]):
186
+ except Exception as e:
187
+ raise self._create_runtime_error(e) from e
188
+
189
+ async def _setup_graph(
190
+ self, memory: AsyncSqliteSaver, graph: StateGraph[Any, Any, Any]
191
+ ) -> CompiledStateGraph[Any, Any, Any]:
192
+ """Setup and compile the graph with memory and interrupts."""
193
+ interrupt_before: list[str] = []
194
+ interrupt_after: list[str] = []
195
+
196
+ return graph.compile(
197
+ checkpointer=memory,
198
+ interrupt_before=interrupt_before,
199
+ interrupt_after=interrupt_after,
200
+ )
201
+
202
+ def _get_graph_config(self) -> RunnableConfig:
203
+ """Build graph execution configuration."""
204
+ graph_config: RunnableConfig = {
205
+ "configurable": {
206
+ "thread_id": (
207
+ self.context.execution_id or self.context.job_id or "default"
208
+ )
209
+ },
210
+ "callbacks": [],
211
+ }
212
+
213
+ # Add optional config from environment
214
+ recursion_limit = os.environ.get("LANGCHAIN_RECURSION_LIMIT")
215
+ max_concurrency = os.environ.get("LANGCHAIN_MAX_CONCURRENCY")
216
+
217
+ if recursion_limit is not None:
218
+ graph_config["recursion_limit"] = int(recursion_limit)
219
+ if max_concurrency is not None:
220
+ graph_config["max_concurrency"] = int(max_concurrency)
221
+
222
+ return graph_config
223
+
224
+ async def _get_graph_input(self, memory: AsyncSqliteSaver) -> Any:
225
+ """Process and return graph input."""
226
+ return await get_graph_input(
227
+ context=self.context,
228
+ memory=memory,
229
+ resume_triggers_table=self.resume_triggers_table,
230
+ )
231
+
232
+ async def _get_graph_state(
233
+ self,
234
+ compiled_graph: CompiledStateGraph[Any, Any, Any],
235
+ graph_config: RunnableConfig,
236
+ ) -> Optional[StateSnapshot]:
237
+ """Get final graph state."""
238
+ try:
239
+ return await compiled_graph.aget_state(graph_config)
240
+ except Exception:
241
+ return None
242
+
243
+ def _extract_graph_result(
244
+ self, final_chunk: Any, output_channels: str | Sequence[str]
245
+ ) -> Any:
186
246
  """
187
247
  Extract the result from a LangGraph output chunk according to the graph's output channels.
188
248
 
189
249
  Args:
190
250
  final_chunk: The final chunk from graph.astream()
191
- graph: The LangGraph instance
251
+ output_channels: The graph's output channel configuration
192
252
 
193
253
  Returns:
194
254
  The extracted result according to the graph's output_channels configuration
195
255
  """
196
256
  # Unwrap from subgraph tuple format if needed
197
257
  if isinstance(final_chunk, tuple) and len(final_chunk) == 2:
198
- final_chunk = final_chunk[
199
- 1
200
- ] # Extract data part from (namespace, data) tuple
258
+ final_chunk = final_chunk[1]
201
259
 
202
260
  # If the result isn't a dict or graph doesn't define output channels, return as is
203
261
  if not isinstance(final_chunk, dict):
@@ -205,10 +263,7 @@ class LangGraphRuntime(UiPathBaseRuntime):
205
263
 
206
264
  # Case 1: Single output channel as string
207
265
  if isinstance(output_channels, str):
208
- if output_channels in final_chunk:
209
- return final_chunk[output_channels]
210
- else:
211
- return final_chunk
266
+ return final_chunk.get(output_channels, final_chunk)
212
267
 
213
268
  # Case 2: Multiple output channels as sequence
214
269
  elif hasattr(output_channels, "__iter__") and not isinstance(
@@ -217,12 +272,12 @@ class LangGraphRuntime(UiPathBaseRuntime):
217
272
  # Check which channels are present
218
273
  available_channels = [ch for ch in output_channels if ch in final_chunk]
219
274
 
220
- # if no available channels, output may contain the last_node name as key
275
+ # If no available channels, output may contain the last_node name as key
221
276
  unwrapped_final_chunk = {}
222
- if not available_channels:
223
- if len(final_chunk) == 1 and isinstance(
224
- unwrapped_final_chunk := next(iter(final_chunk.values())), dict
225
- ):
277
+ if not available_channels and len(final_chunk) == 1:
278
+ potential_unwrap = next(iter(final_chunk.values()))
279
+ if isinstance(potential_unwrap, dict):
280
+ unwrapped_final_chunk = potential_unwrap
226
281
  available_channels = [
227
282
  ch for ch in output_channels if ch in unwrapped_final_chunk
228
283
  ]
@@ -230,14 +285,184 @@ class LangGraphRuntime(UiPathBaseRuntime):
230
285
  if available_channels:
231
286
  # Create a dict with the available channels
232
287
  return {
233
- channel: final_chunk.get(channel, None)
234
- or unwrapped_final_chunk[channel]
288
+ channel: final_chunk.get(channel)
289
+ or unwrapped_final_chunk.get(channel)
235
290
  for channel in available_channels
236
291
  }
237
292
 
238
293
  # Fallback for any other case
239
294
  return final_chunk
240
295
 
296
+ def _is_interrupted(self, state: StateSnapshot) -> bool:
297
+ """Check if execution was interrupted (static or dynamic)."""
298
+ # Check for static interrupts (interrupt_before/after)
299
+ if hasattr(state, "next") and state.next:
300
+ return True
301
+
302
+ # Check for dynamic interrupts (interrupt() inside node)
303
+ if hasattr(state, "tasks"):
304
+ for task in state.tasks:
305
+ if hasattr(task, "interrupts") and task.interrupts:
306
+ return True
307
+
308
+ return False
309
+
310
+ def _get_dynamic_interrupt(self, state: StateSnapshot) -> Optional[Interrupt]:
311
+ """Get the first dynamic interrupt if any."""
312
+ if not hasattr(state, "tasks"):
313
+ return None
314
+
315
+ for task in state.tasks:
316
+ if hasattr(task, "interrupts") and task.interrupts:
317
+ for interrupt in task.interrupts:
318
+ if isinstance(interrupt, Interrupt):
319
+ return interrupt
320
+ return None
321
+
322
+ async def _create_runtime_result(
323
+ self,
324
+ compiled_graph: CompiledStateGraph[Any, Any, Any],
325
+ graph_config: RunnableConfig,
326
+ memory: AsyncSqliteSaver,
327
+ graph_output: Optional[Any],
328
+ ) -> UiPathRuntimeResult:
329
+ """
330
+ Get final graph state and create the execution result.
331
+
332
+ Stores the result in self.context.result and self.context.state.
333
+
334
+ Args:
335
+ compiled_graph: The compiled graph instance
336
+ graph_config: The graph execution configuration
337
+ memory: The SQLite memory instance
338
+ graph_output: The graph execution output
339
+ """
340
+ # Get the final state
341
+ graph_state = await self._get_graph_state(compiled_graph, graph_config)
342
+
343
+ # Check if execution was interrupted (static or dynamic)
344
+ if graph_state and self._is_interrupted(graph_state):
345
+ return await self._create_suspended_result(
346
+ graph_state, memory, graph_output
347
+ )
348
+ else:
349
+ # Normal completion
350
+ return self._create_success_result(graph_output)
351
+
352
+ async def _create_suspended_result(
353
+ self,
354
+ graph_state: StateSnapshot,
355
+ graph_memory: AsyncSqliteSaver,
356
+ graph_output: Optional[Any],
357
+ ) -> UiPathRuntimeResult:
358
+ """Create result for suspended execution."""
359
+ # Check if it's a dynamic interrupt
360
+ dynamic_interrupt = self._get_dynamic_interrupt(graph_state)
361
+ resume_trigger: Optional[UiPathResumeTrigger] = None
362
+
363
+ if dynamic_interrupt:
364
+ # Dynamic interrupt - create and save resume trigger
365
+ resume_trigger = await create_and_save_resume_trigger(
366
+ interrupt_value=dynamic_interrupt.value,
367
+ memory=graph_memory,
368
+ resume_triggers_table=self.resume_triggers_table,
369
+ )
370
+ output = serialize_output(graph_output)
371
+ return UiPathRuntimeResult(
372
+ output=output,
373
+ status=UiPathRuntimeStatus.SUSPENDED,
374
+ resume=resume_trigger,
375
+ )
376
+ else:
377
+ # Static interrupt (breakpoint)
378
+ return self._create_breakpoint_result(graph_state)
379
+
380
+ def _create_breakpoint_result(
381
+ self,
382
+ graph_state: StateSnapshot,
383
+ ) -> UiPathBreakpointResult:
384
+ """Create result for execution paused at a breakpoint."""
385
+
386
+ # Get next nodes - these are the nodes that will execute when resumed
387
+ next_nodes = list(graph_state.next)
388
+
389
+ # Determine breakpoint type and node
390
+ if next_nodes:
391
+ # Breakpoint is BEFORE these nodes (interrupt_before)
392
+ breakpoint_type = "before"
393
+ breakpoint_node = next_nodes[0]
394
+ else:
395
+ # Breakpoint is AFTER the last executed node (interrupt_after)
396
+ # Get the last executed node from tasks
397
+ breakpoint_type = "after"
398
+ if graph_state.tasks:
399
+ # Tasks contain the nodes that just executed
400
+ # Get the last task's name
401
+ breakpoint_node = graph_state.tasks[-1].name
402
+ else:
403
+ # Fallback if no tasks (shouldn't happen)
404
+ breakpoint_node = "unknown"
405
+
406
+ return UiPathBreakpointResult(
407
+ breakpoint_node=breakpoint_node,
408
+ breakpoint_type=breakpoint_type,
409
+ current_state=graph_state.values,
410
+ next_nodes=next_nodes,
411
+ )
412
+
413
+ def _create_success_result(self, output: Optional[Any]) -> UiPathRuntimeResult:
414
+ """Create result for successful completion."""
415
+ return UiPathRuntimeResult(
416
+ output=serialize_output(output),
417
+ status=UiPathRuntimeStatus.SUCCESSFUL,
418
+ )
419
+
420
+ def _create_runtime_error(self, e: Exception) -> LangGraphRuntimeError:
421
+ """Handle execution errors and create appropriate LangGraphRuntimeError."""
422
+ if isinstance(e, LangGraphRuntimeError):
423
+ return e
424
+
425
+ detail = f"Error: {str(e)}"
426
+
427
+ if isinstance(e, GraphRecursionError):
428
+ return LangGraphRuntimeError(
429
+ "GRAPH_RECURSION_ERROR",
430
+ "Graph recursion limit exceeded",
431
+ detail,
432
+ UiPathErrorCategory.USER,
433
+ )
434
+
435
+ if isinstance(e, InvalidUpdateError):
436
+ return LangGraphRuntimeError(
437
+ "GRAPH_INVALID_UPDATE",
438
+ str(e),
439
+ detail,
440
+ UiPathErrorCategory.USER,
441
+ )
442
+
443
+ if isinstance(e, EmptyInputError):
444
+ return LangGraphRuntimeError(
445
+ "GRAPH_EMPTY_INPUT",
446
+ "The input data is empty",
447
+ detail,
448
+ UiPathErrorCategory.USER,
449
+ )
450
+
451
+ return LangGraphRuntimeError(
452
+ "EXECUTION_ERROR",
453
+ "Graph execution failed",
454
+ detail,
455
+ UiPathErrorCategory.USER,
456
+ )
457
+
458
+ async def validate(self) -> None:
459
+ """Validate runtime inputs."""
460
+ pass
461
+
462
+ async def cleanup(self) -> None:
463
+ """Cleanup runtime resources."""
464
+ pass
465
+
241
466
 
242
467
  class LangGraphScriptRuntime(LangGraphRuntime):
243
468
  """
@@ -250,6 +475,7 @@ class LangGraphScriptRuntime(LangGraphRuntime):
250
475
  self.resolver = LangGraphJsonResolver(entrypoint=entrypoint)
251
476
  super().__init__(context, self.resolver)
252
477
 
253
- async def cleanup(self):
478
+ async def cleanup(self) -> None:
479
+ """Cleanup runtime resources including resolver."""
254
480
  await super().cleanup()
255
481
  await self.resolver.cleanup()