本次提交新增了渠道消息处理的完整核心模块,包含以下核心功能: 1. 新增会话围栏类,实现会话并发控制与过期清理 2. 新增媒体清理器,实现过期媒体文件自动清理 3. 新增熔断器组件,实现服务降级与故障隔离 4. 新增消息处理器,完成渠道消息的完整流转处理 5. 新增限流器组件,实现渠道级和账户级流量控制 6. 新增链路追踪模块,集成Langfuse实现调用链路监控 7. 新增指标统计模块,实现消息处理全链路指标采集 8. 新增统一消息模型,封装全渠道消息格式 9. 新增块回复流水线,实现流式回复的合并与去重 10. 新增本地媒体存储模块,实现媒体文件的本地管理 11. 新增回复分发器,实现回复内容的有序发送与延迟处理 12. 完善__init__.py导出所有核心模块与工具类
373 lines
13 KiB
Python
373 lines
13 KiB
Python
import asyncio
|
|
import contextlib
|
|
import json
|
|
import logging
|
|
import time
|
|
from collections.abc import AsyncIterator, Callable
|
|
|
|
from yuxi.channel.message.models import (
|
|
ChunkType,
|
|
DispatchResult,
|
|
ReplyStage,
|
|
StreamCancellationToken,
|
|
StreamCancelledError,
|
|
StreamingChunk,
|
|
StreamResult,
|
|
StreamStatus,
|
|
StreamTimeoutError,
|
|
UnifiedMessage,
|
|
)
|
|
from yuxi.channel.protocols import AgentPromptContext, AgentPromptProtocol, AgentToolProtocol
|
|
from yuxi.channel.plugins.registry import ChannelPluginRegistry
|
|
from yuxi.channel.security.external_content import sanitize_external_content
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_STREAMING_TEXT_STATUSES = frozenset({"init", "loading", "agent_state", "streaming"})
|
|
_STREAMING_REASONING_STATUSES = frozenset({"thinking"})
|
|
_TOOL_STATUSES = frozenset({"tool_call", "tool_result"})
|
|
_TERMINAL_STATUSES = frozenset({"finished", "error", "interrupted", "warning"})
|
|
_INTERACTIVE_STATUSES = frozenset({"ask_user_question_required"})
|
|
|
|
_DEFAULT_TIMEOUT_SECONDS = 300.0
|
|
|
|
|
|
class AgentBridge:
|
|
def __init__(
|
|
self,
|
|
stream_fn: Callable[..., AsyncIterator[bytes]],
|
|
channel_manager=None,
|
|
):
|
|
self._stream_fn = stream_fn
|
|
self._channel_manager = channel_manager
|
|
|
|
def collect_channel_tools(self, channel_type: str) -> list[dict]:
|
|
plugin = ChannelPluginRegistry.get(channel_type)
|
|
if plugin is None or not isinstance(plugin, AgentToolProtocol):
|
|
return []
|
|
tools = plugin.get_agent_tools()
|
|
return [t.to_openai_schema() for t in tools]
|
|
|
|
async def execute_channel_tool(
|
|
self,
|
|
channel_type: str,
|
|
tool_name: str,
|
|
params: dict,
|
|
context: dict,
|
|
) -> dict:
|
|
plugin = ChannelPluginRegistry.get(channel_type)
|
|
if plugin is None or not isinstance(plugin, AgentToolProtocol):
|
|
return {"success": False, "error": f"Channel {channel_type} does not support agent tools"}
|
|
|
|
try:
|
|
return await plugin.execute_agent_tool(tool_name, params, context)
|
|
except Exception as e:
|
|
logger.exception("Channel tool execution failed: %s.%s", channel_type, tool_name)
|
|
return {"success": False, "error": str(e)}
|
|
|
|
# ── 构建 meta ──────────────────────────────────────────
|
|
|
|
def _build_meta(self, msg: UnifiedMessage) -> tuple[dict, str]:
|
|
meta = {
|
|
"source": "channel",
|
|
"channel_type": msg.channel_type,
|
|
"account_id": msg.account_id,
|
|
"peer_id": msg.sender.id,
|
|
}
|
|
|
|
plugin = ChannelPluginRegistry.get(msg.channel_type)
|
|
|
|
safe_content, _detection = sanitize_external_content(msg.content)
|
|
query = safe_content
|
|
|
|
if isinstance(plugin, AgentPromptProtocol):
|
|
context = AgentPromptContext(
|
|
channel_type=msg.channel_type,
|
|
account_id=msg.account_id,
|
|
peer_id=msg.sender.id,
|
|
peer_name=msg.sender.display_name,
|
|
group_id=msg.group.id if msg.group else None,
|
|
group_name=msg.group.name if msg.group else None,
|
|
thread_id=msg.group.thread_id if msg.group else None,
|
|
)
|
|
system_prompt = plugin.build_system_prompt(context)
|
|
if system_prompt:
|
|
meta["channel_system_prompt"] = system_prompt
|
|
context_note = plugin.build_context_note(context)
|
|
if context_note:
|
|
query = f"{context_note}\n{safe_content}"
|
|
|
|
channel_tools = self.collect_channel_tools(msg.channel_type)
|
|
if channel_tools:
|
|
meta["channel_tools"] = channel_tools
|
|
|
|
return meta, query
|
|
|
|
# ── invoke (原始字节流) ─────────────────────────────────
|
|
|
|
async def invoke(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
dispatch_result: DispatchResult,
|
|
agent_config_id: int,
|
|
current_user,
|
|
db,
|
|
) -> AsyncIterator[bytes]:
|
|
if self._channel_manager:
|
|
self._channel_manager.inc_active_runs(msg.channel_type, msg.account_id)
|
|
|
|
try:
|
|
meta, query = self._build_meta(msg)
|
|
|
|
async for chunk in self._stream_fn(
|
|
query=query,
|
|
agent_config_id=agent_config_id,
|
|
thread_id=dispatch_result.thread_id,
|
|
meta=meta,
|
|
image_content=msg.image_base64,
|
|
current_user=current_user,
|
|
db=db,
|
|
):
|
|
yield chunk
|
|
finally:
|
|
if self._channel_manager:
|
|
self._channel_manager.dec_active_runs(msg.channel_type, msg.account_id)
|
|
|
|
# ── invoke_stream (结构化流) ────────────────────────────
|
|
|
|
async def invoke_stream(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
dispatch_result: DispatchResult,
|
|
agent_config_id: int,
|
|
current_user,
|
|
db,
|
|
*,
|
|
cancel_token: StreamCancellationToken | None = None,
|
|
on_chunk: Callable[[StreamingChunk], None] | None = None,
|
|
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
) -> AsyncIterator[StreamingChunk]:
|
|
if self._channel_manager:
|
|
self._channel_manager.inc_active_runs(msg.channel_type, msg.account_id)
|
|
|
|
try:
|
|
meta, query = self._build_meta(msg)
|
|
|
|
async def _stream_all() -> AsyncIterator[bytes]:
|
|
async for raw in self._stream_fn(
|
|
query=query,
|
|
agent_config_id=agent_config_id,
|
|
thread_id=dispatch_result.thread_id,
|
|
meta=meta,
|
|
image_content=msg.image_base64,
|
|
current_user=current_user,
|
|
db=db,
|
|
):
|
|
yield raw
|
|
|
|
stream_iter = _stream_all()
|
|
|
|
async with contextlib.aclosing(stream_iter):
|
|
while True:
|
|
if cancel_token is not None and cancel_token.is_cancelled:
|
|
raise StreamCancelledError("stream cancelled by cancel token")
|
|
|
|
try:
|
|
raw_chunk = await asyncio.wait_for(
|
|
stream_iter.__anext__(),
|
|
timeout=timeout,
|
|
)
|
|
except TimeoutError:
|
|
raise StreamTimeoutError(
|
|
f"No chunk received within {timeout}s"
|
|
)
|
|
except StopAsyncIteration:
|
|
break
|
|
|
|
parsed = self._parse_chunk(raw_chunk)
|
|
if parsed is None:
|
|
continue
|
|
|
|
if on_chunk is not None:
|
|
on_chunk(parsed)
|
|
|
|
yield parsed
|
|
|
|
yield StreamingChunk(
|
|
stage=ReplyStage.FINAL,
|
|
chunk_type=ChunkType.STATUS,
|
|
content="finished",
|
|
)
|
|
finally:
|
|
if self._channel_manager:
|
|
self._channel_manager.dec_active_runs(msg.channel_type, msg.account_id)
|
|
|
|
# ── invoke_with_result (结构化结果) ─────────────────────
|
|
|
|
async def invoke_with_result(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
dispatch_result: DispatchResult,
|
|
agent_config_id: int,
|
|
current_user,
|
|
db,
|
|
*,
|
|
cancel_token: StreamCancellationToken | None = None,
|
|
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
|
) -> StreamResult:
|
|
result = StreamResult(start_time=time.monotonic())
|
|
accumulated: list[str] = []
|
|
|
|
try:
|
|
async for chunk in self.invoke_stream(
|
|
msg,
|
|
dispatch_result,
|
|
agent_config_id,
|
|
current_user,
|
|
db,
|
|
cancel_token=cancel_token,
|
|
timeout=timeout,
|
|
):
|
|
result.chunk_count += 1
|
|
|
|
if chunk.chunk_type == ChunkType.TEXT_DELTA:
|
|
result.text_chunk_count += 1
|
|
if chunk.content:
|
|
accumulated.append(chunk.content)
|
|
elif chunk.chunk_type == ChunkType.REASONING:
|
|
result.reasoning_chunk_count += 1
|
|
elif chunk.chunk_type == ChunkType.TOOL_CALL:
|
|
result.tool_chunk_count += 1
|
|
elif chunk.chunk_type == ChunkType.TOOL_RESULT:
|
|
result.tool_chunk_count += 1
|
|
elif chunk.chunk_type == ChunkType.HEARTBEAT:
|
|
result.heartbeat_count += 1
|
|
elif chunk.chunk_type == ChunkType.CITATION:
|
|
result.citation_count += 1
|
|
elif chunk.chunk_type == ChunkType.ERROR:
|
|
result.error_count += 1
|
|
if chunk.content and not result.error_message:
|
|
result.error_message = chunk.content
|
|
|
|
if chunk.metadata and "token_usage" in chunk.metadata:
|
|
result.token_usage = chunk.metadata["token_usage"]
|
|
|
|
result.accumulated_text = "".join(accumulated)
|
|
result.status = StreamStatus.COMPLETED
|
|
|
|
except StreamCancelledError:
|
|
result.status = StreamStatus.CANCELLED
|
|
result.accumulated_text = "".join(accumulated)
|
|
result.error_message = "stream cancelled"
|
|
logger.debug("Agent stream cancelled for channel=%s peer=%s", msg.channel_type, msg.sender.id)
|
|
except StreamTimeoutError:
|
|
result.status = StreamStatus.TIMEOUT
|
|
result.accumulated_text = "".join(accumulated)
|
|
result.error_message = "stream timeout"
|
|
logger.warning("Agent stream timeout for channel=%s peer=%s", msg.channel_type, msg.sender.id)
|
|
except Exception as e:
|
|
result.status = StreamStatus.ERROR
|
|
result.accumulated_text = "".join(accumulated)
|
|
result.error_message = f"{type(e).__name__}: {e}"
|
|
logger.exception("Agent stream error for channel=%s peer=%s", msg.channel_type, msg.sender.id)
|
|
finally:
|
|
result.end_time = time.monotonic()
|
|
|
|
return result
|
|
|
|
# ── invoke_and_collect (兼容方法) ────────────────────────
|
|
|
|
async def invoke_and_collect(
|
|
self,
|
|
msg: UnifiedMessage,
|
|
dispatch_result: DispatchResult,
|
|
agent_config_id: int,
|
|
current_user,
|
|
db,
|
|
) -> str | None:
|
|
result = await self.invoke_with_result(msg, dispatch_result, agent_config_id, current_user, db)
|
|
return result.accumulated_text or None
|
|
|
|
# ── chunk 解析 ─────────────────────────────────────────
|
|
|
|
def _parse_chunk(self, raw: bytes) -> StreamingChunk | None:
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
logger.debug("Failed to parse stream chunk (len=%d): %s", len(raw), raw[:200])
|
|
return None
|
|
|
|
status = data.get("status", "")
|
|
content = data.get("response", "")
|
|
|
|
# 流式文本块
|
|
if status in _STREAMING_TEXT_STATUSES:
|
|
return StreamingChunk(
|
|
stage=ReplyStage.BLOCK,
|
|
chunk_type=ChunkType.TEXT_DELTA,
|
|
content=content,
|
|
)
|
|
|
|
# 推理/思考块
|
|
if status in _STREAMING_REASONING_STATUSES:
|
|
return StreamingChunk(
|
|
stage=ReplyStage.TOOL,
|
|
chunk_type=ChunkType.REASONING,
|
|
content=content,
|
|
)
|
|
|
|
# 工具调用
|
|
if status == "tool_call":
|
|
tool_name = data.get("tool_name", "")
|
|
tool_input = data.get("tool_input", {})
|
|
return StreamingChunk(
|
|
stage=ReplyStage.TOOL,
|
|
chunk_type=ChunkType.TOOL_CALL,
|
|
content=content,
|
|
tool_name=tool_name,
|
|
tool_input=tool_input if isinstance(tool_input, dict) else {},
|
|
)
|
|
|
|
# 工具结果
|
|
if status == "tool_result":
|
|
tool_name = data.get("tool_name", "")
|
|
tool_output = data.get("tool_output", content)
|
|
return StreamingChunk(
|
|
stage=ReplyStage.TOOL,
|
|
chunk_type=ChunkType.TOOL_RESULT,
|
|
content=content,
|
|
tool_name=tool_name,
|
|
tool_output=tool_output,
|
|
)
|
|
|
|
# 心跳
|
|
if status == "heartbeat":
|
|
return StreamingChunk(
|
|
stage=ReplyStage.BLOCK,
|
|
chunk_type=ChunkType.HEARTBEAT,
|
|
content=content,
|
|
)
|
|
|
|
# 引用
|
|
if status == "citation":
|
|
return StreamingChunk(
|
|
stage=ReplyStage.BLOCK,
|
|
chunk_type=ChunkType.CITATION,
|
|
content=content,
|
|
)
|
|
|
|
# 错误
|
|
if status == "error":
|
|
return StreamingChunk(
|
|
stage=ReplyStage.FINAL,
|
|
chunk_type=ChunkType.ERROR,
|
|
content=content,
|
|
)
|
|
|
|
# 终止态 / 交互态 — 不产出 chunk
|
|
if status in _TERMINAL_STATUSES | _INTERACTIVE_STATUSES:
|
|
return None
|
|
|
|
logger.debug("Unknown chunk status: %s", status)
|
|
return None
|