bridge.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  1. """
  2. 飞书 ↔ Agent Trace 桥接(模块 ``gateway.core.channels.feishu.bridge``)。
  3. 职责概览:用户从飞书发来消息后,经 ``FeishuHttpRunApiExecutor`` 调用 Agent 的 Trace HTTP API
  4. (``POST /api/traces`` 建链或 ``POST /api/traces/{id}/run`` 续跑),再经 WebSocket 订阅
  5. ``/api/traces/{id}/watch`` 跟单,把 **assistant 最终回复** 推回飞书;可选挂载 Workspace /
  6. ``gateway_exec``(Docker 容器)生命周期,与 Trace 终态联动。
  7. 文件内分区(单模块,避免过度拆包):
  8. 1. **Agent 请求体 / 飞书上下文** — ``append_feishu_context_block``、``feishu_adapter_payload`` 等
  9. 2. **Trace / WS 消息解析** — ``TERMINAL_STATUSES``、assistant 正文提取、``trace_watch_ws_url``
  10. 3. **跟单与 Typing** — ``poll_assistants_to_feishu``、``schedule_trace_followup``
  11. 4. **``FeishuHttpRunApiExecutor``** — 飞书入站入口
  12. 转发规则:不推 ``branch_type=reflection``;不推仍含 ``tool_calls`` 的中间轮;避免 ``description`` 与 ``text`` 重复拼接。
  13. """
  14. from __future__ import annotations
  15. import asyncio
  16. import json
  17. import logging
  18. import time
  19. import uuid
  20. from collections.abc import Awaitable, Callable
  21. from copy import copy
  22. from typing import Any
  23. import httpx
  24. from gateway.core.channels.feishu.types import FeishuReplyContext, IncomingFeishuEvent
  25. from gateway.core.lifecycle.trace.backend import LifecycleTraceBackend
  26. from gateway.core.lifecycle.workspace import WorkspaceManager
  27. __all__ = [
  28. "FeishuHttpRunApiExecutor",
  29. "FollowupFinishedCallback",
  30. "TERMINAL_STATUSES",
  31. "append_feishu_context_block",
  32. "feishu_adapter_payload",
  33. "format_api_error",
  34. "normalized_agent_trace_id",
  35. "schedule_trace_followup",
  36. ]
  37. logger = logging.getLogger(__name__)
  38. # =============================================================================
  39. # 1. Agent 请求体与飞书上下文
  40. # =============================================================================
  41. def normalized_agent_trace_id(raw: str) -> str | None:
  42. t = (raw or "").strip()
  43. return t if t else None
  44. def format_api_error(status_code: int, body_text: str) -> str:
  45. try:
  46. data = json.loads(body_text)
  47. detail = data.get("detail")
  48. if isinstance(detail, str):
  49. return detail
  50. if isinstance(detail, list):
  51. return json.dumps(detail, ensure_ascii=False)
  52. if detail is not None:
  53. return str(detail)
  54. except Exception:
  55. pass
  56. return (body_text or "")[:800] or f"HTTP {status_code}"
  57. def append_feishu_context_block(
  58. text: str,
  59. event: IncomingFeishuEvent,
  60. reply_context: FeishuReplyContext,
  61. ) -> str:
  62. core = text.strip() if text else ""
  63. if not core:
  64. core = "(空消息)"
  65. lines = [
  66. core,
  67. "",
  68. "[飞书上下文 · 工具调用时请使用下列字段,勿向用户复述本段]",
  69. f"account_id={reply_context.account_id or ''}",
  70. f"app_id={reply_context.app_id}",
  71. f"chat_id={reply_context.chat_id}",
  72. f"message_id={reply_context.message_id or ''}",
  73. f"open_id={reply_context.open_id or ''}",
  74. f"chat_type={event.chat_type or ''}",
  75. ]
  76. return "\n".join(lines)
  77. def feishu_adapter_payload(
  78. event: IncomingFeishuEvent,
  79. reply_context: FeishuReplyContext,
  80. ) -> dict[str, str]:
  81. return {
  82. "account_id": reply_context.account_id or "",
  83. "app_id": reply_context.app_id,
  84. "chat_id": reply_context.chat_id,
  85. "message_id": reply_context.message_id or "",
  86. "sender_open_id": reply_context.open_id or "",
  87. "chat_type": event.chat_type or "",
  88. }
  89. # =============================================================================
  90. # 2. Trace / WebSocket 消息解析
  91. # =============================================================================
  92. TERMINAL_STATUSES = frozenset({"completed", "failed", "stopped"})
  93. def assistant_content_has_tool_calls(msg: dict[str, Any]) -> bool:
  94. if msg.get("role") != "assistant":
  95. return False
  96. c = msg.get("content")
  97. if not isinstance(c, dict):
  98. return False
  99. tc = c.get("tool_calls")
  100. if tc is None:
  101. return False
  102. if isinstance(tc, list):
  103. return len(tc) > 0
  104. return bool(tc)
  105. def assistant_wire_to_feishu_text(msg: dict[str, Any]) -> str | None:
  106. if msg.get("role") != "assistant":
  107. return None
  108. content = msg.get("content")
  109. parts: list[str] = []
  110. if isinstance(content, dict):
  111. text = (content.get("text") or "").strip()
  112. tool_calls = content.get("tool_calls")
  113. desc = (msg.get("description") or "").strip()
  114. if text:
  115. parts.append(text)
  116. if tool_calls:
  117. if not text:
  118. parts.append(desc if desc else "[工具调用]")
  119. elif desc and desc != text:
  120. parts.append(desc)
  121. elif isinstance(content, str) and content.strip():
  122. parts.append(content.strip())
  123. else:
  124. desc = (msg.get("description") or "").strip()
  125. if desc:
  126. parts.append(desc)
  127. if not parts:
  128. return None
  129. return "\n".join(parts)
  130. def truncate_for_im(text: str, max_chars: int) -> str:
  131. if len(text) <= max_chars:
  132. return text
  133. return text[: max_chars - 80] + "\n\n…(内容过长已截断)"
  134. def trace_watch_ws_url(http_base: str, trace_id: str) -> str:
  135. b = http_base.strip().rstrip("/")
  136. if b.startswith("https://"):
  137. origin = "wss://" + b[8:]
  138. elif b.startswith("http://"):
  139. origin = "ws://" + b[7:]
  140. else:
  141. origin = "ws://" + b
  142. return f"{origin}/api/traces/{trace_id}/watch"
  143. def message_sequence(msg: dict[str, Any]) -> int | None:
  144. s = msg.get("sequence")
  145. if s is None:
  146. return None
  147. if isinstance(s, int):
  148. return s
  149. if isinstance(s, float):
  150. return int(s)
  151. if isinstance(s, str):
  152. try:
  153. return int(s)
  154. except ValueError:
  155. return None
  156. try:
  157. return int(s)
  158. except (TypeError, ValueError):
  159. return None
  160. def watch_ws_payload_to_dict(raw: Any) -> dict[str, Any] | None:
  161. if isinstance(raw, (bytes, bytearray)):
  162. raw = raw.decode("utf-8", errors="replace")
  163. if not isinstance(raw, str):
  164. return None
  165. try:
  166. data = json.loads(raw)
  167. except (json.JSONDecodeError, TypeError):
  168. return None
  169. return data if isinstance(data, dict) else None
  170. # =============================================================================
  171. # 3. Trace 跟单、Typing、HTTP 兜底
  172. # =============================================================================
  173. FollowupFinishedCallback = Callable[[str, str], Awaitable[None]]
  174. """``(trace_id, reason)`` · ``terminal`` | ``timeout`` | ``not_found``"""
  175. _poll_tasks: dict[str, asyncio.Task[None]] = {}
  176. _poll_tasks_lock = asyncio.Lock()
  177. _assistant_sent_sequences: dict[str, set[int]] = {}
  178. _typing_cleanup_lock = asyncio.Lock()
  179. _pending_typing_by_trace: dict[str, list[tuple[str, str | None]]] = {}
  180. async def remove_typing_reaction_safe(
  181. connector: Any,
  182. message_id: str,
  183. account_id: str | None,
  184. emoji: str,
  185. *,
  186. log_label: str,
  187. ) -> None:
  188. try:
  189. r = await connector.remove_bot_reactions_for_emoji(
  190. message_id,
  191. emoji,
  192. account_id=account_id,
  193. )
  194. if not r.get("ok"):
  195. logger.warning(
  196. "%s: remove reaction failed mid=%s result=%s",
  197. log_label,
  198. message_id,
  199. r,
  200. )
  201. except Exception:
  202. logger.exception("%s: remove reaction exception mid=%s", log_label, message_id)
  203. async def register_pending_typing_cleanup(
  204. trace_id: str,
  205. message_id: str,
  206. account_id: str | None,
  207. ) -> None:
  208. async with _typing_cleanup_lock:
  209. _pending_typing_by_trace.setdefault(trace_id, []).append((message_id, account_id))
  210. async def remove_typing_immediate(
  211. connector: Any,
  212. message_id: str | None,
  213. account_id: str | None,
  214. emoji: str,
  215. ) -> None:
  216. if not message_id:
  217. return
  218. await remove_typing_reaction_safe(
  219. connector,
  220. message_id,
  221. account_id,
  222. emoji,
  223. log_label="feishu typing",
  224. )
  225. async def flush_pending_typing_cleanups(
  226. connector: Any,
  227. trace_id: str,
  228. emoji: str,
  229. ) -> None:
  230. async with _typing_cleanup_lock:
  231. pairs = _pending_typing_by_trace.pop(trace_id, [])
  232. for mid, acc in pairs:
  233. await remove_typing_reaction_safe(
  234. connector,
  235. mid,
  236. acc,
  237. emoji,
  238. log_label="feishu typing cleanup",
  239. )
  240. async def inbound_fail_reply(
  241. connector: Any,
  242. reply_context: FeishuReplyContext,
  243. *,
  244. typing_placed: bool,
  245. typing_emoji: str,
  246. message: str,
  247. ) -> None:
  248. if typing_placed:
  249. await remove_typing_immediate(
  250. connector,
  251. reply_context.message_id,
  252. reply_context.account_id,
  253. typing_emoji,
  254. )
  255. await connector.send_text(reply_context, message)
  256. async def forward_one_assistant_to_feishu(
  257. m: dict[str, Any],
  258. *,
  259. sent_sequences: set[int],
  260. reply_ctx: FeishuReplyContext,
  261. connector: Any,
  262. max_text_chars: int,
  263. ) -> None:
  264. seq = message_sequence(m)
  265. if seq is None or m.get("role") != "assistant":
  266. return
  267. if seq in sent_sequences:
  268. return
  269. if m.get("branch_type") == "reflection":
  270. sent_sequences.add(seq)
  271. return
  272. if assistant_content_has_tool_calls(m):
  273. sent_sequences.add(seq)
  274. return
  275. body = assistant_wire_to_feishu_text(m)
  276. if body is None:
  277. sent_sequences.add(seq)
  278. return
  279. body = truncate_for_im(body, max_text_chars)
  280. try:
  281. result = await connector.send_text(reply_ctx, body)
  282. if result.get("ok"):
  283. sent_sequences.add(seq)
  284. else:
  285. logger.error("feishu forward: send_text failed seq=%s result=%s", seq, result)
  286. except Exception:
  287. logger.exception("feishu forward: send_text exception seq=%s", seq)
  288. async def poll_assistants_to_feishu(
  289. *,
  290. agent_base_url: str,
  291. trace_id: str,
  292. reply_ctx: FeishuReplyContext,
  293. connector: Any,
  294. poll_interval: float,
  295. poll_request_timeout: float,
  296. terminal_grace_rounds: int,
  297. poll_max_seconds: float,
  298. max_text_chars: int,
  299. forward_assistants: bool = True,
  300. typing_emoji_for_cleanup: str = "Typing",
  301. on_finished: FollowupFinishedCallback | None = None,
  302. ) -> None:
  303. if trace_id not in _assistant_sent_sequences:
  304. _assistant_sent_sequences[trace_id] = set()
  305. sent_sequences = _assistant_sent_sequences[trace_id]
  306. grace = 0
  307. started = time.monotonic()
  308. base = agent_base_url.rstrip("/")
  309. ws = None
  310. try:
  311. import websockets
  312. ws = await websockets.connect(
  313. trace_watch_ws_url(base, trace_id),
  314. max_size=10_000_000,
  315. ping_interval=20,
  316. ping_timeout=60,
  317. )
  318. logger.info("feishu: trace watch WS connected trace_id=%s", trace_id)
  319. except Exception as e:
  320. logger.warning("feishu: trace watch WS connect failed: %s", e)
  321. ws = None
  322. forward_warned = False
  323. exit_reason: str | None = None
  324. async def _dispatch_watch_event(data: dict[str, Any]) -> str:
  325. ev = data.get("event")
  326. if ev == "message_added" and forward_assistants:
  327. msg = data.get("message")
  328. if isinstance(msg, dict):
  329. await forward_one_assistant_to_feishu(
  330. msg,
  331. sent_sequences=sent_sequences,
  332. reply_ctx=reply_ctx,
  333. connector=connector,
  334. max_text_chars=max_text_chars,
  335. )
  336. if ev == "trace_status_changed":
  337. st = data.get("status")
  338. if isinstance(st, str) and st in TERMINAL_STATUSES:
  339. return st
  340. if ev == "trace_completed":
  341. return "completed"
  342. return "running"
  343. try:
  344. while True:
  345. if poll_max_seconds > 0 and (time.monotonic() - started) >= poll_max_seconds:
  346. logger.warning(
  347. "feishu watch: trace_id=%s stopped by poll_max_seconds=%s",
  348. trace_id,
  349. poll_max_seconds,
  350. )
  351. exit_reason = "timeout"
  352. break
  353. status_hint = "running"
  354. if ws is not None:
  355. stream = ws
  356. try:
  357. raw = await asyncio.wait_for(stream.recv(), timeout=poll_interval)
  358. except asyncio.TimeoutError:
  359. raw = None
  360. except Exception as e:
  361. logger.warning("feishu watch WS error, HTTP status fallback: %s", e)
  362. try:
  363. await stream.close()
  364. except Exception:
  365. pass
  366. ws = None
  367. raw = None
  368. while raw is not None:
  369. data = watch_ws_payload_to_dict(raw)
  370. if data is not None:
  371. st = await _dispatch_watch_event(data)
  372. if st in TERMINAL_STATUSES:
  373. status_hint = st
  374. try:
  375. raw = await asyncio.wait_for(stream.recv(), timeout=0.001)
  376. except asyncio.TimeoutError:
  377. raw = None
  378. except Exception:
  379. raw = None
  380. else:
  381. await asyncio.sleep(poll_interval)
  382. if forward_assistants and not forward_warned:
  383. logger.error(
  384. "feishu: WebSocket 不可用,无法推送 assistant;"
  385. "仅 HTTP 查询 trace 状态以结束跟单(不拉 messages)"
  386. )
  387. forward_warned = True
  388. effective = status_hint
  389. if ws is None:
  390. try:
  391. async with httpx.AsyncClient(timeout=poll_request_timeout) as client:
  392. tr = await client.get(f"{base}/api/traces/{trace_id}")
  393. if tr.status_code == 404:
  394. logger.warning("feishu watch: trace %s not found, stop", trace_id)
  395. exit_reason = "not_found"
  396. break
  397. if tr.status_code >= 400:
  398. logger.warning(
  399. "feishu watch: GET trace failed status=%s body=%s",
  400. tr.status_code,
  401. tr.text[:300],
  402. )
  403. else:
  404. body = tr.json()
  405. trace_obj = body.get("trace") or {}
  406. st = str(trace_obj.get("status") or "running")
  407. if st in TERMINAL_STATUSES:
  408. effective = st
  409. except httpx.RequestError as exc:
  410. logger.warning("feishu watch: HTTP status check error trace_id=%s err=%s", trace_id, exc)
  411. if effective in TERMINAL_STATUSES:
  412. grace += 1
  413. if grace >= terminal_grace_rounds:
  414. exit_reason = "terminal"
  415. break
  416. else:
  417. grace = 0
  418. finally:
  419. if ws is not None:
  420. try:
  421. await ws.close()
  422. except Exception:
  423. pass
  424. await flush_pending_typing_cleanups(connector, trace_id, typing_emoji_for_cleanup)
  425. cur = asyncio.current_task()
  426. async with _poll_tasks_lock:
  427. if _poll_tasks.get(trace_id) is cur:
  428. _ = _poll_tasks.pop(trace_id, None)
  429. if on_finished is not None and exit_reason is not None:
  430. try:
  431. await on_finished(trace_id, exit_reason)
  432. except Exception:
  433. logger.exception(
  434. "feishu watch: on_finished failed trace_id=%s reason=%s",
  435. trace_id,
  436. exit_reason,
  437. )
  438. def schedule_trace_followup(
  439. *,
  440. agent_base_url: str,
  441. trace_id: str,
  442. reply_context: FeishuReplyContext,
  443. connector: Any,
  444. poll_interval: float,
  445. poll_request_timeout: float,
  446. terminal_grace_rounds: int,
  447. poll_max_seconds: float,
  448. max_text_chars: int,
  449. forward_assistants: bool,
  450. typing_emoji: str,
  451. on_finished: FollowupFinishedCallback | None = None,
  452. ) -> None:
  453. async def _runner() -> None:
  454. await poll_assistants_to_feishu(
  455. agent_base_url=agent_base_url,
  456. trace_id=trace_id,
  457. reply_ctx=reply_context,
  458. connector=connector,
  459. poll_interval=poll_interval,
  460. poll_request_timeout=poll_request_timeout,
  461. terminal_grace_rounds=terminal_grace_rounds,
  462. poll_max_seconds=poll_max_seconds,
  463. max_text_chars=max_text_chars,
  464. forward_assistants=forward_assistants,
  465. typing_emoji_for_cleanup=typing_emoji,
  466. on_finished=on_finished,
  467. )
  468. async def _spawn() -> None:
  469. async with _poll_tasks_lock:
  470. existing = _poll_tasks.get(trace_id)
  471. if existing is not None and not existing.done():
  472. return
  473. _poll_tasks[trace_id] = asyncio.create_task(_runner())
  474. try:
  475. loop = asyncio.get_running_loop()
  476. except RuntimeError:
  477. return
  478. _ = loop.create_task(_spawn())
  479. # =============================================================================
  480. # 4. FeishuHttpRunApiExecutor
  481. # =============================================================================
  482. class FeishuHttpRunApiExecutor:
  483. """调用 Agent Trace HTTP API;WebSocket 将 assistant 转发到飞书。"""
  484. def __init__(
  485. self,
  486. *,
  487. base_url: str,
  488. timeout: float,
  489. identity_resolver: Any,
  490. model: str = "qwen3.5-flash",
  491. max_iterations: int = 200,
  492. temperature: float = 0.3,
  493. notify_on_submit: bool = True,
  494. poll_assistant_messages: bool = True,
  495. poll_interval_seconds: float = 1.0,
  496. poll_request_timeout: float = 30.0,
  497. poll_terminal_grace_rounds: int = 2,
  498. poll_max_seconds: float = 0.0,
  499. assistant_max_text_chars: int = 8000,
  500. typing_reaction_enabled: bool = True,
  501. typing_reaction_emoji: str = "Typing",
  502. workspace_manager: WorkspaceManager | None = None,
  503. workspace_prefix: str = "feishu",
  504. channel_id: str = "feishu",
  505. lifecycle_trace_backend: LifecycleTraceBackend | None = None,
  506. stop_container_on_trace_terminal: bool = False,
  507. stop_container_on_trace_not_found: bool = True,
  508. release_ref_on_trace_terminal: bool = False,
  509. ) -> None:
  510. self._base = base_url.rstrip("/")
  511. self._timeout = timeout
  512. self._identity = identity_resolver
  513. self._model = model
  514. self._max_iterations = max_iterations
  515. self._temperature = temperature
  516. self._notify = notify_on_submit
  517. self._poll_assistants = poll_assistant_messages
  518. self._poll_interval = poll_interval_seconds
  519. self._poll_req_timeout = poll_request_timeout
  520. self._poll_grace = poll_terminal_grace_rounds
  521. self._poll_max_seconds = poll_max_seconds
  522. self._assistant_max_chars = assistant_max_text_chars
  523. self._typing_reaction_enabled = typing_reaction_enabled
  524. self._typing_emoji = typing_reaction_emoji
  525. self._workspace_manager = workspace_manager
  526. self._workspace_prefix = workspace_prefix
  527. self._channel_id = channel_id
  528. self._lifecycle_trace_backend = lifecycle_trace_backend
  529. self._stop_container_on_trace_terminal = stop_container_on_trace_terminal
  530. self._stop_container_on_trace_not_found = stop_container_on_trace_not_found
  531. self._release_ref_on_trace_terminal = release_ref_on_trace_terminal
  532. def _gateway_exec_for_user(self, user_id: str) -> dict[str, Any] | None:
  533. wm = self._workspace_manager
  534. if wm is None:
  535. return None
  536. wid = f"{self._workspace_prefix}:{user_id}"
  537. cid = wm.get_workspace_container_id(wid)
  538. if not cid:
  539. return None
  540. return {
  541. "docker_container": cid,
  542. "container_user": "agent",
  543. "container_workdir": "/home/agent/workspace",
  544. }
  545. async def handle_inbound_message(
  546. self,
  547. existing_agent_trace_id: str,
  548. text: str,
  549. reply_context: FeishuReplyContext,
  550. connector: Any,
  551. *,
  552. event: IncomingFeishuEvent,
  553. ) -> tuple[str, str]:
  554. user_id = self._identity.resolve_user_id(event)
  555. content = append_feishu_context_block(text, event, reply_context)
  556. task_id = f"task-{uuid.uuid4()}"
  557. typing_placed = False
  558. if (
  559. self._typing_reaction_enabled
  560. and reply_context.message_id
  561. and event.event_type == "message"
  562. ):
  563. try:
  564. react_res = await connector.add_message_reaction(
  565. reply_context.message_id,
  566. self._typing_emoji,
  567. account_id=reply_context.account_id,
  568. )
  569. typing_placed = bool(react_res.get("ok"))
  570. if not typing_placed:
  571. logger.warning(
  572. "feishu typing: add reaction failed mid=%s result=%s",
  573. reply_context.message_id,
  574. react_res,
  575. )
  576. except Exception:
  577. logger.exception(
  578. "feishu typing: add reaction exception mid=%s",
  579. reply_context.message_id,
  580. )
  581. api_trace_id = normalized_agent_trace_id(existing_agent_trace_id)
  582. adapter = feishu_adapter_payload(event, reply_context)
  583. gateway_exec = self._gateway_exec_for_user(user_id)
  584. try:
  585. async with httpx.AsyncClient(timeout=self._timeout) as client:
  586. if api_trace_id is None:
  587. body: dict[str, Any] = {
  588. "messages": [{"role": "user", "content": content}],
  589. "model": self._model,
  590. "temperature": self._temperature,
  591. "max_iterations": self._max_iterations,
  592. "uid": user_id,
  593. "name": f"feishu-{user_id}",
  594. "feishu_adapter": adapter,
  595. }
  596. if gateway_exec:
  597. body["gateway_exec"] = gateway_exec
  598. resp = await client.post(f"{self._base}/api/traces", json=body)
  599. else:
  600. body = {
  601. "messages": [{"role": "user", "content": content}],
  602. "feishu_adapter": adapter,
  603. }
  604. if gateway_exec:
  605. body["gateway_exec"] = gateway_exec
  606. resp = await client.post(
  607. f"{self._base}/api/traces/{api_trace_id}/run",
  608. json=body,
  609. )
  610. except httpx.RequestError as exc:
  611. logger.exception("FeishuHttpRunApiExecutor: Agent API 请求失败 user_id=%s", user_id)
  612. await inbound_fail_reply(
  613. connector,
  614. reply_context,
  615. typing_placed=typing_placed,
  616. typing_emoji=self._typing_emoji,
  617. message=f"[Gateway] 无法连接 Agent API({self._base}):{exc}",
  618. )
  619. return task_id, ""
  620. body_text = resp.text
  621. if resp.status_code == 409:
  622. await inbound_fail_reply(
  623. connector,
  624. reply_context,
  625. typing_placed=typing_placed,
  626. typing_emoji=self._typing_emoji,
  627. message="[Gateway] 当前会话在 Agent 侧仍在运行,请稍后再发消息。",
  628. )
  629. return task_id, ""
  630. if resp.status_code >= 400:
  631. err = format_api_error(resp.status_code, body_text)
  632. logger.warning(
  633. "FeishuHttpRunApiExecutor: API 错误 status=%s user_id=%s detail=%s",
  634. resp.status_code,
  635. user_id,
  636. err,
  637. )
  638. await inbound_fail_reply(
  639. connector,
  640. reply_context,
  641. typing_placed=typing_placed,
  642. typing_emoji=self._typing_emoji,
  643. message=f"[Gateway] Agent 启动失败({resp.status_code}):{err}",
  644. )
  645. return task_id, ""
  646. try:
  647. data = resp.json()
  648. except Exception:
  649. await inbound_fail_reply(
  650. connector,
  651. reply_context,
  652. typing_placed=typing_placed,
  653. typing_emoji=self._typing_emoji,
  654. message="[Gateway] Agent API 返回非 JSON,已放弃解析。",
  655. )
  656. return task_id, ""
  657. resolved_id = data.get("trace_id")
  658. if not isinstance(resolved_id, str) or not resolved_id:
  659. await inbound_fail_reply(
  660. connector,
  661. reply_context,
  662. typing_placed=typing_placed,
  663. typing_emoji=self._typing_emoji,
  664. message="[Gateway] Agent API 响应缺少 trace_id。",
  665. )
  666. return task_id, ""
  667. if self._notify:
  668. await connector.send_text(
  669. reply_context,
  670. f"[Gateway] 已提交 Agent(API trace_id={resolved_id}),后台执行中。",
  671. )
  672. if typing_placed:
  673. user_mid = reply_context.message_id
  674. if user_mid:
  675. await register_pending_typing_cleanup(
  676. resolved_id,
  677. user_mid,
  678. reply_context.account_id,
  679. )
  680. if self._poll_assistants or typing_placed:
  681. wid = f"{self._workspace_prefix}:{user_id}"
  682. async def _on_followup_finished(tid: str, reason: str) -> None:
  683. if tid != resolved_id:
  684. return
  685. wm = self._workspace_manager
  686. if wm is None:
  687. return
  688. stop = False
  689. if reason == "terminal" and self._stop_container_on_trace_terminal:
  690. stop = True
  691. elif reason == "not_found" and self._stop_container_on_trace_not_found:
  692. stop = True
  693. if stop:
  694. await wm.stop_workspace_sandbox(wid)
  695. if (
  696. reason == "terminal"
  697. and self._release_ref_on_trace_terminal
  698. and self._lifecycle_trace_backend is not None
  699. ):
  700. await self._lifecycle_trace_backend.forget_trace_binding(
  701. self._channel_id,
  702. user_id,
  703. workspace_id=wid,
  704. )
  705. schedule_trace_followup(
  706. agent_base_url=self._base,
  707. trace_id=resolved_id,
  708. reply_context=copy(reply_context),
  709. connector=connector,
  710. poll_interval=self._poll_interval,
  711. poll_request_timeout=self._poll_req_timeout,
  712. terminal_grace_rounds=self._poll_grace,
  713. poll_max_seconds=self._poll_max_seconds,
  714. max_text_chars=self._assistant_max_chars,
  715. forward_assistants=self._poll_assistants,
  716. typing_emoji=self._typing_emoji,
  717. on_finished=_on_followup_finished,
  718. )
  719. return task_id, resolved_id