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/safety/policy.py
ADDED
|
@@ -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)
|
coagent/safety/redact.py
ADDED
|
@@ -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])
|