Skip to content

Commit 206c104

Browse files
wehosHongzhi Wenclaude
authored
feat(plugin-sdk): @llm_tool 装饰器 — 插件一行注册 LLM 工具 (#1055)
* feat(plugin-sdk): @llm_tool 装饰器 — 一行注册插件 LLM 工具 PR #1035 把 main_server 的 ToolRegistry 统一了,但插件仍要自己起 HTTP server、 轮询注册、写 callback 路由、shutdown 时清理。这次把这些样板全部下沉到 SDK: - @llm_tool 装饰器:把方法的 JSON Schema/描述/超时直接挂在方法上, NekoPluginBase.__init__ 自动扫并注册。也提供 register_llm_tool/ unregister_llm_tool/list_llm_tools 实例方法用于运行时动态注册。 - 路径全程复用既有 IPC:方法注册成 dynamic entry(id=__llm_tool__{name}), 调用走 host.trigger,与 @plugin_entry 的派发管线完全一致。 - 新增 user_plugin_server 端点 /api/llm-tools/callback/{plugin_id}/{tool_name} 接收 main_server 的 dispatch、转发到对应插件进程。callback URL 用实际 绑定端口(NEKO_USER_PLUGIN_SERVER_PORT),避免 48916 被占用时回落失效。 - plugin/server/messaging/llm_tool_registry.py 维护 (plugin_id → tool 名集合) 本地索引;插件 stop 时 lifecycle_service.stop_plugin 调 /api/tools/clear?source=plugin:{plugin_id} 一把清空。 文档 docs/plugins/tool-calling.md 顶部加 TL;DR + SDK Helper Reference; docs/changelog/plugin-llm-tool-sdk.md 写完整迁移说明。 新增 tests/unit/test_plugin_llm_tool_sdk.py 18 个单测覆盖装饰器、自动注册、 imperative API、URL 构造、callback 路由 4 种返回路径。 纯 additive,PR #1035 的 /api/tools/* 接口无改动;旧路插件继续可用。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(plugin-sdk): 清理 lint —— 删未用 import + 给 inner-except 加注释 github-code-quality bot 在 #1055 上指出的 5 处: - plugin/sdk/plugin/base.py: 未用的 LLM_TOOL_ENTRY_PREFIX import - tests/unit/test_plugin_llm_tool_sdk.py: 未用的 asyncio / AsyncMock / patch - plugin/sdk/plugin/base.py: 两个内层 except Exception: pass 没注释(外层 except 已有完整说明,内层是“连日志都失败了,吞掉”——与本文件 _notify_host_comm 的既有 idiom 一致;按 bot 建议加一行说明对齐 lint) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin-sdk): callback 路由用每个工具自己的 timeout + 修文档契约描述 CodeRabbit 在 #1055 上指出 4 处: 1. plugin/server/routes/llm_tools.py 把工具执行 timeout 硬编码成 30s,但 注册时每个工具的 timeout_seconds 是已知的 —— 一个 90s 的工具会被 plugin 侧提前砍掉,main_server 那边仍在等 HTTP 响应。改:registry 从 set[str] 升级成 dict[str, {"timeout_seconds": float}],新增 get_plugin_tool_timeout(plugin_id, name);callback 路由读这个值传给 host.trigger。 2/3. docs 把 /api/tools/clear 写成了 query string + atomic,实际是 POST JSON body + best-effort(HTTP 出错就吞掉)。两处都改正。 4. 三个 fenced ASCII 架构图缺 language tag,markdownlint MD040 报警, 全部加 ```text。 测试:新增 test_callback_route_falls_back_to_default_timeout_when_unknown 覆盖 desync 兜底;test_callback_route_invokes_host_trigger 加断言验证 timeout=90.0 一路传到 host.trigger。19/19 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(plugin-sdk): register/unregister 检查 main_server 的 ok=false 响应 + 文档收紧 CodeRabbit 在 #1055 上又指出 3 处: 1. register_remote_tool: HTTP 200 但 body.ok=false("no role accepted") 时仍然会写本地 _plugin_tools,导致 has_plugin_tool 误报已注册。改成 先检查 body.ok,false 则 raise 不写本地。 2. unregister_remote_tool: 之前先删本地再发 HTTP,partial failure (body.failed_roles 非空但 status=200)时本地索引已丢,shutdown 的 clear 兜不到那个 role。改成 HTTP 先发、检查 failed_roles,全成功才 删本地;partial failure 保留本地索引并 raise。 3. docs(changelog 和 tool-calling.md)原本写"自动处理 re-registration logic",过度承诺;helper 实际只做首轮注册,main_server 重启/启动 竞态后插件需 reload 或 imperative API 自己补。明确加警告 callout。 新测试: - test_register_remote_tool_skips_local_tracking_on_ok_false - test_unregister_remote_tool_keeps_local_on_partial_failure - test_unregister_remote_tool_drops_local_on_full_success 22/22 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(i18n): 同步 zh-CN + ja 版 tool-calling 文档加上 @llm_tool helper 章节 英文版(PR #1055 主体改动)已经加了 TL;DR + SDK Helper Reference 两节, zh-CN 和 ja 翻译版漏了。补齐: - TL;DR 推荐路径示例 - 双层架构图(Layer 1 raw HTTP / Layer 2 SDK helper)+ 重启限制 callout - SDK Helper Reference:装饰器签名、imperative API、错误返回 shape、 生命周期时序、各文件职责表 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5be8d98 commit 206c104

14 files changed

Lines changed: 2432 additions & 3 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
# Plugin SDK: `@llm_tool` decorator
2+
3+
**Status**: introduced this release · stable · no deprecations.
4+
5+
## Summary
6+
7+
The plugin SDK now exposes a one-line way to register a model-callable
8+
LLM tool from a `NekoPluginBase` plugin. Decorate a method with
9+
`@llm_tool`, ship it, and the SDK takes care of the registration with
10+
`main_server`, the round-trip when the LLM picks the tool, and the
11+
cleanup on shutdown.
12+
13+
```python
14+
from plugin.sdk.plugin import neko_plugin, NekoPluginBase, llm_tool, lifecycle, Ok
15+
16+
@neko_plugin
17+
class WeatherPlugin(NekoPluginBase):
18+
@lifecycle(id="startup")
19+
async def startup(self, **_):
20+
return Ok({"status": "ready"})
21+
22+
@llm_tool(
23+
name="get_weather",
24+
description="Look up the weather in a given city.",
25+
parameters={
26+
"type": "object",
27+
"properties": {
28+
"city": {"type": "string", "description": "City name"},
29+
},
30+
"required": ["city"],
31+
},
32+
)
33+
async def get_weather(self, *, city: str):
34+
return {"city": city, "temp_c": 22, "weather": "sunny"}
35+
```
36+
37+
The decorator alone is enough — no need to spin up an HTTP server, no
38+
need to write registration / unregistration / cleanup-on-stop logic
39+
yourself.
40+
41+
> ⚠️ **What this helper doesn't do**: it doesn't auto-recover after a
42+
> `main_server` restart or first-boot race. The IPC notification fires
43+
> once at plugin startup; if `main_server` was unreachable at that
44+
> moment the registration is skipped (with a warning logged) and the
45+
> tool stays invisible to the model until the plugin reloads or
46+
> `register_llm_tool` is called imperatively. Plugins that need
47+
> resilience to `main_server` restarts should detect the condition
48+
> (e.g. via a periodic `GET /api/tools` health probe) and re-register
49+
> themselves.
50+
51+
## Why
52+
53+
Before this release, plugins that wanted the LLM to call into them had
54+
to use the raw `/api/tools/register` HTTP API directly (see
55+
`docs/plugins/tool-calling.md`, layer 1). That meant every plugin had
56+
to:
57+
58+
1. Run its own HTTP server inside the plugin process to receive
59+
`callback_url` POSTs.
60+
2. Discover `main_server`'s loopback URL and POST registration with a
61+
correct `callback_url`.
62+
3. Implement retry logic for the inevitable case where `main_server`
63+
isn't ready when the plugin starts.
64+
4. Track every tool name registered so it can `clear` them on
65+
shutdown.
66+
5. Handle the JSON shape of the dispatch (`{"name", "arguments",
67+
"call_id", "raw_arguments"}`) and the response (`{"output",
68+
"is_error"}`).
69+
70+
That's a lot of boilerplate for what should be "expose this method to
71+
the model." The `@llm_tool` decorator collapses all of it into one
72+
declaration.
73+
74+
## Architecture
75+
76+
```text
77+
(1) IPC: LLM_TOOL_REGISTER
78+
┌──────────────────────────────┐
79+
▼ │
80+
┌────────────────────┐ ┌──────────────────────┐ │ ┌─────────────────┐
81+
│ Plugin process │ │ user_plugin_server │──┼─▶│ Main Server │
82+
│ @llm_tool methods │ │ /api/llm-tools/ │ │ │ ToolRegistry │
83+
│ │◀────────│ callback/{pid}/{n} │◀─┼──│ POSTs callback │
84+
│ IPC trigger │ (3) │ POST main_server │ │ │ when LLM picks │
85+
└────────────────────┘ via └──────────────────────┘ │ │ the tool │
86+
host.trigger ▲ │ └─────────────────┘
87+
│ │ │
88+
└──────────────┘ │
89+
(2) HTTP /api/tools/register
90+
with callback_url pointing
91+
back at user_plugin_server
92+
```
93+
94+
1. **Plugin emits IPC notification.** The decorator stores metadata on
95+
the method. `NekoPluginBase.__init__` auto-discovers tagged methods
96+
and emits `LLM_TOOL_REGISTER` over the existing host message
97+
queue. The handler is also stored as a *dynamic plugin entry*
98+
under the reserved id `__llm_tool__{name}` so the entry-trigger
99+
IPC plumbing handles dispatch.
100+
101+
2. **Host registers with `main_server`.**
102+
`plugin/core/communication.py::_handle_llm_tool_register` consumes
103+
the IPC message and POSTs to `main_server`'s
104+
`/api/tools/register` (added in [plugin-tool-calling-unified
105+
ToolRegistry](https://github.com/Project-N-E-K-O/N.E.K.O/pull/1035)).
106+
The `callback_url` points at
107+
`user_plugin_server`'s new
108+
`/api/llm-tools/callback/{plugin_id}/{tool_name}` route, on the
109+
actually-bound port (read from
110+
`NEKO_USER_PLUGIN_SERVER_PORT` so we cope with port-busy fallback).
111+
112+
3. **`main_server` dispatches a model call.** When the LLM picks the
113+
tool, `main_server` POSTs the call to the callback URL. The
114+
`user_plugin_server` route looks up the live plugin via
115+
`state.plugin_hosts[plugin_id]` and calls
116+
`host.trigger("__llm_tool__{name}", arguments, timeout)` — the
117+
exact same IPC path used by regular `@plugin_entry`s. The plugin's
118+
handler runs in its child process and returns a value, which the
119+
route re-shapes into `{"output": ..., "is_error": ...}` for
120+
`main_server` to feed back to the model.
121+
122+
4. **Cleanup on plugin stop.**
123+
`lifecycle_service.stop_plugin` calls
124+
`plugin/server/messaging/llm_tool_registry.py::clear_plugin_tools`
125+
which POSTs `/api/tools/clear` with body
126+
`{"source": "plugin:{plugin_id}", "role": null}` so every tool
127+
registered by the plugin is dropped in one round-trip. The cleanup
128+
is best-effort — a transient `main_server` outage at stop time is
129+
logged and swallowed; a process restart or manual `clear` call
130+
will reconcile.
131+
132+
## API surface
133+
134+
### Decorator
135+
136+
```python
137+
@llm_tool(
138+
*,
139+
name: str | None = None,
140+
description: str = "",
141+
parameters: dict | None = None,
142+
timeout: float = 30.0,
143+
role: str | None = None,
144+
)
145+
```
146+
147+
* `name` — model-visible name. Defaults to the method's `__name__`.
148+
Must match `[A-Za-z0-9_.\-]{1,64}` (URL-safe path segment +
149+
`main_server`'s 64-char cap).
150+
* `description` — free-text shown to the LLM.
151+
* `parameters` — JSON Schema. Defaults to no arguments.
152+
* `timeout` — per-call timeout in seconds, ≤ 300.
153+
* `role``None` for global, or a catgirl/character name to scope to
154+
one role.
155+
156+
The decorated method receives parsed arguments as kwargs. Return any
157+
JSON-serialisable value, or a `{"output": ..., "is_error": True,
158+
"error": "..."}` dict to signal a tool-level error.
159+
160+
### Imperative API
161+
162+
```python
163+
self.register_llm_tool(
164+
name="custom",
165+
description="...",
166+
parameters={"type": "object", "properties": {...}},
167+
handler=my_callable,
168+
timeout=30.0,
169+
role=None,
170+
)
171+
172+
self.unregister_llm_tool("custom")
173+
self.list_llm_tools() # -> list[dict]
174+
```
175+
176+
Use this when a tool's schema is built at runtime (e.g. from config or
177+
discovered from an external system). The decorator is preferred
178+
otherwise.
179+
180+
## Touched files (this release)
181+
182+
* `plugin/sdk/plugin/llm_tool.py` (new) — decorator, metadata, name
183+
validation, method collector.
184+
* `plugin/sdk/plugin/base.py`
185+
`register_llm_tool` / `unregister_llm_tool` / `list_llm_tools`
186+
instance methods, plus auto-registration of decorated methods in
187+
`__init__`.
188+
* `plugin/sdk/plugin/__init__.py` — re-exports `llm_tool` and
189+
`LlmToolMeta`.
190+
* `plugin/core/communication.py` — adds `LLM_TOOL_REGISTER` /
191+
`LLM_TOOL_UNREGISTER` message routing and host-side handlers that
192+
drive `main_server` registration.
193+
* `plugin/server/messaging/llm_tool_registry.py` (new) — process-
194+
global tracker + httpx wrappers around `main_server`'s
195+
`/api/tools/{register,unregister,clear}`.
196+
* `plugin/server/routes/llm_tools.py` (new) —
197+
`/api/llm-tools/callback/{plugin_id}/{tool_name}` route that
198+
forwards model dispatches into the plugin via `host.trigger`.
199+
* `plugin/server/routes/__init__.py`, `plugin/server/http_app.py`
200+
wire the new router.
201+
* `plugin/server/application/plugins/lifecycle_service.py`
202+
`clear_plugin_tools` on plugin stop.
203+
* `docs/plugins/tool-calling.md` — adds a "TL;DR" + "SDK Helper
204+
Reference" section pointing at the decorator as the recommended
205+
path.
206+
207+
## Backward compatibility
208+
209+
This is purely additive. The pre-existing `/api/tools/register` HTTP
210+
API (PR #1035) is unchanged. Plugins that already roll their own HTTP
211+
server and registration loop keep working — the SDK helper just
212+
removes the need to do that.
213+
214+
There is no deprecation cycle for any existing API in this change.

0 commit comments

Comments
 (0)