feat(sdk): add runAgent() to run app agents from logic functions (#21157)
<img width="948" height="593" alt="image" src="https://github.com/user-attachments/assets/d990fa98-3cfd-469d-ab7f-0b2d4ccf3afc" /> <img width="1361" height="802" alt="image" src="https://github.com/user-attachments/assets/1091f598-49f3-4c16-92ea-1e1c200181e2" /> ## Add `runAgent()` to the Logic Function SDK Lets an app's logic function run one of its own AI agents server-side and get the result back synchronously — reusing the existing agent executor instead of a new bespoke transport. ### Backend - New **`runAgent` GraphQL mutation** (metadata schema) in `ai-agent-execution`, wrapping the existing `AgentAsyncExecutorService.executeAgent`. Scopes the agent lookup to the calling application and runs it under an application auth context. - New `@AuthApplication()` param decorator (mirrors `@AuthWorkspace()`) — first GraphQL resolver authenticated by an **application access token**. - Guarded by `WorkspaceAuthGuard` + `SettingsPermissionGuard(PermissionFlagType.AI)`: the app's role must grant the `AI` permission flag. ### SDK - `runAgent({ agentUniversalIdentifier, prompt })` posts the mutation to `/metadata` with the app token via a new runtime GraphQL transport. Returns `{ result, hasNoMoreAvailableCredits }`. - Refactored the connections helpers onto a shared `postAppEndpoint` util (removes duplicated transport logic). ### Frontend - App install permission modal now shows an explicit consent line — _"Run AI agents and bill AI credits to your workspace"_ — when the app's role requests the `AI` flag. ### Docs - Documented `runAgent` and its `AI` permission-flag requirement in _Skills & Agents_. - Fixed outdated role-permission examples in _Roles & Permissions_ (`permissionFlags` → `permissionFlagUniversalIdentifiers`, `PermissionFlag` → `SystemPermissionFlag`). ### Test plan - [x] SDK unit tests (`run-agent.spec.ts`) — request shape, GraphQL/HTTP error handling, missing env vars - [x] `twenty-server`, `twenty-front`, `twenty-shared` typecheck + lint - [ ] Manual: install an app granting the `AI` flag, call `runAgent()` from a logic function, confirm the agent runs and credits are billed --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
+7
@@ -1,6 +1,7 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
@@ -21,14 +22,17 @@ import { AgentMessagePartEntity } from './entities/agent-message-part.entity';
|
||||
import { AgentMessageEntity } from './entities/agent-message.entity';
|
||||
import { AgentTurnEntity } from './entities/agent-turn.entity';
|
||||
import { AgentMessagePartResolver } from './resolvers/agent-message-part.resolver';
|
||||
import { AgentRunResolver } from './resolvers/agent-run.resolver';
|
||||
import { AgentActorContextService } from './services/agent-actor-context.service';
|
||||
import { AgentAsyncExecutorService } from './services/agent-async-executor.service';
|
||||
import { AgentRunService } from './services/agent-run.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AiBillingModule,
|
||||
AiModelsModule,
|
||||
AiAgentModule,
|
||||
ApplicationModule,
|
||||
BillingModule,
|
||||
FileUrlModule,
|
||||
WorkspaceDomainsModule,
|
||||
@@ -50,7 +54,10 @@ import { AgentAsyncExecutorService } from './services/agent-async-executor.servi
|
||||
AgentAsyncExecutorService,
|
||||
AgentActorContextService,
|
||||
AgentMessagePartResolver,
|
||||
AgentRunResolver,
|
||||
AgentRunService,
|
||||
provideWorkspaceScopedRepository(RoleTargetEntity),
|
||||
provideWorkspaceScopedRepository(AgentEntity),
|
||||
],
|
||||
exports: [
|
||||
AgentAsyncExecutorService,
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
import { type RunAgentResult } from 'twenty-shared/application';
|
||||
|
||||
@ObjectType('RunAgentResult')
|
||||
export class RunAgentResultDTO implements RunAgentResult {
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
result: object | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
error: string | null;
|
||||
|
||||
@Field()
|
||||
success: boolean;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { type RunAgentInput } from 'twenty-shared/application';
|
||||
|
||||
@InputType('RunAgentInput')
|
||||
export class RunAgentInputDTO implements RunAgentInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
agentUniversalIdentifier: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
prompt: string;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { RunAgentInputDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent.input';
|
||||
import { RunAgentResultDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/run-agent-result.dto';
|
||||
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, SettingsPermissionGuard(PermissionFlagType.AI))
|
||||
@MetadataResolver()
|
||||
export class AgentRunResolver {
|
||||
constructor(private readonly agentRunService: AgentRunService) {}
|
||||
|
||||
@Mutation(() => RunAgentResultDTO)
|
||||
async runAgent(
|
||||
@Args('input') input: RunAgentInputDTO,
|
||||
@AuthWorkspace() workspace: FlatWorkspace,
|
||||
@AuthUserWorkspaceId({ allowUndefined: true })
|
||||
userWorkspaceId: string | undefined,
|
||||
): Promise<RunAgentResultDTO> {
|
||||
return this.agentRunService.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: userWorkspaceId ?? null,
|
||||
input,
|
||||
});
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { AgentRunService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
|
||||
describe('AgentRunService', () => {
|
||||
let service: AgentRunService;
|
||||
let agentRepository: { findOne: jest.Mock };
|
||||
let applicationService: { findById: jest.Mock };
|
||||
let agentAsyncExecutorService: { executeAgent: jest.Mock };
|
||||
|
||||
const workspace = { id: 'workspace-1' } as FlatWorkspace;
|
||||
|
||||
const agent = { id: 'agent-1', applicationId: 'app-1' } as AgentEntity;
|
||||
|
||||
const input = {
|
||||
agentUniversalIdentifier: 'agent-uid',
|
||||
prompt: 'Enrich record 123',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
agentRepository = { findOne: jest.fn().mockResolvedValue(agent) };
|
||||
applicationService = {
|
||||
findById: jest.fn().mockResolvedValue({ id: 'app-1' }),
|
||||
};
|
||||
agentAsyncExecutorService = {
|
||||
executeAgent: jest.fn().mockResolvedValue({
|
||||
result: { response: 'done' },
|
||||
hasNoMoreAvailableCredits: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentRunService,
|
||||
{
|
||||
provide: AgentAsyncExecutorService,
|
||||
useValue: agentAsyncExecutorService,
|
||||
},
|
||||
{
|
||||
provide: ApplicationService,
|
||||
useValue: applicationService,
|
||||
},
|
||||
{
|
||||
provide: getWorkspaceScopedRepositoryToken(AgentEntity),
|
||||
useValue: agentRepository,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(AgentRunService);
|
||||
});
|
||||
|
||||
it('runs the agent found by its universal identifier and returns a success result', async () => {
|
||||
const result = await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
input,
|
||||
});
|
||||
|
||||
expect(agentRepository.findOne).toHaveBeenCalledWith(workspace.id, {
|
||||
where: {
|
||||
universalIdentifier: input.agentUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
expect(applicationService.findById).toHaveBeenCalledWith(
|
||||
agent.applicationId,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
result: { response: 'done' },
|
||||
error: null,
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds the application auth context from the agent application', async () => {
|
||||
await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
input,
|
||||
});
|
||||
|
||||
expect(agentAsyncExecutorService.executeAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authContext: {
|
||||
type: 'application',
|
||||
workspace,
|
||||
application: { id: 'app-1' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an error result when the workspace ran out of credits', async () => {
|
||||
agentAsyncExecutorService.executeAgent.mockResolvedValue({
|
||||
result: { response: 'partial' },
|
||||
hasNoMoreAvailableCredits: true,
|
||||
});
|
||||
|
||||
const result = await service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: 'user-workspace-1',
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
result: null,
|
||||
error: 'AI agent stopped: no more available credits.',
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when no agent matches the identifier', async () => {
|
||||
agentRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
input,
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
|
||||
expect(applicationService.findById).not.toHaveBeenCalled();
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the agent's application cannot be found", async () => {
|
||||
applicationService.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.run({
|
||||
workspace,
|
||||
requestUserWorkspaceId: null,
|
||||
input,
|
||||
}),
|
||||
).rejects.toThrow(/not found/);
|
||||
|
||||
expect(agentAsyncExecutorService.executeAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
type RunAgentInput,
|
||||
type RunAgentResult,
|
||||
} from 'twenty-shared/application';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum';
|
||||
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AgentRunService {
|
||||
constructor(
|
||||
private readonly agentAsyncExecutorService: AgentAsyncExecutorService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
@InjectWorkspaceScopedRepository(AgentEntity)
|
||||
private readonly agentRepository: WorkspaceScopedRepository<AgentEntity>,
|
||||
) {}
|
||||
|
||||
async run({
|
||||
workspace,
|
||||
requestUserWorkspaceId,
|
||||
input,
|
||||
}: {
|
||||
workspace: FlatWorkspace;
|
||||
requestUserWorkspaceId: string | null;
|
||||
input: RunAgentInput;
|
||||
}): Promise<RunAgentResult> {
|
||||
const agent = await this.agentRepository.findOne(workspace.id, {
|
||||
where: {
|
||||
universalIdentifier: input.agentUniversalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent) {
|
||||
throw new NotFoundException(
|
||||
`Agent ${input.agentUniversalIdentifier} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.applicationService.findById(
|
||||
agent.applicationId,
|
||||
);
|
||||
|
||||
if (!application) {
|
||||
throw new NotFoundException(
|
||||
`Application ${agent.applicationId} not found for agent ${input.agentUniversalIdentifier}`,
|
||||
);
|
||||
}
|
||||
|
||||
const authContext: WorkspaceAuthContext = {
|
||||
type: 'application',
|
||||
workspace,
|
||||
application,
|
||||
};
|
||||
|
||||
const { result, hasNoMoreAvailableCredits } =
|
||||
await this.agentAsyncExecutorService.executeAgent({
|
||||
agent,
|
||||
userPrompt: input.prompt,
|
||||
authContext,
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId: requestUserWorkspaceId,
|
||||
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
});
|
||||
|
||||
if (hasNoMoreAvailableCredits) {
|
||||
return {
|
||||
result: null,
|
||||
error: 'AI agent stopped: no more available credits.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { result, error: null, success: true };
|
||||
}
|
||||
}
|
||||
+6
-12
@@ -1,12 +1,6 @@
|
||||
import { type AgentResponseSchema } from 'twenty-shared/ai';
|
||||
|
||||
export type AgentResponseFormatType = AgentResponseFormat['type'];
|
||||
|
||||
export type AgentTextResponseFormat = { type: 'text' };
|
||||
export type AgentJsonResponseFormat = {
|
||||
type: 'json';
|
||||
schema: AgentResponseSchema;
|
||||
};
|
||||
export type AgentResponseFormat =
|
||||
| AgentTextResponseFormat
|
||||
| AgentJsonResponseFormat;
|
||||
export type {
|
||||
AgentResponseFormat,
|
||||
AgentResponseFormatType,
|
||||
AgentTextResponseFormat,
|
||||
AgentJsonResponseFormat,
|
||||
} from 'twenty-shared/ai';
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.
|
||||
import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module';
|
||||
import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module';
|
||||
import { AiWorkspaceStatsModule } from 'src/engine/metadata-modules/ai/ai-workspace-stats/ai-workspace-stats.module';
|
||||
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
|
||||
import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
|
||||
@@ -45,6 +46,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
|
||||
AiChatModule,
|
||||
AiGenerateTextModule,
|
||||
AiWorkspaceStatsModule,
|
||||
ApplicationConnectionsModule,
|
||||
MinimalMetadataModule,
|
||||
ViewModule,
|
||||
WorkspaceMetadataVersionModule,
|
||||
|
||||
Reference in New Issue
Block a user