cli-agent-yzf 0.6.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.
- cli_agent/__init__.py +3 -0
- cli_agent/__main__.py +8 -0
- cli_agent/agent.py +388 -0
- cli_agent/checkpoint.py +345 -0
- cli_agent/cli.py +755 -0
- cli_agent/config.py +173 -0
- cli_agent/log.py +39 -0
- cli_agent/mcp.py +306 -0
- cli_agent/messages.py +127 -0
- cli_agent/permissions.py +294 -0
- cli_agent/prompt.py +79 -0
- cli_agent/providers/__init__.py +0 -0
- cli_agent/providers/anthropic.py +283 -0
- cli_agent/providers/base.py +116 -0
- cli_agent/providers/fake.py +81 -0
- cli_agent/providers/openai.py +301 -0
- cli_agent/py.typed +0 -0
- cli_agent/sandbox.py +117 -0
- cli_agent/session.py +274 -0
- cli_agent/tools/__init__.py +13 -0
- cli_agent/tools/base.py +118 -0
- cli_agent/tools/files.py +522 -0
- cli_agent/tools/shell.py +224 -0
- cli_agent/ui.py +283 -0
- cli_agent/workspace.py +59 -0
- cli_agent_yzf-0.6.0.dist-info/METADATA +161 -0
- cli_agent_yzf-0.6.0.dist-info/RECORD +29 -0
- cli_agent_yzf-0.6.0.dist-info/WHEEL +4 -0
- cli_agent_yzf-0.6.0.dist-info/entry_points.txt +3 -0
cli_agent/checkpoint.py
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""File checkpoints so the user can undo what the agent's file tools changed.
|
|
2
|
+
|
|
3
|
+
Each user prompt starts a checkpoint. The first time a file tool writes a path
|
|
4
|
+
during that prompt, the original content (or "did not exist") is recorded,
|
|
5
|
+
along with a hash of what the agent last wrote. ``undo`` restores the
|
|
6
|
+
originals of the most recent checkpoint, but skips any file that changed since
|
|
7
|
+
the agent wrote it, so it never discards the user's own later edits.
|
|
8
|
+
|
|
9
|
+
Only write_file/edit_file are tracked; changes made by shell commands are not,
|
|
10
|
+
but the number of shell commands is, so undo can say they ran.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import contextlib
|
|
17
|
+
import fcntl
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import secrets
|
|
22
|
+
import time
|
|
23
|
+
from collections.abc import Callable, Iterator
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
MAX_CHECKPOINTS = 20
|
|
29
|
+
LOCK_TIMEOUT = 10.0
|
|
30
|
+
"""Seconds to wait for another cli-agent process to release the checkpoint lock."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CheckpointBusy(OSError):
|
|
34
|
+
"""The checkpoint lock stayed held by another process past LOCK_TIMEOUT."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
FORMAT_VERSION = 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _digest(data: bytes) -> str:
|
|
41
|
+
return hashlib.sha256(data).hexdigest()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def shell_count(cp: dict[str, Any]) -> int:
|
|
45
|
+
"""The checkpoint's shell-command count; 0 if missing or not a sane integer."""
|
|
46
|
+
v = cp.get("shell", 0)
|
|
47
|
+
return v if type(v) is int and 0 <= v < 1_000_000_000 else 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _valid(stack: object) -> bool:
|
|
51
|
+
if not isinstance(stack, list):
|
|
52
|
+
return False
|
|
53
|
+
for cp in stack:
|
|
54
|
+
if not (isinstance(cp, dict) and isinstance(cp.get("label"), str)):
|
|
55
|
+
return False
|
|
56
|
+
files = cp.get("files")
|
|
57
|
+
if not isinstance(files, dict):
|
|
58
|
+
return False
|
|
59
|
+
for entry in files.values():
|
|
60
|
+
if not (
|
|
61
|
+
isinstance(entry, dict)
|
|
62
|
+
and isinstance(entry.get("written"), str)
|
|
63
|
+
and (entry.get("original") is None or isinstance(entry.get("original"), str))
|
|
64
|
+
):
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class UndoReport:
|
|
71
|
+
restored: list[str] = field(default_factory=list)
|
|
72
|
+
deleted: list[str] = field(default_factory=list)
|
|
73
|
+
skipped: list[str] = field(default_factory=list)
|
|
74
|
+
"""Files changed after the agent wrote them; left alone."""
|
|
75
|
+
label: str = ""
|
|
76
|
+
shell_commands: int = 0
|
|
77
|
+
"""Shell commands run during the prompt; whatever they changed stays."""
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def files_changed(self) -> bool:
|
|
81
|
+
return bool(self.restored or self.deleted or self.skipped)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Checkpoints:
|
|
85
|
+
"""The file is the source of truth. Every change runs under an exclusive
|
|
86
|
+
lock as load, modify, save, so several cli-agent processes in one
|
|
87
|
+
workspace don't lose each other's checkpoints."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, path: Path, root: Path):
|
|
90
|
+
self.path = path
|
|
91
|
+
self.root = root.resolve()
|
|
92
|
+
self._current: str | None = None
|
|
93
|
+
"""Id of the checkpoint this process is recording into."""
|
|
94
|
+
self._shell = 0
|
|
95
|
+
"""Shell commands in the current prompt; saved on the first and by `finish`."""
|
|
96
|
+
self._shell_saved = 0.0
|
|
97
|
+
|
|
98
|
+
def begin(self, label: str) -> None:
|
|
99
|
+
cid = secrets.token_hex(8)
|
|
100
|
+
# Until the new checkpoint is saved, record nothing into the previous one.
|
|
101
|
+
self._current = None
|
|
102
|
+
self._shell = 0
|
|
103
|
+
|
|
104
|
+
def add(stack: list[dict[str, Any]]) -> None:
|
|
105
|
+
# Prompts that changed nothing must not push real checkpoints out of the
|
|
106
|
+
# cap. Another live process's empty checkpoint may still get files, so
|
|
107
|
+
# only ours and those of exited processes are dropped.
|
|
108
|
+
stack[:] = [cp for cp in stack if cp["files"] or shell_count(cp) or _owner_alive(cp)]
|
|
109
|
+
stack.append(
|
|
110
|
+
{
|
|
111
|
+
"id": cid,
|
|
112
|
+
"owner": os.getpid(),
|
|
113
|
+
"label": " ".join(label.split())[:80],
|
|
114
|
+
"files": {},
|
|
115
|
+
}
|
|
116
|
+
)
|
|
117
|
+
_trim(stack)
|
|
118
|
+
|
|
119
|
+
self._update(add)
|
|
120
|
+
self._current = cid
|
|
121
|
+
|
|
122
|
+
def record(self, path: Path, before: bytes | None, after: str) -> None:
|
|
123
|
+
"""Record a write. `before` is the file's exact bytes before this write
|
|
124
|
+
(None if it did not exist); `after` is the text the agent wrote."""
|
|
125
|
+
if self._current is None:
|
|
126
|
+
self.begin("(unlabelled)")
|
|
127
|
+
rel = str(path.relative_to(self.root))
|
|
128
|
+
written = _digest(_encode(after))
|
|
129
|
+
|
|
130
|
+
def add(stack: list[dict[str, Any]]) -> None:
|
|
131
|
+
cp = next((c for c in stack if c.get("id") == self._current), None)
|
|
132
|
+
if cp is None: # evicted by other processes meanwhile; start again
|
|
133
|
+
cp = {"id": self._current, "label": "(continued)", "files": {}}
|
|
134
|
+
stack.append(cp)
|
|
135
|
+
_trim(stack)
|
|
136
|
+
entry = cp["files"].get(rel)
|
|
137
|
+
if entry:
|
|
138
|
+
original = entry["original"]
|
|
139
|
+
else:
|
|
140
|
+
original = None if before is None else base64.b64encode(before).decode("ascii")
|
|
141
|
+
cp["files"][rel] = {"original": original, "written": written}
|
|
142
|
+
|
|
143
|
+
self._update(add)
|
|
144
|
+
|
|
145
|
+
def note_shell(self) -> None:
|
|
146
|
+
"""Count a shell command against the current prompt. Only the first one
|
|
147
|
+
is written at once (rewriting the file per command is slow when it holds
|
|
148
|
+
large originals); `finish` writes the final count."""
|
|
149
|
+
if self._current is None:
|
|
150
|
+
return
|
|
151
|
+
self._shell += 1
|
|
152
|
+
# Also save now and then, so a killed process (SIGHUP) mostly keeps its count.
|
|
153
|
+
if self._shell == 1 or time.monotonic() - self._shell_saved >= 1.0:
|
|
154
|
+
self._save_shell()
|
|
155
|
+
|
|
156
|
+
def finish(self) -> None:
|
|
157
|
+
"""End of the prompt's run: persist the shell-command count."""
|
|
158
|
+
if self._current is not None and self._shell > 1: # may already be saved; harmless
|
|
159
|
+
self._save_shell()
|
|
160
|
+
|
|
161
|
+
def _save_shell(self) -> None:
|
|
162
|
+
count = self._shell
|
|
163
|
+
self._shell_saved = time.monotonic()
|
|
164
|
+
|
|
165
|
+
def add(stack: list[dict[str, Any]]) -> None:
|
|
166
|
+
cp = next((c for c in stack if c.get("id") == self._current), None)
|
|
167
|
+
if cp is not None:
|
|
168
|
+
cp["shell"] = count
|
|
169
|
+
|
|
170
|
+
self._update(add)
|
|
171
|
+
|
|
172
|
+
def undo(self) -> UndoReport | None:
|
|
173
|
+
"""Revert the most recent checkpoint that changed files or ran shell
|
|
174
|
+
commands; None if none."""
|
|
175
|
+
found: list[dict[str, Any]] = []
|
|
176
|
+
|
|
177
|
+
def take(stack: list[dict[str, Any]]) -> None:
|
|
178
|
+
while stack and not stack[-1]["files"] and not shell_count(stack[-1]):
|
|
179
|
+
stack.pop()
|
|
180
|
+
if stack:
|
|
181
|
+
found.append(stack.pop())
|
|
182
|
+
|
|
183
|
+
with self._locked():
|
|
184
|
+
stack = self._load()
|
|
185
|
+
take(stack)
|
|
186
|
+
if not found:
|
|
187
|
+
self._save(stack)
|
|
188
|
+
return None
|
|
189
|
+
cp = found[0]
|
|
190
|
+
report = UndoReport(label=cp["label"], shell_commands=shell_count(cp))
|
|
191
|
+
try:
|
|
192
|
+
self._revert(cp, report)
|
|
193
|
+
except OSError as err:
|
|
194
|
+
# Keep what was not reverted, so a retry can finish the job.
|
|
195
|
+
done = set(report.restored) | set(report.deleted)
|
|
196
|
+
cp["files"] = {k: v for k, v in cp["files"].items() if k not in done}
|
|
197
|
+
stack.append(cp)
|
|
198
|
+
self._save(stack)
|
|
199
|
+
reverted = ", ".join(sorted(done)) or "nothing"
|
|
200
|
+
raise OSError(
|
|
201
|
+
f"{err}; reverted before the failure: {reverted}. "
|
|
202
|
+
"Fix the cause and run undo again for the rest"
|
|
203
|
+
) from err
|
|
204
|
+
self._save(stack)
|
|
205
|
+
return report
|
|
206
|
+
|
|
207
|
+
def _revert(self, cp: dict[str, Any], report: UndoReport) -> None:
|
|
208
|
+
for rel, entry in sorted(cp["files"].items()):
|
|
209
|
+
target = self.root / rel
|
|
210
|
+
# Never follow a symlink (the user may have swapped one in since) and
|
|
211
|
+
# never leave the workspace (a tampered checkpoint file).
|
|
212
|
+
if _has_symlink(self.root, target) or not _inside(self.root, target):
|
|
213
|
+
report.skipped.append(rel)
|
|
214
|
+
continue
|
|
215
|
+
current = _read(target)
|
|
216
|
+
if current is None or _digest(current) != entry["written"]:
|
|
217
|
+
report.skipped.append(rel)
|
|
218
|
+
continue
|
|
219
|
+
if entry["original"] is None:
|
|
220
|
+
target.unlink()
|
|
221
|
+
report.deleted.append(rel)
|
|
222
|
+
else:
|
|
223
|
+
_write(target, base64.b64decode(entry["original"]))
|
|
224
|
+
report.restored.append(rel)
|
|
225
|
+
|
|
226
|
+
def _update(self, change: Callable[[list[dict[str, Any]]], None]) -> None:
|
|
227
|
+
with self._locked():
|
|
228
|
+
stack = self._load()
|
|
229
|
+
change(stack)
|
|
230
|
+
self._save(stack)
|
|
231
|
+
|
|
232
|
+
@contextlib.contextmanager
|
|
233
|
+
def _locked(self) -> Iterator[None]:
|
|
234
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
235
|
+
lock = self.path.with_suffix(".lock")
|
|
236
|
+
fd = os.open(lock, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
237
|
+
try:
|
|
238
|
+
# Poll rather than block, so a stuck holder (e.g. a command the model
|
|
239
|
+
# started that inherited the lock) can't hang every file tool forever.
|
|
240
|
+
deadline = time.monotonic() + LOCK_TIMEOUT
|
|
241
|
+
while True:
|
|
242
|
+
try:
|
|
243
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
244
|
+
break
|
|
245
|
+
except BlockingIOError:
|
|
246
|
+
if time.monotonic() >= deadline:
|
|
247
|
+
raise CheckpointBusy(
|
|
248
|
+
f"undo checkpoints are locked by another process ({lock}); "
|
|
249
|
+
"close other cli-agent sessions in this workspace"
|
|
250
|
+
) from None
|
|
251
|
+
time.sleep(0.05)
|
|
252
|
+
yield
|
|
253
|
+
finally:
|
|
254
|
+
os.close(fd) # closing releases the lock
|
|
255
|
+
|
|
256
|
+
def _load(self) -> list[dict[str, Any]]:
|
|
257
|
+
# A corrupt or older-format file is discarded rather than trusted.
|
|
258
|
+
try:
|
|
259
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
260
|
+
except (OSError, ValueError):
|
|
261
|
+
return []
|
|
262
|
+
if not isinstance(data, dict) or data.get("version") != FORMAT_VERSION:
|
|
263
|
+
return []
|
|
264
|
+
stack = data.get("checkpoints")
|
|
265
|
+
if not isinstance(stack, list):
|
|
266
|
+
return []
|
|
267
|
+
return stack if _valid(stack) else []
|
|
268
|
+
|
|
269
|
+
def _save(self, stack: list[dict[str, Any]]) -> None:
|
|
270
|
+
tmp = self.path.with_suffix(f".{os.getpid()}.tmp")
|
|
271
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
|
|
272
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
273
|
+
os.fchmod(fh.fileno(), 0o600)
|
|
274
|
+
json.dump({"version": FORMAT_VERSION, "checkpoints": stack}, fh)
|
|
275
|
+
os.replace(tmp, self.path)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _trim(stack: list[dict[str, Any]]) -> None:
|
|
279
|
+
"""Enforce MAX_CHECKPOINTS on checkpoints that hold something. Empty ones
|
|
280
|
+
(the prompt just begun, or another live process's) don't count, so they
|
|
281
|
+
can't push real file changes out of reach of undo. Prompts that only ran
|
|
282
|
+
shell commands hold no data and go first, oldest first."""
|
|
283
|
+
|
|
284
|
+
def held(cp: dict[str, Any]) -> bool:
|
|
285
|
+
return bool(cp["files"]) or shell_count(cp) > 0
|
|
286
|
+
|
|
287
|
+
while sum(held(cp) for cp in stack) > MAX_CHECKPOINTS:
|
|
288
|
+
shell_only = [i for i, cp in enumerate(stack) if not cp["files"] and held(cp)]
|
|
289
|
+
if shell_only:
|
|
290
|
+
del stack[shell_only[0]]
|
|
291
|
+
else:
|
|
292
|
+
del stack[next(i for i, cp in enumerate(stack) if held(cp))]
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _owner_alive(cp: dict[str, Any]) -> bool:
|
|
296
|
+
"""True if the checkpoint belongs to another process that is still running."""
|
|
297
|
+
owner = cp.get("owner")
|
|
298
|
+
if not isinstance(owner, int) or owner == os.getpid():
|
|
299
|
+
return False
|
|
300
|
+
try:
|
|
301
|
+
os.kill(owner, 0)
|
|
302
|
+
except ProcessLookupError:
|
|
303
|
+
return False
|
|
304
|
+
except PermissionError:
|
|
305
|
+
return True
|
|
306
|
+
return True
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _inside(root: Path, target: Path) -> bool:
|
|
310
|
+
resolved = target.resolve()
|
|
311
|
+
return resolved != root and root in resolved.parents
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _has_symlink(root: Path, target: Path) -> bool:
|
|
315
|
+
try:
|
|
316
|
+
parts = target.relative_to(root).parts
|
|
317
|
+
except ValueError:
|
|
318
|
+
return True
|
|
319
|
+
p = root
|
|
320
|
+
for part in parts:
|
|
321
|
+
p = p / part
|
|
322
|
+
if p.is_symlink():
|
|
323
|
+
return True
|
|
324
|
+
return False
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _read(path: Path) -> bytes | None:
|
|
328
|
+
try:
|
|
329
|
+
if not path.is_file():
|
|
330
|
+
return None
|
|
331
|
+
return path.read_bytes()
|
|
332
|
+
except OSError:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _encode(text: str) -> bytes:
|
|
337
|
+
from cli_agent.tools.files import encode_text
|
|
338
|
+
|
|
339
|
+
return encode_text(text)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _write(path: Path, data: bytes) -> None:
|
|
343
|
+
from cli_agent.tools.files import _atomic_write_bytes
|
|
344
|
+
|
|
345
|
+
_atomic_write_bytes(path, data)
|