registry.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. """
  2. Tool Registry - 工具注册表和装饰器
  3. 职责:
  4. 1. @tool 装饰器:自动注册工具并生成 Schema
  5. 2. 管理所有工具的 Schema 和实现
  6. 3. 路由工具调用到具体实现
  7. 4. 支持域名过滤、敏感数据处理、工具统计
  8. 从 Resonote/llm/tools/registry.py 抽取并扩展
  9. """
  10. import json
  11. import inspect
  12. import logging
  13. import time
  14. from typing import Any, Callable, Dict, List, Optional
  15. from cyber_agent.tools.approval import tool_argument_hash
  16. from cyber_agent.tools.url_matcher import filter_by_url, match_url_with_patterns
  17. logger = logging.getLogger(__name__)
  18. class ToolStats:
  19. """工具使用统计"""
  20. def __init__(self):
  21. self.call_count: int = 0
  22. self.success_count: int = 0
  23. self.failure_count: int = 0
  24. self.total_duration: float = 0.0
  25. self.last_called: Optional[float] = None
  26. @property
  27. def average_duration(self) -> float:
  28. """平均执行时间(秒)"""
  29. return self.total_duration / self.call_count if self.call_count > 0 else 0.0
  30. @property
  31. def success_rate(self) -> float:
  32. """成功率"""
  33. return self.success_count / self.call_count if self.call_count > 0 else 0.0
  34. def to_dict(self) -> Dict[str, Any]:
  35. return {
  36. "call_count": self.call_count,
  37. "success_count": self.success_count,
  38. "failure_count": self.failure_count,
  39. "average_duration": self.average_duration,
  40. "success_rate": self.success_rate,
  41. "last_called": self.last_called
  42. }
  43. class ToolRegistry:
  44. """工具注册表"""
  45. def __init__(self):
  46. self._tools: Dict[str, Dict[str, Any]] = {}
  47. self._stats: Dict[str, ToolStats] = {}
  48. def register(
  49. self,
  50. func: Callable,
  51. schema: Optional[Dict] = None,
  52. requires_confirmation: bool = False,
  53. editable_params: Optional[List[str]] = None,
  54. display: Optional[Dict[str, Dict[str, Any]]] = None,
  55. url_patterns: Optional[List[str]] = None,
  56. hidden_params: Optional[List[str]] = None,
  57. inject_params: Optional[Dict[str, Any]] = None,
  58. groups: Optional[List[str]] = None,
  59. ):
  60. """
  61. 注册工具
  62. Args:
  63. func: 工具函数
  64. schema: 工具 Schema(如果为 None,自动生成)
  65. requires_confirmation: 是否需要用户确认
  66. editable_params: 允许用户编辑的参数列表
  67. display: i18n 展示信息 {"zh": {"name": "xx", "params": {...}}, "en": {...}}
  68. url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
  69. hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
  70. inject_params: 注入参数规则 {param_name: injector_func}
  71. groups: 工具分组标签(如 ["core"]、["browser"]),用于 RunConfig.tool_groups 过滤
  72. """
  73. func_name = func.__name__
  74. # 如果没有提供 Schema,自动生成
  75. if schema is None:
  76. try:
  77. from cyber_agent.tools.schema import SchemaGenerator
  78. schema = SchemaGenerator.generate(func, hidden_params=hidden_params or [])
  79. except Exception as e:
  80. logger.error(f"Failed to generate schema for {func_name}: {e}")
  81. raise
  82. self._tools[func_name] = {
  83. "func": func,
  84. "schema": schema,
  85. "url_patterns": url_patterns,
  86. "hidden_params": hidden_params or [],
  87. "inject_params": inject_params or {},
  88. "groups": groups or [],
  89. "ui_metadata": {
  90. "requires_confirmation": requires_confirmation,
  91. "editable_params": editable_params or [],
  92. "display": display or {}
  93. }
  94. }
  95. # 初始化统计
  96. self._stats[func_name] = ToolStats()
  97. logger.debug(
  98. f"[ToolRegistry] Registered: {func_name} "
  99. f"(requires_confirmation={requires_confirmation}, "
  100. f"editable_params={editable_params or []}, "
  101. f"url_patterns={url_patterns or 'none'})"
  102. )
  103. @staticmethod
  104. def _resolve_key_path(context: Dict[str, Any], key_path: str) -> Any:
  105. """
  106. 从 context 中按路径取值。
  107. 支持 "obj.field" 格式:第一段从 context dict 取值,后续段用 getattr。
  108. 例如 "knowledge_config.default_tags" → context["knowledge_config"].default_tags
  109. Args:
  110. context: 上下文字典
  111. key_path: 取值路径
  112. Returns:
  113. 取到的值,路径无效返回 None
  114. """
  115. parts = key_path.split(".")
  116. value = context.get(parts[0])
  117. for part in parts[1:]:
  118. if value is None:
  119. return None
  120. value = getattr(value, part, None)
  121. return value
  122. def is_registered(self, tool_name: str) -> bool:
  123. """检查工具是否已注册"""
  124. return tool_name in self._tools
  125. def get_schemas(self, tool_names: Optional[List[str]] = None) -> List[Dict]:
  126. """
  127. 获取工具 Schema
  128. Args:
  129. tool_names: 工具名称列表(None = 所有工具)
  130. Returns:
  131. OpenAI Tool Schema 列表
  132. """
  133. if tool_names is None:
  134. tool_names = list(self._tools.keys())
  135. schemas = []
  136. for name in tool_names:
  137. if name in self._tools:
  138. schemas.append(self._tools[name]["schema"])
  139. else:
  140. logger.warning(f"[ToolRegistry] Tool not found: {name}")
  141. return schemas
  142. def get_tool_names(self, current_url: Optional[str] = None, groups: Optional[List[str]] = None) -> List[str]:
  143. """
  144. 获取工具名称列表(可选 URL 过滤 + group 过滤)
  145. Args:
  146. current_url: 当前 URL(None = 不过滤 URL)
  147. groups: 工具分组白名单(None = 不过滤 group,返回所有工具)
  148. Returns:
  149. 工具名称列表
  150. """
  151. # 1. group 过滤
  152. if groups is not None:
  153. group_set = set(groups)
  154. candidates = {
  155. name for name, tool in self._tools.items()
  156. if group_set & set(tool.get("groups", []))
  157. }
  158. else:
  159. candidates = set(self._tools.keys())
  160. # 2. URL 过滤
  161. if current_url is None:
  162. return list(candidates)
  163. tool_items = [
  164. {"name": name, "url_patterns": self._tools[name]["url_patterns"]}
  165. for name in candidates
  166. ]
  167. filtered = filter_by_url(tool_items, current_url, url_field="url_patterns")
  168. return [item["name"] for item in filtered]
  169. def get_available_groups(self) -> List[str]:
  170. """获取所有已注册的工具分组"""
  171. groups = set()
  172. for tool in self._tools.values():
  173. groups.update(tool.get("groups", []))
  174. return sorted(groups)
  175. def get_schemas_for_url(self, current_url: Optional[str] = None) -> List[Dict]:
  176. """
  177. 根据当前 URL 获取匹配的工具 Schema
  178. Args:
  179. current_url: 当前 URL(None = 返回无 URL 限制的工具)
  180. Returns:
  181. 过滤后的工具 Schema 列表
  182. """
  183. tool_names = self.get_tool_names(current_url)
  184. return self.get_schemas(tool_names)
  185. def get_runtime_policy(self, tool_name: str) -> Dict[str, Any]:
  186. """Return an immutable copy of execution-relevant tool policy metadata."""
  187. if tool_name not in self._tools:
  188. return {
  189. "requires_confirmation": False,
  190. "editable_params": [],
  191. "url_patterns": None,
  192. }
  193. tool = self._tools[tool_name]
  194. ui = tool.get("ui_metadata", {})
  195. return {
  196. "requires_confirmation": bool(ui.get("requires_confirmation", False)),
  197. "editable_params": list(ui.get("editable_params", [])),
  198. "url_patterns": (
  199. list(tool["url_patterns"])
  200. if tool.get("url_patterns") is not None
  201. else None
  202. ),
  203. }
  204. async def execute(
  205. self,
  206. name: str,
  207. arguments: Dict[str, Any],
  208. uid: str = "",
  209. context: Optional[Dict[str, Any]] = None,
  210. sensitive_data: Optional[Dict[str, Any]] = None,
  211. inject_values: Optional[Dict[str, Any]] = None,
  212. allowed_tool_names: Optional[set[str]] = None,
  213. tool_call_id: Optional[str] = None,
  214. approval_grant: Optional[Dict[str, Any]] = None,
  215. ) -> str:
  216. """
  217. 执行一次工具调用,是 AgentRunner 的统一 dispatch 入口。
  218. Recursive 运行会传入 ``allowed_tool_names`` 作为 Schema 之外的硬门禁,
  219. 在参数注入、统计和函数调用前拒绝伪造的未授权 Tool Call。
  220. Args:
  221. name: 工具名称
  222. arguments: 工具参数
  223. uid: 用户ID(自动注入)
  224. context: 额外上下文
  225. sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
  226. allowed_tool_names: 当前运行允许的工具集;None 表示保留旧行为
  227. Returns:
  228. JSON 字符串格式的结果
  229. """
  230. if allowed_tool_names is not None and name not in allowed_tool_names:
  231. error_msg = f"Tool not allowed in current run: {name}"
  232. logger.warning(f"[ToolRegistry] {error_msg}")
  233. return json.dumps({"error": error_msg}, ensure_ascii=False)
  234. if name not in self._tools:
  235. error_msg = f"Unknown tool: {name}"
  236. logger.error(f"[ToolRegistry] {error_msg}")
  237. return json.dumps({"error": error_msg}, ensure_ascii=False)
  238. tool_policy = self.get_runtime_policy(name)
  239. url_patterns = tool_policy.get("url_patterns")
  240. if url_patterns:
  241. current_url = context.get("page_url") if context else None
  242. if not current_url or not match_url_with_patterns(current_url, url_patterns):
  243. error_msg = f"Tool URL policy denied: {name}"
  244. logger.warning("[ToolRegistry] %s", error_msg)
  245. return json.dumps({"error": error_msg}, ensure_ascii=False)
  246. if tool_policy.get("requires_confirmation"):
  247. grant = approval_grant or {}
  248. context_trace_id = context.get("trace_id") if context else None
  249. expected_hash = (
  250. tool_argument_hash(
  251. tool_call_id=tool_call_id,
  252. tool_name=name,
  253. arguments=arguments,
  254. )
  255. if tool_call_id
  256. else None
  257. )
  258. valid_grant = (
  259. tool_call_id is not None
  260. and bool(grant.get("batch_id"))
  261. and context_trace_id is not None
  262. and grant.get("trace_id") == context_trace_id
  263. and grant.get("tool_call_id") == tool_call_id
  264. and grant.get("tool_name") == name
  265. and grant.get("argument_hash") == expected_hash
  266. and grant.get("decision") in {"approved", "auto_approved"}
  267. )
  268. if not valid_grant:
  269. error_msg = f"Tool confirmation required: {name}"
  270. logger.warning("[ToolRegistry] %s", error_msg)
  271. return json.dumps({"error": error_msg}, ensure_ascii=False)
  272. start_time = time.time()
  273. stats = self._stats[name]
  274. stats.call_count += 1
  275. stats.last_called = start_time
  276. try:
  277. func = self._tools[name]["func"]
  278. tool_info = self._tools[name]
  279. # 处理敏感数据占位符
  280. if sensitive_data:
  281. from cyber_agent.tools.sensitive import replace_sensitive_data
  282. current_url = context.get("page_url") if context else None
  283. arguments = replace_sensitive_data(arguments, sensitive_data, current_url)
  284. # 准备参数:只注入函数需要的参数
  285. sig = inspect.signature(func)
  286. # 过滤掉函数签名中不存在的参数(如 Claude SDK 发送的 {"_": true} 占位符)
  287. valid_params = set(sig.parameters.keys())
  288. kwargs = {k: v for k, v in arguments.items() if k in valid_params}
  289. # 注入隐藏参数(hidden_params)
  290. hidden_params = tool_info.get("hidden_params", [])
  291. if "uid" in hidden_params and "uid" in sig.parameters:
  292. kwargs["uid"] = uid
  293. if "context" in hidden_params and "context" in sig.parameters:
  294. kwargs["context"] = context
  295. # 注入参数(inject_params)
  296. inject_params = tool_info.get("inject_params", {})
  297. for param_name, rule in inject_params.items():
  298. if param_name not in sig.parameters:
  299. continue
  300. if not isinstance(rule, dict) or "mode" not in rule:
  301. # 兼容旧格式:直接值或 callable
  302. if param_name not in kwargs or kwargs[param_name] is None:
  303. kwargs[param_name] = rule() if callable(rule) else rule
  304. continue
  305. mode = rule["mode"]
  306. key_path = rule.get("key")
  307. # 从 context 中按路径取值
  308. value = self._resolve_key_path(context, key_path) if key_path and context else None
  309. if value is None:
  310. continue
  311. if mode == "default":
  312. # 默认值模式:LLM 未提供则注入
  313. if param_name not in kwargs or kwargs[param_name] is None:
  314. kwargs[param_name] = value
  315. elif mode == "merge":
  316. # 合并模式:框架值始终保留,LLM 可追加新内容
  317. llm_value = kwargs.get(param_name)
  318. if isinstance(value, dict):
  319. # dict: LLM 追加新 key,同名 key 以框架值为准
  320. kwargs[param_name] = {**(llm_value or {}), **value}
  321. elif isinstance(value, list):
  322. # list: 合并去重
  323. kwargs[param_name] = list(set((llm_value or []) + value))
  324. else:
  325. kwargs[param_name] = value
  326. # 执行函数
  327. if inspect.iscoroutinefunction(func):
  328. result = await func(**kwargs)
  329. else:
  330. result = func(**kwargs)
  331. # 记录成功
  332. stats.success_count += 1
  333. duration = time.time() - start_time
  334. stats.total_duration += duration
  335. # 返回结果:ToolResult 转为可序列化格式
  336. if isinstance(result, str):
  337. return result
  338. # 处理 ToolResult 对象
  339. from cyber_agent.tools.models import ToolResult
  340. if isinstance(result, ToolResult):
  341. ret = {"text": result.to_llm_message()}
  342. # 保留images
  343. if result.images:
  344. ret["images"] = result.images
  345. # 保留tool_usage
  346. if result.tool_usage:
  347. ret["tool_usage"] = result.tool_usage
  348. # 仅透传标准 artifact_refs;不把任意 metadata 暴露给模型或 Validator。
  349. artifact_refs = result.artifact_refs or result.metadata.get("artifact_refs", [])
  350. if artifact_refs:
  351. ret["artifact_refs"] = artifact_refs
  352. # 向后兼容:只有text时返回字符串
  353. if len(ret) == 1:
  354. return ret["text"]
  355. return ret
  356. return json.dumps(result, ensure_ascii=False, indent=2)
  357. except Exception as e:
  358. # 记录失败
  359. stats.failure_count += 1
  360. duration = time.time() - start_time
  361. stats.total_duration += duration
  362. error_msg = f"Error executing tool '{name}': {str(e)}"
  363. logger.error(f"[ToolRegistry] {error_msg}")
  364. import traceback
  365. logger.error(traceback.format_exc())
  366. return json.dumps({"error": error_msg}, ensure_ascii=False)
  367. def get_stats(self, tool_name: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
  368. """
  369. 获取工具统计信息
  370. Args:
  371. tool_name: 工具名称(None = 所有工具)
  372. Returns:
  373. 统计信息字典
  374. """
  375. if tool_name:
  376. if tool_name in self._stats:
  377. return {tool_name: self._stats[tool_name].to_dict()}
  378. return {}
  379. return {name: stats.to_dict() for name, stats in self._stats.items()}
  380. def get_top_tools(self, limit: int = 10, by: str = "call_count") -> List[str]:
  381. """
  382. 获取排名靠前的工具
  383. Args:
  384. limit: 返回数量
  385. by: 排序依据(call_count, success_rate, average_duration)
  386. Returns:
  387. 工具名称列表
  388. """
  389. if by == "call_count":
  390. sorted_tools = sorted(
  391. self._stats.items(),
  392. key=lambda x: x[1].call_count,
  393. reverse=True
  394. )
  395. elif by == "success_rate":
  396. sorted_tools = sorted(
  397. self._stats.items(),
  398. key=lambda x: x[1].success_rate,
  399. reverse=True
  400. )
  401. elif by == "average_duration":
  402. sorted_tools = sorted(
  403. self._stats.items(),
  404. key=lambda x: x[1].average_duration,
  405. reverse=False # 越快越好
  406. )
  407. else:
  408. raise ValueError(f"Invalid sort by: {by}")
  409. return [name for name, _ in sorted_tools[:limit]]
  410. def check_confirmation_required(self, tool_calls: List[Dict]) -> bool:
  411. """检查是否有工具需要用户确认"""
  412. for tc in tool_calls:
  413. tool_name = tc.get("function", {}).get("name")
  414. if tool_name and tool_name in self._tools:
  415. if self._tools[tool_name]["ui_metadata"].get("requires_confirmation", False):
  416. return True
  417. return False
  418. def get_confirmation_flags(self, tool_calls: List[Dict]) -> List[bool]:
  419. """返回每个工具是否需要确认"""
  420. flags = []
  421. for tc in tool_calls:
  422. tool_name = tc.get("function", {}).get("name")
  423. if tool_name and tool_name in self._tools:
  424. flags.append(self._tools[tool_name]["ui_metadata"].get("requires_confirmation", False))
  425. else:
  426. flags.append(False)
  427. return flags
  428. def check_any_param_editable(self, tool_calls: List[Dict]) -> bool:
  429. """检查是否有任何工具允许参数编辑"""
  430. for tc in tool_calls:
  431. tool_name = tc.get("function", {}).get("name")
  432. if tool_name and tool_name in self._tools:
  433. editable_params = self._tools[tool_name]["ui_metadata"].get("editable_params", [])
  434. if editable_params:
  435. return True
  436. return False
  437. def get_editable_params_map(self, tool_calls: List[Dict]) -> Dict[str, List[str]]:
  438. """返回每个工具调用的可编辑参数列表"""
  439. params_map = {}
  440. for tc in tool_calls:
  441. tool_call_id = tc.get("id")
  442. tool_name = tc.get("function", {}).get("name")
  443. if tool_name and tool_name in self._tools:
  444. editable_params = self._tools[tool_name]["ui_metadata"].get("editable_params", [])
  445. params_map[tool_call_id] = editable_params
  446. else:
  447. params_map[tool_call_id] = []
  448. return params_map
  449. def get_ui_metadata(
  450. self,
  451. locale: str = "zh",
  452. tool_names: Optional[List[str]] = None
  453. ) -> Dict[str, Dict[str, Any]]:
  454. """
  455. 获取工具的UI元数据(用于前端展示)
  456. Returns:
  457. {
  458. "tool_name": {
  459. "display_name": "搜索笔记",
  460. "param_display_names": {"query": "搜索关键词"},
  461. "requires_confirmation": false,
  462. "editable_params": ["query"]
  463. }
  464. }
  465. """
  466. if tool_names is None:
  467. tool_names = list(self._tools.keys())
  468. metadata = {}
  469. for name in tool_names:
  470. if name not in self._tools:
  471. continue
  472. ui_meta = self._tools[name]["ui_metadata"]
  473. display = ui_meta.get("display", {}).get(locale, {})
  474. metadata[name] = {
  475. "display_name": display.get("name", name),
  476. "param_display_names": display.get("params", {}),
  477. "requires_confirmation": ui_meta.get("requires_confirmation", False),
  478. "editable_params": ui_meta.get("editable_params", [])
  479. }
  480. return metadata
  481. # 全局单例
  482. _global_registry = ToolRegistry()
  483. def tool(
  484. description: Optional[str] = None,
  485. param_descriptions: Optional[Dict[str, str]] = None,
  486. requires_confirmation: bool = False,
  487. editable_params: Optional[List[str]] = None,
  488. display: Optional[Dict[str, Dict[str, Any]]] = None,
  489. url_patterns: Optional[List[str]] = None,
  490. hidden_params: Optional[List[str]] = None,
  491. inject_params: Optional[Dict[str, Any]] = None,
  492. groups: Optional[List[str]] = None,
  493. ):
  494. """
  495. 工具装饰器 - 自动注册工具并生成 Schema
  496. Args:
  497. description: 函数描述(可选,从 docstring 提取)
  498. param_descriptions: 参数描述(可选,从 docstring 提取)
  499. requires_confirmation: 是否需要用户确认(默认 False)
  500. editable_params: 允许用户编辑的参数列表
  501. display: i18n 展示信息
  502. url_patterns: URL 模式列表(如 ["*.google.com"],None = 无限制)
  503. hidden_params: 隐藏参数列表(不生成 schema,LLM 看不到)
  504. inject_params: 注入参数规则 {param_name: injector_func}
  505. groups: 工具分组标签(如 ["core"]、["browser"]),用于 RunConfig.tool_groups 过滤
  506. Example:
  507. @tool(
  508. hidden_params=["context", "uid"],
  509. inject_params={
  510. "owner": lambda ctx: ctx.config.knowledge.get_owner(),
  511. },
  512. editable_params=["query"],
  513. url_patterns=["*.google.com"],
  514. display={
  515. "zh": {"name": "搜索笔记", "params": {"query": "搜索关键词"}},
  516. "en": {"name": "Search Notes", "params": {"query": "Query"}}
  517. }
  518. )
  519. async def search_blocks(
  520. query: str,
  521. limit: int = 10,
  522. owner: Optional[str] = None,
  523. context: Optional[ToolContext] = None,
  524. uid: str = ""
  525. ) -> str:
  526. '''搜索用户的笔记块'''
  527. ...
  528. """
  529. def decorator(func: Callable) -> Callable:
  530. # 注册到全局 registry
  531. _global_registry.register(
  532. func,
  533. requires_confirmation=requires_confirmation,
  534. editable_params=editable_params,
  535. display=display,
  536. url_patterns=url_patterns,
  537. hidden_params=hidden_params,
  538. inject_params=inject_params,
  539. groups=groups,
  540. )
  541. return func
  542. return decorator
  543. def get_tool_registry() -> ToolRegistry:
  544. """获取全局工具注册表"""
  545. return _global_registry