package toolx import ( "strings" "cs-ai-agent/internal/pkg/enums" "cs-ai-agent/internal/pkg/i18nx" ) type ToolSpec struct { Code string ServerCode string Name string Title string Description string SourceType enums.ToolSourceType AutoInjected bool DirectAccess bool RuntimeStatic bool Aliases []string Appendix string } type ToolMetadata struct { ToolCode string ServerCode string ToolName string SourceType enums.ToolSourceType } var ( BuiltinToolSearch = ToolSpec{ Code: "builtin/tool_search", ServerCode: "builtin", Name: "tool_search", Title: "搜索并调用动态工具", Description: "用于搜索当前允许使用的 MCP 工具,并在确认目标 toolCode 后动态调用该工具。适合处理长尾工具,不应替代固定内置流程工具。", SourceType: enums.ToolSourceTypeBuiltin, AutoInjected: true, DirectAccess: true, Appendix: strings.TrimSpace(` 当你需要使用长尾 MCP 能力时,优先使用 tool_search 工具,并遵守以下规则: 1. 先调用 tool_search 搜索需要的动态工具,再继续使用已选中的真实工具。 2. 不要假设所有长尾工具一开始就可见;只有被 tool_search 选中的工具,后续模型调用才会暴露出来。 3. 如果当前已有固定内置工具可以完成任务,优先使用固定工具,不要滥用 tool_search。 `), } BuiltinSkill = ToolSpec{ Code: "builtin/skill", ServerCode: "builtin", Name: "skill", Title: "加载专项技能说明", Description: "用于按需加载当前 Agent 可用的专项技能说明文档,适合在需要专项处理规则时再注入上下文。", SourceType: enums.ToolSourceTypeBuiltin, AutoInjected: true, } GraphTriageServiceRequest = ToolSpec{ Code: "graph/triage_service_request", ServerCode: "graph", Name: "triage_service_request", Title: "升级分流判断", Description: "Graph Tool。用于综合分析当前对话,判断应继续解答、整理工单草稿还是转人工,并在需要建单时一并整理工单草稿。", SourceType: enums.ToolSourceTypeGraph, RuntimeStatic: true, Appendix: strings.TrimSpace(` 当你需要判断“继续解答 / 建单 / 转人工”这类复杂升级路径时,优先先调用 triage_service_request 这个 Graph Tool,并遵守以下规则: 1. 该工具会综合当前对话输出 recommendedAction,并在需要建单时附带 ticketDraft。 2. 如果 recommendedAction=continue_answering,则优先继续澄清或解答,不要直接升级。 3. 如果 recommendedAction=prepare_ticket,则优先使用 ticketDraft 或继续补充缺失字段,再调用 create_ticket_with_confirmation。 4. 如果 recommendedAction=handoff_to_human,则确认理由充分后再调用 handoff_to_human。 5. 当升级路径不明确时,优先使用该工具,而不是直接凭主 prompt 做复杂分流判断。 `), } GraphAnalyzeConversation = ToolSpec{ Code: "graph/analyze_conversation", ServerCode: "graph", Name: "analyze_conversation", Title: "分析对话风险与摘要", Description: "Graph Tool。用于整理当前对话摘要、识别风险信号,并给出继续解答、建单或转人工的建议。", SourceType: enums.ToolSourceTypeGraph, RuntimeStatic: true, Appendix: strings.TrimSpace(` 当对话可能涉及投诉升级、退款赔偿、明显负面情绪、是否要建单、是否要转人工等复杂判断时,优先调用 analyze_conversation 这个 Graph Tool,并遵守以下规则: 1. 该工具用于输出结构化摘要、风险信号和下一步建议,不代表实际已经建单或转人工。 2. 如果工具建议为 handoff_to_human,应先确认是否满足转人工条件,再考虑调用 handoff_to_human。 3. 如果工具建议为 prepare_ticket,应优先调用 prepare_ticket_draft 或继续补充信息,而不是直接建单。 4. 如果工具建议为 continue_answering,优先继续澄清和解答,不要过早升级动作。 `), } GraphPrepareTicketDraft = ToolSpec{ Code: "graph/prepare_ticket_draft", ServerCode: "graph", Name: "prepare_ticket_draft", Title: "整理工单草稿", Description: "Graph Tool。用于根据当前会话和已收集信息整理工单草稿,输出建议标题、描述、缺失字段和追问建议。", SourceType: enums.ToolSourceTypeGraph, RuntimeStatic: true, Appendix: strings.TrimSpace(` 当用户已经表达了建单、投诉、报障、售后处理等诉求,但工单标题、描述或问题整理还比较散乱时,优先调用 prepare_ticket_draft 这个 Graph Tool,并遵守以下规则: 1. 该工具用于整理工单草稿,会返回建议标题、建议描述、缺失字段和追问建议。 2. 如果工具返回 ready=false,优先根据 missingFields 和 followUpQuestions 继续追问,不要直接创建工单。 3. 如果工具返回 ready=true,再结合结果考虑调用 create_ticket_with_confirmation。 4. 该工具用于“整理草稿”,不代表已经创建工单。 `), } GraphCreateTicketConfirm = ToolSpec{ Code: "graph/create_ticket_with_confirmation", ServerCode: "graph", Name: "create_ticket_with_confirmation", Title: "创建工单确认流程", Description: "Graph Tool。用于封装建单参数整理、用户确认、真正建单和结果返回的确定性流程。", SourceType: enums.ToolSourceTypeGraph, DirectAccess: true, RuntimeStatic: true, Aliases: []string{"builtin/create_ticket_with_confirmation"}, Appendix: strings.TrimSpace(` 你可以在确认信息充分后调用 create_ticket_with_confirmation 这个 Graph Tool 来创建工单,但必须遵守以下规则: 1. 只有在用户明确表达希望提交工单、投诉、报障、售后处理等诉求时,才考虑调用该工具。 2. 调用前你必须已经整理出清晰的工单标题和问题描述;如果信息还比较散乱,优先先调用 prepare_ticket_draft 或继续追问,不要过早调用。 3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。 4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。 5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。 `), } GraphHandoffConversation = ToolSpec{ Code: "graph/handoff_to_human", ServerCode: "graph", Name: "handoff_to_human", Title: "转人工确认流程", Description: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。", SourceType: enums.ToolSourceTypeGraph, DirectAccess: true, RuntimeStatic: true, Appendix: strings.TrimSpace(` 你可以在确认需要人工介入后调用 handoff_to_human 这个 Graph Tool 来转人工,但必须遵守以下规则: 1. 只有在用户明确要求人工客服,或你已经判断该问题必须由人工继续处理时,才调用该工具。 2. 调用前先尽量整理清楚转人工原因;如果理由含糊,先追问或澄清,不要直接转人工。 3. 一旦决定转人工,必须调用 handoff_to_human 工具,禁止只在回复里口头说“我帮你转人工了”。 4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正转人工;用户取消则结束本次转人工流程。 5. 如果问题仍可由当前对话继续解决,优先继续解答,不要过早转人工。 6. 如果工具返回 terminal=true 且 shouldRetry=false,说明转人工流程已经结束,禁止重复调用该工具。 `), } RegisteredToolSpecs = []ToolSpec{ BuiltinToolSearch, BuiltinSkill, GraphTriageServiceRequest, GraphAnalyzeConversation, GraphPrepareTicketDraft, GraphCreateTicketConfirm, GraphHandoffConversation, } ) var ( toolSpecByCode = buildToolSpecByCode() toolSpecByName = buildToolSpecByName() toolAliasToCanonical = buildToolAliasToCanonical() ) func buildToolSpecByCode() map[string]ToolSpec { ret := make(map[string]ToolSpec, len(RegisteredToolSpecs)) for _, spec := range RegisteredToolSpecs { if strings.TrimSpace(spec.Code) == "" { continue } ret[spec.Code] = spec } return ret } func buildToolAliasToCanonical() map[string]string { ret := make(map[string]string) for _, spec := range RegisteredToolSpecs { for _, alias := range spec.Aliases { alias = strings.TrimSpace(alias) if alias == "" { continue } ret[alias] = spec.Code } } return ret } func buildToolSpecByName() map[string]ToolSpec { ret := make(map[string]ToolSpec, len(RegisteredToolSpecs)) for _, spec := range RegisteredToolSpecs { name := strings.TrimSpace(spec.Name) if name == "" { continue } ret[name] = spec } return ret } func ListRegisteredToolSpecs() []ToolSpec { return append([]ToolSpec(nil), RegisteredToolSpecs...) } func GetRegisteredToolSpec(toolCode string) (ToolSpec, bool) { toolCode = NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) spec, ok := toolSpecByCode[toolCode] return spec, ok } func GetRegisteredToolSpecByName(name string) (ToolSpec, bool) { name = strings.TrimSpace(name) spec, ok := toolSpecByName[name] return spec, ok } func GetRegisteredToolTitle(toolCode string) string { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "" } return spec.Title } func GetRegisteredToolTitleLocale(toolCode string, locale string) string { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "" } if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS { return spec.Title } if text := registeredToolEnglishTitle(spec.Code); text != "" { return text } return spec.Title } func GetRegisteredToolDescription(toolCode string) string { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "" } return spec.Description } func GetRegisteredToolDescriptionLocale(toolCode string, locale string) string { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "" } if i18nx.NormalizeLocale(locale) != i18nx.LocaleEnUS { return spec.Description } if text := registeredToolEnglishDescription(spec.Code); text != "" { return text } return spec.Description } func registeredToolEnglishTitle(toolCode string) string { switch toolCode { case BuiltinToolSearch.Code: return "Search and Run Dynamic Tools" case BuiltinSkill.Code: return "Load Skill Instructions" case GraphTriageServiceRequest.Code: return "Route Service Request" case GraphAnalyzeConversation.Code: return "Analyze Conversation Risk and Summary" case GraphPrepareTicketDraft.Code: return "Prepare Ticket Draft" case GraphCreateTicketConfirm.Code: return "Create Ticket With Confirmation" case GraphHandoffConversation.Code: return "Handoff to Human With Confirmation" default: return "" } } func registeredToolEnglishDescription(toolCode string) string { switch toolCode { case BuiltinToolSearch.Code: return "Searches the MCP tools currently available to the agent and runs the selected tool after its toolCode is confirmed. Best for long-tail tools; it should not replace fixed built-in workflow tools." case BuiltinSkill.Code: return "Loads specialized skill instructions for the current agent when extra task-specific guidance is needed." case GraphTriageServiceRequest.Code: return "Analyzes the current conversation to decide whether to keep answering, prepare a ticket draft, or hand off to a human, including a ticket draft when ticket creation is appropriate." case GraphAnalyzeConversation.Code: return "Summarizes the current conversation, identifies risk signals, and recommends whether to keep answering, create a ticket, or hand off to a human." case GraphPrepareTicketDraft.Code: return "Turns the current conversation and collected details into a ticket draft with a suggested title, description, missing fields, and follow-up questions." case GraphCreateTicketConfirm.Code: return "Guides ticket creation with parameter preparation, customer confirmation, actual ticket creation, and final result delivery." case GraphHandoffConversation.Code: return "Guides human handoff with reason preparation, customer confirmation, actual transfer, and final result delivery." default: return "" } } func GetRegisteredToolIdentity(toolCode string) (serverCode, toolName string, ok bool) { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "", "", false } return spec.ServerCode, spec.Name, true } func ResolveToolSourceType(toolCode string) enums.ToolSourceType { if spec, ok := GetRegisteredToolSpec(toolCode); ok { return spec.SourceType } toolCode = strings.TrimSpace(toolCode) switch { case strings.HasPrefix(toolCode, "graph/"): return enums.ToolSourceTypeGraph case strings.HasPrefix(toolCode, "builtin/"): return enums.ToolSourceTypeBuiltin default: return enums.ToolSourceTypeMCP } } func IsAutoInjectedToolCode(toolCode string) bool { spec, ok := GetRegisteredToolSpec(toolCode) return ok && spec.AutoInjected } func ListAgentDirectToolSpecs() []ToolSpec { ret := make([]ToolSpec, 0, len(RegisteredToolSpecs)) for _, spec := range RegisteredToolSpecs { if !spec.DirectAccess { continue } ret = append(ret, spec) } return ret } func ListRuntimeStaticToolSpecs() []ToolSpec { ret := make([]ToolSpec, 0, len(RegisteredToolSpecs)) for _, spec := range RegisteredToolSpecs { if !spec.RuntimeStatic { continue } ret = append(ret, spec) } return ret } func IsAgentDirectToolCode(toolCode string) bool { toolCode = NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) spec, ok := GetRegisteredToolSpec(toolCode) return ok && spec.DirectAccess } func IsAgentDirectGraphToolCode(toolCode string) bool { toolCode = NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) spec, ok := GetRegisteredToolSpec(toolCode) return ok && spec.DirectAccess && spec.SourceType == enums.ToolSourceTypeGraph } func NormalizeToolCodeAlias(toolCode string) string { toolCode = strings.TrimSpace(toolCode) if canonical, ok := toolAliasToCanonical[toolCode]; ok { return canonical } return toolCode } func BuildToolAppendices(hasDynamicMCPTools bool, toolCodes map[string]string) []string { return BuildToolAppendicesForCodes(hasDynamicMCPTools, toolCodesFromMap(toolCodes)) } func BuildToolAppendicesForCodes(hasDynamicMCPTools bool, toolCodes []string) []string { ret := make([]string, 0, len(toolCodes)+1) normalizedToolCodes := NormalizeToolCodes(toolCodes) if hasDynamicMCPTools && strings.TrimSpace(BuiltinToolSearch.Appendix) != "" { ret = append(ret, BuiltinToolSearch.Appendix) } for _, spec := range RegisteredToolSpecs { if strings.TrimSpace(spec.Appendix) == "" { continue } if spec.Code == BuiltinToolSearch.Code { continue } if containsNormalizedToolCode(normalizedToolCodes, spec.Code) { ret = append(ret, spec.Appendix) } } return ret } func BuildToolMetadata(toolCode string) (serverCode, toolName string, sourceType enums.ToolSourceType, ok bool) { spec, ok := GetRegisteredToolSpec(toolCode) if !ok { return "", "", ResolveToolSourceType(toolCode), false } return spec.ServerCode, spec.Name, spec.SourceType, true } func ResolveToolMetadata(toolCode string, fallbackName string) ToolMetadata { toolCode = NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) fallbackName = strings.TrimSpace(fallbackName) serverCode, toolName, sourceType, ok := BuildToolMetadata(toolCode) if !ok && toolName == "" { toolName = fallbackName } if toolName == "" { toolName = fallbackName } return ToolMetadata{ ToolCode: toolCode, ServerCode: strings.TrimSpace(serverCode), ToolName: strings.TrimSpace(toolName), SourceType: sourceType, } } func IsAlwaysAllowedToolCode(toolCode string) bool { return NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) == GraphHandoffConversation.Code } func IsImpliedAllowedToolCode(toolCode string, allowedToolCodes map[string]struct{}) bool { toolCode = NormalizeToolCodeAlias(strings.TrimSpace(toolCode)) if toolCode == "" || len(allowedToolCodes) == 0 { return false } switch toolCode { case GraphTriageServiceRequest.Code, GraphAnalyzeConversation.Code: if _, ok := allowedToolCodes[GraphCreateTicketConfirm.Code]; ok { return true } if _, ok := allowedToolCodes[GraphHandoffConversation.Code]; ok { return true } case GraphPrepareTicketDraft.Code: _, ok := allowedToolCodes[GraphCreateTicketConfirm.Code] return ok } return false } func hasToolCode(toolCodes map[string]string, target string) bool { return containsNormalizedToolCode(toolCodesFromMap(toolCodes), target) } func containsNormalizedToolCode(toolCodes []string, target string) bool { target = strings.TrimSpace(target) if target == "" || len(toolCodes) == 0 { return false } for _, item := range NormalizeToolCodes(toolCodes) { if item == target { return true } } return false } func toolCodesFromMap(toolCodes map[string]string) []string { if len(toolCodes) == 0 { return nil } ret := make([]string, 0, len(toolCodes)) for _, item := range toolCodes { ret = append(ret, item) } return ret } func NormalizeToolCodes(items []string) []string { if len(items) == 0 { return nil } ret := make([]string, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, item := range items { item = NormalizeToolCodeAlias(strings.TrimSpace(item)) if item == "" { continue } if _, ok := seen[item]; ok { continue } seen[item] = struct{}{} ret = append(ret, item) } return ret } func IntersectToolCodes(left []string, right []string) []string { left = NormalizeToolCodes(left) right = NormalizeToolCodes(right) switch { case len(left) == 0: return right case len(right) == 0: return left } rightSet := make(map[string]struct{}, len(right)) for _, item := range right { rightSet[item] = struct{}{} } ret := make([]string, 0, len(left)) for _, item := range left { if _, ok := rightSet[item]; ok { ret = append(ret, item) } } return ret }