Stop runaway unusable response loops
Add a configurable circuit breaker for consecutive malformed or repeated model outputs. Expose the limit in Agent Settings and render the stop notice from a framework prompt.
Alessandro committed
Jul 10, 2026 at 16:36 UTC
d33cac3bf36c5da20a36afd6a603331c54d39e8f
7 files changed
+181
extensions/python/_functions/AGENTS.md
+1
@@ -16,6 +16,7 @@
16
- Extension functions must match the implicit hook's supplied arguments.
17
- Preserve ordering prefixes where exception handling, watchdog registration, or cleanup depends on them.
18
- Hooks that mirror persisted AI responses into UI logs must reuse existing stream log items and avoid duplicating live response-tool logs.
19
+- Recovery-loop circuit breakers must stop at the General Settings limit and render their user-visible cost warning from a core framework prompt.
20
21
## Work Guidance
22
extensions/python/_functions/agent/Agent/hist_add_warning/end/_90_stop_unusable_response_loop.py
new
+55
@@ -0,0 +1,55 @@
1
+from helpers.errors import HandledException
2
+from helpers.extension import Extension
3
+from helpers.settings import get_settings
4
+
5
+
6
+STATE_KEY = "_unusable_response_failures"
7
+
8
+
9
+class StopUnusableResponseLoop(Extension):
10
+ def execute(self, data: dict | None = None, **kwargs):
11
+ if not self.agent or not isinstance(data, dict):
12
+ return
13
+
14
+ call_kwargs = data.get("kwargs")
15
+ message = call_kwargs.get("message") if isinstance(call_kwargs, dict) else None
16
+ call_args = data.get("args")
17
+ if message is None and isinstance(call_args, tuple) and len(call_args) > 1:
18
+ message = call_args[1]
19
+
20
+ if not isinstance(message, str):
21
+ return
22
+ if message not in {
23
+ self.agent.read_prompt("fw.msg_misformat.md"),
24
+ self.agent.read_prompt("fw.msg_repeat.md"),
25
+ }:
26
+ return
27
+
28
+ loop_data = getattr(self.agent, "loop_data", None)
29
+ state = getattr(loop_data, "params_persistent", None)
30
+ iteration = getattr(loop_data, "iteration", None)
31
+ if not isinstance(state, dict) or not isinstance(iteration, int):
32
+ return
33
+
34
+ previous = state.get(STATE_KEY, {})
35
+ if not isinstance(previous, dict):
36
+ previous = {}
37
+ previous_iteration = previous.get("iteration")
38
+ if previous_iteration == iteration:
39
+ return
40
+
41
+ count = (
42
+ previous.get("count", 0) + 1
43
+ if previous_iteration == iteration - 1
44
+ else 1
45
+ )
46
+ state[STATE_KEY] = {"iteration": iteration, "count": count}
47
+ limit = get_settings()["max_consecutive_unusable_responses"]
48
+ if count < limit:
49
+ return
50
+
51
+ stop_message = self.agent.read_prompt(
52
+ "fw.msg_unusable_response_limit.md", limit=limit
53
+ )
54
+ self.agent.context.log.log(type="warning", content=stop_message)
55
+ data["exception"] = HandledException(stop_message)
helpers/settings.py
+7
@@ -56,6 +56,7 @@ class Settings(TypedDict):
56
57
agent_profile: str
58
agent_knowledge_subdir: str
59
+ max_consecutive_unusable_responses: int
60
timezone: str
61
time_format: str
62
@@ -401,6 +402,9 @@ def normalize_settings(settings: Settings) -> Settings:
402
403
# mcp server token is set automatically
404
copy["mcp_server_token"] = create_auth_token()
405
+ copy["max_consecutive_unusable_responses"] = max(
406
+ 1, copy["max_consecutive_unusable_responses"]
407
+ )
408
copy["timezone"] = _normalize_timezone_setting(copy.get("timezone"), default["timezone"])
409
copy["time_format"] = _normalize_time_format(copy.get("time_format"), default["time_format"])
410
@@ -498,6 +502,9 @@ def get_default_settings() -> Settings:
502
root_password="",
503
agent_profile=get_default_value("agent_profile", "agent0"),
504
agent_knowledge_subdir=get_default_value("agent_knowledge_subdir", "custom"),
505
+ max_consecutive_unusable_responses=get_default_value(
506
+ "max_consecutive_unusable_responses", 2
507
+ ),
508
timezone=_normalize_timezone_setting(get_default_value("timezone", TIMEZONE_AUTO)),
509
time_format=_normalize_time_format(get_default_value("time_format", TIME_FORMAT_12H)),
510
workdir_path=get_default_value("workdir_path", files.get_abs_path_dockerized("usr/workdir")),
helpers/settings.py.dox.md
+1
@@ -64,6 +64,7 @@
64
- Important called helpers/classes observed in the source: `TypeVar`, `files.get_abs_path`, `dotenv.get_dotenv_value`, `opts.insert`, `str.strip`, `_is_valid_timezone`, `str.strip.lower`, `_normalize_timezone_setting`, `SettingsOutput`, `get_default_settings`, `_ensure_option_present`, `_resolve_runtime_timezone`, `get_default_secrets_manager`, `get_settings`, `normalize_settings`, `_load_sensitive_settings`, `settings.copy`, `_write_settings_file`, `reload_settings`, `set_settings`, `initialize_agent`.
65
- Applying settings refreshes active context configs while preserving each subordinate agent's own profile.
66
- Applying settings starts a deferred `MCPConfig.update(...)` with the current `mcp_servers` string when global MCP server settings change.
67
+- `max_consecutive_unusable_responses` defaults to `2` and controls the cost circuit breaker for malformed or repeated main-model outputs.
68
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
69
70
## Work Guidance
prompts/fw.msg_unusable_response_limit.md
new
+1
@@ -0,0 +1 @@
1
+Agent stopped after {{limit}} consecutive unusable model responses to prevent further API charges. Send a new message to try again.
tests/test_unusable_response_loop.py
new
+99
@@ -0,0 +1,99 @@
1
+from pathlib import Path
2
+from types import SimpleNamespace
3
+
4
+from helpers.errors import HandledException
5
+from helpers.files import read_prompt_file
6
+from helpers.settings import get_default_settings, normalize_settings
7
+from extensions.python._functions.agent.Agent.hist_add_warning.end import (
8
+ _90_stop_unusable_response_loop as response_loop,
9
+)
10
+
11
+
12
+class FakeLog:
13
+ def __init__(self):
14
+ self.entries = []
15
+
16
+ def log(self, **entry):
17
+ self.entries.append(entry)
18
+
19
+
20
+def _agent():
21
+ prompts = {
22
+ "fw.msg_misformat.md": "misformatted",
23
+ "fw.msg_repeat.md": "repeated",
24
+ }
25
+
26
+ def read_prompt(name, **kwargs):
27
+ if name == "fw.msg_unusable_response_limit.md":
28
+ return f"stopped at {kwargs['limit']}"
29
+ return prompts[name]
30
+
31
+ return SimpleNamespace(
32
+ loop_data=SimpleNamespace(iteration=0, params_persistent={}),
33
+ context=SimpleNamespace(log=FakeLog()),
34
+ read_prompt=read_prompt,
35
+ )
36
+
37
+
38
+def _run(extension, agent, message):
39
+ data = {"args": (agent, message), "kwargs": {}, "exception": None}
40
+ extension.execute(data=data)
41
+ return data
42
+
43
+
44
+def test_stops_at_configured_failure_limit(monkeypatch):
45
+ monkeypatch.setattr(
46
+ response_loop,
47
+ "get_settings",
48
+ lambda: {"max_consecutive_unusable_responses": 3},
49
+ )
50
+ agent = _agent()
51
+ extension = response_loop.StopUnusableResponseLoop(agent=agent)
52
+
53
+ assert _run(extension, agent, "misformatted")["exception"] is None
54
+
55
+ agent.loop_data.iteration = 1
56
+ assert _run(extension, agent, "repeated")["exception"] is None
57
+
58
+ agent.loop_data.iteration = 2
59
+ data = _run(extension, agent, "repeated")
60
+
61
+ assert isinstance(data["exception"], HandledException)
62
+ assert agent.loop_data.params_persistent[response_loop.STATE_KEY]["count"] == 3
63
+ assert agent.context.log.entries == [
64
+ {"type": "warning", "content": "stopped at 3"}
65
+ ]
66
+
67
+
68
+def test_nonconsecutive_failure_starts_a_new_recovery_window(monkeypatch):
69
+ monkeypatch.setattr(
70
+ response_loop,
71
+ "get_settings",
72
+ lambda: {"max_consecutive_unusable_responses": 2},
73
+ )
74
+ agent = _agent()
75
+ extension = response_loop.StopUnusableResponseLoop(agent=agent)
76
+
77
+ assert _run(extension, agent, {"structured": "warning"})["exception"] is None
78
+ _run(extension, agent, "misformatted")
79
+ agent.loop_data.iteration = 2
80
+ data = _run(extension, agent, "repeated")
81
+
82
+ assert data["exception"] is None
83
+ assert agent.loop_data.params_persistent[response_loop.STATE_KEY]["count"] == 1
84
+
85
+
86
+def test_general_settings_expose_the_default_failure_limit():
87
+ settings = get_default_settings()
88
+ assert settings["max_consecutive_unusable_responses"] == 2
89
+ settings["max_consecutive_unusable_responses"] = 0
90
+ assert normalize_settings(settings)["max_consecutive_unusable_responses"] == 1
91
+
92
+ html = Path("webui/components/settings/agent/agent.html").read_text()
93
+ assert (
94
+ 'x-model.number="$store.settings.settings.max_consecutive_unusable_responses"'
95
+ in html
96
+ )
97
+ assert "after 3 consecutive" in read_prompt_file(
98
+ "fw.msg_unusable_response_limit.md", ["prompts"], limit=3
99
+ )
webui/components/settings/agent/agent.html
+17
@@ -66,6 +66,23 @@
66
</label>
67
</div>
68
</div>
69
+
70
+ <div class="field">
71
+ <div class="field-label">
72
+ <div class="field-title">Consecutive unusable response limit</div>
73
+ <div class="field-description">
74
+ Stop after this many malformed or repeated model responses. The default allows one corrective retry.
75
+ </div>
76
+ </div>
77
+ <div class="field-control">
78
+ <input
79
+ type="number"
80
+ min="1"
81
+ step="1"
82
+ x-model.number="$store.settings.settings.max_consecutive_unusable_responses"
83
+ />
84
+ </div>
85
+ </div>
86
</div>
87
</div>
88
</div>