bugbout 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.
- bugbout/__about__.py +1 -0
- bugbout/__init__.py +60 -0
- bugbout/__main__.py +5 -0
- bugbout/agents/__init__.py +26 -0
- bugbout/agents/base.py +287 -0
- bugbout/agents/claude_code.py +107 -0
- bugbout/agents/codex.py +105 -0
- bugbout/agents/command.py +83 -0
- bugbout/agents/gemini_cli.py +73 -0
- bugbout/agents/mini_swe.py +103 -0
- bugbout/agents/plugins/bugbout_mini_env.py +27 -0
- bugbout/agents/registry.py +63 -0
- bugbout/agents/vendors.py +36 -0
- bugbout/arena/__init__.py +30 -0
- bugbout/arena/briefs.py +176 -0
- bugbout/arena/exhibits.py +78 -0
- bugbout/arena/judge.py +301 -0
- bugbout/arena/matrix.py +189 -0
- bugbout/arena/race.py +266 -0
- bugbout/bout.py +316 -0
- bugbout/cli/__init__.py +5 -0
- bugbout/cli/main.py +168 -0
- bugbout/config.py +145 -0
- bugbout/doctor.py +83 -0
- bugbout/errors.py +39 -0
- bugbout/home.py +22 -0
- bugbout/jsonfile.py +34 -0
- bugbout/log.py +30 -0
- bugbout/naming.py +12 -0
- bugbout/project.py +129 -0
- bugbout/py.typed +0 -0
- bugbout/report/__init__.py +19 -0
- bugbout/report/build.py +218 -0
- bugbout/report/markdown.py +161 -0
- bugbout/report/model.py +162 -0
- bugbout/report/terminal.py +42 -0
- bugbout/report/text.py +38 -0
- bugbout/sandbox/__init__.py +51 -0
- bugbout/sandbox/base.py +60 -0
- bugbout/sandbox/bubblewrap.py +80 -0
- bugbout/sandbox/seatbelt.py +71 -0
- bugbout/scaffold.py +135 -0
- bugbout/store.py +125 -0
- bugbout/tasks/__init__.py +18 -0
- bugbout/tasks/issues.py +149 -0
- bugbout/tasks/mining.py +205 -0
- bugbout/tasks/oracle.py +187 -0
- bugbout/tasks/task.py +60 -0
- bugbout/workspace/__init__.py +8 -0
- bugbout/workspace/environment.py +225 -0
- bugbout/workspace/junit.py +29 -0
- bugbout/workspace/patch.py +261 -0
- bugbout/workspace/repository.py +139 -0
- bugbout/workspace/workspace.py +85 -0
- bugbout-0.1.0.dist-info/METADATA +120 -0
- bugbout-0.1.0.dist-info/RECORD +59 -0
- bugbout-0.1.0.dist-info/WHEEL +4 -0
- bugbout-0.1.0.dist-info/entry_points.txt +2 -0
- bugbout-0.1.0.dist-info/licenses/LICENSE +21 -0
bugbout/__about__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bugbout/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""bugbout: find out which coding agent actually fixes bugs in your codebase.
|
|
2
|
+
|
|
3
|
+
Replay your project's own fixed bugs, let coding agents race to fix them again,
|
|
4
|
+
and score every patch with the tests the maintainers wrote::
|
|
5
|
+
|
|
6
|
+
from bugbout import Bout, render_markdown
|
|
7
|
+
|
|
8
|
+
report = Bout.load(".").run() # reads bugbout.toml
|
|
9
|
+
print(render_markdown(report))
|
|
10
|
+
|
|
11
|
+
Agents can also be passed in directly, including ones of your own::
|
|
12
|
+
|
|
13
|
+
from bugbout import Bout, ClaudeCode, Codex, Project
|
|
14
|
+
|
|
15
|
+
bout = Bout(Project.load("."), contestants=[Codex("gpt-5.1-codex"), ClaudeCode("sonnet")])
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from bugbout.__about__ import __version__
|
|
19
|
+
from bugbout.agents import (
|
|
20
|
+
AgentRun,
|
|
21
|
+
ClaudeCode,
|
|
22
|
+
Codex,
|
|
23
|
+
CodingAgent,
|
|
24
|
+
CommandAgent,
|
|
25
|
+
GeminiCli,
|
|
26
|
+
Invocation,
|
|
27
|
+
MiniSweAgent,
|
|
28
|
+
Session,
|
|
29
|
+
create_agent,
|
|
30
|
+
)
|
|
31
|
+
from bugbout.arena.briefs import ASSIGNMENTS
|
|
32
|
+
from bugbout.arena.judge import POLICIES, JudgePolicy
|
|
33
|
+
from bugbout.bout import Bout
|
|
34
|
+
from bugbout.errors import BugboutError
|
|
35
|
+
from bugbout.project import Examiner, Project
|
|
36
|
+
from bugbout.report import Report, render_markdown, render_terminal
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"ASSIGNMENTS",
|
|
40
|
+
"POLICIES",
|
|
41
|
+
"AgentRun",
|
|
42
|
+
"Bout",
|
|
43
|
+
"BugboutError",
|
|
44
|
+
"ClaudeCode",
|
|
45
|
+
"Codex",
|
|
46
|
+
"CodingAgent",
|
|
47
|
+
"CommandAgent",
|
|
48
|
+
"Examiner",
|
|
49
|
+
"GeminiCli",
|
|
50
|
+
"Invocation",
|
|
51
|
+
"JudgePolicy",
|
|
52
|
+
"MiniSweAgent",
|
|
53
|
+
"Project",
|
|
54
|
+
"Report",
|
|
55
|
+
"Session",
|
|
56
|
+
"__version__",
|
|
57
|
+
"create_agent",
|
|
58
|
+
"render_markdown",
|
|
59
|
+
"render_terminal",
|
|
60
|
+
]
|
bugbout/__main__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Adapters that drive coding-agent CLIs headlessly, and the registry that names them."""
|
|
2
|
+
|
|
3
|
+
from bugbout.agents.base import AgentRun, CodingAgent, Identity, Invocation, Session
|
|
4
|
+
from bugbout.agents.claude_code import ClaudeCode
|
|
5
|
+
from bugbout.agents.codex import Codex
|
|
6
|
+
from bugbout.agents.command import CommandAgent
|
|
7
|
+
from bugbout.agents.gemini_cli import GeminiCli
|
|
8
|
+
from bugbout.agents.mini_swe import MiniSweAgent
|
|
9
|
+
from bugbout.agents.registry import agent_types, create_agent
|
|
10
|
+
from bugbout.agents.vendors import vendor_of
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AgentRun",
|
|
14
|
+
"ClaudeCode",
|
|
15
|
+
"Codex",
|
|
16
|
+
"CodingAgent",
|
|
17
|
+
"CommandAgent",
|
|
18
|
+
"GeminiCli",
|
|
19
|
+
"Identity",
|
|
20
|
+
"Invocation",
|
|
21
|
+
"MiniSweAgent",
|
|
22
|
+
"Session",
|
|
23
|
+
"agent_types",
|
|
24
|
+
"create_agent",
|
|
25
|
+
"vendor_of",
|
|
26
|
+
]
|
bugbout/agents/base.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""A coding agent, as bugbout sees it: a command line that edits a checkout.
|
|
2
|
+
|
|
3
|
+
bugbout reimplements no agent. Each adapter drives the same off-the-shelf CLI a
|
|
4
|
+
developer runs, headlessly, and judges it only by what it leaves on disk. Two
|
|
5
|
+
rules hold for every adapter, because a bout is only as fair as its weakest
|
|
6
|
+
contestant:
|
|
7
|
+
|
|
8
|
+
* **Nothing personal leaks in.** A developer's agent configuration carries
|
|
9
|
+
plugins, memories, MCP servers and house rules. Each adapter starts its CLI
|
|
10
|
+
from a clean configuration, so the comparison is between agents, not set-ups.
|
|
11
|
+
* **Nothing from the future leaks in.** Tasks are real bugs with public fixes,
|
|
12
|
+
so web tools are off, shell commands are sandboxed, and the checkout has no
|
|
13
|
+
``.git`` to read.
|
|
14
|
+
|
|
15
|
+
Writing an adapter for another agent means implementing :meth:`CodingAgent.invocation`
|
|
16
|
+
and, optionally, :meth:`~CodingAgent.prepare` and :meth:`~CodingAgent.parse`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import threading
|
|
28
|
+
import time
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
from dataclasses import asdict, dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, ClassVar, TypedDict
|
|
33
|
+
|
|
34
|
+
from bugbout.agents.vendors import vendor_of
|
|
35
|
+
from bugbout.errors import AgentNotAvailableError
|
|
36
|
+
from bugbout.sandbox import Sandbox
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class AgentRun:
|
|
41
|
+
"""How one run of an agent ended, how long it took, and what it cost."""
|
|
42
|
+
|
|
43
|
+
agent: str
|
|
44
|
+
exit_code: int | None
|
|
45
|
+
timed_out: bool
|
|
46
|
+
seconds: float
|
|
47
|
+
summary: str = ""
|
|
48
|
+
usage: dict[str, int] = field(default_factory=dict)
|
|
49
|
+
error: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def ok(self) -> bool:
|
|
53
|
+
return not self.timed_out and not self.error
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
return asdict(self)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_dict(cls, data: dict[str, Any]) -> AgentRun:
|
|
60
|
+
return cls(**{k: v for k, v in data.items() if k in set(cls.__slots__)})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Session:
|
|
65
|
+
"""Everything one run of an agent is given."""
|
|
66
|
+
|
|
67
|
+
prompt: str
|
|
68
|
+
workdir: Path
|
|
69
|
+
"""The checkout the agent works in, and the only place it may change."""
|
|
70
|
+
|
|
71
|
+
logdir: Path
|
|
72
|
+
"""Where the agent's own logs go."""
|
|
73
|
+
|
|
74
|
+
sandbox: Sandbox
|
|
75
|
+
"""Confines the agent's commands, for agents that do not confine themselves."""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Invocation:
|
|
80
|
+
"""The command line that starts one run."""
|
|
81
|
+
|
|
82
|
+
argv: list[str]
|
|
83
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
84
|
+
"""Added to the caller's environment, which the agent inherits for its credentials."""
|
|
85
|
+
|
|
86
|
+
stdin: str | None = None
|
|
87
|
+
unset: tuple[str, ...] = ()
|
|
88
|
+
"""Variables of the caller's environment the agent must not inherit."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Identity(TypedDict, total=False):
|
|
92
|
+
"""How a configured agent is told apart from others: overrides for the defaults."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
vendor: str
|
|
96
|
+
quota: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class CodingAgent(ABC):
|
|
100
|
+
"""One coding-agent CLI, configured with one model."""
|
|
101
|
+
|
|
102
|
+
harness: ClassVar[str]
|
|
103
|
+
"""The agent's own name, such as ``codex``; the default prefix of :attr:`name`."""
|
|
104
|
+
|
|
105
|
+
executable: str
|
|
106
|
+
"""The command that starts it."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
model: str = "",
|
|
111
|
+
*,
|
|
112
|
+
name: str | None = None,
|
|
113
|
+
vendor: str | None = None,
|
|
114
|
+
quota: str | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
self.model = model
|
|
117
|
+
"""The model to run; empty for the CLI's own default."""
|
|
118
|
+
self.name = name or (f"{self.harness}/{model}" if model else self.harness)
|
|
119
|
+
"""Unique within a bout; used in reports and paths."""
|
|
120
|
+
self.vendor = vendor or vendor_of(model or self.harness)
|
|
121
|
+
"""Who made the model, so the judge can keep evidence independent."""
|
|
122
|
+
self.quota = quota or self.harness
|
|
123
|
+
"""The rate limit this agent draws on. Agents sharing one run in a shared lane."""
|
|
124
|
+
|
|
125
|
+
def __repr__(self) -> str:
|
|
126
|
+
return f"{type(self).__name__}({self.name!r})"
|
|
127
|
+
|
|
128
|
+
# --- what an adapter provides ---------------------------------------------------
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def invocation(self, session: Session) -> Invocation:
|
|
132
|
+
"""The command line for one run."""
|
|
133
|
+
|
|
134
|
+
def prepare(self, workdir: Path) -> None: # noqa: B027 - an optional hook
|
|
135
|
+
"""Set up the checkout before the agent starts, e.g. workspace settings."""
|
|
136
|
+
|
|
137
|
+
def parse(self, logdir: Path) -> tuple[str, dict[str, int]]: # noqa: ARG002 - a default
|
|
138
|
+
"""The agent's closing summary and token usage, read from its logs."""
|
|
139
|
+
return "", {}
|
|
140
|
+
|
|
141
|
+
def describe(self) -> dict[str, str]:
|
|
142
|
+
"""What a reader needs to reproduce this agent's runs."""
|
|
143
|
+
return {
|
|
144
|
+
"harness": self.harness,
|
|
145
|
+
"version": version_of(self.command()),
|
|
146
|
+
"model": self.model or "the CLI's default",
|
|
147
|
+
"vendor": self.vendor,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# --- running ----------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def command(self) -> str:
|
|
153
|
+
"""The executable to start: found on ``PATH`` or in a per-user install."""
|
|
154
|
+
return find_executable(self.executable) or self.executable
|
|
155
|
+
|
|
156
|
+
def check(self) -> None:
|
|
157
|
+
"""Raise :class:`AgentNotAvailableError` unless the agent can be started."""
|
|
158
|
+
if find_executable(self.executable) is None:
|
|
159
|
+
raise AgentNotAvailableError(f"{self.name}: `{self.executable}` is not installed")
|
|
160
|
+
|
|
161
|
+
def run(
|
|
162
|
+
self, prompt: str, workdir: Path, logdir: Path, *, sandbox: Sandbox, timeout: int
|
|
163
|
+
) -> AgentRun:
|
|
164
|
+
"""Start the agent in ``workdir``, wait for it, and record how it ended."""
|
|
165
|
+
logdir.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
self.prepare(workdir)
|
|
167
|
+
invocation = self.invocation(Session(prompt, workdir, logdir, sandbox))
|
|
168
|
+
started = time.monotonic()
|
|
169
|
+
code, timed_out, error = _execute(invocation, workdir, logdir, timeout)
|
|
170
|
+
summary, usage = self.parse(logdir)
|
|
171
|
+
return AgentRun(
|
|
172
|
+
agent=self.name,
|
|
173
|
+
exit_code=code,
|
|
174
|
+
timed_out=timed_out,
|
|
175
|
+
seconds=round(time.monotonic() - started, 1),
|
|
176
|
+
summary=summary,
|
|
177
|
+
usage=usage,
|
|
178
|
+
error=error,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _execute(
|
|
183
|
+
invocation: Invocation, workdir: Path, logdir: Path, timeout: int
|
|
184
|
+
) -> tuple[int | None, bool, str]:
|
|
185
|
+
"""Run to the end or the timeout: the exit code, whether it timed out, and any error."""
|
|
186
|
+
with (
|
|
187
|
+
(logdir / "stdout.log").open("w", encoding="utf-8") as out,
|
|
188
|
+
(logdir / "stderr.log").open("w", encoding="utf-8") as err,
|
|
189
|
+
):
|
|
190
|
+
try:
|
|
191
|
+
# A session of its own, so a timeout can take down every process the
|
|
192
|
+
# agent spawned - an orphaned test run would otherwise keep going.
|
|
193
|
+
process = subprocess.Popen( # noqa: S603 - an adapter's own command line
|
|
194
|
+
invocation.argv,
|
|
195
|
+
cwd=workdir,
|
|
196
|
+
env={**_inherited(invocation.unset), **invocation.env},
|
|
197
|
+
stdin=subprocess.PIPE,
|
|
198
|
+
stdout=out,
|
|
199
|
+
stderr=err,
|
|
200
|
+
text=True,
|
|
201
|
+
start_new_session=True,
|
|
202
|
+
)
|
|
203
|
+
except OSError as exc:
|
|
204
|
+
return None, False, f"could not start {invocation.argv[0]}: {exc}"
|
|
205
|
+
with _RUNNING_LOCK:
|
|
206
|
+
_RUNNING.add(process)
|
|
207
|
+
try:
|
|
208
|
+
process.communicate(input=invocation.stdin, timeout=timeout)
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
_kill(process)
|
|
211
|
+
return None, True, f"timed out after {timeout}s"
|
|
212
|
+
except BaseException:
|
|
213
|
+
_kill(process) # interrupted: never leave an agent running unattended
|
|
214
|
+
raise
|
|
215
|
+
finally:
|
|
216
|
+
with _RUNNING_LOCK:
|
|
217
|
+
_RUNNING.discard(process)
|
|
218
|
+
code = process.returncode
|
|
219
|
+
return code, False, "" if code == 0 else f"exit code {code}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _inherited(unset: tuple[str, ...]) -> dict[str, str]:
|
|
223
|
+
return {key: value for key, value in os.environ.items() if key not in unset}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_RUNNING: set[subprocess.Popen[str]] = set()
|
|
227
|
+
_RUNNING_LOCK = threading.Lock()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def stop_all() -> None:
|
|
231
|
+
"""Kill every agent still running, with everything it started.
|
|
232
|
+
|
|
233
|
+
Agents run in sessions of their own, so an interrupt in the terminal does
|
|
234
|
+
not reach them; whoever handles the interrupt calls this.
|
|
235
|
+
"""
|
|
236
|
+
with _RUNNING_LOCK:
|
|
237
|
+
running = list(_RUNNING)
|
|
238
|
+
for process in running:
|
|
239
|
+
_kill(process)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _kill(process: subprocess.Popen[str]) -> None:
|
|
243
|
+
with contextlib.suppress(ProcessLookupError):
|
|
244
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
245
|
+
process.wait()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def find_executable(name: str) -> str | None:
|
|
249
|
+
"""``name`` on ``PATH``, or where a per-user npm or uv install puts it."""
|
|
250
|
+
for candidate in (
|
|
251
|
+
shutil.which(name),
|
|
252
|
+
str(Path.home() / ".npm-global" / "bin" / name),
|
|
253
|
+
str(Path.home() / ".local" / "bin" / name),
|
|
254
|
+
):
|
|
255
|
+
if candidate and Path(candidate).is_file():
|
|
256
|
+
return candidate
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def version_of(executable: str) -> str:
|
|
261
|
+
"""The first line ``executable --version`` prints."""
|
|
262
|
+
try:
|
|
263
|
+
completed = subprocess.run( # noqa: S603 - a resolved CLI path and a literal flag
|
|
264
|
+
[executable, "--version"], capture_output=True, text=True, timeout=30, check=False
|
|
265
|
+
)
|
|
266
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
267
|
+
return "unavailable"
|
|
268
|
+
lines = completed.stdout.strip().splitlines()
|
|
269
|
+
return lines[0] if lines else "unknown"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
273
|
+
"""Every JSON object in a line-delimited log, skipping anything else."""
|
|
274
|
+
if not path.exists():
|
|
275
|
+
return []
|
|
276
|
+
events: list[dict[str, Any]] = []
|
|
277
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
278
|
+
line = line.strip()
|
|
279
|
+
if not line.startswith("{"):
|
|
280
|
+
continue
|
|
281
|
+
try:
|
|
282
|
+
parsed = json.loads(line)
|
|
283
|
+
except json.JSONDecodeError:
|
|
284
|
+
continue
|
|
285
|
+
if isinstance(parsed, dict):
|
|
286
|
+
events.append(parsed)
|
|
287
|
+
return events
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Anthropic's Claude Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Unpack, override
|
|
8
|
+
|
|
9
|
+
from bugbout.agents.base import CodingAgent, Identity, Invocation, Session, read_jsonl
|
|
10
|
+
|
|
11
|
+
# Shell commands run in Claude Code's own sandbox and only there: if the sandbox
|
|
12
|
+
# cannot start, Claude Code stops instead of running them unconfined.
|
|
13
|
+
_SETTINGS = {
|
|
14
|
+
"sandbox": {
|
|
15
|
+
"enabled": True,
|
|
16
|
+
"failIfUnavailable": True,
|
|
17
|
+
"autoAllowBashIfSandboxed": True,
|
|
18
|
+
"allowUnsandboxedCommands": False,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# What a running Claude Code session exports to the commands it runs. bugbout may
|
|
24
|
+
# itself be run from inside Claude Code; a contestant is a session of its own and
|
|
25
|
+
# must never attach to that one.
|
|
26
|
+
PARENT_SESSION = (
|
|
27
|
+
"CLAUDECODE",
|
|
28
|
+
"CLAUDE_AGENT_SDK_VERSION",
|
|
29
|
+
"CLAUDE_CODE_CHILD_SESSION",
|
|
30
|
+
"CLAUDE_CODE_EMIT_STARTUP_TIMING",
|
|
31
|
+
"CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING",
|
|
32
|
+
"CLAUDE_CODE_ENABLE_TASKS",
|
|
33
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
34
|
+
"CLAUDE_CODE_EXECPATH",
|
|
35
|
+
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
36
|
+
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
37
|
+
"CLAUDE_CODE_QUESTION_PREVIEW_FORMAT",
|
|
38
|
+
"CLAUDE_CODE_SESSION_ATTENDED",
|
|
39
|
+
"CLAUDE_CODE_SESSION_ID",
|
|
40
|
+
"CLAUDE_CODE_SSE_PORT",
|
|
41
|
+
"CLAUDE_EFFORT",
|
|
42
|
+
"CLAUDE_PID",
|
|
43
|
+
"ENABLE_IDE_INTEGRATION",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClaudeCode(CodingAgent):
|
|
48
|
+
"""``claude --print`` from a clean configuration, in Claude Code's own sandbox.
|
|
49
|
+
|
|
50
|
+
Safe mode leaves out the user's CLAUDE.md, skills, plugins, hooks and MCP
|
|
51
|
+
servers; web tools are disallowed and no session is saved. Edits are
|
|
52
|
+
accepted inside the checkout only, shell commands run sandboxed, and anything
|
|
53
|
+
that would need a person's approval is refused rather than asked about.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
harness = "claude-code"
|
|
57
|
+
executable = "claude"
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self, model: str = "", *, effort: str | None = None, **identity: Unpack[Identity]
|
|
61
|
+
) -> None:
|
|
62
|
+
"""``model`` is a full model name or an alias such as ``sonnet``; empty for the default."""
|
|
63
|
+
identity.setdefault("vendor", "anthropic") # an alias names no vendor
|
|
64
|
+
super().__init__(model, **identity)
|
|
65
|
+
self.effort = effort
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
def invocation(self, session: Session) -> Invocation:
|
|
69
|
+
argv = [
|
|
70
|
+
self.command(),
|
|
71
|
+
"--print",
|
|
72
|
+
"--output-format", "stream-json",
|
|
73
|
+
"--verbose",
|
|
74
|
+
*(["--model", self.model] if self.model else []),
|
|
75
|
+
*(["--effort", self.effort] if self.effort else []),
|
|
76
|
+
"--safe-mode",
|
|
77
|
+
"--no-session-persistence",
|
|
78
|
+
"--permission-mode", "acceptEdits",
|
|
79
|
+
"--permission-prompts", "none",
|
|
80
|
+
"--settings", json.dumps(_SETTINGS),
|
|
81
|
+
"--disallowedTools", "WebFetch,WebSearch",
|
|
82
|
+
] # fmt: skip
|
|
83
|
+
return Invocation(argv, stdin=session.prompt, unset=PARENT_SESSION)
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
|
|
87
|
+
for event in reversed(read_jsonl(logdir / "stdout.log")):
|
|
88
|
+
if event.get("type") == "result":
|
|
89
|
+
usage = event.get("usage")
|
|
90
|
+
counts = (
|
|
91
|
+
{k: v for k, v in usage.items() if isinstance(v, int)}
|
|
92
|
+
if isinstance(usage, dict)
|
|
93
|
+
else {}
|
|
94
|
+
)
|
|
95
|
+
if isinstance(turns := event.get("num_turns"), int):
|
|
96
|
+
counts["turns"] = turns
|
|
97
|
+
return str(event.get("result") or "").strip(), counts
|
|
98
|
+
return "", {}
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
def describe(self) -> dict[str, str]:
|
|
102
|
+
return {
|
|
103
|
+
**super().describe(),
|
|
104
|
+
"effort": self.effort or "model default",
|
|
105
|
+
"sandbox": "Claude Code's sandbox for shell commands; edits only in the checkout",
|
|
106
|
+
"configuration": "safe mode (no CLAUDE.md, skills, plugins, hooks or MCP); no web tools",
|
|
107
|
+
}
|
bugbout/agents/codex.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""OpenAI's Codex CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Unpack, override
|
|
7
|
+
|
|
8
|
+
from bugbout.agents.base import CodingAgent, Identity, Invocation, Session, read_jsonl
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Codex(CodingAgent):
|
|
12
|
+
"""``codex exec`` from a clean configuration, in Codex's own sandbox.
|
|
13
|
+
|
|
14
|
+
The user's config and rules are ignored, the session is ephemeral, and every
|
|
15
|
+
feature that reaches beyond the checkout is switched off. Shell commands run
|
|
16
|
+
in Codex's ``workspace-write`` sandbox: no network, writes only in the
|
|
17
|
+
checkout. A model runs at its default reasoning effort unless told otherwise,
|
|
18
|
+
so agents are compared as shipped, not as tuned.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
harness = "codex"
|
|
22
|
+
executable = "codex"
|
|
23
|
+
|
|
24
|
+
# Switched off through config overrides, which a Codex version that does not
|
|
25
|
+
# know a feature ignores - where ``--disable`` would refuse to start.
|
|
26
|
+
DISABLED_FEATURES = (
|
|
27
|
+
"apps",
|
|
28
|
+
"browser_use",
|
|
29
|
+
"browser_use_external",
|
|
30
|
+
"browser_use_full_cdp_access",
|
|
31
|
+
"computer_use",
|
|
32
|
+
"hooks",
|
|
33
|
+
"image_generation",
|
|
34
|
+
"in_app_browser",
|
|
35
|
+
"memories",
|
|
36
|
+
"plugins",
|
|
37
|
+
"remote_plugin",
|
|
38
|
+
"skill_mcp_dependency_install",
|
|
39
|
+
"skill_search",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
model: str = "",
|
|
45
|
+
*,
|
|
46
|
+
reasoning_effort: str | None = None,
|
|
47
|
+
local_provider: str | None = None,
|
|
48
|
+
**identity: Unpack[Identity],
|
|
49
|
+
) -> None:
|
|
50
|
+
"""``local_provider`` (``ollama`` or ``lmstudio``) serves an open model locally."""
|
|
51
|
+
identity.setdefault("quota", local_provider or self.harness)
|
|
52
|
+
super().__init__(model, **identity)
|
|
53
|
+
self.reasoning_effort = reasoning_effort
|
|
54
|
+
self.local_provider = local_provider
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def invocation(self, session: Session) -> Invocation:
|
|
58
|
+
effort = (
|
|
59
|
+
["-c", f'model_reasoning_effort="{self.reasoning_effort}"']
|
|
60
|
+
if self.reasoning_effort
|
|
61
|
+
else []
|
|
62
|
+
)
|
|
63
|
+
local = ["--oss", "--local-provider", self.local_provider] if self.local_provider else []
|
|
64
|
+
argv = [
|
|
65
|
+
self.command(),
|
|
66
|
+
"exec",
|
|
67
|
+
"--ignore-user-config",
|
|
68
|
+
"--ignore-rules",
|
|
69
|
+
"--ephemeral",
|
|
70
|
+
"--skip-git-repo-check",
|
|
71
|
+
"--sandbox", "workspace-write",
|
|
72
|
+
"--cd", str(session.workdir),
|
|
73
|
+
*(["--model", self.model] if self.model else []),
|
|
74
|
+
*effort,
|
|
75
|
+
*local,
|
|
76
|
+
"-c", 'web_search="disabled"',
|
|
77
|
+
*[arg for feature in self.DISABLED_FEATURES for arg in ("-c", f"features.{feature}=false")],
|
|
78
|
+
"--json",
|
|
79
|
+
"--color", "never",
|
|
80
|
+
"--output-last-message", str(session.logdir / "last_message.txt"),
|
|
81
|
+
"-",
|
|
82
|
+
] # fmt: skip
|
|
83
|
+
return Invocation(argv, stdin=session.prompt)
|
|
84
|
+
|
|
85
|
+
@override
|
|
86
|
+
def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
|
|
87
|
+
last = logdir / "last_message.txt"
|
|
88
|
+
summary = last.read_text(encoding="utf-8").strip() if last.exists() else ""
|
|
89
|
+
usage: dict[str, int] = {}
|
|
90
|
+
for event in read_jsonl(logdir / "stdout.log"):
|
|
91
|
+
if isinstance(found := event.get("usage"), dict):
|
|
92
|
+
for key, value in found.items():
|
|
93
|
+
if isinstance(value, int):
|
|
94
|
+
usage[key] = usage.get(key, 0) + value
|
|
95
|
+
return summary, usage
|
|
96
|
+
|
|
97
|
+
@override
|
|
98
|
+
def describe(self) -> dict[str, str]:
|
|
99
|
+
return {
|
|
100
|
+
**super().describe(),
|
|
101
|
+
"reasoning_effort": self.reasoning_effort or "model default",
|
|
102
|
+
"provider": f"{self.local_provider} (local)" if self.local_provider else "OpenAI",
|
|
103
|
+
"sandbox": "Codex workspace-write: no network for commands, writes only in the checkout",
|
|
104
|
+
"configuration": "user config and rules ignored; web search, plugins and hooks off",
|
|
105
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Any coding agent with a command line, configured instead of coded."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import override
|
|
8
|
+
|
|
9
|
+
from bugbout.agents.base import CodingAgent, Invocation, Session
|
|
10
|
+
from bugbout.agents.vendors import vendor_of
|
|
11
|
+
from bugbout.errors import ConfigError
|
|
12
|
+
from bugbout.sandbox import Confinement
|
|
13
|
+
|
|
14
|
+
PLACEHOLDERS = ("prompt", "prompt_file", "workdir", "logdir", "model")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CommandAgent(CodingAgent):
|
|
18
|
+
"""Runs a command line, with placeholders filled in for each run.
|
|
19
|
+
|
|
20
|
+
In the command, ``{prompt}``, ``{prompt_file}``, ``{workdir}``, ``{logdir}``
|
|
21
|
+
and ``{model}`` are replaced; when it uses neither ``{prompt}`` nor
|
|
22
|
+
``{prompt_file}``, the prompt arrives on standard input. The whole process
|
|
23
|
+
runs in bugbout's sandbox - it may use the network to reach its model, but
|
|
24
|
+
write only in the checkout, its logs and the ``state_dirs`` it names.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
harness = "command"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
command: Sequence[str],
|
|
32
|
+
*,
|
|
33
|
+
name: str,
|
|
34
|
+
model: str = "",
|
|
35
|
+
env: dict[str, str] | None = None,
|
|
36
|
+
state_dirs: Sequence[str] = (),
|
|
37
|
+
sandboxed: bool = True,
|
|
38
|
+
vendor: str | None = None,
|
|
39
|
+
quota: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
if not command:
|
|
42
|
+
raise ConfigError(f"agent {name!r}: the command is empty")
|
|
43
|
+
super().__init__(
|
|
44
|
+
model, name=name, vendor=vendor or vendor_of(model or name), quota=quota or name
|
|
45
|
+
)
|
|
46
|
+
self.argv = list(command)
|
|
47
|
+
self.executable = self.argv[0]
|
|
48
|
+
self.env = env or {}
|
|
49
|
+
self.state_dirs = tuple(Path(d).expanduser() for d in state_dirs)
|
|
50
|
+
self.sandboxed = sandboxed
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def invocation(self, session: Session) -> Invocation:
|
|
54
|
+
prompt_file = session.logdir / "prompt.md"
|
|
55
|
+
prompt_file.write_text(session.prompt, encoding="utf-8")
|
|
56
|
+
values = {
|
|
57
|
+
"prompt": session.prompt,
|
|
58
|
+
"prompt_file": str(prompt_file),
|
|
59
|
+
"workdir": str(session.workdir),
|
|
60
|
+
"logdir": str(session.logdir),
|
|
61
|
+
"model": self.model,
|
|
62
|
+
}
|
|
63
|
+
argv = [self.command(), *(_fill(arg, values) for arg in self.argv[1:])]
|
|
64
|
+
takes_prompt = any("{prompt}" in arg or "{prompt_file}" in arg for arg in self.argv)
|
|
65
|
+
if self.sandboxed:
|
|
66
|
+
writable = (session.workdir, session.logdir, *self.state_dirs)
|
|
67
|
+
argv = session.sandbox.wrap(argv, Confinement(writable=writable, network=True))
|
|
68
|
+
return Invocation(argv, env=self.env, stdin=None if takes_prompt else session.prompt)
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
def describe(self) -> dict[str, str]:
|
|
72
|
+
return {
|
|
73
|
+
**super().describe(),
|
|
74
|
+
"command": " ".join(self.argv),
|
|
75
|
+
"sandbox": "bugbout's sandbox" if self.sandboxed else "none",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _fill(arg: str, values: dict[str, str]) -> str:
|
|
80
|
+
# Plain replacement, not str.format: arguments may hold braces of their own.
|
|
81
|
+
for key in PLACEHOLDERS:
|
|
82
|
+
arg = arg.replace("{" + key + "}", values[key])
|
|
83
|
+
return arg
|