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/llm/base.py ADDED
@@ -0,0 +1,235 @@
1
+ """The model interface (DESIGN §4).
2
+
3
+ `generate(messages) -> text` behind one interface, so the loop never knows which
4
+ backend it is talking to. That is what keeps the loop testable on a laptop with
5
+ a scripted backend while production runs a 4-bit Qwen on a T4.
6
+
7
+ Two rules this module enforces because a 7B model cannot be trusted with them:
8
+
9
+ * **No silent truncation.** A request over the token budget is rejected before
10
+ generation (PRD D2), counted with the model's own tokenizer.
11
+ * **No inference egress.** Nothing in this layer opens a socket. The only
12
+ outbound traffic in the product is the model download and git (FR-MDL-7).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import enum
18
+ from abc import ABC, abstractmethod
19
+ from collections.abc import Callable, Iterable
20
+ from dataclasses import dataclass, field
21
+ from typing import Any
22
+
23
+ __all__ = [
24
+ "BudgetError",
25
+ "FakeBackend",
26
+ "GenerationConfig",
27
+ "GenerationResult",
28
+ "LLMBackend",
29
+ "Message",
30
+ "StopReason",
31
+ "TokenBudget",
32
+ "Usage",
33
+ ]
34
+
35
+
36
+ class BudgetError(RuntimeError):
37
+ """The request exceeds the context budget. Raised before generation."""
38
+
39
+
40
+ class StopReason(str, enum.Enum):
41
+ STOP = "stop" # model emitted EOS
42
+ LENGTH = "length" # hit max_new_tokens (may be truncated — say so)
43
+ CANCELLED = "cancelled" # user pressed stop
44
+ ERROR = "error"
45
+
46
+
47
+ @dataclass(slots=True)
48
+ class Message:
49
+ role: str # "system" | "user" | "assistant"
50
+ content: str
51
+
52
+ def to_dict(self) -> dict[str, str]:
53
+ return {"role": self.role, "content": self.content}
54
+
55
+
56
+ @dataclass(slots=True)
57
+ class GenerationConfig:
58
+ max_new_tokens: int = 1024
59
+ temperature: float = 0.2
60
+ top_p: float = 0.95
61
+ seed: int = 0
62
+ stop: tuple[str, ...] = ()
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class Usage:
67
+ prompt_tokens: int = 0
68
+ completion_tokens: int = 0
69
+
70
+ @property
71
+ def total(self) -> int:
72
+ return self.prompt_tokens + self.completion_tokens
73
+
74
+
75
+ @dataclass(slots=True)
76
+ class GenerationResult:
77
+ text: str
78
+ stop_reason: StopReason = StopReason.STOP
79
+ usage: Usage = field(default_factory=Usage)
80
+ model: str = ""
81
+
82
+ @property
83
+ def truncated(self) -> bool:
84
+ return self.stop_reason is StopReason.LENGTH
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class TokenBudget:
89
+ """Context arithmetic against a real tokenizer (PRD D2, FR-CTX-6).
90
+
91
+ `count` is injected so this type works with HF tokenizers, a fake counter in
92
+ tests, and a character heuristic only as an explicit last resort.
93
+ """
94
+
95
+ max_context: int
96
+ count: Callable[[str], int]
97
+ reserve_output: int = 1500
98
+
99
+ @property
100
+ def input_limit(self) -> int:
101
+ return max(0, self.max_context - self.reserve_output)
102
+
103
+ def count_messages(self, messages: Iterable[Message]) -> int:
104
+ # Chat templates add role markers and separators; ~4 tokens per turn is
105
+ # the usual overhead and is included so the estimate is not optimistic.
106
+ return sum(self.count(m.content) for m in messages) + 4 * len(list(messages))
107
+
108
+ def check(self, messages: list[Message], *, max_new_tokens: int) -> int:
109
+ """Return the input token count, or raise `BudgetError`."""
110
+ used = self.count_messages(messages)
111
+ cap = self.max_context - min(max_new_tokens, self.reserve_output)
112
+ if used > cap:
113
+ raise BudgetError(
114
+ f"request is {used} tokens but the budget allows {cap} "
115
+ f"(max_context={self.max_context}, reserved output={min(max_new_tokens, self.reserve_output)}). "
116
+ "Evict observations or shorten the retrieval set — the request was not sent."
117
+ )
118
+ return used
119
+
120
+
121
+ class LLMBackend(ABC):
122
+ """What the loop requires of any backend."""
123
+
124
+ name: str = ""
125
+
126
+ @property
127
+ @abstractmethod
128
+ def model_id(self) -> str:
129
+ """Human-readable `model@quant` used in trailers and the status line."""
130
+
131
+ @abstractmethod
132
+ def count_tokens(self, text: str) -> int:
133
+ """Tokenizer truth for budget arithmetic (FR-CTX-6)."""
134
+
135
+ @abstractmethod
136
+ def generate(
137
+ self,
138
+ messages: list[Message],
139
+ config: GenerationConfig | None = None,
140
+ *,
141
+ on_token: Callable[[str], None] | None = None,
142
+ cancel: Callable[[], bool] | None = None,
143
+ ) -> GenerationResult:
144
+ """Generate a completion. `on_token` streams; `cancel` polls a stop flag."""
145
+
146
+ def load(self) -> None: # noqa: B027 - optional hook, not every backend needs it
147
+ """Load weights. Backends that need no load (tests) do nothing."""
148
+
149
+ def unload(self) -> None: # noqa: B027 - optional hook
150
+ """Release the GPU. Required before `verify.gpu_commands` (DESIGN §4.6)."""
151
+
152
+ def close(self) -> None:
153
+ self.unload()
154
+
155
+
156
+ class FakeBackend(LLMBackend):
157
+ """A scripted backend for tests and for `--dry-run`.
158
+
159
+ Produces the queued responses in order; when the queue runs dry it repeats
160
+ the last one, which is what makes loop-detection tests deterministic.
161
+ """
162
+
163
+ def __init__(
164
+ self,
165
+ responses: list[str] | None = None,
166
+ *,
167
+ model_id: str = "fake/coder@none",
168
+ tokens_per_char: float = 0.25,
169
+ ) -> None:
170
+ self._responses = list(responses or ['```json\n{"tool": "done", "args": {}}\n```'])
171
+ self._index = 0
172
+ self._model_id = model_id
173
+ self._tpc = tokens_per_char
174
+ self.loaded = False
175
+ self.calls: list[dict[str, Any]] = []
176
+
177
+ @property
178
+ def model_id(self) -> str:
179
+ return self._model_id
180
+
181
+ def count_tokens(self, text: str) -> int:
182
+ return max(1, int(len(text) * self._tpc))
183
+
184
+ def load(self) -> None:
185
+ self.loaded = True
186
+
187
+ def unload(self) -> None:
188
+ self.loaded = False
189
+
190
+ def queue(self, *responses: str) -> None:
191
+ self._responses.extend(responses)
192
+
193
+ def generate(
194
+ self,
195
+ messages: list[Message],
196
+ config: GenerationConfig | None = None,
197
+ *,
198
+ on_token: Callable[[str], None] | None = None,
199
+ cancel: Callable[[], bool] | None = None,
200
+ ) -> GenerationResult:
201
+ cfg = config or GenerationConfig()
202
+ index = min(self._index, len(self._responses) - 1)
203
+ text = self._responses[index]
204
+ self._index += 1
205
+ self.calls.append(
206
+ {
207
+ "messages": [m.to_dict() for m in messages],
208
+ "max_new_tokens": cfg.max_new_tokens,
209
+ "temperature": cfg.temperature,
210
+ }
211
+ )
212
+
213
+ if on_token is not None:
214
+ # Stream in small chunks so a UI test sees multiple updates.
215
+ for i in range(0, len(text), 16):
216
+ if cancel is not None and cancel():
217
+ emitted = text[:i]
218
+ return GenerationResult(
219
+ text=emitted,
220
+ stop_reason=StopReason.CANCELLED,
221
+ usage=Usage(self.count_tokens("".join(m.content for m in messages)), self.count_tokens(emitted)),
222
+ model=self._model_id,
223
+ )
224
+ on_token(text[i : i + 16])
225
+
226
+ stop = StopReason.LENGTH if len(text) > cfg.max_new_tokens else StopReason.STOP
227
+ return GenerationResult(
228
+ text=text,
229
+ stop_reason=stop,
230
+ usage=Usage(
231
+ prompt_tokens=self.count_tokens("".join(m.content for m in messages)),
232
+ completion_tokens=self.count_tokens(text),
233
+ ),
234
+ model=self._model_id,
235
+ )
coagent/llm/grammar.py ADDED
@@ -0,0 +1,306 @@
1
+ """Tolerant extraction of the tool-call envelope (DESIGN §4.3).
2
+
3
+ A 7B model emits malformed JSON perhaps 3–8% of the time, and a malformed
4
+ envelope costs a full turn. This module is the reliability core: it never
5
+ raises on bad model output, always returns a diagnosis the loop can feed back.
6
+
7
+ Escalation ladder, cheapest first:
8
+
9
+ 1. Exactly one ```` ```json ```` fence — the contract we ask for.
10
+ 2. Any fence containing `{...}` — models sometimes label it ```` ``` ````.
11
+ 3. A bare top-level `{...}` object (no fence at all).
12
+ 4. JSON5-ish repair: single quotes, trailing commas, unquoted keys.
13
+ 5. Failure with a **corrective message** naming what was wrong, so the loop's
14
+ single re-prompt is targeted rather than "try again".
15
+
16
+ Only `{"tool": ..., "args": {...}}` is accepted. An invented tool name is a
17
+ schema error the registry catches, not a parse error.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from dataclasses import dataclass
25
+ from typing import Any
26
+
27
+ __all__ = [
28
+ "Envelope",
29
+ "EnvelopeError",
30
+ "parse_envelope",
31
+ "extract_json_candidates",
32
+ "repair_json",
33
+ "strip_fences",
34
+ "looks_like_final_answer",
35
+ ]
36
+
37
+ _FENCE_RE = re.compile(r"```(?:json|json5|JSON)?\s*\n?(.*?)```", re.DOTALL)
38
+ _TRAILING_COMMA_RE = re.compile(r",(\s*[}\]])")
39
+ _UNQUOTED_KEY_RE = re.compile(r"([{,]\s*)([A-Za-z_][A-Za-z0-9_\-]*)(\s*:)")
40
+ _SINGLE_QUOTE_RE = re.compile(r"'([^'\\]*(?:\\.[^'\\]*)*)'")
41
+
42
+
43
+ class EnvelopeError(ValueError):
44
+ """No usable envelope. `corrective` is fed back to the model."""
45
+
46
+ def __init__(self, message: str, *, corrective: str, raw: str = "") -> None:
47
+ super().__init__(message)
48
+ self.corrective = corrective
49
+ self.raw = raw
50
+
51
+
52
+ @dataclass(slots=True)
53
+ class Envelope:
54
+ tool: str
55
+ args: dict[str, Any]
56
+ raw: str = ""
57
+ repaired: bool = False
58
+
59
+ def to_dict(self) -> dict[str, Any]:
60
+ return {"tool": self.tool, "args": self.args}
61
+
62
+
63
+ def strip_fences(text: str) -> list[str]:
64
+ """Every fenced block, plus the whole text as a last candidate."""
65
+ blocks = [m.group(1).strip() for m in _FENCE_RE.finditer(text)]
66
+ blocks.append(text.strip())
67
+ return blocks
68
+
69
+
70
+ def _balanced_objects(text: str) -> list[str]:
71
+ """Extract balanced `{...}` runs, ignoring braces inside strings.
72
+
73
+ Needed because a model often writes prose around a bare object:
74
+ `Here is the call: {"tool": ...} and I will now...`
75
+ """
76
+ found: list[str] = []
77
+ depth = 0
78
+ start = -1
79
+ in_string = False
80
+ escape = False
81
+ for i, ch in enumerate(text):
82
+ if in_string:
83
+ if escape:
84
+ escape = False
85
+ elif ch == "\\":
86
+ escape = True
87
+ elif ch == '"':
88
+ in_string = False
89
+ continue
90
+ if ch == '"':
91
+ in_string = True
92
+ elif ch == "{":
93
+ if depth == 0:
94
+ start = i
95
+ depth += 1
96
+ elif ch == "}" and depth > 0:
97
+ depth -= 1
98
+ if depth == 0 and start >= 0:
99
+ found.append(text[start : i + 1])
100
+ start = -1
101
+ return found
102
+
103
+
104
+ def extract_json_candidates(text: str) -> list[str]:
105
+ """Ordered candidates, most likely first, deduplicated."""
106
+ seen: set[str] = set()
107
+ out: list[str] = []
108
+ for block in strip_fences(text):
109
+ for candidate in (block, *_balanced_objects(block)):
110
+ candidate = candidate.strip()
111
+ if candidate and candidate not in seen:
112
+ seen.add(candidate)
113
+ out.append(candidate)
114
+ return out
115
+
116
+
117
+
118
+ def _escape_controls(text: str) -> str:
119
+ r"""Escape literal control characters that appear inside JSON strings.
120
+
121
+ A small model writing code into a ``replace`` field emits a real newline
122
+ instead of ``\n``, which makes the block invalid JSON. That is the most
123
+ common envelope failure and it is always recoverable: inside a JSON string
124
+ a raw control character is never legal.
125
+ """
126
+ mapping = {"\n": "\\n", "\r": "\\r", "\t": "\\t"}
127
+ out = []
128
+ in_string = False
129
+ escaped = False
130
+ for ch in text:
131
+ if in_string:
132
+ if escaped:
133
+ escaped = False
134
+ elif ch == "\\":
135
+ escaped = True
136
+ elif ch == '"':
137
+ in_string = False
138
+ elif ch in mapping:
139
+ out.append(mapping[ch])
140
+ continue
141
+ elif ch == '"':
142
+ in_string = True
143
+ out.append(ch)
144
+ return "".join(out)
145
+
146
+
147
+ def _truncated(text: str) -> bool:
148
+ """True when strings/braces are unbalanced, i.e. the block was cut off."""
149
+ depth = 0
150
+ in_string = False
151
+ escaped = False
152
+ for ch in text:
153
+ if in_string:
154
+ if escaped:
155
+ escaped = False
156
+ elif ch == "\\":
157
+ escaped = True
158
+ elif ch == '"':
159
+ in_string = False
160
+ elif ch == '"':
161
+ in_string = True
162
+ elif ch in "{[":
163
+ depth += 1
164
+ elif ch in "}]":
165
+ depth -= 1
166
+ return depth > 0 or in_string
167
+
168
+
169
+ def repair_json(text: str) -> str:
170
+ """Best-effort JSON5-ish repair for the most common model slips."""
171
+ fixed = _escape_controls(text)
172
+ # Single-quoted strings → double-quoted, without touching apostrophes in prose.
173
+ fixed = _SINGLE_QUOTE_RE.sub(lambda m: '"' + m.group(1).replace('"', '\\"') + '"', fixed)
174
+ # Unquoted keys: {tool: "read_file"} → {"tool": "read_file"}
175
+ fixed = _UNQUOTED_KEY_RE.sub(lambda m: f'{m.group(1)}"{m.group(2)}"{m.group(3)}', fixed)
176
+ # Trailing commas before a closing brace/bracket.
177
+ fixed = _TRAILING_COMMA_RE.sub(r"\1", fixed)
178
+ return fixed
179
+
180
+
181
+ def _try_load(candidate: str) -> tuple[dict[str, Any] | None, bool]:
182
+ """Return (object, repaired?) for one candidate."""
183
+ for attempt, payload in ((False, candidate), (True, repair_json(candidate))):
184
+ try:
185
+ value = json.loads(payload)
186
+ except (json.JSONDecodeError, ValueError):
187
+ continue
188
+ if isinstance(value, dict):
189
+ return value, attempt
190
+ return None, False
191
+
192
+
193
+ def parse_envelope(text: str, *, allowed_tools: set[str] | None = None) -> Envelope:
194
+ """Extract `{"tool": ..., "args": {...}}` from model output.
195
+
196
+ Raises `EnvelopeError` with a `corrective` message — never a bare
197
+ `JSONDecodeError` — because the loop feeds that text straight back.
198
+ """
199
+ if not text or not text.strip():
200
+ raise EnvelopeError(
201
+ "the response was empty",
202
+ corrective=(
203
+ "Your last message was empty. Reply with exactly one fenced JSON block:\n"
204
+ '```json\n{"tool": "<tool name>", "args": {...}}\n```'
205
+ ),
206
+ )
207
+
208
+ saw_json = False
209
+ saw_unparseable = ""
210
+ for candidate in extract_json_candidates(text):
211
+ value, repaired = _try_load(candidate)
212
+ if value is None:
213
+ # Only a brace-bearing candidate is a real parse attempt; plain prose
214
+ # reaching here means the model never tried to call a tool.
215
+ if "{" in candidate and not saw_unparseable:
216
+ saw_unparseable = candidate
217
+ continue
218
+ saw_json = True
219
+ if "tool" not in value:
220
+ continue
221
+ tool = value.get("tool")
222
+ if not isinstance(tool, str):
223
+ raise EnvelopeError(
224
+ "`tool` is not a string",
225
+ corrective=f'`tool` must be a string tool name, got {type(tool).__name__}.',
226
+ raw=text,
227
+ )
228
+ if allowed_tools is not None and tool not in allowed_tools:
229
+ raise EnvelopeError(
230
+ f"unknown tool {tool!r}",
231
+ corrective=(
232
+ f"There is no tool named {tool!r}. Choose one of: "
233
+ f"{', '.join(sorted(allowed_tools))}."
234
+ ),
235
+ raw=text,
236
+ )
237
+ args = value.get("args", {})
238
+ if args is None:
239
+ args = {}
240
+ if not isinstance(args, dict):
241
+ raise EnvelopeError(
242
+ "`args` is not an object",
243
+ corrective=(
244
+ f'`args` must be an object such as {{"path": "src/a.py"}}, got '
245
+ f"{type(args).__name__}."
246
+ ),
247
+ raw=text,
248
+ )
249
+ return Envelope(tool=tool, args=args, raw=text, repaired=repaired)
250
+
251
+ if saw_json:
252
+ raise EnvelopeError(
253
+ "JSON found but no `tool` key",
254
+ corrective=(
255
+ "Your JSON block is missing the `tool` key. Reply with exactly:\n"
256
+ '```json\n{"tool": "<tool name>", "args": {...}}\n```'
257
+ ),
258
+ raw=text,
259
+ )
260
+ if saw_unparseable and _truncated(saw_unparseable):
261
+ raise EnvelopeError(
262
+ "the tool call was cut off",
263
+ corrective=(
264
+ "Your last JSON block was cut off before it finished, most likely "
265
+ "because the output limit was reached. Keep the edit small: send a "
266
+ "shorter `replace` block, or split the change into two edits. Reply "
267
+ 'with one complete fenced JSON block:\n```json\n{"tool": "<tool name>", '
268
+ '"args": {...}}\n```'
269
+ ),
270
+ raw=text,
271
+ )
272
+ if saw_unparseable:
273
+ raise EnvelopeError(
274
+ "the JSON was malformed",
275
+ corrective=(
276
+ "Your JSON block could not be parsed. Common causes: an unescaped "
277
+ "quote or backslash inside a string, a missing comma, or a trailing "
278
+ 'comma. If you are writing code into `replace`, escape newlines as '
279
+ '\\n and quotes as \\". Reply with one complete fenced JSON block.'
280
+ ),
281
+ raw=text,
282
+ )
283
+ raise EnvelopeError(
284
+ "no JSON object in the response",
285
+ corrective=(
286
+ "I could not find a JSON tool call in your last message. Do not describe "
287
+ "the action in prose; reply with exactly one fenced JSON block:\n"
288
+ '```json\n{"tool": "<tool name>", "args": {...}}\n```'
289
+ ),
290
+ raw=text,
291
+ )
292
+
293
+
294
+ _FINAL_MARKERS = ("final_answer", "task_complete", "done")
295
+
296
+
297
+ def looks_like_final_answer(text: str) -> bool:
298
+ """True when the model is answering in prose rather than calling a tool.
299
+
300
+ Used by the loop to decide between "prose: treat as the report" and "re-prompt
301
+ for an envelope" — never to guess at an action.
302
+ """
303
+ lowered = text.lower()
304
+ if any(marker in lowered for marker in _FINAL_MARKERS):
305
+ return True
306
+ return not any("{" in candidate for candidate in extract_json_candidates(text))
@@ -0,0 +1,147 @@
1
+ """Model registry and the T4 preflight (PRD D1, FR-MDL-1).
2
+
3
+ v1 supports exactly two configurations: 7B at NF4/12k, and 3B at NF4/8k as the
4
+ only fallback. Nothing here imports torch at module scope, so `coagent doctor`
5
+ and the test suite run on a laptop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ __all__ = [
13
+ "MODELS",
14
+ "ModelSpec",
15
+ "PreflightResult",
16
+ "get_model",
17
+ "preflight_gpu",
18
+ "T4_MIN_VRAM_GB",
19
+ "T4_COMPUTE_CAPABILITY",
20
+ ]
21
+
22
+ T4_MIN_VRAM_GB = 14.0
23
+ T4_COMPUTE_CAPABILITY = (7, 5)
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ModelSpec:
28
+ key: str
29
+ name: str
30
+ quant: str
31
+ max_context: int
32
+ vram_gb: float
33
+ notes: str = ""
34
+
35
+ @property
36
+ def trailer_id(self) -> str:
37
+ """The `Coagent-Model` value: `Qwen2.5-Coder-7B-Instruct@nf4`."""
38
+ short = self.name.rsplit("/", 1)[-1]
39
+ return f"{short}@{self.quant}"
40
+
41
+
42
+ MODELS: dict[str, ModelSpec] = {
43
+ "qwen-coder-7b": ModelSpec(
44
+ key="qwen-coder-7b",
45
+ name="Qwen/Qwen2.5-Coder-7B-Instruct",
46
+ quant="nf4",
47
+ max_context=12288,
48
+ vram_gb=6.5,
49
+ notes="the only validated default on a free T4",
50
+ ),
51
+ "qwen-coder-3b": ModelSpec(
52
+ key="qwen-coder-3b",
53
+ name="Qwen/Qwen2.5-Coder-3B-Instruct",
54
+ quant="nf4",
55
+ max_context=8192,
56
+ vram_gb=3.0,
57
+ notes="OOM fallback only; never a bigger model (PRD D6)",
58
+ ),
59
+ }
60
+
61
+
62
+ def get_model(key: str) -> ModelSpec:
63
+ try:
64
+ return MODELS[key]
65
+ except KeyError:
66
+ raise KeyError(f"unknown model {key!r}; expected one of {sorted(MODELS)}") from None
67
+
68
+
69
+ def get_model_by_name(name: str) -> ModelSpec | None:
70
+ for spec in MODELS.values():
71
+ if spec.name == name:
72
+ return spec
73
+ return None
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class PreflightResult:
78
+ ok: bool
79
+ device: str = "none"
80
+ total_vram_gb: float = 0.0
81
+ compute_capability: tuple[int, int] = (0, 0)
82
+ reason: str = ""
83
+ override_allowed: bool = True
84
+
85
+ def describe(self) -> str:
86
+ if self.device == "none":
87
+ return "no CUDA device detected"
88
+ cap = f"{self.compute_capability[0]}.{self.compute_capability[1]}"
89
+ return f"{self.device} · {self.total_vram_gb:.0f} GB · CC {cap}"
90
+
91
+
92
+ def preflight_gpu(*, device_index: int = 0) -> PreflightResult:
93
+ """Check the free-tier T4 envelope without loading any weights.
94
+
95
+ Returns a result rather than raising: the caller decides whether to refuse,
96
+ and the message is what the user reads (FR-BOOT-2).
97
+ """
98
+ try:
99
+ import torch
100
+ except ImportError:
101
+ return PreflightResult(
102
+ ok=False,
103
+ reason=(
104
+ "torch is not installed. coAgent needs the GPU stack: "
105
+ 'pip install "coagent[llm]"'
106
+ ),
107
+ )
108
+
109
+ if not torch.cuda.is_available():
110
+ return PreflightResult(
111
+ ok=False,
112
+ reason=(
113
+ "no CUDA device is available. coAgent runs on a free Colab T4; "
114
+ "select Runtime → Change runtime type → T4 GPU. CPU-only is out of "
115
+ "scope (PRD D-1)."
116
+ ),
117
+ )
118
+
119
+ props = torch.cuda.get_device_properties(device_index)
120
+ name = props.name.replace("NVIDIA ", "").strip()
121
+ vram = props.total_memory / 1024**3
122
+ cap = (props.major, props.minor)
123
+
124
+ problems: list[str] = []
125
+ if "T4" not in name.upper():
126
+ problems.append(
127
+ f"the GPU is {name}, not a T4. coAgent is validated only on the free-tier T4 "
128
+ "(PRD D-1). Pass --allow-unsupported to run anyway, unsupported and unvalidated."
129
+ )
130
+ if cap < T4_COMPUTE_CAPABILITY:
131
+ problems.append(
132
+ f"compute capability {cap[0]}.{cap[1]} is below 7.5; fp16 and SDPA are the only "
133
+ "safe path and this device does not guarantee them."
134
+ )
135
+ if vram < T4_MIN_VRAM_GB:
136
+ problems.append(
137
+ f"only {vram:.1f} GB of VRAM is visible; the 7B at NF4 needs ~14 GB free to be safe. "
138
+ "The 3B fallback at 8k needs less — pass --allow-unsupported to try it."
139
+ )
140
+
141
+ return PreflightResult(
142
+ ok=not problems,
143
+ device=name,
144
+ total_vram_gb=vram,
145
+ compute_capability=cap,
146
+ reason=" ".join(problems),
147
+ )