registry.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. """工具元数据、运行目录与框架工具转换。
  2. 业务扩展通过 setup(registry) 统一声明,校验后发布到这里。
  3. Agent 组装、工具名中文映射和 SSE 事件渲染从同一目录读取。
  4. ## 来源标注契约
  5. 工具返回「带来源」的结果时,统一用 :func:`sourced` 构造:
  6. return sourced(data=<业务数据>, sources=[source("标题", url, 摘要, 0.87)])
  7. 执行层(runtime/source_tracker.py)会从 ToolMessage 里抽取 ``sources`` 字段并
  8. 聚合去重,最终发 ``citations`` 事件。这是 2.0 相对 1.5 的关键改动:**来源
  9. 由工具侧结构化产出,而不是靠提示词要求模型自觉在正文里写引用**——模型可以
  10. 漏写、可以编造,工具不会有这两种失败模式。
  11. ## 扩展方式
  12. 新增业务工具:使用 define_tool 描述元数据,并在 extensions/<业务>/extension.py
  13. 的 setup(registry) 中声明工具与子 Agent。register 保留为兼容入口。
  14. """
  15. from __future__ import annotations
  16. import inspect
  17. from dataclasses import dataclass, field
  18. from typing import Any, Callable, Iterable
  19. from obs import get_logger
  20. logger = get_logger(__name__)
  21. # 工具返回值里携带来源的字段名。改这里要同步改 runtime/source_tracker.py 的解析。
  22. SOURCES_FIELD = "sources"
  23. DATA_FIELD = "data"
  24. @dataclass(frozen=True)
  25. class ToolSpec:
  26. """一个工具的元数据。"""
  27. name: str
  28. func: Callable[..., Any]
  29. group: str
  30. label: str
  31. summary: str = ""
  32. requires_approval: bool = False
  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. def tool_spec(func: Callable[..., Any], *, group: str | None = None, name: str | None = None,
  56. label: str | None = None, summary: str | None = None,
  57. requires_approval: bool | None = None) -> ToolSpec:
  58. """读取不带注册副作用的工具元数据;显式参数优先。"""
  59. meta = getattr(func, "__vent_tool__", {})
  60. selected = name if name is not None else meta.get("name") or getattr(func, "name", None) or getattr(func, "__name__", "")
  61. approval = requires_approval if requires_approval is not None else meta.get("requires_approval", False)
  62. if not isinstance(approval, bool):
  63. raise ValueError(f"工具 {selected} 的 requires_approval 必须是布尔值")
  64. return ToolSpec(name=selected, func=func,
  65. group=group if group is not None else meta.get("group", "业务工具"),
  66. label=label if label is not None else meta.get("label") or selected,
  67. summary=summary if summary is not None else meta.get("summary", ""),
  68. requires_approval=approval)
  69. def define_tool(*, group: str = "业务工具", label: str = "", name: str | None = None,
  70. summary: str = "", requires_approval: bool = False):
  71. """声明工具元数据,不在 import 时注册;函数仍可以直接调用和独立测试。"""
  72. def decorator(func):
  73. func.__vent_tool__ = dict(group=group, label=label, name=name, summary=summary,
  74. requires_approval=requires_approval)
  75. return func
  76. return decorator
  77. def spec_as_tool(spec: ToolSpec) -> Any:
  78. """保留注册别名、完整 docstring 和异步执行方式,转换成框架工具。"""
  79. from langchain_core.tools import BaseTool, StructuredTool
  80. if isinstance(spec.func, BaseTool):
  81. return spec.func.model_copy(update={"name": spec.name})
  82. description = inspect.getdoc(spec.func) or spec.summary
  83. if not description:
  84. raise ValueError(f"工具 {spec.name} 缺少 docstring 或 summary")
  85. is_async = inspect.iscoroutinefunction(spec.func)
  86. return StructuredTool.from_function(
  87. func=None if is_async else spec.func, coroutine=spec.func if is_async else None,
  88. name=spec.name, description=description,
  89. )
  90. def install_specs(specs: Iterable[ToolSpec]) -> None:
  91. """校验完成后一次性替换全局目录,加载失败不发布半个注册表。"""
  92. global _registry
  93. replacement = _Registry()
  94. for spec in specs:
  95. replacement.add(spec)
  96. _registry = replacement
  97. # ============================================================
  98. # 注册
  99. # ============================================================
  100. def register(
  101. group: str,
  102. label: str = "",
  103. name: str | None = None,
  104. summary: str = "",
  105. requires_approval: bool = False,
  106. ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
  107. """把函数注册为工具。
  108. ``group`` 是给 UI 分组展示用的中文名;``label`` 是工具的中文显示名
  109. (SSE 的 tool_call 事件会带出去);``name`` 不填则用函数名。
  110. """
  111. def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
  112. tool_name = name or func.__name__
  113. _registry.add(
  114. ToolSpec(
  115. name=tool_name,
  116. func=func,
  117. group=group,
  118. label=label or tool_name,
  119. summary=summary,
  120. requires_approval=requires_approval,
  121. )
  122. )
  123. return func
  124. return decorator
  125. def register_external(
  126. func: Callable[..., Any],
  127. group: str,
  128. label: str = "",
  129. name: str | None = None,
  130. summary: str = "",
  131. requires_approval: bool = False,
  132. ) -> Callable[..., Any]:
  133. """以函数式(非装饰器)方式注册,供不便加装饰器的场景使用。"""
  134. tool_name = name or getattr(func, "__name__", "unnamed_tool")
  135. _registry.add(
  136. ToolSpec(
  137. name=tool_name,
  138. func=func,
  139. group=group,
  140. label=label or tool_name,
  141. summary=summary,
  142. requires_approval=requires_approval,
  143. )
  144. )
  145. return func
  146. # ============================================================
  147. # 查询
  148. # ============================================================
  149. def get_all_tool_functions() -> list[Callable[..., Any]]:
  150. """全部工具函数,交给 ``create_deep_agent(tools=...)``。"""
  151. return [spec.func for spec in _registry.tools.values()]
  152. def as_tool(func: Callable[..., Any]) -> Any:
  153. """把裸函数包装成 ``BaseTool``。
  154. 为什么两种形态都要有:主 agent 的 ``tools=`` 接受裸可调用对象(框架
  155. 内部会包),但**子智能体的 ``tools`` 会被直接塞进 ``ToolNode``**,
  156. 那里要求对象带 ``.name``——传裸函数会炸在 ``tool_.name`` 上,而且报错
  157. 发生在图编译期、离真正的原因很远。所以对外统一走 ``BaseTool``。
  158. """
  159. from langchain_core.tools import StructuredTool
  160. if hasattr(func, "name"): # 已经是 BaseTool
  161. return func
  162. return StructuredTool.from_function(func)
  163. def get_all_tools() -> list[Any]:
  164. """全部工具,以 ``BaseTool`` 形态返回。主 agent 用这个。"""
  165. return [spec_as_tool(spec) for spec in _registry.tools.values()]
  166. def get_tools_by_name(names: Iterable[str]) -> list[Any]:
  167. """按名取工具(``BaseTool`` 形态)。子智能体用这个。
  168. 名字未注册时记警告并跳过——子智能体少一个工具是能力降级,不该让整个
  169. agent 编译失败。
  170. """
  171. tools: list[Any] = []
  172. for name in names:
  173. spec = _registry.tools.get(name)
  174. if spec is None:
  175. logger.warning("子智能体引用了未注册的工具 %r,已跳过", name)
  176. continue
  177. tools.append(spec_as_tool(spec))
  178. return tools
  179. def get_tool_names() -> list[str]:
  180. """全部工具名。"""
  181. return list(_registry.tools.keys())
  182. def get_spec(name: str) -> ToolSpec | None:
  183. """按名取工具元数据。"""
  184. return _registry.tools.get(name)
  185. def get_group_map() -> dict[str, list[str]]:
  186. """分组 → 工具名列表。"""
  187. return {group: list(names) for group, names in _registry.groups.items()}
  188. def get_label(name: str) -> str:
  189. """工具的中文显示名;未注册时回落工具名本身。"""
  190. spec = _registry.tools.get(name)
  191. return spec.label if spec else name
  192. def describe() -> dict[str, Any]:
  193. """整个注册表的可序列化描述,供 ``GET /api/model`` 或调试接口使用。"""
  194. return {
  195. "count": len(_registry.tools),
  196. "groups": {
  197. group: [
  198. {
  199. "name": name,
  200. "label": _registry.tools[name].label,
  201. "description": _registry.tools[name].description,
  202. "requires_approval": _registry.tools[name].requires_approval,
  203. }
  204. for name in names
  205. if name in _registry.tools
  206. ]
  207. for group, names in _registry.groups.items()
  208. },
  209. }
  210. # ============================================================
  211. # 来源标注
  212. # ============================================================
  213. def source(
  214. title: str,
  215. url: str = "",
  216. snippet: str = "",
  217. score: float | None = None,
  218. **extra: Any,
  219. ) -> dict[str, Any]:
  220. """构造一条来源记录。"""
  221. record: dict[str, Any] = {"title": title, "url": url, "snippet": snippet}
  222. if score is not None:
  223. record["score"] = score
  224. record.update(extra)
  225. return record
  226. def sourced(
  227. data: Any,
  228. sources: Iterable[dict[str, Any]] | None = None,
  229. **extra: Any,
  230. ) -> dict[str, Any]:
  231. """构造「带来源」的工具返回值。
  232. 用这个而不是自己拼 dict,是为了让来源标注契约只有一个出口——
  233. 将来改字段名只改这里,而不用满仓搜 ``"sources"``。
  234. """
  235. payload: dict[str, Any] = {DATA_FIELD: data, SOURCES_FIELD: list(sources or [])}
  236. payload.update(extra)
  237. return payload
  238. # ============================================================
  239. # 内置工具模块装载
  240. # ============================================================
  241. def load_builtin_tools() -> int:
  242. """兼容旧调用方;工具和子 Agent 统一由业务扩展声明。"""
  243. from extensions.registry import load_extensions
  244. load_extensions()
  245. return len(_registry.tools)
  246. def reset_for_tests() -> None:
  247. """仅供测试:清空发布目录;下次重新执行 setup,不依赖 import 重放。"""
  248. from extensions.registry import reset_for_tests as reset_extensions
  249. _registry.tools.clear()
  250. _registry.groups.clear()
  251. reset_extensions()