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.
Files changed (59) hide show
  1. bugbout/__about__.py +1 -0
  2. bugbout/__init__.py +60 -0
  3. bugbout/__main__.py +5 -0
  4. bugbout/agents/__init__.py +26 -0
  5. bugbout/agents/base.py +287 -0
  6. bugbout/agents/claude_code.py +107 -0
  7. bugbout/agents/codex.py +105 -0
  8. bugbout/agents/command.py +83 -0
  9. bugbout/agents/gemini_cli.py +73 -0
  10. bugbout/agents/mini_swe.py +103 -0
  11. bugbout/agents/plugins/bugbout_mini_env.py +27 -0
  12. bugbout/agents/registry.py +63 -0
  13. bugbout/agents/vendors.py +36 -0
  14. bugbout/arena/__init__.py +30 -0
  15. bugbout/arena/briefs.py +176 -0
  16. bugbout/arena/exhibits.py +78 -0
  17. bugbout/arena/judge.py +301 -0
  18. bugbout/arena/matrix.py +189 -0
  19. bugbout/arena/race.py +266 -0
  20. bugbout/bout.py +316 -0
  21. bugbout/cli/__init__.py +5 -0
  22. bugbout/cli/main.py +168 -0
  23. bugbout/config.py +145 -0
  24. bugbout/doctor.py +83 -0
  25. bugbout/errors.py +39 -0
  26. bugbout/home.py +22 -0
  27. bugbout/jsonfile.py +34 -0
  28. bugbout/log.py +30 -0
  29. bugbout/naming.py +12 -0
  30. bugbout/project.py +129 -0
  31. bugbout/py.typed +0 -0
  32. bugbout/report/__init__.py +19 -0
  33. bugbout/report/build.py +218 -0
  34. bugbout/report/markdown.py +161 -0
  35. bugbout/report/model.py +162 -0
  36. bugbout/report/terminal.py +42 -0
  37. bugbout/report/text.py +38 -0
  38. bugbout/sandbox/__init__.py +51 -0
  39. bugbout/sandbox/base.py +60 -0
  40. bugbout/sandbox/bubblewrap.py +80 -0
  41. bugbout/sandbox/seatbelt.py +71 -0
  42. bugbout/scaffold.py +135 -0
  43. bugbout/store.py +125 -0
  44. bugbout/tasks/__init__.py +18 -0
  45. bugbout/tasks/issues.py +149 -0
  46. bugbout/tasks/mining.py +205 -0
  47. bugbout/tasks/oracle.py +187 -0
  48. bugbout/tasks/task.py +60 -0
  49. bugbout/workspace/__init__.py +8 -0
  50. bugbout/workspace/environment.py +225 -0
  51. bugbout/workspace/junit.py +29 -0
  52. bugbout/workspace/patch.py +261 -0
  53. bugbout/workspace/repository.py +139 -0
  54. bugbout/workspace/workspace.py +85 -0
  55. bugbout-0.1.0.dist-info/METADATA +120 -0
  56. bugbout-0.1.0.dist-info/RECORD +59 -0
  57. bugbout-0.1.0.dist-info/WHEEL +4 -0
  58. bugbout-0.1.0.dist-info/entry_points.txt +2 -0
  59. 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,5 @@
1
+ """``python -m bugbout``."""
2
+
3
+ from bugbout.cli import app
4
+
5
+ app(prog_name="bugbout")
@@ -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
+ }
@@ -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