oc_chat
Loading...
Searching...
No Matches
dependency_healer.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/dependency_healer.py
4
5"""
6Self-healing dependency handler.
7
8Purpose
9-------
10Intercept ``missing_dependency`` errors from tool calls and present an
11interactive menu so the user can install the missing package or skip.
12Lets the agent recover from optional-dep failures (e.g. a tool needs
13``httpx`` but it isn't installed) without restarting the REPL. (The
14legacy "custom command" ``shell=True`` option was removed in M8.0 WU-3d.)
15
16Responsibilities
17----------------
18- :func:`detect_missing_dependency` — recognise the structured
19 ``{"missing_dependency": ...}`` shape returned by tools.
20- :func:`show_dependency_menu` — render the install / skip / custom
21 prompt and read the user's choice.
22- :func:`handle_dependency_issue` — orchestrate detection + menu +
23 optional ``pip install`` retry of the failing tool.
24
25Usage from the ReAct loop (``main.py``)::
26
27 from oc_chat.core.dependency_healer import handle_dependency_issue
28 result = handle_dependency_issue(result, presenter, tool_registry, tc)
29
30Diagnostics
31-----------
32Domain: OC_CHAT.TOOLS
33Levels:
34 L2 — lifecycle events
35 L3 — semantic details
36 L4 — deep tracing
37
38Contracts
39---------
40- ``detect_missing_dependency`` returns ``None`` when the result does
41 not match the structured shape.
42- The handler never raises on a failed install — it surfaces the
43 error to the user and falls back to ``skip``.
44- ``pip install`` runs in a child process via :mod:`subprocess`; the
45 REPL's environment is not modified outside that call.
46
47Dependencies
48------------
49- oc_diagnostics (structured debug logging via _dbg)
50- oc_chat.core._paths (lazy; workspace-root fallback when no manifest sits beside the package)
51- oc_ui.presenter_base (lazy; Presenter type for the install menu and status messages)
52- pip (not imported; python -m pip install of the missing package in a child process)
53"""
54from __future__ import annotations
55
56import json
57import subprocess
58import sys
59from pathlib import Path
60from typing import TYPE_CHECKING
61
62from oc_diagnostics import _dbg
63
64if TYPE_CHECKING:
65 from oc_ui.presenter_base import Presenter
66
67# ── Detection ───────────────────────────────────────────────────────────────
68
69_MISSING_DEP_KEY = "missing_dependency"
70
71
72def detect_missing_dependency(result: dict) -> dict | None:
73 """If *result* contains a structured missing-dependency error, return the
74 error sub-dict, otherwise return ``None``."""
75 if isinstance(result, dict) and _MISSING_DEP_KEY in result:
76 return {
77 "package": result[_MISSING_DEP_KEY],
78 "pip_install": result.get("pip_install", result[_MISSING_DEP_KEY]),
79 "optional_group": result.get("optional_group"),
80 "error": result.get("error", "Missing dependency."),
81 }
82 return None
83
84
85# ── Interactive Menu ────────────────────────────────────────────────────────
86
87def show_dependency_menu(presenter: Presenter, dep_info: dict) -> str:
88 """Show an interactive menu and return the user's choice.
89
90 Returns ``"install"`` or ``"continue"`` (M8.0 WU-3d removed the custom-command
91 ``shell=True`` option per LD-4).
92 """
93 pkg = dep_info["package"]
94 group = dep_info["optional_group"]
95
96 print(f"\n{'─' * 60}")
97 print(f" ⚠ Missing Dependency: {pkg}")
98 print(f"{'─' * 60}")
99 if group:
100 print(f" The {group} feature requires '{pkg}'.")
101 else:
102 print(f" The current tool requires '{pkg}'.")
103 print(f"\n Options:")
104 print(f" 1) Install '{pkg}' via pip + update project files")
105 print(f" 2) Continue without (report error to model)")
106 print(f"{'─' * 60}")
107
108 while True:
109 try:
110 choice = input(" Your choice (1-2) [1]: ").strip()
111 except (EOFError, KeyboardInterrupt):
112 return "continue"
113
114 if not choice:
115 return "install"
116 if choice == "1":
117 return "install"
118 if choice == "2":
119 return "continue"
120 # The legacy "3) custom command" option (subprocess shell=True) was
121 # removed in M8.0 WU-3d (LD-4: no shell=True path).
122 print(f" Invalid choice '{choice}'. Enter 1 or 2.")
123
124
125# ── Installation ────────────────────────────────────────────────────────────
126
127def _project_root() -> Path:
128 """Locate the OC Chat workspace root that holds ``requirements*.txt``.
129
130 Defers to :func:`oc_chat.core._paths.find_workspace_root` so the
131 lookup is install-survivable: it walks up from this module's path
132 rather than depending on the caller's CWD, and skips past the
133 per-package ``oc_chat/pyproject.toml`` (D.1c) to the repo root that
134 actually holds the manifest files.
135
136 Falls back to ``Path.cwd()`` only if the walk fails (e.g. running
137 from a wheel install with no source tree present), in which case
138 manifest updates simply skip files that don't exist.
139
140 Plan 151 T2 step 11(c) — BOUNDED subproject-root resolution first (the
141 session_log V23 pattern): post-monorepo-migration ``find_workspace_root()``
142 returns the OUTERMOST ``.git`` (the option_c monorepo root), which holds
143 NO ``requirements*.txt``/``pyproject.toml`` — the healer would silently
144 degrade. The manifests live at the oc_chat SUBPROJECT root (the parent of
145 the ``oc_chat`` package dir); one adjacency check, no walk, then the old
146 resolver as fallback.
147 """
148 pkg_parent = Path(__file__).resolve().parent.parent.parent
149 if (pkg_parent / "requirements.txt").is_file() or (
150 pkg_parent / "pyproject.toml"
151 ).is_file():
152 return pkg_parent
153 from oc_chat.core._paths import find_workspace_root
154 try:
155 return find_workspace_root()
156 except FileNotFoundError:
157 return Path.cwd().resolve()
158
159
160_PROJECT_ROOT = _project_root()
161
162
163def install_dependency(dep_info: dict) -> bool:
164 """Install the missing package via pip. Returns ``True`` on success."""
165 package = dep_info["pip_install"]
166 print(f" Installing '{package}'...")
167 try:
168 subprocess.run(
169 [sys.executable, "-m", "pip", "install", package],
170 check=True,
171 capture_output=False,
172 )
173 print(f" ✓ Successfully installed '{package}'.")
174 return True
175 except subprocess.CalledProcessError as exc:
176 print(f" ✗ pip install failed (return code {exc.returncode}).")
177 return False
178
179
180# ── Manifest Updates ────────────────────────────────────────────────────────
181
182def update_manifest_files(dep_info: dict) -> dict:
183 """Add the missing package to project dependency manifests where missing.
184
185 Returns ``{"updated": [filenames...], "errors": [...]}``.
186 """
187 pkg = dep_info["package"]
188 group = dep_info.get("optional_group")
189 updated = []
190 errors = []
191
192 # 1. pyproject.toml — ensure package is in optional-dependencies
193 pyproject = _PROJECT_ROOT / "pyproject.toml"
194 if pyproject.exists():
195 try:
196 content = pyproject.read_text(encoding="utf-8")
197 if pkg not in content:
198 if group:
199 marker = f"{group} = ["
200 if marker in content:
201 lines = content.split("\n")
202 new_lines = []
203 added = False
204 for i, line in enumerate(lines):
205 new_lines.append(line)
206 if not added and marker in line:
207 next_line = lines[i + 1] if i + 1 < len(lines) else ""
208 if pkg not in "".join(lines[i:]):
209 new_lines.append(f' "{pkg}",')
210 added = True
211 if not added:
212 # Group not found; append to optional-dependencies
213 new_lines.append(f'{group} = [')
214 new_lines.append(f' "{pkg}",')
215 new_lines.append(']')
216 content = "\n".join(new_lines)
217 else:
218 content += f'\n{group} = [\n "{pkg}",\n]\n'
219 pyproject.write_text(content, encoding="utf-8")
220 updated.append("pyproject.toml")
221 except Exception as e:
222 errors.append(f"pyproject.toml: {e}")
223
224 # 2. requirements-all.txt — ensure package is present (uncommented)
225 req_all = _PROJECT_ROOT / "requirements-all.txt"
226 if req_all.exists():
227 try:
228 lines = req_all.read_text(encoding="utf-8").split("\n")
229 changed = False
230 found = False
231 new_lines = []
232 for line in lines:
233 stripped = line.strip()
234 if stripped.lstrip("#").strip() == pkg and stripped.startswith("#"):
235 new_lines.append(pkg)
236 changed = True
237 found = True
238 else:
239 new_lines.append(line)
240 if stripped == pkg:
241 found = True
242 if not found:
243 new_lines.append(pkg)
244 changed = True
245 if changed:
246 req_all.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
247 updated.append("requirements-all.txt")
248 except Exception as e:
249 errors.append(f"requirements-all.txt: {e}")
250
251 # 3. requirements.txt — ensure package is present (uncommented)
252 req_txt = _PROJECT_ROOT / "requirements.txt"
253 if req_txt.exists():
254 try:
255 lines = req_txt.read_text(encoding="utf-8").split("\n")
256 changed = False
257 found = False
258 new_lines = []
259 for line in lines:
260 stripped = line.strip()
261 if stripped.lstrip("#").strip() == pkg and stripped.startswith("#"):
262 new_lines.append(f"{pkg} # {group or 'optional'}")
263 changed = True
264 found = True
265 else:
266 new_lines.append(line)
267 if stripped == pkg or stripped.startswith(f"{pkg} #") or stripped.startswith(f"{pkg} #"):
268 found = True
269 if not found:
270 new_lines.append(f"{pkg} # {group or 'optional'}")
271 changed = True
272 if changed:
273 req_txt.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
274 updated.append("requirements.txt")
275 except Exception as e:
276 errors.append(f"requirements.txt: {e}")
277
278 return {"updated": updated, "errors": errors}
279
280
281# ── Orchestrator ────────────────────────────────────────────────────────────
282
283def handle_dependency_issue(result: dict, presenter: Presenter, tool_registry: dict | None = None, tc: dict | None = None) -> dict:
284 """Check if *result* reports a missing dependency and, if so, offer an
285 interactive repair menu.
286
287 Args:
288 result: The raw tool result dict.
289 presenter: A :class:`~oc_ui.presenter_base.Presenter` instance.
290 tool_registry: The ``{name: callable}`` registry (needed for re-execution).
291 tc: The original tool-call dict (needed for re-execution).
292
293 Returns:
294 The (possibly re-executed) tool result.
295 """
296 func = "handle_dependency_issue"
297 dep_info = detect_missing_dependency(result)
298 if dep_info is None:
299 return result # No dependency issue
300 _dbg("OC_CHAT.TOOLS", func,
301 f"missing={dep_info.get('package')} tc={tc.get('name') if tc else '-'}", 2)
302
303 # Show the dep info via the presenter
304 presenter.show_info(f"⚠ Missing dependency: {dep_info['error']}")
305
306 choice = show_dependency_menu(presenter, dep_info)
307
308 if choice == "continue":
309 return result # Return original error to the model
310
311 if choice == "install":
312 ok = install_dependency(dep_info)
313 if not ok:
314 presenter.show_error("Installation failed. Continuing with original error.")
315 return result
316 update_result = update_manifest_files(dep_info)
317 if update_result.get("updated"):
318 presenter.show_info(f"Updated: {', '.join(update_result['updated'])}")
319 if update_result.get("errors"):
320 presenter.show_error(f"Manifest update warnings: {', '.join(update_result['errors'])}")
321
322 # Re-execute the tool if we have the registry and tool-call info
323 if tool_registry is not None and tc is not None:
324 tool_name = tc["name"]
325 tool_fn = tool_registry.get(tool_name)
326 if tool_fn:
327 presenter.show_info(f"Re-executing '{tool_name}'...")
328 try:
329 args = json.loads(tc["arguments"] or "{}")
330 if isinstance(args, dict):
331 return tool_fn(**args)
332 else:
333 return tool_fn(args)
334 except Exception as e:
335 return {"error": f"Tool re-execution failed after dependency install: {e}"}
336 return {"error": "Dependency installed. Please retry the operation."}
337
338 # M8.0 WU-3d (LD-4): the legacy "custom command" option ran
339 # subprocess.run(choice, shell=True) -- an arbitrary-shell ACE path. There is
340 # no shell=True lane anywhere in M8; the menu now offers only install/continue,
341 # so any other choice is treated as continue (never executed).
342 return result
str show_dependency_menu(Presenter presenter, dict dep_info)
bool install_dependency(dict dep_info)
dict|None detect_missing_dependency(dict result)
dict handle_dependency_issue(dict result, Presenter presenter, dict|None tool_registry=None, dict|None tc=None)
dict update_manifest_files(dict dep_info)