|
1 | 1 | from typing import Any |
2 | 2 |
|
3 | 3 | import file_utils |
4 | | -import render_machine.render_utils as render_utils |
| 4 | +import plain_spec |
5 | 5 | from plain2code_console import console |
6 | 6 | from plain2code_exceptions import InternalClientError |
| 7 | +from render_machine import agent_tools |
7 | 8 | from render_machine.actions.base_action import BaseAction |
8 | | -from render_machine.implementation_code_helpers import ImplementationCodeHelpers |
9 | 9 | from render_machine.render_context import RenderContext |
10 | 10 |
|
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 |
12 | 15 |
|
13 | 16 |
|
14 | 17 | 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 | + |
15 | 26 | SUCCESSFUL_OUTCOME = "unit_tests_fix_generated" |
16 | 27 |
|
17 | 28 | 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"): |
19 | 30 | raise InternalClientError( |
20 | 31 | "Internal client error: Previous action payload does not contain previous unit tests issue." |
21 | 32 | ) |
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, |
27 | 47 | ) |
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 |
40 | 60 | ) |
41 | 61 |
|
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 | + ) |
53 | 80 |
|
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, |
56 | 96 | ) |
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 | | - |
62 | 97 | return self.SUCCESSFUL_OUTCOME, None |
63 | 98 |
|
64 | 99 | @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 | + } |
74 | 113 |
|
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) |
78 | 130 |
|
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