This commit is contained in:
ehconitin
2026-04-20 14:59:19 +05:30
parent 83b3a4b7dc
commit adad39accb
23 changed files with 639 additions and 764 deletions
@@ -34,17 +34,17 @@ export const SettingsAgentModelCapabilities = ({
disabled = false,
}: SettingsAgentModelCapabilitiesProps) => {
const aiModels = useAtomStateValue(aiModelsState);
const isCodeInterpreterEnabled = useAtomStateValue(
const isCodeInterpreterAvailable = useAtomStateValue(
isCodeInterpreterEnabledState,
);
const selectedModel = aiModels.find((m) => m.modelId === selectedModelId);
const modelCapabilities = selectedModel?.capabilities;
const availableModelCapabilities = selectedModel?.capabilities;
if (
!modelCapabilities?.webSearch &&
!modelCapabilities?.twitterSearch &&
!isCodeInterpreterEnabled
!availableModelCapabilities?.webSearch &&
!availableModelCapabilities?.twitterSearch &&
!isCodeInterpreterAvailable
) {
return null;
}
@@ -66,8 +66,8 @@ export const SettingsAgentModelCapabilities = ({
});
};
const capabilityItems = [
...(modelCapabilities?.webSearch
const modelCapabilityItems = [
...(availableModelCapabilities?.webSearch
? [
{
key: 'webSearch' as const,
@@ -77,7 +77,7 @@ export const SettingsAgentModelCapabilities = ({
},
]
: []),
...(modelCapabilities?.twitterSearch
...(availableModelCapabilities?.twitterSearch
? [
{
key: 'twitterSearch' as const,
@@ -90,19 +90,25 @@ export const SettingsAgentModelCapabilities = ({
},
]
: []),
...(isCodeInterpreterEnabled
? [
{
key: 'codeInterpreter' as const,
label: t`Code Interpreter`,
Icon: IconCode,
enabled: isAgentCapabilityEnabled(
modelConfiguration,
'codeInterpreter',
),
},
]
: []),
];
const workspaceCapabilityItems = isCodeInterpreterAvailable
? [
{
key: 'codeInterpreter' as const,
label: t`Code Interpreter`,
Icon: IconCode,
enabled: isAgentCapabilityEnabled(
modelConfiguration,
'codeInterpreter',
),
},
]
: [];
const capabilityItems = [
...modelCapabilityItems,
...workspaceCapabilityItems,
];
return (
@@ -15,7 +15,6 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
createExecuteToolTool,
@@ -108,10 +107,9 @@ export class McpProtocolService {
userWorkspaceId?: string;
},
): Promise<ToolSet> {
const toolContext: ToolProviderContext = {
const toolContext = {
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
authContext: options?.authContext,
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
@@ -29,8 +29,8 @@ registerEnumType(AiModelRole, {
name: 'AiModelRole',
});
@ObjectType()
export class AgentCapabilities {
@ObjectType('AgentCapabilities')
export class ClientAiModelCapabilities {
@Field(() => Boolean)
webSearch: boolean;
@@ -62,8 +62,9 @@ export class ClientAiModelConfig {
@Field(() => Number, { nullable: true })
outputCostPerMillionTokens?: number;
@Field(() => AgentCapabilities)
capabilities: AgentCapabilities;
@Field(() => ClientAiModelCapabilities)
// Model-level availability. Agent-level on/off state lives in modelConfiguration.
capabilities: ClientAiModelCapabilities;
@Field(() => Boolean, { nullable: true })
isDeprecated?: boolean;
@@ -291,6 +291,40 @@ describe('ClientConfigService', () => {
expect(result.isCodeInterpreterEnabled).toBe(false);
});
it('keeps x search available when external web search is preferred', async () => {
jest
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
.mockReturnValue([
{
modelId: 'xai-model',
sdkPackage: AI_SDK_XAI,
model: {} as never,
providerName: 'xai',
},
]);
jest
.spyOn(twentyConfigService, 'get')
.mockImplementation((key: string) => {
if (key === 'WEB_SEARCH_DRIVER') return WebSearchDriverType.EXA;
if (key === 'WEB_SEARCH_PREFER_NATIVE') return false;
if (key === 'CODE_INTERPRETER_TYPE')
return CodeInterpreterDriverType.DISABLED;
return undefined;
});
const result = await service.getClientConfig();
const xaiModel = result.aiModels.find(
(model) => model.modelId === 'xai-model',
);
expect(xaiModel?.capabilities).toEqual({
webSearch: true,
twitterSearch: true,
});
});
it('surfaces code interpreter availability at the client-config level', async () => {
jest
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
@@ -17,7 +17,7 @@ import {
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import {
type AgentCapabilities,
type ClientAiModelCapabilities,
type ClientAiModelConfig,
type ClientConfig,
} from 'src/engine/core-modules/client-config/client-config.entity';
@@ -40,9 +40,9 @@ export class ClientConfigService {
private maintenanceModeService: MaintenanceModeService,
) {}
private deriveAvailableModelCapabilities(
private deriveModelCapabilities(
sdkPackage?: AiSdkPackage,
): AgentCapabilities {
): ClientAiModelCapabilities {
const supportsProviderNativeWebSearch =
sdkPackage === AI_SDK_OPENAI ||
sdkPackage === AI_SDK_ANTHROPIC ||
@@ -104,7 +104,7 @@ export class ClientConfigService {
sdkPackage: registeredModel.sdkPackage,
providerName,
providerLabel: getProviderLabel(providerName),
capabilities: this.deriveAvailableModelCapabilities(
capabilities: this.deriveModelCapabilities(
registeredModel.sdkPackage,
),
inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens,
@@ -144,7 +144,7 @@ export class ClientConfigService {
defaultPerformanceModel?.providerName,
),
sdkPackage: defaultPerformanceModel?.sdkPackage ?? null,
capabilities: this.deriveAvailableModelCapabilities(
capabilities: this.deriveModelCapabilities(
defaultPerformanceModel?.sdkPackage,
),
inputCostPerMillionTokens:
@@ -165,7 +165,7 @@ export class ClientConfigService {
providerName: defaultSpeedModel?.providerName,
providerLabel: getProviderLabel(defaultSpeedModel?.providerName),
sdkPackage: defaultSpeedModel?.sdkPackage ?? null,
capabilities: this.deriveAvailableModelCapabilities(
capabilities: this.deriveModelCapabilities(
defaultSpeedModel?.sdkPackage,
),
inputCostPerMillionTokens:
@@ -16,3 +16,14 @@ export type ToolProviderContext = {
agent?: ToolProviderAgent | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
export type ToolContext = Pick<
ToolProviderContext,
| 'workspaceId'
| 'roleId'
| 'authContext'
| 'actorContext'
| 'userId'
| 'userWorkspaceId'
| 'onCodeExecutionUpdate'
>;
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
@@ -15,8 +15,6 @@ import { ToolCategory } from 'twenty-shared/ai';
// This provider keeps generateTools() and is excluded from the descriptor system.
@Injectable()
export class NativeModelToolProvider implements NativeToolProvider {
private readonly logger = new Logger(NativeModelToolProvider.name);
readonly category = ToolCategory.NATIVE_MODEL;
constructor(
@@ -37,10 +35,6 @@ export class NativeModelToolProvider implements NativeToolProvider {
const useProviderNativeWebSearch =
this.webSearchService.shouldUseNativeSearch();
this.logger.log(
`Web search strategy: ${useProviderNativeWebSearch ? 'native (provider SDK)' : 'external (EXA)'}`,
);
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
@@ -1,122 +0,0 @@
import { type ToolSet } from 'ai';
import { ToolCategory } from 'twenty-shared/ai';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
} from 'src/engine/core-modules/tool-provider/tools';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
const createTool = (name: string): ToolSet[string] =>
({
description: name,
inputSchema: {},
execute: jest.fn(),
}) as unknown as ToolSet[string];
const createToolIndexEntry = (
name: string,
category: ToolCategory,
): ToolIndexEntry => ({
name,
category,
description: name,
executionRef: { kind: 'static', toolId: name },
});
describe('LazyToolRuntimeService', () => {
const context: ToolProviderContext = {
workspaceId: 'workspace-id',
roleId: 'role-id',
rolePermissionConfig: { unionOf: ['role-id'] },
};
const setup = () => {
const toolRegistry = {
getCatalog: jest.fn(),
getToolsByName: jest.fn(),
getToolInfo: jest.fn(),
resolveAndExecute: jest.fn(),
} as unknown as jest.Mocked<ToolRegistryService>;
const service = new LazyToolRuntimeService(toolRegistry);
return { service, toolRegistry };
};
it('builds runtime tools from direct tools', async () => {
const { service, toolRegistry } = setup();
toolRegistry.getCatalog.mockResolvedValue([
createToolIndexEntry('search_help_center', ToolCategory.ACTION),
]);
const runtime = await service.buildToolRuntime({
context,
directTools: {
search_help_center: createTool('search_help_center'),
x_search: createTool('x_search'),
},
});
expect(runtime.directToolNames).toEqual(['search_help_center', 'x_search']);
expect(Object.keys(runtime.runtimeTools)).toEqual([
'search_help_center',
'x_search',
LEARN_TOOLS_TOOL_NAME,
EXECUTE_TOOL_TOOL_NAME,
]);
});
it('filters lazy tools by category while keeping direct tools callable', async () => {
const { service, toolRegistry } = setup();
toolRegistry.getCatalog.mockResolvedValue([
createToolIndexEntry('find_companies', ToolCategory.DATABASE_CRUD),
createToolIndexEntry('create_workflow', ToolCategory.WORKFLOW),
]);
toolRegistry.getToolInfo.mockImplementation(async (toolNames: string[]) =>
toolNames.map((toolName) => ({
name: toolName,
description: toolName,
})),
);
const runtime = await service.buildToolRuntime({
context,
directTools: {
x_search: createTool('x_search'),
},
lazyToolCategories: [ToolCategory.DATABASE_CRUD],
});
expect(runtime.lazyToolCatalog.map((tool) => tool.name)).toEqual([
'find_companies',
]);
expect(runtime.runtimeTools.x_search).toBeDefined();
const learnTools = runtime.runtimeTools[
LEARN_TOOLS_TOOL_NAME
] as unknown as {
execute: (parameters: {
toolNames: string[];
aspects: ['description'];
}) => Promise<unknown>;
};
await learnTools.execute({
toolNames: ['find_companies', 'create_workflow'],
aspects: ['description'],
});
expect(toolRegistry.getToolInfo).toHaveBeenCalledWith(
['find_companies'],
context,
['description'],
);
});
});
@@ -1,85 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
createExecuteToolTool,
createLearnToolsTool,
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
} from 'src/engine/core-modules/tool-provider/tools';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { ToolCategory } from 'twenty-shared/ai';
export type LazyToolRuntime = {
toolCatalog: ToolIndexEntry[];
lazyToolCatalog: ToolIndexEntry[];
directTools: ToolSet;
directToolNames: string[];
runtimeTools: ToolSet;
};
@Injectable()
export class LazyToolRuntimeService {
constructor(private readonly toolRegistry: ToolRegistryService) {}
async buildToolRuntime({
context,
directTools = {},
lazyToolCategories,
}: {
context: ToolProviderContext;
directTools?: ToolSet;
lazyToolCategories?: readonly ToolCategory[];
}): Promise<LazyToolRuntime> {
const toolCatalog = await this.toolRegistry.getCatalog(context);
const lazyToolCatalog = this.filterLazyToolCatalog(
toolCatalog,
lazyToolCategories,
);
const lazyToolNames = new Set(lazyToolCatalog.map((tool) => tool.name));
const excludedToolNames = new Set([
...toolCatalog
.filter((tool) => !lazyToolNames.has(tool.name))
.map((tool) => tool.name),
...Object.keys(directTools),
]);
return {
toolCatalog,
lazyToolCatalog,
directTools,
directToolNames: Object.keys(directTools),
runtimeTools: {
...directTools,
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
this.toolRegistry,
context,
excludedToolNames,
),
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
this.toolRegistry,
context,
directTools,
excludedToolNames,
),
},
};
}
private filterLazyToolCatalog(
toolCatalog: ToolIndexEntry[],
lazyToolCategories?: readonly ToolCategory[],
): ToolIndexEntry[] {
if (!lazyToolCategories) {
return toolCatalog;
}
const categorySet = new Set(lazyToolCategories);
return toolCatalog.filter((tool) => categorySet.has(tool.category));
}
}
@@ -3,11 +3,15 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolExecutionOptions, type ToolSet, jsonSchema } from 'ai';
import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import {
type ToolContext,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ToolCategory } from 'twenty-shared/ai';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
@@ -16,7 +20,7 @@ import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types
import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { ToolCategory } from 'twenty-shared/ai';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ToolRegistryService {
@@ -133,24 +137,27 @@ export class ToolRegistryService {
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolIndexEntry[]> {
return this.getCatalog({
const context = this.buildContextFromToolContext({
workspaceId,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
userId: options?.userId,
userWorkspaceId: options?.userWorkspaceId,
});
return this.getCatalog(context);
}
async getToolsByName(
names: string[],
context: ToolProviderContext,
context: ToolContext,
): Promise<ToolSet> {
const index = await this.getCatalog(context);
const fullContext = this.buildContextFromToolContext(context);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
const schemas = await this.resolveSchemas(names, context);
const schemas = await this.resolveSchemas(names, fullContext);
const descriptors: ToolDescriptor[] = matchingEntries
.filter((entry) => schemas.has(entry.name))
@@ -159,24 +166,26 @@ export class ToolRegistryService {
inputSchema: schemas.get(entry.name)!,
}));
return this.hydrateToolSet(descriptors, context);
return this.hydrateToolSet(descriptors, fullContext);
}
async getToolInfo(
names: string[],
context: ToolProviderContext,
context: ToolContext,
aspects: LearnToolsAspect[] = ['description', 'schema'],
): Promise<
Array<{ name: string; description?: string; inputSchema?: object }>
> {
const index = await this.getCatalog(context);
const fullContext = this.buildContextFromToolContext(context);
const index = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const matchingEntries = index.filter((entry) => nameSet.has(entry.name));
let schemas: Map<string, object> | undefined;
if (aspects.includes('schema')) {
schemas = await this.resolveSchemas(names, context);
schemas = await this.resolveSchemas(names, fullContext);
}
return matchingEntries.map((entry) => {
@@ -201,11 +210,13 @@ export class ToolRegistryService {
async resolveAndExecute(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
context: ToolContext,
_options: ToolExecutionOptions,
): Promise<ToolOutput> {
try {
const index = await this.getCatalog(context);
const fullContext = this.buildContextFromToolContext(context);
const index = await this.getCatalog(fullContext);
const entry = index.find((indexEntry) => indexEntry.name === toolName);
if (!entry) {
@@ -216,7 +227,7 @@ export class ToolRegistryService {
};
}
return await this.toolExecutorService.dispatch(entry, args, context);
return await this.toolExecutorService.dispatch(entry, args, fullContext);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
@@ -288,4 +299,22 @@ export class ToolRegistryService {
return toolSet;
}
private buildContextFromToolContext(
context: ToolContext,
): ToolProviderContext {
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [context.roleId],
};
return {
workspaceId: context.workspaceId,
roleId: context.roleId,
rolePermissionConfig,
authContext: context.authContext,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
};
}
}
@@ -13,7 +13,6 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p
import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -105,9 +104,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
WorkflowToolProvider,
],
},
LazyToolRuntimeService,
ToolRegistryService,
],
exports: [LazyToolRuntimeService, ToolRegistryService],
exports: [ToolRegistryService],
})
export class ToolProviderModule {}
@@ -2,8 +2,8 @@ import { jsonSchema, type ToolExecutionOptions, type ToolSet } from 'ai';
import { type JSONSchema7 } from 'json-schema';
import { z } from 'zod';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
@@ -43,7 +43,7 @@ export const executeToolInputSchema = jsonSchema<ExecuteToolInput>(
export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolProviderContext,
context: ToolContext,
directTools?: ToolSet,
excludeTools?: Set<string>,
) => ({
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
@@ -38,7 +38,7 @@ export type LearnToolsResult = {
export const createLearnToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolProviderContext,
context: ToolContext,
excludeTools?: Set<string>,
) => ({
description:
@@ -1,78 +0,0 @@
import { wrapJsonSchemaForExecution } from '../wrap-tool-for-execution.util';
describe('wrapJsonSchemaForExecution', () => {
it('preserves schema metadata such as $defs and additionalProperties', () => {
const filterSchema = {
type: 'object',
properties: {
and: {
type: 'array',
items: {
$ref: '#/$defs/condition',
},
},
},
required: ['and'],
};
const inputSchema = {
type: 'object',
properties: {
filter: {
$ref: '#/$defs/filter',
},
},
required: ['filter'],
additionalProperties: false,
$defs: {
filter: filterSchema,
condition: {
type: 'object',
properties: {
eq: { type: 'string' },
},
},
},
};
const wrappedSchema = wrapJsonSchemaForExecution(inputSchema);
expect(wrappedSchema.$defs).toEqual(inputSchema.$defs);
expect(wrappedSchema.additionalProperties).toBe(false);
expect(wrappedSchema.properties).toMatchObject({
filter: { $ref: '#/$defs/filter' },
loadingMessage: {
type: 'string',
description: 'A brief status message for the user.',
},
});
expect(wrappedSchema.required).toEqual(
expect.arrayContaining(['loadingMessage', 'filter']),
);
});
it('deduplicates loadingMessage in required fields', () => {
const wrappedSchema = wrapJsonSchemaForExecution({
type: 'object',
properties: {
query: { type: 'string' },
},
required: ['loadingMessage', 'query'],
});
expect(wrappedSchema.required).toEqual(['loadingMessage', 'query']);
});
it('builds a valid object schema when optional keys are missing', () => {
const wrappedSchema = wrapJsonSchemaForExecution({});
expect(wrappedSchema.type).toBe('object');
expect(wrappedSchema.properties).toEqual({
loadingMessage: {
type: 'string',
description: 'A brief status message for the user.',
},
});
expect(wrappedSchema.required).toEqual(['loadingMessage']);
});
});
@@ -1,4 +1,3 @@
import { isArray, isObject } from '@sniptt/guards';
import { z } from 'zod';
const DEFAULT_LOADING_MESSAGE_SCHEMA = z
@@ -23,16 +22,10 @@ export const wrapSchemaForExecution = <T extends z.ZodRawShape>(
export const wrapJsonSchemaForExecution = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const properties =
isObject(schema.properties) && !isArray(schema.properties)
? (schema.properties as Record<string, unknown>)
: {};
const required = isArray(schema.required)
? schema.required.filter((item): item is string => typeof item === 'string')
: [];
const properties = (schema.properties as Record<string, unknown>) ?? {};
const required = (schema.required as string[]) ?? [];
return {
...schema,
type: 'object',
properties: {
loadingMessage: {
@@ -41,7 +34,7 @@ export const wrapJsonSchemaForExecution = (
},
...properties,
},
required: [...new Set(['loadingMessage', ...required])],
required: ['loadingMessage', ...required],
};
};
@@ -9,9 +9,7 @@ jest.mock('ai', () => {
import { generateText, type ToolSet } from 'ai';
import { type LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type 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';
@@ -22,27 +20,74 @@ import {
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { ToolCategory } from 'twenty-shared/ai';
const createTool = (name: string): ToolSet[string] =>
({
description: name,
inputSchema: {},
execute: jest.fn(),
}) as unknown as ToolSet[string];
const createToolIndexEntry = (
name: string,
category: ToolCategory,
): ToolIndexEntry => ({
name,
category,
description: name,
executionRef: { kind: 'static', toolId: name },
});
describe('AgentAsyncExecutorService', () => {
const mockedGenerateText = jest.mocked(generateText);
const registeredModel = {
modelId: 'xai/grok',
sdkPackage: '@ai-sdk/xai',
model: {} as never,
} as RegisteredAiModel;
const createService = ({
roleId,
tools = {},
}: {
roleId?: string;
tools?: ToolSet;
}) => {
const aiModelRegistryService = {
validateModelAvailability: jest.fn(),
resolveModelForAgent: jest.fn().mockResolvedValue(registeredModel),
} as unknown as jest.Mocked<AiModelRegistryService>;
const aiModelConfigService = {
getProviderOptions: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<AiModelConfigService>;
const toolRegistry = {
getToolsByCategories: jest.fn().mockResolvedValue(tools),
} as unknown as jest.Mocked<ToolRegistryService>;
const roleTargetRepository = {
findOne: jest.fn().mockResolvedValue(roleId ? { roleId } : null),
};
const workspaceRepository = {
findOneBy: jest
.fn()
.mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity),
};
const service = new AgentAsyncExecutorService(
aiModelRegistryService,
aiModelConfigService,
toolRegistry,
roleTargetRepository as never,
workspaceRepository as never,
);
return {
service,
aiModelConfigService,
toolRegistry,
};
};
const agent = {
id: 'agent-id',
workspaceId: 'workspace-id',
modelId: 'xai/grok',
prompt: 'Be helpful.',
modelConfiguration: {
webSearch: { enabled: true },
twitterSearch: { enabled: true },
},
responseFormat: { type: 'text' },
} as unknown as AgentEntity;
beforeEach(() => {
mockedGenerateText.mockReset();
mockedGenerateText.mockResolvedValue({
text: 'Done',
steps: [],
@@ -50,130 +95,67 @@ describe('AgentAsyncExecutorService', () => {
} as never);
});
it('builds workflow execution with native tools eager and database/action tools lazy', async () => {
const registeredModel = {
modelId: 'openai/gpt-4o',
sdkPackage: '@ai-sdk/openai',
model: {} as never,
} as RegisteredAiModel;
const nativeModelTools = {
web_search: createTool('web_search'),
} as ToolSet;
const runtimeTools = {
web_search: createTool('web_search'),
learn_tools: createTool('learn_tools'),
execute_tool: createTool('execute_tool'),
} as ToolSet;
const lazyToolCatalog = [
createToolIndexEntry('find_people', ToolCategory.DATABASE_CRUD),
createToolIndexEntry('send_email', ToolCategory.ACTION),
];
const aiModelRegistryService = {
validateModelAvailability: jest.fn(),
resolveModelForAgent: jest.fn().mockReturnValue(registeredModel),
} as unknown as jest.Mocked<AiModelRegistryService>;
const aiModelConfigService = {
getProviderOptions: jest.fn().mockReturnValue({}),
} as unknown as jest.Mocked<AiModelConfigService>;
const lazyToolRuntimeService = {
buildToolRuntime: jest.fn().mockResolvedValue({
toolCatalog: lazyToolCatalog,
lazyToolCatalog,
directTools: nativeModelTools,
directToolNames: ['web_search'],
runtimeTools,
}),
} as unknown as jest.Mocked<LazyToolRuntimeService>;
const toolRegistry = {
getToolsByCategories: jest.fn().mockResolvedValue(nativeModelTools),
} as unknown as jest.Mocked<ToolRegistryService>;
const roleTargetRepository = {
findOne: jest.fn().mockResolvedValue({ roleId: 'agent-role-id' }),
};
const workspace = { id: 'workspace-id' } as WorkspaceEntity;
const workspaceRepository = {
findOneBy: jest.fn().mockResolvedValue(workspace),
};
const service = new AgentAsyncExecutorService(
aiModelRegistryService,
aiModelConfigService,
lazyToolRuntimeService,
toolRegistry,
roleTargetRepository as never,
workspaceRepository as never,
);
const agent = {
id: 'agent-id',
workspaceId: 'workspace-id',
modelId: 'openai/gpt-4o',
prompt: 'Use tools carefully.',
modelConfiguration: {
webSearch: { enabled: true },
codeInterpreter: { enabled: false },
},
responseFormat: { type: 'text' },
} as unknown as AgentEntity;
it('does not load workflow tools when the agent has no explicit role', async () => {
const { service, toolRegistry } = createService({
roleId: undefined,
});
await service.executeAgent({
agent,
userPrompt: 'Find the matching person.',
userPrompt: 'Find a record.',
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
});
expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled();
expect(mockedGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
tools: {},
}),
);
});
it('intersects the saved agent role with workflow execution permissions', async () => {
const tools = {
x_search: {
description: 'Search X',
inputSchema: {},
execute: jest.fn(),
},
} as unknown as ToolSet;
const { service, aiModelConfigService, toolRegistry } = createService({
roleId: 'agent-role-id',
tools,
});
await service.executeAgent({
agent,
userPrompt: 'Find a record.',
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
});
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: 'workspace-id',
roleId: 'agent-role-id',
rolePermissionConfig: { intersectionOf: ['agent-role-id'] },
rolePermissionConfig: {
intersectionOf: ['agent-role-id', 'workflow-role-id'],
},
agent: {
modelId: 'openai/gpt-4o',
modelId: 'xai/grok',
modelConfiguration: agent.modelConfiguration,
},
}),
{
categories: [ToolCategory.NATIVE_MODEL],
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
wrapWithErrorContext: false,
},
);
expect(lazyToolRuntimeService.buildToolRuntime).toHaveBeenCalledWith({
context: expect.objectContaining({
workspaceId: 'workspace-id',
roleId: 'agent-role-id',
agent: {
modelId: 'openai/gpt-4o',
modelConfiguration: agent.modelConfiguration,
},
}),
directTools: nativeModelTools,
lazyToolCategories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
});
expect(aiModelConfigService.getProviderOptions).toHaveBeenCalledWith(
registeredModel,
);
expect(mockedGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
tools: runtimeTools,
system: expect.stringContaining('`find_people`'),
}),
);
const firstGenerateTextCall = mockedGenerateText.mock.calls[0]?.[0];
expect(firstGenerateTextCall).toBeDefined();
expect(Object.keys(firstGenerateTextCall?.tools ?? {})).not.toEqual(
expect.arrayContaining(['find_people', 'send_email']),
);
});
});
@@ -12,23 +12,13 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
} from 'src/engine/core-modules/tool-provider/tools';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util';
@@ -36,17 +26,21 @@ import {
AiException,
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { ToolCategory } from 'twenty-shared/ai';
const WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES = [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
] as const;
type EffectiveAgentPermissions = {
agentRoleId: string;
rolePermissionConfig: RolePermissionConfig;
};
const toToolProviderAgent = (agent: AgentEntity): ToolProviderAgent => ({
modelId: agent.modelId,
@@ -63,7 +57,6 @@ export class AgentAsyncExecutorService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiModelConfigService: AiModelConfigService,
private readonly lazyToolRuntimeService: LazyToolRuntimeService,
private readonly toolRegistry: ToolRegistryService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
@@ -89,11 +82,41 @@ export class AgentAsyncExecutorService {
return [];
}
private describeRolePermissionConfig(
rolePermissionConfig?: RolePermissionConfig,
): string {
if (!rolePermissionConfig) {
return 'none';
}
if ('shouldBypassPermissionChecks' in rolePermissionConfig) {
return 'bypass';
}
if ('intersectionOf' in rolePermissionConfig) {
return `intersectionOf=[${rolePermissionConfig.intersectionOf.join(', ')}]`;
}
if ('unionOf' in rolePermissionConfig) {
return `unionOf=[${rolePermissionConfig.unionOf.join(', ')}]`;
}
return 'unknown';
}
private extractAttemptedToolNames(
steps: Array<{ toolCalls: Array<{ toolName: string }> }>,
): string[] {
return steps.flatMap((step) =>
step.toolCalls.map((toolCall) => toolCall.toolName),
);
}
private async getEffectiveRolePermissionConfig(
agentId: string,
workspaceId: string,
rolePermissionConfig?: RolePermissionConfig,
): Promise<RolePermissionConfig | undefined> {
): Promise<EffectiveAgentPermissions | undefined> {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId,
@@ -103,60 +126,19 @@ export class AgentAsyncExecutorService {
});
const agentRoleId = roleTarget?.roleId;
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
const allRoleIds = agentRoleId
? [...new Set([...configRoleIds, agentRoleId])]
: configRoleIds;
if (allRoleIds.length === 0) {
if (!agentRoleId) {
return undefined;
}
return { intersectionOf: allRoleIds };
}
const workflowRoleIds = this.extractRoleIds(rolePermissionConfig);
private buildWorkflowToolCatalogPrompt({
toolCatalog,
directToolNames,
}: {
toolCatalog: ToolIndexEntry[];
directToolNames: string[];
}): string {
const toolsByCategory = new Map<ToolCategory, ToolIndexEntry[]>();
for (const tool of toolCatalog) {
const existing = toolsByCategory.get(tool.category) ?? [];
existing.push(tool);
toolsByCategory.set(tool.category, existing);
}
const directToolsSection =
directToolNames.length > 0
? `Direct native model tools available now: ${directToolNames.map((toolName) => `\`${toolName}\``).join(', ')}.`
: 'No direct native model tools are available.';
const sections = [
`## Available Workflow Tools
${directToolsSection}
For database and action tools, first call \`${LEARN_TOOLS_TOOL_NAME}\` with the exact tool name to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` with matching arguments. Do not call tools that are not listed below.`,
];
for (const category of WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES) {
const tools = toolsByCategory.get(category);
if (!tools || tools.length === 0) {
continue;
}
sections.push(`### ${category}
${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
}
return sections.join('\n\n');
return {
agentRoleId,
rolePermissionConfig: {
intersectionOf: [...new Set([agentRoleId, ...workflowRoleIds])],
},
};
}
async executeAgent({
@@ -172,7 +154,9 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
}): Promise<AgentExecutionResult> {
let lazyWorkflowToolCount = 0;
let registeredModel: RegisteredAiModel | undefined;
let generatedToolNames: string[] = [];
let effectiveAgentPermissions: EffectiveAgentPermissions | undefined;
try {
if (agent) {
@@ -188,72 +172,69 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
}
}
const registeredModel =
registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
let tools: ToolSet = {};
let providerOptions = {};
let workflowToolCatalogPrompt = '';
const workflowRoleIds = this.extractRoleIds(rolePermissionConfig);
if (agent) {
const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig(
effectiveAgentPermissions = await this.getEffectiveRolePermissionConfig(
agent.id,
agent.workspaceId,
rolePermissionConfig,
);
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
const toolProviderContext: ToolProviderContext = {
workspaceId: agent.workspaceId,
roleId,
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
authContext,
actorContext,
agent: toToolProviderAgent(agent),
userId:
isDefined(authContext) && isUserAuthContext(authContext)
? authContext.user.id
: undefined,
userWorkspaceId:
isDefined(authContext) && isUserAuthContext(authContext)
? authContext.userWorkspaceId
: undefined,
};
const nativeModelTools = await this.toolRegistry.getToolsByCategories(
toolProviderContext,
{
categories: [ToolCategory.NATIVE_MODEL],
wrapWithErrorContext: false,
},
);
const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({
context: toolProviderContext,
directTools: nativeModelTools,
lazyToolCategories: WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES,
});
tools = toolRuntime.runtimeTools;
lazyWorkflowToolCount = toolRuntime.lazyToolCatalog.length;
workflowToolCatalogPrompt = this.buildWorkflowToolCatalogPrompt({
toolCatalog: toolRuntime.lazyToolCatalog,
directToolNames: toolRuntime.directToolNames,
});
if (effectiveAgentPermissions) {
tools = await this.toolRegistry.getToolsByCategories(
{
workspaceId: agent.workspaceId,
roleId: effectiveAgentPermissions.agentRoleId,
rolePermissionConfig:
effectiveAgentPermissions.rolePermissionConfig,
authContext,
actorContext,
agent: toToolProviderAgent(agent),
userId:
isDefined(authContext) && isUserAuthContext(authContext)
? authContext.user.id
: undefined,
userWorkspaceId:
isDefined(authContext) && isUserAuthContext(authContext)
? authContext.userWorkspaceId
: undefined,
},
{
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
wrapWithErrorContext: false,
},
);
}
providerOptions = this.aiModelConfigService.getProviderOptions(
registeredModel,
);
generatedToolNames = Object.keys(tools).sort();
this.logger.log(
`Workflow agent tool context: agentId=${agent.id} modelId=${registeredModel.modelId} workflowRoleIds=[${workflowRoleIds.join(', ')}] savedAgentRoleId=${effectiveAgentPermissions?.agentRoleId ?? 'none'} effectiveRolePermissionConfig=${this.describeRolePermissionConfig(effectiveAgentPermissions?.rolePermissionConfig)} toolCount=${generatedToolNames.length}`,
);
if (generatedToolNames.length > 0) {
this.logger.log(
`Workflow agent generated tools for ${agent.id}: ${generatedToolNames.join(', ')}`,
);
}
}
const runtimeToolCount = Object.keys(tools).length;
this.logger.log(
`Generated ${runtimeToolCount} runtime tools and ${lazyWorkflowToolCount} lazy workflow tools for agent`,
);
const textResponse = await generateText({
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${workflowToolCatalogPrompt}\n\n${agent ? agent.prompt : ''}`,
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`,
tools,
model: registeredModel.model,
prompt: userPrompt,
@@ -276,6 +257,14 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
},
});
const attemptedToolNames = this.extractAttemptedToolNames(
textResponse.steps,
);
this.logger.log(
`Workflow agent model response: agentId=${agent?.id ?? 'none'} modelId=${registeredModel.modelId} finishReason=${textResponse.finishReason} stepCount=${textResponse.steps.length} attemptedToolCalls=[${attemptedToolNames.join(', ')}]`,
);
const cacheCreationTokens = extractCacheCreationTokensFromSteps(
textResponse.steps,
);
@@ -331,6 +320,23 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`);
throw error;
}
const errorDetails =
typeof error === 'object' && error !== null
? {
name: 'name' in error ? error.name : undefined,
message: 'message' in error ? error.message : undefined,
statusCode:
'statusCode' in error ? error.statusCode : undefined,
responseBody:
'responseBody' in error ? error.responseBody : undefined,
cause: 'cause' in error ? error.cause : undefined,
}
: { message: String(error) };
this.logger.error(
`Workflow agent execution failed: agentId=${agent?.id ?? 'none'} modelId=${registeredModel?.modelId ?? 'unknown'} savedAgentRoleId=${effectiveAgentPermissions?.agentRoleId ?? 'none'} toolCount=${generatedToolNames.length} error=${JSON.stringify(errorDetails)}`,
);
throw new AiException(
error instanceof Error ? error.message : 'Agent execution failed',
AiExceptionCode.AGENT_EXECUTION_FAILED,
@@ -1,21 +1,15 @@
import { type StepResult, type ToolSet } from 'ai';
// Shared by billing and workflow execution logging because both treat these
// as provider-native search tool calls.
export const NATIVE_SEARCH_TOOL_NAMES = new Set(['web_search', 'x_search']);
const WEB_SEARCH_TOOL_NAME = 'web_search';
export const countNativeWebSearchCallsFromSteps = (
steps: StepResult<ToolSet>[],
): number => {
let searchCallCount = 0;
for (const step of steps) {
for (const toolCall of step.toolCalls) {
if (NATIVE_SEARCH_TOOL_NAMES.has(toolCall.toolName)) {
searchCallCount += 1;
}
}
}
return searchCallCount;
};
): number =>
steps.reduce(
(count, step) =>
count +
step.toolCalls.filter(
(toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME,
).length,
0,
);
@@ -23,15 +23,16 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter
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 { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util';
import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
createExecuteToolTool,
createLearnToolsTool,
createLoadSkillTool,
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
LOAD_SKILL_TOOL_NAME,
} from 'src/engine/core-modules/tool-provider/tools';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
@@ -54,10 +55,10 @@ import {
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
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';
import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
@@ -82,7 +83,6 @@ export class ChatExecutionService {
private readonly logger = new Logger(ChatExecutionService.name);
constructor(
private readonly lazyToolRuntimeService: LazyToolRuntimeService,
private readonly toolRegistry: ToolRegistryService,
private readonly skillService: SkillService,
private readonly aiModelRegistryService: AiModelRegistryService,
@@ -92,7 +92,7 @@ export class ChatExecutionService {
private readonly codeInterpreterService: CodeInterpreterService,
private readonly systemPromptBuilder: SystemPromptBuilderService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly sdkProviderFactory: SdkProviderFactoryService,
private readonly aiModelConfigService: AiModelConfigService,
private readonly messagePruningService: MessagePruningService,
private readonly webSearchService: WebSearchService,
) {}
@@ -114,12 +114,9 @@ export class ChatExecutionService {
workspace.id,
);
// Regular AI chat is not executing a saved agent, so agent-specific
// capability toggles should only apply in AgentAsyncExecutorService.
const toolProviderContext: ToolProviderContext = {
const toolContext = {
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
actorContext,
userId,
userWorkspaceId,
@@ -130,17 +127,32 @@ export class ChatExecutionService {
? this.buildContextFromBrowsingContext(workspace, browsingContext)
: undefined;
const useNativeSearch = this.webSearchService.shouldUseNativeSearch();
const toolCatalog = await this.toolRegistry.buildToolIndex(
workspace.id,
roleId,
{ userId, userWorkspaceId },
);
const skillCatalog = await this.skillService.findAllFlatSkills(
workspace.id,
);
this.logger.log(
`Web search strategy: ${useNativeSearch ? 'native (provider SDK)' : 'external (EXA)'}`,
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
);
const useNativeSearch = this.webSearchService.shouldUseNativeSearch();
const toolNamesToPreload = [
...COMMON_PRELOAD_TOOLS,
...(useNativeSearch ? [] : ['web_search']),
];
const preloadedTools = await this.toolRegistry.getToolsByName(
toolNamesToPreload,
toolContext,
);
const resolvedModelId = modelId ?? workspace.smartModel;
this.aiModelRegistryService.validateModelAvailability(
@@ -157,38 +169,36 @@ export class ChatExecutionService {
registeredModel.modelId,
);
const { tools: nativeSearchTools } = useNativeSearch
? this.getNativeWebSearchTools(registeredModel)
: { tools: {} };
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
this.aiModelConfigService.getChatNativeSearchTools(registeredModel, {
useProviderNativeWebSearch: useNativeSearch,
});
const preloadedTools = await this.toolRegistry.getToolsByName(
toolNamesToPreload,
toolProviderContext,
);
// Direct tools: native provider tools + preloaded tools.
// These are callable directly AND as fallback through execute_tool.
const directTools: ToolSet = {
...wrapToolsWithOutputSerialization(preloadedTools),
...nativeSearchTools,
};
const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({
context: toolProviderContext,
directTools: {
...wrapToolsWithOutputSerialization(preloadedTools),
...nativeSearchTools,
},
});
const toolCatalog = toolRuntime.toolCatalog;
const skillCatalog = await this.skillService.findAllFlatSkills(
workspace.id,
);
this.logger.log(
`Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`,
);
const preloadedToolNames = toolRuntime.directToolNames;
const preloadedToolNames = [
...Object.keys(preloadedTools),
...searchToolNames,
];
// ToolSet is constant for the entire conversation — no mutation.
// learn_tools returns schemas as text; execute_tool dispatches to cached tools.
const activeTools: ToolSet = {
...toolRuntime.runtimeTools,
...directTools,
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
this.toolRegistry,
toolContext,
),
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
this.toolRegistry,
toolContext,
directTools,
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
(skillNames) =>
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
@@ -444,48 +454,6 @@ export class ChatExecutionService {
return context;
}
private getNativeWebSearchTools(model: RegisteredAiModel): {
tools: ToolSet;
} {
const empty = { tools: {} };
const providerName = model.providerName;
if (!providerName) {
return empty;
}
switch (model.sdkPackage) {
case AI_SDK_ANTHROPIC: {
const provider =
this.sdkProviderFactory.getRawAnthropicProvider(providerName);
if (!provider) {
return empty;
}
return {
tools: { web_search: provider.tools.webSearch_20250305() },
};
}
case AI_SDK_BEDROCK:
return empty;
case AI_SDK_OPENAI: {
const provider =
this.sdkProviderFactory.getRawOpenAIProvider(providerName);
if (!provider) {
return empty;
}
return {
tools: { web_search: provider.tools.webSearch() },
};
}
default:
return empty;
}
}
private async storeExtractedFiles(
files: ExtractedFile[],
_workspaceId: string,
@@ -0,0 +1,89 @@
import { AiModelConfigService } from './ai-model-config.service';
import {
AI_SDK_OPENAI,
AI_SDK_XAI,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { type SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
describe('AiModelConfigService', () => {
const createService = (
sdkProviderFactory: Partial<SdkProviderFactoryService>,
) => new AiModelConfigService(sdkProviderFactory as SdkProviderFactoryService);
const xSearchTool = { type: 'provider', id: 'xai.x_search', args: {} };
const webSearchTool = { type: 'provider', id: 'xai.web_search', args: {} };
it('keeps x search available for xAI chat when external web search is preferred', () => {
const service = createService({
getRawXaiProvider: jest.fn().mockReturnValue({
tools: {
xSearch: jest.fn().mockReturnValue(xSearchTool),
webSearch: jest.fn().mockReturnValue(webSearchTool),
},
}),
});
const result = service.getChatNativeSearchTools(
{
sdkPackage: AI_SDK_XAI,
providerName: 'xai',
} as RegisteredAiModel,
{ useProviderNativeWebSearch: false },
);
expect(result).toEqual({
tools: {
x_search: xSearchTool,
},
callableToolNames: ['x_search'],
});
});
it('exposes both x search and native web search for xAI chat when enabled', () => {
const service = createService({
getRawXaiProvider: jest.fn().mockReturnValue({
tools: {
xSearch: jest.fn().mockReturnValue(xSearchTool),
webSearch: jest.fn().mockReturnValue(webSearchTool),
},
}),
});
const result = service.getChatNativeSearchTools(
{
sdkPackage: AI_SDK_XAI,
providerName: 'xai',
} as RegisteredAiModel,
{ useProviderNativeWebSearch: true },
);
expect(result).toEqual({
tools: {
web_search: webSearchTool,
x_search: xSearchTool,
},
callableToolNames: ['web_search', 'x_search'],
});
});
it('keeps OpenAI native web search disabled when external search is preferred', () => {
const service = createService({
getRawOpenAIProvider: jest.fn(),
});
const result = service.getChatNativeSearchTools(
{
sdkPackage: AI_SDK_OPENAI,
providerName: 'openai',
} as RegisteredAiModel,
{ useProviderNativeWebSearch: false },
);
expect(result).toEqual({
tools: {},
callableToolNames: [],
});
});
});
@@ -15,6 +15,13 @@ import {
import { 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';
type ChatNativeSearchTools = {
tools: ToolSet;
callableToolNames: string[];
};
type NativeSearchToolEntry = [string, ToolSet[string]];
@Injectable()
export class AiModelConfigService {
constructor(private readonly sdkProviderFactory: SdkProviderFactoryService) {}
@@ -37,7 +44,6 @@ export class AiModelConfigService {
agent: ToolProviderAgent,
options: { useProviderNativeWebSearch: boolean },
): ToolSet {
const tools: ToolSet = {};
const modelConfiguration = agent.modelConfiguration ?? {};
const isWebSearchEnabledForAgent = isAgentCapabilityEnabled(
modelConfiguration,
@@ -50,69 +56,27 @@ export class AiModelConfigService {
const shouldExposeProviderNativeWebSearch =
options.useProviderNativeWebSearch && isWebSearchEnabledForAgent;
switch (model.sdkPackage) {
case AI_SDK_ANTHROPIC:
if (shouldExposeProviderNativeWebSearch) {
const anthropicProvider = model.providerName
? this.sdkProviderFactory.getRawAnthropicProvider(
model.providerName,
)
: undefined;
const toolEntries = this.getNativeSearchToolEntries(model, {
exposeWebSearch: shouldExposeProviderNativeWebSearch,
exposeTwitterSearch: isTwitterSearchEnabledForAgent,
});
if (anthropicProvider) {
tools.web_search = anthropicProvider.tools.webSearch_20250305();
}
}
break;
case AI_SDK_BEDROCK: {
if (shouldExposeProviderNativeWebSearch) {
const bedrockProvider = model.providerName
? this.sdkProviderFactory.getRawBedrockProvider(model.providerName)
: undefined;
return Object.fromEntries(toolEntries) as ToolSet;
}
if (bedrockProvider) {
tools.web_search =
bedrockProvider.tools.webSearch_20250305() as ToolSet[string];
}
}
break;
}
case AI_SDK_OPENAI:
if (shouldExposeProviderNativeWebSearch) {
const openaiProvider = model.providerName
? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName)
: undefined;
getChatNativeSearchTools(
model: RegisteredAiModel,
options: { useProviderNativeWebSearch: boolean },
): ChatNativeSearchTools {
const toolEntries = this.getNativeSearchToolEntries(model, {
exposeWebSearch: options.useProviderNativeWebSearch,
exposeTwitterSearch: model.sdkPackage === AI_SDK_XAI,
});
if (openaiProvider) {
tools.web_search = openaiProvider.tools.webSearch();
}
}
break;
case AI_SDK_XAI:
if (!model.providerName) {
break;
}
const xaiProvider = this.sdkProviderFactory.getRawXaiProvider(
model.providerName,
);
if (!xaiProvider) {
break;
}
if (shouldExposeProviderNativeWebSearch) {
tools.web_search = xaiProvider.tools.webSearch() as ToolSet[string];
}
if (isTwitterSearchEnabledForAgent) {
tools.x_search = xaiProvider.tools.xSearch() as ToolSet[string];
}
break;
}
return tools;
return {
tools: Object.fromEntries(toolEntries) as ToolSet,
callableToolNames: toolEntries.map(([toolName]) => toolName),
};
}
private getAnthropicProviderOptions(
@@ -146,4 +110,98 @@ export class AiModelConfigService {
},
};
}
private getNativeSearchToolEntries(
model: RegisteredAiModel,
options: {
exposeWebSearch: boolean;
exposeTwitterSearch: boolean;
},
): NativeSearchToolEntry[] {
if (!model.providerName) {
return [];
}
switch (model.sdkPackage) {
case AI_SDK_ANTHROPIC: {
if (!options.exposeWebSearch) {
return [];
}
const anthropicProvider = this.sdkProviderFactory.getRawAnthropicProvider(
model.providerName,
);
if (!anthropicProvider) {
return [];
}
return [['web_search', anthropicProvider.tools.webSearch_20250305()]];
}
case AI_SDK_BEDROCK: {
if (!options.exposeWebSearch) {
return [];
}
const bedrockProvider = this.sdkProviderFactory.getRawBedrockProvider(
model.providerName,
);
if (!bedrockProvider) {
return [];
}
return [
[
'web_search',
bedrockProvider.tools.webSearch_20250305() as ToolSet[string],
],
];
}
case AI_SDK_OPENAI: {
if (!options.exposeWebSearch) {
return [];
}
const openAiProvider = this.sdkProviderFactory.getRawOpenAIProvider(
model.providerName,
);
if (!openAiProvider) {
return [];
}
return [['web_search', openAiProvider.tools.webSearch()]];
}
case AI_SDK_XAI: {
const xaiProvider = this.sdkProviderFactory.getRawXaiProvider(
model.providerName,
);
if (!xaiProvider) {
return [];
}
const toolEntries: NativeSearchToolEntry[] = [];
if (options.exposeWebSearch) {
toolEntries.push([
'web_search',
xaiProvider.tools.webSearch() as ToolSet[string],
]);
}
if (options.exposeTwitterSearch) {
toolEntries.push([
'x_search',
xaiProvider.tools.xSearch() as ToolSet[string],
]);
}
return toolEntries;
}
default:
return [];
}
}
}
@@ -3,9 +3,7 @@ import { type AgentCapability } from '../types/agent-capability.type';
export const AGENT_CAPABILITY_DEFAULTS = {
webSearch: true,
twitterSearch: false,
// Default-off: unlike webSearch (free via model-native capability), codeInterpreter
// has no free path — E2B bills per execution. Per-agent opt-in prevents silent
// spend. Breaking for self-hosters on CODE_INTERPRETER_TYPE=E2B: existing agents
// must flip the per-agent toggle to restore prior workspace-wide behavior.
// Default-off because code execution is a higher-risk, billable capability.
// Per-agent opt-in keeps existing workspaces from silently widening access.
codeInterpreter: false,
} satisfies Record<AgentCapability, boolean>;
@@ -1,3 +1,4 @@
import { type ModelConfiguration } from './model-configuration.type';
export type AgentCapability = keyof ModelConfiguration;
export type AgentCapability =
| 'webSearch'
| 'twitterSearch'
| 'codeInterpreter';