left0ver's picture
Publish com.left0ver.agentcade 0.2.0
21e8d36 verified
Raw History Blame Contribute Delete
15.9 kB
import asyncio
import json
import logging
from collections import deque
from dataclasses import dataclass
from typing import Literal
from uuid import uuid4
from pydantic import Field, create_model
import observability
from agentcade.config import Settings
from agentcade.protocol import GameInput, Presentation
from agentcade.providers import OpenAIProvider
from agentcade.robot_calls import SDK_CALLS, problem
from agentcade.tools import NoInput, Tool, ToolInput, ToolRegistry, original_text_input
from observability.events import SCOPE
logger = logging.getLogger(__name__)
# 游戏请求失败时对用户说的话,按请求类型区分。
GAME_FAILURE_REPLIES = {
"start": "这个游戏现在开不了,等一下再试试吧。",
"message": "游戏那边没有反应,你再说一遍好吗?",
"end": "好,这局先结束了。",
}
class StartGameInput(ToolInput):
game_id: str = Field(min_length=1, description="list_games 返回的游戏 ID")
SYSTEM_PROMPT = """你叫小懒,是一台桌面机器人,陪用户聊天、带用户玩游戏。
你的回复会被原样念出来:只写要说的话,通常一至三句、120字以内,用简短自然的口语,语气友好,不刻意卖萌。
current_utterance 来自语音识别,可能有同音错字或只有半句,按读音和上下文理解;实在听不懂就请用户再说一遍。
history 是之前的对话,role=game_agent 的内容是游戏里说过的话。
面向用户只说游戏名称和玩法,不提 Agent、工具、会话、协议或内部状态字段。
点头、摇头、表情和画画只在游戏里才会做。聊天时用户让你做动作,如实说现在做不了,不要假装已经做了。
游戏:
可玩的游戏列在末尾的目录里,名称、别名、介绍和顺序都以目录为准,不补充目录里没有的游戏。用户问有哪些游戏时,按目录简短介绍怎么玩。
闲聊时不提游戏:不推荐,也不用“想玩随时说”这类话收尾;用户自己问起游戏才介绍。
规则、题目、答案、提示、判定和计分都由游戏负责,你不能自己生成或宣布。
工具:
start_game、end_game、send_message 一调用,这一轮就结束,游戏的回复会直接念给用户,所以每轮只选最该做的那一个。
- active_game 为 null,用户要开始、再玩一局或重新开始某个游戏:立即调用 start_game,不用再确认,刚玩过同一个游戏也一样。
- 有游戏在进行,用户的话和这局有关(回答、要提示、跳题、问规则、次数、得分、进度、说继续):调用 send_message 传 current_utterance 完整原文,不根据历史自己回答。active_game.busy 为 true 时也一样。
- 用户要结束当前游戏:调用 end_game。用户说“结束,然后玩另一个”时也调用 end_game。
- 有游戏在进行,用户想换游戏:不转给游戏,直接告诉他先说“结束游戏”;没说换哪个时,顺便说可以换成什么。
- 普通聊天和知识问题:直接回答,不调用工具。答完就等用户说话,不催促答题,也不替游戏出题。"""
def ordinal(number: int) -> str:
"""开机问候里的序号,十以内念成中文数字。"""
return "一二三四五六七八九十"[number - 1] if 1 <= number <= 10 else str(number)
def greeting(games) -> str:
"""开机问候:逐个介绍可以玩的游戏。"""
listed = ";".join(
f"第{ordinal(index)}个游戏是{game.descriptor.name},它的玩法是{game.descriptor.summary}"
for index, game in enumerate(games.values(), 1))
if not listed:
return "你好呀,我是小懒!现在还没有可玩的游戏,我们可以聊聊天。"
return f"你好呀,我是小懒!现在可以玩这些游戏:{listed}。你可以跟我说你想玩的游戏,我也可以跟你聊天。"
@dataclass
class ActiveGame:
game_id: str
context_id: str
closing: bool = False
@dataclass
class OutputRequest:
outputs: list[Presentation]
completed: asyncio.Future
trace_context: dict[str, str | None]
role: str = "assistant"
class MainAgent:
def __init__(self, settings: Settings, games):
self.provider = OpenAIProvider(settings.llm)
self.games = games
self.active: ActiveGame | None = None
self.history = deque(maxlen=48)
self.outputs: asyncio.Queue[OutputRequest] = asyncio.Queue(32)
self.lock = asyncio.Lock()
self.input_lock = asyncio.Lock()
self.pending: set[asyncio.Task] = set()
self.closed = False
def system_prompt(self):
catalog = [game.descriptor.catalog() for game in self.games.values()]
return (
SYSTEM_PROMPT
+ "\n\n当前可用游戏:\n"
+ json.dumps(catalog, ensure_ascii=False, indent=2)
)
def game_name(self, game_id):
connection = self.games.get(game_id)
return connection.descriptor.name if connection is not None else game_id
@property
def busy(self):
return any(not task.done() for task in self.pending)
def status(self):
if self.active is None:
return None
return {
"game_id": self.active.game_id,
"name": self.game_name(self.active.game_id),
"busy": self.busy,
"closing": self.active.closing,
}
async def emit(self, outputs, *, role="assistant"):
completion = asyncio.get_running_loop().create_future()
self.outputs.put_nowait(OutputRequest(
outputs, completion, {name: variable.get() for name, variable in SCOPE.items()}, role,
))
return completion
async def reply(self, text, *, role="assistant"):
observability.reply(text, role)
return await self.emit([Presentation(text=text)], role=role)
async def receive(self, text):
async with self.input_lock:
completions = []
registry = self.tools_for(text, completions)
history = list(self.history)
self.history.append({"role": "user", "content": text})
try:
response = await self.provider.run_tools([
{"role": "system", "content": self.system_prompt()},
{"role": "user", "content": json.dumps({
"history": history,
"active_game": self.status(),
"current_utterance": text,
}, ensure_ascii=False)},
], registry)
except Exception:
# 模型服务出错或超时只影响这一句话,说明情况后继续听下一句。
logger.exception("主 Agent 处理用户输入失败")
if completions:
return completions[-1]
return await self.reply("我刚才没反应过来,你再说一遍好吗?")
if completions:
return completions[-1]
if response:
return await self.reply(response)
return None
def tools_for(self, utterance, completions):
operated = False
message_input = original_text_input(utterance)
start_input = create_model(
"RegisteredGameInput", __base__=StartGameInput,
game_id=(Literal[tuple(self.games)] if self.games else str,
Field(description="注册目录中的游戏 ID")),
)
def claim():
nonlocal operated
if operated:
raise ValueError("每轮只能执行一次游戏会话操作")
operated = True
async def notice(message):
completions.append(await self.reply(message))
return {"message": message}
async def list_games():
return [game.descriptor.catalog() for game in self.games.values()]
async def start_game(game_id):
async with self.lock:
claim()
if self.active is not None:
name = self.game_name(self.active.game_id)
return await notice(f"我们还在玩{name}呢。想换一个的话,先说结束游戏就好。")
if game_id not in self.games:
raise ValueError("主 Agent 选择了未注册的游戏")
self.active = ActiveGame(game_id, uuid4().hex)
completions.append(await self.send(GameInput(kind="start", text=utterance)))
return {"delivered": True, "game": self.status()}
def unavailable():
if self.active is None:
return "我们还没开始玩呢。想玩什么?也可以问我有哪些游戏。"
if self.active.closing:
return "这局马上就结束了,稍等一下。"
return None
async def end_game():
async with self.lock:
claim()
message = unavailable()
if message:
return await notice(message)
self.active.closing = True
await self.cancel_pending()
completions.append(await self.send(
GameInput(kind="end", text=utterance, reason="user"),
))
return {"delivered": True, "game": self.status()}
async def send_message(text):
async with self.lock:
claim()
message = unavailable()
# 游戏逐条处理输入,上一条的回复还没结束时不再发送新的输入。
if message is None and self.busy:
message = "我还在准备呢,稍等一下哦。"
if message:
return await notice(message)
completions.append(await self.send(GameInput(kind="message", text=text)))
return {"delivered": True, "game": self.status()}
return ToolRegistry([
Tool("list_games", "列出可用游戏的 ID、名称、描述和别名。", NoInput, list_games),
Tool("start_game", "开始指定游戏,建立会话并把用户原话发给游戏 Agent。", start_input, start_game),
Tool("end_game", "结束当前游戏并取消未完成的游戏请求。", NoInput, end_game),
Tool("send_message", "将用户原话发送给当前游戏 Agent。", message_input, send_message),
], terminal={"start_game", "end_game", "send_message"})
async def send(self, request):
"""把一条输入发给当前游戏,返回第一条带话的回复的播报结果;其余回复到达后继续播报。"""
active = self.active
request_id = uuid4().hex
first = asyncio.get_running_loop().create_future()
async def deliver(reply):
if self.active is not active:
return
completion = await self.accept(active.game_id, request_id, reply)
# 只有设备指令的回复(例如点头)不结束这一轮,等到第一句话播完再恢复收音。
if reply.text and not first.done():
first.set_result(completion)
async def exchange():
try:
await deliver(await self.games[active.game_id].send(
active.context_id, request, on_reply=deliver, request_id=request_id,
))
except Exception:
logger.exception("游戏请求 %s 失败", request.kind)
if self.active is not active:
return
# 开局和结束失败时不保留这一局,局内输入失败时用户可以重说。
if request.kind != "message":
self.active = None
completion = await self.reply(GAME_FAILURE_REPLIES[request.kind])
if self.active is None:
await self.emit([Presentation(reset=True)])
if not first.done():
first.set_result(completion)
task = asyncio.create_task(exchange())
self.pending.add(task)
await asyncio.wait({first, task}, return_when=asyncio.FIRST_COMPLETED)
if task.done():
self.pending.discard(task)
await task
if not first.done():
# 会话在等待期间被清除(例如正在退出),没有需要播报的内容。
first.set_result([])
return first
return first.result()
async def accept(self, game_id, request_id, reply):
"""先执行回复里的设备指令,再播报要说的话;这一局结束时,说完再把设备复位。"""
ended = reply.session == "ended"
if ended:
self.active = None
completion = None
outputs = self.permitted(game_id, reply.directives)
if outputs:
# 设备指令记在发出它的游戏名下,和游戏处理请求时的其他记录排在一起。
with observability.game_request(game_id, request_id):
completion = await self.emit(outputs, role="game_agent")
if reply.text:
completion = await self.reply(reply.text, role="game_agent")
if ended:
await self.emit([Presentation(reset=True)])
return completion
def permitted(self, game_id, calls) -> list[Presentation]:
"""游戏只能调用开放的 SDK 方法,并且只能用用户同意给它的能力,其余调用跳过并记录。"""
connection = self.games.get(game_id)
granted = connection.granted if connection is not None else set()
allowed = []
for call in calls:
reason = problem(call)
if reason is None and SDK_CALLS[call.call] not in granted:
reason = f"{call.call} 需要能力 {SDK_CALLS[call.call]},但没有得到允许"
if reason:
logger.warning("游戏 %s 的设备指令已跳过:%s", game_id, reason)
continue
allowed.append(Presentation(call=call))
return allowed
async def output_done(self, request):
async with self.lock:
for output in request.outputs:
if output.text:
self.history.append({"role": request.role, "content": output.text})
request.completed.set_result([output.text for output in request.outputs if output.text])
async def tick(self):
if self.input_lock.locked():
return
for task in list(self.pending):
if task.done():
self.pending.remove(task)
if not task.cancelled() and task.exception() is not None:
logger.error("游戏请求任务异常结束", exc_info=task.exception())
async def cancel_pending(self):
pending = list(self.pending)
for task in pending:
task.cancel()
if pending:
results = await asyncio.gather(*pending, return_exceptions=True)
for result in results:
if isinstance(result, Exception):
logger.error("游戏请求任务异常结束", exc_info=result)
self.pending.clear()
async def close(self):
if self.closed:
return
self.closed = True
await self.cancel_pending()
try:
if self.active and not self.active.closing:
await self.games[self.active.game_id].send(
self.active.context_id, GameInput(kind="end", reason="shutdown"),
)
except Exception:
# 退出时通知游戏失败不影响关闭流程。
logger.exception("退出时结束游戏失败")
finally:
self.active = None
await self.provider.close()