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
@@ -3046,14 +3046,8 @@ type Query {
getPageLayout(id: String!): PageLayout
getPageLayoutWidgets(pageLayoutTabId: String!): [PageLayoutWidget!]!
getPageLayoutWidget(id: String!): PageLayoutWidget!
findOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
findManyLogicFunctions: [LogicFunction!]!
getAvailablePackages(input: LogicFunctionIdInput!): JSON!
getLogicFunctionSourceCode(input: LogicFunctionIdInput!): String
commandMenuItems: [CommandMenuItem!]!
commandMenuItem(id: UUID!): CommandMenuItem
frontComponents: [FrontComponent!]!
frontComponent(id: UUID!): FrontComponent
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
objectRecordCounts: [ObjectRecordCount!]!
object(
"""The id of the record to find."""
@@ -3077,8 +3071,14 @@ type Query {
"""Specify to filter the records returned."""
filter: IndexFilter! = {}
): IndexConnection!
findManyAgents: [Agent!]!
findOneAgent(input: AgentIdInput!): Agent!
findOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
findManyLogicFunctions: [LogicFunction!]!
getAvailablePackages(input: LogicFunctionIdInput!): JSON!
getLogicFunctionSourceCode(input: LogicFunctionIdInput!): String
commandMenuItems: [CommandMenuItem!]!
commandMenuItem(id: UUID!): CommandMenuItem
frontComponents: [FrontComponent!]!
frontComponent(id: UUID!): FrontComponent
previewMessageCampaignAudience(input: PreviewMessageCampaignAudienceInput!): CampaignAudiencePreviewDTO!
unsubscribeTopics: [UnsubscribeTopic!]!
unsubscribePagePreviewUrl: String!
@@ -3154,16 +3154,16 @@ input GetApiKeyInput {
id: UUID!
}
input LogicFunctionIdInput {
"""The id of the function."""
id: ID!
}
input AgentIdInput {
"""The id of the agent."""
id: UUID!
}
input LogicFunctionIdInput {
"""The id of the function."""
id: ID!
}
input PreviewMessageCampaignAudienceInput {
listId: String!
unsubscribeTopicId: String
@@ -3314,6 +3314,14 @@ type Mutation {
createPageLayoutWidget(input: CreatePageLayoutWidgetInput!): PageLayoutWidget!
updatePageLayoutWidget(id: String!, input: UpdatePageLayoutWidgetInput!): PageLayoutWidget!
destroyPageLayoutWidget(id: String!): Boolean!
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
createOneObject(input: CreateOneObjectInput!): Object!
deleteOneObject(input: DeleteOneObjectInput!): Object!
updateOneObject(input: UpdateOneObjectInput!): Object!
createOneIndex(input: CreateOneIndexInput!): Index!
deleteOneIndex(input: DeleteOneIndexInput!): Index!
deleteOneLogicFunction(input: LogicFunctionIdInput!): LogicFunction!
createOneLogicFunction(input: CreateLogicFunctionFromSourceInput!): LogicFunction!
executeOneLogicFunction(input: ExecuteOneLogicFunctionInput!): LogicFunctionExecutionResult!
@@ -3325,14 +3333,6 @@ type Mutation {
createFrontComponent(input: CreateFrontComponentInput!): FrontComponent!
updateFrontComponent(input: UpdateFrontComponentInput!): FrontComponent!
deleteFrontComponent(id: UUID!): FrontComponent!
createOneObject(input: CreateOneObjectInput!): Object!
deleteOneObject(input: DeleteOneObjectInput!): Object!
updateOneObject(input: UpdateOneObjectInput!): Object!
createOneIndex(input: CreateOneIndexInput!): Index!
deleteOneIndex(input: DeleteOneIndexInput!): Index!
createOneAgent(input: CreateAgentInput!): Agent!
updateOneAgent(input: UpdateAgentInput!): Agent!
deleteOneAgent(input: AgentIdInput!): Agent!
sendEmailViaEmailingDomain(input: SendEmailViaDomainInput!): SendEmailViaDomainOutput!
sendMessageCampaign(input: SendMessageCampaignInput!): SendMessageCampaignOutputDTO!
createUnsubscribeTopic(input: CreateUnsubscribeTopicInput!): UnsubscribeTopic!
@@ -3903,6 +3903,103 @@ input UpdatePageLayoutWidgetInput {
conditionalAvailabilityExpression: String
}
input CreateAgentInput {
name: String
label: String!
icon: String
description: String
prompt: String!
modelId: String!
roleId: UUID
responseFormat: JSON
modelConfiguration: JSON
evaluationInputs: [String!]
}
input UpdateAgentInput {
id: UUID!
name: String
label: String
icon: String
description: String
prompt: String
modelId: String
roleId: UUID
responseFormat: JSON
modelConfiguration: JSON
evaluationInputs: [String!]
}
input CreateOneObjectInput {
"""The object to create"""
object: CreateObjectInput!
}
input CreateObjectInput {
nameSingular: String!
namePlural: String!
labelSingular: String!
labelPlural: String!
description: String
icon: String
shortcut: String
color: String
skipNameField: Boolean
isRemote: Boolean
primaryKeyColumnType: String
primaryKeyFieldMetadataSettings: JSON
isLabelSyncedWithName: Boolean
}
input DeleteOneObjectInput {
"""The id of the record to delete."""
id: UUID!
}
input UpdateOneObjectInput {
update: UpdateObjectPayload!
"""The id of the object to update"""
id: UUID!
}
input UpdateObjectPayload {
labelSingular: String
labelPlural: String
nameSingular: String
namePlural: String
description: String
icon: String
shortcut: String
color: String
isActive: Boolean
labelIdentifierFieldMetadataId: UUID
imageIdentifierFieldMetadataId: UUID
isLabelSyncedWithName: Boolean
isSearchable: Boolean
}
input CreateOneIndexInput {
"""The custom index to create"""
index: CreateIndexInput!
}
input CreateIndexInput {
objectMetadataId: UUID!
fields: [CreateIndexFieldInput!]!
indexType: IndexType! = BTREE
}
input CreateIndexFieldInput {
fieldMetadataId: UUID!
subFieldName: String
}
input DeleteOneIndexInput {
"""The id of the custom index to delete."""
id: UUID!
}
input CreateLogicFunctionFromSourceInput {
id: UUID
universalIdentifier: UUID
@@ -4001,103 +4098,6 @@ input UpdateFrontComponentInputUpdates {
description: String
}
input CreateOneObjectInput {
"""The object to create"""
object: CreateObjectInput!
}
input CreateObjectInput {
nameSingular: String!
namePlural: String!
labelSingular: String!
labelPlural: String!
description: String
icon: String
shortcut: String
color: String
skipNameField: Boolean
isRemote: Boolean
primaryKeyColumnType: String
primaryKeyFieldMetadataSettings: JSON
isLabelSyncedWithName: Boolean
}
input DeleteOneObjectInput {
"""The id of the record to delete."""
id: UUID!
}
input UpdateOneObjectInput {
update: UpdateObjectPayload!
"""The id of the object to update"""
id: UUID!
}
input UpdateObjectPayload {
labelSingular: String
labelPlural: String
nameSingular: String
namePlural: String
description: String
icon: String
shortcut: String
color: String
isActive: Boolean
labelIdentifierFieldMetadataId: UUID
imageIdentifierFieldMetadataId: UUID
isLabelSyncedWithName: Boolean
isSearchable: Boolean
}
input CreateOneIndexInput {
"""The custom index to create"""
index: CreateIndexInput!
}
input CreateIndexInput {
objectMetadataId: UUID!
fields: [CreateIndexFieldInput!]!
indexType: IndexType! = BTREE
}
input CreateIndexFieldInput {
fieldMetadataId: UUID!
subFieldName: String
}
input DeleteOneIndexInput {
"""The id of the custom index to delete."""
id: UUID!
}
input CreateAgentInput {
name: String
label: String!
icon: String
description: String
prompt: String!
modelId: String!
roleId: UUID
responseFormat: JSON
modelConfiguration: JSON
evaluationInputs: [String!]
}
input UpdateAgentInput {
id: UUID!
name: String
label: String
icon: String
description: String
prompt: String
modelId: String
roleId: UUID
responseFormat: JSON
modelConfiguration: JSON
evaluationInputs: [String!]
}
input SendEmailViaDomainInput {
emailingDomainId: String!
to: [String!]!