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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Google's Gemini CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import override
|
|
8
|
+
|
|
9
|
+
from bugbout.agents.base import CodingAgent, Invocation, Session, read_jsonl
|
|
10
|
+
from bugbout.sandbox import Confinement
|
|
11
|
+
|
|
12
|
+
# Workspace settings, not the user's: no web tools, nothing reported home.
|
|
13
|
+
_SETTINGS = {
|
|
14
|
+
"tools": {"exclude": ["web_fetch", "google_web_search"]},
|
|
15
|
+
"privacy": {"usageStatisticsEnabled": False},
|
|
16
|
+
"telemetry": {"enabled": False},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GeminiCli(CodingAgent):
|
|
21
|
+
"""``gemini --prompt`` in auto-approve mode, inside bugbout's sandbox.
|
|
22
|
+
|
|
23
|
+
Web tools are excluded through workspace settings. The whole process runs in
|
|
24
|
+
bugbout's sandbox: it reaches the Gemini API, but writes only in the
|
|
25
|
+
checkout, its logs and Gemini's own state directory.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
harness = "gemini-cli"
|
|
29
|
+
executable = "gemini"
|
|
30
|
+
|
|
31
|
+
@override
|
|
32
|
+
def prepare(self, workdir: Path) -> None:
|
|
33
|
+
folder = workdir / ".gemini" # never part of a measured patch
|
|
34
|
+
folder.mkdir(exist_ok=True)
|
|
35
|
+
(folder / "settings.json").write_text(json.dumps(_SETTINGS, indent=2), encoding="utf-8")
|
|
36
|
+
|
|
37
|
+
@override
|
|
38
|
+
def invocation(self, session: Session) -> Invocation:
|
|
39
|
+
argv = [
|
|
40
|
+
self.command(),
|
|
41
|
+
"--prompt", session.prompt,
|
|
42
|
+
*(["--model", self.model] if self.model else []),
|
|
43
|
+
"--approval-mode", "yolo",
|
|
44
|
+
"--output-format", "stream-json",
|
|
45
|
+
"--skip-trust",
|
|
46
|
+
] # fmt: skip
|
|
47
|
+
state = Path.home() / ".gemini"
|
|
48
|
+
confinement = Confinement(writable=(session.workdir, session.logdir, state), network=True)
|
|
49
|
+
# Gemini CLI would otherwise start a sandbox of its own inside bugbout's.
|
|
50
|
+
return Invocation(session.sandbox.wrap(argv, confinement), env={"GEMINI_SANDBOX": "false"})
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
|
|
54
|
+
parts: list[str] = []
|
|
55
|
+
usage: dict[str, int] = {}
|
|
56
|
+
for event in read_jsonl(logdir / "stdout.log"):
|
|
57
|
+
if event.get("type") == "message" and event.get("role") == "assistant":
|
|
58
|
+
parts.append(str(event.get("content", "")))
|
|
59
|
+
if event.get("type") != "result":
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(stats := event.get("stats"), dict):
|
|
62
|
+
usage = {k: v for k, v in stats.items() if isinstance(v, int)}
|
|
63
|
+
if isinstance(error := event.get("error"), dict) and not parts:
|
|
64
|
+
parts.append(str(error.get("message", ""))) # why it stopped, as Gemini put it
|
|
65
|
+
return "".join(parts).strip()[-4000:], usage
|
|
66
|
+
|
|
67
|
+
@override
|
|
68
|
+
def describe(self) -> dict[str, str]:
|
|
69
|
+
return {
|
|
70
|
+
**super().describe(),
|
|
71
|
+
"sandbox": "bugbout's sandbox: writes only in the checkout and ~/.gemini",
|
|
72
|
+
"configuration": "workspace settings; web_fetch and google_web_search excluded",
|
|
73
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""mini-swe-agent, the SWE-bench team's minimal agent, on any LiteLLM model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Unpack, override
|
|
9
|
+
|
|
10
|
+
from bugbout.agents.base import CodingAgent, Identity, Invocation, Session
|
|
11
|
+
from bugbout.errors import ConfigError
|
|
12
|
+
from bugbout.sandbox import Confinement
|
|
13
|
+
|
|
14
|
+
PLUGINS = Path(__file__).resolve().parent / "plugins"
|
|
15
|
+
SANDBOX_PREFIX_ENV = "BUGBOUT_SANDBOX_PREFIX"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MiniSweAgent(CodingAgent):
|
|
19
|
+
"""``mini`` with an environment class that runs every command in bugbout's sandbox.
|
|
20
|
+
|
|
21
|
+
mini-swe-agent runs shell commands directly on the host, so bugbout supplies
|
|
22
|
+
its own environment (``plugins/bugbout_mini_env.py``): each command gets no
|
|
23
|
+
network and may write only in the checkout. Model calls stay in the agent's
|
|
24
|
+
own process, outside the sandbox.
|
|
25
|
+
|
|
26
|
+
Text actions - one bash block per reply - suit local models whose server
|
|
27
|
+
cannot parse tool calls reliably.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
harness = "mini-swe-agent"
|
|
31
|
+
executable = "mini"
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
model: str,
|
|
36
|
+
*,
|
|
37
|
+
text_actions: bool = False,
|
|
38
|
+
step_limit: int = 75,
|
|
39
|
+
model_kwargs: dict[str, str] | None = None,
|
|
40
|
+
**identity: Unpack[Identity],
|
|
41
|
+
) -> None:
|
|
42
|
+
"""``model`` is a LiteLLM model name, such as ``ollama/qwen3-coder:30b``."""
|
|
43
|
+
if not model:
|
|
44
|
+
raise ConfigError("mini-swe-agent needs a model: a LiteLLM name such as ollama/qwen3")
|
|
45
|
+
provider = model.split("/", 1)[0] if "/" in model else self.harness
|
|
46
|
+
identity.setdefault("quota", provider)
|
|
47
|
+
super().__init__(model, **identity)
|
|
48
|
+
self.text_actions = text_actions
|
|
49
|
+
self.step_limit = step_limit
|
|
50
|
+
self.model_kwargs = model_kwargs or {}
|
|
51
|
+
|
|
52
|
+
@override
|
|
53
|
+
def invocation(self, session: Session) -> Invocation:
|
|
54
|
+
settings = [f"agent.step_limit={self.step_limit}", f"environment.cwd={session.workdir}"]
|
|
55
|
+
settings += [
|
|
56
|
+
f"model.model_kwargs.{key}={value}" for key, value in self.model_kwargs.items()
|
|
57
|
+
]
|
|
58
|
+
argv = [
|
|
59
|
+
self.command(),
|
|
60
|
+
"--model", self.model,
|
|
61
|
+
"--task", session.prompt,
|
|
62
|
+
"--yolo",
|
|
63
|
+
"--exit-immediately",
|
|
64
|
+
"--cost-limit", "0",
|
|
65
|
+
"--environment-class", "bugbout_mini_env.SandboxedEnvironment",
|
|
66
|
+
*(["--model-class", "litellm_textbased"] if self.text_actions else []),
|
|
67
|
+
"-c", "mini_textbased.yaml" if self.text_actions else "mini.yaml",
|
|
68
|
+
*[arg for setting in settings for arg in ("-c", setting)],
|
|
69
|
+
"--output", str(session.logdir / "trajectory.json"),
|
|
70
|
+
] # fmt: skip
|
|
71
|
+
confinement = Confinement(writable=(session.workdir, session.logdir))
|
|
72
|
+
inherited = os.environ.get("PYTHONPATH")
|
|
73
|
+
env = {
|
|
74
|
+
"PYTHONPATH": os.pathsep.join(filter(None, [str(PLUGINS), inherited])),
|
|
75
|
+
SANDBOX_PREFIX_ENV: json.dumps(session.sandbox.prefix(confinement)),
|
|
76
|
+
"MSWEA_COST_TRACKING": "ignore_errors",
|
|
77
|
+
"MSWEA_CONFIGURED": "true",
|
|
78
|
+
"LITELLM_LOCAL_MODEL_COST_MAP": "True",
|
|
79
|
+
}
|
|
80
|
+
return Invocation(argv, env=env)
|
|
81
|
+
|
|
82
|
+
@override
|
|
83
|
+
def parse(self, logdir: Path) -> tuple[str, dict[str, int]]:
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads((logdir / "trajectory.json").read_text(encoding="utf-8"))
|
|
86
|
+
except (OSError, json.JSONDecodeError):
|
|
87
|
+
return "", {}
|
|
88
|
+
said = [m.get("content") for m in data.get("messages", []) if m.get("role") == "assistant"]
|
|
89
|
+
last = next((s for s in reversed(said) if isinstance(s, str) and s.strip()), "")
|
|
90
|
+
info = data.get("info", {})
|
|
91
|
+
usage = {"steps": len(said)}
|
|
92
|
+
if isinstance(stats := info.get("model_stats"), dict):
|
|
93
|
+
usage.update({k: v for k, v in stats.items() if isinstance(v, int)})
|
|
94
|
+
return f"[{info.get('exit_status', '?')}] {last.strip()[-3000:]}", usage
|
|
95
|
+
|
|
96
|
+
@override
|
|
97
|
+
def describe(self) -> dict[str, str]:
|
|
98
|
+
return {
|
|
99
|
+
**super().describe(),
|
|
100
|
+
"actions": "text (one bash block per reply)" if self.text_actions else "tool calls",
|
|
101
|
+
"sandbox": "bugbout's sandbox per command: no network, writes only in the checkout",
|
|
102
|
+
"configuration": f"stock mini config, step limit {self.step_limit}",
|
|
103
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""A mini-swe-agent environment that runs every command inside bugbout's sandbox.
|
|
2
|
+
|
|
3
|
+
Loaded by mini-swe-agent's own interpreter (through ``PYTHONPATH``), not by
|
|
4
|
+
bugbout's, so it depends on nothing but mini-swe-agent and the standard library.
|
|
5
|
+
bugbout passes the sandbox as a ready-made argv prefix in an environment variable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shlex
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from minisweagent.environments.local import LocalEnvironment # type: ignore[import-not-found]
|
|
16
|
+
|
|
17
|
+
SANDBOX_PREFIX_ENV = "BUGBOUT_SANDBOX_PREFIX"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SandboxedEnvironment(LocalEnvironment): # type: ignore[misc]
|
|
21
|
+
def execute(self, action: dict[str, Any], cwd: str = "", *, timeout: int | None = None) -> Any:
|
|
22
|
+
if SANDBOX_PREFIX_ENV not in os.environ:
|
|
23
|
+
raise RuntimeError(f"{SANDBOX_PREFIX_ENV} is not set; refusing to run unsandboxed")
|
|
24
|
+
prefix: list[str] = json.loads(os.environ[SANDBOX_PREFIX_ENV])
|
|
25
|
+
command = str(action.get("command", ""))
|
|
26
|
+
wrapped = shlex.join([*prefix, "/bin/bash", "-c", command]) if prefix else command
|
|
27
|
+
return super().execute({**action, "command": wrapped}, cwd, timeout=timeout)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Agents by the names configuration files use.
|
|
2
|
+
|
|
3
|
+
Other packages can add agents without touching bugbout, by declaring an entry
|
|
4
|
+
point in the ``bugbout.agents`` group that names a :class:`CodingAgent` subclass::
|
|
5
|
+
|
|
6
|
+
[project.entry-points."bugbout.agents"]
|
|
7
|
+
my-agent = "my_package.agent:MyAgent"
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import entry_points
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from bugbout.agents.base import CodingAgent
|
|
16
|
+
from bugbout.agents.claude_code import ClaudeCode
|
|
17
|
+
from bugbout.agents.codex import Codex
|
|
18
|
+
from bugbout.agents.command import CommandAgent
|
|
19
|
+
from bugbout.agents.gemini_cli import GeminiCli
|
|
20
|
+
from bugbout.agents.mini_swe import MiniSweAgent
|
|
21
|
+
from bugbout.errors import ConfigError
|
|
22
|
+
from bugbout.log import get_logger
|
|
23
|
+
|
|
24
|
+
log = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
ENTRY_POINT_GROUP = "bugbout.agents"
|
|
27
|
+
BUILTIN: dict[str, type[CodingAgent]] = {
|
|
28
|
+
"codex": Codex,
|
|
29
|
+
"claude-code": ClaudeCode,
|
|
30
|
+
"gemini-cli": GeminiCli,
|
|
31
|
+
"mini-swe-agent": MiniSweAgent,
|
|
32
|
+
"command": CommandAgent,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def agent_types() -> dict[str, type[CodingAgent]]:
|
|
37
|
+
"""Every agent type by name: the built-in ones, then any that plugins provide."""
|
|
38
|
+
found = dict(BUILTIN)
|
|
39
|
+
for entry in entry_points(group=ENTRY_POINT_GROUP):
|
|
40
|
+
if entry.name in found:
|
|
41
|
+
log.warning("Ignoring plugin agent %r: the name is taken", entry.name)
|
|
42
|
+
continue
|
|
43
|
+
try:
|
|
44
|
+
loaded = entry.load()
|
|
45
|
+
except Exception as exc: # a broken plugin must not take bugbout down with it
|
|
46
|
+
log.warning("Could not load plugin agent %r: %s", entry.name, exc)
|
|
47
|
+
continue
|
|
48
|
+
if isinstance(loaded, type) and issubclass(loaded, CodingAgent):
|
|
49
|
+
found[entry.name] = loaded
|
|
50
|
+
else:
|
|
51
|
+
log.warning("Ignoring plugin agent %r: not a CodingAgent subclass", entry.name)
|
|
52
|
+
return found
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def create_agent(kind: str, **options: Any) -> CodingAgent:
|
|
56
|
+
"""An agent of type ``kind``, configured with ``options``."""
|
|
57
|
+
types = agent_types()
|
|
58
|
+
if kind not in types:
|
|
59
|
+
raise ConfigError(f"unknown agent type {kind!r}; available: {', '.join(sorted(types))}")
|
|
60
|
+
try:
|
|
61
|
+
return types[kind](**options)
|
|
62
|
+
except TypeError as exc:
|
|
63
|
+
raise ConfigError(f"invalid options for a {kind} agent: {exc}") from exc
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Which vendor made the model behind a name.
|
|
2
|
+
|
|
3
|
+
Independence is the judge's central rule - no vendor's evidence may vouch for its
|
|
4
|
+
own models - so the mapping from a name to a vendor lives in exactly one place.
|
|
5
|
+
Names are matched by marker, in order: ``codex/gpt-5.1`` and
|
|
6
|
+
``mini-swe-agent/ollama/gpt-oss:20b`` are both OpenAI models, whatever runs them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
_MARKERS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
|
|
14
|
+
(re.compile(pattern), vendor)
|
|
15
|
+
for pattern, vendor in (
|
|
16
|
+
(r"claude", "anthropic"),
|
|
17
|
+
(r"gemini|gemma", "google"),
|
|
18
|
+
(r"qwen", "alibaba"),
|
|
19
|
+
(r"devstral|codestral|magistral|mistral", "mistral"),
|
|
20
|
+
(r"deepseek", "deepseek"),
|
|
21
|
+
(r"llama", "meta"),
|
|
22
|
+
(r"grok", "xai"),
|
|
23
|
+
(r"kimi|moonshot", "moonshot"),
|
|
24
|
+
(r"glm", "zhipu"),
|
|
25
|
+
(r"gpt|codex|(?<![a-z0-9])o[1-9](?![0-9])", "openai"),
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def vendor_of(name: str) -> str:
|
|
31
|
+
"""The vendor whose model ``name`` refers to; an unknown name is its own vendor."""
|
|
32
|
+
lowered = name.lower()
|
|
33
|
+
for marker, vendor in _MARKERS:
|
|
34
|
+
if marker.search(lowered):
|
|
35
|
+
return vendor
|
|
36
|
+
return lowered.split("/")[0]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""The contest itself: briefs, the race, cross-examination and the judge."""
|
|
2
|
+
|
|
3
|
+
from bugbout.arena.briefs import ASSIGNMENTS, FIX, Assignment, Brief, Fix, Reproduce, Specify
|
|
4
|
+
from bugbout.arena.exhibits import Exhibit, exhibit_of
|
|
5
|
+
from bugbout.arena.judge import DEFAULT_POLICY, POLICIES, Judge, JudgePolicy, Standing, Verdict
|
|
6
|
+
from bugbout.arena.matrix import BASE, CrossExaminer, Matrix
|
|
7
|
+
from bugbout.arena.race import Attempt, Race
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ASSIGNMENTS",
|
|
11
|
+
"BASE",
|
|
12
|
+
"DEFAULT_POLICY",
|
|
13
|
+
"FIX",
|
|
14
|
+
"POLICIES",
|
|
15
|
+
"Assignment",
|
|
16
|
+
"Attempt",
|
|
17
|
+
"Brief",
|
|
18
|
+
"CrossExaminer",
|
|
19
|
+
"Exhibit",
|
|
20
|
+
"Fix",
|
|
21
|
+
"Judge",
|
|
22
|
+
"JudgePolicy",
|
|
23
|
+
"Matrix",
|
|
24
|
+
"Race",
|
|
25
|
+
"Reproduce",
|
|
26
|
+
"Specify",
|
|
27
|
+
"Standing",
|
|
28
|
+
"Verdict",
|
|
29
|
+
"exhibit_of",
|
|
30
|
+
]
|
bugbout/arena/briefs.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""What an agent is asked to do with a task.
|
|
2
|
+
|
|
3
|
+
The same coding agent can play two roles, and only the brief differs. As a
|
|
4
|
+
**contestant** it fixes the issue and adds a regression test. As an
|
|
5
|
+
**examiner** it must not touch the source at all: it writes tests that capture
|
|
6
|
+
the issue and checks that they fail on the unfixed code for the reason the
|
|
7
|
+
issue describes. An examiner never sees a contestant's patch - its tests are
|
|
8
|
+
evidence about the requirement, not about anyone's fix.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import ClassVar, Literal
|
|
17
|
+
|
|
18
|
+
Role = Literal["contestant", "examiner"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Brief:
|
|
23
|
+
"""Everything an assignment's prompt is rendered from."""
|
|
24
|
+
|
|
25
|
+
repo: str
|
|
26
|
+
title: str
|
|
27
|
+
statement: str
|
|
28
|
+
source_root: str
|
|
29
|
+
test_root: str
|
|
30
|
+
runner: Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Assignment(ABC):
|
|
34
|
+
"""A role an agent can be given, expressed as the prompt it receives."""
|
|
35
|
+
|
|
36
|
+
name: ClassVar[str]
|
|
37
|
+
role: ClassVar[Role]
|
|
38
|
+
test_file: ClassVar[str]
|
|
39
|
+
"""The new test file the agent is asked to write, inside the test directory."""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def template(self) -> str: ...
|
|
44
|
+
|
|
45
|
+
def prompt(self, brief: Brief) -> str:
|
|
46
|
+
return self.template.format(
|
|
47
|
+
repo=brief.repo,
|
|
48
|
+
title=brief.title,
|
|
49
|
+
statement=brief.statement.strip(),
|
|
50
|
+
source_root=brief.source_root,
|
|
51
|
+
test_root=brief.test_root,
|
|
52
|
+
test_file=self.test_file,
|
|
53
|
+
runner=brief.runner,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Fix(Assignment):
|
|
58
|
+
"""Contestant: resolve the issue, and prove it with a test."""
|
|
59
|
+
|
|
60
|
+
name = "fix"
|
|
61
|
+
role = "contestant"
|
|
62
|
+
test_file = "test_issue_regression.py"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def template(self) -> str:
|
|
66
|
+
return """\
|
|
67
|
+
You are resolving an issue in the {repo} repository. Your working directory is a checkout of \
|
|
68
|
+
the repository at a commit where the issue below is still open. There is no git history.
|
|
69
|
+
|
|
70
|
+
<issue>
|
|
71
|
+
# {title}
|
|
72
|
+
|
|
73
|
+
{statement}
|
|
74
|
+
</issue>
|
|
75
|
+
|
|
76
|
+
Do the following:
|
|
77
|
+
1. Fix the issue by editing the library's source code (under `{source_root}/`).
|
|
78
|
+
2. Write a regression test for this issue in a NEW file, `{test_root}/{test_file}`. \
|
|
79
|
+
It must fail on the code as it is now and pass once your fix is in place. Do not modify or \
|
|
80
|
+
delete existing tests.
|
|
81
|
+
3. Run the tests to check your work with this exact command, which points Python at this \
|
|
82
|
+
checkout:
|
|
83
|
+
{runner} {test_root}/{test_file}
|
|
84
|
+
Pass any other test file, or a -k expression, the same way.
|
|
85
|
+
|
|
86
|
+
Work only inside this directory, and do not download the project or look it up online. When \
|
|
87
|
+
you are finished, reply with a short summary of what you changed and why.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Reproduce(Assignment):
|
|
92
|
+
"""Examiner: write failing tests that capture the issue, and fix nothing."""
|
|
93
|
+
|
|
94
|
+
name = "reproduce"
|
|
95
|
+
role = "examiner"
|
|
96
|
+
test_file = "test_issue_reproduction.py"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def template(self) -> str:
|
|
100
|
+
return """\
|
|
101
|
+
You are writing the regression test for an issue in the {repo} repository, BEFORE anyone has \
|
|
102
|
+
fixed it. Your working directory is a checkout of the repository at a commit where the issue \
|
|
103
|
+
below is still open. There is no git history.
|
|
104
|
+
|
|
105
|
+
<issue>
|
|
106
|
+
# {title}
|
|
107
|
+
|
|
108
|
+
{statement}
|
|
109
|
+
</issue>
|
|
110
|
+
|
|
111
|
+
Your job is to capture the issue as a test - not to fix it:
|
|
112
|
+
1. Do NOT modify the library's source code (under `{source_root}/`) or any existing test.
|
|
113
|
+
2. Write pytest tests in a NEW file, `{test_root}/{test_file}`, that exercise exactly the \
|
|
114
|
+
behaviour the issue asks for, through the library's public API. Cover each concrete case the \
|
|
115
|
+
issue mentions or clearly implies. Do not test anything the issue does not ask to change.
|
|
116
|
+
3. Run them with this exact command, which points Python at this checkout:
|
|
117
|
+
{runner} {test_root}/{test_file}
|
|
118
|
+
They MUST fail on the current code, and each failure must be caused by the bug the issue \
|
|
119
|
+
describes - not by a typo, a wrong import, a misremembered API or a mistaken expectation. Read \
|
|
120
|
+
the source until you are sure what correct behaviour is, then make each test assert exactly that.
|
|
121
|
+
|
|
122
|
+
Work only inside this directory, and do not download the project or look it up online. When \
|
|
123
|
+
you are finished, reply with a short summary of what each test checks.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Specify(Assignment):
|
|
128
|
+
"""Examiner: write the tests a maintainer would demand of any fix - and fix nothing.
|
|
129
|
+
|
|
130
|
+
A reproduction checks the case the issue reports, and a partial fix passes
|
|
131
|
+
it. This brief asks for the general rule behind the issue, related inputs a
|
|
132
|
+
complete fix must handle, and behaviour a careless fix might break.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
name = "specify"
|
|
136
|
+
role = "examiner"
|
|
137
|
+
test_file = "test_issue_specification.py"
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def template(self) -> str:
|
|
141
|
+
return """\
|
|
142
|
+
You are the maintainer of the {repo} repository who will review fixes for the issue below. \
|
|
143
|
+
Before anyone has fixed it, write the regression tests you would require a correct fix to pass. \
|
|
144
|
+
Your working directory is a checkout at a commit where the issue is still open. There is no git \
|
|
145
|
+
history.
|
|
146
|
+
|
|
147
|
+
<issue>
|
|
148
|
+
# {title}
|
|
149
|
+
|
|
150
|
+
{statement}
|
|
151
|
+
</issue>
|
|
152
|
+
|
|
153
|
+
1. Do NOT modify the library's source code (under `{source_root}/`) or any existing test.
|
|
154
|
+
2. Read the relevant source and the existing tests until you understand the behaviour the \
|
|
155
|
+
library intends. Work out the general rule the issue implies - not just its literal example.
|
|
156
|
+
3. Write pytest tests in a NEW file, `{test_root}/{test_file}`, in three groups:
|
|
157
|
+
a. the case the issue reports;
|
|
158
|
+
b. the same rule on closely related inputs a complete fix must also handle - other types \
|
|
159
|
+
or values the same code path accepts, boundaries, the sibling branch of the same logic;
|
|
160
|
+
c. nearby behaviour the issue does not ask to change, which a careless fix might break.
|
|
161
|
+
4. Run them with this exact command, which points Python at this checkout:
|
|
162
|
+
{runner} {test_root}/{test_file}
|
|
163
|
+
Every test in groups a and b MUST fail on the current code, because of the bug. If a related \
|
|
164
|
+
input already works, it is not part of the bug: move it to group c or drop it. Every test in \
|
|
165
|
+
group c MUST pass on the current code. Each failure must come from the bug - never from a typo, \
|
|
166
|
+
a wrong import, a misremembered API or a mistaken expectation. Check each expectation against \
|
|
167
|
+
the source and the existing tests before you keep it.
|
|
168
|
+
|
|
169
|
+
Work only inside this directory, and do not download the project or look it up online. When \
|
|
170
|
+
you are finished, reply with a short summary of the rule you tested and why.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
ASSIGNMENTS: dict[str, Assignment] = {a.name: a for a in (Fix(), Reproduce(), Specify())}
|
|
175
|
+
FIX = ASSIGNMENTS["fix"]
|
|
176
|
+
EXAMINER_ASSIGNMENTS = tuple(a.name for a in ASSIGNMENTS.values() if a.role == "examiner")
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Exhibits: test files offered as evidence about a task.
|
|
2
|
+
|
|
3
|
+
Two kinds of author write them. An **examiner** wrote its tests from the issue
|
|
4
|
+
alone and never saw a patch. A **contestant** wrote a regression test for its
|
|
5
|
+
own fix; run against everyone else's fix, that test is evidence too.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from typing import Any, Literal, get_args
|
|
12
|
+
|
|
13
|
+
from bugbout.arena.briefs import ASSIGNMENTS
|
|
14
|
+
from bugbout.arena.race import Attempt
|
|
15
|
+
from bugbout.tasks.task import is_test_module
|
|
16
|
+
|
|
17
|
+
Kind = Literal["examiner", "contestant"]
|
|
18
|
+
KINDS: frozenset[Kind] = frozenset(get_args(Kind))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class Exhibit:
|
|
23
|
+
task_id: str
|
|
24
|
+
author: str
|
|
25
|
+
vendor: str
|
|
26
|
+
kind: Kind
|
|
27
|
+
test_code: str
|
|
28
|
+
note: str = ""
|
|
29
|
+
"""Anything the author did that it was not asked to, such as editing the source."""
|
|
30
|
+
|
|
31
|
+
error: str = ""
|
|
32
|
+
"""Why there is no test, when there is none."""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def usable(self) -> bool:
|
|
36
|
+
return bool(self.test_code.strip())
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, data: dict[str, Any]) -> Exhibit:
|
|
43
|
+
if data.get("kind") not in KINDS:
|
|
44
|
+
raise ValueError(f"unknown exhibit kind {data.get('kind')!r}")
|
|
45
|
+
return cls(**{k: v for k, v in data.items() if k in set(cls.__slots__)})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def author_of(attempt: Attempt) -> str:
|
|
49
|
+
"""Who an attempt's tests are attributed to: a contestant by name, an examiner by role too."""
|
|
50
|
+
if ASSIGNMENTS[attempt.assignment].role == "contestant":
|
|
51
|
+
return attempt.agent
|
|
52
|
+
return f"{attempt.assignment}/{attempt.agent}"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def exhibit_of(attempt: Attempt, test_root: str) -> Exhibit:
|
|
56
|
+
"""The test file an attempt created - the one its brief asked for, if it wrote that one."""
|
|
57
|
+
assignment = ASSIGNMENTS[attempt.assignment]
|
|
58
|
+
kind: Kind = "contestant" if assignment.role == "contestant" else "examiner"
|
|
59
|
+
root = test_root.strip("/")
|
|
60
|
+
created = attempt.patch.only(under=root, keep=True).created()
|
|
61
|
+
wanted = f"{root}/{assignment.test_file}"
|
|
62
|
+
written = (
|
|
63
|
+
wanted if wanted in created else next((p for p in sorted(created) if is_test_module(p)), "")
|
|
64
|
+
)
|
|
65
|
+
notes = []
|
|
66
|
+
if written and written != wanted:
|
|
67
|
+
notes.append(f"tests written to {written} instead of {wanted}")
|
|
68
|
+
if kind == "examiner" and attempt.patch.only(under=root, keep=False).files:
|
|
69
|
+
notes.append("edited the source, which was discarded")
|
|
70
|
+
return Exhibit(
|
|
71
|
+
task_id=attempt.task_id,
|
|
72
|
+
author=author_of(attempt),
|
|
73
|
+
vendor=attempt.vendor,
|
|
74
|
+
kind=kind,
|
|
75
|
+
test_code=created.get(written, ""),
|
|
76
|
+
note="; ".join(notes),
|
|
77
|
+
error="" if written else f"no test written ({attempt.status})",
|
|
78
|
+
)
|