zwarm 1.3.10__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.
@@ -0,0 +1,181 @@
1
+ """
2
+ Watcher manager for running multiple watchers.
3
+
4
+ Handles:
5
+ - Running watchers in parallel
6
+ - Combining results by priority
7
+ - Injecting guidance into orchestrator
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from dataclasses import dataclass, field
14
+ from typing import Any
15
+
16
+ import weave
17
+
18
+ from zwarm.watchers.base import Watcher, WatcherContext, WatcherResult, WatcherAction
19
+ from zwarm.watchers.registry import get_watcher
20
+
21
+
22
+ @dataclass
23
+ class WatcherConfig:
24
+ """Configuration for a watcher instance."""
25
+
26
+ name: str
27
+ enabled: bool = True
28
+ config: dict[str, Any] = field(default_factory=dict)
29
+
30
+
31
+ class WatcherManager:
32
+ """
33
+ Manages and runs multiple watchers.
34
+
35
+ Watchers run in parallel and results are combined by priority.
36
+ """
37
+
38
+ def __init__(self, watcher_configs: list[WatcherConfig | dict] | None = None):
39
+ """
40
+ Initialize manager with watcher configurations.
41
+
42
+ Args:
43
+ watcher_configs: List of WatcherConfig or dicts with watcher configs
44
+ """
45
+ self._watchers: list[Watcher] = []
46
+ self._results_history: list[tuple[str, WatcherResult]] = []
47
+
48
+ # Load watchers from configs
49
+ for cfg in watcher_configs or []:
50
+ if isinstance(cfg, dict):
51
+ cfg = WatcherConfig(**cfg)
52
+
53
+ if cfg.enabled:
54
+ try:
55
+ watcher = get_watcher(cfg.name, cfg.config)
56
+ self._watchers.append(watcher)
57
+ except ValueError:
58
+ # Unknown watcher, skip
59
+ pass
60
+
61
+ def add_watcher(self, watcher: Watcher) -> None:
62
+ """Add a watcher instance."""
63
+ self._watchers.append(watcher)
64
+
65
+ @weave.op()
66
+ async def _run_single_watcher(
67
+ self,
68
+ watcher_name: str,
69
+ watcher: Watcher,
70
+ ctx: WatcherContext,
71
+ ) -> dict[str, Any]:
72
+ """Run a single watcher - traced by Weave."""
73
+ try:
74
+ result = await watcher.observe(ctx)
75
+ return {
76
+ "watcher": watcher_name,
77
+ "action": result.action.value,
78
+ "priority": result.priority,
79
+ "reason": result.reason,
80
+ "guidance": result.guidance,
81
+ "metadata": result.metadata,
82
+ "success": True,
83
+ }
84
+ except Exception as e:
85
+ return {
86
+ "watcher": watcher_name,
87
+ "success": False,
88
+ "error": str(e),
89
+ }
90
+
91
+ @weave.op()
92
+ async def observe(self, ctx: WatcherContext) -> WatcherResult:
93
+ """
94
+ Run all watchers and return combined result.
95
+
96
+ Results are combined by priority:
97
+ - ABORT takes precedence over everything
98
+ - PAUSE takes precedence over NUDGE
99
+ - NUDGE takes precedence over CONTINUE
100
+ - Within same action, higher priority wins
101
+
102
+ Args:
103
+ ctx: Context for watchers
104
+
105
+ Returns:
106
+ Combined WatcherResult
107
+ """
108
+ if not self._watchers:
109
+ return WatcherResult.ok()
110
+
111
+ # Run all watchers in parallel - each traced individually
112
+ tasks = [
113
+ self._run_single_watcher(watcher.name, watcher, ctx)
114
+ for watcher in self._watchers
115
+ ]
116
+ watcher_outputs = await asyncio.gather(*tasks)
117
+
118
+ # Collect valid results with their watcher names
119
+ valid_results: list[tuple[str, WatcherResult]] = []
120
+ for watcher, output in zip(self._watchers, watcher_outputs):
121
+ if not output.get("success"):
122
+ # Log and skip failed watchers
123
+ continue
124
+ result = WatcherResult(
125
+ action=WatcherAction(output["action"]),
126
+ priority=output["priority"],
127
+ reason=output.get("reason"),
128
+ guidance=output.get("guidance"),
129
+ metadata=output.get("metadata", {}),
130
+ )
131
+ valid_results.append((watcher.name, result))
132
+ self._results_history.append((watcher.name, result))
133
+
134
+ if not valid_results:
135
+ return WatcherResult.ok()
136
+
137
+ # Sort by action severity (abort > pause > nudge > continue) then priority
138
+ def sort_key(item: tuple[str, WatcherResult]) -> tuple[int, int]:
139
+ _, result = item
140
+ action_order = {
141
+ WatcherAction.ABORT: 0,
142
+ WatcherAction.PAUSE: 1,
143
+ WatcherAction.NUDGE: 2,
144
+ WatcherAction.CONTINUE: 3,
145
+ }
146
+ return (action_order[result.action], -result.priority)
147
+
148
+ valid_results.sort(key=sort_key)
149
+
150
+ # Return highest priority non-continue result
151
+ for name, result in valid_results:
152
+ if result.action != WatcherAction.CONTINUE:
153
+ # Add which watcher triggered this
154
+ result.metadata["triggered_by"] = name
155
+ return result
156
+
157
+ return WatcherResult.ok()
158
+
159
+ def get_history(self) -> list[tuple[str, WatcherResult]]:
160
+ """Get history of all watcher results."""
161
+ return list(self._results_history)
162
+
163
+ def clear_history(self) -> None:
164
+ """Clear results history."""
165
+ self._results_history.clear()
166
+
167
+
168
+ def build_watcher_manager(
169
+ config: dict[str, Any] | None = None
170
+ ) -> WatcherManager:
171
+ """
172
+ Build a WatcherManager from configuration.
173
+
174
+ Args:
175
+ config: Dict with "watchers" key containing list of watcher configs
176
+
177
+ Returns:
178
+ Configured WatcherManager
179
+ """
180
+ watcher_configs = (config or {}).get("watchers", [])
181
+ return WatcherManager(watcher_configs)
@@ -0,0 +1,57 @@
1
+ """
2
+ Watcher registry for discovering and instantiating watchers.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any, Type
8
+
9
+ from zwarm.watchers.base import Watcher
10
+
11
+
12
+ # Global watcher registry
13
+ _WATCHERS: dict[str, Type[Watcher]] = {}
14
+
15
+
16
+ def register_watcher(name: str):
17
+ """
18
+ Decorator to register a watcher class.
19
+
20
+ Example:
21
+ @register_watcher("progress")
22
+ class ProgressWatcher(Watcher):
23
+ ...
24
+ """
25
+
26
+ def decorator(cls: Type[Watcher]) -> Type[Watcher]:
27
+ cls.name = name
28
+ _WATCHERS[name] = cls
29
+ return cls
30
+
31
+ return decorator
32
+
33
+
34
+ def get_watcher(name: str, config: dict[str, Any] | None = None) -> Watcher:
35
+ """
36
+ Get a watcher instance by name.
37
+
38
+ Args:
39
+ name: Registered watcher name
40
+ config: Optional config to pass to watcher
41
+
42
+ Returns:
43
+ Instantiated watcher
44
+
45
+ Raises:
46
+ ValueError: If watcher not found
47
+ """
48
+ if name not in _WATCHERS:
49
+ raise ValueError(
50
+ f"Unknown watcher: {name}. Available: {list(_WATCHERS.keys())}"
51
+ )
52
+ return _WATCHERS[name](config)
53
+
54
+
55
+ def list_watchers() -> list[str]:
56
+ """List all registered watcher names."""
57
+ return list(_WATCHERS.keys())
@@ -0,0 +1,237 @@
1
+ """Tests for the watcher system."""
2
+
3
+ import pytest
4
+
5
+ from zwarm.watchers import (
6
+ Watcher,
7
+ WatcherContext,
8
+ WatcherResult,
9
+ WatcherAction,
10
+ WatcherManager,
11
+ WatcherConfig,
12
+ get_watcher,
13
+ list_watchers,
14
+ )
15
+
16
+
17
+ class TestWatcherRegistry:
18
+ def test_list_watchers(self):
19
+ """Built-in watchers should be registered."""
20
+ watchers = list_watchers()
21
+ assert "progress" in watchers
22
+ assert "budget" in watchers
23
+ assert "scope" in watchers
24
+ assert "pattern" in watchers
25
+ assert "quality" in watchers
26
+
27
+ def test_get_watcher(self):
28
+ """Can get watcher by name."""
29
+ watcher = get_watcher("progress")
30
+ assert watcher.name == "progress"
31
+
32
+ def test_get_unknown_watcher(self):
33
+ """Unknown watcher raises error."""
34
+ with pytest.raises(ValueError, match="Unknown watcher"):
35
+ get_watcher("nonexistent")
36
+
37
+
38
+ class TestProgressWatcher:
39
+ @pytest.mark.asyncio
40
+ async def test_continues_on_normal_progress(self):
41
+ """Normal progress should continue."""
42
+ watcher = get_watcher("progress")
43
+ ctx = WatcherContext(
44
+ task="Test task",
45
+ step=2,
46
+ max_steps=10,
47
+ messages=[
48
+ {"role": "user", "content": "Start"},
49
+ {"role": "assistant", "content": "Working on it"},
50
+ ],
51
+ )
52
+ result = await watcher.observe(ctx)
53
+ assert result.action == WatcherAction.CONTINUE
54
+
55
+
56
+ class TestBudgetWatcher:
57
+ @pytest.mark.asyncio
58
+ async def test_warns_at_budget_threshold(self):
59
+ """Should warn when approaching step limit."""
60
+ watcher = get_watcher("budget", {"warn_at_percent": 80})
61
+ ctx = WatcherContext(
62
+ task="Test task",
63
+ step=9, # 90% of max
64
+ max_steps=10,
65
+ messages=[],
66
+ )
67
+ result = await watcher.observe(ctx)
68
+ assert result.action == WatcherAction.NUDGE
69
+ assert "remaining" in result.guidance.lower()
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_continues_when_under_budget(self):
73
+ """Should continue when well under budget."""
74
+ watcher = get_watcher("budget")
75
+ ctx = WatcherContext(
76
+ task="Test task",
77
+ step=2,
78
+ max_steps=10,
79
+ messages=[],
80
+ )
81
+ result = await watcher.observe(ctx)
82
+ assert result.action == WatcherAction.CONTINUE
83
+
84
+ @pytest.mark.asyncio
85
+ async def test_only_counts_active_sessions(self):
86
+ """Should only count active sessions, not completed/failed ones."""
87
+ watcher = get_watcher("budget", {"max_sessions": 2})
88
+ # Create 5 sessions: 1 active, 2 completed, 2 failed
89
+ ctx = WatcherContext(
90
+ task="Test task",
91
+ step=2,
92
+ max_steps=10,
93
+ messages=[],
94
+ sessions=[
95
+ {"id": "s1", "status": "active"},
96
+ {"id": "s2", "status": "completed"},
97
+ {"id": "s3", "status": "completed"},
98
+ {"id": "s4", "status": "failed"},
99
+ {"id": "s5", "status": "failed"},
100
+ ],
101
+ )
102
+ # Should continue because only 1 active session (limit is 2)
103
+ result = await watcher.observe(ctx)
104
+ assert result.action == WatcherAction.CONTINUE
105
+
106
+ @pytest.mark.asyncio
107
+ async def test_warns_when_active_sessions_at_limit(self):
108
+ """Should warn when active sessions reach the limit."""
109
+ watcher = get_watcher("budget", {"max_sessions": 2})
110
+ ctx = WatcherContext(
111
+ task="Test task",
112
+ step=2,
113
+ max_steps=10,
114
+ messages=[],
115
+ sessions=[
116
+ {"id": "s1", "status": "active"},
117
+ {"id": "s2", "status": "active"},
118
+ {"id": "s3", "status": "completed"},
119
+ ],
120
+ )
121
+ # Should nudge because 2 active sessions (at limit)
122
+ result = await watcher.observe(ctx)
123
+ assert result.action == WatcherAction.NUDGE
124
+ assert "2 active sessions" in result.guidance
125
+
126
+
127
+ class TestPatternWatcher:
128
+ @pytest.mark.asyncio
129
+ async def test_detects_pattern(self):
130
+ """Should detect configured patterns."""
131
+ watcher = get_watcher("pattern", {
132
+ "patterns": [
133
+ {"regex": r"ERROR", "action": "nudge", "message": "Error detected!"}
134
+ ]
135
+ })
136
+ ctx = WatcherContext(
137
+ task="Test task",
138
+ step=1,
139
+ max_steps=10,
140
+ messages=[
141
+ {"role": "assistant", "content": "Got ERROR in the build"}
142
+ ],
143
+ )
144
+ result = await watcher.observe(ctx)
145
+ assert result.action == WatcherAction.NUDGE
146
+ assert "Error detected" in result.guidance
147
+
148
+ @pytest.mark.asyncio
149
+ async def test_abort_pattern(self):
150
+ """Should abort on critical patterns."""
151
+ watcher = get_watcher("pattern", {
152
+ "patterns": [
153
+ {"regex": r"rm -rf /", "action": "abort", "message": "Dangerous command!"}
154
+ ]
155
+ })
156
+ ctx = WatcherContext(
157
+ task="Test task",
158
+ step=1,
159
+ max_steps=10,
160
+ messages=[
161
+ {"role": "assistant", "content": "Running rm -rf /"}
162
+ ],
163
+ )
164
+ result = await watcher.observe(ctx)
165
+ assert result.action == WatcherAction.ABORT
166
+
167
+
168
+ class TestWatcherManager:
169
+ @pytest.mark.asyncio
170
+ async def test_runs_multiple_watchers(self):
171
+ """Manager runs all watchers."""
172
+ manager = WatcherManager([
173
+ WatcherConfig(name="progress"),
174
+ WatcherConfig(name="budget"),
175
+ ])
176
+ ctx = WatcherContext(
177
+ task="Test task",
178
+ step=2,
179
+ max_steps=10,
180
+ messages=[],
181
+ )
182
+ result = await manager.observe(ctx)
183
+ assert isinstance(result, WatcherResult)
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_highest_priority_wins(self):
187
+ """Most severe action should win."""
188
+ manager = WatcherManager([
189
+ WatcherConfig(name="budget", config={"warn_at_percent": 50}), # Will nudge
190
+ WatcherConfig(name="pattern", config={
191
+ "patterns": [{"regex": "ABORT", "action": "abort", "message": "Abort!"}]
192
+ }),
193
+ ])
194
+ ctx = WatcherContext(
195
+ task="Test task",
196
+ step=6, # 60% - triggers budget nudge
197
+ max_steps=10,
198
+ messages=[
199
+ {"role": "assistant", "content": "Must ABORT now"}
200
+ ],
201
+ )
202
+ result = await manager.observe(ctx)
203
+ # Abort should take precedence over nudge
204
+ assert result.action == WatcherAction.ABORT
205
+
206
+ @pytest.mark.asyncio
207
+ async def test_empty_manager_continues(self):
208
+ """Manager with no watchers should continue."""
209
+ manager = WatcherManager([])
210
+ ctx = WatcherContext(
211
+ task="Test task",
212
+ step=1,
213
+ max_steps=10,
214
+ messages=[],
215
+ )
216
+ result = await manager.observe(ctx)
217
+ assert result.action == WatcherAction.CONTINUE
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_disabled_watcher_skipped(self):
221
+ """Disabled watchers should be skipped."""
222
+ manager = WatcherManager([
223
+ WatcherConfig(name="pattern", enabled=False, config={
224
+ "patterns": [{"regex": ".*", "action": "abort", "message": "Always abort"}]
225
+ }),
226
+ ])
227
+ ctx = WatcherContext(
228
+ task="Test task",
229
+ step=1,
230
+ max_steps=10,
231
+ messages=[
232
+ {"role": "assistant", "content": "This would normally trigger abort"}
233
+ ],
234
+ )
235
+ result = await manager.observe(ctx)
236
+ # Since the pattern watcher is disabled, should continue
237
+ assert result.action == WatcherAction.CONTINUE