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