Skip to content

Commit db23883

Browse files
wehosHongzhi Wenclaude
authored
feat(tools): 统一工具调用接口,offline + realtime 多 provider (#1035)
* feat(tools): 统一工具调用接口,offline + realtime 多 provider 支持 新增 ToolRegistry / ToolDefinition / ToolCall / ToolResult 作为 provider-agnostic 基础类型(main_logic/tool_calling.py),LLMSessionManager 持有 registry 并 向 OmniOfflineClient / OmniRealtimeClient 透传 on_tool_call 回调与 tool 列表。 Provider 协议覆盖: - Offline OpenAI-compat(含 Anthropic / OpenRouter / Qwen-text):ChatOpenAI 扩展 tools/tool_choice 透传 + 流式 tool_call_deltas 累积 - Offline Gemini:迁移到 google-genai SDK 直连,OpenAI-compat 作为 fallback - Realtime OpenAI gpt:flat schema + response.done.output[type=function_call] - Realtime Gemini Live:types.Tool(function_declarations) + send_tool_response - Realtime GLM:flat schema,function_call_output 不带 call_id(按文档), mid-session 更新 tools 时同时传 turn_detection - Realtime StepFun + free-lanlan.tech:嵌套 function 形,保留 web_search - Realtime Qwen Omni:嵌套 function 形(按 Aliyun 最新文档),与 enable_search 互斥时自动关闭 跨进程开放:main_routers/tool_router.py 提供 /api/tools/{register,unregister, clear} HTTP 端点供 plugin / agent_server 注册带 callback_url 的远程工具, ToolRegistry.remote_dispatcher 在执行时 POST 转发。 TODO 留待后续: - Realtime free-lanlan.app(Vertex AI Live 代理,需服务端配合) - Offline lanlan.app 国际版(Gemini OpenAI-compat 端点丢弃 tools 字段) 测试:tests/unit/test_tool_calling.py 16 项,覆盖 registry 本地/远程派发、 streaming delta 聚合、offline tool loop 端到端、迭代上限、各 provider wire-format(Qwen 嵌套 vs GLM flat、GLM 不带 call_id、Qwen enable_search 互斥、GLM mid-session turn_detection、StepFun web_search 共存)。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): 清理 unused imports + 空 except 加注释 - tests/unit/test_tool_calling.py:删掉测试文件没用上的 asyncio / types as _types imports(pytest.mark.asyncio 不需要直接 import asyncio)。 - main_logic/omni_offline_client.py:genai usage_metadata 的兜底 except 加上 debug-log 与意图说明 —— usage 是可选 telemetry,SDK 字段差异不应打断主文本流。 回应 PR #1035 上 github-code-quality bot 的三条 inline 反馈。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): switch_model 重算 genai 路由 + tool 历史带 function name 回应 PR #1035 Codex 两条反馈: P1 (omni_offline_client.py:417): switch_model 之前只改了 self.model / self.llm, 没同步 self.base_url / self.api_key,也没重算 _use_genai_sdk。结果: conversation 是 OpenAI、vision 是 Gemini 的场景,第一次发图切到 vision 后 路由旗标还停在 OpenAI-compat,把 Gemini 请求错送到 OpenAI 路径。修: - switch_model 同步 base_url / api_key - 重算 _use_genai_sdk - 清空 _genai_client(api_key 可能变了,强制 lazy init) - 重置 _genai_tools_unsupported P2 (omni_offline_client.py:222): _execute_and_append_openai_tool_calls append 的 tool 消息没带 name 字段,转 Gemini Content 时只能 fallback 到 tool_call_id("call_xxx"),Gemini server 看到 FunctionResponse.name 不 匹配原 function_call.name 会丢弃这条结果。修: - append tool 消息时一并写入 name 字段 - _genai_messages_to_contents 的 tool 分支:先用 msg.name,再反查前一条 assistant tool_calls 找 tool_call_id 对应的 function name,最后才占位。 绝不把 call_xxx 当函数名给 Gemini。 测试:补 test_offline_switch_model_recomputes_genai_routing 回归保护, test_offline_openai_path_runs_tool_then_text 断言 history 带 name。 共 17/17 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): pending_session 同步 + 空 name 防御 + timeout 上下界 回应 PR #1035 CodeRabbit 三条反馈(剩 2 条与 Codex P1/P2 重复,已在 3d4a39b 修过): 1. core.py _sync_tools_to_active_session 仅同步 self.session, pending_session(热切换预热中)拿不到 mid-conversation 注册的工具。 修:覆盖两个 session,OmniRealtimeClient 仅在 ws 已连接时才 apply_tools。 2. utils/llm_client.py ChatOpenAI.collect_tool_calls 不过滤空 name 槽位。 SDK 偶发流出残缺碎片会被原样写进 tool_calls 历史,下一轮调用被 server schema reject。修:drop empty-name 并 warning。 omni_offline_client._execute_and_append_openai_tool_calls 入口加同样的 防御性过滤,万一外部直接构造也兜得住。 3. main_routers/tool_router.py ToolRegisterRequest.timeout_seconds 没 上下界。误填超大值会卡死整条 tool-call 路径。修:Field(gt=0, le=300)。 测试:补 test_collect_tool_calls_drops_empty_name_fragments 回归保护, 共 18/18 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(tools): 抽 _make_rt_client helper 减少 realtime 测试初始化重复 回应 PR #1035 CodeRabbit nitpick:5 个 OmniRealtimeClient wire-format 测试 里 __new__ + 7 个字段赋值 + send_event 桩的初始化模板逐字一致,抽成 _make_rt_client(api_type, *, tool_name, tool_kwargs) 后,单测从 ~220 行 缩到 ~140 行;后续 client 加新内部状态只需改一处。 行为完全保留——18/18 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): connect 后补 sync + 路由本地校验 + genai client 优雅关闭等 5 处 回应 PR #1035 CodeRabbit 第 3 轮 review 6 条反馈: 1. core.py 主 session / pending_session 的 connect() 后各补一次 _sync_tools_to_active_session()。修构造时拍快照与 connect 期间 register_tool 的 race window:之前的异步 fire_task 可能还没看到 self.session 就跑完,新连上的 session 会带旧 toolset 直到下次工具变更。 2. main_routers/tool_router.py:所有端点挂上 verify_local_access 依赖(复用 cookies_login_router 已有实现),避免服务暴露到 LAN 时 被远程注册任意 callback_url。 3. main_routers/tool_router.py register 的全失败分支:之前永远返回 ok=True,全失败时插件会误以为已注册。改为 affected 为空时返回 ok=False + failed_roles 详情,部分成功时同时带回 failed_roles。 4. omni_offline_client.py _genai_messages_to_contents:tool 消息查 不到 function name 时改成 continue 跳过,不再用 "unknown_tool" 占位(占位会让 Gemini 拿到孤儿 tool result,无效还浪费 token)。 5. omni_offline_client.py switch_model + close:旧 _genai_client 提早 close()(SDK 只有同步 close 没 aclose,用 to_thread 不阻事件循环), 及时释放底层 httpx 连接池。 6. tests/unit/test_tool_calling.py: switch_model 路由重算回归测试 monkeypatch _GENAI_AVAILABLE=True,避免没装 google-genai 的 CI 静默 skip。 18/18 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): final swap promote 后补 sync + transient 错误别永久禁用 genai 回应 PR #1035 CodeRabbit 第 4 轮 review 2 条反馈: 1. core.py _perform_final_swap_sequence 在 self.session = new_session 之后立刻补一次 _sync_tools_to_active_session()。swap 序列里 pending_session → 局部 new_session → self.session 跨了几个 await, 期间 register_tool 触发的 _sync 既找不到 pending_session(已被挪走) 也找不到 self.session(还没赋值),导致 promote 后新 session 缺 那次注册的工具。 2. omni_offline_client.py _astream_genai_with_tools 不再把 generate_content_stream 的所有异常 wrap 成 _GenaiToolsUnsupported。 原实现会让 transient 错误(429/5xx/网络抖动/auth 临时失败)触发 _astream_with_tools 永久翻 _genai_tools_unsupported=True,整个 session 后续都退化到 OpenAI-compat(且 OpenAI-compat 不支持 Gemini 工具)。改为只在错误消息含 tool/function + not_support/unsupported/ invalid 关键字组合时才认定 tools 不支持,其余异常直接 raise,让上层 except Exception 分支临时 fallback 但下一轮还会重试 genai。 测试:补 test_offline_genai_transient_error_does_not_disable_tools 和 test_offline_genai_tools_unsupported_error_correctly_disables_path 做对偶验证。20/20 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): 同轮 text+tool_call 历史完整 + stream 异常判断收紧 回应 PR #1035 CodeRabbit 第 5 轮 review: 1. omni_offline_client.py _astream_genai_with_tools 同一 Gemini turn 里 text part 与 function_call part 并存时,写 assistant 历史的 content 不再固定 "",改为累积本轮已 yield 给用户的 text。否则下一轮 LLM 看上下文会缺掉前半句,模型重复 / 改口,最终持久化历史的顺序也跟 真实生成顺序对不上。 2. 同文件流过程异常判断收紧到与 generate_content_stream 调用本身一致: 只有错误消息含 "tool/function" + "not_support/unsupported/invalid" 关键字组合时才永久翻盘禁用 genai 路径,其他流中异常(含 "function call timeout" 之类的 transient)原样 raise,让上层临时 fallback、下一轮再试。 测试:补 test_offline_genai_streamed_text_persisted_with_tool_call 回归保护。21/21 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): tools sync 串行 + Gemini 历史 text part 不丢 回应 PR #1035 CodeRabbit 第 6 轮 review 2 条: 1. core.py 新增 _tool_sync_lock 串行化 _sync_tools_to_active_session: 防止连续 register / unregister / clear 触发的 session.update 在 wire 上乱序,最后一份快照可能不对应 registry 最终状态。新增 register_tool_and_sync / unregister_tool_and_sync / clear_tools_and_sync await 版本,HTTP /api/tools 端点改用这条路径,让 caller 拿到 ok=True 时 session 已真生效,不再有"返回成功但下次 model 调用看不到工具"的窗口。 同步入口 register_tool 仍保留 fire-and-forget 兜底。 2. omni_offline_client.py _genai_messages_to_contents 在 assistant 同时带 content + tool_calls 时,先 emit text part 再 emit function_call parts。 原实现只保留 function_call parts,把上一轮才修复的 streamed_text_buffer 写进历史的 text 又扔了,下一轮 LLM 还是看不到已 stream 出去的前半句。 测试:补 test_genai_messages_to_contents_preserves_text_with_tool_calls + test_register_tool_and_sync_serializes_concurrent_updates。23/23 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): genai 已吐文本后不再静默 fallback,避免双流拼接 回应 PR #1035 CodeRabbit 第 7 轮 review actionable: omni_offline_client.py _astream_with_tools 跟踪 ``genai_emitted_text``。 原实现:_astream_genai_with_tools 在已经 yield 过 text chunk 之后才抛 transient 异常时,落到 except 分支会继续 fallback 到 _astream_openai_with_tools, 用户在同一轮看到"半截 Gemini 文本 + 一份基于旧历史从头生成的 OpenAI 文本"双流拼接。 修:已吐文本后再抛异常 → 直接 raise,让 stream_text 的 retry / discard 流程清空气泡 + 通知 response_discarded,按 attempt+1 重试。 未吐文本 → 仍然静默 fallback(用户感知不到失败)。 测试:补 test_offline_no_silent_fallback_after_genai_emitted_text + test_offline_silent_fallback_when_genai_did_not_emit 对偶验证两种路径, 25/25 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): _and_sync 真失败 + 角色隔离 + not_support 关键词补齐 回应 PR #1035 CodeRabbit 第 8 轮 review 3 条新 actionable: 1. core.py _sync_tools_to_active_session 加 raise_on_failure=True 选项, register_tool_and_sync / unregister_tool_and_sync / clear_tools_and_sync 全部带这个参数。session.update 真在 wire 上失败时把异常往上抛, 避免 HTTP /api/tools 回 ok=true 假成功(之前同步失败只 log warning, caller 看不到)。 2. main_routers/tool_router.py /api/tools/unregister 与 /clear 加单角色 失败隔离:try/except 单角色 sync 异常,收集到 failed_roles,已成功 的 role 进 affected_roles,跨角色调用不再因为某个 role 抛异常就 整体 500(与已对偶 register 端点一致)。 3. main_logic/omni_offline_client.py "tools 永久不支持"判定关键词补 "not_support"(下划线变体)。原本只覆盖 "not support"(带空格), API 错误消息出现 "function_call_not_support" 这种下划线写法时会被 误判 transient,导致每轮先撞 genai 再回退的额外抖动。 (第 7 轮在 inline 上重复发的 "已有文本输出后别静默 fallback" 已在 7cc67f1 修过,这里不重复。) 测试:补 - test_register_tool_and_sync_propagates_session_update_failure - test_unregister_tool_router_isolates_per_role_failures - test_genai_unsupported_keyword_matches_underscore_variant 28/28 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): stream_text 已吐文本后失败也要 notify discarded 清气泡 回应 PR #1035 CodeRabbit 第 9 轮新增 inline (614): omni_offline_client.py stream_text 通用 except Exception 分支之前只 上报 TEXT_GEN_ERROR 状态消息就 break,没调用 _notify_response_discarded。 这意味着第 7 轮 (7cc67f1) 引入的 "_astream_with_tools 已吐文本后 raise" 新契约其实没被上层真正接住——前端那截半截 Gemini 文本气泡永远停在那。 修:通用 except Exception 分支识别 ``assistant_message`` 已被填过的 场景(说明已经吐过文本给前端),调 _notify_response_discarded( reason="text_gen_error:<type>", will_retry=False, message= '{"code":"TEXT_GEN_ERROR_AFTER_PARTIAL", ...}')让前端清空气泡。 和 (APIConnectionError 等) 分支的"吐过文本就 notify discarded"语义对偶, 区别在 will_retry=False(通用 except 不再重试)。 测试:补 test_stream_text_notifies_discarded_when_partial_text_then_error 模拟 _astream_with_tools 吐文本后 raise,验证 stream_text 调 _notify_response_discarded 而不是只上报 TEXT_GEN_ERROR。29/29 通过。 (本轮另外 3 条都是第 8 轮已修问题的 inline 重发,6ee28120 已处理。) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): OpenAI-compat 路径同 turn text+tool_calls 历史也保留 text 回应 PR #1035 CodeRabbit 第 10 轮 inline 530: omni_offline_client.py _execute_and_append_openai_tool_calls 加 assistant_text 参数;_astream_openai_with_tools 累积 streamed_text_buffer 并传给 helper。 和 Gemini 路径完全对偶——某些 OpenAI-compat provider(GLM-text / Qwen-text 等)会在同一 assistant turn 里"先吐文字再进 tool_calls"。原来 content 固定 "" 会让下一轮 LLM 看不到自己已说过的前半句,模型重复 / 改口。 测试补 test_offline_openai_path_persists_streamed_text_with_tool_calls, 30/30 通过。 (本轮另外 2 条 duplicate (core.py 串行 await sync / Gemini 历史 text part) 都是更早 commit 已修过的,4fd4b633 已处理。) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): GenAI 路径与 OpenAI 路径对偶补齐空 name function_call 防御 回应 PR #1035 CodeRabbit 第 11 轮 inline 791: omni_offline_client.py _astream_genai_with_tools 在收集 function_call 时 检查 name 非空。OpenAI 路径已在 collect_tool_calls 出口 + _execute_and_ append_openai_tool_calls 入口做了双重防御,GenAI 路径之前直接信任 fn_call.name 会让空字符串传到 on_tool_call,并把无效 tool_calls 写回 messages,下一轮 generate_content_stream 收到无名 function_call 直接 schema reject。drop + warning 与 OpenAI 路径完全对偶。 测试:补 test_offline_genai_path_drops_empty_name_function_calls,模拟 同 turn 里一个有效 + 一个空 name 的 function_call,断言 handler 只被 有效那个调用、写回历史也只有有效那个。31/31 通过。 (本轮另外 30 多条 inline 都是 CodeRabbit 把历史 review 一次性重发的 ack,对应 commits c115aa4..6ee2812 已修过,无新动作。) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): set_tools 重置 unsupported 旗标 + stream_text 防 pre-tool 文本双写 回应 PR #1035 CodeRabbit 第 12 轮 2 条 inline: 1. omni_offline_client.py set_tools 顺手清掉 _genai_tools_unsupported。 原本这个旗标因旧工具集触发 GenerateContentConfig rejected / unsupported 异常被翻 True 后永远停留,热卸载坏 schema 工具也救不回 native genai 路径, 只能等下次 connect()/switch_model() 重置。set_tools 既然是工具列表 的"换血"入口,应该一并给 genai 一次重新尝试的机会。 2. omni_offline_client.py stream_text + _astream_*_with_tools 修复 pre-tool 文本双写 history。原结构里 _astream_*_with_tools 在 tool 轮 结束时已经 inline 把 (assistant{content: pre-tool 文本, tool_calls}, tool result) 写进 _conversation_history,但 stream_text 同时把整段 累积的 assistant_message(pre-tool + post-tool)作为 final AIMessage 再 append 一次——pre-tool 文本被双写。 修法:utils/llm_client.py LLMStreamChunk 加 tool_round_persisted sentinel 字段,两个 _astream_*_with_tools 在持久化 tool 轮后 yield LLMStreamChunk(content="", tool_round_persisted=True)。stream_text 收到 sentinel 时把 final-segment buffer (assistant_message) 清掉, pipe_count / prefix_buffer 也跟着重置(下一段是新语义单元)。 同时新增 assistant_message_total 累计完整一轮文本,长度 guard 与 _check_repetition 仍看 total,与人类用户感知的"这一轮 AI 说了什么" 一致。final AIMessage append 只用 final-segment。 测试:补 - test_set_tools_resets_genai_unsupported_flag - test_stream_text_does_not_double_write_pretool_text 33/33 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): stream_text 整轮级判定统一切到 assistant_message_total 回应 PR #1035 CodeRabbit 第 13 轮 inline 1159: 70cf761 引入 sentinel 后 assistant_message 语义改成 "post-tool final-segment buffer",但还有 6 处"整轮级"分支仍看它,导致 tool 轮已 yield pre-tool 文本时这些分支会误判"本轮没出过文本"。统一切到 assistant_message_total: - flush 块的长度 guard(count_tokens)—— 与主累加块对偶 - recovery 路径的 _is_gibberish_response / truncate_to_tokens / 日志 - retry 循环外的 "本轮成功完成" break 判定(避免 max_tool_iterations 耗尽时只剩 pre-tool 但被错误重试) - (APIConnectionError 等) retry 分支的 "已吐文本要 notify_discarded 清气泡" 判定(pre-tool 残留前端时漏触发会让半截气泡停在那) - 通用 except Exception 分支的同一处判定 - finally 末 LLM_NO_RESPONSE 判定(tool 轮跑完但模型没出 final 文本 时不应该报"无回复") 附带:APIConnectionError retry 分支同步重置 assistant_message_total (之前只重置了 final-segment)。stream_text 入口与函数顶部都把两个 buffer 一并初始化,避免 finally 看到 NameError。 final AIMessage append (1407-1412) 仍用 final-segment——这是 sentinel 设计的核心:pre-tool 已经在 assistant.tool_calls.content 持久化,final AIMessage 不再双写。 测试:33/33 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plugins): 新增 LLM 工具调用注册教学(中文) 针对 PR #1035 引入的 /api/tools/* 端点,给插件作者写完整的注册指南: - 架构与端点 schema (register / unregister / clear / list) - callback_url 协议(请求体 / 响应体格式 + ToolResult 字段) - 完整 lifecycle pattern(启动重试 + shutdown unregister) - main_server 重启时的工具丢失风险(registry 是内存对象) - 切换猫娘的 role 字段语义 - 同进程注册(绕过 HTTP)的高级用法 - 注意事项(敏感信息、本机限制、timeout 上限、错误返回约定等) 放在 docs/zh-CN/plugins/tool-calling.md,并在 plugins/index.md 加目录链接。 英文 / 日文翻译留待后续 i18n 同步 PR。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(tools): callback_url host 白名单防 SSRF + 修文档措辞 回应 PR #1035 CodeRabbit 第 14 轮 inline (tool-calling.md:238): 文档原措辞"callback_url 必须是 127.0.0.1/localhost 由 verify_local_access 守门"是错的——后者只管"谁能调注册端点",不校验 callback_url 值本身。 本地 caller 完全可以注册一个公网 callback_url 把 main_server 当 SSRF 出站代理对外发 LLM 工具调用 payload(含用户对话内容、模型 args)。 加 host 白名单: - main_routers/tool_router.py 新增 _validate_local_callback_url 函数, ToolRegisterRequest.callback_url 用 field_validator 调它做校验。 实现:urlparse 拆 scheme/host,scheme 限 http(s),host 用 ipaddress.ip_address 检 is_loopback(覆盖 127.0.0.0/8、::1、 IPv4-mapped IPv6 等),同时认 localhost 字面量。非 loopback / 非 IP / 错误 scheme 都直接 422。 - docs/zh-CN/plugins/tool-calling.md 改成"两道独立闸门"措辞,把 verify_local_access 与 callback_url 白名单的语义分开说清楚。 测试:补 test_tool_register_request_rejects_non_loopback_callback_url, 覆盖合法 (127.0.0.x / localhost / ::1 / https) 和非法(公网 IP / 局域网 IP / 私有域名 / 错误 scheme / 缺 host / 公网 IPv6)case。34/34 通过。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plugins): 补 tool-calling 教学的英文 / 日文版本 f9a0ae3 只加了中文版,英文 / 日文用户读到 plugins/index.md 不会看到 这条目录链接、单独路径下也是 404。补齐 docs/plugins/tool-calling.md (英文)和 docs/ja/plugins/tool-calling.md(日文),结构一致:架构图、 端点 schema、callback_url 协议、完整 lifecycle pattern、main_server 重启行为、role 字段语义、同进程注册、注意事项(含两道独立闸门 SSRF 防护说明)。两个语言的 plugins/index.md 也一并加目录链接。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(plugins): 修 tool-calling 教学的 3 处接口描述错误 回应 PR #1035 CodeRabbit 第 15 轮 inline 反馈,3 处都成立,3 个语言版本 (zh-CN / en / ja)一并修: 1. /api/tools/clear 文档原写"source 为空时清空全部"——错。 ToolClearRequest.source = Field(..., min_length=1) 是必填,空值会被 422 拒绝;"全部清空"语义只在内部 mgr.clear_tools(source=None) 才有。 改成明确"source 必填,需要全清要走内部 API 或按 source 逐个 clear"。 2. callback_url 响应体 output 字段映射写反——_remote_dispatch 实际是 body.get("output", body),含 "output" key 时取值,否则取整 body。 原文档说"main_server 把整个 body 作 ToolResult.output"会让插件作者 把 is_error/error 跟实际结果同层平铺,结果模型看不到 metadata。改 成"始终用 {"output": ...} 包一层" + 解释抽取规则。 3. 同进程注册示例里 ToolDefinition(source="...") 不存在这个参数—— ToolDefinition 只有 name/description/parameters/handler/metadata 5 个 字段,source 标签按 dataclass docstring 应放进 metadata={"source": "..."}。照原示例 copy 会直接 TypeError。修示例和注释。 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 db7d671 commit db23883

14 files changed

Lines changed: 4495 additions & 65 deletions

File tree

docs/ja/plugins/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,5 @@ plugin/plugins/
8080
- [デコレーター](./decorators) — 利用可能なすべてのデコレーター
8181
- [サンプル](./examples) — 完全に動作するサンプル
8282
- [応用トピック](./advanced) — Extension、Adapter、プラグイン間呼び出し、フック
83+
- [LLM ツール呼び出し](./tool-calling) — LLM が会話中に呼び出せるツールをプラグインから登録する
8384
- [ベストプラクティス](./best-practices) — エラーハンドリング、テスト、コード構成

docs/ja/plugins/tool-calling.md

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
# LLM ツール呼び出し(Tool Calling)の登録
2+
3+
LLM が会話中にプラグインの機能を「呼び出せる」ようにします。例えば
4+
プラグインが `get_weather` を提供している場合、ユーザーが「東京の
5+
天気は?」と聞いたときに LLM が自動的に呼び出し、結果を待って最終
6+
応答を生成します。
7+
8+
このメカニズムは `main_logic/tool_calling.py``ToolRegistry`
9+
よって支えられ、ツール呼び出しをサポートする全ての provider
10+
(OpenAI / Gemini / GLM / Qwen Omni / StepFun など)に対して統一的に
11+
抽象化されています。
12+
13+
## アーキテクチャ
14+
15+
```
16+
┌──────────────────┐ HTTP /api/tools/register ┌──────────────────────┐
17+
│ Plugin (process)│ ───────────────────────────▶│ Main Server │
18+
│ │ │ - ToolRegistry │
19+
│ callback_url │ ◀──── HTTP POST tool ──────│ - Realtime / Offline│
20+
│ /tool_invoke │ call invocation │ LLM clients │
21+
└──────────────────┘ └──────────────────────┘
22+
```
23+
24+
- プラグインは **HTTP でツールを登録** します
25+
`LLMSessionManager.tool_registry` に格納)
26+
- LLM がツール呼び出しを発火すると、main_server が **プラグインの
27+
`callback_url` に POST**
28+
- プラグインが JSON 結果を返し、main_server が LLM にフィードバック
29+
して生成を続行
30+
31+
## 登録エンドポイント
32+
33+
すべてのエンドポイントは `MAIN_SERVER_PORT`(デフォルト `48911`)に
34+
マウントされ、`verify_local_access``127.0.0.1` / `::1` /
35+
`localhost` のみ許可します。
36+
37+
### `POST /api/tools/register`
38+
39+
```json
40+
{
41+
"name": "get_weather",
42+
"description": "指定都市の天気を検索する",
43+
"parameters": {
44+
"type": "object",
45+
"properties": {
46+
"city": {"type": "string", "description": "都市名(例: '東京')"}
47+
},
48+
"required": ["city"]
49+
},
50+
"callback_url": "http://127.0.0.1:<plugin_port>/tool_invoke",
51+
"role": null,
52+
"source": "my_plugin",
53+
"timeout_seconds": 30
54+
}
55+
```
56+
57+
| フィールド | 説明 |
58+
|---|---|
59+
| `name` | ツール名(≤64 文字)。LLM が見るのはこの名前 |
60+
| `description` | LLM 向けの説明。いつ呼ぶかを決定する |
61+
| `parameters` | JSON Schema(OpenAI スタイル) |
62+
| `callback_url` | LLM が呼び出したとき main_server が POST する先 |
63+
| `role` | `null` = 全猫娘に登録 / 名前指定 = 特定猫娘のみ |
64+
| `source` | カスタム送信元タグ。後でまとめて `clear` するときに便利 |
65+
| `timeout_seconds` | 1 回の呼び出しタイムアウト(≤300、デフォルト 30) |
66+
67+
レスポンス:
68+
69+
```json
70+
{ "ok": true, "registered": "get_weather", "affected_roles": ["小八"], "failed_roles": [] }
71+
```
72+
73+
`affected_roles` が空のとき `ok=false` となり、`failed_roles[*].error`
74+
に詳細が入ります。
75+
76+
### `POST /api/tools/unregister`
77+
78+
```json
79+
{ "name": "get_weather", "role": null }
80+
```
81+
82+
### `POST /api/tools/clear`
83+
84+
```json
85+
{ "role": null, "source": "my_plugin" }
86+
```
87+
88+
`source`**必須**(≥1 文字)。HTTP エンドポイントは source 指定での
89+
クリアのみサポート、空値は 422 で拒否されます。「全部クリア」が
90+
必要な場合は source ごとに繰り返すか、インプロセスの
91+
`mgr.clear_tools()``source=None` 可)を直接呼んでください。
92+
93+
### `GET /api/tools[?role=<name>]`
94+
95+
現在登録されているツールリストを返します。
96+
97+
## callback_url プロトコル
98+
99+
LLM がツール呼び出しを発火すると、main_server が `callback_url`
100+
`POST` します:
101+
102+
**リクエストボディ**
103+
104+
```json
105+
{
106+
"name": "get_weather",
107+
"arguments": {"city": "東京"},
108+
"call_id": "call_abc123",
109+
"raw_arguments": "{\"city\":\"東京\"}"
110+
}
111+
```
112+
113+
`arguments` は JSON パース済み dict、`raw_arguments` は元の文字列
114+
(LLM が無効な JSON を生成したまれな場合に使えます)。
115+
116+
**レスポンスボディ**
117+
118+
```json
119+
{ "output": {"temp_c": 22, "weather": "晴れ"}, "is_error": false }
120+
```
121+
122+
または失敗時:
123+
124+
```json
125+
{ "output": null, "is_error": true, "error": "city not found" }
126+
```
127+
128+
**`output` 抽出ルール**:main_server は `body.get("output", body)`
129+
呼び出します — レスポンスボディに `output` キーがあればその値を LLM
130+
に渡し、なければボディ全体を output として扱います。**常に
131+
`{"output": ...}` で明示的にラップ** することを推奨します。そうしない
132+
`is_error` / `error` などのメタデータが実結果と同列に並び、モデル
133+
が混乱します。
134+
135+
`output` 自体は任意の JSON(dict / list / 文字列 / 数値)が可能。
136+
`is_error: true` の場合、LLM は呼び出し失敗を認識してスキップしたり
137+
別のツールを選んだりします。
138+
139+
`callback_url``127.0.0.1:<plugin_port>` 上の任意のパスで OK。
140+
プラグインは自前で HTTP server を立てて受信します。
141+
142+
## 完全なライフサイクル例
143+
144+
```python
145+
import asyncio
146+
import httpx
147+
148+
MAIN_SERVER = "http://127.0.0.1:48911"
149+
MY_PORT = 9876
150+
TOOL_NAME = "get_weather"
151+
152+
async def register_with_retry():
153+
"""起動時に呼ぶ:main_server が立ち上がるまで無限リトライで登録。"""
154+
payload = {
155+
"name": TOOL_NAME,
156+
"description": "指定都市の天気を検索する",
157+
"parameters": {
158+
"type": "object",
159+
"properties": {"city": {"type": "string"}},
160+
"required": ["city"],
161+
},
162+
"callback_url": f"http://127.0.0.1:{MY_PORT}/tool_invoke",
163+
"role": None,
164+
"source": "my_plugin",
165+
"timeout_seconds": 30,
166+
}
167+
async with httpx.AsyncClient() as client:
168+
while True:
169+
try:
170+
r = await client.post(f"{MAIN_SERVER}/api/tools/register",
171+
json=payload, timeout=5)
172+
if r.json().get("ok"):
173+
return
174+
except (httpx.ConnectError, httpx.TimeoutException):
175+
pass # main_server がまだ起動していない、後で再試行
176+
await asyncio.sleep(2)
177+
178+
async def unregister_on_shutdown():
179+
"""終了前に呼ぶ:ツールを取り消し、LLM が死んだ callback_url に当たらないように。"""
180+
try:
181+
async with httpx.AsyncClient(timeout=2) as client:
182+
await client.post(f"{MAIN_SERVER}/api/tools/unregister",
183+
json={"name": TOOL_NAME, "role": None})
184+
except Exception:
185+
pass # main_server も死んでいるなら諦める
186+
```
187+
188+
プラグインのライフサイクルフックにバインド:
189+
190+
```python
191+
from plugin.sdk.plugin import NekoPluginBase, plugin
192+
193+
@plugin
194+
class WeatherPlugin(NekoPluginBase):
195+
async def on_start(self):
196+
# 非同期で登録(プラグイン起動メインフローをブロックしない)
197+
asyncio.create_task(register_with_retry())
198+
# コールバック受信用の HTTP server も起動(FastAPI / aiohttp など)
199+
...
200+
201+
async def on_shutdown(self):
202+
await unregister_on_shutdown()
203+
```
204+
205+
## main_server 再起動時の挙動
206+
207+
⚠️ **重要**`tool_registry``LLMSessionManager` のインメモリ属性
208+
なので、**main_server 再起動で全部失われます**。プラグイン側で対応が
209+
必要:
210+
211+
- **プラグインが main_server より長生き**(より一般的):プラグインは
212+
ハートビート / 接続切断を監視し、復旧後に **再登録** する必要があり
213+
ます。最も簡単な方法はバックグラウンドタスクで定期的に
214+
`GET /api/tools?role=...` を確認し、無ければ再登録すること
215+
- **プラグインが main_server と運命共同体**:プラグイン起動フックで
216+
`register_with_retry` を呼んでいれば、main_server 再起動時に
217+
プラグインも再起動するので、自動的に再登録される
218+
219+
## 猫娘切り替え
220+
221+
各猫娘は独立した `LLMSessionManager` インスタンスを持ちますが、
222+
プラグインで登録されたツールは(`role` フィールドにより)共有可能:
223+
224+
- `role: null` で全猫娘に登録 → 切り替え時に再登録不要
225+
- `role: "小八"` で特定猫娘のみ登録 → 別の猫娘ではそのツールは使えず、
226+
別途その猫娘にも登録する必要あり
227+
228+
猫娘切り替えは main_server を **再起動しません** ので、registry は
229+
保持されます。
230+
231+
## 同プロセス登録(高度な使い方)
232+
233+
プラグインが同じ Python プロセスで動く場合(extension モード、
234+
組み込み機能など)は、HTTP をバイパスして `LLMSessionManager.register_tool(...)`
235+
を直接呼び、`handler` をローカルの callable にできます:
236+
237+
```python
238+
from main_logic.tool_calling import ToolDefinition
239+
240+
async def handle_get_weather(args: dict) -> dict:
241+
return {"temp_c": 22, "weather": "晴れ"}
242+
243+
mgr.register_tool(ToolDefinition(
244+
name="get_weather",
245+
description="指定都市の天気を検索する",
246+
parameters={...},
247+
handler=handle_get_weather, # in-process callable
248+
metadata={"source": "my_extension"}, # source タグは metadata へ
249+
))
250+
```
251+
252+
wire 同期完了まで `await` したい場合は
253+
`await mgr.register_tool_and_sync(...)` を使用。
254+
255+
## 注意事項
256+
257+
- **ツール名に機密情報を含めない**:LLM が `tool_calls` にツール名を
258+
書き込み、最終的に会話履歴に永続化されます
259+
- **`callback_url` はローカル loopback でなければならない**:サーバー
260+
`urlparse` + `ipaddress.ip_address` で host が `127.0.0.0/8` /
261+
`::1` / 字面 `localhost` の範囲内かを検証し、それ以外は 422 で
262+
拒否します。これは **2 つの独立したゲート**
263+
- `verify_local_access` は誰が `/api/tools/register` を呼べるかを
264+
制限(呼び出し元)
265+
- `callback_url` host ホワイトリストは登録される callback アドレス
266+
を制限(ローカル caller が main_server を SSRF 出口プロキシとして
267+
悪用するのを防ぐ)
268+
クロスホストの正当ユースケースは独立した reverse proxy + 明示的
269+
認可フローを通すべき
270+
- **`timeout_seconds ≤ 300`**:5 分を超える同期ツールは「即座に返し、
271+
プラグイン独自のイベント機構で非同期にプッシュ」に再設計すべき。
272+
そうしないと会話全体が固まります
273+
- **失敗時は明確なエラーを返す**`is_error: true` + 人間可読な
274+
`error` で LLM に状況を伝えること。空結果を黙って返すと LLM が
275+
混乱します
276+
- **重複 `register` は上書き意味論**:同名ツールは新しいもので
277+
上書きされ、パラメータ schema のホット更新に使えます

docs/plugins/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,5 @@ plugin/plugins/
8585
- [Hosted UI](./hosted-ui) — Build TSX panels and Markdown guides
8686
- [Examples](./examples) — Complete working examples
8787
- [Advanced Topics](./advanced) — Extensions, Adapters, cross-plugin calls, hooks
88+
- [LLM Tool Calling](./tool-calling) — Register plugin functions for the LLM to invoke during conversations
8889
- [Best Practices](./best-practices) — Error handling, testing, code organization

0 commit comments

Comments
 (0)