From ded3f1efb3973f08de1ba61d967bbf6c8d058afd Mon Sep 17 00:00:00 2001
From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Date: Wed, 5 Aug 2026 13:50:12 +0530
Subject: [PATCH] Add messages support to runAgent for multi-turn bot
conversations (#23395)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Extend `runAgent` so callers can pass either a one-shot prompt or a
multi-turn messages array (user / assistant text), matching AI SDK’s XOR
shape — for Slack/Discord/Teams bots that need thread history.
- Enforce exactly one of prompt | messages in AgentRunService; map
messages 1:1 to AI SDK ModelMessages in AgentAsyncExecutorService
- Update shared types, GraphQL/SDK inputs, docs (skills-and-agents), and
regenerate metadata clients; existing prompt-only callers stay unchanged
---
.../src/metadata/generated/schema.graphql | 13 ++-
.../src/metadata/generated/schema.ts | 11 ++-
.../src/metadata/generated/types.ts | 82 +++++++++++--------
.../extend/apps/logic/skills-and-agents.mdx | 21 ++++-
.../src/generated-metadata/graphql.ts | 13 ++-
.../agents/__tests__/run-agent.spec.ts | 36 ++++++++
.../dtos/run-agent-message.input.ts | 18 ++++
.../dtos/run-agent.input.ts | 30 +++++--
.../enums/run-agent-message-role.enum.ts | 10 +++
.../agent-async-executor.service.spec.ts | 55 +++++++++++--
.../__tests__/agent-run.service.spec.ts | 77 +++++++++++++++++
.../services/agent-async-executor.service.ts | 22 ++++-
.../services/agent-run.service.ts | 47 +++++++++--
.../jobs/run-evaluation-input.job.ts | 2 +-
.../ai-agent/ai-agent.workflow-action.ts | 4 +-
.../twenty-shared/src/application/index.ts | 7 +-
.../src/application/runAgentType.ts | 13 ++-
17 files changed, 394 insertions(+), 67 deletions(-)
create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/enums/run-agent-message-role.enum.ts
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
index 262f6b5ac9..e5044a483a 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql
@@ -4703,7 +4703,18 @@ input CreateEmailingDomainInput {
input RunAgentInput {
agentUniversalIdentifier: String!
- prompt: String!
+ prompt: String
+ messages: [RunAgentMessageInput!]
+}
+
+input RunAgentMessageInput {
+ role: RunAgentMessageRole!
+ content: String!
+}
+
+enum RunAgentMessageRole {
+ user
+ assistant
}
input CreateWebhookInput {
diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
index 24e95f96e8..46624ddfdc 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts
@@ -3189,6 +3189,8 @@ export interface Mutation {
export type FileFolder = 'CorePicture' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
+export type RunAgentMessageRole = 'user' | 'assistant'
+
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
export interface Subscription {
@@ -6897,7 +6899,9 @@ export interface UpdateEmailGroupChannelInput {id: Scalars['UUID'],displayName?:
export interface CreateEmailingDomainInput {domain: Scalars['String']}
-export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt: Scalars['String']}
+export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt?: (Scalars['String'] | null),messages?: (RunAgentMessageInput[] | null)}
+
+export interface RunAgentMessageInput {role: RunAgentMessageRole,content: Scalars['String']}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
@@ -9860,6 +9864,11 @@ export const enumFileFolder = {
Dpa: 'Dpa' as const
}
+export const enumRunAgentMessageRole = {
+ user: 'user' as const,
+ assistant: 'assistant' as const
+}
+
export const enumAnalyticsType = {
PAGEVIEW: 'PAGEVIEW' as const,
TRACK: 'TRACK' as const
diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts
index f0daefdf58..2e6591f4bf 100644
--- a/packages/twenty-client-sdk/src/metadata/generated/types.ts
+++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts
@@ -89,7 +89,8 @@ export default {
372,
379,
380,
- 512
+ 492,
+ 514
],
"types": {
"BillingProductDTO": {
@@ -8836,7 +8837,7 @@ export default {
308,
{
"input": [
- 491,
+ 493,
"CreateWebhookInput!"
]
}
@@ -8845,7 +8846,7 @@ export default {
308,
{
"input": [
- 492,
+ 494,
"UpdateWebhookInput!"
]
}
@@ -8863,7 +8864,7 @@ export default {
349,
{
"input": [
- 494,
+ 496,
"UpdateMessageFolderInput!"
]
}
@@ -8872,7 +8873,7 @@ export default {
349,
{
"input": [
- 496,
+ 498,
"UpdateMessageFoldersInput!"
]
}
@@ -8881,7 +8882,7 @@ export default {
344,
{
"input": [
- 497,
+ 499,
"UpdateCalendarChannelInput!"
]
}
@@ -8890,7 +8891,7 @@ export default {
342,
{
"input": [
- 499,
+ 501,
"SetAppKeyValueInput!"
]
}
@@ -8911,7 +8912,7 @@ export default {
341,
{
"input": [
- 500,
+ 502,
"EnqueueJobInput!"
]
}
@@ -8941,7 +8942,7 @@ export default {
1
],
"fileAttachments": [
- 501,
+ 503,
"[FileAttachmentInput!]"
]
}
@@ -8970,7 +8971,7 @@ export default {
"UUID!"
],
"answers": [
- 502,
+ 504,
"[AgentChatQuestionAnswerInput!]!"
],
"modelId": [
@@ -9048,7 +9049,7 @@ export default {
327,
{
"input": [
- 503,
+ 505,
"CreateSkillInput!"
]
}
@@ -9057,7 +9058,7 @@ export default {
327,
{
"input": [
- 504,
+ 506,
"UpdateSkillInput!"
]
}
@@ -9115,7 +9116,7 @@ export default {
255,
{
"input": [
- 505,
+ 507,
"GetAuthorizationUrlForSSOInput!"
]
}
@@ -9281,7 +9282,7 @@ export default {
258,
{
"input": [
- 506
+ 508
]
}
],
@@ -9456,7 +9457,7 @@ export default {
3,
{
"input": [
- 507,
+ 509,
"UpdateWorkspaceMemberSettingsInput!"
]
}
@@ -9490,7 +9491,7 @@ export default {
213,
{
"input": [
- 508,
+ 510,
"SetupOIDCSsoInput!"
]
}
@@ -9499,7 +9500,7 @@ export default {
213,
{
"input": [
- 509,
+ 511,
"SetupSAMLSsoInput!"
]
}
@@ -9508,7 +9509,7 @@ export default {
209,
{
"input": [
- 510,
+ 512,
"DeleteSsoInput!"
]
}
@@ -9517,7 +9518,7 @@ export default {
210,
{
"input": [
- 511,
+ 513,
"EditSsoInput!"
]
}
@@ -9546,7 +9547,7 @@ export default {
323,
{
"type": [
- 512,
+ 514,
"AnalyticsType!"
],
"name": [
@@ -9589,7 +9590,7 @@ export default {
313,
{
"input": [
- 513,
+ 515,
"CreateCalendarEventInput!"
]
}
@@ -9598,7 +9599,7 @@ export default {
322,
{
"input": [
- 514,
+ 516,
"SendEmailInput!"
]
}
@@ -9620,7 +9621,7 @@ export default {
"String!"
],
"connectionParameters": [
- 516,
+ 518,
"EmailAccountConnectionParameters!"
],
"id": [
@@ -9632,7 +9633,7 @@ export default {
173,
{
"input": [
- 518,
+ 520,
"UpdateLabPublicFeatureFlagInput!"
]
}
@@ -12037,10 +12038,25 @@ export default {
"prompt": [
1
],
+ "messages": [
+ 491
+ ],
"__typename": [
1
]
},
+ "RunAgentMessageInput": {
+ "role": [
+ 492
+ ],
+ "content": [
+ 1
+ ],
+ "__typename": [
+ 1
+ ]
+ },
+ "RunAgentMessageRole": {},
"CreateWebhookInput": {
"id": [
4
@@ -12066,7 +12082,7 @@ export default {
4
],
"update": [
- 493
+ 495
],
"__typename": [
1
@@ -12094,7 +12110,7 @@ export default {
4
],
"update": [
- 495
+ 497
],
"__typename": [
1
@@ -12113,7 +12129,7 @@ export default {
4
],
"update": [
- 495
+ 497
],
"__typename": [
1
@@ -12124,7 +12140,7 @@ export default {
4
],
"update": [
- 498
+ 500
],
"__typename": [
1
@@ -12409,7 +12425,7 @@ export default {
1
],
"files": [
- 515
+ 517
],
"__typename": [
1
@@ -12431,13 +12447,13 @@ export default {
1
],
"IMAP": [
- 517
+ 519
],
"SMTP": [
- 517
+ 519
],
"CALDAV": [
- 517
+ 519
],
"__typename": [
1
@@ -12488,7 +12504,7 @@ export default {
243,
{
"input": [
- 520,
+ 522,
"LogicFunctionLogsInput!"
]
}
diff --git a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
index b6e7a0b781..15f071fdff 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/skills-and-agents.mdx
@@ -102,7 +102,8 @@ Schema notes:
`runAgent()` lets a logic function run one of your app's agents (with its
skills and tools). Identify the agent by the `universalIdentifier` you passed
-to `defineAgent()`:
+to `defineAgent()`. Pass either a `prompt` string or a `messages` conversation
+history — not both:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
@@ -113,7 +114,25 @@ const { result, error, success } = await runAgent({
});
```
+For multi-turn bots (Slack, Discord, Teams, …), pass thread history as
+`messages` instead of a single `prompt`:
+
+```ts src/logic-functions/reply-in-thread.ts
+import { runAgent } from 'twenty-sdk/logic-function';
+
+const { result, error, success } = await runAgent({
+ agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
+ messages: [
+ { role: 'user', content: 'Who owns Acme?' },
+ { role: 'assistant', content: 'Sarah owns the Acme account.' },
+ { role: 'user', content: 'What was the last touchpoint?' },
+ ],
+});
+```
+
Key points:
+- Provide **exactly one** of `prompt` (string) or `messages` (1 to 100 entries
+ of `{ role: 'user' | 'assistant', content: string }`).
- The agent runs **synchronously** and can read/update records itself via its
own tools — `runAgent()` resolves once the run completes.
- An app can only run its own agents.
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index 0f5b02fce0..07441f2c23 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -5217,9 +5217,20 @@ export enum RowLevelPermissionPredicateOperand {
export type RunAgentInput = {
agentUniversalIdentifier: Scalars['String']['input'];
- prompt: Scalars['String']['input'];
+ messages?: InputMaybe>;
+ prompt?: InputMaybe;
};
+export type RunAgentMessageInput = {
+ content: Scalars['String']['input'];
+ role: RunAgentMessageRole;
+};
+
+export enum RunAgentMessageRole {
+ assistant = 'assistant',
+ user = 'user'
+}
+
export type RunAgentResult = {
__typename?: 'RunAgentResult';
error?: Maybe;
diff --git a/packages/twenty-sdk/src/sdk/logic-function/agents/__tests__/run-agent.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/agents/__tests__/run-agent.spec.ts
index edaaf800a3..d1b2a15f39 100644
--- a/packages/twenty-sdk/src/sdk/logic-function/agents/__tests__/run-agent.spec.ts
+++ b/packages/twenty-sdk/src/sdk/logic-function/agents/__tests__/run-agent.spec.ts
@@ -68,6 +68,42 @@ describe('runAgent', () => {
});
});
+ it('POSTs messages when provided instead of prompt', async () => {
+ const payload = {
+ result: { response: 'done' },
+ error: null,
+ success: true,
+ };
+
+ fetchSpy.mockResolvedValue(
+ new Response(JSON.stringify({ data: { runAgent: payload } }), {
+ status: 200,
+ }),
+ );
+
+ const messages = [
+ { role: 'user' as const, content: 'Hello' },
+ { role: 'assistant' as const, content: 'Hi' },
+ { role: 'user' as const, content: 'Status?' },
+ ];
+
+ const result = await runAgent({
+ agentUniversalIdentifier: 'agent-uid',
+ messages,
+ });
+
+ expect(result).toEqual(payload);
+
+ const sentBody = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
+
+ expect(sentBody.variables).toEqual({
+ input: {
+ agentUniversalIdentifier: 'agent-uid',
+ messages,
+ },
+ });
+ });
+
it('surfaces GraphQL errors as a regular Error', async () => {
fetchSpy.mockResolvedValue(
new Response(
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input.ts
new file mode 100644
index 0000000000..9505153f0f
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input.ts
@@ -0,0 +1,18 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
+import { type RunAgentMessage } from 'twenty-shared/application';
+
+import { RunAgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/enums/run-agent-message-role.enum';
+
+@InputType('RunAgentMessageInput')
+export class RunAgentMessageInputDTO implements RunAgentMessage {
+ @IsEnum(RunAgentMessageRole)
+ @Field(() => RunAgentMessageRole)
+ role: RunAgentMessageRole;
+
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ content: string;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
index c021020a18..6094b84f21 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input.ts
@@ -1,17 +1,37 @@
import { Field, InputType } from '@nestjs/graphql';
-import { IsNotEmpty, IsString } from 'class-validator';
-import { type RunAgentInput } from 'twenty-shared/application';
+import { Type } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ ArrayNotEmpty,
+ IsArray,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ ValidateNested,
+} from 'class-validator';
+
+import { RunAgentMessageInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-message.input';
@InputType('RunAgentInput')
-export class RunAgentInputDTO implements RunAgentInput {
+export class RunAgentInputDTO {
@IsString()
@IsNotEmpty()
@Field()
agentUniversalIdentifier: string;
+ @IsOptional()
@IsString()
@IsNotEmpty()
- @Field()
- prompt: string;
+ @Field({ nullable: true })
+ prompt?: string;
+
+ @IsOptional()
+ @IsArray()
+ @ArrayNotEmpty()
+ @ArrayMaxSize(100)
+ @ValidateNested({ each: true })
+ @Type(() => RunAgentMessageInputDTO)
+ @Field(() => [RunAgentMessageInputDTO], { nullable: true })
+ messages?: RunAgentMessageInputDTO[];
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/enums/run-agent-message-role.enum.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/enums/run-agent-message-role.enum.ts
new file mode 100644
index 0000000000..b573ac2c39
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/enums/run-agent-message-role.enum.ts
@@ -0,0 +1,10 @@
+import { registerEnumType } from '@nestjs/graphql';
+
+export enum RunAgentMessageRole {
+ user = 'user',
+ assistant = 'assistant',
+}
+
+registerEnumType(RunAgentMessageRole, {
+ name: 'RunAgentMessageRole',
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
index ef7afd0727..c0c21f187a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
@@ -158,7 +158,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -190,7 +190,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
toolLoadingStrategy: 'lazy',
@@ -216,7 +216,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -224,12 +224,51 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled();
});
+ it('passes messages to generateText when messages are provided', async () => {
+ roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
+
+ const messages = [
+ { role: 'user' as const, content: 'Hello' },
+ { role: 'assistant' as const, content: 'Hi' },
+ { role: 'user' as const, content: 'Status?' },
+ ];
+
+ await service.executeAgent({
+ agent: buildAgent(),
+ messages,
+ baseSystemPrompt: 'base system prompt',
+ workspaceId,
+ });
+
+ const generateTextArgs = generateTextMock.mock.calls[0][0];
+
+ expect(generateTextArgs.messages).toEqual([
+ { role: 'user', content: 'Hello' },
+ { role: 'assistant', content: 'Hi' },
+ { role: 'user', content: 'Status?' },
+ ]);
+ expect(generateTextArgs).not.toHaveProperty('prompt');
+ });
+
+ it('throws without calling the model when messages are empty', async () => {
+ await expect(
+ service.executeAgent({
+ agent: buildAgent(),
+ messages: [],
+ baseSystemPrompt: 'base system prompt',
+ workspaceId,
+ }),
+ ).rejects.toThrow(/at least one message/);
+
+ expect(generateTextMock).not.toHaveBeenCalled();
+ });
+
it('prefixes the system prompt with the caller-supplied base prompt', async () => {
roleTargetRepository.findOne.mockResolvedValueOnce(null);
await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'caller base prompt',
workspaceId,
});
@@ -264,7 +303,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
schema: { type: 'object', properties: {} },
},
} as AgentEntity,
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
workspaceId,
});
@@ -302,7 +341,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
const result = await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -342,7 +381,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
});
@@ -377,7 +416,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
const result = await service.executeAgent({
agent: buildAgent(),
- userPrompt: 'test',
+ messages: [{ role: 'user', content: 'test' }],
baseSystemPrompt: 'base system prompt',
workspaceId,
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
index d28d014b48..a3a5d7fc3b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-run.service.spec.ts
@@ -87,6 +87,7 @@ describe('AgentRunService', () => {
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
expect.objectContaining({
+ messages: [{ role: 'user', content: input.prompt }],
authContext: {
type: 'application',
workspace,
@@ -96,6 +97,81 @@ describe('AgentRunService', () => {
);
});
+ it('converts a prompt into a single user message', async () => {
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ input,
+ });
+
+ expect(
+ agentAsyncExecutorService.executeAgent.mock.calls[0][0].messages,
+ ).toEqual([{ role: 'user', content: input.prompt }]);
+ });
+
+ it('passes messages to the executor when messages are provided instead of prompt', async () => {
+ const messages = [
+ { role: 'user' as const, content: 'Hello' },
+ { role: 'assistant' as const, content: 'Hi there' },
+ { role: 'user' as const, content: 'What is the status?' },
+ ];
+
+ await service.run({
+ workspace,
+ requestUserWorkspaceId: 'user-workspace-1',
+ input: {
+ agentUniversalIdentifier: 'agent-uid',
+ messages,
+ },
+ });
+
+ const executeAgentArgs =
+ agentAsyncExecutorService.executeAgent.mock.calls[0][0];
+
+ expect(executeAgentArgs.messages).toEqual(messages);
+ expect(executeAgentArgs.baseSystemPrompt).toBe(
+ AGENT_RUN_BASE_SYSTEM_PROMPT,
+ );
+ expect(executeAgentArgs.toolLoadingStrategy).toBe('lazy');
+ expect(executeAgentArgs.authContext).toEqual({
+ type: 'application',
+ workspace,
+ application: { id: 'app-1' },
+ });
+ });
+
+ it('throws when neither prompt nor messages are provided', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ input: {
+ agentUniversalIdentifier: 'agent-uid',
+ },
+ }),
+ ).rejects.toThrow(/exactly one of prompt or messages/);
+
+ expect(agentRepository.findOne).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
+ it('throws when both prompt and messages are provided', async () => {
+ await expect(
+ service.run({
+ workspace,
+ requestUserWorkspaceId: null,
+ input: {
+ agentUniversalIdentifier: 'agent-uid',
+ prompt: 'Enrich record 123',
+ messages: [{ role: 'user', content: 'Hello' }],
+ },
+ }),
+ ).rejects.toThrow(/exactly one of prompt or messages/);
+
+ expect(agentRepository.findOne).not.toHaveBeenCalled();
+ expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
+ });
+
it('runs the agent with the programmatic base system prompt', async () => {
await service.run({
workspace,
@@ -106,6 +182,7 @@ describe('AgentRunService', () => {
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
expect.objectContaining({
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
+ toolLoadingStrategy: 'lazy',
}),
);
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
index b33ac6687b..362a4453e2 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
@@ -5,14 +5,16 @@ import {
generateText,
jsonSchema,
type LanguageModelUsage,
+ type ModelMessage,
Output,
stepCountIs,
type StepResult,
type ToolSet,
} from 'ai';
+import { type RunAgentMessage } from 'twenty-shared/application';
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
import { type ActorMetadata } from 'twenty-shared/types';
-import { isDefined } from 'twenty-shared/utils';
+import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
@@ -232,7 +234,7 @@ export class AgentAsyncExecutorService {
async executeAgent({
agent,
- userPrompt,
+ messages,
baseSystemPrompt,
actorContext,
authContext,
@@ -242,7 +244,7 @@ export class AgentAsyncExecutorService {
toolLoadingStrategy = 'preload',
}: {
agent: AgentEntity | null;
- userPrompt: string;
+ messages: RunAgentMessage[];
baseSystemPrompt: string;
actorContext?: ActorMetadata;
authContext?: WorkspaceAuthContext;
@@ -251,6 +253,13 @@ export class AgentAsyncExecutorService {
operationType?: UsageOperationType;
toolLoadingStrategy?: AgentToolLoadingStrategy;
}): Promise {
+ if (!isNonEmptyArray(messages)) {
+ throw new AiException(
+ 'Provide at least one message to run an agent',
+ AiExceptionCode.INVALID_AGENT_INPUT,
+ );
+ }
+
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
let accumulatedUsage: LanguageModelUsage = EMPTY_USAGE;
@@ -348,7 +357,12 @@ export class AgentAsyncExecutorService {
system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}${toolCatalogSection}`,
tools,
model: registeredModel.model,
- prompt: userPrompt,
+ messages: messages.map(
+ (message): ModelMessage => ({
+ role: message.role,
+ content: message.content,
+ }),
+ ),
stopWhen: (step) =>
stepCountIs(AGENT_CONFIG.MAX_STEPS)(step) ||
hasNoMoreAvailableCredits,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
index c6abaf2e91..2f4c2b51b0 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
@@ -1,9 +1,11 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
+import { isNonEmptyString } from '@sniptt/guards';
import {
- type RunAgentInput,
+ type RunAgentMessage,
type RunAgentResult,
} from 'twenty-shared/application';
+import { isNonEmptyArray } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
@@ -12,9 +14,19 @@ import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
import { AGENT_RUN_BASE_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-run-base-system-prompt.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
+import {
+ AiException,
+ AiExceptionCode,
+} from 'src/engine/metadata-modules/ai/ai.exception';
import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
+type RunAgentServiceInput = {
+ agentUniversalIdentifier: string;
+ prompt?: string | null;
+ messages?: RunAgentMessage[] | null;
+};
+
@Injectable()
export class AgentRunService {
private readonly logger = new Logger(AgentRunService.name);
@@ -33,8 +45,22 @@ export class AgentRunService {
}: {
workspace: FlatWorkspace;
requestUserWorkspaceId: string | null;
- input: RunAgentInput;
+ input: RunAgentServiceInput;
}): Promise {
+ const prompt = input.prompt;
+
+ // GraphQL cannot express XOR; enforce exactly one of prompt or messages
+ if (isNonEmptyArray(input.messages) === isNonEmptyString(prompt)) {
+ throw new AiException(
+ 'Provide exactly one of prompt or messages',
+ AiExceptionCode.INVALID_AGENT_INPUT,
+ );
+ }
+
+ const messages: RunAgentMessage[] = isNonEmptyString(prompt)
+ ? [{ role: 'user', content: prompt }]
+ : (input.messages ?? []);
+
const agent = await this.agentRepository.findOne(workspace.id, {
where: {
universalIdentifier: input.agentUniversalIdentifier,
@@ -64,19 +90,20 @@ export class AgentRunService {
};
try {
- const { result, hasNoMoreAvailableCredits } =
- await this.agentAsyncExecutorService.executeAgent({
+ const executionResult = await this.agentAsyncExecutorService.executeAgent(
+ {
agent,
- userPrompt: input.prompt,
+ messages,
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
authContext,
workspaceId: workspace.id,
userWorkspaceId: requestUserWorkspaceId,
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
toolLoadingStrategy: 'lazy',
- });
+ },
+ );
- if (hasNoMoreAvailableCredits) {
+ if (executionResult.hasNoMoreAvailableCredits) {
return {
result: null,
error: 'AI agent stopped: no more available credits.',
@@ -84,7 +111,11 @@ export class AgentRunService {
};
}
- return { result, error: null, success: true };
+ return {
+ result: executionResult.result,
+ error: null,
+ success: true,
+ };
} catch (error) {
this.logger.error(
`Agent execution failed for ${input.agentUniversalIdentifier}`,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts
index 524ff8bc05..f27dae5dd1 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-monitor/jobs/run-evaluation-input.job.ts
@@ -56,7 +56,7 @@ export class RunEvaluationInputJob {
const executionResult = await this.aiAgentExecutorService.executeAgent({
agent,
- userPrompt: data.input,
+ messages: [{ role: 'user', content: data.input }],
baseSystemPrompt: AGENT_RUN_BASE_SYSTEM_PROMPT,
workspaceId: data.workspaceId,
userWorkspaceId: null,
diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts
index 055d27d265..3c4871c40c 100644
--- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts
+++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts
@@ -84,7 +84,9 @@ export class AiAgentWorkflowAction implements WorkflowAction {
const executionResult = await this.aiAgentExecutionService.executeAgent({
agent,
- userPrompt: resolveInput(prompt, context) as string,
+ messages: [
+ { role: 'user', content: resolveInput(prompt, context) as string },
+ ],
baseSystemPrompt: WORKFLOW_BASE_SYSTEM_PROMPT,
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts
index 404fea6c80..07b64f4747 100644
--- a/packages/twenty-shared/src/application/index.ts
+++ b/packages/twenty-shared/src/application/index.ts
@@ -135,7 +135,12 @@ export type {
RowLevelPermissionPredicateManifest,
RoleManifest,
} from './roleManifestType';
-export type { RunAgentInput, RunAgentResult } from './runAgentType';
+export type {
+ RunAgentMessageRole,
+ RunAgentMessage,
+ RunAgentInput,
+ RunAgentResult,
+} from './runAgentType';
export type { ServerVariables } from './server-variables.type';
export type { ServerRouteDispatchResult } from './serverRouteDispatchResultType';
export type { ServerRouteTriggerSettings } from './serverRouteTriggerSettingsType';
diff --git a/packages/twenty-shared/src/application/runAgentType.ts b/packages/twenty-shared/src/application/runAgentType.ts
index e5f1992cd6..3f93cf542e 100644
--- a/packages/twenty-shared/src/application/runAgentType.ts
+++ b/packages/twenty-shared/src/application/runAgentType.ts
@@ -1,7 +1,16 @@
+export type RunAgentMessageRole = 'user' | 'assistant';
+
+export type RunAgentMessage = {
+ role: RunAgentMessageRole;
+ content: string;
+};
+
export type RunAgentInput = {
agentUniversalIdentifier: string;
- prompt: string;
-};
+} & (
+ | { prompt: string; messages?: never }
+ | { messages: RunAgentMessage[]; prompt?: never }
+);
export type RunAgentResult = {
result: object | null;