registry.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. """确定性发现、声明和校验业务扩展;通过校验后一次性发布工具目录。"""
  2. from __future__ import annotations
  3. from dataclasses import dataclass, replace
  4. import importlib
  5. import inspect
  6. import re
  7. from pathlib import Path
  8. import threading
  9. from typing import Any, Callable, Iterable
  10. from pydantic import BaseModel
  11. import config
  12. from tools import registry as tools
  13. from extensions.services import ServiceSpec, ordered_names, service_view
  14. _NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\Z")
  15. _RESERVED_TOOLS = {"write_todos", "ls", "read_file", "write_file", "edit_file", "glob", "grep", "execute", "task"}
  16. _RESERVED_AGENTS = {"deep-agent", "general-purpose", "main"}
  17. _RESERVED_MIDDLEWARE = {
  18. "DynamicTimeMiddleware", "SkillCompletionMiddleware", "SummarizationMiddleware", "TodoListMiddleware",
  19. "FilesystemMiddleware", "SubAgentMiddleware", "PatchToolCallsMiddleware",
  20. "SkillsMiddleware", "MemoryMiddleware", "HumanInTheLoopMiddleware",
  21. "AnthropicPromptCachingMiddleware",
  22. }
  23. def _names(values: Iterable[str], kind: str) -> tuple[str, ...]:
  24. if isinstance(values, str):
  25. raise ValueError(f"{kind}必须是名称列表")
  26. result = tuple(_name(value, kind) for value in values)
  27. if len(set(result)) != len(result):
  28. raise ValueError(f"{kind}不能重复")
  29. return result
  30. @dataclass(frozen=True)
  31. class MiddlewareSpec:
  32. name: str
  33. factory: Callable[..., Any]
  34. targets: tuple[str, ...]
  35. requires: tuple[str, ...]
  36. before: tuple[str, ...]
  37. after: tuple[str, ...]
  38. def _name(value: str, kind: str) -> str:
  39. if not isinstance(value, str) or not _NAME.fullmatch(value):
  40. raise ValueError(f"{kind}名称非法:{value!r}(使用字母、数字、下划线或连字符,最多 64 字符)")
  41. return value
  42. def _text(value: str, kind: str) -> str:
  43. if not isinstance(value, str) or not value.strip():
  44. raise ValueError(f"{kind}不能为空")
  45. return value.strip()
  46. def _call_sync(hook: Callable[..., Any], name: str, *args) -> Any:
  47. result = hook(*args)
  48. if inspect.isawaitable(result):
  49. if inspect.iscoroutine(result):
  50. result.close()
  51. raise ValueError(f"{name} 必须同步执行;异步初始化请放入服务工厂")
  52. return result
  53. @dataclass(frozen=True)
  54. class AgentSpec:
  55. name: str
  56. description: str
  57. prompt: str
  58. tools: tuple[str, ...]
  59. approval_tools: tuple[str, ...]
  60. @dataclass(frozen=True)
  61. class GatewayBinding:
  62. """取数网关的调用身份;旧管理字段仅保留兼容,不控制结果推送。"""
  63. agent_code: str = ""
  64. check_type_code: str = ""
  65. sensor_type_list: str = ""
  66. topics: tuple[str, ...] = ()
  67. @dataclass(frozen=True)
  68. class ResultBinding:
  69. """结果接收平台的身份,独立于取数网关。"""
  70. agent_code: str = ""
  71. agent_name: str = ""
  72. @dataclass(frozen=True)
  73. class BusinessAgentSpec:
  74. name: str
  75. display_name: str
  76. input_schema: type[BaseModel]
  77. result_schema: type[BaseModel]
  78. max_concurrency: int = 1
  79. gateway: GatewayBinding = GatewayBinding()
  80. result_validator: Callable[..., dict] | None = None
  81. result_binding: ResultBinding = ResultBinding()
  82. test_runner: Callable[..., Any] | None = None
  83. skill_path: str | None = None
  84. class ExtensionRegistry:
  85. """setup() 收到的注册器;不在导入业务模块时修改全局状态。"""
  86. def __init__(self):
  87. self.tools: dict[str, tools.ToolSpec] = {}
  88. self.agents: dict[str, AgentSpec] = {}
  89. self.business_agents: dict[str, BusinessAgentSpec] = {}
  90. self.modules: list[str] = []
  91. self.disabled_modules: list[str] = []
  92. self.services: dict[str, ServiceSpec] = {}
  93. self.middleware: dict[str, MiddlewareSpec] = {}
  94. self.tool_services: dict[str, tuple[str, ...]] = {}
  95. self._sealed = False
  96. def _editable(self):
  97. if self._sealed:
  98. raise ValueError("扩展已加载;新增或修改扩展后请重启服务")
  99. def register_tool(self, func: Callable[..., Any], *, group: str | None = None,
  100. name: str | None = None, label: str | None = None,
  101. summary: str | None = None, requires_approval: bool | None = None,
  102. requires: Iterable[str] = ()) -> str:
  103. """注册函数;参数覆盖 define_tool 元数据,也可以不使用装饰器。"""
  104. self._editable()
  105. spec = tools.tool_spec(func, group=group, name=name, label=label,
  106. summary=summary, requires_approval=requires_approval)
  107. _name(spec.name, "工具")
  108. if spec.name in _RESERVED_TOOLS:
  109. raise ValueError(f"工具名与框架内置工具冲突:{spec.name}")
  110. if spec.name in self.tools:
  111. raise ValueError(f"工具名冲突:{spec.name}")
  112. self.tool_services[spec.name] = _names(requires, "工具服务依赖")
  113. self.tools[spec.name] = spec
  114. return spec.name
  115. def register_service(self, name: str, factory: Callable[..., Any], *,
  116. requires: Iterable[str] = ()) -> None:
  117. """声明服务。factory(dependencies) 返回 async context manager。"""
  118. self._editable()
  119. _name(name, "服务")
  120. if name in self.services:
  121. raise ValueError(f"服务名称冲突:{name}")
  122. if not callable(factory) or inspect.iscoroutinefunction(factory):
  123. raise ValueError(f"服务 {name} 的 factory 必须返回异步上下文管理器,请使用 asynccontextmanager")
  124. self.services[name] = ServiceSpec(name, factory, _names(requires, "服务依赖"))
  125. def register_middleware(self, name: str, factory: Callable[..., Any], *,
  126. targets: Iterable[str] = ("main",), requires: Iterable[str] = (),
  127. before: Iterable[str] = (), after: Iterable[str] = ()) -> None:
  128. """声明无副作用的同步工厂;每次编译为每个目标生成独立中间件。"""
  129. self._editable()
  130. _name(name, "中间件")
  131. if name in self.middleware or name in _RESERVED_MIDDLEWARE:
  132. raise ValueError(f"中间件名称冲突:{name}")
  133. if not callable(factory) or inspect.iscoroutinefunction(factory):
  134. raise ValueError(f"中间件 {name} 的 factory 必须是同步函数")
  135. targets = _names(targets, "中间件目标")
  136. if not targets:
  137. raise ValueError(f"中间件 {name} 至少需要一个目标")
  138. self.middleware[name] = MiddlewareSpec(name, factory, targets,
  139. _names(requires, "中间件服务依赖"), _names(before, "before"), _names(after, "after"))
  140. def middleware_order(self) -> list[str]:
  141. deps = {name: set(spec.after) for name, spec in self.middleware.items()}
  142. for name, spec in self.middleware.items():
  143. for ref in (*spec.before, *spec.after):
  144. if ref not in deps:
  145. raise ValueError(f"中间件 {name} 引用未注册项:{ref}")
  146. if not set(spec.targets).intersection(self.middleware[ref].targets):
  147. raise ValueError(f"中间件 {name} 与 {ref} 没有共同目标,不能指定顺序")
  148. for ref in spec.before:
  149. deps[ref].add(name)
  150. return ordered_names(deps, "中间件")
  151. def build_middleware(self, target: str) -> list[Any]:
  152. from langchain.agents.middleware.types import AgentMiddleware
  153. result = []
  154. for name in self.middleware_order():
  155. spec = self.middleware[name]
  156. if target not in spec.targets:
  157. continue
  158. instance = spec.factory(service_view(spec.requires))
  159. if not isinstance(instance, AgentMiddleware) or instance.name != name:
  160. if inspect.iscoroutine(instance):
  161. instance.close()
  162. raise ValueError(f"中间件 {name} 的工厂必须返回同名 AgentMiddleware 实例")
  163. result.append(instance)
  164. return result
  165. def validate_components(self) -> None:
  166. ordered_names({name: set(spec.requires) for name, spec in self.services.items()}, "服务")
  167. for name, requires in self.tool_services.items():
  168. self._check_services(f"工具 {name}", requires)
  169. for name, spec in self.middleware.items():
  170. self._check_services(f"中间件 {name}", spec.requires)
  171. missing = set(spec.targets) - {"main", *self.agents}
  172. if missing:
  173. raise ValueError(f"中间件 {name} 引用了未注册目标:{sorted(missing)}")
  174. self.middleware_order()
  175. def _check_services(self, owner: str, names: Iterable[str]) -> None:
  176. missing = set(names) - self.services.keys()
  177. if missing:
  178. raise ValueError(f"{owner} 引用了未注册服务:{sorted(missing)}")
  179. def check_middleware_factories(self) -> None:
  180. # 在服务就绪后验证工厂;编译时重新创建,不能在工厂里开连接或启动任务。
  181. for target in ("main", *sorted(self.agents)):
  182. self.build_middleware(target)
  183. def _tool_name(self, value: str | Callable[..., Any]) -> str:
  184. if isinstance(value, str):
  185. return _name(value, "工具")
  186. # 支持 register_agent(tools=[函数]);已显式注册的函数复用其注册名。
  187. matches = [spec.name for spec in self.tools.values() if spec.func is value]
  188. if len(matches) > 1:
  189. raise ValueError("函数注册了多个名称,请使用明确的工具名引用")
  190. return matches[0] if matches else self.register_tool(value)
  191. def register_agent(self, *, name: str, description: str, prompt: str,
  192. tools: Iterable[str | Callable[..., Any]],
  193. approval_tools: Iterable[str | Callable[..., Any]] = ()) -> None:
  194. """注册子 Agent;描述自动进入主 Agent 的委派说明,prompt 是子 Agent 的系统提示词。"""
  195. self._editable()
  196. _name(name, "Agent")
  197. if name in _RESERVED_AGENTS or name in self.agents:
  198. raise ValueError(f"Agent 名称冲突:{name}")
  199. if isinstance(tools, str) or isinstance(approval_tools, str):
  200. raise ValueError(f"Agent {name} 的 tools / approval_tools 必须是列表")
  201. tool_names = tuple(self._tool_name(tool) for tool in tools)
  202. approvals = []
  203. for tool in approval_tools:
  204. if isinstance(tool, str):
  205. approvals.append(_name(tool, "工具"))
  206. else:
  207. matches = [spec.name for spec in self.tools.values() if spec.func is tool]
  208. if len(matches) != 1:
  209. raise ValueError(f"Agent {name} 的审批工具未注册或名称不明确")
  210. approvals.append(matches[0])
  211. self.agents[name] = AgentSpec(name, _text(description, f"Agent {name} 描述"),
  212. _text(prompt, f"Agent {name} 提示词"), tool_names, tuple(approvals))
  213. def register_business_agent(self, *, name: str, display_name: str,
  214. description: str, prompt: str,
  215. tools: Iterable[str | Callable[..., Any]],
  216. input_schema: type[BaseModel], result_schema: type[BaseModel],
  217. max_concurrency: int = 1,
  218. gateway: GatewayBinding | None = None,
  219. result_binding: ResultBinding | None = None,
  220. result_validator: Callable[..., dict] | None = None,
  221. test_runner: Callable[..., Any] | None = None,
  222. skill_path: str | None = None,
  223. approval_tools: Iterable[str | Callable[..., Any]] = ()) -> None:
  224. """同一业务定义支持独立执行及主 Agent 委派。"""
  225. self._editable()
  226. for schema in (input_schema, result_schema):
  227. if not isinstance(schema, type) or not issubclass(schema, BaseModel):
  228. raise ValueError("业务输入和结果 schema 必须为 Pydantic BaseModel")
  229. schema.model_json_schema()
  230. from runtime.business_models import JudgmentResult
  231. if not issubclass(result_schema, JudgmentResult):
  232. raise ValueError("业务结果 schema 必须继承 JudgmentResult")
  233. if type(max_concurrency) is not int or max_concurrency < 1:
  234. raise ValueError("业务并发上限必须为正整数")
  235. if result_validator is not None and (not callable(result_validator) or inspect.iscoroutinefunction(result_validator)):
  236. raise ValueError("业务结果验证器必须为同步函数")
  237. if test_runner is not None and not inspect.iscoroutinefunction(test_runner):
  238. raise ValueError("业务测试执行器必须为异步函数")
  239. if skill_path is not None:
  240. path = Path(skill_path)
  241. if not skill_path.startswith('/skills/') or '..' in path.parts or path.name != 'SKILL.md':
  242. raise ValueError('业务 Skill 必须是 /skills/ 下的 SKILL.md')
  243. if not (config.ROOT / skill_path.lstrip('/')).is_file():
  244. raise ValueError('业务 Skill 文件不存在:' + skill_path)
  245. binding = gateway or GatewayBinding()
  246. if not isinstance(binding, GatewayBinding):
  247. raise ValueError("gateway 必须为 GatewayBinding")
  248. if binding.agent_code and not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", binding.agent_code):
  249. raise ValueError("平台 agentCode 格式非法")
  250. if binding.agent_code and any(b.gateway.agent_code == binding.agent_code
  251. for b in self.business_agents.values()):
  252. raise ValueError(f"平台 agentCode 重复:{binding.agent_code}")
  253. if not isinstance(binding.topics, tuple) or any(not isinstance(t, str) or not t.strip()
  254. for t in binding.topics):
  255. raise ValueError("topics 必须为非空主题字符串组成的 tuple")
  256. output = result_binding or ResultBinding()
  257. if not isinstance(output, ResultBinding):
  258. raise ValueError("result_binding 必须为 ResultBinding")
  259. if output.agent_code:
  260. if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", output.agent_code) or not output.agent_name.strip():
  261. raise ValueError("结果平台需要合法 agentCode 和非空 agentName")
  262. if any(b.result_binding.agent_code == output.agent_code for b in self.business_agents.values()):
  263. raise ValueError(f"结果平台 agentCode 重复:{output.agent_code}")
  264. label = _text(display_name, "业务显示名称")
  265. self.register_agent(name=name, description=description, prompt=prompt,
  266. tools=tools, approval_tools=approval_tools)
  267. self.business_agents[name] = BusinessAgentSpec(
  268. name, label, input_schema, result_schema,
  269. max_concurrency, binding, result_validator, output, test_runner, skill_path)
  270. def business(self, name: str) -> BusinessAgentSpec:
  271. try:
  272. return self.business_agents[name]
  273. except KeyError:
  274. raise ValueError(f"未注册业务智能体:{name}") from None
  275. def validate(self, extra_approvals: Iterable[str] = ()) -> set[str]:
  276. """在所有模块声明完成后检查跨模块工具引用及审批,允许前向引用。"""
  277. self.validate_components()
  278. for spec in self.tools.values():
  279. tools.spec_as_tool(spec) # 提前验证函数 schema,错误不延迟到第一轮对话
  280. approvals = {spec.name for spec in self.tools.values() if spec.requires_approval}
  281. for agent in self.agents.values():
  282. if len(set(agent.tools)) != len(agent.tools):
  283. raise ValueError(f"Agent {agent.name} 重复引用工具")
  284. missing = set(agent.tools) - self.tools.keys()
  285. if missing:
  286. raise ValueError(f"Agent {agent.name} 引用了未注册工具:{sorted(missing)}")
  287. outside = set(agent.approval_tools) - set(agent.tools)
  288. if outside:
  289. raise ValueError(f"Agent {agent.name} 的审批工具未绑定:{sorted(outside)}")
  290. approvals.update(agent.approval_tools)
  291. approvals.update(extra_approvals)
  292. missing = approvals - self.tools.keys()
  293. if missing:
  294. raise ValueError(f"审批配置引用了未注册工具:{sorted(missing)}")
  295. return approvals
  296. def subagents(self, approvals: set[str]) -> list[dict[str, Any]]:
  297. result = [{"name": agent.name, "description": agent.description,
  298. "system_prompt": agent.prompt,
  299. "tools": tools.get_tools_by_name(agent.tools),
  300. "interrupt_on": {name: True for name in agent.tools if name in approvals}}
  301. for agent in self.agents.values()]
  302. for item in result:
  303. business = self.business_agents.get(item["name"])
  304. if business and not business.skill_path:
  305. item["response_format"] = business.result_schema
  306. return result
  307. _lock = threading.RLock()
  308. _loaded: ExtensionRegistry | None = None
  309. def discover_modules() -> list[str]:
  310. """只扫描代码包下一级的 extension.py;不扫描用户上传或运行数据。"""
  311. base = Path(__file__).resolve().parent
  312. modules = []
  313. for entry in sorted(base.iterdir()):
  314. if entry.name.startswith(("_", ".")) or not entry.is_dir():
  315. continue
  316. path = entry / "extension.py"
  317. if not path.exists():
  318. continue
  319. if entry.is_symlink() or path.is_symlink() or not entry.name.isidentifier():
  320. raise ValueError(f"扩展目录非法或为符号链接:{entry.name}")
  321. modules.append(f"extensions.{entry.name}.extension")
  322. return modules
  323. def extra_approvals() -> list[str]:
  324. return [name.strip() for name in config.get_str("HITL_TOOLS", "").split(",") if name.strip()]
  325. def load_extensions() -> ExtensionRegistry:
  326. global _loaded
  327. with _lock:
  328. if _loaded is not None:
  329. return _loaded
  330. catalog = ExtensionRegistry()
  331. for module_name in discover_modules():
  332. try:
  333. module = importlib.import_module(module_name)
  334. enabled = getattr(module, "enabled", None)
  335. if enabled is not None:
  336. if not callable(enabled):
  337. raise ValueError("enabled 必须是返回布尔值的函数")
  338. active = _call_sync(enabled, "enabled")
  339. if type(active) is not bool:
  340. raise ValueError("enabled() 必须返回布尔值")
  341. if not active:
  342. catalog.disabled_modules.append(module_name)
  343. continue
  344. setup = getattr(module, "setup", None)
  345. if not callable(setup):
  346. raise ValueError("缺少 setup(registry) 函数")
  347. validate_config = getattr(module, "validate_config", None)
  348. if validate_config is not None:
  349. if not callable(validate_config) or inspect.iscoroutinefunction(validate_config):
  350. raise ValueError("validate_config 必须是同步校验函数")
  351. _call_sync(validate_config, "validate_config")
  352. _call_sync(setup, "setup", catalog)
  353. catalog.modules.append(module_name)
  354. except Exception as exc:
  355. raise ValueError(f"扩展 {module_name} 加载失败:{exc}") from exc
  356. approvals = catalog.validate(extra_approvals())
  357. # 主提示词与恢复流程依赖这两个系统工具,缺失时必须在启动阶段报告。
  358. missing = {"request_plan_approval", "ask_user"} - catalog.tools.keys()
  359. if missing or not catalog.tools["request_plan_approval"].requires_approval:
  360. raise ValueError("扩展必须注册 ask_user 和需要审批的 request_plan_approval")
  361. # 工具清单展示的审批标记与实际执行规则保持一致。
  362. catalog.tools = {name: replace(spec, requires_approval=name in approvals)
  363. for name, spec in catalog.tools.items()}
  364. tools.install_specs(catalog.tools.values())
  365. catalog._sealed = True
  366. _loaded = catalog
  367. return catalog
  368. def approval_names() -> set[str]:
  369. catalog = load_extensions()
  370. # 兼容通过旧 register() 入口增加的工具,并严格校验 HITL_TOOLS。
  371. names = {spec.name for spec in catalog.tools.values() if spec.requires_approval}
  372. names.update(name for agent in catalog.agents.values() for name in agent.approval_tools)
  373. names.update(name for name in tools.get_tool_names() if tools.get_spec(name).requires_approval)
  374. names.update(extra_approvals())
  375. missing = names - set(tools.get_tool_names())
  376. if missing:
  377. raise ValueError(f"审批配置引用了未注册工具:{sorted(missing)}")
  378. return names
  379. def reset_for_tests() -> None:
  380. global _loaded
  381. with _lock:
  382. _loaded = None