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
@@ -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`);