diff --git a/pycodeloop/core/agent.py b/pycodeloop/core/agent.py index dd860cb..4f533bb 100644 --- a/pycodeloop/core/agent.py +++ b/pycodeloop/core/agent.py @@ -27,6 +27,8 @@ _MAX_RETRIES = 3 _RETRY_BASE_DELAY = 1.0 +_MAX_EMPTY_RESPONSE_RETRIES = 2 + _TOOL_RESULT_SUMMARIZE_THRESHOLD = 8_000 _TOOL_SUMMARY_PROMPT = ( "Summarize this tool output concisely, preserving specific facts, " @@ -415,6 +417,59 @@ def run( ) elapsed = time.perf_counter() - started_at + empty_retries = 0 + while ( + not response.text.strip() + and not response.tool_calls + and empty_retries < _MAX_EMPTY_RESPONSE_RETRIES + ): + self.usage = self.usage + response.usage + if self.on_usage: + self.on_usage(response.usage, self.usage, elapsed) + + empty_retries += 1 + self._trace( + "empty_response_retry", + model=self.provider.model, + attempt=empty_retries, + ) + if self.on_retry: + self.on_retry( + empty_retries, + 0.0, + RuntimeError( + f"{self.provider.model} returned an empty " + "response with no tool calls" + ), + ) + started_at = time.perf_counter() + response = self._complete( + system_prompt=self.system_prompt, + messages=session.history(), + tools=tools, + on_delta=self.on_text_delta, + ) + elapsed = time.perf_counter() - started_at + + if not response.text.strip() and not response.tool_calls: + self.usage = self.usage + response.usage + if self.on_usage: + self.on_usage(response.usage, self.usage, elapsed) + + error_text = ( + f"{self.provider.model} returned an empty response " + "with no tool calls after " + f"{_MAX_EMPTY_RESPONSE_RETRIES} retries. The model may " + "be too weak for agent mode with the current tool/" + "system-prompt size — try a larger model." + ) + session.add_assistant(error_text) + self._notify_message() + if self.on_turn_end: + self.on_turn_end() + self._trace("run_end", reason="empty_response") + return error_text + self.usage = self.usage + response.usage if self.on_usage: self.on_usage(response.usage, self.usage, elapsed) diff --git a/tests/core/test_agent.py b/tests/core/test_agent.py index e916d53..703202a 100644 --- a/tests/core/test_agent.py +++ b/tests/core/test_agent.py @@ -123,6 +123,92 @@ def test_returns_text_when_no_tool_calls(self): self.assertEqual(result, "hello") + def test_retries_and_recovers_from_empty_response_with_no_tool_calls( + self, + ): + """Regression: a weak model can return a fully empty message (no + text, no tool calls) — the agent used to treat that as a normal + `done` turn and silently return "". It should retry instead.""" + provider = FakeProvider( + [ + ProviderResponse(text="", tool_calls=[]), + ProviderResponse(text="", tool_calls=[]), + ProviderResponse(text="finally, here's the answer"), + ] + ) + agent = Agent(provider=provider, tools=[EchoTool()]) + + result = agent.run("hi") + + self.assertEqual(result, "finally, here's the answer") + + def test_gives_up_with_a_clear_error_after_repeated_empty_responses( + self, + ): + provider = FakeProvider( + [ProviderResponse(text="", tool_calls=[]) for _ in range(5)] + ) + agent = Agent(provider=provider, tools=[EchoTool()]) + + result = agent.run("hi") + + self.assertIn("empty response", result) + self.assertIn(provider.model, result) + + def test_accumulates_usage_across_empty_response_retries(self): + provider = FakeProvider( + [ + ProviderResponse( + text="", tool_calls=[], usage=Usage(input_tokens=10) + ), + ProviderResponse( + text="", tool_calls=[], usage=Usage(input_tokens=20) + ), + ProviderResponse(text="done", usage=Usage(input_tokens=30)), + ] + ) + agent = Agent(provider=provider, tools=[EchoTool()]) + + agent.run("hi") + + self.assertEqual(agent.usage.input_tokens, 60) + + def test_accumulates_usage_even_when_giving_up_after_empty_responses( + self, + ): + provider = FakeProvider( + [ + ProviderResponse( + text="", tool_calls=[], usage=Usage(input_tokens=5) + ) + for _ in range(3) + ] + ) + agent = Agent(provider=provider, tools=[EchoTool()]) + + agent.run("hi") + + self.assertEqual(agent.usage.input_tokens, 15) + + def test_records_assistant_turn_in_session_when_giving_up(self): + provider = FakeProvider( + [ProviderResponse(text="", tool_calls=[]) for _ in range(3)] + ) + notified = [] + agent = Agent( + provider=provider, + tools=[EchoTool()], + on_message=lambda: notified.append(True), + ) + session = Session(system_prompt="sys") + + result = agent.run("hi", session=session) + + history = session.history() + self.assertEqual(history[-1].role, "assistant") + self.assertEqual(history[-1].content, result) + self.assertTrue(notified) + def test_prefers_explicit_provider_context_window(self): provider = FakeProvider( [ProviderResponse(text="done", usage=Usage(input_tokens=90))]