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:
@@ -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';
|
||||
}
|
||||
|
||||
+152
-71
@@ -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} />}
|
||||
</>
|
||||
|
||||
+3
-3
@@ -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;
|
||||
};
|
||||
|
||||
+1
-2
@@ -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]);
|
||||
};
|
||||
|
||||
-17
@@ -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>
|
||||
|
||||
+131
@@ -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>
|
||||
);
|
||||
};
|
||||
+10
-5
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
+7
@@ -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<
|
||||
|
||||
+1
-1
@@ -56,7 +56,7 @@ export const SettingsApplicationDetailContentTab = ({
|
||||
title={t`Application agents`}
|
||||
description={t`Agents created by application`}
|
||||
/>
|
||||
<SettingsAIAgentsTable withSearchBar={false} />
|
||||
<SettingsAIAgentsTable />
|
||||
</Section>
|
||||
)}
|
||||
{shouldDisplayObjects && (
|
||||
|
||||
+22
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -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()
|
||||
|
||||
+10
@@ -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,10 +1 @@
|
||||
export type ModelConfiguration = {
|
||||
webSearch?: {
|
||||
enabled: boolean;
|
||||
configuration: object;
|
||||
};
|
||||
twitterSearch?: {
|
||||
enabled: boolean;
|
||||
configuration: object;
|
||||
};
|
||||
};
|
||||
export type { ModelConfiguration } from 'twenty-shared/ai';
|
||||
|
||||
-42
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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: {},
|
||||
|
||||
+1
-1
@@ -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: {},
|
||||
};
|
||||
|
||||
+1
-1
@@ -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: {},
|
||||
|
||||
+1
-1
@@ -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: {},
|
||||
|
||||
+1
@@ -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',
|
||||
}
|
||||
|
||||
+7
-1
@@ -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],
|
||||
})
|
||||
|
||||
+43
-6
@@ -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;
|
||||
}
|
||||
|
||||
+16
@@ -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),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+176
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+106
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
-1
@@ -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,
|
||||
|
||||
+147
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+27
-331
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+17
-9
@@ -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) +
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user