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;