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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
coagent/gitx/log.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The session log: one JSONL record per turn (DESIGN §3.4, FR-TOL-8).
|
|
2
|
+
|
|
3
|
+
Written per turn (not per session) because a preemption must not lose the tail.
|
|
4
|
+
Every record is **redacted at write time** — redaction after the fact is a
|
|
5
|
+
promise, redaction before the write is a property (PRD FR-PRV-3).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..safety.redact import Redactor
|
|
17
|
+
|
|
18
|
+
__all__ = ["SessionLog", "LogRecord"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class LogRecord:
|
|
23
|
+
step: int
|
|
24
|
+
phase: str
|
|
25
|
+
tool: str
|
|
26
|
+
ok: bool
|
|
27
|
+
tokens_in: int = 0
|
|
28
|
+
tokens_out: int = 0
|
|
29
|
+
ms: int = 0
|
|
30
|
+
commit: str = ""
|
|
31
|
+
args_hash: str = ""
|
|
32
|
+
error: str = ""
|
|
33
|
+
note: str = ""
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
data: dict[str, Any] = {
|
|
37
|
+
"t": round(time.time(), 3),
|
|
38
|
+
"step": self.step,
|
|
39
|
+
"phase": self.phase,
|
|
40
|
+
"tool": self.tool,
|
|
41
|
+
"ok": self.ok,
|
|
42
|
+
}
|
|
43
|
+
if self.tokens_in or self.tokens_out:
|
|
44
|
+
data["tokens"] = {"in": self.tokens_in, "out": self.tokens_out}
|
|
45
|
+
if self.ms:
|
|
46
|
+
data["ms"] = self.ms
|
|
47
|
+
if self.commit:
|
|
48
|
+
data["commit"] = self.commit
|
|
49
|
+
if self.args_hash:
|
|
50
|
+
data["args_hash"] = self.args_hash
|
|
51
|
+
if self.error:
|
|
52
|
+
data["error"] = self.error
|
|
53
|
+
if self.note:
|
|
54
|
+
data["note"] = self.note
|
|
55
|
+
return data
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SessionLog:
|
|
59
|
+
"""Append-only JSONL log for one session."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, repo_root: str | Path, session_id: str, *, redactor: Redactor | None = None) -> None:
|
|
62
|
+
self.path = Path(repo_root) / ".coagent" / "sessions" / f"{session_id}.jsonl"
|
|
63
|
+
self.redactor = redactor or Redactor()
|
|
64
|
+
|
|
65
|
+
def append(self, record: LogRecord) -> None:
|
|
66
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
# Redact the serialised record: every string field, including nested ones.
|
|
68
|
+
safe = self.redactor.redact_obj(record.to_dict())
|
|
69
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
70
|
+
fh.write(json.dumps(safe, ensure_ascii=False) + "\n")
|
|
71
|
+
|
|
72
|
+
def tail(self, count: int = 20) -> list[dict[str, Any]]:
|
|
73
|
+
if not self.path.is_file():
|
|
74
|
+
return []
|
|
75
|
+
records: list[dict[str, Any]] = []
|
|
76
|
+
with self.path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
77
|
+
for line in fh:
|
|
78
|
+
line = line.strip()
|
|
79
|
+
if not line:
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
records.append(json.loads(line))
|
|
83
|
+
except json.JSONDecodeError:
|
|
84
|
+
continue
|
|
85
|
+
return records[-count:]
|
coagent/gitx/resume.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Resume: rebuild a briefing from durable state (PRD FR-GIT-5, FR-REL-4).
|
|
2
|
+
|
|
3
|
+
Deliberately **template extraction, not an LLM call** (DESIGN §3.4). A resume
|
|
4
|
+
must be cheap and must work before the model is loaded, because the most common
|
|
5
|
+
resume happens on a fresh VM after a preemption.
|
|
6
|
+
|
|
7
|
+
Sources, in order of authority:
|
|
8
|
+
|
|
9
|
+
1. the session branch and its `Coagent-*` trailers (always present);
|
|
10
|
+
2. `.coagent/plans/*.md` (the memory that survives eviction);
|
|
11
|
+
3. `.coagent/sessions/<id>.jsonl` (may be missing after a hard kill).
|
|
12
|
+
|
|
13
|
+
If the log is missing, the briefing falls back to trailers — which is why the
|
|
14
|
+
commit protocol matters more than the log.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .commit import parse_trailers
|
|
24
|
+
from .git import GitRepo
|
|
25
|
+
|
|
26
|
+
__all__ = ["Briefing", "build_briefing", "read_plan_items", "read_last_log_records"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class PlanItem:
|
|
31
|
+
text: str
|
|
32
|
+
done: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class Briefing:
|
|
37
|
+
session_id: str
|
|
38
|
+
branch: str
|
|
39
|
+
last_commit: str = ""
|
|
40
|
+
last_subject: str = ""
|
|
41
|
+
trailers: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
committed_steps: int = 0
|
|
43
|
+
max_step: int = 0
|
|
44
|
+
plan_path: Path | None = None
|
|
45
|
+
plan_items: list[PlanItem] = field(default_factory=list)
|
|
46
|
+
open_items: list[str] = field(default_factory=list)
|
|
47
|
+
recent: list[dict] = field(default_factory=list)
|
|
48
|
+
dirty_paths: list[str] = field(default_factory=list)
|
|
49
|
+
#: Committed artifacts from `artifacts/<run>/manifest.json` (§3.5). A resumed
|
|
50
|
+
#: VM has the files in git but no memory of them, so the briefing names them.
|
|
51
|
+
artifacts: list[str] = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def next_step(self) -> int:
|
|
55
|
+
"""One past the highest step ever committed, not just HEAD's.
|
|
56
|
+
|
|
57
|
+
A plan or checkpoint commit can carry a lower `Coagent-Step` than the
|
|
58
|
+
last code commit; taking the max keeps step numbers monotonic, which is
|
|
59
|
+
what `git log --grep="Coagent-Step"` ordering depends on.
|
|
60
|
+
"""
|
|
61
|
+
return self.max_step + 1
|
|
62
|
+
|
|
63
|
+
def render(self, *, max_chars: int = 1500) -> str:
|
|
64
|
+
"""A 15-ish line briefing as plain text."""
|
|
65
|
+
lines = [
|
|
66
|
+
f"coAgent resume · session {self.session_id} · branch {self.branch}",
|
|
67
|
+
f"last commit: {self.last_commit[:8]} {self.last_subject}".rstrip(),
|
|
68
|
+
f"committed steps: {self.committed_steps} · next step: {self.next_step}",
|
|
69
|
+
]
|
|
70
|
+
if self.trailers.get("Coagent-Task"):
|
|
71
|
+
lines.append(f"task: {self.trailers['Coagent-Task']}")
|
|
72
|
+
if self.trailers.get("Coagent-Verify"):
|
|
73
|
+
lines.append(f"last verify: {self.trailers['Coagent-Verify']}")
|
|
74
|
+
if self.dirty_paths:
|
|
75
|
+
lines.append(f"WARNING: uncommitted changes on disk: {', '.join(self.dirty_paths[:5])}")
|
|
76
|
+
if self.plan_path is not None:
|
|
77
|
+
lines.append(f"plan: {self.plan_path}")
|
|
78
|
+
if self.plan_items:
|
|
79
|
+
done = sum(1 for item in self.plan_items if item.done)
|
|
80
|
+
lines.append(f"plan progress: {done}/{len(self.plan_items)}")
|
|
81
|
+
for item in self.open_items[:8]:
|
|
82
|
+
lines.append(f" [ ] {item}")
|
|
83
|
+
if self.artifacts:
|
|
84
|
+
shown = ", ".join(self.artifacts[:5])
|
|
85
|
+
more = f" (+{len(self.artifacts) - 5} more)" if len(self.artifacts) > 5 else ""
|
|
86
|
+
lines.append(f"artifacts: {shown}{more}")
|
|
87
|
+
if self.recent:
|
|
88
|
+
failures = [r for r in self.recent if not r.get("ok", True)]
|
|
89
|
+
if failures:
|
|
90
|
+
last = failures[-1]
|
|
91
|
+
lines.append(
|
|
92
|
+
f"open failure: {last.get('tool', '?')} — "
|
|
93
|
+
f"{str(last.get('error', ''))[:180]}"
|
|
94
|
+
)
|
|
95
|
+
text = "\n".join(lines)
|
|
96
|
+
if len(text) > max_chars:
|
|
97
|
+
text = text[: max_chars - 1] + "…"
|
|
98
|
+
return text
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_plan_items(plan_path: Path) -> list[PlanItem]:
|
|
102
|
+
"""Parse `- [x]` / `- [ ]` checkboxes from a plan file."""
|
|
103
|
+
items: list[PlanItem] = []
|
|
104
|
+
try:
|
|
105
|
+
text = plan_path.read_text(encoding="utf-8")
|
|
106
|
+
except OSError:
|
|
107
|
+
return items
|
|
108
|
+
for line in text.splitlines():
|
|
109
|
+
stripped = line.strip()
|
|
110
|
+
if stripped.startswith(("- [x]", "- [X]")):
|
|
111
|
+
items.append(PlanItem(text=stripped[5:].strip(), done=True))
|
|
112
|
+
elif stripped.startswith("- [ ]"):
|
|
113
|
+
items.append(PlanItem(text=stripped[5:].strip(), done=False))
|
|
114
|
+
return items
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def read_last_log_records(log_path: Path, *, count: int = 20) -> list[dict]:
|
|
118
|
+
"""The last `count` JSONL records, tolerant of a truncated final line."""
|
|
119
|
+
if not log_path.is_file():
|
|
120
|
+
return []
|
|
121
|
+
records: list[dict] = []
|
|
122
|
+
try:
|
|
123
|
+
with log_path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
124
|
+
for line in fh:
|
|
125
|
+
line = line.strip()
|
|
126
|
+
if not line:
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
records.append(json.loads(line))
|
|
130
|
+
except json.JSONDecodeError:
|
|
131
|
+
continue # a kill can leave a partial line; skip it
|
|
132
|
+
except OSError:
|
|
133
|
+
return []
|
|
134
|
+
return records[-count:]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def build_briefing(repo: GitRepo, session_id: str, branch: str) -> Briefing:
|
|
138
|
+
"""Assemble the resume briefing from whatever survived."""
|
|
139
|
+
root = repo.root
|
|
140
|
+
briefing = Briefing(session_id=session_id, branch=branch)
|
|
141
|
+
|
|
142
|
+
log_out = repo.run("log", "--format=%H%x00%s%x00%B%x01", "-n50", branch, check=False)
|
|
143
|
+
commits = [c for c in log_out.stdout.split("\x01") if c.strip()]
|
|
144
|
+
briefing.committed_steps = sum(1 for c in commits if "Coagent-Step:" in c)
|
|
145
|
+
for commit in commits:
|
|
146
|
+
trailers = parse_trailers(commit)
|
|
147
|
+
try:
|
|
148
|
+
briefing.max_step = max(briefing.max_step, int(trailers.get("Coagent-Step", "0")))
|
|
149
|
+
except ValueError:
|
|
150
|
+
continue
|
|
151
|
+
if commits:
|
|
152
|
+
head = commits[0].split("\x00")
|
|
153
|
+
if len(head) >= 3:
|
|
154
|
+
briefing.last_commit = head[0].strip()
|
|
155
|
+
briefing.last_subject = head[1].strip()
|
|
156
|
+
briefing.trailers = parse_trailers(head[2])
|
|
157
|
+
|
|
158
|
+
# Plan: the newest plan file for this session, else the newest overall.
|
|
159
|
+
plan_dir = root / ".coagent" / "plans"
|
|
160
|
+
plan_path = None
|
|
161
|
+
if plan_dir.is_dir():
|
|
162
|
+
candidates = sorted(plan_dir.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
163
|
+
if candidates:
|
|
164
|
+
plan_path = candidates[0]
|
|
165
|
+
if plan_path is not None:
|
|
166
|
+
briefing.plan_path = plan_path
|
|
167
|
+
briefing.plan_items = read_plan_items(plan_path)
|
|
168
|
+
briefing.open_items = [item.text for item in briefing.plan_items if not item.done]
|
|
169
|
+
|
|
170
|
+
briefing.recent = read_last_log_records(root / ".coagent" / "sessions" / f"{session_id}.jsonl")
|
|
171
|
+
briefing.artifacts = read_artifact_manifest(root, session_id)
|
|
172
|
+
|
|
173
|
+
if repo.is_dirty():
|
|
174
|
+
briefing.dirty_paths = repo.changed_paths()
|
|
175
|
+
|
|
176
|
+
return briefing
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_artifact_manifest(root: Path, session_id: str) -> list[str]:
|
|
180
|
+
"""Committed artifact paths for a session, read straight from git's copy.
|
|
181
|
+
|
|
182
|
+
Deliberately tolerant: a missing or malformed manifest is not a reason to
|
|
183
|
+
refuse a resume. The briefing is best-effort, and the trailers already carry
|
|
184
|
+
the artifact list for the last commit.
|
|
185
|
+
"""
|
|
186
|
+
import json
|
|
187
|
+
|
|
188
|
+
manifest = root / "artifacts" / session_id / "manifest.json"
|
|
189
|
+
if not manifest.is_file():
|
|
190
|
+
return []
|
|
191
|
+
try:
|
|
192
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
193
|
+
except (OSError, json.JSONDecodeError):
|
|
194
|
+
return []
|
|
195
|
+
paths: list[str] = []
|
|
196
|
+
for entry in data.get("artifacts", []):
|
|
197
|
+
if isinstance(entry, dict) and entry.get("path"):
|
|
198
|
+
paths.append(str(entry["path"]))
|
|
199
|
+
return paths
|
coagent/gitx/session.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Session lifecycle: clone, guard, branch (PRD FR-GIT-1/2, FR-GIT-5).
|
|
2
|
+
|
|
3
|
+
A session is identified by its branch: `coagent/<yyyy-mm-dd>-<short-id>`. If the
|
|
4
|
+
branch already exists, opening it is a *resume*, not a new session — that is the
|
|
5
|
+
whole recovery story from DESIGN §3.6.
|
|
6
|
+
|
|
7
|
+
Two guards the tests care about:
|
|
8
|
+
|
|
9
|
+
* **Refuse a dirty tree.** A user with uncommitted work must get a stop with
|
|
10
|
+
instructions, never a reset (FR-GIT-1, AT-3).
|
|
11
|
+
* **Never touch `main`.** The session branch is created from `HEAD` and the
|
|
12
|
+
session refuses to run on a branch outside the coagent namespace.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import secrets
|
|
18
|
+
import shutil
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .git import GitError, GitRepo
|
|
24
|
+
from .lock import SessionLock
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Session",
|
|
28
|
+
"SessionError",
|
|
29
|
+
"DirtyTreeError",
|
|
30
|
+
"new_session_id",
|
|
31
|
+
"open_session",
|
|
32
|
+
"short_id",
|
|
33
|
+
"SESSION_BRANCH_PREFIX",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
SESSION_BRANCH_PREFIX = "coagent/"
|
|
37
|
+
SHORT_ID_LEN = 6
|
|
38
|
+
|
|
39
|
+
# coAgent's own ephemeral state. Never counted as user dirt.
|
|
40
|
+
COAGENT_RUNTIME_PATHS: tuple[str, ...] = (
|
|
41
|
+
".coagent/lock.json",
|
|
42
|
+
".coagent/tmp/",
|
|
43
|
+
".coagent/sessions/",
|
|
44
|
+
".coagent/cache/",
|
|
45
|
+
".coagent/bundles/",
|
|
46
|
+
# Plans are coAgent's own memory. Leaving them out would make every re-run
|
|
47
|
+
# refuse to start on coAgent's own leftovers.
|
|
48
|
+
".coagent/plans/",
|
|
49
|
+
".coagent/secrets",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SessionError(RuntimeError):
|
|
54
|
+
"""The session could not be opened. The message is the user's next step."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class DirtyTreeError(SessionError):
|
|
58
|
+
"""The working tree has uncommitted changes; coAgent refuses to clobber them."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, paths: list[str]) -> None:
|
|
61
|
+
listed = "\n ".join(paths[:20])
|
|
62
|
+
more = f"\n … and {len(paths) - 20} more" if len(paths) > 20 else ""
|
|
63
|
+
super().__init__(
|
|
64
|
+
"the working tree has uncommitted changes, so coAgent will not start "
|
|
65
|
+
"(it never resets your work). Commit, stash, or discard them first:\n"
|
|
66
|
+
f" {listed}{more}\n"
|
|
67
|
+
"Then re-run. Nothing has been modified."
|
|
68
|
+
)
|
|
69
|
+
self.paths = paths
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def short_id(n: int = SHORT_ID_LEN) -> str:
|
|
73
|
+
return secrets.token_hex(max(3, n // 2))[:n]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def new_session_id(*, now: datetime | None = None, short: str | None = None) -> str:
|
|
77
|
+
"""`2026-11-12-a1b2c3` — date first so branches sort chronologically."""
|
|
78
|
+
moment = now or datetime.now(UTC)
|
|
79
|
+
return f"{moment:%Y-%m-%d}-{short or short_id()}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class Session:
|
|
84
|
+
"""An open working session: repo, branch, lock, identity."""
|
|
85
|
+
|
|
86
|
+
repo: GitRepo
|
|
87
|
+
session_id: str
|
|
88
|
+
branch: str
|
|
89
|
+
resumed: bool = False
|
|
90
|
+
lock: SessionLock | None = None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def root(self) -> Path:
|
|
94
|
+
return self.repo.root
|
|
95
|
+
|
|
96
|
+
def checkpoint_heartbeat(self) -> None:
|
|
97
|
+
if self.lock is not None:
|
|
98
|
+
self.lock.heartbeat()
|
|
99
|
+
|
|
100
|
+
def close(self, *, release_lock: bool = True) -> None:
|
|
101
|
+
if release_lock and self.lock is not None:
|
|
102
|
+
self.lock.release()
|
|
103
|
+
self.lock = None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ensure_coagent_ignored(root: Path) -> None:
|
|
107
|
+
"""Warn (never silently edit) when `.coagent/` runtime state is untracked.
|
|
108
|
+
|
|
109
|
+
coAgent writes `.coagent/lock.json`, `tmp/`, and `sessions/`; a user repo
|
|
110
|
+
without these ignores would see a permanently dirty tree and refuse to
|
|
111
|
+
start its own session. DESIGN §11 is explicit that bootstrap **warns**
|
|
112
|
+
rather than mutating a tracked file, so we record the advice on the session
|
|
113
|
+
and leave the file alone.
|
|
114
|
+
"""
|
|
115
|
+
ignore = root / ".gitignore"
|
|
116
|
+
needed = (".coagent/lock.json", ".coagent/tmp/", ".coagent/sessions/")
|
|
117
|
+
try:
|
|
118
|
+
existing = ignore.read_text(encoding="utf-8") if ignore.is_file() else ""
|
|
119
|
+
except OSError:
|
|
120
|
+
return
|
|
121
|
+
missing = [p for p in needed if p not in existing]
|
|
122
|
+
if missing:
|
|
123
|
+
# Docker-style: report once, do not surprise the user by editing a file
|
|
124
|
+
# that is already in their history.
|
|
125
|
+
import sys
|
|
126
|
+
|
|
127
|
+
print(
|
|
128
|
+
"coagent: warning — .gitignore does not ignore coAgent runtime state:\n"
|
|
129
|
+
+ "\n".join(f" {p}" for p in missing)
|
|
130
|
+
+ "\n Add them, or `coagent init` will keep reporting a dirty tree.",
|
|
131
|
+
file=sys.stderr,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def open_session(
|
|
136
|
+
repo_path: str | Path,
|
|
137
|
+
*,
|
|
138
|
+
session_id: str | None = None,
|
|
139
|
+
branch: str | None = None,
|
|
140
|
+
force: bool = False,
|
|
141
|
+
author_name: str = "coAgent",
|
|
142
|
+
author_email: str = "coagent@users.noreply.github.com",
|
|
143
|
+
token: str | None = None,
|
|
144
|
+
ttl_s: int = 30 * 60,
|
|
145
|
+
allow_branch: str | None = None,
|
|
146
|
+
) -> Session:
|
|
147
|
+
"""Open (or resume) a coAgent session on an existing clone.
|
|
148
|
+
|
|
149
|
+
`allow_branch` exists for tests and for the `durable` clone path; in normal
|
|
150
|
+
use the branch must live under `coagent/`.
|
|
151
|
+
"""
|
|
152
|
+
root = Path(repo_path).resolve()
|
|
153
|
+
if not root.is_dir():
|
|
154
|
+
raise SessionError(f"{root} is not a directory.")
|
|
155
|
+
repo = GitRepo(root, author_name=author_name, author_email=author_email, token=token)
|
|
156
|
+
if not repo.is_repo():
|
|
157
|
+
raise SessionError(
|
|
158
|
+
f"{root} is not a git repository. Clone it first, or pass a repo URL to coAgent(repo=...)."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
repo.ensure_identity()
|
|
162
|
+
_ensure_coagent_ignored(root)
|
|
163
|
+
|
|
164
|
+
# Guard: never start on top of the user's uncommitted work. coAgent's own
|
|
165
|
+
# runtime state is excluded (DESIGN §11: warn, never mutate a tracked file).
|
|
166
|
+
if repo.is_dirty(ignore=COAGENT_RUNTIME_PATHS):
|
|
167
|
+
raise DirtyTreeError(repo.changed_paths(ignore=COAGENT_RUNTIME_PATHS))
|
|
168
|
+
|
|
169
|
+
current = repo.current_branch()
|
|
170
|
+
|
|
171
|
+
if branch is not None:
|
|
172
|
+
target = branch
|
|
173
|
+
elif current.startswith(SESSION_BRANCH_PREFIX):
|
|
174
|
+
# Already on a session branch: this is a resume.
|
|
175
|
+
target = current
|
|
176
|
+
else:
|
|
177
|
+
target = f"{SESSION_BRANCH_PREFIX}{session_id or new_session_id()}"
|
|
178
|
+
|
|
179
|
+
resumed = repo.branch_exists(target)
|
|
180
|
+
|
|
181
|
+
if not resumed:
|
|
182
|
+
try:
|
|
183
|
+
repo.switch(target, create=True)
|
|
184
|
+
except GitError as exc:
|
|
185
|
+
raise SessionError(
|
|
186
|
+
f"could not create the session branch {target}: {exc}. "
|
|
187
|
+
"If you started from a detached HEAD, check out a branch first."
|
|
188
|
+
) from exc
|
|
189
|
+
else:
|
|
190
|
+
if current != target:
|
|
191
|
+
try:
|
|
192
|
+
repo.switch(target)
|
|
193
|
+
except GitError as exc:
|
|
194
|
+
raise SessionError(f"could not switch to the existing session branch {target}: {exc}") from exc
|
|
195
|
+
|
|
196
|
+
if allow_branch is None and not target.startswith(SESSION_BRANCH_PREFIX):
|
|
197
|
+
raise SessionError(
|
|
198
|
+
f"refusing to work on {target!r}: coAgent only works on {SESSION_BRANCH_PREFIX}* "
|
|
199
|
+
"branches so that main is never touched."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
sid = target.removeprefix(SESSION_BRANCH_PREFIX)
|
|
203
|
+
lock = SessionLock(root, sid, ttl_s=ttl_s)
|
|
204
|
+
lock.acquire(force=force)
|
|
205
|
+
|
|
206
|
+
return Session(repo=repo, session_id=sid, branch=target, resumed=resumed, lock=lock)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def clone_repo(
|
|
210
|
+
url: str,
|
|
211
|
+
dest: str | Path,
|
|
212
|
+
*,
|
|
213
|
+
depth: int = 1,
|
|
214
|
+
token: str | None = None,
|
|
215
|
+
author_name: str = "coAgent",
|
|
216
|
+
author_email: str = "coagent@users.noreply.github.com",
|
|
217
|
+
overwrite: bool = False,
|
|
218
|
+
) -> GitRepo:
|
|
219
|
+
"""Clone `url` into `dest` (`--depth 1` by default, DESIGN §3.1)."""
|
|
220
|
+
target = Path(dest).resolve()
|
|
221
|
+
if target.exists() and any(target.iterdir()):
|
|
222
|
+
if not overwrite:
|
|
223
|
+
raise SessionError(
|
|
224
|
+
f"{target} already exists and is not empty. coAgent will not overwrite it; "
|
|
225
|
+
"pass overwrite=True to replace it, or point at a different directory."
|
|
226
|
+
)
|
|
227
|
+
shutil.rmtree(target)
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
|
|
230
|
+
repo = GitRepo(target, author_name=author_name, author_email=author_email, token=token)
|
|
231
|
+
repo.install_askpass()
|
|
232
|
+
repo.clone(url, target, depth=depth)
|
|
233
|
+
return repo
|
coagent/llm/__init__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The model layer: one interface, a local 4-bit backend, and a scripted fake.
|
|
2
|
+
|
|
3
|
+
`import coagent.llm` must stay torch-free. `make_backend()` is the only place
|
|
4
|
+
that reaches for `transformers`, and it does so lazily.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .base import (
|
|
12
|
+
BudgetError,
|
|
13
|
+
FakeBackend,
|
|
14
|
+
GenerationConfig,
|
|
15
|
+
GenerationResult,
|
|
16
|
+
LLMBackend,
|
|
17
|
+
Message,
|
|
18
|
+
StopReason,
|
|
19
|
+
TokenBudget,
|
|
20
|
+
Usage,
|
|
21
|
+
)
|
|
22
|
+
from .grammar import (
|
|
23
|
+
Envelope,
|
|
24
|
+
EnvelopeError,
|
|
25
|
+
extract_json_candidates,
|
|
26
|
+
looks_like_final_answer,
|
|
27
|
+
parse_envelope,
|
|
28
|
+
repair_json,
|
|
29
|
+
strip_fences,
|
|
30
|
+
)
|
|
31
|
+
from .registry import (
|
|
32
|
+
MODELS,
|
|
33
|
+
T4_COMPUTE_CAPABILITY,
|
|
34
|
+
T4_MIN_VRAM_GB,
|
|
35
|
+
ModelSpec,
|
|
36
|
+
PreflightResult,
|
|
37
|
+
get_model,
|
|
38
|
+
get_model_by_name,
|
|
39
|
+
preflight_gpu,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"MODELS",
|
|
44
|
+
"T4_COMPUTE_CAPABILITY",
|
|
45
|
+
"T4_MIN_VRAM_GB",
|
|
46
|
+
"BudgetError",
|
|
47
|
+
"Envelope",
|
|
48
|
+
"EnvelopeError",
|
|
49
|
+
"FakeBackend",
|
|
50
|
+
"GenerationConfig",
|
|
51
|
+
"GenerationResult",
|
|
52
|
+
"LLMBackend",
|
|
53
|
+
"Message",
|
|
54
|
+
"ModelSpec",
|
|
55
|
+
"PreflightResult",
|
|
56
|
+
"StopReason",
|
|
57
|
+
"TokenBudget",
|
|
58
|
+
"Usage",
|
|
59
|
+
"extract_json_candidates",
|
|
60
|
+
"get_model",
|
|
61
|
+
"get_model_by_name",
|
|
62
|
+
"looks_like_final_answer",
|
|
63
|
+
"make_backend",
|
|
64
|
+
"parse_envelope",
|
|
65
|
+
"preflight_gpu",
|
|
66
|
+
"repair_json",
|
|
67
|
+
"strip_fences",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def make_backend(kind: str = "transformers", **kwargs: Any) -> LLMBackend:
|
|
72
|
+
"""Construct a backend by name.
|
|
73
|
+
|
|
74
|
+
`kind="fake"` is the test/`--dry-run` path; `kind="transformers"` imports the
|
|
75
|
+
GPU stack only when it is actually used.
|
|
76
|
+
"""
|
|
77
|
+
if kind == "fake":
|
|
78
|
+
return FakeBackend(**kwargs)
|
|
79
|
+
if kind == "transformers":
|
|
80
|
+
from .transformers_backend import TransformersBackend
|
|
81
|
+
|
|
82
|
+
return TransformersBackend(**kwargs)
|
|
83
|
+
raise ValueError(f"unknown backend {kind!r}; expected 'transformers' or 'fake'")
|