Count empty responses toward unusable-response limit
Track consecutive empty model responses in the empty-response handler so the agent stops once the configured unusable-response limit is reached, matching repeat and misformat recovery. Log the empty-response warning for under-limit iterations and switch to the framework limit warning when the loop is forced to stop.
linkliti committed
Aug 25, 2026 at 02:54 UTC
ade14897904686a8af20720a01794097b1410dcf
5 files changed
+78
-16
extensions/python/message_loop_result/AGENTS.md
+3
-2
@@ -7,13 +7,14 @@
7
## Ownership
8
9
- Extensions receive mutable `result_data` with `llm_result` and may set `skip_default_processing` after fully handling the turn.
10
-- `_20_empty_response.py` retries turns with neither response nor reasoning, using `fw.msg_empty_response.md` for agent-prefixed UI warning text only.
11
-- `_30_repeat_response.py` retries nonempty response content that exactly matches `loop_data.last_response`, regardless of reasoning, using `fw.msg_repeat.md` for history and `fw.msg_repeat_response.md` for the agent-prefixed UI warning text.
10
+- `_20_empty_response.py` retries turns with neither response nor reasoning, counts them toward the unusable-response limit without adding a warning to model history, and uses `fw.msg_empty_response.md` for agent-prefixed UI warning text only.
11
+- `_30_repeat_response.py` retries response content that exactly matches `loop_data.last_response`, regardless of reasoning, using `fw.msg_repeat.md` for history and `fw.msg_repeat_response.md` for the agent-prefixed UI warning text.
12
13
## Local Contracts
14
15
- Files run in deterministic filename order.
16
- A handler that sets `skip_default_processing` owns needed history and UI side effects for that turn.
17
+- Handlers that should not add side effects after an earlier extension has handled the result must return when `skip_default_processing` is set.
18
- Do not use this point to mutate streamed partial content.
19
20
## Work Guidance
extensions/python/message_loop_result/_20_empty_response.py
+23
-7
@@ -4,14 +4,21 @@ from __future__ import annotations
4
5
from typing import Any
6
7
+from helpers.errors import HandledException
8
from helpers.extension import Extension
9
from helpers.print_style import PrintStyle
10
+from helpers.settings import get_settings
11
+
12
+
13
+STATE_KEY = "_unusable_response_failures"
14
15
16
class EmptyResponse(Extension):
17
def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
18
if not self.agent or not isinstance(result_data, dict):
19
return
20
+ if result_data.get("skip_default_processing"):
21
+ return
22
23
llm_result = result_data.get("llm_result")
24
response = getattr(llm_result, "response", "")
@@ -22,14 +29,23 @@ class EmptyResponse(Extension):
29
return
30
31
warning = self.agent.read_prompt("fw.msg_empty_response.md")
25
- log_item = self.agent.loop_data.params_temporary.get("log_item_generating")
26
- assistant_message = self.agent.hist_add_ai_response(
27
- response,
28
- id=log_item.id if log_item else "",
29
- llm_result=llm_result,
30
- )
31
- self.agent._remember_llm_result_state(llm_result, assistant_message)
32
PrintStyle(font_color="orange", padding=True).print(warning)
33
+ state = self.agent.loop_data.params_persistent
34
+ previous = state.get(STATE_KEY, {})
35
+ previous_iteration = previous.get("iteration") if isinstance(previous, dict) else None
36
+ count = (
37
+ previous.get("count", 0) + 1
38
+ if previous_iteration == self.agent.loop_data.iteration - 1
39
+ else 1
40
+ )
41
+ state[STATE_KEY] = {"iteration": self.agent.loop_data.iteration, "count": count}
42
+ limit = get_settings()["max_consecutive_unusable_responses"]
43
+ if count >= limit:
44
+ stop_message = self.agent.read_prompt(
45
+ "fw.msg_unusable_response_limit.md", limit=limit
46
+ )
47
+ self.agent.context.log.log(type="warning", content=stop_message)
48
+ raise HandledException(stop_message)
49
self.agent.context.log.log(
50
type="warning",
51
content=f"{self.agent.agent_name}: {warning}",
extensions/python/message_loop_result/_30_repeat_response.py
+3
-3
@@ -12,12 +12,12 @@ class RepeatResponse(Extension):
12
def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
13
if not self.agent or not isinstance(result_data, dict):
14
return
15
+ if result_data.get("skip_default_processing"):
16
+ return
17
18
llm_result = result_data.get("llm_result")
19
response = getattr(llm_result, "response", "")
18
- if not isinstance(response, str):
19
- return
20
- if not response.strip() or response != self.agent.loop_data.last_response:
20
+ if not isinstance(response, str) or response != self.agent.loop_data.last_response:
21
return
22
23
warning = self.agent.read_prompt("fw.msg_repeat.md")
prompts/fw.msg_repeat_response.md
+1
-1
@@ -1 +1 @@
1
-Repeated response detected. Retrying.
1
+Repeated response detected. Retrying.
\ No newline at end of file
tests/test_message_loop_result.py
+48
-3
@@ -1,7 +1,11 @@
1
from types import SimpleNamespace
2
3
+from extensions.python.message_loop_result import _20_empty_response as empty_response
4
from extensions.python.message_loop_result._20_empty_response import EmptyResponse
5
from extensions.python.message_loop_result._30_repeat_response import RepeatResponse
6
+from extensions.python._functions.agent.Agent.hist_add_warning.end import (
7
+ _90_stop_unusable_response_loop as response_loop,
8
+)
9
10
11
class FakeAgent:
@@ -9,6 +13,8 @@ class FakeAgent:
13
self.loop_data = SimpleNamespace(
14
last_response=last_response,
15
params_temporary={},
16
+ params_persistent={},
17
+ iteration=0,
18
)
19
self.logs = []
20
self.context = SimpleNamespace(
@@ -20,8 +26,11 @@ class FakeAgent:
26
self.warnings = []
27
self.history = []
28
23
- def read_prompt(self, name):
29
+ def read_prompt(self, name, **kwargs):
30
+ if name == "fw.msg_unusable_response_limit.md":
31
+ return f"stopped at {kwargs['limit']}"
32
return {
33
+ "fw.msg_misformat.md": "misformatted",
34
"fw.msg_empty_response.md": "empty",
35
"fw.msg_repeat.md": "repeat",
36
"fw.msg_repeat_response.md": "Repeated response detected. Retrying.",
@@ -52,11 +61,47 @@ def test_empty_result_skips_default_processing():
61
agent = FakeAgent("")
62
63
assert _run(agent)["skip_default_processing"] is True
55
- assert agent.history == [""]
64
+ assert agent.history == []
65
assert agent.warnings == []
66
assert agent.logs == [{"type": "warning", "content": "A0: empty"}]
67
68
69
+def test_empty_result_counts_toward_unusable_response_limit(monkeypatch):
70
+ monkeypatch.setattr(
71
+ empty_response,
72
+ "get_settings",
73
+ lambda: {"max_consecutive_unusable_responses": 2},
74
+ )
75
+ agent = FakeAgent("")
76
+
77
+ assert _run(agent)["skip_default_processing"] is True
78
+
79
+ agent.loop_data.iteration = 1
80
+ try:
81
+ _run(agent)
82
+ except response_loop.HandledException as error:
83
+ assert str(error) == "stopped at 2"
84
+ else:
85
+ raise AssertionError("empty response should stop at the configured limit")
86
+
87
+ assert agent.loop_data.params_persistent[response_loop.STATE_KEY]["count"] == 2
88
+
89
+
90
+def test_later_handlers_skip_a_result_already_handled_by_an_extension():
91
+ response = '{"tool_name":"response"}'
92
+ agent = FakeAgent(response, last_response=response)
93
+ result_data = {
94
+ "llm_result": SimpleNamespace(response=response, reasoning=""),
95
+ "skip_default_processing": True,
96
+ }
97
+
98
+ EmptyResponse(agent).execute(result_data)
99
+ RepeatResponse(agent).execute(result_data)
100
+
101
+ assert agent.history == []
102
+ assert agent.warnings == []
103
+
104
+
105
def test_repeat_skips_default_processing():
106
agent = FakeAgent('{"tool_name":"response"}', last_response='{"tool_name":"response"}')
107
@@ -80,7 +125,7 @@ def test_repeat_ignores_reasoning():
125
126
127
def test_result_with_reasoning_uses_default_processing():
83
- agent = FakeAgent("", reasoning="thinking")
128
+ agent = FakeAgent("", reasoning="thinking", last_response="previous")
129
130
assert "skip_default_processing" not in _run(agent)
131
assert agent.history == []