diff --git a/AGENTS.md b/AGENTS.md index f8f2008e8..7e30e7169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,9 @@ - 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin. - 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks. - 2026-03-13: Legacy package-name compatibility now has an explicit contract: keep `src-new/astrbot` as a controlled facade for old `astrbot.api.*` and selected `astrbot.core.*` paths, not a wholesale copy of the old application tree. Guard that facade with the checked-in import matrix and the external plugin matrix in `tests_v4/external_plugin_matrix.json`; do not claim compat from `load_plugin()` alone. +- 2026-03-13: Legacy AI compat methods must return `astrbot_sdk.api.provider.entities.LLMResponse`, not the v4 `clients.llm.LLMResponse`. Old plugins inspect compat fields like `completion_text`, `tools_call_name`, and `to_openai_tool_calls()`, so returning the new client model is a silent behavior regression. +- 2026-03-13: `filter.llm_tool()` must resolve deferred annotations from `from __future__ import annotations` when inferring JSON schema. Reading `inspect.Parameter.annotation` directly degrades typed params like `a: int` into `"int"` strings and silently turns tool argument schemas into generic strings. +- 2026-03-13: Legacy result hooks must reuse the same `AstrMessageEvent` instance across `on_decorating_result` and `after_message_sent`. Re-wrapping the original v4 `MessageEvent` for the second hook drops decorated `event.set_result(...)` mutations and makes post-send hooks observe an empty result. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 30807731f..6910973fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,9 @@ - 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin. - 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks. - 2026-03-13: Treat `src-new/astrbot` as a controlled legacy facade, not as a mirror of the old `astrBot/` tree. The compat contract is the checked-in public import matrix plus the external plugin matrix in `tests_v4/external_plugin_matrix.json`; when a new deep-path shim is proposed, require both an import assertion and a real supervisor/worker plugin case before growing the facade. +- 2026-03-13: Legacy AI compat methods must return `astrbot_sdk.api.provider.entities.LLMResponse`, not the v4 `clients.llm.LLMResponse`. Old plugins inspect compat fields like `completion_text`, `tools_call_name`, and `to_openai_tool_calls()`, so returning the new client model is a silent behavior regression. +- 2026-03-13: `filter.llm_tool()` must resolve deferred annotations from `from __future__ import annotations` when inferring JSON schema. Reading `inspect.Parameter.annotation` directly degrades typed params like `a: int` into `"int"` strings and silently turns tool argument schemas into generic strings. +- 2026-03-13: Legacy result hooks must reuse the same `AstrMessageEvent` instance across `on_decorating_result` and `after_message_sent`. Re-wrapping the original v4 `MessageEvent` for the second hook drops decorated `event.set_result(...)` mutations and makes post-send hooks observe an empty result. # 开发命令 diff --git a/src-new/astrbot_sdk/_legacy_api.py b/src-new/astrbot_sdk/_legacy_api.py index b1bf66f07..6c6a56d02 100644 --- a/src-new/astrbot_sdk/_legacy_api.py +++ b/src-new/astrbot_sdk/_legacy_api.py @@ -7,15 +7,19 @@ from __future__ import annotations +import ast import inspect +import json from collections import defaultdict from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import Any from loguru import logger -from .clients.llm import LLMResponse +from .api.basic.astrbot_config import AstrBotConfig +from .api.provider.entities import LLMResponse from .context import Context as NewContext from .star import Star @@ -57,6 +61,192 @@ def _iter_registered_component_methods( return methods +@dataclass(slots=True) +class _CompatHookEntry: + name: str + priority: int + handler: Callable[..., Any] + + +@dataclass(slots=True) +class _CompatToolSpec: + name: str + description: str + parameters: dict[str, Any] + handler: Callable[..., Any] + active: bool = True + + +@dataclass(slots=True) +class _CompatProviderRequest: + prompt: str | None = None + session_id: str | None = "" + image_urls: list[str] | None = None + contexts: list[dict[str, Any]] | None = None + system_prompt: str = "" + conversation: Any | None = None + tool_calls_result: Any | None = None + model: str | None = None + + +def _tool_parameters_from_legacy_args( + func_args: list[dict[str, Any]], +) -> dict[str, Any]: + parameters: dict[str, Any] = {"type": "object", "properties": {}, "required": []} + for item in func_args: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")) + if not name: + continue + schema = {key: value for key, value in item.items() if key != "name"} + parameters["properties"][name] = schema + parameters["required"].append(name) + return parameters + + +class CompatLLMToolManager: + """旧版 llm tool manager 的最小兼容实现。""" + + def __init__(self) -> None: + self.func_list: list[_CompatToolSpec] = [] + + def add_tool( + self, + *, + name: str, + description: str, + parameters: dict[str, Any], + handler: Callable[..., Any], + ) -> None: + self.remove_func(name) + self.func_list.append( + _CompatToolSpec( + name=name, + description=description, + parameters=parameters, + handler=handler, + ) + ) + + def add_func( + self, + name: str, + func_args: list[dict[str, Any]], + desc: str, + handler: Callable[..., Any], + ) -> None: + self.add_tool( + name=name, + description=desc, + parameters=_tool_parameters_from_legacy_args(func_args), + handler=handler, + ) + + def remove_func(self, name: str) -> None: + self.func_list = [tool for tool in self.func_list if tool.name != name] + + def get_func(self, name: str) -> _CompatToolSpec | None: + for tool in self.func_list: + if tool.name == name: + return tool + return None + + def activate_llm_tool(self, name: str) -> bool: + tool = self.get_func(name) + if tool is None: + return False + tool.active = True + return True + + def deactivate_llm_tool(self, name: str) -> bool: + tool = self.get_func(name) + if tool is None: + return False + tool.active = False + return True + + def get_func_desc_openai_style(self) -> list[dict[str, Any]]: + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + }, + } + for tool in self.func_list + if tool.active + ] + + +def _legacy_tool_calls( + response_payload: dict[str, Any] | None, +) -> tuple[list[dict[str, Any]], list[str], list[str]]: + tool_calls = list((response_payload or {}).get("tool_calls") or []) + tool_args: list[dict[str, Any]] = [] + tool_names: list[str] = [] + tool_ids: list[str] = [] + for tool_call in tool_calls: + if not isinstance(tool_call, dict): + continue + function_payload = tool_call.get("function") + if isinstance(function_payload, dict): + name = str(function_payload.get("name") or "") + raw_arguments = function_payload.get("arguments") + else: + name = str(tool_call.get("name") or "") + raw_arguments = tool_call.get("arguments") + if isinstance(raw_arguments, str): + try: + arguments = json.loads(raw_arguments) + except json.JSONDecodeError: + try: + arguments = ast.literal_eval(raw_arguments) + except (SyntaxError, ValueError): + arguments = {} + elif isinstance(raw_arguments, dict): + arguments = raw_arguments + else: + arguments = {} + if not isinstance(arguments, dict): + arguments = {} + tool_names.append(name) + tool_args.append(arguments) + tool_ids.append(str(tool_call.get("id") or f"tool-{len(tool_ids) + 1}")) + return tool_args, tool_names, tool_ids + + +def _legacy_llm_response(response: Any) -> LLMResponse: + if isinstance(response, LLMResponse): + return response + + model_dump = getattr(response, "model_dump", None) + if callable(model_dump): + payload = model_dump() + elif isinstance(response, dict): + payload = dict(response) + else: + payload = { + "text": getattr(response, "text", ""), + "usage": getattr(response, "usage", None), + "finish_reason": getattr(response, "finish_reason", None), + "tool_calls": getattr(response, "tool_calls", []), + } + + tool_args, tool_names, tool_ids = _legacy_tool_calls(payload) + return LLMResponse( + role=str(payload.get("role") or "assistant"), + completion_text=str(payload.get("text") or ""), + tools_call_args=tool_args, + tools_call_name=tool_names, + tools_call_ids=tool_ids, + raw_completion=response, + _new_record=payload, + ) + + class LegacyConversationManager: """旧版会话管理器的兼容实现。 @@ -365,20 +555,50 @@ class LegacyConversationManager: ) async def get_filtered_conversations(self, *args: Any, **kwargs: Any) -> Any: - """已弃用:v4 不支持此方法。""" - raise NotImplementedError( - "get_filtered_conversations() 在 v4 中不再支持。\n" - f"请使用 ctx.db.query(...) 自行实现过滤逻辑。\n" - f"迁移文档:{MIGRATION_DOC_URL}" + """兼容旧版会话过滤接口。""" + unified_msg_origin = kwargs.get("unified_msg_origin") + platform_id = kwargs.get("platform_id") + keyword = kwargs.get("keyword") or kwargs.get("query") + conversations = await self.get_conversations( + unified_msg_origin=unified_msg_origin, + platform_id=platform_id, ) + if not isinstance(keyword, str) or not keyword: + return conversations + filtered: list[dict[str, Any]] = [] + for conversation in conversations: + haystack = json.dumps(conversation, ensure_ascii=False) + if keyword in haystack: + filtered.append(conversation) + return filtered async def get_human_readable_context(self, *args: Any, **kwargs: Any) -> Any: - """已弃用:v4 不支持此方法。""" - raise NotImplementedError( - "get_human_readable_context() 在 v4 中不再支持。\n" - f"请自行遍历会话 content 字段格式化输出。\n" - f"迁移文档:{MIGRATION_DOC_URL}" + """把兼容会话内容格式化为可读文本。""" + unified_msg_origin = kwargs.get("unified_msg_origin") + conversation_id = kwargs.get("conversation_id") + if conversation_id is None and isinstance(unified_msg_origin, str): + conversation_id = await self.get_curr_conversation_id(unified_msg_origin) + if not isinstance(conversation_id, str) or not conversation_id: + return "" + conversation = await self.get_conversation( + unified_msg_origin or "", + conversation_id, + create_if_not_exists=False, ) + if not isinstance(conversation, dict): + return "" + lines: list[str] = [] + for item in conversation.get("content", []): + if not isinstance(item, dict): + continue + role = str(item.get("role") or "unknown") + content = item.get("content") + if isinstance(content, list): + rendered = json.dumps(content, ensure_ascii=False) + else: + rendered = str(content or "") + lines.append(f"{role}: {rendered}".rstrip()) + return "\n".join(lines) class LegacyContext: @@ -389,6 +609,8 @@ class LegacyContext: self._runtime_context: NewContext | None = None self._registered_managers: dict[str, Any] = {} self._registered_functions: dict[str, Callable[..., Any]] = {} + self._compat_hooks: defaultdict[str, list[_CompatHookEntry]] = defaultdict(list) + self._llm_tools = CompatLLMToolManager() self.conversation_manager = LegacyConversationManager(self) self._register_component(self.conversation_manager) @@ -400,6 +622,27 @@ class LegacyContext: raise RuntimeError("LegacyContext 尚未绑定运行时 Context") return self._runtime_context + def get_llm_tool_manager(self) -> CompatLLMToolManager: + return self._llm_tools + + def activate_llm_tool(self, name: str) -> bool: + return self._llm_tools.activate_llm_tool(name) + + def deactivate_llm_tool(self, name: str) -> bool: + return self._llm_tools.deactivate_llm_tool(name) + + def register_llm_tool( + self, + name: str, + func_args: list[dict[str, Any]], + desc: str, + func_obj: Callable[..., Any], + ) -> None: + self._llm_tools.add_func(name, func_args, desc, func_obj) + + def unregister_llm_tool(self, name: str) -> None: + self._llm_tools.remove_func(name) + def get_config(self) -> dict[str, Any]: runtime_context = self._runtime_context if runtime_context is None: @@ -407,6 +650,19 @@ class LegacyContext: config = getattr(runtime_context, "_astrbot_config", None) return dict(config) if isinstance(config, dict) else {} + def _runtime_config(self) -> Any: + runtime_context = self._runtime_context + config = ( + getattr(runtime_context, "_astrbot_config", None) + if runtime_context + else None + ) + if isinstance(config, AstrBotConfig): + return config + if isinstance(config, dict): + return AstrBotConfig(dict(config)) + return AstrBotConfig({}) + @staticmethod def _merge_llm_kwargs( *, @@ -418,6 +674,16 @@ class LegacyContext: merged.setdefault("provider_id", chat_provider_id) return merged + @staticmethod + def _apply_request_overrides( + call_kwargs: dict[str, Any], + request: _CompatProviderRequest, + ) -> dict[str, Any]: + updated = dict(call_kwargs) + if request.model: + updated["model"] = request.model + return updated + @staticmethod def _component_names(component: Any) -> list[str]: names = [component.__class__.__name__] @@ -426,6 +692,216 @@ class LegacyContext: names.insert(0, compat_name) return names + def _register_hook( + self, + name: str, + handler: Callable[..., Any], + *, + priority: int = 0, + ) -> None: + self._compat_hooks[name].append( + _CompatHookEntry(name=name, priority=priority, handler=handler) + ) + self._compat_hooks[name].sort(key=lambda item: item.priority, reverse=True) + + def _register_compat_component(self, component: Any) -> None: + from .api.event.filter import ( + get_compat_hook_metas, + get_compat_llm_tool_meta, + ) + + for _attr_name, attr in _iter_registered_component_methods(component): + tool_meta = get_compat_llm_tool_meta(attr) + if tool_meta is not None: + self._llm_tools.add_tool( + name=tool_meta.name, + description=tool_meta.description, + parameters=_tool_parameters_from_legacy_args(tool_meta.parameters), + handler=attr, + ) + for hook_meta in get_compat_hook_metas(attr): + self._register_hook( + hook_meta.name, + attr, + priority=hook_meta.priority, + ) + + @staticmethod + def _legacy_event(event: Any | None): + if event is None: + return None + from .api.event import AstrMessageEvent + + if isinstance(event, AstrMessageEvent): + return event + return AstrMessageEvent.from_message_event(event) + + @staticmethod + def _hook_type_injection( + annotation: Any, + available: dict[str, Any], + ) -> Any: + from .api.event import AstrMessageEvent + from .context import Context as RuntimeContext + + if annotation is Any or annotation is inspect.Signature.empty: + return None + if annotation is AstrMessageEvent: + return available.get("event") + if annotation is RuntimeContext or annotation is NewContext: + return available.get("context") + if annotation is LegacyContext: + return available.get("legacy_context") + if annotation is LLMResponse: + return available.get("response") + return None + + async def _call_with_available( + self, + handler: Callable[..., Any], + available: dict[str, Any], + ) -> Any: + signature = inspect.signature(handler) + args: list[Any] = [] + kwargs: dict[str, Any] = {} + for parameter in signature.parameters.values(): + injected = None + if parameter.name in available: + injected = available[parameter.name] + else: + injected = self._hook_type_injection(parameter.annotation, available) + if injected is None: + if parameter.default is not parameter.empty: + continue + continue + if parameter.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + args.append(injected) + elif parameter.kind == inspect.Parameter.KEYWORD_ONLY: + kwargs[parameter.name] = injected + result = handler(*args, **kwargs) + if inspect.isasyncgen(result): + final_value = None + async for item in result: + final_value = item + await self._consume_tool_result( + available.get("event"), + available.get("context"), + item, + ) + return final_value + if inspect.isawaitable(result): + return await result + return result + + async def _run_compat_hook( + self, + name: str, + **available: Any, + ) -> list[Any]: + hook_results: list[Any] = [] + for entry in self._compat_hooks.get(name, []): + hook_results.append( + await self._call_with_available(entry.handler, available) + ) + return hook_results + + async def _consume_tool_result( + self, + event: Any | None, + runtime_context: NewContext | None, + item: Any, + ) -> None: + if event is None: + return + from .api.event.event_result import MessageEventResult + from .api.message.chain import MessageChain + + legacy_event = self._legacy_event(event) + if legacy_event is None: + return + if isinstance(item, MessageEventResult): + if ( + item.chain + and runtime_context is not None + and not item.is_plain_text_only() + ): + await runtime_context.platform.send_chain( + legacy_event.session_ref or legacy_event.session_id, + item.to_payload(), + ) + return + plain_text = item.get_plain_text() + if plain_text: + await legacy_event.reply(plain_text) + return + if isinstance(item, MessageChain): + if ( + item.chain + and runtime_context is not None + and not item.is_plain_text_only() + ): + await runtime_context.platform.send_chain( + legacy_event.session_ref or legacy_event.session_id, + item.to_payload(), + ) + return + plain_text = item.get_plain_text() + if plain_text: + await legacy_event.reply(plain_text) + return + if isinstance(item, str): + await legacy_event.reply(item) + + async def _invoke_llm_tool( + self, + *, + tool_name: str, + tool_args: dict[str, Any], + event: Any | None, + ) -> str: + tool = self._llm_tools.get_func(tool_name) + if tool is None or not tool.active: + return f"tool '{tool_name}' not found" + legacy_event = self._legacy_event(event) + runtime_context = self.require_runtime_context() + await self._run_compat_hook( + "on_using_llm_tool", + event=legacy_event, + context=runtime_context, + legacy_context=self, + tool=tool, + tool_args=tool_args, + ) + tool_result = await self._call_with_available( + tool.handler, + { + **tool_args, + "event": legacy_event, + "context": runtime_context, + "ctx": runtime_context, + "legacy_context": self, + }, + ) + if isinstance(tool_result, str): + normalized = tool_result + elif tool_result is None: + normalized = "" + else: + normalized = str(tool_result) + await self._run_compat_hook( + "on_llm_tool_respond", + event=legacy_event, + context=runtime_context, + legacy_context=self, + tool=tool, + tool_args=tool_args, + tool_result=normalized, + ) + return normalized + def _register_component(self, *components: Any) -> None: """保留旧版按名称暴露组件方法的兼容链路。""" for component in components: @@ -433,6 +909,7 @@ class LegacyContext: self._registered_managers[class_name] = component for attr_name, attr in _iter_registered_component_methods(component): self._registered_functions[f"{class_name}.{attr_name}"] = attr + self._register_compat_component(component) async def execute_registered_function( self, @@ -472,6 +949,7 @@ class LegacyContext: tools: Any | None = None, system_prompt: str | None = None, contexts: list[dict] | None = None, + event: Any | None = None, **kwargs: Any, ) -> LLMResponse: _warn_once("context.llm_generate()", "ctx.llm.chat_raw(...)") @@ -480,14 +958,46 @@ class LegacyContext: chat_provider_id=chat_provider_id, kwargs=kwargs, ) - return await ctx.llm.chat_raw( - prompt or "", - system=system_prompt, - history=contexts or [], - image_urls=image_urls or [], + legacy_event = self._legacy_event(event) + request = _CompatProviderRequest( + prompt=prompt or "", + session_id=legacy_event.session_id if legacy_event is not None else "", + image_urls=list(image_urls or []), + contexts=list(contexts or []), + system_prompt=system_prompt or "", + model=call_kwargs.get("model"), + ) + await self._run_compat_hook( + "on_waiting_llm_request", + event=legacy_event, + context=ctx, + legacy_context=self, + ) + await self._run_compat_hook( + "on_llm_request", + event=legacy_event, + context=ctx, + legacy_context=self, + request=request, + ) + call_kwargs = self._apply_request_overrides(call_kwargs, request) + response = await ctx.llm.chat_raw( + request.prompt or "", + system=request.system_prompt or None, + history=request.contexts or [], + image_urls=request.image_urls or [], tools=tools, **call_kwargs, ) + legacy_response = _legacy_llm_response(response) + await self._run_compat_hook( + "on_llm_response", + event=legacy_event, + context=ctx, + legacy_context=self, + response=legacy_response, + ) + return legacy_response async def tool_loop_agent( self, @@ -498,23 +1008,103 @@ class LegacyContext: system_prompt: str | None = None, contexts: list[dict] | None = None, max_steps: int = 30, + event: Any | None = None, **kwargs: Any, ) -> LLMResponse: - _warn_once("context.tool_loop_agent()", "ctx.llm.chat_raw(...)") + _warn_once("context.tool_loop_agent()", "compat local tool loop") ctx = self.require_runtime_context() call_kwargs = self._merge_llm_kwargs( chat_provider_id=chat_provider_id, kwargs=kwargs, ) - return await ctx.llm.chat_raw( - prompt or "", - system=system_prompt, - history=contexts or [], - image_urls=image_urls or [], - tools=tools, - max_steps=max_steps, - **call_kwargs, - ) + legacy_event = self._legacy_event(event) + history = list(contexts or []) + request_prompt = prompt or "" + combined_tools = list(self._llm_tools.get_func_desc_openai_style()) + if isinstance(tools, list): + combined_tools.extend(item for item in tools if isinstance(item, dict)) + elif tools is not None: + openai_schema = getattr(tools, "openai_schema", None) + if callable(openai_schema): + extra_tools = openai_schema() + if isinstance(extra_tools, list): + combined_tools.extend( + item for item in extra_tools if isinstance(item, dict) + ) + + final_response = LLMResponse(role="assistant") + for _step in range(max_steps): + request = _CompatProviderRequest( + prompt=request_prompt, + session_id=legacy_event.session_id if legacy_event is not None else "", + image_urls=list(image_urls or []), + contexts=list(history), + system_prompt=system_prompt or "", + model=call_kwargs.get("model"), + ) + await self._run_compat_hook( + "on_waiting_llm_request", + event=legacy_event, + context=ctx, + legacy_context=self, + ) + await self._run_compat_hook( + "on_llm_request", + event=legacy_event, + context=ctx, + legacy_context=self, + request=request, + ) + call_kwargs = self._apply_request_overrides(call_kwargs, request) + response = await ctx.llm.chat_raw( + request.prompt or "", + system=request.system_prompt or None, + history=request.contexts or [], + image_urls=request.image_urls or [], + tools=combined_tools or None, + max_steps=max_steps, + **call_kwargs, + ) + final_response = _legacy_llm_response(response) + await self._run_compat_hook( + "on_llm_response", + event=legacy_event, + context=ctx, + legacy_context=self, + response=final_response, + ) + if not final_response.tools_call_name: + return final_response + + history.append( + { + "role": "assistant", + "content": final_response.completion_text, + "tool_calls": final_response.to_openai_tool_calls(), + } + ) + for tool_name, tool_args, tool_call_id in zip( + final_response.tools_call_name, + final_response.tools_call_args, + final_response.tools_call_ids, + strict=False, + ): + tool_result = await self._invoke_llm_tool( + tool_name=tool_name, + tool_args=tool_args, + event=legacy_event, + ) + history.append( + { + "role": "tool", + "tool_call_id": tool_call_id, + "name": tool_name, + "content": tool_result, + } + ) + request_prompt = "" + + return final_response async def send_message(self, session: str, message_chain: Any) -> None: _warn_once( @@ -545,12 +1135,27 @@ class LegacyContext: await ctx.platform.send(session, text) async def add_llm_tools(self, *tools: Any) -> None: - # 保留旧签名,让旧插件尽快得到显式迁移提示,而不是悄悄失效。 - raise NotImplementedError( - "context.add_llm_tools() 在 v4 中不再支持。\n" - "请使用 ctx.llm.chat_raw(..., tools=[...]) 直接传递工具。\n" - f"迁移文档:{MIGRATION_DOC_URL}" - ) + for tool in tools: + name = getattr(tool, "name", None) + if not isinstance(name, str) or not name: + raise TypeError("add_llm_tools() 需要带 name 的工具对象") + handler = getattr(tool, "handler", None) + if not callable(handler): + raise TypeError("add_llm_tools() 需要工具对象提供可调用的 handler") + parameters = getattr(tool, "parameters", None) + if not isinstance(parameters, dict): + func_args = getattr(tool, "func_args", None) + if isinstance(func_args, list): + parameters = _tool_parameters_from_legacy_args(func_args) + else: + parameters = {"type": "object", "properties": {}, "required": []} + description = str(getattr(tool, "description", "") or "") + self._llm_tools.add_tool( + name=name, + description=description, + parameters=parameters, + handler=handler, + ) async def put_kv_data(self, key: str, value: Any) -> None: _warn_once("context.put_kv_data()", "ctx.db.set(key, value)") @@ -643,6 +1248,32 @@ class LegacyStar(Star): async def add_llm_tools(self, *tools: Any) -> None: await self._require_legacy_context().add_llm_tools(*tools) + def get_llm_tool_manager(self) -> CompatLLMToolManager: + return self._require_legacy_context().get_llm_tool_manager() + + def activate_llm_tool(self, name: str) -> bool: + return self._require_legacy_context().activate_llm_tool(name) + + def deactivate_llm_tool(self, name: str) -> bool: + return self._require_legacy_context().deactivate_llm_tool(name) + + def register_llm_tool( + self, + name: str, + func_args: list[dict[str, Any]], + desc: str, + func_obj: Callable[..., Any], + ) -> None: + self._require_legacy_context().register_llm_tool( + name, + func_args, + desc, + func_obj, + ) + + def unregister_llm_tool(self, name: str) -> None: + self._require_legacy_context().unregister_llm_tool(name) + def get_config(self) -> dict[str, Any]: return self._require_legacy_context().get_config() diff --git a/src-new/astrbot_sdk/api/event/filter.py b/src-new/astrbot_sdk/api/event/filter.py index f63819195..7e001fa6e 100644 --- a/src-new/astrbot_sdk/api/event/filter.py +++ b/src-new/astrbot_sdk/api/event/filter.py @@ -4,17 +4,21 @@ - ``command(name, alias=..., priority=...)`` -> ``CommandTrigger`` - ``regex(pattern, priority=...)`` -> ``MessageTrigger`` +- ``custom_filter(...)`` -> 记录旧自定义过滤器,运行时在分发前执行 - ``event_message_type(...)`` -> 记录消息类型约束 - ``platform_adapter_type(...)`` -> 记录平台约束 - ``permission(ADMIN)`` / ``permission_type(PermissionType.ADMIN)`` -> ``require_admin`` +- ``after_message_sent`` / ``on_llm_request`` / ``llm_tool`` 等旧 hook + -> 记录 compat 元数据,由 legacy 运行时在可映射链路中执行 -其余旧版高级过滤器和生命周期钩子在 v4 运行时中没有等价执行链路, -兼容层保留名称用于导入兼容,但会在调用时显式报错,避免静默失效。 +其余没有等价执行链路的旧 helper 仍然显式报错,避免静默失效。 """ from __future__ import annotations +import inspect +from dataclasses import dataclass import enum from abc import ABCMeta, abstractmethod from typing import Any @@ -26,6 +30,22 @@ from .astr_message_event import AstrMessageEvent from .message_type import MessageType ADMIN = "admin" +COMPAT_HOOKS_ATTR = "__astrbot_compat_hooks__" +COMPAT_LLM_TOOL_ATTR = "__astrbot_compat_llm_tool__" +COMPAT_CUSTOM_FILTERS_ATTR = "__astrbot_compat_custom_filters__" + + +@dataclass(slots=True) +class CompatHookMeta: + name: str + priority: int = 0 + + +@dataclass(slots=True) +class CompatLLMToolMeta: + name: str + description: str + parameters: list[dict[str, Any]] class PermissionType(enum.Flag): @@ -192,6 +212,131 @@ EVENT_MESSAGE_TYPE_NAMES = { EventMessageType.OTHER_MESSAGE: "other", } +_LLM_TOOL_PARAM_TYPES: dict[type[Any], str] = { + str: "string", + int: "number", + float: "number", + bool: "boolean", + dict: "object", + list: "array", +} + + +def _append_compat_hook(func, name: str, *, priority: int | None = None): + hooks = list(getattr(func, COMPAT_HOOKS_ATTR, ())) + hooks.append(CompatHookMeta(name=name, priority=priority or 0)) + setattr(func, COMPAT_HOOKS_ATTR, hooks) + return func + + +def get_compat_hook_metas(func) -> list[CompatHookMeta]: + return list(getattr(func, COMPAT_HOOKS_ATTR, ())) + + +def _append_custom_filter(func, filter_obj: Any): + filters = list(getattr(func, COMPAT_CUSTOM_FILTERS_ATTR, ())) + filters.append(filter_obj) + setattr(func, COMPAT_CUSTOM_FILTERS_ATTR, filters) + return func + + +def get_compat_custom_filters(func) -> list[Any]: + return list(getattr(func, COMPAT_CUSTOM_FILTERS_ATTR, ())) + + +def _doc_description(func) -> str: + doc = inspect.getdoc(func) or "" + if not doc: + return "" + return doc.split("\n\n", 1)[0].strip() + + +def _parameter_description(func, parameter_name: str) -> str: + doc = inspect.getdoc(func) or "" + if not doc: + return "" + in_args = False + for raw_line in doc.splitlines(): + line = raw_line.rstrip() + stripped = line.strip() + if stripped in {"Args:", "Arguments:"}: + in_args = True + continue + if in_args and stripped and not raw_line.startswith((" ", "\t")): + break + if not in_args: + continue + if stripped.startswith(f"{parameter_name}(") or stripped.startswith( + f"{parameter_name}:" + ): + _, _, tail = stripped.partition(":") + return tail.strip() + return "" + + +def _resolve_json_schema( + func, + parameter: inspect.Parameter, + annotations: dict[str, Any] | None = None, +) -> dict[str, Any]: + annotation = ( + annotations.get(parameter.name, parameter.annotation) + if annotations is not None + else parameter.annotation + ) + origin = getattr(annotation, "__origin__", None) + args = getattr(annotation, "__args__", ()) + item_type = None + if annotation in _LLM_TOOL_PARAM_TYPES: + type_name = _LLM_TOOL_PARAM_TYPES[annotation] + elif origin in {list, tuple}: + type_name = "array" + if args: + item_type = _LLM_TOOL_PARAM_TYPES.get(args[0], "string") + elif origin is dict: + type_name = "object" + else: + type_name = "string" + schema = { + "type": type_name, + "name": parameter.name, + "description": _parameter_description(func, parameter.name), + } + if item_type is not None: + schema["items"] = {"type": item_type} + return schema + + +def _build_llm_tool_meta(func, tool_name: str | None) -> CompatLLMToolMeta: + signature = inspect.signature(func) + annotations = inspect.get_annotations(func, eval_str=True) + parameters: list[dict[str, Any]] = [] + for parameter in signature.parameters.values(): + if parameter.kind not in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ): + continue + if parameter.name in { + "self", + "event", + "ctx", + "context", + "cancel_token", + "token", + }: + continue + parameters.append(_resolve_json_schema(func, parameter, annotations)) + return CompatLLMToolMeta( + name=tool_name or func.__name__, + description=_doc_description(func), + parameters=parameters, + ) + + +def get_compat_llm_tool_meta(func) -> CompatLLMToolMeta | None: + return getattr(func, COMPAT_LLM_TOOL_ATTR, None) + def _merge_unique(existing: list[str], additions: list[str]) -> list[str]: merged: list[str] = [] @@ -374,20 +519,59 @@ def _unsupported_factory(name: str, replacement: str | None = None): return factory -custom_filter = _unsupported_factory("custom_filter") -after_message_sent = _unsupported_factory("after_message_sent") -on_astrbot_loaded = _unsupported_factory("on_astrbot_loaded") -on_platform_loaded = _unsupported_factory("on_platform_loaded") -on_decorating_result = _unsupported_factory("on_decorating_result") -on_llm_request = _unsupported_factory("on_llm_request") -on_llm_response = _unsupported_factory("on_llm_response") -llm_tool = _unsupported_factory("llm_tool") -on_waiting_llm_request = _unsupported_factory("on_waiting_llm_request") -on_using_llm_tool = _unsupported_factory("on_using_llm_tool") -on_llm_tool_respond = _unsupported_factory("on_llm_tool_respond") -on_plugin_error = _unsupported_factory("on_plugin_error") -on_plugin_loaded = _unsupported_factory("on_plugin_loaded") -on_plugin_unloaded = _unsupported_factory("on_plugin_unloaded") +def custom_filter(custom_type_filter, raise_error: bool = True, **kwargs): + """旧版自定义过滤器兼容入口。 + + 当前 compat 层支持最常见的函数级 `@custom_filter(MyFilter)` 用法。 + 指令组级自定义过滤链路仍然依赖旧 command_group 树,不在 v4 主链里复刻。 + """ + + def decorator(func): + if isinstance(custom_type_filter, (CustomFilterAnd, CustomFilterOr)): + filter_obj = custom_type_filter + elif isinstance(custom_type_filter, type) and issubclass( + custom_type_filter, CustomFilter + ): + filter_obj = custom_type_filter(raise_error=raise_error, **kwargs) + elif isinstance(custom_type_filter, CustomFilter): + filter_obj = custom_type_filter + else: + raise TypeError("custom_filter 只支持 CustomFilter 子类或实例") + return _append_custom_filter(func, filter_obj) + + return decorator + + +def _compat_hook(name: str): + def factory(*, priority: int | None = None, **_kwargs): + def decorator(func): + return _append_compat_hook(func, name, priority=priority) + + return decorator + + return factory + + +after_message_sent = _compat_hook("after_message_sent") +on_astrbot_loaded = _compat_hook("on_astrbot_loaded") +on_platform_loaded = _compat_hook("on_platform_loaded") +on_decorating_result = _compat_hook("on_decorating_result") +on_llm_request = _compat_hook("on_llm_request") +on_llm_response = _compat_hook("on_llm_response") +on_waiting_llm_request = _compat_hook("on_waiting_llm_request") +on_using_llm_tool = _compat_hook("on_using_llm_tool") +on_llm_tool_respond = _compat_hook("on_llm_tool_respond") +on_plugin_error = _compat_hook("on_plugin_error") +on_plugin_loaded = _compat_hook("on_plugin_loaded") +on_plugin_unloaded = _compat_hook("on_plugin_unloaded") + + +def llm_tool(name: str | None = None, **_kwargs): + def decorator(func): + setattr(func, COMPAT_LLM_TOOL_ATTR, _build_llm_tool_meta(func, name)) + return func + + return decorator def command_group( diff --git a/src-new/astrbot_sdk/api/provider/entities.py b/src-new/astrbot_sdk/api/provider/entities.py index a982a32fd..537093c8b 100644 --- a/src-new/astrbot_sdk/api/provider/entities.py +++ b/src-new/astrbot_sdk/api/provider/entities.py @@ -80,6 +80,14 @@ class LLMResponse: return self._completion_text = value + @property + def text(self) -> str: + return self.completion_text + + @text.setter + def text(self, value: str) -> None: + self.completion_text = value + def to_openai_tool_calls(self) -> list[dict[str, Any]]: ret: list[dict[str, Any]] = [] for idx, tool_call_arg in enumerate(self.tools_call_args): diff --git a/src-new/astrbot_sdk/runtime/bootstrap.py b/src-new/astrbot_sdk/runtime/bootstrap.py index 4ce2357d8..f5fcff323 100644 --- a/src-new/astrbot_sdk/runtime/bootstrap.py +++ b/src-new/astrbot_sdk/runtime/bootstrap.py @@ -662,6 +662,12 @@ class PluginWorkerRuntime: ], metadata={"plugin_id": self.plugin.name}, ) + await self._run_compat_context_hook("on_astrbot_loaded") + await self._run_compat_context_hook("on_platform_loaded") + await self._run_compat_context_hook( + "on_plugin_loaded", + metadata=dict(self.plugin.manifest_data), + ) except Exception: if lifecycle_started: try: @@ -676,6 +682,10 @@ class PluginWorkerRuntime: async def stop(self) -> None: try: + await self._run_compat_context_hook( + "on_plugin_unloaded", + metadata=dict(self.plugin.manifest_data), + ) await self._run_lifecycle("on_stop") finally: await self.peer.stop() @@ -735,6 +745,25 @@ class PluginWorkerRuntime: if callable(bind_runtime_context): bind_runtime_context(runtime_context) + async def _run_compat_context_hook(self, hook_name: str, **kwargs: Any) -> None: + seen: set[int] = set() + for loaded in [*self.loaded_plugin.handlers, *self.loaded_plugin.capabilities]: + legacy_context = getattr(loaded, "legacy_context", None) + if legacy_context is None: + continue + marker = id(legacy_context) + if marker in seen: + continue + seen.add(marker) + run_hook = getattr(legacy_context, "_run_compat_hook", None) + if callable(run_hook): + await run_hook( + hook_name, + context=self._lifecycle_context, + legacy_context=legacy_context, + **kwargs, + ) + @staticmethod def _resolve_lifecycle_hook(instance: Any, method_name: str): hook = getattr(instance, method_name, None) diff --git a/src-new/astrbot_sdk/runtime/handler_dispatcher.py b/src-new/astrbot_sdk/runtime/handler_dispatcher.py index 04cefe156..ce879ab0e 100644 --- a/src-new/astrbot_sdk/runtime/handler_dispatcher.py +++ b/src-new/astrbot_sdk/runtime/handler_dispatcher.py @@ -68,6 +68,7 @@ from __future__ import annotations import asyncio import inspect +import traceback import typing from collections.abc import AsyncIterator from typing import Any, get_type_hints @@ -105,6 +106,8 @@ class HandlerDispatcher: return {} if loaded.legacy_context is not None: loaded.legacy_context.bind_runtime_context(ctx) + if not await self._passes_compat_filters(loaded, event): + return {} # 提取 legacy args 用于兼容旧版 handler 签名 legacy_args = message.input.get("args") or {} @@ -152,14 +155,31 @@ class HandlerDispatcher: ) if inspect.isasyncgen(result): async for item in result: - await self._consume_legacy_result(item, event, ctx) + await self._consume_legacy_result( + item, + event, + ctx, + legacy_context=loaded.legacy_context, + ) return if inspect.isawaitable(result): result = await result if result is not None: - await self._consume_legacy_result(result, event, ctx) + await self._consume_legacy_result( + result, + event, + ctx, + legacy_context=loaded.legacy_context, + ) except Exception as exc: - await self._handle_error(loaded.owner, exc, event, ctx) + await self._handle_error( + loaded.owner, + exc, + event, + ctx, + legacy_context=loaded.legacy_context, + handler_name=loaded.callable.__name__, + ) raise def _build_args( @@ -285,20 +305,53 @@ class HandlerDispatcher: item: Any, event: MessageEvent, ctx: Context | None = None, + *, + legacy_context=None, ) -> None: from ..api.event.event_result import MessageEventResult + from ..api.event import AstrMessageEvent from ..api.message.chain import MessageChain + compat_event = None + if legacy_context is not None: + compat_event = AstrMessageEvent.from_message_event(event) + if isinstance(item, (MessageEventResult, MessageChain, str)): + compat_event.set_result(item) + await legacy_context._run_compat_hook( + "on_decorating_result", + event=compat_event, + context=ctx, + legacy_context=legacy_context, + result=compat_event.get_result(), + ) + if compat_event.is_stopped(): + return + item = compat_event.get_result() or item + if isinstance(item, MessageEventResult): if item.chain and ctx is not None and not item.is_plain_text_only(): await ctx.platform.send_chain( event.session_ref or event.session_id, item.to_payload(), ) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event, + context=ctx, + legacy_context=legacy_context, + ) return plain_text = item.get_plain_text() if plain_text: await event.reply(plain_text) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event, + context=ctx, + legacy_context=legacy_context, + ) return if isinstance(item, MessageChain): if item.chain and ctx is not None and not item.is_plain_text_only(): @@ -306,19 +359,70 @@ class HandlerDispatcher: event.session_ref or event.session_id, item.to_payload(), ) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event, + context=ctx, + legacy_context=legacy_context, + ) return plain_text = item.get_plain_text() if plain_text: await event.reply(plain_text) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event, + context=ctx, + legacy_context=legacy_context, + ) return if isinstance(item, PlainTextResult): await event.reply(item.text) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event or AstrMessageEvent.from_message_event(event), + context=ctx, + legacy_context=legacy_context, + ) return if isinstance(item, str): await event.reply(item) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event or AstrMessageEvent.from_message_event(event), + context=ctx, + legacy_context=legacy_context, + ) return if isinstance(item, dict) and "text" in item: await event.reply(str(item["text"])) + if legacy_context is not None: + await legacy_context._run_compat_hook( + "after_message_sent", + event=compat_event or AstrMessageEvent.from_message_event(event), + context=ctx, + legacy_context=legacy_context, + ) + + async def _passes_compat_filters( + self, + loaded: LoadedHandler, + event: MessageEvent, + ) -> bool: + if not loaded.compat_filters or loaded.legacy_context is None: + return True + from ..api.event import AstrMessageEvent + + compat_event = AstrMessageEvent.from_message_event(event) + cfg = loaded.legacy_context._runtime_config() + for filter_obj in loaded.compat_filters: + if not filter_obj.filter(compat_event, cfg): + return False + return True async def _handle_error( self, @@ -326,7 +430,25 @@ class HandlerDispatcher: exc: Exception, event: MessageEvent, ctx: Context, + *, + legacy_context=None, + handler_name: str = "", ) -> None: + if legacy_context is not None: + from ..api.event import AstrMessageEvent + + await legacy_context._run_compat_hook( + "on_plugin_error", + event=AstrMessageEvent.from_message_event(event), + context=ctx, + legacy_context=legacy_context, + plugin_name=self._plugin_id, + handler_name=handler_name, + error=exc, + traceback_text="".join( + traceback.TracebackException.from_exception(exc).format() + ), + ) if hasattr(owner, "on_error") and callable(owner.on_error): result = owner.on_error(exc, event, ctx) if inspect.isawaitable(result): diff --git a/src-new/astrbot_sdk/runtime/loader.py b/src-new/astrbot_sdk/runtime/loader.py index 2959ec7f4..ef66c097e 100644 --- a/src-new/astrbot_sdk/runtime/loader.py +++ b/src-new/astrbot_sdk/runtime/loader.py @@ -100,6 +100,9 @@ from typing import Any import yaml +from ..api.event.filter import ( + get_compat_custom_filters, +) from ..api.basic import AstrBotConfig from ..decorators import get_capability_meta, get_handler_meta from ..protocol.descriptors import CapabilityDescriptor, HandlerDescriptor @@ -151,6 +154,7 @@ class LoadedHandler: callable: Any owner: Any legacy_context: Any | None = None + compat_filters: list[Any] = field(default_factory=list) @dataclass(slots=True) @@ -696,6 +700,11 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin: and getattr(instance, "config", None) is None ): setattr(instance, "config", component_config) + register_compat_component = getattr( + legacy_context, "_register_compat_component", None + ) + if callable(register_compat_component): + register_compat_component(instance) instances.append(instance) for name in _iter_discoverable_names(instance): resolved = _resolve_handler_candidate(instance, name) @@ -728,6 +737,7 @@ def load_plugin(plugin: PluginSpec) -> LoadedPlugin: callable=bound, owner=instance, legacy_context=legacy_context, + compat_filters=list(get_compat_custom_filters(bound)), ) ) return LoadedPlugin( diff --git a/tests_v4/test_api_contract.py b/tests_v4/test_api_contract.py index 8bc06693b..52738f247 100644 --- a/tests_v4/test_api_contract.py +++ b/tests_v4/test_api_contract.py @@ -6,7 +6,6 @@ from unittest.mock import patch from astrbot_sdk import MessageEvent, Star, on_command from astrbot_sdk._legacy_api import ( CommandComponent, - MIGRATION_DOC_URL, LegacyContext, _warned_methods, ) @@ -71,15 +70,14 @@ class ApiContractTest(unittest.IsolatedAsyncioTestCase): ], ) - async def test_add_llm_tools_raises_not_implemented(self) -> None: - """add_llm_tools() should raise NotImplementedError in v4.""" + async def test_add_llm_tools_accepts_empty_registration(self) -> None: + """add_llm_tools() should keep the legacy entry point available.""" _warned_methods.clear() legacy_context = LegacyContext("compat-plugin") - with self.assertRaises(NotImplementedError) as context: - await legacy_context.add_llm_tools() - self.assertIn("add_llm_tools", str(context.exception)) - self.assertIn(MIGRATION_DOC_URL, str(context.exception)) + await legacy_context.add_llm_tools() + + self.assertEqual(legacy_context.get_llm_tool_manager().func_list, []) async def test_compat_llm_generate_warning_matches_chat_raw_mapping(self) -> None: class _DummyLLM: diff --git a/tests_v4/test_api_event_filter.py b/tests_v4/test_api_event_filter.py index caf4805fe..383feb075 100644 --- a/tests_v4/test_api_event_filter.py +++ b/tests_v4/test_api_event_filter.py @@ -9,13 +9,18 @@ import pytest from astrbot_sdk.api.event.filter import ( ADMIN, + CustomFilter, EventMessageType, PermissionType, PlatformAdapterType, command, command_group, + custom_filter, event_message_type, filter, + get_compat_custom_filters, + get_compat_hook_metas, + get_compat_llm_tool_meta, llm_tool, on_llm_tool_respond, on_plugin_error, @@ -334,19 +339,27 @@ class TestCommandGroupCompat: assert meta.trigger.command == "math calc add" -class TestUnsupportedCompatFilters: - """Tests for explicitly unsupported legacy helpers.""" +class TestCompatHookMetadata: + """Tests for legacy hook metadata capture.""" - def test_other_unsupported_filter_still_raises_explicitly(self): - """Unsupported helpers should fail loudly instead of silently no-oping.""" - with pytest.raises(NotImplementedError, match="on_llm_request"): - filter.on_llm_request() + def test_hook_decorators_store_prioritized_metadata(self): + """Legacy hook decorators should record runtime metadata instead of raising.""" + + @filter.on_llm_request(priority=5) + @on_waiting_llm_request(priority=1) + async def hook(): + pass + + metas = get_compat_hook_metas(hook) + + assert [(item.name, item.priority) for item in metas] == [ + ("on_waiting_llm_request", 1), + ("on_llm_request", 5), + ] @pytest.mark.parametrize( ("factory", "name"), [ - (llm_tool, "llm_tool"), - (on_waiting_llm_request, "on_waiting_llm_request"), (on_using_llm_tool, "on_using_llm_tool"), (on_llm_tool_respond, "on_llm_tool_respond"), (on_plugin_error, "on_plugin_error"), @@ -354,7 +367,52 @@ class TestUnsupportedCompatFilters: (on_plugin_unloaded, "on_plugin_unloaded"), ], ) - def test_newly_exposed_legacy_helpers_fail_loudly(self, factory, name): - """Newly-exposed legacy import names should still fail explicitly when unsupported.""" - with pytest.raises(NotImplementedError, match=name): - factory() + def test_hook_factories_attach_named_hook_metadata(self, factory, name): + """Compat hook helpers should attach the expected hook name.""" + + @factory() + async def hook(): + pass + + metas = get_compat_hook_metas(hook) + + assert [item.name for item in metas] == [name] + + def test_llm_tool_builds_compat_tool_metadata(self): + """llm_tool() should expose legacy tool metadata for runtime registration.""" + + @llm_tool(name="math.add") + async def add_tool(a: int, b: int, event=None): + """Add two integers. + + Args: + a: first addend + b: second addend + """ + return a + b + + tool_meta = get_compat_llm_tool_meta(add_tool) + + assert tool_meta is not None + assert tool_meta.name == "math.add" + assert tool_meta.description == "Add two integers." + assert tool_meta.parameters == [ + {"type": "number", "name": "a", "description": "first addend"}, + {"type": "number", "name": "b", "description": "second addend"}, + ] + + def test_custom_filter_records_filter_instance_on_handler(self): + """custom_filter() should keep legacy filter objects for dispatcher evaluation.""" + + class AllowAll(CustomFilter): + def filter(self, event, cfg) -> bool: + return True + + @custom_filter(AllowAll) + async def handler(): + pass + + filters = get_compat_custom_filters(handler) + + assert len(filters) == 1 + assert isinstance(filters[0], AllowAll) diff --git a/tests_v4/test_api_legacy_context.py b/tests_v4/test_api_legacy_context.py index 3e90ee660..a3887ce39 100644 --- a/tests_v4/test_api_legacy_context.py +++ b/tests_v4/test_api_legacy_context.py @@ -16,7 +16,15 @@ from astrbot_sdk._legacy_api import ( LegacyConversationManager, LegacyStar, ) +from astrbot_sdk.api.event.filter import ( + llm_tool, + on_llm_request, + on_llm_response, + on_llm_tool_respond, + on_using_llm_tool, +) from astrbot_sdk.api.message import Comp, MessageChain +from astrbot_sdk.api.provider.entities import LLMResponse from astrbot_sdk.star import Star @@ -261,6 +269,75 @@ class TestLegacyConversationManager: assert conv_id == "my_plugin-conv-2" + @pytest.mark.asyncio + async def test_get_filtered_conversations_filters_by_keyword(self): + """get_filtered_conversations() should search over stored conversation payloads.""" + stored_data = { + "__compat_conversations__": { + "conv-1": { + "unified_msg_origin": "session-1", + "platform_id": "qq", + "content": [{"role": "user", "content": "hello astrbot"}], + }, + "conv-2": { + "unified_msg_origin": "session-1", + "platform_id": "qq", + "content": [{"role": "user", "content": "other topic"}], + }, + } + } + + async def mock_get(key): + return stored_data.get(key) + + mock_ctx = MagicMock() + mock_ctx.plugin_id = "my_plugin" + mock_ctx.db = MagicMock() + mock_ctx.db.get = mock_get + + legacy_ctx = LegacyContext("my_plugin") + legacy_ctx._runtime_context = mock_ctx + + result = await legacy_ctx.conversation_manager.get_filtered_conversations( + unified_msg_origin="session-1", + keyword="astrbot", + ) + + assert [item["conversation_id"] for item in result] == ["conv-1"] + + @pytest.mark.asyncio + async def test_get_human_readable_context_renders_conversation_content(self): + """get_human_readable_context() should render stored message pairs as readable text.""" + stored_data = { + "__compat_conversations__": { + "conv-1": { + "unified_msg_origin": "session-1", + "content": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "world"}, + ], + } + } + } + + async def mock_get(key): + return stored_data.get(key) + + mock_ctx = MagicMock() + mock_ctx.plugin_id = "my_plugin" + mock_ctx.db = MagicMock() + mock_ctx.db.get = mock_get + + legacy_ctx = LegacyContext("my_plugin") + legacy_ctx._runtime_context = mock_ctx + legacy_ctx.conversation_manager._current_conversations["session-1"] = "conv-1" + + result = await legacy_ctx.conversation_manager.get_human_readable_context( + unified_msg_origin="session-1" + ) + + assert result == "user: hello\nassistant: world" + class TestLegacyContextMethods: """Tests for LegacyContext methods that delegate to NewContext.""" @@ -456,10 +533,12 @@ class TestLegacyContextLLMMethods: """Tests for LegacyContext LLM methods.""" @pytest.mark.asyncio - async def test_llm_generate_delegates_to_chat_raw(self): - """llm_generate() should delegate to ctx.llm.chat_raw().""" + async def test_llm_generate_returns_compat_response_and_applies_hook_mutation(self): + """llm_generate() should return legacy LLMResponse and honor hook-mutated request data.""" mock_llm = AsyncMock() - mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response")) + mock_llm.chat_raw = AsyncMock( + return_value={"role": "assistant", "text": "response"} + ) mock_ctx = MagicMock() mock_ctx.llm = mock_llm @@ -467,6 +546,19 @@ class TestLegacyContextLLMMethods: legacy_ctx = LegacyContext("test_plugin") legacy_ctx._runtime_context = mock_ctx + seen_completion_texts = [] + + class CompatHooks: + @on_llm_request() + async def mutate_request(self, request): + request.model = "hook-model" + + @on_llm_response() + async def capture_response(self, response: LLMResponse): + seen_completion_texts.append(response.completion_text) + + legacy_ctx._register_component(CompatHooks()) + result = await legacy_ctx.llm_generate( chat_provider_id="provider-1", prompt="hello", @@ -477,13 +569,37 @@ class TestLegacyContextLLMMethods: mock_llm.chat_raw.assert_called_once() call_kwargs = mock_llm.chat_raw.call_args[1] assert call_kwargs["provider_id"] == "provider-1" - assert result is not None + assert call_kwargs["model"] == "hook-model" + assert isinstance(result, LLMResponse) + assert result.completion_text == "response" + assert result.text == "response" + assert seen_completion_texts == ["response"] @pytest.mark.asyncio - async def test_tool_loop_agent_delegates_to_chat_raw(self): - """tool_loop_agent() should delegate to ctx.llm.chat_raw().""" + async def test_tool_loop_agent_runs_registered_compat_tools(self): + """tool_loop_agent() should execute registered compat llm tools and continue the loop.""" mock_llm = AsyncMock() - mock_llm.chat_raw = AsyncMock(return_value=MagicMock(text="response")) + mock_llm.chat_raw = AsyncMock( + side_effect=[ + { + "role": "assistant", + "text": "", + "tool_calls": [ + { + "id": "call-1", + "function": { + "name": "math.add", + "arguments": '{"a": 1, "b": 2}', + }, + } + ], + }, + { + "role": "assistant", + "text": "result ready", + }, + ] + ) mock_ctx = MagicMock() mock_ctx.llm = mock_llm @@ -491,28 +607,73 @@ class TestLegacyContextLLMMethods: legacy_ctx = LegacyContext("test_plugin") legacy_ctx._runtime_context = mock_ctx + seen_tool_events = [] + + class CompatToolComponent: + @llm_tool(name="math.add") + async def add(self, a: int, b: int): + return str(a + b) + + @on_using_llm_tool() + async def before_tool(self, tool_args): + seen_tool_events.append(("before", dict(tool_args))) + + @on_llm_tool_respond() + async def after_tool(self, tool_result): + seen_tool_events.append(("after", tool_result)) + + legacy_ctx._register_component(CompatToolComponent()) + result = await legacy_ctx.tool_loop_agent( chat_provider_id="provider-1", prompt="hello", max_steps=10, ) - mock_llm.chat_raw.assert_called_once() - call_kwargs = mock_llm.chat_raw.call_args[1] - assert call_kwargs["provider_id"] == "provider-1" - assert call_kwargs["max_steps"] == 10 - assert result.text == "response" + assert mock_llm.chat_raw.await_count == 2 + first_call = mock_llm.chat_raw.await_args_list[0] + second_call = mock_llm.chat_raw.await_args_list[1] + assert first_call.kwargs["provider_id"] == "provider-1" + assert first_call.kwargs["max_steps"] == 10 + assert second_call.kwargs["history"][-1] == { + "role": "tool", + "tool_call_id": "call-1", + "name": "math.add", + "content": "3", + } + assert isinstance(result, LLMResponse) + assert result.completion_text == "result ready" + assert result.text == "result ready" + assert seen_tool_events == [ + ("before", {"a": 1, "b": 2}), + ("after", "3"), + ] @pytest.mark.asyncio - async def test_add_llm_tools_raises_not_implemented(self): - """add_llm_tools() should raise NotImplementedError in v4.""" + async def test_add_llm_tools_registers_compat_tool_object(self): + """add_llm_tools() should accept legacy tool objects and expose them via the tool manager.""" legacy_ctx = LegacyContext("test_plugin") - with pytest.raises(NotImplementedError) as exc_info: - await legacy_ctx.add_llm_tools("tool1", "tool2") + class ToolObject: + name = "demo.echo" + description = "Echo input" + parameters = { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + } - assert "add_llm_tools" in str(exc_info.value) - assert MIGRATION_DOC_URL in str(exc_info.value) + async def handler(self, text: str) -> str: + return text + + await legacy_ctx.add_llm_tools(ToolObject()) + + manager = legacy_ctx.get_llm_tool_manager() + tool = manager.get_func("demo.echo") + + assert tool is not None + assert tool.description == "Echo input" + assert tool.parameters["required"] == ["text"] class TestCommandComponent: diff --git a/tests_v4/test_bootstrap.py b/tests_v4/test_bootstrap.py index c467ded3c..7ae17da85 100644 --- a/tests_v4/test_bootstrap.py +++ b/tests_v4/test_bootstrap.py @@ -39,7 +39,7 @@ from astrbot_sdk.runtime.bootstrap import ( _wait_for_shutdown, ) from astrbot_sdk.runtime.capability_router import CapabilityRouter -from astrbot_sdk.runtime.loader import PluginSpec +from astrbot_sdk.runtime.loader import LoadedHandler, PluginSpec from astrbot_sdk.runtime.peer import Peer from tests_v4.helpers import FakeEnvManager, MemoryTransport, make_transport_pair @@ -807,6 +807,92 @@ class DemoComponent(Star): if str(plugin_dir) in sys.path: sys.path.remove(str(plugin_dir)) + @pytest.mark.asyncio + async def test_start_and_stop_run_compat_context_lifecycle_hooks(self): + """PluginWorkerRuntime should execute compat lifecycle hooks around peer startup/shutdown.""" + with tempfile.TemporaryDirectory() as temp_dir: + plugin_dir = Path(temp_dir) + manifest_path = plugin_dir / "plugin.yaml" + requirements_path = plugin_dir / "requirements.txt" + + manifest_path.write_text( + yaml.dump( + { + "name": "test_plugin", + "runtime": {"python": "3.12"}, + "components": [], + } + ), + encoding="utf-8", + ) + requirements_path.write_text("", encoding="utf-8") + + transport = MemoryTransport() + runtime = PluginWorkerRuntime(plugin_dir=plugin_dir, transport=transport) + + seen_hooks = [] + legacy_context = LegacyContext("test_plugin") + + class CompatHooks: + async def on_astrbot_loaded(self, context): + seen_hooks.append(("astrbot", context.plugin_id)) + + async def on_platform_loaded(self, context): + seen_hooks.append(("platform", context.plugin_id)) + + async def on_plugin_loaded(self, metadata): + seen_hooks.append(("loaded", metadata["name"])) + + async def on_plugin_unloaded(self, metadata): + seen_hooks.append(("unloaded", metadata["name"])) + + from astrbot_sdk.api.event.filter import ( + on_astrbot_loaded, + on_platform_loaded, + on_plugin_loaded, + on_plugin_unloaded, + ) + + CompatHooks.on_astrbot_loaded = on_astrbot_loaded()( + CompatHooks.on_astrbot_loaded + ) + CompatHooks.on_platform_loaded = on_platform_loaded()( + CompatHooks.on_platform_loaded + ) + CompatHooks.on_plugin_loaded = on_plugin_loaded()( + CompatHooks.on_plugin_loaded + ) + CompatHooks.on_plugin_unloaded = on_plugin_unloaded()( + CompatHooks.on_plugin_unloaded + ) + legacy_context._register_component(CompatHooks()) + legacy_context.bind_runtime_context(runtime._lifecycle_context) + + runtime.loaded_plugin.handlers.append( + LoadedHandler( + descriptor=HandlerDescriptor( + id="legacy.compat", + trigger=CommandTrigger(command="compat"), + ), + callable=AsyncMock(), + owner=MagicMock(), + legacy_context=legacy_context, + ) + ) + runtime.peer.start = AsyncMock() + runtime.peer.initialize = AsyncMock() + runtime.peer.stop = AsyncMock() + + await runtime.start() + await runtime.stop() + + assert seen_hooks == [ + ("astrbot", "test_plugin"), + ("platform", "test_plugin"), + ("loaded", "test_plugin"), + ("unloaded", "test_plugin"), + ] + @pytest.mark.asyncio async def test_run_lifecycle_sync_hook(self): """_run_lifecycle should call sync hooks.""" diff --git a/tests_v4/test_handler_dispatcher.py b/tests_v4/test_handler_dispatcher.py index eb3e8d63c..6d6242394 100644 --- a/tests_v4/test_handler_dispatcher.py +++ b/tests_v4/test_handler_dispatcher.py @@ -17,6 +17,11 @@ from astrbot.core.utils.session_waiter import ( ) from astrbot_sdk._legacy_api import LegacyContext from astrbot_sdk.api.event import AstrMessageEvent +from astrbot_sdk.api.event.filter import ( + CustomFilter, + after_message_sent, + on_decorating_result, +) from astrbot_sdk.api.message import Comp, MessageChain from astrbot_sdk.context import CancelToken, Context from astrbot_sdk.events import MessageEvent, PlainTextResult @@ -572,6 +577,120 @@ class TestHandlerDispatcherInvoke: assert captured_replies == ["显式触发"] +class TestHandlerDispatcherLegacyCompat: + """Tests for legacy compat hooks and filters during dispatch.""" + + @pytest.mark.asyncio + async def test_custom_filter_can_skip_legacy_handler_invocation(self): + """Legacy custom filters should prevent handler execution when they reject the event.""" + peer = MockPeer() + legacy_context = LegacyContext("test_plugin") + called = [] + + class RejectAll(CustomFilter): + def filter(self, event: AstrMessageEvent, cfg) -> bool: + return False + + async def handler_func(event: AstrMessageEvent): + called.append(event.message_str) + + descriptor = HandlerDescriptor( + id="legacy.filtered", + trigger=CommandTrigger(command="ask"), + ) + handler = LoadedHandler( + descriptor=descriptor, + callable=handler_func, + owner=MagicMock(), + legacy_context=legacy_context, + compat_filters=[RejectAll()], + ) + dispatcher = HandlerDispatcher( + plugin_id="test_plugin", + peer=peer, + handlers=[handler], + ) + + message = InvokeMessage( + id="msg_filtered", + capability="handler.invoke", + input={ + "handler_id": "legacy.filtered", + "event": { + "text": "ask", + "session_id": "session-filtered", + "user_id": "user-1", + "platform": "test", + }, + }, + ) + + result = await dispatcher.invoke(message, CancelToken()) + + assert result == {} + assert called == [] + assert peer.sent_messages == [] + + @pytest.mark.asyncio + async def test_decorating_and_after_send_hooks_run_for_legacy_results(self): + """Legacy decorating/send hooks should be applied around compat result sending.""" + peer = MockPeer() + legacy_context = LegacyContext("test_plugin") + observed_results = [] + + class CompatHooks: + @on_decorating_result() + async def decorate(self, event: AstrMessageEvent): + event.set_result("decorated result") + + @after_message_sent() + async def after_send(self, event: AstrMessageEvent): + result = event.get_result() + observed_results.append(result.get_plain_text() if result else "") + + legacy_context._register_component(CompatHooks()) + + async def handler_func(event: AstrMessageEvent): + return "raw result" + + descriptor = HandlerDescriptor( + id="legacy.decorated", + trigger=CommandTrigger(command="decorate"), + ) + handler = LoadedHandler( + descriptor=descriptor, + callable=handler_func, + owner=MagicMock(), + legacy_context=legacy_context, + ) + dispatcher = HandlerDispatcher( + plugin_id="test_plugin", + peer=peer, + handlers=[handler], + ) + + message = InvokeMessage( + id="msg_decorated", + capability="handler.invoke", + input={ + "handler_id": "legacy.decorated", + "event": { + "text": "decorate", + "session_id": "session-decorated", + "user_id": "user-1", + "platform": "test", + }, + }, + ) + + await dispatcher.invoke(message, CancelToken()) + + assert peer.sent_messages == [ + {"session_id": "session-decorated", "text": "decorated result"} + ] + assert observed_results == ["decorated result"] + + class TestHandlerDispatcherCancel: """Tests for HandlerDispatcher.cancel method."""