From 9d05934d53ed95f2c6c28f1ce92467db783ccbd1 Mon Sep 17 00:00:00 2001 From: whatevertogo Date: Fri, 13 Mar 2026 04:46:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E5=B8=B8=E9=87=8F=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E8=83=BD=E5=8A=9B=E6=8F=8F=E8=BF=B0=E7=AC=A6=E4=BB=A5?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E5=8D=8F=E8=AE=AE=E6=A8=A1=E5=BC=8F=EF=BC=8C?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=9B=B8=E5=85=B3=E6=B5=8B=E8=AF=95=E7=94=A8?= =?UTF-8?q?=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + CLAUDE.md | 1 + src-new/astrbot_sdk/errors.py | 36 ++- src-new/astrbot_sdk/protocol/descriptors.py | 248 +++++++++++++++++- .../astrbot_sdk/runtime/capability_router.py | 161 ++++-------- src-new/astrbot_sdk/runtime/peer.py | 2 +- tests_v4/test_capability_router.py | 16 +- tests_v4/test_protocol_descriptors.py | 26 +- tests_v4/test_protocol_messages.py | 12 +- tests_v4/test_protocol_package.py | 8 +- tests_v4/test_top_level_modules.py | 17 +- 11 files changed, 406 insertions(+), 122 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 757b498fa..174d1c9a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ - 2026-03-13: `load_plugin()` must not blindly `getattr()` every name from `dir(instance)` during handler discovery. Real plugins may expose properties or descriptors with side effects or exceptions; inspect attributes statically first, and only bind names that actually carry handler metadata. - 2026-03-13: In `Peer`, “remote initialized” and “transport still alive” are separate states. Waiting for initialization must fail when the connection closes first, and malformed inbound protocol messages should actively fail pending calls instead of leaving futures/streams hanging. - 2026-03-13: Several first-layer files under `src-new/astrbot_sdk/*.py` carried stale migration comparison blocks that claimed missing CLI help, missing compat APIs, or other gaps already covered by tests and current implementations. Treat those comments as historical noise; verify behavior against code and tests before "restoring" features from the comments. +- 2026-03-13: The v4 design already defines built-in capability schema governance and reserved namespaces at the protocol layer. Keeping anonymous schema builders only inside `runtime/capability_router.py` drifts runtime behavior away from the protocol contract; centralize built-in schemas and namespace constants in `protocol/descriptors.py`. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 757b498fa..174d1c9a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ - 2026-03-13: `load_plugin()` must not blindly `getattr()` every name from `dir(instance)` during handler discovery. Real plugins may expose properties or descriptors with side effects or exceptions; inspect attributes statically first, and only bind names that actually carry handler metadata. - 2026-03-13: In `Peer`, “remote initialized” and “transport still alive” are separate states. Waiting for initialization must fail when the connection closes first, and malformed inbound protocol messages should actively fail pending calls instead of leaving futures/streams hanging. - 2026-03-13: Several first-layer files under `src-new/astrbot_sdk/*.py` carried stale migration comparison blocks that claimed missing CLI help, missing compat APIs, or other gaps already covered by tests and current implementations. Treat those comments as historical noise; verify behavior against code and tests before "restoring" features from the comments. +- 2026-03-13: The v4 design already defines built-in capability schema governance and reserved namespaces at the protocol layer. Keeping anonymous schema builders only inside `runtime/capability_router.py` drifts runtime behavior away from the protocol contract; centralize built-in schemas and namespace constants in `protocol/descriptors.py`. # 开发命令 diff --git a/src-new/astrbot_sdk/errors.py b/src-new/astrbot_sdk/errors.py index aa092b29f..50f53ca44 100644 --- a/src-new/astrbot_sdk/errors.py +++ b/src-new/astrbot_sdk/errors.py @@ -5,6 +5,28 @@ from __future__ import annotations from dataclasses import dataclass +class ErrorCodes: + """AstrBot v4 的稳定错误码常量。""" + + UNKNOWN_ERROR = "unknown_error" + + # retryable = False + LLM_NOT_CONFIGURED = "llm_not_configured" + CAPABILITY_NOT_FOUND = "capability_not_found" + PERMISSION_DENIED = "permission_denied" + LLM_ERROR = "llm_error" + INVALID_INPUT = "invalid_input" + CANCELLED = "cancelled" + PROTOCOL_VERSION_MISMATCH = "protocol_version_mismatch" + PROTOCOL_ERROR = "protocol_error" + INTERNAL_ERROR = "internal_error" + + # retryable = True + CAPABILITY_TIMEOUT = "capability_timeout" + NETWORK_ERROR = "network_error" + LLM_TEMPORARY_ERROR = "llm_temporary_error" + + @dataclass(slots=True) class AstrBotError(Exception): code: str @@ -18,7 +40,7 @@ class AstrBotError(Exception): @classmethod def cancelled(cls, message: str = "调用被取消") -> "AstrBotError": return cls( - code="cancelled", + code=ErrorCodes.CANCELLED, message=message, hint="", retryable=False, @@ -27,7 +49,7 @@ class AstrBotError(Exception): @classmethod def capability_not_found(cls, name: str) -> "AstrBotError": return cls( - code="capability_not_found", + code=ErrorCodes.CAPABILITY_NOT_FOUND, message=f"未找到能力:{name}", hint="请确认 AstrBot Core 是否已注册该 capability", retryable=False, @@ -36,7 +58,7 @@ class AstrBotError(Exception): @classmethod def invalid_input(cls, message: str) -> "AstrBotError": return cls( - code="invalid_input", + code=ErrorCodes.INVALID_INPUT, message=message, hint="请检查调用参数", retryable=False, @@ -45,7 +67,7 @@ class AstrBotError(Exception): @classmethod def protocol_version_mismatch(cls, message: str) -> "AstrBotError": return cls( - code="protocol_version_mismatch", + code=ErrorCodes.PROTOCOL_VERSION_MISMATCH, message=message, hint="请升级 astrbot_sdk 至最新版本", retryable=False, @@ -54,7 +76,7 @@ class AstrBotError(Exception): @classmethod def protocol_error(cls, message: str) -> "AstrBotError": return cls( - code="protocol_error", + code=ErrorCodes.PROTOCOL_ERROR, message=message, hint="请检查通信双方的协议实现", retryable=False, @@ -63,7 +85,7 @@ class AstrBotError(Exception): @classmethod def internal_error(cls, message: str) -> "AstrBotError": return cls( - code="internal_error", + code=ErrorCodes.INTERNAL_ERROR, message=message, hint="请联系插件作者", retryable=False, @@ -80,7 +102,7 @@ class AstrBotError(Exception): @classmethod def from_payload(cls, payload: dict[str, object]) -> "AstrBotError": return cls( - code=str(payload.get("code", "unknown_error")), + code=str(payload.get("code", ErrorCodes.UNKNOWN_ERROR)), message=str(payload.get("message", "未知错误")), hint=str(payload.get("hint", "")), retryable=bool(payload.get("retryable", False)), diff --git a/src-new/astrbot_sdk/protocol/descriptors.py b/src-new/astrbot_sdk/protocol/descriptors.py index 3fcd5eb1b..f87078978 100644 --- a/src-new/astrbot_sdk/protocol/descriptors.py +++ b/src-new/astrbot_sdk/protocol/descriptors.py @@ -11,6 +11,208 @@ from typing import Annotated, Any, Literal from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +JSONSchema = dict[str, Any] +RESERVED_CAPABILITY_NAMESPACES = ("handler", "system", "internal") +RESERVED_CAPABILITY_PREFIXES = tuple( + f"{namespace}." for namespace in RESERVED_CAPABILITY_NAMESPACES +) + + +def _object_schema( + *, + required: tuple[str, ...] = (), + **properties: Any, +) -> JSONSchema: + return { + "type": "object", + "properties": properties, + "required": list(required), + } + + +def _nullable(schema: JSONSchema) -> JSONSchema: + return {"anyOf": [schema, {"type": "null"}]} + + +_OPTIONAL_CHAT_PROPERTIES: dict[str, Any] = { + "system": {"type": "string"}, + "history": {"type": "array", "items": {"type": "object"}}, + "model": {"type": "string"}, + "temperature": {"type": "number"}, + "image_urls": {"type": "array", "items": {"type": "string"}}, + "tools": {"type": "array"}, + "max_steps": {"type": "integer"}, +} + +LLM_CHAT_INPUT_SCHEMA = _object_schema( + required=("prompt",), + prompt={"type": "string"}, + **_OPTIONAL_CHAT_PROPERTIES, +) +LLM_CHAT_OUTPUT_SCHEMA = _object_schema( + required=("text",), + text={"type": "string"}, +) +LLM_CHAT_RAW_INPUT_SCHEMA = _object_schema( + required=("prompt",), + prompt={"type": "string"}, + **_OPTIONAL_CHAT_PROPERTIES, +) +LLM_CHAT_RAW_OUTPUT_SCHEMA = _object_schema( + required=("text",), + text={"type": "string"}, + usage=_nullable({"type": "object"}), + finish_reason=_nullable({"type": "string"}), + tool_calls={"type": "array", "items": {"type": "object"}}, +) +LLM_STREAM_CHAT_INPUT_SCHEMA = _object_schema( + required=("prompt",), + prompt={"type": "string"}, + **_OPTIONAL_CHAT_PROPERTIES, +) +LLM_STREAM_CHAT_OUTPUT_SCHEMA = _object_schema( + required=("text",), + text={"type": "string"}, +) +MEMORY_SEARCH_INPUT_SCHEMA = _object_schema( + required=("query",), + query={"type": "string"}, +) +MEMORY_SEARCH_OUTPUT_SCHEMA = _object_schema( + required=("items",), + items={"type": "array", "items": {"type": "object"}}, +) +MEMORY_SAVE_INPUT_SCHEMA = _object_schema( + required=("key", "value"), + key={"type": "string"}, + value={"type": "object"}, +) +MEMORY_SAVE_OUTPUT_SCHEMA = _object_schema() +MEMORY_GET_INPUT_SCHEMA = _object_schema( + required=("key",), + key={"type": "string"}, +) +MEMORY_GET_OUTPUT_SCHEMA = _object_schema( + required=("value",), + value=_nullable({"type": "object"}), +) +MEMORY_DELETE_INPUT_SCHEMA = _object_schema( + required=("key",), + key={"type": "string"}, +) +MEMORY_DELETE_OUTPUT_SCHEMA = _object_schema() +DB_GET_INPUT_SCHEMA = _object_schema( + required=("key",), + key={"type": "string"}, +) +DB_GET_OUTPUT_SCHEMA = _object_schema( + required=("value",), + value=_nullable({"type": "object"}), +) +DB_SET_INPUT_SCHEMA = _object_schema( + required=("key", "value"), + key={"type": "string"}, + value={"type": "object"}, +) +DB_SET_OUTPUT_SCHEMA = _object_schema() +DB_DELETE_INPUT_SCHEMA = _object_schema( + required=("key",), + key={"type": "string"}, +) +DB_DELETE_OUTPUT_SCHEMA = _object_schema() +DB_LIST_INPUT_SCHEMA = _object_schema( + prefix=_nullable({"type": "string"}), +) +DB_LIST_OUTPUT_SCHEMA = _object_schema( + required=("keys",), + keys={"type": "array", "items": {"type": "string"}}, +) +PLATFORM_SEND_INPUT_SCHEMA = _object_schema( + required=("session", "text"), + session={"type": "string"}, + text={"type": "string"}, +) +PLATFORM_SEND_OUTPUT_SCHEMA = _object_schema( + required=("message_id",), + message_id={"type": "string"}, +) +PLATFORM_SEND_IMAGE_INPUT_SCHEMA = _object_schema( + required=("session", "image_url"), + session={"type": "string"}, + image_url={"type": "string"}, +) +PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA = _object_schema( + required=("message_id",), + message_id={"type": "string"}, +) +PLATFORM_GET_MEMBERS_INPUT_SCHEMA = _object_schema( + required=("session",), + session={"type": "string"}, +) +PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA = _object_schema( + required=("members",), + members={"type": "array", "items": {"type": "object"}}, +) + +BUILTIN_CAPABILITY_SCHEMAS: dict[str, dict[str, JSONSchema]] = { + "llm.chat": { + "input": LLM_CHAT_INPUT_SCHEMA, + "output": LLM_CHAT_OUTPUT_SCHEMA, + }, + "llm.chat_raw": { + "input": LLM_CHAT_RAW_INPUT_SCHEMA, + "output": LLM_CHAT_RAW_OUTPUT_SCHEMA, + }, + "llm.stream_chat": { + "input": LLM_STREAM_CHAT_INPUT_SCHEMA, + "output": LLM_STREAM_CHAT_OUTPUT_SCHEMA, + }, + "memory.search": { + "input": MEMORY_SEARCH_INPUT_SCHEMA, + "output": MEMORY_SEARCH_OUTPUT_SCHEMA, + }, + "memory.save": { + "input": MEMORY_SAVE_INPUT_SCHEMA, + "output": MEMORY_SAVE_OUTPUT_SCHEMA, + }, + "memory.get": { + "input": MEMORY_GET_INPUT_SCHEMA, + "output": MEMORY_GET_OUTPUT_SCHEMA, + }, + "memory.delete": { + "input": MEMORY_DELETE_INPUT_SCHEMA, + "output": MEMORY_DELETE_OUTPUT_SCHEMA, + }, + "db.get": { + "input": DB_GET_INPUT_SCHEMA, + "output": DB_GET_OUTPUT_SCHEMA, + }, + "db.set": { + "input": DB_SET_INPUT_SCHEMA, + "output": DB_SET_OUTPUT_SCHEMA, + }, + "db.delete": { + "input": DB_DELETE_INPUT_SCHEMA, + "output": DB_DELETE_OUTPUT_SCHEMA, + }, + "db.list": { + "input": DB_LIST_INPUT_SCHEMA, + "output": DB_LIST_OUTPUT_SCHEMA, + }, + "platform.send": { + "input": PLATFORM_SEND_INPUT_SCHEMA, + "output": PLATFORM_SEND_OUTPUT_SCHEMA, + }, + "platform.send_image": { + "input": PLATFORM_SEND_IMAGE_INPUT_SCHEMA, + "output": PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA, + }, + "platform.get_members": { + "input": PLATFORM_GET_MEMBERS_INPUT_SCHEMA, + "output": PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA, + }, +} + class _DescriptorBase(BaseModel): model_config = ConfigDict(extra="forbid") @@ -193,19 +395,61 @@ class CapabilityDescriptor(_DescriptorBase): name: str description: str - input_schema: dict[str, Any] | None = None - output_schema: dict[str, Any] | None = None + input_schema: JSONSchema | None = None + output_schema: JSONSchema | None = None supports_stream: bool = False cancelable: bool = False + @model_validator(mode="after") + def validate_builtin_schema_governance(self) -> "CapabilityDescriptor": + if self.name in BUILTIN_CAPABILITY_SCHEMAS and ( + self.input_schema is None or self.output_schema is None + ): + raise ValueError( + f"内建 capability {self.name} 必须同时提供 input_schema 和 output_schema" + ) + return self + __all__ = [ + "BUILTIN_CAPABILITY_SCHEMAS", "CapabilityDescriptor", "CommandTrigger", + "DB_DELETE_INPUT_SCHEMA", + "DB_DELETE_OUTPUT_SCHEMA", + "DB_GET_INPUT_SCHEMA", + "DB_GET_OUTPUT_SCHEMA", + "DB_LIST_INPUT_SCHEMA", + "DB_LIST_OUTPUT_SCHEMA", + "DB_SET_INPUT_SCHEMA", + "DB_SET_OUTPUT_SCHEMA", "EventTrigger", "HandlerDescriptor", + "JSONSchema", + "LLM_CHAT_INPUT_SCHEMA", + "LLM_CHAT_OUTPUT_SCHEMA", + "LLM_CHAT_RAW_INPUT_SCHEMA", + "LLM_CHAT_RAW_OUTPUT_SCHEMA", + "LLM_STREAM_CHAT_INPUT_SCHEMA", + "LLM_STREAM_CHAT_OUTPUT_SCHEMA", + "MEMORY_DELETE_INPUT_SCHEMA", + "MEMORY_DELETE_OUTPUT_SCHEMA", + "MEMORY_GET_INPUT_SCHEMA", + "MEMORY_GET_OUTPUT_SCHEMA", + "MEMORY_SAVE_INPUT_SCHEMA", + "MEMORY_SAVE_OUTPUT_SCHEMA", + "MEMORY_SEARCH_INPUT_SCHEMA", + "MEMORY_SEARCH_OUTPUT_SCHEMA", "MessageTrigger", + "PLATFORM_GET_MEMBERS_INPUT_SCHEMA", + "PLATFORM_GET_MEMBERS_OUTPUT_SCHEMA", + "PLATFORM_SEND_IMAGE_INPUT_SCHEMA", + "PLATFORM_SEND_IMAGE_OUTPUT_SCHEMA", + "PLATFORM_SEND_INPUT_SCHEMA", + "PLATFORM_SEND_OUTPUT_SCHEMA", "Permissions", + "RESERVED_CAPABILITY_NAMESPACES", + "RESERVED_CAPABILITY_PREFIXES", "ScheduleTrigger", "Trigger", ] diff --git a/src-new/astrbot_sdk/runtime/capability_router.py b/src-new/astrbot_sdk/runtime/capability_router.py index 2dee9a5ee..2d53320ba 100644 --- a/src-new/astrbot_sdk/runtime/capability_router.py +++ b/src-new/astrbot_sdk/runtime/capability_router.py @@ -83,6 +83,7 @@ from __future__ import annotations import asyncio +import copy import json import re from collections.abc import AsyncIterator, Awaitable, Callable @@ -90,12 +91,15 @@ from dataclasses import dataclass from typing import Any from ..errors import AstrBotError -from ..protocol.descriptors import CapabilityDescriptor +from ..protocol.descriptors import ( + BUILTIN_CAPABILITY_SCHEMAS, + CapabilityDescriptor, + RESERVED_CAPABILITY_PREFIXES, +) CallHandler = Callable[[str, dict[str, Any], object], Awaitable[dict[str, Any]]] StreamHandler = Callable[[str, dict[str, Any], object], AsyncIterator[dict[str, Any]]] FinalizeHandler = Callable[[list[dict[str, Any]]], dict[str, Any]] -RESERVED_CAPABILITY_PREFIXES = ("handler.", "system.", "internal.") CAPABILITY_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$") @@ -182,12 +186,22 @@ class CapabilityRouter: return output def _register_builtin_capabilities(self) -> None: - def obj_schema(required: list[str], **properties: Any) -> dict[str, Any]: - return { - "type": "object", - "properties": properties, - "required": required, - } + def builtin_descriptor( + name: str, + description: str, + *, + supports_stream: bool = False, + cancelable: bool = False, + ) -> CapabilityDescriptor: + schema = BUILTIN_CAPABILITY_SCHEMAS[name] + return CapabilityDescriptor( + name=name, + description=description, + input_schema=copy.deepcopy(schema["input"]), + output_schema=copy.deepcopy(schema["output"]), + supports_stream=supports_stream, + cancelable=cancelable, + ) async def llm_chat( _request_id: str, payload: dict[str, Any], _token @@ -325,29 +339,17 @@ class CapabilityRouter: } self.register( - CapabilityDescriptor( - name="llm.chat", - description="发送对话请求,返回文本", - input_schema=obj_schema(["prompt"], prompt={"type": "string"}), - output_schema=obj_schema(["text"], text={"type": "string"}), - ), + builtin_descriptor("llm.chat", "发送对话请求,返回文本"), call_handler=llm_chat, ) self.register( - CapabilityDescriptor( - name="llm.chat_raw", - description="发送对话请求,返回完整响应", - input_schema=obj_schema(["prompt"], prompt={"type": "string"}), - output_schema=obj_schema(["text"], text={"type": "string"}), - ), + builtin_descriptor("llm.chat_raw", "发送对话请求,返回完整响应"), call_handler=llm_chat_raw, ) self.register( - CapabilityDescriptor( - name="llm.stream_chat", - description="流式对话", - input_schema=obj_schema(["prompt"], prompt={"type": "string"}), - output_schema=obj_schema(["text"], text={"type": "string"}), + builtin_descriptor( + "llm.stream_chat", + "流式对话", supports_stream=True, cancelable=True, ), @@ -357,114 +359,47 @@ class CapabilityRouter: }, ) self.register( - CapabilityDescriptor( - name="memory.search", - description="搜索记忆", - input_schema=obj_schema(["query"], query={"type": "string"}), - output_schema=obj_schema(["items"], items={"type": "array"}), - ), + builtin_descriptor("memory.search", "搜索记忆"), call_handler=memory_search, ) self.register( - CapabilityDescriptor( - name="memory.save", - description="保存记忆", - input_schema=obj_schema( - ["key", "value"], key={"type": "string"}, value={"type": "object"} - ), - output_schema=obj_schema([]), - ), + builtin_descriptor("memory.save", "保存记忆"), call_handler=memory_save, ) self.register( - CapabilityDescriptor( - name="memory.get", - description="读取单条记忆", - input_schema=obj_schema(["key"], key={"type": "string"}), - output_schema=obj_schema([], value={"type": "object"}), - ), + builtin_descriptor("memory.get", "读取单条记忆"), call_handler=memory_get, ) self.register( - CapabilityDescriptor( - name="memory.delete", - description="删除记忆", - input_schema=obj_schema(["key"], key={"type": "string"}), - output_schema=obj_schema([]), - ), + builtin_descriptor("memory.delete", "删除记忆"), call_handler=memory_delete, ) self.register( - CapabilityDescriptor( - name="db.get", - description="读取 KV", - input_schema=obj_schema(["key"], key={"type": "string"}), - output_schema=obj_schema([], value={"type": "object"}), - ), + builtin_descriptor("db.get", "读取 KV"), call_handler=db_get, ) self.register( - CapabilityDescriptor( - name="db.set", - description="写入 KV", - input_schema=obj_schema( - ["key", "value"], key={"type": "string"}, value={"type": "object"} - ), - output_schema=obj_schema([]), - ), + builtin_descriptor("db.set", "写入 KV"), call_handler=db_set, ) self.register( - CapabilityDescriptor( - name="db.delete", - description="删除 KV", - input_schema=obj_schema(["key"], key={"type": "string"}), - output_schema=obj_schema([]), - ), + builtin_descriptor("db.delete", "删除 KV"), call_handler=db_delete, ) self.register( - CapabilityDescriptor( - name="db.list", - description="列出 KV", - input_schema=obj_schema([], prefix={"type": "string"}), - output_schema=obj_schema(["keys"], keys={"type": "array"}), - ), + builtin_descriptor("db.list", "列出 KV"), call_handler=db_list, ) self.register( - CapabilityDescriptor( - name="platform.send", - description="发送消息", - input_schema=obj_schema( - ["session", "text"], - session={"type": "string"}, - text={"type": "string"}, - ), - output_schema=obj_schema(["message_id"], message_id={"type": "string"}), - ), + builtin_descriptor("platform.send", "发送消息"), call_handler=platform_send, ) self.register( - CapabilityDescriptor( - name="platform.send_image", - description="发送图片", - input_schema=obj_schema( - ["session", "image_url"], - session={"type": "string"}, - image_url={"type": "string"}, - ), - output_schema=obj_schema(["message_id"], message_id={"type": "string"}), - ), + builtin_descriptor("platform.send_image", "发送图片"), call_handler=platform_send_image, ) self.register( - CapabilityDescriptor( - name="platform.get_members", - description="获取群成员", - input_schema=obj_schema(["session"], session={"type": "string"}), - output_schema=obj_schema(["members"], members={"type": "array"}), - ), + builtin_descriptor("platform.get_members", "获取群成员"), call_handler=platform_get_members, ) @@ -473,10 +408,28 @@ class CapabilityRouter: schema: dict[str, Any] | None, payload: dict[str, Any], ) -> None: + def schema_allows_null(field_schema: Any) -> bool: + if not isinstance(field_schema, dict): + return False + if field_schema.get("type") == "null": + return True + any_of = field_schema.get("anyOf") + if not isinstance(any_of, list): + return False + return any( + isinstance(candidate, dict) and candidate.get("type") == "null" + for candidate in any_of + ) + if schema is None: return if schema.get("type") == "object" and not isinstance(payload, dict): raise AstrBotError.invalid_input("输入必须是 object") + properties = schema.get("properties", {}) for field_name in schema.get("required", []): - if field_name not in payload or payload[field_name] is None: + if field_name not in payload: + raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}") + if payload[field_name] is None and not schema_allows_null( + properties.get(field_name) + ): raise AstrBotError.invalid_input(f"缺少必填字段:{field_name}") diff --git a/src-new/astrbot_sdk/runtime/peer.py b/src-new/astrbot_sdk/runtime/peer.py index 57eb73679..2980935f3 100644 --- a/src-new/astrbot_sdk/runtime/peer.py +++ b/src-new/astrbot_sdk/runtime/peer.py @@ -23,7 +23,7 @@ invoke() -> InvokeMessage(stream=False) invoke_stream() -> InvokeMessage(stream=True) cancel() -> CancelMessage - + 与旧版对比: 旧版 JSON-RPC: - 分离的 JSONRPCClient 和 JSONRPCServer diff --git a/tests_v4/test_capability_router.py b/tests_v4/test_capability_router.py index f50a25b35..70ecfc0fd 100644 --- a/tests_v4/test_capability_router.py +++ b/tests_v4/test_capability_router.py @@ -10,7 +10,10 @@ import pytest from astrbot_sdk.context import CancelToken from astrbot_sdk.errors import AstrBotError -from astrbot_sdk.protocol.descriptors import CapabilityDescriptor +from astrbot_sdk.protocol.descriptors import ( + BUILTIN_CAPABILITY_SCHEMAS, + CapabilityDescriptor, +) from astrbot_sdk.runtime.capability_router import ( CAPABILITY_NAME_PATTERN, RESERVED_CAPABILITY_PREFIXES, @@ -167,6 +170,17 @@ class TestCapabilityRouterInit: assert "platform.send_image" in capability_names assert "platform.get_members" in capability_names + def test_builtin_descriptors_use_protocol_schema_registry(self): + """CapabilityRouter should source built-in schemas from protocol constants.""" + router = CapabilityRouter() + descriptors = { + descriptor.name: descriptor for descriptor in router.descriptors() + } + + for name, schema in BUILTIN_CAPABILITY_SCHEMAS.items(): + assert descriptors[name].input_schema == schema["input"] + assert descriptors[name].output_schema == schema["output"] + class TestCapabilityRouterRegister: """Tests for CapabilityRouter.register method.""" diff --git a/tests_v4/test_protocol_descriptors.py b/tests_v4/test_protocol_descriptors.py index 127c80a51..54bc6685d 100644 --- a/tests_v4/test_protocol_descriptors.py +++ b/tests_v4/test_protocol_descriptors.py @@ -8,12 +8,16 @@ import pytest from pydantic import ValidationError from astrbot_sdk.protocol.descriptors import ( + BUILTIN_CAPABILITY_SCHEMAS, CapabilityDescriptor, CommandTrigger, + DB_LIST_INPUT_SCHEMA, EventTrigger, HandlerDescriptor, + LLM_CHAT_INPUT_SCHEMA, MessageTrigger, Permissions, + RESERVED_CAPABILITY_PREFIXES, ScheduleTrigger, ) @@ -256,14 +260,32 @@ class TestCapabilityDescriptor: def test_required_name_and_description(self): """CapabilityDescriptor requires name and description.""" - cap = CapabilityDescriptor(name="llm.chat", description="Chat with LLM") - assert cap.name == "llm.chat" + cap = CapabilityDescriptor(name="custom.chat", description="Chat with LLM") + assert cap.name == "custom.chat" assert cap.description == "Chat with LLM" assert cap.input_schema is None assert cap.output_schema is None assert cap.supports_stream is False assert cap.cancelable is False + def test_builtin_capability_requires_schemas(self): + """Built-in capabilities should enforce schema governance.""" + with pytest.raises(ValidationError, match="必须同时提供"): + CapabilityDescriptor(name="llm.chat", description="missing schemas") + + def test_builtin_capability_schema_registry_contains_required_entries(self): + """Built-in schema registry should cover documented core capabilities.""" + assert "llm.chat" in BUILTIN_CAPABILITY_SCHEMAS + assert "db.list" in BUILTIN_CAPABILITY_SCHEMAS + assert LLM_CHAT_INPUT_SCHEMA["required"] == ["prompt"] + assert ( + DB_LIST_INPUT_SCHEMA["properties"]["prefix"]["anyOf"][1]["type"] == "null" + ) + + def test_reserved_capability_prefixes_are_protocol_constants(self): + """Reserved namespace prefixes should live in protocol descriptors.""" + assert RESERVED_CAPABILITY_PREFIXES == ("handler.", "system.", "internal.") + def test_with_schemas(self): """CapabilityDescriptor should accept input/output schemas.""" cap = CapabilityDescriptor( diff --git a/tests_v4/test_protocol_messages.py b/tests_v4/test_protocol_messages.py index fb73dbcc4..c018f7782 100644 --- a/tests_v4/test_protocol_messages.py +++ b/tests_v4/test_protocol_messages.py @@ -187,10 +187,18 @@ class TestInitializeOutput: def test_with_capabilities(self): """InitializeOutput should accept capabilities.""" - from astrbot_sdk.protocol.descriptors import CapabilityDescriptor + from astrbot_sdk.protocol.descriptors import ( + BUILTIN_CAPABILITY_SCHEMAS, + CapabilityDescriptor, + ) peer = PeerInfo(name="core", role="core") - cap = CapabilityDescriptor(name="llm.chat", description="Chat capability") + cap = CapabilityDescriptor( + name="llm.chat", + description="Chat capability", + input_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"], + output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"], + ) output = InitializeOutput(peer=peer, capabilities=[cap]) assert len(output.capabilities) == 1 assert output.capabilities[0].name == "llm.chat" diff --git a/tests_v4/test_protocol_package.py b/tests_v4/test_protocol_package.py index e8710bb0a..f7ab95133 100644 --- a/tests_v4/test_protocol_package.py +++ b/tests_v4/test_protocol_package.py @@ -19,6 +19,7 @@ from astrbot_sdk.protocol import ( parse_legacy_message, parse_message, ) +from astrbot_sdk.protocol.descriptors import BUILTIN_CAPABILITY_SCHEMAS class TestProtocolPackageExports: @@ -41,7 +42,12 @@ class TestProtocolPackageExports: assert isinstance(parsed, InitializeMessage) assert isinstance(ErrorPayload(code="x", message="y"), ErrorPayload) assert isinstance( - CapabilityDescriptor(name="llm.chat", description="chat"), + CapabilityDescriptor( + name="llm.chat", + description="chat", + input_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["input"], + output_schema=BUILTIN_CAPABILITY_SCHEMAS["llm.chat"]["output"], + ), CapabilityDescriptor, ) assert isinstance(MessageTrigger(keywords=["hello"]), MessageTrigger) diff --git a/tests_v4/test_top_level_modules.py b/tests_v4/test_top_level_modules.py index 0a5722c49..507f27fe3 100644 --- a/tests_v4/test_top_level_modules.py +++ b/tests_v4/test_top_level_modules.py @@ -23,7 +23,7 @@ from astrbot_sdk._legacy_api import ( from astrbot_sdk.cli import cli, setup_logger from astrbot_sdk.context import Context from astrbot_sdk.decorators import on_command -from astrbot_sdk.errors import AstrBotError +from astrbot_sdk.errors import AstrBotError, ErrorCodes from astrbot_sdk.events import MessageEvent from astrbot_sdk.star import Star @@ -224,11 +224,24 @@ class TestErrorsModule: """from_payload() should fill in the documented fallback values.""" error = AstrBotError.from_payload({"message": "boom", "retryable": 1}) - assert error.code == "unknown_error" + assert error.code == ErrorCodes.UNKNOWN_ERROR assert error.message == "boom" assert error.hint == "" assert error.retryable is True + def test_error_code_constants_match_factory_outputs(self): + """核心工厂方法应复用统一错误码常量。""" + assert AstrBotError.cancelled().code == ErrorCodes.CANCELLED + assert ( + AstrBotError.capability_not_found("memory.get").code + == ErrorCodes.CAPABILITY_NOT_FOUND + ) + assert AstrBotError.invalid_input("bad").code == ErrorCodes.INVALID_INPUT + assert ( + AstrBotError.protocol_version_mismatch("bad").code + == ErrorCodes.PROTOCOL_VERSION_MISMATCH + ) + class TestStarModule: """Tests for star.py."""