internal_context_tools.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. """
  2. 工作流节点上下文查询工具(Python 端实现)。
  3. 3 个工具供 Hermes Agent 在执行工作流节点任务时调用,通过 HTTP 反向查询 Java 端的
  4. `/internal/context/{path,output,meta}` 接口,获取前驱节点的路径图 / 输出 / 中间数据。
  5. 设计要点:
  6. - 工具永久注册到全局 registry(启动时一次性,幂等)
  7. - check_fn 检查 contextvar `_CONTEXT_QUERY_CTX`:
  8. - 已设置(含 context_query 的请求)→ 工具对 agent 可见
  9. - 未设置(普通对话请求)→ 工具不可见,避免污染
  10. - handler 内通过 contextvar 读取 base_url/token/run_id/visit_uuid,
  11. 避免在工具签名中暴露这些敏感字段给 LLM
  12. - 返回格式(用户决策 Q3):自然语言摘要 + JSON 代码块
  13. 协议契约:参见 `docs/specs/internal-context-query-spec.md`
  14. 日志要点:
  15. - 入口:每个 handler 被调用时打印工具名 + 关键参数(不含 token)
  16. - HTTP:_http_post 打印 URL + 状态码 + 耗时 + 响应字节数
  17. - 出口:返回内容的长度与摘要预览(前 100 字符)
  18. - token 永远不打印;visit_uuid 仅打印前 8 位
  19. """
  20. import json
  21. import logging
  22. import time
  23. import urllib.error
  24. import urllib.request
  25. from typing import Optional
  26. logger = logging.getLogger("internal-context-tools")
  27. # Java 端点路径(前缀 /internal/context)
  28. _PATH_ENDPOINT = "/internal/context/path"
  29. _OUTPUT_ENDPOINT = "/internal/context/output"
  30. _META_ENDPOINT = "/internal/context/meta"
  31. # HTTP 超时(秒):内存查询亚秒级,DB 回退 < 300ms,5s 兜底足够
  32. _HTTP_TIMEOUT = 5.0
  33. # 工具所属 toolset,独立于 hermes-agent 内置 toolset,避免命名冲突
  34. _TOOLSET = "workflow_context"
  35. # 日志预览截断长度(字符)
  36. _LOG_PREVIEW_LEN = 100
  37. def _short_visit(uuid_str) -> str:
  38. """visit_uuid 截断为前 8 位用于日志(避免完整 UUID 噪声)。"""
  39. if not uuid_str:
  40. return "<none>"
  41. return str(uuid_str)[:8]
  42. def _get_cfg():
  43. """从 hermes_bridge 模块的 contextvar 读取当前请求的 context_query 配置。
  44. 返回 None 表示未启用(普通对话场景),handler 与 check_fn 都依赖此函数。
  45. 延迟 import 避免循环依赖(hermes_bridge 启动时会 import 本模块注册工具)。
  46. """
  47. try:
  48. from hermes_bridge import _CONTEXT_QUERY_CTX
  49. return _CONTEXT_QUERY_CTX.get(None)
  50. except Exception:
  51. return None
  52. def _http_post(base_url: str, token: str, path: str, payload: dict) -> tuple:
  53. """调用 Java 内部接口。
  54. 返回 (status_code, data_or_error):
  55. - 成功:(200, <dict from Result.data>)
  56. - 业务失败:(non-200, {"message": "...", "code": ...})
  57. - 网络/异常:(-1, {"message": "..."})
  58. """
  59. url = base_url.rstrip("/") + path
  60. data = json.dumps(payload).encode("utf-8")
  61. req = urllib.request.Request(
  62. url,
  63. data=data,
  64. method="POST",
  65. headers={
  66. "Content-Type": "application/json",
  67. "X-Context-Token": token,
  68. },
  69. )
  70. start = time.time()
  71. try:
  72. with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
  73. body = resp.read().decode("utf-8")
  74. elapsed_ms = int((time.time() - start) * 1000)
  75. # Java Result<T>: {code: 200, message: ..., data: T}
  76. parsed = json.loads(body) if body else {}
  77. code = parsed.get("code", resp.status)
  78. if code == 200:
  79. logger.info(
  80. "[CtxQuery] POST %s -> 200, %d bytes, %dms, payload=%s",
  81. url, len(body), elapsed_ms, _truncate_payload_for_log(payload),
  82. )
  83. return 200, parsed.get("data") or {}
  84. logger.warning(
  85. "[CtxQuery] POST %s -> code=%d, %d bytes, %dms, msg=%s, payload=%s",
  86. url, code, len(body), elapsed_ms,
  87. parsed.get("message", ""), _truncate_payload_for_log(payload),
  88. )
  89. return code, {"message": parsed.get("message", "未知错误"), "code": code}
  90. except urllib.error.HTTPError as e:
  91. elapsed_ms = int((time.time() - start) * 1000)
  92. try:
  93. err_body = e.read().decode("utf-8")
  94. parsed = json.loads(err_body) if err_body else {}
  95. msg = parsed.get("message", err_body)
  96. except Exception:
  97. parsed = {}
  98. msg = str(e)
  99. logger.warning(
  100. "[CtxQuery] POST %s -> HTTPError %d, %dms, msg=%s, payload=%s",
  101. url, e.code, elapsed_ms, msg, _truncate_payload_for_log(payload),
  102. )
  103. return e.code, {"message": msg, "code": e.code}
  104. except Exception as e:
  105. elapsed_ms = int((time.time() - start) * 1000)
  106. logger.warning(
  107. "[CtxQuery] POST %s -> EXC %dms, err=%s, payload=%s",
  108. url, elapsed_ms, e, _truncate_payload_for_log(payload),
  109. )
  110. return -1, {"message": f"请求失败: {e}", "code": -1}
  111. def _truncate_payload_for_log(payload: dict) -> str:
  112. """日志中打印 payload 的安全摘要(不含 token,visit_uuid 截断)。"""
  113. if not payload:
  114. return "{}"
  115. safe = {}
  116. for k, v in payload.items():
  117. if k in ("visitUuid", "visit_uuid"):
  118. safe[k] = _short_visit(v)
  119. else:
  120. safe[k] = v
  121. s = json.dumps(safe, ensure_ascii=False)
  122. return s if len(s) <= 200 else s[:200] + "..."
  123. def _format_success(summary: str, data: dict) -> str:
  124. """格式化为「自然语言摘要 + JSON 代码块」。"""
  125. if not data:
  126. return summary
  127. return "{summary}\n\n```json\n{json}\n```".format(
  128. summary=summary,
  129. json=json.dumps(data, ensure_ascii=False, indent=2),
  130. )
  131. def _format_error(message: str) -> str:
  132. """错误场景只返回自然语言描述(不带 JSON),避免 LLM 误以为有数据可引用。"""
  133. return message
  134. def _log_handler_return(tool_name: str, result: str, extra: str = ""):
  135. """打印 handler 返回值的长度与预览(前 100 字符)。"""
  136. preview = result[:_LOG_PREVIEW_LEN].replace("\n", "\\n")
  137. if len(result) > _LOG_PREVIEW_LEN:
  138. preview += "..."
  139. logger.info(
  140. "[CtxQuery] %s 返回 %d 字符%s: %s",
  141. tool_name, len(result), f" ({extra})" if extra else "", preview,
  142. )
  143. # ---------------------------------------------------------------------------
  144. # Tool handlers
  145. # ---------------------------------------------------------------------------
  146. def _handle_get_predecessor_paths(args, **kw):
  147. tool_name = "get_predecessor_paths"
  148. cfg = _get_cfg()
  149. if cfg is None:
  150. logger.info("[CtxQuery] %s 被调用但 contextvar 未启用(降级返回错误)", tool_name)
  151. return _format_error("上下文查询未启用:本次运行未注入 context_query 配置")
  152. logger.info(
  153. "[CtxQuery] %s 被调用: run_id=%s, visit_uuid=%s",
  154. tool_name, cfg.get("run_id"), _short_visit(cfg.get("visit_uuid")),
  155. )
  156. status, data = _http_post(
  157. cfg["base_url"], cfg["token"], _PATH_ENDPOINT,
  158. {"runId": cfg["run_id"], "visitUuid": cfg["visit_uuid"]},
  159. )
  160. if status == 401:
  161. _log_handler_return(tool_name, "token_invalid", "401")
  162. return _format_error("token 无效或已过期,无法查询前驱路径")
  163. if status == 403:
  164. _log_handler_return(tool_name, "forbidden", "403")
  165. return _format_error("runId 与 token 不匹配,禁止查询")
  166. if status == 404:
  167. _log_handler_return(tool_name, "not_found", "404")
  168. return _format_error(
  169. "未找到当前 visit 的前驱路径(runId={}, visitUuid={})".format(
  170. cfg["run_id"], _short_visit(cfg["visit_uuid"]),
  171. )
  172. )
  173. if status != 200:
  174. _log_handler_return(tool_name, "error:" + str(status), str(status))
  175. return _format_error("查询前驱路径失败:{}".format(data.get("message", "未知错误")))
  176. node_count = len(data.get("nodeIds") or [])
  177. path_count = len(data.get("paths") or [])
  178. backtrack = data.get("backtrackEdgeKeys") or []
  179. summary = "当前节点的前驱访问路径图:共 {} 个节点、{} 条路径".format(
  180. node_count, path_count,
  181. )
  182. if backtrack:
  183. summary += "(含 {} 条智能回边)".format(len(backtrack))
  184. summary += "。"
  185. result = _format_success(summary, data)
  186. _log_handler_return(tool_name, result, "nodes={},paths={}".format(node_count, path_count))
  187. return result
  188. def _handle_get_node_output(args, **kw):
  189. tool_name = "get_node_output"
  190. cfg = _get_cfg()
  191. if cfg is None:
  192. logger.info("[CtxQuery] %s 被调用但 contextvar 未启用(降级返回错误)", tool_name)
  193. return _format_error("上下文查询未启用:本次运行未注入 context_query 配置")
  194. node_id = (args.get("node_id") or "").strip()
  195. field = args.get("field")
  196. iter_seq = args.get("iter_seq")
  197. logger.info(
  198. "[CtxQuery] %s 被调用: node_id=%s, field=%s, iter_seq=%s, run_id=%s",
  199. tool_name, node_id, field, iter_seq, cfg.get("run_id"),
  200. )
  201. if not node_id:
  202. _log_handler_return(tool_name, "missing_node_id", "缺参")
  203. return _format_error("查询失败:node_id 是必填参数")
  204. payload = {"runId": cfg["run_id"], "nodeId": node_id}
  205. if field:
  206. payload["field"] = field
  207. if iter_seq is not None:
  208. payload["iterSeq"] = int(iter_seq)
  209. status, data = _http_post(
  210. cfg["base_url"], cfg["token"], _OUTPUT_ENDPOINT, payload,
  211. )
  212. if status == 401:
  213. _log_handler_return(tool_name, "token_invalid", "401")
  214. return _format_error("token 无效或已过期,无法查询节点输出")
  215. if status == 403:
  216. _log_handler_return(tool_name, "forbidden", "403")
  217. return _format_error("runId 与 token 不匹配,禁止查询")
  218. if status == 404:
  219. _log_handler_return(tool_name, "not_found:" + node_id, "404")
  220. return _format_error(
  221. "节点 {} 尚未被访问(runId={})".format(node_id, cfg["run_id"]),
  222. )
  223. if status != 200:
  224. _log_handler_return(tool_name, "error:" + str(status), str(status))
  225. return _format_error(
  226. "查询节点 {} 输出失败:{}".format(
  227. node_id, data.get("message", "未知错误"),
  228. ),
  229. )
  230. iter_str = data.get("iterSeq", 1)
  231. status_code = data.get("status", "未知")
  232. field_hint = "字段 {}".format(field) if field else "整包输出"
  233. summary = "节点 {}(第 {} 次访问)的 {},状态={}".format(
  234. node_id, iter_str, field_hint, status_code,
  235. )
  236. if data.get("truncated"):
  237. summary += "(结果已截断)"
  238. summary += "。"
  239. result = _format_success(summary, data)
  240. _log_handler_return(tool_name, result, "node={},status={}".format(node_id, status_code))
  241. return result
  242. def _handle_get_node_meta(args, **kw):
  243. tool_name = "get_node_meta"
  244. cfg = _get_cfg()
  245. if cfg is None:
  246. logger.info("[CtxQuery] %s 被调用但 contextvar 未启用(降级返回错误)", tool_name)
  247. return _format_error("上下文查询未启用:本次运行未注入 context_query 配置")
  248. node_id = (args.get("node_id") or "").strip()
  249. visit_uuid = (args.get("visit_uuid") or "").strip()
  250. kind = (args.get("kind") or "ALL").strip().upper()
  251. iter_seq = args.get("iter_seq")
  252. logger.info(
  253. "[CtxQuery] %s 被调用: node_id=%s, visit_uuid=%s, kind=%s, iter_seq=%s, run_id=%s",
  254. tool_name, node_id or "<none>", _short_visit(visit_uuid) if visit_uuid else "<none>",
  255. kind, iter_seq, cfg.get("run_id"),
  256. )
  257. if not node_id and not visit_uuid:
  258. _log_handler_return(tool_name, "missing_target", "缺参")
  259. return _format_error("查询失败:node_id 和 visit_uuid 至少需要提供一个")
  260. payload = {"runId": cfg["run_id"], "kind": kind}
  261. if node_id:
  262. payload["nodeId"] = node_id
  263. if visit_uuid:
  264. payload["visitUuid"] = visit_uuid
  265. if iter_seq is not None:
  266. payload["iterSeq"] = int(iter_seq)
  267. status, data = _http_post(
  268. cfg["base_url"], cfg["token"], _META_ENDPOINT, payload,
  269. )
  270. if status == 401:
  271. _log_handler_return(tool_name, "token_invalid", "401")
  272. return _format_error("token 无效或已过期,无法查询中间数据")
  273. if status == 403:
  274. _log_handler_return(tool_name, "forbidden", "403")
  275. return _format_error("runId 与 token 不匹配,禁止查询")
  276. if status == 404:
  277. target = _short_visit(visit_uuid) if visit_uuid else node_id
  278. _log_handler_return(tool_name, "not_found:" + target, "404")
  279. return _format_error(
  280. "未找到 {} 的访问记录(runId={})".format(target, cfg["run_id"]),
  281. )
  282. if status != 200:
  283. _log_handler_return(tool_name, "error:" + str(status), str(status))
  284. return _format_error(
  285. "查询中间数据失败:{}".format(data.get("message", "未知错误")),
  286. )
  287. target_id = data.get("nodeId") or node_id or _short_visit(visit_uuid)
  288. iter_str = data.get("iterSeq", 1)
  289. summary = "节点 {}(第 {} 次访问)的 {} 数据".format(target_id, iter_str, kind)
  290. if data.get("truncated"):
  291. summary += "(结果已截断)"
  292. summary += "。"
  293. result = _format_success(summary, data)
  294. _log_handler_return(tool_name, result, "target={},kind={}".format(target_id, kind))
  295. return result
  296. # ---------------------------------------------------------------------------
  297. # Tool schemas (OpenAI Function Calling 格式,与 Java systemPrompt 描述对齐)
  298. # ---------------------------------------------------------------------------
  299. _GET_PREDECESSOR_PATHS_SCHEMA = {
  300. "name": "get_predecessor_paths",
  301. "description": (
  302. "查询当前节点的所有前驱访问路径(含 visit 链、回边标识)。"
  303. "无需参数,自动使用当前请求的工作流运行上下文。"
  304. "适用场景:需要了解「当前节点运行前发生了什么」时调用。"
  305. ),
  306. "parameters": {
  307. "type": "object",
  308. "properties": {},
  309. "required": [],
  310. },
  311. }
  312. _GET_NODE_OUTPUT_SCHEMA = {
  313. "name": "get_node_output",
  314. "description": (
  315. "查询某个前驱节点的输出(envelope.data 整包或单字段)。"
  316. "适用场景:需要精确引用某个前驱节点的输出变量时调用。"
  317. ),
  318. "parameters": {
  319. "type": "object",
  320. "properties": {
  321. "node_id": {
  322. "type": "string",
  323. "description": "目标节点 ID(必填)",
  324. },
  325. "field": {
  326. "type": "string",
  327. "description": "可选;指定后只返回 envelope.data 内该字段的值",
  328. },
  329. "iter_seq": {
  330. "type": "integer",
  331. "description": "可选;同节点多次访问时指定第几次(1-based),不填取最后一次",
  332. },
  333. },
  334. "required": ["node_id"],
  335. },
  336. }
  337. _GET_NODE_META_SCHEMA = {
  338. "name": "get_node_meta",
  339. "description": (
  340. "查询某个前驱节点的中间数据(思考过程 / 工具调用 / 待办清单 / 全部)。"
  341. "适用场景:需要理解前驱节点的思考过程或工具调用结果时调用。"
  342. ),
  343. "parameters": {
  344. "type": "object",
  345. "properties": {
  346. "node_id": {
  347. "type": "string",
  348. "description": "目标节点 ID(与 visit_uuid 二选一)",
  349. },
  350. "visit_uuid": {
  351. "type": "string",
  352. "description": "指定具体访问的 UUID(与 node_id 二选一,优先使用)",
  353. },
  354. "kind": {
  355. "type": "string",
  356. "enum": ["THINKING", "TOOL_CALLS", "TODO", "ALL"],
  357. "description": "返回内容类型,默认 ALL",
  358. },
  359. "iter_seq": {
  360. "type": "integer",
  361. "description": "可选;同节点多次访问时指定第几次,不填取最后一次",
  362. },
  363. },
  364. "required": [],
  365. },
  366. }
  367. # ---------------------------------------------------------------------------
  368. # Registration
  369. # ---------------------------------------------------------------------------
  370. def _check_context_active(*args, **kwargs) -> bool:
  371. """check_fn:仅当本次请求注入了 context_query 时工具对 agent 可见。"""
  372. return _get_cfg() is not None
  373. def register_internal_context_tools():
  374. """一次性注册 3 个工具到全局 registry。
  375. 幂等:重复调用不会报错(相同 name + toolset 会覆盖)。
  376. 在 hermes_bridge 模块加载时调用一次即可。
  377. """
  378. try:
  379. from tools.registry import registry
  380. except ImportError:
  381. logger.warning("无法 import tools.registry,跳过工作流上下文工具注册")
  382. return
  383. try:
  384. registry.register(
  385. name="get_predecessor_paths",
  386. toolset=_TOOLSET,
  387. schema=_GET_PREDECESSOR_PATHS_SCHEMA,
  388. handler=_handle_get_predecessor_paths,
  389. check_fn=_check_context_active,
  390. emoji="🧭",
  391. )
  392. registry.register(
  393. name="get_node_output",
  394. toolset=_TOOLSET,
  395. schema=_GET_NODE_OUTPUT_SCHEMA,
  396. handler=_handle_get_node_output,
  397. check_fn=_check_context_active,
  398. emoji="📤",
  399. )
  400. registry.register(
  401. name="get_node_meta",
  402. toolset=_TOOLSET,
  403. schema=_GET_NODE_META_SCHEMA,
  404. handler=_handle_get_node_meta,
  405. check_fn=_check_context_active,
  406. emoji="🔍",
  407. )
  408. logger.info("已注册工作流上下文查询工具(toolset=%s)", _TOOLSET)
  409. except Exception as e:
  410. logger.warning("注册工作流上下文工具失败: %s", e)