selfcheck.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  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", "extensions.registry", "extensions.services",
  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. "runtime.environment", "runtime.engine", "runtime.execution", "runtime.events", "runtime.interrupts",
  40. "runtime.concurrency", "runtime.context_meter", "runtime.source_tracker", "runtime.subagent_tracker",
  41. "api.request_logging", "api.chat_routes", "api.session_routes", "api.model_routes",
  42. "api.usage_routes", "api.schedule_routes", "api.algorithm_routes", "api",
  43. "algorithms.characteristic_curve_recognition.service", "algorithms.characteristic_curve_recognition.worker",
  44. "algorithms.characteristic_curve_recognition.batch",
  45. "algorithms.characteristic_curve_recognition.vendor.chart_axis_detector.current_image_pipeline",
  46. "scheduler.runner", "scheduler.scheduler", "main",
  47. ]
  48. def check_imports() -> str:
  49. for name in MODULES:
  50. importlib.import_module(name)
  51. return f"{len(MODULES)} 个模块"
  52. def check_syntax() -> str:
  53. """静态语法检查:抓出导入不到的孤儿模块(比如新写但没被 import 的文件)。"""
  54. import ast
  55. files = [
  56. p for p in ROOT.rglob("*.py")
  57. if not any(part in {".venv", "venv", "__pycache__", "data", ".tmp"} for part in p.parts)
  58. ]
  59. bad: list[str] = []
  60. for path in files:
  61. try:
  62. ast.parse(path.read_text(encoding="utf-8"))
  63. except SyntaxError as exc:
  64. bad.append(f"{path.relative_to(ROOT)}:{exc.lineno}")
  65. if bad:
  66. raise SyntaxError("语法错误:" + ", ".join(bad))
  67. return f"{len(files)} 个文件"
  68. def check_schema() -> str:
  69. from db import db_conn
  70. db_conn.init_schema()
  71. health = db_conn.health()
  72. if health.get("status") != "ok":
  73. raise RuntimeError(f"存储不可用:{health}")
  74. return f"{health['dialect']} → {health['target']}"
  75. def check_pricing() -> str:
  76. from billing import pricing
  77. created = pricing.seed_default_pricing()
  78. from db import billing_store
  79. missing = [m for m in {pricing.config.MODEL}
  80. if m and billing_store.get_pricing(m) is None]
  81. if missing:
  82. raise RuntimeError(f"模型未定价(会在请求开始时 fail-closed):{missing}")
  83. return f"新增 {created} 条,当前模型已覆盖"
  84. def check_permissions() -> str:
  85. """权限规则顺序:具体的 allow 必须在宽泛的 deny 之前,否则被短路。"""
  86. from agents.agent_factory import build_permissions
  87. rules = build_permissions()
  88. modes = [r.mode for r in rules]
  89. if "deny" in modes and modes.index("deny") != len(modes) - 1:
  90. raise RuntimeError(
  91. f"deny 规则必须排在最后(首条命中即返回,提前会短路后面的 allow):{modes}"
  92. )
  93. return " · ".join(f"{'/'.join(r.operations)}={'+'.join(r.paths[:1])}:{r.mode}" for r in rules)
  94. def check_tools() -> str:
  95. from tools import registry
  96. count = registry.load_builtin_tools()
  97. if count == 0:
  98. raise RuntimeError(
  99. "工具注册表为空——检查 extensions/*/extension.py 的 setup(registry)"
  100. )
  101. for tool in registry.get_all_tools():
  102. if not hasattr(tool, "name"):
  103. raise RuntimeError(f"工具 {tool!r} 不是 BaseTool(子智能体的 tools 需要 .name)")
  104. groups = registry.get_group_map()
  105. return f"{count} 个 / {len(groups)} 组:{', '.join(groups)}"
  106. def check_import_boundaries() -> str:
  107. """防止工具反向依赖 Agent,以及执行层反向依赖 HTTP。"""
  108. import ast
  109. offenders = []
  110. for directory in ("tools", "runtime", "agents", "scheduler"):
  111. forbidden = {"agents"} if directory == "tools" else {"api", "fastapi", "starlette"}
  112. for path in (ROOT / directory).rglob("*.py"):
  113. for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
  114. modules = ([node.module or ""] if isinstance(node, ast.ImportFrom) else
  115. [alias.name for alias in node.names] if isinstance(node, ast.Import) else [])
  116. for module in modules:
  117. if module.split(".")[0] in forbidden:
  118. offenders.append(f"{path.relative_to(ROOT)} -> {module}")
  119. if offenders:
  120. raise RuntimeError(f"反向依赖:{offenders}")
  121. return "工具层不依赖 Agent;runtime/agents/scheduler 不依赖 HTTP 层"
  122. async def check_agent() -> str:
  123. from agents import agent_factory, agent_registry
  124. from runtime.environment import RuntimeEnvironment
  125. async with RuntimeEnvironment(prewarm=False, billing_retry=False, recover_interrupted=False):
  126. agent = await asyncio.to_thread(agent_registry.get_agent)
  127. if agent is None:
  128. raise RuntimeError("agent 编译返回 None")
  129. info = agent_factory.describe()
  130. if not info["tools"]["count"]:
  131. raise RuntimeError("agent 没有挂任何工具")
  132. if not info["interrupt_on"]:
  133. raise RuntimeError("interrupt_on 为空:中断/恢复协议会失效")
  134. return (
  135. f"{info['name']} · 工具 {info['tools']['count']} · "
  136. f"子智能体 {info['subagents']} · 审批 {info['interrupt_on']} · "
  137. f"压缩 {info['compaction']['enabled']}"
  138. )
  139. def check_contract() -> str:
  140. """SSE 契约自检:事件类型数量与帧格式。"""
  141. from api.sse_events import ALL_EVENT_TYPES, EventWriter
  142. if len(ALL_EVENT_TYPES) != 15:
  143. raise RuntimeError(f"事件类型应为 15 种,实际 {len(ALL_EVENT_TYPES)}")
  144. frame = EventWriter("s", "t").token("x")
  145. if not frame.startswith("data: ") or not frame.endswith("\n\n"):
  146. raise RuntimeError(f"帧格式不对:{frame!r}")
  147. payload = json.loads(frame[6:])
  148. if payload["session_id"] != "s" or payload["trace_id"] != "t":
  149. raise RuntimeError(f"帧缺少 session_id/trace_id:{payload}")
  150. return f"{len(ALL_EVENT_TYPES)} 种事件"
  151. def main() -> int:
  152. print("deep_agent 2.0 结构自检")
  153. print(f"项目根:{ROOT}")
  154. print(f"方言:{__import__('os').getenv('DB_DIALECT') or '(默认 mysql)'}")
  155. print()
  156. step("Python 源码语法", check_syntax)
  157. step("模块导入链", check_imports)
  158. step("建表与存储连通", check_schema)
  159. step("计费单价覆盖", check_pricing)
  160. step("SSE 契约", check_contract)
  161. step("权限规则顺序", check_permissions)
  162. step("工具注册", check_tools)
  163. step("分层依赖", check_import_boundaries)
  164. step("agent 编译", lambda: asyncio.run(check_agent()))
  165. print()
  166. if FAILED:
  167. print(f"失败 {len(FAILED)} 项:{', '.join(FAILED)}")
  168. return 1
  169. print("全部通过")
  170. return 0
  171. if __name__ == "__main__":
  172. sys.exit(main())