From d959416871c6737dca01662ce276d5009dc5184e Mon Sep 17 00:00:00 2001 From: RedShakespeare Date: Wed, 26 Aug 2026 03:09:06 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Feature:=20Recommend=20and=20pr?= =?UTF-8?q?efill=20bound=20NL2Agent=20tools?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/prompts/nl2agent_en.yaml | 4 +- backend/prompts/nl2agent_zh.yaml | 4 +- backend/services/nl2agent_service.py | 43 ++- frontend/app/[locale]/agents/agent-config.tsx | 11 +- .../agentConfig/tool/ToolConfigModal.tsx | 24 +- .../ui/installed-resource-binding-card.tsx | 288 ++++++++++++++++-- frontend/contexts/nl2AgentFlow.tsx | 1 + frontend/public/locales/en/common.json | 2 + frontend/public/locales/zh/common.json | 2 + test/backend/agents/test_nl2agent_agent.py | 4 + .../backend/services/test_nl2agent_service.py | 71 ++++- 11 files changed, 415 insertions(+), 39 deletions(-) diff --git a/backend/prompts/nl2agent_en.yaml b/backend/prompts/nl2agent_en.yaml index 8c50ee198..9efb563a7 100644 --- a/backend/prompts/nl2agent_en.yaml +++ b/backend/prompts/nl2agent_en.yaml @@ -31,7 +31,7 @@ system_prompt: |- ### Resource Revisions - In revision mode, search only for the newly requested capability. Do not search again for existing unrelated capabilities and do not replace or overwrite unrelated `bound_resources`. - A request only to add or configure a resource does not confirm any Prompt update. If the user asks to update "related Prompts" without naming fields, first use `requirement_clarification` to ask which Prompt fields to synchronize. - - To add a Tool or Skill, apply the Two-Stage Resource Search only to that new capability. Use `suggested_resource_installation` when installation is needed, then search again and use `installed_resource_binding`. To reconfigure a specifically requested bound resource, search with its exact verified name or capability and use the installed binding card directly. + - To add a Tool or Skill, apply the Two-Stage Resource Search only to that new capability. Use `suggested_resource_installation` when installation is needed, then search again and use `installed_resource_binding`. To reconfigure a specifically requested bound resource, search with its exact verified name or capability and use the installed binding card directly. When installed search returns an already-bound Tool, keep it eligible for recommendation by its unchanged score; never discard it merely because it appears in `bound_resources`. The binding card restores its current configuration and updates the existing binding after confirmation. - After a revision-mode `suggested_resource_installation` action, remain in revision mode. Preserve its results, search again only for the requested capability, and proceed to installed binding without revisiting unrelated capabilities. - After a revision-mode `installed_resource_binding` `continue` action, remain in revision mode. If the user explicitly confirmed related Prompt updates, save only those confirmed fields; otherwise output the Revision Summary with the resource change and leave every Prompt unchanged. Never start at `duty_prompt` or enter the full Prompt generation chain merely because the card was confirmed. - Conversational removal is unsupported. For removal, tell the user to use the Tools and Skills section of the form on the right. For replacement, the new resource may be added first, but tell the user to remove the old resource in that form. @@ -63,7 +63,7 @@ system_prompt: |- 4. Decode every JSON text result with `json.loads` before indexing or forwarding it. A requirement is uncovered only when its ID occurs in both installed and installable-resource `uncovered_requirement_ids`. Use only the real candidates returned by those searches and never invent an unavailable source. 5. Select only the smallest uninstalled candidate set needed to close coverage gaps, call `{{ recommend_tool_name }}`, then call `{{ wrapper_name }}` with subtype `suggested_resource_installation`. When no installation is needed, select the smallest installed coverage set and proceed directly to `installed_resource_binding`. 6. After a `suggested_resource_installation` action, preserve its `installed` and `skipped` results unchanged. Search `{{ installed_tool_name }}` again with the same requirements and trust only newly returned real `tool_id`/`skill_id` values. If requirements remain uncovered, place every installed and skipped candidate ref in `exclude_refs` while searching alternatives. When no alternative exists, use a clarification card requiring the user to revise, explicitly abandon, or end; never generate an incomplete Agent silently. - 7. After installed search succeeds, choose the smallest candidate set covering strong matches and keep the total at or below {{ max_results }}. Pass unchanged candidates to `{{ recommend_tool_name }}`, decode its result, then pass that unchanged dictionary and the same `agent_id` to `{{ wrapper_name }}` with subtype `installed_resource_binding`. + 7. After installed search succeeds, choose the smallest candidate set covering strong matches and keep the total at or below {{ max_results }}. Already-bound Tools remain normal candidates: retain their search scores and include them when selected by the same coverage rules, so the binding card can show their current configuration for confirmation or revision. Pass unchanged candidates to `{{ recommend_tool_name }}`, decode its result, then pass that unchanged dictionary and the same `agent_id` to `{{ wrapper_name }}` with subtype `installed_resource_binding`. 8. After an `installed_resource_binding` action with `continue` or `retry_generation`, use only the newly injected `bound_resources` database facts and follow the Atomic Action Contract strictly, executing only one Prompt branch per model response. 9. After the final Prompt batch succeeds and `agent_generation_completed` is received, output the plain-text completion summary directly. Do not call another tool or wrapper. For tool errors, use only `code` and `retryable`; retry at most once. diff --git a/backend/prompts/nl2agent_zh.yaml b/backend/prompts/nl2agent_zh.yaml index 3e6ea9921..0354608be 100644 --- a/backend/prompts/nl2agent_zh.yaml +++ b/backend/prompts/nl2agent_zh.yaml @@ -31,7 +31,7 @@ system_prompt: |- ### 资源修订 - 修订模式只搜索用户新请求的能力,不得重新搜索已有无关能力,也不得替换或覆盖无关的 `bound_resources`。 - 仅要求新增或配置资源,不代表用户确认更新任何 Prompt。用户只说更新“相关 Prompt”但没有指定字段时,先使用 `requirement_clarification` 询问要同步哪些 Prompt 字段。 - - 新增 Tool 或 Skill 时,只针对该新能力执行“两阶段资源搜索”。需要安装时使用 `suggested_resource_installation`,安装后重新搜索并进入 `installed_resource_binding`。重新配置用户明确指定的已绑定资源时,使用权威状态中的准确资源名称或能力进行搜索,并直接使用已安装资源绑定卡。 + - 新增 Tool 或 Skill 时,只针对该新能力执行“两阶段资源搜索”。需要安装时使用 `suggested_resource_installation`,安装后重新搜索并进入 `installed_resource_binding`。重新配置用户明确指定的已绑定资源时,使用权威状态中的准确资源名称或能力进行搜索,并直接使用已安装资源绑定卡。已安装搜索返回已绑定 Tool 时,仍按其未改写的分数参与推荐,不得仅因它出现在 `bound_resources` 中就丢弃;绑定卡会恢复当前配置,并在用户确认后更新原绑定。 - 修订模式收到 `suggested_resource_installation` action 后,必须继续修订模式。原样保留 action 结果,只重新搜索用户请求的能力并进入已安装资源绑定,不得重新处理无关能力。 - 修订模式收到 `installed_resource_binding` 的 `continue` action 后,必须继续修订模式。仅当用户明确确认同步相关 Prompt 时,才保存这些确认字段;否则直接输出“修订总结”并说明资源变更,所有 Prompt 保持不变。不得因卡片已确认就从 `duty_prompt` 开始或进入完整 Prompt 生成链。 - 不支持通过对话移除资源。用户要求移除时,引导其在右侧表单的工具与技能区域操作。替换资源时可以先新增资源,但必须提示用户在该表单中移除旧资源。 @@ -63,7 +63,7 @@ system_prompt: |- 4. MCP JSON 文本在索引或继续传递前必须使用 `json.loads` 解码。只有同时出现在 installed 与 installable-resource 的 `uncovered_requirement_ids` 中的需求才算未覆盖;只能使用这些搜索真实返回的候选,不得编造不存在的来源。 5. 从未安装结果中只选择补齐缺口所必需的最小候选集,调用 `{{ recommend_tool_name }}` 后再调用 `{{ wrapper_name }}` 的 `suggested_resource_installation` subtype。若没有必要安装项,则从已安装结果选择最小覆盖集并直接进入 `installed_resource_binding`。 6. 收到 `suggested_resource_installation` action 后,原样保留 `installed` 与 `skipped` 结果。使用相同 requirements 重新调用 `{{ installed_tool_name }}`,只相信新返回的真实 `tool_id`/`skill_id`;仍未覆盖时把全部已安装和已跳过 candidate refs 放入 `exclude_refs` 搜索替代资源。没有替代资源时使用澄清卡要求用户明确修改需求、放弃需求或结束,禁止生成能力不完整的 Agent。 - 7. 已安装搜索成功后选择覆盖强匹配需求的最小候选集,总数不超过 {{ max_results }}。把未改写的候选传给 `{{ recommend_tool_name }}`,解码其结果,再将该字典和同一 `agent_id` 原样传给 `{{ wrapper_name }}` 的 `installed_resource_binding` subtype。 + 7. 已安装搜索成功后选择覆盖强匹配需求的最小候选集,总数不超过 {{ max_results }}。已绑定 Tool 仍是普通候选:保留搜索分数,并在相同覆盖规则选中它时继续推荐,让绑定卡展示当前配置供用户确认或修改。把未改写的候选传给 `{{ recommend_tool_name }}`,解码其结果,再将该字典和同一 `agent_id` 原样传给 `{{ wrapper_name }}` 的 `installed_resource_binding` subtype。 8. 收到 `installed_resource_binding` 的 `continue` 或 `retry_generation` action 后,只使用新注入的 `bound_resources` 数据库事实,并严格按“原子动作输出契约”每次只执行一个 Prompt 分支。 9. 最后一批 Prompt 保存成功并收到 `agent_generation_completed` 后,直接输出普通文本完成总结,不再调用任何 Tool 或 wrapper。Tool 出错时只依据 `code` 和 `retryable`,最多重试一次。 diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 38680f687..c31a75f98 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -67,6 +67,7 @@ MINIMUM_RESOURCE_SCORE = 0.50 UNINSTALLED_SOURCE_PAGE_SIZE = 100 MAX_INTERNAL_SOURCE_ITEMS = 300 +NL2AGENT_VISIBLE_NON_SELECTABLE_TOOLS = frozenset({"knowledge_base_search"}) AGENT_DRAFT_FIELD_ORDER = ( "name", "display_name", @@ -392,6 +393,38 @@ def _normalize_tool_config(params: Any) -> list[dict[str, Any]]: return normalized +def _normalize_nl2agent_tool_config( + *, + tool_name: str, + params: Any, +) -> list[dict[str, Any]]: + config = _normalize_tool_config(params) + if tool_name != "knowledge_base_search": + return config + + index_names = next( + (param for param in config if param["name"] == "index_names"), + None, + ) + if index_names is None: + config.append( + { + "name": "index_names", + "type": "array", + "required": True, + "value": [], + "description": "The list of index names to search", + "description_zh": "要索引的知识库", + } + ) + else: + index_names["type"] = "array" + index_names["required"] = True + if not isinstance(index_names.get("value"), list): + index_names["value"] = [] + return config + + def _normalize_skill_config(skill: dict[str, Any]) -> list[dict[str, Any]]: schemas = skill.get("config_schemas") defaults = skill.get("config_values") @@ -443,7 +476,10 @@ async def _load_installed_resource_catalog( if ( source not in {ToolSourceEnum.LOCAL.value, ToolSourceEnum.MCP.value} or tool.get("is_available") is not True - or tool.get("is_user_selectable") is False + or ( + tool.get("is_user_selectable") is False + and name not in NL2AGENT_VISIBLE_NON_SELECTABLE_TOOLS + ) or name in internal_names ): continue @@ -468,7 +504,10 @@ async def _load_installed_resource_catalog( "params": tool.get("params"), "inputs": inputs, }), - "config": _normalize_tool_config(tool.get("params")), + "config": _normalize_nl2agent_tool_config( + tool_name=name, + params=tool.get("params"), + ), "form_kind": "TOOL_CONFIG", "inputs": inputs, "installed": True, diff --git a/frontend/app/[locale]/agents/agent-config.tsx b/frontend/app/[locale]/agents/agent-config.tsx index 6a21b54fb..40c1f0c57 100644 --- a/frontend/app/[locale]/agents/agent-config.tsx +++ b/frontend/app/[locale]/agents/agent-config.tsx @@ -146,6 +146,7 @@ export default function AgentConfig({ const displayInfoSectionRef = useRef(null); const roleModelSectionRef = useRef(null); const toolsSkillsSectionRef = useRef(null); + const knowledgeBaseSectionRef = useRef(null); const conversationGuideSectionRef = useRef(null); const lastScrolledRequestRef = useRef(null); const { configFocusRequest } = useNl2AgentFlow(); @@ -181,7 +182,10 @@ export default function AgentConfig({ const { requestId, target } = configFocusRequest; setActiveConfigTab( - target.section === "conversation_guide" ? "advanced" : "basic" + target.section === "conversation_guide" || + target.section === "knowledge_base" + ? "advanced" + : "basic" ); setOpenSections((current) => current[target.section] ? current : { ...current, [target.section]: true } @@ -198,7 +202,9 @@ export default function AgentConfig({ ? roleModelSectionRef.current : target.section === "tools_skills" ? toolsSkillsSectionRef.current - : conversationGuideSectionRef.current; + : target.section === "knowledge_base" + ? knowledgeBaseSectionRef.current + : conversationGuideSectionRef.current; if (!sectionElement) return; const prefersReducedMotion = window.matchMedia( @@ -410,6 +416,7 @@ export default function AgentConfig({ onOpenChange={(open) => handleSectionOpenChange("knowledge_base", open) } + containerRef={knowledgeBaseSectionRef} headerActions={} > diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx index 023532877..0e865357b 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx @@ -1132,13 +1132,14 @@ export default function ToolConfigModal({ } }, [knowledgeBases, selectedKbIds]); - // Filter selected KB IDs to the current accessible list. For AIDP, an - // successfully loaded empty list is meaningful: the current user cannot - // read any of the KBs saved by the agent creator. + // Filter selected KB IDs to the current accessible list. For managed + // knowledge tools, a loaded empty list means no saved KB remains readable. useEffect(() => { const canValidateSelection = knowledgeBases.length > 0 || - ((toolKbType === "aidp_search" || toolKbType === "ind_aidp_search") && + ((isKnowledgeBaseSearchTool || + toolKbType === "aidp_search" || + toolKbType === "ind_aidp_search") && isKbListLoaded); if (selectedKbIds.length > 0 && canValidateSelection) { @@ -1156,18 +1157,26 @@ export default function ToolConfigModal({ }); setSelectedKbDisplayNames(displayNames); - if (toolKbType === "aidp_search") { + if ( + isKnowledgeBaseSearchTool || + toolKbType === "aidp_search" || + toolKbType === "ind_aidp_search" + ) { setTestPanelKbIds(validKbIds); setTestPanelKbDisplayNames(displayNames); const fieldIndex = currentParams.findIndex( - (p) => p.name === "kds_list" + (p) => + p.name === + (isKnowledgeBaseSearchTool ? "index_names" : "kds_list") ); if (fieldIndex !== -1) { form.setFieldValue(`param_${fieldIndex}`, validKbIds); } setCurrentParams((prevParams) => { const prevFieldIndex = prevParams.findIndex( - (p) => p.name === "kds_list" + (p) => + p.name === + (isKnowledgeBaseSearchTool ? "index_names" : "kds_list") ); if (prevFieldIndex === -1) return prevParams; const updatedParams = [...prevParams]; @@ -1183,6 +1192,7 @@ export default function ToolConfigModal({ }, [ knowledgeBases, isKbListLoaded, + isKnowledgeBaseSearchTool, toolKbType, selectedKbIds, currentParams, diff --git a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx index 426dadce1..48f3c3202 100644 --- a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx +++ b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useReducer, useState, type FC } from "react"; import { useAui, useAuiState } from "@assistant-ui/react"; -import { useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle, CheckCircle2, @@ -22,6 +22,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; import { useToolList } from "@/hooks/agent/useToolList"; +import { useKnowledgeBasesForToolConfig } from "@/hooks/useKnowledgeBaseSelector"; import { searchAgentInfo, updateToolConfig, @@ -52,6 +53,7 @@ import { Nl2AgentResourceSourceBadge } from "./nl2agent-resource-source-badge"; type ConfigStatus = "unconfigured" | "valid" | "invalid"; type BindingStatus = "idle" | "binding" | "bound" | "failed"; +const KNOWLEDGE_BASE_SEARCH_TOOL_NAME = "knowledge_base_search"; interface BindingItemState { resource: Nl2aRecommendedResource; @@ -66,6 +68,13 @@ interface BindingItemState { type BindingAction = | { type: "toggle"; ref: string } | { type: "save_config"; ref: string; params: Nl2AgentResourceParam[] } + | { + type: "hydrate_tool_configs"; + configs: Map< + string, + { params: Nl2AgentResourceParam[]; requiresConfirmation: boolean } + >; + } | { type: "validation_failed"; errors: Map; @@ -77,6 +86,85 @@ type BindingAction = const candidateRef = (item: BindingItemState): string => item.resource.candidate.candidate_ref; +const parseResourceId = (ref: string, expected: "tool" | "skill"): number => { + const match = new RegExp(`^${expected}:(\\d+)$`).exec(ref); + const resourceId = Number(match?.[1]); + if (!Number.isInteger(resourceId) || resourceId <= 0) { + throw new Error(`Invalid ${expected} candidate reference`); + } + return resourceId; +}; + +const parseStringArray = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map(String) + .map((item) => item.trim()) + .filter(Boolean); + } + if (typeof value !== "string" || !value.trim()) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed + .map(String) + .map((item) => item.trim()) + .filter(Boolean) + : []; + } catch { + return value + .split(",") + .map((item) => item.trim()) + .filter(Boolean); + } +}; + +const hydrateBoundToolConfigs = ( + resources: Nl2aRecommendedResource[], + boundTools: Tool[], + accessibleKnowledgeBaseIds: ReadonlySet +): Map< + string, + { params: Nl2AgentResourceParam[]; requiresConfirmation: boolean } +> => { + const configs = new Map< + string, + { params: Nl2AgentResourceParam[]; requiresConfirmation: boolean } + >(); + resources.forEach((resource) => { + if (resource.candidate.resource_type !== "tool") return; + const toolId = parseResourceId(resource.candidate.candidate_ref, "tool"); + const boundTool = findCanonicalTool(boundTools, toolId); + if (!boundTool) return; + + const values = new Map( + boundTool.initParams.map((param) => [param.name, param.value]) + ); + let requiresConfirmation = false; + const params = resource.config.map((param) => { + if (!values.has(param.name)) return { ...param }; + let value = values.get(param.name); + if ( + resource.candidate.name === KNOWLEDGE_BASE_SEARCH_TOOL_NAME && + param.name === "index_names" + ) { + const savedIds = parseStringArray(value); + const accessibleIds = savedIds.filter((id) => + accessibleKnowledgeBaseIds.has(id) + ); + requiresConfirmation = accessibleIds.length !== savedIds.length; + value = accessibleIds; + } + return { ...param, value }; + }); + configs.set(resource.candidate.candidate_ref, { + params, + requiresConfirmation, + }); + }); + return configs; +}; + const initializeItems = ( resources: Nl2aRecommendedResource[] ): BindingItemState[] => @@ -121,6 +209,20 @@ function reducer( error: undefined, }; } + if (action.type === "hydrate_tool_configs") { + const hydrated = action.configs.get(ref); + if (!hydrated) return item; + const fieldErrors = validateNl2AgentResourceConfig(hydrated.params); + return { + ...item, + draftParams: hydrated.params, + configStatus: + hydrated.requiresConfirmation || fieldErrors.length + ? "unconfigured" + : "valid", + fieldErrors: [], + }; + } if (action.type === "validation_failed") { const fieldErrors = action.errors.get(ref); return fieldErrors @@ -152,15 +254,6 @@ function reducer( }); } -const parseResourceId = (ref: string, expected: "tool" | "skill"): number => { - const match = new RegExp(`^${expected}:(\\d+)$`).exec(ref); - const resourceId = Number(match?.[1]); - if (!Number.isInteger(resourceId) || resourceId <= 0) { - throw new Error(`Invalid ${expected} candidate reference`); - } - return resourceId; -}; - const toPersistedBindings = ( boundItems: BindingItemState[], toolCatalog: Tool[], @@ -259,6 +352,42 @@ export const InstalledResourceBindingCard: FC<{ const hasToolResources = payload.resources.some( (resource) => resource.candidate.resource_type === "tool" ); + const hasKnowledgeBaseSearchResource = payload.resources.some( + (resource) => + resource.candidate.resource_type === "tool" && + resource.candidate.name === KNOWLEDGE_BASE_SEARCH_TOOL_NAME + ); + const [hasHydratedToolConfigs, setHasHydratedToolConfigs] = + useState(!hasToolResources); + const { + data: agentSnapshot, + isFetchedAfterMount: isAgentSnapshotFetched, + isError: isAgentSnapshotError, + refetch: refetchAgentSnapshot, + } = useQuery({ + queryKey: ["agentInfo", payload.agent_id], + queryFn: async () => { + const result = await searchAgentInfo(payload.agent_id, undefined, 0); + if (!result.success || !result.data) { + throw new Error(result.message); + } + return result.data; + }, + enabled: hasToolResources, + staleTime: 0, + refetchOnMount: "always", + }); + const { + data: accessibleKnowledgeBases = [], + isSuccess: isKnowledgeBaseListLoaded, + isError: isKnowledgeBaseListError, + refetch: refetchKnowledgeBases, + } = useKnowledgeBasesForToolConfig( + hasKnowledgeBaseSearchResource ? KNOWLEDGE_BASE_SEARCH_TOOL_NAME : null + ); + const [hasRefreshedKnowledgeBases, setHasRefreshedKnowledgeBases] = useState( + !hasKnowledgeBaseSearchResource + ); const { availableTools, isFetching: isToolCatalogFetching, @@ -272,11 +401,65 @@ export const InstalledResourceBindingCard: FC<{ (state) => state.replaceServerSnapshot ); + useEffect(() => { + if (!hasKnowledgeBaseSearchResource) return; + let active = true; + void refetchKnowledgeBases().finally(() => { + if (active) setHasRefreshedKnowledgeBases(true); + }); + return () => { + active = false; + }; + }, [hasKnowledgeBaseSearchResource, refetchKnowledgeBases]); + + useEffect(() => { + if ( + hasHydratedToolConfigs || + !agentSnapshot || + !isAgentSnapshotFetched || + isAgentSnapshotError || + (hasKnowledgeBaseSearchResource && + (!hasRefreshedKnowledgeBases || + !isKnowledgeBaseListLoaded || + isKnowledgeBaseListError)) + ) { + return; + } + dispatch({ + type: "hydrate_tool_configs", + configs: hydrateBoundToolConfigs( + payload.resources, + agentSnapshot.tools ?? [], + new Set(accessibleKnowledgeBases.map((kb) => String(kb.id).trim())) + ), + }); + setHasHydratedToolConfigs(true); + }, [ + accessibleKnowledgeBases, + agentSnapshot, + hasHydratedToolConfigs, + hasKnowledgeBaseSearchResource, + hasRefreshedKnowledgeBases, + isAgentSnapshotFetched, + isAgentSnapshotError, + isKnowledgeBaseListError, + isKnowledgeBaseListLoaded, + payload.resources, + ]); + useEffect(() => { registerCard(cardKey, payload.subtype); }, [cardKey, payload.subtype, registerCard]); + const isConfigHydrationFailed = + !hasHydratedToolConfigs && + (isAgentSnapshotError || + (hasKnowledgeBaseSearchResource && isKnowledgeBaseListError)); + const isConfigHydrating = + hasToolResources && !hasHydratedToolConfigs && !isConfigHydrationFailed; const isLocked = disabled || isSubmitted || !isCardInteractive(cardKey); + const isInteractionLocked = + isLocked || isConfigHydrating || isConfigHydrationFailed; const selectedItems = items.filter((item) => item.selected); const isBinding = items.some((item) => item.bindingStatus === "binding"); const canContinue = @@ -328,13 +511,19 @@ export const InstalledResourceBindingCard: FC<{ }; const bindSelected = async () => { - if (isLocked || isBinding || isSynchronizing) return; + if (isInteractionLocked || isBinding || isSynchronizing) return; const pending = items.filter( (item) => item.selected && item.bindingStatus !== "bound" ); const validationErrors = new Map(); pending.forEach((item) => { const errors = validateNl2AgentResourceConfig(item.draftParams); + if (item.configStatus === "unconfigured" && errors.length === 0) { + errors.push({ + field: "index_names", + message: "Review the available knowledge bases before binding", + }); + } if (errors.length) validationErrors.set(candidateRef(item), errors); }); if (validationErrors.size) { @@ -429,20 +618,36 @@ export const InstalledResourceBindingCard: FC<{ if (!synchronized) { showSynchronizationError(); } else { - requestConfigFocus(payload.agent_id, { - section: "tools_skills", - capabilityTab: boundItems.some( - (item) => item.resource.candidate.resource_type === "tool" + const ordinaryBoundItems = boundItems.filter( + (item) => + item.resource.candidate.name !== KNOWLEDGE_BASE_SEARCH_TOOL_NAME + ); + if ( + ordinaryBoundItems.length === 0 && + boundItems.some( + (item) => + item.resource.candidate.name === KNOWLEDGE_BASE_SEARCH_TOOL_NAME ) - ? "tools" - : "skills", - }); + ) { + requestConfigFocus(payload.agent_id, { + section: "knowledge_base", + }); + } else { + requestConfigFocus(payload.agent_id, { + section: "tools_skills", + capabilityTab: ordinaryBoundItems.some( + (item) => item.resource.candidate.resource_type === "tool" + ) + ? "tools" + : "skills", + }); + } } } }; const continueFlow = async () => { - if (isLocked || !canContinue || isSynchronizing) return; + if (isInteractionLocked || !canContinue || isSynchronizing) return; setSummaryError(null); const synchronized = await reloadAgentSnapshot( items.filter((item) => item.bindingStatus === "bound") @@ -609,7 +814,7 @@ export const InstalledResourceBindingCard: FC<{ dispatch({ type: "toggle", ref })} className="mt-1 size-4 accent-primary" aria-label={item.resource.candidate.name} @@ -663,7 +868,7 @@ export const InstalledResourceBindingCard: FC<{ size="icon" title={configureTitle} disabled={ - isLocked || + isInteractionLocked || bound || isBinding || isToolCatalogPending || @@ -687,6 +892,38 @@ export const InstalledResourceBindingCard: FC<{
+ {isConfigHydrating ? ( +

+ + {t( + "nl2agent.resourceBinding.loadingExistingConfig", + "Loading the current resource configuration..." + )} +

+ ) : null} + {isConfigHydrationFailed ? ( +
+

+ {t( + "nl2agent.resourceBinding.loadExistingConfigFailed", + "Failed to load the current resource configuration." + )} +

+ +
+ ) : null} {summaryError ? (

{summaryError} @@ -703,7 +940,7 @@ export const InstalledResourceBindingCard: FC<{ ) : null} {!canContinue ? (

- {isConfigHydrating ? ( -

- - {t( - "nl2agent.resourceBinding.loadingExistingConfig", - "Loading the current resource configuration..." - )} -

- ) : null} - {isConfigHydrationFailed ? ( -
-

- {t( - "nl2agent.resourceBinding.loadExistingConfigFailed", - "Failed to load the current resource configuration." - )} -

- -
- ) : null} {summaryError ? (

{summaryError} @@ -957,8 +838,7 @@ export const InstalledResourceBindingCard: FC<{ isLocked || !canContinue || isSynchronizing || - isConfigHydrating || - isConfigHydrationFailed + loadingConfigRef !== null } onClick={continueFlow} > @@ -987,7 +867,7 @@ export const InstalledResourceBindingCard: FC<{ initialParams={configuringItem.draftParams as ToolParam[]} selectedTool={toolForDialog} currentAgentId={payload.agent_id} - localOnly + localOnly={configuringItem.bindingStatus !== "bound"} /> ) : null} {skillForDialog && configuringItem ? ( diff --git a/test/backend/database/test_tool_db.py b/test/backend/database/test_tool_db.py index ce2a1425a..7f33058d7 100644 --- a/test/backend/database/test_tool_db.py +++ b/test/backend/database/test_tool_db.py @@ -141,6 +141,7 @@ def __init__(self): self.user_id = "user1" self.enabled = True self.delete_flag = "N" + self.params = {"database_url": "original"} self.__dict__ = { "tool_instance_id": 1, "tool_id": 1, @@ -148,7 +149,8 @@ def __init__(self): "tenant_id": "tenant1", "user_id": "user1", "enabled": True, - "delete_flag": "N" + "delete_flag": "N", + "params": {"database_url": "original"}, } @@ -232,12 +234,18 @@ def test_create_or_update_tool_by_tool_info_update_existing(monkeypatch, mock_se lambda obj: obj.__dict__ if hasattr(obj, '__dict__') else obj) tool_info = MagicMock() - tool_info.__dict__ = {"agent_id": 1, "tool_id": 1} + tool_info.__dict__ = { + "agent_id": 1, + "tool_id": 1, + "params": {"database_url": "updated"}, + } result = create_or_update_tool_by_tool_info(tool_info, "tenant1", "user1") - # Result is now as_dict() of the tool_instance assert isinstance(result, dict) + assert mock_tool_instance.tool_instance_id == 1 + assert mock_tool_instance.params == {"database_url": "updated"} + session.add.assert_not_called() def test_create_or_update_tool_by_tool_info_create_new(monkeypatch, mock_session): diff --git a/test/backend/services/test_mcp_internal_tool_search.py b/test/backend/services/test_mcp_internal_tool_search.py index 082799f6a..9186f79ee 100644 --- a/test/backend/services/test_mcp_internal_tool_search.py +++ b/test/backend/services/test_mcp_internal_tool_search.py @@ -923,6 +923,7 @@ async def test_resource_tools_return_tenant_scoped_results_and_stable_errors( "tenant_mcp_repository:8" ] assert recommend_impl.await_args.kwargs["user_id"] == "user-a" + assert recommend_impl.await_args.kwargs["agent_id"] == 42 search_impl.side_effect = PermissionError("private auth details") assert ( @@ -992,12 +993,22 @@ async def test_installed_binding_wrapper_rechecks_agent_and_candidates(mocker): "score": 0.9, }, "recommendation": "recommended", + "is_bound": True, "form_kind": "TOOL_CONFIG", "config": [], } ], } - verified = RecommendResourcesOutput.model_validate(resource_result) + verified_result = { + **resource_result, + "resources": [ + { + **resource_result["resources"][0], + "is_bound": False, + } + ], + } + verified = RecommendResourcesOutput.model_validate(verified_result) recommend_impl = mocker.patch.object( nl2agent_service, "recommend_resources_impl", @@ -1010,12 +1021,15 @@ async def test_installed_binding_wrapper_rechecks_agent_and_candidates(mocker): resource_result=resource_result, ) - assert _unwrap_nl2a(wrapped)["resources"][0]["candidate"]["name"] == "search" + wrapped_resource = _unwrap_nl2a(wrapped)["resources"][0] + assert wrapped_resource["candidate"]["name"] == "search" + assert wrapped_resource["is_bound"] is False require_edit.assert_called_once_with( agent_id=42, tenant_id="tenant-a", user_id="user-a", ) + assert recommend_impl.await_args.kwargs["agent_id"] == 42 assert recommend_impl.await_args.kwargs["recommended_refs"] == ["tool:7"] @@ -1073,6 +1087,8 @@ async def test_installation_wrapper_rechecks_agent_and_candidates(mocker): payload = _unwrap_nl2a(wrapped) assert payload["subtype"] == "suggested_resource_installation" assert payload["resources"][0]["default_option_id"] == "official" + assert payload["resources"][0]["is_bound"] is False + assert recommend_impl.await_args.kwargs["agent_id"] == 42 assert recommend_impl.await_args.kwargs["recommended_refs"] == [ "nexent_official_skill:daily-report" ] diff --git a/test/backend/services/test_nl2agent_service.py b/test/backend/services/test_nl2agent_service.py index b958a3d2d..c8eca9efd 100644 --- a/test/backend/services/test_nl2agent_service.py +++ b/test/backend/services/test_nl2agent_service.py @@ -519,7 +519,14 @@ async def test_search_installed_resources_covers_visible_tools_and_skills(mocker "source": "local", "is_available": True, "is_user_selectable": False, - "params": [], + "params": [ + { + "name": "top_k", + "type": "integer", + "optional": False, + "default": 5, + } + ], "inputs": {}, }, { @@ -569,15 +576,33 @@ async def test_search_installed_resources_covers_visible_tools_and_skills(mocker catalog_by_name = {item["name"]: item for item in catalog} assert "wrapper" in catalog_by_name assert "knowledge_base_search" in catalog_by_name - assert "aidp_search" not in catalog_by_name + assert "aidp_search" in catalog_by_name assert catalog_by_name["knowledge_base_search"]["config"] == [ + { + "name": "top_k", + "type": "number", + "required": True, + "value": 5, + "description": "", + "description_zh": "", + }, { "name": "index_names", "type": "array", "required": True, "value": [], - "description": "The list of index names to search", - "description_zh": "要索引的知识库", + "description": "The knowledge bases used by this tool", + "description_zh": "该工具使用的知识库", + } + ] + assert catalog_by_name["aidp_search"]["config"] == [ + { + "name": "kds_list", + "type": "array", + "required": True, + "value": [], + "description": "The knowledge bases used by this tool", + "description_zh": "该工具使用的知识库", } ] @@ -655,6 +680,7 @@ def test_resource_config_normalization_is_frontend_safe(): "default": None, } ], + is_user_selectable=False, ) == [ { "name": "index_names", @@ -665,6 +691,16 @@ def test_resource_config_normalization_is_frontend_safe(): "description_zh": "", } ] + assert _normalize_nl2agent_tool_config( + tool_name="ordinary_tool", + params=[], + is_user_selectable=True, + ) == [] + assert _normalize_nl2agent_tool_config( + tool_name="managed_tool", + params=[], + is_user_selectable=False, + )[0]["name"] == "index_names" assert _normalize_skill_config({"config_schemas": None}) == [] skill_config = _normalize_skill_config( { @@ -1030,12 +1066,14 @@ async def test_recommend_resources_dispatches_homogeneous_sources(mocker): ) assert await recommend_resources_impl( + agent_id=42, candidates=[installed], recommended_refs=[installed.candidate_ref], tenant_id="tenant-a", user_id="user-a", ) is installed_result assert await recommend_resources_impl( + agent_id=42, candidates=[uninstalled], recommended_refs=[], tenant_id="tenant-a", @@ -1047,6 +1085,7 @@ async def test_recommend_resources_dispatches_homogeneous_sources(mocker): for candidates in ([], [installed, uninstalled]): with pytest.raises(Nl2AgentResourceError, match="invalid_candidates"): await recommend_resources_impl( + agent_id=42, candidates=candidates, recommended_refs=[], tenant_id="tenant-a", @@ -1078,6 +1117,10 @@ async def test_recommend_resources_overwrites_model_display_fields(mocker): ] ), ) + query_bound_tools = mocker.patch( + "services.nl2agent_service.query_all_enabled_tool_instances", + return_value=[{"tool_id": 7}], + ) supplied = ResourceCandidate( candidate_ref="tool:7", resource_type="tool", @@ -1089,6 +1132,7 @@ async def test_recommend_resources_overwrites_model_display_fields(mocker): ) result = await recommend_installed_resources_impl( + agent_id=42, candidates=[supplied], recommended_refs=["tool:7"], tenant_id="tenant-a", @@ -1100,7 +1144,13 @@ async def test_recommend_resources_overwrites_model_display_fields(mocker): assert resource.candidate.description == "Verified description" assert resource.candidate.requirement_ids == ["search"] assert resource.recommendation == "recommended" + assert resource.is_bound is True assert resource.form_kind == "TOOL_CONFIG" + query_bound_tools.assert_called_once_with( + agent_id=42, + tenant_id="tenant-a", + version_no=0, + ) @pytest.mark.asyncio @@ -1117,9 +1167,14 @@ async def test_recommend_resources_rejects_missing_or_mismatched_catalog_entries requirement_ids=["lookup"], score=0.9, ) + mocker.patch( + "services.nl2agent_service.query_all_enabled_tool_instances", + return_value=[], + ) with pytest.raises(Nl2AgentResourceError) as missing: await recommend_installed_resources_impl( + agent_id=42, candidates=[supplied], recommended_refs=[], tenant_id="tenant-a", @@ -1136,6 +1191,7 @@ async def test_recommend_resources_rejects_missing_or_mismatched_catalog_entries ] with pytest.raises(Nl2AgentResourceError) as mismatched: await recommend_installed_resources_impl( + agent_id=42, candidates=[supplied], recommended_refs=[], tenant_id="tenant-a", From 72529c1dda41352fdaa1868bf111ccc242813773 Mon Sep 17 00:00:00 2001 From: RedShakespeare Date: Wed, 26 Aug 2026 09:31:48 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20Remove=20NL2Agent?= =?UTF-8?q?=20knowledge=20config=20fallbacks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/services/nl2agent_service.py | 44 +------------------ .../agentConfig/tool/ToolConfigModal.tsx | 7 +-- .../backend/services/test_nl2agent_service.py | 44 +++---------------- 3 files changed, 10 insertions(+), 85 deletions(-) diff --git a/backend/services/nl2agent_service.py b/backend/services/nl2agent_service.py index 7eb888928..87bd46ebc 100644 --- a/backend/services/nl2agent_service.py +++ b/backend/services/nl2agent_service.py @@ -392,44 +392,6 @@ def _normalize_tool_config(params: Any) -> list[dict[str, Any]]: return normalized -def _normalize_nl2agent_tool_config( - *, - tool_name: str, - params: Any, - is_user_selectable: bool, -) -> list[dict[str, Any]]: - config = _normalize_tool_config(params) - if is_user_selectable: - return config - - selection_param = next( - ( - param - for param in config - if param["name"] in {"index_names", "dataset_ids", "kds_list"} - ), - None, - ) - if selection_param is None: - selection_name = "kds_list" if tool_name == "aidp_search" else "index_names" - config.append( - { - "name": selection_name, - "type": "array", - "required": True, - "value": [], - "description": "The knowledge bases used by this tool", - "description_zh": "该工具使用的知识库", - } - ) - else: - selection_param["type"] = "array" - selection_param["required"] = True - if not isinstance(selection_param.get("value"), list): - selection_param["value"] = [] - return config - - def _normalize_skill_config(skill: dict[str, Any]) -> list[dict[str, Any]]: schemas = skill.get("config_schemas") defaults = skill.get("config_values") @@ -505,11 +467,7 @@ async def _load_installed_resource_catalog( "params": tool.get("params"), "inputs": inputs, }), - "config": _normalize_nl2agent_tool_config( - tool_name=name, - params=tool.get("params"), - is_user_selectable=tool.get("is_user_selectable") is not False, - ), + "config": _normalize_tool_config(tool.get("params")), "form_kind": "TOOL_CONFIG", "inputs": inputs, "installed": True, diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx index bb0f7db0e..0e865357b 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/ToolConfigModal.tsx @@ -303,11 +303,8 @@ export default function ToolConfigModal({ // Check if current tool requires knowledge base selection (must be declared before toolKbType) const toolRequiresKbSelection = useMemo(() => { - return ( - tool?.is_user_selectable === false || - TOOLS_REQUIRING_KB_SELECTION.includes(tool?.name) - ); - }, [tool?.is_user_selectable, tool?.name]); + return TOOLS_REQUIRING_KB_SELECTION.includes(tool?.name); + }, [tool?.name]); // Get tool type for knowledge base selection const toolKbType = useMemo((): diff --git a/test/backend/services/test_nl2agent_service.py b/test/backend/services/test_nl2agent_service.py index c8eca9efd..c08ca883d 100644 --- a/test/backend/services/test_nl2agent_service.py +++ b/test/backend/services/test_nl2agent_service.py @@ -17,7 +17,6 @@ _build_verified_bound_resources_context, _load_internal_uninstalled_resource_catalog, _load_installed_resource_catalog, - _normalize_nl2agent_tool_config, _normalize_skill_config, _normalize_tool_config, _redact_installation_snapshot, @@ -585,26 +584,9 @@ async def test_search_installed_resources_covers_visible_tools_and_skills(mocker "value": 5, "description": "", "description_zh": "", - }, - { - "name": "index_names", - "type": "array", - "required": True, - "value": [], - "description": "The knowledge bases used by this tool", - "description_zh": "该工具使用的知识库", - } - ] - assert catalog_by_name["aidp_search"]["config"] == [ - { - "name": "kds_list", - "type": "array", - "required": True, - "value": [], - "description": "The knowledge bases used by this tool", - "description_zh": "该工具使用的知识库", } ] + assert catalog_by_name["aidp_search"]["config"] == [] result = await search_installed_resources_impl( requirements=[ @@ -670,37 +652,25 @@ def test_resource_config_normalization_is_frontend_safe(): "depends_on": "enabled", } ] - assert _normalize_nl2agent_tool_config( - tool_name="knowledge_base_search", - params=[ + assert _normalize_tool_config( + [ { "name": "index_names", "type": "string", "optional": True, "default": None, } - ], - is_user_selectable=False, + ] ) == [ { "name": "index_names", - "type": "array", - "required": True, - "value": [], + "type": "string", + "required": False, + "value": None, "description": "", "description_zh": "", } ] - assert _normalize_nl2agent_tool_config( - tool_name="ordinary_tool", - params=[], - is_user_selectable=True, - ) == [] - assert _normalize_nl2agent_tool_config( - tool_name="managed_tool", - params=[], - is_user_selectable=False, - )[0]["name"] == "index_names" assert _normalize_skill_config({"config_schemas": None}) == [] skill_config = _normalize_skill_config( { From aa5273d610982c49ec3e5734088cef638bc7fb1d Mon Sep 17 00:00:00 2001 From: RedShakespeare Date: Wed, 26 Aug 2026 10:13:17 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=90=9B=20Bugfix:=20Focus=20knowledge?= =?UTF-8?q?=20settings=20after=20NL2Agent=20binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newchat/ui/installed-resource-binding-card.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx index fe8cb93c1..2c503cdd7 100644 --- a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx +++ b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx @@ -23,6 +23,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; import { useToolList } from "@/hooks/agent/useToolList"; +import { isManagedKnowledgeTool } from "@/lib/managedKnowledgeTools"; import { searchAgentInfo, searchToolConfig, @@ -527,6 +528,12 @@ export const InstalledResourceBindingCard: FC<{ if (!synchronized) { showSynchronizationError(); } else { + const onlyManagedKnowledgeTools = boundItems.every((item) => { + if (item.resource.candidate.resource_type !== "tool") return false; + const toolId = parseResourceId(candidateRef(item), "tool"); + const canonicalTool = findCanonicalTool(availableTools, toolId); + return canonicalTool ? isManagedKnowledgeTool(canonicalTool) : false; + }); const visibleBoundItems = boundItems.filter((item) => { if (item.resource.candidate.resource_type === "skill") return true; const toolId = parseResourceId(candidateRef(item), "tool"); @@ -535,7 +542,11 @@ export const InstalledResourceBindingCard: FC<{ false ); }); - if (visibleBoundItems.length) { + if (onlyManagedKnowledgeTools) { + requestConfigFocus(payload.agent_id, { + section: "knowledge_base", + }); + } else if (visibleBoundItems.length) { requestConfigFocus(payload.agent_id, { section: "tools_skills", capabilityTab: visibleBoundItems.some( From 0875e319bb9b4e001a81f718d1078ecde646008b Mon Sep 17 00:00:00 2001 From: RedShakespeare Date: Wed, 26 Aug 2026 11:21:00 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Reduce=20N?= =?UTF-8?q?L2Agent=20binding=20complexity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui/installed-resource-binding-card.tsx | 115 ++++++++++-------- 1 file changed, 66 insertions(+), 49 deletions(-) diff --git a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx index 2c503cdd7..9d25c4541 100644 --- a/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx +++ b/frontend/app/[locale]/newchat/ui/installed-resource-binding-card.tsx @@ -21,7 +21,10 @@ import { } from "../../agents/components/agentConfig/tool/utils"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { useNl2AgentFlow } from "@/contexts/nl2AgentFlow"; +import { + useNl2AgentFlow, + type Nl2AgentConfigFocusTarget, +} from "@/contexts/nl2AgentFlow"; import { useToolList } from "@/hooks/agent/useToolList"; import { isManagedKnowledgeTool } from "@/lib/managedKnowledgeTools"; import { @@ -93,6 +96,45 @@ const parseResourceId = (ref: string, expected: "tool" | "skill"): number => { return resourceId; }; +const findBindingTool = (item: BindingItemState, tools: Tool[]) => + findCanonicalTool(tools, parseResourceId(candidateRef(item), "tool")); + +const isManagedKnowledgeBinding = ( + item: BindingItemState, + tools: Tool[] +): boolean => { + if (item.resource.candidate.resource_type !== "tool") return false; + const tool = findBindingTool(item, tools); + return tool ? isManagedKnowledgeTool(tool) : false; +}; + +const isVisibleBinding = (item: BindingItemState, tools: Tool[]): boolean => { + if (item.resource.candidate.resource_type === "skill") return true; + return findBindingTool(item, tools)?.is_user_selectable !== false; +}; + +const resolveBindingFocusTarget = ( + boundItems: BindingItemState[], + tools: Tool[] +): Nl2AgentConfigFocusTarget | null => { + if (boundItems.every((item) => isManagedKnowledgeBinding(item, tools))) { + return { section: "knowledge_base" }; + } + + const visibleBoundItems = boundItems.filter((item) => + isVisibleBinding(item, tools) + ); + if (!visibleBoundItems.length) return null; + return { + section: "tools_skills", + capabilityTab: visibleBoundItems.some( + (item) => item.resource.candidate.resource_type === "tool" + ) + ? "tools" + : "skills", + }; +}; + const initializeItems = ( resources: Nl2aRecommendedResource[] ): BindingItemState[] => @@ -509,55 +551,30 @@ export const InstalledResourceBindingCard: FC<{ } }); - if (boundItems.length) { - const persistedBindings = toPersistedBindings( - boundItems, - queryClient.getQueryData(["tools"]) ?? [], - queryClient.getQueryData(["skills"]) ?? [] - ); - const applied = applyPersistedResourceBindings( - payload.agent_id, - persistedBindings - ); - const synchronized = - applied && - (await reloadAgentSnapshot([ - ...items.filter((item) => item.bindingStatus === "bound"), - ...boundItems, - ])); - if (!synchronized) { - showSynchronizationError(); - } else { - const onlyManagedKnowledgeTools = boundItems.every((item) => { - if (item.resource.candidate.resource_type !== "tool") return false; - const toolId = parseResourceId(candidateRef(item), "tool"); - const canonicalTool = findCanonicalTool(availableTools, toolId); - return canonicalTool ? isManagedKnowledgeTool(canonicalTool) : false; - }); - const visibleBoundItems = boundItems.filter((item) => { - if (item.resource.candidate.resource_type === "skill") return true; - const toolId = parseResourceId(candidateRef(item), "tool"); - return ( - findCanonicalTool(availableTools, toolId)?.is_user_selectable !== - false - ); - }); - if (onlyManagedKnowledgeTools) { - requestConfigFocus(payload.agent_id, { - section: "knowledge_base", - }); - } else if (visibleBoundItems.length) { - requestConfigFocus(payload.agent_id, { - section: "tools_skills", - capabilityTab: visibleBoundItems.some( - (item) => item.resource.candidate.resource_type === "tool" - ) - ? "tools" - : "skills", - }); - } - } + if (!boundItems.length) return; + + const persistedBindings = toPersistedBindings( + boundItems, + queryClient.getQueryData(["tools"]) ?? [], + queryClient.getQueryData(["skills"]) ?? [] + ); + const applied = applyPersistedResourceBindings( + payload.agent_id, + persistedBindings + ); + const synchronized = + applied && + (await reloadAgentSnapshot([ + ...items.filter((item) => item.bindingStatus === "bound"), + ...boundItems, + ])); + if (!synchronized) { + showSynchronizationError(); + return; } + + const focusTarget = resolveBindingFocusTarget(boundItems, availableTools); + if (focusTarget) requestConfigFocus(payload.agent_id, focusTarget); }; const continueFlow = async () => { From 33dbec407d807863d7e6dee7d054ce00bdfc4224 Mon Sep 17 00:00:00 2001 From: RedShakespeare Date: Thu, 27 Aug 2026 00:57:08 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor:=20Deduplicat?= =?UTF-8?q?e=20tool=20config=20parameter=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/agentConfig/ToolManagement.tsx | 42 ++------------- .../agentConfig/tool/SelectToolsDialog.tsx | 42 +-------------- .../agentConfig/tool/useMergedToolParams.ts | 53 +++++++++++++++++++ 3 files changed, 58 insertions(+), 79 deletions(-) create mode 100644 frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts diff --git a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx index 2b0e7f350..6a338fd16 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/ToolManagement.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { App, Tooltip } from "antd"; +import { Tooltip } from "antd"; import { useToolList } from "@/hooks/agent/useToolList"; import { useAgentStore } from "@/stores/agentStore"; import { usePrefetchKnowledgeBases } from "@/hooks/useKnowledgeBaseSelector"; @@ -11,8 +11,8 @@ import { ChevronRight, Settings, X, AlertTriangle } from "lucide-react"; import type { Tool, ToolParam } from "@/types/agentConfig"; import { TOOL_SOURCE_TYPES } from "@/const/agentConfig"; import { isManagedKnowledgeTool } from "@/lib/managedKnowledgeTools"; -import { searchToolConfig } from "@/services/agentConfigService"; import ToolConfigModal from "./tool/ToolConfigModal"; +import { useMergedToolParams } from "./tool/useMergedToolParams"; import { TOOLS_REQUIRING_EMBEDDING, TOOLS_REQUIRING_IMAGE_UNDERSTANDING, @@ -21,9 +21,7 @@ import { getToolKbType, getToolLabels, mergeCanonicalTool, - mergeToolParamValues, } from "./tool/utils"; -import log from "@/lib/logger"; // --- Local tool helpers (not in utils) --- @@ -78,7 +76,6 @@ export default function ToolManagement({ currentAgentId, }: ToolManagementProps) { const { t } = useTranslation("common"); - const { message } = App.useApp(); const { prefetchKnowledgeBases } = usePrefetchKnowledgeBases(); const { isImageUnderstandingAvailable, @@ -125,40 +122,7 @@ export default function ToolManagement({ // --- Group by source → category --- const grouped = groupToolsBySource(visibleSelectedTools); - const mergeParams = useCallback( - async (tool: Tool): Promise => { - const params = tool.initParams || []; - const current = useAgentStore.getState().editedAgent?.tools ?? []; - const selectedTool = current.find( - (item) => parseInt(item.id) === parseInt(tool.id) - ); - if (selectedTool) { - return mergeToolParamValues( - params, - Object.fromEntries( - selectedTool.initParams.map((param) => [param.name, param.value]) - ) - ); - } - if (!currentAgentId) return params; - const instance = await searchToolConfig( - parseInt(tool.id), - currentAgentId - ); - if (!instance.success || !instance.data) { - log.error("mergeParams:", instance.message); - message.error( - t( - "nl2agent.resourceBinding.loadExistingConfigFailed", - "Failed to load the current resource configuration." - ) - ); - return null; - } - return mergeToolParamValues(params, instance.data.params); - }, - [currentAgentId, message, t] - ); + const mergeParams = useMergedToolParams(currentAgentId); const openConfig = useCallback( async (tool: Tool) => { diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx index 44fb59bb0..f27bb0046 100644 --- a/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/SelectToolsDialog.tsx @@ -3,7 +3,6 @@ import { useState, useMemo, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { - App, Modal, Tabs, Input, @@ -23,10 +22,10 @@ import { useAgentStore } from "@/stores/agentStore"; import { usePrefetchKnowledgeBases } from "@/hooks/useKnowledgeBaseSelector"; import { useConfig } from "@/hooks/useConfig"; import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { searchToolConfig } from "@/services/agentConfigService"; import { TOOL_SOURCE_TYPES } from "@/const/agentConfig"; import type { Tool, ToolParam } from "@/types/agentConfig"; import ToolConfigModal from "./ToolConfigModal"; +import { useMergedToolParams } from "./useMergedToolParams"; import { TOOLS_REQUIRING_KB_SELECTION, TOOLS_REQUIRING_EMBEDDING, @@ -35,9 +34,7 @@ import { TOOLS_REQUIRING_VIDEO_UNDERSTANDING, getToolKbType, getToolLabels, - mergeToolParamValues, } from "./utils"; -import log from "@/lib/logger"; function isToolDisabled( name: string, @@ -115,7 +112,6 @@ export default function SelectToolsDialog({ currentAgentId, }: SelectToolsDialogProps) { const { t } = useTranslation("common"); - const { message } = App.useApp(); const { confirm } = useConfirmModal(); const { selectableTools } = useToolList({ enabled: open }); @@ -275,41 +271,7 @@ export default function SelectToolsDialog({ [activeCategory, currentGroups] ); - // --- Merge instance params for a tool --- - const mergeInstanceParams = useCallback( - async (tool: any): Promise => { - const params = tool.initParams || []; - const currentSelected = useAgentStore.getState().editedAgent?.tools ?? []; - const selectedTool = currentSelected.find( - (item) => parseInt(item.id) === parseInt(tool.id) - ); - if (selectedTool) { - return mergeToolParamValues( - params, - Object.fromEntries( - selectedTool.initParams.map((param) => [param.name, param.value]) - ) - ); - } - if (!currentAgentId) return params; - const instance = await searchToolConfig( - parseInt(tool.id), - currentAgentId - ); - if (!instance.success || !instance.data) { - log.error("Failed to fetch tool instance params:", instance.message); - message.error( - t( - "nl2agent.resourceBinding.loadExistingConfigFailed", - "Failed to load the current resource configuration." - ) - ); - return null; - } - return mergeToolParamValues(params, instance.data.params); - }, - [currentAgentId, message, t] - ); + const mergeInstanceParams = useMergedToolParams(currentAgentId); // --- Check if tool has missing required params --- const hasMissingRequired = useCallback( diff --git a/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts b/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts new file mode 100644 index 000000000..12711470e --- /dev/null +++ b/frontend/app/[locale]/agents/components/agentConfig/tool/useMergedToolParams.ts @@ -0,0 +1,53 @@ +import { useCallback } from "react"; +import { App } from "antd"; +import { useTranslation } from "react-i18next"; + +import { searchToolConfig } from "@/services/agentConfigService"; +import { useAgentStore } from "@/stores/agentStore"; +import type { Tool, ToolParam } from "@/types/agentConfig"; +import log from "@/lib/logger"; +import { mergeToolParamValues } from "./utils"; + +export function useMergedToolParams(currentAgentId?: number) { + const { t } = useTranslation("common"); + const { message } = App.useApp(); + + return useCallback( + async (tool: Tool): Promise => { + const params = tool.initParams || []; + const selectedTools = useAgentStore.getState().editedAgent?.tools ?? []; + const selectedTool = selectedTools.find( + (item) => parseInt(item.id) === parseInt(tool.id) + ); + if (selectedTool) { + return mergeToolParamValues( + params, + Object.fromEntries( + selectedTool.initParams.map((param) => [param.name, param.value]) + ) + ); + } + if (!currentAgentId) return params; + + const instance = await searchToolConfig( + parseInt(tool.id), + currentAgentId + ); + if (!instance.success || !instance.data) { + log.error( + "Failed to load existing tool configuration:", + instance.message + ); + message.error( + t( + "nl2agent.resourceBinding.loadExistingConfigFailed", + "Failed to load the current resource configuration." + ) + ); + return null; + } + return mergeToolParamValues(params, instance.data.params); + }, + [currentAgentId, message, t] + ); +}