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 == []