fix(ai): validate AI agent output field names against schema-key constraint (#21834)

## Problem

On a self-hosted instance, an AI Agent workflow action fails at run time
with an opaque model error:

```
The model returned the following errors: tools.0.custom.input_schema.properties:
Property keys should match pattern '^[a-zA-Z0-9_.-]{1,64}$'
```

This is Anthropic's validation on tool `input_schema` **property keys**.
An AI Agent's structured **Output** fields are turned into a JSON schema
and passed to the model as a tool; each output **variable name** becomes
a property key. Anthropic rejects any key that does not match
`^[a-zA-Z0-9_.-]{1,64}$` — most commonly a name containing a **space**
(e.g. `meetings brief`), but also names over 64 characters or with other
symbols.

Until now nothing validated this: `fieldsToSchema` writes
`properties[field.name]` verbatim, so a bad name only failed once the
workflow executed, with an error that gives the user no idea what to
fix. It doesn't reproduce on every instance — it depends purely on how
the workflow's output variables happen to be named.

## Fix

Introduce a single shared check,
`isValidAgentResponseSchemaPropertyKey`, and enforce it in two places:

- **Backend** — `validateAgentResponseFormat` now rejects invalid output
field names at agent **save time** with a clear `userFriendlyMessage`,
instead of letting the broken schema reach the model. This also gates
agents created via the API and re-saves of existing bad data.
- **Frontend** — the output schema builder shows an inline error on the
Variable Name field as soon as an invalid name is entered.

## Tests

- Unit test for the shared validity check (valid + invalid cases:
spaces, leading space, empty, > 64 chars, symbols, unicode).
- Unit test for `validateAgentResponseFormat` covering text/json
formats, valid names, a space in a name, an over-length name, and
reporting multiple invalid names at once.

## Notes for the reporter

The immediate unblock for an affected workflow is to rename the output
variable to remove the space (e.g. `meetings brief` → `meetings_brief`)
and retry the run. With this change the bad name is caught up front with
an explanation rather than failing mid-run.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21834?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:
Charles Bochet
2026-06-19 13:50:44 +02:00
committed by GitHub
parent 576f88b5c5
commit 0064ff6741
6 changed files with 152 additions and 1 deletions
@@ -7,6 +7,8 @@ import { InputLabel } from '@/ui/input/components/InputLabel';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useContext, useState } from 'react';
import { isNonEmptyString } from '@sniptt/guards';
import { isValidAgentResponseSchemaPropertyKey } from 'twenty-shared/ai';
import { IconChevronDown, IconPlus, IconVariable, IconX } from 'twenty-ui/icon';
import { AnimatedLightIconButton, LightIconButton } from 'twenty-ui/input';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
@@ -148,6 +150,17 @@ export const WorkflowOutputSchemaBuilder = ({
);
};
const getVariableNameError = (name: string): string | undefined => {
if (
!isNonEmptyString(name) ||
isValidAgentResponseSchemaPropertyKey(name)
) {
return undefined;
}
return t`Use only letters, numbers, underscores, dots or hyphens (max 64 characters).`;
};
return (
<StyledOutputSchemaContainer>
<InputLabel>{t`Output`}</InputLabel>
@@ -205,6 +218,7 @@ export const WorkflowOutputSchemaBuilder = ({
label={t`Variable Name`}
placeholder={t`e.g., summary, status, count`}
defaultValue={field.name}
error={getVariableNameError(field.name)}
onChange={(value) =>
updateField(field.id, { name: value.trim() })
}