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

## Summary

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

## Test plan

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


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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait 2026-02-13 10:27:38 +01:00 committed by GitHub
parent 5c3c2e08a6
commit 21c51ec251
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 815 additions and 656 deletions

34
.cursor/Dockerfile Normal file
View file

@ -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

View file

@ -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"
}
]
}
}

View file

@ -3670,6 +3670,7 @@ export type Query = {
getSSOIdentityProviders: Array<FindAvailableSsoidpOutput>;
getSystemHealthStatus: SystemHealth;
getToolIndex: Array<ToolIndexEntry>;
getToolInputSchema?: Maybe<Scalars['JSON']>;
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<typeof useGetToolIndexQuery>;
export type GetToolIndexLazyQueryHookResult = ReturnType<typeof useGetToolIndexLazyQuery>;
export type GetToolIndexQueryResult = Apollo.QueryResult<GetToolIndexQuery, GetToolIndexQueryVariables>;
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<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>(GetToolInputSchemaDocument, options);
}
export function useGetToolInputSchemaLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>(GetToolInputSchemaDocument, options);
}
export type GetToolInputSchemaQueryHookResult = ReturnType<typeof useGetToolInputSchemaQuery>;
export type GetToolInputSchemaLazyQueryHookResult = ReturnType<typeof useGetToolInputSchemaLazyQuery>;
export type GetToolInputSchemaQueryResult = Apollo.QueryResult<GetToolInputSchemaQuery, GetToolInputSchemaQueryVariables>;
export const TrackAnalyticsDocument = gql`
mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) {
trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) {

View file

@ -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 }) => {
<StyledContainer>
<StyledToggleButton isExpandable={false}>
<StyledLeftContent>
<StyledLoadingContainer>
<StyledIconTextContainer>
<ToolIcon size={theme.icon.size.sm} />
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
</StyledIconTextContainer>
</StyledLeftContent>
<StyledRightContent>
<StyledToolName>{toolName}</StyledToolName>
@ -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 (
<StyledContainer>

View file

@ -7,7 +7,6 @@ export const GET_TOOL_INDEX = gql`
description
category
objectName
inputSchema
}
}
`;

View file

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const GET_TOOL_INPUT_SCHEMA = gql`
query GetToolInputSchema($toolName: String!) {
getToolInputSchema(toolName: $toolName)
}
`;

View file

@ -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,
);

View file

@ -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<GetToolIndexQuery>(GET_TOOL_INDEX, {
client: apolloMetadataClient ?? undefined,
});
const { data, loading, error } = useQuery<GetToolIndexQuery>(GET_TOOL_INDEX);
return {
toolIndex: data?.getToolIndex ?? [],

View file

@ -0,0 +1,3 @@
import { z } from 'zod';
export const ToolOutputMessageSchema = z.object({ message: z.string() });

View file

@ -0,0 +1,3 @@
import { z } from 'zod';
export const ToolOutputResultSchema = z.object({ result: z.unknown() });

View file

@ -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}`);
};

View file

@ -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 = () => {
<UserThemeProviderEffect />
<SnackBarProvider>
<ErrorMessageEffect />
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
<AgentChatProvider>
<DialogComponentInstanceContext.Provider
value={{ instanceId: 'dialog-manager' }}
>
<DialogManager>
<StrictMode>
<PromiseRejectionEffect />
<GotoHotkeysEffectsProvider />
<PageTitle title={pageTitle} />
<PageFavicon />
<Outlet />
<GlobalFilePreviewModal />
</StrictMode>
</DialogManager>
</DialogComponentInstanceContext.Provider>
</AgentChatProvider>
</SnackBarProvider>
<MainContextStoreProvider />
<SupportChatEffect />

View file

@ -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 = ({
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
>
<AgentChatProvider>
<StyledCommandMenuContainer isMobile={isMobile}>
{children}
</StyledCommandMenuContainer>
</AgentChatProvider>
<StyledCommandMenuContainer isMobile={isMobile}>
{children}
</StyledCommandMenuContainer>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>

View file

@ -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<GetToolInputSchemaQuery>(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 = ({
<StyledSystemToolTableRow
key={tool.name}
onClick={handleRowClick}
isExpandable={hasInputSchema}
isExpandable
>
<StyledNameTableCell>
<StyledIconContainer>
@ -132,35 +147,42 @@ export const SettingsSystemToolTableRow = ({
<SettingsItemTypeTag item={{ isCustom: false }} />
</TableCell>
<StyledActionTableCell>
{hasInputSchema &&
(isExpanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
))}
{isExpanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
)}
</StyledActionTableCell>
</StyledSystemToolTableRow>
{hasInputSchema && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledExpandableContent>
{tool.description && (
<StyledDescription>{tool.description}</StyledDescription>
)}
<StyledSectionTitle>{t`Input Schema`}</StyledSectionTitle>
<JsonTree
value={tool.inputSchema as JsonValue}
shouldExpandNodeInitially={() => true}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`No parameters`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledExpandableContent>
</AnimatedExpandableContainer>
)}
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledExpandableContent>
{tool.description && (
<StyledDescription>{tool.description}</StyledDescription>
)}
{schemaLoading && (
<StyledSectionTitle>{t`Loading schema...`}</StyledSectionTitle>
)}
{hasInputSchema && (
<>
<StyledSectionTitle>{t`Input Schema`}</StyledSectionTitle>
<JsonTree
value={inputSchema as JsonValue}
shouldExpandNodeInitially={() => true}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`No parameters`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</>
)}
{!schemaLoading && !hasInputSchema && (
<StyledSectionTitle>{t`No parameters`}</StyledSectionTitle>
)}
</StyledExpandableContent>
</AnimatedExpandableContainer>
</>
);
};

View file

@ -4,7 +4,10 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@ -31,12 +34,19 @@ export type ToolRetrievalOptions = {
wrapWithErrorContext?: boolean;
};
export type GenerateDescriptorOptions = {
includeSchemas?: boolean; // defaults to true for backward compat
};
export interface ToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateDescriptors(context: ToolProviderContext): Promise<ToolDescriptor[]>;
generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]>;
}
// NativeModelToolProvider is special: SDK-native tools are opaque and not

View file

@ -4,6 +4,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@ -13,7 +14,10 @@ import {
type StaticToolHandler,
ToolExecutorService,
} from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
@ -65,8 +69,10 @@ export class ActionToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
@ -75,7 +81,9 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasHttpPermission) {
descriptors.push(this.buildDescriptor('http_request', this.httpTool));
descriptors.push(
this.buildDescriptor('http_request', this.httpTool, includeSchemas),
);
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
@ -85,11 +93,17 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasEmailPermission) {
descriptors.push(this.buildDescriptor('send_email', this.sendEmailTool));
descriptors.push(
this.buildDescriptor('send_email', this.sendEmailTool, includeSchemas),
);
}
descriptors.push(
this.buildDescriptor('search_help_center', this.searchHelpCenterTool),
this.buildDescriptor(
'search_help_center',
this.searchHelpCenterTool,
includeSchemas,
),
);
const hasCodeInterpreterPermission =
@ -101,19 +115,29 @@ export class ActionToolProvider implements ToolProvider {
if (hasCodeInterpreterPermission) {
descriptors.push(
this.buildDescriptor('code_interpreter', this.codeInterpreterTool),
this.buildDescriptor(
'code_interpreter',
this.codeInterpreterTool,
includeSchemas,
),
);
}
return descriptors;
}
private buildDescriptor(toolId: string, tool: Tool): ToolDescriptor {
private buildDescriptor(
toolId: string,
tool: Tool,
includeSchemas: boolean,
): ToolIndexEntry | ToolDescriptor {
return {
name: toolId,
description: tool.description,
category: ToolCategory.ACTION,
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
...(includeSchemas && {
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
}),
executionRef: { kind: 'static', toolId },
};
}

View file

@ -3,6 +3,7 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@ -10,7 +11,10 @@ import {
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service';
@ -56,7 +60,8 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.dashboardToolService) {
return [];
}
@ -66,6 +71,8 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}

View file

@ -8,6 +8,7 @@ import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@ -21,7 +22,10 @@ import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-s
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
@ -43,8 +47,10 @@ export class DatabaseToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
return descriptors;
@ -109,9 +115,11 @@ export class DatabaseToolProvider implements ToolProvider {
name: `find_${snakePlural}`,
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -125,7 +133,9 @@ export class DatabaseToolProvider implements ToolProvider {
name: `find_one_${snakeSingular}`,
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
...(includeSchemas && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -141,9 +151,11 @@ export class DatabaseToolProvider implements ToolProvider {
name: `create_${snakeSingular}`,
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -157,12 +169,14 @@ export class DatabaseToolProvider implements ToolProvider {
name: `create_many_${snakePlural}`,
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -176,9 +190,11 @@ export class DatabaseToolProvider implements ToolProvider {
name: `update_${snakeSingular}`,
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -192,12 +208,14 @@ export class DatabaseToolProvider implements ToolProvider {
name: `update_many_${snakePlural}`,
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
...(includeSchemas && {
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
@ -213,7 +231,9 @@ export class DatabaseToolProvider implements ToolProvider {
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
...(includeSchemas && {
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,

View file

@ -3,12 +3,16 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
@ -26,7 +30,10 @@ export class LogicFunctionToolProvider implements ToolProvider {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@ -42,29 +49,34 @@ export class LogicFunctionToolProvider implements ToolProvider {
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const descriptors: ToolDescriptor[] = [];
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
for (const logicFunction of logicFunctionsWithSchema) {
const toolName = this.buildLogicFunctionToolName(logicFunction.name);
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
descriptors.push({
const base: ToolIndexEntry = {
name: toolName,
description:
logicFunction.description ||
`Execute the ${logicFunction.name} logic function`,
category: ToolCategory.LOGIC_FUNCTION,
inputSchema,
executionRef: {
kind: 'logic_function',
logicFunctionId: logicFunction.id,
},
});
};
if (includeSchemas) {
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
descriptors.push({ ...base, inputSchema });
} else {
descriptors.push(base);
}
}
return descriptors;

View file

@ -3,13 +3,17 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
@ -49,12 +53,15 @@ export class MetadataToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const toolSet = {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
return toolSetToDescriptors(toolSet, ToolCategory.METADATA);
return toolSetToDescriptors(toolSet, ToolCategory.METADATA, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}

View file

@ -3,13 +3,17 @@ import { Injectable, OnModuleInit } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@ -65,8 +69,12 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const schemaOptions = {
includeSchemas: options?.includeSchemas ?? true,
};
const readTools = this.viewToolsFactory.generateReadTools(
context.workspaceId,
@ -90,9 +98,10 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW,
schemaOptions,
);
}
return toolSetToDescriptors(readTools, ToolCategory.VIEW);
return toolSetToDescriptors(readTools, ToolCategory.VIEW, schemaOptions);
}
}

View file

@ -3,6 +3,7 @@ import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type GenerateDescriptorOptions,
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@ -10,7 +11,10 @@ import {
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@ -56,7 +60,8 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.workflowToolService) {
return [];
}
@ -66,6 +71,8 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW, {
includeSchemas: options?.includeSchemas ?? true,
});
}
}

View file

@ -1,5 +1,5 @@
import { UseGuards } from '@nestjs/common';
import { Field, ObjectType, Query } from '@nestjs/graphql';
import { Args, Field, ObjectType, Query } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
@ -61,4 +61,34 @@ export class ToolIndexResolver {
userWorkspaceId,
});
}
// Resolves the inputSchema for a single tool on demand (avoids computing
// schemas for every tool in the workspace when listing the tool index).
@Query(() => graphqlTypeJson, { nullable: true })
@UseGuards(NoPermissionGuard)
async getToolInputSchema(
@Args('toolName') toolName: string,
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<object | null> {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId: workspace.id,
});
if (!roleId) {
return null;
}
const schemas = await this.toolRegistryService.resolveSchemas([toolName], {
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
userId: user?.id,
userWorkspaceId,
});
return schemas.get(toolName) ?? null;
}
}

View file

@ -21,7 +21,10 @@ import { FindRecordsService } from 'src/engine/core-modules/record-crud/services
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@ -77,7 +80,7 @@ export class ToolExecutorService {
}
async dispatch(
descriptor: ToolDescriptor,
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
@ -194,7 +197,7 @@ export class ToolExecutorService {
}
private async dispatchStaticTool(
descriptor: ToolDescriptor,
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {

View file

@ -1,7 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolCallOptions, type ToolSet, jsonSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import {
type CodeExecutionStreamEmitter,
@ -14,107 +13,102 @@ import {
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
import {
type ToolDescriptor,
type ToolIndexEntry,
} from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
generateErrorSuggestion,
wrapWithErrorHandler,
} from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
// Backward-compatible alias -- consumers can import this instead of ToolDescriptor
export type ToolIndexEntry = ToolDescriptor;
export type ToolSearchOptions = {
limit?: number;
category?: ToolCategory;
};
export type ToolContext = {
workspaceId: string;
roleId: string;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
const RAM_TTL_MS = 5_000;
const REDIS_TTL_MS = 300_000;
export { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
// Two-tier cache: RAM (5s) → Redis (5min) → generate from providers
private readonly ramCache = new Map<
string,
{ descriptors: ToolDescriptor[]; cachedAt: number }
>();
constructor(
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
private readonly nativeModelToolProvider: NativeModelToolProvider,
private readonly toolExecutorService: ToolExecutorService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
// Core: returns cached ToolDescriptor[] for a workspace+role+user
async getCatalog(context: ToolProviderContext): Promise<ToolDescriptor[]> {
const cacheKey = await this.buildCacheKey(context);
// Returns ToolIndexEntry[] (lightweight, no schemas).
// Underlying data (metadata, permissions) is already cached by WorkspaceCacheService.
// Providers run in parallel since they are independent.
async getCatalog(context: ToolProviderContext): Promise<ToolIndexEntry[]> {
const results = await Promise.all(
this.providers.map(async (provider) => {
if (await provider.isAvailable(context)) {
return provider.generateDescriptors(context, {
includeSchemas: false,
});
}
// 1. RAM hit?
const ramEntry = this.ramCache.get(cacheKey);
return [];
}),
);
if (ramEntry && Date.now() - ramEntry.cachedAt < RAM_TTL_MS) {
return ramEntry.descriptors;
return results.flat();
}
// On-demand schema generation for specific tools
async resolveSchemas(
toolNames: string[],
context: ToolProviderContext,
): Promise<Map<string, object>> {
const index = await this.getCatalog(context);
const nameSet = new Set(toolNames);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
// Group matching entries by provider category
const byCategory = new Map<string, ToolIndexEntry[]>();
for (const entry of matchingEntries) {
const existing = byCategory.get(entry.category) ?? [];
existing.push(entry);
byCategory.set(entry.category, existing);
}
// 2. Redis hit?
const redisData =
await this.workspaceCacheStorageService.getToolCatalog(cacheKey);
const schemas = new Map<string, object>();
if (redisData) {
const descriptors = redisData as ToolDescriptor[];
for (const [category, entries] of byCategory) {
const provider = this.providers.find(
(providerItem) => providerItem.category === category,
);
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
if (!provider) {
continue;
}
const fullDescriptors = await provider.generateDescriptors(context, {
includeSchemas: true,
});
return descriptors;
}
const entryNameSet = new Set(entries.map((entry) => entry.name));
// 3. Generate from providers (cache miss)
const descriptors: ToolDescriptor[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const providerDescriptors = await provider.generateDescriptors(context);
descriptors.push(...providerDescriptors);
for (const descriptor of fullDescriptors) {
if (
entryNameSet.has(descriptor.name) &&
'inputSchema' in descriptor &&
descriptor.inputSchema
) {
schemas.set(descriptor.name, descriptor.inputSchema);
}
}
}
this.logger.log(
`Generated ${descriptors.length} tool descriptors for workspace ${context.workspaceId}`,
);
// Store in both caches
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
await this.workspaceCacheStorageService.setToolCatalog(
cacheKey,
descriptors,
REDIS_TTL_MS,
);
return descriptors;
return schemas;
}
// Hydrate ToolDescriptor[] into an AI SDK ToolSet with thin dispatch closures
@ -126,7 +120,6 @@ export class ToolRegistryService {
const toolSet: ToolSet = {};
for (const descriptor of descriptors) {
// Add loadingMessage to the clean stored schema
const schemaWithLoading = wrapJsonSchemaForExecution(
descriptor.inputSchema as Record<string, unknown>,
);
@ -140,7 +133,7 @@ export class ToolRegistryService {
description: descriptor.description,
inputSchema: jsonSchema(schemaWithLoading),
execute: options?.wrapWithErrorContext
? this.wrapWithErrorHandler(descriptor.name, executeFn)
? wrapWithErrorHandler(descriptor.name, executeFn)
: executeFn,
};
}
@ -152,7 +145,7 @@ export class ToolRegistryService {
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolDescriptor[]> {
): Promise<ToolIndexEntry[]> {
const context = this.buildContext(
workspaceId,
roleId,
@ -164,81 +157,6 @@ export class ToolRegistryService {
return this.getCatalog(context);
}
async searchTools(
query: string,
workspaceId: string,
roleId: string,
options: ToolSearchOptions & {
userId?: string;
userWorkspaceId?: string;
} = {},
): Promise<ToolDescriptor[]> {
const { limit = 5, category, userId, userWorkspaceId } = options;
const context = this.buildContext(
workspaceId,
roleId,
undefined,
userId,
userWorkspaceId,
);
const descriptors = await this.getCatalog(context);
const queryLower = query.toLowerCase();
const queryTerms = queryLower
.split(/\s+/)
.filter((term) => term.length > 2);
const scored = descriptors
.filter((tool) => !category || tool.category === category)
.map((tool) => {
let score = 0;
const nameLower = tool.name.toLowerCase();
const descLower = tool.description.toLowerCase();
const objectLower = tool.objectName?.toLowerCase() ?? '';
if (nameLower.includes(queryLower)) {
score += 100;
}
if (objectLower && queryLower.includes(objectLower)) {
score += 80;
}
for (const term of queryTerms) {
if (nameLower.includes(term)) {
score += 30;
}
if (objectLower.includes(term)) {
score += 25;
}
if (descLower.includes(term)) {
score += 10;
}
}
const operations = ['find', 'create', 'update', 'delete', 'search'];
for (const op of operations) {
if (queryLower.includes(op) && nameLower.includes(op)) {
score += 40;
}
}
return { tool, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.tool);
this.logger.log(
`Tool search for "${query}" returned ${scored.length} results`,
);
return scored;
}
async getToolsByName(
names: string[],
context: ToolContext,
@ -251,13 +169,20 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((descriptor) =>
nameSet.has(descriptor.name),
);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
return this.hydrateToolSet(filtered, fullContext);
const schemas = await this.resolveSchemas(names, fullContext);
const descriptors: ToolDescriptor[] = matchingEntries
.filter((entry) => schemas.has(entry.name))
.map((entry) => ({
...entry,
inputSchema: schemas.get(entry.name)!,
}));
return this.hydrateToolSet(descriptors, fullContext);
}
async getToolInfo(
@ -275,12 +200,17 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((entry) => nameSet.has(entry.name));
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
return filtered.map((entry) => {
let schemas: Map<string, object> | undefined;
if (aspects.includes('schema')) {
schemas = await this.resolveSchemas(names, fullContext);
}
return matchingEntries.map((entry) => {
const info: {
name: string;
description?: string;
@ -291,8 +221,8 @@ export class ToolRegistryService {
info.description = entry.description;
}
if (aspects.includes('schema')) {
info.inputSchema = entry.inputSchema;
if (aspects.includes('schema') && schemas) {
info.inputSchema = schemas.get(entry.name);
}
return info;
@ -314,10 +244,10 @@ export class ToolRegistryService {
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const descriptor = descriptors.find((desc) => desc.name === toolName);
const index = await this.getCatalog(fullContext);
const entry = index.find((indexEntry) => indexEntry.name === toolName);
if (!descriptor) {
if (!entry) {
return {
toolName,
error: {
@ -329,7 +259,7 @@ export class ToolRegistryService {
}
const result = await this.toolExecutorService.dispatch(
descriptor,
entry,
args,
fullContext,
);
@ -348,33 +278,41 @@ export class ToolRegistryService {
toolName,
error: {
message: errorMessage,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
};
}
}
// Main method for eager loading tools by categories
// Eager loading tools by categories (MCP, workflow agent).
// These paths need full schemas, so generate with includeSchemas: true.
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const descriptors = await this.getCatalog(context);
const categorySet = categories ? new Set(categories) : undefined;
let filteredDescriptors: ToolDescriptor[];
const results = await Promise.all(
this.providers
.filter(
(provider) => !categorySet || categorySet.has(provider.category),
)
.map(async (provider) => {
if (await provider.isAvailable(context)) {
return provider.generateDescriptors(context, {
includeSchemas: true,
});
}
if (categories) {
const categorySet = new Set(categories);
return [];
}),
);
filteredDescriptors = descriptors.filter((descriptor) =>
categorySet.has(descriptor.category),
);
} else {
filteredDescriptors = [...descriptors];
}
const descriptors = results.flat() as ToolDescriptor[];
let filteredDescriptors = descriptors;
// Apply excludeTools filter
if (excludeTools?.length) {
const excludeSet = new Set(excludeTools);
@ -387,7 +325,6 @@ export class ToolRegistryService {
wrapWithErrorContext,
});
// Handle NativeModelToolProvider separately (SDK-opaque tools)
if (categories?.includes(ToolCategory.NATIVE_MODEL)) {
if (await this.nativeModelToolProvider.isAvailable(context)) {
const nativeTools = await (
@ -405,15 +342,6 @@ export class ToolRegistryService {
return toolSet;
}
private async buildCacheKey(context: ToolProviderContext): Promise<string> {
const metadataVersion =
(await this.workspaceCacheStorageService.getMetadataVersion(
context.workspaceId,
)) ?? 0;
return `${context.workspaceId}:v${metadataVersion}:${context.roleId}:${context.userId ?? 'system'}`;
}
private buildContext(
workspaceId: string,
roleId: string,
@ -434,66 +362,4 @@ export class ToolRegistryService {
onCodeExecutionUpdate,
};
}
private wrapWithErrorHandler(
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): (args: Record<string, unknown>) => Promise<unknown> {
return async (args: Record<string, unknown>) => {
try {
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
};
}
private generateErrorSuggestion(
_toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}

View file

@ -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,

View file

@ -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';

View file

@ -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';

View file

@ -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(', ')}`,
};
},
});

View file

@ -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;
};

View file

@ -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;
};

View file

@ -0,0 +1,61 @@
export const generateErrorSuggestion = (
_toolName: string,
errorMessage: string,
): string => {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
};
export const wrapWithErrorHandler = (
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): ((args: Record<string, unknown>) => Promise<unknown>) => {
return async (args: Record<string, unknown>) => {
try {
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: generateErrorSuggestion(toolName, errorMessage),
},
};
}
};
};

View file

@ -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 },
};
});
};

View file

@ -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<AgentChatThreadEntity>,
@ -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<ExtendedUIMessage>({
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;
}
}
}

View file

@ -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<string, ToolDescriptor[]>();
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
for (const tool of toolCatalog) {
const category = tool.category;

View file

@ -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<void> {
return this.cacheStorageService.set<unknown[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`,
descriptors,
ttl,
);
}
getToolCatalog(cacheKey: string): Promise<unknown[] | undefined> {
return this.cacheStorageService.get<unknown[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`,
);
}
async flush(workspaceId: string, metadataVersion?: number): Promise<void> {
await this.flushVersionedMetadata(workspaceId, metadataVersion);

View file

@ -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<GlobalWorkspaceOrmManager>;
let logicFunctionMetadataService: jest.Mocked<LogicFunctionMetadataService>;
let codeStepBuildService: jest.Mocked<CodeStepBuildService>;
let agentRepository: jest.Mocked<any>;
let agentService: jest.Mocked<AgentService>;
let roleTargetRepository: jest.Mocked<any>;
let roleRepository: jest.Mocked<any>;
let objectMetadataRepository: jest.Mocked<any>;
let workflowCommonWorkspaceService: jest.Mocked<WorkflowCommonWorkspaceService>;
let aiAgentRoleService: jest.Mocked<AiAgentRoleService>;
@ -96,21 +94,17 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => {
destroyOne: jest.fn(),
} as unknown as jest.Mocked<LogicFunctionMetadataService>;
agentRepository = {
findOne: jest.fn(),
delete: jest.fn(),
};
agentService = {
deleteManyAgents: jest.fn().mockResolvedValue([]),
findOneAgentById: jest.fn(),
createOneAgent: jest.fn(),
} as unknown as jest.Mocked<AgentService>;
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(

View file

@ -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<AgentEntity>,
private readonly agentService: AgentService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@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(),

View file

@ -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,
]),