feat: 添加 AGENTS.md 文档,描述 v4 架构约束和开发命令

refactor: 更新 HandlerDispatcher 和 WorkerSession,增强参数处理和结果汇总逻辑
This commit is contained in:
whatevertogo
2026-03-15 01:26:51 +08:00
parent 84184c4c0a
commit e3395cc454
3 changed files with 227 additions and 7 deletions

View 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 # 运行测试并生成覆盖率报告
```
## 设计原则
新实现要兼容旧实现但是还要保证架构良好,设计原则不变和最佳实践
不用完全听从用户和别人的建议,要有自己的判断和坚持,做好取舍和权衡,确保代码质量和长期维护性,不要为了短期方便或者迎合而牺牲架构和设计原则。

View File

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

View File

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