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/tools/shell.py
ADDED
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""Execution tools: shell and Python, isolated at the process level (FR-TOL-3).
|
|
2
|
+
|
|
3
|
+
There is no container on Colab, so isolation is process-level and the docs say
|
|
4
|
+
so. What this module guarantees:
|
|
5
|
+
|
|
6
|
+
* a scrubbed environment — tokens and API keys never reach a child process
|
|
7
|
+
(FR-TOL-5), and GPU visibility is hidden for verification runs (PRD D4);
|
|
8
|
+
* a timeout that kills the whole process group, not just the parent, so a
|
|
9
|
+
grandchild cannot outlive the turn;
|
|
10
|
+
* an output cap that keeps the head and the tail and elides the middle, because
|
|
11
|
+
the interesting parts of a build log are the invocation and the error;
|
|
12
|
+
* a policy gate, applied to the command string before anything is spawned.
|
|
13
|
+
|
|
14
|
+
`RunTestsTool` is a thin shell over `tools/pyt.py`: pytest output is parsed into
|
|
15
|
+
failing test names plus a bounded digest, because a 500-line pytest dump evicts
|
|
16
|
+
the context the model needs to fix the failure (DESIGN §7.5).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from ..safety.policy import Policy, SafetyClass, Verdict
|
|
34
|
+
from .base import ArgSpec, BaseTool, ToolError, ToolResult
|
|
35
|
+
from .pyt import parse_pytest_output, summarise_pytest
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"ExecConfig",
|
|
39
|
+
"ExecOutcome",
|
|
40
|
+
"RunPythonTool",
|
|
41
|
+
"RunShellTool",
|
|
42
|
+
"RunTestsTool",
|
|
43
|
+
"run_subprocess",
|
|
44
|
+
"scrubbed_env",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
#: Environment names that must never reach a child process (FR-TOL-5).
|
|
48
|
+
#: Matched as a substring, case-insensitively, against the variable name.
|
|
49
|
+
_SECRET_MARKERS = (
|
|
50
|
+
"token",
|
|
51
|
+
"secret",
|
|
52
|
+
"password",
|
|
53
|
+
"passwd",
|
|
54
|
+
"api_key",
|
|
55
|
+
"apikey",
|
|
56
|
+
"access_key",
|
|
57
|
+
"private_key",
|
|
58
|
+
"credential",
|
|
59
|
+
"auth",
|
|
60
|
+
"session_key",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
#: Names that look like secrets but are safe and needed to run anything.
|
|
64
|
+
_SECRET_ALLOWLIST = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"SSH_AUTH_SOCK", # a socket path, not a secret
|
|
67
|
+
"GIT_AUTHOR_NAME",
|
|
68
|
+
"GIT_AUTHOR_EMAIL",
|
|
69
|
+
"GIT_COMMITTER_NAME",
|
|
70
|
+
"GIT_COMMITTER_EMAIL",
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
#: Separator inserted where output was elided.
|
|
75
|
+
_OMITTED = "\n... [{n} bytes omitted] ...\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class ExecConfig:
|
|
80
|
+
"""Everything the exec layer needs, fixed once per session."""
|
|
81
|
+
|
|
82
|
+
repo_root: Path
|
|
83
|
+
max_output_bytes: int = 65536
|
|
84
|
+
timeout_s: int = 120
|
|
85
|
+
notebook_timeout_s: int = 300
|
|
86
|
+
#: PRD D4: verification is CPU-only, so CUDA is hidden by default.
|
|
87
|
+
hide_gpu: bool = True
|
|
88
|
+
#: Temp dir for generated scripts, outside the repo so it is never committed.
|
|
89
|
+
engine_dir: Path | None = None
|
|
90
|
+
|
|
91
|
+
def __post_init__(self) -> None:
|
|
92
|
+
self.repo_root = Path(self.repo_root)
|
|
93
|
+
if self.engine_dir is None:
|
|
94
|
+
self.engine_dir = self.repo_root / ".coagent" / "tmp"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(slots=True)
|
|
98
|
+
class ExecOutcome:
|
|
99
|
+
"""What a finished (or killed) process produced."""
|
|
100
|
+
|
|
101
|
+
returncode: int
|
|
102
|
+
output: str
|
|
103
|
+
truncated: bool = False
|
|
104
|
+
timed_out: bool = False
|
|
105
|
+
duration_s: float = 0.0
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def ok(self) -> bool:
|
|
109
|
+
return self.returncode == 0 and not self.timed_out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def scrubbed_env(cfg: ExecConfig, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
113
|
+
"""The environment a child process sees: no secrets, bounded GPU visibility.
|
|
114
|
+
|
|
115
|
+
Secrets are removed rather than blanked, so a child that checks for the
|
|
116
|
+
variable's presence sees the truth: it is not available (FR-TOL-5).
|
|
117
|
+
"""
|
|
118
|
+
env: dict[str, str] = {}
|
|
119
|
+
for name, value in os.environ.items():
|
|
120
|
+
if name in _SECRET_ALLOWLIST:
|
|
121
|
+
env[name] = value
|
|
122
|
+
continue
|
|
123
|
+
lowered = name.lower()
|
|
124
|
+
if any(marker in lowered for marker in _SECRET_MARKERS):
|
|
125
|
+
continue
|
|
126
|
+
env[name] = value
|
|
127
|
+
|
|
128
|
+
# Keep the repo importable without an install, and stop Python writing
|
|
129
|
+
# .pyc files into a tree the agent is about to diff.
|
|
130
|
+
existing = env.get("PYTHONPATH", "")
|
|
131
|
+
root = str(cfg.repo_root)
|
|
132
|
+
env["PYTHONPATH"] = f"{root}{os.pathsep}{existing}" if existing else root
|
|
133
|
+
env.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
|
134
|
+
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
135
|
+
env.setdefault("TERM", "dumb")
|
|
136
|
+
env.setdefault("NO_COLOR", "1")
|
|
137
|
+
# A stray TERM/COLUMNS makes tools emit progress bars and ANSI codes.
|
|
138
|
+
env.pop("COLUMNS", None)
|
|
139
|
+
env.pop("LINES", None)
|
|
140
|
+
|
|
141
|
+
if cfg.hide_gpu:
|
|
142
|
+
env["CUDA_VISIBLE_DEVICES"] = ""
|
|
143
|
+
else:
|
|
144
|
+
# Only expose the first device: the T4 is a single-GPU box.
|
|
145
|
+
env.setdefault("CUDA_VISIBLE_DEVICES", "0")
|
|
146
|
+
|
|
147
|
+
if extra:
|
|
148
|
+
env.update(extra)
|
|
149
|
+
return env
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def run_subprocess(
|
|
153
|
+
argv: list[str],
|
|
154
|
+
*,
|
|
155
|
+
cfg: ExecConfig,
|
|
156
|
+
cwd: Path | str | None = None,
|
|
157
|
+
env: dict[str, str] | None = None,
|
|
158
|
+
timeout_s: int | float | None = None,
|
|
159
|
+
stdin_text: str | None = None,
|
|
160
|
+
) -> ExecOutcome:
|
|
161
|
+
"""Run `argv` in its own process group with a scrubbed env and an output cap.
|
|
162
|
+
|
|
163
|
+
The process group matters: `pytest` spawns workers, and killing only the
|
|
164
|
+
parent leaves orphans holding the pipe open, which turns a 1s timeout into
|
|
165
|
+
a hang until the workers exit on their own.
|
|
166
|
+
"""
|
|
167
|
+
cwd_path = Path(cwd) if cwd is not None else cfg.repo_root
|
|
168
|
+
effective_timeout = float(timeout_s if timeout_s is not None else cfg.timeout_s)
|
|
169
|
+
limit = cfg.max_output_bytes
|
|
170
|
+
|
|
171
|
+
started = time.monotonic()
|
|
172
|
+
popen_kwargs: dict[str, Any] = {
|
|
173
|
+
"cwd": str(cwd_path),
|
|
174
|
+
"env": env if env is not None else scrubbed_env(cfg),
|
|
175
|
+
"stdin": subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
|
|
176
|
+
"stdout": subprocess.PIPE,
|
|
177
|
+
"stderr": subprocess.STDOUT,
|
|
178
|
+
"text": True,
|
|
179
|
+
"errors": "replace",
|
|
180
|
+
}
|
|
181
|
+
if os.name == "posix":
|
|
182
|
+
popen_kwargs["start_new_session"] = True
|
|
183
|
+
else: # pragma: no cover - exercised on Windows only
|
|
184
|
+
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
proc = subprocess.Popen(argv, **popen_kwargs) # noqa: S603 - argv is policy-gated
|
|
188
|
+
except FileNotFoundError as exc:
|
|
189
|
+
return ExecOutcome(
|
|
190
|
+
returncode=127,
|
|
191
|
+
output=f"{argv[0]}: command not found ({exc.strerror or 'no such file'})",
|
|
192
|
+
duration_s=time.monotonic() - started,
|
|
193
|
+
)
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
return ExecOutcome(
|
|
196
|
+
returncode=126,
|
|
197
|
+
output=f"failed to start {argv[0]}: {exc}",
|
|
198
|
+
duration_s=time.monotonic() - started,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
timed_out = False
|
|
202
|
+
try:
|
|
203
|
+
raw, _ = proc.communicate(input=stdin_text, timeout=effective_timeout)
|
|
204
|
+
except subprocess.TimeoutExpired:
|
|
205
|
+
timed_out = True
|
|
206
|
+
_kill_group(proc)
|
|
207
|
+
try:
|
|
208
|
+
raw, _ = proc.communicate(timeout=5)
|
|
209
|
+
except subprocess.TimeoutExpired: # pragma: no cover - unkillable child
|
|
210
|
+
raw = ""
|
|
211
|
+
raw = (raw or "") + f"\n[timeout after {effective_timeout:.0f}s; process group killed]"
|
|
212
|
+
|
|
213
|
+
duration = time.monotonic() - started
|
|
214
|
+
text, truncated = _cap_output(raw or "", limit)
|
|
215
|
+
return ExecOutcome(
|
|
216
|
+
returncode=proc.returncode if proc.returncode is not None else -1,
|
|
217
|
+
output=text,
|
|
218
|
+
truncated=truncated,
|
|
219
|
+
timed_out=timed_out,
|
|
220
|
+
duration_s=duration,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _kill_group(proc: subprocess.Popen[str]) -> None:
|
|
225
|
+
"""Kill the process and everything it spawned."""
|
|
226
|
+
if proc.poll() is not None:
|
|
227
|
+
return
|
|
228
|
+
try:
|
|
229
|
+
if os.name == "posix":
|
|
230
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
231
|
+
else: # pragma: no cover - Windows
|
|
232
|
+
proc.kill()
|
|
233
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
234
|
+
with contextlib.suppress(OSError):
|
|
235
|
+
proc.kill()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _cap_output(text: str, limit: int) -> tuple[str, bool]:
|
|
239
|
+
"""Keep the head and the tail, elide the middle.
|
|
240
|
+
|
|
241
|
+
The head says what ran; the tail says what went wrong. The middle of a build
|
|
242
|
+
log is progress noise, so it is the cheapest thing to drop.
|
|
243
|
+
"""
|
|
244
|
+
if limit <= 0 or len(text) <= limit:
|
|
245
|
+
return text, False
|
|
246
|
+
head_len = limit // 3
|
|
247
|
+
tail_len = limit - head_len
|
|
248
|
+
head = text[:head_len]
|
|
249
|
+
tail = text[-tail_len:]
|
|
250
|
+
omitted = len(text) - head_len - tail_len
|
|
251
|
+
return head + _OMITTED.format(n=omitted) + tail, True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class _ExecTool(BaseTool):
|
|
255
|
+
"""Shared gate and result shaping for the three exec tools."""
|
|
256
|
+
|
|
257
|
+
#: Running anything is EXEC, never READ: SURVEY must not be able to spawn a
|
|
258
|
+
#: process, so the class is declared once here rather than defaulted.
|
|
259
|
+
safety = SafetyClass.EXEC
|
|
260
|
+
|
|
261
|
+
def __init__(self, cfg: ExecConfig, policy: Policy) -> None:
|
|
262
|
+
super().__init__(cfg)
|
|
263
|
+
self.cfg = cfg
|
|
264
|
+
self.policy = policy
|
|
265
|
+
|
|
266
|
+
def _gate(self, cmd: str) -> None:
|
|
267
|
+
"""Deny or ask before anything is spawned (FR-TOL-4)."""
|
|
268
|
+
decision = self.policy.check_shell(cmd)
|
|
269
|
+
if decision.verdict is Verdict.DENY:
|
|
270
|
+
raise ToolError(f"denied: {decision.reason}")
|
|
271
|
+
if decision.verdict is Verdict.ASK:
|
|
272
|
+
# In non-interactive mode ASK resolves to deny, so the message is the
|
|
273
|
+
# same shape either way: say what would unblock it.
|
|
274
|
+
raise ToolError(f"needs approval: {decision.reason} ({cmd.split()[0]})")
|
|
275
|
+
|
|
276
|
+
def _result(self, outcome: ExecOutcome, label: str) -> ToolResult:
|
|
277
|
+
content = outcome.output
|
|
278
|
+
if not content.strip():
|
|
279
|
+
content = f"{label}: no output"
|
|
280
|
+
if outcome.timed_out:
|
|
281
|
+
return ToolResult.failure(
|
|
282
|
+
f"timeout: {label} exceeded {self.cfg.timeout_s}s and was killed\n{content}".strip(),
|
|
283
|
+
timed_out=True,
|
|
284
|
+
returncode=outcome.returncode,
|
|
285
|
+
duration_s=outcome.duration_s,
|
|
286
|
+
truncated=outcome.truncated,
|
|
287
|
+
)
|
|
288
|
+
if outcome.returncode != 0:
|
|
289
|
+
return ToolResult.failure(
|
|
290
|
+
f"exit {outcome.returncode}: {label}\n{content}".strip(),
|
|
291
|
+
returncode=outcome.returncode,
|
|
292
|
+
timed_out=False,
|
|
293
|
+
duration_s=outcome.duration_s,
|
|
294
|
+
truncated=outcome.truncated,
|
|
295
|
+
)
|
|
296
|
+
return ToolResult.success(
|
|
297
|
+
content,
|
|
298
|
+
returncode=0,
|
|
299
|
+
timed_out=False,
|
|
300
|
+
duration_s=outcome.duration_s,
|
|
301
|
+
truncated=outcome.truncated,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
class RunShellTool(_ExecTool):
|
|
306
|
+
"""Run one command in the repo.
|
|
307
|
+
|
|
308
|
+
Both an argv list and a shell string are accepted. argv is preferred because
|
|
309
|
+
it needs no quoting rules; the string form is what the model usually emits,
|
|
310
|
+
so it is normalized back to argv rather than handed to a shell.
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
name = "run_shell"
|
|
314
|
+
description = (
|
|
315
|
+
"Run a shell command in the repository. Prefer argv (a list of words) over a "
|
|
316
|
+
"command string: it avoids quoting bugs. Use for linters, formatters, git "
|
|
317
|
+
"inspection and build steps. Not for installing packages without approval."
|
|
318
|
+
)
|
|
319
|
+
args = (
|
|
320
|
+
ArgSpec(
|
|
321
|
+
"argv",
|
|
322
|
+
"array",
|
|
323
|
+
"Command as a list of words, e.g. ['pytest', '-q']. Required unless command is given.",
|
|
324
|
+
required=False,
|
|
325
|
+
),
|
|
326
|
+
ArgSpec(
|
|
327
|
+
"command",
|
|
328
|
+
"string",
|
|
329
|
+
"Command as one string, e.g. 'pytest -q'. Required unless argv is given.",
|
|
330
|
+
required=False,
|
|
331
|
+
),
|
|
332
|
+
ArgSpec(
|
|
333
|
+
"timeout_s",
|
|
334
|
+
"integer",
|
|
335
|
+
"Seconds before the process group is killed. Defaults to the session timeout.",
|
|
336
|
+
required=False,
|
|
337
|
+
),
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
def run(
|
|
341
|
+
self,
|
|
342
|
+
argv: list[str] | None = None,
|
|
343
|
+
command: str | None = None,
|
|
344
|
+
timeout_s: int | None = None,
|
|
345
|
+
) -> ToolResult:
|
|
346
|
+
cmd_argv = _resolve_argv(argv, command)
|
|
347
|
+
self._gate(" ".join(cmd_argv))
|
|
348
|
+
outcome = run_subprocess(
|
|
349
|
+
cmd_argv,
|
|
350
|
+
cfg=self.cfg,
|
|
351
|
+
timeout_s=timeout_s if timeout_s else None,
|
|
352
|
+
)
|
|
353
|
+
return self._result(outcome, " ".join(cmd_argv))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class RunTestsTool(_ExecTool):
|
|
357
|
+
"""Run the verify command and return failing test names, not a wall of text.
|
|
358
|
+
|
|
359
|
+
The digest caps at `feedback_max_tokens` (approximated as 4 chars/token) so
|
|
360
|
+
the model gets the verdict, the failing node ids, and the last evidence for
|
|
361
|
+
each — without the passing-test noise (DESIGN §7.5).
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
name = "run_tests"
|
|
365
|
+
description = (
|
|
366
|
+
"Run the test suite (or one test file) and get the failures back as a short "
|
|
367
|
+
"digest: pass/fail verdict, failing test names, and the error for each. Use "
|
|
368
|
+
"this instead of run_shell when you want pytest output you can act on."
|
|
369
|
+
)
|
|
370
|
+
args = (
|
|
371
|
+
ArgSpec(
|
|
372
|
+
"command",
|
|
373
|
+
"string",
|
|
374
|
+
"Test command, e.g. 'pytest -q' or 'pytest tests/test_x.py::test_y'.",
|
|
375
|
+
required=False,
|
|
376
|
+
default="pytest -q",
|
|
377
|
+
),
|
|
378
|
+
ArgSpec(
|
|
379
|
+
"argv",
|
|
380
|
+
"array",
|
|
381
|
+
"Command as a list of words. Prefer this when the command is already tokenized, "
|
|
382
|
+
"e.g. from [verify] commands in config.",
|
|
383
|
+
required=False,
|
|
384
|
+
),
|
|
385
|
+
ArgSpec(
|
|
386
|
+
"max_tokens",
|
|
387
|
+
"integer",
|
|
388
|
+
"Digest budget in tokens (approximate). Defaults to the verify config.",
|
|
389
|
+
required=False,
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def __init__(
|
|
394
|
+
self,
|
|
395
|
+
cfg: ExecConfig,
|
|
396
|
+
policy: Policy,
|
|
397
|
+
*,
|
|
398
|
+
verify_commands: list[str] | None = None,
|
|
399
|
+
feedback_max_tokens: int = 1500,
|
|
400
|
+
) -> None:
|
|
401
|
+
super().__init__(cfg, policy)
|
|
402
|
+
self.verify_commands = list(verify_commands or ["pytest -q"])
|
|
403
|
+
self.feedback_max_tokens = feedback_max_tokens
|
|
404
|
+
|
|
405
|
+
def run(
|
|
406
|
+
self,
|
|
407
|
+
command: str = "pytest -q",
|
|
408
|
+
argv: list[str] | None = None,
|
|
409
|
+
max_tokens: int | None = None,
|
|
410
|
+
) -> ToolResult:
|
|
411
|
+
# argv wins when given: a tokenized command (as config carries it) must
|
|
412
|
+
# not be re-split, or a quoted interpreter path loses its meaning.
|
|
413
|
+
cmd_argv = _resolve_argv(argv, command)
|
|
414
|
+
label = " ".join(cmd_argv)
|
|
415
|
+
self._gate(label)
|
|
416
|
+
|
|
417
|
+
budget = int(max_tokens) if max_tokens else self.feedback_max_tokens
|
|
418
|
+
cap_chars = max(200, budget * 4)
|
|
419
|
+
|
|
420
|
+
outcome = run_subprocess(cmd_argv, cfg=self.cfg)
|
|
421
|
+
parsed = parse_pytest_output(outcome.output)
|
|
422
|
+
digest = summarise_pytest(outcome.output, cap=cap_chars)
|
|
423
|
+
|
|
424
|
+
meta: dict[str, Any] = {
|
|
425
|
+
"returncode": outcome.returncode,
|
|
426
|
+
"timed_out": outcome.timed_out,
|
|
427
|
+
"duration_s": outcome.duration_s,
|
|
428
|
+
"truncated": outcome.truncated,
|
|
429
|
+
"failures": parsed.failed_names,
|
|
430
|
+
"failed": parsed.failures,
|
|
431
|
+
"passed": parsed.passed,
|
|
432
|
+
}
|
|
433
|
+
if outcome.returncode == 0 and not outcome.timed_out and not parsed.failures:
|
|
434
|
+
# Keep the output when there is any: a passing lint or a script that
|
|
435
|
+
# prints a count is information, and "tests passed" would discard it.
|
|
436
|
+
return ToolResult.success(digest or "tests passed", **meta)
|
|
437
|
+
if outcome.timed_out:
|
|
438
|
+
return ToolResult.failure(
|
|
439
|
+
f"timeout: tests exceeded {self.cfg.timeout_s}s and were killed\n{digest}".strip(),
|
|
440
|
+
**meta,
|
|
441
|
+
)
|
|
442
|
+
return ToolResult.failure(digest or f"exit {outcome.returncode}", **meta)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
class RunPythonTool(_ExecTool):
|
|
446
|
+
"""Run a Python snippet in a fresh interpreter inside the repo.
|
|
447
|
+
|
|
448
|
+
A fresh process, not the agent's own: `sys.exit` in generated code would
|
|
449
|
+
otherwise end the session, and module-level state would leak between turns.
|
|
450
|
+
"""
|
|
451
|
+
|
|
452
|
+
name = "run_python"
|
|
453
|
+
description = (
|
|
454
|
+
"Run a short Python snippet in a fresh interpreter with the repo importable. "
|
|
455
|
+
"Use for quick checks, printing values, or exercising a function. The process "
|
|
456
|
+
"is killed on timeout; nothing it prints is lost."
|
|
457
|
+
)
|
|
458
|
+
safety = SafetyClass.EXEC
|
|
459
|
+
args = (
|
|
460
|
+
ArgSpec(
|
|
461
|
+
"code",
|
|
462
|
+
"string",
|
|
463
|
+
"Python source to execute.",
|
|
464
|
+
required=True,
|
|
465
|
+
),
|
|
466
|
+
ArgSpec(
|
|
467
|
+
"timeout_s",
|
|
468
|
+
"integer",
|
|
469
|
+
"Seconds before the process group is killed. Defaults to the session timeout.",
|
|
470
|
+
required=False,
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
def run(self, code: str, timeout_s: int | None = None) -> ToolResult:
|
|
475
|
+
if not code.strip():
|
|
476
|
+
raise ToolError("run_python needs non-empty code")
|
|
477
|
+
# Gate on the source too: generated code that shells out must be reviewed.
|
|
478
|
+
self._gate(code)
|
|
479
|
+
outcome = run_subprocess(
|
|
480
|
+
[sys.executable, "-c", code],
|
|
481
|
+
cfg=self.cfg,
|
|
482
|
+
timeout_s=timeout_s if timeout_s else None,
|
|
483
|
+
)
|
|
484
|
+
return self._result(outcome, "python snippet")
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _resolve_argv(argv: list[str] | None, command: str | None) -> list[str]:
|
|
488
|
+
"""Normalize argv/command to one argv list, or explain what was missing."""
|
|
489
|
+
if argv:
|
|
490
|
+
words = [str(a) for a in argv if str(a) != ""]
|
|
491
|
+
if words:
|
|
492
|
+
return words
|
|
493
|
+
if command and command.strip():
|
|
494
|
+
return _split_command(command)
|
|
495
|
+
raise ToolError("provide either argv or command")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _split_command(command: str) -> list[str]:
|
|
499
|
+
"""Split a command string with shell-like quoting, but no shell features.
|
|
500
|
+
|
|
501
|
+
Deliberately no pipes, redirects or `&&`: those need a shell, and handing a
|
|
502
|
+
shell a string the model wrote is the one thing this layer exists to avoid.
|
|
503
|
+
|
|
504
|
+
`posix=True` is the default because there is no shell here, so the only thing
|
|
505
|
+
posix mode changes is quote handling: non-posix keeps the quotes and turns
|
|
506
|
+
`python -c "print(7)"` into a program whose source is literally `"print(7)"`.
|
|
507
|
+
On Windows, however, posix mode eats backslashes in `C:\\Python\\python.exe`,
|
|
508
|
+
so the first token is re-checked against the raw string and the non-posix
|
|
509
|
+
split wins when posix mangled an existing path.
|
|
510
|
+
"""
|
|
511
|
+
try:
|
|
512
|
+
import shlex
|
|
513
|
+
|
|
514
|
+
words = shlex.split(command, posix=True)
|
|
515
|
+
except ValueError as exc:
|
|
516
|
+
raise ToolError(f"could not parse command {command!r}: {exc}") from None
|
|
517
|
+
|
|
518
|
+
if words and os.name == "nt":
|
|
519
|
+
repaired = _repair_windows_exe(words[0], command)
|
|
520
|
+
if repaired is not None:
|
|
521
|
+
words[0] = repaired
|
|
522
|
+
return [
|
|
523
|
+
w[1:-1] if len(w) > 1 and w[0] == w[-1] == '"' else w
|
|
524
|
+
for w in words
|
|
525
|
+
]
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _repair_windows_exe(token: str, command: str) -> str | None:
|
|
529
|
+
"""Recover an unquoted `C:\\Python\\python.exe` that posix splitting mangled.
|
|
530
|
+
|
|
531
|
+
posix mode treats the backslashes as escapes, so a bare Windows path loses
|
|
532
|
+
them. When the raw first word exists on disk and the split token does not,
|
|
533
|
+
the raw word is the truth. This runs only on Windows and only for a token
|
|
534
|
+
that looks like an executable, so it cannot silently rewrite an odd command.
|
|
535
|
+
"""
|
|
536
|
+
raw_first = command.strip().split(" ", 1)[0].strip('"')
|
|
537
|
+
if ".exe" not in raw_first.lower():
|
|
538
|
+
return None
|
|
539
|
+
if Path(token).exists() or not Path(raw_first).exists():
|
|
540
|
+
return None
|
|
541
|
+
return raw_first
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
#: Matches an ANSI escape sequence, so captured output stays readable in a diff.
|
|
545
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def strip_ansi(text: str) -> str: # pragma: no cover - convenience helper
|
|
549
|
+
return _ANSI.sub("", text)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def write_scratch(cfg: ExecConfig, suffix: str = ".py") -> Path:
|
|
553
|
+
"""A temp path under the scratch dir, never inside the tracked tree."""
|
|
554
|
+
scratch = Path(cfg.engine_dir or (cfg.repo_root / ".coagent" / "tmp"))
|
|
555
|
+
scratch.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
fd, name = tempfile.mkstemp(suffix=suffix, dir=str(scratch))
|
|
557
|
+
os.close(fd)
|
|
558
|
+
return Path(name)
|
coagent/ui/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The notebook surface (DESIGN §8.1).
|
|
2
|
+
|
|
3
|
+
`render.py` turns the loop's event stream into a transcript as plain data;
|
|
4
|
+
`notebook.py` draws it in a Colab cell and collects approvals. The split exists so
|
|
5
|
+
that the interesting logic can be tested without a browser, and so the CLI can
|
|
6
|
+
reuse the same transcript without widgets.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .render import (
|
|
12
|
+
MODE_GLYPH,
|
|
13
|
+
Row,
|
|
14
|
+
Transcript,
|
|
15
|
+
render_status_line,
|
|
16
|
+
shorten_path,
|
|
17
|
+
summarise_verify,
|
|
18
|
+
tool_summary,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"ChatCell",
|
|
23
|
+
"GradioApp",
|
|
24
|
+
"GradioUnavailable",
|
|
25
|
+
"MODE_GLYPH",
|
|
26
|
+
"Row",
|
|
27
|
+
"Transcript",
|
|
28
|
+
"gradio_available",
|
|
29
|
+
"render_status_line",
|
|
30
|
+
"shorten_path",
|
|
31
|
+
"summarise_verify",
|
|
32
|
+
"tool_summary",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def __getattr__(name: str):
|
|
37
|
+
"""Import the widget surfaces lazily so `render` stays usable headless.
|
|
38
|
+
|
|
39
|
+
Both surfaces pull in a different optional dependency (ipywidgets, gradio);
|
|
40
|
+
neither should be needed to import the transcript that renders them.
|
|
41
|
+
"""
|
|
42
|
+
if name == "ChatCell":
|
|
43
|
+
from .notebook import ChatCell
|
|
44
|
+
|
|
45
|
+
return ChatCell
|
|
46
|
+
if name in {"GradioApp", "GradioUnavailable", "gradio_available"}:
|
|
47
|
+
from . import gradio_app
|
|
48
|
+
|
|
49
|
+
return getattr(gradio_app, name)
|
|
50
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|