zwarm 2.3.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/__init__.py +38 -0
- zwarm/adapters/__init__.py +21 -0
- zwarm/adapters/base.py +109 -0
- zwarm/adapters/claude_code.py +357 -0
- zwarm/adapters/codex_mcp.py +1262 -0
- zwarm/adapters/registry.py +69 -0
- zwarm/adapters/test_codex_mcp.py +274 -0
- zwarm/adapters/test_registry.py +68 -0
- zwarm/cli/__init__.py +0 -0
- zwarm/cli/main.py +2503 -0
- zwarm/core/__init__.py +0 -0
- zwarm/core/compact.py +329 -0
- zwarm/core/config.py +344 -0
- zwarm/core/environment.py +173 -0
- zwarm/core/models.py +315 -0
- zwarm/core/state.py +355 -0
- zwarm/core/test_compact.py +312 -0
- zwarm/core/test_config.py +160 -0
- zwarm/core/test_models.py +265 -0
- zwarm/orchestrator.py +683 -0
- zwarm/prompts/__init__.py +10 -0
- zwarm/prompts/orchestrator.py +230 -0
- zwarm/sessions/__init__.py +26 -0
- zwarm/sessions/manager.py +792 -0
- zwarm/test_orchestrator_watchers.py +23 -0
- zwarm/tools/__init__.py +17 -0
- zwarm/tools/delegation.py +784 -0
- zwarm/watchers/__init__.py +31 -0
- zwarm/watchers/base.py +131 -0
- zwarm/watchers/builtin.py +518 -0
- zwarm/watchers/llm_watcher.py +319 -0
- zwarm/watchers/manager.py +181 -0
- zwarm/watchers/registry.py +57 -0
- zwarm/watchers/test_watchers.py +237 -0
- zwarm-2.3.5.dist-info/METADATA +309 -0
- zwarm-2.3.5.dist-info/RECORD +38 -0
- zwarm-2.3.5.dist-info/WHEEL +4 -0
- zwarm-2.3.5.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Watchers: Trajectory aligners for agent behavior.
|
|
3
|
+
|
|
4
|
+
Watchers observe agent activity and can intervene to correct course.
|
|
5
|
+
They are composable and can be layered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from zwarm.watchers.base import Watcher, WatcherContext, WatcherResult, WatcherAction
|
|
9
|
+
from zwarm.watchers.registry import register_watcher, get_watcher, list_watchers
|
|
10
|
+
from zwarm.watchers.manager import WatcherManager, WatcherConfig, build_watcher_manager
|
|
11
|
+
|
|
12
|
+
# Import built-in watchers to register them
|
|
13
|
+
from zwarm.watchers import builtin as _builtin # noqa: F401
|
|
14
|
+
from zwarm.watchers import llm_watcher as _llm_watcher # noqa: F401
|
|
15
|
+
|
|
16
|
+
# Export trajectory compression utility
|
|
17
|
+
from zwarm.watchers.llm_watcher import compress_trajectory
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Watcher",
|
|
21
|
+
"WatcherContext",
|
|
22
|
+
"WatcherResult",
|
|
23
|
+
"WatcherAction",
|
|
24
|
+
"WatcherConfig",
|
|
25
|
+
"WatcherManager",
|
|
26
|
+
"register_watcher",
|
|
27
|
+
"get_watcher",
|
|
28
|
+
"list_watchers",
|
|
29
|
+
"build_watcher_manager",
|
|
30
|
+
"compress_trajectory",
|
|
31
|
+
]
|
zwarm/watchers/base.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base watcher interface and types.
|
|
3
|
+
|
|
4
|
+
Watchers observe agent trajectories and can intervene to correct course.
|
|
5
|
+
They're designed to be:
|
|
6
|
+
- Composable: Layer multiple watchers for different concerns
|
|
7
|
+
- Non-blocking: Check asynchronously, don't slow down the agent
|
|
8
|
+
- Actionable: Return clear guidance when correction is needed
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class WatcherAction(str, Enum):
|
|
20
|
+
"""What action to take based on watcher observation."""
|
|
21
|
+
|
|
22
|
+
CONTINUE = "continue" # Keep going, trajectory looks good
|
|
23
|
+
NUDGE = "nudge" # Insert guidance into next prompt
|
|
24
|
+
PAUSE = "pause" # Pause for human review
|
|
25
|
+
ABORT = "abort" # Stop execution immediately
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class WatcherContext:
|
|
30
|
+
"""
|
|
31
|
+
Context provided to watchers for observation.
|
|
32
|
+
|
|
33
|
+
Contains everything a watcher might need to evaluate trajectory.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
# Current orchestrator state
|
|
37
|
+
task: str # Original task
|
|
38
|
+
step: int # Current step number
|
|
39
|
+
max_steps: int # Maximum steps allowed
|
|
40
|
+
messages: list[dict[str, Any]] # Conversation history
|
|
41
|
+
|
|
42
|
+
# Session activity
|
|
43
|
+
sessions: list[dict[str, Any]] = field(default_factory=list)
|
|
44
|
+
events: list[dict[str, Any]] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
# Working directory context
|
|
47
|
+
working_dir: str | None = None
|
|
48
|
+
files_changed: list[str] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
# Custom metadata
|
|
51
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class WatcherResult:
|
|
56
|
+
"""
|
|
57
|
+
Result from a watcher observation.
|
|
58
|
+
|
|
59
|
+
Contains the recommended action and any guidance to inject.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
action: WatcherAction = WatcherAction.CONTINUE
|
|
63
|
+
reason: str = "" # Why this action was recommended
|
|
64
|
+
guidance: str = "" # Message to inject if action is NUDGE
|
|
65
|
+
priority: int = 0 # Higher priority watchers take precedence
|
|
66
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
@staticmethod
|
|
69
|
+
def ok() -> "WatcherResult":
|
|
70
|
+
"""Trajectory looks good, continue."""
|
|
71
|
+
return WatcherResult(action=WatcherAction.CONTINUE)
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def nudge(guidance: str, reason: str = "", priority: int = 0) -> "WatcherResult":
|
|
75
|
+
"""Insert guidance to correct trajectory."""
|
|
76
|
+
return WatcherResult(
|
|
77
|
+
action=WatcherAction.NUDGE,
|
|
78
|
+
guidance=guidance,
|
|
79
|
+
reason=reason,
|
|
80
|
+
priority=priority,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def pause(reason: str, priority: int = 0) -> "WatcherResult":
|
|
85
|
+
"""Pause for human review."""
|
|
86
|
+
return WatcherResult(
|
|
87
|
+
action=WatcherAction.PAUSE,
|
|
88
|
+
reason=reason,
|
|
89
|
+
priority=priority,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@staticmethod
|
|
93
|
+
def abort(reason: str, priority: int = 100) -> "WatcherResult":
|
|
94
|
+
"""Stop execution immediately."""
|
|
95
|
+
return WatcherResult(
|
|
96
|
+
action=WatcherAction.ABORT,
|
|
97
|
+
reason=reason,
|
|
98
|
+
priority=priority,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Watcher(ABC):
|
|
103
|
+
"""
|
|
104
|
+
Base class for watchers.
|
|
105
|
+
|
|
106
|
+
Watchers observe agent trajectories and provide guidance when needed.
|
|
107
|
+
They're designed to be stateless - all context comes from WatcherContext.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
name: str = "base"
|
|
111
|
+
description: str = ""
|
|
112
|
+
|
|
113
|
+
def __init__(self, config: dict[str, Any] | None = None):
|
|
114
|
+
"""Initialize watcher with optional config."""
|
|
115
|
+
self.config = config or {}
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
119
|
+
"""
|
|
120
|
+
Observe the current trajectory and decide action.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
ctx: Current context with all trajectory info
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
WatcherResult with recommended action
|
|
127
|
+
"""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
def __repr__(self) -> str:
|
|
131
|
+
return f"<{self.__class__.__name__}({self.name})>"
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Built-in watchers for common trajectory alignment needs.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from wbal.helper import TOOL_CALL_TYPE, TOOL_RESULT_TYPE
|
|
11
|
+
from zwarm.watchers.base import Watcher, WatcherContext, WatcherResult, WatcherAction
|
|
12
|
+
from zwarm.watchers.registry import register_watcher
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _get_field(item: Any, name: str, default: Any = None) -> Any:
|
|
16
|
+
if isinstance(item, dict):
|
|
17
|
+
return item.get(name, default)
|
|
18
|
+
return getattr(item, name, default)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _content_to_text(content: Any) -> str:
|
|
22
|
+
if content is None:
|
|
23
|
+
return ""
|
|
24
|
+
if isinstance(content, str):
|
|
25
|
+
return content
|
|
26
|
+
if isinstance(content, list):
|
|
27
|
+
parts = []
|
|
28
|
+
for part in content:
|
|
29
|
+
text = _content_to_text(part)
|
|
30
|
+
if text:
|
|
31
|
+
parts.append(text)
|
|
32
|
+
return "\n".join(parts)
|
|
33
|
+
if isinstance(content, dict):
|
|
34
|
+
text = content.get("text") or content.get("content") or content.get("refusal")
|
|
35
|
+
return str(text) if text is not None else ""
|
|
36
|
+
text = getattr(content, "text", None)
|
|
37
|
+
if text is None:
|
|
38
|
+
text = getattr(content, "refusal", None)
|
|
39
|
+
return str(text) if text is not None else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_tool_call(tool_call: Any) -> dict[str, Any]:
|
|
43
|
+
if isinstance(tool_call, dict):
|
|
44
|
+
if isinstance(tool_call.get("function"), dict):
|
|
45
|
+
return tool_call
|
|
46
|
+
name = tool_call.get("name", "")
|
|
47
|
+
arguments = tool_call.get("arguments", "")
|
|
48
|
+
call_id = tool_call.get("call_id")
|
|
49
|
+
else:
|
|
50
|
+
name = getattr(tool_call, "name", "")
|
|
51
|
+
arguments = getattr(tool_call, "arguments", "")
|
|
52
|
+
call_id = getattr(tool_call, "call_id", None)
|
|
53
|
+
|
|
54
|
+
normalized = {
|
|
55
|
+
"type": "function",
|
|
56
|
+
"function": {"name": name, "arguments": arguments},
|
|
57
|
+
}
|
|
58
|
+
if call_id:
|
|
59
|
+
normalized["call_id"] = call_id
|
|
60
|
+
return normalized
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize_messages(messages: list[Any]) -> list[dict[str, Any]]:
|
|
64
|
+
normalized: list[dict[str, Any]] = []
|
|
65
|
+
for item in messages:
|
|
66
|
+
item_type = _get_field(item, "type")
|
|
67
|
+
role = _get_field(item, "role")
|
|
68
|
+
content = ""
|
|
69
|
+
tool_calls: list[dict[str, Any]] = []
|
|
70
|
+
|
|
71
|
+
if item_type in (TOOL_CALL_TYPE, "function_call"):
|
|
72
|
+
tool_calls = [_normalize_tool_call(item)]
|
|
73
|
+
role = role or "assistant"
|
|
74
|
+
else:
|
|
75
|
+
raw_tool_calls = _get_field(item, "tool_calls") or []
|
|
76
|
+
if raw_tool_calls and not isinstance(raw_tool_calls, list):
|
|
77
|
+
raw_tool_calls = [raw_tool_calls]
|
|
78
|
+
if raw_tool_calls:
|
|
79
|
+
tool_calls = [
|
|
80
|
+
_normalize_tool_call(tc) for tc in raw_tool_calls
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
if role or item_type == "message" or item_type is None:
|
|
84
|
+
content = _content_to_text(_get_field(item, "content"))
|
|
85
|
+
|
|
86
|
+
if item_type == TOOL_RESULT_TYPE and not content:
|
|
87
|
+
content = _content_to_text(_get_field(item, "output"))
|
|
88
|
+
role = role or "tool"
|
|
89
|
+
|
|
90
|
+
if not role and not content and not tool_calls:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
normalized.append(
|
|
94
|
+
{
|
|
95
|
+
"role": role,
|
|
96
|
+
"content": content or "",
|
|
97
|
+
"tool_calls": tool_calls,
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return normalized
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register_watcher("progress")
|
|
104
|
+
class ProgressWatcher(Watcher):
|
|
105
|
+
"""
|
|
106
|
+
Watches for lack of progress.
|
|
107
|
+
|
|
108
|
+
Detects when the agent appears stuck:
|
|
109
|
+
- Repeating same tool calls
|
|
110
|
+
- Not making session progress
|
|
111
|
+
- Spinning without completing tasks
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
name = "progress"
|
|
115
|
+
description = "Detects when agent is stuck or spinning"
|
|
116
|
+
|
|
117
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
118
|
+
messages = _normalize_messages(ctx.messages)
|
|
119
|
+
config = self.config
|
|
120
|
+
max_same_calls = config.get("max_same_calls", 3)
|
|
121
|
+
min_progress_steps = config.get("min_progress_steps", 5)
|
|
122
|
+
|
|
123
|
+
# Check for repeated tool calls
|
|
124
|
+
if len(messages) >= max_same_calls * 2:
|
|
125
|
+
recent_assistant = [
|
|
126
|
+
m for m in messages[-max_same_calls * 2 :]
|
|
127
|
+
if m.get("role") == "assistant"
|
|
128
|
+
]
|
|
129
|
+
if len(recent_assistant) >= max_same_calls:
|
|
130
|
+
# Check if tool calls are repeating
|
|
131
|
+
tool_calls = []
|
|
132
|
+
for msg in recent_assistant:
|
|
133
|
+
if "tool_calls" in msg:
|
|
134
|
+
for tc in msg["tool_calls"]:
|
|
135
|
+
tool_calls.append(
|
|
136
|
+
f"{tc.get('function', {}).get('name', '')}:{tc.get('function', {}).get('arguments', '')}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if len(tool_calls) >= max_same_calls:
|
|
140
|
+
# Check for repetition
|
|
141
|
+
if len(set(tool_calls[-max_same_calls:])) == 1:
|
|
142
|
+
return WatcherResult.nudge(
|
|
143
|
+
guidance=(
|
|
144
|
+
"You appear to be repeating the same action. "
|
|
145
|
+
"Consider a different approach or ask for clarification."
|
|
146
|
+
),
|
|
147
|
+
reason=f"Repeated tool call: {tool_calls[-1][:100]}",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Check for no session completions in a while
|
|
151
|
+
if ctx.step >= min_progress_steps:
|
|
152
|
+
completed = [
|
|
153
|
+
e for e in ctx.events
|
|
154
|
+
if e.get("kind") == "session_completed"
|
|
155
|
+
]
|
|
156
|
+
started = [
|
|
157
|
+
e for e in ctx.events
|
|
158
|
+
if e.get("kind") == "session_started"
|
|
159
|
+
]
|
|
160
|
+
if len(started) > 0 and len(completed) == 0:
|
|
161
|
+
return WatcherResult.nudge(
|
|
162
|
+
guidance=(
|
|
163
|
+
"Several sessions have been started but none completed. "
|
|
164
|
+
"Focus on completing current sessions before starting new ones."
|
|
165
|
+
),
|
|
166
|
+
reason="No session completions",
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return WatcherResult.ok()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@register_watcher("budget")
|
|
173
|
+
class BudgetWatcher(Watcher):
|
|
174
|
+
"""
|
|
175
|
+
Watches resource budget (steps, sessions).
|
|
176
|
+
|
|
177
|
+
Warns when approaching limits.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
name = "budget"
|
|
181
|
+
description = "Monitors resource usage against limits"
|
|
182
|
+
|
|
183
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
184
|
+
config = self.config
|
|
185
|
+
warn_at_percent = config.get("warn_at_percent", 80)
|
|
186
|
+
max_sessions = config.get("max_sessions", 10)
|
|
187
|
+
|
|
188
|
+
# Check step budget
|
|
189
|
+
if ctx.max_steps > 0:
|
|
190
|
+
percent_used = (ctx.step / ctx.max_steps) * 100
|
|
191
|
+
if percent_used >= warn_at_percent:
|
|
192
|
+
remaining = ctx.max_steps - ctx.step
|
|
193
|
+
return WatcherResult.nudge(
|
|
194
|
+
guidance=(
|
|
195
|
+
f"You have {remaining} steps remaining out of {ctx.max_steps}. "
|
|
196
|
+
"Prioritize completing the most important parts of the task."
|
|
197
|
+
),
|
|
198
|
+
reason=f"Step budget {percent_used:.0f}% used",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
# Check session count (only count active sessions, not completed/failed)
|
|
202
|
+
active_sessions = [
|
|
203
|
+
s for s in ctx.sessions
|
|
204
|
+
if s.get("status") == "active"
|
|
205
|
+
]
|
|
206
|
+
if len(active_sessions) >= max_sessions:
|
|
207
|
+
return WatcherResult.nudge(
|
|
208
|
+
guidance=(
|
|
209
|
+
f"You have {len(active_sessions)} active sessions. "
|
|
210
|
+
"Consider completing or closing existing sessions before starting new ones."
|
|
211
|
+
),
|
|
212
|
+
reason=f"Active session limit reached ({len(active_sessions)}/{max_sessions})",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return WatcherResult.ok()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@register_watcher("scope")
|
|
219
|
+
class ScopeWatcher(Watcher):
|
|
220
|
+
"""
|
|
221
|
+
Watches for scope creep.
|
|
222
|
+
|
|
223
|
+
Ensures the agent stays focused on the original task.
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
name = "scope"
|
|
227
|
+
description = "Detects scope creep and keeps agent on task"
|
|
228
|
+
|
|
229
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
230
|
+
config = self.config
|
|
231
|
+
focus_keywords = config.get("focus_keywords", [])
|
|
232
|
+
avoid_keywords = config.get("avoid_keywords", [])
|
|
233
|
+
max_tangent_steps = config.get("max_tangent_steps", 3)
|
|
234
|
+
|
|
235
|
+
# Check last few messages for avoid keywords
|
|
236
|
+
if avoid_keywords:
|
|
237
|
+
messages = _normalize_messages(ctx.messages)
|
|
238
|
+
recent_content = " ".join(
|
|
239
|
+
m.get("content", "") or ""
|
|
240
|
+
for m in messages[-max_tangent_steps * 2:]
|
|
241
|
+
).lower()
|
|
242
|
+
|
|
243
|
+
for keyword in avoid_keywords:
|
|
244
|
+
if keyword.lower() in recent_content:
|
|
245
|
+
return WatcherResult.nudge(
|
|
246
|
+
guidance=(
|
|
247
|
+
f"The task involves '{keyword}' which may be out of scope. "
|
|
248
|
+
f"Remember the original task: {ctx.task[:200]}"
|
|
249
|
+
),
|
|
250
|
+
reason=f"Detected avoid keyword: {keyword}",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return WatcherResult.ok()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@register_watcher("pattern")
|
|
257
|
+
class PatternWatcher(Watcher):
|
|
258
|
+
"""
|
|
259
|
+
Watches for specific patterns in output.
|
|
260
|
+
|
|
261
|
+
Configurable regex patterns that trigger nudges/alerts.
|
|
262
|
+
"""
|
|
263
|
+
|
|
264
|
+
name = "pattern"
|
|
265
|
+
description = "Watches for configurable patterns in output"
|
|
266
|
+
|
|
267
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
268
|
+
messages = _normalize_messages(ctx.messages)
|
|
269
|
+
config = self.config
|
|
270
|
+
patterns = config.get("patterns", [])
|
|
271
|
+
|
|
272
|
+
# Each pattern is: {"regex": "...", "action": "nudge|pause|abort", "message": "..."}
|
|
273
|
+
for pattern_config in patterns:
|
|
274
|
+
regex = pattern_config.get("regex")
|
|
275
|
+
if not regex:
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
compiled = re.compile(regex, re.IGNORECASE)
|
|
280
|
+
except re.error:
|
|
281
|
+
continue
|
|
282
|
+
|
|
283
|
+
# Check recent messages
|
|
284
|
+
for msg in messages[-10:]:
|
|
285
|
+
content = msg.get("content", "") or ""
|
|
286
|
+
if compiled.search(content):
|
|
287
|
+
action = pattern_config.get("action", "nudge")
|
|
288
|
+
message = pattern_config.get("message", f"Pattern matched: {regex}")
|
|
289
|
+
|
|
290
|
+
if action == "abort":
|
|
291
|
+
return WatcherResult.abort(message)
|
|
292
|
+
elif action == "pause":
|
|
293
|
+
return WatcherResult.pause(message)
|
|
294
|
+
else:
|
|
295
|
+
return WatcherResult.nudge(guidance=message, reason=f"Pattern: {regex}")
|
|
296
|
+
|
|
297
|
+
return WatcherResult.ok()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@register_watcher("delegation")
|
|
301
|
+
class DelegationWatcher(Watcher):
|
|
302
|
+
"""
|
|
303
|
+
Watches for the orchestrator trying to write code directly.
|
|
304
|
+
|
|
305
|
+
The orchestrator should DELEGATE coding tasks to executors (Codex, Claude Code),
|
|
306
|
+
not write code itself via bash heredocs, cat, echo, etc.
|
|
307
|
+
|
|
308
|
+
Detects patterns like:
|
|
309
|
+
- cat >> file << 'EOF' (heredocs)
|
|
310
|
+
- echo "code" >> file
|
|
311
|
+
- printf "..." > file.py
|
|
312
|
+
- tee file.py << EOF
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
name = "delegation"
|
|
316
|
+
description = "Ensures orchestrator delegates coding instead of writing directly"
|
|
317
|
+
|
|
318
|
+
# Patterns that indicate direct code writing
|
|
319
|
+
DIRECT_WRITE_PATTERNS = [
|
|
320
|
+
# Heredocs
|
|
321
|
+
r"cat\s+>+\s*\S+.*<<",
|
|
322
|
+
r"tee\s+\S+.*<<",
|
|
323
|
+
# Echo/printf to code files
|
|
324
|
+
r"echo\s+['\"].*['\"]\s*>+\s*\S+\.(py|js|ts|go|rs|java|cpp|c|rb|sh)",
|
|
325
|
+
r"printf\s+['\"].*['\"]\s*>+\s*\S+\.(py|js|ts|go|rs|java|cpp|c|rb|sh)",
|
|
326
|
+
# Sed/awk inline editing (complex patterns suggest code modification)
|
|
327
|
+
r"sed\s+-i.*['\"].*def\s+|class\s+|function\s+|import\s+",
|
|
328
|
+
]
|
|
329
|
+
|
|
330
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
331
|
+
messages = _normalize_messages(ctx.messages)
|
|
332
|
+
config = self.config
|
|
333
|
+
strict = config.get("strict", True) # If True, nudge. If False, just warn.
|
|
334
|
+
|
|
335
|
+
# Check recent messages for bash tool calls
|
|
336
|
+
for msg in messages[-10:]:
|
|
337
|
+
if msg.get("role") != "assistant":
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
# Check tool calls
|
|
341
|
+
tool_calls = msg.get("tool_calls", [])
|
|
342
|
+
for tc in tool_calls:
|
|
343
|
+
func = tc.get("function", {})
|
|
344
|
+
name = func.get("name", "")
|
|
345
|
+
args = func.get("arguments", "")
|
|
346
|
+
|
|
347
|
+
# Only check bash calls
|
|
348
|
+
if name != "bash":
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
# Parse arguments (could be JSON string)
|
|
352
|
+
if isinstance(args, str):
|
|
353
|
+
try:
|
|
354
|
+
import json
|
|
355
|
+
args_dict = json.loads(args)
|
|
356
|
+
command = args_dict.get("command", "")
|
|
357
|
+
except (json.JSONDecodeError, AttributeError):
|
|
358
|
+
command = args
|
|
359
|
+
else:
|
|
360
|
+
command = args.get("command", "") if isinstance(args, dict) else ""
|
|
361
|
+
|
|
362
|
+
# Check for direct write patterns
|
|
363
|
+
for pattern in self.DIRECT_WRITE_PATTERNS:
|
|
364
|
+
if re.search(pattern, command, re.IGNORECASE):
|
|
365
|
+
guidance = (
|
|
366
|
+
"You are trying to write code directly via bash. "
|
|
367
|
+
"As the orchestrator, you should DELEGATE coding tasks to executors "
|
|
368
|
+
"using delegate(). Use bash only for verification commands "
|
|
369
|
+
"(git status, running tests, etc.), not for writing code."
|
|
370
|
+
)
|
|
371
|
+
if strict:
|
|
372
|
+
return WatcherResult.nudge(
|
|
373
|
+
guidance=guidance,
|
|
374
|
+
reason=f"Direct code write detected: {command[:100]}...",
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
# Just log, don't nudge
|
|
378
|
+
return WatcherResult.ok()
|
|
379
|
+
|
|
380
|
+
return WatcherResult.ok()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@register_watcher("quality")
|
|
384
|
+
class QualityWatcher(Watcher):
|
|
385
|
+
"""
|
|
386
|
+
Watches for quality issues.
|
|
387
|
+
|
|
388
|
+
Detects:
|
|
389
|
+
- Missing tests when code is written
|
|
390
|
+
- Large file changes
|
|
391
|
+
- Missing error handling
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
name = "quality"
|
|
395
|
+
description = "Watches for quality issues in code changes"
|
|
396
|
+
|
|
397
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
398
|
+
config = self.config
|
|
399
|
+
require_tests = config.get("require_tests", True)
|
|
400
|
+
max_files_changed = config.get("max_files_changed", 10)
|
|
401
|
+
|
|
402
|
+
# Check for large changes
|
|
403
|
+
if len(ctx.files_changed) > max_files_changed:
|
|
404
|
+
return WatcherResult.nudge(
|
|
405
|
+
guidance=(
|
|
406
|
+
f"You've modified {len(ctx.files_changed)} files. "
|
|
407
|
+
"Consider breaking this into smaller, focused changes."
|
|
408
|
+
),
|
|
409
|
+
reason=f"Large change: {len(ctx.files_changed)} files",
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
# Check for tests if code files are changed
|
|
413
|
+
if require_tests and ctx.files_changed:
|
|
414
|
+
code_files = [
|
|
415
|
+
f for f in ctx.files_changed
|
|
416
|
+
if f.endswith((".py", ".js", ".ts", ".go", ".rs"))
|
|
417
|
+
and not f.startswith("test_")
|
|
418
|
+
and not f.endswith("_test.py")
|
|
419
|
+
and "/test" not in f
|
|
420
|
+
]
|
|
421
|
+
test_files = [
|
|
422
|
+
f for f in ctx.files_changed
|
|
423
|
+
if "test" in f.lower()
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
if code_files and not test_files:
|
|
427
|
+
return WatcherResult.nudge(
|
|
428
|
+
guidance=(
|
|
429
|
+
"Code files were modified but no test files were added or updated. "
|
|
430
|
+
"Consider adding tests for the changes."
|
|
431
|
+
),
|
|
432
|
+
reason="Code without tests",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return WatcherResult.ok()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@register_watcher("delegation_reminder")
|
|
439
|
+
class DelegationReminderWatcher(Watcher):
|
|
440
|
+
"""
|
|
441
|
+
Reminds the orchestrator to delegate work instead of doing it directly.
|
|
442
|
+
|
|
443
|
+
Counts consecutive non-delegation tool calls (bash commands that aren't
|
|
444
|
+
delegation-related). When the count exceeds a threshold, nudges the
|
|
445
|
+
orchestrator to consider delegating to executors instead.
|
|
446
|
+
|
|
447
|
+
This is a softer reminder than the DelegationWatcher - it doesn't detect
|
|
448
|
+
specific code-writing patterns, just notices when the orchestrator seems
|
|
449
|
+
to be doing a lot of direct work that could potentially be delegated.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
name = "delegation_reminder"
|
|
453
|
+
description = "Reminds orchestrator to delegate after many direct tool calls"
|
|
454
|
+
|
|
455
|
+
# Tools that count as delegation-related (don't count against threshold)
|
|
456
|
+
DELEGATION_TOOLS = {
|
|
457
|
+
"delegate",
|
|
458
|
+
"converse",
|
|
459
|
+
"check_session",
|
|
460
|
+
"end_session",
|
|
461
|
+
"list_sessions",
|
|
462
|
+
"chat", # Talking to user is not direct work
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async def observe(self, ctx: WatcherContext) -> WatcherResult:
|
|
466
|
+
messages = _normalize_messages(ctx.messages)
|
|
467
|
+
config = self.config
|
|
468
|
+
threshold = config.get("threshold", 10) # Max consecutive non-delegation calls
|
|
469
|
+
lookback = config.get("lookback", 30) # How many messages to check
|
|
470
|
+
|
|
471
|
+
# Count consecutive non-delegation tool calls from the end
|
|
472
|
+
consecutive_non_delegation = 0
|
|
473
|
+
|
|
474
|
+
# Look through recent messages in reverse order
|
|
475
|
+
for msg in reversed(messages[-lookback:]):
|
|
476
|
+
if msg.get("role") != "assistant":
|
|
477
|
+
continue
|
|
478
|
+
|
|
479
|
+
tool_calls = msg.get("tool_calls", [])
|
|
480
|
+
if not tool_calls:
|
|
481
|
+
# Text-only response doesn't reset counter, but doesn't add to it
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
# Check each tool call in this message
|
|
485
|
+
has_delegation = False
|
|
486
|
+
has_non_delegation = False
|
|
487
|
+
|
|
488
|
+
for tc in tool_calls:
|
|
489
|
+
func = tc.get("function", {})
|
|
490
|
+
name = func.get("name", "")
|
|
491
|
+
|
|
492
|
+
if name in self.DELEGATION_TOOLS:
|
|
493
|
+
has_delegation = True
|
|
494
|
+
elif name: # Any other tool call
|
|
495
|
+
has_non_delegation = True
|
|
496
|
+
|
|
497
|
+
if has_delegation:
|
|
498
|
+
# Found a delegation tool - stop counting
|
|
499
|
+
break
|
|
500
|
+
elif has_non_delegation:
|
|
501
|
+
# Add to consecutive count (one per message, not per tool call)
|
|
502
|
+
consecutive_non_delegation += 1
|
|
503
|
+
|
|
504
|
+
# Check if threshold exceeded
|
|
505
|
+
if consecutive_non_delegation >= threshold:
|
|
506
|
+
return WatcherResult.nudge(
|
|
507
|
+
guidance=(
|
|
508
|
+
f"You've made {consecutive_non_delegation} consecutive direct tool calls "
|
|
509
|
+
"without delegating to an executor. Remember: as the orchestrator, your role "
|
|
510
|
+
"is to delegate coding work to executors, not do it yourself via bash. "
|
|
511
|
+
"Consider whether the work you're doing could be delegated to an executor "
|
|
512
|
+
"using delegate(). Executors can write code, run tests, and handle complex "
|
|
513
|
+
"file operations more effectively than direct bash commands."
|
|
514
|
+
),
|
|
515
|
+
reason=f"Consecutive non-delegation calls: {consecutive_non_delegation}",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return WatcherResult.ok()
|