registry.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """确定性发现、声明和校验业务扩展;通过校验后一次性发布工具目录。"""
  2. from __future__ import annotations
  3. from dataclasses import dataclass, replace
  4. import importlib
  5. import re
  6. from pathlib import Path
  7. import threading
  8. from typing import Any, Callable, Iterable
  9. import config
  10. from tools import registry as tools
  11. _NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,63}\Z")
  12. _RESERVED_TOOLS = {"write_todos", "ls", "read_file", "write_file", "edit_file", "glob", "grep", "execute", "task"}
  13. _RESERVED_AGENTS = {"deep-agent", "general-purpose"}
  14. def _name(value: str, kind: str) -> str:
  15. if not isinstance(value, str) or not _NAME.fullmatch(value):
  16. raise ValueError(f"{kind}名称非法:{value!r}(使用字母、数字、下划线或连字符,最多 64 字符)")
  17. return value
  18. def _text(value: str, kind: str) -> str:
  19. if not isinstance(value, str) or not value.strip():
  20. raise ValueError(f"{kind}不能为空")
  21. return value.strip()
  22. @dataclass(frozen=True)
  23. class AgentSpec:
  24. name: str
  25. description: str
  26. prompt: str
  27. tools: tuple[str, ...]
  28. approval_tools: tuple[str, ...]
  29. class ExtensionRegistry:
  30. """setup() 收到的注册器;不在导入业务模块时修改全局状态。"""
  31. def __init__(self):
  32. self.tools: dict[str, tools.ToolSpec] = {}
  33. self.agents: dict[str, AgentSpec] = {}
  34. self.modules: list[str] = []
  35. self._sealed = False
  36. def _editable(self):
  37. if self._sealed:
  38. raise ValueError("扩展已加载;新增或修改扩展后请重启服务")
  39. def register_tool(self, func: Callable[..., Any], *, group: str | None = None,
  40. name: str | None = None, label: str | None = None,
  41. summary: str | None = None, requires_approval: bool | None = None) -> str:
  42. """注册函数;参数覆盖 define_tool 元数据,也可以不使用装饰器。"""
  43. self._editable()
  44. spec = tools.tool_spec(func, group=group, name=name, label=label,
  45. summary=summary, requires_approval=requires_approval)
  46. _name(spec.name, "工具")
  47. if spec.name in _RESERVED_TOOLS:
  48. raise ValueError(f"工具名与框架内置工具冲突:{spec.name}")
  49. if spec.name in self.tools:
  50. raise ValueError(f"工具名冲突:{spec.name}")
  51. self.tools[spec.name] = spec
  52. return spec.name
  53. def _tool_name(self, value: str | Callable[..., Any]) -> str:
  54. if isinstance(value, str):
  55. return _name(value, "工具")
  56. # 支持 register_agent(tools=[函数]);已显式注册的函数复用其注册名。
  57. matches = [spec.name for spec in self.tools.values() if spec.func is value]
  58. if len(matches) > 1:
  59. raise ValueError("函数注册了多个名称,请使用明确的工具名引用")
  60. return matches[0] if matches else self.register_tool(value)
  61. def register_agent(self, *, name: str, description: str, prompt: str,
  62. tools: Iterable[str | Callable[..., Any]],
  63. approval_tools: Iterable[str | Callable[..., Any]] = ()) -> None:
  64. """注册子 Agent;描述自动进入主 Agent 的委派说明,prompt 是子 Agent 的系统提示词。"""
  65. self._editable()
  66. _name(name, "Agent")
  67. if name in _RESERVED_AGENTS or name in self.agents:
  68. raise ValueError(f"Agent 名称冲突:{name}")
  69. if isinstance(tools, str) or isinstance(approval_tools, str):
  70. raise ValueError(f"Agent {name} 的 tools / approval_tools 必须是列表")
  71. tool_names = tuple(self._tool_name(tool) for tool in tools)
  72. approvals = []
  73. for tool in approval_tools:
  74. if isinstance(tool, str):
  75. approvals.append(_name(tool, "工具"))
  76. else:
  77. matches = [spec.name for spec in self.tools.values() if spec.func is tool]
  78. if len(matches) != 1:
  79. raise ValueError(f"Agent {name} 的审批工具未注册或名称不明确")
  80. approvals.append(matches[0])
  81. self.agents[name] = AgentSpec(name, _text(description, f"Agent {name} 描述"),
  82. _text(prompt, f"Agent {name} 提示词"), tool_names, tuple(approvals))
  83. def validate(self, extra_approvals: Iterable[str] = ()) -> set[str]:
  84. """在所有模块声明完成后检查跨模块工具引用及审批,允许前向引用。"""
  85. for spec in self.tools.values():
  86. tools.spec_as_tool(spec) # 提前验证函数 schema,错误不延迟到第一轮对话
  87. approvals = {spec.name for spec in self.tools.values() if spec.requires_approval}
  88. for agent in self.agents.values():
  89. if len(set(agent.tools)) != len(agent.tools):
  90. raise ValueError(f"Agent {agent.name} 重复引用工具")
  91. missing = set(agent.tools) - self.tools.keys()
  92. if missing:
  93. raise ValueError(f"Agent {agent.name} 引用了未注册工具:{sorted(missing)}")
  94. outside = set(agent.approval_tools) - set(agent.tools)
  95. if outside:
  96. raise ValueError(f"Agent {agent.name} 的审批工具未绑定:{sorted(outside)}")
  97. approvals.update(agent.approval_tools)
  98. approvals.update(extra_approvals)
  99. missing = approvals - self.tools.keys()
  100. if missing:
  101. raise ValueError(f"审批配置引用了未注册工具:{sorted(missing)}")
  102. return approvals
  103. def subagents(self, approvals: set[str]) -> list[dict[str, Any]]:
  104. return [{"name": agent.name, "description": agent.description,
  105. "system_prompt": agent.prompt,
  106. "tools": tools.get_tools_by_name(agent.tools),
  107. "interrupt_on": {name: True for name in agent.tools if name in approvals}}
  108. for agent in self.agents.values()]
  109. _lock = threading.RLock()
  110. _loaded: ExtensionRegistry | None = None
  111. def discover_modules() -> list[str]:
  112. """只扫描代码包下一级的 extension.py;不扫描用户上传或运行数据。"""
  113. base = Path(__file__).resolve().parent
  114. modules = []
  115. for entry in sorted(base.iterdir()):
  116. if entry.name.startswith(("_", ".")) or not entry.is_dir():
  117. continue
  118. path = entry / "extension.py"
  119. if not path.exists():
  120. continue
  121. if entry.is_symlink() or path.is_symlink() or not entry.name.isidentifier():
  122. raise ValueError(f"扩展目录非法或为符号链接:{entry.name}")
  123. modules.append(f"extensions.{entry.name}.extension")
  124. return modules
  125. def extra_approvals() -> list[str]:
  126. return [name.strip() for name in config.get_str("HITL_TOOLS", "").split(",") if name.strip()]
  127. def load_extensions() -> ExtensionRegistry:
  128. global _loaded
  129. with _lock:
  130. if _loaded is not None:
  131. return _loaded
  132. catalog = ExtensionRegistry()
  133. for module_name in discover_modules():
  134. try:
  135. module = importlib.import_module(module_name)
  136. setup = getattr(module, "setup", None)
  137. if not callable(setup):
  138. raise ValueError("缺少 setup(registry) 函数")
  139. setup(catalog)
  140. catalog.modules.append(module_name)
  141. except Exception as exc:
  142. raise ValueError(f"扩展 {module_name} 加载失败:{exc}") from exc
  143. approvals = catalog.validate(extra_approvals())
  144. # 主提示词与恢复流程依赖这两个系统工具,缺失时必须在启动阶段报告。
  145. missing = {"request_plan_approval", "ask_user"} - catalog.tools.keys()
  146. if missing or not catalog.tools["request_plan_approval"].requires_approval:
  147. raise ValueError("扩展必须注册 ask_user 和需要审批的 request_plan_approval")
  148. # 工具清单展示的审批标记与实际执行规则保持一致。
  149. catalog.tools = {name: replace(spec, requires_approval=name in approvals)
  150. for name, spec in catalog.tools.items()}
  151. tools.install_specs(catalog.tools.values())
  152. catalog._sealed = True
  153. _loaded = catalog
  154. return catalog
  155. def approval_names() -> set[str]:
  156. catalog = load_extensions()
  157. # 兼容通过旧 register() 入口增加的工具,并严格校验 HITL_TOOLS。
  158. names = {spec.name for spec in catalog.tools.values() if spec.requires_approval}
  159. names.update(name for agent in catalog.agents.values() for name in agent.approval_tools)
  160. names.update(name for name in tools.get_tool_names() if tools.get_spec(name).requires_approval)
  161. names.update(extra_approvals())
  162. missing = names - set(tools.get_tool_names())
  163. if missing:
  164. raise ValueError(f"审批配置引用了未注册工具:{sorted(missing)}")
  165. return names
  166. def reset_for_tests() -> None:
  167. global _loaded
  168. with _lock:
  169. _loaded = None