Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies = [
"pypdf>=5.0",
"python-docx>=1.2.0",
"python-pptx>=1.0",
"pypinyin>=0.53",
]

[project.optional-dependencies]
Expand Down
10 changes: 10 additions & 0 deletions src/octop/infra/agents/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions src/octop/infra/agents/plugin_tool_names.py
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions tests/unit/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

import pytest
from harness_agent.plugins import (
Expand All @@ -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"


Expand Down Expand Up @@ -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}}
10 changes: 10 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading