http_adapter_tools.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. """
  2. 飞书 Node HTTP 适配层(默认 FEISHU_HTTP_BASE_URL:4380)工具封装。
  3. 与适配服务对齐:
  4. GET /tools
  5. POST /tool-call body: { tool, params, context?, tool_call_id? }
  6. POST /tool-calls/batch body: { calls: [ 同上 ... ] }
  7. context 字段与 Node ``buildTicket`` 一致:message_id、chat_id、account_id、
  8. sender_open_id、chat_type、thread_id。Gateway 写入 Trace.context[\"feishu_adapter\"],
  9. 本模块自动合并;也可用 context_patch 临时覆盖。
  10. """
  11. from __future__ import annotations
  12. import json
  13. import logging
  14. import os
  15. from typing import Any, Dict, List, Optional
  16. import httpx
  17. from agent.tools import tool
  18. from agent.tools.models import ToolResult
  19. logger = logging.getLogger(__name__)
  20. FEISHU_HTTP_TIMEOUT = float(os.getenv("FEISHU_HTTP_TIMEOUT", "120"))
  21. def _adapter_base_url() -> str:
  22. return os.getenv("FEISHU_HTTP_BASE_URL", "http://127.0.0.1:4380").rstrip("/")
  23. async def _load_feishu_adapter(context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
  24. if not context:
  25. return {}
  26. trace_id = context.get("trace_id")
  27. store = context.get("store")
  28. if not trace_id or not store:
  29. return {}
  30. try:
  31. trace = await store.get_trace(trace_id)
  32. except Exception:
  33. logger.exception("feishu_adapter: get_trace failed trace_id=%s", trace_id)
  34. return {}
  35. if not trace or not trace.context:
  36. return {}
  37. raw = trace.context.get("feishu_adapter")
  38. return dict(raw) if isinstance(raw, dict) else {}
  39. async def _resolve_uid_for_adapter(context: Optional[Dict[str, Any]]) -> str:
  40. if not context:
  41. return ""
  42. u = context.get("uid")
  43. if u:
  44. return str(u)
  45. trace_id = context.get("trace_id")
  46. store = context.get("store")
  47. if not trace_id or not store:
  48. return ""
  49. try:
  50. trace = await store.get_trace(trace_id)
  51. except Exception:
  52. return ""
  53. if trace and trace.uid:
  54. return str(trace.uid)
  55. return ""
  56. def _merge_to_node_context(
  57. adapter: Dict[str, Any],
  58. patch: Optional[Dict[str, Any]],
  59. uid: str,
  60. ) -> Dict[str, Any]:
  61. out: Dict[str, Any] = {}
  62. for k, v in adapter.items():
  63. if v is not None and v != "":
  64. out[k] = v
  65. if patch:
  66. for k, v in patch.items():
  67. if v is not None and v != "":
  68. out[k] = v
  69. if "sender_open_id" not in out and out.get("open_id"):
  70. out["sender_open_id"] = out["open_id"]
  71. if uid and "sender_open_id" not in out:
  72. out["sender_open_id"] = uid
  73. out.pop("open_id", None)
  74. out.pop("app_id", None)
  75. return out
  76. def _coerce_tool_params(
  77. params: Any,
  78. *,
  79. tool_name: str,
  80. label: str = "params",
  81. ) -> tuple[Dict[str, Any], Optional[ToolResult]]:
  82. """
  83. 模型常把嵌套 JSON 误传为字符串;若为 str 则尝试 json.loads 成 dict。
  84. 解析失败或类型不对时返回错误 ToolResult,避免静默发空 {} 到 Node。
  85. """
  86. if params is None:
  87. return {}, None
  88. if isinstance(params, dict):
  89. return params, None
  90. if isinstance(params, str):
  91. s = params.strip()
  92. if not s:
  93. return {}, None
  94. try:
  95. parsed = json.loads(s)
  96. except json.JSONDecodeError as e:
  97. return {}, ToolResult(
  98. title=f"{label} 不是合法 JSON",
  99. output=f"工具 `{tool_name}` 的 {label} 应为对象;收到字符串但解析失败:{e}\n"
  100. f"原始片段:{s[:400]!r}",
  101. error="invalid_params_json",
  102. )
  103. if not isinstance(parsed, dict):
  104. return {}, ToolResult(
  105. title=f"{label} 解析后不是对象",
  106. output=f"工具 `{tool_name}` 的 {label} 应为 JSON 对象,解析得到:{type(parsed).__name__}",
  107. error="params_not_object",
  108. )
  109. logger.info("feishu_adapter: coerced %s from JSON string for tool=%s", label, tool_name)
  110. return parsed, None
  111. return {}, ToolResult(
  112. title=f"{label} 类型无效",
  113. output=f"工具 `{tool_name}` 的 {label} 须为 object 或可解析为 object 的 JSON 字符串,收到:{type(params).__name__}",
  114. error="invalid_params_type",
  115. )
  116. def _format_tool_list_body(data: dict[str, Any]) -> str:
  117. if not data.get("ok"):
  118. return json.dumps(data, ensure_ascii=False)
  119. tools = data.get("tools") or []
  120. if not isinstance(tools, list):
  121. return json.dumps(data, ensure_ascii=False)
  122. lines: list[str] = [
  123. "以下为 Node 适配层 GET /tools 返回的工具清单(名称须与 tool-call 的 tool 一致):",
  124. "",
  125. ]
  126. for t in tools[:200]:
  127. if not isinstance(t, dict):
  128. continue
  129. name = t.get("name") or ""
  130. desc = (t.get("description") or "")[:300]
  131. lines.append(f"- {name}: {desc}")
  132. if len(tools) > 200:
  133. lines.append(f"\n… 共 {len(tools)} 个,已截断显示前 200 个。")
  134. lines.append(
  135. "\n升级适配层后请以本列表为准;调用时使用 feishu_adapter_tool_call,"
  136. "params 必须为 **JSON 对象**(不要传转义后的字符串);字段名以各工具的 parameters 为准"
  137. "(例如 feishu_bitable_app.create 通常用 name 而非 app_name)。"
  138. )
  139. return "\n".join(lines)
  140. def _format_tool_call_response(data: dict[str, Any]) -> ToolResult:
  141. if data.get("ok"):
  142. # 始终回传完整 JSON,避免模型只看到 {"ok": true} 而缺少 result/app_token
  143. text = json.dumps(data, ensure_ascii=False, indent=2)
  144. if data.get("result") in (None, ""):
  145. text += (
  146. "\n\n【提示】成功响应中无有效 result:请确认 params 是否为空、字段名是否与 GET /tools 的 schema 一致;"
  147. "必要时先 feishu_adapter_list_tools。"
  148. )
  149. return ToolResult(title="飞书适配层 tool-call 成功", output=text)
  150. err = data.get("error") or "unknown_error"
  151. if err == "need_user_authorization":
  152. details = data.get("details") or {}
  153. hint = (
  154. "需要用户授权:适配层可能已在会话中推送 OAuth / 授权卡片,请提示用户按卡片完成授权后重试。"
  155. f"\n详情:{json.dumps(details, ensure_ascii=False)}"
  156. )
  157. return ToolResult(
  158. title="飞书适配层:需要用户授权",
  159. output=hint,
  160. long_term_memory="need_user_authorization",
  161. error="need_user_authorization",
  162. )
  163. return ToolResult(
  164. title="飞书适配层 tool-call 失败",
  165. output=json.dumps(data, ensure_ascii=False),
  166. error=str(err),
  167. )
  168. @tool(
  169. hidden_params=["context"],
  170. display={
  171. "zh": {
  172. "name": "列出飞书 Node 适配层工具",
  173. "params": {},
  174. },
  175. "en": {
  176. "name": "List Feishu HTTP adapter tools",
  177. "params": {},
  178. },
  179. },
  180. )
  181. async def feishu_adapter_list_tools(context: Optional[Dict[str, Any]] = None) -> ToolResult:
  182. """
  183. 调用适配层 ``GET /tools``,获取当前注册的 MCP/OAPI 工具名与 parameters 说明。
  184. 调用其他飞书相关能力前应先核对名称是否与文档一致。
  185. """
  186. url = f"{_adapter_base_url()}/tools"
  187. try:
  188. async with httpx.AsyncClient(timeout=FEISHU_HTTP_TIMEOUT) as client:
  189. resp = await client.get(url)
  190. try:
  191. data = resp.json()
  192. except Exception:
  193. return ToolResult(
  194. title="获取 /tools 失败",
  195. output=resp.text[:800],
  196. error=f"HTTP {resp.status_code}",
  197. )
  198. if resp.status_code >= 400:
  199. return ToolResult(
  200. title="获取 /tools 失败",
  201. output=json.dumps(data, ensure_ascii=False) if isinstance(data, dict) else resp.text[:800],
  202. error=f"HTTP {resp.status_code}",
  203. )
  204. except Exception as e:
  205. logger.exception("feishu_adapter_list_tools failed")
  206. return ToolResult(
  207. title="获取 /tools 失败",
  208. output=str(e),
  209. error=str(e),
  210. )
  211. if not isinstance(data, dict):
  212. return ToolResult(title="/tools 返回异常", output=str(data), error="invalid_shape")
  213. return ToolResult(
  214. title="飞书适配层工具列表",
  215. output=_format_tool_list_body(data),
  216. metadata={"raw_tools_count": len(data.get("tools") or []) if isinstance(data.get("tools"), list) else 0},
  217. )
  218. @tool(
  219. hidden_params=["context"],
  220. display={
  221. "zh": {
  222. "name": "调用飞书 Node 适配层 tool-call",
  223. "params": {
  224. "tool": "工具名(与 GET /tools 的 name 一致)",
  225. "params": "工具参数对象,对应 schema",
  226. "context_patch": "可选,覆盖 Trace 中的 feishu_adapter 字段(如 message_id)",
  227. },
  228. },
  229. "en": {
  230. "name": "Invoke Feishu HTTP adapter tool-call",
  231. "params": {
  232. "tool": "Tool name (same as GET /tools)",
  233. "params": "Arguments object per schema",
  234. "context_patch": "Optional overrides for adapter context",
  235. },
  236. },
  237. },
  238. )
  239. async def feishu_adapter_tool_call(
  240. tool: str,
  241. params: Optional[Dict[str, Any]] = None,
  242. context_patch: Optional[Dict[str, Any]] = None,
  243. context: Optional[Dict[str, Any]] = None,
  244. ) -> ToolResult:
  245. """
  246. 调用适配层 ``POST /tool-call``:在 Node 侧执行已注册的飞书/OAPI 工具。
  247. Trace 由 Gateway 写入的 ``feishu_adapter``(account_id、chat_id、message_id、
  248. sender_open_id 等)会自动并入 ``context``;必要时用 context_patch 覆盖当前消息的 message_id。
  249. **params**:须为对象;若上游误传 JSON 字符串,会尝试解析,失败则报错(不再静默发空 params)。
  250. **context_patch**:须为对象或合法 JSON 对象字符串;误传字符串时同样会解析。
  251. **tool**:与 ``GET /tools`` 的 ``name`` 完全一致;命名约定见注入的 feishu-bitable 等 SKILL,不确定时先 ``feishu_adapter_list_tools``。
  252. """
  253. name = (tool or "").strip()
  254. if not name:
  255. return ToolResult(title="参数错误", output="tool 不能为空", error="empty_tool")
  256. coerced, err = _coerce_tool_params(params, tool_name=name, label="params")
  257. if err is not None:
  258. return err
  259. patch_dict, patch_err = _coerce_tool_params(context_patch, tool_name=name, label="context_patch")
  260. if patch_err is not None:
  261. return patch_err
  262. adapter = await _load_feishu_adapter(context)
  263. uid = await _resolve_uid_for_adapter(context)
  264. node_ctx = _merge_to_node_context(adapter, patch_dict or None, uid)
  265. body: Dict[str, Any] = {
  266. "tool": name,
  267. "params": coerced,
  268. "context": node_ctx,
  269. }
  270. url = f"{_adapter_base_url()}/tool-call"
  271. try:
  272. async with httpx.AsyncClient(timeout=FEISHU_HTTP_TIMEOUT) as client:
  273. resp = await client.post(url, json=body)
  274. try:
  275. data = resp.json()
  276. except Exception:
  277. data = {"ok": False, "error": "invalid_json", "status_code": resp.status_code, "text": resp.text[:800]}
  278. except Exception as e:
  279. logger.exception("feishu_adapter_tool_call HTTP failed tool=%s", name)
  280. return ToolResult(
  281. title="tool-call 请求失败",
  282. output=str(e),
  283. error=str(e),
  284. )
  285. if not isinstance(data, dict):
  286. return ToolResult(title="tool-call 响应异常", output=str(data), error="invalid_shape")
  287. return _format_tool_call_response(data)
  288. @tool(
  289. hidden_params=["context"],
  290. display={
  291. "zh": {
  292. "name": "批量调用飞书 Node 适配层 tool-call",
  293. "params": {
  294. "calls": "数组,每项含 tool、params,可选 context 覆盖单条",
  295. "context_patch": "可选,合并到每条 call 的 context(后者优先)",
  296. },
  297. },
  298. "en": {
  299. "name": "Batch Feishu HTTP adapter tool-calls",
  300. "params": {
  301. "calls": "Array of {tool, params?, context?}",
  302. "context_patch": "Optional merged into each call context",
  303. },
  304. },
  305. },
  306. )
  307. async def feishu_adapter_tool_calls_batch(
  308. calls: List[Dict[str, Any]],
  309. context_patch: Optional[Dict[str, Any]] = None,
  310. context: Optional[Dict[str, Any]] = None,
  311. ) -> ToolResult:
  312. """
  313. 调用适配层 ``POST /tool-calls/batch``。每项与单次 tool-call 请求体结构相同,
  314. 可单独带 ``context``;会与 Trace 中的 feishu_adapter 及 context_patch 合并。
  315. """
  316. if not isinstance(calls, list) or not calls:
  317. return ToolResult(title="参数错误", output="calls 必须为非空数组", error="empty_calls")
  318. adapter = await _load_feishu_adapter(context)
  319. uid = await _resolve_uid_for_adapter(context)
  320. batch_patch, batch_patch_err = _coerce_tool_params(
  321. context_patch, tool_name="feishu_adapter_tool_calls_batch", label="context_patch"
  322. )
  323. if batch_patch_err is not None:
  324. return batch_patch_err
  325. base_ctx = _merge_to_node_context(adapter, batch_patch or None, uid)
  326. norm_calls: list[dict[str, Any]] = []
  327. for i, raw in enumerate(calls):
  328. if not isinstance(raw, dict):
  329. return ToolResult(title="参数错误", output=f"calls[{i}] 必须为对象", error="invalid_call_item")
  330. tname = raw.get("tool")
  331. if not isinstance(tname, str) or not tname.strip():
  332. return ToolResult(title="参数错误", output=f"calls[{i}].tool 无效", error="missing_tool")
  333. tname_stripped = tname.strip()
  334. p_raw = raw.get("params")
  335. p, p_err = _coerce_tool_params(p_raw, tool_name=tname_stripped, label=f"calls[{i}].params")
  336. if p_err is not None:
  337. return ToolResult(
  338. title="参数错误",
  339. output=f"calls[{i}]: {p_err.output}",
  340. error=p_err.error or "invalid_params",
  341. )
  342. c_extra, c_err = _coerce_tool_params(
  343. raw.get("context"), tool_name=tname_stripped, label=f"calls[{i}].context"
  344. )
  345. if c_err is not None:
  346. return ToolResult(
  347. title="参数错误",
  348. output=f"calls[{i}]: {c_err.output}",
  349. error=c_err.error or "invalid_context",
  350. )
  351. merged = dict(base_ctx)
  352. for k, v in c_extra.items():
  353. if v is not None and v != "":
  354. merged[k] = v
  355. norm_calls.append(
  356. {
  357. "tool": tname.strip(),
  358. "params": p,
  359. "context": merged,
  360. }
  361. )
  362. url = f"{_adapter_base_url()}/tool-calls/batch"
  363. try:
  364. async with httpx.AsyncClient(timeout=FEISHU_HTTP_TIMEOUT) as client:
  365. resp = await client.post(url, json={"calls": norm_calls})
  366. try:
  367. data = resp.json()
  368. except Exception:
  369. data = {"ok": False, "error": "invalid_json", "status_code": resp.status_code, "text": resp.text[:800]}
  370. except Exception as e:
  371. logger.exception("feishu_adapter_tool_calls_batch HTTP failed")
  372. return ToolResult(
  373. title="tool-calls/batch 请求失败",
  374. output=str(e),
  375. error=str(e),
  376. )
  377. if not isinstance(data, dict):
  378. return ToolResult(title="batch 响应异常", output=str(data), error="invalid_shape")
  379. results = data.get("results")
  380. if data.get("ok") and isinstance(results, list):
  381. any_auth = any(
  382. isinstance(r, dict) and r.get("error") == "need_user_authorization" for r in results
  383. )
  384. out_text = json.dumps(data, ensure_ascii=False, indent=2)
  385. if any_auth:
  386. return ToolResult(
  387. title="批量 tool-call 完成(含需授权项)",
  388. output=out_text
  389. + "\n\n若含 need_user_authorization:请提示用户查看会话中的授权卡片并完成 OAuth。",
  390. long_term_memory="need_user_authorization_in_batch",
  391. )
  392. return ToolResult(title="批量 tool-call 完成", output=out_text)
  393. return ToolResult(
  394. title="批量 tool-call 未全部成功",
  395. output=json.dumps(data, ensure_ascii=False),
  396. error=str(data.get("error") or "batch_failed"),
  397. )