Files
twenty/packages/twenty-server/test/integration/ai/suites/workflow-agent-role-assignment-persistence.integration-spec.ts
T
nitin 996cdaf3ff refactor(agents): split tool resolution into native and action rails (#20331)
## Summary

Splits AI agent tool resolution into two independent rails:

- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.

Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).

## Notable changes worth calling out

**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).

**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.

**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.

**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.

## Deferred to follow-ups

- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.

## Conscious non-decisions

- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
2026-05-28 22:08:05 +02:00

121 lines
3.8 KiB
TypeScript

import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant';
import { createOneAgent } from 'test/integration/metadata/suites/agent/utils/create-one-agent.util';
import { deleteOneAgent } from 'test/integration/metadata/suites/agent/utils/delete-one-agent.util';
import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util';
import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util';
describe('Workflow agent role assignment persistence (integration)', () => {
let agentWithRoleId: string;
let agentWithoutRoleId: string;
let roleId: string;
beforeAll(async () => {
const { data: roleData } = await createOneRole({
expectToFail: false,
input: {
label: 'Workflow Agent Assignment Test Role',
description:
'Role used to verify workflow agent role assignment persistence',
canUpdateAllSettings: false,
canAccessAllTools: false,
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
canSoftDeleteAllObjectRecords: false,
canDestroyAllObjectRecords: false,
canBeAssignedToUsers: false,
canBeAssignedToAgents: true,
canBeAssignedToApiKeys: false,
},
});
roleId = roleData.createOneRole.id;
const { data: agentWithRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Role-Assigned Workflow Agent',
prompt: 'Test prompt',
modelId: 'openai/gpt-4.1',
roleId,
},
});
agentWithRoleId = agentWithRoleData.createOneAgent.id;
const { data: agentWithoutRoleData } = await createOneAgent({
expectToFail: false,
input: {
label: 'Roleless Workflow Agent',
prompt: 'Test prompt',
modelId: 'openai/gpt-4.1',
},
});
agentWithoutRoleId = agentWithoutRoleData.createOneAgent.id;
});
afterAll(async () => {
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithRoleId },
});
await deleteOneAgent({
expectToFail: false,
input: { id: agentWithoutRoleId },
});
await deleteOneRole({
expectToFail: false,
input: { idToDelete: roleId },
});
});
it('persists a role target row when an agent is created with a roleId', async () => {
const rows = await global.testDataSource.query(
`SELECT "roleId" FROM "core"."roleTarget"
WHERE "agentId" = $1 AND "workspaceId" = $2`,
[agentWithRoleId, SEED_APPLE_WORKSPACE_ID],
);
expect(rows).toHaveLength(1);
expect(rows[0].roleId).toBe(roleId);
});
it('resolves the persisted agent role through the role target repository', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget?.roleId).toBe(roleId);
});
it('does not create a role target row for an agent with no role assignment', async () => {
const roleTargetRepository = global.app.get<Repository<RoleTargetEntity>>(
getRepositoryToken(RoleTargetEntity),
);
const roleTarget = await roleTargetRepository.findOne({
where: {
agentId: agentWithoutRoleId,
workspaceId: SEED_APPLE_WORKSPACE_ID,
},
select: ['roleId'],
});
expect(roleTarget).toBeNull();
});
});