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/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
+ ]