PicoBot/src/gateway/execution.rs
oudecheng cda14360af chore: 建立工程化基线(rustfmt + clippy + CI + eslint + prettier)
配置:
- rustfmt.toml: 固化 max_width=100 / 4 空格缩进,cargo fmt 全量格式化
- Cargo.toml: 配置 [lints.rust] 与 [lints.clippy] 渐进式规则
- .github/workflows/ci.yml: Rust(fmt+clippy+test) + 前端(eslint+tsc+test) 双平台 CI
- Makefile: 新增 check/fmt/fix 目标,clippy 对齐 --all-targets --all-features
- web: eslint flat config + prettier 配置 + package.json 脚本与依赖
- src/main.rs: loop→while 修复 clippy::never_loop

对抗性审查发现并修复:
- eslint 缺 caughtErrorsIgnorePattern 导致 catch(_) 误报为 error
- 前端 lint 未接入 CI,现已补上 Lint 步骤
- Makefile 与 CI 的 clippy flags 不一致,已对齐
2026-08-03 23:24:02 +08:00

611 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::HashMap;
use std::sync::Arc;
use crate::agent::{
AgentError, AgentProcessResult, EmittedMessageHandler, PersistingEmittedMessageHandler,
SystemPromptContext,
};
use crate::bus::message::ToolMessageState;
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
use crate::config::LLMProviderConfig;
use crate::storage::{ConversationRepository, persistent_session_id};
use async_trait::async_trait;
use tokio::sync::Mutex;
use super::compaction::schedule_background_history_compaction;
use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::Session;
/// 空的 EmittedMessageHandler不转发消息仅配合 PersistingEmittedMessageHandler 做持久化。
struct NoOpEmittedMessageHandler;
#[async_trait]
impl EmittedMessageHandler for NoOpEmittedMessageHandler {
async fn handle(&self, _message: ChatMessage) {}
}
const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器否则不要调用任何定时任务管理工具像“每小时”、“每天”、“cron”、“定时”等词只应视为任务背景不应再解释为新的建任务请求。";
pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
match system_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(system_prompt) => format!(
"{}\n\n任务专属要求:{}",
SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT, system_prompt
),
None => SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT.to_string(),
}
}
pub(crate) struct AgentExecutionService {
show_tool_results: bool,
}
pub(crate) struct FinalizeAgentResultRequest<'a> {
pub(crate) channel_name: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) user_message: &'a ChatMessage,
pub(crate) result: AgentProcessResult,
pub(crate) metadata: &'a HashMap<String, String>,
pub(crate) suppress_live_tool_calls: bool,
pub(crate) execution_kind: &'a str,
pub(crate) original_topic_id: Option<String>,
}
pub(crate) struct FinalizedAgentResult {
pub(crate) outbound_messages: Vec<OutboundMessage>,
pub(crate) should_schedule_compaction: bool,
}
pub(crate) struct MessageExecutionRequest<'a> {
pub(crate) session: Arc<Mutex<Session>>,
pub(crate) channel_name: &'a str,
pub(crate) sender_id: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) content: &'a str,
pub(crate) media: Vec<MediaItem>,
pub(crate) live_emitter: Option<Arc<dyn EmittedMessageHandler>>,
/// 消息接收时捕获的 topic_id全程显式传递避免从共享状态重复读取竞态
pub(crate) topic_id: Option<String>,
}
pub(crate) struct ScheduledExecutionRequest<'a> {
pub(crate) session: Arc<Mutex<Session>>,
pub(crate) channel_name: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) notification_chat_id: Option<&'a str>,
pub(crate) prompt: &'a str,
pub(crate) sender_id: &'a str,
pub(crate) provider_config: LLMProviderConfig,
pub(crate) system_prompt: Option<&'a str>,
pub(crate) metadata: &'a HashMap<String, String>,
pub(crate) fresh_session: bool,
}
impl AgentExecutionService {
pub(crate) fn new(show_tool_results: bool) -> Self {
Self { show_tool_results }
}
pub(crate) fn finalize_result(
&self,
session: &mut Session,
request: FinalizeAgentResultRequest<'_>,
) -> Result<FinalizedAgentResult, AgentError> {
// 判断是否是最新的用户回合
// 直接比较 current_topic(chat_id) 与 original_topic_id
// 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case
let is_current_turn = match request.original_topic_id.as_deref() {
Some(orig_tid) => session.current_topic(request.chat_id).as_deref() == Some(orig_tid),
None => true, // 无 topic 时总是视为当前回合
};
if !is_current_turn {
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
.stale_result_diagnostics(
request
.original_topic_id
.as_deref()
.unwrap_or(request.chat_id),
);
tracing::info!(
channel = %request.channel_name,
chat_id = %request.chat_id,
user_message_id = %request.user_message.id,
latest_user_id,
latest_user_preview,
compression_in_flight,
history_len,
execution_kind = %request.execution_kind,
original_topic_id = ?request.original_topic_id,
"User switched topic during agent execution - saving result to original topic"
);
}
// 确定保存消息的话题 ID
// 始终使用执行开始时捕获的 original_topic_id避免从共享状态重复读取竞态
let target_topic_id = request.original_topic_id.as_deref();
// 将结果消息保存到确定的话题
if let Some(topic_id) = target_topic_id {
if is_current_turn {
// 话题未切换current_topic == original_topic_id安全更新内存历史
if let Err(err) = session
.append_persisted_messages(topic_id, request.result.emitted_messages.clone())
{
tracing::error!(
error = %err,
topic_id = %topic_id,
"Failed to append messages to session history"
);
}
} else {
// 话题已切换,只写 DB 不更新内存(避免污染新话题的历史)
if let Err(err) = session.append_messages_to_topic(
request.chat_id,
topic_id,
&request.result.emitted_messages,
) {
tracing::error!(
error = %err,
topic_id = %topic_id,
"Failed to append messages to topic"
);
}
}
} else if is_current_turn {
// 没有话题直接更新内存历史append_persisted_messages 会处理持久化)
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
if let Err(err) = session
.append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
{
tracing::error!(
error = %err,
chat_id = %request.chat_id,
"Failed to append messages to session history"
);
}
}
// 只有当是最新回合时才发送 outbound 消息给用户
// 如果用户已经切换到其他话题,只保存结果,不发送消息(避免打扰)
let outbound_messages = if is_current_turn {
request
.result
.emitted_messages
.iter()
.filter(|message| {
// 当存在 live_emitter 时,所有消息已在 loop 中实时广播,不需要 post-loop 发送
!request.suppress_live_tool_calls
&& should_display_message_to_user(self.show_tool_results, message)
})
.flat_map(|message| {
OutboundMessage::from_chat_message(
request.channel_name,
request.chat_id,
None, // session_id
None,
request.metadata,
message,
)
})
.collect()
} else {
Vec::new()
};
// 只有当是最新回合时才触发历史压缩
let should_schedule_compaction = is_current_turn;
Ok(FinalizedAgentResult {
outbound_messages,
should_schedule_compaction,
})
}
pub(crate) async fn prepare_and_execute_message(
&self,
request: MessageExecutionRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
// 获取该 topic 的串行锁(通过短暂获取 session 锁)
// 同一 topic 的消息处理必须串行执行,防止并发 loop 操作同一历史的不同快照
// 不同 topic 之间互不阻塞,支持多话题并发执行
let serial_lock = {
let mut session_guard = request.session.lock().await;
let lock_key = request.topic_id.as_deref().unwrap_or(request.chat_id);
session_guard.topic_serial_lock(lock_key)
};
// 等待该 topic 的前一条消息处理完成(含压缩)
// await 串行锁时不持有 session 锁,其他 topic 的消息可以正常处理
let _serial_guard = serial_lock.lock().await;
let (history, agent, user_message, user_message_count, original_topic_id) = {
let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?;
// 优先使用消息接收时捕获的 topic_id消除 #1 与 #2 之间的竞态
let original_topic_id = match &request.topic_id {
Some(tid) => Some(tid.clone()),
None => session_guard
.current_topic(request.chat_id)
.map(|s| s.to_string()),
};
session_guard.ensure_chat_loaded(request.chat_id, original_topic_id.as_deref())?;
session_guard.ensure_agent_prompt_before_user_message(request.chat_id)?;
let media_refs: Vec<String> = request
.media
.iter()
.map(|media| media.path.clone())
.collect();
#[cfg(debug_assertions)]
if !media_refs.is_empty() {
tracing::debug!(media_count = %request.media.len(), media_refs = ?media_refs, "Adding user message with media");
}
let enriched_content =
enrich_user_content_with_media_refs(request.content, &media_refs)?;
// 先计算 user_message_count在添加新消息之前
// 无 topic 时用 chat_id 作为 topic_histories 的回退 key
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
let history_before = session_guard.get_or_create_history(history_key).clone();
let user_message_count = history_before.iter().filter(|m| m.role == "user").count();
let user_message = session_guard.create_user_message(&enriched_content, media_refs);
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
user_message.clone(),
)?;
// 再获取包含新消息的完整历史记录
let history = session_guard.get_or_create_history(history_key).clone();
session_guard.record_skill_offer(request.chat_id)?;
let mut agent = session_guard.create_agent(
request.chat_id,
Some(request.sender_id),
Some(&user_message.id),
original_topic_id.as_deref(),
)?;
if let Some(handler) = request.live_emitter.clone() {
agent = agent.with_emitted_message_handler(handler);
}
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
)
};
// 构建系统提示词上下文
let system_prompt_context = SystemPromptContext {
session_id: Some(persistent_session_id(request.channel_name, request.chat_id)),
chat_id: request.chat_id.to_string(),
user_message_count,
};
let result = agent.process(history, Some(&system_prompt_context)).await?;
let mut metadata = HashMap::new();
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配
metadata.insert("user_message_id".to_string(), user_message.id.clone());
self.finalize_result_and_schedule_compaction(
request.session.clone(),
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: &metadata,
suppress_live_tool_calls: request.live_emitter.is_some(),
execution_kind: "message",
original_topic_id,
},
)
.await
}
pub(crate) async fn prepare_and_execute_scheduled_task(
&self,
request: ScheduledExecutionRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
// 获取该 topic 的串行锁(与普通消息路径共享,保证串行执行)
// 定时任务由调度器触发,无用户消息竞态;在锁前一次性捕获 topic_id
// 锁后复用同一值作为 original_topic_id保证锁键与写入目标一致。
let (serial_lock, lock_time_topic_id) = {
let mut session_guard = request.session.lock().await;
let tid = session_guard
.current_topic(request.chat_id)
.map(|s| s.to_string());
let lock_key = tid.as_deref().unwrap_or(request.chat_id);
(session_guard.topic_serial_lock(lock_key), tid)
};
// 等待该 topic 的前一条消息处理完成(含压缩)
let _serial_guard = serial_lock.lock().await;
let (
history,
mut agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
) = {
let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?;
// 复用锁前捕获的 topic_id保证锁键与写入目标一致
let original_topic_id = lock_time_topic_id.clone();
// 如果 fresh_session 为 true清理历史内存 + 数据库)
if request.fresh_session {
session_guard.clear_chat_history(request.chat_id, original_topic_id.as_deref())?;
tracing::info!(
chat_id = %request.chat_id,
"Fresh session enabled, history cleared"
);
}
session_guard.ensure_chat_loaded(request.chat_id, original_topic_id.as_deref())?;
session_guard.ensure_agent_prompt_before_user_message(request.chat_id)?;
let scheduled_system_prompt =
compose_scheduled_task_system_prompt(request.system_prompt);
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
ChatMessage::system_with_context(
&scheduled_system_prompt,
Some(SYSTEM_CONTEXT_SCHEDULED_PROMPT.to_string()),
),
)?;
// 先计算 user_message_count在添加新消息之前
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
let history_before = session_guard.get_or_create_history(history_key).clone();
let user_message_count = history_before.iter().filter(|m| m.role == "user").count();
let user_message = session_guard.create_user_message(request.prompt, Vec::new());
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
user_message.clone(),
)?;
// 再获取包含新消息的完整历史记录
let history = session_guard.get_or_create_history(history_key).clone();
session_guard.record_skill_offer(request.chat_id)?;
let agent = session_guard.create_agent_with_provider_config(
request.chat_id,
request.notification_chat_id, // 传入真实 chat_id
Some(request.sender_id),
Some(&user_message.id),
request.provider_config.clone(),
original_topic_id.as_deref(),
)?;
// 获取 store 和 session_id用于构造消息持久化 handler
let store = session_guard.store();
let session_id =
crate::storage::persistent_session_id(request.channel_name, request.chat_id);
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
)
};
// 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息
{
let persisting_handler = PersistingEmittedMessageHandler::new(
NoOpEmittedMessageHandler,
store as Arc<dyn ConversationRepository>,
&session_id,
None,
);
agent = agent.with_emitted_message_handler(Arc::new(persisting_handler));
}
// 构建系统提示词上下文
let system_prompt_context = SystemPromptContext {
session_id: Some(persistent_session_id(request.channel_name, request.chat_id)),
chat_id: request.chat_id.to_string(),
user_message_count,
};
let result = agent.process(history, Some(&system_prompt_context)).await?;
let outbound_messages = self
.finalize_result_and_schedule_compaction(
request.session.clone(),
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: request.metadata,
suppress_live_tool_calls: false,
execution_kind: "scheduled_task",
original_topic_id: original_topic_id.clone(),
},
)
.await?;
// 清理内存历史,释放内存(数据库历史保留)
{
let mut session_guard = request.session.lock().await;
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
session_guard.remove_history(history_key);
tracing::info!(
chat_id = %request.chat_id,
"Scheduled task completed, memory history released"
);
}
Ok(outbound_messages)
}
pub(crate) async fn finalize_result_and_schedule_compaction(
&self,
session: Arc<Mutex<Session>>,
request: FinalizeAgentResultRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
let channel_name = request.channel_name.to_string();
let chat_id = request.chat_id.to_string();
let execution_kind = request.execution_kind.to_string();
let topic_id = request.original_topic_id.clone();
let finalized_result = {
let mut session_guard = session.lock().await;
self.finalize_result(&mut session_guard, request)?
};
if finalized_result.should_schedule_compaction {
let compaction_topic_id = topic_id.unwrap_or_else(|| chat_id.clone());
if let Err(error) = schedule_background_history_compaction(
session.clone(),
chat_id.clone(),
compaction_topic_id,
)
.await
{
tracing::warn!(
channel = %channel_name,
chat_id = %chat_id,
execution_kind = %execution_kind,
error = %error,
"Failed to schedule background history compaction"
);
}
}
Ok(finalized_result.outbound_messages)
}
}
pub(crate) fn should_display_message_to_user(
show_tool_results: bool,
message: &ChatMessage,
) -> bool {
if message.role != "tool" {
return true;
}
show_tool_results
|| matches!(
message
.tool_state
.as_ref()
.unwrap_or(&ToolMessageState::Completed),
ToolMessageState::PendingUserAction
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::ChatMessage;
#[test]
fn test_compose_scheduled_task_system_prompt_appends_task_specific_prompt() {
let prompt = compose_scheduled_task_system_prompt(Some(" 只汇报异常 "));
assert!(prompt.contains("当前输入来自一次已经触发的定时任务执行"));
assert!(prompt.contains("任务专属要求:只汇报异常"));
}
#[test]
fn test_compose_scheduled_task_system_prompt_ignores_blank_override() {
let prompt = compose_scheduled_task_system_prompt(Some(" "));
assert!(prompt.contains("当前输入来自一次已经触发的定时任务执行"));
assert!(!prompt.contains("任务专属要求"));
}
#[test]
fn test_should_display_message_to_user_keeps_pending_tool_action_visible() {
let message = ChatMessage::tool_with_state(
"call-1",
"approval",
"需要用户确认",
ToolMessageState::PendingUserAction,
);
assert!(should_display_message_to_user(false, &message));
}
#[test]
fn test_should_display_message_to_user_hides_completed_tool_when_disabled() {
let message = ChatMessage::tool("call-1", "calculator", "2");
assert!(!should_display_message_to_user(false, &message));
assert!(should_display_message_to_user(true, &message));
}
/// 对抗性测试:同一 topic 的串行锁被持有时,第二次获取应阻塞
#[tokio::test]
async fn test_topic_serial_lock_blocks_concurrent_access() {
let lock = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let _guard1 = lock.lock().await;
// 第二次获取应阻塞1ms 超时验证
let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
assert!(result.is_err(), "第二次获取同一锁应阻塞");
}
/// 对抗性测试:不同 topic 的串行锁互不影响,可同时获取
#[tokio::test]
async fn test_different_topic_locks_independent() {
let lock_a = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let lock_b = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let _guard_a = lock_a.lock().await;
// 不同锁应立即可获取
let result =
tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
}
/// 对抗性测试错误返回路径锁被正确释放RAII 保证)
#[tokio::test]
async fn test_serial_lock_released_on_error() {
let lock = std::sync::Arc::new(tokio::sync::Mutex::new(()));
// 模拟 prepare_and_execute_message 的错误路径:
// 获取锁 → 返回错误 → 锁应通过 RAII 释放
{
let _serial_guard = lock.lock().await;
// 模拟错误返回(`?` 或 `Err` 分支)
let _result: Result<(), AgentError> = Err(AgentError::Other("simulated".to_string()));
// _serial_guard 在此块结束时 Drop释放锁
}
// 锁应已释放,可再次获取
let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
assert!(result.is_ok(), "错误返回后锁应已释放");
}
}