Skip to content
Open
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
1 change: 1 addition & 0 deletions rlm/environments/base_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"llm_query_batched",
"rlm_query",
"rlm_query_batched",
"FINAL",
"FINAL_VAR",
"SHOW_VARS",
"context",
Expand Down
9 changes: 9 additions & 0 deletions rlm/environments/local_repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ def setup(self):
self._last_final_answer: str | None = None

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

def _final(self, value: Any) -> str:
"""Store and return a direct final value (compat with models calling FINAL in REPL)."""
answer = str(value)
self._last_final_answer = answer
return answer

def _final_var(self, variable_name: str | Any) -> str:
"""Return the value of a variable as a final answer for the main model, or stringify a direct value."""
if not isinstance(variable_name, str):
Expand Down Expand Up @@ -468,6 +475,8 @@ def _restore_scaffold(self) -> None:
self.globals["rlm_query"] = self._rlm_query
elif name == "rlm_query_batched":
self.globals["rlm_query_batched"] = self._rlm_query_batched
elif name == "FINAL":
self.globals["FINAL"] = self._final
elif name == "FINAL_VAR":
self.globals["FINAL_VAR"] = self._final_var
elif name == "SHOW_VARS":
Expand Down
8 changes: 6 additions & 2 deletions rlm/utils/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ def find_final_answer(text: str, environment: "BaseEnv | None" = None) -> str |
Returns:
The final answer string, or None if no final answer pattern is found
"""
# Remove fenced code blocks first so FINAL()/FINAL_VAR() inside ```repl``` code
# does not get parsed as a terminal answer.
text_no_code = re.sub(r"```[\s\S]*?```", "", text)

Copilot AI Feb 19, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text_no_code = re.sub(r"```[\s\S]*?```", "", text) strips all fenced code blocks from the entire response before searching for FINAL(...). This can silently corrupt a legitimate final answer if the payload inside FINAL(...) includes a markdown code fence (e.g., returning a code snippet), because the fenced content will be removed before extraction. Consider narrowing the stripping to only repl blocks (the ones the runtime executes), or performing code-fence removal only outside the matched FINAL(...)/FINAL_VAR(...) span so the final payload is preserved verbatim.

Suggested change
text_no_code = re.sub(r"```[\s\S]*?```", "", text)
text_no_code = re.sub(r"```repl[\s\S]*?```", "", text)

Copilot uses AI. Check for mistakes.

# Check for FINAL_VAR pattern first - must be at start of line
final_var_pattern = r"^\s*FINAL_VAR\((.*?)\)"
match = re.search(final_var_pattern, text, re.MULTILINE | re.DOTALL)
match = re.search(final_var_pattern, text_no_code, re.MULTILINE | re.DOTALL)
if match:
variable_name = match.group(1).strip().strip('"').strip("'")
if environment is not None:
Expand All @@ -63,7 +67,7 @@ def find_final_answer(text: str, environment: "BaseEnv | None" = None) -> str |
# Check for FINAL pattern - must be at start of line
# Use greedy matching to capture content with nested parentheses
final_pattern = r"^\s*FINAL\((.*)\)\s*$"
match = re.search(final_pattern, text, re.MULTILINE | re.DOTALL)
match = re.search(final_pattern, text_no_code, re.MULTILINE | re.DOTALL)
if match:
return match.group(1).strip()

Expand Down
41 changes: 41 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,47 @@ def test_final_var_variable_not_found_returns_none(self):
result = find_final_answer("FINAL_VAR(missing)", environment=mock_env)
assert result is None

def test_final_inside_repl_code_block_not_parsed_as_terminal(self):
"""FINAL() written inside a ```repl``` block must NOT be treated as the final answer.

This was the root-cause bug: models often write FINAL(x) or FINAL_VAR(x) inside a
repl block as code. The parser must ignore those and only look at prose text.
"""
text = "I will now finalise.\n```repl\nFINAL(final_answer)\n```"
result = find_final_answer(text)
assert result is None, (
"FINAL() inside a repl block should not trigger termination"
)

def test_final_var_inside_repl_code_block_not_parsed_as_terminal(self):
"""FINAL_VAR() inside a repl block must NOT be treated as the final answer."""
text = "Computing now.\n```repl\nFINAL_VAR(result)\n```"
mock_env = Mock()
mock_env.execute_code.return_value = REPLResult(stdout="55", stderr="", locals={})
result = find_final_answer(text, environment=mock_env)
assert result is None, (
"FINAL_VAR() inside a repl block should not trigger termination"
)
mock_env.execute_code.assert_not_called()

def test_final_in_prose_still_works_alongside_repl_block(self):
"""FINAL() in prose (outside code fences) must still be picked up correctly."""
text = "```repl\nx = 1\n```\nFINAL(55)"
result = find_final_answer(text)
assert result == "55"

def test_final_callable_in_repl_environment(self):
"""FINAL() should be callable inside the REPL and store the answer in _last_final_answer."""
env = LocalREPL()
try:
env.execute_code("answer = 55")
result = env.execute_code("FINAL(answer)")
assert result.final_answer == "55", (
f"Expected final_answer='55', got '{result.final_answer}'"
)
finally:
env.cleanup()


class TestFormatExecutionResult:
"""Tests for format_execution_result function."""
Expand Down
Loading