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:
+12
-2
@@ -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
|
||||
|
||||
+33
-9
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
+10
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+44
-24
@@ -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,
|
||||
|
||||
+24
-12
@@ -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;
|
||||
|
||||
+10
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+10
-3
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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> {
|
||||
|
||||
+116
-250
@@ -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,
|
||||
|
||||
+2
-4
@@ -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';
|
||||
|
||||
|
||||
+2
-4
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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(', ')}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
+12
@@ -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;
|
||||
};
|
||||
+7
-3
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
+24
-6
@@ -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 },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user