terminal-agent-cli 0.1.0__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.
- terminal_agent/__init__.py +7 -0
- terminal_agent/__main__.py +7 -0
- terminal_agent/agent/__init__.py +9 -0
- terminal_agent/agent/loop.py +338 -0
- terminal_agent/checkpoints/__init__.py +9 -0
- terminal_agent/checkpoints/manager.py +151 -0
- terminal_agent/cli/commands/__init__.py +26 -0
- terminal_agent/cli/commands/checkpoint.py +76 -0
- terminal_agent/cli/commands/clean.py +170 -0
- terminal_agent/cli/commands/config_cmd.py +32 -0
- terminal_agent/cli/commands/diff.py +45 -0
- terminal_agent/cli/commands/doctor.py +136 -0
- terminal_agent/cli/commands/resume.py +109 -0
- terminal_agent/cli/commands/run.py +184 -0
- terminal_agent/cli/commands/setup.py +229 -0
- terminal_agent/cli/commands/status.py +62 -0
- terminal_agent/cli/commands/test.py +38 -0
- terminal_agent/cli/commands/trace.py +60 -0
- terminal_agent/cli/main.py +94 -0
- terminal_agent/cli/theme.py +55 -0
- terminal_agent/cli/ui.py +147 -0
- terminal_agent/config/__init__.py +30 -0
- terminal_agent/config/schema.py +100 -0
- terminal_agent/config/settings.py +85 -0
- terminal_agent/context/__init__.py +11 -0
- terminal_agent/context/engine.py +173 -0
- terminal_agent/context/ranker.py +87 -0
- terminal_agent/git/__init__.py +10 -0
- terminal_agent/git/adapter.py +219 -0
- terminal_agent/planner/__init__.py +10 -0
- terminal_agent/planner/contract.py +58 -0
- terminal_agent/planner/planner.py +30 -0
- terminal_agent/providers/__init__.py +30 -0
- terminal_agent/providers/anthropic_provider.py +141 -0
- terminal_agent/providers/base.py +57 -0
- terminal_agent/providers/detector.py +397 -0
- terminal_agent/providers/factory.py +29 -0
- terminal_agent/providers/gemini_provider.py +130 -0
- terminal_agent/providers/mock_provider.py +80 -0
- terminal_agent/providers/ollama_provider.py +124 -0
- terminal_agent/providers/openai_provider.py +124 -0
- terminal_agent/recovery/__init__.py +10 -0
- terminal_agent/recovery/classifier.py +64 -0
- terminal_agent/recovery/strategies.py +62 -0
- terminal_agent/sandbox/__init__.py +13 -0
- terminal_agent/sandbox/base.py +41 -0
- terminal_agent/sandbox/docker.py +129 -0
- terminal_agent/sandbox/local.py +129 -0
- terminal_agent/security/__init__.py +13 -0
- terminal_agent/security/classifier.py +151 -0
- terminal_agent/security/policy.py +51 -0
- terminal_agent/security/secrets.py +105 -0
- terminal_agent/session/__init__.py +33 -0
- terminal_agent/session/manager.py +195 -0
- terminal_agent/session/models.py +160 -0
- terminal_agent/telemetry/__init__.py +12 -0
- terminal_agent/telemetry/events.py +87 -0
- terminal_agent/telemetry/metrics.py +38 -0
- terminal_agent/tools/__init__.py +33 -0
- terminal_agent/tools/base.py +59 -0
- terminal_agent/tools/checkpoint_tools.py +72 -0
- terminal_agent/tools/command_tools.py +125 -0
- terminal_agent/tools/file_tools.py +340 -0
- terminal_agent/tools/git_tools.py +124 -0
- terminal_agent/tools/registry.py +109 -0
- terminal_agent/verifier/__init__.py +13 -0
- terminal_agent/verifier/assertions.py +115 -0
- terminal_agent/verifier/engine.py +125 -0
- terminal_agent/verifier/runners.py +80 -0
- terminal_agent_cli-0.1.0.dist-info/METADATA +438 -0
- terminal_agent_cli-0.1.0.dist-info/RECORD +74 -0
- terminal_agent_cli-0.1.0.dist-info/WHEEL +4 -0
- terminal_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- terminal_agent_cli-0.1.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"""Core bounded autonomous agent loop: Observe -> Plan -> Act -> Verify -> Repair -> Verify."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
from terminal_agent.checkpoints.manager import CheckpointManager
|
|
9
|
+
from terminal_agent.config.schema import TerminalAgentConfig
|
|
10
|
+
from terminal_agent.context.engine import RepositoryContextEngine
|
|
11
|
+
from terminal_agent.git.adapter import GitAdapter
|
|
12
|
+
from terminal_agent.planner.contract import TaskContractGenerator
|
|
13
|
+
from terminal_agent.planner.planner import ExecutionPlanner
|
|
14
|
+
from terminal_agent.providers.base import LLMMessage, LLMResponse, LLMToolCall, ModelProvider
|
|
15
|
+
from terminal_agent.recovery.classifier import FailureClassifier
|
|
16
|
+
from terminal_agent.recovery.strategies import RecoveryEngine
|
|
17
|
+
from terminal_agent.session.manager import SessionManager
|
|
18
|
+
from terminal_agent.session.models import (
|
|
19
|
+
FailureCategory,
|
|
20
|
+
PlanItem,
|
|
21
|
+
SessionState,
|
|
22
|
+
StepAction,
|
|
23
|
+
TaskContract,
|
|
24
|
+
VerificationResult,
|
|
25
|
+
VerificationStatus,
|
|
26
|
+
)
|
|
27
|
+
from terminal_agent.telemetry.events import TelemetryLogger
|
|
28
|
+
from terminal_agent.telemetry.metrics import MetricsCollector
|
|
29
|
+
from terminal_agent.tools.registry import ToolRegistry
|
|
30
|
+
from terminal_agent.verifier.engine import IndependentVerifier
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SYSTEM_PROMPT = """You are TERMINAL AGENT, an autonomous verify-first terminal coding agent.
|
|
34
|
+
Tagline: Build. Verify. Ship.
|
|
35
|
+
|
|
36
|
+
CORE PRINCIPLE:
|
|
37
|
+
You do NOT simply modify code and claim completion.
|
|
38
|
+
A task is only complete when independently verified by running tests, validating contract assertions, and inspecting git diffs.
|
|
39
|
+
|
|
40
|
+
RULES:
|
|
41
|
+
1. Every tool call MUST include a clear, concise 'reason' explaining what you are doing and why.
|
|
42
|
+
2. Read and analyze files before making changes.
|
|
43
|
+
3. Keep changes minimal and focused directly on the user's task contract.
|
|
44
|
+
4. When you believe your changes are in place, run verification tests.
|
|
45
|
+
5. If tests fail, analyze the error trace, determine root cause, and apply targeted repairs.
|
|
46
|
+
6. Never modify test files unless the user explicitly requested test changes.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentLoop:
|
|
51
|
+
"""Orchestrates the autonomous verify-first coding cycle."""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
session_state: SessionState,
|
|
56
|
+
config: TerminalAgentConfig,
|
|
57
|
+
provider: ModelProvider,
|
|
58
|
+
tool_registry: ToolRegistry,
|
|
59
|
+
verifier: IndependentVerifier,
|
|
60
|
+
context_engine: RepositoryContextEngine,
|
|
61
|
+
checkpoint_manager: CheckpointManager,
|
|
62
|
+
session_manager: SessionManager,
|
|
63
|
+
telemetry_logger: TelemetryLogger,
|
|
64
|
+
step_callback: Optional[Callable[[str, Any], None]] = None
|
|
65
|
+
):
|
|
66
|
+
self.state = session_state
|
|
67
|
+
self.config = config
|
|
68
|
+
self.provider = provider
|
|
69
|
+
self.tools = tool_registry
|
|
70
|
+
self.verifier = verifier
|
|
71
|
+
self.context = context_engine
|
|
72
|
+
self.checkpoints = checkpoint_manager
|
|
73
|
+
self.session_mgr = session_manager
|
|
74
|
+
self.telemetry = telemetry_logger
|
|
75
|
+
self.step_callback = step_callback or (lambda event, data: None)
|
|
76
|
+
self.start_time = time.time()
|
|
77
|
+
|
|
78
|
+
def _notify(self, event_type: str, data: Any = None) -> None:
|
|
79
|
+
"""Send UI update notification."""
|
|
80
|
+
try:
|
|
81
|
+
self.step_callback(event_type, data)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
def run(self) -> SessionState:
|
|
86
|
+
"""Execute the bounded autonomous loop."""
|
|
87
|
+
self._notify("start", self.state)
|
|
88
|
+
|
|
89
|
+
# 1. OBSERVE & INITIALIZE CONTRACT
|
|
90
|
+
if not self.state.contract:
|
|
91
|
+
self._notify("observe_start", "Inspecting repository...")
|
|
92
|
+
contract = TaskContractGenerator.generate_from_description(
|
|
93
|
+
task_description=self.state.task_description,
|
|
94
|
+
verification_commands=self.config.verification.tests
|
|
95
|
+
)
|
|
96
|
+
self.state.contract = contract
|
|
97
|
+
self.state.plan = ExecutionPlanner.create_initial_plan(contract)
|
|
98
|
+
self.session_mgr.save_session(self.state)
|
|
99
|
+
self._notify("contract_ready", contract)
|
|
100
|
+
|
|
101
|
+
# 2. CREATE BASELINE CHECKPOINT
|
|
102
|
+
if not self.state.checkpoints:
|
|
103
|
+
self._notify("checkpoint_creating", "Creating baseline checkpoint...")
|
|
104
|
+
baseline_chk = self.checkpoints.create_checkpoint(name="baseline_before_changes")
|
|
105
|
+
self.state.checkpoints.append(baseline_chk)
|
|
106
|
+
self.session_mgr.save_session(self.state)
|
|
107
|
+
self._notify("checkpoint_created", baseline_chk)
|
|
108
|
+
|
|
109
|
+
# 3. BUILD INITIAL CONTEXT & MESSAGES
|
|
110
|
+
repo_context = self.context.build_initial_context(
|
|
111
|
+
task_description=self.state.task_description,
|
|
112
|
+
contract=self.state.contract
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
messages: List[LLMMessage] = [
|
|
116
|
+
LLMMessage(role="system", content=SYSTEM_PROMPT),
|
|
117
|
+
LLMMessage(
|
|
118
|
+
role="user",
|
|
119
|
+
content=(
|
|
120
|
+
f"TASK CONTRACT:\n"
|
|
121
|
+
f"Goal: {self.state.contract.goal}\n"
|
|
122
|
+
f"Constraints:\n" + "\n".join(f"- {c}" for c in self.state.contract.constraints) + "\n\n"
|
|
123
|
+
f"Success Criteria:\n" + "\n".join(f"- {sc}" for sc in self.state.contract.success_criteria) + "\n\n"
|
|
124
|
+
f"REPOSITORY CONTEXT:\n{repo_context}\n\n"
|
|
125
|
+
f"Please begin by inspecting the relevant code and formulating your changes."
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
# Replay completed steps if resuming
|
|
131
|
+
for past_step in self.state.completed_steps:
|
|
132
|
+
if past_step.tool_name:
|
|
133
|
+
messages.append(
|
|
134
|
+
LLMMessage(
|
|
135
|
+
role="assistant",
|
|
136
|
+
tool_calls=[
|
|
137
|
+
LLMToolCall(
|
|
138
|
+
id=f"call_{past_step.step_number}",
|
|
139
|
+
name=past_step.tool_name,
|
|
140
|
+
arguments=past_step.tool_args or {}
|
|
141
|
+
)
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
messages.append(
|
|
146
|
+
LLMMessage(
|
|
147
|
+
role="tool",
|
|
148
|
+
name=past_step.tool_name,
|
|
149
|
+
tool_call_id=f"call_{past_step.step_number}",
|
|
150
|
+
content=past_step.output or ""
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
step_count = len(self.state.completed_steps)
|
|
155
|
+
retry_count = len(self.state.failures)
|
|
156
|
+
max_steps = self.config.agent.max_steps
|
|
157
|
+
max_retries = self.config.agent.max_retries
|
|
158
|
+
timeout_sec = self.config.agent.timeout_seconds
|
|
159
|
+
|
|
160
|
+
# 4. BOUNDED AGENT LOOP
|
|
161
|
+
while step_count < max_steps:
|
|
162
|
+
# Check overall timeout
|
|
163
|
+
elapsed = time.time() - self.start_time
|
|
164
|
+
if elapsed > timeout_sec:
|
|
165
|
+
self.state.current_status = VerificationStatus.FAILED
|
|
166
|
+
self.session_mgr.record_step(
|
|
167
|
+
self.state,
|
|
168
|
+
action_type="timeout",
|
|
169
|
+
reason=f"Task timed out after {elapsed:.1f} seconds.",
|
|
170
|
+
status="error"
|
|
171
|
+
)
|
|
172
|
+
self._notify("timeout", f"Execution exceeded {timeout_sec}s limit.")
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
step_count += 1
|
|
176
|
+
self.state.next_step = f"Executing step {step_count}..."
|
|
177
|
+
self._notify("step_start", {"step": step_count, "retries": retry_count})
|
|
178
|
+
|
|
179
|
+
# Query Model Provider
|
|
180
|
+
tool_schemas = self.tools.get_schemas()
|
|
181
|
+
try:
|
|
182
|
+
response: LLMResponse = self.provider.generate(
|
|
183
|
+
messages=messages,
|
|
184
|
+
tools=tool_schemas,
|
|
185
|
+
temperature=self.config.provider.temperature
|
|
186
|
+
)
|
|
187
|
+
except Exception as e:
|
|
188
|
+
self.session_mgr.record_step(
|
|
189
|
+
self.state,
|
|
190
|
+
action_type="provider_error",
|
|
191
|
+
reason=f"Model provider error: {e}",
|
|
192
|
+
status="error"
|
|
193
|
+
)
|
|
194
|
+
self._notify("error", f"Model provider failed: {e}")
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
# Handle model message and tool calls
|
|
198
|
+
if response.content:
|
|
199
|
+
self._notify("agent_thought", response.content)
|
|
200
|
+
|
|
201
|
+
if not response.tool_calls:
|
|
202
|
+
# Model stopped or claims completion without tool calls -> Trigger Independent Verification
|
|
203
|
+
self._notify("verification_triggered", "Model requested verification / completed actions.")
|
|
204
|
+
v_result = self.verifier.verify(contract=self.state.contract)
|
|
205
|
+
self.session_mgr.record_verification(self.state, v_result)
|
|
206
|
+
self._notify("verification_result", v_result)
|
|
207
|
+
|
|
208
|
+
if v_result.status == VerificationStatus.VERIFIED:
|
|
209
|
+
self._finalize_success(v_result)
|
|
210
|
+
break
|
|
211
|
+
else:
|
|
212
|
+
# Verification failed -> enter repair logic
|
|
213
|
+
retry_count += 1
|
|
214
|
+
if retry_count > max_retries:
|
|
215
|
+
self.state.current_status = VerificationStatus.FAILED
|
|
216
|
+
self._notify("max_retries_exceeded", f"Failed verification after {max_retries} repair retries.")
|
|
217
|
+
break
|
|
218
|
+
|
|
219
|
+
# Classify failure & formulate repair
|
|
220
|
+
cat, cat_reason = FailureClassifier.classify(v_result.test_output)
|
|
221
|
+
hypothesis, repair_action = RecoveryEngine.analyze_and_plan_recovery(cat, v_result.test_output, self.state)
|
|
222
|
+
failure_record = self.session_mgr.record_failure(
|
|
223
|
+
self.state,
|
|
224
|
+
category=cat,
|
|
225
|
+
raw_output=v_result.test_output,
|
|
226
|
+
root_cause_hypothesis=hypothesis,
|
|
227
|
+
recovery_action=repair_action
|
|
228
|
+
)
|
|
229
|
+
self._notify("repair_plan", failure_record)
|
|
230
|
+
|
|
231
|
+
# Append failure diagnostics to conversation
|
|
232
|
+
messages.append(
|
|
233
|
+
LLMMessage(
|
|
234
|
+
role="user",
|
|
235
|
+
content=(
|
|
236
|
+
f"INDEPENDENT VERIFICATION FAILED (Status: {v_result.status.value})\n"
|
|
237
|
+
f"Test Failures: {v_result.tests_failed}/{v_result.tests_run}\n"
|
|
238
|
+
f"Primary Failure Category: {cat.value}\n"
|
|
239
|
+
f"Root Cause Hypothesis: {hypothesis}\n"
|
|
240
|
+
f"Recommended Repair: {repair_action}\n\n"
|
|
241
|
+
f"Test Output Trace:\n{v_result.test_output[-2000:]}\n\n"
|
|
242
|
+
f"Please repair the issue in the target files and re-test."
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
continue
|
|
247
|
+
|
|
248
|
+
# Execute tool calls
|
|
249
|
+
for tc in response.tool_calls:
|
|
250
|
+
tool_name = tc.name
|
|
251
|
+
tool_args = tc.arguments or {}
|
|
252
|
+
reason = str(tool_args.get("reason", response.content or "Executing tool action"))
|
|
253
|
+
|
|
254
|
+
self._notify("tool_executing", {"tool": tool_name, "args": tool_args, "reason": reason})
|
|
255
|
+
|
|
256
|
+
tool_result = self.tools.execute(tool_name, tool_args, reason=reason)
|
|
257
|
+
|
|
258
|
+
# Track modified files
|
|
259
|
+
if tool_name in ("write_file", "edit_file") and tool_result.success:
|
|
260
|
+
p = tool_args.get("path")
|
|
261
|
+
if p and p not in self.state.modified_files:
|
|
262
|
+
self.state.modified_files.append(p)
|
|
263
|
+
|
|
264
|
+
# Record step and telemetry
|
|
265
|
+
self.session_mgr.record_step(
|
|
266
|
+
self.state,
|
|
267
|
+
action_type="tool_call",
|
|
268
|
+
tool_name=tool_name,
|
|
269
|
+
tool_args=tool_args,
|
|
270
|
+
reason=reason,
|
|
271
|
+
output=tool_result.output if tool_result.success else tool_result.error,
|
|
272
|
+
status="success" if tool_result.success else "error",
|
|
273
|
+
duration_ms=tool_result.duration_ms
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
self.telemetry.log_event(
|
|
277
|
+
step=step_count,
|
|
278
|
+
event_type="tool_call",
|
|
279
|
+
tool=tool_name,
|
|
280
|
+
reason=reason,
|
|
281
|
+
duration_ms=tool_result.duration_ms,
|
|
282
|
+
status="success" if tool_result.success else "error",
|
|
283
|
+
data=tool_args
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self._notify("tool_completed", tool_result)
|
|
287
|
+
|
|
288
|
+
# Append tool exchange to LLM history
|
|
289
|
+
messages.append(
|
|
290
|
+
LLMMessage(
|
|
291
|
+
role="assistant",
|
|
292
|
+
tool_calls=[tc]
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
messages.append(
|
|
296
|
+
LLMMessage(
|
|
297
|
+
role="tool",
|
|
298
|
+
name=tool_name,
|
|
299
|
+
tool_call_id=tc.id,
|
|
300
|
+
content=tool_result.output if tool_result.success else f"ERROR: {tool_result.error}"
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# End of loop: update final metrics
|
|
305
|
+
MetricsCollector.calculate_metrics(self.state)
|
|
306
|
+
self.session_mgr.save_session(self.state)
|
|
307
|
+
self._notify("finish", self.state)
|
|
308
|
+
return self.state
|
|
309
|
+
|
|
310
|
+
def _finalize_success(self, v_result: VerificationResult) -> None:
|
|
311
|
+
"""Mark session as VERIFIED and synthesize final Proof of Done."""
|
|
312
|
+
self.state.current_status = VerificationStatus.VERIFIED
|
|
313
|
+
diff_stats = self.verifier.git_adapter.get_diff_stats()
|
|
314
|
+
|
|
315
|
+
# Build structured Proof of Done
|
|
316
|
+
pod = {
|
|
317
|
+
"session_id": self.state.session_id,
|
|
318
|
+
"task": self.state.task_description,
|
|
319
|
+
"result": "VERIFIED ✓",
|
|
320
|
+
"tests_passed": f"{v_result.tests_passed}/{v_result.tests_run}",
|
|
321
|
+
"lint": "PASS" if v_result.lint_passed else "FAIL",
|
|
322
|
+
"files_changed_count": len(self.state.modified_files),
|
|
323
|
+
"files_changed": self.state.modified_files,
|
|
324
|
+
"unintended_files_count": v_result.unintended_files_count,
|
|
325
|
+
"retries_count": len(self.state.failures),
|
|
326
|
+
"git_diff_reviewed": True,
|
|
327
|
+
"success_criteria_passed": f"{sum(1 for a in v_result.assertions if a.passed)}/{len(v_result.assertions)}",
|
|
328
|
+
"summary": {
|
|
329
|
+
"what_changed": f"Modified files: {', '.join(self.state.modified_files) if self.state.modified_files else 'None'}",
|
|
330
|
+
"why_it_changed": self.state.contract.goal if self.state.contract else "Fulfilled task requirements",
|
|
331
|
+
"what_was_verified": f"Independent test suite ({v_result.tests_passed} tests passed)",
|
|
332
|
+
"remaining_limitations": "None detected under verified test suite."
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
self.state.proof_of_done = pod
|
|
336
|
+
self.session_mgr.save_session(self.state)
|
|
337
|
+
self._notify("proof_of_done", pod)
|
|
338
|
+
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Checkpoint manager for creating and restoring repository and session snapshots."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from terminal_agent.git.adapter import GitAdapter
|
|
12
|
+
from terminal_agent.session.models import CheckpointSnapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_checkpoints_dir(working_dir: Optional[Path] = None) -> Path:
|
|
16
|
+
"""Return .terminal_agent/checkpoints directory."""
|
|
17
|
+
base = working_dir or Path.cwd()
|
|
18
|
+
c_dir = base / ".terminal_agent" / "checkpoints"
|
|
19
|
+
c_dir.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
return c_dir
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CheckpointManager:
|
|
24
|
+
"""Creates, lists, and restores filesystem snapshots."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, working_dir: Optional[Path] = None):
|
|
27
|
+
self.working_dir = (working_dir or Path.cwd()).resolve()
|
|
28
|
+
self.checkpoints_dir = get_checkpoints_dir(self.working_dir)
|
|
29
|
+
self.git_adapter = GitAdapter(self.working_dir)
|
|
30
|
+
|
|
31
|
+
def create_checkpoint(
|
|
32
|
+
self,
|
|
33
|
+
name: str,
|
|
34
|
+
step_number: int = 0,
|
|
35
|
+
target_files: Optional[List[str]] = None
|
|
36
|
+
) -> CheckpointSnapshot:
|
|
37
|
+
"""Create a checkpoint snapshot of the repository."""
|
|
38
|
+
now = datetime.now(timezone.utc)
|
|
39
|
+
chk_id = f"chk_{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:4]}"
|
|
40
|
+
chk_dir = self.checkpoints_dir / chk_id
|
|
41
|
+
chk_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
files_store_dir = chk_dir / "files"
|
|
43
|
+
files_store_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
git_commit = self.git_adapter.get_head_commit()
|
|
45
|
+
files_to_save: List[Path] = []
|
|
46
|
+
if target_files:
|
|
47
|
+
for f in target_files:
|
|
48
|
+
p = (self.working_dir / f).resolve()
|
|
49
|
+
if p.exists() and p.is_file():
|
|
50
|
+
files_to_save.append(p)
|
|
51
|
+
else:
|
|
52
|
+
for root, dirs, files in os.walk(self.working_dir):
|
|
53
|
+
dirs[:] = [d for d in dirs if d not in (".git", ".terminal_agent", ".venv", "venv", "__pycache__", "node_modules", "dist", "build", ".pytest_cache")]
|
|
54
|
+
for f in files:
|
|
55
|
+
full_p = Path(root) / f
|
|
56
|
+
if not full_p.name.startswith(".env") and not full_p.name.endswith((".key", ".pem")):
|
|
57
|
+
files_to_save.append(full_p)
|
|
58
|
+
file_contents: Dict[str, str] = {}
|
|
59
|
+
modified_rel_paths: List[str] = []
|
|
60
|
+
for p in files_to_save:
|
|
61
|
+
try:
|
|
62
|
+
rel_p = str(p.relative_to(self.working_dir)).replace("\\", "/")
|
|
63
|
+
dest = files_store_dir / rel_p
|
|
64
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
shutil.copy2(p, dest)
|
|
66
|
+
try:
|
|
67
|
+
content = p.read_text(encoding="utf-8", errors="ignore")
|
|
68
|
+
if len(content) < 500000:
|
|
69
|
+
file_contents[rel_p] = content
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
modified_rel_paths.append(rel_p)
|
|
73
|
+
except Exception:
|
|
74
|
+
continue
|
|
75
|
+
snapshot = CheckpointSnapshot(
|
|
76
|
+
checkpoint_id=chk_id,
|
|
77
|
+
name=name,
|
|
78
|
+
created_at=now.isoformat(),
|
|
79
|
+
step_number=step_number,
|
|
80
|
+
git_commit=git_commit,
|
|
81
|
+
modified_files=modified_rel_paths,
|
|
82
|
+
file_contents=file_contents
|
|
83
|
+
)
|
|
84
|
+
metadata_file = chk_dir / "metadata.json"
|
|
85
|
+
metadata_file.write_text(snapshot.model_dump_json(indent=2), encoding="utf-8")
|
|
86
|
+
return snapshot
|
|
87
|
+
|
|
88
|
+
def list_checkpoints(self) -> List[CheckpointSnapshot]:
|
|
89
|
+
"""List all available checkpoints ordered by most recent."""
|
|
90
|
+
checkpoints = []
|
|
91
|
+
if not self.checkpoints_dir.exists():
|
|
92
|
+
return checkpoints
|
|
93
|
+
for d in sorted(self.checkpoints_dir.iterdir(), key=os.path.getmtime, reverse=True):
|
|
94
|
+
if d.is_dir():
|
|
95
|
+
meta_file = d / "metadata.json"
|
|
96
|
+
if meta_file.exists():
|
|
97
|
+
try:
|
|
98
|
+
data = json.loads(meta_file.read_text(encoding="utf-8"))
|
|
99
|
+
checkpoints.append(CheckpointSnapshot.model_validate(data))
|
|
100
|
+
except Exception:
|
|
101
|
+
continue
|
|
102
|
+
return checkpoints
|
|
103
|
+
|
|
104
|
+
def get_checkpoint(self, checkpoint_id: str) -> Optional[CheckpointSnapshot]:
|
|
105
|
+
"""Retrieve checkpoint by ID or name substring."""
|
|
106
|
+
for chk in self.list_checkpoints():
|
|
107
|
+
if chk.checkpoint_id == checkpoint_id or checkpoint_id in chk.checkpoint_id or chk.name == checkpoint_id:
|
|
108
|
+
return chk
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def rollback(self, checkpoint_id: str) -> bool:
|
|
112
|
+
"""Restore repository state from a checkpoint."""
|
|
113
|
+
chk = self.get_checkpoint(checkpoint_id)
|
|
114
|
+
if not chk:
|
|
115
|
+
return False
|
|
116
|
+
chk_dir = self.checkpoints_dir / chk.checkpoint_id
|
|
117
|
+
files_store_dir = chk_dir / "files"
|
|
118
|
+
if not files_store_dir.exists():
|
|
119
|
+
return False
|
|
120
|
+
for root, _, files in os.walk(files_store_dir):
|
|
121
|
+
for f in files:
|
|
122
|
+
src_path = Path(root) / f
|
|
123
|
+
rel_path = src_path.relative_to(files_store_dir)
|
|
124
|
+
target_path = self.working_dir / rel_path
|
|
125
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
shutil.copy2(src_path, target_path)
|
|
127
|
+
return True
|
|
128
|
+
|
|
129
|
+
def prune_checkpoints(self, days: Optional[int] = None) -> List[Path]:
|
|
130
|
+
"""Delete checkpoint directories older than ``days``.
|
|
131
|
+
|
|
132
|
+
If ``days`` is None, delete every checkpoint directory. The parent
|
|
133
|
+
checkpoints directory is left in place. Returns deleted directory paths.
|
|
134
|
+
"""
|
|
135
|
+
deleted: List[Path] = []
|
|
136
|
+
if not self.checkpoints_dir.exists():
|
|
137
|
+
return deleted
|
|
138
|
+
cutoff = None
|
|
139
|
+
if days is not None:
|
|
140
|
+
cutoff = datetime.now(timezone.utc).timestamp() - (days * 86400)
|
|
141
|
+
for entry in list(self.checkpoints_dir.iterdir()):
|
|
142
|
+
if not entry.is_dir():
|
|
143
|
+
continue
|
|
144
|
+
if cutoff is not None and entry.stat().st_mtime >= cutoff:
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
shutil.rmtree(entry)
|
|
148
|
+
deleted.append(entry)
|
|
149
|
+
except OSError:
|
|
150
|
+
continue
|
|
151
|
+
return deleted
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""CLI commands package for Terminal Agent."""
|
|
2
|
+
|
|
3
|
+
from terminal_agent.cli.commands.run import run_command
|
|
4
|
+
from terminal_agent.cli.commands.resume import resume_command
|
|
5
|
+
from terminal_agent.cli.commands.status import status_command
|
|
6
|
+
from terminal_agent.cli.commands.diff import diff_command
|
|
7
|
+
from terminal_agent.cli.commands.test import test_command
|
|
8
|
+
from terminal_agent.cli.commands.checkpoint import checkpoint_app, rollback_command
|
|
9
|
+
from terminal_agent.cli.commands.doctor import doctor_command
|
|
10
|
+
from terminal_agent.cli.commands.trace import trace_command
|
|
11
|
+
from terminal_agent.cli.commands.config_cmd import config_command
|
|
12
|
+
from terminal_agent.cli.commands.clean import clean_command
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"run_command",
|
|
16
|
+
"resume_command",
|
|
17
|
+
"status_command",
|
|
18
|
+
"diff_command",
|
|
19
|
+
"test_command",
|
|
20
|
+
"checkpoint_app",
|
|
21
|
+
"rollback_command",
|
|
22
|
+
"doctor_command",
|
|
23
|
+
"trace_command",
|
|
24
|
+
"config_command",
|
|
25
|
+
"clean_command",
|
|
26
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""CLI checkpoint and rollback commands."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from terminal_agent.checkpoints.manager import CheckpointManager
|
|
9
|
+
from terminal_agent.cli.theme import SYM_CHECK, SYM_CROSS, console
|
|
10
|
+
from terminal_agent.cli.ui import render_header
|
|
11
|
+
|
|
12
|
+
checkpoint_app = typer.Typer(help="Manage repository checkpoints.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@checkpoint_app.command("list")
|
|
16
|
+
def list_checkpoints_cmd(
|
|
17
|
+
working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""List all available checkpoints for this repository."""
|
|
20
|
+
render_header()
|
|
21
|
+
target_dir = (working_dir or Path.cwd()).resolve()
|
|
22
|
+
mgr = CheckpointManager(target_dir)
|
|
23
|
+
checkpoints = mgr.list_checkpoints()
|
|
24
|
+
|
|
25
|
+
if not checkpoints:
|
|
26
|
+
console.print("[agent.muted]No checkpoints found for this repository.[/agent.muted]")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
table = Table(title="[agent.accent]CHECKPOINTS[/agent.accent]", border_style="agent.border")
|
|
30
|
+
table.add_column("Checkpoint ID", style="agent.accent")
|
|
31
|
+
table.add_column("Name", style="agent.text")
|
|
32
|
+
table.add_column("Created At", style="agent.muted")
|
|
33
|
+
table.add_column("Files", style="agent.muted")
|
|
34
|
+
table.add_column("Git Commit", style="agent.muted")
|
|
35
|
+
|
|
36
|
+
for chk in checkpoints:
|
|
37
|
+
table.add_row(
|
|
38
|
+
chk.checkpoint_id,
|
|
39
|
+
chk.name,
|
|
40
|
+
chk.created_at[:19].replace("T", " "),
|
|
41
|
+
str(len(chk.modified_files)),
|
|
42
|
+
chk.git_commit[:8] if chk.git_commit else "N/A"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
console.print(table)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@checkpoint_app.command("create")
|
|
49
|
+
def create_checkpoint_cmd(
|
|
50
|
+
name: str = typer.Argument("manual_checkpoint", help="Descriptive checkpoint name"),
|
|
51
|
+
working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Create a new manual checkpoint snapshot."""
|
|
54
|
+
render_header()
|
|
55
|
+
target_dir = (working_dir or Path.cwd()).resolve()
|
|
56
|
+
mgr = CheckpointManager(target_dir)
|
|
57
|
+
snapshot = mgr.create_checkpoint(name=name)
|
|
58
|
+
console.print(f"{SYM_CHECK} [agent.success]Created checkpoint '{snapshot.checkpoint_id}' ('{snapshot.name}') with {len(snapshot.modified_files)} files.[/agent.success]")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def rollback_command(
|
|
62
|
+
checkpoint_id: str = typer.Argument(..., help="Checkpoint ID or name to restore"),
|
|
63
|
+
working_dir: Optional[Path] = typer.Option(None, "--cwd", "-C", help="Target repository directory"),
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Roll back workspace files to a previous checkpoint."""
|
|
66
|
+
render_header()
|
|
67
|
+
target_dir = (working_dir or Path.cwd()).resolve()
|
|
68
|
+
mgr = CheckpointManager(target_dir)
|
|
69
|
+
success = mgr.rollback(checkpoint_id)
|
|
70
|
+
|
|
71
|
+
if success:
|
|
72
|
+
console.print(f"{SYM_CHECK} [agent.success]Successfully rolled back to checkpoint '{checkpoint_id}'.[/agent.success]")
|
|
73
|
+
else:
|
|
74
|
+
console.print(f"{SYM_CROSS} [agent.error]Failed to rollback to checkpoint '{checkpoint_id}'. Checkpoint not found.[/agent.error]")
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|