Skip to content

Commit 3ebdd69

Browse files
MaximeRivestclaude
andcommitted
refactor(adapters): JSONAdapter on the engine; Chat->JSON fallback guard unified
- _engine/formats/json.py: JSONFormat subclasses ChatFormat, mirroring the legacy class relationship — the marker-based user side is inherited; the JSON schema sentence, assistant/demo JSON rendering (json.dumps(serialize_for_json(...), indent=2, ensure_ascii=False)), output requirements, and parsing are overridden. Parse reproduced bug-for-bug including the asymmetry with chat: per-field parse_value failures propagate RAW (legacy JSONAdapter has no try/except around coercion; the orchestration-level fallback catches them), while not-a-JSON-object and incomplete-fields raise AdapterParseError with adapter_name='JSONAdapter'. Recursive-regex object extraction and json_repair behavior preserved verbatim. - JSONAdapter registered engine-backed; BAMLAdapter (overriding subclass) automatically stays legacy until its own migration. JSONAdapter.parse gains its own engine branch (it overrides parse, so ChatAdapter's branch is not inherited). - The Chat->JSON fallback guard is implemented once (_should_reraise_instead_of_fallback) for the sync and async wrappers; semantics identical, pinned by the fallback corpus fixtures. - Deviation from the epic, with rationale: the structured-output decision tree stays in json_adapter.py as orchestration rather than becoming a _PlanStep — its json_object retry is TWO-CALL control flow, which the v1 IR excludes by design; moving only the happy path would split one decision tree across layers. strategy.py therefore lands in QC-10 with its true first consumers (tools/citations/reasoning strategies), per the land-with-first-consumer rule. - Two stale stack-test expectations updated for the new migration state (JSONAdapter now engine-backed; fallback-constructed instances resolve JSONFormat). Verification: JSON request + parse corpora dual-run (engine AND forced-legacy, both equal to pre-engine fixtures); 100 JSON parse fuzz cases (fenced/prose-wrapped/truncated/non-JSON) with zero divergence; chat corpus re-proven unchanged under the shared machinery; BAML corpus unchanged through its automatic legacy routing. Full battery: 176 fixtures reproduce exactly; engine suite 709 passed; streaming/callback/ predict/teleprompt green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent eb84d00 commit 3ebdd69

8 files changed

Lines changed: 321 additions & 19 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""JSONFormat: the second engine personality, layered on ChatFormat.
2+
3+
JSONAdapter inherits ChatAdapter's user-side rendering (marker headers) and
4+
overrides the schema sentence, the assistant/demo side (JSON objects), and
5+
parsing — so JSONFormat subclasses ChatFormat and overrides exactly those
6+
surfaces, mirroring the legacy class relationship.
7+
8+
Parse semantics preserved bug-for-bug, including the asymmetry with chat:
9+
per-field ``parse_value`` failures propagate RAW here (legacy JSONAdapter
10+
has no try/except around coercion — the orchestration-level fallback
11+
machinery catches them), while the not-a-JSON-object and incomplete-fields
12+
cases raise ``AdapterParseError`` with ``adapter_name="JSONAdapter"``.
13+
14+
The structured-output decision tree (response_format selection and the
15+
json_object retry) deliberately stays in json_adapter.py: it is two-call
16+
ORCHESTRATION, and control flow is excluded from the v1 IR by design.
17+
"""
18+
19+
import json
20+
from typing import Any
21+
22+
import json_repair
23+
import regex
24+
25+
from dspy.adapters._engine.formats.chat import ChatFormat
26+
from dspy.adapters.types.tool import ToolCalls
27+
from dspy.adapters.utils import (
28+
format_field_value,
29+
get_annotation_name,
30+
parse_value,
31+
serialize_for_json,
32+
translate_field_type,
33+
)
34+
from dspy.utils.exceptions import AdapterParseError
35+
36+
37+
class JSONFormat(ChatFormat):
38+
parse_error_adapter_name = "JSONAdapter"
39+
40+
def render_field_structure(self, signature) -> str:
41+
parts = []
42+
parts.append("All interactions will be structured in the following way, with the appropriate values filled in.")
43+
44+
parts.append("Inputs will have the following structure:")
45+
parts.append(
46+
self.render_fields_with_values(
47+
{
48+
field_name: (field_info, translate_field_type(field_name, field_info))
49+
for field_name, field_info in signature.input_fields.items()
50+
}
51+
)
52+
)
53+
parts.append("Outputs will be a JSON object with the following fields.")
54+
parts.append(
55+
self._render_json_object(
56+
{
57+
field_name: translate_field_type(field_name, field_info)
58+
for field_name, field_info in signature.output_fields.items()
59+
}
60+
)
61+
)
62+
return "\n\n".join(parts).strip()
63+
64+
def output_requirements(self, signature) -> str | None:
65+
def type_info(v):
66+
if v.annotation == ToolCalls:
67+
return ' (must be a JSON object like {"tool_calls": [{"name": "...", "args": {...}}]})'
68+
return (
69+
f" (must be formatted as a valid Python {get_annotation_name(v.annotation)})"
70+
if v.annotation is not str
71+
else ""
72+
)
73+
74+
message = "Respond with a JSON object in the following order of fields: "
75+
message += ", then ".join(f"`{f}`{type_info(v)}" for f, v in signature.output_fields.items())
76+
message += "."
77+
return message
78+
79+
def render_assistant_content(self, signature, outputs: dict[str, Any], missing_field_message=None) -> str:
80+
return self._render_json_object(
81+
{k: outputs.get(k, missing_field_message) for k, v in signature.output_fields.items()}
82+
)
83+
84+
def _render_json_object(self, values: dict[str, Any]) -> str:
85+
return json.dumps(serialize_for_json(values), indent=2, ensure_ascii=False)
86+
87+
def parse(self, signature, completion: str) -> dict[str, Any]:
88+
fields = json_repair.loads(completion)
89+
90+
if not isinstance(fields, dict):
91+
pattern = r"\{(?:[^{}]|(?R))*\}"
92+
match = regex.search(pattern, completion, regex.DOTALL)
93+
if match:
94+
completion = match.group(0)
95+
fields = json_repair.loads(completion)
96+
97+
if not isinstance(fields, dict):
98+
raise AdapterParseError(
99+
adapter_name=self.parse_error_adapter_name,
100+
signature=signature,
101+
lm_response=completion,
102+
message="LM response cannot be serialized to a JSON object.",
103+
)
104+
105+
fields = {k: v for k, v in fields.items() if k in signature.output_fields}
106+
107+
# NOTE: coercion failures propagate raw on purpose (legacy parity);
108+
# see module docstring.
109+
for k, v in fields.items():
110+
if k in signature.output_fields:
111+
fields[k] = parse_value(v, signature.output_fields[k].annotation)
112+
113+
if fields.keys() != signature.output_fields.keys():
114+
raise AdapterParseError(
115+
adapter_name=self.parse_error_adapter_name,
116+
signature=signature,
117+
lm_response=completion,
118+
parsed_result=fields,
119+
)
120+
121+
return fields
122+
123+
# format_field_value is reused via ChatFormat for the user side; keep the
124+
# import referenced for grep-ability of the shared dependency.
125+
_shared_value_renderer = staticmethod(format_field_value)

‎dspy/adapters/_engine/migrated.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,20 @@ def ensure_core_migrations() -> None:
1717

1818
from dspy.adapters._engine.formats import register_format
1919
from dspy.adapters._engine.formats.chat import ChatFormat
20+
from dspy.adapters._engine.formats.json import JSONFormat
2021
from dspy.adapters._engine.overrides import register_engine_backed
2122
from dspy.adapters.chat_adapter import ChatAdapter
23+
from dspy.adapters.json_adapter import JSONAdapter
2224

23-
# QC-05: ChatAdapter request-side rendering.
25+
# QC-05: ChatAdapter rendering + parsing.
2426
register_engine_backed(ChatAdapter)
2527
register_format(ChatAdapter, ChatFormat())
2628

29+
# QC-07: JSONAdapter (BAMLAdapter, its overriding subclass, stays legacy
30+
# automatically until its own migration).
31+
register_engine_backed(JSONAdapter)
32+
register_format(JSONAdapter, JSONFormat())
33+
2734

2835
def _suppress_for_tests() -> None:
2936
"""Mark migrations applied without applying them, so tests control

‎dspy/adapters/_engine/overrides.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393
"_get_tool_call_input_field_name",
9494
"_get_tool_call_output_field_name",
9595
"_json_adapter_call_common",
96+
# Fallback decision: consulted by the orchestration wrappers, which
97+
# run identically around both the engine and legacy pipelines.
98+
"_should_reraise_instead_of_fallback",
9699
}
97100
)
98101

‎dspy/adapters/chat_adapter.py‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ def _make_json_adapter_fallback(self):
7373
parallel_tool_calls=self.parallel_tool_calls,
7474
)
7575

76+
def _should_reraise_instead_of_fallback(self, error: Exception) -> bool:
77+
"""The fallback guard, implemented once for the sync and async paths.
78+
79+
On LM errors, when already a JSONAdapter, or when the fallback is
80+
disabled, the ORIGINAL error propagates instead of retrying with a
81+
different adapter.
82+
"""
83+
from dspy.adapters.json_adapter import JSONAdapter
84+
85+
return isinstance(error, LMError) or isinstance(self, JSONAdapter) or not self.use_json_adapter_fallback
86+
7687
def __call__(
7788
self,
7889
lm: BaseLM,
@@ -84,12 +95,7 @@ def __call__(
8495
try:
8596
return super().__call__(lm, lm_kwargs, signature, demos, inputs)
8697
except Exception as e:
87-
# fallback to JSONAdapter
88-
from dspy.adapters.json_adapter import JSONAdapter
89-
90-
if isinstance(e, LMError) or isinstance(self, JSONAdapter) or not self.use_json_adapter_fallback:
91-
# On LM errors, already using JSONAdapter, or use_json_adapter_fallback is False, we don't want to
92-
# retry with a different adapter. Raise the original error instead of the fallback error.
98+
if self._should_reraise_instead_of_fallback(e):
9399
raise
94100
return self._make_json_adapter_fallback()(lm, lm_kwargs, signature, demos, inputs)
95101

@@ -104,12 +110,7 @@ async def acall(
104110
try:
105111
return await super().acall(lm, lm_kwargs, signature, demos, inputs)
106112
except Exception as e:
107-
# fallback to JSONAdapter
108-
from dspy.adapters.json_adapter import JSONAdapter
109-
110-
if isinstance(e, LMError) or isinstance(self, JSONAdapter) or not self.use_json_adapter_fallback:
111-
# On LM errors, already using JSONAdapter, or use_json_adapter_fallback is False, we don't want to
112-
# retry with a different adapter. Raise the original error instead of the fallback error.
113+
if self._should_reraise_instead_of_fallback(e):
113114
raise
114115
return await self._make_json_adapter_fallback().acall(lm, lm_kwargs, signature, demos, inputs)
115116

‎dspy/adapters/json_adapter.py‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ def _json_adapter_call_common(self, lm, lm_kwargs, signature, demos, inputs, cal
5858

5959
has_tool_calls = any(field.annotation == ToolCalls for field in signature.output_fields.values())
6060

61-
if _has_open_ended_mapping(signature) or (not self.use_native_function_calling and has_tool_calls) or not lm.supports_response_schema:
61+
if (
62+
_has_open_ended_mapping(signature)
63+
or (not self.use_native_function_calling and has_tool_calls)
64+
or not lm.supports_response_schema
65+
):
6266
# We found that structured output mode doesn't work well with dspy.ToolCalls as output field.
6367
# So we fall back to json mode if native function calling is disabled and ToolCalls is present.
6468
lm_kwargs["response_format"] = {"type": "json_object"}
@@ -165,6 +169,18 @@ def format_assistant_message_content(
165169
return self.format_field_with_value(fields_with_values, role="assistant")
166170

167171
def parse(self, signature: type[Signature], completion: str) -> dict[str, Any]:
172+
from dspy.adapters._engine.overrides import resolve_override_verdict
173+
174+
# Engine-backed instances parse via the resolved Format (the same
175+
# object that rendered the request); override-routed instances —
176+
# including BAMLAdapter until it migrates — keep the legacy body.
177+
if resolve_override_verdict(self).engine_eligible:
178+
from dspy.adapters._engine.formats import resolve_format
179+
180+
fmt = resolve_format(self)
181+
if fmt is not None:
182+
return fmt.parse(signature, completion)
183+
168184
fields = json_repair.loads(completion)
169185

170186
if not isinstance(fields, dict):
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""QC-07 gates: JSONAdapter cutover dual-run (request + parse), BAML legacy
2+
routing, shared-machinery regression on chat, and JSON parse fuzzing."""
3+
4+
import json
5+
import random
6+
7+
import pytest
8+
from golden.cases import CASES, case_fixture, execute_case
9+
from golden.generate_fixtures import GOLDEN_DIR, REQUEST_DIR, render_fixture
10+
from golden.harness import canonical_error, canonicalize
11+
from golden.parse_cases import PARSE_CASES, parse_case_fixture
12+
13+
import dspy
14+
from dspy.adapters._engine.formats import resolve_format
15+
from dspy.adapters._engine.formats.json import JSONFormat
16+
from dspy.adapters._engine.overrides import resolve_override_verdict
17+
from dspy.adapters.baml_adapter import BAMLAdapter
18+
from dspy.adapters.json_adapter import JSONAdapter
19+
20+
JSON_REQUEST_CASE_IDS = sorted(case_id for case_id, case in CASES.items() if case.adapter == "json")
21+
JSON_PARSE_CASE_IDS = sorted(case_id for case_id, case in PARSE_CASES.items() if case.adapter == "json")
22+
BAML_REQUEST_CASE_IDS = sorted(case_id for case_id, case in CASES.items() if case.adapter == "baml")
23+
CHAT_REQUEST_CASE_IDS = sorted(case_id for case_id, case in CASES.items() if case.adapter == "chat")
24+
25+
26+
class _ForcedLegacyJSON(JSONAdapter):
27+
"""Pass-through format override routes the whole instance legacy."""
28+
29+
def format(self, signature, demos, inputs):
30+
return super().format(signature, demos, inputs)
31+
32+
33+
def _forced_legacy_factory(case, recorder):
34+
return _ForcedLegacyJSON(**case.adapter_kwargs)
35+
36+
37+
def test_json_adapter_is_engine_backed_with_json_format():
38+
adapter = JSONAdapter()
39+
assert resolve_override_verdict(adapter).engine_eligible
40+
assert isinstance(resolve_format(adapter), JSONFormat)
41+
# ChatAdapter still resolves ChatFormat, not JSONFormat.
42+
from dspy.adapters._engine.formats.chat import ChatFormat
43+
from dspy.adapters.chat_adapter import ChatAdapter
44+
45+
assert type(resolve_format(ChatAdapter())) is ChatFormat
46+
47+
48+
def test_baml_adapter_routes_legacy_and_stays_unexported():
49+
assert not resolve_override_verdict(BAMLAdapter()).engine_eligible
50+
import dspy.adapters
51+
52+
assert not hasattr(dspy.adapters, "BAMLAdapter")
53+
54+
55+
@pytest.mark.parametrize("case_id", JSON_REQUEST_CASE_IDS)
56+
def test_json_request_dual_run_engine_matches_fixture(case_id):
57+
actual = json.loads(render_fixture(case_fixture(CASES[case_id])))
58+
expected = json.loads((REQUEST_DIR / f"{case_id}.json").read_text(encoding="utf-8"))
59+
assert actual == expected
60+
61+
62+
@pytest.mark.parametrize("case_id", JSON_REQUEST_CASE_IDS)
63+
def test_json_request_dual_run_legacy_matches_fixture(case_id):
64+
case = CASES[case_id]
65+
legacy_expected = canonicalize(execute_case(case, adapter_factory=_forced_legacy_factory))
66+
fixture = json.loads((REQUEST_DIR / f"{case_id}.json").read_text(encoding="utf-8"))
67+
assert legacy_expected == fixture["expected"]
68+
69+
70+
@pytest.mark.parametrize("case_id", JSON_PARSE_CASE_IDS)
71+
def test_json_parse_dual_run_engine_matches_fixture(case_id):
72+
actual = json.loads(render_fixture(parse_case_fixture(PARSE_CASES[case_id])))
73+
expected = json.loads((GOLDEN_DIR / "parse" / f"{case_id}.json").read_text(encoding="utf-8"))
74+
assert actual == expected
75+
76+
77+
@pytest.mark.parametrize("case_id", BAML_REQUEST_CASE_IDS)
78+
def test_baml_request_corpus_unchanged(case_id):
79+
"""BAML (unmigrated, overriding subclass) must reproduce its fixtures
80+
through the legacy path it automatically keeps."""
81+
actual = json.loads(render_fixture(case_fixture(CASES[case_id])))
82+
expected = json.loads((REQUEST_DIR / f"{case_id}.json").read_text(encoding="utf-8"))
83+
assert actual == expected
84+
85+
86+
@pytest.mark.parametrize("case_id", CHAT_REQUEST_CASE_IDS)
87+
def test_chat_corpus_unchanged_by_shared_machinery(case_id):
88+
"""JSONFormat subclasses ChatFormat: prove the shared machinery did not
89+
shift a single chat byte."""
90+
from golden.generate_fixtures import _python_minor_differs
91+
92+
if getattr(CASES[case_id], "python_sensitive", False) and _python_minor_differs():
93+
pytest.skip("python-minor-dependent docstring bytes; the pinned parity CI job is the byte authority")
94+
actual = json.loads(render_fixture(case_fixture(CASES[case_id])))
95+
expected = json.loads((REQUEST_DIR / f"{case_id}.json").read_text(encoding="utf-8"))
96+
assert actual == expected
97+
98+
99+
# --- JSON parse fuzzing ---------------------------------------------------------
100+
101+
SEED = 0x77AB1
102+
N_CASES = 100
103+
104+
WRAPPERS = [
105+
lambda text: text,
106+
lambda text: f"```json\n{text}\n```",
107+
lambda text: f"Here is the answer:\n{text}\nHope that helps!",
108+
lambda text: text.replace('"', "'", 3),
109+
lambda text: text[:-3],
110+
lambda text: "[" + text + "]",
111+
lambda text: text + ' {"sneaky": "second object"}',
112+
lambda text: "not json at all",
113+
lambda text: "",
114+
]
115+
116+
117+
def _fuzz_case(index):
118+
rng = random.Random(SEED + index)
119+
names = rng.sample(["alpha", "beta", "gamma", "delta"], rng.randint(1, 3))
120+
fields = {name: (str, dspy.OutputField()) for name in names}
121+
fields["seed_input"] = (str, dspy.InputField())
122+
signature = dspy.make_signature(fields, None, signature_name="FuzzJSONSignature")
123+
124+
payload = {
125+
name: rng.choice(["value", "multi\nline", "", "unicode «π» 🧪", '"quoted"'])
126+
for name in names
127+
if rng.random() < 0.9
128+
}
129+
completion = rng.choice(WRAPPERS)(json.dumps(payload, indent=rng.choice([None, 2])))
130+
return signature, completion
131+
132+
133+
@pytest.mark.parametrize("index", range(N_CASES))
134+
def test_json_parse_fuzz_engine_equals_legacy(index):
135+
signature, completion = _fuzz_case(index)
136+
137+
def run(adapter):
138+
try:
139+
return ("completed", canonicalize(adapter.parse(signature, completion)))
140+
except Exception as error:
141+
return (type(error).__name__, canonicalize(canonical_error(error)))
142+
143+
assert run(JSONAdapter()) == run(_ForcedLegacyJSON()), f"json parse fuzz divergence at index {index}"

0 commit comments

Comments
 (0)