|
@@ -1,11 +1,19 @@
|
|
|
"""
|
|
"""
|
|
|
-飞书执行器:HTTP 调用 Agent ``run_api``,WebSocket 订阅 ``/api/traces/{id}/watch``,
|
|
|
|
|
-将 assistant 消息转发到飞书(不轮询 messages)。
|
|
|
|
|
|
|
+飞书 ↔ Agent Trace 桥接(模块 ``gateway.core.channels.feishu.bridge``)。
|
|
|
|
|
|
|
|
-转发规则:
|
|
|
|
|
-- 不转发 ``branch_type=reflection``(完成后知识提取侧分支)
|
|
|
|
|
-- 不转发仍含 ``tool_calls`` 的中间轮,只推工具执行后的最终回复
|
|
|
|
|
-- 提取正文时避免 ``description`` 与 ``text`` 重复拼接
|
|
|
|
|
|
|
+职责概览:用户从飞书发来消息后,经 ``FeishuHttpRunApiExecutor`` 调用 Agent 的 Trace HTTP API
|
|
|
|
|
+(``POST /api/traces`` 建链或 ``POST /api/traces/{id}/run`` 续跑),再经 WebSocket 订阅
|
|
|
|
|
+``/api/traces/{id}/watch`` 跟单,把 **assistant 最终回复** 推回飞书;可选挂载 Workspace /
|
|
|
|
|
+``gateway_exec``(Docker 容器)生命周期,与 Trace 终态联动。
|
|
|
|
|
+
|
|
|
|
|
+文件内分区(单模块,避免过度拆包):
|
|
|
|
|
+
|
|
|
|
|
+1. **Agent 请求体 / 飞书上下文** — ``append_feishu_context_block``、``feishu_adapter_payload`` 等
|
|
|
|
|
+2. **Trace / WS 消息解析** — ``TERMINAL_STATUSES``、assistant 正文提取、``trace_watch_ws_url``
|
|
|
|
|
+3. **跟单与 Typing** — ``poll_assistants_to_feishu``、``schedule_trace_followup``
|
|
|
|
|
+4. **``FeishuHttpRunApiExecutor``** — 飞书入站入口
|
|
|
|
|
+
|
|
|
|
|
+转发规则:不推 ``branch_type=reflection``;不推仍含 ``tool_calls`` 的中间轮;避免 ``description`` 与 ``text`` 重复拼接。
|
|
|
"""
|
|
"""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
from __future__ import annotations
|
|
@@ -15,31 +23,41 @@ import json
|
|
|
import logging
|
|
import logging
|
|
|
import time
|
|
import time
|
|
|
import uuid
|
|
import uuid
|
|
|
|
|
+from collections.abc import Awaitable, Callable
|
|
|
from copy import copy
|
|
from copy import copy
|
|
|
from typing import Any
|
|
from typing import Any
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
|
from gateway.core.channels.feishu.types import FeishuReplyContext, IncomingFeishuEvent
|
|
from gateway.core.channels.feishu.types import FeishuReplyContext, IncomingFeishuEvent
|
|
|
|
|
+from gateway.core.lifecycle.trace.backend import LifecycleTraceBackend
|
|
|
|
|
+from gateway.core.lifecycle.workspace import WorkspaceManager
|
|
|
|
|
+
|
|
|
|
|
+__all__ = [
|
|
|
|
|
+ "FeishuHttpRunApiExecutor",
|
|
|
|
|
+ "FollowupFinishedCallback",
|
|
|
|
|
+ "TERMINAL_STATUSES",
|
|
|
|
|
+ "append_feishu_context_block",
|
|
|
|
|
+ "feishu_adapter_payload",
|
|
|
|
|
+ "format_api_error",
|
|
|
|
|
+ "normalized_agent_trace_id",
|
|
|
|
|
+ "schedule_trace_followup",
|
|
|
|
|
+]
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
-_TERMINAL_STATUSES = frozenset({"completed", "failed", "stopped"})
|
|
|
|
|
|
|
|
|
|
-# 同一 trace 仅一个跟单任务,避免并发重复推送
|
|
|
|
|
-_poll_tasks: dict[str, asyncio.Task[None]] = {}
|
|
|
|
|
-_poll_tasks_lock = asyncio.Lock()
|
|
|
|
|
-# trace_id → 已成功推送到飞书的 assistant sequence(跨多次 run,避免重复发送)
|
|
|
|
|
-_assistant_sent_sequences: dict[str, set[int]] = {}
|
|
|
|
|
-# trace_id → 待任务结束时移除 Typing 表情的用户消息
|
|
|
|
|
-_typing_cleanup_lock = asyncio.Lock()
|
|
|
|
|
-_pending_typing_by_trace: dict[str, list[tuple[str, str | None]]] = {}
|
|
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# 1. Agent 请求体与飞书上下文
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- HTTP / Agent API -----
|
|
|
|
|
|
|
+def normalized_agent_trace_id(raw: str) -> str | None:
|
|
|
|
|
+ t = (raw or "").strip()
|
|
|
|
|
+ return t if t else None
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _format_api_error(status_code: int, body_text: str) -> str:
|
|
|
|
|
|
|
+def format_api_error(status_code: int, body_text: str) -> str:
|
|
|
try:
|
|
try:
|
|
|
data = json.loads(body_text)
|
|
data = json.loads(body_text)
|
|
|
detail = data.get("detail")
|
|
detail = data.get("detail")
|
|
@@ -54,15 +72,11 @@ def _format_api_error(status_code: int, body_text: str) -> str:
|
|
|
return (body_text or "")[:800] or f"HTTP {status_code}"
|
|
return (body_text or "")[:800] or f"HTTP {status_code}"
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- 飞书上下文(用户消息 / Trace.context)-----
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def _append_feishu_context_block(
|
|
|
|
|
|
|
+def append_feishu_context_block(
|
|
|
text: str,
|
|
text: str,
|
|
|
event: IncomingFeishuEvent,
|
|
event: IncomingFeishuEvent,
|
|
|
reply_context: FeishuReplyContext,
|
|
reply_context: FeishuReplyContext,
|
|
|
) -> str:
|
|
) -> str:
|
|
|
- """在用户文本后附加结构化上下文,便于后续工具(Feishu HTTP)读取。"""
|
|
|
|
|
core = text.strip() if text else ""
|
|
core = text.strip() if text else ""
|
|
|
if not core:
|
|
if not core:
|
|
|
core = "(空消息)"
|
|
core = "(空消息)"
|
|
@@ -80,11 +94,10 @@ def _append_feishu_context_block(
|
|
|
return "\n".join(lines)
|
|
return "\n".join(lines)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _feishu_adapter_payload(
|
|
|
|
|
|
|
+def feishu_adapter_payload(
|
|
|
event: IncomingFeishuEvent,
|
|
event: IncomingFeishuEvent,
|
|
|
reply_context: FeishuReplyContext,
|
|
reply_context: FeishuReplyContext,
|
|
|
) -> dict[str, str]:
|
|
) -> dict[str, str]:
|
|
|
- """写入 Trace.context['feishu_adapter'],供 feishu_adapter_tool_call 对齐 Node /tool-call。"""
|
|
|
|
|
return {
|
|
return {
|
|
|
"account_id": reply_context.account_id or "",
|
|
"account_id": reply_context.account_id or "",
|
|
|
"app_id": reply_context.app_id,
|
|
"app_id": reply_context.app_id,
|
|
@@ -95,11 +108,14 @@ def _feishu_adapter_payload(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- Trace assistant → 飞书正文 -----
|
|
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# 2. Trace / WebSocket 消息解析
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
|
|
|
|
|
+TERMINAL_STATUSES = frozenset({"completed", "failed", "stopped"})
|
|
|
|
|
|
|
|
-def _assistant_content_has_tool_calls(msg: dict[str, Any]) -> bool:
|
|
|
|
|
- """assistant 是否仍带有待执行的 tool_calls(中间轮,不当最终回复推给用户)。"""
|
|
|
|
|
|
|
+
|
|
|
|
|
+def assistant_content_has_tool_calls(msg: dict[str, Any]) -> bool:
|
|
|
if msg.get("role") != "assistant":
|
|
if msg.get("role") != "assistant":
|
|
|
return False
|
|
return False
|
|
|
c = msg.get("content")
|
|
c = msg.get("content")
|
|
@@ -113,8 +129,7 @@ def _assistant_content_has_tool_calls(msg: dict[str, Any]) -> bool:
|
|
|
return bool(tc)
|
|
return bool(tc)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _assistant_wire_to_feishu_text(msg: dict[str, Any]) -> str | None:
|
|
|
|
|
- """从 Trace 消息 dict 提取可发给用户的文本。"""
|
|
|
|
|
|
|
+def assistant_wire_to_feishu_text(msg: dict[str, Any]) -> str | None:
|
|
|
if msg.get("role") != "assistant":
|
|
if msg.get("role") != "assistant":
|
|
|
return None
|
|
return None
|
|
|
content = msg.get("content")
|
|
content = msg.get("content")
|
|
@@ -143,14 +158,13 @@ def _assistant_wire_to_feishu_text(msg: dict[str, Any]) -> str | None:
|
|
|
return "\n".join(parts)
|
|
return "\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _truncate_for_im(text: str, max_chars: int) -> str:
|
|
|
|
|
|
|
+def truncate_for_im(text: str, max_chars: int) -> str:
|
|
|
if len(text) <= max_chars:
|
|
if len(text) <= max_chars:
|
|
|
return text
|
|
return text
|
|
|
return text[: max_chars - 80] + "\n\n…(内容过长已截断)"
|
|
return text[: max_chars - 80] + "\n\n…(内容过长已截断)"
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _trace_watch_ws_url(http_base: str, trace_id: str) -> str:
|
|
|
|
|
- """Agent HTTP 根地址 → ``/api/traces/{id}/watch`` 的 WebSocket URL。"""
|
|
|
|
|
|
|
+def trace_watch_ws_url(http_base: str, trace_id: str) -> str:
|
|
|
b = http_base.strip().rstrip("/")
|
|
b = http_base.strip().rstrip("/")
|
|
|
if b.startswith("https://"):
|
|
if b.startswith("https://"):
|
|
|
origin = "wss://" + b[8:]
|
|
origin = "wss://" + b[8:]
|
|
@@ -161,7 +175,7 @@ def _trace_watch_ws_url(http_base: str, trace_id: str) -> str:
|
|
|
return f"{origin}/api/traces/{trace_id}/watch"
|
|
return f"{origin}/api/traces/{trace_id}/watch"
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _message_sequence(msg: dict[str, Any]) -> int | None:
|
|
|
|
|
|
|
+def message_sequence(msg: dict[str, Any]) -> int | None:
|
|
|
s = msg.get("sequence")
|
|
s = msg.get("sequence")
|
|
|
if s is None:
|
|
if s is None:
|
|
|
return None
|
|
return None
|
|
@@ -180,7 +194,7 @@ def _message_sequence(msg: dict[str, Any]) -> int | None:
|
|
|
return None
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _watch_ws_payload_to_dict(raw: Any) -> dict[str, Any] | None:
|
|
|
|
|
|
|
+def watch_ws_payload_to_dict(raw: Any) -> dict[str, Any] | None:
|
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
|
raw = raw.decode("utf-8", errors="replace")
|
|
raw = raw.decode("utf-8", errors="replace")
|
|
|
if not isinstance(raw, str):
|
|
if not isinstance(raw, str):
|
|
@@ -192,10 +206,21 @@ def _watch_ws_payload_to_dict(raw: Any) -> dict[str, Any] | None:
|
|
|
return data if isinstance(data, dict) else None
|
|
return data if isinstance(data, dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- Typing 表情 -----
|
|
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# 3. Trace 跟单、Typing、HTTP 兜底
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
|
|
|
|
|
+FollowupFinishedCallback = Callable[[str, str], Awaitable[None]]
|
|
|
|
|
+"""``(trace_id, reason)`` · ``terminal`` | ``timeout`` | ``not_found``"""
|
|
|
|
|
|
|
|
-async def _remove_typing_reaction_safe(
|
|
|
|
|
|
|
+_poll_tasks: dict[str, asyncio.Task[None]] = {}
|
|
|
|
|
+_poll_tasks_lock = asyncio.Lock()
|
|
|
|
|
+_assistant_sent_sequences: dict[str, set[int]] = {}
|
|
|
|
|
+_typing_cleanup_lock = asyncio.Lock()
|
|
|
|
|
+_pending_typing_by_trace: dict[str, list[tuple[str, str | None]]] = {}
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+async def remove_typing_reaction_safe(
|
|
|
connector: Any,
|
|
connector: Any,
|
|
|
message_id: str,
|
|
message_id: str,
|
|
|
account_id: str | None,
|
|
account_id: str | None,
|
|
@@ -220,7 +245,7 @@ async def _remove_typing_reaction_safe(
|
|
|
logger.exception("%s: remove reaction exception mid=%s", log_label, message_id)
|
|
logger.exception("%s: remove reaction exception mid=%s", log_label, message_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _register_pending_typing_cleanup(
|
|
|
|
|
|
|
+async def register_pending_typing_cleanup(
|
|
|
trace_id: str,
|
|
trace_id: str,
|
|
|
message_id: str,
|
|
message_id: str,
|
|
|
account_id: str | None,
|
|
account_id: str | None,
|
|
@@ -229,7 +254,7 @@ async def _register_pending_typing_cleanup(
|
|
|
_pending_typing_by_trace.setdefault(trace_id, []).append((message_id, account_id))
|
|
_pending_typing_by_trace.setdefault(trace_id, []).append((message_id, account_id))
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _remove_typing_immediate(
|
|
|
|
|
|
|
+async def remove_typing_immediate(
|
|
|
connector: Any,
|
|
connector: Any,
|
|
|
message_id: str | None,
|
|
message_id: str | None,
|
|
|
account_id: str | None,
|
|
account_id: str | None,
|
|
@@ -237,7 +262,7 @@ async def _remove_typing_immediate(
|
|
|
) -> None:
|
|
) -> None:
|
|
|
if not message_id:
|
|
if not message_id:
|
|
|
return
|
|
return
|
|
|
- await _remove_typing_reaction_safe(
|
|
|
|
|
|
|
+ await remove_typing_reaction_safe(
|
|
|
connector,
|
|
connector,
|
|
|
message_id,
|
|
message_id,
|
|
|
account_id,
|
|
account_id,
|
|
@@ -246,7 +271,7 @@ async def _remove_typing_immediate(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _flush_pending_typing_cleanups(
|
|
|
|
|
|
|
+async def flush_pending_typing_cleanups(
|
|
|
connector: Any,
|
|
connector: Any,
|
|
|
trace_id: str,
|
|
trace_id: str,
|
|
|
emoji: str,
|
|
emoji: str,
|
|
@@ -254,7 +279,7 @@ async def _flush_pending_typing_cleanups(
|
|
|
async with _typing_cleanup_lock:
|
|
async with _typing_cleanup_lock:
|
|
|
pairs = _pending_typing_by_trace.pop(trace_id, [])
|
|
pairs = _pending_typing_by_trace.pop(trace_id, [])
|
|
|
for mid, acc in pairs:
|
|
for mid, acc in pairs:
|
|
|
- await _remove_typing_reaction_safe(
|
|
|
|
|
|
|
+ await remove_typing_reaction_safe(
|
|
|
connector,
|
|
connector,
|
|
|
mid,
|
|
mid,
|
|
|
acc,
|
|
acc,
|
|
@@ -263,10 +288,25 @@ async def _flush_pending_typing_cleanups(
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- 跟单:WS 转发 assistant -----
|
|
|
|
|
|
|
+async def inbound_fail_reply(
|
|
|
|
|
+ connector: Any,
|
|
|
|
|
+ reply_context: FeishuReplyContext,
|
|
|
|
|
+ *,
|
|
|
|
|
+ typing_placed: bool,
|
|
|
|
|
+ typing_emoji: str,
|
|
|
|
|
+ message: str,
|
|
|
|
|
+) -> None:
|
|
|
|
|
+ if typing_placed:
|
|
|
|
|
+ await remove_typing_immediate(
|
|
|
|
|
+ connector,
|
|
|
|
|
+ reply_context.message_id,
|
|
|
|
|
+ reply_context.account_id,
|
|
|
|
|
+ typing_emoji,
|
|
|
|
|
+ )
|
|
|
|
|
+ await connector.send_text(reply_context, message)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _forward_one_assistant_to_feishu(
|
|
|
|
|
|
|
+async def forward_one_assistant_to_feishu(
|
|
|
m: dict[str, Any],
|
|
m: dict[str, Any],
|
|
|
*,
|
|
*,
|
|
|
sent_sequences: set[int],
|
|
sent_sequences: set[int],
|
|
@@ -274,7 +314,7 @@ async def _forward_one_assistant_to_feishu(
|
|
|
connector: Any,
|
|
connector: Any,
|
|
|
max_text_chars: int,
|
|
max_text_chars: int,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
- seq = _message_sequence(m)
|
|
|
|
|
|
|
+ seq = message_sequence(m)
|
|
|
if seq is None or m.get("role") != "assistant":
|
|
if seq is None or m.get("role") != "assistant":
|
|
|
return
|
|
return
|
|
|
if seq in sent_sequences:
|
|
if seq in sent_sequences:
|
|
@@ -282,14 +322,14 @@ async def _forward_one_assistant_to_feishu(
|
|
|
if m.get("branch_type") == "reflection":
|
|
if m.get("branch_type") == "reflection":
|
|
|
sent_sequences.add(seq)
|
|
sent_sequences.add(seq)
|
|
|
return
|
|
return
|
|
|
- if _assistant_content_has_tool_calls(m):
|
|
|
|
|
|
|
+ if assistant_content_has_tool_calls(m):
|
|
|
sent_sequences.add(seq)
|
|
sent_sequences.add(seq)
|
|
|
return
|
|
return
|
|
|
- body = _assistant_wire_to_feishu_text(m)
|
|
|
|
|
|
|
+ body = assistant_wire_to_feishu_text(m)
|
|
|
if body is None:
|
|
if body is None:
|
|
|
sent_sequences.add(seq)
|
|
sent_sequences.add(seq)
|
|
|
return
|
|
return
|
|
|
- body = _truncate_for_im(body, max_text_chars)
|
|
|
|
|
|
|
+ body = truncate_for_im(body, max_text_chars)
|
|
|
try:
|
|
try:
|
|
|
result = await connector.send_text(reply_ctx, body)
|
|
result = await connector.send_text(reply_ctx, body)
|
|
|
if result.get("ok"):
|
|
if result.get("ok"):
|
|
@@ -300,7 +340,7 @@ async def _forward_one_assistant_to_feishu(
|
|
|
logger.exception("feishu forward: send_text exception seq=%s", seq)
|
|
logger.exception("feishu forward: send_text exception seq=%s", seq)
|
|
|
|
|
|
|
|
|
|
|
|
|
-async def _poll_assistants_to_feishu(
|
|
|
|
|
|
|
+async def poll_assistants_to_feishu(
|
|
|
*,
|
|
*,
|
|
|
agent_base_url: str,
|
|
agent_base_url: str,
|
|
|
trace_id: str,
|
|
trace_id: str,
|
|
@@ -313,11 +353,8 @@ async def _poll_assistants_to_feishu(
|
|
|
max_text_chars: int,
|
|
max_text_chars: int,
|
|
|
forward_assistants: bool = True,
|
|
forward_assistants: bool = True,
|
|
|
typing_emoji_for_cleanup: str = "Typing",
|
|
typing_emoji_for_cleanup: str = "Typing",
|
|
|
|
|
+ on_finished: FollowupFinishedCallback | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
- """
|
|
|
|
|
- WebSocket 订阅直至终态;转发 ``message_added`` 中的 assistant。
|
|
|
|
|
- WS 不可用时仅 ``GET /api/traces/{id}`` 轮询状态(结束跟单 + 清理 Typing),不拉 messages。
|
|
|
|
|
- """
|
|
|
|
|
if trace_id not in _assistant_sent_sequences:
|
|
if trace_id not in _assistant_sent_sequences:
|
|
|
_assistant_sent_sequences[trace_id] = set()
|
|
_assistant_sent_sequences[trace_id] = set()
|
|
|
sent_sequences = _assistant_sent_sequences[trace_id]
|
|
sent_sequences = _assistant_sent_sequences[trace_id]
|
|
@@ -330,7 +367,7 @@ async def _poll_assistants_to_feishu(
|
|
|
import websockets
|
|
import websockets
|
|
|
|
|
|
|
|
ws = await websockets.connect(
|
|
ws = await websockets.connect(
|
|
|
- _trace_watch_ws_url(base, trace_id),
|
|
|
|
|
|
|
+ trace_watch_ws_url(base, trace_id),
|
|
|
max_size=10_000_000,
|
|
max_size=10_000_000,
|
|
|
ping_interval=20,
|
|
ping_interval=20,
|
|
|
ping_timeout=60,
|
|
ping_timeout=60,
|
|
@@ -341,13 +378,14 @@ async def _poll_assistants_to_feishu(
|
|
|
ws = None
|
|
ws = None
|
|
|
|
|
|
|
|
forward_warned = False
|
|
forward_warned = False
|
|
|
|
|
+ exit_reason: str | None = None
|
|
|
|
|
|
|
|
async def _dispatch_watch_event(data: dict[str, Any]) -> str:
|
|
async def _dispatch_watch_event(data: dict[str, Any]) -> str:
|
|
|
ev = data.get("event")
|
|
ev = data.get("event")
|
|
|
if ev == "message_added" and forward_assistants:
|
|
if ev == "message_added" and forward_assistants:
|
|
|
msg = data.get("message")
|
|
msg = data.get("message")
|
|
|
if isinstance(msg, dict):
|
|
if isinstance(msg, dict):
|
|
|
- await _forward_one_assistant_to_feishu(
|
|
|
|
|
|
|
+ await forward_one_assistant_to_feishu(
|
|
|
msg,
|
|
msg,
|
|
|
sent_sequences=sent_sequences,
|
|
sent_sequences=sent_sequences,
|
|
|
reply_ctx=reply_ctx,
|
|
reply_ctx=reply_ctx,
|
|
@@ -356,7 +394,7 @@ async def _poll_assistants_to_feishu(
|
|
|
)
|
|
)
|
|
|
if ev == "trace_status_changed":
|
|
if ev == "trace_status_changed":
|
|
|
st = data.get("status")
|
|
st = data.get("status")
|
|
|
- if isinstance(st, str) and st in _TERMINAL_STATUSES:
|
|
|
|
|
|
|
+ if isinstance(st, str) and st in TERMINAL_STATUSES:
|
|
|
return st
|
|
return st
|
|
|
if ev == "trace_completed":
|
|
if ev == "trace_completed":
|
|
|
return "completed"
|
|
return "completed"
|
|
@@ -370,32 +408,34 @@ async def _poll_assistants_to_feishu(
|
|
|
trace_id,
|
|
trace_id,
|
|
|
poll_max_seconds,
|
|
poll_max_seconds,
|
|
|
)
|
|
)
|
|
|
|
|
+ exit_reason = "timeout"
|
|
|
break
|
|
break
|
|
|
|
|
|
|
|
status_hint = "running"
|
|
status_hint = "running"
|
|
|
|
|
|
|
|
if ws is not None:
|
|
if ws is not None:
|
|
|
|
|
+ stream = ws
|
|
|
try:
|
|
try:
|
|
|
- raw = await asyncio.wait_for(ws.recv(), timeout=poll_interval)
|
|
|
|
|
|
|
+ raw = await asyncio.wait_for(stream.recv(), timeout=poll_interval)
|
|
|
except asyncio.TimeoutError:
|
|
except asyncio.TimeoutError:
|
|
|
raw = None
|
|
raw = None
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
|
logger.warning("feishu watch WS error, HTTP status fallback: %s", e)
|
|
logger.warning("feishu watch WS error, HTTP status fallback: %s", e)
|
|
|
try:
|
|
try:
|
|
|
- await ws.close()
|
|
|
|
|
|
|
+ await stream.close()
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
ws = None
|
|
ws = None
|
|
|
raw = None
|
|
raw = None
|
|
|
|
|
|
|
|
while raw is not None:
|
|
while raw is not None:
|
|
|
- data = _watch_ws_payload_to_dict(raw)
|
|
|
|
|
|
|
+ data = watch_ws_payload_to_dict(raw)
|
|
|
if data is not None:
|
|
if data is not None:
|
|
|
st = await _dispatch_watch_event(data)
|
|
st = await _dispatch_watch_event(data)
|
|
|
- if st in _TERMINAL_STATUSES:
|
|
|
|
|
|
|
+ if st in TERMINAL_STATUSES:
|
|
|
status_hint = st
|
|
status_hint = st
|
|
|
try:
|
|
try:
|
|
|
- raw = await asyncio.wait_for(ws.recv(), timeout=0.001)
|
|
|
|
|
|
|
+ raw = await asyncio.wait_for(stream.recv(), timeout=0.001)
|
|
|
except asyncio.TimeoutError:
|
|
except asyncio.TimeoutError:
|
|
|
raw = None
|
|
raw = None
|
|
|
except Exception:
|
|
except Exception:
|
|
@@ -416,6 +456,7 @@ async def _poll_assistants_to_feishu(
|
|
|
tr = await client.get(f"{base}/api/traces/{trace_id}")
|
|
tr = await client.get(f"{base}/api/traces/{trace_id}")
|
|
|
if tr.status_code == 404:
|
|
if tr.status_code == 404:
|
|
|
logger.warning("feishu watch: trace %s not found, stop", trace_id)
|
|
logger.warning("feishu watch: trace %s not found, stop", trace_id)
|
|
|
|
|
+ exit_reason = "not_found"
|
|
|
break
|
|
break
|
|
|
if tr.status_code >= 400:
|
|
if tr.status_code >= 400:
|
|
|
logger.warning(
|
|
logger.warning(
|
|
@@ -427,14 +468,15 @@ async def _poll_assistants_to_feishu(
|
|
|
body = tr.json()
|
|
body = tr.json()
|
|
|
trace_obj = body.get("trace") or {}
|
|
trace_obj = body.get("trace") or {}
|
|
|
st = str(trace_obj.get("status") or "running")
|
|
st = str(trace_obj.get("status") or "running")
|
|
|
- if st in _TERMINAL_STATUSES:
|
|
|
|
|
|
|
+ if st in TERMINAL_STATUSES:
|
|
|
effective = st
|
|
effective = st
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
logger.warning("feishu watch: HTTP status check error trace_id=%s err=%s", trace_id, exc)
|
|
logger.warning("feishu watch: HTTP status check error trace_id=%s err=%s", trace_id, exc)
|
|
|
|
|
|
|
|
- if effective in _TERMINAL_STATUSES:
|
|
|
|
|
|
|
+ if effective in TERMINAL_STATUSES:
|
|
|
grace += 1
|
|
grace += 1
|
|
|
if grace >= terminal_grace_rounds:
|
|
if grace >= terminal_grace_rounds:
|
|
|
|
|
+ exit_reason = "terminal"
|
|
|
break
|
|
break
|
|
|
else:
|
|
else:
|
|
|
grace = 0
|
|
grace = 0
|
|
@@ -444,14 +486,23 @@ async def _poll_assistants_to_feishu(
|
|
|
await ws.close()
|
|
await ws.close()
|
|
|
except Exception:
|
|
except Exception:
|
|
|
pass
|
|
pass
|
|
|
- await _flush_pending_typing_cleanups(connector, trace_id, typing_emoji_for_cleanup)
|
|
|
|
|
|
|
+ await flush_pending_typing_cleanups(connector, trace_id, typing_emoji_for_cleanup)
|
|
|
cur = asyncio.current_task()
|
|
cur = asyncio.current_task()
|
|
|
async with _poll_tasks_lock:
|
|
async with _poll_tasks_lock:
|
|
|
if _poll_tasks.get(trace_id) is cur:
|
|
if _poll_tasks.get(trace_id) is cur:
|
|
|
_ = _poll_tasks.pop(trace_id, None)
|
|
_ = _poll_tasks.pop(trace_id, None)
|
|
|
|
|
+ if on_finished is not None and exit_reason is not None:
|
|
|
|
|
+ try:
|
|
|
|
|
+ await on_finished(trace_id, exit_reason)
|
|
|
|
|
+ except Exception:
|
|
|
|
|
+ logger.exception(
|
|
|
|
|
+ "feishu watch: on_finished failed trace_id=%s reason=%s",
|
|
|
|
|
+ trace_id,
|
|
|
|
|
+ exit_reason,
|
|
|
|
|
+ )
|
|
|
|
|
|
|
|
|
|
|
|
|
-def _schedule_trace_followup(
|
|
|
|
|
|
|
+def schedule_trace_followup(
|
|
|
*,
|
|
*,
|
|
|
agent_base_url: str,
|
|
agent_base_url: str,
|
|
|
trace_id: str,
|
|
trace_id: str,
|
|
@@ -464,11 +515,10 @@ def _schedule_trace_followup(
|
|
|
max_text_chars: int,
|
|
max_text_chars: int,
|
|
|
forward_assistants: bool,
|
|
forward_assistants: bool,
|
|
|
typing_emoji: str,
|
|
typing_emoji: str,
|
|
|
|
|
+ on_finished: FollowupFinishedCallback | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
- """同一 trace 仅保留一个活跃跟单任务。"""
|
|
|
|
|
-
|
|
|
|
|
async def _runner() -> None:
|
|
async def _runner() -> None:
|
|
|
- await _poll_assistants_to_feishu(
|
|
|
|
|
|
|
+ await poll_assistants_to_feishu(
|
|
|
agent_base_url=agent_base_url,
|
|
agent_base_url=agent_base_url,
|
|
|
trace_id=trace_id,
|
|
trace_id=trace_id,
|
|
|
reply_ctx=reply_context,
|
|
reply_ctx=reply_context,
|
|
@@ -480,6 +530,7 @@ def _schedule_trace_followup(
|
|
|
max_text_chars=max_text_chars,
|
|
max_text_chars=max_text_chars,
|
|
|
forward_assistants=forward_assistants,
|
|
forward_assistants=forward_assistants,
|
|
|
typing_emoji_for_cleanup=typing_emoji,
|
|
typing_emoji_for_cleanup=typing_emoji,
|
|
|
|
|
+ on_finished=on_finished,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
async def _spawn() -> None:
|
|
async def _spawn() -> None:
|
|
@@ -496,30 +547,13 @@ def _schedule_trace_followup(
|
|
|
_ = loop.create_task(_spawn())
|
|
_ = loop.create_task(_spawn())
|
|
|
|
|
|
|
|
|
|
|
|
|
-# ----- 入站:提交 Agent -----
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-async def _inbound_fail_reply(
|
|
|
|
|
- connector: Any,
|
|
|
|
|
- reply_context: FeishuReplyContext,
|
|
|
|
|
- *,
|
|
|
|
|
- typing_placed: bool,
|
|
|
|
|
- typing_emoji: str,
|
|
|
|
|
- message: str,
|
|
|
|
|
-) -> None:
|
|
|
|
|
- """错误路径:先摘 Typing(若曾加上),再向用户发送说明。"""
|
|
|
|
|
- if typing_placed:
|
|
|
|
|
- await _remove_typing_immediate(
|
|
|
|
|
- connector,
|
|
|
|
|
- reply_context.message_id,
|
|
|
|
|
- reply_context.account_id,
|
|
|
|
|
- typing_emoji,
|
|
|
|
|
- )
|
|
|
|
|
- await connector.send_text(reply_context, message)
|
|
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
+# 4. FeishuHttpRunApiExecutor
|
|
|
|
|
+# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
class FeishuHttpRunApiExecutor:
|
|
class FeishuHttpRunApiExecutor:
|
|
|
- """调用 Agent Trace HTTP API,WebSocket 将 assistant 转发到飞书。"""
|
|
|
|
|
|
|
+ """调用 Agent Trace HTTP API;WebSocket 将 assistant 转发到飞书。"""
|
|
|
|
|
|
|
|
def __init__(
|
|
def __init__(
|
|
|
self,
|
|
self,
|
|
@@ -539,6 +573,13 @@ class FeishuHttpRunApiExecutor:
|
|
|
assistant_max_text_chars: int = 8000,
|
|
assistant_max_text_chars: int = 8000,
|
|
|
typing_reaction_enabled: bool = True,
|
|
typing_reaction_enabled: bool = True,
|
|
|
typing_reaction_emoji: str = "Typing",
|
|
typing_reaction_emoji: str = "Typing",
|
|
|
|
|
+ workspace_manager: WorkspaceManager | None = None,
|
|
|
|
|
+ workspace_prefix: str = "feishu",
|
|
|
|
|
+ channel_id: str = "feishu",
|
|
|
|
|
+ lifecycle_trace_backend: LifecycleTraceBackend | None = None,
|
|
|
|
|
+ stop_container_on_trace_terminal: bool = True,
|
|
|
|
|
+ stop_container_on_trace_not_found: bool = True,
|
|
|
|
|
+ release_ref_on_trace_terminal: bool = False,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
self._base = base_url.rstrip("/")
|
|
self._base = base_url.rstrip("/")
|
|
|
self._timeout = timeout
|
|
self._timeout = timeout
|
|
@@ -555,25 +596,42 @@ class FeishuHttpRunApiExecutor:
|
|
|
self._assistant_max_chars = assistant_max_text_chars
|
|
self._assistant_max_chars = assistant_max_text_chars
|
|
|
self._typing_reaction_enabled = typing_reaction_enabled
|
|
self._typing_reaction_enabled = typing_reaction_enabled
|
|
|
self._typing_emoji = typing_reaction_emoji
|
|
self._typing_emoji = typing_reaction_emoji
|
|
|
- self._map_lock = asyncio.Lock()
|
|
|
|
|
- self._api_trace_by_user: dict[str, str] = {}
|
|
|
|
|
|
|
+ self._workspace_manager = workspace_manager
|
|
|
|
|
+ self._workspace_prefix = workspace_prefix
|
|
|
|
|
+ self._channel_id = channel_id
|
|
|
|
|
+ self._lifecycle_trace_backend = lifecycle_trace_backend
|
|
|
|
|
+ self._stop_container_on_trace_terminal = stop_container_on_trace_terminal
|
|
|
|
|
+ self._stop_container_on_trace_not_found = stop_container_on_trace_not_found
|
|
|
|
|
+ self._release_ref_on_trace_terminal = release_ref_on_trace_terminal
|
|
|
|
|
+
|
|
|
|
|
+ def _gateway_exec_for_user(self, user_id: str) -> dict[str, Any] | None:
|
|
|
|
|
+ wm = self._workspace_manager
|
|
|
|
|
+ if wm is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+ wid = f"{self._workspace_prefix}:{user_id}"
|
|
|
|
|
+ cid = wm.get_workspace_container_id(wid)
|
|
|
|
|
+ if not cid:
|
|
|
|
|
+ return None
|
|
|
|
|
+ return {
|
|
|
|
|
+ "docker_container": cid,
|
|
|
|
|
+ "container_user": "agent",
|
|
|
|
|
+ "container_workdir": "/home/agent/workspace",
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
async def handle_inbound_message(
|
|
async def handle_inbound_message(
|
|
|
self,
|
|
self,
|
|
|
- trace_id: str,
|
|
|
|
|
|
|
+ existing_agent_trace_id: str,
|
|
|
text: str,
|
|
text: str,
|
|
|
reply_context: FeishuReplyContext,
|
|
reply_context: FeishuReplyContext,
|
|
|
connector: Any,
|
|
connector: Any,
|
|
|
*,
|
|
*,
|
|
|
event: IncomingFeishuEvent,
|
|
event: IncomingFeishuEvent,
|
|
|
- ) -> str:
|
|
|
|
|
- _ = trace_id
|
|
|
|
|
|
|
+ ) -> tuple[str, str]:
|
|
|
user_id = self._identity.resolve_user_id(event)
|
|
user_id = self._identity.resolve_user_id(event)
|
|
|
- content = _append_feishu_context_block(text, event, reply_context)
|
|
|
|
|
|
|
+ content = append_feishu_context_block(text, event, reply_context)
|
|
|
task_id = f"task-{uuid.uuid4()}"
|
|
task_id = f"task-{uuid.uuid4()}"
|
|
|
|
|
|
|
|
typing_placed = False
|
|
typing_placed = False
|
|
|
- # 仅对用户发来的 IM 消息打「输入中」表情;卡片交互 / 表情续跑等事件的 message_id 常为机器人消息,避免对其加 reaction。
|
|
|
|
|
if (
|
|
if (
|
|
|
self._typing_reaction_enabled
|
|
self._typing_reaction_enabled
|
|
|
and reply_context.message_id
|
|
and reply_context.message_id
|
|
@@ -598,99 +656,97 @@ class FeishuHttpRunApiExecutor:
|
|
|
reply_context.message_id,
|
|
reply_context.message_id,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- async with self._map_lock:
|
|
|
|
|
- api_trace_id = self._api_trace_by_user.get(user_id)
|
|
|
|
|
-
|
|
|
|
|
- feishu_adapter = _feishu_adapter_payload(event, reply_context)
|
|
|
|
|
|
|
+ api_trace_id = normalized_agent_trace_id(existing_agent_trace_id)
|
|
|
|
|
+ adapter = feishu_adapter_payload(event, reply_context)
|
|
|
|
|
+ gateway_exec = self._gateway_exec_for_user(user_id)
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
|
|
if api_trace_id is None:
|
|
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}",
|
|
|
|
|
- "feishu_adapter": feishu_adapter,
|
|
|
|
|
- },
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ body: dict[str, Any] = {
|
|
|
|
|
+ "messages": [{"role": "user", "content": content}],
|
|
|
|
|
+ "model": self._model,
|
|
|
|
|
+ "temperature": self._temperature,
|
|
|
|
|
+ "max_iterations": self._max_iterations,
|
|
|
|
|
+ "uid": user_id,
|
|
|
|
|
+ "name": f"feishu-{user_id}",
|
|
|
|
|
+ "feishu_adapter": adapter,
|
|
|
|
|
+ }
|
|
|
|
|
+ if gateway_exec:
|
|
|
|
|
+ body["gateway_exec"] = gateway_exec
|
|
|
|
|
+ resp = await client.post(f"{self._base}/api/traces", json=body)
|
|
|
else:
|
|
else:
|
|
|
|
|
+ body = {
|
|
|
|
|
+ "messages": [{"role": "user", "content": content}],
|
|
|
|
|
+ "feishu_adapter": adapter,
|
|
|
|
|
+ }
|
|
|
|
|
+ if gateway_exec:
|
|
|
|
|
+ body["gateway_exec"] = gateway_exec
|
|
|
resp = await client.post(
|
|
resp = await client.post(
|
|
|
f"{self._base}/api/traces/{api_trace_id}/run",
|
|
f"{self._base}/api/traces/{api_trace_id}/run",
|
|
|
- json={
|
|
|
|
|
- "messages": [{"role": "user", "content": content}],
|
|
|
|
|
- "feishu_adapter": feishu_adapter,
|
|
|
|
|
- },
|
|
|
|
|
|
|
+ json=body,
|
|
|
)
|
|
)
|
|
|
except httpx.RequestError as exc:
|
|
except httpx.RequestError as exc:
|
|
|
logger.exception("FeishuHttpRunApiExecutor: Agent API 请求失败 user_id=%s", user_id)
|
|
logger.exception("FeishuHttpRunApiExecutor: Agent API 请求失败 user_id=%s", user_id)
|
|
|
- await _inbound_fail_reply(
|
|
|
|
|
|
|
+ await inbound_fail_reply(
|
|
|
connector,
|
|
connector,
|
|
|
reply_context,
|
|
reply_context,
|
|
|
typing_placed=typing_placed,
|
|
typing_placed=typing_placed,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
message=f"[Gateway] 无法连接 Agent API({self._base}):{exc}",
|
|
message=f"[Gateway] 无法连接 Agent API({self._base}):{exc}",
|
|
|
)
|
|
)
|
|
|
- return task_id
|
|
|
|
|
|
|
+ return task_id, ""
|
|
|
|
|
|
|
|
body_text = resp.text
|
|
body_text = resp.text
|
|
|
if resp.status_code == 409:
|
|
if resp.status_code == 409:
|
|
|
- await _inbound_fail_reply(
|
|
|
|
|
|
|
+ await inbound_fail_reply(
|
|
|
connector,
|
|
connector,
|
|
|
reply_context,
|
|
reply_context,
|
|
|
typing_placed=typing_placed,
|
|
typing_placed=typing_placed,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
message="[Gateway] 当前会话在 Agent 侧仍在运行,请稍后再发消息。",
|
|
message="[Gateway] 当前会话在 Agent 侧仍在运行,请稍后再发消息。",
|
|
|
)
|
|
)
|
|
|
- return task_id
|
|
|
|
|
|
|
+ return task_id, ""
|
|
|
|
|
|
|
|
if resp.status_code >= 400:
|
|
if resp.status_code >= 400:
|
|
|
- err = _format_api_error(resp.status_code, body_text)
|
|
|
|
|
|
|
+ err = format_api_error(resp.status_code, body_text)
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
"FeishuHttpRunApiExecutor: API 错误 status=%s user_id=%s detail=%s",
|
|
"FeishuHttpRunApiExecutor: API 错误 status=%s user_id=%s detail=%s",
|
|
|
resp.status_code,
|
|
resp.status_code,
|
|
|
user_id,
|
|
user_id,
|
|
|
err,
|
|
err,
|
|
|
)
|
|
)
|
|
|
- await _inbound_fail_reply(
|
|
|
|
|
|
|
+ await inbound_fail_reply(
|
|
|
connector,
|
|
connector,
|
|
|
reply_context,
|
|
reply_context,
|
|
|
typing_placed=typing_placed,
|
|
typing_placed=typing_placed,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
message=f"[Gateway] Agent 启动失败({resp.status_code}):{err}",
|
|
message=f"[Gateway] Agent 启动失败({resp.status_code}):{err}",
|
|
|
)
|
|
)
|
|
|
- return task_id
|
|
|
|
|
|
|
+ return task_id, ""
|
|
|
|
|
|
|
|
try:
|
|
try:
|
|
|
data = resp.json()
|
|
data = resp.json()
|
|
|
except Exception:
|
|
except Exception:
|
|
|
- await _inbound_fail_reply(
|
|
|
|
|
|
|
+ await inbound_fail_reply(
|
|
|
connector,
|
|
connector,
|
|
|
reply_context,
|
|
reply_context,
|
|
|
typing_placed=typing_placed,
|
|
typing_placed=typing_placed,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
message="[Gateway] Agent API 返回非 JSON,已放弃解析。",
|
|
message="[Gateway] Agent API 返回非 JSON,已放弃解析。",
|
|
|
)
|
|
)
|
|
|
- return task_id
|
|
|
|
|
|
|
+ return task_id, ""
|
|
|
|
|
|
|
|
resolved_id = data.get("trace_id")
|
|
resolved_id = data.get("trace_id")
|
|
|
if not isinstance(resolved_id, str) or not resolved_id:
|
|
if not isinstance(resolved_id, str) or not resolved_id:
|
|
|
- await _inbound_fail_reply(
|
|
|
|
|
|
|
+ await inbound_fail_reply(
|
|
|
connector,
|
|
connector,
|
|
|
reply_context,
|
|
reply_context,
|
|
|
typing_placed=typing_placed,
|
|
typing_placed=typing_placed,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
message="[Gateway] Agent API 响应缺少 trace_id。",
|
|
message="[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
|
|
|
|
|
|
|
+ return task_id, ""
|
|
|
|
|
|
|
|
if self._notify:
|
|
if self._notify:
|
|
|
await connector.send_text(
|
|
await connector.send_text(
|
|
@@ -701,14 +757,40 @@ class FeishuHttpRunApiExecutor:
|
|
|
if typing_placed:
|
|
if typing_placed:
|
|
|
user_mid = reply_context.message_id
|
|
user_mid = reply_context.message_id
|
|
|
if user_mid:
|
|
if user_mid:
|
|
|
- await _register_pending_typing_cleanup(
|
|
|
|
|
|
|
+ await register_pending_typing_cleanup(
|
|
|
resolved_id,
|
|
resolved_id,
|
|
|
user_mid,
|
|
user_mid,
|
|
|
reply_context.account_id,
|
|
reply_context.account_id,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
if self._poll_assistants or typing_placed:
|
|
if self._poll_assistants or typing_placed:
|
|
|
- _schedule_trace_followup(
|
|
|
|
|
|
|
+ wid = f"{self._workspace_prefix}:{user_id}"
|
|
|
|
|
+
|
|
|
|
|
+ async def _on_followup_finished(tid: str, reason: str) -> None:
|
|
|
|
|
+ if tid != resolved_id:
|
|
|
|
|
+ return
|
|
|
|
|
+ wm = self._workspace_manager
|
|
|
|
|
+ if wm is None:
|
|
|
|
|
+ return
|
|
|
|
|
+ stop = False
|
|
|
|
|
+ if reason == "terminal" and self._stop_container_on_trace_terminal:
|
|
|
|
|
+ stop = True
|
|
|
|
|
+ elif reason == "not_found" and self._stop_container_on_trace_not_found:
|
|
|
|
|
+ stop = True
|
|
|
|
|
+ if stop:
|
|
|
|
|
+ await wm.stop_workspace_sandbox(wid)
|
|
|
|
|
+ if (
|
|
|
|
|
+ reason == "terminal"
|
|
|
|
|
+ and self._release_ref_on_trace_terminal
|
|
|
|
|
+ and self._lifecycle_trace_backend is not None
|
|
|
|
|
+ ):
|
|
|
|
|
+ await self._lifecycle_trace_backend.forget_trace_binding(
|
|
|
|
|
+ self._channel_id,
|
|
|
|
|
+ user_id,
|
|
|
|
|
+ workspace_id=wid,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ schedule_trace_followup(
|
|
|
agent_base_url=self._base,
|
|
agent_base_url=self._base,
|
|
|
trace_id=resolved_id,
|
|
trace_id=resolved_id,
|
|
|
reply_context=copy(reply_context),
|
|
reply_context=copy(reply_context),
|
|
@@ -720,6 +802,7 @@ class FeishuHttpRunApiExecutor:
|
|
|
max_text_chars=self._assistant_max_chars,
|
|
max_text_chars=self._assistant_max_chars,
|
|
|
forward_assistants=self._poll_assistants,
|
|
forward_assistants=self._poll_assistants,
|
|
|
typing_emoji=self._typing_emoji,
|
|
typing_emoji=self._typing_emoji,
|
|
|
|
|
+ on_finished=_on_followup_finished,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
- return task_id
|
|
|
|
|
|
|
+ return task_id, resolved_id
|