feat(workflow): add update_agent tool and responseFormat-aware AI Agent step schema (#21755)

## Summary

Brings the workflow AI/MCP tooling for **AI Agent steps** to parity with
the existing **CODE / logic-function** flow, and makes AI Agent output
references reliable in validation and the variable picker.

Just like a CODE step needs a logic function, an `AI_AGENT` step needs
an agent. The agent is already created as a side effect of step
creation; this PR adds the missing "configure it" tooling and fixes the
output schema so it reflects the agent's actual response format.

## Changes

### New `update_agent` MCP tool
- `update-agent.tool.ts`: lets the assistant configure the agent backing
an `AI_AGENT` step — `prompt` (system prompt), optional `modelId`,
optional `responseFormat` (text or structured json) — via
`AgentService.updateOneAgent`. Direct analog of
`update_logic_function_source`.
- Wired through: added `agentService` to `WorkflowToolDependencies`,
injected `AgentService` and registered the tool in
`workflow-tool.workspace-service.ts`, imported `AiAgentModule` in
`workflow-tools.module.ts`.

### Guided creation flow (mirrors CODE)
- `create-workflow-version-step.tool.ts`: `enrichResultWithNextStep` now
returns an `AI_AGENT` hint instructing the assistant to call
`update_agent` with the step's `settings.input.agentId` (and to set the
task prompt via `update_workflow_version_step` if needed).
- `create-complete-workflow.tool.ts`: rejects `AI_AGENT` steps (it
inserts steps directly and never runs the side effect that creates the
agent), with a description note pointing to
`create_workflow_version_step` + `update_agent`. Same treatment CODE
already gets.

### Correct output schema for AI Agent steps
- Backend `computeStepOutputSchema`
(`workflow-schema.workspace-service.ts`): the `AI_AGENT` case now
derives the output schema from the agent's `responseFormat` instead of a
hardcoded `{ response }`:
  - text → `{ response: string }`
  - json → one leaf per `responseFormat.schema.properties` field
This makes workflow validation resolve `{{stepId.fieldName}}` references
against the agent's real output (previously json agents validated wrong:
real fields rejected, `{{stepId.response}}` accepted but undefined at
runtime).
- Frontend `useStepsOutputSchema.ts`: when an `AI_AGENT` step has no
persisted `outputSchema`, fall back to generating it from the agent's
`responseFormat` (via `FindManyAgents` + the existing
`agentResponseSchemaToOutputSchema`) instead of the hardcoded `{
response }`. Keeps the variable picker correct for structured agents.

## Why output schema matters

Validation resolves every `{{stepId.path}}` against the referenced
step's `settings.outputSchema` (`validateWorkflowVariableReferences`).
The agent step's output schema is therefore the single source of truth
for "is the right output referenced." Because the runtime output depends
on `responseFormat` (text → `{ response }`, json → schema fields
directly), the schema must be derived from `responseFormat` to be
accurate.

## Not solved issue
We want each AI_AGENT step's settings.outputSchema to always match the
backing agent's responseFormat:
- text → { response }
- json → one field per responseFormat.schema.properties
That output schema is what everything downstream relies on: validation
(validateWorkflowVariableReferences resolves {{stepId.field}} against
it), the frontend variable picker (useStepsOutputSchema), and it's also
persisted inside workflowVersion.steps.

Agent should be unique source of truth but syncing agent -> step is not
possible


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21755?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-06-18 15:00:21 +02:00
committed by GitHub
parent 3fe2aec5d1
commit d67aa2889b
10 changed files with 751 additions and 564 deletions
@@ -30,6 +30,7 @@ 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';
import {
type BaseOutputSchema,
Leaf,
Node,
type OutputSchema,
@@ -134,14 +135,10 @@ export class WorkflowSchemaWorkspaceService {
};
}
case WorkflowActionType.AI_AGENT: {
return {
response: {
label: 'Response',
isLeaf: true,
type: 'string',
value: 'Response of the agent',
},
};
return this.computeAiAgentActionOutputSchema({
agentId: step.settings.input.agentId,
workspaceId,
});
}
case WorkflowTriggerType.WEBHOOK:
case WorkflowActionType.CODE:
@@ -379,6 +376,63 @@ export class WorkflowSchemaWorkspaceService {
return { success: { isLeaf: true, type: 'boolean', value: true } };
}
private async computeAiAgentActionOutputSchema({
agentId,
workspaceId,
}: {
agentId?: string;
workspaceId: string;
}): Promise<OutputSchema> {
const textResponseOutputSchema: OutputSchema = {
response: {
label: 'Response',
isLeaf: true,
type: 'string',
value: 'Response of the agent',
},
};
if (!isDefined(agentId)) {
return textResponseOutputSchema;
}
const { flatAgentMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatAgentMaps'],
},
);
const flatAgent = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: agentId,
flatEntityMaps: flatAgentMaps,
});
const responseFormat = flatAgent?.responseFormat;
if (responseFormat?.type !== 'json') {
return textResponseOutputSchema;
}
return Object.entries(responseFormat.schema.properties || {}).reduce(
(outputSchema, [propertyName, property]) => {
outputSchema[propertyName] = {
isLeaf: true,
type: property.type,
label: propertyName,
...(isDefined(property.description)
? { description: property.description }
: {}),
value: generateFakeValue(property.type),
};
return outputSchema;
},
{} as BaseOutputSchema,
);
}
private async computeFormActionOutputSchema({
formFieldMetadataItems,
workspaceId,
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -24,6 +25,7 @@ import { createDeleteWorkflowVersionEdgeTool } from 'src/modules/workflow/workfl
import { createDeleteWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool';
import { createGetWorkflowCurrentVersionTool } from 'src/modules/workflow/workflow-tools/tools/get-workflow-current-version.tool';
import { createListLogicFunctionToolsTool } from 'src/modules/workflow/workflow-tools/tools/list-logic-function-tools.tool';
import { createUpdateAgentTool } from 'src/modules/workflow/workflow-tools/tools/update-agent.tool';
import { createUpdateLogicFunctionSourceTool } from 'src/modules/workflow/workflow-tools/tools/update-logic-function-source.tool';
import { createUpdateWorkflowVersionPositionsTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool';
import { createUpdateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool';
@@ -48,6 +50,7 @@ export class WorkflowToolWorkspaceService {
recordPositionService: RecordPositionService,
logicFunctionFromSourceService: LogicFunctionFromSourceService,
flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
agentService: AgentService,
) {
this.deps = {
workflowVersionStepService,
@@ -61,6 +64,7 @@ export class WorkflowToolWorkspaceService {
recordPositionService,
logicFunctionFromSourceService,
flatEntityMapsCacheService,
agentService,
};
}
@@ -128,6 +132,7 @@ export class WorkflowToolWorkspaceService {
this.deps,
context,
);
const updateAgent = createUpdateAgentTool(this.deps, context);
const validateWorkflow = createValidateWorkflowTool(this.deps, context);
return {
@@ -146,6 +151,7 @@ export class WorkflowToolWorkspaceService {
[getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion,
[updateLogicFunctionSource.name]: updateLogicFunctionSource,
[listLogicFunctionTools.name]: listLogicFunctionTools,
[updateAgent.name]: updateAgent,
[validateWorkflow.name]: validateWorkflow,
};
}
@@ -1,5 +1,6 @@
import {
workflowActionSchema,
WorkflowActionType,
workflowTriggerSchema,
} from 'twenty-shared/workflow';
import { v4 as uuidv4 } from 'uuid';
@@ -99,6 +100,7 @@ Common mistakes to avoid:
- Missing the "objectRecord" field in CREATE_RECORD actions
- Using "fieldsToUpdate" instead of "objectRecord" in CREATE_RECORD actions
- Including CODE steps in this tool — this tool does NOT create the underlying logic function needed by CODE steps. Instead, create the workflow without CODE steps first, then add CODE steps individually using create_workflow_version_step (which properly creates the logic function), then call update_logic_function_source to define the code.
- Including AI_AGENT steps in this tool — this tool does NOT create the underlying agent needed by AI_AGENT steps. Instead, create the workflow without AI_AGENT steps first, then add AI_AGENT steps individually using create_workflow_version_step (which properly creates the agent), then call update_agent to configure the agent.
IMPORTANT: The tool schema provides comprehensive field descriptions, examples, and validation rules. Always refer to the schema for:
- Field requirements and data types
@@ -134,6 +136,17 @@ The response includes a compact validation summary. For the full validation repo
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const aiAgentSteps = parameters.steps.filter(
(step) => step.type === WorkflowActionType.AI_AGENT,
);
if (aiAgentSteps.length > 0) {
throw new WorkflowVersionStepException(
'AI_AGENT steps cannot be created via create_complete_workflow because it does not create the underlying agent. Use create_workflow_version_step instead, then call update_agent to configure the agent.',
WorkflowVersionStepExceptionCode.INVALID_REQUEST,
);
}
const workflowId = await createWorkflow({
deps,
context,
@@ -86,6 +86,12 @@ const enrichResultWithNextStep = ({
nextStep:
'This CODE step was created with a default placeholder function. You MUST now call update_logic_function_source with the logicFunctionId from this step to define the actual code. IMPORTANT: Also provide outputSchema (an example return value, e.g. { datePlus7: "2026-06-16" }) so downstream steps can reference this step\'s output variables via {{stepId.fieldName}}.',
};
case WorkflowActionType.AI_AGENT:
return {
...result,
nextStep:
'This AI_AGENT step was created with a default placeholder agent. You MUST now call update_agent with the agentId from this step\'s settings.input.agentId to set the agent\'s system prompt (and optionally its model and responseFormat). Use responseFormat { type: "json", schema: { ... } } when downstream steps need to reference structured fields via {{stepId.fieldName}}, otherwise the output is referenced as {{stepId.response}}. If the step needs a task-specific prompt, also set it via update_workflow_version_step on settings.input.prompt.',
};
default:
return result;
}
@@ -0,0 +1,104 @@
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { type AgentResponseFormat } from 'src/engine/metadata-modules/ai/ai-agent/types/agent-response-format.type';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import {
type WorkflowToolContext,
type WorkflowToolDependencies,
} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type';
const agentResponseFormatSchema = z.union([
z.object({ type: z.literal('text') }),
z.object({
type: z.literal('json'),
schema: z.object({
type: z.literal('object'),
properties: z.record(
z.string(),
z.object({
type: z.enum(['string', 'number', 'boolean']),
description: z.string().optional(),
}),
),
required: z.array(z.string()).optional(),
additionalProperties: z.literal(false).optional(),
}),
}),
]);
const updateAgentSchema = z.object({
agentId: z
.string()
.uuid()
.describe(
"The ID of the agent to update (from the AI_AGENT step's settings.input.agentId)",
),
prompt: z
.string()
.optional()
.describe(
"The agent's system prompt describing its role, behavior and the task it must accomplish.",
),
modelId: z
.string()
.optional()
.describe(
'Optional model id to use for the agent. Leave empty to keep the auto-selected model.',
),
responseFormat: agentResponseFormatSchema
.optional()
.describe(
'Optional response format. Use { type: "text" } for free-form text output, or { type: "json", schema: { type: "object", properties: { fieldName: { type: "string" } } } } for structured output. Downstream steps can reference structured fields via {{stepId.fieldName}} (or {{stepId.response}} for text format).',
),
});
export const createUpdateAgentTool = (
deps: Pick<WorkflowToolDependencies, 'agentService'>,
context: WorkflowToolContext,
) => ({
name: 'update_agent' as const,
description: `Update the AI agent used by a workflow AI_AGENT step.
Use this tool to configure the agent created when an AI_AGENT step is added: set its system prompt, the model it should use, and the format of its output.
- prompt: the agent's system prompt (its role, behavior and task).
- modelId: optional model id; omit to keep the auto-selected model.
- responseFormat: { type: "text" } for free-form text (referenced as {{stepId.response}}), or { type: "json", schema: { ... } } for structured output whose fields can be referenced as {{stepId.fieldName}}.
To find the agentId, look at the AI_AGENT step's settings.input.agentId field.`,
inputSchema: updateAgentSchema,
execute: async (parameters: {
agentId: string;
prompt?: string;
modelId?: string;
responseFormat?: AgentResponseFormat;
}) => {
try {
const { agentId, prompt, modelId, responseFormat } = parameters;
const { workspaceId } = context;
const updatedAgent = await deps.agentService.updateOneAgent({
input: {
id: agentId,
...(isDefined(prompt) ? { prompt } : {}),
...(isDefined(modelId) ? { modelId: modelId as ModelId } : {}),
...(isDefined(responseFormat) ? { responseFormat } : {}),
},
workspaceId,
});
return {
success: true,
message: `Successfully updated agent ${agentId}`,
agentId: updatedAgent.id,
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to update agent: ${error.message}`,
};
}
},
});
@@ -1,4 +1,5 @@
import type { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import type { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import type { LogicFunctionFromSourceService } from 'src/engine/metadata-modules/logic-function/services/logic-function-from-source.service';
import type { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import type { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
@@ -22,6 +23,7 @@ export type WorkflowToolDependencies = {
recordPositionService: RecordPositionService;
logicFunctionFromSourceService: LogicFunctionFromSourceService;
flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService;
agentService: AgentService;
};
export type WorkflowToolContext = {
@@ -2,6 +2,7 @@ import { Global, Module } from '@nestjs/common';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
@@ -27,6 +28,7 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace
RecordPositionModule,
LogicFunctionModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
AiAgentModule,
],
providers: [
WorkflowToolWorkspaceService,