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/cli.py
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""`coagent` command-line interface.
|
|
2
|
+
|
|
3
|
+
v0.1 scope: `coagent init`, `coagent doctor`, `coagent config`. The run/resume/
|
|
4
|
+
eval verbs land with the loop in milestone M1.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import platform
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .config import ConfigError, load_config, write_default_config
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
19
|
+
try:
|
|
20
|
+
target = args.path or "."
|
|
21
|
+
path = write_default_config(Path(target), force=args.force)
|
|
22
|
+
except ConfigError as exc:
|
|
23
|
+
print(f"coagent: {exc}", file=sys.stderr)
|
|
24
|
+
return 1
|
|
25
|
+
print(f"wrote {path}")
|
|
26
|
+
added = _ensure_gitignore(Path(target))
|
|
27
|
+
if added:
|
|
28
|
+
print(f"added {len(added)} entr{'y' if len(added) == 1 else 'ies'} to .gitignore: {', '.join(added)}")
|
|
29
|
+
print("commit them so every session uses the same settings:")
|
|
30
|
+
print(' git add .coagent/config.toml .gitignore && git commit -m "chore: add coagent config"')
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_COAGENT_IGNORES = (
|
|
35
|
+
".coagent/tmp/",
|
|
36
|
+
".coagent/lock.json",
|
|
37
|
+
".coagent/sessions/",
|
|
38
|
+
".coagent/bundles/",
|
|
39
|
+
".coagent/cache/",
|
|
40
|
+
".coagent/secrets*",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_gitignore(root: Path) -> list[str]:
|
|
45
|
+
"""Add coAgent runtime ignores to a repo's .gitignore.
|
|
46
|
+
|
|
47
|
+
`coagent init` is an explicit setup action, so editing `.gitignore` here is
|
|
48
|
+
expected rather than a surprise. The session path warns instead of editing
|
|
49
|
+
(DESIGN §11).
|
|
50
|
+
"""
|
|
51
|
+
ignore = root / ".gitignore"
|
|
52
|
+
try:
|
|
53
|
+
existing = ignore.read_text(encoding="utf-8") if ignore.is_file() else ""
|
|
54
|
+
except OSError:
|
|
55
|
+
return []
|
|
56
|
+
added = [entry for entry in _COAGENT_IGNORES if entry not in existing]
|
|
57
|
+
if not added:
|
|
58
|
+
return []
|
|
59
|
+
block = "\n# coAgent runtime state (config.toml and plans/ are committed)\n" + "\n".join(added) + "\n"
|
|
60
|
+
try:
|
|
61
|
+
with ignore.open("a", encoding="utf-8") as fh:
|
|
62
|
+
if existing and not existing.endswith("\n"):
|
|
63
|
+
fh.write("\n")
|
|
64
|
+
fh.write(block)
|
|
65
|
+
except OSError:
|
|
66
|
+
return []
|
|
67
|
+
return added
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _cmd_resume(args: argparse.Namespace) -> int:
|
|
71
|
+
from .gitx import GitError, SessionError, build_briefing, open_session
|
|
72
|
+
|
|
73
|
+
repo_path = Path(args.repo or ".")
|
|
74
|
+
try:
|
|
75
|
+
session = open_session(repo_path, session_id=args.session, force=args.force)
|
|
76
|
+
except (SessionError, GitError) as exc:
|
|
77
|
+
print(f"coagent: {exc}", file=sys.stderr)
|
|
78
|
+
return 1
|
|
79
|
+
briefing = build_briefing(session.repo, session.session_id, session.branch)
|
|
80
|
+
# A briefing is read-only: never leave a lock behind from a diagnostic.
|
|
81
|
+
session.close()
|
|
82
|
+
print(briefing.render())
|
|
83
|
+
if args.json:
|
|
84
|
+
import json
|
|
85
|
+
|
|
86
|
+
print(
|
|
87
|
+
json.dumps(
|
|
88
|
+
{
|
|
89
|
+
"session": briefing.session_id,
|
|
90
|
+
"branch": briefing.branch,
|
|
91
|
+
"next_step": briefing.next_step,
|
|
92
|
+
"committed_steps": briefing.committed_steps,
|
|
93
|
+
"trailers": briefing.trailers,
|
|
94
|
+
"plan": str(briefing.plan_path) if briefing.plan_path else None,
|
|
95
|
+
"open_items": briefing.open_items,
|
|
96
|
+
"dirty_paths": briefing.dirty_paths,
|
|
97
|
+
},
|
|
98
|
+
indent=2,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _cmd_doctor(args: argparse.Namespace) -> int:
|
|
105
|
+
cfg = load_config(args.config)
|
|
106
|
+
print(f"coagent {__version__} (python {platform.python_version()}, {platform.system()})")
|
|
107
|
+
source = cfg.source or "(defaults — no .coagent/config.toml found)"
|
|
108
|
+
print(f"config: {source}")
|
|
109
|
+
print(f"model: {cfg.model.name} @ {cfg.model.quant}, ctx {cfg.model.max_context}")
|
|
110
|
+
print(f"verify: {cfg.verify.commands}")
|
|
111
|
+
print(f"durable: {cfg.durability.mode} -> {cfg.durability.remote}")
|
|
112
|
+
try:
|
|
113
|
+
import torch # noqa: F401
|
|
114
|
+
except ImportError:
|
|
115
|
+
torch = None # type: ignore[assignment]
|
|
116
|
+
if torch is None:
|
|
117
|
+
print("torch: not installed (install with `pip install coagent[llm]` in Colab)")
|
|
118
|
+
return 0
|
|
119
|
+
if not torch.cuda.is_available():
|
|
120
|
+
print("GPU: none detected — coAgent runs on a free Colab T4 (PRD D-1)")
|
|
121
|
+
return 0
|
|
122
|
+
props = torch.cuda.get_device_properties(0)
|
|
123
|
+
name = props.name.replace("NVIDIA ", "")
|
|
124
|
+
print(f"GPU: {name} · {props.total_memory / 1024**3:.0f} GB · CC {props.major}.{props.minor}")
|
|
125
|
+
if "T4" not in name or props.major < 7 or props.total_memory < 14 * 1024**3:
|
|
126
|
+
print("verdict: NOT a free-tier T4 envelope — preflight will refuse unless --allow-unsupported")
|
|
127
|
+
else:
|
|
128
|
+
print("verdict: OK for the coAgent T4 envelope")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _cmd_config(args: argparse.Namespace) -> int:
|
|
133
|
+
cfg = load_config(args.config)
|
|
134
|
+
if args.path:
|
|
135
|
+
print(cfg.source or "no .coagent/config.toml found")
|
|
136
|
+
elif args.effective:
|
|
137
|
+
print(cfg.to_toml())
|
|
138
|
+
else:
|
|
139
|
+
print(cfg.source or "no .coagent/config.toml found (using defaults)")
|
|
140
|
+
print(cfg.to_toml())
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cmd_tools(args: argparse.Namespace) -> int:
|
|
145
|
+
"""Print the tool schemas for a phase — the exact contract the model sees."""
|
|
146
|
+
|
|
147
|
+
from .tools import PHASE_SAFETY, build_registry
|
|
148
|
+
|
|
149
|
+
cfg = load_config(args.config)
|
|
150
|
+
repo = Path(args.repo or ".").resolve()
|
|
151
|
+
if not repo.is_dir():
|
|
152
|
+
print(f"coagent: {repo} is not a directory", file=sys.stderr)
|
|
153
|
+
return 1
|
|
154
|
+
try:
|
|
155
|
+
if args.phase == "all":
|
|
156
|
+
for phase in PHASE_SAFETY:
|
|
157
|
+
names = build_registry(repo, cfg, phase=phase).names()
|
|
158
|
+
label = ", ".join(names) if names else "(none - plan cannot act)"
|
|
159
|
+
print(f"{phase:8s} ({len(names)}): {label}")
|
|
160
|
+
return 0
|
|
161
|
+
reg = build_registry(repo, cfg, phase=args.phase)
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
print(f"coagent: {exc}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
if args.json:
|
|
166
|
+
print(reg.schemas_json())
|
|
167
|
+
return 0
|
|
168
|
+
print(f"phase {args.phase}: {len(reg)} tool(s)")
|
|
169
|
+
for schema in reg.schemas():
|
|
170
|
+
params = schema["parameters"]
|
|
171
|
+
required = set(params.get("required", []))
|
|
172
|
+
args_desc = ", ".join(
|
|
173
|
+
name if name in required else f"{name}?" for name in params["properties"]
|
|
174
|
+
)
|
|
175
|
+
print(f" {schema['name']:12s} [{schema['safety']:5s}] ({args_desc})")
|
|
176
|
+
print(f" {schema['description']}")
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _cmd_models(args: argparse.Namespace) -> int:
|
|
181
|
+
from .llm import MODELS, preflight_gpu
|
|
182
|
+
|
|
183
|
+
cfg = load_config(args.config)
|
|
184
|
+
preflight = preflight_gpu() if args.gpu else None
|
|
185
|
+
if preflight is not None:
|
|
186
|
+
print(f"gpu: {preflight.describe()}")
|
|
187
|
+
print(f"verdict: {'OK' if preflight.ok else 'REFUSED'}")
|
|
188
|
+
if not preflight.ok:
|
|
189
|
+
print(f"reason: {preflight.reason}")
|
|
190
|
+
print()
|
|
191
|
+
print(f"configured: {cfg.model.name} @ {cfg.model.quant} · ctx {cfg.model.max_context}")
|
|
192
|
+
print()
|
|
193
|
+
for spec in MODELS.values():
|
|
194
|
+
mark = "*" if spec.name == cfg.model.name else " "
|
|
195
|
+
print(f" {mark} {spec.key:15s} {spec.trailer_id:40s} ctx {spec.max_context:>6d} ~{spec.vram_gb:.1f} GB")
|
|
196
|
+
if spec.notes:
|
|
197
|
+
print(f" {spec.notes}")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _cmd_run(args: argparse.Namespace) -> int:
|
|
202
|
+
"""survey -> plan -> act -> verify -> checkpoint -> report, from the terminal."""
|
|
203
|
+
from .agent import coAgent as Agent
|
|
204
|
+
|
|
205
|
+
cfg = load_config(args.config)
|
|
206
|
+
repo = Path(args.repo or ".").resolve()
|
|
207
|
+
if not repo.is_dir():
|
|
208
|
+
print(f"coagent: {repo} is not a directory", file=sys.stderr)
|
|
209
|
+
return 1
|
|
210
|
+
|
|
211
|
+
backend = None
|
|
212
|
+
if args.dry_run:
|
|
213
|
+
# A scripted model: proves the plumbing without a GPU or a download.
|
|
214
|
+
from .llm import FakeBackend
|
|
215
|
+
|
|
216
|
+
backend = FakeBackend(_dry_run_script(args.task))
|
|
217
|
+
|
|
218
|
+
def on_event(kind: str, data: dict) -> None:
|
|
219
|
+
if kind == "tool":
|
|
220
|
+
mark = "ok " if data.get("ok") else "FAIL"
|
|
221
|
+
print(f" {mark} {data.get('tool')}")
|
|
222
|
+
elif kind == "plan":
|
|
223
|
+
print(f" plan {data.get('title')} ({len(data.get('steps', []))} steps)")
|
|
224
|
+
elif kind == "commit":
|
|
225
|
+
print(f" commit {str(data.get('sha'))[:8]} step {data.get('step')}")
|
|
226
|
+
elif kind == "sync":
|
|
227
|
+
print(f" sync {'ok' if data.get('ok') else 'warn'}: {data.get('message')}")
|
|
228
|
+
elif kind == "verify":
|
|
229
|
+
print(f" verify {'passed' if data.get('ok') else 'failed'}")
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
agent = Agent(
|
|
233
|
+
repo,
|
|
234
|
+
config=cfg,
|
|
235
|
+
backend=backend,
|
|
236
|
+
auto=args.auto or args.dry_run,
|
|
237
|
+
interactive=not args.auto,
|
|
238
|
+
token=None,
|
|
239
|
+
on_event=on_event,
|
|
240
|
+
force=args.force,
|
|
241
|
+
allow_unsupported=args.allow_unsupported,
|
|
242
|
+
)
|
|
243
|
+
except Exception as exc: # noqa: BLE001 - every refusal is a message, not a traceback
|
|
244
|
+
print(f"coagent: {exc}", file=sys.stderr)
|
|
245
|
+
return 1
|
|
246
|
+
|
|
247
|
+
print(agent.status_line())
|
|
248
|
+
try:
|
|
249
|
+
result = agent.run(args.task)
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
print("coagent: interrupted", file=sys.stderr)
|
|
252
|
+
return 130
|
|
253
|
+
finally:
|
|
254
|
+
agent.close()
|
|
255
|
+
|
|
256
|
+
print()
|
|
257
|
+
print(result.render())
|
|
258
|
+
return 0 if result.done else 2
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _dry_run_script(task: str) -> list[str]:
|
|
262
|
+
"""A scripted session so `--dry-run` exercises the real loop.
|
|
263
|
+
|
|
264
|
+
Deliberately ends without a further action: the run should stop cleanly on
|
|
265
|
+
"no more actions" rather than trip the loop detector, so the output shows a
|
|
266
|
+
normal early exit rather than a failure.
|
|
267
|
+
"""
|
|
268
|
+
return [
|
|
269
|
+
"Surveyed the repository map. This is a dry run, so no model is loaded.",
|
|
270
|
+
"# dry run\n"
|
|
271
|
+
"- [ ] list the repository root\n"
|
|
272
|
+
"- [ ] run the configured checks",
|
|
273
|
+
'```json\n{"tool": "list_dir", "args": {"path": "."}}\n```',
|
|
274
|
+
# FakeBackend repeats its final response, so the script must say "stop"
|
|
275
|
+
# explicitly instead of relying on the queue running dry.
|
|
276
|
+
"The dry run is complete; no further action is needed.",
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
281
|
+
parser = argparse.ArgumentParser(
|
|
282
|
+
prog="coagent",
|
|
283
|
+
description="Private AI coding agent for the free Google Colab T4.",
|
|
284
|
+
)
|
|
285
|
+
parser.add_argument("--version", action="version", version=f"coagent {__version__}")
|
|
286
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
287
|
+
|
|
288
|
+
p_init = sub.add_parser("init", help="write a default .coagent/config.toml")
|
|
289
|
+
p_init.add_argument("path", nargs="?", help="repo root (default: current directory)")
|
|
290
|
+
p_init.add_argument("--force", action="store_true", help="overwrite an existing config")
|
|
291
|
+
p_init.set_defaults(func=_cmd_init)
|
|
292
|
+
|
|
293
|
+
p_doctor = sub.add_parser("doctor", help="report versions, GPU, cache state, verdict")
|
|
294
|
+
p_doctor.add_argument("--config", help="path to a config.toml (default: search upward)")
|
|
295
|
+
p_doctor.set_defaults(func=_cmd_doctor)
|
|
296
|
+
|
|
297
|
+
p_config = sub.add_parser("config", help="print the effective config")
|
|
298
|
+
p_config.add_argument("--config", help="path to a config.toml (default: search upward)")
|
|
299
|
+
p_config.add_argument("--path", action="store_true", help="print only the config path")
|
|
300
|
+
p_config.add_argument("--effective", action="store_true", help="print effective values as TOML")
|
|
301
|
+
p_config.set_defaults(func=_cmd_config)
|
|
302
|
+
|
|
303
|
+
p_tools = sub.add_parser("tools", help="list the tool schemas available per phase")
|
|
304
|
+
p_tools.add_argument("--config", help="path to a config.toml (default: search upward)")
|
|
305
|
+
p_tools.add_argument("--repo", help="repo root to jail against (default: cwd)")
|
|
306
|
+
p_tools.add_argument(
|
|
307
|
+
"--phase",
|
|
308
|
+
default="act",
|
|
309
|
+
choices=["survey", "plan", "act", "verify", "report", "all"],
|
|
310
|
+
help="phase to inspect (default: act)",
|
|
311
|
+
)
|
|
312
|
+
p_tools.add_argument("--json", action="store_true", help="print raw JSON schemas")
|
|
313
|
+
p_tools.set_defaults(func=_cmd_tools)
|
|
314
|
+
|
|
315
|
+
p_resume = sub.add_parser("resume", help="print the resume briefing for a session")
|
|
316
|
+
p_resume.add_argument("--repo", help="repo root (default: cwd)")
|
|
317
|
+
p_resume.add_argument("--session", help="session id to resume (default: from the branch)")
|
|
318
|
+
p_resume.add_argument("--force", action="store_true", help="take over a live lock")
|
|
319
|
+
p_resume.add_argument("--json", action="store_true", help="print the briefing as JSON")
|
|
320
|
+
p_resume.set_defaults(func=_cmd_resume)
|
|
321
|
+
|
|
322
|
+
p_models = sub.add_parser("models", help="list the validated models and check the GPU envelope")
|
|
323
|
+
p_models.add_argument("--config", help="path to a config.toml (default: search upward)")
|
|
324
|
+
p_models.add_argument("--gpu", action="store_true", help="run the T4 preflight check")
|
|
325
|
+
p_models.set_defaults(func=_cmd_models)
|
|
326
|
+
|
|
327
|
+
p_run = sub.add_parser("run", help="run one task: survey, plan, act, verify, report")
|
|
328
|
+
p_run.add_argument("task", help="what to do, in one sentence")
|
|
329
|
+
p_run.add_argument("--repo", help="repo root (default: cwd)")
|
|
330
|
+
p_run.add_argument("--config", help="path to a config.toml (default: search upward)")
|
|
331
|
+
p_run.add_argument("--auto", action="store_true", help="pre-approve the plan")
|
|
332
|
+
p_run.add_argument("--dry-run", action="store_true", help="use a scripted model (no GPU)")
|
|
333
|
+
p_run.add_argument("--force", action="store_true", help="take over a live session lock")
|
|
334
|
+
p_run.add_argument(
|
|
335
|
+
"--allow-unsupported",
|
|
336
|
+
action="store_true",
|
|
337
|
+
help="run on non-T4 hardware (unsupported, unvalidated)",
|
|
338
|
+
)
|
|
339
|
+
p_run.set_defaults(func=_cmd_run)
|
|
340
|
+
|
|
341
|
+
return parser
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def main(argv: list[str] | None = None) -> int:
|
|
345
|
+
parser = build_parser()
|
|
346
|
+
args = parser.parse_args(argv)
|
|
347
|
+
return args.func(args)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
if __name__ == "__main__":
|
|
351
|
+
raise SystemExit(main())
|
coagent/config.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Configuration: load, validate, and version-check `.coagent/config.toml`.
|
|
2
|
+
|
|
3
|
+
The config is committed to the target repo, so it must be reproducible and
|
|
4
|
+
refuse to run when its schema is newer than this package understands
|
|
5
|
+
(PRD FR-DIS-5).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
from dataclasses import dataclass, field, fields, is_dataclass
|
|
12
|
+
from importlib import resources
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 11):
|
|
17
|
+
import tomllib
|
|
18
|
+
else: # pragma: no cover - Colab is 3.11+, this is for laptop dev
|
|
19
|
+
import tomli as tomllib
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
CONFIG_RELPATH = Path(".coagent") / "config.toml"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AgentConfig",
|
|
26
|
+
"ArtifactsConfig",
|
|
27
|
+
"ConfigError",
|
|
28
|
+
"Config",
|
|
29
|
+
"DurabilityConfig",
|
|
30
|
+
"GitConfig",
|
|
31
|
+
"ModelConfig",
|
|
32
|
+
"SafetyConfig",
|
|
33
|
+
"ToolsConfig",
|
|
34
|
+
"UIConfig",
|
|
35
|
+
"VerifyConfig",
|
|
36
|
+
"default_config_text",
|
|
37
|
+
"find_config",
|
|
38
|
+
"load_config",
|
|
39
|
+
"write_default_config",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConfigError(RuntimeError):
|
|
44
|
+
"""Raised when the config is missing, malformed, or an unsupported schema."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(slots=True)
|
|
48
|
+
class ModelConfig:
|
|
49
|
+
name: str = "Qwen/Qwen2.5-Coder-7B-Instruct"
|
|
50
|
+
quant: str = "nf4"
|
|
51
|
+
max_context: int = 12288
|
|
52
|
+
max_new_tokens: int = 1024
|
|
53
|
+
temperature_edit: float = 0.2
|
|
54
|
+
temperature_plan: float = 0.7
|
|
55
|
+
top_p: float = 0.95
|
|
56
|
+
seed: int = 0 # 0 = pick a random seed at session start and log it
|
|
57
|
+
constrained: bool = False
|
|
58
|
+
fallback_name: str = "Qwen/Qwen2.5-Coder-3B-Instruct"
|
|
59
|
+
fallback_max_context: int = 8192
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if self.max_context > 12288:
|
|
63
|
+
# PRD D2: the cap is hard, not advisory.
|
|
64
|
+
raise ConfigError(
|
|
65
|
+
f"model.max_context={self.max_context} exceeds the hard cap of 12288 "
|
|
66
|
+
"for the free T4 envelope (PRD D2)."
|
|
67
|
+
)
|
|
68
|
+
if self.quant not in {"nf4", "fp16"}:
|
|
69
|
+
raise ConfigError(f"model.quant must be 'nf4' or 'fp16', got {self.quant!r}.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(slots=True)
|
|
73
|
+
class VerifyConfig:
|
|
74
|
+
commands: list[str] = field(default_factory=lambda: ["ruff check .", "pytest -q"])
|
|
75
|
+
gpu_commands: list[str] = field(default_factory=list)
|
|
76
|
+
timeout_s: int = 300
|
|
77
|
+
vram_reserve_mb: int = 1024
|
|
78
|
+
require_tests: bool = True
|
|
79
|
+
feedback_max_tokens: int = 1500
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(slots=True)
|
|
83
|
+
class GitConfig:
|
|
84
|
+
author_name: str = "coAgent"
|
|
85
|
+
author_email: str = "coagent@users.noreply.github.com"
|
|
86
|
+
remotes: list[str] = field(default_factory=lambda: ["origin"])
|
|
87
|
+
clone_depth: int = 1
|
|
88
|
+
push_ref_prefix: str = "refs/heads/coagent/"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class DurabilityConfig:
|
|
93
|
+
mode: str = "push"
|
|
94
|
+
remote: str = "origin"
|
|
95
|
+
heartbeat_s: int = 180
|
|
96
|
+
|
|
97
|
+
def __post_init__(self) -> None:
|
|
98
|
+
if self.mode not in {"push", "bundle"}:
|
|
99
|
+
raise ConfigError(f"durability.mode must be 'push' or 'bundle', got {self.mode!r}.")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(slots=True)
|
|
103
|
+
class AgentConfig:
|
|
104
|
+
max_steps: int = 60
|
|
105
|
+
max_wall_s: int = 2700
|
|
106
|
+
max_tokens: int = 200_000
|
|
107
|
+
max_gpu_min: int = 40
|
|
108
|
+
max_commits: int = 40
|
|
109
|
+
max_consecutive_failures: int = 3
|
|
110
|
+
replan_after_repeats: int = 3
|
|
111
|
+
stop_after_repeats: int = 5
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(slots=True)
|
|
115
|
+
class ToolsConfig:
|
|
116
|
+
allow_read: list[str] = field(default_factory=list)
|
|
117
|
+
max_output_bytes: int = 65536
|
|
118
|
+
shell_timeout_s: int = 120
|
|
119
|
+
notebook_timeout_s: int = 300
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(slots=True)
|
|
123
|
+
class ArtifactsConfig:
|
|
124
|
+
commit_under_mb: int = 5
|
|
125
|
+
store: str = "drive"
|
|
126
|
+
drive_dir: str = "coagent/artifacts"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class SafetyConfig:
|
|
131
|
+
require_approval: list[str] = field(
|
|
132
|
+
default_factory=lambda: ["network", "install", "delete", "write_outside_repo"]
|
|
133
|
+
)
|
|
134
|
+
deny: list[str] = field(default_factory=list)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(slots=True)
|
|
138
|
+
class ContextConfig:
|
|
139
|
+
"""Token slots for the 12k window (DESIGN §5.1).
|
|
140
|
+
|
|
141
|
+
Only the repo map slot is configurable so far; the rest of the ledger is
|
|
142
|
+
still assembled by the loop. Keeping the default equal to the design table
|
|
143
|
+
means an unconfigured repo behaves as specified.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
repo_map_tokens: int = 2000
|
|
147
|
+
retrieved_tokens: int = 3000
|
|
148
|
+
max_files: int = 800
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
if self.repo_map_tokens < 200:
|
|
152
|
+
raise ConfigError(
|
|
153
|
+
f"context.repo_map_tokens={self.repo_map_tokens} is too small to be useful; "
|
|
154
|
+
"use at least 200 (DESIGN §5.1 gives the map 2000)."
|
|
155
|
+
)
|
|
156
|
+
if self.max_files < 1:
|
|
157
|
+
raise ConfigError(f"context.max_files must be >= 1, got {self.max_files}.")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(slots=True)
|
|
161
|
+
class UIConfig:
|
|
162
|
+
mode: str = "show"
|
|
163
|
+
gradio: bool = False
|
|
164
|
+
gradio_port: int = 7860
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(slots=True)
|
|
168
|
+
class Config:
|
|
169
|
+
"""The whole `.coagent/config.toml`, parsed and validated."""
|
|
170
|
+
|
|
171
|
+
schema_version: int = SCHEMA_VERSION
|
|
172
|
+
model: ModelConfig = field(default_factory=ModelConfig)
|
|
173
|
+
verify: VerifyConfig = field(default_factory=VerifyConfig)
|
|
174
|
+
git: GitConfig = field(default_factory=GitConfig)
|
|
175
|
+
durability: DurabilityConfig = field(default_factory=DurabilityConfig)
|
|
176
|
+
agent: AgentConfig = field(default_factory=AgentConfig)
|
|
177
|
+
tools: ToolsConfig = field(default_factory=ToolsConfig)
|
|
178
|
+
artifacts: ArtifactsConfig = field(default_factory=ArtifactsConfig)
|
|
179
|
+
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
180
|
+
context: ContextConfig = field(default_factory=ContextConfig)
|
|
181
|
+
ui: UIConfig = field(default_factory=UIConfig)
|
|
182
|
+
source: Path | None = None
|
|
183
|
+
|
|
184
|
+
def to_toml(self) -> str:
|
|
185
|
+
return _dump_toml(self)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
_SECTIONS: dict[str, type] = {
|
|
189
|
+
"model": ModelConfig,
|
|
190
|
+
"verify": VerifyConfig,
|
|
191
|
+
"git": GitConfig,
|
|
192
|
+
"durability": DurabilityConfig,
|
|
193
|
+
"agent": AgentConfig,
|
|
194
|
+
"tools": ToolsConfig,
|
|
195
|
+
"artifacts": ArtifactsConfig,
|
|
196
|
+
"safety": SafetyConfig,
|
|
197
|
+
"context": ContextConfig,
|
|
198
|
+
"ui": UIConfig,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _build_section(name: str, cls: type, data: Any) -> Any:
|
|
203
|
+
if not isinstance(data, dict):
|
|
204
|
+
raise ConfigError(f"[{name}] must be a table, got {type(data).__name__}.")
|
|
205
|
+
known = {f.name for f in fields(cls)}
|
|
206
|
+
unknown = set(data) - known
|
|
207
|
+
if unknown:
|
|
208
|
+
raise ConfigError(
|
|
209
|
+
f"[{name}] has unknown key(s): {', '.join(sorted(unknown))}. "
|
|
210
|
+
f"Known keys: {', '.join(sorted(known))}."
|
|
211
|
+
)
|
|
212
|
+
return cls(**data)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def load_config(path: str | Path | None = None, *, start: str | Path | None = None) -> Config:
|
|
216
|
+
"""Load and validate a config file.
|
|
217
|
+
|
|
218
|
+
With no `path`, searches upward from `start` (default: cwd) for
|
|
219
|
+
`.coagent/config.toml`. A missing file yields defaults so that
|
|
220
|
+
`coagent doctor` can run before the repo is initialised.
|
|
221
|
+
"""
|
|
222
|
+
resolved = Path(path) if path is not None else find_config(start)
|
|
223
|
+
if resolved is None:
|
|
224
|
+
return Config()
|
|
225
|
+
try:
|
|
226
|
+
raw = tomllib.loads(resolved.read_text(encoding="utf-8"))
|
|
227
|
+
except OSError as exc:
|
|
228
|
+
raise ConfigError(f"cannot read {resolved}: {exc}") from exc
|
|
229
|
+
except tomllib.TOMLDecodeError as exc:
|
|
230
|
+
raise ConfigError(f"{resolved} is not valid TOML: {exc}") from exc
|
|
231
|
+
|
|
232
|
+
version = raw.pop("schema_version", None)
|
|
233
|
+
if version is None:
|
|
234
|
+
raise ConfigError(f"{resolved} is missing schema_version (expected {SCHEMA_VERSION}).")
|
|
235
|
+
if not isinstance(version, int):
|
|
236
|
+
raise ConfigError(f"schema_version must be an integer, got {version!r}.")
|
|
237
|
+
if version > SCHEMA_VERSION:
|
|
238
|
+
raise ConfigError(
|
|
239
|
+
f"{resolved} uses schema_version {version}, but this coagent build "
|
|
240
|
+
f"understands {SCHEMA_VERSION}. Upgrade coagent, or regenerate the config "
|
|
241
|
+
"with `coagent init`."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
cfg = Config(schema_version=version, source=resolved)
|
|
245
|
+
for key, value in raw.items():
|
|
246
|
+
cls = _SECTIONS.get(key)
|
|
247
|
+
if cls is None:
|
|
248
|
+
raise ConfigError(f"unknown top-level section [{key}] in {resolved}.")
|
|
249
|
+
setattr(cfg, key, _build_section(key, cls, value))
|
|
250
|
+
return cfg
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def find_config(start: str | Path | None = None) -> Path | None:
|
|
254
|
+
"""Walk upward from `start` looking for `.coagent/config.toml`."""
|
|
255
|
+
current = Path(start or Path.cwd()).resolve()
|
|
256
|
+
for candidate in (current, *current.parents):
|
|
257
|
+
target = candidate / CONFIG_RELPATH
|
|
258
|
+
if target.is_file():
|
|
259
|
+
return target
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def default_config_text() -> str:
|
|
264
|
+
"""The shipped template, with its comments intact."""
|
|
265
|
+
return resources.files("coagent.templates").joinpath("config.toml").read_text(encoding="utf-8")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def write_default_config(repo_root: str | Path, *, force: bool = False) -> Path:
|
|
269
|
+
"""Write `.coagent/config.toml` into `repo_root`."""
|
|
270
|
+
target = Path(repo_root) / CONFIG_RELPATH
|
|
271
|
+
if target.exists() and not force:
|
|
272
|
+
raise ConfigError(f"{target} already exists; pass force=True to overwrite.")
|
|
273
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
target.write_text(default_config_text(), encoding="utf-8")
|
|
275
|
+
return target
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _dump_toml(cfg: Config) -> str:
|
|
279
|
+
"""Minimal TOML writer (no dependency); enough for round-tripping a Config."""
|
|
280
|
+
lines = [f"schema_version = {cfg.schema_version}", ""]
|
|
281
|
+
for name, cls in _SECTIONS.items():
|
|
282
|
+
section = getattr(cfg, name)
|
|
283
|
+
lines.append(f"[{name}]")
|
|
284
|
+
for f in fields(cls):
|
|
285
|
+
value = getattr(section, f.name)
|
|
286
|
+
lines.append(f"{f.name} = {_toml_value(value)}")
|
|
287
|
+
lines.append("")
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _toml_value(value: Any) -> str:
|
|
292
|
+
if isinstance(value, bool):
|
|
293
|
+
return "true" if value else "false"
|
|
294
|
+
if value is None:
|
|
295
|
+
return '0' # TOML has no null; only used defensively
|
|
296
|
+
if isinstance(value, (int, float)):
|
|
297
|
+
return repr(value)
|
|
298
|
+
if isinstance(value, str):
|
|
299
|
+
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
300
|
+
if isinstance(value, (list, tuple)):
|
|
301
|
+
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
|
|
302
|
+
raise ConfigError(f"cannot serialise {type(value).__name__} to TOML")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def is_dataclass_config(obj: Any) -> bool:
|
|
306
|
+
"""Small helper used by tests and the doctor command."""
|
|
307
|
+
return is_dataclass(obj)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Context layer: repo map, token budget, truncation (DESIGN §5).
|
|
2
|
+
|
|
3
|
+
Context is the scarcest resource on a 12k window, so it gets its own ledger
|
|
4
|
+
rather than being assembled ad hoc at each call site.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .repo_map import DEFAULT_MAX_TOKENS, FileMap, RepoMap, RepoMapConfig, Signature
|
|
10
|
+
from .retrieval import Chunk, Recalled, RetrievalConfig, Retriever
|
|
11
|
+
from .tokens import estimate_tokens, fit, truncate_middle
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"DEFAULT_MAX_TOKENS",
|
|
15
|
+
"Chunk",
|
|
16
|
+
"FileMap",
|
|
17
|
+
"Recalled",
|
|
18
|
+
"RepoMap",
|
|
19
|
+
"RepoMapConfig",
|
|
20
|
+
"RetrievalConfig",
|
|
21
|
+
"Retriever",
|
|
22
|
+
"Signature",
|
|
23
|
+
"estimate_tokens",
|
|
24
|
+
"fit",
|
|
25
|
+
"truncate_middle",
|
|
26
|
+
]
|