Install Context Doctor dependency after self-update
Read the pinned json_repair requirement from the root requirements file and install it into the active framework interpreter when the package is missing or stale. Invoke the plugin hook during startup migration so self-updated installations converge without rebuilding the image. Add focused coverage for version checks, install command construction, and startup dispatch.
Alessandro committed
Aug 25, 2026 at 23:40 UTC
519f2c2a62418f446ce0eee2a1ba2abd221d2934
4 files changed
+164
plugins/_context_doctor/AGENTS.md
+3
@@ -7,6 +7,8 @@
7
## Ownership
8
9
- `helpers/context_doctor.py` transforms output and refreshes log fields.
10
+- `hooks.py` installs the exact root-pinned repair dependency in the framework runtime.
11
+- `extensions/python/startup_migration/` prepares that dependency after startup and self-update.
12
- `extensions/python/message_loop_result/` normalizes completed model output before default processing.
13
- `webui/config.html` exposes XML suppression and log-detail settings.
14
@@ -16,6 +18,7 @@
18
- Nonempty non-tool output becomes `{"thoughts":[raw]}`; XML-like output becomes `{}` only when suppression is enabled.
19
- Log kvps and heading always reflect transformed output; `update_log` controls only View Details content.
20
- A repaired `response` tool call refreshes the response log item when streaming did not create it.
21
+- Runtime setup reads the `json_repair` pin from root `requirements.txt`; do not duplicate its version in plugin code.
22
23
## Work Guidance
24
plugins/_context_doctor/extensions/python/startup_migration/_20_context_doctor_runtime.py
new
+11
@@ -0,0 +1,11 @@
1
+from helpers.extension import Extension
2
+from helpers.plugins import call_plugin_hook
3
+
4
+
5
+class ContextDoctorRuntime(Extension):
6
+ def execute(self, **kwargs):
7
+ call_plugin_hook(
8
+ "_context_doctor",
9
+ "ensure_dependencies",
10
+ raise_on_error=False,
11
+ )
plugins/_context_doctor/hooks.py
new
+81
@@ -0,0 +1,81 @@
1
+from __future__ import annotations
2
+
3
+import importlib
4
+import importlib.metadata
5
+import importlib.util
6
+import shutil
7
+import subprocess
8
+import sys
9
+import threading
10
+from pathlib import Path
11
+
12
+from helpers.errors import format_error
13
+from helpers.print_style import PrintStyle
14
+
15
+
16
+_LOCK = threading.Lock()
17
+_PLUGIN_DIR = Path(__file__).resolve().parent
18
+_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
19
+
20
+
21
+def ensure_dependencies(raise_on_error: bool = True) -> bool:
22
+ """Install the pinned framework-runtime dependency when needed."""
23
+ with _LOCK:
24
+ try:
25
+ requirement = _json_repair_requirement()
26
+ if _json_repair_is_current(requirement):
27
+ return True
28
+
29
+ uv = shutil.which("uv")
30
+ if not uv:
31
+ raise RuntimeError(
32
+ "Context Doctor plugin requires 'uv' to install json_repair automatically"
33
+ )
34
+
35
+ PrintStyle.info(
36
+ "Context Doctor: installing pinned json_repair dependency"
37
+ )
38
+ subprocess.check_call(
39
+ [uv, "pip", "install", "--python", sys.executable, requirement],
40
+ cwd=str(_PLUGIN_DIR),
41
+ )
42
+ importlib.invalidate_caches()
43
+ if not _json_repair_is_current(requirement):
44
+ raise RuntimeError(
45
+ f"Context Doctor dependency {requirement!r} is unavailable after installation"
46
+ )
47
+ return True
48
+ except Exception as exc:
49
+ message = (
50
+ "Context Doctor: failed to install json_repair dependency: "
51
+ f"{format_error(exc)}"
52
+ )
53
+ if raise_on_error:
54
+ raise RuntimeError(message) from exc
55
+ PrintStyle.error(message)
56
+ return False
57
+
58
+
59
+def install() -> bool:
60
+ return ensure_dependencies(raise_on_error=True)
61
+
62
+
63
+def _json_repair_requirement() -> str:
64
+ if _ROOT_REQUIREMENTS_FILE.is_file():
65
+ for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
66
+ requirement = line.strip()
67
+ if requirement.startswith("json_repair=="):
68
+ return requirement
69
+ raise RuntimeError(
70
+ f"Context Doctor pinned json_repair requirement not found in {_ROOT_REQUIREMENTS_FILE}"
71
+ )
72
+
73
+
74
+def _json_repair_is_current(requirement: str) -> bool:
75
+ expected_version = requirement.partition("==")[2]
76
+ if not expected_version or importlib.util.find_spec("json_repair") is None:
77
+ return False
78
+ try:
79
+ return importlib.metadata.version("json-repair") == expected_version
80
+ except importlib.metadata.PackageNotFoundError:
81
+ return False
plugins/_context_doctor/tests/test_hooks.py
new
+69
@@ -0,0 +1,69 @@
1
+import sys
2
+
3
+from plugins._context_doctor import hooks
4
+from plugins._context_doctor.extensions.python.startup_migration import (
5
+ _20_context_doctor_runtime as startup_runtime,
6
+)
7
+
8
+
9
+def test_dependency_check_requires_root_pinned_version(monkeypatch):
10
+ requirement = hooks._json_repair_requirement()
11
+ expected_version = requirement.partition("==")[2]
12
+
13
+ monkeypatch.setattr(hooks.importlib.util, "find_spec", lambda _name: object())
14
+ monkeypatch.setattr(
15
+ hooks.importlib.metadata, "version", lambda _name: expected_version
16
+ )
17
+ assert hooks._json_repair_is_current(requirement)
18
+
19
+ monkeypatch.setattr(
20
+ hooks.importlib.metadata, "version", lambda _name: f"{expected_version}.stale"
21
+ )
22
+ assert not hooks._json_repair_is_current(requirement)
23
+
24
+
25
+def test_dependency_hook_installs_root_pinned_requirement(monkeypatch):
26
+ requirement = hooks._json_repair_requirement()
27
+ checks = iter((False, True))
28
+ calls = []
29
+
30
+ monkeypatch.setattr(
31
+ hooks, "_json_repair_is_current", lambda _candidate: next(checks)
32
+ )
33
+ monkeypatch.setattr(hooks.shutil, "which", lambda _command: "/usr/local/bin/uv")
34
+ monkeypatch.setattr(
35
+ hooks.subprocess,
36
+ "check_call",
37
+ lambda command, cwd: calls.append((command, cwd)),
38
+ )
39
+
40
+ assert requirement.startswith("json_repair==")
41
+ assert hooks.ensure_dependencies()
42
+ assert calls == [
43
+ (
44
+ [
45
+ "/usr/local/bin/uv",
46
+ "pip",
47
+ "install",
48
+ "--python",
49
+ sys.executable,
50
+ requirement,
51
+ ],
52
+ str(hooks._PLUGIN_DIR),
53
+ )
54
+ ]
55
+
56
+
57
+def test_startup_migration_calls_dependency_hook(monkeypatch):
58
+ calls = []
59
+ monkeypatch.setattr(
60
+ startup_runtime,
61
+ "call_plugin_hook",
62
+ lambda *args, **kwargs: calls.append((args, kwargs)),
63
+ )
64
+
65
+ startup_runtime.ContextDoctorRuntime(None).execute()
66
+
67
+ assert calls == [
68
+ (("_context_doctor", "ensure_dependencies"), {"raise_on_error": False})
69
+ ]