mvanhorn/last30days-skill · error · HandoffContractError

Angles file {path} must carry a top-level \"angles\" list.

Error message

Angles file {path} must carry a top-level \"angles\" list.

What it means

Raised by the angles loader in discovery_handoff.py when an angles file is supplied (path is not None), passes the top-level-object and bundle-binding checks, but its 'angles' key is missing or not a JSON list. Passing path=None is the supported way to run without angles (empty mapping, topics ship without angles) — only a supplied file with the wrong shape raises. Per-row handling is lenient, mirroring the judgments loader; angle sentences are word-boundary capped at 200 chars downstream.

Source

Thrown at skills/last30days/scripts/lib/discovery_handoff.py:818

    """Read the host angles file for leg 3, keyed by nomination id.

    ``bundle`` is the binding target: the finalize leg passes the pending
    report (the bundle_id echo validates against it, and the known ids are
    its surviving ``angle_inputs`` ids), while a NominationsBundle binds
    against the full pool. A missing angles file is legal: ``path=None``
    returns an empty mapping and every topic ships without angles. When a
    path is given the same strict-top-level / lenient-per-row rules as
    judgments apply; angle sentences are word-boundary capped at 200 chars.
    """
    if path is None:
        return {}
    payload = _load_host_file(path, "angles")
    _require_bundle_binding(
        payload, bundle, label="angles", save_dir=save_dir, config_dir=config_dir,
    )
    rows = payload.get("angles")
    if not isinstance(rows, list):
        raise HandoffContractError(
            f"Angles file {path} must carry a top-level \"angles\" list."
        )
    known = (
        set(bundle.angle_inputs)
        if isinstance(bundle, PendingReport)
        else {entry.nomination_id for entry in bundle.nominations}
    )
    angles: dict[str, HostAngles] = {}
    for row_id, row in _known_rows(
        rows, known, row_label="angles", unknown_label="angles"
    ):
        podcast = _sanitized_angle(row.get("podcast"))
        x_article = _sanitized_angle(row.get("x_article"))
        if podcast is None and x_article is None:
            # No usable hook at all: treat the row as absent.
            continue
        angles[row_id] = HostAngles(podcast=podcast, x_article=x_article)
    return angles

View on GitHub (pinned to c7460f6114)

Solutions

  1. Use "angles": [...] as the top-level list key in the angles file.
  2. If you do not want angles for this leg, pass path=None / omit the angles file instead of shipping an empty wrong-shaped file.
  3. Verify the file loads and has the key: assert isinstance(json.load(open(p)).get("angles"), list).

Example fix

// before
{
  "bundle_id": "...",
  "angle": [{"nomination_id": "n1", "podcast": "..."}]
}

// after
{
  "bundle_id": "...",
  "angles": [{"nomination_id": "n1", "podcast": "..."}]
}
Defensive patterns

Strategy: validation

Validate before calling

import json

def valid_angles_file(path: str) -> bool:
    payload = json.loads(open(path, encoding="utf-8").read())
    return isinstance(payload, dict) and isinstance(payload.get("angles"), list)

Try / catch

try:
    load_angles(path, bundle, ...)
except HandoffContractError as e:
    # fix the "angles" key on the host side; or pass path=None to skip angles entirely
    ...

Prevention

When it happens

Trigger: Passing an angles file with key 'angle' (singular), 'suggestions', or omitting the key; "angles": {} instead of []; reusing a judgments file as the angles file (it has 'judgments', not 'angles').

Common situations: The host writes the file from memory of a similar schema; singular/plural confusion; the file was templated from the judgments example.

Related errors


AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15). Data as JSON: /api/errors/6106bc51314a4cb0. Report an issue: GitHub.