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

@@ -2,12 +2,12 @@
Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess. Claude Code CLI adapter — runs the ``claude`` CLI as a subprocess.
""" """
import asyncio import asyncio
import json import json
import os import os
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from llm_connect.adapter import LLMAdapter from llm_connect.adapter import LLMAdapter
from llm_connect.models import RunConfig, LLMResponse from llm_connect.models import RunConfig, LLMResponse
@@ -26,23 +26,23 @@ class ClaudeCodeAdapter(LLMAdapter):
length limits (compiled prompts can exceed 30 KB). length limits (compiled prompts can exceed 30 KB).
""" """
def __init__( def __init__(
self, self,
cli_path: Optional[str] = None, cli_path: Optional[str] = None,
model: Optional[str] = None, model: Optional[str] = None,
config: Optional[LLMConfig] = None, config: Optional[LLMConfig] = None,
): ):
self._config = config or LLMConfig(provider="claude-code") self._config = config or LLMConfig(provider="claude-code")
self._cli_path = cli_path or self._resolve_cli_path() self._cli_path = cli_path or self._resolve_cli_path()
self._model = model self._model = model
# ── LLMAdapter interface ──────────────────────────────────────── # ── LLMAdapter interface ────────────────────────────────────────
def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: def execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
self._preflight_budget(config) self._preflight_budget(config)
cmd = self._build_command(config) cmd = self._build_command(config)
timeout = config.timeout_seconds or self._config.timeout_seconds timeout = config.timeout_seconds or self._config.timeout_seconds
try: try:
result = subprocess.run( result = subprocess.run(
@@ -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)
@@ -86,10 +86,10 @@ class ClaudeCodeAdapter(LLMAdapter):
self._consume_budget(config, response) self._consume_budget(config, response)
return response return response
async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse: async def async_execute_prompt(self, prompt: str, config: RunConfig) -> LLMResponse:
"""Native async implementation using asyncio.create_subprocess_exec.""" """Native async implementation using asyncio.create_subprocess_exec."""
self._preflight_budget(config) self._preflight_budget(config)
cmd = self._build_command(config) cmd = self._build_command(config)
timeout = config.timeout_seconds or self._config.timeout_seconds timeout = config.timeout_seconds or self._config.timeout_seconds
@@ -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)
@@ -139,49 +139,92 @@ class ClaudeCodeAdapter(LLMAdapter):
self._consume_budget(config, response) self._consume_budget(config, response)
return response return response
def validate_config(self, config: RunConfig) -> bool: def validate_config(self, config: RunConfig) -> bool:
try: try:
result = subprocess.run( result = subprocess.run(
[self._cli_path, "--version"], [self._cli_path, "--version"],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
) )
return result.returncode == 0 return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError, OSError): except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
return False return False
def _build_command(self, config: RunConfig) -> list[str]: def _build_command(self, config: RunConfig) -> list[str]:
cmd = [self._cli_path, "--print"] cmd = [self._cli_path, "--print"]
if self._model: if self._model:
cmd.extend(["--model", self._model]) cmd.extend(["--model", self._model])
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])
return cmd # With --json-schema alone the CLI prints conversational text on
# stdout while the structured payload ships on a sidecar channel
def _resolve_cli_path(self) -> str: # callers cannot reach. --output-format json forces the structured
configured = ( # response (wrapped in an envelope) onto stdout.
os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH") cmd.extend(["--output-format", "json"])
or os.environ.get("CLAUDE_CLI_PATH") return cmd
or self._config.claude_cli_path
) def _resolve_cli_path(self) -> str:
if configured and configured != "claude": configured = (
return configured os.environ.get("LLM_CONNECT_CLAUDE_CLI_PATH")
or os.environ.get("CLAUDE_CLI_PATH")
local_cli = Path.home() / ".local" / "bin" / "claude" or self._config.claude_cli_path
if local_cli.exists(): )
return str(local_cli) if configured and configured != "claude":
return configured or "claude" return configured
local_cli = Path.home() / ".local" / "bin" / "claude"
def _json_schema_arg(config: RunConfig) -> str | None: if local_cli.exists():
schema = (config.model_params or {}).get("json_schema") return str(local_cli)
if not schema: return configured or "claude"
return None
if isinstance(schema, str):
return schema def _json_schema_arg(config: RunConfig) -> str | None:
if isinstance(schema, dict): schema = (config.model_params or {}).get("json_schema")
return json.dumps(schema, separators=(",", ":")) if not schema:
return None return None
if isinstance(schema, str):
return schema
if isinstance(schema, dict):
return json.dumps(schema, separators=(",", ":"))
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")