From 996cdaf3ffd18babd798cf24971e80dca672d32f Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 29 May 2026 01:38:05 +0530 Subject: [PATCH] refactor(agents): split tool resolution into native and action rails (#20331) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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>`). 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 Co-authored-by: Félix Malfait --- .../SettingsAgentModelCapabilities.tsx | 9 +- .../useActionRolePermissionFlagConfig.ts | 11 ++ .../services/client-config.service.ts | 30 +--- .../native/native-tool-binder.interface.ts | 14 -- .../tool-provider/tool-provider.module.ts | 4 +- .../agent-async-executor.service.spec.ts | 147 ++++++++++++++++++ .../services/agent-async-executor.service.ts | 127 +++++++-------- .../services/chat-execution.service.ts | 23 ++- .../ai/ai-models/ai-models.module.ts | 3 + ...native-model-tools-by-sdk-package.const.ts | 43 +++++ .../__tests__/ai-model-config.service.spec.ts | 134 ++++++++++++++++ .../services/ai-model-config.service.ts | 102 +++++++++--- .../services/native-tool-binder.interface.ts | 12 ++ .../services}/native-tool-binder.service.ts | 10 +- .../types/native-model-binding.type.ts | 7 + .../types/native-model-tool-key.type.ts | 1 + .../types/native-model-tool-options.type.ts | 8 +- .../types/native-model-tools.type.ts | 15 ++ .../get-native-model-capabilities.util.ts | 17 ++ ...native-model-tools-for-sdk-package.util.ts | 9 ++ .../ai-agent/ai-agent.workflow-action.ts | 1 - ...assignment-persistence.integration-spec.ts | 120 ++++++++++++++ 22 files changed, 691 insertions(+), 156 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.interface.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts rename packages/twenty-server/src/engine/{core-modules/tool-provider/native => metadata-modules/ai/ai-models/services}/native-tool-binder.service.ts (65%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util.ts create mode 100644 packages/twenty-server/test/integration/ai/suites/workflow-agent-role-assignment-persistence.integration-spec.ts diff --git a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx index 07303056f8..4f62aa2e27 100644 --- a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx +++ b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx @@ -69,7 +69,10 @@ export const SettingsAgentModelCapabilities = ({ return null; } - if (!nativeCapabilities.webSearch && !nativeCapabilities.twitterSearch) { + const showNativeWebSearch = nativeCapabilities.webSearch; + const showNativeTwitterSearch = nativeCapabilities.twitterSearch; + + if (!showNativeWebSearch && !showNativeTwitterSearch) { return null; } @@ -91,7 +94,7 @@ export const SettingsAgentModelCapabilities = ({ }; const capabilities = [ - ...(nativeCapabilities.webSearch + ...(showNativeWebSearch ? [ { key: 'webSearch' as const, @@ -101,7 +104,7 @@ export const SettingsAgentModelCapabilities = ({ }, ] : []), - ...(nativeCapabilities.twitterSearch + ...(showNativeTwitterSearch ? [ { key: 'twitterSearch' as const, diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts index 714c51c8b4..561a262f50 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/permission-flags/hooks/useActionRolePermissionFlagConfig.ts @@ -4,6 +4,7 @@ import { useMemo } from 'react'; import { IconApi, IconAt, + IconCode, IconDownload, IconFileExport, IconFileImport, @@ -86,6 +87,16 @@ export const useActionRolePermissionFlagConfig = ({ isRelevantForApiKeys: false, isRelevantForUsers: false, }, + { + key: PermissionFlagType.CODE_INTERPRETER_TOOL, + name: t`Code Interpreter`, + description: t`Run code to analyze files and data`, + Icon: IconCode, + isToolPermission: true, + isRelevantForAgents: true, + isRelevantForApiKeys: false, + isRelevantForUsers: true, + }, { key: PermissionFlagType.IMPORT_CSV, name: t`Import CSV`, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 3c70b5b13d..d945ccaed1 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -2,15 +2,8 @@ import { Injectable } from '@nestjs/common'; import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { type AiSdkPackage } from 'twenty-shared/ai'; import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces/file-storage.interface'; - -import { - AI_SDK_ANTHROPIC, - AI_SDK_BEDROCK, - AI_SDK_OPENAI, -} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface'; import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface'; @@ -18,7 +11,6 @@ import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/main import { type ClientAiModelConfig, type ClientConfig, - type NativeModelCapabilities, } from 'src/engine/core-modules/client-config/client-config.entity'; import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service'; import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const'; @@ -28,6 +20,7 @@ import { AUTO_SELECT_SMART_MODEL_ID, } from 'twenty-shared/constants'; import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const'; +import { getNativeModelCapabilities } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; @Injectable() @@ -39,19 +32,6 @@ export class ClientConfigService { private maintenanceModeService: MaintenanceModeService, ) {} - private deriveNativeCapabilities( - sdkPackage?: AiSdkPackage, - ): NativeModelCapabilities | undefined { - switch (sdkPackage) { - case AI_SDK_OPENAI: - case AI_SDK_ANTHROPIC: - case AI_SDK_BEDROCK: - return { webSearch: true }; - default: - return undefined; - } - } - private isCloudflareIntegrationEnabled(): boolean { return ( !!this.twentyConfigService.get('CLOUDFLARE_API_KEY') && @@ -97,7 +77,7 @@ export class ClientConfigService { sdkPackage: registeredModel.sdkPackage, providerName, providerLabel: getProviderLabel(providerName), - nativeCapabilities: this.deriveNativeCapabilities( + nativeCapabilities: getNativeModelCapabilities( registeredModel.sdkPackage, ), inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens, @@ -137,6 +117,9 @@ export class ClientConfigService { defaultPerformanceModel?.providerName, ), sdkPackage: defaultPerformanceModel?.sdkPackage ?? null, + nativeCapabilities: getNativeModelCapabilities( + defaultPerformanceModel?.sdkPackage, + ), inputCostPerMillionTokens: defaultPerformanceModelConfig?.inputCostPerMillionTokens, outputCostPerMillionTokens: @@ -155,6 +138,9 @@ export class ClientConfigService { providerName: defaultSpeedModel?.providerName, providerLabel: getProviderLabel(defaultSpeedModel?.providerName), sdkPackage: defaultSpeedModel?.sdkPackage ?? null, + nativeCapabilities: getNativeModelCapabilities( + defaultSpeedModel?.sdkPackage, + ), inputCostPerMillionTokens: defaultSpeedModelConfig?.inputCostPerMillionTokens, outputCostPerMillionTokens: diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.interface.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.interface.ts deleted file mode 100644 index a6ca62f5a9..0000000000 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { type ToolSet } from 'ai'; - -import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; - -// Parallel to ToolProvider, not a variant of it. A binder produces SDK-native -// tool objects (Anthropic webSearch, OpenAI webSearch, etc.) that the AI SDK -// passes straight to the model. These tools are opaque — they can't be -// serialized into descriptors, don't appear in the tool catalog, and aren't -// executed by ToolExecutorService. They're merged directly into the ToolSet -// handed to streamText. -export interface NativeToolBinder { - bind(model: RegisteredAiModel, options: NativeModelToolOptions): ToolSet; -} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index a1031e8f08..a922115b4e 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -8,7 +8,6 @@ import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/pro import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider'; import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider'; -import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service'; import { NavigationMenuItemToolProvider } from 'src/engine/core-modules/tool-provider/providers/navigation-menu-item-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WebhookToolProvider } from 'src/engine/core-modules/tool-provider/providers/webhook-tool.provider'; @@ -73,7 +72,6 @@ import { ToolRegistryService } from './services/tool-registry.service'; DashboardToolProvider, DatabaseToolProvider, MetadataToolProvider, - NativeToolBinderService, NavigationMenuItemToolProvider, LogicFunctionToolProvider, ViewToolProvider, @@ -120,6 +118,6 @@ import { ToolRegistryService } from './services/tool-registry.service'; }, ToolRegistryService, ], - exports: [NativeToolBinderService, ToolRegistryService], + exports: [ToolRegistryService], }) export class ToolProviderModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts new file mode 100644 index 0000000000..69f9a36ece --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts @@ -0,0 +1,147 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; +import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service'; +import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; +import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; +import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; +import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; +import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util'; + +jest.mock('ai', () => ({ + ...jest.requireActual('ai'), + generateText: jest.fn().mockResolvedValue({ + text: '', + steps: [], + usage: { + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + inputTokenDetails: { + noCacheTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + }, + outputTokenDetails: { textTokens: 0, reasoningTokens: 0 }, + }, + }), +})); + +describe('AgentAsyncExecutorService — workflow agent role-scoped tool resolution', () => { + let service: AgentAsyncExecutorService; + let toolRegistry: { getToolsByCategories: jest.Mock }; + let roleTargetRepository: { findOne: jest.Mock }; + + const agentId = 'agent-1'; + const workspaceId = 'workspace-1'; + const agentRoleId = 'role-1'; + + const buildAgent = (): AgentEntity => + ({ + id: agentId, + workspaceId, + modelId: 'openai/gpt-4.1', + prompt: 'test prompt', + modelConfiguration: {}, + }) as AgentEntity; + + beforeEach(async () => { + toolRegistry = { getToolsByCategories: jest.fn().mockResolvedValue({}) }; + roleTargetRepository = { findOne: jest.fn() }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentAsyncExecutorService, + { + provide: AiModelRegistryService, + useValue: { + validateModelAvailability: jest.fn(), + resolveModelForAgent: jest.fn().mockResolvedValue({ + modelId: 'openai/gpt-4.1', + sdkPackage: '@ai-sdk/openai', + model: {}, + }), + }, + }, + { + provide: AiModelConfigService, + useValue: { + getReasoningProviderOptions: jest.fn().mockReturnValue({}), + }, + }, + { provide: ToolRegistryService, useValue: toolRegistry }, + { + provide: NativeToolBinderService, + useValue: { + bind: jest.fn().mockReturnValue({ tools: {}, providerOptions: {} }), + }, + }, + { + provide: AiBillingService, + useValue: { + decrementAndCheckAvailableCredits: jest + .fn() + .mockResolvedValue({ hasNoMoreAvailableCredits: false }), + calculateCost: jest.fn().mockReturnValue(0), + emitAiTokenUsageEvent: jest.fn(), + billNativeWebSearchUsage: jest.fn(), + }, + }, + { + provide: BillingUsageService, + useValue: { + hasAvailableCreditsOrThrow: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: getWorkspaceScopedRepositoryToken(RoleTargetEntity), + useValue: roleTargetRepository, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { findOneBy: jest.fn().mockResolvedValue(null) }, + }, + ], + }).compile(); + + service = module.get(AgentAsyncExecutorService); + }); + + it('passes unionOf: [agentRoleId] when the agent has a role assigned', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId }); + + await service.executeAgent({ + agent: buildAgent(), + userPrompt: 'test', + workspaceId, + }); + + expect(toolRegistry.getToolsByCategories).toHaveBeenCalledTimes(1); + expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith( + expect.objectContaining({ + roleId: agentRoleId, + rolePermissionConfig: { unionOf: [agentRoleId] }, + workspaceId, + }), + expect.objectContaining({ wrapWithErrorContext: false }), + ); + }); + + it('does not resolve registry tools when the agent has no role (fail-closed)', async () => { + roleTargetRepository.findOne.mockResolvedValueOnce(null); + + await service.executeAgent({ + agent: buildAgent(), + userPrompt: 'test', + workspaceId, + }); + + expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 403dd40614..ee5d45598d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -18,7 +18,7 @@ import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-a import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; -import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -39,6 +39,7 @@ import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billi import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; import { AiException, AiExceptionCode, @@ -83,29 +84,11 @@ export class AgentAsyncExecutorService { private readonly workspaceRepository: Repository, ) {} - private extractRoleIds( - rolePermissionConfig?: RolePermissionConfig, - ): string[] { - if (!rolePermissionConfig) { - return []; - } - - if ('intersectionOf' in rolePermissionConfig) { - return rolePermissionConfig.intersectionOf; - } - - if ('unionOf' in rolePermissionConfig) { - return rolePermissionConfig.unionOf; - } - - return []; - } - - private async getEffectiveRolePermissionConfig( + private async getAgentRoleId( agentId: string, workspaceId: string, rolePermissionConfig?: RolePermissionConfig, - ): Promise { + ): Promise { const roleTarget = await this.roleTargetRepository.findOne(workspaceId, { where: { agentId, @@ -113,25 +96,13 @@ export class AgentAsyncExecutorService { select: ['roleId'], }); - const agentRoleId = roleTarget?.roleId; - const configRoleIds = this.extractRoleIds(rolePermissionConfig); - - const allRoleIds = agentRoleId - ? [...new Set([...configRoleIds, agentRoleId])] - : configRoleIds; - - if (allRoleIds.length === 0) { - return undefined; - } - - return { intersectionOf: allRoleIds }; + return roleTarget?.roleId; } async executeAgent({ agent, userPrompt, actorContext, - rolePermissionConfig, authContext, workspaceId, userWorkspaceId, @@ -140,7 +111,6 @@ export class AgentAsyncExecutorService { agent: AgentEntity | null; userPrompt: string; actorContext?: ActorMetadata; - rolePermissionConfig?: RolePermissionConfig; authContext?: WorkspaceAuthContext; workspaceId: string; userWorkspaceId?: string | null; @@ -173,56 +143,67 @@ export class AgentAsyncExecutorService { let providerOptions = {}; if (agent) { - const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig( + const agentRoleId = await this.getAgentRoleId( agent.id, agent.workspaceId, - rolePermissionConfig, ); - // Workflow context: registry tools come from DATABASE_CRUD and ACTION. - // Native model tools are bound separately below. - const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? ''; - - const toolProviderContext: ToolProviderContext = { - workspaceId: agent.workspaceId, - roleId, - rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] }, - authContext, - actorContext, - userId: - isDefined(authContext) && isUserAuthContext(authContext) - ? authContext.user.id - : undefined, - userWorkspaceId: - isDefined(authContext) && isUserAuthContext(authContext) - ? authContext.userWorkspaceId - : undefined, + const nativeModelToolOptions: NativeModelToolOptions = { + webSearch: agent.modelConfiguration?.webSearch?.enabled === true, + twitterSearch: + agent.modelConfiguration?.twitterSearch?.enabled === true, }; - const registryTools = await this.toolRegistry.getToolsByCategories( - toolProviderContext, - { - categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES, - wrapWithErrorContext: false, - }, - ); + let registryTools: ToolSet = {}; - const nativeTools = this.nativeToolBinder.bind(registeredModel, { - webSearchEnabled: - agent.modelConfiguration?.webSearch?.enabled === true, - }); + // Workflow agent registry tools are scoped exclusively by the agent + // permission-tab role. No role means no registry tools. + if (isDefined(agentRoleId)) { + const agentRolePermissionConfig: RolePermissionConfig = { + unionOf: [agentRoleId], + }; + + const toolProviderContext: ToolProviderContext = { + workspaceId: agent.workspaceId, + roleId: agentRoleId, + rolePermissionConfig: agentRolePermissionConfig, + authContext, + actorContext, + userId: + isDefined(authContext) && isUserAuthContext(authContext) + ? authContext.user.id + : undefined, + userWorkspaceId: + isDefined(authContext) && isUserAuthContext(authContext) + ? authContext.userWorkspaceId + : undefined, + }; + + registryTools = await this.toolRegistry.getToolsByCategories( + toolProviderContext, + { + categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES, + wrapWithErrorContext: false, + }, + ); + } + + const nativeBinding = this.nativeToolBinder.bind( + registeredModel, + nativeModelToolOptions, + ); tools = { ...registryTools, - ...nativeTools, + ...nativeBinding.tools, }; - providerOptions = this.aiModelConfigService.getProviderOptions( - registeredModel, - agent as unknown as Parameters< - typeof this.aiModelConfigService.getProviderOptions - >[1], - ); + providerOptions = { + ...nativeBinding.providerOptions, + ...this.aiModelConfigService.getReasoningProviderOptions( + registeredModel, + ), + }; } this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index cc08f842d8..ee64f15bd1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -25,7 +25,7 @@ import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-pr import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; -import { NativeToolBinderService } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { createExecuteToolTool, @@ -62,6 +62,7 @@ import { import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; +import { getNativeModelCapabilities } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; export type ChatExecutionOptions = { @@ -163,8 +164,13 @@ export class ChatExecutionService { registeredModel.modelId, ); - const nativeModelTools = this.nativeToolBinder.bind(registeredModel, { - webSearchEnabled: true, + // Native and action search may both be bound here; the model picks at runtime. + const nativeCapabilities = getNativeModelCapabilities( + registeredModel.sdkPackage, + ); + const nativeBinding = this.nativeToolBinder.bind(registeredModel, { + webSearch: nativeCapabilities?.webSearch === true, + twitterSearch: nativeCapabilities?.twitterSearch === true, }); // Tools the model can call directly: preloaded registry tools (already @@ -172,12 +178,12 @@ export class ChatExecutionService { // serialized). execute_tool routes discovered tools through the registry. const directTools: ToolSet = { ...preloadedTools, - ...nativeModelTools, + ...nativeBinding.tools, }; const preloadedToolNames = [ ...Object.keys(preloadedTools), - ...Object.keys(nativeModelTools), + ...Object.keys(nativeBinding.tools), ]; // ToolSet is constant for the entire conversation — no mutation. @@ -393,9 +399,10 @@ export class ChatExecutionService { stopWhen: (step) => stepCountIs(AGENT_CONFIG.MAX_STEPS)(step) || hasNoMoreAvailableCredits, experimental_telemetry: AI_TELEMETRY_CONFIG, - providerOptions: getCallLevelCacheProviderOptions( - registeredModel.sdkPackage, - ), + providerOptions: { + ...nativeBinding.providerOptions, + ...getCallLevelCacheProviderOptions(registeredModel.sdkPackage), + }, prepareStep: ({ messages }) => { stepStartedAt = performance.now(); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts index 21dd89b960..3fe9362e2f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts @@ -5,6 +5,7 @@ import { AiModelPreferencesService } from 'src/engine/metadata-modules/ai/ai-mod import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { DefaultAiCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/default-ai-catalog.service'; import { ModelsDevCatalogService } from 'src/engine/metadata-modules/ai/ai-models/services/models-dev-catalog.service'; +import { NativeToolBinderService } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service'; import { ProviderConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/provider-config.service'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; @@ -18,6 +19,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod AiModelPreferencesService, AiModelRegistryService, AiModelConfigService, + NativeToolBinderService, ], exports: [ DefaultAiCatalogService, @@ -26,6 +28,7 @@ import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-mod AiModelConfigService, SdkProviderFactoryService, ModelsDevCatalogService, + NativeToolBinderService, ], }) export class AiModelsModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts new file mode 100644 index 0000000000..bb58811ec3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const.ts @@ -0,0 +1,43 @@ +import { type AiSdkPackage } from 'twenty-shared/ai'; + +import { + AI_SDK_ANTHROPIC, + AI_SDK_AZURE, + AI_SDK_BEDROCK, + AI_SDK_GOOGLE, + AI_SDK_MISTRAL, + AI_SDK_OPENAI, + AI_SDK_OPENAI_COMPATIBLE, + AI_SDK_XAI, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; +import { type NativeModelTools } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type'; + +export const NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE = { + [AI_SDK_ANTHROPIC]: { + webSearch: { + kind: 'sdk-tool', + directToolName: 'web_search', + }, + }, + [AI_SDK_OPENAI]: { + webSearch: { + kind: 'sdk-tool', + directToolName: 'web_search', + }, + }, + [AI_SDK_XAI]: { + webSearch: { + kind: 'provider-option', + providerOptionKey: 'web', + }, + twitterSearch: { + kind: 'provider-option', + providerOptionKey: 'x', + }, + }, + [AI_SDK_GOOGLE]: {}, + [AI_SDK_MISTRAL]: {}, + [AI_SDK_BEDROCK]: {}, + [AI_SDK_OPENAI_COMPATIBLE]: {}, + [AI_SDK_AZURE]: {}, +} as const satisfies Record; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts new file mode 100644 index 0000000000..11f55c4be8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/__tests__/ai-model-config.service.spec.ts @@ -0,0 +1,134 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { + AI_SDK_ANTHROPIC, + AI_SDK_XAI, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; +import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; +import { + AiModelRegistryService, + type RegisteredAiModel, +} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; + +describe('AiModelConfigService.getNativeModelBinding — xAI search parameters', () => { + let service: AiModelConfigService; + + const xaiModel: RegisteredAiModel = { + modelId: 'xai/grok-4', + sdkPackage: AI_SDK_XAI, + model: {} as RegisteredAiModel['model'], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AiModelConfigService, + { + provide: AiModelRegistryService, + useValue: {}, + }, + { + provide: SdkProviderFactoryService, + useValue: {}, + }, + ], + }).compile(); + + service = module.get(AiModelConfigService); + }); + + it('returns empty options when neither webSearch nor twitterSearch is enabled', () => { + expect( + service.getNativeModelBinding(xaiModel, { + webSearch: false, + twitterSearch: false, + }).providerOptions, + ).toEqual({}); + }); + + it('omits sources entirely when neither flag is enabled (no implicit "auto" search)', () => { + const result = service.getNativeModelBinding(xaiModel, {}).providerOptions; + + expect(result).toEqual({}); + }); + + it('emits only the web source when only webSearch is enabled', () => { + expect( + service.getNativeModelBinding(xaiModel, { + webSearch: true, + twitterSearch: false, + }).providerOptions, + ).toEqual({ + xai: { + searchParameters: { + mode: 'auto', + sources: [{ type: 'web' }], + }, + }, + }); + }); + + it('emits only the x source when only twitterSearch is enabled', () => { + expect( + service.getNativeModelBinding(xaiModel, { + webSearch: false, + twitterSearch: true, + }).providerOptions, + ).toEqual({ + xai: { + searchParameters: { + mode: 'auto', + sources: [{ type: 'x' }], + }, + }, + }); + }); + + it('emits both sources when webSearch and twitterSearch are enabled', () => { + expect( + service.getNativeModelBinding(xaiModel, { + webSearch: true, + twitterSearch: true, + }).providerOptions, + ).toEqual({ + xai: { + searchParameters: { + mode: 'auto', + sources: [{ type: 'web' }, { type: 'x' }], + }, + }, + }); + }); + + it('preserves source order — web before x — for deterministic provider payloads', () => { + const result = service.getNativeModelBinding(xaiModel, { + webSearch: true, + twitterSearch: true, + }).providerOptions; + + expect(result).toMatchObject({ + xai: { + searchParameters: { + sources: [{ type: 'web' }, { type: 'x' }], + }, + }, + }); + }); + + it('returns empty options for non-xAI models even when search flags are on', () => { + const anthropicModel: RegisteredAiModel = { + modelId: 'anthropic/claude-sonnet-4-6', + sdkPackage: AI_SDK_ANTHROPIC, + model: {} as RegisteredAiModel['model'], + supportsReasoning: false, + }; + + expect( + service.getNativeModelBinding(anthropicModel, { + webSearch: true, + twitterSearch: true, + }).providerOptions, + ).toEqual({}); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts index d7f3c2ca15..4ac2084caf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts @@ -1,7 +1,8 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; -import { ProviderOptions } from '@ai-sdk/provider-utils'; -import { ToolSet } from 'ai'; +import { type ProviderOptions } from '@ai-sdk/provider-utils'; +import { type ToolSet } from 'ai'; +import { isDefined } from 'twenty-shared/utils'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { @@ -10,28 +11,36 @@ import { AI_SDK_OPENAI, AI_SDK_XAI, } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; +import { getNativeModelToolsForSdkPackage } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util'; import { AiModelRegistryService, RegisteredAiModel, } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; -import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; @Injectable() export class AiModelConfigService { + private readonly logger = new Logger(AiModelConfigService.name); + constructor( private readonly aiModelRegistryService: AiModelRegistryService, private readonly sdkProviderFactory: SdkProviderFactoryService, ) {} - getProviderOptions( + getNativeModelBinding( model: RegisteredAiModel, - agent: FlatAgentWithRoleId, - ): ProviderOptions { + options: NativeModelToolOptions = {}, + ): NativeModelBinding { + return { + tools: this.getNativeModelTools(model, options), + providerOptions: this.getNativeSearchProviderOptions(model, options), + }; + } + + getReasoningProviderOptions(model: RegisteredAiModel): ProviderOptions { switch (model.sdkPackage) { - case AI_SDK_XAI: - return this.getXaiProviderOptions(agent); case AI_SDK_ANTHROPIC: return this.getAnthropicProviderOptions(model); case AI_SDK_BEDROCK: @@ -41,13 +50,30 @@ export class AiModelConfigService { } } - getNativeModelTools( + private getNativeModelTools( model: RegisteredAiModel, options: NativeModelToolOptions, ): ToolSet { const tools: Record = {}; - if (!options.webSearchEnabled) { + if (options.webSearch !== true) { + return tools as ToolSet; + } + + const webSearchTool = getNativeModelToolsForSdkPackage( + model.sdkPackage, + )?.webSearch; + + if (!isDefined(webSearchTool)) { + this.logger.warn( + `webSearch requested for sdkPackage="${model.sdkPackage}" but no native binding is registered. Skipping.`, + ); + + return tools as ToolSet; + } + + // provider-option bindings (e.g. xAI) are handled in getNativeSearchProviderOptions + if (webSearchTool.kind !== 'sdk-tool') { return tools as ToolSet; } @@ -58,7 +84,8 @@ export class AiModelConfigService { : undefined; if (anthropicProvider) { - tools.web_search = anthropicProvider.tools.webSearch_20250305(); + tools[webSearchTool.directToolName] = + anthropicProvider.tools.webSearch_20250305(); } break; @@ -69,7 +96,8 @@ export class AiModelConfigService { : undefined; if (openaiProvider) { - tools.web_search = openaiProvider.tools.webSearch(); + tools[webSearchTool.directToolName] = + openaiProvider.tools.webSearch(); } break; @@ -79,23 +107,51 @@ export class AiModelConfigService { return tools as ToolSet; } - private getXaiProviderOptions(agent: FlatAgentWithRoleId): ProviderOptions { - if ( - !agent.modelConfiguration || - (!agent.modelConfiguration.webSearch?.enabled && - !agent.modelConfiguration.twitterSearch?.enabled) - ) { + private getNativeSearchProviderOptions( + model: RegisteredAiModel, + options: NativeModelToolOptions, + ): ProviderOptions { + switch (model.sdkPackage) { + case AI_SDK_XAI: + return this.getXaiSearchProviderOptions(options); + default: + return {}; + } + } + + private getXaiSearchProviderOptions( + options: NativeModelToolOptions, + ): ProviderOptions { + const webSearchEnabled = options.webSearch === true; + const twitterSearchEnabled = options.twitterSearch === true; + + if (!webSearchEnabled && !twitterSearchEnabled) { return {}; } const sources: Array<{ type: string }> = []; + const xaiTools = getNativeModelToolsForSdkPackage(AI_SDK_XAI); + const webSearchTool = xaiTools?.webSearch; + const twitterSearchTool = xaiTools?.twitterSearch; - if (agent.modelConfiguration.webSearch?.enabled) { - sources.push({ type: 'web' }); + if (webSearchEnabled) { + if (webSearchTool?.kind === 'provider-option') { + sources.push({ type: webSearchTool.providerOptionKey }); + } else { + this.logger.warn( + `webSearch requested for xAI but no provider-option binding is registered. Skipping.`, + ); + } } - if (agent.modelConfiguration.twitterSearch?.enabled) { - sources.push({ type: 'x' }); + if (twitterSearchEnabled) { + if (twitterSearchTool?.kind === 'provider-option') { + sources.push({ type: twitterSearchTool.providerOptionKey }); + } else { + this.logger.warn( + `twitterSearch requested for xAI but no provider-option binding is registered. Skipping.`, + ); + } } return { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts new file mode 100644 index 0000000000..91ab10995f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface.ts @@ -0,0 +1,12 @@ +import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; +import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; + +// Parallel to ToolProvider — resolves the complete native-model call payload +// (SDK-native tools + provider options), not registry descriptors. +export interface NativeToolBinder { + bind( + model: RegisteredAiModel, + options: NativeModelToolOptions, + ): NativeModelBinding; +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts similarity index 65% rename from packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts index d5fbb93b64..878f748e31 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/native/native-tool-binder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.service.ts @@ -1,11 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { type ToolSet } from 'ai'; - -import { type NativeToolBinder } from 'src/engine/core-modules/tool-provider/native/native-tool-binder.interface'; - import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { type NativeToolBinder } from 'src/engine/metadata-modules/ai/ai-models/services/native-tool-binder.interface'; +import { type NativeModelBinding } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type'; import { type NativeModelToolOptions } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type'; @Injectable() @@ -15,7 +13,7 @@ export class NativeToolBinderService implements NativeToolBinder { bind( model: RegisteredAiModel, options: NativeModelToolOptions = {}, - ): ToolSet { - return this.aiModelConfigService.getNativeModelTools(model, options); + ): NativeModelBinding { + return this.aiModelConfigService.getNativeModelBinding(model, options); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts new file mode 100644 index 0000000000..9bc7a981c3 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-binding.type.ts @@ -0,0 +1,7 @@ +import { type ProviderOptions } from '@ai-sdk/provider-utils'; +import { type ToolSet } from 'ai'; + +export type NativeModelBinding = { + tools: ToolSet; + providerOptions: ProviderOptions; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type.ts new file mode 100644 index 0000000000..d162e9caa1 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type.ts @@ -0,0 +1 @@ +export type NativeModelToolKey = 'webSearch' | 'twitterSearch'; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type.ts index 039fb3ac20..dee48df4f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tool-options.type.ts @@ -1,3 +1,5 @@ -export type NativeModelToolOptions = { - webSearchEnabled?: boolean; -}; +import { type NativeModelToolKey } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type'; + +export type NativeModelToolOptions = Partial< + Record +>; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts new file mode 100644 index 0000000000..08ab624cdc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type.ts @@ -0,0 +1,15 @@ +import { type NativeModelToolKey } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type'; + +export type NativeModelTools = Partial< + Record< + NativeModelToolKey, + | { + kind: 'sdk-tool'; + directToolName: string; + } + | { + kind: 'provider-option'; + providerOptionKey: string; + } + > +>; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util.ts new file mode 100644 index 0000000000..1aac2cf42f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util.ts @@ -0,0 +1,17 @@ +import { type AiSdkPackage } from 'twenty-shared/ai'; + +import { type NativeModelToolKey } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tool-key.type'; +import { getNativeModelToolsForSdkPackage } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util'; + +export const getNativeModelCapabilities = ( + sdkPackage?: AiSdkPackage | null, +): Partial> | undefined => { + const tools = getNativeModelToolsForSdkPackage(sdkPackage); + const toolKeys = tools ? Object.keys(tools) : []; + + if (toolKeys.length === 0) { + return undefined; + } + + return Object.fromEntries(toolKeys.map((toolKey) => [toolKey, true])); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util.ts new file mode 100644 index 0000000000..3462f756a9 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/utils/get-native-model-tools-for-sdk-package.util.ts @@ -0,0 +1,9 @@ +import { type AiSdkPackage } from 'twenty-shared/ai'; + +import { NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE } from 'src/engine/metadata-modules/ai/ai-models/constants/native-model-tools-by-sdk-package.const'; +import { type NativeModelTools } from 'src/engine/metadata-modules/ai/ai-models/types/native-model-tools.type'; + +export const getNativeModelToolsForSdkPackage = ( + sdkPackage?: AiSdkPackage | null, +): NativeModelTools | undefined => + sdkPackage ? NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE[sdkPackage] : undefined; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index 7a19f3877b..16728b1bce 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -80,7 +80,6 @@ export class AiAgentWorkflowAction implements WorkflowAction { actorContext: executionContext.isActingOnBehalfOfUser ? executionContext.initiator : undefined, - rolePermissionConfig: executionContext.rolePermissionConfig, authContext: executionContext.authContext, workspaceId, userWorkspaceId, diff --git a/packages/twenty-server/test/integration/ai/suites/workflow-agent-role-assignment-persistence.integration-spec.ts b/packages/twenty-server/test/integration/ai/suites/workflow-agent-role-assignment-persistence.integration-spec.ts new file mode 100644 index 0000000000..d8ab0949d5 --- /dev/null +++ b/packages/twenty-server/test/integration/ai/suites/workflow-agent-role-assignment-persistence.integration-spec.ts @@ -0,0 +1,120 @@ +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>( + 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>( + getRepositoryToken(RoleTargetEntity), + ); + + const roleTarget = await roleTargetRepository.findOne({ + where: { + agentId: agentWithoutRoleId, + workspaceId: SEED_APPLE_WORKSPACE_ID, + }, + select: ['roleId'], + }); + + expect(roleTarget).toBeNull(); + }); +});