lionagi 0.14.4__py3-none-any.whl → 0.14.6__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.
@@ -60,15 +60,25 @@ class DependencyAwareExecutor:
60
60
  self.operation_branches = {} # operation_id -> Branch
61
61
 
62
62
  # Initialize completion events for all operations
63
+ # and check for already completed operations
63
64
  for node in graph.internal_nodes.values():
64
65
  if isinstance(node, Operation):
65
66
  self.completion_events[node.id] = ConcurrencyEvent()
66
67
 
68
+ # If operation is already completed, mark it and store results
69
+ if node.execution.status == EventStatus.COMPLETED:
70
+ self.completion_events[node.id].set()
71
+ if hasattr(node, "response"):
72
+ self.results[node.id] = node.response
73
+
67
74
  async def execute(self) -> dict[str, Any]:
68
75
  """Execute the operation graph."""
69
76
  if not self.graph.is_acyclic():
70
77
  raise ValueError("Graph must be acyclic for flow execution")
71
78
 
79
+ # Pre-allocate ALL branches upfront to avoid any locking during execution
80
+ await self._preallocate_all_branches()
81
+
72
82
  # Create capacity limiter for concurrency control
73
83
  # None means no limit, use the configured unlimited value
74
84
  capacity = (
@@ -91,10 +101,97 @@ class DependencyAwareExecutor:
91
101
  "final_context": self.context,
92
102
  }
93
103
 
104
+ async def _preallocate_all_branches(self):
105
+ """Pre-allocate ALL branches including for context inheritance to eliminate runtime locking."""
106
+ operations_needing_branches = []
107
+
108
+ # First pass: identify all operations that need branches
109
+ for node in self.graph.internal_nodes.values():
110
+ if not isinstance(node, Operation):
111
+ continue
112
+
113
+ # Skip if operation already has a branch_id
114
+ if node.branch_id:
115
+ try:
116
+ # Ensure the branch exists in our local map
117
+ branch = self.session.branches[node.branch_id]
118
+ self.operation_branches[node.id] = branch
119
+ except:
120
+ pass
121
+ continue
122
+
123
+ # Check if operation needs a new branch
124
+ predecessors = self.graph.get_predecessors(node)
125
+ if predecessors or node.metadata.get("inherit_context"):
126
+ operations_needing_branches.append(node)
127
+
128
+ if not operations_needing_branches:
129
+ return
130
+
131
+ # Create all branches in a single lock acquisition
132
+ async with self.session.branches.async_lock:
133
+ # For context inheritance, we need to create placeholder branches
134
+ # that will be updated once dependencies complete
135
+ for operation in operations_needing_branches:
136
+ # Create a fresh branch for now
137
+ branch_clone = self.session.default_branch.clone(
138
+ sender=self.session.id
139
+ )
140
+
141
+ # Store in our operation branches map
142
+ self.operation_branches[operation.id] = branch_clone
143
+
144
+ # Add to session branches collection directly
145
+ # Check if this is a real branch (not a mock)
146
+ try:
147
+ from lionagi.protocols.types import IDType
148
+
149
+ # Try to validate the ID
150
+ if hasattr(branch_clone, "id"):
151
+ branch_id = branch_clone.id
152
+ # Only add to collections if it's a valid ID
153
+ if isinstance(branch_id, (str, IDType)) or (
154
+ hasattr(branch_id, "__str__")
155
+ and not hasattr(branch_id, "_mock_name")
156
+ ):
157
+ self.session.branches.collections[branch_id] = (
158
+ branch_clone
159
+ )
160
+ self.session.branches.progression.append(branch_id)
161
+ except:
162
+ # If validation fails, it's likely a mock - skip adding to collections
163
+ pass
164
+
165
+ # Mark branches that need context inheritance for later update
166
+ if operation.metadata.get("inherit_context"):
167
+ branch_clone.metadata = branch_clone.metadata or {}
168
+ branch_clone.metadata["pending_context_inheritance"] = True
169
+ branch_clone.metadata["inherit_from_operation"] = (
170
+ operation.metadata.get("primary_dependency")
171
+ )
172
+
173
+ if self.verbose:
174
+ print(f"Pre-allocated {len(operations_needing_branches)} branches")
175
+
94
176
  async def _execute_operation(
95
177
  self, operation: Operation, limiter: CapacityLimiter
96
178
  ):
97
179
  """Execute a single operation with dependency waiting."""
180
+ # Skip if operation is already completed
181
+ if operation.execution.status == EventStatus.COMPLETED:
182
+ if self.verbose:
183
+ print(
184
+ f"Skipping already completed operation: {str(operation.id)[:8]}"
185
+ )
186
+ # Ensure results are available for dependencies
187
+ if operation.id not in self.results and hasattr(
188
+ operation, "response"
189
+ ):
190
+ self.results[operation.id] = operation.response
191
+ # Signal completion for any waiting operations
192
+ self.completion_events[operation.id].set()
193
+ return
194
+
98
195
  try:
99
196
  # Wait for dependencies
100
197
  await self._wait_for_dependencies(operation)
@@ -102,7 +199,7 @@ class DependencyAwareExecutor:
102
199
  # Acquire capacity to limit concurrency
103
200
  async with limiter:
104
201
  # Prepare operation context
105
- await self._prepare_operation(operation)
202
+ self._prepare_operation(operation)
106
203
 
107
204
  # Execute the operation
108
205
  if self.verbose:
@@ -191,7 +288,7 @@ class DependencyAwareExecutor:
191
288
  f"Edge condition not satisfied for {str(operation.id)[:8]}"
192
289
  )
193
290
 
194
- async def _prepare_operation(self, operation: Operation):
291
+ def _prepare_operation(self, operation: Operation):
195
292
  """Prepare operation with context and branch assignment."""
196
293
  # Update operation context with predecessors
197
294
  predecessors = self.graph.get_predecessors(operation)
@@ -209,77 +306,83 @@ class DependencyAwareExecutor:
209
306
  if "context" not in operation.parameters:
210
307
  operation.parameters["context"] = pred_context
211
308
  else:
212
- operation.parameters["context"].update(pred_context)
309
+ # Handle case where context might be a string
310
+ existing_context = operation.parameters["context"]
311
+ if isinstance(existing_context, dict):
312
+ existing_context.update(pred_context)
313
+ else:
314
+ # If it's a string or other type, create a new dict
315
+ operation.parameters["context"] = {
316
+ "original_context": existing_context,
317
+ **pred_context,
318
+ }
213
319
 
214
320
  # Add execution context
215
321
  if self.context:
216
322
  if "context" not in operation.parameters:
217
323
  operation.parameters["context"] = self.context.copy()
218
324
  else:
219
- operation.parameters["context"].update(self.context)
325
+ # Handle case where context might be a string
326
+ existing_context = operation.parameters["context"]
327
+ if isinstance(existing_context, dict):
328
+ existing_context.update(self.context)
329
+ else:
330
+ # If it's a string or other type, create a new dict
331
+ operation.parameters["context"] = {
332
+ "original_context": existing_context,
333
+ **self.context,
334
+ }
220
335
 
221
336
  # Determine and assign branch
222
- branch = await self._resolve_branch_for_operation(operation)
337
+ branch = self._resolve_branch_for_operation(operation)
223
338
  self.operation_branches[operation.id] = branch
224
339
 
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)
340
+ def _resolve_branch_for_operation(self, operation: Operation) -> Branch:
341
+ """Resolve which branch an operation should use - all branches are pre-allocated."""
342
+ # All branches should be pre-allocated
343
+ if operation.id in self.operation_branches:
344
+ branch = self.operation_branches[operation.id]
345
+
346
+ # Handle deferred context inheritance
347
+ if (
348
+ hasattr(branch, "metadata")
349
+ and branch.metadata
350
+ and branch.metadata.get("pending_context_inheritance")
351
+ ):
352
+
353
+ primary_dep_id = branch.metadata.get("inherit_from_operation")
354
+ if primary_dep_id and primary_dep_id in self.results:
355
+ # Find the primary dependency's branch
356
+ primary_branch = self.operation_branches.get(
357
+ primary_dep_id, self.session.default_branch
358
+ )
238
359
 
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
360
+ # Copy the messages from primary branch to this branch
361
+ # This avoids creating a new branch and thus avoids locking
362
+ # Access messages through the MessageManager
363
+ if hasattr(branch, "_message_manager") and hasattr(
364
+ primary_branch, "_message_manager"
249
365
  ):
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
273
- )
274
- if self.verbose:
275
- print(
276
- f"Operation {str(operation.id)[:8]} starting with fresh context"
277
- )
278
- return fresh_branch
279
- except:
280
- pass
366
+ branch._message_manager.pile.clear()
367
+ for msg in primary_branch._message_manager.pile:
368
+ branch._message_manager.pile.append(msg.clone())
369
+
370
+ # Clear the pending flag
371
+ branch.metadata["pending_context_inheritance"] = False
372
+
373
+ if self.verbose:
374
+ print(
375
+ f"Operation {str(operation.id)[:8]} inherited context from {str(primary_dep_id)[:8]}"
376
+ )
377
+
378
+ return branch
379
+
380
+ # Fallback to default branch (should not happen with proper pre-allocation)
381
+ if self.verbose:
382
+ print(
383
+ f"Warning: Operation {str(operation.id)[:8]} using default branch (not pre-allocated)"
384
+ )
281
385
 
282
- # Default to session's default branch or the provided branch
283
386
  if hasattr(self, "_default_branch") and self._default_branch:
284
387
  return self._default_branch
285
388
  return self.session.default_branch
@@ -25,6 +25,7 @@ from pydapter import Adaptable, AsyncAdaptable
25
25
  from typing_extensions import Self, override
26
26
 
27
27
  from lionagi._errors import ItemExistsError, ItemNotFoundError
28
+ from lionagi.libs.concurrency import Lock as ConcurrencyLock
28
29
  from lionagi.utils import UNDEFINED, is_same_dtype, to_list
29
30
 
30
31
  from .._concepts import Observable
@@ -35,9 +36,6 @@ D = TypeVar("D")
35
36
  T = TypeVar("T", bound=E)
36
37
 
37
38
 
38
- __all__ = ("Pile",)
39
-
40
-
41
39
  def synchronized(func: Callable):
42
40
  @wraps(func)
43
41
  def wrapper(self: Pile, *args, **kwargs):
@@ -92,7 +90,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
92
90
  def __pydantic_extra__(self) -> dict[str, FieldInfo]:
93
91
  return {
94
92
  "_lock": Field(default_factory=threading.Lock),
95
- "_async": Field(default_factory=asyncio.Lock),
93
+ "_async": Field(default_factory=ConcurrencyLock),
96
94
  }
97
95
 
98
96
  def __pydantic_private__(self) -> dict[str, FieldInfo]:
@@ -321,8 +319,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
321
319
 
322
320
  def __iter__(self) -> Iterator[T]:
323
321
  """Iterate over items safely."""
324
- with self.lock:
325
- current_order = list(self.progression)
322
+ current_order = list(self.progression)
326
323
 
327
324
  for key in current_order:
328
325
  yield self.collections[key]
@@ -485,7 +482,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
485
482
  """Restore after unpickling."""
486
483
  self.__dict__.update(state)
487
484
  self._lock = threading.Lock()
488
- self._async_lock = asyncio.Lock()
485
+ self._async_lock = ConcurrencyLock()
489
486
 
490
487
  @property
491
488
  def lock(self):
@@ -498,7 +495,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
498
495
  def async_lock(self):
499
496
  """Async lock."""
500
497
  if not hasattr(self, "_async_lock") or self._async_lock is None:
501
- self._async_lock = asyncio.Lock()
498
+ self._async_lock = ConcurrencyLock()
502
499
  return self._async_lock
503
500
 
504
501
  # Async Interface methods
@@ -915,7 +912,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
915
912
 
916
913
  async def __aenter__(self) -> Self:
917
914
  """Enter async context."""
918
- await self.async_lock.acquire()
915
+ await self.async_lock.__aenter__()
919
916
  return self
920
917
 
921
918
  async def __aexit__(
@@ -925,7 +922,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
925
922
  exc_tb: Any,
926
923
  ) -> None:
927
924
  """Exit async context."""
928
- self.async_lock.release()
925
+ await self.async_lock.__aexit__(exc_type, exc_val, exc_tb)
929
926
 
930
927
  def is_homogenous(self) -> bool:
931
928
  """Check if all items are same type."""
@@ -5,6 +5,9 @@
5
5
  import asyncio
6
6
  from typing import Any, ClassVar
7
7
 
8
+ from lionagi.libs.concurrency import Event as ConcurrencyEvent
9
+ from lionagi.libs.concurrency import Semaphore, create_task_group
10
+
8
11
  from .._concepts import Observer
9
12
  from .element import ID
10
13
  from .event import Event, EventStatus
@@ -56,9 +59,9 @@ class Processor(Observer):
56
59
  self.queue = asyncio.Queue()
57
60
  self._available_capacity = queue_capacity
58
61
  self._execution_mode = False
59
- self._stop_event = asyncio.Event()
62
+ self._stop_event = ConcurrencyEvent()
60
63
  if concurrency_limit:
61
- self._concurrency_sem = asyncio.Semaphore(concurrency_limit)
64
+ self._concurrency_sem = Semaphore(concurrency_limit)
62
65
  else:
63
66
  self._concurrency_sem = None
64
67
 
@@ -106,7 +109,9 @@ class Processor(Observer):
106
109
 
107
110
  async def start(self) -> None:
108
111
  """Clears the stop signal, allowing event processing to resume."""
109
- self._stop_event.clear()
112
+ # Create a new event since ConcurrencyEvent doesn't have clear()
113
+ if self._stop_event.is_set():
114
+ self._stop_event = ConcurrencyEvent()
110
115
 
111
116
  def is_stopped(self) -> bool:
112
117
  """Checks whether the processor is in a stopped state.
@@ -136,30 +141,52 @@ class Processor(Observer):
136
141
  for tasks to complete. Resets capacity afterward if any events
137
142
  were processed.
138
143
  """
139
- tasks = set()
140
144
  prev_event: Event | None = None
141
-
142
- while self.available_capacity > 0 and not self.queue.empty():
143
- next_event = None
144
- if prev_event and prev_event.status == EventStatus.PENDING:
145
- # Wait if previous event is still pending
146
- await asyncio.sleep(self.capacity_refresh_time)
147
- next_event = prev_event
148
- else:
149
- next_event = await self.dequeue()
150
-
151
- if await self.request_permission(**next_event.request):
152
- if next_event.streaming:
153
- task = asyncio.create_task(next_event.stream())
145
+ events_processed = 0
146
+
147
+ async with create_task_group() as tg:
148
+ while self.available_capacity > 0 and not self.queue.empty():
149
+ next_event = None
150
+ if prev_event and prev_event.status == EventStatus.PENDING:
151
+ # Wait if previous event is still pending
152
+ await asyncio.sleep(self.capacity_refresh_time)
153
+ next_event = prev_event
154
154
  else:
155
- task = asyncio.create_task(next_event.invoke())
156
- tasks.add(task)
155
+ next_event = await self.dequeue()
156
+
157
+ if await self.request_permission(**next_event.request):
158
+ if next_event.streaming:
159
+ # For streaming, we need to consume the async generator
160
+ async def consume_stream(event):
161
+ async for _ in event.stream():
162
+ pass
163
+
164
+ if self._concurrency_sem:
165
+
166
+ async def stream_with_sem(event):
167
+ async with self._concurrency_sem:
168
+ await consume_stream(event)
169
+
170
+ await tg.start_soon(stream_with_sem, next_event)
171
+ else:
172
+ await tg.start_soon(consume_stream, next_event)
173
+ else:
174
+ # For non-streaming, just invoke
175
+ if self._concurrency_sem:
176
+
177
+ async def invoke_with_sem(event):
178
+ async with self._concurrency_sem:
179
+ await event.invoke()
180
+
181
+ await tg.start_soon(invoke_with_sem, next_event)
182
+ else:
183
+ await tg.start_soon(next_event.invoke)
184
+ events_processed += 1
157
185
 
158
- prev_event = next_event
159
- self._available_capacity -= 1
186
+ prev_event = next_event
187
+ self._available_capacity -= 1
160
188
 
161
- if tasks:
162
- await asyncio.wait(tasks)
189
+ if events_processed > 0:
163
190
  self.available_capacity = self.queue_capacity
164
191
 
165
192
  async def request_permission(self, **kwargs: Any) -> bool:
@@ -270,9 +297,9 @@ class Executor(Observer):
270
297
  Args:
271
298
  event (Event): The event to add.
272
299
  """
273
- async with self.pile:
274
- self.pile.include(event)
275
- self.pending.include(event)
300
+ # Use async methods to avoid deadlock between sync/async locks
301
+ await self.pile.ainclude(event)
302
+ self.pending.include(event)
276
303
 
277
304
  @property
278
305
  def completed_events(self) -> Pile[Event]:
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0