Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 6 additions & 21 deletions codeclash/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,18 @@
from minisweagent.environments.docker import DockerEnvironment

from codeclash.agents.abstract import Player
from codeclash.agents.dummy import Dummy
from codeclash.agents.minisweagent import MiniSWEAgent
from codeclash.agents.utils import GameContext
from codeclash.constants import DIR_WORK
from codeclash.games.abstract import CodeGame


def get_agent(config: dict, prompts: dict, game: CodeGame) -> Player:
def get_agent(
config: dict, game_context: GameContext, environment: DockerEnvironment
) -> Player:
agents = {
"dummy": Dummy,
"mini": MiniSWEAgent,
}.get(config["agent"])
if agents is None:
raise ValueError(f"Unknown agent type: {config['agent']}")
environment = game.get_environment(
f"{game.game_id}.{config['name']}"
) # NOTE: MUST be branch_name (defined in agents/abstract.py)
return agents(
config,
environment,
GameContext(
id=game.game_id,
log_env=game.log_env,
log_local=game.log_local,
name=game.name,
player_id=config["name"],
prompts=prompts,
round=1,
rounds=game.rounds,
working_dir=str(DIR_WORK),
),
)
return agents(config, environment, game_context)
154 changes: 126 additions & 28 deletions codeclash/agents/abstract.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import os
import uuid
from abc import ABC, abstractmethod

from dotenv import load_dotenv
from minisweagent import Environment
from minisweagent.environments.docker import DockerEnvironment

from codeclash.agents.utils import GameContext
from codeclash.constants import GH_ORG
from codeclash.utils.environment import assert_zero_exit_code
from codeclash.tournaments.utils.git_utils import filter_git_diff
from codeclash.utils.environment import assert_zero_exit_code, create_file_on_container
from codeclash.utils.log import get_logger

load_dotenv()
Expand All @@ -16,41 +18,52 @@ class Player(ABC):
def __init__(
self,
config: dict,
environment: Environment,
environment: DockerEnvironment,
game_context: GameContext,
):
) -> None:
self.config = config
self.name = config["name"]
self._player_unique_id = uuid.uuid4()
"""Unique ID that doesn't clash even accross multiple games. Used for git tags."""
self.environment = environment
self.game_context = game_context
self.game_context.render_and_set_prompts()
self.logger = get_logger(
self.name,
log_path=self.game_context.log_local / f"{self.name}.log",
emoji="👤",
)
self._metadata = {
"name": self.name,
"player_unique_id": self._player_unique_id,
"diff": {0: ""}, # mapping round -> diff
"incremental_diff": {0: ""}, # mapping round -> diff
}

@property
def branch_name(self):
"""Get the branch name for the agent's codebase."""
return f"{self.game_context.id}.{self.name}"

def commit(self):
"""Commit changes to the agent's codebase."""
r, rounds = self.game_context.round, self.game_context.rounds
for cmd in [
"git add -A",
f"git commit --allow-empty -m 'Round {r}/{rounds} Update'",
]:
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
self.logger.info(f"Committed changes for {self.name} for round {r}/{rounds}")
# --- Main methods ---

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

def push(self):
def post_run_hook(self, *, round: int) -> None:
"""Should be called after we called the run method."""
self._commit()
self._metadata["diff"][round] = self._get_round_diff(round)
self._metadata["incremental_diff"][round] = self._get_round_diff(
round, incremental=True
)

@abstractmethod
def run(self) -> None:
"""Given the observation / recap, update the codebase"""

def get_metadata(self) -> dict:
"""Get metadata for the agent."""
return self._metadata

def push(self) -> None:
"""Push codebase to a branch on the game's remote repository."""
token = os.getenv("GITHUB_TOKEN")
if not token:
Expand All @@ -59,13 +72,98 @@ def push(self):
for cmd in [
"git remote remove origin",
f"git remote add origin https://x-access-token:{token}@github.com/{GH_ORG}/{self.game_context.name}.git",
f"git push origin {self.branch_name}",
f"git push origin {self._branch_name}",
"git push origin --tags",
]:
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
self.logger.info(
f"Pushed {self.name} commit history to remote repository (branch {self.branch_name})"
f"Pushed {self.name} commit history to remote repository (branch {self._branch_name})"
)

@abstractmethod
def run(self):
"""Given the observation / recap, update the codebase"""
def reset_and_apply_patch(
self, patch: str, *, base_commit: str = "", filter_patch: bool = True
) -> None:
"""Clean all uncommited changes. If base_commit is provided, reset to that commit.
Then apply the patch to the codebase.
"""
# Need to clean before we copy over the patch (else it's gonna be removed by git clean)
self.logger.debug(
assert_zero_exit_code(
self.environment.execute(
f"git reset --hard {base_commit} && git clean -fd"
)
)
)

patch = filter_git_diff(patch) if filter_patch else patch

if not patch.strip():
self.logger.debug("No patch to apply, skipping")
return

create_file_on_container(
container=self.environment, # type: ignore
content=patch,
dest_path="tmp_patch.txt",
)

self.logger.debug(f"Applying patch to agent's codebase: {patch}")

commands = ["git status", "git apply tmp_patch.txt", "rm -f tmp_patch.txt"]
for cmd in commands:
self.logger.debug(f"Executing command: {cmd}")
out = assert_zero_exit_code(
self.environment.execute(cmd), logger=self.logger
)
self.logger.debug(out)

# --- Helper methods ---

def _tag_round(self, round: int) -> None:
"""Git tag the codebase at the given round."""
assert_zero_exit_code(
self.environment.execute(
f"git tag -a {self._get_round_tag_name(round)} -m 'Round {round} Update'"
),
logger=self.logger,
)

@property
def _branch_name(self) -> str:
"""Get the branch name for the agent's codebase."""
return f"{self.game_context.id}.{self.name}"

def _get_round_tag_name(self, round: int) -> str:
"""Get git tag name for the version of the codebase at the given round."""
return f"{self._player_unique_id}-round-{round}"

def _commit(self) -> None:
"""Commit changes to the agent's codebase."""
r = self.game_context.round
for cmd in [
"git add -A",
f"git commit --allow-empty -m 'Round {r} Update'",
]:
assert_zero_exit_code(self.environment.execute(cmd), logger=self.logger)
self._tag_round(r)
self.logger.info(f"Committed changes for {self.name} for round {r}")

def _get_round_diff(self, round: int, *, incremental: bool = False) -> str:
"""Get the diff between the round and initial version (round 0).
If incremental is True, get the diff between the round and the previous round.
Returns empty string if round is 0.
"""
if round == 0:
return ""
if incremental:
previous_round_tag = self._get_round_tag_name(round - 1)
else:
previous_round_tag = self._get_round_tag_name(0)
current_round_tag = self._get_round_tag_name(round)
out = assert_zero_exit_code(
self.environment.execute(
f"git diff {previous_round_tag}..{current_round_tag}"
),
logger=self.logger,
)
return out["output"]
3 changes: 2 additions & 1 deletion codeclash/agents/dummy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ class Dummy(Player):
"""A dummy player that does nothing. Mainly for testing purposes."""

def run(self):
self.commit()
pass
# self.commit() # now called in post_round_hook
12 changes: 7 additions & 5 deletions codeclash/agents/minisweagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@

import yaml
from jinja2 import Template
from minisweagent import Environment, Model
from minisweagent import Model
from minisweagent.agents.default import AgentConfig, DefaultAgent
from minisweagent.environments.docker import DockerEnvironment
from minisweagent.models.litellm_model import LitellmModel
from minisweagent.run.utils.save import save_traj
from rich.console import Console
Expand All @@ -28,7 +29,7 @@ class ClashAgent(DefaultAgent):
def __init__(
self,
model: Model,
env: Environment,
env: DockerEnvironment,
name: str,
game_context: GameContext,
*,
Expand Down Expand Up @@ -56,7 +57,7 @@ def render_template(self, template: str, **kwargs) -> str:
| asdict(self.env.config)
| asdict(self.model.config)
| platform.uname()._asdict()
| self.game_context.to_dict()
| self.game_context.to_template_vars()
)
return Template(template).render(**kwargs, **cs, **os.environ)

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

def __init__(
self, config: dict, environment: Environment, game_context: GameContext
self, config: dict, environment: DockerEnvironment, game_context: GameContext
):
super().__init__(config, environment=environment, game_context=game_context)

Expand Down Expand Up @@ -104,10 +105,11 @@ def run(self):
traj_path,
exit_status=exit_status,
result=result,
print_fct=self.logger.debug,
)
copy_file_to_container(
self.environment,
traj_path,
self.game_context.log_env / traj_path.name,
)
self.commit()
# self.commit() # now called in post_round_hook
31 changes: 11 additions & 20 deletions codeclash/agents/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,24 +36,15 @@ class GameContext:
rounds: int
working_dir: str

def render_and_set_prompts(self):
"""Render and set prompts using the current game context."""
def _render_prompt_templates(self) -> dict:
context = asdict(self)
del context["prompts"]
for key, template_str in self.prompts.items():
rendered = Template(template_str).render(**context)
setattr(self, key, rendered)

def to_dict(self):
"""Convert the GameContext to a dictionary, including dynamically added attributes."""
result = asdict(self)
declared = set(self.__dataclass_fields__)
for attr in dir(self):
if (
not attr.startswith("_")
and attr not in declared
and not callable(getattr(self, attr))
):
result[attr] = getattr(self, attr)
del result["prompts"]
return result
return {
key: Template(template_str).render(**context)
for key, template_str in self.prompts.items()
}

def to_template_vars(self) -> dict[str, str]:
"""Convert the GameContext to a dictionary for rendering prompts in the agent"""
out = asdict(self) | self._render_prompt_templates()
out.pop("prompts")
return out
11 changes: 7 additions & 4 deletions codeclash/games/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pathlib import Path

from codeclash.games.abstract import CodeGame
from codeclash.games.battlecode.main import BattleCodeGame
from codeclash.games.battlesnake.main import BattleSnakeGame
Expand All @@ -7,16 +9,17 @@


# might consider postponing imports to avoid loading things we don't need
def get_game(config: dict) -> CodeGame:
def get_game(config: dict, *, tournament_id: str, local_output_dir: Path) -> CodeGame:
game = {
x.name: x for x in [
x.name: x
for x in [
BattleCodeGame,
BattleSnakeGame,
CoreWarGame,
RoboCodeGame,
RobotRumbleGame
RobotRumbleGame,
]
}.get(config["game"]["name"])
if game is None:
raise ValueError(f"Unknown game: {config['game']['name']}")
return game(config)
return game(config, tournament_id=tournament_id, local_output_dir=local_output_dir)
Loading