Feat: native model capabilities (#14787)

This commit is contained in:
Abdul Rahman
2025-10-01 22:07:21 +05:30
committed by GitHub
parent d715533a90
commit 20403664e3
29 changed files with 523 additions and 59 deletions
@@ -57,6 +57,7 @@ export type Agent = {
id: Scalars['UUID'];
isCustom: Scalars['Boolean'];
label: Scalars['String'];
modelConfiguration?: Maybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
@@ -485,6 +486,7 @@ export type ClientAiModelConfig = {
inputCostPer1kTokensInCredits: Scalars['Float'];
label: Scalars['String'];
modelId: Scalars['String'];
nativeCapabilities?: Maybe<NativeModelCapabilities>;
outputCostPer1kTokensInCredits: Scalars['Float'];
provider: ModelProvider;
};
@@ -697,6 +699,7 @@ export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
label: Scalars['String'];
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name?: InputMaybe<Scalars['String']>;
prompt: Scalars['String'];
@@ -2629,6 +2632,12 @@ export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs
otp: Scalars['String'];
};
export type NativeModelCapabilities = {
__typename?: 'NativeModelCapabilities';
twitterSearch?: Maybe<Scalars['Boolean']>;
webSearch?: Maybe<Scalars['Boolean']>;
};
export type NumberChartConfiguration = {
__typename?: 'NumberChartConfiguration';
aggregateFieldMetadataId: Scalars['UUID'];
@@ -3802,6 +3811,7 @@ export type UpdateAgentInput = {
icon?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
@@ -4459,7 +4469,7 @@ export type WorkspaceUrlsAndId = {
workspaceUrls: WorkspaceUrls;
};
export type AgentFieldsFragment = { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string };
export type AgentFieldsFragment = { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string };
export type AssignRoleToAgentMutationVariables = Exact<{
agentId: Scalars['UUID'];
@@ -4488,14 +4498,14 @@ export type CreateOneAgentMutationVariables = Exact<{
}>;
export type CreateOneAgentMutation = { __typename?: 'Mutation', createOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string } };
export type CreateOneAgentMutation = { __typename?: 'Mutation', createOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string } };
export type DeleteOneAgentMutationVariables = Exact<{
input: AgentIdInput;
}>;
export type DeleteOneAgentMutation = { __typename?: 'Mutation', deleteOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string } };
export type DeleteOneAgentMutation = { __typename?: 'Mutation', deleteOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string } };
export type RemoveAgentHandoffMutationVariables = Exact<{
input: RemoveAgentHandoffInput;
@@ -4516,7 +4526,7 @@ export type UpdateOneAgentMutationVariables = Exact<{
}>;
export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string } };
export type UpdateOneAgentMutation = { __typename?: 'Mutation', updateOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string } };
export type FindAgentHandoffTargetsQueryVariables = Exact<{
input: AgentIdInput;
@@ -4535,14 +4545,14 @@ export type FindAgentHandoffsQuery = { __typename?: 'Query', findAgentHandoffs:
export type FindManyAgentsQueryVariables = Exact<{ [key: string]: never; }>;
export type FindManyAgentsQuery = { __typename?: 'Query', findManyAgents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string }> };
export type FindManyAgentsQuery = { __typename?: 'Query', findManyAgents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string }> };
export type FindOneAgentQueryVariables = Exact<{
id: Scalars['UUID'];
}>;
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string } };
export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string } };
export type GetAgentChatMessagesQueryVariables = Exact<{
threadId: Scalars['UUID'];
@@ -5296,7 +5306,7 @@ export type UpsertPermissionFlagsMutation = { __typename?: 'Mutation', upsertPer
export type GetRolesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, createdAt: string, updatedAt: string }>, apiKeys: Array<{ __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }>, permissionFlags?: Array<{ __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, fieldPermissions?: Array<{ __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }> | null }> };
export type GetRolesQuery = { __typename?: 'Query', getRoles: Array<{ __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean, workspaceMembers: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, createdAt: string, updatedAt: string }>, apiKeys: Array<{ __typename?: 'ApiKeyForRole', id: string, name: string, expiresAt: string, revokedAt?: string | null }>, permissionFlags?: Array<{ __typename?: 'PermissionFlag', id: string, flag: PermissionFlagType, roleId: string }> | null, objectPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null }> | null, fieldPermissions?: Array<{ __typename?: 'FieldPermission', objectMetadataId: string, fieldMetadataId: string, canReadFieldValue?: boolean | null, canUpdateFieldValue?: boolean | null, id: string, roleId: string }> | null }> };
export type CreateApprovedAccessDomainMutationVariables = Exact<{
input: CreateApprovedAccessDomainInput;
@@ -5903,6 +5913,7 @@ export const AgentFieldsFragmentDoc = gql`
responseFormat
roleId
isCustom
modelConfiguration
createdAt
updatedAt
}
@@ -57,6 +57,7 @@ export type Agent = {
id: Scalars['UUID'];
isCustom: Scalars['Boolean'];
label: Scalars['String'];
modelConfiguration?: Maybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
@@ -485,6 +486,7 @@ export type ClientAiModelConfig = {
inputCostPer1kTokensInCredits: Scalars['Float'];
label: Scalars['String'];
modelId: Scalars['String'];
nativeCapabilities?: Maybe<NativeModelCapabilities>;
outputCostPer1kTokensInCredits: Scalars['Float'];
provider: ModelProvider;
};
@@ -697,6 +699,7 @@ export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
label: Scalars['String'];
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name?: InputMaybe<Scalars['String']>;
prompt: Scalars['String'];
@@ -2486,6 +2489,12 @@ export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs
otp: Scalars['String'];
};
export type NativeModelCapabilities = {
__typename?: 'NativeModelCapabilities';
twitterSearch?: Maybe<Scalars['Boolean']>;
webSearch?: Maybe<Scalars['Boolean']>;
};
export type NumberChartConfiguration = {
__typename?: 'NumberChartConfiguration';
aggregateFieldMetadataId: Scalars['UUID'];
@@ -3580,6 +3589,7 @@ export type UpdateAgentInput = {
icon?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
@@ -3,11 +3,14 @@ import { IconDotsVertical } from 'twenty-ui/display';
import { LazyMarkdownRenderer } from '@/ai/components/LazyMarkdownRenderer';
import { ToolStepRenderer } from '@/ai/components/ToolStepRenderer';
import { type ToolInput } from '@/ai/types/ToolInput';
import { type ToolOutput } from '@/ai/types/ToolOutput';
import { keyframes, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import type { ToolUIPart, UIDataTypes, UIMessagePart, UITools } from 'ai';
import {
isToolUIPart,
type UIDataTypes,
type UIMessagePart,
type UITools,
} from 'ai';
const StyledStepsContainer = styled.div`
display: flex;
@@ -64,29 +67,29 @@ export const AIChatAssistantMessageRenderer = ({
isLastMessageStreaming: boolean;
}) => {
const renderStep = (
step: UIMessagePart<UIDataTypes, UITools>,
part: UIMessagePart<UIDataTypes, UITools>,
index: number,
) => {
switch (step.type) {
switch (part.type) {
case 'reasoning':
return (
<ReasoningSummaryDisplay
key={index}
content={step.text}
isThinking={step.state === 'streaming'}
content={part.text}
isThinking={part.state === 'streaming'}
/>
);
case 'text':
return <LazyMarkdownRenderer key={index} text={step.text} />;
return <LazyMarkdownRenderer key={index} text={part.text} />;
default:
{
if (step.type.includes('tool-')) {
const { output, input, type } = step as ToolUIPart;
if (isToolUIPart(part)) {
const { output, input, type } = part;
return (
<ToolStepRenderer
key={index}
input={input as ToolInput}
output={output as ToolOutput}
input={input}
output={output}
toolName={type.split('-')[1]}
/>
);
@@ -7,8 +7,9 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { type ToolInput } from '@/ai/types/ToolInput';
import { type ToolOutput } from '@/ai/types/ToolOutput';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
@@ -73,7 +74,7 @@ export const ToolStepRenderer = ({
toolName,
}: {
input: ToolInput;
output: ToolOutput;
output: ToolUIPart['output'];
toolName: string;
}) => {
const theme = useTheme();
@@ -86,7 +87,9 @@ export const ToolStepRenderer = ({
<StyledContainer>
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>{input?.loadingMessage}</StyledDisplayMessage>
<StyledDisplayMessage>
{getToolDisplayMessage(input, toolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
</StyledContainer>
@@ -94,9 +97,12 @@ export const ToolStepRenderer = ({
}
const displayMessage =
output && typeof output === 'object' && 'message' in output
? (output as { message: string }).message
: undefined;
output &&
typeof output === 'object' &&
'message' in output &&
typeof output.message === 'string'
? output.message
: getToolDisplayMessage(input, toolName, true);
const result =
output && typeof output === 'object' && 'result' in output
@@ -12,6 +12,7 @@ export const AGENT_FRAGMENT = gql`
responseFormat
roleId
isCustom
modelConfiguration
createdAt
updatedAt
}
@@ -1,4 +1,3 @@
export type ToolInput = {
loadingMessage: string;
input: Record<string, unknown>;
};
import { type ToolUIPart } from 'ai';
export type ToolInput = ToolUIPart['input'];
@@ -1 +0,0 @@
export type ToolOutput = Record<string, unknown>;
@@ -0,0 +1,56 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
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;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
if (toolName === 'web_search') {
const query = extractSearchQuery(input);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
return extractLoadingMessage(input);
};
@@ -71,9 +71,10 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
description: agent.description,
icon: agent.icon || 'IconRobot',
modelId: agent.modelId,
role: agent.roleId ?? undefined,
role: agent.roleId,
prompt: agent.prompt,
isCustom: agent.isCustom,
modelConfiguration: agent.modelConfiguration || {},
});
} else {
enqueueErrorSnackBar({
@@ -118,8 +119,9 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
description: formValues.description,
icon: formValues.icon,
modelId: formValues.modelId,
roleId: formValues.role || undefined,
roleId: formValues.role,
prompt: formValues.prompt,
modelConfiguration: formValues.modelConfiguration,
};
await createAgent({
@@ -142,8 +144,9 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
description: formValues.description,
icon: formValues.icon,
modelId: formValues.modelId,
roleId: formValues.role || undefined,
roleId: formValues.role,
prompt: formValues.prompt,
modelConfiguration: formValues.modelConfiguration,
},
},
});
@@ -0,0 +1,142 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { aiModelsState } from '@/client-config/states/aiModelsState';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconBrandX, IconWorld } from 'twenty-ui/display';
import { Checkbox } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(1)};
border-radius: ${({ theme }) => theme.border.radius.sm};
transition: background-color
${({ theme }) => theme.animation.duration.normal}s ease;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
&:hover {
background-color: ${({ theme, disabled }) =>
disabled ? 'transparent' : theme.background.transparent.light};
}
`;
const StyledCheckboxLabel = styled.div`
display: flex;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
`;
type ModelConfiguration = {
webSearch?: {
enabled: boolean;
configuration?: Record<string, unknown>;
};
twitterSearch?: {
enabled: boolean;
configuration?: Record<string, unknown>;
};
};
type SettingsAgentModelCapabilitiesProps = {
selectedModelId: string;
modelConfiguration: ModelConfiguration;
onConfigurationChange: (configuration: ModelConfiguration) => void;
disabled?: boolean;
};
export const SettingsAgentModelCapabilities = ({
selectedModelId,
modelConfiguration,
onConfigurationChange,
disabled = false,
}: SettingsAgentModelCapabilitiesProps) => {
const theme = useTheme();
const aiModels = useRecoilValue(aiModelsState);
const selectedModel = aiModels.find((m) => m.modelId === selectedModelId);
const nativeCapabilities = selectedModel?.nativeCapabilities;
if (!isDefined(nativeCapabilities)) {
return null;
}
if (!nativeCapabilities.webSearch && !nativeCapabilities.twitterSearch) {
return null;
}
const handleCapabilityToggle = (
capability: 'webSearch' | 'twitterSearch',
enabled: boolean,
) => {
if (disabled) {
return;
}
onConfigurationChange({
...modelConfiguration,
[capability]: {
enabled,
configuration: modelConfiguration[capability]?.configuration || {},
},
});
};
const capabilities = [
...(nativeCapabilities.webSearch
? [
{
key: 'webSearch' as const,
label: t`Web Search`,
Icon: IconWorld,
enabled: modelConfiguration.webSearch?.enabled || false,
},
]
: []),
...(nativeCapabilities.twitterSearch
? [
{
key: 'twitterSearch' as const,
label: t`Twitter/X Search`,
Icon: IconBrandX,
enabled: modelConfiguration.twitterSearch?.enabled || false,
},
]
: []),
];
return (
<Section>
<InputLabel>{t`Enable model-specific features`}</InputLabel>
<div>
{capabilities.map((capability) => (
<StyledCheckboxContainer
disabled={disabled}
key={capability.key}
onClick={() =>
handleCapabilityToggle(capability.key, !capability.enabled)
}
>
<StyledCheckboxLabel>
<capability.Icon size={theme.icon.size.sm} />
<span>{capability.label}</span>
</StyledCheckboxLabel>
<Checkbox
checked={capability.enabled}
onChange={(event) => {
event.stopPropagation();
handleCapabilityToggle(capability.key, event.target.checked);
}}
disabled={disabled}
/>
</StyledCheckboxContainer>
))}
</div>
</Section>
);
};
@@ -9,6 +9,7 @@ import { TextArea } from '@/ui/input/components/TextArea';
import { isDefined } from 'twenty-shared/utils';
import { useGetRolesQuery } from '~/generated-metadata/graphql';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
import { SettingsAgentModelCapabilities } from '../../components/SettingsAgentModelCapabilities';
import { type SettingsAIAgentFormValues } from '../../hooks/useSettingsAgentFormState';
const StyledFormContainer = styled.div`
@@ -52,13 +53,18 @@ export const SettingsAIAgentForm = ({
const modelOptions = useAiModelOptions();
const { data: rolesData } = useGetRolesQuery();
const rolesOptions =
rolesData?.getRoles
const rolesOptions = [
{
label: t`None`,
value: null,
},
...(rolesData?.getRoles
?.filter((role) => role.canBeAssignedToAgents)
.map((role) => ({
label: role.label,
value: role.id,
})) || [];
})) || []),
];
const noModelsAvailable = modelOptions.length === 0;
@@ -122,6 +128,19 @@ export const SettingsAIAgentForm = ({
)}
</StyledFormContainer>
{formValues.modelId && (
<StyledFormContainer>
<SettingsAgentModelCapabilities
selectedModelId={formValues.modelId}
modelConfiguration={formValues.modelConfiguration || {}}
onConfigurationChange={(configuration) =>
onFieldChange('modelConfiguration', configuration)
}
disabled={disabled}
/>
</StyledFormContainer>
)}
<StyledFormContainer>
<Select
dropdownId="ai-role-select"
@@ -129,10 +148,6 @@ export const SettingsAIAgentForm = ({
value={formValues.role || ''}
onChange={(value) => onFieldChange('role', value)}
options={rolesOptions}
emptyOption={{
label: t`Select a role`,
value: '',
}}
disabled={disabled}
/>
</StyledFormContainer>
@@ -14,9 +14,10 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
description: '',
icon: 'IconRobot',
modelId: mode === 'edit' ? '' : 'auto',
role: '',
role: null,
prompt: '',
isCustom: true,
modelConfiguration: {},
});
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -50,6 +51,7 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
role: '',
prompt: '',
isCustom: true,
modelConfiguration: {},
});
}
};
@@ -7,9 +7,25 @@ export const settingsAIAgentFormSchema = z.object({
description: z.string().nullish(),
icon: z.string().optional(),
modelId: z.union([z.string().min(1, 'Model is required'), z.literal('auto')]),
role: z.string().optional(),
role: z.string().nullish(),
prompt: zodNonEmptyString,
isCustom: z.boolean().default(true),
modelConfiguration: z
.object({
webSearch: z
.object({
enabled: z.boolean(),
configuration: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
twitterSearch: z
.object({
enabled: z.boolean(),
configuration: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
})
.optional(),
});
export type SettingsAIAgentFormValues = z.infer<
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddNativeCapabilitesToAgent1759200603485
implements MigrationInterface
{
name = 'AddNativeCapabilitesToAgent1759200603485';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ADD "modelConfiguration" jsonb`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" DROP COLUMN "modelConfiguration"`,
);
}
}
@@ -26,6 +26,10 @@ export interface AIModelConfig {
inputCostPer1kTokensInCents: number;
outputCostPer1kTokensInCents: number;
doesSupportThinking?: boolean;
nativeCapabilities?: {
webSearch?: boolean;
twitterSearch?: boolean;
};
}
export const AI_MODELS: AIModelConfig[] = [
@@ -35,6 +39,9 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.25,
outputCostPer1kTokensInCents: 1.0,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'gpt-4o-mini',
@@ -42,6 +49,9 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.015,
outputCostPer1kTokensInCents: 0.06,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'gpt-4-turbo',
@@ -49,6 +59,9 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 1.0,
outputCostPer1kTokensInCents: 3.0,
nativeCapabilities: {
webSearch: false,
},
},
{
modelId: 'claude-opus-4-20250514',
@@ -57,6 +70,9 @@ export const AI_MODELS: AIModelConfig[] = [
inputCostPer1kTokensInCents: 1.5,
outputCostPer1kTokensInCents: 7.5,
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'claude-sonnet-4-20250514',
@@ -65,6 +81,9 @@ export const AI_MODELS: AIModelConfig[] = [
inputCostPer1kTokensInCents: 0.3,
outputCostPer1kTokensInCents: 1.5,
doesSupportThinking: true,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'claude-3-5-haiku-20241022',
@@ -72,7 +91,10 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.08,
outputCostPer1kTokensInCents: 0.4,
doesSupportThinking: true,
doesSupportThinking: false,
nativeCapabilities: {
webSearch: true,
},
},
{
modelId: 'grok-3',
@@ -80,6 +102,10 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.XAI,
inputCostPer1kTokensInCents: 0.3,
outputCostPer1kTokensInCents: 1.5,
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
},
{
modelId: 'grok-3-mini',
@@ -87,6 +113,10 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.XAI,
inputCostPer1kTokensInCents: 0.03,
outputCostPer1kTokensInCents: 0.05,
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
},
{
modelId: 'grok-4',
@@ -94,5 +124,9 @@ export const AI_MODELS: AIModelConfig[] = [
provider: ModelProvider.XAI,
inputCostPer1kTokensInCents: 0.5,
outputCostPer1kTokensInCents: 2.5,
nativeCapabilities: {
webSearch: true,
twitterSearch: true,
},
},
];
@@ -19,6 +19,15 @@ registerEnumType(ModelProvider, {
name: 'ModelProvider',
});
@ObjectType()
class NativeModelCapabilities {
@Field(() => Boolean, { nullable: true })
webSearch?: boolean;
@Field(() => Boolean, { nullable: true })
twitterSearch?: boolean;
}
@ObjectType()
export class ClientAIModelConfig {
@Field(() => String)
@@ -35,6 +44,9 @@ export class ClientAIModelConfig {
@Field(() => Number)
outputCostPer1kTokensInCredits: number;
@Field(() => NativeModelCapabilities, { nullable: true })
nativeCapabilities?: NativeModelCapabilities;
}
@ObjectType()
@@ -46,6 +46,7 @@ export class ClientConfigService {
modelId: registeredModel.modelId,
label: builtInModel?.label || registeredModel.modelId,
provider: registeredModel.provider,
nativeCapabilities: builtInModel?.nativeCapabilities,
inputCostPer1kTokensInCredits: builtInModel
? convertCentsToBillingCredits(
builtInModel.inputCostPer1kTokensInCents,
@@ -19,7 +19,6 @@ import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
@@ -30,6 +29,7 @@ import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modu
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@@ -51,11 +51,10 @@ export class AgentExecutionService {
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
) {}
async prepareAIRequestConfig({
@@ -78,6 +77,7 @@ export class AgentExecutionService {
await this.aiModelRegistryService.resolveModelForAgent(agent);
let tools: ToolSet = {};
let providerOptions;
if (agent) {
const baseTools =
@@ -91,8 +91,18 @@ export class AgentExecutionService {
agent.id,
agent.workspaceId,
);
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent,
);
tools = { ...baseTools, ...handoffTools };
tools = { ...baseTools, ...handoffTools, ...nativeModelTools };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
agent,
);
}
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
@@ -103,16 +113,7 @@ export class AgentExecutionService {
model: registeredModel.model,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
...(registeredModel.doesSupportThinking && {
providerOptions: {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: AGENT_CONFIG.REASONING_BUDGET_TOKENS,
},
},
},
}),
providerOptions,
};
} catch (error) {
this.logger.error(
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import { ToolSet } from 'ai';
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AgentEntity } from './agent.entity';
@Injectable()
export class AgentModelConfigService {
constructor() {}
getProviderOptions(
model: RegisteredAIModel,
agent: AgentEntity,
): ProviderOptions {
switch (model.provider) {
case ModelProvider.XAI:
return this.getXaiProviderOptions(agent);
case ModelProvider.ANTHROPIC:
return this.getAnthropicProviderOptions(model);
default:
return {};
}
}
getNativeModelTools(model: RegisteredAIModel, agent: AgentEntity): ToolSet {
const tools: ToolSet = {};
if (!agent.modelConfiguration) {
return tools;
}
switch (model.provider) {
case ModelProvider.ANTHROPIC:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = anthropic.tools.webSearch_20250305();
}
break;
case ModelProvider.OPENAI:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = openai.tools.webSearch();
}
break;
}
return tools;
}
private getXaiProviderOptions(agent: AgentEntity): ProviderOptions {
if (
!agent.modelConfiguration ||
(!agent.modelConfiguration.webSearch?.enabled &&
!agent.modelConfiguration.twitterSearch?.enabled)
) {
return {};
}
const sources: Array<{ type: string }> = [];
if (agent.modelConfiguration.webSearch?.enabled) {
sources.push({ type: 'web' });
}
if (agent.modelConfiguration.twitterSearch?.enabled) {
sources.push({ type: 'x' });
}
return {
xai: {
searchParameters: {
mode: 'auto',
...(sources.length > 0 && { sources }),
},
},
};
}
private getAnthropicProviderOptions(
model: RegisteredAIModel,
): ProviderOptions {
if (!model.doesSupportThinking) {
return {};
}
return {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: AGENT_CONFIG.REASONING_BUDGET_TOKENS,
},
},
};
}
}
@@ -16,6 +16,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
import { AgentHandoffEntity } from './agent-handoff.entity';
@@ -93,4 +94,7 @@ export class AgentEntity {
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@Column({ nullable: true, type: 'jsonb' })
modelConfiguration: ModelConfiguration;
}
@@ -31,6 +31,7 @@ import { AgentHandoffExecutorService } from './agent-handoff-executor.service';
import { AgentHandoffToolService } from './agent-handoff-tool.service';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
@@ -72,6 +73,7 @@ import { AgentService } from './agent.service';
AgentChatResolver,
AgentService,
AgentExecutionService,
AgentModelConfigService,
AgentToolGeneratorService,
AgentHandoffToolService,
AgentChatService,
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
@@ -133,7 +132,7 @@ export class AgentService {
name: updatedName,
});
if (!isDefined(input.roleId)) {
if (!('roleId' in input)) {
return updatedAgent;
}
@@ -11,6 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
@ObjectType('Agent')
export class AgentDTO {
@@ -66,4 +67,7 @@ export class AgentDTO {
@IsDateString()
@Field()
updatedAt: Date;
@Field(() => GraphQLJSON, { nullable: true })
modelConfiguration: ModelConfiguration;
}
@@ -11,6 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
@InputType()
export class CreateAgentInput {
@@ -54,6 +55,11 @@ export class CreateAgentInput {
@Field(() => GraphQLJSON, { nullable: true })
responseFormat?: object;
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
modelConfiguration?: ModelConfiguration;
@HideField()
standardId?: string;
@@ -11,6 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
@InputType()
export class UpdateAgentInput {
@@ -58,4 +59,9 @@ export class UpdateAgentInput {
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
responseFormat?: object;
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
modelConfiguration?: ModelConfiguration;
}
@@ -0,0 +1,10 @@
export type ModelConfiguration = {
webSearch?: {
enabled: boolean;
configuration: object;
};
twitterSearch?: {
enabled: boolean;
configuration: object;
};
};
@@ -18,5 +18,6 @@ export const transformAgentEntityToFlatAgent = (
isCustom: agentEntity.isCustom,
universalIdentifier: agentEntity.standardId || agentEntity.id,
applicationId: agentEntity.applicationId,
modelConfiguration: agentEntity.modelConfiguration,
};
};
@@ -47,4 +47,5 @@ Be helpful, thorough, and always prioritize user understanding and workflow effe
responseFormat: {},
isCustom: false,
standardRoleId: WORKFLOW_MANAGER_ROLE.standardId,
modelConfiguration: {},
};
@@ -191,6 +191,7 @@ export const createAgentToolTestModule =
chatThreads: [],
incomingHandoffs: [],
outgoingHandoffs: [],
modelConfiguration: {},
};
const testRole: RoleEntity = {