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.
@@ -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
- __all__ = ("Pile",)
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.lock:
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=ConcurrencyLock),
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
- if key in self.collections:
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 = ConcurrencyLock()
484
+ self._lock = threading.Lock()
485
+ self._async_lock = ConcurrencyLock()
473
486
 
474
487
  @property
475
488
  def lock(self):
476
- """Unified concurrency lock for both sync and async operations."""
489
+ """Thread lock."""
477
490
  if not hasattr(self, "_lock") or self._lock is None:
478
- self._lock = ConcurrencyLock()
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.lock:
577
+ async with self.async_lock:
558
578
  current_order = list(self.progression)
559
579
 
560
580
  for key in current_order:
561
- if key in self.collections:
562
- yield self.collections[key]
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.lock.__aenter__()
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.lock.__aexit__(exc_type, exc_val, exc_tb)
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) -> "RoledMessage":
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,3 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -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)