beswarm 0.2.82__py3-none-any.whl → 0.2.83__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.
@@ -4,9 +4,51 @@ import asyncio
4
4
  import logging
5
5
  import hashlib
6
6
  import mimetypes
7
+ import uuid
8
+ import threading
7
9
  from dataclasses import dataclass
8
10
  from abc import ABC, abstractmethod
9
- from typing import List, Dict, Any, Optional, Union
11
+ from typing import List, Dict, Any, Optional, Union, Callable
12
+
13
+ # A wrapper to manage multiple providers with the same name
14
+ class ProviderGroup:
15
+ """A container for multiple providers that share the same name, allowing for bulk operations."""
16
+ def __init__(self, providers: List['ContextProvider']):
17
+ self._providers = providers
18
+ def __getitem__(self, key: int) -> 'ContextProvider':
19
+ """Allows accessing providers by index, e.g., group[-1]."""
20
+ return self._providers[key]
21
+ def __iter__(self):
22
+ """Allows iterating over the providers."""
23
+ return iter(self._providers)
24
+ def __len__(self) -> int:
25
+ """Returns the number of providers in the group."""
26
+ return len(self._providers)
27
+ @property
28
+ def visible(self) -> List[bool]:
29
+ """Gets the visibility of all providers in the group."""
30
+ return [p.visible for p in self._providers]
31
+ @visible.setter
32
+ def visible(self, value: bool):
33
+ """Sets the visibility for all providers in the group."""
34
+ for p in self._providers:
35
+ p.visible = value
36
+
37
+ # Global, thread-safe registry for providers created within f-strings
38
+ _fstring_provider_registry = {}
39
+ _registry_lock = threading.Lock()
40
+
41
+ def _register_provider(provider: 'ContextProvider') -> str:
42
+ """Registers a provider and returns a unique placeholder."""
43
+ with _registry_lock:
44
+ provider_id = f"__provider_placeholder_{uuid.uuid4().hex}__"
45
+ _fstring_provider_registry[provider_id] = provider
46
+ return provider_id
47
+
48
+ def _retrieve_provider(placeholder: str) -> Optional['ContextProvider']:
49
+ """Retrieves a provider from the registry."""
50
+ with _registry_lock:
51
+ return _fstring_provider_registry.pop(placeholder, None)
10
52
 
11
53
  # 1. 核心数据结构: ContentBlock
12
54
  @dataclass
@@ -17,8 +59,30 @@ class ContentBlock:
17
59
  # 2. 上下文提供者 (带缓存)
18
60
  class ContextProvider(ABC):
19
61
  def __init__(self, name: str):
20
- self.name = name; self._cached_content: Optional[str] = None; self._is_stale: bool = True
62
+ self.name = name
63
+ self._cached_content: Optional[str] = None
64
+ self._is_stale: bool = True
65
+ self._visible: bool = True
66
+
67
+ def __str__(self):
68
+ # This allows the object to be captured when used inside an f-string.
69
+ return _register_provider(self)
70
+
21
71
  def mark_stale(self): self._is_stale = True
72
+
73
+ @property
74
+ def visible(self) -> bool:
75
+ """Gets the visibility of the provider."""
76
+ return self._visible
77
+
78
+ @visible.setter
79
+ def visible(self, value: bool):
80
+ """Sets the visibility of the provider."""
81
+ if self._visible != value:
82
+ self._visible = value
83
+ # Content needs to be re-evaluated, but the source data hasn't changed,
84
+ # so just marking it stale is enough for the renderer to reconsider it.
85
+ self.mark_stale()
22
86
  async def refresh(self):
23
87
  if self._is_stale:
24
88
  self._cached_content = await self.render()
@@ -28,35 +92,113 @@ class ContextProvider(ABC):
28
92
  @abstractmethod
29
93
  def update(self, *args, **kwargs): raise NotImplementedError
30
94
  def get_content_block(self) -> Optional[ContentBlock]:
31
- if self._cached_content is not None: return ContentBlock(self.name, self._cached_content)
95
+ if self.visible and self._cached_content is not None:
96
+ return ContentBlock(self.name, self._cached_content)
32
97
  return None
33
98
 
34
99
  class Texts(ContextProvider):
35
- def __init__(self, text: str, name: Optional[str] = None):
36
- self._text = text
100
+ def __init__(self, text: Optional[Union[str, Callable[[], str]]] = None, name: Optional[str] = None):
101
+ if text is None and name is None:
102
+ raise ValueError("Either 'text' or 'name' must be provided.")
103
+
104
+ # Ensure that non-callable inputs are treated as strings
105
+ if not callable(text):
106
+ self._text = str(text) if text is not None else None
107
+ else:
108
+ self._text = text
109
+
110
+ self._is_dynamic = callable(self._text)
111
+
37
112
  if name is None:
38
- h = hashlib.sha1(self._text.encode()).hexdigest()
39
- _name = f"text_{h[:8]}"
113
+ if self._is_dynamic:
114
+ import uuid
115
+ _name = f"dynamic_text_{uuid.uuid4().hex[:8]}"
116
+ else:
117
+ # Handle the case where text is None during initialization
118
+ h = hashlib.sha1(self._text.encode() if self._text else b'').hexdigest()
119
+ _name = f"text_{h[:8]}"
40
120
  else:
41
121
  _name = name
42
122
  super().__init__(_name)
43
123
 
44
- def update(self, text: str):
124
+ async def refresh(self):
125
+ if self._is_dynamic:
126
+ self._is_stale = True
127
+ await super().refresh()
128
+
129
+ def update(self, text: Union[str, Callable[[], str]]):
45
130
  self._text = text
131
+ self._is_dynamic = callable(self._text)
46
132
  self.mark_stale()
47
133
 
48
- async def render(self) -> str: return self._text
134
+ @property
135
+ def content(self) -> Optional[str]:
136
+ """
137
+ Synchronously retrieves the raw text content as a property.
138
+ If the content is dynamic (a callable), it executes the callable.
139
+ """
140
+ if self._is_dynamic:
141
+ # Ensure dynamic content returns a string, even if empty
142
+ result = self._text()
143
+ return result if result is not None else ""
144
+ # Ensure static content returns a string, even if empty
145
+ return self._text if self._text is not None else ""
146
+
147
+ async def render(self) -> Optional[str]:
148
+ return self.content
149
+
150
+ def __getstate__(self):
151
+ """Custom state for pickling."""
152
+ state = self.__dict__.copy()
153
+ if self._is_dynamic:
154
+ # For dynamic content, we snapshot its current value for serialization.
155
+ # The lambda function itself cannot be pickled.
156
+ try:
157
+ # Evaluate the lambda and store it as a static string
158
+ state['_text'] = self.content
159
+ # Mark it as no longer dynamic in the pickled state
160
+ state['_is_dynamic'] = False
161
+ except Exception as e:
162
+ # If the lambda fails for some reason, store an error message.
163
+ logging.error(f"Error evaluating dynamic text '{self.name}' during pickling: {e}")
164
+ state['_text'] = f"[Error: Could not evaluate dynamic content during save: {e}]"
165
+ state['_is_dynamic'] = False
166
+ return state
167
+
168
+ def __setstate__(self, state):
169
+ """Custom state for unpickling."""
170
+ # Just restore the dictionary. The transformation is one-way.
171
+ self.__dict__.update(state)
172
+
173
+ def __eq__(self, other):
174
+ if not isinstance(other, Texts):
175
+ return NotImplemented
176
+ # If either object is dynamic, they are only equal if they are the exact same object.
177
+ if self._is_dynamic or (hasattr(other, '_is_dynamic') and other._is_dynamic):
178
+ return self is other
179
+ # For static content, compare the actual content.
180
+ return self.content == other.content
49
181
 
50
182
  class Tools(ContextProvider):
51
- def __init__(self, tools_json: List[Dict]): super().__init__("tools"); self._tools_json = tools_json
183
+ def __init__(self, tools_json: Optional[List[Dict]] = None, name: str = "tools"):
184
+ super().__init__(name)
185
+ self._tools_json = tools_json or []
52
186
  def update(self, tools_json: List[Dict]):
53
187
  self._tools_json = tools_json
54
188
  self.mark_stale()
55
- async def render(self) -> str: return f"<tools>{str(self._tools_json)}</tools>"
189
+ async def render(self) -> Optional[str]:
190
+ if not self._tools_json:
191
+ return None
192
+ return f"<tools>{str(self._tools_json)}</tools>"
193
+
194
+ def __eq__(self, other):
195
+ if not isinstance(other, Tools):
196
+ return NotImplemented
197
+ return self._tools_json == other._tools_json
56
198
 
57
199
  class Files(ContextProvider):
58
- def __init__(self, *paths: Union[str, List[str]]):
59
- super().__init__("files")
200
+ def __init__(self, *paths: Union[str, List[str]], name: str = "files"):
201
+ super().__init__(name)
60
202
  self._files: Dict[str, str] = {}
61
203
 
62
204
  file_paths: List[str] = []
@@ -130,6 +272,11 @@ class Files(ContextProvider):
130
272
  if not self._files: return None
131
273
  return "<latest_file_content>" + "\n".join([f"<file><file_path>{p}</file_path><file_content>{c}</file_content></file>" for p, c in self._files.items()]) + "\n</latest_file_content>"
132
274
 
275
+ def __eq__(self, other):
276
+ if not isinstance(other, Files):
277
+ return NotImplemented
278
+ return self._files == other._files
279
+
133
280
  class Images(ContextProvider):
134
281
  def __init__(self, url: str, name: Optional[str] = None):
135
282
  super().__init__(name or url)
@@ -150,6 +297,11 @@ class Images(ContextProvider):
150
297
  logging.warning(f"Image file not found: {self.url}. Skipping.")
151
298
  return None # Or handle error appropriately
152
299
 
300
+ def __eq__(self, other):
301
+ if not isinstance(other, Images):
302
+ return NotImplemented
303
+ return self.url == other.url
304
+
153
305
  # 3. 消息类 (已合并 MessageContent)
154
306
  class Message(ABC):
155
307
  def __init__(self, role: str, *initial_items: Union[ContextProvider, str, list]):
@@ -157,9 +309,25 @@ class Message(ABC):
157
309
  processed_items = []
158
310
  for item in initial_items:
159
311
  if isinstance(item, str):
160
- processed_items.append(Texts(text=item))
312
+ # Check if the string contains placeholders from f-string rendering
313
+ import re
314
+ placeholder_pattern = re.compile(r'(__provider_placeholder_[a-f0-9]{32}__)')
315
+ parts = placeholder_pattern.split(item)
316
+
317
+ if len(parts) > 1: # Placeholders were found
318
+ for part in parts:
319
+ if not part: continue
320
+ if placeholder_pattern.match(part):
321
+ provider = _retrieve_provider(part)
322
+ if provider:
323
+ processed_items.append(provider)
324
+ else:
325
+ processed_items.append(Texts(text=part))
326
+ else: # No placeholders, just a regular string
327
+ processed_items.append(Texts(text=item))
328
+
161
329
  elif isinstance(item, Message):
162
- processed_items.extend(item.providers())
330
+ processed_items.extend(item.provider())
163
331
  elif isinstance(item, ContextProvider):
164
332
  processed_items.append(item)
165
333
  elif isinstance(item, list):
@@ -181,9 +349,24 @@ class Message(ABC):
181
349
  self._items: List[ContextProvider] = processed_items
182
350
  self._parent_messages: Optional['Messages'] = None
183
351
 
352
+ @property
353
+ def content(self) -> Optional[Union[str, List[Dict[str, Any]]]]:
354
+ """
355
+ Renders the message content.
356
+ For simple text messages, returns a string.
357
+ For multimodal messages, returns a list of content blocks.
358
+ """
359
+ rendered_dict = self.to_dict()
360
+ return rendered_dict.get('content') if rendered_dict else None
361
+
184
362
  def _render_content(self) -> str:
185
- blocks = [item.get_content_block() for item in self._items]
186
- return "\n\n".join(b.content for b in blocks if b and b.content)
363
+ final_parts = []
364
+ for item in self._items:
365
+ block = item.get_content_block()
366
+ if block and block.content is not None:
367
+ final_parts.append(block.content)
368
+
369
+ return "".join(final_parts)
187
370
 
188
371
  def pop(self, name: str) -> Optional[ContextProvider]:
189
372
  popped_item = None
@@ -205,14 +388,24 @@ class Message(ABC):
205
388
  if self._parent_messages:
206
389
  self._parent_messages._notify_provider_added(item, self)
207
390
 
208
- def providers(self) -> List[ContextProvider]: return self._items
391
+ def provider(self, name: Optional[str] = None) -> Optional[Union[ContextProvider, ProviderGroup, List[ContextProvider]]]:
392
+ if name is None:
393
+ return self._items
394
+
395
+ named_providers = [p for p in self._items if hasattr(p, 'name') and p.name == name]
396
+
397
+ if not named_providers:
398
+ return None
399
+ if len(named_providers) == 1:
400
+ return named_providers[0]
401
+ return ProviderGroup(named_providers)
209
402
 
210
403
  def __add__(self, other):
211
404
  if isinstance(other, str):
212
405
  new_items = self._items + [Texts(text=other)]
213
406
  return type(self)(*new_items)
214
407
  if isinstance(other, Message):
215
- new_items = self._items + other.providers()
408
+ new_items = self._items + other.provider()
216
409
  return type(self)(*new_items)
217
410
  return NotImplemented
218
411
 
@@ -221,30 +414,40 @@ class Message(ABC):
221
414
  new_items = [Texts(text=other)] + self._items
222
415
  return type(self)(*new_items)
223
416
  if isinstance(other, Message):
224
- new_items = other.providers() + self._items
417
+ new_items = other.provider() + self._items
225
418
  return type(self)(*new_items)
226
419
  return NotImplemented
227
420
 
228
- def __getitem__(self, key: str) -> Any:
421
+ def __getitem__(self, key: Union[str, int]) -> Any:
229
422
  """
230
- 使得 Message 对象支持字典风格的访问 (e.g., message['content'])
423
+ 使得 Message 对象支持字典风格的访问 (e.g., message['content'])
424
+ 和列表风格的索引访问 (e.g., message[-1])。
231
425
  """
232
- if key == 'role':
233
- return self.role
234
- elif key == 'content':
235
- # 直接调用 to_dict 并提取 'content',确保逻辑一致
236
- rendered_dict = self.to_dict()
237
- return rendered_dict.get('content') if rendered_dict else None
238
- # 对于 tool_calls 等特殊属性,也通过 to_dict 获取
239
- elif hasattr(self, key):
240
- rendered_dict = self.to_dict()
241
- if rendered_dict and key in rendered_dict:
242
- return rendered_dict[key]
243
-
244
- # 如果在对象本身或其 to_dict() 中都找不到,则引发 KeyError
245
- if hasattr(self, key):
246
- return getattr(self, key)
247
- raise KeyError(f"'{key}'")
426
+ if isinstance(key, str):
427
+ if key == 'role':
428
+ return self.role
429
+ elif key == 'content':
430
+ # 直接调用 to_dict 并提取 'content',确保逻辑一致
431
+ rendered_dict = self.to_dict()
432
+ return rendered_dict.get('content') if rendered_dict else None
433
+ # 对于 tool_calls 等特殊属性,也通过 to_dict 获取
434
+ elif hasattr(self, key):
435
+ rendered_dict = self.to_dict()
436
+ if rendered_dict and key in rendered_dict:
437
+ return rendered_dict[key]
438
+
439
+ # 如果在对象本身或其 to_dict() 中都找不到,则引发 KeyError
440
+ if hasattr(self, key):
441
+ return getattr(self, key)
442
+ raise KeyError(f"'{key}'")
443
+ elif isinstance(key, int):
444
+ return self._items[key]
445
+ else:
446
+ raise TypeError(f"Message indices must be integers or strings, not {type(key).__name__}")
447
+
448
+ def __len__(self) -> int:
449
+ """返回消息中 provider 的数量。"""
450
+ return len(self._items)
248
451
 
249
452
  def __repr__(self): return f"Message(role='{self.role}', items={[i.name for i in self._items]})"
250
453
  def __bool__(self) -> bool:
@@ -323,15 +526,17 @@ class ToolCalls(Message):
323
526
  class ToolResults(Message):
324
527
  """Represents a tool message with the result of a single tool call."""
325
528
  def __init__(self, tool_call_id: str, content: str):
326
- super().__init__("tool")
529
+ # We pass a Texts provider to the parent so it can be rendered,
530
+ # but the primary way to access content for ToolResults is via its dict representation.
531
+ super().__init__("tool", Texts(text=content))
327
532
  self.tool_call_id = tool_call_id
328
- self.content = content
533
+ self._content = content
329
534
 
330
535
  def to_dict(self) -> Dict[str, Any]:
331
536
  return {
332
537
  "role": self.role,
333
538
  "tool_call_id": self.tool_call_id,
334
- "content": self.content
539
+ "content": self._content
335
540
  }
336
541
 
337
542
  # 4. 顶层容器: Messages
@@ -339,22 +544,40 @@ class Messages:
339
544
  def __init__(self, *initial_messages: Message):
340
545
  from typing import Tuple
341
546
  self._messages: List[Message] = []
342
- self._providers_index: Dict[str, Tuple[ContextProvider, Message]] = {}
547
+ self._providers_index: Dict[str, List[Tuple[ContextProvider, Message]]] = {}
343
548
  if initial_messages:
344
549
  for msg in initial_messages:
345
550
  self.append(msg)
346
551
 
347
552
  def _notify_provider_added(self, provider: ContextProvider, message: Message):
348
553
  if provider.name not in self._providers_index:
349
- self._providers_index[provider.name] = (provider, message)
554
+ self._providers_index[provider.name] = []
555
+ self._providers_index[provider.name].append((provider, message))
350
556
 
351
557
  def _notify_provider_removed(self, provider: ContextProvider):
352
558
  if provider.name in self._providers_index:
353
- del self._providers_index[provider.name]
559
+ # Create a new list excluding the provider to be removed.
560
+ # Comparing by object identity (`is`) is crucial here.
561
+ providers_list = self._providers_index[provider.name]
562
+ new_list = [(p, m) for p, m in providers_list if p is not provider]
563
+
564
+ if not new_list:
565
+ # If the list becomes empty, remove the key from the dictionary.
566
+ del self._providers_index[provider.name]
567
+ else:
568
+ # Otherwise, update the dictionary with the new list.
569
+ self._providers_index[provider.name] = new_list
570
+
571
+ def provider(self, name: str) -> Optional[Union[ContextProvider, ProviderGroup]]:
572
+ indexed_list = self._providers_index.get(name)
573
+ if not indexed_list:
574
+ return None
354
575
 
355
- def provider(self, name: str) -> Optional[ContextProvider]:
356
- indexed = self._providers_index.get(name)
357
- return indexed[0] if indexed else None
576
+ providers = [p for p, m in indexed_list]
577
+ if len(providers) == 1:
578
+ return providers[0]
579
+ else:
580
+ return ProviderGroup(providers)
358
581
 
359
582
  def pop(self, key: Optional[Union[str, int]] = None) -> Union[Optional[ContextProvider], Optional[Message]]:
360
583
  # If no key is provided, pop the last message.
@@ -362,10 +585,13 @@ class Messages:
362
585
  key = len(self._messages) - 1
363
586
 
364
587
  if isinstance(key, str):
365
- indexed = self._providers_index.get(key)
366
- if not indexed:
588
+ indexed_list = self._providers_index.get(key)
589
+ if not indexed_list:
367
590
  return None
368
- _provider, parent_message = indexed
591
+ # Pop the first one found, which is consistent with how pop usually works
592
+ _provider, parent_message = indexed_list[0]
593
+ # The actual removal from _providers_index happens in _notify_provider_removed
594
+ # which is called by message.pop()
369
595
  return parent_message.pop(key)
370
596
  elif isinstance(key, int):
371
597
  try:
@@ -375,7 +601,7 @@ class Messages:
375
601
  return None
376
602
  popped_message = self._messages.pop(key)
377
603
  popped_message._parent_messages = None
378
- for provider in popped_message.providers():
604
+ for provider in popped_message.provider():
379
605
  self._notify_provider_removed(provider)
380
606
  return popped_message
381
607
  except IndexError:
@@ -384,7 +610,10 @@ class Messages:
384
610
  return None
385
611
 
386
612
  async def refresh(self):
387
- tasks = [provider.refresh() for provider, _ in self._providers_index.values()]
613
+ tasks = []
614
+ for provider_list in self._providers_index.values():
615
+ for provider, _ in provider_list:
616
+ tasks.append(provider.refresh())
388
617
  await asyncio.gather(*tasks)
389
618
 
390
619
  def render(self) -> List[Dict[str, Any]]:
@@ -398,12 +627,12 @@ class Messages:
398
627
  def append(self, message: Message):
399
628
  if self._messages and self._messages[-1].role == message.role:
400
629
  last_message = self._messages[-1]
401
- for provider in message.providers():
630
+ for provider in message.provider():
402
631
  last_message.append(provider)
403
632
  else:
404
633
  message._parent_messages = self
405
634
  self._messages.append(message)
406
- for p in message.providers():
635
+ for p in message.provider():
407
636
  self._notify_provider_added(p, message)
408
637
 
409
638
  def save(self, file_path: str):
@@ -425,12 +654,41 @@ class Messages:
425
654
  with open(file_path, 'rb') as f:
426
655
  return pickle.load(f)
427
656
  except FileNotFoundError:
428
- logging.warning(f"File not found at {file_path}, returning empty Messages.")
657
+ # logging.warning(f"File not found at {file_path}, returning empty Messages.")
429
658
  return cls()
430
659
  except (pickle.UnpicklingError, EOFError) as e:
431
660
  logging.error(f"Could not deserialize file {file_path}: {e}")
432
661
  return cls()
433
662
 
434
- def __getitem__(self, index: int) -> Message: return self._messages[index]
663
+ def __getitem__(self, index: Union[int, slice]) -> Union[Message, 'Messages']:
664
+ if isinstance(index, slice):
665
+ return Messages(*self._messages[index])
666
+ return self._messages[index]
667
+
668
+ def __setitem__(self, index: slice, value: 'Messages'):
669
+ if not isinstance(index, slice) or not isinstance(value, Messages):
670
+ raise TypeError("Unsupported operand type(s) for slice assignment")
671
+
672
+ # Basic slice assignment logic.
673
+ # A more robust implementation would handle step and negative indices.
674
+ start, stop, step = index.indices(len(self._messages))
675
+
676
+ if step != 1:
677
+ raise ValueError("Slice assignment with step is not supported.")
678
+
679
+ # Remove old providers from the index
680
+ for i in range(start, stop):
681
+ for provider in self._messages[i].provider():
682
+ self._notify_provider_removed(provider)
683
+
684
+ # Replace the slice in the list
685
+ self._messages[start:stop] = value._messages
686
+
687
+ # Add new providers to the index and set parent
688
+ for msg in value:
689
+ msg._parent_messages = self
690
+ for provider in msg.provider():
691
+ self._notify_provider_added(provider, msg)
692
+
435
693
  def __len__(self) -> int: return len(self._messages)
436
694
  def __iter__(self): return iter(self._messages)