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:
+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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user