Files
hermes-agent/tools/delegation_output_schema.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

117 lines
5.1 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 a single JSON object that validates "
"against this JSON Schema. No prose before or after the JSON; a "
"```json code fence is acceptable but not required.\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]
for opener, closer in (("{", "}"), ("[", "]")):
if raw.startswith(opener):
return raw
start = raw.find(opener)
end = raw.rfind(closer)
if start >= 0 and end > start:
return raw[start : end + 1]
return 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.")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
MAX_SCHEMA_RETRIES = 1
# ---- END PLUGIN-COMPAT ----