Repository navigation
Expand file tree
/
Copy pathtest_rlm.py
More file actions
1771 lines (1396 loc) · 69.4 KB
/
Copy pathtest_rlm.py
File metadata and controls
1771 lines (1396 loc) · 69.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Tests for the RLM (Recursive Language Model) module.
Test organization:
- Unit tests (no Deno required): MockInterpreter, RLM formatting, signatures
- Integration tests (@pytest.mark.deno): PythonInterpreter with Deno
"""
import base64
from contextlib import contextmanager
from pathlib import Path
import pytest
import dspy
from dspy.adapters.types.tool import Tool
from dspy.predict.rlm import RLM, _strip_code_fences
from dspy.primitives.code_interpreter import CodeExecutionError, CodeInterpreterError, FinalOutput
from dspy.primitives.prediction import Prediction
from dspy.primitives.python_interpreter import PythonInterpreter
from dspy.primitives.repl_types import REPLEntry, REPLHistory, REPLVariable
from dspy.primitives.sandbox_serializable import SandboxSerializable
from tests.mock_interpreter import MockInterpreter, MockInterpreterFactory
# ============================================================================
# Test Helpers and Factories
# ============================================================================
def make_mock_predictor(responses: list[dict], async_mode: bool = False):
"""Factory for mock predictors with scripted responses.
Args:
responses: List of dicts with keys like 'reasoning', 'code'.
async_mode: If True, returns a predictor with acall() instead of __call__().
"""
class MockPredictor:
def __init__(self):
self.idx = 0
def _next_response(self):
result = responses[self.idx % len(responses)]
self.idx += 1
return Prediction(**result)
def __call__(self, **kwargs):
return self._next_response()
async def acall(self, **kwargs):
return self._next_response()
return MockPredictor()
@contextmanager
def dummy_lm_context(responses: list[dict]):
"""Context manager for DummyLM setup."""
import dspy
from dspy.utils.dummies import DummyLM
lm = DummyLM(responses)
with dspy.context(lm=lm):
yield lm
# Common test tools
def echo_tool(text: str = "") -> str:
"""Echo the input text."""
return f"Echo: {text}"
def add_tool(a: int = 0, b: int = 0) -> str:
"""Add two numbers."""
return str(a + b)
def multiply_tool(a: int = 0, b: int = 0) -> str:
"""Multiply two numbers."""
return str(a * b)
# ============================================================================
# Unit Tests: MockInterpreter
# ============================================================================
class TestMockInterpreter:
"""Unit tests for MockInterpreter."""
def test_scripted_responses(self):
"""Test that MockInterpreter returns scripted responses in order."""
mock = MockInterpreter(responses=["first", "second", "third"])
assert mock.execute("code1") == "first"
assert mock.execute("code2") == "second"
assert mock.execute("code3") == "third"
def test_returns_final_output_result(self):
"""Test that MockInterpreter can return FinalOutput."""
mock = MockInterpreter(responses=["exploring", FinalOutput("42")])
assert mock.execute("print(len(data))") == "exploring"
result = mock.execute("SUBMIT('42')")
assert isinstance(result, FinalOutput)
assert result.output == "42"
def test_raises_exception_from_responses(self):
"""Test that MockInterpreter raises exceptions from responses."""
mock = MockInterpreter(responses=["ok", CodeInterpreterError("undefined variable")])
assert mock.execute("code1") == "ok"
with pytest.raises(CodeInterpreterError, match="undefined variable"):
mock.execute("code2")
def test_records_call_history(self):
"""Test that MockInterpreter records call history for test assertions."""
mock = MockInterpreter(responses=["resp"])
mock.execute("print(1)", variables={"x": 10})
assert mock.call_history == [("print(1)", {"x": 10})]
# ============================================================================
# Unit Tests: RLM Module (no interpreter needed)
# ============================================================================
class TestRLMInitialization:
"""Tests for RLM module initialization."""
def test_basic_initialization(self):
"""Test RLM module initializes correctly with signature."""
rlm = RLM("context, query -> answer", max_iters=5)
assert rlm.max_iters == 5
assert rlm.generate_action is not None
assert rlm.extract is not None
assert rlm.tools == {} # No user tools provided
assert "context" in rlm.signature.input_fields
assert "query" in rlm.signature.input_fields
assert "answer" in rlm.signature.output_fields
def test_custom_signature(self):
"""Test RLM with custom signature."""
rlm = RLM("document, question -> summary, key_facts", max_iters=5)
assert "document" in rlm.signature.input_fields
assert "question" in rlm.signature.input_fields
assert "summary" in rlm.signature.output_fields
assert "key_facts" in rlm.signature.output_fields
def test_custom_tools(self):
"""Test RLM with custom tools."""
def custom_tool(x: str = "") -> str:
return x.upper()
rlm = RLM("context -> answer", max_iters=5, tools=[custom_tool])
assert "custom_tool" in rlm.tools
assert len(rlm.tools) == 1 # Only user tools, not internal llm_query/llm_query_batched
@pytest.mark.parametrize("tool_name", ["invalid-name", "123start"])
def test_tool_validation_invalid_identifier(self, tool_name):
"""Test RLM rejects tool names that aren't valid Python identifiers."""
def my_tool() -> str:
return "result"
tool = Tool(my_tool, name=tool_name)
with pytest.raises(ValueError, match="must be a valid Python identifier"):
RLM("context -> answer", tools=[tool])
def test_tool_validation_rejects_python_keyword(self):
def my_tool() -> str:
return "result"
tool = Tool(my_tool, name="for")
with pytest.raises(ValueError, match="not a keyword"):
RLM("context -> answer", tools=[tool])
@pytest.mark.parametrize("tool_name", ["llm_query", "llm_query_batched", "SUBMIT", "print"])
def test_tool_validation_reserved_names(self, tool_name):
"""Test RLM rejects tool names that conflict with built-in functions."""
def my_tool() -> str:
return "result"
tool = Tool(my_tool, name=tool_name)
with pytest.raises(ValueError, match="conflicts with built-in"):
RLM("context -> answer", tools=[tool])
@pytest.mark.parametrize("invalid_value", ["not a function", 123])
def test_tool_validation_not_callable(self, invalid_value):
"""Test RLM rejects tools that aren't callable."""
with pytest.raises(TypeError, match="must be callable"):
RLM("context -> answer", tools=[invalid_value])
def test_tools_dict_rejected(self):
"""Test RLM rejects dict format for tools with helpful error."""
def my_tool() -> str:
return "result"
with pytest.raises(TypeError, match="tools must be a list, not a dict"):
RLM("context -> answer", tools={"my_tool": my_tool})
def test_duplicate_tool_names_rejected(self):
def first() -> str:
return "first"
def second() -> str:
return "second"
with pytest.raises(ValueError, match="Duplicate tool name 'lookup'"):
RLM("context -> answer", tools=[Tool(first, name="lookup"), Tool(second, name="lookup")])
@pytest.mark.parametrize("input_name", ["llm_query", "llm_query_batched", "SUBMIT", "print"])
def test_input_names_cannot_shadow_sandbox_functions(self, input_name):
with pytest.raises(ValueError, match="Input fields conflict with built-in sandbox functions"):
RLM(f"{input_name} -> answer")
def test_input_name_cannot_shadow_user_tool(self):
def lookup() -> str:
return "result"
with pytest.raises(ValueError, match="Input fields conflict with user tools: \\['lookup'\\]"):
RLM("lookup -> answer", tools=[lookup])
@pytest.mark.parametrize("output_name", ["trajectory", "final_reasoning"])
def test_output_names_cannot_shadow_result_metadata(self, output_name):
with pytest.raises(ValueError, match=f"Output fields conflict with RLM result metadata: \\['{output_name}'\\]"):
RLM(f"context -> {output_name}")
def test_optional_parameters(self):
"""Test RLM optional parameters and their defaults."""
import dspy
# Test defaults
rlm = RLM("context -> answer")
assert rlm.max_llm_calls == 50
assert rlm.sub_lm is None
assert rlm._interpreter_factory is PythonInterpreter
# Test custom values
mock_lm = dspy.LM("openai/gpt-4o-mini")
rlm = RLM(
"context -> answer",
max_llm_calls=100,
sub_lm=mock_lm,
interpreter_factory=MockInterpreter,
)
assert rlm.max_llm_calls == 100
assert rlm.sub_lm is mock_lm
assert rlm._interpreter_factory is MockInterpreter
def test_forward_validates_required_inputs(self):
"""Test that forward() raises ValueError for missing required inputs."""
# Single missing input
rlm = RLM("context, query -> answer", max_iters=3)
with pytest.raises(ValueError, match="Missing required input"):
rlm.forward(context="some context") # Missing 'query'
# Multiple missing inputs - all should be reported
rlm = RLM("a, b, c -> answer", max_iters=3)
with pytest.raises(ValueError) as exc_info:
rlm.forward(a="only a") # Missing 'b' and 'c'
assert "b" in str(exc_info.value)
assert "c" in str(exc_info.value)
def test_interpreter_instance_is_rejected_as_factory(self):
with pytest.raises(TypeError, match="first positional argument when calling the module"):
RLM("context -> answer", interpreter_factory=MockInterpreter())
def test_constructor_interpreter_keyword_is_removed(self):
with pytest.raises(TypeError, match="unexpected keyword argument 'interpreter'"):
RLM("context -> answer", interpreter=MockInterpreter())
def test_factory_return_value_is_validated(self):
rlm = RLM("query -> answer", interpreter_factory=lambda: None)
with pytest.raises(TypeError, match="interpreter_factory must return a CodeInterpreter, not NoneType"):
rlm(query="test")
def test_keyword_interpreter_override_has_clear_error(self):
rlm = RLM("query -> answer")
with pytest.raises(TypeError, match="first positional argument"):
rlm(query="test", interpreter=MockInterpreter())
def test_llm_query_returns_legacy_response_text(self):
from dspy.utils.dummies import DummyLM
tools = RLM("context -> answer", sub_lm=DummyLM([{"answer": "legacy answer"}]))._make_llm_tools()
assert tools["llm_query"]("test prompt") == "[[ ## answer ## ]]\nlegacy answer"
def test_llm_query_returns_typed_response_text(self):
import dspy
from dspy.utils.dummies import DummyLM
tools = RLM("context -> answer", sub_lm=DummyLM([{"answer": "typed answer"}]))._make_llm_tools()
with dspy.context(experimental=True):
result = tools["llm_query"]("test prompt")
assert result == "[[ ## answer ## ]]\ntyped answer"
def test_llm_query_rejects_unsupported_response_shape(self):
from unittest.mock import MagicMock
tools = RLM("context -> answer", sub_lm=MagicMock(return_value="untyped response"))._make_llm_tools()
with pytest.raises(TypeError, match="Sub-LM must return dspy.LMResponse or a non-empty list"):
tools["llm_query"]("test prompt")
def test_llm_query_reports_textless_response_type(self):
from unittest.mock import MagicMock
tools = RLM(
"context -> answer",
sub_lm=MagicMock(return_value=[{"tool_calls": []}]),
)._make_llm_tools()
with pytest.raises(TypeError, match="Sub-LM response must contain text, got NoneType"):
tools["llm_query"]("test prompt")
@pytest.mark.parametrize("unexpected_name", ["SUBMIT", "lookup", "tools"])
def test_forward_rejects_undeclared_inputs_before_interpreter_execution(self, unexpected_name):
def lookup() -> str:
return "tool result"
factory = MockInterpreterFactory(responses=[FinalOutput({"answer": "done"})])
rlm = RLM("context -> answer", tools=[lookup], interpreter_factory=factory)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Return answer", "code": 'SUBMIT("done")'},
])
with pytest.raises(ValueError, match=f"Unexpected inputs not declared in the signature: \\['{unexpected_name}'\\]"):
rlm(context="some context", **{unexpected_name: "shadowed value"})
assert factory.instances == []
def test_batched_query_errors_have_clear_markers(self):
"""Test that errors in llm_query_batched are prefixed with [ERROR]."""
from unittest.mock import MagicMock
import dspy
mock_lm = MagicMock()
mock_lm.side_effect = dspy.LMTransportError("LM failed")
rlm = RLM("context -> answer", max_llm_calls=10, sub_lm=mock_lm)
tools = rlm._make_llm_tools()
results = tools["llm_query_batched"](prompts=["test prompt"])
assert len(results) == 1
assert results[0].startswith("[ERROR]")
assert "LM failed" in results[0]
def test_batched_query_marks_missing_lm_configuration(self):
import dspy
with dspy.context(lm=None):
tools = RLM("context -> answer")._make_llm_tools()
results = tools["llm_query_batched"](["test prompt"])
assert results == ["[ERROR] No LM configured. Use dspy.configure(lm=...) or pass sub_lm to RLM."]
def test_batched_query_propagates_programming_errors(self):
from unittest.mock import MagicMock
mock_lm = MagicMock()
mock_lm.side_effect = TypeError("invalid LM implementation")
tools = RLM("context -> answer", max_llm_calls=10, sub_lm=mock_lm)._make_llm_tools()
with pytest.raises(TypeError, match="invalid LM implementation"):
tools["llm_query_batched"](["test prompt"])
def test_batched_query_inherits_request_context(self):
import contextvars
import dspy
request_marker = contextvars.ContextVar("request_marker", default="global")
class TaggedLM:
def __init__(self, tag):
self.tag = tag
def __call__(self, prompt):
return [f"{self.tag}:{request_marker.get()}"]
dspy.configure(lm=TaggedLM("global"))
tools = RLM("context -> answer")._make_llm_tools()
with dspy.context(lm=TaggedLM("request-local")):
request_marker.set("request-local")
assert tools["llm_query"]("one") == "request-local:request-local"
assert tools["llm_query_batched"](["one", "two"]) == [
"request-local:request-local",
"request-local:request-local",
]
def test_tools_call_counter_is_thread_safe(self):
"""Test that the LLM call counter is thread-safe for concurrent llm_query_batched calls.
The call counter must be protected by a lock since llm_query_batched uses
ThreadPoolExecutor for concurrent execution.
"""
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import MagicMock
mock_lm = MagicMock()
mock_lm.return_value = ["response"]
rlm = RLM("context -> answer", max_llm_calls=10, sub_lm=mock_lm)
tools = rlm._make_llm_tools()
call_count = [0]
errors = []
def make_call():
try:
tools["llm_query"](prompt="test")
call_count[0] += 1
except RuntimeError as e:
errors.append(e)
with ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(make_call) for _ in range(10)]
for f in futures:
f.result()
assert call_count[0] == 10, f"Expected 10 successful calls, got {call_count[0]}"
assert len(errors) == 0, f"Unexpected errors: {errors}"
with pytest.raises(RuntimeError, match="LLM call limit exceeded"):
tools["llm_query"](prompt="one more")
class TestRLMInterpreterLifecycle:
def test_execution_instructions_are_part_of_action_prompt(self):
class Factory(MockInterpreterFactory):
execution_instructions = "Use this runtime."
signature = RLM("query -> answer", interpreter_factory=Factory()).generate_action.signature
optimized = signature.with_instructions("Optimized instructions")
assert "Use this runtime." in signature.instructions
assert optimized.instructions == "Optimized instructions"
def test_python_interpreter_lm_request_bytes(self):
class StopLMCall(BaseException):
pass
class CapturingLM(dspy.BaseLM):
forward_contract = "typed_lm"
def __init__(self):
super().__init__("snapshot-model", temperature=0.0, max_tokens=1000, cache=False)
self.request = None
def forward(self, request):
self.request = request
raise StopLMCall
lm = CapturingLM()
rlm = RLM("query -> answer", interpreter_factory=PythonInterpreter)
with pytest.raises(StopLMCall), dspy.context(lm=lm, adapter=dspy.ChatAdapter()):
rlm.generate_action(
variables_info=["query: str"],
repl_history=REPLHistory(),
iteration="1/20",
)
request = lm.request.model_dump_json(indent=2).encode()
snapshot = Path(__file__).with_name("snapshots") / "rlm_python_interpreter_lm_request.json"
assert request == snapshot.read_bytes().removesuffix(b"\n")
def test_interpreter_remains_available_as_signature_input(self):
factory = MockInterpreterFactory(responses=[FinalOutput({"answer": "CPython"})])
rlm = RLM("interpreter -> answer", max_iters=1, interpreter_factory=factory)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Return input", "code": "SUBMIT(interpreter)"},
])
result = rlm(interpreter="CPython")
assert result.answer == "CPython"
assert factory.instances[0].call_history[0][1] == {"interpreter": "CPython"}
@pytest.mark.asyncio
async def test_factory_creates_and_shuts_down_one_interpreter_per_call(self):
factory = MockInterpreterFactory(responses=[FinalOutput({"answer": "42"})])
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Return answer", "code": 'SUBMIT("42")'},
])
sync_result = rlm(query="sync")
async_result = await rlm.acall(query="async")
assert sync_result.answer == "42"
assert async_result.answer == "42"
assert len(factory.instances) == 2
assert factory.instances[0] is not factory.instances[1]
for interpreter in factory.instances:
with pytest.raises(CodeInterpreterError, match="shutdown"):
interpreter.execute("print('closed')")
@pytest.mark.asyncio
async def test_caller_owned_interpreter_can_be_reused_across_sequential_calls(self):
factory = MockInterpreterFactory()
def backend_tool():
return "backend"
interpreter = MockInterpreter(
responses=[
FinalOutput({"answer": "first"}),
FinalOutput({"answer": "second"}),
],
tools={"backend_tool": backend_tool},
)
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Return answer", "code": "SUBMIT(answer)"},
])
try:
sync_result = rlm(interpreter, query="sync")
async_result = await rlm.acall(interpreter, query="async")
assert sync_result.answer == "first"
assert async_result.answer == "second"
assert factory.instances == []
assert interpreter.tools == {"backend_tool": backend_tool}
assert interpreter.execute("print('still open')") == ""
finally:
interpreter.shutdown()
def test_factory_interpreter_is_shutdown_when_prediction_raises(self):
class RaisingPredictor:
def __call__(self, **kwargs):
raise ValueError("unexpected predictor failure")
factory = MockInterpreterFactory()
rlm = RLM("query -> answer", max_iters=1, interpreter_factory=factory)
rlm.generate_action = RaisingPredictor()
with pytest.raises(ValueError, match="unexpected predictor failure"):
rlm(query="test")
assert len(factory.instances) == 1
with pytest.raises(CodeInterpreterError, match="shutdown"):
factory.instances[0].execute("print('closed')")
class TestRLMCodeFenceParsing:
"""Tests for robust fenced-code extraction."""
@pytest.mark.parametrize(
("raw", "expected"),
[
# Standard python fence
("```python\nprint(1)\n```", "print(1)"),
("```py\nx = 1\nprint(x)\n```", "x = 1\nprint(x)"),
# Bare fence (no language tag)
("```\nprint('no lang')\n```", "print('no lang')"),
# No fences at all
("not fenced code", "not fenced code"),
# Text before fence (preamble is skipped)
("I'll inspect first.\n```python\nprint('hello')\n```\nThen I will submit.", "print('hello')"),
# Text after closing fence (ignored)
("```python\nprint(1)\n```\nsome trailing text", "print(1)"),
# Unclosed fence (just return the body)
("```python\nprint('oops')", "print('oops')"),
# Double fences (outer decorative ```)
("```\n```python\nprint(1)\n```\n```", "print(1)"),
("```\n```\nprint(2)\n```\n```", "print(2)"),
],
)
def test_strip_code_fences(self, raw, expected):
assert _strip_code_fences(raw) == expected
def test_strip_code_fences_rejects_non_python_lang(self):
with pytest.raises(SyntaxError, match="json"):
_strip_code_fences('```json\n{"a": 1}\n```')
class TestRLMFormatting:
"""Tests for RLM formatting helpers."""
def test_format_history(self):
"""Test history formatting using REPLHistory."""
history = REPLHistory()
history = history.append(reasoning="Need to check the data", code="print(1)", output="1")
history = history.append(reasoning="Now calculate", code="x = 2", output="")
formatted = history.format()
assert "Step 1" in formatted
assert "Step 2" in formatted
assert "print(1)" in formatted
assert "Need to check" in formatted
def test_format_history_empty(self):
"""Test history formatting with empty history."""
history = REPLHistory()
formatted = history.format()
assert "have not interacted with the REPL" in formatted
def test_action_signature_has_iteration_field(self):
"""Test action signature includes iteration input field."""
rlm = RLM("context -> answer")
action_sig = rlm.generate_action.signature
assert "iteration" in action_sig.input_fields
def test_format_output(self):
"""Test output formatting."""
rlm = RLM("context -> answer")
formatted = rlm._format_output("output text")
assert "output text" in formatted
def test_format_output_empty(self):
"""Test output formatting with empty output."""
rlm = RLM("context -> answer")
formatted = rlm._format_output("")
assert "no output" in formatted.lower()
def test_format_output_passthrough(self):
"""Test that _format_output passes through non-empty output without truncation."""
rlm = RLM("context -> answer", max_output_chars=100)
long_output = "a" * 200
formatted = rlm._format_output(long_output)
assert formatted == long_output
def test_format_variable_info_string(self):
"""Test variable info formatting for string value using REPLVariable."""
var = REPLVariable.from_value("context", "Hello world", preview_chars=5)
formatted = var.format()
assert "Variable: `context`" in formatted
assert "Type: str" in formatted
assert "11" in formatted # length
assert "He" in formatted # head
assert "ld" in formatted # tail
assert "..." in formatted # truncation indicator
def test_format_variable_info_dict(self):
"""Test variable info formatting for dict value using REPLVariable."""
var = REPLVariable.from_value("data", {"key": "value"})
formatted = var.format()
assert "Variable: `data`" in formatted
assert "Type: dict" in formatted
assert "key" in formatted
def test_build_variables_multiple(self):
"""Test building multiple variables."""
rlm = RLM("context, query -> answer")
variables = rlm._build_variables(
context="Hello world",
query="What is this?"
)
assert len(variables) == 2
formatted = "\n\n".join(v.format() for v in variables)
assert "Variable: `context`" in formatted
assert "Variable: `query`" in formatted
assert "Hello world" in formatted
assert "What is this?" in formatted
class TestREPLTypes:
"""Tests for the REPL type classes."""
def test_repl_history_immutability(self):
"""Test that REPLHistory.append() returns new instance."""
h1 = REPLHistory()
h2 = h1.append(code="print(1)", output="1")
assert len(h1) == 0 # Original unchanged
assert len(h2) == 1 # New has entry
def test_repl_history_len_iter_bool(self):
"""Test REPLHistory list-like interface."""
h = REPLHistory()
assert len(h) == 0
assert not bool(h)
h = h.append(code="x = 1", output="")
h = h.append(code="x = 2", output="")
assert len(h) == 2
assert bool(h)
codes = [e.code for e in h]
assert codes == ["x = 1", "x = 2"]
def test_repl_entry_format(self):
"""Test REPLEntry formatting."""
entry = REPLEntry(reasoning="test reason", code="print(1)", output="1")
formatted = entry.format(index=0)
assert "Step 1" in formatted
assert "test reason" in formatted
assert "print(1)" in formatted
assert "1" in formatted
def test_repl_entry_format_truncation(self):
"""Test REPLEntry.format() truncates with head+tail and shows true length."""
output = "a" * 100 + "b" * 100
entry = REPLEntry(code="print('a' + 'b')", output=output)
formatted = entry.format(index=0, max_output_chars=100)
# Head and tail preserved
assert "a" * 50 in formatted
assert "b" * 50 in formatted
assert "100 characters omitted" in formatted
# True original length shown in header
assert "200 chars" in formatted
def test_repl_entry_format_no_truncation(self):
"""Test REPLEntry.format() passes short output through without truncation."""
output = "a" * 50
entry = REPLEntry(code="print('a')", output=output)
formatted = entry.format(index=0, max_output_chars=100)
assert output in formatted
assert "omitted" not in formatted
def test_repl_history_threads_max_output_chars(self):
"""Test REPLHistory carries max_output_chars through append()."""
h = REPLHistory(max_output_chars=50)
h2 = h.append(code="print(1)", output="a" * 100)
assert h2.max_output_chars == 50
# Formatting should truncate at 50 chars
formatted = h2.format()
assert "50 characters omitted" in formatted
def test_repl_variable_from_value(self):
"""Test REPLVariable.from_value() factory."""
var = REPLVariable.from_value("test", "hello world")
assert var.name == "test"
assert var.type_name == "str"
assert var.total_length == 11
assert "hello world" in var.preview
def test_repl_variable_truncation(self):
"""Test REPLVariable preview shows head and tail."""
var = REPLVariable.from_value("big", "a" * 500 + "b" * 500, preview_chars=50)
assert var.preview.startswith("a" * 25)
assert var.preview.endswith("b" * 25)
assert "..." in var.preview
def test_repl_variable_with_field_info(self):
"""Test REPLVariable includes desc and constraints from field_info."""
import dspy
# Create a field with description and constraints
field = dspy.InputField(desc="The user's question", ge=0, le=100)
var = REPLVariable.from_value("query", "What is 2+2?", field_info=field)
assert var.desc == "The user's question"
assert "greater than or equal to" in var.constraints
# Verify format includes the metadata
formatted = var.format()
assert "Description: The user's question" in formatted
assert "Constraints:" in formatted
def test_repl_variable_without_field_info(self):
"""Test REPLVariable works without field_info."""
var = REPLVariable.from_value("data", [1, 2, 3])
assert var.desc == ""
assert var.constraints == ""
# Format should not include empty desc/constraints lines
formatted = var.format()
assert "Description:" not in formatted
assert "Constraints:" not in formatted
def test_build_variables_includes_field_metadata(self):
"""Test _build_variables passes field_info to REPLVariable."""
import dspy
class QASig(dspy.Signature):
"""Answer questions."""
context: str = dspy.InputField(desc="Background information")
question: str = dspy.InputField(desc="The question to answer")
answer: str = dspy.OutputField()
rlm = RLM(QASig, max_iters=3)
variables = rlm._build_variables(context="Some text", question="What?")
# Find the context variable
context_var = next(v for v in variables if v.name == "context")
assert context_var.desc == "Background information"
question_var = next(v for v in variables if v.name == "question")
assert question_var.desc == "The question to answer"
class TestRLMCallMethod:
"""Tests for RLM __call__ method."""
def test_call_is_alias_for_forward(self):
"""Test that __call__ is an alias for forward()."""
mock = MockInterpreter(responses=[FinalOutput({"answer": "42"})])
rlm = RLM("query -> answer", max_iters=3)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Return answer", "code": 'SUBMIT("42")'},
])
result = rlm(mock, query="What is the answer?")
assert result.answer == "42"
class TestRLMMaxIterationsFallback:
"""Tests for max_iters reached and extract fallback."""
def test_max_iters_triggers_extract(self):
"""Test that reaching max_iters uses extract fallback."""
mock = MockInterpreter(responses=[
"exploring...",
"still exploring...",
"more exploring...",
])
rlm = RLM("query -> answer", max_iters=3)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Explore 1", "code": "print('exploring')"},
{"reasoning": "Explore 2", "code": "print('exploring')"},
{"reasoning": "Explore 3", "code": "print('exploring')"},
])
# Mock the extract predictor to return a value
rlm.extract = make_mock_predictor([
{"answer": "extracted_answer"},
])
result = rlm.forward(mock, query="test")
assert result.answer == "extracted_answer"
assert result.final_reasoning == "Extract forced final output"
class TestRLMToolExceptions:
"""Tests for tool exception handling."""
def test_tool_exception_returns_error_in_output(self):
"""Test that tool exceptions are caught and returned as errors."""
def failing_tool() -> str:
raise RuntimeError("Tool failed!")
mock = MockInterpreter(responses=[
CodeExecutionError("RuntimeError: Tool failed!"),
FinalOutput({"answer": "recovered"}),
])
rlm = RLM("query -> answer", max_iters=5, tools=[failing_tool])
rlm.generate_action = make_mock_predictor([
{"reasoning": "Call tool", "code": "failing_tool()"},
{"reasoning": "Recover", "code": 'SUBMIT("recovered")'},
])
result = rlm.forward(mock, query="test")
assert result.answer == "recovered"
def test_runtime_error_history_uses_stripped_code(self):
"""Runtime execution failures should preserve stripped code in history."""
mock = MockInterpreter(responses=[
CodeExecutionError("NameError: name 'x' is not defined"),
FinalOutput({"answer": "recovered"}),
])
rlm = RLM("query -> answer", max_iters=5)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Will fail", "code": "```python\nprint(x)\n```"},
{"reasoning": "Recover", "code": 'SUBMIT("recovered")'},
])
result = rlm.forward(mock, query="test")
assert result.answer == "recovered"
first_step = result.trajectory[0]
assert first_step["code"] == "print(x)"
def test_syntax_error_from_execute_is_recoverable(self):
"""SyntaxError from interpreter.execute should be surfaced as an iteration error."""
mock = MockInterpreter(responses=[
SyntaxError("invalid syntax"),
FinalOutput({"answer": "recovered"}),
])
rlm = RLM("query -> answer", max_iters=5)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Bad code", "code": "```python\ndef incomplete(\n```"},
{"reasoning": "Recover", "code": 'SUBMIT("recovered")'},
])
result = rlm.forward(mock, query="test")
assert result.answer == "recovered"
assert result.trajectory[0]["output"].startswith("[Error] invalid syntax")
def test_syntax_error_from_strip_code_fences_is_recoverable(self):
"""SyntaxError raised by _strip_code_fences (e.g. non-Python fence tag) should be recoverable."""
mock = MockInterpreter(responses=[
FinalOutput({"answer": "recovered"}),
])
rlm = RLM("query -> answer", max_iters=5)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Wrong language", "code": "```bash\nls -la\n```"},
{"reasoning": "Recover", "code": 'SUBMIT("recovered")'},
])
result = rlm.forward(mock, query="test")
assert result.answer == "recovered"
assert result.trajectory[0]["output"].startswith("[Error]")
def test_interpreter_failure_propagates(self):
"""Process and protocol failures must not fall through to LM extraction."""
def fail_generated_code(code, variables):
if code == "pass":
return ""
raise CodeInterpreterError("protocol corrupt")
mock = MockInterpreter(execute_fn=fail_generated_code)
rlm = RLM("query -> answer", max_iters=1)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Try code", "code": "print('test')"},
])
rlm.extract = make_mock_predictor([{"answer": "hallucinated"}])
with pytest.raises(CodeInterpreterError, match="protocol corrupt"):
rlm.forward(mock, query="test")
@pytest.mark.asyncio
async def test_interpreter_failure_propagates_async(self):
"""Async process and protocol failures must not fall through to LM extraction."""
def fail_generated_code(code, variables):
if code == "pass":
return ""
raise CodeInterpreterError("protocol corrupt")
mock = MockInterpreter(execute_fn=fail_generated_code)
rlm = RLM("query -> answer", max_iters=1)
rlm.generate_action = make_mock_predictor([
{"reasoning": "Try code", "code": "print('test')"},
])
rlm.extract = make_mock_predictor([{"answer": "hallucinated"}])
with pytest.raises(CodeInterpreterError, match="protocol corrupt"):
await rlm.aforward(mock, query="test")
class TestRLMDynamicSignature:
"""Tests for the dynamically built RLM signatures."""
def test_action_signature_structure(self):
"""Test action signature has required fields and instructions."""
rlm = RLM("document, question -> summary, answer")
action_sig = rlm.generate_action.signature
# Required input/output fields
assert "variables_info" in action_sig.input_fields
assert "repl_history" in action_sig.input_fields
assert "reasoning" in action_sig.output_fields
assert "code" in action_sig.output_fields
# Instructions mention key tools and variables
instructions = action_sig.instructions
assert "llm_query" in instructions
assert "llm_query_batched" in instructions
assert "SUBMIT" in instructions
assert "`document`" in instructions
assert "`question`" in instructions
assert "`summary`" in instructions
assert "`answer`" in instructions
def test_extract_signature_structure(self):
"""Test extract signature has required fields for all outputs."""
rlm = RLM("document, question -> summary, key_facts, confidence")
extract_sig = rlm.extract.signature
assert "variables_info" in extract_sig.input_fields
assert "repl_history" in extract_sig.input_fields
assert "summary" in extract_sig.output_fields
assert "key_facts" in extract_sig.output_fields
assert "confidence" in extract_sig.output_fields
# ============================================================================
# Integration Tests: PythonInterpreter (require Deno)
# ============================================================================
@pytest.mark.deno
class TestPythonInterpreter:
"""Integration tests for the secure sandbox with tool support."""
def test_start_prewarms_sandbox(self):
"""Test that start() pre-warms the sandbox."""
interp = PythonInterpreter()
try:
# Before start, deno_process should be None
assert interp.deno_process is None
# After start, it should be running
interp.start()
assert interp.deno_process is not None
assert interp.deno_process.poll() is None # Still running
# Execute should work
result = interp.execute("print(42)")
assert "42" in result
finally:
interp.shutdown()
def test_start_is_idempotent(self):
"""Test that start() can be called multiple times safely."""
interp = PythonInterpreter()
try:
interp.start()
first_process = interp.deno_process
interp.start() # Second call - should be idempotent
assert interp.deno_process is first_process # Same process
finally: