Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
41addbe
feat(agentic): use structured output in all agentic adapters
valdis Jun 17, 2026
d375a49
refactor(agentic): always enforce structured output, remove unstructu…
valdis Jun 17, 2026
d9b1485
test(agentic): remove duplicate tests, fix misleading assertions and …
valdis Jun 17, 2026
af59e15
refactor: log structured output preview and drop dead unstructured gu…
valdis Jun 17, 2026
2b72a32
fix: wrap ReviewIssuesSchema in root object for SDK structured output…
valdis Jun 17, 2026
ba52058
fix: strip unsupported JSON Schema constraints for Anthropic structur…
valdis Jun 17, 2026
8e48e54
chore: upgrade @anthropic-ai/claude-agent-sdk to 0.3.179
valdis Jun 17, 2026
1748fed
fix(agentic): restore plaintext JSON recovery for truncated model res…
valdis Jun 17, 2026
9dc5afd
fix(agentic): strip \$schema from outputFormat schema to enable CLI s…
valdis Jun 17, 2026
6ce4905
fix: apply array-root wrapping in AnthropicProvider for structured ou…
valdis Jun 17, 2026
7adb4c9
feat: wire array-root wrapping and non-strict json_schema for OpenAI/…
valdis Jun 17, 2026
dafb68f
fix(agentic): hard fail when structured output is not an array
valdis Jun 17, 2026
937720e
fix: tighten JSON extraction, structured output shape guard, and hasJ…
valdis Jun 17, 2026
75f3a55
fix(agentic): fall back to result text when structured_output shape i…
valdis Jun 18, 2026
5d7d339
refactor: remove unused isUnstructured free function and minor cleanups
valdis Jun 18, 2026
0e2777f
test: cover structuredOutput non-array throw and malformed-shape no-r…
valdis Jun 18, 2026
cfcd9a3
fix(agentic): strip \$schema from configurable-agent output schema
valdis Jun 18, 2026
96d9202
fix(agentic): throw when OpenAI finalOutput is missing instead of ret…
valdis Jun 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 56 additions & 30 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 13 additions & 5 deletions src/ai/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<S> =>
(wrapped ? unwrapArrayRootResult(value) : value) as z.infer<S>;

if (this.capabilities.structuredDialect === 'anthropic-output-config') {
try {
const response = await client.messages.parse({
Expand All @@ -132,15 +140,15 @@ 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) {
throw new StructuredOutputError('Anthropic returned no parsed_output', raw);
}
this.recordResponseUsage(response, messages, raw, use1HourCache);
return {
content: response.parsed_output as z.infer<S>,
content: unwrap(response.parsed_output),
raw,
usage: this.toTokenUsage(response.usage),
model: response.model,
Expand All @@ -152,7 +160,7 @@ export class AnthropicProvider extends BaseAIProvider {
}

// tool_use fallback for Claude < 4.5
const toolSchema = schemaToJsonSchema(options.schema) as Record<string, unknown>;
const toolSchema = schemaToJsonSchema(requestSchema) as Record<string, unknown>;
try {
const response = await client.messages.create({
model,
Expand All @@ -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}`,
Expand All @@ -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,
Expand Down
44 changes: 29 additions & 15 deletions src/ai/providers/openai-compatible-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type OpenAI from 'openai';
import { zodResponseFormat } from 'openai/helpers/zod';
import type {
ChatCompletionCreateParamsNonStreaming,
ChatCompletionMessageParam,
Expand All @@ -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';
Expand Down Expand Up @@ -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<S> =>
(wrapped ? unwrapArrayRootResult(value) : value) as z.infer<S>;

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<S>,
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;
Expand All @@ -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)}`;
Expand All @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions src/ai/shared/structured/array-root.ts
Original file line number Diff line number Diff line change
@@ -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 `{ <WRAP_KEY>: [...] }` 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<S extends z.ZodType>(
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<string, unknown>;
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;
}
5 changes: 5 additions & 0 deletions src/ai/shared/structured/extract-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ 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 ```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' };

const arrayMatch = trimmed.match(/(\[[\s\S]*\])/);
Expand Down
6 changes: 6 additions & 0 deletions src/ai/shared/structured/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading