Skip to content

Commit 18e7a42

Browse files
committed
Fix unit tests with a server-side agent session
Replace the stateless /fix_unittests_issue call with an agent loop: the first failure starts a session via /agent/start, the client executes the tool calls the model asks for (read/grep/ls/edit/write/delete files, run the unit tests) and posts results to /agent/continue until the agent calls submit_fix. The state machine then re-runs the unit tests as before; a repeated failure is fed back into the same session as the submit_fix result, so earlier attempts stay in context instead of being re-diagnosed from scratch. - render_machine/agent_tools.py: tool implementations confined to the build folder (writes) and project root (reads); changed files are tracked in the unit tests running context so they reach the FRID commit as before. - FixUnitTests: start/continue the session, per-attempt turn cap, fresh session when the previous one ended without a submission. - UnitTestsRunningContext carries the session id and the pending submit_fix call; it is recreated per unit-test loop so a session spans one FRID.
1 parent 8dd06ad commit 18e7a42

6 files changed

Lines changed: 615 additions & 84 deletions

File tree

‎codeplain_REST_api.py‎

Lines changed: 26 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -227,38 +227,6 @@ def render_functional_requirement(
227227

228228
return self.post_request(endpoint_url, headers, payload, run_state)
229229

230-
def fix_unittests_issue(
231-
self,
232-
frid,
233-
plain_source_tree,
234-
linked_resources,
235-
existing_files_content,
236-
module_name: str,
237-
required_modules,
238-
unittests_issue,
239-
run_state: RunState,
240-
conformance_tests_fixes: list[dict] | None = None,
241-
):
242-
endpoint_url = f"{self.api_url}/fix_unittests_issue"
243-
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
244-
245-
payload = {
246-
"frid": frid,
247-
"plain_source_tree": plain_source_tree,
248-
"linked_resources": linked_resources,
249-
"existing_files_content": existing_files_content,
250-
"module_name": module_name,
251-
"required_modules": required_modules,
252-
"unittests_issue": unittests_issue,
253-
"unittest_batch_id": run_state.unittest_batch_id,
254-
}
255-
# Implementation code changes made by the conformance tests fixer right before this unit tests run;
256-
# only sent when unit tests are processed inside the conformance tests phase.
257-
if conformance_tests_fixes is not None:
258-
payload["conformance_tests_fixes"] = conformance_tests_fixes
259-
260-
return self.post_request(endpoint_url, headers, payload, run_state)
261-
262230
def distill_conformance_test_memory(
263231
self,
264232
frid,
@@ -532,3 +500,29 @@ def summarize_finished_conformance_tests(
532500
}
533501

534502
return self.post_request(endpoint_url, headers, payload, run_state)
503+
504+
def agent_start(self, task_type: str, task_params: dict, frid: str, module_name: str, run_state: RunState):
505+
"""Start a server-side agent session; returns the first turn (tool calls or completion)."""
506+
endpoint_url = f"{self.api_url}/agent/start"
507+
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
508+
payload = {
509+
"task_type": task_type,
510+
"task_params": task_params,
511+
"frid": frid,
512+
"module_name": module_name,
513+
}
514+
return self.post_request(endpoint_url, headers, payload, run_state)
515+
516+
def agent_continue(
517+
self, session_id: str, tool_results: list[dict], frid: str, module_name: str, run_state: RunState
518+
):
519+
"""Feed tool results into an agent session and run its next turn."""
520+
endpoint_url = f"{self.api_url}/agent/continue"
521+
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
522+
payload = {
523+
"session_id": session_id,
524+
"tool_results": tool_results,
525+
"frid": frid,
526+
"module_name": module_name,
527+
}
528+
return self.post_request(endpoint_url, headers, payload, run_state)
Lines changed: 112 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,139 @@
11
from typing import Any
22

33
import file_utils
4-
import render_machine.render_utils as render_utils
4+
import plain_spec
55
from plain2code_console import console
66
from plain2code_exceptions import InternalClientError
7+
from render_machine import agent_tools
78
from render_machine.actions.base_action import BaseAction
8-
from render_machine.implementation_code_helpers import ImplementationCodeHelpers
99
from render_machine.render_context import RenderContext
1010

11-
MAX_ISSUE_LENGTH = 10000
11+
TASK_TYPE = "fix_unit_tests"
12+
SUBMIT_FIX_TOOL = "submit_fix"
13+
# Upper bound on LLM turns spent on one fix attempt; the server bounds the whole session.
14+
MAX_AGENT_TURNS_PER_ATTEMPT = 40
1215

1316

1417
class FixUnitTests(BaseAction):
18+
"""Fix failing unit tests with a server-side agent whose session spans all attempts for the FRID.
19+
20+
The first failure starts a session; the agent then drives read/grep/edit/run tool calls
21+
(executed here) until it calls submit_fix. The state machine re-runs the unit tests and,
22+
if they still fail, the next execution of this action answers that submit_fix call with
23+
the new failure output inside the same session, so earlier attempts stay in context.
24+
"""
25+
1526
SUCCESSFUL_OUTCOME = "unit_tests_fix_generated"
1627

1728
def execute(self, render_context: RenderContext, previous_action_payload: Any | None):
18-
if not previous_action_payload.get("previous_unittests_issue"):
29+
if not previous_action_payload or not previous_action_payload.get("previous_unittests_issue"):
1930
raise InternalClientError(
2031
"Internal client error: Previous action payload does not contain previous unit tests issue."
2132
)
22-
previous_unittests_issue = previous_action_payload["previous_unittests_issue"]
23-
24-
if previous_unittests_issue and len(previous_unittests_issue) > MAX_ISSUE_LENGTH:
25-
console.debug(
26-
f"Unit tests issue text is too long and will be smartly truncated to {MAX_ISSUE_LENGTH} characters."
33+
unittests_issue = previous_action_payload["previous_unittests_issue"]
34+
context = render_context.unit_tests_running_context
35+
api = render_context.codeplain_api
36+
frid, module_name = render_context.frid_context.frid, render_context.module_name
37+
changed_files_before = set(context.changed_files)
38+
39+
if context.agent_session_id is None:
40+
console.info("Starting an agent session to fix the unit tests.")
41+
response = api.agent_start(
42+
TASK_TYPE,
43+
self._build_task_params(render_context, unittests_issue),
44+
frid,
45+
module_name,
46+
render_context.run_state,
2747
)
28-
29-
existing_files, existing_files_content = ImplementationCodeHelpers.fetch_existing_files(
30-
render_context.build_folder
31-
)
32-
33-
render_utils.print_inputs(render_context, existing_files_content, "Files sent as input to unit tests fixing:")
34-
35-
conformance_tests_fixes = self._get_conformance_tests_fixes(render_context)
36-
if conformance_tests_fixes:
37-
console.info(
38-
f"Unit tests are fixed while preserving {len(conformance_tests_fixes)} implementation code change(s) "
39-
"made to fix the conformance tests."
48+
context.agent_session_id = response["session_id"]
49+
else:
50+
console.info(f"Continuing agent session {context.agent_session_id} with the new unit tests failure.")
51+
tool_results = context.pending_tool_results + [
52+
{
53+
"call_id": context.pending_submit_call_id,
54+
"output": f"The fix was applied, but the unit tests still fail:\n{unittests_issue}",
55+
}
56+
]
57+
context.pending_tool_results, context.pending_submit_call_id = [], None
58+
response = api.agent_continue(
59+
context.agent_session_id, tool_results, frid, module_name, render_context.run_state
4060
)
4161

42-
response_files = render_context.codeplain_api.fix_unittests_issue(
43-
render_context.frid_context.frid,
44-
render_context.plain_source_tree,
45-
render_context.frid_context.linked_resources,
46-
existing_files_content,
47-
render_context.module_name,
48-
render_context.get_required_modules_functionalities(),
49-
previous_unittests_issue,
50-
run_state=render_context.run_state,
51-
conformance_tests_fixes=conformance_tests_fixes,
52-
)
62+
submitted = False
63+
turns = 0
64+
while response.get("status") == "tool_calls" and turns < MAX_AGENT_TURNS_PER_ATTEMPT:
65+
turns += 1
66+
calls = response["calls"]
67+
submit_call = next((call for call in calls if call["name"] == SUBMIT_FIX_TOOL), None)
68+
tool_results = agent_tools.execute_calls(
69+
[call for call in calls if call is not submit_call], render_context
70+
)
71+
if submit_call is not None:
72+
context.pending_submit_call_id = submit_call["id"]
73+
context.pending_tool_results = tool_results
74+
submitted = True
75+
console.info(f"Agent submitted a fix: {submit_call['args'].get('changes_made', '')}")
76+
break
77+
response = api.agent_continue(
78+
context.agent_session_id, tool_results, frid, module_name, render_context.run_state
79+
)
5380

54-
_, changed_files = file_utils.update_build_folder_with_rendered_files(
55-
render_context.build_folder, existing_files, response_files
81+
if not submitted:
82+
# The session ended without a submission (finished in text, failed, or used up this
83+
# attempt's turn budget) — a fresh session is started if the tests still fail.
84+
status = response.get("status")
85+
if status == "failed":
86+
console.warning(f"Agent session failed: {response.get('error', 'unknown error')}")
87+
elif status == "tool_calls":
88+
console.warning(f"Agent used {MAX_AGENT_TURNS_PER_ATTEMPT} turns without submitting a fix.")
89+
context.agent_session_id, context.pending_submit_call_id, context.pending_tool_results = None, None, []
90+
91+
console.print_files(
92+
"Files changed while fixing unit tests:",
93+
render_context.build_folder,
94+
{path: "" for path in sorted(context.changed_files - changed_files_before)},
95+
style=console.OUTPUT_STYLE,
5696
)
57-
58-
render_context.unit_tests_running_context.changed_files.update(changed_files)
59-
60-
console.print_files("Files fixed:", render_context.build_folder, response_files, style=console.OUTPUT_STYLE)
61-
6297
return self.SUCCESSFUL_OUTCOME, None
6398

6499
@staticmethod
65-
def _get_conformance_tests_fixes(render_context: RenderContext) -> list[dict] | None:
66-
"""Implementation code changes the conformance tests fixer made before these unit tests were run.
67-
68-
Only present when unit tests are processed inside the conformance tests phase - the implementation
69-
and refactoring unit test passes have no conformance tests running context.
70-
"""
71-
conformance_tests_running_context = getattr(render_context, "conformance_tests_running_context", None)
72-
if conformance_tests_running_context is None:
73-
return None
100+
def _build_task_params(render_context: RenderContext, unittests_issue: str) -> dict:
101+
frid = render_context.frid_context.frid
102+
specifications, _ = plain_spec.get_specifications_for_frid(render_context.plain_source_tree, frid)
103+
return {
104+
"definitions": "\n".join(specifications.get(plain_spec.DEFINITIONS, [])),
105+
"non_functional_requirements": "\n".join(specifications.get(plain_spec.NON_FUNCTIONAL_REQUIREMENTS, [])),
106+
"functional_requirements": FixUnitTests._functional_requirements_section(render_context, specifications),
107+
"linked_resources": render_context.frid_context.linked_resources,
108+
"build_folder": render_context.build_folder,
109+
"module_name": render_context.module_name,
110+
"unittests_script_content": FixUnitTests._read_script(render_context.unittests_script),
111+
"unittests_issue": unittests_issue,
112+
}
74113

75-
implementation_code_fixes = getattr(conformance_tests_running_context, "implementation_code_fixes", None)
76-
if not implementation_code_fixes:
77-
return None
114+
@staticmethod
115+
def _functional_requirements_section(render_context: RenderContext, specifications: dict) -> str:
116+
sections = []
117+
for module_name, functionalities in render_context.get_required_modules_functionalities().items():
118+
sections.append(
119+
f"### Module: {module_name} (Already Implemented, for context)\n" + "\n".join(functionalities)
120+
)
121+
current = specifications.get(plain_spec.FUNCTIONAL_REQUIREMENTS, [])
122+
if len(current) > 1:
123+
sections.append(
124+
f"### Module: {render_context.module_name} (Already Implemented, for context)\n"
125+
+ "\n".join(current[:-1])
126+
)
127+
if current:
128+
sections.append(f"### Module: {render_context.module_name} (Currently Being Implemented)\n{current[-1]}")
129+
return "\n\n".join(sections)
78130

79-
return list(implementation_code_fixes)
131+
@staticmethod
132+
def _read_script(script: str | None) -> str:
133+
if not script:
134+
return ""
135+
try:
136+
with open(file_utils.add_current_path_if_no_path(script), "r", encoding="utf-8") as f:
137+
return f.read()
138+
except OSError:
139+
return ""

0 commit comments

Comments
 (0)