nullabot 1.0.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.
@@ -0,0 +1,420 @@
1
+ """
2
+ Reliability - Circuit breaker, exit detection, and progress tracking.
3
+
4
+ Borrowed from Ralph Claude Code with enhancements for async Python.
5
+ """
6
+
7
+ import hashlib
8
+ import json
9
+ from datetime import datetime, timedelta
10
+ from enum import Enum
11
+ from pathlib import Path
12
+ from typing import Any, Callable, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class CircuitState(str, Enum):
18
+ """Circuit breaker states (Michael Nygard pattern)."""
19
+
20
+ CLOSED = "closed" # Normal operation
21
+ OPEN = "open" # Failing, reject calls
22
+ HALF_OPEN = "half_open" # Testing recovery
23
+
24
+
25
+ class CircuitBreaker(BaseModel):
26
+ """
27
+ Circuit breaker to prevent cascading failures.
28
+
29
+ Transitions:
30
+ - CLOSED → OPEN: After failure_threshold failures
31
+ - OPEN → HALF_OPEN: After recovery_timeout
32
+ - HALF_OPEN → CLOSED: If test call succeeds
33
+ - HALF_OPEN → OPEN: If test call fails
34
+ """
35
+
36
+ state: CircuitState = CircuitState.CLOSED
37
+ failure_count: int = 0
38
+ last_failure_time: Optional[datetime] = None
39
+ last_success_time: Optional[datetime] = None
40
+
41
+ # Configuration
42
+ failure_threshold: int = 5
43
+ recovery_timeout_seconds: int = 60
44
+ half_open_max_calls: int = 1
45
+ half_open_calls: int = 0
46
+
47
+ # Tracking
48
+ consecutive_no_progress: int = 0
49
+ consecutive_same_error: int = 0
50
+ last_error_hash: Optional[str] = None
51
+
52
+ def can_execute(self) -> tuple[bool, str]:
53
+ """
54
+ Check if we can execute a call.
55
+
56
+ Returns:
57
+ Tuple of (can_execute, reason)
58
+ """
59
+ if self.state == CircuitState.CLOSED:
60
+ return True, "circuit_closed"
61
+
62
+ if self.state == CircuitState.OPEN:
63
+ # Check if recovery timeout has passed
64
+ if self.last_failure_time:
65
+ elapsed = datetime.now() - self.last_failure_time
66
+ if elapsed.total_seconds() >= self.recovery_timeout_seconds:
67
+ self.state = CircuitState.HALF_OPEN
68
+ self.half_open_calls = 0
69
+ return True, "testing_recovery"
70
+ return False, f"circuit_open_wait_{self.recovery_timeout_seconds}s"
71
+
72
+ if self.state == CircuitState.HALF_OPEN:
73
+ if self.half_open_calls < self.half_open_max_calls:
74
+ self.half_open_calls += 1
75
+ return True, "half_open_test"
76
+ return False, "half_open_limit_reached"
77
+
78
+ return False, "unknown_state"
79
+
80
+ def record_success(self) -> None:
81
+ """Record a successful call."""
82
+ self.last_success_time = datetime.now()
83
+ self.consecutive_no_progress = 0
84
+
85
+ if self.state == CircuitState.HALF_OPEN:
86
+ # Recovery successful
87
+ self.state = CircuitState.CLOSED
88
+ self.failure_count = 0
89
+ self.consecutive_same_error = 0
90
+ self.last_error_hash = None
91
+ elif self.state == CircuitState.CLOSED:
92
+ # Reset failure count on success
93
+ self.failure_count = max(0, self.failure_count - 1)
94
+
95
+ def record_failure(self, error: str) -> None:
96
+ """Record a failed call."""
97
+ self.last_failure_time = datetime.now()
98
+ self.failure_count += 1
99
+
100
+ # Track repeated errors
101
+ error_hash = hashlib.md5(error.encode()).hexdigest()[:8]
102
+ if error_hash == self.last_error_hash:
103
+ self.consecutive_same_error += 1
104
+ else:
105
+ self.consecutive_same_error = 1
106
+ self.last_error_hash = error_hash
107
+
108
+ if self.state == CircuitState.HALF_OPEN:
109
+ # Recovery failed
110
+ self.state = CircuitState.OPEN
111
+ elif self.state == CircuitState.CLOSED:
112
+ # Check if we should open
113
+ if self.failure_count >= self.failure_threshold:
114
+ self.state = CircuitState.OPEN
115
+ elif self.consecutive_same_error >= 3:
116
+ # Same error repeated = likely stuck
117
+ self.state = CircuitState.OPEN
118
+
119
+ def record_no_progress(self) -> bool:
120
+ """
121
+ Record a cycle with no progress.
122
+
123
+ Returns:
124
+ True if circuit should open due to no progress
125
+ """
126
+ self.consecutive_no_progress += 1
127
+
128
+ if self.consecutive_no_progress >= 3:
129
+ self.state = CircuitState.OPEN
130
+ return True
131
+ return False
132
+
133
+ def reset(self) -> None:
134
+ """Reset circuit breaker to initial state."""
135
+ self.state = CircuitState.CLOSED
136
+ self.failure_count = 0
137
+ self.last_failure_time = None
138
+ self.consecutive_no_progress = 0
139
+ self.consecutive_same_error = 0
140
+ self.last_error_hash = None
141
+
142
+
143
+ class ProgressTracker(BaseModel):
144
+ """Track progress to detect deadlocks and completion."""
145
+
146
+ # Output tracking
147
+ last_output_hash: Optional[str] = None
148
+ output_hashes: list[str] = Field(default_factory=list)
149
+
150
+ # Completion signals
151
+ completion_signals: list[datetime] = Field(default_factory=list)
152
+ done_keywords_found: int = 0
153
+
154
+ # Cycle tracking
155
+ cycles_without_change: int = 0
156
+ total_cycles: int = 0
157
+
158
+ # Tool tracking
159
+ last_tool_calls: list[str] = Field(default_factory=list)
160
+ repeated_tool_sequences: int = 0
161
+
162
+ # Configuration
163
+ max_cycles_no_change: int = 5
164
+ max_completion_signals: int = 3
165
+
166
+ # Completion keywords to detect
167
+ COMPLETION_KEYWORDS: list[str] = [
168
+ "task complete",
169
+ "all done",
170
+ "finished",
171
+ "completed successfully",
172
+ "nothing left to do",
173
+ "work is complete",
174
+ ]
175
+
176
+ def compute_hash(self, content: str) -> str:
177
+ """Compute hash of content for comparison."""
178
+ return hashlib.md5(content.encode()).hexdigest()[:16]
179
+
180
+ def record_cycle(
181
+ self,
182
+ output: str,
183
+ tool_calls: list[str],
184
+ files_changed: list[str],
185
+ ) -> dict[str, Any]:
186
+ """
187
+ Record a cycle and return analysis.
188
+
189
+ Returns dict with:
190
+ - has_progress: bool
191
+ - should_exit: bool
192
+ - exit_reason: Optional[str]
193
+ """
194
+ self.total_cycles += 1
195
+
196
+ # Check for progress via output change
197
+ output_hash = self.compute_hash(output)
198
+ has_output_change = output_hash != self.last_output_hash
199
+
200
+ if has_output_change:
201
+ self.output_hashes.append(output_hash)
202
+ # Keep only last 10
203
+ if len(self.output_hashes) > 10:
204
+ self.output_hashes = self.output_hashes[-10:]
205
+
206
+ self.last_output_hash = output_hash
207
+
208
+ # Check for progress via files changed
209
+ has_file_changes = len(files_changed) > 0
210
+
211
+ # Check for repeated tool sequences
212
+ tool_sequence = ",".join(sorted(tool_calls))
213
+ if tool_calls == self.last_tool_calls:
214
+ self.repeated_tool_sequences += 1
215
+ else:
216
+ self.repeated_tool_sequences = 0
217
+ self.last_tool_calls = tool_calls
218
+
219
+ # Determine if we have progress
220
+ has_progress = has_output_change or has_file_changes
221
+
222
+ if has_progress:
223
+ self.cycles_without_change = 0
224
+ else:
225
+ self.cycles_without_change += 1
226
+
227
+ # Check for completion keywords
228
+ output_lower = output.lower()
229
+ for keyword in self.COMPLETION_KEYWORDS:
230
+ if keyword in output_lower:
231
+ self.done_keywords_found += 1
232
+ self.completion_signals.append(datetime.now())
233
+ break
234
+
235
+ # Determine if should exit
236
+ should_exit = False
237
+ exit_reason = None
238
+
239
+ # Exit: Too many cycles without change
240
+ if self.cycles_without_change >= self.max_cycles_no_change:
241
+ should_exit = True
242
+ exit_reason = f"no_progress_{self.cycles_without_change}_cycles"
243
+
244
+ # Exit: Multiple completion signals
245
+ elif self.done_keywords_found >= self.max_completion_signals:
246
+ should_exit = True
247
+ exit_reason = "completion_signals_reached"
248
+
249
+ # Exit: Repeated same tool calls (stuck in loop)
250
+ elif self.repeated_tool_sequences >= 3:
251
+ should_exit = True
252
+ exit_reason = "repeated_tool_sequence"
253
+
254
+ return {
255
+ "has_progress": has_progress,
256
+ "should_exit": should_exit,
257
+ "exit_reason": exit_reason,
258
+ "cycles_without_change": self.cycles_without_change,
259
+ "completion_signals": self.done_keywords_found,
260
+ "total_cycles": self.total_cycles,
261
+ }
262
+
263
+ def reset(self) -> None:
264
+ """Reset tracker for new task."""
265
+ self.last_output_hash = None
266
+ self.output_hashes = []
267
+ self.completion_signals = []
268
+ self.done_keywords_found = 0
269
+ self.cycles_without_change = 0
270
+ self.total_cycles = 0
271
+ self.last_tool_calls = []
272
+ self.repeated_tool_sequences = 0
273
+
274
+
275
+ class ExitDetector:
276
+ """
277
+ Detect when agent should exit (Ralph's dual-condition gate).
278
+
279
+ Requires BOTH:
280
+ 1. Completion indicators (keywords, no changes)
281
+ 2. Explicit signal OR safety threshold
282
+ """
283
+
284
+ def __init__(
285
+ self,
286
+ max_cycles: int = 100,
287
+ max_cycles_no_progress: int = 5,
288
+ completion_signal_threshold: int = 2,
289
+ safety_exit_threshold: int = 5,
290
+ ):
291
+ self.max_cycles = max_cycles
292
+ self.max_cycles_no_progress = max_cycles_no_progress
293
+ self.completion_signal_threshold = completion_signal_threshold
294
+ self.safety_exit_threshold = safety_exit_threshold
295
+
296
+ # State
297
+ self.completion_indicators: list[int] = [] # Cycle numbers
298
+ self.test_only_cycles: list[int] = []
299
+ self.explicit_exit_signal: bool = False
300
+
301
+ def record_completion_indicator(self, cycle: int) -> None:
302
+ """Record that this cycle had completion indicators."""
303
+ self.completion_indicators.append(cycle)
304
+ # Keep only last 5
305
+ if len(self.completion_indicators) > 5:
306
+ self.completion_indicators = self.completion_indicators[-5:]
307
+
308
+ def record_test_only_cycle(self, cycle: int) -> None:
309
+ """Record that this cycle only ran tests."""
310
+ self.test_only_cycles.append(cycle)
311
+ if len(self.test_only_cycles) > 5:
312
+ self.test_only_cycles = self.test_only_cycles[-5:]
313
+
314
+ def set_explicit_exit(self, value: bool = True) -> None:
315
+ """Set explicit exit signal from agent."""
316
+ self.explicit_exit_signal = value
317
+
318
+ def should_exit(
319
+ self,
320
+ current_cycle: int,
321
+ cycles_no_progress: int,
322
+ progress_tracker: Optional[ProgressTracker] = None,
323
+ ) -> tuple[bool, str]:
324
+ """
325
+ Check if agent should exit (dual-condition gate).
326
+
327
+ Returns:
328
+ Tuple of (should_exit, reason)
329
+ """
330
+ # Safety: Max cycles reached
331
+ if current_cycle >= self.max_cycles:
332
+ return True, f"max_cycles_reached_{self.max_cycles}"
333
+
334
+ # Safety: No progress for too long
335
+ if cycles_no_progress >= self.max_cycles_no_progress:
336
+ return True, f"no_progress_{cycles_no_progress}_cycles"
337
+
338
+ # Count recent completion indicators
339
+ recent_indicators = len([
340
+ c for c in self.completion_indicators
341
+ if current_cycle - c <= 5
342
+ ])
343
+
344
+ # Safety circuit breaker: Too many completion indicators
345
+ if recent_indicators >= self.safety_exit_threshold:
346
+ return True, "safety_circuit_breaker"
347
+
348
+ # Dual-condition: Indicators + explicit signal
349
+ if recent_indicators >= self.completion_signal_threshold:
350
+ if self.explicit_exit_signal:
351
+ return True, "task_complete"
352
+
353
+ # Test saturation: Only running tests
354
+ recent_test_only = len([
355
+ c for c in self.test_only_cycles
356
+ if current_cycle - c <= 5
357
+ ])
358
+ if recent_test_only >= 3:
359
+ return True, "test_saturation"
360
+
361
+ # Check progress tracker if available
362
+ if progress_tracker:
363
+ analysis = progress_tracker.record_cycle("", [], [])
364
+ if analysis["should_exit"]:
365
+ return True, analysis["exit_reason"]
366
+
367
+ return False, ""
368
+
369
+ def reset(self) -> None:
370
+ """Reset for new task."""
371
+ self.completion_indicators = []
372
+ self.test_only_cycles = []
373
+ self.explicit_exit_signal = False
374
+
375
+
376
+ def save_reliability_state(
377
+ path: Path,
378
+ circuit_breaker: CircuitBreaker,
379
+ progress_tracker: ProgressTracker,
380
+ exit_detector: ExitDetector,
381
+ ) -> None:
382
+ """Save reliability state to disk."""
383
+ state = {
384
+ "circuit_breaker": circuit_breaker.model_dump(),
385
+ "progress_tracker": progress_tracker.model_dump(),
386
+ "exit_detector": {
387
+ "completion_indicators": exit_detector.completion_indicators,
388
+ "test_only_cycles": exit_detector.test_only_cycles,
389
+ "explicit_exit_signal": exit_detector.explicit_exit_signal,
390
+ "max_cycles": exit_detector.max_cycles,
391
+ },
392
+ "saved_at": datetime.now().isoformat(),
393
+ }
394
+ path.write_text(json.dumps(state, indent=2, default=str), encoding="utf-8")
395
+
396
+
397
+ def load_reliability_state(
398
+ path: Path,
399
+ ) -> tuple[Optional[CircuitBreaker], Optional[ProgressTracker], Optional[ExitDetector]]:
400
+ """Load reliability state from disk."""
401
+ if not path.exists():
402
+ return None, None, None
403
+
404
+ try:
405
+ data = json.loads(path.read_text(encoding="utf-8"))
406
+
407
+ circuit_breaker = CircuitBreaker.model_validate(data["circuit_breaker"])
408
+ progress_tracker = ProgressTracker.model_validate(data["progress_tracker"])
409
+
410
+ exit_data = data["exit_detector"]
411
+ exit_detector = ExitDetector(
412
+ max_cycles=exit_data.get("max_cycles", 100),
413
+ )
414
+ exit_detector.completion_indicators = exit_data.get("completion_indicators", [])
415
+ exit_detector.test_only_cycles = exit_data.get("test_only_cycles", [])
416
+ exit_detector.explicit_exit_signal = exit_data.get("explicit_exit_signal", False)
417
+
418
+ return circuit_breaker, progress_tracker, exit_detector
419
+ except Exception:
420
+ return None, None, None
@@ -0,0 +1,143 @@
1
+ """
2
+ Sandbox - Secure folder isolation for projects.
3
+
4
+ Each project can ONLY access its own folder. No escape allowed.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+
12
+ class SandboxViolation(Exception):
13
+ """Raised when agent tries to access outside its sandbox."""
14
+
15
+ pass
16
+
17
+
18
+ class Sandbox:
19
+ """
20
+ Enforces folder isolation for a project.
21
+
22
+ All file operations must go through this class to ensure
23
+ the agent cannot access files outside its designated folder.
24
+ """
25
+
26
+ def __init__(self, project_root: Path):
27
+ """
28
+ Initialize sandbox with project root folder.
29
+
30
+ Args:
31
+ project_root: Absolute path to project folder
32
+ """
33
+ self.root = project_root.resolve()
34
+ self.workspace = self.root / "workspace"
35
+ self._aurora_dir = self.root / ".nullabot"
36
+
37
+ # Ensure directories exist
38
+ self.workspace.mkdir(parents=True, exist_ok=True)
39
+ self._aurora_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ def validate_path(self, path: str | Path) -> Path:
42
+ """
43
+ Validate and resolve a path, ensuring it's within sandbox.
44
+
45
+ Args:
46
+ path: Relative or absolute path to validate
47
+
48
+ Returns:
49
+ Resolved absolute path within sandbox
50
+
51
+ Raises:
52
+ SandboxViolation: If path escapes sandbox
53
+ """
54
+ # Convert to Path object
55
+ path = Path(path)
56
+
57
+ # If relative, resolve from workspace
58
+ if not path.is_absolute():
59
+ resolved = (self.workspace / path).resolve()
60
+ else:
61
+ resolved = path.resolve()
62
+
63
+ # Check if within sandbox (workspace or .nullabot)
64
+ try:
65
+ resolved.relative_to(self.root)
66
+ except ValueError:
67
+ raise SandboxViolation(
68
+ f"Access denied: '{path}' is outside project sandbox.\n"
69
+ f"Allowed: {self.root}"
70
+ )
71
+
72
+ return resolved
73
+
74
+ def read_file(self, path: str | Path) -> str:
75
+ """Safely read a file within sandbox."""
76
+ safe_path = self.validate_path(path)
77
+ if not safe_path.exists():
78
+ raise FileNotFoundError(f"File not found: {path}")
79
+ if not safe_path.is_file():
80
+ raise IsADirectoryError(f"Not a file: {path}")
81
+ return safe_path.read_text(encoding="utf-8")
82
+
83
+ def write_file(self, path: str | Path, content: str) -> Path:
84
+ """Safely write a file within sandbox."""
85
+ safe_path = self.validate_path(path)
86
+ safe_path.parent.mkdir(parents=True, exist_ok=True)
87
+ safe_path.write_text(content, encoding="utf-8")
88
+ return safe_path
89
+
90
+ def list_dir(self, path: str | Path = ".") -> list[Path]:
91
+ """Safely list directory contents within sandbox."""
92
+ safe_path = self.validate_path(path)
93
+ if not safe_path.is_dir():
94
+ raise NotADirectoryError(f"Not a directory: {path}")
95
+ return list(safe_path.iterdir())
96
+
97
+ def exists(self, path: str | Path) -> bool:
98
+ """Check if path exists within sandbox."""
99
+ try:
100
+ safe_path = self.validate_path(path)
101
+ return safe_path.exists()
102
+ except SandboxViolation:
103
+ return False
104
+
105
+ def mkdir(self, path: str | Path) -> Path:
106
+ """Safely create directory within sandbox."""
107
+ safe_path = self.validate_path(path)
108
+ safe_path.mkdir(parents=True, exist_ok=True)
109
+ return safe_path
110
+
111
+ def delete_file(self, path: str | Path) -> None:
112
+ """Safely delete a file within sandbox."""
113
+ safe_path = self.validate_path(path)
114
+ if safe_path.is_file():
115
+ safe_path.unlink()
116
+ elif safe_path.is_dir():
117
+ raise IsADirectoryError(f"Cannot delete directory with delete_file: {path}")
118
+
119
+ def get_relative_path(self, path: str | Path) -> Path:
120
+ """Get path relative to workspace."""
121
+ safe_path = self.validate_path(path)
122
+ try:
123
+ return safe_path.relative_to(self.workspace)
124
+ except ValueError:
125
+ return safe_path.relative_to(self.root)
126
+
127
+ @property
128
+ def state_file(self) -> Path:
129
+ """Path to agent state file."""
130
+ return self._aurora_dir / "state.json"
131
+
132
+ @property
133
+ def checkpoint_file(self) -> Path:
134
+ """Path to checkpoint file."""
135
+ return self._aurora_dir / "checkpoint.json"
136
+
137
+ @property
138
+ def history_file(self) -> Path:
139
+ """Path to conversation history file."""
140
+ return self._aurora_dir / "history.jsonl"
141
+
142
+ def __repr__(self) -> str:
143
+ return f"Sandbox(root={self.root})"