From 1ae00d67533c0def8647287b1fe200ac60d33abb Mon Sep 17 00:00:00 2001 From: Rich Roberts Date: Tue, 2 Jun 2026 11:58:13 +0100 Subject: [PATCH] fix(ai): correct find-records tool description (top-level filter fields) (#21109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The AI `find_` tool builds its input schema with `generateFindToolInputSchema`, which **spreads field filters at the args root** (alongside `limit`/`offset`/`orderBy`/`and`/`or`/`not`). `tool-executor.service.ts` then maps the raw model args to a filter with: ```ts const { limit, offset, orderBy, ...filter } = args; ``` The zod schema is only used to generate the JSON schema *shown* to the model (`z.toJSONSchema(...)`) — it is **never used to validate the args coming back**. So when the model emits a bare operator where a field name belongs, e.g. `{ ilike: "Foreman" }`, it passes straight to the query runner, which throws and burns a retry mid-turn: ``` ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field. ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "eq" field. ``` Two contributing faults: 1. **The tool description actively misleads the model** — it says ``use filter: { id: { eq: "record-id" } }``, a `filter` wrapper the root-spread schema doesn't have, inviting the malformed shape. 2. **No server-side validation** — invalid root keys reach the query runner instead of being rejected against the advertised contract. ## Fix 1. **`FindRecordsService` prunes invalid filter keys before querying.** Using the same filter shape the tool schema advertises (`generateRecordFilterSchema(...).filterShape`), it drops any key that is neither a real field nor a logical operator (`and`/`or`/`not`), recursing through `and`/`or`/`not`. A model that sends `{ ilike: "Foreman" }` now gets a valid (empty) filter rather than an exception. Extracted as a pure, unit-tested util `pruneFilterToAllowedKeys`. 2. **Corrected the `find_` tool description** to describe the real top-level-field shape and explicitly warn against a `filter` wrapper and bare root operators. ## Test `__tests__/prune-filter-to-allowed-keys.util.spec.ts` covers: valid filters untouched, bare root operators dropped, valid siblings preserved, `and`/`or`/`not` recursion, and non-object input. ## Notes - Defensive for all `FindRecordsService` callers; `find_one` (`{ id: { eq } }`) and workflow find-records pass valid filters and are unaffected. - Companion to #21106 (RICH_TEXT composite filters). Both surfaced from the same `"Tom Foreman's notes"` AI-chat repro; this PR addresses the root-level-operator half. --------- Co-authored-by: Rich Roberts --- .../tool-provider/providers/database-tool.provider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index e3c17932f0..c5da9d9bc4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -124,7 +124,7 @@ export class DatabaseToolProvider implements ToolProvider { if (permission.canReadObjectRecords) { descriptors.push({ name: `find_${snakePlural}`, - description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`, + description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`, category: ToolCategory.DATABASE_CRUD, ...(includeSchemas && { inputSchema: z.toJSONSchema(