File size: 28,704 Bytes
21e8d36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
"""开发游戏用的命令行工具:

    agentcade-game serve <游戏目录>      单独运行游戏服务,给别人的机器人连
    agentcade-game check <地址或目录>    合规检查:名片、SKILL.md 和工具、完整的一局
    agentcade-game chat  <地址或目录>    文字模拟器:不用机器人,打字和游戏对话
    agentcade-game play  [--game ...]    整条链路的文字模式:经过主 Agent 和所有游戏,不用机器人
    agentcade-game catalog <目录文件>   检查游戏目录 catalog/games.json,CI 和审核时用

游戏目录里要有 SKILL.md 和 agent.py(入口类 GameAgent)。配置先读目录里的 .env,
再用同名环境变量覆盖,部署到服务器时把密钥放在环境变量里。
"""
import argparse
import asyncio
import importlib
import json
import logging
import os
import re
import sys
import time
from contextlib import AsyncExitStack, suppress
from difflib import get_close_matches
from pathlib import Path
from types import SimpleNamespace
from typing import ClassVar
from urllib.parse import urlsplit
from uuid import uuid4

import httpx
from a2a.client import A2ACardResolver, ClientConfig, create_client
from a2a.utils.errors import A2AError
from dotenv import dotenv_values
from pydantic import ValidationError

import observability
from agentcade import catalog, registry
from agentcade.config import SOURCE, LLMSettings, Settings, config_file, resolve_options
from agentcade.game_client import (
    GameConnection,
    GameHub,
    declared_url,
    discover,
    explain,
    message_url,
    pinned,
    protocol_of,
    protocol_skill,
    remote_descriptor,
)
from agentcade.game_server import MAX_SESSIONS, serve_game
from agentcade.main_agent import MainAgent, greeting
from agentcade.protocol import RETIRING, SUPPORTED_PROTOCOLS, GameInput, GameReply
from agentcade.robot_calls import SDK_CALLS, describe_call
from agentcade.robot_calls import problem as call_problem
from agentcade.skill_agent import SkillAgent
from agentcade.tools import collect_tools
from observability.events import encode

# 开局之后多久还没有说第一句话,就建议先说一句“稍等”。
FIRST_REPLY_SECONDS = 8
# 连接和收发消息时可能遇到的错误,检查报告里按一句话原因显示。
ERRORS = (A2AError, httpx.HTTPError, ValueError, RuntimeError, TimeoutError)
# SKILL.md 里写成“调用 工具名”的地方,check 核对这个工具存在。
TOOL_CALL = re.compile(r"调用\s*`?([A-Za-z_][A-Za-z0-9_-]*)")
IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*")


def load_factory(directory: Path):
    """导入游戏目录里的 GameAgent。内置游戏按 games.<id> 导入,其他目录把上一级加入导入路径。"""
    directory = directory.resolve()
    if not (directory / "SKILL.md").is_file() or not (directory / "agent.py").is_file():
        raise SystemExit(f"{directory} 不是游戏目录:需要 SKILL.md 和 agent.py")
    if directory.parent == SOURCE / "games":
        module = f"games.{directory.name}.agent"
    else:
        if str(directory.parent) not in sys.path:
            sys.path.insert(0, str(directory.parent))
        module = f"{directory.name}.agent"
    return importlib.import_module(module).GameAgent


def public_url(value: str | None) -> str | None:
    """名片里对外的地址:参数优先,其次 PUBLIC_URL,在 Hugging Face Space 上用 SPACE_HOST。"""
    if value or os.environ.get("PUBLIC_URL"):
        return (value or os.environ["PUBLIC_URL"]).rstrip("/")
    if os.environ.get("SPACE_HOST"):
        return f"https://{os.environ['SPACE_HOST']}"
    return None


async def open_target(stack: AsyncExitStack, target: str) -> str:
    """目标是目录时在本机起一个服务,返回地址;是地址时检查格式后原样返回。"""
    if Path(target).is_dir():
        try:
            factory = load_factory(Path(target))
        except ValidationError as error:
            raise ValueError(f"SKILL.md 不合规:{explain(error)}") from error
        return await stack.enter_async_context(serve_game(factory, factory.config.from_process()))
    return registry.check_url(target)


async def connect(stack: AsyncExitStack, url: str, timeout: float):
    http = await stack.enter_async_context(httpx.AsyncClient(timeout=timeout, trust_env=False))
    card = await A2ACardResolver(http, url).get_agent_card()
    skill = protocol_skill(card)
    descriptor = remote_descriptor(card, skill)
    # 和小懒一样,消息只发到填写的地址。
    client = await create_client(pinned(card, url), client_config=ClientConfig(
        streaming=True, httpx_client=http, supported_protocol_bindings=["JSONRPC"],
    ))
    stack.push_async_callback(client.close)
    return GameConnection(descriptor, client, descriptor.required_capabilities, protocol_of(skill)), card


def describe(call) -> str:
    """设备指令写成 SDK 的调用形式,参数多时只显示几个,模拟器和检查报告里显示。"""
    return describe_call(call, limit=4)


class Tracer:
    """--trace:游戏 Agent 每一轮的提示词、工具调用和结果,一边发生一边打印。

    系统提示词每个游戏只完整打印一次,之后只打印当前状态。只看得到在这个进程里运行的游戏,
    也就是本地目录和内置游戏;地址上的游戏在它自己的服务器上运行。
    """

    NAMES: ClassVar[dict[str, str]] = {"main": "主 Agent"}

    def __init__(self):
        self.shown: set[str] = set()

    @staticmethod
    def dump(value) -> str:
        return json.dumps(value, ensure_ascii=False, default=encode)

    def __call__(self, record) -> None:
        kind, payload = record["type"], record["payload"]
        actor = self.NAMES.get(record["actor"], record["actor"])
        lines = []
        if kind == "agent.prompt":
            if actor not in self.shown:
                self.shown.add(actor)
                lines += [f"{actor} 的系统提示词:", *(f"  {line}" for line in payload["system"].splitlines())]
            else:
                lines.append(f"{actor} 当前状态 {self.dump(payload['state'])}")
            lines.append(f"{actor} 收到 {self.dump(payload['message'])}")
        elif kind == "tool.call":
            lines.append(f"{actor} 调用 {payload['tool']} {self.dump(payload['arguments'])}")
        elif kind == "tool.result":
            outcome = self.dump(payload["result"]) if payload["ok"] else payload.get("error")
            lines.append(f"{actor} {payload['tool']} → {outcome}({payload['duration_ms']} 毫秒)")
        for line in lines:
            print(f"  ┆ {line}", flush=True)


# ---------- serve ----------

async def serve(args) -> None:
    factory = load_factory(Path(args.directory))
    settings = factory.config.from_process()
    async with serve_game(factory, settings, host=args.host, port=args.port,
                          public_url=public_url(args.public_url),
                          max_sessions=args.max_sessions) as url:
        name = factory.skill.descriptor.name
        print(f"{name} 正在运行,监听 {args.host}:{args.port}", flush=True)
        print(f"用户在设置页面里填写的地址:{url}", flush=True)
        await asyncio.Event().wait()


# ---------- check ----------

class Report:
    SYMBOLS: ClassVar[dict[str, str]] = {"ok": "✓", "warn": "⚠", "fail": "✗"}

    def __init__(self):
        self.failed = False

    def section(self, title: str) -> None:
        print(title, flush=True)

    def add(self, level: str, text: str) -> None:
        self.failed |= level == "fail"
        print(f"  {self.SYMBOLS[level]} {text}", flush=True)


def check_tools(factory, report: Report) -> None:
    """核对 SKILL.md 正文和 @tool 声明的工具:让大模型调用的工具要存在,每个工具都要提到。"""
    builtin = {spec.name for spec in collect_tools(SkillAgent)}
    names = [spec.name for spec in factory.tool_specs]
    instructions = factory.skill.instructions
    unknown = [name for name in dict.fromkeys(TOOL_CALL.findall(instructions)) if name not in names]
    for name in unknown:
        guess = get_close_matches(name, names, n=1)
        report.add("fail", f"SKILL.md 让大模型调用 {name},但没有这个工具"
                           + (f",是不是 {guess[0]}?" if guess else ""))
    mentioned = set(IDENTIFIER.findall(instructions))
    games = [name for name in names if name not in builtin]
    unmentioned = [name for name in games if name not in mentioned]
    if unmentioned:
        report.add("warn", f"工具 {'、'.join(unmentioned)} 在 SKILL.md 里没有提到,"
                           "写清楚什么时候调用它、按结果怎么说,大模型才会用好它")
    if not unknown and not unmentioned:
        report.add("ok", f"SKILL.md 提到了全部 {len(games)} 个工具:{'、'.join(games)}" if games
                   else "游戏没有声明工具")


def extra_fields(reply: GameReply) -> list[str]:
    """回复里协议没有定义的字段,小懒会忽略,多半是写错了名字。"""
    names = list(reply.model_extra or {})
    for index, call in enumerate(reply.directives):
        names += [f"directives[{index}].{name}" for name in call.model_extra or {}]
    return names


async def exchange(connection, context_id, request, report, label, timeout):
    """发一条请求,记录每条回复的时间,检查回复的内容和设备指令。"""
    started = time.monotonic()
    replies = []

    async def collect(reply):
        replies.append((time.monotonic() - started, reply))

    try:
        final = await asyncio.wait_for(
            connection.send(context_id, request, on_reply=collect), timeout,
        )
    except ERRORS as error:
        report.add("fail", f"{label}:请求失败,{explain(error)}")
        return None
    replies.append((time.monotonic() - started, final))
    spoken = next((elapsed for elapsed, reply in replies if reply.text), None)
    report.add("ok", f"{label}:{len(replies)} 条回复,最后一条有话,用时 {replies[-1][0]:.1f} 秒")
    for _, reply in replies:
        actions = "] [".join(describe(item) for item in reply.directives)
        print(f"      {'[' + actions + '] ' if actions else ''}{reply.text}", flush=True)
    if spoken is not None and spoken > FIRST_REPLY_SECONDS:
        report.add("warn", f"{label}:{spoken:.1f} 秒后才说第一句话,处理慢时建议先说一句“稍等”")
    extras = list(dict.fromkeys(name for _, reply in replies for name in extra_fields(reply)))
    if extras:
        report.add("warn", f"{label}:回复里有协议没有定义的字段 {'、'.join(extras)},小懒会忽略,检查是不是写错了名字")
    declared = set(connection.descriptor.required_capabilities)
    problems = []
    for _, reply in replies:
        for call in reply.directives:
            reason = call_problem(call)
            if reason is None and SDK_CALLS[call.call] not in declared:
                reason = f"robot.{call.call} 需要能力 {SDK_CALLS[call.call]},SKILL.md 里没有声明"
            if reason:
                problems.append(reason)
    for reason in dict.fromkeys(problems):
        report.add("fail", f"{label}:{reason},小懒会跳过这条指令")
    return final


async def check(args) -> int:
    report = Report()
    async with AsyncExitStack() as stack:
        report.section("名片")
        try:
            url = await open_target(stack, args.target)
        except ValueError as error:
            report.add("fail", str(error))
            return 1
        if registry.unencrypted(url):
            report.add("warn", f"地址 {url} 用的是 http,传输不加密,用户安装时会看到提醒;有域名的话建议配上 https")
        else:
            report.add("ok", f"地址 {url}")
        try:
            connection, card = await connect(stack, url, args.timeout)
        except ValidationError as error:
            report.add("fail", f"名片不合规:{explain(error)}")
            return 1
        except ERRORS as error:
            report.add("fail", f"名片有问题:{explain(error)}")
            return 1
        descriptor = connection.descriptor
        report.add("ok", f"能读到 {url}/.well-known/agent-card.json,支持流式响应,协议 {connection.protocol}")
        if connection.protocol in RETIRING:
            report.add("warn", f"协议 {connection.protocol} 会在 {RETIRING[connection.protocol]} 停止支持,"
                               f"请升级到 {SUPPORTED_PROTOCOLS[0]}")
        report.add("ok", f"游戏 {descriptor.id}“{descriptor.name}”,简短介绍 {len(descriptor.summary)} 字:"
                         f"{descriptor.summary}")
        declared, target = declared_url(card), message_url(card, url)
        if declared != target:
            report.add("warn", f"名片里的消息地址是 {declared},和填写的地址对不上,小懒会改发到 {target};"
                               f"可以把 PUBLIC_URL 设成 {url},让名片里的地址也对")
        report.add("ok", f"消息发到 {target}")
        unknown = [name for name in descriptor.required_capabilities if name not in registry.PERMISSIONS]
        if unknown:
            report.add("warn", f"声明了核心不认识的能力 {'、'.join(unknown)},安装时会按原名显示")
        if not descriptor.author:
            report.add("warn", "名片里没有作者,用户安装时看不到是谁做的;可以在 SKILL.md 里写 author")

        # 地址上的游戏看不到代码,只核对本地目录里的游戏。
        if Path(args.target).is_dir():
            report.section("SKILL.md 和工具")
            check_tools(load_factory(Path(args.target)), report)

        report.section("一局游戏")
        context_id = uuid4().hex
        start = await exchange(connection, context_id, GameInput(
            kind="start", text=args.start or f"我想玩{descriptor.name}",
        ), report, "开局", args.timeout)
        if start is None:
            return 1
        if start.session != "active":
            report.add("warn", "开局后这一局就结束了")
        else:
            await exchange(connection, context_id, GameInput(kind="message", text=args.say),
                           report, f"说“{args.say}”", args.timeout)
            end = await exchange(connection, context_id, GameInput(kind="end", text="结束游戏", reason="user"),
                                 report, "结束", args.timeout)
            if end is not None:
                report.add("ok" if end.session == "ended" else "fail",
                           "结束后会话状态是 ended" if end.session == "ended"
                           else "结束后会话状态应该是 ended")
        stale = await exchange(connection, uuid4().hex, GameInput(kind="message", text="你好"),
                               report, "发给不存在的一局", args.timeout)
        if stale is not None and stale.session != "ended":
            report.add("fail", "发给不存在的一局时,应该回复这一局已经结束(session 为 ended)")
    print("\n" + ("有不合规的地方,修改后再检查一次。" if report.failed else "检查通过。"), flush=True)
    if not report.failed and not registry.local_host(urlsplit(url).hostname or ""):
        if descriptor.author:
            print("\n想让别人在设置页面的“发现更多游戏”里看到它,把下面这一条加进 catalog/games.json "
                  "的 games,再提一个 PR,见 catalog/README.md:", flush=True)
            print(json.dumps(catalog.entry(url, descriptor), ensure_ascii=False, indent=2), flush=True)
        else:
            print("\n提交到游戏目录之前,先在 SKILL.md 里写上 author。", flush=True)
    return 1 if report.failed else 0


# ---------- catalog ----------

async def verify_listing(listing, report, timeout) -> None:
    """读取游戏名片,核对目录条目和名片一致。"""
    async with httpx.AsyncClient(timeout=timeout, trust_env=False) as http:
        try:
            card = await A2ACardResolver(http, listing.url).get_agent_card()
            declared_url(card)
            descriptor = remote_descriptor(card, protocol_skill(card))
        except (*ERRORS, ValidationError) as error:
            report.add("fail", f"{listing.id}:读取名片失败,{explain(error)}")
            return
    expected = {"ID": listing.id, "名称": listing.name, "简短介绍": listing.summary,
                "版本": listing.version, "能力": sorted(listing.capabilities)}
    actual = {"ID": descriptor.id, "名称": descriptor.name, "简短介绍": descriptor.summary,
              "版本": descriptor.version, "能力": sorted(descriptor.required_capabilities)}
    different = [key for key in expected if expected[key] != actual[key]]
    if different:
        report.add("fail", f"{listing.id}:条目和名片不一致," + ";".join(
            f"{key}条目里是 {expected[key]},名片里是 {actual[key]}" for key in different))
    else:
        report.add("ok", f"{listing.id}:名片和条目一致(版本 {descriptor.version})")


async def check_catalog(args) -> int:
    report = Report()
    report.section("目录文件")
    try:
        data = json.loads(Path(args.file).read_text(encoding="utf-8"))
    except (OSError, ValueError) as error:
        report.add("fail", f"读不了 {args.file}:{error}")
        return 1
    problems = catalog.validate(data, {game.id for game in discover(SOURCE / "games")})
    for problem in problems:
        report.add("fail", problem)
    if problems:
        return 1
    listed, _ = catalog.parse(data)
    report.add("ok", f"格式正确,收录 {len(listed.games)} 个游戏")
    if not args.online:
        return 0
    # 有改动前的目录时只核对新增和改过的条目,没有时核对全部没下架的条目。
    before = {}
    if args.base:
        with suppress(OSError, ValueError):
            old, _ = catalog.parse(json.loads(Path(args.base).read_text(encoding="utf-8")))
            before = {listing.id: listing for listing in old.games}
    changed = [listing for listing in listed.games
               if listing.status == "listed" and before.get(listing.id) != listing]
    report.section("名片")
    if not changed:
        report.add("ok", "没有新增或改过的游戏")
    for listing in changed:
        await verify_listing(listing, report, args.timeout)
    return 1 if report.failed else 0


# ---------- chat ----------

async def chat(args) -> None:
    if args.trace:
        observability.bus.listeners.append(Tracer())
        if not Path(args.target).is_dir():
            print("(--trace 只看得到本地目录里的游戏,这个游戏在它自己的服务器上运行)", flush=True)
    async with AsyncExitStack() as stack:
        url = await open_target(stack, args.target)
        connection, _ = await connect(stack, url, args.timeout)
        name = connection.descriptor.name
        print(f"{name}({url})\n输入要说的话;/end 结束这一局,/quit 退出。\n", flush=True)

        def show(reply):
            if reply.directives:
                print("  [" + "] [".join(describe(item) for item in reply.directives) + "]", flush=True)
            if reply.text:
                print(f"小懒:{reply.text}", flush=True)

        async def say(context_id, request):
            async def intermediate(reply):
                show(reply)
            final = await connection.send(context_id, request, on_reply=intermediate)
            show(final)
            return final.session == "active"

        context_id = None
        while True:
            if context_id is None:
                context_id = uuid4().hex
                if not await say(context_id, GameInput(kind="start", text=args.start or f"我想玩{name}")):
                    context_id = None
            try:
                line = (await asyncio.to_thread(input, "你> ")).strip()
            except EOFError:
                line = "/quit"
            if line in {"/end", "/quit"}:
                if context_id is not None:
                    await say(context_id, GameInput(kind="end", text="结束游戏", reason="user"))
                    context_id = None
                if line == "/quit":
                    return
                print("(这一局结束了,按回车重新开一局,/quit 退出)", flush=True)
                try:
                    if (await asyncio.to_thread(input, "")).strip() == "/quit":
                        return
                except EOFError:
                    # 从文件或管道读入的对话读完了。
                    return
                continue
            if not line:
                continue
            if not await say(context_id, GameInput(kind="message", text=line)):
                context_id = None
                print("(这一局结束了,接着说话会重新开一局)", flush=True)


# ---------- play ----------

def main_settings():
    """文字模式只用到主 Agent 的大模型配置:读主 Agent 的配置文件(有的话),再用同名环境变量覆盖。"""
    options = tuple(option for option in Settings.options() if option.key.startswith("LLM_"))
    path = config_file(Settings.env_file())
    stored = dict(dotenv_values(path, interpolate=False)) if path.is_file() else {}
    stored.update({option.key: os.environ[option.key] for option in options
                   if os.environ.get(option.key)})
    try:
        return SimpleNamespace(llm=LLMSettings.from_values(resolve_options(options, stored)))
    except ValueError as error:
        raise SystemExit(f"主 Agent 的大模型配置不全:{error}。填在 {path},或者设置同名环境变量") from None


def describe_output(output) -> str:
    """输出模块要执行的一项内容写成文字:设备指令放在方括号里。"""
    if output.call is not None:
        return f"[{describe(output.call)}]"
    if output.reset:
        return "[一局结束,复位游戏用过的设备]"
    return f"小懒:{output.text}"


async def show_outputs(agent) -> None:
    """代替语音合成和机器人:把主 Agent 要播报的话和设备指令打印出来,马上算作完成。"""
    while True:
        request = await agent.outputs.get()
        try:
            for output in request.outputs:
                print(describe_output(output), flush=True)
            await agent.output_done(request)
        finally:
            if not request.completed.done():
                request.completed.cancel()
            agent.outputs.task_done()


async def settle(agent) -> None:
    """等游戏把还没发完的回复都发完、都打印出来。"""
    while agent.busy:
        await asyncio.sleep(0.1)
    await agent.outputs.join()


async def play(args) -> None:
    if args.trace:
        observability.bus.listeners.append(Tracer())
    async with AsyncExitStack() as stack:
        hub = GameHub()
        # 设备指令都显示成文字,机器人的能力全部算作支持。
        hub.supports = lambda capability: True
        stack.push_async_callback(hub.close)
        await hub.start()
        for game_id, reason in hub.errors.items():
            print(f"({game_id} 没有接入:{reason})", flush=True)
        # 正在开发的游戏直接接入,和已有的游戏同名时代替它。
        for target in args.game:
            url = await open_target(stack, target)
            connection, _ = await connect(stack, url, args.timeout)
            hub.games[connection.descriptor.id] = connection
        agent = MainAgent(main_settings(), hub.games)
        hub.in_use = lambda game_id: agent.active is not None and agent.active.game_id == game_id
        stack.push_async_callback(agent.close)
        printer = asyncio.create_task(show_outputs(agent))
        stack.callback(printer.cancel)

        async def tick():
            while True:
                await agent.tick()
                await asyncio.sleep(0.1)

        ticker = asyncio.create_task(tick())
        stack.callback(ticker.cancel)
        piped = not sys.stdin.isatty()
        print("输入要说的话;/wait 等游戏把话说完,/quit 退出。\n", flush=True)
        await agent.reply(greeting(hub.games))
        await settle(agent)
        while True:
            try:
                line = (await asyncio.to_thread(input, "" if piped else "你> ")).strip()
            except EOFError:
                # 脚本读完了,等游戏把最后的话说完再退出。
                await settle(agent)
                return
            if piped and line:
                print(f"你> {line}", flush=True)
            if line == "/quit":
                return
            if line == "/wait":
                await settle(agent)
                continue
            if not line:
                continue
            turn_id = observability.user_input(line, time.monotonic())
            with observability.turn(turn_id):
                completion = await agent.receive(line)
            # 和机器人一样,第一句话说完才接着听下一句。
            if completion is not None:
                await asyncio.shield(completion)


def main() -> None:
    parser = argparse.ArgumentParser(prog="agentcade-game", description="开发和部署 Agentcade 游戏")
    commands = parser.add_subparsers(dest="command", required=True)

    run = commands.add_parser("serve", help="单独运行游戏服务")
    run.add_argument("directory", help="游戏目录,里面有 SKILL.md 和 agent.py")
    run.add_argument("--host", default="0.0.0.0")
    run.add_argument("--port", type=int, default=7860)
    run.add_argument("--public-url", help="用户访问的地址,默认取 PUBLIC_URL,或者 Space 的 SPACE_HOST")
    run.add_argument("--max-sessions", type=int, default=MAX_SESSIONS, help="同时进行的局数上限")

    for name, text in (("check", "合规检查"), ("chat", "文字模拟器")):
        command = commands.add_parser(name, help=text)
        command.add_argument("target", help="游戏地址,或者本地游戏目录")
        command.add_argument("--start", help="开局时用户说的话,默认“我想玩<游戏名>”")
        command.add_argument("--timeout", type=float, default=300, help="每个请求最长等待的秒数")
        if name == "check":
            command.add_argument("--say", default="你好", help="开局后说的一句话")
        else:
            command.add_argument("--trace", action="store_true",
                                 help="打印游戏的提示词、工具调用和结果,只对本地目录里的游戏有效")

    whole = commands.add_parser("play", help="整条链路的文字模式:经过主 Agent 和所有游戏")
    whole.add_argument("--game", action="append", default=[],
                       help="另外接入的游戏地址或者本地目录,可以写多次;和已有游戏同名时代替它")
    whole.add_argument("--timeout", type=float, default=300, help="每个请求最长等待的秒数")
    whole.add_argument("--trace", action="store_true",
                       help="打印主 Agent 的工具调用,以及内置游戏和本地目录里游戏的提示词、工具调用和结果")

    listed = commands.add_parser("catalog", help="检查游戏目录文件")
    listed.add_argument("file", help="目录文件,一般是 catalog/games.json")
    listed.add_argument("--online", action="store_true", help="读取游戏名片,核对条目和名片一致")
    listed.add_argument("--base", help="改动前的目录文件,只核对新增和改过的条目")
    listed.add_argument("--timeout", type=float, default=60, help="读取每个名片最长等待的秒数")

    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO if args.command == "serve" else logging.WARNING,
                        format="%(asctime)s %(levelname)s %(message)s")
    try:
        if args.command == "serve":
            asyncio.run(serve(args))
        elif args.command == "check":
            sys.exit(asyncio.run(check(args)))
        elif args.command == "catalog":
            sys.exit(asyncio.run(check_catalog(args)))
        elif args.command == "play":
            asyncio.run(play(args))
        else:
            asyncio.run(chat(args))
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()