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.
@@ -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()