Skip to content

Commit bb3ce08

Browse files
committed
Speed up the agentic unit-test fixer
- Send raw test output as test_output (run_unit_tests and the submit_fix follow-up) so the server condenses it, instead of head/tail truncation that can drop the root cause. - Make the full test logs readable by read_file/grep and point the agent at them; grep gains context_lines and include. - Seed the first turn with the build folder's file tree and the files changed for the current FRID. - Skip the harness unit-test run when the agent's own run passed and no file changed since. - Start a new session after an abandoned one with previous_session_id so the server can pass on what was tried. - edit_file returns the edited region; repeated read-only calls with no file change in between return a pointer instead of the same output again.
1 parent df42072 commit bb3ce08

6 files changed

Lines changed: 252 additions & 27 deletions

File tree

‎render_machine/actions/fix_unit_tests.py‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
from typing import Any
23

34
import file_utils
@@ -12,6 +13,9 @@
1213
SUBMIT_FIX_TOOL = "submit_fix"
1314
# Upper bound on LLM turns spent on one fix attempt; the server bounds the whole session.
1415
MAX_AGENT_TURNS_PER_ATTEMPT = 40
16+
# Seeding the first turn: the build folder's file list and the files changed for the FRID.
17+
MAX_FILE_TREE_ENTRIES = 500
18+
MAX_RELEVANT_FILES_CHARS = 60_000
1519

1620

1721
class FixUnitTests(BaseAction):
@@ -35,9 +39,14 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
3539
api = render_context.codeplain_api
3640
frid, module_name = render_context.frid_context.frid, render_context.module_name
3741
changed_files_before = set(context.changed_files)
42+
log_path = render_context.script_execution_history.latest_unit_test_output_path
43+
if log_path:
44+
agent_tools.register_log_path(log_path, render_context)
3845

3946
if context.agent_session_id is None:
4047
console.info("Starting an agent session to fix the unit tests.")
48+
# Cached read results point at earlier turns, which a new session does not have.
49+
context.tool_result_cache.clear()
4150
response = api.agent_start(
4251
TASK_TYPE,
4352
self._build_task_params(render_context, unittests_issue),
@@ -51,7 +60,9 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
5160
tool_results = context.pending_tool_results + [
5261
{
5362
"call_id": context.pending_submit_call_id,
54-
"output": f"The fix was applied, but the unit tests still fail:\n{unittests_issue}",
63+
"output": "The fix was applied, but the unit tests still fail."
64+
+ agent_tools.full_log_pointer(log_path),
65+
"test_output": unittests_issue,
5566
}
5667
]
5768
context.pending_tool_results, context.pending_submit_call_id = [], None
@@ -86,6 +97,7 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
8697
console.warning(f"Agent session failed: {response.get('error', 'unknown error')}")
8798
elif status == "tool_calls":
8899
console.warning(f"Agent used {MAX_AGENT_TURNS_PER_ATTEMPT} turns without submitting a fix.")
100+
context.previous_session_id = context.agent_session_id
89101
context.agent_session_id, context.pending_submit_call_id, context.pending_tool_results = None, None, []
90102

91103
console.print_files(
@@ -100,7 +112,8 @@ def execute(self, render_context: RenderContext, previous_action_payload: Any |
100112
def _build_task_params(render_context: RenderContext, unittests_issue: str) -> dict:
101113
frid = render_context.frid_context.frid
102114
specifications, _ = plain_spec.get_specifications_for_frid(render_context.plain_source_tree, frid)
103-
return {
115+
context = render_context.unit_tests_running_context
116+
task_params = {
104117
"definitions": "\n".join(specifications.get(plain_spec.DEFINITIONS, [])),
105118
"non_functional_requirements": "\n".join(specifications.get(plain_spec.NON_FUNCTIONAL_REQUIREMENTS, [])),
106119
"functional_requirements": FixUnitTests._functional_requirements_section(render_context, specifications),
@@ -109,7 +122,42 @@ def _build_task_params(render_context: RenderContext, unittests_issue: str) -> d
109122
"module_name": render_context.module_name,
110123
"unittests_script_content": FixUnitTests._read_script(render_context.unittests_script),
111124
"unittests_issue": unittests_issue,
125+
"unittests_log_path": render_context.script_execution_history.latest_unit_test_output_path,
126+
"file_tree": FixUnitTests._file_tree(render_context.build_folder),
127+
"relevant_files": FixUnitTests._relevant_files(
128+
render_context.build_folder, render_context.frid_context.changed_files | context.changed_files
129+
),
112130
}
131+
if context.previous_session_id:
132+
task_params["previous_session_id"] = context.previous_session_id
133+
return task_params
134+
135+
@staticmethod
136+
def _file_tree(build_folder: str) -> str:
137+
paths = []
138+
for root, dirs, files in os.walk(build_folder):
139+
dirs[:] = sorted(d for d in dirs if d not in agent_tools.GREP_EXCLUDED_DIRS and not d.startswith("."))
140+
paths.extend(os.path.relpath(os.path.join(root, name), build_folder) for name in sorted(files))
141+
if len(paths) > MAX_FILE_TREE_ENTRIES:
142+
return "\n".join(paths[:MAX_FILE_TREE_ENTRIES]) + "\n... [more files not listed]"
143+
return "\n".join(paths)
144+
145+
@staticmethod
146+
def _relevant_files(build_folder: str, file_names: set[str]) -> dict[str, str]:
147+
"""Contents of the given build-relative files, smallest first, within MAX_RELEVANT_FILES_CHARS."""
148+
contents = {}
149+
for name in file_names:
150+
full_path = os.path.join(build_folder, name)
151+
if os.path.isfile(full_path):
152+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
153+
contents[name] = f.read()
154+
relevant, total = {}, 0
155+
for name in sorted(contents, key=lambda n: (len(contents[n]), n)):
156+
if total + len(contents[name]) > MAX_RELEVANT_FILES_CHARS:
157+
break
158+
relevant[name] = contents[name]
159+
total += len(contents[name])
160+
return dict(sorted(relevant.items()))
113161

114162
@staticmethod
115163
def _functional_requirements_section(render_context: RenderContext, specifications: dict) -> str:

‎render_machine/actions/run_unit_tests.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ class RunUnitTests(BaseAction):
1616
UNRECOVERABLE_ERROR_OUTCOME = "unrecoverable_error_occurred"
1717

1818
def execute(self, render_context: RenderContext, _previous_action_payload: Any | None):
19+
context = render_context.unit_tests_running_context
20+
if context.verified_passing:
21+
# The fixing agent's own run passed and no file changed since; running again is redundant.
22+
context.verified_passing = False
23+
console.info("Unit tests already passed in the fixing agent's last run; not running them again.")
24+
render_context.script_execution_history.latest_unit_test_output_path = context.verified_passing_log_path
25+
render_context.script_execution_history.should_update_script_outputs = True
26+
return self.SUCCESSFUL_OUTCOME, None
27+
1928
unittests_script = os.path.normpath(render_context.unittests_script)
2029

2130
console.info(

‎render_machine/agent_tools.py‎

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import glob
10+
import json
1011
import os
1112
import subprocess
1213
import tempfile
@@ -19,6 +20,11 @@
1920
DEFAULT_READ_LIMIT = 200
2021
MAX_LINE_CHARS = 10_000
2122
MAX_OUTPUT_CHARS = 30_000
23+
EDIT_SNIPPET_CONTEXT_LINES = 3
24+
MAX_EDIT_SNIPPET_LINES = 60
25+
MAX_GREP_CONTEXT_LINES = 20
26+
# Tools without side effects; repeating one while no file changed returns a pointer instead.
27+
READ_ONLY_TOOLS = ("read_file", "grep", "ls_files")
2228
GREP_EXCLUDED_DIRS = (".git", "__pycache__", "node_modules", ".venv", "target", "dist", "build")
2329

2430

@@ -37,7 +43,16 @@ def _within(path: str, folder: str) -> bool:
3743

3844

3945
def _readable(path: str, render_context: RenderContext) -> bool:
40-
return _within(path, _build_folder(render_context)) or _within(path, os.path.normpath(os.getcwd()))
46+
return (
47+
_within(path, _build_folder(render_context))
48+
or _within(path, os.path.normpath(os.getcwd()))
49+
or path in render_context.unit_tests_running_context.readable_log_paths
50+
)
51+
52+
53+
def register_log_path(log_path: str, render_context: RenderContext) -> None:
54+
"""Allow read_file/grep on a full test log the agent is pointed to."""
55+
render_context.unit_tests_running_context.readable_log_paths.add(os.path.normpath(os.path.abspath(log_path)))
4156

4257

4358
def _writable(path: str, render_context: RenderContext) -> bool:
@@ -58,8 +73,10 @@ def _bound(text: str) -> str:
5873

5974

6075
def _track_change(full_path: str, render_context: RenderContext) -> None:
61-
relative_path = os.path.relpath(full_path, _build_folder(render_context))
62-
render_context.unit_tests_running_context.changed_files.add(relative_path)
76+
context = render_context.unit_tests_running_context
77+
context.changed_files.add(os.path.relpath(full_path, _build_folder(render_context)))
78+
context.verified_passing, context.verified_passing_log_path = False, None
79+
context.tool_result_cache.clear()
6380

6481

6582
def read_file(args: dict, render_context: RenderContext) -> str:
@@ -98,7 +115,13 @@ def grep(args: dict, render_context: RenderContext) -> str:
98115
# is the form the other tools accept.
99116
build_folder = _build_folder(render_context)
100117
cwd = build_folder if _within(target, build_folder) else os.getcwd()
101-
command = ["grep", "-rnI", *[f"--exclude-dir={d}" for d in GREP_EXCLUDED_DIRS], "-e", pattern, "--"]
118+
options = [f"--exclude-dir={d}" for d in GREP_EXCLUDED_DIRS]
119+
context_lines = min(max(int(args.get("context_lines") or 0), 0), MAX_GREP_CONTEXT_LINES)
120+
if context_lines:
121+
options.append(f"-C{context_lines}")
122+
if args.get("include"):
123+
options.append(f"--include={args['include']}")
124+
command = ["grep", "-rnI", *options, "-e", pattern, "--"]
102125
command.append(os.path.relpath(target, cwd) if _within(target, cwd) else target)
103126
result = subprocess.run(command, capture_output=True, text=True, cwd=cwd)
104127
if result.returncode == 1:
@@ -118,7 +141,7 @@ def ls_files(args: dict, render_context: RenderContext) -> str:
118141
listing = [entry + "/" if os.path.isdir(os.path.join(target, entry)) else entry for entry in entries]
119142
return f"{target}:\n" + ("\n".join(listing) if listing else "(empty)")
120143
matches = sorted(glob.glob(target, recursive=True))
121-
return "\n".join(matches) if matches else f"No files match '{target}'."
144+
return _bound("\n".join(matches)) if matches else f"No files match '{target}'."
122145

123146

124147
def edit_file(args: dict, render_context: RenderContext) -> str:
@@ -141,7 +164,18 @@ def edit_file(args: dict, render_context: RenderContext) -> str:
141164
with open(full_path, "w", encoding="utf-8") as f:
142165
f.write(content.replace(search, replace, 1))
143166
_track_change(full_path, render_context)
144-
return f"Edited '{full_path}'."
167+
return f"Edited '{full_path}'. The edited region now reads:\n" + _edit_snippet(content, search, replace)
168+
169+
170+
def _edit_snippet(content: str, search: str, replace: str) -> str:
171+
"""Numbered lines of the replacement plus a little context, so the edit needs no re-read."""
172+
lines = content.replace(search, replace, 1).split("\n")
173+
first = content[: content.index(search)].count("\n")
174+
last = first + replace.count("\n")
175+
start = max(first - EDIT_SNIPPET_CONTEXT_LINES, 0)
176+
end = min(last + EDIT_SNIPPET_CONTEXT_LINES + 1, len(lines), start + MAX_EDIT_SNIPPET_LINES)
177+
snippet = "\n".join(f"{start + i + 1}: {line}" for i, line in enumerate(lines[start:end]))
178+
return _bound(snippet)
145179

146180

147181
def write_file(args: dict, render_context: RenderContext) -> str:
@@ -166,25 +200,37 @@ def delete_file(args: dict, render_context: RenderContext) -> str:
166200
return f"Deleted '{full_path}'."
167201

168202

169-
def run_unit_tests(_args: dict, render_context: RenderContext) -> str:
203+
def full_log_pointer(log_file_path: str | None) -> str:
204+
return f" Full log: {log_file_path} (search it with grep, passing that path as file_path)." if log_file_path else ""
205+
206+
207+
def run_unit_tests(_args: dict, render_context: RenderContext) -> dict:
208+
"""Returns a result dict: a short header in `output` and the raw failure output in
209+
`test_output`, which the server condenses (summarizing it when it is long)."""
170210
exit_code, output, log_file_path = render_utils.execute_script(
171211
os.path.normpath(render_context.unittests_script),
172212
[render_context.build_folder],
173213
"Unit Tests",
174214
timeout=render_context.test_script_timeout,
175215
stop_event=render_context.stop_event,
176216
)
217+
context = render_context.unit_tests_running_context
177218
if exit_code == 0:
178-
return "All unit tests passed."
219+
context.verified_passing, context.verified_passing_log_path = True, log_file_path
220+
return {"output": "All unit tests passed."}
179221
if not log_file_path and output:
180222
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False, suffix=".unittest_output") as f:
181223
f.write(output)
182224
log_file_path = f.name
183-
pointer = f" Full output: {log_file_path} (use read_file)." if log_file_path else ""
184-
return f"Unit tests failed (exit code {exit_code}).{pointer}\n{_bound(output)}"
225+
if log_file_path:
226+
register_log_path(log_file_path, render_context)
227+
return {
228+
"output": f"Unit tests failed (exit code {exit_code}).{full_log_pointer(log_file_path)}",
229+
"test_output": output,
230+
}
185231

186232

187-
TOOLS: dict[str, Callable[[dict, RenderContext], str]] = {
233+
TOOLS: dict[str, Callable[[dict, RenderContext], str | dict]] = {
188234
"read_file": read_file,
189235
"grep": grep,
190236
"ls_files": ls_files,
@@ -196,17 +242,31 @@ def run_unit_tests(_args: dict, render_context: RenderContext) -> str:
196242

197243

198244
def execute_calls(calls: list[dict], render_context: RenderContext) -> list[dict]:
199-
"""Execute the agent's tool calls in order; every call gets a result, errors included."""
245+
"""Execute the agent's tool calls in order; every call gets a result, errors included.
246+
247+
A repeated read-only call (same tool and arguments, no file changed in between) is not
248+
re-executed: its result is already in the conversation, so a short pointer is returned."""
249+
cache = render_context.unit_tests_running_context.tool_result_cache
200250
results = []
201251
for call in calls:
202252
tool = TOOLS.get(call["name"])
253+
cache_key = json.dumps([call["name"], call.get("args") or {}], sort_keys=True)
254+
result: dict
203255
if tool is None:
204-
output = f"Error: unknown tool '{call['name']}'."
256+
result = {"output": f"Error: unknown tool '{call['name']}'."}
257+
elif call["name"] in READ_ONLY_TOOLS and cache_key in cache:
258+
result = {
259+
"output": "Same call as an earlier one and no file has changed since; "
260+
"its result is unchanged (see the earlier result above)."
261+
}
205262
else:
206263
try:
207264
output = tool(call.get("args") or {}, render_context)
265+
result = output if isinstance(output, dict) else {"output": output}
208266
except Exception as e:
209-
output = f"Error: tool '{call['name']}' failed: {type(e).__name__}: {e}"
210-
console.debug(f"Agent tool {call['name']}({call.get('args')}) -> {output[:200]!r}")
211-
results.append({"call_id": call["id"], "output": output})
267+
result = {"output": f"Error: tool '{call['name']}' failed: {type(e).__name__}: {e}"}
268+
if call["name"] in READ_ONLY_TOOLS and not result["output"].startswith("Error"):
269+
cache[cache_key] = result["output"]
270+
console.debug(f"Agent tool {call['name']}({call.get('args')}) -> {result['output'][:200]!r}")
271+
results.append({"call_id": call["id"], **result})
212272
return results

‎render_machine/render_types.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ class UnitTestsRunningContext:
6060
# run's outcome, plus results of any tool calls made in the same turn as submit_fix.
6161
pending_submit_call_id: Optional[str] = None
6262
pending_tool_results: list[dict] = field(default_factory=list)
63+
# Session abandoned without a submission (turn budget used up, LLM failure); the next session
64+
# starts with a digest of what it tried.
65+
previous_session_id: Optional[str] = None
66+
# Set when the agent's own run_unit_tests passed and no file changed since, so the harness
67+
# can accept the fix without running the suite again.
68+
verified_passing: bool = False
69+
verified_passing_log_path: Optional[str] = None
70+
# Full test logs the agent was pointed to; readable by read_file/grep although outside the
71+
# build folder.
72+
readable_log_paths: set[str] = field(default_factory=set)
73+
# Results of read-only tool calls, keyed by call; cleared whenever a file changes.
74+
tool_result_cache: dict[str, str] = field(default_factory=dict)
6375

6476

6577
class ConformanceTestsRunningContext:

‎tests/test_agent_tools.py‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from render_machine import agent_tools
9+
from render_machine.render_types import UnitTestsRunningContext
910

1011

1112
@pytest.fixture
@@ -17,7 +18,7 @@ def project(tmp_path, monkeypatch):
1718
monkeypatch.chdir(tmp_path)
1819
render_context = SimpleNamespace(
1920
build_folder=str(build),
20-
unit_tests_running_context=SimpleNamespace(changed_files=set()),
21+
unit_tests_running_context=UnitTestsRunningContext(fix_attempts=1),
2122
unittests_script=None,
2223
test_script_timeout=None,
2324
stop_event=None,
@@ -97,14 +98,56 @@ def boom(_args, _rc):
9798

9899

99100
def test_run_unit_tests_reports_pass_and_failure(project, monkeypatch):
100-
outcomes = iter([(0, "", None), (1, "FAILED test_x", "/tmp/log.txt")])
101+
log = project.root.parent / "log.txt"
102+
log.write_text("FAILED test_x\nCaused by: boom\n")
103+
outcomes = iter([(0, "", "/tmp/pass.txt"), (1, "FAILED test_x", str(log))])
101104
monkeypatch.setattr(agent_tools.render_utils, "execute_script", lambda *a, **k: next(outcomes))
102105
project.rc.unittests_script = "run_tests.sh"
103-
assert agent_tools.run_unit_tests({}, project.rc) == "All unit tests passed."
106+
context = project.rc.unit_tests_running_context
107+
108+
assert agent_tools.run_unit_tests({}, project.rc) == {"output": "All unit tests passed."}
109+
assert context.verified_passing and context.verified_passing_log_path == "/tmp/pass.txt"
110+
104111
failure = agent_tools.run_unit_tests({}, project.rc)
105-
assert (
106-
failure.startswith("Unit tests failed (exit code 1). Full output: /tmp/log.txt") and "FAILED test_x" in failure
112+
assert failure["output"].startswith(f"Unit tests failed (exit code 1). Full log: {log}")
113+
# raw output goes to the server for condensing, not truncated here
114+
assert failure["test_output"] == "FAILED test_x"
115+
# the full log is outside the build folder and project root but greppable
116+
assert agent_tools.grep({"pattern": "Caused by", "file_path": str(log)}, project.rc).endswith("Caused by: boom")
117+
118+
119+
def test_file_change_invalidates_verified_pass_and_read_cache(project):
120+
context = project.rc.unit_tests_running_context
121+
context.verified_passing = True
122+
first = agent_tools.execute_calls([{"id": "1", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
123+
repeat = agent_tools.execute_calls([{"id": "2", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
124+
assert first[0]["output"].startswith("1: def add")
125+
assert repeat[0]["output"].startswith("Same call as an earlier one")
126+
127+
agent_tools.write_file({"file_path": "other.py", "content": "y = 1\n"}, project.rc)
128+
assert context.verified_passing is False
129+
again = agent_tools.execute_calls([{"id": "3", "name": "read_file", "args": {"file_path": "app.py"}}], project.rc)
130+
assert again[0]["output"].startswith("1: def add")
131+
132+
133+
def test_edit_file_returns_the_edited_region(project):
134+
out = agent_tools.edit_file(
135+
{
136+
"file_path": "app.py",
137+
"search": "def sub(a, b):\n return a - b",
138+
"replace": "def sub(a, b):\n return b",
139+
},
140+
project.rc,
107141
)
142+
assert out.startswith("Edited") and "5: def sub(a, b):\n6: return b" in out
143+
assert "2: return a - b" in out and "1: def add" not in out
144+
145+
146+
def test_grep_context_lines_and_include(project):
147+
(project.build / "notes.txt").write_text("def sub is documented here\n")
148+
hits = agent_tools.grep({"pattern": "def sub", "context_lines": 1, "include": "*.py"}, project.rc)
149+
assert "notes.txt" not in hits
150+
assert "app.py-4-" in hits and "app.py:5:def sub(a, b):" in hits and "app.py-6-" in hits
108151

109152

110153
def test_bound_truncates_long_lines_and_large_output():

0 commit comments

Comments
 (0)