lionagi 0.13.7__py3-none-any.whl → 0.14.1__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.
- lionagi/libs/concurrency/__init__.py +25 -0
- lionagi/libs/concurrency/cancel.py +134 -0
- lionagi/libs/concurrency/errors.py +35 -0
- lionagi/libs/concurrency/patterns.py +252 -0
- lionagi/libs/concurrency/primitives.py +242 -0
- lionagi/libs/concurrency/task.py +109 -0
- lionagi/operations/builder.py +46 -0
- lionagi/operations/flow.py +292 -383
- lionagi/operations/node.py +2 -1
- lionagi/protocols/generic/pile.py +41 -156
- lionagi/protocols/graph/edge.py +1 -1
- lionagi/protocols/graph/node.py +27 -55
- lionagi/protocols/types.py +1 -2
- lionagi/service/connections/providers/claude_code_.py +31 -8
- lionagi/service/connections/providers/claude_code_cli.py +2 -3
- lionagi/session/session.py +8 -8
- lionagi/version.py +1 -1
- {lionagi-0.13.7.dist-info → lionagi-0.14.1.dist-info}/METADATA +2 -2
- {lionagi-0.13.7.dist-info → lionagi-0.14.1.dist-info}/RECORD +21 -15
- {lionagi-0.13.7.dist-info → lionagi-0.14.1.dist-info}/WHEEL +0 -0
- {lionagi-0.13.7.dist-info → lionagi-0.14.1.dist-info}/licenses/LICENSE +0 -0
lionagi/operations/flow.py
CHANGED
@@ -2,421 +2,330 @@
|
|
2
2
|
#
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
4
4
|
|
5
|
-
|
6
|
-
|
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.
|
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
|
-
|
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
|
-
|
70
|
-
|
118
|
+
# Store results
|
119
|
+
self.results[operation.id] = operation.response
|
120
|
+
operation.execution.status = EventStatus.COMPLETED
|
71
121
|
|
72
|
-
|
73
|
-
|
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
|
-
|
76
|
-
|
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
|
-
#
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
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
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
100
|
-
|
209
|
+
if "context" not in operation.parameters:
|
210
|
+
operation.parameters["context"] = pred_context
|
101
211
|
else:
|
102
|
-
|
212
|
+
operation.parameters["context"].update(pred_context)
|
103
213
|
|
104
214
|
# Add execution context
|
105
|
-
if
|
106
|
-
if "context" not in
|
107
|
-
|
215
|
+
if self.context:
|
216
|
+
if "context" not in operation.parameters:
|
217
|
+
operation.parameters["context"] = self.context.copy()
|
108
218
|
else:
|
109
|
-
|
110
|
-
|
111
|
-
#
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
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
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
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"
|
276
|
+
f"Operation {str(operation.id)[:8]} starting with fresh context"
|
282
277
|
)
|
278
|
+
return fresh_branch
|
279
|
+
except:
|
280
|
+
pass
|
283
281
|
|
284
|
-
|
285
|
-
|
286
|
-
|
287
|
-
|
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
|
-
|
357
|
-
|
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
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
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
|
-
|
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()
|