Files
hermes-agent/tools/delegation_output_schema.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* 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.
2026-09-28 10:21:41 -07:00

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.")