|
|
@@ -0,0 +1,178 @@
|
|
|
+"""
|
|
|
+飞书执行器:通过 HTTP 调用 Agent 的 ``run_api``(``POST /api/traces`` / ``POST /api/traces/{id}/run``)。
|
|
|
+
|
|
|
+按飞书 ``user_id``(与 ``DefaultUserIdentityResolver`` 一致)维护 API ``trace_id``,与首次创建 / 续跑语义对齐。
|
|
|
+"""
|
|
|
+
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import asyncio
|
|
|
+import logging
|
|
|
+import uuid
|
|
|
+from typing import Any
|
|
|
+
|
|
|
+import httpx
|
|
|
+
|
|
|
+from gateway.core.channels.feishu.types import FeishuReplyContext, IncomingFeishuEvent
|
|
|
+
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+
|
|
|
+def _format_api_error(status_code: int, body_text: str) -> str:
|
|
|
+ try:
|
|
|
+ import json
|
|
|
+
|
|
|
+ data = json.loads(body_text)
|
|
|
+ detail = data.get("detail")
|
|
|
+ if isinstance(detail, str):
|
|
|
+ return detail
|
|
|
+ if isinstance(detail, list):
|
|
|
+ return json.dumps(detail, ensure_ascii=False)
|
|
|
+ if detail is not None:
|
|
|
+ return str(detail)
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ return (body_text or "")[:800] or f"HTTP {status_code}"
|
|
|
+
|
|
|
+
|
|
|
+def _append_feishu_context_block(
|
|
|
+ text: str,
|
|
|
+ event: IncomingFeishuEvent,
|
|
|
+ reply_context: FeishuReplyContext,
|
|
|
+) -> str:
|
|
|
+ """在用户文本后附加结构化上下文,便于后续工具(Feishu HTTP)读取。"""
|
|
|
+ core = text.strip() if text else ""
|
|
|
+ if not core:
|
|
|
+ core = "(空消息)"
|
|
|
+ lines = [
|
|
|
+ core,
|
|
|
+ "",
|
|
|
+ "[飞书上下文 · 工具调用时请使用下列字段,勿向用户复述本段]",
|
|
|
+ f"account_id={reply_context.account_id or ''}",
|
|
|
+ f"app_id={reply_context.app_id}",
|
|
|
+ f"chat_id={reply_context.chat_id}",
|
|
|
+ f"message_id={reply_context.message_id or ''}",
|
|
|
+ f"open_id={reply_context.open_id or ''}",
|
|
|
+ f"chat_type={event.chat_type or ''}",
|
|
|
+ ]
|
|
|
+ return "\n".join(lines)
|
|
|
+
|
|
|
+
|
|
|
+class FeishuHttpRunApiExecutor:
|
|
|
+ """调用 ``agent/trace/run_api`` 暴露的 Trace HTTP API,触发后台 Agent 运行。"""
|
|
|
+
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ *,
|
|
|
+ base_url: str,
|
|
|
+ timeout: float,
|
|
|
+ identity_resolver: Any,
|
|
|
+ model: str = "gpt-4o",
|
|
|
+ max_iterations: int = 200,
|
|
|
+ temperature: float = 0.3,
|
|
|
+ notify_on_submit: bool = True,
|
|
|
+ ) -> None:
|
|
|
+ self._base = base_url.rstrip("/")
|
|
|
+ self._timeout = timeout
|
|
|
+ self._identity = identity_resolver
|
|
|
+ self._model = model
|
|
|
+ self._max_iterations = max_iterations
|
|
|
+ self._temperature = temperature
|
|
|
+ self._notify = notify_on_submit
|
|
|
+ self._map_lock = asyncio.Lock()
|
|
|
+ self._api_trace_by_user: dict[str, str] = {}
|
|
|
+
|
|
|
+ async def handle_inbound_message(
|
|
|
+ self,
|
|
|
+ trace_id: str,
|
|
|
+ text: str,
|
|
|
+ reply_context: FeishuReplyContext,
|
|
|
+ connector: Any,
|
|
|
+ *,
|
|
|
+ event: IncomingFeishuEvent,
|
|
|
+ ) -> str:
|
|
|
+ _ = trace_id
|
|
|
+ user_id = self._identity.resolve_user_id(event)
|
|
|
+ content = _append_feishu_context_block(text, event, reply_context)
|
|
|
+ task_id = f"task-{uuid.uuid4()}"
|
|
|
+
|
|
|
+ async with self._map_lock:
|
|
|
+ api_trace_id = self._api_trace_by_user.get(user_id)
|
|
|
+
|
|
|
+ try:
|
|
|
+ async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
|
+ if api_trace_id is None:
|
|
|
+ resp = await client.post(
|
|
|
+ f"{self._base}/api/traces",
|
|
|
+ json={
|
|
|
+ "messages": [{"role": "user", "content": content}],
|
|
|
+ "model": self._model,
|
|
|
+ "temperature": self._temperature,
|
|
|
+ "max_iterations": self._max_iterations,
|
|
|
+ "uid": user_id,
|
|
|
+ "name": f"feishu-{user_id}",
|
|
|
+ },
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ resp = await client.post(
|
|
|
+ f"{self._base}/api/traces/{api_trace_id}/run",
|
|
|
+ json={"messages": [{"role": "user", "content": content}]},
|
|
|
+ )
|
|
|
+ except httpx.RequestError as exc:
|
|
|
+ logger.exception("FeishuHttpRunApiExecutor: Agent API 请求失败 user_id=%s", user_id)
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ f"[Gateway] 无法连接 Agent API({self._base}):{exc}",
|
|
|
+ )
|
|
|
+ return task_id
|
|
|
+
|
|
|
+ body_text = resp.text
|
|
|
+ if resp.status_code == 409:
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ "[Gateway] 当前会话在 Agent 侧仍在运行,请稍后再发消息。",
|
|
|
+ )
|
|
|
+ return task_id
|
|
|
+
|
|
|
+ if resp.status_code >= 400:
|
|
|
+ err = _format_api_error(resp.status_code, body_text)
|
|
|
+ logger.warning(
|
|
|
+ "FeishuHttpRunApiExecutor: API 错误 status=%s user_id=%s detail=%s",
|
|
|
+ resp.status_code,
|
|
|
+ user_id,
|
|
|
+ err,
|
|
|
+ )
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ f"[Gateway] Agent 启动失败({resp.status_code}):{err}",
|
|
|
+ )
|
|
|
+ return task_id
|
|
|
+
|
|
|
+ try:
|
|
|
+ data = resp.json()
|
|
|
+ except Exception:
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ "[Gateway] Agent API 返回非 JSON,已放弃解析。",
|
|
|
+ )
|
|
|
+ return task_id
|
|
|
+
|
|
|
+ resolved_id = data.get("trace_id")
|
|
|
+ if not isinstance(resolved_id, str) or not resolved_id:
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ "[Gateway] Agent API 响应缺少 trace_id。",
|
|
|
+ )
|
|
|
+ return task_id
|
|
|
+
|
|
|
+ async with self._map_lock:
|
|
|
+ if user_id not in self._api_trace_by_user:
|
|
|
+ self._api_trace_by_user[user_id] = resolved_id
|
|
|
+
|
|
|
+ if self._notify:
|
|
|
+ await connector.send_text(
|
|
|
+ reply_context,
|
|
|
+ f"[Gateway] 已提交 Agent(API trace_id={resolved_id}),后台执行中。",
|
|
|
+ )
|
|
|
+
|
|
|
+ return task_id
|