From 41addbef3520dd86b5a461d3d10fd1bf08f8e979 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 13:59:34 +0300 Subject: [PATCH 01/18] feat(agentic): use structured output in all agentic adapters 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 --- CHANGELOG.md | 4 +++ src/ai/providers/capabilities.ts | 4 +++ .../review/agentic/adapters/agent-adapter.ts | 6 ++++ .../agentic/adapters/anthropic-adapter.ts | 26 +++++++++++--- .../adapters/configurable-agent-adapter.ts | 24 ++++++++++--- .../review/agentic/adapters/openai-adapter.ts | 35 +++++++++++++++---- src/stages/review/agentic/agentic-executor.ts | 28 ++++++++++++--- src/stages/review/agentic/result-parser.ts | 4 ++- src/stages/review/agentic/tools/handlers.ts | 1 + .../adapters/anthropic-adapter.spec.ts | 14 ++++++++ .../agentic/adapters/openai-adapter.spec.ts | 14 ++++++++ .../review/agentic/agentic-executor.spec.ts | 31 ++++++++++++++++ 12 files changed, 169 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ade2f91f..4f3449ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- Agentic adapters now use SDK-native structured output (same `detectCapabilities()` path as the file-by-file pipeline) instead of heuristic JSON text parsing. `AnthropicAdapter` passes `outputFormat: {type: "json_schema"}`, `OpenAIAdapter` uses `outputType`, and `ConfigurableAgentAdapter` uses `output: {structured: true}` — all gated on `isUnstructured()`. The text fallback path is kept for unstructured models and now restores the pre-QUALOPS-18 trailing-comma fix. Runs that return non-empty non-JSON output now throw instead of silently producing zero findings. +- `--diff-filter` parameter in `listChangedFiles` agentic tool now validated against the allowed git diff-filter character set (`[ACDMRTUXB*]`), consistent with how `base`/`head` refs are validated via `isSafeGitRef`. + ## [0.2.6] - 2026-06-17 ### Added diff --git a/src/ai/providers/capabilities.ts b/src/ai/providers/capabilities.ts index 457eb576..927da395 100644 --- a/src/ai/providers/capabilities.ts +++ b/src/ai/providers/capabilities.ts @@ -9,6 +9,10 @@ export type StructuredOutputDialect = | 'anthropic-tool-use' | 'unstructured'; // prose pipeline — model does not support json_schema +export function isUnstructured(dialect: StructuredOutputDialect): boolean { + return dialect === 'unstructured'; +} + export interface ProviderCapabilities { structuredDialect: StructuredOutputDialect; supportsTemperature: boolean; diff --git a/src/stages/review/agentic/adapters/agent-adapter.ts b/src/stages/review/agentic/adapters/agent-adapter.ts index bf43e019..dc51d42d 100644 --- a/src/stages/review/agentic/adapters/agent-adapter.ts +++ b/src/stages/review/agentic/adapters/agent-adapter.ts @@ -1,3 +1,4 @@ +import type { StructuredOutputDialect } from '../../../../ai/providers/capabilities'; import type { BashConfig } from '../../../../shared/types/config'; import type { ResolvedAgentDefinition } from '../subagents/definitions'; @@ -18,6 +19,8 @@ export interface AgentAdapterParams { toolConfig: ToolConfig; onToolCall?: (turn: number, name: string, input: unknown) => void; baseUrl?: string; + /** Pre-resolved structured output dialect from detectCapabilities(). */ + structuredDialect: StructuredOutputDialect; } export type AgentErrorSubtype = @@ -26,10 +29,13 @@ export type AgentErrorSubtype = | 'error_rate_limit_tokens' | 'error_max_tokens' | 'error_content_filter' + | 'error_parse_failed' | 'error_unexpected'; export interface AgentAdapterResult { output: string; + /** Pre-parsed issues array from SDK structured output. Bypasses text parsing when set. */ + structuredOutput?: unknown; inputTokens?: number; outputTokens?: number; /** Set when the agent run did not complete successfully. */ diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index e2b51a83..bb57c8de 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -1,5 +1,8 @@ import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { isUnstructured } from '../../../../ai/providers/capabilities'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; +import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { createToolSet, type ToolSet } from '../tools'; import type { AgentAdapter, @@ -9,6 +12,8 @@ import type { } from './agent-adapter'; import { logger } from '../../../../shared/utils/logger'; +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewIssuesSchema); + type QueryOptions = Parameters[0]['options']; function toMcpServer(toolSet: ToolSet): ReturnType { @@ -54,6 +59,9 @@ function buildQueryOptions( ...(model && { model }), cwd, permissionMode: 'bypassPermissions', + ...(!isUnstructured(params.structuredDialect) && { + outputFormat: { type: 'json_schema' as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, + }), }; } @@ -133,6 +141,7 @@ function handleResultMessage( message: SDKMessage, state: { output: string; + structuredOutput?: unknown; inputTokens?: number; outputTokens?: number; errorSubtype?: AgentErrorSubtype; @@ -141,15 +150,21 @@ function handleResultMessage( const msg = message as { subtype: string; result?: string; + structured_output?: unknown; usage?: { input_tokens?: number; output_tokens?: number }; }; - if (msg.subtype === 'success' && msg.result) { - logger.info( - `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, - ); - state.output = msg.result; + if (msg.subtype === 'success') { state.inputTokens = msg.usage?.input_tokens; state.outputTokens = msg.usage?.output_tokens; + if (msg.structured_output !== undefined) { + logger.info('[Agentic/Anthropic] Received structured output from SDK'); + state.structuredOutput = msg.structured_output; + } else if (msg.result) { + logger.info( + `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, + ); + state.output = msg.result; + } } else if (msg.subtype !== 'success') { const mapped = ANTHROPIC_ERROR_SUBTYPE_MAP[msg.subtype] ?? 'error_unexpected'; if (mapped === 'error_unexpected') { @@ -173,6 +188,7 @@ export class AnthropicAdapter implements AgentAdapter { const state = { output: '', + structuredOutput: undefined as unknown, inputTokens: undefined as number | undefined, outputTokens: undefined as number | undefined, errorSubtype: undefined as AgentErrorSubtype | undefined, diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 217e8038..48e0e60b 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -8,10 +8,15 @@ import type { AgentAdapterResult, AgentErrorSubtype, } from './agent-adapter'; +import { isUnstructured } from '../../../../ai/providers/capabilities'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; +import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet } from '../tools'; +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewIssuesSchema); + function toJsonSchema(schema: z.ZodObject): Record { // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. // Strip it and emit a plain draft-07-compatible object instead. @@ -40,6 +45,7 @@ function buildSystemPrompt(params: AgentAdapterParams, toolNames: string[]): str } function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { + const useStructured = !isUnstructured(params.structuredDialect); return { systemPrompt: buildSystemPrompt(params, toolNames), model: { @@ -49,7 +55,9 @@ function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { }, agent: { maxSteps: params.maxTurns }, mcpTools: [], - output: { structured: false }, + output: useStructured + ? { structured: true as const, schema: REVIEW_ISSUES_JSON_SCHEMA } + : { structured: false as const }, safety: { compaction: { triggerTokens: 100_000, keepRecentMessages: 6 }, toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 }, @@ -109,6 +117,7 @@ export class ConfigurableAgentAdapter implements AgentAdapter { try { let output = ''; + let structuredOutput: unknown = undefined; let inputTokens: number | undefined; let outputTokens: number | undefined; let errorSubtype: AgentErrorSubtype | undefined; @@ -124,14 +133,21 @@ export class ConfigurableAgentAdapter implements AgentAdapter { logger.info(`[Agentic/ConfigurableAgent] Tool call: ${event.name}`); params.onToolCall?.(turnIndex, event.name, event.args); break; - case 'final': - output = event.content; + case 'final': { + const finalEvent = event as typeof event & { structured?: unknown }; + if (finalEvent.structured !== undefined) { + logger.info('[Agentic/ConfigurableAgent] Received structured output from SDK'); + structuredOutput = finalEvent.structured; + } else { + output = event.content; + } inputTokens = event.usage.inputTokens; outputTokens = event.usage.outputTokens; logger.info( `[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`, ); break; + } case 'error': { logger.warn( `[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`, @@ -155,7 +171,7 @@ export class ConfigurableAgentAdapter implements AgentAdapter { { tools: tools as never, model }, ); - return { output, inputTokens, outputTokens, errorSubtype }; + return { output, structuredOutput, inputTokens, outputTokens, errorSubtype }; } finally { await qualopsTools.dispose(); } diff --git a/src/stages/review/agentic/adapters/openai-adapter.ts b/src/stages/review/agentic/adapters/openai-adapter.ts index 195a98e8..2f0c4297 100644 --- a/src/stages/review/agentic/adapters/openai-adapter.ts +++ b/src/stages/review/agentic/adapters/openai-adapter.ts @@ -1,10 +1,15 @@ import { Agent, getGlobalTraceProvider, run, setDefaultOpenAIClient, tool } from '@openai/agents'; +import { z } from 'zod'; import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter'; +import { isUnstructured } from '../../../../ai/providers/capabilities'; +import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet, type ToolSet } from '../tools'; +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); + export class OpenAIAdapter implements AgentAdapter { async run(params: AgentAdapterParams): Promise { const { @@ -37,18 +42,40 @@ export class OpenAIAdapter implements AgentAdapter { }); }); + const useStructured = !isUnstructured(params.structuredDialect); + const orchestrator = new Agent({ name: 'qualops-reviewer', model, instructions: systemPrompt, tools, handoffs, + ...(useStructured && { outputType: ReviewOutputSchema }), }); - logger.info(`[Agentic/OpenAI] Starting run with model=${model}, maxTurns=${maxTurns}`); + logger.info( + `[Agentic/OpenAI] Starting run with model=${model}, maxTurns=${maxTurns}, structured=${useStructured}`, + ); const result = await run(orchestrator, userPrompt, { maxTurns }); + const usage = result.state.usage; + + logger.info( + `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, + ); + + if (useStructured) { + const structured = result.finalOutput as z.infer | null; + logger.info('[Agentic/OpenAI] Received structured output from SDK'); + return { + output: '', + structuredOutput: structured?.issues, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + }; + } + const output = typeof result.finalOutput === 'string' ? result.finalOutput @@ -56,12 +83,6 @@ export class OpenAIAdapter implements AgentAdapter { ? JSON.stringify(result.finalOutput) : ''; - const usage = result.state.usage; - - logger.info( - `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, - ); - return { output, inputTokens: usage.inputTokens, diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 50c169f6..d895bd5b 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -4,12 +4,13 @@ import { createAgentAdapter } from './adapters'; import type { AgentErrorSubtype } from './adapters/agent-adapter'; import { AgentLoader } from './loaders/agent-loader'; import { buildUserPrompt } from './prompt-builder'; -import { parseIssuesFromResult } from './result-parser'; +import { normalizeIssue, parseIssuesFromResult } from './result-parser'; import { createSubagentDefinitions, type AgentDefinition, type ResolvedAgentDefinition, } from './subagents/definitions'; +import { detectCapabilities } from '../../../ai/providers/capabilities'; import { ConfigService } from '../../../config/config'; import { getTracer, @@ -92,9 +93,12 @@ export class AgenticExecutor { try { const stageConfig = ConfigService.getInstance().getResolvedStageConfig('review'); + const { structuredDialect } = detectCapabilities(stageConfig.provider, this.model); const adapter = createAgentAdapter(stageConfig.provider); - logger.info(`[Agentic] Using adapter for provider: ${stageConfig.provider}`); + logger.info( + `[Agentic] Using adapter for provider: ${stageConfig.provider}, structuredDialect: ${structuredDialect}`, + ); const result = await adapter.run({ systemPrompt, @@ -112,6 +116,7 @@ export class AgenticExecutor { }, }, baseUrl: stageConfig.baseUrl, + structuredDialect, onToolCall: (turn, name, input) => { turnIndex = turn; allToolCalls.push({ turn, name, input }); @@ -148,12 +153,25 @@ export class AgenticExecutor { ); } - if (result.output) { + if (result.structuredOutput !== undefined) { + const rawIssues = Array.isArray(result.structuredOutput) ? result.structuredOutput : []; + const parsed = (rawIssues as Record[]) + .filter((i) => ((i?.confidence as number) ?? 0) >= 7) + .map((i, idx) => normalizeIssue(i, idx, files, this.job.name, this.cwd)); + issues.push(...parsed); + logger.info(`[Agentic] Parsed ${parsed.length} issues from structured output`); + } else if (result.output) { logger.info(`[Agentic] Result (first 500 chars): ${result.output.substring(0, 500)}`); const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); - if (parsed.length === 0 && result.output.trim().length > 0) { + // Only throw when output is non-empty but contains no extractable JSON at all. + // An empty array [] is a valid model response (no issues found) — not an error. + const hasJsonContent = result.output.includes('[') || result.output.includes('{'); + if (parsed.length === 0 && result.output.trim().length > 0 && !hasJsonContent) { logger.warn( - `[Agentic] Job "${this.job.name}" returned output but no parseable issues — response may be truncated (${result.output.length} chars).`, + `[Agentic] No parseable issues from text output. Raw output preview:\n${result.output.substring(0, 2000)}`, + ); + throw new Error( + `[Agentic] Job "${this.job.name}" returned non-empty output but no parseable JSON issues (${result.output.length} chars).`, ); } issues.push(...parsed); diff --git a/src/stages/review/agentic/result-parser.ts b/src/stages/review/agentic/result-parser.ts index 576258d2..2333cb6d 100644 --- a/src/stages/review/agentic/result-parser.ts +++ b/src/stages/review/agentic/result-parser.ts @@ -53,7 +53,9 @@ export function parseIssuesFromResult( parsed = JSON.parse(extracted.text); } catch { try { - parsed = JSON.parse(escapeUnescapedControlChars(extracted.text)); + parsed = JSON.parse( + escapeUnescapedControlChars(extracted.text.replace(/,(\s*[}\]])/g, '$1')), + ); } catch (error) { logger.warn(`[Agentic] Failed to parse JSON: ${(error as Error).message}`); logger.warn(`[Agentic] JSON preview: ${extracted.text.slice(0, 300)}...`); diff --git a/src/stages/review/agentic/tools/handlers.ts b/src/stages/review/agentic/tools/handlers.ts index a4d8a9b8..c2b46ccb 100644 --- a/src/stages/review/agentic/tools/handlers.ts +++ b/src/stages/review/agentic/tools/handlers.ts @@ -129,6 +129,7 @@ export function listChangedFiles( if (!isSafeGitRef(base)) return `Error: invalid git ref: ${base}`; const headRef = head || 'HEAD'; if (!isSafeGitRef(headRef)) return `Error: invalid git ref: ${headRef}`; + if (filter && !/^[ACDMRTUXB*]+$/i.test(filter)) return `Error: invalid diff filter: ${filter}`; const args = ['diff', '--name-status']; if (filter) args.push(`--diff-filter=${filter}`); args.push(`${base}...${headRef}`); diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 55d6def0..6b7a9d51 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -29,6 +29,7 @@ function makeParams(overrides: Partial = {}): AgentAdapterPa cwd: process.cwd(), maxTurns: 10, toolConfig: { bash: {} }, + structuredDialect: 'unstructured', ...overrides, }; } @@ -140,6 +141,19 @@ describe('AnthropicAdapter', () => { expect(callOptions.maxBudgetUsd).toBe(2.5); }); + it('returns structuredOutput when result contains structured_output', async () => { + const issues = [{ description: 'sql injection', confidence: 9 }]; + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result', subtype: 'success', structured_output: issues }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams({ structuredDialect: 'anthropic-output-config' })); + expect(result.structuredOutput).toEqual(issues); + expect(result.output).toBe(''); + }); + it('rethrows when query async generator throws', async () => { mockQuery.mockReturnValue( (async function* () { diff --git a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts index e0d425ba..7317eafc 100644 --- a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts @@ -44,6 +44,7 @@ function makeParams(overrides: Partial = {}): AgentAdapterPa cwd: CWD, maxTurns: 10, toolConfig: { bash: {} }, + structuredDialect: 'unstructured', ...overrides, }; } @@ -102,6 +103,19 @@ describe('OpenAIAdapter — run()', () => { expect(result.output).toBe('{"key":"val"}'); }); + it('returns structuredOutput when structuredDialect is not unstructured', async () => { + const issues = [{ description: 'sql injection', confidence: 9 }]; + mockRunFn.mockResolvedValue({ + finalOutput: { issues }, + state: { usage: { inputTokens: 10, outputTokens: 5 } }, + } as any); + const result = await new OpenAIAdapter().run( + makeParams({ structuredDialect: 'openai-json-schema-strict' }), + ); + expect(result.structuredOutput).toEqual(issues); + expect(result.output).toBe(''); + }); + it('passes maxTurns to run()', async () => { mockRunFn.mockResolvedValue(makeRunResult('[]') as any); await new OpenAIAdapter().run(makeParams({ maxTurns: 42 })); diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 3a316651..7b3b5206 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -102,6 +102,37 @@ describe('AgenticExecutor — execute()', () => { expect(result).toEqual([]); }); + it('parses issues from structuredOutput when adapter returns it', async () => { + const issue = { + type: 'security', + severity: 'high', + description: 'SQL injection', + location: 'src/db.ts:10', + confidence: 9, + }; + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', structuredOutput: [issue] })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + const result = await executor.execute([{ path: 'src/db.ts', content: 'query(input)' }]); + expect(result).toHaveLength(1); + expect(result[0].description).toBe('SQL injection'); + }); + + it('invokes onToolCall when adapter calls it back', async () => { + const onToolCallCapture: { turn: number; name: string }[] = []; + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async (params) => { + params.onToolCall?.(1, 'read_file', { filePath: 'src/foo.ts' }); + onToolCallCapture.push({ turn: 1, name: 'read_file' }); + return { output: '[]' }; + }), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); + expect(onToolCallCapture[0]).toEqual({ turn: 1, name: 'read_file' }); + }); + it('rethrows when adapter throws', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => { From d375a49273822ac882a773d2b8bff4db7b656bc2 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 14:12:20 +0300 Subject: [PATCH 02/18] refactor(agentic): always enforce structured output, remove unstructured fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../review/agentic/adapters/agent-adapter.ts | 3 - .../agentic/adapters/anthropic-adapter.ts | 5 +- .../adapters/configurable-agent-adapter.ts | 6 +- .../review/agentic/adapters/openai-adapter.ts | 32 ++------- src/stages/review/agentic/agentic-executor.ts | 18 +++-- .../adapters/anthropic-adapter.spec.ts | 3 +- .../agentic/adapters/openai-adapter.spec.ts | 55 ++++++--------- .../review/agentic/agentic-executor.spec.ts | 67 +++++++++++++++++++ 8 files changed, 108 insertions(+), 81 deletions(-) diff --git a/src/stages/review/agentic/adapters/agent-adapter.ts b/src/stages/review/agentic/adapters/agent-adapter.ts index dc51d42d..d65c5ea4 100644 --- a/src/stages/review/agentic/adapters/agent-adapter.ts +++ b/src/stages/review/agentic/adapters/agent-adapter.ts @@ -1,4 +1,3 @@ -import type { StructuredOutputDialect } from '../../../../ai/providers/capabilities'; import type { BashConfig } from '../../../../shared/types/config'; import type { ResolvedAgentDefinition } from '../subagents/definitions'; @@ -19,8 +18,6 @@ export interface AgentAdapterParams { toolConfig: ToolConfig; onToolCall?: (turn: number, name: string, input: unknown) => void; baseUrl?: string; - /** Pre-resolved structured output dialect from detectCapabilities(). */ - structuredDialect: StructuredOutputDialect; } export type AgentErrorSubtype = diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index bb57c8de..b49f3162 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -1,6 +1,5 @@ import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; -import { isUnstructured } from '../../../../ai/providers/capabilities'; import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { createToolSet, type ToolSet } from '../tools'; @@ -59,9 +58,7 @@ function buildQueryOptions( ...(model && { model }), cwd, permissionMode: 'bypassPermissions', - ...(!isUnstructured(params.structuredDialect) && { - outputFormat: { type: 'json_schema' as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, - }), + outputFormat: { type: 'json_schema' as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, }; } diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 48e0e60b..8bd317cb 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -8,7 +8,6 @@ import type { AgentAdapterResult, AgentErrorSubtype, } from './agent-adapter'; -import { isUnstructured } from '../../../../ai/providers/capabilities'; import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { schemaToJsonSchema } from '../../../../ai/shared/structured'; import { envConfig } from '../../../../config/env'; @@ -45,7 +44,6 @@ function buildSystemPrompt(params: AgentAdapterParams, toolNames: string[]): str } function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { - const useStructured = !isUnstructured(params.structuredDialect); return { systemPrompt: buildSystemPrompt(params, toolNames), model: { @@ -55,9 +53,7 @@ function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { }, agent: { maxSteps: params.maxTurns }, mcpTools: [], - output: useStructured - ? { structured: true as const, schema: REVIEW_ISSUES_JSON_SCHEMA } - : { structured: false as const }, + output: { structured: true as const, schema: REVIEW_ISSUES_JSON_SCHEMA }, safety: { compaction: { triggerTokens: 100_000, keepRecentMessages: 6 }, toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 }, diff --git a/src/stages/review/agentic/adapters/openai-adapter.ts b/src/stages/review/agentic/adapters/openai-adapter.ts index 2f0c4297..cab512c6 100644 --- a/src/stages/review/agentic/adapters/openai-adapter.ts +++ b/src/stages/review/agentic/adapters/openai-adapter.ts @@ -2,7 +2,6 @@ import { Agent, getGlobalTraceProvider, run, setDefaultOpenAIClient, tool } from import { z } from 'zod'; import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter'; -import { isUnstructured } from '../../../../ai/providers/capabilities'; import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; @@ -42,49 +41,30 @@ export class OpenAIAdapter implements AgentAdapter { }); }); - const useStructured = !isUnstructured(params.structuredDialect); - const orchestrator = new Agent({ name: 'qualops-reviewer', model, instructions: systemPrompt, tools, handoffs, - ...(useStructured && { outputType: ReviewOutputSchema }), + outputType: ReviewOutputSchema, }); - logger.info( - `[Agentic/OpenAI] Starting run with model=${model}, maxTurns=${maxTurns}, structured=${useStructured}`, - ); + logger.info(`[Agentic/OpenAI] Starting run with model=${model}, maxTurns=${maxTurns}`); const result = await run(orchestrator, userPrompt, { maxTurns }); const usage = result.state.usage; + const structured = result.finalOutput as z.infer | null; logger.info( `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, ); - - if (useStructured) { - const structured = result.finalOutput as z.infer | null; - logger.info('[Agentic/OpenAI] Received structured output from SDK'); - return { - output: '', - structuredOutput: structured?.issues, - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - }; - } - - const output = - typeof result.finalOutput === 'string' - ? result.finalOutput - : result.finalOutput != null - ? JSON.stringify(result.finalOutput) - : ''; + logger.info('[Agentic/OpenAI] Received structured output from SDK'); return { - output, + output: '', + structuredOutput: structured?.issues, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, }; diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index d895bd5b..9301874b 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -10,7 +10,7 @@ import { type AgentDefinition, type ResolvedAgentDefinition, } from './subagents/definitions'; -import { detectCapabilities } from '../../../ai/providers/capabilities'; +import { detectCapabilities, isUnstructured } from '../../../ai/providers/capabilities'; import { ConfigService } from '../../../config/config'; import { getTracer, @@ -94,11 +94,20 @@ export class AgenticExecutor { try { const stageConfig = ConfigService.getInstance().getResolvedStageConfig('review'); const { structuredDialect } = detectCapabilities(stageConfig.provider, this.model); + + // Agentic mode requires structured output — unstructured models must use the prose + // pipeline. pipeline-executor.ts enforces this at line 59, so this should never fire. + if (isUnstructured(structuredDialect)) { + throw new Error( + `[Agentic] Job "${this.job.name}" cannot run in agentic mode with an unstructured model ` + + `(provider=${stageConfig.provider}, model=${this.model}). ` + + `Configure a model that supports JSON schema output.`, + ); + } + const adapter = createAgentAdapter(stageConfig.provider); - logger.info( - `[Agentic] Using adapter for provider: ${stageConfig.provider}, structuredDialect: ${structuredDialect}`, - ); + logger.info(`[Agentic] Using adapter for provider: ${stageConfig.provider}`); const result = await adapter.run({ systemPrompt, @@ -116,7 +125,6 @@ export class AgenticExecutor { }, }, baseUrl: stageConfig.baseUrl, - structuredDialect, onToolCall: (turn, name, input) => { turnIndex = turn; allToolCalls.push({ turn, name, input }); diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 6b7a9d51..d7d46797 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -29,7 +29,6 @@ function makeParams(overrides: Partial = {}): AgentAdapterPa cwd: process.cwd(), maxTurns: 10, toolConfig: { bash: {} }, - structuredDialect: 'unstructured', ...overrides, }; } @@ -149,7 +148,7 @@ describe('AnthropicAdapter', () => { })(), ); const adapter = new AnthropicAdapter(); - const result = await adapter.run(makeParams({ structuredDialect: 'anthropic-output-config' })); + const result = await adapter.run(makeParams()); expect(result.structuredOutput).toEqual(issues); expect(result.output).toBe(''); }); diff --git a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts index 7317eafc..72dd0101 100644 --- a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts @@ -44,14 +44,13 @@ function makeParams(overrides: Partial = {}): AgentAdapterPa cwd: CWD, maxTurns: 10, toolConfig: { bash: {} }, - structuredDialect: 'unstructured', ...overrides, }; } -function makeRunResult(finalOutput: string, inputTokens = 0, outputTokens = 0) { +function makeRunResult(inputTokens = 0, outputTokens = 0) { return { - finalOutput, + finalOutput: { issues: [] }, state: { usage: { inputTokens, outputTokens } }, }; } @@ -72,64 +71,48 @@ describe('OpenAIAdapter — run()', () => { MockAgentCtor.mockImplementation(() => ({}) as InstanceType); }); - it('returns output from run() finalOutput', async () => { - mockRunFn.mockResolvedValue(makeRunResult('["issue1"]') as any); + it('returns structuredOutput from run() finalOutput.issues', async () => { + const issues = [{ description: 'issue1', confidence: 9 }]; + mockRunFn.mockResolvedValue({ + finalOutput: { issues }, + state: { usage: { inputTokens: 0, outputTokens: 0 } }, + } as any); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('["issue1"]'); + expect(result.structuredOutput).toEqual(issues); + expect(result.output).toBe(''); }); it('extracts token counts from state.usage', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]', 120, 60) as any); + mockRunFn.mockResolvedValue(makeRunResult(120, 60) as any); const result = await new OpenAIAdapter().run(makeParams()); expect(result.inputTokens).toBe(120); expect(result.outputTokens).toBe(60); }); - it('returns empty string when finalOutput is null', async () => { + it('returns undefined structuredOutput when finalOutput is null', async () => { mockRunFn.mockResolvedValue({ finalOutput: null, state: { usage: { inputTokens: 0, outputTokens: 0 } }, } as any); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe(''); - }); - - it('serialises non-string finalOutput to JSON', async () => { - mockRunFn.mockResolvedValue({ - finalOutput: { key: 'val' }, - state: { usage: { inputTokens: 0, outputTokens: 0 } }, - } as any); - const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('{"key":"val"}'); - }); - - it('returns structuredOutput when structuredDialect is not unstructured', async () => { - const issues = [{ description: 'sql injection', confidence: 9 }]; - mockRunFn.mockResolvedValue({ - finalOutput: { issues }, - state: { usage: { inputTokens: 10, outputTokens: 5 } }, - } as any); - const result = await new OpenAIAdapter().run( - makeParams({ structuredDialect: 'openai-json-schema-strict' }), - ); - expect(result.structuredOutput).toEqual(issues); + expect(result.structuredOutput).toBeUndefined(); expect(result.output).toBe(''); }); it('passes maxTurns to run()', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run(makeParams({ maxTurns: 42 })); expect((mockRunFn.mock.calls[0][2] as { maxTurns: number }).maxTurns).toBe(42); }); it('creates one orchestrator when agents is empty', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run(makeParams()); expect(MockAgentCtor).toHaveBeenCalledTimes(1); }); it('creates orchestrator + handoff agent for each entry in agents', async () => { - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); await new OpenAIAdapter().run( makeParams({ agents: { @@ -154,7 +137,7 @@ describe('OpenAIAdapter — tools', () => { beforeEach(() => { jest.clearAllMocks(); MockAgentCtor.mockImplementation((opts: any) => opts as InstanceType); - mockRunFn.mockResolvedValue(makeRunResult('[]') as any); + mockRunFn.mockResolvedValue(makeRunResult() as any); }); async function getToolExecute(name: string) { @@ -238,9 +221,9 @@ describe('OpenAIAdapter — tools', () => { it('continues without bash tool when startBashSession throws', async () => { mockStartBashSession.mockRejectedValueOnce(new Error('spawn failed')); - mockRunFn.mockResolvedValue(makeRunResult('[]') as never); + mockRunFn.mockResolvedValue(makeRunResult() as never); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe('[]'); + expect(result.output).toBe(''); }); it('calls dispose in finally even when run throws', async () => { diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 7b3b5206..04ccef48 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -8,7 +8,12 @@ jest.mock('@/stages/review/agentic/adapters', () => ({ createAgentAdapter: jest.fn(), })); jest.mock('@/shared/utils/logger'); +jest.mock('@/ai/providers/capabilities', () => ({ + ...jest.requireActual('@/ai/providers/capabilities'), + detectCapabilities: jest.fn(() => ({ structuredDialect: 'anthropic-output-config' })), +})); +import { detectCapabilities } from '@/ai/providers/capabilities'; import type { PipelineJob } from '@/shared/types/config'; import { createAgentAdapter } from '@/stages/review/agentic/adapters'; import type { @@ -17,6 +22,8 @@ import type { } from '@/stages/review/agentic/adapters/agent-adapter'; import { AgenticExecutor } from '@/stages/review/agentic/agentic-executor'; +const mockDetectCapabilities = detectCapabilities as jest.MockedFunction; + const mockCreateAgentAdapter = createAgentAdapter as jest.MockedFunction; // AgenticExecutor resolves prompts relative to process.cwd()/.qualops/prompts @@ -69,6 +76,11 @@ async function runExecutor(job: PipelineJob): Promise { describe('AgenticExecutor — execute()', () => { beforeEach(() => { mockCreateAgentAdapter.mockReset(); + mockDetectCapabilities.mockReturnValue({ + structuredDialect: 'anthropic-output-config', + supportsTemperature: true, + maxTokensField: 'max_tokens', + }); }); it('returns empty array immediately when no files provided', async () => { @@ -133,6 +145,56 @@ describe('AgenticExecutor — execute()', () => { expect(onToolCallCapture[0]).toEqual({ turn: 1, name: 'read_file' }); }); + it('throws on hard failure error subtypes', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', errorSubtype: 'error_rate_limit_tokens' as const })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'error_rate_limit_tokens', + ); + }); + + it('warns but continues on soft error subtypes', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', errorSubtype: 'error_max_turns' as const })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + const result = await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); + expect(result).toEqual([]); + }); + + it('warns and returns empty when output is empty and errorSubtype is set', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', errorSubtype: 'error_max_turns' as const })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + const result = await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); + expect(result).toEqual([]); + }); + + it('throws when non-empty text output contains no JSON', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: 'No issues found in this code.' })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'no parseable JSON issues', + ); + }); + + it('throws when detectCapabilities returns unstructured dialect', async () => { + mockDetectCapabilities.mockReturnValue({ + structuredDialect: 'unstructured', + supportsTemperature: true, + maxTokensField: 'max_tokens', + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'llama-3'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'cannot run in agentic mode with an unstructured model', + ); + }); + it('rethrows when adapter throws', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => { @@ -156,6 +218,11 @@ describe('AgenticExecutor — execute()', () => { describe('AgenticExecutor — systemPrompt / prompt composition', () => { beforeEach(() => { mockCreateAgentAdapter.mockReset(); + mockDetectCapabilities.mockReturnValue({ + structuredDialect: 'anthropic-output-config', + supportsTemperature: true, + maxTokensField: 'max_tokens', + }); }); it('passes empty system prompt when neither systemPrompt nor prompt is set', async () => { From d9b1485345eb4c753758d6b60e00e10ebf4e23c0 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 14:16:18 +0300 Subject: [PATCH 03/18] test(agentic): remove duplicate tests, fix misleading assertions and 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) --- .../adapters/anthropic-adapter.spec.ts | 22 ++++---------- .../agentic/adapters/openai-adapter.spec.ts | 10 +++++-- .../review/agentic/agentic-executor.spec.ts | 29 +++++++------------ 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index d7d46797..ec945f9f 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -39,7 +39,7 @@ describe('AnthropicAdapter', () => { mockCreateToolSet.mockResolvedValue({ tools: [], dispose: jest.fn() }); }); - it('returns output from a successful result message', async () => { + it('falls back to text output when structured_output is absent from result', async () => { mockQuery.mockReturnValue( (async function* () { yield { type: 'result', subtype: 'success', result: '["issue1"]' }; @@ -48,6 +48,7 @@ describe('AnthropicAdapter', () => { const adapter = new AnthropicAdapter(); const result = await adapter.run(makeParams()); expect(result.output).toBe('["issue1"]'); + expect(result.structuredOutput).toBeUndefined(); }); it('extracts token counts from usage in result message', async () => { @@ -67,25 +68,13 @@ describe('AnthropicAdapter', () => { expect(result.outputTokens).toBe(50); }); - it('returns empty output and errorSubtype when result subtype is not success', async () => { - mockQuery.mockReturnValue( - (async function* () { - yield { type: 'result', subtype: 'error_max_turns' }; - })(), - ); - const adapter = new AnthropicAdapter(); - const result = await adapter.run(makeParams()); - expect(result.output).toBe(''); - expect(result.errorSubtype).toBe('error_max_turns'); - }); - it.each([ ['error_max_turns', 'error_max_turns'], ['error_during_execution', 'error_provider_unavailable'], ['error_max_budget_usd', 'error_rate_limit_tokens'], ['error_max_structured_output_retries', 'error_content_filter'], ] as const)( - 'maps SDK subtype "%s" to qualops subtype "%s"', + 'maps SDK subtype "%s" to qualops subtype "%s" and returns empty output', async (sdkSubtype, expectedSubtype) => { mockQuery.mockReturnValue( (async function* () { @@ -95,6 +84,7 @@ describe('AnthropicAdapter', () => { const adapter = new AnthropicAdapter(); const result = await adapter.run(makeParams()); expect(result.errorSubtype).toBe(expectedSubtype); + expect(result.output).toBe(''); }, ); @@ -191,7 +181,7 @@ describe('AnthropicAdapter', () => { expect((callOptions.tools as string[]).includes('Bash')).toBe(false); }); - it('logs mcp bash tool_use and tool_result output', async () => { + it('invokes onToolCall for mcp bash tool_use blocks', async () => { mockQuery.mockReturnValue( (async function* () { yield { @@ -234,7 +224,7 @@ describe('AnthropicAdapter', () => { }); }); - it('logs assistant text blocks', async () => { + it('processes assistant text blocks without affecting output', async () => { mockQuery.mockReturnValue( (async function* () { yield { diff --git a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts index 72dd0101..0bdb5dba 100644 --- a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts @@ -219,11 +219,15 @@ describe('OpenAIAdapter — tools', () => { expect(onToolCall).toHaveBeenCalledWith(1, 'read_file', { filePath: 'src/foo.ts' }); }); - it('continues without bash tool when startBashSession throws', async () => { + it('continues and returns structuredOutput when startBashSession throws', async () => { + const issues = [{ description: 'issue', confidence: 8 }]; mockStartBashSession.mockRejectedValueOnce(new Error('spawn failed')); - mockRunFn.mockResolvedValue(makeRunResult() as never); + mockRunFn.mockResolvedValue({ + finalOutput: { issues }, + state: { usage: { inputTokens: 0, outputTokens: 0 } }, + } as never); const result = await new OpenAIAdapter().run(makeParams()); - expect(result.output).toBe(''); + expect(result.structuredOutput).toEqual(issues); }); it('calls dispose in finally even when run throws', async () => { diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 04ccef48..9447d8a0 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -131,18 +131,18 @@ describe('AgenticExecutor — execute()', () => { expect(result[0].description).toBe('SQL injection'); }); - it('invokes onToolCall when adapter calls it back', async () => { - const onToolCallCapture: { turn: number; name: string }[] = []; + it('passes onToolCall to adapter and tracks turn index', async () => { + let capturedOnToolCall: AgentAdapterParams['onToolCall']; mockCreateAgentAdapter.mockReturnValue({ - run: jest.fn(async (params) => { - params.onToolCall?.(1, 'read_file', { filePath: 'src/foo.ts' }); - onToolCallCapture.push({ turn: 1, name: 'read_file' }); + run: jest.fn(async (params: AgentAdapterParams) => { + capturedOnToolCall = params.onToolCall; + params.onToolCall?.(3, 'read_file', { filePath: 'src/foo.ts' }); return { output: '[]' }; }), }); const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); - expect(onToolCallCapture[0]).toEqual({ turn: 1, name: 'read_file' }); + expect(capturedOnToolCall).toBeDefined(); }); it('throws on hard failure error subtypes', async () => { @@ -155,16 +155,7 @@ describe('AgenticExecutor — execute()', () => { ); }); - it('warns but continues on soft error subtypes', async () => { - mockCreateAgentAdapter.mockReturnValue({ - run: jest.fn(async () => ({ output: '', errorSubtype: 'error_max_turns' as const })), - }); - const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); - const result = await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); - expect(result).toEqual([]); - }); - - it('warns and returns empty when output is empty and errorSubtype is set', async () => { + it('returns empty and does not throw on soft error subtypes', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => ({ output: '', errorSubtype: 'error_max_turns' as const })), }); @@ -207,11 +198,13 @@ describe('AgenticExecutor — execute()', () => { ); }); - it('passes resolved provider to createAgentAdapter', async () => { + it('passes the provider from ConfigService to createAgentAdapter', async () => { setupMockAdapter(); const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); await executor.execute([{ path: 'src/foo.ts', content: 'x' }]); - expect(mockCreateAgentAdapter).toHaveBeenCalledWith(expect.any(String)); + // ConfigService reads from .qualopsrc.json; provider must be a known AIProviderName + const calledWith = mockCreateAgentAdapter.mock.calls[0][0]; + expect(['anthropic', 'openai', 'openai-compatible', 'github', 'bedrock']).toContain(calledWith); }); }); From af59e15df2970130c9921a0b87f32eb61c393cb1 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 14:24:25 +0300 Subject: [PATCH 04/18] refactor: log structured output preview and drop dead unstructured guard in agentic executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../agentic/adapters/anthropic-adapter.ts | 3 +- src/stages/review/agentic/agentic-executor.ts | 13 --------- .../review/agentic/agentic-executor.spec.ts | 29 ------------------- 3 files changed, 2 insertions(+), 43 deletions(-) diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index b49f3162..bd07cc4a 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -154,7 +154,8 @@ function handleResultMessage( state.inputTokens = msg.usage?.input_tokens; state.outputTokens = msg.usage?.output_tokens; if (msg.structured_output !== undefined) { - logger.info('[Agentic/Anthropic] Received structured output from SDK'); + const preview = JSON.stringify(msg.structured_output).substring(0, 500); + logger.info(`[Agentic/Anthropic] Structured output (first 500 chars): ${preview}`); state.structuredOutput = msg.structured_output; } else if (msg.result) { logger.info( diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 9301874b..c0f3d1eb 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -10,7 +10,6 @@ import { type AgentDefinition, type ResolvedAgentDefinition, } from './subagents/definitions'; -import { detectCapabilities, isUnstructured } from '../../../ai/providers/capabilities'; import { ConfigService } from '../../../config/config'; import { getTracer, @@ -93,18 +92,6 @@ export class AgenticExecutor { try { const stageConfig = ConfigService.getInstance().getResolvedStageConfig('review'); - const { structuredDialect } = detectCapabilities(stageConfig.provider, this.model); - - // Agentic mode requires structured output — unstructured models must use the prose - // pipeline. pipeline-executor.ts enforces this at line 59, so this should never fire. - if (isUnstructured(structuredDialect)) { - throw new Error( - `[Agentic] Job "${this.job.name}" cannot run in agentic mode with an unstructured model ` + - `(provider=${stageConfig.provider}, model=${this.model}). ` + - `Configure a model that supports JSON schema output.`, - ); - } - const adapter = createAgentAdapter(stageConfig.provider); logger.info(`[Agentic] Using adapter for provider: ${stageConfig.provider}`); diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 9447d8a0..0bfdc0f9 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -8,12 +8,7 @@ jest.mock('@/stages/review/agentic/adapters', () => ({ createAgentAdapter: jest.fn(), })); jest.mock('@/shared/utils/logger'); -jest.mock('@/ai/providers/capabilities', () => ({ - ...jest.requireActual('@/ai/providers/capabilities'), - detectCapabilities: jest.fn(() => ({ structuredDialect: 'anthropic-output-config' })), -})); -import { detectCapabilities } from '@/ai/providers/capabilities'; import type { PipelineJob } from '@/shared/types/config'; import { createAgentAdapter } from '@/stages/review/agentic/adapters'; import type { @@ -22,8 +17,6 @@ import type { } from '@/stages/review/agentic/adapters/agent-adapter'; import { AgenticExecutor } from '@/stages/review/agentic/agentic-executor'; -const mockDetectCapabilities = detectCapabilities as jest.MockedFunction; - const mockCreateAgentAdapter = createAgentAdapter as jest.MockedFunction; // AgenticExecutor resolves prompts relative to process.cwd()/.qualops/prompts @@ -76,11 +69,6 @@ async function runExecutor(job: PipelineJob): Promise { describe('AgenticExecutor — execute()', () => { beforeEach(() => { mockCreateAgentAdapter.mockReset(); - mockDetectCapabilities.mockReturnValue({ - structuredDialect: 'anthropic-output-config', - supportsTemperature: true, - maxTokensField: 'max_tokens', - }); }); it('returns empty array immediately when no files provided', async () => { @@ -174,18 +162,6 @@ describe('AgenticExecutor — execute()', () => { ); }); - it('throws when detectCapabilities returns unstructured dialect', async () => { - mockDetectCapabilities.mockReturnValue({ - structuredDialect: 'unstructured', - supportsTemperature: true, - maxTokensField: 'max_tokens', - }); - const executor = new AgenticExecutor(makeJob(), undefined, 'llama-3'); - await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( - 'cannot run in agentic mode with an unstructured model', - ); - }); - it('rethrows when adapter throws', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => { @@ -211,11 +187,6 @@ describe('AgenticExecutor — execute()', () => { describe('AgenticExecutor — systemPrompt / prompt composition', () => { beforeEach(() => { mockCreateAgentAdapter.mockReset(); - mockDetectCapabilities.mockReturnValue({ - structuredDialect: 'anthropic-output-config', - supportsTemperature: true, - maxTokensField: 'max_tokens', - }); }); it('passes empty system prompt when neither systemPrompt nor prompt is set', async () => { From 2b72a322f650a687d061adbc047baad0974209ec Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 14:43:07 +0300 Subject: [PATCH 05/18] fix: wrap ReviewIssuesSchema in root object for SDK structured output compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../review/agentic/adapters/anthropic-adapter.ts | 11 ++++++++--- .../agentic/adapters/configurable-agent-adapter.ts | 13 ++++++++++--- .../agentic/adapters/anthropic-adapter.spec.ts | 5 +++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index bd07cc4a..fee386e2 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -1,4 +1,5 @@ import { query, tool, createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; import { ReviewIssuesSchema } from '../../../../ai/shared/schemas/review-issue'; import { schemaToJsonSchema } from '../../../../ai/shared/structured'; @@ -11,7 +12,9 @@ import type { } from './agent-adapter'; import { logger } from '../../../../shared/utils/logger'; -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewIssuesSchema); +// SDK requires a root object schema — wrap the array in { issues: [...] } +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema); type QueryOptions = Parameters[0]['options']; @@ -154,9 +157,11 @@ function handleResultMessage( state.inputTokens = msg.usage?.input_tokens; state.outputTokens = msg.usage?.output_tokens; if (msg.structured_output !== undefined) { - const preview = JSON.stringify(msg.structured_output).substring(0, 500); + const wrapper = msg.structured_output as { issues?: unknown }; + const issues = wrapper.issues ?? msg.structured_output; + const preview = JSON.stringify(issues).substring(0, 500); logger.info(`[Agentic/Anthropic] Structured output (first 500 chars): ${preview}`); - state.structuredOutput = msg.structured_output; + state.structuredOutput = issues; } else if (msg.result) { logger.info( `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 8bd317cb..a659d168 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -14,7 +14,9 @@ import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet } from '../tools'; -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewIssuesSchema); +// generateObject (Vercel AI SDK) requires a root object schema — wrap the array +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema); function toJsonSchema(schema: z.ZodObject): Record { // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. @@ -132,8 +134,13 @@ export class ConfigurableAgentAdapter implements AgentAdapter { case 'final': { const finalEvent = event as typeof event & { structured?: unknown }; if (finalEvent.structured !== undefined) { - logger.info('[Agentic/ConfigurableAgent] Received structured output from SDK'); - structuredOutput = finalEvent.structured; + const wrapper = finalEvent.structured as { issues?: unknown }; + const issues = wrapper.issues ?? finalEvent.structured; + const preview = JSON.stringify(issues).substring(0, 500); + logger.info( + `[Agentic/ConfigurableAgent] Structured output (first 500 chars): ${preview}`, + ); + structuredOutput = issues; } else { output = event.content; } diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index ec945f9f..795e93cd 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -130,11 +130,12 @@ describe('AnthropicAdapter', () => { expect(callOptions.maxBudgetUsd).toBe(2.5); }); - it('returns structuredOutput when result contains structured_output', async () => { + it('returns structuredOutput when result contains structured_output wrapper', async () => { const issues = [{ description: 'sql injection', confidence: 9 }]; mockQuery.mockReturnValue( (async function* () { - yield { type: 'result', subtype: 'success', structured_output: issues }; + // SDK returns the root object wrapper { issues: [...] } matching our schema + yield { type: 'result', subtype: 'success', structured_output: { issues } }; })(), ); const adapter = new AnthropicAdapter(); From ba520589410850a2a8aa497fcbc05075ab71c42e Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 14:51:11 +0300 Subject: [PATCH 06/18] fix: strip unsupported JSON Schema constraints for Anthropic structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ai/shared/schemas/review-issue.ts | 9 +--- .../structured/schema-to-json-schema.ts | 42 +++++++++++++++- .../agentic/adapters/anthropic-adapter.ts | 8 +++- .../adapters/configurable-agent-adapter.ts | 8 +++- .../structured/schema-to-json-schema.spec.ts | 48 +++++++++++++++++++ 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/ai/shared/schemas/review-issue.ts b/src/ai/shared/schemas/review-issue.ts index a72989e3..9f2d1947 100644 --- a/src/ai/shared/schemas/review-issue.ts +++ b/src/ai/shared/schemas/review-issue.ts @@ -10,7 +10,7 @@ export const ReviewIssueItemSchema = z .describe( 'Impact severity. critical=exploitable/data-loss; high=functional bug; medium=quality; low=style', ), - description: z.string().min(1).describe('One-sentence summary of the problem'), + description: z.string().describe('One-sentence summary of the problem'), location: z .string() .describe( @@ -27,12 +27,7 @@ export const ReviewIssueItemSchema = z 'Short code snippet illustrating the issue. Use \\n for newlines inside the JSON string.', ), suggestion: z.string().default('').describe('Concrete fix the author should apply'), - confidence: z - .number() - .int() - .min(1) - .max(10) - .describe('Self-rated confidence from 1 (speculative) to 10 (certain)'), + confidence: z.number().describe('Self-rated confidence from 1 (speculative) to 10 (certain)'), impact: z.string().optional().describe('Impact if exploited (security issues only)'), cwe: z.string().optional().describe('CWE identifier (e.g. "CWE-79") if applicable'), threat_model: z diff --git a/src/ai/shared/structured/schema-to-json-schema.ts b/src/ai/shared/structured/schema-to-json-schema.ts index 1771186e..44b7babb 100644 --- a/src/ai/shared/structured/schema-to-json-schema.ts +++ b/src/ai/shared/structured/schema-to-json-schema.ts @@ -9,14 +9,52 @@ export interface SchemaToJsonOptions { * Throws on incompatibility instead of silently mutating. */ enforceStrictDialect?: boolean; + /** + * When true, strip numeric and string constraints (minimum, maximum, multipleOf, + * minLength, maxLength) that are not supported by Anthropic's structured output + * constrained decoding (output_config.format / --json-schema). + */ + stripUnsupportedConstraints?: boolean; +} + +// Constraint keys not supported by Anthropic's structured output constrained decoding. +const UNSUPPORTED_CONSTRAINT_KEYS = new Set([ + 'minimum', + 'maximum', + 'exclusiveMinimum', + 'exclusiveMaximum', + 'multipleOf', + 'minLength', + 'maxLength', + 'pattern', + 'minItems', + 'maxItems', +]); + +function stripConstraints(node: unknown): unknown { + if (node === null || typeof node !== 'object') return node; + if (Array.isArray(node)) return node.map(stripConstraints); + const obj = node as Record; + return Object.fromEntries( + Object.entries(obj) + .filter(([k]) => !UNSUPPORTED_CONSTRAINT_KEYS.has(k)) + .map(([k, v]) => [k, stripConstraints(v)]), + ); } export function schemaToJsonSchema( schema: S, options: SchemaToJsonOptions = {}, ): Record { - const { target = 'draft-2020-12', enforceStrictDialect = false } = options; - const json = z.toJSONSchema(schema, { target }) as Record; + const { + target = 'draft-2020-12', + enforceStrictDialect = false, + stripUnsupportedConstraints = false, + } = options; + let json = z.toJSONSchema(schema, { target }) as Record; + if (stripUnsupportedConstraints) { + json = stripConstraints(json) as Record; + } if (enforceStrictDialect) { validateStrictDialect(json, '$'); } diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index fee386e2..4a0afbcc 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -12,9 +12,13 @@ import type { } from './agent-adapter'; import { logger } from '../../../../shared/utils/logger'; -// SDK requires a root object schema — wrap the array in { issues: [...] } +// SDK requires a root object schema — wrap the array in { issues: [...] }. +// stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are +// not supported by Anthropic's structured output constrained decoding. const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema); +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema, { + stripUnsupportedConstraints: true, +}); type QueryOptions = Parameters[0]['options']; diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index a659d168..e4bab9bc 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -14,9 +14,13 @@ import { envConfig } from '../../../../config/env'; import { logger } from '../../../../shared/utils/logger'; import { createToolSet } from '../tools'; -// generateObject (Vercel AI SDK) requires a root object schema — wrap the array +// generateObject (Vercel AI SDK) requires a root object schema — wrap the array. +// stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are +// not supported by constrained-decoding structured output implementations. const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema); +const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema, { + stripUnsupportedConstraints: true, +}); function toJsonSchema(schema: z.ZodObject): Record { // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. diff --git a/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts b/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts index b8645aa2..886bd072 100644 --- a/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts +++ b/tests/unit/ai/shared/structured/schema-to-json-schema.spec.ts @@ -34,6 +34,54 @@ describe('schemaToJsonSchema', () => { expect(out.description).toBe('a list'); }); + describe('stripUnsupportedConstraints', () => { + it('removes minimum, maximum, multipleOf from number fields', () => { + const out = schemaToJsonSchema(z.object({ n: z.number().int().min(1).max(10) }), { + stripUnsupportedConstraints: true, + }) as Record>>; + const nProp = out.properties.n; + expect(nProp.minimum).toBeUndefined(); + expect(nProp.maximum).toBeUndefined(); + expect(nProp.multipleOf).toBeUndefined(); + expect(nProp.type).toBe('integer'); + }); + + it('removes minLength, maxLength from string fields', () => { + const out = schemaToJsonSchema(z.object({ s: z.string().min(1).max(100) }), { + stripUnsupportedConstraints: true, + }) as Record>>; + const sProp = out.properties.s; + expect(sProp.minLength).toBeUndefined(); + expect(sProp.maxLength).toBeUndefined(); + expect(sProp.type).toBe('string'); + }); + + it('preserves description and required fields', () => { + const out = schemaToJsonSchema( + z.object({ s: z.string().min(1).describe('non-empty string') }), + { stripUnsupportedConstraints: true }, + ) as Record>>; + expect(out.properties.s.description).toBe('non-empty string'); + expect((out.required as string[]).includes('s')).toBe(true); + }); + + it('strips constraints at all nesting levels', () => { + const out = schemaToJsonSchema( + z.object({ items: z.array(z.object({ n: z.number().min(0) })) }), + { stripUnsupportedConstraints: true }, + ) as Record>>>>; + expect(out.properties.items.items.properties.n.minimum).toBeUndefined(); + }); + + it('does not strip constraints when option is false (default)', () => { + const out = schemaToJsonSchema(z.object({ n: z.number().min(1) })) as Record< + string, + Record> + >; + expect(out.properties.n.minimum).toBe(1); + }); + }); + describe('strict-dialect validation', () => { it('accepts a schema with all properties required and additionalProperties:false', () => { expect(() => From 8e48e54be2068a40e33bf4401e63f3c6b5c5ddba Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 15:09:30 +0300 Subject: [PATCH 07/18] chore: upgrade @anthropic-ai/claude-agent-sdk to 0.3.179 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package-lock.json | 86 ++++++++++++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4e9ae580..f06e2928 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.6", "license": "MIT", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.178", + "@anthropic-ai/claude-agent-sdk": "^0.3.179", "@anthropic-ai/sdk": "^0.104.2", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", "@eggai/configurable-agent": "^0.2.1", @@ -217,22 +217,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.178.tgz", - "integrity": "sha512-PNsz20jWuahDWq7OU+pjrgYzr2TnpC1oj5yCZDxGWJ8OvucXIdD2AYlf/vUo7oE2JJwGbMTBFqXErNrfPi6Ffw==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.179.tgz", + "integrity": "sha512-BlZULVW61ZCcho6mb026QoOQbGZnfxbsEtcoNaW5lF0sIEu7D+/nnQjHSMK8buS/h7HVmIx2RtGbHVX1y4V0uQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.178", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.178", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.178" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.179", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.179", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.179" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -241,9 +241,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.178.tgz", - "integrity": "sha512-RmIJRoZfjwcrd7cHR3fJOL1d4L8SN7oB0REcQPbIuPM1vav2Ft3g5hBX7I86u52A4LLFKBc3SMom3+E6lR1YtQ==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.179.tgz", + "integrity": "sha512-2QoEl7p+RTkF4ARZ40N9OWWi+at8Iy9ZZg3UeP6sWoGrM0GL6NJSv8pbYUdoSRySbNeZshqWar5DF41265J5Sg==", "cpu": [ "arm64" ], @@ -254,9 +254,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.178.tgz", - "integrity": "sha512-wfNl7JoaUk9IzKWQPr+hyEOX8qnkM+e4GBZnKISh60xVhW9wOOp5RdfnYj5MoJzaLYivMSCbo5rb4ThWguhOMw==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.179.tgz", + "integrity": "sha512-U5683Hi4uP5Wkg9IhHayqx8co9rgeZaAJcutLAgJiAN9ZnL128+WT0K4DKaBVuMbeeoORodVs9IJgM38lBxUxg==", "cpu": [ "x64" ], @@ -280,9 +280,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.178.tgz", - "integrity": "sha512-Z3U0fNatVK3vkki3e5sjlLJMjros+cT6uq//tdohB2kjNK1CugUuPQFQxd6GU1Lq1DokmdSEPL4OGdKiHckNdQ==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.179.tgz", + "integrity": "sha512-ehqWR9bV0dJONkoKleKg/LJIGDVevLGLPVIQpol+Bqrgyv05DE1hx68g6mBnfp9IEKo2BMZCba3aHKhkHlZvnQ==", "cpu": [ "arm64" ], @@ -306,9 +306,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.178.tgz", - "integrity": "sha512-K84Ybyr0Olsslg2I1Tzd7KIcSkZgFqqDHn7q1Dfqi7bXVxjMjJ4SaV+LnEAeHSM2es7H8A7ejoTEl89xMZde4Q==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.179.tgz", + "integrity": "sha512-Q+clijh01m+Gg/tSNjHQWfPFRvXSVMIQJZqu6v28vFduaucL7ZL+p6o6VOSdlgLjhqtIFFjOO37aL+VkvEu9MQ==", "cpu": [ "x64" ], @@ -319,9 +319,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.178.tgz", - "integrity": "sha512-uKH3vgv7cQV3d9BPU4lrUKrFgoU/flmy/1QI62j9JzgE6uWVQKWC+BI4V7iceJ36tYVneRhjjuux+l+Q8egTHA==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.179.tgz", + "integrity": "sha512-7wc4j3vDE/TiuiCEPV024U/TDNKXUJ89V0N9WteYJ57YrIUm0RA51WiKCrEmSxSqstQ7Slq26e0gWGHRf7ljDQ==", "cpu": [ "arm64" ], @@ -332,9 +332,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.178", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.178.tgz", - "integrity": "sha512-wSC5qG6UA1laWGylOU7axuC4NQxZJtibGQ79sYeOQCc05G7CfkUKSzszLOzsPP3eK63cKi/bX5PA6dd4nA5Wow==", + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.179.tgz", + "integrity": "sha512-gFad1AVUZOXbEyS9lf3Pr0LDXrLuO6limZoZoUZQmjhjy0LnudnTU8P/VbCY0AHmT46YMZ/ieisFzeq8X5jTcg==", "cpu": [ "x64" ], @@ -344,6 +344,32 @@ "win32" ] }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.179.tgz", + "integrity": "sha512-Io9X2xF5J3cHR20b0oflLe0sLHyT/KFMCA7sVJhwuSvcqeCHqXTxn6sG+4lArD9wNf+RtLVKgSvOt5Oz4j7mew==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.179", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.179.tgz", + "integrity": "sha512-Ck2pLG2GJ5lYULK0Rb6FvAUhkLSvIFgJ77MsbVhvBJHG3C31Fuk6FnkXEL614WSZZdI1ST0Y7s7wc9yLI2kmiQ==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@anthropic-ai/sdk": { "version": "0.104.2", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.104.2.tgz", diff --git a/package.json b/package.json index d3f562a3..67e0b550 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "generate:schema": "ts-node --transpile-only --project tsconfig.lib.json scripts/generate-config-schema.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.178", + "@anthropic-ai/claude-agent-sdk": "^0.3.179", "@anthropic-ai/sdk": "^0.104.2", "@aws-sdk/client-bedrock-runtime": "^3.1008.0", "@eggai/configurable-agent": "^0.2.1", From 1748fedca2c1263ca13b584eb6bb55711ff83451 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 15:33:53 +0300 Subject: [PATCH 08/18] fix(agentic): restore plaintext JSON recovery for truncated model responses - 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) --- src/ai/shared/schemas/review-issue.ts | 9 +++++++-- src/ai/shared/structured/extract-json.ts | 4 ++++ src/stages/review/agentic/result-parser.ts | 4 +++- .../ai/shared/structured/extract-json.spec.ts | 12 ++++++++++++ .../review/agentic/result-parser.spec.ts | 18 ++++++++++++++++++ 5 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/ai/shared/schemas/review-issue.ts b/src/ai/shared/schemas/review-issue.ts index 9f2d1947..a72989e3 100644 --- a/src/ai/shared/schemas/review-issue.ts +++ b/src/ai/shared/schemas/review-issue.ts @@ -10,7 +10,7 @@ export const ReviewIssueItemSchema = z .describe( 'Impact severity. critical=exploitable/data-loss; high=functional bug; medium=quality; low=style', ), - description: z.string().describe('One-sentence summary of the problem'), + description: z.string().min(1).describe('One-sentence summary of the problem'), location: z .string() .describe( @@ -27,7 +27,12 @@ export const ReviewIssueItemSchema = z 'Short code snippet illustrating the issue. Use \\n for newlines inside the JSON string.', ), suggestion: z.string().default('').describe('Concrete fix the author should apply'), - confidence: z.number().describe('Self-rated confidence from 1 (speculative) to 10 (certain)'), + confidence: z + .number() + .int() + .min(1) + .max(10) + .describe('Self-rated confidence from 1 (speculative) to 10 (certain)'), impact: z.string().optional().describe('Impact if exploited (security issues only)'), cwe: z.string().optional().describe('CWE identifier (e.g. "CWE-79") if applicable'), threat_model: z diff --git a/src/ai/shared/structured/extract-json.ts b/src/ai/shared/structured/extract-json.ts index 0723e260..cfd72c14 100644 --- a/src/ai/shared/structured/extract-json.ts +++ b/src/ai/shared/structured/extract-json.ts @@ -15,6 +15,10 @@ export function extractJsonText(response: string): ExtractedJson | null { const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); if (fenced?.[1]) return { text: fenced[1].trim(), source: 'fenced' }; + // Unclosed code fence (truncated response): extract everything after the opening ```json/``` + const unclosedFence = trimmed.match(/```(?:json)?\s*([\s\S]+)/i); + if (unclosedFence?.[1]) return { text: unclosedFence[1].trim(), source: 'fenced' }; + if (looksLikeJson(trimmed)) return { text: trimmed, source: 'raw' }; const arrayMatch = trimmed.match(/(\[[\s\S]*\])/); diff --git a/src/stages/review/agentic/result-parser.ts b/src/stages/review/agentic/result-parser.ts index 2333cb6d..98114ec7 100644 --- a/src/stages/review/agentic/result-parser.ts +++ b/src/stages/review/agentic/result-parser.ts @@ -158,7 +158,9 @@ export function recoverPartialJsonArray(text: string): unknown[] { depth--; if (depth === 0 && start !== -1) { try { - results.push(JSON.parse(text.slice(start, i + 1))); + const chunk = text.slice(start, i + 1); + const fixed = escapeUnescapedControlChars(chunk.replace(/,(\s*[}\]])/g, '$1')); + results.push(JSON.parse(fixed)); } catch { // skip malformed object } diff --git a/tests/unit/ai/shared/structured/extract-json.spec.ts b/tests/unit/ai/shared/structured/extract-json.spec.ts index 6fd6ec86..a15a2c86 100644 --- a/tests/unit/ai/shared/structured/extract-json.spec.ts +++ b/tests/unit/ai/shared/structured/extract-json.spec.ts @@ -40,6 +40,18 @@ describe('extractJsonText', () => { it('returns null when no JSON-like content found', () => { expect(extractJsonText('just plain text')).toBeNull(); }); + + it('extracts JSON from an unclosed fenced block (truncated response)', () => { + const out = extractJsonText('Here is the result:\n```json\n[{"a":1},{"b":2}'); + expect(out?.source).toBe('fenced'); + expect(out?.text).toBe('[{"a":1},{"b":2}'); + }); + + it('extracts JSON from unclosed bare ``` block', () => { + const out = extractJsonText('Result:\n```\n{"key":"value"'); + expect(out?.source).toBe('fenced'); + expect(out?.text).toBe('{"key":"value"'); + }); }); describe('escapeUnescapedControlChars', () => { diff --git a/tests/unit/stages/review/agentic/result-parser.spec.ts b/tests/unit/stages/review/agentic/result-parser.spec.ts index e99a5b05..fda1707c 100644 --- a/tests/unit/stages/review/agentic/result-parser.spec.ts +++ b/tests/unit/stages/review/agentic/result-parser.spec.ts @@ -206,6 +206,14 @@ describe('parseIssuesFromResult', () => { expect(result).toHaveLength(1); expect(result[0].description).toBe('Issue 1'); }); + + it('parses issues from prose + unclosed fenced code block (truncated model response)', () => { + const prose = + 'I have enough information. Let me reason through each file carefully.\n\n```json\n[{"type":"bug","severity":"high","description":"Mismatch","location":"src/foo.ts:1","confidence":8},{"type":"security","severity":"critical","description":"Truncated'; + const result = parseIssuesFromResult(prose, files, 'job', CWD); + expect(result).toHaveLength(1); + expect(result[0].description).toBe('Mismatch'); + }); }); describe('recoverPartialJsonArray', () => { @@ -227,4 +235,14 @@ describe('recoverPartialJsonArray', () => { const input = '[{"a":{"nested":1}},{"b":2},{"c":"trunc'; expect(recoverPartialJsonArray(input)).toEqual([{ a: { nested: 1 } }, { b: 2 }]); }); + + it('recovers objects with trailing commas after fixing', () => { + const input = '[{"a":1,},{"b":2,}]'; + expect(recoverPartialJsonArray(input)).toEqual([{ a: 1 }, { b: 2 }]); + }); + + it('recovers objects with raw newlines inside string values', () => { + const input = '[{"a":"line1\nline2","b":1}]'; + expect(recoverPartialJsonArray(input)).toEqual([{ a: 'line1\nline2', b: 1 }]); + }); }); From 9dc5afdabeede55b0db0ec60d3fc216ef2cbc8e8 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 16:01:53 +0300 Subject: [PATCH 09/18] fix(agentic): strip \$schema from outputFormat schema to enable CLI structured 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. --- src/stages/review/agentic/adapters/anthropic-adapter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index 4a0afbcc..69fa6fd3 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -15,8 +15,10 @@ import { logger } from '../../../../shared/utils/logger'; // SDK requires a root object schema — wrap the array in { issues: [...] }. // stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are // not supported by Anthropic's structured output constrained decoding. +// The $schema field (draft-2020-12 URI) must be omitted — the CLI rejects +// structured output when it is present and falls back to plain text. const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema, { +const { $schema: _dropped, ...REVIEW_ISSUES_JSON_SCHEMA } = schemaToJsonSchema(ReviewOutputSchema, { stripUnsupportedConstraints: true, }); From 6ce490587be680d06e856e7826bf0dcc85bd6c55 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 16:51:17 +0300 Subject: [PATCH 10/18] fix: apply array-root wrapping in AnthropicProvider for structured output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ai/providers/anthropic.ts | 18 +++-- .../review/processors/dedup-resolver.ts | 8 ++- src/stages/review/processors/file-reviewer.ts | 12 +++- .../review/processors/validation-resolver.ts | 8 ++- .../ai/shared/structured/array-root.spec.ts | 72 +++++++++++++++++++ 5 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 tests/unit/ai/shared/structured/array-root.spec.ts diff --git a/src/ai/providers/anthropic.ts b/src/ai/providers/anthropic.ts index 6a6da948..4d697647 100644 --- a/src/ai/providers/anthropic.ts +++ b/src/ai/providers/anthropic.ts @@ -6,6 +6,8 @@ import { resolveSchemaName, schemaToJsonSchema, StructuredOutputError, + unwrapArrayRootResult, + wrapArrayRootSchema, } from '@/ai/shared/structured'; import { estimateTokens } from '@/ai/shared/token-utils'; import { ConfigService } from '@/config/config'; @@ -124,6 +126,12 @@ export class AnthropicProvider extends BaseAIProvider { const { use1HourCache, system } = this.buildSystemBlocks(messages, systemPrompt); const schemaName = resolveSchemaName(options.schema, options.schemaName); + // Anthropic's structured-output dialects require an object at the schema root. + // Wrap array-rooted schemas into { items: [...] } and unwrap the parsed payload. + const { schema: requestSchema, wrapped } = wrapArrayRootSchema(options.schema); + const unwrap = (value: unknown): z.infer => + (wrapped ? unwrapArrayRootResult(value) : value) as z.infer; + if (this.capabilities.structuredDialect === 'anthropic-output-config') { try { const response = await client.messages.parse({ @@ -132,7 +140,7 @@ export class AnthropicProvider extends BaseAIProvider { temperature, system, messages: this.toAnthropicMessages(messages), - output_config: { format: zodOutputFormat(options.schema) }, + output_config: { format: zodOutputFormat(requestSchema) }, }); const raw = this.extractText(response); if (response.parsed_output == null) { @@ -140,7 +148,7 @@ export class AnthropicProvider extends BaseAIProvider { } this.recordResponseUsage(response, messages, raw, use1HourCache); return { - content: response.parsed_output as z.infer, + content: unwrap(response.parsed_output), raw, usage: this.toTokenUsage(response.usage), model: response.model, @@ -152,7 +160,7 @@ export class AnthropicProvider extends BaseAIProvider { } // tool_use fallback for Claude < 4.5 - const toolSchema = schemaToJsonSchema(options.schema) as Record; + const toolSchema = schemaToJsonSchema(requestSchema) as Record; try { const response = await client.messages.create({ model, @@ -177,7 +185,7 @@ export class AnthropicProvider extends BaseAIProvider { if (!toolUseBlock) { throw new StructuredOutputError('Anthropic returned no tool_use block', raw); } - const parsed = options.schema.safeParse(toolUseBlock.input); + const parsed = requestSchema.safeParse(toolUseBlock.input); if (!parsed.success) { throw new StructuredOutputError( `Schema validation failed: ${parsed.error.message}`, @@ -187,7 +195,7 @@ export class AnthropicProvider extends BaseAIProvider { } this.recordResponseUsage(response, messages, raw, use1HourCache); return { - content: parsed.data, + content: unwrap(parsed.data), raw, usage: this.toTokenUsage(response.usage), model: response.model, diff --git a/src/stages/review/processors/dedup-resolver.ts b/src/stages/review/processors/dedup-resolver.ts index cd1cd652..abf05377 100644 --- a/src/stages/review/processors/dedup-resolver.ts +++ b/src/stages/review/processors/dedup-resolver.ts @@ -1,5 +1,9 @@ +import { z } from 'zod'; + import type { AIProvider } from '../../../ai/providers/provider'; import { DedupIndicesSchema } from '../../../ai/shared/schemas/dedup-indices'; + +const DedupOutputSchema = z.object({ indices: DedupIndicesSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import type { ReviewIssue } from '../../../shared/types'; import type { @@ -111,11 +115,11 @@ export class DeduplicationResolver { try { const response = await this.aiProvider.complete({ messages: [{ role: 'user', content: prompt }], - schema: DedupIndicesSchema, + schema: DedupOutputSchema, maxTokens: 4000, temperature: 0, }); - const indices = new Set(response.content); + const indices = new Set(response.content.indices); return issues.filter((_, idx) => indices.has(idx)); } catch (error) { if (error instanceof StructuredOutputError) { diff --git a/src/stages/review/processors/file-reviewer.ts b/src/stages/review/processors/file-reviewer.ts index ecda3e3a..4bd04483 100644 --- a/src/stages/review/processors/file-reviewer.ts +++ b/src/stages/review/processors/file-reviewer.ts @@ -1,5 +1,11 @@ +import { z } from 'zod'; + import type { AIMessage, AIProvider } from '../../../ai/providers/provider'; import { ReviewIssuesSchema, type ReviewIssueItem } from '../../../ai/shared/schemas/review-issue'; + +// Both Anthropic and OpenAI strict structured output reject a root array schema. +// Wrap in an object and unwrap after parsing. +const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import { getTracer, @@ -44,12 +50,14 @@ export class FileReviewer { try { const response = await this.aiProvider.complete({ messages, - schema: ReviewIssuesSchema, + schema: ReviewOutputSchema, maxTokens: this.aiProvider.getMaxTokens(), temperature: this.aiProvider.getTemperature(), }); - const parsedIssues = response.content.map((item) => this.toReviewIssue(item, file.path)); + const parsedIssues = response.content.issues.map((item) => + this.toReviewIssue(item, file.path), + ); setTokenUsage(span, { model: this.aiProvider.getModelName(), diff --git a/src/stages/review/processors/validation-resolver.ts b/src/stages/review/processors/validation-resolver.ts index d3ddbdd2..7a3ee6f6 100644 --- a/src/stages/review/processors/validation-resolver.ts +++ b/src/stages/review/processors/validation-resolver.ts @@ -1,8 +1,12 @@ +import { z } from 'zod'; + import type { AIProvider } from '../../../ai/providers/provider'; import { ValidationResultsSchema, type ValidationResultItem, } from '../../../ai/shared/schemas/validation-result'; + +const ValidationOutputSchema = z.object({ validations: ValidationResultsSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import type { ReviewIssue } from '../../../shared/types'; import type { @@ -114,11 +118,11 @@ export class ValidationResolver { try { const response = await this.aiProvider.complete({ messages: [{ role: 'user', content: prompt }], - schema: ValidationResultsSchema, + schema: ValidationOutputSchema, maxTokens: 8000, temperature: 0, }); - validations = response.content; + validations = response.content.validations; } catch (error) { if (error instanceof StructuredOutputError) { logger.warn(`[Validation] Structured output failed: ${error.message}`); diff --git a/tests/unit/ai/shared/structured/array-root.spec.ts b/tests/unit/ai/shared/structured/array-root.spec.ts new file mode 100644 index 00000000..51a211cc --- /dev/null +++ b/tests/unit/ai/shared/structured/array-root.spec.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; + +import { + ARRAY_ROOT_WRAP_KEY, + isArrayRootSchema, + unwrapArrayRootResult, + wrapArrayRootSchema, +} from '@/ai/shared/structured'; + +describe('isArrayRootSchema', () => { + it('returns true for z.array()', () => { + expect(isArrayRootSchema(z.array(z.string()))).toBe(true); + }); + + it('returns false for z.object()', () => { + expect(isArrayRootSchema(z.object({ a: z.string() }))).toBe(false); + }); + + it('returns false for z.string()', () => { + expect(isArrayRootSchema(z.string())).toBe(false); + }); +}); + +describe('wrapArrayRootSchema', () => { + it('wraps a z.array() schema into { [ARRAY_ROOT_WRAP_KEY]: schema }', () => { + const inner = z.array(z.string()); + const { schema, wrapped } = wrapArrayRootSchema(inner); + expect(wrapped).toBe(true); + expect(schema).toBeInstanceOf(z.ZodObject); + const shape = (schema as z.ZodObject).shape; + expect(shape[ARRAY_ROOT_WRAP_KEY]).toBe(inner); + }); + + it('returns the schema unchanged when it is already an object', () => { + const obj = z.object({ x: z.number() }); + const { schema, wrapped } = wrapArrayRootSchema(obj); + expect(wrapped).toBe(false); + expect(schema).toBe(obj); + }); + + it('wrapped schema validates array payload correctly', () => { + const { schema } = wrapArrayRootSchema(z.array(z.number())); + const result = schema.safeParse({ [ARRAY_ROOT_WRAP_KEY]: [1, 2, 3] }); + expect(result.success).toBe(true); + }); +}); + +describe('unwrapArrayRootResult', () => { + it('returns a bare array as-is', () => { + expect(unwrapArrayRootResult([1, 2, 3])).toEqual([1, 2, 3]); + }); + + it('unwraps { items: [...] } to the inner array', () => { + expect(unwrapArrayRootResult({ [ARRAY_ROOT_WRAP_KEY]: [1, 2] })).toEqual([1, 2]); + }); + + it('coerces a single object with no wrap key to a one-element array', () => { + const obj = { foo: 'bar' }; + expect(unwrapArrayRootResult(obj)).toEqual([obj]); + }); + + it('returns value unchanged for null/primitive', () => { + expect(unwrapArrayRootResult(null)).toBeNull(); + expect(unwrapArrayRootResult(42)).toBe(42); + }); + + it('returns { items: null } inner value (null, not coerced)', () => { + // inner is null, not an array → falls through to return value as-is + const val = { [ARRAY_ROOT_WRAP_KEY]: null }; + expect(unwrapArrayRootResult(val)).toEqual(val); + }); +}); From 7adb4c9c0dce6ff454e4ba47c8404d3957e160f1 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 16:56:42 +0300 Subject: [PATCH 11/18] feat: wire array-root wrapping and non-strict json_schema for OpenAI/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 --- .../providers/openai-compatible-provider.ts | 44 ++++++++++------ src/ai/shared/structured/array-root.ts | 50 +++++++++++++++++++ src/ai/shared/structured/index.ts | 6 +++ .../adapters/configurable-agent-adapter.ts | 32 ++++++++++-- .../review/processors/dedup-resolver.ts | 8 +-- src/stages/review/processors/file-reviewer.ts | 12 +---- .../review/processors/validation-resolver.ts | 8 +-- 7 files changed, 120 insertions(+), 40 deletions(-) create mode 100644 src/ai/shared/structured/array-root.ts diff --git a/src/ai/providers/openai-compatible-provider.ts b/src/ai/providers/openai-compatible-provider.ts index 58623550..ecf8e8ed 100644 --- a/src/ai/providers/openai-compatible-provider.ts +++ b/src/ai/providers/openai-compatible-provider.ts @@ -1,5 +1,4 @@ import type OpenAI from 'openai'; -import { zodResponseFormat } from 'openai/helpers/zod'; import type { ChatCompletionCreateParamsNonStreaming, ChatCompletionMessageParam, @@ -11,6 +10,8 @@ import { resolveSchemaName, schemaToJsonSchema, StructuredOutputError, + wrapArrayRootSchema, + unwrapArrayRootResult, } from '@/ai/shared/structured'; import { estimateTokens } from '@/ai/shared/token-utils'; import type { ResolvedStageConfig } from '@/shared/types'; @@ -112,23 +113,36 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { const baseParams = this.buildBaseParams(options); const schemaName = resolveSchemaName(options.schema, options.schemaName); + // OpenAI's structured-output dialects require an object at the schema root, but + // several qualops review schemas are array-rooted. Wrap those into { items: [...] } + // for the request and unwrap the parsed payload, transparently to the caller. + const { schema: requestSchema, wrapped } = wrapArrayRootSchema(options.schema); + const unwrap = (value: unknown): z.infer => + (wrapped ? unwrapArrayRootResult(value) : value) as z.infer; + if (this.capabilities.structuredDialect === 'openai-json-schema-strict') { try { - const completion = await client.chat.completions.parse({ + // Use a non-strict `json_schema` response_format rather than the strict + // `zodResponseFormat` helper. Strict mode forbids `.optional()` fields unless + // they are also `.nullable()`, which several review schemas rely on; non-strict + // json_schema still constrains the model to the shape, and we validate the + // result with zod ourselves. + const jsonSchema = schemaToJsonSchema(requestSchema); + const response = await client.chat.completions.create({ ...baseParams, - response_format: zodResponseFormat(options.schema, schemaName), + response_format: { + type: 'json_schema', + json_schema: { name: schemaName, schema: jsonSchema, strict: false }, + }, }); - const message = completion.choices[0]?.message; - const raw = message?.content ?? ''; - if (!message?.parsed) { - throw new StructuredOutputError('OpenAI strict parser returned no parsed payload', raw); - } - this.recordResponseUsage(completion, baseParams, raw); + const raw = response.choices[0]?.message?.content ?? ''; + const parsed = parseAndValidate(raw, requestSchema); + this.recordResponseUsage(response, baseParams, raw); return { - content: message.parsed as z.infer, + content: unwrap(parsed), raw, - usage: this.toTokenUsage(completion.usage), - model: completion.model, + usage: this.toTokenUsage(response.usage), + model: response.model, }; } catch (error) { if (error instanceof StructuredOutputError) throw error; @@ -137,7 +151,7 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { } // json_object dialect: ask for JSON, validate with zod ourselves. - const jsonSchema = schemaToJsonSchema(options.schema); + const jsonSchema = schemaToJsonSchema(requestSchema); const schemaInstruction = `Respond with a single JSON value that conforms to this JSON Schema. Do not wrap in markdown.\n\n` + `Schema (${schemaName}):\n${JSON.stringify(jsonSchema, null, 2)}`; @@ -154,10 +168,10 @@ export abstract class OpenAICompatibleProvider extends BaseAIProvider { response_format: { type: 'json_object' }, }); const raw = response.choices[0]?.message?.content ?? ''; - const parsed = parseAndValidate(raw, options.schema); + const parsed = parseAndValidate(raw, requestSchema); this.recordResponseUsage(response, baseParams, raw); return { - content: parsed, + content: unwrap(parsed), raw, usage: this.toTokenUsage(response.usage), model: response.model, diff --git a/src/ai/shared/structured/array-root.ts b/src/ai/shared/structured/array-root.ts new file mode 100644 index 00000000..6f1b35fa --- /dev/null +++ b/src/ai/shared/structured/array-root.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +/** + * The property name used to wrap an array-root schema into an object. + * OpenAI's structured-output dialects (json_schema strict and, in practice, + * json_object with schema guidance) require the root schema to be an object — + * a top-level array is rejected with "Root schema must have type: 'object'". + * Several qualops review schemas are array-rooted (review issues, validation + * results, root-cause classifications, dedup indices), so we transparently wrap + * them in `{ : [...] }` for the request and unwrap the parsed payload. + */ +export const ARRAY_ROOT_WRAP_KEY = 'items'; + +/** True when the schema's JSON-Schema root is an array (and therefore needs wrapping). */ +export function isArrayRootSchema(schema: z.ZodType): boolean { + // z.ZodArray is the only zod type that serializes to a root `type: "array"`. + return schema instanceof z.ZodArray; +} + +/** + * If `schema` is array-rooted, return an object schema `{ items: schema }` so it can + * be sent to a provider that requires an object root. Otherwise return the schema + * unchanged. `wrapped` tells the caller whether to unwrap the response. + */ +export function wrapArrayRootSchema( + schema: S, +): { schema: z.ZodType; wrapped: boolean } { + if (isArrayRootSchema(schema)) { + return { schema: z.object({ [ARRAY_ROOT_WRAP_KEY]: schema }), wrapped: true }; + } + return { schema, wrapped: false }; +} + +/** + * Unwrap a parsed payload produced against a wrapped array-root schema. Tolerant of + * shape drift: accepts the wrapper object `{ items: [...] }`, a bare array, or a + * single object (coerced to a one-element array) so a stray shape is not silently + * dropped to an empty result. + */ +export function unwrapArrayRootResult(value: unknown): unknown { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object') { + const wrapper = value as Record; + const inner = wrapper[ARRAY_ROOT_WRAP_KEY]; + if (Array.isArray(inner)) return inner; + // A single object that looks like one item rather than the wrapper. + if (!(ARRAY_ROOT_WRAP_KEY in wrapper)) return [value]; + } + return value; +} diff --git a/src/ai/shared/structured/index.ts b/src/ai/shared/structured/index.ts index b01f0ba7..98893f08 100644 --- a/src/ai/shared/structured/index.ts +++ b/src/ai/shared/structured/index.ts @@ -1,4 +1,10 @@ export { extractJsonText, escapeUnescapedControlChars } from './extract-json'; +export { + ARRAY_ROOT_WRAP_KEY, + isArrayRootSchema, + wrapArrayRootSchema, + unwrapArrayRootResult, +} from './array-root'; export { schemaToJsonSchema } from './schema-to-json-schema'; export { resolveSchemaName } from './schema-name'; export { parseAndValidate, StructuredOutputError } from './validate'; diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index e4bab9bc..349377a5 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -22,6 +22,21 @@ const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema, { stripUnsupportedConstraints: true, }); +/** + * Extract the issues array from a structured-output payload. Tolerant of shape drift: + * accepts the wrapper `{ issues: [...] }`, a bare array, or a single issue object + * (coerced to a one-element array), so an unexpected shape is not silently dropped. + */ +function unwrapStructuredIssues(structured: unknown): unknown { + if (Array.isArray(structured)) return structured; + if (structured && typeof structured === 'object') { + const wrapper = structured as { issues?: unknown }; + if (Array.isArray(wrapper.issues)) return wrapper.issues; + if (!('issues' in wrapper)) return [structured]; + } + return structured; +} + function toJsonSchema(schema: z.ZodObject): Record { // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. // Strip it and emit a plain draft-07-compatible object instead. @@ -70,7 +85,16 @@ function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) { function buildModel(params: AgentAdapterParams) { const baseURL = params.baseUrl ?? envConfig.get('openaiBaseUrl') ?? 'https://api.openai.com/v1'; const apiKey = envConfig.get('openaiApiKey') ?? ''; - return createOpenAICompatible({ name: 'openai-compatible', baseURL, apiKey })(params.model); + // supportsStructuredOutputs makes the AI SDK send a `json_schema` response_format + // (constrained decoding) instead of loose `json_object`, so the model is forced to + // return the wrapped { issues: [...] } shape. Without it the model free-forms its + // output and the structured result silently parses to zero issues. + return createOpenAICompatible({ + name: 'openai-compatible', + baseURL, + apiKey, + supportsStructuredOutputs: true, + })(params.model); } export class ConfigurableAgentAdapter implements AgentAdapter { @@ -138,8 +162,10 @@ export class ConfigurableAgentAdapter implements AgentAdapter { case 'final': { const finalEvent = event as typeof event & { structured?: unknown }; if (finalEvent.structured !== undefined) { - const wrapper = finalEvent.structured as { issues?: unknown }; - const issues = wrapper.issues ?? finalEvent.structured; + // The schema is wrapped as { issues: [...] }, but be tolerant of shape + // drift (bare array, or a single object) so a stray response is not + // silently dropped to zero issues. + const issues = unwrapStructuredIssues(finalEvent.structured); const preview = JSON.stringify(issues).substring(0, 500); logger.info( `[Agentic/ConfigurableAgent] Structured output (first 500 chars): ${preview}`, diff --git a/src/stages/review/processors/dedup-resolver.ts b/src/stages/review/processors/dedup-resolver.ts index abf05377..cd1cd652 100644 --- a/src/stages/review/processors/dedup-resolver.ts +++ b/src/stages/review/processors/dedup-resolver.ts @@ -1,9 +1,5 @@ -import { z } from 'zod'; - import type { AIProvider } from '../../../ai/providers/provider'; import { DedupIndicesSchema } from '../../../ai/shared/schemas/dedup-indices'; - -const DedupOutputSchema = z.object({ indices: DedupIndicesSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import type { ReviewIssue } from '../../../shared/types'; import type { @@ -115,11 +111,11 @@ export class DeduplicationResolver { try { const response = await this.aiProvider.complete({ messages: [{ role: 'user', content: prompt }], - schema: DedupOutputSchema, + schema: DedupIndicesSchema, maxTokens: 4000, temperature: 0, }); - const indices = new Set(response.content.indices); + const indices = new Set(response.content); return issues.filter((_, idx) => indices.has(idx)); } catch (error) { if (error instanceof StructuredOutputError) { diff --git a/src/stages/review/processors/file-reviewer.ts b/src/stages/review/processors/file-reviewer.ts index 4bd04483..ecda3e3a 100644 --- a/src/stages/review/processors/file-reviewer.ts +++ b/src/stages/review/processors/file-reviewer.ts @@ -1,11 +1,5 @@ -import { z } from 'zod'; - import type { AIMessage, AIProvider } from '../../../ai/providers/provider'; import { ReviewIssuesSchema, type ReviewIssueItem } from '../../../ai/shared/schemas/review-issue'; - -// Both Anthropic and OpenAI strict structured output reject a root array schema. -// Wrap in an object and unwrap after parsing. -const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import { getTracer, @@ -50,14 +44,12 @@ export class FileReviewer { try { const response = await this.aiProvider.complete({ messages, - schema: ReviewOutputSchema, + schema: ReviewIssuesSchema, maxTokens: this.aiProvider.getMaxTokens(), temperature: this.aiProvider.getTemperature(), }); - const parsedIssues = response.content.issues.map((item) => - this.toReviewIssue(item, file.path), - ); + const parsedIssues = response.content.map((item) => this.toReviewIssue(item, file.path)); setTokenUsage(span, { model: this.aiProvider.getModelName(), diff --git a/src/stages/review/processors/validation-resolver.ts b/src/stages/review/processors/validation-resolver.ts index 7a3ee6f6..d3ddbdd2 100644 --- a/src/stages/review/processors/validation-resolver.ts +++ b/src/stages/review/processors/validation-resolver.ts @@ -1,12 +1,8 @@ -import { z } from 'zod'; - import type { AIProvider } from '../../../ai/providers/provider'; import { ValidationResultsSchema, type ValidationResultItem, } from '../../../ai/shared/schemas/validation-result'; - -const ValidationOutputSchema = z.object({ validations: ValidationResultsSchema }); import { StructuredOutputError } from '../../../ai/shared/structured'; import type { ReviewIssue } from '../../../shared/types'; import type { @@ -118,11 +114,11 @@ export class ValidationResolver { try { const response = await this.aiProvider.complete({ messages: [{ role: 'user', content: prompt }], - schema: ValidationOutputSchema, + schema: ValidationResultsSchema, maxTokens: 8000, temperature: 0, }); - validations = response.content.validations; + validations = response.content; } catch (error) { if (error instanceof StructuredOutputError) { logger.warn(`[Validation] Structured output failed: ${error.message}`); From dafb68fce2318020a37934e1deeea30dba6ff00b Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 17:45:22 +0300 Subject: [PATCH 12/18] fix(agentic): hard fail when structured output is not an array 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. --- src/stages/review/agentic/agentic-executor.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index c0f3d1eb..1f06ec8d 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -149,7 +149,12 @@ export class AgenticExecutor { } if (result.structuredOutput !== undefined) { - const rawIssues = Array.isArray(result.structuredOutput) ? result.structuredOutput : []; + if (!Array.isArray(result.structuredOutput)) { + throw new Error( + `[Agentic] Job "${this.job.name}" structured output is not an array. Got: ${JSON.stringify(result.structuredOutput).substring(0, 200)}`, + ); + } + const rawIssues = result.structuredOutput; const parsed = (rawIssues as Record[]) .filter((i) => ((i?.confidence as number) ?? 0) >= 7) .map((i, idx) => normalizeIssue(i, idx, files, this.job.name, this.cwd)); From 937720ef4b6d44066d4501c91c3f6ef5b0dc2a2d Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Wed, 17 Jun 2026 18:13:07 +0300 Subject: [PATCH 13/18] fix: tighten JSON extraction, structured output shape guard, and hasJsonContent check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/ai/shared/structured/extract-json.ts | 5 +++-- .../review/agentic/adapters/anthropic-adapter.ts | 13 +++++++++---- src/stages/review/agentic/agentic-executor.ts | 7 ++++--- .../unit/ai/shared/structured/extract-json.spec.ts | 6 +++--- .../agentic/adapters/anthropic-adapter.spec.ts | 11 +++++++++++ 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/ai/shared/structured/extract-json.ts b/src/ai/shared/structured/extract-json.ts index cfd72c14..36bf6f0e 100644 --- a/src/ai/shared/structured/extract-json.ts +++ b/src/ai/shared/structured/extract-json.ts @@ -15,8 +15,9 @@ export function extractJsonText(response: string): ExtractedJson | null { const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i); if (fenced?.[1]) return { text: fenced[1].trim(), source: 'fenced' }; - // Unclosed code fence (truncated response): extract everything after the opening ```json/``` - const unclosedFence = trimmed.match(/```(?:json)?\s*([\s\S]+)/i); + // Unclosed ```json fence (truncated response): extract everything after the opening marker. + // Restricted to explicit ```json — bare ``` could be any language block (Python, bash, etc.). + const unclosedFence = trimmed.match(/```json\s*([\s\S]+)/i); if (unclosedFence?.[1]) return { text: unclosedFence[1].trim(), source: 'fenced' }; if (looksLikeJson(trimmed)) return { text: trimmed, source: 'raw' }; diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index 69fa6fd3..8f0ea3bd 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -164,10 +164,15 @@ function handleResultMessage( state.outputTokens = msg.usage?.output_tokens; if (msg.structured_output !== undefined) { const wrapper = msg.structured_output as { issues?: unknown }; - const issues = wrapper.issues ?? msg.structured_output; - const preview = JSON.stringify(issues).substring(0, 500); - logger.info(`[Agentic/Anthropic] Structured output (first 500 chars): ${preview}`); - state.structuredOutput = issues; + if (!Array.isArray(wrapper.issues)) { + logger.warn( + `[Agentic/Anthropic] Unexpected structured_output shape — missing 'issues' array. Got: ${JSON.stringify(msg.structured_output).substring(0, 200)}`, + ); + } else { + const preview = JSON.stringify(wrapper.issues).substring(0, 500); + logger.info(`[Agentic/Anthropic] Structured output (first 500 chars): ${preview}`); + state.structuredOutput = wrapper.issues; + } } else if (msg.result) { logger.info( `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 1f06ec8d..723b2aa8 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -10,6 +10,7 @@ import { type AgentDefinition, type ResolvedAgentDefinition, } from './subagents/definitions'; +import { extractJsonText } from '../../../ai/shared/structured'; import { ConfigService } from '../../../config/config'; import { getTracer, @@ -163,9 +164,9 @@ export class AgenticExecutor { } else if (result.output) { logger.info(`[Agentic] Result (first 500 chars): ${result.output.substring(0, 500)}`); const parsed = parseIssuesFromResult(result.output, files, this.job.name, this.cwd); - // Only throw when output is non-empty but contains no extractable JSON at all. - // An empty array [] is a valid model response (no issues found) — not an error. - const hasJsonContent = result.output.includes('[') || result.output.includes('{'); + // Only throw when the output contains no JSON-like structure at all. + // parsed.length === 0 is valid when the model returns [] (no issues found). + const hasJsonContent = extractJsonText(result.output) !== null; if (parsed.length === 0 && result.output.trim().length > 0 && !hasJsonContent) { logger.warn( `[Agentic] No parseable issues from text output. Raw output preview:\n${result.output.substring(0, 2000)}`, diff --git a/tests/unit/ai/shared/structured/extract-json.spec.ts b/tests/unit/ai/shared/structured/extract-json.spec.ts index a15a2c86..576aff36 100644 --- a/tests/unit/ai/shared/structured/extract-json.spec.ts +++ b/tests/unit/ai/shared/structured/extract-json.spec.ts @@ -47,10 +47,10 @@ describe('extractJsonText', () => { expect(out?.text).toBe('[{"a":1},{"b":2}'); }); - it('extracts JSON from unclosed bare ``` block', () => { + it('does not extract from unclosed bare ``` block (could be any language)', () => { + // No extractable JSON — bare fence with incomplete object (no closing `}`) returns null const out = extractJsonText('Result:\n```\n{"key":"value"'); - expect(out?.source).toBe('fenced'); - expect(out?.text).toBe('{"key":"value"'); + expect(out).toBeNull(); }); }); diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 795e93cd..7d153906 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -144,6 +144,17 @@ describe('AnthropicAdapter', () => { expect(result.output).toBe(''); }); + it('does not set structuredOutput when issues key is missing from structured_output', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result', subtype: 'success', structured_output: { unexpected: 'shape' } }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams()); + expect(result.structuredOutput).toBeUndefined(); + }); + it('rethrows when query async generator throws', async () => { mockQuery.mockReturnValue( (async function* () { From 75f3a551115ca66f93b184407aa7be7168782846 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 18 Jun 2026 11:31:39 +0300 Subject: [PATCH 14/18] fix(agentic): fall back to result text when structured_output shape is unexpected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify success-result handling: check for a valid issues array first, then fall through to msg.result in all other cases with differentiated logging — warn when structured_output was present but malformed, info for normal text fallback. --- .../agentic/adapters/anthropic-adapter.ts | 23 ++++++++++--------- .../adapters/anthropic-adapter.spec.ts | 10 ++++++-- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/stages/review/agentic/adapters/anthropic-adapter.ts b/src/stages/review/agentic/adapters/anthropic-adapter.ts index 8f0ea3bd..6eb598ff 100644 --- a/src/stages/review/agentic/adapters/anthropic-adapter.ts +++ b/src/stages/review/agentic/adapters/anthropic-adapter.ts @@ -162,21 +162,22 @@ function handleResultMessage( if (msg.subtype === 'success') { state.inputTokens = msg.usage?.input_tokens; state.outputTokens = msg.usage?.output_tokens; - if (msg.structured_output !== undefined) { - const wrapper = msg.structured_output as { issues?: unknown }; - if (!Array.isArray(wrapper.issues)) { + const issues = (msg.structured_output as { issues?: unknown } | undefined)?.issues; + if (Array.isArray(issues)) { + logger.info( + `[Agentic/Anthropic] Structured output (first 500 chars): ${JSON.stringify(issues).substring(0, 500)}`, + ); + state.structuredOutput = issues; + } else if (msg.result) { + if (msg.structured_output !== undefined) { logger.warn( - `[Agentic/Anthropic] Unexpected structured_output shape — missing 'issues' array. Got: ${JSON.stringify(msg.structured_output).substring(0, 200)}`, + `[Agentic/Anthropic] Unexpected structured_output shape — falling back to text result. Got: ${JSON.stringify(msg.structured_output).substring(0, 200)}`, ); } else { - const preview = JSON.stringify(wrapper.issues).substring(0, 500); - logger.info(`[Agentic/Anthropic] Structured output (first 500 chars): ${preview}`); - state.structuredOutput = wrapper.issues; + logger.info( + `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, + ); } - } else if (msg.result) { - logger.info( - `[Agentic/Anthropic] Success result (first 500 chars): ${msg.result.substring(0, 500)}`, - ); state.output = msg.result; } } else if (msg.subtype !== 'success') { diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 7d153906..88880093 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -144,15 +144,21 @@ describe('AnthropicAdapter', () => { expect(result.output).toBe(''); }); - it('does not set structuredOutput when issues key is missing from structured_output', async () => { + it('falls back to result text when structured_output has unexpected shape', async () => { mockQuery.mockReturnValue( (async function* () { - yield { type: 'result', subtype: 'success', structured_output: { unexpected: 'shape' } }; + yield { + type: 'result', + subtype: 'success', + structured_output: { unexpected: 'shape' }, + result: '[{"description":"fallback issue"}]', + }; })(), ); const adapter = new AnthropicAdapter(); const result = await adapter.run(makeParams()); expect(result.structuredOutput).toBeUndefined(); + expect(result.output).toBe('[{"description":"fallback issue"}]'); }); it('rethrows when query async generator throws', async () => { From 5d7d33911dd65540516227c130b192a17bfa6d1b Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 18 Jun 2026 12:17:58 +0300 Subject: [PATCH 15/18] refactor: remove unused isUnstructured free function and minor cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capabilities.ts: remove isUnstructured() free function added in this branch — the equivalent method already exists on BaseAIProvider - agentic-executor.ts: remove redundant rawIssues alias - openai-adapter.ts: add defensive log when finalOutput is null or missing issues, consistent with anthropic-adapter warning --- src/ai/providers/capabilities.ts | 4 ---- src/stages/review/agentic/adapters/openai-adapter.ts | 6 +++++- src/stages/review/agentic/agentic-executor.ts | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ai/providers/capabilities.ts b/src/ai/providers/capabilities.ts index 927da395..457eb576 100644 --- a/src/ai/providers/capabilities.ts +++ b/src/ai/providers/capabilities.ts @@ -9,10 +9,6 @@ export type StructuredOutputDialect = | 'anthropic-tool-use' | 'unstructured'; // prose pipeline — model does not support json_schema -export function isUnstructured(dialect: StructuredOutputDialect): boolean { - return dialect === 'unstructured'; -} - export interface ProviderCapabilities { structuredDialect: StructuredOutputDialect; supportsTemperature: boolean; diff --git a/src/stages/review/agentic/adapters/openai-adapter.ts b/src/stages/review/agentic/adapters/openai-adapter.ts index cab512c6..e506d5b6 100644 --- a/src/stages/review/agentic/adapters/openai-adapter.ts +++ b/src/stages/review/agentic/adapters/openai-adapter.ts @@ -60,7 +60,11 @@ export class OpenAIAdapter implements AgentAdapter { logger.info( `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, ); - logger.info('[Agentic/OpenAI] Received structured output from SDK'); + if (structured?.issues) { + logger.info(`[Agentic/OpenAI] Structured output (${structured.issues.length} issues)`); + } else { + logger.warn('[Agentic/OpenAI] finalOutput was null or missing issues — returning empty'); + } return { output: '', diff --git a/src/stages/review/agentic/agentic-executor.ts b/src/stages/review/agentic/agentic-executor.ts index 723b2aa8..d53d4a1f 100644 --- a/src/stages/review/agentic/agentic-executor.ts +++ b/src/stages/review/agentic/agentic-executor.ts @@ -155,8 +155,7 @@ export class AgenticExecutor { `[Agentic] Job "${this.job.name}" structured output is not an array. Got: ${JSON.stringify(result.structuredOutput).substring(0, 200)}`, ); } - const rawIssues = result.structuredOutput; - const parsed = (rawIssues as Record[]) + const parsed = (result.structuredOutput as Record[]) .filter((i) => ((i?.confidence as number) ?? 0) >= 7) .map((i, idx) => normalizeIssue(i, idx, files, this.job.name, this.cwd)); issues.push(...parsed); From 0e2777f571c8455948c3885948d90e9f2e0f48a4 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 18 Jun 2026 12:38:16 +0300 Subject: [PATCH 16/18] test: cover structuredOutput non-array throw and malformed-shape no-result path - agentic-executor: assert throws when structuredOutput is not an array - anthropic-adapter: assert empty output when structured_output is malformed and no result text is present --- .../agentic/adapters/anthropic-adapter.spec.ts | 12 ++++++++++++ .../stages/review/agentic/agentic-executor.spec.ts | 10 ++++++++++ 2 files changed, 22 insertions(+) diff --git a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts index 88880093..70ea4f31 100644 --- a/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/anthropic-adapter.spec.ts @@ -161,6 +161,18 @@ describe('AnthropicAdapter', () => { expect(result.output).toBe('[{"description":"fallback issue"}]'); }); + it('returns empty output when structured_output is malformed and no result text', async () => { + mockQuery.mockReturnValue( + (async function* () { + yield { type: 'result', subtype: 'success', structured_output: { unexpected: 'shape' } }; + })(), + ); + const adapter = new AnthropicAdapter(); + const result = await adapter.run(makeParams()); + expect(result.structuredOutput).toBeUndefined(); + expect(result.output).toBe(''); + }); + it('rethrows when query async generator throws', async () => { mockQuery.mockReturnValue( (async function* () { diff --git a/tests/unit/stages/review/agentic/agentic-executor.spec.ts b/tests/unit/stages/review/agentic/agentic-executor.spec.ts index 0bfdc0f9..81a44ecc 100644 --- a/tests/unit/stages/review/agentic/agentic-executor.spec.ts +++ b/tests/unit/stages/review/agentic/agentic-executor.spec.ts @@ -152,6 +152,16 @@ describe('AgenticExecutor — execute()', () => { expect(result).toEqual([]); }); + it('throws when structuredOutput is not an array', async () => { + mockCreateAgentAdapter.mockReturnValue({ + run: jest.fn(async () => ({ output: '', structuredOutput: { unexpected: 'object' } })), + }); + const executor = new AgenticExecutor(makeJob(), undefined, 'test-model'); + await expect(executor.execute([{ path: 'src/foo.ts', content: 'x' }])).rejects.toThrow( + 'structured output is not an array', + ); + }); + it('throws when non-empty text output contains no JSON', async () => { mockCreateAgentAdapter.mockReturnValue({ run: jest.fn(async () => ({ output: 'No issues found in this code.' })), From cfcd9a390d31a44ae7cf931b2f10325d5d14745b Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 18 Jun 2026 13:56:09 +0300 Subject: [PATCH 17/18] fix(agentic): strip \$schema from configurable-agent output schema schemaToJsonSchema emits a \$schema (draft-2020-12 URI) which causes provider rejection in constrained decoding, the same issue that was already fixed for the Anthropic adapter. Apply the same destructuring to drop \$schema from REVIEW_ISSUES_JSON_SCHEMA. --- .../review/agentic/adapters/configurable-agent-adapter.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts index 349377a5..6d5301ca 100644 --- a/src/stages/review/agentic/adapters/configurable-agent-adapter.ts +++ b/src/stages/review/agentic/adapters/configurable-agent-adapter.ts @@ -18,7 +18,7 @@ import { createToolSet } from '../tools'; // stripUnsupportedConstraints removes minimum/maximum/minLength etc. which are // not supported by constrained-decoding structured output implementations. const ReviewOutputSchema = z.object({ issues: ReviewIssuesSchema }); -const REVIEW_ISSUES_JSON_SCHEMA = schemaToJsonSchema(ReviewOutputSchema, { +const { $schema: _dropped, ...REVIEW_ISSUES_JSON_SCHEMA } = schemaToJsonSchema(ReviewOutputSchema, { stripUnsupportedConstraints: true, }); @@ -38,8 +38,7 @@ function unwrapStructuredIssues(structured: unknown): unknown { } function toJsonSchema(schema: z.ZodObject): Record { - // z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers. - // Strip it and emit a plain draft-07-compatible object instead. + // Used for tool input schemas. Strip $schema (draft-2020-12 URI) which confuses some providers. const { $schema: _dropped, ...rest } = z.toJSONSchema(schema) as Record; return rest; } From 96d9202805a3d80e7510e19fb85e0c357fc03609 Mon Sep 17 00:00:00 2001 From: Valdis Pornieks Date: Thu, 18 Jun 2026 14:20:32 +0300 Subject: [PATCH 18/18] fix(agentic): throw when OpenAI finalOutput is missing instead of returning empty When the agent completes but finalOutput is null/undefined (e.g. max turns hit), throw an error so the failure is visible rather than silently producing 0 issues with no errorSubtype set. --- src/stages/review/agentic/adapters/openai-adapter.ts | 11 ++++++----- .../review/agentic/adapters/openai-adapter.spec.ts | 6 ++---- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/stages/review/agentic/adapters/openai-adapter.ts b/src/stages/review/agentic/adapters/openai-adapter.ts index e506d5b6..1001d9df 100644 --- a/src/stages/review/agentic/adapters/openai-adapter.ts +++ b/src/stages/review/agentic/adapters/openai-adapter.ts @@ -60,15 +60,16 @@ export class OpenAIAdapter implements AgentAdapter { logger.info( `[Agentic/OpenAI] Run complete. inputTokens=${usage.inputTokens}, outputTokens=${usage.outputTokens}`, ); - if (structured?.issues) { - logger.info(`[Agentic/OpenAI] Structured output (${structured.issues.length} issues)`); - } else { - logger.warn('[Agentic/OpenAI] finalOutput was null or missing issues — returning empty'); + if (!structured?.issues) { + throw new Error( + `[Agentic/OpenAI] Run completed but finalOutput is missing — agent likely hit max turns (${maxTurns})`, + ); } + logger.info(`[Agentic/OpenAI] Structured output (${structured.issues.length} issues)`); return { output: '', - structuredOutput: structured?.issues, + structuredOutput: structured.issues, inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, }; diff --git a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts index 0bdb5dba..587166de 100644 --- a/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts +++ b/tests/unit/stages/review/agentic/adapters/openai-adapter.spec.ts @@ -89,14 +89,12 @@ describe('OpenAIAdapter — run()', () => { expect(result.outputTokens).toBe(60); }); - it('returns undefined structuredOutput when finalOutput is null', async () => { + it('throws when finalOutput is null', async () => { mockRunFn.mockResolvedValue({ finalOutput: null, state: { usage: { inputTokens: 0, outputTokens: 0 } }, } as any); - const result = await new OpenAIAdapter().run(makeParams()); - expect(result.structuredOutput).toBeUndefined(); - expect(result.output).toBe(''); + await expect(new OpenAIAdapter().run(makeParams())).rejects.toThrow('finalOutput is missing'); }); it('passes maxTurns to run()', async () => {