fix(ai) - optimize crud tools (#21133)

- **Add delete many**, `delete_many_{object}` added alongside the
existing `delete_one_{object}`.
- **Uniformize naming**, crud module, type names, and MCP helper
constants renamed for consistency.
- **Optimize tool schema (learn phase)**
  - `find_many(_companies)`: **7 158 → 2 700 tokens**
  - `find_one(_company)`: **280 → 126 tokens**
  -  ....
- Main mechanism: `reused: 'ref'` (line 7 of
`to-tool-json-schema.util.ts`). Zod walks the schema tree, tracks which
Zod schema instances appear more than once, and emits each reused
instance exactly once in `$defs`, replacing all subsequent occurrences
with a `$ref`. Works because filter and value schemas are now extracted
as shared objects.

- **Optimize system prompt (tool catalog)**, DATABASE_CRUD section
restructured to list operation patterns (`find_many_{object}`, …) once +
objects once, instead of the full N×M cross-product of tool names.
- **Optimize execute_tool**, shared record-properties schema (same
`$defs` deduplication applies at call time); introduced `upsert_many`;
added `selectedFields` to `find_*` so the agent only fetches the fields
it needs.
This commit is contained in:
Etienne
2026-06-03 19:57:40 +02:00
committed by GitHub
parent 50c9b68e81
commit 15eaabdbc1
40 changed files with 1610 additions and 633 deletions
@@ -7,7 +7,8 @@ export const WORKFLOW_SYSTEM_PROMPTS = {
Tool usage strategy:
- Chain multiple tools to solve complex tasks
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, etc.) over looping single-item calls
- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) over looping single-item calls
- Use \`upsert_many_*\` instead of \`update_many_*\` when records have different data to set individually, or when some records may not exist yet
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent
@@ -29,11 +29,12 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
## Database vs HTTP Tools
- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations
- Use database tools (find_many_*, find_one_*, create_one_*, create_many_*, update_one_*, update_many_*, upsert_many_*, delete_one_*, delete_many_*) for ALL Twenty CRM data operations
- NEVER guess or construct API URLs — always use the appropriate database tool
- The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data)
- If you need to look up a record, learn and execute the corresponding find_one_* or find_many_* tool
- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results.
- If you need to look up a record by ID, use find_one_*; to search with filters, use find_many_*
- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_many_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results.
- **update_many_* vs upsert_many_***: use \`update_many_*\` when ALL matched records get the SAME data (e.g. mark all as closed). Use \`upsert_many_*\` when each record has different data to set, or when some records may not exist yet (insert-or-update per record).
## Data Efficiency
@@ -41,7 +42,7 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need
- Always apply filters to narrow results — don't fetch all records of a type.
- Fetch one type of data at a time and check if you have what you need before fetching more.
- Every record returned consumes context. Fetching too many records at once will cause failures.
- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, etc.) instead of looping single-item calls.
- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) instead of looping single-item calls.
## Tool Strategy
@@ -281,7 +281,16 @@ ${preloadedList}
const categoryLabel = this.getCategoryLabel(category);
sections.push(`
if (category === ToolCategory.DATABASE_CRUD) {
sections.push(
this.buildDatabaseCrudCatalogSection(
tools,
preloadedSet,
categoryLabel,
),
);
} else {
sections.push(`
#### ${categoryLabel} (${tools.length} tools)
${tools
.map((tool) => {
@@ -290,6 +299,7 @@ ${tools
return `- \`${tool.name}\`${status}`;
})
.join('\n')}`);
}
}
sections.push(`
@@ -300,6 +310,69 @@ ${tools
return sections.join('\n');
}
private buildDatabaseCrudCatalogSection(
tools: ToolIndexEntry[],
preloadedSet: Set<string>,
categoryLabel: string,
): string {
const operationOrder: string[] = [];
const seenOps = new Set<string>();
const objectToolsMap = new Map<string, string[]>();
const standaloneTools: ToolIndexEntry[] = [];
for (const tool of tools) {
if (tool.objectName && tool.operation) {
const ops = objectToolsMap.get(tool.objectName) ?? [];
ops.push(tool.operation);
objectToolsMap.set(tool.objectName, ops);
if (!seenOps.has(tool.operation)) {
seenOps.add(tool.operation);
operationOrder.push(tool.operation);
}
} else {
standaloneTools.push(tool);
}
}
const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
if (objectToolsMap.size > 0) {
const objectNames = [...objectToolsMap.keys()].sort();
lines.push(`Operations per object:`);
lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
lines.push(`\nObjects (${objectNames.length}):`);
lines.push(...objectNames.map((name) => `- \`${name}\``));
const findManyExample = tools.find((t) => t.operation === 'find_many');
const findOneExample = tools.find(
(t) =>
t.operation === 'find_one' &&
t.objectName === findManyExample?.objectName,
);
const examplePart =
findManyExample && findOneExample
? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
: '';
lines.push(
`\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
);
}
for (const tool of standaloneTools) {
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
lines.push(`- \`${tool.name}\`${status}`);
}
return lines.join('\n');
}
private getCategoryLabel(category: ToolCategory): string {
switch (category) {
case ToolCategory.DATABASE_CRUD:
@@ -4,13 +4,13 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
export const shouldExcludeFieldFromAgentToolSchema = (
field: FieldMetadataEntity | FlatFieldMetadata,
excludeId = true,
additionalExcludedFieldNames: string[] = [],
): boolean => {
const excludedFieldNames = [
'createdAt',
'updatedAt',
'deletedAt',
'searchVector',
'createdBy',
...additionalExcludedFieldNames,
];
if (excludeId) {