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/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))
|
coagent/llm/registry.py
ADDED
|
@@ -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
|
+
)
|