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,219 @@
1
+ """Policy: the approval matrix, enforced in code (PRD FR-TOL-4, DESIGN §9.1).
2
+
3
+ Two things live here:
4
+
5
+ * `SafetyClass` — how a tool is classified (read / write / exec / vcs / ui).
6
+ * `Policy.decide()` — the one function that answers "may this run?".
7
+
8
+ Rules, in order (deny before allow, per FR-GIT-9):
9
+
10
+ 1. A denylisted command is denied, always.
11
+ 2. `git push` to a ref outside `git.push_ref_prefix`, or to an unconfigured
12
+ remote, is denied.
13
+ 3. Reads inside the jail are allowed.
14
+ 4. Writes inside the repo on the session branch are allowed.
15
+ 5. Everything in `require_approval` asks in interactive mode and is **denied**
16
+ in non-interactive mode. The default flips to deny, not allow.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import enum
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from .denylist import DEFAULT_DENIED, Denial, deny_reasons
26
+
27
+ __all__ = [
28
+ "Decision",
29
+ "Policy",
30
+ "RiskClass",
31
+ "SafetyClass",
32
+ "Verdict",
33
+ ]
34
+
35
+
36
+ class SafetyClass(str, enum.Enum):
37
+ """How a tool is classified for the approval matrix."""
38
+
39
+ READ = "read"
40
+ WRITE = "write"
41
+ EXEC = "exec"
42
+ VCS = "vcs"
43
+ UI = "ui"
44
+
45
+
46
+ class RiskClass(str, enum.Enum):
47
+ """The action classes named in config `[safety] require_approval`."""
48
+
49
+ NETWORK = "network"
50
+ INSTALL = "install"
51
+ DELETE = "delete"
52
+ WRITE_OUTSIDE_REPO = "write_outside_repo"
53
+
54
+
55
+ class Verdict(str, enum.Enum):
56
+ ALLOW = "allow"
57
+ ASK = "ask"
58
+ DENY = "deny"
59
+
60
+
61
+ @dataclass(frozen=True, slots=True)
62
+ class Decision:
63
+ verdict: Verdict
64
+ reason: str
65
+ rule: str = ""
66
+
67
+ @property
68
+ def allowed(self) -> bool:
69
+ return self.verdict is Verdict.ALLOW
70
+
71
+ @property
72
+ def needs_approval(self) -> bool:
73
+ return self.verdict is Verdict.ASK
74
+
75
+
76
+ @dataclass
77
+ class Policy:
78
+ """The session's enforcement state."""
79
+
80
+ repo_root: Path
81
+ interactive: bool = True
82
+ allow_read: list[Path] = field(default_factory=list)
83
+ push_ref_prefix: str = "refs/heads/coagent/"
84
+ allowed_remotes: tuple[str, ...] = ("origin",)
85
+ require_approval: tuple[str, ...] = (
86
+ RiskClass.NETWORK.value,
87
+ RiskClass.INSTALL.value,
88
+ RiskClass.DELETE.value,
89
+ RiskClass.WRITE_OUTSIDE_REPO.value,
90
+ )
91
+ deny_rules: tuple[str, ...] = DEFAULT_DENIED
92
+ # Origins allowed to every write: the repo and the ephemeral scratch dir.
93
+ scratch: Path | None = None
94
+
95
+ def __post_init__(self) -> None:
96
+ self.repo_root = Path(self.repo_root).resolve()
97
+ self.allow_read = [Path(p).resolve() for p in self.allow_read]
98
+ if self.scratch is not None:
99
+ self.scratch = Path(self.scratch).resolve()
100
+
101
+ # ------------------------------------------------------------------ reads
102
+
103
+ def check_read(self, path: Path) -> Decision:
104
+ """Reads are jailed too (DESIGN §6.2) — after `mount_drive` an unjailed
105
+ read is access to the user's entire Drive."""
106
+ resolved = Path(path).resolve()
107
+ if self._inside(resolved, self.repo_root):
108
+ if ".git" in resolved.relative_to(self.repo_root).parts:
109
+ # .git is not readable through read_file; git tools open it directly.
110
+ return Decision(Verdict.DENY, "reading .git through the file tools is not allowed", "git-internal")
111
+ return Decision(Verdict.ALLOW, "inside the repo", "read-in-jail")
112
+ if self.scratch and self._inside(resolved, self.scratch):
113
+ return Decision(Verdict.ALLOW, "inside the scratch dir", "read-scratch")
114
+ for extra in self.allow_read:
115
+ if self._inside(resolved, extra):
116
+ return Decision(Verdict.ALLOW, "inside an allow_read entry", "read-allowed")
117
+ return Decision(
118
+ Verdict.DENY,
119
+ f"{resolved} is outside the repo jail; add it to [tools] allow_read to permit it",
120
+ "read-outside-jail",
121
+ )
122
+
123
+ # ----------------------------------------------------------------- writes
124
+
125
+ def check_write(self, path: Path) -> Decision:
126
+ resolved = Path(path).resolve()
127
+ if self._inside(resolved, self.repo_root):
128
+ return Decision(Verdict.ALLOW, "inside the repo on the session branch", "write-in-repo")
129
+ if self.scratch and self._inside(resolved, self.scratch):
130
+ return Decision(Verdict.ALLOW, "inside the scratch dir", "write-scratch")
131
+ return self._risk(
132
+ RiskClass.WRITE_OUTSIDE_REPO,
133
+ f"{resolved} is outside the repo; writing there needs approval",
134
+ "write-outside-repo",
135
+ )
136
+
137
+ # ------------------------------------------------------------------ shell
138
+
139
+ def check_shell(self, cmd: str) -> Decision:
140
+ denial: Denial | None = next(iter(deny_reasons(cmd, self.deny_rules)), None)
141
+ if denial is not None:
142
+ return Decision(Verdict.DENY, denial.reason, f"deny:{denial.rule}")
143
+ if _is_delete(cmd):
144
+ return self._risk(RiskClass.DELETE, "delete/move/chmod needs approval", "exec-delete")
145
+ if _is_install(cmd):
146
+ return self._risk(RiskClass.INSTALL, "installing packages needs approval", "exec-install")
147
+ if _is_network(cmd):
148
+ return self._risk(RiskClass.NETWORK, "network access needs approval", "exec-network")
149
+ return Decision(Verdict.ALLOW, "shell command", "exec-allow")
150
+
151
+ # -------------------------------------------------------------------- git
152
+
153
+ def check_push(self, remote: str, ref: str) -> Decision:
154
+ """Only configured remotes, only coagent/* refs (FR-GIT-8)."""
155
+ if remote not in self.allowed_remotes:
156
+ return Decision(
157
+ Verdict.DENY,
158
+ f"remote {remote!r} is not in [git] remotes {list(self.allowed_remotes)}",
159
+ "push-remote",
160
+ )
161
+ normalized = ref if ref.startswith("refs/") else f"refs/heads/{ref}"
162
+ if not normalized.startswith(self.push_ref_prefix):
163
+ return Decision(
164
+ Verdict.DENY,
165
+ f"only {self.push_ref_prefix}* may be pushed, not {normalized}",
166
+ "push-ref",
167
+ )
168
+ return Decision(Verdict.ALLOW, "checkpoint push to the session branch", "push-session")
169
+
170
+ # ------------------------------------------------------------------ help
171
+
172
+ def _risk(self, risk: RiskClass, why: str, rule: str) -> Decision:
173
+ if risk.value in self.require_approval:
174
+ if self.interactive:
175
+ return Decision(Verdict.ASK, why, rule)
176
+ return Decision(Verdict.DENY, f"{why} (non-interactive mode defaults to deny)", rule)
177
+ return Decision(Verdict.ALLOW, why, rule)
178
+
179
+ @staticmethod
180
+ def _inside(path: Path, root: Path) -> bool:
181
+ try:
182
+ path.relative_to(root)
183
+ except ValueError:
184
+ return False
185
+ return True
186
+
187
+
188
+ _INSTALL_HINTS = ("pip install", "pip3 install", "apt-get", "apt ", "conda install", "npm install", "uv pip")
189
+ _NETWORK_HINTS = (
190
+ "curl",
191
+ "wget",
192
+ "git clone",
193
+ "git fetch",
194
+ "git pull",
195
+ "requests.get",
196
+ "http://",
197
+ "https://",
198
+ "huggingface-cli download",
199
+ )
200
+ _DELETE_HINTS = ("rm ", "rmdir", "mv ", "chmod", "chown", "unlink", "shutil.rmtree")
201
+
202
+
203
+ def _first_word(cmd: str) -> str:
204
+ return cmd.strip().split(maxsplit=1)[0] if cmd.strip() else ""
205
+
206
+
207
+ def _is_install(cmd: str) -> bool:
208
+ return any(hint in cmd for hint in _INSTALL_HINTS)
209
+
210
+
211
+ def _is_network(cmd: str) -> bool:
212
+ word = _first_word(cmd)
213
+ if word in {"curl", "wget", "ssh", "scp", "nc"}:
214
+ return True
215
+ return any(hint in cmd for hint in _NETWORK_HINTS)
216
+
217
+
218
+ def _is_delete(cmd: str) -> bool:
219
+ return any(hint in cmd for hint in _DELETE_HINTS)
@@ -0,0 +1,146 @@
1
+ """Secret redaction (PRD FR-PRV-2 / FR-BOOT-5).
2
+
3
+ Redaction happens *before* a string reaches a log, a display, a report, or a
4
+ committed file. Every entry point that writes text goes through `redact()`.
5
+
6
+ The output keeps the last four characters so a log stays useful for "which
7
+ token was that" without being usable as a credential.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+
14
+ __all__ = ["MASK", "Redactor", "redact", "redact_obj", "contains_secret", "SECRET_PATTERNS"]
15
+
16
+ MASK = "•••"
17
+
18
+ # Ordered most-specific first. Each entry is (label, compiled pattern, group).
19
+ # The group selects the value to redact when the overall match includes context.
20
+ _PATTERNS: list[tuple[str, re.Pattern[str], int]] = [
21
+ # Fine-grained GitHub PATs: github_pat_11ABCDEFG0abc...
22
+ ("github_pat_fine", re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), 0),
23
+ # Classic PATs and OAuth tokens.
24
+ ("github_pat", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}"), 0),
25
+ # Hugging Face
26
+ ("hf_token", re.compile(r"\bhf_[A-Za-z0-9]{16,}"), 0),
27
+ # OpenAI-style
28
+ ("openai", re.compile(r"\bsk-[A-Za-z0-9_\-]{16,}"), 0),
29
+ # Anthropic
30
+ ("anthropic", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{16,}"), 0),
31
+ # AWS access key id
32
+ ("aws_key", re.compile(r"\bAKIA[0-9A-Z]{16}\b"), 0),
33
+ # Google API key: AIza + 35 chars (case-sensitive alphabet)
34
+ ("google_api", re.compile(r"\bAIza[0-9A-Za-z_\-]{30,40}\b"), 0),
35
+ # Slack
36
+ ("slack", re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}"), 0),
37
+ # Stripe
38
+ ("stripe", re.compile(r"\bsk_(?:live|test)_[A-Za-z0-9]{16,}"), 0),
39
+ # JWT: three base64url segments, the first of which decodes to {"alg":...
40
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}"), 0),
41
+ # Credentials embedded in a URL (https://user:token@host) — clone URLs leak
42
+ # into .git/config and remote -v output.
43
+ ("url_credentials", re.compile(r"(://)[^/\s:@]+:[^/\s@]+(@)"), 2),
44
+ # Bearer / token headers in captured output.
45
+ ("bearer", re.compile(r"(?i)\b(?:bearer|token)\s+([A-Za-z0-9._\-]{16,})"), 1),
46
+ # Private key blocks.
47
+ (
48
+ "private_key",
49
+ re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S),
50
+ 0,
51
+ ),
52
+ # Key=value assignments for known secret-ish names, e.g. GH_TOKEN=ghp_...
53
+ (
54
+ "assigned_secret",
55
+ re.compile(
56
+ r"(?i)\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|APIKEY|CREDENTIAL)[A-Z0-9_]*)"
57
+ r"\s*[=:]\s*[\"']?([^\s\"',}]{8,})"
58
+ ),
59
+ 2,
60
+ ),
61
+ ]
62
+
63
+ SECRET_PATTERNS = tuple(label for label, _, _ in _PATTERNS)
64
+
65
+
66
+ def _mask_value(value: str) -> str:
67
+ """`MASK` plus the last 4 characters, never the first."""
68
+ tail = value[-4:] if len(value) > 8 else ""
69
+ return f"{MASK}{tail}"
70
+
71
+
72
+ class Redactor:
73
+ """A reusable redactor with a set of literal secrets known for this session.
74
+
75
+ Registering a token means it is removed even when it does not match any
76
+ pattern — e.g. a short PAT pasted from a different provider.
77
+ """
78
+
79
+ __slots__ = ("_literals",)
80
+
81
+ def __init__(self, literals: list[str] | None = None) -> None:
82
+ self._literals: list[str] = [s for s in (literals or []) if s and len(s) >= 8]
83
+
84
+ def add(self, secret: str | None) -> None:
85
+ if secret and len(secret) >= 8 and secret not in self._literals:
86
+ self._literals.append(secret)
87
+ # Longest first so a token that contains another redacts as one unit.
88
+ self._literals.sort(key=len, reverse=True)
89
+
90
+ def redact(self, text: str) -> str:
91
+ if not text:
92
+ return text
93
+ for literal in self._literals:
94
+ if literal in text:
95
+ text = text.replace(literal, _mask_value(literal))
96
+ for _label, pattern, group in _PATTERNS:
97
+ text = pattern.sub(lambda m, g=group: _replace(m, g), text)
98
+ return text
99
+
100
+ def redact_obj(self, obj: object) -> object:
101
+ return _walk(obj, self.redact)
102
+
103
+ def contains_secret(self, text: str) -> bool:
104
+ """True if redaction would change `text` — used by the leak tests."""
105
+ return self.redact(text) != text
106
+
107
+
108
+ def _replace(match: re.Match[str], group: int) -> str:
109
+ whole = match.group(0)
110
+ if group == 0:
111
+ return whole[:0] + _mask_value(whole)
112
+ start, end = match.span(group)
113
+ offset = match.start()
114
+ rel_start, rel_end = start - offset, end - offset
115
+ return whole[:rel_start] + _mask_value(whole[rel_start:rel_end]) + whole[rel_end:]
116
+
117
+
118
+ _DEFAULT = Redactor()
119
+
120
+
121
+ def redact(text: str) -> str:
122
+ """Redact using the default pattern set."""
123
+ return _DEFAULT.redact(text)
124
+
125
+
126
+ def redact_obj(obj: object) -> object:
127
+ """Recursively redact strings inside dicts/lists/tuples."""
128
+ return _DEFAULT.redact_obj(obj)
129
+
130
+
131
+ def contains_secret(text: str) -> bool:
132
+ return _DEFAULT.contains_secret(text)
133
+
134
+
135
+ def _walk(obj: object, fn) -> object:
136
+ if isinstance(obj, str):
137
+ return fn(obj)
138
+ if isinstance(obj, dict):
139
+ return {fn(k) if isinstance(k, str) else k: _walk(v, fn) for k, v in obj.items()}
140
+ if isinstance(obj, list):
141
+ return [_walk(v, fn) for v in obj]
142
+ if isinstance(obj, tuple):
143
+ return tuple(_walk(v, fn) for v in obj)
144
+ if isinstance(obj, set):
145
+ return {_walk(v, fn) for v in obj}
146
+ return obj
@@ -0,0 +1,104 @@
1
+ # coAgent configuration — committed to your repo so a session is reproducible
2
+ # on any VM. See DESIGN.md §11 and PRD.md Appendix A.
3
+ #
4
+ # Bump `schema_version` only when this file's structure changes incompatibly;
5
+ # coAgent refuses to run against a newer schema than it understands.
6
+
7
+ schema_version = 1
8
+
9
+ [model]
10
+ # The only validated configuration on a free T4 (PRD FR-MDL-1).
11
+ name = "Qwen/Qwen2.5-Coder-7B-Instruct"
12
+ # nf4 = bitsandbytes 4-bit; compute dtype is fp16 because T4 (SM 7.5) has no bf16.
13
+ quant = "nf4"
14
+ # Hard cap, counted with the model's own tokenizer (PRD D2 / FR-MDL-2).
15
+ max_context = 12288
16
+ max_new_tokens = 1024
17
+ # Sampling: deterministic for edits, warmer for planning (FR-MDL-4).
18
+ temperature_edit = 0.2
19
+ temperature_plan = 0.7
20
+ top_p = 0.95
21
+ # Per-session seed, logged for reproducibility. 0 = pick a random seed and log it.
22
+ seed = 0
23
+ # Optional outlines-envelope constrained decoding (DESIGN §4.3). Off by default;
24
+ # the tolerant-parse path must stay functional when this is disabled.
25
+ constrained = false
26
+ # Fallback used only after the OOM ladder fails to fit the default model (PRD D6).
27
+ fallback_name = "Qwen/Qwen2.5-Coder-3B-Instruct"
28
+ fallback_max_context = 8192
29
+
30
+ [verify]
31
+ # Run with CUDA_VISIBLE_DEVICES="" so tests never fight the model for VRAM (PRD D4).
32
+ commands = ["ruff check .", "pytest -q"]
33
+ # Run at most once per task, after the model yields the GPU (DESIGN §4.6).
34
+ gpu_commands = []
35
+ timeout_s = 300
36
+ # Free VRAM required before a gpu_commands step is allowed to start.
37
+ vram_reserve_mb = 1024
38
+ # A feature without a test is not done (FR-VER-5).
39
+ require_tests = true
40
+ # Failures fed back to the model are reduced to names + traceback tails (FR-VER-3).
41
+ feedback_max_tokens = 1500
42
+
43
+ [git]
44
+ author_name = "coAgent"
45
+ author_email = "coagent@users.noreply.github.com"
46
+ # Only these remotes may receive a push. Add "durable" when origin is public
47
+ # so WIP history does not leak (DESIGN §3.1, PRD R7).
48
+ remotes = ["origin"]
49
+ clone_depth = 1
50
+ # Ref namespace coAgent is allowed to push. main is never writable (FR-GIT-8).
51
+ push_ref_prefix = "refs/heads/coagent/"
52
+
53
+ [durability]
54
+ # push = push the session branch; bundle = write git bundles to Drive (offline).
55
+ mode = "push"
56
+ remote = "origin"
57
+ # Checkpoint at least this often while the tree is dirty (FR-REL-1).
58
+ heartbeat_s = 180
59
+
60
+ [agent]
61
+ max_steps = 60
62
+ max_wall_s = 2700
63
+ max_tokens = 200000
64
+ max_gpu_min = 40
65
+ max_commits = 40
66
+ max_consecutive_failures = 3
67
+ # Loop detection thresholds on the state fingerprint (FR-AGT-6).
68
+ replan_after_repeats = 3
69
+ stop_after_repeats = 5
70
+
71
+ [tools]
72
+ # Repo root is always readable. Add e.g. "/content/data" to extend the read jail.
73
+ allow_read = []
74
+ # Combined stdout+stderr cap before head/tail truncation.
75
+ max_output_bytes = 65536
76
+ shell_timeout_s = 120
77
+ notebook_timeout_s = 300
78
+
79
+ [artifacts]
80
+ commit_under_mb = 5
81
+ store = "drive"
82
+ drive_dir = "coagent/artifacts"
83
+
84
+ [safety]
85
+ # Risky action classes that need a human click in interactive mode.
86
+ # In non-interactive mode these flip to deny (DESIGN §9.1).
87
+ require_approval = ["network", "install", "delete", "write_outside_repo"]
88
+ # Evaluated before allow rules, so force-push stays denied even on coagent/*.
89
+ deny = [
90
+ "git push --force",
91
+ "git push -f",
92
+ "git push origin main",
93
+ "git reset --hard",
94
+ "git branch -D",
95
+ "rm -rf /",
96
+ "chmod -R 777 /",
97
+ ]
98
+
99
+ [ui]
100
+ # show = render the notebook chat UI; headless = no widgets (CLI/tests).
101
+ mode = "show"
102
+ # Optional Gradio surface behind the Colab port proxy (P1, never required).
103
+ gradio = false
104
+ gradio_port = 7860
@@ -0,0 +1,155 @@
1
+ """Tool packages, assembled per phase (DESIGN §7.1).
2
+
3
+ `build_registry()` returns the tools available for a phase. Phases exist because
4
+ a small model does one job well: SURVEY can only read, PLAN cannot write at all,
5
+ ACT gets one write or exec, VERIFY may only run checks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ from ..config import Config
13
+ from ..context.artifacts import ArtifactStore
14
+ from ..safety.denylist import DEFAULT_DENIED
15
+ from ..safety.policy import Policy, SafetyClass
16
+ from .artifacts import DisplayArtifactTool, SaveArtifactTool
17
+ from .base import ArgSpec, BaseTool, ToolError, ToolRegistry, ToolResult, ToolSchemaError
18
+ from .fs import (
19
+ EditFileTool,
20
+ FileSession,
21
+ GrepTool,
22
+ ListDirTool,
23
+ ReadFileTool,
24
+ WriteFileTool,
25
+ apply_search_replace,
26
+ truncate_head_tail,
27
+ )
28
+ from .notebooks import EditNotebookTool, ExecNotebookTool, ReadNotebookTool
29
+ from .shell import ExecConfig, RunPythonTool, RunShellTool, RunTestsTool
30
+
31
+ __all__ = [
32
+ "ArgSpec",
33
+ "ArtifactStore",
34
+ "BaseTool",
35
+ "ExecConfig",
36
+ "FileSession",
37
+ "Phase",
38
+ "ToolError",
39
+ "ToolRegistry",
40
+ "ToolResult",
41
+ "ToolSchemaError",
42
+ "apply_search_replace",
43
+ "build_policy",
44
+ "build_registry",
45
+ "truncate_head_tail",
46
+ ]
47
+
48
+ # What each phase is allowed to do. PLAN is absent on purpose: it cannot act.
49
+ PHASE_SAFETY: dict[str, frozenset[SafetyClass]] = {
50
+ "survey": frozenset({SafetyClass.READ}),
51
+ "plan": frozenset(),
52
+ "act": frozenset(
53
+ {SafetyClass.READ, SafetyClass.WRITE, SafetyClass.EXEC, SafetyClass.VCS, SafetyClass.UI}
54
+ ),
55
+ # VERIFY may render a plot it just produced: that is how "the cell ran"
56
+ # becomes something the user can see (FR-UX-4), and it writes nothing new.
57
+ "verify": frozenset({SafetyClass.READ, SafetyClass.EXEC, SafetyClass.UI}),
58
+ "report": frozenset({SafetyClass.READ, SafetyClass.UI}),
59
+ }
60
+
61
+
62
+ class Phase:
63
+ SURVEY = "survey"
64
+ PLAN = "plan"
65
+ ACT = "act"
66
+ VERIFY = "verify"
67
+ REPORT = "report"
68
+
69
+ ALL = (SURVEY, PLAN, ACT, VERIFY, REPORT)
70
+
71
+
72
+ def build_policy(
73
+ repo_root: Path,
74
+ cfg: Config,
75
+ *,
76
+ interactive: bool = True,
77
+ scratch: Path | None = None,
78
+ ) -> Policy:
79
+ return Policy(
80
+ repo_root=Path(repo_root),
81
+ interactive=interactive,
82
+ allow_read=[Path(p) for p in cfg.tools.allow_read],
83
+ push_ref_prefix=cfg.git.push_ref_prefix,
84
+ allowed_remotes=tuple(cfg.git.remotes),
85
+ require_approval=tuple(cfg.safety.require_approval),
86
+ deny_rules=tuple(cfg.safety.deny) if cfg.safety.deny else DEFAULT_DENIED,
87
+ scratch=scratch if scratch is not None else Path(repo_root) / ".coagent" / "tmp",
88
+ )
89
+
90
+
91
+ def build_exec_config(repo_root: Path, cfg: Config) -> ExecConfig:
92
+ return ExecConfig(
93
+ repo_root=Path(repo_root),
94
+ max_output_bytes=cfg.tools.max_output_bytes,
95
+ timeout_s=cfg.tools.shell_timeout_s,
96
+ notebook_timeout_s=cfg.tools.notebook_timeout_s,
97
+ hide_gpu=True, # PRD D4: verification is CPU-only by default
98
+ engine_dir=Path(repo_root) / ".coagent" / "tmp",
99
+ )
100
+
101
+
102
+ def build_registry(
103
+ repo_root: Path,
104
+ cfg: Config,
105
+ *,
106
+ phase: str = Phase.ACT,
107
+ policy: Policy | None = None,
108
+ session: FileSession | None = None,
109
+ ) -> ToolRegistry:
110
+ """Every tool this phase may use, already filtered by safety class.
111
+
112
+ ``session`` is shared across phases when the caller has one, because
113
+ read-before-write and the artifact store are per-*session* state, not
114
+ per-phase: SURVEY reading a file must satisfy ACT's edit guard.
115
+ """
116
+ root = Path(repo_root)
117
+ policy = policy or (session.policy if session is not None else build_policy(root, cfg))
118
+ if session is None:
119
+ session = FileSession(repo_root=root, policy=policy)
120
+ exec_cfg = build_exec_config(root, cfg)
121
+ session.exec_cfg = exec_cfg
122
+ if session.artifact_store is None:
123
+ session.artifact_store = ArtifactStore(
124
+ root,
125
+ getattr(session, "run_id", "") or "run",
126
+ commit_under_mb=cfg.artifacts.commit_under_mb,
127
+ drive_dir=cfg.artifacts.drive_dir,
128
+ )
129
+
130
+ all_tools: list[BaseTool] = [
131
+ ListDirTool(session),
132
+ ReadFileTool(session),
133
+ GrepTool(session),
134
+ EditFileTool(session),
135
+ WriteFileTool(session),
136
+ # Notebooks are first-class (PRD D-2): a plot-and-cell task is a task,
137
+ # not a source-file task with a JSON side quest.
138
+ ReadNotebookTool(session),
139
+ EditNotebookTool(session),
140
+ ExecNotebookTool(session),
141
+ SaveArtifactTool(session),
142
+ DisplayArtifactTool(session),
143
+ RunShellTool(exec_cfg, policy),
144
+ RunTestsTool(
145
+ exec_cfg,
146
+ policy,
147
+ verify_commands=list(cfg.verify.commands),
148
+ feedback_max_tokens=cfg.verify.feedback_max_tokens,
149
+ ),
150
+ RunPythonTool(exec_cfg, policy),
151
+ ]
152
+ allowed = PHASE_SAFETY.get(phase)
153
+ if allowed is None:
154
+ raise ValueError(f"unknown phase {phase!r}; expected one of {list(PHASE_SAFETY)}")
155
+ return ToolRegistry([t for t in all_tools if t.safety in allowed])