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.
- lionagi/fields/instruct.py +3 -17
- lionagi/libs/concurrency/__init__.py +25 -1
- lionagi/libs/concurrency/cancel.py +1 -1
- lionagi/libs/concurrency/patterns.py +145 -138
- lionagi/libs/concurrency/primitives.py +145 -97
- lionagi/libs/concurrency/resource_tracker.py +182 -0
- lionagi/libs/concurrency/task.py +4 -2
- lionagi/operations/builder.py +9 -0
- lionagi/operations/flow.py +163 -60
- lionagi/protocols/generic/pile.py +7 -10
- lionagi/protocols/generic/processor.py +53 -26
- lionagi/service/connections/providers/_claude_code/__init__.py +3 -0
- lionagi/service/connections/providers/_claude_code/models.py +235 -0
- lionagi/service/connections/providers/_claude_code/stream_cli.py +350 -0
- lionagi/service/connections/providers/claude_code_.py +13 -223
- lionagi/service/connections/providers/claude_code_cli.py +38 -343
- lionagi/service/rate_limited_processor.py +53 -35
- lionagi/session/branch.py +6 -51
- lionagi/session/session.py +26 -8
- lionagi/utils.py +56 -174
- lionagi/version.py +1 -1
- {lionagi-0.14.4.dist-info → lionagi-0.14.6.dist-info}/METADATA +6 -2
- {lionagi-0.14.4.dist-info → lionagi-0.14.6.dist-info}/RECORD +25 -21
- {lionagi-0.14.4.dist-info → lionagi-0.14.6.dist-info}/WHEEL +0 -0
- {lionagi-0.14.4.dist-info → lionagi-0.14.6.dist-info}/licenses/LICENSE +0 -0
lionagi/operations/flow.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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 =
|
337
|
+
branch = self._resolve_branch_for_operation(operation)
|
223
338
|
self.operation_branches[operation.id] = branch
|
224
339
|
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
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
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
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
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
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=
|
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
|
-
|
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 =
|
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 =
|
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.
|
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.
|
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 =
|
62
|
+
self._stop_event = ConcurrencyEvent()
|
60
63
|
if concurrency_limit:
|
61
|
-
self._concurrency_sem =
|
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
|
-
|
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
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
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
|
-
|
156
|
-
|
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
|
-
|
159
|
-
|
186
|
+
prev_event = next_event
|
187
|
+
self._available_capacity -= 1
|
160
188
|
|
161
|
-
if
|
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
|
274
|
-
|
275
|
-
|
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]:
|