Skip to content
Closed
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
2 changes: 2 additions & 0 deletions debug_gym/gym/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ class Event(Enum):
FILE_CHANGE = "file_change"
REWRITE_SUCCESS = "rewrite_success"
REWRITE_FAIL = "rewrite_fail"
CREATE_SUCCESS = "create_success"
CREATE_FAIL = "create_fail"
SWITCH_CONTEXT = "switch_context"

@property
Expand Down
1 change: 1 addition & 0 deletions debug_gym/gym/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from debug_gym.gym.tools.bash import BashTool
from debug_gym.gym.tools.create import CreateTool
from debug_gym.gym.tools.eval import EvalTool
from debug_gym.gym.tools.grep import GrepTool
from debug_gym.gym.tools.listdir import ListdirTool
Expand Down
105 changes: 105 additions & 0 deletions debug_gym/gym/tools/create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import difflib

from debug_gym.gym.entities import Event, Observation
from debug_gym.gym.tools.tool import EnvironmentTool
from debug_gym.gym.tools.toolbox import Toolbox


@Toolbox.register()
class CreateTool(EnvironmentTool):
name = "create"
examples = [
"""create(path="code/newfile.py", content="print('hola')") will rewrite the specified file 'code/newfile.py' (the entire code) to be print('hola'), because no line number is provided.""",
"""create(path="code/file.py", content=" print('hello')\\n print('hi again')", overwrite=True) will create a new file at the path 'code/existingfile.py' containing the two lines provided, both with indents ahead (in this case, 4 spaces). Additionally, with overwrite=True, if the file already exists, it will be overwritten with the new content.""",
]
description = (
"Create a new file with the specified file path, with the content. Optionally overwrite existing files with new content. The new code should be valid python code include proper indentation (can be determined from context)."
+ "\nExamples (for demonstration purposes only, you need to adjust the tool calling format according to your specific syntax):"
+ "\n".join(examples)
)
arguments = {
"path": {
"type": ["string"],
"description": "A file path to be rewritten.",
},
"content": {
"type": ["string"],
"description": "The content of the new file. The code should be valid python code include proper indentation (can be determined from context).",
},
"overwrite": {
"type": ["boolean", "null"],
"description": "If True, overwrite the existing file. If False, create a new file.",
},
}

def _create_file(self, environment, file_path: str, content: str):
original_content = ""
if environment.workspace.has_file(file_path):
original_content = environment.workspace.read_file(file_path)

environment.workspace.write_file(file_path, content)

# Calculate diff between original and new content
new_content = environment.workspace.read_file(file_path)
diff = "".join(
difflib.unified_diff(
original_content.splitlines(keepends=True),
new_content.splitlines(keepends=True),
fromfile="original",
tofile="current",
)
)

return diff

def fail(self, environment, message: str) -> Observation:
self.create_success = False
message = f"Create failed. Error message:\n{message}\n"
self.queue_event(
environment=environment,
event=Event.CREATE_FAIL,
message=message,
)
return Observation(self.name, message)

def use(
self,
environment,
path: str = None,
content: str = "",
overwrite: bool = False,
) -> Observation:
self.create_success = False
if path is None:
return self.fail(environment, "File path is None.")
if environment.workspace.has_file(path):
if not overwrite:
return self.fail(
environment,
"File already exists. To overwrite, Please specify overwrite=True.",
)
if not environment.workspace.is_editable(path):
return self.fail(environment, f"`{path}` is not editable.")

abs_filepath = environment.workspace.resolve_path(path)
if environment.workspace._is_ignored_func(abs_filepath):
return self.fail(
environment,
f"`{path}` is ignored by the ignore patterns and cannot be created.",
)

try:
diff = self._create_file(environment, path, content)
except Exception as e:
return self.fail(environment, str(e))

self.create_success = True
message = f"The file `{path}` has been created successfully.\n\nDiff:\n\n{diff}"

self.queue_event(
environment=environment,
event=Event.CREATE_SUCCESS,
message=message,
file=path,
)
return Observation(self.name, message)
3 changes: 3 additions & 0 deletions debug_gym/gym/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ def write_file(self, filepath: str, content: str):
"""Writes `content` to `filepath` exactly as-is, preserving any trailing newlines."""
abs_filepath = self.resolve_path(filepath)

# create parent directories via the terminal if needed
self.terminal.run(f'mkdir -p "{str(abs_filepath.parent)}"', raises=True)

# In the following command we:
# - use a single-quoted heredoc (cat <<'nDEBUGGYM_EOF' ... nDEBUGGYM_EOF) so the heredoc body is taken literally (no shell expansion)
# - append a sentinel character DEBUGGYM_DEL inside the heredoc so we can detect/restore trailing newlines later
Expand Down
159 changes: 159 additions & 0 deletions tests/gym/tools/test_create.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from pathlib import Path

import pytest

from debug_gym.gym.envs.env import RepoEnv
from debug_gym.gym.tools import CreateTool


@pytest.fixture
def env(tmp_path):
tmp_path = Path(tmp_path)
repo_path = tmp_path / "repo"
repo_path.mkdir()

file_content = (
"import abc\n"
"\n"
"def greet():\n"
" print('Hello, world!')\n"
" print('Goodbye, world!')\n"
)

with open(repo_path / "existingfile.py", "w") as f:
f.write(file_content)

env = RepoEnv(path=repo_path, dir_tree_depth=2)

create_tool = CreateTool()
env.add_tool(create_tool)

env.reset()
return env


def test_create_no_overwrite_error(env):
create_tool = env.get_tool("create")
patch = {
"path": "existingfile.py",
"content": "print('Hello, world!')",
}
obs = create_tool.use(env, **patch)
assert obs.source == "create"
assert (
obs.observation
== "Create failed. Error message:\nFile already exists. To overwrite, Please specify overwrite=True.\n"
)


def test_overwrite_success(env):
create_tool = env.get_tool("create")
patch = {
"path": "existingfile.py",
"content": "print('Hello, world!')",
"overwrite": True,
}
obs = create_tool.use(env, **patch)

assert create_tool.create_success
assert obs.observation == (
"The file `existingfile.py` has been created successfully.\n"
"\n"
"Diff:\n"
"\n"
"--- original\n"
"+++ current\n"
"@@ -1,5 +1 @@\n"
"-import abc\n"
"-\n"
"-def greet():\n"
"- print('Hello, world!')\n"
"- print('Goodbye, world!')\n"
"+print('Hello, world!')"
)
with open(env.working_dir / "existingfile.py", "r") as f:
new_content = f.read()
assert new_content == "print('Hello, world!')"


def test_create_with_file_path(env):
create_tool = env.get_tool("create")
patch = {
"path": "newdir/newfile.py",
"content": "print('Hello, world!')",
}
obs = create_tool.use(env, **patch)

assert create_tool.create_success
assert obs.observation == (
"The file `newdir/newfile.py` has been created successfully.\n"
"\n"
"Diff:\n"
"\n"
"--- original\n"
"+++ current\n"
"@@ -0,0 +1 @@\n"
"+print('Hello, world!')"
)
with open(env.working_dir / "newdir/newfile.py", "r") as f:
new_content = f.read()
assert new_content == "print('Hello, world!')"


def test_create_no_path_error(env):
create_tool = env.get_tool("create")
patch = {
"path": None,
"content": "print('Hello, world!')",
}
obs = create_tool.use(env, **patch)
assert obs.source == "create"
assert obs.observation == "Create failed. Error message:\nFile path is None.\n"


def test_overwrite_readonly_file_error(env):
# overwrite the is_editable method to simulate a read-only file
env.workspace.is_editable = lambda x: x != "existingfile.py"
create_tool = env.get_tool("create")
patch = {
"path": "existingfile.py",
"content": " print(f'Hello, {name}!')",
"overwrite": True,
}

obs = create_tool.use(env, **patch)
assert obs.observation == (
"Create failed. Error message:\n`existingfile.py` is not editable.\n"
)


def test_ignorable_file_error(env):
# overwrite the _is_ignored_func method to simulate an ignored file
env.workspace._is_ignored_func = lambda x: x.name == "ignoredfile.py"
create_tool = env.get_tool("create")
patch = {
"path": "ignoredfile.py",
"content": " print(f'Hello, {name}!')",
}

obs = create_tool.use(env, **patch)
assert obs.observation == (
"Create failed. Error message:\n`ignoredfile.py` is ignored by the ignore patterns and cannot be created.\n"
)


def test_create_with_newlines(env):
create_tool = env.get_tool("create")
patch = {
"path": "existingfile.py",
"content": " print(f'Hello, {name}!')\n print(f'Hello #2!')",
"overwrite": True,
}

obs = create_tool.use(env, **patch)

assert create_tool.create_success
with open(env.working_dir / "existingfile.py", "r") as f:
new_content = f.read()

assert new_content == (" print(f'Hello, {name}!')\n" " print(f'Hello #2!')")