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

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

## Key Features

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

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

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

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

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

## Changes by Module

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

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

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

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

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

## Migration Notes
- Existing agents will have `responseFormat: {type: 'text'}` set
automatically
- No breaking changes - all existing functionality preserved
- Agents can be updated to use JSON format through settings UI
This commit is contained in:
Félix Malfait
2025-11-20 18:32:44 +01:00
committed by GitHub
parent 5476879f77
commit a281f2a773
49 changed files with 1207 additions and 641 deletions
@@ -1,28 +1,30 @@
import { useAiAgentOutputSchema } from '@/ai/hooks/useAiAgentOutputSchema';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { AI_AGENT_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/AiAgentAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { type AiAgentOutputSchema } from '@/workflow/workflow-variables/types/AiAgentOutputSchema';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import {
type AgentResponseSchema,
type ModelConfiguration,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { useFindManyAgentsQuery } from '~/generated-metadata/graphql';
import { useDebouncedCallback } from 'use-debounce';
import {
useFindOneAgentQuery,
useUpdateOneAgentMutation,
} from '~/generated-metadata/graphql';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import { WorkflowOutputSchemaBuilder } from './WorkflowOutputSchemaBuilder';
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.font.color.danger};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
import { SettingsAgentModelCapabilities } from '~/pages/settings/ai/components/SettingsAgentModelCapabilities';
import { SettingsAgentResponseFormat } from '~/pages/settings/ai/components/SettingsAgentResponseFormat';
type WorkflowEditActionAiAgentProps = {
action: WorkflowAiAgentAction;
@@ -45,62 +47,136 @@ export const WorkflowEditActionAiAgent = ({
defaultTitle: AI_AGENT_ACTION.defaultLabel,
});
const { handleOutputSchemaChange, outputFields } = useAiAgentOutputSchema(
action.settings.outputSchema as AiAgentOutputSchema,
actionOptions.readonly === true ? undefined : actionOptions.onActionUpdate,
action,
actionOptions.readonly,
);
const agentId = action.settings.input.agentId;
const { data: agentData, loading: agentLoading } = useFindOneAgentQuery({
variables: { id: agentId || '' },
skip: !agentId,
});
const [updateAgent] = useUpdateOneAgentMutation();
const aiModelOptions = useAiModelOptions();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const flow = useFlowOrThrow();
const { data: agentsData, loading: agentsLoading } = useFindManyAgentsQuery();
const agent = agentData?.findOneAgent;
const agentOptions = (agentsData?.findManyAgents || []).reduce<
SelectOption<string>[]
>(
(acc, agent) => {
acc.push({
label: agent.label,
value: agent.id,
Icon: agent.icon ? getIcon(agent.icon) : undefined,
const handleAgentPromptChange = useDebouncedCallback(
async (newPrompt: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
prompt: newPrompt,
},
},
refetchQueries: ['FindOneAgent'],
});
return acc;
},
[
{
label: t`No Agent`,
value: '',
},
],
500,
);
const noAgentsAvailable = agentOptions.length === 0;
const handleFieldChange = (field: 'agentId' | 'prompt', value: string) => {
if (actionOptions.readonly === true) {
const handleAgentModelChange = async (modelId: string) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
actionOptions.onActionUpdate?.({
...action,
settings: {
...action.settings,
await updateAgent({
variables: {
input: {
...action.settings.input,
[field]: value,
id: agent.id,
modelId,
},
},
refetchQueries: ['FindOneAgent'],
});
};
return agentsLoading ? (
const handleModelConfigurationChange = async (
configuration: ModelConfiguration,
) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
modelConfiguration: configuration,
},
},
refetchQueries: ['FindOneAgent'],
});
};
const updateAgentResponseFormat = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (actionOptions.readonly === true || !isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
responseFormat: format,
},
},
refetchQueries: ['FindOneAgent'],
});
await updateWorkflowVersionStep({
workflowVersionId: flow.workflowVersionId,
step: action,
});
};
const debouncedUpdateAgentResponseFormat = useDebouncedCallback(
updateAgentResponseFormat,
300,
);
const handleAgentResponseFormatChange = async (format: {
type: 'text' | 'json';
schema?: AgentResponseSchema;
}) => {
if (format.type !== agent?.responseFormat?.type) {
debouncedUpdateAgentResponseFormat.cancel();
void updateAgentResponseFormat(format);
} else {
void debouncedUpdateAgentResponseFormat(format);
}
};
return agentLoading ? (
<RightDrawerSkeletonLoader />
) : (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
onTitleChange={async (newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
// Also update agent label
if (isDefined(agent)) {
await updateAgent({
variables: {
input: {
id: agent.id,
label: newName,
},
},
refetchQueries: ['FindOneAgent'],
});
}
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
@@ -110,38 +186,43 @@ export const WorkflowEditActionAiAgent = ({
iconTooltip={AI_AGENT_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<div>
<Select
dropdownId="select-agent"
label={t`Select Agent`}
options={agentOptions}
value={action.settings.input.agentId || ''}
onChange={(value) => handleFieldChange('agentId', value)}
disabled={actionOptions.readonly || noAgentsAvailable}
/>
{noAgentsAvailable && (
<StyledErrorMessage>
{t`Please create agents in the AI settings to use in workflows.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
multiline
VariablePicker={WorkflowVariablePicker}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
defaultValue={action.settings.input.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
defaultValue={agent?.prompt || ''}
onChange={handleAgentPromptChange}
readonly={actionOptions.readonly}
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
{isDefined(agent) && (
<>
<Select
dropdownId="select-agent-model"
label={t`AI Model`}
options={aiModelOptions}
value={agent.modelId}
onChange={handleAgentModelChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentModelCapabilities
selectedModelId={agent.modelId}
modelConfiguration={agent.modelConfiguration || {}}
onConfigurationChange={handleModelConfigurationChange}
disabled={actionOptions.readonly}
/>
<SettingsAgentResponseFormat
responseFormat={
agent.responseFormat || { type: 'text', schema: {} }
}
onResponseFormatChange={handleAgentResponseFormatChange}
disabled={actionOptions.readonly}
/>
</>
)}
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowStepFooter stepId={action.id} />}
</>
@@ -1,11 +1,11 @@
import { OUTPUT_FIELD_TYPE_OPTIONS } from '@/ai/constants/OutputFieldTypeOptions';
import { Select } from '@/ui/input/components/Select';
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { t } from '@lingui/core/macro';
import { type AgentResponseFieldType } from 'twenty-shared/ai';
type WorkflowOutputFieldTypeSelectorProps = {
value?: InputSchemaPropertyType;
onChange: (value: InputSchemaPropertyType) => void;
value?: AgentResponseFieldType;
onChange: (value: AgentResponseFieldType) => void;
disabled?: boolean;
dropdownId: string;
};
@@ -3,7 +3,6 @@ import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/c
import { type OutputSchemaField } from '@/ai/constants/OutputFieldTypeOptions';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -114,7 +113,7 @@ export const WorkflowOutputSchemaBuilder = ({
id: v4(),
name: '',
description: '',
type: 'TEXT' as InputSchemaPropertyType,
type: 'string',
};
onChange([...fields, newField]);
};
@@ -1,17 +0,0 @@
import { type InputSchemaPropertyType } from '@/workflow/types/InputSchema';
export type AiAgentLeaf = {
isLeaf: true;
type: InputSchemaPropertyType | undefined;
label: string;
value: any;
};
export type AiAgentNode = {
isLeaf: false;
type: 'object' | 'unknown';
label: string;
value: AiAgentOutputSchema;
};
export type AiAgentOutputSchema = Record<string, AiAgentLeaf | AiAgentNode>;