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:
martmull
2026-06-04 18:18:27 +02:00
committed by GitHub
parent 36b654bab3
commit c2ca90c255
38 changed files with 1444 additions and 461 deletions
@@ -2132,6 +2132,18 @@ type FieldConnection {
edges: [FieldEdge!]!
}
type AppConnection {
id: ID!
providerName: String!
name: String!
handle: String!
visibility: String!
userWorkspaceId: String!
accessToken: String!
scopes: [String!]!
authFailedAt: String
}
type ResendEmailVerificationToken {
success: Boolean!
}
@@ -2529,6 +2541,12 @@ type AgentMessagePart {
createdAt: DateTime!
}
type RunAgentResult {
result: JSON
error: String
success: Boolean!
}
type ChannelSyncSuccess {
success: Boolean!
}
@@ -3011,6 +3029,8 @@ type Query {
myMessageChannels(connectedAccountId: UUID): [MessageChannel!]!
myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]!
minimalMetadata: MinimalMetadata!
appConnections(filter: ListAppConnectionsInput): [AppConnection!]!
appConnection(id: ID!): AppConnection!
findWorkspaceAiStats: WorkspaceAiStats!
chatThreads: [AgentChatThread!]!
chatThread(id: UUID!): AgentChatThread!
@@ -3065,6 +3085,12 @@ input AgentIdInput {
id: UUID!
}
input ListAppConnectionsInput {
providerName: String
userWorkspaceId: String
visibility: String
}
input EventLogQueryInput {
table: EventLogTable!
filters: EventLogFiltersInput
@@ -3238,6 +3264,7 @@ type Mutation {
upsertRowLevelPermissionPredicates(input: UpsertRowLevelPermissionPredicatesInput!): UpsertRowLevelPermissionPredicatesResult!
assignRoleToAgent(agentId: UUID!, roleId: UUID!): Boolean!
removeRoleFromAgent(agentId: UUID!): Boolean!
runAgent(input: RunAgentInput!): RunAgentResult!
createWebhook(input: CreateWebhookInput!): Webhook!
updateWebhook(input: UpdateWebhookInput!): Webhook!
deleteWebhook(id: UUID!): Webhook!
@@ -4093,6 +4120,11 @@ input RowLevelPermissionPredicateGroupInput {
positionInRowLevelPermissionPredicateGroup: Float
}
input RunAgentInput {
agentUniversalIdentifier: String!
prompt: String!
}
input CreateWebhookInput {
id: UUID
targetUrl: String!
@@ -1762,6 +1762,19 @@ export interface FieldConnection {
__typename: 'FieldConnection'
}
export interface AppConnection {
id: Scalars['ID']
providerName: Scalars['String']
name: Scalars['String']
handle: Scalars['String']
visibility: Scalars['String']
userWorkspaceId: Scalars['String']
accessToken: Scalars['String']
scopes: Scalars['String'][]
authFailedAt?: Scalars['String']
__typename: 'AppConnection'
}
export interface ResendEmailVerificationToken {
success: Scalars['Boolean']
__typename: 'ResendEmailVerificationToken'
@@ -2211,6 +2224,13 @@ export interface AgentMessagePart {
__typename: 'AgentMessagePart'
}
export interface RunAgentResult {
result?: Scalars['JSON']
error?: Scalars['String']
success: Scalars['Boolean']
__typename: 'RunAgentResult'
}
export interface ChannelSyncSuccess {
success: Scalars['Boolean']
__typename: 'ChannelSyncSuccess'
@@ -2607,6 +2627,8 @@ export interface Query {
myMessageChannels: MessageChannel[]
myCalendarChannels: CalendarChannel[]
minimalMetadata: MinimalMetadata
appConnections: AppConnection[]
appConnection: AppConnection
findWorkspaceAiStats: WorkspaceAiStats
chatThreads: AgentChatThread[]
chatThread: AgentChatThread
@@ -2767,6 +2789,7 @@ export interface Mutation {
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult
assignRoleToAgent: Scalars['Boolean']
removeRoleFromAgent: Scalars['Boolean']
runAgent: RunAgentResult
createWebhook: Webhook
updateWebhook: Webhook
deleteWebhook: Webhook
@@ -4722,6 +4745,20 @@ export interface FieldConnectionGenqlSelection{
__scalar?: boolean | number
}
export interface AppConnectionGenqlSelection{
id?: boolean | number
providerName?: boolean | number
name?: boolean | number
handle?: boolean | number
visibility?: boolean | number
userWorkspaceId?: boolean | number
accessToken?: boolean | number
scopes?: boolean | number
authFailedAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ResendEmailVerificationTokenGenqlSelection{
success?: boolean | number
__typename?: boolean | number
@@ -5226,6 +5263,14 @@ export interface AgentMessagePartGenqlSelection{
__scalar?: boolean | number
}
export interface RunAgentResultGenqlSelection{
result?: boolean | number
error?: boolean | number
success?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ChannelSyncSuccessGenqlSelection{
success?: boolean | number
__typename?: boolean | number
@@ -5646,6 +5691,8 @@ export interface QueryGenqlSelection{
myMessageChannels?: (MessageChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} })
minimalMetadata?: MinimalMetadataGenqlSelection
appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} })
appConnection?: (AppConnectionGenqlSelection & { __args: {id: Scalars['ID']} })
findWorkspaceAiStats?: WorkspaceAiStatsGenqlSelection
chatThreads?: AgentChatThreadGenqlSelection
chatThread?: (AgentChatThreadGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -5698,6 +5745,8 @@ export interface AgentIdInput {
/** The id of the agent. */
id: Scalars['UUID']}
export interface ListAppConnectionsInput {providerName?: (Scalars['String'] | null),userWorkspaceId?: (Scalars['String'] | null),visibility?: (Scalars['String'] | null)}
export interface EventLogQueryInput {table: EventLogTable,filters?: (EventLogFiltersInput | null),first?: (Scalars['Int'] | null),after?: (Scalars['String'] | null)}
export interface EventLogFiltersInput {eventType?: (Scalars['String'] | null),userWorkspaceId?: (Scalars['String'] | null),dateRange?: (EventLogDateRangeInput | null),recordId?: (Scalars['String'] | null),objectMetadataId?: (Scalars['String'] | null)}
@@ -5827,6 +5876,7 @@ export interface MutationGenqlSelection{
upsertRowLevelPermissionPredicates?: (UpsertRowLevelPermissionPredicatesResultGenqlSelection & { __args: {input: UpsertRowLevelPermissionPredicatesInput} })
assignRoleToAgent?: { __args: {agentId: Scalars['UUID'], roleId: Scalars['UUID']} }
removeRoleFromAgent?: { __args: {agentId: Scalars['UUID']} }
runAgent?: (RunAgentResultGenqlSelection & { __args: {input: RunAgentInput} })
createWebhook?: (WebhookGenqlSelection & { __args: {input: CreateWebhookInput} })
updateWebhook?: (WebhookGenqlSelection & { __args: {input: UpdateWebhookInput} })
deleteWebhook?: (WebhookGenqlSelection & { __args: {id: Scalars['UUID']} })
@@ -6184,6 +6234,8 @@ export interface RowLevelPermissionPredicateInput {id?: (Scalars['UUID'] | null)
export interface RowLevelPermissionPredicateGroupInput {id?: (Scalars['UUID'] | null),objectMetadataId: Scalars['UUID'],parentRowLevelPermissionPredicateGroupId?: (Scalars['UUID'] | null),logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator,positionInRowLevelPermissionPredicateGroup?: (Scalars['Float'] | null)}
export interface RunAgentInput {agentUniversalIdentifier: Scalars['String'],prompt: Scalars['String']}
export interface CreateWebhookInput {id?: (Scalars['UUID'] | null),targetUrl: Scalars['String'],operations: Scalars['String'][],description?: (Scalars['String'] | null),secret?: (Scalars['String'] | null)}
export interface UpdateWebhookInput {
@@ -7535,6 +7587,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const AppConnection_possibleTypes: string[] = ['AppConnection']
export const isAppConnection = (obj?: { __typename?: any } | null): obj is AppConnection => {
if (!obj?.__typename) throw new Error('__typename is missing in "isAppConnection"')
return AppConnection_possibleTypes.includes(obj.__typename)
}
const ResendEmailVerificationToken_possibleTypes: string[] = ['ResendEmailVerificationToken']
export const isResendEmailVerificationToken = (obj?: { __typename?: any } | null): obj is ResendEmailVerificationToken => {
if (!obj?.__typename) throw new Error('__typename is missing in "isResendEmailVerificationToken"')
@@ -7991,6 +8051,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const RunAgentResult_possibleTypes: string[] = ['RunAgentResult']
export const isRunAgentResult = (obj?: { __typename?: any } | null): obj is RunAgentResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isRunAgentResult"')
return RunAgentResult_possibleTypes.includes(obj.__typename)
}
const ChannelSyncSuccess_possibleTypes: string[] = ['ChannelSyncSuccess']
export const isChannelSyncSuccess = (obj?: { __typename?: any } | null): obj is ChannelSyncSuccess => {
if (!obj?.__typename) throw new Error('__typename is missing in "isChannelSyncSuccess"')
File diff suppressed because it is too large Load Diff
@@ -9,8 +9,8 @@ A **role** is a permission set: which objects an app can read or write, which fi
```ts src/roles/restricted-company-role.ts
import {
defineRole,
PermissionFlag,
STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS,
SystemPermissionFlag,
} from 'twenty-sdk/define';
export default defineRole({
@@ -46,7 +46,7 @@ export default defineRole({
canUpdateFieldValue: false,
},
],
permissionFlags: [PermissionFlag.APPLICATIONS],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
```
@@ -55,7 +55,7 @@ export default defineRole({
When you scaffold a new app, the CLI creates a default role file declared with `defineApplicationRole()`:
```ts src/roles/default-role.ts
import { defineApplicationRole, PermissionFlag } from 'twenty-sdk/define';
import { defineApplicationRole } from 'twenty-sdk/define';
export const DEFAULT_ROLE_UNIVERSAL_IDENTIFIER =
'b648f87b-1d26-4961-b974-0908fd991061';
@@ -74,7 +74,7 @@ export default defineApplicationRole({
canBeAssignedToApiKeys: false,
objectPermissions: [],
fieldPermissions: [],
permissionFlags: [],
permissionFlagUniversalIdentifiers: [],
});
```
@@ -90,5 +90,5 @@ Notes:
- Start from the scaffolded role, then progressively restrict it — the default grants broad read access, which is rarely what you want in production.
- Replace `objectPermissions` and `fieldPermissions` with the exact objects and fields your functions actually need.
- `permissionFlags` control access to platform-level capabilities. Keep them minimal.
- `permissionFlagUniversalIdentifiers` control access to platform-level capabilities. Keep them minimal.
- See a working example: [`hello-world/src/roles/function-role.ts`](https://github.com/twentyhq/twenty/blob/main/packages/twenty-apps/hello-world/src/roles/function-role.ts).
@@ -64,6 +64,86 @@ Key points:
- `description` (optional) provides context about what the agent does.
- `icon` (optional) sets the icon displayed in the UI.
- `modelId` (optional) overrides the default AI model used by the agent.
- `responseFormat` (optional) controls the shape of the agent's output. Defaults to `{ type: 'text' }` for free-form text. Use `{ type: 'json', schema }` to force structured JSON output.
By default an agent returns free-form text. To get structured output, set `responseFormat` to `{ type: 'json' }` and provide a `schema`:
```ts src/agents/structured-agent.ts
import { defineAgent } from 'twenty-sdk/define';
export default defineAgent({
universalIdentifier: 'c4d5e6f7-a8b9-0123-cdef-456789012345',
name: 'lead-scorer',
label: 'Lead Scorer',
prompt: 'Score the lead and explain your reasoning.',
responseFormat: {
type: 'json',
schema: {
type: 'object',
properties: {
score: { type: 'number', description: 'Lead score from 0 to 100' },
summary: { type: 'string', description: 'Short reasoning for the score' },
},
required: ['score', 'summary'],
additionalProperties: false,
},
},
});
```
Schema notes:
- The schema is a flat object: each property's `type` must be a primitive (`string`, `number`, or `boolean`). Nested objects and arrays are not supported.
- `description` (optional) on each property guides the model on what to put there.
- `required` (optional) lists the properties the model must always return.
- `additionalProperties: false` (optional) forbids any property not declared in `properties`.
</Accordion>
<Accordion title="runAgent" description="Run an agent from a logic function">
`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()`:
```ts src/logic-functions/run-enricher.ts
import { runAgent } from 'twenty-sdk/logic-function';
const { result, error, success } = await runAgent({
agentUniversalIdentifier: 'b3c4d5e6-f7a8-9012-bcde-f34567890123',
prompt: 'Enrich House Ad <recordId>: fill empty fields from its listing URL.',
});
```
Key points:
- 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.
- The app's [default role](/developers/extend/apps/config/roles) must grant the
`AI` permission flag — add `SystemPermissionFlag.AI` to its
`permissionFlagUniversalIdentifiers` (or set `canAccessAllTools: true`).
Without it, `runAgent()` fails with a permission error.
- Set a generous `timeoutSeconds` on the logic function — agent runs can take
several seconds.
- `success` is `true` and `result` is non-null when the run completes; on
failure `success` is `false`, `result` is `null`, and `error` holds the
reason (for example, when the workspace ran out of AI credits mid-run).
```ts src/roles/default-role.ts
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
export default defineApplicationRole({
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
label: 'Default function role',
// runAgent() requires the AI permission flag on the app's default role.
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.AI],
});
```
<Warning>
**Avoid loops:** if you call `runAgent()` from a `*.updated` database-event
trigger and the agent updates the same record, scope the trigger with
`updatedFields` to a field the agent never writes (e.g. the source URL), or
guard on whether any target field is still empty before calling `runAgent()`.
</Warning>
</Accordion>
</AccordionGroup>
@@ -256,6 +256,19 @@ export type ApiKeyToken = {
token: Scalars['String'];
};
export type AppConnection = {
__typename?: 'AppConnection';
accessToken: Scalars['String'];
authFailedAt?: Maybe<Scalars['String']>;
handle: Scalars['String'];
id: Scalars['ID'];
name: Scalars['String'];
providerName: Scalars['String'];
scopes: Array<Scalars['String']>;
userWorkspaceId: Scalars['String'];
visibility: Scalars['String'];
};
export type AppToken = {
__typename?: 'AppToken';
createdAt: Scalars['DateTime'];
@@ -2116,6 +2129,12 @@ export type LineChartSeries = {
label: Scalars['String'];
};
export type ListAppConnectionsInput = {
providerName?: InputMaybe<Scalars['String']>;
userWorkspaceId?: InputMaybe<Scalars['String']>;
visibility?: InputMaybe<Scalars['String']>;
};
export type Location = {
__typename?: 'Location';
lat?: Maybe<Scalars['Float']>;
@@ -2495,6 +2514,7 @@ export type Mutation = {
resetPageLayoutWidgetToDefault: PageLayoutWidget;
revokeApiKey?: Maybe<ApiKey>;
rotateApplicationRegistrationClientSecret: RotateClientSecret;
runAgent: RunAgentResult;
runEvaluationInput: AgentTurn;
runWorkspaceMigration: Scalars['Boolean'];
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess;
@@ -3194,6 +3214,11 @@ export type MutationRotateApplicationRegistrationClientSecretArgs = {
};
export type MutationRunAgentArgs = {
input: RunAgentInput;
};
export type MutationRunEvaluationInputArgs = {
agentId: Scalars['UUID'];
input: Scalars['String'];
@@ -4114,6 +4139,8 @@ export type Query = {
agentTurns: Array<AgentTurn>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
appConnection: AppConnection;
appConnections: Array<AppConnection>;
applicationConnectionProviders: Array<ApplicationConnectionProvider>;
applicationRegistrationTarballUrl?: Maybe<Scalars['String']>;
barChartData: BarChartData;
@@ -4223,6 +4250,16 @@ export type QueryApiKeyArgs = {
};
export type QueryAppConnectionArgs = {
id: Scalars['ID'];
};
export type QueryAppConnectionsArgs = {
filter?: InputMaybe<ListAppConnectionsInput>;
};
export type QueryApplicationConnectionProvidersArgs = {
applicationId: Scalars['UUID'];
};
@@ -4735,6 +4772,18 @@ export enum RowLevelPermissionPredicateOperand {
VECTOR_SEARCH = 'VECTOR_SEARCH'
}
export type RunAgentInput = {
agentUniversalIdentifier: Scalars['String'];
prompt: Scalars['String'];
};
export type RunAgentResult = {
__typename?: 'RunAgentResult';
error?: Maybe<Scalars['String']>;
result?: Maybe<Scalars['JSON']>;
success: Scalars['Boolean'];
};
export type SsoConnection = {
__typename?: 'SSOConnection';
id: Scalars['UUID'];
@@ -9,6 +9,7 @@ import {
IconKey,
IconSettings,
IconSettingsAutomation,
IconSparkles,
IconTool,
IconUsers,
} from 'twenty-ui/display';
@@ -114,6 +115,10 @@ export const buildPermissionSummaryFromRoleManifest = (
label: 'Manage API keys and webhooks',
Icon: IconCode,
},
[SystemPermissionFlag.AI]: {
label: 'Run AI agents',
Icon: IconSparkles,
},
};
for (const flag of otherFlags) {
@@ -21,6 +21,7 @@ export default defineAgent({
label: '${name}',
description: 'Add a description for your agent',
prompt: 'Add the agent system prompt here',
responseFormat: {type: 'text'},
});
`;
};
@@ -4,6 +4,7 @@ import { type AgentManifest } from 'twenty-shared/application';
export const defineAgent: DefineEntity<AgentManifest> = (config) => {
const errors: string[] = [];
const warnings: string[] = [];
if (!config.universalIdentifier) {
errors.push('Agent must have a universalIdentifier');
@@ -21,5 +22,11 @@ export const defineAgent: DefineEntity<AgentManifest> = (config) => {
errors.push('Agent must have a prompt');
}
return createValidationResult({ config, errors });
if (!config.responseFormat) {
warnings.push(
`Agent '${config.name}' has no responseFormat, it will default to { type: 'text' }. Set it explicitly to control the agent's output shape.`,
);
}
return createValidationResult({ config, errors, warnings });
};
@@ -0,0 +1,103 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import { runAgent } from '@/sdk/logic-function/agents/run-agent';
describe('runAgent', () => {
let fetchSpy: MockInstance<typeof fetch>;
beforeEach(() => {
process.env.TWENTY_API_URL = 'https://api.test';
process.env.TWENTY_APP_ACCESS_TOKEN = 'app-token';
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
delete process.env.TWENTY_API_URL;
delete process.env.TWENTY_APP_ACCESS_TOKEN;
fetchSpy.mockRestore();
});
it('POSTs the runAgent mutation to /metadata and returns the result', async () => {
const payload = {
result: { response: 'done' },
error: null,
success: true,
};
fetchSpy.mockResolvedValue(
new Response(JSON.stringify({ data: { runAgent: payload } }), {
status: 200,
}),
);
const result = await runAgent({
agentUniversalIdentifier: 'agent-uid',
prompt: 'Enrich record 123',
});
expect(result).toEqual(payload);
const [url, requestInit] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.test/metadata');
expect(requestInit).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer app-token',
}),
}),
);
const sentBody = JSON.parse(requestInit?.body as string);
expect(sentBody.query).toContain('runAgent(input: $input)');
expect(sentBody.variables).toEqual({
input: {
agentUniversalIdentifier: 'agent-uid',
prompt: 'Enrich record 123',
},
});
});
it('surfaces GraphQL errors as a regular Error', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify({ errors: [{ message: 'Agent not found' }] }),
{ status: 200 },
),
);
await expect(
runAgent({ agentUniversalIdentifier: 'a', prompt: 'p' }),
).rejects.toThrow(/Agent not found/);
});
it('surfaces non-2xx HTTP responses as a regular Error', async () => {
fetchSpy.mockResolvedValue(
new Response('boom', { status: 500, statusText: 'Server Error' }),
);
await expect(
runAgent({ agentUniversalIdentifier: 'a', prompt: 'p' }),
).rejects.toThrow(/HTTP 500/);
});
it('throws when the runtime env vars are missing', async () => {
delete process.env.TWENTY_API_URL;
await expect(
runAgent({ agentUniversalIdentifier: 'a', prompt: 'p' }),
).rejects.toThrow(/requires the app runtime env vars/);
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,31 @@
import {
type RunAgentInput,
type RunAgentResult,
} from 'twenty-shared/application';
import { postGraphqlRequest } from '@/sdk/logic-function/utils/post-graphql-request.util';
const RUN_AGENT_MUTATION = `
mutation RunAgent($input: RunAgentInput!) {
runAgent(input: $input) {
result
error
success
}
}
`;
export const runAgent = async (
input: RunAgentInput,
): Promise<RunAgentResult> => {
const { runAgent: result } = await postGraphqlRequest<
{ input: RunAgentInput },
{ runAgent: RunAgentResult }
>({
query: RUN_AGENT_MUTATION,
variables: { input },
caller: 'runAgent',
});
return result;
};
@@ -42,37 +42,48 @@ describe('getConnection', () => {
fetchSpy.mockRestore();
});
it('POSTs the id to /apps/connections/get and returns the response', async () => {
it('queries appConnection by id over GraphQL and returns the response', async () => {
const connection = buildConnection({ id: 'persisted' });
fetchSpy.mockResolvedValue(
new Response(JSON.stringify(connection), { status: 200 }),
new Response(JSON.stringify({ data: { appConnection: connection } }), {
status: 200,
}),
);
const result = await getConnection('persisted');
expect(result).toEqual(connection);
expect(fetchSpy).toHaveBeenCalledWith(
'https://api.test/apps/connections/get',
const [url, requestInit] = fetchSpy.mock.calls[0];
expect(url).toBe('https://api.test/metadata');
expect(requestInit).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ id: 'persisted' }),
headers: expect.objectContaining({
Authorization: 'Bearer app-token',
}),
}),
);
const sentBody = JSON.parse(requestInit?.body as string);
expect(sentBody.query).toContain('appConnection(id: $id)');
expect(sentBody.variables).toEqual({ id: 'persisted' });
});
it('throws AppConnectionAuthFailedError when the connection needs reconnect', async () => {
fetchSpy.mockResolvedValue(
new Response(
JSON.stringify(
buildConnection({
id: 'broken',
authFailedAt: '2024-01-02T00:00:00.000Z',
}),
),
JSON.stringify({
data: {
appConnection: buildConnection({
id: 'broken',
authFailedAt: '2024-01-02T00:00:00.000Z',
}),
},
}),
{ status: 200 },
),
);
@@ -1,20 +1,9 @@
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
// Resolution rule for an HTTP-route handler that wants "the request user's
// connection, or fall back to a workspace-shared one." Pure function — no
// network. Pass it the result of `listConnections` and the trigger event.
//
// The plan-of-record for picking a connection is documented in the v3 plan
// notes; this utility encodes the most common case so handlers don't have
// to repeat the same `find(...) ?? find(...)` chain.
//
// Returns `null` when no candidate exists — caller decides whether that's
// a 4xx for the end user or a hard error.
export const findConnectionForRequest = (
connections: AppConnection[],
event: { userWorkspaceId: string | null },
): AppConnection | null => {
// 1. Personal credential of the request user (highest specificity).
if (event.userWorkspaceId !== null) {
const personal = connections.find(
(connection) =>
@@ -27,7 +16,6 @@ export const findConnectionForRequest = (
}
}
// 2. Any workspace-shared credential (team-managed service account).
const workspaceShared = connections.find(
(connection) => connection.visibility === 'workspace',
);
@@ -1,25 +1,36 @@
import { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
import { postConnectionsEndpoint } from '@/sdk/logic-function/connections/utils/post-connections-endpoint.util';
import { postGraphqlRequest } from '@/sdk/logic-function/utils/post-graphql-request.util';
const GET_APP_CONNECTION_QUERY = `
query GetAppConnection($id: ID!) {
appConnection(id: $id) {
id
providerName
name
handle
visibility
userWorkspaceId
accessToken
scopes
authFailedAt
}
}
`;
// Look up a single connection by id. The id is stable across reconnects
// (the row keeps its id when the user clicks "Reconnect"), so apps can
// safely persist it in their own data and call this helper on each
// invocation to retrieve a fresh access token.
//
// Throws `AppConnectionAuthFailedError` if the credential is in a
// permanent-failure state (the user must reconnect from the app's
// settings tab). Throws a regular `Error` for any other failure
// (network, not-found, transient refresh failure).
export const getConnection = async (id: string): Promise<AppConnection> => {
const connection = await postConnectionsEndpoint<
const { appConnection } = await postGraphqlRequest<
{ id: string },
AppConnection
>('get', { id });
{ appConnection: AppConnection }
>({
query: GET_APP_CONNECTION_QUERY,
variables: { id },
caller: 'getConnection',
});
if (connection.authFailedAt !== null) {
throw new AppConnectionAuthFailedError(connection.id);
if (appConnection.authFailedAt !== null) {
throw new AppConnectionAuthFailedError(appConnection.id);
}
return connection;
return appConnection;
};
@@ -1,26 +1,39 @@
import { type AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
import { postConnectionsEndpoint } from '@/sdk/logic-function/connections/utils/post-connections-endpoint.util';
import { postGraphqlRequest } from '@/sdk/logic-function/utils/post-graphql-request.util';
const LIST_APP_CONNECTIONS_QUERY = `
query ListAppConnections($filter: ListAppConnectionsInput) {
appConnections(filter: $filter) {
id
providerName
name
handle
visibility
userWorkspaceId
accessToken
scopes
authFailedAt
}
}
`;
export type ListConnectionsFilter = {
// Provider name as declared on `defineConnectionProvider({ name })`.
providerName?: string;
// Restrict to credentials owned by a specific user. Useful in cron
// triggers when picking a service-account user via app config.
userWorkspaceId?: string;
// Restrict by row visibility — 'user' (private) or 'workspace' (shared).
visibility?: 'user' | 'workspace';
};
// Returns every connection owned by the running app, optionally filtered.
// The server refreshes each access token on read, so the returned values
// are usable immediately. When the running execution carries a user
// context (HTTP-route trigger with `isAuthRequired`, tool calls, etc.),
// `scope: 'user'` connections belonging to other users are filtered out
// server-side. Cron and database-event triggers see all connections.
export const listConnections = async (
filter: ListConnectionsFilter = {},
): Promise<AppConnection[]> =>
postConnectionsEndpoint<ListConnectionsFilter, AppConnection[]>(
'list',
filter,
);
): Promise<AppConnection[]> => {
const { appConnections } = await postGraphqlRequest<
{ filter: ListConnectionsFilter },
{ appConnections: AppConnection[] }
>({
query: LIST_APP_CONNECTIONS_QUERY,
variables: { filter },
caller: 'listConnections',
});
return appConnections;
};
@@ -1,40 +0,0 @@
import {
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
} from 'twenty-shared/application';
// Shared transport for `/apps/connections/*` endpoints. Centralises the
// env-var check, auth header, and HTTP error translation so each helper
// stays focused on its own input/output shape.
export const postConnectionsEndpoint = async <TBody, TResponse>(
path: 'list' | 'get',
body: TBody,
): Promise<TResponse> => {
const apiUrl = process.env[DEFAULT_API_URL_NAME];
const accessToken = process.env[DEFAULT_APP_ACCESS_TOKEN_NAME];
if (!apiUrl || !accessToken) {
throw new Error(
`${path === 'list' ? 'listConnections' : 'getConnection'}() requires the app runtime env vars ` +
`${DEFAULT_API_URL_NAME} and ${DEFAULT_APP_ACCESS_TOKEN_NAME}.`,
);
}
const response = await fetch(`${apiUrl}/apps/connections/${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(
`${path === 'list' ? 'listConnections' : 'getConnection'}() failed: ` +
`HTTP ${response.status} ${response.statusText}`,
);
}
return (await response.json()) as TResponse;
};
@@ -44,5 +44,9 @@ export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/lis
export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
export { runAgent } from '@/sdk/logic-function/agents/run-agent';
export type { RunAgentInput, RunAgentResult } from 'twenty-shared/application';
export { Response } from '@/sdk/logic-function/response';
export type { ResponseInit } from '@/sdk/logic-function/response';
@@ -0,0 +1,56 @@
import {
DEFAULT_API_URL_NAME,
DEFAULT_APP_ACCESS_TOKEN_NAME,
} from 'twenty-shared/application';
export const postGraphqlRequest = async <TVariables, TData>({
query,
variables,
caller,
}: {
query: string;
variables: TVariables;
caller: string;
}): Promise<TData> => {
const apiUrl = process.env[DEFAULT_API_URL_NAME];
const accessToken = process.env[DEFAULT_APP_ACCESS_TOKEN_NAME];
if (!apiUrl || !accessToken) {
throw new Error(
`${caller}() requires the app runtime env vars ` +
`${DEFAULT_API_URL_NAME} and ${DEFAULT_APP_ACCESS_TOKEN_NAME}.`,
);
}
const response = await fetch(`${apiUrl}/metadata`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(
`${caller}() failed: HTTP ${response.status} ${response.statusText}`,
);
}
const body = (await response.json()) as {
data?: TData;
errors?: { message: string }[];
};
if (body.errors && body.errors.length > 0) {
throw new Error(
`${caller}() failed: ${body.errors.map((error) => error.message).join(', ')}`,
);
}
if (!body.data) {
throw new Error(`${caller}() failed: response contained no data.`);
}
return body.data;
};
@@ -22,9 +22,10 @@ import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
// On-demand connection lookup for app logic functions. Authenticated via the
// application access token (already injected into the function runtime as
// TWENTY_APP_ACCESS_TOKEN). Apps can only list their own connections.
/** @deprecated Superseded by the `appConnections` / `appConnection` GraphQL
* queries on the metadata schema (ApplicationConnectionsResolver). The SDK
* helpers (`listConnections`, `getConnection`) now call GraphQL. Kept for
* backward compatibility with already-deployed app runtimes. */
@Controller('apps/connections')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.controller';
import { ApplicationConnectionsResolver } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.resolver';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -25,7 +26,10 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
RefreshTokensManagerModule,
ConnectedAccountTokenEncryptionModule,
],
providers: [ApplicationConnectionsListService],
providers: [
ApplicationConnectionsListService,
ApplicationConnectionsResolver,
],
controllers: [ApplicationConnectionsController],
exports: [ApplicationConnectionsListService],
})
@@ -0,0 +1,54 @@
import { UseGuards } from '@nestjs/common';
import { Args, ID, Query } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AppConnectionObjectDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.object';
import { ListAppConnectionsInput } from 'src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.input';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { AuthApplication } from 'src/engine/decorators/auth/auth-application.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
@MetadataResolver()
export class ApplicationConnectionsResolver {
constructor(
private readonly listService: ApplicationConnectionsListService,
) {}
@Query(() => [AppConnectionObjectDto])
async appConnections(
@AuthApplication() application: FlatApplication,
@AuthWorkspace() workspace: FlatWorkspace,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Args('filter', { nullable: true }) filter?: ListAppConnectionsInput,
): Promise<AppConnectionObjectDto[]> {
return this.listService.list({
applicationId: application.id,
workspaceId: workspace.id,
requestUserWorkspaceId: userWorkspaceId ?? null,
filter: filter ?? {},
});
}
@Query(() => AppConnectionObjectDto)
async appConnection(
@AuthApplication() application: FlatApplication,
@AuthWorkspace() workspace: FlatWorkspace,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@Args('id', { type: () => ID }) id: string,
): Promise<AppConnectionObjectDto> {
return this.listService.getOne({
applicationId: application.id,
workspaceId: workspace.id,
requestUserWorkspaceId: userWorkspaceId ?? null,
id,
});
}
}
@@ -0,0 +1,33 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { type AppConnection } from 'twenty-shared/application';
@ObjectType('AppConnection')
export class AppConnectionObjectDto implements AppConnection {
@Field(() => ID)
id: string;
@Field()
providerName: string;
@Field()
name: string;
@Field()
handle: string;
@Field(() => String)
visibility: 'user' | 'workspace';
@Field()
userWorkspaceId: string;
@Field()
accessToken: string;
@Field(() => [String])
scopes: string[];
@Field(() => String, { nullable: true })
authFailedAt: string | null;
}
@@ -0,0 +1,21 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
@InputType('ListAppConnectionsInput')
export class ListAppConnectionsInput {
@IsString()
@IsOptional()
@Field({ nullable: true })
providerName?: string;
@IsUUID()
@IsOptional()
@Field({ nullable: true })
userWorkspaceId?: string;
@IsIn(['user', 'workspace'])
@IsOptional()
@Field(() => String, { nullable: true })
visibility?: 'user' | 'workspace';
}
@@ -22,7 +22,7 @@ export const fromAgentManifestToUniversalFlatAgent = ({
description: agentManifest.description ?? null,
prompt: agentManifest.prompt,
modelId: (agentManifest.modelId as ModelId) ?? AUTO_SELECT_SMART_MODEL_ID,
responseFormat: { type: 'text' },
responseFormat: agentManifest.responseFormat ?? { type: 'text' },
modelConfiguration: null,
evaluationInputs: [],
isCustom: false,
@@ -0,0 +1,25 @@
import {
type ExecutionContext,
ForbiddenException,
createParamDecorator,
} from '@nestjs/common';
import { getRequest } from 'src/utils/extract-request';
interface DecoratorOptions {
allowUndefined?: boolean;
}
export const AuthApplication = createParamDecorator(
(options: DecoratorOptions | undefined, ctx: ExecutionContext) => {
const request = getRequest(ctx);
if (!options?.allowUndefined && !request.application) {
throw new ForbiddenException(
'This endpoint requires an APPLICATION_ACCESS token.',
);
}
return request.application;
},
);
@@ -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,
@@ -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;
}
@@ -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;
}
@@ -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,
});
}
}
@@ -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();
});
});
@@ -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 };
}
}
@@ -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,
+6
View File
@@ -15,6 +15,12 @@ export { DATA_RESIDENCY_KEYS } from './constants/data-residency.const';
export type { NativeAiSdkProviderId } from './constants/native-ai-sdk-provider-ids.const';
export { NATIVE_AI_SDK_PROVIDER_IDS } from './constants/native-ai-sdk-provider-ids.const';
export { ToolCategory } from './constants/tool-category.const';
export type {
AgentResponseFormatType,
AgentTextResponseFormat,
AgentJsonResponseFormat,
AgentResponseFormat,
} from './types/agent-response-format.type';
export type {
AgentResponseFieldType,
AgentResponseSchema,
@@ -0,0 +1,12 @@
import { type AgentResponseSchema } from '@/ai/types/agent-response-schema.type';
export type AgentResponseFormatType = AgentResponseFormat['type'];
export type AgentTextResponseFormat = { type: 'text' };
export type AgentJsonResponseFormat = {
type: 'json';
schema: AgentResponseSchema;
};
export type AgentResponseFormat =
| AgentTextResponseFormat
| AgentJsonResponseFormat;
@@ -1,3 +1,4 @@
import { type AgentResponseFormat } from '@/ai/types/agent-response-format.type';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
export type AgentManifest = SyncableEntityOptions & {
@@ -7,4 +8,5 @@ export type AgentManifest = SyncableEntityOptions & {
description?: string;
prompt: string;
modelId?: string;
responseFormat?: AgentResponseFormat;
};
@@ -66,6 +66,7 @@ export type {
FieldPermissionManifest,
RoleManifest,
} from './roleManifestType';
export type { RunAgentInput, RunAgentResult } from './runAgentType';
export type { ServerVariables } from './server-variables.type';
export type { SkillManifest } from './skillManifestType';
export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType';
@@ -0,0 +1,10 @@
export type RunAgentInput = {
agentUniversalIdentifier: string;
prompt: string;
};
export type RunAgentResult = {
result: object | null;
error: string | null;
success: boolean;
};