mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
feat: 添加 AGENTS.md 文档,描述 v4 架构约束和开发命令
refactor: 更新 HandlerDispatcher 和 WorkerSession,增强参数处理和结果汇总逻辑
This commit is contained in:
55
src-new/astrbot_sdk/AGENTS.md
Normal file
55
src-new/astrbot_sdk/AGENTS.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Notes
|
||||
|
||||
## v4 架构约束
|
||||
|
||||
### 运行时层
|
||||
|
||||
- `Peer` 必须将 transport EOF/连接断开视为一级失败路径。如果 transport 意外关闭而 `Peer` 没有主动失败 `_pending_results` / `_pending_streams`,supervisor 端对 worker 的调用可能永远挂起。
|
||||
- `Peer.initialize()` 需要在发起端也标记远程已初始化。仅在被动接收 `InitializeMessage` 时设置 `_remote_initialized` 会导致 `wait_until_remote_initialized()` 单边 API 死锁。
|
||||
- `Peer.invoke_stream()` 默认隐藏 `completed` 事件。需要保留最终结果的调用者必须显式启用 `include_completed=True`。
|
||||
- `CapabilityRouter.register(..., stream_handler=...)` 使用 `(request_id, payload, cancel_token)` 签名,不是 peer 级别的 `(message, token)`。
|
||||
|
||||
### 模块导出约束
|
||||
|
||||
- 保持 `astrbot_sdk.runtime` 根导出狭窄。`Peer` / `Transport` / `CapabilityRouter` / `HandlerDispatcher` 是合理的高级运行时原语,但 `LoadedPlugin`、`PluginEnvironmentManager`、`WorkerSession`、`run_supervisor` 等应留在子模块中。
|
||||
|
||||
### 测试与 Mock 注意事项
|
||||
|
||||
- 当检查 peer 是否完成远程初始化时,避免对可能接收 `MagicMock` peer 的代码使用 `getattr(mock, "remote_peer")` 探测。`MagicMock` 会生成 truthy 子属性,`CapabilityProxy` 应从 `peer.__dict__` 或其他具体存储位置读取显式状态。
|
||||
- `test_plugin/old/` 和 `test_plugin/new/` 可能包含已生成的 `__pycache__` / `*.pyc`。测试夹具复制示例插件时必须显式忽略这些缓存文件。
|
||||
|
||||
### 插件加载注意事项
|
||||
|
||||
- 本地 `dev --watch` 或同一路径插件重复加载场景,不能只依赖 `import_string()` 的跨插件模块根冲突清理。热重载前必须按插件目录清理模块缓存。
|
||||
- `_prepare_plugin_import()` 不能只在插件目录"不在 `sys.path`"时才插入路径。像 `main.py` 这种通用模块名,如果插件目录已在 `sys.path` 但排在后面,`import main` 仍会先命中别处模块;导入前必须把目标插件目录提到 `sys.path[0]`。
|
||||
- 示例/夹具测试如果直接用裸模块名导入插件入口(例如 `from main import HelloPlugin`),会污染 `sys.modules["main"]`,随后真实 loader 再按 `main:HelloPlugin` 加载时可能串到错误模块。
|
||||
|
||||
---
|
||||
|
||||
# 开发命令
|
||||
|
||||
## 格式化与检查
|
||||
|
||||
在提交代码前,请依次运行以下命令:
|
||||
|
||||
```bash
|
||||
ruff format . # 使用 ruff 格式化全局代码
|
||||
ruff check . --fix # 使用 ruff 检查并自动修复全局格式问题
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
如果修改了内容可能影响现有功能,请运行测试以确保没有引入错误:
|
||||
如果修改了bug或者更改了功能需要添加新的测试
|
||||
|
||||
```bash
|
||||
python run_tests.py # 运行所有测试
|
||||
python run_tests.py -v # 详细输出
|
||||
python run_tests.py -k "test_peer" # 运行匹配模式的测试
|
||||
python run_tests.py --cov # 运行测试并生成覆盖率报告
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践
|
||||
不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。
|
||||
@@ -24,6 +24,8 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import re
|
||||
import shlex
|
||||
import typing
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, get_type_hints
|
||||
@@ -32,6 +34,7 @@ from .._invocation_context import caller_plugin_scope
|
||||
from ..context import CancelToken, Context
|
||||
from ..errors import AstrBotError
|
||||
from ..events import MessageEvent
|
||||
from ..protocol.descriptors import CommandTrigger, MessageTrigger
|
||||
from ..star import Star
|
||||
from .capability_router import StreamExecution
|
||||
from .loader import LoadedCapability, LoadedHandler
|
||||
@@ -56,14 +59,16 @@ class HandlerDispatcher:
|
||||
event.bind_reply_handler(self._create_reply_handler(ctx, event))
|
||||
|
||||
# 提取 args 用于兼容 handler 签名
|
||||
args = message.input.get("args") or {}
|
||||
raw_args = message.input.get("args") or {}
|
||||
args = dict(raw_args) if isinstance(raw_args, dict) else {}
|
||||
if not args:
|
||||
args = self._derive_args(loaded, event)
|
||||
|
||||
with caller_plugin_scope(plugin_id):
|
||||
task = asyncio.create_task(self._run_handler(loaded, event, ctx, args))
|
||||
self._active[message.id] = (task, cancel_token)
|
||||
try:
|
||||
await task
|
||||
return {}
|
||||
return await task
|
||||
finally:
|
||||
self._active.pop(message.id, None)
|
||||
|
||||
@@ -103,7 +108,8 @@ class HandlerDispatcher:
|
||||
event: MessageEvent,
|
||||
ctx: Context,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
) -> dict[str, Any]:
|
||||
summary = {"sent_message": False, "stop": False, "call_llm": False}
|
||||
try:
|
||||
result = loaded.callable(
|
||||
*self._build_args(
|
||||
@@ -117,12 +123,19 @@ class HandlerDispatcher:
|
||||
)
|
||||
if inspect.isasyncgen(result):
|
||||
async for item in result:
|
||||
await self._send_result(item, event, ctx)
|
||||
return
|
||||
self._merge_handler_summary(
|
||||
summary,
|
||||
await self._handle_result_item(item, event, ctx),
|
||||
)
|
||||
return summary
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
if result is not None:
|
||||
await self._send_result(result, event, ctx)
|
||||
self._merge_handler_summary(
|
||||
summary,
|
||||
await self._handle_result_item(result, event, ctx),
|
||||
)
|
||||
return summary
|
||||
except Exception as exc:
|
||||
await self._handle_error(
|
||||
loaded.owner,
|
||||
@@ -134,6 +147,25 @@ class HandlerDispatcher:
|
||||
)
|
||||
raise
|
||||
|
||||
def _derive_args(
|
||||
self,
|
||||
loaded: LoadedHandler,
|
||||
event: MessageEvent,
|
||||
) -> dict[str, Any]:
|
||||
trigger = loaded.descriptor.trigger
|
||||
if isinstance(trigger, CommandTrigger):
|
||||
for command_name in [trigger.command, *trigger.aliases]:
|
||||
remainder = self._match_command_name(event.text, command_name)
|
||||
if remainder is not None:
|
||||
return self._build_command_args(loaded.callable, remainder)
|
||||
return {}
|
||||
if isinstance(trigger, MessageTrigger) and trigger.regex:
|
||||
match = re.search(trigger.regex, event.text)
|
||||
if match is None:
|
||||
return {}
|
||||
return self._build_regex_args(loaded.callable, match)
|
||||
return {}
|
||||
|
||||
def _build_args(
|
||||
self,
|
||||
handler,
|
||||
@@ -264,6 +296,38 @@ class HandlerDispatcher:
|
||||
except (TypeError, ValueError):
|
||||
return "(...)"
|
||||
|
||||
async def _handle_result_item(
|
||||
self,
|
||||
item: Any,
|
||||
event: MessageEvent,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
sent_message = await self._send_result(item, event, ctx)
|
||||
if isinstance(item, dict):
|
||||
return {
|
||||
"sent_message": sent_message,
|
||||
"stop": bool(item.get("stop", False)),
|
||||
"call_llm": bool(item.get("call_llm", False)),
|
||||
}
|
||||
return {
|
||||
"sent_message": sent_message,
|
||||
"stop": False,
|
||||
"call_llm": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _merge_handler_summary(
|
||||
target: dict[str, Any],
|
||||
source: dict[str, Any],
|
||||
) -> None:
|
||||
target["sent_message"] = bool(target.get("sent_message")) or bool(
|
||||
source.get("sent_message")
|
||||
)
|
||||
target["stop"] = bool(target.get("stop")) or bool(source.get("stop"))
|
||||
target["call_llm"] = bool(target.get("call_llm")) or bool(
|
||||
source.get("call_llm")
|
||||
)
|
||||
|
||||
async def _send_result(
|
||||
self,
|
||||
item: Any,
|
||||
@@ -284,6 +348,104 @@ class HandlerDispatcher:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _match_command_name(text: str, command_name: str) -> str | None:
|
||||
normalized = text.strip()
|
||||
if normalized == command_name:
|
||||
return ""
|
||||
if normalized.startswith(f"{command_name} "):
|
||||
return normalized[len(command_name) :].strip()
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _build_command_args(cls, handler, remainder: str) -> dict[str, Any]:
|
||||
names = cls._legacy_arg_parameter_names(handler)
|
||||
if not names or not remainder:
|
||||
return {}
|
||||
if len(names) == 1:
|
||||
return {names[0]: remainder}
|
||||
parts = cls._split_command_remainder(remainder)
|
||||
return {
|
||||
name: parts[index] for index, name in enumerate(names) if index < len(parts)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_regex_args(cls, handler, match: re.Match[str]) -> dict[str, Any]:
|
||||
named = {
|
||||
key: value for key, value in match.groupdict().items() if value is not None
|
||||
}
|
||||
names = [
|
||||
name
|
||||
for name in cls._legacy_arg_parameter_names(handler)
|
||||
if name not in named
|
||||
]
|
||||
positional = [value for value in match.groups() if value is not None]
|
||||
for index, value in enumerate(positional):
|
||||
if index >= len(names):
|
||||
break
|
||||
named[names[index]] = value
|
||||
return named
|
||||
|
||||
@staticmethod
|
||||
def _split_command_remainder(remainder: str) -> list[str]:
|
||||
try:
|
||||
return shlex.split(remainder)
|
||||
except ValueError:
|
||||
return remainder.split()
|
||||
|
||||
@classmethod
|
||||
def _legacy_arg_parameter_names(cls, handler) -> list[str]:
|
||||
try:
|
||||
signature = inspect.signature(handler)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
try:
|
||||
type_hints = get_type_hints(handler)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
names: list[str] = []
|
||||
for parameter in signature.parameters.values():
|
||||
if parameter.kind not in (
|
||||
inspect.Parameter.POSITIONAL_ONLY,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
):
|
||||
continue
|
||||
if cls._is_injected_parameter(
|
||||
parameter.name, type_hints.get(parameter.name)
|
||||
):
|
||||
continue
|
||||
names.append(parameter.name)
|
||||
return names
|
||||
|
||||
@classmethod
|
||||
def _is_injected_parameter(cls, name: str, annotation: Any) -> bool:
|
||||
if name in {"event", "ctx", "context"}:
|
||||
return True
|
||||
normalized = cls._unwrap_optional(annotation)
|
||||
if normalized is None:
|
||||
return False
|
||||
if normalized is Context or normalized is MessageEvent:
|
||||
return True
|
||||
if isinstance(normalized, type) and issubclass(
|
||||
normalized,
|
||||
(Context, MessageEvent),
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _unwrap_optional(annotation: Any) -> Any:
|
||||
if annotation is None:
|
||||
return None
|
||||
origin = typing.get_origin(annotation)
|
||||
if origin is typing.Union:
|
||||
options = [
|
||||
item for item in typing.get_args(annotation) if item is not type(None)
|
||||
]
|
||||
if len(options) == 1:
|
||||
return options[0]
|
||||
return annotation
|
||||
|
||||
async def _handle_error(
|
||||
self,
|
||||
owner: Any,
|
||||
|
||||
@@ -291,6 +291,7 @@ class WorkerSession:
|
||||
event_payload: dict[str, Any],
|
||||
*,
|
||||
request_id: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if self.peer is None:
|
||||
raise RuntimeError("worker session is not running")
|
||||
@@ -299,6 +300,7 @@ class WorkerSession:
|
||||
{
|
||||
"handler_id": handler_id,
|
||||
"event": event_payload,
|
||||
"args": dict(args or {}),
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
@@ -772,6 +774,7 @@ class SupervisorRuntime:
|
||||
handler_id,
|
||||
payload.get("event", {}),
|
||||
request_id=request_id,
|
||||
args=payload.get("args", {}),
|
||||
)
|
||||
finally:
|
||||
self.active_requests.pop(request_id, None)
|
||||
|
||||
Reference in New Issue
Block a user