co-agent-coder 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,326 @@
1
+ """The local `transformers` backend (DESIGN §4.2, §4.5).
2
+
3
+ Pinned to what a Turing T4 can actually do:
4
+
5
+ * **NF4 4-bit weights, fp16 compute.** T4 (SM 7.5) has no bf16.
6
+ * **`attn_implementation="sdpa"`.** FlashAttention-2 needs Ampere+.
7
+ * **No `torch.compile`.** Compile time exceeds most sessions' savings.
8
+ * **`PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True` set before torch import.**
9
+
10
+ Everything torch-related is imported inside methods so `import coagent` stays
11
+ light and the test suite runs without a GPU.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import gc
18
+ import os
19
+ import threading
20
+ from collections.abc import Callable
21
+ from dataclasses import dataclass
22
+ from typing import Any
23
+
24
+ from .base import (
25
+ GenerationConfig,
26
+ GenerationResult,
27
+ LLMBackend,
28
+ Message,
29
+ StopReason,
30
+ Usage,
31
+ )
32
+ from .registry import MODELS, ModelSpec, get_model
33
+
34
+ __all__ = ["TransformersBackend", "OutOfMemoryLadder"]
35
+
36
+
37
+ def _torch():
38
+ """Resolve torch at call time.
39
+
40
+ Module-level accessors keep `import coagent` torch-free while still letting a
41
+ test substitute a stub — patching a local `import` is impossible.
42
+ """
43
+ import torch
44
+
45
+ return torch
46
+
47
+
48
+ def _transformers():
49
+ """Resolve transformers at call time (same reason as `_torch`)."""
50
+ import transformers
51
+
52
+ return transformers
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class OutOfMemoryLadder:
57
+ """The OOM response order from DESIGN §4.5.
58
+
59
+ The session must never be lost to an OOM, and the model must never grow.
60
+ """
61
+
62
+ step: int = 0
63
+ context_reduction: float = 0.25
64
+ max_new_tokens_reduction: float = 0.5
65
+
66
+ def next_action(self) -> str:
67
+ """`shrink_context`, `halve_output`, `fallback_model`, or `stop`."""
68
+ return ("shrink_context", "halve_output", "fallback_model", "stop")[min(self.step, 3)]
69
+
70
+ def advance(self) -> str:
71
+ action = self.next_action()
72
+ self.step += 1
73
+ return action
74
+
75
+ @property
76
+ def exhausted(self) -> bool:
77
+ """True once every recovery action has been tried (3 actions, then stop)."""
78
+ return self.step >= 3
79
+
80
+
81
+ class TransformersBackend(LLMBackend):
82
+ """A 4-bit causal LM on the local GPU."""
83
+
84
+ name = "transformers"
85
+
86
+ def __init__(
87
+ self,
88
+ model_key: str = "qwen-coder-7b",
89
+ *,
90
+ spec: ModelSpec | None = None,
91
+ device_map: str | dict[str, Any] = "auto",
92
+ trust_remote_code: bool = False,
93
+ max_context: int | None = None,
94
+ ) -> None:
95
+ # Set the allocator flag before torch ever initialises CUDA.
96
+ os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
97
+ self.spec = spec or get_model(model_key)
98
+ self._device_map = device_map
99
+ self._trust = trust_remote_code
100
+ self._max_context_override = max_context
101
+ self._model: Any = None
102
+ self._tokenizer: Any = None
103
+ self._tokenizer_errors: set[str] = set()
104
+ self._lock = threading.Lock()
105
+
106
+ # ------------------------------------------------------------- lifecycle
107
+
108
+ @property
109
+ def model_id(self) -> str:
110
+ return self.spec.trailer_id
111
+
112
+ @property
113
+ def max_context(self) -> int:
114
+ return self._max_context_override or self.spec.max_context
115
+
116
+ @property
117
+ def loaded(self) -> bool:
118
+ return self._model is not None
119
+
120
+ def load(self) -> None:
121
+ if self._model is not None:
122
+ return
123
+ torch = _torch()
124
+ transformers = _transformers()
125
+ auto_model = transformers.AutoModelForCausalLM
126
+ auto_tokenizer = transformers.AutoTokenizer
127
+
128
+ quant = transformers.BitsAndBytesConfig(
129
+ load_in_4bit=True,
130
+ bnb_4bit_quant_type="nf4",
131
+ # T4 has no bf16. fp16 compute is the only correct choice.
132
+ bnb_4bit_compute_dtype=torch.float16,
133
+ bnb_4bit_use_double_quant=True,
134
+ )
135
+
136
+ self._tokenizer = auto_tokenizer.from_pretrained(
137
+ self.spec.name, trust_remote_code=self._trust
138
+ )
139
+ self._model = auto_model.from_pretrained(
140
+ self.spec.name,
141
+ quantization_config=quant,
142
+ device_map=self._device_map,
143
+ attn_implementation="sdpa", # flash-attn 2 needs Ampere+
144
+ torch_dtype=torch.float16,
145
+ trust_remote_code=self._trust,
146
+ )
147
+ self._model.eval()
148
+
149
+ def unload(self) -> None:
150
+ """Release VRAM. Required before `verify.gpu_commands` (DESIGN §4.6)."""
151
+ if self._model is None:
152
+ return
153
+ self._model = None
154
+ self._tokenizer = None
155
+ gc.collect()
156
+ try:
157
+ torch = _torch()
158
+
159
+ if torch.cuda.is_available():
160
+ torch.cuda.empty_cache()
161
+ torch.cuda.ipc_collect()
162
+ except Exception: # noqa: BLE001 - unloading must never raise
163
+ pass
164
+
165
+ # ------------------------------------------------------------- tokenizer
166
+
167
+ def count_tokens(self, text: str) -> int:
168
+ """Tokenizer truth for the budget (FR-CTX-6).
169
+
170
+ Falls back to a conservative character estimate only if the tokenizer
171
+ cannot be loaded at all, and records that it did so.
172
+ """
173
+ if not text:
174
+ return 0
175
+ if self._tokenizer is None:
176
+ try:
177
+ self._tokenizer = _transformers().AutoTokenizer.from_pretrained(
178
+ self.spec.name, trust_remote_code=self._trust
179
+ )
180
+ except Exception: # noqa: BLE001 - no network / no weights
181
+ if "estimate" not in self._tokenizer_errors:
182
+ self._tokenizer_errors.add("estimate")
183
+ # ~4 chars/token is the usual ratio; over-estimating is the safe bias.
184
+ return max(1, len(text) // 3)
185
+ return len(self._tokenizer.encode(text, add_special_tokens=False))
186
+
187
+ def using_estimated_tokens(self) -> bool:
188
+ return "estimate" in self._tokenizer_errors
189
+
190
+ # ------------------------------------------------------------ generation
191
+
192
+ def generate(
193
+ self,
194
+ messages: list[Message],
195
+ config: GenerationConfig | None = None,
196
+ *,
197
+ on_token: Callable[[str], None] | None = None,
198
+ cancel: Callable[[], bool] | None = None,
199
+ ) -> GenerationResult:
200
+ cfg = config or GenerationConfig()
201
+ self.load()
202
+ torch = _torch()
203
+ transformers = _transformers()
204
+
205
+ assert self._tokenizer is not None
206
+ prompt = self._tokenizer.apply_chat_template(
207
+ [m.to_dict() for m in messages], tokenize=False, add_generation_prompt=True
208
+ )
209
+ inputs = self._tokenizer(prompt, return_tensors="pt").to(self._model.device)
210
+
211
+ if cfg.seed:
212
+ torch.manual_seed(cfg.seed)
213
+
214
+ streamer = transformers.TextIteratorStreamer(
215
+ self._tokenizer, skip_prompt=True, skip_special_tokens=True
216
+ )
217
+
218
+ stop_criteria = _make_stop_criteria(cancel) if cancel is not None else None
219
+ stopping = transformers.StoppingCriteriaList([stop_criteria]) if stop_criteria else None
220
+
221
+ gen_kwargs: dict[str, Any] = {
222
+ "input_ids": inputs["input_ids"],
223
+ "attention_mask": inputs.get("attention_mask"),
224
+ "max_new_tokens": cfg.max_new_tokens,
225
+ "do_sample": cfg.temperature > 0,
226
+ "temperature": max(cfg.temperature, 1e-5),
227
+ "top_p": cfg.top_p,
228
+ "repetition_penalty": 1.05,
229
+ "pad_token_id": self._tokenizer.eos_token_id,
230
+ "streamer": streamer,
231
+ }
232
+ if stopping is not None:
233
+ gen_kwargs["stopping_criteria"] = stopping
234
+
235
+ error_holder: list[BaseException] = []
236
+
237
+ def _run() -> None:
238
+ try:
239
+ with torch.inference_mode():
240
+ self._model.generate(**gen_kwargs)
241
+ except BaseException as exc: # noqa: BLE001 - surfaced below
242
+ error_holder.append(exc)
243
+ with contextlib.suppress(Exception):
244
+ streamer.end()
245
+
246
+ worker = threading.Thread(target=_run, daemon=True)
247
+ worker.start()
248
+
249
+ pieces: list[str] = []
250
+ try:
251
+ for chunk in streamer:
252
+ if chunk:
253
+ pieces.append(chunk)
254
+ if on_token is not None:
255
+ on_token(chunk)
256
+ finally:
257
+ worker.join(timeout=1.0)
258
+
259
+ if error_holder:
260
+ exc = error_holder[0]
261
+ if _is_cuda_oom(exc):
262
+ self.unload()
263
+ raise _oom_from(exc) from exc
264
+ raise exc
265
+
266
+ text = "".join(pieces)
267
+ cancelled = cancel is not None and cancel()
268
+ stop = StopReason.CANCELLED if cancelled else StopReason.STOP
269
+ if not cancelled and len(pieces) and cfg.max_new_tokens and self.count_tokens(text) >= cfg.max_new_tokens:
270
+ stop = StopReason.LENGTH
271
+
272
+ return GenerationResult(
273
+ text=text,
274
+ stop_reason=stop,
275
+ usage=Usage(
276
+ prompt_tokens=int(inputs["input_ids"].shape[-1]),
277
+ completion_tokens=self.count_tokens(text),
278
+ ),
279
+ model=self.model_id,
280
+ )
281
+
282
+ # ---------------------------------------------------------------- ladder
283
+
284
+ def apply_oom_action(self, action: str, *, context: int, max_new_tokens: int) -> tuple[int, int, str]:
285
+ """Return `(context, max_new_tokens, model_key)` for the next attempt."""
286
+ ladder = OutOfMemoryLadder()
287
+ if action == "shrink_context":
288
+ return int(context * (1 - ladder.context_reduction)), max_new_tokens, self.spec.key
289
+ if action == "halve_output":
290
+ return context, max(64, max_new_tokens // 2), self.spec.key
291
+ if action == "fallback_model":
292
+ fallback = MODELS["qwen-coder-3b"]
293
+ return fallback.max_context, max_new_tokens, fallback.key
294
+ return context, max_new_tokens, self.spec.key
295
+
296
+
297
+ def _make_stop_criteria(cancel: Callable[[], bool]):
298
+ """Build a real HF `StoppingCriteria` that polls the cancel flag (FR-UX-8).
299
+
300
+ Imported lazily: this module must stay importable without transformers.
301
+ """
302
+ class _StopOnCancel(_transformers().StoppingCriteria):
303
+ def __call__(self, input_ids, scores, **kwargs) -> bool: # noqa: ANN001, ARG002
304
+ return bool(cancel())
305
+
306
+ return _StopOnCancel()
307
+
308
+
309
+ def _is_cuda_oom(exc: BaseException) -> bool:
310
+ name = type(exc).__name__.lower()
311
+ text = str(exc).lower()
312
+ return (
313
+ "outofmemory" in name
314
+ or "out of memory" in text
315
+ # A wrapped torch OOM often surfaces only as the class name in the message.
316
+ or "outofmemoryerror" in text
317
+ or "cuda error: out of memory" in text
318
+ )
319
+
320
+
321
+ def _oom_from(exc: BaseException) -> RuntimeError:
322
+ return RuntimeError(
323
+ "CUDA is out of memory. coAgent unloaded the model and will retry with a "
324
+ "smaller context; if that fails it halves the output length, then falls "
325
+ "back to the 3B model. Your session and its checkpoints are intact."
326
+ )
coagent/py.typed ADDED
File without changes
@@ -0,0 +1,24 @@
1
+ """Safety: redaction, denylist, and the approval matrix (DESIGN §9)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .denylist import DEFAULT_DENIED, Denial, Denylist, deny_reasons
6
+ from .policy import Decision, Policy, RiskClass, SafetyClass, Verdict
7
+ from .redact import MASK, Redactor, contains_secret, redact, redact_obj
8
+
9
+ __all__ = [
10
+ "DEFAULT_DENIED",
11
+ "MASK",
12
+ "Decision",
13
+ "Denial",
14
+ "Denylist",
15
+ "Policy",
16
+ "Redactor",
17
+ "RiskClass",
18
+ "SafetyClass",
19
+ "Verdict",
20
+ "contains_secret",
21
+ "deny_reasons",
22
+ "redact",
23
+ "redact_obj",
24
+ ]
@@ -0,0 +1,146 @@
1
+ """Denylist of destructive commands (PRD FR-TOL-5, FR-GIT-9).
2
+
3
+ This is a seatbelt, not a sandbox — the reality of a root-ish Colab shell.
4
+ The real control is the approval matrix in `policy.py`. What this module does
5
+ is evaluate a command *before* anything runs, deny rules before allow rules,
6
+ and return an actionable reason.
7
+
8
+ Matching strategy: a permissive tokenizer (shlex) so `git push --force` and
9
+ `git push --force=true` and `git push -f` are all caught, but a commit message
10
+ quoting `reset --hard` is not.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import shlex
16
+ from dataclasses import dataclass
17
+
18
+ __all__ = ["Denial", "Denylist", "DEFAULT_DENIED", "deny_reasons"]
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Denial:
23
+ rule: str
24
+ reason: str
25
+
26
+
27
+ # Pattern: token sequence of a dangerous command. Tokens are matched in order
28
+ # and may skip over "=" joins: `git push --force` and `git push --force=true`.
29
+ _RULES: tuple[tuple[str, tuple[str, ...], str], ...] = (
30
+ ("git-force-push", ("git", "push", "--force*"), "force-push rewrites remote history (FR-GIT-9)."),
31
+ ("git-force-push-short", ("git", "push", "-f"), "force-push rewrites remote history (FR-GIT-9)."),
32
+ ("git-push-main", ("git", "push", "*", "main*"), "pushing main is never allowed (FR-GIT-8)."),
33
+ ("git-reset-hard", ("git", "reset", "--hard*"), "hard reset discards work (FR-GIT-9)."),
34
+ (
35
+ "git-branch-delete",
36
+ ("git", "branch", "-D*"),
37
+ "deleting branches is destructive (FR-GIT-9).",
38
+ ),
39
+ ("git-clean-fd", ("git", "clean", "-f*"), "git clean -f deletes untracked files."),
40
+ ("rm-root", ("rm", "-rf", "/"), "rm -rf / is never valid here (FR-TOL-5)."),
41
+ ("rm-home", ("rm", "-rf", "~"), "rm -rf ~ is never valid here."),
42
+ ("chmod-root", ("chmod", "-R", "777", "/"), "chmod -R 777 / destroys a system."),
43
+ ("chmod-home", ("chmod", "-R", "777", "~"), "chmod -R 777 ~ destroys a system."),
44
+ (
45
+ "pipe-to-shell",
46
+ ("*", "|", "bash"),
47
+ "piping to a shell executes untrusted input; requires approval at a higher level.",
48
+ ),
49
+ ("curl-pipe-sh", ("curl*", "|", "sh"), "curl | sh executes remote code (FR-TOL-5)."),
50
+ ("wget-pipe-sh", ("wget*", "|", "sh"), "wget | sh executes remote code (FR-TOL-5)."),
51
+ ("dd-disk", ("dd", "*", "of=/dev/*"), "dd to a device overwrites disks."),
52
+ ("fork-bomb", ("*", ":(){", "|", "*", "&"), "fork bombs violate Colab fair use."),
53
+ ("shutdown", ("shutdown", "*"), "shutting down the VM kills the session (FR-REL-2)."),
54
+ ("reboot", ("reboot", "*"), "rebooting the VM kills the session (FR-REL-2)."),
55
+ ("rm-var", ("rm", "-rf", "/var"), "deleting /var destroys the runtime."),
56
+ ("mkfs", ("mkfs*", "*"), "mkfs formats disks."),
57
+ )
58
+
59
+
60
+ def _tokens(cmd: str) -> list[str]:
61
+ try:
62
+ return shlex.split(cmd)
63
+ except ValueError:
64
+ # Unbalanced quotes — split on whitespace instead so a denial still
65
+ # applies; the command would fail to run anyway.
66
+ return cmd.split()
67
+
68
+
69
+ def _token_matches(actual: str, rule: str) -> bool:
70
+ if rule.endswith("*") and len(rule) > 1:
71
+ prefix = rule[:-1]
72
+ if actual.startswith(prefix):
73
+ return True
74
+ # Flags are case-sensitive in git, but `-d` (merged-only delete) is the
75
+ # common lowercase slip; treat any `-d`/`-D` variant as destructive.
76
+ if prefix == "-D":
77
+ return actual.startswith("-d") or actual.startswith("-D")
78
+ return False
79
+ if rule == "*":
80
+ return True
81
+ return actual == rule
82
+
83
+
84
+ DEFAULT_DENIED: tuple[str, ...] = tuple(rule[0] for rule in _RULES)
85
+
86
+
87
+ def deny_reasons(cmd: str, rules: tuple[str, ...] = DEFAULT_DENIED) -> tuple[Denial, ...]:
88
+ """Return every denial that applies to `cmd`.
89
+
90
+ Deny rules are evaluated before allow rules (PRD FR-GIT-9): a force-push to
91
+ a coagent/* ref is still denied.
92
+ """
93
+ tokens = _tokens(cmd)
94
+ if not tokens:
95
+ return ()
96
+ reasons: list[Denial] = []
97
+ for rule_id, pattern, reason in _RULES:
98
+ if rule_id not in rules:
99
+ continue
100
+ if _match_pattern(tokens, pattern):
101
+ reasons.append(Denial(rule=rule_id, reason=reason))
102
+ return tuple(reasons)
103
+
104
+
105
+ def _match_pattern(tokens: list[str], pattern: tuple[str, ...]) -> bool:
106
+ """Sequence match with wildcards.
107
+
108
+ * a trailing `*` on a rule token is a prefix match, so `--force=true`
109
+ matches the rule `--force*`;
110
+ * the rule token `"*"` matches exactly one token;
111
+ * the rule token `"|"` means the *rest* of the pattern must match
112
+ somewhere later in the token list (a shell pipe).
113
+ """
114
+ if not tokens or not pattern:
115
+ return False
116
+
117
+ def match_from(tok_i: int, pat_i: int) -> bool:
118
+ while pat_i < len(pattern):
119
+ rule = pattern[pat_i]
120
+ if rule == "|":
121
+ rest = pattern[pat_i + 1 :]
122
+ # A pipe: try every later token as the start of the rest.
123
+ for start in range(tok_i, len(tokens)):
124
+ if match_from(start, pat_i + 1) if rest else True:
125
+ return True
126
+ return False
127
+ if tok_i >= len(tokens):
128
+ return False
129
+ if not _token_matches(tokens[tok_i], rule):
130
+ return False
131
+ tok_i += 1
132
+ pat_i += 1
133
+ return True
134
+
135
+ return match_from(0, 0)
136
+
137
+
138
+ class Denylist:
139
+ """Holds the configurable deny rules for one session."""
140
+
141
+ def __init__(self, rules: tuple[str, ...] | list[str] | None = None) -> None:
142
+ self.rules: tuple[str, ...] = tuple(rules) if rules is not None else DEFAULT_DENIED
143
+
144
+ def check(self, cmd: str) -> Denial | None:
145
+ reasons = deny_reasons(cmd, self.rules)
146
+ return reasons[0] if reasons else None