co-agent-coder 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.
Files changed (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,431 @@
1
+ """The artifact store: size policy, hashing, and the manifest (DESIGN §3.5).
2
+
3
+ What an artifact is
4
+ -------------------
5
+ Anything a task *produced* rather than wrote by hand: a plot from a cell, a CSV
6
+ from a scrape, a notebook's rendered output. The design gives them one manifest
7
+ and two storage classes:
8
+
9
+ =========================== ========================================= =========
10
+ Size Where In repo
11
+ =========================== ========================================= =========
12
+ < ``commit_under_mb`` ``artifacts/<run-id>/<name>`` committed
13
+ ``commit_under_mb`` – 2 GB Drive ``coagent/artifacts/<sha256>`` pointer
14
+ > 2 GB / datasets Drive or original URL pointer
15
+ =========================== ========================================= =========
16
+
17
+ The rule the design states and this module enforces is: **never commit more than
18
+ the cap.** A 20 MB PNG in a git repo is the kind of mistake that cannot be undone
19
+ without a rewrite; AT-7 exists to prove it does not happen.
20
+
21
+ Why the manifest is the source of truth
22
+ ---------------------------------------
23
+ The committed bytes and the Drive blob are the same artifact in two places, and
24
+ a resumed session on a fresh VM has to be able to tell which is which. So the
25
+ manifest records ``{path, sha256, bytes, store, created_by_step}`` for every
26
+ entry: the hash is what survives a move to Drive, so a pointer with no bytes in
27
+ the repo is still verifiable.
28
+
29
+ Why Drive is optional
30
+ ---------------------
31
+ FR-BOOT-4: mounting Drive is an explicit user action, and declining must degrade
32
+ gracefully. If Drive is not mounted, an over-cap artifact is *not* silently
33
+ dropped or forced into git — it stays where it is and the manifest records a
34
+ pointer with nowhere to point yet, so the user can collect it. That is a
35
+ degraded capability, and the report says so.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import hashlib
41
+ import json
42
+ import re
43
+ from dataclasses import asdict, dataclass, field
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+ from typing import Any
47
+
48
+ __all__ = [
49
+ "ARTIFACT_ROOT",
50
+ "ArtifactError",
51
+ "ArtifactRecord",
52
+ "ArtifactStore",
53
+ "Manifest",
54
+ "MANIFEST_NAME",
55
+ "MAX_DRIVE_BYTES",
56
+ ]
57
+
58
+ #: `artifacts/<run-id>/` at the repo root, per §3.5.
59
+ ARTIFACT_ROOT = "artifacts"
60
+ MANIFEST_NAME = "manifest.json"
61
+
62
+ #: 2 GB: past this the design says "Drive or original URL", never a copy we own.
63
+ MAX_DRIVE_BYTES = 2 * 1024 * 1024 * 1024
64
+
65
+ #: A filesystem- and git-safe name. Artifacts come from cell outputs and model
66
+ #: arguments, so they are untrusted: no `..`, no separators, no absolute paths.
67
+ _SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
68
+ _IMAGE_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"}
69
+ _TEXT_EXT = {"text/html": ".html", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json"}
70
+
71
+
72
+ class ArtifactError(RuntimeError):
73
+ """An artifact could not be stored. The message is the user's next step."""
74
+
75
+
76
+ @dataclass
77
+ class ArtifactRecord:
78
+ """One entry in the manifest (§3.5 shape, plus what the UI needs)."""
79
+
80
+ path: str
81
+ sha256: str
82
+ bytes: int
83
+ store: str # "repo" | "drive" | "external"
84
+ created_by_step: int = 0
85
+ #: Where the bytes actually are. For repo artifacts this is the repo-relative
86
+ #: path; for Drive it is the absolute path or the sha-keyed pointer.
87
+ location: str = ""
88
+ mime: str = ""
89
+ name: str = ""
90
+ #: Set when a large artifact could not reach Drive (no mount). The bytes stay
91
+ #: in place and this explains where.
92
+ note: str = ""
93
+
94
+ @property
95
+ def in_repo(self) -> bool:
96
+ return self.store == "repo"
97
+
98
+ @property
99
+ def human_size(self) -> str:
100
+ size = float(self.bytes)
101
+ for unit in ("B", "KB", "MB", "GB"):
102
+ if size < 1024 or unit == "GB":
103
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
104
+ size /= 1024.0
105
+ return f"{size:.1f} GB"
106
+
107
+ def render(self) -> str:
108
+ where = "committed" if self.in_repo else f"not committed ({self.store})"
109
+ line = f"{self.path} {self.human_size} {self.sha256[:12]} {where}"
110
+ if self.note:
111
+ line += f"\n {self.note}"
112
+ return line
113
+
114
+
115
+ @dataclass
116
+ class Manifest:
117
+ """`artifacts/<run-id>/manifest.json` — every artifact, sorted for stable diffs."""
118
+
119
+ run_id: str
120
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds"))
121
+ artifacts: list[ArtifactRecord] = field(default_factory=list)
122
+
123
+ def add(self, record: ArtifactRecord) -> ArtifactRecord:
124
+ """Insert, replacing any earlier record for the same path.
125
+
126
+ Re-running a cell overwrites the same plot file; keeping both records
127
+ would make the manifest describe a file that no longer exists.
128
+ """
129
+ self.artifacts = [a for a in self.artifacts if a.path != record.path]
130
+ self.artifacts.append(record)
131
+ self.artifacts.sort(key=lambda a: a.path)
132
+ return record
133
+
134
+ def to_dict(self) -> dict[str, Any]:
135
+ return {
136
+ "run_id": self.run_id,
137
+ "created_at": self.created_at,
138
+ "artifacts": [asdict(a) for a in self.artifacts],
139
+ }
140
+
141
+ @classmethod
142
+ def from_dict(cls, data: dict[str, Any]) -> Manifest:
143
+ records = [ArtifactRecord(**item) for item in data.get("artifacts", [])]
144
+ return cls(
145
+ run_id=str(data.get("run_id", "")),
146
+ created_at=str(data.get("created_at", "")),
147
+ artifacts=records,
148
+ )
149
+
150
+ def render(self) -> str:
151
+ if not self.artifacts:
152
+ return f"{self.run_id}: no artifacts"
153
+ lines = [f"{self.run_id}: {len(self.artifacts)} artifact(s)"]
154
+ lines.extend(f" {a.render()}" for a in self.artifacts)
155
+ return "\n".join(lines)
156
+
157
+
158
+ class ArtifactStore:
159
+ """Applies the size policy and maintains the manifest.
160
+
161
+ The store owns no UI and no model: it is the one place that decides *where* a
162
+ produced file lives. Tools hand it bytes, the loop asks it for the committed
163
+ paths that go into the `Coagent-Artifacts` trailer.
164
+ """
165
+
166
+ def __init__(
167
+ self,
168
+ repo_root: Path,
169
+ run_id: str,
170
+ *,
171
+ commit_under_mb: int = 5,
172
+ drive_dir: str = "coagent/artifacts",
173
+ drive_root: Path | None = None,
174
+ step: int = 0,
175
+ ) -> None:
176
+ self.repo_root = Path(repo_root).resolve()
177
+ self.run_id = _safe_segment(run_id) or "run"
178
+ self.commit_limit = max(0, int(commit_under_mb)) * 1024 * 1024
179
+ self.drive_dir = drive_dir
180
+ self.drive_root = Path(drive_root).resolve() if drive_root else _detect_drive()
181
+ self.step = step
182
+ self.manifest = Manifest(run_id=self.run_id)
183
+
184
+ # ----------------------------------------------------------------- paths
185
+
186
+ @property
187
+ def dir(self) -> Path:
188
+ return self.repo_root / ARTIFACT_ROOT / self.run_id
189
+
190
+ @property
191
+ def manifest_path(self) -> Path:
192
+ return self.dir / MANIFEST_NAME
193
+
194
+ @property
195
+ def run_dir_rel(self) -> str:
196
+ return f"{ARTIFACT_ROOT}/{self.run_id}"
197
+
198
+ def rel(self, path: Path) -> str:
199
+ try:
200
+ return path.resolve().relative_to(self.repo_root).as_posix()
201
+ except ValueError:
202
+ return path.as_posix()
203
+
204
+ def _drive_target(self, sha: str, name: str) -> Path | None:
205
+ if self.drive_root is None:
206
+ return None
207
+ return self.drive_root / self.drive_dir / sha[:2] / f"{sha}{Path(name).suffix}"
208
+
209
+ # ------------------------------------------------------------------ save
210
+
211
+ def save_bytes(
212
+ self,
213
+ data: bytes,
214
+ name: str,
215
+ *,
216
+ mime: str = "",
217
+ step: int | None = None,
218
+ subdir: str = "",
219
+ ) -> ArtifactRecord:
220
+ """Store `data` under the §3.5 policy and record it in the manifest."""
221
+ sha = hashlib.sha256(data).hexdigest()
222
+ safe = _safe_name(name, mime)
223
+ use_step = self.step if step is None else step
224
+ size = len(data)
225
+
226
+ if size > MAX_DRIVE_BYTES:
227
+ # Never a copy we own at this size; the caller keeps the original.
228
+ record = ArtifactRecord(
229
+ path=f"{self.run_dir_rel}/{safe}",
230
+ sha256=sha,
231
+ bytes=size,
232
+ store="external",
233
+ created_by_step=use_step,
234
+ mime=mime,
235
+ name=safe,
236
+ note=(
237
+ f"{size / 1024**3:.1f} GB is over the {MAX_DRIVE_BYTES // 1024**3} GB copy "
238
+ "limit; keep the original file or URL and record it here by hand."
239
+ ),
240
+ )
241
+ return self.manifest.add(record)
242
+
243
+ if size <= self.commit_limit:
244
+ record = self._save_to_repo(data, safe, sha, use_step, mime, subdir)
245
+ else:
246
+ record = self._save_to_drive(data, safe, sha, use_step, mime, subdir)
247
+
248
+ self._write_manifest()
249
+ return record
250
+
251
+ def save_file(self, source: Path, *, name: str = "", mime: str = "", subdir: str = "") -> ArtifactRecord:
252
+ """Store an existing file by copying its bytes through the same policy."""
253
+ source = Path(source)
254
+ try:
255
+ data = source.read_bytes()
256
+ except OSError as exc:
257
+ raise ArtifactError(f"cannot read {source}: {exc}") from exc
258
+ return self.save_bytes(data, name or source.name, mime=mime, subdir=subdir)
259
+
260
+ def save_text(self, text: str, name: str, *, mime: str = "text/plain", subdir: str = "") -> ArtifactRecord:
261
+ return self.save_bytes(text.encode("utf-8"), name, mime=mime, subdir=subdir)
262
+
263
+ def _save_to_repo(
264
+ self,
265
+ data: bytes,
266
+ safe: str,
267
+ sha: str,
268
+ step: int,
269
+ mime: str,
270
+ subdir: str,
271
+ ) -> ArtifactRecord:
272
+ target = self.dir / (_safe_segment(subdir) + "/" if subdir else "") / safe
273
+ try:
274
+ target.parent.mkdir(parents=True, exist_ok=True)
275
+ target.write_bytes(data)
276
+ except OSError as exc:
277
+ raise ArtifactError(f"cannot write artifact {self.rel(target)}: {exc}") from exc
278
+ return self.manifest.add(
279
+ ArtifactRecord(
280
+ path=self.rel(target),
281
+ sha256=sha,
282
+ bytes=len(data),
283
+ store="repo",
284
+ created_by_step=step,
285
+ location=self.rel(target),
286
+ mime=mime,
287
+ name=safe,
288
+ )
289
+ )
290
+
291
+ def _save_to_drive(
292
+ self,
293
+ data: bytes,
294
+ safe: str,
295
+ sha: str,
296
+ step: int,
297
+ mime: str,
298
+ subdir: str,
299
+ ) -> ArtifactRecord:
300
+ """Over the commit cap: Drive if mounted, otherwise an honest pointer.
301
+
302
+ The in-repo `path` is where the manifest says it *would* live, so a report
303
+ and the `Coagent-Artifacts` trailer name a stable identity either way.
304
+ """
305
+ path = f"{self.run_dir_rel}/{_safe_segment(subdir) + '/' if subdir else ''}{safe}"
306
+ target = self._drive_target(sha, safe)
307
+ if target is None:
308
+ return self.manifest.add(
309
+ ArtifactRecord(
310
+ path=path,
311
+ sha256=sha,
312
+ bytes=len(data),
313
+ store="drive",
314
+ created_by_step=step,
315
+ location="",
316
+ mime=mime,
317
+ name=safe,
318
+ note=(
319
+ f"{len(data) / 1024**2:.1f} MB is over the "
320
+ f"{self.commit_limit // 1024**2} MB commit cap and Drive is not mounted, "
321
+ "so it was not committed. Mount Drive and save it again, or raise "
322
+ "[artifacts] commit_under_mb deliberately."
323
+ ),
324
+ )
325
+ )
326
+ try:
327
+ target.parent.mkdir(parents=True, exist_ok=True)
328
+ target.write_bytes(data)
329
+ except OSError as exc:
330
+ raise ArtifactError(f"cannot write to Drive at {target}: {exc}") from exc
331
+ return self.manifest.add(
332
+ ArtifactRecord(
333
+ path=path,
334
+ sha256=sha,
335
+ bytes=len(data),
336
+ store="drive",
337
+ created_by_step=step,
338
+ location=str(target),
339
+ mime=mime,
340
+ name=safe,
341
+ )
342
+ )
343
+
344
+ # -------------------------------------------------------------- manifest
345
+
346
+ def _write_manifest(self) -> Path:
347
+ try:
348
+ self.dir.mkdir(parents=True, exist_ok=True)
349
+ self.manifest_path.write_text(
350
+ json.dumps(self.manifest.to_dict(), indent=2, sort_keys=True) + "\n",
351
+ encoding="utf-8",
352
+ )
353
+ except OSError as exc:
354
+ raise ArtifactError(f"cannot write the artifact manifest: {exc}") from exc
355
+ return self.manifest_path
356
+
357
+ def flush(self) -> Path:
358
+ """Write the manifest even when no artifact was added this step.
359
+
360
+ The manifest is committed alongside the run, so it has to exist as soon as
361
+ the run directory does — even if every artifact went to Drive.
362
+
363
+ An **empty** manifest is deliberately not written. Nothing references it,
364
+ nothing commits it, and an untracked file left in the tree makes the next
365
+ session refuse to start with `DirtyTreeError` — a stale empty manifest is
366
+ worse than none, because it breaks the resume path (FR-GIT-5).
367
+ """
368
+ if not self.manifest.artifacts and not self.manifest_path.is_file():
369
+ return self.manifest_path
370
+ return self._write_manifest()
371
+
372
+ def committed_paths(self) -> list[str]:
373
+ """Repo-relative paths for the `Coagent-Artifacts` trailer.
374
+
375
+ Only committed bytes and the manifest are named: a Drive pointer is not a
376
+ path in this commit, and claiming it is would make the trailer lie.
377
+ """
378
+ paths = [a.path for a in self.manifest.artifacts if a.in_repo]
379
+ if paths:
380
+ paths.append(f"{self.run_dir_rel}/{MANIFEST_NAME}")
381
+ return sorted(set(paths))
382
+
383
+ def load(self) -> Manifest:
384
+ """Re-read the manifest from disk, for a resumed session (FR-GIT-5)."""
385
+ if not self.manifest_path.is_file():
386
+ return self.manifest
387
+ try:
388
+ data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
389
+ except (OSError, json.JSONDecodeError) as exc:
390
+ raise ArtifactError(f"cannot read {self.rel(self.manifest_path)}: {exc}") from exc
391
+ self.manifest = Manifest.from_dict(data)
392
+ return self.manifest
393
+
394
+
395
+ # --------------------------------------------------------------------- helpers
396
+
397
+
398
+ def _detect_drive() -> Path | None:
399
+ """The Colab Drive mount, if it is actually there.
400
+
401
+ `drive.mount()` is the user's explicit action (FR-BOOT-4); this only *notices*
402
+ it. On a laptop there is no `/content/drive`, so large artifacts degrade to a
403
+ pointer rather than being forced into git.
404
+ """
405
+ candidate = Path("/content/drive/MyDrive")
406
+ return candidate if candidate.is_dir() else None
407
+
408
+
409
+ def _safe_segment(text: str, *, limit: int = 40) -> str:
410
+ """A single path segment with no traversal and no separators."""
411
+ cleaned = _SAFE_NAME.sub("-", str(text)).strip("-.")
412
+ return cleaned[:limit].strip("-.") or ""
413
+
414
+
415
+ def _safe_name(name: str, mime: str) -> str:
416
+ """A filename that cannot escape the artifact directory.
417
+
418
+ Cell outputs and model arguments are untrusted input, so `../../etc/passwd`
419
+ must not survive this function. An extension is inferred from the MIME type
420
+ when the name has none (a PNG from a cell is usually called `output`).
421
+ """
422
+ base = _safe_segment(Path(str(name)).name, limit=80)
423
+ if not base:
424
+ base = "artifact"
425
+ if not Path(base).suffix:
426
+ base += _IMAGE_EXT.get(mime) or _TEXT_EXT.get(mime) or ".bin"
427
+ return base
428
+
429
+
430
+ def _utcnow() -> str:
431
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")