Skip to content

Commit 5f57d06

Browse files
committed
Added logging
1 parent 444a312 commit 5f57d06

7 files changed

Lines changed: 160 additions & 24 deletions

File tree

codeclash/agents/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def get_agent(config: dict, game: CodeGame) -> Player:
1616
"game_name": game.name,
1717
"game_id": game.game_id,
1818
"rounds": game.rounds,
19+
"round": 1,
1920
"player_id": config["name"],
2021
"game_description": game.config.get("description", ""),
2122
}

codeclash/agents/abstract.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from dotenv import load_dotenv
55
from minisweagent import Environment
66

7-
from codeclash.constants import GH_ORG
7+
from codeclash.constants import DIR_LOGS, GH_ORG
88
from codeclash.utils.environment import assert_zero_exit_code
9+
from codeclash.utils.log import get_logger
910

1011
load_dotenv()
1112

@@ -21,6 +22,11 @@ def __init__(
2122
self.name = f"{template_vars['game_id']}_{config['name']}"
2223
self.environment = environment
2324
self.template_vars = template_vars
25+
self.logger = get_logger(
26+
self.name,
27+
log_path=DIR_LOGS / template_vars["game_id"] / f"{self.name}.log",
28+
emoji="👤",
29+
)
2430

2531
def commit(self):
2632
"""Commit changes to the agent's codebase."""
@@ -30,11 +36,14 @@ def commit(self):
3036
f"git commit --allow-empty -m 'Round {self.round}/{rounds} Update'",
3137
]:
3238
assert_zero_exit_code(self.environment.execute(cmd))
33-
print(f"Committed changes for {self.name} for round {self.round}/{rounds}")
39+
self.logger.info(
40+
f"Committed changes for {self.name} for round {self.round}/{rounds}"
41+
)
3442

3543
def on_round_update(self, new_round: int):
3644
"""Update the agent's round to match the game round."""
3745
self.round = new_round
46+
self.template_vars["round"] = new_round
3847

3948
def push(self):
4049
"""Push codebase to a branch on the game's remote repository."""
@@ -43,7 +52,7 @@ def push(self):
4352
raise ValueError("GITHUB_TOKEN environment variable is required")
4453

4554
for cmd in [
46-
f"git remote remove origin",
55+
"git remote remove origin",
4756
f"git remote add origin https://x-access-token:{token}@github.com/{GH_ORG}/{self.template_vars['game_name']}.git",
4857
f"git push origin {self.name}",
4958
]:

codeclash/agents/minisweagent.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
import os
23
import platform
34
import traceback
@@ -31,19 +32,22 @@ def __init__(
3132
name: str,
3233
template_vars: dict,
3334
*,
35+
logger: logging.Logger,
3436
config_class: Callable = AgentConfig,
3537
**kwargs,
3638
):
3739
super().__init__(model, env, config_class=config_class, **kwargs)
3840
self.name = name
3941
self.template_vars = template_vars
4042
self.console = Console()
43+
self.logger = logger
4144

4245
def add_message(self, role: str, content: str, **kwargs):
4346
super().add_message(role, content, **kwargs)
47+
self.logger.debug(f"[{role}] {content}", extra={"highlighter": None})
4448
if role == "assistant":
45-
self.console.print(
46-
f"[{self.name}] Step taken (step {self.model.n_calls}, cost {self.model.cost:.2f})"
49+
self.logger.info(
50+
f"Step taken (step {self.model.n_calls}, cost {self.model.cost:.2f})"
4751
)
4852

4953
def render_template(self, template: str, **kwargs) -> str:
@@ -58,8 +62,7 @@ def render_template(self, template: str, **kwargs) -> str:
5862

5963
def run(self) -> tuple[str, str]:
6064
"""Run step() until agent is finished. Return exit status & message"""
61-
with self.console.status(f"[bold green]{self.name} updating codebase..."):
62-
return super().run(task="")
65+
return super().run(task="")
6366

6467

6568
class MiniSWEAgent(Player):
@@ -75,6 +78,7 @@ def __init__(self, config: dict, environment: Environment, template_vars: dict):
7578
self.environment,
7679
self.name,
7780
template_vars,
81+
logger=self.logger,
7882
**yaml.safe_load(Path(config["config"]).read_text())["agent"],
7983
)
8084

@@ -93,7 +97,7 @@ def run(self):
9397
save_traj(
9498
self.agent, # type: ignore
9599
DIR_LOGS
96-
/ f"{self.template_vars['game_id']}/{player_id}_r{self.round}.traj.json",
100+
/ f"{self.template_vars['game_id']}/{player_id}_r{self.template_vars['round']}.traj.json",
97101
exit_status=exit_status,
98102
result=result,
99103
)

codeclash/games/abstract.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from codeclash.constants import DIR_LOGS, DIR_WORK, GH_ORG
1414
from codeclash.games.utils import copy_between_containers, copy_file_from_container
1515
from codeclash.utils.environment import assert_zero_exit_code
16+
from codeclash.utils.log import get_logger
1617

1718

1819
class CodeGame(ABC):
@@ -27,6 +28,9 @@ def __init__(self, config: dict):
2728
self.round: int = 0
2829
self.game_id: str = f"{self.name}{uuid4().hex[:6]}"
2930
self.log_path: Path = (DIR_WORK / DIR_LOGS / self.game_id).resolve()
31+
self.logger = get_logger(
32+
self.name, log_path=DIR_LOGS / self.game_id / "game.log", emoji="🏓"
33+
)
3034
self.environment: DockerEnvironment = self.get_environment()
3135
assert len(config["players"]) >= 2, "At least two players are required"
3236

@@ -57,18 +61,20 @@ def build_image(self):
5761
text=True,
5862
)
5963
if result.returncode == 0:
60-
print(f"✅ Built Docker image {self.image_name}")
64+
self.logger.info(f"✅ Built Docker image {self.image_name}")
6165
else:
62-
print(f"❌ Failed to build Docker image: {result.stderr}")
66+
self.logger.error(
67+
f"❌ Failed to build Docker image: {result.stderr}\n{result.stdout}{result.stderr}"
68+
)
6369
raise
6470

6571
def end(self, cleanup: bool = False):
66-
print(Counter([x[1] for x in self.scoreboard]))
72+
self.logger.info(Counter([x[1] for x in self.scoreboard]))
6773
if cleanup:
6874
for artifact in self.artifacts:
6975
if artifact.exists():
7076
subprocess.run(f"rm -rf {artifact}", shell=True)
71-
print(f"🧼 Cleaned up {self.name} game")
77+
self.logger.info(f"🧼 Cleaned up {self.name} game")
7278

7379
def get_environment(self, branch_name: str | None = None) -> DockerEnvironment:
7480
"""Get docker container ID with the game code installed."""
@@ -87,7 +93,7 @@ def get_environment(self, branch_name: str | None = None) -> DockerEnvironment:
8793
'git config --global user.name "Player"',
8894
"git config --global commit.gpgsign false",
8995
]:
90-
assert_zero_exit_code(environment.execute(cmd))
96+
assert_zero_exit_code(environment.execute(cmd), logger=self.logger)
9197
return environment
9298

9399
def _pre_round_setup(self, agents: list[Player]):
@@ -97,10 +103,11 @@ def _pre_round_setup(self, agents: list[Player]):
97103
for agent in agents:
98104
if hasattr(agent, "on_round_update"):
99105
agent.on_round_update(self.round)
100-
print(f"▶️ Running {self.name} round {self.round}...")
106+
self.logger.info(f"▶️ Running {self.name} round {self.round}...")
101107

102108
# Copy agent codebases into game's container
103109
for agent in agents:
110+
self.logger.debug(f"Copying {agent.name}'s codebase")
104111
copy_between_containers(
105112
src_container=agent.environment,
106113
dest_container=self.environment,
@@ -109,8 +116,12 @@ def _pre_round_setup(self, agents: list[Player]):
109116
)
110117

111118
# Ensure the log path + file exists
112-
assert_zero_exit_code(self.environment.execute(f"mkdir -p {self.log_path}"))
113-
assert_zero_exit_code(self.environment.execute(f"touch {self.round_log_path}"))
119+
assert_zero_exit_code(
120+
self.environment.execute(f"mkdir -p {self.log_path}"), logger=self.logger
121+
)
122+
assert_zero_exit_code(
123+
self.environment.execute(f"touch {self.round_log_path}"), logger=self.logger
124+
)
114125

115126
@abstractmethod
116127
def determine_winner(self, agents: list[Player]) -> Any:
@@ -132,11 +143,11 @@ def _post_round_setup(self, agents: list[Player]):
132143
f"{agent.environment.config.cwd}/logs/round_{self.round}.log",
133144
)
134145
except Exception:
135-
print(
146+
self.logger.error(
136147
f"Error copying round log to {agent.name}'s container: {traceback.format_exc()}"
137148
)
138149
else:
139-
print(f"Copied round log to {agent.name}'s container.")
150+
self.logger.info(f"Copied round log to {agent.name}'s container.")
140151

141152
try:
142153
copy_file_from_container(
@@ -145,14 +156,14 @@ def _post_round_setup(self, agents: list[Player]):
145156
DIR_LOGS / f"{self.game_id}/round_{self.round}.log",
146157
)
147158
except Exception:
148-
print(
159+
self.logger.error(
149160
f"Error copying round log to {agent.name}'s container: {traceback.format_exc()}"
150161
)
151162
else:
152-
print(
163+
self.logger.info(
153164
f"Copied round log from {agent.name}'s container to local log dir."
154165
)
155-
print(f"Round {self.round} completed.")
166+
self.logger.info(f"Round {self.round} completed.")
156167

157168
def run_round(self, agents: list[Player]):
158169
"""
@@ -164,7 +175,7 @@ def run_round(self, agents: list[Player]):
164175
self.execute_round(agents)
165176
self.determine_winner(agents)
166177
last_winner = self.scoreboard[-1][1]
167-
print(f"Round {self.round} winner: {last_winner}")
178+
self.logger.info(f"Round {self.round} winner: {last_winner}")
168179
self._post_round_setup(agents)
169180

170181
@property

codeclash/games/battlesnake/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ def execute_round(self, agents: list[Player]):
4040

4141
cmd.append(f"-o {self.round_log_path}")
4242
cmd = " ".join(cmd)
43-
print(f"Running command: {cmd}")
43+
self.logger.info(f"Running command: {cmd}")
4444

4545
# todo: should probably keep output somewhere?
4646
try:

codeclash/utils/environment.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
def assert_zero_exit_code(result: dict) -> dict:
1+
import logging
2+
3+
4+
def assert_zero_exit_code(
5+
result: dict, *, logger: logging.Logger | None = None
6+
) -> dict:
27
if result.get("returncode", 0) != 0:
38
msg = f"Command failed with exit code {result.get('returncode')}:\n{result.get('output')}"
9+
if logger is not None:
10+
logger.error(msg)
411
raise RuntimeError(msg)
512
return result

codeclash/utils/log.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
from pathlib import Path
5+
6+
from rich.console import Console
7+
from rich.text import Text
8+
9+
_STREAM_LEVEL = logging.DEBUG
10+
_FILE_LEVEL = logging.DEBUG
11+
12+
# logging.getLogger().setLevel(logging.DEBUG)
13+
14+
15+
class RichFormatter(logging.Formatter):
16+
"""Custom formatter that uses Rich for colorized output."""
17+
18+
def __init__(self, console: Console | None = None, emoji: str = ""):
19+
super().__init__()
20+
self.console = console or Console()
21+
self.emoji = emoji + " " if emoji and not emoji.endswith(" ") else emoji
22+
self.level_colors = {
23+
logging.DEBUG: "dim cyan",
24+
logging.INFO: "green",
25+
logging.WARNING: "yellow",
26+
logging.ERROR: "red",
27+
logging.CRITICAL: "bold red",
28+
}
29+
30+
def format(self, record: logging.LogRecord) -> str:
31+
level_color = self.level_colors.get(record.levelno, "white")
32+
level_name = record.levelname.replace("WARNING", "WARN")
33+
34+
# Calculate the prefix length for indentation
35+
prefix = f"{self.emoji}{level_name} [{record.name}] "
36+
indent = " " * len(prefix)
37+
38+
text = Text()
39+
text.append(self.emoji, style="white")
40+
text.append(f"{level_name} ", style=level_color)
41+
text.append(f"[{record.name}] ", style="dim")
42+
43+
# Handle multiline messages by indenting continuation lines
44+
message = record.getMessage()
45+
lines = message.split("\n")
46+
text.append(lines[0], style="white")
47+
48+
for line in lines[1:]:
49+
text.append("\n")
50+
text.append(indent, style="dim")
51+
text.append(line, style="white")
52+
53+
if record.exc_info:
54+
text.append("\n")
55+
exception_lines = self.formatException(record.exc_info).split("\n")
56+
for i, exc_line in enumerate(exception_lines):
57+
if i > 0:
58+
text.append("\n")
59+
text.append(indent, style="dim")
60+
text.append(exc_line, style="red")
61+
62+
with self.console.capture() as capture:
63+
self.console.print(text)
64+
return capture.get().rstrip()
65+
66+
67+
def get_logger(
68+
name: str, *, emoji: str = "", log_path: Path | None = None
69+
) -> logging.Logger:
70+
"""Get logger. Use this instead of `logging.getLogger` to ensure
71+
that the logger is set up with the correct handlers.
72+
"""
73+
logger = logging.getLogger(name)
74+
if logger.handlers:
75+
# Already set up
76+
return logger
77+
78+
console = Console()
79+
handler = logging.StreamHandler()
80+
formatter = RichFormatter(console, emoji=emoji)
81+
82+
handler.setFormatter(formatter)
83+
handler.setLevel(_STREAM_LEVEL)
84+
85+
# Set to lowest level and only use stream handlers to adjust levels
86+
logger.setLevel(1)
87+
logger.addHandler(handler)
88+
logger.propagate = True
89+
90+
if log_path is not None:
91+
log_path.parent.mkdir(parents=True, exist_ok=True)
92+
file_handler = logging.FileHandler(log_path)
93+
file_handler.setLevel(_FILE_LEVEL)
94+
95+
# Use a standard formatter for file logs with time, name, and level
96+
file_formatter = logging.Formatter(
97+
fmt="%(asctime)s [%(name)s] %(levelname)s %(message)s",
98+
datefmt="%Y-%m-%d %H:%M:%S",
99+
)
100+
file_handler.setFormatter(file_formatter)
101+
102+
logger.addHandler(file_handler)
103+
104+
return logger

0 commit comments

Comments
 (0)