feat: add configurable response format for AI agents (text/JSON) (#15953)

## Summary
This PR adds configurable response format support for AI agents,
allowing them to return either plain text or structured JSON data based
on a defined schema.

## Key Features

### 1. Agent Response Format Configuration
- Added `AgentResponseFormat` type supporting:
  - `text`: Returns plain text responses (default)
  - `json`: Returns structured JSON based on defined schema
- New `AgentResponseSchema` type moved to `twenty-shared/ai` for sharing
between frontend/backend

### 2. Settings UI
- New `SettingsAgentResponseFormat` component for configuring response
format
- Visual schema builder for defining JSON output structure
- Real-time validation and preview
- Integrated into agent settings tab

### 3. Workflow Integration
- AI Agent workflow action automatically uses agent's configured
response format
- Output schema dynamically generated from agent's response format
- Workflow variable picker shows structured fields for JSON responses
- Backward compatible with existing text-only agents

### 4. Backend Implementation
- Added `convertAgentSchemaToZod` utility to validate JSON responses
- Agent executor service handles both text and JSON generation
- Automatic agent creation/cloning when adding AI agent steps to
workflows
- Unique agent naming with conflict resolution

### 5. Database Migration
- Migration `1763622159656-update-agent-response-format.ts` 
- Sets default `responseFormat` to `{"type":"text"}` for existing agents
- Updated all standard agents with proper response format

## Changes by Module

### Frontend (`twenty-front`)
- 🆕 `AgentResponseFormat` type
- 🆕 `SettingsAgentResponseFormat` component
- ✏️ Updated `WorkflowEditActionAiAgent` to support response format
configuration
- 🗑️ Removed deprecated `useAiAgentOutputSchema` hook and
`AiAgentOutputSchema` type

### Backend (`twenty-server`)
- 🆕 `AgentResponseFormat` type in agent entity
- 🆕 `convertAgentSchemaToZod` utility for schema validation
- ✏️ Updated `AiAgentExecutorService` to handle both text and JSON
generation
- ✏️ Updated `WorkflowSchemaWorkspaceService` to generate output schema
from agent config
- ✏️ Enhanced `WorkflowVersionStepOperationsWorkspaceService` with agent
creation/cloning
- 🆕 Agent naming constants for conflict resolution

### Shared (`twenty-shared`)
- 🆕 `AgentResponseSchema` type
- 🆕 `ModelConfiguration` type moved to shared package
- Updated exports in `ai/index.ts`

## Code Quality
- Removed useless comments following code style guidelines
- All linter checks passed
- Type-safe implementation with proper TypeScript types

## Testing
-  Database migration tested
-  Agent creation/cloning in workflows verified
-  Response format switching (text ↔ JSON) validated
-  Backward compatibility with existing agents confirmed

## Migration Notes
- Existing agents will have `responseFormat: {type: 'text'}` set
automatically
- No breaking changes - all existing functionality preserved
- Agents can be updated to use JSON format through settings UI
This commit is contained in:
Félix Malfait
2025-11-20 18:32:44 +01:00
committed by GitHub
parent 5476879f77
commit a281f2a773
49 changed files with 1207 additions and 641 deletions
@@ -4157,11 +4157,11 @@ export type UpdateAgentInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
label?: InputMaybe<Scalars['String']>;
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
modelId?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
prompt?: InputMaybe<Scalars['String']>;
responseFormat?: InputMaybe<Scalars['JSON']>;
roleId?: InputMaybe<Scalars['UUID']>;
};
@@ -4027,11 +4027,11 @@ export type UpdateAgentInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
id: Scalars['UUID'];
label: Scalars['String'];
label?: InputMaybe<Scalars['String']>;
modelConfiguration?: InputMaybe<Scalars['JSON']>;
modelId: Scalars['String'];
name: Scalars['String'];
prompt: Scalars['String'];
modelId?: InputMaybe<Scalars['String']>;
name?: InputMaybe<Scalars['String']>;
prompt?: InputMaybe<Scalars['String']>;
responseFormat?: InputMaybe<Scalars['JSON']>;
roleId?: InputMaybe<Scalars['UUID']>;
};
@@ -1,8 +1,6 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { type AgentResponseFieldType } from 'twenty-shared/ai';
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import {
IllustrationIconCalendarEvent,
IllustrationIconNumbers,
IllustrationIconText,
IllustrationIconToggle,
@@ -12,28 +10,23 @@ export interface OutputSchemaField {
id: string;
name: string;
description?: string;
type: InputSchemaPropertyType | undefined;
type: AgentResponseFieldType | undefined;
}
export const OUTPUT_FIELD_TYPE_OPTIONS = [
{
label: msg`Text`,
value: FieldMetadataType.TEXT,
value: 'string' as const,
Icon: IllustrationIconText,
},
{
label: msg`Number`,
value: FieldMetadataType.NUMBER,
value: 'number' as const,
Icon: IllustrationIconNumbers,
},
{
label: msg`Boolean`,
value: FieldMetadataType.BOOLEAN,
value: 'boolean' as const,
Icon: IllustrationIconToggle,
},
{
label: msg`Date`,
value: FieldMetadataType.DATE,
Icon: IllustrationIconCalendarEvent,
},
];
@@ -1,66 +0,0 @@
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { type AiAgentOutputSchema } from '@/workflow/workflow-variables/types/AiAgentOutputSchema';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { v4 } from 'uuid';
export const useAiAgentOutputSchema = (
outputSchema?: AiAgentOutputSchema,
onActionUpdate?: (action: WorkflowAiAgentAction) => void,
action?: WorkflowAiAgentAction,
readonly?: boolean,
) => {
const [outputFields, setOutputFields] = useState<OutputSchemaField[]>(
Object.entries(outputSchema || {}).map(([name, field]) => ({
id: v4(),
name,
type: field.type,
})),
);
const debouncedSave = useDebouncedCallback(
async (fields: OutputSchemaField[]) => {
if (readonly === true) {
return;
}
const newOutputSchema = fields.reduce<AiAgentOutputSchema>(
(schema, field) => {
if (isDefined(field.name)) {
schema[field.name] = {
isLeaf: true,
type: field.type,
value: null,
label: field.name,
};
}
return schema;
},
{},
);
if (isDefined(onActionUpdate) && isDefined(action)) {
onActionUpdate({
...action,
settings: {
...action.settings,
outputSchema: newOutputSchema,
},
});
}
},
500,
);
const handleOutputSchemaChange = async (fields: OutputSchemaField[]) => {
setOutputFields(fields);
await debouncedSave(fields);
};
return {
handleOutputSchemaChange,
outputFields,
};
};
@@ -0,0 +1,8 @@
import { type BaseOutputSchemaV2 } from 'twenty-shared/workflow';
export type AgentResponseFormat =
| { type: 'text' }
| {
type: 'json';
schema: BaseOutputSchemaV2;
};
@@ -1,19 +1,15 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { getFieldIcon } from '../getFieldIcon';
describe('getFieldIcon', () => {
describe('supported field types', () => {
it('should return IconAbc for TEXT field type', () => {
expect(getFieldIcon(FieldMetadataType.TEXT)).toBe('IconAbc');
it('should return IconAbc for string field type', () => {
expect(getFieldIcon('string')).toBe('IconAbc');
});
it('should return IconText for NUMBER field type', () => {
expect(getFieldIcon(FieldMetadataType.NUMBER)).toBe('IconText');
it('should return IconText for number field type', () => {
expect(getFieldIcon('number')).toBe('IconText');
});
it('should return IconCheckbox for BOOLEAN field type', () => {
expect(getFieldIcon(FieldMetadataType.BOOLEAN)).toBe('IconCheckbox');
});
it('should return IconCalendarEvent for DATE field type', () => {
expect(getFieldIcon(FieldMetadataType.DATE)).toBe('IconCalendarEvent');
it('should return IconCheckbox for boolean field type', () => {
expect(getFieldIcon('boolean')).toBe('IconCheckbox');
});
});
@@ -36,8 +32,8 @@ describe('getFieldIcon', () => {
describe('consistency', () => {
it('should return the same icon for the same field type', () => {
const result1 = getFieldIcon(FieldMetadataType.TEXT);
const result2 = getFieldIcon(FieldMetadataType.TEXT);
const result1 = getFieldIcon('string');
const result2 = getFieldIcon('string');
expect(result1).toBe(result2);
});
});
@@ -1,16 +1,13 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { FieldMetadataType } from 'twenty-shared/types';
import { type AgentResponseFieldType } from 'twenty-shared/ai';
export const getFieldIcon = (fieldType?: InputSchemaPropertyType): string => {
export const getFieldIcon = (fieldType?: AgentResponseFieldType): string => {
switch (fieldType) {
case FieldMetadataType.TEXT:
case 'string':
return 'IconAbc';
case FieldMetadataType.NUMBER:
case 'number':
return 'IconText';
case FieldMetadataType.BOOLEAN:
case 'boolean':
return 'IconCheckbox';
case FieldMetadataType.DATE:
return 'IconCalendarEvent';
default:
return 'IconQuestionMark';
}
@@ -1,28 +1,30 @@
import { useAiAgentOutputSchema } from '@/ai/hooks/useAiAgentOutputSchema';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { AI_AGENT_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/AiAgentAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { type AiAgentOutputSchema } from '@/workflow/workflow-variables/types/AiAgentOutputSchema';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { useFindManyAgentsQuery } from '~/generated-metadata/graphql';
import { useDebouncedCallback } from 'use-debounce';
import {
useFindOneAgentQuery,
useUpdateOneAgentMutation,
} from '~/generated-metadata/graphql';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import { WorkflowOutputSchemaBuilder } from './WorkflowOutputSchemaBuilder';
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.font.color.danger};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
type WorkflowEditActionAiAgentProps = {
action: WorkflowAiAgentAction;
@@ -45,62 +47,136 @@ export const WorkflowEditActionAiAgent = ({
defaultTitle: AI_AGENT_ACTION.defaultLabel,
});
const { handleOutputSchemaChange, outputFields } = useAiAgentOutputSchema(
action.settings.outputSchema as AiAgentOutputSchema,
actionOptions.readonly === true ? undefined : actionOptions.onActionUpdate,
action,
actionOptions.readonly,
);
const agentId = action.settings.input.agentId;
const { data: agentData, loading: agentLoading } = useFindOneAgentQuery({
variables: { id: agentId || '' },
skip: !agentId,
});
const [updateAgent] = useUpdateOneAgentMutation();
const aiModelOptions = useAiModelOptions();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const flow = useFlowOrThrow();
const { data: agentsData, loading: agentsLoading } = useFindManyAgentsQuery();
const agent = agentData?.findOneAgent;
const agentOptions = (agentsData?.findManyAgents || []).reduce<
SelectOption<string>[]
>(
(acc, agent) => {
acc.push({
label: agent.label,
value: agent.id,
Icon: agent.icon ? getIcon(agent.icon) : undefined,
const handleAgentPromptChange = useDebouncedCallback(
async (newPrompt: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
prompt: newPrompt,
},
},
refetchQueries: ['FindOneAgent'],
});
return acc;
},
[
{
label: t`No Agent`,
value: '',
},
],
500,
);
const noAgentsAvailable = agentOptions.length === 0;
const handleFieldChange = (field: 'agentId' | 'prompt', value: string) => {
if (actionOptions.readonly === true) {
const handleAgentModelChange = async (modelId: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
actionOptions.onActionUpdate?.({
...action,
settings: {
...action.settings,
await updateAgent({
variables: {
input: {
...action.settings.input,
[field]: value,
id: agent.id,
modelId,
},
},
refetchQueries: ['FindOneAgent'],
});
};
return agentsLoading ? (
const handleModelConfigurationChange = async (
configuration: ModelConfiguration,
) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
modelConfiguration: configuration,
},
},
refetchQueries: ['FindOneAgent'],
});
};
const updateAgentResponseFormat = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
responseFormat: format,
},
},
refetchQueries: ['FindOneAgent'],
});
await updateWorkflowVersionStep({
workflowVersionId: flow.workflowVersionId,
step: action,
});
};
const debouncedUpdateAgentResponseFormat = useDebouncedCallback(
updateAgentResponseFormat,
300,
);
const handleAgentResponseFormatChange = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (format.type !== agent?.responseFormat?.type) {
debouncedUpdateAgentResponseFormat.cancel();
void updateAgentResponseFormat(format);
} else {
void debouncedUpdateAgentResponseFormat(format);
}
};
return agentLoading ? (
<RightDrawerSkeletonLoader />
) : (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
onTitleChange={async (newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
// Also update agent label
if (isDefined(agent)) {
await updateAgent({
variables: {
input: {
id: agent.id,
label: newName,
},
},
refetchQueries: ['FindOneAgent'],
});
}
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
@@ -110,38 +186,43 @@ export const WorkflowEditActionAiAgent = ({
iconTooltip={AI_AGENT_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<div>
<Select
dropdownId="select-agent"
label={t`Select Agent`}
options={agentOptions}
value={action.settings.input.agentId || ''}
onChange={(value) => handleFieldChange('agentId', value)}
disabled={actionOptions.readonly || noAgentsAvailable}
/>
{noAgentsAvailable && (
<StyledErrorMessage>
{t`Please create agents in the AI settings to use in workflows.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
multiline
VariablePicker={WorkflowVariablePicker}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
defaultValue={action.settings.input.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
defaultValue={agent?.prompt || ''}
onChange={handleAgentPromptChange}
readonly={actionOptions.readonly}
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
{isDefined(agent) && (
<>
<Select
dropdownId="select-agent-model"
label={t`AI Model`}
options={aiModelOptions}
value={agent.modelId}
onChange={handleAgentModelChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={agent.modelId}
modelConfiguration={agent.modelConfiguration || {}}
onConfigurationChange={handleModelConfigurationChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentResponseFormat
responseFormat={
agent.responseFormat || { type: 'text', schema: {} }
}
onResponseFormatChange={handleAgentResponseFormatChange}
disabled={actionOptions.readonly}
/>
</>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -1,11 +1,11 @@
import { OUTPUT_FIELD_TYPE_OPTIONS } from '@/ai/constants/OutputFieldTypeOptions';
import { Select } from '@/ui/input/components/Select';
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { t } from '@lingui/core/macro';
import { type AgentResponseFieldType } from 'twenty-shared/ai';
type WorkflowOutputFieldTypeSelectorProps = {
value?: InputSchemaPropertyType;
onChange: (value: InputSchemaPropertyType) => void;
value?: AgentResponseFieldType;
onChange: (value: AgentResponseFieldType) => void;
disabled?: boolean;
dropdownId: string;
};
@@ -3,7 +3,6 @@ import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/c
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -114,7 +113,7 @@ export const WorkflowOutputSchemaBuilder = ({
id: v4(),
name: '',
description: '',
type: 'TEXT' as InputSchemaPropertyType,
type: 'string',
};
onChange([...fields, newField]);
};
@@ -1,17 +0,0 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
export type AiAgentLeaf = {
isLeaf: true;
type: InputSchemaPropertyType | undefined;
label: string;
value: any;
};
export type AiAgentNode = {
isLeaf: false;
type: 'object' | 'unknown';
label: string;
value: AiAgentOutputSchema;
};
export type AiAgentOutputSchema = Record<string, AiAgentLeaf | AiAgentNode>;
@@ -92,6 +92,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
prompt: agent.prompt,
isCustom: agent.isCustom,
modelConfiguration: agent.modelConfiguration || {},
responseFormat: agent.responseFormat || { type: 'text', schema: {} },
});
} else {
enqueueErrorSnackBar({
@@ -190,6 +191,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
roleId: formValues.role,
prompt: formValues.prompt,
modelConfiguration: formValues.modelConfiguration,
responseFormat: formValues.responseFormat,
};
await createAgent({
@@ -215,6 +217,7 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
roleId: formValues.role,
prompt: formValues.prompt,
modelConfiguration: formValues.modelConfiguration,
responseFormat: formValues.responseFormat,
},
},
});
@@ -3,6 +3,9 @@ import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { SortableTableHeader } from '@/ui/layout/table/components/SortableTableHeader';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
@@ -10,7 +13,15 @@ import { useSortedArray } from '@/ui/layout/table/hooks/useSortedArray';
import { useTheme } from '@emotion/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { H2Title, IconChevronRight, IconSearch } from 'twenty-ui/display';
import {
H2Title,
IconChevronRight,
IconFilter,
IconSearch,
IconSettingsAutomation,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle } from 'twenty-ui/navigation';
import { SETTINGS_AI_AGENT_TABLE_METADATA } from '~/pages/settings/ai/constants/SettingsAiAgentTableMetadata';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
@@ -22,11 +33,17 @@ import {
StyledAIAgentTableRow,
} from './SettingsAIAgentTableRow';
const StyledSearchInput = styled(SettingsTextInput)`
padding-bottom: ${({ theme }) => theme.spacing(2)};
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(3)};
`;
@@ -36,7 +53,7 @@ const StyledTableHeaderRow = styled(StyledAIAgentTableRow)`
`;
export const SettingsAIAgentsTable = ({
withSearchBar = false,
withSearchBar = true,
}: {
withSearchBar?: boolean;
}) => {
@@ -45,6 +62,7 @@ export const SettingsAIAgentsTable = ({
const { t } = useLingui();
const theme = useTheme();
const [searchTerm, setSearchTerm] = useState('');
const [showWorkflowAgents, setShowWorkflowAgents] = useState(false);
const sortedAgents = useSortedArray(
data?.findManyAgents || [],
@@ -52,11 +70,15 @@ export const SettingsAIAgentsTable = ({
);
const filteredAgents = sortedAgents.filter((agent) => {
const isWorkflowAgent = agent.name.includes('workflow-service-agent');
const matchesType = !isWorkflowAgent || showWorkflowAgents;
const searchNormalized = normalizeSearchText(searchTerm);
return (
const matchesSearch =
normalizeSearchText(agent.name).includes(searchNormalized) ||
normalizeSearchText(agent.label).includes(searchNormalized)
);
normalizeSearchText(agent.label).includes(searchNormalized);
return matchesType && matchesSearch;
});
return (
@@ -67,13 +89,44 @@ export const SettingsAIAgentsTable = ({
/>
{withSearchBar && (
<StyledSearchInput
instanceId="settings-ai-agents-search"
LeftIcon={IconSearch}
placeholder={t`Search an agent...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="settings-ai-agents-search"
LeftIcon={IconSearch}
placeholder={t`Search an agent...`}
value={searchTerm}
onChange={setSearchTerm}
/>
<Dropdown
dropdownId="settings-ai-agents-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<MenuItemToggle
LeftIcon={IconSettingsAutomation}
onToggleChange={() =>
setShowWorkflowAgents(!showWorkflowAgents)
}
toggled={showWorkflowAgents}
text={t`Workflow agents`}
toggleSize="small"
/>
</DropdownMenuItemsContainer>
</DropdownContent>
}
/>
</StyledSearchAndFilterContainer>
)}
<StyledTable>
@@ -0,0 +1,131 @@
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { Select } from '@/ui/input/components/Select';
import { WorkflowOutputSchemaBuilder } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowOutputSchemaBuilder';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import {
type AgentResponseFieldType,
type AgentResponseSchema,
} from 'twenty-shared/ai';
import { v4 } from 'uuid';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
`;
type SettingsAgentResponseFormatProps = {
responseFormat?: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
};
onResponseFormatChange: (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => void;
disabled?: boolean;
};
const schemaToFields = (schema: AgentResponseSchema): OutputSchemaField[] => {
if (!schema.properties) return [];
return Object.entries(schema.properties).map(([key, field]) => ({
id: v4(),
name: key,
description: field.description || '',
type: field.type,
}));
};
const fieldsToSchema = (fields: OutputSchemaField[]): AgentResponseSchema => {
const properties: Record<
string,
{ type: AgentResponseFieldType; description?: string }
> = {};
const required: string[] = [];
fields
.filter((field) => field.name.trim() && field.type)
.forEach((field) => {
properties[field.name] = {
type: field.type!,
description: field.description || field.name,
};
required.push(field.name);
});
return {
type: 'object' as const,
properties,
required,
additionalProperties: false as const,
};
};
export const SettingsAgentResponseFormat = ({
responseFormat,
onResponseFormatChange,
disabled,
}: SettingsAgentResponseFormatProps) => {
const formatType = responseFormat?.type || 'text';
const schema: AgentResponseSchema = responseFormat?.schema || {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
};
const [visualBuilderFields, setVisualBuilderFields] = useState<
OutputSchemaField[]
>(() => schemaToFields(schema));
const handleFormatTypeChange = (newType: 'text' | 'json') => {
if (newType === 'json') {
setVisualBuilderFields(schemaToFields(schema));
}
const emptySchema: AgentResponseSchema = {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
};
onResponseFormatChange({
type: newType,
schema: newType === 'text' ? emptySchema : schema,
});
};
const handleVisualBuilderChange = (updatedFields: OutputSchemaField[]) => {
setVisualBuilderFields(updatedFields);
onResponseFormatChange({
type: 'json',
schema: fieldsToSchema(updatedFields),
});
};
return (
<StyledContainer>
<Select
dropdownId="agent-response-format-type"
label={t`Response Format`}
value={formatType}
onChange={handleFormatTypeChange}
options={[
{ label: t`String`, value: 'text' as const },
{ label: t`JSON`, value: 'json' as const },
]}
disabled={disabled}
/>
{formatType === 'json' && (
<WorkflowOutputSchemaBuilder
fields={visualBuilderFields}
onChange={handleVisualBuilderChange}
readonly={disabled}
/>
)}
</StyledContainer>
);
};
@@ -13,6 +13,7 @@ import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { type Agent } from '~/generated/graphql';
import { SettingsAgentDeleteConfirmationModal } from '~/pages/settings/ai/components/SettingsAgentDeleteConfirmationModal';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
import { computeMetadataNameFromLabel } from '~/pages/settings/data-model/utils/computeMetadataNameFromLabel';
import { SettingsAgentModelCapabilities } from '../components/SettingsAgentModelCapabilities';
import { type SettingsAIAgentFormValues } from '../hooks/useSettingsAgentFormState';
@@ -97,7 +98,6 @@ export const SettingsAgentSettingsTab = ({
</StyledNameContainer>
</StyledIconNameRow>
</StyledFormContainer>
<StyledFormContainer>
<TextArea
textAreaId="agent-description-textarea"
@@ -108,7 +108,6 @@ export const SettingsAgentSettingsTab = ({
disabled={disabled}
/>
</StyledFormContainer>
<StyledFormContainer>
{noModelsAvailable ? (
<StyledErrorMessage>
@@ -125,7 +124,6 @@ export const SettingsAgentSettingsTab = ({
/>
)}
</StyledFormContainer>
{formValues.modelId && (
<StyledFormContainer>
<SettingsAgentModelCapabilities
@@ -138,7 +136,6 @@ export const SettingsAgentSettingsTab = ({
/>
</StyledFormContainer>
)}
<StyledFormContainer>
<TextArea
textAreaId="agent-prompt-textarea"
@@ -151,7 +148,15 @@ export const SettingsAgentSettingsTab = ({
disabled={disabled}
/>
</StyledFormContainer>
<StyledFormContainer>
<SettingsAgentResponseFormat
responseFormat={formValues.responseFormat}
onResponseFormatChange={(format) =>
onFieldChange('responseFormat', format)
}
disabled={disabled}
/>
</StyledFormContainer>
{!disabled && agent && formValues.isCustom && (
<Section>
<H2Title title={t`Danger zone`} description={t`Delete this agent`} />
@@ -18,6 +18,15 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
prompt: '',
isCustom: true,
modelConfiguration: {},
responseFormat: {
type: 'text',
schema: {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
},
},
});
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -52,6 +61,15 @@ export const useSettingsAgentFormState = (mode: 'create' | 'edit') => {
prompt: '',
isCustom: true,
modelConfiguration: {},
responseFormat: {
type: 'text',
schema: {
type: 'object' as const,
properties: {},
required: [],
additionalProperties: false as const,
},
},
});
}
};
@@ -1,3 +1,4 @@
import { type AgentResponseSchema } from 'twenty-shared/ai';
import { z } from 'zod';
import { zodNonEmptyString } from '~/types/ZodNonEmptyString';
@@ -26,6 +27,12 @@ export const settingsAIAgentFormSchema = z.object({
.optional(),
})
.optional(),
responseFormat: z
.object({
type: z.enum(['text', 'json']),
schema: z.custom<AgentResponseSchema>().optional(),
})
.optional(),
});
export type SettingsAIAgentFormValues = z.infer<
@@ -56,7 +56,7 @@ export const SettingsApplicationDetailContentTab = ({
title={t`Application agents`}
description={t`Agents created by application`}
/>
<SettingsAIAgentsTable withSearchBar={false} />
<SettingsAIAgentsTable />
</Section>
)}
{shouldDisplayObjects && (
@@ -0,0 +1,22 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class UpdateAgentResponseFormat1763622159656
implements MigrationInterface
{
name = 'UpdateAgentResponseFormat1763622159656';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`UPDATE "core"."agent" SET "responseFormat" = '{"type":"text"}' WHERE "responseFormat" IS NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "responseFormat" SET DEFAULT '{"type":"text"}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "responseFormat" DROP DEFAULT`,
);
}
}
@@ -21,6 +21,7 @@ export const handleWorkflowVersionStepException = (
case WorkflowVersionStepExceptionCode.NOT_FOUND:
throw new NotFoundError(exception);
case WorkflowVersionStepExceptionCode.CODE_STEP_FAILURE:
case WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE:
throw new InternalServerError(exception);
default: {
assertUnreachable(exception.code);
@@ -16,6 +16,7 @@ import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/inte
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentResponseFormat } from 'src/engine/metadata-modules/agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { AgentHandoffEntity } from './agent-handoff.entity';
@@ -54,8 +55,8 @@ export class AgentEntity
@Column({ nullable: false, type: 'varchar', default: 'auto' })
modelId: ModelId;
@Column({ nullable: true, type: 'jsonb' })
responseFormat: object;
@Column({ nullable: true, type: 'jsonb', default: { type: 'text' } })
responseFormat: AgentResponseFormat;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@@ -120,17 +120,20 @@ export class AgentService {
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
const agent = await this.findOneAgent(input.id, workspaceId);
let updatedName = input.name;
const updateData: Partial<AgentEntity> = {
...agent,
...Object.fromEntries(
Object.entries(input).filter(([_, value]) => value !== undefined),
),
};
if (input.label) {
updatedName = computeMetadataNameFromLabel(input.label);
if (input.label !== undefined) {
updateData.name = computeMetadataNameFromLabel(input.label);
} else if (input.name !== undefined) {
updateData.name = input.name;
}
const updatedAgent = await this.agentRepository.save({
...agent,
...input,
name: updatedName,
});
const updatedAgent = await this.agentRepository.save(updateData);
if (!('roleId' in input)) {
return updatedAgent;
@@ -22,12 +22,12 @@ export class UpdateAgentInput {
@IsString()
@IsOptional()
@Field()
@Field({ nullable: true })
name?: string;
@IsString()
@IsOptional()
@Field()
@Field({ nullable: true })
label?: string;
@IsString()
@@ -42,12 +42,12 @@ export class UpdateAgentInput {
@IsString()
@IsOptional()
@Field()
@Field({ nullable: true })
prompt?: string;
@IsString()
@IsOptional()
@Field(() => String)
@Field(() => String, { nullable: true })
modelId?: ModelId;
@IsUUID()
@@ -0,0 +1,10 @@
import { type AgentResponseSchema } from 'twenty-shared/ai';
export type AgentResponseFormatType = 'text' | 'json';
export type AgentResponseFormat =
| { type: 'text' }
| {
type: 'json';
schema: AgentResponseSchema;
};
@@ -1,10 +1 @@
export type ModelConfiguration = {
webSearch?: {
enabled: boolean;
configuration: object;
};
twitterSearch?: {
enabled: boolean;
configuration: object;
};
};
export type { ModelConfiguration } from 'twenty-shared/ai';
@@ -1,42 +0,0 @@
import { z } from 'zod';
import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
export const convertOutputSchemaToZod = (
schema: OutputSchema,
): z.ZodObject<Record<string, z.ZodTypeAny>> => {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [fieldName, field] of Object.entries(schema)) {
if (field.isLeaf) {
let fieldSchema: z.ZodTypeAny;
switch (field.type) {
case 'TEXT':
fieldSchema = z.string();
break;
case 'NUMBER':
fieldSchema = z.number();
break;
case 'BOOLEAN':
fieldSchema = z.boolean();
break;
case 'DATE':
fieldSchema = z.string().describe('Date-time string');
break;
default:
throw new Error(
`Unsupported field type for AI agent output: ${field.type}`,
);
}
if (field.description) {
fieldSchema = fieldSchema.describe(field.description);
}
shape[fieldName] = fieldSchema;
}
}
return z.object(shape);
};
@@ -15,9 +15,9 @@ import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-tel
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { DATA_MANIPULATOR_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/data-manipulator-agent';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { type ToolHints } from './types/tool-hints.interface';
@@ -198,10 +198,14 @@ export class AiRouterService {
private async getAvailableAgents(
workspaceId: string,
): Promise<AgentEntity[]> {
return this.agentRepository.find({
const agents = await this.agentRepository.find({
where: { workspaceId, deletedAt: undefined },
order: { createdAt: 'ASC' },
});
return agents.filter(
(agent) => !agent.name.includes('workflow-service-agent'),
);
}
private async getHelperAgent(workspaceId: string) {
@@ -63,7 +63,7 @@ Your capabilities include:
Be helpful, thorough, and always prioritize data integrity while executing user requests efficiently.`,
modelId: 'auto',
responseFormat: {},
responseFormat: { type: 'text' },
isCustom: false,
standardRoleId: DATA_MANIPULATOR_ROLE.standardId,
modelConfiguration: {},
@@ -52,7 +52,7 @@ When you find relevant articles:
Be friendly, patient, helpful, and always prioritize accuracy by relying on the official documentation.`,
modelId: 'auto',
responseFormat: {},
responseFormat: { type: 'text' },
isCustom: false,
modelConfiguration: {},
};
@@ -44,7 +44,7 @@ When modifying workflows:
Be helpful, thorough, and always prioritize user understanding and workflow effectiveness.`,
modelId: 'auto',
responseFormat: {},
responseFormat: { type: 'text' },
isCustom: false,
standardRoleId: WORKFLOW_MANAGER_ROLE.standardId,
modelConfiguration: {},
@@ -44,7 +44,7 @@ When modifying workflows:
Be helpful, thorough, and always prioritize user understanding and workflow effectiveness.`,
modelId: 'auto',
responseFormat: {},
responseFormat: { type: 'text' },
isCustom: false,
standardRoleId: WORKFLOW_MANAGER_ROLE.standardId,
modelConfiguration: {},
@@ -6,4 +6,5 @@ export enum WorkflowVersionStepExceptionCode {
INVALID_REQUEST = 'INVALID_REQUEST',
NOT_FOUND = 'NOT_FOUND',
CODE_STEP_FAILURE = 'CODE_STEP_FAILURE',
AI_AGENT_STEP_FAILURE = 'AI_AGENT_STEP_FAILURE',
}
@@ -1,11 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
@Module({
imports: [WorkflowCommonModule, FeatureFlagModule],
imports: [
WorkflowCommonModule,
FeatureFlagModule,
TypeOrmModule.forFeature([AgentEntity]),
],
providers: [WorkflowSchemaWorkspaceService],
exports: [WorkflowSchemaWorkspaceService],
})
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isString } from '@sniptt/guards';
import { isDefined, isValidVariable } from 'twenty-shared/utils';
@@ -11,9 +12,11 @@ import {
SingleRecordAvailability,
TRIGGER_STEP_ID,
} from 'twenty-shared/workflow';
import { Repository } from 'typeorm';
import { type DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { checkStringIsDatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/utils/check-string-is-database-event-action';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { DEFAULT_ITERATOR_CURRENT_ITEM } from 'src/modules/workflow/workflow-builder/workflow-schema/constants/default-iterator-current-item.const';
@@ -42,6 +45,8 @@ import {
export class WorkflowSchemaWorkspaceService {
constructor(
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
) {}
async computeStepOutputSchema({
@@ -122,6 +127,39 @@ export class WorkflowSchemaWorkspaceService {
},
};
}
case WorkflowActionType.AI_AGENT: {
const agentId = step.settings.input.agentId;
if (!isDefined(agentId) || agentId === '') {
return {};
}
const agent = await this.agentRepository.findOne({
where: { id: agentId, workspaceId },
});
if (
!isDefined(agent) ||
agent.responseFormat?.type !== 'json' ||
!isDefined(agent.responseFormat.schema)
) {
return {};
}
return Object.fromEntries(
Object.entries(agent.responseFormat.schema.properties).map(
([key, field]) => [
key,
{
isLeaf: true,
type: field.type,
label: field.description || key,
value: null,
},
],
),
) as OutputSchema;
}
case WorkflowActionType.CODE: // StepOutput schema is computed on serverlessFunction draft execution
default:
return {};
@@ -139,13 +177,12 @@ export class WorkflowSchemaWorkspaceService {
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code and HTTP request workflow actions.
// For code actions, OutputSchema is computed and updated when testing the serverless function.
// For HTTP requests and AI agent, OutputSchema is determined by the example response input
// For HTTP requests, OutputSchema is determined by the example response input
// AI agent OutputSchema is enriched from agent's responseFormat
if (
[
WorkflowActionType.CODE,
WorkflowActionType.HTTP_REQUEST,
WorkflowActionType.AI_AGENT,
].includes(step.type)
[WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes(
step.type,
)
) {
return step;
}
@@ -9,6 +9,10 @@ import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/work
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
import {
type WorkflowAction,
WorkflowActionType,
@@ -113,6 +117,10 @@ describe('WorkflowVersionStepWorkspaceService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkflowVersionStepWorkspaceService,
WorkflowVersionStepHelpersWorkspaceService,
WorkflowVersionStepCreationWorkspaceService,
WorkflowVersionStepUpdateWorkspaceService,
WorkflowVersionStepDeletionWorkspaceService,
{
provide: TwentyORMGlobalManager,
useValue: twentyORMGlobalManager,
@@ -140,6 +148,14 @@ describe('WorkflowVersionStepWorkspaceService', () => {
additionalCreatedSteps: [],
})),
runWorkflowVersionStepDeletionSideEffects: jest.fn(),
cloneStep: jest.fn().mockImplementation(({ step }) => ({
...step,
id: 'cloned-step-id',
})),
markStepAsDuplicate: jest
.fn()
.mockImplementation(({ step }) => step),
createDraftStep: jest.fn().mockImplementation(({ step }) => step),
},
},
{
@@ -0,0 +1,176 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepCreationWorkspaceService {
constructor(
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async createWorkflowVersionStep({
workspaceId,
input,
}: {
workspaceId: string;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowVersionStepChangesDTO> {
const {
workflowVersionId,
stepType,
parentStepId,
nextStepId,
position,
parentStepConnectionOptions,
id,
} = input;
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const existingSteps = workflowVersion.steps;
const existingTrigger = workflowVersion.trigger;
const { builtStep, additionalCreatedSteps } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
id,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: builtStep,
workspaceId,
workflowVersionId,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: existingSteps ?? [],
existingTrigger,
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
parentStepConnectionOptions,
});
if (isDefined(additionalCreatedSteps)) {
updatedSteps.push(...additionalCreatedSteps);
}
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
trigger: updatedTrigger,
steps: updatedSteps,
},
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
});
}
async duplicateWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepId,
}: {
workspaceId: string;
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const stepToDuplicate = workflowVersion.steps?.find(
(step) => step.id === stepId,
);
if (!isDefined(stepToDuplicate)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const clonedStep =
await this.workflowVersionStepOperationsWorkspaceService.cloneStep({
step: stepToDuplicate,
workspaceId,
});
const duplicatedStep =
this.workflowVersionStepOperationsWorkspaceService.markStepAsDuplicate({
step: clonedStep,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
trigger: updatedTrigger,
},
);
return computeWorkflowVersionStepChanges({
existingTrigger: workflowVersion.trigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async createDraftStep({
step,
workspaceId,
}: {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
return this.workflowVersionStepOperationsWorkspaceService.createDraftStep({
step,
workspaceId,
});
}
}
@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
@Injectable()
export class WorkflowVersionStepDeletionWorkspaceService {
constructor(
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async deleteWorkflowVersionStep({
workspaceId,
workflowVersionId,
stepIdToDelete,
}: {
workspaceId: string;
workflowVersionId: string;
stepIdToDelete: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
const existingTrigger = workflowVersion.trigger;
const isDeletingTrigger =
stepIdToDelete === TRIGGER_STEP_ID && isDefined(existingTrigger);
if (!isDeletingTrigger && !isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't delete step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const stepToDelete = workflowVersion.steps?.find(
(step) => step.id === stepIdToDelete,
);
if (!isDeletingTrigger && !isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
"Can't delete not existing step",
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const stepToDeleteChildrenIds = isDeletingTrigger
? (existingTrigger?.nextStepIds ?? [])
: (stepToDelete?.nextStepIds ?? []);
const { updatedSteps, updatedTrigger, removedStepIds } = removeStep({
existingTrigger,
existingSteps: workflowVersion.steps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
trigger: updatedTrigger,
},
);
const removedSteps =
workflowVersion.steps?.filter((step) =>
removedStepIds.includes(step.id),
) ?? [];
await Promise.all(
removedSteps.map((step) =>
this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step,
workspaceId,
},
),
),
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type';
@Injectable()
export class WorkflowVersionStepHelpersWorkspaceService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
) {}
async getValidatedDraftWorkflowVersion({
workflowVersionId,
workspaceId,
}: {
workflowVersionId: string;
workspaceId: string;
}): Promise<WorkflowVersionWorkspaceEntity> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
return workflowVersion;
}
async updateWorkflowVersionStepsAndTrigger({
workspaceId,
workflowVersionId,
steps,
trigger,
}: {
workspaceId: string;
workflowVersionId: string;
steps?: WorkflowAction[] | null;
trigger?: WorkflowTrigger | null;
}): Promise<void> {
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
const updateData: Partial<WorkflowVersionWorkspaceEntity> = {};
if (steps !== undefined) {
updateData.steps = steps;
}
if (trigger !== undefined) {
updateData.trigger = trigger;
}
await workflowVersionRepository.update(workflowVersionId, updateData);
}
}
@@ -336,6 +336,32 @@ export class WorkflowVersionStepOperationsWorkspaceService {
};
}
case WorkflowActionType.AI_AGENT: {
// Get workflow version to use workflow ID and name in agent name
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
workspaceId,
});
const newAgent = await this.agentRepository.save({
name: 'workflow-service-agent' + v4(),
label: 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4),
icon: 'IconRobot',
description: '',
prompt: '',
modelId: 'auto',
responseFormat: { type: 'text' },
workspaceId,
isCustom: true,
});
if (!isDefined(newAgent)) {
throw new WorkflowVersionStepException(
'Failed to create AI Agent step',
WorkflowVersionStepExceptionCode.AI_AGENT_STEP_FAILURE,
);
}
return {
builtStep: {
...baseStep,
@@ -344,7 +370,7 @@ export class WorkflowVersionStepOperationsWorkspaceService {
settings: {
...BASE_STEP_DEFINITION,
input: {
agentId: '',
agentId: newAgent.id,
prompt: '',
},
},
@@ -510,6 +536,45 @@ export class WorkflowVersionStepOperationsWorkspaceService {
},
};
}
case WorkflowActionType.AI_AGENT: {
const existingAgent = await this.agentRepository.findOne({
where: { id: step.settings.input.agentId, workspaceId },
});
if (!isDefined(existingAgent)) {
throw new WorkflowVersionStepException(
'Agent not found 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,
workspaceId,
isCustom: true,
modelConfiguration: existingAgent.modelConfiguration,
});
return {
...step,
id: v4(),
nextStepIds: [],
position: duplicatedStepPosition,
settings: {
...step.settings,
input: {
...step.settings.input,
agentId: clonedAgent.id,
},
},
};
}
case WorkflowActionType.ITERATOR: {
return {
...step,
@@ -0,0 +1,147 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepUpdateWorkspaceService {
constructor(
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowVersionStepHelpersWorkspaceService: WorkflowVersionStepHelpersWorkspaceService,
) {}
async updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step,
}: {
workspaceId: string;
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowActionDTO> {
const workflowVersion =
await this.workflowVersionStepHelpersWorkspaceService.getValidatedDraftWorkflowVersion(
{
workflowVersionId,
workspaceId,
},
);
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't update step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const existingStep = workflowVersion.steps.find(
(existingStep) => existingStep.id === step.id,
);
if (!isDefined(existingStep)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const isStepTypeChanged = existingStep.type !== step.type;
const updatedStep = isStepTypeChanged
? await this.updateWorkflowVersionStepType({
existingStep,
newStep: step,
workspaceId,
workflowVersionId,
})
: await this.updateWorkflowVersionStepSettings({
newStep: step,
workspaceId,
workflowVersionId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {
return updatedStep;
} else {
return existingStep;
}
});
await this.workflowVersionStepHelpersWorkspaceService.updateWorkflowVersionStepsAndTrigger(
{
workspaceId,
workflowVersionId: workflowVersion.id,
steps: updatedSteps,
},
);
return updatedStep;
}
private async updateWorkflowVersionStepType({
existingStep,
newStep,
workspaceId,
workflowVersionId,
}: {
existingStep: WorkflowAction;
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
await this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step: existingStep,
workspaceId,
},
);
const { builtStep } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: newStep.type,
workspaceId,
position: newStep.position,
workflowVersionId,
},
);
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: {
...builtStep,
id: existingStep.id,
nextStepIds: existingStep.nextStepIds,
position: existingStep.position,
},
workspaceId,
workflowVersionId,
});
}
private async updateWorkflowVersionStepSettings({
newStep,
workspaceId,
workflowVersionId,
}: {
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: newStep,
workspaceId,
workflowVersionId,
});
}
}
@@ -9,6 +9,10 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
@Module({
imports: [
@@ -20,6 +24,10 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl
providers: [
WorkflowVersionStepWorkspaceService,
WorkflowVersionStepOperationsWorkspaceService,
WorkflowVersionStepHelpersWorkspaceService,
WorkflowVersionStepCreationWorkspaceService,
WorkflowVersionStepUpdateWorkspaceService,
WorkflowVersionStepDeletionWorkspaceService,
],
exports: [
WorkflowVersionStepWorkspaceService,
@@ -1,33 +1,19 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-action.dto';
import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { type WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity';
import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util';
import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
import { computeWorkflowVersionStepChanges } from 'src/modules/workflow/workflow-builder/utils/compute-workflow-version-step-updates.util';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
import { insertStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/insert-step';
import { removeStep } from 'src/modules/workflow/workflow-builder/workflow-version-step/utils/remove-step';
import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service';
import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service';
import { WorkflowVersionStepDeletionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-deletion.workspace-service';
import { WorkflowVersionStepUpdateWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-update.workspace-service';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class WorkflowVersionStepWorkspaceService {
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService,
private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService,
private readonly workflowVersionStepCreationWorkspaceService: WorkflowVersionStepCreationWorkspaceService,
private readonly workflowVersionStepUpdateWorkspaceService: WorkflowVersionStepUpdateWorkspaceService,
private readonly workflowVersionStepDeletionWorkspaceService: WorkflowVersionStepDeletionWorkspaceService,
) {}
async createWorkflowVersionStep({
@@ -37,77 +23,12 @@ export class WorkflowVersionStepWorkspaceService {
workspaceId: string;
input: CreateWorkflowVersionStepInput;
}): Promise<WorkflowVersionStepChangesDTO> {
const {
workflowVersionId,
stepType,
parentStepId,
nextStepId,
position,
parentStepConnectionOptions,
id,
} = input;
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
return this.workflowVersionStepCreationWorkspaceService.createWorkflowVersionStep(
{
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const existingSteps = workflowVersion.steps;
const existingTrigger = workflowVersion.trigger;
const { builtStep, additionalCreatedSteps } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: stepType,
workspaceId,
position,
workflowVersionId,
id,
},
);
const enrichedNewStep =
await this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: builtStep,
workspaceId,
workflowVersionId,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: existingSteps ?? [],
existingTrigger,
insertedStep: enrichedNewStep,
parentStepId,
nextStepId,
parentStepConnectionOptions,
});
if (isDefined(additionalCreatedSteps)) {
updatedSteps.push(...additionalCreatedSteps);
}
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
trigger: updatedTrigger,
steps: updatedSteps,
});
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps,
updatedTrigger,
updatedSteps,
});
input,
},
);
}
async updateWorkflowVersionStep({
@@ -119,67 +40,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
step: WorkflowAction;
}): Promise<WorkflowActionDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepUpdateWorkspaceService.updateWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
if (!isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't update step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const existingStep = workflowVersion.steps.find(
(existingStep) => existingStep.id === step.id,
step,
},
);
if (!isDefined(existingStep)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const isStepTypeChanged = existingStep.type !== step.type;
const updatedStep = isStepTypeChanged
? await this.updateWorkflowVersionStepType({
existingStep,
newStep: step,
workspaceId,
workflowVersionId,
})
: await this.updateWorkflowVersionStepSettings({
newStep: step,
workspaceId,
workflowVersionId,
});
const updatedSteps = workflowVersion.steps.map((existingStep) => {
if (existingStep.id === step.id) {
return updatedStep;
} else {
return existingStep;
}
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
});
return updatedStep;
}
async deleteWorkflowVersionStep({
@@ -191,82 +58,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
stepIdToDelete: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepDeletionWorkspaceService.deleteWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const existingTrigger = workflowVersion.trigger;
const isDeletingTrigger =
stepIdToDelete === TRIGGER_STEP_ID && isDefined(existingTrigger);
if (!isDeletingTrigger && !isDefined(workflowVersion.steps)) {
throw new WorkflowVersionStepException(
"Can't delete step from undefined steps",
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const stepToDelete = workflowVersion.steps?.find(
(step) => step.id === stepIdToDelete,
stepIdToDelete,
},
);
if (!isDeletingTrigger && !isDefined(stepToDelete)) {
throw new WorkflowVersionStepException(
"Can't delete not existing step",
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const stepToDeleteChildrenIds = isDeletingTrigger
? (existingTrigger?.nextStepIds ?? [])
: (stepToDelete?.nextStepIds ?? []);
const { updatedSteps, updatedTrigger, removedStepIds } = removeStep({
existingTrigger,
existingSteps: workflowVersion.steps,
stepIdToDelete,
stepToDeleteChildrenIds,
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
trigger: updatedTrigger,
});
const removedSteps =
workflowVersion.steps?.filter((step) =>
removedStepIds.includes(step.id),
) ?? [];
await Promise.all(
removedSteps.map((step) =>
this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step,
workspaceId,
},
),
),
);
return computeWorkflowVersionStepChanges({
existingTrigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async duplicateWorkflowVersionStep({
@@ -278,59 +76,13 @@ export class WorkflowVersionStepWorkspaceService {
workflowVersionId: string;
stepId: string;
}): Promise<WorkflowVersionStepChangesDTO> {
const workflowVersion =
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
return this.workflowVersionStepCreationWorkspaceService.duplicateWorkflowVersionStep(
{
workspaceId,
workflowVersionId,
workspaceId,
});
assertWorkflowVersionIsDraft(workflowVersion);
const stepToDuplicate = workflowVersion.steps?.find(
(step) => step.id === stepId,
stepId,
},
);
if (!isDefined(stepToDuplicate)) {
throw new WorkflowVersionStepException(
'Step not found',
WorkflowVersionStepExceptionCode.NOT_FOUND,
);
}
const clonedStep =
await this.workflowVersionStepOperationsWorkspaceService.cloneStep({
step: stepToDuplicate,
workspaceId,
});
const duplicatedStep =
this.workflowVersionStepOperationsWorkspaceService.markStepAsDuplicate({
step: clonedStep,
});
const { updatedSteps, updatedTrigger } = insertStep({
existingSteps: workflowVersion.steps ?? [],
existingTrigger: workflowVersion.trigger,
insertedStep: duplicatedStep,
});
const workflowVersionRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowVersionWorkspaceEntity>(
workspaceId,
'workflowVersion',
{ shouldBypassPermissionChecks: true },
);
await workflowVersionRepository.update(workflowVersion.id, {
steps: updatedSteps,
trigger: updatedTrigger,
});
return computeWorkflowVersionStepChanges({
existingTrigger: workflowVersion.trigger,
existingSteps: workflowVersion.steps,
updatedTrigger,
updatedSteps,
});
}
async createDraftStep({
@@ -340,65 +92,9 @@ export class WorkflowVersionStepWorkspaceService {
step: WorkflowAction;
workspaceId: string;
}): Promise<WorkflowAction> {
return this.workflowVersionStepOperationsWorkspaceService.createDraftStep({
return this.workflowVersionStepCreationWorkspaceService.createDraftStep({
step,
workspaceId,
});
}
private async updateWorkflowVersionStepType({
existingStep,
newStep,
workspaceId,
workflowVersionId,
}: {
existingStep: WorkflowAction;
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
await this.workflowVersionStepOperationsWorkspaceService.runWorkflowVersionStepDeletionSideEffects(
{
step: existingStep,
workspaceId,
},
);
const { builtStep } =
await this.workflowVersionStepOperationsWorkspaceService.runStepCreationSideEffectsAndBuildStep(
{
type: newStep.type,
workspaceId,
position: newStep.position,
workflowVersionId,
},
);
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: {
...builtStep,
id: existingStep.id,
nextStepIds: existingStep.nextStepIds,
position: existingStep.position,
},
workspaceId,
workflowVersionId,
});
}
private async updateWorkflowVersionStepSettings({
newStep,
workspaceId,
workflowVersionId,
}: {
newStep: WorkflowAction;
workspaceId: string;
workflowVersionId: string;
}): Promise<WorkflowAction> {
return this.workflowSchemaWorkspaceService.enrichOutputSchema({
step: newStep,
workspaceId,
workflowVersionId,
});
}
}
@@ -80,7 +80,6 @@ export class AiAgentWorkflowAction implements WorkflowAction {
const { result, usage } = await this.aiAgentExecutionService.executeAgent(
{
agent,
schema: step.settings.outputSchema,
userPrompt: resolveInput(prompt, context) as string,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
@@ -1,9 +1,15 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { generateObject, generateText, stepCountIs, ToolSet } from 'ai';
import { Repository } from 'typeorm';
import {
generateObject,
generateText,
jsonSchema,
stepCountIs,
ToolSet,
} from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
@@ -17,10 +23,8 @@ import {
} from 'src/engine/metadata-modules/agent/agent.exception';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
@Injectable()
export class AiAgentExecutorService {
@@ -86,13 +90,11 @@ export class AiAgentExecutorService {
async executeAgent({
agent,
schema,
userPrompt,
actorContext,
rolePermissionConfig,
}: {
agent: AgentEntity | null;
schema: OutputSchema;
userPrompt: string;
actorContext?: ActorMetadata;
rolePermissionConfig?: RolePermissionConfig;
@@ -121,12 +123,18 @@ export class AiAgentExecutorService {
experimental_telemetry: AI_TELEMETRY_CONFIG,
});
if (Object.keys(schema).length === 0) {
const agentSchema =
agent?.responseFormat?.type === 'json'
? agent.responseFormat.schema
: undefined;
if (!agentSchema) {
return {
result: { response: textResponse.text },
usage: textResponse.usage,
};
}
const output = await generateObject({
system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR,
model: registeredModel.model,
@@ -135,12 +143,12 @@ export class AiAgentExecutorService {
Execution Results: ${textResponse.text}
Please generate the structured output based on the execution results and context above.`,
schema: convertOutputSchemaToZod(schema),
schema: jsonSchema(agentSchema),
experimental_telemetry: AI_TELEMETRY_CONFIG,
});
return {
result: output.object,
result: output.object as object,
usage: {
inputTokens:
(textResponse.usage?.inputTokens ?? 0) +
@@ -243,7 +243,7 @@ export const createAgentToolTestModule =
description: 'Test agent for integration tests',
prompt: 'You are a test agent',
modelId: 'gpt-4o',
responseFormat: {},
responseFormat: { type: 'text' },
workspaceId: testWorkspaceId,
workspace: {} as any,
roleId: testRoleId,
+5
View File
@@ -7,6 +7,11 @@
* |___/
*/
export type {
AgentResponseFieldType,
AgentResponseSchema,
} from './types/agent-response-schema.type';
export type { DataMessagePart } from './types/DataMessagePart';
export type { ExtendedUIMessage } from './types/ExtendedUIMessage';
export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart';
export type { ModelConfiguration } from './types/model-configuration.type';
@@ -0,0 +1,18 @@
// Simple agent response schema for AI SDK using JSON Schema format
// Simple primitive types that map to JSON Schema
export type AgentResponseFieldType = 'string' | 'number' | 'boolean';
// Our simplified schema format (flat object with primitives only)
export type AgentResponseSchema = {
type: 'object';
properties: Record<
string,
{
type: AgentResponseFieldType;
description?: string;
}
>;
required?: string[];
additionalProperties?: false;
};
@@ -0,0 +1,5 @@
export type {
AgentResponseFieldType,
AgentResponseSchema,
} from './agent-response-schema.type';
export type { ModelConfiguration } from './model-configuration.type';
@@ -0,0 +1,10 @@
export type ModelConfiguration = {
webSearch?: {
enabled: boolean;
configuration?: Record<string, unknown>;
};
twitterSearch?: {
enabled: boolean;
configuration?: Record<string, unknown>;
};
};