fix: report unavailable agent tools in publish and workflow checks (#40077)

This commit is contained in:
Joel
2026-08-06 07:44:22 +00:00
committed by GitHub
parent dc309ced59
commit 06d8301a90
8 changed files with 714 additions and 180 deletions
@@ -1,5 +1,7 @@
import type { AgentSoulDifyToolConfig } from '@dify/contracts/api/console/apps/types.gen'
import type { CommonNodeType, Node } from '../../types'
import type { ChecklistItem } from '../use-checklist'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen'
import { QueryClient } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
@@ -18,6 +20,17 @@ import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
// Mocks
// ---------------------------------------------------------------------------
const toolServiceState = vi.hoisted(() => ({
buildInTools: [] as ToolWithProvider[] | undefined,
customTools: [] as ToolWithProvider[] | undefined,
mcpTools: [] as ToolWithProvider[] | undefined,
workflowTools: [] as ToolWithProvider[] | undefined,
}))
const marketplacePluginState = vi.hoisted(() => ({
label: undefined as Record<string, string> | undefined,
}))
vi.mock('reactflow', async () => {
const base = (await import('../../__tests__/reactflow-mock-state')).createReactFlowModuleMock()
return {
@@ -31,9 +44,32 @@ vi.mock('reactflow', async () => {
}
})
vi.mock('@/service/use-tools', async () =>
(await import('../../__tests__/service-mock-factory')).createToolServiceMock(),
)
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: toolServiceState.buildInTools }),
useAllCustomTools: () => ({ data: toolServiceState.customTools }),
useAllMCPTools: () => ({ data: toolServiceState.mcpTools }),
useAllWorkflowTools: () => ({ data: toolServiceState.workflowTools }),
}))
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({
data:
infos.length > 0 && marketplacePluginState.label
? {
data: {
list: infos.map(({ organization, plugin }) => ({
plugin: {
label: marketplacePluginState.label,
labels: marketplacePluginState.label,
name: plugin,
plugin_id: `${organization}/${plugin}`,
},
})),
},
}
: undefined,
}),
}))
vi.mock('@/service/use-triggers', async () =>
(await import('../../__tests__/service-mock-factory')).createTriggerServiceMock(),
@@ -163,6 +199,11 @@ beforeEach(() => {
Object.keys(mockAvailableVarMap).forEach((k) => delete mockAvailableVarMap[k])
mockModelProviders = []
mockUsedVars = []
toolServiceState.buildInTools = []
toolServiceState.customTools = []
toolServiceState.mcpTools = []
toolServiceState.workflowTools = []
marketplacePluginState.label = undefined
setupNodesMap()
})
@@ -183,11 +224,13 @@ function buildConnectedGraph() {
}
function buildInlineAgentGraph({
hasMissingFile,
hasMissingSkill,
difyTools = [],
hasMissingFile = false,
hasMissingSkill = false,
}: {
hasMissingFile: boolean
hasMissingSkill: boolean
difyTools?: AgentSoulDifyToolConfig[]
hasMissingFile?: boolean
hasMissingSkill?: boolean
}) {
const queryClient = new QueryClient({
defaultOptions: {
@@ -217,6 +260,9 @@ function buildInlineAgentGraph({
{ name: 'Available Skill' },
...(hasMissingSkill ? [{ is_missing: true, name: 'Missing Skill' }] : []),
],
tools: {
dify_tools: difyTools,
},
},
node_job: {},
save_options: [],
@@ -258,6 +304,45 @@ function buildInlineAgentGraph({
}
}
const credentialRequiredProvider = {
id: 'google',
name: 'google',
author: 'Google',
description: {
en_US: 'Google tools.',
zh_Hans: 'Google 工具。',
},
icon: 'https://example.com/google.svg',
icon_dark: 'https://example.com/google-dark.svg',
label: {
en_US: 'Google Tools',
zh_Hans: 'Google 工具',
},
type: CollectionType.builtIn,
team_credentials: {
api_key: {
label: {
en_US: 'API Key',
zh_Hans: 'API Key',
},
placeholder: {
en_US: 'Enter API key',
zh_Hans: '输入 API Key',
},
required: true,
type: 'secret-input',
variable: 'api_key',
},
},
is_team_authorization: false,
allow_delete: false,
labels: [],
meta: {
version: '0.0.1',
},
tools: [],
} satisfies ToolWithProvider
// ---------------------------------------------------------------------------
// useChecklist
// ---------------------------------------------------------------------------
@@ -343,6 +428,61 @@ describe('useChecklist', () => {
expect(result.current).toEqual([])
})
it('should report uninstalled tools from inline agents and open their configuration panel', async () => {
marketplacePluginState.label = {
en_US: 'Jina',
}
const { edges, nodeId, nodes, options } = buildInlineAgentGraph({
difyTools: [
{
credential_type: 'unauthorized',
plugin_id: 'langgenius/jina_tool',
provider: 'langgenius/jina_tool/jina',
provider_id: 'langgenius/jina_tool/jina',
provider_type: 'plugin',
tool_name: 'search',
},
],
})
const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options)
await waitFor(() => {
expect(result.current).toEqual([
expect.objectContaining({
id: nodeId,
errorMessages: ['workflow.nodes.agent.toolNotInstallTooltip:{"tool":"Jina"}'],
openInlineAgentPanel: true,
}),
])
})
})
it('should report unauthorized tools from inline agents and open their configuration panel', async () => {
toolServiceState.buildInTools = [credentialRequiredProvider]
const { edges, nodeId, nodes, options } = buildInlineAgentGraph({
difyTools: [
{
credential_type: 'unauthorized',
provider: 'google',
provider_id: 'google',
provider_type: 'builtin',
tool_name: 'search',
},
],
})
const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options)
await waitFor(() => {
expect(result.current).toEqual([
expect.objectContaining({
id: nodeId,
errorMessages: ['workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"Google Tools"}'],
openInlineAgentPanel: true,
}),
])
})
})
it('should pass flow type to node validators', () => {
const checkValid = vi.fn(() => ({ errorMessage: '' }))
mockNodesMap[BlockEnum.LLM] = {
@@ -15,6 +15,7 @@ import type {
} from '../types'
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Emoji } from '@/app/components/tools/types'
import type { AgentToolPublishIssue } from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog'
import type { DataSet } from '@/models/datasets'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
@@ -31,6 +32,12 @@ import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
import {
createAgentToolProviderCatalog,
getAgentToolPublishIssues,
useAgentToolPresentation,
} from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog'
import { consoleQuery } from '@/service/client'
import { fetchDatasets } from '@/service/datasets'
import { useStrategyProviders } from '@/service/use-strategy'
@@ -148,6 +155,10 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const inlineAgentToolProviderCatalog = useMemo(
() => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }),
[buildInTools, customTools, mcpTools, workflowTools],
)
const dataSourceList = useStore((s) => s.dataSourceList)
const environmentVariables =
useStore((s) => s.environmentVariables) ?? EMPTY_ENVIRONMENT_VARIABLES
@@ -173,7 +184,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
),
[nodes],
)
const inlineAgentMissingReferences = useQueries({
const inlineAgentConfigurationIssues = useQueries({
queries:
!configsMap?.flowId ||
(configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet)
@@ -202,9 +213,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
),
),
combine: (results) => {
const missingReferences: Record<
const issuesByNodeId: Record<
string,
{ hasMissingFiles: boolean; hasMissingSkills: boolean }
{
hasMissingFiles: boolean
hasMissingSkills: boolean
toolIssues: AgentToolPublishIssue[]
}
> = {}
results.forEach((result, index) => {
@@ -214,17 +229,33 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
const hasMissingFiles = agentSoul.config_files?.some((file) => file.is_missing === true)
const hasMissingSkills = agentSoul.config_skills?.some((skill) => skill.is_missing === true)
if (!hasMissingFiles && !hasMissingSkills) return
const toolIssues = getAgentToolPublishIssues(
agentSoulConfigToFormState(agentSoul).tools,
inlineAgentToolProviderCatalog,
)
if (!hasMissingFiles && !hasMissingSkills && toolIssues.length === 0) return
missingReferences[nodeId] = {
issuesByNodeId[nodeId] = {
hasMissingFiles: !!hasMissingFiles,
hasMissingSkills: !!hasMissingSkills,
toolIssues,
}
})
return missingReferences
return issuesByNodeId
},
})
const inlineAgentIssueTools = useMemo(
() =>
Object.values(inlineAgentConfigurationIssues).flatMap((issues) =>
issues.toolIssues.map((issue) => issue.tool),
),
[inlineAgentConfigurationIssues],
)
const inlineAgentToolPresentation = useAgentToolPresentation(
inlineAgentIssueTools,
inlineAgentToolProviderCatalog,
)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const knowledgeBaseEmbeddingProviders = useMemo(() => {
@@ -366,7 +397,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
})
const errorMessages: string[] = []
const missingReferences = inlineAgentMissingReferences[node!.id]
const inlineAgentIssues = inlineAgentConfigurationIssues[node!.id]
if (isPluginMissing) {
errorMessages.push(t(($) => $['nodes.common.pluginNotInstalled'], { ns: 'workflow' }))
@@ -398,14 +429,30 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
if (validationError) errorMessages.push(validationError)
}
if (missingReferences?.hasMissingFiles)
if (inlineAgentIssues?.hasMissingFiles)
errorMessages.push(
t(($) => $['agentDetail.configure.files.missing'], { ns: 'agentV2' }),
)
if (missingReferences?.hasMissingSkills)
if (inlineAgentIssues?.hasMissingSkills)
errorMessages.push(
t(($) => $['agentDetail.configure.skills.missing'], { ns: 'agentV2' }),
)
for (const toolIssue of inlineAgentIssues?.toolIssues ?? []) {
const toolName =
inlineAgentToolPresentation.toolDisplayNameById.get(toolIssue.tool.id) ??
toolIssue.tool.name
errorMessages.push(
toolIssue.type === 'uninstalled'
? t(($) => $['nodes.agent.toolNotInstallTooltip'], {
ns: 'workflow',
tool: toolName,
})
: t(($) => $['nodes.agent.toolNotAuthorizedTooltip'], {
ns: 'workflow',
tool: toolName,
}),
)
}
const availableVars = map[node!.id]!.availableVars
let hasInvalidVar = false
@@ -447,7 +494,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
pluginUniqueIdentifier: isPluginMissing
? (node!.data as { plugin_unique_identifier?: string }).plugin_unique_identifier
: undefined,
...(missingReferences ? { openInlineAgentPanel: true } : {}),
...(inlineAgentIssues ? { openInlineAgentPanel: true } : {}),
})
}
}
@@ -517,7 +564,8 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
t,
map,
modelProviders,
inlineAgentMissingReferences,
inlineAgentConfigurationIssues,
inlineAgentToolPresentation.toolDisplayNameById,
options?.flowType,
])
@@ -7,7 +7,9 @@ import type {
} from '../form-state'
import type { DraftFieldUpdate } from './utils'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import isEqual from 'fast-deep-equal'
import { atom } from 'jotai'
import { selectAtom } from 'jotai/utils'
import { syncCliToolReferenceLabels } from '../reference-labels'
import { agentComposerDraftAtom } from '../store'
import { resolveDraftFieldUpdate } from './utils'
@@ -37,6 +39,26 @@ export const agentComposerToolsAtom = atom(
},
)
export const agentComposerToolPresentationIdentitiesAtom = selectAtom(
agentComposerToolsAtom,
(tools) =>
tools.flatMap((tool) => {
if (tool.kind !== 'provider') return []
return [
{
kind: tool.kind,
id: tool.id,
name: tool.name,
displayName: tool.displayName,
pluginId: tool.pluginId,
providerType: tool.providerType,
},
]
}),
isEqual,
)
const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({
id: `${tool.provider_id}:${tool.tool_name}`,
name: tool.tool_label || tool.title || tool.tool_name,
@@ -1,7 +1,10 @@
import type { PropsWithChildren } from 'react'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook } from '@testing-library/react'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { CollectionType } from '@/app/components/tools/types'
import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import {
@@ -20,6 +23,17 @@ const toastMock = vi.hoisted(() => ({
const trackEventMock = vi.hoisted(() => vi.fn())
const toolProviderState = vi.hoisted(() => ({
builtInTools: [] as ToolWithProvider[] | undefined,
customTools: [] as ToolWithProvider[] | undefined,
mcpTools: [] as ToolWithProvider[] | undefined,
workflowTools: [] as ToolWithProvider[] | undefined,
}))
const marketplacePluginState = vi.hoisted(() => ({
label: undefined as Record<string, string> | undefined,
}))
const composerPutMutationFn = vi.hoisted(() =>
vi.fn(
async (variables: {
@@ -139,6 +153,37 @@ vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: trackEventMock,
}))
vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en_US',
}))
vi.mock('@/service/use-plugins', () => ({
useFetchPluginsInMarketPlaceByInfo: (infos: Array<{ organization: string; plugin: string }>) => ({
data:
infos.length > 0 && marketplacePluginState.label
? {
data: {
list: infos.map(({ organization, plugin }) => ({
plugin: {
label: marketplacePluginState.label,
labels: marketplacePluginState.label,
name: plugin,
plugin_id: `${organization}/${plugin}`,
},
})),
},
}
: undefined,
}),
}))
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => ({ data: toolProviderState.builtInTools }),
useAllCustomTools: () => ({ data: toolProviderState.customTools }),
useAllMCPTools: () => ({ data: toolProviderState.mcpTools }),
useAllWorkflowTools: () => ({ data: toolProviderState.workflowTools }),
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
agent: {
@@ -219,11 +264,55 @@ function renderUseAgentConfigureSync({
}
}
const credentialRequiredProvider = {
id: 'google',
name: 'google',
author: 'Google',
description: {
en_US: 'Google tools.',
zh_Hans: 'Google 工具。',
},
icon: 'https://example.com/google.svg',
icon_dark: 'https://example.com/google-dark.svg',
label: {
en_US: 'Google Tools',
zh_Hans: 'Google 工具',
},
type: CollectionType.builtIn,
team_credentials: {
api_key: {
label: {
en_US: 'API Key',
zh_Hans: 'API Key',
},
placeholder: {
en_US: 'Enter API key',
zh_Hans: '输入 API Key',
},
required: true,
type: 'secret-input',
variable: 'api_key',
},
},
is_team_authorization: false,
allow_delete: false,
labels: [],
meta: {
version: '0.0.1',
},
tools: [],
} satisfies ToolWithProvider
describe('useAgentConfigureSync', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
composerPutRequestContexts.length = 0
toolProviderState.builtInTools = []
toolProviderState.customTools = []
toolProviderState.mcpTools = []
toolProviderState.workflowTools = []
marketplacePluginState.label = undefined
})
afterEach(() => {
@@ -880,6 +969,81 @@ describe('useAgentConfigureSync', () => {
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
})
it('should toast and skip publish when a configured tool is not installed', async () => {
marketplacePluginState.label = {
en_US: 'Jina',
}
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
tools: [
{
id: 'langgenius/jina_tool/jina',
kind: 'provider',
name: 'langgenius/jina_tool/jina',
iconClassName: 'i-custom-public-other-default-tool-icon',
providerType: 'plugin',
credentialType: 'unauthorized',
credentialVariant: 'unauthorized',
actions: [],
},
],
} satisfies AgentSoulConfigFormState)
})
await act(async () => {
await result.current.publishDraft()
})
expect(composerPutMutationFn).not.toHaveBeenCalled()
expect(publishAgentMutationFn).not.toHaveBeenCalled()
expect(trackEventMock).not.toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith(
'workflow.nodes.agent.toolNotInstallTooltip:{"tool":"Jina"}',
)
})
it('should toast and skip publish when a configured tool is not authorized', async () => {
toolProviderState.builtInTools = [credentialRequiredProvider]
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
})
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
tools: [
{
id: 'google',
kind: 'provider',
name: 'google',
displayName: 'Google Tools',
iconClassName: 'i-custom-public-other-default-tool-icon',
providerType: 'builtin',
credentialType: 'unauthorized',
credentialVariant: 'unauthorized',
actions: [],
},
],
} satisfies AgentSoulConfigFormState)
})
await act(async () => {
await result.current.publishDraft()
})
expect(composerPutMutationFn).not.toHaveBeenCalled()
expect(publishAgentMutationFn).not.toHaveBeenCalled()
expect(trackEventMock).not.toHaveBeenCalled()
expect(toastMock.error).toHaveBeenCalledWith(
'workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"Google Tools"}',
)
})
it('should keep default model fallback from leaving the local draft dirty after publish', async () => {
const { result, store } = renderUseAgentConfigureSync({
currentModel: configuredModel,
@@ -1,6 +1,5 @@
'use client'
import type { MarketplacePlugin } from '@dify/contracts/marketplace'
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
import type { ToolSettingTarget } from './types'
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
@@ -18,27 +17,25 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { parseToolProviderType } from '@/app/components/tools/provider-type'
import { CollectionType } from '@/app/components/tools/types'
import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker'
import { useGetLanguage } from '@/context/i18n'
import {
addProviderToolsAtom,
agentComposerToolsAtom,
setProviderToolCredentialAtom,
} from '@/features/agent-v2/agent-composer/store-modules/tools'
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
import {
useFetchPluginsInMarketPlaceByInfo,
useInvalidateInstalledPluginList,
} from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidateAllBuiltInTools,
} from '@/service/use-tools'
import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import { useInvalidateAllBuiltInTools } from '@/service/use-tools'
import { getIconFromMarketPlace } from '@/utils/get-icon'
import {
getAgentProviderPluginId,
getAgentProviderToolDisplayName,
getLocalizedText,
getProviderCredentialType,
getProviderCredentialVariant,
useAgentToolPresentation,
useAgentToolProviderCatalog,
} from '../../../tool-provider-catalog'
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
import { ConfigureSectionAddButton } from '../common/add-button'
import { ConfigureSectionEmpty } from '../common/empty'
@@ -135,92 +132,6 @@ const AgentToolItem = memo(
},
)
function useAgentToolProviderMap() {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
return useMemo(() => {
const providers = new Map<string, ToolWithProvider>()
const resolvedProviderTypes = new Set<AgentProviderTool['providerType']>()
const buildInToolList = Array.isArray(buildInTools) ? buildInTools : []
const customToolList = Array.isArray(customTools) ? customTools : []
const workflowToolList = Array.isArray(workflowTools) ? workflowTools : []
const mcpToolList = Array.isArray(mcpTools) ? mcpTools : []
const allProviders = [
...buildInToolList,
...customToolList,
...workflowToolList,
...mcpToolList,
]
if (Array.isArray(buildInTools)) {
resolvedProviderTypes.add(CollectionType.builtIn)
resolvedProviderTypes.add('plugin')
}
if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom)
if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow)
if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp)
allProviders.forEach((provider) => {
providers.set(provider.id, provider)
providers.set(provider.name, provider)
if (provider.plugin_id) {
providers.set(provider.plugin_id, provider)
providers.set(`${provider.plugin_id}/${provider.name}`, provider)
}
})
return {
providerById: providers,
resolvedProviderTypes,
}
}, [buildInTools, customTools, workflowTools, mcpTools])
}
function getLocalizedText(text: Partial<Record<string, string>> | undefined, language: string) {
return text?.[language] ?? text?.en_US ?? text?.zh_Hans
}
function getProviderPluginId(tool: AgentProviderTool) {
if (tool.pluginId) return tool.pluginId
if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return ''
const providerIdSegments = tool.id.split('/')
if (providerIdSegments.length !== 3) return ''
return providerIdSegments.slice(0, 2).join('/')
}
function getProviderDisplayName(tool: AgentProviderTool) {
const providerIdSegments = tool.name.split('/').filter(Boolean)
return providerIdSegments.at(-1) ?? tool.name
}
function getMarketplacePluginInfo(pluginId: string) {
const [organization, plugin, ...remainingSegments] = pluginId.split('/')
if (!organization || !plugin || remainingSegments.length > 0) return undefined
return {
organization,
plugin,
}
}
function getProviderCredentialType(
provider?: ToolWithProvider,
): AgentProviderTool['credentialType'] {
if (!provider) return undefined
if (Object.keys(provider.team_credentials ?? {}).length > 0) return 'api-key'
if (provider.type === CollectionType.builtIn && provider.allow_delete) return 'oauth2'
return undefined
}
function getDisplayCredentialType(
tool: AgentProviderTool,
providerCredentialType: AgentProviderTool['credentialType'],
@@ -233,27 +144,13 @@ function getDisplayCredentialType(
return tool.credentialType ?? providerCredentialType
}
function getProviderCredentialVariant(
tool: AgentProviderTool,
provider: ToolWithProvider,
providerCredentialType: AgentProviderTool['credentialType'],
) {
if (!providerCredentialType) return 'none' as const
if (tool.credentialVariant !== 'none') return tool.credentialVariant
return tool.credentialId || provider.is_team_authorization
? ('authorized' as const)
: ('unauthorized' as const)
}
function useDisplayTools(
tools: AgentTool[],
providerById: Map<string, ToolWithProvider>,
resolvedProviderTypes: Set<AgentProviderTool['providerType']>,
marketplacePluginById: Map<string, MarketplacePlugin>,
toolPresentation: ReturnType<typeof useAgentToolPresentation>,
) {
const language = useGetLanguage()
const { language, marketplacePluginById } = toolPresentation
return useMemo(() => {
return tools.map((tool): DisplayAgentTool => {
@@ -262,7 +159,7 @@ function useDisplayTools(
const provider = providerById.get(tool.id) ?? providerById.get(tool.name)
if (!provider) {
const providerPluginId = getProviderPluginId(tool)
const providerPluginId = getAgentProviderPluginId(tool)
const marketplacePlugin = marketplacePluginById.get(providerPluginId)
return {
@@ -271,11 +168,11 @@ function useDisplayTools(
pluginId: tool.pluginId ?? providerPluginId,
pluginUniqueIdentifier:
tool.pluginUniqueIdentifier ?? marketplacePlugin?.latest_package_identifier,
displayName:
tool.displayName ??
getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ??
marketplacePlugin?.name ??
getProviderDisplayName(tool),
displayName: getAgentProviderToolDisplayName({
language,
marketplacePlugin,
tool,
}),
icon:
tool.icon ??
(marketplacePlugin && providerPluginId
@@ -292,7 +189,7 @@ function useDisplayTools(
return {
...tool,
isInstalled: true,
displayName: tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name,
displayName: getAgentProviderToolDisplayName({ language, provider, tool }),
icon: tool.icon ?? provider.icon,
iconDark: tool.iconDark ?? provider.icon_dark,
providerType: tool.providerType,
@@ -378,7 +275,7 @@ function AddToolMenu({
const { t } = useTranslation('agentV2')
const [open, setOpen] = useState(false)
const [view, setView] = useState<AddToolMenuView>(addToolDefaultView)
const { providerById } = useAgentToolProviderMap()
const { providerById } = useAgentToolProviderCatalog()
const openToolPicker = useCallback(() => {
setView('tool-picker')
@@ -487,7 +384,7 @@ export function AgentTools() {
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
const invalidateAllBuiltInTools = useInvalidateAllBuiltInTools()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
const { providerById, resolvedProviderTypes } = useAgentToolProviderMap()
const { providerById, resolvedProviderTypes } = useAgentToolProviderCatalog()
const tools = useAtomValue(agentComposerToolsAtom)
const selectedTools = useSelectedProviderTools()
const addTools = useSetAtom(addProviderToolsAtom)
@@ -527,45 +424,15 @@ export function AgentTools() {
() => (ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter((tool) => tool.kind !== 'cli')),
[tools],
)
const missingMarketplacePluginInfos = useMemo(() => {
const pluginIds = new Set<string>()
visibleTools.forEach((tool) => {
if (
tool.kind !== 'provider' ||
!resolvedProviderTypes.has(tool.providerType) ||
providerById.has(tool.id) ||
providerById.has(tool.name)
)
return
const pluginId = getProviderPluginId(tool)
if (pluginId) pluginIds.add(pluginId)
})
return Array.from(pluginIds).flatMap((pluginId) => {
const info = getMarketplacePluginInfo(pluginId)
return info ? [info] : []
})
}, [providerById, resolvedProviderTypes, visibleTools])
const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo(
missingMarketplacePluginInfos,
)
const marketplacePluginById = useMemo(
() =>
new Map(
(missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [
plugin.plugin_id,
plugin,
]),
),
[missingMarketplacePluginsData],
)
const toolPresentation = useAgentToolPresentation(visibleTools, {
providerById,
resolvedProviderTypes,
})
const displayTools = useDisplayTools(
visibleTools,
providerById,
resolvedProviderTypes,
marketplacePluginById,
toolPresentation,
)
/*
* knip-ignore-start
@@ -0,0 +1,267 @@
'use client'
import type { MarketplacePlugin } from '@dify/contracts/marketplace'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import type { AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
import { useMemo } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { useGetLanguage } from '@/context/i18n'
import { useFetchPluginsInMarketPlaceByInfo } from '@/service/use-plugins'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
type AgentToolPresentationProvider = Pick<
AgentProviderTool,
'kind' | 'id' | 'name' | 'displayName' | 'pluginId' | 'providerType'
>
type AgentToolPresentationSource = AgentTool | AgentToolPresentationProvider
export type AgentToolProviderCatalog = {
providerById: Map<string, ToolWithProvider>
resolvedProviderTypes: Set<AgentProviderTool['providerType']>
}
export function createAgentToolProviderCatalog({
buildInTools,
customTools,
mcpTools,
workflowTools,
}: {
buildInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
}): AgentToolProviderCatalog {
const providers = new Map<string, ToolWithProvider>()
const resolvedProviderTypes = new Set<AgentProviderTool['providerType']>()
const buildInToolList = Array.isArray(buildInTools) ? buildInTools : []
const customToolList = Array.isArray(customTools) ? customTools : []
const workflowToolList = Array.isArray(workflowTools) ? workflowTools : []
const mcpToolList = Array.isArray(mcpTools) ? mcpTools : []
const allProviders = [...buildInToolList, ...customToolList, ...workflowToolList, ...mcpToolList]
if (Array.isArray(buildInTools)) {
resolvedProviderTypes.add(CollectionType.builtIn)
resolvedProviderTypes.add('plugin')
}
if (Array.isArray(customTools)) resolvedProviderTypes.add(CollectionType.custom)
if (Array.isArray(workflowTools)) resolvedProviderTypes.add(CollectionType.workflow)
if (Array.isArray(mcpTools)) resolvedProviderTypes.add(CollectionType.mcp)
allProviders.forEach((provider) => {
providers.set(provider.id, provider)
providers.set(provider.name, provider)
if (provider.plugin_id) {
providers.set(provider.plugin_id, provider)
providers.set(`${provider.plugin_id}/${provider.name}`, provider)
}
})
return {
providerById: providers,
resolvedProviderTypes,
}
}
export function useAgentToolProviderCatalog(): AgentToolProviderCatalog {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
return useMemo(
() => createAgentToolProviderCatalog({ buildInTools, customTools, mcpTools, workflowTools }),
[buildInTools, customTools, mcpTools, workflowTools],
)
}
export function getLocalizedText(
text: Partial<Record<string, string>> | undefined,
language: string,
) {
return text?.[language] ?? text?.en_US ?? text?.zh_Hans
}
export function getAgentProviderPluginId(tool: AgentToolPresentationProvider) {
if (tool.pluginId) return tool.pluginId
if (tool.providerType !== 'plugin' && tool.providerType !== CollectionType.builtIn) return ''
const providerIdSegments = tool.id.split('/')
if (providerIdSegments.length !== 3) return ''
return providerIdSegments.slice(0, 2).join('/')
}
function getMarketplacePluginInfo(pluginId: string) {
const [organization, plugin, ...remainingSegments] = pluginId.split('/')
if (!organization || !plugin || remainingSegments.length > 0) return undefined
return {
organization,
plugin,
}
}
function getProviderFallbackDisplayName(tool: AgentToolPresentationProvider) {
const providerIdSegments = tool.name.split('/').filter(Boolean)
return providerIdSegments.at(-1) ?? tool.name
}
export function getAgentProviderToolDisplayName({
language,
marketplacePlugin,
provider,
tool,
}: {
language: string
marketplacePlugin?: MarketplacePlugin
provider?: ToolWithProvider
tool: AgentToolPresentationProvider
}) {
if (provider) return tool.displayName ?? getLocalizedText(provider.label, language) ?? tool.name
return (
tool.displayName ??
getLocalizedText(marketplacePlugin?.label ?? marketplacePlugin?.labels, language) ??
marketplacePlugin?.name ??
getProviderFallbackDisplayName(tool)
)
}
export function useAgentToolPresentation(
tools: AgentToolPresentationSource[],
{ providerById, resolvedProviderTypes }: AgentToolProviderCatalog,
) {
const language = useGetLanguage()
const missingMarketplacePluginInfos = useMemo(() => {
const pluginIds = new Set<string>()
tools.forEach((tool) => {
if (
tool.kind !== 'provider' ||
!resolvedProviderTypes.has(tool.providerType) ||
providerById.has(tool.id) ||
providerById.has(tool.name)
)
return
const pluginId = getAgentProviderPluginId(tool)
if (pluginId) pluginIds.add(pluginId)
})
return Array.from(pluginIds).flatMap((pluginId) => {
const info = getMarketplacePluginInfo(pluginId)
return info ? [info] : []
})
}, [providerById, resolvedProviderTypes, tools])
const { data: missingMarketplacePluginsData } = useFetchPluginsInMarketPlaceByInfo(
missingMarketplacePluginInfos,
)
const marketplacePluginById = useMemo(
() =>
new Map(
(missingMarketplacePluginsData?.data.list ?? []).map(({ plugin }) => [
plugin.plugin_id,
plugin,
]),
),
[missingMarketplacePluginsData],
)
const toolDisplayNameById = useMemo(() => {
const displayNames = new Map<string, string>()
tools.forEach((tool) => {
if (tool.kind !== 'provider') return
const provider = providerById.get(tool.id) ?? providerById.get(tool.name)
const marketplacePlugin = marketplacePluginById.get(getAgentProviderPluginId(tool))
displayNames.set(
tool.id,
getAgentProviderToolDisplayName({ language, marketplacePlugin, provider, tool }),
)
})
return displayNames
}, [language, marketplacePluginById, providerById, tools])
return {
language,
marketplacePluginById,
toolDisplayNameById,
}
}
export function getProviderCredentialType(
provider?: ToolWithProvider,
): AgentProviderTool['credentialType'] {
if (!provider) return undefined
if (Object.keys(provider.team_credentials ?? {}).length > 0) return 'api-key'
if (provider.type === CollectionType.builtIn && provider.allow_delete) return 'oauth2'
return undefined
}
export function getProviderCredentialVariant(
tool: AgentProviderTool,
provider: ToolWithProvider,
providerCredentialType: AgentProviderTool['credentialType'],
) {
if (!providerCredentialType) return 'none' as const
if (tool.credentialVariant !== 'none') return tool.credentialVariant
return tool.credentialId || provider.is_team_authorization
? ('authorized' as const)
: ('unauthorized' as const)
}
export type AgentToolPublishIssue = {
type: 'uninstalled' | 'unauthorized'
tool: AgentProviderTool
}
export function getAgentToolPublishIssues(
tools: AgentTool[],
{ providerById, resolvedProviderTypes }: AgentToolProviderCatalog,
): AgentToolPublishIssue[] {
const issues: AgentToolPublishIssue[] = []
for (const tool of tools) {
if (tool.kind !== 'provider') continue
const provider = providerById.get(tool.id) ?? providerById.get(tool.name)
if (!provider) {
if (resolvedProviderTypes.has(tool.providerType)) {
issues.push({
type: 'uninstalled',
tool,
})
}
continue
}
const providerCredentialType = getProviderCredentialType(provider)
if (getProviderCredentialVariant(tool, provider, providerCredentialType) === 'unauthorized') {
issues.push({
type: 'unauthorized',
tool,
})
}
}
return issues
}
export function getAgentToolPublishIssue(
tools: AgentTool[],
catalog: AgentToolProviderCatalog,
): AgentToolPublishIssue | undefined {
return getAgentToolPublishIssues(tools, catalog)[0]
}
@@ -7,7 +7,7 @@ import { toast } from '@langgenius/dify-ui/toast'
import { mutationOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { debounce } from 'es-toolkit/compat'
import isEqual from 'fast-deep-equal'
import { useSetAtom, useStore } from 'jotai'
import { useAtomValue, useSetAtom, useStore } from 'jotai'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
@@ -22,7 +22,13 @@ import {
agentComposerSavedDraftAtom,
isAgentComposerDirtyAtom,
} from '@/features/agent-v2/agent-composer/store'
import { agentComposerToolPresentationIdentitiesAtom } from '@/features/agent-v2/agent-composer/store-modules/tools'
import { consoleQuery } from '@/service/client'
import {
getAgentToolPublishIssue,
useAgentToolPresentation,
useAgentToolProviderCatalog,
} from './tool-provider-catalog'
const DRAFT_AUTOSAVE_WAIT = 5000
@@ -40,7 +46,11 @@ export function useAgentConfigureSync({
enabled: boolean
}) {
const { t: tCommon } = useTranslation('common')
const { t: tWorkflow } = useTranslation('workflow')
const getKnowledgeValidationMessage = useKnowledgeValidationMessage()
const toolPresentationIdentities = useAtomValue(agentComposerToolPresentationIdentitiesAtom)
const toolProviderCatalog = useAgentToolProviderCatalog()
const toolPresentation = useAgentToolPresentation(toolPresentationIdentities, toolProviderCatalog)
const queryClient = useQueryClient()
const store = useStore()
const setSavedDraft = useSetAtom(agentComposerSavedDraftAtom)
@@ -355,6 +365,19 @@ export function useAgentConfigureSync({
return
}
const toolPublishIssue = getAgentToolPublishIssue(draft.tools, toolProviderCatalog)
if (toolPublishIssue) {
const toolName =
toolPresentation.toolDisplayNameById.get(toolPublishIssue.tool.id) ??
toolPublishIssue.tool.name
toast.error(
toolPublishIssue.type === 'uninstalled'
? tWorkflow(($) => $['nodes.agent.toolNotInstallTooltip'], { tool: toolName })
: tWorkflow(($) => $['nodes.agent.toolNotAuthorizedTooltip'], { tool: toolName }),
)
return
}
const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals)
if (!knowledgeValidation.isValid) {
toast.error(
@@ -406,6 +429,9 @@ export function useAgentConfigureSync({
runPublishTransaction,
store,
tCommon,
toolProviderCatalog,
toolPresentation.toolDisplayNameById,
tWorkflow,
])
return {
+1 -1
View File
@@ -472,7 +472,7 @@
"nodes.agent.task.mention": "Mention",
"nodes.agent.task.placeholder": "Describe what this agent should do...",
"nodes.agent.task.tooltip": "Additional prompt to help agent handle this very node. Use / to make explicit reference to variables.\nIf configured properly, you could be able to trust your agent to figure things out itself.",
"nodes.agent.toolNotAuthorizedTooltip": "{{tool}} Not Authorized",
"nodes.agent.toolNotAuthorizedTooltip": "{{tool}} is not authorized",
"nodes.agent.toolNotInstallTooltip": "{{tool}} is not installed",
"nodes.agent.toolbox": "toolbox",
"nodes.agent.tools": "Tools",