Implement legacy hook and tool compat runtime

This commit is contained in:
whatevertogo
2026-03-13 17:41:06 +08:00
parent 885e4fe32e
commit 2fe1311b5d
13 changed files with 1501 additions and 89 deletions

View File

@@ -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.
# 开发命令

View File

@@ -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.
# 开发命令

View File

@@ -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()

View File

@@ -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(

View File

@@ -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):

View File

@@ -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)

View File

@@ -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):

View File

@@ -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(

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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."""