Improve AI agent chat, tool display, and workflow agent management (#17876)

## Summary

- **Fix token renewal endpoint**: Use `/metadata` instead of `/graphql`
for token renewal in agent chat, fixing auth issues
- **Improve tool display**: Add `load_skills` support, show formatted
tool names (underscores → spaces) with finish/loading states, display
tool icons during loading, and support custom loading messages from tool
input
- **Refactor workflow agent management**: Replace direct
`AgentRepository` access with `AgentService` for create/delete/find
operations in workflow steps, improving encapsulation and consistency
- **Simplify Apollo client usage**: Remove explicit Apollo client
override in `useGetToolIndex`, add `AgentChatProvider` to
`AppRouterProviders`
- **Fix load-skill tool**: Change parameter type from `string` to `json`
for proper schema parsing
- **Update agent-chat-streaming**: Use `AgentService` for agent
resolution and tool registration instead of direct repository queries

## Test plan

- [ ] Verify AI agent chat works end-to-end (send message, receive
response)
- [ ] Verify tool steps display correctly with icons and proper messages
during loading and after completion
- [ ] Verify workflow AI agent step creation and deletion works
correctly
- [ ] Verify workflow version cloning preserves agent configuration
- [ ] Verify token renewal works when tokens expire during agent chat


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-13 10:27:38 +01:00
committed by GitHub
parent 5c3c2e08a6
commit 21c51ec251
39 changed files with 815 additions and 656 deletions
@@ -4,7 +4,10 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -31,12 +34,19 @@ export type ToolRetrievalOptions = {
wrapWithErrorContext?: boolean;
};
export type GenerateDescriptorOptions = {
includeSchemas?: boolean; // defaults to true for backward compat
};
export interface ToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateDescriptors(context: ToolProviderContext): Promise<ToolDescriptor[]>;
generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]>;
}
// NativeModelToolProvider is special: SDK-native tools are opaque and not
@@ -4,6 +4,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -13,7 +14,10 @@ import {
type StaticToolHandler,
ToolExecutorService,
} from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
@@ -65,8 +69,10 @@ export class ActionToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
@@ -75,7 +81,9 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasHttpPermission) {
descriptors.push(this.buildDescriptor('http_request', this.httpTool));
descriptors.push(
this.buildDescriptor('http_request', this.httpTool, includeSchemas),
);
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
@@ -85,11 +93,17 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasEmailPermission) {
descriptors.push(this.buildDescriptor('send_email', this.sendEmailTool));
descriptors.push(
this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas),
);
}
descriptors.push(
this.buildDescriptor('search_help_center', this.searchHelpCenterTool),
this.buildDescriptor(
'search_help_center',
this.searchHelpCenterTool,
includeSchemas,
),
);
const hasCodeInterpreterPermission =
@@ -101,19 +115,29 @@ export class ActionToolProvider implements ToolProvider {
if (hasCodeInterpreterPermission) {
descriptors.push(
this.buildDescriptor('code_interpreter', this.codeInterpreterTool),
this.buildDescriptor(
'code_interpreter',
this.codeInterpreterTool,
includeSchemas,
),
);
}
return descriptors;
}
private buildDescriptor(toolId: string, tool: Tool): ToolDescriptor {
private buildDescriptor(
toolId: string,
tool: Tool,
includeSchemas: boolean,
): ToolIndexEntry | ToolDescriptor {
return {
name: toolId,
description: tool.description,
category: ToolCategory.ACTION,
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
...(includeSchemas && {
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
}),
executionRef: { kind: 'static', toolId },
};
}
@@ -3,6 +3,7 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -10,7 +11,10 @@ import {
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service';
@@ -56,7 +60,8 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.dashboardToolService) {
return [];
}
@@ -66,6 +71,8 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}
@@ -8,6 +8,7 @@ import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -21,7 +22,10 @@ import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-s
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@@ -43,8 +47,10 @@ export class DatabaseToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
return descriptors;
@@ -109,9 +115,11 @@ export class DatabaseToolProvider implements ToolProvider {
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.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -125,7 +133,9 @@ export class DatabaseToolProvider implements ToolProvider {
name: `find_one_${snakeSingular}`,
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
...(includeSchemas && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -141,9 +151,11 @@ export class DatabaseToolProvider implements ToolProvider {
name: `create_${snakeSingular}`,
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -157,12 +169,14 @@ export class DatabaseToolProvider implements ToolProvider {
name: `create_many_${snakePlural}`,
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -176,9 +190,11 @@ export class DatabaseToolProvider implements ToolProvider {
name: `update_${snakeSingular}`,
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -192,12 +208,14 @@ export class DatabaseToolProvider implements ToolProvider {
name: `update_many_${snakePlural}`,
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -213,7 +231,9 @@ export class DatabaseToolProvider implements ToolProvider {
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
...(includeSchemas && {
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@@ -3,12 +3,16 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
@@ -26,7 +30,10 @@ export class LogicFunctionToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -42,29 +49,34 @@ export class LogicFunctionToolProvider implements ToolProvider {
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const descriptors: ToolDescriptor[] = [];
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
for (const logicFunction of logicFunctionsWithSchema) {
const toolName = this.buildLogicFunctionToolName(logicFunction.name);
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
descriptors.push({
const base: ToolIndexEntry = {
name: toolName,
description:
logicFunction.description ||
`Execute the ${logicFunction.name} logic function`,
category: ToolCategory.LOGIC_FUNCTION,
inputSchema,
executionRef: {
kind: 'logic_function',
logicFunctionId: logicFunction.id,
},
});
};
if (includeSchemas) {
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
descriptors.push({ ...base, inputSchema });
} else {
descriptors.push(base);
}
}
return descriptors;
@@ -3,13 +3,17 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
@@ -49,12 +53,15 @@ export class MetadataToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const toolSet = {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
return toolSetToDescriptors(toolSet, ToolCategory.METADATA);
return toolSetToDescriptors(toolSet, ToolCategory.METADATA, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}
@@ -3,13 +3,17 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@@ -65,8 +69,12 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const schemaOptions = {
includeSchemas: options?.includeSchemas ?? true,
};
const readTools = this.viewToolsFactory.generateReadTools(
context.workspaceId,
@@ -90,9 +98,10 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW,
schemaOptions,
);
}
return toolSetToDescriptors(readTools, ToolCategory.VIEW);
return toolSetToDescriptors(readTools, ToolCategory.VIEW, schemaOptions);
}
}
@@ -3,6 +3,7 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -10,7 +11,10 @@ import {
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@@ -56,7 +60,8 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.workflowToolService) {
return [];
}
@@ -66,6 +71,8 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}
@@ -1,5 +1,5 @@
import { UseGuards } from '@nestjs/common';
import { Field, ObjectType, Query } from '@nestjs/graphql';
import { Args, Field, ObjectType, Query } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
@@ -61,4 +61,34 @@ export class ToolIndexResolver {
userWorkspaceId,
});
}
// Resolves the inputSchema for a single tool on demand (avoids computing
// schemas for every tool in the workspace when listing the tool index).
@Query(() => graphqlTypeJson, { nullable: true })
@UseGuards(NoPermissionGuard)
async getToolInputSchema(
@Args('toolName') toolName: string,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<object | null> {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId: workspace.id,
});
if (!roleId) {
return null;
}
const schemas = await this.toolRegistryService.resolveSchemas([toolName], {
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
userId: user?.id,
userWorkspaceId,
});
return schemas.get(toolName) ?? null;
}
}
@@ -21,7 +21,10 @@ import { FindRecordsService } from 'src/engine/core-modules/record-crud/services
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -77,7 +80,7 @@ export class ToolExecutorService {
}
async dispatch(
descriptor: ToolDescriptor,
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
@@ -194,7 +197,7 @@ export class ToolExecutorService {
}
private async dispatchStaticTool(
descriptor: ToolDescriptor,
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
@@ -1,7 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolCallOptions, type ToolSet, jsonSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import {
type CodeExecutionStreamEmitter,
@@ -14,107 +13,102 @@ import {
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
generateErrorSuggestion,
wrapWithErrorHandler,
} from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
// Backward-compatible alias -- consumers can import this instead of ToolDescriptor
export type ToolIndexEntry = ToolDescriptor;
export type ToolSearchOptions = {
limit?: number;
category?: ToolCategory;
};
export type ToolContext = {
workspaceId: string;
roleId: string;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
const RAM_TTL_MS = 5_000;
const REDIS_TTL_MS = 300_000;
export { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
// Two-tier cache: RAM (5s) → Redis (5min) → generate from providers
private readonly ramCache = new Map<
string,
{ descriptors: ToolDescriptor[]; cachedAt: number }
>();
constructor(
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
private readonly nativeModelToolProvider: NativeModelToolProvider,
private readonly toolExecutorService: ToolExecutorService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
// Core: returns cached ToolDescriptor[] for a workspace+role+user
async getCatalog(context: ToolProviderContext): Promise<ToolDescriptor[]> {
const cacheKey = await this.buildCacheKey(context);
// Returns ToolIndexEntry[] (lightweight, no schemas).
// Underlying data (metadata, permissions) is already cached by WorkspaceCacheService.
// Providers run in parallel since they are independent.
async getCatalog(context: ToolProviderContext): Promise<ToolIndexEntry[]> {
const results = await Promise.all(
this.providers.map(async (provider) => {
if (await provider.isAvailable(context)) {
return provider.generateDescriptors(context, {
includeSchemas: false,
});
}
// 1. RAM hit?
const ramEntry = this.ramCache.get(cacheKey);
return [];
}),
);
if (ramEntry && Date.now() - ramEntry.cachedAt < RAM_TTL_MS) {
return ramEntry.descriptors;
return results.flat();
}
// On-demand schema generation for specific tools
async resolveSchemas(
toolNames: string[],
context: ToolProviderContext,
): Promise<Map<string, object>> {
const index = await this.getCatalog(context);
const nameSet = new Set(toolNames);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
// Group matching entries by provider category
const byCategory = new Map<string, ToolIndexEntry[]>();
for (const entry of matchingEntries) {
const existing = byCategory.get(entry.category) ?? [];
existing.push(entry);
byCategory.set(entry.category, existing);
}
// 2. Redis hit?
const redisData =
await this.workspaceCacheStorageService.getToolCatalog(cacheKey);
const schemas = new Map<string, object>();
if (redisData) {
const descriptors = redisData as ToolDescriptor[];
for (const [category, entries] of byCategory) {
const provider = this.providers.find(
(providerItem) => providerItem.category === category,
);
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
if (!provider) {
continue;
}
const fullDescriptors = await provider.generateDescriptors(context, {
includeSchemas: true,
});
return descriptors;
}
const entryNameSet = new Set(entries.map((entry) => entry.name));
// 3. Generate from providers (cache miss)
const descriptors: ToolDescriptor[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const providerDescriptors = await provider.generateDescriptors(context);
descriptors.push(...providerDescriptors);
for (const descriptor of fullDescriptors) {
if (
entryNameSet.has(descriptor.name) &&
'inputSchema' in descriptor &&
descriptor.inputSchema
) {
schemas.set(descriptor.name, descriptor.inputSchema);
}
}
}
this.logger.log(
`Generated ${descriptors.length} tool descriptors for workspace ${context.workspaceId}`,
);
// Store in both caches
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
await this.workspaceCacheStorageService.setToolCatalog(
cacheKey,
descriptors,
REDIS_TTL_MS,
);
return descriptors;
return schemas;
}
// Hydrate ToolDescriptor[] into an AI SDK ToolSet with thin dispatch closures
@@ -126,7 +120,6 @@ export class ToolRegistryService {
const toolSet: ToolSet = {};
for (const descriptor of descriptors) {
// Add loadingMessage to the clean stored schema
const schemaWithLoading = wrapJsonSchemaForExecution(
descriptor.inputSchema as Record<string, unknown>,
);
@@ -140,7 +133,7 @@ export class ToolRegistryService {
description: descriptor.description,
inputSchema: jsonSchema(schemaWithLoading),
execute: options?.wrapWithErrorContext
? this.wrapWithErrorHandler(descriptor.name, executeFn)
? wrapWithErrorHandler(descriptor.name, executeFn)
: executeFn,
};
}
@@ -152,7 +145,7 @@ export class ToolRegistryService {
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolDescriptor[]> {
): Promise<ToolIndexEntry[]> {
const context = this.buildContext(
workspaceId,
roleId,
@@ -164,81 +157,6 @@ export class ToolRegistryService {
return this.getCatalog(context);
}
async searchTools(
query: string,
workspaceId: string,
roleId: string,
options: ToolSearchOptions & {
userId?: string;
userWorkspaceId?: string;
} = {},
): Promise<ToolDescriptor[]> {
const { limit = 5, category, userId, userWorkspaceId } = options;
const context = this.buildContext(
workspaceId,
roleId,
undefined,
userId,
userWorkspaceId,
);
const descriptors = await this.getCatalog(context);
const queryLower = query.toLowerCase();
const queryTerms = queryLower
.split(/\s+/)
.filter((term) => term.length > 2);
const scored = descriptors
.filter((tool) => !category || tool.category === category)
.map((tool) => {
let score = 0;
const nameLower = tool.name.toLowerCase();
const descLower = tool.description.toLowerCase();
const objectLower = tool.objectName?.toLowerCase() ?? '';
if (nameLower.includes(queryLower)) {
score += 100;
}
if (objectLower && queryLower.includes(objectLower)) {
score += 80;
}
for (const term of queryTerms) {
if (nameLower.includes(term)) {
score += 30;
}
if (objectLower.includes(term)) {
score += 25;
}
if (descLower.includes(term)) {
score += 10;
}
}
const operations = ['find', 'create', 'update', 'delete', 'search'];
for (const op of operations) {
if (queryLower.includes(op) && nameLower.includes(op)) {
score += 40;
}
}
return { tool, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.tool);
this.logger.log(
`Tool search for "${query}" returned ${scored.length} results`,
);
return scored;
}
async getToolsByName(
names: string[],
context: ToolContext,
@@ -251,13 +169,20 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((descriptor) =>
nameSet.has(descriptor.name),
);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
return this.hydrateToolSet(filtered, fullContext);
const schemas = await this.resolveSchemas(names, fullContext);
const descriptors: ToolDescriptor[] = matchingEntries
.filter((entry) => schemas.has(entry.name))
.map((entry) => ({
...entry,
inputSchema: schemas.get(entry.name)!,
}));
return this.hydrateToolSet(descriptors, fullContext);
}
async getToolInfo(
@@ -275,12 +200,17 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((entry) => nameSet.has(entry.name));
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
return filtered.map((entry) => {
let schemas: Map<string, object> | undefined;
if (aspects.includes('schema')) {
schemas = await this.resolveSchemas(names, fullContext);
}
return matchingEntries.map((entry) => {
const info: {
name: string;
description?: string;
@@ -291,8 +221,8 @@ export class ToolRegistryService {
info.description = entry.description;
}
if (aspects.includes('schema')) {
info.inputSchema = entry.inputSchema;
if (aspects.includes('schema') && schemas) {
info.inputSchema = schemas.get(entry.name);
}
return info;
@@ -314,10 +244,10 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const descriptor = descriptors.find((desc) => desc.name === toolName);
const index = await this.getCatalog(fullContext);
const entry = index.find((indexEntry) => indexEntry.name === toolName);
if (!descriptor) {
if (!entry) {
return {
toolName,
error: {
@@ -329,7 +259,7 @@ export class ToolRegistryService {
}
const result = await this.toolExecutorService.dispatch(
descriptor,
entry,
args,
fullContext,
);
@@ -348,33 +278,41 @@ export class ToolRegistryService {
toolName,
error: {
message: errorMessage,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
};
}
}
// Main method for eager loading tools by categories
// Eager loading tools by categories (MCP, workflow agent).
// These paths need full schemas, so generate with includeSchemas: true.
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const descriptors = await this.getCatalog(context);
const categorySet = categories ? new Set(categories) : undefined;
let filteredDescriptors: ToolDescriptor[];
const results = await Promise.all(
this.providers
.filter(
(provider) => !categorySet || categorySet.has(provider.category),
)
.map(async (provider) => {
if (await provider.isAvailable(context)) {
return provider.generateDescriptors(context, {
includeSchemas: true,
});
}
if (categories) {
const categorySet = new Set(categories);
return [];
}),
);
filteredDescriptors = descriptors.filter((descriptor) =>
categorySet.has(descriptor.category),
);
} else {
filteredDescriptors = [...descriptors];
}
const descriptors = results.flat() as ToolDescriptor[];
let filteredDescriptors = descriptors;
// Apply excludeTools filter
if (excludeTools?.length) {
const excludeSet = new Set(excludeTools);
@@ -387,7 +325,6 @@ export class ToolRegistryService {
wrapWithErrorContext,
});
// Handle NativeModelToolProvider separately (SDK-opaque tools)
if (categories?.includes(ToolCategory.NATIVE_MODEL)) {
if (await this.nativeModelToolProvider.isAvailable(context)) {
const nativeTools = await (
@@ -405,15 +342,6 @@ export class ToolRegistryService {
return toolSet;
}
private async buildCacheKey(context: ToolProviderContext): Promise<string> {
const metadataVersion =
(await this.workspaceCacheStorageService.getMetadataVersion(
context.workspaceId,
)) ?? 0;
return `${context.workspaceId}:v${metadataVersion}:${context.roleId}:${context.userId ?? 'system'}`;
}
private buildContext(
workspaceId: string,
roleId: string,
@@ -434,66 +362,4 @@ export class ToolRegistryService {
onCodeExecutionUpdate,
};
}
private wrapWithErrorHandler(
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): (args: Record<string, unknown>) => Promise<unknown> {
return async (args: Record<string, unknown>) => {
try {
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
};
}
private generateErrorSuggestion(
_toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -25,7 +25,6 @@ import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
@@ -47,7 +46,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
PermissionsModule,
ViewModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
UserRoleModule,
@@ -1,10 +1,8 @@
import { type ToolCallOptions, type ToolSet } from 'ai';
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
@@ -1,9 +1,7 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
@@ -47,7 +47,7 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
label: skill.label,
content: skill.content,
})),
message: `Loaded ${skills.length} skill(s). Follow the instructions in the skill content.`,
message: `Loaded ${skills.map((skill) => skill.label).join(', ')}`,
};
},
});
@@ -0,0 +1,12 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
export type ToolContext = {
workspaceId: string;
roleId: string;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
@@ -18,13 +18,17 @@ export type ToolExecutionRef =
| { kind: 'static'; toolId: string }
| { kind: 'logic_function'; logicFunctionId: string };
// Fully JSON-serializable tool definition, stored in Redis
export type ToolDescriptor = {
// Lightweight entry for catalog/index (no schema)
export type ToolIndexEntry = {
name: string;
description: string;
category: ToolCategory;
inputSchema: object;
executionRef: ToolExecutionRef;
objectName?: string;
operation?: string;
};
// Full descriptor with schema (on-demand)
export type ToolDescriptor = ToolIndexEntry & {
inputSchema: object;
};
@@ -0,0 +1,61 @@
export const generateErrorSuggestion = (
_toolName: string,
errorMessage: string,
): string => {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
};
export const wrapWithErrorHandler = (
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): ((args: Record<string, unknown>) => Promise<unknown>) => {
return async (args: Record<string, unknown>) => {
try {
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
};
}
};
};
@@ -2,7 +2,14 @@ import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
export type ToolSetToDescriptorsOptions = {
includeSchemas?: boolean;
};
// Converts a ToolSet (with Zod schemas and closures) into an array of
// serializable ToolDescriptor objects. Used by providers that delegate to
@@ -10,8 +17,22 @@ import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types
export const toolSetToDescriptors = (
toolSet: ToolSet,
category: ToolCategory,
): ToolDescriptor[] => {
options?: ToolSetToDescriptorsOptions,
): (ToolIndexEntry | ToolDescriptor)[] => {
const includeSchemas = options?.includeSchemas ?? true;
return Object.entries(toolSet).map(([name, tool]) => {
const base: ToolIndexEntry = {
name,
description: tool.description ?? '',
category,
executionRef: { kind: 'static' as const, toolId: name },
};
if (!includeSchemas) {
return base;
}
let inputSchema: object;
try {
@@ -22,11 +43,8 @@ export const toolSetToDescriptors = (
}
return {
name,
description: tool.description ?? '',
category,
...base,
inputSchema,
executionRef: { kind: 'static' as const, toolId: name },
};
});
};