lionagi 0.14.5__py3-none-any.whl → 0.14.7__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/cancel.py +1 -1
- lionagi/libs/hash/__init__.py +3 -0
- lionagi/libs/hash/hash_dict.py +108 -0
- lionagi/libs/hash/manager.py +26 -0
- lionagi/models/hashable_model.py +2 -1
- lionagi/operations/builder.py +9 -0
- lionagi/operations/flow.py +163 -60
- lionagi/protocols/generic/pile.py +34 -15
- lionagi/protocols/messages/message.py +3 -1
- lionagi/service/connections/providers/_claude_code/__init__.py +3 -0
- lionagi/service/connections/providers/_claude_code/models.py +234 -0
- lionagi/service/connections/providers/_claude_code/stream_cli.py +359 -0
- lionagi/service/connections/providers/claude_code_.py +13 -223
- lionagi/service/connections/providers/claude_code_cli.py +38 -343
- lionagi/service/types.py +2 -1
- lionagi/session/branch.py +6 -46
- lionagi/session/session.py +26 -8
- lionagi/version.py +1 -1
- {lionagi-0.14.5.dist-info → lionagi-0.14.7.dist-info}/METADATA +9 -19
- {lionagi-0.14.5.dist-info → lionagi-0.14.7.dist-info}/RECORD +23 -17
- {lionagi-0.14.5.dist-info → lionagi-0.14.7.dist-info}/WHEEL +0 -0
- {lionagi-0.14.5.dist-info → lionagi-0.14.7.dist-info}/licenses/LICENSE +0 -0
@@ -5,6 +5,7 @@
|
|
5
5
|
from __future__ import annotations
|
6
6
|
|
7
7
|
import asyncio
|
8
|
+
import threading
|
8
9
|
from collections import deque
|
9
10
|
from collections.abc import (
|
10
11
|
AsyncIterator,
|
@@ -35,13 +36,19 @@ D = TypeVar("D")
|
|
35
36
|
T = TypeVar("T", bound=E)
|
36
37
|
|
37
38
|
|
38
|
-
|
39
|
+
def synchronized(func: Callable):
|
40
|
+
@wraps(func)
|
41
|
+
def wrapper(self: Pile, *args, **kwargs):
|
42
|
+
with self.lock:
|
43
|
+
return func(self, *args, **kwargs)
|
44
|
+
|
45
|
+
return wrapper
|
39
46
|
|
40
47
|
|
41
48
|
def async_synchronized(func: Callable):
|
42
49
|
@wraps(func)
|
43
50
|
async def wrapper(self: Pile, *args, **kwargs):
|
44
|
-
async with self.
|
51
|
+
async with self.async_lock:
|
45
52
|
return await func(self, *args, **kwargs)
|
46
53
|
|
47
54
|
return wrapper
|
@@ -82,7 +89,8 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
82
89
|
|
83
90
|
def __pydantic_extra__(self) -> dict[str, FieldInfo]:
|
84
91
|
return {
|
85
|
-
"_lock": Field(default_factory=
|
92
|
+
"_lock": Field(default_factory=threading.Lock),
|
93
|
+
"_async": Field(default_factory=ConcurrencyLock),
|
86
94
|
}
|
87
95
|
|
88
96
|
def __pydantic_private__(self) -> dict[str, FieldInfo]:
|
@@ -162,6 +170,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
162
170
|
"""
|
163
171
|
self._setitem(key, item)
|
164
172
|
|
173
|
+
@synchronized
|
165
174
|
def pop(
|
166
175
|
self,
|
167
176
|
key: ID.Ref | ID.RefSeq | int | slice,
|
@@ -224,6 +233,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
224
233
|
"""
|
225
234
|
self._exclude(item)
|
226
235
|
|
236
|
+
@synchronized
|
227
237
|
def clear(self) -> None:
|
228
238
|
"""Remove all items."""
|
229
239
|
self._clear()
|
@@ -243,6 +253,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
243
253
|
"""
|
244
254
|
self._update(other)
|
245
255
|
|
256
|
+
@synchronized
|
246
257
|
def insert(self, index: int, item: T, /) -> None:
|
247
258
|
"""Insert item at position.
|
248
259
|
|
@@ -256,6 +267,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
256
267
|
"""
|
257
268
|
self._insert(index, item)
|
258
269
|
|
270
|
+
@synchronized
|
259
271
|
def append(self, item: T, /) -> None:
|
260
272
|
"""Append item to end (alias for include).
|
261
273
|
|
@@ -267,6 +279,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
267
279
|
"""
|
268
280
|
self.update(item)
|
269
281
|
|
282
|
+
@synchronized
|
270
283
|
def get(
|
271
284
|
self,
|
272
285
|
key: ID.Ref | ID.RefSeq | int | slice,
|
@@ -306,12 +319,10 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
306
319
|
|
307
320
|
def __iter__(self) -> Iterator[T]:
|
308
321
|
"""Iterate over items safely."""
|
309
|
-
# Take a snapshot of the current order to avoid holding lock during iteration
|
310
322
|
current_order = list(self.progression)
|
311
323
|
|
312
324
|
for key in current_order:
|
313
|
-
|
314
|
-
yield self.collections[key]
|
325
|
+
yield self.collections[key]
|
315
326
|
|
316
327
|
def __next__(self) -> T:
|
317
328
|
"""Get next item."""
|
@@ -464,20 +475,29 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
464
475
|
"""Prepare for pickling."""
|
465
476
|
state = self.__dict__.copy()
|
466
477
|
state["_lock"] = None
|
478
|
+
state["_async_lock"] = None
|
467
479
|
return state
|
468
480
|
|
469
481
|
def __setstate__(self, state):
|
470
482
|
"""Restore after unpickling."""
|
471
483
|
self.__dict__.update(state)
|
472
|
-
self._lock =
|
484
|
+
self._lock = threading.Lock()
|
485
|
+
self._async_lock = ConcurrencyLock()
|
473
486
|
|
474
487
|
@property
|
475
488
|
def lock(self):
|
476
|
-
"""
|
489
|
+
"""Thread lock."""
|
477
490
|
if not hasattr(self, "_lock") or self._lock is None:
|
478
|
-
self._lock =
|
491
|
+
self._lock = threading.Lock()
|
479
492
|
return self._lock
|
480
493
|
|
494
|
+
@property
|
495
|
+
def async_lock(self):
|
496
|
+
"""Async lock."""
|
497
|
+
if not hasattr(self, "_async_lock") or self._async_lock is None:
|
498
|
+
self._async_lock = ConcurrencyLock()
|
499
|
+
return self._async_lock
|
500
|
+
|
481
501
|
# Async Interface methods
|
482
502
|
@async_synchronized
|
483
503
|
async def asetitem(
|
@@ -554,13 +574,12 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
554
574
|
|
555
575
|
async def __aiter__(self) -> AsyncIterator[T]:
|
556
576
|
"""Async iterate over items."""
|
557
|
-
async with self.
|
577
|
+
async with self.async_lock:
|
558
578
|
current_order = list(self.progression)
|
559
579
|
|
560
580
|
for key in current_order:
|
561
|
-
|
562
|
-
|
563
|
-
await asyncio.sleep(0) # Yield control to the event loop
|
581
|
+
yield self.collections[key]
|
582
|
+
await asyncio.sleep(0) # Yield control to the event loop
|
564
583
|
|
565
584
|
async def __anext__(self) -> T:
|
566
585
|
"""Async get next item."""
|
@@ -893,7 +912,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
893
912
|
|
894
913
|
async def __aenter__(self) -> Self:
|
895
914
|
"""Enter async context."""
|
896
|
-
await self.
|
915
|
+
await self.async_lock.__aenter__()
|
897
916
|
return self
|
898
917
|
|
899
918
|
async def __aexit__(
|
@@ -903,7 +922,7 @@ class Pile(Element, Collective[E], Generic[E], Adaptable, AsyncAdaptable):
|
|
903
922
|
exc_tb: Any,
|
904
923
|
) -> None:
|
905
924
|
"""Exit async context."""
|
906
|
-
await self.
|
925
|
+
await self.async_lock.__aexit__(exc_type, exc_val, exc_tb)
|
907
926
|
|
908
927
|
def is_homogenous(self) -> bool:
|
909
928
|
"""Check if all items are same type."""
|
@@ -2,6 +2,8 @@
|
|
2
2
|
#
|
3
3
|
# SPDX-License-Identifier: Apache-2.0
|
4
4
|
|
5
|
+
from __future__ import annotations
|
6
|
+
|
5
7
|
import json
|
6
8
|
from pathlib import Path
|
7
9
|
from typing import Any
|
@@ -147,7 +149,7 @@ class RoledMessage(Node, Sendable):
|
|
147
149
|
"""
|
148
150
|
return self._flag == MessageFlag.MESSAGE_CLONE
|
149
151
|
|
150
|
-
def clone(self, keep_role: bool = True) ->
|
152
|
+
def clone(self, keep_role: bool = True) -> RoledMessage:
|
151
153
|
"""
|
152
154
|
Create a shallow copy of this message, possibly resetting the role.
|
153
155
|
|
@@ -0,0 +1,234 @@
|
|
1
|
+
# Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
|
2
|
+
#
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
4
|
+
|
5
|
+
from __future__ import annotations
|
6
|
+
|
7
|
+
import json
|
8
|
+
from pathlib import Path
|
9
|
+
from typing import Any, Literal
|
10
|
+
|
11
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
12
|
+
|
13
|
+
from lionagi.utils import is_import_installed
|
14
|
+
|
15
|
+
HAS_CLAUDE_CODE_SDK = is_import_installed("claude_code_sdk")
|
16
|
+
|
17
|
+
# --------------------------------------------------------------------------- constants
|
18
|
+
ClaudePermission = Literal[
|
19
|
+
"default",
|
20
|
+
"acceptEdits",
|
21
|
+
"bypassPermissions",
|
22
|
+
"dangerously-skip-permissions",
|
23
|
+
]
|
24
|
+
|
25
|
+
CLAUDE_CODE_OPTION_PARAMS = {
|
26
|
+
"allowed_tools",
|
27
|
+
"max_thinking_tokens",
|
28
|
+
"mcp_tools",
|
29
|
+
"mcp_servers",
|
30
|
+
"permission_mode",
|
31
|
+
"continue_conversation",
|
32
|
+
"resume",
|
33
|
+
"max_turns",
|
34
|
+
"disallowed_tools",
|
35
|
+
"model",
|
36
|
+
"permission_prompt_tool_name",
|
37
|
+
"cwd",
|
38
|
+
"system_prompt",
|
39
|
+
"append_system_prompt",
|
40
|
+
}
|
41
|
+
|
42
|
+
|
43
|
+
# --------------------------------------------------------------------------- request model
|
44
|
+
class ClaudeCodeRequest(BaseModel):
|
45
|
+
# -- conversational bits -------------------------------------------------
|
46
|
+
prompt: str = Field(description="The prompt for Claude Code")
|
47
|
+
system_prompt: str | None = None
|
48
|
+
append_system_prompt: str | None = None
|
49
|
+
max_turns: int | None = None
|
50
|
+
continue_conversation: bool = False
|
51
|
+
resume: str | None = None
|
52
|
+
|
53
|
+
# -- repo / workspace ----------------------------------------------------
|
54
|
+
repo: Path = Field(default_factory=Path.cwd, exclude=True)
|
55
|
+
ws: str | None = None # sub-directory under repo
|
56
|
+
add_dir: str | None = None # extra read-only mount
|
57
|
+
allowed_tools: list[str] | None = None
|
58
|
+
|
59
|
+
# -- runtime & safety ----------------------------------------------------
|
60
|
+
model: Literal["sonnet", "opus"] | str | None = "sonnet"
|
61
|
+
max_thinking_tokens: int | None = None
|
62
|
+
mcp_tools: list[str] = Field(default_factory=list)
|
63
|
+
mcp_servers: dict[str, Any] = Field(default_factory=dict)
|
64
|
+
permission_mode: ClaudePermission | None = None
|
65
|
+
permission_prompt_tool_name: str | None = None
|
66
|
+
disallowed_tools: list[str] = Field(default_factory=list)
|
67
|
+
|
68
|
+
# -- internal use --------------------------------------------------------
|
69
|
+
auto_finish: bool = Field(
|
70
|
+
default=False,
|
71
|
+
description="Automatically finish the conversation after the first response",
|
72
|
+
)
|
73
|
+
verbose_output: bool = Field(default=False)
|
74
|
+
cli_display_theme: Literal["light", "dark"] = "light"
|
75
|
+
|
76
|
+
# ------------------------ validators & helpers --------------------------
|
77
|
+
@field_validator("permission_mode", mode="before")
|
78
|
+
def _norm_perm(cls, v):
|
79
|
+
if v in {
|
80
|
+
"dangerously-skip-permissions",
|
81
|
+
"--dangerously-skip-permissions",
|
82
|
+
}:
|
83
|
+
return "bypassPermissions"
|
84
|
+
return v
|
85
|
+
|
86
|
+
# Workspace path derived from repo + ws
|
87
|
+
def cwd(self) -> Path:
|
88
|
+
if not self.ws:
|
89
|
+
return self.repo
|
90
|
+
|
91
|
+
# Convert to Path object for proper validation
|
92
|
+
ws_path = Path(self.ws)
|
93
|
+
|
94
|
+
# Check for absolute paths or directory traversal attempts
|
95
|
+
if ws_path.is_absolute():
|
96
|
+
raise ValueError(
|
97
|
+
f"Workspace path must be relative, got absolute: {self.ws}"
|
98
|
+
)
|
99
|
+
|
100
|
+
if ".." in ws_path.parts:
|
101
|
+
raise ValueError(
|
102
|
+
f"Directory traversal detected in workspace path: {self.ws}"
|
103
|
+
)
|
104
|
+
|
105
|
+
# Resolve paths to handle symlinks and normalize
|
106
|
+
repo_resolved = self.repo.resolve()
|
107
|
+
result = (self.repo / ws_path).resolve()
|
108
|
+
|
109
|
+
# Ensure the resolved path is within the repository bounds
|
110
|
+
try:
|
111
|
+
result.relative_to(repo_resolved)
|
112
|
+
except ValueError:
|
113
|
+
raise ValueError(
|
114
|
+
f"Workspace path escapes repository bounds. "
|
115
|
+
f"Repository: {repo_resolved}, Workspace: {result}"
|
116
|
+
)
|
117
|
+
|
118
|
+
return result
|
119
|
+
|
120
|
+
@model_validator(mode="after")
|
121
|
+
def _check_perm_workspace(self):
|
122
|
+
if self.permission_mode == "bypassPermissions":
|
123
|
+
# Use secure path validation with resolved paths
|
124
|
+
repo_resolved = self.repo.resolve()
|
125
|
+
cwd_resolved = self.cwd().resolve()
|
126
|
+
|
127
|
+
# Check if cwd is within repo bounds using proper path methods
|
128
|
+
try:
|
129
|
+
cwd_resolved.relative_to(repo_resolved)
|
130
|
+
except ValueError:
|
131
|
+
raise ValueError(
|
132
|
+
f"With bypassPermissions, workspace must be within repository bounds. "
|
133
|
+
f"Repository: {repo_resolved}, Workspace: {cwd_resolved}"
|
134
|
+
)
|
135
|
+
return self
|
136
|
+
|
137
|
+
# ------------------------ CLI helpers -----------------------------------
|
138
|
+
def as_cmd_args(self) -> list[str]:
|
139
|
+
"""Build argument list for the *Node* `claude` CLI."""
|
140
|
+
args: list[str] = ["-p", self.prompt, "--output-format", "stream-json"]
|
141
|
+
if self.allowed_tools:
|
142
|
+
args.append("--allowedTools")
|
143
|
+
for tool in self.allowed_tools:
|
144
|
+
args.append(f'"{tool}"')
|
145
|
+
|
146
|
+
if self.disallowed_tools:
|
147
|
+
args.append("--disallowedTools")
|
148
|
+
for tool in self.disallowed_tools:
|
149
|
+
args.append(f'"{tool}"')
|
150
|
+
|
151
|
+
if self.resume:
|
152
|
+
args += ["--resume", self.resume]
|
153
|
+
elif self.continue_conversation:
|
154
|
+
args.append("--continue")
|
155
|
+
|
156
|
+
if self.max_turns:
|
157
|
+
# +1 because CLI counts *pairs*
|
158
|
+
args += ["--max-turns", str(self.max_turns + 1)]
|
159
|
+
|
160
|
+
if self.permission_mode == "bypassPermissions":
|
161
|
+
args += ["--dangerously-skip-permissions"]
|
162
|
+
|
163
|
+
if self.add_dir:
|
164
|
+
args += ["--add-dir", self.add_dir]
|
165
|
+
|
166
|
+
args += ["--model", self.model or "sonnet", "--verbose"]
|
167
|
+
return args
|
168
|
+
|
169
|
+
# ------------------------ SDK helpers -----------------------------------
|
170
|
+
def as_claude_options(self):
|
171
|
+
from claude_code_sdk import ClaudeCodeOptions
|
172
|
+
|
173
|
+
data = {
|
174
|
+
k: v
|
175
|
+
for k, v in self.model_dump(exclude_none=True).items()
|
176
|
+
if k in CLAUDE_CODE_OPTION_PARAMS
|
177
|
+
}
|
178
|
+
return ClaudeCodeOptions(**data)
|
179
|
+
|
180
|
+
# ------------------------ convenience constructor -----------------------
|
181
|
+
@classmethod
|
182
|
+
def create(
|
183
|
+
cls,
|
184
|
+
messages: list[dict[str, Any]],
|
185
|
+
resume: str | None = None,
|
186
|
+
continue_conversation: bool | None = None,
|
187
|
+
**kwargs,
|
188
|
+
):
|
189
|
+
if not messages:
|
190
|
+
raise ValueError("messages may not be empty")
|
191
|
+
|
192
|
+
prompt = ""
|
193
|
+
|
194
|
+
# 1. if resume or continue_conversation, use the last message
|
195
|
+
if resume or continue_conversation:
|
196
|
+
continue_conversation = True
|
197
|
+
prompt = messages[-1]["content"]
|
198
|
+
if isinstance(prompt, (dict, list)):
|
199
|
+
prompt = json.dumps(prompt)
|
200
|
+
|
201
|
+
# 2. else, use entire messages except system message
|
202
|
+
else:
|
203
|
+
prompts = []
|
204
|
+
continue_conversation = False
|
205
|
+
for message in messages:
|
206
|
+
if message["role"] != "system":
|
207
|
+
content = message["content"]
|
208
|
+
prompts.append(
|
209
|
+
json.dumps(content)
|
210
|
+
if isinstance(content, (dict, list))
|
211
|
+
else content
|
212
|
+
)
|
213
|
+
|
214
|
+
prompt = "\n".join(prompts)
|
215
|
+
|
216
|
+
# 3. assemble the request data
|
217
|
+
data: dict[str, Any] = dict(
|
218
|
+
prompt=prompt,
|
219
|
+
resume=resume,
|
220
|
+
continue_conversation=bool(continue_conversation),
|
221
|
+
)
|
222
|
+
|
223
|
+
# 4. extract system prompt if available
|
224
|
+
if (messages[0]["role"] == "system") and (
|
225
|
+
resume or continue_conversation
|
226
|
+
):
|
227
|
+
data["system_prompt"] = messages[0]["content"]
|
228
|
+
if kwargs.get("append_system_prompt"):
|
229
|
+
data["append_system_prompt"] = str(
|
230
|
+
kwargs.get("append_system_prompt")
|
231
|
+
)
|
232
|
+
|
233
|
+
data.update(kwargs)
|
234
|
+
return cls.model_validate(data, strict=False)
|