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:
+62
-8
@@ -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,
|
||||
|
||||
+6
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
+13
@@ -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,
|
||||
|
||||
+6
@@ -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;
|
||||
}
|
||||
|
||||
+104
@@ -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}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
+2
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user