File size: 5,885 Bytes
21e8d36
ad88834
 
 
 
21e8d36
ad88834
21e8d36
 
 
 
 
 
 
ad88834
 
 
 
 
 
21e8d36
 
 
 
 
 
ad88834
 
 
 
21e8d36
 
ad88834
 
21e8d36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad88834
 
 
21e8d36
 
ad88834
21e8d36
 
ad88834
 
21e8d36
ad88834
 
21e8d36
 
ad88834
 
21e8d36
 
 
ad88834
 
 
 
 
 
21e8d36
 
ad88834
 
 
21e8d36
 
 
 
 
 
ad88834
21e8d36
 
 
 
 
ad88834
 
 
 
21e8d36
 
 
 
 
 
ad88834
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
from typing import Literal, TypeVar, get_args

from a2a.helpers import new_data_part
from a2a.types import Part
from google.protobuf.json_format import MessageToDict
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator, model_validator

# 主 Agent 和游戏之间的通信协议,写成“名称/版本”。只有不兼容的改动才升版本,见游戏开发指南的“协议版本”一节。
# Version 是主 Agent 支持的全部版本,新的在前;游戏的名片里写它支持的版本,主 Agent 用双方都支持的最新版本。
Version = Literal["agentcade/1"]
SUPPORTED_PROTOCOLS: tuple[str, ...] = get_args(Version)
PROTOCOL_VERSION = SUPPORTED_PROTOCOLS[0]
# 出了新版本以后,旧版本至少再支持三个月:版本 → 停止支持的日期,check 会提醒还在用它的游戏。
RETIRING: dict[str, str] = {}


class Model(BaseModel):
    model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)


class Wire(BaseModel):
    """主 Agent 和游戏之间传的消息。不认识的字段放进 model_extra 后忽略,所以加可选字段不用升协议版本。"""

    model_config = ConfigDict(extra="allow", str_strip_whitespace=True)


class GameDescriptor(Model):
    id: str = Field(pattern=r"^[a-z][a-z0-9_]{0,63}$")
    name: str = Field(min_length=1)
    description: str = Field(min_length=1)
    # 开机问候里跟在游戏名后面念的一句简短介绍,不带句末标点。
    summary: str = Field(min_length=1, max_length=30, pattern=r"[^。!?.!?,,;;]$")
    aliases: list[str] = Field(default_factory=list)
    required_capabilities: list[str] = Field(default_factory=list)
    # 选填,写进名片,用户安装远程游戏时在确认页上看到。
    version: str = Field(default="1.0.0", min_length=1, max_length=32)
    author: str = Field(default="", max_length=64)
    homepage: str = Field(default="", max_length=512)

    def catalog(self) -> dict:
        """给主 Agent 看的游戏目录条目,只包含选游戏和介绍玩法用得到的内容。"""
        return self.model_dump(include={"id", "name", "description", "summary", "aliases"})


def integral(value):
    """A2A 的结构化数据里数字都是浮点数,整数值还原成 int,SDK 要整数的参数才能通过。"""
    if isinstance(value, float) and value.is_integer():
        return int(value)
    if isinstance(value, list):
        return [integral(item) for item in value]
    if isinstance(value, dict):
        return {key: integral(item) for key, item in value.items()}
    return value


class SdkCall(Wire):
    """一次机器人 SDK 调用,例如 lights.set_color("#00FF00", brightness=0.8)。

    call 是 SDK 里的“模块.方法”,参数和 SDK 一样;主 Agent 检查游戏能不能调用、有没有声明能力后,
    在机器人上执行同名方法。
    """

    call: str = Field(pattern=r"^[a-z_]+\.[a-z_]+$", description="SDK 里的模块.方法,例如 lights.set_color")
    args: list[JsonValue] = Field(default_factory=list)
    kwargs: dict[str, JsonValue] = Field(default_factory=dict)

    @field_validator("args", "kwargs")
    @classmethod
    def restore_integers(cls, value):
        return integral(value)


class Presentation(Model):
    """主 Agent 的一条输出:一句话、一次游戏的 SDK 调用,或者一局结束后复位设备。"""

    text: str = Field(default="", max_length=500)
    call: SdkCall | None = None
    reset: bool = False


class GameInput(Wire):
    """主 Agent 发给游戏 Agent 的一条用户输入。"""

    model_config = ConfigDict(extra="allow", str_strip_whitespace=False)
    protocol: Version = PROTOCOL_VERSION
    kind: Literal["start", "message", "end"]
    text: str = Field(default="", description="用户原话,保持字词和标点")
    reason: Literal["user", "shutdown"] | None = Field(
        default=None, description="end 的原因:user 是用户要求结束,shutdown 是程序退出",
    )

    @model_validator(mode="after")
    def validate_text(self):
        # 程序退出时发送的 end 没有用户原话。
        if self.kind != "end" and not self.text.strip():
            raise ValueError("开局和局内输入需要用户原话")
        if (self.kind == "end") != (self.reason is not None):
            raise ValueError("只有 end 带有结束原因,并且必须带")
        return self


class GameReply(Wire):
    """游戏 Agent 回给主 Agent 的一条回复:要播报的话和设备指令,一次请求至少回复一条。

    设备指令是机器人 SDK 调用。中间回复可以只有设备指令;最后一条回复必须有要播报的话。
    主 Agent 先执行设备指令,再播报。
    """

    protocol: Version = PROTOCOL_VERSION
    text: str = Field(default="", max_length=500, description="主 Agent 原样播报的面向用户回复")
    directives: list[SdkCall] = Field(
        default_factory=list, max_length=16, description="按顺序执行的机器人 SDK 调用",
    )
    session: Literal["active", "ended"] = Field(
        default="active", description="本局是否继续,只有一次请求的最后一条回复可以是 ended",
    )

    @model_validator(mode="after")
    def validate_content(self):
        if not self.text and not self.directives:
            raise ValueError("回复至少要有要播报的话或设备指令")
        return self


Payload = TypeVar("Payload", GameInput, GameReply)


def to_part(payload: GameInput | GameReply) -> Part:
    return new_data_part(payload.model_dump(mode="json"))


def from_parts(model: type[Payload], parts) -> Payload:
    if len(parts) != 1 or not parts[0].HasField("data"):
        raise ValueError("游戏消息必须包含唯一一个结构化数据 Part")
    return model.model_validate(MessageToDict(parts[0].data))