diff --git a/.cursor/Dockerfile b/.cursor/Dockerfile new file mode 100644 index 0000000000..a06a432578 --- /dev/null +++ b/.cursor/Dockerfile @@ -0,0 +1,34 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + curl \ + git \ + make \ + build-essential \ + postgresql-client \ + docker.io \ + && rm -rf /var/lib/apt/lists/* + +# Install nvm (project recommends nvm + .nvmrc for consistent Node versions) +ENV NVM_DIR=/usr/local/nvm +RUN mkdir -p $NVM_DIR \ + && curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash + +SHELL ["/bin/bash", "-c"] + +# Copy .nvmrc so nvm install picks up the right version +COPY .nvmrc /tmp/.nvmrc + +# Install Node.js from .nvmrc, enable Corepack, and symlink binaries +# so they're available on PATH without hardcoding a version +RUN . $NVM_DIR/nvm.sh \ + && nvm install $(cat /tmp/.nvmrc) \ + && nvm alias default $(cat /tmp/.nvmrc) \ + && corepack enable \ + && BIN_DIR=$(dirname $(nvm which default)) \ + && ln -sf $BIN_DIR/node /usr/local/bin/node \ + && ln -sf $BIN_DIR/npm /usr/local/bin/npm \ + && ln -sf $BIN_DIR/npx /usr/local/bin/npx \ + && ln -sf $BIN_DIR/corepack /usr/local/bin/corepack diff --git a/.cursor/environment.json b/.cursor/environment.json index a2327eceb1..49baf88289 100644 --- a/.cursor/environment.json +++ b/.cursor/environment.json @@ -1,18 +1,10 @@ { - "install": "curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash - && sudo apt-get install -y nodejs && node --version && yarn install && echo 'Installing dependencies complete'", - "start": "sudo service docker start && echo 'Docker service started' && sleep 3 && echo 'Starting PostgreSQL and Redis containers...' && make postgres-on-docker && make redis-on-docker && echo 'Waiting for containers to initialize...' && sleep 20 && echo 'Checking container status...' && docker ps --filter name=twenty_ && echo 'Waiting for PostgreSQL to be ready...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'PostgreSQL not ready yet, waiting...'; sleep 3; done && echo 'PostgreSQL is ready!' && echo 'Setting up database...' && cd packages/twenty-server && npx nx database:reset twenty-server || echo 'Database already initialized' && echo 'Environment setup complete!'", + "install": "yarn install", + "start": "sudo service docker start && sleep 2 && (docker start twenty_pg 2>/dev/null || make -C packages/twenty-docker postgres-on-docker) && (docker start twenty_redis 2>/dev/null || make -C packages/twenty-docker redis-on-docker) && until docker exec twenty_pg pg_isready -U postgres -h localhost 2>/dev/null; do sleep 1; done && echo 'PostgreSQL ready' && until docker exec twenty_redis redis-cli ping 2>/dev/null | grep -q PONG; do sleep 1; done && echo 'Redis ready' && bash packages/twenty-utils/setup-dev-env.sh && npx nx database:reset twenty-server", "terminals": [ { "name": "Development Server", - "command": "echo 'Waiting for database to be fully ready...' && sleep 30 && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'Starting Twenty development server...' && export SERVER_URL=http://localhost:3000 && export PG_DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres && yarn start" - }, - { - "name": "Database Management", - "command": "sleep 25 && echo 'Database management terminal ready' && echo 'Waiting for PostgreSQL to be available...' && until docker exec twenty_pg pg_isready -U postgres -h localhost; do echo 'Waiting for PostgreSQL...'; sleep 2; done && echo 'PostgreSQL is ready for database operations!' && echo 'You can now run database commands like:' && echo ' npx nx database:reset twenty-server' && echo ' npx nx database:migrate twenty-server' && bash" - }, - { - "name": "Container Logs & Status", - "command": "sleep 10 && echo '=== Container Status Monitor ===' && while true; do echo '\\n=== Container Status at $(date) ===' && docker ps --filter name=twenty_ --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}' && echo '\\n=== PostgreSQL Status ===' && (docker exec twenty_pg pg_isready -U postgres -h localhost && echo 'PostgreSQL: ✅ Ready') || echo 'PostgreSQL: ❌ Not Ready' && echo '\\n=== Redis Status ===' && (docker exec twenty_redis redis-cli ping && echo 'Redis: ✅ Ready') || echo 'Redis: ❌ Not Ready' && sleep 30; done" + "command": "yarn start" } ] -} \ No newline at end of file +} diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b345ee9341..fc23a27265 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -3670,6 +3670,7 @@ export type Query = { getSSOIdentityProviders: Array; getSystemHealthStatus: SystemHealth; getToolIndex: Array; + getToolInputSchema?: Maybe; index: Index; indexMetadatas: IndexConnection; lineChartData: LineChartDataOutput; @@ -3941,6 +3942,11 @@ export type QueryGetQueueMetricsArgs = { }; +export type QueryGetToolInputSchemaArgs = { + toolName: Scalars['String']; +}; + + export type QueryIndexArgs = { id: Scalars['UUID']; }; @@ -5323,7 +5329,14 @@ export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ _ export type GetToolIndexQueryVariables = Exact<{ [key: string]: never; }>; -export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, description: string, category: string, objectName?: string | null, inputSchema?: any | null }> }; +export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, description: string, category: string, objectName?: string | null }> }; + +export type GetToolInputSchemaQueryVariables = Exact<{ + toolName: Scalars['String']; +}>; + + +export type GetToolInputSchemaQuery = { __typename?: 'Query', getToolInputSchema?: any | null }; export type TrackAnalyticsMutationVariables = Exact<{ type: AnalyticsType; @@ -8486,7 +8499,6 @@ export const GetToolIndexDocument = gql` description category objectName - inputSchema } } `; @@ -8517,6 +8529,39 @@ export function useGetToolIndexLazyQuery(baseOptions?: Apollo.LazyQueryHookOptio export type GetToolIndexQueryHookResult = ReturnType; export type GetToolIndexLazyQueryHookResult = ReturnType; export type GetToolIndexQueryResult = Apollo.QueryResult; +export const GetToolInputSchemaDocument = gql` + query GetToolInputSchema($toolName: String!) { + getToolInputSchema(toolName: $toolName) +} + `; + +/** + * __useGetToolInputSchemaQuery__ + * + * To run a query within a React component, call `useGetToolInputSchemaQuery` and pass it any options that fit your needs. + * When your component renders, `useGetToolInputSchemaQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetToolInputSchemaQuery({ + * variables: { + * toolName: // value for 'toolName' + * }, + * }); + */ +export function useGetToolInputSchemaQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetToolInputSchemaDocument, options); + } +export function useGetToolInputSchemaLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetToolInputSchemaDocument, options); + } +export type GetToolInputSchemaQueryHookResult = ReturnType; +export type GetToolInputSchemaLazyQueryHookResult = ReturnType; +export type GetToolInputSchemaQueryResult = Apollo.QueryResult; export const TrackAnalyticsDocument = gql` mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) { trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) { diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index fb3d37e006..0e7f0c10af 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -13,6 +13,8 @@ import { getToolDisplayMessage, resolveToolInput, } from '@/ai/utils/getToolDisplayMessage'; +import { ToolOutputMessageSchema } from '@/ai/schemas/toolOutputMessageSchema'; +import { ToolOutputResultSchema } from '@/ai/schemas/toolOutputResultSchema'; import { useLingui } from '@lingui/react/macro'; import { type ToolUIPart } from 'ai'; import { isDefined } from 'twenty-shared/utils'; @@ -25,12 +27,6 @@ const StyledContainer = styled.div` gap: ${({ theme }) => theme.spacing(2)}; `; -const StyledLoadingContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; -`; - const StyledContentContainer = styled.div` background: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid ${({ theme }) => theme.border.color.light}; @@ -142,6 +138,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { const hasError = isDefined(errorText); const isExpandable = isDefined(output) || hasError; + const ToolIcon = getToolIcon(toolName); if (toolName === 'code_interpreter') { const codeInput = toolInput as { code?: string } | undefined; @@ -173,13 +170,14 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { - + + {getToolDisplayMessage(input, rawToolName, false)} - + {toolName} @@ -190,33 +188,28 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { } // For execute_tool, the actual result is nested inside output.result + const outputResult = ToolOutputResultSchema.safeParse(output); const unwrappedOutput = - rawToolName === 'execute_tool' && - isDefined(output) && - typeof output === 'object' && - 'result' in output - ? (output as { result: unknown }).result + rawToolName === 'execute_tool' && outputResult.success + ? outputResult.data.result : output; + const unwrappedResult = ToolOutputResultSchema.safeParse(unwrappedOutput); + const unwrappedMessage = ToolOutputMessageSchema.safeParse(unwrappedOutput); + const displayMessage = hasError ? t`Tool execution failed` - : rawToolName === 'learn_tools' || rawToolName === 'execute_tool' + : rawToolName === 'learn_tools' || + rawToolName === 'execute_tool' || + rawToolName === 'load_skills' ? getToolDisplayMessage(input, rawToolName, true) - : unwrappedOutput && - typeof unwrappedOutput === 'object' && - 'message' in unwrappedOutput && - typeof unwrappedOutput.message === 'string' - ? unwrappedOutput.message + : unwrappedMessage.success + ? unwrappedMessage.data.message : getToolDisplayMessage(input, rawToolName, true); - const result = - unwrappedOutput && - typeof unwrappedOutput === 'object' && - 'result' in unwrappedOutput - ? (unwrappedOutput as { result: string }).result - : unwrappedOutput; - - const ToolIcon = getToolIcon(toolName); + const result = unwrappedResult.success + ? unwrappedResult.data.result + : unwrappedOutput; return ( diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts b/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts index 2002c30325..03688c350c 100644 --- a/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts +++ b/packages/twenty-front/src/modules/ai/graphql/queries/getToolIndex.ts @@ -7,7 +7,6 @@ export const GET_TOOL_INDEX = gql` description category objectName - inputSchema } } `; diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/getToolInputSchemas.ts b/packages/twenty-front/src/modules/ai/graphql/queries/getToolInputSchemas.ts new file mode 100644 index 0000000000..850ae28d98 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/graphql/queries/getToolInputSchemas.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const GET_TOOL_INPUT_SCHEMA = gql` + query GetToolInputSchema($toolName: String!) { + getToolInputSchema(toolName: $toolName) + } +`; diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index fac9d9569f..13112ecdc5 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -6,6 +6,8 @@ import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesS import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState'; +import { agentChatInputState } from '@/ai/states/agentChatInputState'; +import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; import { getTokenPair } from '@/apollo/utils/getTokenPair'; import { renewToken } from '@/auth/services/AuthService'; import { tokenPairState } from '@/auth/states/tokenPairState'; @@ -15,8 +17,6 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { cookieStorage } from '~/utils/cookie-storage'; -import { REST_API_BASE_URL } from '@/apollo/constant/rest-api-base-url'; -import { agentChatInputState } from '@/ai/states/agentChatInputState'; export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { const setTokenPair = useSetRecoilState(tokenPairState); @@ -47,7 +47,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { try { const renewedTokens = await renewToken( - `${REACT_APP_SERVER_BASE_URL}/graphql`, + `${REACT_APP_SERVER_BASE_URL}/metadata`, tokenPair, ); diff --git a/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts b/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts index 263aafe4b9..c14fea61c3 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useGetToolIndex.ts @@ -1,4 +1,3 @@ -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; import { GET_TOOL_INDEX } from '@/ai/graphql/queries/getToolIndex'; import { useQuery } from '@apollo/client'; @@ -14,11 +13,7 @@ type GetToolIndexQuery = { }; export const useGetToolIndex = () => { - const apolloMetadataClient = useApolloCoreClient(); - - const { data, loading, error } = useQuery(GET_TOOL_INDEX, { - client: apolloMetadataClient ?? undefined, - }); + const { data, loading, error } = useQuery(GET_TOOL_INDEX); return { toolIndex: data?.getToolIndex ?? [], diff --git a/packages/twenty-front/src/modules/ai/schemas/toolOutputMessageSchema.ts b/packages/twenty-front/src/modules/ai/schemas/toolOutputMessageSchema.ts new file mode 100644 index 0000000000..ceb2505a9d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/schemas/toolOutputMessageSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const ToolOutputMessageSchema = z.object({ message: z.string() }); diff --git a/packages/twenty-front/src/modules/ai/schemas/toolOutputResultSchema.ts b/packages/twenty-front/src/modules/ai/schemas/toolOutputResultSchema.ts new file mode 100644 index 0000000000..41881e8703 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/schemas/toolOutputResultSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const ToolOutputResultSchema = z.object({ result: z.unknown() }); diff --git a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts index b6f9524eec..ce16f0ea3d 100644 --- a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts +++ b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts @@ -1,77 +1,79 @@ +import { t } from '@lingui/core/macro'; + +import { isNonEmptyString } from '@sniptt/guards'; +import { z } from 'zod'; + import { type ToolInput } from '@/ai/types/ToolInput'; import { isDefined } from 'twenty-shared/utils'; +const DirectQuerySchema = z.object({ query: z.string() }); +const NestedQuerySchema = z.object({ + action: z.object({ query: z.string() }), +}); +const CustomLoadingMessageSchema = z.object({ loadingMessage: z.string() }); +const ExecuteToolSchema = z.object({ + toolName: z.coerce.string(), + arguments: z.unknown(), +}); +const LearnToolsSchema = z.object({ toolNames: z.array(z.string()) }); +const LoadSkillsSchema = z.object({ skillNames: z.array(z.string()) }); + const extractSearchQuery = (input: ToolInput): string => { - if (!input) { - return ''; + const direct = DirectQuerySchema.safeParse(input); + + if (direct.success) { + return direct.data.query; } - if ( - typeof input === 'object' && - 'query' in input && - typeof input.query === 'string' - ) { - return input.query; - } + const nested = NestedQuerySchema.safeParse(input); - if ( - typeof input === 'object' && - 'action' in input && - isDefined(input.action) && - typeof input.action === 'object' && - 'query' in input.action && - typeof input.action.query === 'string' - ) { - return input.action.query; + if (nested.success) { + return nested.data.action.query; } return ''; }; -const extractLoadingMessage = (input: ToolInput): string => { - if ( - isDefined(input) && - typeof input === 'object' && - 'loadingMessage' in input && - typeof input.loadingMessage === 'string' - ) { - return input.loadingMessage; - } +const extractCustomLoadingMessage = (input: ToolInput): string | null => { + const parsed = CustomLoadingMessageSchema.safeParse(input); - return 'Processing...'; + return parsed.success ? parsed.data.loadingMessage : null; }; export const resolveToolInput = ( input: ToolInput, toolName: string, ): { resolvedInput: ToolInput; resolvedToolName: string } => { - if ( - toolName === 'execute_tool' && - isDefined(input) && - typeof input === 'object' && - 'toolName' in input && - 'arguments' in input - ) { - return { - resolvedInput: input.arguments as ToolInput, - resolvedToolName: String(input.toolName), - }; + if (toolName !== 'execute_tool') { + return { resolvedInput: input, resolvedToolName: toolName }; } - return { resolvedInput: input, resolvedToolName: toolName }; + const parsed = ExecuteToolSchema.safeParse(input); + + if (!parsed.success) { + return { resolvedInput: input, resolvedToolName: toolName }; + } + + return { + resolvedInput: parsed.data.arguments as ToolInput, + resolvedToolName: parsed.data.toolName, + }; }; const extractLearnToolNames = (input: ToolInput): string => { - if ( - isDefined(input) && - typeof input === 'object' && - 'toolNames' in input && - Array.isArray(input.toolNames) - ) { - return input.toolNames.join(', '); - } + const parsed = LearnToolsSchema.safeParse(input); - return ''; + return parsed.success ? parsed.data.toolNames.join(', ') : ''; +}; + +const extractSkillNames = (input: ToolInput): string => { + const parsed = LoadSkillsSchema.safeParse(input); + + return parsed.success ? parsed.data.skillNames.join(', ') : ''; +}; + +const formatToolName = (toolName: string): string => { + return toolName.replace(/_/g, ' '); }; export const getToolDisplayMessage = ( @@ -81,19 +83,49 @@ export const getToolDisplayMessage = ( ): string => { const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName); + const byStatus = (finished: string, inProgress: string): string => + isFinished ? finished : inProgress; + if (resolvedToolName === 'web_search') { const query = extractSearchQuery(resolvedInput); - const action = isFinished ? 'Searched' : 'Searching'; - return query ? `${action} the web for '${query}'` : `${action} the web`; + if (isNonEmptyString(query)) { + return byStatus( + t`Searched the web for '${query}'`, + t`Searching the web for '${query}'`, + ); + } + + return byStatus(t`Searched the web`, t`Searching the web`); } if (resolvedToolName === 'learn_tools') { const names = extractLearnToolNames(resolvedInput); - const action = isFinished ? 'Learned' : 'Learning'; - return names ? `${action} ${names}` : `${action} tools...`; + if (isNonEmptyString(names)) { + return byStatus(t`Learned ${names}`, t`Learning ${names}`); + } + + return byStatus(t`Learned tools`, t`Learning tools...`); } - return extractLoadingMessage(resolvedInput); + if (resolvedToolName === 'load_skills') { + const names = extractSkillNames(resolvedInput); + + if (isNonEmptyString(names)) { + return byStatus(t`Loaded ${names}`, t`Loading ${names}`); + } + + return byStatus(t`Loaded skills`, t`Loading skills...`); + } + + const customMessage = extractCustomLoadingMessage(resolvedInput); + + if (isDefined(customMessage)) { + return customMessage; + } + + const formattedName = formatToolName(resolvedToolName); + + return byStatus(t`Ran ${formattedName}`, t`Running ${formattedName}`); }; diff --git a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx index 5cc818f27d..7ea8faf5b4 100644 --- a/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx +++ b/packages/twenty-front/src/modules/app/components/AppRouterProviders.tsx @@ -1,3 +1,4 @@ +import { AgentChatProvider } from '@/ai/components/AgentChatProvider'; import { ApolloProvider } from '@/apollo/components/ApolloProvider'; import { GotoHotkeysEffectsProvider } from '@/app/effect-components/GotoHotkeysEffectsProvider'; import { PageChangeEffect } from '@/app/effect-components/PageChangeEffect'; @@ -55,20 +56,22 @@ export const AppRouterProviders = () => { - - - - - - - - - - - - + + + + + + + + + + + + + + diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx index 3330cd2c06..3dea9c62b8 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuContainer.tsx @@ -1,6 +1,5 @@ import styled from '@emotion/styled'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; -import { AgentChatProvider } from '@/ai/components/AgentChatProvider'; import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; @@ -60,11 +59,9 @@ export const CommandMenuContainer = ({ - - - {children} - - + + {children} + diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsSystemToolTableRow.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsSystemToolTableRow.tsx index 52ecbfcd7f..377d729677 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsSystemToolTableRow.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsSystemToolTableRow.tsx @@ -1,7 +1,9 @@ +import { useLazyQuery } from '@apollo/client'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; import { useState } from 'react'; +import { GET_TOOL_INPUT_SCHEMA } from '@/ai/graphql/queries/getToolInputSchemas'; import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag'; import { TableCell } from '@/ui/layout/table/components/TableCell'; import { TableRow } from '@/ui/layout/table/components/TableRow'; @@ -23,12 +25,15 @@ import { type JsonValue } from 'type-fest'; import { useCopyToClipboard } from '~/hooks/useCopyToClipboard'; +type GetToolInputSchemaQuery = { + getToolInputSchema: object | null; +}; + export type SystemTool = { name: string; description: string; category: string; objectName?: string; - inputSchema?: object; }; export type SettingsSystemToolTableRowProps = { @@ -105,14 +110,24 @@ export const SettingsSystemToolTableRow = ({ const [isExpanded, setIsExpanded] = useState(false); const { copyToClipboard } = useCopyToClipboard(); - const Icon = getCategoryIcon(tool.category); + // Fetch inputSchema for this specific tool on first expand + const [fetchSchema, { data: schemaData, loading: schemaLoading }] = + useLazyQuery(GET_TOOL_INPUT_SCHEMA, { + variables: { toolName: tool.name }, + }); + + const inputSchema = schemaData?.getToolInputSchema; + const hasInputSchema = - isDefined(tool.inputSchema) && Object.keys(tool.inputSchema).length > 0; + isDefined(inputSchema) && Object.keys(inputSchema).length > 0; + + const Icon = getCategoryIcon(tool.category); const handleRowClick = () => { - if (hasInputSchema) { - setIsExpanded(!isExpanded); + if (!schemaData && !schemaLoading) { + fetchSchema(); } + setIsExpanded(!isExpanded); }; return ( @@ -120,7 +135,7 @@ export const SettingsSystemToolTableRow = ({ @@ -132,35 +147,42 @@ export const SettingsSystemToolTableRow = ({ - {hasInputSchema && - (isExpanded ? ( - - ) : ( - - ))} + {isExpanded ? ( + + ) : ( + + )} - {hasInputSchema && ( - - - {tool.description && ( - {tool.description} - )} - {t`Input Schema`} - true} - emptyArrayLabel={t`Empty Array`} - emptyObjectLabel={t`No parameters`} - emptyStringLabel={t`[empty string]`} - arrowButtonCollapsedLabel={t`Expand`} - arrowButtonExpandedLabel={t`Collapse`} - onNodeValueClick={copyToClipboard} - /> - - - )} + + + {tool.description && ( + {tool.description} + )} + {schemaLoading && ( + {t`Loading schema...`} + )} + {hasInputSchema && ( + <> + {t`Input Schema`} + true} + emptyArrayLabel={t`Empty Array`} + emptyObjectLabel={t`No parameters`} + emptyStringLabel={t`[empty string]`} + arrowButtonCollapsedLabel={t`Expand`} + arrowButtonExpandedLabel={t`Collapse`} + onNodeValueClick={copyToClipboard} + /> + + )} + {!schemaLoading && !hasInputSchema && ( + {t`No parameters`} + )} + + ); }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts index 6518361a5d..71800accda 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts @@ -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; - generateDescriptors(context: ToolProviderContext): Promise; + generateDescriptors( + context: ToolProviderContext, + options?: GenerateDescriptorOptions, + ): Promise<(ToolIndexEntry | ToolDescriptor)[]>; } // NativeModelToolProvider is special: SDK-native tools are opaque and not diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 96d52ee6cf..4385f894c5 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -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 { - 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 }, }; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts index 7de0bf6bdf..07dadec36f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts @@ -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 { + 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, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index aa19990aac..cb36787388 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -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 { - 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, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index 46af4bd673..18e39932b4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -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 { + 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; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts index 1c5758bae5..ec972a689b 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts @@ -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 { + 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, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts index 2ab5410caf..15def76582 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts @@ -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 { + 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); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts index 5751bea6ac..9dab6c5557 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts @@ -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 { + 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, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts index f4bed085b1..b8cb2ccce0 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/resolvers/tool-index.resolver.ts @@ -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 { + 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; + } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts index 60d443f9d0..b6e56f55ce 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -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, context: ToolProviderContext, ): Promise { @@ -194,7 +197,7 @@ export class ToolExecutorService { } private async dispatchStaticTool( - descriptor: ToolDescriptor, + descriptor: ToolIndexEntry | ToolDescriptor, args: Record, context: ToolProviderContext, ): Promise { diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 8409aa394c..d739cdfd06 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -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 { - 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 { + 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> { + 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(); + + 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(); - 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, ); @@ -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 { + ): Promise { 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 { - 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 | 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 { 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 { - 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) => Promise, - ): (args: Record) => Promise { - return async (args: Record) => { - 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'; - } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 76d6e4ff9e..4d8282632a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts index 4b77db5067..efeee66387 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts index 9d5ef36d6a..989db798ad 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts index 241d87c668..952cfd7e2d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts @@ -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(', ')}`, }; }, }); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts new file mode 100644 index 0000000000..afe4bb8560 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-context.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts index 37d31e9175..e9f3dd7828 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-error.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-error.util.ts new file mode 100644 index 0000000000..8318716937 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-error.util.ts @@ -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) => Promise, +): ((args: Record) => Promise) => { + return async (args: Record) => { + 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), + }, + }; + } + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts index 31ae1b9bda..27fcedbcf4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts @@ -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 }, }; }); }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index c66035733b..4e36d43444 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai'; @@ -34,8 +34,6 @@ export type StreamAgentChatOptions = { @Injectable() export class AgentChatStreamingService { - private readonly logger = new Logger(AgentChatStreamingService.name); - constructor( @InjectRepository(AgentChatThreadEntity) private readonly threadRepository: Repository, @@ -65,6 +63,25 @@ export class AgentChatStreamingService { ); } + // Fire user-message save without awaiting to avoid delaying time-to-first-letter. + // The promise is awaited inside onFinish where we need the turnId. + const lastUserText = + messages[messages.length - 1]?.parts.find((part) => part.type === 'text') + ?.text ?? ''; + + const userMessagePromise = this.agentChatService.addMessage({ + threadId: thread.id, + uiMessage: { + role: AgentMessageRole.USER, + parts: [{ type: 'text', text: lastUserText }], + }, + }); + + // Prevent unhandled rejection if onFinish never runs (e.g. stream + // setup error or empty response early-return). The real error still + // surfaces when awaited in onFinish. + userMessagePromise.catch(() => {}); + try { const uiStream = createUIMessageStream({ execute: async ({ writer }) => { @@ -97,8 +114,6 @@ export class AgentChatStreamingService { writer.merge( stream.toUIMessageStream({ onError: (error) => { - this.logger.error('Stream error:', error); - return error instanceof Error ? error.message : String(error); }, sendStart: false, @@ -179,57 +194,26 @@ export class AgentChatStreamingService { return; } - const validThreadId = thread.id; + const userMessage = await userMessagePromise; - if (!validThreadId) { - this.logger.error('Thread ID is unexpectedly null/undefined'); + await this.agentChatService.addMessage({ + threadId: thread.id, + uiMessage: responseMessage, + turnId: userMessage.turnId, + }); - return; - } - - try { - const userMessage = await this.agentChatService.addMessage({ - threadId: validThreadId, - uiMessage: { - role: AgentMessageRole.USER, - parts: [ - { - type: 'text', - text: - messages[messages.length - 1].parts.find( - (part) => part.type === 'text', - )?.text ?? '', - }, - ], - }, - }); - - await this.agentChatService.addMessage({ - threadId: validThreadId, - uiMessage: responseMessage, - turnId: userMessage.turnId, - }); - - await this.threadRepository.update(validThreadId, { - totalInputTokens: () => - `"totalInputTokens" + ${streamUsage.inputTokens}`, - totalOutputTokens: () => - `"totalOutputTokens" + ${streamUsage.outputTokens}`, - totalInputCredits: () => - `"totalInputCredits" + ${streamUsage.inputCredits}`, - totalOutputCredits: () => - `"totalOutputCredits" + ${streamUsage.outputCredits}`, - contextWindowTokens: modelConfig.contextWindowTokens, - conversationSize: lastStepConversationSize, - }); - } catch (saveError) { - this.logger.error( - 'Failed to save messages:', - saveError instanceof Error - ? saveError.message - : String(saveError), - ); - } + await this.threadRepository.update(thread.id, { + totalInputTokens: () => + `"totalInputTokens" + ${streamUsage.inputTokens}`, + totalOutputTokens: () => + `"totalOutputTokens" + ${streamUsage.outputTokens}`, + totalInputCredits: () => + `"totalInputCredits" + ${streamUsage.inputCredits}`, + totalOutputCredits: () => + `"totalOutputCredits" + ${streamUsage.outputCredits}`, + contextWindowTokens: modelConfig.contextWindowTokens, + conversationSize: lastStepConversationSize, + }); }, sendReasoning: true, }), @@ -237,13 +221,18 @@ export class AgentChatStreamingService { }, }); - pipeUIMessageStreamToResponse({ stream: uiStream, response }); + pipeUIMessageStreamToResponse({ + stream: uiStream, + response, + // Consume the stream independently so onFinish fires even if + // the client disconnects (e.g., page refresh mid-stream) + consumeSseStream: ({ stream }) => { + stream.pipeTo(new WritableStream()).catch(() => {}); + }, + }); } catch (error) { - this.logger.error( - 'Failed to stream chat:', - error instanceof Error ? error.message : String(error), - ); response.end(); + throw error; } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts index e7ca633d85..13b73bbfef 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; -import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { EXECUTE_TOOL_TOOL_NAME, LEARN_TOOLS_TOOL_NAME, @@ -131,7 +131,7 @@ export class SystemPromptBuilderService { } buildFullPrompt( - toolCatalog: ToolDescriptor[], + toolCatalog: ToolIndexEntry[], skillCatalog: FlatSkill[], preloadedTools: string[], contextString?: string, @@ -242,12 +242,12 @@ ${skillsList}`; } buildToolCatalogSection( - toolCatalog: ToolDescriptor[], + toolCatalog: ToolIndexEntry[], preloadedTools: string[], ): string { const preloadedSet = new Set(preloadedTools); - const toolsByCategory = new Map(); + const toolsByCategory = new Map(); for (const tool of toolCatalog) { const category = tool.category; diff --git a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts index fb858071a9..09afecba4c 100644 --- a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts @@ -17,7 +17,6 @@ export const METADATA_VERSIONED_WORKSPACE_CACHE_KEY = { MetadataObjectMetadataMaps: 'metadata:object-metadata-maps', GraphQLUsedScalarNames: 'graphql:used-scalar-names', ORMEntitySchemas: 'orm:entity-schemas', - ToolCatalog: 'tool-catalog', } as const; export const WORKSPACE_CACHE_KEYS = { GraphQLOperations: 'graphql:operations', @@ -215,24 +214,6 @@ export class WorkspaceCacheStorageService { ); } - setToolCatalog( - cacheKey: string, - descriptors: unknown[], - ttl: number, - ): Promise { - return this.cacheStorageService.set( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`, - descriptors, - ttl, - ); - } - - getToolCatalog(cacheKey: string): Promise { - return this.cacheStorageService.get( - `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`, - ); - } - async flush(workspaceId: string, metadataVersion?: number): Promise { await this.flushVersionedMetadata(workspaceId, metadataVersion); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts index 2bc6bcdc59..ab442c008c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts @@ -2,7 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { LogicFunctionRuntime } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; @@ -10,7 +10,6 @@ import { LogicFunctionMetadataService } from 'src/engine/metadata-modules/logic- import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; -import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; @@ -28,9 +27,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { let globalWorkspaceOrmManager: jest.Mocked; let logicFunctionMetadataService: jest.Mocked; let codeStepBuildService: jest.Mocked; - let agentRepository: jest.Mocked; + let agentService: jest.Mocked; let roleTargetRepository: jest.Mocked; - let roleRepository: jest.Mocked; let objectMetadataRepository: jest.Mocked; let workflowCommonWorkspaceService: jest.Mocked; let aiAgentRoleService: jest.Mocked; @@ -96,21 +94,17 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { destroyOne: jest.fn(), } as unknown as jest.Mocked; - agentRepository = { - findOne: jest.fn(), - delete: jest.fn(), - }; + agentService = { + deleteManyAgents: jest.fn().mockResolvedValue([]), + findOneAgentById: jest.fn(), + createOneAgent: jest.fn(), + } as unknown as jest.Mocked; roleTargetRepository = { findOne: jest.fn(), count: jest.fn(), }; - roleRepository = { - findOne: jest.fn(), - delete: jest.fn(), - }; - objectMetadataRepository = { findOne: jest.fn(), }; @@ -146,17 +140,13 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { useValue: codeStepBuildService, }, { - provide: getRepositoryToken(AgentEntity), - useValue: agentRepository, + provide: AgentService, + useValue: agentService, }, { provide: getRepositoryToken(RoleTargetEntity), useValue: roleTargetRepository, }, - { - provide: getRepositoryToken(RoleEntity), - useValue: roleRepository, - }, { provide: getRepositoryToken(ObjectMetadataEntity), useValue: objectMetadataRepository, @@ -239,15 +229,13 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { }, } as unknown as WorkflowAction; - agentRepository.findOne.mockResolvedValue({ id: 'agent-id' }); - await service.runWorkflowVersionStepDeletionSideEffects({ step, workspaceId: mockWorkspaceId, }); - expect(agentRepository.delete).toHaveBeenCalledWith({ - id: 'agent-id', + expect(agentService.deleteManyAgents).toHaveBeenCalledWith({ + ids: ['agent-id'], workspaceId: mockWorkspaceId, }); }); @@ -272,7 +260,6 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { }, } as unknown as WorkflowAction; - agentRepository.findOne.mockResolvedValue({ id: 'agent-id' }); roleTargetRepository.findOne.mockResolvedValue({ id: 'role-target-id', roleId: 'role-id', @@ -283,8 +270,8 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { workspaceId: mockWorkspaceId, }); - expect(agentRepository.delete).toHaveBeenCalledWith({ - id: 'agent-id', + expect(agentService.deleteManyAgents).toHaveBeenCalledWith({ + ids: ['agent-id'], workspaceId: mockWorkspaceId, }); expect( diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 4b6929382f..1c69d28cee 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -17,7 +17,7 @@ import { v4 } from 'uuid'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; import { AiAgentRoleService } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.service'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { LogicFunctionMetadataService } from 'src/engine/metadata-modules/logic-function/services/logic-function-metadata.service'; @@ -66,8 +66,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, private readonly logicFunctionMetadataService: LogicFunctionMetadataService, private readonly codeStepBuildService: CodeStepBuildService, - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, + private readonly agentService: AgentService, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, @InjectRepository(ObjectMetadataEntity) @@ -98,27 +97,24 @@ export class WorkflowVersionStepOperationsWorkspaceService { break; } - const agent = await this.agentRepository.findOne({ - where: { id: step.settings.input.agentId, workspaceId }, + const roleTarget = await this.roleTargetRepository.findOne({ + where: { + agentId: step.settings.input.agentId, + workspaceId, + }, }); - if (isDefined(agent)) { - const roleTarget = await this.roleTargetRepository.findOne({ - where: { - agentId: agent.id, - workspaceId, - }, + await this.agentService.deleteManyAgents({ + ids: [step.settings.input.agentId], + workspaceId, + }); + + if (isDefined(roleTarget?.roleId) && isDefined(roleTarget?.id)) { + await this.aiAgentRoleService.deleteAgentOnlyRoleIfUnused({ + roleId: roleTarget.roleId, + roleTargetId: roleTarget.id, + workspaceId, }); - - await this.agentRepository.delete({ id: agent.id, workspaceId }); - - if (isDefined(roleTarget?.roleId) && isDefined(roleTarget?.id)) { - await this.aiAgentRoleService.deleteAgentOnlyRoleIfUnused({ - roleId: roleTarget.roleId, - roleTargetId: roleTarget.id, - workspaceId, - }); - } } break; } @@ -415,27 +411,20 @@ export class WorkflowVersionStepOperationsWorkspaceService { workspaceId, }); - const newAgent = await this.agentRepository.save({ - name: 'workflow-service-agent' + v4(), - label: 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4), - icon: 'IconRobot', - description: '', - prompt: - 'You are a helpful AI assistant. Complete the task based on the workflow context.', - modelId: DEFAULT_SMART_MODEL, - responseFormat: { type: 'text' }, + const newAgent = await this.agentService.createOneAgent( + { + label: + 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4), + icon: 'IconRobot', + description: '', + prompt: + 'You are a helpful AI assistant. Complete the task based on the workflow context.', + modelId: DEFAULT_SMART_MODEL, + responseFormat: { type: 'text' }, + isCustom: true, + }, workspaceId, - isCustom: true, - }); - - if (!isDefined(newAgent)) { - throw new WorkflowVersionStepException( - 'Failed to create AI Agent step', - WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE, - ); - } - - await this.workspaceCacheService.flush(workspaceId, ['flatAgentMaps']); + ); return { builtStep: { @@ -676,30 +665,34 @@ export class WorkflowVersionStepOperationsWorkspaceService { }; } case WorkflowActionType.AI_AGENT: { - const existingAgent = await this.agentRepository.findOne({ - where: { id: step.settings.input.agentId, workspaceId }, - }); + const agentId = step.settings.input.agentId; - if (!isDefined(existingAgent)) { + if (!isDefined(agentId)) { throw new WorkflowVersionStepException( - 'Agent not found for cloning', + 'Agent ID is required for cloning', WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE, ); } - const clonedAgent = await this.agentRepository.save({ - name: 'workflow-service-agent' + v4(), - label: existingAgent.label, - icon: existingAgent.icon, - description: existingAgent.description, - prompt: existingAgent.prompt, - modelId: existingAgent.modelId, - responseFormat: existingAgent.responseFormat, + const existingAgent = await this.agentService.findOneAgentById({ + id: agentId, workspaceId, - isCustom: true, - modelConfiguration: existingAgent.modelConfiguration, }); + const clonedAgent = await this.agentService.createOneAgent( + { + label: existingAgent.label, + icon: existingAgent.icon ?? undefined, + description: existingAgent.description ?? undefined, + prompt: existingAgent.prompt, + modelId: existingAgent.modelId, + responseFormat: existingAgent.responseFormat ?? undefined, + modelConfiguration: existingAgent.modelConfiguration ?? undefined, + isCustom: true, + }, + workspaceId, + ); + return { ...step, id: v4(), diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts index 09ff162de3..4813f253dc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts @@ -3,7 +3,7 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -27,10 +27,10 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl WorkflowCommonModule, CodeStepBuildModule, AiAgentRoleModule, + AiAgentModule, WorkspaceCacheModule, NestjsQueryTypeOrmModule.forFeature([ ObjectMetadataEntity, - AgentEntity, RoleTargetEntity, RoleEntity, ]),