Skip to content

Feat use structured output for agentic mode when available#255

Open
valdis wants to merge 13 commits into
mainfrom
feat-use-structured-output-for-agentic-mode-when-available
Open

Feat use structured output for agentic mode when available#255
valdis wants to merge 13 commits into
mainfrom
feat-use-structured-output-for-agentic-mode-when-available

Conversation

@valdis

@valdis valdis commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Summary

Wires up native structured output across all review paths — agentic adapters and the file-by-file pipeline — so the model is constrained to return valid JSON instead of being parsed heuristically from free text.

Root causes fixed

  • Agentic adapters (Anthropic, OpenAI, ConfigurableAgent) were ignoring structured output entirely and falling back to heuristic JSON extraction from prose. This caused silent parse failures when the model added reasoning text before the code fence or truncated the response.
  • File-by-file pipeline (FileReviewer, ValidationResolver, DeduplicationResolver) passed root z.array() schemas directly to provider SDKs — both OpenAI zodResponseFormat and Anthropic zodOutputFormat reject a root array with "Root schema must have type: object".
  • $schema field ("https://json-schema.org/draft/2020-12/schema") in the JSON Schema sent to the Claude Code CLI caused it to silently fall back to plain text instead of enforcing constrained decoding.

Changes

Agentic structured output (Anthropic Claude Agent SDK)

  • Wrap ReviewIssuesSchema in a root object { issues: [...] } — required by the SDK's constrained decoding
  • Strip unsupported JSON Schema constraints (minimum, maximum, minLength, maxLength, etc.) that cause silent fallback to text
  • Strip the $schema field from the schema passed to outputFormat — the CLI rejects constrained decoding when it is present (confirmed empirically)

Plaintext JSON recovery (fallback path)

Array-root wrapping for all providers

  • New wrapArrayRootSchema / unwrapArrayRootResult utilities in src/ai/shared/structured/array-root.ts
  • AnthropicProvider: apply wrapping in both anthropic-output-config and tool_use fallback paths
  • OpenAICompatibleProvider: switch from zodResponseFormat strict mode to non-strict json_schema response format (strict mode rejects .optional() fields unless also .nullable()); apply array-root wrapping
  • ConfigurableAgentAdapter: add supportsStructuredOutputs: true so the Vercel AI SDK sends json_schema constrained decoding instead of loose json_object

Test plan

  • All unit tests pass (npm test)
  • Manual agentic run against qualops codebase returns structured issues
  • File-by-file pipeline does not throw "Root schema must have type: object" for Anthropic or OpenAI strict providers

valdis added 11 commits June 17, 2026 13:59
Agentic adapters previously bypassed capability detection and always
returned free-text JSON, causing parse failures when models included
trailing commas or truncated output. Wires structured output (via each
SDK's native API) through all three adapters using the same
detectCapabilities() path already used by the file-by-file pipeline.

- Resolve structuredDialect once in AgenticExecutor via ConfigService +
  detectCapabilities(), pass to adapters via AgentAdapterParams
- AnthropicAdapter: pass outputFormat JSON schema when not unstructured,
  read structured_output from result message
- OpenAIAdapter: pass outputType (z.object wrapper) when not unstructured,
  return structuredOutput from finalOutput
- ConfigurableAgentAdapter: use output.structured mode when not
  unstructured, read event.structured from FinalEvent
- Executor branches on result.structuredOutput (skip text parsing) vs
  text fallback; throws when non-empty text contains no JSON at all
- Restore trailing-comma fix in result-parser fallback path (dropped in
  QUALOPS-18)
- Add isUnstructured(dialect) helper to capabilities.ts to avoid
  magic literals
- Validate --diff-filter param in listChangedFiles against allowlist
…red fallback

Agentic mode cannot produce ReviewIssue[] from prose output — the
pipeline-executor already intercepts unstructured models at line 59 and
routes them to the prose pipeline before any agentic job runs. The
isUnstructured() guards inside the adapters were therefore dead code and
implied a fallback path that can never be exercised.

- Remove structuredDialect from AgentAdapterParams — adapters always
  use structured output and no longer need to inspect the dialect
- Remove isUnstructured() imports and conditional branches from all
  three adapters (anthropic, openai, configurable-agent)
- Add explicit invariant check in AgenticExecutor: if detectCapabilities
  returns 'unstructured', throw immediately with a clear message instead
  of silently producing zero findings
- Update tests to reflect always-structured adapter behaviour
…names

- agentic-executor: remove duplicate soft-error test; fix onToolCall test
  to assert the callback is passed (not a local array populated by the
  test itself); strengthen provider test to assert a valid AIProviderName
- anthropic-adapter: remove standalone error_max_turns test duplicated by
  it.each table; strengthen it.each to also assert output === ''; rename
  'logs' tests to describe what is actually verified
- openai-adapter: fix 'continues without bash tool' to assert
  structuredOutput rather than output === '' (which is always empty now)
…ard in agentic executor

Log first 500 chars of structured_output in AnthropicAdapter on par with
the text-fallback log already present. Remove the unreachable isUnstructured()
invariant check from AgenticExecutor — pipeline-executor.ts already routes
unstructured models to the prose pipeline before any agentic job is dispatched,
making this check dead code by design.
… compatibility

Both the Anthropic Claude Agent SDK and Vercel AI SDK (used by configurable-agent)
require a root object schema for structured output — a root array schema causes
the SDK to fall back to plain text output. Wrap ReviewIssuesSchema in
{ issues: [...] } for both adapters and unwrap on the way out, consistent with
the existing OpenAI adapter pattern.
…ed output

Anthropic's constrained-decoding structured output (output_config.format /
--json-schema) does not support numeric/string constraints (minimum, maximum,
multipleOf, minLength, maxLength). When present, the SDK silently falls back
to plain-text output instead of structured_output.

Add stripUnsupportedConstraints option to schemaToJsonSchema() and enable it
in AnthropicAdapter and ConfigurableAgentAdapter. The Zod schema retains its
validation constraints for runtime use — only the JSON Schema emitted to the
SDK has them stripped.
Picks up Claude Code CLI 2.1.179. No structured output fixes in this
release — outputFormat with MCP tools still falls back to text parsing.
Upgrade tracked for when SDK adds support.
…ponses

- extractJsonText: handle unclosed code fences (truncated responses where
  closing ``` is missing) by extracting content after the opening fence marker
- recoverPartialJsonArray: apply trailing-comma fix and escapeUnescapedControlChars
  to each extracted {..} chunk, restoring robustness lost in PR #145 refactor
- review-issue schema: restore .min(1) on description and .int().min(1).max(10)
  on confidence so Zod runtime validation is preserved (constraints are stripped
  only from the JSON Schema emitted for structured output via stripUnsupportedConstraints)
…tructured output

The Claude Code CLI falls back to plain text when the json-schema passed to
--json-schema contains a \$schema field (draft-2020-12 URI). Stripping it causes
the CLI to enforce constrained decoding and populate structured_output on the
result message.

Confirmed empirically: with \$schema present structured_output is absent; without
it structured_output is populated correctly.
…tput

Both Anthropic structured-output dialects (anthropic-output-config and
tool_use fallback) rejected root z.array() schemas. Apply the existing
wrapArrayRootSchema / unwrapArrayRootResult utilities (already used by
OpenAICompatibleProvider) so all callers — FileReviewer, ValidationResolver,
DeduplicationResolver — work without any call-site changes.

Add unit tests for array-root.ts which had no coverage.
…configurable-agent

- openai-compatible-provider: switch from zodResponseFormat (strict) to non-strict
  json_schema response_format; strict mode rejects .optional() fields unless also
  .nullable(), which several review schemas rely on. Validate with zod ourselves.
  Apply wrapArrayRootSchema/unwrapArrayRootResult for array-rooted schemas.
- structured/index.ts: export array-root utilities (ARRAY_ROOT_WRAP_KEY,
  isArrayRootSchema, wrapArrayRootSchema, unwrapArrayRootResult)
- configurable-agent-adapter: add supportsStructuredOutputs:true so the AI SDK
  sends json_schema constrained decoding instead of loose json_object; replace
  ad-hoc wrapper.issues access with robust unwrapStructuredIssues helper
@github-actions

github-actions Bot commented Jun 17, 2026

Copy link
Copy Markdown

QualOps Code Quality Analysis

Status: ⚠️ FAILED - Critical or high severity issues found

Summary

  • Total Issues: 2
  • Critical: 0 🔴
  • High: 1 🟠
  • Medium: 0 🟡
  • Low: 1 🟢
  • Files Analyzed: 23

🟠 High Issues (1)

  • src/stages/review/agentic/adapters/anthropic-adapter.ts:166 - bug
    Anthropic adapter silently drops issues when structured_output has an unexpected shape (missing 'issues' array)

📊 Full Report

View detailed report


Powered by QualOps

valdis added 2 commits June 17, 2026 17:47
If structuredOutput is set but not an array, the adapter's unwrapping
logic failed to produce the expected shape. Silently discarding to zero
issues would hide a real bug. Throw instead so the failure is visible.
…sonContent check

- extract-json: restrict unclosed-fence extraction to ```json only;
  bare ``` blocks (python, bash, etc.) fall through to substring search
- anthropic-adapter: warn and leave structuredOutput undefined when
  structured_output is present but issues key is missing, so
  agentic-executor can fall through to text recovery instead of throwing
- agentic-executor: use extractJsonText() for hasJsonContent instead of
  naive string includes — prose with '[' no longer suppresses parse errors
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants