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>