Skip to content

Commit db41d15

Browse files
fix: resolve FINAL_VAR answers + improve Python 3.11 compatibility for structured prompts (#8)
* fix: final var * fix: types parsing * refactored final var logic, and added unit test --------- Co-authored-by: Alex Zhang <alex.lx.zhang@gmail.com>
1 parent 231779e commit db41d15

4 files changed

Lines changed: 189 additions & 49 deletions

File tree

‎rlm/core/rlm.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ def completion(
186186
)
187187

188188
# Check if RLM is done and has a final answer.
189-
final_answer = find_final_answer(iteration.response)
189+
final_answer = find_final_answer(iteration.response, environment=environment)
190190
iteration.final_answer = final_answer
191191

192192
# If logger is used, log the iteration.
@@ -196,7 +196,7 @@ def completion(
196196
# Verbose output for this iteration
197197
self.verbose.print_iteration(iteration, i + 1)
198198

199-
if final_answer:
199+
if final_answer is not None:
200200
time_end = time.perf_counter()
201201
usage = lm_handler.get_usage_summary()
202202
self.verbose.print_final_answer(final_answer)

‎rlm/core/types.py‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@
1010

1111
def _serialize_value(value: Any) -> Any:
1212
"""Convert a value to a JSON-serializable representation."""
13-
if value is None or isinstance(value, bool | int | float | str):
13+
if value is None or isinstance(value, (bool, int, float, str)):
1414
return value
1515
if isinstance(value, ModuleType):
1616
return f"<module '{value.__name__}'>"
17-
if isinstance(value, list | tuple):
17+
if isinstance(value, (list, tuple)):
1818
return [_serialize_value(v) for v in value]
1919
if isinstance(value, dict):
2020
return {str(k): _serialize_value(v) for k, v in value.items()}
@@ -215,20 +215,40 @@ class QueryMetadata:
215215
context_total_length: int
216216
context_type: str
217217

218-
def __init__(self, prompt: str | list[str] | dict[Any, str] | list[dict[Any, str]]):
218+
def __init__(self, prompt: str | list[str] | dict[Any, Any] | list[dict[Any, Any]]):
219219
if isinstance(prompt, str):
220220
self.context_lengths = [len(prompt)]
221221
self.context_type = "str"
222-
elif isinstance(prompt, dict[Any, str]):
223-
self.context_lengths = [len(chunk) for chunk in prompt.values()]
222+
elif isinstance(prompt, dict):
223+
self.context_type = "dict"
224+
self.context_lengths = []
225+
for chunk in prompt.values():
226+
if isinstance(chunk, str):
227+
self.context_lengths.append(len(chunk))
228+
continue
229+
try:
230+
import json
231+
232+
self.context_lengths.append(len(json.dumps(chunk, default=str)))
233+
except Exception:
234+
self.context_lengths.append(len(repr(chunk)))
224235
self.context_type = "dict"
225236
elif isinstance(prompt, list):
226237
self.context_type = "list"
227-
if isinstance(prompt[0], dict):
238+
if len(prompt) == 0:
239+
self.context_lengths = [0]
240+
elif isinstance(prompt[0], dict):
228241
if "content" in prompt[0]:
229-
self.context_lengths = [len(chunk["content"]) for chunk in prompt]
242+
self.context_lengths = [len(str(chunk.get("content", ""))) for chunk in prompt]
230243
else:
231-
self.context_lengths = [len(chunk) for chunk in prompt]
244+
self.context_lengths = []
245+
for chunk in prompt:
246+
try:
247+
import json
248+
249+
self.context_lengths.append(len(json.dumps(chunk, default=str)))
250+
except Exception:
251+
self.context_lengths.append(len(repr(chunk)))
232252
else:
233253
self.context_lengths = [len(chunk) for chunk in prompt]
234254
else:

‎rlm/utils/parsing.py‎

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@
33
"""
44

55
import re
6+
from typing import TYPE_CHECKING
67

78
from rlm.core.types import REPLResult, RLMIteration
89

10+
if TYPE_CHECKING:
11+
from rlm.environments.base_env import BaseEnv
12+
913

1014
def find_code_blocks(text: str) -> list[str]:
1115
"""
@@ -22,22 +26,38 @@ def find_code_blocks(text: str) -> list[str]:
2226
return results
2327

2428

25-
def find_final_answer(text: str) -> tuple[str, str] | None:
29+
def find_final_answer(text: str, environment: "BaseEnv | None" = None) -> str | None:
2630
"""
27-
Find FINAL(...) or FINAL_VAR(...) statement in response and return (type, content).
31+
Find FINAL(...) or FINAL_VAR(...) statement in response and return the final answer string.
32+
33+
If FINAL_VAR is found and an environment is provided, executes code to retrieve the variable value.
2834
Returns None if neither pattern is found.
35+
36+
Args:
37+
text: The response text to parse
38+
environment: Optional environment to execute code for FINAL_VAR retrieval
39+
40+
Returns:
41+
The final answer string, or None if no final answer pattern is found
2942
"""
3043
# Check for FINAL_VAR pattern first - must be at start of line
3144
final_var_pattern = r"^\s*FINAL_VAR\((.*?)\)"
3245
match = re.search(final_var_pattern, text, re.MULTILINE | re.DOTALL)
3346
if match:
34-
return ("FINAL_VAR", match.group(1).strip())
47+
variable_name = match.group(1).strip().strip('"').strip("'")
48+
if environment is not None:
49+
result = environment.execute_code(f"print(FINAL_VAR({variable_name!r}))")
50+
final_answer = result.stdout.strip()
51+
if final_answer == "":
52+
final_answer = result.stderr.strip() or ""
53+
return final_answer
54+
return None
3555

3656
# Check for FINAL pattern - must be at start of line
3757
final_pattern = r"^\s*FINAL\((.*?)\)"
3858
match = re.search(final_pattern, text, re.MULTILINE | re.DOTALL)
3959
if match:
40-
return ("FINAL", match.group(1).strip())
60+
return match.group(1).strip()
4161

4262
return None
4363

@@ -106,7 +126,7 @@ def format_execution_result(result: REPLResult) -> str:
106126
"__doc__",
107127
]:
108128
# Only show simple types or short representations
109-
if isinstance(value, str | int | float | bool | list | dict | tuple):
129+
if isinstance(value, (str, int, float, bool, list, dict, tuple)):
110130
important_vars[key] = ""
111131

112132
if important_vars:
@@ -117,35 +137,8 @@ def format_execution_result(result: REPLResult) -> str:
117137

118138
def check_for_final_answer(response: str, repl_env, logger) -> str | None:
119139
"""Check if response contains a final answer."""
120-
result = find_final_answer(response)
121-
if result is None:
122-
return None
123-
124-
answer_type, content = result
125-
126-
if answer_type == "FINAL":
127-
return content
128-
elif answer_type == "FINAL_VAR":
129-
# Get the variable directly from the REPL environment
130-
try:
131-
# Strip spaces, quotes, and newlines from variable name
132-
variable_name = content.strip().strip('"').strip("'").strip("\n").strip("\r")
133-
134-
# Check if variable exists in the REPL environment's locals
135-
if variable_name in repl_env.locals:
136-
variable_value = repl_env.locals[variable_name]
137-
return str(variable_value)
138-
else:
139-
error_msg = f"Variable '{variable_name}' not found in REPL environment"
140-
logger.log_tool_execution("FINAL_VAR", error_msg)
141-
return None
142-
except Exception as e:
143-
error_msg = f"Error retrieving variable '{variable_name}': {str(e)}"
144-
print("ERROR MESSAGE", error_msg)
145-
logger.log_tool_execution("FINAL_VAR", error_msg)
146-
return None
147-
148-
return None
140+
# Use the new find_final_answer function which handles both FINAL and FINAL_VAR
141+
return find_final_answer(response, environment=repl_env)
149142

150143

151144
def convert_context_for_repl(context):

‎tests/test_parsing.py‎

Lines changed: 132 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Tests for parsing utilities."""
22

3+
from unittest.mock import Mock
4+
35
from rlm.core.types import CodeBlock, REPLResult, RLMIteration
6+
from rlm.environments.local_repl import LocalREPL
47
from rlm.utils.parsing import (
58
convert_context_for_repl,
69
find_code_blocks,
@@ -81,12 +84,25 @@ class TestFindFinalAnswer:
8184
def test_final_answer(self):
8285
text = "The answer is:\nFINAL(42)"
8386
result = find_final_answer(text)
84-
assert result == ("FINAL", "42")
87+
assert result == "42"
8588

8689
def test_final_var_answer(self):
8790
text = "Check the variable:\nFINAL_VAR(result)"
91+
# Create a mock environment that returns the variable value
92+
mock_env = Mock()
93+
mock_env.execute_code.return_value = REPLResult(stdout="42", stderr="", locals={})
94+
result = find_final_answer(text, environment=mock_env)
95+
assert result == "42"
96+
# Verify execute_code was called with the correct code
97+
mock_env.execute_code.assert_called_once()
98+
call_args = mock_env.execute_code.call_args[0][0]
99+
assert "FINAL_VAR('result')" in call_args or 'FINAL_VAR("result")' in call_args
100+
101+
def test_final_var_without_environment(self):
102+
text = "Check the variable:\nFINAL_VAR(result)"
103+
# Without environment, FINAL_VAR should return None
88104
result = find_final_answer(text)
89-
assert result == ("FINAL_VAR", "result")
105+
assert result is None
90106

91107
def test_no_final_answer(self):
92108
text = "Still working on the problem..."
@@ -98,8 +114,8 @@ def test_final_with_multiline_content(self):
98114
multiline answer)"""
99115
result = find_final_answer(text)
100116
assert result is not None
101-
assert result[0] == "FINAL"
102-
assert "multiline" in result[1]
117+
assert "multiline" in result
118+
assert "This is a" in result
103119

104120
def test_final_must_be_at_start_of_line(self):
105121
# FINAL not at start of line should not match
@@ -110,7 +126,118 @@ def test_final_must_be_at_start_of_line(self):
110126
def test_final_with_whitespace(self):
111127
text = " FINAL(answer with spaces)"
112128
result = find_final_answer(text)
113-
assert result == ("FINAL", "answer with spaces")
129+
assert result == "answer with spaces"
130+
131+
def test_final_and_final_var_parsing(self):
132+
"""Test that both FINAL and FINAL_VAR patterns are parsed correctly."""
133+
# Test FINAL with various content types
134+
test_cases_final = [
135+
("FINAL(42)", "42"),
136+
("FINAL('hello world')", "'hello world'"),
137+
('FINAL("test")', '"test"'),
138+
("FINAL(123.45)", "123.45"),
139+
("FINAL([1, 2, 3])", "[1, 2, 3]"),
140+
]
141+
142+
for text, expected in test_cases_final:
143+
result = find_final_answer(text)
144+
assert result == expected, f"Failed for text: {text}"
145+
146+
# Test FINAL_VAR with environment
147+
mock_env = Mock()
148+
mock_env.execute_code.return_value = REPLResult(
149+
stdout="computed_result", stderr="", locals={}
150+
)
151+
152+
test_cases_final_var = [
153+
("FINAL_VAR(result)", "result"),
154+
("FINAL_VAR('my_var')", "my_var"),
155+
('FINAL_VAR("another_var")', "another_var"),
156+
("FINAL_VAR(answer)", "answer"),
157+
]
158+
159+
for text, var_name in test_cases_final_var:
160+
result = find_final_answer(text, environment=mock_env)
161+
assert result == "computed_result", f"Failed for text: {text}"
162+
# Verify the variable name was extracted correctly
163+
call_args = mock_env.execute_code.call_args[0][0]
164+
assert (
165+
var_name in call_args
166+
or f"'{var_name}'" in call_args
167+
or f'"{var_name}"' in call_args
168+
)
169+
170+
def test_final_var_takes_precedence_over_final(self):
171+
"""Test that FINAL_VAR is checked first and takes precedence over FINAL."""
172+
mock_env = Mock()
173+
mock_env.execute_code.return_value = REPLResult(stdout="var_value", stderr="", locals={})
174+
175+
# If both appear, FINAL_VAR should be found first (checked first in the function)
176+
text = "FINAL_VAR(result)\nFINAL(direct_answer)"
177+
result = find_final_answer(text, environment=mock_env)
178+
assert result == "var_value" # FINAL_VAR should be returned
179+
180+
# Without environment, FINAL_VAR pattern is found but returns None (no fallback to FINAL)
181+
# This is expected behavior - FINAL_VAR takes precedence when found
182+
result = find_final_answer(text)
183+
assert result is None # FINAL_VAR found but no environment, so returns None
184+
185+
# Test that FINAL alone works when FINAL_VAR is not present
186+
text_final_only = "FINAL(direct_answer)"
187+
result = find_final_answer(text_final_only)
188+
assert result == "direct_answer"
189+
190+
def test_final_var_retrieves_actual_variables_from_environment(self):
191+
"""Test that FINAL_VAR actually retrieves variables from a real code environment."""
192+
# Create a real LocalREPL environment
193+
env = LocalREPL()
194+
195+
try:
196+
# Execute code to create variables with different types
197+
env.execute_code("x = 42")
198+
env.execute_code("result = 'hello world'")
199+
env.execute_code("answer = [1, 2, 3, 4, 5]")
200+
env.execute_code("computed = 10 * 5 + 2")
201+
env.execute_code("nested = {'key': 'value', 'num': 123}")
202+
203+
# Test retrieving integer variable
204+
text = "FINAL_VAR(x)"
205+
result = find_final_answer(text, environment=env)
206+
assert result == "42", f"Expected '42', got '{result}'"
207+
208+
# Test retrieving string variable
209+
text = "FINAL_VAR(result)"
210+
result = find_final_answer(text, environment=env)
211+
assert result == "hello world", f"Expected 'hello world', got '{result}'"
212+
213+
# Test retrieving list variable
214+
text = "FINAL_VAR(answer)"
215+
result = find_final_answer(text, environment=env)
216+
assert result == "[1, 2, 3, 4, 5]", f"Expected '[1, 2, 3, 4, 5]', got '{result}'"
217+
218+
# Test retrieving computed variable
219+
text = "FINAL_VAR(computed)"
220+
result = find_final_answer(text, environment=env)
221+
assert result == "52", f"Expected '52', got '{result}'"
222+
223+
# Test retrieving dictionary variable
224+
text = "FINAL_VAR(nested)"
225+
result = find_final_answer(text, environment=env)
226+
assert "'key': 'value'" in result or '"key": "value"' in result
227+
assert "'num': 123" in result or '"num": 123' in result
228+
229+
# Test that variable updates are reflected
230+
env.execute_code("x = 100")
231+
text = "FINAL_VAR(x)"
232+
result = find_final_answer(text, environment=env)
233+
assert result == "100", f"Expected '100', got '{result}'"
234+
235+
# Test that non-existent variable returns error message
236+
text = "FINAL_VAR(nonexistent)"
237+
result = find_final_answer(text, environment=env)
238+
assert "Error" in result or "not found" in result.lower()
239+
finally:
240+
env.cleanup()
114241

115242

116243
class TestFormatExecutionResult:

0 commit comments

Comments
 (0)