From 30d224155e75d7f572cdf86ce2213dd255469225 Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 5 Feb 2026 22:11:10 +0800 Subject: [PATCH 1/2] fix(ai, config): rewrite tool names for strict providers --- config.toml.example | 14 +- res/prompts/undefined.xml | 2 +- src/Undefined/ai/client.py | 62 +++-- src/Undefined/ai/llm.py | 223 +++++++++++++++++- src/Undefined/config/loader.py | 21 +- .../agents/entertainment_agent/handler.py | 45 +++- .../agents/entertainment_agent/prompt.md | 2 + .../agents/file_analysis_agent/handler.py | 42 +++- .../agents/file_analysis_agent/prompt.md | 2 + .../skills/agents/info_agent/handler.py | 42 +++- .../skills/agents/info_agent/prompt.md | 2 + .../naga_code_analysis_agent/handler.py | 44 +++- .../agents/naga_code_analysis_agent/prompt.md | 2 + .../skills/agents/social_agent/handler.py | 42 +++- .../skills/agents/social_agent/prompt.md | 2 + .../skills/agents/web_agent/handler.py | 42 +++- .../skills/agents/web_agent/prompt.md | 2 + src/Undefined/webui/utils.py | 2 +- 18 files changed, 494 insertions(+), 99 deletions(-) diff --git a/config.toml.example b/config.toml.example index a8b7dd41..8273858d 100644 --- a/config.toml.example +++ b/config.toml.example @@ -157,14 +157,14 @@ backup_count = 5 # en: Log thinking output (default: on). log_thinking = true -# zh: Tools Schema 兼容性(可选)。 -# en: Tools schema compatibility (optional). -# zh: 某些 OpenAI-compatible 网关会对 "tools[].function.description" 做更严格的校验,可能触发 400 错误。 -# en: Some OpenAI-compatible gateways strictly validate "tools[].function.description" and may return 400. +# zh: Tools 兼容性(可选)。 +# en: Tools compatibility (optional). +# zh: 部分 OpenAI-compatible 网关会对 tools schema 做更严格的校验(尤其是 tools[].function.name / description),可能触发 400。 +# en: Some OpenAI-compatible gateways strictly validate tools schema (especially tools[].function.name/description) and may return 400. [tools] -# zh: 是否在请求前清洗 tools schema(默认关闭)。 -# en: Sanitize tools schema before requests (default: off). -sanitize = false +# zh: 工具名分隔符:当工具原始名称包含 '.'(例如 scheduler.create_schedule_task / mcp.server.tool)时,发送给模型前会把 '.' 映射为该分隔符。 +# en: Tool-name delimiter: map '.' to this delimiter before sending tools to the model. +dot_delimiter = "-_-" # zh: description 最大长度(超出会截断)。 # en: Description max length (truncated when exceeded). description_max_len = 1024 diff --git a/res/prompts/undefined.xml b/res/prompts/undefined.xml index 1a302963..51760852 100644 --- a/res/prompts/undefined.xml +++ b/res/prompts/undefined.xml @@ -69,7 +69,7 @@ 你的 content 必须始终始终始终始终为空字符串 ""。 所有消息必须通过 OpenAI tool call 格式调用工具发送。 可用工具:send_message (发送消息), end (结束对话) - **注意:部分高级工具位于工具集 (toolsets) 中,名称带有前缀(如 scheduler.create_schedule_task)。请根据工具定义的名称准确调用。** + **注意:工具集原始命名用 '.' 分隔(如 scheduler.create_schedule_task)。但由于部分模型服务商要求 function.name 只能包含 [a-zA-Z0-9_-],系统会把 '.' 映射为 '-_-'。因此你在 tool call 里应使用 scheduler-_-create_schedule_task(原 scheduler.create_schedule_task)。MCP 工具同理,例如 mcp-_-server-_-tool(原 mcp.server.tool)。请始终以 tools 列表中的 name 为准。** **可以多次调用 send_message 工具,特别是在需要分段发送内容时。** **长回复可分多条发送,但条数要克制,避免刷屏。** diff --git a/src/Undefined/ai/client.py b/src/Undefined/ai/client.py index 43a19756..d0724cab 100644 --- a/src/Undefined/ai/client.py +++ b/src/Undefined/ai/client.py @@ -492,6 +492,17 @@ async def ask( tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice = result.get("choices", [{}])[0] message = choice.get("message", {}) content: str = message.get("content") or "" @@ -566,39 +577,59 @@ async def ask( tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] + tool_internal_names: list[str] = [] for tool_call in tool_calls: call_id = tool_call.get("id", "") function = tool_call.get("function", {}) - function_name = function.get("name", "") + api_function_name = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"[工具准备] 准备调用: {function_name} (ID={call_id})") + internal_function_name = api_to_internal.get( + str(api_function_name), str(api_function_name) + ) + + if internal_function_name != api_function_name: + logger.info( + "[工具准备] 准备调用: %s (原名: %s) (ID=%s)", + internal_function_name, + api_function_name, + call_id, + ) + else: + logger.info( + "[工具准备] 准备调用: %s (ID=%s)", + api_function_name, + call_id, + ) logger.debug( - f"[工具参数] {function_name} 参数: {redact_string(str(raw_args))}" + f"[工具参数] {api_function_name} 参数: {redact_string(str(raw_args))}" ) function_args = parse_tool_arguments( raw_args, logger=logger, - tool_name=function_name, + tool_name=str(api_function_name), ) if not isinstance(function_args, dict): function_args = {} tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(str(api_function_name)) + tool_internal_names.append(str(internal_function_name)) tool_tasks.append( self.tool_manager.execute_tool( - function_name, function_args, tool_context + str(internal_function_name), function_args, tool_context ) ) if tool_tasks: logger.info( - f"[工具执行] 开始并发执行 {len(tool_tasks)} 个工具调用: {', '.join(tool_names)}" + "[工具执行] 开始并发执行 %s 个工具调用: %s", + len(tool_tasks), + ", ".join(tool_internal_names), ) tool_results = await asyncio.gather( *tool_tasks, return_exceptions=True @@ -606,22 +637,23 @@ async def ask( for i, tool_result in enumerate(tool_results): call_id = tool_call_ids[i] - fname = tool_names[i] + api_fname = tool_api_names[i] + internal_fname = tool_internal_names[i] if isinstance(tool_result, Exception): logger.error( - f"[工具异常] {fname} (ID={call_id}) 执行抛出异常: {tool_result}" + f"[工具异常] {internal_fname} (ID={call_id}) 执行抛出异常: {tool_result}" ) content_str = f"执行失败: {str(tool_result)}" else: content_str = str(tool_result) logger.debug( - f"[工具响应] {fname} (ID={call_id}) 返回内容长度: {len(content_str)}" + f"[工具响应] {internal_fname} (ID={call_id}) 返回内容长度: {len(content_str)}" ) if logger.isEnabledFor(logging.DEBUG): log_debug_json( logger, - f"[工具响应体] {fname} (ID={call_id})", + f"[工具响应体] {internal_fname} (ID={call_id})", content_str, ) @@ -629,14 +661,16 @@ async def ask( { "role": "tool", "tool_call_id": call_id, - "name": fname, + "name": api_fname, "content": content_str, } ) if tool_context.get("conversation_ended"): conversation_ended = True - logger.info(f"[会话状态] 工具 {fname} 触发了会话结束标记") + logger.info( + f"[会话状态] 工具 {internal_fname} 触发了会话结束标记" + ) if conversation_ended: logger.info("对话已结束(调用 end 工具)") diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index 6464713e..2f56853b 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import hashlib import json import logging import re @@ -71,6 +72,207 @@ _TOOLS_PARAM_INDEX_RE = re.compile(r"Tools\[(\d+)\]", re.IGNORECASE) _DEFAULT_TOOLS_DESCRIPTION_PREVIEW_LEN = 160 +_DEFAULT_TOOL_NAME_DOT_DELIMITER = "-_-" +_TOOL_NAME_MAX_LEN = 64 +_TOOL_NAME_ALLOWED_RE = re.compile(r"^[a-zA-Z0-9_-]+$") + + +def _tool_name_dot_delimiter() -> str: + runtime_config = _get_runtime_config() + value = ( + getattr(runtime_config, "tools_dot_delimiter", None) if runtime_config else None + ) + text = str(value).strip() if value is not None else _DEFAULT_TOOL_NAME_DOT_DELIMITER + if not text: + return _DEFAULT_TOOL_NAME_DOT_DELIMITER + if "." in text: + return _DEFAULT_TOOL_NAME_DOT_DELIMITER + if not _TOOL_NAME_ALLOWED_RE.match(text): + return _DEFAULT_TOOL_NAME_DOT_DELIMITER + # Keep it reasonably short to avoid tool name truncation. + if len(text) > 16: + return text[:16] + return text + + +def _hash8(text: str) -> str: + return hashlib.sha1(text.encode("utf-8"), usedforsecurity=False).hexdigest()[:8] + + +def _encode_tool_name_for_api(tool_name: str) -> str: + """Encode internal tool name to provider-safe function.name. + + - Replace '.' with '-_-' (keeps internal naming semantics) + - Replace any other disallowed char with '_' + - Enforce max length (<=64), append stable hash when truncated + """ + raw = str(tool_name or "").strip() + if not raw: + return "tool" + + # Preserve separator semantics for toolsets: category.tool -> categorytool + encoded = raw.replace(".", _tool_name_dot_delimiter()) + + # Replace other disallowed characters. + cleaned_chars: list[str] = [] + for ch in encoded: + if ch.isalnum() or ch in {"_", "-"}: + cleaned_chars.append(ch) + else: + cleaned_chars.append("_") + encoded = "".join(cleaned_chars) + + if not encoded: + encoded = "tool" + + if len(encoded) > _TOOL_NAME_MAX_LEN: + suffix = "_" + _hash8(raw) + prefix_len = max(1, _TOOL_NAME_MAX_LEN - len(suffix)) + encoded = encoded[:prefix_len] + suffix + + # Final guard (should always pass) + if not _TOOL_NAME_ALLOWED_RE.match(encoded): + suffix = "_" + _hash8(raw) + encoded = re.sub(r"[^a-zA-Z0-9_-]", "_", encoded) + if len(encoded) > _TOOL_NAME_MAX_LEN: + encoded = encoded[: _TOOL_NAME_MAX_LEN - len(suffix)] + suffix + if not encoded: + encoded = "tool" + suffix + + return encoded + + +def _sanitize_openai_tool_names_in_request( + request_body: dict[str, Any], +) -> tuple[dict[str, str], dict[str, str]]: + """Rewrite request_body tools/messages tool names to provider-safe names. + + Returns: + (api_to_internal, internal_to_api) maps. + + Notes: + - We only guarantee reversibility for names present in tools schema. + - Historical tool calls in messages are rewritten best-effort. + """ + tools = request_body.get("tools") + if not isinstance(tools, list) or not tools: + return {}, {} + + internal_to_api: dict[str, str] = {} + api_to_internal: dict[str, str] = {} + used_api: set[str] = set() + + new_tools: list[dict[str, Any]] = [] + for tool in tools: + if not isinstance(tool, dict): + new_tools.append(tool) + continue + function = tool.get("function") + if not isinstance(function, dict): + new_tools.append(tool) + continue + internal_name = str(function.get("name", "") or "") + if not internal_name: + new_tools.append(tool) + continue + + # Stable encoding; add collision suffix if needed. + base_api_name = _encode_tool_name_for_api(internal_name) + api_name = base_api_name + if api_name in used_api and api_to_internal.get(api_name) != internal_name: + suffix = "_" + _hash8(internal_name) + prefix_len = max(1, _TOOL_NAME_MAX_LEN - len(suffix)) + api_name = base_api_name[:prefix_len] + suffix + if api_name in used_api and api_to_internal.get(api_name) != internal_name: + # Ultra-rare fallback: incorporate index. + suffix = "_" + _hash8(f"{internal_name}:{len(used_api)}") + prefix_len = max(1, _TOOL_NAME_MAX_LEN - len(suffix)) + api_name = base_api_name[:prefix_len] + suffix + + used_api.add(api_name) + internal_to_api[internal_name] = api_name + api_to_internal[api_name] = internal_name + + if api_name != internal_name: + tool = dict(tool) + function = dict(function) + function["name"] = api_name + tool["function"] = function + new_tools.append(tool) + + request_body["tools"] = new_tools + + # Best-effort rewrite of historical tool names in messages. + messages = request_body.get("messages") + if isinstance(messages, list) and messages: + new_messages: list[dict[str, Any]] = [] + changed = False + for message in messages: + if not isinstance(message, dict): + new_messages.append(message) + continue + + new_message = message + + # Rewrite role=tool name (optional field). + msg_name = message.get("name") + if isinstance(msg_name, str) and msg_name: + mapped = internal_to_api.get(msg_name) + if mapped and mapped != msg_name: + if new_message is message: + new_message = dict(message) + new_message["name"] = mapped + changed = True + elif "." in msg_name and not _TOOL_NAME_ALLOWED_RE.match(msg_name): + # Keep request valid even if name isn't in schema map. + safe = _encode_tool_name_for_api(msg_name) + if safe != msg_name: + if new_message is message: + new_message = dict(message) + new_message["name"] = safe + changed = True + + tool_calls = message.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + new_tool_calls: list[Any] = [] + tool_calls_changed = False + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + new_tool_calls.append(tool_call) + continue + function = tool_call.get("function") + if not isinstance(function, dict): + new_tool_calls.append(tool_call) + continue + fname = function.get("name") + if not isinstance(fname, str) or not fname: + new_tool_calls.append(tool_call) + continue + mapped = internal_to_api.get(fname) + safe_name = mapped or _encode_tool_name_for_api(fname) + if safe_name != fname: + tool_calls_changed = True + new_tool_call = dict(tool_call) + new_function = dict(function) + new_function["name"] = safe_name + new_tool_call["function"] = new_function + new_tool_calls.append(new_tool_call) + else: + new_tool_calls.append(tool_call) + + if tool_calls_changed: + if new_message is message: + new_message = dict(message) + new_message["tool_calls"] = new_tool_calls + changed = True + + new_messages.append(new_message) + + if changed: + request_body["messages"] = new_messages + + return api_to_internal, internal_to_api + def _get_runtime_config() -> Config | None: try: @@ -93,10 +295,9 @@ def _split_chat_completion_params( def _tools_sanitize_enabled() -> bool: - runtime_config = _get_runtime_config() - if runtime_config is not None: - return bool(runtime_config.tools_sanitize) - return False + # 历史配置项 tools.sanitize 已迁移为 tools.dot_delimiter。 + # 为兼容严格网关,description 的 schema 清洗默认始终开启。 + return True def _tools_sanitize_verbose() -> bool: @@ -491,6 +692,14 @@ async def request( tool_choice=tool_choice, **kwargs, ) + + api_to_internal: dict[str, str] = {} + internal_to_api: dict[str, str] = {} + if isinstance(request_body.get("tools"), list): + api_to_internal, internal_to_api = _sanitize_openai_tool_names_in_request( + request_body + ) + if "tools" in request_body and isinstance(request_body.get("tools"), list): sanitized_tools, changed_count, changes = _sanitize_openai_tools( request_body["tools"] @@ -543,6 +752,12 @@ async def request( result = await self._request_with_openai(model_config, request_body) result = self._normalize_result(result) + if api_to_internal: + result["_tool_name_map"] = { + "api_to_internal": api_to_internal, + "internal_to_api": internal_to_api, + "dot_delimiter": _tool_name_dot_delimiter(), + } duration = time.perf_counter() - start_time usage = result.get("usage", {}) or {} diff --git a/src/Undefined/config/loader.py b/src/Undefined/config/loader.py index 41d3b7af..53a08e48 100644 --- a/src/Undefined/config/loader.py +++ b/src/Undefined/config/loader.py @@ -321,7 +321,7 @@ class Config: log_max_size: int log_backup_count: int log_thinking: bool - tools_sanitize: bool + tools_dot_delimiter: str tools_description_max_len: int tools_sanitize_verbose: bool tools_description_preview_len: int @@ -415,9 +415,20 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi _get_value(data, ("logging", "log_thinking"), "LOG_THINKING"), True ) - tools_sanitize = _coerce_bool( - _get_value(data, ("tools", "sanitize"), "TOOLS_SANITIZE"), False - ) + tools_dot_delimiter = _coerce_str( + _get_value(data, ("tools", "dot_delimiter"), "TOOLS_DOT_DELIMITER"), "-_-" + ).strip() + if not tools_dot_delimiter: + tools_dot_delimiter = "-_-" + # dot_delimiter 必须满足 OpenAI-compatible 的 function.name 约束。 + if "." in tools_dot_delimiter or not re.fullmatch( + r"[a-zA-Z0-9_-]+", tools_dot_delimiter + ): + logger.warning( + "[配置] tools.dot_delimiter 非法(仅允许 [a-zA-Z0-9_-] 且不能包含 '.'),已回退默认值: '-_-'(当前=%s)", + tools_dot_delimiter, + ) + tools_dot_delimiter = "-_-" tools_description_max_len = _coerce_int( _get_value( data, ("tools", "description_max_len"), "TOOLS_DESCRIPTION_MAX_LEN" @@ -603,7 +614,7 @@ def load(cls, config_path: Optional[Path] = None, strict: bool = True) -> "Confi log_max_size=log_max_size_mb * 1024 * 1024, log_backup_count=log_backup_count, log_thinking=log_thinking, - tools_sanitize=tools_sanitize, + tools_dot_delimiter=tools_dot_delimiter, tools_description_max_len=tools_description_max_len, tools_sanitize_verbose=tools_sanitize_verbose, tools_description_preview_len=tools_description_preview_len, diff --git a/src/Undefined/skills/agents/entertainment_agent/handler.py b/src/Undefined/skills/agents/entertainment_agent/handler.py index f8397341..1c5deda5 100644 --- a/src/Undefined/skills/agents/entertainment_agent/handler.py +++ b/src/Undefined/skills/agents/entertainment_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from __future__ import annotations + import asyncio -import aiofiles import logging from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个娱乐助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 entertainment_agent""" user_prompt: str = args.get("prompt", "") @@ -70,6 +73,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -88,24 +102,33 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备并发执行工具 tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent 正在准备工具: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info( + "Agent 正在准备工具: %s", + internal_function_name, + ) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) # 并发执行 @@ -115,7 +138,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"错误: {str(tool_result)}" @@ -126,7 +149,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/entertainment_agent/prompt.md b/src/Undefined/skills/agents/entertainment_agent/prompt.md index 80c7b890..ebdf30d7 100644 --- a/src/Undefined/skills/agents/entertainment_agent/prompt.md +++ b/src/Undefined/skills/agents/entertainment_agent/prompt.md @@ -1,5 +1,7 @@ 你是娱乐与轻内容助手,帮助用户获得轻松、有趣或休闲的内容。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先理解用户的偏好(风格、题材、口味)再行动。 - 适当给出可选项,让用户选择方向。 diff --git a/src/Undefined/skills/agents/file_analysis_agent/handler.py b/src/Undefined/skills/agents/file_analysis_agent/handler.py index 929a4ca4..321a2bd9 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/file_analysis_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from __future__ import annotations + import asyncio -import aiofiles import logging from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个专业的文件分析助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 file_analysis_agent""" file_source: str = args.get("file_source", "") user_prompt: str = args.get("prompt", "") @@ -76,6 +79,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -97,24 +111,30 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 3. 执行模型选择的工具 (Observation) tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent 正在准备工具: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info("Agent 正在准备工具: %s", internal_function_name) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) if tool_tasks: @@ -123,7 +143,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"错误: {str(tool_result)}" @@ -134,7 +154,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/file_analysis_agent/prompt.md b/src/Undefined/skills/agents/file_analysis_agent/prompt.md index 6d6785ee..62633a6e 100644 --- a/src/Undefined/skills/agents/file_analysis_agent/prompt.md +++ b/src/Undefined/skills/agents/file_analysis_agent/prompt.md @@ -1,5 +1,7 @@ 你是文件分析助手,负责在用户提供文件后进行识别与内容提取。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先明确“用户想要什么结果”(摘要/提取/统计/结构),再选择工具。 - 不确定格式时可先尝试文本读取,再决定是否走专用解析器。 diff --git a/src/Undefined/skills/agents/info_agent/handler.py b/src/Undefined/skills/agents/info_agent/handler.py index 0e673cf5..76255d75 100644 --- a/src/Undefined/skills/agents/info_agent/handler.py +++ b/src/Undefined/skills/agents/info_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from __future__ import annotations + import asyncio -import aiofiles import logging from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个信息查询助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 info_agent""" user_prompt: str = args.get("prompt", "") @@ -70,6 +73,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -88,24 +102,30 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备并发执行工具 tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent 正在准备工具: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info("Agent 正在准备工具: %s", internal_function_name) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) # 并发执行 @@ -115,7 +135,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"错误: {str(tool_result)}" @@ -126,7 +146,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/info_agent/prompt.md b/src/Undefined/skills/agents/info_agent/prompt.md index 6047ed2f..59a10d7d 100644 --- a/src/Undefined/skills/agents/info_agent/prompt.md +++ b/src/Undefined/skills/agents/info_agent/prompt.md @@ -1,5 +1,7 @@ 你是信息查询助手,负责用现有工具快速给出结构化结果或简明结论。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先理解用户是“要数据”还是“要解释”,必要时追问关键参数。 - 能用工具就用工具,不要凭空猜测。 diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py index 071919c2..d8358b8a 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict -from pathlib import Path +from __future__ import annotations + import asyncio -import aiofiles import logging +from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个专业的代码分析助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 code_analysis_agent""" user_prompt: str = args.get("prompt", "") @@ -71,6 +74,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -89,24 +103,30 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备并发执行工具 tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent 正在准备工具: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info("Agent 正在准备工具: %s", internal_function_name) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) # 并发执行 @@ -116,7 +136,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"错误: {str(tool_result)}" @@ -127,7 +147,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md b/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md index 3a44c51e..f9151569 100644 --- a/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md +++ b/src/Undefined/skills/agents/naga_code_analysis_agent/prompt.md @@ -1,5 +1,7 @@ 你是 NagaAgent 项目代码分析助手,目标是帮助用户理解该项目内部实现。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先判断是否是 NagaAgent 相关问题,非相关则建议使用 file_analysis_agent。 - 优先阅读项目说明/文档,再深入到具体文件。 diff --git a/src/Undefined/skills/agents/social_agent/handler.py b/src/Undefined/skills/agents/social_agent/handler.py index cde13423..f85e0654 100644 --- a/src/Undefined/skills/agents/social_agent/handler.py +++ b/src/Undefined/skills/agents/social_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from __future__ import annotations + import asyncio -import aiofiles import logging from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个社交媒体助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 social_agent""" user_prompt: str = args.get("prompt", "") @@ -70,6 +73,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -88,24 +102,30 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备并发执行工具 tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent 正在准备工具: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info("Agent 正在准备工具: %s", internal_function_name) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) # 并发执行 @@ -115,7 +135,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"错误: {str(tool_result)}" @@ -126,7 +146,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/social_agent/prompt.md b/src/Undefined/skills/agents/social_agent/prompt.md index ce2e5b41..2ebc7827 100644 --- a/src/Undefined/skills/agents/social_agent/prompt.md +++ b/src/Undefined/skills/agents/social_agent/prompt.md @@ -1,5 +1,7 @@ 你是社交媒体与影音助手,负责根据用户意图进行内容检索与信息获取。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先澄清平台/关键词/范围,再执行检索。 - 结果多时,优先给“最相关的少量结果 + 可选序号”。 diff --git a/src/Undefined/skills/agents/web_agent/handler.py b/src/Undefined/skills/agents/web_agent/handler.py index 4e7f3ef9..9ea00d43 100644 --- a/src/Undefined/skills/agents/web_agent/handler.py +++ b/src/Undefined/skills/agents/web_agent/handler.py @@ -1,8 +1,11 @@ -from typing import Any, Dict +from __future__ import annotations + import asyncio -import aiofiles import logging from pathlib import Path +from typing import Any + +import aiofiles from Undefined.skills.agents.agent_tool_registry import AgentToolRegistry from Undefined.utils.tool_calls import parse_tool_arguments @@ -24,7 +27,7 @@ def _get_default_prompt() -> str: return "你是一个网络搜索助手..." -async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: +async def execute(args: dict[str, Any], context: dict[str, Any]) -> str: """执行 web_agent""" user_prompt: str = args.get("prompt", "") @@ -71,6 +74,17 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: tool_choice="auto", ) + tool_name_map = ( + result.get("_tool_name_map") if isinstance(result, dict) else None + ) + api_to_internal: dict[str, str] = {} + if isinstance(tool_name_map, dict): + raw_api_to_internal = tool_name_map.get("api_to_internal") + if isinstance(raw_api_to_internal, dict): + api_to_internal = { + str(k): str(v) for k, v in raw_api_to_internal.items() + } + choice: dict[str, Any] = result.get("choices", [{}])[0] message: dict[str, Any] = choice.get("message", {}) content: str = message.get("content") or "" @@ -89,24 +103,30 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: # 准备并发执行工具 tool_tasks = [] tool_call_ids = [] - tool_names = [] + tool_api_names: list[str] = [] for tool_call in tool_calls: call_id: str = tool_call.get("id", "") function: dict[str, Any] = tool_call.get("function", {}) - function_name: str = function.get("name", "") + api_function_name: str = function.get("name", "") raw_args = function.get("arguments") - logger.info(f"Agent preparing tool: {function_name}") + internal_function_name = api_to_internal.get( + api_function_name, api_function_name + ) + + logger.info("Agent preparing tool: %s", internal_function_name) function_args = parse_tool_arguments( - raw_args, logger=logger, tool_name=function_name + raw_args, logger=logger, tool_name=api_function_name ) tool_call_ids.append(call_id) - tool_names.append(function_name) + tool_api_names.append(api_function_name) tool_tasks.append( - tool_registry.execute_tool(function_name, function_args, context) + tool_registry.execute_tool( + internal_function_name, function_args, context + ) ) # 并发执行 @@ -116,7 +136,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: for i, tool_result in enumerate(results): call_id = tool_call_ids[i] - tool_name = tool_names[i] + api_tool_name = tool_api_names[i] content_str: str = "" if isinstance(tool_result, Exception): content_str = f"Error: {str(tool_result)}" @@ -127,7 +147,7 @@ async def execute(args: Dict[str, Any], context: Dict[str, Any]) -> str: { "role": "tool", "tool_call_id": call_id, - "name": tool_name, + "name": api_tool_name, "content": content_str, } ) diff --git a/src/Undefined/skills/agents/web_agent/prompt.md b/src/Undefined/skills/agents/web_agent/prompt.md index c840360e..e51380b8 100644 --- a/src/Undefined/skills/agents/web_agent/prompt.md +++ b/src/Undefined/skills/agents/web_agent/prompt.md @@ -1,5 +1,7 @@ 你是网络搜索与网页阅读助手,负责为用户获取最新或网页内的信息。 +注意:工具名中出现的 `-_-` 代表原本的 `.`(例如 `scheduler-_-create_schedule_task` 原名 `scheduler.create_schedule_task`;MCP 工具同理)。 + 工作原则: - 先判断是“搜索”还是“读取 URL”,必要时追问范围或关键词。 - 优先给出权威来源或一手材料的要点。 diff --git a/src/Undefined/webui/utils.py b/src/Undefined/webui/utils.py index 5725477e..25a5cba9 100644 --- a/src/Undefined/webui/utils.py +++ b/src/Undefined/webui/utils.py @@ -57,7 +57,7 @@ def _resolve_config_example_path(path: Path = CONFIG_EXAMPLE_PATH) -> Path | Non "onebot": ["ws_url", "token"], "logging": ["level", "file_path", "max_size_mb", "backup_count", "log_thinking"], "tools": [ - "sanitize", + "dot_delimiter", "description_max_len", "sanitize_verbose", "description_preview_len", From e7fca123ef59b535a03a5fffccff677362f66f8d Mon Sep 17 00:00:00 2001 From: Null <1708213363@qq.com> Date: Thu, 5 Feb 2026 22:22:38 +0800 Subject: [PATCH 2/2] fix(ai): sanitize historical tool message names --- src/Undefined/ai/llm.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Undefined/ai/llm.py b/src/Undefined/ai/llm.py index 2f56853b..04eb42bc 100644 --- a/src/Undefined/ai/llm.py +++ b/src/Undefined/ai/llm.py @@ -223,8 +223,10 @@ def _sanitize_openai_tool_names_in_request( new_message = dict(message) new_message["name"] = mapped changed = True - elif "." in msg_name and not _TOOL_NAME_ALLOWED_RE.match(msg_name): - # Keep request valid even if name isn't in schema map. + elif (not _TOOL_NAME_ALLOWED_RE.match(msg_name)) or ( + len(msg_name) > _TOOL_NAME_MAX_LEN + ): + # Keep request valid even if name isn't in schema map (e.g. tool renamed/removed). safe = _encode_tool_name_for_api(msg_name) if safe != msg_name: if new_message is message: