zwarm 1.0.0__py3-none-any.whl → 1.1.1__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.
zwarm/core/compact.py CHANGED
@@ -16,6 +16,13 @@ from typing import Any
16
16
  logger = logging.getLogger(__name__)
17
17
 
18
18
 
19
+ def _get_attr(obj: Any, key: str, default: Any = None) -> Any:
20
+ """Get attribute from dict or object (handles both Pydantic models and dicts)."""
21
+ if isinstance(obj, dict):
22
+ return obj.get(key, default)
23
+ return getattr(obj, key, default)
24
+
25
+
19
26
  @dataclass
20
27
  class CompactionResult:
21
28
  """Result of a compaction operation."""
@@ -30,16 +37,17 @@ class CompactionResult:
30
37
  return self.removed_count > 0
31
38
 
32
39
 
33
- def estimate_tokens(messages: list[dict[str, Any]]) -> int:
40
+ def estimate_tokens(messages: list[Any]) -> int:
34
41
  """
35
42
  Rough token estimate for messages.
36
43
 
37
44
  Uses ~4 chars per token as a simple heuristic.
38
45
  This is intentionally conservative.
46
+ Handles both dict messages and Pydantic model messages.
39
47
  """
40
48
  total_chars = 0
41
49
  for msg in messages:
42
- content = msg.get("content", "")
50
+ content = _get_attr(msg, "content", "")
43
51
  if isinstance(content, str):
44
52
  total_chars += len(content)
45
53
  elif isinstance(content, list):
@@ -50,16 +58,22 @@ def estimate_tokens(messages: list[dict[str, Any]]) -> int:
50
58
  total_chars += len(str(block.get("input", "")))
51
59
  elif isinstance(block, str):
52
60
  total_chars += len(block)
61
+ else:
62
+ # Pydantic model block
63
+ total_chars += len(str(_get_attr(block, "text", "")))
64
+ total_chars += len(str(_get_attr(block, "input", "")))
53
65
 
54
66
  # Tool calls add tokens too
55
- tool_calls = msg.get("tool_calls", [])
67
+ tool_calls = _get_attr(msg, "tool_calls", []) or []
56
68
  for tc in tool_calls:
57
- total_chars += len(str(tc.get("function", {}).get("arguments", "")))
69
+ func = _get_attr(tc, "function", {}) or {}
70
+ args = _get_attr(func, "arguments", "") if isinstance(func, dict) else getattr(func, "arguments", "")
71
+ total_chars += len(str(args))
58
72
 
59
73
  return total_chars // 4
60
74
 
61
75
 
62
- def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
76
+ def find_tool_groups(messages: list[Any]) -> list[tuple[int, int]]:
63
77
  """
64
78
  Find message index ranges that form tool call groups.
65
79
 
@@ -69,6 +83,7 @@ def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
69
83
 
70
84
  This handles both OpenAI format (role="tool") and Anthropic format
71
85
  (role="user" with tool_result content).
86
+ Also handles Pydantic model messages.
72
87
 
73
88
  Returns list of (start_idx, end_idx) tuples (inclusive).
74
89
  """
@@ -82,15 +97,16 @@ def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
82
97
  has_tool_calls = False
83
98
 
84
99
  # OpenAI format: tool_calls field
85
- if msg.get("role") == "assistant" and msg.get("tool_calls"):
100
+ if _get_attr(msg, "role") == "assistant" and _get_attr(msg, "tool_calls"):
86
101
  has_tool_calls = True
87
102
 
88
103
  # Anthropic format: content blocks with type="tool_use"
89
- if msg.get("role") == "assistant":
90
- content = msg.get("content", [])
104
+ if _get_attr(msg, "role") == "assistant":
105
+ content = _get_attr(msg, "content", [])
91
106
  if isinstance(content, list):
92
107
  for block in content:
93
- if isinstance(block, dict) and block.get("type") == "tool_use":
108
+ block_type = _get_attr(block, "type", None)
109
+ if block_type == "tool_use":
94
110
  has_tool_calls = True
95
111
  break
96
112
 
@@ -101,7 +117,7 @@ def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
101
117
  # Find all following tool responses
102
118
  while j < len(messages):
103
119
  next_msg = messages[j]
104
- role = next_msg.get("role", "")
120
+ role = _get_attr(next_msg, "role", "")
105
121
 
106
122
  # OpenAI format: tool role
107
123
  if role == "tool":
@@ -110,10 +126,10 @@ def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
110
126
 
111
127
  # Anthropic format: user message with tool_result
112
128
  if role == "user":
113
- content = next_msg.get("content", [])
129
+ content = _get_attr(next_msg, "content", [])
114
130
  if isinstance(content, list):
115
131
  has_tool_result = any(
116
- isinstance(b, dict) and b.get("type") == "tool_result"
132
+ _get_attr(b, "type", None) == "tool_result"
117
133
  for b in content
118
134
  )
119
135
  if has_tool_result:
@@ -132,7 +148,7 @@ def find_tool_groups(messages: list[dict[str, Any]]) -> list[tuple[int, int]]:
132
148
 
133
149
 
134
150
  def compact_messages(
135
- messages: list[dict[str, Any]],
151
+ messages: list[Any],
136
152
  keep_first_n: int = 2,
137
153
  keep_last_n: int = 10,
138
154
  max_tokens: int | None = None,
@@ -298,7 +314,7 @@ def compact_messages(
298
314
 
299
315
 
300
316
  def should_compact(
301
- messages: list[dict[str, Any]],
317
+ messages: list[Any],
302
318
  max_tokens: int,
303
319
  threshold_pct: float = 0.85,
304
320
  ) -> bool:
@@ -306,6 +322,7 @@ def should_compact(
306
322
  Check if messages should be compacted.
307
323
 
308
324
  Returns True if estimated tokens exceed threshold percentage of max.
325
+ Handles both dict messages and Pydantic model messages.
309
326
  """
310
327
  current = estimate_tokens(messages)
311
328
  threshold = int(max_tokens * threshold_pct)
@@ -264,3 +264,49 @@ class TestEdgeCases:
264
264
  result = compact_messages(messages, keep_first_n=2, keep_last_n=2)
265
265
  assert not result.was_compacted
266
266
  assert result.messages == messages
267
+
268
+
269
+ class TestPydanticModelMessages:
270
+ """Test handling of Pydantic model messages (not just dicts)."""
271
+
272
+ def test_estimate_tokens_with_objects(self):
273
+ """estimate_tokens should handle objects with attributes."""
274
+ class MockMessage:
275
+ def __init__(self, role, content):
276
+ self.role = role
277
+ self.content = content
278
+
279
+ messages = [
280
+ MockMessage("user", "Hello world"),
281
+ MockMessage("assistant", "Hi there!"),
282
+ ]
283
+ tokens = estimate_tokens(messages)
284
+ assert tokens > 0
285
+
286
+ def test_should_compact_with_objects(self):
287
+ """should_compact should handle objects with attributes."""
288
+ class MockMessage:
289
+ def __init__(self, role, content):
290
+ self.role = role
291
+ self.content = content
292
+
293
+ messages = [MockMessage("user", "x" * 4000)]
294
+ # Should not crash
295
+ result = should_compact(messages, max_tokens=500, threshold_pct=0.85)
296
+ assert result is True
297
+
298
+ def test_find_tool_groups_with_objects(self):
299
+ """find_tool_groups should handle objects with attributes."""
300
+ class MockMessage:
301
+ def __init__(self, role, content=None, tool_calls=None):
302
+ self.role = role
303
+ self.content = content
304
+ self.tool_calls = tool_calls
305
+
306
+ messages = [
307
+ MockMessage("user", "Task"),
308
+ MockMessage("assistant", "Done"),
309
+ ]
310
+ # Should not crash
311
+ groups = find_tool_groups(messages)
312
+ assert groups == []
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zwarm
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: Multi-Agent CLI Orchestration Research Platform
5
5
  Requires-Python: <3.14,>=3.13
6
6
  Requires-Dist: python-dotenv>=1.0.0
@@ -9,12 +9,12 @@ zwarm/adapters/test_codex_mcp.py,sha256=vodQF5VUrM_F1GygUADtXYrA0kvAc7dWpT_Ff-Uo
9
9
  zwarm/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  zwarm/cli/main.py,sha256=dU_8XLIYzcxikl_MgT6BmChNbqYq94dJ4Ra5STzm_KI,33109
11
11
  zwarm/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- zwarm/core/compact.py,sha256=x3NYHvwDOLjNT7gcOG7mVoL4EDbWsG77rZAbjKZVOgQ,10109
12
+ zwarm/core/compact.py,sha256=Y8C7Gs-5-WOU43WRvQ863Qzd5xtuEqR6Aw3r2p8_-i8,10907
13
13
  zwarm/core/config.py,sha256=H8XFsWeEehZnUsMf7nsM_YqjljFYaC43DLs8Xw4sOuQ,10360
14
14
  zwarm/core/environment.py,sha256=HVDpDZEpDSfyh9-wHZMzMKVUPKvioBkPVWeiME2JmFo,5435
15
15
  zwarm/core/models.py,sha256=PrC3okRBVJxISUa1Fax4KkagqLT6Xub-kTxC9drN0sY,10083
16
16
  zwarm/core/state.py,sha256=wMryIvXP-VDLh2b76A5taeL_9dm5k4jk4HnvHWgLqGE,7658
17
- zwarm/core/test_compact.py,sha256=CbVBkTaLV2VDEn51om0AbY8RgWM2czhxZQQt_U_NggU,10305
17
+ zwarm/core/test_compact.py,sha256=WSdjCB5t4YMcknsrkmJIUsVOPY28s4y9GnDmu3Z4BFw,11878
18
18
  zwarm/core/test_config.py,sha256=26ozyiFOdjFF2c9Q-HDfFM6GOLfgw_5FZ55nTDMNYA8,4888
19
19
  zwarm/core/test_models.py,sha256=sWTIhMZvuLP5AooGR6y8OR2EyWydqVfhmGrE7NPBBnk,8450
20
20
  zwarm/prompts/__init__.py,sha256=FiaIOniLrIyfD3_osxT6I7FfyKjtctbf8jNs5QTPs_s,213
@@ -27,7 +27,7 @@ zwarm/watchers/builtin.py,sha256=52hyRREYYDsSuG-YKElXViSTyMmGySZaFreHc0pz-A4,124
27
27
  zwarm/watchers/manager.py,sha256=XZjBVeHjgCUlkTUeHqdvBvHoBC862U1ik0fG6nlRGog,5587
28
28
  zwarm/watchers/registry.py,sha256=A9iBIVIFNtO7KPX0kLpUaP8dAK7ozqWLA44ocJGnOw4,1219
29
29
  zwarm/watchers/test_watchers.py,sha256=zOsxumBqKfR5ZVGxrNlxz6KcWjkcdp0QhW9WB0_20zM,7855
30
- zwarm-1.0.0.dist-info/METADATA,sha256=aIyHVKHhTe8oo8thqCpMMErJo7S2ILWxvEYF3LaJYv8,14995
31
- zwarm-1.0.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
- zwarm-1.0.0.dist-info/entry_points.txt,sha256=u0OXq4q8d3yJ3EkUXwZfkS-Y8Lcy0F8cWrcQfoRxM6Q,46
33
- zwarm-1.0.0.dist-info/RECORD,,
30
+ zwarm-1.1.1.dist-info/METADATA,sha256=9GoAYbxRjMSdbHIwrZPS_zdrrDSz7-Avnwy_8AqNEBM,14995
31
+ zwarm-1.1.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
32
+ zwarm-1.1.1.dist-info/entry_points.txt,sha256=u0OXq4q8d3yJ3EkUXwZfkS-Y8Lcy0F8cWrcQfoRxM6Q,46
33
+ zwarm-1.1.1.dist-info/RECORD,,
File without changes