selfcheck.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """结构自检:导入 → 建表 → checkpointer → agent 编译 → 工具注册 → 权限。
  2. 比 pytest 更「从头到尾」,比 http_e2e 更轻——改完结构(加模块、换依赖、
  3. 动 agent 组装)先跑这个,能在几秒内定位「哪一层断了」,不必起服务。
  4. 不需要模型网络:agent 编译只构造图,不发请求。
  5. 用法:python scripts/selfcheck.py
  6. 退出码 0 表示全部通过。
  7. """
  8. from __future__ import annotations
  9. import asyncio
  10. import importlib
  11. import json
  12. import sys
  13. import traceback
  14. from pathlib import Path
  15. # 让脚本能从任意目录运行
  16. ROOT = Path(__file__).resolve().parent.parent
  17. if str(ROOT) not in sys.path:
  18. sys.path.insert(0, str(ROOT))
  19. FAILED: list[str] = []
  20. def step(name: str, fn) -> None:
  21. """跑一步检查;异常记为失败但不中断后续步骤(一次看全所有问题)。"""
  22. try:
  23. detail = fn()
  24. print(f" [OK] {name}" + (f" — {detail}" if detail else ""))
  25. except Exception as exc:
  26. FAILED.append(name)
  27. print(f" [FAIL] {name} — {type(exc).__name__}: {exc}")
  28. traceback.print_exc(limit=4)
  29. # 需要走一遍导入链的模块:漏掉一个都可能让「测试过了但服务起不来」
  30. MODULES = [
  31. "config", "obs",
  32. "db.db_conn", "db.chat_store", "db.schedule_store", "db.billing_store",
  33. "billing.pricing", "billing.guard", "billing.meter",
  34. "tools.registry", "tools.demo_tools",
  35. "agents.model_factory", "agents.prompts", "agents.middleware",
  36. "agents.agent_memory", "agents.agent_factory", "agents.agent_registry",
  37. "api.sse_events", "api.subagent_tracker", "api.context_meter",
  38. "api.source_tracker", "api.concurrency", "api.sse_core", "api.models",
  39. "api.chat_routes", "api.session_routes", "api.model_routes",
  40. "api.usage_routes", "api.schedule_routes", "api",
  41. "scheduler.runner", "scheduler.scheduler", "main",
  42. ]
  43. def check_imports() -> str:
  44. for name in MODULES:
  45. importlib.import_module(name)
  46. return f"{len(MODULES)} 个模块"
  47. def check_syntax() -> str:
  48. """静态语法检查:抓出导入不到的孤儿模块(比如新写但没被 import 的文件)。"""
  49. import ast
  50. files = [
  51. p for p in ROOT.rglob("*.py")
  52. if not any(part in {".venv", "venv", "__pycache__", "data", ".tmp"} for part in p.parts)
  53. ]
  54. bad: list[str] = []
  55. for path in files:
  56. try:
  57. ast.parse(path.read_text(encoding="utf-8"))
  58. except SyntaxError as exc:
  59. bad.append(f"{path.relative_to(ROOT)}:{exc.lineno}")
  60. if bad:
  61. raise SyntaxError("语法错误:" + ", ".join(bad))
  62. return f"{len(files)} 个文件"
  63. def check_schema() -> str:
  64. from db import db_conn
  65. db_conn.init_schema()
  66. health = db_conn.health()
  67. if health.get("status") != "ok":
  68. raise RuntimeError(f"存储不可用:{health}")
  69. return f"{health['dialect']} → {health['target']}"
  70. def check_pricing() -> str:
  71. from billing import pricing
  72. created = pricing.seed_default_pricing()
  73. from db import billing_store
  74. missing = [m for m in {pricing.config.MODEL, pricing.config.SUMMARY_MODEL}
  75. if m and billing_store.get_pricing(m) is None]
  76. if missing:
  77. raise RuntimeError(f"模型未定价(会在请求开始时 fail-closed):{missing}")
  78. return f"新增 {created} 条,当前模型已覆盖"
  79. def check_permissions() -> str:
  80. """权限规则顺序:具体的 allow 必须在宽泛的 deny 之前,否则被短路。"""
  81. from agents.agent_factory import build_permissions
  82. rules = build_permissions()
  83. modes = [r.mode for r in rules]
  84. if "deny" in modes and modes.index("deny") != len(modes) - 1:
  85. raise RuntimeError(
  86. f"deny 规则必须排在最后(首条命中即返回,提前会短路后面的 allow):{modes}"
  87. )
  88. return " · ".join(f"{'/'.join(r.operations)}={'+'.join(r.paths[:1])}:{r.mode}" for r in rules)
  89. def check_tools() -> str:
  90. from tools import registry
  91. count = registry.load_builtin_tools()
  92. if count == 0:
  93. raise RuntimeError(
  94. "工具注册表为空——检查 tools/registry._BUILTIN_MODULES 与模块里的 @register"
  95. )
  96. for tool in registry.get_all_tools():
  97. if not hasattr(tool, "name"):
  98. raise RuntimeError(f"工具 {tool!r} 不是 BaseTool(子智能体的 tools 需要 .name)")
  99. groups = registry.get_group_map()
  100. return f"{count} 个 / {len(groups)} 组:{', '.join(groups)}"
  101. def check_import_boundaries() -> str:
  102. """确认工具层没有反向依赖 agents(分层纪律,见 AGENTS.md §三)。"""
  103. import pathlib
  104. offenders: list[str] = []
  105. for path in (ROOT / "tools").rglob("*.py"):
  106. text = path.read_text(encoding="utf-8")
  107. for line in text.splitlines():
  108. stripped = line.strip()
  109. if stripped.startswith(("import agents", "from agents")):
  110. offenders.append(f"{path.name}: {stripped}")
  111. if offenders:
  112. raise RuntimeError(f"tools/ 不得依赖 agents/:{offenders}")
  113. return "tools/ 与 billing/ 未反向依赖上层"
  114. async def check_agent() -> str:
  115. from agents import agent_factory, agent_memory, agent_registry
  116. await agent_memory.init_checkpointer()
  117. agent = await asyncio.to_thread(agent_registry.get_agent)
  118. if agent is None:
  119. raise RuntimeError("agent 编译返回 None")
  120. info = agent_factory.describe()
  121. if not info["tools"]["count"]:
  122. raise RuntimeError("agent 没有挂任何工具")
  123. if not info["interrupt_on"]:
  124. raise RuntimeError("interrupt_on 为空:中断/恢复协议会失效")
  125. await agent_memory.close_checkpointer()
  126. return (
  127. f"{info['name']} · 工具 {info['tools']['count']} · "
  128. f"子智能体 {info['subagents']} · 审批 {info['interrupt_on']} · "
  129. f"压缩 {info['compaction']['enabled']}"
  130. )
  131. def check_contract() -> str:
  132. """SSE 契约自检:事件类型数量与帧格式。"""
  133. from api.sse_events import ALL_EVENT_TYPES, EventWriter
  134. if len(ALL_EVENT_TYPES) != 15:
  135. raise RuntimeError(f"事件类型应为 15 种,实际 {len(ALL_EVENT_TYPES)}")
  136. frame = EventWriter("s", "t").token("x")
  137. if not frame.startswith("data: ") or not frame.endswith("\n\n"):
  138. raise RuntimeError(f"帧格式不对:{frame!r}")
  139. payload = json.loads(frame[6:])
  140. if payload["session_id"] != "s" or payload["trace_id"] != "t":
  141. raise RuntimeError(f"帧缺少 session_id/trace_id:{payload}")
  142. return f"{len(ALL_EVENT_TYPES)} 种事件"
  143. def main() -> int:
  144. print("deep_agent 2.0 结构自检")
  145. print(f"项目根:{ROOT}")
  146. print(f"方言:{__import__('os').getenv('DB_DIALECT') or '(默认 mysql)'}")
  147. print()
  148. step("Python 源码语法", check_syntax)
  149. step("模块导入链", check_imports)
  150. step("建表与存储连通", check_schema)
  151. step("计费单价覆盖", check_pricing)
  152. step("SSE 契约", check_contract)
  153. step("权限规则顺序", check_permissions)
  154. step("工具注册", check_tools)
  155. step("分层依赖", check_import_boundaries)
  156. step("agent 编译", lambda: asyncio.run(check_agent()))
  157. print()
  158. if FAILED:
  159. print(f"失败 {len(FAILED)} 项:{', '.join(FAILED)}")
  160. return 1
  161. print("全部通过")
  162. return 0
  163. if __name__ == "__main__":
  164. sys.exit(main())