Add messages support to runAgent for multi-turn bot conversations (#23395)

- 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

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23395?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:
Abdul Rahman
2026-08-05 13:50:12 +05:30
committed by GitHub
parent 0b817e3bc0
commit ded3f1efb3
17 changed files with 394 additions and 67 deletions
@@ -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;
}
@@ -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[];
}
@@ -0,0 +1,10 @@
import { registerEnumType } from '@nestjs/graphql';
export enum RunAgentMessageRole {
user = 'user',
assistant = 'assistant',
}
registerEnumType(RunAgentMessageRole, {
name: 'RunAgentMessageRole',
});
@@ -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,
});
@@ -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',
}),
);
});
@@ -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<AgentExecutionResult> {
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,
@@ -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<RunAgentResult> {
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}`,
@@ -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,
@@ -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