diff --git a/mesa_llm/reasoning/rewoo.py b/mesa_llm/reasoning/rewoo.py index e3abe87..01e3eac 100644 --- a/mesa_llm/reasoning/rewoo.py +++ b/mesa_llm/reasoning/rewoo.py @@ -1,3 +1,4 @@ +import copy from typing import TYPE_CHECKING from mesa_llm.reasoning.reasoning import ( @@ -115,7 +116,7 @@ def plan( ) self.remaining_tool_calls -= 1 tool_call = [self.current_plan.tool_calls[index_of_tool]] - current_plan = self.current_plan + current_plan = copy.copy(self.current_plan) current_plan.tool_calls = tool_call return Plan(llm_plan=current_plan, step=self.current_obs.step, ttl=ttl) @@ -175,7 +176,7 @@ async def aplan( ) self.remaining_tool_calls -= 1 tool_call = [self.current_plan.tool_calls[index_of_tool]] - current_plan = self.current_plan + current_plan = copy.copy(self.current_plan) current_plan.tool_calls = tool_call return Plan(llm_plan=current_plan, step=self.current_obs.step, ttl=ttl) diff --git a/tests/test_reasoning/test_rewoo.py b/tests/test_reasoning/test_rewoo.py index a4d7d76..bec9788 100644 --- a/tests/test_reasoning/test_rewoo.py +++ b/tests/test_reasoning/test_rewoo.py @@ -575,6 +575,72 @@ def test_remaining_tool_calls_decrement(self): assert reasoning.remaining_tool_calls == 0 assert result3.llm_plan.tool_calls == [mock_tool_3] # index 2 (3-1=2) + def test_sequential_replay_dispatches_distinct_tools(self): + """Regression: plan() replay must dispatch A→B→C, not A→A→A. + + Before the fix, `current_plan = self.current_plan` was an alias, so + `current_plan.tool_calls = [tool_a]` mutated self.current_plan.tool_calls + in-place. On step 2, len became 1 and index -1 (Python wrap) returned + tool_a again. This test sets current_plan ONCE and never resets it. + """ + mock_agent = Mock() + mock_agent.step_prompt = None + + tool_a = Mock(name="tool_a") + tool_b = Mock(name="tool_b") + tool_c = Mock(name="tool_c") + + mock_plan = Mock() + mock_plan.tool_calls = [tool_a, tool_b, tool_c] + + reasoning = ReWOOReasoning(mock_agent) + reasoning.current_plan = mock_plan + reasoning.current_obs = Observation(step=1, self_state={}, local_state={}) + reasoning.remaining_tool_calls = 3 + + result1 = reasoning.plan() + result2 = reasoning.plan() + result3 = reasoning.plan() + + assert result1.llm_plan.tool_calls == [tool_a], "Step 1 should dispatch tool A" + assert result2.llm_plan.tool_calls == [tool_b], ( + "Step 2 should dispatch tool B, not A" + ) + assert result3.llm_plan.tool_calls == [tool_c], ( + "Step 3 should dispatch tool C, not A" + ) + assert reasoning.remaining_tool_calls == 0 + + def test_sequential_replay_dispatches_distinct_tools_async(self): + """Async regression: aplan() replay must dispatch A→B→C, not A→A→A.""" + mock_agent = Mock() + mock_agent.step_prompt = None + + tool_a = Mock(name="tool_a") + tool_b = Mock(name="tool_b") + tool_c = Mock(name="tool_c") + + mock_plan = Mock() + mock_plan.tool_calls = [tool_a, tool_b, tool_c] + + reasoning = ReWOOReasoning(mock_agent) + reasoning.current_plan = mock_plan + reasoning.current_obs = Observation(step=1, self_state={}, local_state={}) + reasoning.remaining_tool_calls = 3 + + result1 = asyncio.run(reasoning.aplan()) + result2 = asyncio.run(reasoning.aplan()) + result3 = asyncio.run(reasoning.aplan()) + + assert result1.llm_plan.tool_calls == [tool_a], "Step 1 should dispatch tool A" + assert result2.llm_plan.tool_calls == [tool_b], ( + "Step 2 should dispatch tool B, not A" + ) + assert result3.llm_plan.tool_calls == [tool_c], ( + "Step 3 should dispatch tool C, not A" + ) + assert reasoning.remaining_tool_calls == 0 + class TestReWOOSignatureConsistency: def test_plan_accepts_obs_kwarg(self):