Add Context Doctor plugin
Repair malformed Agent Zero tool-call JSON before default dispatch, then persist repaired compact response in WebUI log. Pin json_repair, retain targeted installed-parser patch, and expose XML suppression and log-detail settings.
linkliti committed
Aug 24, 2026 at 19:51 UTC
dac39537c9e51a505c70d3cbb5360ae989cd168e
13 files changed
+468
plugins/AGENTS.md
+1
@@ -75,6 +75,7 @@ Direct child DOX files:
75
| [_chat_compaction/AGENTS.md](_chat_compaction/AGENTS.md) | Full-chat compaction into a summary message. |
76
| [_chat_naming/AGENTS.md](_chat_naming/AGENTS.md) | Built-in manual and Utility Model-assisted chat naming. |
77
| [_commands/AGENTS.md](_commands/AGENTS.md) | Built-in slash command manager, command file discovery, and chat composer slash picker. |
78
+| [_context_doctor/AGENTS.md](_context_doctor/AGENTS.md) | Tool-call JSON repair and compact persistence before default dispatch. |
79
| [_context_window/AGENTS.md](_context_window/AGENTS.md) | Context-window token accounting, API, composer indicator, and visibility control. |
80
| [_code_execution/AGENTS.md](_code_execution/AGENTS.md) | Terminal, Python, and Node.js execution tools and shell runtimes. |
81
| [_desktop/AGENTS.md](_desktop/AGENTS.md) | Linux desktop runtime, sessions, and desktop surface. |
plugins/_context_doctor/AGENTS.md
new
+30
@@ -0,0 +1,30 @@
1
+# Context Doctor Plugin DOX
2
+
3
+## Purpose
4
+
5
+- Repair malformed Agent Zero tool-call JSON and persist compact repaired output.
6
+
7
+## Ownership
8
+
9
+- `helpers/context_doctor.py` validates and repairs tool-call JSON.
10
+- `extensions/python/message_loop_result/` normalizes completed model output before default processing.
11
+- `webui/config.html` exposes XML-output suppression only.
12
+
13
+## Local Contracts
14
+
15
+- Repaired tool-call JSON is always minified.
16
+- Invalid non-tool output is unchanged; native processing retains ownership.
17
+- Do not write settings that alter repair mode or log content.
18
+
19
+## Work Guidance
20
+
21
+- Keep repair scoped to complete tool-call JSON.
22
+- Use framework-installed `json_repair`; apply the plugin-local parser patch before repair. Do not vendor dependencies.
23
+
24
+## Verification
25
+
26
+- Run `pytest plugins/_context_doctor/tests`.
27
+
28
+## Child DOX Index
29
+
30
+No child DOX files.
plugins/_context_doctor/README.md
new
+10
@@ -0,0 +1,10 @@
1
+# Context Doctor
2
+
3
+Repairs model tool call JSON before sending it to history and tool processing.
4
+
5
+## Behavior
6
+
7
+- Uses `json_repair` after native parsing has completed.
8
+- Accepts only complete Agent Zero tool calls (`tool_name` and object `tool_args`).
9
+- Stores and displays repaired tool calls as compact JSON.
10
+- Optionally replaces XML-like output with `{}` in cases where model uses native XML tool calls.
plugins/_context_doctor/default_config.yaml
new
+2
@@ -0,0 +1,2 @@
1
+update_log: false
2
+suppress_xml: true
plugins/_context_doctor/extensions/python/message_loop_result/_10_context_doctor.py
new
+34
@@ -0,0 +1,34 @@
1
+"""Repair and minify model tool-call JSON before default processing."""
2
+
3
+from __future__ import annotations
4
+
5
+from typing import Any, override
6
+
7
+from helpers.extension import Extension
8
+from helpers.plugins import get_plugin_config
9
+from plugins._context_doctor.helpers.context_doctor import repair_and_minify, update_log_item
10
+
11
+
12
+class ContextDoctor(Extension):
13
+ @override
14
+ def execute(self, result_data: dict[str, Any] | None = None, **kwargs: Any) -> None:
15
+ if not self.agent or not isinstance(result_data, dict):
16
+ return
17
+
18
+ llm_result = result_data.get("llm_result")
19
+ response = getattr(llm_result, "response", None)
20
+ if not isinstance(response, str):
21
+ return
22
+
23
+ config = get_plugin_config("_context_doctor", agent=self.agent) or {}
24
+ repaired = repair_and_minify(
25
+ response, suppress_xml=config.get("suppress_xml", True)
26
+ )
27
+ if repaired is None:
28
+ return
29
+
30
+ llm_result.response = repaired
31
+ params = getattr(getattr(self.agent, "loop_data", None), "params_temporary", None)
32
+ log_item = params.get("log_item_generating") if isinstance(params, dict) else None
33
+ if log_item is not None and config.get("update_log", False):
34
+ update_log_item(self.agent, log_item, repaired)
plugins/_context_doctor/helpers/context_doctor.py
new
+59
@@ -0,0 +1,59 @@
1
+"""Repair complete Agent Zero tool-call JSON into compact JSON."""
2
+
3
+from __future__ import annotations
4
+
5
+import json
6
+from typing import Any
7
+
8
+
9
+def _is_tool_call(value: Any) -> bool:
10
+ return (
11
+ isinstance(value, dict)
12
+ and isinstance(value.get("tool_name"), str)
13
+ and isinstance(value.get("tool_args"), dict)
14
+ and (
15
+ "thoughts" not in value
16
+ or (
17
+ isinstance(value["thoughts"], list)
18
+ and all(isinstance(item, str) for item in value["thoughts"])
19
+ )
20
+ )
21
+ and ("headline" not in value or isinstance(value["headline"], str))
22
+ )
23
+
24
+
25
+def repair_and_minify(response: str, *, suppress_xml: bool) -> str | None:
26
+ """Return compact tool-call JSON, XML fallback, or ``None`` for other output."""
27
+ if not response:
28
+ return None
29
+
30
+ try:
31
+ from plugins._context_doctor.helpers.json_repair_patch import apply_patch
32
+ from json_repair import repair_json
33
+
34
+ apply_patch()
35
+ repaired = repair_json(response, return_objects=True)
36
+ except Exception:
37
+ return "{}" if suppress_xml and "<" in response and ">" in response else None
38
+
39
+ if isinstance(repaired, list):
40
+ repaired = next((item for item in repaired if _is_tool_call(item)), None)
41
+ if _is_tool_call(repaired):
42
+ return json.dumps(repaired, ensure_ascii=False, separators=(",", ":"))
43
+ return "{}" if suppress_xml and "<" in response and ">" in response else None
44
+
45
+
46
+def update_log_item(agent: Any, log_item: Any, response: str) -> None:
47
+ """Replace final log details with repaired JSON and derived display fields."""
48
+ try:
49
+ parsed = json.loads(response)
50
+ if not _is_tool_call(parsed):
51
+ return
52
+ heading = parsed.get("headline") or f"Using {parsed['tool_name']}"
53
+ log_item.update(
54
+ content=response,
55
+ kvps=parsed,
56
+ heading=f"{getattr(agent, 'agent_name', 'A0')}: {heading}",
57
+ )
58
+ except (AttributeError, TypeError, ValueError):
59
+ pass
plugins/_context_doctor/helpers/json_repair_patch.py
new
+182
@@ -0,0 +1,182 @@
1
+"""Monkeypatch installed json_repair classify_object_value_comma.
2
+
3
+Guards all 4 classification paths against false member-boundary detection
4
+when unescaped quotes, backtick+colon patterns, or timestamp injections
5
+appear inside string values.
6
+
7
+Safe assumptions for A0 tool_args keys:
8
+- Letters and underscore only (no digits, hyphens, or other symbols)
9
+- Length <= 24 characters
10
+
11
+TODO: Replace with checks against actual tool schemas
12
+"""
13
+
14
+from __future__ import annotations
15
+
16
+from typing import TYPE_CHECKING
17
+
18
+if TYPE_CHECKING:
19
+ from collections.abc import Callable
20
+
21
+ from json_repair.json_parser import JSONParser
22
+
23
+_MAX_KEY_LENGTH = 24
24
+
25
+
26
+def _load_string_delimiters() -> tuple[str, ...]:
27
+ try:
28
+ from json_repair.utils.constants import STRING_DELIMITERS
29
+
30
+ return tuple(STRING_DELIMITERS)
31
+ except Exception:
32
+ return ('"', "'")
33
+
34
+
35
+STRING_DELIMITERS_CACHE: tuple[str, ...] = _load_string_delimiters()
36
+
37
+
38
+def _has_four_consecutive_digits(text: str) -> bool:
39
+ count = 0
40
+ for char in text:
41
+ if char.isdigit():
42
+ count += 1
43
+ if count >= 4:
44
+ return True
45
+ else:
46
+ count = 0
47
+ return False
48
+
49
+
50
+def _key_text_is_plausible(parser: JSONParser, key_start: int, key_end: int) -> bool:
51
+ if key_end - key_start > _MAX_KEY_LENGTH:
52
+ return False
53
+ text = parser.json_str[key_start:key_end]
54
+ if "\n" in text:
55
+ return False
56
+ if _has_four_consecutive_digits(text):
57
+ return False
58
+ return True
59
+
60
+
61
+def _colon_follows(parser: JSONParser, idx: int) -> bool:
62
+ return parser.get_char_at(parser.scroll_whitespaces(idx=idx)) == ":"
63
+
64
+
65
+def _has_recoverable_value(
66
+ parser: JSONParser,
67
+ value_start: int,
68
+ skip_to_character: Callable[[str | list[str], int], int],
69
+) -> bool:
70
+ """Delegate to original _bare_member_has_recoverable_value, but at EOF treat
71
+ as recoverable only when stream_stable=False (default streaming mode)."""
72
+ from json_repair.parse_string_helpers.object_value_context import (
73
+ _bare_member_has_recoverable_value,
74
+ )
75
+
76
+ if _bare_member_has_recoverable_value(parser, value_start, skip_to_character):
77
+ return True
78
+ # Original returns False at EOF; override for streaming mode
79
+ value_start_idx = parser.scroll_whitespaces(idx=value_start)
80
+ value_end_idx = skip_to_character([*STRING_DELIMITERS_CACHE, "}"], value_start_idx)
81
+ if parser.get_char_at(value_end_idx) is None:
82
+ return not parser.stream_stable
83
+ return False
84
+
85
+
86
+def _patched_classify_object_value_comma(
87
+ parser: JSONParser,
88
+ cached_skip_to_character: Callable[[str | list[str], int], int] | None = None,
89
+) -> str:
90
+ from json_repair.utils.constants import STRING_DELIMITERS
91
+
92
+ skip_to_character = cached_skip_to_character or parser.skip_to_character
93
+ next_idx = parser.scroll_whitespaces(idx=1)
94
+ next_c = parser.get_char_at(next_idx)
95
+ if next_c in ["}", None]:
96
+ return "member"
97
+
98
+ # Quoted key
99
+ if next_c in STRING_DELIMITERS:
100
+ key_end_idx = parser.skip_to_character(character=next_c, idx=next_idx + 1)
101
+ if not parser.get_char_at(key_end_idx):
102
+ return "string"
103
+ abs_start = parser.index + next_idx + 1
104
+ abs_end = parser.index + key_end_idx
105
+ if _key_text_is_plausible(parser, abs_start, abs_end) and _colon_follows(
106
+ parser, key_end_idx + 1
107
+ ):
108
+ return "member"
109
+ return "string"
110
+
111
+ # Backtick key
112
+ if next_c == "`":
113
+ bare_key_idx = next_idx + 1
114
+ while True:
115
+ key_char = parser.get_char_at(bare_key_idx)
116
+ if not key_char or not (key_char.isalnum() or key_char in ["_", "-"]):
117
+ break
118
+ bare_key_idx += 1
119
+ abs_start = parser.index + next_idx + 1
120
+ abs_end = parser.index + bare_key_idx
121
+ if (
122
+ _key_text_is_plausible(parser, abs_start, abs_end)
123
+ and _colon_follows(parser, bare_key_idx)
124
+ and _has_recoverable_value(parser, bare_key_idx + 1, skip_to_character)
125
+ ):
126
+ return "member"
127
+ return "string"
128
+
129
+ # Bare alnum/underscore key
130
+ if next_c and (next_c.isalnum() or next_c == "_"):
131
+ bare_key_idx = next_idx
132
+ while True:
133
+ key_char = parser.get_char_at(bare_key_idx)
134
+ if not key_char or not (key_char.isalnum() or key_char in ["_", "-"]):
135
+ break
136
+ bare_key_idx += 1
137
+ abs_start = parser.index + next_idx
138
+ abs_end = parser.index + bare_key_idx
139
+ if (
140
+ _key_text_is_plausible(parser, abs_start, abs_end)
141
+ and _colon_follows(parser, bare_key_idx)
142
+ and _has_recoverable_value(parser, bare_key_idx + 1, skip_to_character)
143
+ ):
144
+ return "member"
145
+
146
+ if next_c in ["{", "["]:
147
+ return "container"
148
+
149
+ # Fallback: skip to next string delimiter
150
+ next_special_idx = skip_to_character([*STRING_DELIMITERS, "{", "["], next_idx)
151
+ next_special = parser.get_char_at(next_special_idx)
152
+ if not next_special:
153
+ return "string_no_future_delimiter"
154
+ if next_special in ["{", "["]:
155
+ return "string"
156
+
157
+ key_end_idx = skip_to_character(next_special, next_special_idx + 1)
158
+ if not parser.get_char_at(key_end_idx):
159
+ return "string"
160
+ abs_start = parser.index + next_special_idx + 1
161
+ abs_end = parser.index + key_end_idx
162
+ if _key_text_is_plausible(parser, abs_start, abs_end) and _colon_follows(
163
+ parser, key_end_idx + 1
164
+ ):
165
+ return "member"
166
+ return "string"
167
+
168
+
169
+_applied = False
170
+
171
+
172
+def apply_patch() -> None:
173
+ global _applied
174
+ if _applied:
175
+ return
176
+
177
+ from json_repair.parse_string_helpers import object_value_context as ovc
178
+ from json_repair import parse_string as ps
179
+
180
+ ovc.classify_object_value_comma = _patched_classify_object_value_comma
181
+ ps.classify_object_value_comma = _patched_classify_object_value_comma
182
+ _applied = True
plugins/_context_doctor/plugin.yaml
new
+7
@@ -0,0 +1,7 @@
1
+name: _context_doctor
2
+title: Context Doctor
3
+description: Repairs model tool call JSON before sending it to history and tool processing.
4
+version: 2.0.0
5
+settings_sections: []
6
+per_project_config: true
7
+per_agent_config: true
plugins/_context_doctor/tests/conftest.py
new
+19
@@ -0,0 +1,19 @@
1
+from __future__ import annotations
2
+
3
+from unittest.mock import patch
4
+
5
+import pytest
6
+
7
+_DEFAULT_CONFIG = {
8
+ "update_log": False,
9
+ "suppress_xml": True,
10
+}
11
+
12
+
13
+@pytest.fixture(autouse=True)
14
+def _mock_plugin_config():
15
+ with patch(
16
+ "helpers.plugins.get_plugin_config",
17
+ return_value=_DEFAULT_CONFIG,
18
+ ):
19
+ yield
plugins/_context_doctor/tests/test_context_doctor.py
new
+78
@@ -0,0 +1,78 @@
1
+from types import SimpleNamespace
2
+
3
+from plugins._context_doctor.helpers.context_doctor import repair_and_minify, update_log_item
4
+from plugins._context_doctor.extensions.python.message_loop_result._10_context_doctor import (
5
+ ContextDoctor,
6
+)
7
+
8
+
9
+def test_repairs_and_minifies_tool_call():
10
+ response = '{"tool_name":"response","tool_args":{"text":"ok",},}'
11
+
12
+ assert repair_and_minify(response, suppress_xml=True) == (
13
+ '{"tool_name":"response","tool_args":{"text":"ok"}}'
14
+ )
15
+
16
+
17
+def test_ignores_non_tool_json():
18
+ assert repair_and_minify('{"message":"ok"}', suppress_xml=True) is None
19
+
20
+
21
+def test_suppresses_xml_when_enabled():
22
+ assert repair_and_minify('<tool>response</tool>', suppress_xml=True) == "{}"
23
+ assert repair_and_minify('<tool>response</tool>', suppress_xml=False) is None
24
+
25
+
26
+def test_extension_replaces_completed_result(monkeypatch):
27
+ monkeypatch.setattr(
28
+ "plugins._context_doctor.extensions.python.message_loop_result._10_context_doctor.get_plugin_config",
29
+ lambda *args, **kwargs: {"suppress_xml": True},
30
+ )
31
+ llm_result = SimpleNamespace(
32
+ response='{"tool_name":"response","tool_args":{"text":"ok",},}'
33
+ )
34
+ agent = SimpleNamespace(loop_data=SimpleNamespace(params_temporary={}))
35
+
36
+ ContextDoctor(agent).execute({"llm_result": llm_result})
37
+
38
+ assert llm_result.response == '{"tool_name":"response","tool_args":{"text":"ok"}}'
39
+
40
+
41
+def test_updates_log_with_repaired_tool_call():
42
+ log_item = SimpleNamespace(update=lambda **kwargs: setattr(log_item, "data", kwargs))
43
+
44
+ update_log_item(
45
+ SimpleNamespace(agent_name="A0"),
46
+ log_item,
47
+ '{"headline":"Done","tool_name":"response","tool_args":{"text":"ok"}}',
48
+ )
49
+
50
+ assert log_item.data["content"] == (
51
+ '{"headline":"Done","tool_name":"response","tool_args":{"text":"ok"}}'
52
+ )
53
+ assert log_item.data["heading"] == "A0: Done"
54
+
55
+
56
+def test_extension_updates_log_only_when_enabled(monkeypatch):
57
+ llm_result = SimpleNamespace(
58
+ response='{"tool_name":"response","tool_args":{"text":"ok",},}'
59
+ )
60
+ log_item = SimpleNamespace(update=lambda **kwargs: setattr(log_item, "data", kwargs))
61
+ agent = SimpleNamespace(
62
+ agent_name="A0",
63
+ loop_data=SimpleNamespace(params_temporary={"log_item_generating": log_item}),
64
+ )
65
+
66
+ monkeypatch.setattr(
67
+ "plugins._context_doctor.extensions.python.message_loop_result._10_context_doctor.get_plugin_config",
68
+ lambda *args, **kwargs: {"suppress_xml": True, "update_log": False},
69
+ )
70
+ ContextDoctor(agent).execute({"llm_result": llm_result})
71
+ assert not hasattr(log_item, "data")
72
+
73
+ monkeypatch.setattr(
74
+ "plugins._context_doctor.extensions.python.message_loop_result._10_context_doctor.get_plugin_config",
75
+ lambda *args, **kwargs: {"suppress_xml": True, "update_log": True},
76
+ )
77
+ ContextDoctor(agent).execute({"llm_result": llm_result})
78
+ assert log_item.data["content"] == llm_result.response
plugins/_context_doctor/webui/config.html
new
+45
@@ -0,0 +1,45 @@
1
+<html>
2
+ <head>
3
+ <title>Context Doctor Settings</title>
4
+ </head>
5
+ <body>
6
+ <div x-data>
7
+ <template x-if="config">
8
+ <div>
9
+ <div class="section-title">Context Doctor</div>
10
+ <div class="section">
11
+ <div class="section-title">Debug</div>
12
+ <div class="field">
13
+ <div class="field-label">
14
+ <div class="field-title">Update log content</div>
15
+ <div class="field-description">
16
+ Replace the View Details content shown in the WebUI log with repaired JSON instead of model response.
17
+ </div>
18
+ </div>
19
+ <div class="field-control">
20
+ <label class="toggle">
21
+ <input type="checkbox" x-model="config.update_log" />
22
+ <span class="toggler"></span>
23
+ </label>
24
+ </div>
25
+ </div>
26
+ <div class="field">
27
+ <div class="field-label">
28
+ <div class="field-title">Suppress XML output</div>
29
+ <div class="field-description">
30
+ Replace malformed XML-like output without JSON object in cases where model uses native XML tool calls.
31
+ </div>
32
+ </div>
33
+ <div class="field-control">
34
+ <label class="toggle">
35
+ <input type="checkbox" x-model="config.suppress_xml" />
36
+ <span class="toggler"></span>
37
+ </label>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </template>
43
+ </div>
44
+ </body>
45
+</html>
plugins/_context_doctor/webui/thumbnail.webp
Binary files /dev/null and b/plugins/_context_doctor/webui/thumbnail.webp differ
requirements.txt
+1
@@ -12,6 +12,7 @@ flaredantic==0.1.5
12
GitPython==3.1.43
13
giturlparse==0.14.0
14
inputimeout==1.0.4
15
+json_repair==0.63.3
16
kokoro>=0.9.2
17
simpleeval==1.0.3
18
langchain-core==0.3.49