Pass --output-format json with --json-schema and unwrap CLI envelope
Some checks failed
CI / test (3.10) (push) Has been cancelled
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled

The Claude Code adapter previously passed --json-schema alone. On Claude
CLI 2.1.160 that combination still emits the model's conversational
preamble on stdout while the schema-enforced structured payload ships on
a sidecar channel the adapter cannot read. Result: callers requesting
structured output got prose that fails JSON parsing downstream — exactly
the failure mode the activity-core CUST-WP-0045 daily triage canary hit
on 2026-06-01 ("Triage report generated and returned via structured
output. Key signals:..." → json.loads error at column 1).

Fix: when --json-schema is set, also pass --output-format json. The CLI
then writes a JSON envelope on stdout. The adapter unwraps it by
probing a small allowlist of known text-bearing fields (result,
result_text, content, text, output). Unknown envelope shapes fall
through to raw stdout so the operator can introspect the structure and
extend the allowlist.

The unwrap path is only triggered when --json-schema was set, so non-
schema callers keep the existing raw-stdout behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 10:20:24 +02:00
parent b12d1af8bb
commit 9de0f495db
2 changed files with 160 additions and 67 deletions

View File

@@ -65,7 +65,7 @@ class ClaudeCodeAdapter(LLMAdapter):
stderr=result.stderr, stderr=result.stderr,
) )
content = result.stdout content = _unwrap_cli_json_envelope(result.stdout, config)
prompt_tokens = estimate_tokens(prompt) prompt_tokens = estimate_tokens(prompt)
completion_tokens = estimate_tokens(content) completion_tokens = estimate_tokens(content)
@@ -117,7 +117,7 @@ class ClaudeCodeAdapter(LLMAdapter):
stderr=stderr_bytes.decode(), stderr=stderr_bytes.decode(),
) )
content = stdout_bytes.decode() content = _unwrap_cli_json_envelope(stdout_bytes.decode(), config)
prompt_tokens = estimate_tokens(prompt) prompt_tokens = estimate_tokens(prompt)
completion_tokens = estimate_tokens(content) completion_tokens = estimate_tokens(content)
@@ -159,6 +159,11 @@ class ClaudeCodeAdapter(LLMAdapter):
json_schema = _json_schema_arg(config) json_schema = _json_schema_arg(config)
if json_schema: if json_schema:
cmd.extend(["--json-schema", json_schema]) cmd.extend(["--json-schema", json_schema])
# With --json-schema alone the CLI prints conversational text on
# stdout while the structured payload ships on a sidecar channel
# callers cannot reach. --output-format json forces the structured
# response (wrapped in an envelope) onto stdout.
cmd.extend(["--output-format", "json"])
return cmd return cmd
def _resolve_cli_path(self) -> str: def _resolve_cli_path(self) -> str:
@@ -185,3 +190,41 @@ def _json_schema_arg(config: RunConfig) -> str | None:
if isinstance(schema, dict): if isinstance(schema, dict):
return json.dumps(schema, separators=(",", ":")) return json.dumps(schema, separators=(",", ":"))
return None return None
# Field names Claude Code's `--output-format json` envelope is known to use
# for the model's primary textual response. Probed in order; the first match
# wins. If none match (because the envelope shape is something we haven't
# seen), we return the raw envelope string so the caller still gets the data
# and can introspect it.
_ENVELOPE_TEXT_FIELDS = ("result", "result_text", "content", "text", "output")
def _unwrap_cli_json_envelope(stdout: str, config: RunConfig) -> str:
"""Extract the model's payload from Claude CLI's --output-format json envelope.
Only attempts unwrap when --json-schema was set, because that's the only
code path that adds --output-format json to the CLI invocation. Other
paths keep raw stdout (current behavior preserved).
"""
if not _json_schema_arg(config):
return stdout
text = stdout.strip()
if not text:
return stdout
try:
envelope = json.loads(text)
except json.JSONDecodeError:
return stdout
if not isinstance(envelope, dict):
return stdout
for key in _ENVELOPE_TEXT_FIELDS:
if key in envelope:
value = envelope[key]
if isinstance(value, str):
return value
if isinstance(value, (dict, list)):
return json.dumps(value)
# Unknown envelope shape — surface it raw so the operator can see it
# in the validation error and we can update _ENVELOPE_TEXT_FIELDS.
return stdout

View File

@@ -39,15 +39,65 @@ def test_execute_prompt_passes_json_schema_to_claude_cli(monkeypatch):
"--print", "--print",
"--json-schema", "--json-schema",
'{"type":"object"}', '{"type":"object"}',
"--output-format",
"json",
], ],
"input": "Produce a report.", "input": "Produce a report.",
"capture_output": True, "capture_output": True,
"text": True, "text": True,
"timeout": 42, "timeout": 42,
} }
# Stdout shape that does not match any known envelope field is returned
# verbatim so the caller can introspect and we can extend the field list.
assert response.content == '{"summary":"ok","recommendations":[]}' assert response.content == '{"summary":"ok","recommendations":[]}'
def test_execute_prompt_unwraps_cli_json_envelope_result_field(monkeypatch):
"""With --output-format json the CLI wraps the model payload in an
envelope. The adapter unwraps the textual result so the caller still
sees the model's structured-output JSON, not the envelope."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
envelope = {
"type": "result",
"result": '{"summary":"ok","recommendations":[]}',
"total_cost_usd": 0.001,
}
import json as _json
return SimpleNamespace(
returncode=0,
stdout=_json.dumps(envelope),
stderr="",
)
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt(
"Produce a report.",
RunConfig(model_params={"json_schema": {"type": "object"}}),
)
assert response.content == '{"summary":"ok","recommendations":[]}'
def test_execute_prompt_no_unwrap_without_json_schema(monkeypatch):
"""Without --json-schema we do not pass --output-format json, so the
envelope unwrap path stays inert and raw stdout passes through."""
def fake_run(cmd, input, capture_output, text, timeout): # noqa: ANN001
return SimpleNamespace(
returncode=0,
stdout='{"result":"this is just stdout, not an envelope"}',
stderr="",
)
monkeypatch.setattr("llm_connect.claude_code.subprocess.run", fake_run)
adapter = ClaudeCodeAdapter(cli_path="/custom/claude")
response = adapter.execute_prompt("Plain prompt.", RunConfig())
assert response.content == '{"result":"this is just stdout, not an envelope"}'
def test_claude_code_adapter_prefers_env_cli_path(monkeypatch): def test_claude_code_adapter_prefers_env_cli_path(monkeypatch):
monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude") monkeypatch.setenv("LLM_CONNECT_CLAUDE_CLI_PATH", "/home/me/bin/claude")