* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule The PLUGIN-COMPAT layer (2776813df3+d63e380324+0a5164cebe) kept pre-#102117 import paths alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader has already been skipping plugins that use the old paths. This removes the layer itself: - 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party names, restored dead definitions) and the three re-export stub modules (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils) - COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step - the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section, the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the `plugins.allow_deprecated_imports` escape hatch An external plugin that still imports an old path now fails to load with its ImportError as the reason in `hermes plugins list`, the same path as any broken plugin. hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect, summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout swap (tests/compat/old_updater_surface.json). In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its `shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed `run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the seam production reads, like every sibling test in that file. * chore: retrigger CI (zero-job startup_failure phantom) * test: drop resolution allowlist rows for the two deleted which() sites hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available (a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires their allowlist entries go with them.
116 lines
5.2 KiB
Python
116 lines
5.2 KiB
Python
"""Structured-output schema helpers for delegate_task.
|
|
|
|
Optional per-task ``output_schema`` (a JSON Schema object): the child gets an
|
|
OUTPUT CONTRACT block appended to its context, the parent validates the final
|
|
answer with jsonschema, and on failure sends exactly ONE bounded retry turn
|
|
carrying the validation errors verbatim (more retries make frontier models
|
|
drop fields that were right the first time; the schema is never re-pasted).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def coerce_output_schema(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
"""``(schema, None)`` when usable, ``(None, error)`` when not; ``None`` input
|
|
passes through as ``(None, None)`` (no schema requested)."""
|
|
if raw is None:
|
|
return None, None
|
|
if isinstance(raw, str):
|
|
# Models sometimes double-encode the schema as a JSON string.
|
|
try:
|
|
raw = json.loads(raw)
|
|
except (ValueError, TypeError):
|
|
return None, "output_schema must be a JSON Schema object, got a non-JSON string."
|
|
if not isinstance(raw, dict):
|
|
return None, "output_schema must be a JSON Schema object."
|
|
if not isinstance(raw, dict):
|
|
return None, f"output_schema must be a JSON Schema object, got {type(raw).__name__}."
|
|
try:
|
|
from jsonschema.validators import validator_for # type: ignore[import-untyped]
|
|
validator_for(raw).check_schema(raw)
|
|
except ImportError:
|
|
# Degrade to accepting the dict as-is so delegation still works without jsonschema.
|
|
logger.debug("jsonschema unavailable; skipping output_schema meta-validation")
|
|
except Exception as exc:
|
|
return None, f"output_schema is not a valid JSON Schema: {exc}"
|
|
return raw, None
|
|
|
|
|
|
def append_output_contract(context: Optional[str], schema: Dict[str, Any]) -> str:
|
|
"""Append the explicit output contract block to a child's context."""
|
|
try:
|
|
schema_text = json.dumps(schema, indent=2, ensure_ascii=False)
|
|
except (TypeError, ValueError):
|
|
schema_text = str(schema)
|
|
block = ("OUTPUT CONTRACT (machine-validated):\n"
|
|
"Your FINAL response must be ONLY the JSON value that validates against this JSON "
|
|
"Schema — no prose before or after it, no code fence, no explanation. Anything else "
|
|
"costs a correction turn and, if it fails again, is handed to the caller unvalidated.\n"
|
|
f"{schema_text}")
|
|
base = (context or "").rstrip()
|
|
return f"{base}\n\n{block}" if base else block
|
|
|
|
|
|
def extract_json_candidate(text: str) -> str:
|
|
"""Strip markdown fences and prose around the outermost ``{...}``/``[...]``."""
|
|
raw = (text or "").strip()
|
|
if raw.startswith("```"):
|
|
raw = raw.split("\n", 1)[-1]
|
|
if raw.rstrip().endswith("```"):
|
|
raw = raw.rstrip()[: -3]
|
|
raw = raw.strip()
|
|
if raw.lower().startswith("json\n"):
|
|
raw = raw.split("\n", 1)[1]
|
|
# Try each bracket kind's outermost span, earliest opener first, and keep the first that parses:
|
|
# checking "{" before "[" unconditionally sliced a fenced array down to its first..last object and
|
|
# rejected every valid array answer.
|
|
spans = []
|
|
for opener, closer in (("{", "}"), ("[", "]")):
|
|
start, end = raw.find(opener), raw.rfind(closer)
|
|
if start >= 0 and end > start:
|
|
spans.append((start, raw[start : end + 1]))
|
|
for _start, candidate in sorted(spans):
|
|
try:
|
|
json.loads(candidate)
|
|
return candidate
|
|
except ValueError:
|
|
continue
|
|
return spans[0][1] if spans else raw
|
|
|
|
|
|
def validate_output(text: str, schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
|
|
"""``(True, [])`` or ``(False, errors)`` with strings suitable for the retry turn."""
|
|
candidate = extract_json_candidate(text or "")
|
|
if not candidate.strip():
|
|
return False, ["Response was empty — expected a JSON object matching the schema."]
|
|
try:
|
|
parsed = json.loads(candidate)
|
|
except (ValueError, TypeError) as exc:
|
|
return False, [f"Response is not valid JSON: {exc}"]
|
|
try:
|
|
from jsonschema.validators import validator_for # type: ignore[import-untyped]
|
|
except ImportError:
|
|
logger.debug("jsonschema unavailable; accepting parsed JSON without validation")
|
|
return True, []
|
|
validator = validator_for(schema)(schema)
|
|
errors = sorted(validator.iter_errors(parsed), key=lambda e: list(e.absolute_path))
|
|
rendered = [ # bound error volume for the retry prompt
|
|
"$" + "".join(f"[{p}]" if isinstance(p, int) else f".{p}" for p in err.absolute_path) + f": {err.message}"
|
|
for err in errors[:10]]
|
|
return not rendered, rendered
|
|
|
|
|
|
def build_retry_message(errors: List[str]) -> str:
|
|
"""Single bounded retry turn: errors verbatim, schema deliberately NOT re-pasted."""
|
|
error_block = "\n".join(f"- {e}" for e in errors)
|
|
return ("Your previous final response was rejected by the output contract "
|
|
"validator. Validation errors:\n" f"{error_block}\n\n"
|
|
"Reply with ONLY the corrected JSON object matching the OUTPUT "
|
|
"CONTRACT schema from your task context. No prose, no explanations.")
|