registry.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. """工具的**唯一事实源**。
  2. 全仓只有这里能声明「有哪些工具」。其他地方(agent 组装、系统提示词、
  3. 工具名中文映射、SSE 事件渲染)都从这里派生,避免出现「提示词里说有、
  4. 实际没注册」这类漂移。
  5. ## 来源标注契约
  6. 工具返回「带来源」的结果时,统一用 :func:`sourced` 构造:
  7. return sourced(data=<业务数据>, sources=[source("标题", url, 摘要, 0.87)])
  8. SSE 层(api/source_tracker.py)会从 ToolMessage 里抽取 ``sources`` 字段并
  9. 聚合去重,最终发 ``citations`` 事件。这是 2.0 相对 1.5 的关键改动:**来源
  10. 由工具侧结构化产出,而不是靠提示词要求模型自觉在正文里写引用**——模型可以
  11. 漏写、可以编造,工具不会有这两种失败模式。
  12. ## 扩展方式
  13. 新增业务工具:在 ``tools/`` 下新建模块,用 :func:`register` 装饰,
  14. 然后 import 该模块(见本文件底部的 ``_load_builtin_modules``)。
  15. """
  16. from __future__ import annotations
  17. import inspect
  18. from dataclasses import dataclass, field
  19. from typing import Any, Callable, Iterable
  20. from obs import get_logger
  21. logger = get_logger(__name__)
  22. # 工具返回值里携带来源的字段名。改这里要同步改 api/source_tracker.py 的解析。
  23. SOURCES_FIELD = "sources"
  24. DATA_FIELD = "data"
  25. @dataclass(frozen=True)
  26. class ToolSpec:
  27. """一个工具的元数据。"""
  28. name: str
  29. func: Callable[..., Any]
  30. group: str
  31. label: str
  32. summary: str = ""
  33. requires_approval: bool = False
  34. @property
  35. def description(self) -> str:
  36. """给模型看的描述:优先用函数 docstring 首行。"""
  37. if self.summary:
  38. return self.summary
  39. doc = inspect.getdoc(self.func) or ""
  40. return doc.strip().split("\n", 1)[0]
  41. @dataclass
  42. class _Registry:
  43. """注册表本体。用类而不是全局 dict,方便测试里隔离。"""
  44. tools: dict[str, ToolSpec] = field(default_factory=dict)
  45. groups: dict[str, list[str]] = field(default_factory=dict)
  46. def add(self, spec: ToolSpec) -> None:
  47. if spec.name in self.tools:
  48. existing = self.tools[spec.name]
  49. raise ValueError(
  50. f"工具名冲突:{spec.name!r} 已由 {existing.func.__module__} 注册,"
  51. f"又来了 {spec.func.__module__}。工具名必须全局唯一。"
  52. )
  53. self.tools[spec.name] = spec
  54. self.groups.setdefault(spec.group, []).append(spec.name)
  55. _registry = _Registry()
  56. # ============================================================
  57. # 注册
  58. # ============================================================
  59. def register(
  60. group: str,
  61. label: str = "",
  62. name: str | None = None,
  63. summary: str = "",
  64. requires_approval: bool = False,
  65. ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
  66. """把函数注册为工具。
  67. ``group`` 是给 UI 分组展示用的中文名;``label`` 是工具的中文显示名
  68. (SSE 的 tool_call 事件会带出去);``name`` 不填则用函数名。
  69. """
  70. def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
  71. tool_name = name or func.__name__
  72. _registry.add(
  73. ToolSpec(
  74. name=tool_name,
  75. func=func,
  76. group=group,
  77. label=label or tool_name,
  78. summary=summary,
  79. requires_approval=requires_approval,
  80. )
  81. )
  82. return func
  83. return decorator
  84. def register_external(
  85. func: Callable[..., Any],
  86. group: str,
  87. label: str = "",
  88. name: str | None = None,
  89. summary: str = "",
  90. requires_approval: bool = False,
  91. ) -> Callable[..., Any]:
  92. """以函数式(非装饰器)方式注册,供不便加装饰器的场景使用。"""
  93. tool_name = name or getattr(func, "__name__", "unnamed_tool")
  94. _registry.add(
  95. ToolSpec(
  96. name=tool_name,
  97. func=func,
  98. group=group,
  99. label=label or tool_name,
  100. summary=summary,
  101. requires_approval=requires_approval,
  102. )
  103. )
  104. return func
  105. # ============================================================
  106. # 查询
  107. # ============================================================
  108. def get_all_tool_functions() -> list[Callable[..., Any]]:
  109. """全部工具函数,交给 ``create_deep_agent(tools=...)``。"""
  110. return [spec.func for spec in _registry.tools.values()]
  111. def as_tool(func: Callable[..., Any]) -> Any:
  112. """把裸函数包装成 ``BaseTool``。
  113. 为什么两种形态都要有:主 agent 的 ``tools=`` 接受裸可调用对象(框架
  114. 内部会包),但**子智能体的 ``tools`` 会被直接塞进 ``ToolNode``**,
  115. 那里要求对象带 ``.name``——传裸函数会炸在 ``tool_.name`` 上,而且报错
  116. 发生在图编译期、离真正的原因很远。所以对外统一走 ``BaseTool``。
  117. """
  118. from langchain_core.tools import StructuredTool
  119. if hasattr(func, "name"): # 已经是 BaseTool
  120. return func
  121. return StructuredTool.from_function(func)
  122. def get_all_tools() -> list[Any]:
  123. """全部工具,以 ``BaseTool`` 形态返回。主 agent 用这个。"""
  124. return [as_tool(spec.func) for spec in _registry.tools.values()]
  125. def get_tools_by_name(names: Iterable[str]) -> list[Any]:
  126. """按名取工具(``BaseTool`` 形态)。子智能体用这个。
  127. 名字未注册时记警告并跳过——子智能体少一个工具是能力降级,不该让整个
  128. agent 编译失败。
  129. """
  130. tools: list[Any] = []
  131. for name in names:
  132. spec = _registry.tools.get(name)
  133. if spec is None:
  134. logger.warning("子智能体引用了未注册的工具 %r,已跳过", name)
  135. continue
  136. tools.append(as_tool(spec.func))
  137. return tools
  138. def get_tool_names() -> list[str]:
  139. """全部工具名。"""
  140. return list(_registry.tools.keys())
  141. def get_spec(name: str) -> ToolSpec | None:
  142. """按名取工具元数据。"""
  143. return _registry.tools.get(name)
  144. def get_group_map() -> dict[str, list[str]]:
  145. """分组 → 工具名列表。"""
  146. return {group: list(names) for group, names in _registry.groups.items()}
  147. def get_label(name: str) -> str:
  148. """工具的中文显示名;未注册时回落工具名本身。"""
  149. spec = _registry.tools.get(name)
  150. return spec.label if spec else name
  151. def describe() -> dict[str, Any]:
  152. """整个注册表的可序列化描述,供 ``GET /api/model`` 或调试接口使用。"""
  153. return {
  154. "count": len(_registry.tools),
  155. "groups": {
  156. group: [
  157. {
  158. "name": name,
  159. "label": _registry.tools[name].label,
  160. "description": _registry.tools[name].description,
  161. "requires_approval": _registry.tools[name].requires_approval,
  162. }
  163. for name in names
  164. if name in _registry.tools
  165. ]
  166. for group, names in _registry.groups.items()
  167. },
  168. }
  169. # ============================================================
  170. # 来源标注
  171. # ============================================================
  172. def source(
  173. title: str,
  174. url: str = "",
  175. snippet: str = "",
  176. score: float | None = None,
  177. **extra: Any,
  178. ) -> dict[str, Any]:
  179. """构造一条来源记录。"""
  180. record: dict[str, Any] = {"title": title, "url": url, "snippet": snippet}
  181. if score is not None:
  182. record["score"] = score
  183. record.update(extra)
  184. return record
  185. def sourced(
  186. data: Any,
  187. sources: Iterable[dict[str, Any]] | None = None,
  188. **extra: Any,
  189. ) -> dict[str, Any]:
  190. """构造「带来源」的工具返回值。
  191. 用这个而不是自己拼 dict,是为了让来源标注契约只有一个出口——
  192. 将来改字段名只改这里,而不用满仓搜 ``"sources"``。
  193. """
  194. payload: dict[str, Any] = {DATA_FIELD: data, SOURCES_FIELD: list(sources or [])}
  195. payload.update(extra)
  196. return payload
  197. # ============================================================
  198. # 内置工具模块装载
  199. # ============================================================
  200. _BUILTIN_MODULES = ("tools.demo_tools",)
  201. def load_builtin_tools() -> int:
  202. """import 内置工具模块以触发注册。返回注册到的工具数。
  203. 这里要处理一个 Python 导入语义的坑:模块的注册是靠**模块体里的装饰器**
  204. 完成的,而 ``import_module`` 对已导入的模块是空操作——模块一旦在
  205. ``sys.modules`` 里,再 import 不会重放装饰器。所以「注册表被清空后重新
  206. 装载」这个场景(测试隔离、或将来做热重载)必须显式 ``reload``,
  207. 否则注册表会一直是空的,表现为「工具莫名其妙全没了」。
  208. """
  209. import importlib
  210. import sys
  211. for module_name in _BUILTIN_MODULES:
  212. if module_name not in sys.modules:
  213. importlib.import_module(module_name)
  214. elif not _registry.tools:
  215. # 模块已在缓存里但注册表是空的 → 重放模块体以重新注册。
  216. # 生产路径不会走到这里(首次 import 就已填充注册表),
  217. # 只在测试 reset 之后触发。
  218. importlib.reload(sys.modules[module_name])
  219. return len(_registry.tools)
  220. def reset_for_tests() -> None:
  221. """仅供测试:清空注册表,使下一次 ``load_builtin_tools`` 重新注册。"""
  222. _registry.tools.clear()
  223. _registry.groups.clear()