Skip to content

Commit ab34af0

Browse files
authored
Single player mode initial version (#33)
* Single player mode initial version * Ref: Create new Tournament class. Move main.py code * WIP: Factored out tournament class from game class * Working evaluation for single player mode * Ref/simplify: Add empty patch for round 0 * Enh(viewer): Improve game session dropdown Add warning sign next to probably failed; add # rounds; alphabetical sort * Ref: Change get_agent and prompt rendering (needs to use tournament) * Change: Move round setting to tournament section of config
1 parent 09ab73d commit ab34af0

30 files changed

Lines changed: 869 additions & 264 deletions

codeclash/agents/__init__.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,18 @@
1+
from minisweagent.environments.docker import DockerEnvironment
2+
13
from codeclash.agents.abstract import Player
24
from codeclash.agents.dummy import Dummy
35
from codeclash.agents.minisweagent import MiniSWEAgent
46
from codeclash.agents.utils import GameContext
5-
from codeclash.constants import DIR_WORK
6-
from codeclash.games.abstract import CodeGame
77

88

9-
def get_agent(config: dict, prompts: dict, game: CodeGame) -> Player:
9+
def get_agent(
10+
config: dict, game_context: GameContext, environment: DockerEnvironment
11+
) -> Player:
1012
agents = {
1113
"dummy": Dummy,
1214
"mini": MiniSWEAgent,
1315
}.get(config["agent"])
1416
if agents is None:
1517
raise ValueError(f"Unknown agent type: {config['agent']}")
16-
environment = game.get_environment(
17-
f"{game.game_id}.{config['name']}"
18-
) # NOTE: MUST be branch_name (defined in agents/abstract.py)
19-
return agents(
20-
config,
21-
environment,
22-
GameContext(
23-
id=game.game_id,
24-
log_env=game.log_env,
25-
log_local=game.log_local,
26-
name=game.name,
27-
player_id=config["name"],
28-
prompts=prompts,
29-
round=1,
30-
rounds=game.rounds,
31-
working_dir=str(DIR_WORK),
32-
),
33-
)
18+
return agents(config, environment, game_context)

codeclash/agents/abstract.py

Lines changed: 126 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import os
2+
import uuid
23
from abc import ABC, abstractmethod
34

45
from dotenv import load_dotenv
5-
from minisweagent import Environment
6+
from minisweagent.environments.docker import DockerEnvironment
67

78
from codeclash.agents.utils import GameContext
89
from codeclash.constants import GH_ORG
9-
from codeclash.utils.environment import assert_zero_exit_code
10+
from codeclash.tournaments.utils.git_utils import filter_git_diff
11+
from codeclash.utils.environment import assert_zero_exit_code, create_file_on_container
1012
from codeclash.utils.log import get_logger
1113

1214
load_dotenv()
@@ -16,41 +18,52 @@ class Player(ABC):
1618
def __init__(
1719
self,
1820
config: dict,
19-
environment: Environment,
21+
environment: DockerEnvironment,
2022
game_context: GameContext,
21-
):
23+
) -> None:
2224
self.config = config
2325
self.name = config["name"]
26+
self._player_unique_id = uuid.uuid4()
27+
"""Unique ID that doesn't clash even accross multiple games. Used for git tags."""
2428
self.environment = environment
2529
self.game_context = game_context
26-
self.game_context.render_and_set_prompts()
2730
self.logger = get_logger(
2831
self.name,
2932
log_path=self.game_context.log_local / f"{self.name}.log",
3033
emoji="👤",
3134
)
35+
self._metadata = {
36+
"name": self.name,
37+
"player_unique_id": self._player_unique_id,
38+
"diff": {0: ""}, # mapping round -> diff
39+
"incremental_diff": {0: ""}, # mapping round -> diff
40+
}
3241

33-
@property
34-
def branch_name(self):
35-
"""Get the branch name for the agent's codebase."""
36-
return f"{self.game_context.id}.{self.name}"
37-
38-
def commit(self):
39-
"""Commit changes to the agent's codebase."""
40-
r, rounds = self.game_context.round, self.game_context.rounds
41-
for cmd in [
42-
"git add -A",
43-
f"git commit --allow-empty -m 'Round {r}/{rounds} Update'",
44-
]:
45-
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
46-
self.logger.info(f"Committed changes for {self.name} for round {r}/{rounds}")
42+
# --- Main methods ---
4743

48-
def on_round_update(self, new_round: int):
49-
"""Update the agent's round to match the game round."""
44+
def pre_run_hook(self, *, new_round: int) -> None:
45+
"""Should be called before we call the run method."""
46+
if new_round == 1:
47+
self._tag_round(0)
5048
self.game_context.round = new_round
51-
self.game_context.render_and_set_prompts()
5249

53-
def push(self):
50+
def post_run_hook(self, *, round: int) -> None:
51+
"""Should be called after we called the run method."""
52+
self._commit()
53+
self._metadata["diff"][round] = self._get_round_diff(round)
54+
self._metadata["incremental_diff"][round] = self._get_round_diff(
55+
round, incremental=True
56+
)
57+
58+
@abstractmethod
59+
def run(self) -> None:
60+
"""Given the observation / recap, update the codebase"""
61+
62+
def get_metadata(self) -> dict:
63+
"""Get metadata for the agent."""
64+
return self._metadata
65+
66+
def push(self) -> None:
5467
"""Push codebase to a branch on the game's remote repository."""
5568
token = os.getenv("GITHUB_TOKEN")
5669
if not token:
@@ -59,13 +72,98 @@ def push(self):
5972
for cmd in [
6073
"git remote remove origin",
6174
f"git remote add origin https://x-access-token:{token}@github.com/{GH_ORG}/{self.game_context.name}.git",
62-
f"git push origin {self.branch_name}",
75+
f"git push origin {self._branch_name}",
76+
"git push origin --tags",
6377
]:
6478
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
6579
self.logger.info(
66-
f"Pushed {self.name} commit history to remote repository (branch {self.branch_name})"
80+
f"Pushed {self.name} commit history to remote repository (branch {self._branch_name})"
6781
)
6882

69-
@abstractmethod
70-
def run(self):
71-
"""Given the observation / recap, update the codebase"""
83+
def reset_and_apply_patch(
84+
self, patch: str, *, base_commit: str = "", filter_patch: bool = True
85+
) -> None:
86+
"""Clean all uncommited changes. If base_commit is provided, reset to that commit.
87+
Then apply the patch to the codebase.
88+
"""
89+
# Need to clean before we copy over the patch (else it's gonna be removed by git clean)
90+
self.logger.debug(
91+
assert_zero_exit_code(
92+
self.environment.execute(
93+
f"git reset --hard {base_commit} && git clean -fd"
94+
)
95+
)
96+
)
97+
98+
patch = filter_git_diff(patch) if filter_patch else patch
99+
100+
if not patch.strip():
101+
self.logger.debug("No patch to apply, skipping")
102+
return
103+
104+
create_file_on_container(
105+
container=self.environment, # type: ignore
106+
content=patch,
107+
dest_path="tmp_patch.txt",
108+
)
109+
110+
self.logger.debug(f"Applying patch to agent's codebase: {patch}")
111+
112+
commands = ["git status", "git apply tmp_patch.txt", "rm -f tmp_patch.txt"]
113+
for cmd in commands:
114+
self.logger.debug(f"Executing command: {cmd}")
115+
out = assert_zero_exit_code(
116+
self.environment.execute(cmd), logger=self.logger
117+
)
118+
self.logger.debug(out)
119+
120+
# --- Helper methods ---
121+
122+
def _tag_round(self, round: int) -> None:
123+
"""Git tag the codebase at the given round."""
124+
assert_zero_exit_code(
125+
self.environment.execute(
126+
f"git tag -a {self._get_round_tag_name(round)} -m 'Round {round} Update'"
127+
),
128+
logger=self.logger,
129+
)
130+
131+
@property
132+
def _branch_name(self) -> str:
133+
"""Get the branch name for the agent's codebase."""
134+
return f"{self.game_context.id}.{self.name}"
135+
136+
def _get_round_tag_name(self, round: int) -> str:
137+
"""Get git tag name for the version of the codebase at the given round."""
138+
return f"{self._player_unique_id}-round-{round}"
139+
140+
def _commit(self) -> None:
141+
"""Commit changes to the agent's codebase."""
142+
r = self.game_context.round
143+
for cmd in [
144+
"git add -A",
145+
f"git commit --allow-empty -m 'Round {r} Update'",
146+
]:
147+
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
148+
self._tag_round(r)
149+
self.logger.info(f"Committed changes for {self.name} for round {r}")
150+
151+
def _get_round_diff(self, round: int, *, incremental: bool = False) -> str:
152+
"""Get the diff between the round and initial version (round 0).
153+
If incremental is True, get the diff between the round and the previous round.
154+
Returns empty string if round is 0.
155+
"""
156+
if round == 0:
157+
return ""
158+
if incremental:
159+
previous_round_tag = self._get_round_tag_name(round - 1)
160+
else:
161+
previous_round_tag = self._get_round_tag_name(0)
162+
current_round_tag = self._get_round_tag_name(round)
163+
out = assert_zero_exit_code(
164+
self.environment.execute(
165+
f"git diff {previous_round_tag}..{current_round_tag}"
166+
),
167+
logger=self.logger,
168+
)
169+
return out["output"]

codeclash/agents/dummy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ class Dummy(Player):
55
"""A dummy player that does nothing. Mainly for testing purposes."""
66

77
def run(self):
8-
self.commit()
8+
pass
9+
# self.commit() # now called in post_round_hook

codeclash/agents/minisweagent.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88

99
import yaml
1010
from jinja2 import Template
11-
from minisweagent import Environment, Model
11+
from minisweagent import Model
1212
from minisweagent.agents.default import AgentConfig, DefaultAgent
13+
from minisweagent.environments.docker import DockerEnvironment
1314
from minisweagent.models.litellm_model import LitellmModel
1415
from minisweagent.run.utils.save import save_traj
1516
from rich.console import Console
@@ -28,7 +29,7 @@ class ClashAgent(DefaultAgent):
2829
def __init__(
2930
self,
3031
model: Model,
31-
env: Environment,
32+
env: DockerEnvironment,
3233
name: str,
3334
game_context: GameContext,
3435
*,
@@ -56,7 +57,7 @@ def render_template(self, template: str, **kwargs) -> str:
5657
| asdict(self.env.config)
5758
| asdict(self.model.config)
5859
| platform.uname()._asdict()
59-
| self.game_context.to_dict()
60+
| self.game_context.to_template_vars()
6061
)
6162
return Template(template).render(**kwargs, **cs, **os.environ)
6263

@@ -69,7 +70,7 @@ class MiniSWEAgent(Player):
6970
"""Player with agentic code editing capabilities"""
7071

7172
def __init__(
72-
self, config: dict, environment: Environment, game_context: GameContext
73+
self, config: dict, environment: DockerEnvironment, game_context: GameContext
7374
):
7475
super().__init__(config, environment=environment, game_context=game_context)
7576

@@ -104,10 +105,11 @@ def run(self):
104105
traj_path,
105106
exit_status=exit_status,
106107
result=result,
108+
print_fct=self.logger.debug,
107109
)
108110
copy_file_to_container(
109111
self.environment,
110112
traj_path,
111113
self.game_context.log_env / traj_path.name,
112114
)
113-
self.commit()
115+
# self.commit() # now called in post_round_hook

codeclash/agents/utils.py

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,24 +36,15 @@ class GameContext:
3636
rounds: int
3737
working_dir: str
3838

39-
def render_and_set_prompts(self):
40-
"""Render and set prompts using the current game context."""
39+
def _render_prompt_templates(self) -> dict:
4140
context = asdict(self)
42-
del context["prompts"]
43-
for key, template_str in self.prompts.items():
44-
rendered = Template(template_str).render(**context)
45-
setattr(self, key, rendered)
46-
47-
def to_dict(self):
48-
"""Convert the GameContext to a dictionary, including dynamically added attributes."""
49-
result = asdict(self)
50-
declared = set(self.__dataclass_fields__)
51-
for attr in dir(self):
52-
if (
53-
not attr.startswith("_")
54-
and attr not in declared
55-
and not callable(getattr(self, attr))
56-
):
57-
result[attr] = getattr(self, attr)
58-
del result["prompts"]
59-
return result
41+
return {
42+
key: Template(template_str).render(**context)
43+
for key, template_str in self.prompts.items()
44+
}
45+
46+
def to_template_vars(self) -> dict[str, str]:
47+
"""Convert the GameContext to a dictionary for rendering prompts in the agent"""
48+
out = asdict(self) | self._render_prompt_templates()
49+
out.pop("prompts")
50+
return out

codeclash/games/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from pathlib import Path
2+
13
from codeclash.games.abstract import CodeGame
24
from codeclash.games.battlecode.main import BattleCodeGame
35
from codeclash.games.battlesnake.main import BattleSnakeGame
@@ -7,16 +9,17 @@
79

810

911
# might consider postponing imports to avoid loading things we don't need
10-
def get_game(config: dict) -> CodeGame:
12+
def get_game(config: dict, *, tournament_id: str, local_output_dir: Path) -> CodeGame:
1113
game = {
12-
x.name: x for x in [
14+
x.name: x
15+
for x in [
1316
BattleCodeGame,
1417
BattleSnakeGame,
1518
CoreWarGame,
1619
RoboCodeGame,
17-
RobotRumbleGame
20+
RobotRumbleGame,
1821
]
1922
}.get(config["game"]["name"])
2023
if game is None:
2124
raise ValueError(f"Unknown game: {config['game']['name']}")
22-
return game(config)
25+
return game(config, tournament_id=tournament_id, local_output_dir=local_output_dir)

0 commit comments

Comments
 (0)