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