mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 01:40:15 +08:00
Implement legacy hook and tool compat runtime
This commit is contained in:
@@ -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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user