left0ver's picture
Publish com.left0ver.agentcade 0.1.0
ad88834 verified
Raw History Blame Contribute Delete
7.43 kB
import asyncio
import logging
import socket
from contextlib import asynccontextmanager, nullcontext
import uvicorn
from a2a.server.agent_execution import AgentExecutor, RequestContext
from a2a.server.events import EventQueue
from a2a.server.request_handlers import DefaultRequestHandler
from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes
from a2a.server.tasks import TaskUpdater
from a2a.types import (
AgentCapabilities,
AgentCard,
AgentInterface,
AgentSkill,
Task,
TaskState,
TaskStatus,
)
from sse_starlette.sse import EventSourceResponse
from starlette.applications import Starlette
from starlette.routing import Route
import observability
from agentcade.protocol import PROTOCOL_VERSION, GameInput, GameReply, from_parts, to_part
from agentcade.task_store import GameTaskStore
logger = logging.getLogger(__name__)
class GameExecutor(AgentExecutor):
def __init__(self, factory, settings, device):
self.factory, self.settings, self.device = factory, settings, device
self.agent = None
self.context_id = None
self.lock = asyncio.Lock()
self.turn: asyncio.Task | None = None
async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
message = context.message
if message is None:
raise ValueError("游戏请求缺少消息")
request = from_parts(GameInput, message.parts)
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
await event_queue.enqueue_event(Task(
id=context.task_id, context_id=context.context_id,
status=TaskStatus(state=TaskState.TASK_STATE_WORKING), history=[message],
))
async def emit(reply):
await updater.update_status(
TaskState.TASK_STATE_WORKING, updater.new_agent_message([to_part(reply)]),
)
if request.kind != "message":
await self.cancel_turn()
# 同一局的请求按到达顺序逐条处理。
async with self.lock:
if request.kind == "start":
# 上一局没有正常结束时(例如请求中途失败),新的一局直接替换它。
await self.release()
self.agent = self.factory(self.settings, self.device)
self.context_id = context.context_id
if self.agent is None or self.context_id != context.context_id:
# 这一局已经不存在,回复本局结束,主 Agent 据此清除自己的会话。
text = ("好,这局已经结束了。" if request.kind == "end"
else "这一局已经结束了,想玩的话再说一次开始吧。")
await updater.complete(updater.new_agent_message([
to_part(GameReply(text=text, session="ended")),
]))
return
agent = self.agent
work = self.turn = asyncio.create_task(
self.run(agent, request, emit, message.message_id),
)
try:
reply = await work
except asyncio.CancelledError:
# end 请求取消了这一轮;框架取消整个请求时继续向外传播。
if asyncio.current_task().cancelling():
raise
await updater.cancel()
return
finally:
self.turn = None
if reply.session == "ended":
await self.release()
await updater.complete(updater.new_agent_message([to_part(reply)]))
async def run(self, agent, request, emit, request_id):
with observability.game_request(self.factory.skill.descriptor.id, request_id):
return await agent.handle(request, emit)
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
raise ValueError("游戏使用 end 消息结束会话")
async def cancel_turn(self):
work = self.turn
if work is not None and not work.done():
work.cancel()
await asyncio.wait({work})
async def release(self):
agent, self.agent, self.context_id = self.agent, None, None
if agent is not None:
try:
await agent.close()
except Exception:
logger.exception("关闭游戏 Agent 失败")
async def close(self):
await self.cancel_turn()
await self.release()
class EmbeddedServer(uvicorn.Server):
def capture_signals(self):
return nullcontext()
class GameEventSourceResponse(EventSourceResponse):
def __init__(self, response, shutdown):
super().__init__(response.body_iterator, headers=response.headers)
self.shutdown = shutdown
async def _listen_for_exit_signal(self):
# 每个内嵌服务独立管理 SSE 退出,关闭一个游戏不会影响其他游戏。
await self.shutdown.wait()
@asynccontextmanager
async def serve_game(factory, settings, device, *, task_history_limit=64):
descriptor = factory.skill.descriptor
listener = socket.socket()
listener.bind(("127.0.0.1", 0))
url = f"http://127.0.0.1:{listener.getsockname()[1]}"
card = AgentCard(
name=descriptor.name, description=descriptor.description, version="1.0.0",
capabilities=AgentCapabilities(streaming=True),
default_input_modes=["application/json"], default_output_modes=["application/json"],
skills=[AgentSkill(
id=descriptor.id, name=descriptor.name, description=descriptor.description,
tags=[PROTOCOL_VERSION, *[
f"capability:{name}" for name in descriptor.required_capabilities
]], examples=descriptor.aliases,
)],
supported_interfaces=[AgentInterface(
protocol_binding="JSONRPC", protocol_version="1.0", url=url + "/a2a",
)],
)
executor = GameExecutor(factory, settings, device)
handler = DefaultRequestHandler(
agent_executor=executor, task_store=GameTaskStore(max_completed=task_history_limit),
agent_card=card,
)
shutdown = asyncio.Event()
rpc_route, = create_jsonrpc_routes(handler, rpc_url="/a2a")
async def rpc(request):
response = await rpc_route.endpoint(request)
if isinstance(response, EventSourceResponse):
return GameEventSourceResponse(response, shutdown)
return response
app = Starlette(routes=create_agent_card_routes(card) + [
Route("/a2a", rpc, methods=["POST"]),
])
server = EmbeddedServer(uvicorn.Config(
app, log_level="error", lifespan="off", timeout_graceful_shutdown=10,
))
serving = asyncio.create_task(server.serve(sockets=[listener]))
try:
async with asyncio.timeout(10):
while not server.started:
if serving.done():
await serving
raise RuntimeError("A2A 服务没有启动")
await asyncio.sleep(0.01)
yield url
finally:
server.should_exit = True
try:
await executor.close()
finally:
shutdown.set()
try:
await serving
finally:
try:
await handler.aclose()
finally:
listener.close()