lionagi 0.13.6__py3-none-any.whl → 0.14.0__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.
@@ -2,421 +2,330 @@
2
2
  #
3
3
  # SPDX-License-Identifier: Apache-2.0
4
4
 
5
- import asyncio
6
- import contextlib
5
+ """
6
+ Dependency-aware flow execution using structured concurrency primitives.
7
+
8
+ Provides clean dependency management and context inheritance for operation graphs,
9
+ using Events for synchronization and CapacityLimiter for concurrency control.
10
+ """
11
+
12
+ import os
7
13
  from typing import Any
8
14
 
15
+ from lionagi.libs.concurrency.primitives import CapacityLimiter
16
+ from lionagi.libs.concurrency.primitives import Event as ConcurrencyEvent
17
+ from lionagi.libs.concurrency.task import create_task_group
9
18
  from lionagi.operations.node import Operation
10
- from lionagi.operations.utils import prepare_session
11
- from lionagi.protocols.types import ID, Edge, Graph, Node
19
+ from lionagi.protocols.types import EventStatus, Graph
12
20
  from lionagi.session.branch import Branch
13
21
  from lionagi.session.session import Session
14
22
  from lionagi.utils import to_dict
15
23
 
24
+ # Maximum concurrency when None is specified (effectively unlimited)
25
+ UNLIMITED_CONCURRENCY = int(os.environ.get("LIONAGI_MAX_CONCURRENCY", "10000"))
26
+
27
+
28
+ class DependencyAwareExecutor:
29
+ """Executes operation graphs with dependency management and context inheritance."""
30
+
31
+ def __init__(
32
+ self,
33
+ session: Session,
34
+ graph: Graph,
35
+ context: dict[str, Any] | None = None,
36
+ max_concurrent: int = 5,
37
+ verbose: bool = False,
38
+ default_branch: Branch | None = None,
39
+ ):
40
+ """Initialize the executor.
41
+
42
+ Args:
43
+ session: The session for branch management
44
+ graph: The operation graph to execute
45
+ context: Initial execution context
46
+ max_concurrent: Maximum concurrent operations
47
+ verbose: Enable verbose logging
48
+ default_branch: Optional default branch for operations
49
+ """
50
+ self.session = session
51
+ self.graph = graph
52
+ self.context = context or {}
53
+ self.max_concurrent = max_concurrent
54
+ self.verbose = verbose
55
+ self._default_branch = default_branch
56
+
57
+ # Track results and completion
58
+ self.results = {}
59
+ self.completion_events = {} # operation_id -> Event
60
+ self.operation_branches = {} # operation_id -> Branch
61
+
62
+ # Initialize completion events for all operations
63
+ for node in graph.internal_nodes.values():
64
+ if isinstance(node, Operation):
65
+ self.completion_events[node.id] = ConcurrencyEvent()
66
+
67
+ async def execute(self) -> dict[str, Any]:
68
+ """Execute the operation graph."""
69
+ if not self.graph.is_acyclic():
70
+ raise ValueError("Graph must be acyclic for flow execution")
71
+
72
+ # Create capacity limiter for concurrency control
73
+ # None means no limit, use the configured unlimited value
74
+ capacity = (
75
+ self.max_concurrent
76
+ if self.max_concurrent is not None
77
+ else UNLIMITED_CONCURRENCY
78
+ )
79
+ limiter = CapacityLimiter(capacity)
80
+
81
+ # Execute all operations using structured concurrency
82
+ async with create_task_group() as tg:
83
+ for node in self.graph.internal_nodes.values():
84
+ if isinstance(node, Operation):
85
+ await tg.start_soon(self._execute_operation, node, limiter)
86
+
87
+ # Return results
88
+ return {
89
+ "completed_operations": list(self.results.keys()),
90
+ "operation_results": self.results,
91
+ "final_context": self.context,
92
+ }
93
+
94
+ async def _execute_operation(
95
+ self, operation: Operation, limiter: CapacityLimiter
96
+ ):
97
+ """Execute a single operation with dependency waiting."""
98
+ try:
99
+ # Wait for dependencies
100
+ await self._wait_for_dependencies(operation)
101
+
102
+ # Acquire capacity to limit concurrency
103
+ async with limiter:
104
+ # Prepare operation context
105
+ await self._prepare_operation(operation)
106
+
107
+ # Execute the operation
108
+ if self.verbose:
109
+ print(f"Executing operation: {str(operation.id)[:8]}")
110
+
111
+ branch = self.operation_branches.get(
112
+ operation.id, self.session.default_branch
113
+ )
114
+ operation.execution.status = EventStatus.PROCESSING
16
115
 
17
- async def flow(
18
- branch: Branch,
19
- graph: Graph,
20
- *,
21
- context: dict[str, Any] | None = None,
22
- parallel: bool = True,
23
- max_concurrent: int = 5,
24
- verbose: bool = False,
25
- session: Session | None = None,
26
- ) -> dict[str, Any]:
27
- """
28
- Execute a graph-based workflow using the branch's operations.
29
-
30
- For simple graphs, executes directly on the branch.
31
- For parallel execution, uses session for coordination.
32
-
33
- Args:
34
- branch: The branch to execute operations on
35
- graph: The workflow graph containing Operation nodes
36
- context: Initial context
37
- parallel: Whether to execute independent operations in parallel
38
- max_concurrent: Max concurrent operations
39
- verbose: Enable verbose logging
40
- session: Optional session for multi-branch parallel execution
41
-
42
- Returns:
43
- Execution results with completed operations and final context
44
- """
45
- # Validate graph
46
- if not graph.is_acyclic():
47
- raise ValueError("Graph must be acyclic for flow execution")
48
-
49
- session, branch = prepare_session(session, branch)
50
- if not parallel or max_concurrent == 1:
51
- return await _execute_sequential(branch, graph, context, verbose)
52
-
53
- return await _execute_parallel(
54
- session, graph, context, max_concurrent, verbose
55
- )
56
-
57
-
58
- async def _execute_sequential(
59
- branch: Branch, graph: Graph, context: dict[str, Any] | None, verbose: bool
60
- ) -> dict[str, Any]:
61
- """Execute graph sequentially on a single branch."""
62
- completed = []
63
- results = {}
64
- execution_context = context or {}
65
-
66
- # Get execution order (topological sort)
67
- execution_order = _topological_sort(graph)
116
+ await operation.invoke(branch)
68
117
 
69
- for node_id in execution_order:
70
- node = graph.internal_nodes[node_id]
118
+ # Store results
119
+ self.results[operation.id] = operation.response
120
+ operation.execution.status = EventStatus.COMPLETED
71
121
 
72
- if not isinstance(node, Operation):
73
- continue
122
+ # Update context if response contains context
123
+ if (
124
+ isinstance(operation.response, dict)
125
+ and "context" in operation.response
126
+ ):
127
+ self.context.update(operation.response["context"])
128
+
129
+ if self.verbose:
130
+ print(f"Completed operation: {str(operation.id)[:8]}")
131
+
132
+ except Exception as e:
133
+ operation.execution.status = EventStatus.FAILED
134
+ operation.execution.error = str(e)
135
+ self.results[operation.id] = {"error": str(e)}
136
+
137
+ if self.verbose:
138
+ print(f"Operation {str(operation.id)[:8]} failed: {e}")
139
+
140
+ finally:
141
+ # Signal completion regardless of success/failure
142
+ self.completion_events[operation.id].set()
143
+
144
+ async def _wait_for_dependencies(self, operation: Operation):
145
+ """Wait for all dependencies to complete."""
146
+ # Special handling for aggregations
147
+ if operation.metadata.get("aggregation"):
148
+ sources = operation.parameters.get("aggregation_sources", [])
149
+ if self.verbose and sources:
150
+ print(
151
+ f"Aggregation {str(operation.id)[:8]} waiting for {len(sources)} sources"
152
+ )
74
153
 
75
- # Check dependencies using set for fast lookup
76
- completed_set = set(completed)
154
+ # Wait for ALL sources
155
+ for source_id in sources:
156
+ if source_id in self.completion_events:
157
+ await self.completion_events[source_id].wait()
77
158
 
78
- # Check if dependencies and conditions are satisfied
79
- if not await _dependencies_satisfied_async(
80
- node, graph, completed_set, results, execution_context
81
- ):
82
- continue
159
+ # Regular dependency checking
160
+ predecessors = self.graph.get_predecessors(operation)
161
+ for pred in predecessors:
162
+ if self.verbose:
163
+ print(
164
+ f"Operation {str(operation.id)[:8]} waiting for {str(pred.id)[:8]}"
165
+ )
166
+ await self.completion_events[pred.id].wait()
167
+
168
+ # Check edge conditions
169
+ incoming_edges = [
170
+ edge
171
+ for edge in self.graph.internal_edges.values()
172
+ if edge.tail == operation.id
173
+ ]
174
+
175
+ for edge in incoming_edges:
176
+ # Wait for head to complete
177
+ if edge.head in self.completion_events:
178
+ await self.completion_events[edge.head].wait()
179
+
180
+ # Evaluate edge condition
181
+ if edge.condition is not None:
182
+ result_value = self.results.get(edge.head)
183
+ if result_value is not None and not isinstance(
184
+ result_value, (str, int, float, bool)
185
+ ):
186
+ result_value = to_dict(result_value, recursive=True)
83
187
 
84
- predecessors = graph.get_predecessors(node)
188
+ ctx = {"result": result_value, "context": self.context}
189
+ if not await edge.condition.apply(ctx):
190
+ raise ValueError(
191
+ f"Edge condition not satisfied for {str(operation.id)[:8]}"
192
+ )
85
193
 
194
+ async def _prepare_operation(self, operation: Operation):
195
+ """Prepare operation with context and branch assignment."""
86
196
  # Update operation context with predecessors
197
+ predecessors = self.graph.get_predecessors(operation)
87
198
  if predecessors:
88
199
  pred_context = {}
89
200
  for pred in predecessors:
90
- if pred.id in results:
91
- result = results[pred.id]
92
- # Use to_dict for proper serialization of complex types only
93
- if result is not None and not isinstance(
94
- result, (str, int, float, bool)
95
- ):
96
- result = to_dict(result, recursive=True)
97
- pred_context[f"{pred.id}_result"] = result
201
+ if pred.id in self.results:
202
+ result = self.results[pred.id]
203
+ if result is not None and not isinstance(
204
+ result, (str, int, float, bool)
205
+ ):
206
+ result = to_dict(result, recursive=True)
207
+ pred_context[f"{pred.id}_result"] = result
98
208
 
99
- if "context" not in node.parameters:
100
- node.parameters["context"] = pred_context
209
+ if "context" not in operation.parameters:
210
+ operation.parameters["context"] = pred_context
101
211
  else:
102
- node.parameters["context"].update(pred_context)
212
+ operation.parameters["context"].update(pred_context)
103
213
 
104
214
  # Add execution context
105
- if execution_context:
106
- if "context" not in node.parameters:
107
- node.parameters["context"] = execution_context.copy()
215
+ if self.context:
216
+ if "context" not in operation.parameters:
217
+ operation.parameters["context"] = self.context.copy()
108
218
  else:
109
- node.parameters["context"].update(execution_context)
110
-
111
- # Execute operation
112
- if verbose:
113
- print(f"Executing operation: {node.id}")
114
-
115
- await node.invoke(branch)
116
-
117
- completed.append(node.id)
118
- results[node.id] = node.response
119
-
120
- # Update execution context
121
- if isinstance(node.response, dict) and "context" in node.response:
122
- execution_context.update(node.response["context"])
123
-
124
- return {
125
- "completed_operations": completed,
126
- "operation_results": results,
127
- "final_context": execution_context,
128
- }
129
-
130
-
131
- async def _execute_parallel(
132
- session: Session,
133
- graph: Graph,
134
- context: dict[str, Any] | None,
135
- max_concurrent: int,
136
- verbose: bool,
137
- ) -> dict[str, Any]:
138
- """Execute graph in parallel using multiple branches."""
139
- results = {}
140
- execution_context = context or {}
141
- completed = [] # Track completed operations
142
-
143
- # Get operation nodes in topological order
144
- operation_nodes = []
145
- execution_order = _topological_sort(graph)
146
- for node_id in execution_order:
147
- node = graph.internal_nodes.get(node_id)
148
- if isinstance(node, Operation):
149
- operation_nodes.append(node)
150
-
151
- # Use session branches context manager for safe parallel execution
152
- async with session.branches:
153
- # Create a pool of worker branches
154
- worker_branches = []
155
- for i in range(min(max_concurrent, len(operation_nodes))):
156
- if i == 0:
157
- worker_branches.append(session.default_branch)
158
- else:
159
- worker_branches.append(session.split(session.default_branch))
160
-
161
- # Process nodes in dependency order
162
- remaining_nodes = {node.id for node in operation_nodes}
163
- executing_tasks: dict[ID[Operation], asyncio.Task] = {}
164
- blocked_nodes = set() # Nodes that have been checked and found blocked
165
-
166
- max_iterations = 1000 # Prevent infinite loops
167
- iteration = 0
168
-
169
- while (
170
- remaining_nodes or executing_tasks
171
- ) and iteration < max_iterations:
172
- iteration += 1
173
-
174
- # Check for completed tasks
175
- completed_in_round = []
176
- for node_id, task in list(executing_tasks.items()):
177
- if task.done():
178
- try:
179
- result = await task
180
- results[node_id] = result
181
- completed.append(node_id)
182
- completed_in_round.append(node_id)
183
- if verbose:
184
- print(f"Completed operation: {node_id}")
185
- except Exception as e:
186
- if verbose:
187
- print(f"Operation {node_id} failed: {e}")
188
- results[node_id] = {"error": str(e)}
189
- completed.append(node_id)
190
- completed_in_round.append(node_id)
191
- finally:
192
- del executing_tasks[node_id]
193
-
194
- # Remove completed from remaining
195
- remaining_nodes -= set(completed_in_round)
196
-
197
- # If new completions, clear blocked nodes to re-check
198
- if completed_in_round:
199
- blocked_nodes.clear()
200
-
201
- # Find nodes ready to execute (skip already blocked nodes)
202
- ready_nodes = []
203
- completed_set = set(completed)
204
- newly_blocked = []
205
-
206
- for node in operation_nodes:
207
- if (
208
- node.id in remaining_nodes
209
- and node.id not in executing_tasks
210
- and node.id not in blocked_nodes
211
- and len(executing_tasks) < max_concurrent
212
- ):
213
- if await _dependencies_satisfied_async(
214
- node, graph, completed_set, results, execution_context
219
+ operation.parameters["context"].update(self.context)
220
+
221
+ # Determine and assign branch
222
+ branch = await self._resolve_branch_for_operation(operation)
223
+ self.operation_branches[operation.id] = branch
224
+
225
+ async def _resolve_branch_for_operation(
226
+ self, operation: Operation
227
+ ) -> Branch:
228
+ """Resolve which branch an operation should use based on inheritance rules."""
229
+ # Check if operation has an explicit branch_id
230
+ if operation.branch_id:
231
+ try:
232
+ return self.session.branches[operation.branch_id]
233
+ except:
234
+ pass
235
+
236
+ # Get predecessors for context inheritance check
237
+ predecessors = self.graph.get_predecessors(operation)
238
+
239
+ # Handle context inheritance
240
+ if operation.metadata.get("inherit_context"):
241
+ primary_dep_id = operation.metadata.get("primary_dependency")
242
+ if primary_dep_id and primary_dep_id in self.results:
243
+ # Find the operation that was the primary dependency
244
+ for node in self.graph.internal_nodes.values():
245
+ if (
246
+ isinstance(node, Operation)
247
+ and node.id == primary_dep_id
248
+ and node.branch_id
215
249
  ):
216
- ready_nodes.append(node)
217
- else:
218
- newly_blocked.append(node.id)
219
-
220
- # Update blocked nodes
221
- blocked_nodes.update(newly_blocked)
222
-
223
- # If no ready nodes but we have remaining and no executing tasks, we're stuck
224
- if not ready_nodes and remaining_nodes and not executing_tasks:
225
- if verbose:
226
- print(
227
- f"Deadlock detected: {len(remaining_nodes)} nodes cannot execute"
228
- )
229
- remaining_node_names = [
230
- n.operation
231
- for n in operation_nodes
232
- if n.id in remaining_nodes
233
- ]
234
- print(f"Remaining operations: {remaining_node_names}")
235
- # Mark remaining nodes as failed
236
- for node in operation_nodes:
237
- if node.id in remaining_nodes:
238
- results[node.id] = {
239
- "error": "Blocked by unsatisfied conditions"
240
- }
241
- completed.append(node.id)
242
- break
243
-
244
- # Start execution for ready nodes
245
- started_count = 0
246
- for node in ready_nodes:
247
- if len(executing_tasks) >= max_concurrent:
248
- break
249
-
250
- # Get an available branch (round-robin)
251
- branch_idx = len(executing_tasks) % len(worker_branches)
252
- node_branch = worker_branches[branch_idx]
253
-
254
- # Check if node specifies a branch
255
- branch_id = node.parameters.get("branch_id")
256
- if branch_id:
257
- try:
258
- node_branch = session.branches[branch_id]
259
- except:
260
- pass # Use the selected worker branch
261
-
262
- # Create task for this node
263
- task = asyncio.create_task(
264
- _execute_node_async(
265
- node,
266
- node_branch,
267
- graph,
268
- results,
269
- execution_context,
270
- verbose,
271
- )
272
- )
273
- executing_tasks[node.id] = task
274
- started_count += 1
275
-
276
- if verbose:
277
- branch_name = (
278
- getattr(node_branch, "name", None) or node_branch.id
250
+ try:
251
+ primary_branch = self.session.branches[
252
+ node.branch_id
253
+ ]
254
+ # Use session.branches context manager for split
255
+ async with self.session.branches:
256
+ split_branch = self.session.split(
257
+ primary_branch
258
+ )
259
+ if self.verbose:
260
+ print(
261
+ f"Operation {str(operation.id)[:8]} inheriting context from {str(primary_dep_id)[:8]}"
262
+ )
263
+ return split_branch
264
+ except:
265
+ pass
266
+
267
+ # If operation has dependencies but no inheritance, create fresh branch
268
+ elif predecessors:
269
+ try:
270
+ async with self.session.branches:
271
+ fresh_branch = self.session.split(
272
+ self.session.default_branch
279
273
  )
274
+ if self.verbose:
280
275
  print(
281
- f"Started operation {node.id} on branch: {branch_name}"
276
+ f"Operation {str(operation.id)[:8]} starting with fresh context"
282
277
  )
278
+ return fresh_branch
279
+ except:
280
+ pass
283
281
 
284
- # If we started new tasks or have executing tasks, wait for some to complete
285
- if started_count > 0 or executing_tasks:
286
- # Wait for at least one task to complete before next iteration
287
- if executing_tasks:
288
- done, pending = await asyncio.wait(
289
- executing_tasks.values(),
290
- return_when=asyncio.FIRST_COMPLETED,
291
- )
292
- else:
293
- await asyncio.sleep(0.01)
294
- elif not remaining_nodes:
295
- # All done
296
- break
297
-
298
- if iteration >= max_iterations:
299
- raise RuntimeError(
300
- f"Flow execution exceeded maximum iterations ({max_iterations})"
301
- )
302
-
303
- return {
304
- "completed_operations": completed,
305
- "operation_results": results,
306
- "final_context": execution_context,
307
- }
308
-
309
-
310
- async def _execute_node_async(
311
- node: Operation,
312
- branch: Branch,
313
- graph: Graph,
314
- results: dict[str, Any],
315
- execution_context: dict[str, Any],
316
- verbose: bool,
317
- ) -> Any:
318
- """Execute a single node asynchronously."""
319
- # Update operation context with predecessors
320
- predecessors = graph.get_predecessors(node)
321
- if predecessors:
322
- pred_context = {}
323
- for pred in predecessors:
324
- if pred.id in results:
325
- result = results[pred.id]
326
- # Use to_dict for proper serialization of complex types only
327
- if result is not None and not isinstance(
328
- result, (str, int, float, bool)
329
- ):
330
- result = to_dict(result, recursive=True)
331
- pred_context[f"{pred.id}_result"] = result
332
-
333
- if "context" not in node.parameters:
334
- node.parameters["context"] = pred_context
335
- else:
336
- node.parameters["context"].update(pred_context)
337
-
338
- # Add execution context
339
- if execution_context:
340
- if "context" not in node.parameters:
341
- node.parameters["context"] = execution_context.copy()
342
- else:
343
- node.parameters["context"].update(execution_context)
344
-
345
- # Execute the operation
346
- await node.invoke(branch)
347
- result = node.response
348
-
349
- # Update execution context if needed
350
- if isinstance(result, dict) and "context" in result:
351
- execution_context.update(result["context"])
282
+ # Default to session's default branch or the provided branch
283
+ if hasattr(self, "_default_branch") and self._default_branch:
284
+ return self._default_branch
285
+ return self.session.default_branch
352
286
 
353
- return result
354
287
 
288
+ async def flow(
289
+ session: Session,
290
+ graph: Graph,
291
+ *,
292
+ branch: Branch | None = None,
293
+ context: dict[str, Any] | None = None,
294
+ parallel: bool = True,
295
+ max_concurrent: int = None,
296
+ verbose: bool = False,
297
+ ) -> dict[str, Any]:
298
+ """Execute a graph using structured concurrency primitives.
355
299
 
356
- def _topological_sort(graph: Graph) -> list[str]:
357
- """Get topological ordering of graph nodes."""
358
- visited = set()
359
- stack = []
360
-
361
- def visit(node_id: str):
362
- if node_id in visited:
363
- return
364
- visited.add(node_id)
365
-
366
- successors = graph.get_successors(graph.internal_nodes[node_id])
367
- for successor in successors:
368
- visit(successor.id)
369
-
370
- stack.append(node_id)
300
+ This provides clean dependency management and context inheritance
301
+ using Events and CapacityLimiter for proper coordination.
371
302
 
372
- for node in graph.internal_nodes:
373
- if node.id not in visited:
374
- visit(node.id)
303
+ Args:
304
+ session: Session for branch management and multi-branch execution
305
+ graph: The workflow graph containing Operation nodes
306
+ branch: Optional specific branch to use for single-branch operations
307
+ context: Initial context
308
+ parallel: Whether to execute independent operations in parallel
309
+ max_concurrent: Max concurrent operations (1 if not parallel)
310
+ verbose: Enable verbose logging
375
311
 
376
- return stack[::-1]
312
+ Returns:
313
+ Execution results with completed operations and final context
314
+ """
377
315
 
316
+ # Handle concurrency limits
317
+ if not parallel:
318
+ max_concurrent = 1 # Force sequential execution
319
+ # If max_concurrent is None, it means no limit
320
+
321
+ # Execute using the dependency-aware executor
322
+ executor = DependencyAwareExecutor(
323
+ session=session,
324
+ graph=graph,
325
+ context=context,
326
+ max_concurrent=max_concurrent,
327
+ verbose=verbose,
328
+ default_branch=branch,
329
+ )
378
330
 
379
- async def _dependencies_satisfied_async(
380
- node: Node,
381
- graph: Graph,
382
- completed: set[str],
383
- results: dict[str, Any],
384
- execution_context: dict[str, Any] | None = None,
385
- ) -> bool:
386
- """Check if node dependencies are satisfied and edge conditions pass."""
387
- # Get all incoming edges to this node
388
- incoming_edges: list[Edge] = []
389
- for edge in graph.internal_edges:
390
- if edge.tail == node.id:
391
- incoming_edges.append(edge)
392
-
393
- # If no incoming edges, node can execute
394
- if not incoming_edges:
395
- return True
396
-
397
- # Check each incoming edge
398
- at_least_one_satisfied = False
399
- for edge in incoming_edges:
400
- # Check if predecessor is completed
401
- if edge.head not in completed:
402
- continue
403
-
404
- # Predecessor is completed
405
- if edge.condition:
406
- # Evaluate condition
407
- # Get the result - don't use to_dict if it's already a simple type
408
- result_value = results.get(edge.head)
409
- if result_value is not None and not isinstance(
410
- result_value, (str, int, float, bool)
411
- ):
412
- result_value = to_dict(result_value, recursive=True)
413
-
414
- ctx = {"result": result_value, "context": execution_context or {}}
415
- with contextlib.suppress(Exception):
416
- if await edge.condition.apply(ctx):
417
- at_least_one_satisfied = True
418
- else:
419
- # No condition, edge is satisfied
420
- at_least_one_satisfied = True
421
-
422
- return at_least_one_satisfied
331
+ return await executor.execute()