Skip to content

Commit b2b70a7

Browse files
committed
fix: harden JSON misformat handling to prevent infinite loops and crashes
Circuit breaker now covers BOTH failure modes: - Case A: complete parse failure (tool_request is None) - Case B: parseable-but-invalid JSON (RepairableException from validation) Previously, Case B bypassed the consecutive_misformat counter entirely because RepairableException was swallowed by _50_handle_repairable_exception extension, allowing the agent to loop forever on models producing JSON without required tool_name/tool_args fields. Changes: - agent.py: Wrap validate_tool_request in try/except RepairableException inside process_tools; increment counter for Case B; move counter reset to after validation passes (not after tool execution) - prompts/fw.msg_misformat.md: Add attempt countdown, remove contradictory code-fence example, compress to single-line JSON example - tests/test_misformat_circuit_breaker.py: 73 tests covering extraction, validation, circuit breaker (both cases), and end-to-end pipeline Workaround: Models producing frequent misformats (especially thinking/reasoning models like MiniMax M2.5) can be improved by adding response_format to the Chat model additional parameters in Agent Zero settings: response_format={"type": "json_object"} This forces JSON output at the token generation level. Confirmed supported on OpenRouter for MiniMax M2.5, DeepSeek V3, Gemini 2.5 Flash, Grok 4.1.
1 parent 506d723 commit b2b70a7

4 files changed

Lines changed: 625 additions & 16 deletions

File tree

agent.py

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ def __init__(self, **kwargs):
334334
self.params_temporary: dict = {}
335335
self.params_persistent: dict = {}
336336
self.current_tool = None
337+
self.consecutive_misformat = 0
337338

338339
# override values with kwargs
339340
for key, value in kwargs.items():
@@ -844,10 +845,27 @@ async def process_tools(self, msg: str):
844845
# search for tool usage requests in agent message
845846
tool_request = extract_tools.json_parse_dirty(msg)
846847

847-
# basic validation + extensions
848-
await self.validate_tool_request(tool_request)
848+
# Validate tool request — catches both Case A (None) and Case B (invalid dict).
849+
# RepairableException from validation counts toward the circuit breaker:
850+
# the _50_handle_repairable_exception extension swallows RepairableException
851+
# and continues the loop, so without counting here, the agent loops forever
852+
# on models that produce parseable-but-invalid JSON (e.g., missing tool_name).
853+
try:
854+
await self.validate_tool_request(tool_request)
855+
except RepairableException:
856+
self.loop_data.consecutive_misformat += 1
857+
if self.loop_data.consecutive_misformat >= 5:
858+
raise HandledException(
859+
"Too many consecutive misformat errors (5/5). Stopping to prevent infinite loop."
860+
)
861+
raise # propagate to handle_exception for the standard warning
849862

850863
if tool_request is not None:
864+
# Validation passed — the model produced correctly-formatted JSON.
865+
# Reset the misformat counter regardless of whether the tool is found,
866+
# since format correctness (not tool existence) is what we're tracking.
867+
self.loop_data.consecutive_misformat = 0
868+
851869
raw_tool_name = tool_request.get("tool_name", tool_request.get("tool","")) # Get the raw tool name
852870
tool_args = tool_request.get("tool_args", tool_request.get("args", {}))
853871

@@ -933,22 +951,34 @@ async def process_tools(self, msg: str):
933951
type="warning", content=f"{self.agent_name}: {error_detail}"
934952
)
935953
else:
936-
warning_msg_misformat = self.read_prompt("fw.msg_misformat.md")
954+
self.loop_data.consecutive_misformat += 1
955+
warning_msg_misformat = self.read_prompt(
956+
"fw.msg_misformat.md",
957+
attempt=self.loop_data.consecutive_misformat,
958+
max_attempts=5,
959+
)
937960
self.hist_add_warning(warning_msg_misformat)
938961
PrintStyle(font_color="red", padding=True).print(warning_msg_misformat)
939962
self.context.log.log(
940963
type="warning",
941-
content=f"{self.agent_name}: Message misformat, no valid tool request found.",
964+
content=f"{self.agent_name}: Message misformat ({self.loop_data.consecutive_misformat}/5), no valid tool request found.",
942965
)
966+
if self.loop_data.consecutive_misformat >= 5:
967+
raise HandledException(
968+
"Too many consecutive misformat errors (5/5). Stopping to prevent infinite loop."
969+
)
970+
return
943971

944972
@extension.extensible
945973
async def validate_tool_request(self, tool_request: Any):
974+
if tool_request is None:
975+
return # let process_tools handle the misformat case
946976
if not isinstance(tool_request, dict):
947-
raise ValueError("Tool request must be a dictionary")
977+
raise RepairableException("Tool request must be a dictionary")
948978
if not tool_request.get("tool_name") or not isinstance(tool_request.get("tool_name"), str):
949-
raise ValueError("Tool request must have a tool_name (type string) field")
979+
raise RepairableException("Tool request must have a tool_name (type string) field")
950980
if not tool_request.get("tool_args") or not isinstance(tool_request.get("tool_args"), dict):
951-
raise ValueError("Tool request must have a tool_args (type dictionary) field")
981+
raise RepairableException("Tool request must have a tool_args (type dictionary) field")
952982

953983

954984

helpers/extract_tools.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,32 @@ def extract_json_object_string(content):
2525
if start == -1:
2626
return ""
2727

28-
# Find the first '{'
29-
end = content.rfind('}')
30-
if end == -1:
31-
# If there's no closing '}', return from start to the end
32-
return content[start:]
33-
else:
34-
# If there's a closing '}', return the substring from start to end
35-
return content[start:end+1]
28+
# Walk forward from the first '{' tracking brace depth, respecting strings and escapes
29+
depth = 0
30+
in_string = False
31+
escape_next = False
32+
for i in range(start, len(content)):
33+
ch = content[i]
34+
if escape_next:
35+
escape_next = False
36+
continue
37+
if ch == '\\' and in_string:
38+
escape_next = True
39+
continue
40+
if ch == '"':
41+
in_string = not in_string
42+
continue
43+
if in_string:
44+
continue
45+
if ch == '{':
46+
depth += 1
47+
elif ch == '}':
48+
depth -= 1
49+
if depth == 0:
50+
return content[start:i+1]
51+
52+
# No matching '}' found — return from start to end
53+
return content[start:]
3654

3755
def extract_json_string(content):
3856
# Regular expression pattern to match a JSON object

prompts/fw.msg_misformat.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
You have misformatted your message. Follow system prompt instructions on JSON message formatting precisely.
1+
WARNING: Your response could not be parsed as a valid JSON tool call (attempt {{attempt}}/{{max_attempts}}).
2+
3+
You MUST respond with a single, raw JSON object — no markdown, no code fences, no XML. Example:
4+
5+
{"thoughts": ["your reasoning"], "headline": "Short summary", "tool_name": "response", "tool_args": {"text": "your message"}}
6+
7+
If you want to reply to the user, use tool_name "response" with tool_args.text containing your message.

0 commit comments

Comments
 (0)