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
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
@@ -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
@@ -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'")