diff --git a/CHANGELOG.md b/CHANGELOG.md index 40253da1..9f2a3ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ## [Unreleased] ### 修复 + +- 插件工具使用中文等非 ASCII 名称时 LLM 调用失败:主流 API 要求工具名匹配 `^[a-zA-Z0-9_-]{1,64}$`,现自动将非法名称转写为合法拼音名(`pypinyin` 缺失时退回下划线替换),冲突追加 `_2`/`_3` 后缀,并在工具描述前缀 `[原名: …]` 保留原名映射;`config_json.plugins` 配置键与插件内部仍使用原始名称,路由不受影响 - 修复聊天页在"生成中"时于输入框持续打字导致消息列表上下轻微抖动的问题:输入框高度测量改为在离屏克隆节点上进行,不再瞬态改变页面布局 ## [0.9.24] - 2026-08-15 diff --git a/pyproject.toml b/pyproject.toml index 11d02003..bf1bffe1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "pypdf>=5.0", "python-docx>=1.2.0", "python-pptx>=1.0", + "pypinyin>=0.53", ] [project.optional-dependencies] diff --git a/src/octop/infra/agents/manager.py b/src/octop/infra/agents/manager.py index 71da880c..4e689f54 100644 --- a/src/octop/infra/agents/manager.py +++ b/src/octop/infra/agents/manager.py @@ -1936,6 +1936,16 @@ def _build_harness_config(self, row: AgentRow) -> HarnessAgentConfig: agent_plugins=agent_plugins, global_plugins=global_plugins, ) + # Plugin authors may register tools with non-ASCII (e.g. Chinese) names, + # which strict LLM tool-name APIs reject. Rewrite them to legal names + # before binding, keeping the original in the description. Config keys + # and the plugin-side closures still use the original names. + from octop.infra.agents.plugin_tool_names import sanitize_plugin_tool_names # noqa: PLC0415 + + sanitize_plugin_tool_names( + plugin_tools, + reserved={str(getattr(t, "name", "")) for t in [*(cron_tools or []), *knowledge_tools]}, + ) plugin_middleware = PluginRegistry().build_middleware_chain(global_enabled=global_plugins) global_policy = self._security.harness_policy() agent_override = cfg.get("security") if isinstance(cfg.get("security"), dict) else None diff --git a/src/octop/infra/agents/plugin_tool_names.py b/src/octop/infra/agents/plugin_tool_names.py new file mode 100644 index 00000000..7d62f445 --- /dev/null +++ b/src/octop/infra/agents/plugin_tool_names.py @@ -0,0 +1,90 @@ +"""Sanitize plugin tool names for strict LLM tool-name APIs. + +Plugin authors register tools with ``ctx.tool("中文名", fn, ...)``; the harness +passes that name straight into the function-calling schema, but most LLM APIs +only accept ``^[a-zA-Z0-9_-]{1,64}$``. Mirroring the MCP-side fix +(``harness_agent.mcp.sanitize_llm_tool_name``) this module rewrites non-conforming +plugin tool names to legal ASCII names: + +- CJK characters are transliterated to pinyin (``天气查询`` -> ``tianqichaxun``) + when :mod:`pypinyin` is importable; otherwise every illegal character becomes + ``_``. +- The original name is kept in a ``[原名: ...]`` description prefix so the model + and the user can still map the sanitized name back. +- Collisions get ``_2``/``_3`` suffixes and results are truncated to 64 chars. + +Routing is unaffected: ``ToolNode`` matches the exposed name and the underlying +plugin function is untouched. Config keys (``config_json.plugins``) keep using +the original names. +""" + +from __future__ import annotations + +import re +from typing import Any + +_LLM_TOOL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$") +_ILLEGAL_CHARS_RE = re.compile(r"[^a-zA-Z0-9_-]+") +_MAX_NAME_LEN = 64 + +_ORIGINAL_NAME_PREFIX = "[原名: {name}] " + + +def _transliterate(name: str) -> str: + """Return an ASCII-ish rendering of ``name`` (pinyin when possible).""" + try: + from pypinyin import lazy_pinyin # noqa: PLC0415 + except ImportError: + # Zero-dependency fallback: mirror MCP's underscore substitution. + return _ILLEGAL_CHARS_RE.sub("_", name) + syllables = lazy_pinyin(name, errors="default") + joined = "".join(str(part) for part in syllables if str(part)) + ascii_joined = _ILLEGAL_CHARS_RE.sub("_", joined) + return ascii_joined + + +def _dedupe(candidate: str, used: set[str]) -> str: + if candidate not in used: + return candidate + suffix = 2 + while f"{candidate}_{suffix}" in used: + suffix += 1 + return f"{candidate}_{suffix}" + + +def sanitize_plugin_tool_name(name: str, *, used: set[str] | None = None) -> str: + """Return a legal LLM tool name for ``name``, unique against ``used``.""" + used = used if used is not None else set() + if _LLM_TOOL_NAME_RE.match(name): + candidate = _dedupe(name, used) + used.add(candidate) + return candidate + candidate = _transliterate(name).strip("_") or "plugin_tool" + if len(candidate) > _MAX_NAME_LEN: + # Leave room for a possible dedupe suffix (_2, _3, ...). + candidate = candidate[: _MAX_NAME_LEN - 4].rstrip("_") or "plugin_tool" + candidate = _dedupe(candidate, used) + used.add(candidate) + return candidate + + +def sanitize_plugin_tool_names( + tools: list[Any], + *, + reserved: frozenset[str] | set[str] = frozenset(), +) -> list[Any]: + """Rewrite illegal plugin tool names in place and return ``tools``. + + ``reserved`` holds names already taken by other tools on the same agent + (cron/knowledge/team tools) so sanitized names cannot shadow them. + """ + used: set[str] = set(reserved) + for tool in tools: + original = str(getattr(tool, "name", "")) + sanitized = sanitize_plugin_tool_name(original, used=used) + if sanitized == original: + continue + tool.name = sanitized + description = str(getattr(tool, "description", "") or "") + tool.description = _ORIGINAL_NAME_PREFIX.format(name=original) + description + return tools diff --git a/tests/unit/test_plugins.py b/tests/unit/test_plugins.py index 22aac2c9..ffa7da37 100644 --- a/tests/unit/test_plugins.py +++ b/tests/unit/test_plugins.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any import pytest from harness_agent.plugins import ( @@ -13,6 +14,11 @@ ) from langchain_core.tools import StructuredTool +from octop.infra.agents.plugin_tool_names import ( + sanitize_plugin_tool_name, + sanitize_plugin_tool_names, +) + _FIXTURE = Path(__file__).resolve().parents[1] / "fixtures" / "plugins" / "echo-tool" @@ -58,3 +64,118 @@ def test_collect_plugin_tool_configs() -> None: }, ) assert cfg == {"echo_message": {"prefix": ">> "}} + + +def _make_tool(name: str, description: str = "demo tool") -> StructuredTool: + def _fn(message: str) -> str: + return message + + return StructuredTool.from_function(func=_fn, name=name, description=description) + + +def test_sanitize_ascii_name_passthrough() -> None: + tool = _make_tool("echo_message", "original description") + result = sanitize_plugin_tool_names([tool]) + assert result[0].name == "echo_message" + assert result[0].description == "original description" + + +def test_sanitize_chinese_name_transliterates_to_pinyin() -> None: + pytest.importorskip("pypinyin") + tool = _make_tool("天气查询", "查询指定城市的天气") + result = sanitize_plugin_tool_names([tool]) + assert result[0].name == "tianqichaxun" + assert result[0].description == "[原名: 天气查询] 查询指定城市的天气" + + +def test_sanitize_mixed_name_keeps_ascii_parts() -> None: + pytest.importorskip("pypinyin") + assert sanitize_plugin_tool_name("获取weather信息") == "huoquweatherxinxi" + + +def test_sanitize_collision_gets_suffix() -> None: + first = sanitize_plugin_tool_name("天气查询") + second = sanitize_plugin_tool_name("天气查询", used={first}) + assert second != first + assert second == f"{first}_2" + # A legal name that is already reserved also gets deduped. + assert sanitize_plugin_tool_name("echo_message", used={"echo_message"}) == "echo_message_2" + + +def test_sanitize_truncates_overlong_names() -> None: + long_name = "很" * 80 + sanitized = sanitize_plugin_tool_name(long_name) + assert len(sanitized) <= 64 + + +def test_sanitize_falls_back_to_underscores_without_pypinyin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import builtins + + real_import = builtins.__import__ + + def _blocked(name: str, *args: Any, **kwargs: Any) -> Any: + if name == "pypinyin" or name.startswith("pypinyin."): + raise ImportError(name) + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", _blocked) + sanitized = sanitize_plugin_tool_name("天气查询") + assert sanitized + assert sanitized.isascii() + assert all(ch.isalnum() or ch in "_-" for ch in sanitized) + + +def test_sanitize_plugin_tools_reserved_names() -> None: + tool = _make_tool("echo_message") + sanitize_plugin_tool_names([tool], reserved={"echo_message"}) + assert tool.name == "echo_message_2" + + +def test_build_plugin_tools_then_sanitize_keeps_config_keys_original() -> None: + pytest.importorskip("pypinyin") + from harness_agent.plugins.manifest import PluginManifest + from harness_agent.plugins.registry import LoadedPlugin, ToolRegistration + + manifest = PluginManifest(id="demo", version="1.0.0", name="Demo", kind="tool", entry="main.py") + PluginRegistry().register( + LoadedPlugin( + manifest=manifest, + source_path=Path("."), + tools=[ + ToolRegistration( + plugin_id="demo", + name="发送邮件", + fn=lambda to: to, + description="发送一封邮件", + ), + ToolRegistration( + plugin_id="demo", + name="echo_message", + fn=lambda text: text, + description="echo", + ), + ], + ), + ) + tools = build_plugin_tools( + agent_plugins={ + "demo": { + "tools": { + "发送邮件": {"enabled": True}, + "echo_message": {"enabled": True}, + }, + }, + }, + ) + sanitized = sanitize_plugin_tool_names(tools) + names = {t.name for t in sanitized} + assert "fasongyoujian" in names # pinyin of 发送邮件 + assert "echo_message" in names + descriptions = {t.name: t.description for t in sanitized} + assert descriptions["fasongyoujian"].startswith("[原名: 发送邮件]") + # Config lookup still keyed by the original plugin-side name. + assert collect_plugin_tool_configs( + {"demo": {"tools": {"发送邮件": {"enabled": True, "config": {"a": 1}}}}}, + ) == {"发送邮件": {"a": 1}} diff --git a/uv.lock b/uv.lock index 8c17e950..c743b57f 100644 --- a/uv.lock +++ b/uv.lock @@ -2489,6 +2489,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyjwt" }, { name = "pypdf" }, + { name = "pypinyin" }, { name = "python-docx" }, { name = "python-pptx" }, { name = "questionary" }, @@ -3613,6 +3614,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123 }, ] +[[package]] +name = "pypinyin" +version = "0.55.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/a4/784cf98c09e0dc22776b0d7d8a4a5b761218bcae4608c2416ce1e167c8af/pypinyin-0.55.0.tar.gz", hash = "sha256:b5711b3a0c6f76e67408ec6b2e3c4987a3a806b7c528076e7c7b86fcf0eaa66b", size = 839836 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/7b/4cabc76fcc21c3c7d5c671d8783984d30ac9d3bb387c4ba784fca3cdfa3a/pypinyin-0.55.0-py2.py3-none-any.whl", hash = "sha256:d53b1e8ad2cdb815fb2cb604ed3123372f5a28c6f447571244aca36fc62a286f", size = 840203 }, +] + [[package]] name = "pyproject-hooks" version = "1.2.0"