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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5217,9 +5217,20 @@ export enum RowLevelPermissionPredicateOperand {
|
||||
|
||||
export type RunAgentInput = {
|
||||
agentUniversalIdentifier: Scalars['String']['input'];
|
||||
prompt: Scalars['String']['input'];
|
||||
messages?: InputMaybe<Array<RunAgentMessageInput>>;
|
||||
prompt?: InputMaybe<Scalars['String']['input']>;
|
||||
};
|
||||
|
||||
export type RunAgentMessageInput = {
|
||||
content: Scalars['String']['input'];
|
||||
role: RunAgentMessageRole;
|
||||
};
|
||||
|
||||
export enum RunAgentMessageRole {
|
||||
assistant = 'assistant',
|
||||
user = 'user'
|
||||
}
|
||||
|
||||
export type RunAgentResult = {
|
||||
__typename?: 'RunAgentResult';
|
||||
error?: Maybe<Scalars['String']['output']>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
+25
-5
@@ -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[];
|
||||
}
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum RunAgentMessageRole {
|
||||
user = 'user',
|
||||
assistant = 'assistant',
|
||||
}
|
||||
|
||||
registerEnumType(RunAgentMessageRole, {
|
||||
name: 'RunAgentMessageRole',
|
||||
});
|
||||
+47
-8
@@ -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,
|
||||
});
|
||||
|
||||
+77
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
+18
-4
@@ -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,
|
||||
|
||||
+39
-8
@@ -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}`,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user