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