Skip to content

Commit cbce937

Browse files
authored
fix(anthropic): route strict structured output through forced tool_use instead of prompt-injection (#1002) (#2339)
Fixes #1002
1 parent 246803b commit cbce937

2 files changed

Lines changed: 176 additions & 31 deletions

File tree

hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py

Lines changed: 65 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ async def call(
136136
initial_backoff: Initial backoff time in seconds.
137137
max_backoff: Maximum backoff time in seconds.
138138
skip_validation: Return raw JSON without Pydantic validation.
139-
strict_schema: Use strict JSON schema enforcement (not supported by Anthropic).
139+
strict_schema: Route structured output through a forced tool_use tool for
140+
native constrained decoding (issue #1002). When False, falls back to
141+
schema-in-prompt + JSON parse.
140142
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
141143
142144
Returns:
@@ -167,14 +169,21 @@ async def call(
167169
else:
168170
anthropic_messages.append({"role": role, "content": content})
169171

170-
# Add JSON schema instruction if response_format is provided
172+
# Structured output: prefer Anthropic-native constrained decoding via a single
173+
# forced tool_use tool (strict_schema) over text-injecting the schema and
174+
# parsing the reply. Native constrained decoding guarantees schema-valid JSON,
175+
# eliminating the invalid-JSON retry storm (issue #1002). When strict_schema is
176+
# off we keep the text-inject + json.loads fallback for backward compatibility.
177+
schema = None
178+
use_forced_tool = False
179+
_tool_name = "structured_response"
171180
if response_format is not None and hasattr(response_format, "model_json_schema"):
172181
schema = response_format.model_json_schema()
173-
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
174-
if system_prompt:
175-
system_prompt += schema_msg
182+
if strict_schema:
183+
use_forced_tool = True
176184
else:
177-
system_prompt = schema_msg
185+
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
186+
system_prompt = (system_prompt + schema_msg) if system_prompt else schema_msg
178187

179188
# Prepare parameters
180189
call_params: dict[str, Any] = {
@@ -186,6 +195,14 @@ async def call(
186195
if system_prompt:
187196
call_params["system"] = system_prompt
188197

198+
if use_forced_tool:
199+
# Single tool whose input_schema IS the response schema; force the model to
200+
# emit it via tool_choice so the SDK does constrained decoding for us.
201+
call_params["tools"] = [
202+
{"name": _tool_name, "description": "Return the structured response.", "input_schema": schema}
203+
]
204+
call_params["tool_choice"] = {"type": "tool", "name": _tool_name}
205+
189206
if self._extra_body:
190207
call_params["extra_body"] = self._extra_body
191208

@@ -195,32 +212,49 @@ async def call(
195212
try:
196213
response = await self._client.messages.create(**call_params)
197214

198-
# Anthropic response content is a list of blocks
199-
content = ""
200-
for block in response.content:
201-
if block.type == "text":
202-
content += block.text
203-
204-
if response_format is not None:
205-
# Models may wrap JSON in markdown code blocks
206-
clean_content = content
207-
if "```json" in content:
208-
clean_content = content.split("```json")[1].split("```")[0].strip()
209-
elif "```" in content:
210-
clean_content = content.split("```")[1].split("```")[0].strip()
211-
212-
try:
213-
json_data = json.loads(clean_content)
214-
except json.JSONDecodeError:
215-
# Fallback to parsing raw content if markdown stripping failed
216-
json_data = json.loads(content)
217-
218-
if skip_validation:
219-
result = json_data
220-
else:
221-
result = response_format.model_validate(json_data)
215+
if use_forced_tool:
216+
# Forced tool_use → the validated args are already a dict; no parsing,
217+
# no markdown-strip, no JSON-decode retry possible.
218+
tool_input = None
219+
for block in response.content:
220+
if block.type == "tool_use" and block.name == _tool_name:
221+
tool_input = block.input or {}
222+
break
223+
if tool_input is None:
224+
# Model ignored the forced tool (rare, e.g. a gateway that drops
225+
# tool_choice). Fall back to text parse so we don't hard-fail; the
226+
# existing retry loop still covers genuine errors.
227+
content = "".join(b.text for b in response.content if b.type == "text")
228+
tool_input = json.loads(content)
229+
content = json.dumps(tool_input)
230+
result = tool_input if skip_validation else response_format.model_validate(tool_input)
222231
else:
223-
result = content
232+
# Anthropic response content is a list of blocks
233+
content = ""
234+
for block in response.content:
235+
if block.type == "text":
236+
content += block.text
237+
238+
if response_format is not None:
239+
# Models may wrap JSON in markdown code blocks
240+
clean_content = content
241+
if "```json" in content:
242+
clean_content = content.split("```json")[1].split("```")[0].strip()
243+
elif "```" in content:
244+
clean_content = content.split("```")[1].split("```")[0].strip()
245+
246+
try:
247+
json_data = json.loads(clean_content)
248+
except json.JSONDecodeError:
249+
# Fallback to parsing raw content if markdown stripping failed
250+
json_data = json.loads(content)
251+
252+
if skip_validation:
253+
result = json_data
254+
else:
255+
result = response_format.model_validate(json_data)
256+
else:
257+
result = content
224258

225259
# Record metrics and log slow calls
226260
duration = time.time() - start_time
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Regression tests for issue #1002 — Anthropic structured output via forced tool_use.
2+
3+
When strict_schema=True, AnthropicLLM.call() must request the schema through a single
4+
forced tool_use tool (tool_choice={"type":"tool",...}) and read the validated args from
5+
the tool_use block, NOT inject the schema as text and json.loads() the reply (which caused
6+
a ~1:1 invalid-JSON retry storm / OOM in production).
7+
"""
8+
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
11+
import pytest
12+
from pydantic import BaseModel
13+
14+
15+
class _Decision(BaseModel):
16+
action: str
17+
reason: str
18+
19+
20+
def _make_anthropic_provider():
21+
with patch("anthropic.AsyncAnthropic") as mock_client_cls:
22+
mock_client_cls.return_value = MagicMock()
23+
from hindsight_api.engine.providers.anthropic_llm import AnthropicLLM
24+
25+
provider = AnthropicLLM(
26+
provider="anthropic",
27+
api_key="fake-key",
28+
base_url="",
29+
model="claude-sonnet-4-20250514",
30+
)
31+
provider._client = MagicMock()
32+
return provider
33+
34+
35+
def _tool_use_response(args: dict):
36+
block = MagicMock()
37+
block.type = "tool_use"
38+
block.name = "structured_response"
39+
block.input = args
40+
resp = MagicMock()
41+
resp.content = [block]
42+
resp.usage = MagicMock(input_tokens=5, output_tokens=2, cache_read_input_tokens=0)
43+
resp.stop_reason = "tool_use"
44+
return resp
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_strict_schema_uses_forced_tool_choice():
49+
"""strict_schema=True ⇒ a single tool is defined and tool_choice forces it (no schema text-injection)."""
50+
provider = _make_anthropic_provider()
51+
provider._client.messages.create = AsyncMock(return_value=_tool_use_response({"action": "skip", "reason": "dup"}))
52+
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
53+
result = await provider.call(
54+
messages=[{"role": "user", "content": "decide"}],
55+
response_format=_Decision,
56+
strict_schema=True,
57+
scope="test",
58+
max_retries=0,
59+
)
60+
kwargs = provider._client.messages.create.call_args.kwargs
61+
# forced tool_use requested
62+
assert "tools" in kwargs and len(kwargs["tools"]) == 1
63+
assert kwargs["tool_choice"] == {"type": "tool", "name": "structured_response"}
64+
# schema NOT injected as text into the system prompt
65+
assert "valid JSON matching this schema" not in (kwargs.get("system") or "")
66+
# validated model returned straight from tool_use.input
67+
assert isinstance(result, _Decision)
68+
assert result.action == "skip"
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_strict_schema_tool_use_never_hits_json_retry_loop():
73+
"""A tool_use response is structurally valid → no second messages.create call (no retry storm)."""
74+
provider = _make_anthropic_provider()
75+
create = AsyncMock(return_value=_tool_use_response({"action": "keep", "reason": "novel"}))
76+
provider._client.messages.create = create
77+
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
78+
await provider.call(
79+
messages=[{"role": "user", "content": "x"}],
80+
response_format=_Decision,
81+
strict_schema=True,
82+
scope="test",
83+
max_retries=10, # would allow 11 attempts on the old text-parse path
84+
)
85+
assert create.await_count == 1 # exactly one call — the bug was N retries on malformed text
86+
87+
88+
@pytest.mark.asyncio
89+
async def test_non_strict_keeps_text_injection_fallback():
90+
"""strict_schema=False (default) preserves the legacy schema-in-prompt behavior."""
91+
provider = _make_anthropic_provider()
92+
block = MagicMock()
93+
block.type = "text"
94+
block.text = '{"action":"skip","reason":"d"}'
95+
resp = MagicMock()
96+
resp.content = [block]
97+
resp.usage = MagicMock(input_tokens=5, output_tokens=2, cache_read_input_tokens=0)
98+
resp.stop_reason = "end_turn"
99+
provider._client.messages.create = AsyncMock(return_value=resp)
100+
with patch("hindsight_api.engine.providers.anthropic_llm.get_metrics_collector"):
101+
result = await provider.call(
102+
messages=[{"role": "user", "content": "decide"}],
103+
response_format=_Decision,
104+
strict_schema=False,
105+
scope="test",
106+
max_retries=0,
107+
)
108+
kwargs = provider._client.messages.create.call_args.kwargs
109+
assert "tools" not in kwargs # no forced tool when not strict
110+
assert "valid JSON matching this schema" in (kwargs.get("system") or "")
111+
assert isinstance(result, _Decision)

0 commit comments

Comments
 (0)