Skip to content

Commit 7f5407f

Browse files
qazbnm456claude
andcommitted
feat(tools): make_json_schema_validator — validate a parsed object against a JSON Schema
The generic base for the "validate against an official, vendored, version-pinned upstream JSON schema" pattern: a consumer vendors the schema file + a refresh script and layers its own bespoke checks on top; the kit owns only the validator wiring. Given a JSON Schema (dict or path), returns a function that maps a parsed object to a list of violation messages ("<path>: <reason>", truncated at max_errors so a huge invalid doc can't flood the trace). Parsing stays the consumer's job, so it composes with any extract/parse step. `jsonschema` is an OPTIONAL dependency (rlm-kit[jsonschema]), imported lazily so `import rlm_kit` and the dspy-free tools package stay lean. Consumer-driven: a downstream consumer was hand-rolling structural gates that drift from the real upstream format; this is the reusable half of moving to authoritative schema validation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 571f6f6 commit 7f5407f

6 files changed

Lines changed: 120 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ surfaced by dogfooding a real downstream consumer.
1212

1313
### Added
1414

15+
- **`make_json_schema_validator` — validate a parsed object against a JSON Schema (draft 2020-12).**
16+
The generic base for the "validate against an official, vendored, version-pinned upstream JSON
17+
schema" pattern: a consumer vendors the schema file + a refresh script (the provider-specific half)
18+
and layers its own bespoke checks on top; the kit owns only the validator wiring. Returns the
19+
violation messages (`"<path>: <reason>"`, truncated at `max_errors` so a huge invalid doc can't
20+
flood the trace) for a parsed dict — parsing stays the consumer's job, so it composes with any
21+
extract/parse step. `jsonschema` is an OPTIONAL dependency (`rlm-kit[jsonschema]`), imported lazily
22+
so `import rlm_kit` and the dspy-free `tools` package stay lean. Consumer-driven: a downstream
23+
consumer was hand-rolling structural gates that drift from the real upstream format; this is the
24+
reusable half of moving to authoritative schema validation.
1525
- **`get_sub_lm` promoted to the public surface** (`rlm_kit.get_sub_lm`; lazy re-export, keeps
1626
`import rlm_kit` dspy-free). Returns the base sub-LM `configure` built — the instance a consumer
1727
wraps with `intercept_sub_lm` before passing as `RLMTask(sub_lm=...)`. Consumer-driven: TWO

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Deno sandbox (`brew install deno`) — the logic and tests run without either.
6767
| `task.py` | `RLMTask` base class. |
6868
| `_retry.py` | Validation + retry engine (dspy-free, unit-tested). |
6969
| `sandbox.py` | Interpreter selection + the insecure-sandbox guard. |
70-
| `tools/` | `make_schema_validator`, SSRF-guarded `make_fetch_tool`, provider-agnostic `make_web_search_tool`, and `make_model_tool` — the generic "model-as-tool + transient-retry + validate" core (a project wraps it with its own endpoint/validator/messages). |
70+
| `tools/` | `make_schema_validator` (pydantic) + `make_json_schema_validator` (validate a parsed object against a vendored JSON Schema — the base for the "validate against an official, version-pinned upstream schema" pattern; needs `rlm-kit[jsonschema]`), SSRF-guarded `make_fetch_tool`, provider-agnostic `make_web_search_tool`, and `make_model_tool` — the generic "model-as-tool + transient-retry + validate" core (a project wraps it with its own endpoint/validator/messages). |
7171
| `optimize.py` | GEPA harness — metric templates now, compile in Phase 2. |
7272
| `sub_lm.py` | `intercept_sub_lm` — wrap the RLM's sub-LM to trace every escalation as a `sub_call` (+ optional validate/post-process); `model_as_tool` for LM-decided multi-model routing. |
7373
| `skills.py` | `load_skills_as_tools` — expose a Skills directory to the RLM as tools. |

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ observe = [
4242
mcp = [
4343
"mcp>=1.0",
4444
]
45+
# JSON-Schema validation (make_json_schema_validator): validate a parsed object against a
46+
# vendored, version-pinned upstream JSON schema. Opt-in — the core scaffold runs without it.
47+
jsonschema = [
48+
"jsonschema>=4.0",
49+
]
4550

4651
[project.urls]
4752
Homepage = "https://github.com/qazbnm456/rlm-kit"

rlm_kit/tools/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@
88
)
99
from .model import ModelToolResult, make_model_tool
1010
from .search import make_web_search_tool, normalise_search_results
11-
from .validation import make_schema_validator
11+
from .validation import make_json_schema_validator, make_schema_validator
1212

1313
__all__ = [
1414
"make_schema_validator",
15+
"make_json_schema_validator",
1516
"is_safe_url",
1617
"resolved_host_is_safe",
1718
"parse_cidrs",

rlm_kit/tools/validation.py

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,73 @@
1-
"""Schema-validation tool factory.
1+
"""Schema-validation tool factories.
22
3-
Produces a plain callable the RLM can invoke inside the REPL to check its draft
4-
JSON against the expected pydantic schema before emitting a final answer — the
5-
generalised form of the original app's ``validate_vulnerability_report``.
3+
Two shapes, both consumer-facing base primitives:
4+
5+
- ``make_schema_validator(model)`` — a plain callable the RLM invokes inside the REPL to
6+
check its draft JSON against a pydantic schema before emitting a final answer (returns a
7+
human message). The generalised form of the original app's ``validate_vulnerability_report``.
8+
- ``make_json_schema_validator(schema)`` — validate a PARSED object against a JSON Schema
9+
(draft 2020-12) and return the violation messages, the generic base for the "validate
10+
against a vendored, version-pinned upstream JSON schema" pattern (a consumer vendors the
11+
schema + a refresh script; the kit owns the validator wiring).
612
"""
713

814
from __future__ import annotations
915

10-
from typing import Callable, Type
16+
import json
17+
import os
18+
from typing import Callable, Type, Union
1119

1220
from pydantic import BaseModel
1321

1422

23+
def make_json_schema_validator(
24+
schema: Union[dict, str, os.PathLike],
25+
*,
26+
max_errors: int = 20,
27+
) -> Callable[[object], list[str]]:
28+
"""Return a function that validates a PARSED object against a JSON Schema and returns a
29+
list of human-readable violation messages (empty list = valid).
30+
31+
``schema`` is the JSON Schema as a dict, or a path to a ``.json`` schema file (read once,
32+
at factory time). The returned validator takes an already-parsed object (a dict from
33+
``yaml.safe_load`` / ``json.loads``) — parsing is the consumer's job, so this composes with
34+
whatever extract/parse step precedes it. Each message is ``"<json/pointer/path>: <reason>"``
35+
(root violations use ``"(root)"``); the list is truncated to ``max_errors`` (a huge invalid
36+
doc must not flood the trace). Deterministic ordering (by error path) so traces are stable.
37+
38+
This is the GENERIC base for the "validate against an official, vendored, version-pinned
39+
upstream JSON schema" pattern: a consumer vendors the schema file + a refresh script (the
40+
provider-specific half), and layers its own bespoke checks on top; the kit owns only this
41+
wiring. ``jsonschema`` is an OPTIONAL dependency (``rlm-kit[jsonschema]``) — imported lazily
42+
so ``import rlm_kit`` and the dspy-free ``tools`` package stay lean.
43+
"""
44+
try:
45+
from jsonschema import Draft202012Validator
46+
except ImportError as exc: # pragma: no cover - exercised only without the extra installed
47+
raise ImportError(
48+
"make_json_schema_validator needs the optional 'jsonschema' dependency. "
49+
"Install it with: pip install 'rlm-kit[jsonschema]'"
50+
) from exc
51+
52+
if isinstance(schema, (str, os.PathLike)):
53+
with open(schema, encoding="utf-8") as fh:
54+
schema = json.load(fh)
55+
validator = Draft202012Validator(schema)
56+
57+
def validate(obj: object) -> list[str]:
58+
"""Validate a parsed object against the JSON schema; return violation messages ([] = ok)."""
59+
errors: list[str] = []
60+
for err in sorted(validator.iter_errors(obj), key=lambda e: list(e.absolute_path)):
61+
loc = "/".join(str(p) for p in err.absolute_path) or "(root)"
62+
errors.append(f"{loc}: {err.message}")
63+
if len(errors) >= max_errors:
64+
errors.append(f"… (schema errors truncated at {max_errors})")
65+
break
66+
return errors
67+
68+
return validate
69+
70+
1571
def make_schema_validator(model: Type[BaseModel]) -> Callable[[str], str]:
1672
"""Return a tool that validates a JSON string against ``model``.
1773

tests/test_tools.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515
from rlm_kit.tools.model import ModelToolResult, make_model_tool
1616
from rlm_kit.tools.search import make_web_search_tool, normalise_search_results
17-
from rlm_kit.tools.validation import make_schema_validator
17+
from rlm_kit.tools.validation import make_json_schema_validator, make_schema_validator
1818
from rlm_kit.trace import EVENT_TOOL_CALL, TraceRecorder, load_events
1919

2020

@@ -144,6 +144,46 @@ def test_schema_validator_reports_failure():
144144
assert "failed" in v('{"title": "t"}').lower()
145145

146146

147+
# ---- JSON-schema validator (make_json_schema_validator) -------------------
148+
149+
_JSON_SCHEMA = {
150+
"type": "object",
151+
"properties": {
152+
"id": {"type": "string"},
153+
"severity": {"enum": ["info", "low", "medium", "high", "critical"]},
154+
},
155+
"required": ["id", "severity"],
156+
}
157+
158+
159+
def test_json_schema_validator_passes_a_valid_object():
160+
v = make_json_schema_validator(_JSON_SCHEMA)
161+
assert v({"id": "x", "severity": "high"}) == [] # [] == valid
162+
163+
164+
def test_json_schema_validator_reports_each_violation_with_a_path():
165+
v = make_json_schema_validator(_JSON_SCHEMA)
166+
errs = v({"severity": "spicy"}) # missing id + bad enum
167+
assert any("id" in e for e in errs) # required-field violation
168+
assert any("severity" in e and "spicy" in e for e in errs) # located on the bad field
169+
170+
171+
def test_json_schema_validator_loads_schema_from_a_path(tmp_path):
172+
import json as _json
173+
p = tmp_path / "schema.json"
174+
p.write_text(_json.dumps(_JSON_SCHEMA))
175+
v = make_json_schema_validator(str(p))
176+
assert v({"id": "x", "severity": "high"}) == []
177+
assert v({"id": 1, "severity": "high"}) # id wrong type → non-empty
178+
179+
180+
def test_json_schema_validator_truncates_a_flood_of_errors():
181+
schema = {"type": "object", "additionalProperties": {"type": "string"}}
182+
v = make_json_schema_validator(schema, max_errors=3)
183+
errs = v({f"k{i}": i for i in range(50)}) # 50 int values → 50 violations
184+
assert len(errs) == 4 and "truncated" in errs[-1] # 3 + the truncation marker
185+
186+
147187
# ---- SSRF guard ----------------------------------------------------------
148188

149189
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)