continue
This commit is contained in:
+27
-21
@@ -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,
|
||||
|
||||
+5
-4
@@ -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;
|
||||
|
||||
+34
@@ -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')
|
||||
|
||||
+6
-6
@@ -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:
|
||||
|
||||
+11
@@ -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
-7
@@ -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);
|
||||
|
||||
|
||||
-122
@@ -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'],
|
||||
);
|
||||
});
|
||||
});
|
||||
-85
@@ -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));
|
||||
}
|
||||
}
|
||||
+43
-14
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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
-2
@@ -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>,
|
||||
) => ({
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
-78
@@ -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']);
|
||||
});
|
||||
});
|
||||
+3
-10
@@ -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],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+106
-124
@@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+124
-118
@@ -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,
|
||||
|
||||
+10
-16
@@ -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,
|
||||
);
|
||||
|
||||
+49
-81
@@ -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,
|
||||
|
||||
+89
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
+119
-61
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user