demo_tools.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. """示例工具:演示三种注册形态。
  2. 骨架刻意只放三个工具,每个演示一件不同的事,业务工具照这个模子加:
  3. 1. :func:`get_current_time` —— 最简形态:无参、返回字符串
  4. 2. :func:`search_demo_knowledge` —— 带来源标注:返回 :func:`tools.registry.sourced`
  5. 包裹的结构,SSE 层会自动把它变成 ``citations`` 事件
  6. 3. :func:`calculate` —— 确定性计算:把「不该交给模型算」的事放到工具侧,
  7. 顺带演示参数校验与错误返回
  8. 写工具的三条纪律:
  9. - **docstring 是给模型看的**——第一行说清「做什么」,正文说清「什么时候用、
  10. 参数含义、返回什么」。模型选错工具多半是因为 docstring 写得含糊。
  11. - **参数要写类型注解**——工具 schema 由签名推导,缺注解会导致参数变成
  12. 「任意字符串」。
  13. - **失败要返回可读的原因**,不要抛裸异常:抛异常会中断这一轮工具调用,
  14. 返回错误说明能让模型自己纠正(比如改个参数重试)。
  15. """
  16. from __future__ import annotations
  17. import ast
  18. import datetime
  19. import operator
  20. import re
  21. from typing import Any
  22. from tools.registry import register, source, sourced
  23. # ============================================================
  24. # 1. 最简形态
  25. # ============================================================
  26. @register(group="系统", label="获取当前时间", summary="返回服务器当前本地时间")
  27. def get_current_time() -> str:
  28. """返回服务器当前的本地日期时间。
  29. 当用户问「现在几点」「今天几号」,或任务需要按当前日期做推算时使用。
  30. 不要凭记忆回答时间——你的训练数据里没有「现在」。
  31. """
  32. now = datetime.datetime.now()
  33. weekday = "一二三四五六日"[now.weekday()]
  34. return f"{now.strftime('%Y-%m-%d %H:%M:%S')}(周{weekday})"
  35. # ============================================================
  36. # 2. 带来源标注
  37. # ============================================================
  38. # 演示用的假知识库。真实项目里这里会替换成 MCP / HTTP 调用。
  39. _DEMO_KB: list[dict[str, Any]] = [
  40. {
  41. "title": "矿井通风基础知识",
  42. "url": "https://example.invalid/kb/ventilation-basics",
  43. "text": "矿井通风的任务是向井下各用风地点连续输送足够的新鲜空气,"
  44. "稀释并排出瓦斯、粉尘和有害气体,同时为作业人员提供适宜的气候条件。",
  45. "keywords": ["通风", "矿井", "新鲜空气", "瓦斯"],
  46. },
  47. {
  48. "title": "需风量计算方法",
  49. "url": "https://example.invalid/kb/air-volume",
  50. "text": "采煤工作面需风量按瓦斯涌出量、同时作业人数、气象条件、风速上限"
  51. "四项分别计算,取其中最大值。",
  52. "keywords": ["需风量", "采煤", "瓦斯", "风速"],
  53. },
  54. {
  55. "title": "deepagents 框架简介",
  56. "url": "https://example.invalid/kb/deepagents",
  57. "text": "deepagents 是构建深度智能体的框架,提供待办清单、文件系统、"
  58. "子智能体、技能与上下文压缩等开箱能力。",
  59. "keywords": ["deepagents", "智能体", "子智能体", "技能"],
  60. },
  61. ]
  62. @register(
  63. group="示例检索",
  64. label="检索演示知识库",
  65. summary="在内置演示知识库里按关键词检索,返回命中片段与来源",
  66. )
  67. def search_demo_knowledge(query: str, limit: int = 3) -> dict[str, Any]:
  68. """在内置演示知识库中检索。
  69. Args:
  70. query: 检索关键词,支持空格分隔多个词(命中任意一个即算命中)。
  71. limit: 最多返回几条,默认 3。
  72. Returns:
  73. 含 ``data``(命中片段列表)与 ``sources``(来源列表)的结构。
  74. ``sources`` 会被上层自动渲染成引用标注,因此必须如实填写,
  75. 不要编造 url。
  76. """
  77. terms = [t for t in re.split(r"\s+", (query or "").strip()) if t]
  78. if not terms:
  79. # 返回可读原因而非抛异常:模型看到原因后可以补上关键词重试
  80. return sourced(data=[], sources=[], error="query 为空,请提供至少一个关键词")
  81. hits: list[dict[str, Any]] = []
  82. for item in _DEMO_KB:
  83. haystack = f"{item['title']} {' '.join(item['keywords'])} {item['text']}"
  84. matched = [t for t in terms if t.lower() in haystack.lower()]
  85. if matched:
  86. # 命中词数 / 总词数,作为粗糙的相关度打分
  87. hits.append(
  88. {
  89. "title": item["title"],
  90. "text": item["text"],
  91. "matched": matched,
  92. "score": round(len(matched) / len(terms), 4),
  93. }
  94. )
  95. hits.sort(key=lambda h: h["score"], reverse=True)
  96. top = hits[: max(1, int(limit or 3))]
  97. return sourced(
  98. data=top,
  99. sources=[
  100. source(
  101. title=hit["title"],
  102. url=next(i["url"] for i in _DEMO_KB if i["title"] == hit["title"]),
  103. snippet=hit["text"][:80],
  104. score=hit["score"],
  105. )
  106. for hit in top
  107. ],
  108. matched_count=len(hits),
  109. )
  110. # ============================================================
  111. # 3. 确定性计算
  112. # ============================================================
  113. # 白名单运算符:只允许算术与幂,杜绝 eval 带来的代码执行面
  114. _BIN_OPS = {
  115. ast.Add: operator.add,
  116. ast.Sub: operator.sub,
  117. ast.Mult: operator.mul,
  118. ast.Div: operator.truediv,
  119. ast.FloorDiv: operator.floordiv,
  120. ast.Mod: operator.mod,
  121. ast.Pow: operator.pow,
  122. }
  123. _UNARY_OPS = {ast.UAdd: operator.pos, ast.USub: operator.neg}
  124. _MAX_POW_EXPONENT = 1000
  125. def _eval_node(node: ast.AST) -> float:
  126. """递归求值 AST 节点。只放行算术表达式,其余一律拒绝。"""
  127. if isinstance(node, ast.Expression):
  128. return _eval_node(node.body)
  129. if isinstance(node, ast.Constant):
  130. if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
  131. raise ValueError(f"不支持的字面量:{node.value!r}")
  132. return float(node.value)
  133. if isinstance(node, ast.BinOp):
  134. op_type = type(node.op)
  135. if op_type not in _BIN_OPS:
  136. raise ValueError(f"不支持的运算符:{op_type.__name__}")
  137. left, right = _eval_node(node.left), _eval_node(node.right)
  138. if op_type is ast.Pow and abs(right) > _MAX_POW_EXPONENT:
  139. # 挡住 9**9**9 这类会把进程算死的表达式
  140. raise ValueError(f"幂指数过大(上限 {_MAX_POW_EXPONENT})")
  141. return _BIN_OPS[op_type](left, right)
  142. if isinstance(node, ast.UnaryOp):
  143. op_type = type(node.op)
  144. if op_type not in _UNARY_OPS:
  145. raise ValueError(f"不支持的一元运算符:{op_type.__name__}")
  146. return _UNARY_OPS[op_type](_eval_node(node.operand))
  147. raise ValueError(f"不支持的表达式元素:{type(node).__name__}")
  148. @register(
  149. group="示例计算",
  150. label="安全算术计算",
  151. summary="对纯算术表达式做精确求值,避免心算出错",
  152. )
  153. def calculate(expression: str) -> dict[str, Any]:
  154. """对纯算术表达式求值,支持 + - * / // % ** 与括号。
  155. Args:
  156. expression: 算术表达式,例如 ``"(1200 + 300) * 0.85"``。
  157. Returns:
  158. ``{"expression": ..., "result": ...}``;表达式非法时返回 ``error`` 字段。
  159. 需要精确数值(配风量核算、成本换算、单位折算)时使用本工具,
  160. 不要自己心算——多位数乘除很容易出错,而且错了没人能发现。
  161. """
  162. expr = (expression or "").strip()
  163. if not expr:
  164. return {"error": "expression 为空"}
  165. if len(expr) > 200:
  166. return {"error": "表达式过长(上限 200 字符)"}
  167. try:
  168. tree = ast.parse(expr, mode="eval")
  169. result = _eval_node(tree)
  170. except SyntaxError:
  171. return {"error": f"表达式语法错误:{expr!r}"}
  172. except ZeroDivisionError:
  173. return {"error": "除数为零"}
  174. except (ValueError, OverflowError) as exc:
  175. return {"error": str(exc)}
  176. except Exception as exc: # pragma: no cover - 兜底,避免工具异常打断整轮
  177. return {"error": f"计算失败:{type(exc).__name__}: {exc}"}
  178. # 整数结果去掉多余小数位,便于阅读与后续引用
  179. if result == int(result) and abs(result) < 1e15:
  180. result = int(result)
  181. return {"expression": expr, "result": result}
  182. # ============================================================
  183. # 4. 人工审批(HITL 管道)
  184. # ============================================================
  185. # 这个工具的名字会被 agents/agent_factory 放进 interrupt_on:
  186. # 调用它时官方 HumanInTheLoopMiddleware 会在**执行前**拦下,向用户抛出
  187. # 审批请求;用户批准后才真正执行函数体。
  188. PLAN_APPROVAL_TOOL = "request_plan_approval"
  189. @register(
  190. group="人机协同",
  191. label="请求计划审批",
  192. summary="把待执行的计划提交给用户确认,用户批准后才继续",
  193. )
  194. def request_plan_approval(plan: str) -> str:
  195. """把计划提交给用户审批,等待其批准后再继续执行。
  196. Args:
  197. plan: 待审批的计划全文。要写清步骤、涉及的操作、预期产出。
  198. 不要只写一句「我将开始处理」——用户没有信息就无法判断该不该批准。
  199. 当执行模式为 plan、或任务包含不可逆操作(删除、覆盖、对外发送、
  200. 消耗额度)需要用户点头时调用。普通问答与只读查询不要调用,
  201. 无谓的确认会打断用户。
  202. Returns:
  203. 用户批准后的确认文本;用户拒绝时本函数不会被执行。
  204. """
  205. # 正常情况下走不到这里:调用被 interrupt_on 拦下,用户批准后才会执行函数体。
  206. # 这条 return 是「批准」路径的返回值。
  207. return f"用户已批准以下计划,请按计划执行:\n{plan}"