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>
This commit is contained in:
nitin
2026-05-29 01:38:05 +05:30
committed by GitHub
parent 1d84695fb0
commit 996cdaf3ff
22 changed files with 691 additions and 156 deletions
@@ -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,
@@ -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`,
@@ -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:
@@ -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;
}
@@ -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 {}
@@ -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>(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();
});
});
@@ -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<WorkspaceEntity>,
) {}
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<RolePermissionConfig | undefined> {
): Promise<string | undefined> {
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`);
@@ -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();
@@ -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 {}
@@ -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<AiSdkPackage, NativeModelTools>;
@@ -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>(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({});
});
});
@@ -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<string, unknown> = {};
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 {
@@ -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;
}
@@ -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);
}
}
@@ -0,0 +1,7 @@
import { type ProviderOptions } from '@ai-sdk/provider-utils';
import { type ToolSet } from 'ai';
export type NativeModelBinding = {
tools: ToolSet;
providerOptions: ProviderOptions;
};
@@ -0,0 +1 @@
export type NativeModelToolKey = 'webSearch' | 'twitterSearch';
@@ -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<NativeModelToolKey, boolean>
>;
@@ -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;
}
>
>;
@@ -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<Record<NativeModelToolKey, boolean>> | 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]));
};
@@ -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;
@@ -80,7 +80,6 @@ export class AiAgentWorkflowAction implements WorkflowAction {
actorContext: executionContext.isActingOnBehalfOfUser
? executionContext.initiator
: undefined,
rolePermissionConfig: executionContext.rolePermissionConfig,
authContext: executionContext.authContext,
workspaceId,
userWorkspaceId,
@@ -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<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();
});
});