本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能: 1. 新增会话围栏类,实现会话并发控制与过期清理 2. 新增媒体清理器,实现过期媒体文件自动清理 3. 新增熔断器组件,实现服务降级与故障隔离 4. 新增消息处理器,完成渠道消息的完整流转处理 5. 新增限流器组件,实现渠道级和账户级流量控制 6. 新增链路追踪模块,集成Langfuse实现调用链路监控 7. 新增指标统计模块,实现消息处理全链路指标采集 8. 新增统一消息模型,封装全渠道消息格式 9. 新增块回复流水线,实现流式回复的合并与去重 10. 新增本地媒体存储模块,实现媒体文件的本地管理 11. 新增回复分发器,实现回复内容的有序发送与延迟处理 12. 完善__init__.py导出所有核心模块与工具类
686 lines
28 KiB
Python
686 lines
28 KiB
Python
import asyncio
|
|
import logging
|
|
import time
|
|
from collections.abc import AsyncIterator, Callable
|
|
|
|
from sqlalchemy import select
|
|
|
|
from yuxi.channel.hooks.lifecycle import HookEvent, LifecycleHookRegistry
|
|
from yuxi.channel.message.block_reply_pipeline import create_block_reply_pipeline
|
|
from yuxi.channel.message.bridge import AgentBridge
|
|
from yuxi.channel.message.conversation_fence import ConversationFence
|
|
from yuxi.channel.message.dispatch import MessageDispatcher
|
|
from yuxi.channel.message.idempotency import claim, release
|
|
from yuxi.channel.message.langfuse_trace import ChannelTrace, create_channel_trace
|
|
from yuxi.channel.message.metrics import (
|
|
record_dispatch_duration_ms,
|
|
record_message,
|
|
record_rate_limit_reject,
|
|
set_inflight,
|
|
)
|
|
from yuxi.channel.message.models import (
|
|
PeerKind,
|
|
StreamCancelledError,
|
|
StreamStatus,
|
|
StreamTimeoutError,
|
|
UnifiedMessage,
|
|
)
|
|
from yuxi.channel.message.rate_limiter import rate_limit_manager
|
|
from yuxi.channel.protocols import (
|
|
InboundMessageHook,
|
|
MessageSendingHook,
|
|
OutboundProtocol,
|
|
SendLifecycleHook,
|
|
)
|
|
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
|
from yuxi.channel.routing.matcher import RouteMatcher
|
|
from yuxi.channel.routing.models import RouteBinding
|
|
from yuxi.channel.routing.session_key import SessionKeyBuilder
|
|
from yuxi.channel.security.allowlist import AllowlistChecker
|
|
from yuxi.channel.security.identity_link import IdentityLinkResolver
|
|
from yuxi.channel.security.pairing import InMemoryPairingStore, PairingManager
|
|
from yuxi.channel.message.circuit_breaker import CircuitBreaker
|
|
from yuxi.repositories.channel_binding_repo import ChannelBindingRepository, to_route_binding
|
|
from yuxi.repositories.channel_msg_record_repo import ChannelMsgRecordRepository
|
|
from yuxi.repositories.channel_thread_mapping_repo import ChannelThreadMappingRepository
|
|
from yuxi.repositories.channel_user_mapping_repo import ChannelUserMappingRepository
|
|
from yuxi.storage.postgres.manager import pg_manager
|
|
from yuxi.storage.postgres.models_business import User
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_BINDINGS_CACHE_TTL = 60.0
|
|
_IDEMPOTENCY_TTL = 300
|
|
|
|
_FALLBACK_MESSAGES: dict[str, str] = {
|
|
"no_route": "暂不支持该渠道的消息处理。",
|
|
"route_error": "消息路由异常,请稍后重试。",
|
|
"dispatch_error": "消息处理异常,请稍后重试。",
|
|
"approval_denied": "操作未通过审批。",
|
|
"approval_check_error": "审批校验异常,请稍后重试。",
|
|
"rate_limited": "当前消息量较大,请稍后重试。",
|
|
}
|
|
|
|
|
|
class MessageProcessor:
|
|
def __init__(
|
|
self,
|
|
stream_fn: Callable[..., AsyncIterator[bytes]],
|
|
channel_manager=None,
|
|
*,
|
|
msg_repo=None,
|
|
binding_repo=None,
|
|
user_mapping_repo=None,
|
|
thread_mapping_repo=None,
|
|
allowlist=None,
|
|
pairing=None,
|
|
identity_link_resolver=None,
|
|
response_prefix: str = "",
|
|
human_delay: tuple[float, float] | None = None,
|
|
):
|
|
self._bridge = AgentBridge(stream_fn, channel_manager)
|
|
self._msg_repo = msg_repo or ChannelMsgRecordRepository()
|
|
self._binding_repo = binding_repo or ChannelBindingRepository()
|
|
self._user_mapping_repo = user_mapping_repo or ChannelUserMappingRepository()
|
|
self._thread_mapping_repo = thread_mapping_repo or ChannelThreadMappingRepository()
|
|
self._hooks = LifecycleHookRegistry()
|
|
self._allowlist = allowlist or AllowlistChecker()
|
|
if pairing:
|
|
self._pairing = pairing
|
|
else:
|
|
self._pairing = PairingManager(InMemoryPairingStore(), allowlist=self._allowlist)
|
|
|
|
if allowlist is None:
|
|
self._allowlist.load_dm_allowlist(["*"])
|
|
self._allowlist.load_group_allowlist(["*"])
|
|
|
|
self._response_prefix = response_prefix
|
|
self._human_delay = human_delay
|
|
|
|
self._identity_links = identity_link_resolver or IdentityLinkResolver()
|
|
self._matcher = RouteMatcher()
|
|
self._session_key_builder = SessionKeyBuilder()
|
|
|
|
self._active_message_count = 0
|
|
self._active_count_lock = asyncio.Lock()
|
|
|
|
self._cached_bindings: list[RouteBinding] = []
|
|
self._bindings_ts: float = 0.0
|
|
|
|
self._fence = ConversationFence()
|
|
|
|
self._self_user_ids: dict[str, set[str]] = {}
|
|
|
|
def set_self_user_ids(self, channel_type: str, account_id: str, user_ids: set[str]) -> None:
|
|
key = f"{channel_type}:{account_id}"
|
|
self._self_user_ids[key] = user_ids
|
|
|
|
@property
|
|
def hooks(self) -> LifecycleHookRegistry:
|
|
return self._hooks
|
|
|
|
async def _get_bindings(self) -> list[RouteBinding]:
|
|
now = time.monotonic()
|
|
if now - self._bindings_ts < _BINDINGS_CACHE_TTL and self._cached_bindings:
|
|
return self._cached_bindings
|
|
db_bindings = await self._binding_repo.list_all()
|
|
self._cached_bindings = [to_route_binding(b) for b in db_bindings]
|
|
self._bindings_ts = now
|
|
await self._matcher.set_bindings(self._cached_bindings)
|
|
return self._cached_bindings
|
|
|
|
async def _get_user_for_channel(self, internal_user_id: str | None) -> User | None:
|
|
if not internal_user_id:
|
|
return None
|
|
try:
|
|
uid = int(internal_user_id)
|
|
except (ValueError, TypeError):
|
|
return None
|
|
async with pg_manager.get_async_session_context() as db:
|
|
return (await db.execute(select(User).where(User.id == uid))).scalar_one_or_none()
|
|
|
|
def _is_echo(self, msg: UnifiedMessage) -> bool:
|
|
key = f"{msg.channel_type}:{msg.account_id}"
|
|
self_ids = self._self_user_ids.get(key)
|
|
if not self_ids:
|
|
return False
|
|
return msg.sender.id in self_ids
|
|
|
|
async def _ensure_security_loaded(self) -> None:
|
|
await self._identity_links.ensure_loaded()
|
|
self._session_key_builder = SessionKeyBuilder(
|
|
identity_links=self._identity_links.list_links()
|
|
)
|
|
|
|
async def process(self, msg: UnifiedMessage) -> None:
|
|
if self._is_echo(msg):
|
|
logger.debug("Skipping echo message: %s/%s", msg.channel_type, msg.msg_id)
|
|
return
|
|
|
|
limiter = await rate_limit_manager.get_limiter(msg.channel_type, msg.account_id)
|
|
acquire_result = await limiter.try_acquire()
|
|
if not acquire_result:
|
|
record_rate_limit_reject(msg.channel_type)
|
|
logger.warning(
|
|
"Rate limited: channel=%s account=%s retry_after=%.2fs",
|
|
msg.channel_type, msg.account_id, acquire_result.retry_after_sec,
|
|
)
|
|
await self._send_fallback(msg, _FALLBACK_MESSAGES["rate_limited"])
|
|
return
|
|
|
|
if not await claim(msg.msg_id, ttl=_IDEMPOTENCY_TTL):
|
|
logger.debug("Duplicate/inflight message ignored: %s", msg.msg_id)
|
|
return
|
|
|
|
try:
|
|
async with self._active_count_lock:
|
|
self._active_message_count += 1
|
|
set_inflight(self._active_message_count)
|
|
await self._ensure_security_loaded()
|
|
await self._process_impl(msg, limiter)
|
|
finally:
|
|
await release(msg.msg_id)
|
|
async with self._active_count_lock:
|
|
self._active_message_count = max(0, self._active_message_count - 1)
|
|
set_inflight(self._active_message_count)
|
|
|
|
async def _process_impl(self, msg: UnifiedMessage, limiter) -> None:
|
|
trace = await create_channel_trace(msg)
|
|
await trace.add_span("idempotency_check", metadata={"claimed": True})
|
|
|
|
conversation_key = self._fence.key_for(msg)
|
|
version, stop_event = self._fence.enter(conversation_key)
|
|
lock = self._fence.lock_for(conversation_key)
|
|
|
|
logger.debug(
|
|
"processor: phase=enter msg_id=%s channel=%s conv_key=%s version=%d",
|
|
msg.msg_id, msg.channel_type, conversation_key, version,
|
|
)
|
|
|
|
async with lock:
|
|
if version != self._fence.current_version(conversation_key):
|
|
logger.debug("Foreground fence: message superseded for %s", conversation_key)
|
|
record_message(msg.channel_type, "superseded")
|
|
await trace.finish(error="superseded")
|
|
return
|
|
|
|
record_message(msg.channel_type, "received")
|
|
|
|
plugin = ChannelPluginRegistry.get(msg.channel_type)
|
|
|
|
if isinstance(plugin, InboundMessageHook):
|
|
try:
|
|
result = await plugin.on_message_received(msg)
|
|
if result is None:
|
|
logger.info("Message dropped by inbound hook: %s", msg.msg_id)
|
|
await trace.finish(error="dropped_by_hook")
|
|
return
|
|
except Exception:
|
|
logger.exception("Inbound hook failed, proceeding: %s", msg.msg_id)
|
|
|
|
try:
|
|
await self._hooks.fire(HookEvent.MESSAGE_RECEIVED, msg)
|
|
except Exception:
|
|
logger.exception("MESSAGE_RECEIVED hook failed: %s", msg.msg_id)
|
|
|
|
chat_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
|
|
chat_type = "group" if msg.sender.kind != PeerKind.DIRECT else "direct"
|
|
|
|
record_data = {
|
|
"channel_id": msg.account_id,
|
|
"channel_type": msg.channel_type,
|
|
"message_id": msg.msg_id,
|
|
"chat_id": chat_id,
|
|
"chat_type": chat_type,
|
|
"content_type": msg.message_type.value if msg.message_type else "text",
|
|
"sender_user_id": msg.sender.id,
|
|
"content_preview": (msg.content or "")[:500],
|
|
"reply_to_message_id": msg.metadata.get("reply_to_message_id") if msg.metadata else None,
|
|
"extra_data": msg.raw_payload,
|
|
}
|
|
|
|
record = None
|
|
start_time = time.monotonic()
|
|
|
|
try:
|
|
record = await self._msg_repo.create_record(record_data)
|
|
if stop_event.is_set():
|
|
logger.debug("Aborted before dispatch for %s", conversation_key)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, "aborted_by_fence", elapsed_ms)
|
|
await trace.finish(error="aborted_by_fence")
|
|
return
|
|
|
|
bindings = await self._get_bindings()
|
|
dispatcher = MessageDispatcher(
|
|
bindings,
|
|
self._allowlist,
|
|
self._pairing,
|
|
matcher=self._matcher,
|
|
session_key_builder=self._session_key_builder,
|
|
user_mapping_repo=self._user_mapping_repo,
|
|
thread_mapping_repo=self._thread_mapping_repo,
|
|
)
|
|
|
|
try:
|
|
await self._hooks.fire(HookEvent.BEFORE_AGENT_RUN, msg)
|
|
except Exception:
|
|
logger.exception("BEFORE_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
|
|
result = await dispatcher.dispatch(msg, trace=trace)
|
|
record_dispatch_duration_ms(
|
|
msg.channel_type,
|
|
(time.monotonic() - start_time) * 1000,
|
|
)
|
|
|
|
logger.debug(
|
|
"processor: phase=dispatched msg_id=%s handled_by=%s success=%s agent_config_id=%s",
|
|
msg.msg_id, result.handled_by, result.success, result.agent_config_id,
|
|
)
|
|
|
|
if result.handled_by == "command":
|
|
if result.command_response:
|
|
await self._send_reply(msg, result.command_response)
|
|
try:
|
|
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, result.command_response)
|
|
except Exception:
|
|
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_success(
|
|
record.id,
|
|
"",
|
|
(result.command_response or "")[:500],
|
|
elapsed_ms,
|
|
)
|
|
await trace.finish()
|
|
return
|
|
|
|
if not result.success:
|
|
try:
|
|
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, None)
|
|
except Exception:
|
|
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
|
|
error_msg = result.error or "dispatch_failed"
|
|
|
|
fallback = _FALLBACK_MESSAGES.get(error_msg)
|
|
if fallback is None and result.error and result.error.startswith("approval_denied:"):
|
|
fallback = _FALLBACK_MESSAGES["approval_denied"]
|
|
|
|
if fallback:
|
|
await self._send_fallback(msg, fallback)
|
|
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, error_msg, elapsed_ms)
|
|
await trace.finish(error=error_msg)
|
|
return
|
|
|
|
if stop_event.is_set():
|
|
logger.debug("Aborted after dispatch for %s", conversation_key)
|
|
try:
|
|
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, None)
|
|
except Exception:
|
|
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, "aborted_by_fence", elapsed_ms)
|
|
await trace.finish(error="aborted_by_fence")
|
|
return
|
|
|
|
logger.debug(
|
|
"processor: phase=reply_start msg_id=%s channel=%s agent_config_id=%s",
|
|
msg.msg_id, msg.channel_type, result.agent_config_id,
|
|
)
|
|
await self._handle_agent_reply(msg, result, plugin, stop_event, record, start_time, conversation_key, trace, limiter)
|
|
|
|
except Exception as e:
|
|
logger.exception("Message processing failed: %s", msg.msg_id)
|
|
await trace.finish(error=f"{type(e).__name__}: {e}")
|
|
try:
|
|
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, None, None)
|
|
except Exception:
|
|
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
error_text = f"{type(e).__name__}: {e}"
|
|
if record is not None:
|
|
await self._msg_repo.mark_error(record.id, error_text[:500], elapsed_ms)
|
|
record_message(msg.channel_type, "error")
|
|
|
|
async def _handle_agent_reply(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
result,
|
|
plugin,
|
|
stop_event: asyncio.Event | None,
|
|
record,
|
|
start_time: float,
|
|
conversation_key: str,
|
|
trace: ChannelTrace,
|
|
limiter,
|
|
) -> None:
|
|
user = await self._get_user_for_channel(result.internal_user_id)
|
|
if user is None:
|
|
logger.error(
|
|
"channel_turn skip: msg_id=%s channel=%s reason=no_user_for_internal_id:%s",
|
|
msg.msg_id,
|
|
msg.channel_type,
|
|
result.internal_user_id,
|
|
)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, "no_user_for_internal_id", elapsed_ms)
|
|
await trace.finish(error="no_user_for_internal_id")
|
|
return
|
|
|
|
agent_config_id = result.agent_config_id
|
|
chat_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
|
|
|
|
try:
|
|
await self._hooks.fire(HookEvent.AGENT_BOOTSTRAP, msg, result)
|
|
except Exception:
|
|
logger.exception("AGENT_BOOTSTRAP hook failed: %s", msg.msg_id)
|
|
|
|
stream_status: StreamStatus | None = None
|
|
|
|
if plugin is not None and isinstance(plugin, OutboundProtocol):
|
|
try:
|
|
await self._hooks.fire(HookEvent.MESSAGE_SENDING, msg, "")
|
|
except Exception:
|
|
logger.exception("MESSAGE_SENDING hook failed: %s", msg.msg_id)
|
|
|
|
reply_text, stream_status = await self._process_with_pipeline(
|
|
msg,
|
|
result,
|
|
agent_config_id,
|
|
user,
|
|
plugin,
|
|
stop_event,
|
|
limiter=limiter,
|
|
trace=trace,
|
|
)
|
|
else:
|
|
async with pg_manager.get_async_session_context() as db:
|
|
stream_result = await self._bridge.invoke_with_result(
|
|
msg,
|
|
result,
|
|
agent_config_id,
|
|
user,
|
|
db,
|
|
)
|
|
reply_text = stream_result.accumulated_text or None
|
|
stream_status = stream_result.status
|
|
|
|
if reply_text:
|
|
if plugin is not None:
|
|
reply_text = await self._apply_sending_hooks(msg, plugin, chat_id, reply_text)
|
|
if reply_text is None:
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, "blocked_by_sending_hook", elapsed_ms)
|
|
await trace.finish(error="blocked_by_sending_hook")
|
|
return
|
|
success = await self._send_with_retry(plugin, chat_id, reply_text, reply_to_id=msg.msg_id)
|
|
if not success:
|
|
logger.exception("Failed to send reply via %s after retries", msg.channel_type)
|
|
else:
|
|
logger.warning("No outbound plugin for channel: %s", msg.channel_type)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, "no_outbound_plugin", elapsed_ms)
|
|
await trace.finish(error="no_outbound_plugin")
|
|
return
|
|
|
|
if not reply_text and stream_status in (StreamStatus.TIMEOUT, StreamStatus.ERROR, StreamStatus.CANCELLED):
|
|
fallback_map = {
|
|
StreamStatus.TIMEOUT: "处理超时,请稍后重试。",
|
|
StreamStatus.ERROR: "处理异常,已通知管理员。",
|
|
StreamStatus.CANCELLED: "处理已取消。",
|
|
}
|
|
fallback = fallback_map.get(stream_status)
|
|
if fallback:
|
|
await self._send_fallback(msg, fallback)
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_error(record.id, stream_status.value, elapsed_ms)
|
|
await trace.finish(error=stream_status.value)
|
|
return
|
|
|
|
if isinstance(plugin, SendLifecycleHook):
|
|
try:
|
|
await plugin.after_send_success(
|
|
chat_id,
|
|
{"content": reply_text, "msg_id": msg.msg_id},
|
|
)
|
|
except Exception:
|
|
logger.exception("Send lifecycle hook failed: %s", msg.msg_id)
|
|
|
|
try:
|
|
await self._hooks.fire(HookEvent.MESSAGE_SENT, msg, reply_text)
|
|
except Exception:
|
|
logger.exception("MESSAGE_SENT hook failed: %s", msg.msg_id)
|
|
|
|
try:
|
|
await self._hooks.fire(HookEvent.AFTER_AGENT_RUN, msg, result, reply_text)
|
|
except Exception:
|
|
logger.exception("AFTER_AGENT_RUN hook failed: %s", msg.msg_id)
|
|
|
|
elapsed_ms = int((time.monotonic() - start_time) * 1000)
|
|
await self._msg_repo.mark_success(
|
|
record.id,
|
|
"",
|
|
(reply_text or "")[:500],
|
|
elapsed_ms,
|
|
)
|
|
record_message(msg.channel_type, "success")
|
|
await trace.finish()
|
|
|
|
async def _apply_sending_hooks(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
plugin,
|
|
chat_id: str,
|
|
reply_text: str,
|
|
) -> str | None:
|
|
if isinstance(plugin, MessageSendingHook):
|
|
try:
|
|
modified = await plugin.on_message_sending(msg, reply_text)
|
|
if modified is None:
|
|
logger.info("Message blocked by MessageSendingHook: %s", msg.msg_id)
|
|
return None
|
|
reply_text = modified
|
|
except Exception:
|
|
logger.exception("MessageSendingHook failed: %s", msg.msg_id)
|
|
|
|
if isinstance(plugin, SendLifecycleHook):
|
|
try:
|
|
await plugin.before_send_attempt(chat_id, reply_text)
|
|
except Exception:
|
|
logger.exception("before_send_attempt failed: %s", msg.msg_id)
|
|
|
|
try:
|
|
result = await self._hooks.fire_sequential(
|
|
HookEvent.MESSAGE_SENDING,
|
|
initial=reply_text,
|
|
msg=msg,
|
|
)
|
|
reply_text = result if result is not None else reply_text
|
|
except Exception:
|
|
logger.exception("MESSAGE_SENDING hook failed: %s", msg.msg_id)
|
|
|
|
return reply_text
|
|
|
|
async def _process_with_pipeline(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
result,
|
|
agent_config_id: int,
|
|
user,
|
|
plugin,
|
|
stop_event: asyncio.Event | None = None,
|
|
*,
|
|
limiter=None,
|
|
trace: "ChannelTrace | None" = None,
|
|
) -> tuple[str | None, StreamStatus | None]:
|
|
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
|
|
|
|
async def send_fn(content: str) -> str | None:
|
|
if stop_event and stop_event.is_set():
|
|
return None
|
|
if not content:
|
|
return None
|
|
|
|
send_content = content
|
|
if isinstance(plugin, MessageSendingHook):
|
|
try:
|
|
modified = await plugin.on_message_sending(msg, send_content)
|
|
if modified is None:
|
|
logger.info("Pipeline send blocked by MessageSendingHook: %s", msg.msg_id)
|
|
return None
|
|
send_content = modified
|
|
except Exception:
|
|
logger.exception("Pipeline MessageSendingHook failed: %s", msg.msg_id)
|
|
|
|
if isinstance(plugin, SendLifecycleHook):
|
|
try:
|
|
await plugin.before_send_attempt(target_id, send_content)
|
|
except Exception:
|
|
logger.exception("Pipeline before_send_attempt failed: %s", msg.msg_id)
|
|
|
|
success = await self._send_with_retry(plugin, target_id, send_content, reply_to_id=msg.msg_id)
|
|
if not success:
|
|
logger.exception("Pipeline send failed via %s after retries", msg.channel_type)
|
|
return None
|
|
|
|
pipeline = create_block_reply_pipeline(
|
|
send_fn=send_fn,
|
|
msg=msg,
|
|
response_prefix=self._response_prefix,
|
|
human_delay=self._human_delay,
|
|
)
|
|
|
|
accumulated: list[str] = []
|
|
stream_error: StreamStatus | None = None
|
|
|
|
exec_span_id = None
|
|
if trace:
|
|
exec_span_id = await trace.add_span(
|
|
"agent_execution",
|
|
metadata={
|
|
"agent_config_id": agent_config_id,
|
|
"thread_id": result.thread_id,
|
|
},
|
|
)
|
|
|
|
async with pg_manager.get_async_session_context() as db:
|
|
try:
|
|
stream_iter = self._bridge.invoke_stream(
|
|
msg,
|
|
result,
|
|
agent_config_id,
|
|
user,
|
|
db,
|
|
)
|
|
|
|
if limiter is not None:
|
|
stream_iter = limiter.run_with_limit(stream_iter)
|
|
|
|
async for chunk in stream_iter:
|
|
if stop_event and stop_event.is_set():
|
|
await pipeline.abort()
|
|
logger.debug("Pipeline aborted by foreground fence for %s", self._fence.key_for(msg))
|
|
break
|
|
await pipeline.enqueue(chunk)
|
|
if chunk.content:
|
|
accumulated.append(chunk.content)
|
|
except StreamTimeoutError:
|
|
stream_error = StreamStatus.TIMEOUT
|
|
logger.warning("Agent stream timeout for channel=%s peer=%s", msg.channel_type, msg.sender.id)
|
|
except StreamCancelledError:
|
|
stream_error = StreamStatus.CANCELLED
|
|
logger.debug("Agent stream cancelled for channel=%s peer=%s", msg.channel_type, msg.sender.id)
|
|
|
|
await pipeline.wait_idle()
|
|
|
|
if exec_span_id:
|
|
status_msg = stream_error.value if stream_error else "ok"
|
|
await trace.end_span(
|
|
exec_span_id,
|
|
level="ERROR" if stream_error else "DEFAULT",
|
|
status_message=status_msg,
|
|
metadata={"stream_status": status_msg, "chunks": len(accumulated)},
|
|
)
|
|
|
|
if stop_event and stop_event.is_set():
|
|
return None, None
|
|
|
|
return ("".join(accumulated) if accumulated else None, stream_error)
|
|
|
|
async def _send_fallback(self, msg: UnifiedMessage, fallback_text: str) -> None:
|
|
plugin = ChannelPluginRegistry.get(msg.channel_type)
|
|
if plugin is None or not isinstance(plugin, OutboundProtocol):
|
|
return
|
|
try:
|
|
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
|
|
await plugin.send_text(target_id, fallback_text, reply_to_id=msg.msg_id)
|
|
except Exception:
|
|
logger.exception("Failed to send fallback reply via %s", msg.channel_type)
|
|
|
|
async def _send_with_retry(
|
|
self,
|
|
plugin,
|
|
target_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
max_retries: int = 3,
|
|
circuit_breaker: "CircuitBreaker | None" = None,
|
|
) -> bool:
|
|
last_error = None
|
|
total_attempts = max_retries + 1
|
|
|
|
for attempt in range(total_attempts):
|
|
if circuit_breaker is not None and not circuit_breaker.allow_request():
|
|
logger.warning(
|
|
"Circuit breaker OPEN for %s, skipping send",
|
|
plugin.id if hasattr(plugin, "id") else "unknown",
|
|
)
|
|
return False
|
|
|
|
try:
|
|
await plugin.send_text(target_id, content, reply_to_id=reply_to_id)
|
|
if circuit_breaker is not None:
|
|
circuit_breaker.record_success()
|
|
return True
|
|
except Exception as e:
|
|
last_error = e
|
|
if circuit_breaker is not None:
|
|
circuit_breaker.record_failure()
|
|
logger.warning(
|
|
"Send attempt %d/%d failed (target=%s): %s",
|
|
attempt + 1, total_attempts, target_id, e,
|
|
)
|
|
if attempt < max_retries:
|
|
delay = min(0.3 * (2 ** attempt), 30.0)
|
|
await asyncio.sleep(delay)
|
|
|
|
logger.error("All %d send attempts failed (target=%s): %s", total_attempts, target_id, last_error)
|
|
if isinstance(plugin, SendLifecycleHook):
|
|
try:
|
|
await plugin.after_send_failure(target_id, str(last_error))
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
async def _send_reply(self, msg: UnifiedMessage, content: str) -> str | None:
|
|
plugin = ChannelPluginRegistry.get(msg.channel_type)
|
|
if plugin is None or not isinstance(plugin, OutboundProtocol):
|
|
logger.warning("No outbound plugin for channel: %s", msg.channel_type)
|
|
return None
|
|
|
|
target_id = msg.group.id if msg.group and msg.group.id else msg.sender.id
|
|
reply_to_id = msg.msg_id
|
|
|
|
send_content = await self._apply_sending_hooks(msg, plugin, target_id, content)
|
|
if send_content is None:
|
|
return None
|
|
|
|
success = await self._send_with_retry(plugin, target_id, send_content, reply_to_id=reply_to_id)
|
|
if not success:
|
|
logger.exception("Failed to send reply via %s after retries", msg.channel_type)
|
|
return None
|