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:
@@ -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!]!
|
||||
|
||||
Reference in New Issue
Block a user