- 后端 OutboundEventKind/WsOutbound 新增 ExecutionCompleted 变体 - processor 在 handle_message 完成后发送该信号 - 飞书/微信通道过滤该信号(不发送) - 前端 useChat 移除 stream_delta/assistant_response 的 isLoading=false - 改由 execution_completed 事件统一设置 isLoading=false,确保智能体迭代期间按钮保持停止态
925 lines
31 KiB
Rust
925 lines
31 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use std::collections::{HashMap, HashSet};
|
||
|
||
use crate::domain::messages::ToolCall;
|
||
|
||
pub const SYSTEM_CONTEXT_AGENT_PROMPT: &str = "agent_prompt";
|
||
pub const SYSTEM_CONTEXT_SCHEDULED_PROMPT: &str = "scheduled_system_prompt";
|
||
pub const SYSTEM_CONTEXT_HISTORY_COMPACTION: &str = "history_compaction";
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum ToolMessageState {
|
||
Completed,
|
||
PendingUserAction,
|
||
}
|
||
|
||
// ============================================================================
|
||
// MediaItem - Media metadata for messages
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct MediaItem {
|
||
pub path: String, // Local file path
|
||
pub media_type: String, // "image", "audio", "file", "video"
|
||
pub mime_type: Option<String>,
|
||
pub original_key: Option<String>, // Feishu file_key for download
|
||
pub content_base64: Option<String>, // Base64-encoded file content for web download
|
||
pub file_name: Option<String>, // Display file name
|
||
}
|
||
|
||
impl MediaItem {
|
||
pub fn new(path: impl Into<String>, media_type: impl Into<String>) -> Self {
|
||
Self {
|
||
path: path.into(),
|
||
media_type: media_type.into(),
|
||
mime_type: None,
|
||
original_key: None,
|
||
content_base64: None,
|
||
file_name: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// ChatMessage - Used by AgentLoop for LLM conversation history
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct ChatMessage {
|
||
pub id: String,
|
||
pub role: String,
|
||
pub content: String,
|
||
pub media_refs: Vec<String>, // Paths to media files for context
|
||
pub timestamp: i64,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub system_context: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub reasoning_content: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub tool_call_id: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub tool_name: Option<String>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub tool_state: Option<ToolMessageState>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub tool_duration_ms: Option<u64>,
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub tool_calls: Option<Vec<ToolCall>>,
|
||
}
|
||
|
||
impl ChatMessage {
|
||
pub fn user(content: impl Into<String>) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "user".to_string(),
|
||
content: content.into(),
|
||
media_refs: Vec::new(),
|
||
timestamp: current_timestamp(),
|
||
system_context: None,
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_duration_ms: None,
|
||
tool_state: None,
|
||
tool_calls: None,
|
||
}
|
||
}
|
||
|
||
pub fn user_with_media(content: impl Into<String>, media_refs: Vec<String>) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "user".to_string(),
|
||
content: content.into(),
|
||
media_refs,
|
||
timestamp: current_timestamp(),
|
||
system_context: None,
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_duration_ms: None,
|
||
tool_state: None,
|
||
tool_calls: None,
|
||
}
|
||
}
|
||
|
||
pub fn assistant(content: impl Into<String>) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "assistant".to_string(),
|
||
content: content.into(),
|
||
media_refs: Vec::new(),
|
||
timestamp: current_timestamp(),
|
||
system_context: None,
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_duration_ms: None,
|
||
tool_state: None,
|
||
tool_calls: None,
|
||
}
|
||
}
|
||
|
||
pub fn assistant_with_reasoning(
|
||
content: impl Into<String>,
|
||
reasoning_content: impl Into<String>,
|
||
) -> Self {
|
||
let mut message = Self::assistant(content);
|
||
message.reasoning_content = Some(reasoning_content.into());
|
||
message
|
||
}
|
||
|
||
pub fn assistant_with_tool_calls(
|
||
content: impl Into<String>,
|
||
tool_calls: Vec<ToolCall>,
|
||
) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "assistant".to_string(),
|
||
content: content.into(),
|
||
media_refs: Vec::new(),
|
||
timestamp: current_timestamp(),
|
||
system_context: None,
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_duration_ms: None,
|
||
tool_state: None,
|
||
tool_calls: Some(tool_calls),
|
||
}
|
||
}
|
||
|
||
pub fn assistant_with_tool_calls_and_reasoning(
|
||
content: impl Into<String>,
|
||
tool_calls: Vec<ToolCall>,
|
||
reasoning_content: impl Into<String>,
|
||
) -> Self {
|
||
let mut message = Self::assistant_with_tool_calls(content, tool_calls);
|
||
message.reasoning_content = Some(reasoning_content.into());
|
||
message
|
||
}
|
||
|
||
pub fn system(content: impl Into<String>) -> Self {
|
||
Self::system_with_context(content, None::<String>)
|
||
}
|
||
|
||
pub fn system_with_context(
|
||
content: impl Into<String>,
|
||
system_context: impl Into<Option<String>>,
|
||
) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "system".to_string(),
|
||
content: content.into(),
|
||
media_refs: Vec::new(),
|
||
timestamp: current_timestamp(),
|
||
system_context: system_context.into(),
|
||
reasoning_content: None,
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_duration_ms: None,
|
||
tool_state: None,
|
||
tool_calls: None,
|
||
}
|
||
}
|
||
|
||
pub fn tool(
|
||
tool_call_id: impl Into<String>,
|
||
tool_name: impl Into<String>,
|
||
content: impl Into<String>,
|
||
) -> Self {
|
||
Self::tool_with_state(
|
||
tool_call_id,
|
||
tool_name,
|
||
content,
|
||
ToolMessageState::Completed,
|
||
)
|
||
}
|
||
|
||
pub fn tool_with_state(
|
||
tool_call_id: impl Into<String>,
|
||
tool_name: impl Into<String>,
|
||
content: impl Into<String>,
|
||
tool_state: ToolMessageState,
|
||
) -> Self {
|
||
Self {
|
||
id: uuid::Uuid::new_v4().to_string(),
|
||
role: "tool".to_string(),
|
||
content: content.into(),
|
||
media_refs: Vec::new(),
|
||
timestamp: current_timestamp(),
|
||
system_context: None,
|
||
reasoning_content: None,
|
||
tool_call_id: Some(tool_call_id.into()),
|
||
tool_name: Some(tool_name.into()),
|
||
tool_duration_ms: None,
|
||
tool_state: Some(tool_state),
|
||
tool_calls: None,
|
||
}
|
||
}
|
||
|
||
pub fn with_tool_duration(mut self, ms: u64) -> Self {
|
||
self.tool_duration_ms = Some(ms);
|
||
self
|
||
}
|
||
|
||
pub fn has_system_context(&self, expected: &str) -> bool {
|
||
self.system_context.as_deref() == Some(expected)
|
||
}
|
||
|
||
pub fn is_assistant_tool_call_message(&self) -> bool {
|
||
self.role == "assistant"
|
||
&& self
|
||
.tool_calls
|
||
.as_ref()
|
||
.map(|calls| !calls.is_empty())
|
||
.unwrap_or(false)
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Message sanitization
|
||
// ============================================================================
|
||
|
||
/// Sanitize message history by removing assistant messages with `tool_calls`
|
||
/// that don't have corresponding tool result messages, at ANY position in
|
||
/// the history (not just trailing).
|
||
///
|
||
/// Incomplete sequences can appear in the middle of history when:
|
||
/// 1. The process was interrupted mid-execution, then a new user message
|
||
/// was appended, burying the orphan.
|
||
/// 2. History compaction preserves orphaned `tool_calls` from a pre-fix era
|
||
/// or from a race condition between persistence and snapshot.
|
||
///
|
||
/// Sending such incomplete sequences to the API causes errors like
|
||
/// "insufficient tool messages following tool_calls message".
|
||
///
|
||
/// Returns the number of messages removed.
|
||
pub(crate) fn sanitize_incomplete_tool_call_sequences(messages: &mut Vec<ChatMessage>) -> usize {
|
||
let mut removed = 0;
|
||
|
||
// Phase 1: Single reverse pass to find ALL assistant messages with
|
||
// incomplete tool_calls, regardless of position.
|
||
//
|
||
// Scanning right-to-left means we encounter tool results before their
|
||
// parent assistants, so we naturally know which tool_call_ids have
|
||
// corresponding results.
|
||
let mut resolved_ids: HashSet<String> = HashSet::new();
|
||
let mut with_parent: HashSet<String> = HashSet::new();
|
||
let mut remove_indices: Vec<usize> = Vec::new();
|
||
|
||
// Reverse pass — collect tool result IDs first, then validate each
|
||
// assistant's tool_calls against already-seen results.
|
||
//
|
||
// Because we scan right-to-left, any tool result we've already seen
|
||
// appears AFTER the current message in forward order. This correctly
|
||
// identifies which assistant tool_calls have corresponding results.
|
||
for i in (0..messages.len()).rev() {
|
||
let msg = &messages[i];
|
||
|
||
if msg.role == "tool" {
|
||
if let Some(ref tc_id) = msg.tool_call_id {
|
||
resolved_ids.insert(tc_id.clone());
|
||
}
|
||
}
|
||
|
||
if msg.role == "assistant"
|
||
&& msg.tool_calls.as_ref().map_or(false, |calls| !calls.is_empty())
|
||
{
|
||
let tool_calls = msg.tool_calls.as_ref().unwrap();
|
||
let all_have_results = tool_calls
|
||
.iter()
|
||
.all(|tc| resolved_ids.contains(&tc.id));
|
||
|
||
if all_have_results {
|
||
for tc in tool_calls.iter() {
|
||
with_parent.insert(tc.id.clone());
|
||
}
|
||
} else {
|
||
let missing_count = tool_calls
|
||
.iter()
|
||
.filter(|tc| !resolved_ids.contains(&tc.id))
|
||
.count();
|
||
|
||
tracing::warn!(
|
||
tool_call_count = tool_calls.len(),
|
||
missing_tool_results = missing_count,
|
||
message_id = %msg.id,
|
||
message_index = i,
|
||
"Removing assistant message with incomplete tool call sequence — \
|
||
tool results were never persisted (likely due to process interruption \
|
||
or history compaction preserving an orphan)"
|
||
);
|
||
|
||
remove_indices.push(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Remove in descending index order to avoid shifting
|
||
for &idx in &remove_indices {
|
||
messages.remove(idx);
|
||
removed += 1;
|
||
}
|
||
|
||
// Phase 2: Forward pass to remove ALL orphaned tool messages (not just
|
||
// trailing ones). A tool message is orphaned if its tool_call_id has no
|
||
// matching parent assistant remaining in the history.
|
||
//
|
||
// Always execute this pass unconditionally — even when Phase 1 found no
|
||
// assistant messages with tool_calls (e.g., after heavy compaction that
|
||
// summarized all tool_call sequences into text), there may still be
|
||
// orphaned tool result messages in the history that must be cleaned up.
|
||
{
|
||
let mut i = 0;
|
||
while i < messages.len() {
|
||
let msg = &messages[i];
|
||
if msg.role == "tool" {
|
||
let is_orphaned = match &msg.tool_call_id {
|
||
Some(tc_id) => !with_parent.contains(tc_id),
|
||
None => true,
|
||
};
|
||
if is_orphaned {
|
||
tracing::warn!(
|
||
tool_call_id = ?msg.tool_call_id,
|
||
message_id = %msg.id,
|
||
message_index = i,
|
||
"Removing orphaned tool result message — its parent assistant \
|
||
tool_calls message was removed or never persisted"
|
||
);
|
||
messages.remove(i);
|
||
removed += 1;
|
||
continue;
|
||
}
|
||
}
|
||
i += 1;
|
||
}
|
||
}
|
||
|
||
removed
|
||
}
|
||
|
||
// ============================================================================
|
||
// InboundMessage - Message from Channel to Bus (user input)
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct InboundMessage {
|
||
pub channel: String,
|
||
pub sender_id: String,
|
||
pub chat_id: String,
|
||
pub content: String,
|
||
pub timestamp: i64,
|
||
pub media: Vec<MediaItem>,
|
||
/// Channel-specific data used internally by the channel (not forwarded).
|
||
pub metadata: HashMap<String, String>,
|
||
/// Data forwarded from inbound to outbound (copied to OutboundMessage.metadata by gateway).
|
||
pub forwarded_metadata: HashMap<String, String>,
|
||
}
|
||
|
||
impl InboundMessage {
|
||
pub fn session_key(&self) -> String {
|
||
format!("{}:{}", self.channel, self.chat_id)
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// OutboundMessage - Message from Agent to Channel (bot response)
|
||
// ============================================================================
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub struct OutboundMessage {
|
||
pub channel: String,
|
||
/// 消息发送目标 ID(如飞书 open_id、微信 chat_id)
|
||
/// 注意:这始终是原始入站消息的 chat_id,不会被修改为会话 ID
|
||
pub chat_id: String,
|
||
/// 内部会话 ID(对应 sessions.id)
|
||
/// 用于会话管理和消息持久化,与消息发送目标无关
|
||
pub session_id: Option<String>,
|
||
pub content: String,
|
||
pub reply_to: Option<String>,
|
||
pub media: Vec<MediaItem>,
|
||
pub metadata: HashMap<String, String>,
|
||
pub event_kind: OutboundEventKind,
|
||
pub role: String,
|
||
pub tool_call_id: Option<String>,
|
||
pub tool_name: Option<String>,
|
||
pub tool_arguments: Option<serde_json::Value>,
|
||
pub reasoning_content: Option<String>,
|
||
/// Carry the originating ChatMessage.id so the WS layer can use it
|
||
/// instead of generating a random UUID. Critical for stream delta → assistant_response
|
||
/// ID matching on the front-end.
|
||
pub message_id: Option<String>,
|
||
}
|
||
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum OutboundEventKind {
|
||
AssistantResponse,
|
||
ToolCall,
|
||
ToolResult,
|
||
ToolPending,
|
||
SchedulerNotification,
|
||
ErrorNotification,
|
||
TaskStarted,
|
||
/// 流式文本增量
|
||
StreamDelta,
|
||
/// 流式结束信号
|
||
StreamEnd,
|
||
/// 智能体执行完全结束(不再有后续工具调用或 LLM 迭代)
|
||
ExecutionCompleted,
|
||
}
|
||
|
||
impl OutboundMessage {
|
||
pub fn is_stream_delta(&self) -> bool {
|
||
matches!(self.event_kind, OutboundEventKind::StreamDelta | OutboundEventKind::StreamEnd)
|
||
}
|
||
|
||
pub fn assistant(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
content: impl Into<String>,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content: content.into(),
|
||
reply_to,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::AssistantResponse,
|
||
role: "assistant".to_string(),
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_arguments: None,
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
pub fn scheduler_notification(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
content: impl Into<String>,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
||
message.event_kind = OutboundEventKind::SchedulerNotification;
|
||
message
|
||
}
|
||
|
||
pub fn error_notification(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
content: impl Into<String>,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
let mut message = Self::assistant(channel, chat_id, session_id, content, reply_to, metadata);
|
||
message.event_kind = OutboundEventKind::ErrorNotification;
|
||
message
|
||
}
|
||
|
||
pub fn tool_call(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
message_id: impl Into<String>,
|
||
tool_name: impl Into<String>,
|
||
tool_arguments: serde_json::Value,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
let tool_name = tool_name.into();
|
||
let content = format_tool_call_content(&tool_name, &tool_arguments);
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content,
|
||
reply_to,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::ToolCall,
|
||
role: "assistant".to_string(),
|
||
tool_call_id: Some(message_id.into()),
|
||
tool_name: Some(tool_name),
|
||
tool_arguments: Some(tool_arguments),
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
pub fn tool_result(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
tool_call_id: impl Into<String>,
|
||
tool_name: impl Into<String>,
|
||
content: impl Into<String>,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
let tool_name = tool_name.into();
|
||
let raw_content = content.into();
|
||
let content = format_tool_result_content(&tool_name, &raw_content);
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content,
|
||
reply_to,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::ToolResult,
|
||
role: "tool".to_string(),
|
||
tool_call_id: Some(tool_call_id.into()),
|
||
tool_name: Some(tool_name),
|
||
tool_arguments: None,
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
pub fn tool_pending(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
tool_call_id: impl Into<String>,
|
||
tool_name: impl Into<String>,
|
||
content: impl Into<String>,
|
||
reply_to: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
let tool_name = tool_name.into();
|
||
let raw_content = content.into();
|
||
let content = format_tool_result_content(&tool_name, &raw_content);
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content,
|
||
reply_to,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::ToolPending,
|
||
role: "tool".to_string(),
|
||
tool_call_id: Some(tool_call_id.into()),
|
||
tool_name: Some(tool_name),
|
||
tool_arguments: None,
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
/// 构造流式文本增量消息
|
||
pub fn stream_delta(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
message_id: impl Into<String>,
|
||
delta: impl Into<String>,
|
||
reasoning_delta: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content: delta.into(),
|
||
reply_to: None,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::StreamDelta,
|
||
role: "assistant".to_string(),
|
||
tool_call_id: Some(message_id.into()),
|
||
tool_name: None,
|
||
tool_arguments: None,
|
||
reasoning_content: reasoning_delta,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
/// 构造流式结束信号
|
||
pub fn stream_end(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
message_id: impl Into<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content: String::new(),
|
||
reply_to: None,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::StreamEnd,
|
||
role: "assistant".to_string(),
|
||
tool_call_id: Some(message_id.into()),
|
||
tool_name: None,
|
||
tool_arguments: None,
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
/// 构造执行完成信号
|
||
pub fn execution_completed(
|
||
channel: impl Into<String>,
|
||
chat_id: impl Into<String>,
|
||
session_id: Option<String>,
|
||
metadata: HashMap<String, String>,
|
||
) -> Self {
|
||
Self {
|
||
channel: channel.into(),
|
||
chat_id: chat_id.into(),
|
||
session_id,
|
||
content: String::new(),
|
||
reply_to: None,
|
||
media: Vec::new(),
|
||
metadata,
|
||
event_kind: OutboundEventKind::ExecutionCompleted,
|
||
role: "assistant".to_string(),
|
||
tool_call_id: None,
|
||
tool_name: None,
|
||
tool_arguments: None,
|
||
reasoning_content: None,
|
||
message_id: None,
|
||
}
|
||
}
|
||
|
||
pub fn from_chat_message(
|
||
channel: &str,
|
||
chat_id: &str,
|
||
session_id: Option<String>,
|
||
reply_to: Option<String>,
|
||
metadata: &HashMap<String, String>,
|
||
message: &ChatMessage,
|
||
) -> Vec<Self> {
|
||
match message.role.as_str() {
|
||
"assistant" => {
|
||
if let Some(tool_calls) = &message.tool_calls {
|
||
let mut outbound = Vec::new();
|
||
let has_content_or_reasoning = !message.content.trim().is_empty() || message.reasoning_content.is_some();
|
||
if has_content_or_reasoning {
|
||
let mut resp = Self::assistant(
|
||
channel.to_string(),
|
||
chat_id.to_string(),
|
||
session_id.clone(),
|
||
message.content.clone(),
|
||
reply_to.clone(),
|
||
metadata.clone(),
|
||
);
|
||
resp.reasoning_content = message.reasoning_content.clone();
|
||
resp.message_id = Some(message.id.clone());
|
||
outbound.push(resp);
|
||
}
|
||
|
||
// AssistantResponse 已携带 reasoning 时,ToolCall 不再重复;
|
||
// 只有 AssistantResponse 没发时,ToolCall 才带 reasoning
|
||
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
|
||
outbound.extend(tool_calls.iter().map(|tool_call| {
|
||
let mut tc = Self::tool_call(
|
||
channel.to_string(),
|
||
chat_id.to_string(),
|
||
session_id.clone(),
|
||
tool_call.id.clone(),
|
||
tool_call.name.clone(),
|
||
tool_call.arguments.clone(),
|
||
reply_to.clone(),
|
||
metadata.clone(),
|
||
);
|
||
tc.reasoning_content = tc_reasoning.clone();
|
||
tc
|
||
}));
|
||
outbound
|
||
} else {
|
||
let mut resp = Self::assistant(
|
||
channel.to_string(),
|
||
chat_id.to_string(),
|
||
session_id,
|
||
message.content.clone(),
|
||
reply_to,
|
||
metadata.clone(),
|
||
);
|
||
resp.reasoning_content = message.reasoning_content.clone();
|
||
resp.message_id = Some(message.id.clone());
|
||
vec![resp]
|
||
}
|
||
}
|
||
"tool" => match message
|
||
.tool_state
|
||
.as_ref()
|
||
.unwrap_or(&ToolMessageState::Completed)
|
||
{
|
||
ToolMessageState::Completed => vec![Self::tool_result(
|
||
channel.to_string(),
|
||
chat_id.to_string(),
|
||
session_id,
|
||
message.tool_call_id.clone().unwrap_or_default(),
|
||
message.tool_name.clone().unwrap_or_default(),
|
||
message.content.clone(),
|
||
reply_to,
|
||
metadata.clone(),
|
||
)],
|
||
ToolMessageState::PendingUserAction => vec![Self::tool_pending(
|
||
channel.to_string(),
|
||
chat_id.to_string(),
|
||
session_id,
|
||
message.tool_call_id.clone().unwrap_or_default(),
|
||
message.tool_name.clone().unwrap_or_default(),
|
||
message.content.clone(),
|
||
reply_to,
|
||
metadata.clone(),
|
||
)],
|
||
},
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) fn format_tool_call_content(
|
||
tool_name: &str,
|
||
tool_arguments: &serde_json::Value,
|
||
) -> String {
|
||
match tool_arguments {
|
||
serde_json::Value::Object(map) if map.is_empty() => tool_name.to_string(),
|
||
other => format!("{}\nargs: {}", tool_name, format_tool_arguments_json(other)),
|
||
}
|
||
}
|
||
|
||
fn format_tool_result_content(tool_name: &str, content: &str) -> String {
|
||
format!("工具结果: {}\n\n{}", tool_name, content)
|
||
}
|
||
|
||
fn format_tool_argument_value(value: &serde_json::Value) -> String {
|
||
match value {
|
||
serde_json::Value::String(text) => text.clone(),
|
||
serde_json::Value::Null => "null".to_string(),
|
||
other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
|
||
}
|
||
}
|
||
|
||
fn format_tool_arguments_json(value: &serde_json::Value) -> String {
|
||
match value {
|
||
serde_json::Value::Object(map) => {
|
||
let mut entries: Vec<_> = map.iter().collect();
|
||
entries.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||
let body = entries
|
||
.into_iter()
|
||
.map(|(key, value)| {
|
||
format!(
|
||
"{}:{}",
|
||
serde_json::to_string(key).unwrap_or_else(|_| format!("\"{}\"", key)),
|
||
serde_json::to_string(value).unwrap_or_else(|_| value.to_string()),
|
||
)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(",");
|
||
format!("{{{}}}", body)
|
||
}
|
||
other => format_tool_argument_value(other),
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// Helpers
|
||
// ============================================================================
|
||
|
||
fn current_timestamp() -> i64 {
|
||
std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_millis() as i64
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{ChatMessage, OutboundEventKind, OutboundMessage, ToolMessageState};
|
||
use crate::domain::messages::ToolCall;
|
||
use serde_json::json;
|
||
use std::collections::HashMap;
|
||
|
||
const TEST_CHANNEL: &str = "test-channel";
|
||
|
||
#[test]
|
||
fn test_from_chat_message_expands_tool_calls() {
|
||
let message = ChatMessage::assistant_with_tool_calls(
|
||
"",
|
||
vec![
|
||
ToolCall {
|
||
id: "call-1".to_string(),
|
||
name: "calculator".to_string(),
|
||
arguments: json!({"expression": "1 + 1"}),
|
||
},
|
||
ToolCall {
|
||
id: "call-2".to_string(),
|
||
name: "read".to_string(),
|
||
arguments: json!({"path": "README.md"}),
|
||
},
|
||
],
|
||
);
|
||
|
||
let outbound = OutboundMessage::from_chat_message(
|
||
TEST_CHANNEL,
|
||
"chat-1",
|
||
None,
|
||
None,
|
||
&HashMap::new(),
|
||
&message,
|
||
);
|
||
|
||
assert_eq!(outbound.len(), 2);
|
||
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolCall);
|
||
assert_eq!(outbound[0].tool_name.as_deref(), Some("calculator"));
|
||
assert_eq!(
|
||
outbound[0].tool_arguments.as_ref().unwrap()["expression"],
|
||
"1 + 1"
|
||
);
|
||
assert_eq!(
|
||
outbound[0].content,
|
||
"calculator\nargs: {\"expression\":\"1 + 1\"}"
|
||
);
|
||
assert_eq!(outbound[1].tool_name.as_deref(), Some("read"));
|
||
assert_eq!(
|
||
outbound[1].content,
|
||
"read\nargs: {\"path\":\"README.md\"}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_chat_message_keeps_assistant_content_when_tool_calls_exist() {
|
||
let message = ChatMessage::assistant_with_tool_calls(
|
||
"日报已整理完成。",
|
||
vec![ToolCall {
|
||
id: "call-1".to_string(),
|
||
name: "memory_manage".to_string(),
|
||
arguments: json!({"action": "put"}),
|
||
}],
|
||
);
|
||
|
||
let outbound = OutboundMessage::from_chat_message(
|
||
TEST_CHANNEL,
|
||
"chat-1",
|
||
None,
|
||
None,
|
||
&HashMap::new(),
|
||
&message,
|
||
);
|
||
|
||
assert_eq!(outbound.len(), 2);
|
||
assert_eq!(outbound[0].event_kind, OutboundEventKind::AssistantResponse);
|
||
assert_eq!(outbound[0].content, "日报已整理完成。");
|
||
assert_eq!(outbound[1].event_kind, OutboundEventKind::ToolCall);
|
||
assert_eq!(outbound[1].tool_name.as_deref(), Some("memory_manage"));
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_chat_message_includes_tool_result() {
|
||
let message = ChatMessage::tool("call-9", "calculator", "2");
|
||
|
||
let outbound = OutboundMessage::from_chat_message(
|
||
TEST_CHANNEL,
|
||
"chat-1",
|
||
None,
|
||
None,
|
||
&HashMap::new(),
|
||
&message,
|
||
);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolResult);
|
||
}
|
||
|
||
#[test]
|
||
fn test_from_chat_message_includes_tool_pending() {
|
||
let message = ChatMessage::tool_with_state(
|
||
"call-9",
|
||
"bash",
|
||
"等待你完成浏览器授权后再继续。",
|
||
ToolMessageState::PendingUserAction,
|
||
);
|
||
|
||
let outbound = OutboundMessage::from_chat_message(
|
||
TEST_CHANNEL,
|
||
"chat-1",
|
||
None,
|
||
None,
|
||
&HashMap::new(),
|
||
&message,
|
||
);
|
||
|
||
assert_eq!(outbound.len(), 1);
|
||
assert_eq!(outbound[0].event_kind, OutboundEventKind::ToolPending);
|
||
}
|
||
}
|