Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions pycodeloop/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand Down Expand Up @@ -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()
Comment thread
FernandoCelmer marked this conversation as resolved.
Comment thread
FernandoCelmer marked this conversation as resolved.
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)
Expand Down
86 changes: 86 additions & 0 deletions tests/core/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
Loading