diff --git a/src/minisweagent/agents/default.py b/src/minisweagent/agents/default.py index 9f80e8fda..a2e73e1be 100644 --- a/src/minisweagent/agents/default.py +++ b/src/minisweagent/agents/default.py @@ -71,6 +71,35 @@ def add_messages(self, *messages: dict) -> list[dict]: self.messages.extend(messages) return list(messages) + def _completed_tool_call_ids(self) -> set[str]: + completed_ids = set() + for message in self.messages: + if message.get("role") == "tool" and message.get("tool_call_id"): + completed_ids.add(message["tool_call_id"]) + if message.get("type") == "function_call_output" and message.get("call_id"): + completed_ids.add(message["call_id"]) + return completed_ids + + def _validate_new_tool_call_ids(self, message: dict) -> None: + completed_ids = self._completed_tool_call_ids() + if not completed_ids: + return + repeated_ids = sorted( + { + action["tool_call_id"] + for action in message.get("extra", {}).get("actions", []) + if action.get("tool_call_id") in completed_ids + } + ) + if repeated_ids: + raise FormatError( + self.model.format_message( + role="user", + content=(f"Tool call ID(s) already completed and must not be reused: {', '.join(repeated_ids)}"), + extra={"interrupt_type": "FormatError"}, + ) + ) + def handle_uncaught_exception(self, e: Exception) -> list[dict]: return self.add_messages( self.model.format_message( @@ -146,6 +175,7 @@ def query(self) -> dict: self.n_calls += 1 message = self.model.query(self.messages) self.cost += message.get("extra", {}).get("cost", 0.0) + self._validate_new_tool_call_ids(message) self.add_messages(message) return message diff --git a/tests/agents/test_default.py b/tests/agents/test_default.py index 48aa5b0be..21940e149 100644 --- a/tests/agents/test_default.py +++ b/tests/agents/test_default.py @@ -53,6 +53,21 @@ def is_observation_message(msg: dict) -> bool: return False +class _RecordingEnvironment: + def __init__(self): + self.actions: list[dict] = [] + + def execute(self, action: dict) -> dict: + self.actions.append(action) + return {"output": action["command"], "returncode": 0, "exception_info": ""} + + def get_template_vars(self) -> dict: + return {} + + def serialize(self) -> dict: + return {} + + # --- Fixtures --- @@ -384,6 +399,50 @@ def test_step_adds_messages(model_factory): assert "returncode" in get_observation_text(agent.messages[-1]) +def test_duplicate_completed_tool_call_id_is_not_executed_again(toolcall_config): + """Repeated provider tool-call IDs should not produce duplicate tool results.""" + + first_call = { + "id": "repeat_loop_initial", + "type": "function", + "function": {"name": "bash", "arguments": '{"command": "echo first"}'}, + } + repeated_call = { + "id": "repeat_loop_initial", + "type": "function", + "function": {"name": "bash", "arguments": '{"command": "echo duplicate"}'}, + } + env = _RecordingEnvironment() + agent = DefaultAgent( + model=DeterministicToolcallModel( + outputs=[ + make_toolcall_output( + None, + [first_call], + [{"command": "echo first", "tool_call_id": "repeat_loop_initial"}], + ), + make_toolcall_output( + None, + [repeated_call], + [{"command": "echo duplicate", "tool_call_id": "repeat_loop_initial"}], + ), + ], + ), + env=env, + **toolcall_config, + ) + + agent.add_messages({"role": "system", "content": "system"}, {"role": "user", "content": "task"}) + agent.step() + + with pytest.raises(FormatError) as exc: + agent.step() + + assert "already completed" in exc.value.messages[0]["content"] + assert env.actions == [{"command": "echo first", "tool_call_id": "repeat_loop_initial"}] + assert [msg.get("tool_call_id") for msg in agent.messages if msg.get("role") == "tool"] == ["repeat_loop_initial"] + + def test_observations_captured(model_factory): """Test intermediate outputs are captured correctly.""" factory, config = model_factory @@ -495,10 +554,14 @@ def test_repeated_format_errors_terminate_cleanly(toolcall_config): def test_format_error_counter_resets_on_success(toolcall_config): """A successful tool call between format errors resets the consecutive counter, so isolated errors don't accumulate to the termination threshold.""" - good = make_tc_model([("listing", [{"command": "echo hello"}])]).config.outputs[0] - submit = make_tc_model( - [("done", [{"command": "echo 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT'\necho ok"}])] - ).config.outputs[0] + tool_outputs = make_tc_model( + [ + ("listing", [{"command": "echo hello"}]), + ("done", [{"command": "echo 'COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT'\necho ok"}]), + ] + ).config.outputs + good = tool_outputs[0] + submit = tool_outputs[1] # error, success (reset), error, submit -> never 2 in a row, so it must NOT terminate early. outputs = [{"_format_error": True}, good, {"_format_error": True}, submit] agent = DefaultAgent(