Compare commits

...
Author SHA1 Message Date
Sonarly Claude Code 77ef50786f fix(ai): inline $defs refs in tool schema before sending to Gemini provider
https://sonarly.com/issue/35273?type=bug

All AI Chatbot interactions fail completely when Google Gemini is configured as the AI provider because Gemini's API rejects JSON Schemas containing `$ref`/`$defs` references, which are generated by Zod 4's `z.toJSONSchema()` for the recursive `z.lazy()` filter schema used in database CRUD tools.

Fix: ## What changed

**`packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts`**

Replaced the recursive `z.lazy()` filter schema with a bounded 2-level inline schema:

- **Before**: A single `z.lazy()` self-referencing schema was used to support arbitrarily nested `or`/`and`/`not` conditions. Zod 4's `z.toJSONSchema()` serializes `z.lazy()` using JSON Schema's `$defs`/`$ref` construct (standard Draft 2020-12 behavior for recursive types).

- **After**: Three explicit schema tiers — `leafFilterSchema` (field predicates only), `innerFilterSchema` (field predicates + one level of logical nesting), and `filterSchema` (field predicates + up to two levels of nesting). All three are fully inlined objects with no `$ref` references.

## Why this fixes the bug

Google Gemini's function calling API rejects tool parameter schemas that contain `$ref`/`$defs` references. The error string in the issue — `"The referenced name '#/$defs/__schema0'"` — is the exact key Zod 4 generates for anonymous `z.lazy()` schemas. OpenAI and Anthropic accept `$ref`/`$defs`, which is why this is Gemini-specific.

## Trade-off

The schema now validates filters up to 2 levels of `or`/`and`/`not` nesting instead of infinite depth. In practice — and especially for LLM-generated queries — 2 levels is more than sufficient. The runtime filter parser is unchanged and still processes arbitrarily nested filters; only the schema validation for tool input is bounded.

## Callers unchanged

All 3 callers of `generateRecordFilterSchema` — `find-tool.zod-schema.ts`, `group-by-tool.zod-schema.ts`, and `generate-update-many-record-input-schema.util.ts` — receive the same `{ filterShape, filterSchema }` return shape and require no changes.
2026-05-06 17:41:27 +00:00
@@ -10,8 +10,13 @@ import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-cru
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
// Builds the per-field filter shape and full recursive filter schema
// for a given object metadata, reusable across find and updateMany tools
// Builds the per-field filter shape and full filter schema for a given object
// metadata, reusable across find and updateMany tools.
//
// The filter schema is bounded to 2 levels of logical nesting (or/and/not
// containing or/and/not) rather than using z.lazy(). z.lazy() produces
// $defs/$ref in the JSON Schema output which Google Gemini rejects — fully
// inlined schemas are required by that provider.
export const generateRecordFilterSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
@@ -44,24 +49,44 @@ export const generateRecordFilterSchema = (
fieldFilter;
});
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
);
// Leaf schema: only field-level predicates, no nested logical operators
const leafFilterSchema = z.object({ ...filterShape }).partial();
// One level of logical nesting wrapping leaf filters
const innerFilterSchema: z.ZodTypeAny = z
.object({
...filterShape,
or: z
.array(leafFilterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(leafFilterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: leafFilterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial();
// Outer schema: two levels of logical nesting
const filterSchema: z.ZodTypeAny = z
.object({
...filterShape,
or: z
.array(innerFilterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(innerFilterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: innerFilterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial();
return { filterShape, filterSchema };
};