zwarm 3.10.2__py3-none-any.whl → 3.10.5__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/cli/interactive.py +2 -2
- zwarm/cli/main.py +3 -5
- zwarm/cli/pilot.py +5 -13
- zwarm/compression/__init__.py +37 -0
- zwarm/compression/rollout_compression.py +292 -0
- zwarm/compression/tc_compression.py +165 -0
- zwarm/core/config.py +33 -6
- zwarm/core/registry.py +2 -20
- zwarm/orchestrator.py +43 -0
- zwarm/prompts/orchestrator.py +98 -137
- zwarm/prompts/pilot.py +15 -11
- zwarm/sessions/manager.py +2 -2
- zwarm/tools/delegation.py +86 -94
- zwarm/watchers/llm_watcher.py +1 -1
- {zwarm-3.10.2.dist-info → zwarm-3.10.5.dist-info}/METADATA +22 -15
- {zwarm-3.10.2.dist-info → zwarm-3.10.5.dist-info}/RECORD +18 -15
- {zwarm-3.10.2.dist-info → zwarm-3.10.5.dist-info}/WHEEL +0 -0
- {zwarm-3.10.2.dist-info → zwarm-3.10.5.dist-info}/entry_points.txt +0 -0
zwarm/cli/interactive.py
CHANGED
|
@@ -269,10 +269,10 @@ def cmd_ls(manager):
|
|
|
269
269
|
task_preview = s.task[:23] + "..." if len(s.task) > 26 else s.task
|
|
270
270
|
updated = time_ago(s.updated_at)
|
|
271
271
|
|
|
272
|
-
# Short model name (e.g., "gpt-5.
|
|
272
|
+
# Short model name (e.g., "gpt-5.2-codex" -> "5.2-codex")
|
|
273
273
|
model_short = s.model or "?"
|
|
274
274
|
if "codex" in model_short.lower():
|
|
275
|
-
# Extract codex variant: gpt-5.
|
|
275
|
+
# Extract codex variant: gpt-5.2-codex -> 5.2-codex
|
|
276
276
|
parts = model_short.split("-")
|
|
277
277
|
codex_idx = next((i for i, p in enumerate(parts) if "codex" in p.lower()), -1)
|
|
278
278
|
if codex_idx >= 0:
|
zwarm/cli/main.py
CHANGED
|
@@ -838,19 +838,17 @@ def init(
|
|
|
838
838
|
console.print(" [dim]These control the underlying Codex CLI that runs executor sessions[/]\n")
|
|
839
839
|
|
|
840
840
|
console.print(" Available models:")
|
|
841
|
-
console.print(" [cyan]1[/] gpt-5.2-codex [dim]- GPT-5.2 Codex, balanced (Recommended)[/]")
|
|
841
|
+
console.print(" [cyan]1[/] gpt-5.2-codex [dim]- GPT-5.2 Codex, fast and balanced (Recommended)[/]")
|
|
842
842
|
console.print(" [cyan]2[/] gpt-5.2 [dim]- GPT-5.2 with extended reasoning[/]")
|
|
843
|
-
console.print(" [cyan]3[/] gpt-5.1-codex [dim]- GPT-5.1 Codex (legacy)[/]")
|
|
844
843
|
|
|
845
844
|
model_choice = typer.prompt(
|
|
846
|
-
" Select model (1-
|
|
845
|
+
" Select model (1-2)",
|
|
847
846
|
default="1",
|
|
848
847
|
type=str,
|
|
849
848
|
)
|
|
850
849
|
model_map = {
|
|
851
850
|
"1": "gpt-5.2-codex",
|
|
852
851
|
"2": "gpt-5.2",
|
|
853
|
-
"3": "gpt-5.1-codex",
|
|
854
852
|
}
|
|
855
853
|
codex_model = model_map.get(model_choice, model_choice)
|
|
856
854
|
if model_choice not in model_map:
|
|
@@ -1668,7 +1666,7 @@ def session_start(
|
|
|
1668
1666
|
$ zwarm session start "Fix the bug in auth.py"
|
|
1669
1667
|
|
|
1670
1668
|
[dim]# With specific model[/]
|
|
1671
|
-
$ zwarm session start "Refactor the API" --model gpt-5.
|
|
1669
|
+
$ zwarm session start "Refactor the API" --model gpt-5.2-codex
|
|
1672
1670
|
|
|
1673
1671
|
[dim]# Web search is always available[/]
|
|
1674
1672
|
$ zwarm session start "Research latest OAuth2 best practices"
|
zwarm/cli/pilot.py
CHANGED
|
@@ -83,22 +83,14 @@ class ChoogingSpinner:
|
|
|
83
83
|
# Context window sizes for different models (in tokens)
|
|
84
84
|
# These are for the ORCHESTRATOR LLM, not the executors
|
|
85
85
|
MODEL_CONTEXT_WINDOWS = {
|
|
86
|
-
# OpenAI models
|
|
86
|
+
# OpenAI models (via Codex CLI)
|
|
87
87
|
"gpt-5.2-codex": 200_000,
|
|
88
88
|
"gpt-5.2": 200_000,
|
|
89
|
-
|
|
90
|
-
"gpt-5.1-codex-mini": 200_000,
|
|
91
|
-
"gpt-5": 200_000,
|
|
92
|
-
"gpt-5-mini": 200_000,
|
|
93
|
-
"o3": 200_000,
|
|
94
|
-
"o3-mini": 200_000,
|
|
95
|
-
# Claude models (if used as orchestrator)
|
|
96
|
-
"claude-sonnet": 200_000,
|
|
97
|
-
"claude-opus": 200_000,
|
|
98
|
-
"claude-haiku": 200_000,
|
|
89
|
+
# Claude models (via Claude CLI)
|
|
99
90
|
"sonnet": 200_000,
|
|
100
91
|
"opus": 200_000,
|
|
101
|
-
"
|
|
92
|
+
"claude-sonnet": 200_000,
|
|
93
|
+
"claude-opus": 200_000,
|
|
102
94
|
# Fallback
|
|
103
95
|
"default": 128_000,
|
|
104
96
|
}
|
|
@@ -1080,7 +1072,7 @@ def _run_pilot_repl(
|
|
|
1080
1072
|
renderer.status("")
|
|
1081
1073
|
|
|
1082
1074
|
# Get model from orchestrator if available
|
|
1083
|
-
model = "gpt-5.
|
|
1075
|
+
model = "gpt-5.2-codex" # Default
|
|
1084
1076
|
if hasattr(orchestrator, "lm") and hasattr(orchestrator.lm, "model"):
|
|
1085
1077
|
model = orchestrator.lm.model
|
|
1086
1078
|
elif hasattr(orchestrator, "config"):
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compression modules for infinite-running agents.
|
|
3
|
+
|
|
4
|
+
Two types of compression:
|
|
5
|
+
1. TC (Tool Call) Compression - compresses tool call results before they enter context
|
|
6
|
+
2. Rollout Compression - manages message history eviction (LRU-style)
|
|
7
|
+
|
|
8
|
+
These modules allow agents to run virtually indefinitely without context explosion.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .tc_compression import (
|
|
12
|
+
TCCompressor,
|
|
13
|
+
NoOpTCCompressor,
|
|
14
|
+
NaiveSizeTCCompressor,
|
|
15
|
+
get_tc_compressor,
|
|
16
|
+
)
|
|
17
|
+
from .rollout_compression import (
|
|
18
|
+
RolloutCompressor,
|
|
19
|
+
NoOpRolloutCompressor,
|
|
20
|
+
LRURolloutCompressor,
|
|
21
|
+
SlidingWindowRolloutCompressor,
|
|
22
|
+
get_rollout_compressor,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# TC Compression
|
|
27
|
+
"TCCompressor",
|
|
28
|
+
"NoOpTCCompressor",
|
|
29
|
+
"NaiveSizeTCCompressor",
|
|
30
|
+
"get_tc_compressor",
|
|
31
|
+
# Rollout Compression
|
|
32
|
+
"RolloutCompressor",
|
|
33
|
+
"NoOpRolloutCompressor",
|
|
34
|
+
"LRURolloutCompressor",
|
|
35
|
+
"SlidingWindowRolloutCompressor",
|
|
36
|
+
"get_rollout_compressor",
|
|
37
|
+
]
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rollout Compression - manages message history eviction for infinite-running agents.
|
|
3
|
+
|
|
4
|
+
As agents run, their conversation history grows. These compressors implement
|
|
5
|
+
different strategies for evicting old messages to keep context bounded.
|
|
6
|
+
|
|
7
|
+
Available compressors:
|
|
8
|
+
- NoOpRolloutCompressor: No eviction (context will eventually overflow)
|
|
9
|
+
- LRURolloutCompressor: Evict oldest messages, keeping system prompt
|
|
10
|
+
- SlidingWindowRolloutCompressor: Keep last N turns (user+assistant pairs)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class EvictionStats:
|
|
22
|
+
"""Statistics about message eviction."""
|
|
23
|
+
|
|
24
|
+
messages_before: int = 0
|
|
25
|
+
messages_after: int = 0
|
|
26
|
+
messages_evicted: int = 0
|
|
27
|
+
tokens_evicted_estimate: int = 0 # Rough estimate
|
|
28
|
+
eviction_triggered: bool = False
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"messages_before": self.messages_before,
|
|
33
|
+
"messages_after": self.messages_after,
|
|
34
|
+
"messages_evicted": self.messages_evicted,
|
|
35
|
+
"tokens_evicted_estimate": self.tokens_evicted_estimate,
|
|
36
|
+
"eviction_triggered": self.eviction_triggered,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RolloutCompressor(ABC):
|
|
41
|
+
"""
|
|
42
|
+
Abstract base class for rollout (message history) compression.
|
|
43
|
+
|
|
44
|
+
Subclasses implement different eviction strategies to keep the
|
|
45
|
+
conversation history bounded while preserving important context.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name: str = "base"
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def compress(self, messages: list[dict]) -> tuple[list[dict], EvictionStats]:
|
|
52
|
+
"""
|
|
53
|
+
Compress message history, returning trimmed version and stats.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
messages: List of message dicts with 'role' and 'content' keys
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
(compressed_messages, eviction_stats)
|
|
60
|
+
"""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def should_compress(self, messages: list[dict]) -> bool:
|
|
64
|
+
"""Check if compression is needed (subclasses may override)."""
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def __repr__(self) -> str:
|
|
68
|
+
return f"{self.__class__.__name__}()"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NoOpRolloutCompressor(RolloutCompressor):
|
|
72
|
+
"""
|
|
73
|
+
No-op compressor - keeps all messages.
|
|
74
|
+
|
|
75
|
+
Use this when you want to disable rollout compression and let the
|
|
76
|
+
context window naturally overflow (will error eventually).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name = "noop"
|
|
80
|
+
|
|
81
|
+
def compress(self, messages: list[dict]) -> tuple[list[dict], EvictionStats]:
|
|
82
|
+
"""Pass through unchanged."""
|
|
83
|
+
return messages, EvictionStats(
|
|
84
|
+
messages_before=len(messages),
|
|
85
|
+
messages_after=len(messages),
|
|
86
|
+
eviction_triggered=False,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class LRURolloutCompressor(RolloutCompressor):
|
|
91
|
+
"""
|
|
92
|
+
LRU (Least Recently Used) compressor - evicts oldest messages.
|
|
93
|
+
|
|
94
|
+
Keeps the system prompt and the most recent messages. When the message
|
|
95
|
+
count exceeds max_messages, evicts oldest non-system messages.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
max_messages: Maximum messages to keep (default: 50)
|
|
99
|
+
preserve_system: Keep all system messages (default: True)
|
|
100
|
+
preserve_first_user: Keep first user message as context (default: True)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
name = "lru"
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
max_messages: int = 50,
|
|
108
|
+
preserve_system: bool = True,
|
|
109
|
+
preserve_first_user: bool = True,
|
|
110
|
+
):
|
|
111
|
+
self.max_messages = max_messages
|
|
112
|
+
self.preserve_system = preserve_system
|
|
113
|
+
self.preserve_first_user = preserve_first_user
|
|
114
|
+
|
|
115
|
+
def should_compress(self, messages: list[dict]) -> bool:
|
|
116
|
+
"""Only compress if we exceed max_messages."""
|
|
117
|
+
return len(messages) > self.max_messages
|
|
118
|
+
|
|
119
|
+
def compress(self, messages: list[dict]) -> tuple[list[dict], EvictionStats]:
|
|
120
|
+
"""Evict oldest messages, keeping system prompt and recent history."""
|
|
121
|
+
stats = EvictionStats(messages_before=len(messages))
|
|
122
|
+
|
|
123
|
+
if not self.should_compress(messages):
|
|
124
|
+
stats.messages_after = len(messages)
|
|
125
|
+
return messages, stats
|
|
126
|
+
|
|
127
|
+
# Separate preserved messages from evictable ones
|
|
128
|
+
preserved = []
|
|
129
|
+
evictable = []
|
|
130
|
+
|
|
131
|
+
first_user_seen = False
|
|
132
|
+
for i, msg in enumerate(messages):
|
|
133
|
+
role = msg.get("role", "")
|
|
134
|
+
|
|
135
|
+
# Always preserve system messages
|
|
136
|
+
if self.preserve_system and role == "system":
|
|
137
|
+
preserved.append((i, msg))
|
|
138
|
+
# Preserve first user message as task context
|
|
139
|
+
elif self.preserve_first_user and role == "user" and not first_user_seen:
|
|
140
|
+
preserved.append((i, msg))
|
|
141
|
+
first_user_seen = True
|
|
142
|
+
else:
|
|
143
|
+
evictable.append((i, msg))
|
|
144
|
+
|
|
145
|
+
# Calculate how many evictable messages to keep
|
|
146
|
+
preserved_count = len(preserved)
|
|
147
|
+
keep_count = max(0, self.max_messages - preserved_count)
|
|
148
|
+
|
|
149
|
+
# Keep the most recent evictable messages
|
|
150
|
+
kept_evictable = evictable[-keep_count:] if keep_count > 0 else []
|
|
151
|
+
evicted = evictable[:-keep_count] if keep_count > 0 and len(evictable) > keep_count else []
|
|
152
|
+
|
|
153
|
+
# Merge preserved and kept messages, maintaining original order
|
|
154
|
+
all_kept = preserved + kept_evictable
|
|
155
|
+
all_kept.sort(key=lambda x: x[0]) # Sort by original index
|
|
156
|
+
result = [msg for _, msg in all_kept]
|
|
157
|
+
|
|
158
|
+
# Estimate tokens evicted (rough: ~4 chars per token)
|
|
159
|
+
evicted_content = sum(len(str(msg.get("content", ""))) for _, msg in evicted)
|
|
160
|
+
tokens_evicted = evicted_content // 4
|
|
161
|
+
|
|
162
|
+
stats.messages_after = len(result)
|
|
163
|
+
stats.messages_evicted = len(evicted)
|
|
164
|
+
stats.tokens_evicted_estimate = tokens_evicted
|
|
165
|
+
stats.eviction_triggered = len(evicted) > 0
|
|
166
|
+
|
|
167
|
+
return result, stats
|
|
168
|
+
|
|
169
|
+
def __repr__(self) -> str:
|
|
170
|
+
return f"LRURolloutCompressor(max_messages={self.max_messages})"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SlidingWindowRolloutCompressor(RolloutCompressor):
|
|
174
|
+
"""
|
|
175
|
+
Sliding window compressor - keeps last N turns (user+assistant pairs).
|
|
176
|
+
|
|
177
|
+
A "turn" is a user message followed by an assistant response. This
|
|
178
|
+
preserves conversation coherence better than raw message count.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
max_turns: Maximum turns to keep (default: 20)
|
|
182
|
+
preserve_system: Keep all system messages (default: True)
|
|
183
|
+
preserve_first_turn: Keep first turn as context (default: True)
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
name = "sliding_window"
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
max_turns: int = 20,
|
|
191
|
+
preserve_system: bool = True,
|
|
192
|
+
preserve_first_turn: bool = True,
|
|
193
|
+
):
|
|
194
|
+
self.max_turns = max_turns
|
|
195
|
+
self.preserve_system = preserve_system
|
|
196
|
+
self.preserve_first_turn = preserve_first_turn
|
|
197
|
+
|
|
198
|
+
def compress(self, messages: list[dict]) -> tuple[list[dict], EvictionStats]:
|
|
199
|
+
"""Keep last N turns, preserving system messages."""
|
|
200
|
+
stats = EvictionStats(messages_before=len(messages))
|
|
201
|
+
|
|
202
|
+
# Extract system messages
|
|
203
|
+
system_messages = []
|
|
204
|
+
conversation = []
|
|
205
|
+
|
|
206
|
+
for msg in messages:
|
|
207
|
+
if msg.get("role") == "system":
|
|
208
|
+
system_messages.append(msg)
|
|
209
|
+
else:
|
|
210
|
+
conversation.append(msg)
|
|
211
|
+
|
|
212
|
+
# Group conversation into turns (user + assistant + tool results)
|
|
213
|
+
turns: list[list[dict]] = []
|
|
214
|
+
current_turn: list[dict] = []
|
|
215
|
+
|
|
216
|
+
for msg in conversation:
|
|
217
|
+
role = msg.get("role", "")
|
|
218
|
+
if role == "user" and current_turn:
|
|
219
|
+
# New user message starts a new turn
|
|
220
|
+
turns.append(current_turn)
|
|
221
|
+
current_turn = [msg]
|
|
222
|
+
else:
|
|
223
|
+
current_turn.append(msg)
|
|
224
|
+
|
|
225
|
+
# Don't forget the last turn
|
|
226
|
+
if current_turn:
|
|
227
|
+
turns.append(current_turn)
|
|
228
|
+
|
|
229
|
+
# Decide which turns to keep
|
|
230
|
+
if len(turns) <= self.max_turns:
|
|
231
|
+
# No eviction needed
|
|
232
|
+
result = system_messages + conversation
|
|
233
|
+
stats.messages_after = len(result)
|
|
234
|
+
return result, stats
|
|
235
|
+
|
|
236
|
+
# Keep first turn + last (max_turns - 1) turns
|
|
237
|
+
kept_turns = []
|
|
238
|
+
if self.preserve_first_turn and turns:
|
|
239
|
+
kept_turns.append(turns[0])
|
|
240
|
+
remaining_turns = turns[1:]
|
|
241
|
+
kept_turns.extend(remaining_turns[-(self.max_turns - 1):])
|
|
242
|
+
else:
|
|
243
|
+
kept_turns = turns[-self.max_turns:]
|
|
244
|
+
|
|
245
|
+
# Flatten kept turns back into messages
|
|
246
|
+
kept_conversation = []
|
|
247
|
+
for turn in kept_turns:
|
|
248
|
+
kept_conversation.extend(turn)
|
|
249
|
+
|
|
250
|
+
result = system_messages + kept_conversation
|
|
251
|
+
|
|
252
|
+
# Calculate eviction stats
|
|
253
|
+
evicted_count = len(messages) - len(result)
|
|
254
|
+
stats.messages_after = len(result)
|
|
255
|
+
stats.messages_evicted = evicted_count
|
|
256
|
+
stats.eviction_triggered = evicted_count > 0
|
|
257
|
+
|
|
258
|
+
return result, stats
|
|
259
|
+
|
|
260
|
+
def __repr__(self) -> str:
|
|
261
|
+
return f"SlidingWindowRolloutCompressor(max_turns={self.max_turns})"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# =============================================================================
|
|
265
|
+
# Factory
|
|
266
|
+
# =============================================================================
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_rollout_compressor(
|
|
270
|
+
name: str = "lru",
|
|
271
|
+
**kwargs,
|
|
272
|
+
) -> RolloutCompressor:
|
|
273
|
+
"""
|
|
274
|
+
Get a rollout compressor by name.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
name: Compressor name ("noop", "lru", "sliding_window")
|
|
278
|
+
**kwargs: Passed to compressor constructor
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
Configured RolloutCompressor instance
|
|
282
|
+
"""
|
|
283
|
+
compressors = {
|
|
284
|
+
"noop": NoOpRolloutCompressor,
|
|
285
|
+
"lru": LRURolloutCompressor,
|
|
286
|
+
"sliding_window": SlidingWindowRolloutCompressor,
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if name not in compressors:
|
|
290
|
+
raise ValueError(f"Unknown rollout compressor: {name}. Available: {list(compressors.keys())}")
|
|
291
|
+
|
|
292
|
+
return compressors[name](**kwargs)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tool Call (TC) Compression - compresses tool results before they enter context.
|
|
3
|
+
|
|
4
|
+
When an agent makes a tool call, the result can be arbitrarily large. These
|
|
5
|
+
compressors marshal results into a more digestible format for the agent.
|
|
6
|
+
|
|
7
|
+
Available compressors:
|
|
8
|
+
- NoOpTCCompressor: Pass-through, no compression (default for now)
|
|
9
|
+
- NaiveSizeTCCompressor: Truncate to last N characters
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from abc import ABC, abstractmethod
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TCCompressor(ABC):
|
|
19
|
+
"""
|
|
20
|
+
Abstract base class for tool call result compression.
|
|
21
|
+
|
|
22
|
+
Subclasses implement different compression strategies to prevent
|
|
23
|
+
tool results from exploding the agent's context window.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
name: str = "base"
|
|
27
|
+
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def compress(self, tool_name: str, result: Any) -> Any:
|
|
30
|
+
"""
|
|
31
|
+
Compress a tool call result.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
tool_name: Name of the tool that was called
|
|
35
|
+
result: The raw result from the tool
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Compressed result (same type or string)
|
|
39
|
+
"""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return f"{self.__class__.__name__}()"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NoOpTCCompressor(TCCompressor):
|
|
47
|
+
"""
|
|
48
|
+
No-op compressor - passes results through unchanged.
|
|
49
|
+
|
|
50
|
+
Use this when tool results are already well-bounded or when you want
|
|
51
|
+
to disable compression entirely.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name = "noop"
|
|
55
|
+
|
|
56
|
+
def compress(self, tool_name: str, result: Any) -> Any:
|
|
57
|
+
"""Pass through unchanged."""
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class NaiveSizeTCCompressor(TCCompressor):
|
|
62
|
+
"""
|
|
63
|
+
Naive size-based compressor - truncates results to last N characters.
|
|
64
|
+
|
|
65
|
+
Simple but effective: keeps the most recent output which is usually
|
|
66
|
+
the most relevant (e.g., last N chars of a log file).
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
max_chars: Maximum characters to keep (default: 25000)
|
|
70
|
+
truncation_marker: String to prepend when truncated
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name = "naive_size"
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
max_chars: int = 25000,
|
|
78
|
+
truncation_marker: str = "... [truncated, showing last {n} chars] ...\n",
|
|
79
|
+
):
|
|
80
|
+
self.max_chars = max_chars
|
|
81
|
+
self.truncation_marker = truncation_marker
|
|
82
|
+
|
|
83
|
+
def compress(self, tool_name: str, result: Any) -> Any:
|
|
84
|
+
"""Truncate to last max_chars characters if needed."""
|
|
85
|
+
# Handle dict results (common for our tools)
|
|
86
|
+
if isinstance(result, dict):
|
|
87
|
+
return self._compress_dict(result)
|
|
88
|
+
|
|
89
|
+
# Handle string results
|
|
90
|
+
if isinstance(result, str):
|
|
91
|
+
return self._truncate_string(result)
|
|
92
|
+
|
|
93
|
+
# Handle list results
|
|
94
|
+
if isinstance(result, list):
|
|
95
|
+
return self._compress_list(result)
|
|
96
|
+
|
|
97
|
+
# For other types, convert to string and truncate
|
|
98
|
+
result_str = str(result)
|
|
99
|
+
return self._truncate_string(result_str)
|
|
100
|
+
|
|
101
|
+
def _truncate_string(self, s: str) -> str:
|
|
102
|
+
"""Truncate string to last max_chars."""
|
|
103
|
+
if len(s) <= self.max_chars:
|
|
104
|
+
return s
|
|
105
|
+
|
|
106
|
+
# Keep last N chars with marker
|
|
107
|
+
marker = self.truncation_marker.format(n=self.max_chars)
|
|
108
|
+
keep_chars = self.max_chars - len(marker)
|
|
109
|
+
return marker + s[-keep_chars:]
|
|
110
|
+
|
|
111
|
+
def _compress_dict(self, d: dict) -> dict:
|
|
112
|
+
"""Recursively compress dict values."""
|
|
113
|
+
compressed = {}
|
|
114
|
+
for key, value in d.items():
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
compressed[key] = self._truncate_string(value)
|
|
117
|
+
elif isinstance(value, dict):
|
|
118
|
+
compressed[key] = self._compress_dict(value)
|
|
119
|
+
elif isinstance(value, list):
|
|
120
|
+
compressed[key] = self._compress_list(value)
|
|
121
|
+
else:
|
|
122
|
+
compressed[key] = value
|
|
123
|
+
return compressed
|
|
124
|
+
|
|
125
|
+
def _compress_list(self, lst: list) -> list:
|
|
126
|
+
"""Compress list items."""
|
|
127
|
+
return [
|
|
128
|
+
self._truncate_string(item) if isinstance(item, str)
|
|
129
|
+
else self._compress_dict(item) if isinstance(item, dict)
|
|
130
|
+
else item
|
|
131
|
+
for item in lst
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
def __repr__(self) -> str:
|
|
135
|
+
return f"NaiveSizeTCCompressor(max_chars={self.max_chars})"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# =============================================================================
|
|
139
|
+
# Factory
|
|
140
|
+
# =============================================================================
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_tc_compressor(
|
|
144
|
+
name: str = "noop",
|
|
145
|
+
**kwargs,
|
|
146
|
+
) -> TCCompressor:
|
|
147
|
+
"""
|
|
148
|
+
Get a TC compressor by name.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
name: Compressor name ("noop", "naive_size")
|
|
152
|
+
**kwargs: Passed to compressor constructor
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Configured TCCompressor instance
|
|
156
|
+
"""
|
|
157
|
+
compressors = {
|
|
158
|
+
"noop": NoOpTCCompressor,
|
|
159
|
+
"naive_size": NaiveSizeTCCompressor,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if name not in compressors:
|
|
163
|
+
raise ValueError(f"Unknown TC compressor: {name}. Available: {list(compressors.keys())}")
|
|
164
|
+
|
|
165
|
+
return compressors[name](**kwargs)
|
zwarm/core/config.py
CHANGED
|
@@ -40,9 +40,18 @@ class ExecutorConfig:
|
|
|
40
40
|
# Note: web_search is always enabled via .codex/config.toml (set up by `zwarm init`)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
@dataclass
|
|
44
|
+
class TCCompressionConfig:
|
|
45
|
+
"""Configuration for tool call result compression."""
|
|
46
|
+
|
|
47
|
+
enabled: bool = True
|
|
48
|
+
compressor: str = "naive_size" # noop | naive_size
|
|
49
|
+
max_chars: int = 25000 # For naive_size compressor
|
|
50
|
+
|
|
51
|
+
|
|
43
52
|
@dataclass
|
|
44
53
|
class CompactionConfig:
|
|
45
|
-
"""Configuration for context window compaction."""
|
|
54
|
+
"""Configuration for context window compaction (rollout compression)."""
|
|
46
55
|
|
|
47
56
|
enabled: bool = True
|
|
48
57
|
max_tokens: int = 100000 # Trigger compaction when estimated tokens exceed this
|
|
@@ -62,7 +71,10 @@ class OrchestratorConfig:
|
|
|
62
71
|
max_steps: int = 50
|
|
63
72
|
max_steps_per_turn: int = 60 # Max tool-call steps before returning to user (pilot mode)
|
|
64
73
|
parallel_delegations: int = 4
|
|
65
|
-
|
|
74
|
+
|
|
75
|
+
# Compression settings for infinite-running agents
|
|
76
|
+
compaction: CompactionConfig = field(default_factory=CompactionConfig) # Rollout compression
|
|
77
|
+
tc_compression: TCCompressionConfig = field(default_factory=TCCompressionConfig) # Tool call compression
|
|
66
78
|
|
|
67
79
|
# Directory restrictions for agent delegations
|
|
68
80
|
# None = only working_dir allowed (most restrictive, default)
|
|
@@ -115,10 +127,13 @@ class ZwarmConfig:
|
|
|
115
127
|
orchestrator_data = data.get("orchestrator", {})
|
|
116
128
|
watchers_data = data.get("watchers", {})
|
|
117
129
|
|
|
118
|
-
# Parse
|
|
130
|
+
# Parse compression configs from orchestrator
|
|
119
131
|
compaction_data = orchestrator_data.pop("compaction", {}) if orchestrator_data else {}
|
|
120
132
|
compaction_config = CompactionConfig(**compaction_data) if compaction_data else CompactionConfig()
|
|
121
133
|
|
|
134
|
+
tc_compression_data = orchestrator_data.pop("tc_compression", {}) if orchestrator_data else {}
|
|
135
|
+
tc_compression_config = TCCompressionConfig(**tc_compression_data) if tc_compression_data else TCCompressionConfig()
|
|
136
|
+
|
|
122
137
|
# Parse watchers config - handle both list shorthand and dict format
|
|
123
138
|
if isinstance(watchers_data, list):
|
|
124
139
|
# Shorthand: watchers: [progress, budget, scope]
|
|
@@ -140,11 +155,18 @@ class ZwarmConfig:
|
|
|
140
155
|
message_role=watchers_data.get("message_role", "user"),
|
|
141
156
|
)
|
|
142
157
|
|
|
143
|
-
# Build orchestrator config with nested
|
|
158
|
+
# Build orchestrator config with nested compression configs
|
|
144
159
|
if orchestrator_data:
|
|
145
|
-
orchestrator_config = OrchestratorConfig(
|
|
160
|
+
orchestrator_config = OrchestratorConfig(
|
|
161
|
+
**orchestrator_data,
|
|
162
|
+
compaction=compaction_config,
|
|
163
|
+
tc_compression=tc_compression_config,
|
|
164
|
+
)
|
|
146
165
|
else:
|
|
147
|
-
orchestrator_config = OrchestratorConfig(
|
|
166
|
+
orchestrator_config = OrchestratorConfig(
|
|
167
|
+
compaction=compaction_config,
|
|
168
|
+
tc_compression=tc_compression_config,
|
|
169
|
+
)
|
|
148
170
|
|
|
149
171
|
return cls(
|
|
150
172
|
weave=WeaveConfig(**weave_data) if weave_data else WeaveConfig(),
|
|
@@ -183,6 +205,11 @@ class ZwarmConfig:
|
|
|
183
205
|
"keep_first_n": self.orchestrator.compaction.keep_first_n,
|
|
184
206
|
"keep_last_n": self.orchestrator.compaction.keep_last_n,
|
|
185
207
|
},
|
|
208
|
+
"tc_compression": {
|
|
209
|
+
"enabled": self.orchestrator.tc_compression.enabled,
|
|
210
|
+
"compressor": self.orchestrator.tc_compression.compressor,
|
|
211
|
+
"max_chars": self.orchestrator.tc_compression.max_chars,
|
|
212
|
+
},
|
|
186
213
|
},
|
|
187
214
|
"watchers": {
|
|
188
215
|
"enabled": self.watchers.enabled,
|