registry.py 9.1 KB

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