Skip to content

Commit 2fde0b2

Browse files
jkbrookscursoragent
andcommitted
fix: FINAL() callable in REPL; parser ignores FINAL/FINAL_VAR inside code fences
Two bugs caused the RLM to return wrong answers when a model called FINAL() or FINAL_VAR() inside a ```repl``` code block: 1. Parser bug (utils/parsing.py): find_final_answer() ran its regex over the raw response including fenced code blocks, so FINAL(x) in a repl block was parsed as the terminal answer, returning the literal string "final_answer" instead of its value. Fix: strip all fenced code blocks before running the FINAL/FINAL_VAR regex. FINAL/FINAL_VAR in prose still work as before. 2. Runtime bug (environments/local_repl.py, environments/base_env.py): The system prompt advertises FINAL(value) as option 1 for submitting a final answer, but FINAL was never injected into the REPL globals - only FINAL_VAR was. Models calling FINAL() inside a repl block got a NameError. Fix: add _final() method to LocalREPL (mirrors _final_var for direct values), inject it as globals["FINAL"], restore it in _restore_scaffold, and add "FINAL" to RESERVED_TOOL_NAMES. Tests added to TestFindFinalAnswer: - test_final_inside_repl_code_block_not_parsed_as_terminal - test_final_var_inside_repl_code_block_not_parsed_as_terminal - test_final_in_prose_still_works_alongside_repl_block - test_final_callable_in_repl_environment Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent beb0603 commit 2fde0b2

4 files changed

Lines changed: 57 additions & 2 deletions

File tree

‎rlm/environments/base_env.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"llm_query_batched",
1717
"rlm_query",
1818
"rlm_query_batched",
19+
"FINAL",
1920
"FINAL_VAR",
2021
"SHOW_VARS",
2122
"context",

‎rlm/environments/local_repl.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ def setup(self):
188188
self._last_final_answer: str | None = None
189189

190190
# Add helper functions
191+
self.globals["FINAL"] = self._final
191192
self.globals["FINAL_VAR"] = self._final_var
192193
self.globals["SHOW_VARS"] = self._show_vars
193194
self.globals["llm_query"] = self._llm_query
@@ -205,6 +206,12 @@ def setup(self):
205206
# For non-callable values (constants, data), add to locals
206207
self.locals[name] = value
207208

209+
def _final(self, value: Any) -> str:
210+
"""Store and return a direct final value (compat with models calling FINAL in REPL)."""
211+
answer = str(value)
212+
self._last_final_answer = answer
213+
return answer
214+
208215
def _final_var(self, variable_name: str | Any) -> str:
209216
"""Return the value of a variable as a final answer for the main model, or stringify a direct value."""
210217
if not isinstance(variable_name, str):
@@ -468,6 +475,8 @@ def _restore_scaffold(self) -> None:
468475
self.globals["rlm_query"] = self._rlm_query
469476
elif name == "rlm_query_batched":
470477
self.globals["rlm_query_batched"] = self._rlm_query_batched
478+
elif name == "FINAL":
479+
self.globals["FINAL"] = self._final
471480
elif name == "FINAL_VAR":
472481
self.globals["FINAL_VAR"] = self._final_var
473482
elif name == "SHOW_VARS":

‎rlm/utils/parsing.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,13 @@ def find_final_answer(text: str, environment: "BaseEnv | None" = None) -> str |
4040
Returns:
4141
The final answer string, or None if no final answer pattern is found
4242
"""
43+
# Remove fenced code blocks first so FINAL()/FINAL_VAR() inside ```repl``` code
44+
# does not get parsed as a terminal answer.
45+
text_no_code = re.sub(r"```[\s\S]*?```", "", text)
46+
4347
# Check for FINAL_VAR pattern first - must be at start of line
4448
final_var_pattern = r"^\s*FINAL_VAR\((.*?)\)"
45-
match = re.search(final_var_pattern, text, re.MULTILINE | re.DOTALL)
49+
match = re.search(final_var_pattern, text_no_code, re.MULTILINE | re.DOTALL)
4650
if match:
4751
variable_name = match.group(1).strip().strip('"').strip("'")
4852
if environment is not None:
@@ -63,7 +67,7 @@ def find_final_answer(text: str, environment: "BaseEnv | None" = None) -> str |
6367
# Check for FINAL pattern - must be at start of line
6468
# Use greedy matching to capture content with nested parentheses
6569
final_pattern = r"^\s*FINAL\((.*)\)\s*$"
66-
match = re.search(final_pattern, text, re.MULTILINE | re.DOTALL)
70+
match = re.search(final_pattern, text_no_code, re.MULTILINE | re.DOTALL)
6771
if match:
6872
return match.group(1).strip()
6973

‎tests/test_parsing.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,47 @@ def test_final_var_variable_not_found_returns_none(self):
278278
result = find_final_answer("FINAL_VAR(missing)", environment=mock_env)
279279
assert result is None
280280

281+
def test_final_inside_repl_code_block_not_parsed_as_terminal(self):
282+
"""FINAL() written inside a ```repl``` block must NOT be treated as the final answer.
283+
284+
This was the root-cause bug: models often write FINAL(x) or FINAL_VAR(x) inside a
285+
repl block as code. The parser must ignore those and only look at prose text.
286+
"""
287+
text = "I will now finalise.\n```repl\nFINAL(final_answer)\n```"
288+
result = find_final_answer(text)
289+
assert result is None, (
290+
"FINAL() inside a repl block should not trigger termination"
291+
)
292+
293+
def test_final_var_inside_repl_code_block_not_parsed_as_terminal(self):
294+
"""FINAL_VAR() inside a repl block must NOT be treated as the final answer."""
295+
text = "Computing now.\n```repl\nFINAL_VAR(result)\n```"
296+
mock_env = Mock()
297+
mock_env.execute_code.return_value = REPLResult(stdout="55", stderr="", locals={})
298+
result = find_final_answer(text, environment=mock_env)
299+
assert result is None, (
300+
"FINAL_VAR() inside a repl block should not trigger termination"
301+
)
302+
mock_env.execute_code.assert_not_called()
303+
304+
def test_final_in_prose_still_works_alongside_repl_block(self):
305+
"""FINAL() in prose (outside code fences) must still be picked up correctly."""
306+
text = "```repl\nx = 1\n```\nFINAL(55)"
307+
result = find_final_answer(text)
308+
assert result == "55"
309+
310+
def test_final_callable_in_repl_environment(self):
311+
"""FINAL() should be callable inside the REPL and store the answer in _last_final_answer."""
312+
env = LocalREPL()
313+
try:
314+
env.execute_code("answer = 55")
315+
result = env.execute_code("FINAL(answer)")
316+
assert result.final_answer == "55", (
317+
f"Expected final_answer='55', got '{result.final_answer}'"
318+
)
319+
finally:
320+
env.cleanup()
321+
281322

282323
class TestFormatExecutionResult:
283324
"""Tests for format_execution_result function."""

0 commit comments

Comments
 (0)