Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/normalize-json-schemas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-api": patch
---

Normalize JSON schemas for OpenAI structured output compatibility
5 changes: 4 additions & 1 deletion agents-api/src/domains/run/agents/Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { generateToolId } from '../utils/agent-operations';
import { ArtifactCreateSchema, ArtifactReferenceSchema } from '../utils/artifact-component-schema';
import { withJsonPostProcessing } from '../utils/json-postprocessor';
import { getCompressionConfigForModel } from '../utils/model-context-utils';
import { SchemaProcessor } from '../utils/SchemaProcessor';
import type { StreamHelper } from '../utils/stream-helpers';
import { getStreamHelper } from '../utils/stream-registry';
import {
Expand Down Expand Up @@ -3493,7 +3494,9 @@ ${output}`;
const componentSchemas: z.ZodType<any>[] = [];

this.config.dataComponents?.forEach((dc) => {
const propsSchema = dc.props ? z.fromJSONSchema(dc.props) : z.string();
// Normalize schema to ensure all properties are required (cross-provider compatibility)
const normalizedProps = dc.props ? SchemaProcessor.makeAllPropertiesRequired(dc.props) : null;
const propsSchema = normalizedProps ? z.fromJSONSchema(normalizedProps) : z.string();
componentSchemas.push(
z.object({
id: z.string(),
Expand Down
46 changes: 46 additions & 0 deletions agents-api/src/domains/run/utils/SchemaProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import jmespath from 'jmespath';
import type { JSONSchema } from 'zod/v4/core';
import { getLogger } from '../../../logger';

export interface SchemaValidationResult {
Expand Down Expand Up @@ -223,6 +224,51 @@ export class SchemaProcessor {
return value;
}

/**
* Makes all properties required recursively throughout the schema.
* This ensures compatibility across all LLM providers (OpenAI/Azure require it, Anthropic accepts it).
*/
static makeAllPropertiesRequired(
schema: JSONSchema.BaseSchema | Record<string, unknown> | null | undefined
): JSONSchema.BaseSchema | Record<string, unknown> | null | undefined {
if (!schema || typeof schema !== 'object') {
return schema;
}

const normalized: any = { ...schema };

if (normalized.properties && typeof normalized.properties === 'object') {
normalized.required = Object.keys(normalized.properties);

const normalizedProperties: any = {};
for (const [key, value] of Object.entries(normalized.properties)) {
normalizedProperties[key] = SchemaProcessor.makeAllPropertiesRequired(value as any);
}
normalized.properties = normalizedProperties;
}

if (normalized.items) {
normalized.items = SchemaProcessor.makeAllPropertiesRequired(normalized.items as any);
}
if (Array.isArray(normalized.anyOf)) {
normalized.anyOf = normalized.anyOf.map((s: any) =>
SchemaProcessor.makeAllPropertiesRequired(s)
);
}
if (Array.isArray(normalized.oneOf)) {
normalized.oneOf = normalized.oneOf.map((s: any) =>
SchemaProcessor.makeAllPropertiesRequired(s)
);
}
if (Array.isArray(normalized.allOf)) {
normalized.allOf = normalized.allOf.map((s: any) =>
SchemaProcessor.makeAllPropertiesRequired(s)
);
}

return normalized;
}

/**
* Enhance schema with JMESPath guidance for artifact component schemas
* Transforms all schema types to string selectors with helpful descriptions
Expand Down
304 changes: 304 additions & 0 deletions agents-api/src/domains/run/utils/__tests__/SchemaProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest';
import { SchemaProcessor } from '../SchemaProcessor';

describe('SchemaProcessor.makeAllPropertiesRequired', () => {
describe('basic object normalization', () => {
it('should make all properties required in a flat schema', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' },
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual(['name', 'age']);
expect(result.properties.name).toEqual({ type: 'string' });
expect(result.properties.age).toEqual({ type: 'number' });
});

it('should preserve existing required array while making all properties required', () => {
const schema = {
type: 'object',
properties: {
required: { type: 'string' },
optional: { type: 'string' },
},
required: ['required'],
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual(['required', 'optional']);
});
});

describe('nested object properties', () => {
it('should recursively normalize nested object properties', () => {
const schema = {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
email: { type: 'string' },
name: { type: 'string' },
},
},
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual(['user']);
expect(result.properties.user.required).toEqual(['email', 'name']);
});

it('should handle deeply nested schemas', () => {
const schema = {
type: 'object',
properties: {
level1: {
type: 'object',
properties: {
level2: {
type: 'object',
properties: {
level3: { type: 'string' },
},
},
},
},
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual(['level1']);
expect(result.properties.level1.required).toEqual(['level2']);
expect(result.properties.level1.properties.level2.required).toEqual(['level3']);
});
});

describe('array items', () => {
it('should normalize object schemas in array items', () => {
const schema = {
type: 'object',
properties: {
items: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
value: { type: 'number' },
},
},
},
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.properties.items.items.required).toEqual(['id', 'value']);
});

it('should handle nested arrays', () => {
const schema = {
type: 'object',
properties: {
matrix: {
type: 'array',
items: {
type: 'array',
items: {
type: 'object',
properties: {
cell: { type: 'string' },
},
},
},
},
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.properties.matrix.items.items.required).toEqual(['cell']);
});
});

describe('union types', () => {
it('should normalize all schemas in anyOf', () => {
const schema = {
anyOf: [
{
type: 'object',
properties: { a: { type: 'string' } },
},
{
type: 'object',
properties: { b: { type: 'number' } },
},
],
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.anyOf[0].required).toEqual(['a']);
expect(result.anyOf[1].required).toEqual(['b']);
});

it('should normalize all schemas in oneOf', () => {
const schema = {
oneOf: [
{
type: 'object',
properties: { type: { type: 'string' } },
},
{
type: 'object',
properties: { kind: { type: 'string' } },
},
],
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.oneOf[0].required).toEqual(['type']);
expect(result.oneOf[1].required).toEqual(['kind']);
});

it('should normalize all schemas in allOf', () => {
const schema = {
allOf: [
{
type: 'object',
properties: { base: { type: 'string' } },
},
{
type: 'object',
properties: { extended: { type: 'string' } },
},
],
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.allOf[0].required).toEqual(['base']);
expect(result.allOf[1].required).toEqual(['extended']);
});
});

describe('edge cases', () => {
it('should handle null input', () => {
const result = SchemaProcessor.makeAllPropertiesRequired(null);
expect(result).toBeNull();
});

it('should handle undefined input', () => {
const result = SchemaProcessor.makeAllPropertiesRequired(undefined);
expect(result).toBeUndefined();
});

it('should handle schemas without properties', () => {
const schema = { type: 'object' };
const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;
expect(result).toEqual({ type: 'object' });
});

it('should handle primitive type schemas', () => {
const schema = { type: 'string' };
const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;
expect(result).toEqual({ type: 'string' });
});

it('should handle empty properties object', () => {
const schema = {
type: 'object',
properties: {},
};
const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;
expect(result.required).toEqual([]);
});

it('should not mutate original schema', () => {
const schema = {
type: 'object',
properties: {
name: { type: 'string' },
},
};
const original = JSON.parse(JSON.stringify(schema));

SchemaProcessor.makeAllPropertiesRequired(schema);

expect(schema).toEqual(original);
});
});

describe('real-world schemas', () => {
it('should normalize fact data component schema', () => {
const schema = {
type: 'object',
properties: {
fact: {
type: 'string',
nullable: true,
description: 'a true fact that is supported by citations',
},
},
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual(['fact']);
expect(result.properties.fact.nullable).toBe(true);
expect(result.properties.fact.description).toBe('a true fact that is supported by citations');
});

it('should normalize artifact component schema with nested selectors', () => {
const schema = {
type: 'object',
properties: {
id: { type: 'string' },
tool_call_id: { type: 'string' },
type: { type: 'string', enum: ['Article'] },
base_selector: { type: 'string' },
details_selector: {
type: 'object',
properties: {
title: { type: 'string' },
content: { type: 'string' },
metadata: {
type: 'object',
properties: {
author: { type: 'string' },
date: { type: 'string' },
},
},
},
},
},
required: ['id', 'tool_call_id', 'type', 'base_selector'],
};

const result = SchemaProcessor.makeAllPropertiesRequired(schema) as any;

expect(result.required).toEqual([
'id',
'tool_call_id',
'type',
'base_selector',
'details_selector',
]);
expect(result.properties.details_selector.required).toEqual(['title', 'content', 'metadata']);
expect(result.properties.details_selector.properties.metadata.required).toEqual([
'author',
'date',
]);
});
});
});
Loading