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:
+147
@@ -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();
|
||||
});
|
||||
});
|
||||
+54
-73
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user