Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

788 lines
32 KiB
Python

#!/usr/bin/env python3
"""Per-task_type schema validator for eliza training corpora.
Handles two row shapes: the canonical `eliza_native_v1` boundary record
(routed to validate_native_v1) and the legacy flat ElizaRecord intermediate
(routed per metadata.task_type, below). See docs/dataset/CANONICAL_RECORD.md.
`scripts/lib/eliza_record.py:is_valid()` only enforces the FLOOR (top-level
fields present, non-empty content). This script enforces the CEILING — what
the eliza runtime actually parses for each `metadata.task_type`:
reply plain-text expectedResponse, REPLY+IGNORE in
availableActions, currentMessage role in
{user, assistant}.
should_respond_with_context JSON expectedResponse decoding to one of
(alias: routing / {RESPOND, IGNORE, STOP}, with RESPOND+
should_respond) IGNORE+STOP in availableActions.
tool_call JSON expectedResponse with `tool_calls` field
holding ≥1 `{name, arguments}` entry; the
chosen action MUST be TASK_CALL or the tool
name itself.
shell_command JSON with `command` field; SHELL must
be in availableActions.
agent_trace (planning) JSON envelope: `thought` + `actions`,
each action with a `name`. When
`simple: true` the actions list MUST be
exactly one entry (REPLY-only / single).
mcp_tool_call / mcp_routing /
claude_distill / reasoning_cot
Permissive — non-empty expectedResponse +
task_type matches the record's declared
token. (Distillation reasoning is shaped
by upstream; we don't fight it.)
Schema-agnostic checks apply to ALL records:
- No `"Reply to the user."` literal in `thought:` field.
- No `"Call the tool to satisfy the request."` literal in `thought:`.
- availableActions is non-empty (except `claude_distill`, which is
intentionally empty per `lib/adapters.py:CLAUDE_DISTILL_SYSTEM`).
- currentMessage.content non-empty.
- metadata.source_dataset in the known-source allowlist (built from
`datasets.yaml` + `lib/adapters.py:REGISTRY`).
- Routing actions are uppercase (`RESPOND` not `respond`). DATASET_REVIEW
flagged scambench has lowercase action bug.
Output:
data/synthesized/review/format_validation.json with `total_records`,
`valid_records`, per-task_type and per-source error histograms, and
the first 50 failing records (record_id, task_type, error, fix_hint).
CLI:
uv run python scripts/validate_corpus.py \\
--input data/final/train.jsonl \\
--report data/synthesized/review/format_validation.json \\
[--strict] [--max-records N]
Exit codes:
0 — no errors (or --strict not set and report written)
1 — --strict and ≥1 invalid record
2 — input file missing / unreadable
"""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Iterable
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Canonical action vocabulary (mirrors lib/eliza_record.py).
ACTION_RESPOND = "RESPOND"
ACTION_IGNORE = "IGNORE"
ACTION_STOP = "STOP"
ACTION_REPLY = "REPLY"
ACTION_TASK_CALL = "TASK_CALL"
ACTION_SHELL = "SHELL"
ROUTING_ACTIONS = {ACTION_RESPOND, ACTION_IGNORE, ACTION_STOP}
# DATASET_REVIEW.md-flagged default-thought leaks. Single source of truth
# lives in scripts/lib/eliza_record.DEFAULT_THOUGHT_LEAKS — re-import here
# so the validator and the adapters scrub the same set.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.lib.eliza_record import DEFAULT_THOUGHT_LEAKS # noqa: E402
from scripts.lib.native_record import ( # noqa: E402
FORMAT as ELIZA_NATIVE_FORMAT,
validate_native_record,
)
PRIVACY_ATTESTATION_SCHEMA = "eliza.privacy_filter_attestation.v1"
PRIVACY_ATTESTATION_VERSION = 1
# Stale action names that must not appear in fresh corpus rows (renamed/removed
# in the runtime — see config/eliza1_action_aliases.json and action-docs.ts).
_STALE_ACTION_NAMES = {
"RUN_SKILL_SCRIPT": "USE_SKILL", "GET_SKILL_GUIDANCE": "USE_SKILL",
"SPAWN_AGENT": "TASKS", "SEND_TO_AGENT": "TASKS", "STOP_AGENT": "TASKS",
"TASK_CONTROL": "TASKS", "TASK_HISTORY": "TASKS", "TASK_SHARE": "TASKS",
"TASK_CALL": "TASKS", "SHELL_COMMAND": "SHELL",
}
def _native_tool_name(call: dict) -> str | None:
if not isinstance(call, dict):
return None
fn = call.get("function") if isinstance(call.get("function"), dict) else {}
name = call.get("toolName") or call.get("name") or fn.get("name")
return name if isinstance(name, str) else None
def _as_dict(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _privacy_attestation_candidates(record: dict[str, Any]) -> list[dict[str, Any]]:
metadata = _as_dict(record.get("metadata"))
candidates: list[dict[str, Any]] = []
for value in (
record.get("privacyAttestation"),
record.get("privacy_attestation"),
metadata.get("privacy_attestation"),
metadata.get("privacyAttestation"),
metadata.get("privacy"),
record.get("privacy"),
):
if isinstance(value, dict):
candidates.append(value)
return candidates
def _has_privacy_attestation(record: dict[str, Any]) -> bool:
for attestation in _privacy_attestation_candidates(record):
privacy = _as_dict(attestation.get("privacy"))
if (
attestation.get("schema") == PRIVACY_ATTESTATION_SCHEMA
and attestation.get("version") == PRIVACY_ATTESTATION_VERSION
and attestation.get("passed") is True
and (
attestation.get("reviewed") is True
or attestation.get("redacted") is True
or privacy.get("reviewed") is True
)
):
return True
return False
def native_content_hash(rec: dict[str, Any]) -> str | None:
if rec.get("format") != ELIZA_NATIVE_FORMAT:
return None
request = rec.get("request")
response = rec.get("response")
if not isinstance(request, dict) or not isinstance(response, dict):
return None
payload = {
"request": request,
"response": response,
"tools": request.get("tools"),
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8", "replace")
return hashlib.sha256(encoded).hexdigest()
def validate_native_v1(rec: dict) -> list[tuple[str, str]]:
"""Validator for canonical `eliza_native_v1` corpus rows (the runtime
generateText boundary shape; see docs/dataset/CANONICAL_RECORD.md)."""
errs: list[tuple[str, str]] = []
ok, why = validate_native_record(rec)
if not ok:
errs.append(("native_v1_invalid_shape", why))
return errs
has_privacy_attestation = _has_privacy_attestation(rec)
if not has_privacy_attestation:
errs.append((
"native_v1_missing_privacy_attestation",
f"eliza_native_v1 rows must carry {PRIVACY_ATTESTATION_SCHEMA} "
f"v{PRIVACY_ATTESTATION_VERSION} before training",
))
resp = rec.get("response") if isinstance(rec.get("response"), dict) else {}
for call in resp.get("toolCalls") or []:
name = _native_tool_name(call)
if name and name in _STALE_ACTION_NAMES:
errs.append(("native_v1_stale_action",
f"response tool call {name!r} is removed/renamed — "
f"use {_STALE_ACTION_NAMES[name]!r} "
"(config/eliza1_action_aliases.json)"))
# Confirm it renders to a training example.
if has_privacy_attestation:
try:
from scripts.format_for_training import format_record # noqa: PLC0415
rendered = format_record(rec)
if not rendered or not rendered.get("messages"):
errs.append(("native_v1_unrenderable",
"format_record() produced no messages"))
except Exception as e: # noqa: BLE001
errs.append(("native_v1_render_error", repr(e)))
return errs
# task_types treated as `should_respond_with_context` for validation purposes.
ROUTING_TASK_TYPES = {"should_respond_with_context", "should_respond",
"context_routing", "routing"}
# task_types that are intentionally permissive (downstream-shape preserving).
PERMISSIVE_TASK_TYPES = {
"mcp_tool_call", "mcp_routing", "claude_distill", "reasoning_cot",
"scam_defense", "n8n_workflow_generation", "media_description",
"reflection", "reflection_evaluator", "abliteration_harmful",
"abliteration_harmless", "mobile_action",
}
# `claude_distill` legitimately ships with availableActions=[]; everything
# else must have at least one action.
EMPTY_ACTIONS_OK = {"claude_distill"}
def _load_source_allowlist() -> set[str]:
"""Build the allowlist of `metadata.source_dataset` values from
`datasets.yaml` slugs + adapter REGISTRY keys."""
sources: set[str] = set()
yaml_path = ROOT / "datasets.yaml"
if yaml_path.exists():
slug_re = re.compile(r"^\s*-\s+slug:\s*([A-Za-z0-9_\-]+)\s*$")
for line in yaml_path.read_text().splitlines():
m = slug_re.match(line)
if m:
sources.add(m.group(1))
try:
from scripts.lib.adapters import REGISTRY # noqa: WPS433
sources.update(REGISTRY.keys())
except Exception:
pass
# Local-only / synthesis-only sources that don't appear in REGISTRY but
# are emitted by `scripts/synthesize_*.py`.
sources.update({
"scambench", "synthesized_should_respond",
"synthesized_routing", "synthesized_action_planner",
"synthesized_action_pairs", "synthesized_core_prompts",
"synthesized_messaging", "synthesized_commerce",
"synthesized_music", "synthesized_web3", "synthesized_system",
"synthesized_agent_orch", "synthesized_reasoning",
"synthesized_multiparty",
})
return sources
SOURCE_ALLOWLIST: set[str] = _load_source_allowlist()
# ──────────────────────── decode helpers ────────────────────────
def _try_decode_payload(text: str) -> tuple[bool, Any, str]:
"""Structured decode for native v5 JSON expectedResponse values.
Returns `(ok, value, error)`.
"""
if not isinstance(text, str) or not text.strip():
return False, None, "empty"
stripped = text.strip()
if stripped.startswith("{") or stripped.startswith("["):
try:
return True, json.loads(stripped), ""
except json.JSONDecodeError as e:
return False, None, str(e)[:200]
return False, None, "not_json"
def _action_names(actions: list[Any]) -> list[str]:
"""availableActions can ride as bare strings OR `{name, description}`
dicts. Adapters mostly emit strings; the schema validator accepts both."""
out: list[str] = []
for a in actions or []:
if isinstance(a, str):
out.append(a)
elif isinstance(a, dict) and "name" in a:
n = a.get("name")
if isinstance(n, str):
out.append(n)
return out
def _planner_actions(decoded: Any) -> list[dict[str, Any]] | None:
"""Pull the `actions:` array out of a decoded planner envelope.
Both shapes are valid:
`{thought, actions: [{name, ...}, ...], providers, text, simple}`
`{thought, actions: ["NAME", ...]}` (rare but adapters allow it)
"""
if not isinstance(decoded, dict):
return None
actions = decoded.get("actions")
if not isinstance(actions, list):
return None
out: list[dict[str, Any]] = []
for a in actions:
if isinstance(a, dict):
out.append(a)
elif isinstance(a, str):
out.append({"name": a})
return out
# ──────────────────────── per-task_type validators ────────────────────────
# Each validator returns a list of `(error_code, fix_hint)` tuples. An empty
# list means the record is valid for that task_type. The errors flow back to
# the caller which adds the `record_id` + `task_type` and aggregates.
def validate_reply(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
actions = set(_action_names(rec.get("availableActions", [])))
if ACTION_REPLY not in actions:
errs.append(("reply_missing_REPLY_action",
"adapter must include REPLY in availableActions for task_type=reply"))
if ACTION_IGNORE not in actions:
errs.append(("reply_missing_IGNORE_action",
"adapter must include IGNORE in availableActions for task_type=reply"))
role = (rec.get("currentMessage") or {}).get("role")
if role not in ("user", "assistant"):
errs.append(("reply_currentMessage_role_invalid",
f"currentMessage.role={role!r}, must be 'user' or 'assistant'"))
return errs
def validate_routing(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
actions = set(_action_names(rec.get("availableActions", [])))
missing = ROUTING_ACTIONS - actions
if missing:
errs.append((
"routing_missing_action",
f"task_type={rec.get('metadata', {}).get('task_type')} requires "
f"{sorted(ROUTING_ACTIONS)} in availableActions; missing {sorted(missing)}",
))
expected = (rec.get("expectedResponse") or "").strip()
if not expected:
errs.append(("routing_empty_expectedResponse",
"expectedResponse is empty"))
return errs
# Two acceptable shapes: a bare token "RESPOND"/"IGNORE"/"STOP", or a
# native JSON document with `action:` carrying the token (LIGHT/MultiLIGHT).
if expected in ROUTING_ACTIONS:
return errs
if isinstance(decoded, dict):
action_field = decoded.get("action")
if isinstance(action_field, str) and action_field.upper() in ROUTING_ACTIONS:
if action_field != action_field.upper():
errs.append(("routing_action_lowercase",
f"action={action_field!r} must be uppercase"))
return errs
errs.append((
"routing_decoded_no_action_field",
"native JSON document for routing must have `action: RESPOND|IGNORE|STOP`",
))
return errs
errs.append((
"routing_expectedResponse_not_decision",
f"expectedResponse must be one of RESPOND/IGNORE/STOP "
f"(got {expected[:50]!r})",
))
return errs
def validate_tool_call(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
if decoded is None:
errs.append(("tool_call_invalid_payload",
"expectedResponse failed to native JSON-decode"))
return errs
if not isinstance(decoded, dict):
errs.append(("tool_call_decoded_not_object",
f"decoded native JSON is {type(decoded).__name__}, not an object"))
return errs
# Two valid shapes:
# 1. `{tool_calls: [{name, arguments}, ...]}` — the spec preference.
# 2. Planner envelope where the actions[] entries are TASK_CALL with
# `params.tool` carrying the function name (the most common
# shape, emitted by `_planner_tool_envelope`).
tool_calls = decoded.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
bad = [
i for i, c in enumerate(tool_calls)
if not (isinstance(c, dict) and isinstance(c.get("name"), str)
and c.get("name", "").strip())
]
if bad:
errs.append((
"tool_call_entry_missing_name",
f"tool_calls[{','.join(str(i) for i in bad[:3])}] missing 'name' string",
))
else:
planner_actions = _planner_actions(decoded) or []
tool_actions = [a for a in planner_actions
if a.get("name") in (ACTION_TASK_CALL, "TOOL_CALL")
or "params" in a and isinstance(a.get("params"), dict)
and a["params"].get("tool")]
if not tool_actions:
errs.append((
"tool_call_no_tool_calls_or_TASK_CALL",
"tool_call task expects either `tool_calls: [{name, arguments}]` "
"or planner `actions: [{name: TASK_CALL, params: {tool, arguments}}]`",
))
actions = set(_action_names(rec.get("availableActions", [])))
if not actions:
errs.append(("tool_call_empty_availableActions",
"availableActions must be non-empty for tool_call"))
md = rec.get("metadata", {})
if not md.get("toolSpecs") and not md.get("tools"):
# Soft warning surfaced under a distinct error code so the report can
# split it out from hard failures.
errs.append(("tool_call_no_toolSpecs_metadata_warning",
"metadata.toolSpecs (or .tools) absent — model has no schema "
"for the available tools"))
return errs
def validate_shell_command(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
if decoded is None:
errs.append(("shell_invalid_payload",
"expectedResponse failed to native JSON-decode"))
return errs
if not isinstance(decoded, dict):
errs.append(("shell_decoded_not_object",
f"decoded native JSON is {type(decoded).__name__}, not an object"))
return errs
# Either a top-level `command: ...` or a planner envelope whose canonical
# SHELL action carries `params.command`.
command = decoded.get("command")
if not (isinstance(command, str) and command.strip()):
planner_actions = _planner_actions(decoded) or []
cmd_action = next((a for a in planner_actions
if a.get("name") == ACTION_SHELL), None)
if cmd_action is None:
errs.append(("shell_missing_command_field",
"shell_command task needs `command:` or "
"planner `actions: [{name: SHELL, params: {command}}]`"))
else:
params = cmd_action.get("params") or {}
if not (isinstance(params, dict)
and isinstance(params.get("command"), str)
and params.get("command", "").strip()):
errs.append(("shell_action_missing_params_command",
"SHELL action needs params.command non-empty"))
actions = set(_action_names(rec.get("availableActions", [])))
if ACTION_SHELL not in actions:
errs.append(("shell_missing_SHELL_action",
"availableActions must contain SHELL for task_type=shell_command"))
return errs
def validate_agent_trace(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
if decoded is None:
errs.append(("agent_trace_invalid_payload",
"expectedResponse failed to native JSON-decode"))
return errs
if not isinstance(decoded, dict):
errs.append(("agent_trace_decoded_not_object",
f"decoded native JSON is {type(decoded).__name__}, not an object"))
return errs
if not isinstance(decoded.get("thought"), str):
errs.append(("agent_trace_missing_thought",
"planner envelope needs `thought:` (string)"))
actions = decoded.get("actions")
if not isinstance(actions, list) or not actions:
errs.append(("agent_trace_missing_actions",
"planner envelope needs `actions:` array with ≥1 entry"))
else:
for i, a in enumerate(actions):
name: Any = a.get("name") if isinstance(a, dict) else a
if not (isinstance(name, str) and name.strip()):
errs.append(("agent_trace_action_missing_name",
f"actions[{i}] missing or empty `name`"))
break
if decoded.get("simple") is True and len(actions) != 1:
errs.append((
"agent_trace_simple_with_multiple_actions",
f"`simple: true` envelopes must carry exactly 1 action; got {len(actions)}",
))
return errs
def validate_permissive(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
"""For mcp_tool_call / mcp_routing / claude_distill / reasoning_cot etc:
only require non-empty expectedResponse and the declared task_type."""
errs: list[tuple[str, str]] = []
if not (rec.get("expectedResponse") or "").strip():
errs.append(("permissive_empty_expectedResponse",
"expectedResponse must be non-empty"))
return errs
# Dispatch table — `metadata.task_type` → validator function. Aliases collapse
# (`routing` and `should_respond` both behave like
# `should_respond_with_context`).
TASK_TYPE_VALIDATORS = {
"reply": validate_reply,
"should_respond_with_context": validate_routing,
"should_respond": validate_routing,
"routing": validate_routing,
"context_routing": validate_routing,
"tool_call": validate_tool_call,
"shell_command": validate_shell_command,
"agent_trace": validate_agent_trace,
"mcp_tool_call": validate_permissive,
"mcp_routing": validate_permissive,
"claude_distill": validate_permissive,
"reasoning_cot": validate_permissive,
"scam_defense": validate_permissive,
"n8n_workflow_generation": validate_permissive,
"media_description": validate_permissive,
"reflection": validate_permissive,
"reflection_evaluator": validate_permissive,
"abliteration_harmful": validate_permissive,
"abliteration_harmless": validate_permissive,
"mobile_action": validate_permissive,
}
# ──────────────────────── schema-agnostic checks ────────────────────────
def _extract_thought(rec: dict, decoded: Any | None) -> str | None:
"""Pull a candidate `thought:` string out of the decoded envelope or the
raw expectedResponse."""
if isinstance(decoded, dict):
t = decoded.get("thought")
if isinstance(t, str):
return t
raw = rec.get("expectedResponse") or ""
if isinstance(raw, str) and "<thought>" in raw:
m = re.search(r"<thought>(.*?)</thought>", raw, re.DOTALL)
if m:
return m.group(1).strip()
return None
def schema_agnostic_checks(rec: dict, decoded: Any | None) -> list[tuple[str, str]]:
errs: list[tuple[str, str]] = []
md = rec.get("metadata") or {}
task_type = md.get("task_type") or ""
source = md.get("source_dataset") or ""
# 1. default-thought leaks (DATASET_REVIEW.md TL;DR)
thought = _extract_thought(rec, decoded)
if thought:
for leak in DEFAULT_THOUGHT_LEAKS:
if thought.strip() == leak:
errs.append((
"default_thought_leak",
f"thought == {leak!r} verbatim — adapter pool fix required "
"(see scripts/lib/adapters.py:_REPLY_THOUGHT_POOL)",
))
break
# 2. availableActions non-empty (claude_distill is intentionally empty)
actions = rec.get("availableActions") or []
if not actions and task_type not in EMPTY_ACTIONS_OK:
errs.append((
"empty_availableActions",
f"task_type={task_type} requires non-empty availableActions",
))
# 3. currentMessage.content non-empty (eliza_record.is_valid catches this
# too, but we want a per-task_type rollup)
cm = rec.get("currentMessage") or {}
if not (isinstance(cm, dict) and cm.get("content")):
errs.append(("empty_currentMessage_content",
"currentMessage.content must be a non-empty string"))
# 4. source_dataset is in the known-source allowlist
if source and source not in SOURCE_ALLOWLIST:
errs.append((
"unknown_source_dataset",
f"metadata.source_dataset={source!r} not in datasets.yaml or "
"lib/adapters.py:REGISTRY — register the source or fix the adapter",
))
# 5. Routing action casing — DATASET_REVIEW.md flagged scambench has
# lowercase actions. Apply this to every record.
for a in _action_names(actions):
if a in {"respond", "ignore", "stop", "reply"}:
errs.append((
"lowercase_routing_action",
f"availableActions contains {a!r} — must be uppercase {a.upper()!r} "
"(see DATASET_REVIEW.md scambench bug)",
))
break
return errs
# ──────────────────────── orchestration ────────────────────────
def validate_record(rec: dict) -> list[tuple[str, str]]:
"""Top-level dispatcher. Returns aggregated errors; empty list = valid."""
# Canonical `eliza_native_v1` rows (the runtime generateText boundary shape)
# have their own validator — they don't carry the flat-ElizaRecord fields
# (roomName/currentMessage/expectedResponse/availableActions).
if rec.get("format") == ELIZA_NATIVE_FORMAT:
return validate_native_v1(rec)
md = rec.get("metadata") or {}
task_type = md.get("task_type") or ""
expected = rec.get("expectedResponse") or ""
# Decode the JSON expectedResponse once for task_types that need structured
# validation — validators read from `decoded` rather than re-decoding.
decoded: Any | None = None
if task_type in {"tool_call", "shell_command", "agent_trace",
"should_respond_with_context", "should_respond",
"context_routing", "routing"} or task_type.startswith("mobile_"):
ok, value, _err = _try_decode_payload(expected)
decoded = value if ok else None
errs = schema_agnostic_checks(rec, decoded)
validator = TASK_TYPE_VALIDATORS.get(task_type)
if validator is None:
if task_type:
errs.append((
"unknown_task_type",
f"metadata.task_type={task_type!r} has no validator — add to "
"scripts/validate_corpus.py:TASK_TYPE_VALIDATORS",
))
else:
errs.append(("missing_task_type",
"metadata.task_type is missing or empty"))
return errs
errs.extend(validator(rec, decoded))
return errs
def _record_id(rec: dict, line_no: int) -> str:
md = rec.get("metadata") or {}
return md.get("id") or rec.get("roomName") or f"line:{line_no}"
def iter_records(path: Path, max_records: int | None) -> Iterable[tuple[int, dict]]:
with path.open("r", encoding="utf-8", errors="strict") as f:
for i, line in enumerate(f, start=1):
if max_records is not None and i > max_records:
return
line = line.strip()
if not line:
continue
try:
yield i, json.loads(line)
except json.JSONDecodeError as e:
yield i, {"_parse_error": str(e), "_raw": line[:200]}
def run(input_path: Path, report_path: Path, *, strict: bool,
max_records: int | None) -> int:
if not input_path.exists():
print(f"FAIL: input not found: {input_path}", file=sys.stderr)
return 2
total = 0
valid = 0
err_by_task: dict[str, Counter] = defaultdict(Counter)
err_by_source: dict[str, Counter] = defaultdict(Counter)
failing: list[dict[str, Any]] = []
seen_content_hashes: dict[str, int] = {}
for line_no, rec in iter_records(input_path, max_records):
total += 1
if "_parse_error" in rec:
err_by_task["__parse__"]["json_parse_error"] += 1
err_by_source["__parse__"]["json_parse_error"] += 1
if len(failing) < 50:
failing.append({
"record_id": f"line:{line_no}",
"task_type": "__parse__",
"error": "json_parse_error",
"fix_hint": rec["_parse_error"],
})
if strict:
_emit(report_path, total, valid, err_by_task, err_by_source, failing)
return 1
continue
errs = validate_record(rec)
dedup_hash = native_content_hash(rec)
if dedup_hash is not None:
first_line = seen_content_hashes.get(dedup_hash)
if first_line is not None:
hint = (
"duplicate eliza_native_v1 training boundary; first seen "
f"on line {first_line}; duplicates line {first_line} "
f"(sha256={dedup_hash}). Re-run "
"prepare_eliza1_trajectory_dataset.py with dedup enabled."
)
errs.append((
"duplicate_native_content",
hint,
))
errs.append((
"duplicate_content_hash",
hint,
))
else:
seen_content_hashes[dedup_hash] = line_no
if not errs:
valid += 1
continue
md = rec.get("metadata") or {}
task_type = md.get("task_type") or "__none__"
source = md.get("source_dataset") or "__none__"
for code, hint in errs:
err_by_task[task_type][code] += 1
err_by_source[source][code] += 1
if len(failing) < 50:
failing.append({
"record_id": _record_id(rec, line_no),
"task_type": task_type,
"source_dataset": source,
"error": code,
"fix_hint": hint,
})
if strict:
_emit(report_path, total, valid, err_by_task, err_by_source, failing)
return 1
_emit(report_path, total, valid, err_by_task, err_by_source, failing)
return 0 if total == valid else (1 if strict else 0)
def _emit(report_path: Path, total: int, valid: int,
err_by_task: dict[str, Counter],
err_by_source: dict[str, Counter],
failing: list[dict[str, Any]]) -> None:
report = {
"total_records": total,
"valid_records": valid,
"invalid_records": total - valid,
"errors_by_task_type": {k: dict(v) for k, v in err_by_task.items()},
"errors_by_source": {k: dict(v) for k, v in err_by_source.items()},
"first_50_failing_records": failing[:50],
}
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False))
# Also surface a one-line summary to stderr for CI greppability.
print(
f"[validate_corpus] total={total} valid={valid} invalid={total - valid} "
f"report={report_path}",
file=sys.stderr,
)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--input", required=True, type=Path,
help="path to a JSONL file of eliza records")
p.add_argument("--report", required=True, type=Path,
help="output path for the JSON validation report")
p.add_argument("--strict", action="store_true",
help="exit 1 on the first invalid record")
p.add_argument("--max-records", type=int, default=None,
help="cap number of records scanned (debugging only)")
args = p.parse_args(argv)
return run(args.input, args.report, strict=args.strict,
max_records=args.max_records)
if __name__ == "__main__":
sys.exit(main())