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.
- nullabot/__init__.py +3 -0
- nullabot/agents/__init__.py +7 -0
- nullabot/agents/claude_agent.py +785 -0
- nullabot/bot/__init__.py +5 -0
- nullabot/bot/telegram.py +1729 -0
- nullabot/cli.py +740 -0
- nullabot/core/__init__.py +13 -0
- nullabot/core/claude_code.py +303 -0
- nullabot/core/memory.py +864 -0
- nullabot/core/project.py +194 -0
- nullabot/core/rate_limiter.py +484 -0
- nullabot/core/reliability.py +420 -0
- nullabot/core/sandbox.py +143 -0
- nullabot/core/state.py +214 -0
- nullabot-1.0.1.dist-info/METADATA +130 -0
- nullabot-1.0.1.dist-info/RECORD +19 -0
- nullabot-1.0.1.dist-info/WHEEL +4 -0
- nullabot-1.0.1.dist-info/entry_points.txt +2 -0
- nullabot-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
nullabot/core/sandbox.py
ADDED
|
@@ -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})"
|